feat(calculator): afficher l’expression du calcul, arrondir à 2 décimales et réinitialiser la saisie après calcul; feat(form): forcer 2 décimales dans Montant quand on utilise la calculatrice; fix(tooltip): placement top au-dessus du trigger; embed(minima): runtime CSS en mode embed

This commit is contained in:
Renaud 2025-10-21 07:28:34 +02:00
parent b3b56a9b4e
commit 2fca0fcbf2
12 changed files with 1368 additions and 308 deletions

View file

@ -12,9 +12,12 @@ import EmploisNonArtistiquesContent from './emplois-non-artistiques-data';
import SimulateurContent from '@/components/simulateur/SimulateurContent';
import CalculatorComponent from '@/components/Calculator';
import { useDraggableModal } from '@/hooks/useDraggableModal';
import { useSearchParams } from 'next/navigation';
export default function CCNEACPage() {
usePageTitle("Minima CCNEAC");
const searchParams = useSearchParams();
const isEmbed = searchParams.get('embed') === '1';
const [isSimulateurOpen, setIsSimulateurOpen] = useState(false);
const [isCalculatorOpen, setIsCalculatorOpen] = useState(false);
const [modalPosition, setModalPosition] = useState({ x: 0, y: 0 });
@ -40,6 +43,32 @@ export default function CCNEACPage() {
return () => window.removeEventListener('message', handleMessage);
}, []);
// If embedded, post height to parent for auto-resize
useEffect(() => {
if (!isEmbed) return;
// Inject minimal CSS to hide sidebar/header and tighten paddings
const styleTag = document.createElement('style');
styleTag.textContent = `aside, header { display: none !important; } main { padding: 8px !important; } body { background: transparent; }`;
document.head.appendChild(styleTag);
function postHeight() {
try {
const h = document.documentElement.scrollHeight;
window.parent?.postMessage({ type: 'minima-ccn:height', height: h }, '*');
} catch {}
}
postHeight();
const ro = new ResizeObserver(() => postHeight());
ro.observe(document.body);
window.addEventListener('load', postHeight);
window.addEventListener('resize', postHeight);
return () => {
ro.disconnect();
window.removeEventListener('load', postHeight);
window.removeEventListener('resize', postHeight);
styleTag.remove();
};
}, [isEmbed]);
useEffect(() => {
// Script de gestion des onglets
const tabs = Array.from(document.querySelectorAll('[role="tab"]'));
@ -94,7 +123,7 @@ export default function CCNEACPage() {
}, []);
return (
<div className="space-y-6">
<div className={isEmbed ? "space-y-3" : "space-y-6"}>
<style jsx global>{`
.ccneac-tabs [role="tablist"] {
display: flex;
@ -143,15 +172,18 @@ export default function CCNEACPage() {
`}</style>
{/* Navigation retour */}
<Link
href="/minima-ccn"
className="inline-flex items-center gap-2 text-sm text-slate-600 hover:text-slate-900 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
Retour aux minima CCN
</Link>
{!isEmbed && (
<Link
href="/minima-ccn"
className="inline-flex items-center gap-2 text-sm text-slate-600 hover:text-slate-900 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
Retour aux minima CCN
</Link>
)}
{/* En-tête */}
{!isEmbed && (
<section className="rounded-2xl border bg-white p-6">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-12 h-12 rounded-xl bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center">
@ -175,12 +207,15 @@ export default function CCNEACPage() {
</div>
</div>
</section>
)}
{/* Onglets */}
<section className="rounded-2xl border bg-white p-6 ccneac-tabs">
<p className="text-sm text-slate-600 mb-4">
Cliquez sur un onglet pour accéder aux minima par catégorie d'artistes ou de personnel.
</p>
<section className={isEmbed ? "rounded-lg border bg-white p-3 ccneac-tabs" : "rounded-2xl border bg-white p-6 ccneac-tabs"}>
{!isEmbed && (
<p className="text-sm text-slate-600 mb-4">
Cliquez sur un onglet pour accéder aux minima par catégorie d'artistes ou de personnel.
</p>
)}
<div role="tablist" aria-label="Onglets CCNEAC">
<button
@ -244,6 +279,7 @@ export default function CCNEACPage() {
</section>
{/* Bouton flottant Simulateur */}
{!isEmbed && (
<button
onClick={() => setIsSimulateurOpen(!isSimulateurOpen)}
className="fixed bottom-6 right-6 z-50 inline-flex items-center gap-2 px-5 py-3 rounded-full text-sm font-bold bg-gradient-to-r from-amber-400 to-amber-500 text-slate-900 hover:from-amber-500 hover:to-amber-600 transition-all shadow-lg hover:shadow-xl hover:scale-105"
@ -251,9 +287,10 @@ export default function CCNEACPage() {
<Calculator className="w-5 h-5" />
Simulateur
</button>
)}
{/* Modale compacte qui sort du bouton */}
{isSimulateurOpen && (
{!isEmbed && isSimulateurOpen && (
<>
{/* Modale compacte déplaçable */}
<div
@ -299,10 +336,12 @@ export default function CCNEACPage() {
)}
{/* Calculatrice globale */}
<CalculatorComponent
isOpen={isCalculatorOpen}
onClose={() => setIsCalculatorOpen(false)}
/>
{!isEmbed && (
<CalculatorComponent
isOpen={isCalculatorOpen}
onClose={() => setIsCalculatorOpen(false)}
/>
)}
</div>
);
}

View file

@ -15,9 +15,12 @@ import ArtistesInterpretes from './artistes-interpretes';
import SimulateurContent from '@/components/simulateur/SimulateurContent';
import CalculatorComponent from '@/components/Calculator';
import { useDraggableModal } from '@/hooks/useDraggableModal';
import { useSearchParams } from 'next/navigation';
export default function CCNPAPage() {
usePageTitle("Minima CCNPA");
const searchParams = useSearchParams();
const isEmbed = searchParams.get('embed') === '1';
const [isSimulateurOpen, setIsSimulateurOpen] = useState(false);
const [isCalculatorOpen, setIsCalculatorOpen] = useState(false);
const [modalPosition, setModalPosition] = useState({ x: 0, y: 0 });
@ -43,6 +46,31 @@ export default function CCNPAPage() {
return () => window.removeEventListener('message', handleMessage);
}, []);
// Post height to parent if embedded
useEffect(() => {
if (!isEmbed) return;
const styleTag = document.createElement('style');
styleTag.textContent = `aside, header { display: none !important; } main { padding: 8px !important; } body { background: transparent; }`;
document.head.appendChild(styleTag);
function postHeight() {
try {
const h = document.documentElement.scrollHeight;
window.parent?.postMessage({ type: 'minima-ccn:height', height: h }, '*');
} catch {}
}
postHeight();
const ro = new ResizeObserver(() => postHeight());
ro.observe(document.body);
window.addEventListener('load', postHeight);
window.addEventListener('resize', postHeight);
return () => {
ro.disconnect();
window.removeEventListener('load', postHeight);
window.removeEventListener('resize', postHeight);
styleTag.remove();
};
}, [isEmbed]);
useEffect(() => {
// Script de gestion des onglets
const tabs = Array.from(document.querySelectorAll('[role="tab"]'));
@ -97,7 +125,7 @@ export default function CCNPAPage() {
}, []);
return (
<div className="space-y-6">
<div className={isEmbed ? "space-y-4" : "space-y-6"}>
<style jsx global>{`
.ccnpa-tabs [role="tablist"] {
display: flex;
@ -146,30 +174,38 @@ export default function CCNPAPage() {
`}</style>
{/* Navigation retour */}
<Link
href="/minima-ccn"
className="inline-flex items-center gap-2 text-sm text-slate-600 hover:text-slate-900 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
Retour aux minima CCN
</Link>
{!isEmbed && (
<Link
href="/minima-ccn"
className="inline-flex items-center gap-2 text-sm text-slate-600 hover:text-slate-900 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
Retour aux minima CCN
</Link>
)}
{/* En-tête */}
<section className="rounded-2xl border bg-white p-6">
<section className={isEmbed ? "rounded-xl border bg-white p-4" : "rounded-2xl border bg-white p-6"}>
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-12 h-12 rounded-xl bg-gradient-to-br from-cyan-500 to-blue-600 flex items-center justify-center">
<Scale className="w-6 h-6 text-white" />
</div>
{!isEmbed && (
<div className="flex-shrink-0 w-12 h-12 rounded-xl bg-gradient-to-br from-cyan-500 to-blue-600 flex items-center justify-center">
<Scale className="w-6 h-6 text-white" />
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<h1 className="text-2xl font-semibold text-slate-900">CCNPA (IDCC 2642)</h1>
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-gradient-to-r from-emerald-500 to-emerald-600 text-white shadow-sm">
À jour 2025
</span>
<h1 className={isEmbed ? "text-base font-semibold text-slate-900" : "text-2xl font-semibold text-slate-900"}>CCNPA (IDCC 2642)</h1>
{!isEmbed && (
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-gradient-to-r from-emerald-500 to-emerald-600 text-white shadow-sm">
À jour 2025
</span>
)}
</div>
<p className="text-sm text-slate-600 mb-2">
Convention Collective Nationale de la Production Audiovisuelle
</p>
{!isEmbed && (
<p className="text-sm text-slate-600 mb-2">
Convention Collective Nationale de la Production Audiovisuelle
</p>
)}
<p className="text-xs text-slate-500">
Grille des minima conventionnels pour les salariés de la production audiovisuelle.
<br />
@ -180,10 +216,12 @@ export default function CCNPAPage() {
</section>
{/* Onglets */}
<section className="rounded-2xl border bg-white p-6 ccnpa-tabs">
<p className="text-sm text-slate-600 mb-4">
Cliquez sur un onglet pour accéder aux minima par catégorie professionnelle.
</p>
<section className={isEmbed ? "rounded-xl border bg-white p-4 ccnpa-tabs" : "rounded-2xl border bg-white p-6 ccnpa-tabs"}>
{!isEmbed && (
<p className="text-sm text-slate-600 mb-4">
Cliquez sur un onglet pour accéder aux minima par catégorie professionnelle.
</p>
)}
<div role="tablist" aria-label="Onglets CCNPA">
<button
@ -287,6 +325,7 @@ export default function CCNPAPage() {
</section>
{/* Bouton flottant Simulateur */}
{!isEmbed && (
<button
onClick={() => setIsSimulateurOpen(!isSimulateurOpen)}
className="fixed bottom-6 right-6 z-50 inline-flex items-center gap-2 px-5 py-3 rounded-full text-sm font-bold bg-gradient-to-r from-amber-400 to-amber-500 text-slate-900 hover:from-amber-500 hover:to-amber-600 transition-all shadow-lg hover:shadow-xl hover:scale-105"
@ -294,9 +333,10 @@ export default function CCNPAPage() {
<Calculator className="w-5 h-5" />
Simulateur
</button>
)}
{/* Modale compacte qui sort du bouton */}
{isSimulateurOpen && (
{!isEmbed && isSimulateurOpen && (
<>
{/* Modale compacte déplaçable */}
<div
@ -342,10 +382,12 @@ export default function CCNPAPage() {
)}
{/* Calculatrice globale */}
<CalculatorComponent
isOpen={isCalculatorOpen}
onClose={() => setIsCalculatorOpen(false)}
/>
{!isEmbed && (
<CalculatorComponent
isOpen={isCalculatorOpen}
onClose={() => setIsCalculatorOpen(false)}
/>
)}
</div>
);
}

View file

@ -14,9 +14,12 @@ import Annexe6Content from './annexe6-data';
import SimulateurContent from '@/components/simulateur/SimulateurContent';
import CalculatorComponent from '@/components/Calculator';
import { useDraggableModal } from '@/hooks/useDraggableModal';
import { useSearchParams } from 'next/navigation';
export default function CCNSVPPage() {
usePageTitle("Minima CCNSVP");
const searchParams = useSearchParams();
const isEmbed = searchParams.get('embed') === '1';
const [isSimulateurOpen, setIsSimulateurOpen] = useState(false);
const [isCalculatorOpen, setIsCalculatorOpen] = useState(false);
const [modalPosition, setModalPosition] = useState({ x: 0, y: 0 });
@ -107,8 +110,33 @@ export default function CCNSVPPage() {
});
}, []);
// Auto-resize if embedded
useEffect(() => {
if (!isEmbed) return;
const styleTag = document.createElement('style');
styleTag.textContent = `aside, header { display: none !important; } main { padding: 8px !important; } body { background: transparent; }`;
document.head.appendChild(styleTag);
function postHeight() {
try {
const h = document.documentElement.scrollHeight;
window.parent?.postMessage({ type: 'minima-ccn:height', height: h }, '*');
} catch {}
}
postHeight();
const ro = new ResizeObserver(() => postHeight());
ro.observe(document.body);
window.addEventListener('load', postHeight);
window.addEventListener('resize', postHeight);
return () => {
ro.disconnect();
window.removeEventListener('load', postHeight);
window.removeEventListener('resize', postHeight);
styleTag.remove();
};
}, [isEmbed]);
return (
<div className="space-y-6">
<div className={isEmbed ? "space-y-3" : "space-y-6"}>
<style jsx global>{`
.ccnsvp-tabs [role="tablist"] {
display: flex;
@ -409,15 +437,18 @@ export default function CCNSVPPage() {
`}</style>
{/* Navigation retour */}
<Link
href="/minima-ccn"
className="inline-flex items-center gap-2 text-sm text-slate-600 hover:text-slate-900 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
Retour aux minima CCN
</Link>
{!isEmbed && (
<Link
href="/minima-ccn"
className="inline-flex items-center gap-2 text-sm text-slate-600 hover:text-slate-900 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
Retour aux minima CCN
</Link>
)}
{/* En-tête */}
{!isEmbed && (
<section className="rounded-2xl border bg-white p-6">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-12 h-12 rounded-xl bg-gradient-to-br from-blue-500 to-cyan-600 flex items-center justify-center">
@ -426,9 +457,7 @@ export default function CCNSVPPage() {
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<h1 className="text-2xl font-semibold text-slate-900">CCNSVP (IDCC 3090)</h1>
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-gradient-to-r from-emerald-500 to-emerald-600 text-white shadow-sm">
À jour 2025
</span>
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-gradient-to-r from-emerald-500 to-emerald-600 text-white shadow-sm">À jour 2025</span>
</div>
<p className="text-sm text-slate-600 mb-2">
Spectacle vivant privé - Minima techniciens, artistes et personnels administratifs et commerciaux
@ -448,12 +477,15 @@ export default function CCNSVPPage() {
</div>
</div>
</section>
)}
{/* Onglets */}
<section className="rounded-2xl border bg-white p-6 ccnsvp-tabs">
<p className="text-sm text-slate-600 mb-4">
Survolez le nom d'une annexe pour connaître son titre exact, cliquez sur l'annexe pour accéder aux détails.
</p>
<section className={isEmbed ? "rounded-lg border bg-white p-3 ccnsvp-tabs" : "rounded-2xl border bg-white p-6 ccnsvp-tabs"}>
{!isEmbed && (
<p className="text-sm text-slate-600 mb-4">
Survolez le nom d'une annexe pour connaître son titre exact, cliquez sur l'annexe pour accéder aux détails.
</p>
)}
<div role="tablist" aria-label="Onglets CCNSVP">
<button
@ -552,6 +584,7 @@ export default function CCNSVPPage() {
</section>
{/* Bouton flottant Simulateur */}
{!isEmbed && (
<button
onClick={() => setIsSimulateurOpen(!isSimulateurOpen)}
className="fixed bottom-6 right-6 z-50 inline-flex items-center gap-2 px-5 py-3 rounded-full text-sm font-bold bg-gradient-to-r from-amber-400 to-amber-500 text-slate-900 hover:from-amber-500 hover:to-amber-600 transition-all shadow-lg hover:shadow-xl hover:scale-105"
@ -559,9 +592,10 @@ export default function CCNSVPPage() {
<Calculator className="w-5 h-5" />
Simulateur
</button>
)}
{/* Modale compacte qui sort du bouton */}
{isSimulateurOpen && (
{!isEmbed && isSimulateurOpen && (
<>
{/* Modale compacte déplaçable */}
<div
@ -607,10 +641,12 @@ export default function CCNSVPPage() {
)}
{/* Calculatrice globale */}
<CalculatorComponent
isOpen={isCalculatorOpen}
onClose={() => setIsCalculatorOpen(false)}
/>
{!isEmbed && (
<CalculatorComponent
isOpen={isCalculatorOpen}
onClose={() => setIsCalculatorOpen(false)}
/>
)}
</div>
);
}

View file

@ -1,128 +1,111 @@
"use client";
import React from 'react';
import React, { useEffect } from 'react';
import { usePageTitle } from '@/hooks/usePageTitle';
import Link from 'next/link';
import { Scale, ExternalLink, Drama, Video } from 'lucide-react';
import { useSearchParams } from 'next/navigation';
export default function MinimaCCNPage() {
usePageTitle("Minima CCN");
const searchParams = useSearchParams();
const isEmbed = searchParams.get('embed') === '1';
// Post height to parent for auto-resizing if embedded
useEffect(() => {
if (!isEmbed) return;
function postHeight() {
try {
const h = document.documentElement.scrollHeight;
window.parent?.postMessage({ type: 'minima-ccn:height', height: h }, '*');
} catch {}
}
postHeight();
const ro = new ResizeObserver(() => postHeight());
ro.observe(document.body);
window.addEventListener('load', postHeight);
window.addEventListener('resize', postHeight);
return () => {
ro.disconnect();
window.removeEventListener('load', postHeight);
window.removeEventListener('resize', postHeight);
};
}, [isEmbed]);
return (
<div className="space-y-6">
{/* En-tête */}
<section className="rounded-2xl border bg-white p-6">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-12 h-12 rounded-xl bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center">
<Scale className="w-6 h-6 text-white" />
</div>
<div className="flex-1 min-w-0">
<h1 className="text-2xl font-semibold text-slate-900">Minima des CCN du spectacle</h1>
<p className="text-sm text-slate-600 mt-2">
Accédez aux minima actualisés par convention collective.
</p>
</div>
</div>
</section>
{/* Grille des cartes CCN */}
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* CCNEAC */}
<Link
href="/minima-ccn/ccneac"
className="group relative rounded-2xl border border-slate-200 bg-white p-6 transition-all duration-200 hover:shadow-lg hover:border-indigo-300 hover:-translate-y-1"
>
{/* Badge */}
<div className="absolute top-4 right-4">
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-gradient-to-r from-emerald-500 to-emerald-600 text-white shadow-sm">
À jour 2025
</span>
</div>
{/* Icône */}
<div className="w-14 h-14 rounded-xl bg-gradient-to-br from-indigo-100 to-purple-100 border border-indigo-200 flex items-center justify-center mb-4">
<Drama className="w-7 h-7 text-indigo-600" strokeWidth={1.6} />
</div>
{/* Contenu */}
<h2 className="text-xl font-semibold text-slate-900 mb-2">
CCNEAC (IDCC 1285)
</h2>
<p className="text-sm text-slate-600 mb-4">
Convention Collective Nationale des Entreprises Artistiques et Culturelles
</p>
{/* CTA */}
<div className="inline-flex items-center gap-2 text-sm font-semibold text-indigo-600 group-hover:gap-3 transition-all">
Voir les minima
<ExternalLink className="w-4 h-4" />
</div>
</Link>
{/* CCNSVP */}
<Link
href="/minima-ccn/ccnsvp"
className="group relative rounded-2xl border border-slate-200 bg-white p-6 transition-all duration-200 hover:shadow-lg hover:border-indigo-300 hover:-translate-y-1"
>
{/* Badge */}
<div className="absolute top-4 right-4">
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-gradient-to-r from-emerald-500 to-emerald-600 text-white shadow-sm">
À jour 2025
</span>
</div>
{/* Icône */}
<div className="w-14 h-14 rounded-xl bg-gradient-to-br from-blue-100 to-cyan-100 border border-blue-200 flex items-center justify-center mb-4">
<Drama className="w-7 h-7 text-blue-600" strokeWidth={1.6} />
</div>
{/* Contenu */}
<h2 className="text-xl font-semibold text-slate-900 mb-2">
CCNSVP (IDCC 3090)
</h2>
<p className="text-sm text-slate-600 mb-4">
Convention Collective Nationale du Spectacle Vivant Privé
</p>
{/* CTA */}
<div className="inline-flex items-center gap-2 text-sm font-semibold text-indigo-600 group-hover:gap-3 transition-all">
Voir les minima
<ExternalLink className="w-4 h-4" />
</div>
</Link>
{/* CCNPA */}
<Link
href="/minima-ccn/ccnpa"
className="group relative rounded-2xl border border-slate-200 bg-white p-6 transition-all duration-200 hover:shadow-lg hover:border-cyan-300 hover:-translate-y-1"
>
{/* Badge */}
<div className="absolute top-4 right-4">
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-gradient-to-r from-emerald-500 to-emerald-600 text-white shadow-sm">
À jour 2025
</span>
</div>
{/* Icône */}
<div className="w-14 h-14 rounded-xl bg-gradient-to-br from-cyan-100 to-blue-100 border border-cyan-200 flex items-center justify-center mb-4">
<Video className="w-7 h-7 text-cyan-600" strokeWidth={1.6} />
</div>
{/* Contenu */}
<h2 className="text-xl font-semibold text-slate-900 mb-2">
CCNPA (IDCC 2642)
</h2>
<p className="text-sm text-slate-600 mb-4">
Convention Collective Nationale de la Production Audiovisuelle
</p>
{/* CTA */}
<div className="inline-flex items-center gap-2 text-sm font-semibold text-cyan-600 group-hover:gap-3 transition-all">
Voir les minima
<ExternalLink className="w-4 h-4" />
</div>
</Link>
</section>
<div className={isEmbed ? "space-y-3" : "space-y-6"}>
{!isEmbed ? (
<>
{/* En-tête */}
<section className="rounded-2xl border bg-white p-6">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-12 h-12 rounded-xl bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center">
<Scale className="w-6 h-6 text-white" />
</div>
<div className="flex-1 min-w-0">
<h1 className="text-2xl font-semibold text-slate-900">Minima des CCN du spectacle</h1>
<p className="text-sm text-slate-600 mt-2">Accédez aux minima actualisés par convention collective.</p>
</div>
</div>
</section>
{/* Grille des cartes CCN (desktop) */}
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<Link href="/minima-ccn/ccneac" className="group relative rounded-2xl border border-slate-200 bg-white p-6 transition-all duration-200 hover:shadow-lg hover:border-indigo-300 hover:-translate-y-1">
<div className="absolute top-4 right-4">
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-gradient-to-r from-emerald-500 to-emerald-600 text-white shadow-sm">À jour 2025</span>
</div>
<div className="w-14 h-14 rounded-xl bg-gradient-to-br from-indigo-100 to-purple-100 border border-indigo-200 flex items-center justify-center mb-4">
<Drama className="w-7 h-7 text-indigo-600" strokeWidth={1.6} />
</div>
<h2 className="text-xl font-semibold text-slate-900 mb-2">CCNEAC (IDCC 1285)</h2>
<p className="text-sm text-slate-600 mb-4">Convention Collective Nationale des Entreprises Artistiques et Culturelles</p>
<div className="inline-flex items-center gap-2 text-sm font-semibold text-indigo-600 group-hover:gap-3 transition-all">Voir les minima<ExternalLink className="w-4 h-4" /></div>
</Link>
<Link href="/minima-ccn/ccnsvp" className="group relative rounded-2xl border border-slate-200 bg-white p-6 transition-all duration-200 hover:shadow-lg hover:border-indigo-300 hover:-translate-y-1">
<div className="absolute top-4 right-4"><span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-gradient-to-r from-emerald-500 to-emerald-600 text-white shadow-sm">À jour 2025</span></div>
<div className="w-14 h-14 rounded-xl bg-gradient-to-br from-blue-100 to-cyan-100 border border-blue-200 flex items-center justify-center mb-4">
<Drama className="w-7 h-7 text-blue-600" strokeWidth={1.6} />
</div>
<h2 className="text-xl font-semibold text-slate-900 mb-2">CCNSVP (IDCC 3090)</h2>
<p className="text-sm text-slate-600 mb-4">Convention Collective Nationale du Spectacle Vivant Privé</p>
<div className="inline-flex items-center gap-2 text-sm font-semibold text-indigo-600 group-hover:gap-3 transition-all">Voir les minima<ExternalLink className="w-4 h-4" /></div>
</Link>
<Link href="/minima-ccn/ccnpa" className="group relative rounded-2xl border border-slate-200 bg-white p-6 transition-all duration-200 hover:shadow-lg hover:border-cyan-300 hover:-translate-y-1">
<div className="absolute top-4 right-4"><span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-gradient-to-r from-emerald-500 to-emerald-600 text-white shadow-sm">À jour 2025</span></div>
<div className="w-14 h-14 rounded-xl bg-gradient-to-br from-cyan-100 to-blue-100 border border-cyan-200 flex items-center justify-center mb-4">
<Video className="w-7 h-7 text-cyan-600" strokeWidth={1.6} />
</div>
<h2 className="text-xl font-semibold text-slate-900 mb-2">CCNPA (IDCC 2642)</h2>
<p className="text-sm text-slate-600 mb-4">Convention Collective Nationale de la Production Audiovisuelle</p>
<div className="inline-flex items-center gap-2 text-sm font-semibold text-cyan-600 group-hover:gap-3 transition-all">Voir les minima<ExternalLink className="w-4 h-4" /></div>
</Link>
</section>
</>
) : (
// UI ultra compacte pour embed: simple liste de liens
<section className="rounded-lg border bg-white p-2">
<ul className="text-sm divide-y">
<li>
<Link href="/minima-ccn/ccnsvp?embed=1" className="flex items-center justify-between py-2 px-2 hover:bg-slate-50 rounded">
<span className="font-semibold text-slate-900">CCNSVP</span>
<span className="text-xs text-slate-500">Spectacle vivant privé</span>
</Link>
</li>
<li>
<Link href="/minima-ccn/ccneac?embed=1" className="flex items-center justify-between py-2 px-2 hover:bg-slate-50 rounded">
<span className="font-semibold text-slate-900">CCNEAC</span>
<span className="text-xs text-slate-500">Entreprises artistiques et culturelles</span>
</Link>
</li>
<li>
<Link href="/minima-ccn/ccnpa?embed=1" className="flex items-center justify-between py-2 px-2 hover:bg-slate-50 rounded">
<span className="font-semibold text-slate-900">CCNPA</span>
<span className="text-xs text-slate-500">Production audiovisuelle</span>
</Link>
</li>
</ul>
</section>
)}
</div>
);
}

View file

@ -4,6 +4,7 @@ import { createSbServer } from "@/lib/supabaseServer";
import { createClient } from "@supabase/supabase-js";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { PROFESSIONS_ARTISTE, ProfessionOption } from "@/components/constants/ProfessionsArtiste";
import { parseDateString } from "@/lib/dateFormatter";
import { promises as fs } from "fs";
import path from "path";
@ -142,6 +143,25 @@ async function pollDocumentStatus(documentId: string, pdfMonkeyUrl: string, pdfM
return { status, attempts };
}
// Fonction pour formater un texte de dates brutes en texte PDFMonkey formaté
function formatDateFieldIfNeeded(dateText: string | null | undefined, yearContext: string): string {
if (!dateText || !dateText.trim()) return "";
// Si contient déjà les marqueurs de formatage, c'est déjà du texte formaté
if (dateText.includes(" ; ") || dateText.includes(" le ") || dateText.includes(" du ")) {
return dateText;
}
// Sinon, parser et reformater
try {
const parsed = parseDateString(dateText, yearContext);
return parsed.pdfFormatted || "";
} catch (error) {
console.warn("Erreur lors du formatage des dates:", error);
return dateText; // Retourner le texte original en cas d'erreur
}
}
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
@ -358,7 +378,20 @@ export async function POST(
type_numobjet: production?.prod_type || "",
date_debut: formatDate(contract.start_date),
date_fin: formatDate(contract.end_date),
dates_travaillees: contract.jours_representations || "",
// Combiner toutes les dates travaillées : représentations, répétitions, jours de travail
// Format: "1 représentation le 12/10 ; 2 heures le 13/10 ; 1 service de répétition par jour du 14/10 au 16/10."
// Formater chaque source au besoin, puis les combiner
dates_travaillees: [
formatDateFieldIfNeeded(contract.jours_representations, contract.start_date || new Date().toISOString().slice(0, 10)),
formatDateFieldIfNeeded(contract.jours_repetitions, contract.start_date || new Date().toISOString().slice(0, 10)),
formatDateFieldIfNeeded(contract.jours_travail, contract.start_date || new Date().toISOString().slice(0, 10))
]
.filter(s => s.trim().length > 0)
.join(" ; ")
.replace(/ ; \./g, ".") // Éviter les doubles points
.replace(/\.\./, ".") // Éviter les double points
.replace(/; $/, ".") // Fin correcte
|| "",
salaire_brut: contract.gross_pay
? parseFloat(contract.gross_pay.toString()).toLocaleString('fr-FR', {
minimumFractionDigits: 2,

View file

@ -17,6 +17,7 @@ export default function Calculator({ isOpen, onClose, onUseResult }: CalculatorP
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
const [lastExpression, setLastExpression] = useState<string>("");
// Initialiser la position au centre à l'ouverture
useEffect(() => {
@ -39,12 +40,15 @@ export default function Calculator({ isOpen, onClose, onUseResult }: CalculatorP
setOperator('');
setFirstValue('');
setWaitingForSecond(false);
setLastExpression("");
};
const handleNumber = (num: string) => {
if (waitingForSecond) {
setDisplay(num);
setWaitingForSecond(false);
// Si on était après un calcul (operator vide), effacer l'expression précédente
if (!operator) setLastExpression("");
} else {
setDisplay(display === '0' ? num : display + num);
}
@ -71,10 +75,17 @@ export default function Calculator({ isOpen, onClose, onUseResult }: CalculatorP
case '/': result = second !== 0 ? first / second : 0; break;
}
setDisplay(result.toString());
// Arrondir et afficher 2 décimales dans la calculatrice
const rounded = Math.round((result + Number.EPSILON) * 100) / 100;
// Conserver l'expression utilisée
const opSymbol = operator === '/' ? '÷' : operator === '*' ? '×' : operator === '-' ? '' : operator;
const fmt = (s: string) => s.replace('.', ',');
setLastExpression(`${fmt(firstValue)} ${opSymbol} ${fmt(display)} =`);
setDisplay(rounded.toFixed(2));
setOperator('');
setFirstValue('');
setWaitingForSecond(false);
// Après un calcul, la prochaine saisie numérique doit remplacer l'affichage
setWaitingForSecond(true);
};
const handleDecimal = () => {
@ -129,7 +140,8 @@ export default function Calculator({ isOpen, onClose, onUseResult }: CalculatorP
const handleUse = () => {
const result = parseFloat(display);
if (!isNaN(result) && result > 0 && onUseResult) {
onUseResult(result);
const rounded = Math.round((result + Number.EPSILON) * 100) / 100;
onUseResult(rounded);
onClose();
}
};
@ -234,13 +246,19 @@ export default function Calculator({ isOpen, onClose, onUseResult }: CalculatorP
{/* Body */}
<div className="p-4">
{/* Display */}
<div className="relative bg-gradient-to-br from-slate-100 to-slate-200 border-2 border-slate-300 rounded-xl p-4 text-right text-2xl font-semibold text-slate-900 mb-4 min-h-[50px] break-all">
<div className="relative bg-gradient-to-br from-slate-100 to-slate-200 border-2 border-slate-300 rounded-xl p-4 text-right text-2xl font-semibold text-slate-900 mb-4 min-h-[58px] break-all">
{/* Opérateur courant (badge) */}
{operator && (
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-sm font-semibold text-slate-600 select-none">
{operator === '/' ? '÷' : operator === '*' ? '×' : operator === '-' ? '' : operator}
</span>
)}
{/* Dernière expression calculée */}
{lastExpression && (
<div className="absolute left-3 top-1.5 text-xs font-medium text-slate-500 select-none">
{lastExpression}
</div>
)}
{display || '0'}
</div>

View file

@ -0,0 +1,239 @@
"use client";
import React, { useState, useMemo } from "react";
import { X } from "lucide-react";
import {
convertIsoDatesToGroups,
formatDateFr,
parseFrenchedDate,
formatQuantifiedDates,
} from "@/lib/dateFormatter";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
interface DatesQuantityModalProps {
isOpen: boolean;
onClose: () => void;
onApply: (result: {
selectedDates: string[];
hasMultiMonth: boolean;
pdfFormatted: string;
}) => void;
selectedDates: string[]; // format input "12/10, 13/10, ..."
dateType: "representations" | "repetitions" | "jours_travail"; // Type de dates pour déterminer le libellé
minDate?: string;
maxDate?: string;
}
interface DateGroupWithQuantity {
displayFr: string; // "le 12/10" ou "du 14/10 au 17/10"
startIso: string;
endIso?: string;
isRange: boolean;
quantity: number | string;
unit: string; // "représentation(s)", "service(s) de répétition", "heure(s)"
}
export default function DatesQuantityModal({
isOpen,
onClose,
onApply,
selectedDates,
dateType,
minDate,
maxDate,
}: DatesQuantityModalProps) {
const yearContext = minDate || maxDate || new Date().toISOString().slice(0, 10);
// Convertir les dates sélectionnées en ISO pour grouper
const selectedIsos = useMemo(() => {
return selectedDates
.map((d) => parseFrenchedDate(d.trim(), yearContext))
.filter((iso) => iso.length === 10);
}, [selectedDates, yearContext]);
// Générer les groupes de dates
const dateGroups = useMemo(() => convertIsoDatesToGroups(selectedIsos), [selectedIsos]);
// État pour les quantités saisies par date ISO (object { iso: qty })
const initialQuantities = useMemo(() => {
const map: Record<string, number | string> = {};
selectedIsos.forEach((iso) => {
map[iso] = 1;
});
return map;
}, [selectedIsos]);
const [quantities, setQuantities] = useState<Record<string, number | string>>(initialQuantities);
// Erreur de validation
const [validationError, setValidationError] = useState<string>("");
// Déterminer le libellé et les unités disponibles
function getDefaultUnit(type: string, isRange: boolean): string {
switch (type) {
case "representations":
return "représentation(s)";
case "repetitions":
return "service(s) de répétition";
case "jours_travail":
return isRange ? "heure(s) par jour" : "heure(s)";
default:
return "";
}
}
function getUnitLabel(type: string, isRange: boolean): string {
switch (type) {
case "representations":
return "représentation(s)";
case "repetitions":
return "service(s) de répétition";
case "jours_travail":
return isRange ? "heure(s) par jour" : "heure(s)";
default:
return "";
}
}
// Générer le texte formaté au format PDFMonkey en regroupant consécutifs avec même qty
const pdfFormatted = useMemo(() => {
if (!selectedIsos || selectedIsos.length === 0) return "";
return formatQuantifiedDates(selectedIsos, quantities, dateType);
}, [selectedIsos, quantities, dateType]);
const handleQuantityChange = (iso: string, value: string) => {
const numValue = value === "" ? "" : parseInt(value) || 0;
// Validation : min 1, max 3
if (value !== "" && (numValue < 1 || numValue > 3)) {
setValidationError(`La quantité doit être entre 1 et 3`);
return;
} else {
setValidationError("");
}
setQuantities({ ...quantities, [iso]: numValue });
};
const handleApply = () => {
// Vérifier que toutes les quantités sont > 0
for (const iso of selectedIsos) {
const qty = quantities[iso];
if (!qty || qty === "" || (typeof qty === "number" && qty < 1)) {
setValidationError("Toutes les quantités doivent être >= 1");
return;
}
}
if (!pdfFormatted) {
setValidationError("Erreur lors du formatage des dates");
return;
}
onApply({
selectedDates: selectedDates,
hasMultiMonth: selectedIsos.length > 0 && checkMultiMonth(selectedIsos),
pdfFormatted,
});
onClose();
};
function checkMultiMonth(isos: string[]): boolean {
if (isos.length <= 1) return false;
const months = new Set(isos.map((iso) => iso.slice(0, 7))); // YYYY-MM
return months.size > 1;
}
if (!isOpen) return null;
return (
<>
{/* Overlay */}
<div className="fixed inset-0 bg-black/30 z-40" onClick={onClose} />
{/* Modale */}
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md max-h-[80vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b bg-gradient-to-r from-indigo-50 to-purple-50">
<h2 className="text-lg font-semibold text-slate-900">
Indiquez les quantités
</h2>
<button
onClick={onClose}
className="p-1 hover:bg-white/50 rounded-lg transition"
>
<X className="w-5 h-5 text-slate-500" />
</button>
</div>
{/* Contenu */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{validationError && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-800">
{validationError}
</div>
)}
{dateGroups.length === 0 ? (
<div className="text-center py-8 text-slate-500">
Aucune date sélectionnée
</div>
) : (
// Afficher la liste des dates individuelles pour permettre quantité par date
selectedIsos.map((iso) => (
<div key={iso} className="p-3 border rounded-lg bg-slate-50 space-y-2">
<div className="text-sm font-medium text-slate-700">{formatDateFr(iso)}</div>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
max={3}
placeholder="1-3"
value={quantities[iso] ?? ""}
onChange={(e) => handleQuantityChange(iso, e.target.value)}
className="w-20"
/>
<span className="text-sm text-slate-600">{getUnitLabel(dateType, false)}</span>
</div>
</div>
))
)}
{/* Aperçu du texte généré */}
{pdfFormatted && (
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<div className="text-xs font-semibold text-blue-900 mb-1">
Aperçu:
</div>
<div className="text-sm text-blue-800 font-mono break-words">
{pdfFormatted}
</div>
</div>
)}
</div>
{/* Footer */}
<div className="flex gap-2 p-4 border-t bg-slate-50">
<Button
variant="outline"
onClick={onClose}
className="flex-1"
>
Annuler
</Button>
<Button
onClick={handleApply}
disabled={!pdfFormatted}
className="flex-1"
>
Appliquer
</Button>
</div>
</div>
</div>
</>
);
}

View file

@ -0,0 +1,60 @@
"use client";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Scale, ExternalLink } from "lucide-react";
import Link from "next/link";
interface MinimaCCNPreviewProps {
isRegimeRG: boolean;
datesRep?: string;
datesServ?: string;
joursTravail?: string;
}
export default function MinimaCCNPreview({ isRegimeRG }: MinimaCCNPreviewProps) {
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const [height, setHeight] = useState(320);
const src = useMemo(() => {
// Point d'entrée: hub des minima avec mode embed
return "/minima-ccn?embed=1";
}, []);
useEffect(() => {
function onMessage(e: MessageEvent) {
if (typeof e.data === 'object' && e.data && e.data.type === 'minima-ccn:height') {
const h = Number(e.data.height);
if (Number.isFinite(h) && h > 100 && h < 4000) {
setHeight(Math.ceil(h));
}
}
}
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
}, []);
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Scale className="w-4 h-4 text-amber-600" />
<p className="font-semibold text-slate-900 text-sm">Minima des CCN</p>
</div>
<Link href="/minima-ccn" target="_blank" className="text-xs text-amber-700 hover:text-amber-900 inline-flex items-center gap-1">
Ouvrir la page
<ExternalLink className="w-3 h-3" />
</Link>
</div>
<div className="rounded-lg border overflow-hidden bg-white">
<iframe
ref={iframeRef}
src={src}
title="Minima CCN"
style={{ width: '100%', height: `${height}px` }}
scrolling="no"
/>
</div>
</div>
);
}

View file

@ -11,7 +11,9 @@ import { PROFESSIONS_ARTISTE } from "@/components/constants/ProfessionsArtiste";
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 { Tooltip } from "@/components/ui/tooltip";
/* =========================
Types
@ -348,6 +350,7 @@ export function NouveauCDDUForm({
const [joursTravail, setJoursTravail] = useState("");
const [nbRep, setNbRep] = useState<number | "">("");
const [nbServ, setNbServ] = useState<number | "">("");
const [durationServices, setDurationServices] = useState<"3" | "4">("4"); // Durée des services (3 ou 4 heures)
const [datesRep, setDatesRep] = useState("");
const [datesRepDisplay, setDatesRepDisplay] = useState("");
const [datesRepOpen, setDatesRepOpen] = useState(false);
@ -359,9 +362,22 @@ export function NouveauCDDUForm({
const [joursTravailDisplay, setJoursTravailDisplay] = useState("");
const [joursTravailOpen, setJoursTravailOpen] = useState(false);
// États pour les modales de quantités après sélection des dates
const [quantityModalOpen, setQuantityModalOpen] = useState(false);
const [quantityModalType, setQuantityModalType] = useState<"representations" | "repetitions" | "jours_travail">("representations");
const [pendingDates, setPendingDates] = useState<string[]>([]);
const [typeSalaire, setTypeSalaire] = useState<"Brut" | "Net avant PAS" | "Coût total employeur" | "Minimum conventionnel">("Brut");
const [montantSalaire, setMontantSalaire] = useState<number | "">("");
// Indique si la valeur vient de la calculatrice pour forcer l'affichage en 2 décimales
const [montantFromCalculator, setMontantFromCalculator] = useState<boolean>(false);
const [panierRepas, setPanierRepas] = useState<"Oui" | "Non">("Non");
// Mode de saisie du salaire : "global" ou "par_date"
const [salaryMode, setSalaryMode] = useState<"global" | "par_date">("global");
// Salaires par date : Record<dateISO, montant>
const [salariesByDate, setSalariesByDate] = useState<Record<string, number | "">>({});
const [notes, setNotes] = useState("");
const [validerDirect, setValiderDirect] = useState<"Oui" | "Non">("Oui");
// Confirmation e-mail à la création
@ -420,13 +436,15 @@ export function NouveauCDDUForm({
const [newSalarieOpen, setNewSalarieOpen] = useState(false);
// Handlers pour les calendriers de dates
// Au lieu d'appliquer directement, on ouvre la modale de quantités
const handleDatesRepApply = (result: {
selectedDates: string[];
hasMultiMonth: boolean;
pdfFormatted: string;
}) => {
setDatesRep(result.selectedDates.join(", "));
setDatesRepDisplay(result.pdfFormatted);
setPendingDates(result.selectedDates);
setQuantityModalType("representations");
setQuantityModalOpen(true);
if (result.hasMultiMonth) {
setIsMultiMois("Oui");
}
@ -437,8 +455,9 @@ export function NouveauCDDUForm({
hasMultiMonth: boolean;
pdfFormatted: string;
}) => {
setDatesServ(result.selectedDates.join(", "));
setDatesServDisplay(result.pdfFormatted);
setPendingDates(result.selectedDates);
setQuantityModalType("repetitions");
setQuantityModalOpen(true);
if (result.hasMultiMonth) {
setIsMultiMois("Oui");
}
@ -449,11 +468,51 @@ export function NouveauCDDUForm({
hasMultiMonth: boolean;
pdfFormatted: string;
}) => {
setJoursTravail(result.selectedDates.join(", "));
setJoursTravailDisplay(result.pdfFormatted);
setPendingDates(result.selectedDates);
setQuantityModalType("jours_travail");
setQuantityModalOpen(true);
setIsMultiMois(result.hasMultiMonth ? "Oui" : "Non");
};
// Handler pour la modale de quantités (applique les données finales)
const handleQuantityApply = (result: {
selectedDates: string[];
hasMultiMonth: boolean;
pdfFormatted: string;
}) => {
// Calculer le nombre de jours/dates sélectionnées
const nbDates = result.selectedDates.length;
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);
break;
case "repetitions":
// Ajouter la durée des services au texte formaté
let formattedText = result.pdfFormatted;
if (durationServices) {
// Remplacer "service de répétition" par "service de répétition de X heures"
formattedText = formattedText
.replace(/service de répétition/g, `service de répétition de ${durationServices} heures`)
.replace(/services de répétition/g, `services de répétition de ${durationServices} heures chacun`);
}
setDatesServ(formattedText);
setDatesServDisplay(formattedText);
// Auto-remplir le nombre de services de répétition basé sur les dates sélectionnées
setNbServ(nbDates);
break;
case "jours_travail":
setJoursTravail(result.pdfFormatted);
setJoursTravailDisplay(result.pdfFormatted);
break;
}
setQuantityModalOpen(false);
setPendingDates([]);
};
useEffect(() => {
if (!newSalarieOpen) return;
const prev = document.body.style.overflow;
@ -517,7 +576,8 @@ export function NouveauCDDUForm({
if (typeof prefill.multi_mois === "boolean") setIsMultiMois(prefill.multi_mois ? "Oui" : "Non");
setTypeSalaire(prefill.type_salaire ?? "Brut");
setMontantSalaire(prefill.montant ?? "");
setMontantSalaire(prefill.montant ?? "");
setMontantFromCalculator(false);
setPanierRepas(prefill.panier_repas ?? "Non");
setNotes(prefill.notes || "");
// Si technicien: préremplir heures/minutes depuis nb_heures_annexes si fourni
@ -764,7 +824,10 @@ useEffect(() => {
if (payload.jours_travail) setJoursTravail(String(payload.jours_travail));
if (payload.type_salaire) setTypeSalaire(payload.type_salaire);
if (payload.montant !== "") setMontantSalaire(payload.montant as any);
if (payload.montant !== "") {
setMontantSalaire(payload.montant as any);
setMontantFromCalculator(false);
}
if (payload.panier_repas) setPanierRepas(payload.panier_repas);
if (payload.notes) setNotes(String(payload.notes));
setIsMultiMois(payload.multi_mois ? "Oui" : "Non");
@ -1001,6 +1064,7 @@ useEffect(() => {
const montantValue = Number(data.montant);
if (Number.isFinite(montantValue) && montantValue > 0) {
setMontantSalaire(montantValue);
setMontantFromCalculator(false);
console.log("✅ Montant pré-rempli:", montantValue);
}
}
@ -1198,7 +1262,7 @@ useEffect(() => {
nb_services_repetition: !isRegimeRG ? nbServ : undefined,
dates_representations: !isRegimeRG ? (datesRep || undefined) : undefined,
dates_repetitions: !isRegimeRG ? (datesServ || undefined) : undefined,
heures_travail: !isRegimeRG && useHeuresMode ? heuresTotal : undefined,
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,
type_salaire: typeSalaire,
@ -1896,14 +1960,29 @@ useEffect(() => {
/>
</div>
<div>
<Label>Combien de services de répétition ?</Label>
<input
type="number"
min={0}
value={nbServ}
onChange={(e) => setNbServ(e.target.value === "" ? "" : Number(e.target.value))}
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
/>
<Label>Combien de services de répétition ? / Durée</Label>
<div className="flex gap-2 items-center">
<input
type="number"
min={0}
value={nbServ}
onChange={(e) => setNbServ(e.target.value === "" ? "" : Number(e.target.value))}
placeholder="Nombre"
className="flex-1 px-3 py-2 rounded-lg border bg-white text-sm"
/>
<span className="text-slate-400 font-medium">×</span>
{typeof nbServ === "number" && nbServ > 0 && (
<select
value={durationServices}
onChange={(e) => setDurationServices(e.target.value as "3" | "4")}
className="flex-1 px-3 py-2 rounded-lg border bg-white text-sm"
title="Durée des services de répétition"
>
<option value="3">3 heures</option>
<option value="4">4 heures</option>
</select>
)}
</div>
</div>
</FieldRow>
@ -2057,72 +2136,321 @@ useEffect(() => {
</div>
)}
{/* Rémunération */}
<Section title="Rémunération">
<FieldRow>
<div>
<Label required>Type de salaire</Label>
<select
value={typeSalaire}
onChange={(e) => {
const v = e.target.value as typeof typeSalaire;
setTypeSalaire(v);
if (v === "Minimum conventionnel") setMontantSalaire("");
}}
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
{/* Rémunération et Minima - Conteneur deux colonnes */}
<div className="flex gap-6">
{/* Rémunération - 50% de largeur */}
<div className="flex-1">
<Section title="Rémunération">
{/* Onglets pour choisir le mode de saisie du salaire */}
{typeSalaire !== "Minimum conventionnel" && (
<div className="mb-4 flex gap-2 border-b border-slate-200">
<button
type="button"
onClick={() => setSalaryMode("global")}
className={`px-4 py-2 font-medium text-sm transition-colors ${
salaryMode === "global"
? "text-indigo-600 border-b-2 border-indigo-600 -mb-0.5"
: "text-slate-600 hover:text-slate-900"
}`}
>
<option value="Brut">Brut</option>
<option value="Net avant PAS">Net avant PAS</option>
<option value="Coût total employeur">Coût total employeur</option>
<option value="Minimum conventionnel">Minimum conventionnel</option>
</select>
Saisir le salaire global
</button>
<Tooltip content="Fonction bientôt disponible" side="top">
<button
type="button"
disabled
className={
`px-4 py-2 font-medium text-sm transition-colors opacity-60 cursor-not-allowed ` +
(salaryMode === "par_date"
? "text-slate-600 border-b-2 border-slate-300 -mb-0.5"
: "text-slate-600")
}
aria-disabled="true"
>
Saisir le salaire par date
</button>
</Tooltip>
</div>
)}
{typeSalaire !== "Minimum conventionnel" && (
<div className="mt-2">
<Label required>Montant</Label>
<div className="flex items-center gap-2">
<input
type="number"
min={0}
step="0.01"
value={montantSalaire}
onChange={(e) => setMontantSalaire(e.target.value === "" ? "" : Number(e.target.value))}
placeholder="ex : 250,00"
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
/>
<span className="text-sm text-slate-600"></span>
<button
type="button"
onClick={() => setIsCalculatorOpen(true)}
className="px-3 py-2 rounded-lg border border-slate-200 bg-white hover:bg-slate-50 transition-colors flex items-center justify-center gap-2 text-sm flex-shrink-0"
title="Ouvrir la calculatrice"
aria-label="Calculatrice"
>
<CalculatorIcon className="w-4 h-4 text-slate-600 flex-shrink-0" />
<span className="text-slate-600 whitespace-nowrap">Calculatrice</span>
</button>
{/* Mode salaire global */}
{salaryMode === "global" && (
<FieldRow>
<div>
<Label required>Type de salaire</Label>
<select
value={typeSalaire}
onChange={(e) => {
const v = e.target.value as typeof typeSalaire;
setTypeSalaire(v);
if (v === "Minimum conventionnel") {
setMontantSalaire("");
setMontantFromCalculator(false);
}
}}
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
>
<option value="Brut">Brut</option>
<option value="Net avant PAS">Net avant PAS</option>
<option value="Coût total employeur">Coût total employeur</option>
<option value="Minimum conventionnel">Minimum conventionnel</option>
</select>
{typeSalaire !== "Minimum conventionnel" && (
<div className="mt-2">
<Label required>Montant</Label>
<div className="flex items-center gap-2">
<input
type="number"
min={0}
step="0.01"
value={
montantFromCalculator && montantSalaire !== ""
? Number(montantSalaire).toFixed(2)
: montantSalaire
}
onChange={(e) => {
setMontantFromCalculator(false);
setMontantSalaire(e.target.value === "" ? "" : Number(e.target.value));
}}
placeholder="ex : 250,00"
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
/>
<span className="text-sm text-slate-600"></span>
<button
type="button"
onClick={() => setIsCalculatorOpen(true)}
className="px-3 py-2 rounded-lg border border-slate-200 bg-white hover:bg-slate-50 transition-colors flex items-center justify-center gap-2 text-sm flex-shrink-0"
title="Ouvrir la calculatrice"
aria-label="Calculatrice"
>
<CalculatorIcon className="w-4 h-4 text-slate-600 flex-shrink-0" />
<span className="text-slate-600 whitespace-nowrap">Calculatrice</span>
</button>
</div>
<p className="text-[11px] text-slate-500 mt-1">
Saisissez le montant en euros correspondant au type sélectionné.
</p>
</div>
<p className="text-[11px] text-slate-500 mt-1">
Saisissez le montant en euros correspondant au type sélectionné.
</p>
)}
</div>
<div>
<Label required>Panier(s) repas</Label>
<div className="flex items-center gap-6 mt-2">
<label className="inline-flex items-center gap-2 text-sm">
<input type="radio" checked={panierRepas === "Oui"} onChange={() => setPanierRepas("Oui")} />
Oui
</label>
<label className="inline-flex items-center gap-2 text-sm">
<input type="radio" checked={panierRepas === "Non"} onChange={() => setPanierRepas("Non")} />
Non
</label>
</div>
</div>
</FieldRow>
)}
{/* Mode salaire par date (désactivé temporairement) */}
{false && salaryMode === "par_date" && (
<div>
<div className="mb-4">
<Label required>Type de salaire</Label>
<select
value={typeSalaire}
onChange={(e) => {
const v = e.target.value as typeof typeSalaire;
setTypeSalaire(v);
if (v === "Minimum conventionnel") setMontantSalaire("");
}}
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
>
<option value="Brut">Brut</option>
<option value="Net avant PAS">Net avant PAS</option>
<option value="Coût total employeur">Coût total employeur</option>
<option value="Minimum conventionnel">Minimum conventionnel</option>
</select>
</div>
{/* Tableau des dates avec salaires */}
{(datesRep.length > 0 || datesServ.length > 0 || joursTravail.length > 0) ? (
<>
{/* Tableau des dates avec salaires - groupés par date */}
<div className="max-h-96 overflow-y-auto border rounded-lg bg-white mb-4">
<table className="w-full text-sm">
<tbody>
{/* Représentations groupées par date */}
{datesRep && datesRep.length > 0 && (
<>
<tr className="border-b bg-indigo-50">
<td colSpan={4} className="p-3 font-semibold text-slate-800">Représentations</td>
</tr>
{datesRep.split(" ; ").map((dateStr, groupIdx) => {
const qtyMatch = dateStr.match(/(\d+)\s+représentation/);
const dateMatches = dateStr.match(/(\d{2}\/\d{2})/g) || [];
const qty = qtyMatch ? parseInt(qtyMatch[1]) : 1;
// Create a row for each unique date
return dateMatches.map((dateDisplay, dateIdx) => (
<tr key={`rep_group_${groupIdx}_${dateIdx}`} className="border-b hover:bg-slate-50">
<td className="p-3 font-medium text-slate-700 bg-slate-50">{dateDisplay}</td>
{Array.from({ length: qty }).map((_, i) => (
<td key={`rep_${groupIdx}_${dateIdx}_${i}`} className="p-1">
<div className="flex flex-col items-center gap-1">
<div className="text-xs font-semibold text-slate-600">Repré. #{i + 1}</div>
<input
type="number"
min="0.01"
step="0.01"
value={salariesByDate[`rep_${groupIdx}_${dateIdx}_${i}`] ?? ""}
onChange={(e) =>
setSalariesByDate({
...salariesByDate,
[`rep_${groupIdx}_${dateIdx}_${i}`]: e.target.value === "" ? "" : Number(e.target.value),
})
}
placeholder="€"
className="w-20 px-2 py-1 rounded border border-slate-300 bg-white text-xs text-right"
title={`Représentation #${i + 1}`}
/>
</div>
</td>
))}
</tr>
));
})}
</>
)}
{/* Répétitions groupées par date */}
{datesServ && datesServ.length > 0 && (
<>
<tr className="border-b bg-purple-50">
<td colSpan={4} className="p-3 font-semibold text-slate-800">Répétitions</td>
</tr>
{datesServ.split(" ; ").map((dateStr, groupIdx) => {
const qtyMatch = dateStr.match(/(\d+)\s+service/);
const dateMatches = dateStr.match(/(\d{2}\/\d{2})/g) || [];
const qty = qtyMatch ? parseInt(qtyMatch[1]) : 1;
// Create a row for each unique date
return dateMatches.map((dateDisplay, dateIdx) => (
<tr key={`serv_group_${groupIdx}_${dateIdx}`} className="border-b hover:bg-slate-50">
<td className="p-3 font-medium text-slate-700 bg-slate-50">{dateDisplay}</td>
{Array.from({ length: qty }).map((_, i) => (
<td key={`serv_${groupIdx}_${dateIdx}_${i}`} className="p-1">
<div className="flex flex-col items-center gap-1">
<div className="text-xs font-semibold text-slate-600">Répét. #{i + 1}</div>
<input
type="number"
min="0.01"
step="0.01"
value={salariesByDate[`serv_${groupIdx}_${dateIdx}_${i}`] ?? ""}
onChange={(e) =>
setSalariesByDate({
...salariesByDate,
[`serv_${groupIdx}_${dateIdx}_${i}`]: e.target.value === "" ? "" : Number(e.target.value),
})
}
placeholder="€"
className="w-20 px-2 py-1 rounded border border-slate-300 bg-white text-xs text-right"
title={`Répétition #${i + 1}`}
/>
</div>
</td>
))}
</tr>
));
})}
</>
)}
{/* Jours travaillés */}
{joursTravail && joursTravail.length > 0 && (
<>
<tr className="border-b bg-green-50">
<td colSpan={4} className="p-3 font-semibold text-slate-800">Jours travaillés</td>
</tr>
{joursTravail.split(" ; ").map((dateStr, groupIdx) => {
const dateMatches = dateStr.match(/(\d{2}\/\d{2})/g) || [];
return (
<tr key={`jour_group_${groupIdx}`} className="border-b hover:bg-slate-50">
<td className="p-3 font-medium text-slate-700 bg-slate-50">{dateMatches[0]}</td>
<td className="p-1">
<div className="flex flex-col items-center gap-1">
<div className="text-xs font-semibold text-slate-600">Jour</div>
<input
type="number"
min="0.01"
step="0.01"
value={salariesByDate[`jour_${groupIdx}_0`] ?? ""}
onChange={(e) =>
setSalariesByDate({
...salariesByDate,
[`jour_${groupIdx}_0`]: e.target.value === "" ? "" : Number(e.target.value),
})
}
placeholder="€"
className="w-20 px-2 py-1 rounded border border-slate-300 bg-white text-xs text-right"
/>
</div>
</td>
</tr>
);
})}
</>
)}
</tbody>
</table>
</div>
{/* Total du salaire */}
<div className="p-4 bg-gradient-to-r from-indigo-50 to-purple-50 rounded-lg border-2 border-indigo-200 mb-4">
<div className="flex items-baseline justify-between">
<div>
<div className="text-xs uppercase tracking-wide text-indigo-700 font-semibold">Salaire total ({typeSalaire})</div>
<div className="text-3xl font-bold text-indigo-900 mt-1">
{(Object.values(salariesByDate).reduce((sum: number, val) => {
return sum + (typeof val === "number" ? val : 0);
}, 0) as number).toFixed(2)}
</div>
</div>
</div>
</div>
</>
) : (
<div className="p-4 bg-slate-50 rounded-lg text-sm text-slate-600 text-center mb-4">
Veuillez sélectionner des dates avant de saisir les salaires par date.
</div>
)}
</div>
<div>
<Label required>Panier(s) repas</Label>
<div className="flex items-center gap-6 mt-2">
<label className="inline-flex items-center gap-2 text-sm">
<input type="radio" checked={panierRepas === "Oui"} onChange={() => setPanierRepas("Oui")} />
Oui
</label>
<label className="inline-flex items-center gap-2 text-sm">
<input type="radio" checked={panierRepas === "Non"} onChange={() => setPanierRepas("Non")} />
Non
</label>
<div className="mt-4">
<Label required>Panier(s) repas</Label>
<div className="flex items-center gap-6 mt-2">
<label className="inline-flex items-center gap-2 text-sm">
<input type="radio" checked={panierRepas === "Oui"} onChange={() => setPanierRepas("Oui")} />
Oui
</label>
<label className="inline-flex items-center gap-2 text-sm">
<input type="radio" checked={panierRepas === "Non"} onChange={() => setPanierRepas("Non")} />
Non
</label>
</div>
</div>
</div>
</FieldRow>
</Section>
)}
{/* Type de salaire pour le mode global */}
{salaryMode === "global" && typeSalaire === "Minimum conventionnel" && (
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg text-sm text-blue-800">
Le salaire sera calculé selon le minimum conventionnel applicable.
</div>
)}
</Section>
</div>
{/* Encart Minima CCN entièrement masqué temporairement */}
</div>
{/* Autres infos */}
<Section title="Autres informations">
@ -2276,10 +2604,26 @@ useEffect(() => {
isOpen={isCalculatorOpen}
onClose={() => setIsCalculatorOpen(false)}
onUseResult={(value) => {
setMontantSalaire(value);
const rounded = Math.round((value + Number.EPSILON) * 100) / 100;
setMontantSalaire(rounded);
setMontantFromCalculator(true);
setIsCalculatorOpen(false);
}}
/>
{/* Modale de quantités pour les dates sélectionnées */}
<DatesQuantityModal
isOpen={quantityModalOpen}
onClose={() => {
setQuantityModalOpen(false);
setPendingDates([]);
}}
onApply={handleQuantityApply}
selectedDates={pendingDates}
dateType={quantityModalType}
minDate={dateDebut}
maxDate={dateFin}
/>
</>
);
}

View file

@ -21,6 +21,7 @@ import { PayslipCard } from "./PayslipCard";
import { NotesSection } from "@/components/NotesSection";
import ESignConfirmModal from "./ESignConfirmModal";
import DatePickerCalendar from "@/components/DatePickerCalendar";
import DatesQuantityModal from "@/components/DatesQuantityModal";
import { parseDateString } from "@/lib/dateFormatter";
import { supabase } from "@/lib/supabaseClient";
@ -301,6 +302,7 @@ export default function ContractEditor({
// États pour les champs conditionnels (heures vs répétitions/représentations)
const [nbRepresentations, setNbRepresentations] = useState<number | "">(contract.cachets_representations || "");
const [nbServicesRepetition, setNbServicesRepetition] = useState<number | "">(contract.services_repetitions || "");
const [durationServices, setDurationServices] = useState<"3" | "4">("4"); // Durée des services (3 ou 4 heures)
const [datesRepresentations, setDatesRepresentations] = useState(contract.jours_representations || "");
const [datesRepresentationsDisplay, setDatesRepresentationsDisplay] = useState("");
const [datesRepresentationsOpen, setDatesRepresentationsOpen] = useState(false);
@ -313,6 +315,11 @@ export default function ContractEditor({
const [joursTravailDisplay, setJoursTravailDisplay] = useState("");
const [joursTravailOpen, setJoursTravailOpen] = useState(false);
// États pour la modale de précision des quantités
const [quantityModalOpen, setQuantityModalOpen] = useState(false);
const [quantityModalType, setQuantityModalType] = useState<"representations" | "repetitions" | "jours_travail" | "">("");
const [pendingDates, setPendingDates] = useState<string[]>([]);
const [nombreHeuresTotal, setNombreHeuresTotal] = useState<number | "">(contract.nombre_d_heures || "");
const [nombreHeuresParJour, setNombreHeuresParJour] = useState<number | "">(contract.nombre_d_heures_par_jour || "");
@ -331,20 +338,52 @@ export default function ContractEditor({
// Utiliser les vrais noms de colonnes de la base de données
setNbRepresentations(contract.cachets_representations || "");
setNbServicesRepetition(contract.services_repetitions || "");
setDatesRepresentations(contract.jours_representations || "");
setDatesRepetitions(contract.jours_repetitions || "");
// Initialiser les affichages avec formatage smart si présentes
// Déterminer si c'est du texte formaté PDFMonkey ou des dates brutes
if (contract.jours_representations) {
const yearContext = contract.start_date || new Date().toISOString().slice(0, 10);
const parsed = parseDateString(contract.jours_representations, yearContext);
setDatesRepresentationsDisplay(parsed.pdfFormatted);
// Si contient " ; " ou " le " ou " du ", c'est probablement du texte formaté
const isFormatted = contract.jours_representations.includes(" ; ") ||
contract.jours_representations.includes(" le ") ||
contract.jours_representations.includes(" du ");
if (isFormatted) {
// C'est déjà du texte formaté, utiliser tel quel
setDatesRepresentations(contract.jours_representations);
setDatesRepresentationsDisplay(contract.jours_representations);
} else {
// C'est du texte brut (dates), parser et reformater
const yearContext = contract.start_date || new Date().toISOString().slice(0, 10);
const parsed = parseDateString(contract.jours_representations, yearContext);
setDatesRepresentations(parsed.pdfFormatted);
setDatesRepresentationsDisplay(parsed.pdfFormatted);
}
} else {
setDatesRepresentations("");
setDatesRepresentationsDisplay("");
}
if (contract.jours_repetitions) {
const yearContext = contract.start_date || new Date().toISOString().slice(0, 10);
const parsed = parseDateString(contract.jours_repetitions, yearContext);
setDatesRepetitionsDisplay(parsed.pdfFormatted);
const isFormatted = contract.jours_repetitions.includes(" ; ") ||
contract.jours_repetitions.includes(" le ") ||
contract.jours_repetitions.includes(" du ");
if (isFormatted) {
setDatesRepetitions(contract.jours_repetitions);
setDatesRepetitionsDisplay(contract.jours_repetitions);
// Détecter la durée des services dans le texte
if (contract.jours_repetitions.includes("de 3 heures")) {
setDurationServices("3");
} else if (contract.jours_repetitions.includes("de 4 heures")) {
setDurationServices("4");
}
} else {
const yearContext = contract.start_date || new Date().toISOString().slice(0, 10);
const parsed = parseDateString(contract.jours_repetitions, yearContext);
setDatesRepetitions(parsed.pdfFormatted);
setDatesRepetitionsDisplay(parsed.pdfFormatted);
}
} else {
setDatesRepetitions("");
setDatesRepetitionsDisplay("");
}
setHeuresTotal(contract.nombre_d_heures || "");
@ -352,15 +391,54 @@ export default function ContractEditor({
// Initialiser joursTravail avec formatage smart
if (contract.jours_travail) {
setJoursTravail(contract.jours_travail);
const yearContext = contract.date_debut || new Date().toISOString().slice(0, 10);
const parsed = parseDateString(contract.jours_travail, yearContext);
setJoursTravailDisplay(parsed.pdfFormatted);
const isFormatted = contract.jours_travail.includes(" ; ") ||
contract.jours_travail.includes(" le ") ||
contract.jours_travail.includes(" du ");
if (isFormatted) {
setJoursTravail(contract.jours_travail);
setJoursTravailDisplay(contract.jours_travail);
} else {
const yearContext = contract.date_debut || new Date().toISOString().slice(0, 10);
const parsed = parseDateString(contract.jours_travail, yearContext);
setJoursTravail(parsed.pdfFormatted);
setJoursTravailDisplay(parsed.pdfFormatted);
}
} else {
setJoursTravail("");
setJoursTravailDisplay("");
}
setNombreHeuresTotal(contract.nombre_d_heures || "");
// Pré-remplir le nombre d'heures total
if (contract.nombre_d_heures) {
setNombreHeuresTotal(contract.nombre_d_heures);
} else if (!useHeuresMode && contract.services_repetitions && contract.jours_repetitions) {
// Si pas d'heures stockées, calculer à partir des services × durée
const nbServices = parseInt(contract.services_repetitions) || 0;
let duration = 4; // Durée par défaut
if (contract.jours_repetitions.includes("de 3 heures")) {
duration = 3;
}
setNombreHeuresTotal(nbServices * duration);
} else {
setNombreHeuresTotal("");
}
setNombreHeuresParJour(contract.nombre_d_heures_par_jour || "");
setDateSignature(contract.date_signature?.slice(0, 10) || "");
// Pré-remplir la date de signature selon les règles :
// - Date du jour si la date de début est le jour même ou dans le futur
// - Égale à la date de début si celle-ci est dans le passé
if (contract.date_signature) {
setDateSignature(contract.date_signature.slice(0, 10));
} else if (contract.date_debut) {
const today = new Date().toISOString().split("T")[0];
const dateDebut = contract.date_debut.split("T")[0];
// Si date_debut est dans le passé (avant aujourd'hui), utiliser date_debut
// Sinon, utiliser aujourd'hui
setDateSignature(dateDebut < today ? dateDebut : today);
} else {
setDateSignature("");
}
setPrecisionsSalaire(contract.precisions_salaire || "");
}, [contract.cachets_representations, contract.services_repetitions, contract.jours_representations, contract.jours_repetitions, contract.nombre_d_heures, contract.minutes_total, contract.jours_travail, contract.nombre_d_heures_par_jour, contract.date_signature, contract.precisions_salaire]);
@ -395,34 +473,79 @@ export default function ContractEditor({
const [sendESignNotification, setSendESignNotification] = useState(true);
const [verifiedEmployeeEmail, setVerifiedEmployeeEmail] = useState<string>("");
// Handler pour le calendrier des jours travaillés
const handleJoursTravailApply = (result: {
selectedDates: string[];
hasMultiMonth: boolean;
pdfFormatted: string;
}) => {
setJoursTravail(result.selectedDates.join(", "));
setJoursTravailDisplay(result.pdfFormatted);
};
// Handler pour le calendrier des dates de représentations
// Ouvrir le modal de quantités pour permettre la précision par date
const handleDatesRepresentationsApply = (result: {
selectedDates: string[];
hasMultiMonth: boolean;
pdfFormatted: string;
}) => {
setDatesRepresentations(result.selectedDates.join(", "));
setDatesRepresentationsDisplay(result.pdfFormatted);
setPendingDates(result.selectedDates);
setQuantityModalType("representations");
setQuantityModalOpen(true);
};
// Handler pour le calendrier des dates de répétitions
// Ouvrir le modal de quantités pour permettre la précision par date
const handleDatesRepetitionsApply = (result: {
selectedDates: string[];
hasMultiMonth: boolean;
pdfFormatted: string;
}) => {
setDatesRepetitions(result.selectedDates.join(", "));
setDatesRepetitionsDisplay(result.pdfFormatted);
setPendingDates(result.selectedDates);
setQuantityModalType("repetitions");
setQuantityModalOpen(true);
};
// Handler pour le calendrier des jours travaillés
// Ouvrir le modal de quantités pour permettre la précision par date
const handleJoursTravailApply = (result: {
selectedDates: string[];
hasMultiMonth: boolean;
pdfFormatted: string;
}) => {
setPendingDates(result.selectedDates);
setQuantityModalType("jours_travail");
setQuantityModalOpen(true);
};
// Handler pour la modale de quantités (applique les données finales)
const handleQuantityApply = (result: {
selectedDates: string[];
hasMultiMonth: boolean;
pdfFormatted: string;
}) => {
// Calculer le nombre de jours/dates sélectionnées
const nbDates = result.selectedDates.length;
switch (quantityModalType) {
case "representations":
setDatesRepresentations(result.pdfFormatted);
setDatesRepresentationsDisplay(result.pdfFormatted);
// Auto-remplir le nombre de représentations
setNbRepresentations(nbDates);
break;
case "repetitions":
// Ajouter la durée des services au texte formaté
let formattedText = result.pdfFormatted;
if (durationServices) {
// Remplacer "service de répétition" par "service de répétition de X heures"
formattedText = formattedText
.replace(/service de répétition/g, `service de répétition de ${durationServices} heures`)
.replace(/services de répétition/g, `services de répétition de ${durationServices} heures chacun`);
}
setDatesRepetitions(formattedText);
setDatesRepetitionsDisplay(formattedText);
// Auto-remplir le nombre de services de répétition
setNbServicesRepetition(nbDates);
break;
case "jours_travail":
setJoursTravail(result.pdfFormatted);
setJoursTravailDisplay(result.pdfFormatted);
break;
}
setQuantityModalOpen(false);
setPendingDates([]);
};
// Fonction pour ouvrir le PDF avec URL pré-signée
@ -582,6 +705,28 @@ export default function ContractEditor({
(categoriePro === "Artiste" && professionPick?.code === "MET040")
);
}, [categoriePro, professionPick]);
// Auto-calculer le nombre d'heures total basé sur le nombre de services x durée
useEffect(() => {
if (!useHeuresMode && datesRepetitions) {
// Extraire le nombre total de services depuis le texte formaté
// Exemple: "1 service de répétition de 4 heures le 29/10 ; 2 services de répétition de 4 heures chacun le 30/10"
// On doit sommer : 1 + 2 = 3
let totalServices = 0;
// Chercher tous les patterns "N service(s)"
const servicePattern = /(\d+)\s+service/g;
let match;
while ((match = servicePattern.exec(datesRepetitions)) !== null) {
totalServices += parseInt(match[1], 10);
}
if (totalServices > 0) {
const duration = parseInt(durationServices);
const totalHours = totalServices * duration;
setNombreHeuresTotal(totalHours);
}
}
}, [useHeuresMode, datesRepetitions, durationServices]);
// Load technicians list
async function ensureTechniciensLoaded() {
@ -1967,21 +2112,56 @@ export default function ContractEditor({
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-xs text-muted-foreground">Nombre de représentations</label>
<Input
type="number"
min={0}
value={nbRepresentations}
onChange={(e) => setNbRepresentations(e.target.value === "" ? "" : Number(e.target.value))}
/>
<div className="flex items-center gap-2">
<Input
type="number"
min={0}
value={nbRepresentations}
onChange={(e) => setNbRepresentations(e.target.value === "" ? "" : Number(e.target.value))}
className="flex-1"
/>
<button
type="button"
onClick={() => setDatesRepresentationsOpen(true)}
className="px-3 py-2 rounded-lg border bg-white text-sm hover:bg-slate-50 transition whitespace-nowrap"
title="Préciser les dates et quantités"
>
Préciser
</button>
</div>
</div>
<div>
<label className="text-xs text-muted-foreground">Nombre de services de répétition</label>
<Input
type="number"
min={0}
value={nbServicesRepetition}
onChange={(e) => setNbServicesRepetition(e.target.value === "" ? "" : Number(e.target.value))}
/>
<label className="text-xs text-muted-foreground">Nombre de services de répétition / Durée</label>
<div className="flex items-center gap-2">
<Input
type="number"
min={0}
value={nbServicesRepetition}
onChange={(e) => setNbServicesRepetition(e.target.value === "" ? "" : Number(e.target.value))}
placeholder="Nombre"
className="flex-1"
/>
<span className="text-slate-400 font-medium">×</span>
{nbServicesRepetition > 0 && (
<select
value={durationServices}
onChange={(e) => setDurationServices(e.target.value as "3" | "4")}
className="px-3 py-2 rounded-lg border bg-white text-sm"
title="Durée des services de répétition"
>
<option value="3">3h</option>
<option value="4">4h</option>
</select>
)}
<button
type="button"
onClick={() => setDatesRepetitionsOpen(true)}
className="px-3 py-2 rounded-lg border bg-white text-sm hover:bg-slate-50 transition whitespace-nowrap"
title="Préciser les dates et quantités"
>
Préciser
</button>
</div>
</div>
<div>
<label className="text-xs text-muted-foreground">Dates de représentations</label>
@ -2502,6 +2682,18 @@ export default function ContractEditor({
employeeEmail={verifiedEmployeeEmail}
isLoading={isLaunchingSignature}
/>
{/* Dates Quantity Modal for precise selection */}
<DatesQuantityModal
isOpen={quantityModalOpen}
onClose={() => {
setQuantityModalOpen(false);
setPendingDates([]);
}}
selectedDates={pendingDates}
dateType={quantityModalType === "representations" ? "representations" : quantityModalType === "repetitions" ? "repetitions" : "jours_travail"}
onApply={handleQuantityApply}
/>
</div>
);
}

View file

@ -58,15 +58,21 @@ export function Tooltip({ children, content, className, side = 'top', asChild =
break
}
// Position the tooltip relative to the computed anchor point
// For 'top', anchor is at the bottom-center of the tooltip (so translateY(-100%))
// For 'bottom', anchor is at the top-center (no vertical translation)
// For 'left'/'right', anchor centered vertically.
let transform = ''
if (side === 'top') transform = 'translate(-50%, -100%)'
else if (side === 'bottom') transform = 'translate(-50%, 0)'
else if (side === 'left') transform = 'translate(-100%, -50%)'
else transform = 'translate(0, -50%)'
return {
position: 'fixed',
left: `${left}px`,
top: `${top}px`,
transform: side === 'top' || side === 'bottom'
? 'translateX(-50%)'
: side === 'left'
? 'translate(-100%, -50%)'
: 'translateY(-50%)',
transform,
zIndex: 9999
}
}

View file

@ -72,6 +72,74 @@ function generateDateRange(startIso: string, endIso: string): string[] {
return dates;
}
// Exporter generateDateRange pour usage externe
export { generateDateRange };
/**
* Format des dates quantifiées.
* Prend une liste de dates ISO (YYYY-MM-DD) et une map { iso: quantity }
* Retourne une chaîne formatée pour PDFMonkey en regroupant les dates
* consécutives qui ont la même quantité. Exemple:
* "1 représentation le 12/10 ; 1 représentation par jour du 13/10 au 16/10 ; 3 heures le 13/10."
*/
export function formatQuantifiedDates(
isos: string[],
quantities: Record<string, number | string>,
dateType: "representations" | "repetitions" | "jours_travail"
): string {
if (!isos || isos.length === 0) return "";
// Trier les dates
const sorted = Array.from(new Set(isos)).sort();
// Construire tableau { iso, qty }
const items = sorted.map((iso) => ({ iso, qty: quantities[iso] ?? quantities[formatDateFr(iso)] ?? 0 }));
// Grouper consécutifs avec même qty
const groups: Array<{ startIso: string; endIso?: string; qty: number | string } > = [];
for (let i = 0; i < items.length; i++) {
const cur = items[i];
const prev = groups[groups.length - 1];
if (!prev) {
groups.push({ startIso: cur.iso, endIso: cur.iso, qty: cur.qty });
continue;
}
const isConsec = isConsecutiveDay(prev.endIso!, cur.iso);
const sameQty = String(prev.qty) === String(cur.qty);
if (isConsec && sameQty) {
prev.endIso = cur.iso;
} else {
groups.push({ startIso: cur.iso, endIso: cur.iso, qty: cur.qty });
}
}
// Construire le texte
const parts: string[] = groups.map((g) => {
const qty = g.qty;
const qtyNum = typeof qty === 'number' ? qty : (parseInt(String(qty)) || 0);
// déterminer le libellé de base
let base = '';
if (dateType === 'representations') base = qtyNum > 1 ? 'représentations' : 'représentation';
else if (dateType === 'repetitions') base = qtyNum > 1 ? 'services de répétition' : 'service de répétition';
else if (dateType === 'jours_travail') base = qtyNum > 1 ? 'heures' : 'heure';
if (g.startIso === g.endIso) {
// date isolée
return `${qty} ${base} le ${formatDateFr(g.startIso)}`;
}
// plage: utiliser "par jour" pour indiquer par-journée
if (dateType === 'jours_travail') {
return `${qty} ${base} par jour du ${formatDateFr(g.startIso)} au ${formatDateFr(g.endIso!)}`;
}
// representations / repetitions -> "X base par jour du A au B"
return `${qty} ${base} par jour du ${formatDateFr(g.startIso)} au ${formatDateFr(g.endIso!)}`;
});
return parts.join(' ; ') + '.';
}
/**
* Parse une chaîne de dates en français (DD/MM) séparées par des virgules
* Retourne des groupes (dates isolées ou plages consécutives)