feat: Ajout fonctionnalités virements, facturation, signatures et emails

- Ajout sous-header total net à payer sur page virements-salaires
- Migration transfer_done_at pour tracking précis des virements
- Nouvelle page saisie tableau pour création factures en masse
- APIs bulk pour mise à jour dates signature et jours technicien
- API demande mandat SEPA avec email template
- Webhook DocuSeal pour signature contrats (mode TEST)
- Composants modaux détails et vérification PDF fiches de paie
- Upload/suppression/remplacement PDFs dans PayslipsGrid
- Amélioration affichage colonnes et filtres grilles contrats/paies
- Template email mandat SEPA avec sous-texte CTA
- APIs bulk facturation (création, update statut/date paiement)
- API clients sans facture pour période donnée
- Corrections calculs dates et montants avec auto-remplissage
This commit is contained in:
odentas 2025-11-02 23:26:19 +01:00
parent 46633ecd74
commit 897af4b23a
38 changed files with 5210 additions and 234 deletions

View file

@ -0,0 +1,72 @@
# Migration : Ajout de transfer_done_at pour les virements salaires
**Date** : 2 novembre 2025
**Contexte** : Amélioration du tracking des virements de salaires
## 🎯 Problème identifié
Le système utilisait la colonne `updated_at` pour déterminer si une paie avait été virée dans les 30 derniers jours. Cette colonne est mise à jour à **chaque modification** du payslip, pas seulement quand on marque le virement comme effectué.
**Conséquence** : Une paie marquée comme virée il y a 2 mois pouvait réapparaître dans "Virements récemment effectués" si le payslip était modifié pour une autre raison.
## ✅ Solution implémentée
### 1. Nouvelle colonne `transfer_done_at`
Ajout d'une colonne `transfer_done_at` (TIMESTAMPTZ) dans la table `payslips` :
- **NULL** : Virement pas encore effectué
- **Date** : Date exacte où le virement a été marqué comme effectué
### 2. Modifications du code
#### API PATCH `/api/payslips/[id]`
- Quand `transfer_done` passe à `true` → on enregistre la date actuelle dans `transfer_done_at`
- Quand `transfer_done` passe à `false` → on efface `transfer_done_at` (remis à NULL)
#### API GET `/api/virements-salaires`
- Ajout de `transfer_done_at` dans le SELECT
- Utilisation de `transfer_done_at` au lieu de `updated_at` pour filtrer les paies récentes (≤ 30 jours)
### 3. Migration de données
Les payslips existants avec `transfer_done = true` ont été migrés :
- `transfer_done_at` = `updated_at` (valeur par défaut pour l'historique)
## 📁 Fichiers modifiés
1. `migrations/add_transfer_done_at_to_payslips.sql` - Migration SQL
2. `app/api/payslips/[id]/route.ts` - API PATCH pour mettre à jour transfer_done_at
3. `app/api/virements-salaires/route.ts` - API GET utilisant transfer_done_at
## 🚀 Déploiement
### Étape 1 : Exécuter la migration SQL
```sql
-- Sur Supabase, exécuter le fichier migrations/add_transfer_done_at_to_payslips.sql
```
### Étape 2 : Déployer le code
```bash
# Build et déploiement
npm run build
git push origin feat/direct-docuseal-webhook-contracts
```
### Étape 3 : Vérifier
- Aller sur `/virements-salaires`
- Marquer une paie comme payée
- Vérifier qu'elle apparaît dans "Virements récemment effectués"
- Annuler le marquage
- Vérifier qu'elle revient dans "Paies à payer"
## 🔍 Points de vigilance
- Les paies virées **avant** cette migration auront `transfer_done_at = updated_at`
- Seules les nouvelles modifications auront la vraie date de marquage
- Les index créés optimisent les requêtes sur `transfer_done_at`
## 📊 Impact
- **Performance** : Ajout d'index pour optimiser les requêtes
- **UX** : Les utilisateurs verront désormais précisément les paies virées dans les 30 derniers jours
- **Traçabilité** : Meilleur suivi de l'historique des virements

View file

@ -69,6 +69,8 @@ export default function EditFormulairePage() {
date_fin: data.date_fin || "", date_fin: data.date_fin || "",
nb_representations: (data as any).nb_representations ?? "", nb_representations: (data as any).nb_representations ?? "",
nb_services_repetition: (data as any).nb_services_repetition ?? "", nb_services_repetition: (data as any).nb_services_repetition ?? "",
dates_representations: (data as any).dates_representations || "",
dates_repetitions: (data as any).dates_repetitions || "",
jours_travail: (data as any).jours_travailles || "", jours_travail: (data as any).jours_travailles || "",
nb_heures_annexes: (data as any).nb_heures_annexes ?? (data as any).nb_heures_aem ?? undefined, nb_heures_annexes: (data as any).nb_heures_annexes ?? (data as any).nb_heures_aem ?? undefined,
type_salaire: type_salaire:
@ -109,11 +111,13 @@ export default function EditFormulairePage() {
heures_travail: payload.heures_travail, heures_travail: payload.heures_travail,
minutes_travail: payload.minutes_travail, minutes_travail: payload.minutes_travail,
jours_travail: payload.jours_travail, jours_travail: payload.jours_travail,
jours_travail_non_artiste: payload.jours_travail_non_artiste,
type_salaire: payload.type_salaire, type_salaire: payload.type_salaire,
montant: payload.montant, montant: payload.montant,
panier_repas: payload.panier_repas, panier_repas: payload.panier_repas,
reference: payload.reference, reference: payload.reference,
notes: payload.notes, notes: payload.notes,
send_email_confirmation: payload.send_email_confirmation,
// Optionnel : valider_direct si tu veux déclencher un statut // Optionnel : valider_direct si tu veux déclencher un statut
// valider_direct: payload.valider_direct, // valider_direct: payload.valider_direct,
}), }),

View file

@ -270,6 +270,10 @@ type ContratDetail = {
// Temps de travail réel // Temps de travail réel
jours_travailles?: number; jours_travailles?: number;
jours_travail?: string;
jours_travail_non_artiste?: string;
dates_representations?: string;
dates_repetitions?: string;
nb_representations?: number; nb_representations?: number;
nb_services_repetitions?: number; nb_services_repetitions?: number;
nb_heures_repetitions?: number; nb_heures_repetitions?: number;
@ -1555,7 +1559,14 @@ return (
</Section> </Section>
<Section title="Temps de travail réel" icon={Calendar}> <Section title="Temps de travail réel" icon={Calendar}>
<Field label="Jours travaillés" value={data.jours_travailles ?? 0} /> <Field
label="Jours travaillés"
value={
data.categorie_prof === "Technicien" && data.jours_travail_non_artiste
? data.jours_travail_non_artiste
: (data.jours_travailles ?? 0)
}
/>
<Field label="Nbre de représentations" value={data.nb_representations ?? 0} /> <Field label="Nbre de représentations" value={data.nb_representations ?? 0} />
<Field label="Nbre de services répétitions" value={data.nb_services_repetitions ?? 0} /> <Field label="Nbre de services répétitions" value={data.nb_services_repetitions ?? 0} />
<Field label="Nbre d'heures répétitions" value={data.nb_heures_repetitions ?? 0} /> <Field label="Nbre d'heures répétitions" value={data.nb_heures_repetitions ?? 0} />

View file

@ -3,7 +3,7 @@ import { useState, useMemo, useEffect } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/fetcher"; import { api } from "@/lib/fetcher";
import { ChevronLeft, ChevronRight, Loader2, Search, Plus, Pencil, Copy, Table } from "lucide-react"; import { ChevronLeft, ChevronRight, Loader2, Search, Plus, Pencil, Copy, Table, HelpCircle } from "lucide-react";
import { useDemoMode } from "@/hooks/useDemoMode"; import { useDemoMode } from "@/hooks/useDemoMode";
// --- Types // --- Types
@ -225,6 +225,8 @@ export default function PageContrats(){
const [regime, setRegime] = useState<"CDDU" | "RG">("CDDU"); const [regime, setRegime] = useState<"CDDU" | "RG">("CDDU");
const [sortField, setSortField] = useState<'date_debut' | 'date_fin'>('date_fin'); // Tri par défaut: date de fin const [sortField, setSortField] = useState<'date_debut' | 'date_fin'>('date_fin'); // Tri par défaut: date de fin
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); // Ordre par défaut: décroissant const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); // Ordre par défaut: décroissant
const [showTooltip, setShowTooltip] = useState(false);
const [tooltipPos, setTooltipPos] = useState<{ top: number; left: number } | null>(null);
const router = useRouter(); const router = useRouter();
// 🎭 Détection du mode démo // 🎭 Détection du mode démo
@ -469,7 +471,27 @@ export default function PageContrats(){
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-slate-50/80"> <tr className="border-b bg-slate-50/80">
<Th>État</Th> <Th>
<div className="flex items-center gap-1.5">
État
{regime === 'CDDU' && (
<div
className="relative inline-block"
onMouseEnter={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
setTooltipPos({
top: rect.top + rect.height / 2,
left: rect.right
});
setShowTooltip(true);
}}
onMouseLeave={() => setShowTooltip(false)}
>
<HelpCircle className="w-3.5 h-3.5 text-slate-400 cursor-help" />
</div>
)}
</div>
</Th>
<Th>Référence</Th> <Th>Référence</Th>
<Th>Salarié</Th> <Th>Salarié</Th>
{/* Structure column visible only to staff: we rely on server /api/me to set clientInfo.isStaff via cookie/session. */} {/* Structure column visible only to staff: we rely on server /api/me to set clientInfo.isStaff via cookie/session. */}
@ -617,6 +639,31 @@ export default function PageContrats(){
position="bottom" position="bottom"
/> />
</section> </section>
{/* Tooltip fixe pour l'icône État */}
{showTooltip && tooltipPos && (
<div
className="fixed z-[1000] pointer-events-none"
style={{
top: tooltipPos.top,
left: tooltipPos.left,
transform: 'translateY(-50%)'
}}
>
<div className="flex items-center">
<div className="w-0 h-0" style={{
borderTop: '6px solid transparent',
borderBottom: '6px solid transparent',
borderRight: '6px solid rgb(15, 23, 42)' // slate-900
}} />
<div className="w-72 px-3 py-2 rounded-lg bg-slate-900 text-white text-xs leading-relaxed shadow-xl">
Sur la vue CDDU &gt; En cours, indique l'état de traitement du contrat.
<br /><br />
Sur la vue CDDU &gt; Terminés, indique l'état de traitement de la paie.
</div>
</div>
</div>
)}
</div> </div>
); );
} }

View file

@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Send, Loader2 } from "lucide-react";
type Organization = { type Organization = {
id: string; id: string;
@ -43,6 +44,9 @@ type StructureInfos = {
ouverture_compte?: string; ouverture_compte?: string;
offre_speciale?: string; offre_speciale?: string;
notes?: string; notes?: string;
// Gestion paie
virements_salaires?: string;
contact_principal?: string; contact_principal?: string;
email?: string; email?: string;
@ -261,6 +265,7 @@ export default function ClientDetailPage() {
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [editData, setEditData] = useState<Partial<Organization & StructureInfos & any>>({}); const [editData, setEditData] = useState<Partial<Organization & StructureInfos & any>>({});
const [showSepaMandateModal, setShowSepaMandateModal] = useState(false);
// Récupération de la liste des apporteurs // Récupération de la liste des apporteurs
const { data: referrers = [] } = useQuery<Referrer[]>({ const { data: referrers = [] } = useQuery<Referrer[]>({
@ -325,6 +330,28 @@ export default function ClientDetailPage() {
}, },
}); });
// Mutation pour envoyer la demande de mandat SEPA
const sendSepaMandateMutation = useMutation({
mutationFn: async () => {
const res = await fetch(`/api/staff/clients/${clientId}/request-sepa-mandate`, {
method: "POST",
credentials: "include",
});
if (!res.ok) {
const errorData = await res.json().catch(() => ({}));
throw new Error(errorData.message || "Erreur lors de l'envoi de la demande");
}
return res.json();
},
onSuccess: () => {
setShowSepaMandateModal(false);
alert("Demande de mandat SEPA envoyée avec succès !");
},
onError: (error: any) => {
alert(`Erreur: ${error.message}`);
},
});
// Initialiser les données d'édition // Initialiser les données d'édition
useEffect(() => { useEffect(() => {
if (clientData && isEditing) { if (clientData && isEditing) {
@ -337,6 +364,9 @@ export default function ClientDetailPage() {
offre_speciale: structureInfos.offre_speciale, offre_speciale: structureInfos.offre_speciale,
notes: structureInfos.notes, notes: structureInfos.notes,
// Gestion paie
virements_salaires: structureInfos.virements_salaires,
// Apporteur d'affaires // Apporteur d'affaires
is_referred: details.is_referred, is_referred: details.is_referred,
referrer_code: details.referrer_code, referrer_code: details.referrer_code,
@ -380,6 +410,11 @@ export default function ClientDetailPage() {
qualite_responsable_traitement: structureInfos.qualite_responsable_traitement, qualite_responsable_traitement: structureInfos.qualite_responsable_traitement,
email_responsable_traitement: structureInfos.email_responsable_traitement, email_responsable_traitement: structureInfos.email_responsable_traitement,
// Facturation (SEPA)
iban: details.iban,
bic: details.bic,
id_mandat_sepa: details.id_mandat_sepa,
// Caisses // Caisses
licence_spectacles: structureInfos.licence_spectacles, licence_spectacles: structureInfos.licence_spectacles,
urssaf: structureInfos.urssaf, urssaf: structureInfos.urssaf,
@ -611,12 +646,6 @@ export default function ClientDetailPage() {
]} ]}
onChange={(value) => setEditData(prev => ({ ...prev, structure_a_spectacles: value === "true" }))} onChange={(value) => setEditData(prev => ({ ...prev, structure_a_spectacles: value === "true" }))}
/> />
<EditableLine
label="Entrée en relation"
value={editData.entree_en_relation}
type="date"
onChange={(value) => setEditData(prev => ({ ...prev, entree_en_relation: value }))}
/>
<ImageUpload <ImageUpload
label="Logo" label="Logo"
value={editData.logo_base64} value={editData.logo_base64}
@ -645,7 +674,6 @@ export default function ClientDetailPage() {
<Line label="Trésorier(ère)" value={structureInfos.tresoriere} /> <Line label="Trésorier(ère)" value={structureInfos.tresoriere} />
<Line label="Licence spectacle" value={structureInfos.licence_spectacles} /> <Line label="Licence spectacle" value={structureInfos.licence_spectacles} />
<Line label="Structure à spectacles ?" value={structureInfos.structure_a_spectacles ? "Oui" : "Non"} /> <Line label="Structure à spectacles ?" value={structureInfos.structure_a_spectacles ? "Oui" : "Non"} />
<Line label="Entrée en relation" value={structureInfos.entree_en_relation} />
<LogoLine label="Logo" value={structureInfos.logo_base64} /> <LogoLine label="Logo" value={structureInfos.logo_base64} />
<Line label="Créé le" value={formatDate(organization.created_at)} /> <Line label="Créé le" value={formatDate(organization.created_at)} />
<Line label="Mis à jour le" value={formatDate(organization.updated_at)} /> <Line label="Mis à jour le" value={formatDate(organization.updated_at)} />
@ -653,7 +681,9 @@ export default function ClientDetailPage() {
)} )}
</div> </div>
</section> </section>
</div>
<div className="space-y-4">
{/* Abonnement */} {/* Abonnement */}
<section className="rounded-2xl border bg-white"> <section className="rounded-2xl border bg-white">
<div className="px-4 py-3 border-b"> <div className="px-4 py-3 border-b">
@ -662,39 +692,50 @@ export default function ClientDetailPage() {
<div className="p-4 text-sm"> <div className="p-4 text-sm">
{isEditing ? ( {isEditing ? (
<div className="space-y-2"> <div className="space-y-2">
<EditableLine {/* Section Contrat */}
label="Statut" <div className="pb-2">
value={editData.statut} <div className="text-xs font-semibold text-slate-600 uppercase tracking-wide mb-2">Contrat</div>
type="select" <EditableLine
options={[ label="Entrée en relation"
{ value: "Actif", label: "Actif" }, value={editData.entree_en_relation}
{ value: "Ancien client", label: "Ancien client" }, type="date"
]} onChange={(value) => setEditData(prev => ({ ...prev, entree_en_relation: value }))}
onChange={(value) => setEditData(prev => ({ ...prev, statut: value }))} />
/> <EditableLine
<EditableLine label="Statut"
label="Ouverture de compte" value={editData.statut}
value={editData.ouverture_compte} type="select"
type="select" options={[
options={[ { value: "Actif", label: "Actif" },
{ value: "Simple", label: "Simple" }, { value: "Ancien client", label: "Ancien client" },
{ value: "Complexe", label: "Complexe" }, ]}
]} onChange={(value) => setEditData(prev => ({ ...prev, statut: value }))}
onChange={(value) => setEditData(prev => ({ ...prev, ouverture_compte: value }))} />
/> <EditableLine
<EditableLine label="Ouverture de compte"
label="Offre spéciale" value={editData.ouverture_compte}
value={editData.offre_speciale} type="select"
onChange={(value) => setEditData(prev => ({ ...prev, offre_speciale: value }))} options={[
/> { value: "Simple", label: "Simple" },
<EditableLine { value: "Complexe", label: "Complexe" },
label="Note" ]}
value={editData.notes} onChange={(value) => setEditData(prev => ({ ...prev, ouverture_compte: value }))}
onChange={(value) => setEditData(prev => ({ ...prev, notes: value }))} />
/> <EditableLine
label="Offre spéciale"
value={editData.offre_speciale}
onChange={(value) => setEditData(prev => ({ ...prev, offre_speciale: value }))}
/>
<EditableLine
label="Note"
value={editData.notes}
onChange={(value) => setEditData(prev => ({ ...prev, notes: value }))}
/>
</div>
{/* Section Apporteur d'Affaires */} {/* Section Apporteur d'Affaires */}
<div className="pt-2 border-t"> <div className="pt-2 border-t">
<div className="text-xs font-semibold text-slate-600 uppercase tracking-wide mb-2">Apporteur d'affaires</div>
<EditableLine <EditableLine
label="Client apporté ?" label="Client apporté ?"
value={editData.is_referred ? "true" : "false"} value={editData.is_referred ? "true" : "false"}
@ -729,16 +770,57 @@ export default function ClientDetailPage() {
</> </>
)} )}
</div> </div>
{/* Section Facturation */}
<div className="pt-2 border-t">
<div className="text-xs font-semibold text-slate-600 uppercase tracking-wide mb-2">Facturation</div>
<EditableLine
label="IBAN"
value={editData.iban}
onChange={(value) => setEditData(prev => ({ ...prev, iban: value }))}
/>
<EditableLine
label="BIC"
value={editData.bic}
onChange={(value) => setEditData(prev => ({ ...prev, bic: value }))}
/>
<EditableLine
label="ID mandat SEPA"
value={editData.id_mandat_sepa}
onChange={(value) => setEditData(prev => ({ ...prev, id_mandat_sepa: value }))}
/>
</div>
{/* Section Gestion paie */}
<div className="pt-2 border-t">
<div className="text-xs font-semibold text-slate-600 uppercase tracking-wide mb-2">Gestion paie</div>
<EditableLine
label="Virements salaires"
value={editData.virements_salaires}
type="select"
options={[
{ value: "Odentas", label: "Odentas" },
{ value: "Client", label: "Client (structure)" },
]}
onChange={(value) => setEditData(prev => ({ ...prev, virements_salaires: value }))}
/>
</div>
</div> </div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
<Line label="Statut" value={structureInfos.statut} /> {/* Section Contrat */}
<Line label="Ouverture de compte" value={structureInfos.ouverture_compte} /> <div className="pb-2">
<Line label="Offre spéciale" value={structureInfos.offre_speciale} /> <div className="text-xs font-semibold text-slate-600 uppercase tracking-wide mb-2">Contrat</div>
<Line label="Note" value={structureInfos.notes} /> <Line label="Entrée en relation" value={structureInfos.entree_en_relation} />
<Line label="Statut" value={structureInfos.statut} />
<Line label="Ouverture de compte" value={structureInfos.ouverture_compte} />
<Line label="Offre spéciale" value={structureInfos.offre_speciale} />
<Line label="Note" value={structureInfos.notes} />
</div>
{/* Section Apporteur d'Affaires */} {/* Section Apporteur d'Affaires */}
<div className="pt-2 border-t"> <div className="pt-2 border-t">
<div className="text-xs font-semibold text-slate-600 uppercase tracking-wide mb-2">Apporteur d'affaires</div>
<Line <Line
label="Client apporté ?" label="Client apporté ?"
value={clientData.details.is_referred ? "Oui" : "Non"} value={clientData.details.is_referred ? "Oui" : "Non"}
@ -756,13 +838,65 @@ export default function ClientDetailPage() {
</> </>
)} )}
</div> </div>
{/* Section Facturation */}
<div className="pt-2 border-t">
<div className="text-xs font-semibold text-slate-600 uppercase tracking-wide mb-2">Facturation</div>
{/* Carte Statut Mandat SEPA */}
<div className="mb-3">
{clientData.details.id_mandat_sepa ? (
<div className="flex items-center gap-3 px-4 py-3 bg-gradient-to-r from-emerald-50 to-emerald-100 border border-emerald-200 rounded-xl">
<div className="flex-shrink-0 w-10 h-10 bg-emerald-500 rounded-full flex items-center justify-center">
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<div className="flex-1">
<div className="font-semibold text-emerald-900">Mandat SEPA actif</div>
<div className="text-sm text-emerald-700">Les prélèvements automatiques sont activés</div>
</div>
</div>
) : (
<div className="flex items-center gap-3 px-4 py-3 bg-gradient-to-r from-amber-50 to-amber-100 border border-amber-200 rounded-xl">
<div className="flex-shrink-0 w-10 h-10 bg-amber-500 rounded-full flex items-center justify-center">
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div className="flex-1">
<div className="font-semibold text-amber-900">Aucun mandat SEPA</div>
<div className="text-sm text-amber-700">Paiement par virement uniquement</div>
</div>
<button
onClick={() => setShowSepaMandateModal(true)}
className="flex-shrink-0 inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors"
>
<Send className="w-4 h-4" />
Demande de mandat
</button>
</div>
)}
</div>
<Line label="IBAN" value={clientData.details.iban} />
<Line label="BIC" value={clientData.details.bic} />
<Line label="ID mandat SEPA" value={clientData.details.id_mandat_sepa} />
</div>
{/* Section Gestion paie */}
<div className="pt-2 border-t">
<div className="text-xs font-semibold text-slate-600 uppercase tracking-wide mb-2">Gestion paie</div>
<Line
label="Virements salaires"
value={structureInfos.virements_salaires || "—"}
/>
</div>
</div> </div>
)} )}
</div> </div>
</section> </section>
</div>
<div className="space-y-4">
{/* Informations de contact */} {/* Informations de contact */}
<section className="rounded-2xl border bg-white"> <section className="rounded-2xl border bg-white">
<div className="px-4 py-3 border-b"> <div className="px-4 py-3 border-b">
@ -931,6 +1065,79 @@ export default function ClientDetailPage() {
</section> </section>
</div> </div>
</div> </div>
{/* Modale de confirmation pour la demande de mandat SEPA */}
{showSepaMandateModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-2xl max-w-md w-full">
<div className="px-6 py-4 border-b">
<h3 className="text-lg font-semibold text-slate-900 flex items-center gap-2">
<Send className="w-5 h-5 text-blue-600" />
Demande de mandat SEPA
</h3>
</div>
<div className="p-6 space-y-4">
<p className="text-slate-700">
Êtes-vous sûr de vouloir envoyer une demande de signature de mandat SEPA au client ?
</p>
<div className="bg-slate-50 rounded-lg p-4 space-y-2">
<div className="font-medium text-slate-900">
Client : {organization.name}
</div>
{structureInfos.email && (
<div className="text-sm text-slate-600">
Email : {structureInfos.email}
</div>
)}
{structureInfos.email_cc && (
<div className="text-sm text-slate-600">
Email CC : {structureInfos.email_cc}
</div>
)}
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="text-blue-800 text-sm font-medium mb-2">
Cette action va :
</div>
<ul className="text-blue-700 text-sm space-y-1 list-disc list-inside">
<li>Envoyer un email au client</li>
<li>Inclure un lien vers GoCardless pour la signature</li>
</ul>
</div>
</div>
<div className="px-6 py-4 border-t flex gap-3 justify-end">
<button
onClick={() => setShowSepaMandateModal(false)}
disabled={sendSepaMandateMutation.isPending}
className="px-4 py-2 text-sm border rounded-lg hover:bg-slate-50 transition-colors"
>
Annuler
</button>
<button
onClick={() => sendSepaMandateMutation.mutate()}
disabled={sendSepaMandateMutation.isPending}
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center gap-2 disabled:opacity-50"
>
{sendSepaMandateMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Envoi en cours...
</>
) : (
<>
<Send className="w-4 h-4" />
Envoyer la demande
</>
)}
</button>
</div>
</div>
</div>
)}
</main> </main>
); );
} }

View file

@ -37,8 +37,8 @@ export default async function StaffContractsPage() {
const { data: contracts, error } = await sb const { data: contracts, error } = await sb
.from("cddu_contracts") .from("cddu_contracts")
.select( .select(
`id, contract_number, employee_name, employee_id, structure, type_de_contrat, profession, production_name, start_date, end_date, created_at, etat_de_la_demande, etat_de_la_paie, dpae, gross_pay, org_id, contrat_signe_par_employeur, contrat_signe, last_employer_notification_at, last_employee_notification_at, `id, contract_number, employee_name, employee_id, structure, type_de_contrat, profession, production_name, start_date, end_date, created_at, etat_de_la_demande, etat_de_la_paie, dpae, gross_pay, org_id, contrat_signe_par_employeur, contrat_signe, last_employer_notification_at, last_employee_notification_at, analytique, nombre_d_heures, n_objet, objet_spectacle,
salaries!employee_id(salarie, nom, prenom, adresse_mail), salaries!employee_id(salarie, nom, prenom, adresse_mail, code_salarie),
organizations!org_id(organization_details(code_employeur))` organizations!org_id(organization_details(code_employeur))`
) )
.order("start_date", { ascending: false }) .order("start_date", { ascending: false })

View file

@ -158,6 +158,13 @@ export default function CreateInvoicePage() {
Créer une nouvelle facture Créer une nouvelle facture
</h1> </h1>
</div> </div>
<Link
href="/staff/facturation/create/saisie-tableau"
className="inline-flex items-center gap-2 text-sm px-3 py-2 border border-blue-600 text-blue-600 rounded-lg hover:bg-blue-50 transition-colors"
>
<Plus className="w-4 h-4" />
Saisie tableau
</Link>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-5"> <form onSubmit={handleSubmit} className="space-y-5">

File diff suppressed because it is too large Load diff

View file

@ -72,6 +72,16 @@ function useStaffBilling(page: number, limit: number) {
}); });
} }
// Hook pour récupérer les clients sans facture pour une période donnée
function useClientsWithoutInvoice(periode: string | null) {
return useQuery<{ clients: Array<{ id: string; name: string; structure_api: string | null }>; count: number; periode: string }>({
queryKey: ["clients-sans-facture", periode],
queryFn: () => api(`/staff/facturation/clients-sans-facture?periode=${periode}`),
enabled: !!periode, // Ne s'exécute que si une période est définie
staleTime: 15_000,
});
}
// -------------- Page -------------- // -------------- Page --------------
export default function StaffFacturationPage() { export default function StaffFacturationPage() {
usePageTitle("Facturation (Staff)"); usePageTitle("Facturation (Staff)");
@ -89,14 +99,22 @@ export default function StaffFacturationPage() {
const [showSepaModal, setShowSepaModal] = useState(false); const [showSepaModal, setShowSepaModal] = useState(false);
const [showInvoiceDateModal, setShowInvoiceDateModal] = useState(false); const [showInvoiceDateModal, setShowInvoiceDateModal] = useState(false);
const [showDueDateModal, setShowDueDateModal] = useState(false); const [showDueDateModal, setShowDueDateModal] = useState(false);
const [showPaymentDateModal, setShowPaymentDateModal] = useState(false);
const [showBulkGoCardlessModal, setShowBulkGoCardlessModal] = useState(false); const [showBulkGoCardlessModal, setShowBulkGoCardlessModal] = useState(false);
const [showStatusModal, setShowStatusModal] = useState(false);
const [showDateMenu, setShowDateMenu] = useState(false);
const [newSepaDate, setNewSepaDate] = useState(""); const [newSepaDate, setNewSepaDate] = useState("");
const [newInvoiceDate, setNewInvoiceDate] = useState(""); const [newInvoiceDate, setNewInvoiceDate] = useState("");
const [newDueDate, setNewDueDate] = useState(""); const [newDueDate, setNewDueDate] = useState("");
const [newPaymentDate, setNewPaymentDate] = useState("");
const [newStatus, setNewStatus] = useState<string>("");
const limit = 25; const limit = 25;
const { data, isLoading, isError, error } = useStaffBilling(page, limit); const { data, isLoading, isError, error } = useStaffBilling(page, limit);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// Hook pour récupérer les clients sans facture pour la période sélectionnée
const { data: clientsWithoutInvoiceData, isLoading: isLoadingClientsWithoutInvoice } = useClientsWithoutInvoice(periodFilter || null);
const items = data?.factures.items ?? []; const items = data?.factures.items ?? [];
const hasMore = data?.factures.hasMore ?? false; const hasMore = data?.factures.hasMore ?? false;
@ -225,6 +243,54 @@ export default function StaffFacturationPage() {
}, },
}); });
// Mutation pour mise à jour en masse du statut
const updateStatusMutation = useMutation({
mutationFn: async ({ invoiceIds, status }: { invoiceIds: string[], status: string }) => {
return api('/staff/facturation/bulk-update-status', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ invoiceIds, status }),
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["staff-billing"] });
setShowStatusModal(false);
setNewStatus("");
clearSelection();
alert("Statuts mis à jour avec succès !");
},
onError: (error: any) => {
console.error("Erreur lors de la mise à jour:", error);
alert(`Erreur lors de la mise à jour : ${error.message || "erreur inconnue"}`);
},
});
// Mutation pour mise à jour en masse de la date de paiement
const updatePaymentDateMutation = useMutation({
mutationFn: async ({ invoiceIds, paymentDate }: { invoiceIds: string[], paymentDate: string }) => {
return api('/staff/facturation/bulk-update-payment-date', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ invoiceIds, paymentDate }),
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["staff-billing"] });
setShowPaymentDateModal(false);
setNewPaymentDate("");
clearSelection();
alert("Dates de paiement mises à jour avec succès !");
},
onError: (error: any) => {
console.error("Erreur lors de la mise à jour:", error);
alert(`Erreur lors de la mise à jour : ${error.message || "erreur inconnue"}`);
},
});
const handleBulkUpdateSepa = () => { const handleBulkUpdateSepa = () => {
if (selectedInvoices.size === 0) { if (selectedInvoices.size === 0) {
alert("Veuillez sélectionner au moins une facture."); alert("Veuillez sélectionner au moins une facture.");
@ -298,6 +364,38 @@ export default function StaffFacturationPage() {
setShowBulkGoCardlessModal(false); setShowBulkGoCardlessModal(false);
}; };
const handleBulkUpdateStatus = () => {
if (selectedInvoices.size === 0) {
alert("Veuillez sélectionner au moins une facture.");
return;
}
if (!newStatus) {
alert("Veuillez sélectionner un statut.");
return;
}
updateStatusMutation.mutate({
invoiceIds: Array.from(selectedInvoices),
status: newStatus
});
};
const handleBulkUpdatePaymentDate = () => {
if (selectedInvoices.size === 0) {
alert("Veuillez sélectionner au moins une facture.");
return;
}
if (!newPaymentDate) {
alert("Veuillez sélectionner une date.");
return;
}
updatePaymentDateMutation.mutate({
invoiceIds: Array.from(selectedInvoices),
paymentDate: newPaymentDate
});
};
// Filtrer et trier les éléments côté client // Filtrer et trier les éléments côté client
const filteredAndSortedItems = useMemo(() => { const filteredAndSortedItems = useMemo(() => {
// D'abord filtrer // D'abord filtrer
@ -434,13 +532,22 @@ export default function StaffFacturationPage() {
<h1 className="text-2xl font-bold text-slate-900">Facturation</h1> <h1 className="text-2xl font-bold text-slate-900">Facturation</h1>
<p className="text-slate-600">Gestion des factures de tous les clients</p> <p className="text-slate-600">Gestion des factures de tous les clients</p>
</div> </div>
<Link <div className="flex items-center gap-2">
href="/staff/facturation/create" <Link
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors" href="/staff/facturation/create/saisie-tableau"
> className="inline-flex items-center gap-2 px-4 py-2 border border-blue-600 text-blue-600 rounded-lg hover:bg-blue-50 transition-colors"
<Plus className="w-4 h-4" /> >
Créer une facture <Plus className="w-4 h-4" />
</Link> Saisie tableau
</Link>
<Link
href="/staff/facturation/create"
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" />
Créer une facture
</Link>
</div>
</div> </div>
{/* Filtres */} {/* Filtres */}
@ -622,6 +729,64 @@ export default function StaffFacturationPage() {
</div> </div>
)} )}
{/* Card clients sans facture pour la période sélectionnée */}
{periodFilter && (
<div className="bg-white rounded-xl border">
<div className="px-4 py-3 border-b flex items-center justify-between">
<h3 className="font-medium text-slate-700">
Clients actifs sans facture - {periodFilter}
</h3>
{isLoadingClientsWithoutInvoice && (
<Loader2 className="w-4 h-4 animate-spin text-slate-400" />
)}
</div>
<div className="p-4">
{isLoadingClientsWithoutInvoice ? (
<div className="text-sm text-slate-500 flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
Chargement...
</div>
) : clientsWithoutInvoiceData && clientsWithoutInvoiceData.count > 0 ? (
<>
<div className="mb-3">
<div className="text-2xl font-bold text-orange-600">
{clientsWithoutInvoiceData.count}
</div>
<div className="text-sm text-slate-600">
Client{clientsWithoutInvoiceData.count > 1 ? 's' : ''} actif{clientsWithoutInvoiceData.count > 1 ? 's' : ''} sans facture
</div>
</div>
<div className="space-y-2 max-h-64 overflow-y-auto">
{clientsWithoutInvoiceData.clients.map((client) => (
<div
key={client.id}
className="flex items-center justify-between p-2 bg-slate-50 rounded-lg hover:bg-slate-100 transition-colors"
>
<div>
<div className="font-medium text-slate-900">{client.name}</div>
{client.structure_api && (
<div className="text-xs text-slate-500">{client.structure_api}</div>
)}
</div>
<Link
href={`/staff/clients/${client.id}`}
className="text-xs text-blue-600 hover:text-blue-800 underline"
>
Voir
</Link>
</div>
))}
</div>
</>
) : (
<div className="text-sm text-slate-500">
Tous les clients actifs ont une facture pour cette période.
</div>
)}
</div>
</div>
)}
{/* Liste des factures */} {/* Liste des factures */}
<Section title="Toutes les factures"> <Section title="Toutes les factures">
<div className="text-xs text-slate-500 mb-3 flex items-center gap-4"> <div className="text-xs text-slate-500 mb-3 flex items-center gap-4">
@ -650,26 +815,79 @@ export default function StaffFacturationPage() {
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={() => setShowInvoiceDateModal(true)} onClick={() => setShowStatusModal(true)}
className="inline-flex items-center gap-2 px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm" className="inline-flex items-center gap-2 px-3 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm"
> >
<Calendar className="w-4 h-4" /> <Edit className="w-4 h-4" />
Modifier date facture Modifier statut
</button>
<button
onClick={() => setShowDueDateModal(true)}
className="inline-flex items-center gap-2 px-3 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition-colors text-sm"
>
<Calendar className="w-4 h-4" />
Modifier date échéance
</button>
<button
onClick={() => setShowSepaModal(true)}
className="inline-flex items-center gap-2 px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm"
>
<Calendar className="w-4 h-4" />
Modifier date SEPA
</button> </button>
{/* Menu déroulant pour les dates */}
<div className="relative">
<button
onClick={() => setShowDateMenu(!showDateMenu)}
className="inline-flex items-center gap-2 px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm"
>
<Calendar className="w-4 h-4" />
Modifier dates
<ChevronDown className="w-3 h-3" />
</button>
{showDateMenu && (
<>
{/* Overlay pour fermer le menu */}
<div
className="fixed inset-0 z-10"
onClick={() => setShowDateMenu(false)}
/>
{/* Menu */}
<div className="absolute right-0 mt-2 w-56 bg-white rounded-lg shadow-lg border border-slate-200 py-1 z-20">
<button
onClick={() => {
setShowInvoiceDateModal(true);
setShowDateMenu(false);
}}
className="w-full text-left px-4 py-2 text-sm text-slate-700 hover:bg-slate-50 flex items-center gap-2"
>
<Calendar className="w-4 h-4 text-green-600" />
Date de facture
</button>
<button
onClick={() => {
setShowDueDateModal(true);
setShowDateMenu(false);
}}
className="w-full text-left px-4 py-2 text-sm text-slate-700 hover:bg-slate-50 flex items-center gap-2"
>
<Calendar className="w-4 h-4 text-orange-600" />
Date d'échéance
</button>
<button
onClick={() => {
setShowPaymentDateModal(true);
setShowDateMenu(false);
}}
className="w-full text-left px-4 py-2 text-sm text-slate-700 hover:bg-slate-50 flex items-center gap-2"
>
<Calendar className="w-4 h-4 text-emerald-600" />
Date de paiement
</button>
<button
onClick={() => {
setShowSepaModal(true);
setShowDateMenu(false);
}}
className="w-full text-left px-4 py-2 text-sm text-slate-700 hover:bg-slate-50 flex items-center gap-2"
>
<Calendar className="w-4 h-4 text-blue-600" />
Date SEPA
</button>
</div>
</>
)}
</div>
<button <button
onClick={handleBulkGoCardless} onClick={handleBulkGoCardless}
disabled={bulkGoCardlessMutation.isPending} disabled={bulkGoCardlessMutation.isPending}
@ -680,10 +898,10 @@ export default function StaffFacturationPage() {
</button> </button>
<button <button
onClick={clearSelection} onClick={clearSelection}
className="inline-flex items-center gap-2 px-3 py-2 bg-gray-500 text-white rounded-lg hover:bg-gray-600 transition-colors text-sm" className="inline-flex items-center gap-2 px-3 py-2 bg-slate-200 text-slate-700 rounded-lg hover:bg-slate-300 transition-colors text-sm"
title="Désélectionner tout"
> >
<X className="w-4 h-4" /> <X className="w-4 h-4" />
Annuler
</button> </button>
</div> </div>
</div> </div>
@ -1009,6 +1227,52 @@ export default function StaffFacturationPage() {
</div> </div>
)} )}
{/* Modal pour modification en masse de la date de paiement */}
{showPaymentDateModal && (
<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 mx-4">
<h3 className="text-lg font-semibold mb-4">Modifier la date de paiement</h3>
<p className="text-sm text-slate-600 mb-4">
Cette action va modifier la date de paiement pour {selectedInvoices.size} facture{selectedInvoices.size > 1 ? "s" : ""} sélectionnée{selectedInvoices.size > 1 ? "s" : ""}.
</p>
<div className="mb-6">
<label className="block text-sm font-medium text-slate-700 mb-2">
Nouvelle date de paiement
</label>
<input
type="date"
value={newPaymentDate}
onChange={(e) => setNewPaymentDate(e.target.value)}
className="w-full px-3 py-2 border rounded-lg bg-white"
/>
</div>
<div className="flex justify-end gap-3">
<button
onClick={() => {
setShowPaymentDateModal(false);
setNewPaymentDate("");
}}
className="px-4 py-2 text-slate-600 hover:text-slate-800"
disabled={updatePaymentDateMutation.isPending}
>
Annuler
</button>
<button
onClick={handleBulkUpdatePaymentDate}
disabled={updatePaymentDateMutation.isPending || !newPaymentDate}
className="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{updatePaymentDateMutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
Confirmer
</button>
</div>
</div>
</div>
)}
{/* Modal de confirmation GoCardless bulk */} {/* Modal de confirmation GoCardless bulk */}
<Dialog open={showBulkGoCardlessModal} onOpenChange={setShowBulkGoCardlessModal}> <Dialog open={showBulkGoCardlessModal} onOpenChange={setShowBulkGoCardlessModal}>
<DialogContent className="max-w-md"> <DialogContent className="max-w-md">
@ -1080,6 +1344,59 @@ export default function StaffFacturationPage() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Modal pour modification en masse du statut */}
{showStatusModal && (
<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 mx-4">
<h3 className="text-lg font-semibold mb-4">Modifier le statut</h3>
<p className="text-sm text-slate-600 mb-4">
Cette action va modifier le statut pour {selectedInvoices.size} facture{selectedInvoices.size > 1 ? "s" : ""} sélectionnée{selectedInvoices.size > 1 ? "s" : ""}.
</p>
<div className="mb-6">
<label className="block text-sm font-medium text-slate-700 mb-2">
Nouveau statut
</label>
<select
value={newStatus}
onChange={(e) => setNewStatus(e.target.value)}
className="w-full px-3 py-2 border rounded-lg bg-white"
>
<option value="">-- Sélectionner un statut --</option>
<option value="brouillon">Brouillon</option>
<option value="en_cours">En cours</option>
<option value="prete">Prête</option>
<option value="emise">Émise</option>
<option value="payee">Payée</option>
<option value="annulee">Annulée</option>
</select>
</div>
<div className="flex justify-end gap-3">
<button
onClick={() => {
setShowStatusModal(false);
setNewStatus("");
}}
className="px-4 py-2 text-slate-600 hover:text-slate-800"
disabled={updateStatusMutation.isPending}
>
Annuler
</button>
<button
onClick={handleBulkUpdateStatus}
disabled={updateStatusMutation.isPending || !newStatus}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{updateStatusMutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
Confirmer
</button>
</div>
</div>
</div>
)}
</main> </main>
); );
} }

View file

@ -41,7 +41,8 @@ export default async function StaffPayslipsPage() {
processed, aem_status, transfer_done, organization_id, created_at, processed, aem_status, transfer_done, organization_id, created_at,
cddu_contracts!contract_id( cddu_contracts!contract_id(
id, contract_number, employee_name, employee_id, structure, type_de_contrat, org_id, id, contract_number, employee_name, employee_id, structure, type_de_contrat, org_id,
salaries!employee_id(salarie, nom, prenom) salaries!employee_id(salarie, nom, prenom),
organizations!org_id(organization_details(code_employeur))
)` )`
) )
.order("period_start", { ascending: false }) .order("period_start", { ascending: false })

View file

@ -373,6 +373,8 @@ export default function VirementsPage() {
const [selectedOrgId, setSelectedOrgId] = useState<string>(""); const [selectedOrgId, setSelectedOrgId] = useState<string>("");
const [pdfModalOpen, setPdfModalOpen] = useState(false); const [pdfModalOpen, setPdfModalOpen] = useState(false);
const [pdfUrl, setPdfUrl] = useState<string>(""); const [pdfUrl, setPdfUrl] = useState<string>("");
const [undoModalOpen, setUndoModalOpen] = useState(false);
const [undoPayslipId, setUndoPayslipId] = useState<string | null>(null);
const { data: userInfo, isLoading: isLoadingUser } = useUserInfo(); const { data: userInfo, isLoading: isLoadingUser } = useUserInfo();
const { data: organizations, isLoading: isLoadingOrgs, error: orgsError } = useOrganizations(); const { data: organizations, isLoading: isLoadingOrgs, error: orgsError } = useOrganizations();
@ -502,6 +504,13 @@ export default function VirementsPage() {
const clientUnpaid = clientFilter(clientUnpaidAll); const clientUnpaid = clientFilter(clientUnpaidAll);
const clientRecent = clientFilter(clientRecentAll); const clientRecent = clientFilter(clientRecentAll);
// Calcul du total des nets à payer pour les salaires non payés
const totalNetAPayer = useMemo(() => {
return clientUnpaid.reduce((sum, item) => {
return sum + (item.net_a_payer ?? 0);
}, 0);
}, [clientUnpaid]);
// Mutation: marquer un payslip comme viré // Mutation: marquer un payslip comme viré
async function markPayslipDone(payslipId: string) { async function markPayslipDone(payslipId: string) {
try { try {
@ -521,6 +530,31 @@ export default function VirementsPage() {
} }
} }
// Mutation: marquer un payslip comme NON viré (annuler)
async function markPayslipUndone(payslipId: string) {
try {
await fetch(`/api/payslips/${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"] });
setUndoModalOpen(false);
setUndoPayslipId(null);
} catch (e) {
console.error('Erreur annulation marquage payslip:', e);
alert('Erreur lors de l\'annulation du marquage du virement.');
}
}
// Ouvrir la modale de confirmation pour annuler un marquage
function openUndoModal(payslipId: string) {
setUndoPayslipId(payslipId);
setUndoModalOpen(true);
}
// Filtrage local pour la recherche ET la période // Filtrage local pour la recherche ET la période
const filteredItems = useMemo((): VirementItem[] => { const filteredItems = useMemo((): VirementItem[] => {
let result: VirementItem[] = items; let result: VirementItem[] = items;
@ -839,6 +873,26 @@ export default function VirementsPage() {
<Th className="text-right">Net à payer</Th> <Th className="text-right">Net à payer</Th>
<Th className="text-center">Marquer comme payé</Th> <Th className="text-center">Marquer comme payé</Th>
</tr> </tr>
{/* 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">
<div className="flex items-center gap-2">
<div className="text-sm font-semibold text-indigo-900">
Salaires à payer
</div>
<div className="text-xs text-indigo-700">
({clientUnpaid.length} salarié{clientUnpaid.length > 1 ? 's' : ''})
</div>
</div>
</th>
<th colSpan={2} className="px-4 py-3 text-right">
<div className="text-sm font-bold text-indigo-900">
Total : {formatCurrency(totalNetAPayer)}
</div>
</th>
</tr>
)}
</thead> </thead>
<tbody> <tbody>
{/* Unpaid first */} {/* Unpaid first */}
@ -897,9 +951,28 @@ export default function VirementsPage() {
</tr> </tr>
))} ))}
{clientRecent.length > 0 && ( {clientRecent.length > 0 && (
<tr className="bg-slate-50/50"> <>
<td colSpan={8} className="px-3 py-2 text-xs text-slate-500">Récemment virés ( 30 jours)</td> {/* Séparation visuelle renforcée */}
</tr> <tr>
<td colSpan={8} className="h-4 bg-slate-100"></td>
</tr>
<tr className="bg-gradient-to-r from-emerald-50 to-green-50 border-y-2 border-emerald-200">
<td colSpan={8} className="px-4 py-3">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-emerald-100">
<Check className="w-5 h-5 text-emerald-700" />
</div>
<div>
<div className="font-semibold text-emerald-900">Virements récemment effectués</div>
<div className="text-xs text-emerald-700">Paies virées au cours des 30 derniers jours</div>
<div className="text-xs text-emerald-600 italic mt-1">
Si vous avez noté une paie comme payée par erreur, cliquez sur "Oui" pour la noter comme non-payée.
</div>
</div>
</div>
</td>
</tr>
</>
)} )}
{clientRecent.map((it) => ( {clientRecent.map((it) => (
<tr key={`recent-${it.source}-${it.id}`} className="border-b last:border-b-0 hover:bg-slate-50/50"> <tr key={`recent-${it.source}-${it.id}`} className="border-b last:border-b-0 hover:bg-slate-50/50">
@ -932,7 +1005,18 @@ export default function VirementsPage() {
<Td>{formatPeriode(it.periode)}</Td> <Td>{formatPeriode(it.periode)}</Td>
<Td className="text-right font-medium">{it.net_a_payer != null ? formatCurrency(it.net_a_payer) : '—'}</Td> <Td className="text-right font-medium">{it.net_a_payer != null ? formatCurrency(it.net_a_payer) : '—'}</Td>
<Td className="text-center"> <Td className="text-center">
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs bg-emerald-100 text-emerald-800">Oui</span> {it.source === 'payslip' ? (
<button
type="button"
onClick={() => openUndoModal(it.id)}
className="inline-flex items-center px-2 py-1 rounded-full text-xs bg-emerald-100 text-emerald-800 hover:bg-emerald-200 transition-colors cursor-pointer"
title="Cliquez pour annuler le marquage de ce virement"
>
Oui
</button>
) : (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs bg-emerald-100 text-emerald-800">Oui</span>
)}
</Td> </Td>
</tr> </tr>
))} ))}
@ -1072,6 +1156,51 @@ export default function VirementsPage() {
</div> </div>
</div> </div>
)} )}
{/* Modal de confirmation pour annuler le marquage */}
{undoModalOpen && undoPayslipId && (
<div className="fixed inset-0 z-[1000]">
<div
className="absolute inset-0 bg-black/40"
onClick={() => {
setUndoModalOpen(false);
setUndoPayslipId(null);
}}
/>
<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">
<h2 className="text-base font-semibold">Annuler le marquage du virement</h2>
</div>
<div className="p-5 space-y-4 text-sm">
<p>
Êtes-vous sûr de vouloir marquer cette paie comme <strong>non payée</strong> ?
</p>
<p className="text-slate-600">
Elle réapparaîtra dans la liste des paies à payer.
</p>
</div>
<div className="p-4 border-t flex justify-end gap-2">
<button
onClick={() => {
setUndoModalOpen(false);
setUndoPayslipId(null);
}}
className="px-3 py-2 rounded-md border hover:bg-slate-50 text-sm"
>
Annuler
</button>
<button
onClick={() => markPayslipUndone(undoPayslipId)}
className="px-3 py-2 rounded-md bg-orange-600 text-white hover:bg-orange-700 text-sm"
>
Confirmer
</button>
</div>
</div>
</div>
</div>
)}
</div> </div>
); );
} }

View file

@ -172,6 +172,10 @@ export async function GET(req: NextRequest, { params }: { params: { id: string }
dpae: cddu.dpae, dpae: cddu.dpae,
aem: cddu.aem, aem: cddu.aem,
jours_travailles: cddu.jours_travail_non_artiste ? Number(cddu.jours_travail_non_artiste) : undefined, jours_travailles: cddu.jours_travail_non_artiste ? Number(cddu.jours_travail_non_artiste) : undefined,
jours_travail: cddu.jours_travail || undefined,
jours_travail_non_artiste: cddu.jours_travail_non_artiste || undefined,
dates_representations: cddu.jours_representations || undefined,
dates_repetitions: cddu.jours_repetitions || undefined,
nb_representations: cddu.cachets_representations ? Number(cddu.cachets_representations) : undefined, nb_representations: cddu.cachets_representations ? Number(cddu.cachets_representations) : undefined,
nb_services_repetitions: cddu.services_repetitions ? Number(cddu.services_repetitions) : undefined, nb_services_repetitions: cddu.services_repetitions ? Number(cddu.services_repetitions) : undefined,
nb_heures_repetitions: cddu.heures_de_repet ? Number(cddu.heures_de_repet) : undefined, nb_heures_repetitions: cddu.heures_de_repet ? Number(cddu.heures_de_repet) : undefined,
@ -330,6 +334,25 @@ export async function PATCH(req: NextRequest, { params }: { params: { id: string
if (requestBody.nb_services_repetition !== undefined) { if (requestBody.nb_services_repetition !== undefined) {
supabaseData.services_repetitions = requestBody.nb_services_repetition; supabaseData.services_repetitions = requestBody.nb_services_repetition;
} }
if (requestBody.dates_representations !== undefined) {
supabaseData.jours_representations = requestBody.dates_representations;
}
if (requestBody.dates_repetitions !== undefined) {
supabaseData.jours_repetitions = requestBody.dates_repetitions;
}
// Convertir heures + minutes en nombre décimal pour nombre_d_heures
if (requestBody.heures_travail !== undefined || requestBody.minutes_travail !== undefined) {
const heures = requestBody.heures_travail || 0;
const minutes = requestBody.minutes_travail || 0;
const minutesDecimal = minutes === "30" || minutes === 30 ? 0.5 : 0;
supabaseData.nombre_d_heures = String(Number(heures) + minutesDecimal);
}
if (requestBody.jours_travail !== undefined) {
supabaseData.jours_travail = requestBody.jours_travail;
}
if (requestBody.jours_travail_non_artiste !== undefined) {
supabaseData.jours_travail_non_artiste = requestBody.jours_travail_non_artiste;
}
if (requestBody.type_salaire !== undefined) { if (requestBody.type_salaire !== undefined) {
supabaseData.type_salaire = requestBody.type_salaire; supabaseData.type_salaire = requestBody.type_salaire;
} }
@ -344,6 +367,9 @@ export async function PATCH(req: NextRequest, { params }: { params: { id: string
supabaseData.contract_number = requestBody.reference; supabaseData.contract_number = requestBody.reference;
supabaseData.reference = requestBody.reference; supabaseData.reference = requestBody.reference;
} }
if (requestBody.notes !== undefined) {
supabaseData.notes = requestBody.notes;
}
if (requestBody.multi_mois !== undefined) { if (requestBody.multi_mois !== undefined) {
supabaseData.multi_mois = requestBody.multi_mois ? "Oui" : "Non"; supabaseData.multi_mois = requestBody.multi_mois ? "Oui" : "Non";
} }
@ -367,8 +393,14 @@ export async function PATCH(req: NextRequest, { params }: { params: { id: string
throw new Error(`Supabase update error: ${updateResult.error.message}`); throw new Error(`Supabase update error: ${updateResult.error.message}`);
} }
// Envoyer les notifications email après la mise à jour réussie // Envoyer les notifications email après la mise à jour réussie (sauf si explicitement désactivé)
const shouldSendEmail = requestBody.send_email_confirmation !== false;
try { try {
if (!shouldSendEmail) {
console.log("📧 Email notifications disabled by user (send_email_confirmation=false):", { contractId, requestId });
}
// Récupérer les données du contrat mis à jour pour les emails // Récupérer les données du contrat mis à jour pour les emails
let contractData; let contractData;
if (org.isStaff) { if (org.isStaff) {
@ -380,7 +412,7 @@ export async function PATCH(req: NextRequest, { params }: { params: { id: string
contractData = data; contractData = data;
} }
if (contractData) { if (contractData && shouldSendEmail) {
// Récupérer les données d'organisation avec tous les détails // Récupérer les données d'organisation avec tous les détails
let organizationData; let organizationData;
if (org.isStaff) { if (org.isStaff) {
@ -515,7 +547,8 @@ export async function PATCH(req: NextRequest, { params }: { params: { id: string
// Ajouter d'autres champs selon les besoins // Ajouter d'autres champs selon les besoins
const fieldsToSync = [ const fieldsToSync = [
'cachets_representations', 'services_repetitions', 'jours_representations', 'cachets_representations', 'services_repetitions', 'jours_representations',
'jours_repetitions', 'nombre_d_heures', 'minutes_total', 'jours_travail', 'jours_repetitions', 'nombre_d_heures', 'jours_travail',
'jours_travail_non_artiste', 'notes',
'dates_travaillees', 'type_salaire', 'montant', 'panier_repas' 'dates_travaillees', 'type_salaire', 'montant', 'panier_repas'
]; ];
@ -525,6 +558,23 @@ export async function PATCH(req: NextRequest, { params }: { params: { id: string
} }
}); });
// Mapper dates_representations → jours_representations
if (requestBody.dates_representations !== undefined) {
supabaseData.jours_representations = requestBody.dates_representations;
}
// Mapper dates_repetitions → jours_repetitions
if (requestBody.dates_repetitions !== undefined) {
supabaseData.jours_repetitions = requestBody.dates_repetitions;
}
// Convertir heures + minutes en nombre décimal pour nombre_d_heures (upstream)
if (requestBody.heures_travail !== undefined || requestBody.minutes_travail !== undefined) {
const heures = requestBody.heures_travail || 0;
const minutes = requestBody.minutes_travail || 0;
const minutesDecimal = minutes === "30" || minutes === 30 ? 0.5 : 0;
supabaseData.nombre_d_heures = String(Number(heures) + minutesDecimal);
}
console.log("🔄 SUPABASE DATA TO UPDATE (upstream):", { supabaseData, contractId, requestId }); console.log("🔄 SUPABASE DATA TO UPDATE (upstream):", { supabaseData, contractId, requestId });
// Ne faire l'update que s'il y a des données à sauvegarder // Ne faire l'update que s'il y a des données à sauvegarder
@ -540,8 +590,14 @@ export async function PATCH(req: NextRequest, { params }: { params: { id: string
} }
console.log("✅ SUPABASE UPDATE RESULT (upstream):", { updateResult, contractId, syncedFields: Object.keys(supabaseData), requestId }); console.log("✅ SUPABASE UPDATE RESULT (upstream):", { updateResult, contractId, syncedFields: Object.keys(supabaseData), requestId });
// Envoyer les notifications email après la mise à jour réussie // Envoyer les notifications email après la mise à jour réussie (sauf si explicitement désactivé)
const shouldSendEmail = requestBody.send_email_confirmation !== false;
try { try {
if (!shouldSendEmail) {
console.log("📧 Email notifications disabled by user (send_email_confirmation=false):", { contractId, requestId });
}
// Récupérer les données du contrat mis à jour pour les emails // Récupérer les données du contrat mis à jour pour les emails
let contractData; let contractData;
if (org.isStaff) { if (org.isStaff) {
@ -553,7 +609,7 @@ export async function PATCH(req: NextRequest, { params }: { params: { id: string
contractData = data; contractData = data;
} }
if (contractData) { if (contractData && shouldSendEmail) {
// Récupérer les données d'organisation avec tous les détails // Récupérer les données d'organisation avec tous les détails
let organizationData; let organizationData;
if (org.isStaff) { if (org.isStaff) {

View file

@ -150,15 +150,15 @@ export async function GET(req: Request) {
} }
// Staff should use admin client to bypass RLS when filtering by a specific org // Staff should use admin client to bypass RLS when filtering by a specific org
if (isStaff && admin) { if (isStaff && admin) {
query = admin.from("cddu_contracts").select("*, organizations!inner(name)").eq("org_id", requestedOrg); query = admin.from("cddu_contracts").select("*, organizations!inner(name), payslips(id, processed)").eq("org_id", requestedOrg);
} else { } else {
query = sb.from("cddu_contracts").select("*, organizations!inner(name)").eq("org_id", requestedOrg); query = sb.from("cddu_contracts").select("*, organizations!inner(name), payslips(id, processed)").eq("org_id", requestedOrg);
} }
} else if (orgId) { } else if (orgId) {
if (isStaff && admin) { if (isStaff && admin) {
query = admin.from("cddu_contracts").select("*, organizations!inner(name)").eq("org_id", orgId); query = admin.from("cddu_contracts").select("*, organizations!inner(name), payslips(id, processed)").eq("org_id", orgId);
} else { } else {
query = sb.from("cddu_contracts").select("*, organizations!inner(name)").eq("org_id", orgId); query = sb.from("cddu_contracts").select("*, organizations!inner(name), payslips(id, processed)").eq("org_id", orgId);
} }
} else { } else {
// orgId === null and no requestedOrg -> staff global read required // orgId === null and no requestedOrg -> staff global read required
@ -169,7 +169,7 @@ export async function GET(req: Request) {
console.error('Service role key not configured; cannot perform staff global read'); console.error('Service role key not configured; cannot perform staff global read');
return NextResponse.json({ items: [], page, limit, hasMore: false }); return NextResponse.json({ items: [], page, limit, hasMore: false });
} }
query = admin.from("cddu_contracts").select("*, organizations!inner(name)"); query = admin.from("cddu_contracts").select("*, organizations!inner(name), payslips(id, processed)");
} }
// We'll fetch rows then filter in JS to avoid brittle SQL patterns (accents/variants) // We'll fetch rows then filter in JS to avoid brittle SQL patterns (accents/variants)
if (q) { if (q) {
@ -285,6 +285,33 @@ export async function GET(req: Request) {
const isMulti = row.multi_mois === "Oui" || row.multi_mois === true; const isMulti = row.multi_mois === "Oui" || row.multi_mois === true;
const td = String(row.type_d_embauche || "").toLowerCase(); const td = String(row.type_d_embauche || "").toLowerCase();
const isRG = td.includes("régime général") || td.includes("regime general") || td === "rg"; const isRG = td.includes("régime général") || td.includes("regime general") || td === "rg";
// Déterminer l'état à afficher
let displayEtat = (row.etat_de_la_demande || row.etat || "en_cours");
// Pour les contrats terminés (status === "termines")
if (status === "termines") {
if (isMulti) {
// CDDU multi-mois terminé : afficher "Terminé"
displayEtat = "traitee"; // ou créer un nouvel état "termine" si besoin
} else {
// CDDU mono-mois terminé : afficher l'état de traitement de la payslip
const payslips = row.payslips || [];
if (payslips.length > 0) {
// Prendre la première payslip (mono-mois = une seule paie)
const payslip = payslips[0];
if (payslip.processed === true) {
displayEtat = "traitee";
} else {
displayEtat = "en_cours";
}
} else {
// Pas de payslip créée
displayEtat = "en_cours";
}
}
}
return { return {
id: row.id, id: row.id,
reference: row.contract_number, reference: row.contract_number,
@ -296,7 +323,7 @@ export async function GET(req: Request) {
profession: row.profession || row.role || "", profession: row.profession || row.role || "",
date_debut: row.start_date, date_debut: row.start_date,
date_fin: row.end_date, date_fin: row.end_date,
etat: (row.etat_de_la_demande || row.etat || "en_cours"), etat: displayEtat,
is_multi_mois: isMulti, is_multi_mois: isMulti,
regime: isRG ? "RG" : (isMulti ? "CDDU_MULTI" : "CDDU_MONO"), regime: isRG ? "RG" : (isMulti ? "CDDU_MULTI" : "CDDU_MONO"),
}; };

View file

@ -34,12 +34,24 @@ export async function PATCH(
} }
// Mettre à jour le statut transfer_done // Mettre à jour le statut transfer_done
// Si transfer_done passe à true, on enregistre la date actuelle dans transfer_done_at
// Si transfer_done passe à false, on efface transfer_done_at
const updateData: any = {
transfer_done: body.transfer_done,
updated_at: new Date().toISOString()
};
if (body.transfer_done === true) {
// Virement marqué comme effectué : enregistrer la date
updateData.transfer_done_at = new Date().toISOString();
} else if (body.transfer_done === false) {
// Virement annulé : effacer la date
updateData.transfer_done_at = null;
}
const { data, error } = await sb const { data, error } = await sb
.from("payslips") .from("payslips")
.update({ .update(updateData)
transfer_done: body.transfer_done,
updated_at: new Date().toISOString()
})
.eq("id", id) .eq("id", id)
.select() .select()
.single(); .single();

View file

@ -0,0 +1,99 @@
// app/api/staff/clients/[id]/request-sepa-mandate/route.ts
import { NextResponse } from "next/server";
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { cookies } from "next/headers";
import { sendSepaMandateRequestEmail } from "@/lib/emailMigrationHelpers";
export const dynamic = 'force-dynamic';
export const revalidate = 0;
export const runtime = 'nodejs';
async function isStaffUser(supabase: any, userId: string): Promise<boolean> {
try {
const { data: staffRow } = await supabase
.from('staff_users')
.select('is_staff')
.eq('user_id', userId)
.maybeSingle();
return !!staffRow?.is_staff;
} catch {
return false;
}
}
export async function POST(req: Request, { params }: { params: { id: string } }) {
try {
console.log('[api/staff/clients/[id]/request-sepa-mandate] POST request for organization ID:', params.id);
const supabase = createRouteHandlerClient({ cookies });
const { data: { session } } = await supabase.auth.getSession();
if (!session) return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
// Vérifier que l'utilisateur est staff
const isStaff = await isStaffUser(supabase, session.user.id);
if (!isStaff) {
return NextResponse.json({ error: 'forbidden', message: 'Staff access required' }, { status: 403 });
}
// Récupérer les informations de l'organisation
const { data: orgData, error: orgError } = await supabase
.from('organizations')
.select(`
id,
structure_api,
organization_details(
email_notifs,
email_notifs_cc,
prenom_contact,
code_employeur
)
`)
.eq('id', params.id)
.single();
if (orgError || !orgData) {
console.error('[api/staff/clients/[id]/request-sepa-mandate] Organization error:', orgError);
return NextResponse.json({ error: 'organization_not_found' }, { status: 404 });
}
const organizationDetails = Array.isArray(orgData.organization_details)
? orgData.organization_details[0]
: orgData.organization_details;
if (!organizationDetails.email_notifs) {
return NextResponse.json({ error: 'no_notification_email' }, { status: 400 });
}
// Envoyer l'email de demande de mandat SEPA
console.log('[api/staff/clients/[id]/request-sepa-mandate] Sending SEPA mandate request email...');
const firstName = organizationDetails.prenom_contact || "";
const organizationName = orgData.structure_api || "";
await sendSepaMandateRequestEmail(
organizationDetails.email_notifs,
organizationDetails.email_notifs_cc,
{
firstName,
organizationName,
employerCode: organizationDetails.code_employeur || undefined,
mandateLink: 'https://pay.gocardless.com/BRT0002PDGX37ZX'
}
);
console.log('[api/staff/clients/[id]/request-sepa-mandate] SEPA mandate request email sent successfully');
return NextResponse.json({
success: true,
message: 'Demande de mandat SEPA envoyée avec succès'
});
} catch (error) {
console.error('[api/staff/clients/[id]/request-sepa-mandate] Error:', error);
const message = error instanceof Error ? error.message : String(error);
return NextResponse.json({
error: 'internal_server_error',
message
}, { status: 500 });
}
}

View file

@ -97,6 +97,9 @@ export async function GET(
ouverture_compte: details?.ouverture_compte || null, ouverture_compte: details?.ouverture_compte || null,
offre_speciale: details?.offre_speciale || null, offre_speciale: details?.offre_speciale || null,
notes: details?.notes || null, notes: details?.notes || null,
// Gestion paie
virements_salaires: details?.virements_salaires || null,
// Informations de contact // Informations de contact
contact_principal: details?.nom_contact || (details?.prenom_contact ? `${details.prenom_contact} ${details?.nom_contact ?? ''}`.trim() : null), contact_principal: details?.nom_contact || (details?.prenom_contact ? `${details.prenom_contact} ${details?.nom_contact ?? ''}`.trim() : null),
@ -183,6 +186,8 @@ export async function PUT(
ouverture_compte, ouverture_compte,
offre_speciale, offre_speciale,
notes, notes,
// Gestion paie
virements_salaires,
// Apporteur d'affaires // Apporteur d'affaires
is_referred, is_referred,
referrer_code, referrer_code,
@ -230,6 +235,10 @@ export async function PUT(
nom_responsable_traitement, nom_responsable_traitement,
qualite_responsable_traitement, qualite_responsable_traitement,
email_responsable_traitement, email_responsable_traitement,
// Facturation (SEPA)
iban,
bic,
id_mandat_sepa,
} = body; } = body;
const orgUpdateData: any = {}; const orgUpdateData: any = {};
@ -244,6 +253,8 @@ export async function PUT(
if (ouverture_compte !== undefined) detailsUpdateData.ouverture_compte = ouverture_compte; if (ouverture_compte !== undefined) detailsUpdateData.ouverture_compte = ouverture_compte;
if (offre_speciale !== undefined) detailsUpdateData.offre_speciale = offre_speciale; if (offre_speciale !== undefined) detailsUpdateData.offre_speciale = offre_speciale;
if (notes !== undefined) detailsUpdateData.notes = notes; if (notes !== undefined) detailsUpdateData.notes = notes;
// Gestion paie
if (virements_salaires !== undefined) detailsUpdateData.virements_salaires = virements_salaires;
// Apporteur d'affaires // Apporteur d'affaires
if (is_referred !== undefined) detailsUpdateData.is_referred = is_referred; if (is_referred !== undefined) detailsUpdateData.is_referred = is_referred;
if (referrer_code !== undefined) detailsUpdateData.referrer_code = referrer_code; if (referrer_code !== undefined) detailsUpdateData.referrer_code = referrer_code;
@ -291,6 +302,10 @@ export async function PUT(
if (nom_responsable_traitement !== undefined) detailsUpdateData.nom_responsable_traitement = nom_responsable_traitement; if (nom_responsable_traitement !== undefined) detailsUpdateData.nom_responsable_traitement = nom_responsable_traitement;
if (qualite_responsable_traitement !== undefined) detailsUpdateData.qualite_responsable_traitement = qualite_responsable_traitement; if (qualite_responsable_traitement !== undefined) detailsUpdateData.qualite_responsable_traitement = qualite_responsable_traitement;
if (email_responsable_traitement !== undefined) detailsUpdateData.email_responsable_traitement = email_responsable_traitement; if (email_responsable_traitement !== undefined) detailsUpdateData.email_responsable_traitement = email_responsable_traitement;
// Facturation (SEPA)
if (iban !== undefined) detailsUpdateData.iban = iban;
if (bic !== undefined) detailsUpdateData.bic = bic;
if (id_mandat_sepa !== undefined) detailsUpdateData.id_mandat_sepa = id_mandat_sepa;
if (Object.keys(orgUpdateData).length === 0 && Object.keys(detailsUpdateData).length === 0) { if (Object.keys(orgUpdateData).length === 0 && Object.keys(detailsUpdateData).length === 0) {
return NextResponse.json({ error: "Aucune donnée à mettre à jour" }, { status: 400 }); return NextResponse.json({ error: "Aucune donnée à mettre à jour" }, { status: 400 });

View file

@ -0,0 +1,58 @@
import { NextResponse } from "next/server";
import { createSbServer } from "@/lib/supabaseServer";
export async function POST(req: Request) {
try {
const sb = createSbServer();
const { data: { user } } = await sb.auth.getUser();
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { data: me } = await sb.from("staff_users").select("is_staff").eq("user_id", user.id).maybeSingle();
if (!me?.is_staff) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const { updates } = await req.json();
if (!updates || !Array.isArray(updates) || updates.length === 0) {
return NextResponse.json({ error: "Updates array is required" }, { status: 400 });
}
// Valider que chaque update a un id et un joursTravail
for (const update of updates) {
if (!update.id || typeof update.joursTravail !== 'string') {
return NextResponse.json({ error: "Each update must have id and joursTravail" }, { status: 400 });
}
}
// Mettre à jour chaque contrat individuellement
const updatedContracts = [];
for (const update of updates) {
const { data, error } = await sb
.from("cddu_contracts")
.update({
jours_travail: update.joursTravail,
jours_travail_non_artiste: update.joursTravail
})
.eq("id", update.id)
.select("id, jours_travail, jours_travail_non_artiste");
if (error) {
console.error(`Error updating contract ${update.id}:`, error);
continue;
}
if (data && data.length > 0) {
updatedContracts.push(data[0]);
}
}
return NextResponse.json({
success: true,
contracts: updatedContracts,
message: `${updatedContracts.length} contrat(s) mis à jour`
});
} catch (err: any) {
console.error("Bulk jours technicien update error:", err);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View file

@ -0,0 +1,58 @@
import { NextResponse } from "next/server";
import { createSbServer } from "@/lib/supabaseServer";
export async function POST(req: Request) {
try {
const sb = createSbServer();
const { data: { user } } = await sb.auth.getUser();
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { data: me } = await sb.from("staff_users").select("is_staff").eq("user_id", user.id).maybeSingle();
if (!me?.is_staff) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const body = await req.json();
const { updates } = body;
if (!updates || !Array.isArray(updates) || updates.length === 0) {
return NextResponse.json({ error: "Updates array required" }, { status: 400 });
}
// Groupe par date pour minimiser les appels
const groups: Record<string, string[]> = {};
for (const u of updates) {
const id = u.id;
// accept null or empty to clear the date
const date = u.date === null || u.date === undefined || u.date === '' ? '__NULL__' : String(u.date);
if (!groups[date]) groups[date] = [];
groups[date].push(id);
}
let allUpdated: Array<{ id: string; date_signature: string | null }> = [];
for (const key of Object.keys(groups)) {
const ids = groups[key];
const dateValue = key === '__NULL__' ? null : key;
const { data: updated, error } = await sb
.from('cddu_contracts')
.update({ date_signature: dateValue })
.in('id', ids)
.select('id, date_signature');
if (error) {
console.error('Error updating signature dates:', error);
return NextResponse.json({ error: 'Failed to update contracts' }, { status: 500 });
}
if (updated) {
allUpdated = allUpdated.concat(updated as any);
}
}
return NextResponse.json({ success: true, contracts: allUpdated, message: `${allUpdated.length} contrat(s) mis à jour` });
} catch (err: any) {
console.error('Bulk signature date update error:', err);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View file

@ -40,7 +40,8 @@ export async function GET(req: Request) {
start_date, end_date, created_at, etat_de_la_demande, etat_de_la_paie, dpae, gross_pay, start_date, end_date, created_at, etat_de_la_demande, etat_de_la_paie, dpae, gross_pay,
contrat_signe_par_employeur, contrat_signe, org_id, contrat_signe_par_employeur, contrat_signe, org_id,
last_employer_notification_at, last_employee_notification_at, last_employer_notification_at, last_employee_notification_at,
salaries!employee_id(salarie, nom, prenom, adresse_mail), analytique, nombre_d_heures, n_objet, objet_spectacle,
salaries!employee_id(salarie, nom, prenom, adresse_mail, code_salarie),
organizations!org_id(organization_details(code_employeur)) organizations!org_id(organization_details(code_employeur))
`, { count: "exact" }); `, { count: "exact" });

View file

@ -0,0 +1,151 @@
// app/api/staff/facturation/bulk-create/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';
async function isStaffUser(supabase: any, userId: string): Promise<boolean> {
try {
const { data: staffRow } = await supabase
.from('staff_users')
.select('is_staff')
.eq('user_id', userId)
.maybeSingle();
return !!staffRow?.is_staff;
} catch {
return false;
}
}
type InvoicePayload = {
org_id: string;
numero: string;
periode?: string | null;
date?: string | null;
due_date?: string | null;
payment_date?: string | null;
sepa_day?: string | null;
montant_ht: number;
montant_ttc: number;
statut: string;
notes?: string | null;
pdf_s3_key?: string | null;
};
// POST - Création en masse de factures
export async function POST(req: Request) {
try {
const body = await req.json();
const { invoices } = body;
// Validation des données
if (!Array.isArray(invoices) || invoices.length === 0) {
return NextResponse.json({ error: 'invalid_invoices' }, { status: 400 });
}
// Limiter le nombre de factures
if (invoices.length > 100) {
return NextResponse.json({ error: 'too_many_invoices', message: 'Maximum 100 invoices per batch' }, { status: 400 });
}
const supabase = createRouteHandlerClient({ cookies });
// Auth et vérification staff
const { data: { session }, error: sessionError } = await supabase.auth.getSession();
if (sessionError || !session?.user?.id) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
}
const isStaff = await isStaffUser(supabase, session.user.id);
if (!isStaff) {
return NextResponse.json({ error: 'forbidden' }, { status: 403 });
}
// Valider chaque facture
const validInvoices: InvoicePayload[] = [];
const validationErrors: any[] = [];
invoices.forEach((invoice: any, index: number) => {
const errors: string[] = [];
if (!invoice.org_id) errors.push('org_id required');
if (!invoice.numero) errors.push('numero required');
if (!invoice.montant_ttc || invoice.montant_ttc <= 0) errors.push('montant_ttc must be > 0');
if (errors.length > 0) {
validationErrors.push({ index, errors });
} else {
validInvoices.push({
org_id: invoice.org_id,
numero: invoice.numero,
periode: invoice.periode || null,
date: invoice.date || null,
due_date: invoice.due_date || null,
payment_date: invoice.payment_date || null,
sepa_day: invoice.sepa_day || null,
montant_ht: invoice.montant_ht || 0,
montant_ttc: invoice.montant_ttc,
statut: invoice.statut || 'emise',
notes: invoice.notes || null,
pdf_s3_key: invoice.pdf_s3_key || null,
});
}
});
if (validationErrors.length > 0) {
return NextResponse.json({
error: 'validation_failed',
validationErrors,
message: `${validationErrors.length} invoice(s) have validation errors`
}, { status: 400 });
}
// Mapper les données vers le schéma de la table invoices
const invoicesToInsert = validInvoices.map(inv => ({
org_id: inv.org_id,
invoice_number: inv.numero,
period_label: inv.periode,
invoice_date: inv.date,
due_date: inv.due_date,
payment_date: inv.payment_date,
sepa_day: inv.sepa_day,
amount_ht: inv.montant_ht,
amount_ttc: inv.montant_ttc,
status: inv.statut,
notes: inv.notes,
pdf_s3_key: inv.pdf_s3_key,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}));
// Insérer toutes les factures
const { data: createdInvoices, error: insertError } = await supabase
.from('invoices')
.insert(invoicesToInsert)
.select();
if (insertError) {
console.error('Erreur lors de l\'insertion des factures:', insertError);
return NextResponse.json({
error: 'insert_failed',
details: insertError.message
}, { status: 500 });
}
return NextResponse.json({
success: true,
created: createdInvoices?.length || 0,
message: `${createdInvoices?.length || 0} facture(s) créée(s) avec succès`
});
} catch (error: any) {
console.error('Erreur dans bulk-create:', error);
return NextResponse.json(
{ error: 'internal_server_error', details: error.message },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,105 @@
// app/api/staff/facturation/bulk-update-payment-date/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';
async function isStaffUser(supabase: any, userId: string): Promise<boolean> {
try {
const { data: staffRow } = await supabase
.from('staff_users')
.select('is_staff')
.eq('user_id', userId)
.maybeSingle();
return !!staffRow?.is_staff;
} catch {
return false;
}
}
// POST - Mise à jour en masse des dates de paiement
export async function POST(req: Request) {
try {
const body = await req.json();
const { invoiceIds, paymentDate } = body;
// Validation des données
if (!Array.isArray(invoiceIds) || invoiceIds.length === 0) {
return NextResponse.json({ error: 'invalid_invoice_ids' }, { status: 400 });
}
if (!paymentDate || typeof paymentDate !== 'string') {
return NextResponse.json({ error: 'invalid_payment_date' }, { status: 400 });
}
// Validation du format de date
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
if (!dateRegex.test(paymentDate)) {
return NextResponse.json({ error: 'invalid_date_format' }, { status: 400 });
}
const supabase = createRouteHandlerClient({ cookies });
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
}
// Vérifier que l'utilisateur est staff
const isStaff = await isStaffUser(supabase, session.user.id);
if (!isStaff) {
return NextResponse.json({ error: 'forbidden', message: 'Staff access required' }, { status: 403 });
}
// Limiter le nombre d'IDs pour éviter les abus
if (invoiceIds.length > 100) {
return NextResponse.json({ error: 'too_many_invoices', message: 'Maximum 100 invoices per batch' }, { status: 400 });
}
// Vérifier que toutes les factures existent
const { data: existingInvoices, error: fetchError } = await supabase
.from('invoices')
.select('id')
.in('id', invoiceIds);
if (fetchError) {
console.error('Erreur lors de la récupération des factures:', fetchError);
return NextResponse.json({ error: 'database_error' }, { status: 500 });
}
if (!existingInvoices || existingInvoices.length === 0) {
return NextResponse.json({ error: 'no_invoices_found' }, { status: 404 });
}
// Mettre à jour les dates de paiement
const { data: updatedInvoices, error: updateError } = await supabase
.from('invoices')
.update({
payment_date: paymentDate,
updated_at: new Date().toISOString()
})
.in('id', invoiceIds)
.select();
if (updateError) {
console.error('Erreur lors de la mise à jour des dates de paiement:', updateError);
return NextResponse.json({ error: 'update_failed', details: updateError.message }, { status: 500 });
}
return NextResponse.json({
success: true,
updated: updatedInvoices?.length || 0,
message: `${updatedInvoices?.length || 0} facture(s) mise(s) à jour avec succès`
});
} catch (error: any) {
console.error('Erreur dans bulk-update-payment-date:', error);
return NextResponse.json(
{ error: 'internal_server_error', details: error.message },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,100 @@
// app/api/staff/facturation/bulk-update-status/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';
async function isStaffUser(supabase: any, userId: string): Promise<boolean> {
try {
const { data: staffRow } = await supabase
.from('staff_users')
.select('is_staff')
.eq('user_id', userId)
.maybeSingle();
return !!staffRow?.is_staff;
} catch {
return false;
}
}
// POST - Mise à jour en masse du statut des factures
export async function POST(req: Request) {
try {
const body = await req.json();
const { invoiceIds, status } = body;
// Validation des données
if (!Array.isArray(invoiceIds) || invoiceIds.length === 0) {
return NextResponse.json({ error: 'invalid_invoice_ids' }, { status: 400 });
}
if (!status || typeof status !== 'string') {
return NextResponse.json({ error: 'invalid_status' }, { status: 400 });
}
// Validation des statuts autorisés
const validStatuses = ['payee', 'annulee', 'prete', 'emise', 'en_cours', 'brouillon'];
if (!validStatuses.includes(status)) {
return NextResponse.json({ error: 'invalid_status_value' }, { status: 400 });
}
const supabase = createRouteHandlerClient({ cookies });
// Auth et vérification staff
const { data: { session }, error: sessionError } = await supabase.auth.getSession();
if (sessionError || !session?.user?.id) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
}
const isStaff = await isStaffUser(supabase, session.user.id);
if (!isStaff) {
return NextResponse.json({ error: 'forbidden' }, { status: 403 });
}
// Vérifier que toutes les factures existent
const { data: existingInvoices, error: fetchError } = await supabase
.from('invoices')
.select('id')
.in('id', invoiceIds);
if (fetchError) {
console.error('Erreur lors de la vérification des factures:', fetchError);
return NextResponse.json({ error: 'database_error' }, { status: 500 });
}
if (!existingInvoices || existingInvoices.length !== invoiceIds.length) {
return NextResponse.json({ error: 'some_invoices_not_found' }, { status: 404 });
}
// Mettre à jour les statuts
const { data: updatedInvoices, error: updateError } = await supabase
.from('invoices')
.update({
status: status,
updated_at: new Date().toISOString()
})
.in('id', invoiceIds)
.select();
if (updateError) {
console.error('Erreur lors de la mise à jour des statuts:', updateError);
return NextResponse.json({ error: 'update_failed' }, { status: 500 });
}
return NextResponse.json({
success: true,
updated: updatedInvoices?.length || 0,
message: `${updatedInvoices?.length || 0} facture(s) mise(s) à jour avec succès`
});
} catch (error: any) {
console.error('Erreur dans bulk-update-status:', error);
return NextResponse.json(
{ error: 'internal_server_error', details: error.message },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,88 @@
// app/api/staff/facturation/clients-sans-facture/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';
async function isStaffUser(supabase: any, userId: string): Promise<boolean> {
try {
const { data: staffRow } = await supabase
.from('staff_users')
.select('is_staff')
.eq('user_id', userId)
.maybeSingle();
return !!staffRow?.is_staff;
} catch {
return false;
}
}
// GET - Récupérer les clients actifs sans facture pour une période donnée
export async function GET(req: Request) {
try {
const url = new URL(req.url);
const periode = url.searchParams.get('periode');
if (!periode) {
return NextResponse.json({ error: 'missing_periode', message: 'Le paramètre "periode" est requis' }, { status: 400 });
}
const supabase = createRouteHandlerClient({ cookies });
const { data: { session } } = await supabase.auth.getSession();
if (!session) return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
// Vérifier que l'utilisateur est staff
const isStaff = await isStaffUser(supabase, session.user.id);
if (!isStaff) {
return NextResponse.json({ error: 'forbidden', message: 'Staff access required' }, { status: 403 });
}
// 1. Récupérer tous les clients actifs depuis organization_details
const { data: activeOrgs, error: orgsError } = await supabase
.from('organization_details')
.select('org_id, organizations!inner(id, name, structure_api)')
.eq('statut', 'Actif')
.order('organizations(name)');
if (orgsError) {
console.error('[api/staff/facturation/clients-sans-facture] organizations error:', orgsError.message);
return NextResponse.json({ error: 'supabase_error', detail: orgsError.message }, { status: 500 });
}
// 2. Récupérer toutes les factures de la période
const { data: invoicesForPeriod, error: invoicesError } = await supabase
.from('invoices')
.select('org_id')
.eq('period_label', periode);
if (invoicesError) {
console.error('[api/staff/facturation/clients-sans-facture] invoices error:', invoicesError.message);
return NextResponse.json({ error: 'supabase_error', detail: invoicesError.message }, { status: 500 });
}
// 3. Créer un Set des org_id qui ont une facture pour cette période
const orgsWithInvoice = new Set(invoicesForPeriod?.map((inv: any) => inv.org_id) || []);
// 4. Filtrer les organisations actives qui n'ont pas de facture pour cette période
const clientsWithoutInvoice = (activeOrgs || [])
.filter((org: any) => !orgsWithInvoice.has(org.org_id))
.map((org: any) => ({
id: org.org_id,
name: org.organizations?.name || 'Nom inconnu',
structure_api: org.organizations?.structure_api || null,
}));
return NextResponse.json({
clients: clientsWithoutInvoice,
periode,
count: clientsWithoutInvoice.length
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error('[api/staff/facturation/clients-sans-facture] error:', message);
return NextResponse.json({ error: 'internal_server_error', message }, { status: 500 });
}
}

View file

@ -1,7 +1,6 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { createSbServer } from "@/lib/supabaseServer"; import { createSbServer } from "@/lib/supabaseServer";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"; import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { v4 as uuidv4 } from 'uuid';
const s3Client = new S3Client({ const s3Client = new S3Client({
region: process.env.AWS_REGION || "eu-west-3", region: process.env.AWS_REGION || "eu-west-3",
@ -100,18 +99,15 @@ export async function POST(req: NextRequest) {
.replace(/[^a-z0-9]+/g, '-') .replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, ''); .replace(/^-+|-+$/g, '');
// Générer le chemin S3: bulletins/{org_slug}/contrat_{contract_number}/bulletin_paie_{pay_number}_{uuid}.pdf // Générer le chemin S3: paies/{org_slug}/{contract_number}.pdf
const uniqueId = uuidv4().replace(/-/g, '').substring(0, 8); // Format standardisé cohérent avec ContractEditor.tsx
const contractNumber = contract.contract_number || contractId.substring(0, 8); const contractNumber = contract.contract_number || contractId.substring(0, 8);
const payNumber = payslip.pay_number || 'unknown'; const s3Key = `paies/${orgSlug}/${contractNumber}.pdf`;
const filename = `bulletin_paie_${payNumber}_${uniqueId}.pdf`;
const s3Key = `bulletins/${orgSlug}/contrat_${contractNumber}/${filename}`;
console.log('📄 [Payslip Upload] Uploading to S3:', { console.log('📄 [Payslip Upload] Uploading to S3:', {
contractId, contractId,
payslipId, payslipId,
contractNumber, contractNumber,
payNumber,
s3Key, s3Key,
fileSize: file.size fileSize: file.size
}); });
@ -145,7 +141,6 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
s3_key: s3Key, s3_key: s3Key,
filename: filename,
message: "Bulletin de paie uploadé avec succès" message: "Bulletin de paie uploadé avec succès"
}); });

View file

@ -1,6 +1,47 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createSbServer } from "@/lib/supabaseServer"; import { createSbServer } from "@/lib/supabaseServer";
export async function GET(req: Request, { params }: { params: { id: string } }) {
try {
const sb = createSbServer();
const { data: { user } } = await sb.auth.getUser();
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { data: me } = await sb.from('staff_users').select('is_staff').eq('user_id', user.id).maybeSingle();
if (!me?.is_staff) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const payslipId = params.id;
// Fetch payslip with relations
const { data, error } = await sb
.from('payslips')
.select(`
*,
cddu_contracts!contract_id(
id,
contract_number,
employee_name,
employee_id,
structure,
type_de_contrat,
org_id,
salaries!employee_id(salarie, nom, prenom),
organizations!org_id(organization_details(code_employeur))
)
`)
.eq('id', payslipId)
.single();
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
if (!data) return NextResponse.json({ error: 'Not found' }, { status: 404 });
return NextResponse.json(data);
} catch (err: any) {
console.error(err);
return NextResponse.json({ error: 'Internal' }, { status: 500 });
}
}
export async function PATCH(req: Request, { params }: { params: { id: string } }) { export async function PATCH(req: Request, { params }: { params: { id: string } }) {
try { try {
const sb = createSbServer(); const sb = createSbServer();

View file

@ -49,10 +49,11 @@ export async function GET(req: NextRequest) {
.select( .select(
`id, contract_id, period_start, period_end, period_month, pay_number, pay_date, `id, contract_id, period_start, period_end, period_month, pay_number, pay_date,
gross_amount, net_amount, net_after_withholding, employer_cost, gross_amount, net_amount, net_after_withholding, employer_cost,
processed, aem_status, transfer_done, organization_id, created_at, processed, aem_status, transfer_done, organization_id, storage_path, created_at,
cddu_contracts!contract_id( cddu_contracts!contract_id(
id, contract_number, employee_name, employee_id, structure, type_de_contrat, org_id, id, contract_number, employee_name, employee_id, structure, type_de_contrat, org_id,
salaries!employee_id(salarie, nom, prenom) salaries!employee_id(salarie, nom, prenom),
organizations!org_id(organization_details(code_employeur))
)`, )`,
{ count: "exact" } { count: "exact" }
); // Filtre de recherche textuelle (n° contrat, nom salarié) ); // Filtre de recherche textuelle (n° contrat, nom salarié)

View file

@ -246,7 +246,7 @@ export async function GET(req: NextRequest) {
// Base query payslips de l'organisation // Base query payslips de l'organisation
let payslipsQuery = sb let payslipsQuery = sb
.from("payslips") .from("payslips")
.select("id, contract_id, organization_id, transfer_done, net_after_withholding, period_start, period_end, pay_date, updated_at, processed") .select("id, contract_id, organization_id, transfer_done, transfer_done_at, net_after_withholding, period_start, period_end, pay_date, updated_at, processed")
.eq("organization_id", activeOrgId); .eq("organization_id", activeOrgId);
// Filtrage par année (période de paie) // Filtrage par année (période de paie)
@ -263,12 +263,15 @@ export async function GET(req: NextRequest) {
} else { } else {
// Masquer les payslips dont processed est FALSE // Masquer les payslips dont processed est FALSE
const unpaidPayslips = (allPayslips || []).filter(p => !p.transfer_done && p.processed !== false); const unpaidPayslips = (allPayslips || []).filter(p => !p.transfer_done && p.processed !== false);
// Filtrer les paies récentes (virées dans les 30 derniers jours)
// On utilise transfer_done_at (date exacte du marquage) au lieu de updated_at
const thirtyDaysAgo = new Date(); const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const recentPayslips = (allPayslips || []).filter(p => { const recentPayslips = (allPayslips || []).filter(p => {
if (p.transfer_done && p.processed !== false) { if (p.transfer_done && p.processed !== false && p.transfer_done_at) {
const ts = p.updated_at ? new Date(p.updated_at) : null; const ts = new Date(p.transfer_done_at);
return ts ? ts >= thirtyDaysAgo : false; return ts >= thirtyDaysAgo;
} }
return false; return false;
}); });

View file

@ -0,0 +1,211 @@
import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs';
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
const DOCUSEAL_TOKEN = process.env.DOCUSEAL_TOKEN;
const DOCUSEAL_API_BASE_URL = process.env.DOCUSEAL_API_BASE || 'https://api.docuseal.eu';
/**
* Webhook DocuSeal pour la signature des contrats (CDDU et RG)
* Mode TEST : Lecture seule, pas d'impact sur la production
*
* Cette route remplace les Lambda Functions AWS :
* - lambdaRouterDocuseal
* - postDocuSealSalarie
* - postDocuSealFinalEmails
*/
export async function POST(request: Request) {
const TEST_MODE = true; // ⚠️ Mode TEST activé - Pas de modifications en BDD ni d'envoi d'emails
console.log('🔔 [DOCUSEAL WEBHOOK TEST] Réception webhook contrat');
try {
// 1. Parse le payload
const payload = await request.json();
console.log('📦 [TEST] Payload reçu:', JSON.stringify(payload, null, 2));
// 2. Vérifications de base
const eventType = payload.event_type || payload.event;
console.log('📋 [TEST] Event type:', eventType);
if (eventType !== 'form.completed') {
console.log('⏭️ [TEST] Event ignoré (pas form.completed)');
return NextResponse.json({
received: true,
ignored: true,
reason: 'Event type non géré',
test_mode: true
});
}
// 3. Extraire les données importantes
const data = payload.data;
const documents = data?.documents || [];
const role = data?.role;
const submissionId = data?.id; // ID de la submission DocuSeal
console.log('🔍 [TEST] Données extraites:', {
role,
submissionId,
documentsCount: documents.length,
documentNames: documents.map((d: any) => d.name)
});
// 4. Filtrer : ne traiter que les documents "contrat*"
const contratDocs = documents.filter((doc: any) =>
doc.name && doc.name.toLowerCase().startsWith('contrat')
);
if (contratDocs.length === 0) {
console.log('⏭️ [TEST] Aucun document "contrat" trouvé (probablement un avenant)');
return NextResponse.json({
received: true,
ignored: true,
reason: 'Pas de document contrat',
test_mode: true
});
}
console.log('✅ [TEST] Document contrat trouvé:', contratDocs[0].name);
// 5. Chercher le contrat dans la BDD via docuseal_submission_id
const supabase = createRouteHandlerClient({ cookies });
console.log('🔍 [TEST] Recherche du contrat avec submission_id:', submissionId);
const { data: contract, error: contractError } = await supabase
.from('cddu_contracts')
.select('*')
.eq('docuseal_submission_id', submissionId)
.single();
if (contractError || !contract) {
console.error('❌ [TEST] Contrat non trouvé:', contractError);
return NextResponse.json({
received: true,
error: 'Contract not found',
submissionId,
test_mode: true
}, { status: 404 });
}
console.log('✅ [TEST] Contrat trouvé:', {
id: contract.id,
contract_number: contract.contract_number,
employee_id: contract.employee_id,
signature_status: contract.signature_status,
contrat_signe_par_employeur: contract.contrat_signe_par_employeur,
contrat_signe: contract.contrat_signe,
contract_pdf_s3_key: contract.contract_pdf_s3_key
});
// 6. Router selon le rôle (Employeur ou Salarié)
if (role === 'Employeur') {
console.log('👔 [TEST] === SIGNATURE EMPLOYEUR ===');
// Récupérer le slug du salarié depuis DocuSeal API
console.log('🔍 [TEST] Récupération du employee_docuseal_slug depuis DocuSeal API');
const docusealResponse = await fetch(
`${DOCUSEAL_API_BASE_URL}/submissions/${submissionId}`,
{
headers: {
'X-Auth-Token': DOCUSEAL_TOKEN || '',
'Content-Type': 'application/json'
}
}
);
if (!docusealResponse.ok) {
console.error('❌ [TEST] Erreur DocuSeal API:', docusealResponse.status);
return NextResponse.json({
error: 'DocuSeal API error',
test_mode: true
}, { status: 500 });
}
const submissionData = await docusealResponse.json();
console.log('📦 [TEST] Données submission DocuSeal:', JSON.stringify(submissionData, null, 2));
// Trouver le submitter avec le rôle "Salarié"
const employeeSubmitter = submissionData.submitters?.find(
(s: any) => s.role === 'Salarié'
);
const employeeSlug = employeeSubmitter?.slug;
console.log('🔗 [TEST] Employee slug trouvé:', employeeSlug);
if (TEST_MODE) {
console.log('⚠️ [TEST MODE] On NE met PAS à jour la BDD');
console.log('📝 [TEST] Données qui seraient mises à jour:', {
signature_status: 'pending_employee',
contrat_signe_par_employeur: 'Oui',
employee_docuseal_slug: employeeSlug,
last_employer_notification_at: new Date().toISOString()
});
console.log('📧 [TEST] Email qui serait envoyé au salarié (NON ENVOYÉ EN MODE TEST)');
}
return NextResponse.json({
success: true,
test_mode: true,
role: 'Employeur',
contract_id: contract.id,
contract_number: contract.contract_number,
employee_slug: employeeSlug,
message: 'TEST MODE : Aucune modification effectuée'
});
} else if (role === 'Salarié') {
console.log('👤 [TEST] === SIGNATURE SALARIE ===');
// URL du PDF signé
const pdfUrl = contratDocs[0].url;
console.log('📄 [TEST] URL du PDF signé:', pdfUrl);
if (TEST_MODE) {
console.log('⚠️ [TEST MODE] On NE télécharge PAS le PDF');
console.log('⚠️ [TEST MODE] On N\'upload PAS sur S3');
console.log('⚠️ [TEST MODE] On NE met PAS à jour la BDD');
console.log('📝 [TEST] Données qui seraient mises à jour:', {
signature_status: 'signed',
contrat_signe: 'Oui',
date_signature: new Date().toISOString()
});
console.log('📧 [TEST] Emails de confirmation qui seraient envoyés (NON ENVOYÉS EN MODE TEST)');
console.log('🪣 [TEST] S3 upload qui serait fait:', {
bucket: 'odentas-docs',
key: contract.contract_pdf_s3_key
});
}
return NextResponse.json({
success: true,
test_mode: true,
role: 'Salarié',
contract_id: contract.id,
contract_number: contract.contract_number,
pdf_url: pdfUrl,
s3_key: contract.contract_pdf_s3_key,
message: 'TEST MODE : Aucune modification effectuée'
});
} else {
console.warn('⚠️ [TEST] Rôle inconnu:', role);
return NextResponse.json({
received: true,
error: 'Unknown role',
role,
test_mode: true
}, { status: 400 });
}
} catch (error) {
console.error('❌ [TEST] Erreur dans le webhook:', error);
return NextResponse.json({
error: 'Internal server error',
details: error instanceof Error ? error.message : 'Unknown error',
test_mode: true
}, { status: 500 });
}
}

View file

@ -18,6 +18,8 @@ interface DatesQuantityModalProps {
selectedDates: string[]; selectedDates: string[];
hasMultiMonth: boolean; hasMultiMonth: boolean;
pdfFormatted: string; pdfFormatted: string;
globalQuantity?: number;
globalDuration?: "3" | "4";
}) => void; }) => void;
selectedDates: string[]; // format input "12/10, 13/10, ..." selectedDates: string[]; // format input "12/10, 13/10, ..."
dateType: "representations" | "repetitions" | "jours_travail" | "heures_repetitions"; // Type de dates pour déterminer le libellé dateType: "representations" | "repetitions" | "jours_travail" | "heures_repetitions"; // Type de dates pour déterminer le libellé
@ -72,6 +74,12 @@ export default function DatesQuantityModal({
// État pour la checkbox "Ne pas appliquer d'heures par jour" // État pour la checkbox "Ne pas appliquer d'heures par jour"
const [skipHoursByDay, setSkipHoursByDay] = useState<boolean>(false); const [skipHoursByDay, setSkipHoursByDay] = useState<boolean>(false);
// État pour le nombre global saisi quand skipHoursByDay est coché
const [globalQuantity, setGlobalQuantity] = useState<string>("");
// État pour la durée des services quand c'est des répétitions sans détail
const [globalDuration, setGlobalDuration] = useState<"3" | "4">(repetitionDuration || "4");
// État pour le champ "Appliquer à toutes les dates" // État pour le champ "Appliquer à toutes les dates"
const [applyToAllValue, setApplyToAllValue] = useState<string>(""); const [applyToAllValue, setApplyToAllValue] = useState<string>("");
@ -90,6 +98,9 @@ export default function DatesQuantityModal({
setQuantities(emptyQuantities); setQuantities(emptyQuantities);
setApplyToAllValue(""); setApplyToAllValue("");
setValidationError(""); setValidationError("");
} else {
// Réinitialiser le nombre global
setGlobalQuantity("");
} }
}, [skipHoursByDay, selectedIsos]); }, [skipHoursByDay, selectedIsos]);
@ -232,8 +243,23 @@ export default function DatesQuantityModal({
}; };
const handleApply = () => { const handleApply = () => {
// Si on ne veut pas d'heures par jour, pas besoin de valider les quantités let globalQty: number | undefined = undefined;
if (!skipHoursByDay) { let globalDur: "3" | "4" | undefined = undefined;
// Si on ne veut pas d'heures par jour, valider le nombre global
if (skipHoursByDay) {
const qty = parseInt(globalQuantity, 10);
if (!globalQuantity || isNaN(qty) || qty < 1) {
setValidationError("Veuillez saisir un nombre global valide (>= 1)");
return;
}
globalQty = qty;
// Pour les répétitions, récupérer aussi la durée
if (dateType === "repetitions") {
globalDur = globalDuration;
}
} else {
// Vérifier que toutes les quantités sont > 0 // Vérifier que toutes les quantités sont > 0
for (const iso of selectedIsos) { for (const iso of selectedIsos) {
const qty = quantities[iso]; const qty = quantities[iso];
@ -253,6 +279,8 @@ export default function DatesQuantityModal({
selectedDates: selectedDates, selectedDates: selectedDates,
hasMultiMonth: selectedIsos.length > 0 && checkMultiMonth(selectedIsos), hasMultiMonth: selectedIsos.length > 0 && checkMultiMonth(selectedIsos),
pdfFormatted, pdfFormatted,
globalQuantity: globalQty,
globalDuration: globalDur,
}); });
onClose(); onClose();
@ -300,7 +328,7 @@ export default function DatesQuantityModal({
<div className="flex-1 overflow-y-auto p-4 space-y-4"> <div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* Card option pour ne pas appliquer d'heures par jour */} {/* Card option pour ne pas appliquer d'heures par jour */}
{allowSkipHoursByDay && ( {allowSkipHoursByDay && (
<div className="p-4 rounded-lg border border-indigo-200 bg-indigo-50 space-y-2"> <div className="p-4 rounded-lg border border-indigo-200 bg-indigo-50 space-y-3">
<label className="flex items-start gap-3 cursor-pointer"> <label className="flex items-start gap-3 cursor-pointer">
<input <input
type="checkbox" type="checkbox"
@ -317,6 +345,54 @@ export default function DatesQuantityModal({
</div> </div>
</div> </div>
</label> </label>
{/* Champ de saisie du nombre global si coché */}
{skipHoursByDay && (
<div className="space-y-2">
<label className="block text-sm font-medium text-indigo-900">
{dateType === "representations" && "Nombre total de représentations"}
{dateType === "repetitions" && "Nombre total de services de répétition"}
{(dateType === "jours_travail" || dateType === "heures_repetitions") && "Nombre total d'heures"}
</label>
<Input
type="number"
min={1}
placeholder="Saisissez le nombre"
value={globalQuantity}
onChange={(e) => setGlobalQuantity(e.target.value)}
className="bg-white"
/>
{/* Durée pour les répétitions */}
{dateType === "repetitions" && (
<div className="mt-2">
<label className="block text-sm font-medium text-indigo-900 mb-1">
Durée des services de répétition
</label>
<div className="flex items-center gap-4">
<label className="inline-flex items-center gap-2 text-sm cursor-pointer">
<input
type="radio"
checked={globalDuration === "3"}
onChange={() => setGlobalDuration("3")}
className="text-indigo-600"
/>
3 heures
</label>
<label className="inline-flex items-center gap-2 text-sm cursor-pointer">
<input
type="radio"
checked={globalDuration === "4"}
onChange={() => setGlobalDuration("4")}
className="text-indigo-600"
/>
4 heures
</label>
</div>
</div>
)}
</div>
)}
</div> </div>
)} )}

View file

@ -12,7 +12,7 @@ import { useDemoMode } from "@/hooks/useDemoMode";
import Calculator from "@/components/Calculator"; import Calculator from "@/components/Calculator";
import DatePickerCalendar from "@/components/DatePickerCalendar"; import DatePickerCalendar from "@/components/DatePickerCalendar";
import DatesQuantityModal from "@/components/DatesQuantityModal"; import DatesQuantityModal from "@/components/DatesQuantityModal";
import { parseDateString } from "@/lib/dateFormatter"; import { parseDateString, parseFrenchedDate } from "@/lib/dateFormatter";
import { Tooltip } from "@/components/ui/tooltip"; import { Tooltip } from "@/components/ui/tooltip";
/* ========================= /* =========================
@ -346,6 +346,7 @@ export function NouveauCDDUForm({
const [isMultiMois, setIsMultiMois] = useState<"Oui" | "Non">("Non"); const [isMultiMois, setIsMultiMois] = useState<"Oui" | "Non">("Non");
const [dateDebut, setDateDebut] = useState(""); const [dateDebut, setDateDebut] = useState("");
const [dateFin, setDateFin] = useState(""); const [dateFin, setDateFin] = useState("");
const [manualDatesMode, setManualDatesMode] = useState(false); // Mode manuel pour les dates
const [confirmPastStart, setConfirmPastStart] = useState(false); const [confirmPastStart, setConfirmPastStart] = useState(false);
const [heuresTotal, setHeuresTotal] = useState<number | "">(""); const [heuresTotal, setHeuresTotal] = useState<number | "">("");
const [minutesTotal, setMinutesTotal] = useState<"0" | "30">("0"); const [minutesTotal, setMinutesTotal] = useState<"0" | "30">("0");
@ -484,28 +485,98 @@ export function NouveauCDDUForm({
selectedDates: string[]; selectedDates: string[];
hasMultiMonth: boolean; hasMultiMonth: boolean;
pdfFormatted: string; pdfFormatted: string;
globalQuantity?: number;
globalDuration?: "3" | "4";
}) => { }) => {
// Calculer le nombre de jours/dates sélectionnées // Si un nombre global est fourni, l'utiliser; sinon calculer le nombre de dates
const nbDates = result.selectedDates.length; const quantity = result.globalQuantity || result.selectedDates.length;
// Récupérer toutes les dates AVANT de faire les setState
const allDatesStrings: string[] = [];
// Ajouter les dates selon le type
if (quantityModalType === "representations") {
allDatesStrings.push(...result.pdfFormatted.split(/[;,]/));
// Ajouter les dates de répétitions existantes
if (datesServ) allDatesStrings.push(...datesServ.split(/[;,]/));
// Ajouter les jours de travail existants
if (joursTravail) allDatesStrings.push(...joursTravail.split(/[;,]/));
} else if (quantityModalType === "repetitions") {
// Ajouter les dates de représentations existantes
if (datesRep) allDatesStrings.push(...datesRep.split(/[;,]/));
allDatesStrings.push(...result.pdfFormatted.split(/[;,]/));
// Ajouter les jours de travail existants
if (joursTravail) allDatesStrings.push(...joursTravail.split(/[;,]/));
} else if (quantityModalType === "jours_travail") {
// Ajouter les dates de représentations existantes
if (datesRep) allDatesStrings.push(...datesRep.split(/[;,]/));
// Ajouter les dates de répétitions existantes
if (datesServ) allDatesStrings.push(...datesServ.split(/[;,]/));
allDatesStrings.push(...result.pdfFormatted.split(/[;,]/));
}
// Convertir toutes les dates en format ISO et trier
const isos = allDatesStrings
.filter(d => d && d.trim())
.map(d => {
// Nettoyer la chaîne : enlever "le", "du", "au", ".", espaces
const cleaned = d.trim()
.replace(/^(le|du|au)\s+/i, '')
.replace(/\.$/, '')
.trim();
return parseFrenchedDate(cleaned, dateDebut || new Date().toISOString().slice(0, 10));
})
.filter(iso => iso && iso.length === 10)
.sort();
// Calculer les dates min/max et multi-mois
let newDateDebut = dateDebut;
let newDateFin = dateFin;
let newIsMultiMois = isMultiMois;
if (isos.length > 0) {
newDateDebut = isos[0];
newDateFin = isos[isos.length - 1];
// Déterminer multi-mois
const hasMultiMonth = isos.some((iso, idx) => {
if (idx === 0) return false;
const prevMonth = isos[0].slice(0, 7);
const currMonth = iso.slice(0, 7);
return currMonth !== prevMonth;
});
newIsMultiMois = hasMultiMonth ? "Oui" : "Non";
}
// Maintenant faire tous les setState
switch (quantityModalType) { switch (quantityModalType) {
case "representations": case "representations":
setDatesRep(result.pdfFormatted); setDatesRep(result.pdfFormatted);
setDatesRepDisplay(result.pdfFormatted); setDatesRepDisplay(result.pdfFormatted);
// Auto-remplir le nombre de représentations basé sur les dates sélectionnées setNbRep(quantity);
setNbRep(nbDates);
break; break;
case "repetitions": case "repetitions":
setDatesServ(result.pdfFormatted); setDatesServ(result.pdfFormatted);
setDatesServDisplay(result.pdfFormatted); setDatesServDisplay(result.pdfFormatted);
// Auto-remplir le nombre de services de répétition basé sur les dates sélectionnées setNbServ(quantity);
setNbServ(nbDates); if (result.globalDuration) {
setDurationServices(result.globalDuration);
}
break; break;
case "jours_travail": case "jours_travail":
setJoursTravail(result.pdfFormatted); setJoursTravail(result.pdfFormatted);
setJoursTravailDisplay(result.pdfFormatted); setJoursTravailDisplay(result.pdfFormatted);
break; break;
} }
// Mettre à jour les dates et multi-mois seulement si on n'est pas en mode manuel
if (!manualDatesMode) {
setDateDebut(newDateDebut);
setDateFin(newDateFin);
setIsMultiMois(newIsMultiMois);
}
setQuantityModalOpen(false); setQuantityModalOpen(false);
setPendingDates([]); setPendingDates([]);
}; };
@ -1392,6 +1463,7 @@ useEffect(() => {
heures_travail: !isRegimeRG && useHeuresMode ? heuresTotal : (!isRegimeRG && typeof nbServ === "number" && nbServ > 0 ? nbServ * parseInt(durationServices) : undefined), heures_travail: !isRegimeRG && useHeuresMode ? heuresTotal : (!isRegimeRG && typeof nbServ === "number" && nbServ > 0 ? nbServ * parseInt(durationServices) : undefined),
minutes_travail: !isRegimeRG && useHeuresMode ? minutesTotal : undefined, minutes_travail: !isRegimeRG && useHeuresMode ? minutesTotal : undefined,
jours_travail: !isRegimeRG && useHeuresMode ? (joursTravail || undefined) : undefined, jours_travail: !isRegimeRG && useHeuresMode ? (joursTravail || undefined) : undefined,
jours_travail_non_artiste: !isRegimeRG && useHeuresMode && categoriePro === "Technicien" ? (joursTravail || undefined) : undefined,
type_salaire: typeSalaire, type_salaire: typeSalaire,
montant: salaryMode === "par_date" ? undefined : (typeSalaire !== "Minimum conventionnel" ? (montantSalaire === "" ? undefined : montantSalaire) : undefined), montant: salaryMode === "par_date" ? undefined : (typeSalaire !== "Minimum conventionnel" ? (montantSalaire === "" ? undefined : montantSalaire) : undefined),
salaires_par_date: !isRegimeRG && salaryMode === "par_date" ? convertSalariesByDateToJSON() : undefined, salaires_par_date: !isRegimeRG && salaryMode === "par_date" ? convertSalariesByDateToJSON() : undefined,
@ -1401,6 +1473,7 @@ useEffect(() => {
si_non_montant_par_panier: panierRepas === "Oui" && panierRepasCCN === "Non" && montantParPanier !== "" ? montantParPanier : undefined, si_non_montant_par_panier: panierRepas === "Oui" && panierRepasCCN === "Non" && montantParPanier !== "" ? montantParPanier : undefined,
reference, reference,
notes: notes || undefined, notes: notes || undefined,
send_email_confirmation: emailConfirm === "Oui",
valider_direct: validerDirect === "Oui", valider_direct: validerDirect === "Oui",
} as const; } as const;
@ -1454,6 +1527,7 @@ useEffect(() => {
heures_total: payload.heures_travail, heures_total: payload.heures_travail,
minutes_total: payload.minutes_travail, minutes_total: payload.minutes_travail,
jours_travail: payload.jours_travail, jours_travail: payload.jours_travail,
jours_travail_non_artiste: payload.jours_travail_non_artiste,
multi_mois: payload.multi_mois, multi_mois: payload.multi_mois,
salaires_par_date: payload.salaires_par_date, salaires_par_date: payload.salaires_par_date,
}) })
@ -2029,102 +2103,22 @@ useEffect(() => {
) : ( ) : (
// Contenu pour CDDU (existant) // Contenu pour CDDU (existant)
<> <>
<FieldRow> {/* Question multi-mois masquée - la logique reste active en arrière-plan */}
<div>
<Label required>Le contrat est-il multi-mois ?</Label>
<div className="flex items-center gap-6 mt-2">
<label className="inline-flex items-center gap-2 text-sm">
<input type="radio" checked={isMultiMois === "Oui"} onChange={() => setIsMultiMois("Oui")} />
Oui
</label>
<label className="inline-flex items-center gap-2 text-sm">
<input type="radio" checked={isMultiMois === "Non"} onChange={() => setIsMultiMois("Non")} />
Non
</label>
</div>
</div>
</FieldRow>
</> </>
)} )}
<FieldRow>
<div>
<Label required>Date de début du contrat</Label>
<input
type="date"
value={dateDebut}
onChange={(e) => {
const v = e.target.value;
setDateDebut(v);
setConfirmPastStart(false);
if (dateFin && v && dateFin < v) setDateFin(v);
}}
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
/>
</div>
{/* Masquer la date de fin si CDI en mode RG */}
{!(isRegimeRG && typeContratRG === "CDI") && (
<div>
<Label>Date de fin du contrat</Label>
<input
type="date"
value={dateFin}
min={dateDebut || undefined}
onChange={(e) => setDateFin(e.target.value)}
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
/>
<p className="text-[11px] text-slate-500 mt-1">Pour un contrat d'une journée, sélectionnez la même date que début.</p>
</div>
)}
</FieldRow>
{/* Sections spécifiques aux CDDU - masquées en mode RG */} {/* Sections spécifiques aux CDDU - masquées en mode RG */}
{!isRegimeRG && ( {!isRegimeRG && (
!useHeuresMode ? ( !useHeuresMode ? (
<> <>
<FieldRow>
<div>
<Label>Combien de représentations ?</Label>
<input
type="number"
min={0}
value={nbRep}
onChange={(e) => setNbRep(e.target.value === "" ? "" : Number(e.target.value))}
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
/>
</div>
<div>
<Label>Combien de services de répétition ? / Durée</Label>
<div className="flex gap-2 items-center">
<input
type="number"
min={0}
value={nbServ}
onChange={(e) => setNbServ(e.target.value === "" ? "" : Number(e.target.value))}
placeholder="Nombre"
className="flex-1 px-3 py-2 rounded-lg border bg-white text-sm"
/>
<span className="text-slate-400 font-medium">×</span>
{typeof nbServ === "number" && nbServ > 0 && (
<select
value={durationServices}
onChange={(e) => setDurationServices(e.target.value as "3" | "4")}
className="flex-1 px-3 py-2 rounded-lg border bg-white text-sm"
title="Durée des services de répétition"
>
<option value="3">3 heures</option>
<option value="4">4 heures</option>
</select>
)}
</div>
</div>
</FieldRow>
<FieldRow> <FieldRow>
<div> <div>
<Label>Indiquez les dates de représentations</Label> <Label>Indiquez les dates de représentations</Label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="flex-1 px-3 py-2 rounded-lg border bg-slate-50 text-sm text-slate-700 min-h-[42px] flex items-center"> <div
onClick={() => setDatesRepOpen(true)}
className="flex-1 px-3 py-2 rounded-lg border bg-slate-50 text-sm text-slate-700 min-h-[42px] flex items-center cursor-pointer hover:bg-slate-100 transition"
>
{datesRepDisplay || "Cliquez pour sélectionner…"} {datesRepDisplay || "Cliquez pour sélectionner…"}
</div> </div>
<button <button
@ -2139,7 +2133,10 @@ useEffect(() => {
<div> <div>
<Label>Indiquez les dates de répétitions</Label> <Label>Indiquez les dates de répétitions</Label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="flex-1 px-3 py-2 rounded-lg border bg-slate-50 text-sm text-slate-700 min-h-[42px] flex items-center"> <div
onClick={() => setDatesServOpen(true)}
className="flex-1 px-3 py-2 rounded-lg border bg-slate-50 text-sm text-slate-700 min-h-[42px] flex items-center cursor-pointer hover:bg-slate-100 transition"
>
{datesServDisplay || "Cliquez pour sélectionner…"} {datesServDisplay || "Cliquez pour sélectionner…"}
</div> </div>
<button <button
@ -2160,8 +2157,6 @@ useEffect(() => {
onApply={handleDatesRepApply} onApply={handleDatesRepApply}
initialDates={datesRep ? datesRep.split(", ") : []} initialDates={datesRep ? datesRep.split(", ") : []}
title="Sélectionner les dates de représentations" title="Sélectionner les dates de représentations"
minDate={dateDebut}
maxDate={dateFin}
/> />
<DatePickerCalendar <DatePickerCalendar
isOpen={datesServOpen} isOpen={datesServOpen}
@ -2169,11 +2164,136 @@ useEffect(() => {
onApply={handleDatesServApply} onApply={handleDatesServApply}
initialDates={datesServ ? datesServ.split(", ") : []} initialDates={datesServ ? datesServ.split(", ") : []}
title="Sélectionner les dates de répétitions" title="Sélectionner les dates de répétitions"
minDate={dateDebut}
maxDate={dateFin}
/> />
</> </>
) : ( ) : null
)}
{/* Sous-card avec les champs auto-remplis */}
<div className="bg-slate-50 rounded-lg p-4 border border-slate-200">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-indigo-500"></div>
<p className="text-xs font-medium text-slate-600">Se rempli automatiquement selon vos sélections de dates</p>
</div>
<button
type="button"
onClick={() => setManualDatesMode(!manualDatesMode)}
className="px-2 py-1 text-xs rounded border bg-white hover:bg-slate-50 transition"
>
{manualDatesMode ? "Mode automatique" : "Remplir manuellement"}
</button>
</div>
<FieldRow>
<div>
<Label required>Date de début du contrat</Label>
<input
type="date"
value={dateDebut}
onChange={(e) => {
if (manualDatesMode) {
const v = e.target.value;
setDateDebut(v);
setConfirmPastStart(false);
if (dateFin && v && dateFin < v) setDateFin(v);
}
}}
disabled={!manualDatesMode}
className={`w-full px-3 py-2 rounded-lg border text-sm ${
manualDatesMode
? 'bg-white text-slate-900'
: 'bg-slate-100 text-slate-600 cursor-not-allowed'
}`}
/>
</div>
{/* Masquer la date de fin si CDI en mode RG */}
{!(isRegimeRG && typeContratRG === "CDI") && (
<div>
<Label>Date de fin du contrat</Label>
<input
type="date"
value={dateFin}
min={dateDebut || undefined}
onChange={(e) => manualDatesMode && setDateFin(e.target.value)}
disabled={!manualDatesMode}
className={`w-full px-3 py-2 rounded-lg border text-sm ${
manualDatesMode
? 'bg-white text-slate-900'
: 'bg-slate-100 text-slate-600 cursor-not-allowed'
}`}
/>
</div>
)}
</FieldRow>
{/* Sections spécifiques aux CDDU - masquées en mode RG */}
{!isRegimeRG && !useHeuresMode && (
<FieldRow>
<div>
<Label>Combien de représentations ?</Label>
<input
type="number"
min={0}
value={nbRep}
onChange={(e) => manualDatesMode && setNbRep(e.target.value === "" ? "" : Number(e.target.value))}
disabled={!manualDatesMode}
className={`w-full px-3 py-2 rounded-lg border text-sm ${
manualDatesMode
? 'bg-white text-slate-900'
: 'bg-slate-100 text-slate-600 cursor-not-allowed'
}`}
/>
</div>
<div>
<Label>Combien de services de répétition ? / Durée</Label>
<div className="flex gap-2 items-center">
<input
type="number"
min={0}
value={nbServ}
onChange={(e) => manualDatesMode && setNbServ(e.target.value === "" ? "" : Number(e.target.value))}
disabled={!manualDatesMode}
placeholder="Nombre"
className={`flex-1 px-3 py-2 rounded-lg border text-sm ${
manualDatesMode
? 'bg-white text-slate-900'
: 'bg-slate-100 text-slate-600 cursor-not-allowed'
}`}
/>
<span className="text-slate-400 font-medium">×</span>
{typeof nbServ === "number" && nbServ > 0 && (
<select
value={durationServices}
onChange={(e) => manualDatesMode && setDurationServices(e.target.value as "3" | "4")}
disabled={!manualDatesMode}
className={`flex-1 px-3 py-2 rounded-lg border text-sm ${
manualDatesMode
? 'bg-white text-slate-900'
: 'bg-slate-100 text-slate-600 cursor-not-allowed'
}`}
title="Durée des services de répétition"
>
<option value="3">3 heures</option>
<option value="4">4 heures</option>
</select>
)}
</div>
</div>
</FieldRow>
)}
{/* Affichage du type de contrat */}
{!isRegimeRG && (
<p className="text-xs text-slate-500 mt-2">
Type : <span className="font-medium text-slate-700">{isMultiMois === "Oui" ? "CDDU multi-mois" : "CDDU mono-mois"}</span>
</p>
)}
</div>
{/* Sections spécifiques aux CDDU - masquées en mode RG */}
{!isRegimeRG && (
useHeuresMode ? (
<> <>
<FieldRow> <FieldRow>
<div> <div>
@ -2230,7 +2350,7 @@ useEffect(() => {
maxDate={dateFin} maxDate={dateFin}
/> />
</> </>
) ) : null
)} )}
</Section> </Section>

View file

@ -3,7 +3,7 @@
import { useEffect, useMemo, useState, useRef, useImperativeHandle, forwardRef } from "react"; import { useEffect, useMemo, useState, useRef, useImperativeHandle, forwardRef } from "react";
import { supabase } from "@/lib/supabaseClient"; import { supabase } from "@/lib/supabaseClient";
import Link from "next/link"; import Link from "next/link";
import { RefreshCw, Check, X, Settings, FileText, CheckCircle, BarChart3, Eye, ChevronDown, Trash2, FileDown, FileSignature, Euro, XCircle, BellRing, Clock, AlertCircle } from "lucide-react"; import { RefreshCw, Check, X, Settings, FileText, CheckCircle, BarChart3, Eye, ChevronDown, Trash2, FileDown, FileSignature, Euro, XCircle, BellRing, Clock, AlertCircle, Calendar } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import BulkPdfProgressModal from "./BulkPdfProgressModal"; import BulkPdfProgressModal from "./BulkPdfProgressModal";
import PdfVerificationModal from "./PdfVerificationModal"; import PdfVerificationModal from "./PdfVerificationModal";
@ -178,11 +178,15 @@ type Contract = {
last_employee_notification_at?: string | null; last_employee_notification_at?: string | null;
production_name?: string | null; production_name?: string | null;
analytique?: string | null; analytique?: string | null;
nombre_d_heures?: number | null;
n_objet?: string | null;
objet_spectacle?: string | null;
salaries?: { salaries?: {
salarie?: string | null; salarie?: string | null;
nom?: string | null; nom?: string | null;
prenom?: string | null; prenom?: string | null;
adresse_mail?: string | null; adresse_mail?: string | null;
code_salarie?: string | null;
} | null; } | null;
organizations?: { organizations?: {
organization_details?: { organization_details?: {
@ -300,6 +304,8 @@ function ContractsGridImpl({ initialData, activeOrgId }: { initialData: Contract
const [showESignMenu, setShowESignMenu] = useState(false); const [showESignMenu, setShowESignMenu] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false); const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showBulkPayslipModal, setShowBulkPayslipModal] = useState(false); const [showBulkPayslipModal, setShowBulkPayslipModal] = useState(false);
const [showJoursTechnicienModal, setShowJoursTechnicienModal] = useState(false);
const [showSignatureDateModal, setShowSignatureDateModal] = useState(false);
// Quick filter counts // Quick filter counts
const [countDpaeAFaire, setCountDpaeAFaire] = useState<number | null>(null); const [countDpaeAFaire, setCountDpaeAFaire] = useState<number | null>(null);
@ -1002,6 +1008,165 @@ function ContractsGridImpl({ initialData, activeOrgId }: { initialData: Contract
setShowActionMenu(false); setShowActionMenu(false);
}; };
// Fonction pour exporter les contrats sélectionnés en TSV (format sPAIEctacle)
const exportSelectedToTSV = async () => {
if (selectedContractIds.size === 0) {
toast.error("Aucun contrat sélectionné");
return;
}
const selectedRows = rows.filter(contract => selectedContractIds.has(contract.id));
// Charger les fichiers JSON de professions pour la correspondance
let professionsMap = new Map<string, string>();
try {
const [artistesRes, techniciensRes] = await Promise.all([
fetch('/data/professions-feminisations.json'),
fetch('/data/professions-techniciens.json')
]);
const artistes = await artistesRes.json();
const techniciens = await techniciensRes.json();
// Construire une map label -> code pour les artistes
artistes.forEach((prof: any) => {
if (prof.profession_label && prof.profession_code) {
professionsMap.set(prof.profession_label.toLowerCase(), prof.profession_code);
}
if (prof.profession_feminine && prof.profession_code) {
professionsMap.set(prof.profession_feminine.toLowerCase(), prof.profession_code);
}
});
// Construire une map label -> code pour les techniciens
techniciens.forEach((prof: any) => {
if (prof.label && prof.code) {
professionsMap.set(prof.label.toLowerCase(), prof.code);
}
});
} catch (error) {
console.error("Erreur lors du chargement des professions:", error);
toast.error("Erreur lors du chargement des professions");
return;
}
// En-têtes TSV (format sPAIEctacle)
const headers = [
"Code societe",
"Code contrat",
"Matricule",
"Categorie salariale",
"Code profession",
"Type de contrat",
"Debut de contrat",
"Fin de contrat",
"Code rubrique",
"Quantite rubrique",
"Base rubrique",
"Cout employeur",
"Compte analytique",
"Compte analytique multiple",
"Repartition analytique multiple",
"Numero d'objet",
"Date travaillee debut",
"Date travaillee fin",
"Contrat"
];
// Fonction pour formater une date en DD/MM/YYYY
const formatDateForTSV = (dateString: string | null | undefined): string => {
if (!dateString) return "";
try {
const date = new Date(dateString);
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
const year = date.getFullYear();
return `${day}/${month}/${year}`;
} catch {
return "";
}
};
// Fonction pour obtenir le code profession depuis le label
const getProfessionCode = (professionLabel: string | null | undefined): string => {
if (!professionLabel) return "";
const code = professionsMap.get(professionLabel.toLowerCase());
return code || "";
};
// Construire les lignes de données
const dataRows = selectedRows.map(contract => {
const codeEmployeur = contract.organizations?.organization_details?.code_employeur || "";
const contractNumber = contract.contract_number || "";
const matricule = contract.salaries?.code_salarie || contract.employee_id || "";
const categorieSalariale = "Cas General"; // Fixe
const codeProfession = getProfessionCode(contract.profession);
const typeContrat = "Intermittent"; // Fixe pour CDDU
const dateDebut = formatDateForTSV(contract.start_date);
const dateFin = formatDateForTSV(contract.end_date);
const codeRubrique = "He"; // Fixe
const quantiteRubrique = contract.nombre_d_heures ? contract.nombre_d_heures.toString() : ""; // Heures de travail au total
const baseRubrique = ""; // Vide
const coutEmployeur = contract.gross_pay ? contract.gross_pay.toFixed(2).replace('.', ',') : "";
const compteAnalytique = "COURSCLEMENT"; // Fixe pour l'instant
const compteAnalytiqueMultiple = ""; // Vide
const repartitionAnalytiqueMultiple = ""; // Vide
const numeroObjet = contract.n_objet || contract.objet_spectacle || ""; // Numéro d'objet de la production
const dateTravailleeDebut = ""; // Vide
const dateTravailleeFin = ""; // Vide
const contrat = contractNumber;
return [
codeEmployeur,
contractNumber,
matricule,
categorieSalariale,
codeProfession,
typeContrat,
dateDebut,
dateFin,
codeRubrique,
quantiteRubrique,
baseRubrique,
coutEmployeur,
compteAnalytique,
compteAnalytiqueMultiple,
repartitionAnalytiqueMultiple,
numeroObjet,
dateTravailleeDebut,
dateTravailleeFin,
contrat
];
});
// Construire le contenu TSV
const tsvContent = [
headers.join('\t'),
...dataRows.map(row => row.join('\t'))
].join('\n');
// Créer et télécharger le fichier
const blob = new Blob([tsvContent], { type: 'text/tab-separated-values;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
// Générer un nom de fichier avec la date
const now = new Date();
const dateStr = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`;
const filename = `export_contrats_spaiectacle_${dateStr}.tsv`;
link.setAttribute('href', url);
link.setAttribute('download', filename);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
toast.success(`${selectedRows.length} contrat(s) exporté(s) en TSV`);
setShowActionMenu(false);
};
// Fonction pour ouvrir le modal de confirmation // Fonction pour ouvrir le modal de confirmation
const handleBulkESignClick = () => { const handleBulkESignClick = () => {
if (selectedContractIds.size === 0) { if (selectedContractIds.size === 0) {
@ -2058,6 +2223,35 @@ function ContractsGridImpl({ initialData, activeOrgId }: { initialData: Contract
<Eye className="w-4 h-4" /> <Eye className="w-4 h-4" />
Voir les détails Voir les détails
</button> </button>
<button
onClick={() => {
exportSelectedToTSV();
}}
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"
>
<FileDown className="w-4 h-4" />
Exporter TSV (sPAIEctacle)
</button>
<button
onClick={() => {
setShowJoursTechnicienModal(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"
>
<Calendar className="w-4 h-4" />
Jours technicien
</button>
<button
onClick={() => {
setShowSignatureDateModal(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"
>
<FileSignature className="w-4 h-4" />
Modifier date de signature
</button>
<div className="border-t border-gray-200 my-1"></div> <div className="border-t border-gray-200 my-1"></div>
<button <button
onClick={() => { onClick={() => {
@ -2430,6 +2624,53 @@ function ContractsGridImpl({ initialData, activeOrgId }: { initialData: Contract
</div> </div>
)} )}
{/* Modal Action groupée Jours Technicien */}
{showJoursTechnicienModal && (
<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-2xl">
<h3 className="text-lg font-semibold mb-4">Action groupée - Jours Technicien</h3>
<p className="text-sm text-gray-600 mb-4">
Modifier les jours de travail pour {selectedContractIds.size} contrat{selectedContractIds.size > 1 ? 's' : ''}
</p>
<JoursTechnicienModal
selectedContracts={selectedContracts}
onClose={() => setShowJoursTechnicienModal(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, jours_travail: updated.jours_travail, jours_travail_non_artiste: updated.jours_travail_non_artiste } : row;
}));
setShowJoursTechnicienModal(false);
}}
/>
</div>
</div>
)}
{/* Modal Action groupée - Modifier date de signature */}
{showSignatureDateModal && (
<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 - Date de signature</h3>
<p className="text-sm text-gray-600 mb-4">
Modifier la date de signature pour {selectedContractIds.size} contrat{selectedContractIds.size > 1 ? 's' : ''}
</p>
<SignatureDateModal
selectedContracts={selectedContracts}
onClose={() => setShowSignatureDateModal(false)}
onSuccess={(updatedContracts) => {
setRows(prev => prev.map(row => {
const updated = updatedContracts.find(u => u.id === row.id);
return updated ? { ...row, date_signature: updated.date_signature } : row;
}));
setShowSignatureDateModal(false);
}}
/>
</div>
</div>
)}
{/* Modal Saisir brut */} {/* Modal Saisir brut */}
{showSalaryModal && ( {showSalaryModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
@ -3142,3 +3383,264 @@ function DeleteConfirmModal({
</> </>
); );
} }
// Modal pour les jours technicien
function JoursTechnicienModal({
selectedContracts,
onClose,
onSuccess
}: {
selectedContracts: Contract[];
onClose: () => void;
onSuccess: (contracts: { id: string; jours_travail: string; jours_travail_non_artiste: string }[]) => void;
}) {
const [joursTravailList, setJoursTravailList] = useState<string[]>(
selectedContracts.map(() => '')
);
const [loading, setLoading] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const handlePaste = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
e.preventDefault();
const pastedText = e.clipboardData.getData('text');
const lines = pastedText.split('\n').filter(line => line.trim() !== '');
// Distribuer les lignes collées aux contrats sélectionnés
const newList = [...joursTravailList];
lines.forEach((line, index) => {
if (index < newList.length) {
newList[index] = line.trim();
}
});
setJoursTravailList(newList);
};
const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const lines = e.target.value.split('\n');
const newList = lines.map(line => line.trim());
// Ajuster la taille du tableau pour correspondre au nombre de contrats
while (newList.length < selectedContracts.length) {
newList.push('');
}
setJoursTravailList(newList.slice(0, selectedContracts.length));
};
const handleSubmit = async () => {
setLoading(true);
try {
const updates = selectedContracts.map((contract, index) => ({
id: contract.id,
joursTravail: joursTravailList[index] || ''
}));
const response = await fetch('/api/staff/contracts/bulk-update-jours-technicien', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ updates })
});
if (!response.ok) throw new Error('Erreur lors de la mise à jour');
const result = await response.json();
onSuccess(result.contracts);
} catch (error) {
console.error('Erreur:', error);
alert('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">
Jours de travail (un par ligne)
</label>
<p className="text-xs text-gray-500 mb-2">
Collez les dates ligne par ligne. Chaque ligne correspond à un contrat dans l'ordre ci-dessous.
</p>
<textarea
ref={textareaRef}
value={joursTravailList.join('\n')}
onChange={handleTextareaChange}
onPaste={handlePaste}
placeholder="Exemple:&#10;01/01/2025, 02/01/2025&#10;03/01/2025, 04/01/2025&#10;..."
rows={Math.min(selectedContracts.length, 10)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono text-sm"
/>
</div>
<div className="border rounded-lg p-3 max-h-60 overflow-y-auto">
<p className="text-sm font-medium text-gray-700 mb-2">Contrats sélectionnés :</p>
{selectedContracts.map((contract, index) => (
<div key={contract.id} className="text-sm text-gray-600 py-1 flex items-start gap-2">
<span className="text-gray-400 min-w-[30px]">{index + 1}.</span>
<div className="flex-1">
<div className="font-medium">{contract.contract_number || contract.id} - {formatEmployeeName(contract)}</div>
{joursTravailList[index] && (
<div className="text-xs text-green-600 mt-1">
{joursTravailList[index]}
</div>
)}
</div>
</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={loading || joursTravailList.every(j => !j)}
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 modifier la date de signature en masse
function SignatureDateModal({
selectedContracts,
onClose,
onSuccess
}: {
selectedContracts: Contract[];
onClose: () => void;
onSuccess: (contracts: { id: string; date_signature: string | null }[]) => void;
}) {
const [lines, setLines] = useState<string[]>(selectedContracts.map(() => ''));
const [applyDate, setApplyDate] = useState<string>('');
const [loading, setLoading] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
// Resize initial lines to match contracts
setLines(prev => {
const copy = [...prev];
while (copy.length < selectedContracts.length) copy.push('');
return copy.slice(0, selectedContracts.length);
});
}, [selectedContracts.length]);
const handlePaste = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
e.preventDefault();
const pasted = e.clipboardData.getData('text');
const pastedLines = pasted.split('\n').map(l => l.trim()).filter(Boolean);
const newLines = [...lines];
pastedLines.forEach((l, i) => {
if (i < newLines.length) newLines[i] = l;
});
setLines(newLines);
};
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const vals = e.target.value.split('\n').map(l => l.trim());
while (vals.length < selectedContracts.length) vals.push('');
setLines(vals.slice(0, selectedContracts.length));
};
const handleApplyDateToAll = (d: string) => {
setApplyDate(d);
if (d) {
setLines(selectedContracts.map(() => d));
}
};
const handleSubmit = async () => {
setLoading(true);
try {
const updates = selectedContracts.map((c, idx) => ({ id: c.id, date: lines[idx] ? new Date(lines[idx]).toISOString().split('T')[0] : null }));
const res = await fetch('/api/staff/contracts/bulk-update-signature-date', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ updates })
});
if (!res.ok) throw new Error('Erreur lors de la mise à jour');
const json = await res.json();
onSuccess(json.contracts || []);
} catch (err) {
console.error(err);
toast.error('Erreur lors de la mise à jour des dates de signature');
} finally {
setLoading(false);
}
};
return (
<>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Date de signature</label>
<p className="text-xs text-gray-500 mb-2">Vous pouvez coller une liste de dates (une par ligne) ; chaque ligne correspond à un contrat dans l'ordre ci-dessous. Format recommandé : YYYY-MM-DD ou JJ/MM/YYYY.</p>
<div className="flex gap-2 mb-2">
<input
type="date"
value={applyDate}
onChange={e => handleApplyDateToAll(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-md"
/>
<div className="text-xs text-slate-500 self-center">ou collez ci-dessous</div>
</div>
<textarea
ref={textareaRef}
value={lines.join('\n')}
onChange={handleChange}
onPaste={handlePaste}
rows={Math.min(selectedContracts.length, 10)}
placeholder={`2025-01-01\n2025-02-03\n...`}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono text-sm"
/>
</div>
<div className="border rounded-lg p-3 max-h-60 overflow-y-auto">
<p className="text-sm font-medium text-gray-700 mb-2">Contrats sélectionnés :</p>
{selectedContracts.map((c, idx) => (
<div key={c.id} className="text-sm text-gray-600 py-1 flex items-start gap-2">
<span className="text-gray-400 min-w-[30px]">{idx + 1}.</span>
<div className="flex-1">
<div className="font-medium">{c.contract_number || c.id} - {formatEmployeeName(c)}</div>
{lines[idx] && (
<div className="text-xs text-green-600 mt-1">{lines[idx]}</div>
)}
</div>
</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={loading || lines.every(l => !l)}
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>
</>
);
}

View file

@ -0,0 +1,832 @@
// components/staff/PayslipDetailsModal.tsx
"use client";
import React, { useState, useEffect, useRef, DragEvent } from "react";
import { X, ChevronLeft, ChevronRight, FileText, Loader, Edit, Save, Upload, CheckCircle2, Loader2, ExternalLink, Trash2, RefreshCw } from "lucide-react";
import { toast } from "sonner";
type PayslipDetails = {
id: string;
contract_id: string;
period_start?: string | null;
period_end?: string | null;
period_month?: string | null;
pay_number?: number | null;
pay_date?: string | null;
gross_amount?: string | number | null;
net_amount?: string | number | null;
net_after_withholding?: string | number | null;
employer_cost?: string | number | null;
processed?: boolean | null;
aem_status?: string | null;
transfer_done?: boolean | null;
organization_id?: string | null;
created_at?: string | null;
storage_path?: string | null;
cddu_contracts?: {
id: string;
contract_number?: string | null;
employee_name?: string | null;
employee_id?: string | null;
structure?: string | null;
type_de_contrat?: string | null;
org_id?: string | null;
salaries?: {
salarie?: string | null;
nom?: string | null;
prenom?: string | null;
} | null;
} | null;
};
type PayslipDetailsModalProps = {
isOpen: boolean;
onClose: () => void;
payslipIds: string[];
payslips: any[];
onPayslipUpdated?: (updatedPayslip: any) => void;
};
// Utility function to format dates as DD/MM/YYYY
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 "—";
}
}
// Utility function to format currency
function formatCurrency(value: string | number | null | undefined): string {
if (!value) return "—";
const num = typeof value === 'string' ? parseFloat(value) : value;
if (isNaN(num)) return "—";
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(num);
}
// Utility function to format employee name
function formatEmployeeName(payslip: PayslipDetails): string {
const contract = payslip.cddu_contracts;
if (!contract) return "—";
// Priorité : utiliser salaries.salarie si disponible
if (contract.salaries?.salarie) {
return contract.salaries.salarie;
}
// Construire depuis nom + prénom
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(' ');
}
// Fallback : utiliser employee_name
if (contract.employee_name) {
const parts = contract.employee_name.trim().split(' ');
if (parts.length >= 2) {
const prenom = parts[0];
const nom = parts.slice(1).join(' ');
return `${nom.toUpperCase()} ${prenom}`;
}
return contract.employee_name;
}
return "—";
}
export default function PayslipDetailsModal({
isOpen,
onClose,
payslipIds,
payslips,
onPayslipUpdated
}: PayslipDetailsModalProps) {
const [currentIndex, setCurrentIndex] = useState(0);
const [payslipDetails, setPayslipDetails] = useState<PayslipDetails | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isEditMode, setIsEditMode] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [formData, setFormData] = useState({
gross_amount: "",
net_amount: "",
net_after_withholding: "",
employer_cost: "",
period_start: "",
period_end: "",
pay_date: "",
processed: false,
transfer_done: false,
aem_status: ""
});
// Document upload states
const [isDraggingDoc, setIsDraggingDoc] = useState(false);
const [isUploadingDoc, setIsUploadingDoc] = useState(false);
const [isOpeningDoc, setIsOpeningDoc] = useState(false);
const [isDeletingDoc, setIsDeletingDoc] = useState(false);
const fileInputRefDoc = useRef<HTMLInputElement | null>(null);
const currentPayslipId = payslipIds[currentIndex];
const currentPayslip = payslips.find(p => p.id === currentPayslipId);
// Fetch payslip details from API to get fresh data including storage_path
useEffect(() => {
if (!currentPayslipId) return;
const fetchPayslipDetails = async () => {
setIsLoading(true);
setError(null);
try {
const response = await fetch(`/api/staff/payslips/${currentPayslipId}`);
if (!response.ok) {
throw new Error('Erreur lors du chargement des détails');
}
const data = await response.json();
setPayslipDetails(data);
// Charger les données dans le formulaire
setFormData({
gross_amount: String(data.gross_amount || ""),
net_amount: String(data.net_amount || ""),
net_after_withholding: String(data.net_after_withholding || ""),
employer_cost: String(data.employer_cost || ""),
period_start: data.period_start?.slice(0, 10) || "",
period_end: data.period_end?.slice(0, 10) || "",
pay_date: data.pay_date?.slice(0, 10) || "",
processed: data.processed || false,
transfer_done: data.transfer_done || false,
aem_status: data.aem_status || ""
});
// Désactiver le mode édition lors du changement de paie
setIsEditMode(false);
} catch (err) {
console.error('Erreur fetch payslip:', err);
setError('Erreur lors du chargement des détails');
// Fallback sur les données des props si l'API échoue
if (currentPayslip) {
setPayslipDetails(currentPayslip);
setFormData({
gross_amount: String(currentPayslip.gross_amount || ""),
net_amount: String(currentPayslip.net_amount || ""),
net_after_withholding: String(currentPayslip.net_after_withholding || ""),
employer_cost: String(currentPayslip.employer_cost || ""),
period_start: currentPayslip.period_start?.slice(0, 10) || "",
period_end: currentPayslip.period_end?.slice(0, 10) || "",
pay_date: currentPayslip.pay_date?.slice(0, 10) || "",
processed: currentPayslip.processed || false,
transfer_done: currentPayslip.transfer_done || false,
aem_status: currentPayslip.aem_status || ""
});
setIsEditMode(false);
}
} finally {
setIsLoading(false);
}
};
fetchPayslipDetails();
}, [currentPayslipId, currentPayslip]);
// Fonction pour sauvegarder les modifications
const handleSave = async () => {
if (!payslipDetails) return;
setIsSaving(true);
try {
const response = await fetch(`/api/staff/payslips/${payslipDetails.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
gross_amount: parseFloat(formData.gross_amount) || null,
net_amount: parseFloat(formData.net_amount) || null,
net_after_withholding: parseFloat(formData.net_after_withholding) || null,
employer_cost: parseFloat(formData.employer_cost) || null,
period_start: formData.period_start || null,
period_end: formData.period_end || null,
pay_date: formData.pay_date || null,
processed: formData.processed,
transfer_done: formData.transfer_done,
aem_status: formData.aem_status || null
})
});
if (!response.ok) {
throw new Error('Erreur lors de la sauvegarde');
}
const updatedPayslip = await response.json();
// Mettre à jour les données affichées
setPayslipDetails(updatedPayslip);
setIsEditMode(false);
toast.success('Fiche de paie mise à jour avec succès');
// Notifier le parent pour mettre à jour la liste
if (onPayslipUpdated) {
onPayslipUpdated(updatedPayslip);
}
} catch (error) {
console.error('Erreur:', error);
toast.error('Erreur lors de la sauvegarde');
} finally {
setIsSaving(false);
}
};
// Document upload / open helpers
const handleDragEnterDoc = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDraggingDoc(true);
};
const handleDragLeaveDoc = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDraggingDoc(false);
};
const handleDragOverDoc = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
};
const handleDropDoc = async (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDraggingDoc(false);
const files = Array.from(e.dataTransfer.files);
if (files.length === 0) return;
const file = files[0];
if (file.type !== 'application/pdf') {
toast.error('Seuls les fichiers PDF sont acceptés');
return;
}
await uploadDocFile(file);
};
const handleFileSelectDoc = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
const file = files[0];
if (file.type !== 'application/pdf') {
toast.error('Seuls les fichiers PDF sont acceptés');
return;
}
await uploadDocFile(file);
};
const uploadDocFile = async (file: File) => {
if (!payslipDetails) return;
setIsUploadingDoc(true);
try {
const formData = new FormData();
formData.append('file', file);
formData.append('contract_id', payslipDetails.cddu_contracts?.id || '');
formData.append('payslip_id', payslipDetails.id);
const response = await fetch('/api/staff/payslip-upload', {
method: 'POST',
body: formData,
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.error || 'Erreur lors de l\'upload');
}
const result = await response.json();
// Mettre à jour l'état local et notifier le parent
const updated = { ...payslipDetails, storage_path: result.s3_key };
setPayslipDetails(updated as PayslipDetails);
if (onPayslipUpdated) onPayslipUpdated(updated);
toast.success('Bulletin de paie uploadé avec succès !');
} catch (error) {
console.error('Erreur upload:', error);
toast.error(error instanceof Error ? error.message : 'Erreur lors de l\'upload');
} finally {
setIsUploadingDoc(false);
if (fileInputRefDoc.current) fileInputRefDoc.current.value = '';
}
};
const handleOpenPdfDoc = async (e?: React.MouseEvent) => {
if (e) { e.preventDefault(); e.stopPropagation(); }
if (!payslipDetails?.storage_path || isOpeningDoc) return;
setIsOpeningDoc(true);
try {
const res = await fetch('/api/s3-presigned', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: payslipDetails.storage_path })
});
if (!res.ok) throw new Error('Erreur lors de la génération de l\'URL');
const data = await res.json();
window.open(data.url, '_blank');
} catch (error) {
console.error('Erreur ouverture PDF:', error);
toast.error('Erreur lors de l\'accès au fichier');
} finally {
setIsOpeningDoc(false);
}
};
const handleDeleteDoc = async () => {
if (!payslipDetails?.storage_path || isDeletingDoc) return;
if (!confirm('Êtes-vous sûr de vouloir supprimer ce document ? Cette action est irréversible.')) {
return;
}
setIsDeletingDoc(true);
try {
// Mettre à jour la fiche pour enlever le storage_path
const response = await fetch(`/api/staff/payslips/${payslipDetails.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ storage_path: null })
});
if (!response.ok) {
throw new Error('Erreur lors de la suppression');
}
// Mettre à jour l'état local
const updated = { ...payslipDetails, storage_path: null };
setPayslipDetails(updated as PayslipDetails);
if (onPayslipUpdated) onPayslipUpdated(updated);
toast.success('Document supprimé avec succès');
} catch (error) {
console.error('Erreur suppression:', error);
toast.error(error instanceof Error ? error.message : 'Erreur lors de la suppression');
} finally {
setIsDeletingDoc(false);
}
};
const handleReplaceDoc = () => {
if (fileInputRefDoc.current) {
fileInputRefDoc.current.click();
}
};
// Reset index when payslips change
useEffect(() => {
setCurrentIndex(0);
}, [payslipIds]);
// Keyboard navigation
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "ArrowLeft" && currentIndex > 0) {
e.preventDefault();
setCurrentIndex(prev => prev - 1);
} else if (e.key === "ArrowRight" && currentIndex < payslipIds.length - 1) {
e.preventDefault();
setCurrentIndex(prev => prev + 1);
} else if (e.key === "Escape") {
e.preventDefault();
onClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, currentIndex, payslipIds.length, onClose]);
if (!isOpen) return null;
const handlePrevious = () => {
if (currentIndex > 0) {
setCurrentIndex(prev => prev - 1);
}
};
const handleNext = () => {
if (currentIndex < payslipIds.length - 1) {
setCurrentIndex(prev => prev + 1);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
<div
className="relative w-full max-w-3xl max-h-[90vh] overflow-y-auto bg-white rounded-xl shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-6 py-4">
<div className="flex items-center gap-3">
<FileText className="w-6 h-6 text-slate-600" />
<div>
<h2 className="text-xl font-semibold text-slate-900">
Détails de la fiche de paie
</h2>
<p className="text-sm text-slate-600">
{currentIndex + 1} sur {payslipIds.length}
</p>
</div>
</div>
<button
onClick={onClose}
className="text-slate-400 hover:text-slate-600 transition-colors"
aria-label="Fermer"
>
<X className="w-6 h-6" />
</button>
</div>
{/* Navigation */}
{payslipIds.length > 1 && (
<div className="flex items-center justify-between border-b bg-slate-50 px-6 py-3">
<button
onClick={handlePrevious}
disabled={currentIndex === 0}
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-slate-700 bg-white rounded-lg border border-slate-300 hover:bg-slate-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<ChevronLeft className="w-4 h-4" />
Précédent
</button>
<span className="text-sm text-slate-600">
Navigation : /
</span>
<button
onClick={handleNext}
disabled={currentIndex === payslipIds.length - 1}
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-slate-700 bg-white rounded-lg border border-slate-300 hover:bg-slate-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Suivant
<ChevronRight className="w-4 h-4" />
</button>
</div>
)}
{/* Content */}
<div className="p-6 space-y-6">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader className="w-8 h-8 animate-spin text-indigo-600" />
</div>
) : error ? (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
{error}
</div>
) : payslipDetails ? (
<>
{/* Informations principales */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-slate-900 border-b pb-2">
Informations principales
</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-600 mb-1">
Salarié
</label>
<p className="text-base text-slate-900">
{formatEmployeeName(payslipDetails)}
</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-600 mb-1">
N° Contrat
</label>
<p className="text-base text-slate-900">
{payslipDetails.cddu_contracts?.contract_number || "—"}
</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-600 mb-1">
Période
</label>
<p className="text-base text-slate-900">
{formatDate(payslipDetails.period_start)} - {formatDate(payslipDetails.period_end)}
</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-600 mb-1">
Date de paiement
</label>
<p className="text-base text-slate-900">
{formatDate(payslipDetails.pay_date)}
</p>
</div>
</div>
</div>
{/* Montants */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-slate-900 border-b pb-2">
Montants
</h3>
{!isEditMode ? (
<div className="grid grid-cols-2 gap-4">
<div className="p-4 bg-blue-50 rounded-lg border border-blue-200">
<label className="block text-sm font-medium text-blue-800 mb-1">
Salaire brut
</label>
<p className="text-2xl font-bold text-blue-900">
{formatCurrency(payslipDetails.gross_amount)}
</p>
</div>
<div className="p-4 bg-orange-50 rounded-lg border border-orange-200">
<label className="block text-sm font-medium text-orange-800 mb-1">
Net avant PAS
</label>
<p className="text-2xl font-bold text-orange-900">
{formatCurrency(payslipDetails.net_amount)}
</p>
</div>
<div className="p-4 bg-green-50 rounded-lg border border-green-200">
<label className="block text-sm font-medium text-green-800 mb-1">
Net à payer
</label>
<p className="text-2xl font-bold text-green-900">
{formatCurrency(payslipDetails.net_after_withholding)}
</p>
</div>
<div className="p-4 bg-red-50 rounded-lg border border-red-200">
<label className="block text-sm font-medium text-red-800 mb-1">
Coût employeur
</label>
<p className="text-2xl font-bold text-red-900">
{formatCurrency(payslipDetails.employer_cost)}
</p>
</div>
</div>
) : (
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-600 mb-1">
Salaire brut
</label>
<input
type="number"
step="0.01"
value={formData.gross_amount}
onChange={(e) => setFormData({...formData, gross_amount: e.target.value})}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-600 mb-1">
Net avant PAS
</label>
<input
type="number"
step="0.01"
value={formData.net_amount}
onChange={(e) => setFormData({...formData, net_amount: e.target.value})}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-600 mb-1">
Net à payer
</label>
<input
type="number"
step="0.01"
value={formData.net_after_withholding}
onChange={(e) => setFormData({...formData, net_after_withholding: e.target.value})}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-600 mb-1">
Coût employeur
</label>
<input
type="number"
step="0.01"
value={formData.employer_cost}
onChange={(e) => setFormData({...formData, employer_cost: e.target.value})}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</div>
</div>
)}
</div>
{/* Documents */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-slate-900 border-b pb-2">Documents</h3>
{payslipDetails.storage_path ? (
<div className="p-4 bg-green-50 border border-green-200 rounded-lg space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="inline-flex items-center justify-center w-10 h-10 rounded-md bg-green-100 text-green-700">
<FileText className="w-5 h-5" />
</div>
<div>
<div className="text-sm font-medium text-slate-900">Bulletin de paie</div>
<div className="text-xs text-slate-600">Fichier disponible</div>
</div>
</div>
<button
onClick={handleOpenPdfDoc}
disabled={isOpeningDoc}
className="inline-flex items-center gap-2 px-3 py-2 text-sm font-medium text-green-700 bg-white border border-green-200 rounded-lg hover:bg-green-50 transition-colors disabled:opacity-50"
>
{isOpeningDoc ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Ouverture...
</>
) : (
<>
<ExternalLink className="w-4 h-4" />
Ouvrir
</>
)}
</button>
</div>
{/* Actions : Remplacer et Supprimer */}
<div className="flex items-center gap-2 pt-2 border-t border-green-200">
<button
onClick={handleReplaceDoc}
disabled={isUploadingDoc || isDeletingDoc}
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 transition-colors disabled:opacity-50"
>
<RefreshCw className="w-4 h-4" />
Remplacer
</button>
<button
onClick={handleDeleteDoc}
disabled={isUploadingDoc || isDeletingDoc}
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2 text-sm font-medium text-red-700 bg-white border border-red-300 rounded-lg hover:bg-red-50 transition-colors disabled:opacity-50"
>
{isDeletingDoc ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Suppression...
</>
) : (
<>
<Trash2 className="w-4 h-4" />
Supprimer
</>
)}
</button>
</div>
{/* Hidden file input for replace */}
<input
ref={fileInputRefDoc}
type="file"
accept="application/pdf"
onChange={handleFileSelectDoc}
className="hidden"
/>
</div>
) : (
<div
onDragEnter={handleDragEnterDoc}
onDragLeave={handleDragLeaveDoc}
onDragOver={handleDragOverDoc}
onDrop={handleDropDoc}
className={`border-2 border-dashed rounded-xl p-4 text-center transition-all ${isDraggingDoc ? 'border-blue-500 bg-blue-50' : 'border-gray-300 bg-gray-50 hover:border-blue-400 hover:bg-blue-50'}`}
onClick={() => fileInputRefDoc.current?.click()}
>
{isUploadingDoc ? (
<div className="flex flex-col items-center gap-2">
<Loader2 className="w-6 h-6 text-blue-500 animate-spin" />
<p className="text-sm text-blue-600 font-medium">Upload en cours...</p>
</div>
) : (
<div className="flex flex-col items-center gap-2">
<Upload className="w-6 h-6 text-gray-400" />
<p className="text-sm text-gray-600">
<span className="font-medium text-blue-600">Cliquez</span> ou glissez un PDF ici
</p>
<p className="text-xs text-gray-500">Bulletin de paie au format PDF</p>
</div>
)}
<input
ref={fileInputRefDoc}
type="file"
accept="application/pdf"
onChange={handleFileSelectDoc}
className="hidden"
/>
</div>
)}
</div>
{/* Statuts */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-slate-900 border-b pb-2">
Statuts
</h3>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-slate-600 mb-1">
Traité
</label>
<p className={`text-base font-medium ${payslipDetails.processed ? 'text-green-600' : 'text-orange-600'}`}>
{payslipDetails.processed ? 'Oui' : 'Non'}
</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-600 mb-1">
Virement effectué
</label>
<p className={`text-base font-medium ${payslipDetails.transfer_done ? 'text-green-600' : 'text-orange-600'}`}>
{payslipDetails.transfer_done ? 'Oui' : 'Non'}
</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-600 mb-1">
Statut AEM
</label>
<p className="text-base text-slate-900">
{payslipDetails.aem_status || "—"}
</p>
</div>
</div>
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-3 pt-4 border-t">
{!isEditMode ? (
<button
onClick={() => setIsEditMode(true)}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition-colors"
>
<Edit className="w-4 h-4" />
Modifier la fiche de paie
</button>
) : (
<>
<button
onClick={() => {
setIsEditMode(false);
// Restaurer les données originales
setFormData({
gross_amount: String(payslipDetails.gross_amount || ""),
net_amount: String(payslipDetails.net_amount || ""),
net_after_withholding: String(payslipDetails.net_after_withholding || ""),
employer_cost: String(payslipDetails.employer_cost || ""),
period_start: payslipDetails.period_start?.slice(0, 10) || "",
period_end: payslipDetails.period_end?.slice(0, 10) || "",
pay_date: payslipDetails.pay_date?.slice(0, 10) || "",
processed: payslipDetails.processed || false,
transfer_done: payslipDetails.transfer_done || false,
aem_status: payslipDetails.aem_status || ""
});
}}
disabled={isSaving}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 transition-colors disabled:opacity-50"
>
Annuler
</button>
<button
onClick={handleSave}
disabled={isSaving}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50"
>
<Save className="w-4 h-4" />
{isSaving ? 'Sauvegarde...' : 'Sauvegarder'}
</button>
</>
)}
</div>
</>
) : (
<div className="text-center py-12 text-slate-500">
Aucune donnée disponible
</div>
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,304 @@
// components/staff/PayslipPdfVerificationModal.tsx
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { X, ChevronLeft, ChevronRight, FileText, AlertCircle, CheckCircle, Download, ExternalLink } from "lucide-react";
type PayslipPdf = {
id: string;
contractNumber?: string;
employeeName?: string;
pdfUrl?: string;
hasError: boolean;
errorMessage?: string;
};
type PayslipPdfVerificationModalProps = {
isOpen: boolean;
onClose: () => void;
payslips: PayslipPdf[];
isLoading: boolean;
};
export default function PayslipPdfVerificationModal({
isOpen,
onClose,
payslips,
isLoading
}: PayslipPdfVerificationModalProps) {
const [currentIndex, setCurrentIndex] = useState(0);
const [pdfError, setPdfError] = useState(false);
const currentPayslip = payslips[currentIndex];
const hasPayslips = payslips.length > 0;
// Reset index when payslips change
useEffect(() => {
setCurrentIndex(0);
setPdfError(false);
}, [payslips]);
// Keyboard navigation
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
e.preventDefault();
goToPrevious();
} else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
e.preventDefault();
goToNext();
} else if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, currentIndex, payslips.length]);
const goToPrevious = useCallback(() => {
if (payslips.length === 0) return;
setCurrentIndex(prev => prev > 0 ? prev - 1 : payslips.length - 1);
setPdfError(false);
}, [payslips.length]);
const goToNext = useCallback(() => {
if (payslips.length === 0) return;
setCurrentIndex(prev => prev < payslips.length - 1 ? prev + 1 : 0);
setPdfError(false);
}, [payslips.length]);
const goToIndex = useCallback((index: number) => {
setCurrentIndex(index);
setPdfError(false);
}, []);
const handlePdfError = useCallback(() => {
setPdfError(true);
}, []);
const openPdfInNewTab = useCallback(() => {
if (currentPayslip?.pdfUrl) {
window.open(currentPayslip.pdfUrl, '_blank');
}
}, [currentPayslip?.pdfUrl]);
const downloadPdf = useCallback(() => {
if (currentPayslip?.pdfUrl) {
const link = document.createElement('a');
link.href = currentPayslip.pdfUrl;
link.download = `fiche_paie_${currentPayslip.contractNumber || currentPayslip.id}.pdf`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
}, [currentPayslip]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg w-full h-full max-w-7xl max-h-[95vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b bg-gray-50">
<div className="flex items-center gap-3">
<FileText className="size-5 text-blue-600" />
<div>
<h2 className="text-lg font-semibold">Vérification des Fiches de Paie</h2>
{hasPayslips && (
<p className="text-sm text-gray-600">
{currentIndex + 1} sur {payslips.length} fiches de paie
</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
{currentPayslip && !currentPayslip.hasError && currentPayslip.pdfUrl && (
<>
<button
onClick={downloadPdf}
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
title="Télécharger le PDF"
>
<Download className="size-4" />
</button>
<button
onClick={openPdfInNewTab}
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
title="Ouvrir dans un nouvel onglet"
>
<ExternalLink className="size-4" />
</button>
</>
)}
<button
onClick={onClose}
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
>
<X className="size-5" />
</button>
</div>
</div>
<div className="flex flex-1 overflow-hidden">
{/* Sidebar with payslip list */}
<div className="w-80 border-r bg-gray-50 flex flex-col">
<div className="p-3 border-b bg-white">
<h3 className="font-medium text-sm text-gray-700">Liste des fiches de paie</h3>
</div>
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="p-4 text-center text-gray-500">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mx-auto mb-2"></div>
Chargement des PDFs...
</div>
) : payslips.length === 0 ? (
<div className="p-4 text-center text-gray-500">
<AlertCircle className="size-8 mx-auto mb-2 text-gray-400" />
<p className="text-sm">Aucune fiche de paie sélectionnée</p>
</div>
) : (
<div className="space-y-1 p-2">
{payslips.map((payslip, index) => (
<button
key={payslip.id}
onClick={() => goToIndex(index)}
className={`w-full text-left p-3 rounded-lg transition-colors ${
index === currentIndex
? 'bg-blue-100 border border-blue-200'
: 'hover:bg-gray-100'
}`}
>
<div className="flex items-center gap-2">
<div className="flex-shrink-0">
{payslip.hasError ? (
<AlertCircle className="size-4 text-red-500" />
) : payslip.pdfUrl ? (
<CheckCircle className="size-4 text-green-500" />
) : (
<div className="size-4 rounded-full border-2 border-gray-300"></div>
)}
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-sm truncate">
{payslip.contractNumber || `Paie ${index + 1}`}
</div>
<div className="text-xs text-gray-500 truncate">
{payslip.employeeName || 'Nom non disponible'}
</div>
{payslip.hasError && (
<div className="text-xs text-red-500 truncate">
{payslip.errorMessage || 'Erreur PDF'}
</div>
)}
</div>
</div>
</button>
))}
</div>
)}
</div>
</div>
{/* PDF Viewer */}
<div className="flex-1 flex flex-col">
{isLoading ? (
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">Chargement des PDFs...</p>
</div>
</div>
) : !hasPayslips ? (
<div className="flex-1 flex items-center justify-center">
<div className="text-center text-gray-500">
<FileText className="size-16 mx-auto mb-4 text-gray-300" />
<p className="text-lg font-medium mb-2">Aucune fiche de paie sélectionnée</p>
<p className="text-sm">Sélectionnez des fiches de paie pour vérifier leurs PDFs</p>
</div>
</div>
) : currentPayslip?.hasError ? (
<div className="flex-1 flex items-center justify-center">
<div className="text-center text-red-500">
<AlertCircle className="size-16 mx-auto mb-4" />
<p className="text-lg font-medium mb-2">PDF non disponible</p>
<p className="text-sm">{currentPayslip.errorMessage || 'Le PDF de cette fiche de paie n\'a pas pu être chargé'}</p>
</div>
</div>
) : pdfError ? (
<div className="flex-1 flex items-center justify-center">
<div className="text-center text-red-500">
<AlertCircle className="size-16 mx-auto mb-4" />
<p className="text-lg font-medium mb-2">Erreur de chargement</p>
<p className="text-sm">Impossible d'afficher ce PDF</p>
<button
onClick={openPdfInNewTab}
className="mt-3 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Ouvrir dans un nouvel onglet
</button>
</div>
</div>
) : currentPayslip?.pdfUrl ? (
<div className="flex-1 relative">
<iframe
src={currentPayslip.pdfUrl}
className="w-full h-full border-0"
onError={handlePdfError}
title={`PDF de la fiche de paie ${currentPayslip.contractNumber || currentPayslip.id}`}
/>
</div>
) : (
<div className="flex-1 flex items-center justify-center">
<div className="text-center text-gray-500">
<FileText className="size-16 mx-auto mb-4 text-gray-300" />
<p className="text-lg font-medium mb-2">PDF non uploadé</p>
<p className="text-sm">Cette fiche de paie n'a pas encore de PDF</p>
</div>
</div>
)}
{/* Navigation controls */}
{hasPayslips && (
<div className="border-t bg-gray-50 p-3">
<div className="flex items-center justify-between">
<button
onClick={goToPrevious}
disabled={payslips.length <= 1}
className="flex items-center gap-2 px-3 py-2 bg-white border rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<ChevronLeft className="size-4" />
Précédent
</button>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600">
{currentIndex + 1} / {payslips.length}
</span>
<div className="text-xs text-gray-500">
(Utilisez pour naviguer)
</div>
</div>
<button
onClick={goToNext}
disabled={payslips.length <= 1}
className="flex items-center gap-2 px-3 py-2 bg-white border rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Suivant
<ChevronRight className="size-4" />
</button>
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
}

View file

@ -3,9 +3,11 @@
import { useEffect, useMemo, useState, useRef } from "react"; import { useEffect, useMemo, useState, useRef } from "react";
import { supabase } from "@/lib/supabaseClient"; import { supabase } from "@/lib/supabaseClient";
import Link from "next/link"; import Link from "next/link";
import { RefreshCw, Check, X } from "lucide-react"; import { RefreshCw, Check, X, Eye } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import BulkPayslipUploadModal from "./payslips/BulkPayslipUploadModal"; import BulkPayslipUploadModal from "./payslips/BulkPayslipUploadModal";
import PayslipDetailsModal from "./PayslipDetailsModal";
import PayslipPdfVerificationModal from "./PayslipPdfVerificationModal";
// Utility function to format dates as DD/MM/YYYY // Utility function to format dates as DD/MM/YYYY
function formatDate(dateString: string | null | undefined): string { function formatDate(dateString: string | null | undefined): string {
@ -30,6 +32,20 @@ function formatCurrency(value: string | number | null | undefined): string {
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(num); return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(num);
} }
// Utility function to format period as text (e.g., "Oct. 2025")
function formatPeriodText(periodStart: string | null | undefined): string {
if (!periodStart) return "—";
try {
const date = new Date(periodStart);
const monthNames = ["Jan.", "Fév.", "Mars", "Avr.", "Mai", "Juin", "Juil.", "Août", "Sept.", "Oct.", "Nov.", "Déc."];
const month = monthNames[date.getMonth()];
const year = date.getFullYear();
return `${month} ${year}`;
} catch {
return "—";
}
}
// Utility function to format employee name // Utility function to format employee name
function formatEmployeeName(payslip: { cddu_contracts?: any }): string { function formatEmployeeName(payslip: { cddu_contracts?: any }): string {
const contract = payslip.cddu_contracts; const contract = payslip.cddu_contracts;
@ -97,6 +113,7 @@ type Payslip = {
aem_status?: string | null; aem_status?: string | null;
transfer_done?: boolean | null; transfer_done?: boolean | null;
organization_id?: string | null; organization_id?: string | null;
storage_path?: string | null;
created_at?: string | null; created_at?: string | null;
cddu_contracts?: { cddu_contracts?: {
id: string; id: string;
@ -111,6 +128,11 @@ type Payslip = {
nom?: string | null; nom?: string | null;
prenom?: string | null; prenom?: string | null;
} | null; } | null;
organizations?: {
organization_details?: {
code_employeur?: string | null;
} | null;
} | null;
} | null; } | null;
}; };
@ -176,6 +198,126 @@ export default function PayslipsGrid({ initialData, activeOrgId }: { initialData
const [showDeleteModal, setShowDeleteModal] = useState(false); const [showDeleteModal, setShowDeleteModal] = useState(false);
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const [showBulkUploadModal, setShowBulkUploadModal] = useState(false); const [showBulkUploadModal, setShowBulkUploadModal] = useState(false);
const [showDetailsModal, setShowDetailsModal] = useState(false);
const [payslipDetailsIds, setPayslipDetailsIds] = useState<string[]>([]);
const [showPdfVerificationModal, setShowPdfVerificationModal] = useState(false);
const [payslipsPdfs, setPayslipsPdfs] = useState<Array<{
id: string;
contractNumber?: string;
employeeName?: string;
pdfUrl?: string;
hasError: boolean;
errorMessage?: string;
}>>([]);
const [isLoadingPdfs, setIsLoadingPdfs] = useState(false);
// Handler pour mettre à jour une paie après édition
const handlePayslipUpdated = (updatedPayslip: any) => {
setRows((currentRows) =>
currentRows.map((row) =>
row.id === updatedPayslip.id ? { ...row, ...updatedPayslip } : row
)
);
};
// Fonction pour vérifier les PDFs des fiches de paie sélectionnées
const verifySelectedPdfs = async () => {
if (selectedPayslipIds.size === 0) {
toast.error("Aucune fiche de paie sélectionnée");
return;
}
const payslipIds = Array.from(selectedPayslipIds);
// Ouvrir le modal et commencer le chargement
setShowPdfVerificationModal(true);
setIsLoadingPdfs(true);
setPayslipsPdfs([]);
try {
// Récupérer les URLs des PDFs pour chaque fiche de paie
const pdfPromises = payslipIds.map(async (payslipId) => {
const payslip = rows.find(r => r.id === payslipId);
try {
// Si pas de storage_path, retourner une erreur
if (!payslip?.storage_path) {
return {
id: payslipId,
contractNumber: payslip?.cddu_contracts?.contract_number || undefined,
employeeName: formatEmployeeName(payslip as any),
pdfUrl: undefined,
hasError: true,
errorMessage: 'PDF non uploadé'
};
}
// Générer l'URL presignée
const response = await fetch('/api/s3-presigned', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: payslip.storage_path })
});
if (response.ok) {
const result = await response.json();
return {
id: payslipId,
contractNumber: payslip?.cddu_contracts?.contract_number || undefined,
employeeName: formatEmployeeName(payslip as any),
pdfUrl: result.url,
hasError: false
};
} else {
const errorData = await response.json();
return {
id: payslipId,
contractNumber: payslip?.cddu_contracts?.contract_number || undefined,
employeeName: formatEmployeeName(payslip as any),
pdfUrl: undefined,
hasError: true,
errorMessage: errorData.error || 'PDF non trouvé'
};
}
} catch (error) {
return {
id: payslipId,
contractNumber: payslip?.cddu_contracts?.contract_number || undefined,
employeeName: formatEmployeeName(payslip as any),
pdfUrl: undefined,
hasError: true,
errorMessage: 'Erreur de réseau'
};
}
});
const pdfs = await Promise.all(pdfPromises);
setPayslipsPdfs(pdfs);
const successCount = pdfs.filter(pdf => !pdf.hasError).length;
const errorCount = pdfs.filter(pdf => pdf.hasError).length;
if (successCount > 0) {
toast.success(`${successCount} PDF${successCount > 1 ? 's' : ''} chargé${successCount > 1 ? 's' : ''}`);
}
if (errorCount > 0) {
toast.warning(`${errorCount} PDF${errorCount > 1 ? 's' : ''} non disponible${errorCount > 1 ? 's' : ''}`);
}
} catch (error) {
console.error("Erreur lors du chargement des PDFs:", error);
toast.error("Erreur lors du chargement des PDFs");
} finally {
setIsLoadingPdfs(false);
}
};
// Fonction pour fermer le modal de vérification PDF
const closePdfVerificationModal = () => {
setShowPdfVerificationModal(false);
setPayslipsPdfs([]);
setIsLoadingPdfs(false);
};
// Save filters to localStorage whenever they change // Save filters to localStorage whenever they change
useEffect(() => { useEffect(() => {
@ -574,6 +716,18 @@ export default function PayslipsGrid({ initialData, activeOrgId }: { initialData
/> />
<div className="absolute right-0 mt-1 w-48 bg-white rounded-md shadow-lg z-20 border border-gray-200"> <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"> <div className="py-1">
<button
onClick={() => {
setPayslipDetailsIds(Array.from(selectedPayslipIds));
setShowDetailsModal(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"
>
<Eye className="w-4 h-4" />
Voir le détail
</button>
<div className="border-t border-gray-200 my-1"></div>
<button <button
onClick={() => { onClick={() => {
setShowProcessedModal(true); setShowProcessedModal(true);
@ -610,6 +764,17 @@ export default function PayslipsGrid({ initialData, activeOrgId }: { initialData
> >
Ajouter documents Ajouter documents
</button> </button>
<button
onClick={() => {
verifySelectedPdfs();
setShowActionMenu(false);
}}
disabled={isLoadingPdfs}
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors disabled:text-gray-400 disabled:hover:bg-white flex items-center gap-2"
>
<Eye className="w-4 h-4" />
{isLoadingPdfs ? "Chargement..." : "Vérifier PDFs"}
</button>
<div className="border-t border-gray-200 my-1"></div> <div className="border-t border-gray-200 my-1"></div>
<button <button
onClick={() => { onClick={() => {
@ -638,7 +803,7 @@ export default function PayslipsGrid({ initialData, activeOrgId }: { initialData
)} )}
<div className="overflow-auto"> <div className="overflow-auto">
<table className="w-full text-sm"> <table className="w-full text-xs">
<thead className="bg-slate-50 text-slate-600"> <thead className="bg-slate-50 text-slate-600">
<tr> <tr>
<th className="text-left px-3 py-2 w-12"> <th className="text-left px-3 py-2 w-12">
@ -658,24 +823,30 @@ export default function PayslipsGrid({ initialData, activeOrgId }: { initialData
<th className="text-left px-3 py-2 cursor-pointer" onClick={() => { setSortField('contract_number'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}> <th className="text-left px-3 py-2 cursor-pointer" onClick={() => { setSortField('contract_number'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}>
N° contrat {sortField === 'contract_number' ? (sortOrder === 'asc' ? '▲' : '▼') : ''} N° contrat {sortField === 'contract_number' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</th> </th>
<th className="text-left px-3 py-2 cursor-pointer" onClick={() => { setSortField('pay_number'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}>
N° Paie {sortField === 'pay_number' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</th>
<th className="text-left px-3 py-2 cursor-pointer" onClick={() => { setSortField('employee_name'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}> <th className="text-left px-3 py-2 cursor-pointer" onClick={() => { setSortField('employee_name'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}>
Salarié {sortField === 'employee_name' ? (sortOrder === 'asc' ? '▲' : '▼') : ''} Salarié {sortField === 'employee_name' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</th> </th>
<th className="text-left px-3 py-2 cursor-pointer" onClick={() => { setSortField('structure'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}> <th className="text-left px-3 py-2 cursor-pointer" onClick={() => { setSortField('structure'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}>
Structure {sortField === 'structure' ? (sortOrder === 'asc' ? '▲' : '▼') : ''} Structure {sortField === 'structure' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</th> </th>
<th className="text-left px-3 py-2 cursor-pointer" onClick={() => { setSortField('period_start'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}> <th className="text-left px-3 py-2 cursor-pointer whitespace-nowrap" onClick={() => { setSortField('period_start'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}>
Période {sortField === 'period_start' ? (sortOrder === 'asc' ? '▲' : '▼') : ''} Période {sortField === 'period_start' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</th> </th>
<th className="text-left px-3 py-2 cursor-pointer" onClick={() => { setSortField('pay_number'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}>
N° Paie {sortField === 'pay_number' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</th>
<th className="text-right px-3 py-2 cursor-pointer" onClick={() => { setSortField('gross_amount'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}> <th className="text-right px-3 py-2 cursor-pointer" onClick={() => { setSortField('gross_amount'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}>
Brut {sortField === 'gross_amount' ? (sortOrder === 'asc' ? '▲' : '▼') : ''} Brut {sortField === 'gross_amount' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</th> </th>
<th className="text-right px-3 py-2 cursor-pointer" onClick={() => { setSortField('net_amount'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}>
Net avant PAS {sortField === 'net_amount' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</th>
<th className="text-right px-3 py-2 cursor-pointer" onClick={() => { setSortField('net_after_withholding'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}> <th className="text-right px-3 py-2 cursor-pointer" onClick={() => { setSortField('net_after_withholding'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}>
Net à payer {sortField === 'net_after_withholding' ? (sortOrder === 'asc' ? '▲' : '▼') : ''} Net à payer {sortField === 'net_after_withholding' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</th> </th>
<th className="text-right px-3 py-2 cursor-pointer" onClick={() => { setSortField('employer_cost'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}>
Coût employeur {sortField === 'employer_cost' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -694,16 +865,16 @@ export default function PayslipsGrid({ initialData, activeOrgId }: { initialData
</td> </td>
<td className="px-3 py-2"> <td className="px-3 py-2">
{r.processed ? ( {r.processed ? (
<Check className="size-4 text-green-600" strokeWidth={3} /> <Check className="w-4 h-4 text-green-600" strokeWidth={3} />
) : ( ) : (
<X className="size-4 text-orange-600" strokeWidth={3} /> <X className="w-4 h-4 text-orange-600" strokeWidth={3} />
)} )}
</td> </td>
<td className="px-3 py-2"> <td className="px-3 py-2">
{r.transfer_done ? ( {r.transfer_done ? (
<Check className="size-4 text-green-600" strokeWidth={3} /> <Check className="w-4 h-4 text-green-600" strokeWidth={3} />
) : ( ) : (
<X className="size-4 text-orange-600" strokeWidth={3} /> <X className="w-4 h-4 text-orange-600" strokeWidth={3} />
)} )}
</td> </td>
<td className="px-3 py-2"> <td className="px-3 py-2">
@ -718,9 +889,9 @@ export default function PayslipsGrid({ initialData, activeOrgId }: { initialData
const status = r.aem_status || "À traiter"; const status = r.aem_status || "À traiter";
if (status === "Traité") { if (status === "Traité") {
return <Check className="size-4 text-green-600" strokeWidth={3} />; return <Check className="w-4 h-4 text-green-600" strokeWidth={3} />;
} else { } else {
return <X className="size-4 text-orange-600" strokeWidth={3} />; return <X className="w-4 h-4 text-orange-600" strokeWidth={3} />;
} }
})()} })()}
</td> </td>
@ -734,17 +905,18 @@ export default function PayslipsGrid({ initialData, activeOrgId }: { initialData
> >
{r.cddu_contracts?.contract_number || "—"} {r.cddu_contracts?.contract_number || "—"}
</td> </td>
<td className="px-3 py-2">{formatEmployeeName(r)}</td>
<td className="px-3 py-2">{r.cddu_contracts?.structure || "—"}</td>
<td className="px-3 py-2">
{r.period_start && r.period_end ?
`${formatDate(r.period_start)} - ${formatDate(r.period_end)}` :
(r.period_month ? formatDate(r.period_month) : "—")
}
</td>
<td className="px-3 py-2">{r.pay_number ?? "—"}</td> <td className="px-3 py-2">{r.pay_number ?? "—"}</td>
<td className="px-3 py-2">{formatEmployeeName(r)}</td>
<td className="px-3 py-2">
{r.cddu_contracts?.organizations?.organization_details?.code_employeur || "—"}
</td>
<td className="px-3 py-2 whitespace-nowrap">
{formatPeriodText(r.period_start)}
</td>
<td className="px-3 py-2 text-right">{formatCurrency(r.gross_amount)}</td> <td className="px-3 py-2 text-right">{formatCurrency(r.gross_amount)}</td>
<td className="px-3 py-2 text-right">{formatCurrency(r.net_amount)}</td>
<td className="px-3 py-2 text-right">{formatCurrency(r.net_after_withholding)}</td> <td className="px-3 py-2 text-right">{formatCurrency(r.net_after_withholding)}</td>
<td className="px-3 py-2 text-right">{formatCurrency(r.employer_cost)}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@ -903,6 +1075,23 @@ export default function PayslipsGrid({ initialData, activeOrgId }: { initialData
}} }}
/> />
)} )}
{/* Modal Détails des paies */}
<PayslipDetailsModal
isOpen={showDetailsModal}
onClose={() => setShowDetailsModal(false)}
payslipIds={payslipDetailsIds}
payslips={rows}
onPayslipUpdated={handlePayslipUpdated}
/>
{/* Modal de vérification des PDFs */}
<PayslipPdfVerificationModal
isOpen={showPdfVerificationModal}
onClose={closePdfVerificationModal}
payslips={payslipsPdfs}
isLoading={isLoadingPdfs}
/>
</div> </div>
); );
} }

View file

@ -446,6 +446,36 @@ export async function sendTwoFaEnabledEmail(
}); });
} }
/**
* Migration helper pour les emails de demande de mandat SEPA
*/
export async function sendSepaMandateRequestEmail(
toEmail: string,
ccEmail: string | null,
data: {
firstName: string;
organizationName: string;
employerCode?: string;
mandateLink: string;
}
) {
const emailData: EmailDataV2 = {
firstName: data.firstName,
organizationName: data.organizationName,
employerCode: data.employerCode,
handlerName: 'Renaud BREVIERE-ABRAHAM',
ctaUrl: data.mandateLink
};
await sendUniversalEmailV2({
type: 'sepa-mandate-request',
toEmail,
ccEmail: ccEmail || undefined,
subject: 'Signez votre mandat de prélèvement SEPA',
data: emailData
});
}
/** /**
* Support: Notification interne de création de ticket * Support: Notification interne de création de ticket
*/ */

View file

@ -43,6 +43,7 @@ export type EmailTypeV2 =
| 'salary-transfer-payment-confirmation' // Nouveau type pour notification de paiement effectué | 'salary-transfer-payment-confirmation' // Nouveau type pour notification de paiement effectué
| 'contribution-notification' // Nouveau type pour notification de cotisations | 'contribution-notification' // Nouveau type pour notification de cotisations
| 'production-declared' // Nouveau type pour notification de déclaration de production | 'production-declared' // Nouveau type pour notification de déclaration de production
| 'sepa-mandate-request' // Nouveau type pour demande de signature de mandat SEPA
| 'notification' | 'notification'
// Support // Support
| 'support-reply' // Réponse du staff à un ticket support | 'support-reply' // Réponse du staff à un ticket support
@ -117,6 +118,7 @@ interface EmailTemplateV2 {
greeting?: string; greeting?: string;
closingMessage?: string; closingMessage?: string;
ctaText?: string; ctaText?: string;
ctaSubtext?: string; // Texte sous le bouton CTA
ctaUrl?: string; // URL du bouton CTA ctaUrl?: string; // URL du bouton CTA
footerText: string; footerText: string;
preheaderText: string; preheaderText: string;
@ -990,6 +992,33 @@ const EMAIL_TEMPLATES_V2: Record<EmailTypeV2, EmailTemplateV2> = {
} }
}, },
'sepa-mandate-request': {
subject: 'Signez votre mandat de prélèvement SEPA',
title: 'Signature du mandat de prélèvement SEPA',
greeting: '{{#if firstName}}Bonjour {{firstName}} !{{/if}}',
mainMessage: 'Toute l\'équipe Odentas vous souhaite la bienvenue et vous remercie pour votre confiance.<br><br>Nous vous invitons dès à présent à signer votre mandat de prélèvement SEPA en cliquant sur le bouton ci-dessous.<br><br>Vos factures sont directement prélevées sur le compte bancaire de votre structure, au même titre que les cotisations des caisses et organismes.<br><br>Vous êtes prévenus quelques jours avant le prélèvement par une notification par e-mail. Comme pour tout mandat SEPA, vous pouvez révoquer l\'autorisation de prélèvement à tout moment auprès de nos services ou de votre banque.',
closingMessage: 'N\'hésitez pas à répondre à cet e-mail si vous avez des questions.<br><br>L\'équipe Odentas vous remercie pour votre confiance.',
ctaText: 'Signer le mandat de prélèvement',
ctaSubtext: 'Vous serez redirigé vers notre partenaire Gocardless.',
footerText: 'Vous recevez cet e-mail car vous êtes client de Odentas, pour vous notifier d\'une action sur votre compte.',
preheaderText: 'Signature de votre mandat SEPA · Finalisez votre inscription',
colors: {
headerColor: STANDARD_COLORS.HEADER,
titleColor: '#0F172A',
buttonColor: STANDARD_COLORS.BUTTON,
buttonTextColor: STANDARD_COLORS.BUTTON_TEXT,
cardBackgroundColor: '#FFFFFF',
cardBorder: '#E5E7EB',
cardTitleColor: '#0F172A',
alertIndicatorColor: '#22C55E',
},
infoCard: [
{ label: 'Votre structure', key: 'organizationName' },
{ label: 'Votre code employeur', key: 'employerCode' },
{ label: 'Votre gestionnaire', key: 'handlerName' },
]
},
'notification': { 'notification': {
subject: 'Notification - {{title}}', subject: 'Notification - {{title}}',
title: 'Notification', title: 'Notification',
@ -1258,6 +1287,7 @@ export async function renderUniversalEmailV2(config: EmailConfigV2): Promise<{ s
mainMessage: processMainMessage(templateConfig.mainMessage, data), mainMessage: processMainMessage(templateConfig.mainMessage, data),
closingMessage: templateConfig.closingMessage ? replaceVariables(templateConfig.closingMessage, data) : undefined, closingMessage: templateConfig.closingMessage ? replaceVariables(templateConfig.closingMessage, data) : undefined,
ctaText: templateConfig.ctaText ? replaceVariables(templateConfig.ctaText, data) : undefined, ctaText: templateConfig.ctaText ? replaceVariables(templateConfig.ctaText, data) : undefined,
ctaSubtext: templateConfig.ctaSubtext ? replaceVariables(templateConfig.ctaSubtext, data) : undefined,
footerText: replaceVariables(templateConfig.footerText, data), footerText: replaceVariables(templateConfig.footerText, data),
preheaderText: replaceVariables(templateConfig.preheaderText, data), preheaderText: replaceVariables(templateConfig.preheaderText, data),
textFallback: `${replaceVariables(templateConfig.title, data)} - ${replaceVariables(templateConfig.mainMessage, data)}`, textFallback: `${replaceVariables(templateConfig.title, data)} - ${replaceVariables(templateConfig.mainMessage, data)}`,

View file

@ -0,0 +1,27 @@
-- Migration: Ajouter la colonne transfer_done_at à la table payslips
-- Date: 2025-11-02
-- Description: Cette colonne permet de tracer la date exacte où un virement a été marqué comme effectué
-- Ajouter la colonne transfer_done_at
ALTER TABLE payslips
ADD COLUMN IF NOT EXISTS transfer_done_at TIMESTAMPTZ;
-- Commentaire sur la colonne
COMMENT ON COLUMN payslips.transfer_done_at IS
'Date et heure où le virement a été marqué comme effectué (transfer_done = true). NULL si non encore viré.';
-- Mettre à jour les enregistrements existants où transfer_done = true
-- On utilise updated_at comme valeur par défaut pour les anciens enregistrements
UPDATE payslips
SET transfer_done_at = updated_at
WHERE transfer_done = true AND transfer_done_at IS NULL;
-- Index pour optimiser les requêtes sur les virements récents
CREATE INDEX IF NOT EXISTS idx_payslips_transfer_done_at
ON payslips(transfer_done_at)
WHERE transfer_done_at IS NOT NULL;
-- Index composite pour les requêtes filtrées par organisation
CREATE INDEX IF NOT EXISTS idx_payslips_org_transfer_done_at
ON payslips(organization_id, transfer_done_at)
WHERE transfer_done_at IS NOT NULL;

View file

@ -114,6 +114,11 @@
<!--[if !mso]><!-- --> <!--[if !mso]><!-- -->
<a class="btn" href="{{ctaUrl}}" style="background:{{buttonColor}}; color:{{buttonTextColor}}; display:inline-block; padding:16px 32px; border-radius:10px; font-weight:700; font-size:16px; text-decoration:none; border:none; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">{{ctaText}}</a> <a class="btn" href="{{ctaUrl}}" style="background:{{buttonColor}}; color:{{buttonTextColor}}; display:inline-block; padding:16px 32px; border-radius:10px; font-weight:700; font-size:16px; text-decoration:none; border:none; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">{{ctaText}}</a>
<!--<![endif]--> <!--<![endif]-->
{{#if ctaSubtext}}
<div style="font-size:12px; color:#64748B; font-style:italic; margin-top:8px;">
{{ctaSubtext}}
</div>
{{/if}}
</div> </div>
{{/if}} {{/if}}
@ -124,7 +129,7 @@
{{/if}} {{/if}}
<div class="footer" style="font-size:12px; color:#64748B; text-align:center; padding:18px 28px 28px 28px; line-height:1.4;"> <div class="footer" style="font-size:12px; color:#64748B; text-align:center; padding:18px 28px 28px 28px; line-height:1.4;">
<p>{{{footerText}}}<br><span class="muted" style="color:#94A3B8;">© 2021-2025 Odentas Media SAS | RCS Paris 907880348 | 6 rue d'Armaillé, 75017 Paris</span></p> <p>{{{footerText}}}<br><span class="muted" style="color:#94A3B8;">© 2021-2025 Odentas Media SAS<br>RCS Paris 907880348 | 6 rue d'Armaillé, 75017 Paris</span></p>
</div> </div>
</div> </div>
</div> </div>