2026-02-08 17:01:36 +03:00
|
|
|
|
import React, { useState, useEffect, 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() // От старой к новой
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 17:16:57 +03:00
|
|
|
|
// Проверяет, закончилась ли уже данная ISO-неделя (текущая неделя не закончилась)
|
|
|
|
|
|
|
2026-02-08 17:01:36 +03:00
|
|
|
|
function Tracking({ onNavigate, activeTab }) {
|
|
|
|
|
|
const { authFetch } = useAuth()
|
|
|
|
|
|
const [weeks, setWeeks] = useState(() => getLastFiveWeeks())
|
|
|
|
|
|
const [selectedWeek, setSelectedWeek] = useState(() => {
|
|
|
|
|
|
const initialWeeks = getLastFiveWeeks()
|
|
|
|
|
|
return initialWeeks[initialWeeks.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 updatedWeeks = getLastFiveWeeks()
|
|
|
|
|
|
setWeeks(updatedWeeks)
|
|
|
|
|
|
// Устанавливаем текущую неделю (последняя в списке)
|
|
|
|
|
|
const currentWeek = updatedWeeks[updatedWeeks.length - 1]
|
|
|
|
|
|
setSelectedWeek(currentWeek)
|
|
|
|
|
|
}
|
|
|
|
|
|
prevActiveTabRef.current = activeTab
|
|
|
|
|
|
}, [activeTab])
|
|
|
|
|
|
|
|
|
|
|
|
// Скролл к чипсу текущей недели при открытии экрана
|
|
|
|
|
|
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])
|
|
|
|
|
|
|
|
|
|
|
|
// Функция для обновления данных
|
|
|
|
|
|
const refreshData = 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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Загрузка данных при смене недели
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
refreshData()
|
|
|
|
|
|
}, [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 => (
|
2026-03-12 17:16:57 +03:00
|
|
|
|
<UserTrackingCard key={user.user_id} user={user} selectedWeek={selectedWeek} />
|
2026-02-08 17:01:36 +03:00
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Карточка пользователя с прогрессом
|
2026-03-12 17:16:57 +03:00
|
|
|
|
function UserTrackingCard({ user, selectedWeek }) {
|
2026-02-08 17:01:36 +03:00
|
|
|
|
// Сортируем проекты по 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'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 17:16:57 +03:00
|
|
|
|
// Показываем (черновик) если выбранная неделя позже недели подтверждения
|
|
|
|
|
|
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)
|
|
|
|
|
|
})()
|
|
|
|
|
|
|
2026-02-08 17:01:36 +03:00
|
|
|
|
return (
|
|
|
|
|
|
<div className={`user-tracking-card ${user.is_current_user ? 'current-user' : ''}`}>
|
|
|
|
|
|
<div className="user-header">
|
2026-03-12 17:16:57 +03:00
|
|
|
|
<span className="user-name">
|
|
|
|
|
|
{user.user_name}
|
|
|
|
|
|
{showDraft && (
|
|
|
|
|
|
<span style={{ color: '#9ca3af', fontWeight: 'normal', fontSize: '0.85em', marginLeft: '4px' }}>
|
|
|
|
|
|
(черновик)
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</span>
|
2026-02-08 17:01:36 +03:00
|
|
|
|
<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
|