Ajout filtre org Virements salaires

This commit is contained in:
odentas 2025-10-13 02:21:23 +02:00
parent 2f147382b3
commit 4b72b4cc0d
16 changed files with 633 additions and 139 deletions

View file

@ -37,7 +37,6 @@ export default async function StaffContractsPage() {
.select(
`id, contract_number, employee_name, structure, type_de_contrat, start_date, end_date, created_at, etat_de_la_demande, etat_de_la_paie, dpae, gross_pay, org_id`
)
.eq("type_de_contrat", "CDD d'usage")
.order("start_date", { ascending: false })
.limit(200);

View file

@ -43,6 +43,19 @@ export default async function StaffSalaryTransfersPage() {
.order("period_month", { ascending: false })
.limit(200);
// Récupérer toutes les organisations pour le filtre
const { data: organizations, error: orgError } = await sb
.from("organizations")
.select("id, name")
.order("name", { ascending: true });
if (orgError) {
console.error("[staff/virements-salaires] Erreur chargement organisations:", orgError);
}
// Debug log pour vérifier le nombre d'organisations
console.log("[staff/virements-salaires] Organizations count:", organizations?.length, "first 3:", organizations?.slice(0, 3));
// Server-side debug logging to help diagnose empty results (will appear in Next.js server logs)
try {
console.log("[staff/virements-salaires] supabase fetch salary_transfers result count:", Array.isArray(salaryTransfers) ? salaryTransfers.length : typeof salaryTransfers);
@ -87,7 +100,11 @@ export default async function StaffSalaryTransfersPage() {
</div>
)}
{/* Client-side interactive grid */}
<SalaryTransfersGrid initialData={salaryTransfers ?? []} activeOrgId={activeOrgId} />
<SalaryTransfersGrid
initialData={salaryTransfers ?? []}
activeOrgId={activeOrgId}
organizations={organizations ?? []}
/>
</div>
</main>
);

View file

@ -168,8 +168,8 @@ function useUserInfo() {
const me = await res.json();
return {
isStaff: Boolean(me.isStaff),
orgId: me.orgId || me.active_org_id || "unknown",
isStaff: Boolean(me.is_staff || me.isStaff),
orgId: me.orgId || me.active_org_id || null,
orgName: me.orgName || me.active_org_name || "Organisation",
api_name: me.active_org_api_name
};
@ -182,9 +182,9 @@ function useUserInfo() {
}
function useOrganizations() {
const { data: userInfo } = useUserInfo();
const { data: userInfo, isSuccess: userInfoLoaded } = useUserInfo();
return useQuery({
const query = useQuery({
queryKey: ["organizations"],
queryFn: async () => {
try {
@ -200,9 +200,17 @@ function useOrganizations() {
return [];
}
},
enabled: Boolean(userInfo?.isStaff),
enabled: false,
staleTime: 60_000,
});
React.useEffect(() => {
if (userInfoLoaded && userInfo?.isStaff && !query.data && !query.isFetching) {
query.refetch();
}
}, [userInfo, userInfoLoaded, query]);
return query;
}
// --- Hook pour récupérer les virements ---
@ -268,9 +276,8 @@ export default function VirementsPage() {
const [copiedField, setCopiedField] = useState<null | 'iban' | 'bic' | 'benef'>(null);
const [selectedOrgId, setSelectedOrgId] = useState<string>("");
// Récupération des informations utilisateur et des organisations (pour le staff)
const { data: userInfo } = useUserInfo();
const { data: organizations } = useOrganizations();
const { data: userInfo, isLoading: isLoadingUser } = useUserInfo();
const { data: organizations, isLoading: isLoadingOrgs, error: orgsError } = useOrganizations();
const queryClient = useQueryClient();
const years = useMemo(() => {
@ -384,15 +391,6 @@ export default function VirementsPage() {
const clientUnpaidAll: ClientVirementItem[] = (data?.client?.unpaid ?? []) as ClientVirementItem[];
const clientRecentAll: ClientVirementItem[] = (data?.client?.recent ?? []) as ClientVirementItem[];
// Debug logging
console.log("🔍 [virements-page] Debug data:", {
isOdentas,
itemsCount: items.length,
clientUnpaidCount: clientUnpaidAll.length,
clientRecentCount: clientRecentAll.length,
rawData: data
});
const clientFilter = (arr: ClientVirementItem[]) => {
if (!searchQuery.trim()) return arr;
const q = searchQuery.toLowerCase();
@ -521,19 +519,27 @@ export default function VirementsPage() {
</div>
{/* Sélecteur d'organisation (visible uniquement par le staff) */}
{userInfo?.isStaff && organizations && organizations.length > 0 && (
{userInfo?.isStaff && (
<div className="flex items-center gap-2">
<label className="text-sm font-medium">Organisation :</label>
<select
className="px-3 py-2 rounded-lg border bg-white text-sm min-w-[200px]"
value={selectedOrgId}
onChange={(e) => setSelectedOrgId(e.target.value)}
disabled={!organizations || organizations.length === 0}
>
<option value="">Toutes les organisations</option>
{organizations.map((org: any) => (
<option value="">
{!organizations || organizations.length === 0
? "Chargement..."
: "Toutes les organisations"}
</option>
{organizations && organizations.map((org: any) => (
<option key={org.id} value={org.id}>{org.name}</option>
))}
</select>
{organizations && organizations.length > 0 && (
<span className="text-xs text-slate-500">({organizations.length})</span>
)}
</div>
)}

View file

@ -365,8 +365,8 @@ export async function POST(
cachets: {
representations: contract.cachets_representations ? parseInt(contract.cachets_representations) || 0 : 0,
repetitions: contract.services_repetitions ? parseInt(contract.services_repetitions) || 0 : 0,
heures: contract.nombre_d_heures ? parseInt(contract.nombre_d_heures) || 0 : 0,
heuresparjour: contract.nombre_d_heures_par_jour ? parseInt(contract.nombre_d_heures_par_jour) || 0 : 0
heures: contract.nombre_d_heures ? parseFloat(contract.nombre_d_heures) || 0 : 0,
heuresparjour: contract.nombre_d_heures_par_jour ? parseFloat(contract.nombre_d_heures_par_jour) || 0 : 0
},
nom_responsable_traitement: orgDetails.nom_responsable_traitement || "",
qualite_responsable_traitement: orgDetails.qualite_responsable_traitement || "",

View file

@ -107,7 +107,7 @@ export async function POST(request: NextRequest) {
const { data: orgDetails } = await supabase
.from('organization_details')
.select('*')
.eq('organization_id', contract.org_id)
.eq('org_id', contract.org_id)
.maybeSingle();
if (orgDetails) {
@ -115,6 +115,9 @@ export async function POST(request: NextRequest) {
signerName = `${orgDetails.prenom_signataire || ""} ${orgDetails.nom_signataire || ""}`.trim() || signerName;
organizationName = orgDetails.structure || organizationName;
employerCode = orgDetails.code_employeur || employerCode;
console.log(`✅ Organisation trouvée: ${organizationName}, email: ${employerEmail}`);
} else {
console.log(`⚠️ Aucun orgDetails trouvé pour org_id: ${contract.org_id}`);
}
}

View file

@ -34,7 +34,14 @@ export async function GET(req: Request) {
}
if (employee_matricule) query = query.eq("employee_matricule", employee_matricule);
if (structure) query = query.eq("structure", structure);
if (type_de_contrat) query = query.eq("type_de_contrat", type_de_contrat);
// Handle special "RG" filter for common law contracts (CDD de droit commun + CDI)
if (type_de_contrat === "RG") {
query = query.in("type_de_contrat", ["CDD de droit commun", "CDI"]);
} else if (type_de_contrat) {
query = query.eq("type_de_contrat", type_de_contrat);
}
if (etat_de_la_demande) query = query.eq("etat_de_la_demande", etat_de_la_demande);
if (etat_de_la_paie) query = query.eq("etat_de_la_paie", etat_de_la_paie);
if (dpae) query = query.eq("dpae", dpae);

View file

@ -63,7 +63,7 @@ export async function POST(request: NextRequest) {
employerCode: employerCode,
contractCount: contractCount,
status: 'En attente de signature',
ctaUrl: `${process.env.NEXT_PUBLIC_APP_URL || 'https://staging.paie.odentas.fr'}/signatures-electroniques`,
ctaUrl: 'https://paie.odentas.fr/signatures-electroniques',
handlerName: 'Renaud BREVIERE-ABRAHAM',
organizationId: organizationId // Ajouter pour le logging
};

View file

@ -0,0 +1,141 @@
import { NextRequest, NextResponse } from "next/server";
import { createSbServer } from "@/lib/supabaseServer";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { v4 as uuidv4 } from 'uuid';
const s3Client = new S3Client({
region: process.env.AWS_REGION || "eu-west-3",
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID || "",
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || "",
},
});
const BUCKET_NAME = (process.env.AWS_S3_BUCKET || "odentas-docs").trim();
export async function POST(req: NextRequest) {
try {
const sb = createSbServer();
// Vérifier que l'utilisateur est staff
const { data: { user } } = await sb.auth.getUser();
if (!user) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
const { data: staffUser } = await sb
.from("staff_users")
.select("is_staff")
.eq("user_id", user.id)
.single();
if (!staffUser?.is_staff) {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
// Parser le form data
const formData = await req.formData();
const file = formData.get('file') as File;
const contractId = formData.get('contract_id') as string;
const payslipId = formData.get('payslip_id') as string;
if (!file || !contractId || !payslipId) {
return NextResponse.json({ error: "Paramètres manquants" }, { status: 400 });
}
// Vérifier que c'est bien un PDF
if (file.type !== 'application/pdf') {
return NextResponse.json({ error: "Seuls les fichiers PDF sont acceptés" }, { status: 400 });
}
// Récupérer les informations du contrat pour construire le chemin S3
const { data: contract, error: contractError } = await sb
.from("cddu_contracts")
.select("contract_number, org_id, employee_name")
.eq("id", contractId)
.single();
if (contractError || !contract) {
return NextResponse.json({ error: "Contrat introuvable" }, { status: 404 });
}
// Récupérer les informations de la paie
const { data: payslip, error: payslipError } = await sb
.from("payslips")
.select("pay_number")
.eq("id", payslipId)
.single();
if (payslipError || !payslip) {
return NextResponse.json({ error: "Paie introuvable" }, { status: 404 });
}
// Récupérer l'organization pour avoir l'org_key
const { data: org, error: orgError } = await sb
.from("organizations")
.select("api_name")
.eq("id", contract.org_id)
.single();
if (orgError || !org?.api_name) {
return NextResponse.json({ error: "Organisation introuvable" }, { status: 404 });
}
// Générer le chemin S3: bulletins/{org_key}/contrat_{contract_number}/bulletin_paie_{pay_number}_{uuid}.pdf
const uniqueId = uuidv4().replace(/-/g, '').substring(0, 8);
const contractNumber = contract.contract_number || contractId.substring(0, 8);
const payNumber = payslip.pay_number || 'unknown';
const filename = `bulletin_paie_${payNumber}_${uniqueId}.pdf`;
const s3Key = `bulletins/${org.api_name}/contrat_${contractNumber}/${filename}`;
console.log('📄 [Payslip Upload] Uploading to S3:', {
contractId,
payslipId,
contractNumber,
payNumber,
s3Key,
fileSize: file.size
});
// Upload vers S3
const buffer = Buffer.from(await file.arrayBuffer());
const uploadCommand = new PutObjectCommand({
Bucket: BUCKET_NAME,
Key: s3Key,
Body: buffer,
ContentType: 'application/pdf',
});
await s3Client.send(uploadCommand);
// Mettre à jour la paie dans Supabase avec le chemin S3
const { error: updateError } = await sb
.from('payslips')
.update({
bulletin_pdf_url: s3Key,
bulletin_uploaded_at: new Date().toISOString(),
})
.eq('id', payslipId);
if (updateError) {
console.error('❌ [Payslip Upload] Erreur mise à jour Supabase:', updateError);
return NextResponse.json({ error: "Erreur lors de la mise à jour de la base de données" }, { status: 500 });
}
console.log('✅ [Payslip Upload] Upload réussi:', s3Key);
return NextResponse.json({
success: true,
s3_key: s3Key,
filename: filename,
message: "Bulletin de paie uploadé avec succès"
});
} catch (error) {
console.error('❌ [Payslip Upload] Erreur:', error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Erreur serveur" },
{ status: 500 }
);
}
}

View file

@ -10,6 +10,35 @@ async function assertStaff(sb: ReturnType<typeof createSbServer>, userId: string
return !!me?.is_staff;
}
export async function GET(req: Request) {
const supabase = createSbServer();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: "Non authentifié." }, { status: 401 });
const isStaff = await assertStaff(supabase, user.id);
if (!isStaff) return NextResponse.json({ error: "Accès réservé au staff." }, { status: 403 });
const { searchParams } = new URL(req.url);
const contractId = searchParams.get('contract_id');
if (!contractId) {
return NextResponse.json({ error: "contract_id manquant" }, { status: 400 });
}
const { data: payslips, error } = await supabase
.from("payslips")
.select("*")
.eq("contract_id", contractId)
.order("pay_number", { ascending: true });
if (error) {
console.error('Erreur récupération payslips:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ payslips: payslips ?? [] });
}
export async function POST(req: Request) {
const supabase = createSbServer();
const { data: { user } } = await supabase.auth.getUser();

View file

@ -12,6 +12,7 @@ export async function GET(req: Request) {
const url = new URL(req.url);
const q = url.searchParams.get("q");
const org_id = url.searchParams.get("org_id");
const period_month = url.searchParams.get("period_month");
const mode = url.searchParams.get("mode");
const notification_sent = url.searchParams.get("notification_sent");
@ -31,6 +32,7 @@ export async function GET(req: Request) {
// simple ilike search on period_label, callsheet_url, notes
query = query.or(`period_label.ilike.%${q}%,callsheet_url.ilike.%${q}%,notes.ilike.%${q}%`);
}
if (org_id) query = query.eq("org_id", org_id);
if (period_month) query = query.eq("period_month", period_month);
if (mode) query = query.eq("mode", mode);
if (notification_sent !== null) {

View file

@ -1,13 +1,13 @@
// components/staff/BulkESignConfirmModal.tsx
"use client";
import React from "react";
import React, { useState } from "react";
import { X, FileSignature, AlertTriangle } from "lucide-react";
type BulkESignConfirmModalProps = {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
onConfirm: (sendNotification: boolean) => void;
contractCount: number;
};
@ -17,6 +17,8 @@ export default function BulkESignConfirmModal({
onConfirm,
contractCount
}: BulkESignConfirmModalProps) {
const [sendNotification, setSendNotification] = useState(true);
if (!isOpen) return null;
return (
@ -49,18 +51,38 @@ export default function BulkESignConfirmModal({
</div>
{/* Info */}
<div className="mb-6 text-sm text-gray-600 space-y-2">
<div className="mb-4 text-sm text-gray-600 space-y-2">
<p>Cette action va :</p>
<ul className="list-disc list-inside space-y-1 ml-2">
<li>Créer une demande de signature électronique pour chaque contrat</li>
<li>Envoyer un email à l'employeur et au salarié pour chaque contrat</li>
<li>Envoyer un email récapitulatif au client</li>
{sendNotification && <li className="text-indigo-600 font-medium">Envoyer un email récapitulatif au client</li>}
</ul>
<p className="mt-3 font-medium">
Assurez-vous que tous les contrats ont un PDF généré et que les salariés ont un email renseigné.
</p>
</div>
{/* Option: Envoyer notification */}
<div className="mb-6 p-3 bg-gray-50 border border-gray-200 rounded-lg">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={sendNotification}
onChange={(e) => setSendNotification(e.target.checked)}
className="mt-0.5 size-4 text-indigo-600 rounded focus:ring-indigo-500"
/>
<div className="flex-1">
<span className="text-sm font-medium text-gray-900">
Envoyer un email récapitulatif au client
</span>
<p className="text-xs text-gray-500 mt-1">
Si décoché, les signatures seront créées dans DocuSeal mais aucun email de notification groupée ne sera envoyé à l'employeur.
</p>
</div>
</label>
</div>
{/* Actions */}
<div className="flex justify-end gap-2">
<button
@ -70,7 +92,7 @@ export default function BulkESignConfirmModal({
Annuler
</button>
<button
onClick={onConfirm}
onClick={() => onConfirm(sendNotification)}
className="px-4 py-2 text-sm bg-indigo-600 text-white rounded hover:bg-indigo-700"
>
Confirmer et envoyer

View file

@ -119,7 +119,7 @@ export default function ContractsGrid({ initialData, activeOrgId }: { initialDat
// 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 || "CDD d'usage");
const [typeFilter, setTypeFilter] = useState<string | null>(savedFilters?.typeFilter || null);
const [etatContratFilter, setEtatContratFilter] = useState<string | null>(savedFilters?.etatContratFilter || null);
const [etatPaieFilter, setEtatPaieFilter] = useState<string | null>(savedFilters?.etatPaieFilter || null);
const [dpaeFilter, setDpaeFilter] = useState<string | null>(savedFilters?.dpaeFilter || null);
@ -328,7 +328,7 @@ export default function ContractsGrid({ initialData, activeOrgId }: { initialDat
// Apply local sorting when using initial data
const sortedRows = useMemo(() => {
const noFilters = !q && !structureFilter && typeFilter === "CDD d'usage" && !etatContratFilter && !etatPaieFilter && !dpaeFilter && !startFrom && !startTo;
const noFilters = !q && !structureFilter && !typeFilter && !etatContratFilter && !etatPaieFilter && !dpaeFilter && !startFrom && !startTo;
if (noFilters) {
// Utiliser le tri local pour les données initiales
@ -605,7 +605,7 @@ export default function ContractsGrid({ initialData, activeOrgId }: { initialDat
};
// Fonction pour générer les e-signatures en masse (après confirmation)
const generateBatchESign = async () => {
const generateBatchESign = async (sendNotification: boolean) => {
// Fermer le modal de confirmation
setShowESignConfirmModal(false);
@ -746,8 +746,8 @@ export default function ContractsGrid({ initialData, activeOrgId }: { initialDat
if (successCount > 0) {
toast.success(`${successCount} signature${successCount > 1 ? 's' : ''} électronique${successCount > 1 ? 's' : ''} créée${successCount > 1 ? 's' : ''} !`);
// Envoyer un mail récapitulatif au client (email_notifs + CC)
if (successfulContracts.length > 0) {
// Envoyer un mail récapitulatif au client (email_notifs + CC) si demandé
if (sendNotification && successfulContracts.length > 0) {
try {
console.log('📧 [E-SIGN] Début envoi emails récapitulatifs...');
console.log('📧 [E-SIGN] successfulContracts:', successfulContracts);
@ -797,6 +797,8 @@ export default function ContractsGrid({ initialData, activeOrgId }: { initialDat
console.error("Erreur lors de l'envoi du mail récapitulatif:", emailError);
toast.warning("Signatures créées mais erreur lors de l'envoi de l'email récapitulatif");
}
} else if (!sendNotification) {
console.log('📧 [E-SIGN] Email récapitulatif désactivé par l\'utilisateur');
}
}
if (errorCount > 0) {
@ -834,7 +836,7 @@ export default function ContractsGrid({ initialData, activeOrgId }: { initialDat
// Debounce searches when filters change
useEffect(() => {
// if no filters applied, prefer initial data
const noFilters = !q && !structureFilter && typeFilter === "CDD d'usage" && !etatContratFilter && !etatPaieFilter && !dpaeFilter && !startFrom && !startTo && sortField === 'start_date' && sortOrder === 'desc';
const noFilters = !q && !structureFilter && !typeFilter && !etatContratFilter && !etatPaieFilter && !dpaeFilter && !startFrom && !startTo && sortField === 'start_date' && sortOrder === 'desc';
if (noFilters) {
setRows(initialData || []);
return;
@ -846,7 +848,10 @@ export default function ContractsGrid({ initialData, activeOrgId }: { initialDat
}, [q, structureFilter, typeFilter, etatContratFilter, etatPaieFilter, dpaeFilter, startFrom, startTo, sortField, sortOrder, limit]);
// derive options from initialData for simple selects
const structures = useMemo(() => Array.from(new Set((initialData || []).map((r) => r.structure).filter(Boolean) as string[])).slice(0,50), [initialData]);
const structures = useMemo(() => {
const uniqueStructures = Array.from(new Set((initialData || []).map((r) => r.structure).filter(Boolean) as string[]));
return uniqueStructures.sort((a, b) => a.localeCompare(b, 'fr')).slice(0, 50);
}, [initialData]);
const types = useMemo(() => Array.from(new Set((initialData || []).map((r) => r.type_de_contrat).filter(Boolean) as string[])).slice(0,50), [initialData]);
const etatsContrat = useMemo(() => Array.from(new Set((initialData || []).map((r) => r.etat_de_la_demande).filter(Boolean) as string[])).slice(0,50), [initialData]);
const etatsPaie = useMemo(() => Array.from(new Set((initialData || []).map((r) => r.etat_de_la_paie).filter(Boolean) as string[])).slice(0,50), [initialData]);
@ -874,7 +879,9 @@ export default function ContractsGrid({ initialData, activeOrgId }: { initialDat
</select>
<select value={typeFilter ?? ""} onChange={(e) => setTypeFilter(e.target.value || null)} className="rounded border px-2 py-1 text-sm">
<option value="">Tous types</option>
{types.map((t) => (<option key={t} value={t}>{t === "CDD d'usage" ? "CDDU" : t}</option>))}
<option value="CDD d'usage">CDDU</option>
<option value="RG">RG (CDD/CDI droit commun)</option>
{types.filter(t => t !== "CDD d'usage" && t !== "CDD de droit commun" && t !== "CDI").map((t) => (<option key={t} value={t}>{t}</option>))}
</select>
<button
@ -899,7 +906,7 @@ export default function ContractsGrid({ initialData, activeOrgId }: { initialDat
onClick={() => {
setQ('');
setStructureFilter(null);
setTypeFilter("CDD d'usage");
setTypeFilter(null);
setEtatContratFilter(null);
setEtatPaieFilter(null);
setDpaeFilter(null);

View file

@ -49,12 +49,32 @@ type SalaryTransfer = {
updated_at?: string | null;
};
export default function SalaryTransfersGrid({ initialData, activeOrgId }: { initialData: SalaryTransfer[]; activeOrgId?: string | null }) {
type Organization = {
id: string;
name: string;
};
export default function SalaryTransfersGrid({
initialData,
activeOrgId,
organizations = []
}: {
initialData: SalaryTransfer[];
activeOrgId?: string | null;
organizations?: Organization[];
}) {
const [rows, setRows] = useState<SalaryTransfer[]>(initialData || []);
const [showRaw, setShowRaw] = useState(false);
const [loading, setLoading] = useState(false);
// Debug log pour vérifier que les organisations sont bien passées
useEffect(() => {
console.log("[SalaryTransfersGrid] Organizations received:", organizations?.length, organizations);
}, [organizations]);
// filters / sorting / pagination
const [q, setQ] = useState("");
const [orgFilter, setOrgFilter] = useState<string | null>(null);
const [modeFilter, setModeFilter] = useState<string | null>(null);
const [notificationSentFilter, setNotificationSentFilter] = useState<string | null>(null);
const [notificationOkFilter, setNotificationOkFilter] = useState<string | null>(null);
@ -147,6 +167,7 @@ export default function SalaryTransfersGrid({ initialData, activeOrgId }: { init
try {
const params = new URLSearchParams();
if (q) params.set('q', q);
if (orgFilter) params.set('org_id', orgFilter);
if (modeFilter) params.set('mode', modeFilter);
if (notificationSentFilter) params.set('notification_sent', notificationSentFilter);
if (notificationOkFilter) params.set('notification_ok', notificationOkFilter);
@ -174,7 +195,7 @@ export default function SalaryTransfersGrid({ initialData, activeOrgId }: { init
// Debounce searches when filters change
useEffect(() => {
// if no filters applied, prefer initial data
const noFilters = !q && !modeFilter && !notificationSentFilter && !notificationOkFilter && !hasClientWireFilter && !deadlineFrom && !deadlineTo && sortField === 'period_month' && sortOrder === 'desc';
const noFilters = !q && !orgFilter && !modeFilter && !notificationSentFilter && !notificationOkFilter && !hasClientWireFilter && !deadlineFrom && !deadlineTo && sortField === 'period_month' && sortOrder === 'desc';
if (noFilters) {
setRows(initialData || []);
return;
@ -183,7 +204,7 @@ export default function SalaryTransfersGrid({ initialData, activeOrgId }: { init
const t = setTimeout(() => fetchServer(0), 300);
return () => clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [q, modeFilter, notificationSentFilter, notificationOkFilter, hasClientWireFilter, deadlineFrom, deadlineTo, sortField, sortOrder, limit]);
}, [q, orgFilter, modeFilter, notificationSentFilter, notificationOkFilter, hasClientWireFilter, deadlineFrom, deadlineTo, sortField, sortOrder, limit]);
// derive options from initialData for simple selects
const modes = useMemo(() => Array.from(new Set((initialData || []).map((r) => r.mode).filter(Boolean) as string[])).slice(0,50), [initialData]);
@ -204,6 +225,28 @@ export default function SalaryTransfersGrid({ initialData, activeOrgId }: { init
</div>
<div className="flex items-center gap-2">
{/* Filtres rapides toujours visibles */}
<div className="flex items-center gap-1">
<label className="text-xs font-medium text-slate-700">Organisation:</label>
<select
value={orgFilter ?? ""}
onChange={(e) => setOrgFilter(e.target.value || null)}
className="rounded border px-3 py-2 text-sm bg-white min-w-[200px]"
disabled={!organizations || organizations.length === 0}
>
<option value="">
{!organizations || organizations.length === 0
? "Chargement..."
: "Toutes les organisations"}
</option>
{organizations && organizations.map((org) => (
<option key={org.id} value={org.id}>{org.name}</option>
))}
</select>
{organizations && organizations.length > 0 && (
<span className="text-xs text-slate-500">({organizations.length})</span>
)}
</div>
<select value={modeFilter ?? ""} onChange={(e) => setModeFilter(e.target.value || null)} className="rounded border px-2 py-1 text-sm">
<option value="">Tous modes</option>
{modes.map((m) => (<option key={m} value={m}>{m}</option>))}
@ -220,6 +263,7 @@ export default function SalaryTransfersGrid({ initialData, activeOrgId }: { init
className="rounded border px-3 py-1 text-sm"
onClick={() => {
setQ('');
setOrgFilter(null);
setModeFilter(null);
setNotificationSentFilter(null);
setNotificationOkFilter(null);

View file

@ -17,6 +17,7 @@ import { PROFESSIONS_ARTISTE } from "@/components/constants/ProfessionsArtiste";
import { LoadingModal } from "@/components/ui/loading-modal";
import { PayslipModal } from "./PayslipModal";
import { EmployerReminderModal } from "./EmployerReminderModal";
import { PayslipCard } from "./PayslipCard";
type AnyObj = Record<string, any>;
@ -1011,6 +1012,23 @@ export default function ContractEditor({
window.location.reload(); // Pour l'instant, on recharge. Idéalement on ferait un refetch
};
// Fonction pour rafraîchir les payslips après un upload
const handlePayslipUploadComplete = async () => {
try {
// Recharger les payslips depuis la base de données
const response = await fetch(`/api/staff/payslips?contract_id=${contract.id}`);
if (response.ok) {
const data = await response.json();
setRows(data.payslips ?? []);
toast.success("Données mises à jour");
}
} catch (error) {
console.error("Erreur lors du rafraîchissement des payslips:", error);
// En cas d'erreur, on recharge la page
window.location.reload();
}
};
// Fonction pour marquer le contrat comme non signé
const [isMarkingUnsigned, setIsMarkingUnsigned] = useState(false);
@ -2066,103 +2084,14 @@ export default function ContractEditor({
) : (
<div className="space-y-3">
{rows.map((payslip, i) => (
<div
<PayslipCard
key={payslip.id ?? i}
payslip={payslip}
index={i}
contractId={contract.id}
onClick={() => handleOpenPayslipModal(payslip)}
className="border rounded-2xl p-4 hover:bg-gray-50 cursor-pointer transition-colors"
>
<div className="space-y-3">
{/* En-tête: numéro, période et date de paie */}
<div className="flex items-center gap-4">
<div className="bg-blue-100 text-blue-800 px-3 py-1 rounded-full text-sm font-medium">
#{payslip.pay_number || i + 1}
</div>
<div className="flex-1">
<div className="font-medium text-gray-900">
{payslip.period_start && payslip.period_end ? (
`${new Date(payslip.period_start).toLocaleDateString('fr-FR')} - ${new Date(payslip.period_end).toLocaleDateString('fr-FR')}`
) : (
"Période non définie"
)}
</div>
<div className="text-sm text-gray-500">
{payslip.pay_date ? `Paie du ${new Date(payslip.pay_date).toLocaleDateString('fr-FR')}` : "Date non définie"}
</div>
</div>
</div>
{/* Montants organisés en 2x2 */}
<div className="grid grid-cols-2 gap-4">
<div>
<div className="text-sm text-gray-500">Brut</div>
<div className="font-semibold text-gray-900">
{payslip.gross_amount ? `${parseFloat(payslip.gross_amount).toFixed(2)}` : "Non défini"}
</div>
</div>
<div>
<div className="text-sm text-gray-500">Net avant PAS</div>
<div className="font-semibold text-gray-900">
{payslip.net_amount ? `${parseFloat(payslip.net_amount).toFixed(2)}` : "Non défini"}
</div>
</div>
<div>
<div className="text-sm text-gray-500">Net à payer</div>
<div className="font-semibold text-gray-900">
{payslip.net_after_withholding ? `${parseFloat(payslip.net_after_withholding).toFixed(2)}` : "Non défini"}
</div>
</div>
<div>
<div className="text-sm text-gray-500">Coût employeur</div>
<div className="font-semibold text-gray-900">
{payslip.employer_cost ? `${parseFloat(payslip.employer_cost).toFixed(2)}` : "Non défini"}
</div>
</div>
</div>
{/* Indicateurs en bas */}
<div className="flex gap-2 flex-wrap">
{/* Indicateur traitement */}
{!payslip.processed ? (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-red-50 text-red-600">
À traiter
</span>
) : (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-50 text-green-600">
Traitée
</span>
)}
{/* Indicateur virement */}
{payslip.processed && (
payslip.transfer_done ? (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-emerald-50 text-emerald-600">
Virement OK
</span>
) : (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-orange-50 text-orange-600">
Virement en attente
</span>
)
)}
{/* Indicateur AEM */}
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
payslip.aem_status === 'OK'
? 'bg-green-50 text-green-600'
: 'bg-yellow-50 text-yellow-600'
}`}>
AEM: {payslip.aem_status || 'À traiter'}
</span>
{/* Indicateur PDF */}
{payslip.storage_url && (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-50 text-blue-600">
PDF
</span>
)}
</div>
</div>
</div>
onUploadComplete={handlePayslipUploadComplete}
/>
))}
</div>
)}

View file

@ -0,0 +1,273 @@
"use client";
import { useState, useRef, DragEvent } from "react";
import { CheckCircle2, Upload, FileText, X, Loader2 } from "lucide-react";
import { toast } from "sonner";
type PayslipCardProps = {
payslip: any;
index: number;
contractId: string;
onClick?: () => void;
onUploadComplete?: () => void;
};
export function PayslipCard({ payslip, index, contractId, onClick, onUploadComplete }: PayslipCardProps) {
const [isDragging, setIsDragging] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleDragEnter = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
};
const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
};
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
};
const handleDrop = async (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
if (files.length === 0) return;
const file = files[0];
// Vérifier que c'est un PDF
if (file.type !== 'application/pdf') {
toast.error("Seuls les fichiers PDF sont acceptés");
return;
}
await uploadFile(file);
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
const file = files[0];
// Vérifier que c'est un PDF
if (file.type !== 'application/pdf') {
toast.error("Seuls les fichiers PDF sont acceptés");
return;
}
uploadFile(file);
};
const uploadFile = async (file: File) => {
setIsUploading(true);
try {
const formData = new FormData();
formData.append('file', file);
formData.append('contract_id', contractId);
formData.append('payslip_id', payslip.id);
const response = await fetch('/api/staff/payslip-upload', {
method: 'POST',
body: formData,
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Erreur lors de l\'upload');
}
const result = await response.json();
toast.success("Bulletin de paie uploadé avec succès !");
// Notifier le parent pour rafraîchir les données
if (onUploadComplete) {
onUploadComplete();
}
} catch (error) {
console.error('Erreur upload:', error);
toast.error(error instanceof Error ? error.message : "Erreur lors de l'upload");
} finally {
setIsUploading(false);
// Reset le file input
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
};
const handleCardClick = (e: React.MouseEvent) => {
// Ne pas déclencher onClick si on clique sur la zone d'upload
if (isUploading || isDragging) {
return;
}
if (onClick) {
onClick();
}
};
const handleUploadAreaClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (fileInputRef.current) {
fileInputRef.current.click();
}
};
const hasPdf = !!payslip.bulletin_pdf_url;
return (
<div
className={`border rounded-2xl p-4 transition-all ${
isDragging
? 'border-blue-500 bg-blue-50 border-2'
: hasPdf
? 'border-green-200 bg-green-50 hover:bg-green-100'
: 'hover:bg-gray-50'
} ${!isUploading ? 'cursor-pointer' : ''}`}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onClick={handleCardClick}
>
<div className="space-y-3">
{/* En-tête: numéro, période et date de paie */}
<div className="flex items-center gap-4">
<div className="bg-blue-100 text-blue-800 px-3 py-1 rounded-full text-sm font-medium">
#{payslip.pay_number || index + 1}
</div>
<div className="flex-1">
<div className="font-medium text-gray-900">
{payslip.period_start && payslip.period_end ? (
`${new Date(payslip.period_start).toLocaleDateString('fr-FR')} - ${new Date(payslip.period_end).toLocaleDateString('fr-FR')}`
) : (
"Période non définie"
)}
</div>
<div className="text-sm text-gray-500">
{payslip.pay_date ? `Paie du ${new Date(payslip.pay_date).toLocaleDateString('fr-FR')}` : "Date non définie"}
</div>
</div>
{/* Indicateur de bulletin PDF */}
{hasPdf && (
<div className="flex items-center gap-2 bg-green-100 text-green-700 px-3 py-1 rounded-full text-xs font-medium">
<CheckCircle2 className="size-4" />
Bulletin uploadé
</div>
)}
</div>
{/* Montants organisés en 2x2 */}
<div className="grid grid-cols-2 gap-4">
<div>
<div className="text-sm text-gray-500">Brut</div>
<div className="font-semibold text-gray-900">
{payslip.gross_amount ? `${parseFloat(payslip.gross_amount).toFixed(2)}` : "Non défini"}
</div>
</div>
<div>
<div className="text-sm text-gray-500">Net avant PAS</div>
<div className="font-semibold text-gray-900">
{payslip.net_amount ? `${parseFloat(payslip.net_amount).toFixed(2)}` : "Non défini"}
</div>
</div>
<div>
<div className="text-sm text-gray-500">Net à payer</div>
<div className="font-semibold text-gray-900">
{payslip.net_after_withholding ? `${parseFloat(payslip.net_after_withholding).toFixed(2)}` : "Non défini"}
</div>
</div>
<div>
<div className="text-sm text-gray-500">Coût employeur</div>
<div className="font-semibold text-gray-900">
{payslip.employer_cost ? `${parseFloat(payslip.employer_cost).toFixed(2)}` : "Non défini"}
</div>
</div>
</div>
{/* Zone de drag & drop ou bouton d'upload */}
{!hasPdf && (
<div
onClick={handleUploadAreaClick}
className={`border-2 border-dashed rounded-xl p-4 text-center transition-all ${
isDragging
? 'border-blue-500 bg-blue-100'
: 'border-gray-300 bg-gray-50 hover:border-blue-400 hover:bg-blue-50'
}`}
>
{isUploading ? (
<div className="flex flex-col items-center gap-2">
<Loader2 className="size-6 text-blue-500 animate-spin" />
<p className="text-sm text-blue-600 font-medium">Upload en cours...</p>
</div>
) : (
<div className="flex flex-col items-center gap-2">
<Upload className="size-6 text-gray-400" />
<p className="text-sm text-gray-600">
<span className="font-medium text-blue-600">Cliquez</span> ou glissez un PDF ici
</p>
<p className="text-xs text-gray-500">Bulletin de paie au format PDF</p>
</div>
)}
<input
ref={fileInputRef}
type="file"
accept="application/pdf"
onChange={handleFileSelect}
className="hidden"
/>
</div>
)}
{/* Indicateurs en bas */}
<div className="flex gap-2 flex-wrap">
{/* Indicateur traitement */}
{!payslip.processed ? (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-red-50 text-red-600">
À traiter
</span>
) : (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-50 text-green-600">
Traitée
</span>
)}
{/* Indicateur virement */}
{payslip.processed && (
payslip.transfer_done ? (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-emerald-50 text-emerald-600">
Virement OK
</span>
) : (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-orange-50 text-orange-600">
Virement en attente
</span>
)
)}
{/* Indicateur AEM */}
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
payslip.aem_status === 'OK'
? 'bg-green-50 text-green-600'
: payslip.aem_status === 'KO'
? 'bg-red-50 text-red-600'
: 'bg-gray-50 text-gray-600'
}`}>
AEM: {payslip.aem_status || 'N/A'}
</span>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,15 @@
-- Migration: Ajouter 'bulk-signature-notification' à l'enum email_type
-- Date: 2025-10-13
-- Description: Ajoute le type d'email pour les notifications groupées de signature électronique
-- Ajouter la nouvelle valeur à l'enum email_type si elle n'existe pas déjà
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_enum
WHERE enumlabel = 'bulk-signature-notification'
AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'email_type')
) THEN
ALTER TYPE email_type ADD VALUE 'bulk-signature-notification';
END IF;
END $$;