Cotis mensuelles et notifs

This commit is contained in:
odentas 2025-10-13 20:56:34 +02:00
parent 9a130dbeef
commit e29c648041
10 changed files with 2448 additions and 99 deletions

View file

@ -1,11 +1,12 @@
"use client";
import { useMemo, useState, useEffect } from "react";
import { useMemo, useState, useEffect, useRef } from "react";
import { useSearchParams, usePathname, useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/fetcher";
import { Calendar, Loader2 } from "lucide-react";
import { Calendar, Loader2, Building2, Info } from "lucide-react";
import { usePageTitle } from "@/hooks/usePageTitle";
import { createPortal } from "react-dom";
/* =========================
Types attendus du backend
@ -53,10 +54,124 @@ type ClientInfo = {
api_name?: string;
} | null;
type Organization = {
id: string;
name: string;
structure_api?: string;
};
/* ================
Composant InfoTooltip
================ */
function InfoTooltip({ message }: { message: string }) {
const iconRef = useRef<HTMLSpanElement | null>(null);
const [tipOpen, setTipOpen] = useState(false);
const [tipPos, setTipPos] = useState<{ top: number; left: number } | null>(null);
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
function computePos() {
const el = iconRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
// Position directement depuis viewport (pas besoin d'ajouter scroll)
setTipPos({
top: r.top - 8, // Au-dessus de l'élément avec un petit espace
left: r.left + r.width / 2 // Centré horizontalement
});
}
useEffect(() => {
if (!tipOpen) return;
const onScroll = () => computePos();
const onResize = () => computePos();
window.addEventListener('scroll', onScroll, true);
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('scroll', onScroll, true);
window.removeEventListener('resize', onResize);
};
}, [tipOpen]);
return (
<>
<span
ref={iconRef}
onMouseEnter={() => { computePos(); setTipOpen(true); }}
onMouseLeave={() => setTipOpen(false)}
className="inline-flex cursor-help"
>
<Info className="w-3.5 h-3.5 text-slate-400" />
</span>
{isMounted && tipOpen && tipPos && createPortal(
<div
className="z-[1200] fixed pointer-events-none"
style={{
top: `${tipPos.top}px`,
left: `${tipPos.left}px`,
transform: 'translate(-50%, -100%)'
}}
>
<div className="flex flex-col items-center">
<div className="inline-block max-w-[280px] rounded-lg bg-gray-900 text-white text-xs px-3 py-2 shadow-xl mb-1">
{message}
</div>
{/* Flèche pointant vers le bas */}
<div
className="w-0 h-0"
style={{
borderLeft: '6px solid transparent',
borderRight: '6px solid transparent',
borderTop: '6px solid rgb(17, 24, 39)' // gray-900
}}
/>
</div>
</div>,
document.body
)}
</>
);
}
/* ==============
Data fetching
============== */
function useCotisations(f: Filters) {
// Hook pour vérifier si l'utilisateur est staff
function useStaffCheck() {
return useQuery({
queryKey: ["staff-check"],
queryFn: async () => {
const res = await fetch("/api/me");
if (!res.ok) return { isStaff: false };
const data = await res.json();
return { isStaff: Boolean(data.is_staff || data.isStaff) };
},
staleTime: 30_000,
});
}
// Hook pour charger les organisations (staff uniquement)
function useOrganizations() {
const { data: staffCheck } = useStaffCheck();
return useQuery({
queryKey: ["staff-organizations"],
queryFn: async () => {
const res = await fetch("/api/staff/organizations");
if (!res.ok) return [];
const data = await res.json();
return (data.organizations || []) as Organization[];
},
enabled: !!staffCheck?.isStaff,
staleTime: 60_000,
});
}
function useCotisations(f: Filters, orgIdOverride?: string, organizations?: Organization[]) {
// Récupération dynamique des infos client via /api/me
const { data: clientInfo } = useQuery({
queryKey: ["client-info"],
@ -82,6 +197,19 @@ function useCotisations(f: Filters) {
staleTime: 30_000, // Cache 30s
});
// Si staff override, utiliser l'org_id fourni avec les infos de l'org sélectionnée
let effectiveClientInfo = clientInfo;
if (orgIdOverride && organizations) {
const selectedOrg = organizations.find(org => org.id === orgIdOverride);
if (selectedOrg) {
effectiveClientInfo = {
id: selectedOrg.id,
name: selectedOrg.name,
api_name: selectedOrg.structure_api
} as ClientInfo;
}
}
const qs = new URLSearchParams();
qs.set("year", String(f.year));
qs.set("period", f.period);
@ -89,11 +217,11 @@ function useCotisations(f: Filters) {
if (f.to) qs.set("to", f.to);
return useQuery({
queryKey: ["cotisations-mensuelles", f, clientInfo?.id], // Inclure l'ID client dans la queryKey
queryKey: ["cotisations-mensuelles", f, effectiveClientInfo?.id], // Inclure l'ID client dans la queryKey
// Endpoint à implémenter côté Lambda: GET /cotisations/mensuelles?year=YYYY&period=toute_annee&from=&to=
queryFn: () => api<CotisationsResponse>(`/cotisations/mensuelles?${qs.toString()}`, {}, clientInfo), // Passer clientInfo au helper api()
queryFn: () => api<CotisationsResponse>(`/cotisations/mensuelles?${qs.toString()}`, {}, effectiveClientInfo), // Passer clientInfo au helper api()
staleTime: 15_000,
enabled: !!clientInfo, // Ne pas exécuter si pas d'infos client
enabled: !!effectiveClientInfo, // Ne pas exécuter si pas d'infos client
});
}
@ -168,6 +296,13 @@ export default function CotisationsMensuellesPage() {
usePageTitle("Cotisations mensuelles");
const now = new Date();
// Vérification staff
const { data: staffCheck } = useStaffCheck();
const { data: organizations = [] } = useOrganizations();
// État pour l'org sélectionnée (staff uniquement)
const [selectedOrgId, setSelectedOrgId] = useState<string>("");
const handlePeriodChange = (value: Filters["period"]) => {
setFilters((f) => {
@ -299,7 +434,7 @@ export default function CotisationsMensuellesPage() {
router.replace(`${pathname}?${qs.toString()}`, { scroll: false });
}, [filters, pathname, router]);
const { data, isLoading, isError } = useCotisations(filters);
const { data, isLoading, isError } = useCotisations(filters, selectedOrgId || undefined, organizations);
const items = data?.items ?? [];
const total = data?.total;
@ -318,6 +453,28 @@ export default function CotisationsMensuellesPage() {
{/* Filtres */}
<Section title="Filtres">
<div className="grid grid-cols-1 md:grid-cols-4 gap-3 items-end">
{/* Sélecteur d'organisation (staff uniquement) */}
{staffCheck?.isStaff && (
<div>
<label className="text-xs text-slate-500 block mb-1 flex items-center gap-1">
<Building2 className="w-3 h-3" />
Organisation (Staff)
</label>
<select
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
value={selectedOrgId}
onChange={(e) => setSelectedOrgId(e.target.value)}
>
<option value="">Mon organisation</option>
{organizations.map((org) => (
<option key={org.id} value={org.id}>
{org.name}
</option>
))}
</select>
</div>
)}
<div>
<label className="text-xs text-slate-500 block mb-1">Filtrer par année</label>
<select
@ -412,9 +569,9 @@ export default function CotisationsMensuellesPage() {
) : isError ? (
<div className="py-8 text-center text-rose-600">Impossible de charger les cotisations.</div>
) : (
<div className="overflow-x-auto">
<div className="overflow-x-auto overflow-y-visible">
<table className="w-full text-sm">
<thead>
<thead className="relative">
<tr className="border-b bg-slate-50/80">
<th className="text-left font-medium px-3 py-2">Période</th>
<th className="text-right font-medium px-3 py-2">Total</th>
@ -423,7 +580,12 @@ export default function CotisationsMensuellesPage() {
<th className="text-right font-medium px-3 py-2">Audiens retraite</th>
<th className="text-right font-medium px-3 py-2">Audiens prévoyance</th>
<th className="text-right font-medium px-3 py-2">Congés Spectacles</th>
<th className="text-right font-medium px-3 py-2">Prévoyance RG</th>
<th className="text-right font-medium px-3 py-2">
<div className="flex items-center justify-end gap-1">
<span>Prévoyance RG</span>
<InfoTooltip message="Uniquement si autre que AUDIENS" />
</div>
</th>
<th className="text-right font-medium px-3 py-2">PAS</th>
</tr>
</thead>

File diff suppressed because it is too large Load diff

View file

@ -136,6 +136,25 @@ export async function GET(req: Request) {
return NextResponse.json({ error: 'forbidden', message }, { status: 403 });
}
// Si staff, vérifier si un org_id override est fourni via headers (pour le sélecteur d'organisation)
if (clientInfo.isStaff) {
const headerOrgId = req.headers.get('x-active-org-id');
if (headerOrgId) {
const { data: orgData } = await supabase
.from('organizations')
.select('structure_api')
.eq('id', headerOrgId)
.single();
if (orgData) {
clientInfo = {
id: headerOrgId,
name: orgData.structure_api || 'Staff Access',
isStaff: true
};
}
}
}
// Fetch all contributions for org (volume should be acceptable; filter in memory by period)
let query: any = supabase.from('monthly_contributions').select('*');
if (clientInfo.id) {

View file

@ -0,0 +1,246 @@
// app/api/staff/cotisations/[id]/notify-client/route.ts
import { NextRequest, 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";
export const revalidate = 0;
export const runtime = "nodejs";
/**
* POST /api/staff/cotisations/[id]/notify-client
* Envoie une notification d'échéance de cotisations au client
* L'ID correspond à une cotisation spécifique, mais on notifie pour toute la période
*/
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const supabase = createRouteHandlerClient({ cookies });
// 1) Authentification
const {
data: { session },
error: sessionError,
} = await supabase.auth.getSession();
if (sessionError || !session) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
const user = session.user;
// 2) Vérifier que l'utilisateur est staff
const { data: staffData } = await supabase
.from("staff_users")
.select("is_staff")
.eq("user_id", user.id)
.maybeSingle();
const isStaff = staffData?.is_staff || false;
if (!isStaff) {
return NextResponse.json(
{ error: "Accès refusé : réservé au staff" },
{ status: 403 }
);
}
// 3) Récupérer la cotisation (pour obtenir org_id et period_label)
const { data: cotisation, error: cotisError } = await supabase
.from("monthly_contributions")
.select("*")
.eq("id", params.id)
.single();
if (cotisError || !cotisation) {
return NextResponse.json(
{ error: "Cotisation introuvable" },
{ status: 404 }
);
}
// 4) Récupérer l'organisation et ses détails
const { data: organization, error: orgError } = await supabase
.from("organizations")
.select("*")
.eq("id", cotisation.org_id)
.single();
if (orgError || !organization) {
return NextResponse.json(
{ error: "Organisation introuvable" },
{ status: 404 }
);
}
const { data: orgDetails, error: orgDetailsError } = await supabase
.from("organization_details")
.select("*")
.eq("org_id", cotisation.org_id)
.single();
if (orgDetailsError || !orgDetails) {
return NextResponse.json(
{ error: "Détails de l'organisation introuvables" },
{ status: 404 }
);
}
// 5) Vérifier et nettoyer les emails de notification
const cleanEmail = (email: string | null | undefined): string | undefined => {
if (!email) return undefined;
// Supprimer tous les espaces, retours à la ligne, tabulations, etc.
const cleaned = email.replace(/\s+/g, '').trim();
if (cleaned.length === 0) return undefined;
return cleaned;
};
const isValidEmail = (email: string | null | undefined): boolean => {
if (!email) return false;
const cleaned = cleanEmail(email);
if (!cleaned) return false;
// Vérifier le format basique d'un email
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(cleaned);
};
const emailNotifs = cleanEmail(orgDetails.email_notifs);
const emailNotifsCC = cleanEmail(orgDetails.email_notifs_cc);
console.log("[notify-contribution] Email brut:", JSON.stringify(orgDetails.email_notifs));
console.log("[notify-contribution] Email nettoyé:", emailNotifs);
console.log("[notify-contribution] CC brut:", JSON.stringify(orgDetails.email_notifs_cc));
console.log("[notify-contribution] CC nettoyé:", emailNotifsCC);
if (!emailNotifs || !isValidEmail(emailNotifs)) {
return NextResponse.json(
{ error: "Email de notification non configuré ou invalide pour cette organisation" },
{ status: 400 }
);
}
// Valider l'email CC s'il existe
const validatedCcEmail = (emailNotifsCC && isValidEmail(emailNotifsCC)) ? emailNotifsCC : undefined;
// 6) Récupérer le prénom du contact depuis organization_details
const firstName = orgDetails.prenom_contact || "Cher client";
// 7) Extraire le mois de la période pour calculer les dates de prélèvement
// Format attendu : "Août 2025 Int." ou "Août 2025"
const periodLabel = cotisation.period_label || "";
// Fonction pour calculer la période de prélèvement
const calculateCollectionPeriod = (period: string): string => {
try {
// Extraire le mois et l'année (ex: "Août 2025" ou "Août 2025 Int.")
// Utiliser [^\s]+ pour capturer les mois avec accents (Août, Février, Décembre)
const match = period.match(/^([^\s]+)\s+(\d{4})/);
if (!match) {
return "Entre le 15 et le 30 du mois suivant la période";
}
const monthName = match[1];
const year = parseInt(match[2]);
// Mapping des mois français vers numéros
const monthsMap: { [key: string]: number } = {
'Janvier': 0, 'Février': 1, 'Mars': 2, 'Avril': 3,
'Mai': 4, 'Juin': 5, 'Juillet': 6, 'Août': 7,
'Septembre': 8, 'Octobre': 9, 'Novembre': 10, 'Décembre': 11
};
const monthIndex = monthsMap[monthName];
if (monthIndex === undefined) {
return "Entre le 15 et le 30 du mois suivant la période";
}
// Calculer le mois suivant
const date = new Date(year, monthIndex, 1);
date.setMonth(date.getMonth() + 1);
const nextMonth = date.toLocaleDateString('fr-FR', { month: 'long' });
const nextYear = date.getFullYear();
// En français, les mois sont en minuscules dans une phrase
return `Entre le 15 et le 30 ${nextMonth} ${nextYear}`;
} catch (error) {
console.error("[notify-contribution] Erreur lors du calcul de la période:", error);
return "Entre le 15 et le 30 du mois suivant la période";
}
};
const collectionPeriod = calculateCollectionPeriod(periodLabel);
// 8) Récupérer le code employeur
const codeEmployeur = orgDetails.code_employeur || "N/A";
// 9) Préparer les données pour l'email
const emailData = {
firstName,
organizationName: organization.name,
employerCode: codeEmployeur,
handlerName: "Renaud BREVIERE-ABRAHAM",
periodLabel: periodLabel,
collectionPeriod: collectionPeriod,
};
// 10) 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");
await sendUniversalEmailV2({
type: 'contribution-notification',
toEmail: emailNotifs!,
ccEmail: validatedCcEmail,
data: emailData,
});
console.log("[notify-contribution] Email de notification de cotisations envoyé avec succès");
// 11) 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,
notified_by: user.id,
email_sent_to: emailNotifs,
email_cc: validatedCcEmail || null,
});
const { data: insertedData, error: notificationError } = await supabase
.from("contribution_notifications")
.insert({
org_id: cotisation.org_id,
period_label: periodLabel,
notified_by: user.id,
email_sent_to: emailNotifs,
email_cc: validatedCcEmail || null,
})
.select();
if (notificationError) {
console.error("[notify-contribution] ❌ ERREUR lors de l'enregistrement de la notification:", notificationError);
console.error("[notify-contribution] Détails erreur:", JSON.stringify(notificationError, null, 2));
// Ne pas faire échouer la requête si l'email a été envoyé
// mais logger l'erreur
} else {
console.log("[notify-contribution] ✅ Notification enregistrée dans la base de données:", insertedData);
}
return NextResponse.json({
success: true,
message: "Notification envoyée avec succès",
sentTo: emailNotifs,
sentCC: validatedCcEmail || null,
});
} catch (error: any) {
console.error("[notify-contribution] Erreur:", error);
return NextResponse.json(
{ error: "Erreur lors de l'envoi de la notification", details: error?.message },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,173 @@
// app/api/staff/cotisations/[id]/route.ts
import { NextResponse, NextRequest } from "next/server";
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { cookies } from "next/headers";
export const dynamic = "force-dynamic";
export const revalidate = 0;
export const runtime = "nodejs";
// Fonction helper pour vérifier si l'utilisateur est staff
async function isStaffUser(supabase: any, userId: string): Promise<boolean> {
try {
const { data: staffRow } = await supabase
.from("staff_users")
.select("is_staff")
.eq("user_id", userId)
.maybeSingle();
return !!staffRow?.is_staff;
} catch {
return false;
}
}
// PATCH - Mettre à jour une cotisation
export async function PATCH(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const supabase = createRouteHandlerClient({ cookies });
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
// Vérifier que l'utilisateur est staff
const isStaff = await isStaffUser(supabase, session.user.id);
if (!isStaff) {
return NextResponse.json(
{ error: "forbidden", message: "Staff access required" },
{ status: 403 }
);
}
const { id } = params;
// Vérifier que la cotisation existe
const { data: existing, error: fetchError } = await supabase
.from("monthly_contributions")
.select("*")
.eq("id", id)
.single();
if (fetchError || !existing) {
return NextResponse.json(
{ error: "Cotisation introuvable" },
{ status: 404 }
);
}
// Récupérer les données du body
const body = await req.json();
const updates: any = {};
// Permettre la mise à jour de tous les champs (sauf id, org_id pour sécurité)
const allowedFields = [
'fund',
'contrib_type',
'reference',
'period_label',
'due_date',
'paid_date',
'status',
'amount_due',
'amount_paid',
'amount_diff',
'notes'
];
for (const field of allowedFields) {
if (body[field] !== undefined) {
updates[field] = body[field];
}
}
// Mettre à jour la cotisation
const { data: cotisation, error } = await supabase
.from("monthly_contributions")
.update(updates)
.eq("id", id)
.select()
.single();
if (error) {
console.error("[api/staff/cotisations/[id]] Update error:", error.message);
return NextResponse.json(
{ error: "Erreur lors de la mise à jour", detail: error.message },
{ status: 500 }
);
}
return NextResponse.json({ cotisation });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return NextResponse.json(
{ error: "internal_server_error", message },
{ status: 500 }
);
}
}
// DELETE - Supprimer une cotisation
export async function DELETE(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const supabase = createRouteHandlerClient({ cookies });
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
// Vérifier que l'utilisateur est staff
const isStaff = await isStaffUser(supabase, session.user.id);
if (!isStaff) {
return NextResponse.json(
{ error: "forbidden", message: "Staff access required" },
{ status: 403 }
);
}
const { id } = params;
// Vérifier que la cotisation existe
const { data: existing, error: fetchError } = await supabase
.from("monthly_contributions")
.select("id")
.eq("id", id)
.single();
if (fetchError || !existing) {
return NextResponse.json(
{ error: "Cotisation introuvable" },
{ status: 404 }
);
}
// Supprimer la cotisation
const { error } = await supabase
.from("monthly_contributions")
.delete()
.eq("id", id);
if (error) {
console.error("[api/staff/cotisations/[id]] Delete error:", error.message);
return NextResponse.json(
{ error: "Erreur lors de la suppression", detail: error.message },
{ status: 500 }
);
}
return NextResponse.json({ success: true });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return NextResponse.json(
{ error: "internal_server_error", message },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,81 @@
// app/api/staff/cotisations/notifications/route.ts
import { NextResponse, NextRequest } from "next/server";
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { cookies } from "next/headers";
export const dynamic = "force-dynamic";
export const revalidate = 0;
export const runtime = "nodejs";
/**
* GET /api/staff/cotisations/notifications
* Récupère toutes les notifications de cotisations envoyées
* Query params:
* - org_id: filtrer par organisation
* - year: filtrer par année
*/
export async function GET(req: NextRequest) {
try {
const supabase = createRouteHandlerClient({ cookies });
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
// Vérifier que l'utilisateur est staff
const { data: staffRow } = await supabase
.from("staff_users")
.select("is_staff")
.eq("user_id", session.user.id)
.maybeSingle();
const isStaff = !!staffRow?.is_staff;
if (!isStaff) {
return NextResponse.json(
{ error: "forbidden", message: "Staff access required" },
{ status: 403 }
);
}
// Récupérer les paramètres de requête
const { searchParams } = new URL(req.url);
const orgId = searchParams.get("org_id");
const year = searchParams.get("year");
// Construire la requête
let query = supabase
.from("contribution_notifications")
.select("*")
.order("notified_at", { ascending: false });
if (orgId) {
query = query.eq("org_id", orgId);
}
if (year) {
// Filtrer par année dans period_label (ex: "Août 2025")
query = query.like("period_label", `%${year}%`);
}
const { data: notifications, error } = await query;
if (error) {
console.error("[notifications] Error fetching notifications:", error);
return NextResponse.json(
{ error: "failed_to_fetch", message: error.message },
{ status: 500 }
);
}
return NextResponse.json({ notifications: notifications || [] });
} catch (error: any) {
console.error("[notifications] Unexpected error:", error);
return NextResponse.json(
{ error: "internal_error", message: error?.message || "Unknown error" },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,211 @@
// app/api/staff/cotisations/route.ts
import { NextResponse, NextRequest } from "next/server";
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { cookies } from "next/headers";
export const dynamic = "force-dynamic";
export const revalidate = 0;
export const runtime = "nodejs";
// Fonction helper pour vérifier si l'utilisateur est staff
async function isStaffUser(supabase: any, userId: string): Promise<boolean> {
try {
const { data: staffRow } = await supabase
.from("staff_users")
.select("is_staff")
.eq("user_id", userId)
.maybeSingle();
return !!staffRow?.is_staff;
} catch {
return false;
}
}
// GET - Lister toutes les cotisations (avec filtres optionnels)
export async function GET(req: NextRequest) {
try {
const supabase = createRouteHandlerClient({ cookies });
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
// Vérifier que l'utilisateur est staff
const isStaff = await isStaffUser(supabase, session.user.id);
if (!isStaff) {
return NextResponse.json(
{ error: "forbidden", message: "Staff access required" },
{ status: 403 }
);
}
// Paramètres de filtrage
const url = new URL(req.url);
const orgId = url.searchParams.get("org_id");
const fund = url.searchParams.get("fund");
const status = url.searchParams.get("status");
const year = url.searchParams.get("year");
// Construire la requête
let query = supabase
.from("monthly_contributions")
.select(`
*,
organizations!monthly_contributions_org_id_fkey (
id,
name,
structure_api
)
`)
.order("due_date", { ascending: false });
// Filtrer par organisation si spécifié
if (orgId) {
query = query.eq("org_id", orgId);
}
// Filtrer par caisse si spécifié
if (fund) {
query = query.eq("fund", fund);
}
// Filtrer par statut si spécifié
if (status) {
query = query.eq("status", status);
}
// Filtrer par année si spécifié (via period_label qui contient l'année)
if (year) {
query = query.ilike("period_label", `%${year}%`);
}
const { data: cotisations, error } = await query;
if (error) {
console.error("[api/staff/cotisations] Supabase error:", error.message);
return NextResponse.json(
{ error: "supabase_error", detail: error.message },
{ status: 500 }
);
}
return NextResponse.json({ cotisations: cotisations || [] });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return NextResponse.json(
{ error: "internal_server_error", message },
{ status: 500 }
);
}
}
// POST - Créer une nouvelle cotisation
export async function POST(req: NextRequest) {
try {
const supabase = createRouteHandlerClient({ cookies });
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
// Vérifier que l'utilisateur est staff
const isStaff = await isStaffUser(supabase, session.user.id);
if (!isStaff) {
return NextResponse.json(
{ error: "forbidden", message: "Staff access required" },
{ status: 403 }
);
}
// Récupérer les données du body
const body = await req.json();
const {
org_id,
fund,
contrib_type,
reference,
period_label,
due_date,
paid_date,
status,
amount_due,
amount_paid,
amount_diff,
notes
} = body;
// Validation
if (!org_id) {
return NextResponse.json(
{ error: "L'organisation est requise" },
{ status: 400 }
);
}
if (!fund) {
return NextResponse.json(
{ error: "La caisse est requise" },
{ status: 400 }
);
}
if (!period_label) {
return NextResponse.json(
{ error: "Le libellé de période est requis" },
{ status: 400 }
);
}
// Vérifier que l'organisation existe
const { data: orgExists, error: orgError } = await supabase
.from("organizations")
.select("id")
.eq("id", org_id)
.single();
if (orgError || !orgExists) {
return NextResponse.json(
{ error: "Organisation introuvable" },
{ status: 400 }
);
}
// Créer la cotisation
const { data: cotisation, error } = await supabase
.from("monthly_contributions")
.insert({
org_id,
fund,
contrib_type: contrib_type || null,
reference: reference || null,
period_label,
due_date: due_date || null,
paid_date: paid_date || null,
status: status || "À payer",
amount_due: amount_due || 0,
amount_paid: amount_paid || null,
amount_diff: amount_diff || null,
notes: notes || null,
})
.select()
.single();
if (error) {
console.error("[api/staff/cotisations] Insert error:", error.message);
return NextResponse.json(
{ error: "Erreur lors de la création", detail: error.message },
{ status: 500 }
);
}
return NextResponse.json({ cotisation }, { status: 201 });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return NextResponse.json(
{ error: "internal_server_error", message },
{ status: 500 }
);
}
}

View file

@ -394,97 +394,133 @@ export default function Sidebar({ clientInfo, isStaff = false, mobile = false, o
{/* Menu Staff */}
{isStaff && (
<div className="mt-3 mx-auto max-w-[280px] rounded-2xl border bg-white p-2">
<div className="px-3 py-2 text-sm font-medium text-slate-600">
<div className="px-3 py-2 text-sm font-semibold text-slate-700 border-b">
Staff
</div>
<Link href="/staff/tickets" onClick={() => onNavigate && onNavigate()} className={`block px-3 py-2 rounded-xl text-sm transition truncate ${
isActivePath(pathname, "/staff/tickets") ? "bg-gradient-to-r from-indigo-200 via-purple-200 to-pink-200 text-slate-700 font-semibold" : "hover:bg-slate-50"
}`} title="Tickets support">
<span className="inline-flex items-center gap-2">
<LifeBuoy className="w-4 h-4" aria-hidden />
<span>Tickets support</span>
</span>
</Link>
<Link href="/staff/contrats" onClick={() => onNavigate && onNavigate()} className={`block px-3 py-2 rounded-xl text-sm transition truncate ${
isActivePath(pathname, "/staff/contrats") ? "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 contrats">
<span className="inline-flex items-center gap-2">
<FileSignature className="w-4 h-4" aria-hidden />
<span>Gestion des contrats</span>
</span>
</Link>
<Link href="/staff/salaries" onClick={() => onNavigate && onNavigate()} className={`block px-3 py-2 rounded-xl text-sm transition truncate ${
isActivePath(pathname, "/staff/salaries") ? "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 salariés">
<span className="inline-flex items-center gap-2">
<Users className="w-4 h-4" aria-hidden />
<span>Gestion des salariés</span>
</span>
</Link>
<Link href="/staff/clients" onClick={() => onNavigate && onNavigate()} className={`block px-3 py-2 rounded-xl text-sm transition truncate ${
isActivePath(pathname, "/staff/clients") ? "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 clients">
<span className="inline-flex items-center gap-2">
<Building2 className="w-4 h-4" aria-hidden />
<span>Gestion des clients</span>
</span>
</Link>
<Link href="/staff/facturation" onClick={() => onNavigate && onNavigate()} className={`block px-3 py-2 rounded-xl text-sm transition truncate ${
isActivePath(pathname, "/staff/facturation") ? "bg-gradient-to-r from-indigo-200 via-purple-200 to-pink-200 text-slate-700 font-semibold" : "hover:bg-slate-50"
}`} title="Gestion de la facturation">
<span className="inline-flex items-center gap-2">
<CreditCard className="w-4 h-4" aria-hidden />
<span>Facturation</span>
</span>
</Link>
<Link href="/staff/virements-salaires" onClick={() => onNavigate && onNavigate()} className={`block px-3 py-2 rounded-xl text-sm transition truncate ${
isActivePath(pathname, "/staff/virements-salaires") ? "bg-gradient-to-r from-indigo-200 via-purple-200 to-pink-200 text-slate-700 font-semibold" : "hover:bg-slate-50"
}`} title="Virements de salaires">
<span className="inline-flex items-center gap-2">
<Banknote className="w-4 h-4" aria-hidden />
<span>Virements salaires</span>
</span>
</Link>
<Link href="/staff/gestion-productions" onClick={() => onNavigate && onNavigate()} className={`block px-3 py-2 rounded-xl text-sm transition truncate ${
isActivePath(pathname, "/staff/gestion-productions") ? "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 productions">
<span className="inline-flex items-center gap-2">
<Clapperboard className="w-4 h-4" aria-hidden />
<span>Gestion des productions</span>
</span>
</Link>
<Link href="/staff/utilisateurs" onClick={() => onNavigate && onNavigate()} className={`block px-3 py-2 rounded-xl text-sm transition truncate ${
isActivePath(pathname, "/staff/utilisateurs") ? "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 utilisateurs">
<span className="inline-flex items-center gap-2">
<UserCog className="w-4 h-4" aria-hidden />
<span>Gestion des utilisateurs</span>
</span>
</Link>
<Link href="/staff/emails-groupes" onClick={() => onNavigate && onNavigate()} className={`block px-3 py-2 rounded-xl text-sm transition truncate ${
isActivePath(pathname, "/staff/emails-groupes") ? "bg-gradient-to-r from-indigo-200 via-purple-200 to-pink-200 text-slate-700 font-semibold" : "hover:bg-slate-50"
}`} title="Envoi d'emails groupés">
<span className="inline-flex items-center gap-2">
<Mail className="w-4 h-4" aria-hidden />
<span>Emails groupés</span>
</span>
</Link>
<Link href="/staff/email-logs" onClick={() => onNavigate && onNavigate()} className={`block px-3 py-2 rounded-xl text-sm transition truncate ${
isActivePath(pathname, "/staff/email-logs") ? "bg-gradient-to-r from-indigo-200 via-purple-200 to-pink-200 text-slate-700 font-semibold" : "hover:bg-slate-50"
}`} title="Logs des emails">
<span className="inline-flex items-center gap-2">
<Database className="w-4 h-4" aria-hidden />
<span>Logs des emails</span>
</span>
</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">
<span className="inline-flex items-center gap-2">
<FolderOpen className="w-4 h-4" aria-hidden />
<span>Gestion des documents</span>
</span>
</Link>
{/* Support & Communication */}
<div className="mt-3">
<div className="px-3 py-1.5 text-xs font-medium text-slate-500 uppercase tracking-wider">
Support & Communication
</div>
<Link href="/staff/tickets" onClick={() => onNavigate && onNavigate()} className={`block px-3 py-2 rounded-xl text-sm transition truncate ${
isActivePath(pathname, "/staff/tickets") ? "bg-gradient-to-r from-indigo-200 via-purple-200 to-pink-200 text-slate-700 font-semibold" : "hover:bg-slate-50"
}`} title="Tickets support">
<span className="inline-flex items-center gap-2">
<LifeBuoy className="w-4 h-4" aria-hidden />
<span>Tickets support</span>
</span>
</Link>
<Link href="/staff/emails-groupes" onClick={() => onNavigate && onNavigate()} className={`block px-3 py-2 rounded-xl text-sm transition truncate ${
isActivePath(pathname, "/staff/emails-groupes") ? "bg-gradient-to-r from-indigo-200 via-purple-200 to-pink-200 text-slate-700 font-semibold" : "hover:bg-slate-50"
}`} title="Envoi d'emails groupés">
<span className="inline-flex items-center gap-2">
<Mail className="w-4 h-4" aria-hidden />
<span>Emails groupés</span>
</span>
</Link>
<Link href="/staff/email-logs" onClick={() => onNavigate && onNavigate()} className={`block px-3 py-2 rounded-xl text-sm transition truncate ${
isActivePath(pathname, "/staff/email-logs") ? "bg-gradient-to-r from-indigo-200 via-purple-200 to-pink-200 text-slate-700 font-semibold" : "hover:bg-slate-50"
}`} title="Logs des emails">
<span className="inline-flex items-center gap-2">
<Database className="w-4 h-4" aria-hidden />
<span>Logs des emails</span>
</span>
</Link>
</div>
{/* Gestion RH */}
<div className="mt-3">
<div className="px-3 py-1.5 text-xs font-medium text-slate-500 uppercase tracking-wider">
Gestion RH
</div>
<Link href="/staff/contrats" onClick={() => onNavigate && onNavigate()} className={`block px-3 py-2 rounded-xl text-sm transition truncate ${
isActivePath(pathname, "/staff/contrats") ? "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 contrats">
<span className="inline-flex items-center gap-2">
<FileSignature className="w-4 h-4" aria-hidden />
<span>Contrats</span>
</span>
</Link>
<Link href="/staff/salaries" onClick={() => onNavigate && onNavigate()} className={`block px-3 py-2 rounded-xl text-sm transition truncate ${
isActivePath(pathname, "/staff/salaries") ? "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 salariés">
<span className="inline-flex items-center gap-2">
<Users className="w-4 h-4" aria-hidden />
<span>Salariés</span>
</span>
</Link>
<Link href="/staff/gestion-productions" onClick={() => onNavigate && onNavigate()} className={`block px-3 py-2 rounded-xl text-sm transition truncate ${
isActivePath(pathname, "/staff/gestion-productions") ? "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 productions">
<span className="inline-flex items-center gap-2">
<Clapperboard className="w-4 h-4" aria-hidden />
<span>Productions</span>
</span>
</Link>
</div>
{/* Gestion Financière */}
<div className="mt-3">
<div className="px-3 py-1.5 text-xs font-medium text-slate-500 uppercase tracking-wider">
Gestion Financière
</div>
<Link href="/staff/facturation" onClick={() => onNavigate && onNavigate()} className={`block px-3 py-2 rounded-xl text-sm transition truncate ${
isActivePath(pathname, "/staff/facturation") ? "bg-gradient-to-r from-indigo-200 via-purple-200 to-pink-200 text-slate-700 font-semibold" : "hover:bg-slate-50"
}`} title="Gestion de la facturation">
<span className="inline-flex items-center gap-2">
<CreditCard className="w-4 h-4" aria-hidden />
<span>Facturation</span>
</span>
</Link>
<Link href="/staff/virements-salaires" onClick={() => onNavigate && onNavigate()} className={`block px-3 py-2 rounded-xl text-sm transition truncate ${
isActivePath(pathname, "/staff/virements-salaires") ? "bg-gradient-to-r from-indigo-200 via-purple-200 to-pink-200 text-slate-700 font-semibold" : "hover:bg-slate-50"
}`} title="Virements de salaires">
<span className="inline-flex items-center gap-2">
<Banknote className="w-4 h-4" aria-hidden />
<span>Virements salaires</span>
</span>
</Link>
<Link href="/staff/gestion-cotisations" onClick={() => onNavigate && onNavigate()} className={`block px-3 py-2 rounded-xl text-sm transition truncate ${
isActivePath(pathname, "/staff/gestion-cotisations") ? "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 cotisations">
<span className="inline-flex items-center gap-2">
<Percent className="w-4 h-4" aria-hidden />
<span>Cotisations</span>
</span>
</Link>
</div>
{/* Administration */}
<div className="mt-3">
<div className="px-3 py-1.5 text-xs font-medium text-slate-500 uppercase tracking-wider">
Administration
</div>
<Link href="/staff/clients" onClick={() => onNavigate && onNavigate()} className={`block px-3 py-2 rounded-xl text-sm transition truncate ${
isActivePath(pathname, "/staff/clients") ? "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 clients">
<span className="inline-flex items-center gap-2">
<Building2 className="w-4 h-4" aria-hidden />
<span>Clients</span>
</span>
</Link>
<Link href="/staff/utilisateurs" onClick={() => onNavigate && onNavigate()} className={`block px-3 py-2 rounded-xl text-sm transition truncate ${
isActivePath(pathname, "/staff/utilisateurs") ? "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 utilisateurs">
<span className="inline-flex items-center gap-2">
<UserCog className="w-4 h-4" aria-hidden />
<span>Utilisateurs</span>
</span>
</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">
<span className="inline-flex items-center gap-2">
<FolderOpen className="w-4 h-4" aria-hidden />
<span>Documents</span>
</span>
</Link>
</div>
</div>
)}

View file

@ -36,6 +36,7 @@ export type EmailTypeV2 =
| 'signature-request-employee'
| 'bulk-signature-notification' // Nouveau type pour notification de signatures en masse
| 'salary-transfer-notification' // Nouveau type pour notification d'appel à virement
| 'contribution-notification' // Nouveau type pour notification de cotisations
| 'notification'
// Accès / habilitations
| 'account-activation'
@ -759,6 +760,40 @@ const EMAIL_TEMPLATES_V2: Record<EmailTypeV2, EmailTemplateV2> = {
}
},
'contribution-notification': {
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.',
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.',
preheaderText: 'Rappel de prélèvement · {{periodLabel}}',
colors: {
headerColor: STANDARD_COLORS.HEADER,
titleColor: '#0F172A',
buttonColor: STANDARD_COLORS.BUTTON,
buttonTextColor: STANDARD_COLORS.BUTTON_TEXT,
cardBackgroundColor: '#F0F0F5',
cardBorder: '#E5E7EB',
cardTitleColor: '#0F172A',
alertIndicatorColor: '#F59E0B',
},
infoCard: [
{ label: 'Votre structure', key: 'organizationName' },
{ label: 'Votre code employeur', key: 'employerCode' },
{ label: 'Votre gestionnaire', key: 'handlerName' },
],
detailsCard: {
title: 'Informations sur la période',
rows: [
{ label: 'Période concernée', key: 'periodLabel' },
{ label: 'Dates de prélèvement', key: 'collectionPeriod' },
]
}
},
'notification': {
subject: 'Notification - {{title}}',
title: 'Notification',

View file

@ -0,0 +1,3 @@
-- Fix contribution_notifications table: add DEFAULT to id column
ALTER TABLE contribution_notifications
ALTER COLUMN id SET DEFAULT gen_random_uuid();