Système signatures groupées

This commit is contained in:
odentas 2025-10-13 00:14:16 +02:00
parent ba6b733ad0
commit d99db42e12
9 changed files with 939 additions and 32 deletions

View file

@ -35,7 +35,7 @@ export default async function StaffContractsPage() {
const { data: contracts, error } = await sb
.from("cddu_contracts")
.select(
`id, contract_number, employee_name, structure, type_de_contrat, start_date, end_date, created_at, etat_de_la_demande, etat_de_la_paie, dpae, gross_pay`
`id, contract_number, employee_name, structure, type_de_contrat, start_date, end_date, created_at, etat_de_la_demande, etat_de_la_paie, dpae, gross_pay, org_id`
)
.eq("type_de_contrat", "CDD d'usage")
.order("start_date", { ascending: false })

View file

@ -52,7 +52,8 @@ export async function POST(request: NextRequest) {
employerCode,
employeeFirstName,
matricule,
contractType = 'CDDU'
contractType = 'CDDU',
skipEmployerEmail = false // Nouveau paramètre pour éviter l'envoi d'email à l'employeur
} = data;
// Validation des champs requis
@ -183,37 +184,44 @@ export async function POST(request: NextRequest) {
const signatureLink = `https://staging.paie.odentas.fr/odentas-sign?docuseal_id=${embedCode}`;
// Étape 5 : Envoi de l'email de signature via le template universel (employeur)
const emailData: EmailDataV2 = {
firstName: signerFirstName,
organizationName: structure,
employerCode: employerCode,
employeeName: employeeName,
documentType: contractType || 'Contrat',
contractReference: reference,
status: 'En attente',
ctaUrl: signatureLink,
};
// Seulement si skipEmployerEmail est false (signature individuelle)
let messageId = '';
let emailLink = '';
if (!skipEmployerEmail) {
const emailData: EmailDataV2 = {
firstName: signerFirstName,
organizationName: structure,
employerCode: employerCode,
employeeName: employeeName,
documentType: contractType || 'Contrat',
contractReference: reference,
status: 'En attente',
ctaUrl: signatureLink,
};
// Rendu HTML pour archivage puis envoi
const rendered = await renderUniversalEmailV2({
type: 'signature-request-employer',
toEmail: employerEmail,
subject: `Demande de signature ${reference}`,
data: emailData,
});
// Rendu HTML pour archivage puis envoi
const rendered = await renderUniversalEmailV2({
type: 'signature-request-employer',
toEmail: employerEmail,
subject: `Demande de signature ${reference}`,
data: emailData,
});
const messageId = await sendUniversalEmailV2({
type: 'signature-request-employer',
toEmail: employerEmail,
subject: `Demande de signature ${reference}`,
data: emailData,
});
messageId = await sendUniversalEmailV2({
type: 'signature-request-employer',
toEmail: employerEmail,
subject: `Demande de signature ${reference}`,
data: emailData,
});
// Étape 6 : Upload de l'email HTML rendu sur S3 et logging
const emailHtml = rendered.html;
const emailLink = await uploadEmailToS3(emailHtml, messageId);
console.log('Email HTML uploadé sur S3:', emailLink);
// Étape 6 : Upload de l'email HTML rendu sur S3 et logging
const emailHtml = rendered.html;
emailLink = await uploadEmailToS3(emailHtml, messageId);
console.log('Email HTML uploadé sur S3:', emailLink);
} else {
console.log('⏭️ [EMAIL] Envoi d\'email employeur ignoré (mode bulk)');
}
// Étape 7 : Mise à jour du contrat dans Supabase avec les infos DocuSeal
console.log('🗄️ [SUPABASE] Début de la mise à jour du contrat:', {

View file

@ -0,0 +1,207 @@
// app/api/staff/contracts/bulk-esign/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { createSbServiceRole } from '@/lib/supabaseServer';
export async function POST(request: NextRequest) {
console.log('=== Bulk E-Sign API ===');
try {
const supabase = createSbServiceRole();
const { contractIds } = await request.json();
if (!Array.isArray(contractIds) || contractIds.length === 0) {
return NextResponse.json(
{ error: 'contractIds doit être un tableau non vide' },
{ status: 400 }
);
}
console.log(`📝 Traitement de ${contractIds.length} contrats pour signature électronique`);
// Récupérer les informations des contrats
const { data: contracts, error: contractsError } = await supabase
.from('cddu_contracts')
.select('*')
.in('id', contractIds);
if (contractsError) {
console.error('❌ Erreur lors de la récupération des contrats:', contractsError);
return NextResponse.json(
{ error: 'Erreur lors de la récupération des contrats' },
{ status: 500 }
);
}
const results = [];
for (const contract of contracts || []) {
try {
console.log(`\n🔄 Traitement du contrat ${contract.contract_number || contract.id}`);
// Vérifier que le contrat a un PDF généré
if (!contract.contract_pdf_filename) {
results.push({
contractId: contract.id,
contractNumber: contract.contract_number,
success: false,
error: 'PDF du contrat non généré'
});
continue;
}
// Récupérer l'email du salarié UNIQUEMENT par matricule
let employeeEmail = null;
if (!contract.employee_matricule) {
console.log(`❌ Matricule manquant pour le contrat ${contract.contract_number}`);
results.push({
contractId: contract.id,
contractNumber: contract.contract_number,
employeeName: contract.employee_name,
success: false,
error: `Matricule salarié manquant dans le contrat`
});
continue;
}
console.log(`🔍 Recherche email pour matricule: ${contract.employee_matricule}`);
const { data: salarie } = await supabase
.from('salaries')
.select('adresse_mail, nom, prenom')
.eq('code_salarie', contract.employee_matricule)
.maybeSingle();
employeeEmail = salarie?.adresse_mail;
if (!employeeEmail) {
console.log(`❌ Salarié non trouvé ou email manquant pour matricule: ${contract.employee_matricule}`);
results.push({
contractId: contract.id,
contractNumber: contract.contract_number,
employeeName: contract.employee_name,
success: false,
error: `Email du salarié non trouvé pour le matricule ${contract.employee_matricule}. Vérifiez que le salarié existe et a un email renseigné.`
});
continue;
}
console.log(`✅ Email trouvé: ${employeeEmail} (${salarie?.prenom || ''} ${salarie?.nom || ''})`);
// Récupérer les informations de l'organisation
let employerEmail = "paie@odentas.fr";
let signerName = "Responsable";
let organizationName = contract.production_name || "Organisation";
let employerCode = "DEMO";
if (contract.org_id) {
const { data: orgDetails } = await supabase
.from('organization_details')
.select('*')
.eq('organization_id', contract.org_id)
.maybeSingle();
if (orgDetails) {
employerEmail = orgDetails.email_signature || orgDetails.email_contact || employerEmail;
signerName = `${orgDetails.prenom_signataire || ""} ${orgDetails.nom_signataire || ""}`.trim() || signerName;
organizationName = orgDetails.structure || organizationName;
employerCode = orgDetails.code_employeur || employerCode;
}
}
// Construire la clé S3 du PDF
const pdfS3Key = `unsigned-contracts/${contract.contract_pdf_filename}`;
// Préparer les données pour l'API DocuSeal
const signatureData = {
contractId: contract.id,
pdfS3Key: pdfS3Key,
employerEmail: employerEmail,
employeeEmail: employeeEmail,
reference: contract.reference || contract.contract_number,
employeeName: contract.employee_name,
startDate: contract.start_date,
role: contract.profession || contract.role,
analytique: contract.analytique || contract.production_name,
structure: organizationName,
signerFirstName: signerName,
employerCode: employerCode,
employeeFirstName: contract.employee_name?.split(' ')[0],
matricule: contract.employee_matricule,
contractType: 'CDDU',
skipEmployerEmail: true // Ne pas envoyer d'email à l'employeur individuellement
};
console.log(`📤 Appel API DocuSeal pour contrat ${contract.contract_number}`);
// Appeler l'API DocuSeal
const docusealResponse = await fetch(`${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/api/docuseal-signature`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(signatureData),
});
if (!docusealResponse.ok) {
const errorData = await docusealResponse.json();
results.push({
contractId: contract.id,
contractNumber: contract.contract_number,
success: false,
error: errorData.error || `Erreur ${docusealResponse.status}`
});
continue;
}
const docusealResult = await docusealResponse.json();
results.push({
contractId: contract.id,
contractNumber: contract.contract_number,
employeeName: contract.employee_name,
success: true,
submissionId: docusealResult.data?.submissionId,
org_id: contract.org_id // Ajouter l'org_id pour l'envoi d'email
});
console.log(`✅ Signature créée pour contrat ${contract.contract_number}`);
} catch (error) {
console.error(`❌ Erreur pour contrat ${contract.id}:`, error);
results.push({
contractId: contract.id,
contractNumber: contract.contract_number,
success: false,
error: error instanceof Error ? error.message : 'Erreur inconnue'
});
}
}
const successCount = results.filter(r => r.success).length;
const errorCount = results.filter(r => !r.success).length;
console.log(`\n✅ Traitement terminé: ${successCount} succès, ${errorCount} erreurs`);
return NextResponse.json({
success: true,
message: `Signatures électroniques créées: ${successCount} succès, ${errorCount} erreurs`,
results: results,
summary: {
total: results.length,
success: successCount,
errors: errorCount
}
});
} catch (error) {
console.error('Erreur lors du traitement bulk e-sign:', error);
return NextResponse.json(
{
error: 'Erreur lors du traitement des signatures électroniques',
details: error instanceof Error ? error.message : 'Erreur inconnue'
},
{ status: 500 }
);
}
}

View file

@ -0,0 +1,107 @@
// app/api/staff/contracts/send-esign-notification/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { createSbServiceRole } from '@/lib/supabaseServer';
import { sendUniversalEmailV2, EmailDataV2 } from '@/lib/emailTemplateService';
export async function POST(request: NextRequest) {
console.log('=== Send E-Sign Notification API ===');
try {
const supabase = createSbServiceRole();
const { organizationId, contractCount } = await request.json();
if (!organizationId || !contractCount) {
return NextResponse.json(
{ error: 'organizationId et contractCount sont requis' },
{ status: 400 }
);
}
console.log(`📧 Envoi de notification pour ${contractCount} contrats à l'organisation ${organizationId}`);
// Récupérer les détails de l'organisation
const { data: orgDetails, error: orgError } = await supabase
.from('organization_details')
.select('*')
.eq('org_id', organizationId)
.maybeSingle();
if (orgError || !orgDetails) {
console.error('❌ Erreur lors de la récupération de l\'organisation:', orgError);
return NextResponse.json(
{ error: 'Organisation non trouvée' },
{ status: 404 }
);
}
// Récupérer le nom de l'organisation
const { data: org } = await supabase
.from('organizations')
.select('name')
.eq('id', organizationId)
.maybeSingle();
const organizationName = org?.name || orgDetails.structure || 'Votre organisation';
const emailNotifs = orgDetails.email_notifs;
const emailNotifsCC = orgDetails.email_notifs_cc;
if (!emailNotifs) {
console.error('❌ Email de notification non configuré pour cette organisation');
return NextResponse.json(
{ error: 'Email de notification non configuré' },
{ status: 400 }
);
}
// Préparer les données de l'email
const firstName = orgDetails.prenom_contact || "";
const employerCode = orgDetails.code_employeur || "";
const emailData: EmailDataV2 = {
firstName: firstName,
organizationName: organizationName,
employerCode: employerCode,
contractCount: contractCount,
status: 'En attente de signature',
ctaUrl: `${process.env.NEXT_PUBLIC_APP_URL || 'https://staging.paie.odentas.fr'}/signatures-electroniques`,
handlerName: 'Renaud BREVIERE-ABRAHAM',
organizationId: organizationId // Ajouter pour le logging
};
console.log('📤 Envoi de l\'email de notification client...', {
to: emailNotifs,
cc: emailNotifsCC,
contractCount
});
// Envoyer l'email au client (email_notifs + CC)
await sendUniversalEmailV2({
type: 'bulk-signature-notification',
toEmail: emailNotifs,
ccEmail: emailNotifsCC || undefined,
data: emailData,
});
console.log('✅ Email de notification envoyé avec succès');
return NextResponse.json({
success: true,
message: 'Notification envoyée avec succès',
details: {
to: emailNotifs,
cc: emailNotifsCC,
contractCount
}
});
} catch (error) {
console.error('❌ Erreur lors de l\'envoi de la notification:', error);
return NextResponse.json(
{
error: 'Erreur lors de l\'envoi de la notification',
details: error instanceof Error ? error.message : 'Erreur inconnue'
},
{ status: 500 }
);
}
}

View file

@ -0,0 +1,82 @@
// components/staff/BulkESignConfirmModal.tsx
"use client";
import React from "react";
import { X, FileSignature, AlertTriangle } from "lucide-react";
type BulkESignConfirmModalProps = {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
contractCount: number;
};
export default function BulkESignConfirmModal({
isOpen,
onClose,
onConfirm,
contractCount
}: BulkESignConfirmModalProps) {
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 p-6 w-full max-w-md">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<FileSignature className="size-5 text-indigo-600" />
<h2 className="text-lg font-semibold">Confirmer l'envoi des signatures</h2>
</div>
<button
onClick={onClose}
className="p-1 hover:bg-gray-100 rounded"
>
<X className="size-5" />
</button>
</div>
{/* Warning Message */}
<div className="mb-4 p-3 bg-amber-50 border border-amber-200 rounded-lg flex gap-3">
<AlertTriangle className="size-5 text-amber-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-amber-800">
<p className="font-medium mb-1">Attention</p>
<p>
Vous êtes sur le point de créer les signatures électroniques pour{" "}
<strong>{contractCount}</strong> contrat{contractCount > 1 ? 's' : ''}.
</p>
</div>
</div>
{/* Info */}
<div className="mb-6 text-sm text-gray-600 space-y-2">
<p>Cette action va :</p>
<ul className="list-disc list-inside space-y-1 ml-2">
<li>Créer une demande de signature électronique pour chaque contrat</li>
<li>Envoyer un email à l'employeur et au salarié pour chaque contrat</li>
<li>Envoyer un email récapitulatif au client</li>
</ul>
<p className="mt-3 font-medium">
Assurez-vous que tous les contrats ont un PDF généré et que les salariés ont un email renseigné.
</p>
</div>
{/* Actions */}
<div className="flex justify-end gap-2">
<button
onClick={onClose}
className="px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded hover:bg-gray-50"
>
Annuler
</button>
<button
onClick={onConfirm}
className="px-4 py-2 text-sm bg-indigo-600 text-white rounded hover:bg-indigo-700"
>
Confirmer et envoyer
</button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,182 @@
// components/staff/BulkESignProgressModal.tsx
"use client";
import React from "react";
import { X, FileSignature, CheckCircle, XCircle, Clock, Loader2 } from "lucide-react";
type ContractESignProgress = {
id: string;
contractNumber?: string;
employeeName?: string;
status: 'pending' | 'processing' | 'success' | 'error';
error?: string;
submissionId?: string;
};
type BulkESignProgressModalProps = {
isOpen: boolean;
onClose: () => void;
contracts: ContractESignProgress[];
onCancel?: () => void;
isProcessing: boolean;
totalCount: number;
completedCount: number;
successCount: number;
errorCount: number;
};
export default function BulkESignProgressModal({
isOpen,
onClose,
contracts,
onCancel,
isProcessing,
totalCount,
completedCount,
successCount,
errorCount
}: BulkESignProgressModalProps) {
if (!isOpen) return null;
const progressPercentage = totalCount > 0 ? (completedCount / totalCount) * 100 : 0;
const getStatusIcon = (status: ContractESignProgress['status']) => {
switch (status) {
case 'pending':
return <Clock className="size-4 text-gray-400" />;
case 'processing':
return <Loader2 className="size-4 text-blue-500 animate-spin" />;
case 'success':
return <CheckCircle className="size-4 text-green-500" />;
case 'error':
return <XCircle className="size-4 text-red-500" />;
}
};
const getStatusText = (contract: ContractESignProgress) => {
switch (contract.status) {
case 'pending':
return 'En attente';
case 'processing':
return 'Création de la signature en cours...';
case 'success':
return `Signature électronique créée`;
case 'error':
return `Erreur: ${contract.error || 'Erreur inconnue'}`;
}
};
const getStatusColor = (status: ContractESignProgress['status']) => {
switch (status) {
case 'pending':
return 'text-gray-600';
case 'processing':
return 'text-blue-600';
case 'success':
return 'text-green-600';
case 'error':
return 'text-red-600';
}
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-2xl max-h-[80vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<FileSignature className="size-5 text-purple-600" />
<h2 className="text-lg font-semibold">Envoi des signatures électroniques</h2>
</div>
{!isProcessing && (
<button
onClick={onClose}
className="p-1 hover:bg-gray-100 rounded"
>
<X className="size-5" />
</button>
)}
</div>
{/* Progress Bar */}
<div className="mb-4">
<div className="flex justify-between text-sm text-gray-600 mb-2">
<span>Progression: {completedCount}/{totalCount}</span>
<span>{Math.round(progressPercentage)}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-purple-500 h-2 rounded-full transition-all duration-300"
style={{ width: `${progressPercentage}%` }}
/>
</div>
</div>
{/* Summary Stats */}
<div className="grid grid-cols-3 gap-4 mb-4">
<div className="text-center p-2 bg-green-50 rounded">
<div className="text-lg font-semibold text-green-600">{successCount}</div>
<div className="text-xs text-green-600">Succès</div>
</div>
<div className="text-center p-2 bg-red-50 rounded">
<div className="text-lg font-semibold text-red-600">{errorCount}</div>
<div className="text-xs text-red-600">Erreurs</div>
</div>
<div className="text-center p-2 bg-gray-50 rounded">
<div className="text-lg font-semibold text-gray-600">{totalCount - completedCount}</div>
<div className="text-xs text-gray-600">Restants</div>
</div>
</div>
{/* Contracts List */}
<div className="flex-1 overflow-y-auto border rounded max-h-64">
<div className="space-y-1 p-2">
{contracts.map((contract) => (
<div
key={contract.id}
className={`flex items-center gap-3 p-2 rounded text-sm ${
contract.status === 'processing' ? 'bg-blue-50' :
contract.status === 'success' ? 'bg-green-50' :
contract.status === 'error' ? 'bg-red-50' : 'bg-gray-50'
}`}
>
{getStatusIcon(contract.status)}
<div className="flex-1 min-w-0">
<div className="font-medium truncate">
{contract.contractNumber || contract.id}
</div>
<div className="text-xs text-gray-500 truncate">
{contract.employeeName || 'Nom non disponible'}
</div>
</div>
<div className={`text-xs ${getStatusColor(contract.status)} truncate max-w-xs`}>
{getStatusText(contract)}
</div>
</div>
))}
</div>
</div>
{/* Actions */}
<div className="flex justify-end gap-2 mt-4">
{isProcessing && onCancel && (
<button
onClick={onCancel}
className="px-4 py-2 text-sm bg-red-600 text-white rounded hover:bg-red-700"
>
Annuler
</button>
)}
{!isProcessing && (
<button
onClick={onClose}
className="px-4 py-2 text-sm bg-blue-600 text-white rounded hover:bg-blue-700"
>
Fermer
</button>
)}
</div>
</div>
</div>
);
}

View file

@ -3,10 +3,12 @@
import { useEffect, useMemo, useState, useRef } from "react";
import { supabase } from "@/lib/supabaseClient";
import Link from "next/link";
import { RefreshCw, FileDown, Eye } from "lucide-react";
import { RefreshCw, FileDown, Eye, FileSignature } from "lucide-react";
import { toast } from "sonner";
import BulkPdfProgressModal from "./BulkPdfProgressModal";
import PdfVerificationModal from "./PdfVerificationModal";
import BulkESignProgressModal from "./BulkESignProgressModal";
import BulkESignConfirmModal from "./BulkESignConfirmModal";
// Utility function to format dates as DD/MM/YYYY
function formatDate(dateString: string | null | undefined): string {
@ -60,6 +62,7 @@ type Contract = {
etat_de_la_paie?: string | null;
dpae?: string | null;
gross_pay?: number | null;
org_id?: string | null;
};
type ContractProgress = {
@ -150,6 +153,13 @@ export default function ContractsGrid({ initialData, activeOrgId }: { initialDat
const [contractsPdfs, setContractsPdfs] = useState<ContractPdf[]>([]);
const [isLoadingPdfs, setIsLoadingPdfs] = useState(false);
// E-Sign state
const [isGeneratingESign, setIsGeneratingESign] = useState(false);
const [showESignConfirmModal, setShowESignConfirmModal] = useState(false);
const [showESignProgressModal, setShowESignProgressModal] = useState(false);
const [contractsESignProgress, setContractsESignProgress] = useState<ContractProgress[]>([]);
const [isESignCancelled, setIsESignCancelled] = useState(false);
// Save filters to localStorage whenever they change
useEffect(() => {
const filters = {
@ -582,6 +592,242 @@ export default function ContractsGrid({ initialData, activeOrgId }: { initialDat
setIsLoadingPdfs(false);
};
// Fonction pour ouvrir le modal de confirmation
const handleBulkESignClick = () => {
if (selectedContractIds.size === 0) {
toast.error("Aucun contrat sélectionné");
return;
}
setShowESignConfirmModal(true);
};
// Fonction pour générer les e-signatures en masse (après confirmation)
const generateBatchESign = async () => {
// Fermer le modal de confirmation
setShowESignConfirmModal(false);
const contractIds = Array.from(selectedContractIds);
// Initialiser les contrats avec leurs informations
const initialProgress: ContractProgress[] = contractIds.map(id => {
const contract = rows.find(r => r.id === id);
return {
id,
contractNumber: contract?.contract_number || undefined,
employeeName: contract?.employee_name || undefined,
status: 'pending' as const
};
});
// Ouvrir le modal et initialiser les états
setContractsESignProgress(initialProgress);
setShowESignProgressModal(true);
setIsGeneratingESign(true);
setIsESignCancelled(false);
let successCount = 0;
let errorCount = 0;
const successfulContracts: any[] = [];
try {
// Traiter chaque contrat individuellement
for (let i = 0; i < contractIds.length; i++) {
if (isESignCancelled) {
break;
}
const contractId = contractIds[i];
// Marquer le contrat comme "en cours"
setContractsESignProgress(prev =>
prev.map(c =>
c.id === contractId
? { ...c, status: 'processing' as const }
: c
)
);
try {
// Appeler l'endpoint de signature individuel via l'API bulk
const response = await fetch(`/api/staff/contracts/bulk-esign`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ contractIds: [contractId] }),
});
if (response.ok) {
const result = await response.json();
const contractResult = result.results?.[0];
if (contractResult?.success) {
successCount++;
// Utiliser les données du résultat de l'API qui contient org_id
const contract = rows.find(r => r.id === contractId);
successfulContracts.push({
...contract,
org_id: contractResult.org_id // Utiliser l'org_id de la réponse API
});
// Marquer comme succès
setContractsESignProgress(prev =>
prev.map(c =>
c.id === contractId
? {
...c,
status: 'success' as const,
filename: contractResult.submissionId
}
: c
)
);
} else {
errorCount++;
// Marquer comme erreur
setContractsESignProgress(prev =>
prev.map(c =>
c.id === contractId
? {
...c,
status: 'error' as const,
error: contractResult?.error || 'Erreur inconnue'
}
: c
)
);
}
} else {
const errorData = await response.json();
errorCount++;
// Marquer comme erreur
setContractsESignProgress(prev =>
prev.map(c =>
c.id === contractId
? {
...c,
status: 'error' as const,
error: errorData.error || `Erreur ${response.status}`
}
: c
)
);
}
} catch (error) {
errorCount++;
// Marquer comme erreur
setContractsESignProgress(prev =>
prev.map(c =>
c.id === contractId
? {
...c,
status: 'error' as const,
error: error instanceof Error ? error.message : 'Erreur de réseau'
}
: c
)
);
}
// Petit délai entre chaque contrat
if (i < contractIds.length - 1 && !isESignCancelled) {
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
// Message final et envoi du mail récapitulatif si non annulé
if (!isESignCancelled) {
if (successCount > 0) {
toast.success(`${successCount} signature${successCount > 1 ? 's' : ''} électronique${successCount > 1 ? 's' : ''} créée${successCount > 1 ? 's' : ''} !`);
// Envoyer un mail récapitulatif au client (email_notifs + CC)
if (successfulContracts.length > 0) {
try {
console.log('📧 [E-SIGN] Début envoi emails récapitulatifs...');
console.log('📧 [E-SIGN] successfulContracts:', successfulContracts);
// Regrouper les contrats par organisation
const contractsByOrg = successfulContracts.reduce((acc: any, contract: any) => {
const orgId = contract.org_id;
console.log('📧 [E-SIGN] Contract:', contract.id, 'org_id:', orgId);
if (!acc[orgId]) {
acc[orgId] = [];
}
acc[orgId].push(contract);
return acc;
}, {});
console.log('📧 [E-SIGN] Contrats groupés par organisation:', Object.keys(contractsByOrg).length);
// Envoyer un email récapitulatif pour chaque organisation
for (const [orgId, contracts] of Object.entries(contractsByOrg)) {
const count = (contracts as any[]).length;
console.log(`📧 [E-SIGN] Envoi email pour org ${orgId}, ${count} contrats`);
const response = await fetch('/api/staff/contracts/send-esign-notification', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
organizationId: orgId,
contractCount: count
}),
});
if (!response.ok) {
const errorData = await response.json();
console.error('❌ [E-SIGN] Erreur envoi email:', errorData);
throw new Error(errorData.error || `Erreur ${response.status}`);
}
const result = await response.json();
console.log('✅ [E-SIGN] Email envoyé:', result);
}
toast.success('Email récapitulatif envoyé au client');
} catch (emailError) {
console.error("Erreur lors de l'envoi du mail récapitulatif:", emailError);
toast.warning("Signatures créées mais erreur lors de l'envoi de l'email récapitulatif");
}
}
}
if (errorCount > 0) {
toast.error(`${errorCount} erreur${errorCount > 1 ? 's' : ''} lors de la création des signatures`);
}
// Déselectionner les contrats après succès
if (successCount > 0) {
setSelectedContractIds(new Set());
}
}
} catch (error) {
console.error("Erreur lors de la génération des e-signatures:", error);
toast.error("Erreur lors de la génération des e-signatures");
} finally {
setIsGeneratingESign(false);
}
};
// Fonction pour annuler la génération e-sign
const cancelESignGeneration = () => {
setIsESignCancelled(true);
setIsGeneratingESign(false);
toast.info("Génération annulée");
};
// Fonction pour fermer le modal e-sign
const closeESignProgressModal = () => {
setShowESignProgressModal(false);
setContractsESignProgress([]);
setIsESignCancelled(false);
};
// Debounce searches when filters change
useEffect(() => {
// if no filters applied, prefer initial data
@ -786,6 +1032,14 @@ export default function ContractsGrid({ initialData, activeOrgId }: { initialDat
<Eye className="size-3" />
{isLoadingPdfs ? "Chargement..." : "Vérifier PDFs"}
</button>
<button
onClick={handleBulkESignClick}
disabled={isGeneratingESign}
className="px-3 py-1 text-sm bg-indigo-600 text-white rounded hover:bg-indigo-700 transition-colors disabled:bg-indigo-400 flex items-center gap-1"
>
<FileSignature className="size-3" />
{isGeneratingESign ? "Envoi..." : "Envoyer e-sign"}
</button>
<button
onClick={() => setSelectedContractIds(new Set())}
className="px-3 py-1 text-sm bg-gray-500 text-white rounded hover:bg-gray-600 transition-colors"
@ -1011,6 +1265,27 @@ export default function ContractsGrid({ initialData, activeOrgId }: { initialDat
contracts={contractsPdfs}
isLoading={isLoadingPdfs}
/>
{/* Modal de confirmation pour l'envoi des e-signatures */}
<BulkESignConfirmModal
isOpen={showESignConfirmModal}
onClose={() => setShowESignConfirmModal(false)}
onConfirm={generateBatchESign}
contractCount={selectedContractIds.size}
/>
{/* Modal de progression pour l'envoi des e-signatures */}
<BulkESignProgressModal
isOpen={showESignProgressModal}
onClose={closeESignProgressModal}
contracts={contractsESignProgress}
onCancel={isGeneratingESign ? cancelESignGeneration : undefined}
isProcessing={isGeneratingESign}
totalCount={contractsESignProgress.length}
completedCount={contractsESignProgress.filter(c => c.status === 'success' || c.status === 'error').length}
successCount={contractsESignProgress.filter(c => c.status === 'success').length}
errorCount={contractsESignProgress.filter(c => c.status === 'error').length}
/>
</div>
);
}

View file

@ -12,6 +12,7 @@ export type EmailType =
| 'signature-request'
| 'signature-request-employer'
| 'signature-request-employee'
| 'bulk-signature-notification'
| 'notification'
| 'account-activation'
| 'access-updated'

View file

@ -34,6 +34,7 @@ export type EmailTypeV2 =
| 'signature-request'
| 'signature-request-employer'
| 'signature-request-employee'
| 'bulk-signature-notification' // Nouveau type pour notification de signatures en masse
| 'notification'
// Accès / habilitations
| 'account-activation'
@ -68,6 +69,10 @@ export interface EmailDataV2 {
sepaDate?: string;
paymentMethod?: string;
invoiceType?: string;
// Ajout des champs pour les notifications de signatures en masse
contractCount?: number;
handlerName?: string;
status?: string;
[key: string]: unknown;
}
@ -78,6 +83,7 @@ interface EmailTemplateV2 {
greeting?: string;
closingMessage?: string;
ctaText?: string;
ctaUrl?: string; // URL du bouton CTA
footerText: string;
preheaderText: string;
@ -666,6 +672,40 @@ const EMAIL_TEMPLATES_V2: Record<EmailTypeV2, EmailTemplateV2> = {
}
},
'bulk-signature-notification': {
subject: 'Contrats à signer {{contractCount}} signature{{#if (gt contractCount 1)}}s{{/if}} en attente',
title: 'Contrats en attente de signature',
greeting: '{{#if firstName}}Bonjour {{firstName}},{{/if}}',
mainMessage: 'Vous avez {{contractCount}} contrat{{#if (gt contractCount 1)}}s{{/if}} en attente de signature électronique.<br><br>Pour consulter et signer vos contrats, cliquez sur le bouton ci-dessous :',
closingMessage: 'Si vous avez des questions, n\'hésitez pas à nous contacter à <a href="mailto:paie@odentas.fr" style="color:#0B5FFF; text-decoration:none;">paie@odentas.fr</a>.',
ctaText: 'Accéder aux signatures',
ctaUrl: 'https://paie.odentas.fr/signatures-electroniques',
footerText: 'Vous recevez cet e-mail car vous êtes client de Odentas, pour vous notifier d\'une action sur votre compte.',
preheaderText: 'Contrats à signer · {{contractCount}} signature{{#if (gt contractCount 1)}}s{{/if}} requise{{#if (gt contractCount 1)}}s{{/if}}',
colors: {
headerColor: STANDARD_COLORS.HEADER,
titleColor: '#0F172A',
buttonColor: STANDARD_COLORS.BUTTON,
buttonTextColor: STANDARD_COLORS.BUTTON_TEXT,
cardBackgroundColor: '#FFFFFF',
cardBorder: '#E5E7EB',
cardTitleColor: '#0F172A',
alertIndicatorColor: '#6366F1',
},
infoCard: [
{ label: 'Votre structure', key: 'organizationName' },
{ label: 'Votre code employeur', key: 'employerCode' },
{ label: 'Votre gestionnaire', key: 'handlerName' },
],
detailsCard: {
title: 'Signatures en attente',
rows: [
{ label: 'Nombre de contrats', key: 'contractCount' },
{ label: 'Statut', key: 'status' },
]
}
},
'notification': {
subject: 'Notification - {{title}}',
title: 'Notification',
@ -684,6 +724,11 @@ const EMAIL_TEMPLATES_V2: Record<EmailTypeV2, EmailTemplateV2> = {
}
};
// Register Handlebars helpers
Handlebars.registerHelper('gt', function(a: any, b: any) {
return a > b;
});
function replaceVariables(template: string, data: EmailDataV2): string {
const compiled = Handlebars.compile(template);
return compiled(data);
@ -729,7 +774,7 @@ export async function renderUniversalEmailV2(config: EmailConfigV2): Promise<{ s
footerText: replaceVariables(templateConfig.footerText, data),
preheaderText: replaceVariables(templateConfig.preheaderText, data),
textFallback: `${replaceVariables(templateConfig.title, data)} - ${replaceVariables(templateConfig.mainMessage, data)}`,
ctaUrl: config.type === 'invoice' ? 'https://paie.odentas.fr/facturation' : data.ctaUrl,
ctaUrl: templateConfig.ctaUrl || (config.type === 'invoice' ? 'https://paie.odentas.fr/facturation' : data.ctaUrl),
logoUrl: 'https://newstaging.odentas.fr/wp-content/uploads/2025/08/Odentas-Logo-Bleu-Fond-Transparent-4-1.png',
showInfoCard: !!templateConfig.infoCard,
infoCardRows: templateConfig.infoCard?.map(field => ({