Просмотр исходного кода

feat: moder revision status, filters, rework action, edit page

neyrogovnarik 1 месяц назад
Родитель
Сommit
a2b5ba20ed

+ 1 - 1
backend/internal/handlers/places.go

@@ -192,7 +192,7 @@ type updatePlaceRequest struct {
 }
 }
 
 
 type moderatePlaceRequest struct {
 type moderatePlaceRequest struct {
-	Action  string  `json:"action" validate:"required,oneof=approve reject"`
+	Action  string  `json:"action" validate:"required,oneof=approve reject rework"`
 	Comment *string `json:"comment" validate:"omitempty,max=1000"`
 	Comment *string `json:"comment" validate:"omitempty,max=1000"`
 }
 }
 
 

+ 2 - 0
backend/internal/services/places.go

@@ -242,6 +242,8 @@ func (s *PlaceService) Moderate(ctx context.Context, id, action, comment, modera
 		status = "published"
 		status = "published"
 	case "reject":
 	case "reject":
 		status = "rejected"
 		status = "rejected"
+	case "rework":
+		status = "revision"
 	default:
 	default:
 		return fmt.Errorf("unknown action: %s", action)
 		return fmt.Errorf("unknown action: %s", action)
 	}
 	}

+ 1 - 1
backend/internal/validator/validator.go

@@ -95,7 +95,7 @@ func validateUserStatus(fl validator.FieldLevel) bool {
 func validatePlaceStatus(fl validator.FieldLevel) bool {
 func validatePlaceStatus(fl validator.FieldLevel) bool {
 	statuses := map[string]bool{
 	statuses := map[string]bool{
 		"draft": true, "pending_moderation": true, "published": true,
 		"draft": true, "pending_moderation": true, "published": true,
-		"rejected": true, "archived": true,
+		"rejected": true, "revision": true, "archived": true,
 	}
 	}
 	return statuses[fl.Field().String()]
 	return statuses[fl.Field().String()]
 }
 }

+ 78 - 29
frontend/src/app/admin/page.tsx

@@ -4,19 +4,27 @@ import { useState, useEffect, useCallback } from 'react'
 import { api, ApiRequestError } from '@/lib/api'
 import { api, ApiRequestError } from '@/lib/api'
 import type { Place } from '@/types'
 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() {
 export default function AdminModerationPage() {
+  const [filter, setFilter] = useState<FilterTab>('pending_moderation')
   const [places, setPlaces] = useState<Place[]>([])
   const [places, setPlaces] = useState<Place[]>([])
   const [loading, setLoading] = useState(true)
   const [loading, setLoading] = useState(true)
   const [actionMsg, setActionMsg] = useState('')
   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 {
     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)
       setPlaces(res.data)
     } catch (err) {
     } catch (err) {
       if (err instanceof ApiRequestError && err.status === 403) {
       if (err instanceof ApiRequestError && err.status === 403) {
@@ -25,32 +33,50 @@ export default function AdminModerationPage() {
     } finally {
     } finally {
       setLoading(false)
       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 {
     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))
       setPlaces((prev) => prev.filter((p) => p.id !== id))
-      setActionMsg(action === 'approve' ? 'Одобрено' : 'Отклонено')
+      setActionMsg(
+        action === 'approve' ? 'Одобрено' :
+        action === 'reject' ? 'Отклонено' : 'Отправлено на доработку'
+      )
+      setReworkId(null)
+      setReworkComment('')
     } catch (err: any) {
     } catch (err: any) {
       setActionMsg(err.message || 'Ошибка')
       setActionMsg(err.message || 'Ошибка')
     }
     }
   }
   }
 
 
-  if (loading) return <p className="text-white/60">Загрузка...</p>
-
   return (
   return (
     <div>
     <div>
-      <h1 className="mb-6 text-2xl font-bold text-white">Очередь модерации</h1>
+      <h1 className="mb-6 text-2xl font-bold text-white">Модерация</h1>
 
 
       {actionMsg && (
       {actionMsg && (
         <p className="mb-4 rounded-lg bg-green-500/10 px-4 py-2 text-sm text-green-400">{actionMsg}</p>
         <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">
         <div className="space-y-4">
           {places.map((place) => (
           {places.map((place) => (
@@ -66,19 +92,42 @@ export default function AdminModerationPage() {
               <h3 className="mb-1 text-lg font-medium text-white">{place.title}</h3>
               <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.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.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">
               <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>
             </div>
             </div>
           ))}
           ))}

+ 38 - 0
frontend/src/app/places/edit/[id]/page.tsx

@@ -0,0 +1,38 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import { useParams, useRouter } from 'next/navigation'
+import { api } from '@/lib/api'
+import type { Place } from '@/types'
+import PlaceForm from '@/components/PlaceForm'
+
+export default function EditPlacePage() {
+  const { id } = useParams<{ id: string }>()
+  const router = useRouter()
+  const [place, setPlace] = useState<Place | null>(null)
+  const [loading, setLoading] = useState(true)
+
+  useEffect(() => {
+    if (!id) return
+    api.get<Place>(`/places/${id}`)
+      .then(setPlace)
+      .catch(() => router.push('/places/my'))
+      .finally(() => setLoading(false))
+  }, [id, router])
+
+  if (loading) {
+    return (
+      <div className="flex min-h-screen items-center justify-center bg-[#0f172a]">
+        <p className="text-white/40">Загрузка...</p>
+      </div>
+    )
+  }
+
+  if (!place) return null
+
+  return (
+    <div className="min-h-screen bg-[#0f172a] pt-20">
+      <PlaceForm type={place.type} place={place} onSuccess={() => router.push('/places/my')} />
+    </div>
+  )
+}

+ 36 - 25
frontend/src/app/places/my/page.tsx

@@ -5,6 +5,14 @@ import Link from 'next/link'
 import { api } from '@/lib/api'
 import { api } from '@/lib/api'
 import type { Place } from '@/types'
 import type { Place } from '@/types'
 
 
+const STATUS_LABELS: Record<string, { label: string; color: string }> = {
+  published: { label: 'Опубликовано', color: 'text-green-400' },
+  pending_moderation: { label: 'На модерации', color: 'text-yellow-400' },
+  revision: { label: 'На доработке', color: 'text-orange-400' },
+  rejected: { label: 'Отклонено', color: 'text-red-400' },
+  draft: { label: 'Черновик', color: 'text-white/40' },
+}
+
 export default function MyPlacesPage() {
 export default function MyPlacesPage() {
   const [places, setPlaces] = useState<Place[]>([])
   const [places, setPlaces] = useState<Place[]>([])
   const [loading, setLoading] = useState(true)
   const [loading, setLoading] = useState(true)
@@ -35,32 +43,35 @@ export default function MyPlacesPage() {
           </div>
           </div>
         ) : (
         ) : (
           <div className="space-y-4">
           <div className="space-y-4">
-            {places.map((place) => (
-              <div
-                key={place.id}
-                className="rounded-xl bg-white/5 p-4 text-white"
-              >
-                <h2 className="text-lg font-semibold">{place.title}</h2>
-                {place.address && (
-                  <p className="mt-1 text-sm text-white/60">{place.address}</p>
-                )}
-                <div className="mt-2 flex items-center gap-3 text-sm text-white/40">
-                  <span className={
-                    place.status === 'published' ? 'text-green-400' :
-                    place.status === 'pending_moderation' ? 'text-yellow-400' :
-                    place.status === 'rejected' ? 'text-red-400' :
-                    'text-white/40'
-                  }>
-                    {place.status === 'published' ? 'Опубликовано' :
-                     place.status === 'pending_moderation' ? 'На модерации' :
-                     place.status === 'rejected' ? 'Отклонено' :
-                     place.status === 'draft' ? 'Черновик' :
-                     place.status}
-                  </span>
-                  {place.type === 'studio' && <span>Студия</span>}
+            {places.map((place) => {
+              const st = STATUS_LABELS[place.status] || { label: place.status, color: 'text-white/40' }
+              return (
+                <div key={place.id} className="rounded-xl bg-white/5 p-4 text-white">
+                  <h2 className="text-lg font-semibold">{place.title}</h2>
+                  {place.address && <p className="mt-1 text-sm text-white/60">{place.address}</p>}
+
+                  <div className="mt-2 flex items-center gap-3 text-sm">
+                    <span className={st.color}>{st.label}</span>
+                    {place.type === 'studio' && <span className="text-white/40">Студия</span>}
+                  </div>
+
+                  {place.moderation_comment && (
+                    <p className="mt-2 rounded bg-yellow-500/10 px-3 py-2 text-sm text-yellow-400">
+                      Комментарий модератора: {place.moderation_comment}
+                    </p>
+                  )}
+
+                  {place.status === 'revision' && (
+                    <Link
+                      href={`/places/edit/${place.id}`}
+                      className="mt-3 inline-block rounded-lg bg-yellow-600 px-4 py-1.5 text-sm text-white hover:bg-yellow-500 transition"
+                    >
+                      Редактировать
+                    </Link>
+                  )}
                 </div>
                 </div>
-              </div>
-            ))}
+              )
+            })}
           </div>
           </div>
         )}
         )}
       </div>
       </div>

+ 25 - 18
frontend/src/components/PlaceForm.tsx

@@ -8,12 +8,14 @@ import type { Tag, Feature } from '@/types'
 
 
 const MapPicker = dynamic(() => import('./MapPicker'), { ssr: false })
 const MapPicker = dynamic(() => import('./MapPicker'), { ssr: false })
 
 
-/** Пропсы компонента формы создания места/студии */
+/** Пропсы компонента формы создания/редактирования места/студии */
 interface PlaceFormProps {
 interface PlaceFormProps {
-  /** Тип создаваемого объекта: место или студия */
+  /** Тип объекта: место или студия */
   type: 'place' | 'studio'
   type: 'place' | 'studio'
-  /** Колбэк после успешного создания (заменяет редирект на /) */
+  /** Колбэк после успешного сохранения */
   onSuccess?: () => void
   onSuccess?: () => void
+  /** Данные места для редактирования */
+  place?: import('@/types').Place
 }
 }
 
 
 /**
 /**
@@ -23,25 +25,26 @@ interface PlaceFormProps {
  * Перед сохранением загружает обложку через presigned URL.
  * Перед сохранением загружает обложку через presigned URL.
  * @param type - Тип объекта: 'place' — место, 'studio' — студия
  * @param type - Тип объекта: 'place' — место, 'studio' — студия
  */
  */
-export default function PlaceForm({ type, onSuccess }: PlaceFormProps) {
+export default function PlaceForm({ type, onSuccess, place }: PlaceFormProps) {
   const router = useRouter()
   const router = useRouter()
+  const isEdit = !!place
   const [tags, setTags] = useState<Tag[]>([])
   const [tags, setTags] = useState<Tag[]>([])
   const [features, setFeatures] = useState<Feature[]>([])
   const [features, setFeatures] = useState<Feature[]>([])
   const [loading, setLoading] = useState(false)
   const [loading, setLoading] = useState(false)
   const [error, setError] = useState('')
   const [error, setError] = useState('')
 
 
-  const [title, setTitle] = useState('')
-  const [description, setDescription] = useState('')
-  const [address, setAddress] = useState('')
-  const [accessInfo, setAccessInfo] = useState('')
-  const [selectedTags, setSelectedTags] = useState<string[]>([])
-  const [selectedFeatures, setSelectedFeatures] = useState<string[]>([])
-  const [lat, setLat] = useState('55.7558')
-  const [lng, setLng] = useState('37.6173')
-  const [hourlyRate, setHourlyRate] = useState('')
-  const [minHours, setMinHours] = useState('1')
+  const [title, setTitle] = useState(place?.title || '')
+  const [description, setDescription] = useState(place?.description || '')
+  const [address, setAddress] = useState(place?.address || '')
+  const [accessInfo, setAccessInfo] = useState(place?.access_info || '')
+  const [selectedTags, setSelectedTags] = useState<string[]>(place?.tags?.map((t) => t.id) || [])
+  const [selectedFeatures, setSelectedFeatures] = useState<string[]>(place?.features?.map((f) => f.id) || [])
+  const [lat, setLat] = useState(place?.lat?.toString() || '55.7558')
+  const [lng, setLng] = useState(place?.lng?.toString() || '37.6173')
+  const [hourlyRate, setHourlyRate] = useState(place?.pricing?.hourly_rate?.toString() || '')
+  const [minHours, setMinHours] = useState(place?.pricing?.min_hours?.toString() || '1')
   const [coverFile, setCoverFile] = useState<File | null>(null)
   const [coverFile, setCoverFile] = useState<File | null>(null)
-  const [coverPreview, setCoverPreview] = useState('')
+  const [coverPreview, setCoverPreview] = useState(place?.cover_image || '')
   const [showMapPicker, setShowMapPicker] = useState(false)
   const [showMapPicker, setShowMapPicker] = useState(false)
 
 
   useEffect(() => {
   useEffect(() => {
@@ -99,7 +102,11 @@ export default function PlaceForm({ type, onSuccess }: PlaceFormProps) {
         body.currency = 'RUB'
         body.currency = 'RUB'
       }
       }
 
 
-      await api.post('/places', body)
+      if (isEdit) {
+        await api.patch(`/places/${place.id}`, body)
+      } else {
+        await api.post('/places', body)
+      }
       if (onSuccess) { onSuccess() } else { router.push('/') }
       if (onSuccess) { onSuccess() } else { router.push('/') }
     } catch (err: any) {
     } catch (err: any) {
       setError(err.message || 'Ошибка при создании')
       setError(err.message || 'Ошибка при создании')
@@ -123,7 +130,7 @@ export default function PlaceForm({ type, onSuccess }: PlaceFormProps) {
   return (
   return (
     <form onSubmit={handleSubmit} className="mx-auto max-w-2xl space-y-6 p-6">
     <form onSubmit={handleSubmit} className="mx-auto max-w-2xl space-y-6 p-6">
       <h1 className="text-2xl font-bold text-white">
       <h1 className="text-2xl font-bold text-white">
-        {type === 'studio' ? 'Добавить студию' : 'Добавить место'}
+        {isEdit ? 'Редактировать' : type === 'studio' ? 'Добавить студию' : 'Добавить место'}
       </h1>
       </h1>
 
 
       {error && <p className="rounded-lg bg-red-500/10 p-3 text-sm text-red-400">{error}</p>}
       {error && <p className="rounded-lg bg-red-500/10 p-3 text-sm text-red-400">{error}</p>}
@@ -290,7 +297,7 @@ export default function PlaceForm({ type, onSuccess }: PlaceFormProps) {
         disabled={loading}
         disabled={loading}
         className="w-full rounded-lg bg-green-600 px-4 py-3 font-medium text-white hover:bg-green-500 transition disabled:opacity-50"
         className="w-full rounded-lg bg-green-600 px-4 py-3 font-medium text-white hover:bg-green-500 transition disabled:opacity-50"
       >
       >
-        {loading ? 'Сохранение...' : `Создать ${type === 'studio' ? 'студию' : 'место'}`}
+        {loading ? 'Сохранение...' : isEdit ? 'Сохранить' : `Создать ${type === 'studio' ? 'студию' : 'место'}`}
       </button>
       </button>
 
 
       {showMapPicker && <MapPicker onSelect={handleMapPick} onClose={() => setShowMapPicker(false)} />}
       {showMapPicker && <MapPicker onSelect={handleMapPick} onClose={() => setShowMapPicker(false)} />}

+ 3 - 2
frontend/src/types/index.ts

@@ -33,8 +33,8 @@ export interface User {
 /** Тип объекта: обычное место или студия */
 /** Тип объекта: обычное место или студия */
 export type PlaceType = 'place' | 'studio'
 export type PlaceType = 'place' | 'studio'
 
 
-/** Статус места: черновик, на модерации, опубликовано, отклонено или в архиве */
-export type PlaceStatus = 'draft' | 'pending_moderation' | 'published' | 'rejected' | 'archived'
+/** Статус места: черновик, на модерации, опубликовано, отклонено, на доработке или в архиве */
+export type PlaceStatus = 'draft' | 'pending_moderation' | 'published' | 'rejected' | 'revision' | 'archived'
 
 
 /**
 /**
  * Место или студия для фотосъёмок
  * Место или студия для фотосъёмок
@@ -76,6 +76,7 @@ export interface Place {
   tags: Tag[]
   tags: Tag[]
   features: Feature[]
   features: Feature[]
   status: PlaceStatus
   status: PlaceStatus
+  moderation_comment?: string
   owner: Pick<User, 'id' | 'name' | 'avatar_url'>
   owner: Pick<User, 'id' | 'name' | 'avatar_url'>
   pricing?: {
   pricing?: {
     hourly_rate: number
     hourly_rate: number