Checkpoint: Application SONUM v1.0 - Cartographie des Solutions Numériques FEHAP. Toutes les fonctionnalités implémentées : charte CGU, moteur de recherche multicritères avec tri, Mes Établissements avec accordéons et édition inline, formulaire de saisie guidé en 3 étapes, fiche établissement avec compteur de consultation, prise de contact, Mes Demandes de Contact avec réponse directe, administration SONUM, gestion des rôles (Référent / Gestionnaire), traçabilité, lien espace adhérent FEHAP. 14 tests Vitest passés.
This commit is contained in:
283
client/src/pages/Admin.tsx
Normal file
283
client/src/pages/Admin.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
import { useAuth } from "@/_core/hooks/useAuth";
|
||||
import SonumLayout from "@/components/SonumLayout";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import {
|
||||
Building2,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Plus,
|
||||
Shield,
|
||||
User,
|
||||
Users,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useLocation } from "wouter";
|
||||
|
||||
export default function Admin() {
|
||||
const { user } = useAuth();
|
||||
const [, navigate] = useLocation();
|
||||
const isGestionnaire = user?.sonumRole === "gestionnaire" || user?.role === "admin";
|
||||
|
||||
if (!isGestionnaire) {
|
||||
return (
|
||||
<SonumLayout>
|
||||
<div className="p-8 text-center">
|
||||
<Shield size={48} className="mx-auto text-muted-foreground/30 mb-4" />
|
||||
<p className="text-muted-foreground font-medium">Accès réservé aux gestionnaires SONUM</p>
|
||||
</div>
|
||||
</SonumLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SonumLayout>
|
||||
<div className="p-6 lg:p-8 max-w-6xl mx-auto">
|
||||
{/* En-tête */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-foreground mb-1">Administration SONUM</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Gestion des utilisateurs, des établissements et du référentiel
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Gestion des utilisateurs */}
|
||||
<div className="lg:col-span-2">
|
||||
<UsersPanel />
|
||||
</div>
|
||||
|
||||
{/* Gestion des établissements */}
|
||||
<div className="lg:col-span-2">
|
||||
<EtablissementsPanel />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SonumLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function UsersPanel() {
|
||||
const usersQuery = trpc.admin.users.useQuery();
|
||||
const updateRoleMutation = trpc.admin.updateRole.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Rôle mis à jour");
|
||||
usersQuery.refetch();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="bg-card rounded-xl border border-border shadow-sm overflow-hidden">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users size={18} className="text-primary" />
|
||||
<h2 className="font-semibold text-foreground">Utilisateurs</h2>
|
||||
<span className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full">
|
||||
{usersQuery.data?.length ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{usersQuery.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>
|
||||
) : (
|
||||
<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">Utilisateur</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider hidden md:table-cell">Email</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider">Rôle SONUM</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider hidden lg:table-cell">Dernière connexion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{usersQuery.data?.map((u) => (
|
||||
<tr key={u.id} className="hover:bg-muted/20 transition-colors">
|
||||
<td className="px-5 py-3.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-full bg-primary/10 flex items-center justify-center text-xs font-semibold text-primary">
|
||||
{u.name?.charAt(0)?.toUpperCase() ?? "U"}
|
||||
</div>
|
||||
<span className="font-medium text-foreground">{u.name ?? "—"}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3.5 hidden md:table-cell text-muted-foreground text-xs">{u.email ?? "—"}</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<select
|
||||
value={u.sonumRole ?? "referent"}
|
||||
onChange={(e) =>
|
||||
updateRoleMutation.mutate({
|
||||
userId: u.id,
|
||||
sonumRole: e.target.value as "referent" | "gestionnaire",
|
||||
})
|
||||
}
|
||||
className={`text-xs px-2.5 py-1.5 rounded-lg border font-medium focus:outline-none focus:ring-2 focus:ring-primary/30 transition-all ${
|
||||
u.sonumRole === "gestionnaire"
|
||||
? "bg-accent/10 text-accent border-accent/20"
|
||||
: "bg-primary/10 text-primary border-primary/20"
|
||||
}`}
|
||||
>
|
||||
<option value="referent">Référent numérique</option>
|
||||
<option value="gestionnaire">Gestionnaire SONUM</option>
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-4 py-3.5 hidden lg:table-cell text-muted-foreground text-xs">
|
||||
{u.lastSignedIn
|
||||
? new Date(u.lastSignedIn).toLocaleDateString("fr-FR", { day: "2-digit", month: "short", year: "numeric" })
|
||||
: "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EtablissementsPanel() {
|
||||
const etablissementsQuery = trpc.etablissements.all.useQuery();
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
nom: "",
|
||||
finess: "",
|
||||
region: "",
|
||||
typeActivite: "",
|
||||
tailleEffectifs: "",
|
||||
});
|
||||
|
||||
const createMutation = trpc.etablissements.create.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Établissement créé");
|
||||
setShowCreate(false);
|
||||
setForm({ nom: "", finess: "", region: "", typeActivite: "", tailleEffectifs: "" });
|
||||
etablissementsQuery.refetch();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="bg-card rounded-xl border border-border shadow-sm overflow-hidden">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 size={18} className="text-primary" />
|
||||
<h2 className="font-semibold text-foreground">Établissements</h2>
|
||||
<span className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full">
|
||||
{etablissementsQuery.data?.length ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreate(!showCreate)}
|
||||
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} />
|
||||
Ajouter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Formulaire de création */}
|
||||
{showCreate && (
|
||||
<div className="px-5 py-4 border-b border-border bg-muted/20">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-foreground mb-1">Nom *</label>
|
||||
<input
|
||||
value={form.nom}
|
||||
onChange={(e) => setForm((f) => ({ ...f, nom: e.target.value }))}
|
||||
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||
placeholder="Nom de l'établissement"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-foreground mb-1">FINESS</label>
|
||||
<input
|
||||
value={form.finess}
|
||||
onChange={(e) => setForm((f) => ({ ...f, finess: e.target.value }))}
|
||||
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||
placeholder="N° FINESS"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-foreground mb-1">Région</label>
|
||||
<input
|
||||
value={form.region}
|
||||
onChange={(e) => setForm((f) => ({ ...f, region: e.target.value }))}
|
||||
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||
placeholder="Région"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-foreground mb-1">Type d'activité</label>
|
||||
<input
|
||||
value={form.typeActivite}
|
||||
onChange={(e) => setForm((f) => ({ ...f, typeActivite: e.target.value }))}
|
||||
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||
placeholder="MCO, EHPAD..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowCreate(false)}
|
||||
className="px-3 py-1.5 text-xs font-medium border border-border rounded-lg text-foreground hover:bg-muted"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
onClick={() => createMutation.mutate(form)}
|
||||
disabled={!form.nom.trim() || createMutation.isPending}
|
||||
className="flex items-center gap-1.5 px-4 py-1.5 text-xs font-medium bg-primary text-white rounded-lg hover:bg-primary/90 disabled:opacity-50 shadow-sm"
|
||||
>
|
||||
<Check size={13} />
|
||||
{createMutation.isPending ? "Création..." : "Créer"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{etablissementsQuery.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>
|
||||
) : (
|
||||
<div className="overflow-x-auto max-h-96 overflow-y-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0">
|
||||
<tr className="border-b border-border bg-muted/30">
|
||||
<th className="text-left px-5 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider">Établissement</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider hidden md:table-cell">Région</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider hidden lg:table-cell">Type</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider hidden lg:table-cell">Référent</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{etablissementsQuery.data?.map((etab) => (
|
||||
<tr key={etab.id} className="hover:bg-muted/20 transition-colors">
|
||||
<td className="px-5 py-3">
|
||||
<div className="font-medium text-foreground text-sm">{etab.nom}</div>
|
||||
{etab.finess && <div className="text-xs text-muted-foreground">FINESS : {etab.finess}</div>}
|
||||
</td>
|
||||
<td className="px-4 py-3 hidden md:table-cell text-muted-foreground text-sm">{etab.region ?? "—"}</td>
|
||||
<td className="px-4 py-3 hidden lg:table-cell">
|
||||
{etab.typeActivite ? (
|
||||
<span className="text-xs bg-secondary text-secondary-foreground px-2 py-0.5 rounded border border-border">
|
||||
{etab.typeActivite}
|
||||
</span>
|
||||
) : <span className="text-muted-foreground text-xs">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 hidden lg:table-cell text-muted-foreground text-xs">
|
||||
{etab.referentId ? `Réf. #${etab.referentId}` : <span className="italic">Non assigné</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
240
client/src/pages/FicheEtablissement.tsx
Normal file
240
client/src/pages/FicheEtablissement.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
import { useAuth } from "@/_core/hooks/useAuth";
|
||||
import { EtatBadge, HebergementBadge, FacturationBadge } from "@/components/EtatBadge";
|
||||
import SonumLayout from "@/components/SonumLayout";
|
||||
import ContactModal from "@/components/ContactModal";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Building2,
|
||||
Eye,
|
||||
Mail,
|
||||
MapPin,
|
||||
Phone,
|
||||
Server,
|
||||
Tag,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { INTEROPERABILITE } from "../../../shared/referentiel";
|
||||
|
||||
interface Props {
|
||||
params: { id: string };
|
||||
}
|
||||
|
||||
export default function FicheEtablissement({ params }: Props) {
|
||||
const id = Number(params.id);
|
||||
const { user } = useAuth();
|
||||
const [, navigate] = useLocation();
|
||||
const [showContact, setShowContact] = useState(false);
|
||||
|
||||
const etabQuery = trpc.etablissements.byId.useQuery({ id }, { enabled: !!id });
|
||||
const logicielsQuery = trpc.logiciels.byEtablissement.useQuery({ etablissementId: id }, { enabled: !!id });
|
||||
const recordConsultation = trpc.tracabilite.enregistrerConsultation.useMutation();
|
||||
|
||||
const isGestionnaire = user?.sonumRole === "gestionnaire" || user?.role === "admin";
|
||||
const isReferent = etabQuery.data?.referentId === user?.id;
|
||||
const canSeeCounter = isReferent || isGestionnaire;
|
||||
|
||||
const compteurQuery = trpc.tracabilite.compteur.useQuery(
|
||||
{ etablissementId: id },
|
||||
{ enabled: !!id && canSeeCounter }
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
recordConsultation.mutate({ etablissementId: id });
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
if (etabQuery.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>
|
||||
);
|
||||
}
|
||||
|
||||
if (!etabQuery.data) {
|
||||
return (
|
||||
<SonumLayout>
|
||||
<div className="p-8 text-center text-muted-foreground">Établissement introuvable.</div>
|
||||
</SonumLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const etab = etabQuery.data;
|
||||
|
||||
// Grouper les logiciels par bloc fonctionnel
|
||||
const logicielsByBloc = (logicielsQuery.data ?? []).reduce((acc, l) => {
|
||||
const bloc = l.blocFonctionnelNom ?? "Autres";
|
||||
if (!acc[bloc]) acc[bloc] = [];
|
||||
acc[bloc].push(l);
|
||||
return acc;
|
||||
}, {} as Record<string, typeof logicielsQuery.data>);
|
||||
|
||||
return (
|
||||
<SonumLayout>
|
||||
<div className="p-6 lg:p-8 max-w-5xl mx-auto">
|
||||
{/* Retour */}
|
||||
<button
|
||||
onClick={() => navigate(-1 as any)}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors mb-6"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
Retour
|
||||
</button>
|
||||
|
||||
{/* En-tête fiche */}
|
||||
<div className="bg-card rounded-xl border border-border shadow-sm p-6 mb-6">
|
||||
<div className="flex items-start justify-between flex-wrap gap-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-14 h-14 rounded-xl bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<Building2 size={28} className="text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-foreground mb-1">{etab.nom}</h1>
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
|
||||
{etab.finess && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Tag size={13} />
|
||||
FINESS : {etab.finess}
|
||||
</span>
|
||||
)}
|
||||
{etab.region && (
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin size={13} />
|
||||
{etab.region}
|
||||
</span>
|
||||
)}
|
||||
{etab.typeActivite && (
|
||||
<span className="bg-secondary text-secondary-foreground px-2.5 py-0.5 rounded-full text-xs font-medium border border-border">
|
||||
{etab.typeActivite}
|
||||
</span>
|
||||
)}
|
||||
{etab.tailleEffectifs && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Users size={13} />
|
||||
{etab.tailleEffectifs}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Compteur de consultation (visible référent + gestionnaire) */}
|
||||
{canSeeCounter && compteurQuery.data !== undefined && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-muted rounded-lg border border-border text-sm">
|
||||
<Eye size={15} className="text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Consultations :</span>
|
||||
<span className="font-bold text-foreground">{compteurQuery.data.count}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bouton de contact */}
|
||||
{etab.accepteMiseEnRelation && (
|
||||
<button
|
||||
onClick={() => setShowContact(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary/90 transition-colors shadow-sm"
|
||||
>
|
||||
<Mail size={15} />
|
||||
Prendre contact
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logiciels par bloc fonctionnel */}
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-foreground mb-4">Solutions numériques</h2>
|
||||
|
||||
{logicielsQuery.isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-7 w-7 border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
) : Object.keys(logicielsByBloc).length === 0 ? (
|
||||
<div className="text-center py-12 bg-card rounded-xl border border-border">
|
||||
<Server size={40} className="mx-auto text-muted-foreground/30 mb-3" />
|
||||
<p className="text-muted-foreground">Aucun logiciel renseigné pour cet établissement.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(logicielsByBloc).map(([bloc, logiciels]) => (
|
||||
<div key={bloc} className="bg-card rounded-xl border border-border shadow-sm overflow-hidden">
|
||||
{/* En-tête bloc */}
|
||||
<div className="px-5 py-3.5 bg-muted/30 border-b border-border">
|
||||
<h3 className="text-sm font-semibold text-foreground flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-primary inline-block" />
|
||||
{bloc}
|
||||
<span className="text-xs text-muted-foreground font-normal ml-1">
|
||||
({logiciels?.length} solution{(logiciels?.length ?? 0) > 1 ? "s" : ""})
|
||||
</span>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Tableau logiciels */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<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">État</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider hidden lg:table-cell">Hébergement</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider hidden xl:table-cell">Facturation</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider hidden xl:table-cell">Interop.</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{logiciels?.map((l) => (
|
||||
<tr key={l.id} className="hover:bg-muted/20 transition-colors">
|
||||
<td className="px-5 py-3.5">
|
||||
<div className="font-medium text-foreground">{l.solutionNom}</div>
|
||||
{l.versionMajeure && (
|
||||
<div className="text-xs text-muted-foreground">v{l.versionMajeure}</div>
|
||||
)}
|
||||
{l.commentaire && (
|
||||
<div className="text-xs text-muted-foreground mt-0.5 italic">{l.commentaire}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3.5 hidden md:table-cell text-muted-foreground">{l.editeurNom}</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<EtatBadge etat={l.etatDeploiement} />
|
||||
</td>
|
||||
<td className="px-4 py-3.5 hidden lg:table-cell">
|
||||
{l.modeHebergement ? <HebergementBadge mode={l.modeHebergement} /> : <span className="text-muted-foreground text-xs">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3.5 hidden xl:table-cell">
|
||||
{l.modeFacturation ? <FacturationBadge mode={l.modeFacturation} /> : <span className="text-muted-foreground text-xs">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3.5 hidden xl:table-cell">
|
||||
{l.interoperabilite ? (
|
||||
<span className="text-xs text-muted-foreground">{INTEROPERABILITE[l.interoperabilite]}</span>
|
||||
) : <span className="text-muted-foreground text-xs">—</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showContact && (
|
||||
<ContactModal
|
||||
etablissementId={id}
|
||||
etablissementNom={etab.nom}
|
||||
onClose={() => setShowContact(false)}
|
||||
/>
|
||||
)}
|
||||
</SonumLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +1,415 @@
|
||||
import { useAuth } from "@/_core/hooks/useAuth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { getLoginUrl } from "@/const";
|
||||
import { Streamdown } from 'streamdown';
|
||||
import CguModal from "@/components/CguModal";
|
||||
import { EtatBadge } from "@/components/EtatBadge";
|
||||
import SonumLayout from "@/components/SonumLayout";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import {
|
||||
ArrowUpDown,
|
||||
Building2,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Filter,
|
||||
Mail,
|
||||
MapPin,
|
||||
RotateCcw,
|
||||
Search,
|
||||
SlidersHorizontal,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { REGIONS, TYPES_ACTIVITE, TAILLES_EFFECTIFS } from "../../../shared/referentiel";
|
||||
import ContactModal from "@/components/ContactModal";
|
||||
|
||||
/**
|
||||
* All content in this page are only for example, replace with your own feature implementation
|
||||
* When building pages, remember your instructions in Frontend Workflow, Frontend Best Practices, Design Guide and Common Pitfalls
|
||||
*/
|
||||
export default function Home() {
|
||||
// The userAuth hooks provides authentication state
|
||||
// To implement login/logout functionality, simply call logout() or redirect to getLoginUrl()
|
||||
let { user, loading, error, isAuthenticated, logout } = useAuth();
|
||||
const { user, isAuthenticated } = useAuth();
|
||||
const [, navigate] = useLocation();
|
||||
|
||||
// If theme is switchable in App.tsx, we can implement theme toggling like this:
|
||||
// const { theme, toggleTheme } = useTheme();
|
||||
const [filters, setFilters] = useState({
|
||||
blocFonctionnelId: undefined as number | undefined,
|
||||
solutionId: undefined as number | undefined,
|
||||
editeurId: undefined as number | undefined,
|
||||
region: undefined as string | undefined,
|
||||
typeActivite: undefined as string | undefined,
|
||||
tailleEffectifs: undefined as string | undefined,
|
||||
});
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [contactEtab, setContactEtab] = useState<{ id: number; nom: string } | null>(null);
|
||||
const [expandedEtab, setExpandedEtab] = useState<number | null>(null);
|
||||
const [sortCol, setSortCol] = useState<"nom" | "region" | "typeActivite" | "tailleEffectifs">("nom");
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
|
||||
|
||||
const handleSort = (col: typeof sortCol) => {
|
||||
if (sortCol === col) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||
else { setSortCol(col); setSortDir("asc"); }
|
||||
};
|
||||
|
||||
const cguQuery = trpc.cgu.status.useQuery(undefined, { enabled: isAuthenticated });
|
||||
const blocsQuery = trpc.referentiel.blocsFonctionnels.useQuery();
|
||||
const editeursQuery = trpc.referentiel.editeurs.useQuery();
|
||||
const solutionsQuery = trpc.referentiel.solutions.useQuery({ search: searchText.length >= 2 ? searchText : undefined });
|
||||
const searchQuery = trpc.etablissements.search.useQuery(filters, { enabled: isAuthenticated && (cguQuery.data?.accepted ?? false) });
|
||||
const tracabiliteUtils = trpc.useUtils();
|
||||
|
||||
const recordConsultation = trpc.tracabilite.enregistrerConsultation.useMutation();
|
||||
|
||||
const sortedResults = useMemo(() => {
|
||||
if (!searchQuery.data) return [];
|
||||
return [...searchQuery.data].sort((a, b) => {
|
||||
const aVal = (a[sortCol] ?? "").toString().toLowerCase();
|
||||
const bVal = (b[sortCol] ?? "").toString().toLowerCase();
|
||||
return sortDir === "asc" ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
|
||||
});
|
||||
}, [searchQuery.data, sortCol, sortDir]);
|
||||
|
||||
const handleViewEtab = (id: number) => {
|
||||
setExpandedEtab(expandedEtab === id ? null : id);
|
||||
if (expandedEtab !== id) {
|
||||
recordConsultation.mutate({ etablissementId: id });
|
||||
}
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setFilters({ blocFonctionnelId: undefined, solutionId: undefined, editeurId: undefined, region: undefined, typeActivite: undefined, tailleEffectifs: undefined });
|
||||
setSearchText("");
|
||||
};
|
||||
|
||||
const activeFilterCount = Object.values(filters).filter(Boolean).length;
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<SonumLayout>
|
||||
<div />
|
||||
</SonumLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (cguQuery.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>
|
||||
);
|
||||
}
|
||||
|
||||
if (!cguQuery.data?.accepted) {
|
||||
return (
|
||||
<SonumLayout>
|
||||
<CguModal onAccepted={() => cguQuery.refetch()} />
|
||||
</SonumLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<main>
|
||||
{/* Example: lucide-react for icons */}
|
||||
<Loader2 className="animate-spin" />
|
||||
Example Page
|
||||
{/* Example: Streamdown for markdown rendering */}
|
||||
<Streamdown>Any **markdown** content</Streamdown>
|
||||
<Button variant="default">Example Button</Button>
|
||||
</main>
|
||||
<SonumLayout>
|
||||
<div className="p-6 lg:p-8 max-w-7xl mx-auto">
|
||||
{/* En-tête */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-foreground mb-1">Moteur de recherche SONUM</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Recherchez les établissements adhérents et leurs solutions numériques
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Panneau de filtres */}
|
||||
<div className="bg-card rounded-xl border border-border shadow-sm mb-6">
|
||||
{/* Barre de recherche principale */}
|
||||
<div className="p-5 border-b border-border">
|
||||
<div className="flex gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search size={16} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Rechercher un logiciel, un éditeur..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2.5 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-all"
|
||||
/>
|
||||
{searchText && (
|
||||
<button onClick={() => setSearchText("")} className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium border transition-all ${
|
||||
showAdvanced || activeFilterCount > 0
|
||||
? "bg-primary text-white border-primary"
|
||||
: "bg-background border-border text-foreground hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
<SlidersHorizontal size={15} />
|
||||
Filtres
|
||||
{activeFilterCount > 0 && (
|
||||
<span className="bg-white/20 text-white text-xs px-1.5 py-0.5 rounded-full font-semibold">
|
||||
{activeFilterCount}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown size={14} className={`transition-transform ${showAdvanced ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
{activeFilterCount > 0 && (
|
||||
<button
|
||||
onClick={resetFilters}
|
||||
className="flex items-center gap-1.5 px-3 py-2.5 rounded-lg text-sm text-muted-foreground hover:text-foreground hover:bg-muted border border-border transition-all"
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
Réinitialiser
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filtres avancés */}
|
||||
{showAdvanced && (
|
||||
<div className="p-5 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{/* Bloc fonctionnel */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">
|
||||
Bloc fonctionnel
|
||||
</label>
|
||||
<select
|
||||
value={filters.blocFonctionnelId ?? ""}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, blocFonctionnelId: e.target.value ? Number(e.target.value) : undefined }))}
|
||||
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary"
|
||||
>
|
||||
<option value="">Tous les blocs</option>
|
||||
{blocsQuery.data?.map((b) => (
|
||||
<option key={b.id} value={b.id}>{b.nom}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Éditeur */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">
|
||||
Éditeur
|
||||
</label>
|
||||
<select
|
||||
value={filters.editeurId ?? ""}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, editeurId: e.target.value ? Number(e.target.value) : undefined }))}
|
||||
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary"
|
||||
>
|
||||
<option value="">Tous les éditeurs</option>
|
||||
{editeursQuery.data?.map((e) => (
|
||||
<option key={e.id} value={e.id}>{e.nom}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Région */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">
|
||||
Région
|
||||
</label>
|
||||
<select
|
||||
value={filters.region ?? ""}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, region: e.target.value || undefined }))}
|
||||
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary"
|
||||
>
|
||||
<option value="">Toutes les régions</option>
|
||||
{REGIONS.map((r) => (
|
||||
<option key={r} value={r}>{r}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Type d'activité */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">
|
||||
Type d'activité
|
||||
</label>
|
||||
<select
|
||||
value={filters.typeActivite ?? ""}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, typeActivite: e.target.value || undefined }))}
|
||||
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary"
|
||||
>
|
||||
<option value="">Tous les types</option>
|
||||
{TYPES_ACTIVITE.map((t) => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Taille */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">
|
||||
Taille d'établissement
|
||||
</label>
|
||||
<select
|
||||
value={filters.tailleEffectifs ?? ""}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, tailleEffectifs: e.target.value || undefined }))}
|
||||
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary"
|
||||
>
|
||||
<option value="">Toutes les tailles</option>
|
||||
{TAILLES_EFFECTIFS.map((t) => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Résultats */}
|
||||
<div>
|
||||
{searchQuery.isLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
) : searchQuery.data && searchQuery.data.length === 0 ? (
|
||||
<div className="text-center py-16">
|
||||
<Building2 size={48} className="mx-auto text-muted-foreground/30 mb-4" />
|
||||
<p className="text-muted-foreground font-medium">Aucun établissement trouvé</p>
|
||||
<p className="text-muted-foreground text-sm mt-1">Essayez de modifier vos critères de recherche</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{searchQuery.data && (
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<span className="font-semibold text-foreground">{searchQuery.data.length}</span> établissement{searchQuery.data.length > 1 ? "s" : ""} trouvé{searchQuery.data.length > 1 ? "s" : ""}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Cliquez sur un en-tête pour trier</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-card rounded-xl border border-border shadow-sm overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/40">
|
||||
{(["nom", "region", "typeActivite", "tailleEffectifs"] as const).map((col, i) => (
|
||||
<th
|
||||
key={col}
|
||||
onClick={() => handleSort(col)}
|
||||
className={`text-left px-${i === 0 ? 5 : 4} py-3.5 font-semibold text-muted-foreground text-xs uppercase tracking-wider cursor-pointer hover:text-foreground select-none transition-colors ${
|
||||
i > 0 && i < 2 ? "hidden md:table-cell" : i >= 2 ? "hidden lg:table-cell" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{["Établissement", "Région", "Type d'activité", "Taille"][i]}
|
||||
{sortCol === col ? (
|
||||
sortDir === "asc" ? <ChevronUp size={12} /> : <ChevronDown size={12} />
|
||||
) : <ArrowUpDown size={11} className="opacity-40" />}
|
||||
</span>
|
||||
</th>
|
||||
))}
|
||||
<th className="text-right px-5 py-3.5 font-semibold text-muted-foreground text-xs uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{sortedResults.map((etab) => (
|
||||
<>
|
||||
<tr
|
||||
key={etab.id}
|
||||
className="hover:bg-muted/30 transition-colors cursor-pointer"
|
||||
onClick={() => handleViewEtab(etab.id)}
|
||||
>
|
||||
<td className="px-5 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<Building2 size={15} className="text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-foreground">{etab.nom}</div>
|
||||
{etab.finess && <div className="text-xs text-muted-foreground">FINESS : {etab.finess}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4 hidden md:table-cell">
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<MapPin size={13} />
|
||||
<span>{etab.region ?? "—"}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4 hidden lg:table-cell">
|
||||
<span className="text-muted-foreground">{etab.typeActivite ?? "—"}</span>
|
||||
</td>
|
||||
<td className="px-4 py-4 hidden lg:table-cell">
|
||||
<span className="text-muted-foreground text-xs">{etab.tailleEffectifs ?? "—"}</span>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{etab.accepteMiseEnRelation && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setContactEtab({ id: etab.id, nom: etab.nom }); }}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-primary/10 text-primary hover:bg-primary hover:text-white rounded-lg transition-all border border-primary/20"
|
||||
>
|
||||
<Mail size={12} />
|
||||
Contacter
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); navigate(`/etablissement/${etab.id}`); }}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-muted text-foreground hover:bg-secondary rounded-lg transition-all border border-border"
|
||||
>
|
||||
Voir la fiche
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{expandedEtab === etab.id && (
|
||||
<tr key={`${etab.id}-expanded`} className="bg-muted/20">
|
||||
<td colSpan={5} className="px-5 py-4">
|
||||
<LogicielsInline etablissementId={etab.id} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modale de contact */}
|
||||
{contactEtab && (
|
||||
<ContactModal
|
||||
etablissementId={contactEtab.id}
|
||||
etablissementNom={contactEtab.nom}
|
||||
onClose={() => setContactEtab(null)}
|
||||
/>
|
||||
)}
|
||||
</SonumLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function LogicielsInline({ etablissementId }: { etablissementId: number }) {
|
||||
const logicielsQuery = trpc.logiciels.byEtablissement.useQuery({ etablissementId });
|
||||
|
||||
if (logicielsQuery.isLoading) {
|
||||
return <div className="py-3 text-sm text-muted-foreground">Chargement...</div>;
|
||||
}
|
||||
|
||||
if (!logicielsQuery.data?.length) {
|
||||
return <div className="py-3 text-sm text-muted-foreground italic">Aucun logiciel renseigné pour cet établissement.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Logiciels de l'établissement</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{logicielsQuery.data.map((l) => (
|
||||
<div key={l.id} className="bg-card rounded-lg border border-border p-3">
|
||||
<div className="font-medium text-sm text-foreground mb-1">{l.solutionNom}</div>
|
||||
<div className="text-xs text-muted-foreground mb-2">{l.editeurNom}</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<EtatBadge etat={l.etatDeploiement} />
|
||||
{l.blocFonctionnelNom && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs bg-secondary text-secondary-foreground border border-border">
|
||||
{l.blocFonctionnelNom}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
248
client/src/pages/MesDemandes.tsx
Normal file
248
client/src/pages/MesDemandes.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import { useAuth } from "@/_core/hooks/useAuth";
|
||||
import SonumLayout from "@/components/SonumLayout";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import {
|
||||
Building2,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
Clock,
|
||||
Mail,
|
||||
MessageSquare,
|
||||
Send,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { STATUTS_DEMANDE } from "../../../shared/referentiel";
|
||||
|
||||
export default function MesDemandes() {
|
||||
const { user } = useAuth();
|
||||
const isGestionnaire = user?.sonumRole === "gestionnaire" || user?.role === "admin";
|
||||
|
||||
const mesDemandes = trpc.contact.mesDemandes.useQuery();
|
||||
const demandesRecues = trpc.contact.demandesRecues.useQuery();
|
||||
const toutesLesDemandes = trpc.contact.toutesLesDemandes.useQuery(undefined, {
|
||||
enabled: isGestionnaire,
|
||||
});
|
||||
|
||||
const [activeTab, setActiveTab] = useState<"envoyees" | "recues" | "toutes">("recues");
|
||||
const [replyingTo, setReplyingTo] = useState<number | null>(null);
|
||||
|
||||
const tabs = [
|
||||
{ id: "recues" as const, label: "Reçues", count: demandesRecues.data?.length ?? 0 },
|
||||
{ id: "envoyees" as const, label: "Envoyées", count: mesDemandes.data?.length ?? 0 },
|
||||
...(isGestionnaire ? [{ id: "toutes" as const, label: "Toutes (admin)", count: toutesLesDemandes.data?.length ?? 0 }] : []),
|
||||
];
|
||||
|
||||
const currentData =
|
||||
activeTab === "envoyees"
|
||||
? mesDemandes.data
|
||||
: activeTab === "toutes"
|
||||
? toutesLesDemandes.data
|
||||
: demandesRecues.data;
|
||||
|
||||
const isLoading =
|
||||
activeTab === "envoyees"
|
||||
? mesDemandes.isLoading
|
||||
: activeTab === "toutes"
|
||||
? toutesLesDemandes.isLoading
|
||||
: demandesRecues.isLoading;
|
||||
|
||||
return (
|
||||
<SonumLayout>
|
||||
<div className="p-6 lg:p-8 max-w-4xl mx-auto">
|
||||
{/* En-tête */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-foreground mb-1">Mes Demandes de Contact</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Suivez vos échanges avec les référents numériques des établissements
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Onglets */}
|
||||
<div className="flex gap-1 p-1 bg-muted rounded-xl mb-6 w-fit">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
activeTab === tab.id
|
||||
? "bg-card text-foreground shadow-sm border border-border"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.count > 0 && (
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded-full font-semibold ${
|
||||
activeTab === tab.id ? "bg-primary text-white" : "bg-border text-muted-foreground"
|
||||
}`}>
|
||||
{tab.count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Liste des demandes */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="animate-spin rounded-full h-7 w-7 border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
) : !currentData?.length ? (
|
||||
<div className="text-center py-16 bg-card rounded-xl border border-border">
|
||||
<Mail size={48} className="mx-auto text-muted-foreground/30 mb-4" />
|
||||
<p className="text-muted-foreground font-medium">Aucune demande</p>
|
||||
<p className="text-muted-foreground text-sm mt-1">
|
||||
{activeTab === "envoyees"
|
||||
? "Vous n'avez pas encore envoyé de demande de contact."
|
||||
: "Vous n'avez pas encore reçu de demande de contact."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{currentData.map((demande) => (
|
||||
<DemandeCard
|
||||
key={demande.id}
|
||||
demande={demande}
|
||||
isEnvoyee={activeTab === "envoyees"}
|
||||
isReplying={replyingTo === demande.id}
|
||||
onReply={() => setReplyingTo(replyingTo === demande.id ? null : demande.id)}
|
||||
onReplied={() => {
|
||||
setReplyingTo(null);
|
||||
demandesRecues.refetch();
|
||||
toutesLesDemandes.refetch();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SonumLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function DemandeCard({
|
||||
demande,
|
||||
isEnvoyee,
|
||||
isReplying,
|
||||
onReply,
|
||||
onReplied,
|
||||
}: {
|
||||
demande: any;
|
||||
isEnvoyee: boolean;
|
||||
isReplying: boolean;
|
||||
onReply: () => void;
|
||||
onReplied: () => void;
|
||||
}) {
|
||||
const [reponse, setReponse] = useState("");
|
||||
const repondreMutation = trpc.contact.repondre.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Réponse envoyée avec succès.");
|
||||
setReponse("");
|
||||
onReplied();
|
||||
},
|
||||
onError: (err) => toast.error("Erreur : " + err.message),
|
||||
});
|
||||
|
||||
const statusConfig: Record<string, { icon: React.ReactNode; color: string; label: string }> = {
|
||||
en_attente: { icon: <Clock size={13} />, color: "bg-amber-100 text-amber-700 border-amber-200", label: "En attente" },
|
||||
repondu: { icon: <CheckCircle2 size={13} />, color: "bg-green-100 text-green-700 border-green-200", label: "Répondu" },
|
||||
ferme: { icon: <CheckCircle2 size={13} />, color: "bg-gray-100 text-gray-600 border-gray-200", label: "Fermé" },
|
||||
};
|
||||
|
||||
const status = statusConfig[demande.statut] ?? statusConfig.en_attente;
|
||||
|
||||
return (
|
||||
<div className="bg-card rounded-xl border border-border shadow-sm overflow-hidden">
|
||||
{/* En-tête */}
|
||||
<div className="flex items-start justify-between px-5 py-4 border-b border-border">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<User size={16} className="text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-sm text-foreground">{demande.demandeurNom}</span>
|
||||
<span className="text-muted-foreground text-xs">{demande.demandeurEmail}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<Building2 size={12} className="text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">{demande.etablissementNom}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<span className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium border ${status.color}`}>
|
||||
{status.icon}
|
||||
{status.label}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(demande.createdAt).toLocaleDateString("fr-FR", { day: "2-digit", month: "short", year: "numeric" })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div className="px-5 py-4">
|
||||
<div className="flex items-start gap-2 mb-3">
|
||||
<MessageSquare size={14} className="text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<p className="text-sm text-foreground leading-relaxed">{demande.message}</p>
|
||||
</div>
|
||||
|
||||
{/* Réponse existante */}
|
||||
{demande.reponse && (
|
||||
<div className="mt-3 ml-5 pl-4 border-l-2 border-primary/30">
|
||||
<p className="text-xs font-semibold text-primary mb-1">Réponse</p>
|
||||
<p className="text-sm text-foreground leading-relaxed">{demande.reponse}</p>
|
||||
{demande.reponduAt && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{new Date(demande.reponduAt).toLocaleDateString("fr-FR", { day: "2-digit", month: "short", year: "numeric" })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{!isEnvoyee && demande.statut === "en_attente" && (
|
||||
<div className="px-5 pb-4">
|
||||
{!isReplying ? (
|
||||
<button
|
||||
onClick={onReply}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-primary/10 text-primary hover:bg-primary hover:text-white rounded-lg transition-all border border-primary/20"
|
||||
>
|
||||
<Send size={12} />
|
||||
Répondre
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<textarea
|
||||
value={reponse}
|
||||
onChange={(e) => setReponse(e.target.value)}
|
||||
placeholder="Votre réponse..."
|
||||
rows={3}
|
||||
className="w-full px-3 py-2.5 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 resize-none"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => onReply()}
|
||||
className="px-3 py-1.5 text-xs font-medium border border-border rounded-lg text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
onClick={() => repondreMutation.mutate({ id: demande.id, reponse })}
|
||||
disabled={!reponse.trim() || repondreMutation.isPending}
|
||||
className="flex items-center gap-1.5 px-4 py-1.5 text-xs font-medium bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 shadow-sm"
|
||||
>
|
||||
<Send size={12} />
|
||||
{repondreMutation.isPending ? "Envoi..." : "Envoyer la réponse"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
369
client/src/pages/MesEtablissements.tsx
Normal file
369
client/src/pages/MesEtablissements.tsx
Normal file
@@ -0,0 +1,369 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +1,24 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { AlertCircle, Home } from "lucide-react";
|
||||
import { ArrowLeft, Search } from "lucide-react";
|
||||
import { useLocation } from "wouter";
|
||||
|
||||
export default function NotFound() {
|
||||
const [, setLocation] = useLocation();
|
||||
|
||||
const handleGoHome = () => {
|
||||
setLocation("/");
|
||||
};
|
||||
|
||||
const [, navigate] = useLocation();
|
||||
return (
|
||||
<div className="min-h-screen w-full flex items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100">
|
||||
<Card className="w-full max-w-lg mx-4 shadow-lg border-0 bg-white/80 backdrop-blur-sm">
|
||||
<CardContent className="pt-8 pb-8 text-center">
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-red-100 rounded-full animate-pulse" />
|
||||
<AlertCircle className="relative h-16 w-16 text-red-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="text-4xl font-bold text-slate-900 mb-2">404</h1>
|
||||
|
||||
<h2 className="text-xl font-semibold text-slate-700 mb-4">
|
||||
Page Not Found
|
||||
</h2>
|
||||
|
||||
<p className="text-slate-600 mb-8 leading-relaxed">
|
||||
Sorry, the page you are looking for doesn't exist.
|
||||
<br />
|
||||
It may have been moved or deleted.
|
||||
</p>
|
||||
|
||||
<div
|
||||
id="not-found-button-group"
|
||||
className="flex flex-col sm:flex-row gap-3 justify-center"
|
||||
>
|
||||
<Button
|
||||
onClick={handleGoHome}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2.5 rounded-lg transition-all duration-200 shadow-md hover:shadow-lg"
|
||||
>
|
||||
<Home className="w-4 h-4 mr-2" />
|
||||
Go Home
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="text-center max-w-sm mx-auto px-6">
|
||||
<div className="w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center mx-auto mb-6">
|
||||
<Search size={32} className="text-primary" />
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold text-foreground mb-2">404</h1>
|
||||
<p className="text-muted-foreground mb-6">Cette page n'existe pas ou vous n'y avez pas accès.</p>
|
||||
<button
|
||||
onClick={() => navigate("/")}
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary/90 transition-colors shadow-sm"
|
||||
>
|
||||
<ArrowLeft size={15} />
|
||||
Retour à l'accueil
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user