4.23.0: Добавлено отслеживание и улучшен UI
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m31s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m31s
This commit is contained in:
@@ -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">
|
||||
|
||||
435
play-life-web/src/components/Tracking.css
Normal file
435
play-life-web/src/components/Tracking.css
Normal file
@@ -0,0 +1,435 @@
|
||||
/* ===== Tracking Screen ===== */
|
||||
.tracking-screen {
|
||||
padding: 0 1rem 1rem 1rem;
|
||||
}
|
||||
|
||||
/* Header with title and close button */
|
||||
.tracking-header {
|
||||
position: relative;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-top: 1.25rem;
|
||||
}
|
||||
|
||||
.tracking-header h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.close-x-button {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
color: #7f8c8d;
|
||||
cursor: pointer;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
z-index: 1600;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.close-x-button:hover {
|
||||
background-color: #ffffff;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
/* Week controls: scrollable chips + access button */
|
||||
.week-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.week-chips-scroll {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 0.625rem;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.week-chips-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.access-icon-btn {
|
||||
flex-shrink: 0;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #d1d5db;
|
||||
background: white;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.access-icon-btn:hover {
|
||||
border-color: #a5b4fc;
|
||||
color: #4f46e5;
|
||||
background: #f5f3ff;
|
||||
}
|
||||
|
||||
/* Week chips */
|
||||
.week-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.625rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.week-chip {
|
||||
height: 2.25rem;
|
||||
padding: 0 1rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
background: transparent;
|
||||
color: #374151;
|
||||
border: 1px solid #d1d5db;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.week-chip:hover {
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
|
||||
.week-chip.selected {
|
||||
background: white;
|
||||
color: #111827;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
||||
border-color: #e5e7eb;
|
||||
}
|
||||
|
||||
.week-chip.current:not(.selected) {
|
||||
border-color: #a5b4fc;
|
||||
box-shadow: 0 0 0 2px rgba(165, 180, 252, 0.3);
|
||||
}
|
||||
|
||||
/* User tracking cards */
|
||||
.users-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.user-tracking-card {
|
||||
background: white;
|
||||
border-radius: 1.5rem;
|
||||
padding: 1.125rem 1.5rem;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.user-tracking-card.current-user {
|
||||
border: 1px solid #a5b4fc;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.user-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.user-total {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.percent-green {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.percent-blue {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.projects-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.project-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.project-name {
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.project-score {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Access link section */
|
||||
.access-link-section {
|
||||
margin-top: 2rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.access-link-button {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.75rem;
|
||||
color: #374151;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.access-link-button:hover {
|
||||
border-color: #a5b4fc;
|
||||
color: #4f46e5;
|
||||
}
|
||||
|
||||
/* ===== Tracking Access Screen ===== */
|
||||
.tracking-access-screen {
|
||||
padding: 0 1rem 1rem 1rem;
|
||||
}
|
||||
|
||||
.screen-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.access-section {
|
||||
background: white;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.access-section h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.section-hint {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.create-invite-btn {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background: linear-gradient(to right, #4f46e5, #7c3aed);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.create-invite-btn:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.create-invite-btn:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.create-invite-btn.copied {
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
.empty-list {
|
||||
color: #9ca3af;
|
||||
font-size: 0.875rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.access-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.access-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.access-item-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
padding: 0.5rem;
|
||||
color: #9ca3af;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
border-radius: 0.375rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.remove-btn:hover {
|
||||
color: #ef4444;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
/* ===== Tracking Invite Screen ===== */
|
||||
.tracking-invite-screen {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.invite-card {
|
||||
background: white;
|
||||
border-radius: 1rem;
|
||||
padding: 2rem;
|
||||
max-width: 24rem;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.invite-card.error-card {
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
.invite-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.invite-card h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.invite-info {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.user-name-large {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #4f46e5;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.accept-btn {
|
||||
width: 100%;
|
||||
padding: 0.875rem;
|
||||
background: linear-gradient(to right, #4f46e5, #7c3aed);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.accept-btn:disabled {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.cancel-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.cancel-link:hover {
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #ef4444;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error-inline {
|
||||
color: #ef4444;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.secondary-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.loading-container {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border: 4px solid #e0e7ff;
|
||||
border-top-color: #4f46e5;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 1rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #ef4444;
|
||||
}
|
||||
184
play-life-web/src/components/Tracking.jsx
Normal file
184
play-life-web/src/components/Tracking.jsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react'
|
||||
import { useAuth } from './auth/AuthContext'
|
||||
import './Tracking.css'
|
||||
|
||||
// Функция для вычисления номера недели ISO
|
||||
function getISOWeek(date) {
|
||||
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()))
|
||||
const dayNum = d.getUTCDay() || 7
|
||||
d.setUTCDate(d.getUTCDate() + 4 - dayNum)
|
||||
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1))
|
||||
return Math.ceil((((d - yearStart) / 86400000) + 1) / 7)
|
||||
}
|
||||
|
||||
// Функция для вычисления 5 недель (текущая + 4 предыдущие)
|
||||
function getLastFiveWeeks() {
|
||||
const weeks = []
|
||||
const now = new Date()
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const date = new Date(now)
|
||||
date.setDate(date.getDate() - i * 7)
|
||||
const week = getISOWeek(date)
|
||||
const year = date.getFullYear()
|
||||
weeks.push({
|
||||
year,
|
||||
week,
|
||||
isCurrent: i === 0
|
||||
})
|
||||
}
|
||||
return weeks.reverse() // От старой к новой
|
||||
}
|
||||
|
||||
function Tracking({ onNavigate, activeTab }) {
|
||||
const { authFetch } = useAuth()
|
||||
const weeks = useMemo(() => getLastFiveWeeks(), [])
|
||||
const [selectedWeek, setSelectedWeek] = useState(weeks[weeks.length - 1]) // Текущая неделя
|
||||
const [data, setData] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const scrollContainerRef = useRef(null)
|
||||
const currentWeekChipRef = useRef(null)
|
||||
const prevActiveTabRef = useRef(null)
|
||||
|
||||
// Сброс выбранной недели на текущую при открытии экрана
|
||||
useEffect(() => {
|
||||
// Проверяем, что экран только что открылся (activeTab стал 'tracking')
|
||||
if (activeTab === 'tracking' && prevActiveTabRef.current !== 'tracking') {
|
||||
const currentWeek = weeks[weeks.length - 1] // Последняя неделя в списке - текущая
|
||||
setSelectedWeek(currentWeek)
|
||||
}
|
||||
prevActiveTabRef.current = activeTab
|
||||
}, [activeTab, weeks])
|
||||
|
||||
// Скролл к чипсу текущей недели при открытии экрана
|
||||
useEffect(() => {
|
||||
// Выполняем скролл только когда экран открыт и только что открылся
|
||||
if (activeTab === 'tracking' && currentWeekChipRef.current && scrollContainerRef.current) {
|
||||
const chip = currentWeekChipRef.current
|
||||
const container = scrollContainerRef.current
|
||||
|
||||
// Небольшая задержка для гарантии рендеринга
|
||||
setTimeout(() => {
|
||||
const chipLeft = chip.offsetLeft
|
||||
const chipWidth = chip.offsetWidth
|
||||
const containerWidth = container.offsetWidth
|
||||
const scrollLeft = chipLeft - (containerWidth / 2) + (chipWidth / 2)
|
||||
|
||||
container.scrollTo({
|
||||
left: scrollLeft,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}, 100)
|
||||
}
|
||||
}, [activeTab])
|
||||
|
||||
// Загрузка данных при смене недели
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await authFetch(`/api/tracking/stats?year=${selectedWeek.year}&week=${selectedWeek.week}`)
|
||||
if (res.ok) {
|
||||
setData(await res.json())
|
||||
} else {
|
||||
setError('Ошибка загрузки')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Ошибка загрузки')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchData()
|
||||
}, [selectedWeek, authFetch])
|
||||
|
||||
return (
|
||||
<div className="tracking-screen max-w-2xl mx-auto">
|
||||
{/* Заголовок с крестиком */}
|
||||
<div className="tracking-header">
|
||||
<h2 className="text-2xl font-semibold text-gray-800">Отслеживание</h2>
|
||||
</div>
|
||||
<button className="close-x-button" onClick={() => window.history.back()}>✕</button>
|
||||
|
||||
{/* Чипсы недель с кнопкой доступов */}
|
||||
<div className="week-controls">
|
||||
<div className="week-chips-scroll" ref={scrollContainerRef}>
|
||||
{weeks.map(w => (
|
||||
<button
|
||||
key={`${w.year}-${w.week}`}
|
||||
ref={w.isCurrent ? currentWeekChipRef : null}
|
||||
onClick={() => setSelectedWeek(w)}
|
||||
className={`week-chip
|
||||
${selectedWeek.year === w.year && selectedWeek.week === w.week ? 'selected' : ''}
|
||||
${w.isCurrent ? 'current' : ''}`}
|
||||
>
|
||||
Неделя {w.week}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="access-icon-btn"
|
||||
onClick={() => onNavigate('tracking-access')}
|
||||
title="Управление доступами"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Контент */}
|
||||
{loading ? (
|
||||
<div className="loading-spinner">Загрузка...</div>
|
||||
) : error ? (
|
||||
<div className="error-message">{error}</div>
|
||||
) : (
|
||||
<div className="users-list">
|
||||
{data?.users.map(user => (
|
||||
<UserTrackingCard key={user.user_id} user={user} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Карточка пользователя с прогрессом
|
||||
function UserTrackingCard({ user }) {
|
||||
// Сортируем проекты по priority (1, 2, остальные)
|
||||
const sortedProjects = [...user.projects].sort((a, b) => {
|
||||
const pa = a.priority ?? 99
|
||||
const pb = b.priority ?? 99
|
||||
return pa - pb
|
||||
})
|
||||
|
||||
const totalPercent = user.total || 0
|
||||
const getPercentColorClass = (percent) => {
|
||||
return percent >= 100 ? 'percent-green' : 'percent-blue'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`user-tracking-card ${user.is_current_user ? 'current-user' : ''}`}>
|
||||
<div className="user-header">
|
||||
<span className="user-name">{user.user_name}</span>
|
||||
<span className={`user-total ${getPercentColorClass(totalPercent)}`}>{totalPercent.toFixed(0)}%</span>
|
||||
</div>
|
||||
<div className="projects-list">
|
||||
{sortedProjects.map((project, idx) => {
|
||||
const projectPercent = project.calculated_score || 0
|
||||
return (
|
||||
<div key={idx} className="project-row">
|
||||
<span className="project-name">{project.project_name}</span>
|
||||
<span className={`project-score ${getPercentColorClass(projectPercent)}`}>{projectPercent.toFixed(0)}%</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Tracking
|
||||
148
play-life-web/src/components/TrackingAccess.jsx
Normal file
148
play-life-web/src/components/TrackingAccess.jsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useAuth } from './auth/AuthContext'
|
||||
import Toast from './Toast'
|
||||
import './Tracking.css'
|
||||
|
||||
function TrackingAccess({ onNavigate }) {
|
||||
const { authFetch } = useAuth()
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [trackers, setTrackers] = useState([])
|
||||
const [tracked, setTracked] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [toastMessage, setToastMessage] = useState(null)
|
||||
|
||||
// Загрузка списков при монтировании
|
||||
useEffect(() => {
|
||||
fetchAccessData()
|
||||
}, [])
|
||||
|
||||
const fetchAccessData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await authFetch('/api/tracking/access')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setTrackers(data.trackers || [])
|
||||
setTracked(data.tracked || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching access data:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateInvite = async () => {
|
||||
setGenerating(true)
|
||||
try {
|
||||
const res = await authFetch('/api/tracking/invite', { method: 'POST' })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
await navigator.clipboard.writeText(data.invite_url)
|
||||
setCopied(true)
|
||||
setToastMessage({ text: 'Ссылка скопирована! Действует 1 час', type: 'success' })
|
||||
setTimeout(() => setCopied(false), 3000)
|
||||
} else {
|
||||
setToastMessage({ text: 'Ошибка создания ссылки', type: 'error' })
|
||||
}
|
||||
} catch (err) {
|
||||
setToastMessage({ text: 'Ошибка создания ссылки', type: 'error' })
|
||||
} finally {
|
||||
setGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveTracker = async (relationId) => {
|
||||
if (!window.confirm('Запретить этому пользователю видеть вашу статистику?')) return
|
||||
try {
|
||||
const res = await authFetch(`/api/tracking/trackers/${relationId}`, { method: 'DELETE' })
|
||||
if (res.ok) {
|
||||
setTrackers(prev => prev.filter(t => t.relation_id !== relationId))
|
||||
setToastMessage({ text: 'Доступ отозван', type: 'success' })
|
||||
}
|
||||
} catch (err) {
|
||||
setToastMessage({ text: 'Ошибка', type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveTracked = async (relationId) => {
|
||||
if (!window.confirm('Прекратить отслеживать этого пользователя?')) return
|
||||
try {
|
||||
const res = await authFetch(`/api/tracking/tracked/${relationId}`, { method: 'DELETE' })
|
||||
if (res.ok) {
|
||||
setTracked(prev => prev.filter(t => t.relation_id !== relationId))
|
||||
setToastMessage({ text: 'Отслеживание прекращено', type: 'success' })
|
||||
}
|
||||
} catch (err) {
|
||||
setToastMessage({ text: 'Ошибка', type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tracking-access-screen max-w-2xl mx-auto">
|
||||
{/* Заголовок с крестиком */}
|
||||
<div className="tracking-header">
|
||||
<h2 className="text-2xl font-semibold text-gray-800">Управление доступами</h2>
|
||||
</div>
|
||||
<button className="close-x-button" onClick={() => window.history.back()}>✕</button>
|
||||
|
||||
{/* Секция создания ссылки */}
|
||||
<div className="access-section">
|
||||
<h3>Поделиться статистикой</h3>
|
||||
<p className="section-hint">Создайте одноразовую ссылку (действует 1 час)</p>
|
||||
<button
|
||||
className={`create-invite-btn ${copied ? 'copied' : ''}`}
|
||||
onClick={handleCreateInvite}
|
||||
disabled={generating}
|
||||
>
|
||||
{generating ? 'Создание...' : copied ? '✓ Ссылка скопирована' : 'Создать и скопировать ссылку'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Список: кто меня отслеживает */}
|
||||
<div className="access-section">
|
||||
<h3>Меня отслеживают ({trackers.length})</h3>
|
||||
{trackers.length === 0 ? (
|
||||
<p className="empty-list">Пока никто не отслеживает вашу статистику</p>
|
||||
) : (
|
||||
trackers.map(t => (
|
||||
<div key={t.relation_id} className="access-item">
|
||||
<span className="access-item-name">{t.name}</span>
|
||||
<button className="remove-btn" onClick={() => handleRemoveTracker(t.relation_id)}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Список: кого я отслеживаю */}
|
||||
<div className="access-section">
|
||||
<h3>Я отслеживаю ({tracked.length})</h3>
|
||||
{tracked.length === 0 ? (
|
||||
<p className="empty-list">Вы пока никого не отслеживаете</p>
|
||||
) : (
|
||||
tracked.map(t => (
|
||||
<div key={t.relation_id} className="access-item">
|
||||
<span className="access-item-name">{t.name}</span>
|
||||
<button className="remove-btn" onClick={() => handleRemoveTracked(t.relation_id)}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{toastMessage && (
|
||||
<Toast message={toastMessage.text} type={toastMessage.type} onClose={() => setToastMessage(null)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TrackingAccess
|
||||
115
play-life-web/src/components/TrackingInviteAccept.jsx
Normal file
115
play-life-web/src/components/TrackingInviteAccept.jsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useAuth } from './auth/AuthContext'
|
||||
import './Tracking.css'
|
||||
|
||||
function TrackingInviteAccept({ inviteToken, onNavigate }) {
|
||||
const { authFetch } = useAuth()
|
||||
const [inviteInfo, setInviteInfo] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [accepting, setAccepting] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
// Загрузить информацию о приглашении
|
||||
useEffect(() => {
|
||||
const fetchInviteInfo = async () => {
|
||||
try {
|
||||
const res = await authFetch(`/api/tracking/invite/${inviteToken}`)
|
||||
if (res.ok) {
|
||||
setInviteInfo(await res.json())
|
||||
} else {
|
||||
const err = await res.json()
|
||||
setError(err.error || 'Ссылка недействительна или устарела')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Ошибка загрузки')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (inviteToken) {
|
||||
fetchInviteInfo()
|
||||
}
|
||||
}, [inviteToken, authFetch])
|
||||
|
||||
const handleAccept = async () => {
|
||||
setAccepting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await authFetch(`/api/tracking/invite/${inviteToken}/accept`, { method: 'POST' })
|
||||
if (res.ok) {
|
||||
// Успех - переходим на экран отслеживания
|
||||
onNavigate('tracking')
|
||||
} else {
|
||||
const err = await res.json()
|
||||
setError(err.error || 'Ошибка при принятии приглашения')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Ошибка при принятии приглашения')
|
||||
} finally {
|
||||
setAccepting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
onNavigate('tracking')
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="tracking-invite-screen">
|
||||
<div className="loading-container">
|
||||
<div className="spinner"></div>
|
||||
<p>Загрузка...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error && !inviteInfo) {
|
||||
return (
|
||||
<div className="tracking-invite-screen">
|
||||
<div className="invite-card error-card">
|
||||
<div className="invite-icon">❌</div>
|
||||
<h2>Ошибка</h2>
|
||||
<p className="error-text">{error}</p>
|
||||
<button className="secondary-btn" onClick={handleClose}>
|
||||
Перейти к отслеживанию
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tracking-invite-screen">
|
||||
<button className="close-x-button" onClick={handleClose}>✕</button>
|
||||
|
||||
<div className="invite-card">
|
||||
<div className="invite-icon">👁</div>
|
||||
<h2>Приглашение на отслеживание</h2>
|
||||
|
||||
<div className="invite-info">
|
||||
<p>Вы сможете видеть статистику пользователя:</p>
|
||||
<p className="user-name-large">{inviteInfo?.user_name}</p>
|
||||
</div>
|
||||
|
||||
{error && <p className="error-inline">{error}</p>}
|
||||
|
||||
<button
|
||||
className="accept-btn"
|
||||
onClick={handleAccept}
|
||||
disabled={accepting}
|
||||
>
|
||||
{accepting ? 'Принятие...' : 'Начать отслеживать'}
|
||||
</button>
|
||||
|
||||
<button className="cancel-link" onClick={handleClose}>
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TrackingInviteAccept
|
||||
Reference in New Issue
Block a user