From dd570d450956e6fe29dd97a51ea3d5a5b0d3c09b Mon Sep 17 00:00:00 2001 From: odentas Date: Thu, 27 Nov 2025 20:31:11 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Am=C3=A9liorations=20majeures=20des=20c?= =?UTF-8?q?ontrats=20et=20fiches=20de=20paie?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ajout détails cachets/répétitions/heures au modal ContractDetails - Card verte avec validation quand tous les contrats ont une fiche de paie - Système complet de création de fiches de paie avec recherche et vérification - Modal liste des contrats sans paie avec création directe - Amélioration édition dates dans PayslipDetailsModal - Optimisation recherche contrats (ordre des filtres) - Augmentation limite pagination ContractsGrid à 200 - Ajout logs debug génération PDF logo - Script SQL vérification cohérence structure/organisation --- app/api/contrats/[id]/generate-pdf/route.ts | 14 +- app/api/staff/contracts/search/route.ts | 16 +- .../staff/payslips/check-existing/route.ts | 100 +++ app/api/staff/payslips/create/route.ts | 182 +++++ .../staff/payslips/last-pay-number/route.ts | 53 ++ app/api/staff/payslips/missing-stats/route.ts | 143 ++++ .../staff/payslips/search-contracts/route.ts | 211 ++++++ check_structure_mismatch.sql | 44 ++ components/staff/ContractDetailsModal.tsx | 58 +- components/staff/ContractsGrid.tsx | 2 +- components/staff/CreatePayslipModal.tsx | 691 ++++++++++++++++++ components/staff/MissingPayslipsModal.tsx | 194 +++++ components/staff/PayslipDetailsModal.tsx | 76 +- components/staff/PayslipsGrid.tsx | 167 ++++- 14 files changed, 1917 insertions(+), 34 deletions(-) create mode 100644 app/api/staff/payslips/check-existing/route.ts create mode 100644 app/api/staff/payslips/create/route.ts create mode 100644 app/api/staff/payslips/last-pay-number/route.ts create mode 100644 app/api/staff/payslips/missing-stats/route.ts create mode 100644 app/api/staff/payslips/search-contracts/route.ts create mode 100644 check_structure_mismatch.sql create mode 100644 components/staff/CreatePayslipModal.tsx create mode 100644 components/staff/MissingPayslipsModal.tsx diff --git a/app/api/contrats/[id]/generate-pdf/route.ts b/app/api/contrats/[id]/generate-pdf/route.ts index 530fbb1..c624cf2 100644 --- a/app/api/contrats/[id]/generate-pdf/route.ts +++ b/app/api/contrats/[id]/generate-pdf/route.ts @@ -239,7 +239,11 @@ export async function POST( .eq("org_id", contract.org_id) .single(); - console.log("Résultat organization_details:", { orgDetails, orgError }); + console.log("Résultat organization_details:", { + orgDetails, + orgError, + logoField: orgDetails?.logo ? `${orgDetails.logo.substring(0, 50)}...` : 'NULL' + }); if (orgError || !orgDetails) { return NextResponse.json( @@ -446,6 +450,14 @@ export async function POST( imageUrl: orgDetails.logo || "" }; + // Log de débogage pour vérifier le logo + console.log("🖼️ [PDF Generation] Logo récupéré depuis DB:", { + hasLogo: !!orgDetails.logo, + logoLength: orgDetails.logo?.length || 0, + logoPrefix: orgDetails.logo?.substring(0, 30) || 'VIDE', + hasDataPrefix: orgDetails.logo?.startsWith('data:') || false + }); + const pdfPayload = { document_template_id: "736E1A5F-BBA1-4D3E-91ED-A6184479B58D", payload: dataPayload, diff --git a/app/api/staff/contracts/search/route.ts b/app/api/staff/contracts/search/route.ts index 3535b0f..0cb355a 100644 --- a/app/api/staff/contracts/search/route.ts +++ b/app/api/staff/contracts/search/route.ts @@ -45,12 +45,9 @@ export async function GET(req: Request) { organizations!org_id(organization_details(code_employeur)) `, { count: "exact" }); - if (q) { - // simple ilike search on a few columns - query = query.or(`contract_number.ilike.%${q}%,employee_name.ilike.%${q}%,employee_matricule.ilike.%${q}%`); - } + // Appliquer d'abord les filtres exacts (non-OR) pour éviter les conflits if (employee_matricule) query = query.eq("employee_matricule", employee_matricule); - if (structure) query = query.eq("structure", structure); + if (structure) query = query.eq("structure", structure.trim()); if (production_name) query = query.eq("production_name", production_name); // Handle special "RG" filter for common law contracts (CDD de droit commun + CDI) @@ -86,6 +83,12 @@ export async function GET(req: Request) { if (end_from) query = query.gte("end_date", end_from); if (end_to) query = query.lte("end_date", end_to); + // Appliquer le filtre de recherche textuelle EN DERNIER pour éviter les conflits avec les autres filtres + if (q) { + // Recherche sur plusieurs colonnes avec OR + query = query.or(`contract_number.ilike.%${q}%,employee_name.ilike.%${q}%,employee_matricule.ilike.%${q}%`); + } + // allow sort by start_date or end_date or created_at or employee_name or production_name const allowedSorts = new Set(["start_date", "end_date", "created_at", "contract_number", "employee_name", "production_name"]); const sortCol = allowedSorts.has(sort) ? sort : "created_at"; @@ -117,13 +120,10 @@ export async function GET(req: Request) { salariesMap.set(s.id, s.nom); }); - console.log("DEBUG TRI - Mapping salaries:", Array.from(salariesMap.entries())); - // Trier les contrats par nom de famille const sortedContracts = contractsData.sort((a, b) => { const nomA = salariesMap.get(a.employee_id) || ''; const nomB = salariesMap.get(b.employee_id) || ''; - console.log(`DEBUG TRI - Comparaison: ${nomA} vs ${nomB} (employee_ids: ${a.employee_id} vs ${b.employee_id})`); if (order === "asc") { return nomA.localeCompare(nomB); } else { diff --git a/app/api/staff/payslips/check-existing/route.ts b/app/api/staff/payslips/check-existing/route.ts new file mode 100644 index 0000000..4beb159 --- /dev/null +++ b/app/api/staff/payslips/check-existing/route.ts @@ -0,0 +1,100 @@ +// app/api/staff/payslips/check-existing/route.ts +import { createSbServer } from "@/lib/supabaseServer"; +import { NextRequest, NextResponse } from "next/server"; + +export async function GET(request: NextRequest) { + try { + const sb = createSbServer(); + + // Vérifier l'authentification + const { data: { user } } = await sb.auth.getUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + // Vérifier que c'est un staff + const { data: me } = await sb + .from("staff_users") + .select("is_staff") + .eq("user_id", user.id) + .maybeSingle(); + + if (!me?.is_staff) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const searchParams = request.nextUrl.searchParams; + const contractId = searchParams.get("contract_id"); + + if (!contractId) { + return NextResponse.json({ error: "contract_id requis" }, { status: 400 }); + } + + // Récupérer le contrat pour vérifier s'il est mono-mois + const { data: contract, error: contractError } = await sb + .from("cddu_contracts") + .select("start_date, end_date") + .eq("id", contractId) + .single(); + + if (contractError || !contract) { + return NextResponse.json({ error: "Contrat non trouvé" }, { status: 404 }); + } + + // Déterminer si le contrat est mono-mois + const contractStartDate = new Date(contract.start_date); + const contractEndDate = new Date(contract.end_date); + const isMultiMonth = contractStartDate.getMonth() !== contractEndDate.getMonth() || + contractStartDate.getFullYear() !== contractEndDate.getFullYear(); + + console.log("[GET /api/staff/payslips/check-existing] Contract:", { + contractId, + start_date: contract.start_date, + end_date: contract.end_date, + isMultiMonth + }); + + // Si le contrat est multi-mois, pas besoin de bloquer + if (isMultiMonth) { + return NextResponse.json({ + has_existing: false, + is_multi_month: true + }); + } + + // Vérifier si une paie existe déjà pour ce contrat mono-mois + const { data: payslips, error } = await sb + .from("payslips") + .select("id, period_month") + .eq("contract_id", contractId) + .limit(1); + + if (error) { + console.error("[GET /api/staff/payslips/check-existing] Database error:", error); + return NextResponse.json({ error: "Database error" }, { status: 500 }); + } + + const hasExisting = payslips && payslips.length > 0; + + if (hasExisting) { + const monthYear = new Date(payslips[0].period_month).toLocaleDateString('fr-FR', { + month: 'long', + year: 'numeric' + }); + + return NextResponse.json({ + has_existing: true, + is_multi_month: false, + message: `Une fiche de paie existe déjà pour ce contrat mono-mois (${monthYear})` + }); + } + + return NextResponse.json({ + has_existing: false, + is_multi_month: false + }); + } catch (error) { + console.error("[GET /api/staff/payslips/check-existing] Error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/app/api/staff/payslips/create/route.ts b/app/api/staff/payslips/create/route.ts new file mode 100644 index 0000000..b5b8b50 --- /dev/null +++ b/app/api/staff/payslips/create/route.ts @@ -0,0 +1,182 @@ +// app/api/staff/payslips/create/route.ts +import { createSbServer } from "@/lib/supabaseServer"; +import { NextRequest, NextResponse } from "next/server"; + +export async function POST(request: NextRequest) { + try { + const sb = createSbServer(); + + // Vérifier l'authentification + const { data: { user } } = await sb.auth.getUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + // Vérifier que c'est un staff + const { data: me } = await sb + .from("staff_users") + .select("is_staff") + .eq("user_id", user.id) + .maybeSingle(); + + if (!me?.is_staff) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const body = await request.json(); + const { + contract_id, + period_start, + period_end, + pay_date, + pay_number: requestedPayNumber, + gross_amount, + net_amount, + net_after_withholding, + employer_cost, + } = body; + + // Validation + if (!contract_id || !period_start || !period_end) { + return NextResponse.json( + { error: "contract_id, period_start et period_end sont requis" }, + { status: 400 } + ); + } + + // Récupérer les infos du contrat pour obtenir l'organization_id et les dates + const { data: contract, error: contractError } = await sb + .from("cddu_contracts") + .select("org_id, start_date, end_date") + .eq("id", contract_id) + .single(); + + if (contractError || !contract) { + return NextResponse.json( + { error: "Contrat non trouvé" }, + { status: 404 } + ); + } + + // Déterminer si le contrat est mono-mois ou multi-mois + const contractStartDate = new Date(contract.start_date); + const contractEndDate = new Date(contract.end_date); + const isMultiMonth = contractStartDate.getMonth() !== contractEndDate.getMonth() || + contractStartDate.getFullYear() !== contractEndDate.getFullYear(); + + console.log("[POST /api/staff/payslips/create] Contract dates:", { + start_date: contract.start_date, + end_date: contract.end_date, + isMultiMonth + }); + + // Calculer period_month (format YYYY-MM-01 - premier jour du mois basé sur period_start) + const periodStartDate = new Date(period_start); + const period_month = `${periodStartDate.getFullYear()}-${String(periodStartDate.getMonth() + 1).padStart(2, '0')}-01`; + + // Vérifier si une paie existe déjà pour ce contrat et ce mois + const { data: existingPayslips, error: checkError } = await sb + .from("payslips") + .select("id, pay_number, period_month") + .eq("contract_id", contract_id) + .order("pay_number", { ascending: false }); + + if (checkError) { + console.error("[POST /api/staff/payslips/create] Error checking existing payslips:", checkError); + } + + // Si contrat mono-mois et une paie existe déjà, interdire la création + if (!isMultiMonth && existingPayslips && existingPayslips.length > 0) { + const monthYear = new Date(period_month).toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' }); + return NextResponse.json( + { error: `Une fiche de paie existe déjà pour ce contrat mono-mois (${monthYear})` }, + { status: 409 } + ); + } + + // Si contrat multi-mois, vérifier si une paie existe pour CE mois précis + if (isMultiMonth && existingPayslips && existingPayslips.length > 0) { + const existingForThisMonth = existingPayslips.find(p => p.period_month === period_month); + if (existingForThisMonth) { + const monthYear = new Date(period_month).toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' }); + return NextResponse.json( + { error: `Une fiche de paie existe déjà pour ce mois (${monthYear})` }, + { status: 409 } + ); + } + } + + // Déterminer le numéro de paie + let pay_number = 1; + if (isMultiMonth) { + if (requestedPayNumber !== undefined && requestedPayNumber !== null) { + // Utiliser le numéro fourni + pay_number = requestedPayNumber; + } else if (existingPayslips && existingPayslips.length > 0) { + // Trouver le numéro de paie le plus élevé et ajouter 1 + const maxPayNumber = Math.max(...existingPayslips.map(p => p.pay_number || 0)); + pay_number = maxPayNumber + 1; + } + console.log("[POST /api/staff/payslips/create] Multi-month contract, setting pay_number to:", pay_number); + } else { + // Pour les contrats mono-mois, toujours 1 + pay_number = 1; + } + + console.log("[POST /api/staff/payslips/create] Creating payslip with data:", { + contract_id, + organization_id: contract.org_id, + period_start, + period_end, + period_month, + pay_date, + pay_number + }); + + // Créer la fiche de paie + const { data: payslip, error: createError } = await sb + .from("payslips") + .insert({ + contract_id, + organization_id: contract.org_id, + period_start, + period_end, + period_month, + pay_date: pay_date || null, + pay_number, + gross_amount: gross_amount || null, + net_amount: net_amount || null, + net_after_withholding: net_after_withholding || null, + employer_cost: employer_cost || null, + processed: false, + transfer_done: false, + }) + .select() + .single(); + + if (createError) { + console.error("[POST /api/staff/payslips/create] Database error:", createError); + + // Gérer l'erreur de contrainte d'unicité + if (createError.code === '23505' && createError.message.includes('payslips_contract_id_period_month_key')) { + const monthYear = new Date(period_month).toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' }); + return NextResponse.json( + { error: `Une fiche de paie existe déjà pour ce contrat pour le mois de ${monthYear}` }, + { status: 409 } + ); + } + + return NextResponse.json( + { error: "Erreur lors de la création de la fiche de paie", details: createError.message }, + { status: 500 } + ); + } + + console.log("[POST /api/staff/payslips/create] Payslip created successfully:", payslip.id); + + return NextResponse.json({ payslip }, { status: 201 }); + } catch (error) { + console.error("[POST /api/staff/payslips/create] Error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/app/api/staff/payslips/last-pay-number/route.ts b/app/api/staff/payslips/last-pay-number/route.ts new file mode 100644 index 0000000..43995ed --- /dev/null +++ b/app/api/staff/payslips/last-pay-number/route.ts @@ -0,0 +1,53 @@ +// app/api/staff/payslips/last-pay-number/route.ts +import { createSbServer } from "@/lib/supabaseServer"; +import { NextRequest, NextResponse } from "next/server"; + +export async function GET(request: NextRequest) { + try { + const sb = createSbServer(); + + // Vérifier l'authentification + const { data: { user } } = await sb.auth.getUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + // Vérifier que c'est un staff + const { data: me } = await sb + .from("staff_users") + .select("is_staff") + .eq("user_id", user.id) + .maybeSingle(); + + if (!me?.is_staff) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const searchParams = request.nextUrl.searchParams; + const contractId = searchParams.get("contract_id"); + + if (!contractId) { + return NextResponse.json({ error: "contract_id requis" }, { status: 400 }); + } + + // Récupérer le dernier numéro de paie pour ce contrat + const { data: payslips, error } = await sb + .from("payslips") + .select("pay_number") + .eq("contract_id", contractId) + .order("pay_number", { ascending: false }) + .limit(1); + + if (error) { + console.error("[GET /api/staff/payslips/last-pay-number] Database error:", error); + return NextResponse.json({ error: "Database error" }, { status: 500 }); + } + + const lastPayNumber = payslips && payslips.length > 0 ? payslips[0].pay_number : 0; + + return NextResponse.json({ last_pay_number: lastPayNumber || 0 }); + } catch (error) { + console.error("[GET /api/staff/payslips/last-pay-number] Error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/app/api/staff/payslips/missing-stats/route.ts b/app/api/staff/payslips/missing-stats/route.ts new file mode 100644 index 0000000..87421a7 --- /dev/null +++ b/app/api/staff/payslips/missing-stats/route.ts @@ -0,0 +1,143 @@ +// app/api/staff/payslips/missing-stats/route.ts +import { createSbServer } from "@/lib/supabaseServer"; +import { NextRequest, NextResponse } from "next/server"; + +export async function GET(request: NextRequest) { + try { + const sb = createSbServer(); + + // Vérifier l'authentification + const { data: { user } } = await sb.auth.getUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + // Vérifier que c'est un staff + const { data: me } = await sb + .from("staff_users") + .select("is_staff") + .eq("user_id", user.id) + .maybeSingle(); + + if (!me?.is_staff) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const searchParams = request.nextUrl.searchParams; + const structure = searchParams.get("structure"); + const periodFrom = searchParams.get("period_from"); + const periodTo = searchParams.get("period_to"); + + if (!structure || !periodFrom || !periodTo) { + return NextResponse.json({ + missing_count: 0, + message: "Filtres incomplets" + }); + } + + console.log("[GET /api/staff/payslips/missing-stats] Params:", { + structure, + periodFrom, + periodTo + }); + + // Récupérer tous les contrats de cette structure dans cette période + const { data: contracts, error: contractsError } = await sb + .from("cddu_contracts") + .select(` + id, + start_date, + end_date, + type_de_contrat, + n_objet, + reference, + employee_name, + production_name, + profession + `) + .eq("structure", structure) + .lte("start_date", periodTo) + .gte("end_date", periodFrom); + + if (contractsError) { + console.error("[GET /api/staff/payslips/missing-stats] Contracts error:", contractsError); + return NextResponse.json({ error: "Database error" }, { status: 500 }); + } + + if (!contracts || contracts.length === 0) { + return NextResponse.json({ missing_count: 0 }); + } + + console.log("[GET /api/staff/payslips/missing-stats] Found contracts:", contracts.length); + + // Pour chaque contrat, vérifier s'il a des paies + const contractIds = contracts.map(c => c.id); + const { data: payslips, error: payslipsError } = await sb + .from("payslips") + .select("contract_id, period_month") + .in("contract_id", contractIds); + + if (payslipsError) { + console.error("[GET /api/staff/payslips/missing-stats] Payslips error:", payslipsError); + return NextResponse.json({ error: "Database error" }, { status: 500 }); + } + + console.log("[GET /api/staff/payslips/missing-stats] Found payslips:", payslips?.length || 0); + + // Créer un map des contrats avec paies + const contractsWithPayslips = new Set(); + if (payslips) { + payslips.forEach(p => contractsWithPayslips.add(p.contract_id)); + } + + // Collecter les contrats sans paie avec leurs informations + const missingContracts: Array<{ + id: string; + start_date: string; + end_date: string; + type_de_contrat: string; + }> = []; + + contracts.forEach(contract => { + const startDate = new Date(contract.start_date); + const endDate = new Date(contract.end_date); + const isMultiMonth = startDate.getMonth() !== endDate.getMonth() || + startDate.getFullYear() !== endDate.getFullYear(); + + if (!isMultiMonth) { + // Mono-mois : pas de paie du tout + if (!contractsWithPayslips.has(contract.id)) { + missingContracts.push(contract); + } + } else { + // Multi-mois ou RG : vérifier s'il y a une paie dans la période demandée + const contractPayslips = payslips?.filter(p => p.contract_id === contract.id) || []; + + // Vérifier si au moins une paie couvre la période + const hasPeriodPayslip = contractPayslips.some(p => { + const payslipMonth = new Date(p.period_month); + const requestedFrom = new Date(periodFrom); + const requestedTo = new Date(periodTo); + + // Vérifier si le mois de paie est dans la période demandée + return payslipMonth >= requestedFrom && payslipMonth <= requestedTo; + }); + + if (!hasPeriodPayslip) { + missingContracts.push(contract); + } + } + }); + + console.log("[GET /api/staff/payslips/missing-stats] Missing count:", missingContracts.length); + + return NextResponse.json({ + missing_count: missingContracts.length, + total_contracts: contracts.length, + missing_contracts: missingContracts + }); + } catch (error) { + console.error("[GET /api/staff/payslips/missing-stats] Error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/app/api/staff/payslips/search-contracts/route.ts b/app/api/staff/payslips/search-contracts/route.ts new file mode 100644 index 0000000..4b2622f --- /dev/null +++ b/app/api/staff/payslips/search-contracts/route.ts @@ -0,0 +1,211 @@ +// app/api/staff/payslips/search-contracts/route.ts +import { createSbServer } from "@/lib/supabaseServer"; +import { NextRequest, NextResponse } from "next/server"; + +export async function GET(request: NextRequest) { + try { + const sb = createSbServer(); + + // Vérifier l'authentification + const { data: { user } } = await sb.auth.getUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + // Vérifier que c'est un staff + const { data: me } = await sb + .from("staff_users") + .select("is_staff") + .eq("user_id", user.id) + .maybeSingle(); + + if (!me?.is_staff) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const searchParams = request.nextUrl.searchParams; + const query = searchParams.get("q"); + + console.log("[GET /api/staff/payslips/search-contracts] Query:", query); + + if (!query || query.trim().length < 2) { + console.log("[GET /api/staff/payslips/search-contracts] Query too short, returning empty"); + return NextResponse.json({ contracts: [] }); + } + + const searchTerm = query.trim(); + const searchPattern = `%${searchTerm}%`; + + console.log("[GET /api/staff/payslips/search-contracts] Search pattern:", searchPattern); + + // Rechercher dans les contrats avec jointures + // On utilise une recherche textuelle simple sur les champs directs du contrat + const { data: contracts, error } = await sb + .from("cddu_contracts") + .select(` + id, + contract_number, + employee_name, + employee_id, + structure, + type_de_contrat, + start_date, + end_date, + production_name, + n_objet, + objet_spectacle, + org_id, + salaries!employee_id( + salarie, + nom, + prenom + ), + organizations!org_id( + name + ) + `) + .or(`contract_number.ilike.${searchPattern},employee_name.ilike.${searchPattern},structure.ilike.${searchPattern},production_name.ilike.${searchPattern},n_objet.ilike.${searchPattern},objet_spectacle.ilike.${searchPattern}`) + .order("created_at", { ascending: false }) + .limit(50); + + if (error) { + console.error("[GET /api/staff/payslips/search-contracts] Database error:", error); + return NextResponse.json({ error: "Database error", details: error.message }, { status: 500 }); + } + + console.log("[GET /api/staff/payslips/search-contracts] Initial contracts found:", contracts?.length || 0); + + // Filtrer aussi par nom/prénom de salarié côté serveur si pas de résultat + let finalContracts = contracts || []; + + // Si on a peu de résultats, essayer une recherche sur les salariés + if (finalContracts.length < 10) { + console.log("[GET /api/staff/payslips/search-contracts] Searching in salaries table..."); + const { data: contractsBySalaries, error: salariesError } = await sb + .from("salaries") + .select(` + id, + salarie, + nom, + prenom, + cddu_contracts!employee_id( + id, + contract_number, + employee_name, + employee_id, + structure, + type_de_contrat, + start_date, + end_date, + production_name, + n_objet, + objet_spectacle, + org_id, + organizations!org_id( + name + ) + ) + `) + .or(`salarie.ilike.${searchPattern},nom.ilike.${searchPattern},prenom.ilike.${searchPattern}`) + .limit(50); + + if (salariesError) { + console.error("[GET /api/staff/payslips/search-contracts] Salaries search error:", salariesError); + } + + if (!salariesError && contractsBySalaries) { + console.log("[GET /api/staff/payslips/search-contracts] Salaries found:", contractsBySalaries.length); + // Aplatir les résultats et ajouter les infos du salarié + const additionalContracts = contractsBySalaries.flatMap(salarie => { + if (!salarie.cddu_contracts || !Array.isArray(salarie.cddu_contracts)) return []; + return salarie.cddu_contracts.map((contract: any) => ({ + ...contract, + salaries: { + salarie: salarie.salarie, + nom: salarie.nom, + prenom: salarie.prenom + } + })); + }); + + // Fusionner et dédupliquer par ID + const existingIds = new Set(finalContracts.map(c => c.id)); + additionalContracts.forEach(contract => { + if (!existingIds.has(contract.id)) { + finalContracts.push(contract); + existingIds.add(contract.id); + } + }); + console.log("[GET /api/staff/payslips/search-contracts] After salaries merge:", finalContracts.length); + } + } + + // Filtrer aussi par nom d'organisation si peu de résultats + if (finalContracts.length < 10) { + console.log("[GET /api/staff/payslips/search-contracts] Searching in organizations table..."); + const { data: contractsByOrgs, error: orgsError } = await sb + .from("organizations") + .select(` + id, + name, + cddu_contracts!org_id( + id, + contract_number, + employee_name, + employee_id, + structure, + type_de_contrat, + start_date, + end_date, + production_name, + n_objet, + objet_spectacle, + org_id, + salaries!employee_id( + salarie, + nom, + prenom + ) + ) + `) + .ilike('name', searchPattern) + .limit(50); + + if (orgsError) { + console.error("[GET /api/staff/payslips/search-contracts] Organizations search error:", orgsError); + } + + if (!orgsError && contractsByOrgs) { + console.log("[GET /api/staff/payslips/search-contracts] Organizations found:", contractsByOrgs.length); + const additionalContracts = contractsByOrgs.flatMap(org => { + if (!org.cddu_contracts || !Array.isArray(org.cddu_contracts)) return []; + return org.cddu_contracts.map((contract: any) => ({ + ...contract, + organizations: { + name: org.name + } + })); + }); + + const existingIds = new Set(finalContracts.map(c => c.id)); + additionalContracts.forEach(contract => { + if (!existingIds.has(contract.id)) { + finalContracts.push(contract); + existingIds.add(contract.id); + } + }); + console.log("[GET /api/staff/payslips/search-contracts] After organizations merge:", finalContracts.length); + } + } + + // Limiter à 50 résultats + finalContracts = finalContracts.slice(0, 50); + + console.log("[GET /api/staff/payslips/search-contracts] Final contracts count:", finalContracts.length); + + return NextResponse.json({ contracts: finalContracts }); + } catch (error) { + console.error("[GET /api/staff/payslips/search-contracts] Error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/check_structure_mismatch.sql b/check_structure_mismatch.sql new file mode 100644 index 0000000..e57f934 --- /dev/null +++ b/check_structure_mismatch.sql @@ -0,0 +1,44 @@ +-- Script pour trouver les contrats dont le champ "structure" ne correspond pas au nom de l'organisation +-- Cela permet d'identifier les incohérences dans les données + +SELECT + c.id, + c.contract_number, + c.employee_name, + c.structure AS structure_actuelle, + o.name AS nom_organisation, + c.start_date, + c.end_date, + c.created_at +FROM + cddu_contracts c + INNER JOIN organizations o ON c.org_id = o.id +WHERE + c.structure IS DISTINCT FROM o.name + -- IS DISTINCT FROM gère aussi les cas NULL +ORDER BY + c.created_at DESC; + +-- Compter le nombre total de contrats avec incohérence +SELECT + COUNT(*) as nombre_contrats_incoherents +FROM + cddu_contracts c + INNER JOIN organizations o ON c.org_id = o.id +WHERE + c.structure IS DISTINCT FROM o.name; + +-- Grouper par organisation pour voir quelles organisations ont le plus d'incohérences +SELECT + o.name AS nom_organisation, + COUNT(*) as nombre_incoherences, + ARRAY_AGG(DISTINCT c.structure) as structures_utilisees +FROM + cddu_contracts c + INNER JOIN organizations o ON c.org_id = o.id +WHERE + c.structure IS DISTINCT FROM o.name +GROUP BY + o.name +ORDER BY + nombre_incoherences DESC; diff --git a/components/staff/ContractDetailsModal.tsx b/components/staff/ContractDetailsModal.tsx index 45e86df..3c0a823 100644 --- a/components/staff/ContractDetailsModal.tsx +++ b/components/staff/ContractDetailsModal.tsx @@ -21,11 +21,16 @@ type ContractDetails = { contrat_signe_par_employeur?: string; contrat_signe?: string; date_signature?: string; + notes?: string | null; // Nouveaux champs pour détails du contrat cachets_representations?: number | null; + services_repetitions?: number | null; nombre_d_heures?: number | null; nombre_d_heures_par_jour?: number | null; + minutes_total?: string | null; jours_travail?: string | null; + jours_representations?: string | null; + jours_repetitions?: string | null; precisions_salaire?: string | null; profession?: string | null; categorie_pro?: string | null; @@ -395,21 +400,52 @@ export default function ContractDetailsModal({ {/* Deuxième grille: Détails des jours/heures/cachets */}
+

Détails du contrat

+ {/* Colonne 1 */}
- {/* Nombre de cachets */} + {/* Nombre de cachets (représentations) */} {contractDetails.cachets_representations !== null && contractDetails.cachets_representations !== undefined && (
- +

{contractDetails.cachets_representations}

)} - {/* Nombre d'heures */} + {/* Dates des représentations */} + {contractDetails.jours_representations && ( +
+ +

{contractDetails.jours_representations}

+
+ )} + + {/* Nombre de services (répétitions) */} + {contractDetails.services_repetitions !== null && contractDetails.services_repetitions !== undefined && ( +
+ +

{contractDetails.services_repetitions}

+
+ )} + + {/* Dates des répétitions */} + {contractDetails.jours_repetitions && ( +
+ +

{contractDetails.jours_repetitions}

+
+ )} + + {/* Nombre d'heures total */} {contractDetails.nombre_d_heures !== null && contractDetails.nombre_d_heures !== undefined && (
- -

{contractDetails.nombre_d_heures}h

+ +

+ {contractDetails.nombre_d_heures}h + {contractDetails.minutes_total && contractDetails.minutes_total !== "0" && ( + {contractDetails.minutes_total} + )} +

)} @@ -428,7 +464,7 @@ export default function ContractDetailsModal({ {contractDetails.jours_travail && (
-

{contractDetails.jours_travail}

+

{contractDetails.jours_travail}

)} @@ -449,6 +485,16 @@ export default function ContractDetailsModal({ )}
+ + {/* Section Notes */} + {contractDetails.notes && ( +
+ +
+

{contractDetails.notes}

+
+
+ )} ) : null} diff --git a/components/staff/ContractsGrid.tsx b/components/staff/ContractsGrid.tsx index 67b7e50..0f2780b 100644 --- a/components/staff/ContractsGrid.tsx +++ b/components/staff/ContractsGrid.tsx @@ -284,7 +284,7 @@ function ContractsGridImpl({ initialData, activeOrgId }: { initialData: Contract const [sortField, setSortField] = useState(savedFilters?.sortField || "start_date"); const [sortOrder, setSortOrder] = useState<'asc'|'desc'>(savedFilters?.sortOrder || 'desc'); const [page, setPage] = useState(0); - const [limit, setLimit] = useState(50); + const [limit, setLimit] = useState(200); // Augmenté à 200 pour gérer plus de contrats const [showFilters, setShowFilters] = useState(savedFilters?.showFilters || false); const totalCountRef = useRef(null); const [selectedId, setSelectedId] = useState(null); diff --git a/components/staff/CreatePayslipModal.tsx b/components/staff/CreatePayslipModal.tsx new file mode 100644 index 0000000..12fcb9c --- /dev/null +++ b/components/staff/CreatePayslipModal.tsx @@ -0,0 +1,691 @@ +// components/staff/CreatePayslipModal.tsx +"use client"; + +import React, { useState, useEffect } from "react"; +import { X, FileText, Search, Loader, AlertCircle } from "lucide-react"; +import { toast } from "sonner"; + +type Contract = { + id: string; + contract_number?: string | null; + employee_name?: string | null; + structure?: string | null; + type_de_contrat?: string | null; + start_date?: string | null; + end_date?: string | null; + production_name?: string | null; + n_objet?: string | null; + objet_spectacle?: string | null; + salaries?: { + salarie?: string | null; + nom?: string | null; + prenom?: string | null; + } | null; + organizations?: { + name?: string | null; + } | null; +}; + +type CreatePayslipModalProps = { + isOpen: boolean; + onClose: () => void; + onPayslipCreated?: () => void; + preselectedContractId?: string | null; +}; + +function formatDate(dateString: string | null | undefined): string { + if (!dateString) return "—"; + try { + const date = new Date(dateString); + return date.toLocaleDateString('fr-FR', { + day: '2-digit', + month: '2-digit', + year: 'numeric' + }); + } catch { + return "—"; + } +} + +function formatEmployeeName(contract: Contract): string { + if (contract.salaries?.salarie) { + return contract.salaries.salarie; + } + + if (contract.salaries?.nom || contract.salaries?.prenom) { + const nom = (contract.salaries.nom || '').toUpperCase().trim(); + const prenom = (contract.salaries.prenom || '').trim(); + return [nom, prenom].filter(Boolean).join(' '); + } + + if (contract.employee_name) { + return contract.employee_name; + } + + return "—"; +} + +export default function CreatePayslipModal({ + isOpen, + onClose, + onPayslipCreated, + preselectedContractId +}: CreatePayslipModalProps) { + const [searchQuery, setSearchQuery] = useState(""); + const [contracts, setContracts] = useState([]); + const [isSearching, setIsSearching] = useState(false); + const [selectedContract, setSelectedContract] = useState(null); + const [isCreating, setIsCreating] = useState(false); + const [isMultiMonth, setIsMultiMonth] = useState(false); + const [suggestedPayNumber, setSuggestedPayNumber] = useState(null); + const [hasExistingPayslip, setHasExistingPayslip] = useState(false); + const [existingPayslipMessage, setExistingPayslipMessage] = useState(""); + const [isCheckingExisting, setIsCheckingExisting] = useState(false); + const [isLoadingPreselected, setIsLoadingPreselected] = useState(false); + + // Form fields + const [periodStart, setPeriodStart] = useState(""); + const [periodEnd, setPeriodEnd] = useState(""); + const [payNumber, setPayNumber] = useState(""); + const [grossAmount, setGrossAmount] = useState(""); + const [netAmount, setNetAmount] = useState(""); + const [netAfterWithholding, setNetAfterWithholding] = useState(""); + const [employerCost, setEmployerCost] = useState(""); + + // Load preselected contract + useEffect(() => { + if (!isOpen || !preselectedContractId) return; + + const loadPreselectedContract = async () => { + setIsLoadingPreselected(true); + try { + // Fetch contract by ID directly + const response = await fetch(`/api/staff/contracts/${preselectedContractId}`); + if (!response.ok) { + throw new Error('Erreur lors du chargement du contrat'); + } + const data = await response.json(); + const contract = data.contract || data; + + // Set the selected contract + setSelectedContract(contract); + setSearchQuery(""); + setContracts([]); + setHasExistingPayslip(false); + setExistingPayslipMessage(""); + + // Déterminer si le contrat est multi-mois + if (contract.start_date && contract.end_date) { + const startDate = new Date(contract.start_date); + const endDate = new Date(contract.end_date); + const isMulti = startDate.getMonth() !== endDate.getMonth() || + startDate.getFullYear() !== endDate.getFullYear(); + setIsMultiMonth(isMulti); + + // Si multi-mois, récupérer le dernier numéro de paie + if (isMulti) { + try { + const payNumResponse = await fetch(`/api/staff/payslips/last-pay-number?contract_id=${contract.id}`); + if (payNumResponse.ok) { + const payNumData = await payNumResponse.json(); + const nextNumber = (payNumData.last_pay_number || 0) + 1; + setSuggestedPayNumber(nextNumber); + setPayNumber(String(nextNumber)); + } + } catch (err) { + console.error('Error fetching last pay number:', err); + } + } else { + // Si mono-mois, vérifier si une paie existe déjà + setIsCheckingExisting(true); + try { + const checkResponse = await fetch(`/api/staff/payslips/check-existing?contract_id=${contract.id}`); + if (checkResponse.ok) { + const checkData = await checkResponse.json(); + if (checkData.has_existing) { + setHasExistingPayslip(true); + setExistingPayslipMessage(checkData.message || "Une fiche de paie existe déjà pour ce contrat"); + } + } + } catch (err) { + console.error('Error checking existing payslip:', err); + } finally { + setIsCheckingExisting(false); + } + } + } + } catch (error) { + console.error('Error loading preselected contract:', error); + toast.error('Erreur lors du chargement du contrat'); + } finally { + setIsLoadingPreselected(false); + } + }; + + loadPreselectedContract(); + }, [isOpen, preselectedContractId]); + + // Search contracts + useEffect(() => { + if (!searchQuery.trim() || !isOpen) { + setContracts([]); + return; + } + + const timer = setTimeout(async () => { + setIsSearching(true); + try { + const response = await fetch(`/api/staff/payslips/search-contracts?q=${encodeURIComponent(searchQuery)}`); + if (!response.ok) { + throw new Error('Erreur lors de la recherche'); + } + const data = await response.json(); + setContracts(data.contracts || []); + } catch (error) { + console.error('Error searching contracts:', error); + toast.error('Erreur lors de la recherche des contrats'); + setContracts([]); + } finally { + setIsSearching(false); + } + }, 300); + + return () => clearTimeout(timer); + }, [searchQuery, isOpen]); + + // Reset form when modal closes + useEffect(() => { + if (!isOpen) { + setSearchQuery(""); + setContracts([]); + setSelectedContract(null); + setIsMultiMonth(false); + setSuggestedPayNumber(null); + setHasExistingPayslip(false); + setExistingPayslipMessage(""); + setIsCheckingExisting(false); + setPeriodStart(""); + setPeriodEnd(""); + setPayNumber(""); + setGrossAmount(""); + setNetAmount(""); + setNetAfterWithholding(""); + setEmployerCost(""); + } + }, [isOpen]); + + // Keyboard navigation + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + onClose(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [isOpen, onClose]); + + const handleSelectContract = (contract: Contract) => { + setSelectedContract(contract); + setSearchQuery(""); + setContracts([]); + setHasExistingPayslip(false); + setExistingPayslipMessage(""); + + // Déterminer si le contrat est multi-mois + if (contract.start_date && contract.end_date) { + const startDate = new Date(contract.start_date); + const endDate = new Date(contract.end_date); + const isMulti = startDate.getMonth() !== endDate.getMonth() || + startDate.getFullYear() !== endDate.getFullYear(); + setIsMultiMonth(isMulti); + + // Si multi-mois, récupérer le dernier numéro de paie + if (isMulti) { + fetchLastPayNumber(contract.id); + } else { + // Si mono-mois, vérifier si une paie existe déjà + checkExistingPayslip(contract.id); + } + } + }; + + const checkExistingPayslip = async (contractId: string) => { + setIsCheckingExisting(true); + try { + const response = await fetch(`/api/staff/payslips/check-existing?contract_id=${contractId}`); + if (response.ok) { + const data = await response.json(); + if (data.has_existing) { + setHasExistingPayslip(true); + setExistingPayslipMessage(data.message || "Une fiche de paie existe déjà pour ce contrat"); + } + } + } catch (error) { + console.error('Error checking existing payslip:', error); + } finally { + setIsCheckingExisting(false); + } + }; + + const fetchLastPayNumber = async (contractId: string) => { + try { + const response = await fetch(`/api/staff/payslips/last-pay-number?contract_id=${contractId}`); + if (response.ok) { + const data = await response.json(); + const nextNumber = (data.last_pay_number || 0) + 1; + setSuggestedPayNumber(nextNumber); + setPayNumber(String(nextNumber)); + } + } catch (error) { + console.error('Error fetching last pay number:', error); + } + }; + + const handleCreatePayslip = async (closeAfter: boolean = true) => { + if (!selectedContract) { + toast.error('Veuillez sélectionner un contrat'); + return; + } + + if (!periodStart || !periodEnd) { + toast.error('Veuillez renseigner les dates de période'); + return; + } + + if (isMultiMonth && !payNumber) { + toast.error('Veuillez renseigner le numéro de paie'); + return; + } + + setIsCreating(true); + try { + const response = await fetch('/api/staff/payslips/create', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + contract_id: selectedContract.id, + period_start: periodStart, + period_end: periodEnd, + pay_number: isMultiMonth && payNumber ? parseInt(payNumber) : undefined, + gross_amount: grossAmount ? parseFloat(grossAmount) : null, + net_amount: netAmount ? parseFloat(netAmount) : null, + net_after_withholding: netAfterWithholding ? parseFloat(netAfterWithholding) : null, + employer_cost: employerCost ? parseFloat(employerCost) : null, + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Erreur lors de la création'); + } + + toast.success('Fiche de paie créée avec succès'); + + if (closeAfter) { + onPayslipCreated?.(); + onClose(); + } else { + // Réinitialiser le formulaire mais garder le modal ouvert + onPayslipCreated?.(); + setSelectedContract(null); + setSearchQuery(""); + setContracts([]); + setIsMultiMonth(false); + setSuggestedPayNumber(null); + setHasExistingPayslip(false); + setExistingPayslipMessage(""); + setPeriodStart(""); + setPeriodEnd(""); + setPayNumber(""); + setGrossAmount(""); + setNetAmount(""); + setNetAfterWithholding(""); + setEmployerCost(""); + } + } catch (error) { + console.error('Error creating payslip:', error); + toast.error(error instanceof Error ? error.message : 'Erreur lors de la création de la fiche de paie'); + } finally { + setIsCreating(false); + } + }; + + if (!isOpen) return null; + + return ( +
+
+ {/* Header */} +
+
+ +

Créer une fiche de paie

+
+ + +
+ + {/* Content */} +
+ {isLoadingPreselected ? ( +
+
+ +

Chargement du contrat...

+
+
+ ) : !selectedContract ? ( +
+ +
+ + setSearchQuery(e.target.value)} + placeholder="Nom salarié, n° contrat, production, organisation..." + className="w-full pl-10 pr-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + autoFocus + /> + {isSearching && ( + + )} +
+ + {/* Search results */} + {contracts.length > 0 && ( +
+ {contracts.map((contract) => ( + + ))} +
+ )} + + {searchQuery.trim() && !isSearching && contracts.length === 0 && ( +
+ +

Aucun contrat trouvé pour cette recherche

+
+ )} +
+ ) : ( +
+ {/* Selected contract info */} +
+
+
+

+ {formatEmployeeName(selectedContract)} +

+
+ {selectedContract.contract_number && ( + N° {selectedContract.contract_number} + )} + {selectedContract.structure && ( + {selectedContract.structure} + )} + {selectedContract.type_de_contrat && ( + {selectedContract.type_de_contrat === "CDD d'usage" ? "CDDU" : selectedContract.type_de_contrat} + )} + {isMultiMonth && ( + + Contrat multi-mois + + )} +
+ {selectedContract.production_name && ( +
+ Production: {selectedContract.production_name} +
+ )} +
+ +
+
+ + {/* Alerte si une paie existe déjà */} + {isCheckingExisting && ( +
+ +

Vérification des paies existantes...

+
+ )} + + {hasExistingPayslip && ( +
+
+ +
+

+ Impossible de créer une paie +

+

+ {existingPayslipMessage} +

+
+
+
+ )} + + {/* Form fields */} + {!hasExistingPayslip && ( +
+ {/* Section Numéro de paie (si multi-mois) */} + {isMultiMonth && ( +
+

+ Numéro de paie +

+
+ + setPayNumber(e.target.value)} + className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent" + placeholder={suggestedPayNumber ? `Suggestion: ${suggestedPayNumber}` : "1"} + required + /> + {suggestedPayNumber && ( +

+ Numéro suggéré basé sur les paies existantes: {suggestedPayNumber} +

+ )} +
+
+ )} + + {/* Section Dates */} +
+

+ Période et dates +

+
+
+ + setPeriodStart(e.target.value)} + className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent" + required + /> +
+ +
+ + setPeriodEnd(e.target.value)} + className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent" + required + /> +
+
+
+ + {/* Section Montants */} +
+

+ Montants +

+
+
+ + setGrossAmount(e.target.value)} + placeholder="0.00" + className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent" + /> +
+ +
+ + setNetAmount(e.target.value)} + placeholder="0.00" + className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent" + /> +
+ +
+ + setNetAfterWithholding(e.target.value)} + placeholder="0.00" + className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent" + /> +
+ +
+ + setEmployerCost(e.target.value)} + placeholder="0.00" + className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent" + /> +
+
+
+
+ )} +
+ )} +
+ + {/* Footer */} +
+ + {selectedContract && !hasExistingPayslip && ( + <> + + + + + )} +
+
+
+ ); +} diff --git a/components/staff/MissingPayslipsModal.tsx b/components/staff/MissingPayslipsModal.tsx new file mode 100644 index 0000000..eb16592 --- /dev/null +++ b/components/staff/MissingPayslipsModal.tsx @@ -0,0 +1,194 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { X, FileText } from "lucide-react"; + +interface MissingContract { + id: string; + start_date: string; + end_date: string; + type_de_contrat: string; + n_objet?: string; + reference?: string; + employee_name?: string; + profession?: string; + production_name?: string; +} + +interface MissingPayslipsModalProps { + isOpen: boolean; + onClose: () => void; + structureId: string; + periodFrom: string; + periodTo: string; + onContractSelect: (contractId: string) => void; +} + +export default function MissingPayslipsModal({ + isOpen, + onClose, + structureId, + periodFrom, + periodTo, + onContractSelect, +}: MissingPayslipsModalProps) { + const [contracts, setContracts] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + if (!isOpen) return; + + const fetchMissingContracts = async () => { + setIsLoading(true); + try { + const params = new URLSearchParams({ + structure: structureId, + period_from: periodFrom, + period_to: periodTo, + }); + + const response = await fetch(`/api/staff/payslips/missing-stats?${params}`); + const data = await response.json(); + + if (response.ok && data.missing_contracts) { + setContracts(data.missing_contracts); + } + } catch (error) { + console.error("Error fetching missing contracts:", error); + } finally { + setIsLoading(false); + } + }; + + fetchMissingContracts(); + }, [isOpen, structureId, periodFrom, periodTo]); + + if (!isOpen) return null; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString('fr-FR', { + day: '2-digit', + month: '2-digit', + year: 'numeric' + }); + }; + + const handleContractClick = (contractId: string) => { + onContractSelect(contractId); + onClose(); + }; + + return ( +
+
+ {/* Header */} +
+

+ Contrats sans fiche de paie +

+ +
+ + {/* Content */} +
+ {isLoading ? ( +
+
+
+ ) : contracts.length === 0 ? ( +
+

Aucun contrat sans fiche de paie

+
+ ) : ( +
+ {contracts.map((contract) => { + const startDate = new Date(contract.start_date); + const endDate = new Date(contract.end_date); + const isMultiMonth = startDate.getMonth() !== endDate.getMonth() || + startDate.getFullYear() !== endDate.getFullYear(); + + return ( + + ); + })} +
+ )} +
+ + {/* Footer */} +
+
+

+ {contracts.length} contrat{contracts.length > 1 ? 's' : ''} sans fiche de paie +

+ +
+
+
+
+ ); +} diff --git a/components/staff/PayslipDetailsModal.tsx b/components/staff/PayslipDetailsModal.tsx index cd96722..b4e6276 100644 --- a/components/staff/PayslipDetailsModal.tsx +++ b/components/staff/PayslipDetailsModal.tsx @@ -510,23 +510,65 @@ export default function PayslipDetailsModal({

-
- -

- {formatDate(payslipDetails.period_start)} - {formatDate(payslipDetails.period_end)} -

-
- -
- -

- {formatDate(payslipDetails.pay_date)} -

-
+ {!isEditMode ? ( + <> +
+ +

+ {formatDate(payslipDetails.period_start)} - {formatDate(payslipDetails.period_end)} +

+
+ +
+ +

+ {formatDate(payslipDetails.pay_date)} +

+
+ + ) : ( + <> +
+ + setFormData({...formData, period_start: e.target.value})} + className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent" + /> +
+ +
+ + setFormData({...formData, period_end: e.target.value})} + className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent" + /> +
+ +
+ + setFormData({...formData, pay_date: e.target.value})} + className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent" + /> +
+ + )} diff --git a/components/staff/PayslipsGrid.tsx b/components/staff/PayslipsGrid.tsx index 6105ab6..65a440b 100644 --- a/components/staff/PayslipsGrid.tsx +++ b/components/staff/PayslipsGrid.tsx @@ -3,11 +3,13 @@ import { useEffect, useMemo, useState, useRef } from "react"; import { supabase } from "@/lib/supabaseClient"; import Link from "next/link"; -import { RefreshCw, Check, X, Eye } from "lucide-react"; +import { RefreshCw, Check, X, Eye, Plus, CheckCircle2 } from "lucide-react"; import { toast } from "sonner"; import BulkPayslipUploadModal from "./payslips/BulkPayslipUploadModal"; import PayslipDetailsModal from "./PayslipDetailsModal"; import PayslipPdfVerificationModal from "./PayslipPdfVerificationModal"; +import CreatePayslipModal from "./CreatePayslipModal"; +import MissingPayslipsModal from "./MissingPayslipsModal"; // Utility function to format dates as DD/MM/YYYY function formatDate(dateString: string | null | undefined): string { @@ -199,6 +201,7 @@ export default function PayslipsGrid({ initialData, activeOrgId }: { initialData const [isDeleting, setIsDeleting] = useState(false); const [showBulkUploadModal, setShowBulkUploadModal] = useState(false); const [showDetailsModal, setShowDetailsModal] = useState(false); + const [showCreateModal, setShowCreateModal] = useState(false); const [payslipDetailsIds, setPayslipDetailsIds] = useState([]); const [showPdfVerificationModal, setShowPdfVerificationModal] = useState(false); const [payslipsPdfs, setPayslipsPdfs] = useState>([]); const [isLoadingPdfs, setIsLoadingPdfs] = useState(false); + + // Stats for missing payslips + const [missingPayslipsCount, setMissingPayslipsCount] = useState(null); + const [isLoadingStats, setIsLoadingStats] = useState(false); + const [showMissingPayslipsModal, setShowMissingPayslipsModal] = useState(false); + const [preselectedContractId, setPreselectedContractId] = useState(null); // Handler pour mettre à jour une paie après édition const handlePayslipUpdated = (updatedPayslip: any) => { @@ -337,6 +346,68 @@ export default function PayslipsGrid({ initialData, activeOrgId }: { initialData saveFiltersToStorage(filters); }, [q, structureFilter, typeFilter, processedFilter, transferFilter, aemFilter, periodFrom, periodTo, sortField, sortOrder, showFilters]); + // Fetch missing payslips statistics when structure and period filters change + useEffect(() => { + const fetchMissingStats = async () => { + if (!structureFilter || !periodFrom || !periodTo) { + setMissingPayslipsCount(null); + return; + } + + setIsLoadingStats(true); + try { + const params = new URLSearchParams({ + structure: structureFilter, + period_from: periodFrom, + period_to: periodTo, + }); + + const response = await fetch(`/api/staff/payslips/missing-stats?${params}`); + const data = await response.json(); + + if (!response.ok) { + console.error("Error fetching missing stats:", data.error); + setMissingPayslipsCount(null); + return; + } + + setMissingPayslipsCount(data.missing_count || 0); + } catch (error) { + console.error("Error fetching missing stats:", error); + setMissingPayslipsCount(null); + } finally { + setIsLoadingStats(false); + } + }; + + fetchMissingStats(); + }, [structureFilter, periodFrom, periodTo]); + + // Create a callback ref to refetch stats (for use in realtime subscription) + const refetchStatsRef = useRef<() => void>(); + useEffect(() => { + refetchStatsRef.current = async () => { + if (!structureFilter || !periodFrom || !periodTo) return; + + try { + const params = new URLSearchParams({ + structure: structureFilter, + period_from: periodFrom, + period_to: periodTo, + }); + + const response = await fetch(`/api/staff/payslips/missing-stats?${params}`); + const data = await response.json(); + + if (response.ok) { + setMissingPayslipsCount(data.missing_count || 0); + } + } catch (error) { + console.error("Error refetching missing stats:", error); + } + }; + }, [structureFilter, periodFrom, periodTo]); + // Realtime subscription: listen to INSERT / UPDATE / DELETE on payslips useEffect(() => { try { @@ -365,11 +436,15 @@ export default function PayslipsGrid({ initialData, activeOrgId }: { initialData if (rs.find((r) => r.id === newRec.id)) return rs; return [newRec, ...rs]; }); + // Refetch stats when a new payslip is created + refetchStatsRef.current?.(); } else if (event === "UPDATE") { setRows((rs) => rs.map((r) => (r.id === record.id ? { ...r, ...(record as Payslip) } : r))); } else if (event === "DELETE") { const id = record?.id ?? payload.old?.id; if (id) setRows((rs) => rs.filter((r) => r.id !== id)); + // Refetch stats when a payslip is deleted + refetchStatsRef.current?.(); } } catch (err) { console.error("Realtime handler error", err); @@ -584,6 +659,15 @@ export default function PayslipsGrid({ initialData, activeOrgId }: { initialData Actualiser + + + )} + + ) : null} + + )} {/* Boutons d'actions groupées */} @@ -1114,6 +1248,37 @@ export default function PayslipsGrid({ initialData, activeOrgId }: { initialData onPayslipUpdated={handlePayslipUpdated} /> + {/* Modal Créer une paie */} + { + setShowCreateModal(false); + setPreselectedContractId(null); + }} + onPayslipCreated={() => { + // Rafraîchir les données + fetchServer(page); + // Rafraîchir les stats aussi + refetchStatsRef.current?.(); + }} + preselectedContractId={preselectedContractId} + /> + + {/* Modal Liste des contrats sans paie */} + {showMissingPayslipsModal && structureFilter && periodFrom && periodTo && ( + setShowMissingPayslipsModal(false)} + structureId={structureFilter} + periodFrom={periodFrom} + periodTo={periodTo} + onContractSelect={(contractId) => { + setPreselectedContractId(contractId); + setShowCreateModal(true); + }} + /> + )} + {/* Modal de vérification des PDFs */}