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:
parent
8edb624330
commit
dd570d4509
14 changed files with 1917 additions and 34 deletions
|
|
@ -239,7 +239,11 @@ export async function POST(
|
||||||
.eq("org_id", contract.org_id)
|
.eq("org_id", contract.org_id)
|
||||||
.single();
|
.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) {
|
if (orgError || !orgDetails) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|
@ -446,6 +450,14 @@ export async function POST(
|
||||||
imageUrl: orgDetails.logo || ""
|
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 = {
|
const pdfPayload = {
|
||||||
document_template_id: "736E1A5F-BBA1-4D3E-91ED-A6184479B58D",
|
document_template_id: "736E1A5F-BBA1-4D3E-91ED-A6184479B58D",
|
||||||
payload: dataPayload,
|
payload: dataPayload,
|
||||||
|
|
|
||||||
|
|
@ -45,12 +45,9 @@ export async function GET(req: Request) {
|
||||||
organizations!org_id(organization_details(code_employeur))
|
organizations!org_id(organization_details(code_employeur))
|
||||||
`, { count: "exact" });
|
`, { count: "exact" });
|
||||||
|
|
||||||
if (q) {
|
// Appliquer d'abord les filtres exacts (non-OR) pour éviter les conflits
|
||||||
// simple ilike search on a few columns
|
|
||||||
query = query.or(`contract_number.ilike.%${q}%,employee_name.ilike.%${q}%,employee_matricule.ilike.%${q}%`);
|
|
||||||
}
|
|
||||||
if (employee_matricule) query = query.eq("employee_matricule", employee_matricule);
|
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);
|
if (production_name) query = query.eq("production_name", production_name);
|
||||||
|
|
||||||
// Handle special "RG" filter for common law contracts (CDD de droit commun + CDI)
|
// 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_from) query = query.gte("end_date", end_from);
|
||||||
if (end_to) query = query.lte("end_date", end_to);
|
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
|
// 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 allowedSorts = new Set(["start_date", "end_date", "created_at", "contract_number", "employee_name", "production_name"]);
|
||||||
const sortCol = allowedSorts.has(sort) ? sort : "created_at";
|
const sortCol = allowedSorts.has(sort) ? sort : "created_at";
|
||||||
|
|
@ -117,13 +120,10 @@ export async function GET(req: Request) {
|
||||||
salariesMap.set(s.id, s.nom);
|
salariesMap.set(s.id, s.nom);
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("DEBUG TRI - Mapping salaries:", Array.from(salariesMap.entries()));
|
|
||||||
|
|
||||||
// Trier les contrats par nom de famille
|
// Trier les contrats par nom de famille
|
||||||
const sortedContracts = contractsData.sort((a, b) => {
|
const sortedContracts = contractsData.sort((a, b) => {
|
||||||
const nomA = salariesMap.get(a.employee_id) || '';
|
const nomA = salariesMap.get(a.employee_id) || '';
|
||||||
const nomB = salariesMap.get(b.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") {
|
if (order === "asc") {
|
||||||
return nomA.localeCompare(nomB);
|
return nomA.localeCompare(nomB);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
100
app/api/staff/payslips/check-existing/route.ts
Normal file
100
app/api/staff/payslips/check-existing/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
182
app/api/staff/payslips/create/route.ts
Normal file
182
app/api/staff/payslips/create/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
53
app/api/staff/payslips/last-pay-number/route.ts
Normal file
53
app/api/staff/payslips/last-pay-number/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
143
app/api/staff/payslips/missing-stats/route.ts
Normal file
143
app/api/staff/payslips/missing-stats/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
211
app/api/staff/payslips/search-contracts/route.ts
Normal file
211
app/api/staff/payslips/search-contracts/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
44
check_structure_mismatch.sql
Normal file
44
check_structure_mismatch.sql
Normal 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;
|
||||||
|
|
@ -21,11 +21,16 @@ type ContractDetails = {
|
||||||
contrat_signe_par_employeur?: string;
|
contrat_signe_par_employeur?: string;
|
||||||
contrat_signe?: string;
|
contrat_signe?: string;
|
||||||
date_signature?: string;
|
date_signature?: string;
|
||||||
|
notes?: string | null;
|
||||||
// Nouveaux champs pour détails du contrat
|
// Nouveaux champs pour détails du contrat
|
||||||
cachets_representations?: number | null;
|
cachets_representations?: number | null;
|
||||||
|
services_repetitions?: number | null;
|
||||||
nombre_d_heures?: number | null;
|
nombre_d_heures?: number | null;
|
||||||
nombre_d_heures_par_jour?: number | null;
|
nombre_d_heures_par_jour?: number | null;
|
||||||
|
minutes_total?: string | null;
|
||||||
jours_travail?: string | null;
|
jours_travail?: string | null;
|
||||||
|
jours_representations?: string | null;
|
||||||
|
jours_repetitions?: string | null;
|
||||||
precisions_salaire?: string | null;
|
precisions_salaire?: string | null;
|
||||||
profession?: string | null;
|
profession?: string | null;
|
||||||
categorie_pro?: string | null;
|
categorie_pro?: string | null;
|
||||||
|
|
@ -395,21 +400,52 @@ export default function ContractDetailsModal({
|
||||||
|
|
||||||
{/* Deuxième grille: Détails des jours/heures/cachets */}
|
{/* Deuxième grille: Détails des jours/heures/cachets */}
|
||||||
<div className="grid grid-cols-2 gap-6 mt-6 pt-6 border-t">
|
<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 */}
|
{/* Colonne 1 */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Nombre de cachets */}
|
{/* Nombre de cachets (représentations) */}
|
||||||
{contractDetails.cachets_representations !== null && contractDetails.cachets_representations !== undefined && (
|
{contractDetails.cachets_representations !== null && contractDetails.cachets_representations !== undefined && (
|
||||||
<div>
|
<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>
|
<p className="text-sm text-gray-900">{contractDetails.cachets_representations}</p>
|
||||||
</div>
|
</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 && (
|
{contractDetails.nombre_d_heures !== null && contractDetails.nombre_d_heures !== undefined && (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-semibold text-gray-600 uppercase">Nombre d'heures</label>
|
<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</p>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -428,7 +464,7 @@ export default function ContractDetailsModal({
|
||||||
{contractDetails.jours_travail && (
|
{contractDetails.jours_travail && (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-semibold text-gray-600 uppercase">Jours travaillés</label>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -449,6 +485,16 @@ export default function ContractDetailsModal({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -284,7 +284,7 @@ function ContractsGridImpl({ initialData, activeOrgId }: { initialData: Contract
|
||||||
const [sortField, setSortField] = useState<string>(savedFilters?.sortField || "start_date");
|
const [sortField, setSortField] = useState<string>(savedFilters?.sortField || "start_date");
|
||||||
const [sortOrder, setSortOrder] = useState<'asc'|'desc'>(savedFilters?.sortOrder || 'desc');
|
const [sortOrder, setSortOrder] = useState<'asc'|'desc'>(savedFilters?.sortOrder || 'desc');
|
||||||
const [page, setPage] = useState(0);
|
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 [showFilters, setShowFilters] = useState(savedFilters?.showFilters || false);
|
||||||
const totalCountRef = useRef<number | null>(null);
|
const totalCountRef = useRef<number | null>(null);
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
|
|
||||||
691
components/staff/CreatePayslipModal.tsx
Normal file
691
components/staff/CreatePayslipModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
194
components/staff/MissingPayslipsModal.tsx
Normal file
194
components/staff/MissingPayslipsModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -510,23 +510,65 @@ export default function PayslipDetailsModal({
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{!isEditMode ? (
|
||||||
<label className="block text-sm font-medium text-slate-600 mb-1">
|
<>
|
||||||
Période
|
<div>
|
||||||
</label>
|
<label className="block text-sm font-medium text-slate-600 mb-1">
|
||||||
<p className="text-base text-slate-900">
|
Période
|
||||||
{formatDate(payslipDetails.period_start)} - {formatDate(payslipDetails.period_end)}
|
</label>
|
||||||
</p>
|
<p className="text-base text-slate-900">
|
||||||
</div>
|
{formatDate(payslipDetails.period_start)} - {formatDate(payslipDetails.period_end)}
|
||||||
|
</p>
|
||||||
<div>
|
</div>
|
||||||
<label className="block text-sm font-medium text-slate-600 mb-1">
|
|
||||||
Date de paiement
|
<div>
|
||||||
</label>
|
<label className="block text-sm font-medium text-slate-600 mb-1">
|
||||||
<p className="text-base text-slate-900">
|
Date de paiement
|
||||||
{formatDate(payslipDetails.pay_date)}
|
</label>
|
||||||
</p>
|
<p className="text-base text-slate-900">
|
||||||
</div>
|
{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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,13 @@
|
||||||
import { useEffect, useMemo, useState, useRef } from "react";
|
import { useEffect, useMemo, useState, useRef } from "react";
|
||||||
import { supabase } from "@/lib/supabaseClient";
|
import { supabase } from "@/lib/supabaseClient";
|
||||||
import Link from "next/link";
|
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 { toast } from "sonner";
|
||||||
import BulkPayslipUploadModal from "./payslips/BulkPayslipUploadModal";
|
import BulkPayslipUploadModal from "./payslips/BulkPayslipUploadModal";
|
||||||
import PayslipDetailsModal from "./PayslipDetailsModal";
|
import PayslipDetailsModal from "./PayslipDetailsModal";
|
||||||
import PayslipPdfVerificationModal from "./PayslipPdfVerificationModal";
|
import PayslipPdfVerificationModal from "./PayslipPdfVerificationModal";
|
||||||
|
import CreatePayslipModal from "./CreatePayslipModal";
|
||||||
|
import MissingPayslipsModal from "./MissingPayslipsModal";
|
||||||
|
|
||||||
// Utility function to format dates as DD/MM/YYYY
|
// Utility function to format dates as DD/MM/YYYY
|
||||||
function formatDate(dateString: string | null | undefined): string {
|
function formatDate(dateString: string | null | undefined): string {
|
||||||
|
|
@ -199,6 +201,7 @@ export default function PayslipsGrid({ initialData, activeOrgId }: { initialData
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
const [showBulkUploadModal, setShowBulkUploadModal] = useState(false);
|
const [showBulkUploadModal, setShowBulkUploadModal] = useState(false);
|
||||||
const [showDetailsModal, setShowDetailsModal] = useState(false);
|
const [showDetailsModal, setShowDetailsModal] = useState(false);
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
const [payslipDetailsIds, setPayslipDetailsIds] = useState<string[]>([]);
|
const [payslipDetailsIds, setPayslipDetailsIds] = useState<string[]>([]);
|
||||||
const [showPdfVerificationModal, setShowPdfVerificationModal] = useState(false);
|
const [showPdfVerificationModal, setShowPdfVerificationModal] = useState(false);
|
||||||
const [payslipsPdfs, setPayslipsPdfs] = useState<Array<{
|
const [payslipsPdfs, setPayslipsPdfs] = useState<Array<{
|
||||||
|
|
@ -210,6 +213,12 @@ export default function PayslipsGrid({ initialData, activeOrgId }: { initialData
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
}>>([]);
|
}>>([]);
|
||||||
const [isLoadingPdfs, setIsLoadingPdfs] = useState(false);
|
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
|
// Handler pour mettre à jour une paie après édition
|
||||||
const handlePayslipUpdated = (updatedPayslip: any) => {
|
const handlePayslipUpdated = (updatedPayslip: any) => {
|
||||||
|
|
@ -337,6 +346,68 @@ export default function PayslipsGrid({ initialData, activeOrgId }: { initialData
|
||||||
saveFiltersToStorage(filters);
|
saveFiltersToStorage(filters);
|
||||||
}, [q, structureFilter, typeFilter, processedFilter, transferFilter, aemFilter, periodFrom, periodTo, sortField, sortOrder, showFilters]);
|
}, [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
|
// Realtime subscription: listen to INSERT / UPDATE / DELETE on payslips
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -365,11 +436,15 @@ export default function PayslipsGrid({ initialData, activeOrgId }: { initialData
|
||||||
if (rs.find((r) => r.id === newRec.id)) return rs;
|
if (rs.find((r) => r.id === newRec.id)) return rs;
|
||||||
return [newRec, ...rs];
|
return [newRec, ...rs];
|
||||||
});
|
});
|
||||||
|
// Refetch stats when a new payslip is created
|
||||||
|
refetchStatsRef.current?.();
|
||||||
} else if (event === "UPDATE") {
|
} else if (event === "UPDATE") {
|
||||||
setRows((rs) => rs.map((r) => (r.id === record.id ? { ...r, ...(record as Payslip) } : r)));
|
setRows((rs) => rs.map((r) => (r.id === record.id ? { ...r, ...(record as Payslip) } : r)));
|
||||||
} else if (event === "DELETE") {
|
} else if (event === "DELETE") {
|
||||||
const id = record?.id ?? payload.old?.id;
|
const id = record?.id ?? payload.old?.id;
|
||||||
if (id) setRows((rs) => rs.filter((r) => r.id !== id));
|
if (id) setRows((rs) => rs.filter((r) => r.id !== id));
|
||||||
|
// Refetch stats when a payslip is deleted
|
||||||
|
refetchStatsRef.current?.();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Realtime handler error", err);
|
console.error("Realtime handler error", err);
|
||||||
|
|
@ -584,6 +659,15 @@ export default function PayslipsGrid({ initialData, activeOrgId }: { initialData
|
||||||
Actualiser
|
Actualiser
|
||||||
</button>
|
</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
|
<button
|
||||||
className="rounded border px-3 py-1 text-sm"
|
className="rounded border px-3 py-1 text-sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|
@ -686,6 +770,56 @@ export default function PayslipsGrid({ initialData, activeOrgId }: { initialData
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Boutons d'actions groupées */}
|
{/* Boutons d'actions groupées */}
|
||||||
|
|
@ -1114,6 +1248,37 @@ export default function PayslipsGrid({ initialData, activeOrgId }: { initialData
|
||||||
onPayslipUpdated={handlePayslipUpdated}
|
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 */}
|
{/* Modal de vérification des PDFs */}
|
||||||
<PayslipPdfVerificationModal
|
<PayslipPdfVerificationModal
|
||||||
isOpen={showPdfVerificationModal}
|
isOpen={showPdfVerificationModal}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue