From d7bdb1ef08a439062bae651075abb38fda6372c3 Mon Sep 17 00:00:00 2001 From: odentas Date: Wed, 22 Oct 2025 21:49:35 +0200 Subject: [PATCH] feat: Add notification tracking system with smart reminders - Add database columns for last_employer_notification_at and last_employee_notification_at in cddu_contracts - Update all email sending endpoints to record timestamps (remind-employer, relance-salarie, docuseal-signature, signature-salarie) - Create smart reminder system with 24h cooldown to prevent spam - Add progress tracking modal with real-time status (pending/sending/success/error) - Display actual employer/employee email addresses in reminder modal - Show notification timestamps in contracts grid with color coding (green/orange/red based on contract start date) - Change employer email button URL from DocuSeal direct link to /signatures-electroniques - Create /api/staff/organizations/emails endpoint for bulk email fetching - Add retroactive migration script for historical email_logs data - Update Contract TypeScript type and API responses to include new fields --- MIGRATION_NOTIF_RETROACTIVE.md | 102 ++++++ app/(app)/staff/contrats/page.tsx | 4 +- app/api/docuseal-signature/route.ts | 14 + app/api/emails/signature-salarie/route.ts | 27 +- .../signatures-electroniques/relance/route.ts | 1 + app/api/staff/contracts/search/route.ts | 1 + .../contrats/[id]/remind-employer/route.ts | 21 +- .../staff/contrats/relance-salarie/route.ts | 14 + app/api/staff/organizations/emails/route.ts | 47 +++ components/staff/ContractsGrid.tsx | 294 +++++++++++++++--- .../staff/contracts/SmartReminderModal.tsx | 248 +++++++++++++++ ...02_backfill_contract_ids_in_email_logs.sql | 101 ++++++ ...d_notification_timestamps_to_contracts.sql | 17 + 13 files changed, 831 insertions(+), 60 deletions(-) create mode 100644 MIGRATION_NOTIF_RETROACTIVE.md create mode 100644 app/api/staff/organizations/emails/route.ts create mode 100644 components/staff/contracts/SmartReminderModal.tsx create mode 100644 supabase/migrations/002_backfill_contract_ids_in_email_logs.sql create mode 100644 supabase/migrations/add_notification_timestamps_to_contracts.sql diff --git a/MIGRATION_NOTIF_RETROACTIVE.md b/MIGRATION_NOTIF_RETROACTIVE.md new file mode 100644 index 0000000..ccbe491 --- /dev/null +++ b/MIGRATION_NOTIF_RETROACTIVE.md @@ -0,0 +1,102 @@ +# Migration rétroactive des contract_id dans email_logs + +## Problème + +Les emails de signature électronique envoyés **avant la modification** n'ont pas de `contract_id` enregistré dans la table `email_logs`. Cela empêche l'affichage des notifications passées dans la colonne "Notif." de la page staff/contrats. + +## Solution + +Ce script de migration analyse les emails de signature existants et remplit rétroactivement le champ `contract_id` en utilisant les données du `template_data`. + +## Comment l'utiliser + +### Option 1 : Via l'éditeur SQL de Supabase (Recommandé) + +1. Connectez-vous à votre projet Supabase +2. Allez dans **SQL Editor** +3. Copiez-collez le contenu du fichier `002_backfill_contract_ids_in_email_logs.sql` +4. Cliquez sur **Run** +5. Vérifiez les logs qui affichent le nombre d'emails mis à jour + +### Option 2 : Via la CLI Supabase + +```bash +supabase db execute --file supabase/migrations/002_backfill_contract_ids_in_email_logs.sql +``` + +## Ce que fait le script + +1. **Parcourt** tous les emails de type signature (`signature-request-employer`, `signature-request-employee`, `signature-request-salarie`) qui n'ont pas de `contract_id` + +2. **Extrait** la référence du contrat depuis le `template_data` : + - `contractReference` + - `contract_number` + - `reference` + +3. **Recherche** le contrat correspondant dans la table `cddu_contracts` + +4. **Met à jour** le champ `contract_id` dans `email_logs` + +5. **Affiche** des statistiques finales + +## Résultats attendus + +Après l'exécution, vous devriez voir : + +``` +Début de la migration des contract_id dans email_logs... +Progression: 100 emails mis à jour... +Progression: 200 emails mis à jour... +Migration terminée: 247 emails mis à jour avec leur contract_id + +Statistiques après migration: +- Emails de signature avec contract_id: 247 +- Emails de signature sans contract_id: 12 +``` + +Les emails qui restent sans `contract_id` sont probablement : +- Des emails de test +- Des emails pour des contrats supprimés +- Des emails avec des données incomplètes + +## Vérification + +Pour vérifier manuellement : + +```sql +-- Voir les emails de signature avec leur contract_id +SELECT + el.id, + el.email_type, + el.recipient_email, + el.created_at, + el.contract_id, + c.contract_number +FROM email_logs el +LEFT JOIN cddu_contracts c ON c.id = el.contract_id +WHERE el.email_type IN ('signature-request-employer', 'signature-request-employee', 'signature-request-salarie') +ORDER BY el.created_at DESC +LIMIT 20; +``` + +## Impact + +- ✅ **Sécurité** : Lecture seule puis UPDATE uniquement sur `contract_id` +- ✅ **Performance** : Indexé sur `contract_id` après migration +- ✅ **Rollback** : Peut être annulé en mettant `contract_id` à NULL +- ✅ **Idempotent** : Peut être exécuté plusieurs fois sans problème + +## Rollback (si nécessaire) + +Si vous voulez annuler la migration : + +```sql +UPDATE email_logs +SET contract_id = NULL +WHERE email_type IN ('signature-request-employer', 'signature-request-employee', 'signature-request-salarie') + AND contract_id IS NOT NULL; +``` + +## Après la migration + +Une fois la migration exécutée, la colonne "Notif." dans `/staff/contrats` affichera **toutes** les notifications passées et futures pour chaque contrat. diff --git a/app/(app)/staff/contrats/page.tsx b/app/(app)/staff/contrats/page.tsx index 63f5983..59ea6e7 100644 --- a/app/(app)/staff/contrats/page.tsx +++ b/app/(app)/staff/contrats/page.tsx @@ -36,8 +36,8 @@ export default async function StaffContractsPage() { const { data: contracts, error } = await sb .from("cddu_contracts") .select( - `id, contract_number, employee_name, employee_id, structure, type_de_contrat, start_date, end_date, created_at, etat_de_la_demande, etat_de_la_paie, dpae, gross_pay, org_id, - salaries!employee_id(salarie, nom, prenom)` + `id, contract_number, employee_name, employee_id, structure, type_de_contrat, start_date, end_date, created_at, etat_de_la_demande, etat_de_la_paie, dpae, gross_pay, org_id, contrat_signe_par_employeur, contrat_signe, last_employer_notification_at, last_employee_notification_at, + salaries!employee_id(salarie, nom, prenom, adresse_mail)` ) .order("start_date", { ascending: false }) .limit(200); diff --git a/app/api/docuseal-signature/route.ts b/app/api/docuseal-signature/route.ts index b501346..4ff1141 100644 --- a/app/api/docuseal-signature/route.ts +++ b/app/api/docuseal-signature/route.ts @@ -330,6 +330,7 @@ export async function POST(request: NextRequest) { contractReference: reference, status: 'En attente', ctaUrl: signatureLink, + contractId: contractId, // Ajout pour traçabilité dans email_logs }; // Rendu HTML pour archivage puis envoi @@ -347,6 +348,19 @@ export async function POST(request: NextRequest) { data: emailData, }); + // Mettre à jour le timestamp de dernière notification employeur + const now = new Date().toISOString(); + const { error: updateError } = await supabase + .from('cddu_contracts') + .update({ last_employer_notification_at: now }) + .eq('id', contractId); + + if (updateError) { + console.warn("⚠️ [DOCUSEAL] Impossible de mettre à jour last_employer_notification_at:", updateError); + } else { + console.log("✅ [DOCUSEAL] Timestamp notification employeur enregistré:", now); + } + // Étape 6 : Upload de l'email HTML rendu sur S3 et logging const emailHtml = rendered.html; emailLink = await uploadEmailToS3(emailHtml, messageId); diff --git a/app/api/emails/signature-salarie/route.ts b/app/api/emails/signature-salarie/route.ts index 73bdedb..6c3d7b4 100644 --- a/app/api/emails/signature-salarie/route.ts +++ b/app/api/emails/signature-salarie/route.ts @@ -192,25 +192,38 @@ export async function POST(request: NextRequest) { reference }); - // 7. Stocker le signature_link dans cddu_contracts pour permettre la vérification - if (contractId && signatureLink) { - console.log('💾 Stockage du signature_link dans cddu_contracts...'); + // 7. Mettre à jour le timestamp de dernière notification salarié et stocker le signature_link + if (contractId) { + console.log('💾 Mise à jour du contrat dans cddu_contracts...'); try { const supabase = createSbServiceRole(); + const now = new Date().toISOString(); + + const updateData: any = { + last_employee_notification_at: now + }; + + if (signatureLink) { + updateData.signature_link = signatureLink; + } + const { error: updateError } = await supabase .from('cddu_contracts') - .update({ signature_link: signatureLink }) + .update(updateData) .eq('id', contractId); if (updateError) { - console.error('⚠️ Erreur lors de la mise à jour du signature_link:', updateError); + console.error('⚠️ Erreur lors de la mise à jour:', updateError); // Ne pas bloquer le flux, l'email est déjà envoyé } else { - console.log('✅ signature_link stocké avec succès'); + console.log('✅ Timestamp notification salarié enregistré:', now); + if (signatureLink) { + console.log('✅ signature_link stocké avec succès'); + } } } catch (err) { - console.error('⚠️ Exception lors du stockage du signature_link:', err); + console.error('⚠️ Exception lors de la mise à jour:', err); // Ne pas bloquer le flux } } diff --git a/app/api/signatures-electroniques/relance/route.ts b/app/api/signatures-electroniques/relance/route.ts index 10b5a19..d9f6ba1 100644 --- a/app/api/signatures-electroniques/relance/route.ts +++ b/app/api/signatures-electroniques/relance/route.ts @@ -213,6 +213,7 @@ export async function POST(req: NextRequest) { documentType: contract.role || 'Contrat', contractReference: contract.reference || String(contract.id), ctaUrl: signatureLink, + contractId: contractId, // Ajout pour traçabilité dans email_logs }; const messageId = await sendUniversalEmailV2({ diff --git a/app/api/staff/contracts/search/route.ts b/app/api/staff/contracts/search/route.ts index bc32a93..1ab4de6 100644 --- a/app/api/staff/contracts/search/route.ts +++ b/app/api/staff/contracts/search/route.ts @@ -37,6 +37,7 @@ export async function GET(req: Request) { id, contract_number, employee_name, employee_matricule, employee_id, structure, type_de_contrat, start_date, end_date, created_at, etat_de_la_demande, etat_de_la_paie, dpae, gross_pay, contrat_signe_par_employeur, contrat_signe, org_id, + last_employer_notification_at, last_employee_notification_at, salaries!employee_id(salarie, nom, prenom, adresse_mail) `, { count: "exact" }); diff --git a/app/api/staff/contrats/[id]/remind-employer/route.ts b/app/api/staff/contrats/[id]/remind-employer/route.ts index a8ce21c..6291b5e 100644 --- a/app/api/staff/contrats/[id]/remind-employer/route.ts +++ b/app/api/staff/contrats/[id]/remind-employer/route.ts @@ -213,15 +213,14 @@ export async function POST( console.log("🔗 [REMIND-EMPLOYER] Récupération lien signature DocuSeal..."); - // Récupération du slug employeur depuis DocuSeal + // Récupération du slug employeur depuis DocuSeal (pour référence) const employerSlug = await getEmployerSlug(contractData.docusealSubID); - - // Création du lien de signature const signatureLink = `https://staging.paie.odentas.fr/odentas-sign?docuseal_id=${employerSlug}`; console.log("📧 [REMIND-EMPLOYER] Envoi email de rappel via template universel..."); // Préparation des données au format universel + // Le bouton redirige maintenant vers la page des signatures électroniques const emailData: EmailDataV2 = { firstName: contractData.prenom_signataire || 'Employeur', organizationName: contractData.structure, @@ -230,7 +229,8 @@ export async function POST( documentType: contractData.typecontrat || 'Contrat de travail', contractReference: contractData.reference, status: 'En attente', - ctaUrl: signatureLink, + ctaUrl: 'https://paie.odentas.fr/signatures-electroniques', + contractId: params.id, // Ajout pour traçabilité dans email_logs }; await sendUniversalEmailV2({ @@ -242,6 +242,19 @@ export async function POST( console.log("✅ [REMIND-EMPLOYER] Email envoyé avec succès via système universel"); + // Mettre à jour le timestamp de dernière notification employeur + const now = new Date().toISOString(); + const { error: updateError } = await sb + .from('cddu_contracts') + .update({ last_employer_notification_at: now }) + .eq('id', params.id); + + if (updateError) { + console.warn("⚠️ [REMIND-EMPLOYER] Impossible de mettre à jour last_employer_notification_at:", updateError); + } else { + console.log("✅ [REMIND-EMPLOYER] Timestamp de notification enregistré:", now); + } + return NextResponse.json({ success: true, message: "Relance employeur envoyée avec succès", diff --git a/app/api/staff/contrats/relance-salarie/route.ts b/app/api/staff/contrats/relance-salarie/route.ts index b2abcfa..7b26cec 100644 --- a/app/api/staff/contrats/relance-salarie/route.ts +++ b/app/api/staff/contrats/relance-salarie/route.ts @@ -217,6 +217,7 @@ export async function POST(req: NextRequest) { contractReference: contract.reference || String(contract.id), typecontrat: (contract as any).type_de_contrat || '', ctaUrl: signatureLink, + contractId: contractId, // Ajout pour traçabilité dans email_logs }; const messageId = await sendUniversalEmailV2({ @@ -234,6 +235,19 @@ export async function POST(req: NextRequest) { messageId }); + // Mettre à jour le timestamp de dernière notification salarié + const now = new Date().toISOString(); + const { error: updateError } = await sb + .from('cddu_contracts') + .update({ last_employee_notification_at: now }) + .eq('id', contractId); + + if (updateError) { + console.warn("⚠️ [RELANCE-SALARIE] Impossible de mettre à jour last_employee_notification_at:", updateError); + } else { + console.log("✅ [RELANCE-SALARIE] Timestamp de notification enregistré:", now); + } + return NextResponse.json({ success: true, message: 'Email de relance envoyé avec succès', diff --git a/app/api/staff/organizations/emails/route.ts b/app/api/staff/organizations/emails/route.ts new file mode 100644 index 0000000..9d9c177 --- /dev/null +++ b/app/api/staff/organizations/emails/route.ts @@ -0,0 +1,47 @@ +export const dynamic = 'force-dynamic'; +export const revalidate = 0; + +import { NextResponse } from "next/server"; +import { createSbServer } from "@/lib/supabaseServer"; + +/** + * Récupère les emails de signature (employeur) pour plusieurs organisations + * POST /api/staff/organizations/emails + * Body: { org_ids: string[] } + */ +export async function POST(req: Request) { + try { + const sb = createSbServer(); + + // Vérification de l'authentification + const { data: { user } } = await sb.auth.getUser(); + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + // Vérification des droits staff + const { data: me } = await sb.from("staff_users").select("is_staff").eq("user_id", user.id).maybeSingle(); + if (!me?.is_staff) return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + + const body = await req.json(); + const orgIds = body.org_ids as string[]; + + if (!Array.isArray(orgIds) || orgIds.length === 0) { + return NextResponse.json({ error: "org_ids array required" }, { status: 400 }); + } + + // Récupérer les emails de signature pour toutes les organisations + const { data, error } = await sb + .from("organization_details") + .select("org_id, email_signature") + .in("org_id", orgIds); + + if (error) { + console.error('Error fetching organization emails:', error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } + + return NextResponse.json(data || []); + } catch (err: any) { + console.error('Unexpected error in /api/staff/organizations/emails:', err); + return NextResponse.json({ error: err.message || "Internal Server Error" }, { status: 500 }); + } +} diff --git a/components/staff/ContractsGrid.tsx b/components/staff/ContractsGrid.tsx index e95208e..620588b 100644 --- a/components/staff/ContractsGrid.tsx +++ b/components/staff/ContractsGrid.tsx @@ -12,6 +12,7 @@ import BulkESignProgressModal from "./BulkESignProgressModal"; import BulkESignConfirmModal from "./BulkESignConfirmModal"; import { BulkEmployeeReminderModal } from "./contracts/BulkEmployeeReminderModal"; import { EmployeeReminderModal } from "./contracts/EmployeeReminderModal"; +import { SmartReminderModal, type SmartReminderContract, type ReminderAction } from "./contracts/SmartReminderModal"; // Utility function to format dates as DD/MM/YYYY function formatDate(dateString: string | null | undefined): string { @@ -125,6 +126,8 @@ type Contract = { org_id?: string | null; contrat_signe_par_employeur?: string | null; contrat_signe?: string | null; + last_employer_notification_at?: string | null; + last_employee_notification_at?: string | null; salaries?: { salarie?: string | null; nom?: string | null; @@ -276,6 +279,10 @@ function ContractsGridImpl({ initialData, activeOrgId }: { initialData: Contract const [showBulkReminderModal, setShowBulkReminderModal] = useState(false); const [bulkReminderContracts, setBulkReminderContracts] = useState>([]); const [isLoadingReminderEmails, setIsLoadingReminderEmails] = useState(false); + // Smart reminder modal state + const [showSmartReminderModal, setShowSmartReminderModal] = useState(false); + const [smartReminderContracts, setSmartReminderContracts] = useState([]); + const [smartReminderProgress, setSmartReminderProgress] = useState([]); // Quick filters helpers const toYMD = (d: Date) => { @@ -1083,42 +1090,24 @@ function ContractsGridImpl({ initialData, activeOrgId }: { initialData: Contract setIsESignCancelled(false); }; - // Fonction pour récupérer les dernières notifications de signature pour un ensemble de contrats - const fetchLastNotifications = async (contractIds: string[]) => { - if (contractIds.length === 0) return; - - try { - const { data, error } = await supabase - .from('email_logs') - .select('contract_id, created_at, email_type') - .in('contract_id', contractIds) - .in('email_type', ['signature-request-employer', 'signature-request-employee', 'signature-request-salarie']) - .order('created_at', { ascending: false }); - - if (error) { - console.error('Erreur lors de la récupération des notifications:', error); - return; + // Fonction pour extraire les notifications directement des données de contrats + // Plus besoin d'appel API séparé, les timestamps sont maintenant dans cddu_contracts + const updateNotificationsFromRows = (contracts: Contract[]) => { + const map = new Map(); + + contracts.forEach(contract => { + const info: NotificationInfo = { + employerLastSent: contract.last_employer_notification_at || null, + employeeLastSent: contract.last_employee_notification_at || null, + }; + + // N'ajouter à la map que si au moins une notification existe + if (info.employerLastSent || info.employeeLastSent) { + map.set(contract.id, info); } + }); - // Grouper par contract_id et trouver les dernières notifications - const map = new Map(); - data?.forEach(log => { - if (!map.has(log.contract_id)) { - map.set(log.contract_id, { employerLastSent: null, employeeLastSent: null }); - } - const info = map.get(log.contract_id)!; - if (log.email_type === 'signature-request-employer' && !info.employerLastSent) { - info.employerLastSent = log.created_at; - } - if (['signature-request-employee', 'signature-request-salarie'].includes(log.email_type) && !info.employeeLastSent) { - info.employeeLastSent = log.created_at; - } - }); - - setNotificationMap(map); - } catch (error) { - console.error('Exception lors de la récupération des notifications:', error); - } + setNotificationMap(map); }; // Debounce searches when filters change @@ -1137,9 +1126,8 @@ function ContractsGridImpl({ initialData, activeOrgId }: { initialData: Contract // Récupérer les notifications quand les données changent useEffect(() => { - const contractIds = rows.map(r => r.id); - if (contractIds.length > 0) { - fetchLastNotifications(contractIds); + if (rows.length > 0) { + updateNotificationsFromRows(rows); } }, [rows]); @@ -1276,7 +1264,125 @@ function ContractsGridImpl({ initialData, activeOrgId }: { initialData: Contract } }; - // Fonction pour relancer en masse tous les salariés sélectionnés + // Fonction pour analyser intelligemment les contrats et déterminer qui relancer + const handleSmartReminderClick = async () => { + if (selectedContractIds.size === 0) { + toast.error("Aucun contrat sélectionné"); + return; + } + + setIsLoadingReminderEmails(true); + + try { + // Récupérer les contrats sélectionnés + const selectedContracts = rows.filter(r => selectedContractIds.has(r.id)); + + // Récupérer les emails employeurs depuis l'API + const orgIds = [...new Set(selectedContracts.map(c => c.org_id).filter(Boolean))]; + const employerEmailsMap = new Map(); + + try { + const response = await fetch('/api/staff/organizations/emails', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ org_ids: orgIds }), + }); + + if (response.ok) { + const data = await response.json(); + // data devrait être un array de { org_id, email_signature } + data.forEach((org: any) => { + if (org.org_id && org.email_signature) { + employerEmailsMap.set(org.org_id, org.email_signature); + } + }); + } + } catch (error) { + console.error('Erreur lors de la récupération des emails employeurs:', error); + } + + // Fonction pour vérifier si une relance a été envoyée il y a moins de 24h + const wasRecentlySent = (timestamp: string | null | undefined): boolean => { + if (!timestamp) return false; + const sentDate = new Date(timestamp); + const now = new Date(); + const hoursDiff = (now.getTime() - sentDate.getTime()) / (1000 * 60 * 60); + return hoursDiff < 24; + }; + + // Analyser chaque contrat pour déterminer l'action appropriée + const analyzedContracts: SmartReminderContract[] = selectedContracts.map(contract => { + const employerSigned = contract.contrat_signe_par_employeur === 'Oui'; + const employeeSigned = contract.contrat_signe === 'Oui'; + const contractProcessed = String(contract.etat_de_la_demande || contract.etat_demande || "").toLowerCase().includes('traité') || + String(contract.etat_de_la_demande || contract.etat_demande || "").toLowerCase().includes('traitée'); + + const employerRecentlySent = wasRecentlySent(contract.last_employer_notification_at); + const employeeRecentlySent = wasRecentlySent(contract.last_employee_notification_at); + + let action: ReminderAction; + let reason: string; + + // Logique de décision + if (employerSigned && employeeSigned) { + action = 'already-signed'; + reason = 'Contrat entièrement signé'; + } else if (!contractProcessed) { + action = 'skip'; + reason = 'Contrat non traité (e-signature non envoyée)'; + } else if (!employerSigned) { + if (employerRecentlySent) { + action = 'skip'; + reason = 'Relance employeur envoyée il y a moins de 24h'; + } else { + action = 'employer'; + reason = 'Employeur n\'a pas encore signé'; + } + } else if (employerSigned && !employeeSigned) { + if (employeeRecentlySent) { + action = 'skip'; + reason = 'Relance salarié envoyée il y a moins de 24h'; + } else { + action = 'employee'; + reason = 'Salarié n\'a pas encore signé'; + } + } else { + action = 'skip'; + reason = 'État indéterminé'; + } + + const employerEmail = contract.org_id ? employerEmailsMap.get(contract.org_id) : undefined; + + return { + id: contract.id, + reference: (contract as any).reference as string | null | undefined, + contract_number: contract.contract_number, + employee_name: formatEmployeeName(contract as any), + employee_email: (contract as any)?.salaries?.adresse_mail || undefined, + employer_email: employerEmail, + employer_signed: employerSigned, + employee_signed: employeeSigned, + contract_processed: contractProcessed, + action, + reason, + status: 'pending' as const + }; + }); + + // Trier par action pour une meilleure lisibilité + const sorted = analyzedContracts.sort((a, b) => { + const order = { 'employer': 0, 'employee': 1, 'skip': 2, 'already-signed': 3 }; + return order[a.action] - order[b.action]; + }); + + setSmartReminderContracts(sorted); + setShowSmartReminderModal(true); + } finally { + setIsLoadingReminderEmails(false); + } + }; + + // Fonction pour relancer en masse tous les salariés sélectionnés (ancienne version - conservée pour compatibilité) const handleBulkReminderClick = async () => { if (selectedContractIds.size === 0) { toast.error("Aucun contrat sélectionné"); @@ -1318,7 +1424,89 @@ function ContractsGridImpl({ initialData, activeOrgId }: { initialData: Contract } }; - // Envoi des relances après confirmation dans le modal + // Envoi des relances intelligentes après confirmation + const confirmSendSmartReminders = async () => { + setIsLoadingReminder(true); + + const toSend = smartReminderContracts.filter(c => c.action === 'employer' || c.action === 'employee'); + + // Initialiser le state de progression avec tous les contrats + setSmartReminderProgress([...smartReminderContracts]); + + let successCount = 0; + let errorCount = 0; + + try { + for (const contract of toSend) { + // Mettre à jour le statut à "sending" + setSmartReminderProgress(prev => + prev.map(c => c.id === contract.id ? { ...c, status: 'sending' as const } : c) + ); + + try { + if (contract.action === 'employer') { + // Relancer l'employeur + const response = await fetch(`/api/staff/contrats/${contract.id}/remind-employer`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + + if (response.ok) { + successCount++; + setSmartReminderProgress(prev => + prev.map(c => c.id === contract.id ? { ...c, status: 'success' as const } : c) + ); + } else { + errorCount++; + setSmartReminderProgress(prev => + prev.map(c => c.id === contract.id ? { ...c, status: 'error' as const } : c) + ); + } + } else if (contract.action === 'employee') { + // Relancer le salarié + const response = await fetch('/api/staff/contrats/relance-salarie', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ contractId: contract.id }), + }); + + if (response.ok) { + successCount++; + setSmartReminderProgress(prev => + prev.map(c => c.id === contract.id ? { ...c, status: 'success' as const } : c) + ); + } else { + errorCount++; + setSmartReminderProgress(prev => + prev.map(c => c.id === contract.id ? { ...c, status: 'error' as const } : c) + ); + } + } + } catch { + errorCount++; + setSmartReminderProgress(prev => + prev.map(c => c.id === contract.id ? { ...c, status: 'error' as const } : c) + ); + } + } + + if (successCount > 0) { + toast.success(`${successCount} relance${successCount > 1 ? 's' : ''} envoyée${successCount > 1 ? 's' : ''} avec succès`); + } + if (errorCount > 0) { + toast.error(`${errorCount} erreur${errorCount > 1 ? 's' : ''} lors de l'envoi`); + } + + // Rafraîchir les données des contrats pour récupérer les nouveaux timestamps + // Les notifications seront automatiquement mises à jour via l'effet useEffect sur rows + setTimeout(() => fetchServer(page), 1000); + } finally { + setIsLoadingReminder(false); + setShowSmartReminderModal(false); + } + }; + + // Envoi des relances après confirmation dans le modal (ancienne version) const confirmSendBulkReminders = async () => { setIsLoadingReminder(true); const ids = bulkReminderContracts.map(c => c.id); @@ -1696,13 +1884,13 @@ function ContractsGridImpl({ initialData, activeOrgId }: { initialData: Contract @@ -1865,25 +2053,27 @@ function ContractsGridImpl({ initialData, activeOrgId }: { initialData: Contract {formatDate(r.end_date)} {(() => { - const notifInfo = notificationMap.get(r.id); + // Lire directement depuis le contrat au lieu de la map pour éviter les problèmes de timing + const employerLastSent = r.last_employer_notification_at; + const employeeLastSent = r.last_employee_notification_at; const color = getNotificationColor(r.start_date); - if (!notifInfo || (!notifInfo.employerLastSent && !notifInfo.employeeLastSent)) { + if (!employerLastSent && !employeeLastSent) { return ; } return (
- {notifInfo.employerLastSent && ( + {employerLastSent && (
E: - {formatNotificationDate(notifInfo.employerLastSent)} + {formatNotificationDate(employerLastSent)}
)} - {notifInfo.employeeLastSent && ( + {employeeLastSent && (
S: - {formatNotificationDate(notifInfo.employeeLastSent)} + {formatNotificationDate(employeeLastSent)}
)}
@@ -2067,6 +2257,16 @@ function ContractsGridImpl({ initialData, activeOrgId }: { initialData: Contract contracts={bulkReminderContracts} /> + {/* Modal intelligent pour les relances */} + setShowSmartReminderModal(false)} + onConfirm={confirmSendSmartReminders} + isLoading={isLoadingReminder} + contracts={smartReminderContracts} + progressContracts={smartReminderProgress.length > 0 ? smartReminderProgress : undefined} + /> + {/* Modal de progression pour l'envoi des e-signatures */} void; + onConfirm: () => void; + isLoading: boolean; + contracts: SmartReminderContract[]; + progressContracts?: SmartReminderContract[]; // Contrats avec statut de progression +} + +export const SmartReminderModal: React.FC = ({ + isOpen, + onClose, + onConfirm, + isLoading, + contracts, + progressContracts +}) => { + // Utiliser progressContracts si disponible (pendant l'envoi), sinon contracts (avant l'envoi) + const displayContracts = progressContracts || contracts; + + const employerReminders = contracts.filter(c => c.action === 'employer').length; + const employeeReminders = contracts.filter(c => c.action === 'employee').length; + const skipped = contracts.filter(c => c.action === 'skip').length; + const alreadySigned = contracts.filter(c => c.action === 'already-signed').length; + + const getActionBadge = (action: ReminderAction, status?: ReminderStatus) => { + // Si un statut de progression existe, l'afficher à la place + if (status === 'sending') { + return ( + + + Envoi en cours... + + ); + } + if (status === 'success') { + return ( + + + Envoyé + + ); + } + if (status === 'error') { + return ( + + + Erreur + + ); + } + + // Sinon afficher l'action prévue + switch (action) { + case 'employer': + return ( + + + Relance employeur + + ); + case 'employee': + return ( + + + Relance salarié + + ); + case 'already-signed': + return ( + + + Déjà signé + + ); + case 'skip': + return ( + + + Non traité + + ); + } + }; + + const getEmailForAction = (contract: SmartReminderContract): string => { + if (contract.action === 'employer') { + return contract.employer_email || '—'; + } + if (contract.action === 'employee') { + return contract.employee_email || '—'; + } + return '—'; + }; + + const totalToSend = employerReminders + employeeReminders; + const totalSent = displayContracts.filter(c => c.status === 'success').length; + const totalErrors = displayContracts.filter(c => c.status === 'error').length; + + return ( + + + + + + Relances intelligentes - Analyse des contrats + + + Le système a analysé les {contracts.length} contrat{contracts.length > 1 ? 's' : ''} sélectionné{contracts.length > 1 ? 's' : ''} et déterminé automatiquement qui relancer. + + + + {/* Résumé */} +
+ {employerReminders > 0 && ( +
+
{employerReminders}
+
Employeur
+
+ )} + {employeeReminders > 0 && ( +
+
{employeeReminders}
+
Salarié
+
+ )} + {alreadySigned > 0 && ( +
+
{alreadySigned}
+
Déjà signé
+
+ )} + {skipped > 0 && ( +
+
{skipped}
+
Non traité
+
+ )} +
+ + {totalToSend === 0 && ( +
+ +
+ Aucune relance à envoyer. Tous les contrats sont soit déjà signés, soit en attente de traitement. +
+
+ )} + + {/* Barre de progression pendant l'envoi */} + {isLoading && ( +
+
+ Progression + {totalSent} / {totalToSend} +
+
+
0 ? (totalSent / totalToSend) * 100 : 0}%` }} + /> +
+ {totalErrors > 0 && ( +
+ {totalErrors} erreur{totalErrors > 1 ? 's' : ''} +
+ )} +
+ )} + + {/* Liste des contrats */} +
+ + + + + + + + + + + + {displayContracts.map(c => ( + + + + + + + + ))} + +
RéférenceSalariéEmailActionRaison
{c.reference || c.contract_number || '—'}{c.employee_name || '—'} + {getEmailForAction(c)} + + {getActionBadge(c.action, c.status)} + {c.reason || '—'}
+
+ + + + + + +
+ ); +}; diff --git a/supabase/migrations/002_backfill_contract_ids_in_email_logs.sql b/supabase/migrations/002_backfill_contract_ids_in_email_logs.sql new file mode 100644 index 0000000..3f8b3c3 --- /dev/null +++ b/supabase/migrations/002_backfill_contract_ids_in_email_logs.sql @@ -0,0 +1,101 @@ +-- Migration pour rétroactivement associer les emails de signature aux contrats +-- À exécuter dans l'éditeur SQL de Supabase + +-- Cette migration remplit le champ contract_id des emails de signature existants +-- en utilisant les données du template_data (contractReference, contract_number, etc.) + +DO $$ +DECLARE + updated_count INTEGER := 0; + email_record RECORD; + contract_reference TEXT; + found_contract_id UUID; +BEGIN + RAISE NOTICE 'Début de la migration des contract_id dans email_logs...'; + + -- Parcourir tous les emails de signature qui n'ont pas de contract_id + FOR email_record IN + SELECT id, template_data, subject + FROM email_logs + WHERE email_type IN ('signature-request-employer', 'signature-request-employee', 'signature-request-salarie') + AND contract_id IS NULL + AND template_data IS NOT NULL + LOOP + found_contract_id := NULL; + contract_reference := NULL; + + -- Essayer d'extraire la référence du contrat depuis template_data + -- Les templates peuvent contenir contractReference, contract_number, ou reference + IF email_record.template_data ? 'contractReference' THEN + contract_reference := email_record.template_data->>'contractReference'; + ELSIF email_record.template_data ? 'contract_number' THEN + contract_reference := email_record.template_data->>'contract_number'; + ELSIF email_record.template_data ? 'reference' THEN + contract_reference := email_record.template_data->>'reference'; + END IF; + + -- Si on a trouvé une référence, chercher le contrat correspondant + IF contract_reference IS NOT NULL AND contract_reference != '' THEN + -- Chercher dans cddu_contracts par contract_number + SELECT id INTO found_contract_id + FROM cddu_contracts + WHERE contract_number = contract_reference + LIMIT 1; + + -- Si pas trouvé, essayer avec la colonne reference (si elle existe) + IF found_contract_id IS NULL THEN + BEGIN + SELECT id INTO found_contract_id + FROM cddu_contracts + WHERE reference = contract_reference + LIMIT 1; + EXCEPTION + WHEN undefined_column THEN + -- La colonne reference n'existe pas, on ignore + NULL; + END; + END IF; + + -- Mettre à jour l'email si on a trouvé un contrat + IF found_contract_id IS NOT NULL THEN + UPDATE email_logs + SET contract_id = found_contract_id + WHERE id = email_record.id; + + updated_count := updated_count + 1; + + IF updated_count % 100 = 0 THEN + RAISE NOTICE 'Progression: % emails mis à jour...', updated_count; + END IF; + END IF; + END IF; + END LOOP; + + RAISE NOTICE 'Migration terminée: % emails mis à jour avec leur contract_id', updated_count; + + -- Afficher quelques statistiques + RAISE NOTICE 'Statistiques après migration:'; + RAISE NOTICE '- Emails de signature avec contract_id: %', + (SELECT COUNT(*) FROM email_logs + WHERE email_type IN ('signature-request-employer', 'signature-request-employee', 'signature-request-salarie') + AND contract_id IS NOT NULL); + RAISE NOTICE '- Emails de signature sans contract_id: %', + (SELECT COUNT(*) FROM email_logs + WHERE email_type IN ('signature-request-employer', 'signature-request-employee', 'signature-request-salarie') + AND contract_id IS NULL); + +END $$; + +-- Créer un index sur contract_id s'il n'existe pas déjà +CREATE INDEX IF NOT EXISTS idx_email_logs_contract_id ON email_logs(contract_id); + +-- Afficher les résultats finaux +SELECT + email_type, + COUNT(*) as total, + COUNT(contract_id) as with_contract_id, + COUNT(*) - COUNT(contract_id) as without_contract_id +FROM email_logs +WHERE email_type IN ('signature-request-employer', 'signature-request-employee', 'signature-request-salarie') +GROUP BY email_type +ORDER BY email_type; diff --git a/supabase/migrations/add_notification_timestamps_to_contracts.sql b/supabase/migrations/add_notification_timestamps_to_contracts.sql new file mode 100644 index 0000000..a4cb8bd --- /dev/null +++ b/supabase/migrations/add_notification_timestamps_to_contracts.sql @@ -0,0 +1,17 @@ +-- Migration: Ajout de colonnes pour tracer les dernières notifications de signature +-- Date: 2025-10-22 +-- Description: Ajoute last_employer_notification_at et last_employee_notification_at +-- pour suivre les envois individuels et groupés de relances + +-- Ajouter les colonnes de timestamp pour les notifications +ALTER TABLE cddu_contracts +ADD COLUMN IF NOT EXISTS last_employer_notification_at TIMESTAMPTZ, +ADD COLUMN IF NOT EXISTS last_employee_notification_at TIMESTAMPTZ; + +-- Créer des index pour optimiser les requêtes de tri/filtrage +CREATE INDEX IF NOT EXISTS idx_cddu_contracts_employer_notif ON cddu_contracts(last_employer_notification_at); +CREATE INDEX IF NOT EXISTS idx_cddu_contracts_employee_notif ON cddu_contracts(last_employee_notification_at); + +-- Ajouter des commentaires pour la documentation +COMMENT ON COLUMN cddu_contracts.last_employer_notification_at IS 'Timestamp de la dernière notification de signature envoyée à l''employeur (individuelle ou groupée)'; +COMMENT ON COLUMN cddu_contracts.last_employee_notification_at IS 'Timestamp de la dernière notification de signature envoyée au salarié';