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:
odentas 2025-11-28 00:11:52 +01:00
parent d535b17f26
commit ad2a9c6b7d
9 changed files with 588 additions and 85 deletions

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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