- Créer hook useStaffOrgSelection avec persistence localStorage - Ajouter badge StaffOrgBadge dans Sidebar - Synchroniser filtres org dans toutes les pages (contrats, cotisations, facturation, etc.) - Fix calcul cachets: utiliser totalQuantities au lieu de dates.length - Fix structure field bug: ne plus écraser avec production_name - Ajouter création note lors modification contrat - Implémenter montants personnalisés pour virements salaires - Migrations SQL: custom_amount + fix_structure_field - Réorganiser boutons ContractEditor en carte flottante droite
558 lines
21 KiB
TypeScript
558 lines
21 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useMemo, useEffect } 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;
|
|
globalQuantity?: number;
|
|
globalDuration?: "3" | "4";
|
|
totalHours?: number; // Total des heures saisies (pour jours_travail)
|
|
totalQuantities?: number; // Somme de toutes les quantités saisies par jour
|
|
}) => void;
|
|
selectedDates: string[]; // format input "12/10, 13/10, ..."
|
|
dateType: "representations" | "repetitions" | "jours_travail" | "heures_repetitions"; // Type de dates pour déterminer le libellé
|
|
minDate?: string;
|
|
maxDate?: string;
|
|
allowSkipHoursByDay?: boolean; // Permet de cocher "Ne pas appliquer d'heures par jour"
|
|
repetitionDuration?: "3" | "4"; // Durée des services de répétition (3 ou 4 heures)
|
|
}
|
|
|
|
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,
|
|
allowSkipHoursByDay = false,
|
|
repetitionDuration,
|
|
}: 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);
|
|
|
|
// État pour la checkbox "Ne pas appliquer d'heures par jour"
|
|
const [skipHoursByDay, setSkipHoursByDay] = useState<boolean>(false);
|
|
|
|
// État pour le nombre global saisi quand skipHoursByDay est coché
|
|
const [globalQuantity, setGlobalQuantity] = useState<string>("");
|
|
|
|
// État pour la durée des services quand c'est des répétitions sans détail
|
|
const [globalDuration, setGlobalDuration] = useState<"3" | "4">(repetitionDuration || "4");
|
|
|
|
// État pour le champ "Appliquer à toutes les dates"
|
|
const [applyToAllValue, setApplyToAllValue] = useState<string>("");
|
|
|
|
// Erreur de validation
|
|
const [validationError, setValidationError] = useState<string>("");
|
|
|
|
// Réinitialiser les quantités quand on coche "Ne pas appliquer d'heures par jour"
|
|
useEffect(() => {
|
|
if (skipHoursByDay) {
|
|
// Vider toutes les quantités
|
|
const emptyQuantities: Record<string, number | string> = {};
|
|
selectedIsos.forEach((iso) => {
|
|
emptyQuantities[iso] = "";
|
|
});
|
|
setQuantities(emptyQuantities);
|
|
setApplyToAllValue("");
|
|
setValidationError("");
|
|
} else {
|
|
// Réinitialiser le nombre global
|
|
setGlobalQuantity("");
|
|
}
|
|
}, [skipHoursByDay, selectedIsos]);
|
|
|
|
// 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)";
|
|
case "heures_repetitions":
|
|
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)";
|
|
case "heures_repetitions":
|
|
return isRange ? "heure(s) par jour" : "heure(s)";
|
|
default:
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function getSkipCheckboxLabel(type: string): { title: string; description: string } {
|
|
switch (type) {
|
|
case "representations":
|
|
return {
|
|
title: "Ne pas appliquer de représentations par jour",
|
|
description: "Vous pouvez choisir d'afficher le détail des représentations par jour sur le contrat de travail, ou uniquement le nombre global de représentations."
|
|
};
|
|
case "repetitions":
|
|
return {
|
|
title: "Ne pas appliquer de répétitions par jour",
|
|
description: "Vous pouvez choisir d'afficher le détail des répétitions par jour sur le contrat de travail, ou uniquement le nombre global de répétitions."
|
|
};
|
|
case "jours_travail":
|
|
case "heures_repetitions":
|
|
default:
|
|
return {
|
|
title: "Ne pas appliquer d'heures par jour",
|
|
description: "Vous pouvez choisir d'afficher le détail des heures de travail par jour sur le contrat de travail, ou uniquement le nombre global d'heures."
|
|
};
|
|
}
|
|
}
|
|
|
|
const skipCheckboxText = getSkipCheckboxLabel(dateType);
|
|
|
|
// 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 "";
|
|
|
|
// Si on ne veut pas de détails par jour, générer un format simplifié
|
|
if (skipHoursByDay) {
|
|
// Grouper les dates consécutives sans quantités
|
|
const groups = convertIsoDatesToGroups(selectedIsos);
|
|
const parts = groups.map(g => {
|
|
if (g.type === "range") {
|
|
// Le displayFr est déjà formaté comme "du DD/MM au DD/MM"
|
|
// On le garde tel quel, pas besoin de manipulation
|
|
return g.displayFr;
|
|
}
|
|
// Pour les dates isolées : "le DD/MM"
|
|
return g.displayFr;
|
|
});
|
|
return parts.join(' ; ') + '.';
|
|
}
|
|
|
|
return formatQuantifiedDates(selectedIsos, quantities, dateType, repetitionDuration);
|
|
}, [selectedIsos, quantities, dateType, skipHoursByDay, repetitionDuration]);
|
|
|
|
const handleQuantityChange = (iso: string, value: string) => {
|
|
// Gestion de la valeur vide: on permet l'effacement temporaire
|
|
if (value === "") {
|
|
setValidationError("");
|
|
setQuantities({ ...quantities, [iso]: "" });
|
|
return;
|
|
}
|
|
|
|
const n = parseInt(value, 10) || 0;
|
|
|
|
// Validation selon le type de dates
|
|
if (dateType === "heures_repetitions" || dateType === "jours_travail") {
|
|
// Pour les heures, accepter n'importe quel nombre >= 1
|
|
if (n < 1) {
|
|
setValidationError(`La quantité doit être au moins 1`);
|
|
return;
|
|
}
|
|
} else {
|
|
// Pour les représentations et services de répétition : limiter à 1-3
|
|
if (n < 1 || n > 3) {
|
|
setValidationError(`La quantité doit être entre 1 et 3`);
|
|
return;
|
|
}
|
|
}
|
|
|
|
setValidationError("");
|
|
setQuantities({ ...quantities, [iso]: n });
|
|
};
|
|
|
|
const handleApplyToAll = () => {
|
|
if (!applyToAllValue || applyToAllValue === "") {
|
|
setValidationError("Veuillez saisir une quantité");
|
|
return;
|
|
}
|
|
|
|
const n = parseInt(applyToAllValue, 10);
|
|
|
|
// Validation selon le type de dates
|
|
if (dateType === "heures_repetitions" || dateType === "jours_travail") {
|
|
if (n < 1) {
|
|
setValidationError(`La quantité doit être au moins 1`);
|
|
return;
|
|
}
|
|
} else {
|
|
if (n < 1 || n > 3) {
|
|
setValidationError(`La quantité doit être entre 1 et 3`);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Appliquer la valeur à toutes les dates
|
|
const newQuantities: Record<string, number | string> = {};
|
|
selectedIsos.forEach((iso) => {
|
|
newQuantities[iso] = n;
|
|
});
|
|
setQuantities(newQuantities);
|
|
setValidationError("");
|
|
setApplyToAllValue(""); // Réinitialiser le champ
|
|
};
|
|
|
|
const handleApply = () => {
|
|
let globalQty: number | undefined = undefined;
|
|
let globalDur: "3" | "4" | undefined = undefined;
|
|
let totalHrs: number | undefined = undefined;
|
|
let totalQtys: number | undefined = undefined;
|
|
|
|
// Si on ne veut pas d'heures par jour, valider le nombre global
|
|
if (skipHoursByDay) {
|
|
const qty = parseInt(globalQuantity, 10);
|
|
if (!globalQuantity || isNaN(qty) || qty < 1) {
|
|
setValidationError("Veuillez saisir un nombre global valide (>= 1)");
|
|
return;
|
|
}
|
|
globalQty = qty;
|
|
|
|
// Pour les répétitions, récupérer aussi la durée
|
|
if (dateType === "repetitions") {
|
|
globalDur = globalDuration;
|
|
}
|
|
} else {
|
|
// Vérifier que toutes les quantités sont > 0 ET calculer la somme totale
|
|
let sum = 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;
|
|
}
|
|
// Calculer le total de TOUTES les quantités
|
|
const qtyNum = typeof qty === "number" ? qty : parseInt(String(qty), 10);
|
|
if (!isNaN(qtyNum)) {
|
|
sum += qtyNum;
|
|
}
|
|
}
|
|
|
|
// Stocker la somme totale pour les représentations/répétitions
|
|
if (dateType === "representations" || dateType === "repetitions") {
|
|
totalQtys = sum;
|
|
}
|
|
|
|
// Si c'est des jours de travail, on retourne le total d'heures
|
|
if (dateType === "jours_travail" && sum > 0) {
|
|
totalHrs = sum;
|
|
}
|
|
}
|
|
|
|
if (!pdfFormatted) {
|
|
setValidationError("Erreur lors du formatage des dates");
|
|
return;
|
|
}
|
|
|
|
onApply({
|
|
selectedDates: selectedDates,
|
|
hasMultiMonth: selectedIsos.length > 0 && checkMultiMonth(selectedIsos),
|
|
pdfFormatted,
|
|
globalQuantity: globalQty,
|
|
globalDuration: dateType === "repetitions" ? globalDuration : globalDur,
|
|
totalHours: totalHrs,
|
|
totalQuantities: totalQtys,
|
|
});
|
|
|
|
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/50 z-[100]"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onClose();
|
|
}}
|
|
/>
|
|
|
|
{/* Modale */}
|
|
<div className="fixed inset-0 z-[101] flex items-center justify-center p-4 pointer-events-none">
|
|
<div
|
|
className="bg-white rounded-2xl shadow-2xl w-full max-w-md max-h-[80vh] flex flex-col pointer-events-auto"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* 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">
|
|
{/* Card option pour ne pas appliquer d'heures par jour */}
|
|
{allowSkipHoursByDay && (
|
|
<div className="p-4 rounded-lg border border-indigo-200 bg-indigo-50 space-y-3">
|
|
<label className="flex items-start gap-3 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={skipHoursByDay}
|
|
onChange={(e) => setSkipHoursByDay(e.target.checked)}
|
|
className="mt-1 w-4 h-4 rounded border-indigo-300 text-indigo-600 focus:ring-indigo-500"
|
|
/>
|
|
<div className="flex-1">
|
|
<div className="font-medium text-indigo-900">
|
|
{skipCheckboxText.title}
|
|
</div>
|
|
<div className="text-xs text-indigo-700 mt-1">
|
|
{skipCheckboxText.description}
|
|
</div>
|
|
</div>
|
|
</label>
|
|
|
|
{/* Champ de saisie du nombre global si coché */}
|
|
{skipHoursByDay && (
|
|
<div className="space-y-2">
|
|
<label className="block text-sm font-medium text-indigo-900">
|
|
{dateType === "representations" && "Nombre total de représentations"}
|
|
{dateType === "repetitions" && "Nombre total de services de répétition"}
|
|
{(dateType === "jours_travail" || dateType === "heures_repetitions") && "Nombre total d'heures"}
|
|
</label>
|
|
<Input
|
|
type="number"
|
|
min={1}
|
|
placeholder="Saisissez le nombre"
|
|
value={globalQuantity}
|
|
onChange={(e) => setGlobalQuantity(e.target.value)}
|
|
className="bg-white"
|
|
/>
|
|
|
|
{/* Durée pour les répétitions */}
|
|
{dateType === "repetitions" && (
|
|
<div className="mt-2">
|
|
<label className="block text-sm font-medium text-indigo-900 mb-1">
|
|
Durée des services de répétition
|
|
</label>
|
|
<div className="flex items-center gap-4">
|
|
<label className="inline-flex items-center gap-2 text-sm cursor-pointer">
|
|
<input
|
|
type="radio"
|
|
checked={globalDuration === "3"}
|
|
onChange={() => setGlobalDuration("3")}
|
|
className="text-indigo-600"
|
|
/>
|
|
3 heures
|
|
</label>
|
|
<label className="inline-flex items-center gap-2 text-sm cursor-pointer">
|
|
<input
|
|
type="radio"
|
|
checked={globalDuration === "4"}
|
|
onChange={() => setGlobalDuration("4")}
|
|
className="text-indigo-600"
|
|
/>
|
|
4 heures
|
|
</label>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{validationError && (
|
|
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-800">
|
|
{validationError}
|
|
</div>
|
|
)}
|
|
|
|
{/* Champ pour appliquer à toutes les dates */}
|
|
{!skipHoursByDay && selectedIsos.length > 1 && (
|
|
<div className="p-4 rounded-lg border border-emerald-200 bg-emerald-50 space-y-2">
|
|
<div className="font-medium text-emerald-900 text-sm mb-2">
|
|
Appliquer la même quantité à toutes les dates
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
type="number"
|
|
min={1}
|
|
max={dateType === "heures_repetitions" || dateType === "jours_travail" ? undefined : 3}
|
|
placeholder={dateType === "heures_repetitions" || dateType === "jours_travail" ? "≥1" : "1-3"}
|
|
value={applyToAllValue}
|
|
onChange={(e) => setApplyToAllValue(e.target.value)}
|
|
className="w-24"
|
|
/>
|
|
<span className="text-sm text-emerald-700">{getUnitLabel(dateType, false)}</span>
|
|
<button
|
|
type="button"
|
|
onClick={handleApplyToAll}
|
|
className="ml-auto bg-emerald-600 hover:bg-emerald-700 text-white text-sm px-3 py-1.5 rounded-md font-medium transition-colors"
|
|
>
|
|
Appliquer
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Durée des services de répétitions */}
|
|
{dateType === "repetitions" && (
|
|
<div className="p-4 rounded-lg border border-indigo-200 bg-indigo-50 space-y-3">
|
|
<div className="font-medium text-indigo-900 text-sm">
|
|
Durée des services de répétitions
|
|
</div>
|
|
<div className="flex items-center gap-4">
|
|
<label className="inline-flex items-center gap-2 text-sm cursor-pointer">
|
|
<input
|
|
type="radio"
|
|
checked={globalDuration === "3"}
|
|
onChange={() => setGlobalDuration("3")}
|
|
className="text-indigo-600"
|
|
/>
|
|
3 heures
|
|
</label>
|
|
<label className="inline-flex items-center gap-2 text-sm cursor-pointer">
|
|
<input
|
|
type="radio"
|
|
checked={globalDuration === "4"}
|
|
onChange={() => setGlobalDuration("4")}
|
|
className="text-indigo-600"
|
|
/>
|
|
4 heures
|
|
</label>
|
|
</div>
|
|
<div className="text-xs text-indigo-700 leading-relaxed">
|
|
La durée des services de répétitions dépend de votre CCN et du type de profession. Consultez les minima pour plus d'infos ou contactez-nous en cas de doute.
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{dateGroups.length === 0 ? (
|
|
<div className="text-center py-8 text-slate-500">
|
|
Aucune date sélectionnée
|
|
</div>
|
|
) : skipHoursByDay ? (
|
|
// Si on ne veut pas d'heures par jour, afficher juste la liste des dates sans input
|
|
<div className="space-y-2">
|
|
<div className="text-sm font-medium text-slate-700 mb-2">
|
|
Dates sélectionnées :
|
|
</div>
|
|
{selectedIsos.map((iso) => (
|
|
<div key={iso} className="p-2 border rounded-lg bg-slate-50 text-sm text-slate-700">
|
|
{formatDateFr(iso)}
|
|
</div>
|
|
))}
|
|
</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={dateType === "heures_repetitions" || dateType === "jours_travail" ? undefined : 3}
|
|
placeholder={dateType === "heures_repetitions" || dateType === "jours_travail" ? "≥1" : "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>
|
|
</>
|
|
);
|
|
}
|