|
|
@@ -4,19 +4,27 @@ import { useState, useEffect, useCallback } from 'react'
|
|
|
import { api, ApiRequestError } from '@/lib/api'
|
|
|
import type { Place } from '@/types'
|
|
|
|
|
|
-/**
|
|
|
- * Страница модерации мест. Загружает места со статусом pending_moderation
|
|
|
- * и позволяет модератору одобрить или отклонить каждое место.
|
|
|
- * Использует API /places/:id/moderate для выполнения действий.
|
|
|
- */
|
|
|
+type FilterTab = 'pending_moderation' | 'published' | 'rejected' | 'revision'
|
|
|
+
|
|
|
+const FILTERS: { key: FilterTab; label: string }[] = [
|
|
|
+ { key: 'pending_moderation', label: 'Новые' },
|
|
|
+ { key: 'published', label: 'Одобренные недавно' },
|
|
|
+ { key: 'rejected', label: 'Отклонённые' },
|
|
|
+ { key: 'revision', label: 'На доработке' },
|
|
|
+]
|
|
|
+
|
|
|
export default function AdminModerationPage() {
|
|
|
+ const [filter, setFilter] = useState<FilterTab>('pending_moderation')
|
|
|
const [places, setPlaces] = useState<Place[]>([])
|
|
|
const [loading, setLoading] = useState(true)
|
|
|
const [actionMsg, setActionMsg] = useState('')
|
|
|
+ const [reworkId, setReworkId] = useState<string | null>(null)
|
|
|
+ const [reworkComment, setReworkComment] = useState('')
|
|
|
|
|
|
- const fetchPending = useCallback(async () => {
|
|
|
+ const fetchPlaces = useCallback(async () => {
|
|
|
+ setLoading(true)
|
|
|
try {
|
|
|
- const res = await api.get<{ data: Place[] }>('/places?status=pending_moderation&limit=50')
|
|
|
+ const res = await api.get<{ data: Place[] }>(`/places?status=${filter}&limit=50`)
|
|
|
setPlaces(res.data)
|
|
|
} catch (err) {
|
|
|
if (err instanceof ApiRequestError && err.status === 403) {
|
|
|
@@ -25,32 +33,50 @@ export default function AdminModerationPage() {
|
|
|
} finally {
|
|
|
setLoading(false)
|
|
|
}
|
|
|
- }, [])
|
|
|
+ }, [filter])
|
|
|
|
|
|
- useEffect(() => { fetchPending() }, [fetchPending])
|
|
|
+ useEffect(() => { fetchPlaces() }, [fetchPlaces])
|
|
|
|
|
|
- const moderate = async (id: string, action: 'approve' | 'reject') => {
|
|
|
+ const moderate = async (id: string, action: 'approve' | 'reject' | 'rework') => {
|
|
|
+ const comment = action === 'rework' ? reworkComment : ''
|
|
|
try {
|
|
|
- await api.post(`/places/${id}/moderate`, { action, comment: '' })
|
|
|
+ await api.post(`/places/${id}/moderate`, { action, comment })
|
|
|
setPlaces((prev) => prev.filter((p) => p.id !== id))
|
|
|
- setActionMsg(action === 'approve' ? 'Одобрено' : 'Отклонено')
|
|
|
+ setActionMsg(
|
|
|
+ action === 'approve' ? 'Одобрено' :
|
|
|
+ action === 'reject' ? 'Отклонено' : 'Отправлено на доработку'
|
|
|
+ )
|
|
|
+ setReworkId(null)
|
|
|
+ setReworkComment('')
|
|
|
} catch (err: any) {
|
|
|
setActionMsg(err.message || 'Ошибка')
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- if (loading) return <p className="text-white/60">Загрузка...</p>
|
|
|
-
|
|
|
return (
|
|
|
<div>
|
|
|
- <h1 className="mb-6 text-2xl font-bold text-white">Очередь модерации</h1>
|
|
|
+ <h1 className="mb-6 text-2xl font-bold text-white">Модерация</h1>
|
|
|
|
|
|
{actionMsg && (
|
|
|
<p className="mb-4 rounded-lg bg-green-500/10 px-4 py-2 text-sm text-green-400">{actionMsg}</p>
|
|
|
)}
|
|
|
|
|
|
- {places.length === 0 ? (
|
|
|
- <p className="text-white/40">Нет мест на модерации</p>
|
|
|
+ <div className="mb-6 flex flex-wrap gap-2">
|
|
|
+ {FILTERS.map((f) => (
|
|
|
+ <button key={f.key} onClick={() => { setFilter(f.key); setActionMsg('') }}
|
|
|
+ className={`rounded-lg px-4 py-2 text-sm transition ${
|
|
|
+ filter === f.key ? 'bg-green-600 text-white' : 'bg-white/10 text-white/60 hover:bg-white/20'
|
|
|
+ }`}
|
|
|
+ >
|
|
|
+ {f.label}
|
|
|
+ </button>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {loading ? (
|
|
|
+ <p className="text-white/60">Загрузка...</p>
|
|
|
+ ) : places.length === 0 ? (
|
|
|
+ <p className="text-white/40">Нет мест</p>
|
|
|
) : (
|
|
|
<div className="space-y-4">
|
|
|
{places.map((place) => (
|
|
|
@@ -66,19 +92,42 @@ export default function AdminModerationPage() {
|
|
|
<h3 className="mb-1 text-lg font-medium text-white">{place.title}</h3>
|
|
|
{place.address && <p className="mb-2 text-sm text-white/40">{place.address}</p>}
|
|
|
{place.description && <p className="mb-3 text-sm text-white/60">{place.description}</p>}
|
|
|
+ {place.moderation_comment && (
|
|
|
+ <p className="mb-3 rounded bg-yellow-500/10 px-3 py-2 text-sm text-yellow-400">
|
|
|
+ Комментарий модератора: {place.moderation_comment}
|
|
|
+ </p>
|
|
|
+ )}
|
|
|
+
|
|
|
<div className="flex items-center gap-2">
|
|
|
- <button
|
|
|
- onClick={() => moderate(place.id, 'approve')}
|
|
|
- className="rounded-lg bg-green-600 px-4 py-2 text-sm text-white hover:bg-green-500 transition"
|
|
|
- >
|
|
|
- Одобрить
|
|
|
- </button>
|
|
|
- <button
|
|
|
- onClick={() => moderate(place.id, 'reject')}
|
|
|
- className="rounded-lg bg-red-600/80 px-4 py-2 text-sm text-white hover:bg-red-500 transition"
|
|
|
- >
|
|
|
- Отклонить
|
|
|
- </button>
|
|
|
+ {filter === 'pending_moderation' && (
|
|
|
+ <>
|
|
|
+ <button onClick={() => moderate(place.id, 'approve')}
|
|
|
+ className="rounded-lg bg-green-600 px-4 py-2 text-sm text-white hover:bg-green-500 transition"
|
|
|
+ >Одобрить</button>
|
|
|
+ {reworkId === place.id ? (
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
+ <input value={reworkComment} onChange={(e) => setReworkComment(e.target.value)}
|
|
|
+ placeholder="Что доработать?"
|
|
|
+ className="w-48 rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-white outline-none focus:border-yellow-500"
|
|
|
+ />
|
|
|
+ <button onClick={() => moderate(place.id, 'rework')}
|
|
|
+ disabled={!reworkComment.trim()}
|
|
|
+ className="rounded-lg bg-yellow-600 px-4 py-2 text-sm text-white hover:bg-yellow-500 transition disabled:opacity-50"
|
|
|
+ >Отправить</button>
|
|
|
+ <button onClick={() => { setReworkId(null); setReworkComment('') }}
|
|
|
+ className="text-sm text-white/40 hover:text-white"
|
|
|
+ >Отмена</button>
|
|
|
+ </div>
|
|
|
+ ) : (
|
|
|
+ <button onClick={() => { setReworkId(place.id); setReworkComment('') }}
|
|
|
+ className="rounded-lg bg-yellow-600/80 px-4 py-2 text-sm text-white hover:bg-yellow-500 transition"
|
|
|
+ >На доработку</button>
|
|
|
+ )}
|
|
|
+ <button onClick={() => moderate(place.id, 'reject')}
|
|
|
+ className="rounded-lg bg-red-600/80 px-4 py-2 text-sm text-white hover:bg-red-500 transition"
|
|
|
+ >Отклонить</button>
|
|
|
+ </>
|
|
|
+ )}
|
|
|
</div>
|
|
|
</div>
|
|
|
))}
|