Pārlūkot izejas kodu

feat(admin): редактирование и удаление тегов и характеристик через админку

neyrogovnarik 2 nedēļas atpakaļ
vecāks
revīzija
86b0c221f5

+ 2 - 0
backend/cmd/api/main.go

@@ -258,8 +258,10 @@ func main() {
 			r.Patch("/admin/users/{id}", userHandler.AdminUpdateUser)
 			r.Patch("/admin/bookings/{id}/confirm", bookingHandler.Confirm)
 			r.Post("/admin/tags", tagHandler.CreateTag)
+			r.Put("/admin/tags", tagHandler.UpdateTag)
 			r.Delete("/admin/tags", tagHandler.DeleteTag)
 			r.Post("/admin/features", tagHandler.CreateFeature)
+			r.Put("/admin/features", tagHandler.UpdateFeature)
 			r.Delete("/admin/features", tagHandler.DeleteFeature)
 			r.Delete("/admin/places/{id}", placeHandler.HardDelete)
 		})

+ 53 - 0
backend/internal/handlers/tags.go

@@ -57,6 +57,59 @@ type deleteTagRequest struct {
 	ID string `json:"id" validate:"required,slug,max=50"`
 }
 
+type updateTagRequest struct {
+	ID        string `json:"id" validate:"required,slug,max=50"`
+	Name      string `json:"name" validate:"required,max=255"`
+	Category  string `json:"category" validate:"omitempty,max=50"`
+	SortOrder int    `json:"sort_order" validate:"min=0"`
+}
+
+type updateFeatureRequest struct {
+	ID        string `json:"id" validate:"required,slug,max=50"`
+	Name      string `json:"name" validate:"required,max=255"`
+	Category  string `json:"category" validate:"omitempty,max=50"`
+	Icon      string `json:"icon" validate:"omitempty,max=50"`
+	SortOrder int    `json:"sort_order" validate:"min=0"`
+}
+
+func (h *TagHandler) UpdateTag(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
+	var req updateTagRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeError(w, http.StatusBadRequest, "invalid request body", err)
+		return
+	}
+	if err := validator.Validate(req); err != nil {
+		writeValidationError(w, err)
+		return
+	}
+	tag := &models.Tag{ID: req.ID, Name: req.Name, Category: pointer.Str(req.Category), SortOrder: req.SortOrder}
+	if err := h.tagRepo.Update(r.Context(), req.ID, tag); err != nil {
+		writeError(w, http.StatusInternalServerError, "failed to update tag", err)
+		return
+	}
+	writeJSON(w, http.StatusOK, tag)
+}
+
+func (h *TagHandler) UpdateFeature(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
+	var req updateFeatureRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeError(w, http.StatusBadRequest, "invalid request body", err)
+		return
+	}
+	if err := validator.Validate(req); err != nil {
+		writeValidationError(w, err)
+		return
+	}
+	feature := &models.Feature{ID: req.ID, Name: req.Name, Category: pointer.Str(req.Category), Icon: pointer.Str(req.Icon), SortOrder: req.SortOrder}
+	if err := h.featureRepo.Update(r.Context(), req.ID, feature); err != nil {
+		writeError(w, http.StatusInternalServerError, "failed to update feature", err)
+		return
+	}
+	writeJSON(w, http.StatusOK, feature)
+}
+
 func (h *TagHandler) CreateTag(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
 	var req createTagRequest

+ 14 - 0
backend/internal/repository/tags.go

@@ -61,6 +61,13 @@ func (r *TagRepo) Create(ctx context.Context, tag *models.Tag) error {
 	return err
 }
 
+func (r *TagRepo) Update(ctx context.Context, id string, tag *models.Tag) error {
+	_, err := r.pool.Exec(ctx,
+		`UPDATE tags SET name = $1, category = $2, sort_order = $3 WHERE id = $4`,
+		tag.Name, tag.Category, tag.SortOrder, id)
+	return err
+}
+
 func (r *TagRepo) Delete(ctx context.Context, id string) error {
 	_, err := r.pool.Exec(ctx, `DELETE FROM tags WHERE id = $1`, id)
 	return err
@@ -100,6 +107,13 @@ func (r *FeatureRepo) Create(ctx context.Context, feature *models.Feature) error
 	return err
 }
 
+func (r *FeatureRepo) Update(ctx context.Context, id string, feature *models.Feature) error {
+	_, err := r.pool.Exec(ctx,
+		`UPDATE features SET name = $1, category = $2, icon = $3, sort_order = $4 WHERE id = $5`,
+		feature.Name, feature.Category, feature.Icon, feature.SortOrder, id)
+	return err
+}
+
 func (r *FeatureRepo) Delete(ctx context.Context, id string) error {
 	_, err := r.pool.Exec(ctx, `DELETE FROM features WHERE id = $1`, id)
 	return err

+ 147 - 6
frontend/src/app/admin/tags/page.tsx

@@ -7,8 +7,8 @@ import type { Tag, Feature } from '@/types'
 /**
  * Страница управления тегами и характеристиками.
  * Отображает две колонки: список тегов (стили съёмки) и список
- * характеристик. Администратор может добавлять новые записи через
- * форму с полями ID и названия.
+ * характеристик. Администратор может добавлять, редактировать (название)
+ * и удалять записи.
  */
 export default function AdminTagsPage() {
   const [tags, setTags] = useState<Tag[]>([])
@@ -21,6 +21,11 @@ export default function AdminTagsPage() {
   const [newFeatureName, setNewFeatureName] = useState('')
   const [error, setError] = useState('')
 
+  const [editingTagId, setEditingTagId] = useState<string | null>(null)
+  const [editTagName, setEditTagName] = useState('')
+  const [editingFeatureId, setEditingFeatureId] = useState<string | null>(null)
+  const [editFeatureName, setEditFeatureName] = useState('')
+
   const fetchData = async () => {
     try {
       const [t, f] = await Promise.all([
@@ -62,6 +67,72 @@ export default function AdminTagsPage() {
     }
   }
 
+  const startEditTag = (tag: Tag) => {
+    setEditingTagId(tag.id)
+    setEditTagName(tag.name)
+  }
+
+  const cancelEditTag = () => {
+    setEditingTagId(null)
+    setEditTagName('')
+  }
+
+  const saveTag = async (id: string) => {
+    if (!editTagName) return
+    try {
+      await api.put('/admin/tags', { id, name: editTagName })
+      cancelEditTag()
+      setError('')
+      fetchData()
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Ошибка обновления тега')
+    }
+  }
+
+  const deleteTag = async (id: string) => {
+    if (!confirm('Удалить тег «' + id + '»?')) return
+    try {
+      await api.delete('/admin/tags?id=' + encodeURIComponent(id))
+      setError('')
+      fetchData()
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Ошибка удаления тега')
+    }
+  }
+
+  const startEditFeature = (feat: Feature) => {
+    setEditingFeatureId(feat.id)
+    setEditFeatureName(feat.name)
+  }
+
+  const cancelEditFeature = () => {
+    setEditingFeatureId(null)
+    setEditFeatureName('')
+  }
+
+  const saveFeature = async (id: string) => {
+    if (!editFeatureName) return
+    try {
+      await api.put('/admin/features', { id, name: editFeatureName })
+      cancelEditFeature()
+      setError('')
+      fetchData()
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Ошибка обновления характеристики')
+    }
+  }
+
+  const deleteFeature = async (id: string) => {
+    if (!confirm('Удалить характеристику «' + id + '»?')) return
+    try {
+      await api.delete('/admin/features?id=' + encodeURIComponent(id))
+      setError('')
+      fetchData()
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Ошибка удаления характеристики')
+    }
+  }
+
   if (loading) return <p className="text-white/60">Загрузка...</p>
 
   return (
@@ -89,8 +160,43 @@ export default function AdminTagsPage() {
         <div className="max-h-[calc(100vh-13rem)] space-y-1 overflow-y-auto pb-8 pr-1">
           {tags.map((tag) => (
             <div key={tag.id} className="flex items-center justify-between rounded-lg bg-white/5 px-3 py-2">
-              <span className="text-sm text-white">{tag.name}</span>
-              <span className="text-xs text-white/40">{tag.id}</span>
+              {editingTagId === tag.id ? (
+                <div className="flex flex-1 items-center gap-2">
+                  <input value={editTagName} onChange={(e) => setEditTagName(e.target.value)}
+                    className="flex-1 rounded border border-white/10 bg-white/10 px-2 py-1 text-sm text-white outline-none"
+                    autoFocus
+                    onKeyDown={(e) => {
+                      if (e.key === 'Enter') saveTag(tag.id)
+                      if (e.key === 'Escape') cancelEditTag()
+                    }}
+                  />
+                  <button onClick={() => saveTag(tag.id)}
+                    className="rounded bg-green-600 px-2 py-1 text-xs text-white hover:bg-green-500">
+                    ✓
+                  </button>
+                  <button onClick={cancelEditTag}
+                    className="rounded bg-white/10 px-2 py-1 text-xs text-white/60 hover:text-white">
+                    ✕
+                  </button>
+                </div>
+              ) : (
+                <>
+                  <span className="text-sm text-white">{tag.name}</span>
+                  <div className="flex items-center gap-2">
+                    <span className="text-xs text-white/40">{tag.id}</span>
+                    <button onClick={() => startEditTag(tag)}
+                      className="text-xs text-white/40 hover:text-white"
+                      title="Редактировать">
+                      ✏️
+                    </button>
+                    <button onClick={() => deleteTag(tag.id)}
+                      className="text-xs text-white/40 hover:text-red-400"
+                      title="Удалить">
+                      🗑️
+                    </button>
+                  </div>
+                </>
+              )}
             </div>
           ))}
         </div>
@@ -114,8 +220,43 @@ export default function AdminTagsPage() {
         <div className="max-h-[calc(100vh-13rem)] space-y-1 overflow-y-auto pb-8 pr-1">
           {features.map((feat) => (
             <div key={feat.id} className="flex items-center justify-between rounded-lg bg-white/5 px-3 py-2">
-              <span className="text-sm text-white">{feat.name}</span>
-              <span className="text-xs text-white/40">{feat.id}</span>
+              {editingFeatureId === feat.id ? (
+                <div className="flex flex-1 items-center gap-2">
+                  <input value={editFeatureName} onChange={(e) => setEditFeatureName(e.target.value)}
+                    className="flex-1 rounded border border-white/10 bg-white/10 px-2 py-1 text-sm text-white outline-none"
+                    autoFocus
+                    onKeyDown={(e) => {
+                      if (e.key === 'Enter') saveFeature(feat.id)
+                      if (e.key === 'Escape') cancelEditFeature()
+                    }}
+                  />
+                  <button onClick={() => saveFeature(feat.id)}
+                    className="rounded bg-green-600 px-2 py-1 text-xs text-white hover:bg-green-500">
+                    ✓
+                  </button>
+                  <button onClick={cancelEditFeature}
+                    className="rounded bg-white/10 px-2 py-1 text-xs text-white/60 hover:text-white">
+                    ✕
+                  </button>
+                </div>
+              ) : (
+                <>
+                  <span className="text-sm text-white">{feat.name}</span>
+                  <div className="flex items-center gap-2">
+                    <span className="text-xs text-white/40">{feat.id}</span>
+                    <button onClick={() => startEditFeature(feat)}
+                      className="text-xs text-white/40 hover:text-white"
+                      title="Редактировать">
+                      ✏️
+                    </button>
+                    <button onClick={() => deleteFeature(feat.id)}
+                      className="text-xs text-white/40 hover:text-red-400"
+                      title="Удалить">
+                      🗑️
+                    </button>
+                  </div>
+                </>
+              )}
             </div>
           ))}
         </div>

+ 4 - 0
frontend/src/lib/api.ts

@@ -140,6 +140,10 @@ export const api = {
   upload: <T>(path: string, formData: FormData) =>
     requestFormData<T>(path, formData),
 
+  /** PUT-запрос с JSON-телом */
+  put: <T>(path: string, body: unknown) =>
+    request<T>(path, { method: 'PUT', body: JSON.stringify(body) }),
+
   /** PATCH-запрос с JSON-телом */
   patch: <T>(path: string, body: unknown) =>
     request<T>(path, { method: 'PATCH', body: JSON.stringify(body) }),