4.26.0: Добавление записи и улучшения
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m19s

This commit is contained in:
poignatov
2026-02-06 19:28:49 +03:00
parent 9f37d8b518
commit f1c590de43
8 changed files with 613 additions and 25 deletions

View File

@@ -104,6 +104,9 @@ function AppContent() {
// Модальное окно выбора типа задачи
const [showAddModal, setShowAddModal] = useState(false)
// Ref для функции открытия модала добавления записи в CurrentWeek
const currentWeekAddModalRef = useRef(null)
// Кеширование данных
const [currentWeekData, setCurrentWeekData] = useState(null)
@@ -995,6 +998,9 @@ function AppContent() {
onRetry={fetchCurrentWeekData}
allProjectsData={fullStatisticsData}
onNavigate={handleNavigate}
onOpenAddModal={(setOpenFn) => {
currentWeekAddModalRef.current = setOpenFn
}}
/>
</div>
</div>
@@ -1206,7 +1212,10 @@ function AppContent() {
{loadedTabs.tracking && (
<div className={getTabContainerClasses('tracking')}>
<div className={getInnerContainerClasses('tracking')}>
<Tracking onNavigate={handleNavigate} activeTab={activeTab} />
<Tracking
onNavigate={handleNavigate}
activeTab={activeTab}
/>
</div>
</div>
)}
@@ -1290,6 +1299,32 @@ function AppContent() {
</button>
)}
{/* Кнопка добавления записи (только для таба current - экран прогресса) */}
{!isFullscreenTab && activeTab === 'current' && (
<button
onClick={() => {
if (currentWeekAddModalRef.current) {
currentWeekAddModalRef.current()
}
}}
className="fixed bottom-16 right-4 z-20 bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-700 hover:to-purple-700 text-white w-[61px] h-[61px] rounded-2xl shadow-lg transition-all duration-200 hover:scale-105 flex items-center justify-center"
title="Добавить запись"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 5v14M5 12h14" />
</svg>
</button>
)}
{/* Кнопка добавления словаря (только для таба dictionaries) */}
{activeTab === 'dictionaries' && (
<button

View File

@@ -0,0 +1,176 @@
/* Стили для модального окна добавления записи */
.add-entry-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
padding: 1rem;
}
.add-entry-modal {
background: white;
border-radius: 0.5rem;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
max-width: 400px;
width: calc(100% - 2rem);
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.add-entry-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem 0.5rem 1.5rem;
}
.add-entry-modal-title {
font-size: 1.25rem;
font-weight: 600;
color: #1f2937;
margin: 0;
}
.add-entry-close-button {
background: none;
border: none;
font-size: 1.5rem;
color: #6b7280;
cursor: pointer;
padding: 0;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.25rem;
transition: all 0.2s;
}
.add-entry-close-button:hover {
background: #f3f4f6;
color: #1f2937;
}
.add-entry-modal-content {
padding: 0.5rem 1.5rem 1.5rem 1.5rem;
overflow-y: auto;
overflow-x: hidden;
flex: 1;
}
.add-entry-field {
margin-bottom: 0.5rem;
}
.add-entry-label {
display: block;
font-size: 0.875rem;
font-weight: 600;
color: #374151;
margin-bottom: 0.5rem;
}
.add-entry-textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 1rem;
box-sizing: border-box;
resize: vertical;
font-family: inherit;
}
.add-entry-textarea:focus {
outline: none;
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.add-entry-rewards {
margin-bottom: 1.5rem;
}
.add-entry-reward-item {
display: flex;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.75rem;
min-width: 0;
}
.add-entry-reward-item:last-child {
margin-bottom: 0;
}
.add-entry-reward-number {
display: flex;
align-items: center;
justify-content: center;
min-width: 2rem;
height: 2rem;
background: #f3f4f6;
border-radius: 0.375rem;
font-size: 1rem;
font-weight: 600;
color: #6b7280;
}
.add-entry-input {
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 1rem;
box-sizing: border-box;
min-width: 0;
width: 100%;
}
.add-entry-input:focus {
outline: none;
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.add-entry-project-input {
flex: 3;
min-width: 0;
}
.add-entry-score-input {
flex: 1;
min-width: 0;
max-width: 100px;
}
.add-entry-submit-button {
width: 100%;
padding: 0.75rem 1.5rem;
background: linear-gradient(to right, #6366f1, #8b5cf6);
color: white;
border: none;
border-radius: 0.375rem;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.add-entry-submit-button:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
}
.add-entry-submit-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}

View File

@@ -1,9 +1,13 @@
import React from 'react'
import React, { useState, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { useAuth } from './auth/AuthContext'
import ProjectProgressBar from './ProjectProgressBar'
import LoadingError from './LoadingError'
import Toast from './Toast'
import { getAllProjectsSorted, getProjectColor } from '../utils/projectUtils'
import { CircularProgressbar, buildStyles } from 'react-circular-progressbar'
import 'react-circular-progressbar/dist/styles.css'
import './CurrentWeek.css'
// Компонент круглого прогрессбара с использованием react-circular-progressbar
function CircularProgressBar({ progress, size = 120, strokeWidth = 8, showCheckmark = true, textSize = 'large', displayProgress = null, textPosition = 'default', projectColor = null }) {
@@ -250,7 +254,279 @@ function PriorityGroup({ title, subtitle, projects, allProjects, onProjectClick
)
}
function CurrentWeek({ onProjectClick, data, loading, error, onRetry, allProjectsData, onNavigate }) {
// Компонент модального окна для добавления записи
function AddEntryModal({ onClose, onSuccess, authFetch, setToastMessage }) {
const [message, setMessage] = useState('')
const [rewards, setRewards] = useState([])
const [projects, setProjects] = useState([])
const [isSending, setIsSending] = useState(false)
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 = []
// Ищем ${N}
const matchesCurly = msg.match(/\$\{(\d+)\}/g) || []
matchesCurly.forEach(match => {
const numMatch = match.match(/\d+/)
if (numMatch) indices.push(parseInt(numMatch[0]))
})
// Ищем $N (но не \$N)
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 при изменении сообщения (debounce 500ms)
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
// Формируем строки замены для каждого reward
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
})
// Заменяем ${N}
for (let i = 0; i < 100; i++) {
const placeholder = `\${${i}}`
if (rewardStrings[i]) {
result = result.split(placeholder).join(rewardStrings[i])
}
}
// Заменяем $N (с конца, чтобы $10 не заменился раньше $1)
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()) {
setToastMessage({ text: 'Заполните все проекты', type: 'error' })
return
}
}
const finalMessage = buildFinalMessage()
if (!finalMessage.trim()) {
setToastMessage({ text: 'Введите сообщение', type: 'error' })
return
}
setIsSending(true)
try {
const response = await authFetch('/message/post', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: finalMessage })
})
if (!response.ok) {
throw new Error('Ошибка при отправке')
}
setToastMessage({ text: 'Запись добавлена', type: 'success' })
onSuccess()
} catch (err) {
console.error('Error sending message:', err)
setToastMessage({ text: err.message || 'Ошибка при отправке', type: 'error' })
} 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={`add-entry-projects-${index}`}
/>
<datalist id={`add-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>
)}
{/* Кнопка отправки */}
<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 CurrentWeek({ onProjectClick, data, loading, error, onRetry, allProjectsData, onNavigate, onOpenAddModal }) {
const { authFetch } = useAuth()
const [isAddModalOpen, setIsAddModalOpen] = useState(false)
const [toastMessage, setToastMessage] = useState(null)
// Экспортируем функцию открытия модала для использования из App.jsx
useEffect(() => {
if (onOpenAddModal) {
const openFn = () => {
setIsAddModalOpen(true)
}
onOpenAddModal(openFn)
}
}, [onOpenAddModal])
// Функция для обновления данных после добавления записи
const refreshData = () => {
if (onRetry) {
onRetry()
}
}
// Обрабатываем данные: может быть объект с projects и total, или просто массив
const projectsData = data?.projects || (Array.isArray(data) ? data : []) || []
@@ -384,7 +660,7 @@ function CurrentWeek({ onProjectClick, data, loading, error, onRetry, allProject
</div>
{/* Группы проектов по приоритетам */}
<div className="space-y-6">
<div className="space-y-6" style={{ paddingBottom: '5rem' }}>
<PriorityGroup
title="Главный"
subtitle={`${Math.round(mainProgress)}%`}
@@ -409,6 +685,28 @@ function CurrentWeek({ onProjectClick, data, loading, error, onRetry, allProject
onProjectClick={onProjectClick}
/>
</div>
{/* Модальное окно добавления записи */}
{isAddModalOpen && (
<AddEntryModal
onClose={() => setIsAddModalOpen(false)}
onSuccess={() => {
setIsAddModalOpen(false)
refreshData()
}}
authFetch={authFetch}
setToastMessage={setToastMessage}
/>
)}
{/* Toast уведомления */}
{toastMessage && (
<Toast
message={toastMessage.text}
type={toastMessage.type}
onClose={() => setToastMessage(null)}
/>
)}
</div>
)
}

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useMemo, useRef } from 'react'
import React, { useState, useEffect, useRef } from 'react'
import { useAuth } from './auth/AuthContext'
import './Tracking.css'
@@ -80,9 +80,8 @@ function Tracking({ onNavigate, activeTab }) {
}
}, [activeTab])
// Загрузка данных при смене недели
useEffect(() => {
const fetchData = async () => {
// Функция для обновления данных
const refreshData = async () => {
setLoading(true)
setError(null)
try {
@@ -98,7 +97,10 @@ function Tracking({ onNavigate, activeTab }) {
setLoading(false)
}
}
fetchData()
// Загрузка данных при смене недели
useEffect(() => {
refreshData()
}, [selectedWeek, authFetch])
return (