Checkpoint: Pagination serveur des établissements et des messages, tri serveur, navigation utilisateur, limitation de débit sur connexion locale, messages et demandes, avec 46 tests Vitest, typage TypeScript et build validés.

This commit is contained in:
Manus
2026-08-18 07:34:59 +00:00
parent 7c97bec6c1
commit 9d9ae68b18
8 changed files with 429 additions and 51 deletions

View File

@@ -38,10 +38,13 @@ export default function Home() {
const [expandedEtab, setExpandedEtab] = useState<number | null>(null);
const [sortCol, setSortCol] = useState<"nom" | "region" | "typeActivite" | "tailleEffectifs">("nom");
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
const [page, setPage] = useState(1);
const pageSize = 25;
const handleSort = (col: typeof sortCol) => {
if (sortCol === col) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
if (sortCol === col) setSortDir((direction) => (direction === "asc" ? "desc" : "asc"));
else { setSortCol(col); setSortDir("asc"); }
setPage(1);
};
const cguQuery = trpc.cgu.status.useQuery(undefined, { enabled: isAuthenticated });
@@ -68,18 +71,21 @@ export default function Home() {
);
const solutionsQuery = trpc.referentiel.solutions.useQuery(solutionsInput);
const cguFullyAccepted = sessionCguAccepted && (cguQuery.data?.accepted ?? false);
const searchQuery = trpc.etablissements.search.useQuery(filters, { enabled: isAuthenticated && cguFullyAccepted });
const searchInput = useMemo(
() => ({ ...filters, page, pageSize, sortBy: sortCol, sortDirection: sortDir }),
[filters, page, pageSize, sortCol, sortDir]
);
const searchQuery = trpc.etablissements.search.useQuery(searchInput, { enabled: isAuthenticated && cguFullyAccepted });
const recordConsultation = trpc.tracabilite.enregistrerConsultation.useMutation();
const results = searchQuery.data?.items ?? [];
const totalPages = Math.max(1, Math.ceil((searchQuery.data?.total ?? 0) / pageSize));
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]);
useEffect(() => {
// Un changement de filtre invalide la page courante : repartir de la première page.
setPage(1);
setExpandedEtab(null);
}, [filters]);
const handleViewEtab = (id: number) => {
setExpandedEtab(expandedEtab === id ? null : id);
@@ -91,6 +97,7 @@ export default function Home() {
const resetFilters = () => {
setFilters({ blocFonctionnelId: undefined, solutionId: undefined, editeurId: undefined, region: undefined, typeActivite: undefined, tailleEffectifs: undefined });
setSearchText("");
setPage(1);
};
const activeFilterCount = Object.values(filters).filter(Boolean).length;
@@ -282,7 +289,7 @@ export default function Home() {
<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 ? (
) : searchQuery.data && searchQuery.data.total === 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>
@@ -293,7 +300,7 @@ export default function Home() {
{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" : ""}
<span className="font-semibold text-foreground">{searchQuery.data.total}</span> établissement{searchQuery.data.total > 1 ? "s" : ""} trouvé{searchQuery.data.total > 1 ? "s" : ""}
</p>
<p className="text-xs text-muted-foreground">Cliquez sur un en-tête pour trier</p>
</div>
@@ -323,7 +330,7 @@ export default function Home() {
</tr>
</thead>
<tbody className="divide-y divide-border">
{sortedResults.map((etab) => (
{results.map((etab) => (
<Fragment key={etab.id}>
<tr
className="hover:bg-muted/30 transition-colors cursor-pointer"
@@ -384,6 +391,32 @@ export default function Home() {
</tbody>
</table>
</div>
{searchQuery.data && searchQuery.data.total > pageSize && (
<div className="mt-4 flex items-center justify-between gap-4">
<p className="text-xs text-muted-foreground">
Page {page} sur {totalPages}
</p>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setPage((currentPage) => Math.max(1, currentPage - 1))}
disabled={page === 1}
className="px-3 py-2 text-sm rounded-lg border border-border bg-card hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
>
Précédent
</button>
<button
type="button"
onClick={() => setPage((currentPage) => Math.min(totalPages, currentPage + 1))}
disabled={page >= totalPages}
className="px-3 py-2 text-sm rounded-lg border border-border bg-card hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
>
Suivant
</button>
</div>
</div>
)}
</>
)}
</div>

View File

@@ -33,25 +33,29 @@ export default function MesEchanges() {
const { user } = useAuth();
const [selectedCanalId, setSelectedCanalId] = useState<number | null>(null);
const [newMessage, setNewMessage] = useState("");
const [messagePage, setMessagePage] = useState(1);
const messagesEndRef = useRef<HTMLDivElement>(null);
const canauxQuery = trpc.canaux.list.useQuery();
const messagesQuery = trpc.canaux.messages.useQuery(
{ canalId: selectedCanalId! },
{ enabled: !!selectedCanalId, refetchInterval: 5000 }
{ canalId: selectedCanalId!, page: messagePage, pageSize: 50 },
{ enabled: !!selectedCanalId, refetchInterval: messagePage === 1 ? 5000 : false }
);
const utils = trpc.useUtils();
const sendMutation = trpc.canaux.sendMessage.useMutation({
onSuccess: () => {
setNewMessage("");
utils.canaux.messages.invalidate({ canalId: selectedCanalId! });
setMessagePage(1);
utils.canaux.messages.invalidate();
},
});
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messagesQuery.data]);
if (messagePage === 1) {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}
}, [messagesQuery.data, messagePage]);
const selectedCanal = canauxQuery.data?.find((c: Canal) => c.id === selectedCanalId);
@@ -128,7 +132,7 @@ export default function MesEchanges() {
{canauxQuery.data?.map((canal: Canal) => (
<button
key={canal.id}
onClick={() => setSelectedCanalId(canal.id)}
onClick={() => { setSelectedCanalId(canal.id); setMessagePage(1); }}
className={`w-full text-left p-4 rounded-xl border transition-all ${
selectedCanalId === canal.id
? "border-primary bg-primary/5 shadow-sm"
@@ -209,6 +213,28 @@ export default function MesEchanges() {
{/* Messages */}
<div className="flex-1 overflow-y-auto p-6 space-y-4">
{(messagePage > 1 || messagesQuery.data?.hasMore) && (
<div className="flex items-center justify-center gap-2">
{messagePage > 1 && (
<button
type="button"
onClick={() => setMessagePage((page) => page - 1)}
className="px-3 py-1.5 rounded-lg text-xs border border-border hover:bg-muted"
>
Messages plus récents
</button>
)}
{messagesQuery.data?.hasMore && (
<button
type="button"
onClick={() => setMessagePage((page) => page + 1)}
className="px-3 py-1.5 rounded-lg text-xs border border-border hover:bg-muted"
>
Afficher les messages plus anciens
</button>
)}
</div>
)}
{messagesQuery.isLoading && (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
@@ -218,13 +244,13 @@ export default function MesEchanges() {
))}
</div>
)}
{!messagesQuery.isLoading && messagesQuery.data?.length === 0 && (
{!messagesQuery.isLoading && messagesQuery.data?.items.length === 0 && (
<div className="flex flex-col items-center justify-center h-full text-center">
<MessageSquare size={32} className="text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">Aucun message pour l'instant. Soyez le premier à écrire !</p>
</div>
)}
{messagesQuery.data?.map((msg: Message) => {
{messagesQuery.data?.items.map((msg: Message) => {
const isMe = msg.auteurId === user?.id;
return (
<div key={msg.id} className={`flex ${isMe ? "justify-end" : "justify-start"}`}>