group by client / category

This commit is contained in:
Omindu Hirushka
2025-06-06 13:23:23 +05:30
parent 585a65be31
commit e9e9bffd9a
7 changed files with 164 additions and 156 deletions

View File

@@ -76,6 +76,8 @@ import groupByFilterDropdownReducer from '../features/group-by-filter-dropdown/g
import homePageApiService from '@/api/home-page/home-page.api.service'; import homePageApiService from '@/api/home-page/home-page.api.service';
import { projectsApi } from '@/api/projects/projects.v1.api.service'; import { projectsApi } from '@/api/projects/projects.v1.api.service';
import projectViewReducer from '@features/project/project-view-slice';
export const store = configureStore({ export const store = configureStore({
middleware: getDefaultMiddleware => middleware: getDefaultMiddleware =>
getDefaultMiddleware({ getDefaultMiddleware({
@@ -114,6 +116,8 @@ export const store = configureStore({
boardReducer: boardReducer, boardReducer: boardReducer,
projectDrawerReducer: projectDrawerReducer, projectDrawerReducer: projectDrawerReducer,
projectViewReducer: projectViewReducer,
// Project Lookups // Project Lookups
projectCategoriesReducer: projectCategoriesReducer, projectCategoriesReducer: projectCategoriesReducer,
projectStatusesReducer: projectStatusesReducer, projectStatusesReducer: projectStatusesReducer,

View File

@@ -33,9 +33,6 @@ const ProjectGroupList: React.FC<ProjectGroupListProps> = ({
)} )}
<Title level={4} className="project-group-title"> <Title level={4} className="project-group-title">
{group.groupName} {group.groupName}
<Text type="secondary" className="group-stats">
({group.count} projects {group.averageProgress}% avg {group.totalTasks} tasks)
</Text>
</Title> </Title>
</div> </div>
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>

View File

@@ -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<ProjectViewType>) => {
state.mode = action.payload;
state.lastUpdated = new Date().toISOString();
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(state));
},
setGroupBy: (state, action: PayloadAction<ProjectGroupBy>) => {
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;

View File

@@ -1,8 +1,8 @@
// Updated project-list.tsx
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ProjectViewType, ProjectGroupBy } from '@/types/project/project.types';
import { setViewMode, setGroupBy } from '@features/project/project-view-slice';
import { import {
Button, Button,
Card, Card,
@@ -10,11 +10,11 @@ import {
Flex, Flex,
Input, Input,
Segmented, Segmented,
Select,
Skeleton, Skeleton,
Table, Table,
TablePaginationConfig, TablePaginationConfig,
Tooltip, Tooltip,
Select,
} from 'antd'; } from 'antd';
import { PageHeader } from '@ant-design/pro-components'; import { PageHeader } from '@ant-design/pro-components';
import { import {
@@ -38,7 +38,7 @@ import {
PROJECT_SORT_FIELD, PROJECT_SORT_FIELD,
PROJECT_SORT_ORDER, PROJECT_SORT_ORDER,
} from '@/shared/constants'; } 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 { IProjectViewModel } from '@/types/project/projectViewModel.types';
import { useDocumentTitle } from '@/hooks/useDoumentTItle'; import { useDocumentTitle } from '@/hooks/useDoumentTItle';
@@ -64,32 +64,26 @@ import {
} from '@/shared/worklenz-analytics-events'; } from '@/shared/worklenz-analytics-events';
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking'; import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
import ProjectGroupList from '@/components/project-list/project-group/project-group-list'; 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 = () => { const ProjectList: React.FC = () => {
// All hooks must be called at the top level, in the same order every time const [filteredInfo, setFilteredInfo] = useState<Record<string, FilterValue | null>>({});
const [isLoading, setIsLoading] = useState(false);
const { t } = useTranslation('all-project-list'); const { t } = useTranslation('all-project-list');
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const navigate = useNavigate(); const navigate = useNavigate();
useDocumentTitle('Projects');
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin(); const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
const { trackMixpanelEvent } = useMixpanelTracking(); const { trackMixpanelEvent } = useMixpanelTracking();
// State hooks // Get view state from Redux
const [filteredInfo, setFilteredInfo] = useState<Record<string, FilterValue | null>>({}); const { mode: viewMode, groupBy } = useAppSelector((state) => state.projectViewReducer);
const [isLoading, setIsLoading] = useState(false);
const [viewMode, setViewMode] = useState<'list' | 'group'>('list');
const [groupBy, setGroupBy] = useState<ProjectGroupBy>(ProjectGroupBy.CATEGORY);
// Custom hooks
useDocumentTitle('Projects');
// Selector hooks
const { requestParams } = useAppSelector(state => state.projectsReducer); const { requestParams } = useAppSelector(state => state.projectsReducer);
const { projectStatuses } = useAppSelector(state => state.projectStatusesReducer); const { projectStatuses } = useAppSelector(state => state.projectStatusesReducer);
const { projectHealths } = useAppSelector(state => state.projectHealthReducer); const { projectHealths } = useAppSelector(state => state.projectHealthReducer);
const { projectCategories } = useAppSelector(state => state.projectCategoriesReducer); const { projectCategories } = useAppSelector(state => state.projectCategoriesReducer);
// Query hooks
const { const {
data: projectsData, data: projectsData,
isLoading: loadingProjects, isLoading: loadingProjects,
@@ -97,7 +91,6 @@ const ProjectList: React.FC = () => {
refetch: refetchProjects, refetch: refetchProjects,
} = useGetProjectsQuery(requestParams); } = useGetProjectsQuery(requestParams);
// Callback hooks
const getFilterIndex = useCallback(() => { const getFilterIndex = useCallback(() => {
return +(localStorage.getItem(FILTER_INDEX_KEY) || 0); return +(localStorage.getItem(FILTER_INDEX_KEY) || 0);
}, []); }, []);
@@ -111,10 +104,8 @@ const ProjectList: React.FC = () => {
localStorage.setItem(PROJECT_SORT_ORDER, order); localStorage.setItem(PROJECT_SORT_ORDER, order);
}, []); }, []);
// Memoized values
const filters = useMemo(() => Object.values(IProjectFilter), []); const filters = useMemo(() => Object.values(IProjectFilter), []);
// Create translated segment options for the filters
const segmentOptions = useMemo(() => { const segmentOptions = useMemo(() => {
return filters.map(filter => ({ return filters.map(filter => ({
value: filter, value: filter,
@@ -122,55 +113,48 @@ const ProjectList: React.FC = () => {
})); }));
}, [filters, t]); }, [filters, t]);
// Toggle options for List/Group view
const viewToggleOptions = useMemo( const viewToggleOptions = useMemo(
() => [ () => [
{ {
value: 'list' as const, value: ProjectViewType.LIST,
label: ( label: (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <Tooltip title={t('listView')}>
<UnorderedListOutlined /> <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
List <UnorderedListOutlined />
</div> <span>{t('list')}</span>
</div>
</Tooltip>
), ),
}, },
{ {
value: 'group' as const, value: ProjectViewType.GROUP,
label: ( label: (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <Tooltip title={t('groupView')}>
<AppstoreOutlined /> <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
Group <AppstoreOutlined />
</div> <span>{t('group')}</span>
</div>
</Tooltip>
), ),
}, },
], ],
[] [t]
); );
// Group by options
const groupByOptions = useMemo( 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( const paginationConfig = useMemo(
() => ({ () => ({
current: requestParams.index, current: requestParams.index,
@@ -184,28 +168,6 @@ const ProjectList: React.FC = () => {
[requestParams.index, requestParams.size, projectsData?.body?.total] [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( const handleTableChange = useCallback(
( (
newPagination: TablePaginationConfig, newPagination: TablePaginationConfig,
@@ -242,13 +204,13 @@ const ProjectList: React.FC = () => {
dispatch(setRequestParams(newParams)); dispatch(setRequestParams(newParams));
setFilteredInfo(filters); setFilteredInfo(filters);
}, },
[dispatch, setSortingValues, requestParams] [dispatch, setSortingValues]
); );
const handleRefresh = useCallback(() => { const handleRefresh = useCallback(() => {
trackMixpanelEvent(evt_projects_refresh_click); trackMixpanelEvent(evt_projects_refresh_click);
refetchProjects(); refetchProjects();
}, [refetchProjects, trackMixpanelEvent]); }, [trackMixpanelEvent, refetchProjects]);
const handleSegmentChange = useCallback( const handleSegmentChange = useCallback(
(value: IProjectFilter) => { (value: IProjectFilter) => {
@@ -264,15 +226,15 @@ const ProjectList: React.FC = () => {
trackMixpanelEvent(evt_projects_search); trackMixpanelEvent(evt_projects_search);
const value = e.target.value; const value = e.target.value;
dispatch(setRequestParams({ search: value })); dispatch(setRequestParams({ search: value }));
}, [dispatch, trackMixpanelEvent]); }, [trackMixpanelEvent, dispatch]);
const handleViewToggle = useCallback((value: 'list' | 'group') => { const handleViewToggle = useCallback((value: ProjectViewType) => {
setViewMode(value); dispatch(setViewMode(value));
}, []); }, [dispatch]);
const handleGroupByChange = useCallback((value: ProjectGroupBy) => { const handleGroupByChange = useCallback((value: ProjectGroupBy) => {
setGroupBy(value); dispatch(setGroupBy(value));
}, []); }, [dispatch]);
const handleDrawerClose = useCallback(() => { const handleDrawerClose = useCallback(() => {
dispatch(setProject({} as IProjectViewModel)); dispatch(setProject({} as IProjectViewModel));
@@ -287,6 +249,26 @@ const ProjectList: React.FC = () => {
} }
}, [navigate]); }, [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 ( return (
<div style={{ marginBlock: 65, minHeight: '90vh' }}> <div style={{ marginBlock: 65, minHeight: '90vh' }}>
<PageHeader <PageHeader
@@ -317,13 +299,12 @@ const ProjectList: React.FC = () => {
border: 'none', border: 'none',
}} }}
/> />
{viewMode === 'group' && ( {viewMode === ProjectViewType.GROUP && (
<Select <Select
value={groupBy} value={groupBy}
onChange={handleGroupByChange} onChange={handleGroupByChange}
options={groupByOptions} options={groupByOptions}
style={{ width: 120 }} style={{ width: 150 }}
size="middle"
/> />
)} )}
<Input <Input
@@ -340,7 +321,7 @@ const ProjectList: React.FC = () => {
/> />
<Card className="project-card"> <Card className="project-card">
<Skeleton active loading={isLoading} className="mt-4 p-4"> <Skeleton active loading={isLoading} className="mt-4 p-4">
{viewMode === 'list' ? ( {viewMode === ProjectViewType.LIST ? (
<Table<IProjectViewModel> <Table<IProjectViewModel>
columns={TableColumns({ columns={TableColumns({
navigate, navigate,
@@ -352,14 +333,14 @@ const ProjectList: React.FC = () => {
size="small" size="small"
onChange={handleTableChange} onChange={handleTableChange}
pagination={paginationConfig} pagination={paginationConfig}
locale={{ emptyText: <Empty description={t('noProjects')} /> }} locale={{ emptyText: <Empty description={t('noProjects')} />}}
onRow={record => ({ onRow={record => ({
onClick: () => navigateToProject(record.id, record.team_member_default_view), onClick: () => navigateToProject(record.id, record.team_member_default_view),
})} })}
/> />
) : ( ) : (
<ProjectGroupList <ProjectGroupList
groups={groupedProjects} groups={groupProjects(projectsData?.body?.data || [], groupBy)}
navigate={navigate} navigate={navigate}
onProjectSelect={id => navigateToProject(id, undefined)} onProjectSelect={id => navigateToProject(id, undefined)}
onArchive={() => {}} onArchive={() => {}}

View File

@@ -90,7 +90,6 @@ export interface ProjectListTableProps {
onArchive: (id: string) => void; onArchive: (id: string) => void;
} }
// New types for grouping functionality
export enum ProjectViewType { export enum ProjectViewType {
LIST = 'list', LIST = 'list',
GROUP = 'group' GROUP = 'group'
@@ -108,18 +107,10 @@ export interface GroupedProject {
count: number; count: number;
} }
export interface IProjectFilterConfig{
current_tab: string | null;
projects_group_by: number;
current_view: number;
is_group_view: boolean;
}
export interface ProjectViewControlsProps { export interface ProjectViewControlsProps {
viewType: ProjectViewType; viewState: ProjectViewState;
groupBy: ProjectGroupBy; onViewChange: (state: ProjectViewState) => void;
onViewTypeChange: (type: ProjectViewType) => void; availableGroupByOptions?: ProjectGroupBy[];
onGroupByChange: (groupBy: ProjectGroupBy) => void;
t: (key: string) => string; t: (key: string) => string;
} }
@@ -152,3 +143,9 @@ export interface GroupedProject {
totalTasks: number; totalTasks: number;
averageProgress?: number; averageProgress?: number;
} }
export interface ProjectViewState {
mode: ProjectViewType;
groupBy: ProjectGroupBy;
lastUpdated?: string;
}

View File

@@ -7,4 +7,8 @@ export interface IProjectFilterConfig {
filter: string | null; filter: string | null;
categories: string | null; categories: string | null;
statuses: string | null; statuses: string | null;
current_tab: string | null;
projects_group_by: number;
current_view: number;
is_group_view: boolean;
} }

View File

@@ -1,19 +1,35 @@
// Updated project-group.ts import { GroupedProject, ProjectGroupBy } from "@/types/project/project.types";
import { GroupedProject } from "@/types/project/project.types";
import { IProjectViewModel } from "@/types/project/projectViewModel.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<string, GroupedProject> = {}; const grouped: Record<string, GroupedProject> = {};
projects?.forEach(project => { projects?.forEach(project => {
const categoryName = project.category_name || 'Uncategorized'; let groupKey: string;
const categoryColor = project.category_color || '#888'; let groupName: string;
let groupColor: string;
if (!grouped[categoryName]) { switch (groupBy) {
grouped[categoryName] = { case ProjectGroupBy.CLIENT:
groupKey: categoryName, groupKey = project.client_name || 'No Client';
groupName: categoryName, groupName = groupKey;
groupColor: categoryColor, 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: [], projects: [],
count: 0, count: 0,
totalProgress: 0, totalProgress: 0,
@@ -21,48 +37,10 @@ export const groupProjectsByCategory = (projects: IProjectViewModel[]): GroupedP
}; };
} }
grouped[categoryName].projects.push(project); grouped[groupKey].projects.push(project);
grouped[categoryName].count++; grouped[groupKey].count++;
grouped[categoryName].totalProgress += project.progress || 0; grouped[groupKey].totalProgress += project.progress || 0;
grouped[categoryName].totalTasks += project.task_count || 0; grouped[groupKey].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<string, GroupedProject> = {};
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;
}); });
return Object.values(grouped); return Object.values(grouped);