diff --git a/VERSION b/VERSION index 9776e72..19b860c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -6.3.10 +6.4.0 diff --git a/play-life-backend/main.go b/play-life-backend/main.go index 34a7268..1866283 100644 --- a/play-life-backend/main.go +++ b/play-life-backend/main.go @@ -4567,6 +4567,27 @@ func main() { protected.HandleFunc("/api/wishlist/invite/{token}", app.getBoardInviteInfoHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/wishlist/invite/{token}/join", app.joinBoardHandler).Methods("POST", "OPTIONS") + // Shopping Boards (Товары) + protected.HandleFunc("/api/shopping/boards", app.getShoppingBoardsHandler).Methods("GET", "OPTIONS") + protected.HandleFunc("/api/shopping/boards", app.createShoppingBoardHandler).Methods("POST", "OPTIONS") + protected.HandleFunc("/api/shopping/boards/{id}", app.getShoppingBoardHandler).Methods("GET", "OPTIONS") + protected.HandleFunc("/api/shopping/boards/{id}", app.updateShoppingBoardHandler).Methods("PUT", "OPTIONS") + protected.HandleFunc("/api/shopping/boards/{id}", app.deleteShoppingBoardHandler).Methods("DELETE", "OPTIONS") + protected.HandleFunc("/api/shopping/boards/{id}/regenerate-invite", app.regenerateShoppingBoardInviteHandler).Methods("POST", "OPTIONS") + protected.HandleFunc("/api/shopping/boards/{id}/members", app.getShoppingBoardMembersHandler).Methods("GET", "OPTIONS") + protected.HandleFunc("/api/shopping/boards/{id}/members/{userId}", app.removeShoppingBoardMemberHandler).Methods("DELETE", "OPTIONS") + protected.HandleFunc("/api/shopping/boards/{id}/leave", app.leaveShoppingBoardHandler).Methods("POST", "OPTIONS") + protected.HandleFunc("/api/shopping/boards/{boardId}/items", app.getShoppingItemsHandler).Methods("GET", "OPTIONS") + protected.HandleFunc("/api/shopping/boards/{boardId}/items", app.createShoppingItemHandler).Methods("POST", "OPTIONS") + protected.HandleFunc("/api/shopping/items/{id}", app.getShoppingItemHandler).Methods("GET", "OPTIONS") + protected.HandleFunc("/api/shopping/items/{id}", app.updateShoppingItemHandler).Methods("PUT", "OPTIONS") + protected.HandleFunc("/api/shopping/items/{id}", app.deleteShoppingItemHandler).Methods("DELETE", "OPTIONS") + protected.HandleFunc("/api/shopping/items/{id}/complete", app.completeShoppingItemHandler).Methods("POST", "OPTIONS") + protected.HandleFunc("/api/shopping/items/{id}/postpone", app.postponeShoppingItemHandler).Methods("POST", "OPTIONS") + protected.HandleFunc("/api/shopping/groups", app.getShoppingGroupSuggestionsHandler).Methods("GET", "OPTIONS") + protected.HandleFunc("/api/shopping/invite/{token}", app.getShoppingBoardInviteInfoHandler).Methods("GET", "OPTIONS") + protected.HandleFunc("/api/shopping/invite/{token}/join", app.joinShoppingBoardHandler).Methods("POST", "OPTIONS") + // Tracking protected.HandleFunc("/api/tracking/stats", app.getTrackingStatsHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/tracking/invite", app.createTrackingInviteHandler).Methods("POST", "OPTIONS") @@ -17687,3 +17708,1373 @@ func (a *App) getGroupSuggestionsHandler(w http.ResponseWriter, r *http.Request) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(groups) } + +// ============================================ +// Shopping (Товары) - Board & Item handlers +// ============================================ + +type ShoppingBoard struct { + ID int `json:"id"` + OwnerID int `json:"owner_id"` + OwnerName string `json:"owner_name,omitempty"` + Name string `json:"name"` + InviteEnabled bool `json:"invite_enabled"` + InviteToken *string `json:"invite_token,omitempty"` + InviteURL *string `json:"invite_url,omitempty"` + MemberCount int `json:"member_count"` + IsOwner bool `json:"is_owner"` + CreatedAt time.Time `json:"created_at"` +} + +type ShoppingItem struct { + ID int `json:"id"` + UserID int `json:"user_id"` + BoardID int `json:"board_id"` + AuthorID int `json:"author_id"` + Name string `json:"name"` + GroupName *string `json:"group_name,omitempty"` + VolumeBase float64 `json:"volume_base"` + RepetitionPeriod *string `json:"repetition_period,omitempty"` + NextShowAt *string `json:"next_show_at,omitempty"` + Completed int `json:"completed"` + LastCompletedAt *string `json:"last_completed_at,omitempty"` + CreatedAt string `json:"created_at"` +} + +type ShoppingItemRequest struct { + Name string `json:"name"` + GroupName *string `json:"group_name,omitempty"` + VolumeBase *float64 `json:"volume_base,omitempty"` + RepetitionPeriod *string `json:"repetition_period,omitempty"` +} + +type CompleteShoppingItemRequest struct { + Volume *float64 `json:"volume,omitempty"` +} + +type ShoppingJoinBoardResponse struct { + Board ShoppingBoard `json:"board"` + Message string `json:"message"` +} + +// getShoppingBoardsHandler возвращает список досок покупок пользователя +func (a *App) getShoppingBoardsHandler(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 + } + + boards := []ShoppingBoard{} + + rows, err := a.DB.Query(` + SELECT DISTINCT + sb.id, + sb.owner_id, + COALESCE(u.name, u.email) as owner_name, + sb.name, + sb.invite_enabled, + sb.invite_token, + sb.created_at, + (SELECT COUNT(*) FROM shopping_board_members sbm WHERE sbm.board_id = sb.id) as member_count, + (sb.owner_id = $1) as is_owner + FROM shopping_boards sb + JOIN users u ON sb.owner_id = u.id + LEFT JOIN shopping_board_members sbm ON sb.id = sbm.board_id + WHERE sb.deleted = FALSE + AND (sb.owner_id = $1 OR sbm.user_id = $1) + ORDER BY is_owner DESC, sb.created_at DESC + `, userID) + if err != nil { + log.Printf("Error getting shopping boards: %v", err) + sendErrorWithCORS(w, "Error getting boards", http.StatusInternalServerError) + return + } + defer rows.Close() + + baseURL := getEnv("WEBHOOK_BASE_URL", "") + + for rows.Next() { + var board ShoppingBoard + var inviteToken sql.NullString + err := rows.Scan( + &board.ID, + &board.OwnerID, + &board.OwnerName, + &board.Name, + &board.InviteEnabled, + &inviteToken, + &board.CreatedAt, + &board.MemberCount, + &board.IsOwner, + ) + if err != nil { + log.Printf("Error scanning shopping board: %v", err) + continue + } + + if board.IsOwner && inviteToken.Valid { + board.InviteToken = &inviteToken.String + if baseURL != "" { + url := baseURL + "/shopping-invite/" + inviteToken.String + board.InviteURL = &url + } + } + + boards = append(boards, board) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(boards) +} + +// createShoppingBoardHandler создаёт новую доску покупок +func (a *App) createShoppingBoardHandler(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 BoardRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) + return + } + + if strings.TrimSpace(req.Name) == "" { + sendErrorWithCORS(w, "Name is required", http.StatusBadRequest) + return + } + + var boardID int + err := a.DB.QueryRow(` + INSERT INTO shopping_boards (owner_id, name) + VALUES ($1, $2) + RETURNING id + `, userID, strings.TrimSpace(req.Name)).Scan(&boardID) + + if err != nil { + log.Printf("Error creating shopping board: %v", err) + sendErrorWithCORS(w, "Error creating board", http.StatusInternalServerError) + return + } + + board := ShoppingBoard{ + ID: boardID, + OwnerID: userID, + Name: strings.TrimSpace(req.Name), + InviteEnabled: false, + MemberCount: 0, + IsOwner: true, + CreatedAt: time.Now(), + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(board) +} + +// getShoppingBoardHandler возвращает детали доски покупок +func (a *App) getShoppingBoardHandler(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 + } + + vars := mux.Vars(r) + boardID, err := strconv.Atoi(vars["id"]) + if err != nil { + sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest) + return + } + + var board ShoppingBoard + var inviteToken sql.NullString + + err = a.DB.QueryRow(` + SELECT + sb.id, + sb.owner_id, + COALESCE(u.name, u.email) as owner_name, + sb.name, + sb.invite_enabled, + sb.invite_token, + sb.created_at, + (SELECT COUNT(*) FROM shopping_board_members sbm WHERE sbm.board_id = sb.id) as member_count + FROM shopping_boards sb + JOIN users u ON sb.owner_id = u.id + WHERE sb.id = $1 AND sb.deleted = FALSE + `, boardID).Scan( + &board.ID, + &board.OwnerID, + &board.OwnerName, + &board.Name, + &board.InviteEnabled, + &inviteToken, + &board.CreatedAt, + &board.MemberCount, + ) + + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "Board not found", http.StatusNotFound) + return + } + if err != nil { + log.Printf("Error getting shopping board: %v", err) + sendErrorWithCORS(w, "Error getting board", http.StatusInternalServerError) + return + } + + board.IsOwner = board.OwnerID == userID + + if !board.IsOwner { + var isMember bool + a.DB.QueryRow(` + SELECT EXISTS(SELECT 1 FROM shopping_board_members WHERE board_id = $1 AND user_id = $2) + `, boardID, userID).Scan(&isMember) + + if !isMember { + sendErrorWithCORS(w, "Access denied", http.StatusForbidden) + return + } + } + + if board.IsOwner && inviteToken.Valid { + board.InviteToken = &inviteToken.String + baseURL := getEnv("WEBHOOK_BASE_URL", "") + if baseURL != "" { + url := baseURL + "/shopping-invite/" + inviteToken.String + board.InviteURL = &url + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(board) +} + +// updateShoppingBoardHandler обновляет доску покупок (только владелец) +func (a *App) updateShoppingBoardHandler(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 + } + + vars := mux.Vars(r) + boardID, err := strconv.Atoi(vars["id"]) + if err != nil { + sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest) + return + } + + var ownerID int + err = a.DB.QueryRow(`SELECT owner_id FROM shopping_boards WHERE id = $1 AND deleted = FALSE`, boardID).Scan(&ownerID) + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "Board not found", http.StatusNotFound) + return + } + if ownerID != userID { + sendErrorWithCORS(w, "Only owner can update board", http.StatusForbidden) + return + } + + var req BoardRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) + return + } + + if strings.TrimSpace(req.Name) != "" { + _, err = a.DB.Exec(`UPDATE shopping_boards SET name = $1, updated_at = NOW() WHERE id = $2`, + strings.TrimSpace(req.Name), boardID) + if err != nil { + log.Printf("Error updating shopping board name: %v", err) + } + } + + if req.InviteEnabled != nil { + if *req.InviteEnabled { + var currentToken sql.NullString + a.DB.QueryRow(`SELECT invite_token FROM shopping_boards WHERE id = $1`, boardID).Scan(¤tToken) + + if !currentToken.Valid || currentToken.String == "" { + token := generateInviteToken() + _, err = a.DB.Exec(`UPDATE shopping_boards SET invite_enabled = TRUE, invite_token = $1, updated_at = NOW() WHERE id = $2`, + token, boardID) + } else { + _, err = a.DB.Exec(`UPDATE shopping_boards SET invite_enabled = TRUE, updated_at = NOW() WHERE id = $1`, boardID) + } + } else { + _, err = a.DB.Exec(`UPDATE shopping_boards SET invite_enabled = FALSE, updated_at = NOW() WHERE id = $1`, boardID) + } + if err != nil { + log.Printf("Error updating shopping board invite_enabled: %v", err) + } + } + + var board ShoppingBoard + var inviteToken sql.NullString + + a.DB.QueryRow(` + SELECT + sb.id, sb.owner_id, COALESCE(u.name, u.email), sb.name, sb.invite_enabled, sb.invite_token, sb.created_at, + (SELECT COUNT(*) FROM shopping_board_members sbm WHERE sbm.board_id = sb.id) + FROM shopping_boards sb + JOIN users u ON sb.owner_id = u.id + WHERE sb.id = $1 + `, boardID).Scan(&board.ID, &board.OwnerID, &board.OwnerName, &board.Name, &board.InviteEnabled, &inviteToken, &board.CreatedAt, &board.MemberCount) + + board.IsOwner = true + if inviteToken.Valid { + board.InviteToken = &inviteToken.String + baseURL := getEnv("WEBHOOK_BASE_URL", "") + if baseURL != "" { + url := baseURL + "/shopping-invite/" + inviteToken.String + board.InviteURL = &url + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(board) +} + +// deleteShoppingBoardHandler удаляет доску покупок (только владелец) +func (a *App) deleteShoppingBoardHandler(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 + } + + vars := mux.Vars(r) + boardID, err := strconv.Atoi(vars["id"]) + if err != nil { + sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest) + return + } + + var ownerID int + err = a.DB.QueryRow(`SELECT owner_id FROM shopping_boards WHERE id = $1 AND deleted = FALSE`, boardID).Scan(&ownerID) + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "Board not found", http.StatusNotFound) + return + } + if ownerID != userID { + sendErrorWithCORS(w, "Only owner can delete board", http.StatusForbidden) + return + } + + _, err = a.DB.Exec(`UPDATE shopping_boards SET deleted = TRUE, updated_at = NOW() WHERE id = $1`, boardID) + if err != nil { + log.Printf("Error deleting shopping board: %v", err) + sendErrorWithCORS(w, "Error deleting board", http.StatusInternalServerError) + return + } + + _, err = a.DB.Exec(`UPDATE shopping_items SET deleted = TRUE, updated_at = NOW() WHERE board_id = $1`, boardID) + if err != nil { + log.Printf("Error deleting shopping board items: %v", err) + } + + w.WriteHeader(http.StatusNoContent) +} + +// regenerateShoppingBoardInviteHandler перегенерирует invite token для доски покупок +func (a *App) regenerateShoppingBoardInviteHandler(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 + } + + vars := mux.Vars(r) + boardID, err := strconv.Atoi(vars["id"]) + if err != nil { + sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest) + return + } + + var ownerID int + err = a.DB.QueryRow(`SELECT owner_id FROM shopping_boards WHERE id = $1 AND deleted = FALSE`, boardID).Scan(&ownerID) + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "Board not found", http.StatusNotFound) + return + } + if ownerID != userID { + sendErrorWithCORS(w, "Only owner can regenerate invite", http.StatusForbidden) + return + } + + token := generateInviteToken() + _, err = a.DB.Exec(`UPDATE shopping_boards SET invite_token = $1, invite_enabled = TRUE, updated_at = NOW() WHERE id = $2`, + token, boardID) + if err != nil { + log.Printf("Error regenerating shopping invite token: %v", err) + sendErrorWithCORS(w, "Error regenerating invite", http.StatusInternalServerError) + return + } + + baseURL := getEnv("WEBHOOK_BASE_URL", "") + inviteURL := "" + if baseURL != "" { + inviteURL = baseURL + "/shopping-invite/" + token + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "invite_token": token, + "invite_url": inviteURL, + }) +} + +// getShoppingBoardMembersHandler возвращает список участников доски покупок +func (a *App) getShoppingBoardMembersHandler(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 + } + + vars := mux.Vars(r) + boardID, err := strconv.Atoi(vars["id"]) + if err != nil { + sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest) + return + } + + var ownerID int + err = a.DB.QueryRow(`SELECT owner_id FROM shopping_boards WHERE id = $1 AND deleted = FALSE`, boardID).Scan(&ownerID) + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "Board not found", http.StatusNotFound) + return + } + if ownerID != userID { + sendErrorWithCORS(w, "Only owner can view members", http.StatusForbidden) + return + } + + members := []BoardMember{} + rows, err := a.DB.Query(` + SELECT sbm.id, sbm.user_id, COALESCE(u.name, '') as name, u.email, sbm.joined_at + FROM shopping_board_members sbm + JOIN users u ON sbm.user_id = u.id + WHERE sbm.board_id = $1 + ORDER BY sbm.joined_at DESC + `, boardID) + if err != nil { + log.Printf("Error getting shopping board members: %v", err) + sendErrorWithCORS(w, "Error getting members", http.StatusInternalServerError) + return + } + defer rows.Close() + + for rows.Next() { + var member BoardMember + err := rows.Scan(&member.ID, &member.UserID, &member.Name, &member.Email, &member.JoinedAt) + if err != nil { + log.Printf("Error scanning shopping board member: %v", err) + continue + } + members = append(members, member) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(members) +} + +// removeShoppingBoardMemberHandler удаляет участника из доски покупок +func (a *App) removeShoppingBoardMemberHandler(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 + } + + vars := mux.Vars(r) + boardID, err := strconv.Atoi(vars["id"]) + if err != nil { + sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest) + return + } + memberUserID, err := strconv.Atoi(vars["userId"]) + if err != nil { + sendErrorWithCORS(w, "Invalid user ID", http.StatusBadRequest) + return + } + + var ownerID int + err = a.DB.QueryRow(`SELECT owner_id FROM shopping_boards WHERE id = $1 AND deleted = FALSE`, boardID).Scan(&ownerID) + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "Board not found", http.StatusNotFound) + return + } + if ownerID != userID { + sendErrorWithCORS(w, "Only owner can remove members", http.StatusForbidden) + return + } + + _, err = a.DB.Exec(`DELETE FROM shopping_board_members WHERE board_id = $1 AND user_id = $2`, boardID, memberUserID) + if err != nil { + log.Printf("Error removing shopping board member: %v", err) + sendErrorWithCORS(w, "Error removing member", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// leaveShoppingBoardHandler позволяет участнику выйти из доски покупок +func (a *App) leaveShoppingBoardHandler(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 + } + + vars := mux.Vars(r) + boardID, err := strconv.Atoi(vars["id"]) + if err != nil { + sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest) + return + } + + var ownerID int + err = a.DB.QueryRow(`SELECT owner_id FROM shopping_boards WHERE id = $1 AND deleted = FALSE`, boardID).Scan(&ownerID) + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "Board not found", http.StatusNotFound) + return + } + if ownerID == userID { + sendErrorWithCORS(w, "Owner cannot leave board, delete it instead", http.StatusBadRequest) + return + } + + _, err = a.DB.Exec(`DELETE FROM shopping_board_members WHERE board_id = $1 AND user_id = $2`, boardID, userID) + if err != nil { + log.Printf("Error leaving shopping board: %v", err) + sendErrorWithCORS(w, "Error leaving board", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// getShoppingBoardInviteInfoHandler возвращает информацию о доске покупок по invite token +func (a *App) getShoppingBoardInviteInfoHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + setCORSHeaders(w) + w.WriteHeader(http.StatusOK) + return + } + setCORSHeaders(w) + + vars := mux.Vars(r) + token := vars["token"] + + var info BoardInviteInfo + var ownerName string + err := a.DB.QueryRow(` + SELECT + sb.id, + sb.name, + COALESCE(u.name, u.email) as owner_name, + (SELECT COUNT(*) FROM shopping_board_members sbm WHERE sbm.board_id = sb.id) as member_count + FROM shopping_boards sb + JOIN users u ON sb.owner_id = u.id + WHERE sb.invite_token = $1 AND sb.invite_enabled = TRUE AND sb.deleted = FALSE + `, token).Scan(&info.BoardID, &info.Name, &ownerName, &info.MemberCount) + + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "Invalid or expired invite link", http.StatusNotFound) + return + } + if err != nil { + log.Printf("Error getting shopping invite info: %v", err) + sendErrorWithCORS(w, "Error getting invite info", http.StatusInternalServerError) + return + } + + info.OwnerName = ownerName + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(info) +} + +// joinShoppingBoardHandler присоединяет пользователя к доске покупок по invite token +func (a *App) joinShoppingBoardHandler(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 + } + + vars := mux.Vars(r) + token := vars["token"] + + var boardID, ownerID int + var boardName, ownerName string + err := a.DB.QueryRow(` + SELECT sb.id, sb.owner_id, sb.name, COALESCE(u.name, u.email) + FROM shopping_boards sb + JOIN users u ON sb.owner_id = u.id + WHERE sb.invite_token = $1 AND sb.invite_enabled = TRUE AND sb.deleted = FALSE + `, token).Scan(&boardID, &ownerID, &boardName, &ownerName) + + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "Invalid or expired invite link", http.StatusNotFound) + return + } + if err != nil { + log.Printf("Error getting shopping board by token: %v", err) + sendErrorWithCORS(w, "Error joining board", http.StatusInternalServerError) + return + } + + if ownerID == userID { + sendErrorWithCORS(w, "You are the owner of this board", http.StatusBadRequest) + return + } + + var exists bool + a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM shopping_board_members WHERE board_id = $1 AND user_id = $2)`, + boardID, userID).Scan(&exists) + if exists { + sendErrorWithCORS(w, "You are already a member of this board", http.StatusBadRequest) + return + } + + _, err = a.DB.Exec(`INSERT INTO shopping_board_members (board_id, user_id) VALUES ($1, $2)`, boardID, userID) + if err != nil { + log.Printf("Error joining shopping board: %v", err) + sendErrorWithCORS(w, "Error joining board", http.StatusInternalServerError) + return + } + + var memberCount int + a.DB.QueryRow(`SELECT COUNT(*) FROM shopping_board_members WHERE board_id = $1`, boardID).Scan(&memberCount) + + board := ShoppingBoard{ + ID: boardID, + OwnerID: ownerID, + OwnerName: ownerName, + Name: boardName, + InviteEnabled: true, + MemberCount: memberCount, + IsOwner: false, + } + + response := ShoppingJoinBoardResponse{ + Board: board, + Message: "Вы успешно присоединились к доске!", + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(response) +} + +// getShoppingItemsHandler возвращает все товары на доске +func (a *App) getShoppingItemsHandler(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 + } + + vars := mux.Vars(r) + boardID, err := strconv.Atoi(vars["boardId"]) + if err != nil { + sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest) + return + } + + // Проверяем доступ + var ownerID int + err = a.DB.QueryRow(`SELECT owner_id FROM shopping_boards WHERE id = $1 AND deleted = FALSE`, boardID).Scan(&ownerID) + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "Board not found", http.StatusNotFound) + return + } + if ownerID != userID { + var isMember bool + a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM shopping_board_members WHERE board_id = $1 AND user_id = $2)`, + boardID, userID).Scan(&isMember) + if !isMember { + sendErrorWithCORS(w, "Access denied", http.StatusForbidden) + return + } + } + + items := []ShoppingItem{} + rows, err := a.DB.Query(` + SELECT + si.id, si.user_id, si.board_id, si.author_id, si.name, si.group_name, + si.volume_base, si.repetition_period::text, si.next_show_at, si.completed, + si.last_completed_at, si.created_at + FROM shopping_items si + WHERE si.board_id = $1 AND si.deleted = FALSE + ORDER BY si.created_at ASC + `, boardID) + if err != nil { + log.Printf("Error getting shopping items: %v", err) + sendErrorWithCORS(w, "Error getting items", http.StatusInternalServerError) + return + } + defer rows.Close() + + for rows.Next() { + var item ShoppingItem + var groupName sql.NullString + var repetitionPeriod sql.NullString + var nextShowAt sql.NullTime + var lastCompletedAt sql.NullTime + var createdAt time.Time + + err := rows.Scan( + &item.ID, &item.UserID, &item.BoardID, &item.AuthorID, &item.Name, &groupName, + &item.VolumeBase, &repetitionPeriod, &nextShowAt, &item.Completed, + &lastCompletedAt, &createdAt, + ) + if err != nil { + log.Printf("Error scanning shopping item: %v", err) + continue + } + + if groupName.Valid { + item.GroupName = &groupName.String + } + if repetitionPeriod.Valid { + item.RepetitionPeriod = &repetitionPeriod.String + } + if nextShowAt.Valid { + s := nextShowAt.Time.Format(time.RFC3339) + item.NextShowAt = &s + } + if lastCompletedAt.Valid { + s := lastCompletedAt.Time.Format(time.RFC3339) + item.LastCompletedAt = &s + } + item.CreatedAt = createdAt.Format(time.RFC3339) + + items = append(items, item) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(items) +} + +// createShoppingItemHandler создаёт новый товар на доске +func (a *App) createShoppingItemHandler(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 + } + + vars := mux.Vars(r) + boardID, err := strconv.Atoi(vars["boardId"]) + if err != nil { + sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest) + return + } + + // Проверяем доступ + var boardOwnerID int + err = a.DB.QueryRow(`SELECT owner_id FROM shopping_boards WHERE id = $1 AND deleted = FALSE`, boardID).Scan(&boardOwnerID) + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "Board not found", http.StatusNotFound) + return + } + if boardOwnerID != userID { + var isMember bool + a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM shopping_board_members WHERE board_id = $1 AND user_id = $2)`, + boardID, userID).Scan(&isMember) + if !isMember { + sendErrorWithCORS(w, "Access denied", http.StatusForbidden) + return + } + } + + var req ShoppingItemRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) + return + } + + if strings.TrimSpace(req.Name) == "" { + sendErrorWithCORS(w, "Name is required", http.StatusBadRequest) + return + } + + volumeBase := 1.0 + if req.VolumeBase != nil && *req.VolumeBase > 0 { + volumeBase = *req.VolumeBase + } + + var itemID int + err = a.DB.QueryRow(` + INSERT INTO shopping_items (user_id, board_id, author_id, name, group_name, volume_base, repetition_period) + VALUES ($1, $2, $3, $4, $5, $6, $7::interval) + RETURNING id + `, boardOwnerID, boardID, userID, strings.TrimSpace(req.Name), req.GroupName, volumeBase, req.RepetitionPeriod).Scan(&itemID) + + if err != nil { + log.Printf("Error creating shopping item: %v", err) + sendErrorWithCORS(w, "Error creating item", http.StatusInternalServerError) + return + } + + item := ShoppingItem{ + ID: itemID, + UserID: boardOwnerID, + BoardID: boardID, + AuthorID: userID, + Name: strings.TrimSpace(req.Name), + GroupName: req.GroupName, + VolumeBase: volumeBase, + RepetitionPeriod: req.RepetitionPeriod, + Completed: 0, + CreatedAt: time.Now().Format(time.RFC3339), + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(item) +} + +// getShoppingItemHandler возвращает детали товара +func (a *App) getShoppingItemHandler(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 + } + + vars := mux.Vars(r) + itemID, err := strconv.Atoi(vars["id"]) + if err != nil { + sendErrorWithCORS(w, "Invalid item ID", http.StatusBadRequest) + return + } + + var item ShoppingItem + var groupName sql.NullString + var repetitionPeriod sql.NullString + var nextShowAt sql.NullTime + var lastCompletedAt sql.NullTime + var createdAt time.Time + + err = a.DB.QueryRow(` + SELECT + si.id, si.user_id, si.board_id, si.author_id, si.name, si.group_name, + si.volume_base, si.repetition_period::text, si.next_show_at, si.completed, + si.last_completed_at, si.created_at + FROM shopping_items si + WHERE si.id = $1 AND si.deleted = FALSE + `, itemID).Scan( + &item.ID, &item.UserID, &item.BoardID, &item.AuthorID, &item.Name, &groupName, + &item.VolumeBase, &repetitionPeriod, &nextShowAt, &item.Completed, + &lastCompletedAt, &createdAt, + ) + + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "Item not found", http.StatusNotFound) + return + } + if err != nil { + log.Printf("Error getting shopping item: %v", err) + sendErrorWithCORS(w, "Error getting item", http.StatusInternalServerError) + return + } + + // Проверяем доступ через доску + var ownerID int + a.DB.QueryRow(`SELECT owner_id FROM shopping_boards WHERE id = $1`, item.BoardID).Scan(&ownerID) + if ownerID != userID { + var isMember bool + a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM shopping_board_members WHERE board_id = $1 AND user_id = $2)`, + item.BoardID, userID).Scan(&isMember) + if !isMember { + sendErrorWithCORS(w, "Access denied", http.StatusForbidden) + return + } + } + + if groupName.Valid { + item.GroupName = &groupName.String + } + if repetitionPeriod.Valid { + item.RepetitionPeriod = &repetitionPeriod.String + } + if nextShowAt.Valid { + s := nextShowAt.Time.Format(time.RFC3339) + item.NextShowAt = &s + } + if lastCompletedAt.Valid { + s := lastCompletedAt.Time.Format(time.RFC3339) + item.LastCompletedAt = &s + } + item.CreatedAt = createdAt.Format(time.RFC3339) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(item) +} + +// updateShoppingItemHandler обновляет товар +func (a *App) updateShoppingItemHandler(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 + } + + vars := mux.Vars(r) + itemID, err := strconv.Atoi(vars["id"]) + if err != nil { + sendErrorWithCORS(w, "Invalid item ID", http.StatusBadRequest) + return + } + + // Проверяем что товар существует и получаем board_id + var boardID int + err = a.DB.QueryRow(`SELECT board_id FROM shopping_items WHERE id = $1 AND deleted = FALSE`, itemID).Scan(&boardID) + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "Item not found", http.StatusNotFound) + return + } + + // Проверяем доступ + var ownerID int + a.DB.QueryRow(`SELECT owner_id FROM shopping_boards WHERE id = $1`, boardID).Scan(&ownerID) + if ownerID != userID { + var isMember bool + a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM shopping_board_members WHERE board_id = $1 AND user_id = $2)`, + boardID, userID).Scan(&isMember) + if !isMember { + sendErrorWithCORS(w, "Access denied", http.StatusForbidden) + return + } + } + + var req ShoppingItemRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) + return + } + + if strings.TrimSpace(req.Name) == "" { + sendErrorWithCORS(w, "Name is required", http.StatusBadRequest) + return + } + + volumeBase := 1.0 + if req.VolumeBase != nil && *req.VolumeBase > 0 { + volumeBase = *req.VolumeBase + } + + _, err = a.DB.Exec(` + UPDATE shopping_items + SET name = $1, group_name = $2, volume_base = $3, repetition_period = $4::interval, updated_at = NOW() + WHERE id = $5 + `, strings.TrimSpace(req.Name), req.GroupName, volumeBase, req.RepetitionPeriod, itemID) + + if err != nil { + log.Printf("Error updating shopping item: %v", err) + sendErrorWithCORS(w, "Error updating item", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": "Item updated successfully", + }) +} + +// deleteShoppingItemHandler удаляет товар +func (a *App) deleteShoppingItemHandler(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 + } + + vars := mux.Vars(r) + itemID, err := strconv.Atoi(vars["id"]) + if err != nil { + sendErrorWithCORS(w, "Invalid item ID", http.StatusBadRequest) + return + } + + var boardID int + err = a.DB.QueryRow(`SELECT board_id FROM shopping_items WHERE id = $1 AND deleted = FALSE`, itemID).Scan(&boardID) + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "Item not found", http.StatusNotFound) + return + } + + var ownerID int + a.DB.QueryRow(`SELECT owner_id FROM shopping_boards WHERE id = $1`, boardID).Scan(&ownerID) + if ownerID != userID { + var isMember bool + a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM shopping_board_members WHERE board_id = $1 AND user_id = $2)`, + boardID, userID).Scan(&isMember) + if !isMember { + sendErrorWithCORS(w, "Access denied", http.StatusForbidden) + return + } + } + + _, err = a.DB.Exec(`UPDATE shopping_items SET deleted = TRUE, updated_at = NOW() WHERE id = $1`, itemID) + if err != nil { + log.Printf("Error deleting shopping item: %v", err) + sendErrorWithCORS(w, "Error deleting item", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// completeShoppingItemHandler выполняет товар (покупку) +func (a *App) completeShoppingItemHandler(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 + } + + vars := mux.Vars(r) + itemID, err := strconv.Atoi(vars["id"]) + if err != nil { + sendErrorWithCORS(w, "Invalid item ID", http.StatusBadRequest) + return + } + + var req CompleteShoppingItemRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Получаем товар + var boardID int + var volumeBase float64 + var repetitionPeriod sql.NullString + err = a.DB.QueryRow(` + SELECT board_id, volume_base, repetition_period::text + FROM shopping_items WHERE id = $1 AND deleted = FALSE + `, itemID).Scan(&boardID, &volumeBase, &repetitionPeriod) + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "Item not found", http.StatusNotFound) + return + } + if err != nil { + log.Printf("Error getting shopping item for complete: %v", err) + sendErrorWithCORS(w, "Error completing item", http.StatusInternalServerError) + return + } + + // Проверяем доступ + var ownerID int + a.DB.QueryRow(`SELECT owner_id FROM shopping_boards WHERE id = $1`, boardID).Scan(&ownerID) + if ownerID != userID { + var isMember bool + a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM shopping_board_members WHERE board_id = $1 AND user_id = $2)`, + boardID, userID).Scan(&isMember) + if !isMember { + sendErrorWithCORS(w, "Access denied", http.StatusForbidden) + return + } + } + + actualVolume := volumeBase + if req.Volume != nil && *req.Volume > 0 { + actualVolume = *req.Volume + } + + now := time.Now() + + if repetitionPeriod.Valid && repetitionPeriod.String != "" { + // Рассчитываем next_show_at с учётом объёма + multiplier := actualVolume / volumeBase + baseNext := calculateNextShowAtFromRepetitionPeriod(repetitionPeriod.String, now) + if baseNext != nil { + // Применяем множитель: сдвигаем пропорционально объёму + baseDuration := baseNext.Sub(time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())) + adjustedDuration := time.Duration(float64(baseDuration) * multiplier) + nextShowAt := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).Add(adjustedDuration) + + _, err = a.DB.Exec(` + UPDATE shopping_items + SET completed = completed + 1, last_completed_at = $1, next_show_at = $2, updated_at = NOW() + WHERE id = $3 + `, now, nextShowAt, itemID) + } else { + _, err = a.DB.Exec(` + UPDATE shopping_items + SET completed = completed + 1, last_completed_at = $1, updated_at = NOW() + WHERE id = $2 + `, now, itemID) + } + } else { + // Одноразовый товар - помечаем удалённым + _, err = a.DB.Exec(` + UPDATE shopping_items + SET completed = completed + 1, last_completed_at = $1, deleted = TRUE, updated_at = NOW() + WHERE id = $2 + `, now, itemID) + } + + if err != nil { + log.Printf("Error completing shopping item: %v", err) + sendErrorWithCORS(w, "Error completing item", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": "Item completed successfully", + }) +} + +// postponeShoppingItemHandler переносит товар на указанную дату +func (a *App) postponeShoppingItemHandler(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 + } + + vars := mux.Vars(r) + itemID, err := strconv.Atoi(vars["id"]) + if err != nil { + sendErrorWithCORS(w, "Invalid item ID", http.StatusBadRequest) + return + } + + var req PostponeTaskRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Проверяем что товар существует и получаем board_id + var boardID int + err = a.DB.QueryRow(`SELECT board_id FROM shopping_items WHERE id = $1 AND deleted = FALSE`, itemID).Scan(&boardID) + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "Item not found", http.StatusNotFound) + return + } + + // Проверяем доступ + var ownerID int + a.DB.QueryRow(`SELECT owner_id FROM shopping_boards WHERE id = $1`, boardID).Scan(&ownerID) + if ownerID != userID { + var isMember bool + a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM shopping_board_members WHERE board_id = $1 AND user_id = $2)`, + boardID, userID).Scan(&isMember) + if !isMember { + sendErrorWithCORS(w, "Access denied", http.StatusForbidden) + return + } + } + + var nextShowAtValue interface{} + if req.NextShowAt == nil || *req.NextShowAt == "" { + nextShowAtValue = nil + } else { + nextShowAt, err := time.Parse(time.RFC3339, *req.NextShowAt) + if err != nil { + sendErrorWithCORS(w, "Invalid date format. Use RFC3339 format", http.StatusBadRequest) + return + } + nextShowAtValue = nextShowAt + } + + _, err = a.DB.Exec(` + UPDATE shopping_items + SET next_show_at = $1, updated_at = NOW() + WHERE id = $2 + `, nextShowAtValue, itemID) + if err != nil { + log.Printf("Error postponing shopping item: %v", err) + sendErrorWithCORS(w, "Error postponing item", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": "Item postponed successfully", + }) +} + +// getShoppingGroupSuggestionsHandler возвращает уникальные группы товаров для автодополнения +func (a *App) getShoppingGroupSuggestionsHandler(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 + } + + groups := []string{} + rows, err := a.DB.Query(` + SELECT DISTINCT si.group_name + FROM shopping_items si + JOIN shopping_boards sb ON si.board_id = sb.id + LEFT JOIN shopping_board_members sbm ON sb.id = sbm.board_id + WHERE si.group_name IS NOT NULL + AND si.group_name != '' + AND si.deleted = FALSE + AND sb.deleted = FALSE + AND (sb.owner_id = $1 OR sbm.user_id = $1) + ORDER BY si.group_name + `, userID) + if err != nil { + log.Printf("Error getting shopping group suggestions: %v", err) + sendErrorWithCORS(w, "Error getting groups", http.StatusInternalServerError) + return + } + defer rows.Close() + + for rows.Next() { + var groupName string + if err := rows.Scan(&groupName); err != nil { + log.Printf("Error scanning shopping group name: %v", err) + continue + } + groups = append(groups, groupName) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(groups) +} diff --git a/play-life-backend/migrations/000026_shopping_list.down.sql b/play-life-backend/migrations/000026_shopping_list.down.sql new file mode 100644 index 0000000..5e4b1e0 --- /dev/null +++ b/play-life-backend/migrations/000026_shopping_list.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS shopping_items; +DROP TABLE IF EXISTS shopping_board_members; +DROP TABLE IF EXISTS shopping_boards; diff --git a/play-life-backend/migrations/000026_shopping_list.up.sql b/play-life-backend/migrations/000026_shopping_list.up.sql new file mode 100644 index 0000000..95a80ee --- /dev/null +++ b/play-life-backend/migrations/000026_shopping_list.up.sql @@ -0,0 +1,50 @@ +-- Shopping boards (аналог wishlist_boards) +CREATE TABLE shopping_boards ( + id SERIAL PRIMARY KEY, + owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + invite_token VARCHAR(64) UNIQUE, + invite_enabled BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN DEFAULT FALSE +); + +CREATE INDEX idx_shopping_boards_owner_id ON shopping_boards(owner_id); +CREATE INDEX idx_shopping_boards_invite_token ON shopping_boards(invite_token) WHERE invite_token IS NOT NULL; +CREATE INDEX idx_shopping_boards_owner_deleted ON shopping_boards(owner_id, deleted); + +-- Shopping board members (аналог wishlist_board_members) +CREATE TABLE shopping_board_members ( + id SERIAL PRIMARY KEY, + board_id INTEGER NOT NULL REFERENCES shopping_boards(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + joined_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT unique_shopping_board_member UNIQUE (board_id, user_id) +); + +CREATE INDEX idx_shopping_board_members_board_id ON shopping_board_members(board_id); +CREATE INDEX idx_shopping_board_members_user_id ON shopping_board_members(user_id); + +-- Shopping items (товары) +CREATE TABLE shopping_items ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + board_id INTEGER NOT NULL REFERENCES shopping_boards(id) ON DELETE CASCADE, + author_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + group_name VARCHAR(255), + volume_base NUMERIC(10,4) NOT NULL DEFAULT 1, + repetition_period INTERVAL, + next_show_at TIMESTAMP WITH TIME ZONE, + completed INTEGER DEFAULT 0, + last_completed_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN DEFAULT FALSE +); + +CREATE INDEX idx_shopping_items_board_id ON shopping_items(board_id); +CREATE INDEX idx_shopping_items_user_id ON shopping_items(user_id); +CREATE INDEX idx_shopping_items_deleted ON shopping_items(deleted); +CREATE INDEX idx_shopping_items_next_show_at ON shopping_items(next_show_at); diff --git a/play-life-web/package.json b/play-life-web/package.json index 4022dff..00e2ab4 100644 --- a/play-life-web/package.json +++ b/play-life-web/package.json @@ -1,6 +1,6 @@ { "name": "play-life-web", - "version": "6.3.10", + "version": "6.4.0", "type": "module", "scripts": { "dev": "vite", diff --git a/play-life-web/src/App.jsx b/play-life-web/src/App.jsx index ceae6fa..e39b29d 100644 --- a/play-life-web/src/App.jsx +++ b/play-life-web/src/App.jsx @@ -14,6 +14,10 @@ import WishlistForm from './components/WishlistForm' import WishlistDetail from './components/WishlistDetail' import BoardForm from './components/BoardForm' import BoardJoinPreview from './components/BoardJoinPreview' +import ShoppingList from './components/ShoppingList' +import ShoppingItemForm from './components/ShoppingItemForm' +import ShoppingBoardForm from './components/ShoppingBoardForm' +import ShoppingBoardJoinPreview from './components/ShoppingBoardJoinPreview' import TodoistIntegration from './components/TodoistIntegration' import TelegramIntegration from './components/TelegramIntegration' import FitbitIntegration from './components/FitbitIntegration' @@ -29,8 +33,8 @@ const CURRENT_WEEK_API_URL = '/playlife-feed' const FULL_STATISTICS_API_URL = '/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b' // Определяем основные табы (без крестика) и глубокие табы (с крестиком) -const mainTabs = ['current', 'tasks', 'wishlist', 'profile'] -const deepTabs = ['add-words', 'test', 'task-form', 'wishlist-form', 'wishlist-detail', 'board-form', 'board-join', 'words', 'dictionaries', 'todoist-integration', 'telegram-integration', 'fitbit-integration', 'full', 'priorities', 'tracking', 'tracking-access', 'tracking-invite'] +const mainTabs = ['current', 'tasks', 'wishlist', 'shopping', 'profile'] +const deepTabs = ['add-words', 'test', 'task-form', 'wishlist-form', 'wishlist-detail', 'board-form', 'board-join', 'words', 'dictionaries', 'todoist-integration', 'telegram-integration', 'fitbit-integration', 'full', 'priorities', 'tracking', 'tracking-access', 'tracking-invite', 'shopping-item-form', 'shopping-board-form', 'shopping-board-join'] /** * Гарантирует базовую запись истории для главного экрана перед глубоким табом. @@ -77,8 +81,12 @@ function AppContent() { tracking: false, 'tracking-access': false, 'tracking-invite': false, + shopping: false, + 'shopping-item-form': false, + 'shopping-board-form': false, + 'shopping-board-join': false, }) - + // Отслеживаем, какие табы уже были загружены (для предотвращения повторных загрузок) const [tabsInitialized, setTabsInitialized] = useState({ current: false, @@ -102,6 +110,10 @@ function AppContent() { tracking: false, 'tracking-access': false, 'tracking-invite': false, + shopping: false, + 'shopping-item-form': false, + 'shopping-board-form': false, + 'shopping-board-join': false, }) // Параметры для навигации между вкладками @@ -149,6 +161,7 @@ function AppContent() { const [dictionariesRefreshTrigger, setDictionariesRefreshTrigger] = useState(0) const [wordsRefreshTrigger, setWordsRefreshTrigger] = useState(0) const [wishlistRefreshTrigger, setWishlistRefreshTrigger] = useState(0) + const [shoppingRefreshTrigger, setShoppingRefreshTrigger] = useState(0) @@ -227,6 +240,20 @@ function AppContent() { } } + // Проверяем путь /shopping-invite/:token для присоединения к shopping доске + if (path.startsWith('/shopping-invite/')) { + const token = path.replace('/shopping-invite/', '') + if (token) { + const url = '/?tab=shopping-board-join&inviteToken=' + token + ensureBaseHistory('shopping-board-join', { inviteToken: token }, url) + setActiveTab('shopping-board-join') + setLoadedTabs(prev => ({ ...prev, 'shopping-board-join': true })) + setTabParams({ inviteToken: token }) + setIsInitialized(true) + return + } + } + // Проверяем путь /tracking/invite/:token if (path.startsWith('/tracking/invite/')) { const token = path.replace('/tracking/invite/', '') @@ -262,8 +289,8 @@ function AppContent() { // Проверяем URL только для глубоких табов const tabFromUrl = urlParams.get('tab') - const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'board-form', 'board-join', 'profile', 'todoist-integration', 'telegram-integration', 'fitbit-integration', 'tracking', 'tracking-access', 'tracking-invite'] - + const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'board-form', 'board-join', 'profile', 'todoist-integration', 'telegram-integration', 'fitbit-integration', 'tracking', 'tracking-access', 'tracking-invite', 'shopping', 'shopping-item-form', 'shopping-board-form', 'shopping-board-join'] + if (tabFromUrl && validTabs.includes(tabFromUrl) && deepTabs.includes(tabFromUrl) && window.history.length > 1) { // Восстанавливаем глубокий таб из URL только если есть история (не рестарт PWA) const params = {} @@ -754,7 +781,7 @@ function AppContent() { return } - const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'profile', 'todoist-integration', 'telegram-integration', 'tracking', 'tracking-access', 'tracking-invite'] + const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'profile', 'todoist-integration', 'telegram-integration', 'tracking', 'tracking-access', 'tracking-invite', 'shopping', 'shopping-item-form', 'shopping-board-form', 'shopping-board-join'] // Проверяем state текущей записи истории (куда мы вернулись) if (event.state && event.state.tab) { @@ -858,8 +885,8 @@ function AppContent() { setSelectedProject(null) setTabParams({}) updateUrl('full', {}, activeTab) - } else if (tab !== activeTab || tab === 'task-form' || tab === 'wishlist-form' || (tab === 'words' && Object.keys(params).length > 0)) { - // Для task-form и wishlist-form всегда обновляем параметры, даже если это тот же таб + } else if (tab !== activeTab || tab === 'task-form' || tab === 'wishlist-form' || tab === 'shopping-item-form' || (tab === 'words' && Object.keys(params).length > 0)) { + // Для task-form, wishlist-form и shopping-item-form всегда обновляем параметры, даже если это тот же таб markTabAsLoaded(tab) // Определяем, является ли текущий таб глубоким @@ -889,7 +916,7 @@ function AppContent() { // Проверяем, была ли последняя запись в истории от модального окна const currentState = window.history.state || {} const isFromModal = currentState.modalOpen === true - const isNavigatingToForm = tab === 'task-form' || tab === 'wishlist-form' + const isNavigatingToForm = tab === 'task-form' || tab === 'wishlist-form' || tab === 'shopping-item-form' if (isFromModal && isNavigatingToForm) { // Заменяем запись модального окна на запись формы редактирования @@ -945,7 +972,7 @@ function AppContent() { if ((tab === 'wishlist-form' || tab === 'wishlist-detail') && activeTab !== tab) { setPreviousTab(activeTab) } - + // Обновляем список желаний при возврате из экрана редактирования if (activeTab === 'wishlist-form' && tab !== 'wishlist-form') { // Сохраняем boardId из параметров или текущих tabParams @@ -958,13 +985,27 @@ function AppContent() { setWishlistRefreshTrigger(prev => prev + 1) } } - + // Обновляем список желаний при возврате из экрана детализации if (activeTab === 'wishlist-detail' && tab !== 'wishlist-detail') { if (tab === 'wishlist') { setWishlistRefreshTrigger(prev => prev + 1) } } + + // Сохраняем предыдущий таб при открытии shopping-item-form + if (tab === 'shopping-item-form' && activeTab !== tab) { + setPreviousTab(activeTab) + } + + // Обновляем список товаров при возврате из экрана редактирования + if ((activeTab === 'shopping-item-form' || activeTab === 'shopping-board-form') && tab === 'shopping') { + const savedBoardId = params.boardId || tabParams.boardId + if (savedBoardId) { + setTabParams(prev => ({ ...prev, boardId: savedBoardId })) + } + setShoppingRefreshTrigger(prev => prev + 1) + } // Загрузка данных произойдет в useEffect при изменении activeTab } } @@ -1063,7 +1104,7 @@ function AppContent() { } // Определяем, нужно ли скрывать нижнюю панель (для fullscreen экранов) - const isFullscreenTab = activeTab === 'test' || activeTab === 'add-words' || activeTab === 'task-form' || activeTab === 'wishlist-form' || activeTab === 'wishlist-detail' || activeTab === 'todoist-integration' || activeTab === 'telegram-integration' || activeTab === 'fitbit-integration' || activeTab === 'full' || activeTab === 'priorities' || activeTab === 'words' || activeTab === 'dictionaries' || activeTab === 'tracking' || activeTab === 'tracking-access' || activeTab === 'tracking-invite' + const isFullscreenTab = activeTab === 'test' || activeTab === 'add-words' || activeTab === 'task-form' || activeTab === 'wishlist-form' || activeTab === 'wishlist-detail' || activeTab === 'todoist-integration' || activeTab === 'telegram-integration' || activeTab === 'fitbit-integration' || activeTab === 'full' || activeTab === 'priorities' || activeTab === 'words' || activeTab === 'dictionaries' || activeTab === 'tracking' || activeTab === 'tracking-access' || activeTab === 'tracking-invite' || activeTab === 'shopping-item-form' || activeTab === 'shopping-board-form' || activeTab === 'shopping-board-join' // Функция для получения классов скролл-контейнера для каждого таба // Каждый таб имеет свой изолированный скролл-контейнер для автоматического сохранения позиции скролла @@ -1075,7 +1116,7 @@ function AppContent() { // Определяем padding для каждого таба let paddingClasses = '' - if (tabName === 'current' || tabName === 'tasks' || tabName === 'wishlist' || tabName === 'profile') { + if (tabName === 'current' || tabName === 'tasks' || tabName === 'wishlist' || tabName === 'shopping' || tabName === 'profile') { paddingClasses = 'pb-20' } else if (tabName === 'words' || tabName === 'dictionaries') { paddingClasses = 'pb-16' @@ -1086,7 +1127,7 @@ function AppContent() { // Функция для определения отступов внутреннего контейнера const getInnerContainerClasses = (tabName) => { - if (tabName === 'tasks' || tabName === 'wishlist' || tabName === 'profile') { + if (tabName === 'tasks' || tabName === 'wishlist' || tabName === 'shopping' || tabName === 'profile') { return 'max-w-7xl mx-auto p-4 md:p-8' } if (tabName === 'current') { @@ -1301,6 +1342,59 @@ function AppContent() { )} + {loadedTabs.shopping && ( +
+
+ +
+
+ )} + + {loadedTabs['shopping-item-form'] && ( +
+
+ setShoppingRefreshTrigger(prev => prev + 1)} + /> +
+
+ )} + + {loadedTabs['shopping-board-form'] && ( +
+
+ setShoppingRefreshTrigger(prev => prev + 1)} + /> +
+
+ )} + + {loadedTabs['shopping-board-join'] && ( +
+
+ +
+
+ )} + {loadedTabs.profile && (
@@ -1423,6 +1517,42 @@ function AppContent() { )} + {/* Кнопка добавления товара (только для таба shopping) */} + {!isFullscreenTab && activeTab === 'shopping' && ( + + )} + {/* Кнопка добавления записи (только для таба current - экран прогресса) */} {!isFullscreenTab && activeTab === 'current' && (
)} + + +

{isEdit ? 'Настройки доски' : 'Новая доска'}

+ +
+
+ + setName(e.target.value)} + placeholder="Название доски" + /> +
+ + {isEdit && ( + <> +
+

Доступ по ссылке

+ + + + {inviteEnabled && inviteURL && ( +
+
+ + +
+

+ Пользователь, открывший ссылку, сможет присоединиться к доске +

+
+ )} +
+ + { + setToastMessage({ text: 'Участник удалён', type: 'success' }) + }} + /> + + )} + +
+ + Сохранить + + {isEdit && ( + + )} +
+
+ + {toastMessage && ( + setToastMessage(null)} + /> + )} +
+ ) +} + +export default ShoppingBoardForm diff --git a/play-life-web/src/components/ShoppingBoardJoinPreview.jsx b/play-life-web/src/components/ShoppingBoardJoinPreview.jsx new file mode 100644 index 0000000..6b56578 --- /dev/null +++ b/play-life-web/src/components/ShoppingBoardJoinPreview.jsx @@ -0,0 +1,149 @@ +import React, { useState, useEffect } from 'react' +import { useAuth } from './auth/AuthContext' +import './BoardJoinPreview.css' + +function ShoppingBoardJoinPreview({ inviteToken, onNavigate }) { + const { authFetch, user } = useAuth() + const [board, setBoard] = useState(null) + const [loading, setLoading] = useState(true) + const [joining, setJoining] = useState(false) + const [error, setError] = useState('') + + useEffect(() => { + if (inviteToken) { + fetchBoardInfo() + } + }, [inviteToken]) + + const fetchBoardInfo = async () => { + try { + const res = await authFetch(`/api/shopping/invite/${inviteToken}`) + + if (res.ok) { + setBoard(await res.json()) + } else { + const err = await res.json() + setError(err.error || 'Ссылка недействительна или устарела') + } + } catch (err) { + setError('Ошибка загрузки') + } finally { + setLoading(false) + } + } + + const handleJoin = async () => { + if (!user) { + sessionStorage.setItem('pendingShoppingInviteToken', inviteToken) + onNavigate('login') + return + } + + setJoining(true) + setError('') + + try { + const res = await authFetch(`/api/shopping/invite/${inviteToken}/join`, { + method: 'POST' + }) + + if (res.ok) { + const data = await res.json() + onNavigate('shopping', { boardId: data.board.id }) + } else { + const err = await res.json() + setError(err.error || 'Ошибка при присоединении') + } + } catch (err) { + setError('Ошибка при присоединении') + } finally { + setJoining(false) + } + } + + const handleGoBack = () => { + onNavigate('shopping') + } + + if (loading) { + return ( +
+
+
+

Загрузка...

+
+
+ ) + } + + if (error && !board) { + return ( +
+
+
X
+

Ошибка

+

{error}

+ +
+
+ ) + } + + return ( +
+
+

Приглашение на доску

+ +
+
{board.name}
+
+ Владелец: + {board.owner_name} +
+ {board.member_count > 0 && ( +
+ Участников: + {board.member_count} +
+ )} +
+ + {error && ( +
{error}
+ )} + + {user ? ( + + ) : ( +
+

Для присоединения необходимо войти в аккаунт

+ +
+ )} + + +
+
+ ) +} + +export default ShoppingBoardJoinPreview diff --git a/play-life-web/src/components/ShoppingItemDetail.jsx b/play-life-web/src/components/ShoppingItemDetail.jsx new file mode 100644 index 0000000..042089a --- /dev/null +++ b/play-life-web/src/components/ShoppingItemDetail.jsx @@ -0,0 +1,202 @@ +import React, { useState, useEffect, useCallback } from 'react' +import { createPortal } from 'react-dom' +import { useAuth } from './auth/AuthContext' +import LoadingError from './LoadingError' +import Toast from './Toast' +import './TaskDetail.css' + +function ShoppingItemDetail({ itemId, onClose, onRefresh, onItemCompleted, onNavigate }) { + const { authFetch } = useAuth() + const [item, setItem] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [volumeValue, setVolumeValue] = useState('') + const [isCompleting, setIsCompleting] = useState(false) + const [toastMessage, setToastMessage] = useState(null) + + const fetchItem = useCallback(async () => { + try { + setLoading(true) + setError(null) + const response = await authFetch(`/api/shopping/items/${itemId}`) + if (!response.ok) { + throw new Error('Ошибка загрузки товара') + } + const data = await response.json() + setItem(data) + } catch (err) { + setError(err.message) + } finally { + setLoading(false) + } + }, [itemId, authFetch]) + + useEffect(() => { + if (itemId) { + fetchItem() + } else { + setItem(null) + setLoading(true) + setError(null) + setVolumeValue('') + } + }, [itemId, fetchItem]) + + const handleComplete = async () => { + if (!item) return + + setIsCompleting(true) + try { + const payload = {} + if (volumeValue.trim()) { + payload.volume = parseFloat(volumeValue) + if (isNaN(payload.volume)) { + throw new Error('Неверное значение объёма') + } + } else { + payload.volume = item.volume_base + } + + const response = await authFetch(`/api/shopping/items/${itemId}/complete`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + throw new Error(errorData.error || 'Ошибка при выполнении') + } + + onItemCompleted?.() + onRefresh?.() + onClose?.() + } catch (err) { + setToastMessage({ text: err.message || 'Ошибка', type: 'error' }) + } finally { + setIsCompleting(false) + } + } + + if (!itemId) return null + + const modalContent = ( +
+
e.stopPropagation()}> +
+

{ + onClose?.(true) + onNavigate?.('shopping-item-form', { itemId: itemId, boardId: item.board_id }) + } : undefined} + style={{ cursor: item ? 'pointer' : 'default' }} + > + {loading ? 'Загрузка...' : error ? 'Ошибка' : item ? ( + <> + {item.name} + + + + + ) : 'Товар'} +

+ +
+ +
+ {loading && ( +
Загрузка...
+ )} + + {error && !loading && ( + + )} + + {!loading && !error && item && ( + <> +
+ +
+ setVolumeValue(e.target.value)} + placeholder={item.volume_base?.toString() || '1'} + className="progression-input" + /> +
+ + +
+
+
+ +
+ +
+
+
+ +
+
+
+ + )} +
+ {toastMessage && ( + setToastMessage(null)} + /> + )} +
+
+ ) + + return typeof document !== 'undefined' + ? createPortal(modalContent, document.body) + : modalContent +} + +export default ShoppingItemDetail diff --git a/play-life-web/src/components/ShoppingItemForm.css b/play-life-web/src/components/ShoppingItemForm.css new file mode 100644 index 0000000..25e9879 --- /dev/null +++ b/play-life-web/src/components/ShoppingItemForm.css @@ -0,0 +1,19 @@ +.shopping-item-form { + padding: 20px; + max-width: 600px; + margin: 0 auto; + position: relative; +} + +.shopping-item-form h2 { + font-size: 1.5rem; + font-weight: 700; + color: #1e293b; + margin-bottom: 24px; +} + +.shopping-item-form .repetition-label { + color: #64748b; + font-size: 0.875rem; + white-space: nowrap; +} diff --git a/play-life-web/src/components/ShoppingItemForm.jsx b/play-life-web/src/components/ShoppingItemForm.jsx new file mode 100644 index 0000000..5e21b5b --- /dev/null +++ b/play-life-web/src/components/ShoppingItemForm.jsx @@ -0,0 +1,295 @@ +import React, { useState, useEffect } from 'react' +import { useAuth } from './auth/AuthContext' +import Toast from './Toast' +import SubmitButton from './SubmitButton' +import DeleteButton from './DeleteButton' +import './ShoppingItemForm.css' + +function ShoppingItemForm({ onNavigate, itemId, boardId, onSaved }) { + const { authFetch } = useAuth() + const [name, setName] = useState('') + const [groupName, setGroupName] = useState('') + const [groupSuggestions, setGroupSuggestions] = useState([]) + const [volumeBase, setVolumeBase] = useState('') + const [repetitionPeriodValue, setRepetitionPeriodValue] = useState('') + const [repetitionPeriodType, setRepetitionPeriodType] = useState('day') + const [loading, setLoading] = useState(false) + const [loadingItem, setLoadingItem] = useState(false) + const [isDeleting, setIsDeleting] = useState(false) + const [toastMessage, setToastMessage] = useState(null) + + const isEdit = !!itemId + + useEffect(() => { + loadGroupSuggestions() + }, []) + + useEffect(() => { + if (itemId) { + fetchItem() + } + }, [itemId]) + + const loadGroupSuggestions = async () => { + try { + const res = await authFetch('/api/shopping/groups') + if (res.ok) { + const data = await res.json() + setGroupSuggestions(Array.isArray(data) ? data : []) + } + } catch (err) { + console.error('Error loading group suggestions:', err) + } + } + + const fetchItem = async () => { + setLoadingItem(true) + try { + const res = await authFetch(`/api/shopping/items/${itemId}`) + if (res.ok) { + const data = await res.json() + setName(data.name) + setGroupName(data.group_name || '') + if (data.volume_base && data.volume_base !== 1) { + setVolumeBase(data.volume_base.toString()) + } + if (data.repetition_period) { + const parts = data.repetition_period.trim().split(/\s+/) + if (parts.length >= 2) { + const value = parseInt(parts[0], 10) + const unit = parts[1].toLowerCase() + setRepetitionPeriodValue(value.toString()) + // Map PostgreSQL units to our types + if (unit.startsWith('day')) setRepetitionPeriodType('day') + else if (unit.startsWith('week') || unit === 'wks' || unit === 'wk') setRepetitionPeriodType('week') + else if (unit.startsWith('mon')) setRepetitionPeriodType('month') + else if (unit.startsWith('year') || unit === 'yrs' || unit === 'yr') setRepetitionPeriodType('year') + else if (unit.startsWith('hour') || unit === 'hrs' || unit === 'hr') setRepetitionPeriodType('hour') + else if (unit.startsWith('min')) setRepetitionPeriodType('minute') + // Handle PostgreSQL weeks-as-days: "7 days" -> 1 week + if (unit.startsWith('day') && value % 7 === 0 && value >= 7) { + setRepetitionPeriodValue((value / 7).toString()) + setRepetitionPeriodType('week') + } + } + } + } else { + setToastMessage({ text: 'Ошибка загрузки товара', type: 'error' }) + } + } catch (err) { + setToastMessage({ text: 'Ошибка загрузки', type: 'error' }) + } finally { + setLoadingItem(false) + } + } + + const handleSave = async () => { + if (!name.trim()) { + setToastMessage({ text: 'Введите название товара', type: 'error' }) + return + } + + if (!hasValidPeriod) { + setToastMessage({ text: 'Укажите период повторения', type: 'error' }) + return + } + + setLoading(true) + try { + let repetitionPeriod = null + if (repetitionPeriodValue && repetitionPeriodValue.trim() !== '') { + const val = parseInt(repetitionPeriodValue.trim(), 10) + if (!isNaN(val) && val > 0) { + repetitionPeriod = `${val} ${repetitionPeriodType}` + } + } + + const vb = volumeBase.trim() ? parseFloat(volumeBase.trim()) : null + const payload = { + name: name.trim(), + group_name: groupName.trim() || null, + volume_base: vb && vb > 0 ? vb : null, + repetition_period: repetitionPeriod, + } + + let url, method + if (isEdit) { + url = `/api/shopping/items/${itemId}` + method = 'PUT' + } else { + url = `/api/shopping/boards/${boardId}/items` + method = 'POST' + } + + const res = await authFetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }) + + if (res.ok) { + onSaved?.() + onNavigate('shopping', { boardId }) + } else { + const err = await res.json() + setToastMessage({ text: err.error || 'Ошибка сохранения', type: 'error' }) + } + } catch (err) { + setToastMessage({ text: 'Ошибка сохранения', type: 'error' }) + } finally { + setLoading(false) + } + } + + const handleDelete = async () => { + if (!window.confirm('Удалить товар?')) return + + setIsDeleting(true) + try { + const res = await authFetch(`/api/shopping/items/${itemId}`, { method: 'DELETE' }) + if (res.ok) { + onSaved?.() + onNavigate('shopping', { boardId }) + } else { + setToastMessage({ text: 'Ошибка удаления', type: 'error' }) + setIsDeleting(false) + } + } catch (err) { + setToastMessage({ text: 'Ошибка удаления', type: 'error' }) + setIsDeleting(false) + } + } + + const handleClose = () => { + window.history.back() + } + + const hasValidPeriod = repetitionPeriodValue && repetitionPeriodValue.trim() !== '' && parseInt(repetitionPeriodValue.trim(), 10) > 0 + + if (loadingItem) { + return ( +
+
+
+
+
Загрузка...
+
+
+
+ ) + } + + return ( +
+ + +

{isEdit ? 'Редактировать товар' : 'Новый товар'}

+ +
+
+ + setName(e.target.value)} + placeholder="Название товара" + /> +
+ +
+ + setGroupName(e.target.value)} + placeholder="Группа товара" + list="shopping-group-suggestions" + /> + + {groupSuggestions.map((g, i) => ( + +
+ +
+ + setVolumeBase(e.target.value)} + placeholder="1" + /> +
+ +
+ +
+ Через + setRepetitionPeriodValue(e.target.value)} + placeholder="Число" + style={{ flex: '1' }} + /> + +
+
+ +
+ + Сохранить + + {isEdit && ( + + )} +
+
+ + {toastMessage && ( + setToastMessage(null)} + /> + )} +
+ ) +} + +export default ShoppingItemForm diff --git a/play-life-web/src/components/ShoppingList.css b/play-life-web/src/components/ShoppingList.css new file mode 100644 index 0000000..d9d24d9 --- /dev/null +++ b/play-life-web/src/components/ShoppingList.css @@ -0,0 +1,28 @@ +.shopping-list { + max-width: 42rem; + margin: 0 auto; + padding-bottom: 2.5rem; +} + +.shopping-empty { + text-align: center; + padding: 3rem 1rem; + color: #94a3b8; +} + +.shopping-empty p:first-child { + font-size: 1.125rem; + font-weight: 600; + color: #64748b; + margin-bottom: 0.5rem; +} + +.shopping-empty-hint { + font-size: 0.875rem; +} + +.shopping-loading { + display: flex; + justify-content: center; + padding: 3rem 0; +} diff --git a/play-life-web/src/components/ShoppingList.jsx b/play-life-web/src/components/ShoppingList.jsx new file mode 100644 index 0000000..fdb7f74 --- /dev/null +++ b/play-life-web/src/components/ShoppingList.jsx @@ -0,0 +1,736 @@ +import React, { useState, useEffect, useRef, useMemo } from 'react' +import { createPortal } from 'react-dom' +import { useAuth } from './auth/AuthContext' +import BoardSelector from './BoardSelector' +import ShoppingItemDetail from './ShoppingItemDetail' +import LoadingError from './LoadingError' +import Toast from './Toast' +import { DayPicker } from 'react-day-picker' +import { ru } from 'react-day-picker/locale' +import 'react-day-picker/style.css' +import './TaskList.css' +import './ShoppingList.css' + +const BOARDS_CACHE_KEY = 'shopping_boards_cache' +const ITEMS_CACHE_KEY = 'shopping_items_cache' +const SELECTED_BOARD_KEY = 'shopping_selected_board_id' + +// Форматирование даты в YYYY-MM-DD (локальное время) +const formatDateToLocal = (date) => { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +// Форматирование даты для отображения +const formatDateForDisplay = (dateStr) => { + if (!dateStr) return '' + const date = new Date(dateStr) + if (isNaN(date.getTime())) return '' + + const now = new Date() + now.setHours(0, 0, 0, 0) + const target = new Date(date.getFullYear(), date.getMonth(), date.getDate()) + + const diffDays = Math.floor((target - now) / (1000 * 60 * 60 * 24)) + + if (diffDays === 0) return 'Сегодня' + if (diffDays === 1) return 'Завтра' + if (diffDays === -1) return 'Вчера' + + if (diffDays > 0 && diffDays <= 7) { + const dayNames = ['Воскресенье', 'Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота'] + return dayNames[target.getDay()] + } + + const monthNames = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня', + 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря'] + + if (target.getFullYear() === now.getFullYear()) { + return `${target.getDate()} ${monthNames[target.getMonth()]}` + } + return `${target.getDate()} ${monthNames[target.getMonth()]} ${target.getFullYear()}` +} + +// Вычисление следующей даты по repetition_period +const calculateNextDateFromRepetitionPeriod = (repetitionPeriodStr) => { + if (!repetitionPeriodStr) return null + const parts = repetitionPeriodStr.trim().split(/\s+/) + if (parts.length < 2) return null + const value = parseInt(parts[0], 10) + if (isNaN(value) || value === 0) return null + const unit = parts[1].toLowerCase() + const now = new Date() + now.setHours(0, 0, 0, 0) + const nextDate = new Date(now) + + switch (unit) { + case 'day': case 'days': + if (value % 7 === 0 && value >= 7) { + nextDate.setDate(nextDate.getDate() + value) + } else { + nextDate.setDate(nextDate.getDate() + value) + } + break + case 'week': case 'weeks': case 'wks': case 'wk': + nextDate.setDate(nextDate.getDate() + value * 7) + break + case 'month': case 'months': case 'mons': case 'mon': + nextDate.setMonth(nextDate.getMonth() + value) + break + case 'year': case 'years': case 'yrs': case 'yr': + nextDate.setFullYear(nextDate.getFullYear() + value) + break + case 'hour': case 'hours': case 'hrs': case 'hr': + nextDate.setHours(nextDate.getHours() + value) + break + case 'minute': case 'minutes': case 'mins': case 'min': + nextDate.setMinutes(nextDate.getMinutes() + value) + break + default: + return null + } + return nextDate +} + +function ShoppingList({ onNavigate, refreshTrigger = 0, isActive = false, initialBoardId = null, boardDeleted = false }) { + const { authFetch } = useAuth() + const [boards, setBoards] = useState([]) + + const getInitialBoardId = () => { + if (initialBoardId) return initialBoardId + try { + const saved = localStorage.getItem(SELECTED_BOARD_KEY) + if (saved) { + const boardId = parseInt(saved, 10) + if (!isNaN(boardId)) return boardId + } + } catch (err) {} + return null + } + + const [selectedBoardId, setSelectedBoardIdState] = useState(getInitialBoardId) + const [items, setItems] = useState([]) + const [loading, setLoading] = useState(true) + const [boardsLoading, setBoardsLoading] = useState(true) + const [error, setError] = useState('') + const [selectedItemForDetail, setSelectedItemForDetail] = useState(null) + const [selectedItemForPostpone, setSelectedItemForPostpone] = useState(null) + const [postponeDate, setPostponeDate] = useState('') + const [isPostponing, setIsPostponing] = useState(false) + const [toast, setToast] = useState(null) + const fetchingRef = useRef(false) + const initialFetchDoneRef = useRef(false) + const prevIsActiveRef = useRef(isActive) + + const setSelectedBoardId = (boardId) => { + setSelectedBoardIdState(boardId) + try { + if (boardId) { + localStorage.setItem(SELECTED_BOARD_KEY, String(boardId)) + } else { + localStorage.removeItem(SELECTED_BOARD_KEY) + } + } catch (err) {} + } + + // Загрузка досок + const fetchBoards = async () => { + setBoardsLoading(true) + try { + const res = await authFetch('/api/shopping/boards') + if (res.ok) { + const data = await res.json() + const boardsList = Array.isArray(data) ? data : [] + setBoards(boardsList) + + try { + localStorage.setItem(BOARDS_CACHE_KEY, JSON.stringify({ boards: boardsList })) + } catch (err) {} + + if (boardDeleted || !boardsList.some(b => b.id === selectedBoardId)) { + if (boardsList.length > 0) { + setSelectedBoardId(boardsList[0].id) + } else { + setSelectedBoardId(null) + } + } + } + } catch (err) { + setError('Ошибка загрузки досок') + } finally { + setBoardsLoading(false) + } + } + + // Загрузка товаров + const fetchItems = async (boardId) => { + if (!boardId || fetchingRef.current) return + fetchingRef.current = true + setLoading(true) + setError('') + + try { + const res = await authFetch(`/api/shopping/boards/${boardId}/items`) + if (res.ok) { + const data = await res.json() + setItems(Array.isArray(data) ? data : []) + try { + localStorage.setItem(`${ITEMS_CACHE_KEY}_${boardId}`, JSON.stringify(data)) + } catch (err) {} + } else { + setError('Ошибка загрузки товаров') + } + } catch (err) { + setError('Ошибка загрузки товаров') + } finally { + setLoading(false) + fetchingRef.current = false + } + } + + // Загрузка из кэша + useEffect(() => { + try { + const cached = localStorage.getItem(BOARDS_CACHE_KEY) + if (cached) { + const data = JSON.parse(cached) + if (data.boards) setBoards(data.boards) + } + } catch (err) {} + + if (selectedBoardId) { + try { + const cached = localStorage.getItem(`${ITEMS_CACHE_KEY}_${selectedBoardId}`) + if (cached) { + setItems(JSON.parse(cached) || []) + } + } catch (err) {} + } + }, []) + + // Начальная загрузка + useEffect(() => { + fetchBoards() + initialFetchDoneRef.current = true + }, []) + + // Загрузка при смене доски + useEffect(() => { + if (selectedBoardId) { + fetchItems(selectedBoardId) + } else { + setItems([]) + setLoading(false) + } + }, [selectedBoardId]) + + // Рефреш при возврате на таб + useEffect(() => { + if (isActive && !prevIsActiveRef.current && initialFetchDoneRef.current) { + fetchBoards() + if (selectedBoardId) fetchItems(selectedBoardId) + } + prevIsActiveRef.current = isActive + }, [isActive]) + + // Рефреш по триггеру + useEffect(() => { + if (refreshTrigger > 0) { + fetchBoards() + if (selectedBoardId) fetchItems(selectedBoardId) + } + }, [refreshTrigger]) + + // initialBoardId + useEffect(() => { + if (initialBoardId) { + setSelectedBoardId(initialBoardId) + } + }, [initialBoardId]) + + // Фильтрация и группировка на клиенте + const groupedItems = useMemo(() => { + const now = new Date() + now.setHours(0, 0, 0, 0) + const todayEnd = new Date(now) + todayEnd.setHours(23, 59, 59, 999) + + const groups = {} + + items.forEach(item => { + const groupKey = item.group_name || 'Остальные' + if (!groups[groupKey]) { + groups[groupKey] = { active: [], future: [] } + } + + if (item.next_show_at) { + const showAt = new Date(item.next_show_at) + if (showAt > todayEnd) { + groups[groupKey].future.push(item) + return + } + } + groups[groupKey].active.push(item) + }) + + // Сортируем future по next_show_at ASC + Object.values(groups).forEach(group => { + group.future.sort((a, b) => { + if (!a.next_show_at) return 1 + if (!b.next_show_at) return -1 + return new Date(a.next_show_at) - new Date(b.next_show_at) + }) + }) + + return groups + }, [items]) + + const [expandedFuture, setExpandedFuture] = useState({}) + + const handleBoardChange = (boardId) => { + setSelectedBoardId(boardId) + } + + const handleBoardEdit = () => { + if (selectedBoardId) { + const board = boards.find(b => b.id === selectedBoardId) + if (board?.is_owner) { + onNavigate('shopping-board-form', { boardId: selectedBoardId }) + } else { + if (window.confirm('Покинуть доску?')) { + authFetch(`/api/shopping/boards/${selectedBoardId}/leave`, { method: 'POST' }) + .then(res => { + if (res.ok) { + fetchBoards() + } + }) + } + } + } + } + + const handleAddBoard = () => { + onNavigate('shopping-board-form') + } + + const handleRefresh = () => { + if (selectedBoardId) fetchItems(selectedBoardId) + } + + // Модалка переноса + const handlePostponeClose = () => { + setSelectedItemForPostpone(null) + setPostponeDate('') + } + + const handleDateSelect = (date) => { + if (date) { + setPostponeDate(formatDateToLocal(date)) + } + } + + const handleDayClick = (date) => { + if (date) { + const dateStr = formatDateToLocal(date) + handlePostponeSubmitWithDate(dateStr) + } + } + + const handlePostponeSubmitWithDate = async (dateStr) => { + if (!selectedItemForPostpone || !dateStr) return + + setIsPostponing(true) + try { + const nextShowAt = new Date(dateStr + 'T00:00:00') + const res = await authFetch(`/api/shopping/items/${selectedItemForPostpone.id}/postpone`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ next_show_at: nextShowAt.toISOString() }) + }) + if (res.ok) { + setToast({ message: 'Дата перенесена', type: 'success' }) + handleRefresh() + handlePostponeClose() + } else { + setToast({ message: 'Ошибка переноса', type: 'error' }) + } + } catch (err) { + setToast({ message: 'Ошибка переноса', type: 'error' }) + } finally { + setIsPostponing(false) + } + } + + const handleTodayClick = () => { + handlePostponeSubmitWithDate(formatDateToLocal(new Date())) + } + + const handleTomorrowClick = () => { + const tomorrow = new Date() + tomorrow.setDate(tomorrow.getDate() + 1) + handlePostponeSubmitWithDate(formatDateToLocal(tomorrow)) + } + + const handleWithoutDateClick = async () => { + if (!selectedItemForPostpone) return + setIsPostponing(true) + try { + const res = await authFetch(`/api/shopping/items/${selectedItemForPostpone.id}/postpone`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ next_show_at: null }) + }) + if (res.ok) { + setToast({ message: 'Дата убрана', type: 'success' }) + handleRefresh() + handlePostponeClose() + } + } catch (err) { + setToast({ message: 'Ошибка', type: 'error' }) + } finally { + setIsPostponing(false) + } + } + + + const groupNames = useMemo(() => { + const names = Object.keys(groupedItems) + return names.sort((a, b) => { + const groupA = groupedItems[a] + const groupB = groupedItems[b] + const hasActiveA = groupA.active.length > 0 + const hasActiveB = groupB.active.length > 0 + + if (hasActiveA && !hasActiveB) return -1 + if (!hasActiveA && hasActiveB) return 1 + + if (a === 'Остальные') return 1 + if (b === 'Остальные') return -1 + return a.localeCompare(b, 'ru') + }) + }, [groupedItems]) + + const toggleFuture = (groupName) => { + setExpandedFuture(prev => ({ + ...prev, + [groupName]: !prev[groupName] + })) + } + + return ( +
+ + + {boards.length === 0 && !boardsLoading && ( +
+

Нет досок

+

Создайте доску, чтобы начать добавлять товары

+
+ )} + + {selectedBoardId && error && ( + + )} + + {selectedBoardId && !error && ( + <> + {loading && items.length === 0 && ( +
+
+
+ )} + + {!loading && items.length === 0 && ( +
+

Нет товаров

+
+ )} + + {groupNames.map(groupName => { + const group = groupedItems[groupName] + const hasActive = group.active.length > 0 + const hasFuture = group.future.length > 0 + const isFutureExpanded = expandedFuture[groupName] + + return ( +
+
toggleFuture(groupName) : undefined} + > +

{groupName}

+ {hasFuture ? ( + + ) : ( +
+ +
+ )} +
+ + {hasActive && ( +
+ {group.active.map(item => { + let dateDisplay = null + if (item.next_show_at) { + dateDisplay = formatDateForDisplay(item.next_show_at) + if (dateDisplay === 'Сегодня') dateDisplay = null + } + + return ( +
setSelectedItemForDetail(item.id)} + > +
+
{ + e.stopPropagation() + setSelectedItemForDetail(item.id) + }} + title="Выполнить" + > + + + + +
+
+
+
{item.name}
+ {dateDisplay && ( +
{dateDisplay}
+ )} +
+
+
+ +
+
+
+ ) + })} +
+ )} + + {hasFuture && isFutureExpanded && ( +
+ {group.future.map(item => ( +
setSelectedItemForDetail(item.id)} + > +
+
{ + e.stopPropagation() + setSelectedItemForDetail(item.id) + }} + title="Выполнить" + > + + + + +
+
+
+
{item.name}
+
+ {formatDateForDisplay(item.next_show_at)} +
+
+
+
+ +
+
+
+ ))} +
+ )} +
+ ) + })} + + )} + + {/* Модалка выполнения */} + {selectedItemForDetail && ( + setSelectedItemForDetail(null)} + onRefresh={handleRefresh} + onItemCompleted={() => setToast({ message: 'Товар выполнен', type: 'success' })} + onNavigate={onNavigate} + /> + )} + + {/* Модалка переноса */} + {selectedItemForPostpone && (() => { + const todayStr = formatDateToLocal(new Date()) + const tomorrow = new Date() + tomorrow.setDate(tomorrow.getDate() + 1) + const tomorrowStr = formatDateToLocal(tomorrow) + + let nextShowAtStr = null + if (selectedItemForPostpone.next_show_at) { + const nextShowAtDate = new Date(selectedItemForPostpone.next_show_at) + nextShowAtStr = formatDateToLocal(nextShowAtDate) + } + + const isToday = nextShowAtStr === todayStr + const isTomorrow = nextShowAtStr === tomorrowStr + const showTodayChip = !nextShowAtStr || nextShowAtStr > todayStr + + // Дата "по плану" + const item = selectedItemForPostpone + let plannedDate + const now = new Date() + now.setHours(0, 0, 0, 0) + if (item.repetition_period) { + const nextDate = calculateNextDateFromRepetitionPeriod(item.repetition_period) + if (nextDate) plannedDate = nextDate + } + if (!plannedDate) { + plannedDate = new Date(now) + plannedDate.setDate(plannedDate.getDate() + 1) + } + plannedDate.setHours(0, 0, 0, 0) + const plannedDateStr = formatDateToLocal(plannedDate) + const plannedNorm = plannedDateStr.slice(0, 10) + const nextShowNorm = nextShowAtStr ? String(nextShowAtStr).slice(0, 10) : '' + const isCurrentDatePlanned = plannedNorm && nextShowNorm && plannedNorm === nextShowNorm + + const modalContent = ( +
+
e.stopPropagation()}> +
+

{selectedItemForPostpone.name}

+ +
+
+
+ { + const today = new Date() + today.setHours(0, 0, 0, 0) + return today + })() }} + locale={ru} + /> +
+
+ {showTodayChip && ( + + )} + {!isTomorrow && ( + + )} + {!isCurrentDatePlanned && ( + + )} + {selectedItemForPostpone?.next_show_at && ( + + )} +
+
+
+
+ ) + + return typeof document !== 'undefined' + ? createPortal(modalContent, document.body) + : modalContent + })()} + + {toast && ( + setToast(null)} + /> + )} +
+ ) +} + +export default ShoppingList