4.25.0: Группы вместо проектов для задач и желаний
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m31s

This commit is contained in:
poignatov
2026-02-06 17:42:36 +03:00
parent 0275d9aecf
commit 9f37d8b518
13 changed files with 888 additions and 188 deletions

View File

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

View File

@@ -17,6 +17,11 @@
cursor: pointer;
transition: all 0.2s;
flex: 1;
height: 44px;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
}
.submit-button:hover:not(:disabled) {
@@ -32,7 +37,7 @@
.delete-button {
background: #ef4444;
color: white;
padding: 0.75rem;
padding: 0;
border: none;
border-radius: 0.375rem;
font-size: 1rem;
@@ -44,6 +49,8 @@
justify-content: center;
min-width: 44px;
width: 44px;
height: 44px;
box-sizing: border-box;
}
.delete-button:hover:not(:disabled) {

View File

@@ -546,3 +546,65 @@
color: #6b7280;
font-style: italic;
}
/* Group Autocomplete */
.group-autocomplete {
position: relative;
}
.group-autocomplete-input-wrapper {
position: relative;
}
.group-autocomplete-clear {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: #9ca3af;
cursor: pointer;
padding: 4px;
font-size: 12px;
line-height: 1;
border-radius: 4px;
transition: all 0.15s;
}
.group-autocomplete-clear:hover {
color: #6b7280;
background: #f3f4f6;
}
.group-autocomplete-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
max-height: 240px;
overflow-y: auto;
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
z-index: 50;
}
.group-autocomplete-item {
padding: 12px 14px;
cursor: pointer;
font-size: 14px;
color: #374151;
border-bottom: 1px solid #f3f4f6;
transition: background 0.1s;
}
.group-autocomplete-item:last-child {
border-bottom: none;
}
.group-autocomplete-item:hover,
.group-autocomplete-item.highlighted {
background: #f3f4f6;
}

View File

@@ -19,6 +19,8 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
const [rewards, setRewards] = useState([])
const [subtasks, setSubtasks] = useState([])
const [projects, setProjects] = useState([])
const [groupName, setGroupName] = useState('')
const [groupSuggestions, setGroupSuggestions] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('') // Только для валидации
const [toastMessage, setToastMessage] = useState(null)
@@ -49,7 +51,23 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
}
}
loadProjects()
}, [])
}, [authFetch])
// Загрузка саджестов групп
useEffect(() => {
const loadGroupSuggestions = async () => {
try {
const response = await authFetch('/api/group-suggestions')
if (response.ok) {
const data = await response.json()
setGroupSuggestions(Array.isArray(data) ? data : [])
}
} catch (err) {
console.error('Error loading group suggestions:', err)
}
}
loadGroupSuggestions()
}, [authFetch])
// Загрузка словарей для тестов
useEffect(() => {
@@ -350,6 +368,13 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
} else {
setRewardPolicy('personal') // Значение по умолчанию
}
// Загружаем группу
if (data.task.group_name) {
setGroupName(data.task.group_name)
} else {
setGroupName('')
}
} else {
setCurrentWishlistId(null)
setWishlistInfo(null)
@@ -684,6 +709,7 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
// Отправляем reward_policy если задача связана с желанием
// Проверяем currentWishlistId или wishlistInfo, так как currentWishlistId устанавливается при загрузке задачи
reward_policy: (wishlistInfo || currentWishlistId) ? rewardPolicy : undefined,
group_name: groupName.trim() || null,
rewards: rewards.map(r => ({
position: r.position,
project_name: r.project_name.trim(),
@@ -833,6 +859,15 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
/>
</div>
<div className="form-group">
<label htmlFor="group">Группа</label>
<GroupAutocomplete
suggestions={groupSuggestions}
value={groupName}
onChange={setGroupName}
/>
</div>
{/* Информация о связанном желании */}
{wishlistInfo && (
<div className="form-group">
@@ -1259,5 +1294,139 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
)
}
// Компонент автодополнения для выбора группы
function GroupAutocomplete({ suggestions, value, onChange }) {
const [inputValue, setInputValue] = useState('')
const [isOpen, setIsOpen] = useState(false)
const [highlightedIndex, setHighlightedIndex] = useState(-1)
const wrapperRef = useRef(null)
const inputRef = useRef(null)
// При изменении value - обновить inputValue
useEffect(() => {
setInputValue(value || '')
}, [value])
// Фильтрация саджестов
const filteredSuggestions = inputValue.trim()
? suggestions.filter(group =>
group.toLowerCase().includes(inputValue.toLowerCase())
)
: suggestions
// Закрытие при клике снаружи
useEffect(() => {
const handleClickOutside = (e) => {
if (wrapperRef.current && !wrapperRef.current.contains(e.target)) {
setIsOpen(false)
// Восстанавливаем значение
setInputValue(value || '')
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [value])
const handleInputChange = (e) => {
const newValue = e.target.value
setInputValue(newValue)
setIsOpen(true)
setHighlightedIndex(-1)
onChange(newValue)
}
const handleSelectGroup = (group) => {
onChange(group)
setInputValue(group)
setIsOpen(false)
setHighlightedIndex(-1)
}
const handleKeyDown = (e) => {
if (!isOpen) {
if (e.key === 'ArrowDown' || e.key === 'Enter') {
setIsOpen(true)
e.preventDefault()
}
return
}
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setHighlightedIndex(prev =>
prev < filteredSuggestions.length - 1 ? prev + 1 : prev
)
break
case 'ArrowUp':
e.preventDefault()
setHighlightedIndex(prev => prev > 0 ? prev - 1 : -1)
break
case 'Enter':
e.preventDefault()
if (highlightedIndex >= 0 && filteredSuggestions[highlightedIndex]) {
handleSelectGroup(filteredSuggestions[highlightedIndex])
}
break
case 'Escape':
setIsOpen(false)
setInputValue(value || '')
break
}
}
const handleFocus = () => {
setIsOpen(true)
}
return (
<div className="group-autocomplete" ref={wrapperRef}>
<div className="group-autocomplete-input-wrapper">
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={handleInputChange}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
placeholder="Введите название группы..."
className="form-input"
autoComplete="off"
/>
{inputValue && (
<button
type="button"
onClick={() => {
setInputValue('')
onChange('')
inputRef.current?.focus()
}}
className="group-autocomplete-clear"
>
</button>
)}
</div>
{isOpen && filteredSuggestions.length > 0 && (
<div className="group-autocomplete-dropdown">
{filteredSuggestions.map((group, index) => (
<div
key={group}
className={`group-autocomplete-item ${
highlightedIndex === index ? 'highlighted' : ''
}`}
onClick={() => handleSelectGroup(group)}
onMouseEnter={() => setHighlightedIndex(index)}
>
{group}
</div>
))}
</div>
)}
</div>
)
}
export default TaskForm

View File

@@ -21,7 +21,7 @@
.task-search-input {
width: 100%;
padding: 0.75rem 2.5rem 0.75rem 3rem;
padding: 0.75rem 5rem 0.75rem 3rem;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
font-size: 1rem;
@@ -40,9 +40,34 @@
color: #9ca3af;
}
/* Кнопка переключения группировки */
.task-grouping-toggle {
position: absolute;
right: 1rem; /* Такой же отступ, как у иконки лупы */
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: #6366f1;
cursor: pointer;
padding: 0.25rem;
border-radius: 0.25rem;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
}
.task-grouping-toggle:hover {
background: rgba(99, 102, 241, 0.1);
color: #4f46e5;
}
.task-search-clear {
position: absolute;
right: 0.75rem;
right: 0.75rem; /* Остаётся на месте */
top: 50%;
transform: translateY(-50%);
background: none;

View File

@@ -23,6 +23,25 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
const [isPostponing, setIsPostponing] = useState(false)
const [toast, setToast] = useState(null)
const [searchQuery, setSearchQuery] = useState('')
// Режим группировки: 'project' (по проекту - по умолчанию) или 'group' (по группе)
const [groupingMode, setGroupingMode] = useState(() => {
// Восстанавливаем из localStorage, по умолчанию 'project'
try {
const saved = localStorage.getItem('taskListGroupingMode')
return saved === 'group' ? 'group' : 'project'
} catch {
return 'project'
}
})
// Сохраняем режим группировки в localStorage при изменении
useEffect(() => {
try {
localStorage.setItem('taskListGroupingMode', groupingMode)
} catch {
// Игнорируем ошибки localStorage
}
}, [groupingMode])
useEffect(() => {
if (data) {
@@ -508,6 +527,16 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
return []
}
// Получаем название группы задачи (для режима группировки по группе)
const getTaskGroupName = (task) => {
// Если у задачи есть group_name - возвращаем его
if (task.group_name && task.group_name.trim()) {
return task.group_name.trim()
}
// Иначе возвращаем null - задача попадёт в "Остальные"
return null
}
// Функция для проверки, является ли период нулевым
const isZeroPeriod = (intervalStr) => {
if (!intervalStr) return false
@@ -548,7 +577,7 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
return !isNaN(numValue) && numValue === 0
}
// Группируем задачи по проектам
// Группируем задачи по проектам или группам
const groupedTasks = useMemo(() => {
const today = new Date()
today.setHours(0, 0, 0, 0)
@@ -563,11 +592,18 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
const groups = {}
filteredTasks.forEach(task => {
const projects = getTaskProjects(task)
let groupKeys = []
// Если у задачи нет проектов, добавляем в группу "Без проекта"
if (projects.length === 0) {
projects.push('Без проекта')
if (groupingMode === 'project') {
// Группировка по проекту (текущее поведение)
groupKeys = getTaskProjects(task)
if (groupKeys.length === 0) {
groupKeys = ['Остальные'] // Было 'Без проекта'
}
} else {
// Группировка по group_name
const groupName = getTaskGroupName(task)
groupKeys = groupName ? [groupName] : ['Остальные']
}
// Определяем, в какую группу попадает задача
@@ -593,19 +629,19 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
isCompleted = false
}
projects.forEach(projectName => {
if (!groups[projectName]) {
groups[projectName] = {
groupKeys.forEach(groupKey => {
if (!groups[groupKey]) {
groups[groupKey] = {
notCompleted: [],
completed: []
}
}
if (isCompleted) {
groups[projectName].completed.push(task)
groups[groupKey].completed.push(task)
} else {
// Бесконечные задачи теперь идут в обычный список
groups[projectName].notCompleted.push(task)
groups[groupKey].notCompleted.push(task)
}
})
})
@@ -651,7 +687,7 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
})
return groups
}, [tasks, searchQuery])
}, [tasks, searchQuery, groupingMode])
// Сортируем проекты: сначала с невыполненными задачами, потом без них
// Группа "Без проекта" всегда последняя в своей категории
@@ -667,12 +703,12 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
if (!hasNotCompletedA && hasNotCompletedB) return 1
// Если обе группы в одной категории
const isNoProjectA = a === 'Без проекта'
const isNoProjectB = b === 'Без проекта'
const isOthersA = a === 'Остальные'
const isOthersB = b === 'Остальные'
// "Без проекта" всегда последняя в своей категории
if (isNoProjectA && !isNoProjectB) return 1
if (!isNoProjectA && isNoProjectB) return -1
// "Остальные" всегда последняя в своей категории
if (isOthersA && !isOthersB) return 1
if (!isOthersA && isOthersB) return -1
// Остальные группы сортируем по алфавиту
return a.localeCompare(b)
@@ -953,6 +989,26 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{/* Кнопка переключения группировки */}
<button
type="button"
className="task-grouping-toggle"
onClick={() => setGroupingMode(prev => prev === 'project' ? 'group' : 'project')}
title={groupingMode === 'project' ? 'Группировка по проекту' : 'Группировка по группе'}
>
{groupingMode === 'project' ? (
// Иконка "папка" для группировки по проекту
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
</svg>
) : (
// Иконка "тег" для группировки по группе
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"></path>
<line x1="7" y1="7" x2="7.01" y2="7"></line>
</svg>
)}
</button>
{searchQuery && (
<button
className="task-search-clear"

View File

@@ -577,59 +577,33 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false, initialBoa
)
}
// Группируем желания по проектам
// Группируем желания по группам
const groupedItems = useMemo(() => {
const groups = {}
const noProjectItems = []
const noGroupItems = []
items.forEach(item => {
if (item.project_id && item.project_name) {
const projectId = item.project_id
if (!groups[projectId]) {
groups[projectId] = {
projectId: projectId,
projectName: item.project_name,
if (item.group_name && item.group_name.trim()) {
const groupName = item.group_name.trim()
if (!groups[groupName]) {
groups[groupName] = {
groupName: groupName,
items: []
}
}
groups[projectId].items.push(item)
groups[groupName].items.push(item)
} else {
noProjectItems.push(item)
noGroupItems.push(item)
}
})
// Сортируем группы проектов
const projectIds = Object.keys(groups)
if (currentWeekData && projectIds.length > 0) {
const projectNames = projectIds.map(id => groups[id].projectName)
const sortedProjectNames = sortProjectsLikeCurrentWeek(projectNames, currentWeekData)
// Создаем отсортированный массив групп
const sortedGroups = []
sortedProjectNames.forEach(projectName => {
const group = Object.values(groups).find(g => g.projectName === projectName)
if (group) {
sortedGroups.push(group)
}
})
// Добавляем группы, которых нет в currentWeekData (если есть)
Object.values(groups).forEach(group => {
if (!sortedProjectNames.includes(group.projectName)) {
sortedGroups.push(group)
}
})
return { groups: sortedGroups, noProjectItems }
}
// Если нет данных текущей недели, сортируем по алфавиту
// Сортируем группы по алфавиту
const sortedGroups = Object.values(groups).sort((a, b) =>
a.projectName.localeCompare(b.projectName)
a.groupName.localeCompare(b.groupName)
)
return { groups: sortedGroups, noProjectItems }
}, [items, currentWeekData])
return { groups: sortedGroups, noGroupItems }
}, [items])
const renderItem = (item) => {
const isFaded = (!item.unlocked && !item.completed) || item.completed
@@ -722,19 +696,19 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false, initialBoa
<>
{/* Группы проектов */}
{groupedItems.groups.map(group => (
<div key={group.projectId} className="wishlist-project-group">
<div className="wishlist-project-group-title">{group.projectName}</div>
<div key={group.groupName} className="wishlist-project-group">
<div className="wishlist-project-group-title">{group.groupName}</div>
<div className="wishlist-project-group-items">
{group.items.map(renderItem)}
</div>
</div>
))}
{/* Желания без проекта */}
{groupedItems.noProjectItems.length > 0 && (
{/* Желания без группы */}
{groupedItems.noGroupItems.length > 0 && (
<div className="wishlist-no-project">
<div className="wishlist-grid">
{groupedItems.noProjectItems.map(renderItem)}
{groupedItems.noGroupItems.map(renderItem)}
</div>
</div>
)}

View File

@@ -650,3 +650,64 @@
background: #e0e7ff;
}
/* Group Autocomplete */
.group-autocomplete {
position: relative;
}
.group-autocomplete-input-wrapper {
position: relative;
}
.group-autocomplete-clear {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: #9ca3af;
cursor: pointer;
padding: 4px;
font-size: 12px;
line-height: 1;
border-radius: 4px;
transition: all 0.15s;
}
.group-autocomplete-clear:hover {
color: #6b7280;
background: #f3f4f6;
}
.group-autocomplete-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
max-height: 240px;
overflow-y: auto;
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
z-index: 50;
}
.group-autocomplete-item {
padding: 12px 14px;
cursor: pointer;
font-size: 14px;
color: #374151;
border-bottom: 1px solid #f3f4f6;
transition: background 0.1s;
}
.group-autocomplete-item:last-child {
border-bottom: none;
}
.group-autocomplete-item:hover,
.group-autocomplete-item.highlighted {
background: #f3f4f6;
}

View File

@@ -26,7 +26,8 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
const [editingConditionIndex, setEditingConditionIndex] = useState(null)
const [tasks, setTasks] = useState([])
const [projects, setProjects] = useState([])
const [selectedProjectId, setSelectedProjectId] = useState('')
const [groupName, setGroupName] = useState('')
const [groupSuggestions, setGroupSuggestions] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [toastMessage, setToastMessage] = useState(null)
@@ -36,7 +37,7 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
const [loadedWishlistData, setLoadedWishlistData] = useState(null) // Данные желания для последующего маппинга условий
const fileInputRef = useRef(null)
// Загрузка задач и проектов
// Загрузка задач, проектов и саджестов групп
useEffect(() => {
const loadData = async () => {
try {
@@ -47,18 +48,25 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
setTasks(Array.isArray(tasksData) ? tasksData : [])
}
// Загружаем проекты
// Загружаем проекты (нужны для ConditionForm)
const projectsResponse = await authFetch(PROJECTS_API_URL)
if (projectsResponse.ok) {
const projectsData = await projectsResponse.json()
setProjects(Array.isArray(projectsData) ? projectsData : [])
}
// Загружаем саджесты групп
const groupsResponse = await authFetch('/api/group-suggestions')
if (groupsResponse.ok) {
const groupsData = await groupsResponse.json()
setGroupSuggestions(Array.isArray(groupsData) ? groupsData : [])
}
} catch (err) {
console.error('Error loading data:', err)
}
}
loadData()
}, [])
}, [authFetch])
// Загрузка желания при редактировании или сброс формы при создании
useEffect(() => {
@@ -88,7 +96,7 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
setPrice(data.price ? String(data.price) : '')
setLink(data.link || '')
setImageUrl(data.image_url || null)
setSelectedProjectId(data.project_id ? String(data.project_id) : '')
setGroupName(data.group_name || '')
if (data.unlock_conditions) {
setUnlockConditions(data.unlock_conditions.map((cond, idx) => ({
id: cond.id || null,
@@ -246,9 +254,9 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
setPrice(data.price ? String(data.price) : '')
setLink(data.link || '')
setImageUrl(data.image_url || null)
setImageFile(null) // Сбрасываем imageFile при загрузке существующего желания
setImageRemoved(false) // Сбрасываем флаг удаления при загрузке
setSelectedProjectId(data.project_id ? String(data.project_id) : '')
setImageFile(null) // Сбрасываем imageFile при загрузке существующего желания
setImageRemoved(false) // Сбрасываем флаг удаления при загрузке
setGroupName(data.group_name || '')
if (data.unlock_conditions) {
setUnlockConditions(data.unlock_conditions.map((cond, idx) => ({
id: cond.id || null,
@@ -276,7 +284,7 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
setImageUrl(data.image_url || null)
setImageFile(null)
setImageRemoved(false) // Сбрасываем флаг удаления при загрузке
setSelectedProjectId(data.project_id ? String(data.project_id) : '')
setGroupName(data.group_name || '')
}
} catch (err) {
setError(err.message)
@@ -293,7 +301,7 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
setImageFile(null)
setImageRemoved(false)
setUnlockConditions([])
setSelectedProjectId('')
setGroupName('')
setError('')
setShowCropper(false)
setCrop({ x: 0, y: 0 })
@@ -567,7 +575,7 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
name: name.trim(),
price: price ? parseFloat(price) : null,
link: link.trim() || null,
project_id: selectedProjectId ? parseInt(selectedProjectId, 10) : null,
group_name: groupName.trim() || null,
unlock_conditions: unlockConditions.map(cond => ({
id: cond.id || null,
type: cond.type,
@@ -743,6 +751,15 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
/>
</div>
<div className="form-group">
<label htmlFor="group">Группа</label>
<GroupAutocomplete
suggestions={groupSuggestions}
value={groupName}
onChange={setGroupName}
/>
</div>
<div className="form-group">
<label>Картинка</label>
{imageUrl && !showCropper && (
@@ -867,23 +884,6 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
</button>
</div>
<div className="form-group">
<label htmlFor="project">Принадлежность к проекту</label>
<select
id="project"
value={selectedProjectId}
onChange={(e) => setSelectedProjectId(e.target.value)}
className="form-input"
>
<option value="">Не выбран</option>
{projects.map(project => (
<option key={project.project_id} value={project.project_id}>
{project.project_name}
</option>
))}
</select>
</div>
{error && <div className="error-message">{error}</div>}
<div className="form-actions">
@@ -997,6 +997,140 @@ function DateSelector({ value, onChange, placeholder = "За всё время"
)
}
// Компонент автодополнения для выбора группы
function GroupAutocomplete({ suggestions, value, onChange }) {
const [inputValue, setInputValue] = useState('')
const [isOpen, setIsOpen] = useState(false)
const [highlightedIndex, setHighlightedIndex] = useState(-1)
const wrapperRef = useRef(null)
const inputRef = useRef(null)
// При изменении value - обновить inputValue
useEffect(() => {
setInputValue(value || '')
}, [value])
// Фильтрация саджестов
const filteredSuggestions = inputValue.trim()
? suggestions.filter(group =>
group.toLowerCase().includes(inputValue.toLowerCase())
)
: suggestions
// Закрытие при клике снаружи
useEffect(() => {
const handleClickOutside = (e) => {
if (wrapperRef.current && !wrapperRef.current.contains(e.target)) {
setIsOpen(false)
// Восстанавливаем значение
setInputValue(value || '')
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [value])
const handleInputChange = (e) => {
const newValue = e.target.value
setInputValue(newValue)
setIsOpen(true)
setHighlightedIndex(-1)
onChange(newValue)
}
const handleSelectGroup = (group) => {
onChange(group)
setInputValue(group)
setIsOpen(false)
setHighlightedIndex(-1)
}
const handleKeyDown = (e) => {
if (!isOpen) {
if (e.key === 'ArrowDown' || e.key === 'Enter') {
setIsOpen(true)
e.preventDefault()
}
return
}
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setHighlightedIndex(prev =>
prev < filteredSuggestions.length - 1 ? prev + 1 : prev
)
break
case 'ArrowUp':
e.preventDefault()
setHighlightedIndex(prev => prev > 0 ? prev - 1 : -1)
break
case 'Enter':
e.preventDefault()
if (highlightedIndex >= 0 && filteredSuggestions[highlightedIndex]) {
handleSelectGroup(filteredSuggestions[highlightedIndex])
}
break
case 'Escape':
setIsOpen(false)
setInputValue(value || '')
break
}
}
const handleFocus = () => {
setIsOpen(true)
}
return (
<div className="group-autocomplete" ref={wrapperRef}>
<div className="group-autocomplete-input-wrapper">
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={handleInputChange}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
placeholder="Введите название группы..."
className="form-input"
autoComplete="off"
/>
{inputValue && (
<button
type="button"
onClick={() => {
setInputValue('')
onChange('')
inputRef.current?.focus()
}}
className="group-autocomplete-clear"
>
</button>
)}
</div>
{isOpen && filteredSuggestions.length > 0 && (
<div className="group-autocomplete-dropdown">
{filteredSuggestions.map((group, index) => (
<div
key={group}
className={`group-autocomplete-item ${
highlightedIndex === index ? 'highlighted' : ''
}`}
onClick={() => handleSelectGroup(group)}
onMouseEnter={() => setHighlightedIndex(index)}
>
{group}
</div>
))}
</div>
)}
</div>
)
}
// Компонент автодополнения для выбора задачи
function TaskAutocomplete({ tasks, value, onChange, onCreateTask, preselectedTaskId }) {
const [inputValue, setInputValue] = useState('')