feat: Badge signatures en attente avec polling optimisé 30s

- Ajout endpoint API /api/signatures-electroniques/pending-count (COUNT() Supabase)
- Ajout hook usePendingSignatures avec polling 30s et pause en background
- Badge rouge animé dans Sidebar pour signatures employeur en attente
- Optimisé pour ne pas surcharger Supabase (cache 30s, refetch au focus)
- Désactivé en mode démo et pour staff
This commit is contained in:
odentas 2025-11-07 18:27:30 +01:00
parent 14a9d141d3
commit 5351456516
3 changed files with 235 additions and 1 deletions

View file

@ -0,0 +1,151 @@
import { NextResponse, NextRequest } from 'next/server';
import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs';
import { cookies } from 'next/headers';
import { detectDemoModeFromHeaders } from '@/lib/demo-detector';
/**
* GET /api/signatures-electroniques/pending-count
*
* Endpoint ultra-léger pour compter les signatures employeur en attente
* Optimisé pour le polling fréquent (30s) sans surcharger Supabase
*
* Retourne : { count: number, org_id: string | null }
*/
export async function GET(req: NextRequest) {
// 🎭 Mode démo : retourner 0 signatures
if (detectDemoModeFromHeaders(req.headers)) {
return NextResponse.json(
{ count: 0, org_id: null, demo: true },
{
status: 200,
headers: {
'Cache-Control': 'public, max-age=30',
'CDN-Cache-Control': 'public, max-age=30'
}
}
);
}
const reqUrl = new URL(req.url);
const orgIdParam = reqUrl.searchParams.get('org_id'); // Pour les staff
// Authentification
const sb = createRouteHandlerClient({ cookies });
const { data: { user } } = await sb.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
}
// Vérifier si l'utilisateur est staff
let isStaff = false;
try {
const { data } = await sb
.from('staff_users')
.select('is_staff')
.eq('user_id', user.id)
.maybeSingle();
isStaff = !!data?.is_staff;
} catch (err) {
console.warn('⚠️ [pending-count] Erreur vérification staff:', err);
}
// Déterminer l'organisation cible
let orgId: string | null = null;
if (isStaff) {
// Staff : utiliser le paramètre org_id ou le cookie active_org_id
if (orgIdParam) {
orgId = orgIdParam;
} else {
try {
const c = cookies();
orgId = c.get('active_org_id')?.value || null;
} catch {}
}
} else {
// Client normal : récupérer son organisation
try {
const { data, error } = await sb
.from('organization_members')
.select('org_id')
.eq('user_id', user.id)
.single();
if (error || !data?.org_id) {
return NextResponse.json(
{ count: 0, org_id: null, error: 'no_org' },
{ status: 200 }
);
}
orgId = data.org_id;
} catch (err) {
console.error('❌ [pending-count] Erreur récupération org:', err);
return NextResponse.json(
{ count: 0, org_id: null, error: 'org_fetch_failed' },
{ status: 200 }
);
}
}
// Si pas d'organisation, retourner 0 pour les staff (accès global) ou erreur pour les clients
if (!orgId) {
if (isStaff) {
// Staff sans org sélectionnée : retourner 0
return NextResponse.json(
{ count: 0, org_id: null },
{
status: 200,
headers: {
'Cache-Control': 'public, max-age=30',
'CDN-Cache-Control': 'public, max-age=30'
}
}
);
} else {
// Client sans org : erreur
return NextResponse.json(
{ count: 0, org_id: null, error: 'no_org' },
{ status: 200 }
);
}
}
// Requête COUNT() optimisée sur Supabase
try {
const { count, error } = await sb
.from('cddu_contracts')
.select('*', { count: 'exact', head: true })
.eq('org_id', orgId)
.in('etat_de_la_demande', ['traitee', 'Traitée', 'Traitee', 'TRAITEE'])
.in('contrat_signe_par_employeur', ['non', 'Non', 'NON', 'false', false, null]);
if (error) {
console.error('❌ [pending-count] Erreur Supabase:', error);
return NextResponse.json(
{ count: 0, org_id: orgId, error: 'db_error' },
{ status: 200 }
);
}
console.log(`✅ [pending-count] org_id: ${orgId}, count: ${count || 0}`);
return NextResponse.json(
{ count: count || 0, org_id: orgId },
{
status: 200,
headers: {
'Cache-Control': 'public, max-age=30',
'CDN-Cache-Control': 'public, max-age=30'
}
}
);
} catch (err) {
console.error('❌ [pending-count] Exception:', err);
return NextResponse.json(
{ count: 0, org_id: orgId, error: 'exception' },
{ status: 200 }
);
}
}

View file

@ -8,6 +8,7 @@ import { createPortal } from "react-dom";
import LogoutButton from "@/components/LogoutButton";
import { MaintenanceButton } from "@/components/MaintenanceButton";
import { useDemoMode } from "@/hooks/useDemoMode";
import { usePendingSignatures } from "@/hooks/usePendingSignatures";
function AccessLink({ disabled, fullWidth = false }: { disabled: boolean; fullWidth?: boolean }) {
const btnRef = useRef<HTMLElement | null>(null);
@ -255,6 +256,11 @@ export default function Sidebar({ clientInfo, isStaff = false, mobile = false, o
// 🎭 Détection du mode démo
const { isDemoMode } = useDemoMode();
// 🔔 Compteur de signatures en attente (polling 30s, optimisé)
const orgId = clientInfo?.id || null;
const { data: pendingData } = usePendingSignatures(orgId, !isStaff && !isDemoMode);
const pendingCount = pendingData?.count || 0;
// Signature count in sidebar disabled to reduce Airtable load
useEffect(() => {
let cancelled = false;
@ -401,6 +407,10 @@ export default function Sidebar({ clientInfo, isStaff = false, mobile = false, o
{items.map((it) => {
const active = isActivePath(pathname, it.href);
const Icon = it.icon as any;
// Badge spécial pour les signatures électroniques
const showBadge = it.href === "/signatures-electroniques" && pendingCount > 0;
return (
<Link
key={it.href}
@ -417,7 +427,16 @@ export default function Sidebar({ clientInfo, isStaff = false, mobile = false, o
{Icon ? <Icon className="w-4 h-4" aria-hidden /> : null}
<span>{it.label}</span>
</span>
{/* Badge disabled to reduce Airtable load */}
{/* Badge rouge avec animation pour les signatures en attente */}
{showBadge && (
<span
className="flex items-center justify-center min-w-[20px] h-5 px-1.5 rounded-full bg-red-500 text-white text-xs font-semibold animate-pulse shadow-sm"
title={`${pendingCount} signature${pendingCount > 1 ? 's' : ''} en attente`}
>
{pendingCount}
</span>
)}
</Link>
);
})}

View file

@ -0,0 +1,64 @@
import { useQuery } from '@tanstack/react-query';
type PendingSignaturesResponse = {
count: number;
org_id: string | null;
demo?: boolean;
error?: string;
};
/**
* Hook pour récupérer le nombre de signatures employeur en attente
*
* Configuration optimisée pour le temps réel sans surcharger Supabase :
* - Polling toutes les 30 secondes quand l'onglet est actif
* - Pause automatique quand l'onglet est en arrière-plan
* - Refetch immédiat au retour sur l'onglet
* - Cache de 30 secondes pour éviter les requêtes doublons
*
* @param orgId - ID de l'organisation (optionnel, pour les staff)
* @param enabled - Activer ou désactiver le hook (défaut: true)
*/
export function usePendingSignatures(orgId?: string | null, enabled: boolean = true) {
return useQuery<PendingSignaturesResponse>({
queryKey: ['pending-signatures-count', orgId],
queryFn: async () => {
const url = orgId
? `/api/signatures-electroniques/pending-count?org_id=${orgId}`
: `/api/signatures-electroniques/pending-count`;
const res = await fetch(url, {
cache: 'no-store',
headers: {
'Accept': 'application/json',
},
});
if (!res.ok) {
throw new Error('Erreur lors de la récupération du compteur');
}
const data = await res.json();
return data;
},
// Stratégie de cache optimisée
staleTime: 30 * 1000, // 30 secondes - considéré comme "frais"
gcTime: 5 * 60 * 1000, // 5 minutes - conservation en cache
// Polling intelligent
refetchInterval: 30 * 1000, // Polling toutes les 30 secondes
refetchIntervalInBackground: false, // PAUSE quand l'onglet est en arrière-plan
// Refetch automatique
refetchOnWindowFocus: true, // Refetch au retour sur l'onglet
refetchOnMount: true, // Refetch au montage du composant
refetchOnReconnect: true, // Refetch après reconnexion réseau
// Retry strategy
retry: 1, // 1 seul retry en cas d'échec
retryDelay: 1000, // 1 seconde entre les retries
// Activation conditionnelle
enabled: enabled,
});
}