From 585a65be31ec87dcdbf66eeab4af6605ee6f04c8 Mon Sep 17 00:00:00 2001 From: Omindu Hirushka <102536488+OminduHirushka@users.noreply.github.com> Date: Fri, 6 Jun 2025 09:47:42 +0530 Subject: [PATCH] current updates --- .../project-group/project-group-list.tsx | 107 ++++++++ .../src/pages/projects/project-list.css | 81 ++++++ .../src/pages/projects/project-list.tsx | 259 +++++++++++------- .../src/types/project/project.types.ts | 107 ++++++++ worklenz-frontend/src/utils/project-group.ts | 69 +++++ 5 files changed, 531 insertions(+), 92 deletions(-) create mode 100644 worklenz-frontend/src/components/project-list/project-group/project-group-list.tsx create mode 100644 worklenz-frontend/src/utils/project-group.ts 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 new file mode 100644 index 00000000..66e03007 --- /dev/null +++ b/worklenz-frontend/src/components/project-list/project-group/project-group-list.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import { Card, Col, Empty, Row, Skeleton, Tag, Typography, Progress, Tooltip } from 'antd'; +import { ClockCircleOutlined, TeamOutlined, CheckCircleOutlined } from '@ant-design/icons'; +import { ProjectGroupListProps } from '@/types/project/project.types'; + +const { Title, Text } = Typography; + +const ProjectGroupList: React.FC = ({ + groups, + navigate, + onProjectSelect, + loading, + t +}) => { + if (loading) { + return ; + } + + if (groups.length === 0) { + return ; + } + + return ( +
+ {groups.map(group => ( +
+
+ {group.groupColor && ( + + )} + + {group.groupName} + <Text type="secondary" className="group-stats"> + ({group.count} projects • {group.averageProgress}% avg • {group.totalTasks} tasks) + </Text> + +
+ + {group.projects.map(project => ( + + onProjectSelect(project.id)} + className="project-card" + cover={ + project.status_color && ( +
+ ) + } + > +
+ + {project.name} + + + {project.client_name && ( + + {project.client_name} + + )} + + + +
+ + + {project.completed_tasks_count || 0}/{project.all_tasks_count || 0} + + + + + + {project.members_count || 0} + + + + {project.updated_at_string && ( + + + {project.updated_at_string} + + + )} +
+
+ + + ))} + +
+ ))} +
+ ); +}; + +export default ProjectGroupList; \ No newline at end of file diff --git a/worklenz-frontend/src/pages/projects/project-list.css b/worklenz-frontend/src/pages/projects/project-list.css index d64d3142..349e637a 100644 --- a/worklenz-frontend/src/pages/projects/project-list.css +++ b/worklenz-frontend/src/pages/projects/project-list.css @@ -25,3 +25,84 @@ :where(.css-dev-only-do-not-override-17sis5b).ant-tabs-bottom > div > .ant-tabs-nav::before { border: none; } + +.project-group-container { + margin-top: 16px; +} + +.project-group { + margin-bottom: 32px; +} + +.project-group-header { + display: flex; + align-items: center; + margin-bottom: 16px; + gap: 8px; +} + +.group-color-indicator { + width: 12px; + height: 12px; + border-radius: 50%; + display: inline-block; +} + +.group-stats { + margin-left: 8px; + font-size: 14px; + font-weight: normal; +} + +.project-card { + height: 100%; + overflow: hidden; +} + +.project-card .ant-card-cover { + height: 4px; +} + +.project-status-bar { + width: 100%; + height: 100%; +} + +.project-card-content { + padding: 8px; +} + +.project-title { + margin-bottom: 8px !important; + min-height: 44px; +} + +.project-client { + display: block; + margin-bottom: 12px; + font-size: 12px; +} + +.project-progress { + margin-bottom: 12px; +} + +.project-meta { + display: flex; + justify-content: space-between; + font-size: 12px; + color: #666; + margin-bottom: 8px; +} + +.project-meta span { + display: flex; + align-items: center; + gap: 4px; +} + +.project-status-tag { + margin-top: 8px; + width: 100%; + text-align: center; +} \ 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 7fc17180..359cccf5 100644 --- a/worklenz-frontend/src/pages/projects/project-list.tsx +++ b/worklenz-frontend/src/pages/projects/project-list.tsx @@ -1,3 +1,4 @@ +// Updated project-list.tsx import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; @@ -13,9 +14,15 @@ import { Table, TablePaginationConfig, Tooltip, + Select, } from 'antd'; import { PageHeader } from '@ant-design/pro-components'; -import { SearchOutlined, SyncOutlined, UnorderedListOutlined, AppstoreOutlined } from '@ant-design/icons'; +import { + SearchOutlined, + SyncOutlined, + UnorderedListOutlined, + AppstoreOutlined, +} from '@ant-design/icons'; import type { FilterValue, SorterResult } from 'antd/es/table/interface'; import ProjectDrawer from '@/components/projects/project-drawer/project-drawer'; @@ -31,7 +38,7 @@ import { PROJECT_SORT_FIELD, PROJECT_SORT_ORDER, } from '@/shared/constants'; -import { IProjectFilter } from '@/types/project/project.types'; +import { IProjectFilter, ProjectGroupBy } from '@/types/project/project.types'; import { IProjectViewModel } from '@/types/project/projectViewModel.types'; import { useDocumentTitle } from '@/hooks/useDoumentTItle'; @@ -50,20 +57,47 @@ import { fetchProjectHealth } from '@/features/projects/lookups/projectHealth/pr import { setProjectId, setStatuses } from '@/features/project/project.slice'; import { setProject } from '@/features/project/project.slice'; import { createPortal } from 'react-dom'; -import { evt_projects_page_visit, evt_projects_refresh_click, evt_projects_search } from '@/shared/worklenz-analytics-events'; +import { + evt_projects_page_visit, + evt_projects_refresh_click, + evt_projects_search, +} 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'; const ProjectList: React.FC = () => { - const [filteredInfo, setFilteredInfo] = useState>({}); - const [isLoading, setIsLoading] = useState(false); - const [viewMode, setViewMode] = useState<'list' | 'group'>('list'); + // All hooks must be called at the top level, in the same order every time 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 + 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, + isFetching: isFetchingProjects, + refetch: refetchProjects, + } = useGetProjectsQuery(requestParams); + + // Callback hooks const getFilterIndex = useCallback(() => { return +(localStorage.getItem(FILTER_INDEX_KEY) || 0); }, []); @@ -77,51 +111,80 @@ const ProjectList: React.FC = () => { localStorage.setItem(PROJECT_SORT_ORDER, order); }, []); - const { requestParams } = useAppSelector(state => state.projectsReducer); - - const { projectStatuses } = useAppSelector(state => state.projectStatusesReducer); - const { projectHealths } = useAppSelector(state => state.projectHealthReducer); - const { projectCategories } = useAppSelector(state => state.projectCategoriesReducer); - - const { - data: projectsData, - isLoading: loadingProjects, - isFetching: isFetchingProjects, - refetch: refetchProjects, - } = useGetProjectsQuery(requestParams); - + // Memoized values const filters = useMemo(() => Object.values(IProjectFilter), []); - + // Create translated segment options for the filters const segmentOptions = useMemo(() => { return filters.map(filter => ({ value: filter, - label: t(filter.toLowerCase()) + label: t(filter.toLowerCase()), })); }, [filters, t]); // Toggle options for List/Group view - const viewToggleOptions = useMemo(() => [ - { - value: 'list' as const, - label: ( -
- - List -
- ) - }, - { - value: 'group' as const, - label: ( -
- - Group -
- ) - } - ], []); + const viewToggleOptions = useMemo( + () => [ + { + value: 'list' as const, + label: ( +
+ + List +
+ ), + }, + { + value: 'group' as const, + label: ( +
+ + Group +
+ ), + }, + ], + [] + ); + // Group by options + const groupByOptions = useMemo( + () => [ + { value: ProjectGroupBy.CATEGORY, label: 'Category' }, + { value: ProjectGroupBy.CLIENT, label: 'Client' }, + ], + [] + ); + + // 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, + pageSize: requestParams.size, + showSizeChanger: true, + defaultPageSize: DEFAULT_PAGE_SIZE, + pageSizeOptions: PAGE_SIZE_OPTIONS, + size: 'small' as const, + total: projectsData?.body?.total, + }), + [requestParams.index, requestParams.size, projectsData?.body?.total] + ); + + // Effect hooks useEffect(() => { setIsLoading(loadingProjects || isFetchingProjects); }, [loadingProjects, isFetchingProjects]); @@ -134,8 +197,15 @@ const ProjectList: React.FC = () => { useEffect(() => { trackMixpanelEvent(evt_projects_page_visit); refetchProjects(); - }, [requestParams, 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, @@ -147,7 +217,6 @@ const ProjectList: React.FC = () => { newParams.statuses = null; dispatch(setFilteredStatuses([])); } else { - // dispatch(setFilteredStatuses(filters.status_id as Array)); newParams.statuses = filters.status_id.join(' '); } @@ -155,7 +224,6 @@ const ProjectList: React.FC = () => { newParams.categories = null; dispatch(setFilteredCategories([])); } else { - // dispatch(setFilteredCategories(filters.category_id as Array)); newParams.categories = filters.category_id.join(' '); } @@ -174,13 +242,13 @@ const ProjectList: React.FC = () => { dispatch(setRequestParams(newParams)); setFilteredInfo(filters); }, - [setSortingValues] + [dispatch, setSortingValues, requestParams] ); const handleRefresh = useCallback(() => { trackMixpanelEvent(evt_projects_refresh_click); refetchProjects(); - }, [refetchProjects, requestParams]); + }, [refetchProjects, trackMixpanelEvent]); const handleSegmentChange = useCallback( (value: IProjectFilter) => { @@ -189,48 +257,35 @@ const ProjectList: React.FC = () => { dispatch(setRequestParams({ filter: newFilterIndex })); refetchProjects(); }, - [filters, setFilterIndex, refetchProjects] + [filters, setFilterIndex, dispatch, refetchProjects] ); const handleSearchChange = useCallback((e: React.ChangeEvent) => { trackMixpanelEvent(evt_projects_search); const value = e.target.value; dispatch(setRequestParams({ search: value })); - }, []); + }, [dispatch, trackMixpanelEvent]); const handleViewToggle = useCallback((value: 'list' | 'group') => { setViewMode(value); }, []); - const paginationConfig = useMemo( - () => ({ - current: requestParams.index, - pageSize: requestParams.size, - showSizeChanger: true, - defaultPageSize: DEFAULT_PAGE_SIZE, - pageSizeOptions: PAGE_SIZE_OPTIONS, - size: 'small' as const, - total: projectsData?.body?.total, - }), - [requestParams.index, requestParams.size, projectsData?.body?.total] - ); + const handleGroupByChange = useCallback((value: ProjectGroupBy) => { + setGroupBy(value); + }, []); - const handleDrawerClose = () => { + const handleDrawerClose = useCallback(() => { dispatch(setProject({} as IProjectViewModel)); dispatch(setProjectId(null)); - }; - - const navigateToProject = (project_id: string | undefined, default_view: string | undefined) => { - if (project_id) { - navigate(`/worklenz/projects/${project_id}?tab=${default_view === 'BOARD' ? 'board' : 'tasks-list'}&pinned_tab=${default_view === 'BOARD' ? 'board' : 'tasks-list'}`); // Update the route as per your project structure - } - }; + }, [dispatch]); - useEffect(() => { - if (projectStatuses.length === 0) dispatch(fetchProjectStatuses()); - if (projectCategories.length === 0) dispatch(fetchProjectCategories()); - if (projectHealths.length === 0) dispatch(fetchProjectHealth()); - }, [requestParams]); + const navigateToProject = useCallback((project_id: string | undefined, default_view: string | undefined) => { + if (project_id) { + navigate( + `/worklenz/projects/${project_id}?tab=${default_view === 'BOARD' ? 'board' : 'tasks-list'}&pinned_tab=${default_view === 'BOARD' ? 'board' : 'tasks-list'}` + ); + } + }, [navigate]); return (
@@ -262,6 +317,15 @@ const ProjectList: React.FC = () => { border: 'none', }} /> + {viewMode === 'group' && ( + } @@ -275,25 +339,36 @@ const ProjectList: React.FC = () => { } /> - - - columns={TableColumns({ - navigate, - filteredInfo, - })} - dataSource={projectsData?.body?.data || []} - rowKey={record => record.id || ''} - loading={loadingProjects} - size="small" - onChange={handleTableChange} - pagination={paginationConfig} - locale={{ emptyText: }} - onRow={record => ({ - onClick: () => navigateToProject(record.id, record.team_member_default_view), // Navigate to project on row click - })} - /> + + {viewMode === 'list' ? ( + + columns={TableColumns({ + navigate, + filteredInfo, + })} + dataSource={projectsData?.body?.data || []} + rowKey={record => record.id || ''} + loading={loadingProjects} + size="small" + onChange={handleTableChange} + pagination={paginationConfig} + locale={{ emptyText: }} + onRow={record => ({ + onClick: () => navigateToProject(record.id, record.team_member_default_view), + })} + /> + ) : ( + navigateToProject(id, undefined)} + onArchive={() => {}} + isOwnerOrAdmin={isOwnerOrAdmin} + loading={loadingProjects} + t={t} + /> + )} - {createPortal(, document.body, 'project-drawer')} diff --git a/worklenz-frontend/src/types/project/project.types.ts b/worklenz-frontend/src/types/project/project.types.ts index e89baf9a..b98fce9b 100644 --- a/worklenz-frontend/src/types/project/project.types.ts +++ b/worklenz-frontend/src/types/project/project.types.ts @@ -1,5 +1,10 @@ import { IProjectCategory } from '@/types/project/projectCategory.types'; import { IProjectStatus } from '@/types/project/projectStatus.types'; +import { IProjectViewModel } from './projectViewModel.types'; +import { NavigateFunction } from 'react-router-dom'; +import { AppDispatch } from '@/app/store'; +import { TablePaginationConfig } from 'antd'; +import { FilterValue, SorterResult } from 'antd/es/table/interface'; export interface IProject { id?: string; @@ -45,3 +50,105 @@ export enum IProjectFilter { Favourites = 'Favorites', Archived = 'Archived', } + +export interface ProjectNameCellProps { + record: IProjectViewModel; + navigate: NavigateFunction; +} + +export interface CategoryCellProps { + record: IProjectViewModel; +} + +export interface ActionButtonsProps { + t: (key: string) => string; + record: IProjectViewModel; + setProjectId: (id: string) => void; + dispatch: AppDispatch; + isOwnerOrAdmin: boolean; +} + +export interface TableColumnsProps { + navigate: NavigateFunction; + statuses: IProjectStatus[]; + categories: IProjectCategory[]; + setProjectId: (id: string) => void; +} + +export interface ProjectListTableProps { + loading: boolean; + projects: IProjectViewModel[]; + statuses: IProjectStatus[]; + categories: IProjectCategory[]; + pagination: TablePaginationConfig; + onTableChange: ( + pagination: TablePaginationConfig, + filters: Record, + sorter: SorterResult | SorterResult[] + ) => void; + onProjectSelect: (id: string) => void; + onArchive: (id: string) => void; +} + +// New types for grouping functionality +export enum ProjectViewType { + LIST = 'list', + GROUP = 'group' +} + +export enum ProjectGroupBy { + CLIENT = 'client', + CATEGORY = 'category' +} + +export interface GroupedProject { + groupKey: string; + groupName: string; + projects: IProjectViewModel[]; + 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; + t: (key: string) => string; +} + +export interface ProjectGroupCardProps { + group: GroupedProject; + navigate: NavigateFunction; + onProjectSelect: (id: string) => void; + onArchive: (id: string) => void; + isOwnerOrAdmin: boolean; + t: (key: string) => string; +} + +export interface ProjectGroupListProps { + groups: GroupedProject[]; + navigate: NavigateFunction; + onProjectSelect: (id: string) => void; + onArchive: (id: string) => void; + isOwnerOrAdmin: boolean; + loading: boolean; + t: (key: string) => string; +} + +export interface GroupedProject { + groupKey: string; + groupName: string; + groupColor?: string; + projects: IProjectViewModel[]; + count: number; + totalProgress: number; + totalTasks: number; + averageProgress?: number; +} \ No newline at end of file diff --git a/worklenz-frontend/src/utils/project-group.ts b/worklenz-frontend/src/utils/project-group.ts new file mode 100644 index 00000000..382e5a48 --- /dev/null +++ b/worklenz-frontend/src/utils/project-group.ts @@ -0,0 +1,69 @@ +// Updated project-group.ts +import { GroupedProject } from "@/types/project/project.types"; +import { IProjectViewModel } from "@/types/project/projectViewModel.types"; + +export const groupProjectsByCategory = (projects: IProjectViewModel[]): 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, + projects: [], + count: 0, + totalProgress: 0, + totalTasks: 0 + }; + } + + 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; + }); + + return Object.values(grouped); +}; \ No newline at end of file