frontend phase 15

This commit is contained in:
hamid
2026-07-10 20:28:06 +03:30
parent bc51cf59b4
commit 70cf00ce4a
151 changed files with 10711 additions and 44 deletions
@@ -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>
);
}