370 lines
14 KiB
TypeScript
370 lines
14 KiB
TypeScript
import { useAuth } from "@/_core/hooks/useAuth";
|
|
import { EtatBadge, HebergementBadge, FacturationBadge } from "@/components/EtatBadge";
|
|
import SonumLayout from "@/components/SonumLayout";
|
|
import { trpc } from "@/lib/trpc";
|
|
import {
|
|
Building2,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
Edit3,
|
|
Eye,
|
|
EyeOff,
|
|
Pencil,
|
|
Plus,
|
|
Save,
|
|
Trash2,
|
|
X,
|
|
} from "lucide-react";
|
|
import { useState } from "react";
|
|
import { useLocation } from "wouter";
|
|
import { toast } from "sonner";
|
|
import RattacherSolutionModal from "@/components/RattacherSolutionModal";
|
|
|
|
export default function MesEtablissements() {
|
|
const { user } = useAuth();
|
|
const [, navigate] = useLocation();
|
|
const [openEtab, setOpenEtab] = useState<number | null>(null);
|
|
const [editMode, setEditMode] = useState<number | null>(null);
|
|
const [rattacherEtab, setRattacherEtab] = useState<{ id: number; nom: string } | null>(null);
|
|
|
|
const etablissementsQuery = trpc.etablissements.mesEtablissements.useQuery();
|
|
const utils = trpc.useUtils();
|
|
|
|
const toggleAccordion = (id: number) => {
|
|
setOpenEtab(openEtab === id ? null : id);
|
|
setEditMode(null);
|
|
};
|
|
|
|
if (etablissementsQuery.isLoading) {
|
|
return (
|
|
<SonumLayout>
|
|
<div className="flex items-center justify-center h-64">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary border-t-transparent" />
|
|
</div>
|
|
</SonumLayout>
|
|
);
|
|
}
|
|
|
|
const etablissements = etablissementsQuery.data ?? [];
|
|
|
|
return (
|
|
<SonumLayout>
|
|
<div className="p-6 lg:p-8 max-w-5xl mx-auto">
|
|
{/* En-tête */}
|
|
<div className="mb-8 flex items-start justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-foreground mb-1">Mes Établissements</h1>
|
|
<p className="text-muted-foreground text-sm">
|
|
Gérez les logiciels de vos établissements rattachés
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-muted px-3 py-1.5 rounded-lg border border-border">
|
|
<Building2 size={15} />
|
|
<span>{etablissements.length} établissement{etablissements.length > 1 ? "s" : ""}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{etablissements.length === 0 ? (
|
|
<div className="text-center py-16 bg-card rounded-xl border border-border">
|
|
<Building2 size={48} className="mx-auto text-muted-foreground/30 mb-4" />
|
|
<p className="text-muted-foreground font-medium">Aucun établissement rattaché</p>
|
|
<p className="text-muted-foreground text-sm mt-1">
|
|
Contactez un gestionnaire SONUM pour rattacher vos établissements.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{etablissements.map((etab) => (
|
|
<EtablissementAccordion
|
|
key={etab.id}
|
|
etab={etab}
|
|
isOpen={openEtab === etab.id}
|
|
isEditMode={editMode === etab.id}
|
|
onToggle={() => toggleAccordion(etab.id)}
|
|
onEditMode={() => setEditMode(editMode === etab.id ? null : etab.id)}
|
|
onRattacher={() => setRattacherEtab({ id: etab.id, nom: etab.nom })}
|
|
onViewFiche={() => navigate(`/etablissement/${etab.id}`)}
|
|
onRefresh={() => utils.etablissements.mesEtablissements.invalidate()}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{rattacherEtab && (
|
|
<RattacherSolutionModal
|
|
etablissementId={rattacherEtab.id}
|
|
etablissementNom={rattacherEtab.nom}
|
|
onClose={() => setRattacherEtab(null)}
|
|
onSuccess={() => {
|
|
setRattacherEtab(null);
|
|
utils.logiciels.byEtablissement.invalidate({ etablissementId: rattacherEtab.id });
|
|
}}
|
|
/>
|
|
)}
|
|
</SonumLayout>
|
|
);
|
|
}
|
|
|
|
function EtablissementAccordion({
|
|
etab,
|
|
isOpen,
|
|
isEditMode,
|
|
onToggle,
|
|
onEditMode,
|
|
onRattacher,
|
|
onViewFiche,
|
|
onRefresh,
|
|
}: {
|
|
etab: any;
|
|
isOpen: boolean;
|
|
isEditMode: boolean;
|
|
onToggle: () => void;
|
|
onEditMode: () => void;
|
|
onRattacher: () => void;
|
|
onViewFiche: () => void;
|
|
onRefresh: () => void;
|
|
}) {
|
|
const logicielsQuery = trpc.logiciels.byEtablissement.useQuery(
|
|
{ etablissementId: etab.id },
|
|
{ enabled: isOpen }
|
|
);
|
|
const deleteMutation = trpc.logiciels.delete.useMutation({
|
|
onSuccess: () => {
|
|
toast.success("Logiciel supprimé");
|
|
logicielsQuery.refetch();
|
|
},
|
|
});
|
|
const utils = trpc.useUtils();
|
|
|
|
return (
|
|
<div className="bg-card rounded-xl border border-border shadow-sm overflow-hidden transition-all duration-200">
|
|
{/* En-tête accordéon */}
|
|
<button
|
|
className="w-full flex items-center justify-between px-5 py-4 hover:bg-muted/30 transition-colors text-left"
|
|
onClick={onToggle}
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
|
|
<Building2 size={17} className="text-primary" />
|
|
</div>
|
|
<div>
|
|
<div className="font-semibold text-foreground">{etab.nom}</div>
|
|
<div className="flex items-center gap-3 mt-0.5">
|
|
{etab.finess && <span className="text-xs text-muted-foreground">FINESS : {etab.finess}</span>}
|
|
{etab.region && <span className="text-xs text-muted-foreground">{etab.region}</span>}
|
|
{etab.typeActivite && (
|
|
<span className="text-xs bg-secondary text-secondary-foreground px-2 py-0.5 rounded border border-border">
|
|
{etab.typeActivite}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs text-muted-foreground hidden sm:block">
|
|
{isOpen ? "Masquer" : "Voir les logiciels"}
|
|
</span>
|
|
{isOpen ? (
|
|
<ChevronDown size={18} className="text-muted-foreground transition-transform" />
|
|
) : (
|
|
<ChevronRight size={18} className="text-muted-foreground" />
|
|
)}
|
|
</div>
|
|
</button>
|
|
|
|
{/* Contenu accordéon */}
|
|
{isOpen && (
|
|
<div className="border-t border-border">
|
|
{/* Barre d'actions */}
|
|
<div className="flex items-center justify-between px-5 py-3 bg-muted/20 border-b border-border">
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={onRattacher}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors shadow-sm"
|
|
>
|
|
<Plus size={13} />
|
|
Rattacher une solution
|
|
</button>
|
|
<button
|
|
onClick={onEditMode}
|
|
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors border ${
|
|
isEditMode
|
|
? "bg-amber-50 text-amber-700 border-amber-200"
|
|
: "bg-background text-foreground border-border hover:bg-muted"
|
|
}`}
|
|
>
|
|
<Edit3 size={13} />
|
|
{isEditMode ? "Mode édition actif" : "Édition complète"}
|
|
</button>
|
|
</div>
|
|
<button
|
|
onClick={onViewFiche}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted rounded-lg transition-colors border border-border"
|
|
>
|
|
<Eye size={13} />
|
|
Voir la fiche
|
|
</button>
|
|
</div>
|
|
|
|
{/* Tableau des logiciels */}
|
|
{logicielsQuery.isLoading ? (
|
|
<div className="flex items-center justify-center py-8">
|
|
<div className="animate-spin rounded-full h-6 w-6 border-2 border-primary border-t-transparent" />
|
|
</div>
|
|
) : !logicielsQuery.data?.length ? (
|
|
<div className="text-center py-8 text-muted-foreground text-sm">
|
|
<p>Aucun logiciel renseigné pour cet établissement.</p>
|
|
<button onClick={onRattacher} className="mt-2 text-primary hover:underline text-sm font-medium">
|
|
Rattacher une première solution →
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-border bg-muted/20">
|
|
<th className="text-left px-5 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider">Solution</th>
|
|
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider hidden md:table-cell">Éditeur</th>
|
|
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider hidden lg:table-cell">Bloc fonctionnel</th>
|
|
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider">État</th>
|
|
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider hidden xl:table-cell">Hébergement</th>
|
|
{isEditMode && (
|
|
<th className="text-right px-5 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider">Actions</th>
|
|
)}
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border">
|
|
{logicielsQuery.data.map((logiciel) => (
|
|
<LogicielRow
|
|
key={logiciel.id}
|
|
logiciel={logiciel}
|
|
isEditMode={isEditMode}
|
|
etablissementId={etab.id}
|
|
onDelete={() => deleteMutation.mutate({ id: logiciel.id, etablissementId: etab.id })}
|
|
onUpdated={() => logicielsQuery.refetch()}
|
|
/>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function LogicielRow({
|
|
logiciel,
|
|
isEditMode,
|
|
etablissementId,
|
|
onDelete,
|
|
onUpdated,
|
|
}: {
|
|
logiciel: any;
|
|
isEditMode: boolean;
|
|
etablissementId: number;
|
|
onDelete: () => void;
|
|
onUpdated: () => void;
|
|
}) {
|
|
const [editing, setEditing] = useState(false);
|
|
const [etat, setEtat] = useState(logiciel.etatDeploiement);
|
|
const [commentaire, setCommentaire] = useState(logiciel.commentaire ?? "");
|
|
|
|
const updateMutation = trpc.logiciels.upsert.useMutation({
|
|
onSuccess: () => {
|
|
toast.success("Logiciel mis à jour");
|
|
setEditing(false);
|
|
onUpdated();
|
|
},
|
|
});
|
|
|
|
if (editing && isEditMode) {
|
|
return (
|
|
<tr className="bg-amber-50/50">
|
|
<td className="px-5 py-3 font-medium">{logiciel.solutionNom}</td>
|
|
<td className="px-4 py-3 hidden md:table-cell text-muted-foreground">{logiciel.editeurNom}</td>
|
|
<td className="px-4 py-3 hidden lg:table-cell text-muted-foreground text-xs">{logiciel.blocFonctionnelNom}</td>
|
|
<td className="px-4 py-3">
|
|
<select
|
|
value={etat}
|
|
onChange={(e) => setEtat(e.target.value)}
|
|
className="text-xs border border-border rounded px-2 py-1 bg-background"
|
|
>
|
|
<option value="demarrage">Démarrage</option>
|
|
<option value="en_cours">En cours</option>
|
|
<option value="operationnel">Opérationnel</option>
|
|
<option value="en_remplacement">En remplacement</option>
|
|
</select>
|
|
</td>
|
|
<td className="px-4 py-3 hidden xl:table-cell">
|
|
<input
|
|
value={commentaire}
|
|
onChange={(e) => setCommentaire(e.target.value)}
|
|
placeholder="Commentaire..."
|
|
className="text-xs border border-border rounded px-2 py-1 bg-background w-full"
|
|
/>
|
|
</td>
|
|
<td className="px-5 py-3 text-right">
|
|
<div className="flex items-center justify-end gap-1.5">
|
|
<button
|
|
onClick={() => updateMutation.mutate({
|
|
id: logiciel.id,
|
|
etablissementId,
|
|
solutionId: logiciel.solutionId,
|
|
etatDeploiement: etat,
|
|
commentaire,
|
|
})}
|
|
className="p-1.5 rounded bg-green-100 text-green-700 hover:bg-green-200 transition-colors"
|
|
>
|
|
<Save size={13} />
|
|
</button>
|
|
<button onClick={() => setEditing(false)} className="p-1.5 rounded bg-muted text-muted-foreground hover:bg-secondary transition-colors">
|
|
<X size={13} />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<tr className="hover:bg-muted/20 transition-colors">
|
|
<td className="px-5 py-3.5 font-medium text-foreground">{logiciel.solutionNom}</td>
|
|
<td className="px-4 py-3.5 hidden md:table-cell text-muted-foreground">{logiciel.editeurNom}</td>
|
|
<td className="px-4 py-3.5 hidden lg:table-cell">
|
|
{logiciel.blocFonctionnelNom && (
|
|
<span className="text-xs bg-secondary text-secondary-foreground px-2 py-0.5 rounded border border-border">
|
|
{logiciel.blocFonctionnelNom}
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3.5">
|
|
<EtatBadge etat={logiciel.etatDeploiement} />
|
|
</td>
|
|
<td className="px-4 py-3.5 hidden xl:table-cell">
|
|
{logiciel.modeHebergement ? <HebergementBadge mode={logiciel.modeHebergement} /> : <span className="text-muted-foreground text-xs">—</span>}
|
|
</td>
|
|
{isEditMode && (
|
|
<td className="px-5 py-3.5 text-right">
|
|
<div className="flex items-center justify-end gap-1.5">
|
|
<button
|
|
onClick={() => setEditing(true)}
|
|
className="p-1.5 rounded bg-blue-50 text-blue-600 hover:bg-blue-100 transition-colors"
|
|
>
|
|
<Pencil size={13} />
|
|
</button>
|
|
<button
|
|
onClick={onDelete}
|
|
className="p-1.5 rounded bg-red-50 text-red-600 hover:bg-red-100 transition-colors"
|
|
>
|
|
<Trash2 size={13} />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
)}
|
|
</tr>
|
|
);
|
|
}
|