feat: Ajout système de sélection manuelle de paies pour virements + corrections RLS

- Ajout mode de sélection 'period' (existant) et 'manual' (nouveau) pour les virements
- Création table de liaison salary_transfer_payslips pour paies sélectionnées manuellement
- Nouveau modal de sélection de paies avec filtres (recherche, période, statut)
- API route /api/staff/payslips/available pour récupérer les paies disponibles
- Rendre period_month nullable en mode manual avec contrainte de validation
- Correction fonction is_staff() pour vérifier is_staff = true
- Correction is_member_of_org() pour utiliser la version à jour de is_staff()
- Mise à jour génération PDF pour supporter les deux modes (period et manual)
- Filtre des organisations sur virements-salaires (uniquement celles avec virements_salaires = 'Odentas')
- Amélioration affichage totaux dans PayslipsGrid (total sélection si lignes sélectionnées)
- Ajout boutons 'Créer par période' et 'Créer personnalisé' dans SalaryTransfersGrid
- Interface optimisée (textes plus courts, tailles réduites)
This commit is contained in:
odentas 2025-11-28 20:12:48 +01:00
parent 5020298912
commit 8ba984af1d
11 changed files with 1273 additions and 113 deletions

View file

@ -43,10 +43,11 @@ export default async function StaffSalaryTransfersPage() {
.order("period_month", { ascending: false })
.limit(200);
// Récupérer toutes les organisations pour le filtre
// Récupérer toutes les organisations pour le filtre (uniquement celles dont virements_salaires = "Odentas")
const { data: organizations, error: orgError } = await sb
.from("organizations")
.select("id, name")
.select("id, name, organization_details!inner(virements_salaires)")
.eq("organization_details.virements_salaires", "Odentas")
.order("name", { ascending: true });
if (orgError) {

View file

@ -0,0 +1,136 @@
import { NextRequest, NextResponse } from "next/server";
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { cookies } from "next/headers";
// =============================================================================
// GET /api/staff/payslips/available?org_id=xxx
// Récupère les paies disponibles pour une organisation (non liées à un virement manuel)
// =============================================================================
export async function GET(req: NextRequest) {
try {
// 1) Check auth
const supabase = createRouteHandlerClient({ cookies });
const {
data: { session },
error: sessionError,
} = await supabase.auth.getSession();
if (sessionError || !session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const user = session.user;
// 2) Check if staff
const { data: staffData, error: staffError } = await supabase
.from("staff_users")
.select("is_staff")
.eq("user_id", user.id)
.maybeSingle();
const isStaff = staffData?.is_staff || false;
if (!isStaff) {
return NextResponse.json(
{ error: "Forbidden: staff only" },
{ status: 403 }
);
}
// 3) Get org_id from query params
const searchParams = req.nextUrl.searchParams;
const orgId = searchParams.get("org_id");
if (!orgId) {
return NextResponse.json(
{ error: "Missing org_id parameter" },
{ status: 400 }
);
}
// 4) Get payslips for this organization (last 2 months by default)
const twoMonthsAgo = new Date();
twoMonthsAgo.setMonth(twoMonthsAgo.getMonth() - 2);
const minDate = twoMonthsAgo.toISOString().split('T')[0]; // Format YYYY-MM-DD
const { data: payslips, error: payslipsError } = await supabase
.from("payslips")
.select(`
id,
contract_id,
period_month,
period_start,
period_end,
pay_date,
net_amount,
net_after_withholding,
processed,
organization_id,
cddu_contracts (
id,
contract_number,
employee_name,
employee_id,
employee_matricule,
salaries (
nom,
prenom
)
)
`)
.eq("organization_id", orgId)
.gte("period_start", minDate)
.order("period_start", { ascending: false });
if (payslipsError) {
console.error("[GET /api/staff/payslips/available] Error:", payslipsError);
return NextResponse.json(
{ error: "Failed to fetch payslips", details: payslipsError.message },
{ status: 500 }
);
}
// 5) Get payslips already linked to manual transfers
const { data: linkedPayslips, error: linkedError } = await supabase
.from("salary_transfer_payslips")
.select("payslip_id");
if (linkedError) {
console.warn("[GET /api/staff/payslips/available] Warning: could not fetch linked payslips:", linkedError);
// Continue without filtering
return NextResponse.json({
success: true,
payslips: payslips || [],
total: payslips?.length || 0,
});
}
// 6) Filter out already linked payslips
const linkedIds = new Set(linkedPayslips?.map(lp => lp.payslip_id) || []);
const availablePayslips = (payslips || []).filter(p => !linkedIds.has(p.id));
console.log("[GET /api/staff/payslips/available] Results:", {
orgId,
total: payslips?.length || 0,
linked: linkedIds.size,
available: availablePayslips.length,
});
return NextResponse.json({
success: true,
payslips: availablePayslips,
total: availablePayslips.length,
stats: {
total: payslips?.length || 0,
linked: linkedIds.size,
available: availablePayslips.length,
},
});
} catch (err: any) {
console.error("[GET /api/staff/payslips/available] Error:", err);
return NextResponse.json(
{ error: err.message || "Internal server error" },
{ status: 500 }
);
}
}

View file

@ -48,13 +48,36 @@ export async function POST(req: NextRequest) {
num_appel,
total_net,
notes,
selection_mode, // 'period' (default) or 'manual'
payslip_ids, // Array of payslip IDs (required if selection_mode = 'manual')
} = body;
// 4) Validate required fields
if (!org_id || !period_month || !deadline || !mode) {
const selectionMode = selection_mode || 'period';
if (!org_id || !deadline || !mode) {
return NextResponse.json(
{
error: "Missing required fields: org_id, period_month, deadline, mode",
error: "Missing required fields: org_id, deadline, mode",
},
{ status: 400 }
);
}
// Validate based on selection mode
if (selectionMode === 'period' && !period_month) {
return NextResponse.json(
{
error: "period_month is required when selection_mode is 'period'",
},
{ status: 400 }
);
}
if (selectionMode === 'manual' && (!payslip_ids || !Array.isArray(payslip_ids) || payslip_ids.length === 0)) {
return NextResponse.json(
{
error: "payslip_ids array is required and must not be empty when selection_mode is 'manual'",
},
{ status: 400 }
);
@ -72,17 +95,44 @@ export async function POST(req: NextRequest) {
{ status: 404 }
);
}
// 5b) If manual mode, verify all payslips exist and belong to the organization
if (selectionMode === 'manual') {
const { data: payslips, error: payslipsError } = await supabase
.from("payslips")
.select("id, organization_id, period_month")
.in("id", payslip_ids);
if (payslipsError || !payslips || payslips.length !== payslip_ids.length) {
return NextResponse.json(
{ error: "One or more payslips not found" },
{ status: 404 }
);
}
// Verify all payslips belong to the specified organization
const invalidPayslips = payslips.filter(p => p.organization_id !== org_id);
if (invalidPayslips.length > 0) {
return NextResponse.json(
{ error: `${invalidPayslips.length} payslip(s) do not belong to organization ${org_id}` },
{ status: 400 }
);
}
console.log("[create salary transfer] Manual mode: verified", payslips.length, "payslips");
}
// 6) Insert new salary transfer
const insertData = {
org_id,
period_month,
period_month: selectionMode === 'period' ? period_month : null, // NULL for manual mode
period_label: period_label || null,
deadline,
mode,
num_appel: num_appel || null,
total_net: total_net || null,
notes: notes || null,
selection_mode: selectionMode,
notification_sent: false,
notification_ok: false,
salaires_payes: false,
@ -110,11 +160,39 @@ export async function POST(req: NextRequest) {
{ status: 500 }
);
}
// 6b) If manual mode, insert payslip links
if (selectionMode === 'manual' && payslip_ids && payslip_ids.length > 0) {
const links = payslip_ids.map(payslip_id => ({
salary_transfer_id: newTransfer.id,
payslip_id,
}));
const { error: linksError } = await supabase
.from("salary_transfer_payslips")
.insert(links);
if (linksError) {
console.error("[create salary transfer] Links insert error:", linksError);
// Rollback: delete the transfer we just created
await supabase.from("salary_transfers").delete().eq("id", newTransfer.id);
return NextResponse.json(
{
error: "Failed to link payslips to salary transfer",
details: linksError.message
},
{ status: 500 }
);
}
console.log("[create salary transfer] Linked", payslip_ids.length, "payslips to transfer");
}
// 7) Return the new record
return NextResponse.json({
success: true,
data: newTransfer,
payslips_count: selectionMode === 'manual' ? payslip_ids?.length : null,
});
} catch (err: any) {
console.error("Error in create salary transfer:", err);

View file

@ -178,30 +178,76 @@ export async function POST(req: NextRequest) {
}
console.log("[generate-pdf] Organization details loaded:", orgDetails ? "✓" : "✗");
// 6) Get payslips for this period
console.log("[generate-pdf] Fetching payslips for period...");
const periodMonth = salaryTransfer.period_month; // Already in YYYY-MM-01 format
console.log("[generate-pdf] Period month:", periodMonth);
// 6) Get payslips based on selection mode
console.log("[generate-pdf] Fetching payslips...");
console.log("[generate-pdf] Selection mode:", salaryTransfer.selection_mode || 'period');
console.log("[generate-pdf] Organization ID:", salaryTransfer.org_id);
const { data: payslips, error: payslipsError } = await supabase
.from("payslips")
.select(`
*,
cddu_contracts (
employee_matricule,
contract_number,
analytique,
profession,
salaries (
nom,
prenom,
iban
let payslips: any[] | null = null;
let payslipsError: any = null;
if (salaryTransfer.selection_mode === 'manual') {
// Mode manuel: récupérer les paies liées via la table de liaison
console.log("[generate-pdf] Manual mode: fetching linked payslips");
const { data: linkedPayslips, error: linkedError } = await supabase
.from("salary_transfer_payslips")
.select(`
payslip_id,
payslips (
*,
cddu_contracts (
employee_matricule,
contract_number,
analytique,
profession,
salaries (
nom,
prenom,
iban
)
)
)
)
`)
.eq("organization_id", salaryTransfer.org_id)
.eq("period_month", periodMonth);
`)
.eq("salary_transfer_id", salary_transfer_id);
payslipsError = linkedError;
if (!linkedError && linkedPayslips) {
// Flatten the structure (extract payslips from the join)
payslips = linkedPayslips
.map(lp => lp.payslips)
.filter(p => p !== null);
console.log("[generate-pdf] Found", payslips.length, "linked payslips");
}
} else {
// Mode période: récupérer toutes les paies du mois (comportement actuel)
console.log("[generate-pdf] Period mode: fetching all payslips for period");
const periodMonth = salaryTransfer.period_month; // Already in YYYY-MM-01 format
console.log("[generate-pdf] Period month:", periodMonth);
const { data: periodPayslips, error: periodError } = await supabase
.from("payslips")
.select(`
*,
cddu_contracts (
employee_matricule,
contract_number,
analytique,
profession,
salaries (
nom,
prenom,
iban
)
)
`)
.eq("organization_id", salaryTransfer.org_id)
.eq("period_month", periodMonth);
payslips = periodPayslips;
payslipsError = periodError;
}
console.log("[generate-pdf] Payslips query result:", {
error: payslipsError,
@ -221,16 +267,18 @@ export async function POST(req: NextRequest) {
details: (payslipsError as any)?.message || "Unknown error",
debugInfo: {
org_id: salaryTransfer.org_id,
period_month: periodMonth
selection_mode: salaryTransfer.selection_mode,
period_month: salaryTransfer.period_month
}
}, { status: 500 });
}
if (!payslips || payslips.length === 0) {
console.warn("[generate-pdf] No payslips found for this period!");
console.warn("[generate-pdf] No payslips found!");
console.warn("[generate-pdf] Query params:", {
organization_id: salaryTransfer.org_id,
period_month: periodMonth
selection_mode: salaryTransfer.selection_mode,
period_month: salaryTransfer.period_month
});
// Continue anyway - we'll generate a PDF with empty payslips array
} else {
@ -332,7 +380,7 @@ export async function POST(req: NextRequest) {
transfer_method: salaryTransfer.mode || "Virement SEPA",
// Period and amounts
periode: formatMonthPeriod(salaryTransfer.period_month),
periode: salaryTransfer.period_label || formatMonthPeriod(salaryTransfer.period_month),
total_salaires_nets: totalNet,
solde_compte_client: 0.0, // À implémenter si nécessaire
total_transfer: totalNet, // Pour l'instant, même que total_salaires_nets

View file

@ -1017,28 +1017,30 @@ export default function PayslipsGrid({ initialData, activeOrgId }: { initialData
</tr>
<tr className="bg-indigo-50 border-t border-slate-200 font-semibold">
<td className="px-3 py-2" colSpan={9}>
<span className="text-slate-700">Totaux ({sortedRows.length} ligne{sortedRows.length > 1 ? 's' : ''})</span>
<span className="text-slate-700">
Totaux ({selectedPayslipIds.size > 0 ? `${selectedPayslipIds.size} ligne${selectedPayslipIds.size > 1 ? 's' : ''} sélectionnée${selectedPayslipIds.size > 1 ? 's' : ''}` : `${sortedRows.length} ligne${sortedRows.length > 1 ? 's' : ''}`})
</span>
</td>
<td className="text-right px-3 py-2 text-slate-700">
{formatCurrency(sortedRows.reduce((sum, r) => {
{formatCurrency((selectedPayslipIds.size > 0 ? sortedRows.filter(r => selectedPayslipIds.has(r.id)) : sortedRows).reduce((sum, r) => {
const val = typeof r.gross_amount === 'string' ? parseFloat(r.gross_amount) : (r.gross_amount || 0);
return sum + (isNaN(val) ? 0 : val);
}, 0))}
</td>
<td className="text-right px-3 py-2 text-slate-700">
{formatCurrency(sortedRows.reduce((sum, r) => {
{formatCurrency((selectedPayslipIds.size > 0 ? sortedRows.filter(r => selectedPayslipIds.has(r.id)) : sortedRows).reduce((sum, r) => {
const val = typeof r.net_amount === 'string' ? parseFloat(r.net_amount) : (r.net_amount || 0);
return sum + (isNaN(val) ? 0 : val);
}, 0))}
</td>
<td className="text-right px-3 py-2 text-slate-700">
{formatCurrency(sortedRows.reduce((sum, r) => {
{formatCurrency((selectedPayslipIds.size > 0 ? sortedRows.filter(r => selectedPayslipIds.has(r.id)) : sortedRows).reduce((sum, r) => {
const val = typeof r.net_after_withholding === 'string' ? parseFloat(r.net_after_withholding) : (r.net_after_withholding || 0);
return sum + (isNaN(val) ? 0 : val);
}, 0))}
</td>
<td className="text-right px-3 py-2 text-slate-700">
{formatCurrency(sortedRows.reduce((sum, r) => {
{formatCurrency((selectedPayslipIds.size > 0 ? sortedRows.filter(r => selectedPayslipIds.has(r.id)) : sortedRows).reduce((sum, r) => {
const val = typeof r.employer_cost === 'string' ? parseFloat(r.employer_cost) : (r.employer_cost || 0);
return sum + (isNaN(val) ? 0 : val);
}, 0))}

View file

@ -2,11 +2,12 @@
import { useEffect, useMemo, useState, useRef } from "react";
import { supabase } from "@/lib/supabaseClient";
import { Plus, Edit, Trash2, FileText, Save, X, CheckCircle2, XCircle } from "lucide-react";
import { Plus, Edit, Trash2, FileText, Save, X, CheckCircle2, XCircle, ListChecks } from "lucide-react";
import { ConfirmationModal } from "@/components/ui/confirmation-modal";
import { toast } from "sonner";
import NotifyClientModal from "./salary-transfers/NotifyClientModal";
import NotifyPaymentSentModal from "./salary-transfers/NotifyPaymentSentModal";
import PayslipsSelectionModal from "./salary-transfers/PayslipsSelectionModal";
// Utility function to format dates as DD/MM/YYYY
function formatDate(dateString: string | null | undefined): string {
@ -55,6 +56,7 @@ type SalaryTransfer = {
payment_notification_sent?: boolean | null;
payment_notification_sent_at?: string | null;
notes?: string | null;
selection_mode?: 'period' | 'manual' | null;
created_at?: string | null;
updated_at?: string | null;
};
@ -111,9 +113,15 @@ export default function SalaryTransfersGrid({
num_appel: "",
total_net: "",
notes: "",
selection_mode: "period" as "period" | "manual",
payslip_ids: [] as string[],
});
const [creating, setCreating] = useState(false);
// Modal de sélection des paies (mode manuel)
const [showPayslipsModal, setShowPayslipsModal] = useState(false);
const [selectedOrgForPayslips, setSelectedOrgForPayslips] = useState<{ id: string; name: string } | null>(null);
// PDF generation
const [generatingPdfForId, setGeneratingPdfForId] = useState<string | null>(null);
const [pdfError, setPdfError] = useState(false);
@ -286,21 +294,34 @@ export default function SalaryTransfersGrid({
// Function to create a new salary transfer
async function handleCreateTransfer() {
if (!createForm.org_id || !createForm.period_month || !createForm.deadline || !createForm.mode || !createForm.num_appel) {
toast.error("Veuillez remplir tous les champs obligatoires");
return;
// Validation différente selon le mode
if (createForm.selection_mode === 'period') {
if (!createForm.org_id || !createForm.period_month || !createForm.deadline || !createForm.mode || !createForm.num_appel) {
toast.error("Veuillez remplir tous les champs obligatoires");
return;
}
} else {
// Mode manuel
if (!createForm.org_id || !createForm.deadline || !createForm.mode || !createForm.num_appel) {
toast.error("Veuillez remplir tous les champs obligatoires");
return;
}
if (createForm.payslip_ids.length === 0) {
toast.error("Veuillez sélectionner au moins une paie");
return;
}
}
setCreating(true);
try {
// Convert period_month from "YYYY-MM" to "YYYY-MM-01" (first day of month)
const periodDate = createForm.period_month.includes('-') && createForm.period_month.length === 7
// Convert period_month from "YYYY-MM" to "YYYY-MM-01" (first day of month) only for period mode
const periodDate = createForm.selection_mode === 'period' && createForm.period_month.includes('-') && createForm.period_month.length === 7
? `${createForm.period_month}-01`
: createForm.period_month;
const payload = {
...createForm,
period_month: periodDate,
period_month: createForm.selection_mode === 'period' ? periodDate : null,
};
const res = await fetch("/api/staff/virements-salaires/create", {
@ -334,10 +355,15 @@ export default function SalaryTransfersGrid({
num_appel: "",
total_net: "",
notes: "",
selection_mode: "period",
payslip_ids: [],
});
setShowCreateModal(false);
toast.success("Virement créé avec succès");
const modeLabel = createForm.selection_mode === 'manual'
? `avec ${result.payslips_count} paie(s)`
: 'avec succès';
toast.success(`Virement créé ${modeLabel}`);
} catch (err: any) {
console.error("Create error:", err);
toast.error(err.message || "Erreur lors de la création");
@ -843,16 +869,41 @@ export default function SalaryTransfersGrid({
return (
<div className="relative">
{/* Header avec bouton de création */}
{/* Header avec boutons de création */}
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-800">Virements de salaires</h2>
<button
onClick={() => setShowCreateModal(true)}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus className="w-4 h-4" />
<span>Créer un virement</span>
</button>
<div className="flex items-center gap-2">
<button
onClick={() => {
setCreateForm({
...createForm,
selection_mode: "period",
payslip_ids: [],
});
setShowCreateModal(true);
}}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus className="w-4 h-4" />
<span>Créer par période</span>
</button>
<button
onClick={() => {
setCreateForm({
...createForm,
selection_mode: "manual",
period_month: "",
period_label: "",
payslip_ids: [],
});
setShowCreateModal(true);
}}
className="inline-flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
>
<ListChecks className="w-4 h-4" />
<span>Créer personnalisé</span>
</button>
</div>
</div>
{/* Barre d'actions groupées */}
@ -1111,7 +1162,7 @@ export default function SalaryTransfersGrid({
</div>
<div className="overflow-auto">
<table className="w-full text-sm">
<table className="w-full text-xs">
<thead className="bg-slate-50 text-slate-600">
<tr>
<th className="px-3 py-2 w-12">
@ -1166,18 +1217,18 @@ export default function SalaryTransfersGrid({
{/* Organisation */}
<td className="px-3 py-2">
<div className="font-medium text-sm">{r.organizations?.name || "—"}</div>
<div className="font-medium">{r.organizations?.name || "—"}</div>
</td>
{/* Période */}
<td className="px-3 py-2">
<div className="font-medium">{r.period_label || "—"}</div>
<div className="text-xs text-slate-500">{formatDate(r.period_month)}</div>
<div className="text-[10px] text-slate-500">{formatDate(r.period_month)}</div>
</td>
{/* Numéro d'appel */}
<td className="px-3 py-2">
<div className="font-mono text-sm">{r.num_appel || "—"}</div>
<div className="font-mono">{r.num_appel || "—"}</div>
</td>
{/* Échéance */}
@ -1189,46 +1240,39 @@ export default function SalaryTransfersGrid({
{/* Feuille d'appel */}
<td className="px-3 py-2">
{r.callsheet_url ? (
<div className="inline-flex items-center gap-2 px-3 py-1.5 bg-green-50 border border-green-200 rounded-lg">
<CheckCircle2 className="w-4 h-4 text-green-600" />
<span className="text-sm font-medium text-green-700">Disponible</span>
<div className="inline-flex items-center gap-1 px-2 py-0.5 bg-green-50 border border-green-200 rounded">
<CheckCircle2 className="w-3 h-3 text-green-600" />
<span className="font-medium text-green-700 whitespace-nowrap">Dispo</span>
</div>
) : (
<div className="inline-flex items-center gap-2 px-3 py-1.5 bg-slate-50 border border-slate-200 rounded-lg">
<XCircle className="w-4 h-4 text-slate-400" />
<span className="text-sm font-medium text-slate-500">Non générée</span>
<div className="inline-flex items-center gap-1 px-2 py-0.5 bg-slate-50 border border-slate-200 rounded">
<XCircle className="w-3 h-3 text-slate-400" />
<span className="font-medium text-slate-500 whitespace-nowrap">Non gén.</span>
</div>
)}
</td>
{/* Notification */}
<td className="px-3 py-2">
<span className={`inline-block px-2 py-1 rounded-full text-xs font-medium ${
<span className={`inline-block px-2 py-0.5 rounded-full text-[10px] font-medium whitespace-nowrap ${
r.notification_sent ? 'bg-green-100 text-green-800' : 'bg-slate-100 text-slate-800'
}`}>
{r.notification_sent ? '✓ Envoyée' : 'Non envoyée'}
{r.notification_sent ? '✓ Envoyée' : 'Non env.'}
</span>
</td>
{/* Paiement client reçu */}
<td className="px-3 py-2">
<div className="flex flex-col gap-1">
<span className={`inline-block px-2 py-1 rounded-full text-xs font-medium ${
r.notification_ok ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}>
{r.notification_ok ? '✓ Reçu' : 'Non reçu'}
</span>
{r.client_wire_received_at && (
<span className="text-xs text-slate-500">
{formatDate(r.client_wire_received_at)}
</span>
)}
</div>
<span className={`inline-block px-2 py-0.5 rounded-full text-[10px] font-medium whitespace-nowrap ${
r.notification_ok ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`} title={r.client_wire_received_at ? formatDate(r.client_wire_received_at) : ''}>
{r.notification_ok ? '✓ Reçu' : 'Non reçu'}
</span>
</td>
{/* Salaires payés */}
<td className="px-3 py-2">
<span className={`inline-block px-2 py-1 rounded-full text-xs font-medium ${
<span className={`inline-block px-2 py-0.5 rounded-full text-[10px] font-medium whitespace-nowrap ${
r.salaires_payes ? 'bg-blue-100 text-blue-800' : 'bg-slate-100 text-slate-600'
}`}>
{r.salaires_payes ? '✓ Payés' : 'Non payés'}
@ -1240,7 +1284,7 @@ export default function SalaryTransfersGrid({
<button
onClick={() => handleGeneratePdf(r.id)}
disabled={generatingPdfForId === r.id}
className={`px-3 py-1 rounded text-xs font-medium transition-colors ${
className={`px-2 py-0.5 rounded text-[10px] font-medium transition-colors whitespace-nowrap ${
generatingPdfForId === r.id
? "bg-slate-300 text-slate-500 cursor-not-allowed"
: r.callsheet_url
@ -1249,10 +1293,10 @@ export default function SalaryTransfersGrid({
}`}
>
{generatingPdfForId === r.id
? "Génération..."
? "Génér..."
: r.callsheet_url
? "Regénérer PDF"
: "Générer PDF"}
? "Régénérer"
: "Générer"}
</button>
</td>
</tr>
@ -1291,7 +1335,16 @@ export default function SalaryTransfersGrid({
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6">
<h3 className="text-xl font-semibold mb-4">Créer un nouveau virement de salaire</h3>
<div className="flex items-center justify-between mb-4">
<h3 className="text-xl font-semibold">
{createForm.selection_mode === 'period'
? 'Créer un virement par période'
: 'Créer un virement personnalisé'}
</h3>
<div className="px-3 py-1 bg-slate-100 rounded-lg text-sm text-slate-700">
Mode: {createForm.selection_mode === 'period' ? 'Période' : 'Manuel'}
</div>
</div>
<div className="space-y-4">
{/* Organisation */}
@ -1312,37 +1365,95 @@ export default function SalaryTransfersGrid({
</select>
</div>
{/* Période (mois) */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Période (mois) <span className="text-red-500">*</span>
</label>
<input
type="month"
value={createForm.period_month}
onChange={(e) => setCreateForm({
...createForm,
period_month: e.target.value,
// Auto-générer le label si vide
period_label: !createForm.period_label ? new Date(e.target.value + "-01").toLocaleDateString("fr-FR", { month: "long", year: "numeric" }) : createForm.period_label
})}
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
{/* Sélection des paies (mode manuel uniquement) */}
{createForm.selection_mode === 'manual' && (
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Paies à inclure <span className="text-red-500">*</span>
</label>
<button
type="button"
onClick={() => {
if (!createForm.org_id) {
toast.error("Veuillez d'abord sélectionner une organisation");
return;
}
console.log("[SalaryTransfersGrid] Opening payslips modal for org_id:", createForm.org_id);
const org = organizations?.find(o => o.id === createForm.org_id);
console.log("[SalaryTransfersGrid] Found organization:", org);
if (org) {
setSelectedOrgForPayslips({ id: org.id, name: org.name });
setShowPayslipsModal(true);
} else {
toast.error("Organisation introuvable");
}
}}
disabled={!createForm.org_id}
className="w-full px-4 py-3 border-2 border-dashed rounded-lg text-sm text-slate-600 hover:border-indigo-400 hover:text-indigo-600 hover:bg-indigo-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{createForm.payslip_ids.length > 0
? `${createForm.payslip_ids.length} paie(s) sélectionnée(s) - Cliquer pour modifier`
: 'Cliquer pour sélectionner les paies'}
</button>
{createForm.total_net && createForm.payslip_ids.length > 0 && (
<div className="mt-2 text-sm text-slate-600">
Total net: <span className="font-semibold text-slate-800">{createForm.total_net} </span>
</div>
)}
</div>
)}
{/* Libellé de la période */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Libellé de la période (optionnel)
</label>
<input
type="text"
value={createForm.period_label}
onChange={(e) => setCreateForm({ ...createForm, period_label: e.target.value })}
placeholder="Ex: Janvier 2025"
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
{/* Période (mois) - uniquement pour mode période */}
{createForm.selection_mode === 'period' && (
<>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Période (mois) <span className="text-red-500">*</span>
</label>
<input
type="month"
value={createForm.period_month}
onChange={(e) => setCreateForm({
...createForm,
period_month: e.target.value,
// Auto-générer le label si vide
period_label: !createForm.period_label ? new Date(e.target.value + "-01").toLocaleDateString("fr-FR", { month: "long", year: "numeric" }) : createForm.period_label
})}
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
{/* Libellé de la période */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Libellé de la période (optionnel)
</label>
<input
type="text"
value={createForm.period_label}
onChange={(e) => setCreateForm({ ...createForm, period_label: e.target.value })}
placeholder="Ex: Janvier 2025"
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
</>
)}
{/* Libellé manuel pour mode personnalisé */}
{createForm.selection_mode === 'manual' && (
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Libellé du virement <span className="text-red-500">*</span>
</label>
<input
type="text"
value={createForm.period_label}
onChange={(e) => setCreateForm({ ...createForm, period_label: e.target.value })}
placeholder="Ex: Virements sélectionnés - Décembre 2024"
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
)}
{/* Date d'échéance */}
<div>
@ -1442,7 +1553,15 @@ export default function SalaryTransfersGrid({
</button>
<button
onClick={handleCreateTransfer}
disabled={creating || !createForm.org_id || !createForm.period_month || !createForm.deadline || !createForm.mode || !createForm.num_appel}
disabled={
creating ||
!createForm.org_id ||
!createForm.deadline ||
!createForm.mode ||
!createForm.num_appel ||
(createForm.selection_mode === 'period' && !createForm.period_month) ||
(createForm.selection_mode === 'manual' && createForm.payslip_ids.length === 0)
}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:bg-slate-300 disabled:cursor-not-allowed"
>
{creating ? "Création..." : "Créer"}
@ -2051,6 +2170,28 @@ export default function SalaryTransfersGrid({
: []
}
/>
{/* Modal de sélection des paies (mode manuel) */}
{showPayslipsModal && selectedOrgForPayslips && (
<PayslipsSelectionModal
organizationId={selectedOrgForPayslips.id}
organizationName={selectedOrgForPayslips.name}
onClose={() => {
setShowPayslipsModal(false);
setSelectedOrgForPayslips(null);
}}
onConfirm={(payslipIds, totalNet) => {
setCreateForm({
...createForm,
payslip_ids: payslipIds,
total_net: totalNet.toFixed(2),
});
setShowPayslipsModal(false);
setSelectedOrgForPayslips(null);
toast.success(`${payslipIds.length} paie(s) sélectionnée(s)`);
}}
/>
)}
</div>
);
}

View file

@ -0,0 +1,520 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { X, Search, Filter, CheckCircle2, XCircle, Calendar } from "lucide-react";
import { supabase } from "@/lib/supabaseClient";
import { toast } from "sonner";
type Payslip = {
id: string;
contract_id: string | null;
period_month: string | null;
period_start: string | null;
period_end: string | null;
pay_date: string | null;
net_amount: number | null;
net_after_withholding: number | null;
processed: boolean | null;
organization_id: string | null;
cddu_contracts?: {
id: string;
contract_number: string | null;
employee_name: string | null;
employee_id: string | null;
employee_matricule: string | null;
salaries?: {
nom: string | null;
prenom: string | null;
};
} | null;
};
type PayslipsSelectionModalProps = {
organizationId: string;
organizationName: string;
onClose: () => void;
onConfirm: (payslipIds: string[], totalNet: number) => void;
};
function formatDate(dateString: string | null | undefined): string {
if (!dateString) return "—";
try {
const date = new Date(dateString);
return date.toLocaleDateString("fr-FR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
} catch {
return "—";
}
}
function formatMonthYear(dateString: string | null | undefined): string {
if (!dateString) return "—";
try {
const date = new Date(dateString);
return date.toLocaleDateString("fr-FR", {
month: "long",
year: "numeric",
});
} catch {
return "—";
}
}
function formatAmount(amount: string | number | null | undefined): string {
if (!amount) return "0,00 €";
try {
const num = typeof amount === "string" ? parseFloat(amount) : amount;
return new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
}).format(num);
} catch {
return "0,00 €";
}
}
export default function PayslipsSelectionModal({
organizationId,
organizationName,
onClose,
onConfirm,
}: PayslipsSelectionModalProps) {
const [payslips, setPayslips] = useState<Payslip[]>([]);
const [loading, setLoading] = useState(true);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
// Filtres
const [searchQuery, setSearchQuery] = useState("");
const [periodFilter, setPeriodFilter] = useState<string | null>(null);
const [processedFilter, setProcessedFilter] = useState<string | null>(null);
const [showFilters, setShowFilters] = useState(false);
// Charger les paies de l'organisation
useEffect(() => {
async function loadPayslips() {
setLoading(true);
try {
console.log("[PayslipsSelectionModal] Loading payslips for organization:", organizationId);
// Utiliser l'API route côté serveur au lieu d'une requête client-side
// Cela évite les problèmes de RLS avec la clé anon
const res = await fetch(`/api/staff/payslips/available?org_id=${organizationId}`);
if (!res.ok) {
const error = await res.json();
console.error("[PayslipsSelectionModal] API error:", error);
toast.error(error.error || "Erreur lors du chargement des paies");
return;
}
const result = await res.json();
console.log("[PayslipsSelectionModal] API result:", {
success: result.success,
total: result.total,
stats: result.stats
});
if (result.payslips && result.payslips.length > 0) {
console.log("[PayslipsSelectionModal] First payslip sample:", result.payslips[0]);
}
setPayslips(result.payslips || []);
} catch (err) {
console.error("[PayslipsSelectionModal] Error:", err);
toast.error("Erreur lors du chargement");
} finally {
setLoading(false);
}
}
loadPayslips();
}, [organizationId]);
// Périodes disponibles (pour le filtre)
const availablePeriods = useMemo(() => {
const periods = new Set<string>();
payslips.forEach(p => {
if (p.period_month) {
periods.add(formatMonthYear(p.period_month));
}
});
return Array.from(periods).sort().reverse();
}, [payslips]);
// Paies filtrées
const filteredPayslips = useMemo(() => {
console.log("[PayslipsSelectionModal] Filtering payslips:", {
total: payslips.length,
searchQuery,
periodFilter,
processedFilter
});
return payslips.filter(p => {
// Filtre de recherche (nom employé, matricule, contrat)
if (searchQuery) {
const query = searchQuery.toLowerCase().trim();
// Essayer différentes sources pour le nom
const prenom = p.cddu_contracts?.salaries?.prenom || "";
const nom = p.cddu_contracts?.salaries?.nom || "";
const employeeName = `${prenom} ${nom}`.toLowerCase().trim();
const employeeNameAlt = p.cddu_contracts?.employee_name?.toLowerCase() || "";
const contractNumber = p.cddu_contracts?.contract_number?.toLowerCase() || "";
const matricule = p.cddu_contracts?.employee_matricule?.toLowerCase() || "";
// Log pour debug (seulement pour les 3 premières paies)
if (payslips.indexOf(p) < 3) {
console.log("[PayslipsSelectionModal] Checking payslip:", {
id: p.id,
employeeName,
employeeNameAlt,
contractNumber,
matricule,
query
});
}
// Vérifier si la query apparaît dans l'un des champs
const matches =
employeeName.includes(query) ||
employeeNameAlt.includes(query) ||
contractNumber.includes(query) ||
matricule.includes(query);
if (!matches) {
return false;
}
}
// Filtre par période
if (periodFilter && p.period_month) {
if (formatMonthYear(p.period_month) !== periodFilter) {
return false;
}
}
// Filtre par statut processed
if (processedFilter !== null) {
const isProcessed = p.processed === true;
if (processedFilter === "processed" && !isProcessed) return false;
if (processedFilter === "not_processed" && isProcessed) return false;
}
return true;
});
}, [payslips, searchQuery, periodFilter, processedFilter]);
// Log du résultat du filtrage
useEffect(() => {
console.log("[PayslipsSelectionModal] Filtered results:", {
total: payslips.length,
filtered: filteredPayslips.length,
searchQuery,
periodFilter,
processedFilter
});
}, [filteredPayslips, payslips.length, searchQuery, periodFilter, processedFilter]);
// Calcul du total sélectionné
const selectedTotal = useMemo(() => {
return filteredPayslips
.filter(p => selectedIds.has(p.id))
.reduce((sum, p) => sum + (p.net_after_withholding || p.net_amount || 0), 0);
}, [filteredPayslips, selectedIds]);
// Sélection/désélection
const toggleSelection = (id: string) => {
const newSet = new Set(selectedIds);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
setSelectedIds(newSet);
};
const selectAll = () => {
const allIds = new Set(filteredPayslips.map(p => p.id));
setSelectedIds(allIds);
};
const deselectAll = () => {
setSelectedIds(new Set());
};
const handleConfirm = () => {
if (selectedIds.size === 0) {
toast.error("Veuillez sélectionner au moins une paie");
return;
}
onConfirm(Array.from(selectedIds), selectedTotal);
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4" onClick={onClose}>
<div className="bg-white rounded-2xl shadow-2xl max-w-6xl w-full max-h-[90vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
{/* Header */}
<div className="px-6 py-4 border-b border-slate-200 flex items-center justify-between bg-gradient-to-r from-indigo-50 to-slate-50">
<div>
<h3 className="text-2xl font-bold text-slate-800">
Sélection des paies
</h3>
<p className="text-sm text-slate-600 mt-1">
{organizationName}
</p>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
>
<X className="w-6 h-6 text-slate-600" />
</button>
</div>
{/* Barre de recherche et filtres */}
<div className="px-6 py-4 border-b border-slate-200 space-y-3">
<div className="flex items-center gap-3">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
<input
type="text"
placeholder="Rechercher par nom ou n° de contrat..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 border rounded-lg"
/>
</div>
<button
onClick={() => setShowFilters(!showFilters)}
className={`px-4 py-2 rounded-lg flex items-center gap-2 transition-colors ${
showFilters ? "bg-indigo-100 text-indigo-700" : "bg-slate-100 text-slate-700 hover:bg-slate-200"
}`}
>
<Filter className="w-5 h-5" />
Filtres
</button>
</div>
{showFilters && (
<div className="flex items-center gap-3">
<select
value={periodFilter || ""}
onChange={(e) => setPeriodFilter(e.target.value || null)}
className="px-3 py-2 border rounded-lg text-sm"
>
<option value="">Toutes les périodes</option>
{availablePeriods.map(period => (
<option key={period} value={period}>{period}</option>
))}
</select>
<select
value={processedFilter || ""}
onChange={(e) => setProcessedFilter(e.target.value || null)}
className="px-3 py-2 border rounded-lg text-sm"
>
<option value="">Tous les statuts</option>
<option value="processed">Paies traitées</option>
<option value="not_processed">Paies non traitées</option>
</select>
{(periodFilter || processedFilter) && (
<button
onClick={() => {
setPeriodFilter(null);
setProcessedFilter(null);
}}
className="px-3 py-2 text-sm text-slate-600 hover:text-slate-800 underline"
>
Réinitialiser
</button>
)}
</div>
)}
</div>
{/* Barre d'actions */}
<div className="px-6 py-3 bg-slate-50 border-b border-slate-200 flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={selectAll}
className="px-3 py-1.5 text-sm bg-white border rounded-lg hover:bg-slate-50 transition-colors"
>
Tout sélectionner ({filteredPayslips.length})
</button>
<button
onClick={deselectAll}
className="px-3 py-1.5 text-sm bg-white border rounded-lg hover:bg-slate-50 transition-colors"
>
Tout désélectionner
</button>
</div>
<div className="text-sm text-slate-600">
<span className="font-semibold text-indigo-600">{selectedIds.size}</span> paie(s) sélectionnée(s)
{selectedIds.size > 0 && (
<span className="ml-2">
Total: <span className="font-semibold text-slate-800">{formatAmount(selectedTotal)}</span>
</span>
)}
</div>
</div>
{/* Liste des paies */}
<div className="flex-1 overflow-y-auto px-6 py-4">
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="text-slate-600">Chargement des paies...</div>
</div>
) : payslips.length === 0 ? (
<div className="flex items-center justify-center py-12">
<div className="text-center">
<p className="text-slate-600 font-medium">Aucune paie trouvée pour cette organisation</p>
<p className="text-sm text-slate-500 mt-2">
Organisation: <span className="font-mono">{organizationName}</span>
</p>
<p className="text-xs text-slate-400 mt-2">
ID: <span className="font-mono">{organizationId}</span>
</p>
<div className="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg text-left">
<p className="text-sm text-slate-700">
<strong>Vérifications :</strong>
</p>
<ul className="text-xs text-slate-600 mt-2 space-y-1 list-disc list-inside">
<li>Vérifiez que des paies existent dans la table <code className="bg-slate-100 px-1 rounded">payslips</code></li>
<li>Vérifiez que <code className="bg-slate-100 px-1 rounded">organization_id</code> correspond bien à cette organisation</li>
<li>Vérifiez les permissions RLS sur la table <code className="bg-slate-100 px-1 rounded">payslips</code></li>
</ul>
</div>
</div>
</div>
) : filteredPayslips.length === 0 ? (
<div className="flex items-center justify-center py-12">
<div className="text-center">
<p className="text-slate-600">Aucune paie disponible</p>
<p className="text-sm text-slate-500 mt-1">
{searchQuery || periodFilter || processedFilter
? "Essayez de modifier les filtres"
: "Toutes les paies sont déjà incluses dans des virements"}
</p>
<p className="text-xs text-slate-400 mt-2">
({payslips.length} paie(s) au total, {filteredPayslips.length} après filtres)
</p>
</div>
</div>
) : (
<table className="w-full">
<thead className="bg-slate-50 sticky top-0 z-10">
<tr className="border-b">
<th className="text-left px-3 py-2 w-12">
<input
type="checkbox"
checked={filteredPayslips.length > 0 && filteredPayslips.every(p => selectedIds.has(p.id))}
onChange={(e) => e.target.checked ? selectAll() : deselectAll()}
className="w-4 h-4 text-indigo-600 rounded focus:ring-indigo-500"
/>
</th>
<th className="text-left px-3 py-2">Employé</th>
<th className="text-left px-3 py-2">N° Contrat</th>
<th className="text-left px-3 py-2">Période</th>
<th className="text-left px-3 py-2">Date de paie</th>
<th className="text-right px-3 py-2">Net à payer</th>
<th className="text-center px-3 py-2">Statut</th>
</tr>
</thead>
<tbody>
{filteredPayslips.map((payslip) => {
const isSelected = selectedIds.has(payslip.id);
const employeeName = payslip.cddu_contracts?.salaries
? `${payslip.cddu_contracts.salaries.prenom || ""} ${payslip.cddu_contracts.salaries.nom || ""}`.trim()
: payslip.cddu_contracts?.employee_name || "—";
return (
<tr
key={payslip.id}
onClick={() => toggleSelection(payslip.id)}
className={`border-b cursor-pointer transition-colors ${
isSelected ? "bg-indigo-50 hover:bg-indigo-100" : "hover:bg-slate-50"
}`}
>
<td className="px-3 py-3">
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleSelection(payslip.id)}
onClick={(e) => e.stopPropagation()}
className="w-4 h-4 text-indigo-600 rounded focus:ring-indigo-500"
/>
</td>
<td className="px-3 py-3">
<div className="font-medium text-sm">{employeeName}</div>
</td>
<td className="px-3 py-3">
<div className="font-mono text-sm">{payslip.cddu_contracts?.contract_number || "—"}</div>
</td>
<td className="px-3 py-3">
<div className="text-sm">{formatMonthYear(payslip.period_month)}</div>
<div className="text-xs text-slate-500">
{formatDate(payslip.period_start)} - {formatDate(payslip.period_end)}
</div>
</td>
<td className="px-3 py-3">
<div className="text-sm">{formatDate(payslip.pay_date)}</div>
</td>
<td className="px-3 py-3 text-right">
<div className="font-semibold text-sm">
{formatAmount(payslip.net_after_withholding || payslip.net_amount)}
</div>
</td>
<td className="px-3 py-3 text-center">
{payslip.processed ? (
<CheckCircle2 className="w-5 h-5 text-green-600 inline-block" />
) : (
<XCircle className="w-5 h-5 text-slate-400 inline-block" />
)}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
{/* Footer avec actions */}
<div className="px-6 py-4 border-t border-slate-200 bg-slate-50 flex items-center justify-between">
<div className="text-sm text-slate-600">
{selectedIds.size > 0 ? (
<>
<span className="font-semibold text-indigo-600">{selectedIds.size}</span> paie(s) sélectionnée(s)
<span className="mx-2"></span>
Total: <span className="font-semibold text-slate-800">{formatAmount(selectedTotal)}</span>
</>
) : (
<span>Aucune paie sélectionnée</span>
)}
</div>
<div className="flex items-center gap-3">
<button
onClick={onClose}
className="px-4 py-2 text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
>
Annuler
</button>
<button
onClick={handleConfirm}
disabled={selectedIds.size === 0}
className="px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed font-medium"
>
Confirmer la sélection
</button>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,160 @@
-- Migration: Ajout du système de sélection manuelle de paies pour les virements
-- Date: 2025-11-28
-- Description: Permet de créer des virements pour des paies spécifiques au lieu d'une période complète
-- 1. Ajouter le champ selection_mode à salary_transfers
ALTER TABLE salary_transfers
ADD COLUMN IF NOT EXISTS selection_mode TEXT DEFAULT 'period'
CHECK (selection_mode IN ('period', 'manual'));
-- Commentaire pour documenter le champ
COMMENT ON COLUMN salary_transfers.selection_mode IS
'Mode de sélection des paies: "period" (toutes les paies du mois) ou "manual" (paies sélectionnées manuellement)';
-- 2. Créer la table de liaison salary_transfer_payslips
CREATE TABLE IF NOT EXISTS salary_transfer_payslips (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
salary_transfer_id UUID NOT NULL REFERENCES salary_transfers(id) ON DELETE CASCADE,
payslip_id UUID NOT NULL REFERENCES payslips(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW(),
-- Empêcher les doublons
UNIQUE(salary_transfer_id, payslip_id)
);
-- Index pour améliorer les performances
CREATE INDEX IF NOT EXISTS idx_salary_transfer_payslips_transfer_id
ON salary_transfer_payslips(salary_transfer_id);
CREATE INDEX IF NOT EXISTS idx_salary_transfer_payslips_payslip_id
ON salary_transfer_payslips(payslip_id);
-- Commentaire pour documenter la table
COMMENT ON TABLE salary_transfer_payslips IS
'Table de liaison entre les virements de salaires et les paies spécifiques (mode manual uniquement)';
-- 3. Row Level Security (RLS) pour salary_transfer_payslips
ALTER TABLE salary_transfer_payslips ENABLE ROW LEVEL SECURITY;
-- Policy pour les staff users (lecture/écriture complète)
CREATE POLICY "Staff users can manage salary transfer payslips"
ON salary_transfer_payslips
FOR ALL
USING (
auth.uid() IN (
SELECT user_id FROM staff_users WHERE is_staff = true
)
)
WITH CHECK (
auth.uid() IN (
SELECT user_id FROM staff_users WHERE is_staff = true
)
);
-- 4. Activer Realtime pour salary_transfer_payslips
ALTER PUBLICATION supabase_realtime ADD TABLE salary_transfer_payslips;
-- 5. Fonction helper pour récupérer les paies d'un virement (utile pour les requêtes)
CREATE OR REPLACE FUNCTION get_salary_transfer_payslips(transfer_id UUID)
RETURNS TABLE (
payslip_id UUID,
contract_id UUID,
employee_name TEXT,
net_amount NUMERIC,
net_after_withholding NUMERIC,
period_month DATE
) AS $$
BEGIN
RETURN QUERY
SELECT
p.id,
p.contract_id,
COALESCE(
c.employee_name,
CONCAT(s.prenom, ' ', s.nom)
) as employee_name,
p.net_amount,
p.net_after_withholding,
p.period_month
FROM salary_transfer_payslips stp
JOIN payslips p ON p.id = stp.payslip_id
LEFT JOIN cddu_contracts c ON c.id = p.contract_id
LEFT JOIN salaries s ON s.salarie = c.employee_id
WHERE stp.salary_transfer_id = transfer_id
ORDER BY employee_name;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- 6. Vue pour faciliter les requêtes sur les virements avec leurs paies
CREATE OR REPLACE VIEW v_salary_transfers_with_payslips AS
SELECT
st.id as transfer_id,
st.org_id,
st.period_month,
st.period_label,
st.selection_mode,
st.mode,
st.deadline,
st.num_appel,
st.total_net,
st.callsheet_url,
st.notification_sent,
st.notification_ok,
st.salaires_payes,
o.name as organization_name,
CASE
WHEN st.selection_mode = 'manual' THEN (
SELECT COUNT(*)
FROM salary_transfer_payslips
WHERE salary_transfer_id = st.id
)
ELSE (
SELECT COUNT(*)
FROM payslips
WHERE organization_id = st.org_id
AND period_month = st.period_month
)
END as payslips_count,
CASE
WHEN st.selection_mode = 'manual' THEN (
SELECT COALESCE(SUM(net_after_withholding), 0)
FROM payslips p
JOIN salary_transfer_payslips stp ON stp.payslip_id = p.id
WHERE stp.salary_transfer_id = st.id
)
ELSE (
SELECT COALESCE(SUM(net_after_withholding), 0)
FROM payslips
WHERE organization_id = st.org_id
AND period_month = st.period_month
)
END as calculated_total_net
FROM salary_transfers st
LEFT JOIN organizations o ON o.id = st.org_id;
-- Commentaire pour documenter la vue
COMMENT ON VIEW v_salary_transfers_with_payslips IS
'Vue enrichie des virements avec le nombre de paies et le total calculé selon le mode de sélection';
-- 7. Vérifications post-migration
DO $$
BEGIN
-- Vérifier que la colonne selection_mode existe
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'salary_transfers'
AND column_name = 'selection_mode'
) THEN
RAISE EXCEPTION 'Colonne selection_mode non créée';
END IF;
-- Vérifier que la table salary_transfer_payslips existe
IF NOT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_name = 'salary_transfer_payslips'
) THEN
RAISE EXCEPTION 'Table salary_transfer_payslips non créée';
END IF;
RAISE NOTICE 'Migration réussie: salary_transfer_payslips table et selection_mode ajoutés';
END $$;

View file

@ -0,0 +1,23 @@
-- Migration: Corriger la fonction is_staff pour vérifier is_staff = true
-- Date: 2025-11-28
-- Description: La fonction is_staff ne vérifie pas si is_staff = true, elle vérifie seulement l'existence dans staff_users
-- Corriger la fonction is_staff
CREATE OR REPLACE FUNCTION public.is_staff()
RETURNS boolean
LANGUAGE sql
STABLE
AS $function$
select exists (
select 1 from public.staff_users su
where su.user_id = auth.uid()
and su.is_staff = true -- AJOUT de cette condition critique
);
$function$;
-- Commentaire pour documenter la correction
COMMENT ON FUNCTION public.is_staff() IS
'Vérifie si l''utilisateur actuel est un staff user (is_staff = true)';
-- Note: Cette correction affecte toutes les policies RLS qui utilisent is_staff()
-- y compris les policies sur payslips via is_member_of_org()

View file

@ -0,0 +1,30 @@
-- Migration: Forcer le rafraîchissement de is_member_of_org
-- Date: 2025-11-28
-- Description: Recréer is_member_of_org pour s'assurer qu'elle utilise la version mise à jour de is_staff()
-- D'abord, supprimer la fonction existante
DROP FUNCTION IF EXISTS public.is_member_of_org(uuid);
-- Recréer la fonction avec la bonne logique
CREATE OR REPLACE FUNCTION public.is_member_of_org(target_org uuid)
RETURNS boolean
LANGUAGE sql
STABLE
AS $function$
select public.is_staff() -- bypass total pour le staff
or exists (
select 1
from public.organization_members om
where om.org_id = target_org
and om.user_id = auth.uid()
);
$function$;
-- Commentaire
COMMENT ON FUNCTION public.is_member_of_org(uuid) IS
'Vérifie si l''utilisateur est staff (is_staff = true) OU membre de l''organisation';
-- Tester immédiatement
SELECT
public.is_staff() as is_staff_result,
public.is_member_of_org('fba4ad7b-bac7-4ce3-8707-05d220280fb1') as can_access_via_arte;

View file

@ -0,0 +1,21 @@
-- Migration: Rendre period_month nullable pour le mode manual
-- Date: 2025-11-28
-- Description: En mode manual, period_month n'est pas requis car les paies peuvent être de différents mois
-- Rendre la colonne period_month nullable
ALTER TABLE salary_transfers
ALTER COLUMN period_month DROP NOT NULL;
-- Ajouter un commentaire pour documenter
COMMENT ON COLUMN salary_transfers.period_month IS
'Mois de la période (YYYY-MM-DD). NULL en mode manual, requis en mode period.';
-- Ajouter une contrainte de validation pour s'assurer de la cohérence
-- En mode period: period_month doit être renseigné
-- En mode manual: period_month doit être NULL
ALTER TABLE salary_transfers
ADD CONSTRAINT check_period_month_by_mode
CHECK (
(selection_mode = 'period' AND period_month IS NOT NULL) OR
(selection_mode = 'manual' AND period_month IS NULL)
);