6.16.0: Редактирование записей в статистике
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m24s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m24s
This commit is contained in:
@@ -4765,6 +4765,7 @@ func main() {
|
|||||||
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")
|
protected.HandleFunc("/api/entries/{id}", app.deleteEntryHandler).Methods("DELETE", "OPTIONS")
|
||||||
|
protected.HandleFunc("/api/entries/{id}", app.updateEntryHandler).Methods("PUT", "OPTIONS")
|
||||||
|
|
||||||
// Integrations
|
// Integrations
|
||||||
protected.HandleFunc("/api/integrations/telegram", app.getTelegramIntegrationHandler).Methods("GET", "OPTIONS")
|
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
|
// 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": "6.15.6",
|
"version": "6.16.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1244,6 +1244,7 @@ function AppContent() {
|
|||||||
fetchTodayEntries={fetchTodayEntries}
|
fetchTodayEntries={fetchTodayEntries}
|
||||||
onRetry={fetchFullStatisticsData}
|
onRetry={fetchFullStatisticsData}
|
||||||
currentWeekData={currentWeekData}
|
currentWeekData={currentWeekData}
|
||||||
|
fetchCurrentWeekData={fetchCurrentWeekData}
|
||||||
onNavigate={handleNavigate}
|
onNavigate={handleNavigate}
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const formatDate = (date) => {
|
|||||||
// Названия дней недели
|
// Названия дней недели
|
||||||
const dayNames = ['пн', 'вт', 'ср', 'чт', 'пт', 'сб', 'вс']
|
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 [selectedDate, setSelectedDate] = useState(null)
|
||||||
const prevActiveTabRef = React.useRef(activeTab)
|
const prevActiveTabRef = React.useRef(activeTab)
|
||||||
const componentJustOpenedRef = React.useRef(false)
|
const componentJustOpenedRef = React.useRef(false)
|
||||||
@@ -199,7 +199,11 @@ 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)}
|
onDelete={() => {
|
||||||
|
fetchTodayEntries && fetchTodayEntries(false, selectedProject, selectedDate)
|
||||||
|
fetchCurrentWeekData && fetchCurrentWeekData(true)
|
||||||
|
onRetry && onRetry(true)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react'
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
import LoadingError from './LoadingError'
|
import LoadingError from './LoadingError'
|
||||||
import { useAuth } from './auth/AuthContext'
|
import { useAuth } from './auth/AuthContext'
|
||||||
import TaskDetail from './TaskDetail'
|
import TaskDetail from './TaskDetail'
|
||||||
@@ -132,10 +133,227 @@ const formatEntryText = (text, nodes) => {
|
|||||||
return result.length > 0 ? result : currentText
|
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 = (
|
||||||
|
<div className="add-entry-modal-overlay" onClick={onClose}>
|
||||||
|
<div className="add-entry-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="add-entry-modal-header">
|
||||||
|
<h2 className="add-entry-modal-title">Редактировать запись</h2>
|
||||||
|
<button onClick={onClose} className="add-entry-close-button">✕</button>
|
||||||
|
</div>
|
||||||
|
<div className="add-entry-modal-content">
|
||||||
|
<div className="add-entry-field">
|
||||||
|
<label className="add-entry-label">Сообщение</label>
|
||||||
|
<textarea
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
|
placeholder="Используйте $0, $1 для указания проектов"
|
||||||
|
className="add-entry-textarea"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{rewards.length > 0 && (
|
||||||
|
<div className="add-entry-rewards">
|
||||||
|
{rewards.map((reward, index) => (
|
||||||
|
<div key={index} className="add-entry-reward-item">
|
||||||
|
<span className="add-entry-reward-number">{index}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={reward.project_name}
|
||||||
|
onChange={(e) => handleRewardChange(index, 'project_name', e.target.value)}
|
||||||
|
placeholder="Проект"
|
||||||
|
className="add-entry-input add-entry-project-input"
|
||||||
|
list={`edit-entry-projects-${index}`}
|
||||||
|
/>
|
||||||
|
<datalist id={`edit-entry-projects-${index}`}>
|
||||||
|
{projects.map(p => (
|
||||||
|
<option key={p.project_id} value={p.project_name} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
value={reward.value}
|
||||||
|
onChange={(e) => handleRewardChange(index, 'value', e.target.value)}
|
||||||
|
placeholder="Баллы"
|
||||||
|
className="add-entry-input add-entry-score-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div style={{ color: '#ef4444', fontSize: '0.875rem', marginBottom: '0.5rem' }}>{error}</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isSending || !isFormValid()}
|
||||||
|
className="add-entry-submit-button"
|
||||||
|
>
|
||||||
|
{isSending ? 'Сохранение...' : 'Сохранить'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return typeof document !== 'undefined'
|
||||||
|
? createPortal(modalContent, document.body)
|
||||||
|
: modalContent
|
||||||
|
}
|
||||||
|
|
||||||
function TodayEntriesList({ data, loading, error, onRetry, onDelete }) {
|
function TodayEntriesList({ data, loading, error, onRetry, onDelete }) {
|
||||||
const { authFetch } = useAuth()
|
const { authFetch } = useAuth()
|
||||||
const [deletingIds, setDeletingIds] = useState(new Set())
|
const [deletingIds, setDeletingIds] = useState(new Set())
|
||||||
const [selectedTaskId, setSelectedTaskId] = useState(null)
|
const [selectedTaskId, setSelectedTaskId] = useState(null)
|
||||||
|
const [editingEntry, setEditingEntry] = useState(null)
|
||||||
|
const [removingAutoCompleteId, setRemovingAutoCompleteId] = useState(null)
|
||||||
const selectedTaskIdRef = useRef(null)
|
const selectedTaskIdRef = useRef(null)
|
||||||
const historyPushedRef = useRef(false)
|
const historyPushedRef = useRef(false)
|
||||||
|
|
||||||
@@ -181,8 +399,26 @@ function TodayEntriesList({ data, loading, error, onRetry, onDelete }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleTaskSaved = () => {
|
const handleTaskSaved = () => {
|
||||||
if (onDelete) {
|
if (onDelete) onDelete()
|
||||||
onDelete()
|
}
|
||||||
|
|
||||||
|
const handleRemoveAutoComplete = async (taskId) => {
|
||||||
|
if (!window.confirm('Убрать автовыполнение этой задачи в конце дня?')) return
|
||||||
|
|
||||||
|
setRemovingAutoCompleteId(taskId)
|
||||||
|
try {
|
||||||
|
const response = await authFetch(`/api/tasks/${taskId}/draft`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ auto_complete: false })
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error('Ошибка при обновлении')
|
||||||
|
if (onDelete) onDelete()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Remove auto-complete failed:', err)
|
||||||
|
alert(err.message || 'Не удалось убрать автовыполнение')
|
||||||
|
} finally {
|
||||||
|
setRemovingAutoCompleteId(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,9 +434,7 @@ function TodayEntriesList({ data, loading, error, onRetry, onDelete }) {
|
|||||||
try {
|
try {
|
||||||
const response = await authFetch(`/api/entries/${entryId}`, {
|
const response = await authFetch(`/api/entries/${entryId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -209,10 +443,7 @@ function TodayEntriesList({ data, loading, error, onRetry, onDelete }) {
|
|||||||
throw new Error(`Ошибка при удалении записи: ${response.status}`)
|
throw new Error(`Ошибка при удалении записи: ${response.status}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Вызываем callback для обновления данных
|
if (onDelete) onDelete()
|
||||||
if (onDelete) {
|
|
||||||
onDelete()
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Delete failed:', err)
|
console.error('Delete failed:', err)
|
||||||
alert(err.message || 'Не удалось удалить запись')
|
alert(err.message || 'Не удалось удалить запись')
|
||||||
@@ -255,12 +486,24 @@ function TodayEntriesList({ data, loading, error, onRetry, onDelete }) {
|
|||||||
<div
|
<div
|
||||||
key={isDraft ? `draft-${entry.task_id}` : entry.id}
|
key={isDraft ? `draft-${entry.task_id}` : entry.id}
|
||||||
className={`bg-white rounded-lg p-4 shadow-sm border relative group ${
|
className={`bg-white rounded-lg p-4 shadow-sm border relative group ${
|
||||||
isDraft ? 'border-blue-400' : 'border-gray-200'
|
isDraft ? 'border-blue-400 cursor-pointer' : 'border-gray-200 cursor-pointer'
|
||||||
}`}
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (isDraft) {
|
||||||
|
handleOpenTaskDetail(entry.task_id)
|
||||||
|
} else {
|
||||||
|
setEditingEntry(entry)
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{isDraft ? (
|
{isDraft ? (
|
||||||
|
// Кнопка-молния с зачёркиванием — убрать автовыполнение
|
||||||
<button
|
<button
|
||||||
onClick={() => handleOpenTaskDetail(entry.task_id)}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleRemoveAutoComplete(entry.task_id)
|
||||||
|
}}
|
||||||
|
disabled={removingAutoCompleteId === entry.task_id}
|
||||||
className="absolute top-4 right-4"
|
className="absolute top-4 right-4"
|
||||||
style={{
|
style={{
|
||||||
color: '#3b82f6',
|
color: '#3b82f6',
|
||||||
@@ -275,23 +518,27 @@ function TodayEntriesList({ data, loading, error, onRetry, onDelete }) {
|
|||||||
width: '24px',
|
width: '24px',
|
||||||
height: '24px',
|
height: '24px',
|
||||||
transition: 'all 0.2s',
|
transition: 'all 0.2s',
|
||||||
|
opacity: removingAutoCompleteId === entry.task_id ? 0.5 : 1,
|
||||||
zIndex: 10
|
zIndex: 10
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = '#eff6ff' }}
|
||||||
e.currentTarget.style.backgroundColor = '#eff6ff'
|
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent' }}
|
||||||
}}
|
title="Убрать автовыполнение"
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = 'transparent'
|
|
||||||
}}
|
|
||||||
title="Редактировать задачу"
|
|
||||||
>
|
>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" title="Автовыполнение в конце дня">
|
{/* Молния с зачёркиванием */}
|
||||||
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"></path>
|
<svg width="16" height="16" viewBox="0 0 24 24">
|
||||||
|
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" fill="currentColor" />
|
||||||
|
{/* Зачёркивающая линия: белая с синей обводкой */}
|
||||||
|
<line x1="3" y1="21" x2="21" y2="3" stroke="white" strokeWidth="4" strokeLinecap="round" />
|
||||||
|
<line x1="3" y1="21" x2="21" y2="3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(entry.id)}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleDelete(entry.id)
|
||||||
|
}}
|
||||||
disabled={deletingIds.has(entry.id)}
|
disabled={deletingIds.has(entry.id)}
|
||||||
className="absolute top-4 right-4 disabled:opacity-50 disabled:cursor-not-allowed"
|
className="absolute top-4 right-4 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
style={{
|
style={{
|
||||||
@@ -361,6 +608,17 @@ function TodayEntriesList({ data, loading, error, onRetry, onDelete }) {
|
|||||||
onRefresh={handleTaskSaved}
|
onRefresh={handleTaskSaved}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{editingEntry && (
|
||||||
|
<EditEntryModal
|
||||||
|
entry={editingEntry}
|
||||||
|
onClose={() => setEditingEntry(null)}
|
||||||
|
onSuccess={() => {
|
||||||
|
setEditingEntry(null)
|
||||||
|
if (onDelete) onDelete()
|
||||||
|
}}
|
||||||
|
authFetch={authFetch}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user