- 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
248 lines
9.4 KiB
TypeScript
248 lines
9.4 KiB
TypeScript
"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>
|
|
);
|
|
};
|