From c22e56e68a52c8dca05955f711107020fa9be128 Mon Sep 17 00:00:00 2001 From: poignatov Date: Wed, 4 Feb 2026 15:46:05 +0300 Subject: [PATCH] =?UTF-8?q?4.15.0:=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=BF=D1=80=D0=B8=D0=BD=D0=B0=D0=B4=D0=BB?= =?UTF-8?q?=D0=B5=D0=B6=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20=D0=B6=D0=B5=D0=BB?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B9=20=D0=BA=20=D0=BF=D1=80=D0=BE=D0=B5?= =?UTF-8?q?=D0=BA=D1=82=D0=B0=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION | 2 +- play-life-backend/main.go | 74 ++++++++++--- ..._add_project_id_to_wishlist_items.down.sql | 9 ++ ...10_add_project_id_to_wishlist_items.up.sql | 13 +++ play-life-web/package.json | 2 +- play-life-web/src/components/Wishlist.css | 47 ++++++++ play-life-web/src/components/Wishlist.jsx | 100 +++++++++++++++++- play-life-web/src/components/WishlistForm.jsx | 23 ++++ 8 files changed, 252 insertions(+), 18 deletions(-) create mode 100644 play-life-backend/migrations/000010_add_project_id_to_wishlist_items.down.sql create mode 100644 play-life-backend/migrations/000010_add_project_id_to_wishlist_items.up.sql diff --git a/VERSION b/VERSION index d2b9909..5c517bf 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.14.1 +4.15.0 diff --git a/play-life-backend/main.go b/play-life-backend/main.go index 6031f1f..3493979 100644 --- a/play-life-backend/main.go +++ b/play-life-backend/main.go @@ -378,6 +378,8 @@ type WishlistItem struct { UnlockConditions []UnlockConditionDisplay `json:"unlock_conditions,omitempty"` LinkedTask *LinkedTask `json:"linked_task,omitempty"` TasksCount int `json:"tasks_count,omitempty"` // Количество задач для этого желания + ProjectID *int `json:"project_id,omitempty"` // ID проекта, к которому принадлежит желание + ProjectName *string `json:"project_name,omitempty"` // Название проекта } type UnlockConditionDisplay struct { @@ -404,6 +406,7 @@ type WishlistRequest struct { Name string `json:"name"` Price *float64 `json:"price,omitempty"` Link *string `json:"link,omitempty"` + ProjectID *int `json:"project_id,omitempty"` // ID проекта, к которому принадлежит желание UnlockConditions []UnlockConditionRequest `json:"unlock_conditions,omitempty"` } @@ -9938,6 +9941,8 @@ func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool) wi.image_path, wi.link, wi.completed, + wi.project_id AS item_project_id, + wp.name AS item_project_name, wc.id AS condition_id, wc.display_order, wc.task_condition_id, @@ -9950,6 +9955,7 @@ func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool) sc.required_points, sc.start_date FROM wishlist_items wi + LEFT JOIN projects wp ON wi.project_id = wp.id AND wp.deleted = FALSE LEFT JOIN wishlist_conditions wc ON wi.id = wc.wishlist_item_id LEFT JOIN task_conditions tc ON wc.task_condition_id = tc.id LEFT JOIN tasks t ON tc.task_id = t.id AND t.deleted = FALSE @@ -9976,6 +9982,8 @@ func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool) var price sql.NullFloat64 var imagePath, link sql.NullString var completed bool + var itemProjectID sql.NullInt64 + var itemProjectName sql.NullString var conditionID, displayOrder sql.NullInt64 var taskConditionID, scoreConditionID sql.NullInt64 @@ -9989,6 +9997,7 @@ func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool) err := rows.Scan( &itemID, &name, &price, &imagePath, &link, &completed, + &itemProjectID, &itemProjectName, &conditionID, &displayOrder, &taskConditionID, &scoreConditionID, &conditionUserID, &taskID, &taskName, @@ -10019,6 +10028,14 @@ func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool) l := link.String item.Link = &l } + if itemProjectID.Valid { + projectIDVal := int(itemProjectID.Int64) + item.ProjectID = &projectIDVal + } + if itemProjectName.Valid { + projectNameVal := itemProjectName.String + item.ProjectName = &projectNameVal + } itemsMap[itemID] = item } @@ -10681,10 +10698,10 @@ func (a *App) createWishlistHandler(w http.ResponseWriter, r *http.Request) { var wishlistID int err = tx.QueryRow(` - INSERT INTO wishlist_items (user_id, author_id, name, price, link, completed, deleted) - VALUES ($1, $1, $2, $3, $4, FALSE, FALSE) + INSERT INTO wishlist_items (user_id, author_id, name, price, link, project_id, completed, deleted) + VALUES ($1, $1, $2, $3, $4, $5, FALSE, FALSE) RETURNING id - `, userID, strings.TrimSpace(req.Name), req.Price, req.Link).Scan(&wishlistID) + `, userID, strings.TrimSpace(req.Name), req.Price, req.Link, req.ProjectID).Scan(&wishlistID) if err != nil { log.Printf("Error creating wishlist item: %v", err) @@ -10895,6 +10912,8 @@ func (a *App) getWishlistItemHandler(w http.ResponseWriter, r *http.Request) { wi.image_path, wi.link, wi.completed, + wi.project_id AS item_project_id, + wp.name AS item_project_name, wc.id AS condition_id, wc.display_order, wc.task_condition_id, @@ -10907,6 +10926,7 @@ func (a *App) getWishlistItemHandler(w http.ResponseWriter, r *http.Request) { sc.required_points, sc.start_date FROM wishlist_items wi + LEFT JOIN projects wp ON wi.project_id = wp.id AND wp.deleted = FALSE LEFT JOIN wishlist_conditions wc ON wi.id = wc.wishlist_item_id LEFT JOIN task_conditions tc ON wc.task_condition_id = tc.id LEFT JOIN tasks t ON tc.task_id = t.id AND t.deleted = FALSE @@ -10933,6 +10953,8 @@ func (a *App) getWishlistItemHandler(w http.ResponseWriter, r *http.Request) { var imagePath sql.NullString var link sql.NullString var completed bool + var itemProjectID sql.NullInt64 + var itemProjectName sql.NullString var conditionID sql.NullInt64 var displayOrder sql.NullInt64 var taskConditionID sql.NullInt64 @@ -10946,7 +10968,7 @@ func (a *App) getWishlistItemHandler(w http.ResponseWriter, r *http.Request) { var startDate sql.NullTime err := rows.Scan( - &itemID, &name, &price, &imagePath, &link, &completed, + &itemID, &name, &price, &imagePath, &link, &completed, &itemProjectID, &itemProjectName, &conditionID, &displayOrder, &taskConditionID, &scoreConditionID, &conditionUserID, &taskID, &taskName, &projectID, &projectName, &requiredPoints, &startDate, ) @@ -10976,6 +10998,14 @@ func (a *App) getWishlistItemHandler(w http.ResponseWriter, r *http.Request) { if link.Valid { item.Link = &link.String } + if itemProjectID.Valid { + projectIDVal := int(itemProjectID.Int64) + item.ProjectID = &projectIDVal + } + if itemProjectName.Valid { + projectNameVal := itemProjectName.String + item.ProjectName = &projectNameVal + } itemsMap[itemID] = item } @@ -11231,9 +11261,9 @@ func (a *App) updateWishlistHandler(w http.ResponseWriter, r *http.Request) { // Обновляем желание (не проверяем user_id в WHERE, так как доступ уже проверен выше) _, err = tx.Exec(` UPDATE wishlist_items - SET name = $1, price = $2, link = $3, updated_at = NOW() - WHERE id = $4 - `, strings.TrimSpace(req.Name), req.Price, req.Link, itemID) + SET name = $1, price = $2, link = $3, project_id = $4, updated_at = NOW() + WHERE id = $5 + `, strings.TrimSpace(req.Name), req.Price, req.Link, req.ProjectID, itemID) if err != nil { log.Printf("Error updating wishlist item: %v", err) @@ -13014,6 +13044,8 @@ func (a *App) getBoardCompletedHandler(w http.ResponseWriter, r *http.Request) { wi.image_path, wi.link, wi.completed, + wi.project_id AS item_project_id, + wp.name AS item_project_name, wc.id AS condition_id, wc.display_order, wc.task_condition_id, @@ -13027,6 +13059,7 @@ func (a *App) getBoardCompletedHandler(w http.ResponseWriter, r *http.Request) { sc.start_date, COALESCE(u.name, u.email) AS user_name FROM wishlist_items wi + LEFT JOIN projects wp ON wi.project_id = wp.id AND wp.deleted = FALSE LEFT JOIN wishlist_conditions wc ON wi.id = wc.wishlist_item_id LEFT JOIN task_conditions tc ON wc.task_condition_id = tc.id LEFT JOIN tasks t ON tc.task_id = t.id AND t.deleted = FALSE @@ -13056,6 +13089,8 @@ func (a *App) getBoardCompletedHandler(w http.ResponseWriter, r *http.Request) { var imagePath sql.NullString var link sql.NullString var completed bool + var itemProjectID sql.NullInt64 + var itemProjectName sql.NullString var conditionID sql.NullInt64 var displayOrder sql.NullInt64 var taskConditionID sql.NullInt64 @@ -13070,7 +13105,7 @@ func (a *App) getBoardCompletedHandler(w http.ResponseWriter, r *http.Request) { var userName sql.NullString err := rows.Scan( - &itemID, &name, &price, &imagePath, &link, &completed, + &itemID, &name, &price, &imagePath, &link, &completed, &itemProjectID, &itemProjectName, &conditionID, &displayOrder, &taskConditionID, &scoreConditionID, &userIDCond, &taskID, &taskName, &projectID, &projectName, &requiredPoints, &startDate, &userName, ) @@ -13100,6 +13135,8 @@ func (a *App) getBoardCompletedHandler(w http.ResponseWriter, r *http.Request) { if link.Valid { item.Link = &link.String } + // Для завершённых желаний не устанавливаем project_id и project_name + // Они отображаются отдельно без группировки по проектам itemsMap[itemID] = item } @@ -13305,6 +13342,8 @@ func (a *App) getWishlistItemsByBoard(boardID int, userID int) ([]WishlistItem, wi.image_path, wi.link, wi.completed, + wi.project_id AS item_project_id, + wp.name AS item_project_name, COALESCE(wi.author_id, wi.user_id) AS item_owner_id, wc.id AS condition_id, wc.display_order, @@ -13318,6 +13357,7 @@ func (a *App) getWishlistItemsByBoard(boardID int, userID int) ([]WishlistItem, sc.required_points, sc.start_date FROM wishlist_items wi + LEFT JOIN projects wp ON wi.project_id = wp.id AND wp.deleted = FALSE LEFT JOIN wishlist_conditions wc ON wi.id = wc.wishlist_item_id LEFT JOIN task_conditions tc ON wc.task_condition_id = tc.id LEFT JOIN tasks t ON tc.task_id = t.id AND t.deleted = FALSE @@ -13344,6 +13384,8 @@ func (a *App) getWishlistItemsByBoard(boardID int, userID int) ([]WishlistItem, var imagePath sql.NullString var link sql.NullString var completed bool + var itemProjectID sql.NullInt64 + var itemProjectName sql.NullString var itemOwnerID sql.NullInt64 var conditionID sql.NullInt64 var displayOrder sql.NullInt64 @@ -13358,7 +13400,7 @@ func (a *App) getWishlistItemsByBoard(boardID int, userID int) ([]WishlistItem, var startDate sql.NullTime err := rows.Scan( - &itemID, &name, &price, &imagePath, &link, &completed, &itemOwnerID, + &itemID, &name, &price, &imagePath, &link, &completed, &itemProjectID, &itemProjectName, &itemOwnerID, &conditionID, &displayOrder, &taskConditionID, &scoreConditionID, &conditionUserID, &taskID, &taskName, &projectID, &projectName, &requiredPoints, &startDate, ) @@ -13388,6 +13430,14 @@ func (a *App) getWishlistItemsByBoard(boardID int, userID int) ([]WishlistItem, if link.Valid { item.Link = &link.String } + if itemProjectID.Valid { + projectIDVal := int(itemProjectID.Int64) + item.ProjectID = &projectIDVal + } + if itemProjectName.Valid { + projectNameVal := itemProjectName.String + item.ProjectName = &projectNameVal + } itemsMap[itemID] = item } @@ -13657,10 +13707,10 @@ func (a *App) createBoardItemHandler(w http.ResponseWriter, r *http.Request) { var itemID int err = tx.QueryRow(` - INSERT INTO wishlist_items (user_id, board_id, author_id, name, price, link, completed, deleted) - VALUES ($1, $2, $3, $4, $5, $6, FALSE, FALSE) + INSERT INTO wishlist_items (user_id, board_id, author_id, name, price, link, project_id, completed, deleted) + VALUES ($1, $2, $3, $4, $5, $6, $7, FALSE, FALSE) RETURNING id - `, ownerID, boardID, userID, strings.TrimSpace(req.Name), req.Price, req.Link).Scan(&itemID) + `, ownerID, boardID, userID, strings.TrimSpace(req.Name), req.Price, req.Link, req.ProjectID).Scan(&itemID) if err != nil { log.Printf("createBoardItemHandler: Error creating board item: %v", err) diff --git a/play-life-backend/migrations/000010_add_project_id_to_wishlist_items.down.sql b/play-life-backend/migrations/000010_add_project_id_to_wishlist_items.down.sql new file mode 100644 index 0000000..3b2e68c --- /dev/null +++ b/play-life-backend/migrations/000010_add_project_id_to_wishlist_items.down.sql @@ -0,0 +1,9 @@ +-- Migration: Remove project_id field from wishlist_items table +-- Date: 2026-02-02 +-- +-- This migration reverts the addition of project_id field. + +DROP INDEX IF EXISTS idx_wishlist_items_project_id; + +ALTER TABLE wishlist_items +DROP COLUMN IF EXISTS project_id; diff --git a/play-life-backend/migrations/000010_add_project_id_to_wishlist_items.up.sql b/play-life-backend/migrations/000010_add_project_id_to_wishlist_items.up.sql new file mode 100644 index 0000000..6c4043e --- /dev/null +++ b/play-life-backend/migrations/000010_add_project_id_to_wishlist_items.up.sql @@ -0,0 +1,13 @@ +-- Migration: Add project_id field to wishlist_items table +-- Date: 2026-02-02 +-- +-- This migration adds project_id field to wishlist_items table to allow +-- grouping wishlist items by project. The field is nullable, so existing +-- items without a project will remain valid. + +ALTER TABLE wishlist_items +ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL; + +CREATE INDEX idx_wishlist_items_project_id ON wishlist_items(project_id); + +COMMENT ON COLUMN wishlist_items.project_id IS 'Project this wishlist item belongs to (optional)'; diff --git a/play-life-web/package.json b/play-life-web/package.json index 8e796e8..8f5a2d4 100644 --- a/play-life-web/package.json +++ b/play-life-web/package.json @@ -1,6 +1,6 @@ { "name": "play-life-web", - "version": "4.14.1", + "version": "4.15.0", "type": "module", "scripts": { "dev": "vite", diff --git a/play-life-web/src/components/Wishlist.css b/play-life-web/src/components/Wishlist.css index a6314a4..dcb494c 100644 --- a/play-life-web/src/components/Wishlist.css +++ b/play-life-web/src/components/Wishlist.css @@ -93,6 +93,53 @@ margin-bottom: 1rem; } +.wishlist-project-group { + margin-bottom: 2rem; +} + +.wishlist-project-group-title { + font-size: 1.25rem; + font-weight: 600; + color: #2c3e50; + margin-bottom: 0.75rem; +} + +.wishlist-project-group-items { + display: flex; + overflow-x: auto; + gap: 1rem; + padding-bottom: 0.5rem; + -webkit-overflow-scrolling: touch; + scrollbar-width: thin; +} + +.wishlist-project-group-items::-webkit-scrollbar { + height: 8px; +} + +.wishlist-project-group-items::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 4px; +} + +.wishlist-project-group-items::-webkit-scrollbar-thumb { + background: #888; + border-radius: 4px; +} + +.wishlist-project-group-items::-webkit-scrollbar-thumb:hover { + background: #555; +} + +.wishlist-project-group-items .wishlist-card { + flex: 0 0 150px; + min-width: 150px; +} + +.wishlist-no-project { + margin-top: 1.5rem; +} + .wishlist-card { overflow: hidden; cursor: pointer; diff --git a/play-life-web/src/components/Wishlist.jsx b/play-life-web/src/components/Wishlist.jsx index 9d8d606..e3b6988 100644 --- a/play-life-web/src/components/Wishlist.jsx +++ b/play-life-web/src/components/Wishlist.jsx @@ -1,8 +1,9 @@ -import React, { useState, useEffect, useRef } from 'react' +import React, { useState, useEffect, useRef, useMemo } from 'react' import { useAuth } from './auth/AuthContext' import BoardSelector from './BoardSelector' import LoadingError from './LoadingError' import WishlistDetail from './WishlistDetail' +import { sortProjectsLikeCurrentWeek } from '../utils/projectUtils' import './Wishlist.css' const API_URL = '/api/wishlist' @@ -45,6 +46,7 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false, initialBoa const [completedLoading, setCompletedLoading] = useState(false) const [selectedItem, setSelectedItem] = useState(null) const [selectedWishlistForDetail, setSelectedWishlistForDetail] = useState(null) + const [currentWeekData, setCurrentWeekData] = useState(null) const fetchingRef = useRef(false) const fetchingCompletedRef = useRef(false) const initialFetchDoneRef = useRef(false) @@ -234,6 +236,24 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false, initialBoa } } + // Загрузка данных текущей недели для сортировки проектов + const fetchCurrentWeek = async () => { + try { + const response = await authFetch('/api/current-week') + if (response.ok) { + const data = await response.json() + // Обрабатываем ответ: приходит массив с одним объектом [{total: ..., projects: [...]}] + if (Array.isArray(data) && data.length > 0) { + setCurrentWeekData(data[0]) + } else if (data && typeof data === 'object') { + setCurrentWeekData(data) + } + } + } catch (err) { + console.error('Error loading current week data:', err) + } + } + // Первая инициализация useEffect(() => { if (!initialFetchDoneRef.current) { @@ -247,6 +267,9 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false, initialBoa // Загружаем доски с сервера fetchBoards() + + // Загружаем данные текущей недели для сортировки проектов + fetchCurrentWeek() } }, []) @@ -557,6 +580,60 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false, initialBoa ) } + // Группируем желания по проектам + const groupedItems = useMemo(() => { + const groups = {} + const noProjectItems = [] + + items.forEach(item => { + if (item.project_id && item.project_name) { + const projectId = item.project_id + if (!groups[projectId]) { + groups[projectId] = { + projectId: projectId, + projectName: item.project_name, + items: [] + } + } + groups[projectId].items.push(item) + } else { + noProjectItems.push(item) + } + }) + + // Сортируем группы проектов + const projectIds = Object.keys(groups) + if (currentWeekData && projectIds.length > 0) { + const projectNames = projectIds.map(id => groups[id].projectName) + const sortedProjectNames = sortProjectsLikeCurrentWeek(projectNames, currentWeekData) + + // Создаем отсортированный массив групп + const sortedGroups = [] + sortedProjectNames.forEach(projectName => { + const group = Object.values(groups).find(g => g.projectName === projectName) + if (group) { + sortedGroups.push(group) + } + }) + + // Добавляем группы, которых нет в currentWeekData (если есть) + Object.values(groups).forEach(group => { + if (!sortedProjectNames.includes(group.projectName)) { + sortedGroups.push(group) + } + }) + + return { groups: sortedGroups, noProjectItems } + } + + // Если нет данных текущей недели, сортируем по алфавиту + const sortedGroups = Object.values(groups).sort((a, b) => + a.projectName.localeCompare(b.projectName) + ) + + return { groups: sortedGroups, noProjectItems } + }, [items, currentWeekData]) + const renderItem = (item) => { const isFaded = (!item.unlocked && !item.completed) || item.completed @@ -646,9 +723,24 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false, initialBoa ) : ( <> -
- {items.map(renderItem)} -
+ {/* Группы проектов */} + {groupedItems.groups.map(group => ( +
+
{group.projectName}
+
+ {group.items.map(renderItem)} +
+
+ ))} + + {/* Желания без проекта */} + {groupedItems.noProjectItems.length > 0 && ( +
+
+ {groupedItems.noProjectItems.map(renderItem)} +
+
+ )} {/* Завершённые */} {completedCount > 0 && ( diff --git a/play-life-web/src/components/WishlistForm.jsx b/play-life-web/src/components/WishlistForm.jsx index edc0393..4e6bd2d 100644 --- a/play-life-web/src/components/WishlistForm.jsx +++ b/play-life-web/src/components/WishlistForm.jsx @@ -25,6 +25,7 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b const [editingConditionIndex, setEditingConditionIndex] = useState(null) const [tasks, setTasks] = useState([]) const [projects, setProjects] = useState([]) + const [selectedProjectId, setSelectedProjectId] = useState('') const [loading, setLoading] = useState(false) const [error, setError] = useState('') const [toastMessage, setToastMessage] = useState(null) @@ -86,6 +87,7 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b setPrice(data.price ? String(data.price) : '') setLink(data.link || '') setImageUrl(data.image_url || null) + setSelectedProjectId(data.project_id ? String(data.project_id) : '') if (data.unlock_conditions) { setUnlockConditions(data.unlock_conditions.map((cond, idx) => ({ id: cond.id || null, @@ -242,6 +244,7 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b setLink(data.link || '') setImageUrl(data.image_url || null) setImageFile(null) // Сбрасываем imageFile при загрузке существующего желания + setSelectedProjectId(data.project_id ? String(data.project_id) : '') if (data.unlock_conditions) { setUnlockConditions(data.unlock_conditions.map((cond, idx) => ({ id: cond.id || null, @@ -268,6 +271,7 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b setLink(data.link || '') setImageUrl(data.image_url || null) setImageFile(null) + setSelectedProjectId(data.project_id ? String(data.project_id) : '') } } catch (err) { setError(err.message) @@ -283,6 +287,7 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b setImageUrl(null) setImageFile(null) setUnlockConditions([]) + setSelectedProjectId('') setError('') setShowCropper(false) setCrop({ x: 0, y: 0 }) @@ -553,6 +558,7 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b name: name.trim(), price: price ? parseFloat(price) : null, link: link.trim() || null, + project_id: selectedProjectId ? parseInt(selectedProjectId, 10) : null, unlock_conditions: unlockConditions.map(cond => ({ id: cond.id || null, type: cond.type, @@ -841,6 +847,23 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b +
+ + +
+ {error &&
{error}
}