feat: Ajouter interface de saisie en masse du temps de travail réel

- Création de la page /staff/contrats/saisie-temps-reel avec tableau éditable
- Ajout des colonnes jours_representations et jours_repetitions dans l'API
- Construction intelligente du TT Contractuel (concaténation des sources)
- Ajout de la colonne temps_reel_traite pour marquer les contrats traités
- Interface avec filtres (année, mois, organisation, recherche)
- Tri par date/salarié
- Édition inline avec auto-save via API
- Checkbox pour marquer comme traité (masque automatiquement la ligne)
- Toggle pour afficher/masquer les contrats traités
- Migration SQL pour la colonne temps_reel_traite
- Ajout du menu 'Temps de travail réel' dans la sidebar
- Logs de débogage pour le suivi des sauvegardes
This commit is contained in:
odentas 2025-11-28 12:31:02 +01:00
parent 7fae87353c
commit 965b1fb9cd
10 changed files with 1266 additions and 28 deletions

108
TEMPS_TRAVAIL_REEL.md Normal file
View file

@ -0,0 +1,108 @@
# Temps de Travail Réel - Fonctionnalité
## 📋 Contexte
Depuis la migration vers le nouvel Espace Paie, le système de saisie des heures et jours de travail a changé. Les données affichées dans la card "Temps de travail réel" sur la page client `contrats/[id]` n'étaient plus correctement alimentées.
## ✅ Solution Implémentée
### 1. Nouvelles Colonnes Supabase
Ajout de colonnes dédiées dans `cddu_contracts` pour stocker le temps de travail **réel** (distinct des données contractuelles) :
| Colonne | Type | Description |
|---------|------|-------------|
| `jours_travail_reel` | TEXT | Jours de travail réels (artiste) |
| `jours_travail_non_artiste_reel` | TEXT | Jours de travail réels (technicien) |
| `nb_representations_reel` | INTEGER | Nombre réel de représentations |
| `dates_representations_reel` | TEXT | Dates réelles des représentations |
| `nb_services_repetitions_reel` | INTEGER | Nombre réel de services de répétition |
| `nb_heures_repetitions_reel` | NUMERIC(10,2) | Heures réelles de répétition |
| `dates_repetitions_reel` | TEXT | Dates réelles des répétitions |
| `nb_heures_annexes_reel` | NUMERIC(10,2) | Heures réelles Annexes 8 |
| `nb_cachets_aem_reel` | INTEGER | Cachets AEM réels |
| `nb_heures_aem_reel` | NUMERIC(10,2) | Heures AEM réelles |
**Note importante** : Ces colonnes sont purement **informatives pour le client** et ne sont **pas utilisées** pour la génération du PDF du contrat via PDFMonkey.
### 2. Interface Staff (staff/contrats/[id])
Ajout d'une **sous-section "Temps de travail réel"** dans la card "Temps de travail" :
- **Emplacement** : Après le séparateur, en bas de la card "Temps de travail"
- **Indication visuelle** : Message clair indiquant que c'est informatif pour le client
- **Champs disponibles** :
- Jours travaillés (artiste)
- Jours travaillés (technicien)
- Nombre de représentations
- Dates de représentations
- Nombre de services répétitions
- Nombre d'heures répétitions
- Dates de répétitions
- Nombre d'heures Annexes 8
- Nombre de cachets AEM
- Nombre d'heures AEM
### 3. Affichage Client (contrats/[id])
La card "Temps de travail réel" affiche maintenant les données des colonnes `*_reel` :
```tsx
<Section title="Temps de travail réel" icon={Calendar}>
<Field label="Jours travaillés" value={data.jours_travail_reel} />
<Field label="Nbre de représentations" value={data.nb_representations_reel} />
// etc.
</Section>
```
### 4. API Mapping
L'API `/api/contrats/[id]` mappe automatiquement ces nouvelles colonnes :
```typescript
nb_representations_reel: cddu.nb_representations_reel ? Number(cddu.nb_representations_reel) : undefined,
nb_services_repetitions_reel: cddu.nb_services_repetitions_reel ? Number(cddu.nb_services_repetitions_reel) : undefined,
// etc.
```
## 🚀 Migration SQL
Exécuter le script de migration :
```bash
# Via Supabase Dashboard > SQL Editor
# Ou via CLI supabase
supabase db execute -f migrations/add_temps_travail_reel_columns.sql
```
## 📝 Utilisation
1. **Staff** : Aller sur `staff/contrats/[id]`
2. Descendre jusqu'à la card **"Temps de travail"**
3. Remplir la sous-section **"Temps de travail réel (informatif client)"**
4. Cliquer sur **Sauvegarder**
5. **Client** : Les données apparaissent immédiatement dans `contrats/[id]` > "Temps de travail réel"
## ⚠️ Points d'Attention
- ✅ Ces données sont **indépendantes** des données contractuelles (utilisées pour le PDF)
- ✅ Elles **ne sont pas envoyées** à PDFMonkey
- ✅ Elles sont **purement informatives** pour le client
- ✅ Les anciennes colonnes (`jours_travail`, `nb_representations`, etc.) restent utilisées pour le contrat PDF
## 📂 Fichiers Modifiés
| Fichier | Modifications |
|---------|---------------|
| `migrations/add_temps_travail_reel_columns.sql` | Création des colonnes |
| `components/staff/contracts/ContractEditor.tsx` | Ajout UI + états + sauvegarde |
| `app/api/contrats/[id]/route.ts` | Mapping API |
| `app/(app)/contrats/[id]/page.tsx` | Affichage client |
## ✅ Tests
- [ ] Exécuter la migration SQL sur Supabase
- [ ] Vérifier que les colonnes sont créées
- [ ] Tester la saisie dans staff/contrats/[id]
- [ ] Vérifier l'affichage dans contrats/[id]
- [ ] Vérifier que le PDF du contrat n'est pas impacté

View file

@ -115,7 +115,7 @@ function usePayslips(contractId: string) {
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/fetcher"; import { api } from "@/lib/fetcher";
import { Loader2, ArrowLeft, Check, Pencil, Download, Info, AlertTriangle, CheckCircle, Clock, Copy, PenTool, XCircle, Users, Send, FileText, CreditCard, Shield, Calendar, StickyNote, Euro } from "lucide-react"; import { Loader2, ArrowLeft, Check, Pencil, Download, Info, AlertTriangle, CheckCircle, Clock, Copy, PenTool, XCircle, Users, Send, FileText, CreditCard, Shield, Calendar, StickyNote, Euro, HelpCircle, Repeat, Timer, Wrench, Theater, TrendingUp } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { NotesSection } from "@/components/NotesSection"; import { NotesSection } from "@/components/NotesSection";
@ -1563,20 +1563,139 @@ return (
</Section> </Section>
<Section title="Temps de travail réel" icon={Calendar}> <Section title="Temps de travail réel" icon={Calendar}>
<Field <div className="bg-gradient-to-br from-blue-50 to-indigo-50 rounded-xl p-6 border border-blue-100">
label="Jours travaillés" <div className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-5">
value={ {/* Jours travaillés */}
data.categorie_prof === "Technicien" && data.jours_travail_non_artiste <div className="md:col-span-2 pb-4 border-b border-blue-200">
? data.jours_travail_non_artiste <div className="flex items-start gap-3">
: (data.jours_travailles ?? 0) <div className="mt-1 w-10 h-10 rounded-lg bg-blue-100 flex items-center justify-center flex-shrink-0">
} <Calendar className="w-5 h-5 text-blue-600" />
/> </div>
<Field label="Nbre de représentations" value={data.nb_representations ?? 0} /> <div className="flex-1">
<Field label="Nbre de services répétitions" value={data.nb_services_repetitions ?? 0} /> <div className="text-xs font-medium text-blue-600 uppercase tracking-wide mb-1">
<Field label="Nbre d'heures répétitions" value={data.nb_heures_repetitions ?? 0} /> Jours travaillés
<Field label="Nbre d'heures Annexes 8" value={data.nb_heures_annexes ?? 0} /> </div>
<Field label="Nombre de cachets AEM" value={data.nb_cachets_aem ?? 0} /> <div className="text-base font-semibold text-slate-900">
<Field label="Nombre d'heures AEM" value={data.nb_heures_aem ?? 0} /> {data.jours_travail_reel || "—"}
</div>
</div>
</div>
</div>
{/* Représentations */}
<div>
<div className="flex items-center gap-1.5 mb-2">
<div className="text-xs font-medium text-slate-500 uppercase tracking-wide">
Représentations
</div>
</div>
<div className="text-2xl font-bold text-slate-900">
{data.nb_representations_reel ?? 0}
</div>
<div className="text-xs text-slate-500 mt-1">cachets</div>
</div>
{/* Services répétitions */}
<div>
<div className="flex items-center gap-1.5 mb-2">
<div className="text-xs font-medium text-slate-500 uppercase tracking-wide">
Services répétitions
</div>
<div className="group relative">
<HelpCircle className="w-3.5 h-3.5 text-slate-400 cursor-help" />
<div className="absolute left-1/2 -translate-x-1/2 bottom-full mb-2 hidden group-hover:block w-64 bg-slate-900 text-white text-xs rounded-lg px-3 py-2 shadow-lg z-10">
Indique le nombre de services de répétitions réels, indépendamment du type de rémunération (au cachet ou à l'heure).
<div className="absolute left-1/2 -translate-x-1/2 top-full w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-slate-900"></div>
</div>
</div>
</div>
<div className="text-2xl font-bold text-slate-900">
{data.nb_services_repetitions_reel ?? 0}
</div>
<div className="text-xs text-slate-500 mt-1">services</div>
</div>
{/* Heures répétitions */}
<div>
<div className="flex items-center gap-1.5 mb-2">
<div className="text-xs font-medium text-slate-500 uppercase tracking-wide">
Heures répétitions
</div>
<div className="group relative">
<HelpCircle className="w-3.5 h-3.5 text-slate-400 cursor-help" />
<div className="absolute left-1/2 -translate-x-1/2 bottom-full mb-2 hidden group-hover:block w-64 bg-slate-900 text-white text-xs rounded-lg px-3 py-2 shadow-lg z-10">
Indique le nombre d'heures de répétitions réel, indépendamment du type de rémunération (au cachet ou à l'heure).
<div className="absolute left-1/2 -translate-x-1/2 top-full w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-slate-900"></div>
</div>
</div>
</div>
<div className="text-2xl font-bold text-slate-900">
{data.nb_heures_repetitions_reel ?? 0}
</div>
<div className="text-xs text-slate-500 mt-1">heures</div>
</div>
{/* Heures Annexe 8 */}
<div>
<div className="flex items-center gap-1.5 mb-2">
<div className="text-xs font-medium text-slate-500 uppercase tracking-wide">
Heures Annexe 8
</div>
<div className="group relative">
<HelpCircle className="w-3.5 h-3.5 text-slate-400 cursor-help" />
<div className="absolute left-1/2 -translate-x-1/2 bottom-full mb-2 hidden group-hover:block w-64 bg-slate-900 text-white text-xs rounded-lg px-3 py-2 shadow-lg z-10">
Concerne uniquement les techniciens (Annexe 8). Les techniciens ne peuvent être rémunérés qu'à l'heure.
<div className="absolute left-1/2 -translate-x-1/2 top-full w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-slate-900"></div>
</div>
</div>
</div>
<div className="text-2xl font-bold text-slate-900">
{data.nb_heures_annexes_reel ?? 0}
</div>
<div className="text-xs text-slate-500 mt-1">heures</div>
</div>
{/* Cachets AEM */}
<div>
<div className="flex items-center gap-1.5 mb-2">
<div className="text-xs font-medium text-slate-500 uppercase tracking-wide">
Cachets AEM
</div>
<div className="group relative">
<HelpCircle className="w-3.5 h-3.5 text-slate-400 cursor-help" />
<div className="absolute left-1/2 -translate-x-1/2 bottom-full mb-2 hidden group-hover:block w-64 bg-slate-900 text-white text-xs rounded-lg px-3 py-2 shadow-lg z-10">
Indique le nombre de cachets de représentation et/ou de répétitions (uniquement pour les répétitions pouvant être rémunérées au cachet) ayant é déclaré dans l'AEM.
<div className="absolute left-1/2 -translate-x-1/2 top-full w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-slate-900"></div>
</div>
</div>
</div>
<div className="text-2xl font-bold text-slate-900">
{data.nb_cachets_aem_reel ?? 0}
</div>
<div className="text-xs text-slate-500 mt-1">cachets</div>
</div>
{/* Heures AEM */}
<div>
<div className="flex items-center gap-1.5 mb-2">
<div className="text-xs font-medium text-slate-500 uppercase tracking-wide">
Heures AEM
</div>
<div className="group relative">
<HelpCircle className="w-3.5 h-3.5 text-slate-400 cursor-help" />
<div className="absolute left-1/2 -translate-x-1/2 bottom-full mb-2 hidden group-hover:block w-64 bg-slate-900 text-white text-xs rounded-lg px-3 py-2 shadow-lg z-10">
Indique les heures déclarées dans l'AEM (contrat technicien, metteur en scène ou répétitions ne pouvant être rémunérées en cachet).
<div className="absolute left-1/2 -translate-x-1/2 top-full w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-slate-900"></div>
</div>
</div>
</div>
<div className="text-2xl font-bold text-slate-900">
{data.nb_heures_aem_reel ?? 0}
</div>
<div className="text-xs text-slate-500 mt-1">heures</div>
</div>
</div>
</div>
</Section> </Section>
</div> </div>
</div> </div>

View file

@ -0,0 +1,619 @@
"use client";
import { useState, useEffect } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { supabase } from "@/lib/supabaseClient";
import { api } from "@/lib/fetcher";
import { ArrowLeft, Search, Filter, ChevronDown, Save, CheckCircle2 } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { toast } from "sonner";
type Contract = {
id: string;
production_name: string;
employee_name: string;
profession: string;
start_date: string;
end_date: string;
org_id: string;
organization_name?: string;
// Temps de travail contractuel (référence)
jours_travail: string | null;
jours_travail_non_artiste: string | null;
jours_representations: string | null;
jours_repetitions: string | null;
cachets_representations: number | null;
services_repetitions: number | null;
nombre_d_heures: number | null;
precisions_salaire: string | null;
// Temps de travail réel (éditable)
jours_travail_reel: string | null;
nb_representations_reel: number | null;
nb_services_repetitions_reel: number | null;
nb_heures_repetitions_reel: number | null;
nb_heures_annexes_reel: number | null;
nb_cachets_aem_reel: number | null;
nb_heures_aem_reel: number | null;
temps_reel_traite: boolean | null;
};
export default function SaisieTempsReelPage() {
const queryClient = useQueryClient();
// Filtres
const [yearFilter, setYearFilter] = useState<string>("all");
const [monthFilter, setMonthFilter] = useState<string>("all");
const [orgFilter, setOrgFilter] = useState<string>("all");
const [searchQuery, setSearchQuery] = useState("");
const [sortBy, setSortBy] = useState<"production" | "employee" | "date">("date");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
const [showTraites, setShowTraites] = useState(false);
// États d'édition locale
const [editedCells, setEditedCells] = useState<Record<string, any>>({});
const [savingCells, setSavingCells] = useState<Set<string>>(new Set());
// Récupération des contrats
const { data: contracts = [], isLoading, error } = useQuery<Contract[]>({
queryKey: ["contracts-temps-reel", yearFilter, monthFilter, orgFilter],
queryFn: async () => {
const params = new URLSearchParams();
if (yearFilter !== "all") params.set("year", yearFilter);
if (monthFilter !== "all") params.set("month", monthFilter);
if (orgFilter !== "all") params.set("org", orgFilter);
const url = `/staff/contrats/temps-reel?${params.toString()}`;
const data = await api(url) as Contract[];
console.log("✅ Contrats récupérés:", data?.length || 0);
return data || [];
},
staleTime: 30_000,
});
console.log("📊 État query:", { isLoading, error, contractsCount: contracts.length });
// Récupération des organisations pour le filtre
const { data: organizations = [] } = useQuery<Array<{id: string; name: string}>>({
queryKey: ["organizations-list"],
queryFn: async () => {
const response = await api("/staff/organizations") as { organizations: Array<{id: string; name: string}> };
return response.organizations || [];
},
});
// Mutation pour sauvegarder une cellule
const saveMutation = useMutation({
mutationFn: async ({ contractId, field, value }: { contractId: string; field: string; value: any }) => {
console.log("[saveMutation] Début sauvegarde:", { contractId, field, value });
const response = await fetch("/api/staff/contrats/temps-reel", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contractId, field, value }),
});
if (!response.ok) {
const error = await response.json();
console.error("[saveMutation] Erreur réponse:", error);
throw new Error(error.error || "Erreur de sauvegarde");
}
const data = await response.json();
console.log("[saveMutation] Succès:", data);
return { contractId, field, value };
},
onMutate: ({ contractId, field }) => {
console.log("[saveMutation] onMutate:", { contractId, field });
setSavingCells((prev) => new Set(prev).add(`${contractId}-${field}`));
},
onSuccess: ({ contractId, field, value }) => {
console.log("[saveMutation] onSuccess:", { contractId, field, value });
toast.success("Sauvegardé");
setSavingCells((prev) => {
const next = new Set(prev);
next.delete(`${contractId}-${field}`);
return next;
});
queryClient.invalidateQueries({ queryKey: ["contracts-temps-reel"] });
},
onError: (error, { contractId, field }) => {
console.error("[saveMutation] onError:", error, { contractId, field });
toast.error(`Erreur: ${error.message}`);
setSavingCells((prev) => {
const next = new Set(prev);
next.delete(`${contractId}-${field}`);
return next;
});
},
});
// Fonction pour gérer le changement d'une cellule
const handleCellChange = (contractId: string, field: string, value: any) => {
const key = `${contractId}-${field}`;
console.log("[handleCellChange]", { contractId, field, value, key });
setEditedCells((prev) => ({ ...prev, [key]: value }));
};
// Fonction pour sauvegarder une cellule (au blur ou Enter)
const handleCellSave = (contractId: string, field: string) => {
const key = `${contractId}-${field}`;
const value = editedCells[key];
console.log("[handleCellSave]", { contractId, field, key, value, editedCells });
if (value !== undefined) {
saveMutation.mutate({ contractId, field, value });
setEditedCells((prev) => {
const next = { ...prev };
delete next[key];
return next;
});
} else {
console.log("[handleCellSave] Pas de valeur à sauvegarder");
}
};
// Filtrer et trier les contrats
const filteredContracts = contracts
.filter((contract) => {
// Filtre par recherche
if (searchQuery) {
const query = searchQuery.toLowerCase();
const matches = (
contract.production_name?.toLowerCase().includes(query) ||
contract.employee_name?.toLowerCase().includes(query) ||
contract.profession?.toLowerCase().includes(query)
);
if (!matches) return false;
}
// Filtre par statut traité (masquer les traités par défaut)
if (!showTraites && contract.temps_reel_traite) return false;
return true;
})
.sort((a, b) => {
let aVal, bVal;
if (sortBy === "production") {
aVal = a.production_name || "";
bVal = b.production_name || "";
} else if (sortBy === "employee") {
aVal = a.employee_name || "";
bVal = b.employee_name || "";
} else {
aVal = a.start_date || "";
bVal = b.start_date || "";
}
if (sortOrder === "asc") {
return aVal > bVal ? 1 : -1;
} else {
return aVal < bVal ? 1 : -1;
}
});
// Générer les années disponibles (5 dernières années)
const currentYear = new Date().getFullYear();
const years = Array.from({ length: 5 }, (_, i) => currentYear - i);
return (
<div className="min-h-screen bg-slate-50 p-6">
<div className="max-w-[1800px] mx-auto">
{/* Header */}
<div className="mb-6">
<Link href="/staff/contrats">
<Button variant="ghost" size="sm" className="mb-4">
<ArrowLeft className="w-4 h-4 mr-2" />
Retour aux contrats
</Button>
</Link>
<h1 className="text-3xl font-bold text-slate-900">Saisie Temps de Travail Réel</h1>
<p className="text-slate-600 mt-2">
Saisissez rapidement les données réelles pour alimenter la vue client. Cliquez sur une cellule pour modifier, les modifications sont sauvegardées automatiquement.
</p>
</div>
{/* Filtres */}
<Card className="mb-6">
<CardContent className="pt-6">
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
{/* Recherche */}
<div className="md:col-span-2">
<label className="text-xs font-medium text-slate-600 mb-2 block">Rechercher</label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
placeholder="Production, salarié, profession..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
</div>
{/* Année */}
<div>
<label className="text-xs font-medium text-slate-600 mb-2 block">Année</label>
<select
value={yearFilter}
onChange={(e) => setYearFilter(e.target.value)}
className="w-full px-3 py-2 border rounded-lg text-sm"
>
<option value="all">Toutes</option>
{years.map((year) => (
<option key={year} value={year}>
{year}
</option>
))}
</select>
</div>
{/* Mois */}
<div>
<label className="text-xs font-medium text-slate-600 mb-2 block">Mois</label>
<select
value={monthFilter}
onChange={(e) => setMonthFilter(e.target.value)}
className="w-full px-3 py-2 border rounded-lg text-sm"
disabled={yearFilter === "all"}
>
<option value="all">Tous</option>
<option value="1">Janvier</option>
<option value="2">Février</option>
<option value="3">Mars</option>
<option value="4">Avril</option>
<option value="5">Mai</option>
<option value="6">Juin</option>
<option value="7">Juillet</option>
<option value="8">Août</option>
<option value="9">Septembre</option>
<option value="10">Octobre</option>
<option value="11">Novembre</option>
<option value="12">Décembre</option>
</select>
</div>
{/* Organisation */}
<div>
<label className="text-xs font-medium text-slate-600 mb-2 block">Organisation</label>
<select
value={orgFilter}
onChange={(e) => setOrgFilter(e.target.value)}
className="w-full px-3 py-2 border rounded-lg text-sm"
>
<option value="all">Toutes</option>
{organizations?.map((org: any) => (
<option key={org.id} value={org.id}>
{org.name}
</option>
))}
</select>
</div>
</div>
{/* Tri et affichage des traités */}
<div className="flex items-center justify-between mt-4">
<div className="flex items-center gap-4">
<span className="text-xs font-medium text-slate-600">Trier par :</span>
<div className="flex gap-2">
<Button
variant={sortBy === "date" ? "default" : "outline"}
size="sm"
onClick={() => {
if (sortBy === "date") {
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else {
setSortBy("date");
setSortOrder("desc");
}
}}
>
Date {sortBy === "date" && (sortOrder === "asc" ? "↑" : "↓")}
</Button>
<Button
variant={sortBy === "employee" ? "default" : "outline"}
size="sm"
onClick={() => {
if (sortBy === "employee") {
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else {
setSortBy("employee");
setSortOrder("asc");
}
}}
>
Salarié {sortBy === "employee" && (sortOrder === "asc" ? "↑" : "↓")}
</Button>
</div>
</div>
{/* Toggle pour afficher les traités */}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={showTraites}
onChange={(e) => setShowTraites(e.target.checked)}
className="w-4 h-4 rounded border-slate-300"
/>
<span className="text-xs text-slate-600">Afficher les contrats traités</span>
</label>
</div>
</CardContent>
</Card>
{/* Tableau */}
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-slate-100 border-b sticky top-0 z-10">
<tr>
<th className="px-3 py-3 text-left font-semibold text-slate-700 w-32">Salarié</th>
<th className="px-3 py-3 text-left font-semibold text-slate-700 w-32">Profession</th>
<th className="px-3 py-3 text-left font-semibold text-slate-700 w-24">Dates</th>
<th className="px-3 py-3 text-left font-semibold text-slate-500 w-48 bg-slate-50">TT Contractuel</th>
<th className="px-3 py-3 text-center font-semibold text-slate-500 w-20 bg-slate-50">Cach.</th>
<th className="px-3 py-3 text-center font-semibold text-slate-500 w-20 bg-slate-50">Serv.</th>
<th className="px-3 py-3 text-center font-semibold text-slate-500 w-20 bg-slate-50">Heures</th>
<th className="px-3 py-3 text-left font-semibold text-slate-500 w-56 bg-slate-50">Précisions</th>
<th className="px-3 py-3 text-left font-semibold text-blue-700 w-32 bg-blue-50">Jours réels</th>
<th className="px-3 py-3 text-center font-semibold text-blue-700 w-24 bg-blue-50">Repr.</th>
<th className="px-3 py-3 text-center font-semibold text-blue-700 w-24 bg-blue-50">Serv. rép.</th>
<th className="px-3 py-3 text-center font-semibold text-blue-700 w-24 bg-blue-50">H. rép.</th>
<th className="px-3 py-3 text-center font-semibold text-blue-700 w-24 bg-blue-50">H. Ann.8</th>
<th className="px-3 py-3 text-center font-semibold text-blue-700 w-24 bg-blue-50">Cach. AEM</th>
<th className="px-3 py-3 text-center font-semibold text-blue-700 w-24 bg-blue-50">H. AEM</th>
<th className="px-3 py-3 text-center font-semibold text-green-700 w-20 bg-green-50">Traité</th>
</tr>
</thead>
<tbody>
{isLoading ? (
<tr>
<td colSpan={17} className="px-3 py-12 text-center text-slate-400">
Chargement des contrats...
</td>
</tr>
) : error ? (
<tr>
<td colSpan={17} className="px-3 py-12 text-center">
<div className="text-red-600 font-medium">Erreur de chargement</div>
<div className="text-sm text-slate-500 mt-2">{String(error)}</div>
</td>
</tr>
) : filteredContracts.length === 0 ? (
<tr>
<td colSpan={17} className="px-3 py-12 text-center">
<div className="text-slate-400 mb-2">Aucun contrat trouvé avec ces filtres.</div>
<div className="text-xs text-slate-400">Total dans la base : {contracts.length}</div>
</td>
</tr>
) : (
filteredContracts.map((contract) => (
<tr key={contract.id} className="border-b hover:bg-slate-50">
{/* Salarié */}
<td className="px-3 py-2 text-slate-600 text-xs">
<div className="truncate" title={contract.employee_name}>
{contract.employee_name || "—"}
</div>
</td>
{/* Profession */}
<td className="px-3 py-2 text-slate-600 text-xs">
{contract.profession || "—"}
</td>
{/* Dates */}
<td className="px-3 py-2 text-slate-600 text-xs">
{contract.start_date ? new Date(contract.start_date).toLocaleDateString("fr-FR") : "—"}
</td>
{/* TT Contractuel (référence) */}
<td className="px-3 py-2 text-slate-500 text-xs bg-slate-50 max-w-[12rem]">
<div className="whitespace-normal break-words">
{(() => {
const parts = [];
if (contract.jours_travail_non_artiste) parts.push(contract.jours_travail_non_artiste);
if (contract.jours_representations) parts.push(contract.jours_representations);
if (contract.jours_repetitions) parts.push(contract.jours_repetitions);
if (contract.jours_travail && !contract.jours_representations && !contract.jours_repetitions) parts.push(contract.jours_travail);
return parts.length > 0 ? parts.join(" + ") : "—";
})()}
</div>
</td>
{/* Cachets représentations (PDFMonkey) */}
<td className="px-3 py-2 text-center text-slate-500 text-xs bg-slate-50">
{contract.cachets_representations ?? "—"}
</td>
{/* Services répétitions (PDFMonkey) */}
<td className="px-3 py-2 text-center text-slate-500 text-xs bg-slate-50">
{contract.services_repetitions ?? "—"}
</td>
{/* Nombre d'heures (PDFMonkey) */}
<td className="px-3 py-2 text-center text-slate-500 text-xs bg-slate-50">
{contract.nombre_d_heures ?? "—"}
</td>
{/* Précisions salaire (référence) */}
<td className="px-3 py-2 text-slate-500 text-xs bg-slate-50 max-w-[14rem]">
<div className="whitespace-normal break-words">
{contract.precisions_salaire || "—"}
</div>
</td>
{/* Jours travaillés réel */}
<td className="px-3 py-2 bg-blue-50">
<EditableCell
contractId={contract.id}
field="jours_travail_reel"
value={contract.jours_travail_reel}
editedValue={editedCells[`${contract.id}-jours_travail_reel`]}
onChange={handleCellChange}
onSave={handleCellSave}
isSaving={savingCells.has(`${contract.id}-jours_travail_reel`)}
type="text"
/>
</td>
{/* Nb représentations réel */}
<td className="px-3 py-2 bg-blue-50">
<EditableCell
contractId={contract.id}
field="nb_representations_reel"
value={contract.nb_representations_reel}
editedValue={editedCells[`${contract.id}-nb_representations_reel`]}
onChange={handleCellChange}
onSave={handleCellSave}
isSaving={savingCells.has(`${contract.id}-nb_representations_reel`)}
type="number"
/>
</td>
{/* Nb services répétitions réel */}
<td className="px-3 py-2 bg-blue-50">
<EditableCell
contractId={contract.id}
field="nb_services_repetitions_reel"
value={contract.nb_services_repetitions_reel}
editedValue={editedCells[`${contract.id}-nb_services_repetitions_reel`]}
onChange={handleCellChange}
onSave={handleCellSave}
isSaving={savingCells.has(`${contract.id}-nb_services_repetitions_reel`)}
type="number"
/>
</td>
{/* Nb heures répétitions réel */}
<td className="px-3 py-2 bg-blue-50">
<EditableCell
contractId={contract.id}
field="nb_heures_repetitions_reel"
value={contract.nb_heures_repetitions_reel}
editedValue={editedCells[`${contract.id}-nb_heures_repetitions_reel`]}
onChange={handleCellChange}
onSave={handleCellSave}
isSaving={savingCells.has(`${contract.id}-nb_heures_repetitions_reel`)}
type="number"
/>
</td>
{/* Nb heures Annexe 8 réel */}
<td className="px-3 py-2 bg-blue-50">
<EditableCell
contractId={contract.id}
field="nb_heures_annexes_reel"
value={contract.nb_heures_annexes_reel}
editedValue={editedCells[`${contract.id}-nb_heures_annexes_reel`]}
onChange={handleCellChange}
onSave={handleCellSave}
isSaving={savingCells.has(`${contract.id}-nb_heures_annexes_reel`)}
type="number"
/>
</td>
{/* Nb cachets AEM réel */}
<td className="px-3 py-2 bg-blue-50">
<EditableCell
contractId={contract.id}
field="nb_cachets_aem_reel"
value={contract.nb_cachets_aem_reel}
editedValue={editedCells[`${contract.id}-nb_cachets_aem_reel`]}
onChange={handleCellChange}
onSave={handleCellSave}
isSaving={savingCells.has(`${contract.id}-nb_cachets_aem_reel`)}
type="number"
/>
</td>
{/* Nb heures AEM réel */}
<td className="px-3 py-2 bg-blue-50">
<EditableCell
contractId={contract.id}
field="nb_heures_aem_reel"
value={contract.nb_heures_aem_reel}
editedValue={editedCells[`${contract.id}-nb_heures_aem_reel`]}
onChange={handleCellChange}
onSave={handleCellSave}
isSaving={savingCells.has(`${contract.id}-nb_heures_aem_reel`)}
type="number"
/>
</td>
{/* Checkbox traité */}
<td className="px-3 py-2 bg-green-50 text-center">
<input
type="checkbox"
checked={contract.temps_reel_traite || false}
onChange={(e) => {
saveMutation.mutate({
contractId: contract.id,
field: "temps_reel_traite",
value: e.target.checked,
});
}}
className="w-4 h-4 rounded border-slate-300 cursor-pointer"
title="Marquer comme traité"
/>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</CardContent>
</Card>
{/* Statistiques */}
{filteredContracts.length > 0 && (
<div className="mt-4 text-sm text-slate-600">
{filteredContracts.length} contrat{filteredContracts.length > 1 ? "s" : ""} affiché{filteredContracts.length > 1 ? "s" : ""}
</div>
)}
</div>
</div>
);
}
// Composant pour cellule éditable
function EditableCell({
contractId,
field,
value,
editedValue,
onChange,
onSave,
isSaving,
type,
}: {
contractId: string;
field: string;
value: string | number | null;
editedValue: any;
onChange: (contractId: string, field: string, value: any) => void;
onSave: (contractId: string, field: string) => void;
isSaving: boolean;
type: "text" | "number";
}) {
const displayValue = editedValue !== undefined ? editedValue : (value ?? "");
const isEmpty = !value && editedValue === undefined;
return (
<div className="relative">
<input
type={type}
value={displayValue}
onChange={(e) => {
const val = type === "number" ? (e.target.value === "" ? null : Number(e.target.value)) : e.target.value;
onChange(contractId, field, val);
}}
onBlur={() => onSave(contractId, field)}
onKeyDown={(e) => {
if (e.key === "Enter") {
onSave(contractId, field);
e.currentTarget.blur();
}
}}
className={`w-full px-2 py-1 text-sm border rounded focus:outline-none focus:ring-2 focus:ring-blue-500 ${
isEmpty ? "border-red-200 bg-red-50" : "border-slate-200"
} ${isSaving ? "opacity-50" : ""}`}
placeholder={type === "number" ? "0" : "—"}
disabled={isSaving}
/>
{isSaving && (
<div className="absolute right-2 top-1/2 -translate-y-1/2">
<div className="w-3 h-3 border-2 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
</div>
)}
</div>
);
}

View file

@ -182,6 +182,17 @@ export async function GET(req: NextRequest, { params }: { params: { id: string }
nb_heures_annexes: cddu.heures_annexe_8 ? Number(cddu.heures_annexe_8) : undefined, nb_heures_annexes: cddu.heures_annexe_8 ? Number(cddu.heures_annexe_8) : undefined,
nb_cachets_aem: cddu.cachets ? Number(cddu.cachets) : undefined, nb_cachets_aem: cddu.cachets ? Number(cddu.cachets) : undefined,
nb_heures_aem: cddu.nombre_d_heures ? Number(cddu.nombre_d_heures) : undefined, nb_heures_aem: cddu.nombre_d_heures ? Number(cddu.nombre_d_heures) : undefined,
// Temps de travail réel (nouveaux champs informatifs)
jours_travail_reel: cddu.jours_travail_reel || undefined,
jours_travail_non_artiste_reel: cddu.jours_travail_non_artiste_reel || undefined,
nb_representations_reel: cddu.nb_representations_reel ? Number(cddu.nb_representations_reel) : undefined,
dates_representations_reel: cddu.dates_representations_reel || undefined,
nb_services_repetitions_reel: cddu.nb_services_repetitions_reel ? Number(cddu.nb_services_repetitions_reel) : undefined,
nb_heures_repetitions_reel: cddu.nb_heures_repetitions_reel ? Number(cddu.nb_heures_repetitions_reel) : undefined,
dates_repetitions_reel: cddu.dates_repetitions_reel || undefined,
nb_heures_annexes_reel: cddu.nb_heures_annexes_reel ? Number(cddu.nb_heures_annexes_reel) : undefined,
nb_cachets_aem_reel: cddu.nb_cachets_aem_reel ? Number(cddu.nb_cachets_aem_reel) : undefined,
nb_heures_aem_reel: cddu.nb_heures_aem_reel ? Number(cddu.nb_heures_aem_reel) : undefined,
created_at: cddu.created_at, created_at: cddu.created_at,
updated_at: cddu.updated_at, updated_at: cddu.updated_at,
}; };
@ -353,6 +364,28 @@ export async function PATCH(req: NextRequest, { params }: { params: { id: string
if (requestBody.jours_travail_non_artiste !== undefined) { if (requestBody.jours_travail_non_artiste !== undefined) {
supabaseData.jours_travail_non_artiste = requestBody.jours_travail_non_artiste; supabaseData.jours_travail_non_artiste = requestBody.jours_travail_non_artiste;
} }
// Temps de travail réel (informatif pour le client)
if (requestBody.jours_travail_reel !== undefined) {
supabaseData.jours_travail_reel = requestBody.jours_travail_reel;
}
if (requestBody.nb_representations_reel !== undefined) {
supabaseData.nb_representations_reel = requestBody.nb_representations_reel;
}
if (requestBody.nb_services_repetitions_reel !== undefined) {
supabaseData.nb_services_repetitions_reel = requestBody.nb_services_repetitions_reel;
}
if (requestBody.nb_heures_repetitions_reel !== undefined) {
supabaseData.nb_heures_repetitions_reel = requestBody.nb_heures_repetitions_reel;
}
if (requestBody.nb_heures_annexes_reel !== undefined) {
supabaseData.nb_heures_annexes_reel = requestBody.nb_heures_annexes_reel;
}
if (requestBody.nb_cachets_aem_reel !== undefined) {
supabaseData.nb_cachets_aem_reel = requestBody.nb_cachets_aem_reel;
}
if (requestBody.nb_heures_aem_reel !== undefined) {
supabaseData.nb_heures_aem_reel = requestBody.nb_heures_aem_reel;
}
if (requestBody.type_salaire !== undefined) { if (requestBody.type_salaire !== undefined) {
supabaseData.type_salaire = requestBody.type_salaire; supabaseData.type_salaire = requestBody.type_salaire;
} }
@ -549,7 +582,13 @@ export async function PATCH(req: NextRequest, { params }: { params: { id: string
'cachets_representations', 'services_repetitions', 'jours_representations', 'cachets_representations', 'services_repetitions', 'jours_representations',
'jours_repetitions', 'nombre_d_heures', 'jours_travail', 'jours_repetitions', 'nombre_d_heures', 'jours_travail',
'jours_travail_non_artiste', 'notes', 'jours_travail_non_artiste', 'notes',
'dates_travaillees', 'type_salaire', 'montant', 'panier_repas' 'dates_travaillees', 'type_salaire', 'montant', 'panier_repas',
// Temps de travail réel
'jours_travail_reel',
'nb_representations_reel',
'nb_services_repetitions_reel', 'nb_heures_repetitions_reel',
'nb_heures_annexes_reel',
'nb_cachets_aem_reel', 'nb_heures_aem_reel'
]; ];
fieldsToSync.forEach(field => { fieldsToSync.forEach(field => {

View file

@ -65,7 +65,13 @@ const CONTRACT_UPDATABLE_FIELDS = new Set([
"si_non_montant_par_indemnite", "mineur_entre_16_et_18", "civilite_representant_legal", "si_non_montant_par_indemnite", "mineur_entre_16_et_18", "civilite_representant_legal",
"nom_representant_legal", "adresse_representant_legal", "dob_representant_legal", "nom_representant_legal", "adresse_representant_legal", "dob_representant_legal",
"cob_representant_legal", "periode", "docuseal_template_id", "docuseal_submission_id", "cob_representant_legal", "periode", "docuseal_template_id", "docuseal_submission_id",
"motif_cdd", "contract_kind", "date_signature" "motif_cdd", "contract_kind", "date_signature",
// Temps de travail réel (informatif pour le client)
"jours_travail_reel",
"nb_representations_reel",
"nb_services_repetitions_reel", "nb_heures_repetitions_reel",
"nb_heures_annexes_reel",
"nb_cachets_aem_reel", "nb_heures_aem_reel"
]); ]);
export async function GET(_req: Request, { params }: { params: { id: string } }) { export async function GET(_req: Request, { params }: { params: { id: string } }) {

View file

@ -0,0 +1,166 @@
import { NextResponse } from "next/server";
import { createSbServer } from "@/lib/supabaseServer";
export async function GET(req: Request) {
const supabase = createSbServer();
// Vérifier l'authentification
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
// Vérifier que l'utilisateur est staff
const { data: staffUser } = await supabase
.from("staff_users")
.select("is_staff")
.eq("user_id", user.id)
.maybeSingle();
if (!staffUser?.is_staff) {
return NextResponse.json({ error: "Accès réservé au staff" }, { status: 403 });
}
// Récupérer les paramètres de filtrage depuis l'URL
const { searchParams } = new URL(req.url);
const yearFilter = searchParams.get("year");
const monthFilter = searchParams.get("month");
const orgFilter = searchParams.get("org");
try {
let query = supabase
.from("cddu_contracts")
.select(`
id,
production_name,
employee_name,
profession,
start_date,
end_date,
org_id,
jours_travail,
jours_travail_non_artiste,
jours_representations,
jours_repetitions,
cachets_representations,
services_repetitions,
nombre_d_heures,
precisions_salaire,
jours_travail_reel,
nb_representations_reel,
nb_services_repetitions_reel,
nb_heures_repetitions_reel,
nb_heures_annexes_reel,
nb_cachets_aem_reel,
nb_heures_aem_reel,
temps_reel_traite,
organizations(name)
`);
// Filtre par année
if (yearFilter && yearFilter !== "all") {
const yearStart = `${yearFilter}-01-01`;
const yearEnd = `${yearFilter}-12-31`;
query = query.gte("start_date", yearStart).lte("start_date", yearEnd);
}
// Filtre par mois
if (monthFilter && monthFilter !== "all" && yearFilter && yearFilter !== "all") {
const monthPadded = monthFilter.padStart(2, "0");
const monthStart = `${yearFilter}-${monthPadded}-01`;
const nextMonth = parseInt(monthFilter) === 12 ? 1 : parseInt(monthFilter) + 1;
const nextYear = parseInt(monthFilter) === 12 ? parseInt(yearFilter) + 1 : parseInt(yearFilter);
const monthEnd = `${nextYear}-${String(nextMonth).padStart(2, "0")}-01`;
query = query.gte("start_date", monthStart).lt("start_date", monthEnd);
}
// Filtre par organisation
if (orgFilter && orgFilter !== "all") {
query = query.eq("org_id", orgFilter);
}
const { data, error } = await query.order("start_date", { ascending: false });
if (error) {
console.error("Erreur récupération contrats:", error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
// Ajouter le nom de l'organisation
const contracts = (data || []).map((contract: any) => ({
...contract,
organization_name: contract.organizations?.name || "—",
}));
return NextResponse.json(contracts);
} catch (error: any) {
console.error("Erreur serveur:", error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export async function PATCH(req: Request) {
const supabase = createSbServer();
// Vérifier l'authentification
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
// Vérifier que l'utilisateur est staff
const { data: staffUser } = await supabase
.from("staff_users")
.select("is_staff")
.eq("user_id", user.id)
.maybeSingle();
if (!staffUser?.is_staff) {
return NextResponse.json({ error: "Accès réservé au staff" }, { status: 403 });
}
try {
const body = await req.json();
const { contractId, field, value } = body;
console.log("[PATCH temps-reel] Mise à jour:", { contractId, field, value });
if (!contractId || !field) {
return NextResponse.json({ error: "contractId et field requis" }, { status: 400 });
}
// Liste des champs autorisés pour la mise à jour
const allowedFields = [
"jours_travail_reel",
"nb_representations_reel",
"nb_services_repetitions_reel",
"nb_heures_repetitions_reel",
"nb_heures_annexes_reel",
"nb_cachets_aem_reel",
"nb_heures_aem_reel",
"temps_reel_traite",
];
if (!allowedFields.includes(field)) {
return NextResponse.json({ error: "Champ non autorisé" }, { status: 400 });
}
const { data, error } = await supabase
.from("cddu_contracts")
.update({ [field]: value })
.eq("id", contractId)
.select()
.single();
if (error) {
console.error("[PATCH temps-reel] Erreur:", error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
console.log("[PATCH temps-reel] Succès:", data);
return NextResponse.json({ success: true, data });
} catch (error: any) {
console.error("[PATCH temps-reel] Erreur serveur:", error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View file

@ -2,7 +2,7 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { Shield, KeyRound, Home, FileSignature, Banknote, Users, Percent, FolderOpen, Receipt, Building2, UserCog, Edit3, LifeBuoy, CreditCard, Calculator, Euro, Mail, Database, Clapperboard, LogOut, Scale, FileEdit, FileText, Megaphone, Gift } from "lucide-react"; import { Shield, KeyRound, Home, FileSignature, Banknote, Users, Percent, FolderOpen, Receipt, Building2, UserCog, Edit3, LifeBuoy, CreditCard, Calculator, Euro, Mail, Database, Clapperboard, LogOut, Scale, FileEdit, FileText, Megaphone, Gift, Clock } from "lucide-react";
// import { api } from "@/lib/fetcher"; // import { api } from "@/lib/fetcher";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import LogoutButton from "@/components/LogoutButton"; import LogoutButton from "@/components/LogoutButton";
@ -558,6 +558,14 @@ export default function Sidebar({ clientInfo, isStaff = false, mobile = false, o
<span>Contrats</span> <span>Contrats</span>
</span> </span>
</Link> </Link>
<Link href="/staff/contrats/saisie-temps-reel" onClick={() => onNavigate && onNavigate()} className={`block px-3 py-2 rounded-xl text-sm transition truncate ${
isActivePath(pathname, "/staff/contrats/saisie-temps-reel") ? "bg-gradient-to-r from-indigo-200 via-purple-200 to-pink-200 text-slate-700 font-semibold" : "hover:bg-slate-50"
}`} title="Saisie temps de travail réel">
<span className="inline-flex items-center gap-2">
<Clock className="w-4 h-4" aria-hidden />
<span>Temps de travail réel</span>
</span>
</Link>
<Link href="/staff/payslips" onClick={() => onNavigate && onNavigate()} className={`block px-3 py-2 rounded-xl text-sm transition truncate ${ <Link href="/staff/payslips" onClick={() => onNavigate && onNavigate()} className={`block px-3 py-2 rounded-xl text-sm transition truncate ${
isActivePath(pathname, "/staff/payslips") ? "bg-gradient-to-r from-indigo-200 via-purple-200 to-pink-200 text-slate-700 font-semibold" : "hover:bg-slate-50" isActivePath(pathname, "/staff/payslips") ? "bg-gradient-to-r from-indigo-200 via-purple-200 to-pink-200 text-slate-700 font-semibold" : "hover:bg-slate-50"
}`} title="Gestion des fiches de paie"> }`} title="Gestion des fiches de paie">

View file

@ -327,6 +327,18 @@ export default function ContractEditor({
const [nombreHeuresTotal, setNombreHeuresTotal] = useState<number | "">(contract.nombre_d_heures || ""); const [nombreHeuresTotal, setNombreHeuresTotal] = useState<number | "">(contract.nombre_d_heures || "");
const [nombreHeuresParJour, setNombreHeuresParJour] = useState<number | "">(contract.nombre_d_heures_par_jour || ""); const [nombreHeuresParJour, setNombreHeuresParJour] = useState<number | "">(contract.nombre_d_heures_par_jour || "");
// États pour le temps de travail réel (informatif pour le client)
const [joursTravailReel, setJoursTravailReel] = useState(contract.jours_travail_reel || "");
const [joursTravailNonArtisteReel, setJoursTravailNonArtisteReel] = useState(contract.jours_travail_non_artiste_reel || "");
const [nbRepresentationsReel, setNbRepresentationsReel] = useState<number | "">(contract.nb_representations_reel || "");
const [datesRepresentationsReel, setDatesRepresentationsReel] = useState(contract.dates_representations_reel || "");
const [nbServicesRepetitionsReel, setNbServicesRepetitionsReel] = useState<number | "">(contract.nb_services_repetitions_reel || "");
const [nbHeuresRepetitionsReel, setNbHeuresRepetitionsReel] = useState<number | "">(contract.nb_heures_repetitions_reel || "");
const [datesRepetitionsReel, setDatesRepetitionsReel] = useState(contract.dates_repetitions_reel || "");
const [nbHeuresAnnexesReel, setNbHeuresAnnexesReel] = useState<number | "">(contract.nb_heures_annexes_reel || "");
const [nbCachetsAemReel, setNbCachetsAemReel] = useState<number | "">(contract.nb_cachets_aem_reel || "");
const [nbHeuresAemReel, setNbHeuresAemReel] = useState<number | "">(contract.nb_heures_aem_reel || "");
// Synchroniser les états avec les données du contrat // Synchroniser les états avec les données du contrat
useEffect(() => { useEffect(() => {
// Calculer le mode (heures vs représentations/répétitions) // Calculer le mode (heures vs représentations/répétitions)
@ -508,6 +520,18 @@ export default function ContractEditor({
setPrecisionsSalaire(contract.precisions_salaire || ""); setPrecisionsSalaire(contract.precisions_salaire || "");
setAutrePrecisionDuree(contract.autreprecision_duree || ""); setAutrePrecisionDuree(contract.autreprecision_duree || "");
setAutrePrecisionSalaire(contract.autreprecision_salaire || ""); setAutrePrecisionSalaire(contract.autreprecision_salaire || "");
// Synchroniser les champs de temps de travail réel
setJoursTravailReel(contract.jours_travail_reel || "");
setJoursTravailNonArtisteReel(contract.jours_travail_non_artiste_reel || "");
setNbRepresentationsReel(contract.nb_representations_reel || "");
setDatesRepresentationsReel(contract.dates_representations_reel || "");
setNbServicesRepetitionsReel(contract.nb_services_repetitions_reel || "");
setNbHeuresRepetitionsReel(contract.nb_heures_repetitions_reel || "");
setDatesRepetitionsReel(contract.dates_repetitions_reel || "");
setNbHeuresAnnexesReel(contract.nb_heures_annexes_reel || "");
setNbCachetsAemReel(contract.nb_cachets_aem_reel || "");
setNbHeuresAemReel(contract.nb_heures_aem_reel || "");
}, [contract, categoriePro, professionPick?.code]); }, [contract, categoriePro, professionPick?.code]);
// États pour les autres champs CDDU actuellement grisés // États pour les autres champs CDDU actuellement grisés
@ -1103,6 +1127,14 @@ export default function ContractEditor({
brut: typeSalaire === "Brut" ? parseMonetaryAmount(montant) : null, brut: typeSalaire === "Brut" ? parseMonetaryAmount(montant) : null,
// Le champ gross_pay correspond au champ "Brut" de l'interface utilisateur // Le champ gross_pay correspond au champ "Brut" de l'interface utilisateur
gross_pay: parseMonetaryAmount(form.gross_pay), gross_pay: parseMonetaryAmount(form.gross_pay),
// Temps de travail réel (informatif pour le client)
jours_travail_reel: joursTravailReel || null,
nb_representations_reel: nbRepresentationsReel || null,
nb_services_repetitions_reel: nbServicesRepetitionsReel || null,
nb_heures_repetitions_reel: nbHeuresRepetitionsReel || null,
nb_heures_annexes_reel: nbHeuresAnnexesReel || null,
nb_cachets_aem_reel: nbCachetsAemReel || null,
nb_heures_aem_reel: nbHeuresAemReel || null,
}; };
console.log("💾 États actuels avant sauvegarde:", { console.log("💾 États actuels avant sauvegarde:", {
@ -1874,15 +1906,6 @@ export default function ContractEditor({
</div> </div>
<div className="flex gap-2 justify-end"> <div className="flex gap-2 justify-end">
<Button
onClick={saveContract}
disabled={isSaving}
className="rounded-2xl px-5"
>
<Save className="size-4 mr-2" />
{isSaving ? "Sauvegarde..." : "Enregistrer le contrat"}
</Button>
<Button <Button
onClick={generatePdf} onClick={generatePdf}
disabled={isGeneratingPdf} disabled={isGeneratingPdf}
@ -1910,6 +1933,18 @@ export default function ContractEditor({
</div> </div>
</header> </header>
{/* Bouton flottant pour enregistrer */}
<div className="fixed bottom-6 right-6 z-50">
<Button
onClick={saveContract}
disabled={isSaving}
className="rounded-full shadow-lg hover:shadow-xl transition-all duration-200 px-8 py-7 bg-green-600 hover:bg-green-700 text-black text-base font-semibold ring-2 ring-green-400 ring-offset-2"
>
<Save className="size-5 mr-2 text-black" />
{isSaving ? "Sauvegarde..." : "Enregistrer"}
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="col-span-2 space-y-6"> <div className="col-span-2 space-y-6">
{/* Card 1: États et Statuts - Le plus important */} {/* Card 1: États et Statuts - Le plus important */}
@ -2612,6 +2647,15 @@ export default function ContractEditor({
placeholder="ex : 8" placeholder="ex : 8"
/> />
</div> </div>
<div className="md:col-span-2">
<label className="text-xs text-muted-foreground">Jours de travail</label>
<Textarea
rows={3}
value={joursTravail}
onChange={(e) => setJoursTravail(e.target.value)}
placeholder="ex : 12/10, 13/10, 24/10"
/>
</div>
</div> </div>
</> </>
) : ( ) : (
@ -2640,7 +2684,8 @@ export default function ContractEditor({
</div> </div>
<div> <div>
<label className="text-xs text-muted-foreground">Jours de travail</label> <label className="text-xs text-muted-foreground">Jours de travail</label>
<Input <Textarea
rows={3}
value={joursTravail} value={joursTravail}
onChange={(e) => setJoursTravail(e.target.value)} onChange={(e) => setJoursTravail(e.target.value)}
placeholder="ex : 12/10, 13/10, 24/10" placeholder="ex : 12/10, 13/10, 24/10"
@ -2667,6 +2712,95 @@ export default function ContractEditor({
</> </>
)} )}
</div> </div>
{/* Séparateur */}
<Separator className="my-6" />
{/* Sous-section: Temps de travail réel (informatif pour le client) */}
<div className="mt-6">
<div className="flex items-center gap-2 mb-4">
<div className="h-px flex-1 bg-gradient-to-r from-slate-200 to-transparent"></div>
<h4 className="text-sm font-semibold text-slate-700">Temps de travail réel (informatif client)</h4>
<div className="h-px flex-1 bg-gradient-to-l from-slate-200 to-transparent"></div>
</div>
<p className="text-xs text-muted-foreground mb-4">
Ces informations sont purement informatives pour le client et ne sont pas utilisées pour la génération du PDF du contrat.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-xs text-muted-foreground">Jours travaillés</label>
<Input
value={joursTravailReel}
onChange={(e) => setJoursTravailReel(e.target.value)}
placeholder="ex: 15/01, 16/01, 17/01"
/>
</div>
<div>
<label className="text-xs text-muted-foreground">Nbre de représentations</label>
<Input
type="number"
min={0}
value={nbRepresentationsReel}
onChange={(e) => setNbRepresentationsReel(e.target.value === "" ? "" : Number(e.target.value))}
placeholder="0"
/>
</div>
<div>
<label className="text-xs text-muted-foreground">Nbre de services répétitions</label>
<Input
type="number"
min={0}
value={nbServicesRepetitionsReel}
onChange={(e) => setNbServicesRepetitionsReel(e.target.value === "" ? "" : Number(e.target.value))}
placeholder="0"
/>
</div>
<div>
<label className="text-xs text-muted-foreground">Nbre d'heures répétitions</label>
<Input
type="number"
min={0}
step="0.5"
value={nbHeuresRepetitionsReel}
onChange={(e) => setNbHeuresRepetitionsReel(e.target.value === "" ? "" : Number(e.target.value))}
placeholder="0"
/>
</div>
<div>
<label className="text-xs text-muted-foreground">Nbre d'heures Annexes 8</label>
<Input
type="number"
min={0}
step="0.5"
value={nbHeuresAnnexesReel}
onChange={(e) => setNbHeuresAnnexesReel(e.target.value === "" ? "" : Number(e.target.value))}
placeholder="0"
/>
</div>
<div>
<label className="text-xs text-muted-foreground">Nombre de cachets AEM</label>
<Input
type="number"
min={0}
value={nbCachetsAemReel}
onChange={(e) => setNbCachetsAemReel(e.target.value === "" ? "" : Number(e.target.value))}
placeholder="0"
/>
</div>
<div>
<label className="text-xs text-muted-foreground">Nombre d'heures AEM</label>
<Input
type="number"
min={0}
step="0.5"
value={nbHeuresAemReel}
onChange={(e) => setNbHeuresAemReel(e.target.value === "" ? "" : Number(e.target.value))}
placeholder="0"
/>
</div>
</div>
</div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View file

@ -0,0 +1,5 @@
-- Ajouter une colonne pour marquer les contrats comme traités pour le temps de travail réel
ALTER TABLE cddu_contracts
ADD COLUMN IF NOT EXISTS temps_reel_traite BOOLEAN DEFAULT false;
COMMENT ON COLUMN cddu_contracts.temps_reel_traite IS 'Indique si le temps de travail réel a été saisi et validé';

View file

@ -0,0 +1,34 @@
-- Migration: Ajouter les colonnes pour le temps de travail réel
-- Ces colonnes sont purement informatives pour les clients et ne sont pas utilisées pour la génération PDF
-- Ajouter les colonnes dans cddu_contracts
ALTER TABLE cddu_contracts
-- Jours travaillés
ADD COLUMN IF NOT EXISTS jours_travail_reel TEXT,
ADD COLUMN IF NOT EXISTS jours_travail_non_artiste_reel TEXT,
-- Représentations
ADD COLUMN IF NOT EXISTS nb_representations_reel INTEGER DEFAULT 0,
ADD COLUMN IF NOT EXISTS dates_representations_reel TEXT,
-- Répétitions
ADD COLUMN IF NOT EXISTS nb_services_repetitions_reel INTEGER DEFAULT 0,
ADD COLUMN IF NOT EXISTS nb_heures_repetitions_reel NUMERIC(10, 2) DEFAULT 0,
ADD COLUMN IF NOT EXISTS dates_repetitions_reel TEXT,
-- Heures annexes et AEM
ADD COLUMN IF NOT EXISTS nb_heures_annexes_reel NUMERIC(10, 2) DEFAULT 0,
ADD COLUMN IF NOT EXISTS nb_cachets_aem_reel INTEGER DEFAULT 0,
ADD COLUMN IF NOT EXISTS nb_heures_aem_reel NUMERIC(10, 2) DEFAULT 0;
-- Ajouter des commentaires pour documenter les colonnes
COMMENT ON COLUMN cddu_contracts.jours_travail_reel IS 'Jours de travail réels effectués (artiste) - Informatif pour client';
COMMENT ON COLUMN cddu_contracts.jours_travail_non_artiste_reel IS 'Jours de travail réels effectués (technicien) - Informatif pour client';
COMMENT ON COLUMN cddu_contracts.nb_representations_reel IS 'Nombre réel de représentations effectuées - Informatif pour client';
COMMENT ON COLUMN cddu_contracts.dates_representations_reel IS 'Dates réelles des représentations - Informatif pour client';
COMMENT ON COLUMN cddu_contracts.nb_services_repetitions_reel IS 'Nombre réel de services de répétition - Informatif pour client';
COMMENT ON COLUMN cddu_contracts.nb_heures_repetitions_reel IS 'Nombre réel d''heures de répétition - Informatif pour client';
COMMENT ON COLUMN cddu_contracts.dates_repetitions_reel IS 'Dates réelles des répétitions - Informatif pour client';
COMMENT ON COLUMN cddu_contracts.nb_heures_annexes_reel IS 'Nombre réel d''heures Annexes 8 - Informatif pour client';
COMMENT ON COLUMN cddu_contracts.nb_cachets_aem_reel IS 'Nombre réel de cachets AEM - Informatif pour client';
COMMENT ON COLUMN cddu_contracts.nb_heures_aem_reel IS 'Nombre réel d''heures AEM - Informatif pour client';