6.4.0: Экран товаров (Shopping List)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m22s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m22s
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
|||||||
|
DROP TABLE IF EXISTS shopping_items;
|
||||||
|
DROP TABLE IF EXISTS shopping_board_members;
|
||||||
|
DROP TABLE IF EXISTS shopping_boards;
|
||||||
50
play-life-backend/migrations/000026_shopping_list.up.sql
Normal file
50
play-life-backend/migrations/000026_shopping_list.up.sql
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
-- Shopping boards (аналог wishlist_boards)
|
||||||
|
CREATE TABLE shopping_boards (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
invite_token VARCHAR(64) UNIQUE,
|
||||||
|
invite_enabled BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted BOOLEAN DEFAULT FALSE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_shopping_boards_owner_id ON shopping_boards(owner_id);
|
||||||
|
CREATE INDEX idx_shopping_boards_invite_token ON shopping_boards(invite_token) WHERE invite_token IS NOT NULL;
|
||||||
|
CREATE INDEX idx_shopping_boards_owner_deleted ON shopping_boards(owner_id, deleted);
|
||||||
|
|
||||||
|
-- Shopping board members (аналог wishlist_board_members)
|
||||||
|
CREATE TABLE shopping_board_members (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
board_id INTEGER NOT NULL REFERENCES shopping_boards(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
joined_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT unique_shopping_board_member UNIQUE (board_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_shopping_board_members_board_id ON shopping_board_members(board_id);
|
||||||
|
CREATE INDEX idx_shopping_board_members_user_id ON shopping_board_members(user_id);
|
||||||
|
|
||||||
|
-- Shopping items (товары)
|
||||||
|
CREATE TABLE shopping_items (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
board_id INTEGER NOT NULL REFERENCES shopping_boards(id) ON DELETE CASCADE,
|
||||||
|
author_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
group_name VARCHAR(255),
|
||||||
|
volume_base NUMERIC(10,4) NOT NULL DEFAULT 1,
|
||||||
|
repetition_period INTERVAL,
|
||||||
|
next_show_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
completed INTEGER DEFAULT 0,
|
||||||
|
last_completed_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted BOOLEAN DEFAULT FALSE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_shopping_items_board_id ON shopping_items(board_id);
|
||||||
|
CREATE INDEX idx_shopping_items_user_id ON shopping_items(user_id);
|
||||||
|
CREATE INDEX idx_shopping_items_deleted ON shopping_items(deleted);
|
||||||
|
CREATE INDEX idx_shopping_items_next_show_at ON shopping_items(next_show_at);
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "play-life-web",
|
"name": "play-life-web",
|
||||||
"version": "6.3.10",
|
"version": "6.4.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ import WishlistForm from './components/WishlistForm'
|
|||||||
import WishlistDetail from './components/WishlistDetail'
|
import WishlistDetail from './components/WishlistDetail'
|
||||||
import BoardForm from './components/BoardForm'
|
import BoardForm from './components/BoardForm'
|
||||||
import BoardJoinPreview from './components/BoardJoinPreview'
|
import BoardJoinPreview from './components/BoardJoinPreview'
|
||||||
|
import ShoppingList from './components/ShoppingList'
|
||||||
|
import ShoppingItemForm from './components/ShoppingItemForm'
|
||||||
|
import ShoppingBoardForm from './components/ShoppingBoardForm'
|
||||||
|
import ShoppingBoardJoinPreview from './components/ShoppingBoardJoinPreview'
|
||||||
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'
|
||||||
@@ -29,8 +33,8 @@ const CURRENT_WEEK_API_URL = '/playlife-feed'
|
|||||||
const FULL_STATISTICS_API_URL = '/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b'
|
const FULL_STATISTICS_API_URL = '/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b'
|
||||||
|
|
||||||
// Определяем основные табы (без крестика) и глубокие табы (с крестиком)
|
// Определяем основные табы (без крестика) и глубокие табы (с крестиком)
|
||||||
const mainTabs = ['current', 'tasks', 'wishlist', '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']
|
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']
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Гарантирует базовую запись истории для главного экрана перед глубоким табом.
|
* Гарантирует базовую запись истории для главного экрана перед глубоким табом.
|
||||||
@@ -77,8 +81,12 @@ function AppContent() {
|
|||||||
tracking: false,
|
tracking: false,
|
||||||
'tracking-access': false,
|
'tracking-access': false,
|
||||||
'tracking-invite': false,
|
'tracking-invite': false,
|
||||||
|
shopping: false,
|
||||||
|
'shopping-item-form': false,
|
||||||
|
'shopping-board-form': false,
|
||||||
|
'shopping-board-join': false,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Отслеживаем, какие табы уже были загружены (для предотвращения повторных загрузок)
|
// Отслеживаем, какие табы уже были загружены (для предотвращения повторных загрузок)
|
||||||
const [tabsInitialized, setTabsInitialized] = useState({
|
const [tabsInitialized, setTabsInitialized] = useState({
|
||||||
current: false,
|
current: false,
|
||||||
@@ -102,6 +110,10 @@ function AppContent() {
|
|||||||
tracking: false,
|
tracking: false,
|
||||||
'tracking-access': false,
|
'tracking-access': false,
|
||||||
'tracking-invite': false,
|
'tracking-invite': false,
|
||||||
|
shopping: false,
|
||||||
|
'shopping-item-form': false,
|
||||||
|
'shopping-board-form': false,
|
||||||
|
'shopping-board-join': false,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Параметры для навигации между вкладками
|
// Параметры для навигации между вкладками
|
||||||
@@ -149,6 +161,7 @@ function AppContent() {
|
|||||||
const [dictionariesRefreshTrigger, setDictionariesRefreshTrigger] = useState(0)
|
const [dictionariesRefreshTrigger, setDictionariesRefreshTrigger] = useState(0)
|
||||||
const [wordsRefreshTrigger, setWordsRefreshTrigger] = useState(0)
|
const [wordsRefreshTrigger, setWordsRefreshTrigger] = useState(0)
|
||||||
const [wishlistRefreshTrigger, setWishlistRefreshTrigger] = useState(0)
|
const [wishlistRefreshTrigger, setWishlistRefreshTrigger] = useState(0)
|
||||||
|
const [shoppingRefreshTrigger, setShoppingRefreshTrigger] = useState(0)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -227,6 +240,20 @@ function AppContent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Проверяем путь /shopping-invite/:token для присоединения к shopping доске
|
||||||
|
if (path.startsWith('/shopping-invite/')) {
|
||||||
|
const token = path.replace('/shopping-invite/', '')
|
||||||
|
if (token) {
|
||||||
|
const url = '/?tab=shopping-board-join&inviteToken=' + token
|
||||||
|
ensureBaseHistory('shopping-board-join', { inviteToken: token }, url)
|
||||||
|
setActiveTab('shopping-board-join')
|
||||||
|
setLoadedTabs(prev => ({ ...prev, 'shopping-board-join': true }))
|
||||||
|
setTabParams({ inviteToken: token })
|
||||||
|
setIsInitialized(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Проверяем путь /tracking/invite/:token
|
// Проверяем путь /tracking/invite/:token
|
||||||
if (path.startsWith('/tracking/invite/')) {
|
if (path.startsWith('/tracking/invite/')) {
|
||||||
const token = path.replace('/tracking/invite/', '')
|
const token = path.replace('/tracking/invite/', '')
|
||||||
@@ -262,8 +289,8 @@ 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']
|
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']
|
||||||
|
|
||||||
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)
|
||||||
const params = {}
|
const params = {}
|
||||||
@@ -754,7 +781,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']
|
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']
|
||||||
|
|
||||||
// Проверяем state текущей записи истории (куда мы вернулись)
|
// Проверяем state текущей записи истории (куда мы вернулись)
|
||||||
if (event.state && event.state.tab) {
|
if (event.state && event.state.tab) {
|
||||||
@@ -858,8 +885,8 @@ function AppContent() {
|
|||||||
setSelectedProject(null)
|
setSelectedProject(null)
|
||||||
setTabParams({})
|
setTabParams({})
|
||||||
updateUrl('full', {}, activeTab)
|
updateUrl('full', {}, activeTab)
|
||||||
} else if (tab !== activeTab || tab === 'task-form' || tab === 'wishlist-form' || (tab === 'words' && Object.keys(params).length > 0)) {
|
} else if (tab !== activeTab || tab === 'task-form' || tab === 'wishlist-form' || tab === 'shopping-item-form' || (tab === 'words' && Object.keys(params).length > 0)) {
|
||||||
// Для task-form и wishlist-form всегда обновляем параметры, даже если это тот же таб
|
// Для task-form, wishlist-form и shopping-item-form всегда обновляем параметры, даже если это тот же таб
|
||||||
markTabAsLoaded(tab)
|
markTabAsLoaded(tab)
|
||||||
|
|
||||||
// Определяем, является ли текущий таб глубоким
|
// Определяем, является ли текущий таб глубоким
|
||||||
@@ -889,7 +916,7 @@ function AppContent() {
|
|||||||
// Проверяем, была ли последняя запись в истории от модального окна
|
// Проверяем, была ли последняя запись в истории от модального окна
|
||||||
const currentState = window.history.state || {}
|
const currentState = window.history.state || {}
|
||||||
const isFromModal = currentState.modalOpen === true
|
const isFromModal = currentState.modalOpen === true
|
||||||
const isNavigatingToForm = tab === 'task-form' || tab === 'wishlist-form'
|
const isNavigatingToForm = tab === 'task-form' || tab === 'wishlist-form' || tab === 'shopping-item-form'
|
||||||
|
|
||||||
if (isFromModal && isNavigatingToForm) {
|
if (isFromModal && isNavigatingToForm) {
|
||||||
// Заменяем запись модального окна на запись формы редактирования
|
// Заменяем запись модального окна на запись формы редактирования
|
||||||
@@ -945,7 +972,7 @@ function AppContent() {
|
|||||||
if ((tab === 'wishlist-form' || tab === 'wishlist-detail') && activeTab !== tab) {
|
if ((tab === 'wishlist-form' || tab === 'wishlist-detail') && activeTab !== tab) {
|
||||||
setPreviousTab(activeTab)
|
setPreviousTab(activeTab)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обновляем список желаний при возврате из экрана редактирования
|
// Обновляем список желаний при возврате из экрана редактирования
|
||||||
if (activeTab === 'wishlist-form' && tab !== 'wishlist-form') {
|
if (activeTab === 'wishlist-form' && tab !== 'wishlist-form') {
|
||||||
// Сохраняем boardId из параметров или текущих tabParams
|
// Сохраняем boardId из параметров или текущих tabParams
|
||||||
@@ -958,13 +985,27 @@ function AppContent() {
|
|||||||
setWishlistRefreshTrigger(prev => prev + 1)
|
setWishlistRefreshTrigger(prev => prev + 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обновляем список желаний при возврате из экрана детализации
|
// Обновляем список желаний при возврате из экрана детализации
|
||||||
if (activeTab === 'wishlist-detail' && tab !== 'wishlist-detail') {
|
if (activeTab === 'wishlist-detail' && tab !== 'wishlist-detail') {
|
||||||
if (tab === 'wishlist') {
|
if (tab === 'wishlist') {
|
||||||
setWishlistRefreshTrigger(prev => prev + 1)
|
setWishlistRefreshTrigger(prev => prev + 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Сохраняем предыдущий таб при открытии shopping-item-form
|
||||||
|
if (tab === 'shopping-item-form' && activeTab !== tab) {
|
||||||
|
setPreviousTab(activeTab)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем список товаров при возврате из экрана редактирования
|
||||||
|
if ((activeTab === 'shopping-item-form' || activeTab === 'shopping-board-form') && tab === 'shopping') {
|
||||||
|
const savedBoardId = params.boardId || tabParams.boardId
|
||||||
|
if (savedBoardId) {
|
||||||
|
setTabParams(prev => ({ ...prev, boardId: savedBoardId }))
|
||||||
|
}
|
||||||
|
setShoppingRefreshTrigger(prev => prev + 1)
|
||||||
|
}
|
||||||
// Загрузка данных произойдет в useEffect при изменении activeTab
|
// Загрузка данных произойдет в useEffect при изменении activeTab
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1063,7 +1104,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'
|
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'
|
||||||
|
|
||||||
// Функция для получения классов скролл-контейнера для каждого таба
|
// Функция для получения классов скролл-контейнера для каждого таба
|
||||||
// Каждый таб имеет свой изолированный скролл-контейнер для автоматического сохранения позиции скролла
|
// Каждый таб имеет свой изолированный скролл-контейнер для автоматического сохранения позиции скролла
|
||||||
@@ -1075,7 +1116,7 @@ function AppContent() {
|
|||||||
|
|
||||||
// Определяем padding для каждого таба
|
// Определяем padding для каждого таба
|
||||||
let paddingClasses = ''
|
let paddingClasses = ''
|
||||||
if (tabName === 'current' || tabName === 'tasks' || tabName === 'wishlist' || tabName === 'profile') {
|
if (tabName === 'current' || tabName === 'tasks' || tabName === 'wishlist' || tabName === 'shopping' || tabName === 'profile') {
|
||||||
paddingClasses = 'pb-20'
|
paddingClasses = 'pb-20'
|
||||||
} else if (tabName === 'words' || tabName === 'dictionaries') {
|
} else if (tabName === 'words' || tabName === 'dictionaries') {
|
||||||
paddingClasses = 'pb-16'
|
paddingClasses = 'pb-16'
|
||||||
@@ -1086,7 +1127,7 @@ function AppContent() {
|
|||||||
|
|
||||||
// Функция для определения отступов внутреннего контейнера
|
// Функция для определения отступов внутреннего контейнера
|
||||||
const getInnerContainerClasses = (tabName) => {
|
const getInnerContainerClasses = (tabName) => {
|
||||||
if (tabName === 'tasks' || tabName === 'wishlist' || tabName === 'profile') {
|
if (tabName === 'tasks' || tabName === 'wishlist' || tabName === 'shopping' || tabName === 'profile') {
|
||||||
return 'max-w-7xl mx-auto p-4 md:p-8'
|
return 'max-w-7xl mx-auto p-4 md:p-8'
|
||||||
}
|
}
|
||||||
if (tabName === 'current') {
|
if (tabName === 'current') {
|
||||||
@@ -1301,6 +1342,59 @@ function AppContent() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{loadedTabs.shopping && (
|
||||||
|
<div className={getTabContainerClasses('shopping')}>
|
||||||
|
<div className={getInnerContainerClasses('shopping')}>
|
||||||
|
<ShoppingList
|
||||||
|
onNavigate={handleNavigate}
|
||||||
|
refreshTrigger={shoppingRefreshTrigger}
|
||||||
|
isActive={activeTab === 'shopping'}
|
||||||
|
initialBoardId={tabParams.boardId}
|
||||||
|
boardDeleted={tabParams.boardDeleted}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadedTabs['shopping-item-form'] && (
|
||||||
|
<div className={getTabContainerClasses('shopping-item-form')}>
|
||||||
|
<div className={getInnerContainerClasses('shopping-item-form')}>
|
||||||
|
<ShoppingItemForm
|
||||||
|
key={tabParams.itemId || 'new'}
|
||||||
|
onNavigate={handleNavigate}
|
||||||
|
itemId={tabParams.itemId}
|
||||||
|
boardId={tabParams.boardId}
|
||||||
|
onSaved={() => setShoppingRefreshTrigger(prev => prev + 1)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadedTabs['shopping-board-form'] && (
|
||||||
|
<div className={getTabContainerClasses('shopping-board-form')}>
|
||||||
|
<div className={getInnerContainerClasses('shopping-board-form')}>
|
||||||
|
<ShoppingBoardForm
|
||||||
|
key={tabParams.boardId || 'new'}
|
||||||
|
onNavigate={handleNavigate}
|
||||||
|
boardId={tabParams.boardId}
|
||||||
|
onSaved={() => setShoppingRefreshTrigger(prev => prev + 1)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadedTabs['shopping-board-join'] && (
|
||||||
|
<div className={getTabContainerClasses('shopping-board-join')}>
|
||||||
|
<div className={getInnerContainerClasses('shopping-board-join')}>
|
||||||
|
<ShoppingBoardJoinPreview
|
||||||
|
key={tabParams.inviteToken}
|
||||||
|
onNavigate={handleNavigate}
|
||||||
|
inviteToken={tabParams.inviteToken}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{loadedTabs.profile && (
|
{loadedTabs.profile && (
|
||||||
<div className={getTabContainerClasses('profile')}>
|
<div className={getTabContainerClasses('profile')}>
|
||||||
<div className={getInnerContainerClasses('profile')}>
|
<div className={getInnerContainerClasses('profile')}>
|
||||||
@@ -1423,6 +1517,42 @@ function AppContent() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Кнопка добавления товара (только для таба shopping) */}
|
||||||
|
{!isFullscreenTab && activeTab === 'shopping' && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
let boardId = tabParams.boardId
|
||||||
|
if (!boardId) {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('shopping_selected_board_id')
|
||||||
|
if (saved) {
|
||||||
|
const parsed = parseInt(saved, 10)
|
||||||
|
if (!isNaN(parsed)) boardId = parsed
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading boardId from localStorage:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
handleNavigate('shopping-item-form', { itemId: undefined, boardId: boardId })
|
||||||
|
}}
|
||||||
|
className="fixed bottom-16 right-4 z-20 bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-700 hover:to-purple-700 text-white w-[61px] h-[61px] rounded-2xl shadow-lg transition-all duration-200 hover:scale-105 flex items-center justify-center"
|
||||||
|
title="Добавить товар"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M12 5v14M5 12h14" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Кнопка добавления записи (только для таба current - экран прогресса) */}
|
{/* Кнопка добавления записи (только для таба current - экран прогресса) */}
|
||||||
{!isFullscreenTab && activeTab === 'current' && (
|
{!isFullscreenTab && activeTab === 'current' && (
|
||||||
<button
|
<button
|
||||||
@@ -1538,6 +1668,26 @@ function AppContent() {
|
|||||||
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-indigo-500 to-purple-500"></div>
|
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-indigo-500 to-purple-500"></div>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleTabChange('shopping')}
|
||||||
|
className={`min-w-max whitespace-nowrap px-4 py-4 transition-all duration-300 relative ${
|
||||||
|
activeTab === 'shopping' || activeTab === 'shopping-item-form'
|
||||||
|
? 'text-indigo-700 bg-white/50'
|
||||||
|
: 'text-gray-600 hover:text-indigo-600 hover:bg-white/30'
|
||||||
|
}`}
|
||||||
|
title="Товары"
|
||||||
|
>
|
||||||
|
<span className="relative z-10 flex items-center justify-center">
|
||||||
|
<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>
|
||||||
|
</span>
|
||||||
|
{(activeTab === 'shopping' || activeTab === 'shopping-item-form') && (
|
||||||
|
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-indigo-500 to-purple-500"></div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleTabChange('wishlist')}
|
onClick={() => handleTabChange('wishlist')}
|
||||||
className={`min-w-max whitespace-nowrap px-4 py-4 transition-all duration-300 relative ${
|
className={`min-w-max whitespace-nowrap px-4 py-4 transition-all duration-300 relative ${
|
||||||
|
|||||||
@@ -2,21 +2,21 @@ import React, { useState, useEffect } from 'react'
|
|||||||
import { useAuth } from './auth/AuthContext'
|
import { useAuth } from './auth/AuthContext'
|
||||||
import './BoardMembers.css'
|
import './BoardMembers.css'
|
||||||
|
|
||||||
function BoardMembers({ boardId, onMemberRemoved }) {
|
function BoardMembers({ boardId, onMemberRemoved, apiBase = '/api/wishlist' }) {
|
||||||
const { authFetch } = useAuth()
|
const { authFetch } = useAuth()
|
||||||
const [members, setMembers] = useState([])
|
const [members, setMembers] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [removingId, setRemovingId] = useState(null)
|
const [removingId, setRemovingId] = useState(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (boardId) {
|
if (boardId) {
|
||||||
fetchMembers()
|
fetchMembers()
|
||||||
}
|
}
|
||||||
}, [boardId])
|
}, [boardId])
|
||||||
|
|
||||||
const fetchMembers = async () => {
|
const fetchMembers = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await authFetch(`/api/wishlist/boards/${boardId}/members`)
|
const res = await authFetch(`${apiBase}/boards/${boardId}/members`)
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
setMembers(data || [])
|
setMembers(data || [])
|
||||||
@@ -27,13 +27,13 @@ function BoardMembers({ boardId, onMemberRemoved }) {
|
|||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRemoveMember = async (userId) => {
|
const handleRemoveMember = async (userId) => {
|
||||||
if (!window.confirm('Удалить участника из доски?')) return
|
if (!window.confirm('Удалить участника из доски?')) return
|
||||||
|
|
||||||
setRemovingId(userId)
|
setRemovingId(userId)
|
||||||
try {
|
try {
|
||||||
const res = await authFetch(`/api/wishlist/boards/${boardId}/members/${userId}`, {
|
const res = await authFetch(`${apiBase}/boards/${boardId}/members/${userId}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
|||||||
278
play-life-web/src/components/ShoppingBoardForm.jsx
Normal file
278
play-life-web/src/components/ShoppingBoardForm.jsx
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { useAuth } from './auth/AuthContext'
|
||||||
|
import BoardMembers from './BoardMembers'
|
||||||
|
import Toast from './Toast'
|
||||||
|
import SubmitButton from './SubmitButton'
|
||||||
|
import DeleteButton from './DeleteButton'
|
||||||
|
import './Buttons.css'
|
||||||
|
import './BoardForm.css'
|
||||||
|
|
||||||
|
function ShoppingBoardForm({ boardId, onNavigate, onSaved }) {
|
||||||
|
const { authFetch } = useAuth()
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [inviteEnabled, setInviteEnabled] = useState(false)
|
||||||
|
const [inviteURL, setInviteURL] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [loadingBoard, setLoadingBoard] = useState(false)
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
const [toastMessage, setToastMessage] = useState(null)
|
||||||
|
|
||||||
|
const isEdit = !!boardId
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (boardId) {
|
||||||
|
fetchBoard()
|
||||||
|
}
|
||||||
|
}, [boardId])
|
||||||
|
|
||||||
|
const fetchBoard = async () => {
|
||||||
|
setLoadingBoard(true)
|
||||||
|
try {
|
||||||
|
const res = await authFetch(`/api/shopping/boards/${boardId}`)
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setName(data.name)
|
||||||
|
setInviteEnabled(data.invite_enabled)
|
||||||
|
setInviteURL(data.invite_url || '')
|
||||||
|
} else {
|
||||||
|
setToastMessage({ text: 'Ошибка загрузки доски', type: 'error' })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setToastMessage({ text: 'Ошибка загрузки', type: 'error' })
|
||||||
|
} finally {
|
||||||
|
setLoadingBoard(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!name.trim()) {
|
||||||
|
setToastMessage({ text: 'Введите название доски', type: 'error' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const url = boardId
|
||||||
|
? `/api/shopping/boards/${boardId}`
|
||||||
|
: '/api/shopping/boards'
|
||||||
|
|
||||||
|
const res = await authFetch(url, {
|
||||||
|
method: boardId ? 'PUT' : 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: name.trim(),
|
||||||
|
invite_enabled: inviteEnabled
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.invite_url) {
|
||||||
|
setInviteURL(data.invite_url)
|
||||||
|
}
|
||||||
|
onSaved?.()
|
||||||
|
if (!boardId) {
|
||||||
|
onNavigate('shopping', { boardId: data.id })
|
||||||
|
} else {
|
||||||
|
onNavigate('shopping', { boardId: boardId })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const err = await res.json()
|
||||||
|
setToastMessage({ text: err.error || 'Ошибка сохранения', type: 'error' })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setToastMessage({ text: 'Ошибка сохранения', type: 'error' })
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateInviteLink = async () => {
|
||||||
|
try {
|
||||||
|
const res = await authFetch(`/api/shopping/boards/${boardId}/regenerate-invite`, {
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setInviteURL(data.invite_url)
|
||||||
|
setInviteEnabled(true)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error generating invite link:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopyLink = () => {
|
||||||
|
navigator.clipboard.writeText(inviteURL)
|
||||||
|
setCopied(true)
|
||||||
|
setToastMessage({ text: 'Ссылка скопирована', type: 'success' })
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggleInvite = async (enabled) => {
|
||||||
|
setInviteEnabled(enabled)
|
||||||
|
|
||||||
|
if (boardId && enabled && !inviteURL) {
|
||||||
|
await generateInviteLink()
|
||||||
|
} else if (boardId) {
|
||||||
|
try {
|
||||||
|
await authFetch(`/api/shopping/boards/${boardId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ invite_enabled: enabled })
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error updating invite status:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!window.confirm('Удалить доску? Все товары на ней будут удалены.')) return
|
||||||
|
|
||||||
|
setIsDeleting(true)
|
||||||
|
try {
|
||||||
|
const res = await authFetch(`/api/shopping/boards/${boardId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
onSaved?.()
|
||||||
|
onNavigate('shopping', { boardDeleted: true })
|
||||||
|
} else {
|
||||||
|
setToastMessage({ text: 'Ошибка удаления', type: 'error' })
|
||||||
|
setIsDeleting(false)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setToastMessage({ text: 'Ошибка удаления', type: 'error' })
|
||||||
|
setIsDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
window.history.back()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadingBoard) {
|
||||||
|
return (
|
||||||
|
<div className="board-form">
|
||||||
|
<div className="fixed inset-0 bottom-20 flex justify-center items-center">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div className="w-12 h-12 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin mb-4"></div>
|
||||||
|
<div className="text-gray-600 font-medium">Загрузка...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="board-form">
|
||||||
|
<button className="close-x-button" onClick={handleClose}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h2>{isEdit ? 'Настройки доски' : 'Новая доска'}</h2>
|
||||||
|
|
||||||
|
<div className="form-card">
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="board-name">Название</label>
|
||||||
|
<input
|
||||||
|
id="board-name"
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
value={name}
|
||||||
|
onChange={e => setName(e.target.value)}
|
||||||
|
placeholder="Название доски"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEdit && (
|
||||||
|
<>
|
||||||
|
<div className="form-section">
|
||||||
|
<h3>Доступ по ссылке</h3>
|
||||||
|
|
||||||
|
<label className="toggle-field">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={inviteEnabled}
|
||||||
|
onChange={e => handleToggleInvite(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="toggle-slider"></span>
|
||||||
|
<span className="toggle-label">Разрешить присоединение по ссылке</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{inviteEnabled && inviteURL && (
|
||||||
|
<div className="invite-link-section">
|
||||||
|
<div className="invite-url-row">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="invite-url-input"
|
||||||
|
value={inviteURL}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="copy-btn"
|
||||||
|
onClick={handleCopyLink}
|
||||||
|
title="Копировать ссылку"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M20 6L9 17l-5-5"></path>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||||
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="invite-hint">
|
||||||
|
Пользователь, открывший ссылку, сможет присоединиться к доске
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BoardMembers
|
||||||
|
boardId={boardId}
|
||||||
|
apiBase="/api/shopping"
|
||||||
|
onMemberRemoved={() => {
|
||||||
|
setToastMessage({ text: 'Участник удалён', type: 'success' })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<SubmitButton
|
||||||
|
onClick={handleSave}
|
||||||
|
loading={loading}
|
||||||
|
disabled={!name.trim()}
|
||||||
|
>
|
||||||
|
Сохранить
|
||||||
|
</SubmitButton>
|
||||||
|
{isEdit && (
|
||||||
|
<DeleteButton
|
||||||
|
onClick={handleDelete}
|
||||||
|
loading={isDeleting}
|
||||||
|
disabled={loading}
|
||||||
|
title="Удалить доску"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{toastMessage && (
|
||||||
|
<Toast
|
||||||
|
message={toastMessage.text}
|
||||||
|
type={toastMessage.type}
|
||||||
|
onClose={() => setToastMessage(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ShoppingBoardForm
|
||||||
149
play-life-web/src/components/ShoppingBoardJoinPreview.jsx
Normal file
149
play-life-web/src/components/ShoppingBoardJoinPreview.jsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { useAuth } from './auth/AuthContext'
|
||||||
|
import './BoardJoinPreview.css'
|
||||||
|
|
||||||
|
function ShoppingBoardJoinPreview({ inviteToken, onNavigate }) {
|
||||||
|
const { authFetch, user } = useAuth()
|
||||||
|
const [board, setBoard] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [joining, setJoining] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (inviteToken) {
|
||||||
|
fetchBoardInfo()
|
||||||
|
}
|
||||||
|
}, [inviteToken])
|
||||||
|
|
||||||
|
const fetchBoardInfo = async () => {
|
||||||
|
try {
|
||||||
|
const res = await authFetch(`/api/shopping/invite/${inviteToken}`)
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setBoard(await res.json())
|
||||||
|
} else {
|
||||||
|
const err = await res.json()
|
||||||
|
setError(err.error || 'Ссылка недействительна или устарела')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('Ошибка загрузки')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleJoin = async () => {
|
||||||
|
if (!user) {
|
||||||
|
sessionStorage.setItem('pendingShoppingInviteToken', inviteToken)
|
||||||
|
onNavigate('login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setJoining(true)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await authFetch(`/api/shopping/invite/${inviteToken}/join`, {
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
onNavigate('shopping', { boardId: data.board.id })
|
||||||
|
} else {
|
||||||
|
const err = await res.json()
|
||||||
|
setError(err.error || 'Ошибка при присоединении')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('Ошибка при присоединении')
|
||||||
|
} finally {
|
||||||
|
setJoining(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGoBack = () => {
|
||||||
|
onNavigate('shopping')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="board-join-preview">
|
||||||
|
<div className="preview-loading">
|
||||||
|
<div className="w-12 h-12 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin"></div>
|
||||||
|
<p>Загрузка...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !board) {
|
||||||
|
return (
|
||||||
|
<div className="board-join-preview">
|
||||||
|
<div className="preview-card error-card">
|
||||||
|
<div className="error-icon">X</div>
|
||||||
|
<h2>Ошибка</h2>
|
||||||
|
<p className="error-text">{error}</p>
|
||||||
|
<button className="back-btn" onClick={handleGoBack}>
|
||||||
|
Вернуться к товарам
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="board-join-preview">
|
||||||
|
<div className="preview-card">
|
||||||
|
<h2>Приглашение на доску</h2>
|
||||||
|
|
||||||
|
<div className="board-info">
|
||||||
|
<div className="board-name">{board.name}</div>
|
||||||
|
<div className="board-owner">
|
||||||
|
<span className="label">Владелец:</span>
|
||||||
|
<span className="value">{board.owner_name}</span>
|
||||||
|
</div>
|
||||||
|
{board.member_count > 0 && (
|
||||||
|
<div className="board-members">
|
||||||
|
<span className="label">Участников:</span>
|
||||||
|
<span className="value">{board.member_count}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="join-error">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{user ? (
|
||||||
|
<button
|
||||||
|
className="join-btn"
|
||||||
|
onClick={handleJoin}
|
||||||
|
disabled={joining}
|
||||||
|
>
|
||||||
|
{joining ? (
|
||||||
|
<>
|
||||||
|
<span className="spinner-small"></span>
|
||||||
|
<span>Присоединение...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span>Присоединиться</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="login-prompt">
|
||||||
|
<p>Для присоединения необходимо войти в аккаунт</p>
|
||||||
|
<button className="login-btn" onClick={() => onNavigate('login')}>
|
||||||
|
Войти
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button className="cancel-link" onClick={handleGoBack}>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ShoppingBoardJoinPreview
|
||||||
202
play-life-web/src/components/ShoppingItemDetail.jsx
Normal file
202
play-life-web/src/components/ShoppingItemDetail.jsx
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { useAuth } from './auth/AuthContext'
|
||||||
|
import LoadingError from './LoadingError'
|
||||||
|
import Toast from './Toast'
|
||||||
|
import './TaskDetail.css'
|
||||||
|
|
||||||
|
function ShoppingItemDetail({ itemId, onClose, onRefresh, onItemCompleted, onNavigate }) {
|
||||||
|
const { authFetch } = useAuth()
|
||||||
|
const [item, setItem] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [volumeValue, setVolumeValue] = useState('')
|
||||||
|
const [isCompleting, setIsCompleting] = useState(false)
|
||||||
|
const [toastMessage, setToastMessage] = useState(null)
|
||||||
|
|
||||||
|
const fetchItem = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
const response = await authFetch(`/api/shopping/items/${itemId}`)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Ошибка загрузки товара')
|
||||||
|
}
|
||||||
|
const data = await response.json()
|
||||||
|
setItem(data)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [itemId, authFetch])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (itemId) {
|
||||||
|
fetchItem()
|
||||||
|
} else {
|
||||||
|
setItem(null)
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
setVolumeValue('')
|
||||||
|
}
|
||||||
|
}, [itemId, fetchItem])
|
||||||
|
|
||||||
|
const handleComplete = async () => {
|
||||||
|
if (!item) return
|
||||||
|
|
||||||
|
setIsCompleting(true)
|
||||||
|
try {
|
||||||
|
const payload = {}
|
||||||
|
if (volumeValue.trim()) {
|
||||||
|
payload.volume = parseFloat(volumeValue)
|
||||||
|
if (isNaN(payload.volume)) {
|
||||||
|
throw new Error('Неверное значение объёма')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
payload.volume = item.volume_base
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await authFetch(`/api/shopping/items/${itemId}/complete`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}))
|
||||||
|
throw new Error(errorData.error || 'Ошибка при выполнении')
|
||||||
|
}
|
||||||
|
|
||||||
|
onItemCompleted?.()
|
||||||
|
onRefresh?.()
|
||||||
|
onClose?.()
|
||||||
|
} catch (err) {
|
||||||
|
setToastMessage({ text: err.message || 'Ошибка', type: 'error' })
|
||||||
|
} finally {
|
||||||
|
setIsCompleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!itemId) return null
|
||||||
|
|
||||||
|
const modalContent = (
|
||||||
|
<div className="task-detail-modal-overlay" onClick={onClose}>
|
||||||
|
<div className="task-detail-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="task-detail-modal-header">
|
||||||
|
<h2
|
||||||
|
className="task-detail-title"
|
||||||
|
onClick={item ? () => {
|
||||||
|
onClose?.(true)
|
||||||
|
onNavigate?.('shopping-item-form', { itemId: itemId, boardId: item.board_id })
|
||||||
|
} : undefined}
|
||||||
|
style={{ cursor: item ? 'pointer' : 'default' }}
|
||||||
|
>
|
||||||
|
{loading ? 'Загрузка...' : error ? 'Ошибка' : item ? (
|
||||||
|
<>
|
||||||
|
{item.name}
|
||||||
|
<svg
|
||||||
|
className="task-detail-edit-icon"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path>
|
||||||
|
</svg>
|
||||||
|
</>
|
||||||
|
) : 'Товар'}
|
||||||
|
</h2>
|
||||||
|
<button onClick={onClose} className="task-detail-close-button">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="task-detail-modal-content">
|
||||||
|
{loading && (
|
||||||
|
<div className="loading">Загрузка...</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && !loading && (
|
||||||
|
<LoadingError onRetry={fetchItem} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && item && (
|
||||||
|
<>
|
||||||
|
<div className="progression-section">
|
||||||
|
<label className="progression-label">Объём</label>
|
||||||
|
<div className="progression-input-wrapper">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
value={volumeValue}
|
||||||
|
onChange={(e) => setVolumeValue(e.target.value)}
|
||||||
|
placeholder={item.volume_base?.toString() || '1'}
|
||||||
|
className="progression-input"
|
||||||
|
/>
|
||||||
|
<div className="progression-controls-capsule">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="progression-control-btn progression-control-minus"
|
||||||
|
onClick={() => {
|
||||||
|
const current = parseFloat(volumeValue) || 0
|
||||||
|
const step = item.volume_base || 1
|
||||||
|
setVolumeValue((current - step).toString())
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="progression-control-btn progression-control-plus"
|
||||||
|
onClick={() => {
|
||||||
|
const current = parseFloat(volumeValue) || 0
|
||||||
|
const step = item.volume_base || 1
|
||||||
|
setVolumeValue((current + step).toString())
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="task-detail-divider"></div>
|
||||||
|
|
||||||
|
<div className="task-actions-section">
|
||||||
|
<div className="task-actions-buttons">
|
||||||
|
<div className="task-action-left">
|
||||||
|
<button
|
||||||
|
onClick={handleComplete}
|
||||||
|
disabled={isCompleting}
|
||||||
|
className="action-button action-button-check"
|
||||||
|
>
|
||||||
|
{isCompleting ? 'Выполнение...' : 'Выполнить'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{toastMessage && (
|
||||||
|
<Toast
|
||||||
|
message={toastMessage.text}
|
||||||
|
type={toastMessage.type}
|
||||||
|
onClose={() => setToastMessage(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return typeof document !== 'undefined'
|
||||||
|
? createPortal(modalContent, document.body)
|
||||||
|
: modalContent
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ShoppingItemDetail
|
||||||
19
play-life-web/src/components/ShoppingItemForm.css
Normal file
19
play-life-web/src/components/ShoppingItemForm.css
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
.shopping-item-form {
|
||||||
|
padding: 20px;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-item-form h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1e293b;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-item-form .repetition-label {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
295
play-life-web/src/components/ShoppingItemForm.jsx
Normal file
295
play-life-web/src/components/ShoppingItemForm.jsx
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { useAuth } from './auth/AuthContext'
|
||||||
|
import Toast from './Toast'
|
||||||
|
import SubmitButton from './SubmitButton'
|
||||||
|
import DeleteButton from './DeleteButton'
|
||||||
|
import './ShoppingItemForm.css'
|
||||||
|
|
||||||
|
function ShoppingItemForm({ onNavigate, itemId, boardId, onSaved }) {
|
||||||
|
const { authFetch } = useAuth()
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [groupName, setGroupName] = useState('')
|
||||||
|
const [groupSuggestions, setGroupSuggestions] = useState([])
|
||||||
|
const [volumeBase, setVolumeBase] = useState('')
|
||||||
|
const [repetitionPeriodValue, setRepetitionPeriodValue] = useState('')
|
||||||
|
const [repetitionPeriodType, setRepetitionPeriodType] = useState('day')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [loadingItem, setLoadingItem] = useState(false)
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
|
const [toastMessage, setToastMessage] = useState(null)
|
||||||
|
|
||||||
|
const isEdit = !!itemId
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadGroupSuggestions()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (itemId) {
|
||||||
|
fetchItem()
|
||||||
|
}
|
||||||
|
}, [itemId])
|
||||||
|
|
||||||
|
const loadGroupSuggestions = async () => {
|
||||||
|
try {
|
||||||
|
const res = await authFetch('/api/shopping/groups')
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setGroupSuggestions(Array.isArray(data) ? data : [])
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading group suggestions:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchItem = async () => {
|
||||||
|
setLoadingItem(true)
|
||||||
|
try {
|
||||||
|
const res = await authFetch(`/api/shopping/items/${itemId}`)
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setName(data.name)
|
||||||
|
setGroupName(data.group_name || '')
|
||||||
|
if (data.volume_base && data.volume_base !== 1) {
|
||||||
|
setVolumeBase(data.volume_base.toString())
|
||||||
|
}
|
||||||
|
if (data.repetition_period) {
|
||||||
|
const parts = data.repetition_period.trim().split(/\s+/)
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
const value = parseInt(parts[0], 10)
|
||||||
|
const unit = parts[1].toLowerCase()
|
||||||
|
setRepetitionPeriodValue(value.toString())
|
||||||
|
// Map PostgreSQL units to our types
|
||||||
|
if (unit.startsWith('day')) setRepetitionPeriodType('day')
|
||||||
|
else if (unit.startsWith('week') || unit === 'wks' || unit === 'wk') setRepetitionPeriodType('week')
|
||||||
|
else if (unit.startsWith('mon')) setRepetitionPeriodType('month')
|
||||||
|
else if (unit.startsWith('year') || unit === 'yrs' || unit === 'yr') setRepetitionPeriodType('year')
|
||||||
|
else if (unit.startsWith('hour') || unit === 'hrs' || unit === 'hr') setRepetitionPeriodType('hour')
|
||||||
|
else if (unit.startsWith('min')) setRepetitionPeriodType('minute')
|
||||||
|
// Handle PostgreSQL weeks-as-days: "7 days" -> 1 week
|
||||||
|
if (unit.startsWith('day') && value % 7 === 0 && value >= 7) {
|
||||||
|
setRepetitionPeriodValue((value / 7).toString())
|
||||||
|
setRepetitionPeriodType('week')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setToastMessage({ text: 'Ошибка загрузки товара', type: 'error' })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setToastMessage({ text: 'Ошибка загрузки', type: 'error' })
|
||||||
|
} finally {
|
||||||
|
setLoadingItem(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!name.trim()) {
|
||||||
|
setToastMessage({ text: 'Введите название товара', type: 'error' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasValidPeriod) {
|
||||||
|
setToastMessage({ text: 'Укажите период повторения', type: 'error' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
let repetitionPeriod = null
|
||||||
|
if (repetitionPeriodValue && repetitionPeriodValue.trim() !== '') {
|
||||||
|
const val = parseInt(repetitionPeriodValue.trim(), 10)
|
||||||
|
if (!isNaN(val) && val > 0) {
|
||||||
|
repetitionPeriod = `${val} ${repetitionPeriodType}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const vb = volumeBase.trim() ? parseFloat(volumeBase.trim()) : null
|
||||||
|
const payload = {
|
||||||
|
name: name.trim(),
|
||||||
|
group_name: groupName.trim() || null,
|
||||||
|
volume_base: vb && vb > 0 ? vb : null,
|
||||||
|
repetition_period: repetitionPeriod,
|
||||||
|
}
|
||||||
|
|
||||||
|
let url, method
|
||||||
|
if (isEdit) {
|
||||||
|
url = `/api/shopping/items/${itemId}`
|
||||||
|
method = 'PUT'
|
||||||
|
} else {
|
||||||
|
url = `/api/shopping/boards/${boardId}/items`
|
||||||
|
method = 'POST'
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await authFetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
onSaved?.()
|
||||||
|
onNavigate('shopping', { boardId })
|
||||||
|
} else {
|
||||||
|
const err = await res.json()
|
||||||
|
setToastMessage({ text: err.error || 'Ошибка сохранения', type: 'error' })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setToastMessage({ text: 'Ошибка сохранения', type: 'error' })
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!window.confirm('Удалить товар?')) return
|
||||||
|
|
||||||
|
setIsDeleting(true)
|
||||||
|
try {
|
||||||
|
const res = await authFetch(`/api/shopping/items/${itemId}`, { method: 'DELETE' })
|
||||||
|
if (res.ok) {
|
||||||
|
onSaved?.()
|
||||||
|
onNavigate('shopping', { boardId })
|
||||||
|
} else {
|
||||||
|
setToastMessage({ text: 'Ошибка удаления', type: 'error' })
|
||||||
|
setIsDeleting(false)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setToastMessage({ text: 'Ошибка удаления', type: 'error' })
|
||||||
|
setIsDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
window.history.back()
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasValidPeriod = repetitionPeriodValue && repetitionPeriodValue.trim() !== '' && parseInt(repetitionPeriodValue.trim(), 10) > 0
|
||||||
|
|
||||||
|
if (loadingItem) {
|
||||||
|
return (
|
||||||
|
<div className="shopping-item-form">
|
||||||
|
<div className="fixed inset-0 bottom-20 flex justify-center items-center">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div className="w-12 h-12 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin mb-4"></div>
|
||||||
|
<div className="text-gray-600 font-medium">Загрузка...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="shopping-item-form">
|
||||||
|
<button className="close-x-button" onClick={handleClose}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h2>{isEdit ? 'Редактировать товар' : 'Новый товар'}</h2>
|
||||||
|
|
||||||
|
<div className="form-card">
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="item-name">Название</label>
|
||||||
|
<input
|
||||||
|
id="item-name"
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
value={name}
|
||||||
|
onChange={e => setName(e.target.value)}
|
||||||
|
placeholder="Название товара"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="item-group">Группа</label>
|
||||||
|
<input
|
||||||
|
id="item-group"
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
value={groupName}
|
||||||
|
onChange={e => setGroupName(e.target.value)}
|
||||||
|
placeholder="Группа товара"
|
||||||
|
list="shopping-group-suggestions"
|
||||||
|
/>
|
||||||
|
<datalist id="shopping-group-suggestions">
|
||||||
|
{groupSuggestions.map((g, i) => (
|
||||||
|
<option key={i} value={g} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="item-volume">Объём</label>
|
||||||
|
<input
|
||||||
|
id="item-volume"
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
min="0"
|
||||||
|
className="form-input"
|
||||||
|
value={volumeBase}
|
||||||
|
onChange={e => setVolumeBase(e.target.value)}
|
||||||
|
placeholder="1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="item-repetition">Повторения</label>
|
||||||
|
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||||
|
<span className="repetition-label">Через</span>
|
||||||
|
<input
|
||||||
|
id="item-repetition"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
className="form-input"
|
||||||
|
value={repetitionPeriodValue}
|
||||||
|
onChange={e => setRepetitionPeriodValue(e.target.value)}
|
||||||
|
placeholder="Число"
|
||||||
|
style={{ flex: '1' }}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={repetitionPeriodType}
|
||||||
|
onChange={e => setRepetitionPeriodType(e.target.value)}
|
||||||
|
className="form-input"
|
||||||
|
style={{ width: '120px' }}
|
||||||
|
>
|
||||||
|
<option value="minute">Минута</option>
|
||||||
|
<option value="hour">Час</option>
|
||||||
|
<option value="day">День</option>
|
||||||
|
<option value="week">Неделя</option>
|
||||||
|
<option value="month">Месяц</option>
|
||||||
|
<option value="year">Год</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<SubmitButton
|
||||||
|
onClick={handleSave}
|
||||||
|
loading={loading}
|
||||||
|
disabled={!name.trim() || !hasValidPeriod}
|
||||||
|
>
|
||||||
|
Сохранить
|
||||||
|
</SubmitButton>
|
||||||
|
{isEdit && (
|
||||||
|
<DeleteButton
|
||||||
|
onClick={handleDelete}
|
||||||
|
loading={isDeleting}
|
||||||
|
disabled={loading}
|
||||||
|
title="Удалить товар"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{toastMessage && (
|
||||||
|
<Toast
|
||||||
|
message={toastMessage.text}
|
||||||
|
type={toastMessage.type}
|
||||||
|
onClose={() => setToastMessage(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ShoppingItemForm
|
||||||
28
play-life-web/src/components/ShoppingList.css
Normal file
28
play-life-web/src/components/ShoppingList.css
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
.shopping-list {
|
||||||
|
max-width: 42rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding-bottom: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem 1rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-empty p:first-child {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #64748b;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-empty-hint {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-loading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 3rem 0;
|
||||||
|
}
|
||||||
736
play-life-web/src/components/ShoppingList.jsx
Normal file
736
play-life-web/src/components/ShoppingList.jsx
Normal file
@@ -0,0 +1,736 @@
|
|||||||
|
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { useAuth } from './auth/AuthContext'
|
||||||
|
import BoardSelector from './BoardSelector'
|
||||||
|
import ShoppingItemDetail from './ShoppingItemDetail'
|
||||||
|
import LoadingError from './LoadingError'
|
||||||
|
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'
|
||||||
|
|
||||||
|
const BOARDS_CACHE_KEY = 'shopping_boards_cache'
|
||||||
|
const ITEMS_CACHE_KEY = 'shopping_items_cache'
|
||||||
|
const SELECTED_BOARD_KEY = 'shopping_selected_board_id'
|
||||||
|
|
||||||
|
// Форматирование даты в 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.floor((target - now) / (1000 * 60 * 60 * 24))
|
||||||
|
|
||||||
|
if (diffDays === 0) return 'Сегодня'
|
||||||
|
if (diffDays === 1) return 'Завтра'
|
||||||
|
if (diffDays === -1) return 'Вчера'
|
||||||
|
|
||||||
|
if (diffDays > 0 && diffDays <= 7) {
|
||||||
|
const dayNames = ['Воскресенье', 'Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота']
|
||||||
|
return dayNames[target.getDay()]
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthNames = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
|
||||||
|
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря']
|
||||||
|
|
||||||
|
if (target.getFullYear() === now.getFullYear()) {
|
||||||
|
return `${target.getDate()} ${monthNames[target.getMonth()]}`
|
||||||
|
}
|
||||||
|
return `${target.getDate()} ${monthNames[target.getMonth()]} ${target.getFullYear()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вычисление следующей даты по repetition_period
|
||||||
|
const calculateNextDateFromRepetitionPeriod = (repetitionPeriodStr) => {
|
||||||
|
if (!repetitionPeriodStr) return null
|
||||||
|
const parts = repetitionPeriodStr.trim().split(/\s+/)
|
||||||
|
if (parts.length < 2) return null
|
||||||
|
const value = parseInt(parts[0], 10)
|
||||||
|
if (isNaN(value) || value === 0) return null
|
||||||
|
const unit = parts[1].toLowerCase()
|
||||||
|
const now = new Date()
|
||||||
|
now.setHours(0, 0, 0, 0)
|
||||||
|
const nextDate = new Date(now)
|
||||||
|
|
||||||
|
switch (unit) {
|
||||||
|
case 'day': case 'days':
|
||||||
|
if (value % 7 === 0 && value >= 7) {
|
||||||
|
nextDate.setDate(nextDate.getDate() + value)
|
||||||
|
} else {
|
||||||
|
nextDate.setDate(nextDate.getDate() + value)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'week': case 'weeks': case 'wks': case 'wk':
|
||||||
|
nextDate.setDate(nextDate.getDate() + value * 7)
|
||||||
|
break
|
||||||
|
case 'month': case 'months': case 'mons': case 'mon':
|
||||||
|
nextDate.setMonth(nextDate.getMonth() + value)
|
||||||
|
break
|
||||||
|
case 'year': case 'years': case 'yrs': case 'yr':
|
||||||
|
nextDate.setFullYear(nextDate.getFullYear() + value)
|
||||||
|
break
|
||||||
|
case 'hour': case 'hours': case 'hrs': case 'hr':
|
||||||
|
nextDate.setHours(nextDate.getHours() + value)
|
||||||
|
break
|
||||||
|
case 'minute': case 'minutes': case 'mins': case 'min':
|
||||||
|
nextDate.setMinutes(nextDate.getMinutes() + value)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return nextDate
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShoppingList({ onNavigate, refreshTrigger = 0, isActive = false, initialBoardId = null, boardDeleted = false }) {
|
||||||
|
const { authFetch } = useAuth()
|
||||||
|
const [boards, setBoards] = useState([])
|
||||||
|
|
||||||
|
const getInitialBoardId = () => {
|
||||||
|
if (initialBoardId) return initialBoardId
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(SELECTED_BOARD_KEY)
|
||||||
|
if (saved) {
|
||||||
|
const boardId = parseInt(saved, 10)
|
||||||
|
if (!isNaN(boardId)) return boardId
|
||||||
|
}
|
||||||
|
} catch (err) {}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const [selectedBoardId, setSelectedBoardIdState] = useState(getInitialBoardId)
|
||||||
|
const [items, setItems] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [boardsLoading, setBoardsLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
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 fetchingRef = useRef(false)
|
||||||
|
const initialFetchDoneRef = useRef(false)
|
||||||
|
const prevIsActiveRef = useRef(isActive)
|
||||||
|
|
||||||
|
const setSelectedBoardId = (boardId) => {
|
||||||
|
setSelectedBoardIdState(boardId)
|
||||||
|
try {
|
||||||
|
if (boardId) {
|
||||||
|
localStorage.setItem(SELECTED_BOARD_KEY, String(boardId))
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(SELECTED_BOARD_KEY)
|
||||||
|
}
|
||||||
|
} catch (err) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка досок
|
||||||
|
const fetchBoards = async () => {
|
||||||
|
setBoardsLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await authFetch('/api/shopping/boards')
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
const boardsList = Array.isArray(data) ? data : []
|
||||||
|
setBoards(boardsList)
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.setItem(BOARDS_CACHE_KEY, JSON.stringify({ boards: boardsList }))
|
||||||
|
} catch (err) {}
|
||||||
|
|
||||||
|
if (boardDeleted || !boardsList.some(b => b.id === selectedBoardId)) {
|
||||||
|
if (boardsList.length > 0) {
|
||||||
|
setSelectedBoardId(boardsList[0].id)
|
||||||
|
} else {
|
||||||
|
setSelectedBoardId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('Ошибка загрузки досок')
|
||||||
|
} finally {
|
||||||
|
setBoardsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка товаров
|
||||||
|
const fetchItems = async (boardId) => {
|
||||||
|
if (!boardId || fetchingRef.current) return
|
||||||
|
fetchingRef.current = true
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await authFetch(`/api/shopping/boards/${boardId}/items`)
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setItems(Array.isArray(data) ? data : [])
|
||||||
|
try {
|
||||||
|
localStorage.setItem(`${ITEMS_CACHE_KEY}_${boardId}`, JSON.stringify(data))
|
||||||
|
} catch (err) {}
|
||||||
|
} else {
|
||||||
|
setError('Ошибка загрузки товаров')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('Ошибка загрузки товаров')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
fetchingRef.current = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка из кэша
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const cached = localStorage.getItem(BOARDS_CACHE_KEY)
|
||||||
|
if (cached) {
|
||||||
|
const data = JSON.parse(cached)
|
||||||
|
if (data.boards) setBoards(data.boards)
|
||||||
|
}
|
||||||
|
} catch (err) {}
|
||||||
|
|
||||||
|
if (selectedBoardId) {
|
||||||
|
try {
|
||||||
|
const cached = localStorage.getItem(`${ITEMS_CACHE_KEY}_${selectedBoardId}`)
|
||||||
|
if (cached) {
|
||||||
|
setItems(JSON.parse(cached) || [])
|
||||||
|
}
|
||||||
|
} catch (err) {}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Начальная загрузка
|
||||||
|
useEffect(() => {
|
||||||
|
fetchBoards()
|
||||||
|
initialFetchDoneRef.current = true
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Загрузка при смене доски
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedBoardId) {
|
||||||
|
fetchItems(selectedBoardId)
|
||||||
|
} else {
|
||||||
|
setItems([])
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [selectedBoardId])
|
||||||
|
|
||||||
|
// Рефреш при возврате на таб
|
||||||
|
useEffect(() => {
|
||||||
|
if (isActive && !prevIsActiveRef.current && initialFetchDoneRef.current) {
|
||||||
|
fetchBoards()
|
||||||
|
if (selectedBoardId) fetchItems(selectedBoardId)
|
||||||
|
}
|
||||||
|
prevIsActiveRef.current = isActive
|
||||||
|
}, [isActive])
|
||||||
|
|
||||||
|
// Рефреш по триггеру
|
||||||
|
useEffect(() => {
|
||||||
|
if (refreshTrigger > 0) {
|
||||||
|
fetchBoards()
|
||||||
|
if (selectedBoardId) fetchItems(selectedBoardId)
|
||||||
|
}
|
||||||
|
}, [refreshTrigger])
|
||||||
|
|
||||||
|
// initialBoardId
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialBoardId) {
|
||||||
|
setSelectedBoardId(initialBoardId)
|
||||||
|
}
|
||||||
|
}, [initialBoardId])
|
||||||
|
|
||||||
|
// Фильтрация и группировка на клиенте
|
||||||
|
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) {
|
||||||
|
const showAt = new Date(item.next_show_at)
|
||||||
|
if (showAt > todayEnd) {
|
||||||
|
groups[groupKey].future.push(item)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
groups[groupKey].active.push(item)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Сортируем future по next_show_at ASC
|
||||||
|
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 [expandedFuture, setExpandedFuture] = useState({})
|
||||||
|
|
||||||
|
const handleBoardChange = (boardId) => {
|
||||||
|
setSelectedBoardId(boardId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBoardEdit = () => {
|
||||||
|
if (selectedBoardId) {
|
||||||
|
const board = boards.find(b => b.id === selectedBoardId)
|
||||||
|
if (board?.is_owner) {
|
||||||
|
onNavigate('shopping-board-form', { boardId: selectedBoardId })
|
||||||
|
} else {
|
||||||
|
if (window.confirm('Покинуть доску?')) {
|
||||||
|
authFetch(`/api/shopping/boards/${selectedBoardId}/leave`, { method: 'POST' })
|
||||||
|
.then(res => {
|
||||||
|
if (res.ok) {
|
||||||
|
fetchBoards()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddBoard = () => {
|
||||||
|
onNavigate('shopping-board-form')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
if (selectedBoardId) fetchItems(selectedBoardId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Модалка переноса
|
||||||
|
const handlePostponeClose = () => {
|
||||||
|
setSelectedItemForPostpone(null)
|
||||||
|
setPostponeDate('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDateSelect = (date) => {
|
||||||
|
if (date) {
|
||||||
|
setPostponeDate(formatDateToLocal(date))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDayClick = (date) => {
|
||||||
|
if (date) {
|
||||||
|
const dateStr = formatDateToLocal(date)
|
||||||
|
handlePostponeSubmitWithDate(dateStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 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]
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="shopping-list">
|
||||||
|
<BoardSelector
|
||||||
|
boards={boards}
|
||||||
|
selectedBoardId={selectedBoardId}
|
||||||
|
onBoardChange={handleBoardChange}
|
||||||
|
onBoardEdit={handleBoardEdit}
|
||||||
|
onAddBoard={handleAddBoard}
|
||||||
|
loading={boardsLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{boards.length === 0 && !boardsLoading && (
|
||||||
|
<div className="shopping-empty">
|
||||||
|
<p>Нет досок</p>
|
||||||
|
<p className="shopping-empty-hint">Создайте доску, чтобы начать добавлять товары</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedBoardId && error && (
|
||||||
|
<LoadingError onRetry={handleRefresh} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedBoardId && !error && (
|
||||||
|
<>
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && 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 => {
|
||||||
|
let dateDisplay = null
|
||||||
|
if (item.next_show_at) {
|
||||||
|
dateDisplay = formatDateForDisplay(item.next_show_at)
|
||||||
|
if (dateDisplay === 'Сегодня') dateDisplay = null
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}}
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasFuture && isFutureExpanded && (
|
||||||
|
<div className="task-group completed-tasks">
|
||||||
|
{group.future.map(item => (
|
||||||
|
<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>
|
||||||
|
<div className="task-next-show-date">
|
||||||
|
{formatDateForDisplay(item.next_show_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="task-actions">
|
||||||
|
<button
|
||||||
|
className="task-postpone-button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setSelectedItemForPostpone(item)
|
||||||
|
}}
|
||||||
|
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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Модалка выполнения */}
|
||||||
|
{selectedItemForDetail && (
|
||||||
|
<ShoppingItemDetail
|
||||||
|
itemId={selectedItemForDetail}
|
||||||
|
onClose={() => setSelectedItemForDetail(null)}
|
||||||
|
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 isToday = nextShowAtStr === todayStr
|
||||||
|
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
|
||||||
|
onClick={handleTodayClick}
|
||||||
|
className="task-postpone-quick-button"
|
||||||
|
disabled={isPostponing}
|
||||||
|
>
|
||||||
|
Сегодня
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!isTomorrow && (
|
||||||
|
<button
|
||||||
|
onClick={handleTomorrowClick}
|
||||||
|
className="task-postpone-quick-button"
|
||||||
|
disabled={isPostponing}
|
||||||
|
>
|
||||||
|
Завтра
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!isCurrentDatePlanned && (
|
||||||
|
<button
|
||||||
|
onClick={() => handlePostponeSubmitWithDate(plannedDateStr)}
|
||||||
|
className="task-postpone-quick-button"
|
||||||
|
disabled={isPostponing}
|
||||||
|
>
|
||||||
|
По плану
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{selectedItemForPostpone?.next_show_at && (
|
||||||
|
<button
|
||||||
|
onClick={handleWithoutDateClick}
|
||||||
|
className="task-postpone-quick-button"
|
||||||
|
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 ShoppingList
|
||||||
Reference in New Issue
Block a user