feat: замена period_type на start_date в wishlist, обновление UI формы условий
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m5s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m5s
- Добавлена миграция 020 для замены period_type на start_date в score_conditions - Обновлена функция подсчёта баллов: calculateProjectPointsFromDate вместо calculateProjectPointsForPeriod - Добавлен компонент DateSelector для выбора даты начала подсчёта - По умолчанию выбран тип условия 'Баллы' - Переименованы опции: 'Баллы' и 'Задача' - Версия: 3.9.3
This commit is contained in:
@@ -293,7 +293,7 @@ type UnlockConditionDisplay struct {
|
|||||||
TaskName *string `json:"task_name,omitempty"`
|
TaskName *string `json:"task_name,omitempty"`
|
||||||
ProjectName *string `json:"project_name,omitempty"`
|
ProjectName *string `json:"project_name,omitempty"`
|
||||||
RequiredPoints *float64 `json:"required_points,omitempty"`
|
RequiredPoints *float64 `json:"required_points,omitempty"`
|
||||||
PeriodType *string `json:"period_type,omitempty"`
|
StartDate *string `json:"start_date,omitempty"` // Дата начала подсчёта (YYYY-MM-DD), NULL = за всё время
|
||||||
DisplayOrder int `json:"display_order"`
|
DisplayOrder int `json:"display_order"`
|
||||||
// Прогресс выполнения
|
// Прогресс выполнения
|
||||||
CurrentPoints *float64 `json:"current_points,omitempty"` // Текущее количество баллов (для project_points)
|
CurrentPoints *float64 `json:"current_points,omitempty"` // Текущее количество баллов (для project_points)
|
||||||
@@ -312,7 +312,7 @@ type UnlockConditionRequest struct {
|
|||||||
TaskID *int `json:"task_id,omitempty"`
|
TaskID *int `json:"task_id,omitempty"`
|
||||||
ProjectID *int `json:"project_id,omitempty"`
|
ProjectID *int `json:"project_id,omitempty"`
|
||||||
RequiredPoints *float64 `json:"required_points,omitempty"`
|
RequiredPoints *float64 `json:"required_points,omitempty"`
|
||||||
PeriodType *string `json:"period_type,omitempty"`
|
StartDate *string `json:"start_date,omitempty"` // Дата начала подсчёта (YYYY-MM-DD), NULL = за всё время
|
||||||
DisplayOrder *int `json:"display_order,omitempty"`
|
DisplayOrder *int `json:"display_order,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2816,6 +2816,12 @@ func (a *App) initAuthDB() error {
|
|||||||
// Не возвращаем ошибку, чтобы приложение могло запуститься
|
// Не возвращаем ошибку, чтобы приложение могло запуститься
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply migration 020: Change period_type to start_date in score_conditions
|
||||||
|
if err := a.applyMigration020(); err != nil {
|
||||||
|
log.Printf("Warning: Failed to apply migration 020: %v", err)
|
||||||
|
// Не возвращаем ошибку, чтобы приложение могло запуститься
|
||||||
|
}
|
||||||
|
|
||||||
// Clean up expired refresh tokens (only those with expiration date set)
|
// Clean up expired refresh tokens (only those with expiration date set)
|
||||||
a.DB.Exec("DELETE FROM refresh_tokens WHERE expires_at IS NOT NULL AND expires_at < NOW()")
|
a.DB.Exec("DELETE FROM refresh_tokens WHERE expires_at IS NOT NULL AND expires_at < NOW()")
|
||||||
|
|
||||||
@@ -2993,6 +2999,52 @@ func (a *App) applyMigration019() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// applyMigration020 применяет миграцию 020_change_period_to_start_date.sql
|
||||||
|
func (a *App) applyMigration020() error {
|
||||||
|
log.Printf("Applying migration 020: Change period_type to start_date in score_conditions")
|
||||||
|
|
||||||
|
// Проверяем, существует ли уже поле start_date
|
||||||
|
var exists bool
|
||||||
|
err := a.DB.QueryRow(`
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'score_conditions'
|
||||||
|
AND column_name = 'start_date'
|
||||||
|
)
|
||||||
|
`).Scan(&exists)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check if start_date exists: %w", err)
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
log.Printf("Migration 020 already applied (start_date column exists), skipping")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Читаем SQL файл миграции
|
||||||
|
migrationPath := "/migrations/020_change_period_to_start_date.sql"
|
||||||
|
if _, err := os.Stat(migrationPath); os.IsNotExist(err) {
|
||||||
|
// Пробуем альтернативный путь (для локальной разработки)
|
||||||
|
migrationPath = "play-life-backend/migrations/020_change_period_to_start_date.sql"
|
||||||
|
if _, err := os.Stat(migrationPath); os.IsNotExist(err) {
|
||||||
|
migrationPath = "migrations/020_change_period_to_start_date.sql"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
migrationSQL, err := os.ReadFile(migrationPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read migration file %s: %w", migrationPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Выполняем миграцию
|
||||||
|
if _, err := a.DB.Exec(string(migrationSQL)); err != nil {
|
||||||
|
return fmt.Errorf("failed to execute migration 020: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Migration 020 applied successfully")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) initPlayLifeDB() error {
|
func (a *App) initPlayLifeDB() error {
|
||||||
// Создаем таблицу projects
|
// Создаем таблицу projects
|
||||||
createProjectsTable := `
|
createProjectsTable := `
|
||||||
@@ -8427,10 +8479,10 @@ func (a *App) todoistDisconnectHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Wishlist handlers
|
// Wishlist handlers
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
// calculateProjectPointsForPeriod считает баллы проекта за указанный период
|
// calculateProjectPointsFromDate считает баллы проекта с указанной даты до текущего момента
|
||||||
func (a *App) calculateProjectPointsForPeriod(
|
func (a *App) calculateProjectPointsFromDate(
|
||||||
projectID int,
|
projectID int,
|
||||||
periodType sql.NullString,
|
startDate sql.NullTime,
|
||||||
userID int,
|
userID int,
|
||||||
) (float64, error) {
|
) (float64, error) {
|
||||||
var totalScore float64
|
var totalScore float64
|
||||||
@@ -8442,7 +8494,7 @@ func (a *App) calculateProjectPointsForPeriod(
|
|||||||
log.Printf("Warning: Failed to refresh materialized view: %v", err)
|
log.Printf("Warning: Failed to refresh materialized view: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !periodType.Valid || periodType.String == "" {
|
if !startDate.Valid {
|
||||||
// За всё время
|
// За всё время
|
||||||
err = a.DB.QueryRow(`
|
err = a.DB.QueryRow(`
|
||||||
SELECT COALESCE(SUM(wr.total_score), 0)
|
SELECT COALESCE(SUM(wr.total_score), 0)
|
||||||
@@ -8451,46 +8503,21 @@ func (a *App) calculateProjectPointsForPeriod(
|
|||||||
WHERE wr.project_id = $1 AND p.user_id = $2
|
WHERE wr.project_id = $1 AND p.user_id = $2
|
||||||
`, projectID, userID).Scan(&totalScore)
|
`, projectID, userID).Scan(&totalScore)
|
||||||
} else {
|
} else {
|
||||||
switch periodType.String {
|
// С указанной даты до текущего момента
|
||||||
case "week":
|
// Нужно найти все недели, которые попадают в диапазон от startDate до CURRENT_DATE
|
||||||
// Текущая неделя
|
// Используем сравнение (year, week) >= (startDate_year, startDate_week)
|
||||||
err = a.DB.QueryRow(`
|
err = a.DB.QueryRow(`
|
||||||
SELECT COALESCE(SUM(wr.total_score), 0)
|
SELECT COALESCE(SUM(wr.total_score), 0)
|
||||||
FROM weekly_report_mv wr
|
FROM weekly_report_mv wr
|
||||||
JOIN projects p ON wr.project_id = p.id
|
JOIN projects p ON wr.project_id = p.id
|
||||||
WHERE wr.project_id = $1
|
WHERE wr.project_id = $1
|
||||||
AND p.user_id = $2
|
AND p.user_id = $2
|
||||||
AND wr.report_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
|
AND (
|
||||||
AND wr.report_week = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER
|
wr.report_year > EXTRACT(ISOYEAR FROM $3)::INTEGER
|
||||||
`, projectID, userID).Scan(&totalScore)
|
OR (wr.report_year = EXTRACT(ISOYEAR FROM $3)::INTEGER
|
||||||
|
AND wr.report_week >= EXTRACT(WEEK FROM $3)::INTEGER)
|
||||||
case "month":
|
)
|
||||||
// Текущий месяц
|
`, projectID, userID, startDate.Time).Scan(&totalScore)
|
||||||
err = a.DB.QueryRow(`
|
|
||||||
SELECT COALESCE(SUM(wr.total_score), 0)
|
|
||||||
FROM weekly_report_mv wr
|
|
||||||
JOIN projects p ON wr.project_id = p.id
|
|
||||||
WHERE wr.project_id = $1
|
|
||||||
AND p.user_id = $2
|
|
||||||
AND wr.report_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
|
|
||||||
AND wr.report_week >= EXTRACT(WEEK FROM DATE_TRUNC('month', CURRENT_DATE))::INTEGER
|
|
||||||
AND wr.report_week <= EXTRACT(WEEK FROM (DATE_TRUNC('month', CURRENT_DATE) + INTERVAL '1 month' - INTERVAL '1 day'))::INTEGER
|
|
||||||
`, projectID, userID).Scan(&totalScore)
|
|
||||||
|
|
||||||
case "year":
|
|
||||||
// Текущий год
|
|
||||||
err = a.DB.QueryRow(`
|
|
||||||
SELECT COALESCE(SUM(wr.total_score), 0)
|
|
||||||
FROM weekly_report_mv wr
|
|
||||||
JOIN projects p ON wr.project_id = p.id
|
|
||||||
WHERE wr.project_id = $1
|
|
||||||
AND p.user_id = $2
|
|
||||||
AND wr.report_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
|
|
||||||
`, projectID, userID).Scan(&totalScore)
|
|
||||||
|
|
||||||
default:
|
|
||||||
return 0, fmt.Errorf("unknown period_type: %s", periodType.String)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -8513,7 +8540,7 @@ func (a *App) checkWishlistUnlock(itemID int, userID int) (bool, error) {
|
|||||||
tc.task_id,
|
tc.task_id,
|
||||||
sc.project_id,
|
sc.project_id,
|
||||||
sc.required_points,
|
sc.required_points,
|
||||||
sc.period_type
|
sc.start_date
|
||||||
FROM wishlist_conditions wc
|
FROM wishlist_conditions wc
|
||||||
LEFT JOIN task_conditions tc ON wc.task_condition_id = tc.id
|
LEFT JOIN task_conditions tc ON wc.task_condition_id = tc.id
|
||||||
LEFT JOIN score_conditions sc ON wc.score_condition_id = sc.id
|
LEFT JOIN score_conditions sc ON wc.score_condition_id = sc.id
|
||||||
@@ -8537,12 +8564,12 @@ func (a *App) checkWishlistUnlock(itemID int, userID int) (bool, error) {
|
|||||||
var taskID sql.NullInt64
|
var taskID sql.NullInt64
|
||||||
var projectID sql.NullInt64
|
var projectID sql.NullInt64
|
||||||
var requiredPoints sql.NullFloat64
|
var requiredPoints sql.NullFloat64
|
||||||
var periodType sql.NullString
|
var startDate sql.NullTime
|
||||||
|
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&wcID, &displayOrder,
|
&wcID, &displayOrder,
|
||||||
&taskConditionID, &scoreConditionID,
|
&taskConditionID, &scoreConditionID,
|
||||||
&taskID, &projectID, &requiredPoints, &periodType,
|
&taskID, &projectID, &requiredPoints, &startDate,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
@@ -8577,9 +8604,9 @@ func (a *App) checkWishlistUnlock(itemID int, userID int) (bool, error) {
|
|||||||
return false, fmt.Errorf("project_id or required_points missing for score_condition_id=%d", scoreConditionID.Int64)
|
return false, fmt.Errorf("project_id or required_points missing for score_condition_id=%d", scoreConditionID.Int64)
|
||||||
}
|
}
|
||||||
|
|
||||||
totalScore, err := a.calculateProjectPointsForPeriod(
|
totalScore, err := a.calculateProjectPointsFromDate(
|
||||||
int(projectID.Int64),
|
int(projectID.Int64),
|
||||||
periodType,
|
startDate,
|
||||||
userID,
|
userID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -8623,7 +8650,7 @@ func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool)
|
|||||||
sc.project_id,
|
sc.project_id,
|
||||||
p.name AS project_name,
|
p.name AS project_name,
|
||||||
sc.required_points,
|
sc.required_points,
|
||||||
sc.period_type
|
sc.start_date
|
||||||
FROM wishlist_items wi
|
FROM wishlist_items wi
|
||||||
LEFT JOIN wishlist_conditions wc ON wi.id = wc.wishlist_item_id
|
LEFT JOIN wishlist_conditions wc ON wi.id = wc.wishlist_item_id
|
||||||
LEFT JOIN task_conditions tc ON wc.task_condition_id = tc.id
|
LEFT JOIN task_conditions tc ON wc.task_condition_id = tc.id
|
||||||
@@ -8659,14 +8686,14 @@ func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool)
|
|||||||
var projectID sql.NullInt64
|
var projectID sql.NullInt64
|
||||||
var projectName sql.NullString
|
var projectName sql.NullString
|
||||||
var requiredPoints sql.NullFloat64
|
var requiredPoints sql.NullFloat64
|
||||||
var periodType sql.NullString
|
var startDate sql.NullTime
|
||||||
|
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&itemID, &name, &price, &imagePath, &link, &completed,
|
&itemID, &name, &price, &imagePath, &link, &completed,
|
||||||
&conditionID, &displayOrder,
|
&conditionID, &displayOrder,
|
||||||
&taskConditionID, &scoreConditionID,
|
&taskConditionID, &scoreConditionID,
|
||||||
&taskID, &taskName,
|
&taskID, &taskName,
|
||||||
&projectID, &projectName, &requiredPoints, &periodType,
|
&projectID, &projectName, &requiredPoints, &startDate,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -8716,8 +8743,10 @@ func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool)
|
|||||||
if requiredPoints.Valid {
|
if requiredPoints.Valid {
|
||||||
condition.RequiredPoints = &requiredPoints.Float64
|
condition.RequiredPoints = &requiredPoints.Float64
|
||||||
}
|
}
|
||||||
if periodType.Valid {
|
if startDate.Valid {
|
||||||
condition.PeriodType = &periodType.String
|
// Форматируем дату в YYYY-MM-DD
|
||||||
|
dateStr := startDate.Time.Format("2006-01-02")
|
||||||
|
condition.StartDate = &dateStr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8767,15 +8796,15 @@ func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool)
|
|||||||
// Находим project_id и required_points для этого условия
|
// Находим project_id и required_points для этого условия
|
||||||
var projectID int
|
var projectID int
|
||||||
var requiredPoints float64
|
var requiredPoints float64
|
||||||
var periodType sql.NullString
|
var startDate sql.NullTime
|
||||||
err = a.DB.QueryRow(`
|
err = a.DB.QueryRow(`
|
||||||
SELECT sc.project_id, sc.required_points, sc.period_type
|
SELECT sc.project_id, sc.required_points, sc.start_date
|
||||||
FROM wishlist_conditions wc
|
FROM wishlist_conditions wc
|
||||||
JOIN score_conditions sc ON wc.score_condition_id = sc.id
|
JOIN score_conditions sc ON wc.score_condition_id = sc.id
|
||||||
WHERE wc.id = $1
|
WHERE wc.id = $1
|
||||||
`, condition.ID).Scan(&projectID, &requiredPoints, &periodType)
|
`, condition.ID).Scan(&projectID, &requiredPoints, &startDate)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
totalScore, err := a.calculateProjectPointsForPeriod(projectID, periodType, userID)
|
totalScore, err := a.calculateProjectPointsFromDate(projectID, startDate, userID)
|
||||||
conditionMet = err == nil && totalScore >= requiredPoints
|
conditionMet = err == nil && totalScore >= requiredPoints
|
||||||
if err == nil {
|
if err == nil {
|
||||||
condition.CurrentPoints = &totalScore
|
condition.CurrentPoints = &totalScore
|
||||||
@@ -8819,15 +8848,15 @@ func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool)
|
|||||||
} else if condition.Type == "project_points" {
|
} else if condition.Type == "project_points" {
|
||||||
var projectID int
|
var projectID int
|
||||||
var requiredPoints float64
|
var requiredPoints float64
|
||||||
var periodType sql.NullString
|
var startDate sql.NullTime
|
||||||
err := a.DB.QueryRow(`
|
err := a.DB.QueryRow(`
|
||||||
SELECT sc.project_id, sc.required_points, sc.period_type
|
SELECT sc.project_id, sc.required_points, sc.start_date
|
||||||
FROM wishlist_conditions wc
|
FROM wishlist_conditions wc
|
||||||
JOIN score_conditions sc ON wc.score_condition_id = sc.id
|
JOIN score_conditions sc ON wc.score_condition_id = sc.id
|
||||||
WHERE wc.id = $1
|
WHERE wc.id = $1
|
||||||
`, condition.ID).Scan(&projectID, &requiredPoints, &periodType)
|
`, condition.ID).Scan(&projectID, &requiredPoints, &startDate)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
totalScore, err := a.calculateProjectPointsForPeriod(projectID, periodType, userID)
|
totalScore, err := a.calculateProjectPointsFromDate(projectID, startDate, userID)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
condition.CurrentPoints = &totalScore
|
condition.CurrentPoints = &totalScore
|
||||||
}
|
}
|
||||||
@@ -8914,34 +8943,35 @@ func (a *App) saveWishlistConditions(
|
|||||||
return fmt.Errorf("project_id and required_points are required for project_points")
|
return fmt.Errorf("project_id and required_points are required for project_points")
|
||||||
}
|
}
|
||||||
|
|
||||||
periodType := condition.PeriodType
|
startDateStr := condition.StartDate
|
||||||
|
|
||||||
// Получаем или создаём score_condition
|
// Получаем или создаём score_condition
|
||||||
var scID int
|
var scID int
|
||||||
var periodTypeVal interface{}
|
var startDateVal interface{}
|
||||||
if periodType != nil && *periodType != "" {
|
if startDateStr != nil && *startDateStr != "" {
|
||||||
periodTypeVal = *periodType
|
// Парсим дату из строки YYYY-MM-DD
|
||||||
|
startDateVal = *startDateStr
|
||||||
} else {
|
} else {
|
||||||
// Пустая строка или nil = NULL для "за всё время"
|
// Пустая строка или nil = NULL для "за всё время"
|
||||||
periodTypeVal = nil
|
startDateVal = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
err := tx.QueryRow(`
|
err := tx.QueryRow(`
|
||||||
SELECT id FROM score_conditions
|
SELECT id FROM score_conditions
|
||||||
WHERE project_id = $1
|
WHERE project_id = $1
|
||||||
AND required_points = $2
|
AND required_points = $2
|
||||||
AND (period_type = $3 OR (period_type IS NULL AND $3 IS NULL))
|
AND (start_date = $3::DATE OR (start_date IS NULL AND $3 IS NULL))
|
||||||
`, *condition.ProjectID, *condition.RequiredPoints, periodTypeVal).Scan(&scID)
|
`, *condition.ProjectID, *condition.RequiredPoints, startDateVal).Scan(&scID)
|
||||||
|
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
// Создаём новое условие
|
// Создаём новое условие
|
||||||
err = tx.QueryRow(`
|
err = tx.QueryRow(`
|
||||||
INSERT INTO score_conditions (project_id, required_points, period_type)
|
INSERT INTO score_conditions (project_id, required_points, start_date)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3::DATE)
|
||||||
ON CONFLICT (project_id, required_points, period_type)
|
ON CONFLICT (project_id, required_points, start_date)
|
||||||
DO UPDATE SET project_id = EXCLUDED.project_id
|
DO UPDATE SET project_id = EXCLUDED.project_id
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`, *condition.ProjectID, *condition.RequiredPoints, periodTypeVal).Scan(&scID)
|
`, *condition.ProjectID, *condition.RequiredPoints, startDateVal).Scan(&scID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
-- Migration: Change period_type to start_date in score_conditions
|
||||||
|
-- This allows specifying a start date for counting points instead of period type
|
||||||
|
-- Date can be in the past or future, NULL means count all time
|
||||||
|
|
||||||
|
-- Добавляем новое поле start_date
|
||||||
|
ALTER TABLE score_conditions
|
||||||
|
ADD COLUMN IF NOT EXISTS start_date DATE;
|
||||||
|
|
||||||
|
-- Миграция данных: для существующих записей с period_type устанавливаем start_date
|
||||||
|
-- Если period_type = 'week', то start_date = начало текущей недели
|
||||||
|
-- Если period_type = 'month', то start_date = начало текущего месяца
|
||||||
|
-- Если period_type = 'year', то start_date = начало текущего года
|
||||||
|
-- Если period_type IS NULL, то start_date = NULL (за всё время)
|
||||||
|
UPDATE score_conditions
|
||||||
|
SET start_date = CASE
|
||||||
|
WHEN period_type = 'week' THEN DATE_TRUNC('week', CURRENT_DATE)::DATE
|
||||||
|
WHEN period_type = 'month' THEN DATE_TRUNC('month', CURRENT_DATE)::DATE
|
||||||
|
WHEN period_type = 'year' THEN DATE_TRUNC('year', CURRENT_DATE)::DATE
|
||||||
|
ELSE NULL
|
||||||
|
END
|
||||||
|
WHERE start_date IS NULL;
|
||||||
|
|
||||||
|
-- Обновляем уникальное ограничение (удаляем старое, добавляем новое)
|
||||||
|
ALTER TABLE score_conditions
|
||||||
|
DROP CONSTRAINT IF EXISTS unique_score_condition;
|
||||||
|
|
||||||
|
ALTER TABLE score_conditions
|
||||||
|
ADD CONSTRAINT unique_score_condition
|
||||||
|
UNIQUE (project_id, required_points, start_date);
|
||||||
|
|
||||||
|
-- Обновляем комментарии
|
||||||
|
COMMENT ON COLUMN score_conditions.start_date IS 'Date from which to start counting points. NULL means count all time.';
|
||||||
|
|
||||||
|
-- Примечание: поле period_type оставляем пока для обратной совместимости
|
||||||
|
-- Его можно будет удалить позже после проверки, что всё работает:
|
||||||
|
-- ALTER TABLE score_conditions DROP COLUMN period_type;
|
||||||
|
|
||||||
@@ -166,16 +166,14 @@ function WishlistDetail({ wishlistId, onNavigate, onRefresh }) {
|
|||||||
const requiredPoints = condition.required_points || 0
|
const requiredPoints = condition.required_points || 0
|
||||||
const currentPoints = condition.current_points || 0
|
const currentPoints = condition.current_points || 0
|
||||||
const project = condition.project_name || 'Проект'
|
const project = condition.project_name || 'Проект'
|
||||||
let period = ''
|
let dateText = ''
|
||||||
if (condition.period_type) {
|
if (condition.start_date) {
|
||||||
const periodLabels = {
|
const date = new Date(condition.start_date + 'T00:00:00')
|
||||||
week: 'за неделю',
|
dateText = ` с ${date.toLocaleDateString('ru-RU')}`
|
||||||
month: 'за месяц',
|
} else {
|
||||||
year: 'за год',
|
dateText = ' за всё время'
|
||||||
}
|
}
|
||||||
period = ' ' + periodLabels[condition.period_type] || ''
|
conditionText = `${requiredPoints} в ${project}${dateText}`
|
||||||
}
|
|
||||||
conditionText = `${requiredPoints} в ${project}${period}`
|
|
||||||
progress = {
|
progress = {
|
||||||
type: 'points',
|
type: 'points',
|
||||||
current: currentPoints,
|
current: currentPoints,
|
||||||
|
|||||||
@@ -383,3 +383,67 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Date Selector Styles (аналогично task-postpone-input-group) */
|
||||||
|
.date-selector-input-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-selector-input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-selector-display-date {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
color: #1f2937;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-selector-display-date:hover {
|
||||||
|
border-color: #3498db;
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-selector-display-date:active {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-selector-clear-button {
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: #e5e7eb;
|
||||||
|
color: #6b7280;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-selector-clear-button:hover {
|
||||||
|
background: #d1d5db;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-selector-clear-button:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react'
|
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import Cropper from 'react-easy-crop'
|
import Cropper from 'react-easy-crop'
|
||||||
import { useAuth } from './auth/AuthContext'
|
import { useAuth } from './auth/AuthContext'
|
||||||
import Toast from './Toast'
|
import Toast from './Toast'
|
||||||
@@ -80,7 +80,7 @@ function WishlistForm({ onNavigate, wishlistId }) {
|
|||||||
task_id: cond.type === 'task_completion' ? tasks.find(t => t.name === cond.task_name)?.id : null,
|
task_id: cond.type === 'task_completion' ? tasks.find(t => t.name === cond.task_name)?.id : null,
|
||||||
project_id: cond.type === 'project_points' ? projects.find(p => p.project_name === cond.project_name)?.project_id : null,
|
project_id: cond.type === 'project_points' ? projects.find(p => p.project_name === cond.project_name)?.project_id : null,
|
||||||
required_points: cond.required_points || null,
|
required_points: cond.required_points || null,
|
||||||
period_type: cond.period_type || null,
|
start_date: cond.start_date || null,
|
||||||
display_order: idx,
|
display_order: idx,
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
@@ -289,7 +289,7 @@ function WishlistForm({ onNavigate, wishlistId }) {
|
|||||||
task_id: cond.type === 'task_completion' ? cond.task_id : null,
|
task_id: cond.type === 'task_completion' ? cond.task_id : null,
|
||||||
project_id: cond.type === 'project_points' ? cond.project_id : null,
|
project_id: cond.type === 'project_points' ? cond.project_id : null,
|
||||||
required_points: cond.type === 'project_points' ? parseFloat(cond.required_points) : null,
|
required_points: cond.type === 'project_points' ? parseFloat(cond.required_points) : null,
|
||||||
period_type: cond.type === 'project_points' ? cond.period_type : null,
|
start_date: cond.type === 'project_points' ? cond.start_date : null,
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -498,7 +498,7 @@ function WishlistForm({ onNavigate, wishlistId }) {
|
|||||||
<span>
|
<span>
|
||||||
{cond.type === 'task_completion'
|
{cond.type === 'task_completion'
|
||||||
? `Задача: ${tasks.find(t => t.id === cond.task_id)?.name || 'Не выбрана'}`
|
? `Задача: ${tasks.find(t => t.id === cond.task_id)?.name || 'Не выбрана'}`
|
||||||
: `Баллы: ${cond.required_points} в ${projects.find(p => p.project_id === cond.project_id)?.project_name || 'Не выбран'}${cond.period_type ? ` за ${cond.period_type === 'week' ? 'неделю' : cond.period_type === 'month' ? 'месяц' : 'год'}` : ''}`}
|
: `Баллы: ${cond.required_points} в ${projects.find(p => p.project_id === cond.project_id)?.project_name || 'Не выбран'}${cond.start_date ? ` с ${new Date(cond.start_date + 'T00:00:00').toLocaleDateString('ru-RU')}` : ' за всё время'}`}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -549,13 +549,91 @@ function WishlistForm({ onNavigate, wishlistId }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Компонент селектора даты с календарём (аналогично TaskList)
|
||||||
|
function DateSelector({ value, onChange, placeholder = "За всё время" }) {
|
||||||
|
const dateInputRef = useRef(null)
|
||||||
|
|
||||||
|
const formatDateForDisplay = (dateStr) => {
|
||||||
|
if (!dateStr) return ''
|
||||||
|
const date = new Date(dateStr + 'T00:00:00')
|
||||||
|
const now = new Date()
|
||||||
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||||
|
const targetDate = new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
||||||
|
|
||||||
|
const diffDays = Math.floor((targetDate - today) / (1000 * 60 * 60 * 24))
|
||||||
|
|
||||||
|
const monthNames = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
|
||||||
|
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря']
|
||||||
|
|
||||||
|
if (diffDays === 0) {
|
||||||
|
return 'Сегодня'
|
||||||
|
} else if (diffDays === 1) {
|
||||||
|
return 'Завтра'
|
||||||
|
} else if (diffDays === -1) {
|
||||||
|
return 'Вчера'
|
||||||
|
} else if (diffDays > 1 && diffDays <= 7) {
|
||||||
|
const dayOfWeek = targetDate.getDay()
|
||||||
|
const dayNames = ['воскресенье', 'понедельник', 'вторник', 'среда', 'четверг', 'пятница', 'суббота']
|
||||||
|
return dayNames[dayOfWeek]
|
||||||
|
} else if (targetDate.getFullYear() === now.getFullYear()) {
|
||||||
|
return `${targetDate.getDate()} ${monthNames[targetDate.getMonth()]}`
|
||||||
|
} else {
|
||||||
|
return `${targetDate.getDate()} ${monthNames[targetDate.getMonth()]} ${targetDate.getFullYear()}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDisplayClick = () => {
|
||||||
|
if (dateInputRef.current) {
|
||||||
|
if (typeof dateInputRef.current.showPicker === 'function') {
|
||||||
|
dateInputRef.current.showPicker()
|
||||||
|
} else {
|
||||||
|
dateInputRef.current.focus()
|
||||||
|
dateInputRef.current.click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClear = (e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onChange('')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="date-selector-input-group">
|
||||||
|
<input
|
||||||
|
ref={dateInputRef}
|
||||||
|
type="date"
|
||||||
|
value={value || ''}
|
||||||
|
onChange={(e) => onChange(e.target.value || '')}
|
||||||
|
className="date-selector-input"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="date-selector-display-date"
|
||||||
|
onClick={handleDisplayClick}
|
||||||
|
>
|
||||||
|
{value ? formatDateForDisplay(value) : placeholder}
|
||||||
|
</div>
|
||||||
|
{value && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClear}
|
||||||
|
className="date-selector-clear-button"
|
||||||
|
aria-label="Очистить дату"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Компонент формы условия разблокировки
|
// Компонент формы условия разблокировки
|
||||||
function ConditionForm({ tasks, projects, onSubmit, onCancel }) {
|
function ConditionForm({ tasks, projects, onSubmit, onCancel }) {
|
||||||
const [type, setType] = useState('task_completion')
|
const [type, setType] = useState('project_points')
|
||||||
const [taskId, setTaskId] = useState('')
|
const [taskId, setTaskId] = useState('')
|
||||||
const [projectId, setProjectId] = useState('')
|
const [projectId, setProjectId] = useState('')
|
||||||
const [requiredPoints, setRequiredPoints] = useState('')
|
const [requiredPoints, setRequiredPoints] = useState('')
|
||||||
const [periodType, setPeriodType] = useState('')
|
const [startDate, setStartDate] = useState('')
|
||||||
|
|
||||||
const handleSubmit = (e) => {
|
const handleSubmit = (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -574,15 +652,15 @@ function ConditionForm({ tasks, projects, onSubmit, onCancel }) {
|
|||||||
task_id: type === 'task_completion' ? parseInt(taskId) : null,
|
task_id: type === 'task_completion' ? parseInt(taskId) : null,
|
||||||
project_id: type === 'project_points' ? parseInt(projectId) : null,
|
project_id: type === 'project_points' ? parseInt(projectId) : null,
|
||||||
required_points: type === 'project_points' ? parseFloat(requiredPoints) : null,
|
required_points: type === 'project_points' ? parseFloat(requiredPoints) : null,
|
||||||
period_type: type === 'project_points' && periodType ? periodType : null,
|
start_date: type === 'project_points' && startDate ? startDate : null,
|
||||||
}
|
}
|
||||||
onSubmit(condition)
|
onSubmit(condition)
|
||||||
// Сброс формы
|
// Сброс формы
|
||||||
setType('task_completion')
|
setType('project_points')
|
||||||
setTaskId('')
|
setTaskId('')
|
||||||
setProjectId('')
|
setProjectId('')
|
||||||
setRequiredPoints('')
|
setRequiredPoints('')
|
||||||
setPeriodType('')
|
setStartDate('')
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -597,8 +675,8 @@ function ConditionForm({ tasks, projects, onSubmit, onCancel }) {
|
|||||||
onChange={(e) => setType(e.target.value)}
|
onChange={(e) => setType(e.target.value)}
|
||||||
className="form-input"
|
className="form-input"
|
||||||
>
|
>
|
||||||
<option value="task_completion">Выполнить задачу</option>
|
<option value="project_points">Баллы</option>
|
||||||
<option value="project_points">Набрать баллы в проекте</option>
|
<option value="task_completion">Задача</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -649,17 +727,12 @@ function ConditionForm({ tasks, projects, onSubmit, onCancel }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Период</label>
|
<label>Дата начала подсчёта</label>
|
||||||
<select
|
<DateSelector
|
||||||
value={periodType}
|
value={startDate}
|
||||||
onChange={(e) => setPeriodType(e.target.value)}
|
onChange={setStartDate}
|
||||||
className="form-input"
|
placeholder="За всё время"
|
||||||
>
|
/>
|
||||||
<option value="">За всё время</option>
|
|
||||||
<option value="week">За неделю</option>
|
|
||||||
<option value="month">За месяц</option>
|
|
||||||
<option value="year">За год</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user