fix: исправлен импорт TaskForm с явным расширением .jsx, версия 2.9.1
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 39s

This commit is contained in:
poignatov
2026-01-04 19:42:29 +03:00
parent 79430ba7f0
commit a6065d7ff1
11 changed files with 2823 additions and 31 deletions

View File

@@ -0,0 +1,16 @@
---
description: Перезапуск приложения после изменений в бэкенде или фронтенде
alwaysApply: true
---
## Правило перезапуска приложения
**ВАЖНО:** После применения всех изменений в бэкенде (`play-life-backend/`) или фронтенде (`play-life-web/`), а также после изменений в `docker-compose.yml`, **ОБЯЗАТЕЛЬНО** выполни команду `./run.sh` для перезапуска всех сервисов приложения.
Это правило применяется при работе с:
- Go кодом в `play-life-backend/`
- Миграциями базы данных в `play-life-backend/migrations/`
- React компонентами и стилями в `play-life-web/src/`
- Docker конфигурациями (`docker-compose.yml`, `Dockerfile`)
**Команда для перезапуска:** `./run.sh` или `bash run.sh` в корне проекта.

26
.vscode/launch.json vendored
View File

@@ -1,28 +1,4 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Restart Server",
"type": "node",
"request": "launch",
"runtimeExecutable": "bash",
"runtimeArgs": ["${workspaceFolder}/run.sh"],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"skipFiles": ["<node_internals>/**"]
},
{
"name": "Init Server",
"type": "node",
"request": "launch",
"runtimeExecutable": "bash",
"runtimeArgs": ["${workspaceFolder}/init.sh"],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"skipFiles": ["<node_internals>/**"]
}
]
"configurations": []
}

View File

@@ -1 +1 @@
2.9.0
2.9.1

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,58 @@
-- Migration: Add tasks and reward_configs tables
-- This script creates tables for task management system
-- ============================================
-- Table: tasks
-- ============================================
CREATE TABLE IF NOT EXISTS tasks (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
completed INTEGER DEFAULT 0,
last_completed_at TIMESTAMP WITH TIME ZONE,
parent_task_id INTEGER REFERENCES tasks(id) ON DELETE CASCADE,
reward_message TEXT,
progression_base NUMERIC(10,4),
deleted BOOLEAN DEFAULT FALSE
);
CREATE INDEX IF NOT EXISTS idx_tasks_user_id ON tasks(user_id);
CREATE INDEX IF NOT EXISTS idx_tasks_parent_task_id ON tasks(parent_task_id);
CREATE INDEX IF NOT EXISTS idx_tasks_deleted ON tasks(deleted);
CREATE INDEX IF NOT EXISTS idx_tasks_last_completed_at ON tasks(last_completed_at);
-- ============================================
-- Table: reward_configs
-- ============================================
CREATE TABLE IF NOT EXISTS reward_configs (
id SERIAL PRIMARY KEY,
position INTEGER NOT NULL,
task_id INTEGER REFERENCES tasks(id) ON DELETE CASCADE,
project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE,
value NUMERIC(10,4) NOT NULL,
use_progression BOOLEAN DEFAULT FALSE
);
CREATE INDEX IF NOT EXISTS idx_reward_configs_task_id ON reward_configs(task_id);
CREATE INDEX IF NOT EXISTS idx_reward_configs_project_id ON reward_configs(project_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_reward_configs_task_position ON reward_configs(task_id, position);
-- ============================================
-- Comments for documentation
-- ============================================
COMMENT ON TABLE tasks IS 'Tasks table for task management system';
COMMENT ON COLUMN tasks.name IS 'Task name (required for main tasks, optional for subtasks)';
COMMENT ON COLUMN tasks.completed IS 'Number of times task was completed';
COMMENT ON COLUMN tasks.last_completed_at IS 'Date and time of last task completion';
COMMENT ON COLUMN tasks.parent_task_id IS 'Parent task ID for subtasks (NULL for main tasks)';
COMMENT ON COLUMN tasks.reward_message IS 'Reward message template with placeholders ${0}, ${1}, etc.';
COMMENT ON COLUMN tasks.progression_base IS 'Base value for progression calculation (NULL means no progression)';
COMMENT ON COLUMN tasks.deleted IS 'Soft delete flag';
COMMENT ON TABLE reward_configs IS 'Reward configurations for tasks';
COMMENT ON COLUMN reward_configs.position IS 'Position in reward_message template (0, 1, 2, etc.)';
COMMENT ON COLUMN reward_configs.task_id IS 'Task this reward belongs to';
COMMENT ON COLUMN reward_configs.project_id IS 'Project to add reward to';
COMMENT ON COLUMN reward_configs.value IS 'Default score value (can be negative)';
COMMENT ON COLUMN reward_configs.use_progression IS 'Whether to use progression multiplier for this reward';

View File

@@ -0,0 +1,14 @@
-- Migration: Add repetition_period field to tasks table
-- This script adds the repetition_period field for recurring tasks
-- ============================================
-- Add repetition_period column
-- ============================================
ALTER TABLE tasks
ADD COLUMN IF NOT EXISTS repetition_period INTERVAL;
-- ============================================
-- Comments for documentation
-- ============================================
COMMENT ON COLUMN tasks.repetition_period IS 'Period after which task should be repeated (NULL means task is not recurring)';

View File

@@ -9,7 +9,7 @@ import AddConfig from './components/AddConfig'
import TestWords from './components/TestWords'
import Profile from './components/Profile'
import TaskList from './components/TaskList'
import TaskForm from './components/TaskForm'
import TaskForm from './components/TaskForm.jsx'
import { AuthProvider, useAuth } from './components/auth/AuthContext'
import AuthScreen from './components/auth/AuthScreen'

View File

@@ -0,0 +1,370 @@
.task-form {
padding: 1rem;
max-width: 800px;
margin: 0 auto;
position: relative;
}
.close-x-button {
position: absolute;
top: 1rem;
right: 1rem;
background: #f3f4f6;
border: 1px solid #e5e7eb;
border-radius: 50%;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 1.25rem;
color: #6b7280;
transition: all 0.2s;
}
.close-x-button:hover {
background: #e5e7eb;
color: #1f2937;
}
.task-form h2 {
font-size: 1.5rem;
font-weight: 600;
color: #1f2937;
margin: 0 0 1.5rem 0;
}
.task-form form {
background: white;
border-radius: 0.5rem;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
font-weight: 500;
color: #374151;
margin-bottom: 0.5rem;
}
.form-input,
.form-textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 1rem;
transition: all 0.2s;
}
.form-input:focus,
.form-textarea:focus {
outline: none;
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.form-textarea {
resize: vertical;
min-height: 80px;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: normal;
cursor: pointer;
}
.checkbox-label input[type="checkbox"] {
margin-right: 0.5rem;
}
.form-group label input[type="checkbox"] {
margin-right: 0.5rem;
}
.progression-button {
padding: 0.5rem;
border: 2px solid #d1d5db;
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
min-width: 2.5rem;
height: 2.5rem;
background: transparent;
color: #6b7280;
}
.progression-button-outlined {
background: transparent;
color: #6b7280;
border-color: #d1d5db;
}
.progression-button-filled {
background: #10b981;
color: white;
border-color: #10b981;
}
.progression-button:hover {
background: #f3f4f6;
color: #6b7280;
border-color: #9ca3af;
}
.progression-button-filled:hover {
background: #059669;
border-color: #059669;
}
.progression-button:focus {
outline: none;
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.2);
}
.progression-button-outlined:focus {
background: transparent !important;
color: #6b7280 !important;
border-color: #d1d5db !important;
}
.progression-button-filled:focus {
background: #10b981 !important;
color: white !important;
border-color: #10b981 !important;
}
.progression-button-subtask.progression-button-filled {
background: #10b981;
color: white;
border-color: #10b981;
}
.progression-button-subtask.progression-button-filled:hover {
background: #059669;
border-color: #059669;
}
.progression-button-subtask.progression-button-filled:focus {
background: #10b981 !important;
color: white !important;
border-color: #10b981 !important;
}
.rewards-container {
margin-top: 0.75rem;
}
.reward-item {
display: flex;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.75rem;
}
.reward-item:last-child {
margin-bottom: 0;
}
.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;
flex-shrink: 0;
}
.subtask-name-input {
margin-bottom: 0.75rem;
}
.reward-item .form-input {
flex: 1;
}
.reward-item .reward-project-input {
flex: 3;
}
.reward-item .reward-score-input {
flex: 1;
}
.subtasks-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.subtasks-header label {
margin: 0;
display: flex;
align-items: center;
height: 2rem;
line-height: 2rem;
}
.add-subtask-button {
padding: 0.375rem;
background: #6366f1;
color: white;
border: none;
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
min-width: 2rem;
height: 2rem;
}
.add-subtask-button:hover {
background: #4f46e5;
}
.subtask-form-item {
padding: 1rem;
background: #f9fafb;
border-radius: 0.375rem;
border: 1px solid #e5e7eb;
margin-bottom: 1rem;
}
.subtask-header-row {
display: flex;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.75rem;
}
.subtask-name-input {
flex: 1;
margin-bottom: 0;
}
.subtask-rewards {
margin-top: 0.75rem;
}
.remove-subtask-button {
padding: 0.5rem;
background: #ef4444;
color: white;
border: none;
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
min-width: 2.5rem;
height: 2.5rem;
}
.remove-subtask-button:hover {
background: #dc2626;
}
.error-message {
color: #ef4444;
margin-bottom: 1rem;
padding: 0.75rem;
background: #fef2f2;
border-radius: 0.375rem;
border: 1px solid #fecaca;
}
.form-actions {
display: flex;
gap: 1rem;
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid #e5e7eb;
}
.cancel-button,
.submit-button,
.delete-button {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 0.375rem;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.cancel-button {
background: #f3f4f6;
color: #374151;
}
.cancel-button:hover {
background: #e5e7eb;
}
.submit-button {
background: linear-gradient(to right, #6366f1, #8b5cf6);
color: white;
flex: 1;
}
.submit-button:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
}
.submit-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.delete-button {
background: #ef4444;
color: white;
padding: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
min-width: 44px;
width: 44px;
}
.delete-button:hover:not(:disabled) {
background: #dc2626;
}
.delete-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.loading {
text-align: center;
padding: 3rem 1rem;
color: #6b7280;
}

View File

@@ -0,0 +1,744 @@
import React, { useState, useEffect, useRef } from 'react'
import { useAuth } from './auth/AuthContext'
import './TaskForm.css'
const API_URL = '/api/tasks'
const PROJECTS_API_URL = '/projects'
function TaskForm({ onNavigate, taskId }) {
const { authFetch } = useAuth()
const [name, setName] = useState('')
const [progressionBase, setProgressionBase] = useState('')
const [rewardMessage, setRewardMessage] = useState('')
const [repetitionPeriodValue, setRepetitionPeriodValue] = useState('')
const [repetitionPeriodType, setRepetitionPeriodType] = useState('day')
const [rewards, setRewards] = useState([])
const [subtasks, setSubtasks] = useState([])
const [projects, setProjects] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [loadingTask, setLoadingTask] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const debounceTimer = useRef(null)
// Загрузка проектов для автокомплита
useEffect(() => {
const loadProjects = async () => {
try {
const response = await authFetch(PROJECTS_API_URL)
if (response.ok) {
const data = await response.json()
setProjects(Array.isArray(data) ? data : [])
}
} catch (err) {
console.error('Error loading projects:', err)
}
}
loadProjects()
}, [])
// Функция сброса формы
const resetForm = () => {
setName('')
setRewardMessage('')
setProgressionBase('')
setRepetitionPeriodValue('')
setRepetitionPeriodType('day')
setRewards([])
setSubtasks([])
setError('')
setLoadingTask(false)
if (debounceTimer.current) {
clearTimeout(debounceTimer.current)
debounceTimer.current = null
}
}
// Загрузка задачи при редактировании или сброс формы при создании новой
useEffect(() => {
if (taskId !== undefined && taskId !== null) {
loadTask()
} else {
// Сбрасываем форму при создании новой задачи
resetForm()
}
}, [taskId])
const loadTask = async () => {
setLoadingTask(true)
try {
const response = await authFetch(`${API_URL}/${taskId}`)
if (!response.ok) {
throw new Error('Ошибка загрузки задачи')
}
const data = await response.json()
setName(data.task.name)
setRewardMessage(data.task.reward_message || '')
setProgressionBase(data.task.progression_base ? String(data.task.progression_base) : '')
// Парсим repetition_period если он есть
if (data.task.repetition_period) {
const periodStr = data.task.repetition_period.trim()
console.log('Parsing repetition_period:', periodStr, 'Full task data:', data.task) // Отладка
// PostgreSQL может возвращать INTERVAL в разных форматах:
// - "1 day" / "1 days" / "10 days"
// - "02:00:00" (часы в формате времени)
// - "21 days" (недели преобразуются в дни)
// - "1 month" / "1 months" / "1 mon"
let parsed = false
// Пробуем парсить формат "N unit" или "N units"
// Используем более гибкий regex для парсинга
const match = periodStr.match(/^(\d+)\s+(minute|minutes|hour|hours|day|days|week|weeks|month|months|mon|year|years)/i)
if (match) {
const value = parseInt(match[1], 10)
const unit = match[2].toLowerCase()
console.log('Matched value:', value, 'unit:', unit) // Отладка
if (!isNaN(value) && value >= 0) {
// Преобразуем единицы PostgreSQL в наш формат
if (unit.startsWith('minute')) {
setRepetitionPeriodValue(String(value))
setRepetitionPeriodType('minute')
parsed = true
} else if (unit.startsWith('hour')) {
setRepetitionPeriodValue(String(value))
setRepetitionPeriodType('hour')
parsed = true
} else if (unit.startsWith('day')) {
// Может быть "1 day" или "10 days" или "21 days" (для недель)
// Если значение кратно 7, это может быть неделя
if (value % 7 === 0 && value >= 7) {
setRepetitionPeriodValue(String(value / 7))
setRepetitionPeriodType('week')
} else {
setRepetitionPeriodValue(String(value))
setRepetitionPeriodType('day')
}
parsed = true
} else if (unit.startsWith('week')) {
setRepetitionPeriodValue(String(value))
setRepetitionPeriodType('week')
parsed = true
} else if (unit.startsWith('month') || unit.startsWith('mon')) {
// PostgreSQL возвращает "1 mon" для месяцев
setRepetitionPeriodValue(String(value))
setRepetitionPeriodType('month')
parsed = true
} else if (unit.startsWith('year')) {
setRepetitionPeriodValue(String(value))
setRepetitionPeriodType('year')
parsed = true
}
}
} else {
// Если regex не сработал, пробуем старый способ через split
const parts = periodStr.split(/\s+/)
if (parts.length >= 2) {
const value = parseInt(parts[0], 10)
if (!isNaN(value) && value >= 0) {
const unit = parts[1].toLowerCase()
console.log('Fallback parsing - value:', value, 'unit:', unit) // Отладка
if (unit.startsWith('minute')) {
setRepetitionPeriodValue(String(value))
setRepetitionPeriodType('minute')
parsed = true
} else if (unit.startsWith('hour')) {
setRepetitionPeriodValue(String(value))
setRepetitionPeriodType('hour')
parsed = true
} else if (unit.startsWith('day')) {
if (value % 7 === 0 && value >= 7) {
setRepetitionPeriodValue(String(value / 7))
setRepetitionPeriodType('week')
} else {
setRepetitionPeriodValue(String(value))
setRepetitionPeriodType('day')
}
parsed = true
} else if (unit.startsWith('week')) {
setRepetitionPeriodValue(String(value))
setRepetitionPeriodType('week')
parsed = true
} else if (unit.startsWith('month') || unit.startsWith('mon')) {
setRepetitionPeriodValue(String(value))
setRepetitionPeriodType('month')
parsed = true
} else if (unit.startsWith('year')) {
setRepetitionPeriodValue(String(value))
setRepetitionPeriodType('year')
parsed = true
}
}
}
}
// Если не удалось распарсить, пробуем формат времени "HH:MM:SS"
if (!parsed && /^\d{1,2}:\d{2}:\d{2}/.test(periodStr)) {
const timeParts = periodStr.split(':')
if (timeParts.length >= 3) {
const hours = parseInt(timeParts[0], 10)
if (!isNaN(hours) && hours >= 0) {
setRepetitionPeriodValue(String(hours))
setRepetitionPeriodType('hour')
parsed = true
}
}
}
// Если не удалось распарсить, сбрасываем значения
if (!parsed) {
console.log('Failed to parse repetition_period:', periodStr) // Отладка
setRepetitionPeriodValue('')
setRepetitionPeriodType('day')
} else {
console.log('Successfully parsed repetition_period - value will be set') // Отладка
}
} else {
console.log('No repetition_period in task data') // Отладка
setRepetitionPeriodValue('')
setRepetitionPeriodType('day')
}
// Загружаем rewards
setRewards(data.rewards.map(r => ({
position: r.position,
project_name: r.project_name,
value: String(r.value),
use_progression: r.use_progression
})))
// Загружаем подзадачи
setSubtasks(data.subtasks.map(st => ({
id: st.task.id,
name: st.task.name || '',
reward_message: st.task.reward_message || '',
rewards: st.rewards.map(r => ({
position: r.position,
project_name: r.project_name,
value: String(r.value),
use_progression: r.use_progression
}))
})))
} catch (err) {
setError(err.message)
} finally {
setLoadingTask(false)
}
}
// Пересчет rewards при изменении reward_message (debounce)
useEffect(() => {
if (debounceTimer.current) {
clearTimeout(debounceTimer.current)
}
debounceTimer.current = setTimeout(() => {
const maxIndex = findMaxPlaceholderIndex(rewardMessage)
const currentRewards = [...rewards]
// Удаляем лишние rewards
while (currentRewards.length > maxIndex + 1) {
currentRewards.pop()
}
// Добавляем недостающие rewards
while (currentRewards.length < maxIndex + 1) {
currentRewards.push({
position: currentRewards.length,
project_name: '',
value: '0',
use_progression: false
})
}
setRewards(currentRewards)
}, 500)
return () => {
if (debounceTimer.current) {
clearTimeout(debounceTimer.current)
}
}
}, [rewardMessage])
const findMaxPlaceholderIndex = (message) => {
if (!message) return -1
const matches = message.match(/\$\{(\d+)\}/g)
if (!matches) return -1
const indices = matches.map(m => parseInt(m.match(/\d+/)[0]))
return Math.max(...indices)
}
const handleRewardChange = (index, field, value) => {
const newRewards = [...rewards]
newRewards[index] = { ...newRewards[index], [field]: value }
setRewards(newRewards)
}
const handleRewardProgressionToggle = (index, checked) => {
const newRewards = [...rewards]
newRewards[index] = { ...newRewards[index], use_progression: checked }
setRewards(newRewards)
}
const handleAddSubtask = () => {
setSubtasks([...subtasks, {
id: null,
name: '',
reward_message: '',
rewards: []
}])
}
const handleSubtaskChange = (index, field, value) => {
const newSubtasks = [...subtasks]
newSubtasks[index] = { ...newSubtasks[index], [field]: value }
setSubtasks(newSubtasks)
}
const handleSubtaskRewardMessageChange = (index, value) => {
const newSubtasks = [...subtasks]
newSubtasks[index] = { ...newSubtasks[index], reward_message: value }
// Пересчитываем rewards для подзадачи
const maxIndex = findMaxPlaceholderIndex(value)
const currentRewards = newSubtasks[index].rewards || []
const newRewards = [...currentRewards]
while (newRewards.length < maxIndex + 1) {
newRewards.push({
position: newRewards.length,
project_name: '',
value: '0',
use_progression: false
})
}
while (newRewards.length > maxIndex + 1) {
newRewards.pop()
}
newSubtasks[index] = { ...newSubtasks[index], rewards: newRewards }
setSubtasks(newSubtasks)
}
const handleRemoveSubtask = (index) => {
setSubtasks(subtasks.filter((_, i) => i !== index))
}
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
setLoading(true)
// Валидация
if (!name.trim() || name.trim().length < 1) {
setError('Название задачи обязательно (минимум 1 символ)')
setLoading(false)
return
}
// Проверяем, что все rewards заполнены
for (const reward of rewards) {
if (!reward.project_name.trim()) {
setError('Все проекты в наградах должны быть заполнены')
setLoading(false)
return
}
}
try {
// Преобразуем период повторения в строку INTERVAL для PostgreSQL
let repetitionPeriod = null
if (repetitionPeriodValue && repetitionPeriodValue.trim() !== '') {
const value = parseInt(repetitionPeriodValue.trim(), 10)
if (!isNaN(value) && value >= 0) {
const typeMap = {
'minute': 'minute',
'hour': 'hour',
'day': 'day',
'week': 'week',
'month': 'month',
'year': 'year'
}
const unit = typeMap[repetitionPeriodType] || 'day'
repetitionPeriod = `${value} ${unit}`
console.log('Sending repetition_period:', repetitionPeriod, 'from value:', repetitionPeriodValue, 'type:', repetitionPeriodType)
}
} else {
console.log('No repetition_period to send (value:', repetitionPeriodValue, 'type:', repetitionPeriodType, ')')
}
const payload = {
name: name.trim(),
reward_message: rewardMessage.trim() || null,
progression_base: progressionBase ? parseFloat(progressionBase) : null,
repetition_period: repetitionPeriod,
rewards: rewards.map(r => ({
position: r.position,
project_name: r.project_name.trim(),
value: parseFloat(r.value) || 0,
use_progression: !!(progressionBase && r.use_progression)
})),
subtasks: subtasks.map(st => ({
id: st.id || undefined,
name: st.name.trim() || null,
reward_message: st.reward_message.trim() || null,
rewards: st.rewards.map(r => ({
position: r.position,
project_name: r.project_name.trim(),
value: parseFloat(r.value) || 0,
use_progression: !!(progressionBase && r.use_progression)
}))
}))
}
const url = taskId ? `${API_URL}/${taskId}` : API_URL
const method = taskId ? 'PUT' : 'POST'
const response = await authFetch(url, {
method,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
})
if (!response.ok) {
let errorMessage = 'Ошибка при сохранении задачи'
try {
const errorData = await response.json()
errorMessage = errorData.message || errorData.error || errorMessage
} catch (e) {
// Если не удалось распарсить JSON, используем текст ответа
const text = await response.text().catch(() => '')
if (text) {
errorMessage = text
}
}
throw new Error(errorMessage)
}
// Возвращаемся к списку задач
onNavigate?.('tasks')
} catch (err) {
setError(err.message)
console.error('Error saving task:', err)
} finally {
setLoading(false)
}
}
const handleCancel = () => {
resetForm()
onNavigate?.('tasks')
}
const handleDelete = async () => {
if (!taskId) return
if (!window.confirm(`Вы уверены, что хотите удалить задачу "${name}"?`)) {
return
}
setIsDeleting(true)
try {
const response = await authFetch(`${API_URL}/${taskId}`, {
method: 'DELETE',
})
if (!response.ok) {
throw new Error('Ошибка при удалении задачи')
}
// Возвращаемся к списку задач
onNavigate?.('tasks')
} catch (err) {
console.error('Error deleting task:', err)
setError('Ошибка при удалении задачи')
setIsDeleting(false)
}
}
if (loadingTask) {
return (
<div className="task-form">
<div className="loading">Загрузка...</div>
</div>
)
}
return (
<div className="task-form">
<button className="close-x-button" onClick={handleCancel}>
</button>
<h2>{taskId ? 'Редактировать задачу' : 'Новая задача'}</h2>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="name">Название задачи *</label>
<input
id="name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
required
minLength={1}
className="form-input"
/>
</div>
<div className="form-group">
<label htmlFor="progression_base">Прогрессия</label>
<input
id="progression_base"
type="number"
step="any"
value={progressionBase}
onChange={(e) => setProgressionBase(e.target.value)}
placeholder="Базовое значение"
className="form-input"
/>
<small style={{ color: '#666', fontSize: '0.9em' }}>
Оставьте пустым, если прогрессия не используется
</small>
</div>
<div className="form-group">
<label htmlFor="repetition_period">Период повторения</label>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<input
id="repetition_period"
type="number"
min="0"
value={repetitionPeriodValue}
onChange={(e) => setRepetitionPeriodValue(e.target.value)}
placeholder="Число"
className="form-input"
style={{ flex: '1' }}
/>
{repetitionPeriodValue && repetitionPeriodValue.trim() !== '' && parseInt(repetitionPeriodValue.trim(), 10) !== 0 && (
<select
value={repetitionPeriodType}
onChange={(e) => setRepetitionPeriodType(e.target.value)}
className="form-input"
style={{ width: '120px' }}
>
<option value="minute">Минута</option>
<option value="hour">Час</option>
<option value="day">День</option>
<option value="week">Неделя</option>
<option value="month">Месяц</option>
<option value="year">Год</option>
</select>
)}
</div>
<small style={{ color: '#666', fontSize: '0.9em' }}>
Оставьте пустым, если задача не повторяется. Введите 0, если задача никогда не переносится в выполненные.
</small>
</div>
<div className="form-group">
<label htmlFor="reward_message">Сообщение награды</label>
<textarea
id="reward_message"
value={rewardMessage}
onChange={(e) => setRewardMessage(e.target.value)}
placeholder="Используйте ${0}, ${1} для указания проектов"
className="form-textarea"
rows={3}
/>
{rewards.length > 0 && (
<div className="rewards-container">
{rewards.map((reward, index) => (
<div key={index} className="reward-item">
<span className="reward-number">{index}</span>
<input
type="text"
value={reward.project_name}
onChange={(e) => handleRewardChange(index, 'project_name', e.target.value)}
placeholder="Проект"
className="form-input reward-project-input"
list={`projects-list-${index}`}
/>
<datalist id={`projects-list-${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="Score"
className="form-input reward-score-input"
/>
{progressionBase && (
<button
type="button"
tabIndex={0}
className={`progression-button ${reward.use_progression ? 'progression-button-filled' : 'progression-button-outlined'}`}
onMouseDown={(e) => {
e.preventDefault()
handleRewardProgressionToggle(index, !reward.use_progression)
}}
title={reward.use_progression ? 'Отключить прогрессию' : 'Включить прогрессию'}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
</svg>
</button>
)}
</div>
))}
</div>
)}
</div>
<div className="form-group">
<div className="subtasks-header">
<label>Подзадачи</label>
<button type="button" onClick={handleAddSubtask} className="add-subtask-button" title="Добавить подзадачу">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>
</div>
{subtasks.map((subtask, index) => (
<div key={index} className="subtask-form-item">
<div className="subtask-header-row">
<input
type="text"
value={subtask.name}
onChange={(e) => handleSubtaskChange(index, 'name', e.target.value)}
placeholder="Название подзадачи"
className="form-input subtask-name-input"
/>
<button
type="button"
onClick={() => handleRemoveSubtask(index)}
className="remove-subtask-button"
title="Удалить подзадачу"
>
<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>
<textarea
value={subtask.reward_message}
onChange={(e) => handleSubtaskRewardMessageChange(index, e.target.value)}
placeholder="Сообщение награды (опционально)"
className="form-textarea"
rows={2}
/>
{subtask.rewards && subtask.rewards.length > 0 && (
<div className="subtask-rewards">
{subtask.rewards.map((reward, rIndex) => (
<div key={rIndex} className="reward-item">
<span className="reward-number">{rIndex}</span>
<input
type="text"
value={reward.project_name}
onChange={(e) => {
const newSubtasks = [...subtasks]
newSubtasks[index].rewards[rIndex].project_name = e.target.value
setSubtasks(newSubtasks)
}}
placeholder="Проект"
className="form-input reward-project-input"
list={`subtask-projects-${index}-${rIndex}`}
/>
<datalist id={`subtask-projects-${index}-${rIndex}`}>
{projects.map(p => (
<option key={p.project_id} value={p.project_name} />
))}
</datalist>
<input
type="number"
step="any"
value={reward.value}
onChange={(e) => {
const newSubtasks = [...subtasks]
newSubtasks[index].rewards[rIndex].value = e.target.value
setSubtasks(newSubtasks)
}}
placeholder="Score"
className="form-input reward-score-input"
/>
{progressionBase && (
<button
type="button"
tabIndex={0}
className={`progression-button progression-button-subtask ${reward.use_progression ? 'progression-button-filled' : 'progression-button-outlined'}`}
onMouseDown={(e) => {
e.preventDefault()
const newSubtasks = [...subtasks]
newSubtasks[index].rewards[rIndex].use_progression = !newSubtasks[index].rewards[rIndex].use_progression
setSubtasks(newSubtasks)
}}
title={reward.use_progression ? 'Отключить прогрессию' : 'Включить прогрессию'}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
</svg>
</button>
)}
</div>
))}
</div>
)}
</div>
))}
</div>
{error && <div className="error-message">{error}</div>}
<div className="form-actions">
<button type="submit" disabled={loading || isDeleting} className="submit-button">
{loading ? 'Сохранение...' : 'Сохранить'}
</button>
{taskId && (
<button
type="button"
onClick={handleDelete}
className="delete-button"
disabled={isDeleting || loading}
title="Удалить задачу"
>
{isDeleting ? (
<span>...</span>
) : (
<svg width="20" height="20" 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>
</form>
</div>
)
}
export default TaskForm

View File

@@ -0,0 +1,285 @@
.task-list {
padding: 1rem;
max-width: 800px;
margin: 0 auto;
}
.add-task-button {
width: 100%;
padding: 0.75rem 1rem;
background: linear-gradient(to right, #6366f1, #8b5cf6);
color: white;
border: none;
border-radius: 0.5rem;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
margin-bottom: 1.5rem;
transition: all 0.2s;
}
.add-task-button:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
}
.task-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
.task-divider {
height: 1px;
background: linear-gradient(to right, transparent, #e5e7eb, transparent);
margin: 1rem 0;
}
.task-item {
background: white;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
padding: 1rem;
cursor: pointer;
transition: all 0.2s;
}
.task-item:hover {
border-color: #6366f1;
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.1);
}
.task-item-content {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
}
.task-checkmark {
flex-shrink: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #9ca3af;
transition: all 0.2s;
border-radius: 50%;
padding: 2px;
}
.task-checkmark:hover {
color: #6366f1;
background-color: #f3f4f6;
}
.task-checkmark .checkmark-check {
opacity: 0;
transition: opacity 0.2s;
}
.task-checkmark:hover .checkmark-check {
opacity: 1;
}
.task-checkmark-detail:hover {
color: #8b5cf6;
}
.task-name {
font-size: 1rem;
font-weight: 500;
color: #1f2937;
flex: 1;
}
.task-actions {
display: flex;
align-items: center;
gap: 1rem;
}
.task-completed-count {
color: #6b7280;
font-size: 0.875rem;
font-weight: 500;
}
.task-menu-button {
background: none;
border: none;
font-size: 1.25rem;
color: #6b7280;
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
transition: all 0.2s;
}
.task-menu-button:hover {
background: #f3f4f6;
color: #1f2937;
}
.task-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: 50;
}
.task-modal {
background: white;
border-radius: 0.5rem;
padding: 0;
max-width: 400px;
width: 90%;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
}
.task-modal-header {
padding: 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.task-modal-header h3 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: #1f2937;
}
.task-modal-actions {
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.task-modal-edit,
.task-modal-delete {
width: 100%;
padding: 0.75rem;
border: none;
border-radius: 0.375rem;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.task-modal-edit {
background: #6366f1;
color: white;
}
.task-modal-edit:hover {
background: #4f46e5;
}
.task-modal-delete {
background: #ef4444;
color: white;
}
.task-modal-delete:hover:not(:disabled) {
background: #dc2626;
}
.task-modal-delete:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.loading,
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: #6b7280;
}
.empty-state p {
margin: 0;
font-size: 1rem;
}
.loading-details {
text-align: center;
padding: 1rem;
color: #6b7280;
font-size: 0.875rem;
}
.project-group {
margin-bottom: 2rem;
}
.project-group-header {
margin-bottom: 1rem;
}
.project-group-title {
font-size: 1.125rem;
font-weight: 600;
color: #1f2937;
margin: 0;
padding-bottom: 0.5rem;
border-bottom: 2px solid #e5e7eb;
}
.completed-section {
margin-top: 1rem;
}
.completed-toggle {
width: 100%;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
color: #6b7280;
transition: all 0.2s;
margin-bottom: 0.5rem;
}
.completed-toggle:hover {
background: #f3f4f6;
color: #1f2937;
}
.completed-toggle-icon {
font-size: 0.75rem;
transition: transform 0.2s;
}
.completed-tasks {
margin-top: 0.5rem;
}
.completed-tasks .task-item {
opacity: 0.7;
}
.empty-group {
padding: 1rem;
text-align: center;
color: #9ca3af;
font-size: 0.875rem;
font-style: italic;
}

14
run.sh
View File

@@ -39,14 +39,22 @@ echo ""
# Проверяем, запущены ли контейнеры
if docker-compose ps | grep -q "Up"; then
echo -e "${YELLOW}Перезапуск существующих контейнеров...${NC}"
docker-compose restart
echo " - Backend сервер"
echo " - Frontend приложение (с пересборкой)"
echo " - База данных"
# Пересобираем и перезапускаем веб-сервер с новыми изменениями
echo -e "${BLUE}Пересборка веб-приложения...${NC}"
docker-compose build play-life-web
docker-compose up -d play-life-web
# Перезапускаем остальные сервисы
docker-compose restart backend db
echo -e "${GREEN}✅ Контейнеры перезапущены${NC}"
else
echo -e "${YELLOW}Запуск контейнеров...${NC}"
echo " - База данных PostgreSQL 18.0 (порт: $DB_PORT)"
echo " - База данных PostgreSQL 15 (порт: $DB_PORT)"
echo " - Backend сервер (порт: $PORT)"
echo " - Frontend приложение (порт: $WEB_PORT)"
docker-compose up -d
docker-compose up -d --build
echo -e "${GREEN}✅ Контейнеры запущены${NC}"
fi