feat: Améliorations majeures des contrats et fiches de paie

- 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
This commit is contained in:
odentas 2025-11-27 20:31:11 +01:00
parent 8edb624330
commit dd570d4509
14 changed files with 1917 additions and 34 deletions

View file

@ -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,

View file

@ -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 {

View file

@ -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 });
}
}

View file

@ -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 });
}
}

View file

@ -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 });
}
}

View file

@ -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<string>();
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 });
}
}

View file

@ -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 });
}
}

View file

@ -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;

View file

@ -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 */}
<div className="grid grid-cols-2 gap-6 mt-6 pt-6 border-t">
<h3 className="col-span-2 text-base font-semibold text-gray-800 mb-2">Détails du contrat</h3>
{/* Colonne 1 */}
<div className="space-y-4">
{/* Nombre de cachets */}
{/* Nombre de cachets (représentations) */}
{contractDetails.cachets_representations !== null && contractDetails.cachets_representations !== undefined && (
<div>
<label className="text-xs font-semibold text-gray-600 uppercase">Nombre de cachets</label>
<label className="text-xs font-semibold text-gray-600 uppercase">Cachets (Représentations)</label>
<p className="text-sm text-gray-900">{contractDetails.cachets_representations}</p>
</div>
)}
{/* Nombre d'heures */}
{/* Dates des représentations */}
{contractDetails.jours_representations && (
<div>
<label className="text-xs font-semibold text-gray-600 uppercase">Jours représentations</label>
<p className="text-sm text-gray-900 whitespace-pre-wrap">{contractDetails.jours_representations}</p>
</div>
)}
{/* Nombre de services (répétitions) */}
{contractDetails.services_repetitions !== null && contractDetails.services_repetitions !== undefined && (
<div>
<label className="text-xs font-semibold text-gray-600 uppercase">Services (Répétitions)</label>
<p className="text-sm text-gray-900">{contractDetails.services_repetitions}</p>
</div>
)}
{/* Dates des répétitions */}
{contractDetails.jours_repetitions && (
<div>
<label className="text-xs font-semibold text-gray-600 uppercase">Jours répétitions</label>
<p className="text-sm text-gray-900 whitespace-pre-wrap">{contractDetails.jours_repetitions}</p>
</div>
)}
{/* Nombre d'heures total */}
{contractDetails.nombre_d_heures !== null && contractDetails.nombre_d_heures !== undefined && (
<div>
<label className="text-xs font-semibold text-gray-600 uppercase">Nombre d'heures</label>
<p className="text-sm text-gray-900">{contractDetails.nombre_d_heures}h</p>
<label className="text-xs font-semibold text-gray-600 uppercase">Nombre d'heures total</label>
<p className="text-sm text-gray-900">
{contractDetails.nombre_d_heures}h
{contractDetails.minutes_total && contractDetails.minutes_total !== "0" && (
<span>{contractDetails.minutes_total}</span>
)}
</p>
</div>
)}
@ -428,7 +464,7 @@ export default function ContractDetailsModal({
{contractDetails.jours_travail && (
<div>
<label className="text-xs font-semibold text-gray-600 uppercase">Jours travaillés</label>
<p className="text-sm text-gray-900">{contractDetails.jours_travail}</p>
<p className="text-sm text-gray-900 whitespace-pre-wrap">{contractDetails.jours_travail}</p>
</div>
)}
@ -449,6 +485,16 @@ export default function ContractDetailsModal({
)}
</div>
</div>
{/* Section Notes */}
{contractDetails.notes && (
<div className="mt-6 pt-6 border-t">
<label className="text-xs font-semibold text-gray-600 uppercase">Notes</label>
<div className="mt-2 p-3 bg-amber-50 border border-amber-200 rounded-lg">
<p className="text-sm text-gray-900 whitespace-pre-wrap">{contractDetails.notes}</p>
</div>
</div>
)}
</div>
) : null}

View file

@ -284,7 +284,7 @@ function ContractsGridImpl({ initialData, activeOrgId }: { initialData: Contract
const [sortField, setSortField] = useState<string>(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<number | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);

View file

@ -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<Contract[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [selectedContract, setSelectedContract] = useState<Contract | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [isMultiMonth, setIsMultiMonth] = useState(false);
const [suggestedPayNumber, setSuggestedPayNumber] = useState<number | null>(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 (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg w-full max-w-4xl max-h-[90vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b bg-gray-50">
<div className="flex items-center gap-3">
<FileText className="size-5 text-blue-600" />
<h2 className="text-lg font-semibold">Créer une fiche de paie</h2>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
>
<X className="size-5" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
{isLoadingPreselected ? (
<div className="flex items-center justify-center py-12">
<div className="text-center">
<Loader className="w-8 h-8 text-blue-600 animate-spin mx-auto mb-3" />
<p className="text-sm text-gray-600">Chargement du contrat...</p>
</div>
</div>
) : !selectedContract ? (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Rechercher un contrat
</label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-gray-400" />
<input
type="text"
value={searchQuery}
onChange={(e) => 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 && (
<Loader className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-gray-400 animate-spin" />
)}
</div>
{/* Search results */}
{contracts.length > 0 && (
<div className="mt-3 border rounded-lg overflow-hidden max-h-96 overflow-y-auto">
{contracts.map((contract) => (
<button
key={contract.id}
onClick={() => handleSelectContract(contract)}
className="w-full text-left p-4 hover:bg-blue-50 border-b last:border-b-0 transition-colors"
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-900">
{formatEmployeeName(contract)}
</div>
<div className="text-sm text-gray-600 mt-1">
{contract.contract_number && (
<span className="mr-3">N° {contract.contract_number}</span>
)}
{contract.structure && (
<span className="mr-3">{contract.structure}</span>
)}
{contract.organizations?.name && (
<span className="mr-3">{contract.organizations.name}</span>
)}
</div>
{contract.production_name && (
<div className="text-sm text-gray-500 mt-1">
Production: {contract.production_name}
</div>
)}
{(contract.n_objet || contract.objet_spectacle) && (
<div className="text-sm text-gray-500">
N° Objet: {contract.n_objet || contract.objet_spectacle}
</div>
)}
</div>
<div className="text-sm text-gray-500 whitespace-nowrap">
{formatDate(contract.start_date)} - {formatDate(contract.end_date)}
</div>
</div>
</button>
))}
</div>
)}
{searchQuery.trim() && !isSearching && contracts.length === 0 && (
<div className="mt-3 p-4 bg-amber-50 border border-amber-200 rounded-lg text-sm text-amber-800 flex items-start gap-2">
<AlertCircle className="size-4 mt-0.5 flex-shrink-0" />
<p>Aucun contrat trouvé pour cette recherche</p>
</div>
)}
</div>
) : (
<div>
{/* Selected contract info */}
<div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<h3 className="font-medium text-gray-900">
{formatEmployeeName(selectedContract)}
</h3>
<div className="text-sm text-gray-600 mt-1">
{selectedContract.contract_number && (
<span className="mr-3">N° {selectedContract.contract_number}</span>
)}
{selectedContract.structure && (
<span className="mr-3">{selectedContract.structure}</span>
)}
{selectedContract.type_de_contrat && (
<span className="mr-3">{selectedContract.type_de_contrat === "CDD d'usage" ? "CDDU" : selectedContract.type_de_contrat}</span>
)}
{isMultiMonth && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-indigo-100 text-indigo-800">
Contrat multi-mois
</span>
)}
</div>
{selectedContract.production_name && (
<div className="text-sm text-gray-600 mt-1">
Production: {selectedContract.production_name}
</div>
)}
</div>
<button
onClick={() => setSelectedContract(null)}
className="text-sm text-blue-600 hover:text-blue-800"
>
Changer
</button>
</div>
</div>
{/* Alerte si une paie existe déjà */}
{isCheckingExisting && (
<div className="mb-4 p-4 bg-gray-50 border border-gray-200 rounded-lg flex items-center gap-2">
<Loader className="size-4 animate-spin text-gray-600" />
<p className="text-sm text-gray-600">Vérification des paies existantes...</p>
</div>
)}
{hasExistingPayslip && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg">
<div className="flex items-start gap-2">
<AlertCircle className="size-5 text-red-600 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<p className="text-sm font-medium text-red-800">
Impossible de créer une paie
</p>
<p className="text-sm text-red-700 mt-1">
{existingPayslipMessage}
</p>
</div>
</div>
</div>
)}
{/* Form fields */}
{!hasExistingPayslip && (
<div className="space-y-6">
{/* Section Numéro de paie (si multi-mois) */}
{isMultiMonth && (
<div className="space-y-4">
<h3 className="text-lg font-semibold text-slate-900 border-b pb-2">
Numéro de paie
</h3>
<div>
<label className="block text-sm font-medium text-slate-600 mb-1">
N° de paie <span className="text-red-500">*</span>
</label>
<input
type="number"
min="1"
value={payNumber}
onChange={(e) => 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 && (
<p className="text-xs text-slate-500 mt-1">
Numéro suggéré basé sur les paies existantes: {suggestedPayNumber}
</p>
)}
</div>
</div>
)}
{/* Section Dates */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-slate-900 border-b pb-2">
Période et dates
</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-600 mb-1">
Date début période <span className="text-red-500">*</span>
</label>
<input
type="date"
value={periodStart}
onChange={(e) => 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
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-600 mb-1">
Date fin période <span className="text-red-500">*</span>
</label>
<input
type="date"
value={periodEnd}
onChange={(e) => 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
/>
</div>
</div>
</div>
{/* Section Montants */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-slate-900 border-b pb-2">
Montants
</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-600 mb-1">
Salaire brut
</label>
<input
type="number"
step="0.01"
value={grossAmount}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-600 mb-1">
Net avant PAS
</label>
<input
type="number"
step="0.01"
value={netAmount}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-600 mb-1">
Net à payer
</label>
<input
type="number"
step="0.01"
value={netAfterWithholding}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-600 mb-1">
Coût employeur
</label>
<input
type="number"
step="0.01"
value={employerCost}
onChange={(e) => 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"
/>
</div>
</div>
</div>
</div>
)}
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 p-4 border-t bg-gray-50">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50"
disabled={isCreating}
>
Annuler
</button>
{selectedContract && !hasExistingPayslip && (
<>
<button
onClick={() => handleCreatePayslip(false)}
disabled={isCreating || !periodStart || !periodEnd || isCheckingExisting}
className="px-4 py-2 text-sm font-medium text-blue-600 bg-white border border-blue-600 rounded-lg hover:bg-blue-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{isCreating && <Loader className="size-4 animate-spin" />}
Créer et ajouter une autre paie
</button>
<button
onClick={() => handleCreatePayslip(true)}
disabled={isCreating || !periodStart || !periodEnd || isCheckingExisting}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{isCreating && <Loader className="size-4 animate-spin" />}
Créer et fermer
</button>
</>
)}
</div>
</div>
</div>
);
}

View file

@ -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<MissingContract[]>([]);
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-4xl max-h-[80vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b">
<h2 className="text-xl font-semibold text-slate-900">
Contrats sans fiche de paie
</h2>
<button
onClick={onClose}
className="text-slate-400 hover:text-slate-600 transition-colors"
>
<X className="w-6 h-6" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-indigo-600 border-t-transparent"></div>
</div>
) : contracts.length === 0 ? (
<div className="text-center py-12">
<p className="text-slate-600">Aucun contrat sans fiche de paie</p>
</div>
) : (
<div className="space-y-3">
{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 (
<button
key={contract.id}
onClick={() => handleContractClick(contract.id)}
className="w-full text-left p-4 border rounded-lg hover:bg-slate-50 hover:border-indigo-300 transition-colors group"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<FileText className="w-4 h-4 text-slate-400 group-hover:text-indigo-600" />
<span className="font-medium text-slate-900">
{contract.reference || 'Référence non définie'}
</span>
<span className={`text-xs px-2 py-0.5 rounded ${
isMultiMonth
? 'bg-purple-100 text-purple-700'
: 'bg-blue-100 text-blue-700'
}`}>
{isMultiMonth ? 'Multi-mois' : 'Mono-mois'}
</span>
<span className="text-xs px-2 py-0.5 rounded bg-slate-100 text-slate-700">
{contract.type_de_contrat}
</span>
</div>
<div className="text-sm text-slate-600">
<span className="font-medium">Période :</span> {formatDate(contract.start_date)} {formatDate(contract.end_date)}
</div>
{contract.employee_name && (
<div className="text-sm text-slate-600 mt-1">
<span className="font-medium">Salarié :</span> {contract.employee_name}
</div>
)}
<div className="flex gap-4 mt-1 text-sm text-slate-600">
{contract.profession && (
<div>
<span className="font-medium">Profession :</span> {contract.profession}
</div>
)}
{contract.production_name && (
<div>
<span className="font-medium">Production :</span> {contract.production_name}
</div>
)}
</div>
</div>
<div className="ml-4">
<span className="text-sm text-indigo-600 group-hover:text-indigo-700 font-medium">
Créer une paie
</span>
</div>
</div>
</button>
);
})}
</div>
)}
</div>
{/* Footer */}
<div className="p-6 border-t bg-slate-50">
<div className="flex items-center justify-between">
<p className="text-sm text-slate-600">
{contracts.length} contrat{contracts.length > 1 ? 's' : ''} sans fiche de paie
</p>
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50"
>
Fermer
</button>
</div>
</div>
</div>
</div>
);
}

View file

@ -510,6 +510,8 @@ export default function PayslipDetailsModal({
</p>
</div>
{!isEditMode ? (
<>
<div>
<label className="block text-sm font-medium text-slate-600 mb-1">
Période
@ -527,6 +529,46 @@ export default function PayslipDetailsModal({
{formatDate(payslipDetails.pay_date)}
</p>
</div>
</>
) : (
<>
<div>
<label className="block text-sm font-medium text-slate-600 mb-1">
Date début période
</label>
<input
type="date"
value={formData.period_start}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-600 mb-1">
Date fin période
</label>
<input
type="date"
value={formData.period_end}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-600 mb-1">
Date de paiement
</label>
<input
type="date"
value={formData.pay_date}
onChange={(e) => 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"
/>
</div>
</>
)}
</div>
</div>

View file

@ -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<string[]>([]);
const [showPdfVerificationModal, setShowPdfVerificationModal] = useState(false);
const [payslipsPdfs, setPayslipsPdfs] = useState<Array<{
@ -211,6 +214,12 @@ export default function PayslipsGrid({ initialData, activeOrgId }: { initialData
}>>([]);
const [isLoadingPdfs, setIsLoadingPdfs] = useState(false);
// Stats for missing payslips
const [missingPayslipsCount, setMissingPayslipsCount] = useState<number | null>(null);
const [isLoadingStats, setIsLoadingStats] = useState(false);
const [showMissingPayslipsModal, setShowMissingPayslipsModal] = useState(false);
const [preselectedContractId, setPreselectedContractId] = useState<string | null>(null);
// Handler pour mettre à jour une paie après édition
const handlePayslipUpdated = (updatedPayslip: any) => {
setRows((currentRows) =>
@ -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
</button>
<button
onClick={() => setShowCreateModal(true)}
className="inline-flex items-center gap-1 text-sm text-white bg-blue-600 hover:bg-blue-700 px-3 py-1 rounded border border-blue-600"
title="Créer une nouvelle fiche de paie"
>
<Plus className="w-4 h-4" />
Créer une paie
</button>
<button
className="rounded border px-3 py-1 text-sm"
onClick={() => {
@ -686,6 +770,56 @@ export default function PayslipsGrid({ initialData, activeOrgId }: { initialData
</div>
</div>
)}
{/* Card showing missing payslips statistics */}
{structureFilter && periodFrom && periodTo && (
<div className={`mt-4 p-4 border rounded-lg ${
missingPayslipsCount === 0
? 'bg-emerald-50 border-emerald-200'
: 'bg-amber-50 border-amber-200'
}`}>
{isLoadingStats ? (
<div className="flex items-center gap-2 text-sm text-amber-800">
<div className="animate-spin rounded-full h-4 w-4 border-2 border-amber-600 border-t-transparent"></div>
<span>Calcul en cours...</span>
</div>
) : missingPayslipsCount !== null ? (
<div className="flex items-center justify-between gap-3">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 mt-0.5">
{missingPayslipsCount === 0 ? (
<CheckCircle2 className="w-5 h-5 text-emerald-600" />
) : (
<svg className="w-5 h-5 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
)}
</div>
<p className={`text-sm font-medium ${
missingPayslipsCount === 0
? 'text-emerald-900'
: 'text-amber-900'
}`}>
{missingPayslipsCount === 0 ? (
<>Tous les contrats de cette période ont au moins une fiche de paie</>
) : (
<>{missingPayslipsCount} contrat{missingPayslipsCount > 1 ? 's' : ''} sans fiche de paie pour cette période</>
)}
</p>
</div>
{missingPayslipsCount > 0 && (
<button
onClick={() => setShowMissingPayslipsModal(true)}
className="flex-shrink-0 inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-amber-900 bg-amber-100 hover:bg-amber-200 border border-amber-300 rounded-lg transition-colors"
>
<Eye className="w-4 h-4" />
Voir la liste
</button>
)}
</div>
) : null}
</div>
)}
</div>
{/* Boutons d'actions groupées */}
@ -1114,6 +1248,37 @@ export default function PayslipsGrid({ initialData, activeOrgId }: { initialData
onPayslipUpdated={handlePayslipUpdated}
/>
{/* Modal Créer une paie */}
<CreatePayslipModal
isOpen={showCreateModal}
onClose={() => {
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 && (
<MissingPayslipsModal
isOpen={showMissingPayslipsModal}
onClose={() => setShowMissingPayslipsModal(false)}
structureId={structureFilter}
periodFrom={periodFrom}
periodTo={periodTo}
onContractSelect={(contractId) => {
setPreselectedContractId(contractId);
setShowCreateModal(true);
}}
/>
)}
{/* Modal de vérification des PDFs */}
<PayslipPdfVerificationModal
isOpen={showPdfVerificationModal}