diff --git a/worklenz-frontend/src/app/store.ts b/worklenz-frontend/src/app/store.ts index 6bf7adcf..8fa3cadc 100644 --- a/worklenz-frontend/src/app/store.ts +++ b/worklenz-frontend/src/app/store.ts @@ -76,6 +76,8 @@ import groupByFilterDropdownReducer from '../features/group-by-filter-dropdown/g import homePageApiService from '@/api/home-page/home-page.api.service'; import { projectsApi } from '@/api/projects/projects.v1.api.service'; +import projectViewReducer from '@features/project/project-view-slice'; + export const store = configureStore({ middleware: getDefaultMiddleware => getDefaultMiddleware({ @@ -113,6 +115,8 @@ export const store = configureStore({ taskListCustomColumnsReducer: taskListCustomColumnsReducer, boardReducer: boardReducer, projectDrawerReducer: projectDrawerReducer, + + projectViewReducer: projectViewReducer, // Project Lookups projectCategoriesReducer: projectCategoriesReducer, diff --git a/worklenz-frontend/src/components/project-list/project-group/project-group-list.tsx b/worklenz-frontend/src/components/project-list/project-group/project-group-list.tsx index 66e03007..207f12ae 100644 --- a/worklenz-frontend/src/components/project-list/project-group/project-group-list.tsx +++ b/worklenz-frontend/src/components/project-list/project-group/project-group-list.tsx @@ -33,9 +33,6 @@ const ProjectGroupList: React.FC = ({ )} {group.groupName} - <Text type="secondary" className="group-stats"> - ({group.count} projects • {group.averageProgress}% avg • {group.totalTasks} tasks) - </Text> diff --git a/worklenz-frontend/src/features/project/project-view-slice.ts b/worklenz-frontend/src/features/project/project-view-slice.ts new file mode 100644 index 00000000..709d5e25 --- /dev/null +++ b/worklenz-frontend/src/features/project/project-view-slice.ts @@ -0,0 +1,47 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { ProjectGroupBy, ProjectViewType } from '@/types/project/project.types'; + +interface ProjectViewState { + mode: ProjectViewType; + groupBy: ProjectGroupBy; + lastUpdated?: string; +} + +const LOCAL_STORAGE_KEY = 'project_view_preferences'; + +const loadInitialState = (): ProjectViewState => { + const saved = localStorage.getItem(LOCAL_STORAGE_KEY); + return saved + ? JSON.parse(saved) + : { + mode: ProjectViewType.LIST, + groupBy: ProjectGroupBy.CATEGORY, + lastUpdated: new Date().toISOString() + }; +}; + +const initialState: ProjectViewState = loadInitialState(); + +export const projectViewSlice = createSlice({ + name: 'projectView', + initialState, + reducers: { + setViewMode: (state, action: PayloadAction) => { + state.mode = action.payload; + state.lastUpdated = new Date().toISOString(); + localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(state)); + }, + setGroupBy: (state, action: PayloadAction) => { + state.groupBy = action.payload; + state.lastUpdated = new Date().toISOString(); + localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(state)); + }, + resetViewState: () => { + localStorage.removeItem(LOCAL_STORAGE_KEY); + return loadInitialState(); + } + } +}); + +export const { setViewMode, setGroupBy, resetViewState } = projectViewSlice.actions; +export default projectViewSlice.reducer; \ No newline at end of file diff --git a/worklenz-frontend/src/pages/projects/project-list.tsx b/worklenz-frontend/src/pages/projects/project-list.tsx index 359cccf5..4b9a6fe9 100644 --- a/worklenz-frontend/src/pages/projects/project-list.tsx +++ b/worklenz-frontend/src/pages/projects/project-list.tsx @@ -1,8 +1,8 @@ -// Updated project-list.tsx import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; - +import { ProjectViewType, ProjectGroupBy } from '@/types/project/project.types'; +import { setViewMode, setGroupBy } from '@features/project/project-view-slice'; import { Button, Card, @@ -10,11 +10,11 @@ import { Flex, Input, Segmented, + Select, Skeleton, Table, TablePaginationConfig, Tooltip, - Select, } from 'antd'; import { PageHeader } from '@ant-design/pro-components'; import { @@ -38,7 +38,7 @@ import { PROJECT_SORT_FIELD, PROJECT_SORT_ORDER, } from '@/shared/constants'; -import { IProjectFilter, ProjectGroupBy } from '@/types/project/project.types'; +import { IProjectFilter } from '@/types/project/project.types'; import { IProjectViewModel } from '@/types/project/projectViewModel.types'; import { useDocumentTitle } from '@/hooks/useDoumentTItle'; @@ -64,32 +64,26 @@ import { } from '@/shared/worklenz-analytics-events'; import { useMixpanelTracking } from '@/hooks/useMixpanelTracking'; import ProjectGroupList from '@/components/project-list/project-group/project-group-list'; -import { groupProjectsByCategory, groupProjectsByClient } from '@/utils/project-group'; +import { groupProjects } from '@/utils/project-group'; const ProjectList: React.FC = () => { - // All hooks must be called at the top level, in the same order every time + const [filteredInfo, setFilteredInfo] = useState>({}); + const [isLoading, setIsLoading] = useState(false); + const { t } = useTranslation('all-project-list'); const dispatch = useAppDispatch(); const navigate = useNavigate(); + useDocumentTitle('Projects'); const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin(); const { trackMixpanelEvent } = useMixpanelTracking(); - - // State hooks - const [filteredInfo, setFilteredInfo] = useState>({}); - const [isLoading, setIsLoading] = useState(false); - const [viewMode, setViewMode] = useState<'list' | 'group'>('list'); - const [groupBy, setGroupBy] = useState(ProjectGroupBy.CATEGORY); - - // Custom hooks - useDocumentTitle('Projects'); - // Selector hooks + // Get view state from Redux + const { mode: viewMode, groupBy } = useAppSelector((state) => state.projectViewReducer); const { requestParams } = useAppSelector(state => state.projectsReducer); const { projectStatuses } = useAppSelector(state => state.projectStatusesReducer); const { projectHealths } = useAppSelector(state => state.projectHealthReducer); const { projectCategories } = useAppSelector(state => state.projectCategoriesReducer); - // Query hooks const { data: projectsData, isLoading: loadingProjects, @@ -97,7 +91,6 @@ const ProjectList: React.FC = () => { refetch: refetchProjects, } = useGetProjectsQuery(requestParams); - // Callback hooks const getFilterIndex = useCallback(() => { return +(localStorage.getItem(FILTER_INDEX_KEY) || 0); }, []); @@ -111,10 +104,8 @@ const ProjectList: React.FC = () => { localStorage.setItem(PROJECT_SORT_ORDER, order); }, []); - // Memoized values const filters = useMemo(() => Object.values(IProjectFilter), []); - // Create translated segment options for the filters const segmentOptions = useMemo(() => { return filters.map(filter => ({ value: filter, @@ -122,55 +113,48 @@ const ProjectList: React.FC = () => { })); }, [filters, t]); - // Toggle options for List/Group view const viewToggleOptions = useMemo( () => [ { - value: 'list' as const, + value: ProjectViewType.LIST, label: ( -
- - List -
+ +
+ + {t('list')} +
+
), }, { - value: 'group' as const, + value: ProjectViewType.GROUP, label: ( -
- - Group -
+ +
+ + {t('group')} +
+
), }, ], - [] + [t] ); - // Group by options const groupByOptions = useMemo( () => [ - { value: ProjectGroupBy.CATEGORY, label: 'Category' }, - { value: ProjectGroupBy.CLIENT, label: 'Client' }, + { + value: ProjectGroupBy.CATEGORY, + label: t('groupBy.category'), + }, + { + value: ProjectGroupBy.CLIENT, + label: t('groupBy.client'), + }, ], - [] + [t] ); - // Get grouped projects based on current groupBy selection - const groupedProjects = useMemo(() => { - const projects = projectsData?.body?.data || []; - if (viewMode !== 'group') return []; - - switch (groupBy) { - case ProjectGroupBy.CATEGORY: - return groupProjectsByCategory(projects); - case ProjectGroupBy.CLIENT: - return groupProjectsByClient(projects); - default: - return groupProjectsByCategory(projects); - } - }, [projectsData?.body?.data, viewMode, groupBy]); - const paginationConfig = useMemo( () => ({ current: requestParams.index, @@ -184,28 +168,6 @@ const ProjectList: React.FC = () => { [requestParams.index, requestParams.size, projectsData?.body?.total] ); - // Effect hooks - useEffect(() => { - setIsLoading(loadingProjects || isFetchingProjects); - }, [loadingProjects, isFetchingProjects]); - - useEffect(() => { - const filterIndex = getFilterIndex(); - dispatch(setRequestParams({ filter: filterIndex })); - }, [dispatch, getFilterIndex]); - - useEffect(() => { - trackMixpanelEvent(evt_projects_page_visit); - refetchProjects(); - }, [requestParams, refetchProjects, trackMixpanelEvent]); - - useEffect(() => { - if (projectStatuses.length === 0) dispatch(fetchProjectStatuses()); - if (projectCategories.length === 0) dispatch(fetchProjectCategories()); - if (projectHealths.length === 0) dispatch(fetchProjectHealth()); - }, [dispatch, projectStatuses.length, projectCategories.length, projectHealths.length]); - - // Event handlers const handleTableChange = useCallback( ( newPagination: TablePaginationConfig, @@ -242,13 +204,13 @@ const ProjectList: React.FC = () => { dispatch(setRequestParams(newParams)); setFilteredInfo(filters); }, - [dispatch, setSortingValues, requestParams] + [dispatch, setSortingValues] ); const handleRefresh = useCallback(() => { trackMixpanelEvent(evt_projects_refresh_click); refetchProjects(); - }, [refetchProjects, trackMixpanelEvent]); + }, [trackMixpanelEvent, refetchProjects]); const handleSegmentChange = useCallback( (value: IProjectFilter) => { @@ -264,15 +226,15 @@ const ProjectList: React.FC = () => { trackMixpanelEvent(evt_projects_search); const value = e.target.value; dispatch(setRequestParams({ search: value })); - }, [dispatch, trackMixpanelEvent]); + }, [trackMixpanelEvent, dispatch]); - const handleViewToggle = useCallback((value: 'list' | 'group') => { - setViewMode(value); - }, []); + const handleViewToggle = useCallback((value: ProjectViewType) => { + dispatch(setViewMode(value)); + }, [dispatch]); const handleGroupByChange = useCallback((value: ProjectGroupBy) => { - setGroupBy(value); - }, []); + dispatch(setGroupBy(value)); + }, [dispatch]); const handleDrawerClose = useCallback(() => { dispatch(setProject({} as IProjectViewModel)); @@ -287,6 +249,26 @@ const ProjectList: React.FC = () => { } }, [navigate]); + useEffect(() => { + setIsLoading(loadingProjects || isFetchingProjects); + }, [loadingProjects, isFetchingProjects]); + + useEffect(() => { + const filterIndex = getFilterIndex(); + dispatch(setRequestParams({ filter: filterIndex })); + }, [dispatch, getFilterIndex]); + + useEffect(() => { + trackMixpanelEvent(evt_projects_page_visit); + refetchProjects(); + }, [requestParams, refetchProjects, trackMixpanelEvent]); + + useEffect(() => { + if (projectStatuses.length === 0) dispatch(fetchProjectStatuses()); + if (projectCategories.length === 0) dispatch(fetchProjectCategories()); + if (projectHealths.length === 0) dispatch(fetchProjectHealth()); + }, [dispatch, projectStatuses.length, projectCategories.length, projectHealths.length]); + return (
{ border: 'none', }} /> - {viewMode === 'group' && ( + {viewMode === ProjectViewType.GROUP && ( { /> - {viewMode === 'list' ? ( + {viewMode === ProjectViewType.LIST ? ( columns={TableColumns({ navigate, @@ -352,14 +333,14 @@ const ProjectList: React.FC = () => { size="small" onChange={handleTableChange} pagination={paginationConfig} - locale={{ emptyText: }} + locale={{ emptyText: }} onRow={record => ({ onClick: () => navigateToProject(record.id, record.team_member_default_view), })} /> ) : ( navigateToProject(id, undefined)} onArchive={() => {}} diff --git a/worklenz-frontend/src/types/project/project.types.ts b/worklenz-frontend/src/types/project/project.types.ts index b98fce9b..96c225ed 100644 --- a/worklenz-frontend/src/types/project/project.types.ts +++ b/worklenz-frontend/src/types/project/project.types.ts @@ -90,7 +90,6 @@ export interface ProjectListTableProps { onArchive: (id: string) => void; } -// New types for grouping functionality export enum ProjectViewType { LIST = 'list', GROUP = 'group' @@ -108,18 +107,10 @@ export interface GroupedProject { count: number; } -export interface IProjectFilterConfig{ - current_tab: string | null; - projects_group_by: number; - current_view: number; - is_group_view: boolean; -} - export interface ProjectViewControlsProps { - viewType: ProjectViewType; - groupBy: ProjectGroupBy; - onViewTypeChange: (type: ProjectViewType) => void; - onGroupByChange: (groupBy: ProjectGroupBy) => void; + viewState: ProjectViewState; + onViewChange: (state: ProjectViewState) => void; + availableGroupByOptions?: ProjectGroupBy[]; t: (key: string) => string; } @@ -151,4 +142,10 @@ export interface GroupedProject { totalProgress: number; totalTasks: number; averageProgress?: number; -} \ No newline at end of file +} + +export interface ProjectViewState { + mode: ProjectViewType; + groupBy: ProjectGroupBy; + lastUpdated?: string; +} diff --git a/worklenz-frontend/src/types/project/projectFilterConfig.types.ts b/worklenz-frontend/src/types/project/projectFilterConfig.types.ts index 8a089e95..c620b531 100644 --- a/worklenz-frontend/src/types/project/projectFilterConfig.types.ts +++ b/worklenz-frontend/src/types/project/projectFilterConfig.types.ts @@ -7,4 +7,8 @@ export interface IProjectFilterConfig { filter: string | null; categories: string | null; statuses: string | null; + current_tab: string | null; + projects_group_by: number; + current_view: number; + is_group_view: boolean; } diff --git a/worklenz-frontend/src/utils/project-group.ts b/worklenz-frontend/src/utils/project-group.ts index 382e5a48..6da5fa6c 100644 --- a/worklenz-frontend/src/utils/project-group.ts +++ b/worklenz-frontend/src/utils/project-group.ts @@ -1,19 +1,35 @@ -// Updated project-group.ts -import { GroupedProject } from "@/types/project/project.types"; +import { GroupedProject, ProjectGroupBy } from "@/types/project/project.types"; import { IProjectViewModel } from "@/types/project/projectViewModel.types"; -export const groupProjectsByCategory = (projects: IProjectViewModel[]): GroupedProject[] => { +export const groupProjects = ( + projects: IProjectViewModel[], + groupBy: ProjectGroupBy +): GroupedProject[] => { const grouped: Record = {}; projects?.forEach(project => { - const categoryName = project.category_name || 'Uncategorized'; - const categoryColor = project.category_color || '#888'; - - if (!grouped[categoryName]) { - grouped[categoryName] = { - groupKey: categoryName, - groupName: categoryName, - groupColor: categoryColor, + let groupKey: string; + let groupName: string; + let groupColor: string; + + switch (groupBy) { + case ProjectGroupBy.CLIENT: + groupKey = project.client_name || 'No Client'; + groupName = groupKey; + groupColor = '#688'; + break; + case ProjectGroupBy.CATEGORY: + default: + groupKey = project.category_name || 'Uncategorized'; + groupName = groupKey; + groupColor = project.category_color || '#888'; + } + + if (!grouped[groupKey]) { + grouped[groupKey] = { + groupKey, + groupName, + groupColor, projects: [], count: 0, totalProgress: 0, @@ -21,48 +37,10 @@ export const groupProjectsByCategory = (projects: IProjectViewModel[]): GroupedP }; } - grouped[categoryName].projects.push(project); - grouped[categoryName].count++; - grouped[categoryName].totalProgress += project.progress || 0; - grouped[categoryName].totalTasks += project.task_count || 0; - }); - - // Calculate average progress for each category - Object.values(grouped).forEach(group => { - group.averageProgress = group.count > 0 ? Math.round(group.totalProgress / group.count) : 0; - }); - - return Object.values(grouped); -}; - -export const groupProjectsByClient = (projects: IProjectViewModel[]): GroupedProject[] => { - const grouped: Record = {}; - - projects?.forEach(project => { - const clientName = project.client_name || 'No Client'; - const clientKey = project.client_id || 'no-client'; - - if (!grouped[clientKey]) { - grouped[clientKey] = { - groupKey: clientKey, - groupName: clientName, - groupColor: '#4A90E2', // Default blue color for clients - projects: [], - count: 0, - totalProgress: 0, - totalTasks: 0 - }; - } - - grouped[clientKey].projects.push(project); - grouped[clientKey].count++; - grouped[clientKey].totalProgress += project.progress || 0; - grouped[clientKey].totalTasks += project.task_count || 0; - }); - - // Calculate average progress for each client - Object.values(grouped).forEach(group => { - group.averageProgress = group.count > 0 ? Math.round(group.totalProgress / group.count) : 0; + grouped[groupKey].projects.push(project); + grouped[groupKey].count++; + grouped[groupKey].totalProgress += project.progress || 0; + grouped[groupKey].totalTasks += project.task_count || 0; }); return Object.values(grouped);