2026-03-13 15:49:09 +03:00
|
|
|
|
import React, { useState, useEffect, useRef } from 'react'
|
2026-03-13 21:48:11 +03:00
|
|
|
|
import { createPortal } from 'react-dom'
|
2026-02-08 17:01:36 +03:00
|
|
|
|
import LoadingError from './LoadingError'
|
|
|
|
|
|
import { useAuth } from './auth/AuthContext'
|
2026-03-13 15:49:09 +03:00
|
|
|
|
import TaskDetail from './TaskDetail'
|
2026-02-08 17:01:36 +03:00
|
|
|
|
|
|
|
|
|
|
// Функция для форматирования скорa (аналогично formatScore из TaskDetail)
|
|
|
|
|
|
const formatScore = (num) => {
|
|
|
|
|
|
if (num === 0) return '0'
|
2026-03-13 14:43:19 +03:00
|
|
|
|
|
2026-02-08 17:01:36 +03:00
|
|
|
|
let str = num.toPrecision(4)
|
|
|
|
|
|
str = str.replace(/\.?0+$/, '')
|
2026-03-13 14:43:19 +03:00
|
|
|
|
|
2026-02-08 17:01:36 +03:00
|
|
|
|
if (str.includes('e+') || str.includes('e-')) {
|
|
|
|
|
|
const numValue = parseFloat(str)
|
|
|
|
|
|
if (Math.abs(numValue) >= 10000) {
|
|
|
|
|
|
return str
|
|
|
|
|
|
}
|
|
|
|
|
|
return numValue.toString().replace(/\.?0+$/, '')
|
|
|
|
|
|
}
|
2026-03-13 14:43:19 +03:00
|
|
|
|
|
2026-02-08 17:01:36 +03:00
|
|
|
|
return str
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Функция для форматирования текста с заменой плейсхолдеров на nodes
|
|
|
|
|
|
const formatEntryText = (text, nodes) => {
|
|
|
|
|
|
if (!text || !nodes || nodes.length === 0) {
|
|
|
|
|
|
return text
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Создаем map для быстрого доступа к nodes по индексу
|
|
|
|
|
|
const nodesMap = {}
|
|
|
|
|
|
nodes.forEach(node => {
|
|
|
|
|
|
nodesMap[node.index] = node
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// Создаем массив для хранения частей текста и React элементов
|
|
|
|
|
|
const parts = []
|
|
|
|
|
|
let lastIndex = 0
|
|
|
|
|
|
let currentText = text
|
|
|
|
|
|
|
|
|
|
|
|
// Сначала защищаем экранированные плейсхолдеры
|
|
|
|
|
|
const escapedMarkers = {}
|
|
|
|
|
|
for (let i = 0; i < 100; i++) {
|
|
|
|
|
|
const escaped = `\\$${i}`
|
|
|
|
|
|
const marker = `__ESCAPED_DOLLAR_${i}__`
|
|
|
|
|
|
if (currentText.includes(escaped)) {
|
|
|
|
|
|
escapedMarkers[marker] = escaped
|
|
|
|
|
|
currentText = currentText.replace(new RegExp(escaped.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), marker)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Заменяем ${0}, ${1}, и т.д.
|
|
|
|
|
|
for (let i = 0; i < 100; i++) {
|
|
|
|
|
|
const placeholder = `\${${i}}`
|
|
|
|
|
|
const regex = new RegExp(placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')
|
|
|
|
|
|
if (nodesMap[i] && currentText.includes(placeholder)) {
|
|
|
|
|
|
const node = nodesMap[i]
|
2026-03-13 14:43:19 +03:00
|
|
|
|
const scoreStr = node.score >= 0
|
2026-02-08 17:01:36 +03:00
|
|
|
|
? `${node.project_name}+${formatScore(node.score)}`
|
|
|
|
|
|
: `${node.project_name}-${formatScore(Math.abs(node.score))}`
|
|
|
|
|
|
currentText = currentText.replace(regex, `__NODE_${i}__`)
|
|
|
|
|
|
// Сохраняем информацию о замене
|
|
|
|
|
|
if (!escapedMarkers[`__NODE_${i}__`]) {
|
|
|
|
|
|
escapedMarkers[`__NODE_${i}__`] = { type: 'node', text: scoreStr }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Заменяем $0, $1, и т.д. (с конца, чтобы не заменить $1 в $10)
|
|
|
|
|
|
for (let i = 99; i >= 0; i--) {
|
|
|
|
|
|
if (nodesMap[i]) {
|
|
|
|
|
|
const node = nodesMap[i]
|
2026-03-13 14:43:19 +03:00
|
|
|
|
const scoreStr = node.score >= 0
|
2026-02-08 17:01:36 +03:00
|
|
|
|
? `${node.project_name}+${formatScore(node.score)}`
|
|
|
|
|
|
: `${node.project_name}-${formatScore(Math.abs(node.score))}`
|
|
|
|
|
|
const regex = new RegExp(`\\$${i}(?!\\d)`, 'g')
|
|
|
|
|
|
if (currentText.match(regex)) {
|
|
|
|
|
|
currentText = currentText.replace(regex, `__NODE_${i}__`)
|
|
|
|
|
|
if (!escapedMarkers[`__NODE_${i}__`]) {
|
|
|
|
|
|
escapedMarkers[`__NODE_${i}__`] = { type: 'node', text: scoreStr }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Разбиваем текст на части и создаем React элементы
|
|
|
|
|
|
const result = []
|
|
|
|
|
|
let searchIndex = 0
|
2026-03-13 14:43:19 +03:00
|
|
|
|
|
2026-02-08 17:01:36 +03:00
|
|
|
|
while (searchIndex < currentText.length) {
|
|
|
|
|
|
// Ищем следующий маркер
|
|
|
|
|
|
let foundMarker = null
|
|
|
|
|
|
let markerIndex = currentText.length
|
2026-03-13 14:43:19 +03:00
|
|
|
|
|
2026-02-08 17:01:36 +03:00
|
|
|
|
// Ищем все маркеры
|
|
|
|
|
|
for (const marker in escapedMarkers) {
|
|
|
|
|
|
const index = currentText.indexOf(marker, searchIndex)
|
|
|
|
|
|
if (index !== -1 && index < markerIndex) {
|
|
|
|
|
|
markerIndex = index
|
|
|
|
|
|
foundMarker = marker
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-13 14:43:19 +03:00
|
|
|
|
|
2026-02-08 17:01:36 +03:00
|
|
|
|
// Если нашли маркер
|
|
|
|
|
|
if (foundMarker) {
|
|
|
|
|
|
// Добавляем текст до маркера
|
|
|
|
|
|
if (markerIndex > searchIndex) {
|
|
|
|
|
|
result.push(currentText.substring(searchIndex, markerIndex))
|
|
|
|
|
|
}
|
2026-03-13 14:43:19 +03:00
|
|
|
|
|
2026-02-08 17:01:36 +03:00
|
|
|
|
// Добавляем элемент для маркера
|
|
|
|
|
|
const markerData = escapedMarkers[foundMarker]
|
|
|
|
|
|
if (markerData && markerData.type === 'node') {
|
|
|
|
|
|
result.push(
|
|
|
|
|
|
<strong key={`node-${searchIndex}`}>{markerData.text}</strong>
|
|
|
|
|
|
)
|
|
|
|
|
|
} else if (typeof markerData === 'string') {
|
|
|
|
|
|
// Это экранированный плейсхолдер
|
|
|
|
|
|
result.push(markerData)
|
|
|
|
|
|
}
|
2026-03-13 14:43:19 +03:00
|
|
|
|
|
2026-02-08 17:01:36 +03:00
|
|
|
|
searchIndex = markerIndex + foundMarker.length
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Больше маркеров нет, добавляем оставшийся текст
|
|
|
|
|
|
if (searchIndex < currentText.length) {
|
|
|
|
|
|
result.push(currentText.substring(searchIndex))
|
|
|
|
|
|
}
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return result.length > 0 ? result : currentText
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-13 21:48:11 +03:00
|
|
|
|
// Модальное окно редактирования записи
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 17:01:36 +03:00
|
|
|
|
function TodayEntriesList({ data, loading, error, onRetry, onDelete }) {
|
|
|
|
|
|
const { authFetch } = useAuth()
|
|
|
|
|
|
const [deletingIds, setDeletingIds] = useState(new Set())
|
2026-03-13 15:49:09 +03:00
|
|
|
|
const [selectedTaskId, setSelectedTaskId] = useState(null)
|
2026-03-13 21:48:11 +03:00
|
|
|
|
const [editingEntry, setEditingEntry] = useState(null)
|
|
|
|
|
|
const [removingAutoCompleteId, setRemovingAutoCompleteId] = useState(null)
|
2026-03-13 15:49:09 +03:00
|
|
|
|
const selectedTaskIdRef = useRef(null)
|
|
|
|
|
|
const historyPushedRef = useRef(false)
|
|
|
|
|
|
|
|
|
|
|
|
// Обновляем ref при изменении selectedTaskId
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
selectedTaskIdRef.current = selectedTaskId
|
|
|
|
|
|
}, [selectedTaskId])
|
|
|
|
|
|
|
|
|
|
|
|
// Управление историей браузера при открытии/закрытии TaskDetail
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (selectedTaskId && !historyPushedRef.current) {
|
|
|
|
|
|
window.history.pushState({ modalOpen: true, type: 'task-detail-statistics' }, '', window.location.href)
|
|
|
|
|
|
historyPushedRef.current = true
|
|
|
|
|
|
} else if (!selectedTaskId) {
|
|
|
|
|
|
historyPushedRef.current = false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!selectedTaskId) return
|
|
|
|
|
|
|
|
|
|
|
|
const handlePopState = () => {
|
|
|
|
|
|
if (selectedTaskIdRef.current) {
|
|
|
|
|
|
setSelectedTaskId(null)
|
|
|
|
|
|
historyPushedRef.current = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
window.addEventListener('popstate', handlePopState)
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
window.removeEventListener('popstate', handlePopState)
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [selectedTaskId])
|
|
|
|
|
|
|
|
|
|
|
|
const handleOpenTaskDetail = (taskId) => {
|
|
|
|
|
|
setSelectedTaskId(taskId)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleCloseTaskDetail = () => {
|
|
|
|
|
|
if (historyPushedRef.current) {
|
|
|
|
|
|
window.history.back()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setSelectedTaskId(null)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleTaskSaved = () => {
|
2026-03-13 21:48:11 +03:00
|
|
|
|
if (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)
|
2026-03-13 15:49:09 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-08 17:01:36 +03:00
|
|
|
|
|
|
|
|
|
|
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',
|
2026-03-13 21:48:11 +03:00
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
2026-02-08 17:01:36 +03:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
const errorText = await response.text()
|
|
|
|
|
|
console.error('Delete error:', response.status, errorText)
|
|
|
|
|
|
throw new Error(`Ошибка при удалении записи: ${response.status}`)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-13 21:48:11 +03:00
|
|
|
|
if (onDelete) onDelete()
|
2026-02-08 17:01:36 +03:00
|
|
|
|
} 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) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex justify-center items-center py-8">
|
|
|
|
|
|
<div className="w-8 h-8 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin"></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (error) {
|
|
|
|
|
|
return <LoadingError onRetry={onRetry} />
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!data || data.length === 0) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="text-center py-8 text-gray-500">
|
|
|
|
|
|
Нет записей за выбранный день
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2026-03-13 15:49:09 +03:00
|
|
|
|
<>
|
2026-02-08 17:01:36 +03:00
|
|
|
|
<div className="mt-2 mb-6">
|
|
|
|
|
|
<div className="space-y-3">
|
2026-03-13 14:43:19 +03:00
|
|
|
|
{data.map((entry) => {
|
|
|
|
|
|
const isDraft = entry.is_draft === true
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={isDraft ? `draft-${entry.task_id}` : entry.id}
|
|
|
|
|
|
className={`bg-white rounded-lg p-4 shadow-sm border relative group ${
|
2026-03-13 21:48:11 +03:00
|
|
|
|
isDraft ? 'border-blue-400 cursor-pointer' : 'border-gray-200 cursor-pointer'
|
2026-03-13 14:43:19 +03:00
|
|
|
|
}`}
|
2026-03-13 21:48:11 +03:00
|
|
|
|
onClick={() => {
|
|
|
|
|
|
if (isDraft) {
|
|
|
|
|
|
handleOpenTaskDetail(entry.task_id)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setEditingEntry(entry)
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
2026-02-08 17:01:36 +03:00
|
|
|
|
>
|
2026-03-13 14:43:19 +03:00
|
|
|
|
{isDraft ? (
|
2026-03-13 21:48:11 +03:00
|
|
|
|
// Кнопка-молния с зачёркиванием — убрать автовыполнение
|
2026-03-13 14:43:19 +03:00
|
|
|
|
<button
|
2026-03-13 21:48:11 +03:00
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
e.stopPropagation()
|
|
|
|
|
|
handleRemoveAutoComplete(entry.task_id)
|
|
|
|
|
|
}}
|
|
|
|
|
|
disabled={removingAutoCompleteId === entry.task_id}
|
2026-03-13 15:49:09 +03:00
|
|
|
|
className="absolute top-4 right-4"
|
2026-03-13 14:43:19 +03:00
|
|
|
|
style={{
|
|
|
|
|
|
color: '#3b82f6',
|
|
|
|
|
|
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',
|
2026-03-13 21:48:11 +03:00
|
|
|
|
opacity: removingAutoCompleteId === entry.task_id ? 0.5 : 1,
|
2026-03-13 14:43:19 +03:00
|
|
|
|
zIndex: 10
|
|
|
|
|
|
}}
|
2026-03-13 21:48:11 +03:00
|
|
|
|
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = '#eff6ff' }}
|
|
|
|
|
|
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent' }}
|
|
|
|
|
|
title="Убрать автовыполнение"
|
2026-03-13 14:43:19 +03:00
|
|
|
|
>
|
2026-03-13 21:48:11 +03:00
|
|
|
|
{/* Молния с зачёркиванием */}
|
|
|
|
|
|
<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" />
|
2026-03-13 15:49:09 +03:00
|
|
|
|
</svg>
|
2026-03-13 14:43:19 +03:00
|
|
|
|
</button>
|
2026-02-08 17:01:36 +03:00
|
|
|
|
) : (
|
2026-03-13 14:43:19 +03:00
|
|
|
|
<button
|
2026-03-13 21:48:11 +03:00
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
e.stopPropagation()
|
|
|
|
|
|
handleDelete(entry.id)
|
|
|
|
|
|
}}
|
2026-03-13 14:43:19 +03:00
|
|
|
|
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>
|
2026-02-08 17:01:36 +03:00
|
|
|
|
)}
|
2026-03-13 14:43:19 +03:00
|
|
|
|
<div className="text-gray-800 whitespace-pre-wrap pr-8">
|
|
|
|
|
|
{formatEntryText(entry.text, entry.nodes)}
|
|
|
|
|
|
</div>
|
2026-02-08 17:01:36 +03:00
|
|
|
|
<div className="text-xs text-gray-500 mt-2">
|
2026-03-13 14:43:19 +03:00
|
|
|
|
{isDraft ? 'в конце дня' : (
|
|
|
|
|
|
entry.created_date && new Date(entry.created_date).toLocaleTimeString('ru-RU', {
|
|
|
|
|
|
hour: '2-digit',
|
|
|
|
|
|
minute: '2-digit'
|
|
|
|
|
|
})
|
|
|
|
|
|
)}
|
2026-02-08 17:01:36 +03:00
|
|
|
|
</div>
|
2026-03-13 14:43:19 +03:00
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
})}
|
2026-02-08 17:01:36 +03:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-03-13 15:49:09 +03:00
|
|
|
|
{selectedTaskId && (
|
|
|
|
|
|
<TaskDetail
|
|
|
|
|
|
taskId={selectedTaskId}
|
|
|
|
|
|
onClose={handleCloseTaskDetail}
|
|
|
|
|
|
onRefresh={handleTaskSaved}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-03-13 21:48:11 +03:00
|
|
|
|
{editingEntry && (
|
|
|
|
|
|
<EditEntryModal
|
|
|
|
|
|
entry={editingEntry}
|
|
|
|
|
|
onClose={() => setEditingEntry(null)}
|
|
|
|
|
|
onSuccess={() => {
|
|
|
|
|
|
setEditingEntry(null)
|
|
|
|
|
|
if (onDelete) onDelete()
|
|
|
|
|
|
}}
|
|
|
|
|
|
authFetch={authFetch}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-03-13 15:49:09 +03:00
|
|
|
|
</>
|
2026-02-08 17:01:36 +03:00
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default TodayEntriesList
|