feat: Implémenter la notification groupée des cotisations

- Créer le composant BulkNotifyModal avec aperçu détaillé
- Afficher les statistiques (avec/sans cotisations, déjà notifiées, emails manquants)
- Grouper les périodes par organisation
- Afficher les destinataires des emails pour chaque organisation
- Indiquer les organisations sans email configuré
- Afficher un aperçu du contenu de l'email
- Gérer les re-notifications avec badge
- Envoyer les notifications en parallèle avec gestion d'erreurs
- Afficher un résumé des succès/erreurs après envoi
- Désélectionner automatiquement les périodes après notification
This commit is contained in:
odentas 2025-11-06 21:38:12 +01:00
parent 699a862160
commit 61da4f5d35
2 changed files with 472 additions and 2 deletions

View file

@ -4,6 +4,7 @@ import { useEffect, useMemo, useState, useRef } from "react";
import { supabase } from "@/lib/supabaseClient";
import { RefreshCw, Check, X, Bell, Trash2, Plus, Edit2, ChevronDown, ChevronUp } from "lucide-react";
import { toast } from "sonner";
import BulkNotifyModal from "./cotisations/BulkNotifyModal";
// Utility function to format dates as DD/MM/YYYY
function formatDate(dateString: string | null | undefined): string {
@ -153,6 +154,9 @@ export default function CotisationsGrid({
// Selection state
const [selectedPeriodKeys, setSelectedPeriodKeys] = useState<Set<string>>(new Set());
// Modal states
const [showBulkNotifyModal, setShowBulkNotifyModal] = useState(false);
// Save filters to localStorage whenever they change
useEffect(() => {
@ -479,8 +483,7 @@ export default function CotisationsGrid({
toast.error("Aucune période sélectionnée");
return;
}
toast.info("Notification groupée non implémentée pour le moment");
// TODO: Implémenter la notification groupée
setShowBulkNotifyModal(true);
};
const handleBulkDelete = () => {
@ -492,6 +495,41 @@ export default function CotisationsGrid({
// TODO: Implémenter la suppression groupée
};
const handleConfirmBulkNotify = async (periodIds: string[]) => {
// Envoyer les notifications pour chaque période
const promises = periodIds.map(async (firstFundId) => {
try {
const res = await fetch(`/api/staff/cotisations/${firstFundId}/notify-client`, {
method: 'POST',
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || "Erreur lors de l'envoi");
}
return { success: true, id: firstFundId };
} catch (error) {
console.error(`Erreur pour ${firstFundId}:`, error);
return { success: false, id: firstFundId, error };
}
});
const results = await Promise.all(promises);
const successCount = results.filter(r => r.success).length;
const errorCount = results.filter(r => !r.success).length;
if (successCount > 0) {
toast.success(`${successCount} notification${successCount > 1 ? 's envoyées' : ' envoyée'} avec succès`);
}
if (errorCount > 0) {
toast.error(`${errorCount} notification${errorCount > 1 ? 's' : ''} en erreur`);
}
// Désélectionner les périodes
setSelectedPeriodKeys(new Set());
};
return (
<div className="relative">
{/* Filters */}
@ -838,6 +876,15 @@ export default function CotisationsGrid({
<div className="mt-3 text-sm text-slate-600">
Affichage de {sortedRows.length} période{sortedRows.length > 1 ? 's' : ''} sur {periodRows.length} au total
</div>
{/* Bulk Notify Modal */}
{showBulkNotifyModal && (
<BulkNotifyModal
periods={selectedPeriods}
onClose={() => setShowBulkNotifyModal(false)}
onConfirm={handleConfirmBulkNotify}
/>
)}
</div>
);
}

View file

@ -0,0 +1,423 @@
"use client";
import { useState, useMemo } from "react";
import { X, Bell, Mail, CheckCircle2, AlertCircle, Loader2 } from "lucide-react";
type PeriodRow = {
org_id: string;
org_name: string;
org_code: string | null;
period_label: string;
due_date: string | null;
status: string;
funds: {
[fundName: string]: {
id: string;
amount_due: number;
amount_paid: number | null;
amount_diff: number | null;
paid_date: string | null;
notes: string | null;
};
};
total_due: number;
total_paid: number;
is_notified?: boolean;
notification_date?: string | null;
};
type BulkNotifyModalProps = {
periods: PeriodRow[];
onClose: () => void;
onConfirm: (periodIds: string[]) => Promise<void>;
};
type OrgDetails = {
org_id: string;
org_name: string;
email_notifs: string | null;
email_notifs_cc: string | null;
};
export default function BulkNotifyModal({ periods, onClose, onConfirm }: BulkNotifyModalProps) {
const [loading, setLoading] = useState(false);
const [loadingDetails, setLoadingDetails] = useState(true);
const [orgDetailsMap, setOrgDetailsMap] = useState<Map<string, OrgDetails>>(new Map());
const [sending, setSending] = useState(false);
const [sent, setSent] = useState(false);
// Charger les détails des organisations
useState(() => {
(async () => {
try {
const { createClientComponentClient } = await import('@supabase/auth-helpers-nextjs');
const supabase = createClientComponentClient();
// Récupérer tous les org_id uniques
const orgIds = Array.from(new Set(periods.map(p => p.org_id)));
// Charger les détails pour chaque organisation
const { data: orgDetails, error } = await supabase
.from('organization_details')
.select('org_id, email_notifs, email_notifs_cc')
.in('org_id', orgIds);
if (!error && orgDetails) {
const detailsMap = new Map<string, OrgDetails>();
orgDetails.forEach((detail: any) => {
const org = periods.find(p => p.org_id === detail.org_id);
if (org) {
detailsMap.set(detail.org_id, {
org_id: detail.org_id,
org_name: org.org_name,
email_notifs: detail.email_notifs,
email_notifs_cc: detail.email_notifs_cc,
});
}
});
setOrgDetailsMap(detailsMap);
}
} catch (error) {
console.error("Erreur lors du chargement des détails:", error);
} finally {
setLoadingDetails(false);
}
})();
});
// Grouper les périodes par organisation
const periodsByOrg = useMemo(() => {
const grouped = new Map<string, PeriodRow[]>();
periods.forEach(period => {
const existing = grouped.get(period.org_id) || [];
grouped.set(period.org_id, [...existing, period]);
});
return grouped;
}, [periods]);
// Calculer les statistiques
const stats = useMemo(() => {
let withContributions = 0;
let withoutContributions = 0;
let alreadyNotified = 0;
let missingEmail = 0;
periods.forEach(period => {
if (period.is_notified) alreadyNotified++;
if (period.total_due === 0) withoutContributions++;
else withContributions++;
const orgDetails = orgDetailsMap.get(period.org_id);
if (!orgDetails?.email_notifs) missingEmail++;
});
return { withContributions, withoutContributions, alreadyNotified, missingEmail };
}, [periods, orgDetailsMap]);
// Calculer la période de prélèvement
const calculateCollectionPeriod = (periodLabel: string): string => {
try {
const match = periodLabel.match(/(\w+)\s+(\d{4})/);
if (!match) return "Entre le 15 et le 30 du mois suivant";
const monthName = match[1];
const year = parseInt(match[2]);
const monthsMap: { [key: string]: number } = {
'Janvier': 0, 'Février': 1, 'Mars': 2, 'Avril': 3,
'Mai': 4, 'Juin': 5, 'Juillet': 6, 'Août': 7,
'Septembre': 8, 'Octobre': 9, 'Novembre': 10, 'Décembre': 11
};
const monthIndex = monthsMap[monthName];
if (monthIndex === undefined) return "Entre le 15 et le 30 du mois suivant";
const date = new Date(year, monthIndex, 1);
date.setMonth(date.getMonth() + 1);
const nextMonth = date.toLocaleDateString('fr-FR', { month: 'long' });
const nextYear = date.getFullYear();
return `Entre le 15 et le 30 ${nextMonth} ${nextYear}`;
} catch {
return "Entre le 15 et le 30 du mois suivant";
}
};
const handleConfirm = async () => {
// Filtrer les périodes qui ont un email configuré
const periodsToNotify = periods.filter(p => {
const orgDetails = orgDetailsMap.get(p.org_id);
return orgDetails?.email_notifs;
});
if (periodsToNotify.length === 0) {
alert("Aucune période ne peut être notifiée (emails manquants)");
return;
}
setSending(true);
try {
// Extraire les IDs des premières cotisations de chaque période
const periodIds = periodsToNotify.map(p => Object.values(p.funds)[0]?.id).filter(Boolean);
await onConfirm(periodIds);
setSent(true);
setTimeout(() => {
onClose();
setSent(false);
}, 2000);
} catch (error) {
console.error("Erreur lors de l'envoi:", error);
alert("Erreur lors de l'envoi des notifications");
} finally {
setSending(false);
}
};
// Affichage de succès
if (sent) {
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md">
<div className="p-6 space-y-4">
<div className="w-12 h-12 rounded-full bg-green-100 flex items-center justify-center mx-auto">
<CheckCircle2 className="w-6 h-6 text-green-600" />
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-slate-900">
Notifications envoyées !
</h3>
<p className="text-sm text-slate-600 mt-2">
{periods.length} email{periods.length > 1 ? 's ont' : ' a'} é envoyé{periods.length > 1 ? 's' : ''} avec succès
</p>
</div>
</div>
</div>
</div>
);
}
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-3xl max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="sticky top-0 bg-gradient-to-r from-amber-500 to-orange-500 text-white px-6 py-4 flex items-center justify-between rounded-t-2xl">
<div>
<h2 className="text-xl font-semibold">Notification groupée</h2>
<p className="text-sm text-amber-100 mt-1">
{periods.length} période{periods.length > 1 ? 's' : ''} sélectionnée{periods.length > 1 ? 's' : ''}
</p>
</div>
<button
onClick={onClose}
className="text-white hover:bg-white/20 rounded-lg p-2 transition"
disabled={sending}
>
<X className="w-5 h-5" />
</button>
</div>
{/* Body */}
<div className="p-6 space-y-6">
{loadingDetails ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-amber-600" />
<span className="ml-2 text-sm text-slate-600">Chargement des informations...</span>
</div>
) : (
<>
{/* Statistiques */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="bg-blue-50 rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-blue-600">{stats.withContributions}</div>
<div className="text-xs text-blue-700 mt-1">Avec cotisations</div>
</div>
<div className="bg-slate-50 rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-slate-600">{stats.withoutContributions}</div>
<div className="text-xs text-slate-700 mt-1">Sans cotisations</div>
</div>
<div className="bg-amber-50 rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-amber-600">{stats.alreadyNotified}</div>
<div className="text-xs text-amber-700 mt-1">Déjà notifiées</div>
</div>
<div className="bg-red-50 rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-red-600">{stats.missingEmail}</div>
<div className="text-xs text-red-700 mt-1">Emails manquants</div>
</div>
</div>
{/* Alertes */}
{stats.missingEmail > 0 && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-red-900">
Attention : {stats.missingEmail} organisation{stats.missingEmail > 1 ? 's' : ''} sans email configuré
</p>
<p className="text-xs text-red-700 mt-1">
Les notifications ne seront pas envoyées pour ces organisations.
</p>
</div>
</div>
)}
{stats.alreadyNotified > 0 && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 flex items-start gap-3">
<Bell className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-blue-900">
{stats.alreadyNotified} période{stats.alreadyNotified > 1 ? 's' : ''} déjà notifiée{stats.alreadyNotified > 1 ? 's' : ''}
</p>
<p className="text-xs text-blue-700 mt-1">
Un nouvel email sera envoyé à ces organisations.
</p>
</div>
</div>
)}
{/* Liste des périodes par organisation */}
<div className="space-y-4">
<h3 className="text-sm font-semibold text-slate-900">Détail des notifications :</h3>
<div className="space-y-3 max-h-96 overflow-y-auto">
{Array.from(periodsByOrg.entries()).map(([orgId, orgPeriods]) => {
const orgDetails = orgDetailsMap.get(orgId);
const hasEmail = !!orgDetails?.email_notifs;
return (
<div
key={orgId}
className={`rounded-lg border p-4 ${
hasEmail ? 'bg-white border-slate-200' : 'bg-red-50 border-red-200'
}`}
>
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<h4 className="font-medium text-slate-900">{orgDetails?.org_name || orgId}</h4>
<p className="text-xs text-slate-600 mt-1">
{orgPeriods.length} période{orgPeriods.length > 1 ? 's' : ''}
</p>
</div>
{!hasEmail && (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-red-100 text-red-700 rounded-full text-xs font-medium">
<AlertCircle className="w-3 h-3" />
Pas d'email
</span>
)}
</div>
{hasEmail && (
<div className="bg-blue-50 rounded p-3 mb-3">
<div className="flex items-start gap-2">
<Mail className="w-4 h-4 text-blue-600 mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-blue-900">Destinataire :</p>
<p className="text-sm text-blue-700 break-all">{orgDetails.email_notifs}</p>
{orgDetails.email_notifs_cc && (
<>
<p className="text-xs font-medium text-blue-900 mt-2">Copie (CC) :</p>
<p className="text-sm text-blue-700 break-all">{orgDetails.email_notifs_cc}</p>
</>
)}
</div>
</div>
</div>
)}
<div className="space-y-2">
{orgPeriods.map((period) => (
<div
key={`${period.org_id}|${period.period_label}`}
className="flex items-center justify-between text-sm bg-slate-50 rounded p-2"
>
<div className="flex items-center gap-2">
<span className="font-medium text-slate-900">{period.period_label}</span>
{period.is_notified && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 bg-amber-100 text-amber-700 rounded-full text-xs">
<Bell className="w-3 h-3" />
Déjà notifié
</span>
)}
</div>
<div className="text-right">
{period.total_due === 0 ? (
<span className="text-xs text-slate-600">Aucune cotisation</span>
) : (
<span className="text-xs font-medium text-slate-900">
{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(period.total_due)}
</span>
)}
</div>
</div>
))}
</div>
{/* Aperçu du contenu de l'email */}
{hasEmail && orgPeriods.length > 0 && (
<div className="mt-3 bg-slate-50 rounded p-3">
<p className="text-xs font-medium text-slate-700 mb-2">Contenu de l'email :</p>
<p className="text-xs text-slate-600 leading-relaxed">
Période{orgPeriods.length > 1 ? 's' : ''} : <strong>{orgPeriods.map(p => p.period_label).join(', ')}</strong><br />
Date de prélèvement : <strong>{calculateCollectionPeriod(orgPeriods[0].period_label)}</strong><br />
{orgPeriods.every(p => p.total_due === 0) ? (
<> Message : En l'absence de paie, aucune cotisation ne sera prélevée</>
) : (
<> Message : Rappel des prélèvements de cotisations à venir</>
)}
</p>
</div>
)}
</div>
);
})}
</div>
</div>
</>
)}
</div>
{/* Footer */}
<div className="sticky bottom-0 bg-slate-50 px-6 py-4 flex items-center justify-between rounded-b-2xl border-t">
<div className="text-sm text-slate-600">
{stats.missingEmail > 0 ? (
<span className="text-red-600">
{periods.length - stats.missingEmail} sur {periods.length} notification{periods.length > 1 ? 's' : ''} sera envoyée{periods.length > 1 ? 's' : ''}
</span>
) : (
<span>
{periods.length} notification{periods.length > 1 ? 's' : ''} sera envoyée{periods.length > 1 ? 's' : ''}
</span>
)}
</div>
<div className="flex items-center gap-3">
<button
onClick={onClose}
className="px-4 py-2 text-slate-700 hover:bg-slate-200 rounded-lg transition"
disabled={sending}
>
Annuler
</button>
<button
onClick={handleConfirm}
disabled={sending || loadingDetails || (periods.length - stats.missingEmail) === 0}
className="px-4 py-2 bg-gradient-to-r from-amber-500 to-orange-500 text-white rounded-lg hover:from-amber-600 hover:to-orange-600 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 transition"
>
{sending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Envoi en cours...
</>
) : (
<>
<Bell className="w-4 h-4" />
Envoyer les notifications
</>
)}
</button>
</div>
</div>
</div>
</div>
);
}