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:
parent
d326730cfb
commit
6170365fc0
28 changed files with 3861 additions and 167 deletions
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
561
app/(app)/parrainage/ParrainageClient.tsx
Normal file
561
app/(app)/parrainage/ParrainageClient.tsx
Normal 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 été 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>
|
||||
);
|
||||
}
|
||||
76
app/(app)/parrainage/page.tsx
Normal file
76
app/(app)/parrainage/page.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
591
app/(app)/staff/offres-promo/OffresPromoClient.tsx
Normal file
591
app/(app)/staff/offres-promo/OffresPromoClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
app/(app)/staff/offres-promo/page.tsx
Normal file
37
app/(app)/staff/offres-promo/page.tsx
Normal 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 />;
|
||||
}
|
||||
137
app/api/contrats/[id]/avenants/route.ts
Normal file
137
app/api/contrats/[id]/avenants/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
227
app/api/promo-banners/route.ts
Normal file
227
app/api/promo-banners/route.ts
Normal 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
248
app/api/referrals/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
86
app/api/send-email-v2/route.ts
Normal file
86
app/api/send-email-v2/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
212
components/PromoBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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'); }}>
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
90
lib/webhookDeduplication.ts
Normal file
90
lib/webhookDeduplication.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { createSbServiceRole } from "./supabaseServer";
|
||||
|
||||
/**
|
||||
* Vérifie si un webhook a déjà été 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à été 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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)';
|
||||
|
||||
128
supabase/migrations/20251031_create_bulk_email_drafts.sql
Normal file
128
supabase/migrations/20251031_create_bulk_email_drafts.sql
Normal 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)';
|
||||
76
supabase/migrations/20251031_create_promo_banners.sql
Normal file
76
supabase/migrations/20251031_create_promo_banners.sql
Normal 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';
|
||||
103
supabase/migrations/20251031_create_referrals.sql
Normal file
103
supabase/migrations/20251031_create_referrals.sql
Normal 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)';
|
||||
|
|
@ -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';
|
||||
145
templates-mails/bulk-email-template.html
Normal file
145
templates-mails/bulk-email-template.html
Normal 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>
|
||||
117
templates-mails/referral-template.html
Normal file
117
templates-mails/referral-template.html
Normal 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>
|
||||
Loading…
Reference in a new issue