espace-paie-odentas/components/staff/contracts/SmartReminderModal.tsx
odentas d7bdb1ef08 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
2025-10-22 21:49:35 +02:00

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