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:
parent
699a862160
commit
61da4f5d35
2 changed files with 472 additions and 2 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
423
components/staff/cotisations/BulkNotifyModal.tsx
Normal file
423
components/staff/cotisations/BulkNotifyModal.tsx
Normal 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'} été 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue