fix: reviews — correct pending/archive logic, rejected_at field, stats, reply email

- Migration 085: add rejected_at to reviews
- pending = !isPublic && !reply && !rejectedAt
- archived = isPublic || reply || rejectedAt
- Stats: avgRating/NPS from all reviews; 'В архиве' = archived count
- sendReply: no longer sets isPublic=true on negative reviews
- reject: sets rejected_at=NOW() (distinct from pending)
- restore: clears rejected_at (now actually works)
- Badge 'Отклонён' for rejected reviews, opacity-60

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-21 10:33:01 +03:00
parent 89e919bd8d
commit 47215c7d64
4 changed files with 37 additions and 26 deletions

View File

@@ -0,0 +1,2 @@
-- Migration 085 — add rejected_at to reviews for explicit rejected state
ALTER TABLE reviews ADD COLUMN IF NOT EXISTS rejected_at TIMESTAMPTZ;

View File

@@ -37,7 +37,7 @@ const reviewsRoutes: FastifyPluginAsync = async (fastify) => {
const { rows } = await db.query(
`SELECT r.id, r.booking_id, r.guest_id, r.guest_name, r.source,
r.rating, r.text, r.reply, r.replied_at, r.is_public,
r.rating, r.text, r.reply, r.replied_at, r.is_public, r.rejected_at,
r.created_at, r.updated_at,
b.room_id,
rm.number AS room_number,
@@ -54,7 +54,7 @@ const reviewsRoutes: FastifyPluginAsync = async (fastify) => {
)
// ── PATCH /api/hotels/:slug/reviews/:id ───────────────────────────────────
fastify.patch<SlugIdParam & { Body: { reply?: string; isPublic?: boolean } }>(
fastify.patch<SlugIdParam & { Body: { reply?: string; isPublic?: boolean; rejected?: boolean } }>(
'/api/hotels/:slug/reviews/:id',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
@@ -65,7 +65,7 @@ const reviewsRoutes: FastifyPluginAsync = async (fastify) => {
const hotelId = await getHotelId(slug)
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
const { reply: replyText, isPublic } = request.body
const { reply: replyText, isPublic, rejected } = request.body
const updates: string[] = []
const vals: unknown[] = [id, hotelId]
@@ -77,6 +77,9 @@ const reviewsRoutes: FastifyPluginAsync = async (fastify) => {
vals.push(isPublic)
updates.push(`is_public = $${vals.length}`)
}
if (rejected === true) updates.push(`rejected_at = NOW()`)
if (rejected === false) updates.push(`rejected_at = NULL`)
if (updates.length === 0) return reply.code(400).send({ error: 'Nothing to update' })
const { rows } = await db.query(

View File

@@ -482,7 +482,7 @@ export const api = {
list: (slug: string) =>
req<ReviewApi[]>('GET', `/api/hotels/${slug}/reviews`),
update: (slug: string, id: string, data: { reply?: string; isPublic?: boolean }) =>
update: (slug: string, id: string, data: { reply?: string; isPublic?: boolean; rejected?: boolean }) =>
req<ReviewApi>('PATCH', `/api/hotels/${slug}/reviews/${id}`, data),
},
@@ -1874,6 +1874,7 @@ export interface ReviewApi {
reply: string | null
repliedAt: string | null
isPublic: boolean
rejectedAt: string | null
roomNumber: string | null
bookingSource: string | null
createdAt: string

View File

@@ -173,7 +173,8 @@ export function ReviewsPage() {
const sendReply = async (id: string) => {
if (!replyText.trim() || !slug) return
const updated = await api.reviews.update(slug, id, { reply: replyText.trim(), isPublic: true })
// Negative review stays private (isPublic=false), just saves the reply
const updated = await api.reviews.update(slug, id, { reply: replyText.trim() })
setReviews(prev => prev.map(r => r.id === id ? updated : r))
setReplyId(null)
setReplyText('')
@@ -181,13 +182,13 @@ export function ReviewsPage() {
const reject = async (id: string) => {
if (!slug) return
const updated = await api.reviews.update(slug, id, { isPublic: false })
const updated = await api.reviews.update(slug, id, { rejected: true })
setReviews(prev => prev.map(r => r.id === id ? updated : r))
}
const restore = async (id: string, r: ReviewApi) => {
const restore = async (id: string) => {
if (!slug) return
const updated = await api.reviews.update(slug, id, { isPublic: r.rating >= threshold })
const updated = await api.reviews.update(slug, id, { rejected: false })
setReviews(prev => prev.map(x => x.id === id ? updated : x))
}
@@ -212,28 +213,30 @@ export function ReviewsPage() {
// ── Derived state ───────────────────────────────────────────────────────────
const pending = reviews.filter(r => !r.isPublic && !r.reply)
const published = reviews.filter(r => r.isPublic)
// pending = негативные без ответа и без отклонения
// archived = всё обработанное: опубликованные + отвеченные + отклонённые
const pending = reviews.filter(r => !r.isPublic && !r.reply && !r.rejectedAt)
const archived = reviews.filter(r => r.isPublic || !!r.reply || !!r.rejectedAt)
const enabledPlatforms = platforms.filter(p => p.enabled)
const avgRating = published.length
? (published.reduce((s, r) => s + r.rating, 0) / published.length).toFixed(1)
const avgRating = reviews.length
? (reviews.reduce((s, r) => s + r.rating, 0) / reviews.length).toFixed(1)
: '—'
const nps = published.length
const nps = reviews.length
? Math.round(
(published.filter(r => r.rating >= 5).length / published.length * 100) -
(published.filter(r => r.rating <= 2).length / published.length * 100),
(reviews.filter(r => r.rating >= 5).length / reviews.length * 100) -
(reviews.filter(r => r.rating <= 2).length / reviews.length * 100),
)
: 0
const filtered = tab === 'all' ? reviews
: tab === 'settings' || tab === 'preview' ? []
: tab === 'pending' ? pending
: published
const filtered = tab === 'all' ? reviews
: tab === 'settings' || tab === 'preview' || tab === 'qr' ? []
: tab === 'pending' ? pending
: archived
const TABS: { key: Tab; label: string; count?: number; colorCls?: string; Icon?: React.ElementType }[] = [
{ key: 'pending', label: 'Требуют ответа', count: pending.length, colorCls: 'bg-red-500 text-white' },
{ key: 'published', label: 'Архив', count: published.length },
{ key: 'published', label: 'Архив', count: archived.length },
{ key: 'all', label: 'Все' },
{ key: 'preview', label: 'Превью', Icon: Eye },
{ key: 'qr', label: 'QR-коды', Icon: QrCode },
@@ -256,7 +259,7 @@ export function ReviewsPage() {
{[
{ label: 'Средняя оценка', value: avgRating, sub: 'из 5 звёзд', color: 'text-amber-600 dark:text-amber-400' },
{ label: 'NPS', value: nps >= 0 ? `+${nps}` : `${nps}`, sub: 'лояльность', color: 'text-emerald-600 dark:text-emerald-400' },
{ label: 'В архиве', value: published.length, sub: 'опубликованных', color: 'text-brand-600 dark:text-brand-400' },
{ label: 'В архиве', value: archived.length, sub: 'обработанных', color: 'text-brand-600 dark:text-brand-400' },
{ label: 'Ждут ответа', value: pending.length, sub: 'негативных', color: pending.length > 0 ? 'text-red-600 dark:text-red-400' : 'text-slate-500' },
].map(s => (
<div key={s.label} className="card p-4">
@@ -776,7 +779,8 @@ export function ReviewsPage() {
key={review.id}
className={cn(
'card p-4',
!review.isPublic && !review.reply && 'border-red-300 dark:border-red-700/60 bg-red-50/50 dark:bg-red-900/10',
!review.isPublic && !review.reply && !review.rejectedAt && 'border-red-300 dark:border-red-700/60 bg-red-50/50 dark:bg-red-900/10',
!!review.rejectedAt && 'opacity-60',
)}
>
<div className="flex items-start gap-3">
@@ -793,7 +797,8 @@ export function ReviewsPage() {
{BOOKING_SOURCE_LABEL[review.bookingSource]}
</Badge>
)}
{isNegative && <Badge className="bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300">Негативный</Badge>}
{isNegative && !review.rejectedAt && <Badge className="bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300">Негативный</Badge>}
{!!review.rejectedAt && <Badge className="bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-400">Отклонён</Badge>}
{review.isPublic && !isNegative && (
<Badge className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300 flex items-center gap-1">
<ArrowUpRight size={10} />
@@ -838,7 +843,7 @@ export function ReviewsPage() {
)}
</div>
<div className="flex gap-2 flex-wrap mt-3">
{!review.isPublic && !review.reply && replyId !== review.id && (
{!review.isPublic && !review.reply && !review.rejectedAt && replyId !== review.id && (
<>
<button
onClick={() => { setReplyId(review.id); setReplyText('') }}
@@ -854,9 +859,9 @@ export function ReviewsPage() {
</button>
</>
)}
{!review.isPublic && review.reply && (
{!!review.rejectedAt && (
<button
onClick={() => restore(review.id, review)}
onClick={() => restore(review.id)}
className="flex items-center gap-1 px-3 py-1.5 rounded-lg border border-slate-200 dark:border-slate-600 text-xs font-medium text-slate-600 dark:text-slate-400 hover:border-brand-400"
>
Восстановить