6.14.0: Еженедельное подтверждение приоритетов
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m19s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m19s
This commit is contained in:
8
.claude/settings.json
Normal file
8
.claude/settings.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:8080/priorities/confirm)",
|
||||||
|
"Bash(curl -s -o /dev/null -w \"%{http_code}\" -X POST http://localhost:8080/priorities/confirm -H \"Content-Type: application/json\" -d '[]')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -169,6 +169,7 @@ type WeeklyStatsResponse struct {
|
|||||||
Projects []WeeklyProjectStats `json:"projects"`
|
Projects []WeeklyProjectStats `json:"projects"`
|
||||||
Wishes []WishlistItem `json:"wishes,omitempty"`
|
Wishes []WishlistItem `json:"wishes,omitempty"`
|
||||||
PendingScoresByProject map[int]float64 `json:"pending_scores_by_project,omitempty"`
|
PendingScoresByProject map[int]float64 `json:"pending_scores_by_project,omitempty"`
|
||||||
|
PrioritiesConfirmed bool `json:"priorities_confirmed"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MessagePostRequest struct {
|
type MessagePostRequest struct {
|
||||||
@@ -284,11 +285,13 @@ type TelegramUpdate struct {
|
|||||||
|
|
||||||
// Tracking structures
|
// Tracking structures
|
||||||
type TrackingUserStats struct {
|
type TrackingUserStats struct {
|
||||||
UserID int `json:"user_id"`
|
UserID int `json:"user_id"`
|
||||||
UserName string `json:"user_name"`
|
UserName string `json:"user_name"`
|
||||||
IsCurrentUser bool `json:"is_current_user"`
|
IsCurrentUser bool `json:"is_current_user"`
|
||||||
Total *float64 `json:"total,omitempty"`
|
Total *float64 `json:"total,omitempty"`
|
||||||
Projects []TrackingProjectStats `json:"projects"`
|
Projects []TrackingProjectStats `json:"projects"`
|
||||||
|
PrioritiesConfirmedYear int `json:"priorities_confirmed_year"`
|
||||||
|
PrioritiesConfirmedWeek int `json:"priorities_confirmed_week"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TrackingProjectStats struct {
|
type TrackingProjectStats struct {
|
||||||
@@ -2997,6 +3000,13 @@ func (a *App) getWeeklyStatsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
if pendingByProject == nil {
|
if pendingByProject == nil {
|
||||||
pendingByProject = make(map[int]float64)
|
pendingByProject = make(map[int]float64)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
confirmed, err := a.isPrioritiesConfirmedForUser(userID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error checking priorities confirmation for user %d: %v", userID, err)
|
||||||
|
confirmed = false
|
||||||
|
}
|
||||||
|
|
||||||
response := WeeklyStatsResponse{
|
response := WeeklyStatsResponse{
|
||||||
Total: total,
|
Total: total,
|
||||||
GroupProgress1: groupsProgress.Group1,
|
GroupProgress1: groupsProgress.Group1,
|
||||||
@@ -3005,6 +3015,7 @@ func (a *App) getWeeklyStatsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
Projects: projects,
|
Projects: projects,
|
||||||
Wishes: wishes,
|
Wishes: wishes,
|
||||||
PendingScoresByProject: pendingByProject,
|
PendingScoresByProject: pendingByProject,
|
||||||
|
PrioritiesConfirmed: confirmed,
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
@@ -3220,6 +3231,8 @@ func (a *App) startWeeklyGoalsScheduler() {
|
|||||||
log.Printf("Project score sample materialized view refreshed successfully")
|
log.Printf("Project score sample materialized view refreshed successfully")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Затем настраиваем цели на новую неделю
|
// Затем настраиваем цели на новую неделю
|
||||||
if err := a.setupWeeklyGoals(); err != nil {
|
if err := a.setupWeeklyGoals(); err != nil {
|
||||||
log.Printf("Error in scheduled weekly goals setup: %v", err)
|
log.Printf("Error in scheduled weekly goals setup: %v", err)
|
||||||
@@ -3908,13 +3921,20 @@ func (a *App) getWeeklyStatsDataForUser(userID int) (*WeeklyStatsResponse, error
|
|||||||
// Фильтруем желания для экрана прогресса недели
|
// Фильтруем желания для экрана прогресса недели
|
||||||
wishes := a.filterWishesForWeekProgress(allWishes, projectMinGoalScores)
|
wishes := a.filterWishesForWeekProgress(allWishes, projectMinGoalScores)
|
||||||
|
|
||||||
|
confirmed, err := a.isPrioritiesConfirmedForUser(userID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error checking priorities confirmation for user %d: %v", userID, err)
|
||||||
|
confirmed = false
|
||||||
|
}
|
||||||
|
|
||||||
response := WeeklyStatsResponse{
|
response := WeeklyStatsResponse{
|
||||||
Total: total,
|
Total: total,
|
||||||
GroupProgress1: groupsProgress.Group1,
|
GroupProgress1: groupsProgress.Group1,
|
||||||
GroupProgress2: groupsProgress.Group2,
|
GroupProgress2: groupsProgress.Group2,
|
||||||
GroupProgress0: groupsProgress.Group0,
|
GroupProgress0: groupsProgress.Group0,
|
||||||
Projects: projects,
|
Projects: projects,
|
||||||
Wishes: wishes,
|
Wishes: wishes,
|
||||||
|
PrioritiesConfirmed: confirmed,
|
||||||
}
|
}
|
||||||
|
|
||||||
return &response, nil
|
return &response, nil
|
||||||
@@ -3955,9 +3975,9 @@ func (a *App) formatDailyReport(data *WeeklyStatsResponse) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Форматирование текста целей
|
// Форматирование текста целей
|
||||||
// Проверяем, что minGoal валиден (не NaN, как в JS коде: !isNaN(minGoal))
|
// Цели не отображаются пока пользователь не подтвердил приоритеты на этой неделе
|
||||||
goalText := ""
|
goalText := ""
|
||||||
if !math.IsNaN(minGoal) {
|
if data.PrioritiesConfirmed && !math.IsNaN(minGoal) {
|
||||||
if hasMaxGoal && !math.IsNaN(maxGoal) {
|
if hasMaxGoal && !math.IsNaN(maxGoal) {
|
||||||
goalText = fmt.Sprintf(" (Цель: %.1f–%.1f)", minGoal, maxGoal)
|
goalText = fmt.Sprintf(" (Цель: %.1f–%.1f)", minGoal, maxGoal)
|
||||||
} else {
|
} else {
|
||||||
@@ -4517,6 +4537,7 @@ func main() {
|
|||||||
// Note: /weekly_goals/setup, /daily-report/trigger moved to adminAPIRoutes
|
// Note: /weekly_goals/setup, /daily-report/trigger moved to adminAPIRoutes
|
||||||
protected.HandleFunc("/projects", app.getProjectsHandler).Methods("GET", "OPTIONS")
|
protected.HandleFunc("/projects", app.getProjectsHandler).Methods("GET", "OPTIONS")
|
||||||
protected.HandleFunc("/project/priority", app.setProjectPriorityHandler).Methods("POST", "OPTIONS")
|
protected.HandleFunc("/project/priority", app.setProjectPriorityHandler).Methods("POST", "OPTIONS")
|
||||||
|
protected.HandleFunc("/priorities/confirm", app.confirmPrioritiesHandler).Methods("POST", "OPTIONS")
|
||||||
protected.HandleFunc("/project/color", app.setProjectColorHandler).Methods("POST", "OPTIONS")
|
protected.HandleFunc("/project/color", app.setProjectColorHandler).Methods("POST", "OPTIONS")
|
||||||
protected.HandleFunc("/project/move", app.moveProjectHandler).Methods("POST", "OPTIONS")
|
protected.HandleFunc("/project/move", app.moveProjectHandler).Methods("POST", "OPTIONS")
|
||||||
protected.HandleFunc("/project/delete", app.deleteProjectHandler).Methods("POST", "OPTIONS")
|
protected.HandleFunc("/project/delete", app.deleteProjectHandler).Methods("POST", "OPTIONS")
|
||||||
@@ -5561,6 +5582,115 @@ func (a *App) setupWeeklyGoals() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isPrioritiesConfirmedForUser проверяет, подтвердил ли пользователь приоритеты на текущей ISO-неделе
|
||||||
|
func (a *App) isPrioritiesConfirmedForUser(userID int) (bool, error) {
|
||||||
|
timezoneStr := getEnv("TIMEZONE", "UTC")
|
||||||
|
loc, err := time.LoadLocation(timezoneStr)
|
||||||
|
if err != nil {
|
||||||
|
loc = time.UTC
|
||||||
|
}
|
||||||
|
now := time.Now().In(loc)
|
||||||
|
currentYear, currentWeek := now.ISOWeek()
|
||||||
|
|
||||||
|
var confirmedYear, confirmedWeek int
|
||||||
|
err = a.DB.QueryRow(
|
||||||
|
`SELECT priorities_confirmed_year, priorities_confirmed_week FROM users WHERE id = $1`,
|
||||||
|
userID,
|
||||||
|
).Scan(&confirmedYear, &confirmedWeek)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return confirmedYear == currentYear && confirmedWeek == currentWeek, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// setPrioritiesConfirmed помечает приоритеты пользователя как подтверждённые на текущей ISO-неделе
|
||||||
|
func (a *App) setPrioritiesConfirmed(userID int) error {
|
||||||
|
timezoneStr := getEnv("TIMEZONE", "UTC")
|
||||||
|
loc, err := time.LoadLocation(timezoneStr)
|
||||||
|
if err != nil {
|
||||||
|
loc = time.UTC
|
||||||
|
}
|
||||||
|
now := time.Now().In(loc)
|
||||||
|
currentYear, currentWeek := now.ISOWeek()
|
||||||
|
|
||||||
|
_, err = a.DB.Exec(
|
||||||
|
`UPDATE users SET priorities_confirmed_year = $1, priorities_confirmed_week = $2 WHERE id = $3`,
|
||||||
|
currentYear, currentWeek, userID,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupWeeklyGoalsForUser устанавливает цели на текущую неделю для одного пользователя
|
||||||
|
func (a *App) setupWeeklyGoalsForUser(userID int) error {
|
||||||
|
setupQuery := `
|
||||||
|
WITH current_info AS (
|
||||||
|
SELECT
|
||||||
|
EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER AS c_year,
|
||||||
|
EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER AS c_week
|
||||||
|
),
|
||||||
|
goal_metrics AS (
|
||||||
|
SELECT
|
||||||
|
project_id,
|
||||||
|
AVG(normalized_total_score) AS avg_score
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
project_id,
|
||||||
|
normalized_total_score,
|
||||||
|
report_year,
|
||||||
|
report_week,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY project_id ORDER BY report_year DESC, report_week DESC) as rn
|
||||||
|
FROM weekly_report_mv
|
||||||
|
WHERE
|
||||||
|
(report_year < EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER)
|
||||||
|
OR (report_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
|
||||||
|
AND report_week < EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER)
|
||||||
|
) sub
|
||||||
|
WHERE rn <= 4
|
||||||
|
GROUP BY project_id
|
||||||
|
)
|
||||||
|
INSERT INTO weekly_goals (
|
||||||
|
project_id,
|
||||||
|
goal_year,
|
||||||
|
goal_week,
|
||||||
|
min_goal_score,
|
||||||
|
max_goal_score,
|
||||||
|
priority,
|
||||||
|
user_id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
p.id,
|
||||||
|
ci.c_year,
|
||||||
|
ci.c_week,
|
||||||
|
COALESCE(gm.avg_score, 0) AS min_goal_score,
|
||||||
|
CASE
|
||||||
|
WHEN gm.avg_score IS NULL THEN NULL
|
||||||
|
WHEN p.priority = 1 THEN gm.avg_score * 2.0
|
||||||
|
WHEN p.priority = 2 THEN gm.avg_score * 1.7
|
||||||
|
ELSE gm.avg_score * 1.4
|
||||||
|
END AS max_goal_score,
|
||||||
|
p.priority,
|
||||||
|
p.user_id
|
||||||
|
FROM projects p
|
||||||
|
CROSS JOIN current_info ci
|
||||||
|
LEFT JOIN goal_metrics gm ON p.id = gm.project_id
|
||||||
|
WHERE p.deleted = FALSE AND p.user_id = $1
|
||||||
|
ON CONFLICT (project_id, goal_year, goal_week) DO UPDATE
|
||||||
|
SET
|
||||||
|
min_goal_score = EXCLUDED.min_goal_score,
|
||||||
|
max_goal_score = EXCLUDED.max_goal_score,
|
||||||
|
priority = EXCLUDED.priority,
|
||||||
|
user_id = EXCLUDED.user_id
|
||||||
|
`
|
||||||
|
|
||||||
|
_, err := a.DB.Exec(setupQuery, userID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error setting up weekly goals for user %d: %w", userID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Weekly goals setup completed for user %d", userID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// getWeeklyGoalsForUser получает цели для конкретного пользователя
|
// getWeeklyGoalsForUser получает цели для конкретного пользователя
|
||||||
func (a *App) getWeeklyGoalsForUser(userID int) ([]WeeklyGoalSetup, error) {
|
func (a *App) getWeeklyGoalsForUser(userID int) ([]WeeklyGoalSetup, error) {
|
||||||
selectQuery := `
|
selectQuery := `
|
||||||
@@ -6195,6 +6325,117 @@ func (a *App) setProjectPriorityHandler(w http.ResponseWriter, r *http.Request)
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// confirmPrioritiesHandler сохраняет приоритеты и помечает их как подтверждённые на текущей неделе.
|
||||||
|
// Если приоритеты ещё не подтверждались на этой неделе — также пересчитывает цели.
|
||||||
|
func (a *App) confirmPrioritiesHandler(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
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
sendErrorWithCORS(w, "Error reading request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer r.Body.Close()
|
||||||
|
|
||||||
|
var projectsToUpdate []ProjectPriorityUpdate
|
||||||
|
|
||||||
|
var directArray []interface{}
|
||||||
|
arrayErr := json.Unmarshal(bodyBytes, &directArray)
|
||||||
|
if arrayErr == nil && len(directArray) > 0 {
|
||||||
|
for _, item := range directArray {
|
||||||
|
if itemMap, ok := item.(map[string]interface{}); ok {
|
||||||
|
var project ProjectPriorityUpdate
|
||||||
|
if idVal, ok := itemMap["id"].(float64); ok {
|
||||||
|
project.ID = int(idVal)
|
||||||
|
} else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if priorityVal, ok := itemMap["priority"]; ok && priorityVal != nil {
|
||||||
|
if numVal, ok := priorityVal.(float64); ok {
|
||||||
|
priorityInt := int(numVal)
|
||||||
|
project.Priority = &priorityInt
|
||||||
|
} else {
|
||||||
|
project.Priority = nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
project.Priority = nil
|
||||||
|
}
|
||||||
|
projectsToUpdate = append(projectsToUpdate, project)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(projectsToUpdate) == 0 {
|
||||||
|
sendErrorWithCORS(w, "No projects to update", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, подтверждены ли уже приоритеты на этой неделе
|
||||||
|
alreadyConfirmed, err := a.isPrioritiesConfirmedForUser(userID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error checking priorities confirmation for user %d: %v", userID, err)
|
||||||
|
alreadyConfirmed = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохраняем приоритеты в транзакции
|
||||||
|
tx, err := a.DB.Begin()
|
||||||
|
if err != nil {
|
||||||
|
sendErrorWithCORS(w, "Error beginning transaction", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
for _, project := range projectsToUpdate {
|
||||||
|
if project.Priority == nil {
|
||||||
|
_, err = tx.Exec(`UPDATE projects SET priority = NULL WHERE id = $1 AND user_id = $2`, project.ID, userID)
|
||||||
|
} else {
|
||||||
|
_, err = tx.Exec(`UPDATE projects SET priority = $1 WHERE id = $2 AND user_id = $3`, *project.Priority, project.ID, userID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
sendErrorWithCORS(w, fmt.Sprintf("Error updating project %d", project.ID), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
sendErrorWithCORS(w, "Error committing transaction", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Помечаем приоритеты как подтверждённые
|
||||||
|
if err := a.setPrioritiesConfirmed(userID); err != nil {
|
||||||
|
log.Printf("Error setting priorities confirmed for user %d: %v", userID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пересчитываем цели только если ещё не подтверждали на этой неделе
|
||||||
|
goalsUpdated := false
|
||||||
|
if !alreadyConfirmed {
|
||||||
|
if err := a.setupWeeklyGoalsForUser(userID); err != nil {
|
||||||
|
log.Printf("Error setting up weekly goals for user %d: %v", userID, err)
|
||||||
|
} else {
|
||||||
|
goalsUpdated = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"message": "Priorities confirmed",
|
||||||
|
"goals_updated": goalsUpdated,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
type ProjectColorRequest struct {
|
type ProjectColorRequest struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
Color string `json:"color"`
|
Color string `json:"color"`
|
||||||
@@ -17466,12 +17707,23 @@ func (a *App) getTrackingStatsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return pi < pj
|
return pi < pj
|
||||||
})
|
})
|
||||||
|
|
||||||
|
var confirmedYear, confirmedWeek int
|
||||||
|
err = a.DB.QueryRow(
|
||||||
|
`SELECT priorities_confirmed_year, priorities_confirmed_week FROM users WHERE id = $1`,
|
||||||
|
uid,
|
||||||
|
).Scan(&confirmedYear, &confirmedWeek)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error getting priorities confirmed for user %d: %v", uid, err)
|
||||||
|
}
|
||||||
|
|
||||||
users = append(users, TrackingUserStats{
|
users = append(users, TrackingUserStats{
|
||||||
UserID: uid,
|
UserID: uid,
|
||||||
UserName: userName,
|
UserName: userName,
|
||||||
IsCurrentUser: uid == userID,
|
IsCurrentUser: uid == userID,
|
||||||
Total: stats.Total,
|
Total: stats.Total,
|
||||||
Projects: projects,
|
Projects: projects,
|
||||||
|
PrioritiesConfirmedYear: confirmedYear,
|
||||||
|
PrioritiesConfirmedWeek: confirmedWeek,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE users
|
||||||
|
DROP COLUMN IF EXISTS priorities_confirmed_year,
|
||||||
|
DROP COLUMN IF EXISTS priorities_confirmed_week;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN priorities_confirmed_year INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN priorities_confirmed_week INTEGER NOT NULL DEFAULT 0;
|
||||||
@@ -50,7 +50,7 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Proxy other API endpoints to backend
|
# Proxy other API endpoints to backend
|
||||||
location ~ ^/(playlife-feed|d2dc349a-0d13-49b2-a8f0-1ab094bfba9b|projects|message/post|webhook/|weekly_goals/setup|project_score_sample_mv/refresh)$ {
|
location ~ ^/(playlife-feed|d2dc349a-0d13-49b2-a8f0-1ab094bfba9b|projects|message/post|webhook/|weekly_goals/setup|project_score_sample_mv/refresh|priorities/confirm)$ {
|
||||||
proxy_pass http://backend:8080;
|
proxy_pass http://backend:8080;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "play-life-web",
|
"name": "play-life-web",
|
||||||
"version": "6.13.1",
|
"version": "6.14.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -134,6 +134,10 @@ function AppContent() {
|
|||||||
// Ref для функции открытия модала добавления записи в CurrentWeek
|
// Ref для функции открытия модала добавления записи в CurrentWeek
|
||||||
const currentWeekAddModalRef = useRef(null)
|
const currentWeekAddModalRef = useRef(null)
|
||||||
|
|
||||||
|
// Подтверждение приоритетов на текущей неделе (null = неизвестно, true/false = известно)
|
||||||
|
const [prioritiesConfirmed, setPrioritiesConfirmed] = useState(null)
|
||||||
|
const prioritiesOverlayPushedRef = useRef(false)
|
||||||
|
|
||||||
// Кеширование данных
|
// Кеширование данных
|
||||||
const [currentWeekData, setCurrentWeekData] = useState(null)
|
const [currentWeekData, setCurrentWeekData] = useState(null)
|
||||||
const [fullStatisticsData, setFullStatisticsData] = useState(null)
|
const [fullStatisticsData, setFullStatisticsData] = useState(null)
|
||||||
@@ -173,7 +177,22 @@ function AppContent() {
|
|||||||
|
|
||||||
// Восстанавливаем последний выбранный таб после перезагрузки
|
// Восстанавливаем последний выбранный таб после перезагрузки
|
||||||
const [isInitialized, setIsInitialized] = useState(false)
|
const [isInitialized, setIsInitialized] = useState(false)
|
||||||
|
|
||||||
|
// Управление историей для оверлея приоритетов
|
||||||
|
useEffect(() => {
|
||||||
|
const overlayVisible = activeTab === 'current' && prioritiesConfirmed === false
|
||||||
|
if (overlayVisible && !prioritiesOverlayPushedRef.current) {
|
||||||
|
prioritiesOverlayPushedRef.current = true
|
||||||
|
// Заменяем текущую запись { tab: 'current' } на { tab: 'tasks' },
|
||||||
|
// затем добавляем запись оверлея. Так системная кнопка "Назад" вернёт на tasks.
|
||||||
|
window.history.replaceState({ tab: 'tasks' }, '', '/')
|
||||||
|
window.history.pushState({ tab: 'current', prioritiesOverlay: true }, '', '/')
|
||||||
|
}
|
||||||
|
if (!overlayVisible) {
|
||||||
|
prioritiesOverlayPushedRef.current = false
|
||||||
|
}
|
||||||
|
}, [activeTab, prioritiesConfirmed])
|
||||||
|
|
||||||
// Переключение на экран прогрессии после успешной авторизации
|
// Переключение на экран прогрессии после успешной авторизации
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Обновляем ref только после того, как authLoading стал false
|
// Обновляем ref только после того, как authLoading стал false
|
||||||
@@ -199,6 +218,7 @@ function AppContent() {
|
|||||||
setFullStatisticsData(null)
|
setFullStatisticsData(null)
|
||||||
setTasksData(null)
|
setTasksData(null)
|
||||||
setTodayEntriesData(null)
|
setTodayEntriesData(null)
|
||||||
|
setPrioritiesConfirmed(null)
|
||||||
// Сбрасываем инициализацию табов, чтобы данные загрузились заново
|
// Сбрасываем инициализацию табов, чтобы данные загрузились заново
|
||||||
Object.keys(tabsInitializedRef.current).forEach(key => {
|
Object.keys(tabsInitializedRef.current).forEach(key => {
|
||||||
tabsInitializedRef.current[key] = false
|
tabsInitializedRef.current[key] = false
|
||||||
@@ -506,6 +526,9 @@ function AppContent() {
|
|||||||
const wishes = jsonData?.wishes || []
|
const wishes = jsonData?.wishes || []
|
||||||
const pendingScoresByProject = jsonData?.pending_scores_by_project && typeof jsonData.pending_scores_by_project === 'object' ? jsonData.pending_scores_by_project : {}
|
const pendingScoresByProject = jsonData?.pending_scores_by_project && typeof jsonData.pending_scores_by_project === 'object' ? jsonData.pending_scores_by_project : {}
|
||||||
|
|
||||||
|
const rootData = (Array.isArray(jsonData) && jsonData.length > 0) ? jsonData[0] : jsonData
|
||||||
|
const prioritiesConfirmedValue = rootData?.priorities_confirmed ?? null
|
||||||
|
|
||||||
setCurrentWeekData({
|
setCurrentWeekData({
|
||||||
projects: Array.isArray(projects) ? projects : [],
|
projects: Array.isArray(projects) ? projects : [],
|
||||||
total: total,
|
total: total,
|
||||||
@@ -515,6 +538,10 @@ function AppContent() {
|
|||||||
wishes: wishes,
|
wishes: wishes,
|
||||||
pending_scores_by_project: pendingScoresByProject
|
pending_scores_by_project: pendingScoresByProject
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (prioritiesConfirmedValue !== null) {
|
||||||
|
setPrioritiesConfirmed(prioritiesConfirmedValue)
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setCurrentWeekError(err.message)
|
setCurrentWeekError(err.message)
|
||||||
console.error('Ошибка загрузки данных текущей недели:', err)
|
console.error('Ошибка загрузки данных текущей недели:', err)
|
||||||
@@ -796,7 +823,7 @@ function AppContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'purchase', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'profile', 'todoist-integration', 'telegram-integration', 'tracking', 'tracking-access', 'tracking-invite', 'shopping', 'shopping-item-form', 'shopping-board-form', 'shopping-board-join', 'shopping-item-history']
|
const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'purchase', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'profile', 'todoist-integration', 'telegram-integration', 'tracking', 'tracking-access', 'tracking-invite', 'shopping', 'shopping-item-form', 'shopping-board-form', 'shopping-board-join', 'shopping-item-history']
|
||||||
|
|
||||||
// Проверяем state текущей записи истории (куда мы вернулись)
|
// Проверяем state текущей записи истории (куда мы вернулись)
|
||||||
if (event.state && event.state.tab) {
|
if (event.state && event.state.tab) {
|
||||||
const { tab, params = {} } = event.state
|
const { tab, params = {} } = event.state
|
||||||
@@ -1123,6 +1150,8 @@ function AppContent() {
|
|||||||
let paddingClasses = ''
|
let paddingClasses = ''
|
||||||
if (tabName === 'current' || tabName === 'tasks' || tabName === 'wishlist' || tabName === 'profile') {
|
if (tabName === 'current' || tabName === 'tasks' || tabName === 'wishlist' || tabName === 'profile') {
|
||||||
paddingClasses = 'pb-20'
|
paddingClasses = 'pb-20'
|
||||||
|
} else if (tabName === 'priorities') {
|
||||||
|
paddingClasses = 'pb-20'
|
||||||
} else if (tabName === 'words' || tabName === 'dictionaries' || tabName === 'shopping') {
|
} else if (tabName === 'words' || tabName === 'dictionaries' || tabName === 'shopping') {
|
||||||
paddingClasses = 'pb-16'
|
paddingClasses = 'pb-16'
|
||||||
}
|
}
|
||||||
@@ -1171,7 +1200,7 @@ function AppContent() {
|
|||||||
{loadedTabs.priorities && (
|
{loadedTabs.priorities && (
|
||||||
<div className={getTabContainerClasses('priorities')}>
|
<div className={getTabContainerClasses('priorities')}>
|
||||||
<div className={getInnerContainerClasses('priorities')}>
|
<div className={getInnerContainerClasses('priorities')}>
|
||||||
<ProjectPriorityManager
|
<ProjectPriorityManager
|
||||||
allProjectsData={fullStatisticsData}
|
allProjectsData={fullStatisticsData}
|
||||||
currentWeekData={currentWeekData}
|
currentWeekData={currentWeekData}
|
||||||
shouldLoad={activeTab === 'priorities' && loadedTabs.priorities}
|
shouldLoad={activeTab === 'priorities' && loadedTabs.priorities}
|
||||||
@@ -1179,6 +1208,12 @@ function AppContent() {
|
|||||||
onErrorChange={setPrioritiesError}
|
onErrorChange={setPrioritiesError}
|
||||||
refreshTrigger={prioritiesRefreshTrigger}
|
refreshTrigger={prioritiesRefreshTrigger}
|
||||||
onNavigate={handleNavigate}
|
onNavigate={handleNavigate}
|
||||||
|
onConfirmed={async () => {
|
||||||
|
await fetchCurrentWeekData(false)
|
||||||
|
setPrioritiesConfirmed(true)
|
||||||
|
markTabAsLoaded('current')
|
||||||
|
setActiveTab('current')
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1742,6 +1777,31 @@ function AppContent() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Оверлей подтверждения приоритетов — показывается поверх экрана прогресса недели */}
|
||||||
|
{activeTab === 'current' && prioritiesConfirmed === false && (
|
||||||
|
<div className="fixed inset-0 bg-white z-50 overflow-y-auto">
|
||||||
|
<div className="max-w-2xl mx-auto px-4 h-full">
|
||||||
|
<ProjectPriorityManager
|
||||||
|
allProjectsData={fullStatisticsData}
|
||||||
|
currentWeekData={currentWeekData}
|
||||||
|
shouldLoad={true}
|
||||||
|
onLoadingChange={setPrioritiesLoading}
|
||||||
|
onErrorChange={setPrioritiesError}
|
||||||
|
refreshTrigger={Math.max(prioritiesRefreshTrigger, 1)}
|
||||||
|
onNavigate={handleNavigate}
|
||||||
|
onConfirmed={async () => {
|
||||||
|
await fetchCurrentWeekData(false)
|
||||||
|
setPrioritiesConfirmed(true)
|
||||||
|
}}
|
||||||
|
onClose={() => {
|
||||||
|
// history.back() переходит к { tab: 'tasks' }, popstate обработает переключение
|
||||||
|
window.history.back()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,10 +27,10 @@ import './Integrations.css'
|
|||||||
|
|
||||||
// API endpoints (используем относительные пути, проксирование настроено в nginx/vite)
|
// API endpoints (используем относительные пути, проксирование настроено в nginx/vite)
|
||||||
const PROJECTS_API_URL = '/projects'
|
const PROJECTS_API_URL = '/projects'
|
||||||
const PRIORITY_UPDATE_API_URL = '/project/priority'
|
|
||||||
const PROJECT_COLOR_API_URL = '/project/color'
|
const PROJECT_COLOR_API_URL = '/project/color'
|
||||||
const PROJECT_MOVE_API_URL = '/project/move'
|
const PROJECT_MOVE_API_URL = '/project/move'
|
||||||
const PROJECT_CREATE_API_URL = '/project/create'
|
const PROJECT_CREATE_API_URL = '/project/create'
|
||||||
|
const PRIORITIES_CONFIRM_API_URL = '/priorities/confirm'
|
||||||
|
|
||||||
// Компонент экрана добавления проекта
|
// Компонент экрана добавления проекта
|
||||||
function AddProjectScreen({ onClose, onSuccess, onError }) {
|
function AddProjectScreen({ onClose, onSuccess, onError }) {
|
||||||
@@ -387,13 +387,14 @@ function PrioritySlot({ title, projects, allProjects, onMenuClick, maxItems = nu
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad, onLoadingChange, onErrorChange, refreshTrigger, onNavigate }) {
|
function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad, onLoadingChange, onErrorChange, refreshTrigger, onNavigate, onConfirmed, onClose }) {
|
||||||
const { authFetch } = useAuth()
|
const { authFetch } = useAuth()
|
||||||
const [projectsLoading, setProjectsLoading] = useState(false)
|
const [projectsLoading, setProjectsLoading] = useState(false)
|
||||||
const [projectsError, setProjectsError] = useState(null)
|
const [projectsError, setProjectsError] = useState(null)
|
||||||
const [hasDataCache, setHasDataCache] = useState(false) // Отслеживаем наличие кеша
|
const [hasDataCache, setHasDataCache] = useState(false) // Отслеживаем наличие кеша
|
||||||
const [toastMessage, setToastMessage] = useState(null)
|
const [toastMessage, setToastMessage] = useState(null)
|
||||||
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
|
||||||
// Уведомляем родительский компонент об изменении состояния загрузки
|
// Уведомляем родительский компонент об изменении состояния загрузки
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (onLoadingChange) {
|
if (onLoadingChange) {
|
||||||
@@ -421,9 +422,8 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad,
|
|||||||
|
|
||||||
const scrollContainerRef = useRef(null)
|
const scrollContainerRef = useRef(null)
|
||||||
const hasFetchedRef = useRef(false)
|
const hasFetchedRef = useRef(false)
|
||||||
const skipNextEffectRef = useRef(false)
|
const lastRefreshTriggerRef = useRef(0)
|
||||||
const lastRefreshTriggerRef = useRef(0) // Отслеживаем последний обработанный refreshTrigger
|
const isLoadingRef = useRef(false)
|
||||||
const isLoadingRef = useRef(false) // Отслеживаем, идет ли сейчас загрузка
|
|
||||||
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, {
|
useSensor(PointerSensor, {
|
||||||
@@ -608,60 +608,30 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad,
|
|||||||
return map
|
return map
|
||||||
}, [lowPriority, maxPriority, mediumPriority])
|
}, [lowPriority, maxPriority, mediumPriority])
|
||||||
|
|
||||||
const prevAssignmentsRef = useRef(new Map())
|
const handleSave = useCallback(async () => {
|
||||||
const initializedAssignmentsRef = useRef(false)
|
const assignments = buildAssignments()
|
||||||
|
const changes = []
|
||||||
|
assignments.forEach(({ id, priority }) => {
|
||||||
|
if (id) changes.push({ id, priority })
|
||||||
|
})
|
||||||
|
|
||||||
const sendPriorityChanges = useCallback(async (changes) => {
|
setIsSaving(true)
|
||||||
if (!changes.length) return
|
|
||||||
try {
|
try {
|
||||||
await authFetch(PRIORITY_UPDATE_API_URL, {
|
const response = await authFetch(PRIORITIES_CONFIRM_API_URL, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(changes),
|
body: JSON.stringify(changes),
|
||||||
})
|
})
|
||||||
} catch (e) {
|
if (!response.ok) {
|
||||||
console.error('Ошибка отправки изменений приоритета', e)
|
throw new Error('Ошибка сохранения')
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const current = buildAssignments()
|
|
||||||
|
|
||||||
if (!initializedAssignmentsRef.current) {
|
|
||||||
prevAssignmentsRef.current = current
|
|
||||||
initializedAssignmentsRef.current = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (skipNextEffectRef.current) {
|
|
||||||
skipNextEffectRef.current = false
|
|
||||||
prevAssignmentsRef.current = current
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const prev = prevAssignmentsRef.current
|
|
||||||
const allKeys = new Set([...prev.keys(), ...current.keys()])
|
|
||||||
const changes = []
|
|
||||||
|
|
||||||
allKeys.forEach(key => {
|
|
||||||
const prevItem = prev.get(key)
|
|
||||||
const currItem = current.get(key)
|
|
||||||
const prevPriority = prevItem?.priority ?? null
|
|
||||||
const currPriority = currItem?.priority ?? null
|
|
||||||
const id = currItem?.id ?? prevItem?.id
|
|
||||||
|
|
||||||
if (!id) return
|
|
||||||
if (prevPriority !== currPriority) {
|
|
||||||
changes.push({ id, priority: currPriority })
|
|
||||||
}
|
}
|
||||||
})
|
if (onConfirmed) onConfirmed()
|
||||||
|
} catch (e) {
|
||||||
if (changes.length) {
|
setToastMessage({ text: e.message || 'Ошибка сохранения', type: 'error' })
|
||||||
sendPriorityChanges(changes)
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
}
|
}
|
||||||
|
}, [authFetch, buildAssignments, onConfirmed])
|
||||||
prevAssignmentsRef.current = current
|
|
||||||
}, [buildAssignments, sendPriorityChanges])
|
|
||||||
|
|
||||||
const findProjectContainer = (projectName) => {
|
const findProjectContainer = (projectName) => {
|
||||||
if (maxPriority.find(p => p.name === projectName)) return 'max'
|
if (maxPriority.find(p => p.name === projectName)) return 'max'
|
||||||
@@ -919,9 +889,9 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad,
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto flex flex-col h-full">
|
<div className="max-w-2xl mx-auto flex flex-col h-full">
|
||||||
{onNavigate && (
|
{(onNavigate || onClose) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => window.history.back()}
|
onClick={() => onClose ? onClose() : window.history.back()}
|
||||||
className="close-x-button"
|
className="close-x-button"
|
||||||
title="Закрыть"
|
title="Закрыть"
|
||||||
>
|
>
|
||||||
@@ -1090,6 +1060,21 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad,
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className={onClose
|
||||||
|
? 'sticky bottom-0 pt-2 pb-4 mt-2'
|
||||||
|
: 'fixed bottom-0 left-0 right-0 bg-gray-100 px-4 pt-2 pb-4'
|
||||||
|
}>
|
||||||
|
<div className="max-w-2xl mx-auto">
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="w-full py-3 bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-400 text-white font-semibold rounded-lg shadow transition-all"
|
||||||
|
>
|
||||||
|
{isSaving ? 'Сохранение...' : 'Сохранить'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{toastMessage && (
|
{toastMessage && (
|
||||||
<Toast
|
<Toast
|
||||||
message={toastMessage.text}
|
message={toastMessage.text}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ function getLastFiveWeeks() {
|
|||||||
return weeks.reverse() // От старой к новой
|
return weeks.reverse() // От старой к новой
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Проверяет, закончилась ли уже данная ISO-неделя (текущая неделя не закончилась)
|
||||||
|
|
||||||
function Tracking({ onNavigate, activeTab }) {
|
function Tracking({ onNavigate, activeTab }) {
|
||||||
const { authFetch } = useAuth()
|
const { authFetch } = useAuth()
|
||||||
const [weeks, setWeeks] = useState(() => getLastFiveWeeks())
|
const [weeks, setWeeks] = useState(() => getLastFiveWeeks())
|
||||||
@@ -147,7 +149,7 @@ function Tracking({ onNavigate, activeTab }) {
|
|||||||
) : (
|
) : (
|
||||||
<div className="users-list">
|
<div className="users-list">
|
||||||
{data?.users.map(user => (
|
{data?.users.map(user => (
|
||||||
<UserTrackingCard key={user.user_id} user={user} />
|
<UserTrackingCard key={user.user_id} user={user} selectedWeek={selectedWeek} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -156,7 +158,7 @@ function Tracking({ onNavigate, activeTab }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Карточка пользователя с прогрессом
|
// Карточка пользователя с прогрессом
|
||||||
function UserTrackingCard({ user }) {
|
function UserTrackingCard({ user, selectedWeek }) {
|
||||||
// Сортируем проекты по priority (1, 2, остальные)
|
// Сортируем проекты по priority (1, 2, остальные)
|
||||||
const sortedProjects = [...user.projects].sort((a, b) => {
|
const sortedProjects = [...user.projects].sort((a, b) => {
|
||||||
const pa = a.priority ?? 99
|
const pa = a.priority ?? 99
|
||||||
@@ -169,10 +171,27 @@ function UserTrackingCard({ user }) {
|
|||||||
return percent >= 100 ? 'percent-green' : 'percent-blue'
|
return percent >= 100 ? 'percent-green' : 'percent-blue'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Показываем (черновик) если выбранная неделя позже недели подтверждения
|
||||||
|
const showDraft = selectedWeek && (() => {
|
||||||
|
const cy = user.priorities_confirmed_year || 0
|
||||||
|
const cw = user.priorities_confirmed_week || 0
|
||||||
|
const sy = selectedWeek.year
|
||||||
|
const sw = selectedWeek.week
|
||||||
|
// Неделя не подтверждена вообще (0,0) или выбранная неделя позже подтверждённой
|
||||||
|
return sy > cy || (sy === cy && sw > cw)
|
||||||
|
})()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`user-tracking-card ${user.is_current_user ? 'current-user' : ''}`}>
|
<div className={`user-tracking-card ${user.is_current_user ? 'current-user' : ''}`}>
|
||||||
<div className="user-header">
|
<div className="user-header">
|
||||||
<span className="user-name">{user.user_name}</span>
|
<span className="user-name">
|
||||||
|
{user.user_name}
|
||||||
|
{showDraft && (
|
||||||
|
<span style={{ color: '#9ca3af', fontWeight: 'normal', fontSize: '0.85em', marginLeft: '4px' }}>
|
||||||
|
(черновик)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
<span className={`user-total ${getPercentColorClass(totalPercent)}`}>{totalPercent.toFixed(0)}%</span>
|
<span className={`user-total ${getPercentColorClass(totalPercent)}`}>{totalPercent.toFixed(0)}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="projects-list">
|
<div className="projects-list">
|
||||||
|
|||||||
Reference in New Issue
Block a user