4.13.0: Добавлено удаление записей в статистике
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m22s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m22s
This commit is contained in:
@@ -3754,6 +3754,7 @@ func main() {
|
|||||||
protected.HandleFunc("/project/create", app.createProjectHandler).Methods("POST", "OPTIONS")
|
protected.HandleFunc("/project/create", app.createProjectHandler).Methods("POST", "OPTIONS")
|
||||||
protected.HandleFunc("/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b", app.getFullStatisticsHandler).Methods("GET", "OPTIONS")
|
protected.HandleFunc("/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b", app.getFullStatisticsHandler).Methods("GET", "OPTIONS")
|
||||||
protected.HandleFunc("/api/today-entries", app.getTodayEntriesHandler).Methods("GET", "OPTIONS")
|
protected.HandleFunc("/api/today-entries", app.getTodayEntriesHandler).Methods("GET", "OPTIONS")
|
||||||
|
protected.HandleFunc("/api/entries/{id}", app.deleteEntryHandler).Methods("DELETE", "OPTIONS")
|
||||||
|
|
||||||
// Integrations
|
// Integrations
|
||||||
protected.HandleFunc("/api/integrations/telegram", app.getTelegramIntegrationHandler).Methods("GET", "OPTIONS")
|
protected.HandleFunc("/api/integrations/telegram", app.getTelegramIntegrationHandler).Methods("GET", "OPTIONS")
|
||||||
@@ -6321,6 +6322,74 @@ func (a *App) getTodayEntriesHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
json.NewEncoder(w).Encode(entries)
|
json.NewEncoder(w).Encode(entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deleteEntryHandler удаляет entry и каскадно удаляет связанные nodes
|
||||||
|
func (a *App) deleteEntryHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "OPTIONS" {
|
||||||
|
setCORSHeaders(w)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setCORSHeaders(w)
|
||||||
|
|
||||||
|
userID, ok := getUserIDFromContext(r)
|
||||||
|
if !ok {
|
||||||
|
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
entryIDStr := vars["id"]
|
||||||
|
entryID, err := strconv.Atoi(entryIDStr)
|
||||||
|
if err != nil {
|
||||||
|
sendErrorWithCORS(w, "Invalid entry ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, что entry принадлежит пользователю
|
||||||
|
var entryUserID int
|
||||||
|
err = a.DB.QueryRow("SELECT user_id FROM entries WHERE id = $1", entryID).Scan(&entryUserID)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
sendErrorWithCORS(w, "Entry not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error checking entry ownership: %v", err)
|
||||||
|
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем права доступа
|
||||||
|
if entryUserID != userID {
|
||||||
|
sendErrorWithCORS(w, "Forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаляем entry (nodes удалятся каскадно из-за ON DELETE CASCADE)
|
||||||
|
result, err := a.DB.Exec("DELETE FROM entries WHERE id = $1 AND user_id = $2", entryID, userID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error deleting entry: %v", err)
|
||||||
|
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rowsAffected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error getting rows affected: %v", err)
|
||||||
|
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if rowsAffected == 0 {
|
||||||
|
sendErrorWithCORS(w, "Entry not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"message": "Entry deleted successfully",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// getTelegramIntegrationHandler возвращает текущую telegram интеграцию с deep link
|
// getTelegramIntegrationHandler возвращает текущую telegram интеграцию с deep link
|
||||||
func (a *App) getTelegramIntegrationHandler(w http.ResponseWriter, r *http.Request) {
|
func (a *App) getTelegramIntegrationHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method == "OPTIONS" {
|
if r.Method == "OPTIONS" {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "play-life-web",
|
"name": "play-life-web",
|
||||||
"version": "4.12.0",
|
"version": "4.13.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -174,6 +174,7 @@ function FullStatistics({ selectedProject, onClearSelection, data, loading, erro
|
|||||||
loading={todayEntriesLoading}
|
loading={todayEntriesLoading}
|
||||||
error={todayEntriesError}
|
error={todayEntriesError}
|
||||||
onRetry={() => fetchTodayEntries && fetchTodayEntries(false, selectedProject, selectedDate)}
|
onRetry={() => fetchTodayEntries && fetchTodayEntries(false, selectedProject, selectedDate)}
|
||||||
|
onDelete={() => fetchTodayEntries && fetchTodayEntries(false, selectedProject, selectedDate)}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react'
|
import React, { useState } from 'react'
|
||||||
import LoadingError from './LoadingError'
|
import LoadingError from './LoadingError'
|
||||||
|
import { useAuth } from './auth/AuthContext'
|
||||||
|
|
||||||
// Функция для форматирования скорa (аналогично formatScore из TaskDetail)
|
// Функция для форматирования скорa (аналогично formatScore из TaskDetail)
|
||||||
const formatScore = (num) => {
|
const formatScore = (num) => {
|
||||||
@@ -130,7 +131,49 @@ const formatEntryText = (text, nodes) => {
|
|||||||
return result.length > 0 ? result : currentText
|
return result.length > 0 ? result : currentText
|
||||||
}
|
}
|
||||||
|
|
||||||
function TodayEntriesList({ data, loading, error, onRetry }) {
|
function TodayEntriesList({ data, loading, error, onRetry, onDelete }) {
|
||||||
|
const { authFetch } = useAuth()
|
||||||
|
const [deletingIds, setDeletingIds] = useState(new Set())
|
||||||
|
|
||||||
|
const handleDelete = async (entryId) => {
|
||||||
|
if (deletingIds.has(entryId)) return
|
||||||
|
|
||||||
|
if (!window.confirm('Вы уверены, что хотите удалить эту запись? Это действие нельзя отменить.')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setDeletingIds(prev => new Set(prev).add(entryId))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await authFetch(`/api/entries/${entryId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
console.error('Delete error:', response.status, errorText)
|
||||||
|
throw new Error(`Ошибка при удалении записи: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вызываем callback для обновления данных
|
||||||
|
if (onDelete) {
|
||||||
|
onDelete()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Delete failed:', err)
|
||||||
|
alert(err.message || 'Не удалось удалить запись')
|
||||||
|
} finally {
|
||||||
|
setDeletingIds(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.delete(entryId)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center py-8">
|
<div className="flex justify-center items-center py-8">
|
||||||
@@ -157,9 +200,56 @@ function TodayEntriesList({ data, loading, error, onRetry }) {
|
|||||||
{data.map((entry) => (
|
{data.map((entry) => (
|
||||||
<div
|
<div
|
||||||
key={entry.id}
|
key={entry.id}
|
||||||
className="bg-white rounded-lg p-4 shadow-sm border border-gray-200"
|
className="bg-white rounded-lg p-4 shadow-sm border border-gray-200 relative group"
|
||||||
>
|
>
|
||||||
<div className="text-gray-800 whitespace-pre-wrap">
|
<button
|
||||||
|
onClick={() => handleDelete(entry.id)}
|
||||||
|
disabled={deletingIds.has(entry.id)}
|
||||||
|
className="absolute top-4 right-4 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
style={{
|
||||||
|
color: '#6b7280',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '0.25rem',
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '24px',
|
||||||
|
height: '24px',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
opacity: deletingIds.has(entry.id) ? 0.5 : 1,
|
||||||
|
zIndex: 10
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!deletingIds.has(entry.id)) {
|
||||||
|
e.currentTarget.style.backgroundColor = '#f3f4f6'
|
||||||
|
e.currentTarget.style.color = '#1f2937'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'transparent'
|
||||||
|
e.currentTarget.style.color = '#6b7280'
|
||||||
|
}}
|
||||||
|
title="Удалить запись"
|
||||||
|
>
|
||||||
|
{deletingIds.has(entry.id) ? (
|
||||||
|
<svg className="w-5 h-5 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M3 6h18"></path>
|
||||||
|
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path>
|
||||||
|
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path>
|
||||||
|
<line x1="10" y1="11" x2="10" y2="17"></line>
|
||||||
|
<line x1="14" y1="11" x2="14" y2="17"></line>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<div className="text-gray-800 whitespace-pre-wrap pr-8">
|
||||||
{formatEntryText(entry.text, entry.nodes)}
|
{formatEntryText(entry.text, entry.nodes)}
|
||||||
</div>
|
</div>
|
||||||
{entry.created_date && (
|
{entry.created_date && (
|
||||||
|
|||||||
@@ -252,8 +252,8 @@ function WeekProgressChart({ data, allProjectsSorted, currentWeekData, selectedP
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-8">
|
<div>
|
||||||
<h2 className="text-2xl font-semibold text-gray-800 mb-6">Прогресс недель</h2>
|
<h2 className="text-2xl font-semibold text-gray-800 mb-6" style={{ marginTop: '1.25rem' }}>Прогресс недель</h2>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* Остальные недели (старые сверху, новые снизу) */}
|
{/* Остальные недели (старые сверху, новые снизу) */}
|
||||||
{allOtherWeeksData.map((weekData) => (
|
{allOtherWeeksData.map((weekData) => (
|
||||||
|
|||||||
Reference in New Issue
Block a user