Modifs 13/10 soir

This commit is contained in:
Renaud 2025-10-13 23:46:54 +02:00
parent e29c648041
commit 9d54549493
19 changed files with 581 additions and 71 deletions

3
.gitignore vendored
View file

@ -18,4 +18,5 @@ build/
*.log
# Tests & coverage
coverage/
coverage/
.vercel

247
POSTHOG_ANALYTICS_GUIDE.md Normal file
View file

@ -0,0 +1,247 @@
# 📊 PostHog Analytics - Guide d'Utilisation
## 🎯 Vue d'ensemble
PostHog est configuré pour tracker automatiquement les événements utilisateurs dans l'application Odentas Espace Paie.
## ✅ Configuration actuelle
### Installation
- **Package** : `posthog-js` (client) + `posthog-node` (serveur)
- **Région** : EU (https://eu.i.posthog.com)
- **Variables d'environnement** :
- `NEXT_PUBLIC_POSTHOG_KEY` : Clé API publique
- `NEXT_PUBLIC_POSTHOG_HOST` : URL du serveur PostHog
### Composants créés
1. **PostHogProvider** (`components/PostHogProvider.tsx`)
- Initialise PostHog au chargement de l'application
- Active le mode debug en développement
- Expose `window.posthog` pour les tests console en dev
2. **PostHogPageView** (`components/PostHogPageView.tsx`)
- Track automatiquement chaque changement de page
- Capture l'URL complète avec query params
3. **PostHogIdentifier** (`components/PostHogIdentifier.tsx`)
- Identifie automatiquement les utilisateurs connectés
- Associe les propriétés utilisateur (email, nom, company, etc.)
- Réinitialise l'identité lors de la déconnexion
## 📈 Événements trackés automatiquement
### Événements système
- `$pageview` : Chaque changement de page
- `$pageleave` : Quand l'utilisateur quitte une page
- `$autocapture` : Clics et interactions (si activé)
### Propriétés utilisateur trackées
Lors de l'identification (connexion) :
- `email` : Email de l'utilisateur
- `first_name` : Prénom
- `last_name` : Nom
- `company_name` : Nom de la société
- `company_id` : ID de la société
- `is_staff` : Membre du staff ou non
## 🔧 Utilisation avancée
### Tracker un événement personnalisé
```tsx
'use client';
import { usePostHog } from 'posthog-js/react';
function MonComposant() {
const posthog = usePostHog();
const handleAction = () => {
posthog.capture('nom_evenement', {
propriete1: 'valeur',
propriete2: 123,
timestamp: new Date().toISOString()
});
};
return <button onClick={handleAction}>Action</button>;
}
```
### Identifier un utilisateur manuellement
```typescript
posthog.identify('user_id_unique', {
email: 'user@example.com',
name: 'Nom Utilisateur',
plan: 'premium'
});
```
### Tracker une propriété super (persistante)
```typescript
// Sera ajoutée à tous les événements futurs
posthog.register({
app_version: '1.0.0',
environment: 'production'
});
```
### Réinitialiser l'identité
```typescript
// À la déconnexion (déjà fait dans LogoutButton)
posthog.reset();
```
## 🧪 Tests en développement
### Console du navigateur
En mode développement, PostHog est accessible via `window.posthog` :
```javascript
// Tester un événement
window.posthog.capture('test_event', { message: 'Hello' });
// Voir l'utilisateur actuel
window.posthog.get_distinct_id();
// Voir les propriétés
window.posthog.get_property('email');
```
### Vérifier les requêtes réseau
1. Ouvrez DevTools (F12)
2. Onglet Network
3. Filtrez par "batch" ou "posthog"
4. Les requêtes vers `eu.i.posthog.com/batch/` doivent être en status 200
### Logs console
Avec le mode debug activé, vous verrez :
```
[PostHog.js] "capture" {event: "$pageview", properties: {...}}
[PostHog.js] "identify" {distinctId: "user_id", properties: {...}}
```
## 📊 Dashboard PostHog
### Accès
https://eu.posthog.com/
### Vues utiles
1. **Activity / Live Events** : Voir les événements en temps réel
2. **Persons** : Liste des utilisateurs identifiés
3. **Insights** : Créer des graphiques et analyses
4. **Funnels** : Analyser les parcours utilisateurs
5. **Session Recordings** : Voir les replays de sessions (si activé)
### Filtres utiles
- Exclure dev : `$current_url does not contain localhost`
- Voir uniquement staff : `is_staff = true`
- Par société : `company_name = X`
## 🎯 Cas d'usage recommandés
### 1. Tracker les actions importantes
```typescript
// Création d'un contrat
posthog.capture('contrat_created', {
contrat_id: contratId,
type: typeContrat,
montant: montant
});
// Signature d'un document
posthog.capture('document_signed', {
document_type: 'contrat',
document_id: docId
});
// Téléchargement de fiche de paie
posthog.capture('payslip_downloaded', {
month: '2025-10',
employee_id: employeeId
});
```
### 2. Groupes (pour analytics par société)
```typescript
posthog.group('company', companyId, {
company_name: 'Société X',
plan: 'premium',
employees_count: 50
});
```
### 3. Feature Flags (A/B testing)
```typescript
const showNewFeature = posthog.isFeatureEnabled('new_ui_design');
if (showNewFeature) {
// Afficher la nouvelle interface
}
```
## 🔒 Confidentialité & RGPD
### Données collectées
- Événements de navigation et interactions
- Propriétés utilisateur (email, nom, société)
- Données techniques (navigateur, OS, résolution)
### Données NON collectées par défaut
- Mots de passe
- Données sensibles dans les formulaires
- Contenu des documents
### Respect du RGPD
- Hébergement EU (GDPR compliant)
- Possibilité d'anonymiser les IPs
- Droit à l'oubli via l'API PostHog
### Opt-out utilisateur
```typescript
// Si l'utilisateur refuse le tracking
posthog.opt_out_capturing();
// Pour réactiver
posthog.opt_in_capturing();
```
## 🚨 Troubleshooting
### Les événements n'arrivent pas
1. ✅ Vérifier que les variables `NEXT_PUBLIC_*` sont dans `.env.local`
2. ✅ Redémarrer le serveur après modification du `.env.local`
3. ✅ Vérifier la console : doit afficher "✅ PostHog initialisé"
4. ✅ Vérifier Network : requêtes `/batch/` en status 200
5. ✅ Vérifier la clé API dans le dashboard PostHog
### Double initialisation
Si vous voyez "You have already initialized PostHog!", c'est normal en dev (React Strict Mode). En production, cela n'arrive pas.
### Événements en retard
PostHog batch les événements pour optimiser les performances. Ils peuvent apparaître avec 5-30s de délai dans le dashboard.
## 📚 Ressources
- [Documentation PostHog](https://posthog.com/docs)
- [API JavaScript](https://posthog.com/docs/libraries/js)
- [React Integration](https://posthog.com/docs/libraries/react)
- [Feature Flags](https://posthog.com/docs/feature-flags)
---
**Dernière mise à jour** : 13 octobre 2025

View file

@ -103,13 +103,13 @@ function useContrats(params: { regime: "CDDU" | "RG"; status: "en_cours" | "term
// --- Mapping état → couleur/texte
const ETATS: Record<Contrat["etat"], { label: string; className: string }> = {
"pre-demande": { label: "Pré-demande", className: "bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300" },
"Reçue": { label: "Reçue", className: "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300" },
"envoye": { label: "Envoyé", className: "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300" },
"signe": { label: "Contrat signé", className: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300" },
"modification": { label: "Modifier la demande", className: "bg-rose-100 text-rose-800 dark:bg-rose-900/40 dark:text-rose-300" },
"traitee": { label: "Traitée", className: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300" },
"en_cours": { label: "En cours", className: "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300" },
"pre-demande": { label: "Pré-demande", className: "bg-slate-100 text-slate-700" },
"Reçue": { label: "Reçue", className: "bg-blue-100 text-blue-800" },
"envoye": { label: "Envoyé", className: "bg-blue-100 text-blue-800" },
"signe": { label: "Contrat signé", className: "bg-emerald-100 text-emerald-800" },
"modification": { label: "Modifier la demande", className: "bg-rose-100 text-rose-800" },
"traitee": { label: "Traitée", className: "bg-emerald-100 text-emerald-800" },
"en_cours": { label: "En cours", className: "bg-blue-100 text-blue-800" },
};
function humanizeEtat(raw?: string){
@ -140,13 +140,13 @@ type PaginationProps = {
function Pagination({ page, totalPages, total, limit, onPageChange, onLimitChange, isFetching, itemsCount, position = 'bottom' }: PaginationProps) {
return (
<div className={`p-3 flex flex-col sm:flex-row items-center gap-3 ${position === 'bottom' ? 'border-t dark:border-slate-800' : ''}`}>
<div className={`p-3 flex flex-col sm:flex-row items-center gap-3 ${position === 'bottom' ? 'border-t' : ''}`}>
{/* Navigation */}
<div className="flex items-center gap-2">
<button
onClick={() => onPageChange(Math.max(1, page - 1))}
disabled={page === 1}
className="px-2 py-1 rounded-lg border dark:border-slate-800 disabled:opacity-40 hover:bg-slate-50 dark:hover:bg-slate-800 disabled:hover:bg-transparent"
className="px-2 py-1 rounded-lg border disabled:opacity-40 hover:bg-slate-50 disabled:hover:bg-transparent"
>
<ChevronLeft className="w-4 h-4"/>
</button>
@ -156,7 +156,7 @@ function Pagination({ page, totalPages, total, limit, onPageChange, onLimitChang
<button
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
className="px-2 py-1 rounded-lg border dark:border-slate-800 disabled:opacity-40 hover:bg-slate-50 dark:hover:bg-slate-800 disabled:hover:bg-transparent"
className="px-2 py-1 rounded-lg border disabled:opacity-40 hover:bg-slate-50 disabled:hover:bg-transparent"
>
<ChevronRight className="w-4 h-4"/>
</button>
@ -164,11 +164,11 @@ function Pagination({ page, totalPages, total, limit, onPageChange, onLimitChang
{/* Sélecteur de limite */}
<div className="flex items-center gap-2 text-sm">
<span className="text-slate-600 dark:text-slate-400">Afficher :</span>
<span className="text-slate-600">Afficher :</span>
<select
value={limit}
onChange={(e) => onLimitChange(parseInt(e.target.value, 10))}
className="px-2 py-1 rounded-lg border dark:border-slate-800 bg-white dark:bg-slate-900 text-sm"
className="px-2 py-1 rounded-lg border bg-white text-sm"
>
<option value={10}>10</option>
<option value={50}>50</option>
@ -177,7 +177,7 @@ function Pagination({ page, totalPages, total, limit, onPageChange, onLimitChang
</div>
{/* Informations */}
<div className="sm:ml-auto text-sm text-slate-600 dark:text-slate-400">
<div className="sm:ml-auto text-sm text-slate-600">
{isFetching ? (
<span className="flex items-center gap-1">
<Loader2 className="w-3 h-3 animate-spin" />
@ -206,7 +206,7 @@ function safeEtat(etat?: string){
const label = etat ? etat.charAt(0).toUpperCase() + etat.slice(1) : "—";
return {
label,
className: "bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300",
className: "bg-slate-100 text-slate-700",
} as { label: string; className: string };
}
@ -301,11 +301,11 @@ export default function PageContrats(){
return (
<div className="space-y-5">
{/* En-tête + Recherche */}
<section className="relative overflow-visible rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800 p-4">
<section className="relative overflow-visible rounded-2xl border bg-white p-4">
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<h1 className="text-xl font-semibold">Contrats & Paies</h1>
<div className="sm:ml-auto flex items-center gap-2 w-full sm:w-auto">
<div className="flex items-center gap-2 px-3 py-2 rounded-xl border dark:border-slate-800 w-full sm:w-80">
<div className="flex items-center gap-2 px-3 py-2 rounded-xl border w-full sm:w-80">
<Search className="w-4 h-4"/>
<input value={q} onChange={(e)=>{ setQ(e.target.value); setPage(1); }} placeholder="Référence, nom, production…" className="bg-transparent outline-none text-sm flex-1"/>
</div>
@ -314,7 +314,7 @@ export default function PageContrats(){
<select
value={selectedOrg || ""}
onChange={(e) => { setSelectedOrg(e.target.value || null); setPage(1); }}
className="px-3 py-2 rounded-lg border dark:border-slate-800 bg-white dark:bg-slate-900 text-sm"
className="px-3 py-2 rounded-lg border bg-white text-sm"
>
<option value="">Toutes les structures</option>
{orgs.map(o => <option key={o.id} value={o.id}>{o.name}</option>)}
@ -324,16 +324,16 @@ export default function PageContrats(){
</div>
{/* Régime tabs */}
<div className="mt-3 inline-flex rounded-xl border dark:border-slate-800 p-1 bg-slate-50 dark:bg-slate-800/50">
<button onClick={()=>{ setRegime("CDDU"); setPage(1); }} className={`px-3 py-1.5 text-sm rounded-lg ${regime==='CDDU' ? 'bg-white dark:bg-slate-900 shadow border dark:border-slate-700' : 'opacity-80'}`}>CDDU</button>
<button onClick={()=>{ setRegime("RG"); setPage(1); }} className={`px-3 py-1.5 text-sm rounded-lg ${regime==='RG' ? 'bg-white dark:bg-slate-900 shadow border dark:border-slate-700' : 'opacity-80'}`}>Régime général</button>
<div className="mt-3 inline-flex rounded-xl border p-1 bg-slate-50">
<button onClick={()=>{ setRegime("CDDU"); setPage(1); }} className={`px-3 py-1.5 text-sm rounded-lg ${regime==='CDDU' ? 'bg-white shadow border' : 'opacity-80'}`}>CDDU</button>
<button onClick={()=>{ setRegime("RG"); setPage(1); }} className={`px-3 py-1.5 text-sm rounded-lg ${regime==='RG' ? 'bg-white shadow border' : 'opacity-80'}`}>Régime général</button>
</div>
{/* Onglets + action */}
<div className="mt-4 flex items-center gap-3">
<div className="inline-flex rounded-xl border dark:border-slate-800 p-1 bg-slate-50 dark:bg-slate-800/50">
<button onClick={()=>switchTab("en_cours")} className={`px-3 py-1.5 text-sm rounded-lg ${status==='en_cours' ? 'bg-white dark:bg-slate-900 shadow border dark:border-slate-700' : 'opacity-80'}`}>En cours</button>
<button onClick={()=>switchTab("termines")} className={`px-3 py-1.5 text-sm rounded-lg ${status==='termines' ? 'bg-white dark:bg-slate-900 shadow border dark:border-slate-700' : 'opacity-80'}`}>Terminés</button>
<div className="inline-flex rounded-xl border p-1 bg-slate-50">
<button onClick={()=>switchTab("en_cours")} className={`px-3 py-1.5 text-sm rounded-lg ${status==='en_cours' ? 'bg-white shadow border' : 'opacity-80'}`}>En cours</button>
<button onClick={()=>switchTab("termines")} className={`px-3 py-1.5 text-sm rounded-lg ${status==='termines' ? 'bg-white shadow border' : 'opacity-80'}`}>Terminés</button>
</div>
<div className="ml-auto flex items-center gap-2">
<a
@ -349,7 +349,7 @@ export default function PageContrats(){
return (
<a
href="/contrats/nouveau/saisie-tableau"
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border dark:border-slate-800 bg-white hover:bg-slate-50 dark:bg-slate-900 dark:hover:bg-slate-800 whitespace-nowrap"
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border bg-white hover:bg-slate-50 whitespace-nowrap"
>
<Table className="w-4 h-4" /> Saisie en tableau
</a>
@ -361,16 +361,16 @@ export default function PageContrats(){
type="button"
aria-disabled="true"
disabled
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border dark:border-slate-800 bg-white text-slate-400 dark:text-slate-500 opacity-60 cursor-not-allowed whitespace-nowrap"
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border bg-white text-slate-400 opacity-60 cursor-not-allowed whitespace-nowrap"
>
<Table className="w-4 h-4" /> Saisie en tableau
</button>
<div
role="tooltip"
className="pointer-events-none absolute right-0 top-full mt-2 z-10 w-64 px-3 py-2 rounded-lg bg-slate-900 text-white dark:bg-slate-800 text-xs shadow-lg opacity-0 group-hover:opacity-100 transition"
className="pointer-events-none absolute right-0 top-full mt-2 z-10 w-64 px-3 py-2 rounded-lg bg-slate-900 text-white text-xs shadow-lg opacity-0 group-hover:opacity-100 transition"
>
Cette fonction sera bientôt disponible.
<div className="absolute left-4 -top-1 w-2 h-2 rotate-45 bg-slate-900 dark:bg-slate-800" />
<div className="absolute left-4 -top-1 w-2 h-2 rotate-45 bg-slate-900" />
</div>
</div>
);
@ -380,13 +380,13 @@ export default function PageContrats(){
</div>
{status === "termines" && (
<div className="mt-3 flex flex-col sm:flex-row gap-2 sm:items-center">
<div className="text-sm text-slate-600 dark:text-slate-300">Filtrer par période :</div>
<div className="text-sm text-slate-600">Filtrer par période :</div>
<div className="flex gap-2">
<select
id="period-select"
value={period}
onChange={(e)=>{ setPeriod(e.target.value); setPage(1); }}
className="px-3 py-2 rounded-lg border dark:border-slate-800 bg-white dark:bg-slate-900 text-sm"
className="px-3 py-2 rounded-lg border bg-white text-sm"
>
<optgroup label="Année">
<option value="Y">Toute l'année</option>
@ -419,7 +419,7 @@ export default function PageContrats(){
<select
value={year}
onChange={(e)=>{ setYear(parseInt(e.target.value,10)); setPage(1); }}
className="px-3 py-2 rounded-lg border dark:border-slate-800 bg-white dark:bg-slate-900 text-sm"
className="px-3 py-2 rounded-lg border bg-white text-sm"
>
{yearOptions.map(y => <option key={y} value={y}>{y}</option>)}
</select>
@ -430,7 +430,7 @@ export default function PageContrats(){
{/* Pagination supérieure */}
{items.length > 0 && (
<section className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800">
<section className="rounded-2xl border bg-white">
<Pagination
page={page}
totalPages={totalPages}
@ -446,11 +446,11 @@ export default function PageContrats(){
)}
{/* Tableau */}
<section className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800">
<section className="rounded-2xl border bg-white">
<div className="overflow-x-auto overflow-visible pb-6">
<table className="w-full text-sm">
<thead>
<tr className="border-b dark:border-slate-800 bg-slate-50/80 dark:bg-slate-800/40">
<tr className="border-b bg-slate-50/80">
<Th>État</Th>
<Th>Référence</Th>
<Th>Salarié</Th>
@ -472,7 +472,7 @@ export default function PageContrats(){
<tr><td colSpan={8} className="py-12 text-center text-slate-500">{status==='en_cours' ? 'Aucun contrat en cours.' : 'Aucun contrat terminé.'}</td></tr>
) : (
items.map((c)=> (
<tr key={c.id} className="border-b last:border-b-0 dark:border-slate-800">
<tr key={c.id} className="border-b last:border-b-0">
<Td>
{(() => { const e = safeEtat(c.etat as any); return (
<span className={`px-2 py-1 rounded-full text-xs whitespace-nowrap ${e.className}`}>{e.label}</span>
@ -482,7 +482,7 @@ export default function PageContrats(){
<div className="flex flex-col">
<a href={detailHref(c)} className="underline font-medium">{c.reference}</a>
{(c.is_multi_mois === true || (c.regime && c.regime.toUpperCase() === "CDDU_MULTI")) && (
<span className="mt-1 inline-flex w-fit text-[11px] px-1.5 py-0.5 rounded-full bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-200">Multimois</span>
<span className="mt-1 inline-flex w-fit text-[11px] px-1.5 py-0.5 rounded-full bg-purple-100 text-purple-800">Multimois</span>
)}
</div>
</Td>
@ -508,16 +508,16 @@ export default function PageContrats(){
type="button"
aria-disabled="true"
disabled
className="inline-flex items-center justify-center w-8 h-8 rounded-lg border dark:border-slate-800 opacity-60 cursor-not-allowed"
className="inline-flex items-center justify-center w-8 h-8 rounded-lg border opacity-60 cursor-not-allowed"
>
<Pencil className="w-4 h-4" />
</button>
<div
role="tooltip"
className="pointer-events-none absolute right-full top-1/2 -translate-y-1/2 z-10 mr-2 w-72 px-3 py-2 rounded-lg bg-slate-900 text-white dark:bg-slate-800 text-xs shadow-lg opacity-0 group-hover:opacity-100 transition"
className="pointer-events-none absolute right-full top-1/2 -translate-y-1/2 z-10 mr-2 w-72 px-3 py-2 rounded-lg bg-slate-900 text-white text-xs shadow-lg opacity-0 group-hover:opacity-100 transition"
>
La modification et la duplication d'un contrat Régime général n'est pas encore possible depuis l'Espace Paie, veuillez nous contacter.
<div className="absolute -right-1 top-1/2 -translate-y-1/2 w-2 h-2 rotate-45 bg-slate-900 dark:bg-slate-800" />
<div className="absolute -right-1 top-1/2 -translate-y-1/2 w-2 h-2 rotate-45 bg-slate-900" />
</div>
</div>
);
@ -527,7 +527,7 @@ export default function PageContrats(){
return (
<a
href={`/contrats/${c.id}/edit`}
className="inline-flex items-center justify-center w-8 h-8 rounded-lg border dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800"
className="inline-flex items-center justify-center w-8 h-8 rounded-lg border hover:bg-slate-50"
aria-label="Modifier le contrat"
title="Modifier le contrat"
>
@ -546,16 +546,16 @@ export default function PageContrats(){
type="button"
disabled
aria-disabled="true"
className="inline-flex items-center justify-center w-8 h-8 rounded-lg border dark:border-slate-800 opacity-60 cursor-not-allowed"
className="inline-flex items-center justify-center w-8 h-8 rounded-lg border opacity-60 cursor-not-allowed"
>
<Copy className="w-4 h-4" />
</button>
<div
role="tooltip"
className="pointer-events-none absolute right-full top-1/2 -translate-y-1/2 z-10 mr-2 w-72 px-3 py-2 rounded-lg bg-slate-900 text-white dark:bg-slate-800 text-xs shadow-lg opacity-0 group-hover:opacity-100 transition"
className="pointer-events-none absolute right-full top-1/2 -translate-y-1/2 z-10 mr-2 w-72 px-3 py-2 rounded-lg bg-slate-900 text-white text-xs shadow-lg opacity-0 group-hover:opacity-100 transition"
>
La modification et la duplication d'un contrat Régime général n'est pas encore possible depuis l'Espace Paie, veuillez nous contacter.
<div className="absolute -right-1 top-1/2 -translate-y-1/2 w-2 h-2 rotate-45 bg-slate-900 dark:bg-slate-800" />
<div className="absolute -right-1 top-1/2 -translate-y-1/2 w-2 h-2 rotate-45 bg-slate-900" />
</div>
</div>
);
@ -565,7 +565,7 @@ export default function PageContrats(){
<button
type="button"
onClick={() => router.push(`/contrats/nouveau?dupe_id=${encodeURIComponent(c.id)}`)}
className="inline-flex items-center justify-center w-8 h-8 rounded-lg border dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800"
className="inline-flex items-center justify-center w-8 h-8 rounded-lg border hover:bg-slate-50"
aria-label="Dupliquer le contrat"
title="Dupliquer le contrat"
>

View file

@ -325,4 +325,4 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
</div>
</div>
);
}
}

View file

@ -39,7 +39,7 @@ export default async function StaffClientsPage({ searchParams }: { searchParams?
);
}
let query = sb.from("organizations").select("id, name, structure_api, created_at").order("created_at", { ascending: false });
let query = sb.from("organizations").select("id, name, structure_api, created_at").order("name", { ascending: true });
if (q) {
// recherche simple sur name et structure_api
query = query.ilike("name", `%${q}%`).ilike("structure_api", `%${q}%`);

View file

@ -807,8 +807,12 @@ function NotifyContributionModal({ period, orgDetails, isRenotification, lastNot
<p className="text-xs font-medium text-slate-700 mb-2">Contenu de l'email :</p>
<p className="text-xs text-slate-600 leading-relaxed">
Période concernée : <strong>{period.period_label}</strong><br />
Dates de prélèvement : <strong>{collectionPeriod}</strong><br />
Rappel que les montants seront prélevés directement sur le compte bancaire
Date de prélèvement théorique : <strong>{collectionPeriod}</strong><br />
{period.total_due === 0 ? (
<> Message : En l'absence de paie pour cette période, aucune cotisation ne sera prélevée</>
) : (
<> Message : Rappel des prélèvements de cotisations à venir</>
)}
</p>
</div>

View file

@ -177,7 +177,25 @@ export async function POST(
// 8) Récupérer le code employeur
const codeEmployeur = orgDetails.code_employeur || "N/A";
// 9) Préparer les données pour l'email
// 9) Récupérer toutes les cotisations de la période pour calculer le total
const { data: allContributions, error: contributionsError } = await supabase
.from("monthly_contributions")
.select("amount_due")
.eq("org_id", cotisation.org_id)
.eq("period_label", periodLabel);
if (contributionsError) {
console.error("[notify-contribution] Erreur lors de la récupération des cotisations:", contributionsError);
}
// Calculer le total des cotisations pour la période
const totalContributions = (allContributions || []).reduce((sum, contrib) => sum + (contrib.amount_due || 0), 0);
const hasZeroContributions = totalContributions === 0;
console.log("[notify-contribution] Total des cotisations:", totalContributions);
console.log("[notify-contribution] hasZeroContributions:", hasZeroContributions);
// 10) Préparer les données pour l'email
const emailData = {
firstName,
organizationName: organization.name,
@ -185,9 +203,10 @@ export async function POST(
handlerName: "Renaud BREVIERE-ABRAHAM",
periodLabel: periodLabel,
collectionPeriod: collectionPeriod,
hasZeroContributions: hasZeroContributions,
};
// 10) Envoyer l'email via le système universel V2
// 11) Envoyer l'email via le système universel V2
console.log("[notify-contribution] Envoi de l'email à:", emailNotifs);
console.log("[notify-contribution] CC:", validatedCcEmail || "Aucun");
@ -200,7 +219,7 @@ export async function POST(
console.log("[notify-contribution] Email de notification de cotisations envoyé avec succès");
// 11) Enregistrer la notification dans la table contribution_notifications
// 12) Enregistrer la notification dans la table contribution_notifications
console.log("[notify-contribution] Tentative d'insertion dans contribution_notifications:", {
org_id: cotisation.org_id,
period_label: periodLabel,

View file

@ -3,8 +3,9 @@ import "./globals.css";
import "@/styles/cmdk.css";
import Providers from "@/components/Providers";
import ProgressBar from "@/components/ProgressBar";
import { useEffect } from "react";
import Head from "next/head";
import { PostHogPageView } from "@/components/PostHogPageView";
import PostHogIdentifier from "@/components/PostHogIdentifier";
import { useEffect, Suspense } from "react";
/**
* Simple garde pour maintenir le company_name dans localStorage pour l'affichage UI.
@ -65,9 +66,15 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<ProgressBar />
{/* Garde simple pour préserver l'affichage UI */}
<OrgPersistenceGuard />
<Providers>{children}</Providers>
{/* BugReporter temporairement masqué */}
<Providers>
<Suspense fallback={null}>
<PostHogPageView />
<PostHogIdentifier />
</Suspense>
{children}
</Providers>
{/* BugReporter temporairement masqué */}
</body>
</html>
);
}
}

View file

@ -95,9 +95,8 @@ export default function InviteForm({ orgs, defaultOrgId, hideOrgSelect }: { orgs
<option value="COMPTA">Compta</option>
</select>
<p className="mt-1 text-xs text-slate-500">
Le nouveau compte sera créé par <em>invitation e-mail</em>. Par défaut, il pourra se connecter par lien magique.
Le nouveau compte sera créé par <em>invitation e-mail</em>. Par défaut, il pourra se connecter par envoi de code.
</p>
{hideOrgSelect && <p className="mt-1 text-xs text-amber-600">En tant que client, vous ne pouvez pas créer de Super Admin.</p>}
</div>
</div>

View file

@ -5,9 +5,11 @@ import { LogOut } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { usePostHog } from "posthog-js/react";
export default function LogoutButton({ variant = "default", className }: { variant?: "default" | "compact"; className?: string }) {
const router = useRouter();
const posthog = usePostHog();
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
@ -25,7 +27,11 @@ export default function LogoutButton({ variant = "default", className }: { varia
console.warn("Erreur lors de la déconnexion:", error);
}
// 2. Nettoyage complet côté client (au cas où il resterait des données)
// 2. Réinitialiser l'identité PostHog
posthog?.reset();
console.log('👋 PostHog: Utilisateur déconnecté');
// 3. Nettoyage complet côté client (au cas où il resterait des données)
try {
// Nettoyer localStorage
localStorage.removeItem("company_name");
@ -42,7 +48,7 @@ export default function LogoutButton({ variant = "default", className }: { varia
console.warn("Erreur lors du nettoyage local:", error);
}
// 3. Redirection vers signin
// 4. Redirection vers signin
router.push("/signin");
}

View file

@ -0,0 +1,46 @@
"use client";
import { useEffect } from "react";
import { usePostHog } from "posthog-js/react";
/**
* Composant pour identifier l'utilisateur dans PostHog
* À placer dans le layout après l'authentification
*/
export default function PostHogIdentifier() {
const posthog = usePostHog();
useEffect(() => {
(async () => {
try {
const res = await fetch("/api/me", { credentials: "include", cache: "no-store" });
if (!res.ok) {
// Si pas authentifié, on réinitialise l'identité
posthog?.reset();
return;
}
const me = await res.json();
if (me?.id) {
// Identifier l'utilisateur avec son ID unique
posthog?.identify(me.id, {
email: me.email,
first_name: me.first_name,
last_name: me.last_name,
company_name: me.company_name,
company_id: me.company_id,
is_staff: me.is_staff || false,
// Ajoutez d'autres propriétés utiles pour vos analytics
});
console.log('👤 PostHog: Utilisateur identifié:', me.email);
}
} catch (e) {
console.error('❌ PostHog: Erreur lors de l\'identification:', e);
}
})();
}, [posthog]);
return null;
}

View file

@ -0,0 +1,26 @@
'use client';
import { usePathname, useSearchParams } from 'next/navigation';
import { useEffect } from 'react';
import { usePostHog } from 'posthog-js/react';
export function PostHogPageView(): null {
const pathname = usePathname();
const searchParams = useSearchParams();
const posthog = usePostHog();
useEffect(() => {
// Tracker les changements de page
if (pathname && posthog) {
let url = window.origin + pathname;
if (searchParams && searchParams.toString()) {
url = url + '?' + searchParams.toString();
}
posthog.capture('$pageview', {
$current_url: url,
});
}
}, [pathname, searchParams, posthog]);
return null;
}

View file

@ -0,0 +1,43 @@
'use client';
import posthog from 'posthog-js';
import { PostHogProvider as PHProvider } from 'posthog-js/react';
import { useEffect } from 'react';
export function PostHogProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
// Initialiser PostHog uniquement côté client et une seule fois
if (typeof window !== 'undefined' && !posthog.__loaded) {
const key = process.env.NEXT_PUBLIC_POSTHOG_KEY;
const host = process.env.NEXT_PUBLIC_POSTHOG_HOST;
// Debug: vérifier que les variables d'environnement sont bien chargées
console.log('🔍 PostHog - Initialisation...');
console.log('🔑 Key:', key ? `${key.substring(0, 10)}...` : '❌ MANQUANTE');
console.log('🌐 Host:', host || '❌ MANQUANT');
if (!key || !host) {
console.error('❌ PostHog: Variables d\'environnement manquantes. Avez-vous redémarré le serveur après avoir ajouté NEXT_PUBLIC_POSTHOG_KEY et NEXT_PUBLIC_POSTHOG_HOST ?');
return;
}
posthog.init(key, {
api_host: host,
person_profiles: 'identified_only',
capture_pageview: false, // On gère les pageviews manuellement avec PostHogPageView
capture_pageleave: true,
loaded: (posthog) => {
console.log('✅ PostHog initialisé avec succès!');
// Rendre posthog accessible globalement pour les tests en dev
if (process.env.NODE_ENV === 'development') {
posthog.debug(true); // Active les logs en dev
(window as any).posthog = posthog; // Exposer pour les tests console
console.log('💡 Vous pouvez maintenant utiliser "window.posthog" dans la console');
}
},
});
}
}, []);
return <PHProvider client={posthog}>{children}</PHProvider>;
}

View file

@ -1,16 +1,21 @@
"use client";
"use client";
import GlobalSearchOverlay from "@/components/GlobalSearchOverlay";
import MobileSidebarOverlay from "@/components/MobileSidebarOverlay";
import { ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { PostHogProvider } from "@/components/PostHogProvider";
const qc = new QueryClient();
export default function Providers({ children }: { children: ReactNode }){
export default function Providers({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={qc}>
{children}
<GlobalSearchOverlay />
<MobileSidebarOverlay />
</QueryClientProvider>
<PostHogProvider>
<QueryClientProvider client={qc}>
{children}
<GlobalSearchOverlay />
<MobileSidebarOverlay />
</QueryClientProvider>
</PostHogProvider>
);
}
}

View file

@ -764,8 +764,8 @@ const EMAIL_TEMPLATES_V2: Record<EmailTypeV2, EmailTemplateV2> = {
subject: 'Échéance de cotisations - {{periodLabel}}',
title: 'Échéance de cotisations',
greeting: '{{#if firstName}}👋 Bonjour {{firstName}},{{/if}}',
mainMessage: 'Nous vous rappelons que les prélèvements des cotisations sociales pour la période {{periodLabel}} auront lieu prochainement.<br><br>Les montants seront prélevés directement sur votre compte bancaire entre le 15 et le 30 du mois suivant la période concernée, conformément aux échéances fixées par les organismes sociaux (URSSAF, France Travail, Audiens, etc.).',
closingMessage: 'Assurez-vous que votre compte dispose de la provision nécessaire pour ces prélèvements.<br><br>Les détails des cotisations sont disponibles sur votre Espace Paie dans la page Cotisations.<br><br>Si vous avez des questions concernant ces cotisations, n\'hésitez pas à nous contacter en répondant à cet e-mail.<br><br>Cordialement,<br>L\'équipe Odentas.',
mainMessage: '{{#if hasZeroContributions}}Nous vous informons qu\'en l\'absence de paie pour la période {{periodLabel}}, aucune cotisation ne vous sera prélevée par les caisses et organismes.{{else}}Nous vous rappelons que les prélèvements des cotisations sociales pour la période {{periodLabel}} auront lieu prochainement.{{/if}}',
closingMessage: 'Si vous avez des questions concernant ces cotisations, n\'hésitez pas à nous contacter en répondant à cet e-mail.<br><br>Cordialement,<br>L\'équipe Odentas.',
ctaText: 'Voir mes cotisations',
ctaUrl: 'https://paie.odentas.fr/cotisations',
footerText: 'Vous recevez cet e-mail car vous êtes client de Odentas, pour vous notifier d\'une action sur votre compte.',
@ -789,7 +789,7 @@ const EMAIL_TEMPLATES_V2: Record<EmailTypeV2, EmailTemplateV2> = {
title: 'Informations sur la période',
rows: [
{ label: 'Période concernée', key: 'periodLabel' },
{ label: 'Dates de prélèvement', key: 'collectionPeriod' },
{ label: 'Date de prélèvement théorique', key: 'collectionPeriod' },
]
}
},

11
lib/posthog.ts Normal file
View file

@ -0,0 +1,11 @@
import { PostHog } from "posthog-node"
// NOTE: This is a Node.js client, so you can use it for sending events from the server side to PostHog.
export default function PostHogClient() {
const posthogClient = new PostHog(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
flushAt: 1,
flushInterval: 0,
})
return posthogClient
}

View file

@ -27,5 +27,21 @@ const nextConfig = {
}
return config;
},
// Rewrites pour proxier les requêtes PostHog
async rewrites() {
return [
{
source: '/ingest/static/:path*',
destination: 'https://eu-assets.i.posthog.com/static/:path*',
},
{
source: '/ingest/:path*',
destination: 'https://eu.i.posthog.com/:path*',
},
];
},
// Nécessaire pour supporter les requêtes API PostHog avec slash final
skipTrailingSlashRedirect: true,
};
export default nextConfig;

78
package-lock.json generated
View file

@ -28,6 +28,8 @@
"lucide-react": "^0.460.0",
"next": "^14.2.5",
"nprogress": "^0.2.0",
"posthog-js": "^1.275.1",
"posthog-node": "^5.9.5",
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@ -1667,6 +1669,12 @@
"node": ">=14"
}
},
"node_modules/@posthog/core": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.2.4.tgz",
"integrity": "sha512-o2TkycuV98PtAkcqE8B1DJv5LBvHEDTWirK5TlkQMeF2MJg0BYliY95CeRZFILNgZJCbI3k/fhahSMRQlpXOMg==",
"license": "MIT"
},
"node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
@ -4375,6 +4383,17 @@
"node": ">=18"
}
},
"node_modules/core-js": {
"version": "3.46.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.46.0.tgz",
"integrity": "sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA==",
"hasInstallScript": true,
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -5560,6 +5579,12 @@
"reusify": "^1.0.4"
}
},
"node_modules/fflate": {
"version": "0.4.8",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz",
"integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==",
"license": "MIT"
},
"node_modules/file-entry-cache": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
@ -7595,6 +7620,53 @@
"dev": true,
"license": "MIT"
},
"node_modules/posthog-js": {
"version": "1.275.1",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.275.1.tgz",
"integrity": "sha512-ILglAzeUQl7h7rB3axr5rn5j2wBp53XedzJoUha5IC594BsrScdOD9NjLpkDAqV/Q5IsRKXbYOkr+HKaxgb4FA==",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"@posthog/core": "1.2.4",
"core-js": "^3.38.1",
"fflate": "^0.4.8",
"preact": "^10.19.3",
"web-vitals": "^4.2.4"
},
"peerDependencies": {
"@rrweb/types": "2.0.0-alpha.17",
"rrweb-snapshot": "2.0.0-alpha.17"
},
"peerDependenciesMeta": {
"@rrweb/types": {
"optional": true
},
"rrweb-snapshot": {
"optional": true
}
}
},
"node_modules/posthog-node": {
"version": "5.9.5",
"resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-5.9.5.tgz",
"integrity": "sha512-Rv82jMVhnxlBNf8wDbP+iAJdZrhU0aHul0LaFrQ/JGxxDiK3EkclIqr+QUwA9CulleTtXf6AIFz22tLvbVs/HA==",
"license": "MIT",
"dependencies": {
"@posthog/core": "1.2.4"
},
"engines": {
"node": ">=20"
}
},
"node_modules/preact": {
"version": "10.27.2",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz",
"integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@ -9239,6 +9311,12 @@
"uuid": "dist/bin/uuid"
}
},
"node_modules/web-vitals": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz",
"integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==",
"license": "Apache-2.0"
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",

View file

@ -29,6 +29,8 @@
"lucide-react": "^0.460.0",
"next": "^14.2.5",
"nprogress": "^0.2.0",
"posthog-js": "^1.275.1",
"posthog-node": "^5.9.5",
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",