All checks were successful
PR 2/2 de l'issue #80 — la vue admin en lecture seule des messages remontés via le formulaire de contact (PR 1 = #123). - Contrat partagé : `listContactMessagesQuerySchema` (filtre `type` optionnel) + `types/contact.ts` `ContactMessageAdminView` (`author` = `{ id, firstName, lastName, email } | null`, `null` = compte supprimé). - API : `admin-contact.{routes,service}.ts` — `GET /admin/api/contact?type=` (`requireAdmin`), tri `createdAt desc`, plafond `CONTACT_MESSAGES_PAGE_SIZE = 200` (même esprit que `CORRECTIONS_PAGE_SIZE`). Branché dans `admin.routes.ts` (`adminRouter.use(/contact, …)`). Aucune route d'écriture. - Front : page `/admin/messages` (`MessagesPage` + `.scss`) — tableau lecture seule (date, expéditeur ou « Compte supprimé », badge de type, message, page, user-agent tronqué + titre au survol), un filtre par type. Entrée de nav « Messages » (icône `Inbox`) dans `AdminLayout` + `adminApiClient.getContactMessages` + i18n `admin.nav.messages` / `admin.messages.*`. - Tests : `apps/api/test/admin-contact.test.ts` (Mocha — 401 sans session admin, tri récent d'abord + `author` résolu, filtre `type`, type inconnu → 400, plafond 200, `author: null` après suppression du compte), `apps/web/cypress/e2e/admin-messages.cy.ts` (rendu lecture seule, filtre par type via le query param). - Docs : `backend-architecture.md` (section `admin` + event `contact.submitted` dans la liste). Closes #80. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
118 lines
4.3 KiB
TypeScript
118 lines
4.3 KiB
TypeScript
import { CONTACT_MESSAGE_TYPES, type ContactMessageAdminView } from "@batch-cooking/shared";
|
|
import { useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { adminApiClient } from "../../../api/admin-client";
|
|
import "../admin-page.scss";
|
|
import "./messages-page.scss";
|
|
|
|
type MessagesState =
|
|
| { status: "loading" }
|
|
| { status: "loaded"; rows: ContactMessageAdminView[] }
|
|
| { status: "error" };
|
|
|
|
/**
|
|
* Contact-form messages (issue #80) — a read-only list of the bug reports /
|
|
* suggestions users have sent from `/parametres/contact`, newest first,
|
|
* capped server-side at 200. One optional filter (by `type`); no actions,
|
|
* no triage workflow in this first version.
|
|
*/
|
|
export function MessagesPage() {
|
|
const { t } = useTranslation();
|
|
const [type, setType] = useState("");
|
|
const [state, setState] = useState<MessagesState>({ status: "loading" });
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
setState({ status: "loading" });
|
|
adminApiClient
|
|
.getContactMessages({ type: type || undefined })
|
|
.then((rows) => {
|
|
if (!cancelled) setState({ status: "loaded", rows });
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) setState({ status: "error" });
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [type]);
|
|
|
|
return (
|
|
<div className="admin-page">
|
|
<h1 className="admin-page__title">{t("admin.messages.title")}</h1>
|
|
<p className="admin-page__lead">{t("admin.messages.lead")}</p>
|
|
|
|
<div className="messages-filters">
|
|
<label>
|
|
{t("admin.messages.filter.type")}
|
|
<select value={type} onChange={(event) => setType(event.target.value)}>
|
|
<option value="">{t("admin.messages.filter.any")}</option>
|
|
{CONTACT_MESSAGE_TYPES.map((option) => (
|
|
<option key={option} value={option}>
|
|
{t(`admin.messages.type.${option}`)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
|
|
{state.status === "loading" && (
|
|
<p className="admin-page__placeholder">{t("admin.common.loading")}</p>
|
|
)}
|
|
{state.status === "error" && (
|
|
<p className="admin-page__placeholder">{t("admin.common.loadError")}</p>
|
|
)}
|
|
{state.status === "loaded" && state.rows.length === 0 && (
|
|
<p className="admin-page__placeholder">{t("admin.messages.empty")}</p>
|
|
)}
|
|
{state.status === "loaded" && state.rows.length > 0 && (
|
|
<div className="messages-table-wrap">
|
|
<table className="messages-table">
|
|
<thead>
|
|
<tr>
|
|
<th>{t("admin.messages.col.date")}</th>
|
|
<th>{t("admin.messages.col.sender")}</th>
|
|
<th>{t("admin.messages.col.type")}</th>
|
|
<th>{t("admin.messages.col.message")}</th>
|
|
<th>{t("admin.messages.col.page")}</th>
|
|
<th>{t("admin.messages.col.userAgent")}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{state.rows.map((row) => (
|
|
<tr key={row.id}>
|
|
<td className="messages-table__date">
|
|
{new Date(row.createdAt).toLocaleString("fr-FR")}
|
|
</td>
|
|
<td>
|
|
{row.author ? (
|
|
<>
|
|
{row.author.firstName} {row.author.lastName}
|
|
<br />
|
|
<span className="messages-table__email">{row.author.email}</span>
|
|
</>
|
|
) : (
|
|
<span className="messages-table__deleted">
|
|
{t("admin.messages.deletedAuthor")}
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td>
|
|
<span className={`messages-badge messages-badge--${row.type}`}>
|
|
{t(`admin.messages.type.${row.type}`)}
|
|
</span>
|
|
</td>
|
|
<td className="messages-table__message">{row.message}</td>
|
|
<td className="messages-table__page">{row.pageUrl ?? "—"}</td>
|
|
<td className="messages-table__ua" title={row.userAgent ?? undefined}>
|
|
{row.userAgent ?? "—"}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|