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:
1 parent
52bf43b74f
commit
2b92abd185
16 files changed
+1431
-153
No files matched your search
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { RefreshCw, Search } from "lucide-react";
|
||||
import { useTranslations } from "../../../../i18n/provider";
|
||||
import { Badge } from "../../../../components/ui/Badge";
|
||||
import { Button } from "../../../../components/ui/Button";
|
||||
import { Pagination } from "../../../../components/ui/Pagination";
|
||||
import { useDebounced } from "../../../../lib/useDebounced";
|
||||
|
||||
/**
|
||||
* The outbound queue, read from the database.
|
||||
@@ -33,6 +35,10 @@ interface QueueJob {
|
||||
interface QueueSummary {
|
||||
by_status: Record<string, number>;
|
||||
recent: QueueJob[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,10 +61,28 @@ export default function AdminQueuePage() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Filtering and paging happen in the database. Holding them here and
|
||||
// slicing in the browser would still have fetched only the first page,
|
||||
// which is the whole reason a backed-up queue could not be inspected.
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const debouncedSearch = useDebounced(search, 300);
|
||||
|
||||
// A new search belongs on page one; keeping the old page number lands the
|
||||
// reader on an empty page of a smaller result set.
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [debouncedSearch, status]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/v1/admin/queue");
|
||||
const params = new URLSearchParams({ page: String(page), page_size: "25" });
|
||||
if (debouncedSearch) params.set("search", debouncedSearch);
|
||||
if (status) params.set("status", status);
|
||||
|
||||
const res = await fetch(`/api/v1/admin/queue?${params.toString()}`);
|
||||
if (!res.ok) throw new Error();
|
||||
setSummary(await res.json());
|
||||
setError(null);
|
||||
@@ -70,7 +94,7 @@ export default function AdminQueuePage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
}, [t, page, debouncedSearch, status]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
@@ -134,6 +158,33 @@ export default function AdminQueuePage() {
|
||||
<div className="rounded-xl bg-rose-900/60 px-4 py-3 text-[12.5px] text-rose-200 shadow-edge">{error}</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
<div className="relative min-w-[220px] flex-1">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-500" />
|
||||
<input
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t("ui.searchPlaceholder")}
|
||||
aria-label={t("ui.searchPlaceholder")}
|
||||
className="w-full rounded-[10px] border border-white/[0.14] bg-transparent py-2 pl-9 pr-3 text-[13px] text-slate-100 placeholder:text-slate-500"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
aria-label={t("common.status")}
|
||||
className="rounded-[10px] border border-white/[0.14] bg-dark-panel px-3 py-2 text-[13px] text-slate-100"
|
||||
>
|
||||
<option value="">{t("ui.all")}</option>
|
||||
{/* Taken from the aggregate, so the choices are the states this
|
||||
queue actually contains rather than a list invented here. */}
|
||||
{Object.keys(summary?.by_status ?? {}).sort().map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-dark-panel px-[18px] pb-3 pt-1.5 shadow-edge">
|
||||
{jobs.length === 0 && !loading ? (
|
||||
<div className="p-8 text-center text-[13px] text-slate-400">{t("admin.queueEmpty")}</div>
|
||||
@@ -186,6 +237,15 @@ export default function AdminQueuePage() {
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Pagination
|
||||
page={summary?.page ?? 1}
|
||||
pageSize={summary?.page_size ?? 25}
|
||||
total={summary?.total ?? 0}
|
||||
totalPages={summary?.total_pages ?? 1}
|
||||
onPage={setPage}
|
||||
busy={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user