espace-paie-odentas/components/staff/contracts/ContractEditor.tsx
odentas 965b1fb9cd feat: Ajouter interface de saisie en masse du temps de travail réel
- Création de la page /staff/contrats/saisie-temps-reel avec tableau éditable
- Ajout des colonnes jours_representations et jours_repetitions dans l'API
- Construction intelligente du TT Contractuel (concaténation des sources)
- Ajout de la colonne temps_reel_traite pour marquer les contrats traités
- Interface avec filtres (année, mois, organisation, recherche)
- Tri par date/salarié
- Édition inline avec auto-save via API
- Checkbox pour marquer comme traité (masque automatiquement la ligne)
- Toggle pour afficher/masquer les contrats traités
- Migration SQL pour la colonne temps_reel_traite
- Ajout du menu 'Temps de travail réel' dans la sidebar
- Logs de débogage pour le suivi des sauvegardes
2025-11-28 12:31:02 +01:00

3340 lines
No EOL
149 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// components/staff/contracts/ContractEditor.tsx
"use client";
import { useMemo, useState, useEffect, useRef, useCallback } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Separator } from "@/components/ui/separator";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
import { toast } from "sonner";
import { CalendarRange, FilePlus2, FileText, Save, Search, FileDown, PenTool, RefreshCw, Mail, Clock, CheckCircle2, XCircle, Users, Send, Check, Upload, Ban, Euro, StickyNote } from "lucide-react";
import PayslipForm from "./PayslipForm";
import { api } from "@/lib/fetcher";
import { PROFESSIONS_ARTISTE } from "@/components/constants/ProfessionsArtiste";
import { LoadingModal } from "@/components/ui/loading-modal";
import { PayslipModal } from "./PayslipModal";
import { EmployerReminderModal } from "./EmployerReminderModal";
import { PayslipCard } from "./PayslipCard";
import { NotesSection } from "@/components/NotesSection";
import ESignConfirmModal from "./ESignConfirmModal";
import DatePickerCalendar from "@/components/DatePickerCalendar";
import DatesQuantityModal from "@/components/DatesQuantityModal";
import { parseDateString } from "@/lib/dateFormatter";
import { supabase } from "@/lib/supabaseClient";
import { ManualSignedContractUpload } from "./ManualSignedContractUpload";
import CancelContractModal from "./CancelContractModal";
type AnyObj = Record<string, any>;
// Types from NouveauCDDUForm
type SalarieOption = {
matricule: string;
nom: string;
email?: string | null;
code_salarie?: string | null;
prenom?: string | null;
civilite?: string | null;
};
type SpectacleOption = {
id?: string;
nom: string;
numero_objet?: string | null;
prod_type?: string | null;
director?: string | null;
};
type ProfessionOption = { code: string; label: string };
type TechnicienOption = ProfessionOption;
type ClientInfo = { id: string; name: string; api_name?: string } | null;
// Helper functions from NouveauCDDUForm
function norm(s: string) {
return s
.toLowerCase()
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
.replace(/[^a-z0-9\s]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
// Helper function to parse monetary amounts with French decimal notation (comma)
function parseMonetaryAmount(value: string | number): number | null {
if (!value) return null;
const str = String(value).trim();
if (!str) return null;
// Replace comma with dot for proper parseFloat
const normalized = str.replace(',', '.');
const parsed = parseFloat(normalized);
return isNaN(parsed) ? null : parsed;
}
function useDebouncedValue<T>(value: T, delay = 300) {
const [debounced, setDebounced] = useState<T>(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
// Search hooks from NouveauCDDUForm
function useSearchSalaries(q: string) {
const { data: clientInfo } = useQuery({
queryKey: ["client-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 { id: me.active_org_id || null, name: me.active_org_name || "Organisation", api_name: me.active_org_api_name } as ClientInfo;
} catch { return null; }
},
staleTime: 30_000,
});
const params = new URLSearchParams();
params.set("page", "1");
params.set("limit", "10");
if (q.trim()) params.set("q", q.trim());
return useQuery({
queryKey: ["search-salaries", q, clientInfo?.id],
queryFn: async () => {
const result = await api<{ items: SalarieOption[] }>(`/salaries?${params.toString()}`, {}, clientInfo);
return {
items: result.items ?? [],
};
},
enabled: q.trim().length > 1 && !!clientInfo,
staleTime: 10_000,
});
}
function useSearchSpectacles(q: string) {
const { data: clientInfo } = useQuery({
queryKey: ["client-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 { id: me.active_org_id || null, name: me.active_org_name || "Organisation", api_name: me.active_org_api_name } as ClientInfo;
} catch { return null; }
},
staleTime: 30_000,
});
const params = new URLSearchParams();
params.set("page", "1");
params.set("limit", "10");
if (q.trim()) params.set("q", q.trim());
return useQuery({
queryKey: ["search-spectacles", q, clientInfo?.id],
queryFn: async () => {
const result = await api<{ items: SpectacleOption[] }>(`/spectacles?${params.toString()}`, {}, clientInfo);
return {
items: result.items ?? [],
};
},
enabled: q.trim().length > 1 && !!clientInfo,
staleTime: 10_000,
});
}
function slugify(input: string) {
return (input || "")
.toLowerCase()
.normalize("NFD")
.replace(/\p{Diacritic}/gu, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
}
// Hook pour récupérer les informations d'organisation avec la CCN
function useOrganizationDetails() {
const { data: clientInfo } = useQuery({
queryKey: ["client-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 { id: me.active_org_id || null, name: me.active_org_name || "Organisation", api_name: me.active_org_api_name } as ClientInfo;
} catch { return null; }
},
staleTime: 30_000,
});
return useQuery({
queryKey: ["organization-details", clientInfo?.id],
queryFn: async () => {
if (!clientInfo?.id) return null;
try {
const res = await fetch(`/api/organizations/${clientInfo.id}/details`, {
cache: "no-store",
headers: { Accept: "application/json" },
credentials: "include"
});
if (!res.ok) return null;
const orgData = await res.json();
return orgData;
} catch (error) {
console.error("Error fetching organization details:", error);
return null;
}
},
enabled: !!clientInfo?.id,
staleTime: 60_000, // Cache plus long car ces données changent rarement
});
}
export default function ContractEditor({
contract,
payslips,
organizationDetails: serverOrganizationDetails,
}: {
contract: AnyObj;
payslips: AnyObj[];
organizationDetails?: any;
}) {
const queryClient = useQueryClient();
// Récupération des informations d'organisation avec la CCN
const { data: clientOrganizationDetails } = useOrganizationDetails();
// Utiliser les données serveur en priorité, sinon les données client
const organizationDetails = serverOrganizationDetails || clientOrganizationDetails;
// Préremplissages pour aligner l'affichage avec le formulaire de création CDDU
const prefill = useMemo(() => {
const spectacle = contract.production_name || contract.nom_du_spectacle || "";
const numero_objet = contract.objet_spectacle || contract.numero_objet || "";
// Priorité: salaries.salarie > salaries.nom+prenom > employee_name fallback
const salarie_nom = contract.salaries?.salarie
|| (contract.salaries?.nom
? `${contract.salaries.nom.toUpperCase()}${contract.salaries.prenom ? ' ' + contract.salaries.prenom.charAt(0).toUpperCase() + contract.salaries.prenom.slice(1) : ''}`
: contract.employee_name || "");
const salarie_matricule = contract.employee_matricule || contract.matricule || contract.salaries?.code_salarie || "";
const salarie_email = contract.salaries?.adresse_mail || null;
const salarie_prenom = contract.salaries?.prenom || null;
const salarie_code = contract.salaries?.code_salarie || null;
const categorie = contract.categorie_pro || contract.categorie_professionnelle || "";
const profession = contract.profession || "";
const date_debut = (contract.start_date || contract.debut_contrat || "").slice?.(0, 10) || "";
const date_fin = (contract.end_date || contract.fin_contrat || "").slice?.(0, 10) || "";
const jours_travail = contract.jours_travail || "";
const heures_total = contract.nombre_d_heures || contract.heures_travail || "";
const heures_annexe_8 = contract.heures_annexe_8 || "";
const type_salaire = contract.type_salaire || (contract.gross_pay ? "Brut" : contract.net_pay ? "Net avant PAS" : "");
const montant = contract.salaire || contract.gross_pay || contract.net_pay || "";
const panier_repas = contract.paniers_repas || contract.panier_repas || "";
const reference = contract.reference || contract.contract_number || "";
const analytique = contract.analytique || "";
const multi_mois = String(contract.multi_mois || "");
return {
spectacle,
numero_objet,
salarie_nom,
salarie_matricule,
salarie_email,
salarie_prenom,
salarie_code,
categorie,
profession,
date_debut,
date_fin,
jours_travail,
heures_total,
heures_annexe_8,
type_salaire,
montant,
panier_repas,
reference,
analytique,
multi_mois,
};
}, [contract]);
// CDDU form state
const [spectacleQuery, setSpectacleQuery] = useState("");
const [spectaclePick, setSpectaclePick] = useState<SpectacleOption | null>(
prefill.spectacle ? { nom: prefill.spectacle, numero_objet: prefill.numero_objet } : null
);
const [salarieQuery, setSalarieQuery] = useState("");
const [salarie, setSalarie] = useState<SalarieOption | null>(
prefill.salarie_nom && prefill.salarie_matricule ?
{
nom: prefill.salarie_nom,
matricule: prefill.salarie_matricule,
email: prefill.salarie_email,
prenom: prefill.salarie_prenom,
code_salarie: prefill.salarie_code
} : null
);
const [categoriePro, setCategoriePro] = useState<"Artiste" | "Technicien">(
(prefill.categorie as "Artiste" | "Technicien") || "Artiste"
);
const [professionQuery, setProfessionQuery] = useState("");
const [professionPick, setProfessionPick] = useState<ProfessionOption | null>(() => {
if (!prefill.profession) return null;
// Chercher le code dans les professions artistes
const artisteProfession = PROFESSIONS_ARTISTE.find(p =>
p.label.toLowerCase() === prefill.profession.toLowerCase()
);
if (artisteProfession) {
return artisteProfession;
}
// Si pas trouvé, créer avec un code vide (sera résolu plus tard pour les techniciens)
return { label: prefill.profession, code: "" };
});
const [professionFeminine, setProfessionFeminine] = useState("");
const [isSavingFeminisation, setIsSavingFeminisation] = useState(false);
const [techniciens, setTechniciens] = useState<TechnicienOption[] | null>(null);
const techLoadedRef = useRef(false);
// États pour les champs conditionnels (heures vs répétitions/représentations)
const [nbRepresentations, setNbRepresentations] = useState<number | "">(contract.cachets_representations || "");
const [nbServicesRepetition, setNbServicesRepetition] = useState<number | "">(contract.services_repetitions || "");
const [durationServices, setDurationServices] = useState<"3" | "4">("4"); // Durée des services (3 ou 4 heures)
const [datesRepresentations, setDatesRepresentations] = useState(contract.jours_representations || "");
const [datesRepresentationsDisplay, setDatesRepresentationsDisplay] = useState("");
const [datesRepresentationsOpen, setDatesRepresentationsOpen] = useState(false);
const [datesRepetitions, setDatesRepetitions] = useState(contract.jours_repetitions || "");
const [datesRepetitionsDisplay, setDatesRepetitionsDisplay] = useState("");
const [datesRepetitionsOpen, setDatesRepetitionsOpen] = useState(false);
const [heuresTotal, setHeuresTotal] = useState<number | "">(contract.nombre_d_heures || "");
const [minutesTotal, setMinutesTotal] = useState<"0" | "30">((contract.minutes_total || "0") as "0" | "30");
const [joursTravail, setJoursTravail] = useState(contract.jours_travail || "");
const [joursTravailDisplay, setJoursTravailDisplay] = useState("");
const [joursTravailOpen, setJoursTravailOpen] = useState(false);
// États pour la modale de précision des quantités
const [quantityModalOpen, setQuantityModalOpen] = useState(false);
const [quantityModalType, setQuantityModalType] = useState<"representations" | "repetitions" | "jours_travail" | "">("");
const [pendingDates, setPendingDates] = useState<string[]>([]);
const [nombreHeuresTotal, setNombreHeuresTotal] = useState<number | "">(contract.nombre_d_heures || "");
const [nombreHeuresParJour, setNombreHeuresParJour] = useState<number | "">(contract.nombre_d_heures_par_jour || "");
// États pour le temps de travail réel (informatif pour le client)
const [joursTravailReel, setJoursTravailReel] = useState(contract.jours_travail_reel || "");
const [joursTravailNonArtisteReel, setJoursTravailNonArtisteReel] = useState(contract.jours_travail_non_artiste_reel || "");
const [nbRepresentationsReel, setNbRepresentationsReel] = useState<number | "">(contract.nb_representations_reel || "");
const [datesRepresentationsReel, setDatesRepresentationsReel] = useState(contract.dates_representations_reel || "");
const [nbServicesRepetitionsReel, setNbServicesRepetitionsReel] = useState<number | "">(contract.nb_services_repetitions_reel || "");
const [nbHeuresRepetitionsReel, setNbHeuresRepetitionsReel] = useState<number | "">(contract.nb_heures_repetitions_reel || "");
const [datesRepetitionsReel, setDatesRepetitionsReel] = useState(contract.dates_repetitions_reel || "");
const [nbHeuresAnnexesReel, setNbHeuresAnnexesReel] = useState<number | "">(contract.nb_heures_annexes_reel || "");
const [nbCachetsAemReel, setNbCachetsAemReel] = useState<number | "">(contract.nb_cachets_aem_reel || "");
const [nbHeuresAemReel, setNbHeuresAemReel] = useState<number | "">(contract.nb_heures_aem_reel || "");
// Synchroniser les états avec les données du contrat
useEffect(() => {
// Calculer le mode (heures vs représentations/répétitions)
const isHeuresMode = (
categoriePro === "Technicien" ||
(categoriePro === "Artiste" && professionPick?.code === "MET040")
);
console.log("🔄 Synchronisation des champs avec les données du contrat:", {
cachets_representations: contract.cachets_representations,
services_repetitions: contract.services_repetitions,
jours_representations: contract.jours_representations,
jours_repetitions: contract.jours_repetitions,
nombre_d_heures: contract.nombre_d_heures,
minutes_total: contract.minutes_total,
jours_travail: contract.jours_travail,
jours_travail_non_artiste: contract.jours_travail_non_artiste,
isHeuresMode,
categoriePro,
professionCode: professionPick?.code
});
// Utiliser les vrais noms de colonnes de la base de données
setNbRepresentations(contract.cachets_representations || "");
setNbServicesRepetition(contract.services_repetitions || "");
// Initialiser les affichages avec formatage smart si présentes
// Déterminer si c'est du texte formaté PDFMonkey ou des dates brutes
if (contract.jours_representations) {
// Détection améliorée du texte formaté
const hasFormatIndicators =
contract.jours_representations.includes(" ; ") ||
contract.jours_representations.includes(" le ") ||
contract.jours_representations.includes(" du ") ||
contract.jours_representations.includes(" au ") ||
contract.jours_representations.includes(" par jour ") ||
contract.jours_representations.startsWith("du ") ||
contract.jours_representations.startsWith("le ") ||
/^\d+ /.test(contract.jours_representations) ||
contract.jours_representations.endsWith(".");
if (hasFormatIndicators) {
// C'est déjà du texte formaté, utiliser tel quel
setDatesRepresentations(contract.jours_representations);
setDatesRepresentationsDisplay(contract.jours_representations);
} else {
// C'est du texte brut (dates), parser et reformater
const yearContext = contract.start_date || new Date().toISOString().slice(0, 10);
const parsed = parseDateString(contract.jours_representations, yearContext);
setDatesRepresentations(parsed.pdfFormatted);
setDatesRepresentationsDisplay(parsed.pdfFormatted);
}
} else {
setDatesRepresentations("");
setDatesRepresentationsDisplay("");
}
if (contract.jours_repetitions) {
const hasFormatIndicators =
contract.jours_repetitions.includes(" ; ") ||
contract.jours_repetitions.includes(" le ") ||
contract.jours_repetitions.includes(" du ") ||
contract.jours_repetitions.includes(" au ") ||
contract.jours_repetitions.includes(" par jour ") ||
contract.jours_repetitions.startsWith("du ") ||
contract.jours_repetitions.startsWith("le ") ||
/^\d+ /.test(contract.jours_repetitions) ||
contract.jours_repetitions.endsWith(".");
if (hasFormatIndicators) {
setDatesRepetitions(contract.jours_repetitions);
setDatesRepetitionsDisplay(contract.jours_repetitions);
// Détecter la durée des services dans le texte
if (contract.jours_repetitions.includes("de 3 heures")) {
setDurationServices("3");
} else if (contract.jours_repetitions.includes("de 4 heures")) {
setDurationServices("4");
}
} else {
const yearContext = contract.start_date || new Date().toISOString().slice(0, 10);
const parsed = parseDateString(contract.jours_repetitions, yearContext);
setDatesRepetitions(parsed.pdfFormatted);
setDatesRepetitionsDisplay(parsed.pdfFormatted);
}
} else {
setDatesRepetitions("");
setDatesRepetitionsDisplay("");
}
setHeuresTotal(contract.nombre_d_heures || "");
setMinutesTotal((contract.minutes_total || "0") as "0" | "30");
// Initialiser joursTravail avec formatage smart
// En mode heures (technicien/metteur en scène), charger depuis jours_travail_non_artiste
// En mode artiste classique, charger depuis jours_travail
const joursTravailSource = isHeuresMode ? contract.jours_travail_non_artiste : contract.jours_travail;
console.log("📍 Source pour joursTravail:", {
isHeuresMode,
source: isHeuresMode ? "jours_travail_non_artiste" : "jours_travail",
valeur: joursTravailSource,
contract_jours_travail: contract.jours_travail,
contract_jours_travail_non_artiste: contract.jours_travail_non_artiste
});
if (joursTravailSource) {
// Détection améliorée du texte formaté PDFMonkey
// - Contient " ; " (séparateur de dates multiples)
// - Contient des mots indicateurs : " le ", " du ", " au ", " par jour "
// - Commence par des mots clés : "du ", "le ", nombre + espace
// - Finit par un point (convention PDFMonkey)
const hasFormatIndicators =
joursTravailSource.includes(" ; ") ||
joursTravailSource.includes(" le ") ||
joursTravailSource.includes(" du ") ||
joursTravailSource.includes(" au ") ||
joursTravailSource.includes(" par jour ") ||
joursTravailSource.startsWith("du ") ||
joursTravailSource.startsWith("le ") ||
/^\d+ /.test(joursTravailSource) || // commence par un nombre + espace (ex: "5 heures le...")
joursTravailSource.endsWith(".");
console.log("🔍 Traitement de joursTravailSource:", {
joursTravailSource,
isFormatted: hasFormatIndicators,
action: hasFormatIndicators ? "utilisation directe" : "parsing"
});
if (hasFormatIndicators) {
setJoursTravail(joursTravailSource);
setJoursTravailDisplay(joursTravailSource);
console.log("✅ Valeurs définies (formaté):", {
joursTravail: joursTravailSource,
joursTravailDisplay: joursTravailSource
});
} else {
const yearContext = contract.date_debut || new Date().toISOString().slice(0, 10);
const parsed = parseDateString(joursTravailSource, yearContext);
setJoursTravail(parsed.pdfFormatted);
setJoursTravailDisplay(parsed.pdfFormatted);
console.log("✅ Valeurs définies (parsé):", {
joursTravail: parsed.pdfFormatted,
joursTravailDisplay: parsed.pdfFormatted
});
}
} else {
setJoursTravail("");
setJoursTravailDisplay("");
}
// Pré-remplir le nombre d'heures total
if (contract.nombre_d_heures) {
setNombreHeuresTotal(contract.nombre_d_heures);
} else if (!isHeuresMode && contract.services_repetitions && contract.jours_repetitions) {
// Si pas d'heures stockées, calculer à partir des services × durée
const nbServices = parseInt(contract.services_repetitions) || 0;
let duration = 4; // Durée par défaut
if (contract.jours_repetitions.includes("de 3 heures")) {
duration = 3;
}
setNombreHeuresTotal(nbServices * duration);
} else {
setNombreHeuresTotal("");
}
setNombreHeuresParJour(contract.nombre_d_heures_par_jour || "");
// Pré-remplir la date de signature selon les règles :
// - Date du jour si la date de début est le jour même ou dans le futur
// - Égale à la date de début si celle-ci est dans le passé
if (contract.date_signature) {
setDateSignature(contract.date_signature.slice(0, 10));
} else if (contract.date_debut) {
const today = new Date().toISOString().split("T")[0];
const dateDebut = contract.date_debut.split("T")[0];
// Si date_debut est dans le passé (avant aujourd'hui), utiliser date_debut
// Sinon, utiliser aujourd'hui
setDateSignature(dateDebut < today ? dateDebut : today);
} else {
setDateSignature("");
}
setPrecisionsSalaire(contract.precisions_salaire || "");
setAutrePrecisionDuree(contract.autreprecision_duree || "");
setAutrePrecisionSalaire(contract.autreprecision_salaire || "");
// Synchroniser les champs de temps de travail réel
setJoursTravailReel(contract.jours_travail_reel || "");
setJoursTravailNonArtisteReel(contract.jours_travail_non_artiste_reel || "");
setNbRepresentationsReel(contract.nb_representations_reel || "");
setDatesRepresentationsReel(contract.dates_representations_reel || "");
setNbServicesRepetitionsReel(contract.nb_services_repetitions_reel || "");
setNbHeuresRepetitionsReel(contract.nb_heures_repetitions_reel || "");
setDatesRepetitionsReel(contract.dates_repetitions_reel || "");
setNbHeuresAnnexesReel(contract.nb_heures_annexes_reel || "");
setNbCachetsAemReel(contract.nb_cachets_aem_reel || "");
setNbHeuresAemReel(contract.nb_heures_aem_reel || "");
}, [contract, categoriePro, professionPick?.code]);
// États pour les autres champs CDDU actuellement grisés
const [typeSalaire, setTypeSalaire] = useState(prefill.type_salaire || "");
const [panierRepas, setPanierRepas] = useState(prefill.panier_repas || "");
const [reference, setReference] = useState(prefill.reference || "");
const [analytique, setAnalytique] = useState(prefill.analytique || "");
const [dateSignature, setDateSignature] = useState(contract.date_signature?.slice(0, 10) || "");
const [montant, setMontant] = useState(String(prefill.montant || ""));
const [precisionsSalaire, setPrecisionsSalaire] = useState(contract.precisions_salaire || "");
const [autrePrecisionDuree, setAutrePrecisionDuree] = useState(contract.autreprecision_duree || "");
const [autrePrecisionSalaire, setAutrePrecisionSalaire] = useState(contract.autreprecision_salaire || "");
// Search states
const [spectacleActive, setSpectacleActive] = useState(0);
const [salarieActive, setSalarieActive] = useState(0);
const [professionActive, setProfessionActive] = useState(0);
// Loading states
const [isGeneratingPdf, setIsGeneratingPdf] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isLaunchingSignature, setIsLaunchingSignature] = useState(false);
const [showPdfSuccess, setShowPdfSuccess] = useState(false);
const [showSaveSuccess, setShowSaveSuccess] = useState(false);
// Payslip modal states
const [isPayslipModalOpen, setIsPayslipModalOpen] = useState(false);
const [editingPayslip, setEditingPayslip] = useState<any>(null);
// E-Sign confirmation modal states
const [showESignConfirmModal, setShowESignConfirmModal] = useState(false);
const [sendESignNotification, setSendESignNotification] = useState(true);
const [verifiedEmployeeEmail, setVerifiedEmployeeEmail] = useState<string>("");
const [verifiedEmployerEmail, setVerifiedEmployerEmail] = useState<string>("");
// Manual upload modal state
const [isUploadModalOpen, setIsUploadModalOpen] = useState(false);
// Cancel contract modal states
const [showCancelModal, setShowCancelModal] = useState(false);
const [isCancelling, setIsCancelling] = useState(false);
// Handler pour annuler le contrat
const handleCancelContract = async () => {
setIsCancelling(true);
try {
const response = await fetch(`/api/staff/contracts/${contract.id}/cancel`, {
method: "POST",
headers: { "Content-Type": "application/json" },
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Erreur lors de l'annulation");
}
toast.success("Contrat annulé avec succès");
setShowCancelModal(false);
// Recharger la page pour afficher les mises à jour
setTimeout(() => {
window.location.reload();
}, 1000);
} catch (error: any) {
console.error("Erreur annulation contrat:", error);
toast.error(`Erreur: ${error.message}`);
} finally {
setIsCancelling(false);
}
};
// Handler pour le calendrier des dates de représentations
// Ouvrir le modal de quantités pour permettre la précision par date
const handleDatesRepresentationsApply = (result: {
selectedDates: string[];
hasMultiMonth: boolean;
pdfFormatted: string;
}) => {
setPendingDates(result.selectedDates);
setQuantityModalType("representations");
setQuantityModalOpen(true);
};
// Handler pour le calendrier des dates de répétitions
// Ouvrir le modal de quantités pour permettre la précision par date
const handleDatesRepetitionsApply = (result: {
selectedDates: string[];
hasMultiMonth: boolean;
pdfFormatted: string;
}) => {
setPendingDates(result.selectedDates);
setQuantityModalType("repetitions");
setQuantityModalOpen(true);
};
// Handler pour le calendrier des jours travaillés
// Ouvrir le modal de quantités pour permettre la précision par date
const handleJoursTravailApply = (result: {
selectedDates: string[];
hasMultiMonth: boolean;
pdfFormatted: string;
}) => {
setPendingDates(result.selectedDates);
setQuantityModalType("jours_travail");
setQuantityModalOpen(true);
};
// Handler pour la modale de quantités (applique les données finales)
const handleQuantityApply = (result: {
selectedDates: string[];
hasMultiMonth: boolean;
pdfFormatted: string;
}) => {
// Calculer le nombre de jours/dates sélectionnées
const nbDates = result.selectedDates.length;
switch (quantityModalType) {
case "representations":
setDatesRepresentations(result.pdfFormatted);
setDatesRepresentationsDisplay(result.pdfFormatted);
// Auto-remplir le nombre de représentations
setNbRepresentations(nbDates);
break;
case "repetitions":
// Ajouter la durée des services au texte formaté
let formattedText = result.pdfFormatted;
if (durationServices) {
// Remplacer "service de répétition" par "service de répétition de X heures"
formattedText = formattedText
.replace(/service de répétition/g, `service de répétition de ${durationServices} heures`)
.replace(/services de répétition/g, `services de répétition de ${durationServices} heures chacun`);
}
setDatesRepetitions(formattedText);
setDatesRepetitionsDisplay(formattedText);
// Auto-remplir le nombre de services de répétition
setNbServicesRepetition(nbDates);
break;
case "jours_travail":
console.log("📅 Jours travail - result:", result);
setJoursTravail(result.pdfFormatted);
setJoursTravailDisplay(result.pdfFormatted);
console.log("📅 Jours travail mis à jour:", result.pdfFormatted);
break;
}
setQuantityModalOpen(false);
setPendingDates([]);
};
// Fonction pour ouvrir le PDF avec URL pré-signée
const openPdf = async () => {
try {
const response = await fetch(`/api/contrats/${contract.id}/pdf-url`);
if (!response.ok) {
throw new Error("Erreur lors de la récupération de l'URL du PDF");
}
const data = await response.json();
window.open(data.signedUrl, '_blank');
} catch (error) {
console.error("Erreur:", error);
toast.error("Impossible d'ouvrir le PDF");
}
};
const openSignedPdf = async () => {
if (signedPdfData?.signedUrl) {
window.open(signedPdfData.signedUrl, '_blank');
} else {
toast.error("URL du contrat signé non disponible");
}
};
// Fonction pour déterminer l'état de la signature électronique
const getSignatureStatus = () => {
const etatDemande = form.etat_de_la_demande || contract.etat_de_la_demande;
const contratSigneEmployeur = form.contrat_signe_par_employeur || contract.contrat_signe_par_employeur;
const contratSigne = form.contrat_signe || contract.contrat_signe;
if (etatDemande !== "Traitée") {
return {
status: "not_sent",
label: "E-signature non envoyée",
icon: XCircle,
color: "text-gray-500",
bgColor: "bg-gray-50",
borderColor: "border-gray-200"
};
}
if (contratSigneEmployeur === "Non") {
return {
status: "waiting_employer",
label: "En attente employeur",
icon: Clock,
color: "text-orange-600",
bgColor: "bg-orange-50",
borderColor: "border-orange-200"
};
}
if (contratSigneEmployeur === "Oui" && contratSigne === "Non") {
return {
status: "waiting_employee",
label: "En attente salarié",
icon: Users,
color: "text-blue-600",
bgColor: "bg-blue-50",
borderColor: "border-blue-200"
};
}
if (contratSigne === "Oui") {
return {
status: "completed",
label: "E-signature finalisée",
icon: CheckCircle2,
color: "text-green-600",
bgColor: "bg-green-50",
borderColor: "border-green-200"
};
}
// État par défaut
return {
status: "unknown",
label: "Statut inconnu",
icon: XCircle,
color: "text-gray-500",
bgColor: "bg-gray-50",
borderColor: "border-gray-200"
};
};
const [form, setForm] = useState<AnyObj>({
role: contract.role ?? "",
start_date: contract.start_date?.slice(0, 10) ?? "",
end_date: contract.end_date?.slice(0, 10) ?? "",
hours: contract.hours ?? "",
gross_pay: contract.gross_pay ?? "",
net_pay: contract.net_pay ?? "",
contract_pdf_s3_key: contract.contract_pdf_s3_key ?? "",
notes: contract.notes ?? "",
etat_de_la_demande: contract.etat_de_la_demande ?? "",
contrat_signe: contract.contrat_signe ?? "",
contrat_signe_par_employeur: contract.contrat_signe_par_employeur ?? "",
dpae: contract.dpae ?? "",
etat_de_la_paie: contract.etat_de_la_paie ?? "",
aem: contract.aem ?? "",
});
const [rows, setRows] = useState<AnyObj[]>(
(payslips ?? []).map((p) => ({ ...p }))
);
// Déterminer le type de contrat pour l'affichage des paies
const contractType = useMemo(() => {
const typeContrat = contract.type_de_contrat || "";
const isMultiMois = contract.multi_mois === true || contract.multi_mois === "true" || contract.multi_mois === "Oui";
// RG = "CDD de droit commun" ou "CDI"
if (typeContrat === "CDD de droit commun" || typeContrat === "CDI") {
return "RG";
}
// CDDU multi-mois
if (typeContrat === "CDD d'usage" && isMultiMois) {
return "CDDU_MULTI";
}
// CDDU mono-mois (par défaut)
return "CDDU_MONO";
}, [contract.type_de_contrat, contract.multi_mois]);
// État pour la pagination des paies (pour RG et CDDU multi)
const [payslipPage, setPayslipPage] = useState(0);
const payslipsPerPage = 6;
// Trier et paginer les paies si nécessaire
const displayedPayslips = useMemo(() => {
const needsCompactView = contractType === "RG" || contractType === "CDDU_MULTI";
// Trier par numéro de paie décroissant (plus récent en premier)
const sorted = [...rows].sort((a, b) => {
const numA = a.pay_number || 0;
const numB = b.pay_number || 0;
return numB - numA; // Décroissant
});
if (needsCompactView && sorted.length > payslipsPerPage) {
const start = payslipPage * payslipsPerPage;
const end = start + payslipsPerPage;
return sorted.slice(start, end);
}
return sorted;
}, [rows, contractType, payslipPage]);
const totalPayslipPages = Math.ceil(rows.length / payslipsPerPage);
const needsPagination = (contractType === "RG" || contractType === "CDDU_MULTI") && rows.length > payslipsPerPage;
const [open, setOpen] = useState(false);
// Search hooks
const debouncedSpectacleQuery = useDebouncedValue(spectacleQuery, 300);
const debouncedSalarieQuery = useDebouncedValue(salarieQuery, 300);
const { data: spectacleSearch, isFetching: fetchingSpectacles } = useSearchSpectacles(debouncedSpectacleQuery);
const { data: salarieSearch, isFetching: fetchingSalaries } = useSearchSalaries(debouncedSalarieQuery);
// Hook pour récupérer l'URL pré-signée du contrat signé
const { data: signedPdfData, isLoading: loadingSignedPdf } = useQuery({
queryKey: ["signed-contract-pdf", contract.id],
queryFn: async () => {
const response = await fetch(`/api/staff/contrats/${contract.id}/signed-pdf`);
if (!response.ok) {
if (response.status === 404) {
const errorData = await response.json();
return { hasSignedPdf: false, error: errorData.error };
}
throw new Error('Erreur lors de la récupération du contrat signé');
}
return await response.json();
},
enabled: !!contract.id,
staleTime: 5 * 60 * 1000, // 5 minutes
retry: 1,
});
// Profession filtering
const filteredProfessions = useMemo(() => {
const q = professionQuery.trim().toLowerCase();
if (!q) return PROFESSIONS_ARTISTE;
return PROFESSIONS_ARTISTE.filter(p =>
p.code.toLowerCase().includes(q) || p.label.toLowerCase().includes(q)
).slice(0, 20);
}, [professionQuery]);
const filteredTechniciens = useMemo(() => {
if (!techniciens) return [] as TechnicienOption[];
const q = norm(professionQuery);
if (!q) return techniciens.slice(0, 30);
return techniciens
.filter(p => norm(p.code).includes(q) || norm(p.label).includes(q))
.slice(0, 30);
}, [techniciens, professionQuery]);
// Logique conditionnelle : heures pour techniciens, répétitions/représentations pour artistes
const useHeuresMode = useMemo(() => {
return (
categoriePro === "Technicien" ||
(categoriePro === "Artiste" && professionPick?.code === "MET040")
);
}, [categoriePro, professionPick]);
// Auto-calculer le nombre d'heures total basé sur le nombre de services x durée
useEffect(() => {
if (!useHeuresMode && datesRepetitions) {
// Extraire le nombre total de services depuis le texte formaté
// Exemple: "1 service de répétition de 4 heures le 29/10 ; 2 services de répétition de 4 heures chacun le 30/10"
// On doit sommer : 1 + 2 = 3
let totalServices = 0;
// Chercher tous les patterns "N service(s)"
const servicePattern = /(\d+)\s+service/g;
let match;
while ((match = servicePattern.exec(datesRepetitions)) !== null) {
totalServices += parseInt(match[1], 10);
}
if (totalServices > 0) {
const duration = parseInt(durationServices);
const totalHours = totalServices * duration;
setNombreHeuresTotal(totalHours);
}
}
}, [useHeuresMode, datesRepetitions, durationServices]);
// Load technicians list
async function ensureTechniciensLoaded() {
if (techLoadedRef.current || techniciens) return;
try {
const res = await fetch('/data/professions-techniciens.json', { cache: 'force-cache' });
if (!res.ok) throw new Error('HTTP ' + res.status);
const list: TechnicienOption[] = await res.json();
setTechniciens(list);
techLoadedRef.current = true;
} catch (e) {
console.warn("Could not load technicians:", e);
}
}
// Functions for profession feminization
const loadFeminisation = async (professionCode: string) => {
try {
const res = await fetch(`/api/professions-feminisations?code=${encodeURIComponent(professionCode)}`);
if (!res.ok) return null;
const data = await res.json();
return data ? data.profession_feminine : null;
} catch (error) {
console.warn("Erreur lors du chargement de la féminisation:", error);
return null;
}
};
const saveFeminisation = async (professionCode: string, professionLabel: string, professionFeminine: string) => {
try {
const res = await fetch('/api/professions-feminisations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
profession_code: professionCode,
profession_label: professionLabel,
profession_feminine: professionFeminine.trim()
})
});
if (!res.ok) {
const errorData = await res.json().catch(() => ({}));
throw new Error(errorData.error || `HTTP ${res.status}`);
}
const result = await res.json();
console.log("✅ Féminisation sauvegardée avec succès:", result);
return true;
} catch (error) {
console.error("❌ Erreur lors de la sauvegarde de la féminisation:", error);
throw error;
}
};
// Load technicians when category changes to Technicien
useEffect(() => {
if (categoriePro === "Technicien") {
ensureTechniciensLoaded();
}
}, [categoriePro]);
// Resolve profession code if missing (for technicians)
useEffect(() => {
if (professionPick && !professionPick.code && categoriePro === "Technicien" && techniciens) {
const technicienProfession = techniciens.find(p =>
p.label.toLowerCase() === professionPick.label.toLowerCase()
);
if (technicienProfession) {
console.log("🔧 Resolving technicien profession code:", technicienProfession);
setProfessionPick(technicienProfession);
}
}
}, [professionPick, categoriePro, techniciens]);
// Load feminization when profession changes
useEffect(() => {
if (professionPick?.code) {
loadFeminisation(professionPick.code).then(feminine => {
if (feminine) {
setProfessionFeminine(feminine);
} else {
setProfessionFeminine("");
}
});
} else {
setProfessionFeminine("");
}
}, [professionPick]);
// Save feminization when profession feminine is changed
const handleFeminineChange = (value: string) => {
console.log("📝 handleFeminineChange called with:", value);
setProfessionFeminine(value);
};
// Manual save feminization function
const saveCurrentFeminisation = async () => {
console.log("🔧 saveCurrentFeminisation called");
console.log("📊 Current state:", {
professionPick,
professionFeminine: `"${professionFeminine}"`,
professionFeminineTrimmed: `"${professionFeminine.trim()}"`,
hasCode: !!professionPick?.code,
hasLabel: !!professionPick?.label,
hasTrimmedFeminine: !!professionFeminine.trim()
});
// Ensure technicians are loaded if needed
if (categoriePro === "Technicien") {
await ensureTechniciensLoaded();
}
// Fallback: try to resolve code if missing
let codeToUse = professionPick?.code;
if (!codeToUse && professionPick?.label) {
console.log("🔍 Trying to resolve missing code for:", professionPick.label);
// Check in artiste professions
const artisteProfession = PROFESSIONS_ARTISTE.find(p =>
p.label.toLowerCase() === professionPick.label.toLowerCase()
);
if (artisteProfession) {
codeToUse = artisteProfession.code;
console.log("✅ Found artiste code:", codeToUse);
}
// Check in technicien professions if loaded
if (!codeToUse && techniciens) {
const technicienProfession = techniciens.find(p =>
p.label.toLowerCase() === professionPick.label.toLowerCase()
);
if (technicienProfession) {
codeToUse = technicienProfession.code;
console.log("✅ Found technicien code:", codeToUse);
}
}
}
if (!codeToUse || !professionPick?.label || !professionFeminine.trim()) {
console.log("❌ Missing required data - detailed check:");
console.log(" - resolved code:", codeToUse);
console.log(" - professionPick?.label:", professionPick?.label);
console.log(" - professionFeminine.trim():", `"${professionFeminine.trim()}"`);
toast.error("Veuillez sélectionner une profession et saisir sa forme féminine");
return;
}
console.log("✅ Starting save process with code:", codeToUse);
setIsSavingFeminisation(true);
try {
await saveFeminisation(codeToUse, professionPick.label, professionFeminine);
toast.success(`Féminisation sauvegardée : "${professionFeminine}"`);
console.log("✅ Féminisation sauvegardée avec succès");
} catch (error) {
console.error("❌ Erreur:", error);
toast.error("Erreur lors de la sauvegarde de la féminisation");
} finally {
setIsSavingFeminisation(false);
}
};
const nextPayNumber = useMemo(
() => (rows?.length ? Math.max(...rows.map((r) => Number(r.pay_number) || 0)) + 1 : 1),
[rows]
);
const handleContractChange = (key: string, value: any) => {
setForm((f: AnyObj) => ({ ...f, [key]: value }));
};
const saveContract = async () => {
setIsSaving(true);
try {
const payload: AnyObj = {
...form,
start_date: form.start_date || null,
end_date: form.end_date || null,
// CDDU fields - searchable/selectable
production_id: spectaclePick?.id || null,
production_name: spectaclePick?.nom || "",
objet_spectacle: spectaclePick?.numero_objet || "",
employee_name: salarie?.nom || "",
employee_matricule: salarie?.matricule || "",
categorie_pro: categoriePro,
profession: professionPick?.label || professionQuery || "",
// CDDU fields - manual inputs (nouvellement modifiables)
type_salaire: typeSalaire || null,
salaire: montant ? parseFloat(montant) : null,
paniers_repas: panierRepas || null,
reference: reference || null,
analytique: analytique || null,
date_signature: dateSignature || null,
// Conditional fields based on category
// Pour les metteurs en scène, garder nb_representations même en mode heures
nb_representations: (useHeuresMode && professionPick?.code !== "MET040") ? null : nbRepresentations || null,
nb_services_repetition: useHeuresMode ? null : nbServicesRepetition || null,
dates_representations: useHeuresMode ? null : datesRepresentations || null,
dates_repetitions: useHeuresMode ? null : datesRepetitions || null,
heures_total: useHeuresMode ? heuresTotal || null : null,
minutes_total: useHeuresMode ? minutesTotal : null,
precisions_salaire: precisionsSalaire || null, // Nouveau champ pour précisions salaire
autreprecision_duree: autrePrecisionDuree ? autrePrecisionDuree : null, // Autre précision durée
autreprecision_salaire: autrePrecisionSalaire ? autrePrecisionSalaire : null, // Autre précision salaire
// Map to database fields
// Pour les metteurs en scène, garder nb_representations même en mode heures
cachets_representations: (useHeuresMode && professionPick?.code !== "MET040") ? null : nbRepresentations || null,
services_repetitions: useHeuresMode ? null : nbServicesRepetition || null,
nombre_d_heures: useHeuresMode ? heuresTotal || null : nombreHeuresTotal || null,
nombre_d_heures_par_jour: nombreHeuresParJour || null,
jours_representations: useHeuresMode ? null : datesRepresentations || null,
jours_repetitions: useHeuresMode ? null : datesRepetitions || null,
// Envoyer jours_travail_non_artiste en mode heures (technicien ou metteur en scène)
jours_travail_non_artiste: useHeuresMode ? ((typeof joursTravail === "string" && joursTravail.trim() === "") ? null : (joursTravail || null)) : null,
// Envoyer jours_travail en mode artiste classique (représentations/répétitions)
jours_travail: !useHeuresMode ? ((typeof joursTravail === "string" && joursTravail.trim() === "") ? null : (joursTravail || null)) : null,
panier_repas: panierRepas || null,
n_objet: spectaclePick?.numero_objet || null,
brut: typeSalaire === "Brut" ? parseMonetaryAmount(montant) : null,
// Le champ gross_pay correspond au champ "Brut" de l'interface utilisateur
gross_pay: parseMonetaryAmount(form.gross_pay),
// Temps de travail réel (informatif pour le client)
jours_travail_reel: joursTravailReel || null,
nb_representations_reel: nbRepresentationsReel || null,
nb_services_repetitions_reel: nbServicesRepetitionsReel || null,
nb_heures_repetitions_reel: nbHeuresRepetitionsReel || null,
nb_heures_annexes_reel: nbHeuresAnnexesReel || null,
nb_cachets_aem_reel: nbCachetsAemReel || null,
nb_heures_aem_reel: nbHeuresAemReel || null,
};
console.log("💾 États actuels avant sauvegarde:", {
nbRepresentations,
nbServicesRepetition,
datesRepresentations,
datesRepetitions,
heuresTotal,
minutesTotal,
joursTravail,
joursTravailDisplay,
useHeuresMode,
categoriePro,
professionCode: professionPick?.code,
autrePrecisionDuree,
autrePrecisionSalaire,
precisionsSalaire
});
console.log("💾 Sauvegarde contrat - payload:", payload);
console.log("💾 Champs jours_travail dans le payload:", {
jours_travail: payload.jours_travail,
jours_travail_non_artiste: payload.jours_travail_non_artiste
});
const res = await fetch(`/api/staff/contracts/${contract.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contract: payload }),
});
const j = await res.json();
if (!res.ok) {
toast.error(j.error ?? "Erreur lors de la mise à jour du contrat");
return;
}
setShowSaveSuccess(true);
toast.success("Contrat mis à jour");
} catch (error) {
console.error("Erreur lors de la sauvegarde:", error);
toast.error("Erreur lors de la sauvegarde du contrat");
} finally {
setIsSaving(false);
}
};
const savePayslips = async () => {
const sanitized = rows.map((r) => ({
...r,
period_start: r.period_start?.slice?.(0, 10) ?? r.period_start,
period_end: r.period_end?.slice?.(0, 10) ?? r.period_end,
period_month: r.period_month?.slice?.(0, 10) ?? r.period_month,
pay_date: r.pay_date ? r.pay_date.slice(0, 10) : null,
processed: Boolean(r.processed),
transfer_done: Boolean(r.transfer_done),
contract_id: contract.id,
}));
const res = await fetch(`/api/staff/contracts/${contract.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ payslips: sanitized }),
});
const j = await res.json();
if (!res.ok) {
toast.error(j.error ?? "Erreur lors de lenregistrement des payslips");
return;
}
setRows(j.payslips ?? []);
toast.success("Payslips enregistrés");
};
const onCreatePayslip = async (payload: AnyObj) => {
const res = await fetch(`/api/staff/contracts/${contract.id}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ payslip: payload }),
});
const j = await res.json();
if (!res.ok) {
toast.error(j.error ?? "Erreur lors de lajout");
return;
}
setRows((prev) => [...prev, j.payslip].sort((a, b) => (a.pay_number ?? 0) - (b.pay_number ?? 0)));
toast.success("Payslip ajouté");
setOpen(false);
};
const generatePdf = async () => {
setIsGeneratingPdf(true);
try {
const response = await fetch(`/api/contrats/${contract.id}/generate-pdf`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `Erreur ${response.status}`);
}
const result = await response.json();
setShowPdfSuccess(true);
toast.success("PDF créé avec succès !");
console.log("PDF généré:", result);
// Rafraîchir la page après un délai pour laisser le temps de voir le message de succès
setTimeout(() => {
window.location.reload();
}, 2500);
} catch (error) {
console.error("Erreur lors de la génération du PDF:", error);
toast.error(
error instanceof Error
? error.message
: "Erreur lors de la génération du PDF"
);
} finally {
setIsGeneratingPdf(false);
}
};
const launchElectronicSignature = async (sendNotification: boolean = true) => {
console.log("🚀 [SIGNATURE] Fonction déclenchée");
console.log("🚀 [SIGNATURE] contract.contract_pdf_filename:", contract.contract_pdf_filename);
console.log("🚀 [SIGNATURE] salarie?.email:", salarie?.email);
console.log("🚀 [SIGNATURE] sendNotification:", sendNotification);
setIsLaunchingSignature(true);
try {
// Vérifier que le contrat a un PDF généré
if (!contract.contract_pdf_filename) {
toast.error("Veuillez d'abord générer le PDF du contrat");
return;
}
// ========================================
// 🔒 SÉCURITÉ MAXIMALE : Récupération et validation de l'email du salarié
// ========================================
let employeeEmail = salarie?.email;
let verifiedEmployeeName = "";
if (!employeeEmail) {
console.log("🔍 [SIGNATURE] Email non trouvé dans salarie, recherche dans la base...");
// 🛡️ VÉRIFICATION 1 : Le matricule doit être présent
if (!contract.employee_matricule) {
toast.error("❌ SÉCURITÉ : Le matricule du salarié est manquant dans le contrat. Signature annulée.");
console.error("❌ [SIGNATURE] SÉCURITÉ : Matricule manquant");
return;
}
// Normaliser le matricule (trim + conversion en string)
const matricule = String(contract.employee_matricule).trim();
console.log(`🔍 [SIGNATURE] Recherche EXACTE par matricule (code_salarie): "${matricule}" (type: ${typeof matricule}, longueur: ${matricule.length})`);
// 🛡️ VÉRIFICATION 2 : Recherche EXACTE par code_salarie (même méthode que bulk-esign)
const { data: salarieData, error: salarieError } = await supabase
.from('salaries')
.select('adresse_mail, nom, prenom, code_salarie, employer_id')
.eq('code_salarie', matricule)
.maybeSingle();
if (salarieError) {
console.error("❌ [SIGNATURE] Erreur Supabase lors de la recherche du salarié:", salarieError);
toast.error("❌ Erreur lors de la recherche du salarié. Signature annulée.");
return;
}
if (!salarieData) {
console.error(`❌ [SIGNATURE] SÉCURITÉ : Aucun salarié trouvé pour le matricule: ${matricule}`);
toast.error(`❌ SÉCURITÉ : Aucun salarié trouvé avec le matricule ${matricule}. Vérifiez les données du contrat.`);
return;
}
// 🛡️ VÉRIFICATION 3 : L'email doit exister
employeeEmail = salarieData.adresse_mail;
if (!employeeEmail) {
console.error(`❌ [SIGNATURE] SÉCURITÉ : Email manquant pour le salarié ${salarieData.prenom} ${salarieData.nom}`);
toast.error(`❌ SÉCURITÉ : Le salarié ${salarieData.prenom} ${salarieData.nom} n'a pas d'email renseigné. Ajoutez un email avant de lancer la signature.`);
return;
}
// 🛡️ VÉRIFICATION 4 : Le salarié doit appartenir à la même organisation que le contrat
if (contract.org_id && salarieData.employer_id && salarieData.employer_id !== contract.org_id) {
console.error(`❌ [SIGNATURE] SÉCURITÉ : Le salarié (org: ${salarieData.employer_id}) n'appartient pas à l'organisation du contrat (org: ${contract.org_id})`);
toast.error(`❌ SÉCURITÉ : Le salarié ${salarieData.prenom} ${salarieData.nom} n'appartient pas à l'organisation de ce contrat. Signature annulée.`);
return;
}
verifiedEmployeeName = `${salarieData.prenom || ''} ${salarieData.nom || ''}`.trim();
console.log(`✅ [SIGNATURE] VALIDÉ : Email trouvé et vérifié`);
console.log(` - Salarié: ${verifiedEmployeeName}`);
console.log(` - Matricule: ${salarieData.code_salarie}`);
console.log(` - Email: ${employeeEmail}`);
console.log(` - Organisation: ${salarieData.employer_id}`);
} else {
// Email déjà présent dans l'état local, récupérer le nom pour la vérification
verifiedEmployeeName = salarie?.nom || contract.employee_name || "";
console.log(`✅ [SIGNATURE] Email déjà présent dans l'état local: ${employeeEmail}`);
}
// 🛡️ VÉRIFICATION 5 : L'email doit avoir un format valide
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(employeeEmail)) {
console.error(`❌ [SIGNATURE] SÉCURITÉ : Format d'email invalide: ${employeeEmail}`);
toast.error(`❌ SÉCURITÉ : Le format de l'email du salarié est invalide (${employeeEmail}). Signature annulée.`);
return;
}
// 🛡️ VÉRIFICATION 6 : Récupération du prénom du salarié depuis la base de données
let employeeFirstName = salarie?.prenom || "";
if (!employeeFirstName && contract.employee_matricule) {
console.log("🔍 [SIGNATURE] Récupération du prénom depuis la base de données...");
const matricule = String(contract.employee_matricule).trim();
const { data: prenomData, error: prenomError } = await supabase
.from('salaries')
.select('prenom')
.eq('code_salarie', matricule)
.maybeSingle();
if (prenomData?.prenom) {
employeeFirstName = prenomData.prenom;
console.log(`✅ [SIGNATURE] Prénom trouvé: ${employeeFirstName}`);
} else {
console.error(`❌ [SIGNATURE] Prénom non trouvé pour le matricule ${matricule}`);
toast.error(`Le prénom du salarié est manquant dans la base de données. Veuillez le renseigner avant de lancer la signature.`);
return;
}
}
if (!employeeFirstName) {
console.error(`❌ [SIGNATURE] Prénom du salarié manquant`);
toast.error(`Le prénom du salarié est manquant. Veuillez le renseigner avant de lancer la signature.`);
return;
}
// 🛡️ VÉRIFICATION 7 : Confirmation finale avant envoi
console.log("🔒 [SIGNATURE] VÉRIFICATIONS DE SÉCURITÉ RÉUSSIES");
console.log(` ✓ Matricule vérifié: ${contract.employee_matricule}`);
console.log(` ✓ Salarié identifié: ${verifiedEmployeeName}`);
console.log(` ✓ Prénom: ${employeeFirstName}`);
console.log(` ✓ Email validé: ${employeeEmail}`);
console.log(` ✓ Organisation vérifiée: ${contract.org_id}`);
console.log(` ✓ Contrat: ${contract.contract_number || contract.id}`);
// Récupérer les informations de l'organisation active (ou du contrat pour les staff)
console.log("🏢 [SIGNATURE] Récupération des infos organisation...");
let employerEmail = "";
let signerName = "";
let organizationName = "";
let employerCode = "";
const orgResponse = await fetch('/api/me');
const orgData = await orgResponse.json();
console.log("🏢 [SIGNATURE] Réponse /api/me:", JSON.stringify(orgData, null, 2));
if (orgData.is_staff) {
// Pour les staff, récupérer les infos depuis l'organisation du contrat
console.log("👨‍💼 [SIGNATURE] Utilisateur staff - récupération des infos du contrat...");
console.log("👨‍💼 [SIGNATURE] contract.org_id:", contract.org_id);
// Appel API pour récupérer les détails de l'organisation du contrat
const contractOrgResponse = await fetch(`/api/organizations/${contract.org_id}/details`);
if (contractOrgResponse.ok) {
const contractOrgData = await contractOrgResponse.json();
console.log("🏢 [SIGNATURE] Détails organisation contrat:", contractOrgData);
employerEmail = contractOrgData.email_signature || contractOrgData.email_contact || "paie@odentas.fr";
signerName = `${contractOrgData.prenom_signataire || ""} ${contractOrgData.nom_signataire || ""}`.trim() || "Responsable";
organizationName = contractOrgData.structure || contract.production_name || "Organisation";
employerCode = contractOrgData.code_employeur || "DEMO";
} else {
// Fallback avec les valeurs du contrat
console.log("⚠️ [SIGNATURE] Impossible de récupérer les détails org, utilisation des valeurs par défaut");
employerEmail = "paie@odentas.fr";
signerName = "Responsable";
organizationName = contract.production_name || "Organisation";
employerCode = "DEMO";
}
} else {
// Pour les clients normaux, récupérer les infos depuis leur organisation active
console.log("👤 [SIGNATURE] Utilisateur client - récupération des infos de l'organisation active...");
console.log("👤 [SIGNATURE] orgData.active_org_id:", orgData.active_org_id);
// Appel API pour récupérer les détails de l'organisation active du client
const clientOrgResponse = await fetch(`/api/organizations/${orgData.active_org_id}/details`);
if (clientOrgResponse.ok) {
const clientOrgData = await clientOrgResponse.json();
console.log("🏢 [SIGNATURE] Détails organisation client:", clientOrgData);
employerEmail = clientOrgData.email_signature || clientOrgData.email_contact || "paie@odentas.fr";
signerName = `${clientOrgData.prenom_signataire || ""} ${clientOrgData.nom_signataire || ""}`.trim() || "Responsable";
organizationName = clientOrgData.structure || orgData.active_org_name || "Organisation";
employerCode = clientOrgData.code_employeur || "CLIENT";
} else {
// Fallback avec les valeurs par défaut
console.log("⚠️ [SIGNATURE] Impossible de récupérer les détails org client, utilisation des valeurs par défaut");
employerEmail = "paie@odentas.fr";
signerName = "Responsable";
organizationName = orgData.active_org_name || "Organisation";
employerCode = "CLIENT";
}
}
console.log("✅ [SIGNATURE] Informations finales:", {
employerEmail,
signerName,
organizationName,
employerCode
});
// Construire la clé S3 du PDF généré
const pdfS3Key = `unsigned-contracts/${contract.contract_pdf_filename}`;
const signatureData = {
contractId: contract.id,
pdfS3Key: pdfS3Key, // Utilisation du PDF généré et stocké dans Documents
employerEmail: employerEmail,
employeeEmail: employeeEmail,
reference: reference || contract.reference || contract.contract_number,
employeeName: salarie?.nom || contract.employee_name,
startDate: form.start_date || contract.start_date,
role: professionPick?.label || contract.profession || contract.role,
analytique: analytique || contract.analytique || contract.production_name,
structure: organizationName,
signerFirstName: signerName,
employerCode: employerCode,
employeeFirstName: employeeFirstName,
matricule: salarie?.code_salarie || salarie?.matricule || contract.employee_matricule,
contractType: 'CDDU',
orgId: contract.org_id, // Passer l'org_id pour récupérer la signature
skipEmployerEmail: true, // Ne pas envoyer l'email individuel, seulement le récapitulatif si demandé
};
console.log("📤 [SIGNATURE] Données envoyées:", signatureData);
console.log("🌐 [SIGNATURE] Appel API docuseal-signature...");
const response = await fetch('/api/docuseal-signature', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(signatureData),
});
console.log("🌐 [SIGNATURE] Réponse HTTP:", response.status, response.statusText);
if (!response.ok) {
const errorData = await response.json();
console.error("❌ [SIGNATURE] Erreur API DocuSeal:", {
status: response.status,
statusText: response.statusText,
errorData: errorData
});
throw new Error(errorData.error || `Erreur ${response.status}`);
}
const result = await response.json();
toast.success("Signature électronique lancée avec succès !");
console.log("Signature électronique initiée:", result);
// Envoyer l'email de notification au client si demandé
if (sendNotification && contract.org_id) {
console.log("📧 [SIGNATURE] Envoi de l'email de notification au client...");
try {
const notificationResponse = await fetch('/api/staff/contracts/send-esign-notification', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
organizationId: contract.org_id,
contractCount: 1
}),
});
if (notificationResponse.ok) {
console.log("✅ [SIGNATURE] Email de notification envoyé avec succès");
} else {
const errorData = await notificationResponse.json();
console.error("⚠️ [SIGNATURE] Erreur lors de l'envoi de la notification:", errorData);
// Ne pas faire échouer toute l'opération si l'email de notif ne part pas
toast.warning("Signature créée, mais l'email de notification n'a pas pu être envoyé");
}
} catch (notifError) {
console.error("⚠️ [SIGNATURE] Exception lors de l'envoi de la notification:", notifError);
// Ne pas faire échouer toute l'opération si l'email de notif ne part pas
}
}
// Optionnel : rafraîchir les données du contrat
setTimeout(() => {
window.location.reload();
}, 2000);
} catch (error) {
console.error("Erreur lors du lancement de la signature électronique:", error);
toast.error(
error instanceof Error
? error.message
: "Erreur lors du lancement de la signature électronique"
);
} finally {
setIsLaunchingSignature(false);
setShowESignConfirmModal(false);
}
};
// Handler for opening the e-sign confirmation modal
const handleESignButtonClick = async () => {
// Récupérer et vérifier l'email du salarié AVANT d'ouvrir le modal
let employeeEmail = salarie?.email;
if (!employeeEmail) {
console.log("🔍 [MODAL] Recherche de l'email du salarié...");
console.log("🔍 [MODAL] Contrat:", {
id: contract.id,
employee_matricule: contract.employee_matricule,
employee_name: contract.employee_name,
org_id: contract.org_id
});
if (!contract.employee_matricule) {
toast.error("❌ SÉCURITÉ : Le matricule du salarié est manquant dans le contrat.");
return;
}
// Normaliser le matricule (trim + conversion en string)
const matricule = String(contract.employee_matricule).trim();
console.log(`🔍 [MODAL] Recherche EXACTE par matricule (code_salarie): "${matricule}" (type: ${typeof matricule}, longueur: ${matricule.length})`);
const { data: salarieData, error: salarieError } = await supabase
.from('salaries')
.select('adresse_mail, code_salarie, employer_id, nom, prenom')
.eq('code_salarie', matricule)
.maybeSingle();
if (salarieError) {
console.error("❌ [MODAL] Erreur Supabase lors de la recherche du salarié:", salarieError);
toast.error("❌ Impossible de trouver le salarié. Vérifiez les données du contrat.");
return;
}
if (!salarieData) {
console.error(`❌ [MODAL] Aucun salarié trouvé pour le matricule: ${matricule}`);
toast.error("❌ Impossible de trouver le salarié. Vérifiez les données du contrat.");
return;
}
console.log("✅ [MODAL] Salarié trouvé:", salarieData);
employeeEmail = salarieData.adresse_mail;
if (!employeeEmail) {
toast.error("❌ Le salarié n'a pas d'email renseigné.");
return;
}
// Vérifier l'organisation
if (contract.org_id && salarieData.employer_id && salarieData.employer_id !== contract.org_id) {
toast.error("❌ SÉCURITÉ : Le salarié n'appartient pas à l'organisation de ce contrat.");
return;
}
// Valider le format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(employeeEmail)) {
toast.error(`❌ SÉCURITÉ : Le format de l'email est invalide (${employeeEmail}).`);
return;
}
console.log(`✅ [MODAL] Email vérifié: ${employeeEmail}`);
}
// Récupérer l'email de l'employeur pour les signatures
let employerEmail = "";
if (!contract.org_id) {
toast.error("❌ ID d'organisation manquant dans le contrat.");
return;
}
try {
console.log("🔍 [MODAL] Recherche de l'email de l'employeur...");
const response = await fetch(`/api/organizations/${contract.org_id}/details`);
if (!response.ok) {
throw new Error(`Erreur ${response.status}`);
}
const orgData = await response.json();
employerEmail = orgData.email_signature || orgData.email_contact || "paie@odentas.fr";
console.log(`✅ [MODAL] Email employeur vérifié: ${employerEmail}`);
} catch (error) {
console.error("❌ [MODAL] Erreur lors de la récupération de l'email employeur:", error);
toast.error("❌ Impossible de récupérer l'email de l'employeur.");
return;
}
setVerifiedEmployeeEmail(employeeEmail);
setVerifiedEmployerEmail(employerEmail);
setShowESignConfirmModal(true);
};
// Handler for confirming the e-sign launch
const handleConfirmESign = (sendNotification: boolean) => {
setSendESignNotification(sendNotification);
launchElectronicSignature(sendNotification);
};
// Payslip modal functions
const handleOpenPayslipModal = (payslip?: any) => {
setEditingPayslip(payslip || null);
setIsPayslipModalOpen(true);
};
const handleClosePayslipModal = () => {
setIsPayslipModalOpen(false);
setEditingPayslip(null);
};
const handlePayslipModalSuccess = () => {
// Refresh the payslips data
window.location.reload(); // Pour l'instant, on recharge. Idéalement on ferait un refetch
};
// Fonction pour rafraîchir les payslips après un upload
const handlePayslipUploadComplete = async () => {
try {
// Recharger les payslips depuis la base de données
const response = await fetch(`/api/staff/payslips?contract_id=${contract.id}`);
if (response.ok) {
const data = await response.json();
setRows(data.payslips ?? []);
toast.success("Données mises à jour");
}
} catch (error) {
console.error("Erreur lors du rafraîchissement des payslips:", error);
// En cas d'erreur, on recharge la page
window.location.reload();
}
};
// Fonction pour marquer le contrat comme non signé
const [isMarkingUnsigned, setIsMarkingUnsigned] = useState(false);
const handleMarkAsUnsigned = async () => {
if (isMarkingUnsigned) return;
try {
setIsMarkingUnsigned(true);
const response = await fetch(`/api/staff/contrats/${contract.id}/mark-unsigned`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `Erreur ${response.status}`);
}
const result = await response.json();
toast.success(result.message || "Contrat marqué comme non signé avec succès !");
// Mettre à jour l'état local du formulaire
setForm(prev => ({
...prev,
contrat_signe: "Non",
contrat_signe_par_employeur: "Non"
}));
// Optionnel : rafraîchir après un délai
setTimeout(() => {
window.location.reload();
}, 1500);
} catch (error) {
console.error("Erreur lors du marquage comme non signé:", error);
toast.error(
error instanceof Error
? error.message
: "Erreur lors du marquage comme non signé"
);
} finally {
setIsMarkingUnsigned(false);
}
};
// Vérifier si le bouton doit être désactivé (les deux colonnes sont déjà "Non")
const isBothUnsigned = (form.contrat_signe || contract.contrat_signe) === "Non" &&
(form.contrat_signe_par_employeur || contract.contrat_signe_par_employeur) === "Non";
// États et fonctions pour la relance employeur
const [isReminderModalOpen, setIsReminderModalOpen] = useState(false);
const [isSendingReminder, setIsSendingReminder] = useState(false);
const [organizationData, setOrganizationData] = useState<{
email_signature?: string;
organization_name?: string;
organization_code?: string;
} | null>(null);
const [isLoadingOrgData, setIsLoadingOrgData] = useState(false);
const fetchOrganizationData = async () => {
if (!contract.org_id) {
toast.error("ID d'organisation manquant dans le contrat");
return false;
}
try {
setIsLoadingOrgData(true);
// Utiliser l'API existante ou créer une requête directe
const response = await fetch(`/api/organizations/${contract.org_id}/details`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`Erreur ${response.status}`);
}
const result = await response.json();
setOrganizationData(result);
return true;
} catch (error) {
console.error("Erreur lors de la récupération des données d'organisation:", error);
toast.error("Impossible de récupérer les données de l'organisation");
return false;
} finally {
setIsLoadingOrgData(false);
}
};
const handleOpenReminderModal = async () => {
const success = await fetchOrganizationData();
if (success) {
setIsReminderModalOpen(true);
}
};
const handleCloseReminderModal = () => {
setIsReminderModalOpen(false);
};
const handleSendEmployerReminder = async () => {
if (isSendingReminder) return;
try {
setIsSendingReminder(true);
const response = await fetch(`/api/staff/contrats/${contract.id}/remind-employer`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `Erreur ${response.status}`);
}
const result = await response.json();
toast.success("Relance employeur envoyée avec succès !");
console.log("Relance employeur envoyée:", result);
// Fermer la modal
setIsReminderModalOpen(false);
} catch (error) {
console.error("Erreur lors de l'envoi de la relance employeur:", error);
toast.error(
error instanceof Error
? error.message
: "Erreur lors de l'envoi de la relance employeur"
);
} finally {
setIsSendingReminder(false);
}
};
const defaultNewPayslip = {
organization_id: contract.org_id,
contract_id: contract.id,
period_start: form.start_date || contract.start_date?.slice(0, 10) || "",
period_end: form.end_date || contract.end_date?.slice(0, 10) || "",
period_month:
(form.start_date || contract.start_date?.slice(0, 10) || "").slice(0, 7) + "-01",
pay_number: nextPayNumber,
gross_amount: contract.gross_pay ?? "",
net_amount: "",
employer_cost: "",
aem_status: "À traiter",
processed: false,
transfer_done: false,
analytic_tag: contract.analytique ?? null,
storage_path: `paies/${slugify(contract.structure || "")}/${contract.contract_number}.pdf`,
source_reference: contract.contract_number,
};
return (
<div className="min-h-[100svh] bg-gradient-to-br from-white via-zinc-50 to-zinc-100 px-6 py-10">
<div className="mx-auto max-w-6xl space-y-6">
<header className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl md:text-3xl font-semibold tracking-tight">
{contract.salaries?.salarie
|| (contract.salaries?.nom
? `${contract.salaries.nom.toUpperCase()}${contract.salaries.prenom ? ' ' + contract.salaries.prenom.charAt(0).toUpperCase() + contract.salaries.prenom.slice(1) : ''}`
: contract.employee_name || "")}
</h1>
<p className="text-sm text-muted-foreground">
{contract.production_name} {contract.contract_number}
</p>
</div>
<div className="flex gap-2">
<Button
onClick={() => setShowCancelModal(true)}
disabled={isCancelling || form.etat_de_la_demande === "Annulée"}
variant="outline"
className="rounded-2xl px-5 text-red-600 border-red-300 hover:bg-red-50"
>
<Ban className="size-4 mr-2" />
{form.etat_de_la_demande === "Annulée" ? "Contrat annulé" : "Annuler le contrat"}
</Button>
<Button
onClick={async () => {
setIsRefreshing(true);
try {
const res = await fetch(`/api/staff/contracts/${contract.id}`);
if (!res.ok) throw new Error('Erreur lors du rechargement');
toast.success('Données régénérées');
window.location.reload();
} catch (e) {
console.error('Erreur lors du rafraîchissement:', e);
toast.error('Impossible de régénérer les données');
} finally {
setIsRefreshing(false);
}
}}
disabled={isRefreshing}
variant="ghost"
className="rounded-2xl px-3"
>
<RefreshCw className="size-4 mr-2" />
{isRefreshing ? 'Rafraîchissement...' : 'Régénérer'}
</Button>
</div>
</div>
<div className="flex gap-2 justify-end">
<Button
onClick={generatePdf}
disabled={isGeneratingPdf}
variant="outline"
className="rounded-2xl px-5"
>
<FileDown className="size-4 mr-2" />
{isGeneratingPdf ? "Génération..." : "Créer le PDF"}
</Button>
<Button
onClick={() => {
console.log("🖱️ [SIGNATURE] Bouton cliqué");
console.log("🖱️ [SIGNATURE] isLaunchingSignature:", isLaunchingSignature);
console.log("🖱️ [SIGNATURE] contract.contract_pdf_filename:", contract.contract_pdf_filename);
handleESignButtonClick();
}}
disabled={isLaunchingSignature || !contract.contract_pdf_filename}
variant="secondary"
className="rounded-2xl px-5"
>
<PenTool className="size-4 mr-2" />
{isLaunchingSignature ? "Lancement..." : "Lancer l'e-signature"}
</Button>
</div>
</header>
{/* Bouton flottant pour enregistrer */}
<div className="fixed bottom-6 right-6 z-50">
<Button
onClick={saveContract}
disabled={isSaving}
className="rounded-full shadow-lg hover:shadow-xl transition-all duration-200 px-8 py-7 bg-green-600 hover:bg-green-700 text-black text-base font-semibold ring-2 ring-green-400 ring-offset-2"
>
<Save className="size-5 mr-2 text-black" />
{isSaving ? "Sauvegarde..." : "Enregistrer"}
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="col-span-2 space-y-6">
{/* Card 1: États et Statuts - Le plus important */}
<Card className="rounded-3xl">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CheckCircle2 className="size-5" /> États et statuts
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="text-xs text-muted-foreground">État de la demande</label>
<select
value={form.etat_de_la_demande || ""}
onChange={(e) => handleContractChange("etat_de_la_demande", e.target.value)}
className="w-full p-2 border rounded"
>
<option value="">Sélectionner...</option>
<option value="Reçue">Reçue</option>
<option value="En cours">En cours</option>
<option value="Traitée">Traitée</option>
<option value="Refusée">Refusée</option>
<option value="Annulée">Annulée</option>
</select>
</div>
<div>
<label className="text-xs text-muted-foreground">État de la paie</label>
<select
value={form.etat_de_la_paie || ""}
onChange={(e) => handleContractChange("etat_de_la_paie", e.target.value)}
className="w-full p-2 border rounded"
>
<option value="">Sélectionner...</option>
<option value="À traiter">À traiter</option>
<option value="En cours">En cours</option>
<option value="Traitée">Traitée</option>
<option value="Non concernée">Non concernée</option>
</select>
</div>
<div>
<label className="text-xs text-muted-foreground">DPAE</label>
<select
value={form.dpae || ""}
onChange={(e) => handleContractChange("dpae", e.target.value)}
className="w-full p-2 border rounded"
>
<option value="">Sélectionner...</option>
<option value="À faire">À faire</option>
<option value="Faite">Faite</option>
<option value="Non concernée">Non concernée</option>
</select>
</div>
<div>
<label className="text-xs text-muted-foreground">Contrat signé par salarié</label>
<select
value={form.contrat_signe || ""}
onChange={(e) => handleContractChange("contrat_signe", e.target.value)}
className="w-full p-2 border rounded"
>
<option value="">Sélectionner...</option>
<option value="Oui">Oui</option>
<option value="Non">Non</option>
</select>
</div>
<div>
<label className="text-xs text-muted-foreground">Contrat signé par employeur</label>
<select
value={form.contrat_signe_par_employeur || ""}
onChange={(e) => handleContractChange("contrat_signe_par_employeur", e.target.value)}
className="w-full p-2 border rounded"
>
<option value="">Sélectionner...</option>
<option value="Oui">Oui</option>
<option value="Non">Non</option>
</select>
</div>
</div>
</CardContent>
</Card>
{/* Card 2: Informations principales du contrat */}
<Card className="rounded-3xl">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="size-5" /> Informations du contrat
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-6">
{/* Spectacle et Référence */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="relative md:col-span-2">
<label className="text-xs text-muted-foreground">Spectacle</label>
{spectaclePick ? (
<div className="flex items-center gap-2">
<div className="flex-1 p-2 border rounded bg-green-50 text-sm">
<div className="font-medium">{spectaclePick.nom}</div>
{spectaclePick.numero_objet && (
<div className="text-xs text-muted-foreground">{spectaclePick.numero_objet}</div>
)}
</div>
<button
type="button"
onClick={() => {
setSpectaclePick(null);
setSpectacleQuery("");
}}
className="px-2 py-1 text-xs border rounded hover:bg-gray-50"
>
Changer
</button>
</div>
) : (
<>
<div className="relative">
<Input
value={spectacleQuery}
onChange={(e) => setSpectacleQuery(e.target.value)}
placeholder="Rechercher un spectacle..."
/>
<Search className="absolute right-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
</div>
{spectacleQuery.length > 1 && spectacleSearch?.items && (
<div className="absolute z-10 w-full mt-1 bg-white border rounded-md shadow-lg max-h-48 overflow-y-auto">
{spectacleSearch.items.length === 0 ? (
<div className="p-2 text-sm text-gray-500">Aucun résultat</div>
) : (
spectacleSearch.items.map((item, idx) => (
<button
key={idx}
type="button"
className="w-full text-left p-2 hover:bg-gray-50 text-sm border-b last:border-b-0"
onClick={() => {
setSpectaclePick(item);
setSpectacleQuery("");
}}
>
<div className="font-medium">{item.nom}</div>
{(item.numero_objet || item.prod_type) && (
<div className="text-xs text-gray-500">
{item.numero_objet && <span>{item.numero_objet}</span>}
{item.numero_objet && item.prod_type && <span> </span>}
{item.prod_type && <span>{item.prod_type}</span>}
</div>
)}
</button>
))
)}
</div>
)}
</>
)}
</div>
{/* Analytique */}
<div>
<label className="text-xs text-muted-foreground">Analytique</label>
<Input
value={analytique}
onChange={(e) => setAnalytique(e.target.value)}
placeholder="Code analytique"
/>
</div>
</div>
{/* Période du contrat */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<div>
<label className="text-xs text-muted-foreground">Début</label>
<Input
type="date"
value={form.start_date || prefill.date_debut}
onChange={(e) => handleContractChange("start_date", e.target.value)}
/>
</div>
<div>
<label className="text-xs text-muted-foreground">Fin</label>
<Input
type="date"
value={form.end_date || prefill.date_fin}
onChange={(e) => handleContractChange("end_date", e.target.value)}
/>
</div>
<div>
<label className="text-xs text-muted-foreground">Date de signature</label>
<Input
type="date"
value={dateSignature}
onChange={(e) => setDateSignature(e.target.value)}
/>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Card 3: Salarié et Profession */}
<Card className="rounded-3xl">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="size-5" /> Salarié et profession
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-6">
{/* Salarié avec recherche */}
<div className="relative md:col-span-2">
<label className="text-xs text-muted-foreground">Salarié</label>
{salarie ? (
<div className="flex items-center gap-2">
<div className="flex-1 p-2 border rounded bg-green-50 text-sm">
<div className="font-medium">{salarie.nom}</div>
<div className="text-xs text-muted-foreground">{salarie.matricule}</div>
</div>
<button
type="button"
onClick={() => {
setSalarie(null);
setSalarieQuery("");
}}
className="px-2 py-1 text-xs border rounded hover:bg-gray-50"
>
Changer
</button>
</div>
) : (
<>
<div className="relative">
<Input
value={salarieQuery}
onChange={(e) => setSalarieQuery(e.target.value)}
placeholder="Rechercher un·e salarié·e..."
/>
<Search className="absolute right-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
</div>
{salarieQuery.length > 1 && salarieSearch?.items && (
<div className="absolute z-10 w-full mt-1 bg-white border rounded-md shadow-lg max-h-48 overflow-y-auto">
{salarieSearch.items.length === 0 ? (
<div className="p-2 text-sm text-gray-500">Aucun résultat</div>
) : (
salarieSearch.items.map((item, idx) => (
<button
key={idx}
type="button"
className="w-full text-left p-2 hover:bg-gray-50 text-sm border-b last:border-b-0"
onClick={() => {
setSalarie(item);
setSalarieQuery("");
}}
>
<div className="font-medium">{item.nom}</div>
<div className="text-xs text-gray-500">
<span>{item.matricule}</span>
{item.email && <span> {item.email}</span>}
</div>
</button>
))
)}
</div>
)}
</>
)}
</div>
{/* Profession, Profession féminin, Catégorie professionnelle - 3 colonnes */}
<div className="md:col-span-3">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Profession avec recherche conditionnelle */}
<div className="relative">
<label className="text-xs text-muted-foreground">Profession</label>
{professionPick ? (
<div className="flex items-center gap-2">
<div className="flex-1 p-2 border rounded bg-green-50 text-sm">
<div className="font-medium">{professionPick.label}</div>
{professionPick.code && (
<div className="text-xs text-muted-foreground">{professionPick.code}</div>
)}
</div>
<button
type="button"
onClick={() => {
setProfessionPick(null);
setProfessionQuery("");
setProfessionFeminine("");
}}
className="px-2 py-1 text-xs border rounded hover:bg-gray-50"
>
Changer
</button>
</div>
) : (
<>
<Input
value={professionQuery}
onChange={(e) => setProfessionQuery(e.target.value)}
placeholder={`Rechercher une profession ${categoriePro.toLowerCase()}...`}
/>
{professionQuery && (
<div className="absolute z-10 w-full mt-1 bg-white border rounded-md shadow-lg max-h-48 overflow-y-auto">
{categoriePro === "Artiste" ? (
filteredProfessions.length === 0 ? (
<div className="p-2 text-sm text-gray-500">Aucune profession trouvée</div>
) : (
filteredProfessions.map((prof, idx) => (
<button
key={idx}
type="button"
className="w-full text-left p-2 hover:bg-gray-50 text-sm border-b last:border-b-0"
onClick={() => {
setProfessionPick(prof);
setProfessionQuery("");
}}
>
<div className="font-medium">{prof.label}</div>
<div className="text-xs text-gray-500">{prof.code}</div>
</button>
))
)
) : (
filteredTechniciens.length === 0 ? (
<div className="p-2 text-sm text-gray-500">
{techniciens ? "Aucune profession trouvée" : "Chargement..."}
</div>
) : (
filteredTechniciens.map((prof, idx) => (
<button
key={idx}
type="button"
className="w-full text-left p-2 hover:bg-gray-50 text-sm border-b last:border-b-0"
onClick={() => {
setProfessionPick(prof);
setProfessionQuery("");
}}
>
<div className="font-medium">{prof.label}</div>
<div className="text-xs text-gray-500">{prof.code}</div>
</button>
))
)
)}
</div>
)}
</>
)}
</div>
{/* Profession féminin */}
<div>
<label className="text-xs text-muted-foreground">Profession (féminin)</label>
<div className="relative">
<Input
value={professionFeminine}
onChange={(e) => handleFeminineChange(e.target.value)}
placeholder={professionPick ? "Saisir le féminin..." : "Sélectionnez d'abord une profession"}
disabled={!professionPick}
className={`${!professionPick ? "bg-gray-50 text-gray-400" : ""} pr-12`}
onKeyDown={(e) => {
console.log("🔍 KeyDown event:", e.key, {professionPick, professionFeminine});
if (e.key === 'Enter' && professionPick && professionFeminine.trim()) {
e.preventDefault();
console.log("🚀 Calling saveCurrentFeminisation from Enter key");
saveCurrentFeminisation();
}
}}
/>
{professionPick && professionFeminine.trim() && (
<button
type="button"
onClick={() => {
console.log("🚀 Button clicked, calling saveCurrentFeminisation");
saveCurrentFeminisation();
}}
disabled={isSavingFeminisation}
className="absolute right-2 top-1/2 transform -translate-y-1/2 p-1.5 rounded-full hover:bg-green-100 text-green-600 disabled:opacity-50 disabled:cursor-not-allowed"
title="Sauvegarder la féminisation"
>
{isSavingFeminisation ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
<Check className="w-4 h-4" />
)}
</button>
)}
</div>
{professionPick && !professionFeminine && (
<div className="text-xs text-amber-600 mt-1">
Saisissez la forme féminine pour les contrats de femmes
</div>
)}
{professionPick && professionFeminine.trim() && (
<div className="text-xs text-green-600 mt-1">
💾 Cliquez sur ou appuyez sur Entrée pour sauvegarder
</div>
)}
</div>
{/* Catégorie professionnelle */}
<div>
<label className="text-xs text-muted-foreground">Catégorie professionnelle</label>
<select
value={categoriePro}
onChange={(e) => {
const newCat = e.target.value as "Artiste" | "Technicien";
setCategoriePro(newCat);
// Reset profession when category changes
setProfessionPick(null);
setProfessionQuery("");
}}
className="w-full p-2 border rounded"
>
<option value="Artiste">Artiste</option>
<option value="Technicien">Technicien</option>
</select>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Card 4: Rémunération */}
<Card className="rounded-3xl">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Euro className="size-5" /> Rémunération
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Champs CDDU modifiables */}
<div>
<label className="text-xs text-muted-foreground">Type de salaire</label>
<select
value={typeSalaire}
onChange={(e) => setTypeSalaire(e.target.value)}
className="w-full p-2 border rounded"
>
<option value="">Sélectionner...</option>
<option value="Brut">Brut</option>
<option value="Net avant PAS">Net avant PAS</option>
<option value="Coût total employeur">Coût total employeur</option>
<option value="Minimum conventionnel">Minimum conventionnel</option>
</select>
</div>
<div>
<label className="text-xs text-muted-foreground">Montant</label>
<Input
type="number"
step="0.01"
value={montant}
onChange={(e) => setMontant(e.target.value)}
placeholder="Montant en euros"
/>
{/* Affichage du détail des salaires par date si disponible */}
{contract.salaires_par_date && (
<div className="mt-3 p-3 bg-slate-50 rounded-lg border border-slate-200">
<div className="text-xs font-semibold text-slate-700 mb-2">Détail des salaires par date</div>
<div className="space-y-2 text-xs">
{/* Représentations */}
{contract.salaires_par_date.representations && contract.salaires_par_date.representations.length > 0 && (
<div className="space-y-1">
<div className="text-xs font-semibold text-indigo-700 uppercase tracking-wide">Représentations</div>
{contract.salaires_par_date.representations.map((rep: any, idx: number) => (
<div key={idx} className="flex items-center gap-2 text-xs">
<span className="font-medium text-slate-700 w-12">{rep.date}</span>
<div className="flex flex-wrap gap-2">
{rep.items.map((item: any, itemIdx: number) => (
<span key={itemIdx} className="text-slate-600">
R{item.numero}: <span className="font-semibold">{item.montant.toFixed(2)} </span>
</span>
))}
</div>
</div>
))}
</div>
)}
{/* Répétitions */}
{contract.salaires_par_date.repetitions && contract.salaires_par_date.repetitions.length > 0 && (
<div className="space-y-1">
<div className="text-xs font-semibold text-purple-700 uppercase tracking-wide">Répétitions</div>
{contract.salaires_par_date.repetitions.map((rep: any, idx: number) => (
<div key={idx} className="flex items-center gap-2 text-xs">
<span className="font-medium text-slate-700 w-12">{rep.date}</span>
<div className="flex flex-wrap gap-2">
{rep.items.map((item: any, itemIdx: number) => (
<span key={itemIdx} className="text-slate-600">
S{item.numero}: <span className="font-semibold">{item.montant.toFixed(2)} </span>
</span>
))}
</div>
</div>
))}
</div>
)}
{/* Jours travaillés */}
{contract.salaires_par_date.jours_travail && contract.salaires_par_date.jours_travail.length > 0 && (
<div className="space-y-1">
<div className="text-xs font-semibold text-green-700 uppercase tracking-wide">Jours travaillés</div>
{contract.salaires_par_date.jours_travail.map((jour: any, idx: number) => (
<div key={idx} className="flex items-center gap-2 text-xs">
<span className="font-medium text-slate-700 w-12">{jour.date}</span>
<span className="text-slate-600">
Jour: <span className="font-semibold">{jour.montant.toFixed(2)} </span>
</span>
</div>
))}
</div>
)}
{/* Total */}
<div className="pt-2 border-t border-slate-200 mt-2">
<span className="text-xs font-semibold text-slate-700">Total: </span>
<span className="text-sm font-bold text-indigo-900">{contract.salaires_par_date.total_calcule.toFixed(2)} </span>
</div>
</div>
</div>
)}
</div>
<div>
<label className="text-xs text-muted-foreground">Brut</label>
<Input
value={form.gross_pay || String(prefill.montant || "")}
onChange={(e) => handleContractChange("gross_pay", e.target.value)}
/>
</div>
<div>
<label className="text-xs text-muted-foreground">Panier repas</label>
<select
value={panierRepas}
onChange={(e) => setPanierRepas(e.target.value)}
className="w-full p-2 border rounded"
>
<option value="">Sélectionner...</option>
<option value="Oui">Oui</option>
<option value="Non">Non</option>
</select>
</div>
</div>
<div>
<label className="text-xs text-muted-foreground">Précisions salaire</label>
<Textarea
rows={3}
value={precisionsSalaire}
onChange={(e) => setPrecisionsSalaire(e.target.value)}
placeholder="Précisions sur le calcul du salaire"
/>
</div>
</div>
</CardContent>
</Card>
{/* Card 5: Temps de travail */}
<Card className="rounded-3xl">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CalendarRange className="size-5" /> Temps de travail
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-6">
{/* Champs conditionnels selon la catégorie professionnelle */}
<div className="mt-4">
{!useHeuresMode ? (
<>
<h4 className="text-sm font-medium mb-3">Répétitions et représentations</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-xs text-muted-foreground">Nombre de représentations</label>
<div className="flex items-center gap-2">
<Input
type="number"
min={0}
value={nbRepresentations}
onChange={(e) => setNbRepresentations(e.target.value === "" ? "" : Number(e.target.value))}
className="flex-1"
/>
<button
type="button"
onClick={() => setDatesRepresentationsOpen(true)}
className="px-3 py-2 rounded-lg border bg-white text-sm hover:bg-slate-50 transition whitespace-nowrap"
title="Préciser les dates et quantités"
>
Préciser
</button>
</div>
</div>
<div>
<label className="text-xs text-muted-foreground">Nombre de services de répétition / Durée</label>
<div className="flex items-center gap-2">
<Input
type="number"
min={0}
value={nbServicesRepetition}
onChange={(e) => setNbServicesRepetition(e.target.value === "" ? "" : Number(e.target.value))}
placeholder="Nombre"
className="flex-1"
/>
<span className="text-slate-400 font-medium">×</span>
{typeof nbServicesRepetition === "number" && nbServicesRepetition > 0 && (
<select
value={durationServices}
onChange={(e) => setDurationServices(e.target.value as "3" | "4")}
className="px-3 py-2 rounded-lg border bg-white text-sm"
title="Durée des services de répétition"
>
<option value="3">3h</option>
<option value="4">4h</option>
</select>
)}
<button
type="button"
onClick={() => setDatesRepetitionsOpen(true)}
className="px-3 py-2 rounded-lg border bg-white text-sm hover:bg-slate-50 transition whitespace-nowrap"
title="Préciser les dates et quantités"
>
Préciser
</button>
</div>
</div>
<div>
<label className="text-xs text-muted-foreground">Dates de représentations</label>
<div className="flex items-center gap-2">
<div className="flex-1 px-3 py-2 rounded-lg border bg-slate-50 text-sm text-slate-700 min-h-[42px] flex items-center">
{datesRepresentationsDisplay || (datesRepresentations ? datesRepresentations : "Cliquez pour sélectionner…")}
</div>
<button
type="button"
onClick={() => setDatesRepresentationsOpen(true)}
className="px-3 py-2 rounded-lg border bg-white text-sm hover:bg-slate-50 transition whitespace-nowrap"
>
Modifier
</button>
</div>
</div>
<div>
<label className="text-xs text-muted-foreground">Dates de répétitions</label>
<div className="flex items-center gap-2">
<div className="flex-1 px-3 py-2 rounded-lg border bg-slate-50 text-sm text-slate-700 min-h-[42px] flex items-center">
{datesRepetitionsDisplay || (datesRepetitions ? datesRepetitions : "Cliquez pour sélectionner…")}
</div>
<button
type="button"
onClick={() => setDatesRepetitionsOpen(true)}
className="px-3 py-2 rounded-lg border bg-white text-sm hover:bg-slate-50 transition whitespace-nowrap"
>
Modifier
</button>
</div>
</div>
</div>
{/* Calendriers pour représentations et répétitions */}
<DatePickerCalendar
isOpen={datesRepresentationsOpen}
onClose={() => setDatesRepresentationsOpen(false)}
onApply={handleDatesRepresentationsApply}
initialDates={datesRepresentations ? datesRepresentations.split(", ") : []}
title="Sélectionner les dates de représentations"
minDate={contract.start_date?.slice(0, 10)}
maxDate={contract.end_date?.slice(0, 10)}
/>
<DatePickerCalendar
isOpen={datesRepetitionsOpen}
onClose={() => setDatesRepetitionsOpen(false)}
onApply={handleDatesRepetitionsApply}
initialDates={datesRepetitions ? datesRepetitions.split(", ") : []}
title="Sélectionner les dates de répétitions"
minDate={contract.start_date?.slice(0, 10)}
maxDate={contract.end_date?.slice(0, 10)}
/>
{/* Nouveaux champs pour heures total et par jour */}
<h4 className="text-sm font-medium mb-3 mt-6">Heures de travail</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-xs text-muted-foreground">Nombre d'heures total</label>
<Input
type="number"
min={0}
step="0.5"
value={nombreHeuresTotal}
onChange={(e) => setNombreHeuresTotal(e.target.value === "" ? "" : Number(e.target.value))}
placeholder="ex : 24"
/>
</div>
<div>
<label className="text-xs text-muted-foreground">Nombre d'heures par jour</label>
<Input
type="number"
min={0}
step="0.5"
value={nombreHeuresParJour}
onChange={(e) => setNombreHeuresParJour(e.target.value === "" ? "" : Number(e.target.value))}
placeholder="ex : 8"
/>
</div>
<div className="md:col-span-2">
<label className="text-xs text-muted-foreground">Jours de travail</label>
<Textarea
rows={3}
value={joursTravail}
onChange={(e) => setJoursTravail(e.target.value)}
placeholder="ex : 12/10, 13/10, 24/10"
/>
</div>
</div>
</>
) : (
<>
<h4 className="text-sm font-medium mb-3">Heures de travail</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-xs text-muted-foreground">Heures de travail au total</label>
<div className="flex items-center gap-2">
<Input
type="number"
min={0}
value={heuresTotal}
onChange={(e) => setHeuresTotal(e.target.value === "" ? "" : Number(e.target.value))}
placeholder="ex : 3"
/>
<select
value={minutesTotal}
onChange={(e) => setMinutesTotal(e.target.value as "0" | "30")}
className="px-3 py-2 rounded-lg border bg-white text-sm"
>
<option value="0">+ 00 min</option>
<option value="30">+ 30 min</option>
</select>
</div>
</div>
<div>
<label className="text-xs text-muted-foreground">Jours de travail</label>
<Textarea
rows={3}
value={joursTravail}
onChange={(e) => setJoursTravail(e.target.value)}
placeholder="ex : 12/10, 13/10, 24/10"
/>
</div>
</div>
{/* Champ cachets pour metteur en scène */}
{professionPick?.code === "MET040" && (
<div className="mt-4">
<label className="text-xs text-muted-foreground">Nombre de représentations (cachets)</label>
<Input
type="number"
min={0}
value={nbRepresentations}
onChange={(e) => setNbRepresentations(e.target.value === "" ? "" : Number(e.target.value))}
placeholder="ex : 2"
/>
<p className="text-[10px] text-muted-foreground mt-1">
Nombre de cachets de représentation pour le metteur en scène.
</p>
</div>
)}
</>
)}
</div>
{/* Séparateur */}
<Separator className="my-6" />
{/* Sous-section: Temps de travail réel (informatif pour le client) */}
<div className="mt-6">
<div className="flex items-center gap-2 mb-4">
<div className="h-px flex-1 bg-gradient-to-r from-slate-200 to-transparent"></div>
<h4 className="text-sm font-semibold text-slate-700">Temps de travail réel (informatif client)</h4>
<div className="h-px flex-1 bg-gradient-to-l from-slate-200 to-transparent"></div>
</div>
<p className="text-xs text-muted-foreground mb-4">
Ces informations sont purement informatives pour le client et ne sont pas utilisées pour la génération du PDF du contrat.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-xs text-muted-foreground">Jours travaillés</label>
<Input
value={joursTravailReel}
onChange={(e) => setJoursTravailReel(e.target.value)}
placeholder="ex: 15/01, 16/01, 17/01"
/>
</div>
<div>
<label className="text-xs text-muted-foreground">Nbre de représentations</label>
<Input
type="number"
min={0}
value={nbRepresentationsReel}
onChange={(e) => setNbRepresentationsReel(e.target.value === "" ? "" : Number(e.target.value))}
placeholder="0"
/>
</div>
<div>
<label className="text-xs text-muted-foreground">Nbre de services répétitions</label>
<Input
type="number"
min={0}
value={nbServicesRepetitionsReel}
onChange={(e) => setNbServicesRepetitionsReel(e.target.value === "" ? "" : Number(e.target.value))}
placeholder="0"
/>
</div>
<div>
<label className="text-xs text-muted-foreground">Nbre d'heures répétitions</label>
<Input
type="number"
min={0}
step="0.5"
value={nbHeuresRepetitionsReel}
onChange={(e) => setNbHeuresRepetitionsReel(e.target.value === "" ? "" : Number(e.target.value))}
placeholder="0"
/>
</div>
<div>
<label className="text-xs text-muted-foreground">Nbre d'heures Annexes 8</label>
<Input
type="number"
min={0}
step="0.5"
value={nbHeuresAnnexesReel}
onChange={(e) => setNbHeuresAnnexesReel(e.target.value === "" ? "" : Number(e.target.value))}
placeholder="0"
/>
</div>
<div>
<label className="text-xs text-muted-foreground">Nombre de cachets AEM</label>
<Input
type="number"
min={0}
value={nbCachetsAemReel}
onChange={(e) => setNbCachetsAemReel(e.target.value === "" ? "" : Number(e.target.value))}
placeholder="0"
/>
</div>
<div>
<label className="text-xs text-muted-foreground">Nombre d'heures AEM</label>
<Input
type="number"
min={0}
step="0.5"
value={nbHeuresAemReel}
onChange={(e) => setNbHeuresAemReel(e.target.value === "" ? "" : Number(e.target.value))}
placeholder="0"
/>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Card 6: Informations complémentaires */}
<Card className="rounded-3xl">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<StickyNote className="size-5" /> Informations complémentaires
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div>
<label className="text-xs text-muted-foreground">S3 key (contrat PDF)</label>
<Input
value={form.contract_pdf_s3_key}
onChange={(e) =>
handleContractChange("contract_pdf_s3_key", e.target.value)
}
/>
</div>
<div>
<label className="text-xs text-muted-foreground">Note interne</label>
<Textarea
rows={4}
value={form.notes ?? ""}
onChange={(e) => handleContractChange("notes", e.target.value)}
/>
</div>
<div>
<label className="text-xs text-muted-foreground">Autre précision durée</label>
<Textarea
rows={2}
value={autrePrecisionDuree}
onChange={(e) => setAutrePrecisionDuree(e.target.value)}
placeholder="Autre précision sur la durée du contrat"
/>
</div>
<div className="md:col-span-2">
<label className="text-xs text-muted-foreground">Autre précision salaire</label>
<Textarea
rows={2}
value={autrePrecisionSalaire}
onChange={(e) => setAutrePrecisionSalaire(e.target.value)}
placeholder="Autre précision sur le salaire"
/>
</div>
<div className="md:col-span-2">
<label className="text-xs text-muted-foreground">Définition "Cachet de répétition"</label>
<div className="relative">
<div
className="p-3 bg-gray-50 border rounded-md text-sm cursor-pointer hover:bg-gray-100 transition-colors"
onClick={async () => {
const ccn = organizationDetails?.organization_details?.ccn || "Convention Collective Nationale des Entreprises Artistiques & Culturelles";
const text = `On entend par 'Cachet de répétition' une journée de répétitions, qu'elle soit constituée d'un ou deux services, conformément aux dispositions de la ${ccn}`;
try {
await navigator.clipboard.writeText(text);
toast.success("Texte copié dans le presse-papiers");
} catch (err) {
console.error("Erreur lors de la copie:", err);
toast.error("Erreur lors de la copie");
}
}}
title="Cliquer pour copier"
>
On entend par 'Cachet de répétition' une journée de répétitions, qu'elle soit constituée d'un ou deux services, conformément aux dispositions de la {organizationDetails?.organization_details?.ccn || "Convention Collective Nationale des Entreprises Artistiques & Culturelles"}
</div>
<div className="absolute top-2 right-2 text-xs text-gray-400 pointer-events-none">
📋 Cliquer pour copier
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
<div className="space-y-6">
{/* Calendrier pour dates travaillées - Modal global */}
<DatePickerCalendar
isOpen={joursTravailOpen}
onClose={() => setJoursTravailOpen(false)}
onApply={handleJoursTravailApply}
initialDates={joursTravail ? joursTravail.split(", ") : []}
title="Sélectionner les dates travaillées"
minDate={contract.date_debut}
maxDate={contract.date_fin}
/>
{/* Card de suivi de signature électronique */}
<Card className="rounded-3xl overflow-hidden">
<CardHeader className={`${getSignatureStatus().bgColor} ${getSignatureStatus().borderColor} border-b`}>
<CardTitle className="flex items-center justify-between">
<div className="flex items-center gap-3">
<PenTool className="size-5 text-indigo-600" />
<span>Signature électronique</span>
</div>
<div className={`flex items-center gap-2 px-3 py-1 rounded-full ${getSignatureStatus().bgColor} ${getSignatureStatus().color} border ${getSignatureStatus().borderColor}`}>
{(() => {
const IconComponent = getSignatureStatus().icon;
return <IconComponent className="size-4" />;
})()}
<span className="text-sm font-medium">{getSignatureStatus().label}</span>
</div>
</CardTitle>
</CardHeader>
<CardContent className="p-6">
<div className="space-y-4">
{/* Affichage détaillé du statut */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
<div className="flex flex-col space-y-1">
<span className="text-gray-500 font-medium">État de la demande</span>
<span className={`font-semibold ${
(form.etat_de_la_demande || contract.etat_de_la_demande) === "Traitée" ? "text-green-600" : "text-gray-600"
}`}>
{form.etat_de_la_demande || contract.etat_de_la_demande || "Non défini"}
</span>
</div>
<div className="flex flex-col space-y-1">
<span className="text-gray-500 font-medium">Signature employeur</span>
<span className={`font-semibold ${
(form.contrat_signe_par_employeur || contract.contrat_signe_par_employeur) === "Oui" ? "text-green-600" : "text-gray-600"
}`}>
{form.contrat_signe_par_employeur || contract.contrat_signe_par_employeur || "Non"}
</span>
</div>
<div className="flex flex-col space-y-1">
<span className="text-gray-500 font-medium">Signature salarié</span>
<span className={`font-semibold ${
(form.contrat_signe || contract.contrat_signe) === "Oui" ? "text-green-600" : "text-gray-600"
}`}>
{form.contrat_signe || contract.contrat_signe || "Non"}
</span>
</div>
</div>
{/* Bouton de relance si en attente employeur */}
{getSignatureStatus().status === "waiting_employer" && (
<div className="pt-4 border-t">
<Button
variant="outline"
size="sm"
className="flex items-center gap-2 text-orange-600 border-orange-200 hover:bg-orange-50"
onClick={handleOpenReminderModal}
disabled={isLoadingOrgData}
>
{isLoadingOrgData ? (
<RefreshCw className="size-4 animate-spin" />
) : (
<Send className="size-4" />
)}
{isLoadingOrgData ? "Chargement..." : "Relancer l'employeur"}
</Button>
</div>
)}
{/* Bouton "Marquer comme non signé" à la place de la progression */}
<div className="pt-4">
<Button
variant="outline"
size="sm"
className="flex items-center gap-2 text-red-600 border-red-200 hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={handleMarkAsUnsigned}
disabled={isBothUnsigned || isMarkingUnsigned}
>
{isMarkingUnsigned ? (
<RefreshCw className="size-4 animate-spin" />
) : (
<XCircle className="size-4" />
)}
{isMarkingUnsigned
? "Marquage en cours..."
: isBothUnsigned
? "Déjà marqué comme non signé"
: "Marquer comme non signé"
}
</Button>
{isBothUnsigned && (
<p className="text-xs text-gray-500 mt-2">
Les deux parties sont déjà marquées comme n'ayant pas signé le contrat.
</p>
)}
</div>
</div>
</CardContent>
</Card>
<Card className="rounded-3xl">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="size-5" /> Documents
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{/* Contrat CDDU non signé */}
{contract.contract_pdf_url ? (
<div
onClick={openPdf}
className="flex items-center gap-3 p-3 border rounded-lg cursor-pointer hover:bg-muted/50 transition-colors"
>
<FileText className="size-5 text-blue-600" />
<div className="flex-1">
<p className="font-medium">Contrat CDDU (version préliminaire)</p>
<p className="text-sm text-muted-foreground">
{contract.contract_pdf_filename || 'contrat.pdf'}
</p>
</div>
</div>
) : null}
{/* Contrat CDDU signé */}
{loadingSignedPdf ? (
<div className="flex items-center gap-3 p-3 border rounded-lg bg-gray-50">
<RefreshCw className="size-5 text-gray-400 animate-spin" />
<div className="flex-1">
<p className="font-medium text-gray-600">Vérification du contrat signé...</p>
</div>
</div>
) : signedPdfData?.hasSignedPdf && signedPdfData?.signedUrl ? (
<div
onClick={openSignedPdf}
className="flex items-center gap-3 p-3 border-2 border-green-200 bg-green-50 rounded-lg cursor-pointer hover:bg-green-100 transition-colors"
>
<CheckCircle2 className="size-5 text-green-600" />
<div className="flex-1">
<p className="font-medium text-green-800">Contrat CDDU signé</p>
<p className="text-sm text-green-600">
Document électroniquement signé
</p>
</div>
<div className="text-xs text-green-600">
Cliquer pour ouvrir
</div>
</div>
) : null}
{/* Message quand aucun document n'est disponible */}
{!contract.contract_pdf_url && !loadingSignedPdf && !signedPdfData?.hasSignedPdf && (
<div className="text-center text-muted-foreground py-4">
<FileText className="size-8 mx-auto mb-2 opacity-50" />
<p>Aucun document généré</p>
<p className="text-sm">Utilisez le bouton "Créer le PDF" pour générer le contrat</p>
</div>
)}
{/* Bouton pour upload manuel du contrat signé */}
<Separator className="my-4" />
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">Upload manuel</p>
<Button
variant="outline"
size="sm"
onClick={() => setIsUploadModalOpen(true)}
className="w-full"
>
<Upload className="size-4 mr-2" />
Ajouter le contrat signé manuellement
</Button>
<p className="text-xs text-muted-foreground">
Si vous avez reçu le contrat signé par email ou autre moyen
</p>
</div>
</CardContent>
</Card>
<Card className="rounded-3xl">
<CardHeader>
<CardTitle className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<CalendarRange className="size-5" /> Paies ({rows.length})
</div>
{contractType === "CDDU_MONO" && (
<Button
onClick={() => handleOpenPayslipModal()}
variant="outline"
size="sm"
className="rounded-2xl"
>
<FilePlus2 className="size-4 mr-2" />
Ajouter une paie
</Button>
)}
</div>
{(contractType === "RG" || contractType === "CDDU_MULTI") && (
<div className="flex items-center justify-between">
<span className="text-sm font-normal text-muted-foreground bg-slate-100 px-3 py-1.5 rounded-full">
{contractType === "RG" ? "Régime Général" : "CDDU Multi-mois"}
</span>
<Button
onClick={() => handleOpenPayslipModal()}
variant="outline"
size="sm"
className="rounded-2xl"
>
<FilePlus2 className="size-4 mr-2" />
Ajouter une paie
</Button>
</div>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{rows.length === 0 ? (
<div className="text-center text-gray-500 py-8">
<CalendarRange className="size-12 mx-auto mb-4 text-gray-300" />
<p>Aucune paie enregistrée</p>
<p className="text-sm">Cliquez sur "Ajouter une paie" pour commencer</p>
</div>
) : (
<>
{contractType === "CDDU_MONO" ? (
// Affichage normal pour CDDU mono-mois
<div className="space-y-3">
{displayedPayslips.map((payslip, i) => (
<PayslipCard
key={payslip.id ?? i}
payslip={payslip}
index={i}
contractId={contract.id}
onClick={() => handleOpenPayslipModal(payslip)}
onUploadComplete={handlePayslipUploadComplete}
/>
))}
</div>
) : (
// Affichage compact pour RG et CDDU multi-mois
<>
<div className="space-y-2">
{displayedPayslips.map((payslip, i) => (
<div
key={payslip.id ?? i}
onClick={() => handleOpenPayslipModal(payslip)}
className="p-3 border rounded-xl hover:bg-slate-50 cursor-pointer transition-colors"
>
<div className="flex items-start gap-6">
<div className="bg-blue-100 text-blue-800 px-2 py-1 rounded-full text-xs font-medium shrink-0">
#{payslip.pay_number || (payslipPage * payslipsPerPage + i + 1)}
</div>
<div className="flex-1 min-w-0">
<div className="font-normal text-sm text-gray-600 whitespace-nowrap mb-3">
{payslip.period_start && payslip.period_end ? (
`${new Date(payslip.period_start).toLocaleDateString('fr-FR', { month: 'short', year: 'numeric' })}`
) : (
"Période non définie"
)}
</div>
<div className="flex gap-1.5 flex-nowrap">
{payslip.storage_path && (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-50 text-green-600 whitespace-nowrap">
<CheckCircle2 className="size-3 mr-1" />
PDF
</span>
)}
{payslip.processed ? (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-50 text-green-600 whitespace-nowrap">
Traitée
</span>
) : (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-red-50 text-red-600 whitespace-nowrap">
À traiter
</span>
)}
{/* Statut AEM */}
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap ${
payslip.aem_status === 'OK'
? 'bg-green-50 text-green-600'
: payslip.aem_status === 'KO'
? 'bg-red-50 text-red-600'
: 'bg-gray-50 text-gray-600'
}`}>
AEM: {payslip.aem_status || 'N/A'}
</span>
</div>
</div>
<div className="flex gap-10 text-right shrink-0 ml-10">
<div className="leading-tight">
<div className="font-semibold text-sm">
{payslip.gross_amount ? `${parseFloat(payslip.gross_amount).toFixed(2)}€` : ""}
</div>
<div className="text-xs text-gray-500">Brut</div>
</div>
<div className="leading-tight">
<div className="font-semibold text-sm">
{payslip.net_after_withholding ? `${parseFloat(payslip.net_after_withholding).toFixed(2)}€` : ""}
</div>
<div className="text-xs text-gray-500">Net à payer</div>
</div>
</div>
</div>
</div>
))}
</div>
{needsPagination && (
<div className="flex items-center justify-center gap-2 pt-2">
<Button
onClick={() => setPayslipPage(prev => Math.max(0, prev - 1))}
disabled={payslipPage === 0}
variant="outline"
size="sm"
className="rounded-lg"
>
Précédent
</Button>
<span className="text-sm text-muted-foreground px-3">
Page {payslipPage + 1} sur {totalPayslipPages}
</span>
<Button
onClick={() => setPayslipPage(prev => Math.min(totalPayslipPages - 1, prev + 1))}
disabled={payslipPage >= totalPayslipPages - 1}
variant="outline"
size="sm"
className="rounded-lg"
>
Suivant
</Button>
</div>
)}
</>
)}
</>
)}
</CardContent>
</Card>
{/* Section Notes */}
<div className="rounded-3xl overflow-hidden">
<NotesSection
contractId={contract.id}
contractRef={contract.contract_number || contract.reference}
source="Renaud [Staff Odentas]"
/>
</div>
</div>
</div>
</div>
{/* Loading Modals */}
<LoadingModal
isOpen={isSaving}
title="Sauvegarde en cours..."
description="Veuillez patienter pendant la sauvegarde du contrat."
success={showSaveSuccess}
successMessage="Contrat sauvegardé avec succès !"
onClose={() => setShowSaveSuccess(false)}
/>
<LoadingModal
isOpen={isGeneratingPdf}
title="Génération du PDF en cours..."
description="Création du document de contrat, cela peut prendre quelques instants."
success={showPdfSuccess}
successMessage="PDF créé avec succès !"
onClose={() => setShowPdfSuccess(false)}
/>
{/* Payslip Modal */}
<PayslipModal
isOpen={isPayslipModalOpen}
payslip={editingPayslip}
contractId={contract.id}
contractNumber={contract.contract_number}
onClose={handleClosePayslipModal}
onSuccess={handlePayslipModalSuccess}
/>
{/* Employer Reminder Modal */}
<EmployerReminderModal
isOpen={isReminderModalOpen}
onClose={handleCloseReminderModal}
onConfirm={handleSendEmployerReminder}
isLoading={isSendingReminder}
contractData={{
reference: contract.reference || contract.contract_number,
employee_name: contract.employee_name,
employer_email: organizationData?.email_signature,
employer_name: organizationData?.organization_name
}}
/>
{/* E-Sign Confirmation Modal */}
<ESignConfirmModal
isOpen={showESignConfirmModal}
onClose={() => setShowESignConfirmModal(false)}
onConfirm={handleConfirmESign}
contractReference={reference || contract.reference || contract.contract_number}
employeeName={salarie?.nom || contract.employee_name}
employeeEmail={verifiedEmployeeEmail}
employerEmail={verifiedEmployerEmail}
isLoading={isLaunchingSignature}
/>
{/* Manual Signed Contract Upload Modal */}
<ManualSignedContractUpload
contractId={contract.id}
contractNumber={contract.contract_number}
open={isUploadModalOpen}
onOpenChange={setIsUploadModalOpen}
onSuccess={() => {
// Invalider la query pour rafraîchir les données du contrat signé
queryClient.invalidateQueries({ queryKey: ["signed-contract-pdf", contract.id] });
}}
/>
{/* Dates Quantity Modal for precise selection */}
<DatesQuantityModal
isOpen={quantityModalOpen}
onClose={() => {
setQuantityModalOpen(false);
setPendingDates([]);
}}
selectedDates={pendingDates}
dateType={quantityModalType || "jours_travail"}
onApply={handleQuantityApply}
allowSkipHoursByDay={quantityModalType === "jours_travail"}
/>
{/* Cancel Contract Modal */}
<CancelContractModal
isOpen={showCancelModal}
onClose={() => setShowCancelModal(false)}
onConfirm={handleCancelContract}
isCancelling={isCancelling}
contractInfo={{
contract_number: contract.contract_number || contract.reference,
employee_name: contract.employee_name || contract.salaries?.salarie,
}}
/>
</div>
);
}