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:
parent
807cb20456
commit
d7bdb1ef08
13 changed files with 831 additions and 60 deletions
102
MIGRATION_NOTIF_RETROACTIVE.md
Normal file
102
MIGRATION_NOTIF_RETROACTIVE.md
Normal 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.
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
47
app/api/staff/organizations/emails/route.ts
Normal file
47
app/api/staff/organizations/emails/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
248
components/staff/contracts/SmartReminderModal.tsx
Normal file
248
components/staff/contracts/SmartReminderModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
101
supabase/migrations/002_backfill_contract_ids_in_email_logs.sql
Normal file
101
supabase/migrations/002_backfill_contract_ids_in_email_logs.sql
Normal 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;
|
||||
|
|
@ -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é';
|
||||
Loading…
Reference in a new issue