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 || "",
|
||||
nb_representations: (data as any).nb_representations ?? "",
|
||||
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 || "",
|
||||
nb_heures_annexes: (data as any).nb_heures_annexes ?? (data as any).nb_heures_aem ?? undefined,
|
||||
type_salaire:
|
||||
|
|
@ -109,11 +111,13 @@ export default function EditFormulairePage() {
|
|||
heures_travail: payload.heures_travail,
|
||||
minutes_travail: payload.minutes_travail,
|
||||
jours_travail: payload.jours_travail,
|
||||
jours_travail_non_artiste: payload.jours_travail_non_artiste,
|
||||
type_salaire: payload.type_salaire,
|
||||
montant: payload.montant,
|
||||
panier_repas: payload.panier_repas,
|
||||
reference: payload.reference,
|
||||
notes: payload.notes,
|
||||
send_email_confirmation: payload.send_email_confirmation,
|
||||
// Optionnel : valider_direct si tu veux déclencher un statut
|
||||
// valider_direct: payload.valider_direct,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -270,6 +270,10 @@ type ContratDetail = {
|
|||
|
||||
// Temps de travail réel
|
||||
jours_travailles?: number;
|
||||
jours_travail?: string;
|
||||
jours_travail_non_artiste?: string;
|
||||
dates_representations?: string;
|
||||
dates_repetitions?: string;
|
||||
nb_representations?: number;
|
||||
nb_services_repetitions?: number;
|
||||
nb_heures_repetitions?: number;
|
||||
|
|
@ -1555,7 +1559,14 @@ return (
|
|||
</Section>
|
||||
|
||||
<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 services répétitions" value={data.nb_services_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 { useQuery } from "@tanstack/react-query";
|
||||
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";
|
||||
|
||||
// --- Types
|
||||
|
|
@ -225,6 +225,8 @@ export default function PageContrats(){
|
|||
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 [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();
|
||||
|
||||
// 🎭 Détection du mode démo
|
||||
|
|
@ -469,7 +471,27 @@ export default function PageContrats(){
|
|||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<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>Salarié</Th>
|
||||
{/* 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"
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
|
|||
import { useParams, useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Send, Loader2 } from "lucide-react";
|
||||
|
||||
type Organization = {
|
||||
id: string;
|
||||
|
|
@ -43,6 +44,9 @@ type StructureInfos = {
|
|||
ouverture_compte?: string;
|
||||
offre_speciale?: string;
|
||||
notes?: string;
|
||||
|
||||
// Gestion paie
|
||||
virements_salaires?: string;
|
||||
|
||||
contact_principal?: string;
|
||||
email?: string;
|
||||
|
|
@ -261,6 +265,7 @@ export default function ClientDetailPage() {
|
|||
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editData, setEditData] = useState<Partial<Organization & StructureInfos & any>>({});
|
||||
const [showSepaMandateModal, setShowSepaMandateModal] = useState(false);
|
||||
|
||||
// Récupération de la liste des apporteurs
|
||||
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
|
||||
useEffect(() => {
|
||||
if (clientData && isEditing) {
|
||||
|
|
@ -337,6 +364,9 @@ export default function ClientDetailPage() {
|
|||
offre_speciale: structureInfos.offre_speciale,
|
||||
notes: structureInfos.notes,
|
||||
|
||||
// Gestion paie
|
||||
virements_salaires: structureInfos.virements_salaires,
|
||||
|
||||
// Apporteur d'affaires
|
||||
is_referred: details.is_referred,
|
||||
referrer_code: details.referrer_code,
|
||||
|
|
@ -380,6 +410,11 @@ export default function ClientDetailPage() {
|
|||
qualite_responsable_traitement: structureInfos.qualite_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
|
||||
licence_spectacles: structureInfos.licence_spectacles,
|
||||
urssaf: structureInfos.urssaf,
|
||||
|
|
@ -611,12 +646,6 @@ export default function ClientDetailPage() {
|
|||
]}
|
||||
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
|
||||
label="Logo"
|
||||
value={editData.logo_base64}
|
||||
|
|
@ -645,7 +674,6 @@ export default function ClientDetailPage() {
|
|||
<Line label="Trésorier(ère)" value={structureInfos.tresoriere} />
|
||||
<Line label="Licence spectacle" value={structureInfos.licence_spectacles} />
|
||||
<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} />
|
||||
<Line label="Créé le" value={formatDate(organization.created_at)} />
|
||||
<Line label="Mis à jour le" value={formatDate(organization.updated_at)} />
|
||||
|
|
@ -653,7 +681,9 @@ export default function ClientDetailPage() {
|
|||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Abonnement */}
|
||||
<section className="rounded-2xl border bg-white">
|
||||
<div className="px-4 py-3 border-b">
|
||||
|
|
@ -662,39 +692,50 @@ export default function ClientDetailPage() {
|
|||
<div className="p-4 text-sm">
|
||||
{isEditing ? (
|
||||
<div className="space-y-2">
|
||||
<EditableLine
|
||||
label="Statut"
|
||||
value={editData.statut}
|
||||
type="select"
|
||||
options={[
|
||||
{ value: "Actif", label: "Actif" },
|
||||
{ value: "Ancien client", label: "Ancien client" },
|
||||
]}
|
||||
onChange={(value) => setEditData(prev => ({ ...prev, statut: value }))}
|
||||
/>
|
||||
<EditableLine
|
||||
label="Ouverture de compte"
|
||||
value={editData.ouverture_compte}
|
||||
type="select"
|
||||
options={[
|
||||
{ value: "Simple", label: "Simple" },
|
||||
{ value: "Complexe", label: "Complexe" },
|
||||
]}
|
||||
onChange={(value) => setEditData(prev => ({ ...prev, ouverture_compte: 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 }))}
|
||||
/>
|
||||
{/* Section Contrat */}
|
||||
<div className="pb-2">
|
||||
<div className="text-xs font-semibold text-slate-600 uppercase tracking-wide mb-2">Contrat</div>
|
||||
<EditableLine
|
||||
label="Entrée en relation"
|
||||
value={editData.entree_en_relation}
|
||||
type="date"
|
||||
onChange={(value) => setEditData(prev => ({ ...prev, entree_en_relation: value }))}
|
||||
/>
|
||||
<EditableLine
|
||||
label="Statut"
|
||||
value={editData.statut}
|
||||
type="select"
|
||||
options={[
|
||||
{ value: "Actif", label: "Actif" },
|
||||
{ value: "Ancien client", label: "Ancien client" },
|
||||
]}
|
||||
onChange={(value) => setEditData(prev => ({ ...prev, statut: value }))}
|
||||
/>
|
||||
<EditableLine
|
||||
label="Ouverture de compte"
|
||||
value={editData.ouverture_compte}
|
||||
type="select"
|
||||
options={[
|
||||
{ value: "Simple", label: "Simple" },
|
||||
{ value: "Complexe", label: "Complexe" },
|
||||
]}
|
||||
onChange={(value) => setEditData(prev => ({ ...prev, ouverture_compte: 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 */}
|
||||
<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
|
||||
label="Client apporté ?"
|
||||
value={editData.is_referred ? "true" : "false"}
|
||||
|
|
@ -729,16 +770,57 @@ export default function ClientDetailPage() {
|
|||
</>
|
||||
)}
|
||||
</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 className="space-y-2">
|
||||
<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} />
|
||||
{/* Section Contrat */}
|
||||
<div className="pb-2">
|
||||
<div className="text-xs font-semibold text-slate-600 uppercase tracking-wide mb-2">Contrat</div>
|
||||
<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 */}
|
||||
<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
|
||||
label="Client apporté ?"
|
||||
value={clientData.details.is_referred ? "Oui" : "Non"}
|
||||
|
|
@ -756,13 +838,65 @@ export default function ClientDetailPage() {
|
|||
</>
|
||||
)}
|
||||
</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>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Informations de contact */}
|
||||
<section className="rounded-2xl border bg-white">
|
||||
<div className="px-4 py-3 border-b">
|
||||
|
|
@ -931,6 +1065,79 @@ export default function ClientDetailPage() {
|
|||
</section>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -37,8 +37,8 @@ export default async function StaffContractsPage() {
|
|||
const { data: contracts, error } = await sb
|
||||
.from("cddu_contracts")
|
||||
.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,
|
||||
salaries!employee_id(salarie, nom, prenom, adresse_mail),
|
||||
`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, code_salarie),
|
||||
organizations!org_id(organization_details(code_employeur))`
|
||||
)
|
||||
.order("start_date", { ascending: false })
|
||||
|
|
|
|||
|
|
@ -158,6 +158,13 @@ export default function CreateInvoicePage() {
|
|||
Créer une nouvelle facture
|
||||
</h1>
|
||||
</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>
|
||||
|
||||
<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 --------------
|
||||
export default function StaffFacturationPage() {
|
||||
usePageTitle("Facturation (Staff)");
|
||||
|
|
@ -89,14 +99,22 @@ export default function StaffFacturationPage() {
|
|||
const [showSepaModal, setShowSepaModal] = useState(false);
|
||||
const [showInvoiceDateModal, setShowInvoiceDateModal] = useState(false);
|
||||
const [showDueDateModal, setShowDueDateModal] = useState(false);
|
||||
const [showPaymentDateModal, setShowPaymentDateModal] = useState(false);
|
||||
const [showBulkGoCardlessModal, setShowBulkGoCardlessModal] = useState(false);
|
||||
const [showStatusModal, setShowStatusModal] = useState(false);
|
||||
const [showDateMenu, setShowDateMenu] = useState(false);
|
||||
const [newSepaDate, setNewSepaDate] = useState("");
|
||||
const [newInvoiceDate, setNewInvoiceDate] = useState("");
|
||||
const [newDueDate, setNewDueDate] = useState("");
|
||||
const [newPaymentDate, setNewPaymentDate] = useState("");
|
||||
const [newStatus, setNewStatus] = useState<string>("");
|
||||
const limit = 25;
|
||||
const { data, isLoading, isError, error } = useStaffBilling(page, limit);
|
||||
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 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 = () => {
|
||||
if (selectedInvoices.size === 0) {
|
||||
alert("Veuillez sélectionner au moins une facture.");
|
||||
|
|
@ -298,6 +364,38 @@ export default function StaffFacturationPage() {
|
|||
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
|
||||
const filteredAndSortedItems = useMemo(() => {
|
||||
// D'abord filtrer
|
||||
|
|
@ -434,13 +532,22 @@ export default function StaffFacturationPage() {
|
|||
<h1 className="text-2xl font-bold text-slate-900">Facturation</h1>
|
||||
<p className="text-slate-600">Gestion des factures de tous les clients</p>
|
||||
</div>
|
||||
<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 className="flex items-center gap-2">
|
||||
<Link
|
||||
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" />
|
||||
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>
|
||||
|
||||
{/* Filtres */}
|
||||
|
|
@ -622,6 +729,64 @@ export default function StaffFacturationPage() {
|
|||
</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 */}
|
||||
<Section title="Toutes les factures">
|
||||
<div className="text-xs text-slate-500 mb-3 flex items-center gap-4">
|
||||
|
|
@ -650,26 +815,79 @@ export default function StaffFacturationPage() {
|
|||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowInvoiceDateModal(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"
|
||||
onClick={() => setShowStatusModal(true)}
|
||||
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" />
|
||||
Modifier date facture
|
||||
</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
|
||||
<Edit className="w-4 h-4" />
|
||||
Modifier statut
|
||||
</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
|
||||
onClick={handleBulkGoCardless}
|
||||
disabled={bulkGoCardlessMutation.isPending}
|
||||
|
|
@ -680,10 +898,10 @@ export default function StaffFacturationPage() {
|
|||
</button>
|
||||
<button
|
||||
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" />
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1009,6 +1227,52 @@ export default function StaffFacturationPage() {
|
|||
</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 */}
|
||||
<Dialog open={showBulkGoCardlessModal} onOpenChange={setShowBulkGoCardlessModal}>
|
||||
<DialogContent className="max-w-md">
|
||||
|
|
@ -1080,6 +1344,59 @@ export default function StaffFacturationPage() {
|
|||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -41,7 +41,8 @@ export default async function StaffPayslipsPage() {
|
|||
processed, aem_status, transfer_done, organization_id, created_at,
|
||||
cddu_contracts!contract_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 })
|
||||
|
|
|
|||
|
|
@ -373,6 +373,8 @@ export default function VirementsPage() {
|
|||
const [selectedOrgId, setSelectedOrgId] = useState<string>("");
|
||||
const [pdfModalOpen, setPdfModalOpen] = useState(false);
|
||||
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: organizations, isLoading: isLoadingOrgs, error: orgsError } = useOrganizations();
|
||||
|
|
@ -502,6 +504,13 @@ export default function VirementsPage() {
|
|||
const clientUnpaid = clientFilter(clientUnpaidAll);
|
||||
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é
|
||||
async function markPayslipDone(payslipId: string) {
|
||||
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
|
||||
const filteredItems = useMemo((): VirementItem[] => {
|
||||
let result: VirementItem[] = items;
|
||||
|
|
@ -839,6 +873,26 @@ export default function VirementsPage() {
|
|||
<Th className="text-right">Net à payer</Th>
|
||||
<Th className="text-center">Marquer comme payé</Th>
|
||||
</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>
|
||||
<tbody>
|
||||
{/* Unpaid first */}
|
||||
|
|
@ -897,9 +951,28 @@ export default function VirementsPage() {
|
|||
</tr>
|
||||
))}
|
||||
{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>
|
||||
</tr>
|
||||
<>
|
||||
{/* Séparation visuelle renforcée */}
|
||||
<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) => (
|
||||
<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 className="text-right font-medium">{it.net_a_payer != null ? formatCurrency(it.net_a_payer) : '—'}</Td>
|
||||
<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>
|
||||
</tr>
|
||||
))}
|
||||
|
|
@ -1072,6 +1156,51 @@ export default function VirementsPage() {
|
|||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -172,6 +172,10 @@ export async function GET(req: NextRequest, { params }: { params: { id: string }
|
|||
dpae: cddu.dpae,
|
||||
aem: cddu.aem,
|
||||
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_services_repetitions: cddu.services_repetitions ? Number(cddu.services_repetitions) : 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) {
|
||||
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) {
|
||||
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.reference = requestBody.reference;
|
||||
}
|
||||
if (requestBody.notes !== undefined) {
|
||||
supabaseData.notes = requestBody.notes;
|
||||
}
|
||||
if (requestBody.multi_mois !== undefined) {
|
||||
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}`);
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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
|
||||
let contractData;
|
||||
if (org.isStaff) {
|
||||
|
|
@ -380,7 +412,7 @@ export async function PATCH(req: NextRequest, { params }: { params: { id: string
|
|||
contractData = data;
|
||||
}
|
||||
|
||||
if (contractData) {
|
||||
if (contractData && shouldSendEmail) {
|
||||
// Récupérer les données d'organisation avec tous les détails
|
||||
let organizationData;
|
||||
if (org.isStaff) {
|
||||
|
|
@ -515,7 +547,8 @@ export async function PATCH(req: NextRequest, { params }: { params: { id: string
|
|||
// Ajouter d'autres champs selon les besoins
|
||||
const fieldsToSync = [
|
||||
'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'
|
||||
];
|
||||
|
||||
|
|
@ -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 });
|
||||
|
||||
// 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 });
|
||||
|
||||
// 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 {
|
||||
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
|
||||
let contractData;
|
||||
if (org.isStaff) {
|
||||
|
|
@ -553,7 +609,7 @@ export async function PATCH(req: NextRequest, { params }: { params: { id: string
|
|||
contractData = data;
|
||||
}
|
||||
|
||||
if (contractData) {
|
||||
if (contractData && shouldSendEmail) {
|
||||
// Récupérer les données d'organisation avec tous les détails
|
||||
let organizationData;
|
||||
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
|
||||
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 {
|
||||
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) {
|
||||
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 {
|
||||
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 {
|
||||
// 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');
|
||||
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)
|
||||
if (q) {
|
||||
|
|
@ -285,6 +285,33 @@ export async function GET(req: Request) {
|
|||
const isMulti = row.multi_mois === "Oui" || row.multi_mois === true;
|
||||
const td = String(row.type_d_embauche || "").toLowerCase();
|
||||
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 {
|
||||
id: row.id,
|
||||
reference: row.contract_number,
|
||||
|
|
@ -296,7 +323,7 @@ export async function GET(req: Request) {
|
|||
profession: row.profession || row.role || "",
|
||||
date_debut: row.start_date,
|
||||
date_fin: row.end_date,
|
||||
etat: (row.etat_de_la_demande || row.etat || "en_cours"),
|
||||
etat: displayEtat,
|
||||
is_multi_mois: isMulti,
|
||||
regime: isRG ? "RG" : (isMulti ? "CDDU_MULTI" : "CDDU_MONO"),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -34,12 +34,24 @@ export async function PATCH(
|
|||
}
|
||||
|
||||
// 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
|
||||
.from("payslips")
|
||||
.update({
|
||||
transfer_done: body.transfer_done,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.update(updateData)
|
||||
.eq("id", id)
|
||||
.select()
|
||||
.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,
|
||||
offre_speciale: details?.offre_speciale || null,
|
||||
notes: details?.notes || null,
|
||||
|
||||
// Gestion paie
|
||||
virements_salaires: details?.virements_salaires || null,
|
||||
|
||||
// Informations de contact
|
||||
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,
|
||||
offre_speciale,
|
||||
notes,
|
||||
// Gestion paie
|
||||
virements_salaires,
|
||||
// Apporteur d'affaires
|
||||
is_referred,
|
||||
referrer_code,
|
||||
|
|
@ -230,6 +235,10 @@ export async function PUT(
|
|||
nom_responsable_traitement,
|
||||
qualite_responsable_traitement,
|
||||
email_responsable_traitement,
|
||||
// Facturation (SEPA)
|
||||
iban,
|
||||
bic,
|
||||
id_mandat_sepa,
|
||||
} = body;
|
||||
|
||||
const orgUpdateData: any = {};
|
||||
|
|
@ -244,6 +253,8 @@ export async function PUT(
|
|||
if (ouverture_compte !== undefined) detailsUpdateData.ouverture_compte = ouverture_compte;
|
||||
if (offre_speciale !== undefined) detailsUpdateData.offre_speciale = offre_speciale;
|
||||
if (notes !== undefined) detailsUpdateData.notes = notes;
|
||||
// Gestion paie
|
||||
if (virements_salaires !== undefined) detailsUpdateData.virements_salaires = virements_salaires;
|
||||
// Apporteur d'affaires
|
||||
if (is_referred !== undefined) detailsUpdateData.is_referred = is_referred;
|
||||
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 (qualite_responsable_traitement !== undefined) detailsUpdateData.qualite_responsable_traitement = qualite_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) {
|
||||
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,
|
||||
contrat_signe_par_employeur, contrat_signe, org_id,
|
||||
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))
|
||||
`, { 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 { createSbServer } from "@/lib/supabaseServer";
|
||||
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const s3Client = new S3Client({
|
||||
region: process.env.AWS_REGION || "eu-west-3",
|
||||
|
|
@ -100,18 +99,15 @@ export async function POST(req: NextRequest) {
|
|||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
|
||||
// Générer le chemin S3: bulletins/{org_slug}/contrat_{contract_number}/bulletin_paie_{pay_number}_{uuid}.pdf
|
||||
const uniqueId = uuidv4().replace(/-/g, '').substring(0, 8);
|
||||
// Générer le chemin S3: paies/{org_slug}/{contract_number}.pdf
|
||||
// Format standardisé cohérent avec ContractEditor.tsx
|
||||
const contractNumber = contract.contract_number || contractId.substring(0, 8);
|
||||
const payNumber = payslip.pay_number || 'unknown';
|
||||
const filename = `bulletin_paie_${payNumber}_${uniqueId}.pdf`;
|
||||
const s3Key = `bulletins/${orgSlug}/contrat_${contractNumber}/${filename}`;
|
||||
const s3Key = `paies/${orgSlug}/${contractNumber}.pdf`;
|
||||
|
||||
console.log('📄 [Payslip Upload] Uploading to S3:', {
|
||||
contractId,
|
||||
payslipId,
|
||||
contractNumber,
|
||||
payNumber,
|
||||
s3Key,
|
||||
fileSize: file.size
|
||||
});
|
||||
|
|
@ -145,7 +141,6 @@ export async function POST(req: NextRequest) {
|
|||
return NextResponse.json({
|
||||
success: true,
|
||||
s3_key: s3Key,
|
||||
filename: filename,
|
||||
message: "Bulletin de paie uploadé avec succès"
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,47 @@
|
|||
import { NextResponse } from "next/server";
|
||||
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 } }) {
|
||||
try {
|
||||
const sb = createSbServer();
|
||||
|
|
|
|||
|
|
@ -49,10 +49,11 @@ export async function GET(req: NextRequest) {
|
|||
.select(
|
||||
`id, contract_id, period_start, period_end, period_month, pay_number, pay_date,
|
||||
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(
|
||||
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" }
|
||||
); // Filtre de recherche textuelle (n° contrat, nom salarié)
|
||||
|
|
|
|||
|
|
@ -246,7 +246,7 @@ export async function GET(req: NextRequest) {
|
|||
// Base query payslips de l'organisation
|
||||
let payslipsQuery = sb
|
||||
.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);
|
||||
|
||||
// Filtrage par année (période de paie)
|
||||
|
|
@ -263,12 +263,15 @@ export async function GET(req: NextRequest) {
|
|||
} else {
|
||||
// Masquer les payslips dont processed est 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();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
const recentPayslips = (allPayslips || []).filter(p => {
|
||||
if (p.transfer_done && p.processed !== false) {
|
||||
const ts = p.updated_at ? new Date(p.updated_at) : null;
|
||||
return ts ? ts >= thirtyDaysAgo : false;
|
||||
if (p.transfer_done && p.processed !== false && p.transfer_done_at) {
|
||||
const ts = new Date(p.transfer_done_at);
|
||||
return ts >= thirtyDaysAgo;
|
||||
}
|
||||
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[];
|
||||
hasMultiMonth: boolean;
|
||||
pdfFormatted: string;
|
||||
globalQuantity?: number;
|
||||
globalDuration?: "3" | "4";
|
||||
}) => void;
|
||||
selectedDates: string[]; // format input "12/10, 13/10, ..."
|
||||
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"
|
||||
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"
|
||||
const [applyToAllValue, setApplyToAllValue] = useState<string>("");
|
||||
|
|
@ -90,6 +98,9 @@ export default function DatesQuantityModal({
|
|||
setQuantities(emptyQuantities);
|
||||
setApplyToAllValue("");
|
||||
setValidationError("");
|
||||
} else {
|
||||
// Réinitialiser le nombre global
|
||||
setGlobalQuantity("");
|
||||
}
|
||||
}, [skipHoursByDay, selectedIsos]);
|
||||
|
||||
|
|
@ -232,8 +243,23 @@ export default function DatesQuantityModal({
|
|||
};
|
||||
|
||||
const handleApply = () => {
|
||||
// Si on ne veut pas d'heures par jour, pas besoin de valider les quantités
|
||||
if (!skipHoursByDay) {
|
||||
let globalQty: number | undefined = undefined;
|
||||
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
|
||||
for (const iso of selectedIsos) {
|
||||
const qty = quantities[iso];
|
||||
|
|
@ -253,6 +279,8 @@ export default function DatesQuantityModal({
|
|||
selectedDates: selectedDates,
|
||||
hasMultiMonth: selectedIsos.length > 0 && checkMultiMonth(selectedIsos),
|
||||
pdfFormatted,
|
||||
globalQuantity: globalQty,
|
||||
globalDuration: globalDur,
|
||||
});
|
||||
|
||||
onClose();
|
||||
|
|
@ -300,7 +328,7 @@ export default function DatesQuantityModal({
|
|||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{/* Card option pour ne pas appliquer d'heures par jour */}
|
||||
{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">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
|
@ -317,6 +345,54 @@ export default function DatesQuantityModal({
|
|||
</div>
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { useDemoMode } from "@/hooks/useDemoMode";
|
|||
import Calculator from "@/components/Calculator";
|
||||
import DatePickerCalendar from "@/components/DatePickerCalendar";
|
||||
import DatesQuantityModal from "@/components/DatesQuantityModal";
|
||||
import { parseDateString } from "@/lib/dateFormatter";
|
||||
import { parseDateString, parseFrenchedDate } from "@/lib/dateFormatter";
|
||||
import { Tooltip } from "@/components/ui/tooltip";
|
||||
|
||||
/* =========================
|
||||
|
|
@ -346,6 +346,7 @@ export function NouveauCDDUForm({
|
|||
const [isMultiMois, setIsMultiMois] = useState<"Oui" | "Non">("Non");
|
||||
const [dateDebut, setDateDebut] = useState("");
|
||||
const [dateFin, setDateFin] = useState("");
|
||||
const [manualDatesMode, setManualDatesMode] = useState(false); // Mode manuel pour les dates
|
||||
const [confirmPastStart, setConfirmPastStart] = useState(false);
|
||||
const [heuresTotal, setHeuresTotal] = useState<number | "">("");
|
||||
const [minutesTotal, setMinutesTotal] = useState<"0" | "30">("0");
|
||||
|
|
@ -484,28 +485,98 @@ export function NouveauCDDUForm({
|
|||
selectedDates: string[];
|
||||
hasMultiMonth: boolean;
|
||||
pdfFormatted: string;
|
||||
globalQuantity?: number;
|
||||
globalDuration?: "3" | "4";
|
||||
}) => {
|
||||
// Calculer le nombre de jours/dates sélectionnées
|
||||
const nbDates = result.selectedDates.length;
|
||||
// Si un nombre global est fourni, l'utiliser; sinon calculer le nombre de dates
|
||||
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) {
|
||||
case "representations":
|
||||
setDatesRep(result.pdfFormatted);
|
||||
setDatesRepDisplay(result.pdfFormatted);
|
||||
// Auto-remplir le nombre de représentations basé sur les dates sélectionnées
|
||||
setNbRep(nbDates);
|
||||
setNbRep(quantity);
|
||||
break;
|
||||
case "repetitions":
|
||||
setDatesServ(result.pdfFormatted);
|
||||
setDatesServDisplay(result.pdfFormatted);
|
||||
// Auto-remplir le nombre de services de répétition basé sur les dates sélectionnées
|
||||
setNbServ(nbDates);
|
||||
setNbServ(quantity);
|
||||
if (result.globalDuration) {
|
||||
setDurationServices(result.globalDuration);
|
||||
}
|
||||
break;
|
||||
case "jours_travail":
|
||||
setJoursTravail(result.pdfFormatted);
|
||||
setJoursTravailDisplay(result.pdfFormatted);
|
||||
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);
|
||||
setPendingDates([]);
|
||||
};
|
||||
|
|
@ -1392,6 +1463,7 @@ useEffect(() => {
|
|||
heures_travail: !isRegimeRG && useHeuresMode ? heuresTotal : (!isRegimeRG && typeof nbServ === "number" && nbServ > 0 ? nbServ * parseInt(durationServices) : undefined),
|
||||
minutes_travail: !isRegimeRG && useHeuresMode ? minutesTotal : undefined,
|
||||
jours_travail: !isRegimeRG && useHeuresMode ? (joursTravail || undefined) : undefined,
|
||||
jours_travail_non_artiste: !isRegimeRG && useHeuresMode && categoriePro === "Technicien" ? (joursTravail || undefined) : undefined,
|
||||
type_salaire: typeSalaire,
|
||||
montant: salaryMode === "par_date" ? undefined : (typeSalaire !== "Minimum conventionnel" ? (montantSalaire === "" ? undefined : montantSalaire) : 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,
|
||||
reference,
|
||||
notes: notes || undefined,
|
||||
send_email_confirmation: emailConfirm === "Oui",
|
||||
valider_direct: validerDirect === "Oui",
|
||||
} as const;
|
||||
|
||||
|
|
@ -1454,6 +1527,7 @@ useEffect(() => {
|
|||
heures_total: payload.heures_travail,
|
||||
minutes_total: payload.minutes_travail,
|
||||
jours_travail: payload.jours_travail,
|
||||
jours_travail_non_artiste: payload.jours_travail_non_artiste,
|
||||
multi_mois: payload.multi_mois,
|
||||
salaires_par_date: payload.salaires_par_date,
|
||||
})
|
||||
|
|
@ -2029,102 +2103,22 @@ useEffect(() => {
|
|||
) : (
|
||||
// Contenu pour CDDU (existant)
|
||||
<>
|
||||
<FieldRow>
|
||||
<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>
|
||||
{/* Question multi-mois masquée - la logique reste active en arrière-plan */}
|
||||
</>
|
||||
)}
|
||||
|
||||
<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 */}
|
||||
{!isRegimeRG && (
|
||||
!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>
|
||||
<div>
|
||||
<Label>Indiquez les dates de représentations</Label>
|
||||
<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…"}
|
||||
</div>
|
||||
<button
|
||||
|
|
@ -2139,7 +2133,10 @@ useEffect(() => {
|
|||
<div>
|
||||
<Label>Indiquez les dates de répétitions</Label>
|
||||
<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…"}
|
||||
</div>
|
||||
<button
|
||||
|
|
@ -2160,8 +2157,6 @@ useEffect(() => {
|
|||
onApply={handleDatesRepApply}
|
||||
initialDates={datesRep ? datesRep.split(", ") : []}
|
||||
title="Sélectionner les dates de représentations"
|
||||
minDate={dateDebut}
|
||||
maxDate={dateFin}
|
||||
/>
|
||||
<DatePickerCalendar
|
||||
isOpen={datesServOpen}
|
||||
|
|
@ -2169,11 +2164,136 @@ useEffect(() => {
|
|||
onApply={handleDatesServApply}
|
||||
initialDates={datesServ ? datesServ.split(", ") : []}
|
||||
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>
|
||||
<div>
|
||||
|
|
@ -2230,7 +2350,7 @@ useEffect(() => {
|
|||
maxDate={dateFin}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
) : null
|
||||
)}
|
||||
</Section>
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { useEffect, useMemo, useState, useRef, useImperativeHandle, forwardRef } from "react";
|
||||
import { supabase } from "@/lib/supabaseClient";
|
||||
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 BulkPdfProgressModal from "./BulkPdfProgressModal";
|
||||
import PdfVerificationModal from "./PdfVerificationModal";
|
||||
|
|
@ -178,11 +178,15 @@ type Contract = {
|
|||
last_employee_notification_at?: string | null;
|
||||
production_name?: string | null;
|
||||
analytique?: string | null;
|
||||
nombre_d_heures?: number | null;
|
||||
n_objet?: string | null;
|
||||
objet_spectacle?: string | null;
|
||||
salaries?: {
|
||||
salarie?: string | null;
|
||||
nom?: string | null;
|
||||
prenom?: string | null;
|
||||
adresse_mail?: string | null;
|
||||
code_salarie?: string | null;
|
||||
} | null;
|
||||
organizations?: {
|
||||
organization_details?: {
|
||||
|
|
@ -300,6 +304,8 @@ function ContractsGridImpl({ initialData, activeOrgId }: { initialData: Contract
|
|||
const [showESignMenu, setShowESignMenu] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [showBulkPayslipModal, setShowBulkPayslipModal] = useState(false);
|
||||
const [showJoursTechnicienModal, setShowJoursTechnicienModal] = useState(false);
|
||||
const [showSignatureDateModal, setShowSignatureDateModal] = useState(false);
|
||||
|
||||
// Quick filter counts
|
||||
const [countDpaeAFaire, setCountDpaeAFaire] = useState<number | null>(null);
|
||||
|
|
@ -1002,6 +1008,165 @@ function ContractsGridImpl({ initialData, activeOrgId }: { initialData: Contract
|
|||
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
|
||||
const handleBulkESignClick = () => {
|
||||
if (selectedContractIds.size === 0) {
|
||||
|
|
@ -2058,6 +2223,35 @@ function ContractsGridImpl({ initialData, activeOrgId }: { initialData: Contract
|
|||
<Eye className="w-4 h-4" />
|
||||
Voir les détails
|
||||
</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>
|
||||
<button
|
||||
onClick={() => {
|
||||
|
|
@ -2430,6 +2624,53 @@ function ContractsGridImpl({ initialData, activeOrgId }: { initialData: Contract
|
|||
</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 */}
|
||||
{showSalaryModal && (
|
||||
<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 { supabase } from "@/lib/supabaseClient";
|
||||
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 BulkPayslipUploadModal from "./payslips/BulkPayslipUploadModal";
|
||||
import PayslipDetailsModal from "./PayslipDetailsModal";
|
||||
import PayslipPdfVerificationModal from "./PayslipPdfVerificationModal";
|
||||
|
||||
// Utility function to format dates as DD/MM/YYYY
|
||||
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);
|
||||
}
|
||||
|
||||
// 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
|
||||
function formatEmployeeName(payslip: { cddu_contracts?: any }): string {
|
||||
const contract = payslip.cddu_contracts;
|
||||
|
|
@ -97,6 +113,7 @@ type Payslip = {
|
|||
aem_status?: string | null;
|
||||
transfer_done?: boolean | null;
|
||||
organization_id?: string | null;
|
||||
storage_path?: string | null;
|
||||
created_at?: string | null;
|
||||
cddu_contracts?: {
|
||||
id: string;
|
||||
|
|
@ -111,6 +128,11 @@ type Payslip = {
|
|||
nom?: string | null;
|
||||
prenom?: string | null;
|
||||
} | null;
|
||||
organizations?: {
|
||||
organization_details?: {
|
||||
code_employeur?: string | null;
|
||||
} | null;
|
||||
} | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
|
|
@ -176,6 +198,126 @@ export default function PayslipsGrid({ initialData, activeOrgId }: { initialData
|
|||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = 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
|
||||
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="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
|
||||
onClick={() => {
|
||||
setShowProcessedModal(true);
|
||||
|
|
@ -610,6 +764,17 @@ export default function PayslipsGrid({ initialData, activeOrgId }: { initialData
|
|||
>
|
||||
Ajouter documents
|
||||
</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>
|
||||
<button
|
||||
onClick={() => {
|
||||
|
|
@ -638,7 +803,7 @@ export default function PayslipsGrid({ initialData, activeOrgId }: { initialData
|
|||
)}
|
||||
|
||||
<div className="overflow-auto">
|
||||
<table className="w-full text-sm">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-slate-50 text-slate-600">
|
||||
<tr>
|
||||
<th className="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'); }}>
|
||||
N° contrat {sortField === 'contract_number' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
|
||||
</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'); }}>
|
||||
Salarié {sortField === 'employee_name' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
|
||||
</th>
|
||||
<th className="text-left px-3 py-2 cursor-pointer" onClick={() => { setSortField('structure'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}>
|
||||
Structure {sortField === 'structure' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
|
||||
</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' ? '▲' : '▼') : ''}
|
||||
</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'); }}>
|
||||
Brut {sortField === 'gross_amount' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
|
||||
</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'); }}>
|
||||
Net à payer {sortField === 'net_after_withholding' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
|
||||
</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>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -694,16 +865,16 @@ export default function PayslipsGrid({ initialData, activeOrgId }: { initialData
|
|||
</td>
|
||||
<td className="px-3 py-2">
|
||||
{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 className="px-3 py-2">
|
||||
{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 className="px-3 py-2">
|
||||
|
|
@ -718,9 +889,9 @@ export default function PayslipsGrid({ initialData, activeOrgId }: { initialData
|
|||
|
||||
const status = r.aem_status || "À traiter";
|
||||
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 {
|
||||
return <X className="size-4 text-orange-600" strokeWidth={3} />;
|
||||
return <X className="w-4 h-4 text-orange-600" strokeWidth={3} />;
|
||||
}
|
||||
})()}
|
||||
</td>
|
||||
|
|
@ -734,17 +905,18 @@ export default function PayslipsGrid({ initialData, activeOrgId }: { initialData
|
|||
>
|
||||
{r.cddu_contracts?.contract_number || "—"}
|
||||
</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">{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.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.employer_cost)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ export type EmailTypeV2 =
|
|||
| 'salary-transfer-payment-confirmation' // Nouveau type pour notification de paiement effectué
|
||||
| 'contribution-notification' // Nouveau type pour notification de cotisations
|
||||
| 'production-declared' // Nouveau type pour notification de déclaration de production
|
||||
| 'sepa-mandate-request' // Nouveau type pour demande de signature de mandat SEPA
|
||||
| 'notification'
|
||||
// Support
|
||||
| 'support-reply' // Réponse du staff à un ticket support
|
||||
|
|
@ -117,6 +118,7 @@ interface EmailTemplateV2 {
|
|||
greeting?: string;
|
||||
closingMessage?: string;
|
||||
ctaText?: string;
|
||||
ctaSubtext?: string; // Texte sous le bouton CTA
|
||||
ctaUrl?: string; // URL du bouton CTA
|
||||
footerText: 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': {
|
||||
subject: 'Notification - {{title}}',
|
||||
title: 'Notification',
|
||||
|
|
@ -1258,6 +1287,7 @@ export async function renderUniversalEmailV2(config: EmailConfigV2): Promise<{ s
|
|||
mainMessage: processMainMessage(templateConfig.mainMessage, data),
|
||||
closingMessage: templateConfig.closingMessage ? replaceVariables(templateConfig.closingMessage, data) : undefined,
|
||||
ctaText: templateConfig.ctaText ? replaceVariables(templateConfig.ctaText, data) : undefined,
|
||||
ctaSubtext: templateConfig.ctaSubtext ? replaceVariables(templateConfig.ctaSubtext, data) : undefined,
|
||||
footerText: replaceVariables(templateConfig.footerText, data),
|
||||
preheaderText: replaceVariables(templateConfig.preheaderText, 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]><!-- -->
|
||||
<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]-->
|
||||
{{#if ctaSubtext}}
|
||||
<div style="font-size:12px; color:#64748B; font-style:italic; margin-top:8px;">
|
||||
{{ctaSubtext}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
|
|
@ -124,7 +129,7 @@
|
|||
{{/if}}
|
||||
|
||||
<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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue