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

@@ -1 +1 @@
5.1.0 5.1.1

View File

@@ -3859,6 +3859,44 @@ func (a *App) startFitbitSyncScheduler() {
// Планировщик будет работать в фоновом режиме // Планировщик будет работать в фоновом режиме
} }
// startFitbitDailySyncScheduler запускает обязательную синхронизацию Fitbit каждый день в 23:50 (часовой пояс из TIMEZONE)
func (a *App) startFitbitDailySyncScheduler() {
timezoneStr := getEnv("TIMEZONE", "UTC")
log.Printf("Loading timezone for Fitbit daily sync scheduler: '%s'", timezoneStr)
loc, err := time.LoadLocation(timezoneStr)
if err != nil {
log.Printf("Warning: Invalid timezone '%s': %v. Using UTC instead.", timezoneStr, err)
loc = time.UTC
timezoneStr = "UTC"
} else {
log.Printf("Fitbit daily sync scheduler timezone set to: %s", timezoneStr)
}
now := time.Now().In(loc)
log.Printf("Current time in Fitbit daily sync timezone (%s): %s", timezoneStr, now.Format("2006-01-02 15:04:05 MST"))
log.Printf("Next mandatory Fitbit sync will be at: 23:50 %s (cron: '50 23 * * *')", timezoneStr)
c := cron.New(cron.WithLocation(loc))
_, err = c.AddFunc("50 23 * * *", func() {
now := time.Now().In(loc)
log.Printf("Scheduled task: Mandatory Fitbit sync at 23:50 (timezone: %s, local time: %s)", timezoneStr, now.Format("2006-01-02 15:04:05 MST"))
if err := a.syncFitbitDataForAllUsers(); err != nil {
log.Printf("Error in mandatory Fitbit sync at 23:50: %v", err)
} else {
log.Printf("Mandatory Fitbit sync at 23:50 completed successfully")
}
})
if err != nil {
log.Printf("Error adding cron job for Fitbit daily sync at 23:50: %v", err)
return
}
c.Start()
log.Printf("Fitbit daily sync scheduler started: every day at 23:50 %s", timezoneStr)
}
// syncFitbitDataForAllUsers синхронизирует данные Fitbit для всех подключенных пользователей // syncFitbitDataForAllUsers синхронизирует данные Fitbit для всех подключенных пользователей
func (a *App) syncFitbitDataForAllUsers() error { func (a *App) syncFitbitDataForAllUsers() error {
rows, err := a.DB.Query(` rows, err := a.DB.Query(`
@@ -4170,6 +4208,8 @@ func main() {
// Запускаем планировщик синхронизации Fitbit каждые 4 часа // Запускаем планировщик синхронизации Fitbit каждые 4 часа
app.startFitbitSyncScheduler() app.startFitbitSyncScheduler()
// Обязательная синхронизация Fitbit каждый день в 23:50 (перед автовыполнением задач в 23:55)
app.startFitbitDailySyncScheduler()
r := mux.NewRouter() r := mux.NewRouter()
@@ -4272,6 +4312,8 @@ func main() {
protected.HandleFunc("/api/integrations/fitbit/status", app.getFitbitStatusHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/integrations/fitbit/status", app.getFitbitStatusHandler).Methods("GET", "OPTIONS")
protected.HandleFunc("/api/integrations/fitbit/disconnect", app.fitbitDisconnectHandler).Methods("DELETE", "OPTIONS") protected.HandleFunc("/api/integrations/fitbit/disconnect", app.fitbitDisconnectHandler).Methods("DELETE", "OPTIONS")
protected.HandleFunc("/api/integrations/fitbit/bindings", app.updateFitbitBindingsHandler).Methods("PUT", "OPTIONS") protected.HandleFunc("/api/integrations/fitbit/bindings", app.updateFitbitBindingsHandler).Methods("PUT", "OPTIONS")
protected.HandleFunc("/api/integrations/fitbit/bindings/steps", app.updateFitbitStepsBindingsHandler).Methods("PUT", "OPTIONS")
protected.HandleFunc("/api/integrations/fitbit/bindings/floors", app.updateFitbitFloorsBindingsHandler).Methods("PUT", "OPTIONS")
protected.HandleFunc("/api/integrations/fitbit/sync", app.fitbitSyncHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/api/integrations/fitbit/sync", app.fitbitSyncHandler).Methods("POST", "OPTIONS")
protected.HandleFunc("/api/integrations/fitbit/stats", app.getFitbitStatsHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/integrations/fitbit/stats", app.getFitbitStatsHandler).Methods("GET", "OPTIONS")
@@ -10717,6 +10759,180 @@ func (a *App) updateFitbitBindingsHandler(w http.ResponseWriter, r *http.Request
}) })
} }
// updateFitbitStepsBindingsHandler обновляет только привязки для шагов
func (a *App) updateFitbitStepsBindingsHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
setCORSHeaders(w)
w.WriteHeader(http.StatusOK)
return
}
setCORSHeaders(w)
userID, ok := getUserIDFromContext(r)
if !ok {
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
return
}
var req struct {
StepsTaskID *int `json:"steps_task_id"`
StepsGoalTaskID *int `json:"steps_goal_task_id"`
StepsGoalSubtaskID *int `json:"steps_goal_subtask_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest)
return
}
validateTask := func(taskID *int, fieldName string) error {
if taskID == nil {
return nil
}
var exists bool
err := a.DB.QueryRow(`
SELECT EXISTS(SELECT 1 FROM tasks WHERE id = $1 AND user_id = $2 AND deleted = FALSE)
`, *taskID, userID).Scan(&exists)
if err != nil || !exists {
return fmt.Errorf("%s: task not found", fieldName)
}
return nil
}
validateSubtask := func(subtaskID *int, parentTaskID *int, fieldName string) error {
if subtaskID == nil {
return nil
}
if parentTaskID == nil {
return fmt.Errorf("%s: parent task is required", fieldName)
}
var exists bool
err := a.DB.QueryRow(`
SELECT EXISTS(SELECT 1 FROM tasks WHERE id = $1 AND parent_task_id = $2 AND user_id = $3 AND deleted = FALSE)
`, *subtaskID, *parentTaskID, userID).Scan(&exists)
if err != nil || !exists {
return fmt.Errorf("%s: subtask not found", fieldName)
}
return nil
}
if err := validateTask(req.StepsTaskID, "steps_task_id"); err != nil {
sendErrorWithCORS(w, err.Error(), http.StatusBadRequest)
return
}
if err := validateTask(req.StepsGoalTaskID, "steps_goal_task_id"); err != nil {
sendErrorWithCORS(w, err.Error(), http.StatusBadRequest)
return
}
if err := validateSubtask(req.StepsGoalSubtaskID, req.StepsGoalTaskID, "steps_goal_subtask_id"); err != nil {
sendErrorWithCORS(w, err.Error(), http.StatusBadRequest)
return
}
toNullInt64 := func(v *int) sql.NullInt64 {
if v == nil {
return sql.NullInt64{Valid: false}
}
return sql.NullInt64{Int64: int64(*v), Valid: true}
}
_, err := a.DB.Exec(`
UPDATE fitbit_integrations
SET steps_task_id = $1, steps_goal_task_id = $2, steps_goal_subtask_id = $3, updated_at = CURRENT_TIMESTAMP
WHERE user_id = $4
`, toNullInt64(req.StepsTaskID), toNullInt64(req.StepsGoalTaskID), toNullInt64(req.StepsGoalSubtaskID), userID)
if err != nil {
log.Printf("Fitbit update steps bindings: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Failed to update bindings: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"success": true, "message": "Steps bindings updated"})
}
// updateFitbitFloorsBindingsHandler обновляет только привязки для этажей
func (a *App) updateFitbitFloorsBindingsHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
setCORSHeaders(w)
w.WriteHeader(http.StatusOK)
return
}
setCORSHeaders(w)
userID, ok := getUserIDFromContext(r)
if !ok {
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
return
}
var req struct {
FloorsTaskID *int `json:"floors_task_id"`
FloorsGoalTaskID *int `json:"floors_goal_task_id"`
FloorsGoalSubtaskID *int `json:"floors_goal_subtask_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest)
return
}
validateTask := func(taskID *int, fieldName string) error {
if taskID == nil {
return nil
}
var exists bool
err := a.DB.QueryRow(`
SELECT EXISTS(SELECT 1 FROM tasks WHERE id = $1 AND user_id = $2 AND deleted = FALSE)
`, *taskID, userID).Scan(&exists)
if err != nil || !exists {
return fmt.Errorf("%s: task not found", fieldName)
}
return nil
}
validateSubtask := func(subtaskID *int, parentTaskID *int, fieldName string) error {
if subtaskID == nil {
return nil
}
if parentTaskID == nil {
return fmt.Errorf("%s: parent task is required", fieldName)
}
var exists bool
err := a.DB.QueryRow(`
SELECT EXISTS(SELECT 1 FROM tasks WHERE id = $1 AND parent_task_id = $2 AND user_id = $3 AND deleted = FALSE)
`, *subtaskID, *parentTaskID, userID).Scan(&exists)
if err != nil || !exists {
return fmt.Errorf("%s: subtask not found", fieldName)
}
return nil
}
if err := validateTask(req.FloorsTaskID, "floors_task_id"); err != nil {
sendErrorWithCORS(w, err.Error(), http.StatusBadRequest)
return
}
if err := validateTask(req.FloorsGoalTaskID, "floors_goal_task_id"); err != nil {
sendErrorWithCORS(w, err.Error(), http.StatusBadRequest)
return
}
if err := validateSubtask(req.FloorsGoalSubtaskID, req.FloorsGoalTaskID, "floors_goal_subtask_id"); err != nil {
sendErrorWithCORS(w, err.Error(), http.StatusBadRequest)
return
}
toNullInt64 := func(v *int) sql.NullInt64 {
if v == nil {
return sql.NullInt64{Valid: false}
}
return sql.NullInt64{Int64: int64(*v), Valid: true}
}
_, err := a.DB.Exec(`
UPDATE fitbit_integrations
SET floors_task_id = $1, floors_goal_task_id = $2, floors_goal_subtask_id = $3, updated_at = CURRENT_TIMESTAMP
WHERE user_id = $4
`, toNullInt64(req.FloorsTaskID), toNullInt64(req.FloorsGoalTaskID), toNullInt64(req.FloorsGoalSubtaskID), userID)
if err != nil {
log.Printf("Fitbit update floors bindings: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Failed to update bindings: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"success": true, "message": "Floors bindings updated"})
}
// getFitbitAccessToken получает актуальный access_token (обновляет если нужно) // getFitbitAccessToken получает актуальный access_token (обновляет если нужно)
func (a *App) getFitbitAccessToken(userID int) (string, error) { func (a *App) getFitbitAccessToken(userID int) (string, error) {
var accessToken, refreshToken sql.NullString var accessToken, refreshToken sql.NullString

View File

@@ -1,6 +1,6 @@
{ {
"name": "play-life-web", "name": "play-life-web",
"version": "5.1.0", "version": "5.1.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -29,7 +29,8 @@ function FitbitIntegration({ onNavigate }) {
floors_goal_subtask_id: null floors_goal_subtask_id: null
}) })
const [editedBindings, setEditedBindings] = useState(bindings) 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 [tasks, setTasks] = useState([])
const [loadingTasks, setLoadingTasks] = useState(false) const [loadingTasks, setLoadingTasks] = useState(false)
@@ -258,25 +259,65 @@ function FitbitIntegration({ onNavigate }) {
} }
} }
const handleSaveBindings = async () => { const handleSaveStepsBindings = async () => {
try { try {
setSavingBindings(true) setSavingStepsBindings(true)
const response = await authFetch('/api/integrations/fitbit/bindings', { const response = await authFetch('/api/integrations/fitbit/bindings/steps', {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, 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) { if (!response.ok) {
const errorData = await response.json().catch(() => ({})) const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.error || 'Ошибка при сохранении привязок') throw new Error(errorData.error || 'Ошибка при сохранении привязок')
} }
setBindings(editedBindings) setBindings(prev => ({
setToastMessage({ text: 'Привязки сохранены', type: 'success' }) ...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) { } catch (error) {
console.error('Error saving bindings:', error) console.error('Error saving steps bindings:', error)
setToastMessage({ text: error.message || 'Не удалось сохранить привязки', type: 'error' }) setToastMessage({ text: error.message || 'Не удалось сохранить привязки', type: 'error' })
} finally { } 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' 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) { if (isLoadingError && !loading) {
return ( return (
<div className="p-4 md:p-6"> <div className="p-4 md:p-6">
@@ -331,21 +382,14 @@ function FitbitIntegration({ onNavigate }) {
</div> </div>
) : connected ? ( ) : connected ? (
<div> <div>
{/* Группа: Шаги */}
<div className="bg-white rounded-lg shadow-md p-6 mb-6"> <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 mb-4">Шаги</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"
>
{syncing ? 'Синхронизация...' : 'Синхронизировать'}
</button>
</div>
<div className="mb-6"> <div className="space-y-4">
<div>
<div className="flex justify-between items-center mb-2"> <div className="flex justify-between items-center mb-2">
<span className="text-gray-700 font-medium">Шаги</span> <span className="text-gray-600 text-sm">Сегодня</span>
<span className={`font-bold ${getProgressColor(stats.steps.value, stats.steps.goal)}`}> <span className={`font-bold ${getProgressColor(stats.steps.value, stats.steps.goal)}`}>
{stats.steps.value.toLocaleString()} / {stats.steps.goal.toLocaleString()} {stats.steps.value.toLocaleString()} / {stats.steps.goal.toLocaleString()}
</span> </span>
@@ -358,9 +402,88 @@ function FitbitIntegration({ onNavigate }) {
</div> </div>
</div> </div>
<div className="mb-2"> <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
value={editedBindings.steps_goal_subtask_id ?? ''}
onChange={(e) => setEditedBindings({
...editedBindings,
steps_goal_subtask_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="">Не выбрано</option>
{stepsGoalSubtasks.map(subtask => (
<option key={subtask.id} value={subtask.id}>{subtask.name}</option>
))}
</select>
</div>
)}
</div>
</div>
{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="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"> <div className="flex justify-between items-center mb-2">
<span className="text-gray-700 font-medium">Этажи</span> <span className="text-gray-600 text-sm">Сегодня</span>
<span className={`font-bold ${getProgressColor(stats.floors.value, stats.floors.goal)}`}> <span className={`font-bold ${getProgressColor(stats.floors.value, stats.floors.goal)}`}>
{stats.floors.value} / {stats.floors.goal} {stats.floors.value} / {stats.floors.goal}
</span> </span>
@@ -372,101 +495,29 @@ function FitbitIntegration({ onNavigate }) {
></div> ></div>
</div> </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> <div>
<label className="block text-sm text-gray-600 mb-1">Этажи задача</label> <label className="block text-sm font-medium text-gray-700 mb-1">Прогресс этажей</label>
<p className="text-xs text-gray-500 mb-1">Задача</p>
<select <select
value={editedBindings.floors_task_id ?? ''} value={editedBindings.floors_task_id ?? ''}
onChange={(e) => setEditedBindings({ onChange={(e) => setEditedBindings({
...editedBindings, ...editedBindings,
floors_task_id: e.target.value ? parseInt(e.target.value, 10) : null 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" disabled={loadingTasks}
>
<option value="">Не выбрано</option>
{getProgressTasks().map(task => (
<option key={task.id} value={task.id}>{task.name}</option>
))}
</select>
</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 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" className="w-full px-3 py-2 border border-gray-300 rounded-lg disabled:bg-gray-100"
> >
<option value="">Не выбрано</option> <option value="">{loadingTasks ? 'Загрузка...' : 'Не выбрано'}</option>
{stepsGoalSubtasks.map(subtask => ( {!loadingTasks && getProgressTasks().map(task => (
<option key={subtask.id} value={subtask.id}>{subtask.name}</option> <option key={task.id} value={task.id}>{task.name}</option>
))} ))}
</select> </select>
</div> </div>
</div>
</div>
<div> <div>
<h3 className="text-md font-medium text-gray-700 mb-3">Отметка достижения цели по этажам</h3> <h3 className="text-sm font-medium text-gray-700 mb-2">Достижение цели</h3>
<div className="pl-0 space-y-2">
<div className="space-y-3">
<div> <div>
<label className="block text-sm text-gray-600 mb-1">Задача</label> <label className="block text-sm text-gray-600 mb-1">Задача</label>
<select <select
@@ -476,15 +527,16 @@ function FitbitIntegration({ onNavigate }) {
floors_goal_task_id: e.target.value ? parseInt(e.target.value, 10) : null, floors_goal_task_id: e.target.value ? parseInt(e.target.value, 10) : null,
floors_goal_subtask_id: null floors_goal_subtask_id: null
})} })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg" disabled={loadingTasks}
className="w-full px-3 py-2 border border-gray-300 rounded-lg disabled:bg-gray-100"
> >
<option value="">Не выбрано</option> <option value="">{loadingTasks ? 'Загрузка...' : 'Не выбрано'}</option>
{getParentTasks().map(task => ( {!loadingTasks && getParentTasks().map(task => (
<option key={task.id} value={task.id}>{task.name}</option> <option key={task.id} value={task.id}>{task.name}</option>
))} ))}
</select> </select>
</div> </div>
{editedBindings.floors_goal_task_id && (
<div> <div>
<label className="block text-sm text-gray-600 mb-1">Подзадача</label> <label className="block text-sm text-gray-600 mb-1">Подзадача</label>
<select <select
@@ -493,7 +545,7 @@ function FitbitIntegration({ onNavigate }) {
...editedBindings, ...editedBindings,
floors_goal_subtask_id: e.target.value ? parseInt(e.target.value, 10) : null 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" className="w-full px-3 py-2 border border-gray-300 rounded-lg disabled:bg-gray-100"
> >
<option value="">Не выбрано</option> <option value="">Не выбрано</option>
@@ -502,19 +554,29 @@ function FitbitIntegration({ onNavigate }) {
))} ))}
</select> </select>
</div> </div>
)}
</div>
</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>
</div> </div>
<button <button
onClick={handleSaveBindings} onClick={handleSync}
disabled={savingBindings} disabled={syncing}
className="w-full px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50" className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50 mb-6"
> >
{savingBindings ? 'Сохранение...' : 'Сохранить привязки'} {syncing ? 'Синхронизация...' : 'Синхронизировать'}
</button> </button>
</div>
)}
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-6"> <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> <h3 className="text-lg font-semibold mb-3 text-blue-900">Как это работает</h3>