diff --git a/backend/migrations/20250920074825-add-project-state-column.js b/backend/migrations/20250920074825-add-project-state-column.js new file mode 100644 index 0000000..7ac7e7e --- /dev/null +++ b/backend/migrations/20250920074825-add-project-state-column.js @@ -0,0 +1,28 @@ +'use strict'; + +const { safeAddColumns } = require('../utils/migration-utils'); + +module.exports = { + up: async (queryInterface, Sequelize) => { + await safeAddColumns(queryInterface, 'projects', [ + { + name: 'state', + definition: { + type: Sequelize.ENUM( + 'idea', + 'planned', + 'in_progress', + 'blocked', + 'completed' + ), + allowNull: false, + defaultValue: 'idea', + }, + }, + ]); + }, + + down: async (queryInterface, Sequelize) => { + await queryInterface.removeColumn('projects', 'state'); + }, +}; diff --git a/backend/migrations/20250920075905-convert-active-to-states.js b/backend/migrations/20250920075905-convert-active-to-states.js new file mode 100644 index 0000000..f3bd449 --- /dev/null +++ b/backend/migrations/20250920075905-convert-active-to-states.js @@ -0,0 +1,25 @@ +'use strict'; + +module.exports = { + up: async (queryInterface, Sequelize) => { + // Update all projects: active=true -> state='in_progress', active=false -> state='idea' + await queryInterface.sequelize.query(` + UPDATE projects + SET state = CASE + WHEN active = 1 THEN 'in_progress' + ELSE 'idea' + END + `); + }, + + down: async (queryInterface, Sequelize) => { + // Reverse the conversion: state='in_progress' -> active=true, others -> active=false + await queryInterface.sequelize.query(` + UPDATE projects + SET active = CASE + WHEN state = 'in_progress' THEN 1 + ELSE 0 + END + `); + }, +}; diff --git a/backend/migrations/20250920075916-remove-active-column.js b/backend/migrations/20250920075916-remove-active-column.js new file mode 100644 index 0000000..0202b11 --- /dev/null +++ b/backend/migrations/20250920075916-remove-active-column.js @@ -0,0 +1,36 @@ +'use strict'; + +const { + safeRemoveColumn, + safeAddColumns, +} = require('../utils/migration-utils'); + +module.exports = { + up: async (queryInterface, Sequelize) => { + // Remove the active column from projects table + await safeRemoveColumn(queryInterface, 'projects', 'active'); + }, + + down: async (queryInterface, Sequelize) => { + // Add the active column back + await safeAddColumns(queryInterface, 'projects', [ + { + name: 'active', + definition: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: true, + }, + }, + ]); + + // Restore active values based on state + await queryInterface.sequelize.query(` + UPDATE projects + SET active = CASE + WHEN state = 'in_progress' THEN 1 + ELSE 0 + END + `); + }, +}; diff --git a/backend/models/project.js b/backend/models/project.js index 6a75df3..3b5d4c7 100644 --- a/backend/models/project.js +++ b/backend/models/project.js @@ -24,11 +24,6 @@ module.exports = (sequelize) => { type: DataTypes.TEXT, allowNull: true, }, - active: { - type: DataTypes.BOOLEAN, - allowNull: false, - defaultValue: true, - }, pin_to_sidebar: { type: DataTypes.BOOLEAN, allowNull: false, @@ -76,6 +71,17 @@ module.exports = (sequelize) => { allowNull: true, defaultValue: 'created_at:desc', }, + state: { + type: DataTypes.ENUM( + 'idea', + 'planned', + 'in_progress', + 'blocked', + 'completed' + ), + allowNull: false, + defaultValue: 'idea', + }, }, { tableName: 'projects', diff --git a/backend/routes/projects.js b/backend/routes/projects.js index e99cd05..065de29 100644 --- a/backend/routes/projects.js +++ b/backend/routes/projects.js @@ -137,15 +137,26 @@ router.get('/projects', async (req, res) => { return res.status(401).json({ error: 'Authentication required' }); } - const { active, pin_to_sidebar, area_id, area } = req.query; + const { state, active, pin_to_sidebar, area_id, area } = req.query; let whereClause = { user_id: req.session.userId }; - // Filter by active status + // Filter by state (new primary filter) + if (state && state !== 'all') { + if (Array.isArray(state)) { + whereClause.state = { [Op.in]: state }; + } else { + whereClause.state = state; + } + } + + // Legacy support for active filter - map to states if (active === 'true') { - whereClause.active = true; + whereClause.state = { + [Op.in]: ['planned', 'in_progress', 'blocked'], + }; } else if (active === 'false') { - whereClause.active = false; + whereClause.state = { [Op.in]: ['idea', 'completed'] }; } // Filter by pinned status @@ -384,10 +395,10 @@ router.post('/project', async (req, res) => { name, description, area_id, - active, priority, due_date_at, image_url, + state, tags, Tags, } = req.body; @@ -407,11 +418,11 @@ router.post('/project', async (req, res) => { name: name.trim(), description: description || '', area_id: area_id || null, - active: active !== undefined ? active : true, pin_to_sidebar: false, priority: priority || null, due_date_at: due_date_at || null, image_url: image_url || null, + state: state || 'idea', user_id: req.session.userId, }; @@ -463,11 +474,11 @@ router.patch('/project/:id', async (req, res) => { name, description, area_id, - active, pin_to_sidebar, priority, due_date_at, image_url, + state, tags, Tags, } = req.body; @@ -479,12 +490,12 @@ router.patch('/project/:id', async (req, res) => { if (name !== undefined) updateData.name = name; if (description !== undefined) updateData.description = description; if (area_id !== undefined) updateData.area_id = area_id; - if (active !== undefined) updateData.active = active; if (pin_to_sidebar !== undefined) updateData.pin_to_sidebar = pin_to_sidebar; if (priority !== undefined) updateData.priority = priority; if (due_date_at !== undefined) updateData.due_date_at = due_date_at; if (image_url !== undefined) updateData.image_url = image_url; + if (state !== undefined) updateData.state = state; await project.update(updateData); await updateProjectTags(project, tagsData, req.session.userId); diff --git a/backend/routes/tasks.js b/backend/routes/tasks.js index 3afec13..c0df71b 100644 --- a/backend/routes/tasks.js +++ b/backend/routes/tasks.js @@ -527,7 +527,7 @@ async function filterTasksByParams(params, userId, userTimezone) { }, { model: Project, - attributes: ['id', 'name', 'uid'], + attributes: ['id', 'name', 'state', 'uid'], required: false, }, { @@ -735,7 +735,7 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') { }, { model: Project, - attributes: ['id', 'name', 'active', 'uid'], + attributes: ['id', 'name', 'state', 'uid'], required: false, }, { @@ -780,7 +780,7 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') { }, { model: Project, - attributes: ['id', 'name', 'active', 'uid'], + attributes: ['id', 'name', 'state', 'uid'], required: false, }, { @@ -838,7 +838,7 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') { }, { model: Project, - attributes: ['id', 'name', 'active', 'uid'], + attributes: ['id', 'name', 'state', 'uid'], required: false, }, { @@ -907,7 +907,7 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') { }, { model: Project, - attributes: ['id', 'name', 'active', 'uid'], + attributes: ['id', 'name', 'uid'], required: false, }, { @@ -952,7 +952,7 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') { }, { model: Project, - attributes: ['id', 'name', 'active', 'uid'], + attributes: ['id', 'name', 'uid'], required: false, }, { @@ -1008,7 +1008,7 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') { }, { model: Project, - attributes: ['id', 'name', 'active', 'uid'], + attributes: ['id', 'name', 'uid'], required: false, }, { @@ -1062,7 +1062,7 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') { }, { model: Project, - attributes: ['id', 'name', 'active', 'uid'], + attributes: ['id', 'name', 'state', 'uid'], required: false, }, { diff --git a/backend/seeders/dev-seeder.js b/backend/seeders/dev-seeder.js index 47506e7..0b9f4d0 100644 --- a/backend/seeders/dev-seeder.js +++ b/backend/seeders/dev-seeder.js @@ -65,7 +65,7 @@ async function seedDatabase() { description: 'Complete overhaul of company website', user_id: testUser.id, area_id: areas[1].id, - active: true, + state: 'in_progress', due_date_at: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days from now }, { @@ -73,14 +73,14 @@ async function seedDatabase() { description: 'Master mobile app development', user_id: testUser.id, area_id: areas[3].id, - active: true, + state: 'in_progress', }, { name: 'Home Renovation', description: 'Kitchen and bathroom updates', user_id: testUser.id, area_id: areas[4].id, - active: true, + state: 'in_progress', due_date_at: new Date(Date.now() + 60 * 24 * 60 * 60 * 1000), // 60 days from now }, { @@ -88,7 +88,7 @@ async function seedDatabase() { description: '90-day fitness transformation', user_id: testUser.id, area_id: areas[2].id, - active: true, + state: 'in_progress', due_date_at: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), // 90 days from now }, { @@ -96,14 +96,14 @@ async function seedDatabase() { description: 'Launch online consulting service', user_id: testUser.id, area_id: areas[1].id, - active: true, + state: 'in_progress', }, { name: 'Investment Portfolio', description: 'Build diversified investment portfolio', user_id: testUser.id, area_id: areas[5].id, - active: true, + state: 'in_progress', due_date_at: new Date(Date.now() + 120 * 24 * 60 * 60 * 1000), // 120 days from now }, { @@ -111,7 +111,7 @@ async function seedDatabase() { description: 'Plan and execute 3-week European vacation', user_id: testUser.id, area_id: areas[6].id, - active: true, + state: 'in_progress', due_date_at: new Date(Date.now() + 180 * 24 * 60 * 60 * 1000), // 180 days from now }, { @@ -119,14 +119,14 @@ async function seedDatabase() { description: 'Learn advanced photography techniques', user_id: testUser.id, area_id: areas[7].id, - active: true, + state: 'in_progress', }, { name: 'Professional Certification', description: 'Get AWS Solutions Architect certification', user_id: testUser.id, area_id: areas[9].id, - active: true, + state: 'in_progress', due_date_at: new Date(Date.now() + 150 * 24 * 60 * 60 * 1000), // 150 days from now }, { @@ -134,7 +134,7 @@ async function seedDatabase() { description: 'Transform backyard into productive garden', user_id: testUser.id, area_id: areas[4].id, - active: true, + state: 'in_progress', due_date_at: new Date(Date.now() + 45 * 24 * 60 * 60 * 1000), // 45 days from now }, { @@ -142,21 +142,21 @@ async function seedDatabase() { description: 'Start personal tech blog', user_id: testUser.id, area_id: areas[0].id, - active: true, + state: 'in_progress', }, { name: 'Language Learning Spanish', description: 'Become conversational in Spanish', user_id: testUser.id, area_id: areas[3].id, - active: false, // Paused project + state: 'blocked', // Paused project }, { name: 'Wedding Planning', description: 'Plan and organize wedding ceremony', user_id: testUser.id, area_id: areas[8].id, - active: true, + state: 'in_progress', due_date_at: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), // 1 year from now }, { @@ -164,14 +164,14 @@ async function seedDatabase() { description: 'Establish weekly meal preparation routine', user_id: testUser.id, area_id: areas[2].id, - active: true, + state: 'in_progress', }, { name: 'Smart Home Setup', description: 'Install and configure smart home devices', user_id: testUser.id, area_id: areas[4].id, - active: true, + state: 'in_progress', due_date_at: new Date(Date.now() + 21 * 24 * 60 * 60 * 1000), // 21 days from now }, ]); diff --git a/backend/tests/integration/projects.test.js b/backend/tests/integration/projects.test.js index 8b26513..43b3308 100644 --- a/backend/tests/integration/projects.test.js +++ b/backend/tests/integration/projects.test.js @@ -29,7 +29,7 @@ describe('Projects Routes', () => { const projectData = { name: 'Test Project', description: 'Test Description', - active: true, + state: 'planned', pin_to_sidebar: false, priority: 1, area_id: area.id, @@ -40,7 +40,7 @@ describe('Projects Routes', () => { expect(response.status).toBe(201); expect(response.body.name).toBe(projectData.name); expect(response.body.description).toBe(projectData.description); - expect(response.body.active).toBe(projectData.active); + expect(response.body.state).toBe(projectData.state); expect(response.body.pin_to_sidebar).toBe( projectData.pin_to_sidebar ); @@ -193,7 +193,7 @@ describe('Projects Routes', () => { project = await Project.create({ name: 'Test Project', description: 'Test Description', - active: false, + state: 'idea', priority: 0, user_id: user.id, }); @@ -203,7 +203,7 @@ describe('Projects Routes', () => { const updateData = { name: 'Updated Project', description: 'Updated Description', - active: true, + state: 'in_progress', priority: 2, }; @@ -214,7 +214,7 @@ describe('Projects Routes', () => { expect(response.status).toBe(200); expect(response.body.name).toBe(updateData.name); expect(response.body.description).toBe(updateData.description); - expect(response.body.active).toBe(updateData.active); + expect(response.body.state).toBe(updateData.state); expect(response.body.priority).toBe(updateData.priority); }); diff --git a/backend/tests/unit/models/project.test.js b/backend/tests/unit/models/project.test.js index 4b15394..0964df8 100644 --- a/backend/tests/unit/models/project.test.js +++ b/backend/tests/unit/models/project.test.js @@ -21,7 +21,7 @@ describe('Project Model', () => { const projectData = { name: 'Test Project', description: 'Test Description', - active: true, + state: 'planned', pin_to_sidebar: false, priority: 1, user_id: user.id, @@ -32,7 +32,7 @@ describe('Project Model', () => { expect(project.name).toBe(projectData.name); expect(project.description).toBe(projectData.description); - expect(project.active).toBe(projectData.active); + expect(project.state).toBe(projectData.state); expect(project.pin_to_sidebar).toBe(projectData.pin_to_sidebar); expect(project.priority).toBe(projectData.priority); expect(project.user_id).toBe(user.id); @@ -85,7 +85,7 @@ describe('Project Model', () => { user_id: user.id, }); - expect(project.active).toBe(true); + expect(project.state).toBe('idea'); expect(project.pin_to_sidebar).toBe(false); expect(project.task_show_completed).toBe(false); expect(project.task_sort_order).toBe('created_at:desc'); diff --git a/backend/utils/migration-utils.js b/backend/utils/migration-utils.js index cb580d6..fd6b6d7 100644 --- a/backend/utils/migration-utils.js +++ b/backend/utils/migration-utils.js @@ -55,8 +55,25 @@ async function safeAddIndex(queryInterface, tableName, fields, options = {}) { } } +async function safeRemoveColumn(queryInterface, tableName, columnName) { + try { + const tableInfo = await queryInterface.describeTable(tableName); + + if (columnName in tableInfo) { + await queryInterface.removeColumn(tableName, columnName); + } + } catch (error) { + console.log( + `Migration error removing column ${columnName} from ${tableName}:`, + error.message + ); + throw error; + } +} + module.exports = { safeAddColumns, safeCreateTable, safeAddIndex, + safeRemoveColumn, }; diff --git a/frontend/Layout.tsx b/frontend/Layout.tsx index 293eca0..366cbdf 100644 --- a/frontend/Layout.tsx +++ b/frontend/Layout.tsx @@ -223,7 +223,7 @@ const Layout: React.FC = ({ try { const newProject = await createProject({ name, - active: true, + state: 'planned', }); return newProject; } catch (error) { diff --git a/frontend/components/Inbox/InboxItemDetail.tsx b/frontend/components/Inbox/InboxItemDetail.tsx index 99ec29f..98e1033 100644 --- a/frontend/components/Inbox/InboxItemDetail.tsx +++ b/frontend/components/Inbox/InboxItemDetail.tsx @@ -322,7 +322,7 @@ const InboxItemDetail: React.FC = ({ const newProject: Project = { name: cleanedContent || item.content, description: '', - active: true, + state: 'planned', tags: projectTags, }; diff --git a/frontend/components/Inbox/InboxItems.tsx b/frontend/components/Inbox/InboxItems.tsx index ad66112..ce8aa6e 100644 --- a/frontend/components/Inbox/InboxItems.tsx +++ b/frontend/components/Inbox/InboxItems.tsx @@ -395,7 +395,7 @@ const InboxItems: React.FC = () => { const handleCreateProject = async (name: string): Promise => { try { - const project = await createProject({ name, active: true }); + const project = await createProject({ name, state: 'planned' }); showSuccessToast(t('project.createSuccess')); return project; } catch (error) { diff --git a/frontend/components/Inbox/InboxModal.tsx b/frontend/components/Inbox/InboxModal.tsx index 6cc9bcc..3155951 100644 --- a/frontend/components/Inbox/InboxModal.tsx +++ b/frontend/components/Inbox/InboxModal.tsx @@ -926,7 +926,7 @@ const InboxModal: React.FC = ({ try { await createProject({ name: projectName, - active: true, + state: 'planned', }); // Projects are managed by the parent component through props // No need to update local state diff --git a/frontend/components/Productivity/ProductivityAssistant.tsx b/frontend/components/Productivity/ProductivityAssistant.tsx index 03cf508..3a1497e 100644 --- a/frontend/components/Productivity/ProductivityAssistant.tsx +++ b/frontend/components/Productivity/ProductivityAssistant.tsx @@ -87,7 +87,8 @@ const ProductivityAssistant: React.FC = ({ // 1. Stalled Projects (no tasks/actions) const stalledProjects = projects.filter( (project) => - project.active && + (project.state === 'planned' || + project.state === 'in_progress') && !activeTasks.some((task) => task.project_id === project.id) ); @@ -123,7 +124,12 @@ const ProductivityAssistant: React.FC = ({ (task.status === 'not_started' || task.status === 'in_progress') ); - return project.active && hasCompletedTasks && !hasNextAction; + return ( + (project.state === 'planned' || + project.state === 'in_progress') && + hasCompletedTasks && + !hasNextAction + ); }); if (projectsNeedingNextAction.length > 0) { @@ -221,7 +227,13 @@ const ProductivityAssistant: React.FC = ({ // 6. Stuck projects (not updated in a month) const stuckProjects = projects.filter((project) => { - if (!project.active) return false; + if ( + !( + project.state === 'planned' || + project.state === 'in_progress' + ) + ) + return false; // Projects don't have date fields in the interface, so we'll check if they have recent tasks const projectTasks = activeTasks.filter( @@ -345,7 +357,7 @@ const ProductivityAssistant: React.FC = ({ const handleCreateProject = async (name: string): Promise => { try { - const project = await createProject({ name, active: true }); + const project = await createProject({ name, state: 'planned' }); setAllProjects((prev) => [...prev, project]); return project; } catch (error) { diff --git a/frontend/components/Project/ProjectDetails.tsx b/frontend/components/Project/ProjectDetails.tsx index 46a4e2b..7c267ed 100644 --- a/frontend/components/Project/ProjectDetails.tsx +++ b/frontend/components/Project/ProjectDetails.tsx @@ -7,6 +7,12 @@ import { TrashIcon, TagIcon, PlusCircleIcon, + Squares2X2Icon, + PlayIcon, + LightBulbIcon, + ClipboardDocumentListIcon, + ExclamationTriangleIcon, + CheckCircleIcon, } from '@heroicons/react/24/outline'; import TaskList from '../Task/TaskList'; import ProjectModal from '../Project/ProjectModal'; @@ -39,6 +45,7 @@ import { getAutoSuggestNextActionsEnabled } from '../../utils/profileService'; import AutoSuggestNextActionBox from './AutoSuggestNextActionBox'; import SortFilterButton, { SortOption } from '../Shared/SortFilterButton'; import LoadingSpinner from '../Shared/LoadingSpinner'; +import { usePersistedModal } from '../../hooks/usePersistedModal'; const ProjectDetails: React.FC = () => { const { uidSlug } = useParams<{ uidSlug: string }>(); @@ -64,7 +71,14 @@ const ProjectDetails: React.FC = () => { const [notes, setNotes] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(false); - const [isModalOpen, setIsModalOpen] = useState(false); + // Use persisted modal state that survives component remounts + const { + isOpen: isModalOpen, + openModal, + closeModal, + } = usePersistedModal(project?.id); + const editButtonRef = useRef(null); + const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false); const [showCompleted, setShowCompleted] = useState(false); const [showAutoSuggestForm, setShowAutoSuggestForm] = useState(false); @@ -376,9 +390,22 @@ const ProjectDetails: React.FC = () => { } }; - const handleEditProject = () => { - setIsModalOpen(true); - }; + // Setup native event listener for edit button to avoid React event system conflicts + useEffect(() => { + const button = editButtonRef.current; + if (button) { + const handleClick = (e: Event) => { + e.preventDefault(); + e.stopPropagation(); + openModal(); + }; + + button.addEventListener('click', handleClick); + return () => { + button.removeEventListener('click', handleClick); + }; + } + }, [openModal]); const handleSaveProject = async (updatedProject: Project) => { if (!updatedProject.id) { @@ -390,8 +417,14 @@ const ProjectDetails: React.FC = () => { updatedProject.id, updatedProject ); - setProject(savedProject); - setIsModalOpen(false); + // Merge the saved project with existing project to preserve area data + setProject((prevProject) => ({ + ...savedProject, + // Preserve area info if it's missing from the response + area: savedProject.area || prevProject?.area, + Area: (savedProject as any).Area || (prevProject as any)?.Area, + })); + closeModal(); } catch { // Error saving project - silently handled } @@ -635,6 +668,36 @@ const ProjectDetails: React.FC = () => { return sortedTasks; }, [tasks, showCompleted, orderBy]); + // Function to get the appropriate icon for project state + const getStateIcon = (state: string) => { + switch (state) { + case 'idea': + return ( + + ); + case 'planned': + return ( + + ); + case 'in_progress': + return ( + + ); + case 'blocked': + return ( + + ); + case 'completed': + return ( + + ); + default: + return ( + + ); + } + }; + if (loading) { return ; } @@ -687,9 +750,26 @@ const ProjectDetails: React.FC = () => { - {/* Tags Display - Bottom Left */} - {project.tags && project.tags.length > 0 && ( -
+ {/* State, Tags and Area Display - Bottom Left */} +
+ {/* Project State Display */} + {project.state && ( +
+ {getStateIcon(project.state)} +
+ + + {t( + `projects.states.${project.state}` + )} + + +
+
+ )} + + {/* Tags Display */} + {project.tags && project.tags.length > 0 && (
@@ -733,22 +813,75 @@ const ProjectDetails: React.FC = () => { ))}
-
- )} + )} + + {/* Area Display */} + {(project.area || (project as any).Area) && ( +
+ +
+ + + +
+
+ )} +
{/* Edit/Delete Buttons - Bottom Right */}
)} diff --git a/frontend/components/Project/ProjectModal.tsx b/frontend/components/Project/ProjectModal.tsx index ca72821..191c6e5 100644 --- a/frontend/components/Project/ProjectModal.tsx +++ b/frontend/components/Project/ProjectModal.tsx @@ -8,8 +8,8 @@ import TagInput from '../Tag/TagInput'; import PriorityDropdown from '../Shared/PriorityDropdown'; import AreaDropdown from '../Shared/AreaDropdown'; import DatePicker from '../Shared/DatePicker'; +import ProjectStateDropdown from '../Shared/ProjectStateDropdown'; import { PriorityType } from '../../entities/Task'; -import Switch from '../Shared/Switch'; import { useStore } from '../../store/useStore'; import { useTranslation } from 'react-i18next'; import { @@ -19,7 +19,7 @@ import { CameraIcon, CalendarIcon, ExclamationTriangleIcon, - PowerIcon, + PlayIcon, } from '@heroicons/react/24/outline'; interface ProjectModalProps { @@ -45,7 +45,7 @@ const ProjectModal: React.FC = ({ name: '', description: '', area_id: null, - active: true, + state: 'idea', tags: [], priority: 'low', due_date_at: null, @@ -76,35 +76,32 @@ const ProjectModal: React.FC = ({ // Collapsible sections state const [expandedSections, setExpandedSections] = useState({ + state: false, tags: false, area: false, image: false, priority: false, dueDate: false, - active: false, }); const { showSuccessToast, showErrorToast } = useToast(); const { t } = useTranslation(); - // Load tags when modal opens and auto-focus on the name input + // Auto-focus on the name input when modal opens useEffect(() => { if (isOpen) { - // Load tags with a delay to avoid conflicts with modal state - if (!tagsStore.hasLoaded && !tagsStore.isLoading) { - // Delay tag loading to avoid immediate state conflicts - setTimeout(() => { - if (isOpen) { - // Only load if modal is still open - tagsStore.loadTags(); - } - }, 300); - } setTimeout(() => { nameInputRef.current?.focus(); }, 200); } - }, [isOpen, tagsStore]); + }, [isOpen]); + + // Load tags only when user actually interacts with tag input to prevent refresh + const handleTagInputFocus = () => { + if (!tagsStore.hasLoaded && !tagsStore.isLoading) { + tagsStore.loadTags(); + } + }; // Manage body scroll when modal is open useEffect(() => { @@ -139,7 +136,7 @@ const ProjectModal: React.FC = ({ name: '', description: '', area_id: null, - active: true, + state: 'idea', tags: [], priority: 'low', due_date_at: null, @@ -176,13 +173,13 @@ const ProjectModal: React.FC = ({ handleClose(); }; - if (isOpen) { + if (isOpen && !modalJustOpened) { document.addEventListener('mousedown', handleClickOutside); } return () => { document.removeEventListener('mousedown', handleClickOutside); }; - }, [isOpen]); + }, [isOpen, modalJustOpened]); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { @@ -390,13 +387,6 @@ const ProjectModal: React.FC = ({ }, 300); }; - const handleToggleActive = () => { - setFormData((prev) => ({ - ...prev, - active: !prev.active, - })); - }; - const toggleSection = useCallback( (section: keyof typeof expandedSections) => { setExpandedSections((prev) => { @@ -445,7 +435,6 @@ const ProjectModal: React.FC = ({ return createPortal( <> - ,
= ({
{/* Expandable Sections - Only show when expanded */} - {/* Active Status Section - First */} - {expandedSections.active && ( + {/* State Section - First */} + {expandedSections.state && (

{t( - 'projects.active', - 'Status' + 'projects.state', + 'Project State' )}

-
- - -
+ + setFormData( + (prev) => ({ + ...prev, + state, + }) + ) + } + />
)} @@ -576,6 +560,9 @@ const ProjectModal: React.FC = ({ availableTags={ availableTags } + onFocus={ + handleTagInputFocus + } /> )} @@ -741,26 +728,27 @@ const ProjectModal: React.FC = ({
{/* Left side: Section icons */}
- {/* Active Status Toggle - First */} + {/* State Toggle - First */} {/* Tags Toggle */} diff --git a/frontend/components/Projects.tsx b/frontend/components/Projects.tsx index 0f52295..d50aab5 100644 --- a/frontend/components/Projects.tsx +++ b/frontend/components/Projects.tsx @@ -59,7 +59,7 @@ const Projects: React.FC = () => { const [orderBy, setOrderBy] = useState('created_at:desc'); const [searchParams, setSearchParams] = useSearchParams(); - const activeFilter = searchParams.get('active') || 'all'; + const stateFilter = searchParams.get('state') || 'all'; // Handle both 'area_id' and 'area' parameters from URL const getAreaIdFromParams = () => { @@ -92,8 +92,17 @@ const Projects: React.FC = () => { // Filter options for dropdowns const statusOptions: FilterOption[] = [ { value: 'all', label: t('projects.filters.all') }, - { value: 'true', label: t('projects.filters.active') }, - { value: 'false', label: t('projects.filters.inactive') }, + { value: 'idea', label: t('projects.states.idea', 'Idea') }, + { value: 'planned', label: t('projects.states.planned', 'Planned') }, + { + value: 'in_progress', + label: t('projects.states.in_progress', 'In Progress'), + }, + { value: 'blocked', label: t('projects.states.blocked', 'Blocked') }, + { + value: 'completed', + label: t('projects.states.completed', 'Completed'), + }, ]; const areaOptions: FilterOption[] = [ @@ -218,13 +227,13 @@ const Projects: React.FC = () => { return (project as any).completion_percentage || 0; }; - const handleActiveFilterChange = (value: string) => { + const handleStateFilterChange = (value: string) => { const params = new URLSearchParams(searchParams); if (value === 'all') { - params.delete('active'); + params.delete('state'); } else { - params.set('active', value); + params.set('state', value); } setSearchParams(params); }; @@ -252,11 +261,10 @@ const Projects: React.FC = () => { const displayProjects = useMemo(() => { let filteredProjects = [...projects]; - // Apply active filter - if (activeFilter !== 'all') { - const isActive = activeFilter === 'true'; + // Apply state filter + if (stateFilter !== 'all') { filteredProjects = filteredProjects.filter( - (project) => project.active === isActive + (project) => project.state === stateFilter ); } @@ -327,7 +335,7 @@ const Projects: React.FC = () => { }); return filteredProjects; - }, [projects, activeFilter, actualAreaFilter, searchQuery, orderBy]); + }, [projects, stateFilter, actualAreaFilter, searchQuery, orderBy]); if (isLoading) { return ( @@ -406,8 +414,8 @@ const Projects: React.FC = () => {
diff --git a/frontend/components/Shared/ProjectStateDropdown.tsx b/frontend/components/Shared/ProjectStateDropdown.tsx new file mode 100644 index 0000000..0c4318f --- /dev/null +++ b/frontend/components/Shared/ProjectStateDropdown.tsx @@ -0,0 +1,223 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { + ChevronDownIcon, + LightBulbIcon, + ClipboardDocumentListIcon, + PlayIcon, + ExclamationTriangleIcon, + CheckCircleIcon, +} from '@heroicons/react/24/outline'; +import { ProjectState } from '../../entities/Project'; +import { useTranslation } from 'react-i18next'; + +interface ProjectStateDropdownProps { + value: ProjectState; + onChange: (value: ProjectState) => void; +} + +const ProjectStateDropdown: React.FC = ({ + value, + onChange, +}) => { + const { t } = useTranslation(); + + const states = [ + { + value: 'idea' as ProjectState, + label: t('projects.states.idea', 'Idea'), + description: t( + 'projects.states.idea_desc', + 'captured but not planned yet' + ), + icon: , + }, + { + value: 'planned' as ProjectState, + label: t('projects.states.planned', 'Planned'), + description: t( + 'projects.states.planned_desc', + 'scoped and ready to start' + ), + icon: ( + + ), + }, + { + value: 'in_progress' as ProjectState, + label: t('projects.states.in_progress', 'In Progress'), + description: t( + 'projects.states.in_progress_desc', + 'active work happening' + ), + icon: , + }, + { + value: 'blocked' as ProjectState, + label: t('projects.states.blocked', 'Blocked'), + description: t( + 'projects.states.blocked_desc', + 'temporarily paused or stuck' + ), + icon: , + }, + { + value: 'completed' as ProjectState, + label: t('projects.states.completed', 'Completed'), + description: t( + 'projects.states.completed_desc', + 'finished and done' + ), + icon: , + }, + ]; + + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + const handleToggle = () => { + setIsOpen(!isOpen); + + // Scroll dropdown into view when opening to ensure options are visible + if (!isOpen && dropdownRef.current) { + setTimeout(() => { + // Find the dropdown options container + const dropdownOptions = + dropdownRef.current?.querySelector('.absolute.z-10'); + if (dropdownOptions) { + dropdownOptions.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + inline: 'nearest', + }); + } else { + // Fallback to scrolling the dropdown container itself + dropdownRef.current?.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + inline: 'nearest', + }); + } + }, 150); // Increased timeout to ensure dropdown is rendered + } + }; + + const handleClickOutside = (event: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + }; + + const handleSelect = (state: ProjectState) => { + onChange(state); + setIsOpen(false); + }; + + useEffect(() => { + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + + // Ensure dropdown is visible after opening + setTimeout(() => { + const dropdownOptions = + dropdownRef.current?.querySelector('.absolute.z-10'); + if (dropdownOptions) { + // Try to scroll the parent modal container to show the dropdown + const modalScrollContainer = + document.querySelector( + '.absolute.inset-0.overflow-y-auto' + ) || + document.querySelector('[style*="overflow-y"]') || + document.querySelector('.overflow-y-auto'); + + if (modalScrollContainer) { + const rect = dropdownOptions.getBoundingClientRect(); + const containerRect = + modalScrollContainer.getBoundingClientRect(); + + // Check if dropdown is below visible area + if (rect.bottom > containerRect.bottom) { + modalScrollContainer.scrollTo({ + top: + modalScrollContainer.scrollTop + + (rect.bottom - containerRect.bottom) + + 20, + behavior: 'smooth', + }); + } + } else { + // Fallback to scrollIntoView + dropdownOptions.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + inline: 'nearest', + }); + } + } + }, 200); + } else { + document.removeEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isOpen]); + + const selectedState = states.find((s) => s.value === value); + + return ( +
+ + + {isOpen && ( +
+ {states.map((state) => ( + + ))} +
+ )} +
+ ); +}; + +export default ProjectStateDropdown; diff --git a/frontend/components/Sidebar/SidebarProjects.tsx b/frontend/components/Sidebar/SidebarProjects.tsx index 5abea68..2748656 100644 --- a/frontend/components/Sidebar/SidebarProjects.tsx +++ b/frontend/components/Sidebar/SidebarProjects.tsx @@ -31,7 +31,7 @@ const SidebarProjects: React.FC = ({ )}`} onClick={() => handleNavClick( - '/projects?active=true', + '/projects', 'Projects', ) diff --git a/frontend/components/Tag/TagInput.tsx b/frontend/components/Tag/TagInput.tsx index 5b529dd..8109f91 100644 --- a/frontend/components/Tag/TagInput.tsx +++ b/frontend/components/Tag/TagInput.tsx @@ -6,12 +6,14 @@ interface TagInputProps { initialTags: string[]; onTagsChange: (tags: string[]) => void; availableTags: Tag[]; + onFocus?: () => void; } const TagInput: React.FC = ({ initialTags, onTagsChange, availableTags, + onFocus, }) => { const { t } = useTranslation(); const [inputValue, setInputValue] = useState(''); @@ -229,6 +231,7 @@ const TagInput: React.FC = ({ placeholder={t('tags.typeToAdd')} className="flex-grow bg-transparent border-none outline-none text-sm text-gray-900 dark:text-gray-100" onFocus={() => { + onFocus?.(); if (filteredTags.length > 0) setIsDropdownOpen(true); }} style={{ minWidth: '150px' }} @@ -248,7 +251,7 @@ const TagInput: React.FC = ({ > {filteredTags.map((tag, index) => (