diff --git a/backend/migrations/085_reviews_rejected.sql b/backend/migrations/085_reviews_rejected.sql new file mode 100644 index 0000000..7f851a9 --- /dev/null +++ b/backend/migrations/085_reviews_rejected.sql @@ -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; diff --git a/backend/src/routes/reviews.ts b/backend/src/routes/reviews.ts index 2241fa3..3108eba 100644 --- a/backend/src/routes/reviews.ts +++ b/backend/src/routes/reviews.ts @@ -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( + fastify.patch( '/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( diff --git a/src/lib/api.ts b/src/lib/api.ts index f425af1..d230b15 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -482,7 +482,7 @@ export const api = { list: (slug: string) => req('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('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 diff --git a/src/pages/ReviewsPage.tsx b/src/pages/ReviewsPage.tsx index 0af8427..825913f 100644 --- a/src/pages/ReviewsPage.tsx +++ b/src/pages/ReviewsPage.tsx @@ -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 => (
@@ -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', )} >
@@ -793,7 +797,8 @@ export function ReviewsPage() { {BOOKING_SOURCE_LABEL[review.bookingSource]} )} - {isNegative && Негативный} + {isNegative && !review.rejectedAt && Негативный} + {!!review.rejectedAt && Отклонён} {review.isPublic && !isNegative && ( @@ -838,7 +843,7 @@ export function ReviewsPage() { )}
- {!review.isPublic && !review.reply && replyId !== review.id && ( + {!review.isPublic && !review.reply && !review.rejectedAt && replyId !== review.id && ( <> )} - {!review.isPublic && review.reply && ( + {!!review.rejectedAt && (