4.23.0: Добавлено отслеживание и улучшен UI
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m31s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m31s
This commit is contained in:
@@ -14,6 +14,8 @@ services:
|
|||||||
POSTGRES_DB: ${DB_NAME:-playeng}
|
POSTGRES_DB: ${DB_NAME:-playeng}
|
||||||
ports:
|
ports:
|
||||||
- "${DB_PORT:-5432}:5432"
|
- "${DB_PORT:-5432}:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-playeng}"]
|
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-playeng}"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -59,6 +61,10 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
name: play-life_postgres_data
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
default:
|
default:
|
||||||
name: play-life-network
|
name: play-life-network
|
||||||
|
|||||||
@@ -270,6 +270,49 @@ type TelegramUpdate struct {
|
|||||||
EditedMessage *TelegramMessage `json:"edited_message,omitempty"`
|
EditedMessage *TelegramMessage `json:"edited_message,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tracking structures
|
||||||
|
type TrackingUserStats struct {
|
||||||
|
UserID int `json:"user_id"`
|
||||||
|
UserName string `json:"user_name"`
|
||||||
|
IsCurrentUser bool `json:"is_current_user"`
|
||||||
|
Total *float64 `json:"total,omitempty"`
|
||||||
|
Projects []TrackingProjectStats `json:"projects"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrackingProjectStats struct {
|
||||||
|
ProjectName string `json:"project_name"`
|
||||||
|
CalculatedScore float64 `json:"calculated_score"` // процент выполнения
|
||||||
|
Priority *int `json:"priority,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrackingStatsResponse struct {
|
||||||
|
WeekNumber int `json:"week_number"`
|
||||||
|
Year int `json:"year"`
|
||||||
|
Users []TrackingUserStats `json:"users"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrackingAccessResponse struct {
|
||||||
|
Trackers []TrackingUser `json:"trackers"` // кто меня отслеживает
|
||||||
|
Tracked []TrackingUser `json:"tracked"` // кого я отслеживаю
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrackingUser struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
RelationID int `json:"relation_id"` // id записи в user_tracking для удаления
|
||||||
|
Name string `json:"name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrackingInviteInfo struct {
|
||||||
|
UserID int `json:"user_id"`
|
||||||
|
UserName string `json:"user_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrackingInviteResponse struct {
|
||||||
|
InviteURL string `json:"invite_url"`
|
||||||
|
}
|
||||||
|
|
||||||
// Task structures
|
// Task structures
|
||||||
type Task struct {
|
type Task struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
@@ -2698,28 +2741,39 @@ func (a *App) getWeeklyStatsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Параметры бонуса в зависимости от priority
|
// Параметры бонуса в зависимости от priority
|
||||||
var extraBonusLimit float64 = 40
|
var extraBonusLimit float64 = 20
|
||||||
if priorityVal == 1 {
|
if priorityVal == 1 {
|
||||||
extraBonusLimit = 100
|
extraBonusLimit = 50
|
||||||
} else if priorityVal == 2 {
|
} else if priorityVal == 2 {
|
||||||
extraBonusLimit = 70
|
extraBonusLimit = 35
|
||||||
}
|
}
|
||||||
|
|
||||||
// Расчет базового прогресса
|
// Расчет calculated_score по логике фронтенда
|
||||||
var baseProgress float64
|
// min_goal -> 100%, max_goal -> 150%/135%/120% в зависимости от приоритета
|
||||||
if minGoalScoreVal > 0 {
|
var resultScore float64
|
||||||
baseProgress = (min(totalScore, minGoalScoreVal) / minGoalScoreVal) * 100.0
|
if minGoalScoreVal <= 0 {
|
||||||
}
|
// Если нет minGoal, возвращаем 0 (или можно относительно maxGoal, но обычно 0)
|
||||||
|
resultScore = 0
|
||||||
|
} else if totalScore < minGoalScoreVal {
|
||||||
|
// До достижения minGoal растем линейно от 0 до 100%
|
||||||
|
resultScore = (totalScore / minGoalScoreVal) * 100.0
|
||||||
|
} else {
|
||||||
|
// Достигнут minGoal - базовый прогресс = 100%
|
||||||
|
baseProgress := 100.0
|
||||||
|
|
||||||
// Расчет экстра прогресса
|
// Если maxGoal задан корректно и больше minGoal, добавляем экстра прогресс
|
||||||
var extraProgress float64
|
if maxGoalScoreVal > minGoalScoreVal {
|
||||||
denominator := maxGoalScoreVal - minGoalScoreVal
|
extraRange := maxGoalScoreVal - minGoalScoreVal
|
||||||
if denominator > 0 && totalScore > minGoalScoreVal {
|
|
||||||
excess := min(totalScore, maxGoalScoreVal) - minGoalScoreVal
|
excess := min(totalScore, maxGoalScoreVal) - minGoalScoreVal
|
||||||
extraProgress = (excess / denominator) * extraBonusLimit
|
extraRatio := min(1.0, max(0.0, excess/extraRange))
|
||||||
|
extraProgress := extraRatio * extraBonusLimit
|
||||||
|
resultScore = min(100.0+extraBonusLimit, baseProgress+extraProgress)
|
||||||
|
} else {
|
||||||
|
// Если maxGoal не задан или некорректен, просто 100%
|
||||||
|
resultScore = baseProgress
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resultScore := baseProgress + extraProgress
|
|
||||||
project.CalculatedScore = roundToTwoDecimals(resultScore)
|
project.CalculatedScore = roundToTwoDecimals(resultScore)
|
||||||
|
|
||||||
// Группировка для итогового расчета
|
// Группировка для итогового расчета
|
||||||
@@ -2738,7 +2792,7 @@ func (a *App) getWeeklyStatsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
groupsProgress := calculateGroupsProgress(groups)
|
groupsProgress := calculateGroupsProgress(groups)
|
||||||
|
|
||||||
// Вычисляем общий процент выполнения
|
// Вычисляем общий процент выполнения
|
||||||
total := calculateOverallProgress(groupsProgress)
|
total := calculateOverallProgress(groupsProgress, groups)
|
||||||
|
|
||||||
response := WeeklyStatsResponse{
|
response := WeeklyStatsResponse{
|
||||||
Total: total,
|
Total: total,
|
||||||
@@ -3232,28 +3286,39 @@ func (a *App) getWeeklyStatsData() (*WeeklyStatsResponse, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Параметры бонуса в зависимости от priority
|
// Параметры бонуса в зависимости от priority
|
||||||
var extraBonusLimit float64 = 40
|
var extraBonusLimit float64 = 20
|
||||||
if priorityVal == 1 {
|
if priorityVal == 1 {
|
||||||
extraBonusLimit = 100
|
extraBonusLimit = 50
|
||||||
} else if priorityVal == 2 {
|
} else if priorityVal == 2 {
|
||||||
extraBonusLimit = 70
|
extraBonusLimit = 35
|
||||||
}
|
}
|
||||||
|
|
||||||
// Расчет базового прогресса
|
// Расчет calculated_score по логике фронтенда
|
||||||
var baseProgress float64
|
// min_goal -> 100%, max_goal -> 150%/135%/120% в зависимости от приоритета
|
||||||
if minGoalScoreVal > 0 {
|
var resultScore float64
|
||||||
baseProgress = (min(totalScore, minGoalScoreVal) / minGoalScoreVal) * 100.0
|
if minGoalScoreVal <= 0 {
|
||||||
}
|
// Если нет minGoal, возвращаем 0 (или можно относительно maxGoal, но обычно 0)
|
||||||
|
resultScore = 0
|
||||||
|
} else if totalScore < minGoalScoreVal {
|
||||||
|
// До достижения minGoal растем линейно от 0 до 100%
|
||||||
|
resultScore = (totalScore / minGoalScoreVal) * 100.0
|
||||||
|
} else {
|
||||||
|
// Достигнут minGoal - базовый прогресс = 100%
|
||||||
|
baseProgress := 100.0
|
||||||
|
|
||||||
// Расчет экстра прогресса
|
// Если maxGoal задан корректно и больше minGoal, добавляем экстра прогресс
|
||||||
var extraProgress float64
|
if maxGoalScoreVal > minGoalScoreVal {
|
||||||
denominator := maxGoalScoreVal - minGoalScoreVal
|
extraRange := maxGoalScoreVal - minGoalScoreVal
|
||||||
if denominator > 0 && totalScore > minGoalScoreVal {
|
|
||||||
excess := min(totalScore, maxGoalScoreVal) - minGoalScoreVal
|
excess := min(totalScore, maxGoalScoreVal) - minGoalScoreVal
|
||||||
extraProgress = (excess / denominator) * extraBonusLimit
|
extraRatio := min(1.0, max(0.0, excess/extraRange))
|
||||||
|
extraProgress := extraRatio * extraBonusLimit
|
||||||
|
resultScore = min(100.0+extraBonusLimit, baseProgress+extraProgress)
|
||||||
|
} else {
|
||||||
|
// Если maxGoal не задан или некорректен, просто 100%
|
||||||
|
resultScore = baseProgress
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resultScore := baseProgress + extraProgress
|
|
||||||
project.CalculatedScore = roundToTwoDecimals(resultScore)
|
project.CalculatedScore = roundToTwoDecimals(resultScore)
|
||||||
|
|
||||||
// Группировка для итогового расчета
|
// Группировка для итогового расчета
|
||||||
@@ -3272,7 +3337,7 @@ func (a *App) getWeeklyStatsData() (*WeeklyStatsResponse, error) {
|
|||||||
groupsProgress := calculateGroupsProgress(groups)
|
groupsProgress := calculateGroupsProgress(groups)
|
||||||
|
|
||||||
// Вычисляем общий процент выполнения
|
// Вычисляем общий процент выполнения
|
||||||
total := calculateOverallProgress(groupsProgress)
|
total := calculateOverallProgress(groupsProgress, groups)
|
||||||
|
|
||||||
response := WeeklyStatsResponse{
|
response := WeeklyStatsResponse{
|
||||||
Total: total,
|
Total: total,
|
||||||
@@ -3392,28 +3457,39 @@ func (a *App) getWeeklyStatsDataForUser(userID int) (*WeeklyStatsResponse, error
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Параметры бонуса в зависимости от priority
|
// Параметры бонуса в зависимости от priority
|
||||||
var extraBonusLimit float64 = 40
|
var extraBonusLimit float64 = 20
|
||||||
if priorityVal == 1 {
|
if priorityVal == 1 {
|
||||||
extraBonusLimit = 100
|
extraBonusLimit = 50
|
||||||
} else if priorityVal == 2 {
|
} else if priorityVal == 2 {
|
||||||
extraBonusLimit = 70
|
extraBonusLimit = 35
|
||||||
}
|
}
|
||||||
|
|
||||||
// Расчет базового прогресса
|
// Расчет calculated_score по логике фронтенда
|
||||||
var baseProgress float64
|
// min_goal -> 100%, max_goal -> 150%/135%/120% в зависимости от приоритета
|
||||||
if minGoalScoreVal > 0 {
|
var resultScore float64
|
||||||
baseProgress = (min(totalScore, minGoalScoreVal) / minGoalScoreVal) * 100.0
|
if minGoalScoreVal <= 0 {
|
||||||
}
|
// Если нет minGoal, возвращаем 0 (или можно относительно maxGoal, но обычно 0)
|
||||||
|
resultScore = 0
|
||||||
|
} else if totalScore < minGoalScoreVal {
|
||||||
|
// До достижения minGoal растем линейно от 0 до 100%
|
||||||
|
resultScore = (totalScore / minGoalScoreVal) * 100.0
|
||||||
|
} else {
|
||||||
|
// Достигнут minGoal - базовый прогресс = 100%
|
||||||
|
baseProgress := 100.0
|
||||||
|
|
||||||
// Расчет экстра прогресса
|
// Если maxGoal задан корректно и больше minGoal, добавляем экстра прогресс
|
||||||
var extraProgress float64
|
if maxGoalScoreVal > minGoalScoreVal {
|
||||||
denominator := maxGoalScoreVal - minGoalScoreVal
|
extraRange := maxGoalScoreVal - minGoalScoreVal
|
||||||
if denominator > 0 && totalScore > minGoalScoreVal {
|
|
||||||
excess := min(totalScore, maxGoalScoreVal) - minGoalScoreVal
|
excess := min(totalScore, maxGoalScoreVal) - minGoalScoreVal
|
||||||
extraProgress = (excess / denominator) * extraBonusLimit
|
extraRatio := min(1.0, max(0.0, excess/extraRange))
|
||||||
|
extraProgress := extraRatio * extraBonusLimit
|
||||||
|
resultScore = min(100.0+extraBonusLimit, baseProgress+extraProgress)
|
||||||
|
} else {
|
||||||
|
// Если maxGoal не задан или некорректен, просто 100%
|
||||||
|
resultScore = baseProgress
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resultScore := baseProgress + extraProgress
|
|
||||||
project.CalculatedScore = roundToTwoDecimals(resultScore)
|
project.CalculatedScore = roundToTwoDecimals(resultScore)
|
||||||
projects = append(projects, project)
|
projects = append(projects, project)
|
||||||
|
|
||||||
@@ -3431,7 +3507,7 @@ func (a *App) getWeeklyStatsDataForUser(userID int) (*WeeklyStatsResponse, error
|
|||||||
groupsProgress := calculateGroupsProgress(groups)
|
groupsProgress := calculateGroupsProgress(groups)
|
||||||
|
|
||||||
// Вычисляем общий процент выполнения
|
// Вычисляем общий процент выполнения
|
||||||
total := calculateOverallProgress(groupsProgress)
|
total := calculateOverallProgress(groupsProgress, groups)
|
||||||
|
|
||||||
response := WeeklyStatsResponse{
|
response := WeeklyStatsResponse{
|
||||||
Total: total,
|
Total: total,
|
||||||
@@ -3977,6 +4053,15 @@ func main() {
|
|||||||
protected.HandleFunc("/api/wishlist/invite/{token}", app.getBoardInviteInfoHandler).Methods("GET", "OPTIONS")
|
protected.HandleFunc("/api/wishlist/invite/{token}", app.getBoardInviteInfoHandler).Methods("GET", "OPTIONS")
|
||||||
protected.HandleFunc("/api/wishlist/invite/{token}/join", app.joinBoardHandler).Methods("POST", "OPTIONS")
|
protected.HandleFunc("/api/wishlist/invite/{token}/join", app.joinBoardHandler).Methods("POST", "OPTIONS")
|
||||||
|
|
||||||
|
// Tracking
|
||||||
|
protected.HandleFunc("/api/tracking/stats", app.getTrackingStatsHandler).Methods("GET", "OPTIONS")
|
||||||
|
protected.HandleFunc("/api/tracking/invite", app.createTrackingInviteHandler).Methods("POST", "OPTIONS")
|
||||||
|
protected.HandleFunc("/api/tracking/invite/{token}", app.getTrackingInviteInfoHandler).Methods("GET", "OPTIONS")
|
||||||
|
protected.HandleFunc("/api/tracking/invite/{token}/accept", app.acceptTrackingInviteHandler).Methods("POST", "OPTIONS")
|
||||||
|
protected.HandleFunc("/api/tracking/access", app.getTrackingAccessHandler).Methods("GET", "OPTIONS")
|
||||||
|
protected.HandleFunc("/api/tracking/trackers/{id}", app.deleteTrackingTrackerHandler).Methods("DELETE", "OPTIONS")
|
||||||
|
protected.HandleFunc("/api/tracking/tracked/{id}", app.deleteTrackingTrackedHandler).Methods("DELETE", "OPTIONS")
|
||||||
|
|
||||||
// Wishlist items (после boards, чтобы {id} не перехватывал "boards")
|
// Wishlist items (после boards, чтобы {id} не перехватывал "boards")
|
||||||
protected.HandleFunc("/api/wishlist/{id}", app.getWishlistItemHandler).Methods("GET", "OPTIONS")
|
protected.HandleFunc("/api/wishlist/{id}", app.getWishlistItemHandler).Methods("GET", "OPTIONS")
|
||||||
protected.HandleFunc("/api/wishlist/{id}", app.updateWishlistHandler).Methods("PUT", "OPTIONS")
|
protected.HandleFunc("/api/wishlist/{id}", app.updateWishlistHandler).Methods("PUT", "OPTIONS")
|
||||||
@@ -4091,6 +4176,7 @@ func roundToFourDecimals(val float64) float64 {
|
|||||||
// groups - карта приоритетов к спискам calculatedScore проектов
|
// groups - карта приоритетов к спискам calculatedScore проектов
|
||||||
// Возвращает структуру GroupsProgress с процентами для каждой группы
|
// Возвращает структуру GroupsProgress с процентами для каждой группы
|
||||||
// Если какая-то группа отсутствует, она считается как 100%
|
// Если какая-то группа отсутствует, она считается как 100%
|
||||||
|
// min_goal = 100%, max_goal = 150%/135%/120% в зависимости от приоритета
|
||||||
func calculateGroupsProgress(groups map[int][]float64) GroupsProgress {
|
func calculateGroupsProgress(groups map[int][]float64) GroupsProgress {
|
||||||
// Всего есть 3 группы: приоритет 1, приоритет 2, приоритет 0
|
// Всего есть 3 группы: приоритет 1, приоритет 2, приоритет 0
|
||||||
// Вычисляем среднее для каждой группы, если она есть
|
// Вычисляем среднее для каждой группы, если она есть
|
||||||
@@ -4109,34 +4195,22 @@ func calculateGroupsProgress(groups map[int][]float64) GroupsProgress {
|
|||||||
// Если группы нет, считаем как 100%
|
// Если группы нет, считаем как 100%
|
||||||
avg = 100.0
|
avg = 100.0
|
||||||
} else {
|
} else {
|
||||||
// Вычисляем среднее для группы
|
// Для приоритета 1 и 2 - обычное среднее
|
||||||
// Для всех приоритетов: если calculated_score > 100%, избыточная часть делится на 2
|
|
||||||
// Функция для корректировки score: если > 100%, то 100 + (score - 100) / 2
|
|
||||||
adjustScore := func(score float64) float64 {
|
|
||||||
if score > 100.0 {
|
|
||||||
return 100.0 + (score-100.0)/2.0
|
|
||||||
}
|
|
||||||
return score
|
|
||||||
}
|
|
||||||
|
|
||||||
// Для приоритета 1 и 2 - обычное среднее с корректировкой
|
|
||||||
if priorityVal == 1 || priorityVal == 2 {
|
if priorityVal == 1 || priorityVal == 2 {
|
||||||
sum := 0.0
|
sum := 0.0
|
||||||
for _, score := range scores {
|
for _, score := range scores {
|
||||||
sum += adjustScore(score)
|
sum += score
|
||||||
}
|
}
|
||||||
avg = sum / float64(len(scores))
|
avg = sum / float64(len(scores))
|
||||||
} else {
|
} else {
|
||||||
// Для проектов без приоритета (priorityVal == 0) - специальная формула с корректировкой
|
// Для проектов без приоритета (priorityVal == 0) - специальная формула
|
||||||
projectCount := float64(len(scores))
|
projectCount := float64(len(scores))
|
||||||
multiplier := 100.0 / (projectCount * 0.8)
|
multiplier := 100.0 / (projectCount * 0.8)
|
||||||
|
|
||||||
sum := 0.0
|
sum := 0.0
|
||||||
for _, score := range scores {
|
for _, score := range scores {
|
||||||
// Применяем корректировку перед использованием в формуле
|
|
||||||
adjustedScore := adjustScore(score)
|
|
||||||
// score уже в процентах (например, 80.0), переводим в долю (0.8)
|
// score уже в процентах (например, 80.0), переводим в долю (0.8)
|
||||||
scoreAsDecimal := adjustedScore / 100.0
|
scoreAsDecimal := score / 100.0
|
||||||
sum += scoreAsDecimal * multiplier
|
sum += scoreAsDecimal * multiplier
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4161,33 +4235,37 @@ func calculateGroupsProgress(groups map[int][]float64) GroupsProgress {
|
|||||||
|
|
||||||
// calculateOverallProgress вычисляет общий процент выполнения на основе процентов групп
|
// calculateOverallProgress вычисляет общий процент выполнения на основе процентов групп
|
||||||
// groupsProgress - структура с процентами для каждой группы приоритетов
|
// groupsProgress - структура с процентами для каждой группы приоритетов
|
||||||
|
// groups - карта приоритетов к спискам calculatedScore проектов (используется для точного расчета)
|
||||||
// Возвращает указатель на float64 с общим процентом выполнения
|
// Возвращает указатель на float64 с общим процентом выполнения
|
||||||
// Всегда вычисляет среднее всех трех групп (даже если какая-то группа отсутствует, она считается как 100%)
|
// Вычисляет среднее между группами (min_goal = 100%, max_goal = 150%/135%/120%)
|
||||||
func calculateOverallProgress(groupsProgress GroupsProgress) *float64 {
|
func calculateOverallProgress(groupsProgress GroupsProgress, groups map[int][]float64) *float64 {
|
||||||
// Находим среднее между всеми тремя группами
|
// Собираем проценты по группам
|
||||||
// Если какая-то группа отсутствует (nil), считаем её как 100%
|
var groupScores []float64
|
||||||
|
|
||||||
var group1Val, group2Val, group0Val float64
|
|
||||||
|
|
||||||
|
// Добавляем проценты только тех групп, которые существуют (имеют проекты)
|
||||||
if groupsProgress.Group1 != nil {
|
if groupsProgress.Group1 != nil {
|
||||||
group1Val = *groupsProgress.Group1
|
groupScores = append(groupScores, *groupsProgress.Group1)
|
||||||
} else {
|
|
||||||
group1Val = 100.0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if groupsProgress.Group2 != nil {
|
if groupsProgress.Group2 != nil {
|
||||||
group2Val = *groupsProgress.Group2
|
groupScores = append(groupScores, *groupsProgress.Group2)
|
||||||
} else {
|
|
||||||
group2Val = 100.0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if groupsProgress.Group0 != nil {
|
if groupsProgress.Group0 != nil {
|
||||||
group0Val = *groupsProgress.Group0
|
groupScores = append(groupScores, *groupsProgress.Group0)
|
||||||
} else {
|
|
||||||
group0Val = 100.0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
overallProgress := (group1Val + group2Val + group0Val) / 3.0 // Всегда делим на 3, так как групп всегда 3
|
// Если нет групп с проектами, возвращаем 0
|
||||||
|
if len(groupScores) == 0 {
|
||||||
|
zero := 0.0
|
||||||
|
return &zero
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вычисляем среднее между группами
|
||||||
|
var sum float64
|
||||||
|
for _, score := range groupScores {
|
||||||
|
sum += score
|
||||||
|
}
|
||||||
|
|
||||||
|
overallProgress := sum / float64(len(groupScores))
|
||||||
overallProgressRounded := roundToFourDecimals(overallProgress)
|
overallProgressRounded := roundToFourDecimals(overallProgress)
|
||||||
total := &overallProgressRounded
|
total := &overallProgressRounded
|
||||||
|
|
||||||
@@ -14623,6 +14701,650 @@ func (a *App) proxyImageHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.Write(bodyBytes)
|
w.Write(bodyBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Tracking handlers
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// getWeeklyStatsDataForUserAndWeek получает данные о проектах для конкретного пользователя и недели
|
||||||
|
func (a *App) getWeeklyStatsDataForUserAndWeek(userID int, year int, week int) (*WeeklyStatsResponse, error) {
|
||||||
|
// Определяем, является ли это текущей неделей
|
||||||
|
now := time.Now()
|
||||||
|
currentYear, currentWeek := now.ISOWeek()
|
||||||
|
isCurrentWeek := year == currentYear && week == currentWeek
|
||||||
|
|
||||||
|
var currentWeekScores map[int]float64
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if isCurrentWeek {
|
||||||
|
// Для текущей недели используем realtime данные
|
||||||
|
currentWeekScores, err = a.getCurrentWeekScores(userID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error getting current week scores: %v", err)
|
||||||
|
return nil, fmt.Errorf("error getting current week scores: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Для исторических недель используем пустой map (данные из MV)
|
||||||
|
currentWeekScores = make(map[int]float64)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `
|
||||||
|
SELECT
|
||||||
|
p.id AS project_id,
|
||||||
|
p.name AS project_name,
|
||||||
|
COALESCE(wr.total_score, 0.0000) AS total_score,
|
||||||
|
wg.min_goal_score,
|
||||||
|
wg.max_goal_score,
|
||||||
|
wg.priority AS priority,
|
||||||
|
p.color
|
||||||
|
FROM
|
||||||
|
projects p
|
||||||
|
LEFT JOIN
|
||||||
|
weekly_goals wg ON wg.project_id = p.id
|
||||||
|
AND wg.goal_year = $2
|
||||||
|
AND wg.goal_week = $3
|
||||||
|
LEFT JOIN
|
||||||
|
weekly_report_mv wr
|
||||||
|
ON p.id = wr.project_id
|
||||||
|
AND $2 = wr.report_year
|
||||||
|
AND $3 = wr.report_week
|
||||||
|
WHERE
|
||||||
|
p.deleted = FALSE AND p.user_id = $1
|
||||||
|
ORDER BY
|
||||||
|
total_score DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := a.DB.Query(query, userID, year, week)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error querying weekly stats: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
projects := make([]WeeklyProjectStats, 0)
|
||||||
|
groups := make(map[int][]float64)
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var project WeeklyProjectStats
|
||||||
|
var projectID int
|
||||||
|
var minGoalScore sql.NullFloat64
|
||||||
|
var maxGoalScore sql.NullFloat64
|
||||||
|
var priority sql.NullInt64
|
||||||
|
|
||||||
|
err := rows.Scan(
|
||||||
|
&projectID,
|
||||||
|
&project.ProjectName,
|
||||||
|
&project.TotalScore,
|
||||||
|
&minGoalScore,
|
||||||
|
&maxGoalScore,
|
||||||
|
&priority,
|
||||||
|
&project.Color,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error scanning weekly stats row: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Объединяем данные: если это текущая неделя и есть данные, используем их вместо MV
|
||||||
|
if isCurrentWeek {
|
||||||
|
if currentWeekScore, exists := currentWeekScores[projectID]; exists {
|
||||||
|
project.TotalScore = currentWeekScore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if minGoalScore.Valid {
|
||||||
|
project.MinGoalScore = minGoalScore.Float64
|
||||||
|
} else {
|
||||||
|
project.MinGoalScore = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if maxGoalScore.Valid {
|
||||||
|
maxGoalVal := maxGoalScore.Float64
|
||||||
|
project.MaxGoalScore = &maxGoalVal
|
||||||
|
}
|
||||||
|
|
||||||
|
var priorityVal int
|
||||||
|
if priority.Valid {
|
||||||
|
priorityVal = int(priority.Int64)
|
||||||
|
project.Priority = &priorityVal
|
||||||
|
}
|
||||||
|
|
||||||
|
// Расчет calculated_score
|
||||||
|
totalScore := project.TotalScore
|
||||||
|
minGoalScoreVal := project.MinGoalScore
|
||||||
|
var maxGoalScoreVal float64
|
||||||
|
if project.MaxGoalScore != nil {
|
||||||
|
maxGoalScoreVal = *project.MaxGoalScore
|
||||||
|
}
|
||||||
|
|
||||||
|
// Параметры бонуса в зависимости от priority
|
||||||
|
var extraBonusLimit float64 = 20
|
||||||
|
if priorityVal == 1 {
|
||||||
|
extraBonusLimit = 50
|
||||||
|
} else if priorityVal == 2 {
|
||||||
|
extraBonusLimit = 35
|
||||||
|
}
|
||||||
|
|
||||||
|
// Расчет calculated_score по логике фронтенда
|
||||||
|
// min_goal -> 100%, max_goal -> 150%/135%/120% в зависимости от приоритета
|
||||||
|
var resultScore float64
|
||||||
|
if minGoalScoreVal <= 0 {
|
||||||
|
// Если нет minGoal, возвращаем 0 (или можно относительно maxGoal, но обычно 0)
|
||||||
|
resultScore = 0
|
||||||
|
} else if totalScore < minGoalScoreVal {
|
||||||
|
// До достижения minGoal растем линейно от 0 до 100%
|
||||||
|
resultScore = (totalScore / minGoalScoreVal) * 100.0
|
||||||
|
} else {
|
||||||
|
// Достигнут minGoal - базовый прогресс = 100%
|
||||||
|
baseProgress := 100.0
|
||||||
|
|
||||||
|
// Если maxGoal задан корректно и больше minGoal, добавляем экстра прогресс
|
||||||
|
if maxGoalScoreVal > minGoalScoreVal {
|
||||||
|
extraRange := maxGoalScoreVal - minGoalScoreVal
|
||||||
|
excess := min(totalScore, maxGoalScoreVal) - minGoalScoreVal
|
||||||
|
extraRatio := min(1.0, max(0.0, excess/extraRange))
|
||||||
|
extraProgress := extraRatio * extraBonusLimit
|
||||||
|
resultScore = min(100.0+extraBonusLimit, baseProgress+extraProgress)
|
||||||
|
} else {
|
||||||
|
// Если maxGoal не задан или некорректен, просто 100%
|
||||||
|
resultScore = baseProgress
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
project.CalculatedScore = roundToTwoDecimals(resultScore)
|
||||||
|
projects = append(projects, project)
|
||||||
|
|
||||||
|
// Группировка для итогового расчета
|
||||||
|
if minGoalScoreVal > 0 {
|
||||||
|
if _, exists := groups[priorityVal]; !exists {
|
||||||
|
groups[priorityVal] = make([]float64, 0)
|
||||||
|
}
|
||||||
|
groups[priorityVal] = append(groups[priorityVal], project.CalculatedScore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вычисляем проценты для каждой группы
|
||||||
|
groupsProgress := calculateGroupsProgress(groups)
|
||||||
|
|
||||||
|
// Вычисляем общий процент выполнения
|
||||||
|
total := calculateOverallProgress(groupsProgress, groups)
|
||||||
|
|
||||||
|
response := WeeklyStatsResponse{
|
||||||
|
Total: total,
|
||||||
|
GroupProgress1: groupsProgress.Group1,
|
||||||
|
GroupProgress2: groupsProgress.Group2,
|
||||||
|
GroupProgress0: groupsProgress.Group0,
|
||||||
|
Projects: projects,
|
||||||
|
}
|
||||||
|
|
||||||
|
return &response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getISOWeek вычисляет номер недели ISO для даты
|
||||||
|
func getISOWeek(t time.Time) int {
|
||||||
|
_, week := t.ISOWeek()
|
||||||
|
return week
|
||||||
|
}
|
||||||
|
|
||||||
|
// getTrackingStatsHandler возвращает статистику недели для текущего пользователя и отслеживаемых
|
||||||
|
func (a *App) getTrackingStatsHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "OPTIONS" {
|
||||||
|
setCORSHeaders(w)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setCORSHeaders(w)
|
||||||
|
|
||||||
|
userID, ok := getUserIDFromContext(r)
|
||||||
|
if !ok {
|
||||||
|
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем year и week из query params
|
||||||
|
yearStr := r.URL.Query().Get("year")
|
||||||
|
weekStr := r.URL.Query().Get("week")
|
||||||
|
|
||||||
|
var year, week int
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
if yearStr == "" || weekStr == "" {
|
||||||
|
// Если не указаны - текущая неделя
|
||||||
|
year, week = now.ISOWeek()
|
||||||
|
} else {
|
||||||
|
var err error
|
||||||
|
year, err = strconv.Atoi(yearStr)
|
||||||
|
if err != nil {
|
||||||
|
sendErrorWithCORS(w, "Invalid year parameter", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
week, err = strconv.Atoi(weekStr)
|
||||||
|
if err != nil {
|
||||||
|
sendErrorWithCORS(w, "Invalid week parameter", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем список отслеживаемых пользователей
|
||||||
|
rows, err := a.DB.Query(`
|
||||||
|
SELECT tracked_id
|
||||||
|
FROM user_tracking
|
||||||
|
WHERE tracker_id = $1
|
||||||
|
`, userID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error getting tracked users: %v", err)
|
||||||
|
sendErrorWithCORS(w, "Error getting tracked users", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
trackedUserIDs := []int{userID} // Начинаем с текущего пользователя
|
||||||
|
for rows.Next() {
|
||||||
|
var trackedID int
|
||||||
|
if err := rows.Scan(&trackedID); err != nil {
|
||||||
|
log.Printf("Error scanning tracked user: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
trackedUserIDs = append(trackedUserIDs, trackedID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем данные для каждого пользователя
|
||||||
|
users := make([]TrackingUserStats, 0)
|
||||||
|
for _, uid := range trackedUserIDs {
|
||||||
|
stats, err := a.getWeeklyStatsDataForUserAndWeek(uid, year, week)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error getting stats for user %d: %v", uid, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем имя пользователя
|
||||||
|
var userName string
|
||||||
|
err = a.DB.QueryRow(`SELECT COALESCE(name, email) FROM users WHERE id = $1`, uid).Scan(&userName)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error getting user name: %v", err)
|
||||||
|
userName = "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Преобразуем проекты в TrackingProjectStats
|
||||||
|
projects := make([]TrackingProjectStats, 0)
|
||||||
|
for _, p := range stats.Projects {
|
||||||
|
projects = append(projects, TrackingProjectStats{
|
||||||
|
ProjectName: p.ProjectName,
|
||||||
|
CalculatedScore: p.CalculatedScore,
|
||||||
|
Priority: p.Priority,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сортируем проекты по priority (1, 2, остальные)
|
||||||
|
sort.Slice(projects, func(i, j int) bool {
|
||||||
|
pi, pj := 99, 99
|
||||||
|
if projects[i].Priority != nil {
|
||||||
|
pi = *projects[i].Priority
|
||||||
|
}
|
||||||
|
if projects[j].Priority != nil {
|
||||||
|
pj = *projects[j].Priority
|
||||||
|
}
|
||||||
|
return pi < pj
|
||||||
|
})
|
||||||
|
|
||||||
|
users = append(users, TrackingUserStats{
|
||||||
|
UserID: uid,
|
||||||
|
UserName: userName,
|
||||||
|
IsCurrentUser: uid == userID,
|
||||||
|
Total: stats.Total,
|
||||||
|
Projects: projects,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сортируем: текущий пользователь всегда первый
|
||||||
|
sortedUsers := make([]TrackingUserStats, 0)
|
||||||
|
for _, u := range users {
|
||||||
|
if u.IsCurrentUser {
|
||||||
|
sortedUsers = append([]TrackingUserStats{u}, sortedUsers...)
|
||||||
|
} else {
|
||||||
|
sortedUsers = append(sortedUsers, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response := TrackingStatsResponse{
|
||||||
|
WeekNumber: week,
|
||||||
|
Year: year,
|
||||||
|
Users: sortedUsers,
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// createTrackingInviteHandler создает токен приглашения
|
||||||
|
func (a *App) createTrackingInviteHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "OPTIONS" {
|
||||||
|
setCORSHeaders(w)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setCORSHeaders(w)
|
||||||
|
|
||||||
|
userID, ok := getUserIDFromContext(r)
|
||||||
|
if !ok {
|
||||||
|
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Генерируем токен
|
||||||
|
token := generateInviteToken()
|
||||||
|
|
||||||
|
// Сохраняем в базу с истечением через 1 час
|
||||||
|
_, err := a.DB.Exec(`
|
||||||
|
INSERT INTO tracking_invite_tokens (user_id, token, expires_at)
|
||||||
|
VALUES ($1, $2, NOW() + INTERVAL '1 hour')
|
||||||
|
`, userID, token)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error creating tracking invite token: %v", err)
|
||||||
|
sendErrorWithCORS(w, "Error creating invite token", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Формируем URL
|
||||||
|
baseURL := getEnv("WEBHOOK_BASE_URL", "")
|
||||||
|
inviteURL := baseURL + "/tracking/invite/" + token
|
||||||
|
|
||||||
|
response := TrackingInviteResponse{
|
||||||
|
InviteURL: inviteURL,
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getTrackingInviteInfoHandler возвращает информацию о приглашении
|
||||||
|
func (a *App) getTrackingInviteInfoHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "OPTIONS" {
|
||||||
|
setCORSHeaders(w)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setCORSHeaders(w)
|
||||||
|
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
token := vars["token"]
|
||||||
|
|
||||||
|
var userID int
|
||||||
|
var expiresAt time.Time
|
||||||
|
err := a.DB.QueryRow(`
|
||||||
|
SELECT user_id, expires_at
|
||||||
|
FROM tracking_invite_tokens
|
||||||
|
WHERE token = $1
|
||||||
|
`, token).Scan(&userID, &expiresAt)
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
sendErrorWithCORS(w, "Ссылка недействительна или устарела", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error getting invite token: %v", err)
|
||||||
|
sendErrorWithCORS(w, "Error getting invite info", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем срок действия
|
||||||
|
if time.Now().After(expiresAt) {
|
||||||
|
sendErrorWithCORS(w, "Ссылка недействительна или устарела", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем имя пользователя
|
||||||
|
var userName string
|
||||||
|
err = a.DB.QueryRow(`SELECT COALESCE(name, email) FROM users WHERE id = $1`, userID).Scan(&userName)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error getting user name: %v", err)
|
||||||
|
sendErrorWithCORS(w, "Error getting user info", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response := TrackingInviteInfo{
|
||||||
|
UserID: userID,
|
||||||
|
UserName: userName,
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// acceptTrackingInviteHandler принимает приглашение на отслеживание
|
||||||
|
func (a *App) acceptTrackingInviteHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "OPTIONS" {
|
||||||
|
setCORSHeaders(w)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setCORSHeaders(w)
|
||||||
|
|
||||||
|
currentUserID, ok := getUserIDFromContext(r)
|
||||||
|
if !ok {
|
||||||
|
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
token := vars["token"]
|
||||||
|
|
||||||
|
// Получаем информацию о токене
|
||||||
|
var trackedUserID int
|
||||||
|
var expiresAt time.Time
|
||||||
|
err := a.DB.QueryRow(`
|
||||||
|
SELECT user_id, expires_at
|
||||||
|
FROM tracking_invite_tokens
|
||||||
|
WHERE token = $1 AND expires_at > NOW()
|
||||||
|
`, token).Scan(&trackedUserID, &expiresAt)
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
sendErrorWithCORS(w, "Ссылка недействительна или устарела", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error getting invite token: %v", err)
|
||||||
|
sendErrorWithCORS(w, "Error getting invite info", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, что пользователь не пытается отслеживать себя
|
||||||
|
if trackedUserID == currentUserID {
|
||||||
|
sendErrorWithCORS(w, "Нельзя отслеживать себя", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаем запись отслеживания
|
||||||
|
_, err = a.DB.Exec(`
|
||||||
|
INSERT INTO user_tracking (tracker_id, tracked_id)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (tracker_id, tracked_id) DO NOTHING
|
||||||
|
`, currentUserID, trackedUserID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error creating tracking relation: %v", err)
|
||||||
|
sendErrorWithCORS(w, "Error accepting invite", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаляем использованный токен (одноразовый)
|
||||||
|
_, err = a.DB.Exec(`DELETE FROM tracking_invite_tokens WHERE token = $1`, token)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error deleting used token: %v", err)
|
||||||
|
// Не критично, продолжаем
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем имя пользователя для ответа
|
||||||
|
var userName string
|
||||||
|
a.DB.QueryRow(`SELECT COALESCE(name, email) FROM users WHERE id = $1`, trackedUserID).Scan(&userName)
|
||||||
|
|
||||||
|
response := map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"tracked_user_name": userName,
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getTrackingAccessHandler возвращает списки трекеров и отслеживаемых
|
||||||
|
func (a *App) getTrackingAccessHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "OPTIONS" {
|
||||||
|
setCORSHeaders(w)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setCORSHeaders(w)
|
||||||
|
|
||||||
|
userID, ok := getUserIDFromContext(r)
|
||||||
|
if !ok {
|
||||||
|
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trackers (кто меня отслеживает)
|
||||||
|
trackers := make([]TrackingUser, 0)
|
||||||
|
rows, err := a.DB.Query(`
|
||||||
|
SELECT ut.id as relation_id, u.id, COALESCE(u.name, u.email) as name, u.email, ut.created_at
|
||||||
|
FROM user_tracking ut
|
||||||
|
JOIN users u ON ut.tracker_id = u.id
|
||||||
|
WHERE ut.tracked_id = $1
|
||||||
|
ORDER BY ut.created_at DESC
|
||||||
|
`, userID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error getting trackers: %v", err)
|
||||||
|
sendErrorWithCORS(w, "Error getting trackers", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var t TrackingUser
|
||||||
|
if err := rows.Scan(&t.RelationID, &t.ID, &t.Name, &t.Email, &t.CreatedAt); err != nil {
|
||||||
|
log.Printf("Error scanning tracker: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
trackers = append(trackers, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tracked (кого я отслеживаю)
|
||||||
|
tracked := make([]TrackingUser, 0)
|
||||||
|
rows, err = a.DB.Query(`
|
||||||
|
SELECT ut.id as relation_id, u.id, COALESCE(u.name, u.email) as name, u.email, ut.created_at
|
||||||
|
FROM user_tracking ut
|
||||||
|
JOIN users u ON ut.tracked_id = u.id
|
||||||
|
WHERE ut.tracker_id = $1
|
||||||
|
ORDER BY ut.created_at DESC
|
||||||
|
`, userID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error getting tracked: %v", err)
|
||||||
|
sendErrorWithCORS(w, "Error getting tracked", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var t TrackingUser
|
||||||
|
if err := rows.Scan(&t.RelationID, &t.ID, &t.Name, &t.Email, &t.CreatedAt); err != nil {
|
||||||
|
log.Printf("Error scanning tracked: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tracked = append(tracked, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
response := TrackingAccessResponse{
|
||||||
|
Trackers: trackers,
|
||||||
|
Tracked: tracked,
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteTrackingTrackerHandler удаляет того, кто меня отслеживает
|
||||||
|
func (a *App) deleteTrackingTrackerHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "OPTIONS" {
|
||||||
|
setCORSHeaders(w)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setCORSHeaders(w)
|
||||||
|
|
||||||
|
userID, ok := getUserIDFromContext(r)
|
||||||
|
if !ok {
|
||||||
|
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
relationIDStr := vars["id"]
|
||||||
|
relationID, err := strconv.Atoi(relationIDStr)
|
||||||
|
if err != nil {
|
||||||
|
sendErrorWithCORS(w, "Invalid relation ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаляем только если это действительно тот, кто отслеживает меня
|
||||||
|
result, err := a.DB.Exec(`
|
||||||
|
DELETE FROM user_tracking
|
||||||
|
WHERE id = $1 AND tracked_id = $2
|
||||||
|
`, relationID, userID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error deleting tracker relation: %v", err)
|
||||||
|
sendErrorWithCORS(w, "Error deleting relation", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rowsAffected, _ := result.RowsAffected()
|
||||||
|
if rowsAffected == 0 {
|
||||||
|
sendErrorWithCORS(w, "Relation not found or access denied", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{"success": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteTrackingTrackedHandler прекращает отслеживать пользователя
|
||||||
|
func (a *App) deleteTrackingTrackedHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "OPTIONS" {
|
||||||
|
setCORSHeaders(w)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setCORSHeaders(w)
|
||||||
|
|
||||||
|
userID, ok := getUserIDFromContext(r)
|
||||||
|
if !ok {
|
||||||
|
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
relationIDStr := vars["id"]
|
||||||
|
relationID, err := strconv.Atoi(relationIDStr)
|
||||||
|
if err != nil {
|
||||||
|
sendErrorWithCORS(w, "Invalid relation ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаляем только если это действительно тот, кого я отслеживаю
|
||||||
|
result, err := a.DB.Exec(`
|
||||||
|
DELETE FROM user_tracking
|
||||||
|
WHERE id = $1 AND tracker_id = $2
|
||||||
|
`, relationID, userID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error deleting tracked relation: %v", err)
|
||||||
|
sendErrorWithCORS(w, "Error deleting relation", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rowsAffected, _ := result.RowsAffected()
|
||||||
|
if rowsAffected == 0 {
|
||||||
|
sendErrorWithCORS(w, "Relation not found or access denied", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{"success": true})
|
||||||
|
}
|
||||||
|
|
||||||
// decodeHTMLEntities декодирует базовые HTML entities
|
// decodeHTMLEntities декодирует базовые HTML entities
|
||||||
func decodeHTMLEntities(s string) string {
|
func decodeHTMLEntities(s string) string {
|
||||||
replacements := map[string]string{
|
replacements := map[string]string{
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DROP TABLE IF EXISTS tracking_invite_tokens;
|
||||||
|
DROP TABLE IF EXISTS user_tracking;
|
||||||
24
play-life-backend/migrations/000013_add_user_tracking.up.sql
Normal file
24
play-life-backend/migrations/000013_add_user_tracking.up.sql
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
-- Таблица отслеживания между пользователями
|
||||||
|
CREATE TABLE user_tracking (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
tracker_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
tracked_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT unique_tracking_pair UNIQUE (tracker_id, tracked_id),
|
||||||
|
CONSTRAINT no_self_tracking CHECK (tracker_id != tracked_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_user_tracking_tracker ON user_tracking(tracker_id);
|
||||||
|
CREATE INDEX idx_user_tracking_tracked ON user_tracking(tracked_id);
|
||||||
|
|
||||||
|
-- Таблица токенов приглашений (живут 1 час)
|
||||||
|
CREATE TABLE tracking_invite_tokens (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
token VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_tracking_invite_tokens_token ON tracking_invite_tokens(token);
|
||||||
|
CREATE INDEX idx_tracking_invite_tokens_user ON tracking_invite_tokens(user_id);
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "play-life-web",
|
"name": "play-life-web",
|
||||||
"version": "4.22.0",
|
"version": "4.23.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ import BoardForm from './components/BoardForm'
|
|||||||
import BoardJoinPreview from './components/BoardJoinPreview'
|
import BoardJoinPreview from './components/BoardJoinPreview'
|
||||||
import TodoistIntegration from './components/TodoistIntegration'
|
import TodoistIntegration from './components/TodoistIntegration'
|
||||||
import TelegramIntegration from './components/TelegramIntegration'
|
import TelegramIntegration from './components/TelegramIntegration'
|
||||||
|
import Tracking from './components/Tracking'
|
||||||
|
import TrackingAccess from './components/TrackingAccess'
|
||||||
|
import TrackingInviteAccept from './components/TrackingInviteAccept'
|
||||||
import { AuthProvider, useAuth } from './components/auth/AuthContext'
|
import { AuthProvider, useAuth } from './components/auth/AuthContext'
|
||||||
import AuthScreen from './components/auth/AuthScreen'
|
import AuthScreen from './components/auth/AuthScreen'
|
||||||
import PWAUpdatePrompt from './components/PWAUpdatePrompt'
|
import PWAUpdatePrompt from './components/PWAUpdatePrompt'
|
||||||
@@ -26,7 +29,7 @@ const FULL_STATISTICS_API_URL = '/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b'
|
|||||||
|
|
||||||
// Определяем основные табы (без крестика) и глубокие табы (с крестиком)
|
// Определяем основные табы (без крестика) и глубокие табы (с крестиком)
|
||||||
const mainTabs = ['current', 'tasks', 'wishlist', 'profile']
|
const mainTabs = ['current', 'tasks', 'wishlist', 'profile']
|
||||||
const deepTabs = ['add-words', 'test', 'task-form', 'wishlist-form', 'wishlist-detail', 'board-form', 'board-join', 'words', 'dictionaries', 'todoist-integration', 'telegram-integration', 'full', 'priorities']
|
const deepTabs = ['add-words', 'test', 'task-form', 'wishlist-form', 'wishlist-detail', 'board-form', 'board-join', 'words', 'dictionaries', 'todoist-integration', 'telegram-integration', 'full', 'priorities', 'tracking', 'tracking-access', 'tracking-invite']
|
||||||
|
|
||||||
function AppContent() {
|
function AppContent() {
|
||||||
const { authFetch, isAuthenticated, loading: authLoading } = useAuth()
|
const { authFetch, isAuthenticated, loading: authLoading } = useAuth()
|
||||||
@@ -64,6 +67,9 @@ function AppContent() {
|
|||||||
profile: false,
|
profile: false,
|
||||||
'todoist-integration': false,
|
'todoist-integration': false,
|
||||||
'telegram-integration': false,
|
'telegram-integration': false,
|
||||||
|
tracking: false,
|
||||||
|
'tracking-access': false,
|
||||||
|
'tracking-invite': false,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Отслеживаем, какие табы уже были загружены (для предотвращения повторных загрузок)
|
// Отслеживаем, какие табы уже были загружены (для предотвращения повторных загрузок)
|
||||||
@@ -85,6 +91,9 @@ function AppContent() {
|
|||||||
profile: false,
|
profile: false,
|
||||||
'todoist-integration': false,
|
'todoist-integration': false,
|
||||||
'telegram-integration': false,
|
'telegram-integration': false,
|
||||||
|
tracking: false,
|
||||||
|
'tracking-access': false,
|
||||||
|
'tracking-invite': false,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Параметры для навигации между вкладками
|
// Параметры для навигации между вкладками
|
||||||
@@ -155,10 +164,23 @@ function AppContent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Проверяем путь /tracking/invite/:token
|
||||||
|
if (path.startsWith('/tracking/invite/')) {
|
||||||
|
const token = path.replace('/tracking/invite/', '')
|
||||||
|
if (token) {
|
||||||
|
setActiveTab('tracking-invite')
|
||||||
|
setLoadedTabs(prev => ({ ...prev, 'tracking-invite': true }))
|
||||||
|
setTabParams({ inviteToken: token })
|
||||||
|
setIsInitialized(true)
|
||||||
|
window.history.replaceState({}, '', '/?tab=tracking-invite&inviteToken=' + token)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Проверяем URL только для глубоких табов
|
// Проверяем URL только для глубоких табов
|
||||||
const urlParams = new URLSearchParams(window.location.search)
|
const urlParams = new URLSearchParams(window.location.search)
|
||||||
const tabFromUrl = urlParams.get('tab')
|
const tabFromUrl = urlParams.get('tab')
|
||||||
const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'board-form', 'board-join', 'profile', 'todoist-integration', 'telegram-integration']
|
const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'board-form', 'board-join', 'profile', 'todoist-integration', 'telegram-integration', 'tracking', 'tracking-access', 'tracking-invite']
|
||||||
|
|
||||||
if (tabFromUrl && validTabs.includes(tabFromUrl) && deepTabs.includes(tabFromUrl)) {
|
if (tabFromUrl && validTabs.includes(tabFromUrl) && deepTabs.includes(tabFromUrl)) {
|
||||||
// Если в URL есть глубокий таб, восстанавливаем его
|
// Если в URL есть глубокий таб, восстанавливаем его
|
||||||
@@ -469,6 +491,9 @@ function AppContent() {
|
|||||||
profile: false,
|
profile: false,
|
||||||
'todoist-integration': false,
|
'todoist-integration': false,
|
||||||
'telegram-integration': false,
|
'telegram-integration': false,
|
||||||
|
tracking: false,
|
||||||
|
'tracking-access': false,
|
||||||
|
'tracking-invite': false,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Используем ref для отслеживания кеша (чтобы не зависеть от состояния в useCallback)
|
// Используем ref для отслеживания кеша (чтобы не зависеть от состояния в useCallback)
|
||||||
@@ -618,7 +643,7 @@ function AppContent() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'profile', 'todoist-integration', 'telegram-integration']
|
const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'profile', 'todoist-integration', 'telegram-integration', 'tracking', 'tracking-access', 'tracking-invite']
|
||||||
|
|
||||||
// Проверяем state текущей записи истории (куда мы вернулись)
|
// Проверяем state текущей записи истории (куда мы вернулись)
|
||||||
if (event.state && event.state.tab) {
|
if (event.state && event.state.tab) {
|
||||||
@@ -915,7 +940,7 @@ function AppContent() {
|
|||||||
}, [activeTab])
|
}, [activeTab])
|
||||||
|
|
||||||
// Определяем, нужно ли скрывать нижнюю панель (для fullscreen экранов)
|
// Определяем, нужно ли скрывать нижнюю панель (для fullscreen экранов)
|
||||||
const isFullscreenTab = activeTab === 'test' || activeTab === 'add-words' || activeTab === 'task-form' || activeTab === 'wishlist-form' || activeTab === 'wishlist-detail' || activeTab === 'todoist-integration' || activeTab === 'telegram-integration' || activeTab === 'full' || activeTab === 'priorities' || activeTab === 'words' || activeTab === 'dictionaries'
|
const isFullscreenTab = activeTab === 'test' || activeTab === 'add-words' || activeTab === 'task-form' || activeTab === 'wishlist-form' || activeTab === 'wishlist-detail' || activeTab === 'todoist-integration' || activeTab === 'telegram-integration' || activeTab === 'full' || activeTab === 'priorities' || activeTab === 'words' || activeTab === 'dictionaries' || activeTab === 'tracking' || activeTab === 'tracking-access' || activeTab === 'tracking-invite'
|
||||||
|
|
||||||
// Функция для получения классов скролл-контейнера для каждого таба
|
// Функция для получения классов скролл-контейнера для каждого таба
|
||||||
// Каждый таб имеет свой изолированный скролл-контейнер для автоматического сохранения позиции скролла
|
// Каждый таб имеет свой изолированный скролл-контейнер для автоматического сохранения позиции скролла
|
||||||
@@ -1173,6 +1198,33 @@ function AppContent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{loadedTabs.tracking && (
|
||||||
|
<div className={getTabContainerClasses('tracking')}>
|
||||||
|
<div className={getInnerContainerClasses('tracking')}>
|
||||||
|
<Tracking onNavigate={handleNavigate} activeTab={activeTab} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadedTabs['tracking-access'] && (
|
||||||
|
<div className={getTabContainerClasses('tracking-access')}>
|
||||||
|
<div className={getInnerContainerClasses('tracking-access')}>
|
||||||
|
<TrackingAccess onNavigate={handleNavigate} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadedTabs['tracking-invite'] && (
|
||||||
|
<div className={getTabContainerClasses('tracking-invite')}>
|
||||||
|
<div className={getInnerContainerClasses('tracking-invite')}>
|
||||||
|
<TrackingInviteAccept
|
||||||
|
inviteToken={tabParams.inviteToken}
|
||||||
|
onNavigate={handleNavigate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Кнопка добавления задачи (только для таба задач) */}
|
{/* Кнопка добавления задачи (только для таба задач) */}
|
||||||
|
|||||||
@@ -35,9 +35,10 @@ function Profile({ onNavigate }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Admin Button */}
|
{/* Admin & Tracking Buttons */}
|
||||||
{user?.is_admin && (
|
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{user?.is_admin && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const adminUrl = window.location.origin + '/admin';
|
const adminUrl = window.location.origin + '/admin';
|
||||||
@@ -64,8 +65,32 @@ function Profile({ onNavigate }) {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => onNavigate?.('tracking')}
|
||||||
|
className="w-full p-4 bg-white rounded-xl shadow-sm hover:shadow-md transition-all text-left border border-gray-100 hover:border-indigo-200 group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-gray-800 font-medium group-hover:text-indigo-600 transition-colors">
|
||||||
|
Отслеживание
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5 text-gray-400 group-hover:text-indigo-500 transition-colors"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9 5l7 7-7 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Features Section */}
|
{/* Features Section */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
|
|||||||
435
play-life-web/src/components/Tracking.css
Normal file
435
play-life-web/src/components/Tracking.css
Normal file
@@ -0,0 +1,435 @@
|
|||||||
|
/* ===== Tracking Screen ===== */
|
||||||
|
.tracking-screen {
|
||||||
|
padding: 0 1rem 1rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header with title and close button */
|
||||||
|
.tracking-header {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding-top: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracking-header h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-x-button {
|
||||||
|
position: fixed;
|
||||||
|
top: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
border: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #7f8c8d;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: background-color 0.2s, color 0.2s;
|
||||||
|
z-index: 1600;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-x-button:hover {
|
||||||
|
background-color: #ffffff;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Week controls: scrollable chips + access button */
|
||||||
|
.week-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-chips-scroll {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
gap: 0.625rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-chips-scroll::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-icon-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
background: white;
|
||||||
|
color: #374151;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-icon-btn:hover {
|
||||||
|
border-color: #a5b4fc;
|
||||||
|
color: #4f46e5;
|
||||||
|
background: #f5f3ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Week chips */
|
||||||
|
.week-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.625rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-chip {
|
||||||
|
height: 2.25rem;
|
||||||
|
padding: 0 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
background: transparent;
|
||||||
|
color: #374151;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-chip:hover {
|
||||||
|
border-color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-chip.selected {
|
||||||
|
background: white;
|
||||||
|
color: #111827;
|
||||||
|
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
||||||
|
border-color: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-chip.current:not(.selected) {
|
||||||
|
border-color: #a5b4fc;
|
||||||
|
box-shadow: 0 0 0 2px rgba(165, 180, 252, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* User tracking cards */
|
||||||
|
.users-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-tracking-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 1.5rem;
|
||||||
|
padding: 1.125rem 1.5rem;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-tracking-card.current-user {
|
||||||
|
border: 1px solid #a5b4fc;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 1px solid #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-total {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.percent-green {
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.percent-blue {
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.projects-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-name {
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-score {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Access link section */
|
||||||
|
.access-link-section {
|
||||||
|
margin-top: 2rem;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-link-button {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
color: #374151;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-link-button:hover {
|
||||||
|
border-color: #a5b4fc;
|
||||||
|
color: #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Tracking Access Screen ===== */
|
||||||
|
.tracking-access-screen {
|
||||||
|
padding: 0 1rem 1rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screen-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-section {
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-section h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-hint {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-invite-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: linear-gradient(to right, #4f46e5, #7c3aed);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-invite-btn:hover:not(:disabled) {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-invite-btn:disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-invite-btn.copied {
|
||||||
|
background: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-list {
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 0;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-item-name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-btn {
|
||||||
|
padding: 0.5rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-btn:hover {
|
||||||
|
color: #ef4444;
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Tracking Invite Screen ===== */
|
||||||
|
.tracking-invite-screen {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 1rem;
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 24rem;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-card.error-card {
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-card h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-info {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name-large {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #4f46e5;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accept-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.875rem;
|
||||||
|
background: linear-gradient(to right, #4f46e5, #7c3aed);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accept-btn:disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-link {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #6b7280;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-link:hover {
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-text {
|
||||||
|
color: #ef4444;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-inline {
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-btn {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #374151;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading */
|
||||||
|
.loading-container {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
border: 4px solid #e0e7ff;
|
||||||
|
border-top-color: #4f46e5;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin: 0 auto 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
184
play-life-web/src/components/Tracking.jsx
Normal file
184
play-life-web/src/components/Tracking.jsx
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import React, { useState, useEffect, useMemo, useRef } from 'react'
|
||||||
|
import { useAuth } from './auth/AuthContext'
|
||||||
|
import './Tracking.css'
|
||||||
|
|
||||||
|
// Функция для вычисления номера недели ISO
|
||||||
|
function getISOWeek(date) {
|
||||||
|
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()))
|
||||||
|
const dayNum = d.getUTCDay() || 7
|
||||||
|
d.setUTCDate(d.getUTCDate() + 4 - dayNum)
|
||||||
|
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1))
|
||||||
|
return Math.ceil((((d - yearStart) / 86400000) + 1) / 7)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для вычисления 5 недель (текущая + 4 предыдущие)
|
||||||
|
function getLastFiveWeeks() {
|
||||||
|
const weeks = []
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const date = new Date(now)
|
||||||
|
date.setDate(date.getDate() - i * 7)
|
||||||
|
const week = getISOWeek(date)
|
||||||
|
const year = date.getFullYear()
|
||||||
|
weeks.push({
|
||||||
|
year,
|
||||||
|
week,
|
||||||
|
isCurrent: i === 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return weeks.reverse() // От старой к новой
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tracking({ onNavigate, activeTab }) {
|
||||||
|
const { authFetch } = useAuth()
|
||||||
|
const weeks = useMemo(() => getLastFiveWeeks(), [])
|
||||||
|
const [selectedWeek, setSelectedWeek] = useState(weeks[weeks.length - 1]) // Текущая неделя
|
||||||
|
const [data, setData] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const scrollContainerRef = useRef(null)
|
||||||
|
const currentWeekChipRef = useRef(null)
|
||||||
|
const prevActiveTabRef = useRef(null)
|
||||||
|
|
||||||
|
// Сброс выбранной недели на текущую при открытии экрана
|
||||||
|
useEffect(() => {
|
||||||
|
// Проверяем, что экран только что открылся (activeTab стал 'tracking')
|
||||||
|
if (activeTab === 'tracking' && prevActiveTabRef.current !== 'tracking') {
|
||||||
|
const currentWeek = weeks[weeks.length - 1] // Последняя неделя в списке - текущая
|
||||||
|
setSelectedWeek(currentWeek)
|
||||||
|
}
|
||||||
|
prevActiveTabRef.current = activeTab
|
||||||
|
}, [activeTab, weeks])
|
||||||
|
|
||||||
|
// Скролл к чипсу текущей недели при открытии экрана
|
||||||
|
useEffect(() => {
|
||||||
|
// Выполняем скролл только когда экран открыт и только что открылся
|
||||||
|
if (activeTab === 'tracking' && currentWeekChipRef.current && scrollContainerRef.current) {
|
||||||
|
const chip = currentWeekChipRef.current
|
||||||
|
const container = scrollContainerRef.current
|
||||||
|
|
||||||
|
// Небольшая задержка для гарантии рендеринга
|
||||||
|
setTimeout(() => {
|
||||||
|
const chipLeft = chip.offsetLeft
|
||||||
|
const chipWidth = chip.offsetWidth
|
||||||
|
const containerWidth = container.offsetWidth
|
||||||
|
const scrollLeft = chipLeft - (containerWidth / 2) + (chipWidth / 2)
|
||||||
|
|
||||||
|
container.scrollTo({
|
||||||
|
left: scrollLeft,
|
||||||
|
behavior: 'smooth'
|
||||||
|
})
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
}, [activeTab])
|
||||||
|
|
||||||
|
// Загрузка данных при смене недели
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await authFetch(`/api/tracking/stats?year=${selectedWeek.year}&week=${selectedWeek.week}`)
|
||||||
|
if (res.ok) {
|
||||||
|
setData(await res.json())
|
||||||
|
} else {
|
||||||
|
setError('Ошибка загрузки')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('Ошибка загрузки')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchData()
|
||||||
|
}, [selectedWeek, authFetch])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tracking-screen max-w-2xl mx-auto">
|
||||||
|
{/* Заголовок с крестиком */}
|
||||||
|
<div className="tracking-header">
|
||||||
|
<h2 className="text-2xl font-semibold text-gray-800">Отслеживание</h2>
|
||||||
|
</div>
|
||||||
|
<button className="close-x-button" onClick={() => window.history.back()}>✕</button>
|
||||||
|
|
||||||
|
{/* Чипсы недель с кнопкой доступов */}
|
||||||
|
<div className="week-controls">
|
||||||
|
<div className="week-chips-scroll" ref={scrollContainerRef}>
|
||||||
|
{weeks.map(w => (
|
||||||
|
<button
|
||||||
|
key={`${w.year}-${w.week}`}
|
||||||
|
ref={w.isCurrent ? currentWeekChipRef : null}
|
||||||
|
onClick={() => setSelectedWeek(w)}
|
||||||
|
className={`week-chip
|
||||||
|
${selectedWeek.year === w.year && selectedWeek.week === w.week ? 'selected' : ''}
|
||||||
|
${w.isCurrent ? 'current' : ''}`}
|
||||||
|
>
|
||||||
|
Неделя {w.week}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="access-icon-btn"
|
||||||
|
onClick={() => onNavigate('tracking-access')}
|
||||||
|
title="Управление доступами"
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
|
||||||
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Контент */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="loading-spinner">Загрузка...</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="error-message">{error}</div>
|
||||||
|
) : (
|
||||||
|
<div className="users-list">
|
||||||
|
{data?.users.map(user => (
|
||||||
|
<UserTrackingCard key={user.user_id} user={user} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Карточка пользователя с прогрессом
|
||||||
|
function UserTrackingCard({ user }) {
|
||||||
|
// Сортируем проекты по priority (1, 2, остальные)
|
||||||
|
const sortedProjects = [...user.projects].sort((a, b) => {
|
||||||
|
const pa = a.priority ?? 99
|
||||||
|
const pb = b.priority ?? 99
|
||||||
|
return pa - pb
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalPercent = user.total || 0
|
||||||
|
const getPercentColorClass = (percent) => {
|
||||||
|
return percent >= 100 ? 'percent-green' : 'percent-blue'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`user-tracking-card ${user.is_current_user ? 'current-user' : ''}`}>
|
||||||
|
<div className="user-header">
|
||||||
|
<span className="user-name">{user.user_name}</span>
|
||||||
|
<span className={`user-total ${getPercentColorClass(totalPercent)}`}>{totalPercent.toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="projects-list">
|
||||||
|
{sortedProjects.map((project, idx) => {
|
||||||
|
const projectPercent = project.calculated_score || 0
|
||||||
|
return (
|
||||||
|
<div key={idx} className="project-row">
|
||||||
|
<span className="project-name">{project.project_name}</span>
|
||||||
|
<span className={`project-score ${getPercentColorClass(projectPercent)}`}>{projectPercent.toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Tracking
|
||||||
148
play-life-web/src/components/TrackingAccess.jsx
Normal file
148
play-life-web/src/components/TrackingAccess.jsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { useAuth } from './auth/AuthContext'
|
||||||
|
import Toast from './Toast'
|
||||||
|
import './Tracking.css'
|
||||||
|
|
||||||
|
function TrackingAccess({ onNavigate }) {
|
||||||
|
const { authFetch } = useAuth()
|
||||||
|
const [generating, setGenerating] = useState(false)
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
const [trackers, setTrackers] = useState([])
|
||||||
|
const [tracked, setTracked] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [toastMessage, setToastMessage] = useState(null)
|
||||||
|
|
||||||
|
// Загрузка списков при монтировании
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAccessData()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const fetchAccessData = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await authFetch('/api/tracking/access')
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setTrackers(data.trackers || [])
|
||||||
|
setTracked(data.tracked || [])
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching access data:', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateInvite = async () => {
|
||||||
|
setGenerating(true)
|
||||||
|
try {
|
||||||
|
const res = await authFetch('/api/tracking/invite', { method: 'POST' })
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
await navigator.clipboard.writeText(data.invite_url)
|
||||||
|
setCopied(true)
|
||||||
|
setToastMessage({ text: 'Ссылка скопирована! Действует 1 час', type: 'success' })
|
||||||
|
setTimeout(() => setCopied(false), 3000)
|
||||||
|
} else {
|
||||||
|
setToastMessage({ text: 'Ошибка создания ссылки', type: 'error' })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setToastMessage({ text: 'Ошибка создания ссылки', type: 'error' })
|
||||||
|
} finally {
|
||||||
|
setGenerating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveTracker = async (relationId) => {
|
||||||
|
if (!window.confirm('Запретить этому пользователю видеть вашу статистику?')) return
|
||||||
|
try {
|
||||||
|
const res = await authFetch(`/api/tracking/trackers/${relationId}`, { method: 'DELETE' })
|
||||||
|
if (res.ok) {
|
||||||
|
setTrackers(prev => prev.filter(t => t.relation_id !== relationId))
|
||||||
|
setToastMessage({ text: 'Доступ отозван', type: 'success' })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setToastMessage({ text: 'Ошибка', type: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveTracked = async (relationId) => {
|
||||||
|
if (!window.confirm('Прекратить отслеживать этого пользователя?')) return
|
||||||
|
try {
|
||||||
|
const res = await authFetch(`/api/tracking/tracked/${relationId}`, { method: 'DELETE' })
|
||||||
|
if (res.ok) {
|
||||||
|
setTracked(prev => prev.filter(t => t.relation_id !== relationId))
|
||||||
|
setToastMessage({ text: 'Отслеживание прекращено', type: 'success' })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setToastMessage({ text: 'Ошибка', type: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tracking-access-screen max-w-2xl mx-auto">
|
||||||
|
{/* Заголовок с крестиком */}
|
||||||
|
<div className="tracking-header">
|
||||||
|
<h2 className="text-2xl font-semibold text-gray-800">Управление доступами</h2>
|
||||||
|
</div>
|
||||||
|
<button className="close-x-button" onClick={() => window.history.back()}>✕</button>
|
||||||
|
|
||||||
|
{/* Секция создания ссылки */}
|
||||||
|
<div className="access-section">
|
||||||
|
<h3>Поделиться статистикой</h3>
|
||||||
|
<p className="section-hint">Создайте одноразовую ссылку (действует 1 час)</p>
|
||||||
|
<button
|
||||||
|
className={`create-invite-btn ${copied ? 'copied' : ''}`}
|
||||||
|
onClick={handleCreateInvite}
|
||||||
|
disabled={generating}
|
||||||
|
>
|
||||||
|
{generating ? 'Создание...' : copied ? '✓ Ссылка скопирована' : 'Создать и скопировать ссылку'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Список: кто меня отслеживает */}
|
||||||
|
<div className="access-section">
|
||||||
|
<h3>Меня отслеживают ({trackers.length})</h3>
|
||||||
|
{trackers.length === 0 ? (
|
||||||
|
<p className="empty-list">Пока никто не отслеживает вашу статистику</p>
|
||||||
|
) : (
|
||||||
|
trackers.map(t => (
|
||||||
|
<div key={t.relation_id} className="access-item">
|
||||||
|
<span className="access-item-name">{t.name}</span>
|
||||||
|
<button className="remove-btn" onClick={() => handleRemoveTracker(t.relation_id)}>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Список: кого я отслеживаю */}
|
||||||
|
<div className="access-section">
|
||||||
|
<h3>Я отслеживаю ({tracked.length})</h3>
|
||||||
|
{tracked.length === 0 ? (
|
||||||
|
<p className="empty-list">Вы пока никого не отслеживаете</p>
|
||||||
|
) : (
|
||||||
|
tracked.map(t => (
|
||||||
|
<div key={t.relation_id} className="access-item">
|
||||||
|
<span className="access-item-name">{t.name}</span>
|
||||||
|
<button className="remove-btn" onClick={() => handleRemoveTracked(t.relation_id)}>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{toastMessage && (
|
||||||
|
<Toast message={toastMessage.text} type={toastMessage.type} onClose={() => setToastMessage(null)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TrackingAccess
|
||||||
115
play-life-web/src/components/TrackingInviteAccept.jsx
Normal file
115
play-life-web/src/components/TrackingInviteAccept.jsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { useAuth } from './auth/AuthContext'
|
||||||
|
import './Tracking.css'
|
||||||
|
|
||||||
|
function TrackingInviteAccept({ inviteToken, onNavigate }) {
|
||||||
|
const { authFetch } = useAuth()
|
||||||
|
const [inviteInfo, setInviteInfo] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [accepting, setAccepting] = useState(false)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
|
// Загрузить информацию о приглашении
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchInviteInfo = async () => {
|
||||||
|
try {
|
||||||
|
const res = await authFetch(`/api/tracking/invite/${inviteToken}`)
|
||||||
|
if (res.ok) {
|
||||||
|
setInviteInfo(await res.json())
|
||||||
|
} else {
|
||||||
|
const err = await res.json()
|
||||||
|
setError(err.error || 'Ссылка недействительна или устарела')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('Ошибка загрузки')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inviteToken) {
|
||||||
|
fetchInviteInfo()
|
||||||
|
}
|
||||||
|
}, [inviteToken, authFetch])
|
||||||
|
|
||||||
|
const handleAccept = async () => {
|
||||||
|
setAccepting(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await authFetch(`/api/tracking/invite/${inviteToken}/accept`, { method: 'POST' })
|
||||||
|
if (res.ok) {
|
||||||
|
// Успех - переходим на экран отслеживания
|
||||||
|
onNavigate('tracking')
|
||||||
|
} else {
|
||||||
|
const err = await res.json()
|
||||||
|
setError(err.error || 'Ошибка при принятии приглашения')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('Ошибка при принятии приглашения')
|
||||||
|
} finally {
|
||||||
|
setAccepting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
onNavigate('tracking')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="tracking-invite-screen">
|
||||||
|
<div className="loading-container">
|
||||||
|
<div className="spinner"></div>
|
||||||
|
<p>Загрузка...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !inviteInfo) {
|
||||||
|
return (
|
||||||
|
<div className="tracking-invite-screen">
|
||||||
|
<div className="invite-card error-card">
|
||||||
|
<div className="invite-icon">❌</div>
|
||||||
|
<h2>Ошибка</h2>
|
||||||
|
<p className="error-text">{error}</p>
|
||||||
|
<button className="secondary-btn" onClick={handleClose}>
|
||||||
|
Перейти к отслеживанию
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tracking-invite-screen">
|
||||||
|
<button className="close-x-button" onClick={handleClose}>✕</button>
|
||||||
|
|
||||||
|
<div className="invite-card">
|
||||||
|
<div className="invite-icon">👁</div>
|
||||||
|
<h2>Приглашение на отслеживание</h2>
|
||||||
|
|
||||||
|
<div className="invite-info">
|
||||||
|
<p>Вы сможете видеть статистику пользователя:</p>
|
||||||
|
<p className="user-name-large">{inviteInfo?.user_name}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="error-inline">{error}</p>}
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="accept-btn"
|
||||||
|
onClick={handleAccept}
|
||||||
|
disabled={accepting}
|
||||||
|
>
|
||||||
|
{accepting ? 'Принятие...' : 'Начать отслеживать'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button className="cancel-link" onClick={handleClose}>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TrackingInviteAccept
|
||||||
Reference in New Issue
Block a user