- 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
619 lines
27 KiB
TypeScript
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>
|
|
);
|
|
}
|