6.9.0: Задачи-закупки
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m20s

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
poignatov
2026-03-10 22:37:03 +03:00
parent 786a03bf86
commit 636f53eb04
12 changed files with 1363 additions and 21 deletions

View File

@@ -1 +1 @@
6.8.3 6.9.0

View File

@@ -338,6 +338,7 @@ type Task struct {
RepetitionDate *string `json:"repetition_date,omitempty"` RepetitionDate *string `json:"repetition_date,omitempty"`
WishlistID *int `json:"wishlist_id,omitempty"` WishlistID *int `json:"wishlist_id,omitempty"`
ConfigID *int `json:"config_id,omitempty"` ConfigID *int `json:"config_id,omitempty"`
PurchaseConfigID *int `json:"purchase_config_id,omitempty"`
RewardPolicy *string `json:"reward_policy,omitempty"` // "personal" или "general" для задач, связанных с желаниями RewardPolicy *string `json:"reward_policy,omitempty"` // "personal" или "general" для задач, связанных с желаниями
Position *int `json:"position,omitempty"` // Position for subtasks Position *int `json:"position,omitempty"` // Position for subtasks
// Дополнительные поля для списка задач (без omitempty чтобы всегда передавались) // Дополнительные поля для списка задач (без omitempty чтобы всегда передавались)
@@ -384,7 +385,8 @@ type TaskDetail struct {
// Test-specific fields (only present if task has config_id) // Test-specific fields (only present if task has config_id)
WordsCount *int `json:"words_count,omitempty"` WordsCount *int `json:"words_count,omitempty"`
MaxCards *int `json:"max_cards,omitempty"` MaxCards *int `json:"max_cards,omitempty"`
DictionaryIDs []int `json:"dictionary_ids,omitempty"` DictionaryIDs []int `json:"dictionary_ids,omitempty"`
PurchaseBoards []PurchaseBoardInfo `json:"purchase_boards,omitempty"`
// Draft fields (only present if draft exists) // Draft fields (only present if draft exists)
DraftProgressionValue *float64 `json:"draft_progression_value,omitempty"` DraftProgressionValue *float64 `json:"draft_progression_value,omitempty"`
DraftSubtasks []DraftSubtask `json:"draft_subtasks,omitempty"` DraftSubtasks []DraftSubtask `json:"draft_subtasks,omitempty"`
@@ -421,6 +423,20 @@ type TaskRequest struct {
WordsCount *int `json:"words_count,omitempty"` WordsCount *int `json:"words_count,omitempty"`
MaxCards *int `json:"max_cards,omitempty"` MaxCards *int `json:"max_cards,omitempty"`
DictionaryIDs []int `json:"dictionary_ids,omitempty"` DictionaryIDs []int `json:"dictionary_ids,omitempty"`
// Purchase-specific fields
IsPurchase bool `json:"is_purchase,omitempty"`
PurchaseBoards []PurchaseBoardRequest `json:"purchase_boards,omitempty"`
}
type PurchaseBoardRequest struct {
BoardID int `json:"board_id"`
GroupName *string `json:"group_name,omitempty"`
}
type PurchaseBoardInfo struct {
BoardID int `json:"board_id"`
BoardName string `json:"board_name"`
GroupName *string `json:"group_name,omitempty"`
} }
type CompleteTaskRequest struct { type CompleteTaskRequest struct {
@@ -4590,6 +4606,10 @@ func main() {
protected.HandleFunc("/api/shopping/invite/{token}", app.getShoppingBoardInviteInfoHandler).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") protected.HandleFunc("/api/shopping/invite/{token}/join", app.joinShoppingBoardHandler).Methods("POST", "OPTIONS")
// Purchase tasks
protected.HandleFunc("/api/purchase/boards-info", app.getPurchaseBoardsInfoHandler).Methods("GET", "OPTIONS")
protected.HandleFunc("/api/purchase/items/{purchaseConfigId}", app.getPurchaseItemsHandler).Methods("GET", "OPTIONS")
// Tracking // Tracking
protected.HandleFunc("/api/tracking/stats", app.getTrackingStatsHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/tracking/stats", app.getTrackingStatsHandler).Methods("GET", "OPTIONS")
protected.HandleFunc("/api/tracking/invite", app.createTrackingInviteHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/api/tracking/invite", app.createTrackingInviteHandler).Methods("POST", "OPTIONS")
@@ -7811,6 +7831,7 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) {
t.progression_base, t.progression_base,
t.wishlist_id, t.wishlist_id,
t.config_id, t.config_id,
t.purchase_config_id,
t.reward_policy, t.reward_policy,
t.group_name, t.group_name,
COALESCE(( COALESCE((
@@ -7869,6 +7890,7 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) {
var progressionBase sql.NullFloat64 var progressionBase sql.NullFloat64
var wishlistID sql.NullInt64 var wishlistID sql.NullInt64
var configID sql.NullInt64 var configID sql.NullInt64
var purchaseConfigID sql.NullInt64
var rewardPolicy sql.NullString var rewardPolicy sql.NullString
var groupName sql.NullString var groupName sql.NullString
var projectNames pq.StringArray var projectNames pq.StringArray
@@ -7887,6 +7909,7 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) {
&progressionBase, &progressionBase,
&wishlistID, &wishlistID,
&configID, &configID,
&purchaseConfigID,
&rewardPolicy, &rewardPolicy,
&groupName, &groupName,
&task.SubtasksCount, &task.SubtasksCount,
@@ -7926,6 +7949,10 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) {
configIDInt := int(configID.Int64) configIDInt := int(configID.Int64)
task.ConfigID = &configIDInt task.ConfigID = &configIDInt
} }
if purchaseConfigID.Valid {
purchaseConfigIDInt := int(purchaseConfigID.Int64)
task.PurchaseConfigID = &purchaseConfigIDInt
}
if rewardPolicy.Valid { if rewardPolicy.Valid {
task.RewardPolicy = &rewardPolicy.String task.RewardPolicy = &rewardPolicy.String
} }
@@ -7995,6 +8022,7 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) {
var repetitionDate sql.NullString var repetitionDate sql.NullString
var wishlistID sql.NullInt64 var wishlistID sql.NullInt64
var configID sql.NullInt64 var configID sql.NullInt64
var purchaseConfigID sql.NullInt64
var rewardPolicy sql.NullString var rewardPolicy sql.NullString
var groupName sql.NullString var groupName sql.NullString
@@ -8007,12 +8035,13 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) {
COALESCE(repetition_date, '') as repetition_date, COALESCE(repetition_date, '') as repetition_date,
wishlist_id, wishlist_id,
config_id, config_id,
purchase_config_id,
reward_policy, reward_policy,
group_name group_name
FROM tasks FROM tasks
WHERE id = $1 AND user_id = $2 AND deleted = FALSE WHERE id = $1 AND user_id = $2 AND deleted = FALSE
`, taskID, userID).Scan( `, taskID, userID).Scan(
&task.ID, &task.Name, &task.Completed, &lastCompletedAt, &nextShowAt, &rewardMessage, &progressionBase, &repetitionPeriodStr, &repetitionDateStr, &wishlistID, &configID, &rewardPolicy, &groupName, &task.ID, &task.Name, &task.Completed, &lastCompletedAt, &nextShowAt, &rewardMessage, &progressionBase, &repetitionPeriodStr, &repetitionDateStr, &wishlistID, &configID, &purchaseConfigID, &rewardPolicy, &groupName,
) )
log.Printf("Scanned repetition_period for task %d: String='%s', repetition_date='%s'", taskID, repetitionPeriodStr, repetitionDateStr) log.Printf("Scanned repetition_period for task %d: String='%s', repetition_date='%s'", taskID, repetitionPeriodStr, repetitionDateStr)
@@ -8069,6 +8098,10 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) {
configIDInt := int(configID.Int64) configIDInt := int(configID.Int64)
task.ConfigID = &configIDInt task.ConfigID = &configIDInt
} }
if purchaseConfigID.Valid {
purchaseConfigIDInt := int(purchaseConfigID.Int64)
task.PurchaseConfigID = &purchaseConfigIDInt
}
if rewardPolicy.Valid { if rewardPolicy.Valid {
task.RewardPolicy = &rewardPolicy.String task.RewardPolicy = &rewardPolicy.String
} }
@@ -8343,6 +8376,35 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) {
} }
} }
// Если задача - закупка (есть purchase_config_id), загружаем данные конфигурации
if purchaseConfigID.Valid {
boardRows, err := a.DB.Query(`
SELECT pcb.board_id, sb.name, pcb.group_name
FROM purchase_config_boards pcb
JOIN shopping_boards sb ON sb.id = pcb.board_id
WHERE pcb.purchase_config_id = $1
`, purchaseConfigID.Int64)
if err == nil {
defer boardRows.Close()
purchaseBoards := make([]PurchaseBoardInfo, 0)
for boardRows.Next() {
var info PurchaseBoardInfo
var groupName sql.NullString
if err := boardRows.Scan(&info.BoardID, &info.BoardName, &groupName); err == nil {
if groupName.Valid {
info.GroupName = &groupName.String
}
purchaseBoards = append(purchaseBoards, info)
}
}
if len(purchaseBoards) > 0 {
response.PurchaseBoards = purchaseBoards
}
} else {
log.Printf("Error loading purchase config for task %d: %v", taskID, err)
}
}
log.Printf("Task %d: Sending response with auto_complete = %v (task.AutoComplete = %v)", taskID, response.Task.AutoComplete, task.AutoComplete) log.Printf("Task %d: Sending response with auto_complete = %v (task.AutoComplete = %v)", taskID, response.Task.AutoComplete, task.AutoComplete)
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response) json.NewEncoder(w).Encode(response)
@@ -8816,6 +8878,45 @@ func (a *App) createTaskHandler(w http.ResponseWriter, r *http.Request) {
log.Printf("Created test config %d for task %d", configID, taskID) log.Printf("Created test config %d for task %d", configID, taskID)
} }
// Если это закупка, создаем конфигурацию
if req.IsPurchase {
if len(req.PurchaseBoards) == 0 {
sendErrorWithCORS(w, "At least one board is required for purchase tasks", http.StatusBadRequest)
return
}
var purchaseConfigID int
err = tx.QueryRow(`
INSERT INTO purchase_configs (user_id) VALUES ($1) RETURNING id
`, userID).Scan(&purchaseConfigID)
if err != nil {
log.Printf("Error creating purchase config: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error creating purchase config: %v", err), http.StatusInternalServerError)
return
}
for _, pb := range req.PurchaseBoards {
_, err = tx.Exec(`
INSERT INTO purchase_config_boards (purchase_config_id, board_id, group_name)
VALUES ($1, $2, $3)
`, purchaseConfigID, pb.BoardID, pb.GroupName)
if err != nil {
log.Printf("Error linking board %d to purchase config: %v", pb.BoardID, err)
sendErrorWithCORS(w, fmt.Sprintf("Error linking board to purchase config: %v", err), http.StatusInternalServerError)
return
}
}
_, err = tx.Exec("UPDATE tasks SET purchase_config_id = $1 WHERE id = $2", purchaseConfigID, taskID)
if err != nil {
log.Printf("Error linking purchase config to task: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error linking purchase config to task: %v", err), http.StatusInternalServerError)
return
}
log.Printf("Created purchase config %d for task %d", purchaseConfigID, taskID)
}
// Коммитим транзакцию // Коммитим транзакцию
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
log.Printf("Error committing transaction: %v", err) log.Printf("Error committing transaction: %v", err)
@@ -9363,6 +9464,90 @@ func (a *App) updateTaskHandler(w http.ResponseWriter, r *http.Request) {
} }
} }
// Обработка конфигурации закупки
var currentPurchaseConfigID sql.NullInt64
err = tx.QueryRow("SELECT purchase_config_id FROM tasks WHERE id = $1", taskID).Scan(&currentPurchaseConfigID)
if err != nil {
log.Printf("Error getting current purchase_config_id: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error getting task purchase config: %v", err), http.StatusInternalServerError)
return
}
if req.IsPurchase {
if len(req.PurchaseBoards) == 0 {
sendErrorWithCORS(w, "At least one board is required for purchase tasks", http.StatusBadRequest)
return
}
if currentPurchaseConfigID.Valid {
// Обновляем существующую конфигурацию - удаляем старые связи и создаем новые
_, err = tx.Exec("DELETE FROM purchase_config_boards WHERE purchase_config_id = $1", currentPurchaseConfigID.Int64)
if err != nil {
log.Printf("Error deleting purchase config boards: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error updating purchase config: %v", err), http.StatusInternalServerError)
return
}
for _, pb := range req.PurchaseBoards {
_, err = tx.Exec(`
INSERT INTO purchase_config_boards (purchase_config_id, board_id, group_name)
VALUES ($1, $2, $3)
`, currentPurchaseConfigID.Int64, pb.BoardID, pb.GroupName)
if err != nil {
log.Printf("Error linking board %d to purchase config: %v", pb.BoardID, err)
sendErrorWithCORS(w, fmt.Sprintf("Error linking board to purchase config: %v", err), http.StatusInternalServerError)
return
}
}
} else {
// Создаем новую конфигурацию закупки
var newPurchaseConfigID int
err = tx.QueryRow(`
INSERT INTO purchase_configs (user_id) VALUES ($1) RETURNING id
`, userID).Scan(&newPurchaseConfigID)
if err != nil {
log.Printf("Error creating purchase config: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error creating purchase config: %v", err), http.StatusInternalServerError)
return
}
for _, pb := range req.PurchaseBoards {
_, err = tx.Exec(`
INSERT INTO purchase_config_boards (purchase_config_id, board_id, group_name)
VALUES ($1, $2, $3)
`, newPurchaseConfigID, pb.BoardID, pb.GroupName)
if err != nil {
log.Printf("Error linking board %d to purchase config: %v", pb.BoardID, err)
sendErrorWithCORS(w, fmt.Sprintf("Error linking board to purchase config: %v", err), http.StatusInternalServerError)
return
}
}
_, err = tx.Exec("UPDATE tasks SET purchase_config_id = $1 WHERE id = $2", newPurchaseConfigID, taskID)
if err != nil {
log.Printf("Error linking purchase config to task: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error linking purchase config to task: %v", err), http.StatusInternalServerError)
return
}
}
} else if currentPurchaseConfigID.Valid {
// Задача перестала быть закупкой - удаляем конфигурацию
_, err = tx.Exec("DELETE FROM purchase_config_boards WHERE purchase_config_id = $1", currentPurchaseConfigID.Int64)
if err != nil {
log.Printf("Error deleting purchase config boards: %v", err)
}
_, err = tx.Exec("DELETE FROM purchase_configs WHERE id = $1", currentPurchaseConfigID.Int64)
if err != nil {
log.Printf("Error deleting purchase config: %v", err)
}
_, err = tx.Exec("UPDATE tasks SET purchase_config_id = NULL WHERE id = $1", taskID)
if err != nil {
log.Printf("Error unlinking purchase config from task: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error unlinking purchase config from task: %v", err), http.StatusInternalServerError)
return
}
}
// Коммитим транзакцию // Коммитим транзакцию
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
log.Printf("Error committing transaction: %v", err) log.Printf("Error committing transaction: %v", err)
@@ -19269,3 +19454,268 @@ func (a *App) deleteShoppingItemHistoryHandler(w http.ResponseWriter, r *http.Re
"success": true, "success": true,
}) })
} }
// getPurchaseBoardsInfoHandler возвращает доски пользователя с их группами для формы закупок
func (a *App) getPurchaseBoardsInfoHandler(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
}
// Получаем все доски пользователя (свои и расшаренные)
boardRows, err := a.DB.Query(`
SELECT sb.id, sb.name
FROM shopping_boards sb
WHERE sb.deleted = FALSE AND (
sb.owner_id = $1
OR EXISTS (SELECT 1 FROM shopping_board_members sbm WHERE sbm.board_id = sb.id AND sbm.user_id = $1)
)
ORDER BY sb.name
`, userID)
if err != nil {
log.Printf("Error getting boards for purchase config: %v", err)
sendErrorWithCORS(w, "Error getting boards", http.StatusInternalServerError)
return
}
defer boardRows.Close()
type BoardInfo struct {
ID int `json:"id"`
Name string `json:"name"`
Groups []string `json:"groups"`
}
boards := make([]BoardInfo, 0)
boardIDs := make([]int, 0)
for boardRows.Next() {
var board BoardInfo
if err := boardRows.Scan(&board.ID, &board.Name); err != nil {
log.Printf("Error scanning board: %v", err)
continue
}
board.Groups = make([]string, 0)
boards = append(boards, board)
boardIDs = append(boardIDs, board.ID)
}
// Получаем группы для каждой доски
for i, boardID := range boardIDs {
groupRows, err := a.DB.Query(`
SELECT DISTINCT group_name
FROM shopping_items
WHERE board_id = $1 AND deleted = FALSE AND group_name IS NOT NULL AND group_name != ''
ORDER BY group_name
`, boardID)
if err != nil {
log.Printf("Error getting groups for board %d: %v", boardID, err)
continue
}
for groupRows.Next() {
var groupName string
if err := groupRows.Scan(&groupName); err == nil {
boards[i].Groups = append(boards[i].Groups, groupName)
}
}
groupRows.Close()
// Проверяем наличие товаров без группы
var hasUngrouped bool
a.DB.QueryRow(`
SELECT EXISTS(SELECT 1 FROM shopping_items WHERE board_id = $1 AND deleted = FALSE AND (group_name IS NULL OR group_name = ''))
`, boardID).Scan(&hasUngrouped)
if hasUngrouped {
boards[i].Groups = append(boards[i].Groups, "")
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"boards": boards,
})
}
// getPurchaseItemsHandler возвращает товары для конфигурации закупки
func (a *App) getPurchaseItemsHandler(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)
purchaseConfigID, err := strconv.Atoi(vars["purchaseConfigId"])
if err != nil {
sendErrorWithCORS(w, "Invalid purchase config ID", http.StatusBadRequest)
return
}
// Проверяем что конфиг принадлежит пользователю
var configUserID int
err = a.DB.QueryRow("SELECT user_id FROM purchase_configs WHERE id = $1", purchaseConfigID).Scan(&configUserID)
if err == sql.ErrNoRows {
sendErrorWithCORS(w, "Purchase config not found", http.StatusNotFound)
return
}
if err != nil {
sendErrorWithCORS(w, "Error checking purchase config", http.StatusInternalServerError)
return
}
if configUserID != userID {
sendErrorWithCORS(w, "Access denied", http.StatusForbidden)
return
}
// Получаем связанные доски и группы
boardRows, err := a.DB.Query(`
SELECT board_id, group_name
FROM purchase_config_boards
WHERE purchase_config_id = $1
`, purchaseConfigID)
if err != nil {
sendErrorWithCORS(w, "Error getting purchase config boards", http.StatusInternalServerError)
return
}
defer boardRows.Close()
type boardFilter struct {
BoardID int
GroupName *string
}
filters := make([]boardFilter, 0)
for boardRows.Next() {
var f boardFilter
var groupName sql.NullString
if err := boardRows.Scan(&f.BoardID, &groupName); err == nil {
if groupName.Valid {
f.GroupName = &groupName.String
}
filters = append(filters, f)
}
}
if len(filters) == 0 {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode([]ShoppingItem{})
return
}
// Строим динамический запрос для получения товаров
items := make([]ShoppingItem, 0)
for _, f := range filters {
var query string
var args []interface{}
if f.GroupName != nil {
if *f.GroupName == "" {
// Пустая строка означает "товары без группы"
query = `
SELECT
si.id, si.user_id, si.board_id, si.author_id, si.name, si.description, 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 AND (si.group_name IS NULL OR si.group_name = '')
ORDER BY si.created_at ASC
`
args = []interface{}{f.BoardID}
} else {
query = `
SELECT
si.id, si.user_id, si.board_id, si.author_id, si.name, si.description, 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 AND si.group_name = $2
ORDER BY si.created_at ASC
`
args = []interface{}{f.BoardID, *f.GroupName}
}
} else {
query = `
SELECT
si.id, si.user_id, si.board_id, si.author_id, si.name, si.description, 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
`
args = []interface{}{f.BoardID}
}
rows, err := a.DB.Query(query, args...)
if err != nil {
log.Printf("Error getting purchase items for board %d: %v", f.BoardID, err)
continue
}
for rows.Next() {
var item ShoppingItem
var description sql.NullString
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, &description, &groupName,
&item.VolumeBase, &repetitionPeriod, &nextShowAt, &item.Completed,
&lastCompletedAt, &createdAt,
)
if err != nil {
log.Printf("Error scanning purchase item: %v", err)
continue
}
if description.Valid {
item.Description = &description.String
}
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)
}
rows.Close()
}
// Дедупликация (товар может попасть из нескольких фильтров)
seen := make(map[int]bool)
uniqueItems := make([]ShoppingItem, 0, len(items))
for _, item := range items {
if !seen[item.ID] {
seen[item.ID] = true
uniqueItems = append(uniqueItems, item)
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(uniqueItems)
}

View File

@@ -0,0 +1,3 @@
ALTER TABLE tasks DROP COLUMN IF EXISTS purchase_config_id;
DROP TABLE IF EXISTS purchase_config_boards;
DROP TABLE IF EXISTS purchase_configs;

View File

@@ -0,0 +1,24 @@
-- Purchase task configurations
CREATE TABLE purchase_configs (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_purchase_configs_user_id ON purchase_configs(user_id);
-- Purchase config board/group associations
CREATE TABLE purchase_config_boards (
id SERIAL PRIMARY KEY,
purchase_config_id INTEGER NOT NULL REFERENCES purchase_configs(id) ON DELETE CASCADE,
board_id INTEGER NOT NULL REFERENCES shopping_boards(id) ON DELETE CASCADE,
group_name VARCHAR(255),
UNIQUE (purchase_config_id, board_id, group_name)
);
CREATE INDEX idx_purchase_config_boards_config_id ON purchase_config_boards(purchase_config_id);
CREATE INDEX idx_purchase_config_boards_board_id ON purchase_config_boards(board_id);
-- Add purchase_config_id to tasks
ALTER TABLE tasks ADD COLUMN purchase_config_id INTEGER REFERENCES purchase_configs(id) ON DELETE SET NULL;
CREATE INDEX idx_tasks_purchase_config_id ON tasks(purchase_config_id);

View File

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

View File

@@ -19,6 +19,7 @@ import ShoppingItemForm from './components/ShoppingItemForm'
import ShoppingBoardForm from './components/ShoppingBoardForm' import ShoppingBoardForm from './components/ShoppingBoardForm'
import ShoppingBoardJoinPreview from './components/ShoppingBoardJoinPreview' import ShoppingBoardJoinPreview from './components/ShoppingBoardJoinPreview'
import ShoppingItemHistory from './components/ShoppingItemHistory' import ShoppingItemHistory from './components/ShoppingItemHistory'
import PurchaseScreen from './components/PurchaseScreen'
import TodoistIntegration from './components/TodoistIntegration' import TodoistIntegration from './components/TodoistIntegration'
import TelegramIntegration from './components/TelegramIntegration' import TelegramIntegration from './components/TelegramIntegration'
import FitbitIntegration from './components/FitbitIntegration' import FitbitIntegration from './components/FitbitIntegration'
@@ -35,7 +36,7 @@ const FULL_STATISTICS_API_URL = '/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b'
// Определяем основные табы (без крестика) и глубокие табы (с крестиком) // Определяем основные табы (без крестика) и глубокие табы (с крестиком)
const mainTabs = ['current', 'tasks', 'wishlist', 'shopping', 'profile'] 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', 'shopping-item-history'] const deepTabs = ['add-words', 'test', 'purchase', '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', 'shopping-item-history']
/** /**
* Гарантирует базовую запись истории для главного экрана перед глубоким табом. * Гарантирует базовую запись истории для главного экрана перед глубоким табом.
@@ -87,6 +88,7 @@ function AppContent() {
'shopping-board-form': false, 'shopping-board-form': false,
'shopping-board-join': false, 'shopping-board-join': false,
'shopping-item-history': false, 'shopping-item-history': false,
purchase: false,
}) })
// Отслеживаем, какие табы уже были загружены (для предотвращения повторных загрузок) // Отслеживаем, какие табы уже были загружены (для предотвращения повторных загрузок)
@@ -117,6 +119,7 @@ function AppContent() {
'shopping-board-form': false, 'shopping-board-form': false,
'shopping-board-join': false, 'shopping-board-join': false,
'shopping-item-history': false, 'shopping-item-history': false,
purchase: false,
}) })
// Параметры для навигации между вкладками // Параметры для навигации между вкладками
@@ -295,7 +298,7 @@ function AppContent() {
// Проверяем URL только для глубоких табов // Проверяем URL только для глубоких табов
const tabFromUrl = urlParams.get('tab') 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', 'shopping', 'shopping-item-form', 'shopping-board-form', 'shopping-board-join', 'shopping-item-history'] const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'purchase', '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', 'shopping-item-history']
if (tabFromUrl && validTabs.includes(tabFromUrl) && deepTabs.includes(tabFromUrl) && window.history.length > 1) { if (tabFromUrl && validTabs.includes(tabFromUrl) && deepTabs.includes(tabFromUrl) && window.history.length > 1) {
// Восстанавливаем глубокий таб из URL только если есть история (не рестарт PWA) // Восстанавливаем глубокий таб из URL только если есть история (не рестарт PWA)
@@ -792,7 +795,7 @@ function AppContent() {
return 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', 'shopping', 'shopping-item-form', 'shopping-board-form', 'shopping-board-join', 'shopping-item-history'] const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'purchase', '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', 'shopping-item-history']
// Проверяем state текущей записи истории (куда мы вернулись) // Проверяем state текущей записи истории (куда мы вернулись)
if (event.state && event.state.tab) { if (event.state && event.state.tab) {
@@ -909,7 +912,7 @@ function AppContent() {
{ {
// Для task-form и wishlist-form явно удаляем параметры, только если нет никаких параметров // Для task-form и wishlist-form явно удаляем параметры, только если нет никаких параметров
// task-form может иметь taskId (редактирование), wishlistId (создание из желания), returnTo (возврат после создания), или isTest (создание теста) // task-form может иметь taskId (редактирование), wishlistId (создание из желания), returnTo (возврат после создания), или isTest (создание теста)
const isTaskFormWithNoParams = tab === 'task-form' && params.taskId === undefined && params.wishlistId === undefined && params.returnTo === undefined && params.isTest === undefined const isTaskFormWithNoParams = tab === 'task-form' && params.taskId === undefined && params.wishlistId === undefined && params.returnTo === undefined && params.isTest === undefined && params.isPurchase === undefined
// Проверяем, что boardId не null и не undefined (null означает "нет доски", но это валидное значение) // Проверяем, что boardId не null и не undefined (null означает "нет доски", но это валидное значение)
const hasBoardId = params.boardId !== null && params.boardId !== undefined const hasBoardId = params.boardId !== null && params.boardId !== undefined
const isWishlistFormWithNoParams = tab === 'wishlist-form' && params.wishlistId === undefined && params.newTaskId === undefined && !hasBoardId const isWishlistFormWithNoParams = tab === 'wishlist-form' && params.wishlistId === undefined && params.newTaskId === undefined && !hasBoardId
@@ -976,7 +979,7 @@ function AppContent() {
} }
// Обновляем список задач при возврате из экрана редактирования или теста // Обновляем список задач при возврате из экрана редактирования или теста
// Используем фоновую загрузку, чтобы не показывать индикатор загрузки // Используем фоновую загрузку, чтобы не показывать индикатор загрузки
if ((activeTab === 'task-form' || activeTab === 'test') && tab === 'tasks') { if ((activeTab === 'task-form' || activeTab === 'test' || activeTab === 'purchase') && tab === 'tasks') {
fetchTasksData(true) fetchTasksData(true)
} }
// Сохраняем предыдущий таб при открытии wishlist-form или wishlist-detail // Сохраняем предыдущий таб при открытии wishlist-form или wishlist-detail
@@ -1037,6 +1040,11 @@ function AppContent() {
handleNavigate('task-form', { taskId: undefined, isTest: true }) handleNavigate('task-form', { taskId: undefined, isTest: true })
} }
const handleAddPurchase = () => {
setShowAddModal(false)
handleNavigate('task-form', { taskId: undefined, isPurchase: true })
}
// Обработчик навигации для компонентов // Обработчик навигации для компонентов
const handleNavigate = (tab, params = {}, options = {}) => { const handleNavigate = (tab, params = {}, options = {}) => {
handleTabChange(tab, params, options) handleTabChange(tab, params, options)
@@ -1116,7 +1124,7 @@ function AppContent() {
} }
// Определяем, нужно ли скрывать нижнюю панель (для fullscreen экранов) // Определяем, нужно ли скрывать нижнюю панель (для 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' || activeTab === 'shopping-item-form' || activeTab === 'shopping-board-form' || activeTab === 'shopping-board-join' || activeTab === 'shopping-item-history' const isFullscreenTab = activeTab === 'test' || activeTab === 'purchase' || 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' || activeTab === 'shopping-item-history'
// Функция для получения классов скролл-контейнера для каждого таба // Функция для получения классов скролл-контейнера для каждого таба
// Каждый таб имеет свой изолированный скролл-контейнер для автоматического сохранения позиции скролла // Каждый таб имеет свой изолированный скролл-контейнер для автоматического сохранения позиции скролла
@@ -1145,7 +1153,7 @@ function AppContent() {
if (tabName === 'current') { if (tabName === 'current') {
return 'max-w-7xl mx-auto p-4 md:p-6' return 'max-w-7xl mx-auto p-4 md:p-6'
} }
if (tabName === 'full' || tabName === 'priorities' || tabName === 'dictionaries' || tabName === 'words' || tabName === 'shopping-item-history') { if (tabName === 'full' || tabName === 'priorities' || tabName === 'dictionaries' || tabName === 'words' || tabName === 'shopping-item-history' || tabName === 'purchase') {
return 'max-w-7xl mx-auto px-4 md:px-8 py-0' return 'max-w-7xl mx-auto px-4 md:px-8 py-0'
} }
// Fullscreen табы без отступов // Fullscreen табы без отступов
@@ -1257,7 +1265,7 @@ function AppContent() {
{loadedTabs.test && ( {loadedTabs.test && (
<div className={getTabContainerClasses('test')}> <div className={getTabContainerClasses('test')}>
<div className={getInnerContainerClasses('test')}> <div className={getInnerContainerClasses('test')}>
<TestWords <TestWords
onNavigate={handleNavigate} onNavigate={handleNavigate}
wordCount={tabParams.wordCount} wordCount={tabParams.wordCount}
configId={tabParams.configId} configId={tabParams.configId}
@@ -1268,6 +1276,19 @@ function AppContent() {
</div> </div>
)} )}
{loadedTabs.purchase && (
<div className={getTabContainerClasses('purchase')}>
<div className={getInnerContainerClasses('purchase')}>
<PurchaseScreen
onNavigate={handleNavigate}
purchaseConfigId={tabParams.purchaseConfigId}
taskId={tabParams.taskId}
taskName={tabParams.taskName}
/>
</div>
</div>
)}
{loadedTabs.tasks && ( {loadedTabs.tasks && (
<div className={getTabContainerClasses('tasks')}> <div className={getTabContainerClasses('tasks')}>
<div className={getInnerContainerClasses('tasks')}> <div className={getInnerContainerClasses('tasks')}>
@@ -1293,6 +1314,7 @@ function AppContent() {
taskId={tabParams.taskId} taskId={tabParams.taskId}
wishlistId={tabParams.wishlistId} wishlistId={tabParams.wishlistId}
isTest={tabParams.isTest} isTest={tabParams.isTest}
isPurchase={tabParams.isPurchase}
returnTo={tabParams.returnTo} returnTo={tabParams.returnTo}
returnWishlistId={tabParams.returnWishlistId} returnWishlistId={tabParams.returnWishlistId}
/> />
@@ -1787,6 +1809,17 @@ function AppContent() {
</svg> </svg>
Тест Тест
</button> </button>
<button
className="task-add-modal-button task-add-modal-button-purchase"
onClick={handleAddPurchase}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="9" cy="21" r="1"></circle>
<circle cx="20" cy="21" r="1"></circle>
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"></path>
</svg>
Закупка
</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,640 @@
import React, { useState, useEffect, useRef, useMemo } from 'react'
import { createPortal } from 'react-dom'
import { useAuth } from './auth/AuthContext'
import ShoppingItemDetail from './ShoppingItemDetail'
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'
// Форматирование даты в 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.round((target - now) / (1000 * 60 * 60 * 24))
if (diffDays === 0) return 'Сегодня'
if (diffDays === 1) return 'Завтра'
const months = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря']
return `${date.getDate()} ${months[date.getMonth()]}`
}
const calculateNextDateFromRepetitionPeriod = (periodStr) => {
if (!periodStr) return null
const match = periodStr.match(/(\d+)\s*(day|week|mon|year)/i)
if (!match) return null
const value = parseInt(match[1], 10)
const unit = match[2].toLowerCase()
const next = new Date()
next.setHours(0, 0, 0, 0)
if (unit.startsWith('day')) next.setDate(next.getDate() + value)
else if (unit.startsWith('week')) next.setDate(next.getDate() + value * 7)
else if (unit.startsWith('mon')) next.setMonth(next.getMonth() + value)
else if (unit.startsWith('year')) next.setFullYear(next.getFullYear() + value)
return next
}
function PurchaseScreen({ onNavigate, purchaseConfigId, taskId, taskName }) {
const { authFetch } = useAuth()
const [items, setItems] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(false)
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 [expandedFuture, setExpandedFuture] = useState({})
const [isCompleting, setIsCompleting] = useState(false)
const historyPushedForDetailRef = useRef(false)
const historyPushedForPostponeRef = useRef(false)
const selectedItemForDetailRef = useRef(null)
const selectedItemForPostponeRef = useRef(null)
const fetchItems = async () => {
if (!purchaseConfigId) return
try {
setLoading(true)
setError(false)
const response = await authFetch(`/api/purchase/items/${purchaseConfigId}`)
if (response.ok) {
const data = await response.json()
setItems(Array.isArray(data) ? data : [])
} else {
setError(true)
}
} catch (err) {
console.error('Error loading purchase items:', err)
setError(true)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchItems()
}, [purchaseConfigId])
const handleRefresh = () => {
fetchItems()
}
const handleClose = () => {
onNavigate?.('tasks')
}
const handleCompleteTask = async () => {
if (!taskId || isCompleting) return
setIsCompleting(true)
try {
const response = await authFetch(`/api/tasks/${taskId}/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
})
if (response.ok) {
setToast({ message: 'Задача выполнена', type: 'success' })
setTimeout(() => onNavigate?.('tasks'), 500)
} else {
const errorData = await response.json().catch(() => ({}))
setToast({ message: errorData.error || 'Ошибка выполнения', type: 'error' })
}
} catch (err) {
setToast({ message: 'Ошибка выполнения', type: 'error' })
} finally {
setIsCompleting(false)
}
}
// Синхронизация refs для диалогов
useEffect(() => {
selectedItemForDetailRef.current = selectedItemForDetail
selectedItemForPostponeRef.current = selectedItemForPostpone
}, [selectedItemForDetail, selectedItemForPostpone])
// Пуш в историю при открытии модалок и обработка popstate
useEffect(() => {
if (selectedItemForPostpone && !historyPushedForPostponeRef.current) {
window.history.pushState({ modalOpen: true, type: 'purchase-postpone' }, '', window.location.href)
historyPushedForPostponeRef.current = true
} else if (!selectedItemForPostpone) {
historyPushedForPostponeRef.current = false
}
if (selectedItemForDetail && !historyPushedForDetailRef.current) {
window.history.pushState({ modalOpen: true, type: 'purchase-detail' }, '', window.location.href)
historyPushedForDetailRef.current = true
} else if (!selectedItemForDetail) {
historyPushedForDetailRef.current = false
}
if (!selectedItemForDetail && !selectedItemForPostpone) return
const handlePopState = () => {
const currentDetail = selectedItemForDetailRef.current
const currentPostpone = selectedItemForPostponeRef.current
if (currentPostpone) {
setSelectedItemForPostpone(null)
setPostponeDate('')
historyPushedForPostponeRef.current = false
return
}
if (currentDetail) {
setSelectedItemForDetail(null)
historyPushedForDetailRef.current = false
}
}
window.addEventListener('popstate', handlePopState)
return () => {
window.removeEventListener('popstate', handlePopState)
}
}, [selectedItemForDetail, selectedItemForPostpone])
// Фильтрация и группировка
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) {
groups[groupKey].future.push(item)
return
}
const showAt = new Date(item.next_show_at)
if (showAt > todayEnd) {
groups[groupKey].future.push(item)
return
}
groups[groupKey].active.push(item)
})
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 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]
}))
}
const handleCloseDetail = () => {
if (historyPushedForDetailRef.current) {
window.history.back()
} else {
setSelectedItemForDetail(null)
}
}
const handlePostponeClose = () => {
if (historyPushedForPostponeRef.current) {
window.history.back()
} else {
setSelectedItemForPostpone(null)
setPostponeDate('')
}
}
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 handleDateSelect = (date) => {
if (date) {
setPostponeDate(formatDateToLocal(date))
}
}
const handleDayClick = (date) => {
if (date) {
handlePostponeSubmitWithDate(formatDateToLocal(date))
}
}
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 renderItem = (item) => {
let dateDisplay = null
if (item.next_show_at) {
const itemDate = new Date(item.next_show_at)
const now = new Date()
now.setHours(0, 0, 0, 0)
const target = new Date(itemDate.getFullYear(), itemDate.getMonth(), itemDate.getDate())
if (target > now) {
dateDisplay = formatDateForDisplay(item.next_show_at)
}
}
return (
<div
key={item.id}
className="task-item"
onClick={() => setSelectedItemForDetail(item.id)}
>
<div className="task-item-content">
<div
className="task-checkmark"
onClick={(e) => {
e.stopPropagation()
setSelectedItemForDetail(item.id)
}}
title="Выполнить"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="10" cy="10" r="9" stroke="currentColor" strokeWidth="2" fill="none" className="checkmark-circle" />
<path d="M6 10 L9 13 L14 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="checkmark-check" />
</svg>
</div>
<div className="task-name-container">
<div className="task-name-wrapper">
<div className="task-name">{item.name}</div>
{dateDisplay && (
<div className="task-next-show-date">{dateDisplay}</div>
)}
</div>
</div>
<div className="task-actions">
<button
className="task-postpone-button"
onClick={(e) => {
e.stopPropagation()
setSelectedItemForPostpone(item)
// Предвыбираем дату "по плану" если она не совпадает с текущей next_show_at
const now2 = new Date()
now2.setHours(0, 0, 0, 0)
let planned
if (item.repetition_period) {
planned = calculateNextDateFromRepetitionPeriod(item.repetition_period)
}
if (!planned) {
planned = new Date(now2)
planned.setDate(planned.getDate() + 1)
}
planned.setHours(0, 0, 0, 0)
const plannedStr = formatDateToLocal(planned)
let nextShowStr = null
if (item.next_show_at) {
nextShowStr = formatDateToLocal(new Date(item.next_show_at))
}
if (plannedStr !== nextShowStr) {
setPostponeDate(plannedStr)
} else {
setPostponeDate('')
}
}}
title="Перенести"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="10" cy="10" r="8" stroke="currentColor" strokeWidth="1.5" fill="none"/>
<path d="M10 5V10L13 12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" fill="none"/>
</svg>
</button>
</div>
</div>
</div>
)
}
return (
<div className="max-w-2xl mx-auto" style={{ paddingBottom: taskId ? '5rem' : '2.5rem' }}>
<button className="close-x-button" onClick={handleClose}></button>
<h2 className="text-2xl font-semibold text-gray-800 mb-6" style={{ marginTop: '1.25rem' }}>{taskName || 'Закупка'}</h2>
{loading && items.length === 0 && (
<div className="shopping-loading">
<div className="w-8 h-8 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin"></div>
</div>
)}
{error && (
<div className="shopping-empty">
<p>Ошибка загрузки</p>
<button onClick={handleRefresh} style={{ marginTop: '8px', color: 'var(--accent-color)' }}>
Повторить
</button>
</div>
)}
{!loading && !error && items.length === 0 && (
<div className="shopping-empty">
<p>Нет товаров</p>
</div>
)}
{groupNames.map(groupName => {
const group = groupedItems[groupName]
const hasActive = group.active.length > 0
const hasFuture = group.future.length > 0
const isFutureExpanded = expandedFuture[groupName]
return (
<div key={groupName} className={`project-group ${!hasActive ? 'project-group-no-tasks' : ''}`}>
<div
className={`project-group-header ${hasFuture ? 'project-group-header-clickable' : ''}`}
onClick={hasFuture ? () => toggleFuture(groupName) : undefined}
>
<h3 className={`project-group-title ${!hasActive ? 'project-group-title-empty' : ''}`}>{groupName}</h3>
{hasFuture ? (
<button
className="completed-toggle-header"
onClick={(e) => {
e.stopPropagation()
toggleFuture(groupName)
}}
title={isFutureExpanded ? 'Скрыть ожидающие' : 'Показать ожидающие'}
>
<span className="completed-toggle-icon">
{isFutureExpanded ? '▼' : '▶'}
</span>
</button>
) : (
<div className="completed-toggle-header" style={{ visibility: 'hidden', pointerEvents: 'none' }}>
<span className="completed-toggle-icon"></span>
</div>
)}
</div>
{hasActive && (
<div className="task-group">
{group.active.map(item => renderItem(item))}
</div>
)}
{hasFuture && isFutureExpanded && (
<div className="task-group completed-tasks">
{group.future.map(item => renderItem(item))}
</div>
)}
</div>
)
})}
{/* Кнопка завершения задачи — фиксированная внизу */}
{!loading && !error && taskId && createPortal(
<div style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
padding: '0.75rem 1rem',
paddingBottom: 'max(0.75rem, env(safe-area-inset-bottom))',
background: 'linear-gradient(to top, white 60%, rgba(255,255,255,0))',
zIndex: 1500,
display: 'flex',
justifyContent: 'center',
}}>
<button
onClick={handleCompleteTask}
disabled={isCompleting}
style={{
width: '100%',
maxWidth: '42rem',
padding: '0.875rem',
background: 'linear-gradient(to right, #10b981, #059669)',
color: 'white',
border: 'none',
borderRadius: '0.5rem',
fontSize: '1rem',
fontWeight: 600,
cursor: isCompleting ? 'not-allowed' : 'pointer',
opacity: isCompleting ? 0.6 : 1,
transition: 'all 0.2s',
}}
>
{isCompleting ? 'Выполняется...' : 'Завершить'}
</button>
</div>,
document.body
)}
{/* Модалка выполнения */}
{selectedItemForDetail && (
<ShoppingItemDetail
itemId={selectedItemForDetail}
onClose={handleCloseDetail}
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 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 = (
<div className="task-postpone-modal-overlay" onClick={handlePostponeClose}>
<div className="task-postpone-modal" onClick={(e) => e.stopPropagation()}>
<div className="task-postpone-modal-header">
<h3>{selectedItemForPostpone.name}</h3>
<button onClick={handlePostponeClose} className="task-postpone-close-button">
</button>
</div>
<div className="task-postpone-modal-content">
<div className="task-postpone-calendar">
<DayPicker
mode="single"
selected={postponeDate ? new Date(postponeDate + 'T00:00:00') : undefined}
onSelect={handleDateSelect}
onDayClick={handleDayClick}
disabled={{ before: (() => {
const today = new Date()
today.setHours(0, 0, 0, 0)
return today
})() }}
locale={ru}
/>
</div>
<div className="task-postpone-quick-buttons">
{showTodayChip && (
<button
className="task-postpone-quick-button"
onClick={handleTodayClick}
disabled={isPostponing}
>
Сегодня
</button>
)}
{!isTomorrow && (
<button
className="task-postpone-quick-button"
onClick={handleTomorrowClick}
disabled={isPostponing}
>
Завтра
</button>
)}
{!isCurrentDatePlanned && (
<button
className="task-postpone-quick-button"
onClick={() => handlePostponeSubmitWithDate(plannedDateStr)}
disabled={isPostponing}
>
По плану
</button>
)}
{nextShowAtStr && (
<button
className="task-postpone-quick-button task-postpone-quick-button-danger"
onClick={handleWithoutDateClick}
disabled={isPostponing}
>
Без даты
</button>
)}
</div>
</div>
</div>
</div>
)
return typeof document !== 'undefined'
? createPortal(modalContent, document.body)
: modalContent
})()}
{toast && (
<Toast
message={toast.message}
type={toast.type}
onClose={() => setToast(null)}
/>
)}
</div>
)
}
export default PurchaseScreen

View File

@@ -398,6 +398,32 @@ function ShoppingList({ onNavigate, refreshTrigger = 0, isActive = false, initia
} }
} }
const openPostpone = (item) => {
setSelectedItemForPostpone(item)
// Предвыбираем дату "по плану" если она не совпадает с текущей next_show_at
const now2 = new Date()
now2.setHours(0, 0, 0, 0)
let planned
if (item.repetition_period) {
planned = calculateNextDateFromRepetitionPeriod(item.repetition_period)
}
if (!planned) {
planned = new Date(now2)
planned.setDate(planned.getDate() + 1)
}
planned.setHours(0, 0, 0, 0)
const plannedStr = formatDateToLocal(planned)
let nextShowStr = null
if (item.next_show_at) {
nextShowStr = formatDateToLocal(new Date(item.next_show_at))
}
if (plannedStr !== nextShowStr) {
setPostponeDate(plannedStr)
} else {
setPostponeDate('')
}
}
// Модалка переноса // Модалка переноса
const handlePostponeClose = () => { const handlePostponeClose = () => {
if (historyPushedForPostponeRef.current) { if (historyPushedForPostponeRef.current) {
@@ -619,7 +645,7 @@ function ShoppingList({ onNavigate, refreshTrigger = 0, isActive = false, initia
className="task-postpone-button" className="task-postpone-button"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
setSelectedItemForPostpone(item) openPostpone(item)
}} }}
title="Перенести" title="Перенести"
> >

View File

@@ -513,7 +513,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
padding: 0.5rem; padding: 0.25rem 0.5rem;
border-radius: 0.25rem; border-radius: 0.25rem;
cursor: pointer; cursor: pointer;
transition: background-color 0.2s; transition: background-color 0.2s;
@@ -526,6 +526,8 @@
.test-dictionary-item input[type="checkbox"] { .test-dictionary-item input[type="checkbox"] {
width: 18px; width: 18px;
height: 18px; height: 18px;
flex-shrink: 0;
margin: 0;
accent-color: #3498db; accent-color: #3498db;
} }

View File

@@ -8,7 +8,7 @@ import './TaskForm.css'
const API_URL = '/api/tasks' const API_URL = '/api/tasks'
const PROJECTS_API_URL = '/projects' const PROJECTS_API_URL = '/projects'
function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = false, returnTo, returnWishlistId }) { function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = false, isPurchase: isPurchaseFromProps = false, returnTo, returnWishlistId }) {
const { authFetch } = useAuth() const { authFetch } = useAuth()
const [name, setName] = useState('') const [name, setName] = useState('')
const [progressionBase, setProgressionBase] = useState('') const [progressionBase, setProgressionBase] = useState('')
@@ -35,6 +35,10 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
const [maxCards, setMaxCards] = useState('') const [maxCards, setMaxCards] = useState('')
const [selectedDictionaryIDs, setSelectedDictionaryIDs] = useState([]) const [selectedDictionaryIDs, setSelectedDictionaryIDs] = useState([])
const [availableDictionaries, setAvailableDictionaries] = useState([]) const [availableDictionaries, setAvailableDictionaries] = useState([])
// Purchase-specific state
const [isPurchase, setIsPurchase] = useState(isPurchaseFromProps)
const [availableBoards, setAvailableBoards] = useState([])
const [selectedPurchaseBoards, setSelectedPurchaseBoards] = useState([])
const debounceTimer = useRef(null) const debounceTimer = useRef(null)
// Загрузка проектов для автокомплита // Загрузка проектов для автокомплита
@@ -85,6 +89,22 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
loadDictionaries() loadDictionaries()
}, []) }, [])
// Загрузка досок для закупок
useEffect(() => {
const loadBoards = async () => {
try {
const response = await authFetch('/api/purchase/boards-info')
if (response.ok) {
const data = await response.json()
setAvailableBoards(Array.isArray(data.boards) ? data.boards : [])
}
} catch (err) {
console.error('Error loading boards for purchase:', err)
}
}
loadBoards()
}, [])
// Функция сброса формы // Функция сброса формы
const resetForm = () => { const resetForm = () => {
setName('') setName('')
@@ -399,6 +419,23 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
setMaxCards('') setMaxCards('')
setSelectedDictionaryIDs([]) setSelectedDictionaryIDs([])
} }
// Загружаем информацию о закупке, если есть purchase_config_id
if (data.task.purchase_config_id) {
setIsPurchase(true)
if (data.purchase_boards && Array.isArray(data.purchase_boards)) {
setSelectedPurchaseBoards(data.purchase_boards.map(pb => ({
board_id: pb.board_id,
group_name: pb.group_name || null
})))
}
// Закупки не могут иметь прогрессию и подзадачи
setProgressionBase('')
setSubtasks([])
} else {
setIsPurchase(false)
setSelectedPurchaseBoards([])
}
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
} finally { } finally {
@@ -413,6 +450,13 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
} }
}, [isTest]) }, [isTest])
// Очистка подзадач при переключении задачи в режим закупки
useEffect(() => {
if (isPurchase && subtasks.length > 0) {
setSubtasks([])
}
}, [isPurchase])
// Пересчет rewards при изменении reward_message (debounce) // Пересчет rewards при изменении reward_message (debounce)
useEffect(() => { useEffect(() => {
if (debounceTimer.current) { if (debounceTimer.current) {
@@ -597,6 +641,13 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
} }
} }
// Валидация закупки
if (isPurchase && selectedPurchaseBoards.length === 0) {
setError('Выберите хотя бы одну доску или группу для закупки')
setLoading(false)
return
}
// Проверяем, что задача с привязанным желанием не может быть периодической // Проверяем, что задача с привязанным желанием не может быть периодической
const isLinkedToWishlist = wishlistInfo !== null || (taskId && currentWishlistId) const isLinkedToWishlist = wishlistInfo !== null || (taskId && currentWishlistId)
if (isLinkedToWishlist && repetitionPeriodValue && repetitionPeriodValue.trim() !== '') { if (isLinkedToWishlist && repetitionPeriodValue && repetitionPeriodValue.trim() !== '') {
@@ -711,7 +762,7 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
value: parseFloat(r.value) || 0, value: parseFloat(r.value) || 0,
use_progression: !!(progressionBase && r.use_progression) use_progression: !!(progressionBase && r.use_progression)
})), })),
subtasks: isTest ? [] : subtasks.map((st, index) => ({ subtasks: (isTest || isPurchase) ? [] : subtasks.map((st, index) => ({
id: st.id || undefined, id: st.id || undefined,
name: st.name.trim() || null, name: st.name.trim() || null,
reward_message: st.reward_message.trim() || null, reward_message: st.reward_message.trim() || null,
@@ -727,7 +778,10 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
is_test: isTest, is_test: isTest,
words_count: isTest ? parseInt(wordsCount, 10) : undefined, words_count: isTest ? parseInt(wordsCount, 10) : undefined,
max_cards: isTest && maxCards ? parseInt(maxCards, 10) : undefined, max_cards: isTest && maxCards ? parseInt(maxCards, 10) : undefined,
dictionary_ids: isTest ? selectedDictionaryIDs : undefined dictionary_ids: isTest ? selectedDictionaryIDs : undefined,
// Purchase-specific fields
is_purchase: isPurchase,
purchase_boards: isPurchase ? selectedPurchaseBoards : undefined
} }
const url = taskId ? `${API_URL}/${taskId}` : API_URL const url = taskId ? `${API_URL}/${taskId}` : API_URL
@@ -974,6 +1028,68 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
</div> </div>
)} )}
{/* Purchase-specific fields */}
{isPurchase && (
<div className="form-group test-config-section">
<label>Настройка закупки</label>
<div style={{ marginTop: '0.5rem' }}>
<label style={{ fontSize: '0.875rem', fontWeight: 500, color: '#374151', marginBottom: '0.5rem', display: 'block' }}>Доски и группы *</label>
<div className="test-dictionaries-list">
{availableBoards.map(board => (
<div key={board.id}>
<label className="test-dictionary-item">
<input
type="checkbox"
checked={selectedPurchaseBoards.some(pb => pb.board_id === board.id && pb.group_name === null)}
onChange={(e) => {
if (e.target.checked) {
// Добавляем всю доску, убираем отдельные группы этой доски
setSelectedPurchaseBoards(prev => [
...prev.filter(pb => pb.board_id !== board.id),
{ board_id: board.id, group_name: null }
])
} else {
// Убираем доску целиком
setSelectedPurchaseBoards(prev => prev.filter(pb => !(pb.board_id === board.id && pb.group_name === null)))
}
}}
/>
<span className="test-dictionary-name">{board.name}</span>
<span className="test-dictionary-count">(вся доска)</span>
</label>
{board.groups.length > 0 && !selectedPurchaseBoards.some(pb => pb.board_id === board.id && pb.group_name === null) && (
<div style={{ paddingLeft: '1.25rem', marginTop: '2px' }}>
{board.groups.map(group => (
<label key={group || '__ungrouped'} className="test-dictionary-item">
<input
type="checkbox"
checked={selectedPurchaseBoards.some(pb => pb.board_id === board.id && pb.group_name === (group || ''))}
onChange={(e) => {
const groupValue = group || ''
if (e.target.checked) {
setSelectedPurchaseBoards(prev => [...prev, { board_id: board.id, group_name: groupValue }])
} else {
setSelectedPurchaseBoards(prev => prev.filter(pb => !(pb.board_id === board.id && pb.group_name === groupValue)))
}
}}
/>
<span className="test-dictionary-name">{group || 'Остальные'}</span>
</label>
))}
</div>
)}
</div>
))}
{availableBoards.length === 0 && (
<div className="test-no-dictionaries">
Нет доступных досок. Создайте доску в разделе "Товары".
</div>
)}
</div>
</div>
</div>
)}
{!wishlistInfo && ( {!wishlistInfo && (
<div className="form-group"> <div className="form-group">
<label htmlFor="repetition_period">Повторения</label> <label htmlFor="repetition_period">Повторения</label>
@@ -1123,7 +1239,7 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
)} )}
</div> </div>
{!isTest && ( {!isTest && !isPurchase && (
<div className="form-group"> <div className="form-group">
<div className="subtasks-header"> <div className="subtasks-header">
<label>Подзадачи</label> <label>Подзадачи</label>

View File

@@ -917,3 +917,13 @@
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.3); box-shadow: 0 4px 12px rgba(52, 152, 219, 0.3);
} }
.task-add-modal-button-purchase {
background: linear-gradient(to right, #27ae60, #229954);
color: white;
}
.task-add-modal-button-purchase:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(39, 174, 96, 0.3);
}

View File

@@ -87,6 +87,17 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
return return
} }
// Для задач-закупок открываем экран закупок
const isPurchase = task.purchase_config_id != null
if (isPurchase) {
onNavigate?.('purchase', {
purchaseConfigId: task.purchase_config_id,
taskId: task.id,
taskName: task.name
})
return
}
// Для обычных задач открываем диалог подтверждения // Для обычных задач открываем диалог подтверждения
setSelectedTaskForDetail(task.id) setSelectedTaskForDetail(task.id)
} }
@@ -408,7 +419,19 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
} }
defaultDate.setHours(0, 0, 0, 0) defaultDate.setHours(0, 0, 0, 0)
setPostponeDate(formatDateToLocal(defaultDate)) const plannedStr = formatDateToLocal(defaultDate)
// Предвыбираем дату только если она не совпадает с текущей next_show_at (т.е. если чипс "По плану" будет показан)
let nextShowStr = null
if (task.next_show_at) {
const d = new Date(task.next_show_at)
d.setHours(0, 0, 0, 0)
nextShowStr = formatDateToLocal(d)
}
if (plannedStr !== nextShowStr) {
setPostponeDate(plannedStr)
} else {
setPostponeDate('')
}
} }
const handlePostponeSubmit = async () => { const handlePostponeSubmit = async () => {
@@ -791,7 +814,8 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
const hasProgression = task.has_progression || task.progression_base != null const hasProgression = task.has_progression || task.progression_base != null
const hasSubtasks = task.subtasks_count > 0 const hasSubtasks = task.subtasks_count > 0
const isTest = task.config_id != null const isTest = task.config_id != null
const showDetailOnCheckmark = !isTest const isPurchase = task.purchase_config_id != null
const showDetailOnCheckmark = !isTest && !isPurchase
const isWishlist = task.wishlist_id != null const isWishlist = task.wishlist_id != null
// Проверяем бесконечную задачу: repetition_period = 0 И (repetition_date = 0 ИЛИ отсутствует) // Проверяем бесконечную задачу: repetition_period = 0 И (repetition_date = 0 ИЛИ отсутствует)
@@ -816,7 +840,7 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
<div <div
className={`task-checkmark ${showDetailOnCheckmark ? 'task-checkmark-detail' : ''} ${task.auto_complete ? 'task-checkmark-auto-complete' : ''}`} className={`task-checkmark ${showDetailOnCheckmark ? 'task-checkmark-detail' : ''} ${task.auto_complete ? 'task-checkmark-auto-complete' : ''}`}
onClick={(e) => handleCheckmarkClick(task, e)} onClick={(e) => handleCheckmarkClick(task, e)}
title={isTest ? 'Запустить тест' : (showDetailOnCheckmark ? 'Открыть детали' : 'Выполнить задачу')} title={isTest ? 'Запустить тест' : (isPurchase ? 'Открыть закупки' : (showDetailOnCheckmark ? 'Открыть детали' : 'Выполнить задачу'))}
> >
{isTest ? ( {isTest ? (
<svg <svg
@@ -832,6 +856,20 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path> <path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path>
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path> <path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path>
</svg> </svg>
) : isPurchase ? (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M2 7h20l-2 13a2 2 0 0 1-2 1.5H6a2 2 0 0 1-2-1.5L2 7z"></path>
<path d="M9 7V6a3 3 0 0 1 6 0v1"></path>
</svg>
) : isWishlist ? ( ) : isWishlist ? (
<> <>
<svg <svg