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:
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user