espace-paie-odentas/app/(staff)/staff/contrats/saisie-temps-reel/page.tsx
odentas 965b1fb9cd 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
2025-11-28 12:31:02 +01:00

619 lines
27 KiB
TypeScript

"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>
);
}