2026-02-08 17:01:36 +03:00
|
|
|
|
import React, { useState, useEffect } from 'react'
|
|
|
|
|
|
import { useAuth } from './auth/AuthContext'
|
|
|
|
|
|
import LoadingError from './LoadingError'
|
|
|
|
|
|
import Toast from './Toast'
|
|
|
|
|
|
import './Integrations.css'
|
|
|
|
|
|
|
|
|
|
|
|
function FitbitIntegration({ onNavigate }) {
|
|
|
|
|
|
const { authFetch } = useAuth()
|
|
|
|
|
|
const [connected, setConnected] = useState(false)
|
|
|
|
|
|
const [loading, setLoading] = useState(true)
|
|
|
|
|
|
const [error, setError] = useState('')
|
|
|
|
|
|
const [message, setMessage] = useState('')
|
|
|
|
|
|
const [oauthError, setOauthError] = useState('')
|
|
|
|
|
|
const [toastMessage, setToastMessage] = useState(null)
|
|
|
|
|
|
const [isLoadingError, setIsLoadingError] = useState(false)
|
2026-02-09 17:06:08 +03:00
|
|
|
|
const [syncing, setSyncing] = useState(false)
|
|
|
|
|
|
|
2026-02-08 17:01:36 +03:00
|
|
|
|
const [stats, setStats] = useState({
|
2026-02-09 17:06:08 +03:00
|
|
|
|
steps: { value: 0, goal: 10000 },
|
|
|
|
|
|
floors: { value: 0, goal: 10 }
|
2026-02-08 17:01:36 +03:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-02-09 17:06:08 +03:00
|
|
|
|
const [bindings, setBindings] = useState({
|
|
|
|
|
|
steps_task_id: null,
|
|
|
|
|
|
floors_task_id: null,
|
|
|
|
|
|
steps_goal_task_id: null,
|
|
|
|
|
|
steps_goal_subtask_id: null,
|
|
|
|
|
|
floors_goal_task_id: null,
|
|
|
|
|
|
floors_goal_subtask_id: null
|
|
|
|
|
|
})
|
|
|
|
|
|
const [editedBindings, setEditedBindings] = useState(bindings)
|
2026-02-09 17:33:05 +03:00
|
|
|
|
const [savingStepsBindings, setSavingStepsBindings] = useState(false)
|
|
|
|
|
|
const [savingFloorsBindings, setSavingFloorsBindings] = useState(false)
|
2026-02-09 17:06:08 +03:00
|
|
|
|
|
|
|
|
|
|
const [tasks, setTasks] = useState([])
|
|
|
|
|
|
const [loadingTasks, setLoadingTasks] = useState(false)
|
|
|
|
|
|
const [stepsGoalSubtasks, setStepsGoalSubtasks] = useState([])
|
|
|
|
|
|
const [floorsGoalSubtasks, setFloorsGoalSubtasks] = useState([])
|
|
|
|
|
|
|
2026-02-08 17:01:36 +03:00
|
|
|
|
const oauthStatusRef = React.useRef(null)
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const params = new URLSearchParams(window.location.search)
|
|
|
|
|
|
const integration = params.get('integration')
|
|
|
|
|
|
const status = params.get('status')
|
|
|
|
|
|
if (integration === 'fitbit') {
|
|
|
|
|
|
oauthStatusRef.current = status
|
|
|
|
|
|
if (status === 'connected') {
|
|
|
|
|
|
setMessage('Fitbit успешно подключен!')
|
|
|
|
|
|
} else if (status === 'error') {
|
|
|
|
|
|
const errorMsg = params.get('message') || 'unknown_error'
|
|
|
|
|
|
const errorMessages = {
|
|
|
|
|
|
'config_error': 'Ошибка конфигурации сервера. Обратитесь к администратору.',
|
|
|
|
|
|
'invalid_state': 'Недействительный токен авторизации. Попробуйте ещё раз.',
|
|
|
|
|
|
'no_code': 'Не получен код авторизации от Fitbit. Попробуйте ещё раз.',
|
|
|
|
|
|
'token_exchange_failed': 'Не удалось обменять код на токен. Проверьте настройки Fitbit приложения.',
|
|
|
|
|
|
'user_info_failed': 'Не удалось получить информацию о пользователе Fitbit.',
|
|
|
|
|
|
'db_error': 'Ошибка сохранения данных. Попробуйте ещё раз.',
|
|
|
|
|
|
'unknown_error': 'Произошла неизвестная ошибка при подключении Fitbit.'
|
|
|
|
|
|
}
|
|
|
|
|
|
setOauthError(errorMessages[errorMsg] || `Ошибка: ${errorMsg}`)
|
|
|
|
|
|
}
|
|
|
|
|
|
window.history.replaceState({}, '', window.location.pathname)
|
|
|
|
|
|
}
|
|
|
|
|
|
checkStatus()
|
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (connected) {
|
|
|
|
|
|
loadStats()
|
2026-02-09 17:06:08 +03:00
|
|
|
|
loadTasks()
|
2026-02-08 17:01:36 +03:00
|
|
|
|
}
|
|
|
|
|
|
}, [connected])
|
|
|
|
|
|
|
2026-02-09 17:06:08 +03:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!editedBindings.steps_goal_task_id) {
|
|
|
|
|
|
setStepsGoalSubtasks([])
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
let cancelled = false
|
|
|
|
|
|
authFetch(`/api/tasks/${editedBindings.steps_goal_task_id}`)
|
|
|
|
|
|
.then((r) => r.ok ? r.json() : null)
|
|
|
|
|
|
.then((data) => {
|
|
|
|
|
|
if (!cancelled && data?.subtasks) {
|
|
|
|
|
|
setStepsGoalSubtasks(data.subtasks.map((s) => ({ id: s.task.id, name: s.task.name })))
|
|
|
|
|
|
} else if (!cancelled) {
|
|
|
|
|
|
setStepsGoalSubtasks([])
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(() => {
|
|
|
|
|
|
if (!cancelled) setStepsGoalSubtasks([])
|
|
|
|
|
|
})
|
|
|
|
|
|
return () => { cancelled = true }
|
|
|
|
|
|
}, [editedBindings.steps_goal_task_id, authFetch])
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!editedBindings.floors_goal_task_id) {
|
|
|
|
|
|
setFloorsGoalSubtasks([])
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
let cancelled = false
|
|
|
|
|
|
authFetch(`/api/tasks/${editedBindings.floors_goal_task_id}`)
|
|
|
|
|
|
.then((r) => r.ok ? r.json() : null)
|
|
|
|
|
|
.then((data) => {
|
|
|
|
|
|
if (!cancelled && data?.subtasks) {
|
|
|
|
|
|
setFloorsGoalSubtasks(data.subtasks.map((s) => ({ id: s.task.id, name: s.task.name })))
|
|
|
|
|
|
} else if (!cancelled) {
|
|
|
|
|
|
setFloorsGoalSubtasks([])
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(() => {
|
|
|
|
|
|
if (!cancelled) setFloorsGoalSubtasks([])
|
|
|
|
|
|
})
|
|
|
|
|
|
return () => { cancelled = true }
|
|
|
|
|
|
}, [editedBindings.floors_goal_task_id, authFetch])
|
|
|
|
|
|
|
2026-02-08 17:01:36 +03:00
|
|
|
|
const checkStatus = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
setLoading(true)
|
|
|
|
|
|
setError('')
|
|
|
|
|
|
const response = await authFetch('/api/integrations/fitbit/status')
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
throw new Error('Ошибка при проверке статуса')
|
|
|
|
|
|
}
|
|
|
|
|
|
const data = await response.json()
|
|
|
|
|
|
setConnected(data.connected || false)
|
2026-02-09 17:06:08 +03:00
|
|
|
|
if (data.connected && data.bindings) {
|
|
|
|
|
|
const b = data.bindings
|
|
|
|
|
|
const normalized = {
|
|
|
|
|
|
steps_task_id: b.steps_task_id ?? null,
|
|
|
|
|
|
floors_task_id: b.floors_task_id ?? null,
|
|
|
|
|
|
steps_goal_task_id: b.steps_goal_task_id ?? null,
|
|
|
|
|
|
steps_goal_subtask_id: b.steps_goal_subtask_id ?? null,
|
|
|
|
|
|
floors_goal_task_id: b.floors_goal_task_id ?? null,
|
|
|
|
|
|
floors_goal_subtask_id: b.floors_goal_subtask_id ?? null
|
|
|
|
|
|
}
|
|
|
|
|
|
setBindings(normalized)
|
|
|
|
|
|
setEditedBindings(normalized)
|
2026-02-08 17:01:36 +03:00
|
|
|
|
}
|
|
|
|
|
|
if (oauthStatusRef.current === 'connected' && !data.connected) {
|
2026-02-09 17:06:08 +03:00
|
|
|
|
setOauthError('Авторизация в Fitbit прошла, но подключение не сохранилось. Попробуйте ещё раз.')
|
2026-02-08 17:01:36 +03:00
|
|
|
|
setMessage('')
|
|
|
|
|
|
}
|
|
|
|
|
|
oauthStatusRef.current = null
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error checking status:', error)
|
|
|
|
|
|
setError(error.message || 'Не удалось проверить статус')
|
|
|
|
|
|
setIsLoadingError(true)
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const loadStats = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await authFetch('/api/integrations/fitbit/stats')
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
throw new Error('Ошибка при загрузке статистики')
|
|
|
|
|
|
}
|
|
|
|
|
|
const data = await response.json()
|
2026-02-09 17:06:08 +03:00
|
|
|
|
setStats({
|
2026-02-08 17:01:36 +03:00
|
|
|
|
steps: {
|
|
|
|
|
|
value: data.steps?.value ?? 0,
|
2026-02-09 17:06:08 +03:00
|
|
|
|
goal: data.steps?.goal ?? 10000
|
2026-02-08 17:01:36 +03:00
|
|
|
|
},
|
|
|
|
|
|
floors: {
|
|
|
|
|
|
value: data.floors?.value ?? 0,
|
2026-02-09 17:06:08 +03:00
|
|
|
|
goal: data.floors?.goal ?? 10
|
2026-02-08 17:01:36 +03:00
|
|
|
|
}
|
2026-02-09 17:06:08 +03:00
|
|
|
|
})
|
2026-02-08 17:01:36 +03:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error loading stats:', error)
|
2026-02-09 17:06:08 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const loadTasks = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
setLoadingTasks(true)
|
|
|
|
|
|
const response = await authFetch('/api/tasks')
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
throw new Error('Ошибка при загрузке задач')
|
|
|
|
|
|
}
|
|
|
|
|
|
const data = await response.json()
|
|
|
|
|
|
setTasks(data || [])
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error loading tasks:', error)
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoadingTasks(false)
|
2026-02-08 17:01:36 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleConnect = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
setLoading(true)
|
|
|
|
|
|
setError('')
|
|
|
|
|
|
const response = await authFetch('/api/integrations/fitbit/oauth/connect')
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
const errorData = await response.json().catch(() => ({}))
|
|
|
|
|
|
throw new Error(errorData.error || 'Ошибка при подключении Fitbit')
|
|
|
|
|
|
}
|
|
|
|
|
|
const data = await response.json()
|
|
|
|
|
|
if (data.auth_url) {
|
|
|
|
|
|
window.location.href = data.auth_url
|
|
|
|
|
|
} else {
|
|
|
|
|
|
throw new Error('URL для авторизации не получен')
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error connecting Fitbit:', error)
|
|
|
|
|
|
setToastMessage({ text: error.message || 'Не удалось подключить Fitbit', type: 'error' })
|
|
|
|
|
|
setLoading(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleDisconnect = async () => {
|
|
|
|
|
|
if (!window.confirm('Вы уверены, что хотите отключить Fitbit?')) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
setLoading(true)
|
|
|
|
|
|
setError('')
|
|
|
|
|
|
const response = await authFetch('/api/integrations/fitbit/disconnect', {
|
|
|
|
|
|
method: 'DELETE',
|
|
|
|
|
|
})
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
const errorData = await response.json().catch(() => ({}))
|
|
|
|
|
|
throw new Error(errorData.error || 'Ошибка при отключении')
|
|
|
|
|
|
}
|
|
|
|
|
|
setConnected(false)
|
|
|
|
|
|
setStats({
|
2026-02-09 17:06:08 +03:00
|
|
|
|
steps: { value: 0, goal: 10000 },
|
|
|
|
|
|
floors: { value: 0, goal: 10 }
|
2026-02-08 17:01:36 +03:00
|
|
|
|
})
|
|
|
|
|
|
setToastMessage({ text: 'Fitbit отключен', type: 'success' })
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error disconnecting:', error)
|
|
|
|
|
|
setToastMessage({ text: error.message || 'Не удалось отключить Fitbit', type: 'error' })
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleSync = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
setSyncing(true)
|
|
|
|
|
|
const response = await authFetch('/api/integrations/fitbit/sync', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
})
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
const errorData = await response.json().catch(() => ({}))
|
|
|
|
|
|
throw new Error(errorData.error || 'Ошибка при синхронизации')
|
|
|
|
|
|
}
|
|
|
|
|
|
setToastMessage({ text: 'Данные синхронизированы', type: 'success' })
|
|
|
|
|
|
await loadStats()
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error syncing:', error)
|
|
|
|
|
|
setToastMessage({ text: error.message || 'Не удалось синхронизировать данные', type: 'error' })
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSyncing(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-09 17:33:05 +03:00
|
|
|
|
const handleSaveStepsBindings = async () => {
|
2026-02-08 17:01:36 +03:00
|
|
|
|
try {
|
2026-02-09 17:33:05 +03:00
|
|
|
|
setSavingStepsBindings(true)
|
|
|
|
|
|
const response = await authFetch('/api/integrations/fitbit/bindings/steps', {
|
2026-02-08 17:01:36 +03:00
|
|
|
|
method: 'PUT',
|
2026-02-09 17:06:08 +03:00
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
2026-02-09 17:33:05 +03:00
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
|
steps_task_id: editedBindings.steps_task_id,
|
|
|
|
|
|
steps_goal_task_id: editedBindings.steps_goal_task_id,
|
|
|
|
|
|
steps_goal_subtask_id: editedBindings.steps_goal_subtask_id
|
|
|
|
|
|
})
|
2026-02-08 17:01:36 +03:00
|
|
|
|
})
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
const errorData = await response.json().catch(() => ({}))
|
2026-02-09 17:06:08 +03:00
|
|
|
|
throw new Error(errorData.error || 'Ошибка при сохранении привязок')
|
2026-02-08 17:01:36 +03:00
|
|
|
|
}
|
2026-02-09 17:33:05 +03:00
|
|
|
|
setBindings(prev => ({
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
steps_task_id: editedBindings.steps_task_id,
|
|
|
|
|
|
steps_goal_task_id: editedBindings.steps_goal_task_id,
|
|
|
|
|
|
steps_goal_subtask_id: editedBindings.steps_goal_subtask_id
|
|
|
|
|
|
}))
|
|
|
|
|
|
setToastMessage({ text: 'Привязки шагов сохранены', type: 'success' })
|
2026-02-08 17:01:36 +03:00
|
|
|
|
} catch (error) {
|
2026-02-09 17:33:05 +03:00
|
|
|
|
console.error('Error saving steps bindings:', error)
|
2026-02-09 17:06:08 +03:00
|
|
|
|
setToastMessage({ text: error.message || 'Не удалось сохранить привязки', type: 'error' })
|
|
|
|
|
|
} finally {
|
2026-02-09 17:33:05 +03:00
|
|
|
|
setSavingStepsBindings(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleSaveFloorsBindings = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
setSavingFloorsBindings(true)
|
|
|
|
|
|
const response = await authFetch('/api/integrations/fitbit/bindings/floors', {
|
|
|
|
|
|
method: 'PUT',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
|
floors_task_id: editedBindings.floors_task_id,
|
|
|
|
|
|
floors_goal_task_id: editedBindings.floors_goal_task_id,
|
|
|
|
|
|
floors_goal_subtask_id: editedBindings.floors_goal_subtask_id
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
const errorData = await response.json().catch(() => ({}))
|
|
|
|
|
|
throw new Error(errorData.error || 'Ошибка при сохранении привязок')
|
|
|
|
|
|
}
|
|
|
|
|
|
setBindings(prev => ({
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
floors_task_id: editedBindings.floors_task_id,
|
|
|
|
|
|
floors_goal_task_id: editedBindings.floors_goal_task_id,
|
|
|
|
|
|
floors_goal_subtask_id: editedBindings.floors_goal_subtask_id
|
|
|
|
|
|
}))
|
|
|
|
|
|
setToastMessage({ text: 'Привязки этажей сохранены', type: 'success' })
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error saving floors bindings:', error)
|
|
|
|
|
|
setToastMessage({ text: error.message || 'Не удалось сохранить привязки', type: 'error' })
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSavingFloorsBindings(false)
|
2026-02-08 17:01:36 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-09 17:06:08 +03:00
|
|
|
|
const getProgressTasks = () => {
|
|
|
|
|
|
return tasks.filter(t => t.has_progression || t.progression_base != null)
|
2026-02-08 17:01:36 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-09 17:06:08 +03:00
|
|
|
|
const getParentTasks = () => {
|
|
|
|
|
|
return tasks.filter(t => (t.subtasks_count ?? 0) > 0)
|
2026-02-08 17:01:36 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-09 17:06:08 +03:00
|
|
|
|
const getProgressPercent = (value, goal) => {
|
|
|
|
|
|
if (!goal || goal === 0) return 0
|
|
|
|
|
|
return Math.min(100, (value / goal) * 100)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const getProgressColor = (value, goal) => {
|
|
|
|
|
|
if (value >= goal) return 'text-green-600'
|
|
|
|
|
|
if (value >= goal * 0.5) return 'text-blue-600'
|
2026-02-08 17:01:36 +03:00
|
|
|
|
return 'text-gray-600'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-09 17:33:05 +03:00
|
|
|
|
const isStepsBindingsDirty =
|
|
|
|
|
|
(editedBindings.steps_task_id ?? null) !== (bindings.steps_task_id ?? null) ||
|
|
|
|
|
|
(editedBindings.steps_goal_task_id ?? null) !== (bindings.steps_goal_task_id ?? null) ||
|
|
|
|
|
|
(editedBindings.steps_goal_subtask_id ?? null) !== (bindings.steps_goal_subtask_id ?? null)
|
|
|
|
|
|
|
|
|
|
|
|
const isFloorsBindingsDirty =
|
|
|
|
|
|
(editedBindings.floors_task_id ?? null) !== (bindings.floors_task_id ?? null) ||
|
|
|
|
|
|
(editedBindings.floors_goal_task_id ?? null) !== (bindings.floors_goal_task_id ?? null) ||
|
|
|
|
|
|
(editedBindings.floors_goal_subtask_id ?? null) !== (bindings.floors_goal_subtask_id ?? null)
|
|
|
|
|
|
|
2026-02-09 17:43:21 +03:00
|
|
|
|
const handleClose = () => {
|
|
|
|
|
|
setEditedBindings(bindings)
|
|
|
|
|
|
onNavigate?.('profile')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 17:01:36 +03:00
|
|
|
|
if (isLoadingError && !loading) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="p-4 md:p-6">
|
2026-02-09 17:43:21 +03:00
|
|
|
|
<button className="close-x-button" onClick={handleClose} title="Закрыть">
|
2026-02-08 17:01:36 +03:00
|
|
|
|
✕
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<LoadingError onRetry={checkStatus} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="p-4 md:p-6">
|
2026-02-09 17:43:21 +03:00
|
|
|
|
<button className="close-x-button" onClick={handleClose} title="Закрыть">
|
2026-02-08 17:01:36 +03:00
|
|
|
|
✕
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
2026-02-09 17:06:08 +03:00
|
|
|
|
<h1 className="text-2xl font-bold mb-6">Fitbit</h1>
|
|
|
|
|
|
|
|
|
|
|
|
{message && (
|
|
|
|
|
|
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
|
|
|
|
|
|
<p className="text-green-800">{message}</p>
|
|
|
|
|
|
<button onClick={() => setMessage('')} className="text-green-600 text-sm underline mt-2">Скрыть</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-02-08 17:01:36 +03:00
|
|
|
|
|
|
|
|
|
|
{loading ? (
|
2026-02-09 17:06:08 +03:00
|
|
|
|
<div className="flex justify-center items-center h-32">
|
|
|
|
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div>
|
2026-02-08 17:01:36 +03:00
|
|
|
|
</div>
|
|
|
|
|
|
) : connected ? (
|
|
|
|
|
|
<div>
|
2026-02-09 17:33:05 +03:00
|
|
|
|
{/* Группа: Шаги */}
|
2026-02-08 17:01:36 +03:00
|
|
|
|
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
2026-02-09 17:33:05 +03:00
|
|
|
|
<h2 className="text-lg font-semibold mb-4">Шаги</h2>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div className="flex justify-between items-center mb-2">
|
|
|
|
|
|
<span className="text-gray-600 text-sm">Сегодня</span>
|
|
|
|
|
|
<span className={`font-bold ${getProgressColor(stats.steps.value, stats.steps.goal)}`}>
|
|
|
|
|
|
{stats.steps.value.toLocaleString()} / {stats.steps.goal.toLocaleString()}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="w-full bg-gray-200 rounded-full h-3">
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="bg-indigo-600 h-3 rounded-full transition-all"
|
|
|
|
|
|
style={{ width: `${getProgressPercent(stats.steps.value, stats.steps.goal)}%` }}
|
|
|
|
|
|
></div>
|
|
|
|
|
|
</div>
|
2026-02-08 17:01:36 +03:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-02-09 17:33:05 +03:00
|
|
|
|
<div>
|
|
|
|
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Прогресс шагов</label>
|
|
|
|
|
|
<p className="text-xs text-gray-500 mb-1">Задача</p>
|
|
|
|
|
|
<select
|
|
|
|
|
|
value={editedBindings.steps_task_id ?? ''}
|
|
|
|
|
|
onChange={(e) => setEditedBindings({
|
|
|
|
|
|
...editedBindings,
|
|
|
|
|
|
steps_task_id: e.target.value ? parseInt(e.target.value, 10) : null
|
|
|
|
|
|
})}
|
|
|
|
|
|
disabled={loadingTasks}
|
|
|
|
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg disabled:bg-gray-100"
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="">{loadingTasks ? 'Загрузка...' : 'Не выбрано'}</option>
|
|
|
|
|
|
{!loadingTasks && getProgressTasks().map(task => (
|
|
|
|
|
|
<option key={task.id} value={task.id}>{task.name}</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
2026-02-08 17:01:36 +03:00
|
|
|
|
</div>
|
2026-02-09 17:06:08 +03:00
|
|
|
|
|
2026-02-09 17:33:05 +03:00
|
|
|
|
<div>
|
|
|
|
|
|
<h3 className="text-sm font-medium text-gray-700 mb-2">Достижение цели</h3>
|
|
|
|
|
|
<div className="pl-0 space-y-2">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="block text-sm text-gray-600 mb-1">Задача</label>
|
|
|
|
|
|
<select
|
|
|
|
|
|
value={editedBindings.steps_goal_task_id ?? ''}
|
|
|
|
|
|
onChange={(e) => setEditedBindings({
|
|
|
|
|
|
...editedBindings,
|
|
|
|
|
|
steps_goal_task_id: e.target.value ? parseInt(e.target.value, 10) : null,
|
|
|
|
|
|
steps_goal_subtask_id: null
|
|
|
|
|
|
})}
|
|
|
|
|
|
disabled={loadingTasks}
|
|
|
|
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg disabled:bg-gray-100"
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="">{loadingTasks ? 'Загрузка...' : 'Не выбрано'}</option>
|
2026-02-09 17:40:29 +03:00
|
|
|
|
{!loadingTasks && tasks.map(task => (
|
2026-02-09 17:33:05 +03:00
|
|
|
|
<option key={task.id} value={task.id}>{task.name}</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
2026-02-08 17:01:36 +03:00
|
|
|
|
</div>
|
2026-02-09 17:40:29 +03:00
|
|
|
|
{editedBindings.steps_goal_task_id && stepsGoalSubtasks.length > 0 && (
|
2026-02-09 17:06:08 +03:00
|
|
|
|
<div>
|
|
|
|
|
|
<label className="block text-sm text-gray-600 mb-1">Подзадача</label>
|
|
|
|
|
|
<select
|
|
|
|
|
|
value={editedBindings.steps_goal_subtask_id ?? ''}
|
|
|
|
|
|
onChange={(e) => setEditedBindings({
|
|
|
|
|
|
...editedBindings,
|
|
|
|
|
|
steps_goal_subtask_id: e.target.value ? parseInt(e.target.value, 10) : null
|
|
|
|
|
|
})}
|
2026-02-09 17:33:05 +03:00
|
|
|
|
disabled={loadingTasks}
|
2026-02-09 17:06:08 +03:00
|
|
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg disabled:bg-gray-100"
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="">Не выбрано</option>
|
|
|
|
|
|
{stepsGoalSubtasks.map(subtask => (
|
|
|
|
|
|
<option key={subtask.id} value={subtask.id}>{subtask.name}</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
2026-02-09 17:33:05 +03:00
|
|
|
|
)}
|
2026-02-08 17:01:36 +03:00
|
|
|
|
</div>
|
2026-02-09 17:33:05 +03:00
|
|
|
|
</div>
|
2026-02-08 17:01:36 +03:00
|
|
|
|
|
2026-02-09 17:33:05 +03:00
|
|
|
|
{isStepsBindingsDirty && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={handleSaveStepsBindings}
|
|
|
|
|
|
disabled={savingStepsBindings}
|
|
|
|
|
|
className="mt-3 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50"
|
|
|
|
|
|
>
|
|
|
|
|
|
{savingStepsBindings ? 'Сохранение...' : 'Сохранить'}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-02-09 17:06:08 +03:00
|
|
|
|
|
2026-02-09 17:33:05 +03:00
|
|
|
|
{/* Группа: Этажи */}
|
|
|
|
|
|
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
|
|
|
|
|
<h2 className="text-lg font-semibold mb-4">Этажи</h2>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div className="flex justify-between items-center mb-2">
|
|
|
|
|
|
<span className="text-gray-600 text-sm">Сегодня</span>
|
|
|
|
|
|
<span className={`font-bold ${getProgressColor(stats.floors.value, stats.floors.goal)}`}>
|
|
|
|
|
|
{stats.floors.value} / {stats.floors.goal}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="w-full bg-gray-200 rounded-full h-3">
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="bg-indigo-600 h-3 rounded-full transition-all"
|
|
|
|
|
|
style={{ width: `${getProgressPercent(stats.floors.value, stats.floors.goal)}%` }}
|
|
|
|
|
|
></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-02-09 17:06:08 +03:00
|
|
|
|
|
2026-02-09 17:33:05 +03:00
|
|
|
|
<div>
|
|
|
|
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Прогресс этажей</label>
|
|
|
|
|
|
<p className="text-xs text-gray-500 mb-1">Задача</p>
|
|
|
|
|
|
<select
|
|
|
|
|
|
value={editedBindings.floors_task_id ?? ''}
|
|
|
|
|
|
onChange={(e) => setEditedBindings({
|
|
|
|
|
|
...editedBindings,
|
|
|
|
|
|
floors_task_id: e.target.value ? parseInt(e.target.value, 10) : null
|
|
|
|
|
|
})}
|
|
|
|
|
|
disabled={loadingTasks}
|
|
|
|
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg disabled:bg-gray-100"
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="">{loadingTasks ? 'Загрузка...' : 'Не выбрано'}</option>
|
|
|
|
|
|
{!loadingTasks && getProgressTasks().map(task => (
|
|
|
|
|
|
<option key={task.id} value={task.id}>{task.name}</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h3 className="text-sm font-medium text-gray-700 mb-2">Достижение цели</h3>
|
|
|
|
|
|
<div className="pl-0 space-y-2">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="block text-sm text-gray-600 mb-1">Задача</label>
|
|
|
|
|
|
<select
|
|
|
|
|
|
value={editedBindings.floors_goal_task_id ?? ''}
|
|
|
|
|
|
onChange={(e) => setEditedBindings({
|
|
|
|
|
|
...editedBindings,
|
|
|
|
|
|
floors_goal_task_id: e.target.value ? parseInt(e.target.value, 10) : null,
|
|
|
|
|
|
floors_goal_subtask_id: null
|
|
|
|
|
|
})}
|
|
|
|
|
|
disabled={loadingTasks}
|
|
|
|
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg disabled:bg-gray-100"
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="">{loadingTasks ? 'Загрузка...' : 'Не выбрано'}</option>
|
2026-02-09 17:40:29 +03:00
|
|
|
|
{!loadingTasks && tasks.map(task => (
|
2026-02-09 17:33:05 +03:00
|
|
|
|
<option key={task.id} value={task.id}>{task.name}</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
2026-02-09 17:40:29 +03:00
|
|
|
|
{editedBindings.floors_goal_task_id && floorsGoalSubtasks.length > 0 && (
|
2026-02-09 17:06:08 +03:00
|
|
|
|
<div>
|
|
|
|
|
|
<label className="block text-sm text-gray-600 mb-1">Подзадача</label>
|
|
|
|
|
|
<select
|
|
|
|
|
|
value={editedBindings.floors_goal_subtask_id ?? ''}
|
|
|
|
|
|
onChange={(e) => setEditedBindings({
|
|
|
|
|
|
...editedBindings,
|
|
|
|
|
|
floors_goal_subtask_id: e.target.value ? parseInt(e.target.value, 10) : null
|
|
|
|
|
|
})}
|
2026-02-09 17:33:05 +03:00
|
|
|
|
disabled={loadingTasks}
|
2026-02-09 17:06:08 +03:00
|
|
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg disabled:bg-gray-100"
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="">Не выбрано</option>
|
|
|
|
|
|
{floorsGoalSubtasks.map(subtask => (
|
|
|
|
|
|
<option key={subtask.id} value={subtask.id}>{subtask.name}</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
2026-02-09 17:33:05 +03:00
|
|
|
|
)}
|
2026-02-08 17:01:36 +03:00
|
|
|
|
</div>
|
2026-02-09 17:33:05 +03:00
|
|
|
|
</div>
|
2026-02-08 17:01:36 +03:00
|
|
|
|
|
2026-02-09 17:33:05 +03:00
|
|
|
|
{isFloorsBindingsDirty && (
|
2026-02-09 17:06:08 +03:00
|
|
|
|
<button
|
2026-02-09 17:33:05 +03:00
|
|
|
|
onClick={handleSaveFloorsBindings}
|
|
|
|
|
|
disabled={savingFloorsBindings}
|
|
|
|
|
|
className="mt-3 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50"
|
2026-02-09 17:06:08 +03:00
|
|
|
|
>
|
2026-02-09 17:33:05 +03:00
|
|
|
|
{savingFloorsBindings ? 'Сохранение...' : 'Сохранить'}
|
2026-02-09 17:06:08 +03:00
|
|
|
|
</button>
|
2026-02-09 17:33:05 +03:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-02-08 17:01:36 +03:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-02-09 17:33:05 +03:00
|
|
|
|
<button
|
|
|
|
|
|
onClick={handleSync}
|
|
|
|
|
|
disabled={syncing}
|
|
|
|
|
|
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50 mb-6"
|
|
|
|
|
|
>
|
|
|
|
|
|
{syncing ? 'Синхронизация...' : 'Синхронизировать'}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
2026-02-08 17:01:36 +03:00
|
|
|
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-6">
|
2026-02-09 17:06:08 +03:00
|
|
|
|
<h3 className="text-lg font-semibold mb-3 text-blue-900">Как это работает</h3>
|
|
|
|
|
|
<ul className="text-gray-700 space-y-2 text-sm">
|
|
|
|
|
|
<li>• Данные синхронизируются автоматически каждые 4 часа</li>
|
|
|
|
|
|
<li>• При синхронизации данные записываются в привязанные задачи</li>
|
|
|
|
|
|
<li>• Задачи автоматически выполняются в конце дня (23:55)</li>
|
|
|
|
|
|
</ul>
|
2026-02-08 17:01:36 +03:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={handleDisconnect}
|
|
|
|
|
|
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
|
|
|
|
|
|
>
|
|
|
|
|
|
Отключить Fitbit
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div>
|
|
|
|
|
|
{oauthError && (
|
|
|
|
|
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
|
|
|
|
|
|
<p className="text-red-800 font-medium">Ошибка подключения Fitbit</p>
|
|
|
|
|
|
<p className="text-red-700 mt-1">{oauthError}</p>
|
|
|
|
|
|
<button onClick={() => setOauthError('')} className="text-red-600 text-sm underline mt-2">Скрыть</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
|
|
|
|
|
<h2 className="text-lg font-semibold mb-4">Подключение Fitbit</h2>
|
|
|
|
|
|
<p className="text-gray-700 mb-4">
|
2026-02-09 17:06:08 +03:00
|
|
|
|
Подключите свой Fitbit аккаунт для автоматической синхронизации шагов и этажей с вашими задачами.
|
2026-02-08 17:01:36 +03:00
|
|
|
|
</p>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={handleConnect}
|
|
|
|
|
|
className="px-6 py-3 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors font-semibold"
|
|
|
|
|
|
>
|
|
|
|
|
|
Подключить Fitbit
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6">
|
2026-02-09 17:06:08 +03:00
|
|
|
|
<h3 className="text-lg font-semibold mb-3 text-blue-900">Что нужно сделать</h3>
|
2026-02-08 17:01:36 +03:00
|
|
|
|
<ol className="list-decimal list-inside space-y-2 text-gray-700">
|
|
|
|
|
|
<li>Нажмите кнопку "Подключить Fitbit"</li>
|
|
|
|
|
|
<li>Авторизуйтесь в Fitbit</li>
|
|
|
|
|
|
<li>Разрешите доступ к данным о физической активности</li>
|
2026-02-09 17:06:08 +03:00
|
|
|
|
<li>Настройте привязки к задачам</li>
|
2026-02-08 17:01:36 +03:00
|
|
|
|
</ol>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-02-09 17:06:08 +03:00
|
|
|
|
|
2026-02-08 17:01:36 +03:00
|
|
|
|
{toastMessage && (
|
|
|
|
|
|
<Toast
|
|
|
|
|
|
message={toastMessage.text}
|
|
|
|
|
|
type={toastMessage.type}
|
|
|
|
|
|
onClose={() => setToastMessage(null)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default FitbitIntegration
|