espace-paie-odentas/app/(app)/staff/gestion-productions/page.tsx

719 lines
26 KiB
TypeScript

// app/(app)/staff/gestion-productions/page.tsx
"use client";
import React, { useState, useMemo } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
Loader2,
Search,
Filter,
X,
Plus,
Edit2,
Trash2,
Calendar,
Building2,
Hash,
FileText
} from "lucide-react";
import { usePageTitle } from "@/hooks/usePageTitle";
// Types
type Production = {
id: string;
name: string;
reference: string | null;
declaration_date: string | null;
org_id: string;
created_at?: string;
updated_at?: string;
};
type Organization = {
id: string;
name: string;
structure_api: string;
};
// Hook pour vérifier si l'utilisateur est staff
function useStaffCheck() {
return useQuery({
queryKey: ["staff-check"],
queryFn: async () => {
const res = await fetch("/api/me");
if (!res.ok) return { isStaff: false };
const data = await res.json();
return { isStaff: Boolean(data.is_staff || data.isStaff) };
},
staleTime: 30_000,
});
}
// Hook pour récupérer les organisations
function useOrganizations() {
return useQuery({
queryKey: ["organizations"],
queryFn: async () => {
const res = await fetch("/api/staff/organizations");
if (!res.ok) throw new Error("Erreur chargement organisations");
const data = await res.json();
return data.organizations as Organization[];
},
staleTime: 60_000,
});
}
// Hook pour récupérer les productions
function useProductions(orgId?: string, searchQuery?: string) {
return useQuery({
queryKey: ["staff-productions", orgId, searchQuery],
queryFn: async () => {
const params = new URLSearchParams();
if (orgId) params.set("org_id", orgId);
if (searchQuery) params.set("q", searchQuery);
const res = await fetch(`/api/staff/productions?${params.toString()}`);
if (!res.ok) throw new Error("Erreur chargement productions");
const data = await res.json();
return data.productions as Production[];
},
enabled: true,
staleTime: 15_000,
});
}
// Composant Modal pour Créer/Éditer
function ProductionModal({
isOpen,
onClose,
production,
organizations,
}: {
isOpen: boolean;
onClose: () => void;
production?: Production | null;
organizations: Organization[];
}) {
const queryClient = useQueryClient();
const [name, setName] = useState(production?.name || "");
const [reference, setReference] = useState(production?.reference || "");
const [declarationDate, setDeclarationDate] = useState(production?.declaration_date || "");
const [orgId, setOrgId] = useState(production?.org_id || "");
const [error, setError] = useState("");
React.useEffect(() => {
if (isOpen) {
setName(production?.name || "");
setReference(production?.reference || "");
setDeclarationDate(production?.declaration_date || "");
setOrgId(production?.org_id || "");
setError("");
}
}, [isOpen, production]);
const mutation = useMutation({
mutationFn: async (data: any) => {
const url = production
? `/api/staff/productions/${production.id}`
: "/api/staff/productions";
const method = production ? "PATCH" : "POST";
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) {
const errorData = await res.json();
throw new Error(errorData.error || "Erreur lors de l'opération");
}
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["staff-productions"] });
onClose();
},
onError: (err: any) => {
setError(err.message || "Une erreur est survenue");
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setError("");
if (!name.trim()) {
setError("Le nom est requis");
return;
}
if (!orgId) {
setError("L'organisation est requise");
return;
}
mutation.mutate({
name: name.trim(),
reference: reference.trim() || null,
declaration_date: declarationDate || null,
org_id: orgId,
});
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="relative w-full max-w-2xl bg-white rounded-2xl shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-200">
{/* Header */}
<div className="px-6 py-4 border-b bg-gradient-to-r from-indigo-50 to-purple-50">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-white rounded-lg shadow-sm">
<Building2 className="w-5 h-5 text-indigo-600" />
</div>
<div>
<h2 className="text-lg font-semibold text-slate-900">
{production ? "Modifier la production" : "Nouvelle production"}
</h2>
<p className="text-sm text-slate-600">
{production ? "Modifiez les informations ci-dessous" : "Remplissez les informations ci-dessous"}
</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-white/50 rounded-lg transition-colors"
>
<X className="w-5 h-5 text-slate-500" />
</button>
</div>
</div>
{/* Body */}
<form onSubmit={handleSubmit} className="p-6 space-y-6">
{error && (
<div className="p-4 bg-red-50 border border-red-200 rounded-xl flex items-start gap-3">
<div className="flex-shrink-0 w-5 h-5 bg-red-100 rounded-full flex items-center justify-center">
<span className="text-red-600 text-xs font-bold">!</span>
</div>
<div className="flex-1">
<p className="text-sm text-red-800 font-medium">Erreur</p>
<p className="text-sm text-red-600">{error}</p>
</div>
</div>
)}
{/* Nom */}
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-slate-700">
<FileText className="w-4 h-4 text-slate-500" />
Nom de la production *
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Ex: Festival d'Avignon 2025"
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all"
required
/>
</div>
{/* Référence */}
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-slate-700">
<Hash className="w-4 h-4 text-slate-500" />
Numéro d'objet / Référence
</label>
<input
type="text"
value={reference}
onChange={(e) => setReference(e.target.value)}
placeholder="Ex: PROD-2025-001"
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all"
/>
</div>
{/* Date de déclaration */}
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-slate-700">
<Calendar className="w-4 h-4 text-slate-500" />
Date de déclaration
</label>
<input
type="date"
value={declarationDate}
onChange={(e) => setDeclarationDate(e.target.value)}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all"
/>
</div>
{/* Organisation */}
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-slate-700">
<Building2 className="w-4 h-4 text-slate-500" />
Organisation *
</label>
<select
value={orgId}
onChange={(e) => setOrgId(e.target.value)}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all"
required
>
<option value="">Sélectionnez une organisation</option>
{organizations.map((org) => (
<option key={org.id} value={org.id}>
{org.name}
</option>
))}
</select>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 pt-4 border-t">
<button
type="button"
onClick={onClose}
className="px-4 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
disabled={mutation.isPending}
>
Annuler
</button>
<button
type="submit"
disabled={mutation.isPending}
className="px-4 py-2.5 text-sm font-medium text-white bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-700 hover:to-purple-700 rounded-lg transition-all shadow-sm hover:shadow-md disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{mutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
{production ? "Enregistrer" : "Créer"}
</button>
</div>
</form>
</div>
</div>
);
}
// Composant Modal de suppression
function DeleteConfirmModal({
isOpen,
onClose,
production,
}: {
isOpen: boolean;
onClose: () => void;
production: Production | null;
}) {
const queryClient = useQueryClient();
const deleteMutation = useMutation({
mutationFn: async () => {
if (!production) return;
const res = await fetch(`/api/staff/productions/${production.id}`, {
method: "DELETE",
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Erreur lors de la suppression");
}
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["staff-productions"] });
onClose();
},
});
if (!isOpen || !production) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
<div className="relative w-full max-w-md bg-white rounded-2xl shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-200">
<div className="p-6">
<div className="flex items-center gap-4 mb-4">
<div className="flex-shrink-0 w-12 h-12 bg-red-100 rounded-full flex items-center justify-center">
<Trash2 className="w-6 h-6 text-red-600" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-slate-900">
Confirmer la suppression
</h3>
<p className="text-sm text-slate-600 mt-1">
Cette action est irréversible
</p>
</div>
</div>
<div className="p-4 bg-slate-50 rounded-xl mb-6">
<p className="text-sm text-slate-700">
Voulez-vous vraiment supprimer la production{" "}
<span className="font-semibold">{production.name}</span> ?
</p>
{production.reference && (
<p className="text-xs text-slate-500 mt-1">
Référence : {production.reference}
</p>
)}
</div>
<div className="flex items-center justify-end gap-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
disabled={deleteMutation.isPending}
>
Annuler
</button>
<button
onClick={() => deleteMutation.mutate()}
disabled={deleteMutation.isPending}
className="px-4 py-2.5 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{deleteMutation.isPending && (
<Loader2 className="w-4 h-4 animate-spin" />
)}
Supprimer
</button>
</div>
</div>
</div>
</div>
);
}
// Page principale
export default function GestionProductionsPage() {
usePageTitle("Gestion des productions");
const [searchQuery, setSearchQuery] = useState("");
const [selectedOrgId, setSelectedOrgId] = useState("");
const [showFilters, setShowFilters] = useState(false);
const [editingProduction, setEditingProduction] = useState<Production | null>(null);
const [deletingProduction, setDeletingProduction] = useState<Production | null>(null);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const { data: staffCheck, isLoading: isLoadingStaff } = useStaffCheck();
const { data: organizations = [], isLoading: isLoadingOrgs } = useOrganizations();
const { data: productions = [], isLoading: isLoadingProductions, error } = useProductions(
selectedOrgId,
searchQuery
);
// Filtrage local supplémentaire si nécessaire
const filteredProductions = useMemo(() => {
let result = productions;
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
result = result.filter(
(p) =>
p.name.toLowerCase().includes(query) ||
(p.reference && p.reference.toLowerCase().includes(query))
);
}
return result;
}, [productions, searchQuery]);
// Vérification staff
if (isLoadingStaff) {
return (
<div className="flex items-center justify-center min-h-screen">
<Loader2 className="w-8 h-8 animate-spin text-indigo-600" />
</div>
);
}
if (!staffCheck?.isStaff) {
return (
<main className="p-6">
<h1 className="text-lg font-semibold">Accès refusé</h1>
<p className="text-sm text-slate-600">Cette page est réservée au Staff.</p>
</main>
);
}
const hasActiveFilters = selectedOrgId || searchQuery;
return (
<div className="space-y-5 p-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900">Gestion des productions</h1>
<p className="text-sm text-slate-600 mt-1">
Gérez les productions et spectacles de toutes les organisations
</p>
</div>
<button
onClick={() => setIsCreateModalOpen(true)}
className="inline-flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-indigo-600 to-purple-600 text-white rounded-xl hover:from-indigo-700 hover:to-purple-700 transition-all shadow-sm hover:shadow-md"
>
<Plus className="w-5 h-5" />
Nouvelle production
</button>
</div>
{/* Filtres et recherche */}
<section className="rounded-2xl border bg-white overflow-hidden shadow-sm">
<div className="p-4 border-b bg-slate-50/50">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="flex items-center gap-3 flex-1">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Rechercher par nom ou référence..."
className="w-full pl-10 pr-4 py-2.5 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery("")}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 hover:bg-slate-100 rounded-md transition-colors"
>
<X className="w-4 h-4 text-slate-400" />
</button>
)}
</div>
<button
onClick={() => setShowFilters(!showFilters)}
className={`inline-flex items-center gap-2 px-4 py-2.5 rounded-xl border transition-all ${
hasActiveFilters
? "bg-indigo-50 border-indigo-200 text-indigo-700"
: "bg-white border-slate-200 text-slate-700 hover:bg-slate-50"
}`}
>
<Filter className="w-4 h-4" />
Filtres
{hasActiveFilters && (
<span className="px-2 py-0.5 bg-indigo-600 text-white text-xs rounded-full">
{selectedOrgId ? 1 : 0}
</span>
)}
</button>
</div>
{hasActiveFilters && (
<button
onClick={() => {
setSelectedOrgId("");
setSearchQuery("");
}}
className="inline-flex items-center gap-1 text-sm text-slate-600 hover:text-slate-900 transition-colors"
>
<X className="w-4 h-4" />
Réinitialiser
</button>
)}
</div>
{showFilters && (
<div className="mt-4 pt-4 border-t">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700">
Organisation
</label>
<select
value={selectedOrgId}
onChange={(e) => setSelectedOrgId(e.target.value)}
className="w-full px-3 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
>
<option value="">Toutes les organisations</option>
{organizations.map((org) => (
<option key={org.id} value={org.id}>
{org.name}
</option>
))}
</select>
</div>
</div>
</div>
)}
</div>
{/* Stats */}
<div className="px-4 py-3 bg-gradient-to-r from-slate-50 to-indigo-50/30 border-b">
<div className="flex items-center justify-between text-sm">
<span className="text-slate-600">
{isLoadingProductions ? (
<span className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
Chargement...
</span>
) : (
<>
<span className="font-semibold text-slate-900">
{filteredProductions.length}
</span>{" "}
production{filteredProductions.length > 1 ? "s" : ""}
{hasActiveFilters && " (filtrée" + (filteredProductions.length > 1 ? "s" : "") + ")"}
</>
)}
</span>
{selectedOrgId && (
<span className="text-slate-600">
Organisation :{" "}
<span className="font-medium text-slate-900">
{organizations.find((o) => o.id === selectedOrgId)?.name}
</span>
</span>
)}
</div>
</div>
{/* Tableau */}
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-slate-50 border-b">
<tr>
<th className="text-left px-6 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">
Nom
</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">
Référence
</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">
Date déclaration
</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">
Organisation
</th>
<th className="text-right px-6 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{isLoadingProductions ? (
<tr>
<td colSpan={5} className="px-6 py-12 text-center">
<Loader2 className="w-6 h-6 animate-spin mx-auto text-indigo-600" />
<p className="text-sm text-slate-600 mt-2">
Chargement des productions...
</p>
</td>
</tr>
) : error ? (
<tr>
<td colSpan={5} className="px-6 py-12 text-center">
<p className="text-sm text-red-600">
Erreur lors du chargement des productions
</p>
</td>
</tr>
) : filteredProductions.length === 0 ? (
<tr>
<td colSpan={5} className="px-6 py-12 text-center">
<Building2 className="w-12 h-12 mx-auto text-slate-300 mb-3" />
<p className="text-sm font-medium text-slate-600">
Aucune production trouvée
</p>
<p className="text-xs text-slate-500 mt-1">
{hasActiveFilters
? "Essayez de modifier vos filtres"
: "Créez votre première production"}
</p>
</td>
</tr>
) : (
filteredProductions.map((production) => {
const org = organizations.find((o) => o.id === production.org_id);
return (
<tr
key={production.id}
className="hover:bg-slate-50/50 transition-colors"
>
<td className="px-6 py-4">
<div className="font-medium text-slate-900">
{production.name}
</div>
</td>
<td className="px-6 py-4">
{production.reference ? (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-slate-100 text-slate-700 rounded-lg text-xs font-medium">
<Hash className="w-3 h-3" />
{production.reference}
</span>
) : (
<span className="text-slate-400 text-sm"></span>
)}
</td>
<td className="px-6 py-4">
{production.declaration_date ? (
<div className="flex items-center gap-2 text-sm text-slate-600">
<Calendar className="w-4 h-4 text-slate-400" />
{new Date(production.declaration_date).toLocaleDateString("fr-FR")}
</div>
) : (
<span className="text-slate-400 text-sm"></span>
)}
</td>
<td className="px-6 py-4">
<span className="text-sm text-slate-600">
{org?.name || "—"}
</span>
</td>
<td className="px-6 py-4">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => setEditingProduction(production)}
className="p-2 hover:bg-indigo-50 rounded-lg transition-colors group"
title="Modifier"
>
<Edit2 className="w-4 h-4 text-slate-400 group-hover:text-indigo-600" />
</button>
<button
onClick={() => setDeletingProduction(production)}
className="p-2 hover:bg-red-50 rounded-lg transition-colors group"
title="Supprimer"
>
<Trash2 className="w-4 h-4 text-slate-400 group-hover:text-red-600" />
</button>
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</section>
{/* Modals */}
<ProductionModal
isOpen={isCreateModalOpen || !!editingProduction}
onClose={() => {
setIsCreateModalOpen(false);
setEditingProduction(null);
}}
production={editingProduction}
organizations={organizations}
/>
<DeleteConfirmModal
isOpen={!!deletingProduction}
onClose={() => setDeletingProduction(null)}
production={deletingProduction}
/>
</div>
);
}