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
|
const { data: contracts, error } = await sb
|
||||||
.from("cddu_contracts")
|
.from("cddu_contracts")
|
||||||
.select(
|
.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,
|
`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)`
|
salaries!employee_id(salarie, nom, prenom, adresse_mail)`
|
||||||
)
|
)
|
||||||
.order("start_date", { ascending: false })
|
.order("start_date", { ascending: false })
|
||||||
.limit(200);
|
.limit(200);
|
||||||
|
|
|
||||||
|
|
@ -330,6 +330,7 @@ export async function POST(request: NextRequest) {
|
||||||
contractReference: reference,
|
contractReference: reference,
|
||||||
status: 'En attente',
|
status: 'En attente',
|
||||||
ctaUrl: signatureLink,
|
ctaUrl: signatureLink,
|
||||||
|
contractId: contractId, // Ajout pour traçabilité dans email_logs
|
||||||
};
|
};
|
||||||
|
|
||||||
// Rendu HTML pour archivage puis envoi
|
// Rendu HTML pour archivage puis envoi
|
||||||
|
|
@ -347,6 +348,19 @@ export async function POST(request: NextRequest) {
|
||||||
data: emailData,
|
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
|
// Étape 6 : Upload de l'email HTML rendu sur S3 et logging
|
||||||
const emailHtml = rendered.html;
|
const emailHtml = rendered.html;
|
||||||
emailLink = await uploadEmailToS3(emailHtml, messageId);
|
emailLink = await uploadEmailToS3(emailHtml, messageId);
|
||||||
|
|
|
||||||
|
|
@ -192,25 +192,38 @@ export async function POST(request: NextRequest) {
|
||||||
reference
|
reference
|
||||||
});
|
});
|
||||||
|
|
||||||
// 7. Stocker le signature_link dans cddu_contracts pour permettre la vérification
|
// 7. Mettre à jour le timestamp de dernière notification salarié et stocker le signature_link
|
||||||
if (contractId && signatureLink) {
|
if (contractId) {
|
||||||
console.log('💾 Stockage du signature_link dans cddu_contracts...');
|
console.log('💾 Mise à jour du contrat dans cddu_contracts...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const supabase = createSbServiceRole();
|
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
|
const { error: updateError } = await supabase
|
||||||
.from('cddu_contracts')
|
.from('cddu_contracts')
|
||||||
.update({ signature_link: signatureLink })
|
.update(updateData)
|
||||||
.eq('id', contractId);
|
.eq('id', contractId);
|
||||||
|
|
||||||
if (updateError) {
|
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é
|
// Ne pas bloquer le flux, l'email est déjà envoyé
|
||||||
} else {
|
} 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) {
|
} 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
|
// Ne pas bloquer le flux
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -213,6 +213,7 @@ export async function POST(req: NextRequest) {
|
||||||
documentType: contract.role || 'Contrat',
|
documentType: contract.role || 'Contrat',
|
||||||
contractReference: contract.reference || String(contract.id),
|
contractReference: contract.reference || String(contract.id),
|
||||||
ctaUrl: signatureLink,
|
ctaUrl: signatureLink,
|
||||||
|
contractId: contractId, // Ajout pour traçabilité dans email_logs
|
||||||
};
|
};
|
||||||
|
|
||||||
const messageId = await sendUniversalEmailV2({
|
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,
|
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,
|
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,
|
contrat_signe_par_employeur, contrat_signe, org_id,
|
||||||
|
last_employer_notification_at, last_employee_notification_at,
|
||||||
salaries!employee_id(salarie, nom, prenom, adresse_mail)
|
salaries!employee_id(salarie, nom, prenom, adresse_mail)
|
||||||
`, { count: "exact" });
|
`, { count: "exact" });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -213,15 +213,14 @@ export async function POST(
|
||||||
|
|
||||||
console.log("🔗 [REMIND-EMPLOYER] Récupération lien signature DocuSeal...");
|
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);
|
const employerSlug = await getEmployerSlug(contractData.docusealSubID);
|
||||||
|
|
||||||
// Création du lien de signature
|
|
||||||
const signatureLink = `https://staging.paie.odentas.fr/odentas-sign?docuseal_id=${employerSlug}`;
|
const signatureLink = `https://staging.paie.odentas.fr/odentas-sign?docuseal_id=${employerSlug}`;
|
||||||
|
|
||||||
console.log("📧 [REMIND-EMPLOYER] Envoi email de rappel via template universel...");
|
console.log("📧 [REMIND-EMPLOYER] Envoi email de rappel via template universel...");
|
||||||
|
|
||||||
// Préparation des données au format universel
|
// Préparation des données au format universel
|
||||||
|
// Le bouton redirige maintenant vers la page des signatures électroniques
|
||||||
const emailData: EmailDataV2 = {
|
const emailData: EmailDataV2 = {
|
||||||
firstName: contractData.prenom_signataire || 'Employeur',
|
firstName: contractData.prenom_signataire || 'Employeur',
|
||||||
organizationName: contractData.structure,
|
organizationName: contractData.structure,
|
||||||
|
|
@ -230,7 +229,8 @@ export async function POST(
|
||||||
documentType: contractData.typecontrat || 'Contrat de travail',
|
documentType: contractData.typecontrat || 'Contrat de travail',
|
||||||
contractReference: contractData.reference,
|
contractReference: contractData.reference,
|
||||||
status: 'En attente',
|
status: 'En attente',
|
||||||
ctaUrl: signatureLink,
|
ctaUrl: 'https://paie.odentas.fr/signatures-electroniques',
|
||||||
|
contractId: params.id, // Ajout pour traçabilité dans email_logs
|
||||||
};
|
};
|
||||||
|
|
||||||
await sendUniversalEmailV2({
|
await sendUniversalEmailV2({
|
||||||
|
|
@ -242,6 +242,19 @@ export async function POST(
|
||||||
|
|
||||||
console.log("✅ [REMIND-EMPLOYER] Email envoyé avec succès via système universel");
|
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({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: "Relance employeur envoyée avec succès",
|
message: "Relance employeur envoyée avec succès",
|
||||||
|
|
|
||||||
|
|
@ -217,6 +217,7 @@ export async function POST(req: NextRequest) {
|
||||||
contractReference: contract.reference || String(contract.id),
|
contractReference: contract.reference || String(contract.id),
|
||||||
typecontrat: (contract as any).type_de_contrat || '',
|
typecontrat: (contract as any).type_de_contrat || '',
|
||||||
ctaUrl: signatureLink,
|
ctaUrl: signatureLink,
|
||||||
|
contractId: contractId, // Ajout pour traçabilité dans email_logs
|
||||||
};
|
};
|
||||||
|
|
||||||
const messageId = await sendUniversalEmailV2({
|
const messageId = await sendUniversalEmailV2({
|
||||||
|
|
@ -234,6 +235,19 @@ export async function POST(req: NextRequest) {
|
||||||
messageId
|
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({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Email de relance envoyé avec succès',
|
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 BulkESignConfirmModal from "./BulkESignConfirmModal";
|
||||||
import { BulkEmployeeReminderModal } from "./contracts/BulkEmployeeReminderModal";
|
import { BulkEmployeeReminderModal } from "./contracts/BulkEmployeeReminderModal";
|
||||||
import { EmployeeReminderModal } from "./contracts/EmployeeReminderModal";
|
import { EmployeeReminderModal } from "./contracts/EmployeeReminderModal";
|
||||||
|
import { SmartReminderModal, type SmartReminderContract, type ReminderAction } from "./contracts/SmartReminderModal";
|
||||||
|
|
||||||
// Utility function to format dates as DD/MM/YYYY
|
// Utility function to format dates as DD/MM/YYYY
|
||||||
function formatDate(dateString: string | null | undefined): string {
|
function formatDate(dateString: string | null | undefined): string {
|
||||||
|
|
@ -125,6 +126,8 @@ type Contract = {
|
||||||
org_id?: string | null;
|
org_id?: string | null;
|
||||||
contrat_signe_par_employeur?: string | null;
|
contrat_signe_par_employeur?: string | null;
|
||||||
contrat_signe?: string | null;
|
contrat_signe?: string | null;
|
||||||
|
last_employer_notification_at?: string | null;
|
||||||
|
last_employee_notification_at?: string | null;
|
||||||
salaries?: {
|
salaries?: {
|
||||||
salarie?: string | null;
|
salarie?: string | null;
|
||||||
nom?: string | null;
|
nom?: string | null;
|
||||||
|
|
@ -276,6 +279,10 @@ function ContractsGridImpl({ initialData, activeOrgId }: { initialData: Contract
|
||||||
const [showBulkReminderModal, setShowBulkReminderModal] = useState(false);
|
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 [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);
|
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
|
// Quick filters helpers
|
||||||
const toYMD = (d: Date) => {
|
const toYMD = (d: Date) => {
|
||||||
|
|
@ -1083,42 +1090,24 @@ function ContractsGridImpl({ initialData, activeOrgId }: { initialData: Contract
|
||||||
setIsESignCancelled(false);
|
setIsESignCancelled(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fonction pour récupérer les dernières notifications de signature pour un ensemble de contrats
|
// Fonction pour extraire les notifications directement des données de contrats
|
||||||
const fetchLastNotifications = async (contractIds: string[]) => {
|
// Plus besoin d'appel API séparé, les timestamps sont maintenant dans cddu_contracts
|
||||||
if (contractIds.length === 0) return;
|
const updateNotificationsFromRows = (contracts: Contract[]) => {
|
||||||
|
const map = new Map<string, NotificationInfo>();
|
||||||
try {
|
|
||||||
const { data, error } = await supabase
|
contracts.forEach(contract => {
|
||||||
.from('email_logs')
|
const info: NotificationInfo = {
|
||||||
.select('contract_id, created_at, email_type')
|
employerLastSent: contract.last_employer_notification_at || null,
|
||||||
.in('contract_id', contractIds)
|
employeeLastSent: contract.last_employee_notification_at || null,
|
||||||
.in('email_type', ['signature-request-employer', 'signature-request-employee', 'signature-request-salarie'])
|
};
|
||||||
.order('created_at', { ascending: false });
|
|
||||||
|
// N'ajouter à la map que si au moins une notification existe
|
||||||
if (error) {
|
if (info.employerLastSent || info.employeeLastSent) {
|
||||||
console.error('Erreur lors de la récupération des notifications:', error);
|
map.set(contract.id, info);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Grouper par contract_id et trouver les dernières notifications
|
setNotificationMap(map);
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Debounce searches when filters change
|
// 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
|
// Récupérer les notifications quand les données changent
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const contractIds = rows.map(r => r.id);
|
if (rows.length > 0) {
|
||||||
if (contractIds.length > 0) {
|
updateNotificationsFromRows(rows);
|
||||||
fetchLastNotifications(contractIds);
|
|
||||||
}
|
}
|
||||||
}, [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 () => {
|
const handleBulkReminderClick = async () => {
|
||||||
if (selectedContractIds.size === 0) {
|
if (selectedContractIds.size === 0) {
|
||||||
toast.error("Aucun contrat sélectionné");
|
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 () => {
|
const confirmSendBulkReminders = async () => {
|
||||||
setIsLoadingReminder(true);
|
setIsLoadingReminder(true);
|
||||||
const ids = bulkReminderContracts.map(c => c.id);
|
const ids = bulkReminderContracts.map(c => c.id);
|
||||||
|
|
@ -1696,13 +1884,13 @@ function ContractsGridImpl({ initialData, activeOrgId }: { initialData: Contract
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowESignMenu(false);
|
setShowESignMenu(false);
|
||||||
handleBulkReminderClick();
|
handleSmartReminderClick();
|
||||||
}}
|
}}
|
||||||
disabled={isLoadingReminder}
|
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"
|
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" />
|
<BellRing className="w-4 h-4" />
|
||||||
{isLoadingReminder ? "Envoi..." : "Relancer salariés"}
|
{isLoadingReminder ? "Analyse..." : "Relances intelligentes"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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">{formatDate(r.end_date)}</td>
|
||||||
<td className="px-3 py-2">
|
<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);
|
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 <span className="text-xs text-slate-400">—</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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`}>
|
<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">
|
<div className="flex items-center gap-1">
|
||||||
<span className="font-semibold">E:</span>
|
<span className="font-semibold">E:</span>
|
||||||
<span>{formatNotificationDate(notifInfo.employerLastSent)}</span>
|
<span>{formatNotificationDate(employerLastSent)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{notifInfo.employeeLastSent && (
|
{employeeLastSent && (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className="font-semibold">S:</span>
|
<span className="font-semibold">S:</span>
|
||||||
<span>{formatNotificationDate(notifInfo.employeeLastSent)}</span>
|
<span>{formatNotificationDate(employeeLastSent)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -2067,6 +2257,16 @@ function ContractsGridImpl({ initialData, activeOrgId }: { initialData: Contract
|
||||||
contracts={bulkReminderContracts}
|
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 */}
|
{/* Modal de progression pour l'envoi des e-signatures */}
|
||||||
<BulkESignProgressModal
|
<BulkESignProgressModal
|
||||||
isOpen={showESignProgressModal}
|
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