feat: Ajout programme de parrainage, bannières promo, déduplication webhooks et avenants signés

- Programme de parrainage (referrals):
  * Page /parrainage pour clients et staff
  * API /api/referrals (GET, POST)
  * Table referrals avec tracking complet
  * Email template avec design orange/gradient
  * Réductions: 30€ HT parrain, 20€ HT filleul

- Bannières promotionnelles (promo_banners):
  * Page staff /staff/offres-promo pour gérer les bannières
  * API /api/promo-banners (CRUD complet)
  * Composant PromoBanner affiché en haut de l'espace
  * Compte à rebours optionnel
  * Customisation couleurs (gradient, texte, CTA)

- Déduplication des webhooks DocuSeal:
  * Table webhook_events pour tracker les webhooks traités
  * Helper checkAndMarkWebhookProcessed()
  * Intégré dans docuseal-amendment et docuseal-amendment-completed
  * Prévient les doublons d'emails

- Avenants signés:
  * API GET /api/contrats/[id]/avenants
  * Affichage des avenants signés dans DocumentsCard
  * Génération d'URLs presignées S3

- Brouillons d'emails groupés:
  * Table bulk_email_drafts pour sauvegarder les brouillons
  * Template HTML bulk-email-template.html

- Améliorations ContractsGrid:
  * Ajout filtre par production (dépendant de la structure)
  * Tri par production

- Templates emails:
  * referral-template.html (parrainage)
  * bulk-email-template.html (emails groupés staff)
This commit is contained in:
odentas 2025-10-31 23:31:53 +01:00
parent d326730cfb
commit 6170365fc0
28 changed files with 3861 additions and 167 deletions

View file

@ -1242,7 +1242,7 @@ return (
{/* Grille 2 colonnes */}
<div className="flex flex-col md:grid md:grid-cols-2 gap-5 md:items-start">
{/* Left column: Documents, Demande - ordre 1 sur mobile */}
{/* Left column: Documents, Demande, Notes - ordre 1 sur mobile */}
<div className="space-y-5 order-1">
{/* Card Documents */}
<DocumentsCard
@ -1383,6 +1383,9 @@ return (
<Field label="Fin contrat" value={formatDateFR(data.date_fin)} />
<Field label="Panier repas" value={boolBadge(data.panier_repas)} />
</Section>
{/* Section Notes */}
<NotesSection contractId={id} contractRef={data.numero} />
</div>
{/* Right column: Signature électronique, Déclarations, Paie, Temps de travail réel - ordre 2 sur mobile */}
@ -1561,11 +1564,6 @@ return (
<Field label="Nombre d'heures AEM" value={data.nb_heures_aem ?? 0} />
</Section>
</div>
{/* Section Notes - ordre 3 sur mobile (en dernier) */}
<div className="order-3">
<NotesSection contractId={id} contractRef={data.numero} />
</div>
</div>
{/* Script DocuSeal */}

View file

@ -12,6 +12,7 @@ import { detectDemoModeFromHeaders } from "@/lib/demo-detector";
import { DEMO_USER, DEMO_ORGANIZATION } from "@/lib/demo-data";
import { DemoBanner } from "@/components/DemoBanner";
import { DemoModeProvider } from "@/hooks/useDemoMode";
import { PromoBanner } from "@/components/PromoBanner";
type ClientInfo = {
id: string;
@ -298,25 +299,32 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
const displayInfo = isStaff ? staffOrgInfo : clientInfo;
return (
<div className="min-h-screen md:grid md:grid-cols-[calc(var(--sidebar-w)_+_8px)_1fr]">
{/* Sidebar flush left */}
<aside className="hidden md:block sticky top-0 h-screen overflow-y-auto overflow-x-hidden border-r bg-background">
<Sidebar clientInfo={displayInfo} isStaff={isStaff} />
</aside>
<DemoModeProvider forceDemoMode={false}>
<div className="min-h-screen">
{/* Bannière promo (hors mode démo) */}
<PromoBanner />
<div className="min-h-screen md:grid md:grid-cols-[calc(var(--sidebar-w)_+_8px)_1fr]">
{/* Sidebar flush left */}
<aside className="hidden md:block sticky top-0 h-screen overflow-y-auto overflow-x-hidden border-r bg-background">
<Sidebar clientInfo={displayInfo} isStaff={isStaff} />
</aside>
{/* Main column (header + content) */}
<div className="flex flex-col min-h-screen min-w-0">
{/* Header aligned with content column */}
<header className="m-0 p-0 sticky top-0 z-40">
<Header clientInfo={displayInfo} isStaff={isStaff} />
</header>
{/* Main column (header + content) */}
<div className="flex flex-col min-h-screen min-w-0">
{/* Header aligned with content column */}
<header className="m-0 p-0 sticky top-0 z-40">
<Header clientInfo={displayInfo} isStaff={isStaff} />
</header>
{/* Main content area */}
<main className="p-4 overflow-x-hidden">
<Breadcrumb />
{children}
</main>
{/* Main content area */}
<main className="p-4 overflow-x-hidden">
<Breadcrumb />
{children}
</main>
</div>
</div>
</div>
</div>
</DemoModeProvider>
);
}

View file

@ -0,0 +1,561 @@
"use client";
import { useEffect, useState } from "react";
import { Gift, Mail, Check, Clock, XCircle, Send, AlertCircle, Building2, User, Sparkles } from "lucide-react";
interface Referral {
id: string;
referee_name: string;
referee_email: string;
status: string;
personal_message: string | null;
referrer_credit_amount: number;
referee_discount_amount: number;
email_sent_at: string | null;
contract_signed_at: string | null;
invoice_paid_at: string | null;
validated_at: string | null;
created_at: string;
}
interface Organization {
id: string;
name: string;
}
interface ParrainageClientProps {
isStaff: boolean;
organizations: Organization[];
initialOrgId: string | null;
}
const STATUS_CONFIG = {
pending: {
label: "En attente",
color: "bg-slate-100 text-slate-700",
icon: Clock,
description: "Email pas encore envoyé",
},
email_sent: {
label: "Email envoyé",
color: "bg-blue-100 text-blue-700",
icon: Mail,
description: "En attente de signature",
},
signed: {
label: "Contrat signé",
color: "bg-purple-100 text-purple-700",
icon: Check,
description: "En attente de paiement",
},
validated: {
label: "Validé",
color: "bg-green-100 text-green-700",
icon: Check,
description: "Crédit de 30€ HT disponible",
},
cancelled: {
label: "Annulé",
color: "bg-red-100 text-red-700",
icon: XCircle,
description: "Parrainage annulé",
},
};
export default function ParrainageClient({
isStaff,
organizations,
initialOrgId
}: ParrainageClientProps) {
const [referrals, setReferrals] = useState<Referral[]>([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [sending, setSending] = useState(false);
const [selectedOrgId, setSelectedOrgId] = useState<string | null>(initialOrgId);
// Form state
const [formData, setFormData] = useState({
referrer_name: "",
referee_name: "",
referee_email: "",
personal_message: "",
});
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const fetchReferrals = async (orgId: string | null = selectedOrgId) => {
if (!orgId) {
setLoading(false);
return;
}
try {
const url = isStaff
? `/api/referrals?org_id=${orgId}`
: "/api/referrals";
const res = await fetch(url, {
credentials: "include",
cache: "no-store",
});
if (!res.ok) throw new Error("Erreur fetch");
const data = await res.json();
setReferrals(data.referrals || []);
} catch (err) {
console.error("Erreur lors du chargement des parrainages:", err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchReferrals();
}, [selectedOrgId]);
const handleOrgChange = async (orgId: string) => {
setSelectedOrgId(orgId);
setLoading(true);
// Mettre à jour le cookie pour le staff
if (isStaff) {
document.cookie = `active_org_id=${orgId}; path=/; max-age=31536000`;
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
// Vérifier que le staff a bien sélectionné une organisation
if (isStaff && !selectedOrgId) {
setError("Veuillez sélectionner une organisation");
return;
}
setSending(true);
try {
// Pour le staff, ajouter l'org_id en paramètre
const url = isStaff && selectedOrgId
? `/api/referrals?org_id=${selectedOrgId}`
: "/api/referrals";
console.log('[Parrainage] Envoi invitation:', {
isStaff,
selectedOrgId,
url,
formData
});
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(formData),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || "Erreur lors de l'envoi");
}
setSuccess(true);
setFormData({
referrer_name: "",
referee_name: "",
referee_email: "",
personal_message: "",
});
await fetchReferrals();
setTimeout(() => {
setSuccess(false);
setShowForm(false);
}, 3000);
} catch (err: any) {
setError(err.message);
} finally {
setSending(false);
}
};
const totalCredits = referrals
.filter((r) => r.status === "validated")
.reduce((sum, r) => sum + r.referrer_credit_amount, 0);
if (loading) {
return (
<div className="p-6">
<p className="text-sm text-slate-600">Chargement...</p>
</div>
);
}
return (
<div className="p-6 space-y-6">
{/* Sélecteur d'organisation (Staff uniquement) */}
{isStaff && organizations.length > 0 && (
<div className="bg-indigo-50 border border-indigo-200 rounded-xl p-4">
<div className="flex items-center gap-3">
<Building2 className="w-5 h-5 text-indigo-600" />
<div className="flex-1">
<label className="block text-sm font-medium text-indigo-900 mb-1">
Organisation sélectionnée (mode Staff)
</label>
<select
value={selectedOrgId || ""}
onChange={(e) => handleOrgChange(e.target.value)}
className="w-full px-3 py-2 border border-indigo-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 bg-white"
>
<option value="">-- Sélectionner une organisation --</option>
{organizations.map((org) => (
<option key={org.id} value={org.id}>
{org.name}
</option>
))}
</select>
</div>
</div>
</div>
)}
{/* Header */}
<div className="flex items-start justify-between gap-6">
<div className="flex-1">
<h1 className="text-2xl font-bold text-slate-900">Programme de parrainage</h1>
<p className="text-sm text-slate-600 mt-1">
Recommandez Odentas et profitez ensemble de 50 HT de réduction*
</p>
</div>
{!showForm && (
<button
onClick={() => setShowForm(true)}
className="inline-flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-indigo-200 via-purple-200 to-pink-200 text-slate-900 rounded-lg hover:from-indigo-300 hover:via-purple-300 hover:to-pink-300 transition font-medium shadow-sm"
>
<Gift className="w-4 h-4" />
Inviter un ami
</button>
)}
</div>
{/* Carte des avantages */}
<div className="bg-white border border-slate-200 rounded-lg p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-slate-100 rounded-lg flex items-center justify-center">
<Gift className="w-5 h-5 text-slate-700" />
</div>
<div>
<h2 className="text-base font-semibold text-slate-900">Parrainez et gagnez 30 HT</h2>
<p className="text-sm text-slate-600">Votre filleul reçoit 20 HT de réduction</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-slate-50 rounded-lg p-4 border border-slate-200">
<div className="flex items-center gap-3 mb-2">
<div className="w-8 h-8 bg-white rounded-lg flex items-center justify-center border border-slate-200">
<User className="w-4 h-4 text-slate-700" />
</div>
<div>
<p className="text-xs text-slate-500 font-medium">Vous gagnez</p>
<p className="text-lg font-semibold text-slate-900">30 HT</p>
</div>
</div>
<p className="text-xs text-slate-600">Crédit automatique sur votre prochaine facture</p>
</div>
<div className="bg-slate-50 rounded-lg p-4 border border-slate-200">
<div className="flex items-center gap-3 mb-2">
<div className="w-8 h-8 bg-white rounded-lg flex items-center justify-center border border-slate-200">
<Sparkles className="w-4 h-4 text-slate-700" />
</div>
<div>
<p className="text-xs text-slate-500 font-medium">Votre filleul gagne</p>
<p className="text-lg font-semibold text-slate-900">20 HT</p>
</div>
</div>
<p className="text-xs text-slate-600">Réduction à l'ouverture du compte</p>
</div>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white rounded-xl border p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-600">Total de parrainages</p>
<p className="text-3xl font-bold text-slate-900 mt-1">{referrals.length}</p>
</div>
<div className="w-12 h-12 bg-blue-100 rounded-xl flex items-center justify-center">
<Mail className="w-6 h-6 text-blue-600" />
</div>
</div>
</div>
<div className="bg-white rounded-xl border p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-600">Parrainages validés</p>
<p className="text-3xl font-bold text-green-600 mt-1">
{referrals.filter((r) => r.status === "validated").length}
</p>
</div>
<div className="w-12 h-12 bg-green-100 rounded-xl flex items-center justify-center">
<Check className="w-6 h-6 text-green-600" />
</div>
</div>
</div>
<div className="bg-white rounded-xl border p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-600">Crédits obtenus</p>
<p className="text-3xl font-bold text-orange-600 mt-1">{totalCredits.toFixed(2)} </p>
</div>
<div className="w-12 h-12 bg-orange-100 rounded-xl flex items-center justify-center">
<Gift className="w-6 h-6 text-orange-600" />
</div>
</div>
</div>
</div>
{/* Modal Formulaire */}
{showForm && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
{/* Header du modal */}
<div className="sticky top-0 bg-gradient-to-r from-indigo-200 via-purple-200 to-pink-200 p-5 rounded-t-lg border-b border-slate-200">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-white/50 rounded-lg flex items-center justify-center">
<Gift className="w-5 h-5 text-slate-900" />
</div>
<div>
<h2 className="text-lg font-semibold text-slate-900">Inviter un ami</h2>
<p className="text-slate-700 text-sm">Partagez les avantages du parrainage</p>
</div>
</div>
<button
onClick={() => {
setShowForm(false);
setError(null);
setSuccess(false);
}}
className="text-slate-600 hover:text-slate-900 transition"
>
<XCircle className="w-5 h-5" />
</button>
</div>
</div>
{/* Contenu du modal */}
<div className="p-6">
{success && (
<div className="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg flex items-start gap-3">
<Check className="w-5 h-5 text-green-600 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-green-900">Invitation envoyée</p>
<p className="text-sm text-green-700 mt-1">
Votre ami va recevoir un email avec votre recommandation.
</p>
</div>
</div>
)}
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-700">{error}</p>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Votre nom <span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.referrer_name}
onChange={(e) => setFormData({ ...formData, referrer_name: e.target.value })}
required
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-slate-500 focus:border-slate-500 transition"
placeholder="Ex: Jean Dupont"
/>
<p className="text-xs text-slate-500 mt-1">Votre nom apparaîtra dans l'email</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Nom de votre ami <span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.referee_name}
onChange={(e) => setFormData({ ...formData, referee_name: e.target.value })}
required
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-slate-500 focus:border-slate-500 transition"
placeholder="Ex: Marie Martin"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Email de votre ami <span className="text-red-500">*</span>
</label>
<input
type="email"
value={formData.referee_email}
onChange={(e) => setFormData({ ...formData, referee_email: e.target.value })}
required
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-slate-500 focus:border-slate-500 transition"
placeholder="ami@email.com"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Message personnalisé (optionnel)
</label>
<textarea
value={formData.personal_message}
onChange={(e) => setFormData({ ...formData, personal_message: e.target.value })}
rows={4}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-slate-500 focus:border-slate-500 transition resize-none"
placeholder="Ajoutez un message personnel..."
/>
<p className="text-xs text-slate-500 mt-1">
Ce message sera inclus dans l'email d'invitation
</p>
</div>
<div className="flex items-center gap-3 pt-4 border-t">
<button
type="submit"
disabled={sending}
className="flex-1 inline-flex items-center justify-center gap-2 px-5 py-2.5 bg-gradient-to-r from-indigo-200 via-purple-200 to-pink-200 text-slate-900 rounded-lg hover:from-indigo-300 hover:via-purple-300 hover:to-pink-300 transition disabled:opacity-50 disabled:cursor-not-allowed font-medium shadow-sm"
>
{sending ? (
<>
<div className="w-4 h-4 border-2 border-slate-900 border-t-transparent rounded-full animate-spin" />
Envoi en cours...
</>
) : (
<>
<Send className="w-4 h-4" />
Envoyer l'invitation
</>
)}
</button>
<button
type="button"
onClick={() => {
setShowForm(false);
setError(null);
setSuccess(false);
}}
className="px-6 py-3 border border-slate-300 rounded-xl hover:bg-slate-50 transition font-medium"
>
Annuler
</button>
</div>
</form>
</div>
</div>
</div>
)}
{/* Liste des parrainages */}
<div className="bg-white rounded-xl border">
<div className="p-6 border-b">
<h2 className="text-lg font-semibold text-slate-900">Mes parrainages</h2>
</div>
<div className="divide-y">
{referrals.length === 0 ? (
<div className="p-8 text-center">
<div className="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-4">
<Gift className="w-8 h-8 text-slate-400" />
</div>
<p className="text-sm text-slate-600">Aucun parrainage pour le moment</p>
<p className="text-xs text-slate-500 mt-1">
Commencez à parrainer pour gagner des crédits !
</p>
</div>
) : (
referrals.map((referral) => {
const statusConfig = STATUS_CONFIG[referral.status as keyof typeof STATUS_CONFIG] || STATUS_CONFIG.pending;
const StatusIcon = statusConfig.icon;
return (
<div key={referral.id} className="p-6">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="font-medium text-slate-900">{referral.referee_name}</h3>
<span className={`inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full ${statusConfig.color}`}>
<StatusIcon className="w-3 h-3" />
{statusConfig.label}
</span>
</div>
<p className="text-sm text-slate-600 mb-1">{referral.referee_email}</p>
<p className="text-xs text-slate-500">{statusConfig.description}</p>
{referral.personal_message && (
<div className="mt-3 p-3 bg-amber-50 border border-amber-200 rounded-lg">
<p className="text-xs text-amber-900 italic">"{referral.personal_message}"</p>
</div>
)}
</div>
<div className="text-right">
<p className="text-xs text-slate-500">Créé le</p>
<p className="text-sm font-medium text-slate-900">
{new Date(referral.created_at).toLocaleDateString("fr-FR")}
</p>
{referral.status === "validated" && (
<p className="text-sm font-semibold text-green-600 mt-2">
+{referral.referrer_credit_amount} HT
</p>
)}
</div>
</div>
</div>
);
})
)}
</div>
</div>
{/* Conditions générales */}
<div className="bg-slate-50 rounded-xl border p-6">
<h3 className="text-sm font-semibold text-slate-900 mb-3">
Conditions de l'offre de parrainage Odentas
</h3>
<div className="text-xs text-slate-600 space-y-2 leading-relaxed">
<p> Le programme de parrainage est ouvert à tout client Odentas disposant d'un contrat de gestion de la paie actif.</p>
<p> Il n'y a aucune limite au nombre de filleuls pouvant être parrainés.</p>
<p> Le parrain bénéficie d'une réduction de 30 HT sur ses prochaines factures, une fois le parrainage validé.</p>
<p> Le filleul bénéficie d'une réduction de 20 € HT sur sa facture d'ouverture de compte (49,99 HT ou 99,99 HT selon le cas).</p>
<p> Le parrainage est considéré comme validé lorsque :</p>
<p className="pl-4"> le filleul a signé son contrat Odentas, et</p>
<p className="pl-4"> la facture d'ouverture de compte du filleul a é intégralement réglée.</p>
<p> La réduction du parrain est déduite automatiquement de la prochaine facture émise après validation du parrainage.</p>
<p> Si le montant de la facture est inférieur à la réduction disponible, le solde restant est reporté sur les factures suivantes, jusqu'à épuisement du crédit.</p>
<p> Les réductions obtenues ne sont ni échangeables, ni remboursables, ni convertibles en numéraire.</p>
<p> Odentas Media SAS se réserve le droit de modifier ou suspendre le programme de parrainage à tout moment, sans effet rétroactif sur les parrainages déjà validés.</p>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,76 @@
export const dynamic = "force-dynamic";
import { cookies } from "next/headers";
import { createSbServer } from "@/lib/supabaseServer";
import { redirect } from "next/navigation";
import { Metadata } from "next";
import ParrainageClient from "./ParrainageClient";
export const metadata: Metadata = {
title: "Programme de parrainage | Espace Paie Odentas",
description: "Parrainez vos connaissances et bénéficiez de réductions sur vos factures",
};
export default async function ParrainagePage() {
const sb = createSbServer();
const { data: { user } } = await sb.auth.getUser();
if (!user) {
redirect("/signin");
}
// Vérifier si l'utilisateur est staff
const { data: staffData } = await sb
.from("staff_users")
.select("is_staff")
.eq("user_id", user.id)
.maybeSingle();
const isStaff = !!staffData?.is_staff;
let organizationId: string | null = null;
let organizations: Array<{ id: string; name: string }> = [];
if (isStaff) {
// Staff : récupérer toutes les organisations pour le sélecteur
const { data: orgsData } = await sb
.from("organizations")
.select("id, name")
.order("name");
organizations = orgsData || [];
// Récupérer l'organisation sélectionnée depuis le cookie
const c = cookies();
const activeOrgId = c.get("active_org_id")?.value;
organizationId = activeOrgId || (organizations.length > 0 ? organizations[0].id : null);
} else {
// Client : récupérer son organisation
const { data: memberData } = await sb
.from("organization_members")
.select("org_id")
.eq("user_id", user.id)
.eq("revoked", false)
.maybeSingle();
if (!memberData?.org_id) {
return (
<main className="p-6">
<h1 className="text-lg font-semibold">Accès refusé</h1>
<p className="text-sm text-slate-600">
Vous devez appartenir à une organisation pour accéder au programme de parrainage.
</p>
</main>
);
}
organizationId = memberData.org_id;
}
return (
<ParrainageClient
isStaff={isStaff}
organizations={organizations}
initialOrgId={organizationId}
/>
);
}

View file

@ -0,0 +1,591 @@
"use client";
import { useEffect, useState } from "react";
import { Plus, Edit, Trash2, Check, X, Calendar, Link as LinkIcon, Palette } from "lucide-react";
interface PromoBanner {
id: string;
text: string;
cta_text?: string | null;
cta_link?: string | null;
countdown_enabled: boolean;
countdown_end_date?: string | null;
gradient_from: string;
gradient_to: string;
text_color: string;
cta_bg_color: string;
cta_text_color: string;
is_active: boolean;
created_at: string;
updated_at: string;
}
const GRADIENT_PRESETS = [
{ from: "slate-100", to: "slate-200", label: "Gris clair" },
{ from: "indigo-100", to: "purple-100", label: "Indigo clair → Violet clair" },
{ from: "blue-50", to: "cyan-100", label: "Bleu clair → Cyan clair" },
{ from: "purple-50", to: "pink-100", label: "Violet clair → Rose clair" },
{ from: "orange-50", to: "amber-100", label: "Orange clair → Ambre clair" },
{ from: "green-50", to: "teal-100", label: "Vert clair → Turquoise clair" },
];
const TEXT_COLOR_OPTIONS = [
{ value: "slate-800", label: "Gris foncé", hex: "#1e293b" },
{ value: "slate-900", label: "Gris très foncé", hex: "#0f172a" },
{ value: "white", label: "Blanc", hex: "#ffffff" },
{ value: "indigo-900", label: "Indigo foncé", hex: "#312e81" },
{ value: "purple-900", label: "Violet foncé", hex: "#581c87" },
];
const CTA_BG_COLOR_OPTIONS = [
{ value: "#efc543", label: "Jaune Odentas", hex: "#efc543" },
{ value: "#1e293b", label: "Gris foncé", hex: "#1e293b" },
{ value: "#0f172a", label: "Gris très foncé", hex: "#0f172a" },
{ value: "#ffffff", label: "Blanc", hex: "#ffffff" },
{ value: "#312e81", label: "Indigo foncé", hex: "#312e81" },
{ value: "#581c87", label: "Violet foncé", hex: "#581c87" },
];
const CTA_TEXT_COLOR_OPTIONS = [
{ value: "slate-900", label: "Gris très foncé", hex: "#0f172a" },
{ value: "white", label: "Blanc", hex: "#ffffff" },
{ value: "slate-800", label: "Gris foncé", hex: "#1e293b" },
];
export default function OffresPromoClient() {
const [banners, setBanners] = useState<PromoBanner[]>([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
// Form state
const [formData, setFormData] = useState({
text: "",
cta_text: "",
cta_link: "",
countdown_enabled: false,
countdown_end_date: "",
gradient_from: "indigo-600",
gradient_to: "purple-600",
text_color: "slate-800",
cta_bg_color: "#efc543",
cta_text_color: "slate-900",
is_active: false,
});
const fetchBanners = async () => {
try {
const res = await fetch("/api/promo-banners", {
credentials: "include",
cache: "no-store"
});
if (!res.ok) throw new Error("Erreur fetch");
const data = await res.json();
setBanners(data.banners || []);
} catch (err) {
console.error("Erreur lors du chargement des bannières:", err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchBanners();
}, []);
const resetForm = () => {
setFormData({
text: "",
cta_text: "",
cta_link: "",
countdown_enabled: false,
countdown_end_date: "",
gradient_from: "slate-100",
gradient_to: "slate-200",
text_color: "slate-800",
cta_bg_color: "#efc543",
cta_text_color: "slate-900",
is_active: false,
});
setEditingId(null);
setShowForm(false);
};
const handleEdit = (banner: PromoBanner) => {
setFormData({
text: banner.text,
cta_text: banner.cta_text || "",
cta_link: banner.cta_link || "",
countdown_enabled: banner.countdown_enabled,
countdown_end_date: banner.countdown_end_date
? new Date(banner.countdown_end_date).toISOString().slice(0, 16)
: "",
gradient_from: banner.gradient_from,
gradient_to: banner.gradient_to,
text_color: banner.text_color || "slate-800",
cta_bg_color: banner.cta_bg_color || "#efc543",
cta_text_color: banner.cta_text_color || "slate-900",
is_active: banner.is_active,
});
setEditingId(banner.id);
setShowForm(true);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const payload = {
...formData,
countdown_end_date: formData.countdown_enabled && formData.countdown_end_date
? new Date(formData.countdown_end_date).toISOString()
: null,
};
try {
if (editingId) {
// Mise à jour
const res = await fetch("/api/promo-banners", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ id: editingId, ...payload }),
});
if (!res.ok) throw new Error("Erreur mise à jour");
} else {
// Création
const res = await fetch("/api/promo-banners", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error("Erreur création");
}
await fetchBanners();
resetForm();
} catch (err) {
console.error("Erreur lors de la sauvegarde:", err);
alert("Erreur lors de la sauvegarde");
}
};
const handleDelete = async (id: string) => {
if (!confirm("Êtes-vous sûr de vouloir supprimer cette bannière ?")) return;
try {
const res = await fetch(`/api/promo-banners?id=${id}`, {
method: "DELETE",
credentials: "include",
});
if (!res.ok) throw new Error("Erreur suppression");
await fetchBanners();
} catch (err) {
console.error("Erreur lors de la suppression:", err);
alert("Erreur lors de la suppression");
}
};
const toggleActive = async (banner: PromoBanner) => {
try {
const res = await fetch("/api/promo-banners", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
id: banner.id,
is_active: !banner.is_active
}),
});
if (!res.ok) throw new Error("Erreur activation");
await fetchBanners();
} catch (err) {
console.error("Erreur lors de l'activation:", err);
alert("Erreur lors de l'activation");
}
};
if (loading) {
return (
<div className="p-6">
<p className="text-sm text-slate-600">Chargement...</p>
</div>
);
}
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900">Offres promo</h1>
<p className="text-sm text-slate-600 mt-1">
Gérez les bannières promotionnelles affichées en haut de l'espace paie
</p>
</div>
<button
onClick={() => setShowForm(true)}
className="inline-flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition"
>
<Plus className="w-4 h-4" />
Nouvelle bannière
</button>
</div>
{/* Formulaire */}
{showForm && (
<div className="bg-white rounded-xl border p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-slate-900">
{editingId ? "Modifier la bannière" : "Nouvelle bannière"}
</h2>
<button
onClick={resetForm}
className="text-slate-400 hover:text-slate-600"
>
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Texte principal */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Texte de la bannière
</label>
<input
type="text"
value={formData.text}
onChange={(e) => setFormData({ ...formData, text: e.target.value })}
required
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Ex: Offre spéciale - 20% de réduction jusqu'à la fin du mois !"
/>
</div>
{/* CTA */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Texte du bouton (optionnel)
</label>
<input
type="text"
value={formData.cta_text}
onChange={(e) => setFormData({ ...formData, cta_text: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Ex: En savoir plus"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Lien du bouton (optionnel)
</label>
<input
type="url"
value={formData.cta_link}
onChange={(e) => setFormData({ ...formData, cta_link: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
placeholder="https://..."
/>
</div>
</div>
{/* Compte à rebours */}
<div className="space-y-2">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.countdown_enabled}
onChange={(e) => setFormData({ ...formData, countdown_enabled: e.target.checked })}
className="rounded text-indigo-600 focus:ring-indigo-500"
/>
<span className="text-sm font-medium text-slate-700">
Activer le compte à rebours
</span>
</label>
{formData.countdown_enabled && (
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Date de fin
</label>
<input
type="datetime-local"
value={formData.countdown_end_date}
onChange={(e) => setFormData({ ...formData, countdown_end_date: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
)}
</div>
{/* Gradient */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Couleur de fond (gradient)
</label>
<div className="grid grid-cols-3 gap-2">
{GRADIENT_PRESETS.map((preset) => (
<button
key={`${preset.from}-${preset.to}`}
type="button"
onClick={() => setFormData({
...formData,
gradient_from: preset.from,
gradient_to: preset.to
})}
className={`p-3 rounded-lg border-2 transition ${
formData.gradient_from === preset.from && formData.gradient_to === preset.to
? "border-indigo-600"
: "border-slate-200 hover:border-slate-300"
}`}
>
<div className={`h-8 rounded bg-gradient-to-r from-${preset.from} to-${preset.to} mb-1`} />
<p className="text-xs text-slate-600">{preset.label}</p>
</button>
))}
</div>
</div>
{/* Couleur du texte */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Couleur du texte
</label>
<div className="grid grid-cols-5 gap-2">
{TEXT_COLOR_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
onClick={() => setFormData({ ...formData, text_color: option.value })}
className={`p-3 rounded-lg border-2 transition ${
formData.text_color === option.value
? "border-indigo-600"
: "border-slate-200 hover:border-slate-300"
}`}
>
<div
className="h-8 rounded mb-1"
style={{ backgroundColor: option.hex }}
/>
<p className="text-xs text-slate-600">{option.label}</p>
</button>
))}
</div>
</div>
{/* Couleur du bouton CTA */}
{formData.cta_text && formData.cta_link && (
<>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Couleur de fond du bouton CTA
</label>
<div className="grid grid-cols-6 gap-2">
{CTA_BG_COLOR_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
onClick={() => setFormData({ ...formData, cta_bg_color: option.value })}
className={`p-3 rounded-lg border-2 transition ${
formData.cta_bg_color === option.value
? "border-indigo-600"
: "border-slate-200 hover:border-slate-300"
}`}
>
<div
className="h-8 rounded mb-1"
style={{ backgroundColor: option.hex }}
/>
<p className="text-xs text-slate-600">{option.label}</p>
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Couleur du texte du bouton CTA
</label>
<div className="grid grid-cols-3 gap-2">
{CTA_TEXT_COLOR_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
onClick={() => setFormData({ ...formData, cta_text_color: option.value })}
className={`p-3 rounded-lg border-2 transition ${
formData.cta_text_color === option.value
? "border-indigo-600"
: "border-slate-200 hover:border-slate-300"
}`}
>
<div
className="h-8 rounded mb-1"
style={{ backgroundColor: option.hex }}
/>
<p className="text-xs text-slate-600">{option.label}</p>
</button>
))}
</div>
</div>
</>
)}
{/* Prévisualisation */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Aperçu
</label>
<div
className={`py-3 px-4 rounded-lg bg-gradient-to-r from-${formData.gradient_from} to-${formData.gradient_to}`}
style={{
background: `linear-gradient(to right, var(--tw-gradient-from), var(--tw-gradient-to))`
}}
>
<div className="flex items-center justify-between gap-4">
<p className={`text-sm font-medium text-${formData.text_color}`}>
{formData.text || "Votre texte ici..."}
</p>
{formData.cta_text && (
<span
className={`inline-flex items-center gap-2 px-3 py-1 rounded-lg text-sm font-semibold text-${formData.cta_text_color}`}
style={{ backgroundColor: formData.cta_bg_color }}
>
{formData.cta_text}
</span>
)}
</div>
</div>
</div>
{/* Activation */}
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.is_active}
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
className="rounded text-indigo-600 focus:ring-indigo-500"
/>
<span className="text-sm font-medium text-slate-700">
Activer cette bannière immédiatement
</span>
</label>
{/* Actions */}
<div className="flex items-center gap-3 pt-4 border-t">
<button
type="submit"
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition"
>
{editingId ? "Mettre à jour" : "Créer"}
</button>
<button
type="button"
onClick={resetForm}
className="px-4 py-2 border rounded-lg hover:bg-slate-50 transition"
>
Annuler
</button>
</div>
</form>
</div>
)}
{/* Liste des bannières */}
<div className="space-y-3">
{banners.length === 0 ? (
<div className="bg-white rounded-xl border p-6 text-center">
<p className="text-sm text-slate-600">Aucune bannière créée pour le moment</p>
</div>
) : (
banners.map((banner) => (
<div
key={banner.id}
className="bg-white rounded-xl border p-4"
>
<div className="flex items-start justify-between gap-4">
{/* Contenu */}
<div className="flex-1 space-y-2">
<div className="flex items-center gap-3">
<h3 className="font-medium text-slate-900">{banner.text}</h3>
{banner.is_active && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-green-100 text-green-700 text-xs font-medium rounded-full">
<Check className="w-3 h-3" />
Active
</span>
)}
</div>
<div className="flex items-center gap-4 text-xs text-slate-500">
{banner.cta_text && banner.cta_link && (
<span className="inline-flex items-center gap-1">
<LinkIcon className="w-3 h-3" />
CTA: {banner.cta_text}
</span>
)}
{banner.countdown_enabled && banner.countdown_end_date && (
<span className="inline-flex items-center gap-1">
<Calendar className="w-3 h-3" />
Fin: {new Date(banner.countdown_end_date).toLocaleString("fr-FR")}
</span>
)}
<span className="inline-flex items-center gap-1">
<Palette className="w-3 h-3" />
{banner.gradient_from} {banner.gradient_to}
</span>
</div>
{/* Aperçu miniature */}
<div className={`bg-gradient-to-r from-${banner.gradient_from} to-${banner.gradient_to} text-white py-2 px-3 rounded text-xs`}>
{banner.text}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
<button
onClick={() => toggleActive(banner)}
className={`p-2 rounded-lg transition ${
banner.is_active
? "bg-green-100 text-green-700 hover:bg-green-200"
: "bg-slate-100 text-slate-600 hover:bg-slate-200"
}`}
title={banner.is_active ? "Désactiver" : "Activer"}
>
{banner.is_active ? <X className="w-4 h-4" /> : <Check className="w-4 h-4" />}
</button>
<button
onClick={() => handleEdit(banner)}
className="p-2 bg-slate-100 text-slate-600 rounded-lg hover:bg-slate-200 transition"
title="Modifier"
>
<Edit className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(banner.id)}
className="p-2 bg-red-100 text-red-600 rounded-lg hover:bg-red-200 transition"
title="Supprimer"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
</div>
))
)}
</div>
</div>
);
}

View file

@ -0,0 +1,37 @@
export const dynamic = "force-dynamic";
import { cookies } from "next/headers";
import { createSbServer } from "@/lib/supabaseServer";
import { redirect } from "next/navigation";
import { Metadata } from "next";
import OffresPromoClient from "./OffresPromoClient";
export const metadata: Metadata = {
title: "Offres promo | Staff | Espace Paie Odentas",
};
export default async function OffresPromoPage() {
const sb = createSbServer();
const { data: { user } } = await sb.auth.getUser();
if (!user) {
redirect("/signin");
}
// Vérifier que l'utilisateur est staff
const { data: staffData } = await sb
.from("staff_users")
.select("is_staff")
.eq("user_id", user.id)
.maybeSingle();
if (!staffData?.is_staff) {
return (
<main className="p-6">
<h1 className="text-lg font-semibold">Accès refusé</h1>
<p className="text-sm text-slate-600">Cette page est réservée au Staff.</p>
</main>
);
}
return <OffresPromoClient />;
}

View file

@ -0,0 +1,137 @@
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const s3Client = new S3Client({
region: process.env.AWS_REGION || "eu-west-3",
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID || "",
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || "",
},
});
const BUCKET = (process.env.AWS_S3_BUCKET || "odentas-docs").trim();
/**
* GET /api/contrats/[id]/avenants
* Récupère les avenants signés d'un contrat avec leurs URLs presignées
*/
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
try {
const contractId = params.id;
if (!contractId) {
return NextResponse.json(
{ error: "Contract ID manquant" },
{ status: 400 }
);
}
const supabase = createRouteHandlerClient({ cookies });
// Vérifier l'authentification
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
return NextResponse.json(
{ error: "Non authentifié" },
{ status: 401 }
);
}
// Vérifier que l'utilisateur a accès au contrat
const { data: contract, error: contractError } = await supabase
.from("cddu_contracts")
.select("id, org_id")
.eq("id", contractId)
.single();
if (contractError || !contract) {
return NextResponse.json(
{ error: "Contrat non trouvé ou accès refusé" },
{ status: 404 }
);
}
// Récupérer les avenants signés du contrat
const { data: avenants, error: avenantError } = await supabase
.from("avenants")
.select(`
id,
numero_avenant,
date_avenant,
date_effet,
type_avenant,
motif_avenant,
elements_avenantes,
signature_status,
pdf_s3_key,
created_at
`)
.eq("contract_id", contractId)
.eq("signature_status", "signed")
.order("date_avenant", { ascending: false });
if (avenantError) {
console.error("Erreur récupération avenants:", avenantError);
return NextResponse.json(
{ error: "Erreur lors de la récupération des avenants" },
{ status: 500 }
);
}
// Générer les URLs presignées pour les avenants qui ont un PDF
const avenantWithUrls = await Promise.all(
(avenants || []).map(async (avenant) => {
let presignedUrl = null;
if (avenant.pdf_s3_key) {
try {
const command = new GetObjectCommand({
Bucket: BUCKET,
Key: avenant.pdf_s3_key,
});
presignedUrl = await getSignedUrl(s3Client, command, {
expiresIn: 3600, // 1 heure
});
} catch (error) {
console.error(`Erreur génération URL presignée pour avenant ${avenant.id}:`, error);
}
}
return {
id: avenant.id,
numero_avenant: avenant.numero_avenant,
date_avenant: avenant.date_avenant,
date_effet: avenant.date_effet,
type_avenant: avenant.type_avenant,
motif_avenant: avenant.motif_avenant,
elements_avenantes: avenant.elements_avenantes,
signature_status: avenant.signature_status,
has_pdf: !!avenant.pdf_s3_key,
signed_url: presignedUrl,
created_at: avenant.created_at,
};
})
);
// Filtrer pour ne garder que ceux qui ont un PDF disponible
const avenantAvailable = avenantWithUrls.filter(a => a.has_pdf && a.signed_url);
return NextResponse.json({
avenants: avenantAvailable,
count: avenantAvailable.length,
});
} catch (error) {
console.error("Erreur API avenants:", error);
return NextResponse.json(
{ error: "Erreur serveur" },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,227 @@
import { NextResponse } from "next/server";
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { cookies } from "next/headers";
export const dynamic = "force-dynamic";
// GET: Récupérer toutes les bannières (staff) ou la bannière active (public)
export async function GET() {
const sb = createRouteHandlerClient({ cookies });
const { data: { user } } = await sb.auth.getUser();
// Vérifier si l'utilisateur est staff
let isStaff = false;
if (user) {
const { data: staffData } = await sb
.from("staff_users")
.select("is_staff")
.eq("user_id", user.id)
.maybeSingle();
isStaff = !!staffData?.is_staff;
}
if (isStaff) {
// Staff : récupérer toutes les bannières
const { data, error } = await sb
.from("promo_banners")
.select("*")
.order("created_at", { ascending: false });
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ banners: data });
} else {
// Public : récupérer uniquement la bannière active
const { data, error } = await sb
.from("promo_banners")
.select("*")
.eq("is_active", true)
.maybeSingle();
if (error && error.code !== "PGRST116") {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ banner: data });
}
}
// POST: Créer une nouvelle bannière (staff uniquement)
export async function POST(req: Request) {
const sb = createRouteHandlerClient({ cookies });
const { data: { user } } = await sb.auth.getUser();
if (!user) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
// Vérifier que l'utilisateur est staff
const { data: staffData } = await sb
.from("staff_users")
.select("is_staff")
.eq("user_id", user.id)
.maybeSingle();
if (!staffData?.is_staff) {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
try {
const body = await req.json();
const {
text,
cta_text,
cta_link,
countdown_enabled,
countdown_end_date,
gradient_from,
gradient_to,
text_color,
cta_bg_color,
cta_text_color,
is_active,
} = body;
if (!text) {
return NextResponse.json({ error: "Le texte est requis" }, { status: 400 });
}
// Si on active cette bannière, désactiver toutes les autres
if (is_active) {
await sb
.from("promo_banners")
.update({ is_active: false })
.neq("id", "00000000-0000-0000-0000-000000000000"); // Désactive toutes
}
const { data, error } = await sb
.from("promo_banners")
.insert({
text,
cta_text: cta_text || null,
cta_link: cta_link || null,
countdown_enabled: countdown_enabled || false,
countdown_end_date: countdown_end_date || null,
gradient_from: gradient_from || "indigo-600",
gradient_to: gradient_to || "purple-600",
text_color: text_color || "slate-800",
cta_bg_color: cta_bg_color || "#efc543",
cta_text_color: cta_text_color || "slate-900",
is_active: is_active || false,
created_by: user.id,
updated_by: user.id,
})
.select()
.single();
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ banner: data });
} catch (err) {
return NextResponse.json({ error: "Erreur lors de la création" }, { status: 500 });
}
}
// PATCH: Mettre à jour une bannière existante (staff uniquement)
export async function PATCH(req: Request) {
const sb = createRouteHandlerClient({ cookies });
const { data: { user } } = await sb.auth.getUser();
if (!user) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
// Vérifier que l'utilisateur est staff
const { data: staffData } = await sb
.from("staff_users")
.select("is_staff")
.eq("user_id", user.id)
.maybeSingle();
if (!staffData?.is_staff) {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
try {
const body = await req.json();
const { id, ...updates } = body;
if (!id) {
return NextResponse.json({ error: "L'ID est requis" }, { status: 400 });
}
// Si on active cette bannière, désactiver toutes les autres
if (updates.is_active === true) {
await sb
.from("promo_banners")
.update({ is_active: false })
.neq("id", id);
}
const { data, error } = await sb
.from("promo_banners")
.update({
...updates,
updated_by: user.id,
})
.eq("id", id)
.select()
.single();
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ banner: data });
} catch (err) {
return NextResponse.json({ error: "Erreur lors de la mise à jour" }, { status: 500 });
}
}
// DELETE: Supprimer une bannière (staff uniquement)
export async function DELETE(req: Request) {
const sb = createRouteHandlerClient({ cookies });
const { data: { user } } = await sb.auth.getUser();
if (!user) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
// Vérifier que l'utilisateur est staff
const { data: staffData } = await sb
.from("staff_users")
.select("is_staff")
.eq("user_id", user.id)
.maybeSingle();
if (!staffData?.is_staff) {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
try {
const { searchParams } = new URL(req.url);
const id = searchParams.get("id");
if (!id) {
return NextResponse.json({ error: "L'ID est requis" }, { status: 400 });
}
const { error } = await sb
.from("promo_banners")
.delete()
.eq("id", id);
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ success: true });
} catch (err) {
return NextResponse.json({ error: "Erreur lors de la suppression" }, { status: 500 });
}
}

248
app/api/referrals/route.ts Normal file
View file

@ -0,0 +1,248 @@
import { NextResponse } from "next/server";
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { cookies } from "next/headers";
import { sendUniversalEmailV2 } from "@/lib/emailTemplateService";
export const dynamic = "force-dynamic";
// GET: Récupérer les parrainages de l'utilisateur ou d'une org (staff)
export async function GET(req: Request) {
const sb = createRouteHandlerClient({ cookies });
const { data: { user } } = await sb.auth.getUser();
if (!user) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
const { searchParams } = new URL(req.url);
const orgIdParam = searchParams.get("org_id");
// Vérifier si l'utilisateur est staff
const { data: staffData } = await sb
.from("staff_users")
.select("is_staff")
.eq("user_id", user.id)
.maybeSingle();
const isStaff = !!staffData?.is_staff;
let organizationId: string | null = null;
if (isStaff && orgIdParam) {
// Staff : utiliser l'org_id fourni en paramètre
organizationId = orgIdParam;
} else if (isStaff) {
// Staff sans org_id : ne rien retourner
return NextResponse.json({ referrals: [] });
} else {
// Client : récupérer son organisation
const { data: memberData } = await sb
.from("organization_members")
.select("org_id")
.eq("user_id", user.id)
.eq("revoked", false)
.maybeSingle();
if (!memberData?.org_id) {
return NextResponse.json({ error: "Organisation introuvable" }, { status: 404 });
}
organizationId = memberData.org_id;
}
// Récupérer les parrainages de cette organisation
const { data: referrals, error } = await sb
.from("referrals")
.select("*")
.eq("referrer_org_id", organizationId)
.order("created_at", { ascending: false });
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ referrals });
}
// POST: Créer un nouveau parrainage et envoyer l'email
export async function POST(req: Request) {
const sb = createRouteHandlerClient({ cookies });
const { data: { user } } = await sb.auth.getUser();
if (!user) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
try {
const body = await req.json();
const {
referrer_name,
referee_name,
referee_email,
personal_message,
} = body;
// Validation
if (!referrer_name || !referee_name || !referee_email) {
return NextResponse.json({
error: "Tous les champs obligatoires doivent être remplis"
}, { status: 400 });
}
// Validation email
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(referee_email)) {
return NextResponse.json({
error: "Email invalide"
}, { status: 400 });
}
// Vérifier si l'utilisateur est staff
const { data: staffData } = await sb
.from("staff_users")
.select("is_staff")
.eq("user_id", user.id)
.maybeSingle();
const isStaff = !!staffData?.is_staff;
console.log('[Referrals API] POST - User info:', {
userId: user.id,
isStaff,
staffData
});
// Pour le staff, récupérer l'org_id depuis les params
let orgId: string | null = null;
if (isStaff) {
const url = new URL(req.url);
orgId = url.searchParams.get("org_id");
console.log('[Referrals API] Staff mode:', {
requestUrl: req.url,
orgIdParam: orgId
});
if (!orgId) {
return NextResponse.json({
error: "L'org_id est requis pour le staff"
}, { status: 400 });
}
} else {
// Pour les clients, récupérer l'org_id depuis organization_members
const { data: memberData } = await sb
.from("organization_members")
.select("org_id")
.eq("user_id", user.id)
.eq("revoked", false)
.maybeSingle();
console.log('[Referrals API] Client mode:', {
memberData
});
if (!memberData?.org_id) {
return NextResponse.json({ error: "Organisation introuvable" }, { status: 404 });
}
orgId = memberData.org_id;
}
console.log('[Referrals API] Final orgId:', orgId);
if (!orgId) {
return NextResponse.json({ error: "Organisation introuvable" }, { status: 404 });
}
// Vérifier si un parrainage existe déjà pour cet email
const { data: existing } = await sb
.from("referrals")
.select("id, status")
.eq("referrer_org_id", orgId)
.eq("referee_email", referee_email)
.maybeSingle();
if (existing && existing.status !== 'cancelled') {
return NextResponse.json({
error: "Un parrainage existe déjà pour cette adresse email"
}, { status: 400 });
}
// Créer le parrainage
const { data: referral, error: insertError } = await sb
.from("referrals")
.insert({
referrer_org_id: orgId,
referrer_user_id: user.id,
referee_name,
referee_email,
personal_message: personal_message || null,
status: 'pending',
created_by: user.id,
})
.select()
.single();
if (insertError) {
console.error("Erreur création parrainage:", insertError);
return NextResponse.json({ error: insertError.message }, { status: 500 });
}
// Récupérer le nom de l'organisation pour l'email
const { data: orgData } = await sb
.from("organizations")
.select("name")
.eq("id", orgId)
.single();
const organizationName = orgData?.name || "cette organisation";
// Extraire les prénoms (premier mot du nom complet)
const referrerFirstName = referrer_name.split(' ')[0];
const refereeFirstName = referee_name.split(' ')[0];
// Envoyer l'email via le service mail universel v2
try {
console.log('[Referrals API] Sending email with data:', {
referrerFirstName,
refereeFirstName,
organizationName,
hasPersonalMessage: !!personal_message
});
const messageId = await sendUniversalEmailV2({
type: "referral",
toEmail: referee_email,
data: {
referrer_first_name: referrerFirstName,
referee_first_name: refereeFirstName,
organization_name: organizationName,
personal_message: personal_message || "",
},
});
console.log('[Referrals API] Email sent successfully:', messageId);
// Mettre à jour le statut et la date d'envoi
await sb
.from("referrals")
.update({
status: 'email_sent',
email_sent_at: new Date().toISOString(),
})
.eq("id", referral.id);
referral.status = 'email_sent';
referral.email_sent_at = new Date().toISOString();
} catch (emailError) {
console.error("Erreur lors de l'envoi de l'email:", emailError);
// On continue même si l'email échoue
}
return NextResponse.json({ referral });
} catch (err) {
console.error("Erreur POST /api/referrals:", err);
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
}
}

View file

@ -0,0 +1,86 @@
import { NextResponse } from "next/server";
import { sendUniversalEmailV2, EmailTypeV2 } from "@/lib/emailTemplateService";
export async function POST(req: Request) {
try {
const body = await req.json();
const { to, subject, template, data, cc } = body;
// Validation
if (!to || !template) {
return NextResponse.json(
{ error: "Les champs 'to' et 'template' sont requis" },
{ status: 400 }
);
}
// Valider que le template est un EmailTypeV2 valide
const validTypes: EmailTypeV2[] = [
'contract-created',
'contract-updated',
'contract-cancelled',
'employee-created',
'invitation',
'auto-declaration-invitation',
'invoice',
'signature-request',
'signature-request-employer',
'signature-request-employee',
'signature-request-employee-amendment',
'signature-request-salarie',
'amendment-completed-employer',
'amendment-completed-employee',
'bulk-signature-notification',
'salary-transfer-notification',
'salary-transfer-payment-confirmation',
'contribution-notification',
'production-declared',
'notification',
'support-reply',
'support-ticket-created',
'support-ticket-reply',
'contact-support',
'referral',
'account-activation',
'access-updated',
'access-revoked',
'password-created',
'password-changed',
'twofa-enabled',
'twofa-disabled',
];
if (!validTypes.includes(template)) {
return NextResponse.json(
{ error: `Type de template invalide: ${template}` },
{ status: 400 }
);
}
// Préparer les données avec un discount par défaut pour le template referral
const emailData = template === 'referral'
? { ...data, discount: '20 € HT' }
: data;
// Envoyer l'email
const messageId = await sendUniversalEmailV2({
type: template as EmailTypeV2,
toEmail: to,
ccEmail: cc,
subject,
data: emailData,
});
return NextResponse.json({
success: true,
messageId,
message: "Email envoyé avec succès"
});
} catch (error: any) {
console.error("Erreur lors de l'envoi de l'email:", error);
return NextResponse.json(
{ error: error.message || "Erreur lors de l'envoi de l'email" },
{ status: 500 }
);
}
}

View file

@ -18,6 +18,7 @@ export async function GET(req: Request) {
const q = url.searchParams.get("q");
const structure = url.searchParams.get("structure");
const type_de_contrat = url.searchParams.get("type_de_contrat");
const production_name = url.searchParams.get("production_name");
const etat_de_la_demande = url.searchParams.get("etat_de_la_demande");
const etat_de_la_paie = url.searchParams.get("etat_de_la_paie");
const dpae = url.searchParams.get("dpae");
@ -49,6 +50,7 @@ export async function GET(req: Request) {
}
if (employee_matricule) query = query.eq("employee_matricule", employee_matricule);
if (structure) query = query.eq("structure", structure);
if (production_name) query = query.eq("production_name", production_name);
// Handle special "RG" filter for common law contracts (CDD de droit commun + CDI)
if (type_de_contrat === "RG") {
@ -83,8 +85,8 @@ export async function GET(req: Request) {
if (end_from) query = query.gte("end_date", end_from);
if (end_to) query = query.lte("end_date", end_to);
// allow sort by start_date or end_date or created_at or employee_name
const allowedSorts = new Set(["start_date", "end_date", "created_at", "contract_number", "employee_name"]);
// allow sort by start_date or end_date or created_at or employee_name or production_name
const allowedSorts = new Set(["start_date", "end_date", "created_at", "contract_number", "employee_name", "production_name"]);
const sortCol = allowedSorts.has(sort) ? sort : "created_at";
// Pour le tri par nom, on doit traiter différemment

View file

@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { createSbServiceRole } from "@/lib/supabaseServer";
import { sendUniversalEmailV2, EmailDataV2 } from "@/lib/emailTemplateService";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { checkAndMarkWebhookProcessed } from "@/lib/webhookDeduplication";
const s3Client = new S3Client({
region: process.env.AWS_REGION || "eu-west-3",
@ -72,6 +73,24 @@ export async function POST(request: NextRequest) {
);
}
// 🔒 DÉDUPLICATION : Vérifier si ce webhook a déjà été traité
const isNew = await checkAndMarkWebhookProcessed(
submissionId,
"avenant_employee_signed",
undefined,
body
);
if (!isNew) {
console.log("⚠️ [WEBHOOK AVENANT COMPLETED] Webhook déjà traité, ignoré pour éviter doublon");
return NextResponse.json(
{ message: "Webhook déjà traité" },
{ status: 200 }
);
}
console.log("✅ [WEBHOOK AVENANT COMPLETED] Nouveau webhook, traitement...");
if (!documentUrl) {
console.error("❌ [WEBHOOK AVENANT COMPLETED] document URL manquante");
return NextResponse.json(

View file

@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { createSbServiceRole } from "@/lib/supabaseServer";
import { sendUniversalEmailV2, EmailDataV2 } from "@/lib/emailTemplateService";
import { checkAndMarkWebhookProcessed } from "@/lib/webhookDeduplication";
export const dynamic = "force-dynamic";
@ -42,18 +43,37 @@ export async function POST(request: NextRequest) {
} = body;
// Validation des champs requis
if (!documentName || !employeeSlug || !employeeEmail) {
if (!documentName || !employeeSlug || !employeeEmail || !submissionId) {
console.error("❌ [WEBHOOK AVENANT] Champs manquants:", {
documentName: !!documentName,
employeeSlug: !!employeeSlug,
employeeEmail: !!employeeEmail,
submissionId: !!submissionId,
});
return NextResponse.json(
{ error: "Champs requis manquants" },
{ error: "Champs manquants" },
{ status: 400 }
);
}
// 🔒 DÉDUPLICATION : Vérifier si ce webhook a déjà été traité
const isNew = await checkAndMarkWebhookProcessed(
submissionId,
"avenant_employer_signed",
undefined,
body
);
if (!isNew) {
console.log("⚠️ [WEBHOOK AVENANT] Webhook déjà traité, ignoré pour éviter doublon");
return NextResponse.json(
{ message: "Webhook déjà traité" },
{ status: 200 }
);
}
console.log("✅ [WEBHOOK AVENANT] Nouveau webhook, traitement...");
const supabase = createSbServiceRole();
// 1. Trouver l'avenant par son submission ID DocuSeal (identifiant unique)

212
components/PromoBanner.tsx Normal file
View file

@ -0,0 +1,212 @@
"use client";
import { useEffect, useState } from "react";
import { X, ChevronRight } from "lucide-react";
interface PromoBanner {
id: string;
text: string;
cta_text?: string | null;
cta_link?: string | null;
countdown_enabled: boolean;
countdown_end_date?: string | null;
gradient_from: string;
gradient_to: string;
text_color: string;
cta_bg_color: string;
cta_text_color: string;
is_active: boolean;
}
function useCountdown(endDate: string | null | undefined) {
const [timeLeft, setTimeLeft] = useState<string>("");
useEffect(() => {
if (!endDate) return;
const calculateTimeLeft = () => {
const now = new Date().getTime();
const end = new Date(endDate).getTime();
const diff = end - now;
if (diff <= 0) {
setTimeLeft("Terminé");
return;
}
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
if (days > 0) {
setTimeLeft(`${days}j ${hours}h ${minutes}m ${seconds}s`);
} else if (hours > 0) {
setTimeLeft(`${hours}h ${minutes}m ${seconds}s`);
} else if (minutes > 0) {
setTimeLeft(`${minutes}m ${seconds}s`);
} else {
setTimeLeft(`${seconds}s`);
}
};
calculateTimeLeft();
const interval = setInterval(calculateTimeLeft, 1000);
return () => clearInterval(interval);
}, [endDate]);
return timeLeft;
}
export function PromoBanner() {
const [banner, setBanner] = useState<PromoBanner | null>(null);
const [dismissed, setDismissed] = useState(false);
const [loading, setLoading] = useState(true);
const timeLeft = useCountdown(banner?.countdown_end_date);
useEffect(() => {
async function fetchBanner() {
try {
const res = await fetch("/api/promo-banners", {
credentials: "include",
cache: "no-store"
});
if (!res.ok) throw new Error("Erreur fetch");
const data = await res.json();
if (data.banner) {
setBanner(data.banner);
// Vérifier si la bannière a été fermée dans ce session
const dismissedId = sessionStorage.getItem("promo_banner_dismissed");
if (dismissedId === data.banner.id) {
setDismissed(true);
}
}
} catch (err) {
console.error("Erreur lors du chargement de la bannière promo:", err);
} finally {
setLoading(false);
}
}
fetchBanner();
}, []);
const handleDismiss = () => {
if (banner) {
sessionStorage.setItem("promo_banner_dismissed", banner.id);
}
setDismissed(true);
};
if (loading || !banner || dismissed || !banner.is_active) {
return null;
}
// Convertir les classes Tailwind en couleurs CSS
const getGradientColors = (tailwindClass: string): string => {
const colorMap: Record<string, string> = {
'slate-100': '#f1f5f9',
'slate-200': '#e2e8f0',
'indigo-100': '#e0e7ff',
'purple-100': '#f3e8ff',
'blue-50': '#eff6ff',
'cyan-100': '#cffafe',
'purple-50': '#faf5ff',
'pink-100': '#fce7f3',
'orange-50': '#fff7ed',
'amber-100': '#fef3c7',
'green-50': '#f0fdf4',
'teal-100': '#ccfbf1',
};
return colorMap[tailwindClass] || '#f1f5f9';
};
const getTextColor = (tailwindClass: string): string => {
const colorMap: Record<string, string> = {
'slate-800': '#1e293b',
'slate-900': '#0f172a',
'white': '#ffffff',
'indigo-900': '#312e81',
'purple-900': '#581c87',
};
return colorMap[tailwindClass] || '#1e293b';
};
const getCtaTextColor = (tailwindClass: string): string => {
const colorMap: Record<string, string> = {
'slate-800': '#1e293b',
'slate-900': '#0f172a',
'white': '#ffffff',
};
return colorMap[tailwindClass] || '#0f172a';
};
const gradientFrom = getGradientColors(banner.gradient_from);
const gradientTo = getGradientColors(banner.gradient_to);
const textColor = getTextColor(banner.text_color || 'slate-800');
const ctaBgColor = banner.cta_bg_color || '#efc543';
const ctaTextColor = getCtaTextColor(banner.cta_text_color || 'slate-900');
return (
<div
className="py-3 px-4 relative"
style={{
background: `linear-gradient(to right, ${gradientFrom}, ${gradientTo})`,
color: textColor
}}
role="banner"
aria-label="Bannière promotionnelle"
>
<div className="max-w-7xl mx-auto flex items-center justify-between gap-4">
{/* Texte principal */}
<div className="flex-1 flex items-center gap-4 flex-wrap">
<p className="text-sm font-medium">{banner.text}</p>
{/* Compte à rebours */}
{banner.countdown_enabled && banner.countdown_end_date && timeLeft && (
<div
className="inline-flex items-center gap-2 backdrop-blur-sm rounded-lg px-3 py-1"
style={{
backgroundColor: banner.text_color === 'white' ? 'rgba(0, 0, 0, 0.1)' : 'rgba(0, 0, 0, 0.05)'
}}
>
<span className="text-xs font-semibold">{timeLeft}</span>
</div>
)}
</div>
{/* CTA */}
{banner.cta_text && banner.cta_link && (
<a
href={banner.cta_link}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 transition px-4 py-2 rounded-lg text-sm font-semibold whitespace-nowrap hover:opacity-90"
style={{
backgroundColor: ctaBgColor,
color: ctaTextColor
}}
>
{banner.cta_text}
<ChevronRight className="w-4 h-4" />
</a>
)}
{/* Bouton fermer */}
<button
onClick={handleDismiss}
className="rounded-full p-1 transition hover:opacity-70"
style={{ color: textColor }}
aria-label="Fermer la bannière"
title="Fermer"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
);
}

View file

@ -2,7 +2,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState, useEffect, useRef } from "react";
import { Shield, KeyRound, Home, FileSignature, Banknote, Users, Percent, FolderOpen, Receipt, Building2, UserCog, Edit3, LifeBuoy, CreditCard, Calculator, Euro, Mail, Database, Clapperboard, LogOut, Scale, FileEdit, FileText } from "lucide-react";
import { Shield, KeyRound, Home, FileSignature, Banknote, Users, Percent, FolderOpen, Receipt, Building2, UserCog, Edit3, LifeBuoy, CreditCard, Calculator, Euro, Mail, Database, Clapperboard, LogOut, Scale, FileEdit, FileText, Megaphone, Gift } from "lucide-react";
// import { api } from "@/lib/fetcher";
import { createPortal } from "react-dom";
import LogoutButton from "@/components/LogoutButton";
@ -379,6 +379,21 @@ export default function Sidebar({ clientInfo, isStaff = false, mobile = false, o
<LogoutButton variant="compact" className="w-full justify-center" />
)}
</div>
{/* Parrainage - Bouton discret en dessous */}
{!isDemoMode && (
<div className="mt-2 pt-2 border-t">
<Link
href="/parrainage"
prefetch={false}
className="inline-flex items-center gap-1.5 px-2 py-1.5 rounded-md text-[10px] font-medium bg-gradient-to-r from-orange-50 to-amber-50 text-orange-700 hover:from-orange-100 hover:to-amber-100 w-full justify-center transition-colors"
title="Parrainez vos amis"
>
<Gift className="w-3 h-3" />
Parrainage
</Link>
</div>
)}
</div>
</div>
@ -628,7 +643,7 @@ export default function Sidebar({ clientInfo, isStaff = false, mobile = false, o
</Link>
<Link href="/staff/documents" onClick={() => onNavigate && onNavigate()} className={`block px-3 py-2 rounded-xl text-sm transition truncate ${
isActivePath(pathname, "/staff/documents") ? "bg-gradient-to-r from-indigo-200 via-purple-200 to-pink-200 text-slate-700 font-semibold" : "hover:bg-slate-50"
}`} title="Gestion des documents">
}`} title="Documents">
<span className="inline-flex items-center gap-2">
<FolderOpen className="w-4 h-4" aria-hidden />
<span>Documents</span>
@ -641,6 +656,14 @@ export default function Sidebar({ clientInfo, isStaff = false, mobile = false, o
<div className="px-3 py-1.5 text-xs font-medium text-slate-500 uppercase tracking-wider">
Espace Paie
</div>
<Link href="/staff/offres-promo" onClick={() => onNavigate && onNavigate()} className={`block px-3 py-2 rounded-xl text-sm transition truncate ${
isActivePath(pathname, "/staff/offres-promo") ? "bg-gradient-to-r from-indigo-200 via-purple-200 to-pink-200 text-slate-700 font-semibold" : "hover:bg-slate-50"
}`} title="Offres promo">
<span className="inline-flex items-center gap-2">
<Megaphone className="w-4 h-4" aria-hidden />
<span>Offres promo</span>
</span>
</Link>
<div className="px-3 py-2">
<MaintenanceButton isStaff={true} />
</div>

View file

@ -38,11 +38,27 @@ interface PayslipData {
hasDocument: boolean;
}
interface AvenantData {
id: string;
numero_avenant: string;
date_avenant: string;
date_effet: string;
type_avenant: string;
motif_avenant: string | null;
elements_avenantes: string[];
signature_status: string;
has_pdf: boolean;
signed_url: string;
created_at: string;
}
export default function DocumentsCard({ contractId, contractNumber, contractData, showPayslips = true }: DocumentsCardProps) {
const [signedPdfData, setSignedPdfData] = useState<SignedPdfData | null>(null);
const [loadingSignedPdf, setLoadingSignedPdf] = useState(true);
const [payslips, setPayslips] = useState<PayslipData[]>([]);
const [loadingPayslips, setLoadingPayslips] = useState(true);
const [avenants, setAvenants] = useState<AvenantData[]>([]);
const [loadingAvenants, setLoadingAvenants] = useState(true);
// États pour le modal
const [isModalOpen, setIsModalOpen] = useState(false);
@ -96,6 +112,18 @@ export default function DocumentsCard({ contractId, contractNumber, contractData
}
};
// Fonction pour ouvrir un avenant
const openAvenant = (avenant: AvenantData) => {
if (avenant.signed_url) {
openDocumentInModal(
avenant.signed_url,
`Avenant ${avenant.numero_avenant}`
);
} else {
toast.error("Document d'avenant non disponible");
}
};
// Fonction pour ouvrir dans un nouvel onglet
const openInNewTab = () => {
if (currentDocumentUrl) {
@ -144,6 +172,33 @@ export default function DocumentsCard({ contractId, contractNumber, contractData
fetchSignedPdf();
}, [contractId]);
// Récupération des avenants signés
useEffect(() => {
const fetchAvenants = async () => {
try {
const response = await fetch(`/api/contrats/${contractId}/avenants`, {
credentials: 'include',
headers: { Accept: 'application/json' }
});
if (response.ok) {
const data = await response.json();
setAvenants(data.avenants || []);
} else {
console.error("Erreur lors de la récupération des avenants");
setAvenants([]);
}
} catch (error) {
console.error("Erreur:", error);
setAvenants([]);
} finally {
setLoadingAvenants(false);
}
};
fetchAvenants();
}, [contractId]);
// Effet pour bloquer le défilement et gérer la touche Échap quand le modal est ouvert
useEffect(() => {
if (isModalOpen) {
@ -220,7 +275,8 @@ export default function DocumentsCard({ contractId, contractNumber, contractData
const hasSignedContract = signedPdfData?.hasSignedPdf && signedPdfData?.signedUrl;
const hasOriginalContract = contractData?.pdf_contrat?.available && contractData?.pdf_contrat?.url;
const bothPartiesSigned = contractData?.contrat_signe_employeur === 'oui' && contractData?.contrat_signe_salarie === 'oui';
const hasAnyDocument = hasSignedContract || hasOriginalContract || (showPayslips && payslips.some(p => p.hasDocument));
const hasAvenants = avenants.length > 0;
const hasAnyDocument = hasSignedContract || hasOriginalContract || hasAvenants || (showPayslips && payslips.some(p => p.hasDocument));
return (
<Card className="rounded-3xl">
@ -289,6 +345,43 @@ export default function DocumentsCard({ contractId, contractNumber, contractData
)
) : null}
{/* Avenants signés */}
{loadingAvenants ? (
<div className="flex items-center gap-3 p-3 border rounded-lg bg-gray-50">
<RefreshCw className="size-5 text-gray-400 animate-spin" />
<div className="flex-1">
<p className="font-medium text-gray-600">Chargement des avenants...</p>
</div>
</div>
) : avenants.length > 0 ? (
avenants.map((avenant) => (
<div
key={avenant.id}
onClick={() => openAvenant(avenant)}
className="flex items-center gap-3 p-3 border-2 border-purple-200 bg-purple-50 rounded-lg cursor-pointer hover:bg-purple-100 transition-colors"
>
<FileText className="size-5 text-purple-600" />
<div className="flex-1">
<p className="font-medium text-purple-800">
Avenant {avenant.numero_avenant}
</p>
<p className="text-sm text-purple-600">
{avenant.type_avenant === 'modification' ? 'Modification' : 'Annulation'}
Signé le {formatDate(avenant.date_avenant)}
</p>
{avenant.motif_avenant && (
<p className="text-xs text-purple-500 mt-1 line-clamp-1">
{avenant.motif_avenant}
</p>
)}
</div>
<div className="text-xs text-purple-600">
Cliquez pour ouvrir
</div>
</div>
))
) : null}
{/* Fiches de paie - afficher uniquement si showPayslips est true */}
{showPayslips && (
<>
@ -326,7 +419,7 @@ export default function DocumentsCard({ contractId, contractNumber, contractData
)}
{/* Message quand aucun document n'est disponible */}
{!loadingSignedPdf && !loadingPayslips && !hasAnyDocument && (
{!loadingSignedPdf && !loadingPayslips && !loadingAvenants && !hasAnyDocument && (
<div className="text-center text-muted-foreground py-6">
<FileText className="size-8 mx-auto mb-2 opacity-50" />
<p className="font-medium">Aucun document disponible</p>

View file

@ -1,9 +1,89 @@
"use client";
// components/staff/BulkEmailForm.tsx
import { useState, useRef } from "react";
import { Eye, Send, Users, Mail, X, Check, Filter, Search, FileText, Palette } from "lucide-react";
import { useState, useRef, useEffect } from "react";
import { Eye, Send, Users, Mail, X, Check, Filter, Search, Save, FolderOpen, Bold, Italic, List } from "lucide-react";
import BulkEmailProgressModal from "./BulkEmailProgressModal";
import { createClientComponentClient } from "@supabase/auth-helpers-nextjs";
// Composant d'éditeur de texte avec formatage
interface RichTextEditorProps {
value: string;
onChange: (value: string) => void;
disabled?: boolean;
rows?: number;
placeholder?: string;
}
function RichTextEditor({ value, onChange, disabled, rows = 4, placeholder }: RichTextEditorProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const insertFormatting = (before: string, after: string = '') => {
if (!textareaRef.current) return;
const textarea = textareaRef.current;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selectedText = value.substring(start, end);
const newText = value.substring(0, start) + before + selectedText + after + value.substring(end);
onChange(newText);
// Repositionner le curseur
setTimeout(() => {
textarea.focus();
const newCursorPos = start + before.length + selectedText.length;
textarea.setSelectionRange(newCursorPos, newCursorPos);
}, 0);
};
return (
<div className="space-y-2">
<div className="flex gap-1 p-2 bg-gray-50 border border-gray-200 rounded-t-lg">
<button
type="button"
onClick={() => insertFormatting('<strong>', '</strong>')}
disabled={disabled}
className="p-2 hover:bg-gray-200 rounded transition-colors disabled:opacity-50"
title="Gras"
>
<Bold className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => insertFormatting('<em>', '</em>')}
disabled={disabled}
className="p-2 hover:bg-gray-200 rounded transition-colors disabled:opacity-50"
title="Italique"
>
<Italic className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => insertFormatting('\n- ')}
disabled={disabled}
className="p-2 hover:bg-gray-200 rounded transition-colors disabled:opacity-50"
title="Puce"
>
<List className="w-4 h-4" />
</button>
<div className="flex-1" />
<span className="text-xs text-gray-500 self-center">
Sélectionnez du texte et cliquez sur un bouton pour formater
</span>
</div>
<textarea
ref={textareaRef}
value={value}
onChange={(e) => onChange(e.target.value)}
rows={rows}
placeholder={placeholder}
className="w-full px-3 py-2 border border-t-0 rounded-b-lg font-sans"
disabled={disabled}
/>
</div>
);
}
interface User {
id: string;
@ -33,77 +113,29 @@ type EmailProgress = {
};
export default function BulkEmailForm({ users }: BulkEmailFormProps) {
const [subject, setSubject] = useState("");
const [htmlContent, setHtmlContent] = useState(`<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Email - Espace Paie Odentas</title>
<style>
body { margin: 0; padding: 0; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; }
.container { max-width: 600px; margin: 0 auto; background-color: #ffffff; }
.header { background: linear-gradient(135deg, #171424 0%, #2d1b3d 100%); color: white; padding: 30px 20px; text-align: center; }
.header h1 { margin: 0; font-size: 28px; font-weight: 300; }
.content { padding: 40px 30px; background-color: #f8f9fa; }
.content h2 { color: #171424; margin-top: 0; margin-bottom: 20px; font-size: 24px; }
.content p { line-height: 1.6; color: #333; margin-bottom: 16px; }
.button { display: inline-block; background-color: #efc543; color: #000; padding: 15px 30px; text-decoration: none; border-radius: 8px; font-weight: 600; margin: 20px 0; transition: background-color 0.3s; }
.button:hover { background-color: #d4a834; }
.footer { background-color: #f1f3f4; padding: 20px; text-align: center; color: #666; font-size: 12px; }
.highlight { background-color: #fff3cd; border: 1px solid #ffeaa7; padding: 15px; border-radius: 5px; margin: 20px 0; }
@media only screen and (max-width: 600px) {
.content { padding: 20px 15px; }
.header { padding: 20px 15px; }
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🏢 Espace Paie Odentas</h1>
<p style="margin: 10px 0 0 0; opacity: 0.9; font-size: 16px;">Votre partenaire RH de confiance</p>
</div>
<div class="content">
<h2>Bonjour,</h2>
<p>Nous espérons que vous allez bien. Nous vous contactons aujourd'hui pour vous informer de...</p>
<div class="highlight">
<strong>📢 Information importante :</strong> N'hésitez pas à personnaliser ce message selon vos besoins.
</div>
<p>Pour plus d'informations ou pour toute question, n'hésitez pas à nous contacter.</p>
<div style="text-align: center; margin: 30px 0;">
<a href="#" class="button">Accéder à mon espace</a>
</div>
<p>Cordialement,<br><strong>L'équipe Odentas</strong></p>
</div>
<div class="footer">
<p><strong>Odentas Media SAS</strong><br>
Votre partenaire pour la gestion de la paie et des RH</p>
<p>📧 contact@odentas.com | 🌐 www.odentas.com</p>
<p style="margin-top: 15px;">
<span style="color: #666;">🇫🇷 Données hébergées en France</span><br>
© ${new Date().getFullYear()} Odentas Media SAS - Tous droits réservés
</p>
</div>
</div>
</body>
</html>`);
const [subject, setSubject] = useState("Information importante - Espace Paie");
// États pour l'éditeur visuel
const [emailTitle, setEmailTitle] = useState("Communication importante");
const [emailGreeting, setEmailGreeting] = useState("Bonjour,");
const [emailIntro, setEmailIntro] = useState("Nous vous contactons aujourd'hui pour vous informer d'une information importante concernant votre espace paie.");
const [contentTitle, setContentTitle] = useState("Détails de la communication");
const [contentBody, setContentBody] = useState("Voici les informations importantes que nous souhaitons partager avec vous :\n\n- Point important numéro 1\n- Point important numéro 2\n- Point important numéro 3\n\nN'hésitez pas à nous contacter si vous avez des questions concernant cette communication.");
const [showAlert, setShowAlert] = useState(false);
const [alertTitle, setAlertTitle] = useState("Information importante");
const [alertText, setAlertText] = useState("Ajoutez ici un message important qui nécessite l'attention particulière des destinataires.");
const [showButton, setShowButton] = useState(false);
const [buttonText, setButtonText] = useState("Accéder à mon espace");
const [buttonUrl, setButtonUrl] = useState("https://paie.odentas.fr");
const [closingMessage, setClosingMessage] = useState("Pour toute question, n'hésitez pas à nous contacter à paie@odentas.fr.\n\nCordialement,\nL'équipe Odentas");
const [selectedUsers, setSelectedUsers] = useState<Set<string>>(new Set());
const [showPreview, setShowPreview] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [editMode, setEditMode] = useState<'visual' | 'code'>('visual');
const [searchTerm, setSearchTerm] = useState("");
const [roleFilter, setRoleFilter] = useState("all");
const [orgFilter, setOrgFilter] = useState("all");
const [showUsersPanel, setShowUsersPanel] = useState(true);
const [showTemplates, setShowTemplates] = useState(false);
// États pour le modal de progression
const [showProgressModal, setShowProgressModal] = useState(false);
@ -116,33 +148,282 @@ export default function BulkEmailForm({ users }: BulkEmailFormProps) {
});
const [currentStream, setCurrentStream] = useState<AbortController | null>(null);
const previewRef = useRef<HTMLDivElement>(null);
// États pour la gestion des brouillons
const [drafts, setDrafts] = useState<any[]>([]);
const [showDraftsModal, setShowDraftsModal] = useState(false);
const [showSaveDraftModal, setShowSaveDraftModal] = useState(false);
const [draftName, setDraftName] = useState("");
const [currentDraftId, setCurrentDraftId] = useState<string | null>(null);
const [isSavingDraft, setIsSavingDraft] = useState(false);
const previewRef = useRef<HTMLIFrameElement>(null);
const supabase = createClientComponentClient();
// Templates prédéfinis
const templates = [
{
name: "Maintenance programmée",
subject: "Maintenance programmée - Espace Paie Odentas",
description: "Informer les utilisateurs d'une maintenance"
},
{
name: "Nouvelle fonctionnalité",
subject: "Nouvelle fonctionnalité disponible",
description: "Annoncer une nouvelle fonctionnalité"
},
{
name: "Information importante",
subject: "Information importante - Espace Paie",
description: "Communication générale importante"
// Charger les brouillons au montage du composant
useEffect(() => {
loadDrafts();
}, []);
const loadDrafts = async () => {
try {
const { data, error } = await supabase
.from('bulk_email_drafts')
.select('*')
.order('updated_at', { ascending: false });
if (error) throw error;
setDrafts(data || []);
} catch (error) {
console.error('Erreur lors du chargement des brouillons:', error);
}
];
const loadTemplate = (templateName: string) => {
setSubject(templates.find(t => t.name === templateName)?.subject || "");
// Vous pouvez ajouter du contenu HTML spécifique pour chaque template ici
setShowTemplates(false);
};
const saveDraft = async () => {
if (!draftName.trim()) {
alert("Veuillez saisir un nom pour le brouillon");
return;
}
setIsSavingDraft(true);
try {
const draftData = {
draft_name: draftName,
subject,
email_title: emailTitle,
email_greeting: emailGreeting,
email_intro: emailIntro,
content_title: contentTitle,
content_body: contentBody,
show_alert: showAlert,
alert_title: alertTitle,
alert_text: alertText,
show_button: showButton,
button_text: buttonText,
button_url: buttonUrl,
closing_message: closingMessage,
filters: {
roleFilter,
orgFilter,
searchTerm
}
};
if (currentDraftId) {
// Mise à jour d'un brouillon existant
const { error } = await supabase
.from('bulk_email_drafts')
.update(draftData)
.eq('id', currentDraftId);
if (error) throw error;
alert("Brouillon mis à jour avec succès");
} else {
// Création d'un nouveau brouillon
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error("Utilisateur non connecté");
const { data, error } = await supabase
.from('bulk_email_drafts')
.insert([{ ...draftData, user_id: user.id }])
.select()
.single();
if (error) throw error;
setCurrentDraftId(data.id);
alert("Brouillon enregistré avec succès");
}
await loadDrafts();
setShowSaveDraftModal(false);
setDraftName("");
} catch (error: any) {
console.error('Erreur lors de la sauvegarde du brouillon:', error);
alert(`Erreur: ${error.message}`);
} finally {
setIsSavingDraft(false);
}
};
const loadDraft = (draft: any) => {
setSubject(draft.subject);
setEmailTitle(draft.email_title);
setEmailGreeting(draft.email_greeting);
setEmailIntro(draft.email_intro);
setContentTitle(draft.content_title);
setContentBody(draft.content_body);
setShowAlert(draft.show_alert);
setAlertTitle(draft.alert_title || "");
setAlertText(draft.alert_text || "");
setShowButton(draft.show_button);
setButtonText(draft.button_text || "");
setButtonUrl(draft.button_url || "");
setClosingMessage(draft.closing_message);
// Charger les filtres si disponibles
if (draft.filters) {
setRoleFilter(draft.filters.roleFilter || "all");
setOrgFilter(draft.filters.orgFilter || "all");
setSearchTerm(draft.filters.searchTerm || "");
}
setCurrentDraftId(draft.id);
setDraftName(draft.draft_name);
setShowDraftsModal(false);
};
const deleteDraft = async (draftId: string) => {
if (!confirm("Êtes-vous sûr de vouloir supprimer ce brouillon ?")) return;
try {
const { error } = await supabase
.from('bulk_email_drafts')
.delete()
.eq('id', draftId);
if (error) throw error;
if (currentDraftId === draftId) {
setCurrentDraftId(null);
setDraftName("");
}
await loadDrafts();
alert("Brouillon supprimé avec succès");
} catch (error: any) {
console.error('Erreur lors de la suppression du brouillon:', error);
alert(`Erreur: ${error.message}`);
}
};
// Fonction pour générer le HTML à partir des champs du formulaire
const generateHtmlContent = () => {
// Fonction helper pour convertir le texte avec formatage en HTML
const processText = (text: string) => {
return text
.replace(/\n/g, '<br>'); // Conserver les sauts de ligne simples
};
// Convertir les sauts de ligne en paragraphes HTML pour le contenu principal
const contentParagraphs = contentBody
.split('\n\n')
.map(para => {
if (para.trim().startsWith('-')) {
// Convertir les listes en <ul><li>
const items = para.split('\n').filter(line => line.trim().startsWith('-'));
const listItems = items.map(item => {
const itemText = item.trim().substring(1).trim();
return `<li style="margin-bottom:8px;">${itemText}</li>`;
}).join('\n ');
return `<ul style="margin:0 0 12px 0; padding-left:20px;">\n ${listItems}\n </ul>`;
} else {
return `<p style="margin:0 0 12px 0;">\n ${para.trim()}\n </p>`;
}
})
.join('\n ');
// Convertir les sauts de ligne en <br> pour le message de clôture
const formattedClosingMessage = closingMessage.replace(/\n/g, '<br>');
return `<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Communication Odentas</title>
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<style>
body { margin:0; padding:0; background:#F4F6FA; color:#0F172A; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; }
a { color:#0B5FFF; text-decoration:none; }
.wrapper { width:100%; padding:32px 0; background:#F4F6FA; }
.container { width:100%; max-width:520px; margin:0 auto; background:#FFFFFF; border-radius:14px; box-shadow: 0 6px 20px rgba(15,23,42,0.06); overflow:hidden; }
.logo { height:60px; display:block; }
.label { font-size:12px; color:#64748B; text-transform:uppercase; letter-spacing:0.03em; margin-bottom:6px; font-weight:500; }
.value { font-size:15px; color:#0F172A; font-weight:500; line-height:1.3; }
.muted { color:#94A3B8; }
.card { margin:16px 28px 0 28px; border:1px solid #E5E7EB; border-radius:12px; padding:20px; background:#F9FAFB; }
.highlight { margin:16px 28px 0 28px; border:1px solid #fde68a; border-radius:12px; padding:16px; background:#fffbeb; }
@media (prefers-color-scheme: dark) {
body, .wrapper { background:#0B1220; color:#E5E7EB; }
}
@media (max-width: 480px) {
.wrapper { padding:16px 0; }
.card, .highlight { margin:16px 16px 0 16px; padding:16px; }
}
</style>
</head>
<body>
<div style="display:none;max-height:0;overflow:hidden;opacity:0;">Communication importante de l'équipe Odentas</div>
<div class="wrapper" style="width:100%; padding:32px 0; background:#F4F6FA;">
<div class="container" style="width:100%; max-width:520px; margin:0 auto; background:#FFFFFF; border-radius:14px; box-shadow: 0 6px 20px rgba(15,23,42,0.06); overflow:hidden;">
<div class="header" style="padding:28px 28px 10px 28px; text-align:left; background:#FFFFFF;">
<img class="logo" src="https://newstaging.odentas.fr/wp-content/uploads/2025/08/Odentas-Logo-Bleu-Fond-Transparent-4-1.png" alt="Odentas" style="height:60px; display:block;">
<div style="margin:18px 0 16px 0;">
<h1 class="title" style="font-size:22px; line-height:1.3; margin:0; font-weight:700; color:#0F172A;">
${emailTitle}
</h1>
</div>
<div style="margin:20px 0;">
<p style="font-size:15px; color:#334155; margin:0 0 8px; font-weight:500;">
${emailGreeting}
</p>
<p class="subtitle" style="font-size:15px; color:#334155; margin:0; line-height:1.4;">
${emailIntro}
</p>
</div>
</div>
<div class="card" style="margin:16px 28px 0 28px; border:1px solid #E5E7EB; border-radius:12px; padding:20px; background:#F9FAFB;">
<h2 style="font-size:16px; margin:0 0 12px; color:#374151; font-weight:600;">
${contentTitle}
</h2>
<div style="font-size:15px; color:#334155; line-height:1.6;">
${contentParagraphs}
</div>
</div>
${showAlert ? `<div class="highlight" style="margin:16px 28px 0 28px; border:1px solid #fde68a; border-radius:12px; padding:16px; background:#fffbeb;">
<div style="font-size:15px; font-weight:600; margin-bottom:8px; color:#854d0e;">
${alertTitle}
</div>
<div style="font-size:14px; color:#a16207;">
${alertText}
</div>
</div>` : ''}
${showButton ? `<div class="cta-wrap" style="padding:24px 28px 6px 28px; text-align:center; background:#FFFFFF;">
<a class="btn" href="${buttonUrl}" style="background:#efc543; color:#000000; display:inline-block; padding:16px 32px; border-radius:10px; font-weight:700; font-size:16px; text-decoration:none; border:none; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
${buttonText}
</a>
</div>` : ''}
<p class="note" style="font-size:13px; color:#475569; padding:0 28px 26px 28px; line-height:1.5;">
${formattedClosingMessage}
</p>
<div class="footer" style="font-size:12px; color:#64748B; text-align:center; padding:18px 28px 28px 28px; line-height:1.4;">
<p>
Vous recevez cet e-mail en tant qu'utilisateur de l'Espace Paie Odentas.<br>
<span class="muted" style="color:#94A3B8;">© 2021-2025 Odentas Media SAS | RCS Paris 907880348 | 6 rue d'Armaillé, 75017 Paris</span>
</p>
</div>
</div>
</div>
<div style="display:none; font-size:0; line-height:0;">Communication Odentas - Espace Paie</div>
</body>
</html>`;
};
// Générer le HTML content
const htmlContent = editMode === 'visual' ? generateHtmlContent() : generateHtmlContent();
const [isLoading, setIsLoading] = useState(false);
// Filtrer les utilisateurs
const filteredUsers = users.filter(user => {
// Validation basique de l'email
@ -385,11 +666,11 @@ export default function BulkEmailForm({ users }: BulkEmailFormProps) {
/>
</div>
<div className="flex gap-2">
<div className="grid grid-cols-1 gap-2">
<select
value={roleFilter}
onChange={(e) => setRoleFilter(e.target.value)}
className="flex-1 px-3 py-2 border rounded-lg text-sm"
className="w-full px-3 py-2 border rounded-lg text-sm"
>
<option value="all">Tous les rôles</option>
<option value="admin">Admin</option>
@ -400,11 +681,14 @@ export default function BulkEmailForm({ users }: BulkEmailFormProps) {
<select
value={orgFilter}
onChange={(e) => setOrgFilter(e.target.value)}
className="flex-1 px-3 py-2 border rounded-lg text-sm"
className="w-full px-3 py-2 border rounded-lg text-sm truncate"
title={orgFilter !== "all" ? allOrganizations.find(org => org.id === orgFilter)?.name : "Toutes les organisations"}
>
<option value="all">Toutes les orgs</option>
{allOrganizations.map(org => (
<option key={org.id} value={org.id}>{org.name}</option>
<option key={org.id} value={org.id} title={org.name}>
{org.name.length > 30 ? `${org.name.substring(0, 30)}...` : org.name}
</option>
))}
</select>
</div>
@ -468,15 +752,30 @@ export default function BulkEmailForm({ users }: BulkEmailFormProps) {
<div className="flex items-center gap-2">
<Mail className="w-5 h-5 text-green-600" />
<h3 className="font-semibold">Composition de l'email</h3>
{currentDraftId && draftName && (
<span className="text-xs bg-purple-100 text-purple-700 px-2 py-1 rounded">
{draftName}
</span>
)}
</div>
<div className="flex gap-2">
<button
onClick={() => setShowTemplates(!showTemplates)}
onClick={() => setShowDraftsModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 transition-colors"
title="Charger un brouillon"
>
<FileText className="w-4 h-4" />
Templates
<FolderOpen className="w-4 h-4" />
Brouillons
</button>
<button
onClick={() => setShowSaveDraftModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-indigo-100 text-indigo-700 rounded-lg hover:bg-indigo-200 transition-colors"
title="Enregistrer comme brouillon"
>
<Save className="w-4 h-4" />
Enregistrer
</button>
<button
@ -498,7 +797,7 @@ export default function BulkEmailForm({ users }: BulkEmailFormProps) {
</div>
</div>
{/* Sujet et Templates */}
{/* Sujet */}
<div className="space-y-3">
<input
type="text"
@ -508,45 +807,175 @@ export default function BulkEmailForm({ users }: BulkEmailFormProps) {
className="w-full px-4 py-3 border rounded-lg text-lg"
disabled={isLoading}
/>
{/* Panel Templates */}
{showTemplates && (
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
<h4 className="font-semibold text-purple-800 mb-3">Templates disponibles</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-2">
{templates.map((template) => (
<button
key={template.name}
onClick={() => loadTemplate(template.name)}
className="p-3 text-left bg-white border border-purple-200 rounded-lg hover:bg-purple-100 transition-colors"
>
<div className="font-medium text-sm">{template.name}</div>
<div className="text-xs text-gray-600 mt-1">{template.description}</div>
</button>
))}
</div>
</div>
)}
</div>
</div>
{/* Contenu */}
<div className="flex-1 flex">
<div className="flex-1 flex overflow-auto">
{!showPreview ? (
<textarea
value={htmlContent}
onChange={(e) => setHtmlContent(e.target.value)}
placeholder="Contenu HTML de l'email..."
className="w-full p-4 font-mono text-sm resize-none border-0 outline-none"
disabled={isLoading}
/>
<div className="w-full p-4 space-y-4 overflow-auto">
<div className="space-y-3">
<label className="block text-sm font-medium text-gray-700">
Titre principal
</label>
<input
type="text"
value={emailTitle}
onChange={(e) => setEmailTitle(e.target.value)}
className="w-full px-3 py-2 border rounded-lg"
disabled={isLoading}
/>
</div>
<div className="space-y-3">
<label className="block text-sm font-medium text-gray-700">
Salutation
</label>
<RichTextEditor
value={emailGreeting}
onChange={setEmailGreeting}
disabled={isLoading}
rows={2}
placeholder="Bonjour,"
/>
</div>
<div className="space-y-3">
<label className="block text-sm font-medium text-gray-700">
Message d'introduction
</label>
<textarea
value={emailIntro}
onChange={(e) => setEmailIntro(e.target.value)}
rows={2}
className="w-full px-3 py-2 border rounded-lg"
disabled={isLoading}
/>
</div>
<div className="space-y-3">
<label className="block text-sm font-medium text-gray-700">
Titre de la section principale
</label>
<input
type="text"
value={contentTitle}
onChange={(e) => setContentTitle(e.target.value)}
className="w-full px-3 py-2 border rounded-lg"
disabled={isLoading}
/>
</div>
<div className="space-y-3">
<label className="block text-sm font-medium text-gray-700">
Contenu principal
<span className="text-xs text-gray-500 ml-2">(Utilisez des tirets (-) en début de ligne pour créer des listes à puces)</span>
</label>
<RichTextEditor
value={contentBody}
onChange={setContentBody}
disabled={isLoading}
rows={8}
placeholder="Votre contenu..."
/>
</div>
<div className="space-y-3 border-t pt-4">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={showAlert}
onChange={(e) => setShowAlert(e.target.checked)}
className="rounded"
disabled={isLoading}
/>
<span className="text-sm font-medium text-gray-700">Afficher un encadré d'alerte</span>
</label>
{showAlert && (
<div className="ml-6 space-y-3">
<input
type="text"
value={alertTitle}
onChange={(e) => setAlertTitle(e.target.value)}
placeholder="Titre de l'alerte"
className="w-full px-3 py-2 border rounded-lg"
disabled={isLoading}
/>
<RichTextEditor
value={alertText}
onChange={setAlertText}
disabled={isLoading}
rows={3}
placeholder="Texte de l'alerte"
/>
</div>
)}
</div>
<div className="space-y-3 border-t pt-4">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={showButton}
onChange={(e) => setShowButton(e.target.checked)}
className="rounded"
disabled={isLoading}
/>
<span className="text-sm font-medium text-gray-700">Afficher un bouton d'action</span>
</label>
{showButton && (
<div className="ml-6 space-y-3">
<input
type="text"
value={buttonText}
onChange={(e) => setButtonText(e.target.value)}
placeholder="Texte du bouton"
className="w-full px-3 py-2 border rounded-lg"
disabled={isLoading}
/>
<input
type="url"
value={buttonUrl}
onChange={(e) => setButtonUrl(e.target.value)}
placeholder="URL du bouton"
className="w-full px-3 py-2 border rounded-lg"
disabled={isLoading}
/>
</div>
)}
</div>
<div className="space-y-3 border-t pt-4">
<label className="block text-sm font-medium text-gray-700">
Message de clôture
</label>
<textarea
value={closingMessage}
onChange={(e) => setClosingMessage(e.target.value)}
rows={4}
className="w-full px-3 py-2 border rounded-lg"
disabled={isLoading}
/>
</div>
</div>
) : (
<div className="w-full p-4 overflow-auto bg-gray-50">
<div className="max-w-2xl mx-auto bg-white rounded-lg shadow-sm">
<div
<div className="text-center mb-2">
<span className="text-xs text-gray-500">Aperçu de l'email</span>
</div>
<div className="max-w-2xl mx-auto">
<iframe
ref={previewRef}
dangerouslySetInnerHTML={{ __html: htmlContent }}
className="prose max-w-none"
srcDoc={htmlContent}
className="w-full border-0 rounded-lg shadow-sm bg-white"
style={{
minHeight: '600px',
height: 'calc(100vh - 300px)'
}}
title="Aperçu de l'email"
sandbox="allow-same-origin"
/>
</div>
</div>
@ -567,6 +996,104 @@ export default function BulkEmailForm({ users }: BulkEmailFormProps) {
errorCount={progressStats.errors}
subject={subject}
/>
{/* Modal de sauvegarde de brouillon */}
{showSaveDraftModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
<h3 className="text-lg font-semibold mb-4">Enregistrer le brouillon</h3>
<input
type="text"
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
placeholder="Nom du brouillon..."
className="w-full px-4 py-2 border rounded-lg mb-4"
autoFocus
/>
<div className="flex gap-2 justify-end">
<button
onClick={() => {
setShowSaveDraftModal(false);
setDraftName("");
}}
className="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
disabled={isSavingDraft}
>
Annuler
</button>
<button
onClick={saveDraft}
disabled={isSavingDraft || !draftName.trim()}
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 transition-colors"
>
{isSavingDraft ? "Enregistrement..." : "Enregistrer"}
</button>
</div>
</div>
</div>
)}
{/* Modal de chargement de brouillon */}
{showDraftsModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-2xl w-full mx-4 max-h-[80vh] overflow-hidden flex flex-col">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold">Mes brouillons</h3>
<button
onClick={() => setShowDraftsModal(false)}
className="text-gray-400 hover:text-gray-600"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="flex-1 overflow-y-auto">
{drafts.length === 0 ? (
<p className="text-center text-gray-500 py-8">Aucun brouillon enregistré</p>
) : (
<div className="space-y-2">
{drafts.map((draft) => (
<div
key={draft.id}
className="border rounded-lg p-4 hover:bg-gray-50 transition-colors"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<h4 className="font-medium text-gray-900">{draft.draft_name}</h4>
<p className="text-sm text-gray-600 mt-1">{draft.subject}</p>
<p className="text-xs text-gray-400 mt-2">
Modifié le {new Date(draft.updated_at).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</p>
</div>
<div className="flex gap-2 ml-4">
<button
onClick={() => loadDraft(draft)}
className="px-3 py-1 text-sm bg-indigo-100 text-indigo-700 rounded hover:bg-indigo-200 transition-colors"
>
Charger
</button>
<button
onClick={() => deleteDraft(draft.id)}
className="px-3 py-1 text-sm bg-red-100 text-red-700 rounded hover:bg-red-200 transition-colors"
>
Supprimer
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -268,6 +268,7 @@ function ContractsGridImpl({ initialData, activeOrgId }: { initialData: Contract
const [q, setQ] = useState(savedFilters?.q || "");
const [structureFilter, setStructureFilter] = useState<string | null>(savedFilters?.structureFilter || null);
const [typeFilter, setTypeFilter] = useState<string | null>(savedFilters?.typeFilter || null);
const [productionFilter, setProductionFilter] = useState<string | null>(savedFilters?.productionFilter || null);
const [etatContratFilters, setEtatContratFilters] = useState<Set<string>>(new Set(savedFilters?.etatContratFilters || []));
const [etatPaieFilter, setEtatPaieFilter] = useState<string | null>(savedFilters?.etatPaieFilter || null);
const [dpaeFilter, setDpaeFilter] = useState<string | null>(savedFilters?.dpaeFilter || null);
@ -356,6 +357,7 @@ function ContractsGridImpl({ initialData, activeOrgId }: { initialData: Contract
setQ(""); // Reset recherche
setStructureFilter(null); // Reset organisation
setTypeFilter(null); // Reset type de contrat
setProductionFilter(null); // Reset production
setSignatureFilter(null); // Reset signature
setDpaeFilter('À faire');
setEtatContratFilters(new Set()); // Reset état contrat
@ -376,6 +378,7 @@ function ContractsGridImpl({ initialData, activeOrgId }: { initialData: Contract
setQ(""); // Reset recherche
setStructureFilter(null); // Reset organisation
setTypeFilter(null); // Reset type de contrat
setProductionFilter(null); // Reset production
setSignatureFilter(null); // Reset signature
setEtatContratFilters(new Set(['Reçue', 'En cours'])); // Multiple selections
setDpaeFilter(null); // Reset DPAE
@ -401,6 +404,7 @@ function ContractsGridImpl({ initialData, activeOrgId }: { initialData: Contract
setQ(""); // Reset recherche
setStructureFilter(null); // Reset organisation
setTypeFilter(null); // Reset type de contrat
setProductionFilter(null); // Reset production
setSignatureFilter(null); // Reset signature
setEtatPaieFilter('À traiter');
setDpaeFilter(null); // Reset DPAE
@ -419,6 +423,7 @@ function ContractsGridImpl({ initialData, activeOrgId }: { initialData: Contract
setQ(""); // Reset recherche
setStructureFilter(null); // Reset organisation
setTypeFilter(null); // Reset type de contrat
setProductionFilter(null); // Reset production
setSignatureFilter(null); // Reset signature
setEtatPaieFilter('À traiter');
setDpaeFilter(null); // Reset DPAE
@ -447,6 +452,7 @@ function ContractsGridImpl({ initialData, activeOrgId }: { initialData: Contract
setQ(""); // Reset recherche
setStructureFilter(null); // Reset organisation
setTypeFilter(null); // Reset type de contrat
setProductionFilter(null); // Reset production
setSignatureFilter('non_signe'); // Filtre pour les contrats non complètement signés
setEtatPaieFilter(null); // Reset état paie
setEtatContratFilters(new Set()); // Reset état contrat
@ -468,6 +474,7 @@ function ContractsGridImpl({ initialData, activeOrgId }: { initialData: Contract
setQ(""); // Reset recherche
setStructureFilter(null); // Reset organisation
setTypeFilter(null); // Reset type de contrat
setProductionFilter(null); // Reset production
setSignatureFilter(null); // Tous les contrats
setEtatPaieFilter(null); // Reset état paie
setEtatContratFilters(new Set()); // Reset état contrat
@ -501,6 +508,7 @@ function ContractsGridImpl({ initialData, activeOrgId }: { initialData: Contract
q,
structureFilter,
typeFilter,
productionFilter,
etatContratFilters: Array.from(etatContratFilters),
etatPaieFilter,
dpaeFilter,
@ -514,7 +522,26 @@ function ContractsGridImpl({ initialData, activeOrgId }: { initialData: Contract
showFilters
};
saveFiltersToStorage(filters);
}, [q, structureFilter, typeFilter, etatContratFilters, etatPaieFilter, dpaeFilter, signatureFilter, startFrom, startTo, endFrom, endTo, sortField, sortOrder, showFilters]);
}, [q, structureFilter, typeFilter, productionFilter, etatContratFilters, etatPaieFilter, dpaeFilter, signatureFilter, startFrom, startTo, endFrom, endTo, sortField, sortOrder, showFilters]);
// Réinitialiser le filtre de production quand la structure change
useEffect(() => {
// Si une structure est sélectionnée et que le filtre de production actuel n'existe pas dans la nouvelle liste
if (structureFilter && productionFilter) {
const availableProductions = (initialData || [])
.filter(r => r.structure === structureFilter)
.map(r => r.production_name)
.filter(Boolean);
if (!availableProductions.includes(productionFilter)) {
setProductionFilter(null);
}
}
// Si aucune structure n'est sélectionnée, réinitialiser le filtre de production
if (!structureFilter && productionFilter) {
setProductionFilter(null);
}
}, [structureFilter, initialData, productionFilter]);
// optimistic update helper
const selectedRow = useMemo(() => rows.find((r) => r.id === selectedId) ?? null, [rows, selectedId]);
@ -620,6 +647,7 @@ function ContractsGridImpl({ initialData, activeOrgId }: { initialData: Contract
if (q) params.set('q', q);
if (structureFilter) params.set('structure', structureFilter);
if (typeFilter) params.set('type_de_contrat', typeFilter);
if (productionFilter) params.set('production_name', productionFilter);
// Handle multiple etat_de_la_demande filters
if (etatContratFilters.size > 0) {
params.set('etat_de_la_demande', Array.from(etatContratFilters).join(','));
@ -668,6 +696,10 @@ function ContractsGridImpl({ initialData, activeOrgId }: { initialData: Contract
valueA = getLastName(a);
valueB = getLastName(b);
break;
case 'production_name':
valueA = a.production_name || '';
valueB = b.production_name || '';
break;
case 'start_date':
case 'end_date':
case 'created_at':
@ -690,7 +722,7 @@ function ContractsGridImpl({ initialData, activeOrgId }: { initialData: Contract
// Apply local sorting when using initial data
const sortedRows = useMemo(() => {
const noFilters = !q && !structureFilter && !typeFilter && etatContratFilters.size === 0 && !etatPaieFilter && !dpaeFilter && !signatureFilter && !startFrom && !startTo && !endFrom && !endTo;
const noFilters = !q && !structureFilter && !typeFilter && !productionFilter && etatContratFilters.size === 0 && !etatPaieFilter && !dpaeFilter && !signatureFilter && !startFrom && !startTo && !endFrom && !endTo;
if (noFilters) {
// Utiliser le tri local pour les données initiales
@ -699,7 +731,7 @@ function ContractsGridImpl({ initialData, activeOrgId }: { initialData: Contract
// Pour les données filtrées, utiliser les données telles qu'elles viennent du serveur
return rows;
}, [rows, sortField, sortOrder, q, structureFilter, typeFilter, etatContratFilters, etatPaieFilter, dpaeFilter, signatureFilter, startFrom, startTo, endFrom, endTo]);
}, [rows, sortField, sortOrder, q, structureFilter, typeFilter, productionFilter, etatContratFilters, etatPaieFilter, dpaeFilter, signatureFilter, startFrom, startTo, endFrom, endTo]);
// Selection functions (updated to use sortedRows for selection)
const toggleSelectAll = () => {
@ -1258,7 +1290,7 @@ function ContractsGridImpl({ initialData, activeOrgId }: { initialData: Contract
// Debounce searches when filters change
useEffect(() => {
// if no filters applied, prefer initial data
const noFilters = !q && !structureFilter && !typeFilter && etatContratFilters.size === 0 && !etatPaieFilter && !dpaeFilter && !signatureFilter && !startFrom && !startTo && !endFrom && !endTo && sortField === 'start_date' && sortOrder === 'desc';
const noFilters = !q && !structureFilter && !typeFilter && !productionFilter && etatContratFilters.size === 0 && !etatPaieFilter && !dpaeFilter && !signatureFilter && !startFrom && !startTo && !endFrom && !endTo && sortField === 'start_date' && sortOrder === 'desc';
if (noFilters) {
setRows(initialData || []);
return;
@ -1267,7 +1299,7 @@ function ContractsGridImpl({ initialData, activeOrgId }: { initialData: Contract
const t = setTimeout(() => fetchServer(0), 300);
return () => clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [q, structureFilter, typeFilter, etatContratFilters, etatPaieFilter, dpaeFilter, signatureFilter, startFrom, startTo, endFrom, endTo, sortField, sortOrder, limit]);
}, [q, structureFilter, typeFilter, productionFilter, etatContratFilters, etatPaieFilter, dpaeFilter, signatureFilter, startFrom, startTo, endFrom, endTo, sortField, sortOrder, limit]);
// Récupérer les notifications quand les données changent
useEffect(() => {
@ -1700,6 +1732,18 @@ function ContractsGridImpl({ initialData, activeOrgId }: { initialData: Contract
return uniqueStructures.sort((a, b) => a.localeCompare(b, 'fr')).slice(0, 50);
}, [initialData]);
const types = useMemo(() => Array.from(new Set((initialData || []).map((r) => r.type_de_contrat).filter(Boolean) as string[])).slice(0,50), [initialData]);
// Filtrer les productions par structure sélectionnée
const productions = useMemo(() => {
let filteredData = initialData || [];
// Si une structure est sélectionnée, filtrer les contrats par cette structure
if (structureFilter) {
filteredData = filteredData.filter(r => r.structure === structureFilter);
}
const uniqueProductions = Array.from(new Set(filteredData.map((r) => r.production_name).filter(Boolean) as string[]));
return uniqueProductions.sort((a, b) => a.localeCompare(b, 'fr')).slice(0, 50);
}, [initialData, structureFilter]);
const etatsContrat = useMemo(() => Array.from(new Set((initialData || []).map((r) => r.etat_de_la_demande).filter(Boolean) as string[])).slice(0,50), [initialData]);
const etatsPaie = useMemo(() => Array.from(new Set((initialData || []).map((r) => r.etat_de_la_paie).filter(Boolean) as string[])).slice(0,50), [initialData]);
const etatsDpae = useMemo(() => Array.from(new Set((initialData || []).map((r) => r.dpae).filter(Boolean) as string[])).slice(0,50), [initialData]);
@ -1720,7 +1764,12 @@ function ContractsGridImpl({ initialData, activeOrgId }: { initialData: Contract
</div>
<div className="flex items-center gap-2">
{/* Filtres rapides toujours visibles */}
<select value={structureFilter ?? ""} onChange={(e) => setStructureFilter(e.target.value || null)} className="rounded border px-2 py-1 text-sm">
<select
value={structureFilter ?? ""}
onChange={(e) => setStructureFilter(e.target.value || null)}
className="rounded border px-2 py-1 text-sm max-w-[200px]"
title={structureFilter || "Toutes structures"}
>
<option value="">Toutes structures</option>
{structures.map((s) => (<option key={s} value={s}>{s}</option>))}
</select>
@ -1730,6 +1779,18 @@ function ContractsGridImpl({ initialData, activeOrgId }: { initialData: Contract
<option value="RG">RG (CDD/CDI droit commun)</option>
{types.filter(t => t !== "CDD d'usage" && t !== "CDD de droit commun" && t !== "CDI").map((t) => (<option key={t} value={t}>{t}</option>))}
</select>
{/* Afficher le dropdown de production uniquement si une structure est sélectionnée */}
{structureFilter && (
<select
value={productionFilter ?? ""}
onChange={(e) => setProductionFilter(e.target.value || null)}
className="rounded border px-2 py-1 text-sm max-w-[200px]"
title={productionFilter || "Toutes productions"}
>
<option value="">Toutes productions</option>
{productions.map((p) => (<option key={p} value={p}>{p}</option>))}
</select>
)}
<button
onClick={() => setShowFilters(!showFilters)}
className="inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-800 px-2 py-1 rounded border"
@ -1752,7 +1813,8 @@ function ContractsGridImpl({ initialData, activeOrgId }: { initialData: Contract
onClick={() => {
setQ('');
setStructureFilter(null);
setTypeFilter(null);
setTypeFilter(null);
setProductionFilter(null);
setEtatContratFilters(new Set());
setEtatPaieFilter(null);
setDpaeFilter(null);
@ -2126,7 +2188,9 @@ function ContractsGridImpl({ initialData, activeOrgId }: { initialData: Contract
Salarié {sortField === 'employee_name' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</th>
<th className="text-left px-3 py-2">Structure</th>
<th className="text-left px-3 py-2">Production</th>
<th className="text-left px-3 py-2 cursor-pointer" onClick={() => { setSortField('production_name'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}>
Production {sortField === 'production_name' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</th>
<th className="text-left px-3 py-2">Type</th>
<th className="text-left px-3 py-2">Profession</th>
<th className="text-left px-3 py-2 cursor-pointer" onClick={() => { setSortField('start_date'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}>

View file

@ -25,6 +25,7 @@ export type EmailType =
| 'contact-support' // Formulaire contact public
| 'amendment-completed-employer' // Avenant signé (notification employeur)
| 'amendment-completed-employee' // Avenant signé (notification salarié)
| 'referral' // Email d'invitation au parrainage
| 'account-activation'
| 'access-updated'
| 'access-revoked'

View file

@ -49,6 +49,8 @@ export type EmailTypeV2 =
| 'support-ticket-created' // Notification interne : nouveau ticket créé
| 'support-ticket-reply' // Notification interne : réponse utilisateur à un ticket
| 'contact-support' // Formulaire de contact public vers le support
// Parrainage
| 'referral' // Email d'invitation au parrainage
// Accès / habilitations
| 'account-activation'
| 'access-updated'
@ -277,6 +279,32 @@ const EMAIL_TEMPLATES_V2: Record<EmailTypeV2, EmailTemplateV2> = {
]
}
},
'referral': {
subject: '{{referrer_first_name}} de {{organization_name}} vous recommande Odentas',
title: 'Vous avez été recommandé',
greeting: 'Bonjour {{referee_first_name}},',
mainMessage: '<strong>{{referrer_first_name}}</strong> de <strong>{{organization_name}}</strong> vous invite à découvrir les services de Odentas pour votre gestion de la paie du spectacle.<br><br>[Placeholder : Présentation des avantages Odentas]<br><br>[Placeholder : Offre de réduction 20 € HT]{{#if personal_message}}<br><br><div style="background-color:#F8FAFC; padding:16px; border-left:3px solid #A78BFA; border-radius:4px;"><strong style="color:#0F172A;">Message de {{referrer_first_name}} :</strong><br><br><em style="color:#475569;">{{personal_message}}</em></div>{{/if}}',
ctaText: 'Découvrir Odentas',
closingMessage: 'Nous espérons avoir le plaisir de vous accompagner dans la gestion de vos contrats intermittents.<br><br>À très bientôt,<br>L\'équipe Odentas',
footerText: 'Vous recevez cet email car {{referrer_first_name}} de {{organization_name}} vous a recommandé nos services.',
preheaderText: '{{referrer_first_name}} de {{organization_name}} vous invite à découvrir Odentas',
colors: {
headerColor: '#A78BFA',
titleColor: '#0F172A',
buttonColor: '#A78BFA',
buttonTextColor: '#FFFFFF',
cardBackgroundColor: '#FFFFFF',
cardBorder: '#E5E7EB',
cardTitleColor: '#0F172A',
},
ctaUrl: 'https://espace-paie.odentas.fr/auth/signup',
infoCard: [
{ label: 'Recommandé par', key: 'referrer_first_name' },
{ label: 'Organisation', key: 'organization_name' },
],
},
'account-activation': {
subject: 'Activez votre espace Odentas Paie {{organizationName}}',
title: 'Activez votre espace',

View file

@ -0,0 +1,90 @@
import { createSbServiceRole } from "./supabaseServer";
/**
* Vérifie si un webhook a déjà é traité pour éviter les doublons
* Utilise une clé unique basée sur submission_id + event_type + timestamp
*
* @param submissionId - ID de la soumission DocuSeal
* @param eventType - Type d'événement (ex: "avenant_employer_signed")
* @param timestamp - Timestamp de l'événement (optionnel, sinon on utilise l'heure actuelle)
* @param payload - Données du webhook (optionnel, pour debug)
* @returns true si le webhook est nouveau, false si déjà traité
*/
export async function checkAndMarkWebhookProcessed(
submissionId: string,
eventType: string,
timestamp?: string,
payload?: any
): Promise<boolean> {
const supabase = createSbServiceRole();
// Créer une clé unique
// On utilise submission_id + event_type (pas de timestamp car DocuSeal peut renvoyer le même timestamp)
const webhookKey = `${submissionId}_${eventType}`;
console.log(`🔍 [WEBHOOK DEDUP] Vérification: ${webhookKey}`);
try {
// Essayer d'insérer l'événement
const { data, error } = await supabase
.from("webhook_events")
.insert({
webhook_key: webhookKey,
event_type: eventType,
submission_id: submissionId,
payload: payload || null,
})
.select()
.single();
if (error) {
// Si l'erreur est une violation de contrainte unique, c'est un doublon
if (error.code === "23505") {
console.log(`⚠️ [WEBHOOK DEDUP] Doublon détecté: ${webhookKey}`);
return false; // Webhook déjà traité
}
// Autre erreur
console.error(`❌ [WEBHOOK DEDUP] Erreur lors de l'insertion:`, error);
// En cas d'erreur technique, on laisse passer pour éviter de bloquer
return true;
}
console.log(`✅ [WEBHOOK DEDUP] Nouveau webhook: ${webhookKey}`);
return true; // Nouveau webhook, on peut traiter
} catch (error) {
console.error(`❌ [WEBHOOK DEDUP] Exception:`, error);
// En cas d'exception, on laisse passer pour éviter de bloquer
return true;
}
}
/**
* Vérifie si un webhook a déjà é traité (lecture seule)
* Utile pour vérifier sans marquer comme traité
*/
export async function isWebhookProcessed(
submissionId: string,
eventType: string
): Promise<boolean> {
const supabase = createSbServiceRole();
const webhookKey = `${submissionId}_${eventType}`;
try {
const { data, error } = await supabase
.from("webhook_events")
.select("id")
.eq("webhook_key", webhookKey)
.maybeSingle();
if (error) {
console.error(`❌ [WEBHOOK DEDUP] Erreur vérification:`, error);
return false;
}
return !!data; // true si trouvé, false sinon
} catch (error) {
console.error(`❌ [WEBHOOK DEDUP] Exception:`, error);
return false;
}
}

View file

@ -0,0 +1,15 @@
-- Migration: Ajout de la couleur du texte et du bouton CTA pour les bannières promotionnelles
ALTER TABLE promo_banners
ADD COLUMN IF NOT EXISTS text_color VARCHAR(50) DEFAULT 'slate-800';
ALTER TABLE promo_banners
ADD COLUMN IF NOT EXISTS cta_bg_color VARCHAR(50) DEFAULT 'slate-900';
ALTER TABLE promo_banners
ADD COLUMN IF NOT EXISTS cta_text_color VARCHAR(50) DEFAULT 'white';
COMMENT ON COLUMN promo_banners.text_color IS 'Couleur du texte (classe Tailwind, ex: slate-800, white, slate-900)';
COMMENT ON COLUMN promo_banners.cta_bg_color IS 'Couleur de fond du bouton CTA (hex ou classe Tailwind)';
COMMENT ON COLUMN promo_banners.cta_text_color IS 'Couleur du texte du bouton CTA (classe Tailwind)';

View file

@ -0,0 +1,128 @@
-- Migration: Création de la table pour les brouillons d'emails groupés
-- Date: 2025-10-31
-- Créer la table bulk_email_drafts
CREATE TABLE IF NOT EXISTS bulk_email_drafts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
-- Identité de l'auteur (staff uniquement)
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
-- Métadonnées du brouillon
draft_name TEXT NOT NULL,
-- Contenu de l'email
subject TEXT NOT NULL,
email_title TEXT NOT NULL,
email_greeting TEXT NOT NULL,
email_intro TEXT NOT NULL,
content_title TEXT NOT NULL,
content_body TEXT NOT NULL,
-- Options
show_alert BOOLEAN DEFAULT false,
alert_title TEXT,
alert_text TEXT,
show_button BOOLEAN DEFAULT false,
button_text TEXT,
button_url TEXT,
closing_message TEXT NOT NULL,
-- Filtres de destinataires (optionnels, pour mémoriser la sélection)
filters JSONB DEFAULT '{}'::jsonb
);
-- Créer des index
CREATE INDEX IF NOT EXISTS idx_bulk_email_drafts_user_id ON bulk_email_drafts(user_id);
CREATE INDEX IF NOT EXISTS idx_bulk_email_drafts_created_at ON bulk_email_drafts(created_at DESC);
-- Activer RLS (Row Level Security)
ALTER TABLE bulk_email_drafts ENABLE ROW LEVEL SECURITY;
-- Politique : Les utilisateurs staff peuvent voir uniquement leurs propres brouillons
DROP POLICY IF EXISTS "Staff users can view their own drafts" ON bulk_email_drafts;
CREATE POLICY "Staff users can view their own drafts"
ON bulk_email_drafts
FOR SELECT
USING (
EXISTS (
SELECT 1 FROM staff_users
WHERE staff_users.user_id = auth.uid()
AND staff_users.is_staff = true
)
AND user_id = auth.uid()
);
-- Politique : Les utilisateurs staff peuvent créer leurs propres brouillons
DROP POLICY IF EXISTS "Staff users can create their own drafts" ON bulk_email_drafts;
CREATE POLICY "Staff users can create their own drafts"
ON bulk_email_drafts
FOR INSERT
WITH CHECK (
EXISTS (
SELECT 1 FROM staff_users
WHERE staff_users.user_id = auth.uid()
AND staff_users.is_staff = true
)
AND user_id = auth.uid()
);
-- Politique : Les utilisateurs staff peuvent modifier leurs propres brouillons
DROP POLICY IF EXISTS "Staff users can update their own drafts" ON bulk_email_drafts;
CREATE POLICY "Staff users can update their own drafts"
ON bulk_email_drafts
FOR UPDATE
USING (
EXISTS (
SELECT 1 FROM staff_users
WHERE staff_users.user_id = auth.uid()
AND staff_users.is_staff = true
)
AND user_id = auth.uid()
);
-- Politique : Les utilisateurs staff peuvent supprimer leurs propres brouillons
DROP POLICY IF EXISTS "Staff users can delete their own drafts" ON bulk_email_drafts;
CREATE POLICY "Staff users can delete their own drafts"
ON bulk_email_drafts
FOR DELETE
USING (
EXISTS (
SELECT 1 FROM staff_users
WHERE staff_users.user_id = auth.uid()
AND staff_users.is_staff = true
)
AND user_id = auth.uid()
);
-- Fonction pour mettre à jour automatiquement updated_at
-- On vérifie d'abord si elle existe pour éviter les conflits
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_proc WHERE proname = 'update_bulk_email_drafts_updated_at'
) THEN
CREATE FUNCTION update_bulk_email_drafts_updated_at()
RETURNS TRIGGER AS $func$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$func$ LANGUAGE plpgsql;
END IF;
END $$;
-- Trigger pour mettre à jour updated_at automatiquement
DROP TRIGGER IF EXISTS bulk_email_drafts_updated_at_trigger ON bulk_email_drafts;
CREATE TRIGGER bulk_email_drafts_updated_at_trigger
BEFORE UPDATE ON bulk_email_drafts
FOR EACH ROW
EXECUTE FUNCTION update_bulk_email_drafts_updated_at();
-- Commentaires pour la documentation
COMMENT ON TABLE bulk_email_drafts IS 'Table pour stocker les brouillons d''emails groupés créés par le staff';
COMMENT ON COLUMN bulk_email_drafts.user_id IS 'ID de l''utilisateur staff qui a créé le brouillon';
COMMENT ON COLUMN bulk_email_drafts.draft_name IS 'Nom du brouillon pour identification';
COMMENT ON COLUMN bulk_email_drafts.filters IS 'Filtres de destinataires au format JSON (roleFilter, orgFilter, searchTerm)';

View file

@ -0,0 +1,76 @@
-- Migration: Création de la table promo_banners pour gérer les bannières promotionnelles
CREATE TABLE IF NOT EXISTS promo_banners (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Contenu de la bannière
text TEXT NOT NULL,
cta_text TEXT, -- Texte du bouton CTA (optionnel)
cta_link TEXT, -- Lien du bouton CTA (optionnel)
-- Compte à rebours (optionnel)
countdown_enabled BOOLEAN DEFAULT false,
countdown_end_date TIMESTAMPTZ, -- Date de fin du compte à rebours
-- Gradient de fond (couleurs Tailwind)
gradient_from VARCHAR(50) DEFAULT 'indigo-600',
gradient_to VARCHAR(50) DEFAULT 'purple-600',
-- État d'activation
is_active BOOLEAN DEFAULT false,
-- Métadonnées
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
created_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
updated_by UUID REFERENCES auth.users(id) ON DELETE SET NULL
);
-- Index pour performances
CREATE INDEX IF NOT EXISTS idx_promo_banners_active ON promo_banners(is_active);
CREATE INDEX IF NOT EXISTS idx_promo_banners_created_at ON promo_banners(created_at DESC);
-- Trigger pour mettre à jour updated_at
CREATE OR REPLACE FUNCTION update_promo_banners_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_update_promo_banners_updated_at
BEFORE UPDATE ON promo_banners
FOR EACH ROW
EXECUTE FUNCTION update_promo_banners_updated_at();
-- RLS: Lecture publique (pour afficher la bannière active)
ALTER TABLE promo_banners ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Lecture publique de la bannière active"
ON promo_banners
FOR SELECT
USING (is_active = true);
-- RLS: Gestion réservée au staff
CREATE POLICY "Staff peut tout gérer"
ON promo_banners
FOR ALL
USING (
EXISTS (
SELECT 1 FROM staff_users
WHERE staff_users.user_id = auth.uid()
AND staff_users.is_staff = true
)
);
-- Commentaires
COMMENT ON TABLE promo_banners IS 'Bannières promotionnelles affichées en haut de l''espace paie';
COMMENT ON COLUMN promo_banners.text IS 'Texte principal de la bannière';
COMMENT ON COLUMN promo_banners.cta_text IS 'Texte du bouton d''appel à l''action (optionnel)';
COMMENT ON COLUMN promo_banners.cta_link IS 'Lien du bouton CTA (optionnel)';
COMMENT ON COLUMN promo_banners.countdown_enabled IS 'Activer le compte à rebours';
COMMENT ON COLUMN promo_banners.countdown_end_date IS 'Date de fin du compte à rebours';
COMMENT ON COLUMN promo_banners.gradient_from IS 'Couleur de départ du gradient (Tailwind)';
COMMENT ON COLUMN promo_banners.gradient_to IS 'Couleur de fin du gradient (Tailwind)';
COMMENT ON COLUMN promo_banners.is_active IS 'Une seule bannière peut être active à la fois';

View file

@ -0,0 +1,103 @@
-- Migration: Création de la table referrals pour gérer le programme de parrainage
CREATE TABLE IF NOT EXISTS referrals (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Parrain (client existant)
referrer_org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
referrer_user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
-- Filleul (prospect/nouveau client)
referee_name VARCHAR(255) NOT NULL, -- Nom du filleul
referee_email VARCHAR(255) NOT NULL, -- Email du filleul
referee_org_id UUID REFERENCES organizations(id) ON DELETE SET NULL, -- Org créée si validation
-- Statut du parrainage
status VARCHAR(50) NOT NULL DEFAULT 'pending', -- pending, email_sent, signed, validated, cancelled
-- Message personnalisé du parrain
personal_message TEXT,
-- Montants des réductions
referrer_credit_amount DECIMAL(10, 2) DEFAULT 30.00, -- Crédit du parrain en € HT
referee_discount_amount DECIMAL(10, 2) DEFAULT 20.00, -- Réduction du filleul en € HT
-- Tracking
email_sent_at TIMESTAMPTZ, -- Date d'envoi de l'email de parrainage
contract_signed_at TIMESTAMPTZ, -- Date de signature du contrat par le filleul
invoice_paid_at TIMESTAMPTZ, -- Date de paiement de la facture d'ouverture
validated_at TIMESTAMPTZ, -- Date de validation du parrainage
credit_applied_at TIMESTAMPTZ, -- Date d'application du crédit sur la facture du parrain
-- Métadonnées
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
created_by UUID REFERENCES auth.users(id) ON DELETE SET NULL
);
-- Index pour performances
CREATE INDEX IF NOT EXISTS idx_referrals_referrer_org ON referrals(referrer_org_id);
CREATE INDEX IF NOT EXISTS idx_referrals_referrer_user ON referrals(referrer_user_id);
CREATE INDEX IF NOT EXISTS idx_referrals_referee_email ON referrals(referee_email);
CREATE INDEX IF NOT EXISTS idx_referrals_status ON referrals(status);
CREATE INDEX IF NOT EXISTS idx_referrals_created_at ON referrals(created_at DESC);
-- Trigger pour mettre à jour updated_at
CREATE OR REPLACE FUNCTION update_referrals_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_update_referrals_updated_at
BEFORE UPDATE ON referrals
FOR EACH ROW
EXECUTE FUNCTION update_referrals_updated_at();
-- RLS: Les clients peuvent voir leurs propres parrainages
ALTER TABLE referrals ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Les clients voient leurs parrainages"
ON referrals
FOR SELECT
USING (
referrer_org_id IN (
SELECT org_id
FROM organization_members
WHERE user_id = auth.uid()
AND revoked = false
)
);
CREATE POLICY "Les clients créent leurs parrainages"
ON referrals
FOR INSERT
WITH CHECK (
referrer_org_id IN (
SELECT org_id
FROM organization_members
WHERE user_id = auth.uid()
AND revoked = false
)
AND referrer_user_id = auth.uid()
);
-- RLS: Le staff peut tout gérer
CREATE POLICY "Staff peut tout gérer sur les parrainages"
ON referrals
FOR ALL
USING (
EXISTS (
SELECT 1 FROM staff_users
WHERE staff_users.user_id = auth.uid()
AND staff_users.is_staff = true
)
);
-- Commentaires
COMMENT ON TABLE referrals IS 'Programme de parrainage Odentas';
COMMENT ON COLUMN referrals.status IS 'Statut: pending (créé), email_sent (email envoyé), signed (contrat signé), validated (facture payée), cancelled (annulé)';
COMMENT ON COLUMN referrals.referrer_credit_amount IS 'Montant du crédit accordé au parrain (30€ HT par défaut)';
COMMENT ON COLUMN referrals.referee_discount_amount IS 'Montant de la réduction pour le filleul (20€ HT par défaut)';

View file

@ -0,0 +1,62 @@
-- Migration: Création de la table de déduplication des webhooks
-- Date: 2025-10-31
-- Description: Empêcher les doublons d'emails quand DocuSeal envoie le même webhook plusieurs fois
-- Créer la table webhook_events
CREATE TABLE IF NOT EXISTS public.webhook_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Identifiant unique du webhook (submission_id + timestamp + event_type)
webhook_key TEXT NOT NULL UNIQUE,
-- Type de webhook (avenant_signature, contrat_signature, etc.)
event_type TEXT NOT NULL,
-- ID de la soumission DocuSeal
submission_id TEXT NOT NULL,
-- Données du webhook (pour debug)
payload JSONB,
-- Timestamps
processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Index pour recherche rapide
CREATE INDEX IF NOT EXISTS idx_webhook_events_key ON public.webhook_events(webhook_key);
CREATE INDEX IF NOT EXISTS idx_webhook_events_submission_id ON public.webhook_events(submission_id);
CREATE INDEX IF NOT EXISTS idx_webhook_events_event_type ON public.webhook_events(event_type);
CREATE INDEX IF NOT EXISTS idx_webhook_events_created_at ON public.webhook_events(created_at DESC);
-- RLS (Row Level Security)
ALTER TABLE public.webhook_events ENABLE ROW LEVEL SECURITY;
-- Policy: Seul le service role peut écrire (les webhooks)
-- Pas besoin de lecture publique
CREATE POLICY "Service role can manage webhook_events"
ON public.webhook_events
FOR ALL
TO service_role
USING (true)
WITH CHECK (true);
-- Commentaires
COMMENT ON TABLE public.webhook_events IS 'Table de déduplication des webhooks pour éviter les doublons d''emails';
COMMENT ON COLUMN public.webhook_events.webhook_key IS 'Clé unique composée de submission_id + timestamp + event_type';
COMMENT ON COLUMN public.webhook_events.event_type IS 'Type de webhook: avenant_employer_signed, avenant_completed, etc.';
COMMENT ON COLUMN public.webhook_events.submission_id IS 'ID de la soumission DocuSeal';
COMMENT ON COLUMN public.webhook_events.payload IS 'Données brutes du webhook (pour debug)';
-- Nettoyage automatique des vieux événements (> 30 jours)
-- Créer une fonction pour nettoyer
CREATE OR REPLACE FUNCTION clean_old_webhook_events()
RETURNS void AS $$
BEGIN
DELETE FROM public.webhook_events
WHERE created_at < NOW() - INTERVAL '30 days';
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Commentaire sur la fonction
COMMENT ON FUNCTION clean_old_webhook_events() IS 'Nettoie les événements webhook de plus de 30 jours';

View file

@ -0,0 +1,145 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Communication Odentas</title>
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<style>
/* CSS minimal pour les emails */
body { margin:0; padding:0; background:#F4F6FA; color:#0F172A; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; }
a { color:#0B5FFF; text-decoration:none; }
.wrapper { width:100%; padding:32px 0; background:#F4F6FA; }
.container { width:100%; max-width:520px; margin:0 auto; background:#FFFFFF; border-radius:14px; box-shadow: 0 6px 20px rgba(15,23,42,0.06); overflow:hidden; }
.logo { height:60px; display:block; }
.label { font-size:12px; color:#64748B; text-transform:uppercase; letter-spacing:0.03em; margin-bottom:6px; font-weight:500; }
.value { font-size:15px; color:#0F172A; font-weight:500; line-height:1.3; }
.muted { color:#94A3B8; }
.card { margin:16px 28px 0 28px; border:1px solid #E5E7EB; border-radius:12px; padding:20px; background:#F9FAFB; }
.highlight { margin:16px 28px 0 28px; border:1px solid #fde68a; border-radius:12px; padding:16px; background:#fffbeb; }
@media (prefers-color-scheme: dark) {
body, .wrapper { background:#0B1220; color:#E5E7EB; }
}
@media (max-width: 480px) {
.wrapper { padding:16px 0; }
.card, .highlight { margin:16px 16px 0 16px; padding:16px; }
}
</style>
</head>
<body>
<!-- Preheader (texte caché visible dans l'aperçu de la boîte de réception) -->
<div style="display:none;max-height:0;overflow:hidden;opacity:0;">Communication importante de l'équipe Odentas</div>
<div class="wrapper" style="width:100%; padding:32px 0; background:#F4F6FA;">
<div class="container" style="width:100%; max-width:520px; margin:0 auto; background:#FFFFFF; border-radius:14px; box-shadow: 0 6px 20px rgba(15,23,42,0.06); overflow:hidden;">
<!-- Header avec logo -->
<div class="header" style="padding:28px 28px 10px 28px; text-align:left; background:#FFFFFF;">
<img class="logo" src="https://newstaging.odentas.fr/wp-content/uploads/2025/08/Odentas-Logo-Bleu-Fond-Transparent-4-1.png" alt="Odentas" style="height:60px; display:block;">
<!-- Titre principal -->
<div style="margin:18px 0 16px 0;">
<h1 class="title" style="font-size:22px; line-height:1.3; margin:0; font-weight:700; color:#0F172A;">
<!-- ✏️ PERSONNALISEZ ICI : Titre principal de l'email -->
Communication importante
</h1>
</div>
<!-- Salutation et message d'introduction -->
<div style="margin:20px 0;">
<p style="font-size:15px; color:#334155; margin:0 0 8px; font-weight:500;">
<!-- ✏️ PERSONNALISEZ ICI : Salutation (ex: Bonjour {{firstName}}) -->
Bonjour,
</p>
<p class="subtitle" style="font-size:15px; color:#334155; margin:0; line-height:1.4;">
<!-- ✏️ PERSONNALISEZ ICI : Message d'introduction -->
Nous vous contactons aujourd'hui pour vous informer d'une information importante concernant votre espace paie.
</p>
</div>
</div>
<!-- SECTION 1 : Carte d'information (optionnelle) -->
<!-- Décommentez cette section pour afficher une carte avec des informations clés -->
<!--
<div class="card" style="margin:0 28px 0 28px; border:1px solid #E5E7EB; border-radius:12px; padding:16px; background:#F8FAFC;">
<div style="margin-bottom:8px;">
<div class="label" style="font-size:11px; color:#64748B; text-transform:uppercase; letter-spacing:0.03em; margin-bottom:4px; font-weight:500;">Votre organisation</div>
<div class="value" style="font-size:13px; color:#0F172A; font-weight:500; line-height:1.3;">{{organizationName}}</div>
</div>
<div style="margin-bottom:0;">
<div class="label" style="font-size:11px; color:#64748B; text-transform:uppercase; letter-spacing:0.03em; margin-bottom:4px; font-weight:500;">Code employeur</div>
<div class="value" style="font-size:13px; color:#0F172A; font-weight:500; line-height:1.3;">{{employerCode}}</div>
</div>
</div>
-->
<!-- SECTION 2 : Contenu principal -->
<div class="card" style="margin:16px 28px 0 28px; border:1px solid #E5E7EB; border-radius:12px; padding:20px; background:#F9FAFB;">
<h2 style="font-size:16px; margin:0 0 12px; color:#374151; font-weight:600;">
<!-- ✏️ PERSONNALISEZ ICI : Titre de la section principale -->
Détails de la communication
</h2>
<div style="font-size:15px; color:#334155; line-height:1.6;">
<!-- ✏️ PERSONNALISEZ ICI : Contenu principal de l'email -->
<p style="margin:0 0 12px 0;">
Voici les informations importantes que nous souhaitons partager avec vous :
</p>
<ul style="margin:0 0 12px 0; padding-left:20px;">
<li style="margin-bottom:8px;">Point important numéro 1</li>
<li style="margin-bottom:8px;">Point important numéro 2</li>
<li style="margin-bottom:0;">Point important numéro 3</li>
</ul>
<p style="margin:12px 0 0 0;">
N'hésitez pas à nous contacter si vous avez des questions concernant cette communication.
</p>
</div>
</div>
<!-- SECTION 3 : Zone d'alerte / Important (optionnelle) -->
<!-- Décommentez cette section pour afficher un encadré important jaune -->
<!--
<div class="highlight" style="margin:16px 28px 0 28px; border:1px solid #fde68a; border-radius:12px; padding:16px; background:#fffbeb;">
<div style="font-size:15px; font-weight:600; margin-bottom:8px; color:#854d0e;">
Information importante
</div>
<div style="font-size:14px; color:#a16207;">
Ajoutez ici un message important qui nécessite l'attention particulière des destinataires.
</div>
</div>
-->
<!-- SECTION 4 : Bouton d'action (optionnel) -->
<!-- Décommentez cette section pour ajouter un bouton CTA -->
<!--
<div class="cta-wrap" style="padding:24px 28px 6px 28px; text-align:center; background:#FFFFFF;">
<a class="btn" href="https://paie.odentas.fr" style="background:#efc543; color:#000000; display:inline-block; padding:16px 32px; border-radius:10px; font-weight:700; font-size:16px; text-decoration:none; border:none; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
Accéder à mon espace
</a>
</div>
-->
<!-- Message de clôture -->
<p class="note" style="font-size:13px; color:#475569; padding:0 28px 26px 28px; line-height:1.5;">
<!-- ✏️ PERSONNALISEZ ICI : Message de clôture -->
Pour toute question, n'hésitez pas à nous contacter à <a href="mailto:paie@odentas.fr" style="color:#0B5FFF; text-decoration:underline;">paie@odentas.fr</a>.<br><br>
Cordialement,<br>
<strong>L'équipe Odentas</strong>
</p>
<!-- Footer -->
<div class="footer" style="font-size:12px; color:#64748B; text-align:center; padding:18px 28px 28px 28px; line-height:1.4;">
<p>
Vous recevez cet e-mail en tant qu'utilisateur de l'Espace Paie Odentas.<br>
<span class="muted" style="color:#94A3B8;">© 2021-2025 Odentas Media SAS | RCS Paris 907880348 | 6 rue d'Armaillé, 75017 Paris</span>
</p>
</div>
</div>
</div>
<!-- Texte alternatif (invisible) -->
<div style="display:none; font-size:0; line-height:0;">Communication Odentas - Espace Paie</div>
</body>
</html>

View file

@ -0,0 +1,117 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Recommandation Odentas</title>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f8fafc; color: #334155;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f8fafc; padding: 40px 20px;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 16px; overflow: hidden; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);">
<!-- Header avec gradient -->
<tr>
<td style="background: linear-gradient(135deg, #f97316 0%, #fb923c 100%); padding: 40px 30px; text-align: center;">
<h1 style="margin: 0; color: #ffffff; font-size: 28px; font-weight: 600;">
Vous avez été recommandé !
</h1>
</td>
</tr>
<!-- Contenu principal -->
<tr>
<td style="padding: 40px 30px;">
<p style="margin: 0 0 20px; font-size: 16px; line-height: 1.6; color: #334155;">
Bonjour <strong>{{referee_name}}</strong>,
</p>
<p style="margin: 0 0 20px; font-size: 16px; line-height: 1.6; color: #334155;">
<strong>{{referrer_name}}</strong> vous recommande <strong>Odentas</strong>, la solution de gestion de paie pour les professionnels du spectacle et de l'événementiel.
</p>
{{#if personal_message}}
<div style="margin: 30px 0; padding: 20px; background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); border-radius: 12px; border-left: 4px solid #f59e0b;">
<p style="margin: 0; font-size: 15px; line-height: 1.6; color: #78350f; font-style: italic;">
"{{personal_message}}"
</p>
<p style="margin: 10px 0 0; font-size: 14px; color: #92400e; font-weight: 600;">
— {{referrer_name}}
</p>
</div>
{{/if}}
<!-- Avantage parrainage -->
<div style="margin: 30px 0; padding: 25px; background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%); border-radius: 12px; text-align: center;">
<p style="margin: 0 0 10px; font-size: 14px; color: #1e3a8a; text-transform: uppercase; font-weight: 600; letter-spacing: 0.5px;">
Offre de parrainage
</p>
<p style="margin: 0; font-size: 32px; font-weight: 700; color: #1e40af;">
-20 € HT
</p>
<p style="margin: 5px 0 0; font-size: 15px; color: #1e40af;">
sur votre facture d'ouverture de compte
</p>
</div>
<h2 style="margin: 30px 0 15px; font-size: 20px; font-weight: 600; color: #0f172a;">
Pourquoi choisir Odentas ?
</h2>
<ul style="margin: 0 0 30px; padding-left: 20px; color: #475569; line-height: 1.8;">
<li style="margin-bottom: 10px;">
<strong>Spécialiste du spectacle :</strong> CDDU, intermittents, régime général
</li>
<li style="margin-bottom: 10px;">
<strong>100% en ligne :</strong> Créez vos contrats en quelques clics
</li>
<li style="margin-bottom: 10px;">
<strong>Accompagnement expert :</strong> Une équipe dédiée pour vous guider
</li>
<li style="margin-bottom: 10px;">
<strong>Tarifs transparents :</strong> Pas de frais cachés
</li>
</ul>
<!-- CTA Button -->
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 30px 0;">
<tr>
<td align="center">
<a href="https://espace-paie.odentas.fr/signin?utm_source=referral&utm_medium=email"
style="display: inline-block; padding: 16px 40px; background: linear-gradient(135deg, #f97316 0%, #ea580c 100%); color: #ffffff; text-decoration: none; border-radius: 12px; font-size: 16px; font-weight: 600; box-shadow: 0 4px 6px rgba(249, 115, 22, 0.3);">
Créer mon compte gratuitement
</a>
</td>
</tr>
</table>
<p style="margin: 30px 0 0; font-size: 14px; line-height: 1.6; color: #64748b; text-align: center;">
Des questions ? Notre équipe est à votre disposition :<br>
<a href="mailto:contact@odentas.fr" style="color: #f97316; text-decoration: none;">contact@odentas.fr</a>
<a href="https://odentas.fr" style="color: #f97316; text-decoration: none;">odentas.fr</a>
</p>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="padding: 30px; background-color: #f8fafc; border-top: 1px solid #e2e8f0;">
<p style="margin: 0 0 10px; font-size: 13px; color: #64748b; text-align: center; line-height: 1.5;">
Odentas Media SAS • SIREN 892 806 596<br>
125 Avenue du Président Kennedy, 59800 Lille • France
</p>
<p style="margin: 10px 0 0; font-size: 12px; color: #94a3b8; text-align: center;">
Vous recevez cet email car <strong>{{referrer_name}}</strong> vous a recommandé nos services.<br>
<a href="https://espace-paie.odentas.fr/mentions-legales" style="color: #64748b; text-decoration: underline;">Mentions légales</a>
<a href="https://espace-paie.odentas.fr/politique-confidentialite" style="color: #64748b; text-decoration: underline;">Politique de confidentialité</a>
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>