xpeditis2.0/apps/frontend/app/[locale]/admin/wiki/page.tsx

267 lines
8.2 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,
expectedVersion,
body,
}: {
id: string;
expectedVersion: number;
body?: string;
}) => publishWikiContribution(id, { expectedVersion, body }),
onSuccess: page => {
toast.success(t('publishDone', { title: page.title }));
refresh();
},
onError: () => toast.error(t('publishFailed')),
});
const reject = useMutation({
mutationFn: ({
id,
expectedVersion,
note,
}: {
id: string;
expectedVersion: number;
note?: string;
}) => rejectWikiContribution(id, expectedVersion, 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.version}`}
page={page}
locale={locale}
busy={publish.isPending || reject.isPending}
onPublish={(id, body) => publish.mutate({ id, expectedVersion: page.version, body })}
onReject={(id, note) => reject.mutate({ id, expectedVersion: page.version, 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',
};