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:
@@ -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>
|
||||
|
||||
@@ -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"}`}>
|
||||
|
||||
Reference in New Issue
Block a user