218 lines
8.3 KiB
TypeScript
218 lines
8.3 KiB
TypeScript
'use client';
|
|
import { Suspense, 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, AppLoading, RatingInput } from '@/components';
|
|
import { AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager, ConfirmDialog } from '@/components/admin';
|
|
import { useAdminCapabilities, useAdminListState } 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.
|
|
*/
|
|
const DEFAULT_STATUS: ModerationStatus = 'pending_moderation';
|
|
|
|
function parseFilters(params: URLSearchParams): { status: ModerationStatus } {
|
|
const status = params.get('status') as ModerationStatus | null;
|
|
return { status: status && MODERATION_STATUSES.includes(status) ? status : DEFAULT_STATUS };
|
|
}
|
|
|
|
export default function AdminReviewsPage() {
|
|
return (
|
|
<Suspense fallback={<AppLoading />}>
|
|
<AdminReviewsPageInner />
|
|
</Suspense>
|
|
);
|
|
}
|
|
|
|
function AdminReviewsPageInner() {
|
|
const t = useTranslations('admin');
|
|
const caps = useAdminCapabilities();
|
|
const { enqueueSnackbar } = useSnackbar();
|
|
|
|
const listState = useAdminListState<{ status: ModerationStatus }>({
|
|
parse: parseFilters,
|
|
serialize: (f): Record<string, string> => (f.status !== DEFAULT_STATUS ? { status: f.status } : {}),
|
|
empty: { status: DEFAULT_STATUS },
|
|
});
|
|
const status = listState.applied.status;
|
|
const page = listState.page;
|
|
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 setStatus = (next: ModerationStatus) => listState.applyFilters({ status: next });
|
|
|
|
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)}
|
|
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={() => listState.goToPage(Math.max(1, page - 1))}
|
|
onNext={() => listState.goToPage(Math.min(pageCount, page + 1))}
|
|
prevLabel={t('prev_page')}
|
|
nextLabel={t('next_page')}
|
|
indicator={t('page_indicator', { page, total: pageCount })}
|
|
/>
|
|
|
|
<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')}>
|
|
{t('mod_publish')}
|
|
</AppButton>
|
|
<AppButton variant="outlined" color="inherit" onClick={() => onAct('hide')}>
|
|
{t('mod_hide')}
|
|
</AppButton>
|
|
<AppButton variant="outlined" color="error" onClick={() => onAct('reject')}>
|
|
{t('mod_reject')}
|
|
</AppButton>
|
|
</Stack>
|
|
) : null}
|
|
</Paper>
|
|
);
|
|
}
|