5.1.0: Fitbit: привязки к задачам, цели из API
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m23s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m23s
This commit is contained in:
@@ -13,25 +13,32 @@ function FitbitIntegration({ onNavigate }) {
|
||||
const [oauthError, setOauthError] = useState('')
|
||||
const [toastMessage, setToastMessage] = useState(null)
|
||||
const [isLoadingError, setIsLoadingError] = useState(false)
|
||||
const [goals, setGoals] = useState({
|
||||
steps: { min: 8000, max: 10000 },
|
||||
floors: { min: 8, max: 10 },
|
||||
azm: { min: 22, max: 44 }
|
||||
})
|
||||
const [stats, setStats] = useState({
|
||||
steps: { value: 0, goal: { min: 8000, max: 10000 } },
|
||||
floors: { value: 0, goal: { min: 8, max: 10 } },
|
||||
azm: { value: 0, goal: { min: 22, max: 44 } }
|
||||
})
|
||||
const [isEditingGoals, setIsEditingGoals] = useState(false)
|
||||
const [editedGoals, setEditedGoals] = useState(goals)
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
|
||||
// Сохраняем OAuth статус из URL в ref, чтобы проверить после checkStatus
|
||||
const [stats, setStats] = useState({
|
||||
steps: { value: 0, goal: 10000 },
|
||||
floors: { value: 0, goal: 10 }
|
||||
})
|
||||
|
||||
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)
|
||||
const [savingBindings, setSavingBindings] = useState(false)
|
||||
|
||||
const [tasks, setTasks] = useState([])
|
||||
const [loadingTasks, setLoadingTasks] = useState(false)
|
||||
const [stepsGoalSubtasks, setStepsGoalSubtasks] = useState([])
|
||||
const [floorsGoalSubtasks, setFloorsGoalSubtasks] = useState([])
|
||||
|
||||
const oauthStatusRef = React.useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Проверяем URL параметры для сообщений ДО вызова checkStatus
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const integration = params.get('integration')
|
||||
const status = params.get('status')
|
||||
@@ -52,7 +59,6 @@ function FitbitIntegration({ onNavigate }) {
|
||||
}
|
||||
setOauthError(errorMessages[errorMsg] || `Ошибка: ${errorMsg}`)
|
||||
}
|
||||
// Очищаем URL параметры
|
||||
window.history.replaceState({}, '', window.location.pathname)
|
||||
}
|
||||
checkStatus()
|
||||
@@ -61,9 +67,52 @@ function FitbitIntegration({ onNavigate }) {
|
||||
useEffect(() => {
|
||||
if (connected) {
|
||||
loadStats()
|
||||
loadTasks()
|
||||
}
|
||||
}, [connected])
|
||||
|
||||
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])
|
||||
|
||||
const checkStatus = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
@@ -74,13 +123,21 @@ function FitbitIntegration({ onNavigate }) {
|
||||
}
|
||||
const data = await response.json()
|
||||
setConnected(data.connected || false)
|
||||
if (data.connected && data.goals) {
|
||||
setGoals(data.goals)
|
||||
setEditedGoals(data.goals)
|
||||
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)
|
||||
}
|
||||
// Если OAuth вернул status=connected, но бэкенд не подтвердил подключение
|
||||
if (oauthStatusRef.current === 'connected' && !data.connected) {
|
||||
setOauthError('Авторизация в Fitbit прошла, но подключение не сохранилось. Попробуйте ещё раз или обратитесь к администратору.')
|
||||
setOauthError('Авторизация в Fitbit прошла, но подключение не сохранилось. Попробуйте ещё раз.')
|
||||
setMessage('')
|
||||
}
|
||||
oauthStatusRef.current = null
|
||||
@@ -100,39 +157,34 @@ function FitbitIntegration({ onNavigate }) {
|
||||
throw new Error('Ошибка при загрузке статистики')
|
||||
}
|
||||
const data = await response.json()
|
||||
// Нормализуем данные, чтобы избежать undefined
|
||||
const defaultGoal = { min: 0, max: 0 }
|
||||
const normalizedStats = {
|
||||
setStats({
|
||||
steps: {
|
||||
value: data.steps?.value ?? 0,
|
||||
goal: data.steps?.goal ?? defaultGoal
|
||||
goal: data.steps?.goal ?? 10000
|
||||
},
|
||||
floors: {
|
||||
value: data.floors?.value ?? 0,
|
||||
goal: data.floors?.goal ?? defaultGoal
|
||||
},
|
||||
azm: {
|
||||
value: data.azm?.value ?? 0,
|
||||
goal: data.azm?.goal ?? defaultGoal
|
||||
goal: data.floors?.goal ?? 10
|
||||
}
|
||||
}
|
||||
setStats(normalizedStats)
|
||||
// Обновляем цели из ответа
|
||||
if (data.steps?.goal) {
|
||||
setGoals({
|
||||
steps: data.steps.goal,
|
||||
floors: data.floors?.goal ?? defaultGoal,
|
||||
azm: data.azm?.goal ?? defaultGoal
|
||||
})
|
||||
setEditedGoals({
|
||||
steps: data.steps.goal,
|
||||
floors: data.floors?.goal ?? defaultGoal,
|
||||
azm: data.azm?.goal ?? defaultGoal
|
||||
})
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error loading stats:', error)
|
||||
// Не показываем ошибку, просто не обновляем статистику
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,7 +214,6 @@ function FitbitIntegration({ onNavigate }) {
|
||||
if (!window.confirm('Вы уверены, что хотите отключить Fitbit?')) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
@@ -175,9 +226,8 @@ function FitbitIntegration({ onNavigate }) {
|
||||
}
|
||||
setConnected(false)
|
||||
setStats({
|
||||
steps: { value: 0, goal: { min: 8000, max: 10000 } },
|
||||
floors: { value: 0, goal: { min: 8, max: 10 } },
|
||||
azm: { value: 0, goal: { min: 22, max: 44 } }
|
||||
steps: { value: 0, goal: 10000 },
|
||||
floors: { value: 0, goal: 10 }
|
||||
})
|
||||
setToastMessage({ text: 'Fitbit отключен', type: 'success' })
|
||||
} catch (error) {
|
||||
@@ -208,47 +258,44 @@ function FitbitIntegration({ onNavigate }) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveGoals = async () => {
|
||||
const handleSaveBindings = async () => {
|
||||
try {
|
||||
const response = await authFetch('/api/integrations/fitbit/goals', {
|
||||
setSavingBindings(true)
|
||||
const response = await authFetch('/api/integrations/fitbit/bindings', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
steps: editedGoals.steps,
|
||||
floors: editedGoals.floors,
|
||||
azm: editedGoals.azm,
|
||||
}),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(editedBindings)
|
||||
})
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.error || 'Ошибка при сохранении целей')
|
||||
throw new Error(errorData.error || 'Ошибка при сохранении привязок')
|
||||
}
|
||||
setGoals(editedGoals)
|
||||
setIsEditingGoals(false)
|
||||
setToastMessage({ text: 'Цели сохранены', type: 'success' })
|
||||
await loadStats()
|
||||
setBindings(editedBindings)
|
||||
setToastMessage({ text: 'Привязки сохранены', type: 'success' })
|
||||
} catch (error) {
|
||||
console.error('Error saving goals:', error)
|
||||
setToastMessage({ text: error.message || 'Не удалось сохранить цели', type: 'error' })
|
||||
console.error('Error saving bindings:', error)
|
||||
setToastMessage({ text: error.message || 'Не удалось сохранить привязки', type: 'error' })
|
||||
} finally {
|
||||
setSavingBindings(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditedGoals(goals)
|
||||
setIsEditingGoals(false)
|
||||
const getProgressTasks = () => {
|
||||
return tasks.filter(t => t.has_progression || t.progression_base != null)
|
||||
}
|
||||
|
||||
const getProgressPercent = (value, min, max) => {
|
||||
if (value >= max) return 100
|
||||
if (value <= min) return (value / min) * 50
|
||||
return 50 + ((value - min) / (max - min)) * 50
|
||||
const getParentTasks = () => {
|
||||
return tasks.filter(t => (t.subtasks_count ?? 0) > 0)
|
||||
}
|
||||
|
||||
const getProgressColor = (value, min, max) => {
|
||||
if (value >= max) return 'text-green-600'
|
||||
if (value >= min) return 'text-blue-600'
|
||||
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'
|
||||
return 'text-gray-600'
|
||||
}
|
||||
|
||||
@@ -269,207 +316,213 @@ function FitbitIntegration({ onNavigate }) {
|
||||
✕
|
||||
</button>
|
||||
|
||||
<h1 className="text-2xl font-bold mb-6">Fitbit интеграция</h1>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="fixed inset-0 flex justify-center items-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-12 h-12 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin mb-4"></div>
|
||||
<div className="text-gray-600 font-medium">Загрузка...</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
) : connected ? (
|
||||
<div>
|
||||
{message && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
|
||||
<p className="text-green-800">{message}</p>
|
||||
</div>
|
||||
)}
|
||||
{oauthError && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
|
||||
<p className="text-red-800">{oauthError}</p>
|
||||
<button onClick={() => setOauthError('')} className="text-red-600 text-sm underline mt-1">Скрыть</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Статистика */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-lg font-semibold">Статистика за сегодня</h2>
|
||||
<h2 className="text-lg font-semibold">Сегодня</h2>
|
||||
<button
|
||||
onClick={handleSync}
|
||||
disabled={syncing}
|
||||
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
||||
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{syncing ? 'Синхронизация...' : 'Синхронизировать'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Шаги */}
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-gray-700 font-medium">Шаги</span>
|
||||
<span className={`font-bold ${getProgressColor(stats.steps?.value ?? 0, stats.steps?.goal?.min ?? 0, stats.steps?.goal?.max ?? 0)}`}>
|
||||
{(stats.steps?.value ?? 0).toLocaleString()} / {stats.steps?.goal?.min ?? 0}-{stats.steps?.goal?.max ?? 0}
|
||||
<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: `${Math.min(100, getProgressPercent(stats.steps?.value ?? 0, stats.steps?.goal?.min ?? 0, stats.steps?.goal?.max ?? 0))}%` }}
|
||||
style={{ width: `${getProgressPercent(stats.steps.value, stats.steps.goal)}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Этажи */}
|
||||
<div className="mb-6">
|
||||
<div className="mb-2">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-gray-700 font-medium">Этажи</span>
|
||||
<span className={`font-bold ${getProgressColor(stats.floors?.value ?? 0, stats.floors?.goal?.min ?? 0, stats.floors?.goal?.max ?? 0)}`}>
|
||||
{stats.floors?.value ?? 0} / {stats.floors?.goal?.min ?? 0}-{stats.floors?.goal?.max ?? 0}
|
||||
<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: `${Math.min(100, getProgressPercent(stats.floors?.value ?? 0, stats.floors?.goal?.min ?? 0, stats.floors?.goal?.max ?? 0))}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Баллы кардио (AZM) */}
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-gray-700 font-medium">Баллы кардио</span>
|
||||
<span className={`font-bold ${getProgressColor(stats.azm?.value ?? 0, stats.azm?.goal?.min ?? 0, stats.azm?.goal?.max ?? 0)}`}>
|
||||
{stats.azm?.value ?? 0} / {stats.azm?.goal?.min ?? 0}-{stats.azm?.goal?.max ?? 0}
|
||||
</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: `${Math.min(100, getProgressPercent(stats.azm?.value ?? 0, stats.azm?.goal?.min ?? 0, stats.azm?.goal?.max ?? 0))}%` }}
|
||||
style={{ width: `${getProgressPercent(stats.floors.value, stats.floors.goal)}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Настройка целей */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-lg font-semibold">Дневные цели</h2>
|
||||
{!isEditingGoals && (
|
||||
<button
|
||||
onClick={() => setIsEditingGoals(true)}
|
||||
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors text-sm"
|
||||
>
|
||||
Изменить
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold mb-4">Привязка к задачам</h2>
|
||||
|
||||
{isEditingGoals ? (
|
||||
<div className="space-y-4">
|
||||
{/* Шаги */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Шаги (мин - макс)</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
value={editedGoals.steps.min}
|
||||
onChange={(e) => setEditedGoals({ ...editedGoals, steps: { ...editedGoals.steps, min: parseInt(e.target.value) || 0 } })}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
value={editedGoals.steps.max}
|
||||
onChange={(e) => setEditedGoals({ ...editedGoals, steps: { ...editedGoals.steps, max: parseInt(e.target.value) || 0 } })}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Этажи */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Этажи (мин - макс)</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
value={editedGoals.floors.min}
|
||||
onChange={(e) => setEditedGoals({ ...editedGoals, floors: { ...editedGoals.floors, min: parseInt(e.target.value) || 0 } })}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
value={editedGoals.floors.max}
|
||||
onChange={(e) => setEditedGoals({ ...editedGoals, floors: { ...editedGoals.floors, max: parseInt(e.target.value) || 0 } })}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Баллы кардио */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Баллы кардио (мин - макс)</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
value={editedGoals.azm.min}
|
||||
onChange={(e) => setEditedGoals({ ...editedGoals, azm: { ...editedGoals.azm, min: parseInt(e.target.value) || 0 } })}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
value={editedGoals.azm.max}
|
||||
onChange={(e) => setEditedGoals({ ...editedGoals, azm: { ...editedGoals.azm, max: parseInt(e.target.value) || 0 } })}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleSaveGoals}
|
||||
className="flex-1 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
Сохранить
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancelEdit}
|
||||
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{loadingTasks ? (
|
||||
<div className="text-gray-500">Загрузка задач...</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Шаги:</span>
|
||||
<span className="font-medium">{goals.steps.min} - {goals.steps.max}</span>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-md font-medium text-gray-700 mb-3">Запись прогресса</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600 mb-1">Шаги → задача</label>
|
||||
<select
|
||||
value={editedBindings.steps_task_id ?? ''}
|
||||
onChange={(e) => setEditedBindings({
|
||||
...editedBindings,
|
||||
steps_task_id: e.target.value ? parseInt(e.target.value, 10) : null
|
||||
})}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||
>
|
||||
<option value="">Не выбрано</option>
|
||||
{getProgressTasks().map(task => (
|
||||
<option key={task.id} value={task.id}>{task.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600 mb-1">Этажи → задача</label>
|
||||
<select
|
||||
value={editedBindings.floors_task_id ?? ''}
|
||||
onChange={(e) => setEditedBindings({
|
||||
...editedBindings,
|
||||
floors_task_id: e.target.value ? parseInt(e.target.value, 10) : null
|
||||
})}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||
>
|
||||
<option value="">Не выбрано</option>
|
||||
{getProgressTasks().map(task => (
|
||||
<option key={task.id} value={task.id}>{task.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Этажи:</span>
|
||||
<span className="font-medium">{goals.floors.min} - {goals.floors.max}</span>
|
||||
|
||||
<div>
|
||||
<h3 className="text-md font-medium text-gray-700 mb-3">Отметка достижения цели по шагам</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<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
|
||||
})}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||
>
|
||||
<option value="">Не выбрано</option>
|
||||
{getParentTasks().map(task => (
|
||||
<option key={task.id} value={task.id}>{task.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<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
|
||||
})}
|
||||
disabled={!editedBindings.steps_goal_task_id}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Баллы кардио:</span>
|
||||
<span className="font-medium">{goals.azm.min} - {goals.azm.max}</span>
|
||||
|
||||
<div>
|
||||
<h3 className="text-md font-medium text-gray-700 mb-3">Отметка достижения цели по этажам</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<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
|
||||
})}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||
>
|
||||
<option value="">Не выбрано</option>
|
||||
{getParentTasks().map(task => (
|
||||
<option key={task.id} value={task.id}>{task.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<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
|
||||
})}
|
||||
disabled={!editedBindings.floors_goal_task_id}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSaveBindings}
|
||||
disabled={savingBindings}
|
||||
className="w-full px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{savingBindings ? 'Сохранение...' : 'Сохранить привязки'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-6">
|
||||
<h3 className="text-lg font-semibold mb-3 text-blue-900">
|
||||
Как это работает
|
||||
</h3>
|
||||
<p className="text-gray-700 mb-2">
|
||||
✅ Fitbit подключен! Данные синхронизируются автоматически каждые 4 часа.
|
||||
</p>
|
||||
<p className="text-gray-600 text-sm">
|
||||
Вы также можете синхронизировать данные вручную, нажав кнопку "Синхронизировать".
|
||||
</p>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -491,7 +544,7 @@ function FitbitIntegration({ onNavigate }) {
|
||||
<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">
|
||||
Подключите свой Fitbit аккаунт для отслеживания шагов, этажей и баллов кардионагрузки.
|
||||
Подключите свой Fitbit аккаунт для автоматической синхронизации шагов и этажей с вашими задачами.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleConnect}
|
||||
@@ -502,18 +555,17 @@ function FitbitIntegration({ onNavigate }) {
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold mb-3 text-blue-900">
|
||||
Что нужно сделать
|
||||
</h3>
|
||||
<h3 className="text-lg font-semibold mb-3 text-blue-900">Что нужно сделать</h3>
|
||||
<ol className="list-decimal list-inside space-y-2 text-gray-700">
|
||||
<li>Нажмите кнопку "Подключить Fitbit"</li>
|
||||
<li>Авторизуйтесь в Fitbit</li>
|
||||
<li>Разрешите доступ к данным о физической активности</li>
|
||||
<li>Готово! Данные будут синхронизироваться автоматически</li>
|
||||
<li>Настройте привязки к задачам</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{toastMessage && (
|
||||
<Toast
|
||||
message={toastMessage.text}
|
||||
|
||||
Reference in New Issue
Block a user