2026-02-08 17:01:36 +03:00
|
|
|
|
import React, { useState } from 'react'
|
|
|
|
|
|
import LoadingError from './LoadingError'
|
|
|
|
|
|
import { useAuth } from './auth/AuthContext'
|
|
|
|
|
|
|
|
|
|
|
|
// Функция для форматирования скор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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function TodayEntriesList({ data, loading, error, onRetry, onDelete }) {
|
|
|
|
|
|
const { authFetch } = useAuth()
|
|
|
|
|
|
const [deletingIds, setDeletingIds] = useState(new Set())
|
2026-03-13 14:43:19 +03:00
|
|
|
|
const [clearingAutoCompleteIds, setClearingAutoCompleteIds] = useState(new Set())
|
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',
|
|
|
|
|
|
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
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-13 14:43:19 +03:00
|
|
|
|
const handleClearAutoComplete = async (taskId) => {
|
|
|
|
|
|
if (clearingAutoCompleteIds.has(taskId)) return
|
|
|
|
|
|
|
|
|
|
|
|
if (!window.confirm('Снять автовыполнение в конце дня? Задача не будет выполнена автоматически.')) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setClearingAutoCompleteIds(prev => new Set(prev).add(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) {
|
|
|
|
|
|
const errorText = await response.text()
|
|
|
|
|
|
console.error('Clear auto_complete error:', response.status, errorText)
|
|
|
|
|
|
throw new Error(`Ошибка: ${response.status}`)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (onDelete) {
|
|
|
|
|
|
onDelete()
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('Clear auto_complete failed:', err)
|
|
|
|
|
|
alert(err.message || 'Не удалось снять автовыполнение')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setClearingAutoCompleteIds(prev => {
|
|
|
|
|
|
const next = new Set(prev)
|
|
|
|
|
|
next.delete(taskId)
|
|
|
|
|
|
return next
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 17:01:36 +03:00
|
|
|
|
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">
|
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 ${
|
|
|
|
|
|
isDraft ? 'border-blue-400' : 'border-gray-200'
|
|
|
|
|
|
}`}
|
2026-02-08 17:01:36 +03:00
|
|
|
|
>
|
2026-03-13 14:43:19 +03:00
|
|
|
|
{isDraft ? (
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => handleClearAutoComplete(entry.task_id)}
|
|
|
|
|
|
disabled={clearingAutoCompleteIds.has(entry.task_id)}
|
|
|
|
|
|
className="absolute top-4 right-4 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
|
|
|
|
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: clearingAutoCompleteIds.has(entry.task_id) ? 0.5 : 1,
|
|
|
|
|
|
zIndex: 10
|
|
|
|
|
|
}}
|
|
|
|
|
|
onMouseEnter={(e) => {
|
|
|
|
|
|
if (!clearingAutoCompleteIds.has(entry.task_id)) {
|
|
|
|
|
|
e.currentTarget.style.backgroundColor = '#eff6ff'
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
onMouseLeave={(e) => {
|
|
|
|
|
|
e.currentTarget.style.backgroundColor = 'transparent'
|
|
|
|
|
|
}}
|
|
|
|
|
|
title="Снять автовыполнение в конце дня"
|
|
|
|
|
|
>
|
|
|
|
|
|
{clearingAutoCompleteIds.has(entry.task_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="16" height="16" viewBox="0 0 24 24" fill="currentColor" title="Автовыполнение в конце дня">
|
|
|
|
|
|
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"></path>
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</button>
|
2026-02-08 17:01:36 +03:00
|
|
|
|
) : (
|
2026-03-13 14:43:19 +03:00
|
|
|
|
<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>
|
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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default TodayEntriesList
|