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:
parent
5020298912
commit
8ba984af1d
11 changed files with 1273 additions and 113 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
136
app/api/staff/payslips/available/route.ts
Normal file
136
app/api/staff/payslips/available/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
520
components/staff/salary-transfers/PayslipsSelectionModal.tsx
Normal file
520
components/staff/salary-transfers/PayslipsSelectionModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
160
migrations/add_salary_transfer_payslips_table.sql
Normal file
160
migrations/add_salary_transfer_payslips_table.sql
Normal 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 $$;
|
||||
23
migrations/fix_payslips_rls_for_staff.sql
Normal file
23
migrations/fix_payslips_rls_for_staff.sql
Normal 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()
|
||||
30
migrations/force_refresh_is_member_of_org.sql
Normal file
30
migrations/force_refresh_is_member_of_org.sql
Normal 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;
|
||||
21
migrations/make_period_month_nullable.sql
Normal file
21
migrations/make_period_month_nullable.sql
Normal 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)
|
||||
);
|
||||
Loading…
Reference in a new issue