xpeditis2.0/apps/frontend/app/[locale]/admin/wiki/page.tsx
David c35f3d7bfb feat(ia): fait valider par un administrateur toute page ecrite par l'assistant
L'assistant publiait directement dans le wiki global. La politique de contenu
ecarte la faute franche — conseil FCL, cas client, hors perimetre — mais une
heuristique ne juge pas la justesse : une page fausse mais bien ecrite la
franchissait, et se retrouvait citee comme documentation Xpeditis aupres de
tous les clients.

Domaine
- `WikiContributionStatus` : pending / published / rejected. `create()` ne
  prend pas le statut en parametre — rien ne nait publie.
- `publish(reviewerId, edits?)` valide, en acceptant une correction du
  relecteur, qui repasse la meme politique de contenu.
- `reject(reviewerId, note?)` ecarte sans supprimer : la liste des refus montre
  ou l'assistant se trompe.
- `revise()` remet une page validee en attente : sans cela, la validation
  porterait sur un texte que l'assistant a remplace depuis.

Ce qui sort du depot
- `findPublished` pour la recherche et la page publique, `findForReview` pour
  l'administration. Le statut est porte par la requete, pas filtre en memoire :
  une page en attente ne peut pas sortir par le chemin des clients.
- `revision()` ne compte que le publie, donc l'index vectoriel ne se reconstruit
  que sur une decision.

Relecture
- `GET /admin/wiki-contributions`, `POST :id/publish`, `POST :id/reject`, sous
  JwtAuthGuard + RolesGuard et @Roles('admin').
- Chaque decision est journalisee (`WIKI_CONTRIBUTION_REVIEWED`) : une page
  publiee engage la marque, on doit pouvoir dire qui l'a laissee passer.
- Ecran `/admin/wiki` : file par statut, corps complet affiche, correction et
  motif de refus facultatifs.

L'assistant
- La capacite rend `pending_review` et un message d'attente, plus d'URL : le
  modele annonce une proposition, jamais une publication. La consigne le lui
  dit explicitement.

SQL de la migration verifie sur la base locale : l'upsert sur index
d'expression met bien a jour a la casse pres, la contrainte de statut refuse
une valeur inconnue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 12:41:28 +02:00

252 lines
8.0 KiB
TypeScript

'use client';
import * as React from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useLocale, useTranslations } from 'next-intl';
import { BookOpen, Check, Pencil, RefreshCw, X } from 'lucide-react';
import {
getWikiReviewQueue,
publishWikiContribution,
rejectWikiContribution,
type WikiContributionReview,
type WikiContributionStatus,
} from '@/lib/api/trade-assistant';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Callout } from '@/components/ui/callout';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { PageHeader } from '@/components/ui/PageHeader';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea';
import { EmptyState, ErrorState, PageSpinner } from '@/components/ui/states';
import { useToast } from '@/components/ui/toast';
/**
* Relecture des pages que l'assistant propose au wiki.
*
* Le wiki est global : une page validee devient visible pour tous les clients
* et citable par l'assistant dans toutes ses reponses suivantes. L'ecran est
* donc construit pour qu'on lise avant de decider — le corps complet est
* affiche, pas un extrait — et pour qu'on puisse corriger au passage plutot
* que de renvoyer une page presque juste.
*/
export default function WikiReviewPage() {
const t = useTranslations('admin.wikiReview');
const locale = useLocale();
const { toast } = useToast();
const queryClient = useQueryClient();
const [filter, setFilter] = React.useState<WikiContributionStatus>('pending');
const queue = useQuery({
queryKey: ['wiki-review', filter],
queryFn: () => getWikiReviewQueue(filter),
});
const refresh = () => {
queryClient.invalidateQueries({ queryKey: ['wiki-review'] });
// La page publique et la recherche lisent le meme jeu : une decision les
// perime toutes les deux.
queryClient.invalidateQueries({ queryKey: ['wiki-complements'] });
};
const publish = useMutation({
mutationFn: ({ id, body }: { id: string; body?: string }) =>
publishWikiContribution(id, body ? { body } : undefined),
onSuccess: page => {
toast.success(t('publishDone', { title: page.title }));
refresh();
},
onError: () => toast.error(t('publishFailed')),
});
const reject = useMutation({
mutationFn: ({ id, note }: { id: string; note?: string }) => rejectWikiContribution(id, note),
onSuccess: page => {
toast.success(t('rejectDone', { title: page.title }));
refresh();
},
onError: () => toast.error(t('rejectFailed')),
});
if (queue.isPending) return <PageSpinner />;
if (queue.isError) {
return (
<ErrorState
title={t('errorTitle')}
description={t('errorBody')}
onRetry={() => queue.refetch()}
/>
);
}
const pages = queue.data.contributions;
return (
<>
<PageHeader
title={t('title')}
description={t('description')}
actions={
<Button variant="outline" onClick={() => queue.refetch()}>
<RefreshCw />
{t('refresh')}
</Button>
}
/>
<div className="flex flex-col gap-4">
<Callout variant="info" title={t('noticeTitle')}>
{t('noticeBody')}
</Callout>
<Tabs value={filter} onValueChange={value => setFilter(value as WikiContributionStatus)}>
<TabsList>
<TabsTrigger value="pending">{t('tabs.pending')}</TabsTrigger>
<TabsTrigger value="published">{t('tabs.published')}</TabsTrigger>
<TabsTrigger value="rejected">{t('tabs.rejected')}</TabsTrigger>
</TabsList>
</Tabs>
{pages.length === 0 ? (
<EmptyState icon={BookOpen} title={t(`empty.${filter}`)} />
) : (
pages.map(page => (
<ReviewCard
key={page.id}
page={page}
locale={locale}
busy={publish.isPending || reject.isPending}
onPublish={(id, body) => publish.mutate({ id, body })}
onReject={(id, note) => reject.mutate({ id, note })}
/>
))
)}
</div>
</>
);
}
/* -------------------------------------------------------------------------- */
interface ReviewCardProps {
page: WikiContributionReview;
locale: string;
busy: boolean;
onPublish: (id: string, body?: string) => void;
onReject: (id: string, note?: string) => void;
}
/**
* Une proposition, lisible en entier.
*
* Deux champs facultatifs, jamais ouverts par defaut : la correction du corps
* et le motif du refus. Les afficher en permanence ferait de la relecture une
* saisie ; les cacher derriere un bouton garde l'ecran sur la lecture.
*/
function ReviewCard({ page, locale, busy, onPublish, onReject }: ReviewCardProps) {
const t = useTranslations('admin.wikiReview');
const [body, setBody] = React.useState<string | null>(null);
const [note, setNote] = React.useState<string | null>(null);
const editing = body !== null;
const pending = page.status === 'pending';
const date = (value: string) =>
new Date(value).toLocaleString(locale === 'fr' ? 'fr-FR' : 'en-GB', {
dateStyle: 'medium',
timeStyle: 'short',
});
return (
<Card>
<CardHeader className="gap-2 pb-3">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={STATUS_VARIANT[page.status]}>{t(`status.${page.status}`)}</Badge>
<Badge variant="neutral">{page.topic}</Badge>
<Badge variant="neutral">{page.locale.toUpperCase()}</Badge>
</div>
<CardTitle>{page.title}</CardTitle>
<p className="text-body-sm text-neutral-500">{page.section}</p>
</CardHeader>
<CardContent className="space-y-4">
{editing ? (
<Textarea
aria-label={t('editLabel')}
rows={12}
value={body}
onChange={event => setBody(event.target.value)}
/>
) : (
page.body
.split(/\n+/)
.filter(Boolean)
.map((paragraph, index) => (
<p key={index} className="text-body-sm leading-6 text-neutral-700">
{paragraph}
</p>
))
)}
<p className="text-body-xs text-neutral-500">
{t('proposedOn', { date: date(page.createdAt) })}
{page.reviewedBy
? ` — ${t('reviewedBy', { who: page.reviewedBy, date: date(page.reviewedAt ?? page.updatedAt) })}`
: ''}
</p>
{page.reviewNote && (
<p className="text-body-sm text-neutral-600">
{t('noteLabel')} {page.reviewNote}
</p>
)}
{note !== null && (
<Textarea
aria-label={t('noteLabel')}
rows={2}
placeholder={t('notePlaceholder')}
value={note}
onChange={event => setNote(event.target.value)}
/>
)}
{pending && (
<div className="flex flex-wrap gap-2">
<Button disabled={busy} onClick={() => onPublish(page.id, editing ? body : undefined)}>
<Check />
{t('publish')}
</Button>
<Button
variant="outline"
disabled={busy}
onClick={() => (note === null ? setNote('') : onReject(page.id, note || undefined))}
>
<X />
{note === null ? t('reject') : t('confirmReject')}
</Button>
<Button
variant="ghost"
disabled={busy}
onClick={() => setBody(editing ? null : page.body)}
>
<Pencil />
{editing ? t('cancelEdit') : t('edit')}
</Button>
</div>
)}
</CardContent>
</Card>
);
}
const STATUS_VARIANT: Record<WikiContributionStatus, 'warning' | 'success' | 'danger'> = {
pending: 'warning',
published: 'success',
rejected: 'danger',
};