Payslips staff + statut AEM contrats et contrats-multi

This commit is contained in:
odentas 2025-10-16 01:17:20 +02:00
parent 68cbe3bc6e
commit ecc84d9652
11 changed files with 1846 additions and 6 deletions

View file

@ -1123,10 +1123,10 @@ export default function ContratMultiPage() {
.sort((a,b) => (b.ordre ?? 0) - (a.ordre ?? 0))
.map((p) => {
const label = payLabel(p) || (p.ordre ? `Paie ${p.ordre}` : 'Paie');
// Normalize aem_statut for robust checks (API can return 'OK', 'ok', etc.)
// Normalize aem_statut for robust checks (API can return 'OK', 'ok', 'Traité', etc.)
const aemNorm = String((p as any).aem_statut || '').normalize('NFD').replace(/\p{Diacritic}/gu, '').trim().toLowerCase();
const aemOk = aemNorm === 'ok' || aemNorm === 'oui' || aemNorm.includes('ok');
const aemPending = aemNorm.includes('a_traiter') || aemNorm.includes('a traiter') || aemNorm.includes('traiter');
const aemOk = aemNorm === 'ok' || aemNorm === 'oui' || aemNorm.includes('ok') || aemNorm === 'traite' || aemNorm === 'traitee' || aemNorm.includes('traite');
const aemPending = aemNorm.includes('a_traiter') || aemNorm.includes('a traiter') || (aemNorm.includes('traiter') && !aemNorm.includes('traite'));
const CardInner = (
<div className="h-full rounded-2xl border bg-white p-4 hover:shadow-md transition-shadow relative">

View file

@ -1476,7 +1476,11 @@ return (
<Field label="Net après PAS" value={formatEUR(slip.net_after_withholding)} />
<Field label="Brut" value={formatEUR(slip.gross_amount)} />
<Field label="Coût total employeur" value={formatEUR(slip.employer_cost)} />
<Field label="AEM" value={slip.aem_status === "OK" ? <Badge tone="ok">AEM OK</Badge> : <Badge tone="warn">À traiter</Badge>} />
<Field label="AEM" value={(() => {
const aemNorm = String(slip.aem_status || '').normalize('NFD').replace(/\p{Diacritic}/gu, '').trim().toLowerCase();
const aemOk = aemNorm === 'ok' || aemNorm === 'oui' || aemNorm.includes('ok') || aemNorm === 'traite' || aemNorm === 'traitee' || aemNorm.includes('traite');
return aemOk ? <Badge tone="ok">AEM OK</Badge> : <Badge tone="warn">À traiter</Badge>;
})()} />
</div>
))
) : (

View file

@ -0,0 +1,98 @@
// app/(app)/staff/payslips/page.tsx
import { cookies } from "next/headers";
import NextDynamic from "next/dynamic";
import { createSbServer } from "@/lib/supabaseServer";
export const dynamic = "force-dynamic";
const PayslipsGrid = NextDynamic<any>(() => import("../../../../components/staff/PayslipsGrid"), { ssr: false });
export default async function StaffPayslipsPage() {
const sb = createSbServer();
const { data: { user } } = await sb.auth.getUser();
if (!user) {
return (
<main className="p-6">
<h1 className="text-lg font-semibold">Accès refusé</h1>
<p className="text-sm text-slate-600">Vous devez être connecté.</p>
</main>
);
}
const { data: me } = await sb.from("staff_users").select("is_staff").eq("user_id", user.id).maybeSingle();
const isStaff = !!me?.is_staff;
if (!isStaff) {
return (
<main className="p-6">
<h1 className="text-lg font-semibold">Accès refusé</h1>
<p className="text-sm text-slate-600">Cette page est réservée au Staff.</p>
</main>
);
}
// Initial fetch: server-side list of latest payslips (limited)
// Utiliser une jointure pour récupérer les informations du contrat et du salarié
const { data: payslips, error } = await sb
.from("payslips")
.select(
`id, contract_id, period_start, period_end, period_month, pay_number, pay_date,
gross_amount, net_amount, net_after_withholding, employer_cost,
processed, aem_status, transfer_done, organization_id, created_at,
cddu_contracts!contract_id(
id, contract_number, employee_name, employee_id, structure, type_de_contrat, org_id,
salaries!employee_id(salarie, nom, prenom)
)`
)
.order("period_start", { ascending: false })
.limit(200);
// Server-side debug logging
try {
console.log("[staff/payslips] supabase fetch payslips result count:", Array.isArray(payslips) ? payslips.length : typeof payslips);
if (error) console.log("[staff/payslips] supabase error:", error);
if (payslips && payslips.length > 0) console.log("[staff/payslips] sample:", payslips.slice(0, 3));
} catch (e) {
// ignore logging errors
}
if (error) {
return (
<main className="p-6">
<h1 className="text-lg font-semibold">Erreur</h1>
<p className="text-sm text-rose-600">{error.message}</p>
<div className="mt-4 p-3 bg-slate-50 rounded text-sm text-slate-700">
<div><strong>Debug</strong></div>
<div>Supabase returned an error when reading <code>payslips</code>. See server logs for details.</div>
</div>
</main>
);
}
// active org (optional) for staff we allow global view
const c = cookies();
const activeOrgId = c.get("active_org_id")?.value || null;
return (
<main className="p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-lg font-semibold">Fiches de paie (Staff)</h1>
</div>
<div className="rounded-2xl border bg-white p-4">
{(!payslips || payslips.length === 0) && (
<div className="mb-4 p-3 rounded bg-yellow-50 text-sm text-slate-800 border">
<div><strong>Debug:</strong> Aucune fiche de paie trouvée côté serveur (initialData vide).</div>
<div className="mt-2 text-xs text-slate-600">Si tu utilises Realtime / RLS, vérifie que la table <code>payslips</code> est publiée pour Realtime et que les policies autorisent la lecture pour ton utilisateur staff.</div>
<details className="mt-2 text-xs">
<summary className="cursor-pointer text-xs underline">Voir le payload serveur (preview)</summary>
<pre className="mt-2 max-h-48 overflow-auto text-xs bg-slate-50 p-2 rounded border">{JSON.stringify(payslips ?? [], null, 2)}</pre>
</details>
</div>
)}
{/* Client-side interactive grid */}
<PayslipsGrid initialData={payslips ?? []} activeOrgId={activeOrgId} />
</div>
</main>
);
}

View file

@ -43,7 +43,7 @@ export async function GET(
);
const r = await admin
.from("cddu_contracts")
.select("id, contract_pdf_s3_key, employee_email")
.select("id, contract_pdf_s3_key")
.eq("id", params.id)
.single();
contract = r.data;
@ -52,7 +52,7 @@ export async function GET(
} else {
const r = await sb
.from("cddu_contracts")
.select("id, contract_pdf_s3_key, employee_email")
.select("id, contract_pdf_s3_key")
.eq("id", params.id)
.single();
contract = r.data;

View file

@ -0,0 +1,59 @@
import { NextRequest, NextResponse } from "next/server";
import { createSbServer } from "@/lib/supabaseServer";
async function assertStaff(sb: ReturnType<typeof createSbServer>, userId: string) {
const { data: me } = await sb
.from("staff_users")
.select("is_staff")
.eq("user_id", userId)
.maybeSingle();
return !!me?.is_staff;
}
export async function POST(req: NextRequest) {
const supabase = createSbServer();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: "Non authentifié." }, { status: 401 });
}
const isStaff = await assertStaff(supabase, user.id);
if (!isStaff) {
return NextResponse.json({ error: "Accès réservé au staff." }, { status: 403 });
}
try {
const body = await req.json();
const { payslipIds } = body;
if (!Array.isArray(payslipIds) || payslipIds.length === 0) {
return NextResponse.json({ error: "payslipIds requis" }, { status: 400 });
}
console.log(`[bulk-delete] Suppression de ${payslipIds.length} payslips:`, payslipIds);
// Suppression groupée
const { data, error } = await supabase
.from("payslips")
.delete()
.in("id", payslipIds)
.select("id");
if (error) {
console.error('[bulk-delete] Error:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
console.log(`[bulk-delete] ${data?.length || 0} payslips supprimées avec succès`);
return NextResponse.json({
success: true,
deleted: data?.length || 0,
payslips: data
});
} catch (error) {
console.error('[bulk-delete] Unexpected error:', error);
return NextResponse.json({ error: "Erreur lors de la suppression" }, { status: 500 });
}
}

View file

@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from "next/server";
import { createSbServer } from "@/lib/supabaseServer";
async function assertStaff(sb: ReturnType<typeof createSbServer>, userId: string) {
const { data: me } = await sb
.from("staff_users")
.select("is_staff")
.eq("user_id", userId)
.maybeSingle();
return !!me?.is_staff;
}
export async function POST(req: NextRequest) {
const supabase = createSbServer();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: "Non authentifié." }, { status: 401 });
}
const isStaff = await assertStaff(supabase, user.id);
if (!isStaff) {
return NextResponse.json({ error: "Accès réservé au staff." }, { status: 403 });
}
try {
const body = await req.json();
const { payslipIds, aemStatus } = body;
if (!Array.isArray(payslipIds) || payslipIds.length === 0) {
return NextResponse.json({ error: "payslipIds requis" }, { status: 400 });
}
if (!aemStatus || typeof aemStatus !== 'string') {
return NextResponse.json({ error: "aemStatus requis" }, { status: 400 });
}
// Mise à jour groupée
const { data, error } = await supabase
.from("payslips")
.update({ aem_status: aemStatus })
.in("id", payslipIds)
.select("id, aem_status");
if (error) {
console.error('[bulk-update-aem] Error:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ payslips: data });
} catch (error) {
console.error('[bulk-update-aem] Unexpected error:', error);
return NextResponse.json({ error: "Erreur lors de la mise à jour" }, { status: 500 });
}
}

View file

@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from "next/server";
import { createSbServer } from "@/lib/supabaseServer";
async function assertStaff(sb: ReturnType<typeof createSbServer>, userId: string) {
const { data: me } = await sb
.from("staff_users")
.select("is_staff")
.eq("user_id", userId)
.maybeSingle();
return !!me?.is_staff;
}
export async function POST(req: NextRequest) {
const supabase = createSbServer();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: "Non authentifié." }, { status: 401 });
}
const isStaff = await assertStaff(supabase, user.id);
if (!isStaff) {
return NextResponse.json({ error: "Accès réservé au staff." }, { status: 403 });
}
try {
const body = await req.json();
const { payslipIds, processed } = body;
if (!Array.isArray(payslipIds) || payslipIds.length === 0) {
return NextResponse.json({ error: "payslipIds requis" }, { status: 400 });
}
if (typeof processed !== 'boolean') {
return NextResponse.json({ error: "processed doit être un booléen" }, { status: 400 });
}
// Mise à jour groupée
const { data, error } = await supabase
.from("payslips")
.update({ processed })
.in("id", payslipIds)
.select("id, processed");
if (error) {
console.error('[bulk-update-processed] Error:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ payslips: data });
} catch (error) {
console.error('[bulk-update-processed] Unexpected error:', error);
return NextResponse.json({ error: "Erreur lors de la mise à jour" }, { status: 500 });
}
}

View file

@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from "next/server";
import { createSbServer } from "@/lib/supabaseServer";
async function assertStaff(sb: ReturnType<typeof createSbServer>, userId: string) {
const { data: me } = await sb
.from("staff_users")
.select("is_staff")
.eq("user_id", userId)
.maybeSingle();
return !!me?.is_staff;
}
export async function POST(req: NextRequest) {
const supabase = createSbServer();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: "Non authentifié." }, { status: 401 });
}
const isStaff = await assertStaff(supabase, user.id);
if (!isStaff) {
return NextResponse.json({ error: "Accès réservé au staff." }, { status: 403 });
}
try {
const body = await req.json();
const { payslipIds, transferDone } = body;
if (!Array.isArray(payslipIds) || payslipIds.length === 0) {
return NextResponse.json({ error: "payslipIds requis" }, { status: 400 });
}
if (typeof transferDone !== 'boolean') {
return NextResponse.json({ error: "transferDone doit être un booléen" }, { status: 400 });
}
// Mise à jour groupée
const { data, error } = await supabase
.from("payslips")
.update({ transfer_done: transferDone })
.in("id", payslipIds)
.select("id, transfer_done");
if (error) {
console.error('[bulk-update-transfer] Error:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ payslips: data });
} catch (error) {
console.error('[bulk-update-transfer] Unexpected error:', error);
return NextResponse.json({ error: "Erreur lors de la mise à jour" }, { status: 500 });
}
}

View file

@ -0,0 +1,286 @@
import { NextRequest, NextResponse } from "next/server";
import { createSbServer } from "@/lib/supabaseServer";
async function assertStaff(sb: ReturnType<typeof createSbServer>, userId: string) {
const { data: me } = await sb
.from("staff_users")
.select("is_staff")
.eq("user_id", userId)
.maybeSingle();
return !!me?.is_staff;
}
export async function GET(req: NextRequest) {
const supabase = createSbServer();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: "Non authentifié." }, { status: 401 });
}
const isStaff = await assertStaff(supabase, user.id);
if (!isStaff) {
return NextResponse.json({ error: "Accès réservé au staff." }, { status: 403 });
}
const { searchParams } = new URL(req.url);
// Paramètres de recherche et filtres
const q = searchParams.get('q') || '';
const structure = searchParams.get('structure') || '';
const typeDeContrat = searchParams.get('type_de_contrat') || '';
const processed = searchParams.get('processed') || '';
const transferDone = searchParams.get('transfer_done') || '';
const aemStatus = searchParams.get('aem_status') || '';
const periodFrom = searchParams.get('period_from') || '';
const periodTo = searchParams.get('period_to') || '';
const sort = searchParams.get('sort') || 'period_start';
const order = searchParams.get('order') || 'desc';
const limit = parseInt(searchParams.get('limit') || '50');
const offset = parseInt(searchParams.get('offset') || '0');
console.log('[staff/payslips/search] Filters:', { q, structure, typeDeContrat, processed, transferDone, aemStatus, periodFrom, periodTo, sort, order, limit, offset });
console.log('[staff/payslips/search] typeDeContrat truthy check:', !!typeDeContrat, 'length:', typeDeContrat?.length);
try {
// Construction de la requête avec jointure
let query = supabase
.from("payslips")
.select(
`id, contract_id, period_start, period_end, period_month, pay_number, pay_date,
gross_amount, net_amount, net_after_withholding, employer_cost,
processed, aem_status, transfer_done, organization_id, created_at,
cddu_contracts!contract_id(
id, contract_number, employee_name, employee_id, structure, type_de_contrat, org_id,
salaries!employee_id(salarie, nom, prenom)
)`,
{ count: "exact" }
); // Filtre de recherche textuelle (n° contrat, nom salarié)
if (q) {
// Nous devons filtrer côté serveur sur les données de la jointure
// Supabase ne permet pas de filtrer directement sur les jointures dans le SELECT
// On va d'abord récupérer tous les contrats qui matchent, puis filter les payslips
const { data: matchingContracts } = await supabase
.from("cddu_contracts")
.select("id")
.or(`contract_number.ilike.%${q}%,employee_name.ilike.%${q}%`);
if (matchingContracts && matchingContracts.length > 0) {
const contractIds = matchingContracts.map(c => c.id);
query = query.in('contract_id', contractIds);
} else {
// Aucun contrat ne correspond, retourner un résultat vide
return NextResponse.json({ rows: [], count: 0 });
}
}
// Filtre par structure (via contrat)
if (structure) {
const { data: matchingContracts } = await supabase
.from("cddu_contracts")
.select("id")
.eq("structure", structure);
if (matchingContracts && matchingContracts.length > 0) {
const contractIds = matchingContracts.map(c => c.id);
query = query.in('contract_id', contractIds);
} else {
return NextResponse.json({ rows: [], count: 0 });
}
}
// Filtre par type de contrat (CDDU vs RG)
// Au lieu de récupérer les IDs de contrats (limité à 1000), on récupère toutes les payslips
// et on filtre côté serveur par le type_de_contrat de la relation
if (typeDeContrat) {
console.log('[staff/payslips/search] Filtering by type:', typeDeContrat);
// Récupérer toutes les payslips avec leurs contrats pour la période
let allQuery = supabase
.from("payslips")
.select(
`id, contract_id, period_start, period_end, period_month, pay_number, pay_date,
gross_amount, net_amount, net_after_withholding, employer_cost,
processed, aem_status, transfer_done, organization_id, created_at,
cddu_contracts!contract_id(
id, contract_number, employee_name, employee_id, structure, type_de_contrat, org_id,
salaries!employee_id(salarie, nom, prenom)
)`,
{ count: "exact" }
);
// Appliquer les filtres de période en amont
if (periodFrom) {
allQuery = allQuery.gte('period_start', periodFrom);
}
if (periodTo) {
allQuery = allQuery.lte('period_start', periodTo);
}
// Limiter à 10000 pour ne pas surcharger
allQuery = allQuery.limit(10000);
const { data: allPayslips, error: allError } = await allQuery;
if (allError) {
console.error('[staff/payslips/search] Error fetching all payslips:', allError);
return NextResponse.json({ error: allError.message }, { status: 500 });
}
console.log('[staff/payslips/search] Retrieved', allPayslips?.length, 'total payslips from DB');
// Filtrer côté serveur par type de contrat
let filteredPayslips = (allPayslips || []).filter(p => {
const contract = Array.isArray(p.cddu_contracts) ? p.cddu_contracts[0] : p.cddu_contracts;
if (!contract) return false;
if (typeDeContrat === "RG") {
return contract.type_de_contrat === "CDD de droit commun" || contract.type_de_contrat === "CDI";
} else {
return contract.type_de_contrat === typeDeContrat;
}
});
console.log('[staff/payslips/search] Filtered to', filteredPayslips.length, 'payslips after contract type filter');
// Appliquer les autres filtres manuellement
let finalPayslips = filteredPayslips;
if (processed !== '') {
finalPayslips = finalPayslips.filter(p => p.processed === (processed === 'true'));
}
if (transferDone !== '') {
finalPayslips = finalPayslips.filter(p => p.transfer_done === (transferDone === 'true'));
}
if (aemStatus) {
finalPayslips = finalPayslips.filter(p => p.aem_status === aemStatus);
}
// Tri côté serveur
const ascending = order === 'asc';
finalPayslips.sort((a, b) => {
let valueA: any, valueB: any;
// cddu_contracts est un objet, pas un tableau dans ce contexte
const contractA = Array.isArray(a.cddu_contracts) ? a.cddu_contracts[0] : a.cddu_contracts;
const contractB = Array.isArray(b.cddu_contracts) ? b.cddu_contracts[0] : b.cddu_contracts;
if (sort === 'employee_name') {
valueA = contractA?.employee_name || '';
valueB = contractB?.employee_name || '';
} else if (sort === 'contract_number') {
valueA = contractA?.contract_number || '';
valueB = contractB?.contract_number || '';
} else if (sort === 'structure') {
valueA = contractA?.structure || '';
valueB = contractB?.structure || '';
} else {
valueA = (a as any)[sort];
valueB = (b as any)[sort];
}
if (typeof valueA === 'string') valueA = valueA.toLowerCase();
if (typeof valueB === 'string') valueB = valueB.toLowerCase();
if (valueA < valueB) return ascending ? -1 : 1;
if (valueA > valueB) return ascending ? 1 : -1;
return 0;
});
// Pagination
const paginatedPayslips = finalPayslips.slice(offset, offset + limit);
return NextResponse.json({
rows: paginatedPayslips,
count: finalPayslips.length
});
}
// Filtre par état de traitement
if (processed !== '') {
query = query.eq('processed', processed === 'true');
}
// Filtre par état de virement
if (transferDone !== '') {
query = query.eq('transfer_done', transferDone === 'true');
}
// Filtre par AEM
if (aemStatus) {
query = query.eq('aem_status', aemStatus);
}
// Filtre par période
if (periodFrom) {
query = query.gte('period_start', periodFrom);
}
if (periodTo) {
query = query.lte('period_start', periodTo);
}
// Tri
const ascending = order === 'asc';
// Pour le tri par des champs de la jointure, on devra trier côté client
// Pour l'instant, on trie uniquement sur les champs de payslips
const sortableFields = ['period_start', 'period_end', 'pay_number', 'pay_date', 'gross_amount', 'net_amount', 'created_at'];
if (sortableFields.includes(sort)) {
query = query.order(sort, { ascending });
} else {
// Fallback sur period_start
query = query.order('period_start', { ascending });
}
// Pagination
query = query.range(offset, offset + limit - 1);
console.log('[staff/payslips/search] Executing query...');
const { data: payslips, error, count } = await query;
if (error) {
console.error('[staff/payslips/search] Supabase query error:', error);
console.error('[staff/payslips/search] Error details:', { message: error.message, details: error.details, hint: error.hint, code: error.code });
return NextResponse.json({ error: error.message }, { status: 500 });
}
// Si le tri est sur des champs de la jointure, on trie côté serveur
let sortedPayslips = payslips || [];
if (sort === 'contract_number' || sort === 'employee_name' || sort === 'structure') {
sortedPayslips = [...sortedPayslips].sort((a: any, b: any) => {
let valueA: any;
let valueB: any;
if (sort === 'contract_number') {
valueA = a.cddu_contracts?.contract_number || '';
valueB = b.cddu_contracts?.contract_number || '';
} else if (sort === 'employee_name') {
valueA = a.cddu_contracts?.salaries?.nom || a.cddu_contracts?.employee_name || '';
valueB = b.cddu_contracts?.salaries?.nom || b.cddu_contracts?.employee_name || '';
} else if (sort === 'structure') {
valueA = a.cddu_contracts?.structure || '';
valueB = b.cddu_contracts?.structure || '';
}
if (typeof valueA === 'string') valueA = valueA.toLowerCase();
if (typeof valueB === 'string') valueB = valueB.toLowerCase();
if (valueA < valueB) return ascending ? -1 : 1;
if (valueA > valueB) return ascending ? 1 : -1;
return 0;
});
}
console.log(`[staff/payslips/search] Found ${sortedPayslips.length} payslips (total: ${count})`);
return NextResponse.json({
rows: sortedPayslips,
count: count ?? sortedPayslips.length
});
} catch (error: any) {
console.error('[staff/payslips/search] Unexpected error:', error);
console.error('[staff/payslips/search] Error details:', { message: error?.message, stack: error?.stack });
return NextResponse.json({ error: error?.message || "Erreur lors de la recherche" }, { status: 500 });
}
}

View file

@ -520,6 +520,14 @@ export default function Sidebar({ clientInfo, isStaff = false, mobile = false, o
<span>Contrats</span>
</span>
</Link>
<Link href="/staff/payslips" onClick={() => onNavigate && onNavigate()} className={`block px-3 py-2 rounded-xl text-sm transition truncate ${
isActivePath(pathname, "/staff/payslips") ? "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 fiches de paie">
<span className="inline-flex items-center gap-2">
<CreditCard className="w-4 h-4" aria-hidden />
<span>Fiches de paie</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">

File diff suppressed because it is too large Load diff