feat: Amélioration modale création fiche de paie et page staff/contrats
- Ajout profession et dates de contrat dans la modale de création de fiche de paie - Pré-remplissage automatique des dates et salaire brut pour contrats mono-mois - Exclusion des contrats annulés des statistiques et recherches - Suppression titre page staff/contrats et mise en pleine largeur des filtres - Ajout route API pour organisations avec contrats sans paie
This commit is contained in:
parent
d535b17f26
commit
ad2a9c6b7d
9 changed files with 588 additions and 85 deletions
184
app/api/staff/payslips/missing-organizations/route.ts
Normal file
184
app/api/staff/payslips/missing-organizations/route.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
// app/api/staff/payslips/missing-organizations/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 periodFrom = searchParams.get("period_from");
|
||||
const periodTo = searchParams.get("period_to");
|
||||
|
||||
if (!periodFrom || !periodTo) {
|
||||
return NextResponse.json({
|
||||
organizations: [],
|
||||
message: "Filtres incomplets"
|
||||
});
|
||||
}
|
||||
|
||||
console.log("[GET /api/staff/payslips/missing-organizations] Params:", {
|
||||
periodFrom,
|
||||
periodTo
|
||||
});
|
||||
|
||||
// Récupérer tous les contrats dans cette période
|
||||
const { data: contracts, error: contractsError } = await sb
|
||||
.from("cddu_contracts")
|
||||
.select(`
|
||||
id,
|
||||
start_date,
|
||||
end_date,
|
||||
type_de_contrat,
|
||||
structure,
|
||||
org_id,
|
||||
etat_de_la_demande
|
||||
`)
|
||||
.lte("start_date", periodTo)
|
||||
.gte("end_date", periodFrom)
|
||||
.neq("etat_de_la_demande", "Annulée");
|
||||
|
||||
if (contractsError) {
|
||||
console.error("[GET /api/staff/payslips/missing-organizations] Contracts error:", contractsError);
|
||||
return NextResponse.json({ error: "Database error" }, { status: 500 });
|
||||
}
|
||||
|
||||
if (!contracts || contracts.length === 0) {
|
||||
return NextResponse.json({
|
||||
organizations: [],
|
||||
total_contracts: 0
|
||||
});
|
||||
}
|
||||
|
||||
console.log("[GET /api/staff/payslips/missing-organizations] Found contracts:", contracts.length);
|
||||
|
||||
// Récupérer les informations des organisations uniques
|
||||
const uniqueOrgIds = [...new Set(contracts.map(c => c.org_id).filter(Boolean))];
|
||||
const { data: organizationsData, error: orgsError } = await sb
|
||||
.from("organizations")
|
||||
.select("id, name, structure_api")
|
||||
.in("id", uniqueOrgIds);
|
||||
|
||||
if (orgsError) {
|
||||
console.error("[GET /api/staff/payslips/missing-organizations] Organizations error:", orgsError);
|
||||
}
|
||||
|
||||
// Créer un map pour accéder rapidement aux noms des organisations
|
||||
const orgNamesMap = new Map<string, string>();
|
||||
if (organizationsData) {
|
||||
organizationsData.forEach(org => {
|
||||
const orgName = org.name || org.structure_api || `Organisation ${org.id}`;
|
||||
orgNamesMap.set(org.id, orgName);
|
||||
});
|
||||
}
|
||||
|
||||
// Récupérer toutes les paies pour ces contrats
|
||||
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-organizations] Payslips error:", payslipsError);
|
||||
return NextResponse.json({ error: "Database error" }, { status: 500 });
|
||||
}
|
||||
|
||||
console.log("[GET /api/staff/payslips/missing-organizations] 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));
|
||||
}
|
||||
|
||||
// Grouper par organisation et compter les contrats sans paie
|
||||
const orgMap = new Map<string, { name: string; missingCount: number }>();
|
||||
|
||||
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();
|
||||
|
||||
let isMissing = false;
|
||||
|
||||
if (!isMultiMonth) {
|
||||
// Mono-mois : pas de paie du tout
|
||||
isMissing = !contractsWithPayslips.has(contract.id);
|
||||
} 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;
|
||||
});
|
||||
|
||||
isMissing = !hasPeriodPayslip;
|
||||
}
|
||||
|
||||
if (isMissing && contract.org_id) {
|
||||
const orgId = contract.org_id;
|
||||
const orgName = orgNamesMap.get(orgId) ||
|
||||
contract.structure ||
|
||||
`Organisation ${orgId}`;
|
||||
|
||||
const existing = orgMap.get(orgId);
|
||||
if (existing) {
|
||||
existing.missingCount++;
|
||||
} else {
|
||||
orgMap.set(orgId, {
|
||||
name: orgName,
|
||||
missingCount: 1
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Convertir en tableau et trier par ordre alphabétique du nom
|
||||
const organizationsList = Array.from(orgMap.entries())
|
||||
.map(([id, data]) => ({
|
||||
organization_id: id,
|
||||
organization_name: data.name,
|
||||
missing_count: data.missingCount
|
||||
}))
|
||||
.sort((a, b) => a.organization_name.localeCompare(b.organization_name, 'fr'));
|
||||
|
||||
// Calculer le nombre total de contrats sans paie
|
||||
const totalMissingContracts = organizationsList.reduce((sum, org) => sum + org.missing_count, 0);
|
||||
|
||||
console.log("[GET /api/staff/payslips/missing-organizations] Organizations:", organizationsList.length);
|
||||
|
||||
return NextResponse.json({
|
||||
organizations: organizationsList,
|
||||
total_organizations: organizationsList.length,
|
||||
total_missing_contracts: totalMissingContracts
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[GET /api/staff/payslips/missing-organizations] Error:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -53,11 +53,13 @@ export async function GET(request: NextRequest) {
|
|||
reference,
|
||||
employee_name,
|
||||
production_name,
|
||||
profession
|
||||
profession,
|
||||
etat_de_la_demande
|
||||
`)
|
||||
.eq("structure", structure)
|
||||
.lte("start_date", periodTo)
|
||||
.gte("end_date", periodFrom);
|
||||
.gte("end_date", periodFrom)
|
||||
.neq("etat_de_la_demande", "Annulée");
|
||||
|
||||
if (contractsError) {
|
||||
console.error("[GET /api/staff/payslips/missing-stats] Contracts error:", contractsError);
|
||||
|
|
|
|||
|
|
@ -52,6 +52,8 @@ export async function GET(request: NextRequest) {
|
|||
start_date,
|
||||
end_date,
|
||||
production_name,
|
||||
profession,
|
||||
gross_pay,
|
||||
n_objet,
|
||||
objet_spectacle,
|
||||
org_id,
|
||||
|
|
@ -65,6 +67,7 @@ export async function GET(request: NextRequest) {
|
|||
)
|
||||
`)
|
||||
.or(`contract_number.ilike.${searchPattern},employee_name.ilike.${searchPattern},structure.ilike.${searchPattern},production_name.ilike.${searchPattern},n_objet.ilike.${searchPattern},objet_spectacle.ilike.${searchPattern}`)
|
||||
.neq("etat_de_la_demande", "Annulée")
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(50);
|
||||
|
||||
|
|
@ -98,6 +101,8 @@ export async function GET(request: NextRequest) {
|
|||
start_date,
|
||||
end_date,
|
||||
production_name,
|
||||
profession,
|
||||
gross_pay,
|
||||
n_objet,
|
||||
objet_spectacle,
|
||||
org_id,
|
||||
|
|
@ -158,6 +163,8 @@ export async function GET(request: NextRequest) {
|
|||
start_date,
|
||||
end_date,
|
||||
production_name,
|
||||
profession,
|
||||
gross_pay,
|
||||
n_objet,
|
||||
objet_spectacle,
|
||||
org_id,
|
||||
|
|
|
|||
|
|
@ -77,15 +77,115 @@ export async function GET(req: NextRequest) {
|
|||
|
||||
// Filtre par structure (via contrat)
|
||||
if (structure) {
|
||||
const { data: matchingContracts } = await supabase
|
||||
const { data: matchingContracts, error: structureError } = await supabase
|
||||
.from("cddu_contracts")
|
||||
.select("id")
|
||||
.eq("structure", structure);
|
||||
|
||||
if (structureError) {
|
||||
console.error('[staff/payslips/search] Structure filter error:', structureError);
|
||||
return NextResponse.json({ error: "Erreur lors du filtre par structure" }, { status: 500 });
|
||||
}
|
||||
|
||||
console.log('[staff/payslips/search] Matching contracts for structure:', matchingContracts?.length || 0);
|
||||
|
||||
if (matchingContracts && matchingContracts.length > 0) {
|
||||
const contractIds = matchingContracts.map(c => c.id);
|
||||
query = query.in('contract_id', contractIds);
|
||||
|
||||
// Si trop de contrats (>400), on va récupérer toutes les payslips et filtrer côté serveur
|
||||
if (contractIds.length > 400) {
|
||||
console.log('[staff/payslips/search] Too many contracts, switching to server-side filtering');
|
||||
|
||||
// Récupérer toutes les payslips pour la période
|
||||
let allQuery = supabase
|
||||
.from("payslips")
|
||||
.select(
|
||||
`id, contract_id, period_start, period_end, period_month, pay_number, pay_date,
|
||||
gross_amount, net_amount, net_after_withholding, employer_cost,
|
||||
processed, aem_status, transfer_done, organization_id, storage_path, created_at,
|
||||
cddu_contracts!contract_id(
|
||||
id, contract_number, employee_name, employee_id, structure, type_de_contrat, org_id,
|
||||
salaries!employee_id(salarie, nom, prenom),
|
||||
organizations!org_id(organization_details(code_employeur))
|
||||
)`,
|
||||
{ count: "exact" }
|
||||
);
|
||||
|
||||
// Filtres de période
|
||||
if (periodFrom) {
|
||||
allQuery = allQuery.gte('period_start', periodFrom);
|
||||
}
|
||||
if (periodTo) {
|
||||
allQuery = allQuery.lte('period_start', periodTo);
|
||||
}
|
||||
|
||||
// Filtres additionnels
|
||||
if (processed !== '') {
|
||||
allQuery = allQuery.eq('processed', processed === 'true');
|
||||
}
|
||||
if (transferDone !== '') {
|
||||
allQuery = allQuery.eq('transfer_done', transferDone === 'true');
|
||||
}
|
||||
if (aemStatus) {
|
||||
allQuery = allQuery.eq('aem_status', aemStatus);
|
||||
}
|
||||
|
||||
// Limiter à 10000 pour ne pas surcharger
|
||||
allQuery = allQuery.limit(10000);
|
||||
|
||||
const { data: allPayslips, error: allError } = await allQuery;
|
||||
|
||||
if (allError) {
|
||||
console.error('[staff/payslips/search] Error fetching payslips:', allError);
|
||||
return NextResponse.json({ error: allError.message }, { status: 500 });
|
||||
}
|
||||
|
||||
// Filtrer côté serveur par contract_id
|
||||
const contractIdsSet = new Set(contractIds);
|
||||
let filteredPayslips = (allPayslips || []).filter(p => contractIdsSet.has(p.contract_id));
|
||||
|
||||
console.log('[staff/payslips/search] Filtered to', filteredPayslips.length, 'payslips');
|
||||
|
||||
// Tri côté serveur
|
||||
const ascending = order === 'asc';
|
||||
filteredPayslips.sort((a: any, b: any) => {
|
||||
let valueA: any, valueB: any;
|
||||
|
||||
if (sort === 'employee_name') {
|
||||
valueA = a.cddu_contracts?.salaries?.nom || a.cddu_contracts?.employee_name || '';
|
||||
valueB = b.cddu_contracts?.salaries?.nom || b.cddu_contracts?.employee_name || '';
|
||||
} else if (sort === 'contract_number') {
|
||||
valueA = a.cddu_contracts?.contract_number || '';
|
||||
valueB = b.cddu_contracts?.contract_number || '';
|
||||
} else if (sort === 'structure') {
|
||||
valueA = a.cddu_contracts?.structure || '';
|
||||
valueB = b.cddu_contracts?.structure || '';
|
||||
} else {
|
||||
valueA = a[sort];
|
||||
valueB = b[sort];
|
||||
}
|
||||
|
||||
if (typeof valueA === 'string') valueA = valueA.toLowerCase();
|
||||
if (typeof valueB === 'string') valueB = valueB.toLowerCase();
|
||||
|
||||
if (valueA < valueB) return ascending ? -1 : 1;
|
||||
if (valueA > valueB) return ascending ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
// Pagination
|
||||
const paginatedPayslips = filteredPayslips.slice(offset, offset + limit);
|
||||
|
||||
return NextResponse.json({
|
||||
rows: paginatedPayslips,
|
||||
count: filteredPayslips.length
|
||||
});
|
||||
} else {
|
||||
// Assez peu de contrats, on peut utiliser .in()
|
||||
query = query.in('contract_id', contractIds);
|
||||
}
|
||||
} else {
|
||||
console.log('[staff/payslips/search] No contracts found for structure, returning empty');
|
||||
return NextResponse.json({ rows: [], count: 0 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ type Contract = {
|
|||
start_date?: string | null;
|
||||
end_date?: string | null;
|
||||
production_name?: string | null;
|
||||
profession?: string | null;
|
||||
gross_pay?: string | number | null;
|
||||
n_objet?: string | null;
|
||||
objet_spectacle?: string | null;
|
||||
salaries?: {
|
||||
|
|
@ -136,7 +138,13 @@ export default function CreatePayslipModal({
|
|||
console.error('Error fetching last pay number:', err);
|
||||
}
|
||||
} else {
|
||||
// Si mono-mois, vérifier si une paie existe déjà
|
||||
// Si mono-mois, pré-remplir les dates de période et vérifier si une paie existe déjà
|
||||
setPeriodStart(contract.start_date);
|
||||
setPeriodEnd(contract.end_date);
|
||||
if (contract.gross_pay) {
|
||||
setGrossAmount(String(contract.gross_pay));
|
||||
}
|
||||
|
||||
setIsCheckingExisting(true);
|
||||
try {
|
||||
const checkResponse = await fetch(`/api/staff/payslips/check-existing?contract_id=${contract.id}`);
|
||||
|
|
@ -248,7 +256,12 @@ export default function CreatePayslipModal({
|
|||
if (isMulti) {
|
||||
fetchLastPayNumber(contract.id);
|
||||
} else {
|
||||
// Si mono-mois, vérifier si une paie existe déjà
|
||||
// Si mono-mois, pré-remplir les dates de période et vérifier si une paie existe déjà
|
||||
setPeriodStart(contract.start_date);
|
||||
setPeriodEnd(contract.end_date);
|
||||
if (contract.gross_pay) {
|
||||
setGrossAmount(String(contract.gross_pay));
|
||||
}
|
||||
checkExistingPayslip(contract.id);
|
||||
}
|
||||
}
|
||||
|
|
@ -486,6 +499,14 @@ export default function CreatePayslipModal({
|
|||
Production: {selectedContract.production_name}
|
||||
</div>
|
||||
)}
|
||||
{selectedContract.profession && (
|
||||
<div className="text-sm text-gray-600 mt-1">
|
||||
Profession: {selectedContract.profession}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm text-gray-600 mt-1">
|
||||
Du {formatDate(selectedContract.start_date)} au {formatDate(selectedContract.end_date)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedContract(null)}
|
||||
|
|
|
|||
143
components/staff/MissingPayslipsOrganizationsModal.tsx
Normal file
143
components/staff/MissingPayslipsOrganizationsModal.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { X, Building2 } from "lucide-react";
|
||||
|
||||
interface OrganizationWithMissing {
|
||||
organization_id: string;
|
||||
organization_name: string;
|
||||
missing_count: number;
|
||||
}
|
||||
|
||||
interface MissingPayslipsOrganizationsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
periodFrom: string;
|
||||
periodTo: string;
|
||||
onOrganizationSelect: (orgId: string) => void;
|
||||
}
|
||||
|
||||
export default function MissingPayslipsOrganizationsModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
periodFrom,
|
||||
periodTo,
|
||||
onOrganizationSelect,
|
||||
}: MissingPayslipsOrganizationsModalProps) {
|
||||
const [organizations, setOrganizations] = useState<OrganizationWithMissing[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [totalMissing, setTotalMissing] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const fetchOrganizations = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
period_from: periodFrom,
|
||||
period_to: periodTo,
|
||||
});
|
||||
|
||||
const response = await fetch(`/api/staff/payslips/missing-organizations?${params}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.organizations) {
|
||||
setOrganizations(data.organizations);
|
||||
setTotalMissing(data.total_missing_contracts || 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching organizations:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchOrganizations();
|
||||
}, [isOpen, periodFrom, periodTo]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleOrganizationClick = (orgName: string) => {
|
||||
onOrganizationSelect(orgName);
|
||||
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-3xl 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">
|
||||
Organisations avec contrats sans 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>
|
||||
) : organizations.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-slate-600">Aucune organisation avec des contrats sans paie</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{organizations.map((org) => (
|
||||
<button
|
||||
key={org.organization_id}
|
||||
onClick={() => handleOrganizationClick(org.organization_name)}
|
||||
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-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-indigo-100 rounded-lg group-hover:bg-indigo-200 transition-colors">
|
||||
<Building2 className="w-5 h-5 text-indigo-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-slate-900">
|
||||
{org.organization_name}
|
||||
</div>
|
||||
<div className="text-sm text-slate-600 mt-0.5">
|
||||
{org.missing_count} contrat{org.missing_count > 1 ? 's' : ''} sans paie
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<span className="text-sm text-indigo-600 group-hover:text-indigo-700 font-medium">
|
||||
Voir les contrats →
|
||||
</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">
|
||||
{organizations.length} organisation{organizations.length > 1 ? 's' : ''} · {totalMissing} contrat{totalMissing > 1 ? 's' : ''} sans paie au total
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import PayslipDetailsModal from "./PayslipDetailsModal";
|
|||
import PayslipPdfVerificationModal from "./PayslipPdfVerificationModal";
|
||||
import CreatePayslipModal from "./CreatePayslipModal";
|
||||
import MissingPayslipsModal from "./MissingPayslipsModal";
|
||||
import MissingPayslipsOrganizationsModal from "./MissingPayslipsOrganizationsModal";
|
||||
|
||||
// Utility function to format dates as DD/MM/YYYY
|
||||
function formatDate(dateString: string | null | undefined): string {
|
||||
|
|
@ -146,45 +147,20 @@ export default function PayslipsGrid({ initialData, activeOrgId }: { initialData
|
|||
// Selection state
|
||||
const [selectedPayslipIds, setSelectedPayslipIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// Key for localStorage
|
||||
const FILTERS_STORAGE_KEY = 'staff-payslips-filters';
|
||||
|
||||
// Helper functions for localStorage
|
||||
const saveFiltersToStorage = (filters: any) => {
|
||||
try {
|
||||
localStorage.setItem(FILTERS_STORAGE_KEY, JSON.stringify(filters));
|
||||
} catch (error) {
|
||||
console.warn('Failed to save filters to localStorage:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadFiltersFromStorage = () => {
|
||||
try {
|
||||
const saved = localStorage.getItem(FILTERS_STORAGE_KEY);
|
||||
return saved ? JSON.parse(saved) : null;
|
||||
} catch (error) {
|
||||
console.warn('Failed to load filters from localStorage:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Load saved filters or use defaults
|
||||
const savedFilters = loadFiltersFromStorage();
|
||||
|
||||
// filters / sorting / pagination - with localStorage persistence
|
||||
const [q, setQ] = useState(savedFilters?.q || "");
|
||||
const [structureFilter, setStructureFilter] = useState<string | null>(savedFilters?.structureFilter || null);
|
||||
const [typeFilter, setTypeFilter] = useState<string | null>(savedFilters?.typeFilter || null);
|
||||
const [processedFilter, setProcessedFilter] = useState<string | null>(savedFilters?.processedFilter || null);
|
||||
const [transferFilter, setTransferFilter] = useState<string | null>(savedFilters?.transferFilter || null);
|
||||
const [aemFilter, setAemFilter] = useState<string | null>(savedFilters?.aemFilter || null);
|
||||
const [periodFrom, setPeriodFrom] = useState<string | null>(savedFilters?.periodFrom || null);
|
||||
const [periodTo, setPeriodTo] = useState<string | null>(savedFilters?.periodTo || null);
|
||||
const [sortField, setSortField] = useState<string>(savedFilters?.sortField || "period_start");
|
||||
const [sortOrder, setSortOrder] = useState<'asc'|'desc'>(savedFilters?.sortOrder || 'desc');
|
||||
// filters / sorting / pagination
|
||||
const [q, setQ] = useState("");
|
||||
const [structureFilter, setStructureFilter] = useState<string | null>(null);
|
||||
const [typeFilter, setTypeFilter] = useState<string | null>(null);
|
||||
const [processedFilter, setProcessedFilter] = useState<string | null>(null);
|
||||
const [transferFilter, setTransferFilter] = useState<string | null>(null);
|
||||
const [aemFilter, setAemFilter] = useState<string | null>(null);
|
||||
const [periodFrom, setPeriodFrom] = useState<string | null>(null);
|
||||
const [periodTo, setPeriodTo] = useState<string | null>(null);
|
||||
const [sortField, setSortField] = useState<string>("period_start");
|
||||
const [sortOrder, setSortOrder] = useState<'asc'|'desc'>('desc');
|
||||
const [page, setPage] = useState(0);
|
||||
const [limit, setLimit] = useState(50);
|
||||
const [showFilters, setShowFilters] = useState(savedFilters?.showFilters || false);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const totalCountRef = useRef<number | null>(null);
|
||||
|
||||
// Selection helpers
|
||||
|
|
@ -216,9 +192,19 @@ export default function PayslipsGrid({ initialData, activeOrgId }: { initialData
|
|||
|
||||
// Stats for missing payslips
|
||||
const [missingPayslipsCount, setMissingPayslipsCount] = useState<number | null>(null);
|
||||
const [missingOrganizationsCount, setMissingOrganizationsCount] = useState<number | null>(null);
|
||||
const [totalMissingContracts, setTotalMissingContracts] = useState<number | null>(null);
|
||||
const [isLoadingStats, setIsLoadingStats] = useState(false);
|
||||
const [showMissingPayslipsModal, setShowMissingPayslipsModal] = useState(false);
|
||||
const [showMissingOrganizationsModal, setShowMissingOrganizationsModal] = useState(false);
|
||||
const [preselectedContractId, setPreselectedContractId] = useState<string | null>(null);
|
||||
const isFirstRender = useRef(true);
|
||||
|
||||
// Fetch initial au montage du composant
|
||||
useEffect(() => {
|
||||
fetchServer(0);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Handler pour mettre à jour une paie après édition
|
||||
const handlePayslipUpdated = (updatedPayslip: any) => {
|
||||
|
|
@ -328,53 +314,66 @@ export default function PayslipsGrid({ initialData, activeOrgId }: { initialData
|
|||
setIsLoadingPdfs(false);
|
||||
};
|
||||
|
||||
// Save filters to localStorage whenever they change
|
||||
useEffect(() => {
|
||||
const filters = {
|
||||
q,
|
||||
structureFilter,
|
||||
typeFilter,
|
||||
processedFilter,
|
||||
transferFilter,
|
||||
aemFilter,
|
||||
periodFrom,
|
||||
periodTo,
|
||||
sortField,
|
||||
sortOrder,
|
||||
showFilters
|
||||
};
|
||||
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) {
|
||||
if (!periodFrom || !periodTo) {
|
||||
setMissingPayslipsCount(null);
|
||||
setMissingOrganizationsCount(null);
|
||||
setTotalMissingContracts(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoadingStats(true);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
structure: structureFilter,
|
||||
period_from: periodFrom,
|
||||
period_to: periodTo,
|
||||
});
|
||||
if (structureFilter) {
|
||||
// Si une organisation est sélectionnée
|
||||
setMissingOrganizationsCount(null);
|
||||
setTotalMissingContracts(null);
|
||||
|
||||
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();
|
||||
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);
|
||||
if (!response.ok) {
|
||||
console.error("Error fetching missing stats:", data.error);
|
||||
setMissingPayslipsCount(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setMissingPayslipsCount(data.missing_count || 0);
|
||||
} else {
|
||||
// Si "Toutes structures"
|
||||
setMissingPayslipsCount(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
period_from: periodFrom,
|
||||
period_to: periodTo,
|
||||
});
|
||||
|
||||
setMissingPayslipsCount(data.missing_count || 0);
|
||||
const response = await fetch(`/api/staff/payslips/missing-organizations?${params}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
console.error("Error fetching missing organizations:", data.error);
|
||||
setMissingOrganizationsCount(null);
|
||||
setTotalMissingContracts(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setMissingOrganizationsCount(data.total_organizations || 0);
|
||||
setTotalMissingContracts(data.total_missing_contracts || 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching missing stats:", error);
|
||||
setMissingPayslipsCount(null);
|
||||
setMissingOrganizationsCount(null);
|
||||
setTotalMissingContracts(null);
|
||||
} finally {
|
||||
setIsLoadingStats(false);
|
||||
}
|
||||
|
|
@ -597,9 +596,9 @@ export default function PayslipsGrid({ initialData, activeOrgId }: { initialData
|
|||
|
||||
// Debounce searches when filters change
|
||||
useEffect(() => {
|
||||
const noFilters = !q && !structureFilter && !typeFilter && !processedFilter && !transferFilter && !aemFilter && !periodFrom && !periodTo && sortField === 'period_start' && sortOrder === 'desc';
|
||||
if (noFilters) {
|
||||
setRows(initialData || []);
|
||||
// Skip le premier rendu car le fetch initial est géré par un autre useEffect
|
||||
if (isFirstRender.current) {
|
||||
isFirstRender.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -772,9 +771,9 @@ export default function PayslipsGrid({ initialData, activeOrgId }: { initialData
|
|||
)}
|
||||
|
||||
{/* Card showing missing payslips statistics */}
|
||||
{structureFilter && periodFrom && periodTo && (
|
||||
{periodFrom && periodTo && (structureFilter || (!structureFilter && missingOrganizationsCount !== null)) && (
|
||||
<div className={`mt-4 p-4 border rounded-lg ${
|
||||
missingPayslipsCount === 0
|
||||
(structureFilter ? missingPayslipsCount : missingOrganizationsCount) === 0
|
||||
? 'bg-emerald-50 border-emerald-200'
|
||||
: 'bg-amber-50 border-amber-200'
|
||||
}`}>
|
||||
|
|
@ -783,7 +782,7 @@ export default function PayslipsGrid({ initialData, activeOrgId }: { initialData
|
|||
<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 ? (
|
||||
) : structureFilter && 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">
|
||||
|
|
@ -817,6 +816,40 @@ export default function PayslipsGrid({ initialData, activeOrgId }: { initialData
|
|||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : !structureFilter && missingOrganizationsCount !== 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">
|
||||
{missingOrganizationsCount === 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 ${
|
||||
missingOrganizationsCount === 0
|
||||
? 'text-emerald-900'
|
||||
: 'text-amber-900'
|
||||
}`}>
|
||||
{missingOrganizationsCount === 0 ? (
|
||||
<>Toutes les organisations ont des fiches de paie pour cette période</>
|
||||
) : (
|
||||
<>{missingOrganizationsCount} organisation{missingOrganizationsCount > 1 ? 's' : ''} avec {totalMissingContracts} contrat{totalMissingContracts && totalMissingContracts > 1 ? 's' : ''} sans paie</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{missingOrganizationsCount > 0 && (
|
||||
<button
|
||||
onClick={() => setShowMissingOrganizationsModal(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>
|
||||
)}
|
||||
|
|
@ -1279,6 +1312,21 @@ export default function PayslipsGrid({ initialData, activeOrgId }: { initialData
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* Modal Liste des organisations avec contrats sans paie */}
|
||||
{showMissingOrganizationsModal && periodFrom && periodTo && (
|
||||
<MissingPayslipsOrganizationsModal
|
||||
isOpen={showMissingOrganizationsModal}
|
||||
onClose={() => setShowMissingOrganizationsModal(false)}
|
||||
periodFrom={periodFrom}
|
||||
periodTo={periodTo}
|
||||
onOrganizationSelect={(orgId) => {
|
||||
setStructureFilter(orgId);
|
||||
setShowMissingOrganizationsModal(false);
|
||||
setShowMissingPayslipsModal(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Modal de vérification des PDFs */}
|
||||
<PayslipPdfVerificationModal
|
||||
isOpen={showPdfVerificationModal}
|
||||
|
|
|
|||
|
|
@ -180,10 +180,8 @@ export default function StaffContractsPageClient({ initialData, activeOrgId }: {
|
|||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h1 className="text-lg font-semibold">Contrats (Staff)</h1>
|
||||
|
||||
{/* Card de raccourcis à droite du titre */}
|
||||
{/* Card de raccourcis pleine largeur */}
|
||||
<div className="w-full">
|
||||
<div className="flex items-center gap-3 rounded-xl border border-slate-200 bg-gradient-to-r from-slate-50 to-slate-100 p-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-slate-600 font-medium">Filtres contrats:</span>
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue