6.9.0: Задачи-закупки
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m20s
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:
@@ -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(¤tPurchaseConfigID)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
24
play-life-backend/migrations/000029_purchase_tasks.up.sql
Normal file
24
play-life-backend/migrations/000029_purchase_tasks.up.sql
Normal 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);
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
640
play-life-web/src/components/PurchaseScreen.jsx
Normal file
640
play-life-web/src/components/PurchaseScreen.jsx
Normal 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
|
||||||
@@ -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="Перенести"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user