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
This commit is contained in:
odentas 2025-10-22 21:49:35 +02:00
parent 807cb20456
commit d7bdb1ef08
13 changed files with 831 additions and 60 deletions

View file

@ -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.

View file

@ -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);

View file

@ -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);

View file

@ -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
}
}

View file

@ -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({

View file

@ -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" });

View file

@ -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",

View file

@ -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',

View file

@ -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 });
}
}

View file

@ -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<Array<{ id: string; reference?: string | null; contract_number?: string | null; employee_name?: string | null; employee_email?: string | null }>>([]);
const [isLoadingReminderEmails, setIsLoadingReminderEmails] = useState(false);
// Smart reminder modal state
const [showSmartReminderModal, setShowSmartReminderModal] = useState(false);
const [smartReminderContracts, setSmartReminderContracts] = useState<SmartReminderContract[]>([]);
const [smartReminderProgress, setSmartReminderProgress] = useState<SmartReminderContract[]>([]);
// 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;
// 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<string, NotificationInfo>();
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 });
contracts.forEach(contract => {
const info: NotificationInfo = {
employerLastSent: contract.last_employer_notification_at || null,
employeeLastSent: contract.last_employee_notification_at || null,
};
if (error) {
console.error('Erreur lors de la récupération des notifications:', error);
return;
// 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<string, NotificationInfo>();
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<string, string>();
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
<button
onClick={() => {
setShowESignMenu(false);
handleBulkReminderClick();
handleSmartReminderClick();
}}
disabled={isLoadingReminder}
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors disabled:text-gray-400 disabled:hover:bg-white flex items-center gap-2"
>
<BellRing className="w-4 h-4" />
{isLoadingReminder ? "Envoi..." : "Relancer salariés"}
{isLoadingReminder ? "Analyse..." : "Relances intelligentes"}
</button>
</div>
</div>
@ -1865,25 +2053,27 @@ function ContractsGridImpl({ initialData, activeOrgId }: { initialData: Contract
<td className="px-3 py-2">{formatDate(r.end_date)}</td>
<td className="px-3 py-2">
{(() => {
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 <span className="text-xs text-slate-400"></span>;
}
return (
<div className={`flex flex-col gap-1 text-xs ${color}`} title={`E: Employeur • S: Salarié\nCouleur basée sur la date de début du contrat`}>
{notifInfo.employerLastSent && (
{employerLastSent && (
<div className="flex items-center gap-1">
<span className="font-semibold">E:</span>
<span>{formatNotificationDate(notifInfo.employerLastSent)}</span>
<span>{formatNotificationDate(employerLastSent)}</span>
</div>
)}
{notifInfo.employeeLastSent && (
{employeeLastSent && (
<div className="flex items-center gap-1">
<span className="font-semibold">S:</span>
<span>{formatNotificationDate(notifInfo.employeeLastSent)}</span>
<span>{formatNotificationDate(employeeLastSent)}</span>
</div>
)}
</div>
@ -2067,6 +2257,16 @@ function ContractsGridImpl({ initialData, activeOrgId }: { initialData: Contract
contracts={bulkReminderContracts}
/>
{/* Modal intelligent pour les relances */}
<SmartReminderModal
isOpen={showSmartReminderModal}
onClose={() => setShowSmartReminderModal(false)}
onConfirm={confirmSendSmartReminders}
isLoading={isLoadingReminder}
contracts={smartReminderContracts}
progressContracts={smartReminderProgress.length > 0 ? smartReminderProgress : undefined}
/>
{/* Modal de progression pour l'envoi des e-signatures */}
<BulkESignProgressModal
isOpen={showESignProgressModal}

View file

@ -0,0 +1,248 @@
"use client";
import React from 'react';
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Mail, AlertTriangle, CheckCircle, XCircle, Clock, Loader2 } from "lucide-react";
export type ReminderAction = 'employer' | 'employee' | 'skip' | 'already-signed';
export type ReminderStatus = 'pending' | 'sending' | 'success' | 'error';
export interface SmartReminderContract {
id: string;
reference?: string | null;
contract_number?: string | null;
employee_name?: string | null;
employee_email?: string | null;
employer_email?: string | null;
employer_signed: boolean;
employee_signed: boolean;
contract_processed: boolean; // État de la demande = "Traitée"
action: ReminderAction;
reason?: string;
status?: ReminderStatus;
}
export interface SmartReminderModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
isLoading: boolean;
contracts: SmartReminderContract[];
progressContracts?: SmartReminderContract[]; // Contrats avec statut de progression
}
export const SmartReminderModal: React.FC<SmartReminderModalProps> = ({
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 (
<span className="inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-blue-100 text-blue-800">
<Loader2 className="size-3 animate-spin" />
Envoi en cours...
</span>
);
}
if (status === 'success') {
return (
<span className="inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-green-100 text-green-800">
<CheckCircle className="size-3" />
Envoyé
</span>
);
}
if (status === 'error') {
return (
<span className="inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-red-100 text-red-800">
<XCircle className="size-3" />
Erreur
</span>
);
}
// Sinon afficher l'action prévue
switch (action) {
case 'employer':
return (
<span className="inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-blue-100 text-blue-800">
<Mail className="size-3" />
Relance employeur
</span>
);
case 'employee':
return (
<span className="inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-indigo-100 text-indigo-800">
<Mail className="size-3" />
Relance salarié
</span>
);
case 'already-signed':
return (
<span className="inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-green-100 text-green-800">
<CheckCircle className="size-3" />
Déjà signé
</span>
);
case 'skip':
return (
<span className="inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-amber-100 text-amber-800">
<XCircle className="size-3" />
Non traité
</span>
);
}
};
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 (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[800px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Mail className="size-5 text-indigo-600" />
Relances intelligentes - Analyse des contrats
</DialogTitle>
<DialogDescription>
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.
</DialogDescription>
</DialogHeader>
{/* Résumé */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
{employerReminders > 0 && (
<div className="rounded border border-blue-200 bg-blue-50 p-3 text-center">
<div className="text-2xl font-bold text-blue-800">{employerReminders}</div>
<div className="text-xs text-blue-600 mt-1">Employeur</div>
</div>
)}
{employeeReminders > 0 && (
<div className="rounded border border-indigo-200 bg-indigo-50 p-3 text-center">
<div className="text-2xl font-bold text-indigo-800">{employeeReminders}</div>
<div className="text-xs text-indigo-600 mt-1">Salarié</div>
</div>
)}
{alreadySigned > 0 && (
<div className="rounded border border-green-200 bg-green-50 p-3 text-center">
<div className="text-2xl font-bold text-green-800">{alreadySigned}</div>
<div className="text-xs text-green-600 mt-1">Déjà signé</div>
</div>
)}
{skipped > 0 && (
<div className="rounded border border-amber-200 bg-amber-50 p-3 text-center">
<div className="text-2xl font-bold text-amber-800">{skipped}</div>
<div className="text-xs text-amber-600 mt-1">Non traité</div>
</div>
)}
</div>
{totalToSend === 0 && (
<div className="mb-3 rounded border border-amber-200 bg-amber-50 p-3 text-amber-800 text-sm flex items-start gap-2">
<AlertTriangle className="size-4 mt-0.5 flex-shrink-0" />
<div>
Aucune relance à envoyer. Tous les contrats sont soit déjà signés, soit en attente de traitement.
</div>
</div>
)}
{/* Barre de progression pendant l'envoi */}
{isLoading && (
<div className="mb-3 rounded border border-blue-200 bg-blue-50 p-3">
<div className="flex items-center justify-between mb-2 text-sm">
<span className="font-medium text-blue-900">Progression</span>
<span className="text-blue-700">{totalSent} / {totalToSend}</span>
</div>
<div className="w-full bg-blue-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${totalToSend > 0 ? (totalSent / totalToSend) * 100 : 0}%` }}
/>
</div>
{totalErrors > 0 && (
<div className="mt-2 text-xs text-red-600">
{totalErrors} erreur{totalErrors > 1 ? 's' : ''}
</div>
)}
</div>
)}
{/* Liste des contrats */}
<div className="max-h-[50vh] overflow-auto rounded border">
<table className="w-full text-sm">
<thead className="bg-slate-50 text-slate-600 sticky top-0">
<tr>
<th className="text-left px-3 py-2 w-24">Référence</th>
<th className="text-left px-3 py-2">Salarié</th>
<th className="text-left px-3 py-2">Email</th>
<th className="text-left px-3 py-2 w-40">Action</th>
<th className="text-left px-3 py-2">Raison</th>
</tr>
</thead>
<tbody>
{displayContracts.map(c => (
<tr key={c.id} className="border-t hover:bg-slate-50">
<td className="px-3 py-2 font-mono text-xs">{c.reference || c.contract_number || '—'}</td>
<td className="px-3 py-2">{c.employee_name || '—'}</td>
<td className="px-3 py-2 text-xs text-slate-600">
{getEmailForAction(c)}
</td>
<td className="px-3 py-2">
{getActionBadge(c.action, c.status)}
</td>
<td className="px-3 py-2 text-xs text-slate-600">{c.reason || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
<DialogFooter className="flex gap-2">
<Button variant="outline" onClick={onClose} disabled={isLoading}>
Annuler
</Button>
<Button
onClick={onConfirm}
disabled={isLoading || totalToSend === 0}
className="bg-indigo-600 hover:bg-indigo-700 text-white"
>
{isLoading ? 'Envoi en cours…' : `Envoyer ${totalToSend} relance${totalToSend > 1 ? 's' : ''}`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View file

@ -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;

View file

@ -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é';