feat: Ajout modal création en masse de paies avec tous les champs corrects
This commit is contained in:
parent
eb3133866e
commit
2aeac651c1
3 changed files with 748 additions and 2 deletions
99
app/api/staff/payslips/bulk-create/route.ts
Normal file
99
app/api/staff/payslips/bulk-create/route.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { createSbServer } from "@/lib/supabaseServer";
|
||||
|
||||
async function assertStaff(sb: ReturnType<typeof createSbServer>, userId: string) {
|
||||
const { data: me } = await sb
|
||||
.from("staff_users")
|
||||
.select("is_staff")
|
||||
.eq("user_id", userId)
|
||||
.maybeSingle();
|
||||
return !!me?.is_staff;
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const supabase = createSbServer();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return NextResponse.json({ error: "Non authentifié." }, { status: 401 });
|
||||
|
||||
const isStaff = await assertStaff(supabase, user.id);
|
||||
if (!isStaff) return NextResponse.json({ error: "Accès réservé au staff." }, { status: 403 });
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const { payslips } = body;
|
||||
|
||||
if (!payslips || !Array.isArray(payslips) || payslips.length === 0) {
|
||||
return NextResponse.json({ error: "Aucune paie fournie" }, { status: 400 });
|
||||
}
|
||||
|
||||
console.log(`📊 Création en masse de ${payslips.length} paies...`);
|
||||
|
||||
const results = [];
|
||||
const errors = [];
|
||||
|
||||
for (const payslipData of payslips) {
|
||||
try {
|
||||
if (!payslipData.contract_id) {
|
||||
errors.push({
|
||||
contract_id: payslipData.contract_id,
|
||||
error: "ID du contrat manquant"
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Récupérer les informations du contrat pour obtenir l'organization_id
|
||||
const { data: contract, error: contractError } = await supabase
|
||||
.from("cddu_contracts")
|
||||
.select("org_id")
|
||||
.eq("id", payslipData.contract_id)
|
||||
.single();
|
||||
|
||||
if (contractError || !contract) {
|
||||
console.error(`❌ Erreur récupération contrat ${payslipData.contract_id}:`, contractError);
|
||||
errors.push({
|
||||
contract_id: payslipData.contract_id,
|
||||
error: "Contrat non trouvé"
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ajouter l'organization_id aux données
|
||||
const fullPayslipData = {
|
||||
...payslipData,
|
||||
organization_id: contract.org_id
|
||||
};
|
||||
|
||||
// Créer la paie
|
||||
const { data: payslip, error: insertError } = await supabase
|
||||
.from("payslips")
|
||||
.insert([fullPayslipData])
|
||||
.select("*")
|
||||
.single();
|
||||
|
||||
if (insertError) {
|
||||
console.error(`❌ Erreur création payslip pour contrat ${payslipData.contract_id}:`, insertError);
|
||||
errors.push({
|
||||
contract_id: payslipData.contract_id,
|
||||
error: insertError.message
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`✅ Paie créée pour contrat ${payslipData.contract_id}`);
|
||||
results.push(payslip);
|
||||
} catch (error) {
|
||||
console.error(`❌ Erreur lors de la création de la paie:`, error);
|
||||
errors.push({
|
||||
contract_id: payslipData.contract_id,
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`📊 Résultat: ${results.length} paies créées, ${errors.length} erreurs`);
|
||||
|
||||
return NextResponse.json({
|
||||
created: results.length,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
payslips: results
|
||||
});
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ import BulkESignConfirmModal from "./BulkESignConfirmModal";
|
|||
import { BulkEmployeeReminderModal } from "./contracts/BulkEmployeeReminderModal";
|
||||
import { EmployeeReminderModal } from "./contracts/EmployeeReminderModal";
|
||||
import { SmartReminderModal, type SmartReminderContract, type ReminderAction } from "./contracts/SmartReminderModal";
|
||||
import BulkPayslipModal from "./contracts/BulkPayslipModal";
|
||||
|
||||
// Utility function to format dates as DD/MM/YYYY
|
||||
function formatDate(dateString: string | null | undefined): string {
|
||||
|
|
@ -176,6 +177,7 @@ type Contract = {
|
|||
last_employer_notification_at?: string | null;
|
||||
last_employee_notification_at?: string | null;
|
||||
production_name?: string | null;
|
||||
analytique?: string | null;
|
||||
salaries?: {
|
||||
salarie?: string | null;
|
||||
nom?: string | null;
|
||||
|
|
@ -290,10 +292,12 @@ function ContractsGridImpl({ initialData, activeOrgId }: { initialData: Contract
|
|||
const [showDpaeModal, setShowDpaeModal] = useState(false);
|
||||
const [showEtatContratModal, setShowEtatContratModal] = useState(false);
|
||||
const [showEtatPaieModal, setShowEtatPaieModal] = useState(false);
|
||||
const [showAnalytiqueModal, setShowAnalytiqueModal] = useState(false);
|
||||
const [showSalaryModal, setShowSalaryModal] = useState(false);
|
||||
const [showActionMenu, setShowActionMenu] = useState(false);
|
||||
const [showESignMenu, setShowESignMenu] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [showBulkPayslipModal, setShowBulkPayslipModal] = useState(false);
|
||||
|
||||
// Quick filter counts
|
||||
const [countDpaeAFaire, setCountDpaeAFaire] = useState<number | null>(null);
|
||||
|
|
@ -1167,6 +1171,36 @@ function ContractsGridImpl({ initialData, activeOrgId }: { initialData: Contract
|
|||
setIsESignCancelled(false);
|
||||
};
|
||||
|
||||
// Fonction pour créer les paies en masse
|
||||
const handleBulkPayslipSubmit = async (payslips: any[]) => {
|
||||
try {
|
||||
const response = await fetch('/api/staff/payslips/bulk-create', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ payslips }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Erreur lors de la création des paies');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
toast.success(`${result.created || payslips.length} paie(s) créée(s) avec succès`);
|
||||
|
||||
// Rafraîchir les données
|
||||
handleRefresh();
|
||||
|
||||
// Désélectionner les contrats
|
||||
setSelectedContractIds(new Set());
|
||||
} catch (error) {
|
||||
console.error('Erreur création paies:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Fonction pour extraire les notifications directement des données de contrats
|
||||
// Plus besoin d'appel API séparé, les timestamps sont maintenant dans cddu_contracts
|
||||
const updateNotificationsFromRows = (contracts: Contract[]) => {
|
||||
|
|
@ -1190,7 +1224,7 @@ function ContractsGridImpl({ initialData, activeOrgId }: { initialData: Contract
|
|||
// Debounce searches when filters change
|
||||
useEffect(() => {
|
||||
// if no filters applied, prefer initial data
|
||||
const noFilters = !q && !structureFilter && !typeFilter && etatContratFilters.size === 0 && !etatPaieFilter && !dpaeFilter && !signatureFilter && !startFrom && !startTo && sortField === 'start_date' && sortOrder === 'desc';
|
||||
const noFilters = !q && !structureFilter && !typeFilter && etatContratFilters.size === 0 && !etatPaieFilter && !dpaeFilter && !signatureFilter && !startFrom && !startTo && !endFrom && !endTo && sortField === 'start_date' && sortOrder === 'desc';
|
||||
if (noFilters) {
|
||||
setRows(initialData || []);
|
||||
return;
|
||||
|
|
@ -1199,7 +1233,7 @@ function ContractsGridImpl({ initialData, activeOrgId }: { initialData: Contract
|
|||
const t = setTimeout(() => fetchServer(0), 300);
|
||||
return () => clearTimeout(t);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [q, structureFilter, typeFilter, etatContratFilters, etatPaieFilter, dpaeFilter, signatureFilter, startFrom, startTo, sortField, sortOrder, limit]);
|
||||
}, [q, structureFilter, typeFilter, etatContratFilters, etatPaieFilter, dpaeFilter, signatureFilter, startFrom, startTo, endFrom, endTo, sortField, sortOrder, limit]);
|
||||
|
||||
// Récupérer les notifications quand les données changent
|
||||
useEffect(() => {
|
||||
|
|
@ -1865,6 +1899,17 @@ function ContractsGridImpl({ initialData, activeOrgId }: { initialData: Contract
|
|||
/>
|
||||
<div className="absolute right-0 mt-1 w-48 bg-white rounded-md shadow-lg z-20 border border-gray-200">
|
||||
<div className="py-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowBulkPayslipModal(true);
|
||||
setShowActionMenu(false);
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Euro className="w-4 h-4" />
|
||||
Ajout de paie
|
||||
</button>
|
||||
<div className="border-t border-gray-200 my-1"></div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowDpaeModal(true);
|
||||
|
|
@ -1895,6 +1940,16 @@ function ContractsGridImpl({ initialData, activeOrgId }: { initialData: Contract
|
|||
<BarChart3 className="w-4 h-4" />
|
||||
Modifier État Paie
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowAnalytiqueModal(true);
|
||||
setShowActionMenu(false);
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
Modifier Analytique
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
viewSelectedDetails();
|
||||
|
|
@ -2250,6 +2305,30 @@ function ContractsGridImpl({ initialData, activeOrgId }: { initialData: Contract
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal Action groupée Analytique */}
|
||||
{showAnalytiqueModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md">
|
||||
<h3 className="text-lg font-semibold mb-4">Action groupée - Analytique</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Modifier l'analytique pour {selectedContractIds.size} contrat{selectedContractIds.size > 1 ? 's' : ''}
|
||||
</p>
|
||||
<AnalytiqueActionModal
|
||||
selectedContracts={selectedContracts}
|
||||
onClose={() => setShowAnalytiqueModal(false)}
|
||||
onSuccess={(updatedContracts) => {
|
||||
// Mettre à jour les contrats dans la liste
|
||||
setRows(prev => prev.map(row => {
|
||||
const updated = updatedContracts.find(u => u.id === row.id);
|
||||
return updated ? { ...row, analytique: updated.analytique, production_name: updated.analytique } : row;
|
||||
}));
|
||||
setShowAnalytiqueModal(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal Saisir brut */}
|
||||
{showSalaryModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
|
|
@ -2332,6 +2411,14 @@ function ContractsGridImpl({ initialData, activeOrgId }: { initialData: Contract
|
|||
progressContracts={smartReminderProgress.length > 0 ? smartReminderProgress : undefined}
|
||||
/>
|
||||
|
||||
{/* Modal d'ajout de paie en masse */}
|
||||
<BulkPayslipModal
|
||||
isOpen={showBulkPayslipModal}
|
||||
onClose={() => setShowBulkPayslipModal(false)}
|
||||
contracts={selectedContracts}
|
||||
onSubmit={handleBulkPayslipSubmit}
|
||||
/>
|
||||
|
||||
{/* Modal de progression pour l'envoi des e-signatures */}
|
||||
<BulkESignProgressModal
|
||||
isOpen={showESignProgressModal}
|
||||
|
|
@ -2654,6 +2741,107 @@ function EtatPaieActionModal({
|
|||
);
|
||||
}
|
||||
|
||||
// Modal pour l'analytique
|
||||
function AnalytiqueActionModal({
|
||||
selectedContracts,
|
||||
onClose,
|
||||
onSuccess
|
||||
}: {
|
||||
selectedContracts: Contract[];
|
||||
onClose: () => void;
|
||||
onSuccess: (contracts: { id: string; analytique: string }[]) => void;
|
||||
}) {
|
||||
const [newAnalytique, setNewAnalytique] = useState<string>('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!newAnalytique.trim()) {
|
||||
toast.error("Veuillez saisir un code analytique");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/staff/contracts/bulk-update-analytique', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contractIds: selectedContracts.map(c => c.id),
|
||||
analytique: newAnalytique.trim()
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Erreur lors de la mise à jour');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
toast.success(`${result.count} contrat(s) mis à jour`);
|
||||
onSuccess(result.contracts);
|
||||
} catch (error: any) {
|
||||
console.error('Erreur:', error);
|
||||
toast.error(error.message || 'Erreur lors de la mise à jour des contrats');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Nouveau code analytique
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newAnalytique}
|
||||
onChange={(e) => setNewAnalytique(e.target.value)}
|
||||
placeholder="Ex: Production 2025"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Ce code sera appliqué au champ "Analytique" de tous les contrats sélectionnés
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg p-3 max-h-40 overflow-y-auto">
|
||||
<p className="text-sm font-medium text-gray-700 mb-2">Contrats sélectionnés :</p>
|
||||
{selectedContracts.map(contract => (
|
||||
<div key={contract.id} className="text-sm text-gray-600 py-1">
|
||||
{contract.contract_number || contract.id} - {formatEmployeeName(contract)}
|
||||
{contract.analytique && (
|
||||
<span className="text-xs text-gray-400 ml-2">
|
||||
(actuel: {contract.analytique})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 mt-6">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded hover:bg-gray-50"
|
||||
disabled={loading}
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!newAnalytique.trim() || loading}
|
||||
className="px-4 py-2 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? 'Mise à jour...' : 'Appliquer'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Modal pour saisir le brut
|
||||
function SalaryInputModal({
|
||||
selectedContracts,
|
||||
|
|
|
|||
459
components/staff/contracts/BulkPayslipModal.tsx
Normal file
459
components/staff/contracts/BulkPayslipModal.tsx
Normal file
|
|
@ -0,0 +1,459 @@
|
|||
// components/staff/contracts/BulkPayslipModal.tsx
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { X, Plus, Check, AlertCircle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type Contract = {
|
||||
id: string;
|
||||
contract_number?: string | null;
|
||||
employee_name?: string | null;
|
||||
production_name?: string | null;
|
||||
start_date?: string | null;
|
||||
end_date?: string | null;
|
||||
salaries?: {
|
||||
salarie?: string | null;
|
||||
nom?: string | null;
|
||||
prenom?: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
type PayslipData = {
|
||||
contract_id: string;
|
||||
pay_number: string;
|
||||
pay_date: string;
|
||||
period_start: string;
|
||||
period_end: string;
|
||||
gross_amount: string;
|
||||
net_amount: string;
|
||||
net_after_withholding: string;
|
||||
employer_cost: string;
|
||||
aem_status: string;
|
||||
storage_path: string;
|
||||
storage_url: string;
|
||||
processed: boolean;
|
||||
transfer_done: boolean;
|
||||
};
|
||||
|
||||
type BulkPayslipModalProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
contracts: Contract[];
|
||||
onSubmit: (payslips: PayslipData[]) => Promise<void>;
|
||||
};
|
||||
|
||||
// Fonction utilitaire pour formater le nom du salarié
|
||||
function formatEmployeeName(contract: Contract): string {
|
||||
if (contract.salaries?.salarie) {
|
||||
return contract.salaries.salarie;
|
||||
}
|
||||
if (contract.salaries?.nom || contract.salaries?.prenom) {
|
||||
const nom = (contract.salaries.nom || '').toUpperCase().trim();
|
||||
const prenom = (contract.salaries.prenom || '').trim();
|
||||
return [nom, prenom].filter(Boolean).join(' ');
|
||||
}
|
||||
return contract.employee_name || "—";
|
||||
}
|
||||
|
||||
export default function BulkPayslipModal({ isOpen, onClose, contracts, onSubmit }: BulkPayslipModalProps) {
|
||||
// État pour les valeurs communes (pré-remplissage)
|
||||
const [commonValues, setCommonValues] = useState({
|
||||
pay_date: "",
|
||||
period_start: "",
|
||||
period_end: "",
|
||||
aem_status: "À traiter",
|
||||
processed: false,
|
||||
transfer_done: false,
|
||||
});
|
||||
|
||||
// État pour les données de chaque contrat
|
||||
const [payslipsData, setPayslipsData] = useState<Record<string, Partial<PayslipData>>>({});
|
||||
|
||||
// Initialiser les données avec les dates de contrat (une seule fois)
|
||||
useEffect(() => {
|
||||
const initialData: Record<string, Partial<PayslipData>> = {};
|
||||
contracts.forEach((contract) => {
|
||||
initialData[contract.id] = {
|
||||
contract_id: contract.id,
|
||||
pay_number: "",
|
||||
pay_date: "",
|
||||
period_start: contract.start_date?.slice(0, 10) || "",
|
||||
period_end: contract.end_date?.slice(0, 10) || "",
|
||||
gross_amount: "",
|
||||
net_amount: "",
|
||||
net_after_withholding: "",
|
||||
employer_cost: "",
|
||||
aem_status: "À traiter",
|
||||
storage_path: "",
|
||||
storage_url: "",
|
||||
processed: false,
|
||||
transfer_done: false,
|
||||
};
|
||||
});
|
||||
setPayslipsData(initialData);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // Seulement au montage du composant
|
||||
|
||||
// Mettre à jour une valeur pour un contrat spécifique
|
||||
const updatePayslipData = (contractId: string, field: keyof PayslipData, value: any) => {
|
||||
setPayslipsData((prev) => ({
|
||||
...prev,
|
||||
[contractId]: {
|
||||
...prev[contractId],
|
||||
[field]: value,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
// Appliquer les valeurs communes à tous les contrats
|
||||
const applyCommonValues = () => {
|
||||
setPayslipsData((prev) => {
|
||||
const updated = { ...prev };
|
||||
Object.keys(updated).forEach((contractId) => {
|
||||
updated[contractId] = {
|
||||
...updated[contractId],
|
||||
pay_date: commonValues.pay_date || updated[contractId]?.pay_date,
|
||||
aem_status: commonValues.aem_status || updated[contractId]?.aem_status,
|
||||
processed: commonValues.processed,
|
||||
transfer_done: commonValues.transfer_done,
|
||||
};
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
toast.success("Valeurs communes appliquées à tous les contrats");
|
||||
};
|
||||
|
||||
// Validation et soumission
|
||||
const handleSubmit = async () => {
|
||||
const payslips: PayslipData[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
contracts.forEach((contract) => {
|
||||
const data = payslipsData[contract.id];
|
||||
if (!data) return;
|
||||
|
||||
// Validation basique
|
||||
if (!data.pay_number?.trim()) {
|
||||
errors.push(`Contrat ${contract.contract_number || contract.id}: n° de paie manquant`);
|
||||
}
|
||||
if (!data.pay_date) {
|
||||
errors.push(`Contrat ${contract.contract_number || contract.id}: date de paie manquante`);
|
||||
}
|
||||
|
||||
payslips.push({
|
||||
contract_id: contract.id,
|
||||
pay_number: data.pay_number || "",
|
||||
pay_date: data.pay_date || "",
|
||||
period_start: data.period_start || "",
|
||||
period_end: data.period_end || "",
|
||||
gross_amount: data.gross_amount || "",
|
||||
net_amount: data.net_amount || "",
|
||||
net_after_withholding: data.net_after_withholding || "",
|
||||
employer_cost: data.employer_cost || "",
|
||||
aem_status: data.aem_status || "À traiter",
|
||||
storage_path: data.storage_path || "",
|
||||
storage_url: data.storage_url || "",
|
||||
processed: data.processed || false,
|
||||
transfer_done: data.transfer_done || false,
|
||||
});
|
||||
});
|
||||
|
||||
if (errors.length > 0) {
|
||||
toast.error(`Erreurs de validation: ${errors.join(", ")}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await onSubmit(payslips);
|
||||
toast.success(`${payslips.length} paie(s) créée(s) avec succès`);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Erreur lors de la création des paies:", error);
|
||||
toast.error("Erreur lors de la création des paies");
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-[95vw] h-[90vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-slate-900">Ajout de paies en masse</h2>
|
||||
<p className="text-sm text-slate-600 mt-1">
|
||||
{contracts.length} contrat{contracts.length > 1 ? "s" : ""} sélectionné{contracts.length > 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-slate-100 rounded-full transition-colors"
|
||||
>
|
||||
<X className="w-6 h-6 text-slate-600" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Zone de valeurs communes */}
|
||||
<div className="px-6 py-4 bg-gradient-to-r from-indigo-50 to-blue-50 border-b border-indigo-200">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-2 h-2 bg-indigo-600 rounded-full"></div>
|
||||
<h3 className="text-sm font-semibold text-indigo-900">Valeurs communes (optionnel)</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs text-slate-700">Date de paie</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={commonValues.pay_date}
|
||||
onChange={(e) => setCommonValues((prev) => ({ ...prev, pay_date: e.target.value }))}
|
||||
className="h-9 bg-white border-indigo-200 focus:border-indigo-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-slate-700">Statut AEM</Label>
|
||||
<select
|
||||
value={commonValues.aem_status}
|
||||
onChange={(e) => setCommonValues((prev) => ({ ...prev, aem_status: e.target.value }))}
|
||||
className="h-9 w-full bg-white border border-indigo-200 rounded-md focus:border-indigo-400 focus:ring-2 focus:ring-indigo-400 text-sm"
|
||||
>
|
||||
<option value="À traiter">À traiter</option>
|
||||
<option value="OK">OK</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<label className="flex items-center gap-2 text-xs text-slate-700 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={commonValues.processed}
|
||||
onChange={(e) => setCommonValues((prev) => ({ ...prev, processed: e.target.checked }))}
|
||||
className="rounded border-indigo-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
Traité
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs text-slate-700 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={commonValues.transfer_done}
|
||||
onChange={(e) => setCommonValues((prev) => ({ ...prev, transfer_done: e.target.checked }))}
|
||||
className="rounded border-indigo-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
Virement ok
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<Button
|
||||
onClick={applyCommonValues}
|
||||
className="w-full h-9 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors"
|
||||
>
|
||||
<Check className="w-4 h-4 mr-1" />
|
||||
Appliquer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Liste des contrats avec leurs champs */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
<div className="space-y-4">
|
||||
{contracts.map((contract, index) => {
|
||||
const data = payslipsData[contract.id] || {};
|
||||
return (
|
||||
<div
|
||||
key={contract.id}
|
||||
className="bg-gradient-to-r from-slate-50 to-slate-100 rounded-xl p-4 border border-slate-200 hover:shadow-md transition-shadow"
|
||||
>
|
||||
{/* En-tête du contrat */}
|
||||
<div className="flex items-center gap-3 mb-4 pb-3 border-b border-slate-300">
|
||||
<div className="w-8 h-8 bg-indigo-600 text-white rounded-full flex items-center justify-center font-bold text-sm">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-slate-900">
|
||||
{formatEmployeeName(contract)}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500">•</span>
|
||||
<span className="text-sm text-slate-600">
|
||||
{contract.contract_number || "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mt-1">
|
||||
{contract.production_name || "—"} • {contract.start_date?.slice(0, 10)} → {contract.end_date?.slice(0, 10)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Champs de la paie */}
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{/* Ligne 1 */}
|
||||
<div>
|
||||
<Label className="text-xs text-slate-700 flex items-center gap-1">
|
||||
N° de paie
|
||||
<span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={data.pay_number || ""}
|
||||
onChange={(e) => updatePayslipData(contract.id, "pay_number", e.target.value)}
|
||||
className="h-9 bg-white border-slate-300 focus:border-indigo-500"
|
||||
placeholder="ex: 001"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-slate-700 flex items-center gap-1">
|
||||
Date de paie
|
||||
<span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={data.pay_date || ""}
|
||||
onChange={(e) => updatePayslipData(contract.id, "pay_date", e.target.value)}
|
||||
className="h-9 bg-white border-slate-300 focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-slate-700">Période début</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={data.period_start || ""}
|
||||
onChange={(e) => updatePayslipData(contract.id, "period_start", e.target.value)}
|
||||
className="h-9 bg-white border-slate-300 focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-slate-700">Période fin</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={data.period_end || ""}
|
||||
onChange={(e) => updatePayslipData(contract.id, "period_end", e.target.value)}
|
||||
className="h-9 bg-white border-slate-300 focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Ligne 2 */}
|
||||
<div>
|
||||
<Label className="text-xs text-slate-700">Montant brut</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={data.gross_amount || ""}
|
||||
onChange={(e) => updatePayslipData(contract.id, "gross_amount", e.target.value)}
|
||||
className="h-9 bg-white border-slate-300 focus:border-indigo-500"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-slate-700">Net avant PAS</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={data.net_amount || ""}
|
||||
onChange={(e) => updatePayslipData(contract.id, "net_amount", e.target.value)}
|
||||
className="h-9 bg-white border-slate-300 focus:border-indigo-500"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-slate-700">Net à payer</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={data.net_after_withholding || ""}
|
||||
onChange={(e) => updatePayslipData(contract.id, "net_after_withholding", e.target.value)}
|
||||
className="h-9 bg-white border-slate-300 focus:border-indigo-500"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-slate-700">Coût total employeur</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={data.employer_cost || ""}
|
||||
onChange={(e) => updatePayslipData(contract.id, "employer_cost", e.target.value)}
|
||||
className="h-9 bg-white border-slate-300 focus:border-indigo-500"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Ligne 3 */}
|
||||
<div>
|
||||
<Label className="text-xs text-slate-700">Statut AEM</Label>
|
||||
<select
|
||||
value={data.aem_status || "À traiter"}
|
||||
onChange={(e) => updatePayslipData(contract.id, "aem_status", e.target.value)}
|
||||
className="h-9 w-full bg-white border border-slate-300 rounded-md focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 text-sm"
|
||||
>
|
||||
<option value="À traiter">À traiter</option>
|
||||
<option value="OK">OK</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
<Label className="text-xs text-slate-700">Chemin de stockage (S3)</Label>
|
||||
<Input
|
||||
value={data.storage_path || ""}
|
||||
onChange={(e) => updatePayslipData(contract.id, "storage_path", e.target.value)}
|
||||
className="h-9 bg-white border-slate-300 focus:border-indigo-500"
|
||||
placeholder="ex: payslips/2025/..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Ligne 4 - Checkboxes */}
|
||||
<div className="col-span-4 flex items-center gap-4 pt-2">
|
||||
<label className="flex items-center gap-2 text-xs text-slate-700 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.processed || false}
|
||||
onChange={(e) => updatePayslipData(contract.id, "processed", e.target.checked)}
|
||||
className="rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
Traité
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs text-slate-700 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.transfer_done || false}
|
||||
onChange={(e) => updatePayslipData(contract.id, "transfer_done", e.target.checked)}
|
||||
className="rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
Virement ok
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-slate-200 bg-slate-50 flex items-center justify-between rounded-b-2xl">
|
||||
<div className="flex items-center gap-2 text-sm text-slate-600">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
<span>Les champs marqués d'un <span className="text-red-500">*</span> sont obligatoires</span>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="outline"
|
||||
className="rounded-lg border-slate-300 hover:bg-slate-100"
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
className="rounded-lg bg-gradient-to-r from-indigo-600 to-blue-600 hover:from-indigo-700 hover:to-blue-700 text-white px-6"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Créer {contracts.length} paie{contracts.length > 1 ? "s" : ""}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue