page.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222
  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. <div className="mb-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-white/40">
  128. <span>Создано: {new Date(place.created_at).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' })}</span>
  129. <span>Изменено: {new Date(place.updated_at).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' })}</span>
  130. <span>Правок: {place.version}</span>
  131. </div>
  132. {place.moderation_comment && (
  133. <p className="mb-3 rounded bg-yellow-500/10 px-3 py-2 text-sm text-yellow-400">
  134. Комментарий модератора: {place.moderation_comment}
  135. </p>
  136. )}
  137. <div className="flex items-center gap-2">
  138. {filter === 'pending_moderation' && (
  139. <>
  140. <button onClick={() => moderate(place.id, 'approve')}
  141. className="rounded-lg bg-green-600 px-4 py-2 text-sm text-white hover:bg-green-500 transition"
  142. >Одобрить</button>
  143. <button onClick={() => setReworkPlace(place)}
  144. className="rounded-lg bg-yellow-600/80 px-4 py-2 text-sm text-white hover:bg-yellow-500 transition"
  145. >На доработку</button>
  146. <button onClick={() => moderate(place.id, 'reject')}
  147. className="rounded-lg bg-red-600/80 px-4 py-2 text-sm text-white hover:bg-red-500 transition"
  148. >Отклонить</button>
  149. </>
  150. )}
  151. {filter === 'published' && (
  152. <>
  153. <button onClick={() => moderate(place.id, 'revoke')}
  154. className="rounded-lg bg-orange-600/80 px-4 py-2 text-sm text-white hover:bg-orange-500 transition"
  155. >Отозвать</button>
  156. <button onClick={() => setReworkPlace(place)}
  157. className="rounded-lg bg-yellow-600/80 px-4 py-2 text-sm text-white hover:bg-yellow-500 transition"
  158. >На доработку</button>
  159. </>
  160. )}
  161. {filter === 'deleted' && (
  162. <>
  163. <button onClick={() => restore(place.id)}
  164. className="rounded-lg bg-green-700 px-4 py-2 text-sm text-white hover:bg-green-600 transition"
  165. >Восстановить</button>
  166. <button onClick={() => setHardDeletePlace(place)}
  167. className="rounded-lg bg-red-700 px-4 py-2 text-sm text-white hover:bg-red-600 transition"
  168. >Полностью удалить</button>
  169. </>
  170. )}
  171. </div>
  172. </div>
  173. ))}
  174. </div>
  175. )}
  176. {reworkPlace && (
  177. <ReworkModal
  178. place={reworkPlace}
  179. onClose={() => setReworkPlace(null)}
  180. onConfirm={(id, comment) => moderate(id, 'rework', comment)}
  181. />
  182. )}
  183. {hardDeletePlace && (
  184. <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={() => setHardDeletePlace(null)}>
  185. <div className="w-full max-w-sm rounded-xl bg-[#1e293b] p-6 shadow-xl" onClick={(e) => e.stopPropagation()}>
  186. <h3 className="mb-2 text-lg font-bold text-white">Полное удаление</h3>
  187. <p className="mb-2 text-sm text-white/60">{hardDeletePlace.title}</p>
  188. <p className="mb-4 text-sm text-red-400">Место и все его файлы будут безвозвратно удалены.</p>
  189. <div className="flex justify-end gap-3">
  190. <button onClick={() => setHardDeletePlace(null)}
  191. className="rounded-lg bg-white/10 px-4 py-2 text-sm text-white hover:bg-white/20 transition"
  192. >Отмена</button>
  193. <button onClick={() => { hardDelete(hardDeletePlace.id); setHardDeletePlace(null) }}
  194. className="rounded-lg bg-red-700 px-4 py-2 text-sm text-white hover:bg-red-600 transition"
  195. >Удалить навсегда</button>
  196. </div>
  197. </div>
  198. </div>
  199. )}
  200. </div>
  201. )
  202. }