feat: Ajout export SEPA et marquage groupé des virements salaires

- Ajout export fichier SEPA XML (norme ISO 20022 pain.001.001.03)
  * Sélection multiple via checkboxes
  * Génération XML pour virements bancaires groupés
  * Validation IBAN et gestion des salariés sans RIB
  * Modal de succès/avertissements
  * Référence: Nom organisation - Période

- Ajout marquage groupé des paies comme payées
  * Sélection multiple des paies
  * Modal de confirmation
  * Actualisation automatique sans refresh

- Nouvelle route API /api/virements-salaires/[id] (PATCH)
  * Mise à jour transfer_done et transfer_done_at

- Amélioration UX
  * Card informative pour clients non-Odentas
  * Modal informatif dans 'En savoir plus'
  * Messages clairs et cohérents
This commit is contained in:
odentas 2025-11-03 00:08:21 +01:00
parent 897af4b23a
commit 91e0919274
4 changed files with 968 additions and 6 deletions

141
SEPA_EXPORT_FEATURE.md Normal file
View file

@ -0,0 +1,141 @@
# Export SEPA XML pour Virements Salaires
**Date** : 2 novembre 2025
**Contexte** : Faciliter les virements de salaires pour les clients
## 🎯 Fonctionnalité
Permet aux clients d'exporter un fichier XML au format **SEPA ISO 20022 "pain.001.001.03"** pour lancer facilement les virements de salaires depuis leur banque.
## ✨ Caractéristiques
### 1. Sélection des paies
- ✅ Checkbox sur chaque ligne de paie non payée
- ✅ Checkbox "Tout sélectionner" dans le header du tableau
- ✅ Bouton "Export fichier bancaire (SEPA)" apparaît quand des paies sont cochées
- ✅ Compteur de paies sélectionnées affiché
### 2. Regroupement automatique
- ✅ Si un salarié a plusieurs paies sélectionnées → **un seul virement** avec montant total
- ✅ Les références de contrats sont combinées dans le libellé du virement
### 3. Gestion des IBAN manquants
- ⚠️ Si un salarié n'a pas d'IBAN/BIC → il est **automatiquement exclu** de l'export
- ⚠️ Un modal d'avertissement liste les salariés exclus
- ✅ L'export se fait quand même pour les autres salariés
### 4. Format SEPA XML
Le fichier généré respecte la norme **ISO 20022 pain.001.001.03** :
- **Donneur d'ordre** : L'entreprise (IBAN depuis `organization_details`)
- **Bénéficiaires** : Les salariés (IBAN/BIC depuis la table `salaries`)
- **Montants** : Net à payer (`net_after_withholding`)
- **Libellés** : **"Nom organisation - Période"** (ex: "Atelier Moz - Octobre 2025")
- Le nom de l'organisation est automatiquement tronqué si trop long (limite SEPA : 140 caractères)
- La période est formatée au format "Mois Année" (ex: "Octobre 2025")
- **Date d'exécution** : Date du jour (modifiable par la banque)
## 📁 Fichiers modifiés
### 1. API Route
**`app/api/virements-salaires/export-sepa/route.ts`**
- Récupère les payslips sélectionnés
- Valide les IBAN (format basique)
- Regroupe les paiements par salarié
- Génère le XML SEPA
- Retourne le fichier + métadonnées
### 2. Page Client
**`app/(app)/virements-salaires/page.tsx`**
- Ajout d'états : `selectedPayslips`, `showExportModal`, `exportWarnings`, `isExporting`
- Ajout de fonctions :
- `togglePayslipSelection()`
- `toggleSelectAll()`
- `handleExportSepa()`
- Ajout de la colonne checkbox dans le tableau
- Ajout du bouton d'export
- Ajout du modal d'avertissement pour salariés sans IBAN
## 🗄️ Données requises
### Dans `organization_details`
- ✅ `iban` (IBAN de l'entreprise) - **REQUIS**
- ⚠️ `bic` (BIC de la banque) - Optionnel mais recommandé
### Dans `salaries`
- ✅ `iban` (IBAN du salarié) - **REQUIS pour inclusion**
- ⚠️ `bic` (BIC de la banque du salarié) - Optionnel
Si l'entreprise n'a pas d'IBAN → Erreur bloquante
Si un salarié n'a pas d'IBAN → Exclusion automatique avec avertissement
## 🔄 Workflow utilisateur
1. Client va sur `/virements-salaires`
2. Client coche les paies à payer
3. Bouton "Export fichier bancaire" apparaît
4. Client clique sur le bouton
5. API génère le XML SEPA
6. Si des salariés n'ont pas d'IBAN → Modal d'avertissement
7. Fichier `virements_sepa_MSGXXX.xml` est téléchargé
8. Client importe le fichier dans son espace bancaire
9. Client valide les virements dans sa banque
## 📝 Exemple de fichier généré
```xml
<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:pain.001.001.03">
<CstmrCdtTrfInitn>
<GrpHdr>
<MsgId>MSG20251102143025ABC123</MsgId>
<CreDtTm>2025-11-02T14:30:25Z</CreDtTm>
<NbOfTxs>3</NbOfTxs>
<CtrlSum>4250.50</CtrlSum>
<InitgPty>
<Nm>MA STRUCTURE</Nm>
</InitgPty>
</GrpHdr>
<PmtInf>
<PmtInfId>MSG20251102143025ABC123-001</PmtInfId>
<PmtMtd>TRF</PmtMtd>
<NbOfTxs>3</NbOfTxs>
<CtrlSum>4250.50</CtrlSum>
<PmtTpInf>
<SvcLvl><Cd>SEPA</Cd></SvcLvl>
</PmtTpInf>
<ReqdExctnDt>2025-11-02</ReqdExctnDt>
<Dbtr><Nm>MA STRUCTURE</Nm></Dbtr>
<DbtrAcct><Id><IBAN>FR7630001007941234567890185</IBAN></Id></DbtrAcct>
<DbtrAgt><FinInstnId><BIC>BNPAFRPP</BIC></FinInstnId></DbtrAgt>
<ChrgBr>SLEV</ChrgBr>
<CdtTrfTxInf>
<PmtId><EndToEndId>MSG20251102143025ABC123-001</EndToEndId></PmtId>
<Amt><InstdAmt Ccy="EUR">1500.00</InstdAmt></Amt>
<CdtrAgt><FinInstnId><BIC>CEPAFRPP</BIC></FinInstnId></CdtrAgt>
<Cdtr><Nm>DUPONT Jean</Nm></Cdtr>
<CdtrAcct><Id><IBAN>FR7612345678901234567890123</IBAN></Id></CdtrAcct>
<RmtInf><Ustrd>Atelier Moz - Octobre 2025</Ustrd></RmtInf>
</CdtTrfTxInf>
<!-- Autres virements... -->
</PmtInf>
</CstmrCdtTrfInitn>
</Document>
```
## 🚀 Prochaines améliorations possibles
- [ ] Permettre de choisir la date d'exécution
- [ ] Enregistrer l'historique des exports SEPA
- [ ] Marquer automatiquement les paies comme "payées" après export
- [ ] Support du format pain.001.001.09 (version plus récente)
- [ ] Validation IBAN plus stricte avec module de contrôle
- [ ] Récupération automatique du BIC depuis l'IBAN (API externe)
## 📊 Impact
- **UX** : Les clients gagnent du temps sur la saisie manuelle des virements
- **Sécurité** : Moins d'erreurs de saisie d'IBAN
- **Traçabilité** : Libellés standardisés avec références de contrats
- **Conformité** : Format SEPA standard accepté par toutes les banques européennes

View file

@ -375,6 +375,20 @@ export default function VirementsPage() {
const [pdfUrl, setPdfUrl] = useState<string>("");
const [undoModalOpen, setUndoModalOpen] = useState(false);
const [undoPayslipId, setUndoPayslipId] = useState<string | null>(null);
// États pour la sélection et l'export SEPA
const [selectedPayslips, setSelectedPayslips] = useState<Set<string>>(new Set());
const [showExportModal, setShowExportModal] = useState(false);
const [exportWarnings, setExportWarnings] = useState<string[]>([]);
const [exportSuccess, setExportSuccess] = useState<{
numberOfTransactions: number;
totalAmount: string;
includedPayments: number;
excludedPayments: number;
} | null>(null);
const [isExporting, setIsExporting] = useState(false);
const [isMarkingPaid, setIsMarkingPaid] = useState(false);
const [showBulkMarkPaidModal, setShowBulkMarkPaidModal] = useState(false);
const { data: userInfo, isLoading: isLoadingUser } = useUserInfo();
const { data: organizations, isLoading: isLoadingOrgs, error: orgsError } = useOrganizations();
@ -516,14 +530,14 @@ export default function VirementsPage() {
try {
// Optimistic UI: masquer l'élément avant refetch
// (on ne modifie pas le cache TanStack ici, on refetch directement après)
await fetch(`/api/payslips/${payslipId}`, {
await fetch(`/api/virements-salaires/${encodeURIComponent(payslipId)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
credentials: 'include',
body: JSON.stringify({ transfer_done: true })
});
// Invalider les requêtes liées pour recharger la liste
queryClient.invalidateQueries({ queryKey: ["virements-salaires"] });
await queryClient.invalidateQueries({ queryKey: ["virements-salaires"] });
} catch (e) {
console.error('Erreur marquage payslip:', e);
alert('Erreur lors du marquage du virement.');
@ -533,14 +547,14 @@ export default function VirementsPage() {
// Mutation: marquer un payslip comme NON viré (annuler)
async function markPayslipUndone(payslipId: string) {
try {
await fetch(`/api/payslips/${payslipId}`, {
await fetch(`/api/virements-salaires/${encodeURIComponent(payslipId)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
credentials: 'include',
body: JSON.stringify({ transfer_done: false })
});
// Invalider les requêtes liées pour recharger la liste
queryClient.invalidateQueries({ queryKey: ["virements-salaires"] });
await queryClient.invalidateQueries({ queryKey: ["virements-salaires"] });
setUndoModalOpen(false);
setUndoPayslipId(null);
} catch (e) {
@ -555,6 +569,141 @@ export default function VirementsPage() {
setUndoModalOpen(true);
}
// Gestion de la sélection de payslips
function togglePayslipSelection(payslipId: string) {
setSelectedPayslips(prev => {
const next = new Set(prev);
if (next.has(payslipId)) {
next.delete(payslipId);
} else {
next.add(payslipId);
}
return next;
});
}
function toggleSelectAll() {
if (selectedPayslips.size === clientUnpaid.length && clientUnpaid.length > 0) {
setSelectedPayslips(new Set());
} else {
const allIds = clientUnpaid
.filter(item => item.source === 'payslip' && item.id)
.map(item => item.id);
setSelectedPayslips(new Set(allIds));
}
}
// Export SEPA
async function handleExportSepa() {
if (selectedPayslips.size === 0) {
alert('Veuillez sélectionner au moins une fiche de paie');
return;
}
setIsExporting(true);
try {
const response = await fetch('/api/virements-salaires/export-sepa', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
payslipIds: Array.from(selectedPayslips),
organizationId: selectedOrgId
})
});
const result = await response.json();
if (!response.ok) {
if (result.employeesWithoutIBAN && result.employeesWithoutIBAN.length > 0) {
setExportWarnings(result.employeesWithoutIBAN);
setExportSuccess(null);
setShowExportModal(true);
} else {
throw new Error(result.message || 'Erreur lors de l\'export');
}
return;
}
// Préparer les données de succès
setExportSuccess({
numberOfTransactions: result.metadata.numberOfTransactions,
totalAmount: result.metadata.totalAmount,
includedPayments: result.metadata.includedPayments || result.metadata.numberOfTransactions,
excludedPayments: result.metadata.excludedPayments || 0
});
// Si des salariés n'ont pas d'IBAN mais que d'autres oui
if (result.metadata?.employeesWithoutIBAN && result.metadata.employeesWithoutIBAN.length > 0) {
setExportWarnings(result.metadata.employeesWithoutIBAN);
} else {
setExportWarnings([]);
}
// Afficher le modal de succès
setShowExportModal(true);
// Télécharger le fichier XML
const blob = new Blob([result.xml], { type: 'application/xml' });
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `virements_sepa_${result.metadata.messageId}.xml`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
// Décocher les payslips après export réussi
setSelectedPayslips(new Set());
} catch (error) {
console.error('Erreur export SEPA:', error);
alert(error instanceof Error ? error.message : 'Erreur lors de l\'export SEPA');
} finally {
setIsExporting(false);
}
}
// Marquage groupé des paies comme payées
async function handleBulkMarkPaid() {
if (selectedPayslips.size === 0) {
alert('Veuillez sélectionner au moins une fiche de paie');
return;
}
setShowBulkMarkPaidModal(true);
}
async function confirmBulkMarkPaid() {
setShowBulkMarkPaidModal(false);
setIsMarkingPaid(true);
try {
const promises = Array.from(selectedPayslips).map(payslipId =>
fetch(`/api/virements-salaires/${encodeURIComponent(payslipId)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
credentials: 'include',
body: JSON.stringify({ transfer_done: true })
})
);
await Promise.all(promises);
// Invalider les requêtes pour recharger la liste
await queryClient.invalidateQueries({ queryKey: ["virements-salaires"] });
// Réinitialiser la sélection
setSelectedPayslips(new Set());
} catch (error) {
console.error('Erreur marquage groupé:', error);
alert('Erreur lors du marquage groupé des virements');
} finally {
setIsMarkingPaid(false);
}
}
// Filtrage local pour la recherche ET la période
const filteredItems = useMemo((): VirementItem[] => {
let result: VirementItem[] = items;
@ -762,6 +911,25 @@ export default function VirementsPage() {
</div>
</div>
</div>
{/* Card informative SEPA pour clients non-Odentas */}
{!isOdentas && (
<div className="mt-4 rounded-xl border border-blue-200 bg-blue-50 p-4">
<div className="flex items-start gap-3">
<div className="rounded-lg bg-blue-100 p-2 mt-0.5">
<Info className="h-4 w-4 text-blue-700" />
</div>
<div className="flex-1">
<p className="text-sm text-blue-900 mb-2">
<span className="font-semibold">Nouveau : Export SEPA</span> Générez un fichier XML (norme ISO 20022) pour effectuer vos virements salaires groupés depuis votre banque, si celle-ci le permet. Il vous suffit de cocher les paies concernées.
</p>
<p className="text-sm text-blue-900">
Vous pouvez également marquer plusieurs salaires comme payés en une seule fois en sélectionnant les paies et en cliquant sur Marquer comme payé.
</p>
</div>
</div>
</div>
)}
</section>
{/* Tableau principal */}
@ -860,10 +1028,56 @@ export default function VirementsPage() {
</section>
) : (
<section className="rounded-2xl border bg-white">
{/* Bouton export SEPA si des payslips sont sélectionnés */}
{selectedPayslips.size > 0 && (
<div className="border-b bg-blue-50 px-4 py-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="text-sm font-medium text-blue-900">
{selectedPayslips.size} paie{selectedPayslips.size > 1 ? 's' : ''} sélectionnée{selectedPayslips.size > 1 ? 's' : ''}
</div>
<button
onClick={() => setSelectedPayslips(new Set())}
className="text-xs text-blue-600 hover:text-blue-800 underline"
>
Tout décocher
</button>
</div>
<div className="flex items-center gap-3">
<button
onClick={handleBulkMarkPaid}
disabled={isMarkingPaid}
className="inline-flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Check className="w-4 h-4" />
{isMarkingPaid ? 'Marquage en cours...' : 'Marquer comme payé'}
</button>
<button
onClick={handleExportSepa}
disabled={isExporting}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Download className="w-4 h-4" />
{isExporting ? 'Export en cours...' : 'Exporter fichier bancaire (SEPA)'}
</button>
</div>
</div>
</div>
)}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-slate-50/80">
<Th className="w-12">
<input
type="checkbox"
checked={selectedPayslips.size > 0 && selectedPayslips.size === clientUnpaid.filter(it => it.source === 'payslip').length}
onChange={toggleSelectAll}
className="rounded"
title="Tout sélectionner / Tout décocher"
/>
</Th>
<Th>Salarié·e</Th>
<Th>Contrat</Th>
<Th>Profession</Th>
@ -876,7 +1090,7 @@ export default function VirementsPage() {
{/* Sous-header avec le total des nets à payer (salaires non payés) */}
{!isLoading && !isError && clientUnpaid.length > 0 && (
<tr className="bg-gradient-to-r from-indigo-50 to-purple-50 border-b-2 border-indigo-200">
<th colSpan={6} className="px-4 py-3 text-left">
<th colSpan={7} className="px-4 py-3 text-left">
<div className="flex items-center gap-2">
<div className="text-sm font-semibold text-indigo-900">
Salaires à payer
@ -901,11 +1115,23 @@ export default function VirementsPage() {
) : isError ? (
<tr><td colSpan={8} className="py-12 text-center text-rose-500">Erreur : {(error as any)?.message || 'imprévue'}</td></tr>
) : (clientUnpaid.length === 0 && clientRecent.length === 0) ? (
<tr><td colSpan={8} className="py-12 text-center text-slate-500">Aucun virement de salaires trouvé.</td></tr>
<tr><td colSpan={9} className="py-12 text-center text-slate-500">Aucun virement de salaires trouvé.</td></tr>
) : (
<>
{clientUnpaid.map((it) => (
<tr key={`unpaid-${it.source}-${it.id}`} className="border-b last:border-b-0 hover:bg-slate-50/50">
<Td>
{it.source === 'payslip' ? (
<input
type="checkbox"
checked={selectedPayslips.has(it.id)}
onChange={() => togglePayslipSelection(it.id)}
className="rounded"
/>
) : (
<div className="w-4"></div>
)}
</Td>
<Td>
{it.salarie_matricule ? (
<a href={`/salaries/${encodeURIComponent(it.salarie_matricule)}`} target="_blank" rel="noreferrer" className="text-blue-600 hover:underline font-medium">
@ -976,6 +1202,7 @@ export default function VirementsPage() {
)}
{clientRecent.map((it) => (
<tr key={`recent-${it.source}-${it.id}`} className="border-b last:border-b-0 hover:bg-slate-50/50">
<Td><span></span></Td>
<Td>
{it.salarie_matricule ? (
<a href={`/salaries/${encodeURIComponent(it.salarie_matricule)}`} target="_blank" rel="noreferrer" className="text-blue-600 hover:underline font-medium">
@ -1042,6 +1269,20 @@ export default function VirementsPage() {
<button onClick={() => setAboutOpen(false)} className="text-slate-500 hover:text-slate-900"></button>
</div>
<div className="p-5 space-y-4 text-sm">
{/* Card informative pour tous les clients */}
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
<div className="flex items-start gap-3">
<div className="rounded-lg bg-blue-100 p-2 mt-0.5">
<Info className="h-4 w-4 text-blue-700" />
</div>
<div className="flex-1">
<p className="text-sm text-blue-900">
Les informations ci-dessous ne concernent que les clients ayant confié la gestion de leurs virements de salaires à Odentas. Vous pouvez nous confier cette gestion sans surcoût, contactez-nous pour plus d'informations.
</p>
</div>
</div>
</div>
<p>
En fin de mois, nous vous envoyons un <strong>appel à virement</strong> avec le <strong>total des salaires nets</strong> de la période concernée.
</p>
@ -1157,6 +1398,51 @@ export default function VirementsPage() {
</div>
)}
{/* Modal de confirmation pour le marquage groupé */}
{showBulkMarkPaidModal && (
<div className="fixed inset-0 z-[1000]">
<div
className="absolute inset-0 bg-black/40"
onClick={() => setShowBulkMarkPaidModal(false)}
/>
<div className="absolute inset-0 flex items-center justify-center p-4">
<div role="dialog" aria-modal="true" className="w-full max-w-md rounded-2xl border bg-white shadow-xl">
<div className="p-5 border-b bg-green-50">
<div className="flex items-center gap-3">
<div className="rounded-lg bg-green-100 p-2">
<Check className="h-5 w-5 text-green-700" />
</div>
<h2 className="text-base font-semibold text-green-900">Marquer comme payé</h2>
</div>
</div>
<div className="p-5 space-y-4 text-sm">
<p className="text-slate-700">
Voulez-vous vraiment marquer <strong>{selectedPayslips.size} paie{selectedPayslips.size > 1 ? 's' : ''}</strong> comme payée{selectedPayslips.size > 1 ? 's' : ''} ?
</p>
<p className="text-slate-600">
En cas d'erreur, vous pourrez marquer la paie comme non payée à tout moment.
</p>
</div>
<div className="p-4 border-t flex justify-end gap-2">
<button
onClick={() => setShowBulkMarkPaidModal(false)}
className="px-4 py-2 rounded-lg border hover:bg-slate-50 text-sm transition-colors"
>
Annuler
</button>
<button
onClick={confirmBulkMarkPaid}
disabled={isMarkingPaid}
className="px-4 py-2 rounded-lg bg-green-600 text-white hover:bg-green-700 text-sm transition-colors disabled:opacity-50"
>
{isMarkingPaid ? 'Marquage en cours...' : 'Confirmer'}
</button>
</div>
</div>
</div>
</div>
)}
{/* Modal de confirmation pour annuler le marquage */}
{undoModalOpen && undoPayslipId && (
<div className="fixed inset-0 z-[1000]">
@ -1201,6 +1487,108 @@ export default function VirementsPage() {
</div>
</div>
)}
{/* Modal d'avertissement pour les salariés sans IBAN */}
{showExportModal && (
<div className="fixed inset-0 z-[1000]">
<div
className="absolute inset-0 bg-black/40"
onClick={() => {
setShowExportModal(false);
setExportSuccess(null);
setExportWarnings([]);
}}
/>
<div className="absolute inset-0 flex items-center justify-center p-4">
<div role="dialog" aria-modal="true" className="w-full max-w-lg rounded-2xl border bg-white shadow-xl">
{/* Header avec succès ou erreur */}
{exportSuccess ? (
<div className="p-5 border-b bg-green-50">
<div className="flex items-center gap-3">
<div className="rounded-lg bg-green-100 p-2">
<Check className="h-5 w-5 text-green-700" />
</div>
<div>
<h2 className="text-base font-semibold text-green-900">Fichier SEPA généré avec succès !</h2>
<p className="text-sm text-green-700">Le téléchargement a démarré automatiquement</p>
</div>
</div>
</div>
) : (
<div className="p-5 border-b bg-red-50">
<div className="flex items-center gap-3">
<div className="rounded-lg bg-red-100 p-2">
<X className="h-5 w-5 text-red-700" />
</div>
<h2 className="text-base font-semibold text-red-900">Export impossible</h2>
</div>
</div>
)}
<div className="p-5 space-y-4 text-sm">
{/* Résumé de l'export si succès */}
{exportSuccess && (
<div className="bg-slate-50 rounded-lg p-4 space-y-2">
<div className="flex justify-between items-center">
<span className="text-slate-600">Nombre de virements :</span>
<span className="font-semibold text-slate-900">{exportSuccess.numberOfTransactions}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-slate-600">Montant total :</span>
<span className="font-semibold text-slate-900">{exportSuccess.totalAmount} </span>
</div>
{exportSuccess.excludedPayments > 0 && (
<>
<div className="border-t border-slate-200 my-2"></div>
<div className="flex justify-between items-center text-amber-700">
<span>Salariés inclus :</span>
<span className="font-semibold">{exportSuccess.includedPayments}</span>
</div>
<div className="flex justify-between items-center text-amber-700">
<span>Salariés exclus (sans RIB) :</span>
<span className="font-semibold">{exportSuccess.excludedPayments}</span>
</div>
</>
)}
</div>
)}
{/* Liste des salariés sans RIB */}
{exportWarnings.length > 0 && (
<>
<div className={`${exportSuccess ? 'border-t border-slate-200 pt-4' : ''}`}>
<p className="text-slate-700 font-medium mb-2">
{exportSuccess ? 'Salariés exclus (sans RIB) :' : 'Tous les salariés sélectionnés n\'ont pas de RIB :'}
</p>
<ul className="list-disc list-inside space-y-1 text-slate-600 bg-amber-50 rounded-lg p-3 border border-amber-200">
{exportWarnings.map((name, index) => (
<li key={index}>{name}</li>
))}
</ul>
<p className="text-slate-600 mt-3">
Ces salariés doivent <strong>envoyer leur RIB depuis leur Espace Transat</strong> pour pouvoir être inclus dans les prochains exports de virements.
</p>
</div>
</>
)}
</div>
<div className="p-4 border-t flex justify-end">
<button
onClick={() => {
setShowExportModal(false);
setExportSuccess(null);
setExportWarnings([]);
}}
className="px-4 py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-700 text-sm"
>
{exportSuccess ? 'Fermer' : 'J\'ai compris'}
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,56 @@
import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs';
import { cookies } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const supabase = createRouteHandlerClient({ cookies });
// Vérifier l'authentification
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
}
const payslipId = params.id;
const body = await request.json();
const { transfer_done } = body;
if (typeof transfer_done !== 'boolean') {
return NextResponse.json(
{ error: 'Le champ transfer_done doit être un booléen' },
{ status: 400 }
);
}
// Mettre à jour la fiche de paie
const { data, error } = await supabase
.from('payslips')
.update({
transfer_done,
transfer_done_at: transfer_done ? new Date().toISOString() : null
})
.eq('id', payslipId)
.select()
.single();
if (error) {
console.error('Erreur mise à jour payslip:', error);
return NextResponse.json(
{ error: 'Erreur lors de la mise à jour' },
{ status: 500 }
);
}
return NextResponse.json({ success: true, data });
} catch (error) {
console.error('Erreur API virements-salaires PATCH:', error);
return NextResponse.json(
{ error: 'Erreur serveur' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,377 @@
// app/api/virements-salaires/export-sepa/route.ts
import { NextResponse } from "next/server";
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { cookies } from "next/headers";
export const dynamic = 'force-dynamic';
export const revalidate = 0;
export const runtime = 'nodejs';
// Fonction pour générer un identifiant de message unique
function generateMessageId(): string {
const now = new Date();
const timestamp = now.toISOString().replace(/[-:T.]/g, '').slice(0, 14);
const random = Math.random().toString(36).substring(2, 8).toUpperCase();
return `MSG${timestamp}${random}`;
}
// Fonction pour formater la date au format ISO (YYYY-MM-DD)
function formatDateISO(date: Date = new Date()): string {
return date.toISOString().split('T')[0];
}
// Fonction pour formater la date-heure ISO 8601
function formatDateTimeISO(date: Date = new Date()): string {
return date.toISOString();
}
// Fonction pour nettoyer et limiter les caractères XML
function sanitizeXML(str: string, maxLength?: number): string {
let cleaned = str
.replace(/[<>&'"]/g, '') // Supprimer les caractères XML problématiques
.replace(/[^\x20-\x7E\xA0-\xFF]/g, '') // Garder uniquement les caractères imprimables
.trim();
if (maxLength && cleaned.length > maxLength) {
cleaned = cleaned.substring(0, maxLength);
}
return cleaned;
}
// Fonction pour valider un IBAN (basique)
function isValidIBAN(iban: string): boolean {
const cleaned = iban.replace(/\s/g, '').toUpperCase();
return /^[A-Z]{2}[0-9]{2}[A-Z0-9]+$/.test(cleaned) && cleaned.length >= 15 && cleaned.length <= 34;
}
// Fonction pour extraire le BIC depuis l'IBAN si non fourni (approximatif)
function extractBICFromIBAN(iban: string): string | null {
// Cette fonction est approximative - dans un cas réel, il faudrait une base de données BIC
// Pour la France, on peut extraire le code banque mais pas le BIC complet
return null;
}
// Fonction pour formater une période (YYYY-MM-DD) en texte (ex: "Octobre 2025")
function formatPeriod(periodStart: string | null): string {
if (!periodStart) return '';
try {
const date = new Date(periodStart);
const monthNames = [
'Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'
];
const month = monthNames[date.getMonth()];
const year = date.getFullYear();
return `${month} ${year}`;
} catch {
return '';
}
}
type PayslipDetail = {
id: string;
net_after_withholding: number;
contract_id: string;
period_start: string | null;
cddu_contracts: {
contract_number: string;
employee_id: string;
salaries: {
id: string;
nom: string;
prenom: string;
iban: string | null;
bic: string | null;
};
};
};
type GroupedPayment = {
employeeId: string;
employeeName: string;
iban: string;
bic: string;
totalAmount: number;
payslipIds: string[];
references: string[];
period?: string; // Format "Octobre 2025"
};
export async function POST(req: Request) {
try {
const supabase = createRouteHandlerClient({ cookies });
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
}
const body = await req.json();
const { payslipIds, organizationId } = body;
if (!payslipIds || !Array.isArray(payslipIds) || payslipIds.length === 0) {
return NextResponse.json({ error: 'payslipIds_required' }, { status: 400 });
}
if (!organizationId) {
return NextResponse.json({ error: 'organizationId_required' }, { status: 400 });
}
// Récupérer les informations de l'organisation (donneur d'ordre)
const { data: orgData, error: orgError } = await supabase
.from('organizations')
.select(`
id,
name,
structure_api,
organization_details(
iban,
bic,
code_employeur
)
`)
.eq('id', organizationId)
.single();
if (orgError || !orgData) {
return NextResponse.json({ error: 'organization_not_found' }, { status: 404 });
}
const orgDetails = Array.isArray(orgData.organization_details)
? orgData.organization_details[0]
: orgData.organization_details;
// Vérifier que l'organisation a un IBAN
if (!orgDetails?.iban) {
return NextResponse.json({
error: 'missing_organization_iban',
message: 'L\'IBAN de votre structure n\'est pas renseigné'
}, { status: 400 });
}
// Récupérer les détails des payslips avec les informations des salariés
const { data: payslips, error: payslipsError } = await supabase
.from('payslips')
.select(`
id,
net_after_withholding,
contract_id,
period_start,
cddu_contracts!inner(
contract_number,
employee_id,
salaries!inner(
id,
nom,
prenom,
iban,
bic
)
)
`)
.in('id', payslipIds)
.eq('organization_id', organizationId);
if (payslipsError) {
console.error('Error fetching payslips:', payslipsError);
return NextResponse.json({ error: 'database_error' }, { status: 500 });
}
if (!payslips || payslips.length === 0) {
return NextResponse.json({ error: 'no_payslips_found' }, { status: 404 });
}
// Regrouper les paiements par salarié
const paymentsByEmployee = new Map<string, GroupedPayment>();
const employeesWithoutIBAN: string[] = [];
for (const payslip of payslips as any[]) {
const contract = payslip.cddu_contracts;
const salary = contract.salaries;
const employeeId = salary.id;
const employeeName = `${salary.nom || ''} ${salary.prenom || ''}`.trim();
const iban = salary.iban;
const bic = salary.bic;
const amount = parseFloat(payslip.net_after_withholding || 0);
const periodFormatted = formatPeriod(payslip.period_start);
// Vérifier si l'IBAN est présent et valide
if (!iban || !isValidIBAN(iban)) {
if (!employeesWithoutIBAN.includes(employeeName)) {
employeesWithoutIBAN.push(employeeName);
}
continue; // Exclure ce payslip
}
// Regrouper les paiements par employé
if (paymentsByEmployee.has(employeeId)) {
const existing = paymentsByEmployee.get(employeeId)!;
existing.totalAmount += amount;
existing.payslipIds.push(payslip.id);
existing.references.push(contract.contract_number);
// Mettre à jour la période si elle n'est pas encore définie
if (!existing.period && periodFormatted) {
existing.period = periodFormatted;
}
} else {
paymentsByEmployee.set(employeeId, {
employeeId,
employeeName,
iban: iban.replace(/\s/g, ''),
bic: bic || '', // Le BIC peut être déduit par la banque si vide
totalAmount: amount,
payslipIds: [payslip.id],
references: [contract.contract_number],
period: periodFormatted
});
}
}
// Si tous les salariés n'ont pas d'IBAN, retourner une erreur
if (paymentsByEmployee.size === 0) {
return NextResponse.json({
error: 'no_valid_payments',
message: 'Aucun salarié n\'a d\'IBAN valide',
employeesWithoutIBAN
}, { status: 400 });
}
// Calculer le montant total et le nombre de transactions
const payments = Array.from(paymentsByEmployee.values());
const totalAmount = payments.reduce((sum, p) => sum + p.totalAmount, 0);
const numberOfTransactions = payments.length;
// Générer le fichier SEPA XML (pain.001.001.03)
const messageId = generateMessageId();
const creationDateTime = formatDateTimeISO();
const requestedExecutionDate = formatDateISO(); // Aujourd'hui ou date souhaitée
const debtorIBAN = orgDetails.iban.replace(/\s/g, '');
const debtorBIC = orgDetails.bic || '';
const debtorName = sanitizeXML(orgData.structure_api || orgData.name, 70);
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
xml += '<Document xmlns="urn:iso:std:iso:20022:tech:xsd:pain.001.001.03" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">\n';
xml += ' <CstmrCdtTrfInitn>\n';
// Group Header
xml += ' <GrpHdr>\n';
xml += ` <MsgId>${messageId}</MsgId>\n`;
xml += ` <CreDtTm>${creationDateTime}</CreDtTm>\n`;
xml += ` <NbOfTxs>${numberOfTransactions}</NbOfTxs>\n`;
xml += ` <CtrlSum>${totalAmount.toFixed(2)}</CtrlSum>\n`;
xml += ' <InitgPty>\n';
xml += ` <Nm>${debtorName}</Nm>\n`;
xml += ' </InitgPty>\n';
xml += ' </GrpHdr>\n';
// Payment Information
xml += ' <PmtInf>\n';
xml += ` <PmtInfId>${messageId}-001</PmtInfId>\n`;
xml += ' <PmtMtd>TRF</PmtMtd>\n';
xml += ` <NbOfTxs>${numberOfTransactions}</NbOfTxs>\n`;
xml += ` <CtrlSum>${totalAmount.toFixed(2)}</CtrlSum>\n`;
xml += ' <PmtTpInf>\n';
xml += ' <SvcLvl>\n';
xml += ' <Cd>SEPA</Cd>\n';
xml += ' </SvcLvl>\n';
xml += ' </PmtTpInf>\n';
xml += ` <ReqdExctnDt>${requestedExecutionDate}</ReqdExctnDt>\n`;
xml += ' <Dbtr>\n';
xml += ` <Nm>${debtorName}</Nm>\n`;
xml += ' </Dbtr>\n';
xml += ' <DbtrAcct>\n';
xml += ' <Id>\n';
xml += ` <IBAN>${debtorIBAN}</IBAN>\n`;
xml += ' </Id>\n';
xml += ' </DbtrAcct>\n';
if (debtorBIC) {
xml += ' <DbtrAgt>\n';
xml += ' <FinInstnId>\n';
xml += ` <BIC>${debtorBIC}</BIC>\n`;
xml += ' </FinInstnId>\n';
xml += ' </DbtrAgt>\n';
}
xml += ' <ChrgBr>SLEV</ChrgBr>\n';
// Credit Transfer Transaction Information (une par bénéficiaire)
payments.forEach((payment, index) => {
const endToEndId = `${messageId}-${String(index + 1).padStart(3, '0')}`;
const creditorName = sanitizeXML(payment.employeeName, 70);
// Format: "Nom organisation - Période" (ex: "Atelier Moz - Octobre 2025")
// Limite SEPA: 140 caractères pour le libellé
const orgName = debtorName;
const periodText = payment.period || 'Salaire';
// Calculer l'espace disponible pour le nom de l'organisation
// Format: "OrgName - Période" → " - " = 3 caractères
const separator = ' - ';
const maxOrgNameLength = 140 - separator.length - periodText.length;
// Tronquer le nom de l'organisation si nécessaire
const truncatedOrgName = orgName.length > maxOrgNameLength
? orgName.substring(0, maxOrgNameLength - 3) + '...'
: orgName;
const remittanceInfo = sanitizeXML(`${truncatedOrgName}${separator}${periodText}`, 140);
xml += ' <CdtTrfTxInf>\n';
xml += ' <PmtId>\n';
xml += ` <EndToEndId>${endToEndId}</EndToEndId>\n`;
xml += ' </PmtId>\n';
xml += ' <Amt>\n';
xml += ` <InstdAmt Ccy="EUR">${payment.totalAmount.toFixed(2)}</InstdAmt>\n`;
xml += ' </Amt>\n';
if (payment.bic) {
xml += ' <CdtrAgt>\n';
xml += ' <FinInstnId>\n';
xml += ` <BIC>${payment.bic}</BIC>\n`;
xml += ' </FinInstnId>\n';
xml += ' </CdtrAgt>\n';
}
xml += ' <Cdtr>\n';
xml += ` <Nm>${creditorName}</Nm>\n`;
xml += ' </Cdtr>\n';
xml += ' <CdtrAcct>\n';
xml += ' <Id>\n';
xml += ` <IBAN>${payment.iban}</IBAN>\n`;
xml += ' </Id>\n';
xml += ' </CdtrAcct>\n';
xml += ' <RmtInf>\n';
xml += ` <Ustrd>${remittanceInfo}</Ustrd>\n`;
xml += ' </RmtInf>\n';
xml += ' </CdtTrfTxInf>\n';
});
xml += ' </PmtInf>\n';
xml += ' </CstmrCdtTrfInitn>\n';
xml += '</Document>';
// Retourner le XML avec les métadonnées
return NextResponse.json({
success: true,
xml,
metadata: {
messageId,
numberOfTransactions,
totalAmount: totalAmount.toFixed(2),
currency: 'EUR',
executionDate: requestedExecutionDate,
debtorName,
employeesWithoutIBAN: employeesWithoutIBAN.length > 0 ? employeesWithoutIBAN : undefined,
includedPayments: payments.length,
excludedPayments: employeesWithoutIBAN.length
}
});
} catch (error) {
console.error('Error generating SEPA XML:', error);
return NextResponse.json({
error: 'internal_server_error',
message: error instanceof Error ? error.message : 'Unknown error'
}, { status: 500 });
}
}