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:
2
backend/migrations/085_reviews_rejected.sql
Normal file
2
backend/migrations/085_reviews_rejected.sql
Normal 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;
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
Восстановить
|
||||
|
||||
Reference in New Issue
Block a user