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:
parent
b3b56a9b4e
commit
2fca0fcbf2
12 changed files with 1368 additions and 308 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
239
components/DatesQuantityModal.tsx
Normal file
239
components/DatesQuantityModal.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
60
components/contrats/MinimaCCNPreview.tsx
Normal file
60
components/contrats/MinimaCCNPreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue