espace-paie-odentas/components/staff/CreatePayslipModal.tsx
odentas dd570d4509 feat: Améliorations majeures des contrats et fiches de paie
- Ajout détails cachets/répétitions/heures au modal ContractDetails
- Card verte avec validation quand tous les contrats ont une fiche de paie
- Système complet de création de fiches de paie avec recherche et vérification
- Modal liste des contrats sans paie avec création directe
- Amélioration édition dates dans PayslipDetailsModal
- Optimisation recherche contrats (ordre des filtres)
- Augmentation limite pagination ContractsGrid à 200
- Ajout logs debug génération PDF logo
- Script SQL vérification cohérence structure/organisation
2025-11-27 20:31:11 +01:00

691 lines
27 KiB
TypeScript

// components/staff/CreatePayslipModal.tsx
"use client";
import React, { useState, useEffect } from "react";
import { X, FileText, Search, Loader, AlertCircle } from "lucide-react";
import { toast } from "sonner";
type Contract = {
id: string;
contract_number?: string | null;
employee_name?: string | null;
structure?: string | null;
type_de_contrat?: string | null;
start_date?: string | null;
end_date?: string | null;
production_name?: string | null;
n_objet?: string | null;
objet_spectacle?: string | null;
salaries?: {
salarie?: string | null;
nom?: string | null;
prenom?: string | null;
} | null;
organizations?: {
name?: string | null;
} | null;
};
type CreatePayslipModalProps = {
isOpen: boolean;
onClose: () => void;
onPayslipCreated?: () => void;
preselectedContractId?: string | null;
};
function formatDate(dateString: string | null | undefined): string {
if (!dateString) return "—";
try {
const date = new Date(dateString);
return date.toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
} catch {
return "—";
}
}
function formatEmployeeName(contract: Contract): string {
if (contract.salaries?.salarie) {
return contract.salaries.salarie;
}
if (contract.salaries?.nom || contract.salaries?.prenom) {
const nom = (contract.salaries.nom || '').toUpperCase().trim();
const prenom = (contract.salaries.prenom || '').trim();
return [nom, prenom].filter(Boolean).join(' ');
}
if (contract.employee_name) {
return contract.employee_name;
}
return "—";
}
export default function CreatePayslipModal({
isOpen,
onClose,
onPayslipCreated,
preselectedContractId
}: CreatePayslipModalProps) {
const [searchQuery, setSearchQuery] = useState("");
const [contracts, setContracts] = useState<Contract[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [selectedContract, setSelectedContract] = useState<Contract | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [isMultiMonth, setIsMultiMonth] = useState(false);
const [suggestedPayNumber, setSuggestedPayNumber] = useState<number | null>(null);
const [hasExistingPayslip, setHasExistingPayslip] = useState(false);
const [existingPayslipMessage, setExistingPayslipMessage] = useState("");
const [isCheckingExisting, setIsCheckingExisting] = useState(false);
const [isLoadingPreselected, setIsLoadingPreselected] = useState(false);
// Form fields
const [periodStart, setPeriodStart] = useState("");
const [periodEnd, setPeriodEnd] = useState("");
const [payNumber, setPayNumber] = useState("");
const [grossAmount, setGrossAmount] = useState("");
const [netAmount, setNetAmount] = useState("");
const [netAfterWithholding, setNetAfterWithholding] = useState("");
const [employerCost, setEmployerCost] = useState("");
// Load preselected contract
useEffect(() => {
if (!isOpen || !preselectedContractId) return;
const loadPreselectedContract = async () => {
setIsLoadingPreselected(true);
try {
// Fetch contract by ID directly
const response = await fetch(`/api/staff/contracts/${preselectedContractId}`);
if (!response.ok) {
throw new Error('Erreur lors du chargement du contrat');
}
const data = await response.json();
const contract = data.contract || data;
// Set the selected contract
setSelectedContract(contract);
setSearchQuery("");
setContracts([]);
setHasExistingPayslip(false);
setExistingPayslipMessage("");
// Déterminer si le contrat est multi-mois
if (contract.start_date && contract.end_date) {
const startDate = new Date(contract.start_date);
const endDate = new Date(contract.end_date);
const isMulti = startDate.getMonth() !== endDate.getMonth() ||
startDate.getFullYear() !== endDate.getFullYear();
setIsMultiMonth(isMulti);
// Si multi-mois, récupérer le dernier numéro de paie
if (isMulti) {
try {
const payNumResponse = await fetch(`/api/staff/payslips/last-pay-number?contract_id=${contract.id}`);
if (payNumResponse.ok) {
const payNumData = await payNumResponse.json();
const nextNumber = (payNumData.last_pay_number || 0) + 1;
setSuggestedPayNumber(nextNumber);
setPayNumber(String(nextNumber));
}
} catch (err) {
console.error('Error fetching last pay number:', err);
}
} else {
// Si mono-mois, vérifier si une paie existe déjà
setIsCheckingExisting(true);
try {
const checkResponse = await fetch(`/api/staff/payslips/check-existing?contract_id=${contract.id}`);
if (checkResponse.ok) {
const checkData = await checkResponse.json();
if (checkData.has_existing) {
setHasExistingPayslip(true);
setExistingPayslipMessage(checkData.message || "Une fiche de paie existe déjà pour ce contrat");
}
}
} catch (err) {
console.error('Error checking existing payslip:', err);
} finally {
setIsCheckingExisting(false);
}
}
}
} catch (error) {
console.error('Error loading preselected contract:', error);
toast.error('Erreur lors du chargement du contrat');
} finally {
setIsLoadingPreselected(false);
}
};
loadPreselectedContract();
}, [isOpen, preselectedContractId]);
// Search contracts
useEffect(() => {
if (!searchQuery.trim() || !isOpen) {
setContracts([]);
return;
}
const timer = setTimeout(async () => {
setIsSearching(true);
try {
const response = await fetch(`/api/staff/payslips/search-contracts?q=${encodeURIComponent(searchQuery)}`);
if (!response.ok) {
throw new Error('Erreur lors de la recherche');
}
const data = await response.json();
setContracts(data.contracts || []);
} catch (error) {
console.error('Error searching contracts:', error);
toast.error('Erreur lors de la recherche des contrats');
setContracts([]);
} finally {
setIsSearching(false);
}
}, 300);
return () => clearTimeout(timer);
}, [searchQuery, isOpen]);
// Reset form when modal closes
useEffect(() => {
if (!isOpen) {
setSearchQuery("");
setContracts([]);
setSelectedContract(null);
setIsMultiMonth(false);
setSuggestedPayNumber(null);
setHasExistingPayslip(false);
setExistingPayslipMessage("");
setIsCheckingExisting(false);
setPeriodStart("");
setPeriodEnd("");
setPayNumber("");
setGrossAmount("");
setNetAmount("");
setNetAfterWithholding("");
setEmployerCost("");
}
}, [isOpen]);
// Keyboard navigation
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
const handleSelectContract = (contract: Contract) => {
setSelectedContract(contract);
setSearchQuery("");
setContracts([]);
setHasExistingPayslip(false);
setExistingPayslipMessage("");
// Déterminer si le contrat est multi-mois
if (contract.start_date && contract.end_date) {
const startDate = new Date(contract.start_date);
const endDate = new Date(contract.end_date);
const isMulti = startDate.getMonth() !== endDate.getMonth() ||
startDate.getFullYear() !== endDate.getFullYear();
setIsMultiMonth(isMulti);
// Si multi-mois, récupérer le dernier numéro de paie
if (isMulti) {
fetchLastPayNumber(contract.id);
} else {
// Si mono-mois, vérifier si une paie existe déjà
checkExistingPayslip(contract.id);
}
}
};
const checkExistingPayslip = async (contractId: string) => {
setIsCheckingExisting(true);
try {
const response = await fetch(`/api/staff/payslips/check-existing?contract_id=${contractId}`);
if (response.ok) {
const data = await response.json();
if (data.has_existing) {
setHasExistingPayslip(true);
setExistingPayslipMessage(data.message || "Une fiche de paie existe déjà pour ce contrat");
}
}
} catch (error) {
console.error('Error checking existing payslip:', error);
} finally {
setIsCheckingExisting(false);
}
};
const fetchLastPayNumber = async (contractId: string) => {
try {
const response = await fetch(`/api/staff/payslips/last-pay-number?contract_id=${contractId}`);
if (response.ok) {
const data = await response.json();
const nextNumber = (data.last_pay_number || 0) + 1;
setSuggestedPayNumber(nextNumber);
setPayNumber(String(nextNumber));
}
} catch (error) {
console.error('Error fetching last pay number:', error);
}
};
const handleCreatePayslip = async (closeAfter: boolean = true) => {
if (!selectedContract) {
toast.error('Veuillez sélectionner un contrat');
return;
}
if (!periodStart || !periodEnd) {
toast.error('Veuillez renseigner les dates de période');
return;
}
if (isMultiMonth && !payNumber) {
toast.error('Veuillez renseigner le numéro de paie');
return;
}
setIsCreating(true);
try {
const response = await fetch('/api/staff/payslips/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contract_id: selectedContract.id,
period_start: periodStart,
period_end: periodEnd,
pay_number: isMultiMonth && payNumber ? parseInt(payNumber) : undefined,
gross_amount: grossAmount ? parseFloat(grossAmount) : null,
net_amount: netAmount ? parseFloat(netAmount) : null,
net_after_withholding: netAfterWithholding ? parseFloat(netAfterWithholding) : null,
employer_cost: employerCost ? parseFloat(employerCost) : null,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Erreur lors de la création');
}
toast.success('Fiche de paie créée avec succès');
if (closeAfter) {
onPayslipCreated?.();
onClose();
} else {
// Réinitialiser le formulaire mais garder le modal ouvert
onPayslipCreated?.();
setSelectedContract(null);
setSearchQuery("");
setContracts([]);
setIsMultiMonth(false);
setSuggestedPayNumber(null);
setHasExistingPayslip(false);
setExistingPayslipMessage("");
setPeriodStart("");
setPeriodEnd("");
setPayNumber("");
setGrossAmount("");
setNetAmount("");
setNetAfterWithholding("");
setEmployerCost("");
}
} catch (error) {
console.error('Error creating payslip:', error);
toast.error(error instanceof Error ? error.message : 'Erreur lors de la création de la fiche de paie');
} finally {
setIsCreating(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg w-full max-w-4xl max-h-[90vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b bg-gray-50">
<div className="flex items-center gap-3">
<FileText className="size-5 text-blue-600" />
<h2 className="text-lg font-semibold">Créer une fiche de paie</h2>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
>
<X className="size-5" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
{isLoadingPreselected ? (
<div className="flex items-center justify-center py-12">
<div className="text-center">
<Loader className="w-8 h-8 text-blue-600 animate-spin mx-auto mb-3" />
<p className="text-sm text-gray-600">Chargement du contrat...</p>
</div>
</div>
) : !selectedContract ? (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Rechercher un contrat
</label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-gray-400" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Nom salarié, n° contrat, production, organisation..."
className="w-full pl-10 pr-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
autoFocus
/>
{isSearching && (
<Loader className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-gray-400 animate-spin" />
)}
</div>
{/* Search results */}
{contracts.length > 0 && (
<div className="mt-3 border rounded-lg overflow-hidden max-h-96 overflow-y-auto">
{contracts.map((contract) => (
<button
key={contract.id}
onClick={() => handleSelectContract(contract)}
className="w-full text-left p-4 hover:bg-blue-50 border-b last:border-b-0 transition-colors"
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-900">
{formatEmployeeName(contract)}
</div>
<div className="text-sm text-gray-600 mt-1">
{contract.contract_number && (
<span className="mr-3">N° {contract.contract_number}</span>
)}
{contract.structure && (
<span className="mr-3">{contract.structure}</span>
)}
{contract.organizations?.name && (
<span className="mr-3">{contract.organizations.name}</span>
)}
</div>
{contract.production_name && (
<div className="text-sm text-gray-500 mt-1">
Production: {contract.production_name}
</div>
)}
{(contract.n_objet || contract.objet_spectacle) && (
<div className="text-sm text-gray-500">
N° Objet: {contract.n_objet || contract.objet_spectacle}
</div>
)}
</div>
<div className="text-sm text-gray-500 whitespace-nowrap">
{formatDate(contract.start_date)} - {formatDate(contract.end_date)}
</div>
</div>
</button>
))}
</div>
)}
{searchQuery.trim() && !isSearching && contracts.length === 0 && (
<div className="mt-3 p-4 bg-amber-50 border border-amber-200 rounded-lg text-sm text-amber-800 flex items-start gap-2">
<AlertCircle className="size-4 mt-0.5 flex-shrink-0" />
<p>Aucun contrat trouvé pour cette recherche</p>
</div>
)}
</div>
) : (
<div>
{/* Selected contract info */}
<div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<h3 className="font-medium text-gray-900">
{formatEmployeeName(selectedContract)}
</h3>
<div className="text-sm text-gray-600 mt-1">
{selectedContract.contract_number && (
<span className="mr-3">N° {selectedContract.contract_number}</span>
)}
{selectedContract.structure && (
<span className="mr-3">{selectedContract.structure}</span>
)}
{selectedContract.type_de_contrat && (
<span className="mr-3">{selectedContract.type_de_contrat === "CDD d'usage" ? "CDDU" : selectedContract.type_de_contrat}</span>
)}
{isMultiMonth && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-indigo-100 text-indigo-800">
Contrat multi-mois
</span>
)}
</div>
{selectedContract.production_name && (
<div className="text-sm text-gray-600 mt-1">
Production: {selectedContract.production_name}
</div>
)}
</div>
<button
onClick={() => setSelectedContract(null)}
className="text-sm text-blue-600 hover:text-blue-800"
>
Changer
</button>
</div>
</div>
{/* Alerte si une paie existe déjà */}
{isCheckingExisting && (
<div className="mb-4 p-4 bg-gray-50 border border-gray-200 rounded-lg flex items-center gap-2">
<Loader className="size-4 animate-spin text-gray-600" />
<p className="text-sm text-gray-600">Vérification des paies existantes...</p>
</div>
)}
{hasExistingPayslip && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg">
<div className="flex items-start gap-2">
<AlertCircle className="size-5 text-red-600 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<p className="text-sm font-medium text-red-800">
Impossible de créer une paie
</p>
<p className="text-sm text-red-700 mt-1">
{existingPayslipMessage}
</p>
</div>
</div>
</div>
)}
{/* Form fields */}
{!hasExistingPayslip && (
<div className="space-y-6">
{/* Section Numéro de paie (si multi-mois) */}
{isMultiMonth && (
<div className="space-y-4">
<h3 className="text-lg font-semibold text-slate-900 border-b pb-2">
Numéro de paie
</h3>
<div>
<label className="block text-sm font-medium text-slate-600 mb-1">
N° de paie <span className="text-red-500">*</span>
</label>
<input
type="number"
min="1"
value={payNumber}
onChange={(e) => setPayNumber(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
placeholder={suggestedPayNumber ? `Suggestion: ${suggestedPayNumber}` : "1"}
required
/>
{suggestedPayNumber && (
<p className="text-xs text-slate-500 mt-1">
Numéro suggéré basé sur les paies existantes: {suggestedPayNumber}
</p>
)}
</div>
</div>
)}
{/* Section Dates */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-slate-900 border-b pb-2">
Période et dates
</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-600 mb-1">
Date début période <span className="text-red-500">*</span>
</label>
<input
type="date"
value={periodStart}
onChange={(e) => setPeriodStart(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-600 mb-1">
Date fin période <span className="text-red-500">*</span>
</label>
<input
type="date"
value={periodEnd}
onChange={(e) => setPeriodEnd(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
required
/>
</div>
</div>
</div>
{/* Section Montants */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-slate-900 border-b pb-2">
Montants
</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-600 mb-1">
Salaire brut
</label>
<input
type="number"
step="0.01"
value={grossAmount}
onChange={(e) => setGrossAmount(e.target.value)}
placeholder="0.00"
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-600 mb-1">
Net avant PAS
</label>
<input
type="number"
step="0.01"
value={netAmount}
onChange={(e) => setNetAmount(e.target.value)}
placeholder="0.00"
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-600 mb-1">
Net à payer
</label>
<input
type="number"
step="0.01"
value={netAfterWithholding}
onChange={(e) => setNetAfterWithholding(e.target.value)}
placeholder="0.00"
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-600 mb-1">
Coût employeur
</label>
<input
type="number"
step="0.01"
value={employerCost}
onChange={(e) => setEmployerCost(e.target.value)}
placeholder="0.00"
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</div>
</div>
</div>
</div>
)}
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 p-4 border-t bg-gray-50">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50"
disabled={isCreating}
>
Annuler
</button>
{selectedContract && !hasExistingPayslip && (
<>
<button
onClick={() => handleCreatePayslip(false)}
disabled={isCreating || !periodStart || !periodEnd || isCheckingExisting}
className="px-4 py-2 text-sm font-medium text-blue-600 bg-white border border-blue-600 rounded-lg hover:bg-blue-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{isCreating && <Loader className="size-4 animate-spin" />}
Créer et ajouter une autre paie
</button>
<button
onClick={() => handleCreatePayslip(true)}
disabled={isCreating || !periodStart || !periodEnd || isCheckingExisting}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{isCreating && <Loader className="size-4 animate-spin" />}
Créer et fermer
</button>
</>
)}
</div>
</div>
</div>
);
}