5.1.1: Интерфейс Fitbit и синхронизация в 23:50
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m21s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m21s
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user