398 lines
14 KiB
TypeScript
398 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import { useState, DragEvent } from "react";
|
|
import { X, Upload, FileText, CheckCircle2, Loader2, AlertCircle } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
|
|
type PayslipUploadItem = {
|
|
id: string;
|
|
pay_number: number | null | undefined;
|
|
period_start?: string | null;
|
|
period_end?: string | null;
|
|
contract_id: string;
|
|
employee_name?: string;
|
|
file?: File | null;
|
|
isUploading?: boolean;
|
|
isSuccess?: boolean;
|
|
error?: string | null;
|
|
hasExistingDocument?: boolean;
|
|
};
|
|
|
|
type BulkPayslipUploadModalProps = {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
payslips: Array<{
|
|
id: string;
|
|
pay_number?: number | null;
|
|
period_start?: string | null;
|
|
period_end?: string | null;
|
|
contract_id: string;
|
|
storage_path?: string | null;
|
|
cddu_contracts?: {
|
|
employee_name?: string | null;
|
|
salaries?: {
|
|
salarie?: string | null;
|
|
nom?: string | null;
|
|
prenom?: string | null;
|
|
} | null;
|
|
} | null;
|
|
}>;
|
|
onSuccess: () => void;
|
|
};
|
|
|
|
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(payslip: any): string {
|
|
const contract = payslip.cddu_contracts;
|
|
if (!contract) return "—";
|
|
|
|
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 BulkPayslipUploadModal({
|
|
isOpen,
|
|
onClose,
|
|
payslips,
|
|
onSuccess,
|
|
}: BulkPayslipUploadModalProps) {
|
|
const [uploadItems, setUploadItems] = useState<PayslipUploadItem[]>(() =>
|
|
payslips.map(p => ({
|
|
id: p.id,
|
|
pay_number: p.pay_number,
|
|
period_start: p.period_start,
|
|
period_end: p.period_end,
|
|
contract_id: p.contract_id,
|
|
employee_name: formatEmployeeName(p),
|
|
file: null,
|
|
isUploading: false,
|
|
isSuccess: false,
|
|
error: null,
|
|
hasExistingDocument: !!p.storage_path,
|
|
}))
|
|
);
|
|
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
|
|
if (!isOpen) return null;
|
|
|
|
const handleDragOver = (e: DragEvent<HTMLDivElement>, itemId: string) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
};
|
|
|
|
const handleDrop = (e: DragEvent<HTMLDivElement>, itemId: string) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
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;
|
|
}
|
|
|
|
setUploadItems(prev => prev.map(item =>
|
|
item.id === itemId ? { ...item, file, error: null } : item
|
|
));
|
|
};
|
|
|
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>, itemId: string) => {
|
|
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;
|
|
}
|
|
|
|
setUploadItems(prev => prev.map(item =>
|
|
item.id === itemId ? { ...item, file, error: null } : item
|
|
));
|
|
};
|
|
|
|
const handleRemoveFile = (itemId: string) => {
|
|
setUploadItems(prev => prev.map(item =>
|
|
item.id === itemId ? { ...item, file: null, error: null } : item
|
|
));
|
|
};
|
|
|
|
const handleSubmit = async () => {
|
|
const itemsToUpload = uploadItems.filter(item => item.file && !item.isSuccess);
|
|
|
|
if (itemsToUpload.length === 0) {
|
|
toast.error("Aucun document à uploader");
|
|
return;
|
|
}
|
|
|
|
setIsSubmitting(true);
|
|
|
|
// Upload un par un pour avoir un feedback précis
|
|
for (const item of itemsToUpload) {
|
|
if (!item.file) continue;
|
|
|
|
// Marquer comme en cours d'upload
|
|
setUploadItems(prev => prev.map(i =>
|
|
i.id === item.id ? { ...i, isUploading: true, error: null } : i
|
|
));
|
|
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('file', item.file);
|
|
formData.append('contract_id', item.contract_id);
|
|
formData.append('payslip_id', item.id);
|
|
|
|
const response = await fetch('/api/staff/payslip-upload', {
|
|
method: 'POST',
|
|
body: formData,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json();
|
|
throw new Error(errorData.error || 'Erreur lors de l\'upload');
|
|
}
|
|
|
|
// Marquer comme succès
|
|
setUploadItems(prev => prev.map(i =>
|
|
i.id === item.id ? { ...i, isUploading: false, isSuccess: true, error: null } : i
|
|
));
|
|
|
|
} catch (error) {
|
|
console.error('Erreur upload:', error);
|
|
|
|
// Marquer l'erreur
|
|
setUploadItems(prev => prev.map(i =>
|
|
i.id === item.id ? {
|
|
...i,
|
|
isUploading: false,
|
|
isSuccess: false,
|
|
error: error instanceof Error ? error.message : "Erreur lors de l'upload"
|
|
} : i
|
|
));
|
|
}
|
|
}
|
|
|
|
setIsSubmitting(false);
|
|
|
|
// Compter les succès
|
|
const successCount = uploadItems.filter(item => item.isSuccess).length;
|
|
const errorCount = itemsToUpload.length - successCount;
|
|
|
|
if (successCount > 0) {
|
|
toast.success(`${successCount} document(s) uploadé(s) avec succès`);
|
|
}
|
|
|
|
if (errorCount > 0) {
|
|
toast.error(`${errorCount} erreur(s) lors de l'upload`);
|
|
}
|
|
|
|
// Si tous les uploads sont réussis, fermer le modal et rafraîchir
|
|
if (errorCount === 0) {
|
|
onSuccess();
|
|
onClose();
|
|
}
|
|
};
|
|
|
|
const totalItems = uploadItems.length;
|
|
const itemsWithFiles = uploadItems.filter(item => item.file).length;
|
|
const successfulUploads = uploadItems.filter(item => item.isSuccess).length;
|
|
const canSubmit = itemsWithFiles > 0 && !isSubmitting;
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
|
<div className="bg-white rounded-2xl shadow-xl w-full max-w-4xl max-h-[90vh] flex flex-col">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between p-6 border-b">
|
|
<div>
|
|
<h2 className="text-xl font-semibold text-slate-900">
|
|
Ajouter des documents de paie en masse
|
|
</h2>
|
|
<p className="text-sm text-slate-600 mt-1">
|
|
{totalItems} paie{totalItems > 1 ? 's' : ''} sélectionnée{totalItems > 1 ? 's' : ''}
|
|
{itemsWithFiles > 0 && ` • ${itemsWithFiles} document${itemsWithFiles > 1 ? 's' : ''} prêt${itemsWithFiles > 1 ? 's' : ''}`}
|
|
{successfulUploads > 0 && ` • ${successfulUploads} uploadé${successfulUploads > 1 ? 's' : ''}`}
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={onClose}
|
|
disabled={isSubmitting}
|
|
className="p-2 hover:bg-slate-100 rounded-lg transition-colors disabled:opacity-50"
|
|
>
|
|
<X className="w-5 h-5 text-slate-500" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Liste des paies avec zones d'upload */}
|
|
<div className="flex-1 overflow-y-auto p-6">
|
|
<div className="space-y-3">
|
|
{uploadItems.map((item) => (
|
|
<div
|
|
key={item.id}
|
|
className={`border rounded-xl p-4 transition-all ${
|
|
item.isSuccess
|
|
? 'border-green-300 bg-green-50'
|
|
: item.error
|
|
? 'border-red-300 bg-red-50'
|
|
: item.file
|
|
? 'border-blue-300 bg-blue-50'
|
|
: 'border-slate-200 hover:border-slate-300 bg-white'
|
|
}`}
|
|
>
|
|
<div className="flex items-start gap-4">
|
|
{/* Info paie */}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<span className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 text-blue-800 text-sm font-medium">
|
|
#{item.pay_number}
|
|
</span>
|
|
<div className="flex-1">
|
|
<p className="font-medium text-slate-900">
|
|
{item.employee_name}
|
|
</p>
|
|
<p className="text-xs text-slate-600">
|
|
{formatDate(item.period_start)} → {formatDate(item.period_end)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Statut */}
|
|
{item.hasExistingDocument && !item.file && (
|
|
<div className="flex items-center gap-2 text-xs text-amber-700 bg-amber-100 px-2 py-1 rounded mb-2">
|
|
<AlertCircle className="w-3 h-3" />
|
|
Document existant (sera remplacé si nouveau fichier uploadé)
|
|
</div>
|
|
)}
|
|
|
|
{item.error && (
|
|
<div className="flex items-center gap-2 text-xs text-red-700 bg-red-100 px-2 py-1 rounded mb-2">
|
|
<AlertCircle className="w-3 h-3" />
|
|
{item.error}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Zone d'upload ou fichier sélectionné */}
|
|
<div className="w-64 flex-shrink-0">
|
|
{item.isSuccess ? (
|
|
<div className="flex items-center justify-center gap-2 h-20 rounded-lg bg-green-100 border-2 border-green-300 text-green-700">
|
|
<CheckCircle2 className="w-5 h-5" />
|
|
<span className="text-sm font-medium">Uploadé</span>
|
|
</div>
|
|
) : item.isUploading ? (
|
|
<div className="flex items-center justify-center gap-2 h-20 rounded-lg bg-blue-100 border-2 border-blue-300 text-blue-700">
|
|
<Loader2 className="w-5 h-5 animate-spin" />
|
|
<span className="text-sm font-medium">Upload...</span>
|
|
</div>
|
|
) : item.file ? (
|
|
<div className="relative h-20 rounded-lg border-2 border-blue-300 bg-blue-50 p-3 flex items-center gap-2">
|
|
<FileText className="w-5 h-5 text-blue-600 flex-shrink-0" />
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium text-slate-900 truncate">
|
|
{item.file.name}
|
|
</p>
|
|
<p className="text-xs text-slate-600">
|
|
{(item.file.size / 1024).toFixed(0)} KB
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={() => handleRemoveFile(item.id)}
|
|
className="p-1 hover:bg-white rounded transition-colors flex-shrink-0"
|
|
>
|
|
<X className="w-4 h-4 text-slate-500" />
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div
|
|
onDragOver={(e) => handleDragOver(e, item.id)}
|
|
onDrop={(e) => handleDrop(e, item.id)}
|
|
className="relative h-20 rounded-lg border-2 border-dashed border-slate-300 hover:border-blue-400 hover:bg-blue-50 transition-all cursor-pointer"
|
|
onClick={() => document.getElementById(`file-input-${item.id}`)?.click()}
|
|
>
|
|
<input
|
|
id={`file-input-${item.id}`}
|
|
type="file"
|
|
accept=".pdf,application/pdf"
|
|
className="hidden"
|
|
onChange={(e) => handleFileSelect(e, item.id)}
|
|
/>
|
|
<div className="flex flex-col items-center justify-center h-full text-slate-500">
|
|
<Upload className="w-5 h-5 mb-1" />
|
|
<p className="text-xs font-medium">Glisser ou cliquer</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="flex items-center justify-between gap-3 p-6 border-t bg-slate-50">
|
|
<p className="text-sm text-slate-600">
|
|
{itemsWithFiles === 0 && "Ajoutez des documents pour continuer"}
|
|
{itemsWithFiles > 0 && !isSubmitting && `${itemsWithFiles} document${itemsWithFiles > 1 ? 's' : ''} prêt${itemsWithFiles > 1 ? 's' : ''} à être uploadé${itemsWithFiles > 1 ? 's' : ''}`}
|
|
{isSubmitting && "Upload en cours..."}
|
|
</p>
|
|
<div className="flex gap-3">
|
|
<button
|
|
onClick={onClose}
|
|
disabled={isSubmitting}
|
|
className="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"
|
|
>
|
|
{successfulUploads > 0 ? 'Fermer' : 'Annuler'}
|
|
</button>
|
|
<button
|
|
onClick={handleSubmit}
|
|
disabled={!canSubmit}
|
|
className="px-6 py-2 text-sm font-medium text-white bg-gradient-to-r from-blue-600 to-indigo-600 rounded-lg hover:from-blue-700 hover:to-indigo-700 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
|
>
|
|
{isSubmitting ? (
|
|
<>
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
Upload en cours...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Upload className="w-4 h-4" />
|
|
Uploader {itemsWithFiles} document{itemsWithFiles > 1 ? 's' : ''}
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|