From 705eb2400eb03d6fb56822208bec1a552f5b10a6 Mon Sep 17 00:00:00 2001 From: poignatov Date: Mon, 12 Jan 2026 17:42:51 +0300 Subject: [PATCH] =?UTF-8?q?v3.9.5:=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=B2=D0=BE=D0=B7=D0=BC=D0=BE=D0=B6=D0=BD?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D1=8C=20=D0=BA=D0=BE=D0=BF=D0=B8=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=B6=D0=B5=D0=BB=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=B9,=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=B7=D0=B0=D0=BC=D0=B5=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=B8=D0=B7=D0=BE=D0=B1=D1=80=D0=B0=D0=B6=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION | 2 +- play-life-backend/main.go | 164 +++++++--- play-life-web/src/App.jsx | 3 +- play-life-web/src/components/Wishlist.css | 11 + play-life-web/src/components/Wishlist.jsx | 300 ++++++++++++++---- .../src/components/WishlistDetail.css | 33 +- .../src/components/WishlistDetail.jsx | 52 ++- play-life-web/src/components/WishlistForm.css | 11 + play-life-web/src/components/WishlistForm.jsx | 95 ++++-- 9 files changed, 509 insertions(+), 162 deletions(-) diff --git a/VERSION b/VERSION index e0d61b5..11aaa06 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.9.4 +3.9.5 diff --git a/play-life-backend/main.go b/play-life-backend/main.go index c876f9f..f75795b 100644 --- a/play-life-backend/main.go +++ b/play-life-backend/main.go @@ -8,6 +8,7 @@ import ( "encoding/base64" "encoding/json" "fmt" + "html" "io" "log" "math" @@ -317,9 +318,10 @@ type UnlockConditionRequest struct { } type WishlistResponse struct { - Unlocked []WishlistItem `json:"unlocked"` - Locked []WishlistItem `json:"locked"` - Completed []WishlistItem `json:"completed,omitempty"` + Unlocked []WishlistItem `json:"unlocked"` + Locked []WishlistItem `json:"locked"` + Completed []WishlistItem `json:"completed,omitempty"` + CompletedCount int `json:"completed_count"` // Количество завершённых желаний } // ============================================ @@ -8480,6 +8482,7 @@ func (a *App) todoistDisconnectHandler(w http.ResponseWriter, r *http.Request) { // ============================================ // calculateProjectPointsFromDate считает баллы проекта с указанной даты до текущего момента +// Считает напрямую из таблицы nodes, фильтруя по дате entries func (a *App) calculateProjectPointsFromDate( projectID int, startDate sql.NullTime, @@ -8488,39 +8491,32 @@ func (a *App) calculateProjectPointsFromDate( var totalScore float64 var err error - // Обновляем materialized view перед запросом - _, err = a.DB.Exec("REFRESH MATERIALIZED VIEW weekly_report_mv") - if err != nil { - log.Printf("Warning: Failed to refresh materialized view: %v", err) - } - if !startDate.Valid { - // За всё время + // За всё время - считаем все nodes этого пользователя для указанного проекта err = a.DB.QueryRow(` - SELECT COALESCE(SUM(wr.total_score), 0) - FROM weekly_report_mv wr - JOIN projects p ON wr.project_id = p.id - WHERE wr.project_id = $1 AND p.user_id = $2 + SELECT COALESCE(SUM(n.score), 0) + FROM nodes n + JOIN projects p ON n.project_id = p.id + WHERE n.project_id = $1 AND n.user_id = $2 AND p.user_id = $2 `, projectID, userID).Scan(&totalScore) } else { // С указанной даты до текущего момента - // Нужно найти все недели, которые попадают в диапазон от startDate до CURRENT_DATE - // Используем сравнение (year, week) >= (startDate_year, startDate_week) + // Считаем все nodes этого пользователя, где дата entry >= startDate + // Используем DATE() для сравнения только по дате (без времени) err = a.DB.QueryRow(` - SELECT COALESCE(SUM(wr.total_score), 0) - FROM weekly_report_mv wr - JOIN projects p ON wr.project_id = p.id - WHERE wr.project_id = $1 + SELECT COALESCE(SUM(n.score), 0) + FROM nodes n + JOIN entries e ON n.entry_id = e.id + JOIN projects p ON n.project_id = p.id + WHERE n.project_id = $1 + AND n.user_id = $2 AND p.user_id = $2 - AND ( - wr.report_year > EXTRACT(ISOYEAR FROM $3)::INTEGER - OR (wr.report_year = EXTRACT(ISOYEAR FROM $3)::INTEGER - AND wr.report_week >= EXTRACT(WEEK FROM $3)::INTEGER) - ) + AND DATE(e.created_date) >= DATE($3) `, projectID, userID, startDate.Time).Scan(&totalScore) } if err != nil { + log.Printf("Error calculating project points from date: %v", err) return 0, err } @@ -8805,9 +8801,14 @@ func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool) `, condition.ID).Scan(&projectID, &requiredPoints, &startDate) if err == nil { totalScore, err := a.calculateProjectPointsFromDate(projectID, startDate, userID) - conditionMet = err == nil && totalScore >= requiredPoints - if err == nil { + if err != nil { + // Если ошибка при расчете, устанавливаем 0 + zeroScore := 0.0 + condition.CurrentPoints = &zeroScore + conditionMet = false + } else { condition.CurrentPoints = &totalScore + conditionMet = totalScore >= requiredPoints } } } @@ -8857,7 +8858,11 @@ func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool) `, condition.ID).Scan(&projectID, &requiredPoints, &startDate) if err == nil { totalScore, err := a.calculateProjectPointsFromDate(projectID, startDate, userID) - if err == nil { + if err != nil { + // Если ошибка при расчете, устанавливаем 0 + zeroScore := 0.0 + condition.CurrentPoints = &zeroScore + } else { condition.CurrentPoints = &totalScore } } @@ -9025,10 +9030,14 @@ func (a *App) getWishlistHandler(w http.ResponseWriter, r *http.Request) { unlocked := make([]WishlistItem, 0) locked := make([]WishlistItem, 0) completed := make([]WishlistItem, 0) + completedCount := 0 for _, item := range items { if item.Completed { - completed = append(completed, item) + completedCount++ + if includeCompleted { + completed = append(completed, item) + } } else if item.Unlocked { unlocked = append(unlocked, item) } else { @@ -9074,9 +9083,10 @@ func (a *App) getWishlistHandler(w http.ResponseWriter, r *http.Request) { }) response := WishlistResponse{ - Unlocked: unlocked, - Locked: locked, - Completed: completed, + Unlocked: unlocked, + Locked: locked, + Completed: completed, + CompletedCount: completedCount, } w.Header().Set("Content-Type", "application/json") @@ -9457,8 +9467,10 @@ func (a *App) uploadWishlistImageHandler(w http.ResponseWriter, r *http.Request) return } - // Сохраняем как JPEG - filename := fmt.Sprintf("%d.jpg", wishlistID) + // Генерируем уникальное имя файла + randomBytes := make([]byte, 8) + rand.Read(randomBytes) + filename := fmt.Sprintf("%d_%x.jpg", wishlistID, randomBytes) filepath := filepath.Join(uploadDir, filename) dst, err := os.Create(filepath) @@ -9477,7 +9489,7 @@ func (a *App) uploadWishlistImageHandler(w http.ResponseWriter, r *http.Request) return } - // Обновляем путь в БД + // Обновляем путь в БД (уникальное имя файла уже обеспечивает сброс кэша) imagePath := fmt.Sprintf("/uploads/wishlist/%d/%s", userID, filename) _, err = a.DB.Exec(` UPDATE wishlist_items @@ -9690,13 +9702,14 @@ func (a *App) extractLinkMetadataHandler(w http.ResponseWriter, r *http.Request) body := string(bodyBytes) metadata := &LinkMetadataResponse{} - // Извлекаем Open Graph теги - ogTitleRe := regexp.MustCompile(`]+property=["']og:title["'][^>]+content=["']([^"']+)["']`) - ogTitleRe2 := regexp.MustCompile(`]+content=["']([^"']+)["'][^>]+property=["']og:title["']`) - ogImageRe := regexp.MustCompile(`]+property=["']og:image["'][^>]+content=["']([^"']+)["']`) - ogImageRe2 := regexp.MustCompile(`]+content=["']([^"']+)["'][^>]+property=["']og:image["']`) - ogDescRe := regexp.MustCompile(`]+property=["']og:description["'][^>]+content=["']([^"']+)["']`) - ogDescRe2 := regexp.MustCompile(`]+content=["']([^"']+)["'][^>]+property=["']og:description["']`) + // Извлекаем Open Graph теги (более гибкие регулярные выражения с case-insensitive) + // Поддерживаем различные варианты: property/content, content/property, одинарные/двойные кавычки, пробелы + ogTitleRe := regexp.MustCompile(`(?i)]*(?:property|name)\s*=\s*["']og:title["'][^>]*content\s*=\s*["']([^"']+)["']`) + ogTitleRe2 := regexp.MustCompile(`(?i)]*content\s*=\s*["']([^"']+)["'][^>]*(?:property|name)\s*=\s*["']og:title["']`) + ogImageRe := regexp.MustCompile(`(?i)]*(?:property|name)\s*=\s*["']og:image["'][^>]*content\s*=\s*["']([^"']+)["']`) + ogImageRe2 := regexp.MustCompile(`(?i)]*content\s*=\s*["']([^"']+)["'][^>]*(?:property|name)\s*=\s*["']og:image["']`) + ogDescRe := regexp.MustCompile(`(?i)]*(?:property|name)\s*=\s*["']og:description["'][^>]*content\s*=\s*["']([^"']+)["']`) + ogDescRe2 := regexp.MustCompile(`(?i)]*content\s*=\s*["']([^"']+)["'][^>]*(?:property|name)\s*=\s*["']og:description["']`) // og:title if matches := ogTitleRe.FindStringSubmatch(body); len(matches) > 1 { @@ -9721,19 +9734,66 @@ func (a *App) extractLinkMetadataHandler(w http.ResponseWriter, r *http.Request) // Если нет og:title, пытаемся взять if metadata.Title == "" { - titleRe := regexp.MustCompile(`<title[^>]*>([^<]+)`) + titleRe := regexp.MustCompile(`(?i)]*>([^<]+)`) if matches := titleRe.FindStringSubmatch(body); len(matches) > 1 { metadata.Title = strings.TrimSpace(matches[1]) } } + // Если нет og:image, пытаемся найти другие meta теги для изображения + if metadata.Image == "" { + // Twitter Card image + twitterImageRe := regexp.MustCompile(`(?i)]*(?:property|name)\s*=\s*["']twitter:image["'][^>]*content\s*=\s*["']([^"']+)["']`) + if matches := twitterImageRe.FindStringSubmatch(body); len(matches) > 1 { + metadata.Image = strings.TrimSpace(matches[1]) + } else { + twitterImageRe2 := regexp.MustCompile(`(?i)]*content\s*=\s*["']([^"']+)["'][^>]*(?:property|name)\s*=\s*["']twitter:image["']`) + if matches := twitterImageRe2.FindStringSubmatch(body); len(matches) > 1 { + metadata.Image = strings.TrimSpace(matches[1]) + } + } + } + // Пытаемся найти цену (Schema.org JSON-LD или типовые паттерны) - // Schema.org Product price - priceRe := regexp.MustCompile(`"price"\s*:\s*"?(\d+(?:[.,]\d+)?)"?`) - if matches := priceRe.FindStringSubmatch(body); len(matches) > 1 { - priceStr := strings.ReplaceAll(matches[1], ",", ".") - if price, err := strconv.ParseFloat(priceStr, 64); err == nil && price > 0 && price < 100000000 { - metadata.Price = &price + // Сначала ищем в JSON-LD (Schema.org) + jsonLdRe := regexp.MustCompile(`(?i)]*type\s*=\s*["']application/ld\+json["'][^>]*>([^<]+)`) + jsonLdMatches := jsonLdRe.FindAllStringSubmatch(body, -1) + for _, match := range jsonLdMatches { + if len(match) > 1 { + jsonStr := match[1] + // Ищем цену в JSON-LD + priceRe := regexp.MustCompile(`(?i)"price"\s*:\s*"?(\d+(?:[.,]\d+)?)"?`) + if priceMatches := priceRe.FindStringSubmatch(jsonStr); len(priceMatches) > 1 { + priceStr := strings.ReplaceAll(priceMatches[1], ",", ".") + if price, err := strconv.ParseFloat(priceStr, 64); err == nil && price > 0 && price < 100000000 { + metadata.Price = &price + break + } + } + } + } + + // Если не нашли в JSON-LD, ищем в обычном HTML + if metadata.Price == nil { + priceRe := regexp.MustCompile(`(?i)"price"\s*:\s*"?(\d+(?:[.,]\d+)?)"?`) + if matches := priceRe.FindStringSubmatch(body); len(matches) > 1 { + priceStr := strings.ReplaceAll(matches[1], ",", ".") + if price, err := strconv.ParseFloat(priceStr, 64); err == nil && price > 0 && price < 100000000 { + metadata.Price = &price + } + } + } + + // Также ищем цену в meta тегах (некоторые сайты используют это) + if metadata.Price == nil { + metaPriceRe := regexp.MustCompile(`(?i)]*(?:property|name)\s*=\s*["'](?:price|product:price)["'][^>]*content\s*=\s*["']([^"']+)["']`) + if matches := metaPriceRe.FindStringSubmatch(body); len(matches) > 1 { + priceStr := strings.ReplaceAll(strings.TrimSpace(matches[1]), ",", ".") + // Убираем валюту и лишние символы + priceStr = regexp.MustCompile(`[^\d.]`).ReplaceAllString(priceStr, "") + if price, err := strconv.ParseFloat(priceStr, 64); err == nil && price > 0 && price < 100000000 { + metadata.Price = &price + } } } @@ -9749,9 +9809,9 @@ func (a *App) extractLinkMetadataHandler(w http.ResponseWriter, r *http.Request) } } - // Декодируем HTML entities - metadata.Title = decodeHTMLEntities(metadata.Title) - metadata.Description = decodeHTMLEntities(metadata.Description) + // Декодируем HTML entities (используем стандартную библиотеку) + metadata.Title = html.UnescapeString(metadata.Title) + metadata.Description = html.UnescapeString(metadata.Description) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(metadata) diff --git a/play-life-web/src/App.jsx b/play-life-web/src/App.jsx index ff8dbac..c5b7f43 100644 --- a/play-life-web/src/App.jsx +++ b/play-life-web/src/App.jsx @@ -881,9 +881,10 @@ function AppContent() { {loadedTabs['wishlist-form'] && (
)} diff --git a/play-life-web/src/components/Wishlist.css b/play-life-web/src/components/Wishlist.css index fdd8c92..ae5f795 100644 --- a/play-life-web/src/components/Wishlist.css +++ b/play-life-web/src/components/Wishlist.css @@ -265,6 +265,7 @@ } .wishlist-modal-edit, +.wishlist-modal-copy, .wishlist-modal-complete, .wishlist-modal-delete { width: 100%; @@ -287,6 +288,16 @@ transform: translateY(-1px); } +.wishlist-modal-copy { + background-color: #9b59b6; + color: white; +} + +.wishlist-modal-copy:hover { + background-color: #8e44ad; + transform: translateY(-1px); +} + .wishlist-modal-complete { background-color: #27ae60; color: white; diff --git a/play-life-web/src/components/Wishlist.jsx b/play-life-web/src/components/Wishlist.jsx index 5a02958..ccf7259 100644 --- a/play-life-web/src/components/Wishlist.jsx +++ b/play-life-web/src/components/Wishlist.jsx @@ -9,37 +9,52 @@ function Wishlist({ onNavigate, refreshTrigger = 0 }) { const { authFetch } = useAuth() const [items, setItems] = useState([]) const [completed, setCompleted] = useState([]) + const [completedCount, setCompletedCount] = useState(0) const [loading, setLoading] = useState(true) const [error, setError] = useState('') const [completedExpanded, setCompletedExpanded] = useState(false) const [completedLoading, setCompletedLoading] = useState(false) const [selectedItem, setSelectedItem] = useState(null) + const [tasks, setTasks] = useState([]) + const [projects, setProjects] = useState([]) useEffect(() => { fetchWishlist() + loadTasksAndProjects() }, []) + const loadTasksAndProjects = async () => { + try { + // Загружаем задачи + const tasksResponse = await authFetch('/api/tasks') + if (tasksResponse.ok) { + const tasksData = await tasksResponse.json() + setTasks(Array.isArray(tasksData) ? tasksData : []) + } + + // Загружаем проекты + const projectsResponse = await authFetch('/projects') + if (projectsResponse.ok) { + const projectsData = await projectsResponse.json() + setProjects(Array.isArray(projectsData) ? projectsData : []) + } + } catch (err) { + console.error('Error loading tasks and projects:', err) + } + } + // Обновляем данные при изменении refreshTrigger useEffect(() => { if (refreshTrigger > 0) { fetchWishlist() - // Если завершённые развёрнуты, обновляем и их - if (completedExpanded) { - fetchWishlist(true) - } } }, [refreshTrigger]) - const fetchWishlist = async (includeCompleted = false) => { + const fetchWishlist = async () => { try { - if (includeCompleted) { - setCompletedLoading(true) - } else { - setLoading(true) - } + setLoading(true) - const url = includeCompleted ? `${API_URL}?include_completed=true` : API_URL - const response = await authFetch(url) + const response = await authFetch(API_URL) if (!response.ok) { throw new Error('Ошибка при загрузке желаний') @@ -49,18 +64,42 @@ function Wishlist({ onNavigate, refreshTrigger = 0 }) { // Объединяем разблокированные и заблокированные в один список const allItems = [...(data.unlocked || []), ...(data.locked || [])] setItems(allItems) - if (includeCompleted) { - setCompleted(data.completed || []) + const count = data.completed_count || 0 + setCompletedCount(count) + + // Загружаем завершённые сразу, если они есть + if (count > 0) { + fetchCompleted() + } else { + setCompleted([]) } + setError('') } catch (err) { setError(err.message) setItems([]) - if (includeCompleted) { - setCompleted([]) - } + setCompleted([]) + setCompletedCount(0) } finally { setLoading(false) + } + } + + const fetchCompleted = async () => { + try { + setCompletedLoading(true) + const response = await authFetch(`${API_URL}?include_completed=true`) + + if (!response.ok) { + throw new Error('Ошибка при загрузке завершённых желаний') + } + + const data = await response.json() + setCompleted(data.completed || []) + } catch (err) { + console.error('Error fetching completed items:', err) + setCompleted([]) + } finally { setCompletedLoading(false) } } @@ -68,8 +107,8 @@ function Wishlist({ onNavigate, refreshTrigger = 0 }) { const handleToggleCompleted = () => { const newExpanded = !completedExpanded setCompletedExpanded(newExpanded) - if (newExpanded && completed.length === 0) { - fetchWishlist(true) + if (newExpanded && completed.length === 0 && completedCount > 0) { + fetchCompleted() } } @@ -133,6 +172,108 @@ function Wishlist({ onNavigate, refreshTrigger = 0 }) { } } + const handleCopy = async () => { + if (!selectedItem) return + + try { + // Загружаем полные данные желания + const response = await authFetch(`${API_URL}/${selectedItem.id}`) + if (!response.ok) { + throw new Error('Ошибка при загрузке желания') + } + + const itemData = await response.json() + + // Преобразуем условия из формата Display в формат Request + const unlockConditions = (itemData.unlock_conditions || []).map((cond) => { + const condition = { + type: cond.type, + display_order: cond.display_order, + } + + if (cond.type === 'task_completion' && cond.task_name) { + // Находим task_id по имени задачи + const task = tasks.find(t => t.name === cond.task_name) + if (task) { + condition.task_id = task.id + } + } else if (cond.type === 'project_points' && cond.project_name) { + // Находим project_id по имени проекта + const project = projects.find(p => p.project_name === cond.project_name) + if (project) { + condition.project_id = project.project_id + } + if (cond.required_points !== undefined && cond.required_points !== null) { + condition.required_points = cond.required_points + } + if (cond.start_date) { + condition.start_date = cond.start_date + } + } + + return condition + }) + + // Создаем копию желания + const copyData = { + name: `${itemData.name} (копия)`, + price: itemData.price || null, + link: itemData.link || null, + unlock_conditions: unlockConditions, + } + + const createResponse = await authFetch(API_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(copyData), + }) + + if (!createResponse.ok) { + throw new Error('Ошибка при создании копии') + } + + const newItem = await createResponse.json() + + // Копируем изображение, если оно есть + if (itemData.image_url) { + try { + // Загружаем изображение по URL (используем authFetch для авторизованных запросов) + const imageResponse = await authFetch(itemData.image_url) + if (imageResponse.ok) { + const blob = await imageResponse.blob() + // Проверяем, что это изображение и размер не превышает 5MB + if (blob.type.startsWith('image/') && blob.size <= 5 * 1024 * 1024) { + // Загружаем изображение для нового желания + const formData = new FormData() + formData.append('image', blob, 'image.jpg') + + const uploadResponse = await authFetch(`${API_URL}/${newItem.id}/image`, { + method: 'POST', + body: formData, + }) + + if (!uploadResponse.ok) { + console.error('Ошибка при копировании изображения') + } + } + } + } catch (imgErr) { + console.error('Ошибка при копировании изображения:', imgErr) + // Не прерываем процесс, просто логируем ошибку + } + } + + setSelectedItem(null) + // Открываем экран редактирования нового желания + onNavigate?.('wishlist-form', { wishlistId: newItem.id }) + } catch (err) { + setError(err.message) + setSelectedItem(null) + } + } + const formatPrice = (price) => { return new Intl.NumberFormat('ru-RU', { style: 'currency', @@ -142,12 +283,38 @@ function Wishlist({ onNavigate, refreshTrigger = 0 }) { }).format(price) } - const renderUnlockCondition = (item) => { - if (item.unlocked || item.completed) return null - if (!item.first_locked_condition) return null + // Находит первое невыполненное условие + const findFirstUnmetCondition = (item) => { + if (!item.unlock_conditions || item.unlock_conditions.length === 0) { + return null + } - const condition = item.first_locked_condition - const moreCount = item.more_locked_conditions || 0 + for (const condition of item.unlock_conditions) { + let isMet = false + + if (condition.type === 'task_completion') { + // Условие выполнено, если task_completed === true + isMet = condition.task_completed === true + } else if (condition.type === 'project_points') { + // Условие выполнено, если current_points >= required_points + const currentPoints = condition.current_points || 0 + const requiredPoints = condition.required_points || 0 + isMet = currentPoints >= requiredPoints + } + + if (!isMet) { + return condition + } + } + + return null + } + + const renderUnlockCondition = (item) => { + if (item.completed) return null + + const condition = findFirstUnmetCondition(item) + if (!condition) return null let conditionText = '' if (condition.type === 'task_completion') { @@ -155,16 +322,14 @@ function Wishlist({ onNavigate, refreshTrigger = 0 }) { } else { const points = condition.required_points || 0 const project = condition.project_name || 'Проект' - let period = '' - if (condition.period_type) { - const periodLabels = { - week: 'за неделю', - month: 'за месяц', - year: 'за год', - } - period = ' ' + periodLabels[condition.period_type] || '' + let dateText = '' + if (condition.start_date) { + const date = new Date(condition.start_date + 'T00:00:00') + dateText = ` с ${date.toLocaleDateString('ru-RU')}` + } else { + dateText = ' за всё время' } - conditionText = `${points} в ${project}${period}` + conditionText = `${points} в ${project}${dateText}` } return ( @@ -176,9 +341,6 @@ function Wishlist({ onNavigate, refreshTrigger = 0 }) { {conditionText} - {item.price && ( -
{formatPrice(item.price)}
- )} ) @@ -217,11 +379,18 @@ function Wishlist({ onNavigate, refreshTrigger = 0 }) {
{item.name}
- {isFaded && !item.completed ? ( - renderUnlockCondition(item) - ) : ( - item.price &&
{formatPrice(item.price)}
- )} + {(() => { + // Показываем первое невыполненное условие, если есть + const unmetCondition = findFirstUnmetCondition(item) + if (unmetCondition && !item.completed) { + return renderUnlockCondition(item) + } + // Если все условия выполнены или условий нет - показываем цену + if (item.price) { + return
{formatPrice(item.price)}
+ } + return null + })()} ) } @@ -261,28 +430,32 @@ function Wishlist({ onNavigate, refreshTrigger = 0 }) { )} - {/* Завершённые */} -
- -
- {completedExpanded && ( + {/* Завершённые - показываем только если есть завершённые желания */} + {completedCount > 0 && ( <> - {completedLoading ? ( -
-
-
- ) : ( -
- {completed.map(renderItem)} -
+
+ +
+ {completedExpanded && ( + <> + {completedLoading ? ( +
+
+
+ ) : ( +
+ {completed.map(renderItem)} +
+ )} + )} )} @@ -298,6 +471,9 @@ function Wishlist({ onNavigate, refreshTrigger = 0 }) { + {!selectedItem.completed && selectedItem.unlocked && ( )} - {!imageUrl && ( - - )} + {showCropper && ( @@ -495,7 +538,10 @@ function WishlistForm({ onNavigate, wishlistId }) {
{unlockConditions.map((cond, idx) => (
- + handleEditCondition(idx)} + > {cond.type === 'task_completion' ? `Задача: ${tasks.find(t => t.id === cond.task_id)?.name || 'Не выбрана'}` : `Баллы: ${cond.required_points} в ${projects.find(p => p.project_id === cond.project_id)?.project_name || 'Не выбран'}${cond.start_date ? ` с ${new Date(cond.start_date + 'T00:00:00').toLocaleDateString('ru-RU')}` : ' за всё время'}`} @@ -516,7 +562,7 @@ function WishlistForm({ onNavigate, wishlistId }) { onClick={handleAddCondition} className="add-condition-button" > - Добавить условие + Добавить цель
@@ -534,7 +580,8 @@ function WishlistForm({ onNavigate, wishlistId }) { tasks={tasks} projects={projects} onSubmit={handleConditionSubmit} - onCancel={() => setShowConditionForm(false)} + onCancel={handleConditionCancel} + editingCondition={editingConditionIndex !== null ? unlockConditions[editingConditionIndex] : null} /> )} @@ -627,13 +674,15 @@ function DateSelector({ value, onChange, placeholder = "За всё время" ) } -// Компонент формы условия разблокировки -function ConditionForm({ tasks, projects, onSubmit, onCancel }) { - const [type, setType] = useState('project_points') - const [taskId, setTaskId] = useState('') - const [projectId, setProjectId] = useState('') - const [requiredPoints, setRequiredPoints] = useState('') - const [startDate, setStartDate] = useState('') +// Компонент формы цели +function ConditionForm({ tasks, projects, onSubmit, onCancel, editingCondition }) { + const [type, setType] = useState(editingCondition?.type || 'project_points') + const [taskId, setTaskId] = useState(editingCondition?.task_id?.toString() || '') + const [projectId, setProjectId] = useState(editingCondition?.project_id?.toString() || '') + const [requiredPoints, setRequiredPoints] = useState(editingCondition?.required_points?.toString() || '') + const [startDate, setStartDate] = useState(editingCondition?.start_date || '') + + const isEditing = editingCondition !== null const handleSubmit = (e) => { e.preventDefault() @@ -666,7 +715,7 @@ function ConditionForm({ tasks, projects, onSubmit, onCancel }) { return (
e.stopPropagation()}> -

Добавить условие разблокировки

+

{isEditing ? 'Редактировать цель' : 'Добавить цель'}

@@ -742,7 +791,7 @@ function ConditionForm({ tasks, projects, onSubmit, onCancel }) { Отмена