diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..213d2ca --- /dev/null +++ b/.claude/settings.json @@ -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 '[]')" + ] + } +} diff --git a/VERSION b/VERSION index d7d9d3f..6839049 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -6.13.1 +6.14.0 diff --git a/play-life-backend/main.go b/play-life-backend/main.go index 6eb9395..f3488a8 100644 --- a/play-life-backend/main.go +++ b/play-life-backend/main.go @@ -169,6 +169,7 @@ type WeeklyStatsResponse struct { Projects []WeeklyProjectStats `json:"projects"` Wishes []WishlistItem `json:"wishes,omitempty"` PendingScoresByProject map[int]float64 `json:"pending_scores_by_project,omitempty"` + PrioritiesConfirmed bool `json:"priorities_confirmed"` } type MessagePostRequest struct { @@ -284,11 +285,13 @@ type TelegramUpdate struct { // 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"` + 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"` + PrioritiesConfirmedYear int `json:"priorities_confirmed_year"` + PrioritiesConfirmedWeek int `json:"priorities_confirmed_week"` } type TrackingProjectStats struct { @@ -2997,6 +3000,13 @@ func (a *App) getWeeklyStatsHandler(w http.ResponseWriter, r *http.Request) { if pendingByProject == nil { 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{ Total: total, GroupProgress1: groupsProgress.Group1, @@ -3005,6 +3015,7 @@ func (a *App) getWeeklyStatsHandler(w http.ResponseWriter, r *http.Request) { Projects: projects, Wishes: wishes, PendingScoresByProject: pendingByProject, + PrioritiesConfirmed: confirmed, } w.Header().Set("Content-Type", "application/json") @@ -3220,6 +3231,8 @@ func (a *App) startWeeklyGoalsScheduler() { log.Printf("Project score sample materialized view refreshed successfully") } + + // Затем настраиваем цели на новую неделю if err := a.setupWeeklyGoals(); err != nil { 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) + confirmed, err := a.isPrioritiesConfirmedForUser(userID) + if err != nil { + log.Printf("Error checking priorities confirmation for user %d: %v", userID, err) + confirmed = false + } + response := WeeklyStatsResponse{ - Total: total, - GroupProgress1: groupsProgress.Group1, - GroupProgress2: groupsProgress.Group2, - GroupProgress0: groupsProgress.Group0, - Projects: projects, - Wishes: wishes, + Total: total, + GroupProgress1: groupsProgress.Group1, + GroupProgress2: groupsProgress.Group2, + GroupProgress0: groupsProgress.Group0, + Projects: projects, + Wishes: wishes, + PrioritiesConfirmed: confirmed, } return &response, nil @@ -3955,9 +3975,9 @@ func (a *App) formatDailyReport(data *WeeklyStatsResponse) string { } // Форматирование текста целей - // Проверяем, что minGoal валиден (не NaN, как в JS коде: !isNaN(minGoal)) + // Цели не отображаются пока пользователь не подтвердил приоритеты на этой неделе goalText := "" - if !math.IsNaN(minGoal) { + if data.PrioritiesConfirmed && !math.IsNaN(minGoal) { if hasMaxGoal && !math.IsNaN(maxGoal) { goalText = fmt.Sprintf(" (Цель: %.1f–%.1f)", minGoal, maxGoal) } else { @@ -4517,6 +4537,7 @@ func main() { // Note: /weekly_goals/setup, /daily-report/trigger moved to adminAPIRoutes protected.HandleFunc("/projects", app.getProjectsHandler).Methods("GET", "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/move", app.moveProjectHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/project/delete", app.deleteProjectHandler).Methods("POST", "OPTIONS") @@ -5561,6 +5582,115 @@ func (a *App) setupWeeklyGoals() error { 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 получает цели для конкретного пользователя func (a *App) getWeeklyGoalsForUser(userID int) ([]WeeklyGoalSetup, error) { 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 { ID int `json:"id"` Color string `json:"color"` @@ -17466,12 +17707,23 @@ func (a *App) getTrackingStatsHandler(w http.ResponseWriter, r *http.Request) { 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{ - UserID: uid, - UserName: userName, - IsCurrentUser: uid == userID, - Total: stats.Total, - Projects: projects, + UserID: uid, + UserName: userName, + IsCurrentUser: uid == userID, + Total: stats.Total, + Projects: projects, + PrioritiesConfirmedYear: confirmedYear, + PrioritiesConfirmedWeek: confirmedWeek, }) } diff --git a/play-life-backend/migrations/000030_add_priorities_confirmed.down.sql b/play-life-backend/migrations/000030_add_priorities_confirmed.down.sql new file mode 100644 index 0000000..c707ef1 --- /dev/null +++ b/play-life-backend/migrations/000030_add_priorities_confirmed.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE users + DROP COLUMN IF EXISTS priorities_confirmed_year, + DROP COLUMN IF EXISTS priorities_confirmed_week; diff --git a/play-life-backend/migrations/000030_add_priorities_confirmed.up.sql b/play-life-backend/migrations/000030_add_priorities_confirmed.up.sql new file mode 100644 index 0000000..3936934 --- /dev/null +++ b/play-life-backend/migrations/000030_add_priorities_confirmed.up.sql @@ -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; diff --git a/play-life-web/nginx.conf b/play-life-web/nginx.conf index 6ce1a5d..d3ef7f0 100644 --- a/play-life-web/nginx.conf +++ b/play-life-web/nginx.conf @@ -50,7 +50,7 @@ server { } # 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_http_version 1.1; proxy_set_header Upgrade $http_upgrade; diff --git a/play-life-web/package.json b/play-life-web/package.json index 359ce45..2e31fa0 100644 --- a/play-life-web/package.json +++ b/play-life-web/package.json @@ -1,6 +1,6 @@ { "name": "play-life-web", - "version": "6.13.1", + "version": "6.14.0", "type": "module", "scripts": { "dev": "vite", diff --git a/play-life-web/src/App.jsx b/play-life-web/src/App.jsx index a5878b0..845d3a2 100644 --- a/play-life-web/src/App.jsx +++ b/play-life-web/src/App.jsx @@ -134,6 +134,10 @@ function AppContent() { // Ref для функции открытия модала добавления записи в CurrentWeek const currentWeekAddModalRef = useRef(null) + // Подтверждение приоритетов на текущей неделе (null = неизвестно, true/false = известно) + const [prioritiesConfirmed, setPrioritiesConfirmed] = useState(null) + const prioritiesOverlayPushedRef = useRef(false) + // Кеширование данных const [currentWeekData, setCurrentWeekData] = useState(null) const [fullStatisticsData, setFullStatisticsData] = useState(null) @@ -173,7 +177,22 @@ function AppContent() { // Восстанавливаем последний выбранный таб после перезагрузки 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(() => { // Обновляем ref только после того, как authLoading стал false @@ -199,6 +218,7 @@ function AppContent() { setFullStatisticsData(null) setTasksData(null) setTodayEntriesData(null) + setPrioritiesConfirmed(null) // Сбрасываем инициализацию табов, чтобы данные загрузились заново Object.keys(tabsInitializedRef.current).forEach(key => { tabsInitializedRef.current[key] = false @@ -506,6 +526,9 @@ function AppContent() { const wishes = jsonData?.wishes || [] 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({ projects: Array.isArray(projects) ? projects : [], total: total, @@ -515,6 +538,10 @@ function AppContent() { wishes: wishes, pending_scores_by_project: pendingScoresByProject }) + + if (prioritiesConfirmedValue !== null) { + setPrioritiesConfirmed(prioritiesConfirmedValue) + } } catch (err) { setCurrentWeekError(err.message) 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'] - + // Проверяем state текущей записи истории (куда мы вернулись) if (event.state && event.state.tab) { const { tab, params = {} } = event.state @@ -1123,6 +1150,8 @@ function AppContent() { let paddingClasses = '' if (tabName === 'current' || tabName === 'tasks' || tabName === 'wishlist' || tabName === 'profile') { paddingClasses = 'pb-20' + } else if (tabName === 'priorities') { + paddingClasses = 'pb-20' } else if (tabName === 'words' || tabName === 'dictionaries' || tabName === 'shopping') { paddingClasses = 'pb-16' } @@ -1171,7 +1200,7 @@ function AppContent() { {loadedTabs.priorities && (
- { + await fetchCurrentWeekData(false) + setPrioritiesConfirmed(true) + markTabAsLoaded('current') + setActiveTab('current') + }} />
@@ -1742,6 +1777,31 @@ function AppContent() { )} + {/* Оверлей подтверждения приоритетов — показывается поверх экрана прогресса недели */} + {activeTab === 'current' && prioritiesConfirmed === false && ( +
+
+ { + await fetchCurrentWeekData(false) + setPrioritiesConfirmed(true) + }} + onClose={() => { + // history.back() переходит к { tab: 'tasks' }, popstate обработает переключение + window.history.back() + }} + /> +
+
+ )} + ) } diff --git a/play-life-web/src/components/ProjectPriorityManager.jsx b/play-life-web/src/components/ProjectPriorityManager.jsx index f604091..7d4afaa 100644 --- a/play-life-web/src/components/ProjectPriorityManager.jsx +++ b/play-life-web/src/components/ProjectPriorityManager.jsx @@ -27,10 +27,10 @@ import './Integrations.css' // API endpoints (используем относительные пути, проксирование настроено в nginx/vite) const PROJECTS_API_URL = '/projects' -const PRIORITY_UPDATE_API_URL = '/project/priority' const PROJECT_COLOR_API_URL = '/project/color' const PROJECT_MOVE_API_URL = '/project/move' const PROJECT_CREATE_API_URL = '/project/create' +const PRIORITIES_CONFIRM_API_URL = '/priorities/confirm' // Компонент экрана добавления проекта 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 [projectsLoading, setProjectsLoading] = useState(false) const [projectsError, setProjectsError] = useState(null) const [hasDataCache, setHasDataCache] = useState(false) // Отслеживаем наличие кеша const [toastMessage, setToastMessage] = useState(null) - + const [isSaving, setIsSaving] = useState(false) + // Уведомляем родительский компонент об изменении состояния загрузки useEffect(() => { if (onLoadingChange) { @@ -421,9 +422,8 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad, const scrollContainerRef = useRef(null) const hasFetchedRef = useRef(false) - const skipNextEffectRef = useRef(false) - const lastRefreshTriggerRef = useRef(0) // Отслеживаем последний обработанный refreshTrigger - const isLoadingRef = useRef(false) // Отслеживаем, идет ли сейчас загрузка + const lastRefreshTriggerRef = useRef(0) + const isLoadingRef = useRef(false) const sensors = useSensors( useSensor(PointerSensor, { @@ -608,60 +608,30 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad, return map }, [lowPriority, maxPriority, mediumPriority]) - const prevAssignmentsRef = useRef(new Map()) - const initializedAssignmentsRef = useRef(false) + const handleSave = useCallback(async () => { + const assignments = buildAssignments() + const changes = [] + assignments.forEach(({ id, priority }) => { + if (id) changes.push({ id, priority }) + }) - const sendPriorityChanges = useCallback(async (changes) => { - if (!changes.length) return + setIsSaving(true) try { - await authFetch(PRIORITY_UPDATE_API_URL, { + const response = await authFetch(PRIORITIES_CONFIRM_API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(changes), }) - } catch (e) { - console.error('Ошибка отправки изменений приоритета', e) - } - }, []) - - 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 (!response.ok) { + throw new Error('Ошибка сохранения') } - }) - - if (changes.length) { - sendPriorityChanges(changes) + if (onConfirmed) onConfirmed() + } catch (e) { + setToastMessage({ text: e.message || 'Ошибка сохранения', type: 'error' }) + } finally { + setIsSaving(false) } - - prevAssignmentsRef.current = current - }, [buildAssignments, sendPriorityChanges]) + }, [authFetch, buildAssignments, onConfirmed]) const findProjectContainer = (projectName) => { if (maxPriority.find(p => p.name === projectName)) return 'max' @@ -919,9 +889,9 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad, return (
- {onNavigate && ( + {(onNavigate || onClose) && ( +
+ + {toastMessage && ( getLastFiveWeeks()) @@ -147,7 +149,7 @@ function Tracking({ onNavigate, activeTab }) { ) : (
{data?.users.map(user => ( - + ))}
)} @@ -156,7 +158,7 @@ function Tracking({ onNavigate, activeTab }) { } // Карточка пользователя с прогрессом -function UserTrackingCard({ user }) { +function UserTrackingCard({ user, selectedWeek }) { // Сортируем проекты по priority (1, 2, остальные) const sortedProjects = [...user.projects].sort((a, b) => { const pa = a.priority ?? 99 @@ -169,10 +171,27 @@ function UserTrackingCard({ user }) { 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 (
- {user.user_name} + + {user.user_name} + {showDraft && ( + + (черновик) + + )} + {totalPercent.toFixed(0)}%