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:
parent
46633ecd74
commit
897af4b23a
38 changed files with 5210 additions and 234 deletions
72
MIGRATION_TRANSFER_DONE_AT.md
Normal file
72
MIGRATION_TRANSFER_DONE_AT.md
Normal 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
|
||||||
|
|
@ -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,
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
|
|
@ -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 > En cours, indique l'état de traitement du contrat.
|
||||||
|
<br /><br />
|
||||||
|
Sur la vue CDDU > Terminés, indique l'état de traitement de la paie.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -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 })
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
1045
app/(app)/staff/facturation/create/saisie-tableau/page.tsx
Normal file
1045
app/(app)/staff/facturation/create/saisie-tableau/page.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -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 })
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
99
app/api/staff/clients/[id]/request-sepa-mandate/route.ts
Normal file
99
app/api/staff/clients/[id]/request-sepa-mandate/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
58
app/api/staff/contracts/bulk-update-signature-date/route.ts
Normal file
58
app/api/staff/contracts/bulk-update-signature-date/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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" });
|
||||||
|
|
||||||
|
|
|
||||||
151
app/api/staff/facturation/bulk-create/route.ts
Normal file
151
app/api/staff/facturation/bulk-create/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
105
app/api/staff/facturation/bulk-update-payment-date/route.ts
Normal file
105
app/api/staff/facturation/bulk-update-payment-date/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
100
app/api/staff/facturation/bulk-update-status/route.ts
Normal file
100
app/api/staff/facturation/bulk-update-status/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
88
app/api/staff/facturation/clients-sans-facture/route.ts
Normal file
88
app/api/staff/facturation/clients-sans-facture/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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é)
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
211
app/api/webhooks/docuseal-contract/route.ts
Normal file
211
app/api/webhooks/docuseal-contract/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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: 01/01/2025, 02/01/2025 03/01/2025, 04/01/2025 ..."
|
||||||
|
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
832
components/staff/PayslipDetailsModal.tsx
Normal file
832
components/staff/PayslipDetailsModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
304
components/staff/PayslipPdfVerificationModal.tsx
Normal file
304
components/staff/PayslipPdfVerificationModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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)}`,
|
||||||
|
|
|
||||||
27
migrations/add_transfer_done_at_to_payslips.sql
Normal file
27
migrations/add_transfer_done_at_to_payslips.sql
Normal 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;
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue