Files
play-life/play-life-web/src/components/TodayEntriesList.jsx
poignatov dff929c52c
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m15s
6.19.3: Навигация на редактирование из диалога задачи
2026-03-17 10:37:02 +03:00

629 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'
// Функция для форматирования скорa (аналогично formatScore из TaskDetail)
const formatScore = (num) => {
if (num === 0) return '0'
let str = num.toPrecision(4)
str = str.replace(/\.?0+$/, '')
if (str.includes('e+') || str.includes('e-')) {
const numValue = parseFloat(str)
if (Math.abs(numValue) >= 10000) {
return str
}
return numValue.toString().replace(/\.?0+$/, '')
}
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]
const scoreStr = node.score >= 0
? `${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]
const scoreStr = node.score >= 0
? `${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
while (searchIndex < currentText.length) {
// Ищем следующий маркер
let foundMarker = null
let markerIndex = currentText.length
// Ищем все маркеры
for (const marker in escapedMarkers) {
const index = currentText.indexOf(marker, searchIndex)
if (index !== -1 && index < markerIndex) {
markerIndex = index
foundMarker = marker
}
}
// Если нашли маркер
if (foundMarker) {
// Добавляем текст до маркера
if (markerIndex > searchIndex) {
result.push(currentText.substring(searchIndex, markerIndex))
}
// Добавляем элемент для маркера
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)
}
searchIndex = markerIndex + foundMarker.length
} else {
// Больше маркеров нет, добавляем оставшийся текст
if (searchIndex < currentText.length) {
result.push(currentText.substring(searchIndex))
}
break
}
}
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, onNavigate }) {
const { authFetch } = useAuth()
const [deletingIds, setDeletingIds] = useState(new Set())
const [selectedTaskId, setSelectedTaskId] = useState(null)
const [editingEntry, setEditingEntry] = useState(null)
const [removingAutoCompleteId, setRemovingAutoCompleteId] = useState(null)
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 = (skipHistoryBack = false) => {
if (!skipHistoryBack && historyPushedRef.current) {
window.history.back()
} else {
historyPushedRef.current = false
setSelectedTaskId(null)
}
}
const handleTaskSaved = () => {
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)
}
}
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}`)
}
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) {
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 (
<>
<div className="mt-2 mb-6">
<div className="space-y-3">
{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 ${
isDraft ? 'border-blue-400 cursor-pointer' : 'border-gray-200 cursor-pointer'
}`}
onClick={() => {
if (isDraft) {
handleOpenTaskDetail(entry.task_id)
} else {
setEditingEntry(entry)
}
}}
>
{isDraft ? (
// Кнопка-молния с зачёркиванием — убрать автовыполнение
<button
onClick={(e) => {
e.stopPropagation()
handleRemoveAutoComplete(entry.task_id)
}}
disabled={removingAutoCompleteId === entry.task_id}
className="absolute top-4 right-4"
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',
opacity: removingAutoCompleteId === entry.task_id ? 0.5 : 1,
zIndex: 10
}}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = '#eff6ff' }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent' }}
title="Убрать автовыполнение"
>
{/* Молния с зачёркиванием */}
<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>
</button>
) : (
<button
onClick={(e) => {
e.stopPropagation()
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)}
</div>
<div className="text-xs text-gray-500 mt-2">
{isDraft ? 'в конце дня' : (
entry.created_date && new Date(entry.created_date).toLocaleTimeString('ru-RU', {
hour: '2-digit',
minute: '2-digit'
})
)}
</div>
</div>
)
})}
</div>
</div>
{selectedTaskId && (
<TaskDetail
taskId={selectedTaskId}
onClose={handleCloseTaskDetail}
onRefresh={handleTaskSaved}
onNavigate={onNavigate}
/>
)}
{editingEntry && (
<EditEntryModal
entry={editingEntry}
onClose={() => setEditingEntry(null)}
onSuccess={() => {
setEditingEntry(null)
if (onDelete) onDelete()
}}
authFetch={authFetch}
/>
)}
</>
)
}
export default TodayEntriesList