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:
@@ -14,6 +14,8 @@ services:
|
|||||||
POSTGRES_DB: ${DB_NAME:-playeng}
|
POSTGRES_DB: ${DB_NAME:-playeng}
|
||||||
ports:
|
ports:
|
||||||
- "${DB_PORT:-5432}:5432"
|
- "${DB_PORT:-5432}:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-playeng}"]
|
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-playeng}"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -59,6 +61,10 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
name: play-life_postgres_data
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
default:
|
default:
|
||||||
name: play-life-network
|
name: play-life-network
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
|||||||
|
DROP TABLE IF EXISTS tracking_invite_tokens;
|
||||||
|
DROP TABLE IF EXISTS user_tracking;
|
||||||
24
play-life-backend/migrations/000013_add_user_tracking.up.sql
Normal file
24
play-life-backend/migrations/000013_add_user_tracking.up.sql
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
-- Таблица отслеживания между пользователями
|
||||||
|
CREATE TABLE user_tracking (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
tracker_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
tracked_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT unique_tracking_pair UNIQUE (tracker_id, tracked_id),
|
||||||
|
CONSTRAINT no_self_tracking CHECK (tracker_id != tracked_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_user_tracking_tracker ON user_tracking(tracker_id);
|
||||||
|
CREATE INDEX idx_user_tracking_tracked ON user_tracking(tracked_id);
|
||||||
|
|
||||||
|
-- Таблица токенов приглашений (живут 1 час)
|
||||||
|
CREATE TABLE tracking_invite_tokens (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
token VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_tracking_invite_tokens_token ON tracking_invite_tokens(token);
|
||||||
|
CREATE INDEX idx_tracking_invite_tokens_user ON tracking_invite_tokens(user_id);
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "play-life-web",
|
"name": "play-life-web",
|
||||||
"version": "4.22.0",
|
"version": "4.23.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ import BoardForm from './components/BoardForm'
|
|||||||
import BoardJoinPreview from './components/BoardJoinPreview'
|
import BoardJoinPreview from './components/BoardJoinPreview'
|
||||||
import TodoistIntegration from './components/TodoistIntegration'
|
import TodoistIntegration from './components/TodoistIntegration'
|
||||||
import TelegramIntegration from './components/TelegramIntegration'
|
import TelegramIntegration from './components/TelegramIntegration'
|
||||||
|
import Tracking from './components/Tracking'
|
||||||
|
import TrackingAccess from './components/TrackingAccess'
|
||||||
|
import TrackingInviteAccept from './components/TrackingInviteAccept'
|
||||||
import { AuthProvider, useAuth } from './components/auth/AuthContext'
|
import { AuthProvider, useAuth } from './components/auth/AuthContext'
|
||||||
import AuthScreen from './components/auth/AuthScreen'
|
import AuthScreen from './components/auth/AuthScreen'
|
||||||
import PWAUpdatePrompt from './components/PWAUpdatePrompt'
|
import PWAUpdatePrompt from './components/PWAUpdatePrompt'
|
||||||
@@ -26,7 +29,7 @@ const FULL_STATISTICS_API_URL = '/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b'
|
|||||||
|
|
||||||
// Определяем основные табы (без крестика) и глубокие табы (с крестиком)
|
// Определяем основные табы (без крестика) и глубокие табы (с крестиком)
|
||||||
const mainTabs = ['current', 'tasks', 'wishlist', 'profile']
|
const mainTabs = ['current', 'tasks', 'wishlist', 'profile']
|
||||||
const deepTabs = ['add-words', 'test', 'task-form', 'wishlist-form', 'wishlist-detail', 'board-form', 'board-join', 'words', 'dictionaries', 'todoist-integration', 'telegram-integration', 'full', 'priorities']
|
const deepTabs = ['add-words', 'test', 'task-form', 'wishlist-form', 'wishlist-detail', 'board-form', 'board-join', 'words', 'dictionaries', 'todoist-integration', 'telegram-integration', 'full', 'priorities', 'tracking', 'tracking-access', 'tracking-invite']
|
||||||
|
|
||||||
function AppContent() {
|
function AppContent() {
|
||||||
const { authFetch, isAuthenticated, loading: authLoading } = useAuth()
|
const { authFetch, isAuthenticated, loading: authLoading } = useAuth()
|
||||||
@@ -64,6 +67,9 @@ function AppContent() {
|
|||||||
profile: false,
|
profile: false,
|
||||||
'todoist-integration': false,
|
'todoist-integration': false,
|
||||||
'telegram-integration': false,
|
'telegram-integration': false,
|
||||||
|
tracking: false,
|
||||||
|
'tracking-access': false,
|
||||||
|
'tracking-invite': false,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Отслеживаем, какие табы уже были загружены (для предотвращения повторных загрузок)
|
// Отслеживаем, какие табы уже были загружены (для предотвращения повторных загрузок)
|
||||||
@@ -85,6 +91,9 @@ function AppContent() {
|
|||||||
profile: false,
|
profile: false,
|
||||||
'todoist-integration': false,
|
'todoist-integration': false,
|
||||||
'telegram-integration': false,
|
'telegram-integration': false,
|
||||||
|
tracking: false,
|
||||||
|
'tracking-access': false,
|
||||||
|
'tracking-invite': false,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Параметры для навигации между вкладками
|
// Параметры для навигации между вкладками
|
||||||
@@ -155,10 +164,23 @@ function AppContent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Проверяем путь /tracking/invite/:token
|
||||||
|
if (path.startsWith('/tracking/invite/')) {
|
||||||
|
const token = path.replace('/tracking/invite/', '')
|
||||||
|
if (token) {
|
||||||
|
setActiveTab('tracking-invite')
|
||||||
|
setLoadedTabs(prev => ({ ...prev, 'tracking-invite': true }))
|
||||||
|
setTabParams({ inviteToken: token })
|
||||||
|
setIsInitialized(true)
|
||||||
|
window.history.replaceState({}, '', '/?tab=tracking-invite&inviteToken=' + token)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Проверяем URL только для глубоких табов
|
// Проверяем URL только для глубоких табов
|
||||||
const urlParams = new URLSearchParams(window.location.search)
|
const urlParams = new URLSearchParams(window.location.search)
|
||||||
const tabFromUrl = urlParams.get('tab')
|
const tabFromUrl = urlParams.get('tab')
|
||||||
const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'board-form', 'board-join', 'profile', 'todoist-integration', 'telegram-integration']
|
const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'board-form', 'board-join', 'profile', 'todoist-integration', 'telegram-integration', 'tracking', 'tracking-access', 'tracking-invite']
|
||||||
|
|
||||||
if (tabFromUrl && validTabs.includes(tabFromUrl) && deepTabs.includes(tabFromUrl)) {
|
if (tabFromUrl && validTabs.includes(tabFromUrl) && deepTabs.includes(tabFromUrl)) {
|
||||||
// Если в URL есть глубокий таб, восстанавливаем его
|
// Если в URL есть глубокий таб, восстанавливаем его
|
||||||
@@ -469,6 +491,9 @@ function AppContent() {
|
|||||||
profile: false,
|
profile: false,
|
||||||
'todoist-integration': false,
|
'todoist-integration': false,
|
||||||
'telegram-integration': false,
|
'telegram-integration': false,
|
||||||
|
tracking: false,
|
||||||
|
'tracking-access': false,
|
||||||
|
'tracking-invite': false,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Используем ref для отслеживания кеша (чтобы не зависеть от состояния в useCallback)
|
// Используем ref для отслеживания кеша (чтобы не зависеть от состояния в useCallback)
|
||||||
@@ -618,7 +643,7 @@ function AppContent() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'profile', 'todoist-integration', 'telegram-integration']
|
const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'profile', 'todoist-integration', 'telegram-integration', 'tracking', 'tracking-access', 'tracking-invite']
|
||||||
|
|
||||||
// Проверяем state текущей записи истории (куда мы вернулись)
|
// Проверяем state текущей записи истории (куда мы вернулись)
|
||||||
if (event.state && event.state.tab) {
|
if (event.state && event.state.tab) {
|
||||||
@@ -915,7 +940,7 @@ function AppContent() {
|
|||||||
}, [activeTab])
|
}, [activeTab])
|
||||||
|
|
||||||
// Определяем, нужно ли скрывать нижнюю панель (для fullscreen экранов)
|
// Определяем, нужно ли скрывать нижнюю панель (для fullscreen экранов)
|
||||||
const isFullscreenTab = activeTab === 'test' || activeTab === 'add-words' || activeTab === 'task-form' || activeTab === 'wishlist-form' || activeTab === 'wishlist-detail' || activeTab === 'todoist-integration' || activeTab === 'telegram-integration' || activeTab === 'full' || activeTab === 'priorities' || activeTab === 'words' || activeTab === 'dictionaries'
|
const isFullscreenTab = activeTab === 'test' || activeTab === 'add-words' || activeTab === 'task-form' || activeTab === 'wishlist-form' || activeTab === 'wishlist-detail' || activeTab === 'todoist-integration' || activeTab === 'telegram-integration' || activeTab === 'full' || activeTab === 'priorities' || activeTab === 'words' || activeTab === 'dictionaries' || activeTab === 'tracking' || activeTab === 'tracking-access' || activeTab === 'tracking-invite'
|
||||||
|
|
||||||
// Функция для получения классов скролл-контейнера для каждого таба
|
// Функция для получения классов скролл-контейнера для каждого таба
|
||||||
// Каждый таб имеет свой изолированный скролл-контейнер для автоматического сохранения позиции скролла
|
// Каждый таб имеет свой изолированный скролл-контейнер для автоматического сохранения позиции скролла
|
||||||
@@ -1173,6 +1198,33 @@ function AppContent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{loadedTabs.tracking && (
|
||||||
|
<div className={getTabContainerClasses('tracking')}>
|
||||||
|
<div className={getInnerContainerClasses('tracking')}>
|
||||||
|
<Tracking onNavigate={handleNavigate} activeTab={activeTab} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadedTabs['tracking-access'] && (
|
||||||
|
<div className={getTabContainerClasses('tracking-access')}>
|
||||||
|
<div className={getInnerContainerClasses('tracking-access')}>
|
||||||
|
<TrackingAccess onNavigate={handleNavigate} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadedTabs['tracking-invite'] && (
|
||||||
|
<div className={getTabContainerClasses('tracking-invite')}>
|
||||||
|
<div className={getInnerContainerClasses('tracking-invite')}>
|
||||||
|
<TrackingInviteAccept
|
||||||
|
inviteToken={tabParams.inviteToken}
|
||||||
|
onNavigate={handleNavigate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Кнопка добавления задачи (только для таба задач) */}
|
{/* Кнопка добавления задачи (только для таба задач) */}
|
||||||
|
|||||||
@@ -35,22 +35,47 @@ function Profile({ onNavigate }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Admin Button */}
|
{/* Admin & Tracking Buttons */}
|
||||||
{user?.is_admin && (
|
<div className="mb-6">
|
||||||
<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
|
<button
|
||||||
onClick={() => {
|
onClick={() => onNavigate?.('tracking')}
|
||||||
const adminUrl = window.location.origin + '/admin';
|
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"
|
||||||
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">
|
<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>
|
</span>
|
||||||
<svg
|
<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"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -65,7 +90,7 @@ function Profile({ onNavigate }) {
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{/* Features Section */}
|
{/* Features Section */}
|
||||||
<div className="mb-6">
|
<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