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:
parent
14a9d141d3
commit
5351456516
3 changed files with 235 additions and 1 deletions
151
app/api/signatures-electroniques/pending-count/route.ts
Normal file
151
app/api/signatures-electroniques/pending-count/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
64
hooks/usePendingSignatures.ts
Normal file
64
hooks/usePendingSignatures.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue