diff --git a/VERSION b/VERSION index a42fb8a..bfec95b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -6.15.6 +6.16.0 diff --git a/play-life-backend/main.go b/play-life-backend/main.go index 21d8475..4b16f2b 100644 --- a/play-life-backend/main.go +++ b/play-life-backend/main.go @@ -4765,6 +4765,7 @@ func main() { 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/entries/{id}", app.deleteEntryHandler).Methods("DELETE", "OPTIONS") + protected.HandleFunc("/api/entries/{id}", app.updateEntryHandler).Methods("PUT", "OPTIONS") // Integrations protected.HandleFunc("/api/integrations/telegram", app.getTelegramIntegrationHandler).Methods("GET", "OPTIONS") @@ -7900,6 +7901,154 @@ func (a *App) deleteEntryHandler(w http.ResponseWriter, r *http.Request) { }) } +// updateEntryHandler обновляет текст и ноды записи, сохраняя оригинальную дату +func (a *App) updateEntryHandler(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 + } + + var req struct { + Text string `json:"text"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) + return + } + if req.Text == "" { + sendErrorWithCORS(w, "Missing 'text' field", http.StatusBadRequest) + return + } + + // Получаем оригинальную дату и проверяем права + var originalDate string + var entryUserID int + err = a.DB.QueryRow("SELECT user_id, created_date FROM entries WHERE id = $1", entryID).Scan(&entryUserID, &originalDate) + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "Entry not found", http.StatusNotFound) + return + } + if err != nil { + sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) + return + } + if entryUserID != userID { + sendErrorWithCORS(w, "Forbidden", http.StatusForbidden) + return + } + + // Парсим ноды из нового текста + regex := regexp.MustCompile(`\*\*(.+?)([+-])([\d.]+)\*\*`) + nodes := make([]ProcessedNode, 0) + nodeCounter := 0 + processedText := regex.ReplaceAllStringFunc(req.Text, func(fullMatch string) string { + matches := regex.FindStringSubmatch(fullMatch) + if len(matches) != 4 { + return fullMatch + } + projectName := strings.TrimSpace(matches[1]) + sign := matches[2] + scoreString := matches[3] + score, err := strconv.ParseFloat(scoreString, 64) + if err != nil { + return fullMatch + } + if sign == "-" { + score = -score + } + nodes = append(nodes, ProcessedNode{Project: projectName, Score: score}) + placeholder := fmt.Sprintf("${%d}", nodeCounter) + nodeCounter++ + return placeholder + }) + // Убираем пустые строки + lines := strings.Split(processedText, "\n") + cleanedLines := make([]string, 0) + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed != "" { + cleanedLines = append(cleanedLines, trimmed) + } + } + processedText = strings.Join(cleanedLines, "\n") + + // Обновляем в транзакции: удаляем старые ноды, вставляем новые, обновляем текст + tx, err := a.DB.Begin() + if err != nil { + sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) + return + } + defer tx.Rollback() + + // Удаляем старые ноды + if _, err = tx.Exec("DELETE FROM nodes WHERE entry_id = $1", entryID); err != nil { + sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) + return + } + + // Обновляем текст записи + if _, err = tx.Exec("UPDATE entries SET text = $1 WHERE id = $2 AND user_id = $3", processedText, entryID, userID); err != nil { + sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) + return + } + + // Вставляем новые ноды + for _, node := range nodes { + var projectID int + err = tx.QueryRow(` + SELECT id FROM projects WHERE name = $1 AND user_id = $2 AND deleted = FALSE + `, node.Project, userID).Scan(&projectID) + if err == sql.ErrNoRows { + randomColor := generateRandomProjectColor() + err = tx.QueryRow(` + INSERT INTO projects (name, deleted, user_id, color) VALUES ($1, FALSE, $2, $3) RETURNING id + `, node.Project, userID, randomColor).Scan(&projectID) + if err != nil { + sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) + return + } + } else if err != nil { + sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) + return + } + + _, err = tx.Exec(` + INSERT INTO nodes (project_id, entry_id, score, user_id, created_date) + VALUES ($1, $2, $3, $4, $5) + `, projectID, entryID, node.Score, userID, originalDate) + if err != nil { + sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) + return + } + } + + if err = tx.Commit(); err != nil { + sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "Entry updated successfully", + }) +} + // getTelegramIntegrationHandler возвращает текущую telegram интеграцию с deep link func (a *App) getTelegramIntegrationHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { diff --git a/play-life-web/package.json b/play-life-web/package.json index c6bdbc0..b650461 100644 --- a/play-life-web/package.json +++ b/play-life-web/package.json @@ -1,6 +1,6 @@ { "name": "play-life-web", - "version": "6.15.6", + "version": "6.16.0", "type": "module", "scripts": { "dev": "vite", diff --git a/play-life-web/src/App.jsx b/play-life-web/src/App.jsx index 743dca4..f7c4492 100644 --- a/play-life-web/src/App.jsx +++ b/play-life-web/src/App.jsx @@ -1244,6 +1244,7 @@ function AppContent() { fetchTodayEntries={fetchTodayEntries} onRetry={fetchFullStatisticsData} currentWeekData={currentWeekData} + fetchCurrentWeekData={fetchCurrentWeekData} onNavigate={handleNavigate} activeTab={activeTab} /> diff --git a/play-life-web/src/components/FullStatistics.jsx b/play-life-web/src/components/FullStatistics.jsx index 5816e3d..9f9ee76 100644 --- a/play-life-web/src/components/FullStatistics.jsx +++ b/play-life-web/src/components/FullStatistics.jsx @@ -37,7 +37,7 @@ const formatDate = (date) => { // Названия дней недели const dayNames = ['пн', 'вт', 'ср', 'чт', 'пт', 'сб', 'вс'] -function FullStatistics({ selectedProject, onClearSelection, data, loading, error, onRetry, currentWeekData, onNavigate, todayEntries, todayEntriesLoading, todayEntriesError, onRetryTodayEntries, fetchTodayEntries, activeTab }) { +function FullStatistics({ selectedProject, onClearSelection, data, loading, error, onRetry, currentWeekData, onNavigate, todayEntries, todayEntriesLoading, todayEntriesError, onRetryTodayEntries, fetchTodayEntries, fetchCurrentWeekData, activeTab }) { const [selectedDate, setSelectedDate] = useState(null) const prevActiveTabRef = React.useRef(activeTab) const componentJustOpenedRef = React.useRef(false) @@ -199,7 +199,11 @@ function FullStatistics({ selectedProject, onClearSelection, data, loading, erro loading={todayEntriesLoading} error={todayEntriesError} onRetry={() => fetchTodayEntries && fetchTodayEntries(false, selectedProject, selectedDate)} - onDelete={() => fetchTodayEntries && fetchTodayEntries(false, selectedProject, selectedDate)} + onDelete={() => { + fetchTodayEntries && fetchTodayEntries(false, selectedProject, selectedDate) + fetchCurrentWeekData && fetchCurrentWeekData(true) + onRetry && onRetry(true) + }} /> > )} diff --git a/play-life-web/src/components/TodayEntriesList.jsx b/play-life-web/src/components/TodayEntriesList.jsx index a48cccb..4223594 100644 --- a/play-life-web/src/components/TodayEntriesList.jsx +++ b/play-life-web/src/components/TodayEntriesList.jsx @@ -1,4 +1,5 @@ import React, { useState, useEffect, useRef } from 'react' +import { createPortal } from 'react-dom' import LoadingError from './LoadingError' import { useAuth } from './auth/AuthContext' import TaskDetail from './TaskDetail' @@ -132,10 +133,227 @@ const formatEntryText = (text, nodes) => { return result.length > 0 ? result : currentText } +// Модальное окно редактирования записи +function EditEntryModal({ entry, onClose, onSuccess, authFetch }) { + const [message, setMessage] = useState(() => { + // Заменяем ${N} на $N для удобного отображения + return (entry.text || '').replace(/\$\{(\d+)\}/g, '$$$1') + }) + const [rewards, setRewards] = useState(() => { + if (!entry.nodes || entry.nodes.length === 0) return [] + const sorted = [...entry.nodes].sort((a, b) => a.index - b.index) + return sorted.map((node, idx) => ({ + position: idx, + project_name: node.project_name, + value: String(node.score) + })) + }) + const [projects, setProjects] = useState([]) + const [isSending, setIsSending] = useState(false) + const [error, setError] = useState(null) + const debounceTimer = useRef(null) + + useEffect(() => { + const loadProjects = async () => { + try { + const response = await authFetch('/projects') + if (response.ok) { + const data = await response.json() + setProjects(Array.isArray(data) ? data : []) + } + } catch (err) { + console.error('Error loading projects:', err) + } + } + loadProjects() + }, [authFetch]) + + const findMaxPlaceholderIndex = (msg) => { + if (!msg) return -1 + const indices = [] + const matchesCurly = msg.match(/\$\{(\d+)\}/g) || [] + matchesCurly.forEach(match => { + const numMatch = match.match(/\d+/) + if (numMatch) indices.push(parseInt(numMatch[0])) + }) + let searchIndex = 0 + while (true) { + const index = msg.indexOf('$', searchIndex) + if (index === -1) break + if (index === 0 || msg[index - 1] !== '\\') { + const afterDollar = msg.substring(index + 1) + const digitMatch = afterDollar.match(/^(\d+)/) + if (digitMatch) { + indices.push(parseInt(digitMatch[0])) + } + } + searchIndex = index + 1 + } + return indices.length > 0 ? Math.max(...indices) : -1 + } + + // Пересчет rewards при изменении сообщения + useEffect(() => { + if (debounceTimer.current) clearTimeout(debounceTimer.current) + debounceTimer.current = setTimeout(() => { + const maxIndex = findMaxPlaceholderIndex(message) + setRewards(prevRewards => { + const currentRewards = [...prevRewards] + while (currentRewards.length > maxIndex + 1) currentRewards.pop() + while (currentRewards.length < maxIndex + 1) { + currentRewards.push({ position: currentRewards.length, project_name: '', value: '0' }) + } + return currentRewards + }) + }, 500) + return () => { if (debounceTimer.current) clearTimeout(debounceTimer.current) } + }, [message]) + + const handleRewardChange = (index, field, value) => { + const newRewards = [...rewards] + newRewards[index] = { ...newRewards[index], [field]: value } + setRewards(newRewards) + } + + const buildFinalMessage = () => { + let result = message + const rewardStrings = {} + rewards.forEach((reward, index) => { + const score = parseFloat(reward.value) || 0 + const projectName = reward.project_name.trim() + if (!projectName) return + const scoreStr = score >= 0 + ? `**${projectName}+${score}**` + : `**${projectName}${score}**` + rewardStrings[index] = scoreStr + }) + for (let i = 0; i < 100; i++) { + const placeholder = `\${${i}}` + if (rewardStrings[i]) result = result.split(placeholder).join(rewardStrings[i]) + } + for (let i = 99; i >= 0; i--) { + if (rewardStrings[i]) { + const regex = new RegExp(`\\$${i}(?!\\d)`, 'g') + result = result.replace(regex, rewardStrings[i]) + } + } + return result + } + + const isFormValid = () => { + if (rewards.length === 0) return true + return rewards.every(reward => { + const projectName = reward.project_name?.trim() || '' + const value = reward.value?.toString().trim() || '' + return projectName !== '' && value !== '' + }) + } + + const handleSubmit = async () => { + for (const reward of rewards) { + if (!reward.project_name.trim()) { + setError('Заполните все проекты') + return + } + } + const finalMessage = buildFinalMessage() + if (!finalMessage.trim()) { + setError('Введите сообщение') + return + } + + setIsSending(true) + setError(null) + try { + const response = await authFetch(`/api/entries/${entry.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: finalMessage }) + }) + if (!response.ok) throw new Error('Ошибка при сохранении') + onSuccess() + } catch (err) { + console.error('Error updating entry:', err) + setError(err.message || 'Ошибка при сохранении') + } finally { + setIsSending(false) + } + } + + const modalContent = ( +