feat(admin): paginacao na interface, edicao de utilizadores e pagina de testes

- fila e utilizadores passam a pedir uma pagina de cada vez, com pesquisa e
  filtros; nada e cortado no browser, porque o que faltava nunca chegou a ser
  buscado. A pesquisa espera que se pare de escrever: cada tecla seria mais
  uma consulta, e as respostas podem chegar fora de ordem
- pageWindow e pageRange tem testes proprios: uma pagina fora do intervalo vem
  de um URL velho ou de uma lista que encolheu, e nao pode dar um paginador
  vazio ou negativo, nem dizer "76 a 100 de 82"
- editar utilizador: papel, quota, idioma, com os apelidos que entregam
  naquela caixa a mostra no mesmo painel
- "Caixas de e-mail" passa a chamar-se Utilizadores, que e o que sao

Pagina nova /admin/tests: verificacoes que o operador corre a mao, sobre o
servidor ou sobre um utilizador. Todas leem estado; nenhuma envia correio nem
altera definicoes -- uma pagina de diagnostico que mexe naquilo que
diagnostica e pior do que nao existir.

As regras de cada verificacao sao funcoes puras com testes, separadas da
recolha dos factos. Alguns casos que estes testes fixam: uma quota zero
significa sem limite, e um racio marcaria todas as caixas ilimitadas como
cheias; um bloqueio ja expirado nao bloqueia ninguem e nao deve mandar
ninguem investigar; e uma resposta de ausencia ligada com o periodo ja
passado esta ligada sem fazer nada, que e o que parece avariado a quem a pos.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Opus 5 committed 2026-08-24 09:36:35 -03:00
1 parent 52bf43b74f
commit 2b92abd185
16 files changed
+1431 -153

No files matched your search

+50
View File
@@ -0,0 +1,50 @@
import { describe, it, expect } from "vitest";
import { pageWindow, pageRange } from "./paging";
describe("pageWindow", () => {
it("shows every page when there are few", () => {
expect(pageWindow(1, 3)).toEqual([1, 2, 3]);
});
it("follows the current page without running past the ends", () => {
expect(pageWindow(10, 20)).toEqual([8, 9, 10, 11, 12]);
expect(pageWindow(1, 20)).toEqual([1, 2, 3]);
expect(pageWindow(20, 20)).toEqual([18, 19, 20]);
});
// A page number out of range comes back from a stale URL or a list that
// shrank under the reader; it must not produce an empty or negative pager.
it.each([
[0, 5],
[-3, 5],
[99, 5],
[Number.NaN, 5],
])("stays inside the range for page=%s of %s", (page, total) => {
const w = pageWindow(page, total);
expect(w.length).toBeGreaterThan(0);
for (const p of w) {
expect(p).toBeGreaterThanOrEqual(1);
expect(p).toBeLessThanOrEqual(total);
}
});
it("survives having no pages at all", () => {
expect(pageWindow(1, 0)).toEqual([1]);
});
});
describe("pageRange", () => {
it("describes the rows on this page", () => {
expect(pageRange(1, 25, 100)).toEqual({ from: 1, to: 25 });
expect(pageRange(4, 25, 100)).toEqual({ from: 76, to: 100 });
});
// The last page is usually short; saying "76 to 100 of 82" would be wrong.
it("stops at the total on a partial last page", () => {
expect(pageRange(4, 25, 82)).toEqual({ from: 76, to: 82 });
});
it("says nothing when there is nothing", () => {
expect(pageRange(1, 25, 0)).toEqual({ from: 0, to: 0 });
});
});
+24
View File
@@ -0,0 +1,24 @@
/**
* The page numbers a pager should offer.
*
* Rendering every page is fine for three and unusable for four hundred, so the
* window follows the current page and the ends stay reachable.
*/
export function pageWindow(page: number, totalPages: number, span = 2): number[] {
if (!Number.isFinite(totalPages) || totalPages < 1) return [1];
const current = Math.min(Math.max(Math.trunc(page) || 1, 1), totalPages);
const first = Math.max(1, current - span);
const last = Math.min(totalPages, current + span);
const pages: number[] = [];
for (let p = first; p <= last; p++) pages.push(p);
return pages;
}
/** The human sentence under a list: which rows these are, out of how many. */
export function pageRange(page: number, pageSize: number, total: number): { from: number; to: number } {
if (total <= 0) return { from: 0, to: 0 };
const from = (page - 1) * pageSize + 1;
return { from, to: Math.min(total, page * pageSize) };
}
+21
View File
@@ -0,0 +1,21 @@
"use client";
import { useEffect, useState } from "react";
/**
* Holds a value still until typing stops.
*
* Every keystroke in a search box would otherwise be one more query against
* the database, and the answers can arrive out of order - so the list flickers
* between results for "an", "ana" and "a".
*/
export function useDebounced<T>(value: T, delayMs = 300): T {
const [settled, setSettled] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setSettled(value), delayMs);
return () => clearTimeout(timer);
}, [value, delayMs]);
return settled;
}