4.23.0: Добавлено отслеживание и улучшен UI
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m31s

This commit is contained in:
poignatov
2026-02-05 18:36:14 +03:00
parent d6d40f4f86
commit 6e9e2db23e
12 changed files with 1817 additions and 104 deletions

View File

@@ -35,22 +35,47 @@ function Profile({ onNavigate }) {
</div>
</div>
{/* Admin Button */}
{user?.is_admin && (
<div className="mb-6">
{/* Admin & Tracking Buttons */}
<div className="mb-6">
<div className="space-y-3">
{user?.is_admin && (
<button
onClick={() => {
const adminUrl = window.location.origin + '/admin';
window.open(adminUrl, '_blank', 'noopener,noreferrer');
}}
className="w-full p-4 bg-white rounded-xl shadow-sm hover:shadow-md transition-all text-left border border-gray-100 hover:border-purple-200 group"
>
<div className="flex items-center justify-between">
<span className="text-gray-800 font-medium group-hover:text-purple-600 transition-colors">
Администрирование
</span>
<svg
className="w-5 h-5 text-gray-400 group-hover:text-purple-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>
)}
<button
onClick={() => {
const adminUrl = window.location.origin + '/admin';
window.open(adminUrl, '_blank', 'noopener,noreferrer');
}}
className="w-full p-4 bg-white rounded-xl shadow-sm hover:shadow-md transition-all text-left border border-gray-100 hover:border-purple-200 group"
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-purple-600 transition-colors">
Администрирование
<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-purple-500 transition-colors"
className="w-5 h-5 text-gray-400 group-hover:text-indigo-500 transition-colors"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -65,7 +90,7 @@ function Profile({ onNavigate }) {
</div>
</button>
</div>
)}
</div>
{/* Features Section */}
<div className="mb-6">

View 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;
}

View 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

View 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

View 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