batchCooking/apps/web/src/pages/admin/messages/MessagesPage.tsx
Nicolas 712772dc02
All checks were successful
CI / lint (push) Successful in 3m52s
CI / build (push) Successful in 4m5s
CI / intent-service-test (push) Successful in 5m11s
CI / e2e (push) Successful in 8m11s
CI / test (push) Successful in 21m19s
CI / build-android (push) Has been skipped
feat(admin): consultation des messages de contact (#80)
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>
2026-09-08 08:34:36 +02:00

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>
);
}