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:
parent
e6c7dc45cc
commit
31459f3c10
7 changed files with 648 additions and 153 deletions
|
|
@ -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)} />
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
56
migrations/add_salaires_par_date_column.sql
Normal file
56
migrations/add_salaires_par_date_column.sql
Normal 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
92
types/salaires.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue