267 lines
8.2 KiB
TypeScript
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',
|
|
};
|