- Ajout sous-header total net à payer sur page virements-salaires - Migration transfer_done_at pour tracking précis des virements - Nouvelle page saisie tableau pour création factures en masse - APIs bulk pour mise à jour dates signature et jours technicien - API demande mandat SEPA avec email template - Webhook DocuSeal pour signature contrats (mode TEST) - Composants modaux détails et vérification PDF fiches de paie - Upload/suppression/remplacement PDFs dans PayslipsGrid - Amélioration affichage colonnes et filtres grilles contrats/paies - Template email mandat SEPA avec sous-texte CTA - APIs bulk facturation (création, update statut/date paiement) - API clients sans facture pour période donnée - Corrections calculs dates et montants avec auto-remplissage
832 lines
32 KiB
TypeScript
832 lines
32 KiB
TypeScript
// components/staff/PayslipDetailsModal.tsx
|
|
"use client";
|
|
|
|
import React, { useState, useEffect, useRef, DragEvent } from "react";
|
|
import { X, ChevronLeft, ChevronRight, FileText, Loader, Edit, Save, Upload, CheckCircle2, Loader2, ExternalLink, Trash2, RefreshCw } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
|
|
type PayslipDetails = {
|
|
id: string;
|
|
contract_id: string;
|
|
period_start?: string | null;
|
|
period_end?: string | null;
|
|
period_month?: string | null;
|
|
pay_number?: number | null;
|
|
pay_date?: string | null;
|
|
gross_amount?: string | number | null;
|
|
net_amount?: string | number | null;
|
|
net_after_withholding?: string | number | null;
|
|
employer_cost?: string | number | null;
|
|
processed?: boolean | null;
|
|
aem_status?: string | null;
|
|
transfer_done?: boolean | null;
|
|
organization_id?: string | null;
|
|
created_at?: string | null;
|
|
storage_path?: string | null;
|
|
cddu_contracts?: {
|
|
id: string;
|
|
contract_number?: string | null;
|
|
employee_name?: string | null;
|
|
employee_id?: string | null;
|
|
structure?: string | null;
|
|
type_de_contrat?: string | null;
|
|
org_id?: string | null;
|
|
salaries?: {
|
|
salarie?: string | null;
|
|
nom?: string | null;
|
|
prenom?: string | null;
|
|
} | null;
|
|
} | null;
|
|
};
|
|
|
|
type PayslipDetailsModalProps = {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
payslipIds: string[];
|
|
payslips: any[];
|
|
onPayslipUpdated?: (updatedPayslip: any) => void;
|
|
};
|
|
|
|
// Utility function to format dates as DD/MM/YYYY
|
|
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 "—";
|
|
}
|
|
}
|
|
|
|
// Utility function to format currency
|
|
function formatCurrency(value: string | number | null | undefined): string {
|
|
if (!value) return "—";
|
|
const num = typeof value === 'string' ? parseFloat(value) : value;
|
|
if (isNaN(num)) return "—";
|
|
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(num);
|
|
}
|
|
|
|
// Utility function to format employee name
|
|
function formatEmployeeName(payslip: PayslipDetails): string {
|
|
const contract = payslip.cddu_contracts;
|
|
if (!contract) return "—";
|
|
|
|
// Priorité : utiliser salaries.salarie si disponible
|
|
if (contract.salaries?.salarie) {
|
|
return contract.salaries.salarie;
|
|
}
|
|
|
|
// Construire depuis nom + prénom
|
|
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(' ');
|
|
}
|
|
|
|
// Fallback : utiliser employee_name
|
|
if (contract.employee_name) {
|
|
const parts = contract.employee_name.trim().split(' ');
|
|
if (parts.length >= 2) {
|
|
const prenom = parts[0];
|
|
const nom = parts.slice(1).join(' ');
|
|
return `${nom.toUpperCase()} ${prenom}`;
|
|
}
|
|
return contract.employee_name;
|
|
}
|
|
|
|
return "—";
|
|
}
|
|
|
|
export default function PayslipDetailsModal({
|
|
isOpen,
|
|
onClose,
|
|
payslipIds,
|
|
payslips,
|
|
onPayslipUpdated
|
|
}: PayslipDetailsModalProps) {
|
|
const [currentIndex, setCurrentIndex] = useState(0);
|
|
const [payslipDetails, setPayslipDetails] = useState<PayslipDetails | null>(null);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [isEditMode, setIsEditMode] = useState(false);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [formData, setFormData] = useState({
|
|
gross_amount: "",
|
|
net_amount: "",
|
|
net_after_withholding: "",
|
|
employer_cost: "",
|
|
period_start: "",
|
|
period_end: "",
|
|
pay_date: "",
|
|
processed: false,
|
|
transfer_done: false,
|
|
aem_status: ""
|
|
});
|
|
|
|
// Document upload states
|
|
const [isDraggingDoc, setIsDraggingDoc] = useState(false);
|
|
const [isUploadingDoc, setIsUploadingDoc] = useState(false);
|
|
const [isOpeningDoc, setIsOpeningDoc] = useState(false);
|
|
const [isDeletingDoc, setIsDeletingDoc] = useState(false);
|
|
const fileInputRefDoc = useRef<HTMLInputElement | null>(null);
|
|
|
|
const currentPayslipId = payslipIds[currentIndex];
|
|
const currentPayslip = payslips.find(p => p.id === currentPayslipId);
|
|
|
|
// Fetch payslip details from API to get fresh data including storage_path
|
|
useEffect(() => {
|
|
if (!currentPayslipId) return;
|
|
|
|
const fetchPayslipDetails = async () => {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
try {
|
|
const response = await fetch(`/api/staff/payslips/${currentPayslipId}`);
|
|
if (!response.ok) {
|
|
throw new Error('Erreur lors du chargement des détails');
|
|
}
|
|
const data = await response.json();
|
|
setPayslipDetails(data);
|
|
|
|
// Charger les données dans le formulaire
|
|
setFormData({
|
|
gross_amount: String(data.gross_amount || ""),
|
|
net_amount: String(data.net_amount || ""),
|
|
net_after_withholding: String(data.net_after_withholding || ""),
|
|
employer_cost: String(data.employer_cost || ""),
|
|
period_start: data.period_start?.slice(0, 10) || "",
|
|
period_end: data.period_end?.slice(0, 10) || "",
|
|
pay_date: data.pay_date?.slice(0, 10) || "",
|
|
processed: data.processed || false,
|
|
transfer_done: data.transfer_done || false,
|
|
aem_status: data.aem_status || ""
|
|
});
|
|
|
|
// Désactiver le mode édition lors du changement de paie
|
|
setIsEditMode(false);
|
|
} catch (err) {
|
|
console.error('Erreur fetch payslip:', err);
|
|
setError('Erreur lors du chargement des détails');
|
|
// Fallback sur les données des props si l'API échoue
|
|
if (currentPayslip) {
|
|
setPayslipDetails(currentPayslip);
|
|
setFormData({
|
|
gross_amount: String(currentPayslip.gross_amount || ""),
|
|
net_amount: String(currentPayslip.net_amount || ""),
|
|
net_after_withholding: String(currentPayslip.net_after_withholding || ""),
|
|
employer_cost: String(currentPayslip.employer_cost || ""),
|
|
period_start: currentPayslip.period_start?.slice(0, 10) || "",
|
|
period_end: currentPayslip.period_end?.slice(0, 10) || "",
|
|
pay_date: currentPayslip.pay_date?.slice(0, 10) || "",
|
|
processed: currentPayslip.processed || false,
|
|
transfer_done: currentPayslip.transfer_done || false,
|
|
aem_status: currentPayslip.aem_status || ""
|
|
});
|
|
setIsEditMode(false);
|
|
}
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
fetchPayslipDetails();
|
|
}, [currentPayslipId, currentPayslip]);
|
|
|
|
// Fonction pour sauvegarder les modifications
|
|
const handleSave = async () => {
|
|
if (!payslipDetails) return;
|
|
|
|
setIsSaving(true);
|
|
try {
|
|
const response = await fetch(`/api/staff/payslips/${payslipDetails.id}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
gross_amount: parseFloat(formData.gross_amount) || null,
|
|
net_amount: parseFloat(formData.net_amount) || null,
|
|
net_after_withholding: parseFloat(formData.net_after_withholding) || null,
|
|
employer_cost: parseFloat(formData.employer_cost) || null,
|
|
period_start: formData.period_start || null,
|
|
period_end: formData.period_end || null,
|
|
pay_date: formData.pay_date || null,
|
|
processed: formData.processed,
|
|
transfer_done: formData.transfer_done,
|
|
aem_status: formData.aem_status || null
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Erreur lors de la sauvegarde');
|
|
}
|
|
|
|
const updatedPayslip = await response.json();
|
|
|
|
// Mettre à jour les données affichées
|
|
setPayslipDetails(updatedPayslip);
|
|
setIsEditMode(false);
|
|
toast.success('Fiche de paie mise à jour avec succès');
|
|
|
|
// Notifier le parent pour mettre à jour la liste
|
|
if (onPayslipUpdated) {
|
|
onPayslipUpdated(updatedPayslip);
|
|
}
|
|
} catch (error) {
|
|
console.error('Erreur:', error);
|
|
toast.error('Erreur lors de la sauvegarde');
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
// Document upload / open helpers
|
|
const handleDragEnterDoc = (e: DragEvent<HTMLDivElement>) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setIsDraggingDoc(true);
|
|
};
|
|
|
|
const handleDragLeaveDoc = (e: DragEvent<HTMLDivElement>) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setIsDraggingDoc(false);
|
|
};
|
|
|
|
const handleDragOverDoc = (e: DragEvent<HTMLDivElement>) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
};
|
|
|
|
const handleDropDoc = async (e: DragEvent<HTMLDivElement>) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setIsDraggingDoc(false);
|
|
|
|
const files = Array.from(e.dataTransfer.files);
|
|
if (files.length === 0) return;
|
|
const file = files[0];
|
|
if (file.type !== 'application/pdf') {
|
|
toast.error('Seuls les fichiers PDF sont acceptés');
|
|
return;
|
|
}
|
|
await uploadDocFile(file);
|
|
};
|
|
|
|
const handleFileSelectDoc = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const files = e.target.files;
|
|
if (!files || files.length === 0) return;
|
|
const file = files[0];
|
|
if (file.type !== 'application/pdf') {
|
|
toast.error('Seuls les fichiers PDF sont acceptés');
|
|
return;
|
|
}
|
|
await uploadDocFile(file);
|
|
};
|
|
|
|
const uploadDocFile = async (file: File) => {
|
|
if (!payslipDetails) return;
|
|
setIsUploadingDoc(true);
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
formData.append('contract_id', payslipDetails.cddu_contracts?.id || '');
|
|
formData.append('payslip_id', payslipDetails.id);
|
|
|
|
const response = await fetch('/api/staff/payslip-upload', {
|
|
method: 'POST',
|
|
body: formData,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const err = await response.json().catch(() => ({}));
|
|
throw new Error(err.error || 'Erreur lors de l\'upload');
|
|
}
|
|
|
|
const result = await response.json();
|
|
// Mettre à jour l'état local et notifier le parent
|
|
const updated = { ...payslipDetails, storage_path: result.s3_key };
|
|
setPayslipDetails(updated as PayslipDetails);
|
|
if (onPayslipUpdated) onPayslipUpdated(updated);
|
|
toast.success('Bulletin de paie uploadé avec succès !');
|
|
} catch (error) {
|
|
console.error('Erreur upload:', error);
|
|
toast.error(error instanceof Error ? error.message : 'Erreur lors de l\'upload');
|
|
} finally {
|
|
setIsUploadingDoc(false);
|
|
if (fileInputRefDoc.current) fileInputRefDoc.current.value = '';
|
|
}
|
|
};
|
|
|
|
const handleOpenPdfDoc = async (e?: React.MouseEvent) => {
|
|
if (e) { e.preventDefault(); e.stopPropagation(); }
|
|
if (!payslipDetails?.storage_path || isOpeningDoc) return;
|
|
setIsOpeningDoc(true);
|
|
try {
|
|
const res = await fetch('/api/s3-presigned', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ key: payslipDetails.storage_path })
|
|
});
|
|
if (!res.ok) throw new Error('Erreur lors de la génération de l\'URL');
|
|
const data = await res.json();
|
|
window.open(data.url, '_blank');
|
|
} catch (error) {
|
|
console.error('Erreur ouverture PDF:', error);
|
|
toast.error('Erreur lors de l\'accès au fichier');
|
|
} finally {
|
|
setIsOpeningDoc(false);
|
|
}
|
|
};
|
|
|
|
const handleDeleteDoc = async () => {
|
|
if (!payslipDetails?.storage_path || isDeletingDoc) return;
|
|
|
|
if (!confirm('Êtes-vous sûr de vouloir supprimer ce document ? Cette action est irréversible.')) {
|
|
return;
|
|
}
|
|
|
|
setIsDeletingDoc(true);
|
|
try {
|
|
// Mettre à jour la fiche pour enlever le storage_path
|
|
const response = await fetch(`/api/staff/payslips/${payslipDetails.id}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ storage_path: null })
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Erreur lors de la suppression');
|
|
}
|
|
|
|
// Mettre à jour l'état local
|
|
const updated = { ...payslipDetails, storage_path: null };
|
|
setPayslipDetails(updated as PayslipDetails);
|
|
if (onPayslipUpdated) onPayslipUpdated(updated);
|
|
toast.success('Document supprimé avec succès');
|
|
} catch (error) {
|
|
console.error('Erreur suppression:', error);
|
|
toast.error(error instanceof Error ? error.message : 'Erreur lors de la suppression');
|
|
} finally {
|
|
setIsDeletingDoc(false);
|
|
}
|
|
};
|
|
|
|
const handleReplaceDoc = () => {
|
|
if (fileInputRefDoc.current) {
|
|
fileInputRefDoc.current.click();
|
|
}
|
|
};
|
|
|
|
// Reset index when payslips change
|
|
useEffect(() => {
|
|
setCurrentIndex(0);
|
|
}, [payslipIds]);
|
|
|
|
// Keyboard navigation
|
|
useEffect(() => {
|
|
if (!isOpen) return;
|
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === "ArrowLeft" && currentIndex > 0) {
|
|
e.preventDefault();
|
|
setCurrentIndex(prev => prev - 1);
|
|
} else if (e.key === "ArrowRight" && currentIndex < payslipIds.length - 1) {
|
|
e.preventDefault();
|
|
setCurrentIndex(prev => prev + 1);
|
|
} else if (e.key === "Escape") {
|
|
e.preventDefault();
|
|
onClose();
|
|
}
|
|
};
|
|
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
}, [isOpen, currentIndex, payslipIds.length, onClose]);
|
|
|
|
if (!isOpen) return null;
|
|
|
|
const handlePrevious = () => {
|
|
if (currentIndex > 0) {
|
|
setCurrentIndex(prev => prev - 1);
|
|
}
|
|
};
|
|
|
|
const handleNext = () => {
|
|
if (currentIndex < payslipIds.length - 1) {
|
|
setCurrentIndex(prev => prev + 1);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
|
|
<div
|
|
className="relative w-full max-w-3xl max-h-[90vh] overflow-y-auto bg-white rounded-xl shadow-2xl"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* Header */}
|
|
<div className="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-6 py-4">
|
|
<div className="flex items-center gap-3">
|
|
<FileText className="w-6 h-6 text-slate-600" />
|
|
<div>
|
|
<h2 className="text-xl font-semibold text-slate-900">
|
|
Détails de la fiche de paie
|
|
</h2>
|
|
<p className="text-sm text-slate-600">
|
|
{currentIndex + 1} sur {payslipIds.length}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={onClose}
|
|
className="text-slate-400 hover:text-slate-600 transition-colors"
|
|
aria-label="Fermer"
|
|
>
|
|
<X className="w-6 h-6" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Navigation */}
|
|
{payslipIds.length > 1 && (
|
|
<div className="flex items-center justify-between border-b bg-slate-50 px-6 py-3">
|
|
<button
|
|
onClick={handlePrevious}
|
|
disabled={currentIndex === 0}
|
|
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-slate-700 bg-white rounded-lg border border-slate-300 hover:bg-slate-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
>
|
|
<ChevronLeft className="w-4 h-4" />
|
|
Précédent
|
|
</button>
|
|
<span className="text-sm text-slate-600">
|
|
Navigation : ← / →
|
|
</span>
|
|
<button
|
|
onClick={handleNext}
|
|
disabled={currentIndex === payslipIds.length - 1}
|
|
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-slate-700 bg-white rounded-lg border border-slate-300 hover:bg-slate-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
>
|
|
Suivant
|
|
<ChevronRight className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Content */}
|
|
<div className="p-6 space-y-6">
|
|
{isLoading ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<Loader className="w-8 h-8 animate-spin text-indigo-600" />
|
|
</div>
|
|
) : error ? (
|
|
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
|
|
{error}
|
|
</div>
|
|
) : payslipDetails ? (
|
|
<>
|
|
{/* Informations principales */}
|
|
<div className="space-y-4">
|
|
<h3 className="text-lg font-semibold text-slate-900 border-b pb-2">
|
|
Informations principales
|
|
</h3>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-600 mb-1">
|
|
Salarié
|
|
</label>
|
|
<p className="text-base text-slate-900">
|
|
{formatEmployeeName(payslipDetails)}
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-600 mb-1">
|
|
N° Contrat
|
|
</label>
|
|
<p className="text-base text-slate-900">
|
|
{payslipDetails.cddu_contracts?.contract_number || "—"}
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-600 mb-1">
|
|
Période
|
|
</label>
|
|
<p className="text-base text-slate-900">
|
|
{formatDate(payslipDetails.period_start)} - {formatDate(payslipDetails.period_end)}
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-600 mb-1">
|
|
Date de paiement
|
|
</label>
|
|
<p className="text-base text-slate-900">
|
|
{formatDate(payslipDetails.pay_date)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Montants */}
|
|
<div className="space-y-4">
|
|
<h3 className="text-lg font-semibold text-slate-900 border-b pb-2">
|
|
Montants
|
|
</h3>
|
|
|
|
{!isEditMode ? (
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="p-4 bg-blue-50 rounded-lg border border-blue-200">
|
|
<label className="block text-sm font-medium text-blue-800 mb-1">
|
|
Salaire brut
|
|
</label>
|
|
<p className="text-2xl font-bold text-blue-900">
|
|
{formatCurrency(payslipDetails.gross_amount)}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="p-4 bg-orange-50 rounded-lg border border-orange-200">
|
|
<label className="block text-sm font-medium text-orange-800 mb-1">
|
|
Net avant PAS
|
|
</label>
|
|
<p className="text-2xl font-bold text-orange-900">
|
|
{formatCurrency(payslipDetails.net_amount)}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="p-4 bg-green-50 rounded-lg border border-green-200">
|
|
<label className="block text-sm font-medium text-green-800 mb-1">
|
|
Net à payer
|
|
</label>
|
|
<p className="text-2xl font-bold text-green-900">
|
|
{formatCurrency(payslipDetails.net_after_withholding)}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="p-4 bg-red-50 rounded-lg border border-red-200">
|
|
<label className="block text-sm font-medium text-red-800 mb-1">
|
|
Coût employeur
|
|
</label>
|
|
<p className="text-2xl font-bold text-red-900">
|
|
{formatCurrency(payslipDetails.employer_cost)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<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={formData.gross_amount}
|
|
onChange={(e) => setFormData({...formData, gross_amount: 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"
|
|
/>
|
|
</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={formData.net_amount}
|
|
onChange={(e) => setFormData({...formData, net_amount: 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"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-600 mb-1">
|
|
Net à payer
|
|
</label>
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
value={formData.net_after_withholding}
|
|
onChange={(e) => setFormData({...formData, net_after_withholding: 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"
|
|
/>
|
|
</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={formData.employer_cost}
|
|
onChange={(e) => setFormData({...formData, employer_cost: 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"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Documents */}
|
|
<div className="space-y-4">
|
|
<h3 className="text-lg font-semibold text-slate-900 border-b pb-2">Documents</h3>
|
|
|
|
{payslipDetails.storage_path ? (
|
|
<div className="p-4 bg-green-50 border border-green-200 rounded-lg space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className="inline-flex items-center justify-center w-10 h-10 rounded-md bg-green-100 text-green-700">
|
|
<FileText className="w-5 h-5" />
|
|
</div>
|
|
<div>
|
|
<div className="text-sm font-medium text-slate-900">Bulletin de paie</div>
|
|
<div className="text-xs text-slate-600">Fichier disponible</div>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={handleOpenPdfDoc}
|
|
disabled={isOpeningDoc}
|
|
className="inline-flex items-center gap-2 px-3 py-2 text-sm font-medium text-green-700 bg-white border border-green-200 rounded-lg hover:bg-green-50 transition-colors disabled:opacity-50"
|
|
>
|
|
{isOpeningDoc ? (
|
|
<>
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
Ouverture...
|
|
</>
|
|
) : (
|
|
<>
|
|
<ExternalLink className="w-4 h-4" />
|
|
Ouvrir
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Actions : Remplacer et Supprimer */}
|
|
<div className="flex items-center gap-2 pt-2 border-t border-green-200">
|
|
<button
|
|
onClick={handleReplaceDoc}
|
|
disabled={isUploadingDoc || isDeletingDoc}
|
|
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 transition-colors disabled:opacity-50"
|
|
>
|
|
<RefreshCw className="w-4 h-4" />
|
|
Remplacer
|
|
</button>
|
|
<button
|
|
onClick={handleDeleteDoc}
|
|
disabled={isUploadingDoc || isDeletingDoc}
|
|
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2 text-sm font-medium text-red-700 bg-white border border-red-300 rounded-lg hover:bg-red-50 transition-colors disabled:opacity-50"
|
|
>
|
|
{isDeletingDoc ? (
|
|
<>
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
Suppression...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Trash2 className="w-4 h-4" />
|
|
Supprimer
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Hidden file input for replace */}
|
|
<input
|
|
ref={fileInputRefDoc}
|
|
type="file"
|
|
accept="application/pdf"
|
|
onChange={handleFileSelectDoc}
|
|
className="hidden"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div
|
|
onDragEnter={handleDragEnterDoc}
|
|
onDragLeave={handleDragLeaveDoc}
|
|
onDragOver={handleDragOverDoc}
|
|
onDrop={handleDropDoc}
|
|
className={`border-2 border-dashed rounded-xl p-4 text-center transition-all ${isDraggingDoc ? 'border-blue-500 bg-blue-50' : 'border-gray-300 bg-gray-50 hover:border-blue-400 hover:bg-blue-50'}`}
|
|
onClick={() => fileInputRefDoc.current?.click()}
|
|
>
|
|
{isUploadingDoc ? (
|
|
<div className="flex flex-col items-center gap-2">
|
|
<Loader2 className="w-6 h-6 text-blue-500 animate-spin" />
|
|
<p className="text-sm text-blue-600 font-medium">Upload en cours...</p>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col items-center gap-2">
|
|
<Upload className="w-6 h-6 text-gray-400" />
|
|
<p className="text-sm text-gray-600">
|
|
<span className="font-medium text-blue-600">Cliquez</span> ou glissez un PDF ici
|
|
</p>
|
|
<p className="text-xs text-gray-500">Bulletin de paie au format PDF</p>
|
|
</div>
|
|
)}
|
|
<input
|
|
ref={fileInputRefDoc}
|
|
type="file"
|
|
accept="application/pdf"
|
|
onChange={handleFileSelectDoc}
|
|
className="hidden"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Statuts */}
|
|
<div className="space-y-4">
|
|
<h3 className="text-lg font-semibold text-slate-900 border-b pb-2">
|
|
Statuts
|
|
</h3>
|
|
|
|
<div className="grid grid-cols-3 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-600 mb-1">
|
|
Traité
|
|
</label>
|
|
<p className={`text-base font-medium ${payslipDetails.processed ? 'text-green-600' : 'text-orange-600'}`}>
|
|
{payslipDetails.processed ? 'Oui' : 'Non'}
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-600 mb-1">
|
|
Virement effectué
|
|
</label>
|
|
<p className={`text-base font-medium ${payslipDetails.transfer_done ? 'text-green-600' : 'text-orange-600'}`}>
|
|
{payslipDetails.transfer_done ? 'Oui' : 'Non'}
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-600 mb-1">
|
|
Statut AEM
|
|
</label>
|
|
<p className="text-base text-slate-900">
|
|
{payslipDetails.aem_status || "—"}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex items-center justify-end gap-3 pt-4 border-t">
|
|
{!isEditMode ? (
|
|
<button
|
|
onClick={() => setIsEditMode(true)}
|
|
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition-colors"
|
|
>
|
|
<Edit className="w-4 h-4" />
|
|
Modifier la fiche de paie
|
|
</button>
|
|
) : (
|
|
<>
|
|
<button
|
|
onClick={() => {
|
|
setIsEditMode(false);
|
|
// Restaurer les données originales
|
|
setFormData({
|
|
gross_amount: String(payslipDetails.gross_amount || ""),
|
|
net_amount: String(payslipDetails.net_amount || ""),
|
|
net_after_withholding: String(payslipDetails.net_after_withholding || ""),
|
|
employer_cost: String(payslipDetails.employer_cost || ""),
|
|
period_start: payslipDetails.period_start?.slice(0, 10) || "",
|
|
period_end: payslipDetails.period_end?.slice(0, 10) || "",
|
|
pay_date: payslipDetails.pay_date?.slice(0, 10) || "",
|
|
processed: payslipDetails.processed || false,
|
|
transfer_done: payslipDetails.transfer_done || false,
|
|
aem_status: payslipDetails.aem_status || ""
|
|
});
|
|
}}
|
|
disabled={isSaving}
|
|
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 transition-colors disabled:opacity-50"
|
|
>
|
|
Annuler
|
|
</button>
|
|
<button
|
|
onClick={handleSave}
|
|
disabled={isSaving}
|
|
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50"
|
|
>
|
|
<Save className="w-4 h-4" />
|
|
{isSaving ? 'Sauvegarde...' : 'Sauvegarder'}
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="text-center py-12 text-slate-500">
|
|
Aucune donnée disponible
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|