5.1.1: Интерфейс Fitbit и синхронизация в 23:50
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m21s

This commit is contained in:
poignatov
2026-02-09 17:33:05 +03:00
parent 242183a422
commit 9cfb988960
4 changed files with 429 additions and 151 deletions

View File

@@ -29,7 +29,8 @@ function FitbitIntegration({ onNavigate }) {
floors_goal_subtask_id: null
})
const [editedBindings, setEditedBindings] = useState(bindings)
const [savingBindings, setSavingBindings] = useState(false)
const [savingStepsBindings, setSavingStepsBindings] = useState(false)
const [savingFloorsBindings, setSavingFloorsBindings] = useState(false)
const [tasks, setTasks] = useState([])
const [loadingTasks, setLoadingTasks] = useState(false)
@@ -258,25 +259,65 @@ function FitbitIntegration({ onNavigate }) {
}
}
const handleSaveBindings = async () => {
const handleSaveStepsBindings = async () => {
try {
setSavingBindings(true)
const response = await authFetch('/api/integrations/fitbit/bindings', {
setSavingStepsBindings(true)
const response = await authFetch('/api/integrations/fitbit/bindings/steps', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editedBindings)
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
})
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.error || 'Ошибка при сохранении привязок')
}
setBindings(editedBindings)
setToastMessage({ text: 'Привязки сохранены', type: 'success' })
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' })
} catch (error) {
console.error('Error saving bindings:', error)
console.error('Error saving steps bindings:', error)
setToastMessage({ text: error.message || 'Не удалось сохранить привязки', type: 'error' })
} finally {
setSavingBindings(false)
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)
}
}
@@ -299,6 +340,16 @@ function FitbitIntegration({ onNavigate }) {
return 'text-gray-600'
}
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)
if (isLoadingError && !loading) {
return (
<div className="p-4 md:p-6">
@@ -331,118 +382,67 @@ function FitbitIntegration({ onNavigate }) {
</div>
) : connected ? (
<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>
<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"
>
{syncing ? 'Синхронизация...' : 'Синхронизировать'}
</button>
</div>
<h2 className="text-lg font-semibold mb-4">Шаги</h2>
<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, 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>
</div>
<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, 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>
</div>
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
<h2 className="text-lg font-semibold mb-4">Привязка к задачам</h2>
{loadingTasks ? (
<div className="text-gray-500">Загрузка задач...</div>
) : (
<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 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>
</div>
<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 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>
</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.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>
{!loadingTasks && getParentTasks().map(task => (
<option key={task.id} value={task.id}>{task.name}</option>
))}
</select>
</div>
{editedBindings.steps_goal_task_id && (
<div>
<label className="block text-sm text-gray-600 mb-1">Подзадача</label>
<select
@@ -451,7 +451,7 @@ function FitbitIntegration({ onNavigate }) {
...editedBindings,
steps_goal_subtask_id: e.target.value ? parseInt(e.target.value, 10) : null
})}
disabled={!editedBindings.steps_goal_task_id}
disabled={loadingTasks}
className="w-full px-3 py-2 border border-gray-300 rounded-lg disabled:bg-gray-100"
>
<option value="">Не выбрано</option>
@@ -460,31 +460,83 @@ function FitbitIntegration({ onNavigate }) {
))}
</select>
</div>
</div>
)}
</div>
</div>
<div>
<h3 className="text-md font-medium text-gray-700 mb-3">Отметка достижения цели по этажам</h3>
{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>
<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 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>
<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>
{!loadingTasks && getParentTasks().map(task => (
<option key={task.id} value={task.id}>{task.name}</option>
))}
</select>
</div>
{editedBindings.floors_goal_task_id && (
<div>
<label className="block text-sm text-gray-600 mb-1">Подзадача</label>
<select
@@ -493,7 +545,7 @@ function FitbitIntegration({ onNavigate }) {
...editedBindings,
floors_goal_subtask_id: e.target.value ? parseInt(e.target.value, 10) : null
})}
disabled={!editedBindings.floors_goal_task_id}
disabled={loadingTasks}
className="w-full px-3 py-2 border border-gray-300 rounded-lg disabled:bg-gray-100"
>
<option value="">Не выбрано</option>
@@ -502,20 +554,30 @@ function FitbitIntegration({ onNavigate }) {
))}
</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>
)}
{isFloorsBindingsDirty && (
<button
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"
>
{savingFloorsBindings ? 'Сохранение...' : 'Сохранить'}
</button>
)}
</div>
</div>
<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>
<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>
<ul className="text-gray-700 space-y-2 text-sm">