feat: Ajout fonctionnalité salaires par date avec JSONB

 Nouvelle fonctionnalité
- Ajout mode 'Saisir le salaire par date' dans création de contrat
- Interface de saisie compacte et moderne par représentation/répétition/jour
- Stockage en JSONB dans colonne salaires_par_date
- Affichage détaillé dans contrats/[id] et staff/contrats/[id]

🗄️ Base de données
- Migration SQL : ajout colonne salaires_par_date JSONB
- Index GIN pour requêtes performantes sur JSONB
- Index partial sur le champ mode
- Fonction de validation validate_salaires_par_date()

🎨 Interface utilisateur
- Design en ligne (inline) au lieu de tableau traditionnel
- Labels courts (R1, R2 pour représentations, S1, S2 pour services)
- Cartes par catégorie avec codes couleur (indigo/purple/green)
- Calculatrice et Minima accessibles dans les deux modes
- Calculatrice désactivée en mode par_date (pas de champ unique)
- Minimum conventionnel retiré du menu déroulant en mode par_date
- Calcul automatique du total

💻 Code
- Types TypeScript : SalaireParDate avec interfaces complètes
- Fonction convertSalariesByDateToJSON() pour conversion formulaire → JSONB
- Validation adaptée selon le mode (global vs par_date)
- API /api/cddu-contracts : support du champ salaires_par_date
- API /api/contrats/[id] : retour du champ salaires_par_date
- Contournement temporaire de la RPC pour utiliser service_role

📝 Fichiers modifiés
- migrations/add_salaires_par_date_column.sql (nouveau)
- types/salaires.ts (nouveau)
- components/contrats/NouveauCDDUForm.tsx
- app/api/cddu-contracts/route.ts
- app/api/contrats/[id]/route.ts
- app/(app)/contrats/[id]/page.tsx
- components/staff/contracts/ContractEditor.tsx
This commit is contained in:
odentas 2025-10-29 23:12:15 +01:00
parent e6c7dc45cc
commit 31459f3c10
7 changed files with 648 additions and 153 deletions

View file

@ -224,6 +224,24 @@ type ContratDetail = {
categorie_prof?: string;
type_salaire?: string; // Brut / Net etc.
salaire_demande?: string; // "622,40€"
salaires_par_date?: { // Détail des salaires par date (JSONB)
mode: "par_date";
type_salaire: string;
representations?: Array<{
date: string;
items: Array<{ numero: number; montant: number }>;
}>;
repetitions?: Array<{
date: string;
items: Array<{ numero: number; montant: number; duree_heures?: number }>;
}>;
jours_travail?: Array<{
date: string;
montant: number;
heures?: number;
}>;
total_calcule: number;
};
date_debut: string; // ISO
date_fin: string; // ISO
panier_repas?: StatutSimple;
@ -1289,7 +1307,78 @@ return (
<Field label="Profession" value={data.profession} />
<Field label="Catégorie professionnelle" value={data.categorie_prof} />
<Field label="Type de salaire demandé" value={data.type_salaire} />
<Field label="Salaire demandé" value={formatEUR(data.salaire_demande)} />
{/* Affichage conditionnel : soit salaire global, soit détail par date */}
{data.salaires_par_date ? (
<Field
label="Salaire demandé (par date)"
value={
<div className="space-y-2 text-sm">
{/* Représentations */}
{data.salaires_par_date.representations && data.salaires_par_date.representations.length > 0 && (
<div className="space-y-1">
<div className="text-xs font-semibold text-indigo-700 uppercase tracking-wide">Représentations</div>
{data.salaires_par_date.representations.map((rep: any, idx: number) => (
<div key={idx} className="flex items-center gap-2 text-xs">
<span className="font-medium text-slate-700 w-12">{rep.date}</span>
<div className="flex flex-wrap gap-2">
{rep.items.map((item: any, itemIdx: number) => (
<span key={itemIdx} className="text-slate-600">
R{item.numero}: <span className="font-semibold">{formatEUR(item.montant)}</span>
</span>
))}
</div>
</div>
))}
</div>
)}
{/* Répétitions */}
{data.salaires_par_date.repetitions && data.salaires_par_date.repetitions.length > 0 && (
<div className="space-y-1">
<div className="text-xs font-semibold text-purple-700 uppercase tracking-wide">Répétitions</div>
{data.salaires_par_date.repetitions.map((rep: any, idx: number) => (
<div key={idx} className="flex items-center gap-2 text-xs">
<span className="font-medium text-slate-700 w-12">{rep.date}</span>
<div className="flex flex-wrap gap-2">
{rep.items.map((item: any, itemIdx: number) => (
<span key={itemIdx} className="text-slate-600">
S{item.numero}: <span className="font-semibold">{formatEUR(item.montant)}</span>
</span>
))}
</div>
</div>
))}
</div>
)}
{/* Jours travaillés */}
{data.salaires_par_date.jours_travail && data.salaires_par_date.jours_travail.length > 0 && (
<div className="space-y-1">
<div className="text-xs font-semibold text-green-700 uppercase tracking-wide">Jours travaillés</div>
{data.salaires_par_date.jours_travail.map((jour: any, idx: number) => (
<div key={idx} className="flex items-center gap-2 text-xs">
<span className="font-medium text-slate-700 w-12">{jour.date}</span>
<span className="text-slate-600">
Jour: <span className="font-semibold">{formatEUR(jour.montant)}</span>
</span>
</div>
))}
</div>
)}
{/* Total */}
<div className="pt-2 border-t border-slate-200 mt-2">
<span className="text-xs font-semibold text-slate-700">Total: </span>
<span className="text-sm font-bold text-indigo-900">{formatEUR(data.salaires_par_date.total_calcule)}</span>
</div>
</div>
}
/>
) : (
<Field label="Salaire demandé" value={formatEUR(data.salaire_demande)} />
)}
<Field label="Début contrat" value={formatDateFR(data.date_debut)} />
<Field label="Fin contrat" value={formatDateFR(data.date_fin)} />
<Field label="Panier repas" value={boolBadge(data.panier_repas)} />

View file

@ -50,6 +50,7 @@ export async function POST(request: NextRequest) {
const body = await request.json();
console.log("Body reçu pour création CDDU:", body);
console.log("🔍 [API] body.salaires_par_date:", body.salaires_par_date);
// Générer un identifiant unique pour le contrat
const contractId = uuidv4();
@ -446,6 +447,9 @@ export async function POST(request: NextRequest) {
mineur_entre_16_et_18: "Non",
heure_traitement_demande: new Date().toLocaleTimeString('fr-FR', { hour12: false }),
// Salaires par date (JSONB)
salaires_par_date: body.salaires_par_date || null,
// Champs numériques avec valeurs par défaut
cachets_representations: body.nb_representations ? body.nb_representations.toString() : "0",
services_repetitions: body.nb_services_repetition ? body.nb_services_repetition.toString() : "0",
@ -464,7 +468,33 @@ export async function POST(request: NextRequest) {
};
console.log("Données du contrat préparées:", contractData);
console.log("🔍 [API] contractData.salaires_par_date:", contractData.salaires_par_date);
// TEMPORAIRE : Utiliser directement service_role pour supporter salaires_par_date
// TODO: Mettre à jour la fonction RPC create_cddu_contract pour inclure ce champ
console.log('💡 [API] Insertion directe via service_role pour supporter salaires_par_date...');
if (!serviceSupabase) {
console.error('❌ [API] Service role non configuré');
return NextResponse.json({ error: 'Configuration Supabase incomplète' }, { status: 500 });
}
const { data: contract, error } = await serviceSupabase
.from('cddu_contracts')
.insert(contractData)
.select()
.single();
let finalContract = contract;
if (error) {
console.error('❌ [API] Erreur insertion contrat via service_role:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
console.log('✅ [API] Contrat inséré avec succès, ID:', finalContract?.id);
/* ANCIEN CODE avec RPC - désactivé temporairement
// Essayer d'abord la fonction RPC pour contourner les politiques RLS
const { data: contract, error } = await supabase
.rpc('create_cddu_contract', { contract_data: contractData });
@ -495,6 +525,7 @@ export async function POST(request: NextRequest) {
finalContract = serviceContract;
}
*/
// Créer une note système automatique pour tracer la création du contrat
try {

View file

@ -151,6 +151,7 @@ export async function GET(req: NextRequest, { params }: { params: { id: string }
categorie_prof: cddu.categorie_pro || undefined,
type_salaire: cddu.type_salaire || undefined,
salaire_demande: cddu.salaire || cddu.gross_pay || undefined,
salaires_par_date: cddu.salaires_par_date || undefined,
date_debut: cddu.start_date,
date_fin: cddu.end_date,
panier_repas: cddu.paniers_repas,

View file

@ -1197,6 +1197,126 @@ useEffect(() => {
// Upload de fichiers supprimé
// Fonction de conversion des salaires par date en JSONB
function convertSalariesByDateToJSON(): any {
console.log("🔍 [convertSalariesByDateToJSON] Début de la conversion");
console.log("🔍 salaryMode:", salaryMode);
console.log("🔍 salariesByDate:", salariesByDate);
console.log("🔍 datesRep:", datesRep);
console.log("🔍 datesServ:", datesServ);
console.log("🔍 joursTravail:", joursTravail);
if (salaryMode !== "par_date") {
console.log("❌ salaryMode n'est pas 'par_date', retour null");
return null;
}
const representations: any[] = [];
const repetitions: any[] = [];
const jours_travail: any[] = [];
// Parser les représentations
if (datesRep && datesRep.length > 0) {
const groups = datesRep.split(" ; ");
groups.forEach((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;
dateMatches.forEach((dateDisplay, dateIdx) => {
const items: any[] = [];
for (let i = 0; i < qty; i++) {
const key = `rep_${groupIdx}_${dateIdx}_${i}`;
const montant = salariesByDate[key];
if (montant && typeof montant === "number") {
items.push({
numero: i + 1,
montant: montant,
});
}
}
if (items.length > 0) {
representations.push({
date: dateDisplay,
items: items,
});
}
});
});
}
// Parser les répétitions
if (datesServ && datesServ.length > 0) {
const groups = datesServ.split(" ; ");
groups.forEach((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;
dateMatches.forEach((dateDisplay, dateIdx) => {
const items: any[] = [];
for (let i = 0; i < qty; i++) {
const key = `serv_${groupIdx}_${dateIdx}_${i}`;
const montant = salariesByDate[key];
if (montant && typeof montant === "number") {
items.push({
numero: i + 1,
montant: montant,
duree_heures: parseInt(durationServices) || 4,
});
}
}
if (items.length > 0) {
repetitions.push({
date: dateDisplay,
items: items,
});
}
});
});
}
// Parser les jours travaillés
if (joursTravail && joursTravail.length > 0) {
const groups = joursTravail.split(" ; ");
groups.forEach((dateStr, groupIdx) => {
const dateMatches = dateStr.match(/(\d{2}\/\d{2})/g) || [];
if (dateMatches.length > 0) {
const key = `jour_${groupIdx}_0`;
const montant = salariesByDate[key];
if (montant && typeof montant === "number") {
// Extraire les heures si présentes dans la string
const heuresMatch = dateStr.match(/(\d+)\s+heures?/);
const heures = heuresMatch ? parseInt(heuresMatch[1]) : 7;
jours_travail.push({
date: dateMatches[0],
montant: montant,
heures: heures,
});
}
}
});
}
// Calculer le total
const total_calcule = Object.values(salariesByDate).reduce((sum: number, val) => {
return sum + (typeof val === "number" ? val : 0);
}, 0);
const result = {
mode: "par_date",
type_salaire: typeSalaire,
representations: representations,
repetitions: repetitions,
jours_travail: jours_travail,
total_calcule: total_calcule,
};
console.log("✅ [convertSalariesByDateToJSON] Résultat:", result);
return result;
}
async function onSubmit() {
setErr(null);
@ -1231,9 +1351,19 @@ useEffect(() => {
if (isPastStart && !confirmPastStart)
return setErr("Veuillez cocher la case de confirmation liée au démarrage dans le passé.");
if (typeSalaire !== "Minimum conventionnel") {
if (montantSalaire === "" || Number(montantSalaire) <= 0)
return setErr("Veuillez saisir un montant de rémunération valide.");
// Validation du salaire selon le mode
if (salaryMode === "par_date") {
// En mode par date, vérifier qu'au moins un montant a été saisi
const hasAtLeastOneAmount = Object.values(salariesByDate).some(val => typeof val === "number" && val > 0);
if (!hasAtLeastOneAmount) {
return setErr("Veuillez saisir au moins un montant de salaire pour les dates.");
}
} else {
// En mode global
if (typeSalaire !== "Minimum conventionnel") {
if (montantSalaire === "" || Number(montantSalaire) <= 0)
return setErr("Veuillez saisir un montant de rémunération valide.");
}
}
// Valeurs structurées communes (PATCH ou webhook)
@ -1263,7 +1393,8 @@ useEffect(() => {
minutes_travail: !isRegimeRG && useHeuresMode ? minutesTotal : undefined,
jours_travail: !isRegimeRG && useHeuresMode ? (joursTravail || undefined) : undefined,
type_salaire: typeSalaire,
montant: typeSalaire !== "Minimum conventionnel" ? (montantSalaire === "" ? undefined : montantSalaire) : undefined,
montant: salaryMode === "par_date" ? undefined : (typeSalaire !== "Minimum conventionnel" ? (montantSalaire === "" ? undefined : montantSalaire) : undefined),
salaires_par_date: !isRegimeRG && salaryMode === "par_date" ? convertSalariesByDateToJSON() : undefined,
panier_repas: panierRepas,
nombre_paniers_repas: panierRepas === "Oui" && nombrePaniersRepas !== "" ? nombrePaniersRepas : undefined,
panier_repas_ccn: panierRepas === "Oui" ? panierRepasCCN : undefined,
@ -1324,9 +1455,13 @@ useEffect(() => {
minutes_total: payload.minutes_travail,
jours_travail: payload.jours_travail,
multi_mois: payload.multi_mois,
salaires_par_date: payload.salaires_par_date,
})
};
console.log("🚀 [onSubmit] contractData envoyé à l'API:", contractData);
console.log("🚀 [onSubmit] salaires_par_date dans contractData:", (contractData as any).salaires_par_date);
// Utiliser l'endpoint approprié
const apiEndpoint = isRegimeRG ? "/api/rg-contracts" : "/api/cddu-contracts";
@ -2154,21 +2289,18 @@ useEffect(() => {
>
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>
<button
type="button"
onClick={() => setSalaryMode("par_date")}
className={
`px-4 py-2 font-medium text-sm transition-colors ` +
(salaryMode === "par_date"
? "text-indigo-600 border-b-2 border-indigo-600 -mb-0.5"
: "text-slate-600 hover:text-slate-900")
}
>
Saisir le salaire par date
</button>
</div>
)}
@ -2315,157 +2447,184 @@ useEffect(() => {
)}
{/* Mode salaire par date (désactivé temporairement) */}
{false && salaryMode === "par_date" && (
{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 className="flex items-center gap-2">
<select
value={typeSalaire}
onChange={(e) => {
const v = e.target.value as typeof typeSalaire;
setTypeSalaire(v);
}}
className="flex-1 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>
</select>
{/* Bouton Calculatrice */}
<button
type="button"
onClick={() => setIsCalculatorOpen(true)}
className="px-3 py-2 rounded-lg border-0 bg-gradient-to-r from-blue-500 to-indigo-600 hover:from-blue-600 hover:to-indigo-700 transition-all flex items-center justify-center gap-2 text-sm flex-shrink-0 shadow-sm hover:shadow-md"
title="Ouvrir la calculatrice"
aria-label="Calculatrice"
>
<CalculatorIcon className="w-4 h-4 text-white flex-shrink-0" />
<span className="text-white font-medium whitespace-nowrap">Calculatrice</span>
</button>
{/* Bouton Minima */}
<a
href="/minima-ccn"
target="_blank"
rel="noopener noreferrer"
className="px-3 py-2 rounded-lg border-0 bg-gradient-to-r from-emerald-500 to-teal-600 hover:from-emerald-600 hover:to-teal-700 transition-all flex items-center justify-center gap-2 text-sm flex-shrink-0 shadow-sm hover:shadow-md"
title="Consulter les minima conventionnels"
>
<span className="text-white font-medium whitespace-nowrap">Minima</span>
<ExternalLink className="w-3.5 h-3.5 text-white flex-shrink-0" />
</a>
</div>
</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>
{/* Tableau des dates avec salaires - version compacte */}
<div className="space-y-3 mb-4">
{/* Représentations */}
{datesRep && datesRep.length > 0 && (
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
<div className="bg-indigo-50 px-3 py-2 border-b border-indigo-100">
<span className="text-xs font-semibold text-indigo-900 uppercase tracking-wide">Représentations</span>
</div>
<div className="p-2 space-y-1.5">
{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;
return dateMatches.map((dateDisplay, dateIdx) => (
<div key={`rep_group_${groupIdx}_${dateIdx}`} className="flex items-center gap-2 py-1.5 px-2 rounded hover:bg-slate-50">
<div className="text-xs font-medium text-slate-700 w-14 shrink-0">{dateDisplay}</div>
<div className="flex items-center gap-1.5 flex-wrap">
{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>
<div key={`rep_${groupIdx}_${dateIdx}_${i}`} className="flex items-center gap-1">
<span className="text-xs text-slate-500">R{i + 1}</span>
<input
type="number"
min="0.01"
step="0.01"
value={salariesByDate[`jour_${groupIdx}_0`] ?? ""}
value={salariesByDate[`rep_${groupIdx}_${dateIdx}_${i}`] ?? ""}
onChange={(e) =>
setSalariesByDate({
...salariesByDate,
[`jour_${groupIdx}_0`]: e.target.value === "" ? "" : Number(e.target.value),
[`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"
placeholder="0.00"
className="w-20 px-2 py-1 rounded border border-slate-300 bg-white text-xs text-right focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
title={`Représentation #${i + 1}`}
/>
<span className="text-xs text-slate-400"></span>
</div>
</td>
</tr>
);
})}
</>
)}
</tbody>
</table>
))}
</div>
</div>
));
})}
</div>
</div>
)}
{/* Répétitions */}
{datesServ && datesServ.length > 0 && (
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
<div className="bg-purple-50 px-3 py-2 border-b border-purple-100">
<span className="text-xs font-semibold text-purple-900 uppercase tracking-wide">Répétitions</span>
</div>
<div className="p-2 space-y-1.5">
{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;
return dateMatches.map((dateDisplay, dateIdx) => (
<div key={`serv_group_${groupIdx}_${dateIdx}`} className="flex items-center gap-2 py-1.5 px-2 rounded hover:bg-slate-50">
<div className="text-xs font-medium text-slate-700 w-14 shrink-0">{dateDisplay}</div>
<div className="flex items-center gap-1.5 flex-wrap">
{Array.from({ length: qty }).map((_, i) => (
<div key={`serv_${groupIdx}_${dateIdx}_${i}`} className="flex items-center gap-1">
<span className="text-xs text-slate-500">S{i + 1}</span>
<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="0.00"
className="w-20 px-2 py-1 rounded border border-slate-300 bg-white text-xs text-right focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
title={`Répétition #${i + 1}`}
/>
<span className="text-xs text-slate-400"></span>
</div>
))}
</div>
</div>
));
})}
</div>
</div>
)}
{/* Jours travaillés */}
{joursTravail && joursTravail.length > 0 && (
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
<div className="bg-green-50 px-3 py-2 border-b border-green-100">
<span className="text-xs font-semibold text-green-900 uppercase tracking-wide">Jours travaillés</span>
</div>
<div className="p-2 space-y-1.5">
{joursTravail.split(" ; ").map((dateStr, groupIdx) => {
const dateMatches = dateStr.match(/(\d{2}\/\d{2})/g) || [];
return (
<div key={`jour_group_${groupIdx}`} className="flex items-center gap-2 py-1.5 px-2 rounded hover:bg-slate-50">
<div className="text-xs font-medium text-slate-700 w-14 shrink-0">{dateMatches[0]}</div>
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-1">
<span className="text-xs text-slate-500">Jour</span>
<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="0.00"
className="w-20 px-2 py-1 rounded border border-slate-300 bg-white text-xs text-right focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500"
/>
<span className="text-xs text-slate-400"></span>
</div>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
{/* Total du salaire */}
@ -2718,12 +2877,12 @@ useEffect(() => {
<Calculator
isOpen={isCalculatorOpen}
onClose={() => setIsCalculatorOpen(false)}
onUseResult={(value) => {
onUseResult={salaryMode === "global" ? (value) => {
const rounded = Math.round((value + Number.EPSILON) * 100) / 100;
setMontantSalaire(rounded);
setMontantFromCalculator(true);
setIsCalculatorOpen(false);
}}
} : undefined}
/>
{/* Modale de quantités pour les dates sélectionnées */}

View file

@ -2207,6 +2207,73 @@ export default function ContractEditor({
onChange={(e) => setMontant(e.target.value)}
placeholder="Montant en euros"
/>
{/* Affichage du détail des salaires par date si disponible */}
{contract.salaires_par_date && (
<div className="mt-3 p-3 bg-slate-50 rounded-lg border border-slate-200">
<div className="text-xs font-semibold text-slate-700 mb-2">Détail des salaires par date</div>
<div className="space-y-2 text-xs">
{/* Représentations */}
{contract.salaires_par_date.representations && contract.salaires_par_date.representations.length > 0 && (
<div className="space-y-1">
<div className="text-xs font-semibold text-indigo-700 uppercase tracking-wide">Représentations</div>
{contract.salaires_par_date.representations.map((rep: any, idx: number) => (
<div key={idx} className="flex items-center gap-2 text-xs">
<span className="font-medium text-slate-700 w-12">{rep.date}</span>
<div className="flex flex-wrap gap-2">
{rep.items.map((item: any, itemIdx: number) => (
<span key={itemIdx} className="text-slate-600">
R{item.numero}: <span className="font-semibold">{item.montant.toFixed(2)} </span>
</span>
))}
</div>
</div>
))}
</div>
)}
{/* Répétitions */}
{contract.salaires_par_date.repetitions && contract.salaires_par_date.repetitions.length > 0 && (
<div className="space-y-1">
<div className="text-xs font-semibold text-purple-700 uppercase tracking-wide">Répétitions</div>
{contract.salaires_par_date.repetitions.map((rep: any, idx: number) => (
<div key={idx} className="flex items-center gap-2 text-xs">
<span className="font-medium text-slate-700 w-12">{rep.date}</span>
<div className="flex flex-wrap gap-2">
{rep.items.map((item: any, itemIdx: number) => (
<span key={itemIdx} className="text-slate-600">
S{item.numero}: <span className="font-semibold">{item.montant.toFixed(2)} </span>
</span>
))}
</div>
</div>
))}
</div>
)}
{/* Jours travaillés */}
{contract.salaires_par_date.jours_travail && contract.salaires_par_date.jours_travail.length > 0 && (
<div className="space-y-1">
<div className="text-xs font-semibold text-green-700 uppercase tracking-wide">Jours travaillés</div>
{contract.salaires_par_date.jours_travail.map((jour: any, idx: number) => (
<div key={idx} className="flex items-center gap-2 text-xs">
<span className="font-medium text-slate-700 w-12">{jour.date}</span>
<span className="text-slate-600">
Jour: <span className="font-semibold">{jour.montant.toFixed(2)} </span>
</span>
</div>
))}
</div>
)}
{/* Total */}
<div className="pt-2 border-t border-slate-200 mt-2">
<span className="text-xs font-semibold text-slate-700">Total: </span>
<span className="text-sm font-bold text-indigo-900">{contract.salaires_par_date.total_calcule.toFixed(2)} </span>
</div>
</div>
</div>
)}
</div>
<div>
<label className="text-xs text-muted-foreground">Panier repas</label>

View file

@ -0,0 +1,56 @@
-- Migration: Ajouter colonne salaires_par_date pour la saisie par date/représentation/répétition
-- Date: 2025-10-29
-- Description: Permet aux clients de saisir des salaires différenciés par date/événement
-- Ajouter la colonne JSONB
ALTER TABLE cddu_contracts
ADD COLUMN IF NOT EXISTS salaires_par_date JSONB;
-- Ajouter un commentaire explicatif
COMMENT ON COLUMN cddu_contracts.salaires_par_date IS
'Structure JSONB pour les salaires détaillés par date/représentation/répétition.
Format: {
"mode": "par_date",
"type_salaire": "Brut|Net avant PAS|Coût total employeur",
"representations": [{
"date": "2025-11-15",
"date_display": "15/11",
"items": [{"index": 1, "montant": 250.00}]
}],
"repetitions": [{
"date": "2025-11-10",
"date_display": "10/11",
"items": [{"index": 1, "montant": 150.00, "duree": 4}]
}],
"jours_travail": [{
"date": "2025-11-05",
"date_display": "05/11",
"montant": 180.00,
"heures": 8
}],
"total_calcule": 1130.00
}';
-- Créer un index GIN pour requêtes performantes sur le JSONB
CREATE INDEX IF NOT EXISTS idx_cddu_contracts_salaires_par_date
ON cddu_contracts USING GIN (salaires_par_date);
-- Créer un index partiel pour les contrats utilisant le mode par_date
CREATE INDEX IF NOT EXISTS idx_cddu_contracts_salaires_par_date_mode
ON cddu_contracts ((salaires_par_date->>'mode'))
WHERE salaires_par_date IS NOT NULL;
-- Fonction helper pour valider la structure JSONB (optionnel, pour validation future)
CREATE OR REPLACE FUNCTION validate_salaires_par_date(data JSONB)
RETURNS BOOLEAN AS $$
BEGIN
-- Vérifier que le mode existe
IF data ? 'mode' THEN
RETURN data->>'mode' IN ('global', 'par_date');
END IF;
RETURN TRUE; -- NULL est valide
END;
$$ LANGUAGE plpgsql IMMUTABLE;
COMMENT ON FUNCTION validate_salaires_par_date IS
'Valide la structure du JSONB salaires_par_date';

92
types/salaires.ts Normal file
View file

@ -0,0 +1,92 @@
// types/salaires.ts
// Types pour la saisie de salaires par date/représentation/répétition
export type SalaryMode = "global" | "par_date";
export type SalaryType = "Brut" | "Net avant PAS" | "Coût total employeur" | "Minimum conventionnel";
export interface RepresentationSalaryItem {
index: number; // Numéro de la représentation (1, 2, 3)
montant: number; // Montant en euros
}
export interface RepresentationSalaryDate {
date: string; // Format ISO "2025-11-15"
date_display: string; // Format d'affichage "15/11"
items: RepresentationSalaryItem[];
}
export interface RepetitionSalaryItem {
index: number; // Numéro du service de répétition
montant: number; // Montant en euros
duree?: 3 | 4; // Durée en heures (optionnel, pour info)
}
export interface RepetitionSalaryDate {
date: string; // Format ISO "2025-11-10"
date_display: string; // Format d'affichage "10/11"
items: RepetitionSalaryItem[];
}
export interface JourTravailSalary {
date: string; // Format ISO "2025-11-05"
date_display: string; // Format d'affichage "05/11"
montant: number; // Montant en euros
heures?: number; // Nombre d'heures (optionnel, pour info)
}
export interface SalaireParDate {
mode: SalaryMode;
type_salaire: SalaryType;
representations?: RepresentationSalaryDate[];
repetitions?: RepetitionSalaryDate[];
jours_travail?: JourTravailSalary[];
total_calcule?: number; // Total calculé automatiquement
}
// Helper pour créer une structure vide
export function createEmptySalaireParDate(
mode: SalaryMode,
type_salaire: SalaryType
): SalaireParDate {
return {
mode,
type_salaire,
representations: [],
repetitions: [],
jours_travail: [],
total_calcule: 0,
};
}
// Helper pour calculer le total
export function calculateTotalSalaire(data: SalaireParDate): number {
let total = 0;
// Représentations
if (data.representations) {
for (const dateGroup of data.representations) {
for (const item of dateGroup.items) {
total += item.montant || 0;
}
}
}
// Répétitions
if (data.repetitions) {
for (const dateGroup of data.repetitions) {
for (const item of dateGroup.items) {
total += item.montant || 0;
}
}
}
// Jours de travail
if (data.jours_travail) {
for (const jour of data.jours_travail) {
total += jour.montant || 0;
}
}
return total;
}