Système signatures groupées
This commit is contained in:
parent
ba6b733ad0
commit
d99db42e12
9 changed files with 939 additions and 32 deletions
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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:', {
|
||||
|
|
|
|||
207
app/api/staff/contracts/bulk-esign/route.ts
Normal file
207
app/api/staff/contracts/bulk-esign/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
107
app/api/staff/contracts/send-esign-notification/route.ts
Normal file
107
app/api/staff/contracts/send-esign-notification/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
82
components/staff/BulkESignConfirmModal.tsx
Normal file
82
components/staff/BulkESignConfirmModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
182
components/staff/BulkESignProgressModal.tsx
Normal file
182
components/staff/BulkESignProgressModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export type EmailType =
|
|||
| 'signature-request'
|
||||
| 'signature-request-employer'
|
||||
| 'signature-request-employee'
|
||||
| 'bulk-signature-notification'
|
||||
| 'notification'
|
||||
| 'account-activation'
|
||||
| 'access-updated'
|
||||
|
|
|
|||
|
|
@ -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 => ({
|
||||
|
|
|
|||
Loading…
Reference in a new issue