frontend phase 15
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import { Box, Chip, MenuItem, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
|
||||
import { AppButton, RatingInput } from '@/components';
|
||||
import { AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager, ConfirmDialog } from '@/components/admin';
|
||||
import { useAdminCapabilities } from '@/hooks';
|
||||
import { REVIEWS_PAGE_SIZE } from '@/services/reviews/constants';
|
||||
import type { ModerationAction, ModerationQueueItem, ModerationStatus } from '@/services/reviews/types';
|
||||
import { useModerationQueue, useModerateReview } from '@/services/reviews';
|
||||
|
||||
/** The moderation worklist tabs — the four `moderationStatus` values; the queue defaults to the pending backlog. */
|
||||
const MODERATION_STATUSES: readonly ModerationStatus[] = ['pending_moderation', 'published', 'hidden', 'rejected'];
|
||||
|
||||
/** Status → MUI chip color. **Never** styles `pending_moderation` like a published review (it reads as a warning). */
|
||||
const STATUS_CHIP_COLOR: Record<ModerationStatus, 'default' | 'success' | 'warning' | 'error'> = {
|
||||
pending_moderation: 'warning',
|
||||
published: 'success',
|
||||
hidden: 'default',
|
||||
rejected: 'error',
|
||||
};
|
||||
|
||||
/**
|
||||
* Review moderation queue (f15) — the admin worklist over `reviews` awaiting a decision (b14). Each row carries
|
||||
* moderation internals (`lowRatingAlertId`, the nurse/booking context) that are **never** rendered on a
|
||||
* customer/nurse surface. A review is born `pending_moderation` and is never public / never counted until an
|
||||
* admin publishes it, so the card presents pending content as an under-review item, not as a published review.
|
||||
* Publishing recomputes the nurse aggregate **server-side**; the mutation invalidates the queue so the row
|
||||
* leaves on success. `canModerate` only hides the controls — the server enforces the role scope.
|
||||
*/
|
||||
export default function AdminReviewsPage() {
|
||||
const t = useTranslations('admin');
|
||||
const caps = useAdminCapabilities();
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
|
||||
const [status, setStatus] = useState<ModerationStatus>('pending_moderation');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pending, setPending] = useState<{ item: ModerationQueueItem; action: ModerationAction } | null>(null);
|
||||
|
||||
const queue = useModerationQueue({ status }, page);
|
||||
const moderate = useModerateReview();
|
||||
|
||||
const items = queue.data?.items ?? [];
|
||||
const pageCount = Math.max(1, Math.ceil((queue.data?.total ?? 0) / REVIEWS_PAGE_SIZE));
|
||||
|
||||
const requireReason = pending?.action === 'hide' || pending?.action === 'reject';
|
||||
|
||||
const onConfirm = (reason?: string) => {
|
||||
if (!pending) return;
|
||||
moderate.mutate(
|
||||
{ reviewId: pending.item.id, action: pending.action, reason },
|
||||
{
|
||||
onSuccess: () => {
|
||||
enqueueSnackbar(t('mod_done'), { variant: 'success' });
|
||||
setPending(null);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<AdminPageHeader
|
||||
title={t('mod_title')}
|
||||
subtitle={t('mod_subtitle')}
|
||||
actions={
|
||||
<TextField
|
||||
select
|
||||
size="small"
|
||||
label={t('filter_label')}
|
||||
value={status}
|
||||
onChange={(e) => {
|
||||
setStatus(e.target.value as ModerationStatus);
|
||||
setPage(1);
|
||||
}}
|
||||
sx={{ minWidth: 180 }}
|
||||
>
|
||||
{MODERATION_STATUSES.map((s) => (
|
||||
<MenuItem key={s} value={s}>
|
||||
{t(`mstatus_${s}`)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
}
|
||||
/>
|
||||
|
||||
{queue.isLoading ? (
|
||||
<Stack sx={{ gap: 2 }}>{[0, 1, 2].map((k) => <Skeleton key={k} variant="rounded" height={150} />)}</Stack>
|
||||
) : queue.isError ? (
|
||||
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => queue.refetch()} />
|
||||
) : items.length === 0 ? (
|
||||
<AdminEmptyState icon="moderation" title={t('mod_empty')} />
|
||||
) : (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
{items.map((item) => (
|
||||
<ModerationCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
canModerate={caps.canModerate}
|
||||
onAct={(action) => setPending({ item, action })}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<AdminPager
|
||||
page={page}
|
||||
pageCount={pageCount}
|
||||
onPrev={() => setPage((p) => Math.max(1, p - 1))}
|
||||
onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
|
||||
prevLabel={t('prev_page')}
|
||||
nextLabel={t('next_page')}
|
||||
indicator={t('page_indicator', { page })}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={pending != null}
|
||||
title={pending ? t(`mod_${pending.action}`) : ''}
|
||||
body={pending ? t(`mod_confirm_${pending.action}`) : undefined}
|
||||
confirmLabel={pending ? t(`mod_${pending.action}`) : t('confirm')}
|
||||
cancelLabel={t('cancel')}
|
||||
confirmColor={pending?.action === 'reject' ? 'error' : 'primary'}
|
||||
loading={moderate.isPending}
|
||||
requireReason={requireReason}
|
||||
reasonLabel={t('reason_label')}
|
||||
onConfirm={onConfirm}
|
||||
onClose={() => setPending(null)}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** One review awaiting a decision. Presentational; the actions bubble up to the page-level confirm dialog. */
|
||||
function ModerationCard({
|
||||
item,
|
||||
canModerate,
|
||||
onAct,
|
||||
}: {
|
||||
item: ModerationQueueItem;
|
||||
canModerate: boolean;
|
||||
onAct: (action: ModerationAction) => void;
|
||||
}) {
|
||||
const t = useTranslations('admin');
|
||||
const tReviews = useTranslations('reviews');
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2, display: 'flex', flexDirection: 'column', gap: 1.5 }}
|
||||
>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<RatingInput value={item.rating} readOnly size={20} ariaLabel={t('mod_col_rating')} />
|
||||
<Chip size="small" color={STATUS_CHIP_COLOR[item.moderationStatus]} label={t(`mstatus_${item.moderationStatus}`)} />
|
||||
{item.lowRatingAlertId != null ? (
|
||||
<Chip size="small" color="warning" variant="outlined" label={t('mod_low_rating')} />
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
{item.body ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.primary' }}>
|
||||
{item.body}
|
||||
</Typography>
|
||||
) : null}
|
||||
|
||||
{item.tagCodes.length > 0 ? (
|
||||
<Stack direction="row" sx={{ gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{item.tagCodes.map((code) => (
|
||||
<Chip key={code} size="small" variant="outlined" label={tReviews(`tag_${code}`)} />
|
||||
))}
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
<Stack direction="row" sx={{ gap: 2, flexWrap: 'wrap' }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('mod_nurse', { id: item.nurseProfileId })}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('mod_booking', { id: item.bookingId })}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
{canModerate ? (
|
||||
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
|
||||
<AppButton variant="contained" color="primary" startIcon="publish" onClick={() => onAct('publish')} sx={{ m: 0 }}>
|
||||
{t('mod_publish')}
|
||||
</AppButton>
|
||||
<AppButton variant="outlined" color="inherit" onClick={() => onAct('hide')} sx={{ m: 0 }}>
|
||||
{t('mod_hide')}
|
||||
</AppButton>
|
||||
<AppButton variant="outlined" color="error" onClick={() => onAct('reject')} sx={{ m: 0 }}>
|
||||
{t('mod_reject')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
) : null}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user