page.tsx 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. 'use client'
  2. import { useState, useEffect, useCallback } from 'react'
  3. import { api, ApiRequestError } from '@/lib/api'
  4. import type { Place } from '@/types'
  5. type FilterTab = 'pending_moderation' | 'published' | 'rejected' | 'revision' | 'deleted'
  6. const FILTERS: { key: FilterTab; label: string }[] = [
  7. { key: 'pending_moderation', label: 'Новые' },
  8. { key: 'published', label: 'Одобренные недавно' },
  9. { key: 'rejected', label: 'Отклонённые' },
  10. { key: 'revision', label: 'На доработке' },
  11. { key: 'deleted', label: 'Удалённые' },
  12. ]
  13. function ReworkModal({ place, onClose, onConfirm }: { place: Place; onClose: () => void; onConfirm: (id: string, comment: string) => void }) {
  14. const [comment, setComment] = useState('')
  15. return (
  16. <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
  17. <div className="w-full max-w-md rounded-xl bg-[#1e293b] p-6 shadow-xl" onClick={(e) => e.stopPropagation()}>
  18. <h2 className="mb-2 text-lg font-bold text-white">Отправить на доработку</h2>
  19. <p className="mb-4 text-sm text-white/60">{place.title}</p>
  20. <textarea value={comment} onChange={(e) => setComment(e.target.value)}
  21. placeholder="Что нужно доработать?"
  22. rows={4}
  23. className="mb-4 w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-sm text-white outline-none focus:border-yellow-500"
  24. />
  25. <div className="flex items-center justify-end gap-3">
  26. <button onClick={onClose}
  27. className="rounded-lg px-4 py-2 text-sm text-white/60 hover:text-white transition"
  28. >Отмена</button>
  29. <button onClick={() => onConfirm(place.id, comment)}
  30. disabled={!comment.trim()}
  31. className="rounded-lg bg-yellow-600 px-4 py-2 text-sm text-white hover:bg-yellow-500 transition disabled:opacity-50"
  32. >Отправить</button>
  33. </div>
  34. </div>
  35. </div>
  36. )
  37. }
  38. export default function AdminModerationPage() {
  39. const [filter, setFilter] = useState<FilterTab>('pending_moderation')
  40. const [places, setPlaces] = useState<Place[]>([])
  41. const [loading, setLoading] = useState(true)
  42. const [actionMsg, setActionMsg] = useState('')
  43. const [reworkPlace, setReworkPlace] = useState<Place | null>(null)
  44. const [hardDeletePlace, setHardDeletePlace] = useState<Place | null>(null)
  45. const fetchPlaces = useCallback(async () => {
  46. setLoading(true)
  47. try {
  48. const res = await api.get<{ data: Place[] }>(`/places?status=${filter}&limit=50`)
  49. setPlaces(res.data)
  50. } catch (err) {
  51. if (err instanceof ApiRequestError && err.status === 403) {
  52. setActionMsg('Нет прав доступа')
  53. }
  54. } finally {
  55. setLoading(false)
  56. }
  57. }, [filter])
  58. useEffect(() => { fetchPlaces() }, [fetchPlaces])
  59. const moderate = async (id: string, action: 'approve' | 'reject' | 'rework' | 'revoke', comment?: string) => {
  60. try {
  61. await api.post(`/places/${id}/moderate`, { action, comment: comment || '' })
  62. setPlaces((prev) => prev.filter((p) => p.id !== id))
  63. setActionMsg(
  64. action === 'approve' ? 'Одобрено' :
  65. action === 'reject' ? 'Отклонено' :
  66. action === 'revoke' ? 'Отозвано' : 'Отправлено на доработку'
  67. )
  68. setReworkPlace(null)
  69. } catch (err: any) {
  70. setActionMsg(err.message || 'Ошибка')
  71. }
  72. }
  73. const hardDelete = async (id: string) => {
  74. try {
  75. await api.delete(`/admin/places/${id}`)
  76. setPlaces((prev) => prev.filter((p) => p.id !== id))
  77. setActionMsg('Место полностью удалено')
  78. } catch (err: any) {
  79. setActionMsg(err.message || 'Ошибка при удалении')
  80. }
  81. }
  82. const restore = async (id: string) => {
  83. try {
  84. await api.post(`/places/${id}/restore`, {})
  85. setPlaces((prev) => prev.filter((p) => p.id !== id))
  86. setActionMsg('Место восстановлено')
  87. } catch (err: any) {
  88. setActionMsg(err.message || 'Ошибка при восстановлении')
  89. }
  90. }
  91. return (
  92. <div>
  93. <h1 className="mb-6 text-2xl font-bold text-white">Модерация</h1>
  94. {actionMsg && (
  95. <p className="mb-4 rounded-lg bg-green-500/10 px-4 py-2 text-sm text-green-400">{actionMsg}</p>
  96. )}
  97. <div className="mb-6 flex flex-wrap gap-2">
  98. {FILTERS.map((f) => (
  99. <button key={f.key} onClick={() => { setFilter(f.key); setActionMsg('') }}
  100. className={`rounded-lg px-4 py-2 text-sm transition ${
  101. filter === f.key ? 'bg-green-600 text-white' : 'bg-white/10 text-white/60 hover:bg-white/20'
  102. }`}
  103. >
  104. {f.label}
  105. </button>
  106. ))}
  107. </div>
  108. {loading ? (
  109. <p className="text-white/60">Загрузка...</p>
  110. ) : places.length === 0 ? (
  111. <p className="text-white/40">Нет мест</p>
  112. ) : (
  113. <div className="space-y-4">
  114. {places.map((place) => (
  115. <div key={place.id} className="rounded-lg border border-white/10 bg-white/5 p-4">
  116. <div className="mb-2 flex items-center gap-3">
  117. <span className={`rounded-full px-2 py-0.5 text-xs ${
  118. place.type === 'studio' ? 'bg-purple-500/20 text-purple-400' : 'bg-blue-500/20 text-blue-400'
  119. }`}>
  120. {place.type === 'studio' ? 'Студия' : 'Место'}
  121. </span>
  122. <span className="text-xs text-white/40">ID: {place.id.slice(0, 8)}</span>
  123. </div>
  124. <h3 className="mb-1 text-lg font-medium text-white">{place.title}</h3>
  125. {place.address && <p className="mb-2 text-sm text-white/40">{place.address}</p>}
  126. {place.description && <p className="mb-3 text-sm text-white/60">{place.description}</p>}
  127. {place.moderation_comment && (
  128. <p className="mb-3 rounded bg-yellow-500/10 px-3 py-2 text-sm text-yellow-400">
  129. Комментарий модератора: {place.moderation_comment}
  130. </p>
  131. )}
  132. <div className="flex items-center gap-2">
  133. {filter === 'pending_moderation' && (
  134. <>
  135. <button onClick={() => moderate(place.id, 'approve')}
  136. className="rounded-lg bg-green-600 px-4 py-2 text-sm text-white hover:bg-green-500 transition"
  137. >Одобрить</button>
  138. <button onClick={() => setReworkPlace(place)}
  139. className="rounded-lg bg-yellow-600/80 px-4 py-2 text-sm text-white hover:bg-yellow-500 transition"
  140. >На доработку</button>
  141. <button onClick={() => moderate(place.id, 'reject')}
  142. className="rounded-lg bg-red-600/80 px-4 py-2 text-sm text-white hover:bg-red-500 transition"
  143. >Отклонить</button>
  144. </>
  145. )}
  146. {filter === 'published' && (
  147. <>
  148. <button onClick={() => moderate(place.id, 'revoke')}
  149. className="rounded-lg bg-orange-600/80 px-4 py-2 text-sm text-white hover:bg-orange-500 transition"
  150. >Отозвать</button>
  151. <button onClick={() => setReworkPlace(place)}
  152. className="rounded-lg bg-yellow-600/80 px-4 py-2 text-sm text-white hover:bg-yellow-500 transition"
  153. >На доработку</button>
  154. </>
  155. )}
  156. {filter === 'deleted' && (
  157. <>
  158. <button onClick={() => restore(place.id)}
  159. className="rounded-lg bg-green-700 px-4 py-2 text-sm text-white hover:bg-green-600 transition"
  160. >Восстановить</button>
  161. <button onClick={() => setHardDeletePlace(place)}
  162. className="rounded-lg bg-red-700 px-4 py-2 text-sm text-white hover:bg-red-600 transition"
  163. >Полностью удалить</button>
  164. </>
  165. )}
  166. </div>
  167. </div>
  168. ))}
  169. </div>
  170. )}
  171. {reworkPlace && (
  172. <ReworkModal
  173. place={reworkPlace}
  174. onClose={() => setReworkPlace(null)}
  175. onConfirm={(id, comment) => moderate(id, 'rework', comment)}
  176. />
  177. )}
  178. {hardDeletePlace && (
  179. <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={() => setHardDeletePlace(null)}>
  180. <div className="w-full max-w-sm rounded-xl bg-[#1e293b] p-6 shadow-xl" onClick={(e) => e.stopPropagation()}>
  181. <h3 className="mb-2 text-lg font-bold text-white">Полное удаление</h3>
  182. <p className="mb-2 text-sm text-white/60">{hardDeletePlace.title}</p>
  183. <p className="mb-4 text-sm text-red-400">Место и все его файлы будут безвозвратно удалены.</p>
  184. <div className="flex justify-end gap-3">
  185. <button onClick={() => setHardDeletePlace(null)}
  186. className="rounded-lg bg-white/10 px-4 py-2 text-sm text-white hover:bg-white/20 transition"
  187. >Отмена</button>
  188. <button onClick={() => { hardDelete(hardDeletePlace.id); setHardDeletePlace(null) }}
  189. className="rounded-lg bg-red-700 px-4 py-2 text-sm text-white hover:bg-red-600 transition"
  190. >Удалить навсегда</button>
  191. </div>
  192. </div>
  193. </div>
  194. )}
  195. </div>
  196. )
  197. }