diff --git a/MIGRATION_TRANSFER_DONE_AT.md b/MIGRATION_TRANSFER_DONE_AT.md new file mode 100644 index 0000000..1de02a2 --- /dev/null +++ b/MIGRATION_TRANSFER_DONE_AT.md @@ -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 diff --git a/app/(app)/contrats/[id]/edit/formulaire/page.tsx b/app/(app)/contrats/[id]/edit/formulaire/page.tsx index a656e3c..a95b6ac 100644 --- a/app/(app)/contrats/[id]/edit/formulaire/page.tsx +++ b/app/(app)/contrats/[id]/edit/formulaire/page.tsx @@ -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, }), diff --git a/app/(app)/contrats/[id]/page.tsx b/app/(app)/contrats/[id]/page.tsx index 7280ebf..0b45edc 100644 --- a/app/(app)/contrats/[id]/page.tsx +++ b/app/(app)/contrats/[id]/page.tsx @@ -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 (
- + diff --git a/app/(app)/contrats/page.tsx b/app/(app)/contrats/page.tsx index 3bc11b0..a011c0f 100644 --- a/app/(app)/contrats/page.tsx +++ b/app/(app)/contrats/page.tsx @@ -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(){ - + {/* 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" /> + + {/* Tooltip fixe pour l'icĂŽne État */} + {showTooltip && tooltipPos && ( +
+
+
+
+ Sur la vue CDDU > En cours, indique l'état de traitement du contrat. +

+ Sur la vue CDDU > Terminés, indique l'état de traitement de la paie. +
+
+
+ )}
); } diff --git a/app/(app)/staff/clients/[id]/page.tsx b/app/(app)/staff/clients/[id]/page.tsx index 2986727..e540157 100644 --- a/app/(app)/staff/clients/[id]/page.tsx +++ b/app/(app)/staff/clients/[id]/page.tsx @@ -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>({}); + const [showSepaMandateModal, setShowSepaMandateModal] = useState(false); // Récupération de la liste des apporteurs const { data: referrers = [] } = useQuery({ @@ -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" }))} /> - setEditData(prev => ({ ...prev, entree_en_relation: value }))} - /> - @@ -653,7 +681,9 @@ export default function ClientDetailPage() { )} + +
{/* Abonnement */}
@@ -662,39 +692,50 @@ export default function ClientDetailPage() {
{isEditing ? (
- setEditData(prev => ({ ...prev, statut: value }))} - /> - setEditData(prev => ({ ...prev, ouverture_compte: value }))} - /> - setEditData(prev => ({ ...prev, offre_speciale: value }))} - /> - setEditData(prev => ({ ...prev, notes: value }))} - /> + {/* Section Contrat */} +
+
Contrat
+ setEditData(prev => ({ ...prev, entree_en_relation: value }))} + /> + setEditData(prev => ({ ...prev, statut: value }))} + /> + setEditData(prev => ({ ...prev, ouverture_compte: value }))} + /> + setEditData(prev => ({ ...prev, offre_speciale: value }))} + /> + setEditData(prev => ({ ...prev, notes: value }))} + /> +
{/* Section Apporteur d'Affaires */}
+
Apporteur d'affaires
)}
+ + {/* Section Facturation */} +
+
Facturation
+ setEditData(prev => ({ ...prev, iban: value }))} + /> + setEditData(prev => ({ ...prev, bic: value }))} + /> + setEditData(prev => ({ ...prev, id_mandat_sepa: value }))} + /> +
+ + {/* Section Gestion paie */} +
+
Gestion paie
+ setEditData(prev => ({ ...prev, virements_salaires: value }))} + /> +
) : (
- - - - + {/* Section Contrat */} +
+
Contrat
+ + + + + +
{/* Section Apporteur d'Affaires */}
+
Apporteur d'affaires
)}
+ + {/* Section Facturation */} +
+
Facturation
+ + {/* Carte Statut Mandat SEPA */} +
+ {clientData.details.id_mandat_sepa ? ( +
+
+ + + +
+
+
Mandat SEPA actif
+
Les prélÚvements automatiques sont activés
+
+
+ ) : ( +
+
+ + + +
+
+
Aucun mandat SEPA
+
Paiement par virement uniquement
+
+ +
+ )} +
+ + + + +
+ + {/* Section Gestion paie */} +
+
Gestion paie
+ +
)}
-
-
{/* Informations de contact */}
@@ -931,6 +1065,79 @@ export default function ClientDetailPage() {
+ + {/* Modale de confirmation pour la demande de mandat SEPA */} + {showSepaMandateModal && ( +
+
+
+

+ + Demande de mandat SEPA +

+
+ +
+

+ Êtes-vous sĂ»r de vouloir envoyer une demande de signature de mandat SEPA au client ? +

+ +
+
+ Client : {organization.name} +
+ {structureInfos.email && ( +
+ Email : {structureInfos.email} +
+ )} + {structureInfos.email_cc && ( +
+ Email CC : {structureInfos.email_cc} +
+ )} +
+ +
+
+ Cette action va : +
+
    +
  • Envoyer un email au client
  • +
  • Inclure un lien vers GoCardless pour la signature
  • +
+
+
+ +
+ + +
+
+
+ )} ); } \ No newline at end of file diff --git a/app/(app)/staff/contrats/page.tsx b/app/(app)/staff/contrats/page.tsx index c24349f..810ec01 100644 --- a/app/(app)/staff/contrats/page.tsx +++ b/app/(app)/staff/contrats/page.tsx @@ -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 }) diff --git a/app/(app)/staff/facturation/create/page.tsx b/app/(app)/staff/facturation/create/page.tsx index 6edea78..b816049 100644 --- a/app/(app)/staff/facturation/create/page.tsx +++ b/app/(app)/staff/facturation/create/page.tsx @@ -158,6 +158,13 @@ export default function CreateInvoicePage() { Créer une nouvelle facture + + + Saisie tableau +
diff --git a/app/(app)/staff/facturation/create/saisie-tableau/page.tsx b/app/(app)/staff/facturation/create/saisie-tableau/page.tsx new file mode 100644 index 0000000..1f47ebe --- /dev/null +++ b/app/(app)/staff/facturation/create/saisie-tableau/page.tsx @@ -0,0 +1,1045 @@ +"use client"; + +import React, { useCallback, useMemo, useRef, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { ArrowLeft, Save, Plus, Copy, Trash2, Upload, Download, ChevronDown, ChevronUp, FileText, FileX, Loader2 } from "lucide-react"; +import { usePageTitle } from "@/hooks/usePageTitle"; +import { api } from '@/lib/fetcher'; + +// ---------------- Types ---------------- +type Organization = { + id: string; + structure_api: string; +}; + +type InvoiceRow = { + id: string; + selected: boolean; + org_id: string; + organization_name: string; + numero: string; + periode: string; + date: string; + due_date: string; + payment_date: string; + sepa_day: string; + montant_ht: string | number; + montant_ttc: string | number; + statut: string; + notes: string; + pdf_s3_key?: string | null; + pdf_uploading?: boolean; +}; + +// ---------------- Utils ---------------- +function uid() { + return Math.random().toString(36).substring(2, 11); +} + +function incrementInvoiceNumber(numero: string): string { + if (!numero) return numero; + + // Chercher un nombre Ă  la fin du numĂ©ro (aprĂšs un tiret ou directement) + const match = numero.match(/^(.+?)(\d+)$/); + + if (match) { + const prefix = match[1]; + const number = parseInt(match[2], 10); + const incrementedNumber = number + 1; + // Garder le mĂȘme nombre de chiffres avec padding + const paddedNumber = incrementedNumber.toString().padStart(match[2].length, '0'); + return prefix + paddedNumber; + } + + // Si pas de nombre trouvĂ©, ajouter -1 Ă  la fin + return numero + "-1"; +} + +function calculateDueDate(invoiceDate: string): string { + if (!invoiceDate) return ""; + + const date = new Date(invoiceDate); + date.setDate(date.getDate() + 7); // J+7 + return date.toISOString().split('T')[0]; +} + +function calculateTTC(montantHT: string | number): string { + if (!montantHT || montantHT === "") return ""; + + const ht = typeof montantHT === 'string' ? parseFloat(montantHT) : montantHT; + if (isNaN(ht)) return ""; + + const ttc = ht * 1.20; // +20% + return ttc.toFixed(2); +} + +function emptyRow(rest: Partial = {}): InvoiceRow { + const today = new Date().toISOString().split('T')[0]; + const dueDate = rest.date ? calculateDueDate(rest.date) : calculateDueDate(today); + + return { + org_id: "", + organization_name: "", + numero: "", + periode: "", + date: today, + due_date: dueDate, + payment_date: "", + sepa_day: "", + montant_ht: "", + montant_ttc: "", + statut: "en_cours", + notes: "", + pdf_s3_key: null, + pdf_uploading: false, + ...rest, + id: uid(), + selected: false, + }; +} + +function validateRow(r: InvoiceRow) { + const errors: Record = {}; + if (!r.org_id) errors.org_id = "Requis"; + if (!r.numero) errors.numero = "Requis"; + if (!r.montant_ttc || Number(r.montant_ttc) <= 0) errors.montant_ttc = "> 0"; + if (!r.date) errors.date = "Requis"; + + // Validation des dates + if (r.due_date && r.date && r.due_date < r.date) { + errors.due_date = "ÉchĂ©ance < Date"; + } + if (r.payment_date && r.date && r.payment_date < r.date) { + errors.payment_date = "Paiement < Date"; + } + + return errors; +} + +function detectDelimiter(text: string): string { + const comma = (text.match(/,/g) || []).length; + const semicolon = (text.match(/;/g) || []).length; + const tab = (text.match(/\t/g) || []).length; + if (tab > 0) return '\t'; + return semicolon > comma ? ';' : ','; +} + +function parseCSV(text: string): string[][] { + const delim = detectDelimiter(text); + const rows: string[][] = []; + let row: string[] = []; + let field = ''; + let inQuotes = false; + + for (let i = 0; i < text.length; i++) { + const c = text[i]; + const next = text[i + 1]; + + if (c === '"') { + if (inQuotes && next === '"') { + field += '"'; + i++; + continue; + } + inQuotes = !inQuotes; + continue; + } + + if (!inQuotes && (c === '\n' || c === '\r')) { + if (c === '\r' && next === '\n') i++; + row.push(field); + field = ''; + if (row.length > 1 || row[0] !== '') rows.push(row); + row = []; + continue; + } + + if (!inQuotes && c === delim) { + row.push(field); + field = ''; + continue; + } + + field += c; + } + + row.push(field); + if (row.length > 1 || row[0] !== '') rows.push(row); + return rows; +} + +function normalizeDate(val: string): string { + if (!val) return ""; + const s = String(val).trim(); + + // DD/MM/YYYY + const m1 = s.match(/^([0-3]?\d)\/([0-1]?\d)\/(\d{4})$/); + if (m1) { + const dd = m1[1].padStart(2, '0'); + const mm = m1[2].padStart(2, '0'); + const yyyy = m1[3]; + return `${yyyy}-${mm}-${dd}`; + } + + // YYYY-MM-DD + const m2 = s.match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (m2) return s; + + return s; +} + +const PASTE_ORDER = [ + "org_id", + "numero", + "periode", + "date", + "due_date", + "payment_date", + "sepa_day", + "montant_ht", + "montant_ttc", + "statut", + "notes", +] as const; + +function rowsFromMatrix(parsed: string[][], organizations: Organization[]): InvoiceRow[] { + if (parsed.length === 0) return []; + + return parsed.map((cols) => { + const base = emptyRow(); + const assigned: Partial = {}; + + PASTE_ORDER.forEach((key, i) => { + const val = cols[i]; + if (val == null) return; + + switch (key) { + case 'org_id': + // Rechercher l'organisation par nom + const org = organizations.find(o => + o.structure_api.toLowerCase().includes(val.toLowerCase()) + ); + if (org) { + assigned.org_id = org.id; + assigned.organization_name = org.structure_api; + } else { + assigned.org_id = val; + } + break; + + case 'date': + case 'due_date': + case 'payment_date': + case 'sepa_day': + (assigned as any)[key] = normalizeDate(val); + break; + + case 'montant_ht': + case 'montant_ttc': + (assigned as any)[key] = val === '' ? '' : Number(String(val).replace(',', '.')); + break; + + case 'statut': + const statutNormalized = val.toLowerCase(); + if (statutNormalized.includes('emit') || statutNormalized.includes('emise')) assigned.statut = 'emise'; + else if (statutNormalized.includes('cours')) assigned.statut = 'en_cours'; + else if (statutNormalized.includes('pay')) assigned.statut = 'payee'; + else if (statutNormalized.includes('annul')) assigned.statut = 'annulee'; + else if (statutNormalized.includes('pret')) assigned.statut = 'prete'; + else if (statutNormalized.includes('brouillon')) assigned.statut = 'brouillon'; + else assigned.statut = val; + break; + + default: + (assigned as any)[key] = val; + } + }); + + return { ...base, ...assigned } as InvoiceRow; + }); +} + +const COLUMN_ORDER = [ + "org_id", + "numero", + "periode", + "date", + "due_date", + "payment_date", + "sepa_day", + "montant_ht", + "montant_ttc", + "statut", + "notes", +] as const; + +// ---------------- Classes ---------------- +const thCls = "text-left text-[11px] font-medium uppercase tracking-wide text-muted-foreground px-2 py-1 sticky top-0 bg-slate-50 z-10"; +const thStickyLeft = "text-left text-[11px] font-medium uppercase tracking-wide text-muted-foreground px-2 py-1 sticky top-0 bg-slate-50 z-20 border-r-2 border-slate-200 shadow-[2px_0_4px_rgba(0,0,0,0.05)]"; +const tdCls = "px-1.5 py-1 align-middle"; +const tdStickyLeft = "px-1.5 py-1 align-middle sticky bg-white z-10 border-r-2 border-slate-200 shadow-[2px_0_4px_rgba(0,0,0,0.05)]"; +const inputCls = (error?: boolean) => + `w-full rounded-md border px-2 py-1 h-8 text-[13px] ${error ? "border-red-500" : ""}`; +const selectCls = "w-full rounded-md border px-2 h-8 text-[13px] bg-white"; +const numberCls = (error?: boolean) => + `w-full rounded-md border px-2 py-1 h-8 text-right text-[13px] ${error ? "border-red-500" : ""}`; + +// ---------------- PDF Upload Component ---------------- +function PdfUploadCell({ + rowId, + pdfKey, + isUploading, + onUpload, + onRemove, + disabled +}: { + rowId: string; + pdfKey?: string | null; + isUploading?: boolean; + onUpload: (rowId: string, file: File) => void; + onRemove: (rowId: string) => void; + disabled?: boolean; +}) { + const [isDragging, setIsDragging] = useState(false); + const inputRef = useRef(null); + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!disabled) setIsDragging(true); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + + if (disabled) return; + + const files = Array.from(e.dataTransfer.files); + const pdfFile = files.find(f => f.type === 'application/pdf'); + + if (pdfFile) { + onUpload(rowId, pdfFile); + } else { + alert('Veuillez dĂ©poser un fichier PDF'); + } + }; + + const handleFileSelect = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + onUpload(rowId, file); + } + // Reset input + if (inputRef.current) { + inputRef.current.value = ''; + } + }; + + if (isUploading) { + return ( +
+ + Upload... +
+ ); + } + + if (pdfKey) { + return ( +
+
+ + PDF +
+ +
+ ); + } + + return ( + <> + +
!disabled && inputRef.current?.click()} + className={` + h-8 flex items-center justify-center border-2 border-dashed rounded cursor-pointer + transition-colors text-xs + ${disabled + ? 'border-slate-200 bg-slate-50 cursor-not-allowed text-slate-400' + : isDragging + ? 'border-blue-400 bg-blue-50 text-blue-600' + : 'border-slate-300 hover:border-slate-400 text-slate-500 hover:bg-slate-50' + } + `} + title={disabled ? "Remplir org + numéro d'abord" : "Cliquer ou glisser un PDF"} + > + + {disabled ? 'Requis' : 'PDF'} +
+ + ); +} + +// ---------------- Component ---------------- +export default function SaisieTableauFacturesPage() { + usePageTitle("Saisie tableau - Factures"); + const router = useRouter(); + + const [rows, setRows] = useState([emptyRow(), emptyRow(), emptyRow()]); + const [organizations, setOrganizations] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [errors, setErrors] = useState>>({}); + const [showHelp, setShowHelp] = useState(false); + const fileInputRef = useRef(null); + + // Charger les organisations + React.useEffect(() => { + (async () => { + try { + const result = await api<{ organizations: Organization[] }>("/staff/organizations"); + setOrganizations(result.organizations || []); + } catch (e) { + console.error("Erreur lors du chargement des organisations:", e); + } + })(); + }, []); + + // Validation + const validateAll = useCallback(() => { + const newErrors: Record> = {}; + rows.forEach((r) => { + const rowErrors = validateRow(r); + if (Object.keys(rowErrors).length > 0) { + newErrors[r.id] = rowErrors; + } + }); + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }, [rows]); + + // Actions sur les lignes + const updateRow = useCallback((id: string, field: keyof InvoiceRow, value: any) => { + setRows((prev) => + prev.map((r) => { + if (r.id !== id) return r; + const updated = { ...r, [field]: value }; + + // Si on change l'org, mettre Ă  jour le nom + if (field === 'org_id') { + const org = organizations.find(o => o.id === value); + if (org) { + updated.organization_name = org.structure_api; + } + } + + // Si on change la date de facture, calculer automatiquement la date d'Ă©chĂ©ance (J+7) + if (field === 'date' && value) { + updated.due_date = calculateDueDate(value); + } + + // Si on change le montant HT, calculer automatiquement le TTC (+20%) + if (field === 'montant_ht' && value !== "") { + updated.montant_ttc = calculateTTC(value); + } + + return updated; + }) + ); + }, [organizations]); + + const addRow = useCallback(() => { + setRows((prev) => [...prev, emptyRow()]); + }, []); + + const duplicateRow = useCallback((id: string) => { + setRows((prev) => { + const idx = prev.findIndex((r) => r.id === id); + if (idx === -1) return prev; + + const original = prev[idx]; + const copy = emptyRow({ + ...original, + numero: incrementInvoiceNumber(original.numero), + pdf_s3_key: null, // Ne pas dupliquer le PDF + pdf_uploading: false, + }); + + return [...prev.slice(0, idx + 1), copy, ...prev.slice(idx + 1)]; + }); + }, []); + + const deleteRow = useCallback((id: string) => { + setRows((prev) => prev.filter((r) => r.id !== id)); + }, []); + + const toggleSelect = useCallback((id: string) => { + setRows((prev) => + prev.map((r) => (r.id === id ? { ...r, selected: !r.selected } : r)) + ); + }, []); + + const toggleSelectAll = useCallback(() => { + const allSelected = rows.every((r) => r.selected); + setRows((prev) => prev.map((r) => ({ ...r, selected: !allSelected }))); + }, [rows]); + + const deleteSelected = useCallback(() => { + setRows((prev) => prev.filter((r) => !r.selected)); + }, []); + + // Upload PDF pour une ligne + const handlePdfUpload = useCallback(async (rowId: string, file: File) => { + const row = rows.find(r => r.id === rowId); + if (!row) return; + + if (file.type !== 'application/pdf') { + alert('Seuls les fichiers PDF sont acceptĂ©s.'); + return; + } + + if (!row.org_id || !row.numero) { + alert('Veuillez remplir l\'organisation et le numĂ©ro de facture avant d\'uploader le PDF.'); + return; + } + + // Marquer comme en cours d'upload + setRows(prev => prev.map(r => + r.id === rowId ? { ...r, pdf_uploading: true } : r + )); + + try { + const formData = new FormData(); + formData.append('pdf', file); + formData.append('org_id', row.org_id); + formData.append('invoice_number', row.numero); + + const response = await fetch('/api/staff/facturation/upload-pdf', { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || 'Upload failed'); + } + + const result = await response.json(); + + // Mettre Ă  jour la ligne avec le s3_key + setRows(prev => prev.map(r => + r.id === rowId + ? { ...r, pdf_s3_key: result.s3_key, pdf_uploading: false } + : r + )); + } catch (error) { + console.error('Upload error:', error); + alert(`Erreur d'upload : ${error instanceof Error ? error.message : 'erreur inconnue'}`); + + // Retirer l'Ă©tat d'upload + setRows(prev => prev.map(r => + r.id === rowId ? { ...r, pdf_uploading: false } : r + )); + } + }, [rows]); + + const handleRemovePdf = useCallback((rowId: string) => { + setRows(prev => prev.map(r => + r.id === rowId ? { ...r, pdf_s3_key: null } : r + )); + }, []); + + // Import CSV + const handleFileImport = useCallback((e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (evt) => { + const text = evt.target?.result as string; + if (!text) return; + + const parsed = parseCSV(text); + if (parsed.length === 0) return; + + // Skip header si prĂ©sent + const hasHeader = parsed[0].some(cell => + cell.toLowerCase().includes('organisation') || + cell.toLowerCase().includes('numero') || + cell.toLowerCase().includes('montant') + ); + + const dataRows = hasHeader ? parsed.slice(1) : parsed; + const newRows = rowsFromMatrix(dataRows, organizations); + + setRows(newRows.length > 0 ? newRows : [emptyRow()]); + }; + reader.readAsText(file); + + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }, [organizations]); + + // Export CSV template + const exportTemplate = useCallback(() => { + const headers = [ + "Organisation", + "NumĂ©ro", + "PĂ©riode", + "Date", + "Date Ă©chĂ©ance", + "Date paiement", + "Date SEPA", + "Montant HT", + "Montant TTC", + "Statut", + "Notes" + ]; + + const example = [ + "NomOrganisation", + "FAC-2025-001", + "Janvier 2025", + "2025-01-15", + "2025-02-15", + "", + "", + "1000.00", + "1200.00", + "emise", + "Notes facultatives" + ]; + + const note = [ + "", + "Note: Les PDFs doivent ĂȘtre uploadĂ©s manuellement via le tableau (colonne PDF)", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ]; + + const csv = [headers.join(';'), example.join(';'), note.join(';')].join('\n'); + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = 'template_factures.csv'; + link.click(); + }, []); + + // Soumission + const handleSubmit = useCallback(async (e: React.FormEvent) => { + e.preventDefault(); + + if (!validateAll()) { + alert("Veuillez corriger les erreurs avant de soumettre."); + return; + } + + if (rows.length === 0) { + alert("Aucune facture Ă  crĂ©er."); + return; + } + + const confirmed = confirm( + `Vous allez crĂ©er ${rows.length} facture(s). Continuer ?` + ); + if (!confirmed) return; + + setIsLoading(true); + + try { + const payload = rows.map(r => ({ + org_id: r.org_id, + numero: r.numero, + periode: r.periode || null, + date: r.date || null, + due_date: r.due_date || null, + payment_date: r.payment_date || null, + sepa_day: r.sepa_day || null, + montant_ht: Number(r.montant_ht) || 0, + montant_ttc: Number(r.montant_ttc) || 0, + statut: r.statut, + notes: r.notes || null, + pdf_s3_key: r.pdf_s3_key || null, + })); + + const result = await api<{ created: number }>("/staff/facturation/bulk-create", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ invoices: payload }), + }); + + alert(`${result.created || rows.length} facture(s) créée(s) avec succĂšs !`); + router.push("/staff/facturation"); + } catch (error: any) { + console.error("Erreur lors de la crĂ©ation:", error); + alert(`Erreur : ${error.message || "erreur inconnue"}`); + } finally { + setIsLoading(false); + } + }, [rows, validateAll, router]); + + const selectedCount = rows.filter((r) => r.selected).length; + + return ( +
+ {/* Header */} +
+
+ + + Retour + +
/
+

+ Création factures - Saisie tableau +

+
+ + +
+ + {/* Aide */} + {showHelp && ( +
+

Comment utiliser la saisie tableau ?

+
    +
  • Remplissez les lignes directement dans le tableau
  • +
  • Utilisez Tab/Shift+Tab pour naviguer entre les colonnes
  • +
  • Cliquez sur "Ajouter une ligne" pour crĂ©er plus de lignes
  • +
  • Importez un fichier CSV pour prĂ©-remplir plusieurs factures
  • +
  • TĂ©lĂ©chargez le modĂšle CSV pour voir le format attendu
  • +
  • Les champs obligatoires : Organisation, NumĂ©ro, Montant TTC, Date
  • +
  • Pour chaque ligne, vous pouvez glisser-dĂ©poser un PDF ou cliquer sur la colonne PDF
  • +
  • Les PDFs doivent ĂȘtre ajoutĂ©s aprĂšs avoir rempli l'organisation et le numĂ©ro
  • +
+ +

Automatismes :

+
    +
  • Le statut par dĂ©faut est "En cours"
  • +
  • La date d'Ă©chĂ©ance se calcule automatiquement Ă  J+7 de la date de facture
  • +
  • Le montant TTC se calcule automatiquement (+20% du HT)
  • +
  • Lors de la duplication d'une ligne, le numĂ©ro de facture s'incrĂ©mente automatiquement
  • +
  • Tous ces champs restent modifiables manuellement
  • +
+
+ )} + + {/* Actions toolbar */} +
+
+
+ + + + + + +
+ +
+ {selectedCount > 0 && ( + <> + + {selectedCount} ligne{selectedCount > 1 ? "s" : ""} sélectionnée{selectedCount > 1 ? "s" : ""} + + + + )} + +
+ {rows.length} ligne{rows.length > 1 ? "s" : ""} +
+
+
+
+ + {/* Tableau */} + +
+
+
État +
+ État + {regime === 'CDDU' && ( +
{ + const rect = e.currentTarget.getBoundingClientRect(); + setTooltipPos({ + top: rect.top + rect.height / 2, + left: rect.right + }); + setShowTooltip(true); + }} + onMouseLeave={() => setShowTooltip(false)} + > + +
+ )} +
+
Référence Salarié
+ + + + + + + + + + + + + + + + + + + + {rows.map((row, idx) => { + const rowErrors = errors[row.id] || {}; + + return ( + + + + + + + + + + + + + + + + + ); + })} + +
+ 0 && rows.every((r) => r.selected)} + onChange={toggleSelectAll} + className="rounded" + /> + ActionsOrganisation *NumĂ©ro *PĂ©riodeDate *ÉchĂ©ancePaiementSEPAMontant HTMontant TTC *StatutPDFNotes
+ toggleSelect(row.id)} + className="rounded" + /> + +
+ + +
+
+ + + updateRow(row.id, "numero", e.target.value)} + className={inputCls(!!rowErrors.numero)} + placeholder="FAC-2025-001" + /> + + updateRow(row.id, "periode", e.target.value)} + className={inputCls()} + placeholder="Janvier 2025" + /> + + updateRow(row.id, "date", e.target.value)} + className={inputCls(!!rowErrors.date)} + /> + + updateRow(row.id, "due_date", e.target.value)} + className={inputCls(!!rowErrors.due_date)} + /> + + updateRow(row.id, "payment_date", e.target.value)} + className={inputCls(!!rowErrors.payment_date)} + /> + + updateRow(row.id, "sepa_day", e.target.value)} + className={inputCls()} + /> + + updateRow(row.id, "montant_ht", e.target.value)} + className={numberCls()} + placeholder="0.00" + /> + + updateRow(row.id, "montant_ttc", e.target.value)} + className={numberCls(!!rowErrors.montant_ttc)} + placeholder="0.00" + /> + + + + + + updateRow(row.id, "notes", e.target.value)} + className={inputCls()} + placeholder="Notes..." + /> +
+ + + + {/* Footer actions */} +
+
+ {Object.keys(errors).length > 0 && ( + + {Object.keys(errors).length} ligne{Object.keys(errors).length > 1 ? "s" : ""} avec erreur{Object.keys(errors).length > 1 ? "s" : ""} + + )} +
+ +
+ + Annuler + + +
+
+ + + ); +} diff --git a/app/(app)/staff/facturation/page.tsx b/app/(app)/staff/facturation/page.tsx index 05c64d5..1c062b6 100644 --- a/app/(app)/staff/facturation/page.tsx +++ b/app/(app)/staff/facturation/page.tsx @@ -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(""); 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() {

Facturation

Gestion des factures de tous les clients

- - - Créer une facture - +
+ + + Saisie tableau + + + + Créer une facture + +
{/* Filtres */} @@ -622,6 +729,64 @@ export default function StaffFacturationPage() { )} + {/* Card clients sans facture pour la période sélectionnée */} + {periodFilter && ( +
+
+

+ Clients actifs sans facture - {periodFilter} +

+ {isLoadingClientsWithoutInvoice && ( + + )} +
+
+ {isLoadingClientsWithoutInvoice ? ( +
+ + Chargement... +
+ ) : clientsWithoutInvoiceData && clientsWithoutInvoiceData.count > 0 ? ( + <> +
+
+ {clientsWithoutInvoiceData.count} +
+
+ Client{clientsWithoutInvoiceData.count > 1 ? 's' : ''} actif{clientsWithoutInvoiceData.count > 1 ? 's' : ''} sans facture +
+
+
+ {clientsWithoutInvoiceData.clients.map((client) => ( +
+
+
{client.name}
+ {client.structure_api && ( +
{client.structure_api}
+ )} +
+ + Voir + +
+ ))} +
+ + ) : ( +
+ Tous les clients actifs ont une facture pour cette période. +
+ )} +
+
+ )} + {/* Liste des factures */}
@@ -650,26 +815,79 @@ export default function StaffFacturationPage() {
- - + + {/* Menu déroulant pour les dates */} +
+ + + {showDateMenu && ( + <> + {/* Overlay pour fermer le menu */} +
setShowDateMenu(false)} + /> + + {/* Menu */} +
+ + + + +
+ + )} +
+
@@ -1009,6 +1227,52 @@ export default function StaffFacturationPage() { )} + {/* Modal pour modification en masse de la date de paiement */} + {showPaymentDateModal && ( +
+
+

Modifier la date de paiement

+ +

+ Cette action va modifier la date de paiement pour {selectedInvoices.size} facture{selectedInvoices.size > 1 ? "s" : ""} sélectionnée{selectedInvoices.size > 1 ? "s" : ""}. +

+ +
+ + setNewPaymentDate(e.target.value)} + className="w-full px-3 py-2 border rounded-lg bg-white" + /> +
+ +
+ + +
+
+
+ )} + {/* Modal de confirmation GoCardless bulk */} @@ -1080,6 +1344,59 @@ export default function StaffFacturationPage() { + + {/* Modal pour modification en masse du statut */} + {showStatusModal && ( +
+
+

Modifier le statut

+ +

+ Cette action va modifier le statut pour {selectedInvoices.size} facture{selectedInvoices.size > 1 ? "s" : ""} sélectionnée{selectedInvoices.size > 1 ? "s" : ""}. +

+ +
+ + +
+ +
+ + +
+
+
+ )} ); } \ No newline at end of file diff --git a/app/(app)/staff/payslips/page.tsx b/app/(app)/staff/payslips/page.tsx index b67b722..bc9eff9 100644 --- a/app/(app)/staff/payslips/page.tsx +++ b/app/(app)/staff/payslips/page.tsx @@ -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 }) diff --git a/app/(app)/virements-salaires/page.tsx b/app/(app)/virements-salaires/page.tsx index 188bf3f..c61423f 100644 --- a/app/(app)/virements-salaires/page.tsx +++ b/app/(app)/virements-salaires/page.tsx @@ -373,6 +373,8 @@ export default function VirementsPage() { const [selectedOrgId, setSelectedOrgId] = useState(""); const [pdfModalOpen, setPdfModalOpen] = useState(false); const [pdfUrl, setPdfUrl] = useState(""); + const [undoModalOpen, setUndoModalOpen] = useState(false); + const [undoPayslipId, setUndoPayslipId] = useState(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() { Net Ă  payer Marquer comme payĂ© + {/* Sous-header avec le total des nets Ă  payer (salaires non payĂ©s) */} + {!isLoading && !isError && clientUnpaid.length > 0 && ( + + +
+
+ Salaires Ă  payer +
+
+ ({clientUnpaid.length} salarié{clientUnpaid.length > 1 ? 's' : ''}) +
+
+ + +
+ Total : {formatCurrency(totalNetAPayer)} +
+ + + )} {/* Unpaid first */} @@ -897,9 +951,28 @@ export default function VirementsPage() { ))} {clientRecent.length > 0 && ( - - RĂ©cemment virĂ©s (≀ 30 jours) - + <> + {/* SĂ©paration visuelle renforcĂ©e */} + + + + + +
+
+ +
+
+
Virements récemment effectués
+
Paies virées au cours des 30 derniers jours
+
+ Si vous avez noté une paie comme payée par erreur, cliquez sur "Oui" pour la noter comme non-payée. +
+
+
+ + + )} {clientRecent.map((it) => ( @@ -932,7 +1005,18 @@ export default function VirementsPage() { {formatPeriode(it.periode)} {it.net_a_payer != null ? formatCurrency(it.net_a_payer) : '—'} - Oui + {it.source === 'payslip' ? ( + + ) : ( + Oui + )} ))} @@ -1072,6 +1156,51 @@ export default function VirementsPage() { )} + + {/* Modal de confirmation pour annuler le marquage */} + {undoModalOpen && undoPayslipId && ( +
+
{ + setUndoModalOpen(false); + setUndoPayslipId(null); + }} + /> +
+
+
+

Annuler le marquage du virement

+
+
+

+ Êtes-vous sĂ»r de vouloir marquer cette paie comme non payĂ©e ? +

+

+ Elle réapparaßtra dans la liste des paies à payer. +

+
+
+ + +
+
+
+
+ )}
); } diff --git a/app/api/contrats/[id]/route.ts b/app/api/contrats/[id]/route.ts index 2ffee1d..16ec53c 100644 --- a/app/api/contrats/[id]/route.ts +++ b/app/api/contrats/[id]/route.ts @@ -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) { diff --git a/app/api/contrats/route.ts b/app/api/contrats/route.ts index 0fd481c..ec36aaf 100644 --- a/app/api/contrats/route.ts +++ b/app/api/contrats/route.ts @@ -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"), }; diff --git a/app/api/payslips/[id]/route.ts b/app/api/payslips/[id]/route.ts index f826668..31c0842 100644 --- a/app/api/payslips/[id]/route.ts +++ b/app/api/payslips/[id]/route.ts @@ -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(); diff --git a/app/api/staff/clients/[id]/request-sepa-mandate/route.ts b/app/api/staff/clients/[id]/request-sepa-mandate/route.ts new file mode 100644 index 0000000..2c020be --- /dev/null +++ b/app/api/staff/clients/[id]/request-sepa-mandate/route.ts @@ -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 { + 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 }); + } +} diff --git a/app/api/staff/clients/[id]/route.ts b/app/api/staff/clients/[id]/route.ts index 6d3aa65..43eb135 100644 --- a/app/api/staff/clients/[id]/route.ts +++ b/app/api/staff/clients/[id]/route.ts @@ -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 }); diff --git a/app/api/staff/contracts/bulk-update-jours-technicien/route.ts b/app/api/staff/contracts/bulk-update-jours-technicien/route.ts new file mode 100644 index 0000000..a3bd993 --- /dev/null +++ b/app/api/staff/contracts/bulk-update-jours-technicien/route.ts @@ -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 }); + } +} diff --git a/app/api/staff/contracts/bulk-update-signature-date/route.ts b/app/api/staff/contracts/bulk-update-signature-date/route.ts new file mode 100644 index 0000000..366a466 --- /dev/null +++ b/app/api/staff/contracts/bulk-update-signature-date/route.ts @@ -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 = {}; + 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 }); + } +} diff --git a/app/api/staff/contracts/search/route.ts b/app/api/staff/contracts/search/route.ts index 7e10ff7..3535b0f 100644 --- a/app/api/staff/contracts/search/route.ts +++ b/app/api/staff/contracts/search/route.ts @@ -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" }); diff --git a/app/api/staff/facturation/bulk-create/route.ts b/app/api/staff/facturation/bulk-create/route.ts new file mode 100644 index 0000000..29123d4 --- /dev/null +++ b/app/api/staff/facturation/bulk-create/route.ts @@ -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 { + 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 } + ); + } +} diff --git a/app/api/staff/facturation/bulk-update-payment-date/route.ts b/app/api/staff/facturation/bulk-update-payment-date/route.ts new file mode 100644 index 0000000..8286fdc --- /dev/null +++ b/app/api/staff/facturation/bulk-update-payment-date/route.ts @@ -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 { + 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 } + ); + } +} diff --git a/app/api/staff/facturation/bulk-update-status/route.ts b/app/api/staff/facturation/bulk-update-status/route.ts new file mode 100644 index 0000000..1eebb86 --- /dev/null +++ b/app/api/staff/facturation/bulk-update-status/route.ts @@ -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 { + 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 } + ); + } +} diff --git a/app/api/staff/facturation/clients-sans-facture/route.ts b/app/api/staff/facturation/clients-sans-facture/route.ts new file mode 100644 index 0000000..710aff2 --- /dev/null +++ b/app/api/staff/facturation/clients-sans-facture/route.ts @@ -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 { + 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 }); + } +} diff --git a/app/api/staff/payslip-upload/route.ts b/app/api/staff/payslip-upload/route.ts index 2b6d969..939a2c6 100644 --- a/app/api/staff/payslip-upload/route.ts +++ b/app/api/staff/payslip-upload/route.ts @@ -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" }); diff --git a/app/api/staff/payslips/[id]/route.ts b/app/api/staff/payslips/[id]/route.ts index de34e18..73e0d4f 100644 --- a/app/api/staff/payslips/[id]/route.ts +++ b/app/api/staff/payslips/[id]/route.ts @@ -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(); diff --git a/app/api/staff/payslips/search/route.ts b/app/api/staff/payslips/search/route.ts index 703bcf1..393a3f6 100644 --- a/app/api/staff/payslips/search/route.ts +++ b/app/api/staff/payslips/search/route.ts @@ -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Ă©) diff --git a/app/api/virements-salaires/route.ts b/app/api/virements-salaires/route.ts index 2b321e3..defe09c 100644 --- a/app/api/virements-salaires/route.ts +++ b/app/api/virements-salaires/route.ts @@ -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; }); diff --git a/app/api/webhooks/docuseal-contract/route.ts b/app/api/webhooks/docuseal-contract/route.ts new file mode 100644 index 0000000..0670f43 --- /dev/null +++ b/app/api/webhooks/docuseal-contract/route.ts @@ -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 }); + } +} diff --git a/components/DatesQuantityModal.tsx b/components/DatesQuantityModal.tsx index 46f50a2..62a917c 100644 --- a/components/DatesQuantityModal.tsx +++ b/components/DatesQuantityModal.tsx @@ -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(false); + + // État pour le nombre global saisi quand skipHoursByDay est cochĂ© + const [globalQuantity, setGlobalQuantity] = useState(""); + + // É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(""); @@ -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({
{/* Card option pour ne pas appliquer d'heures par jour */} {allowSkipHoursByDay && ( -
+
+ + {/* Champ de saisie du nombre global si coché */} + {skipHoursByDay && ( +
+ + setGlobalQuantity(e.target.value)} + className="bg-white" + /> + + {/* Durée pour les répétitions */} + {dateType === "repetitions" && ( +
+ +
+ + +
+
+ )} +
+ )}
)} diff --git a/components/contrats/NouveauCDDUForm.tsx b/components/contrats/NouveauCDDUForm.tsx index dd2c8f0..1a96355 100644 --- a/components/contrats/NouveauCDDUForm.tsx +++ b/components/contrats/NouveauCDDUForm.tsx @@ -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(""); 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) <> - -
- -
- - -
-
-
+ {/* Question multi-mois masquée - la logique reste active en arriÚre-plan */} )} - -
- - { - 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" - /> -
- {/* Masquer la date de fin si CDI en mode RG */} - {!(isRegimeRG && typeContratRG === "CDI") && ( -
- - setDateFin(e.target.value)} - className="w-full px-3 py-2 rounded-lg border bg-white text-sm" - /> -

Pour un contrat d'une journĂ©e, sĂ©lectionnez la mĂȘme date que dĂ©but.

-
- )} -
- {/* Sections spécifiques aux CDDU - masquées en mode RG */} {!isRegimeRG && ( !useHeuresMode ? ( <> - -
- - setNbRep(e.target.value === "" ? "" : Number(e.target.value))} - className="w-full px-3 py-2 rounded-lg border bg-white text-sm" - /> -
-
- -
- setNbServ(e.target.value === "" ? "" : Number(e.target.value))} - placeholder="Nombre" - className="flex-1 px-3 py-2 rounded-lg border bg-white text-sm" - /> - × - {typeof nbServ === "number" && nbServ > 0 && ( - - )} -
-
-
-
-
+
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
"}
+
+ + +
+ + { + 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' + }`} + /> +
+ {/* Masquer la date de fin si CDI en mode RG */} + {!(isRegimeRG && typeContratRG === "CDI") && ( +
+ + 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' + }`} + /> +
+ )} +
+ + {/* Sections spécifiques aux CDDU - masquées en mode RG */} + {!isRegimeRG && !useHeuresMode && ( + +
+ + 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' + }`} + /> +
+
+ +
+ 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' + }`} + /> + × + {typeof nbServ === "number" && nbServ > 0 && ( + + )} +
+
+
+ )} + + {/* Affichage du type de contrat */} + {!isRegimeRG && ( +

+ Type : {isMultiMois === "Oui" ? "CDDU multi-mois" : "CDDU mono-mois"} +

+ )} +
+ + {/* Sections spécifiques aux CDDU - masquées en mode RG */} + {!isRegimeRG && ( + useHeuresMode ? ( <>
@@ -2230,7 +2350,7 @@ useEffect(() => { maxDate={dateFin} /> - ) + ) : null )}
diff --git a/components/staff/ContractsGrid.tsx b/components/staff/ContractsGrid.tsx index 7bfd122..67b7e50 100644 --- a/components/staff/ContractsGrid.tsx +++ b/components/staff/ContractsGrid.tsx @@ -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(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(); + 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 Voir les dĂ©tails + + +