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

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

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,7 @@ import './TaskForm.css'
const API_URL = '/api/tasks'
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 [name, setName] = useState('')
const [progressionBase, setProgressionBase] = useState('')
@@ -35,6 +35,10 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
const [maxCards, setMaxCards] = useState('')
const [selectedDictionaryIDs, setSelectedDictionaryIDs] = 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)
// Загрузка проектов для автокомплита
@@ -85,6 +89,22 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
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 = () => {
setName('')
@@ -399,6 +419,23 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
setMaxCards('')
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) {
setError(err.message)
} finally {
@@ -413,6 +450,13 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
}
}, [isTest])
// Очистка подзадач при переключении задачи в режим закупки
useEffect(() => {
if (isPurchase && subtasks.length > 0) {
setSubtasks([])
}
}, [isPurchase])
// Пересчет rewards при изменении reward_message (debounce)
useEffect(() => {
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)
if (isLinkedToWishlist && repetitionPeriodValue && repetitionPeriodValue.trim() !== '') {
@@ -711,7 +762,7 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
value: parseFloat(r.value) || 0,
use_progression: !!(progressionBase && r.use_progression)
})),
subtasks: isTest ? [] : subtasks.map((st, index) => ({
subtasks: (isTest || isPurchase) ? [] : subtasks.map((st, index) => ({
id: st.id || undefined,
name: st.name.trim() || null,
reward_message: st.reward_message.trim() || null,
@@ -727,7 +778,10 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
is_test: isTest,
words_count: isTest ? parseInt(wordsCount, 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
@@ -974,6 +1028,68 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
</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 && (
<div className="form-group">
<label htmlFor="repetition_period">Повторения</label>
@@ -1123,7 +1239,7 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
)}
</div>
{!isTest && (
{!isTest && !isPurchase && (
<div className="form-group">
<div className="subtasks-header">
<label>Подзадачи</label>

View File

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

View File

@@ -87,6 +87,17 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
return
}
// Для задач-закупок открываем экран закупок
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)
}
@@ -408,7 +419,19 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
}
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 () => {
@@ -791,7 +814,8 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
const hasProgression = task.has_progression || task.progression_base != null
const hasSubtasks = task.subtasks_count > 0
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
// Проверяем бесконечную задачу: repetition_period = 0 И (repetition_date = 0 ИЛИ отсутствует)
@@ -816,7 +840,7 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
<div
className={`task-checkmark ${showDetailOnCheckmark ? 'task-checkmark-detail' : ''} ${task.auto_complete ? 'task-checkmark-auto-complete' : ''}`}
onClick={(e) => handleCheckmarkClick(task, e)}
title={isTest ? 'Запустить тест' : (showDetailOnCheckmark ? 'Открыть детали' : 'Выполнить задачу')}
title={isTest ? 'Запустить тест' : (isPurchase ? 'Открыть закупки' : (showDetailOnCheckmark ? 'Открыть детали' : 'Выполнить задачу'))}
>
{isTest ? (
<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="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>
) : 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 ? (
<>
<svg