Merge pull request #152 from OminduHirushka/upstream/feature/project-groupby

Projects - List / Group View
This commit is contained in:
Chamika J
2025-06-13 13:02:30 +05:30
committed by GitHub
17 changed files with 5770 additions and 5571 deletions

6
package-lock.json generated Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "worklenz",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

File diff suppressed because it is too large Load Diff

View File

@@ -68,6 +68,7 @@
"express-rate-limit": "^6.8.0", "express-rate-limit": "^6.8.0",
"express-session": "^1.17.3", "express-session": "^1.17.3",
"express-validator": "^6.15.0", "express-validator": "^6.15.0",
"grunt-cli": "^1.5.0",
"helmet": "^6.2.0", "helmet": "^6.2.0",
"hpp": "^0.2.3", "hpp": "^0.2.3",
"http-errors": "^2.0.0", "http-errors": "^2.0.0",
@@ -93,8 +94,10 @@
"sharp": "^0.32.6", "sharp": "^0.32.6",
"slugify": "^1.6.6", "slugify": "^1.6.6",
"socket.io": "^4.7.1", "socket.io": "^4.7.1",
"tinymce": "^7.8.0",
"uglify-js": "^3.17.4", "uglify-js": "^3.17.4",
"winston": "^3.10.0", "winston": "^3.10.0",
"worklenz-backend": "file:",
"xss-filters": "^1.2.7" "xss-filters": "^1.2.7"
}, },
"devDependencies": { "devDependencies": {
@@ -102,15 +105,17 @@
"@babel/preset-typescript": "^7.22.5", "@babel/preset-typescript": "^7.22.5",
"@types/bcrypt": "^5.0.0", "@types/bcrypt": "^5.0.0",
"@types/bluebird": "^3.5.38", "@types/bluebird": "^3.5.38",
"@types/body-parser": "^1.19.2",
"@types/compression": "^1.7.2", "@types/compression": "^1.7.2",
"@types/connect-flash": "^0.0.37", "@types/connect-flash": "^0.0.37",
"@types/cookie-parser": "^1.4.3", "@types/cookie-parser": "^1.4.3",
"@types/cron": "^2.0.1", "@types/cron": "^2.0.1",
"@types/crypto-js": "^4.2.2", "@types/crypto-js": "^4.2.2",
"@types/csurf": "^1.11.2", "@types/csurf": "^1.11.2",
"@types/express": "^4.17.17", "@types/express": "^4.17.21",
"@types/express-brute": "^1.0.2", "@types/express-brute": "^1.0.2",
"@types/express-brute-redis": "^0.0.4", "@types/express-brute-redis": "^0.0.4",
"@types/express-serve-static-core": "^4.17.34",
"@types/express-session": "^1.17.7", "@types/express-session": "^1.17.7",
"@types/fs-extra": "^9.0.13", "@types/fs-extra": "^9.0.13",
"@types/hpp": "^0.2.2", "@types/hpp": "^0.2.2",

View File

@@ -0,0 +1,20 @@
{
"name": "tinymce",
"version": "6.8.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "tinymce",
"version": "6.8.4",
"license": "MIT",
"dependencies": {
"tinymce": "file:"
}
},
"node_modules/tinymce": {
"resolved": "",
"link": true
}
}
}

View File

@@ -28,5 +28,8 @@
"homepage": "https://www.tiny.cloud/", "homepage": "https://www.tiny.cloud/",
"bugs": { "bugs": {
"url": "https://github.com/tinymce/tinymce/issues" "url": "https://github.com/tinymce/tinymce/issues"
},
"dependencies": {
"tinymce": "file:"
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,8 @@
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"scripts": { "scripts": {
"start": "vite", "start": "vite dev",
"dev": "vite dev",
"prebuild": "node scripts/copy-tinymce.js", "prebuild": "node scripts/copy-tinymce.js",
"build": "vite build", "build": "vite build",
"dev-build": "vite build", "dev-build": "vite build",
@@ -13,7 +14,7 @@
"dependencies": { "dependencies": {
"@ant-design/colors": "^7.1.0", "@ant-design/colors": "^7.1.0",
"@ant-design/compatible": "^5.1.4", "@ant-design/compatible": "^5.1.4",
"@ant-design/icons": "^5.4.0", "@ant-design/icons": "^4.7.0",
"@ant-design/pro-components": "^2.7.19", "@ant-design/pro-components": "^2.7.19",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/modifiers": "^9.0.0",
@@ -29,6 +30,7 @@
"axios": "^1.9.0", "axios": "^1.9.0",
"chart.js": "^4.4.7", "chart.js": "^4.4.7",
"chartjs-plugin-datalabels": "^2.2.0", "chartjs-plugin-datalabels": "^2.2.0",
"cors": "^2.8.5",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dompurify": "^3.2.5", "dompurify": "^3.2.5",
"gantt-task-react": "^0.3.9", "gantt-task-react": "^0.3.9",
@@ -38,6 +40,7 @@
"i18next-http-backend": "^2.7.3", "i18next-http-backend": "^2.7.3",
"jspdf": "^3.0.0", "jspdf": "^3.0.0",
"mixpanel-browser": "^2.56.0", "mixpanel-browser": "^2.56.0",
"nanoid": "^5.1.5",
"primereact": "^10.8.4", "primereact": "^10.8.4",
"re-resizable": "^6.10.3", "re-resizable": "^6.10.3",
"react": "^18.3.1", "react": "^18.3.1",
@@ -52,7 +55,8 @@
"react-window": "^1.8.11", "react-window": "^1.8.11",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
"tinymce": "^7.7.2", "tinymce": "^7.7.2",
"web-vitals": "^4.2.4" "web-vitals": "^4.2.4",
"worklenz": "file:"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
@@ -70,6 +74,7 @@
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"postcss": "^8.5.2", "postcss": "^8.5.2",
"prettier-plugin-tailwindcss": "^0.6.8", "prettier-plugin-tailwindcss": "^0.6.8",
"rollup": "^4.40.2",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"terser": "^5.39.0", "terser": "^5.39.0",
"typescript": "^5.7.3", "typescript": "^5.7.3",

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({
@@ -113,6 +115,8 @@ export const store = configureStore({
taskListCustomColumnsReducer: taskListCustomColumnsReducer, taskListCustomColumnsReducer: taskListCustomColumnsReducer,
boardReducer: boardReducer, boardReducer: boardReducer,
projectDrawerReducer: projectDrawerReducer, projectDrawerReducer: projectDrawerReducer,
projectViewReducer: projectViewReducer,
// Project Lookups // Project Lookups
projectCategoriesReducer: projectCategoriesReducer, projectCategoriesReducer: projectCategoriesReducer,

View File

@@ -0,0 +1,104 @@
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<ProjectGroupListProps> = ({
groups,
navigate,
onProjectSelect,
loading,
t
}) => {
if (loading) {
return <Skeleton active />;
}
if (groups.length === 0) {
return <Empty description={t('noProjects')} />;
}
return (
<div className="project-group-container">
{groups.map(group => (
<div key={group.groupKey} className="project-group">
<div className="project-group-header">
{group.groupColor && (
<span
className="group-color-indicator"
style={{ backgroundColor: group.groupColor }}
/>
)}
<Title level={4} className="project-group-title">
{group.groupName}
</Title>
</div>
<Row gutter={[16, 16]}>
{group.projects.map(project => (
<Col key={project.id} xs={24} sm={12} md={8} lg={6}>
<Card
hoverable
onClick={() => onProjectSelect(project.id)}
className="project-card"
cover={
project.status_color && (
<div
className="project-status-bar"
style={{ backgroundColor: project.status_color }}
/>
)
}
>
<div className="project-card-content">
<Title level={5} ellipsis={{ rows: 2 }} className="project-title">
{project.name}
</Title>
{project.client_name && (
<Text type="secondary" className="project-client">
{project.client_name}
</Text>
)}
<Progress
percent={project.progress}
size="small"
status="active"
className="project-progress"
/>
<div className="project-meta">
<Tooltip title="Tasks">
<span>
<CheckCircleOutlined /> {project.completed_tasks_count || 0}/{project.all_tasks_count || 0}
</span>
</Tooltip>
<Tooltip title="Members">
<span>
<TeamOutlined /> {project.members_count || 0}
</span>
</Tooltip>
{project.updated_at_string && (
<Tooltip title="Last updated">
<span>
<ClockCircleOutlined /> {project.updated_at_string}
</span>
</Tooltip>
)}
</div>
</div>
</Card>
</Col>
))}
</Row>
</div>
))}
</div>
);
};
export default ProjectGroupList;

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

@@ -25,3 +25,84 @@
:where(.css-dev-only-do-not-override-17sis5b).ant-tabs-bottom > div > .ant-tabs-nav::before { :where(.css-dev-only-do-not-override-17sis5b).ant-tabs-bottom > div > .ant-tabs-nav::before {
border: none; 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;
}

View File

@@ -1,7 +1,8 @@
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,
@@ -9,13 +10,19 @@ import {
Flex, Flex,
Input, Input,
Segmented, Segmented,
Select,
Skeleton, Skeleton,
Table, Table,
TablePaginationConfig, TablePaginationConfig,
Tooltip, Tooltip,
} from 'antd'; } from 'antd';
import { PageHeader } from '@ant-design/pro-components'; import { PageHeader } from '@ant-design/pro-components';
import { SearchOutlined, SyncOutlined } from '@ant-design/icons'; import {
SearchOutlined,
SyncOutlined,
UnorderedListOutlined,
AppstoreOutlined,
} from '@ant-design/icons';
import type { FilterValue, SorterResult } from 'antd/es/table/interface'; import type { FilterValue, SorterResult } from 'antd/es/table/interface';
import ProjectDrawer from '@/components/projects/project-drawer/project-drawer'; import ProjectDrawer from '@/components/projects/project-drawer/project-drawer';
@@ -50,12 +57,19 @@ import { fetchProjectHealth } from '@/features/projects/lookups/projectHealth/pr
import { setProjectId, setStatuses } from '@/features/project/project.slice'; import { setProjectId, setStatuses } from '@/features/project/project.slice';
import { setProject } from '@/features/project/project.slice'; import { setProject } from '@/features/project/project.slice';
import { createPortal } from 'react-dom'; 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 { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
import ProjectGroupList from '@/components/project-list/project-group/project-group-list';
import { groupProjects } from '@/utils/project-group';
const ProjectList: React.FC = () => { const ProjectList: React.FC = () => {
const [filteredInfo, setFilteredInfo] = useState<Record<string, FilterValue | null>>({}); const [filteredInfo, setFilteredInfo] = useState<Record<string, FilterValue | null>>({});
const [isLoading, setIsLoading] = useState(false); 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();
@@ -63,6 +77,20 @@ const ProjectList: React.FC = () => {
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin(); const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
const { trackMixpanelEvent } = useMixpanelTracking(); const { trackMixpanelEvent } = useMixpanelTracking();
// 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);
const {
data: projectsData,
isLoading: loadingProjects,
isFetching: isFetchingProjects,
refetch: refetchProjects,
} = useGetProjectsQuery(requestParams);
const getFilterIndex = useCallback(() => { const getFilterIndex = useCallback(() => {
return +(localStorage.getItem(FILTER_INDEX_KEY) || 0); return +(localStorage.getItem(FILTER_INDEX_KEY) || 0);
}, []); }, []);
@@ -76,42 +104,69 @@ const ProjectList: React.FC = () => {
localStorage.setItem(PROJECT_SORT_ORDER, order); 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);
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,
label: t(filter.toLowerCase()) label: t(filter.toLowerCase()),
})); }));
}, [filters, t]); }, [filters, t]);
useEffect(() => { const viewToggleOptions = useMemo(
setIsLoading(loadingProjects || isFetchingProjects); () => [
}, [loadingProjects, isFetchingProjects]); {
value: ProjectViewType.LIST,
label: (
<Tooltip title={t('listView')}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<UnorderedListOutlined />
<span>{t('list')}</span>
</div>
</Tooltip>
),
},
{
value: ProjectViewType.GROUP,
label: (
<Tooltip title={t('groupView')}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<AppstoreOutlined />
<span>{t('group')}</span>
</div>
</Tooltip>
),
},
],
[t]
);
useEffect(() => { const groupByOptions = useMemo(
const filterIndex = getFilterIndex(); () => [
dispatch(setRequestParams({ filter: filterIndex })); {
}, [dispatch, getFilterIndex]); value: ProjectGroupBy.CATEGORY,
label: t('groupBy.category'),
},
{
value: ProjectGroupBy.CLIENT,
label: t('groupBy.client'),
},
],
[t]
);
useEffect(() => { const paginationConfig = useMemo(
trackMixpanelEvent(evt_projects_page_visit); () => ({
refetchProjects(); current: requestParams.index,
}, [requestParams, refetchProjects]); 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 handleTableChange = useCallback( const handleTableChange = useCallback(
( (
@@ -124,7 +179,6 @@ const ProjectList: React.FC = () => {
newParams.statuses = null; newParams.statuses = null;
dispatch(setFilteredStatuses([])); dispatch(setFilteredStatuses([]));
} else { } else {
// dispatch(setFilteredStatuses(filters.status_id as Array<string>));
newParams.statuses = filters.status_id.join(' '); newParams.statuses = filters.status_id.join(' ');
} }
@@ -132,7 +186,6 @@ const ProjectList: React.FC = () => {
newParams.categories = null; newParams.categories = null;
dispatch(setFilteredCategories([])); dispatch(setFilteredCategories([]));
} else { } else {
// dispatch(setFilteredCategories(filters.category_id as Array<string>));
newParams.categories = filters.category_id.join(' '); newParams.categories = filters.category_id.join(' ');
} }
@@ -151,13 +204,13 @@ const ProjectList: React.FC = () => {
dispatch(setRequestParams(newParams)); dispatch(setRequestParams(newParams));
setFilteredInfo(filters); setFilteredInfo(filters);
}, },
[setSortingValues] [dispatch, setSortingValues]
); );
const handleRefresh = useCallback(() => { const handleRefresh = useCallback(() => {
trackMixpanelEvent(evt_projects_refresh_click); trackMixpanelEvent(evt_projects_refresh_click);
refetchProjects(); refetchProjects();
}, [refetchProjects, requestParams]); }, [trackMixpanelEvent, refetchProjects]);
const handleSegmentChange = useCallback( const handleSegmentChange = useCallback(
(value: IProjectFilter) => { (value: IProjectFilter) => {
@@ -166,43 +219,55 @@ const ProjectList: React.FC = () => {
dispatch(setRequestParams({ filter: newFilterIndex })); dispatch(setRequestParams({ filter: newFilterIndex }));
refetchProjects(); refetchProjects();
}, },
[filters, setFilterIndex, refetchProjects] [filters, setFilterIndex, dispatch, refetchProjects]
); );
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
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 }));
}, []); }, [trackMixpanelEvent, dispatch]);
const paginationConfig = useMemo( const handleViewToggle = useCallback((value: ProjectViewType) => {
() => ({ dispatch(setViewMode(value));
current: requestParams.index, }, [dispatch]);
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 handleDrawerClose = () => { const handleGroupByChange = useCallback((value: ProjectGroupBy) => {
dispatch(setGroupBy(value));
}, [dispatch]);
const handleDrawerClose = useCallback(() => {
dispatch(setProject({} as IProjectViewModel)); dispatch(setProject({} as IProjectViewModel));
dispatch(setProjectId(null)); dispatch(setProjectId(null));
}; }, [dispatch]);
const navigateToProject = (project_id: string | undefined, default_view: string | undefined) => {
const navigateToProject = useCallback((project_id: string | undefined, default_view: string | undefined) => {
if (project_id) { 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 navigate(
`/worklenz/projects/${project_id}?tab=${default_view === 'BOARD' ? 'board' : 'tasks-list'}&pinned_tab=${default_view === 'BOARD' ? 'board' : 'tasks-list'}`
);
} }
}; }, [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(() => { useEffect(() => {
if (projectStatuses.length === 0) dispatch(fetchProjectStatuses()); if (projectStatuses.length === 0) dispatch(fetchProjectStatuses());
if (projectCategories.length === 0) dispatch(fetchProjectCategories()); if (projectCategories.length === 0) dispatch(fetchProjectCategories());
if (projectHealths.length === 0) dispatch(fetchProjectHealth()); if (projectHealths.length === 0) dispatch(fetchProjectHealth());
}, [requestParams]); }, [dispatch, projectStatuses.length, projectCategories.length, projectHealths.length]);
return ( return (
<div style={{ marginBlock: 65, minHeight: '90vh' }}> <div style={{ marginBlock: 65, minHeight: '90vh' }}>
@@ -225,6 +290,23 @@ const ProjectList: React.FC = () => {
defaultValue={filters[getFilterIndex()] ?? filters[0]} defaultValue={filters[getFilterIndex()] ?? filters[0]}
onChange={handleSegmentChange} onChange={handleSegmentChange}
/> />
<Segmented
options={viewToggleOptions}
value={viewMode}
onChange={handleViewToggle}
style={{
backgroundColor: '#1f2937',
border: 'none',
}}
/>
{viewMode === ProjectViewType.GROUP && (
<Select
value={groupBy}
onChange={handleGroupByChange}
options={groupByOptions}
style={{ width: 150 }}
/>
)}
<Input <Input
placeholder={t('placeholder')} placeholder={t('placeholder')}
suffix={<SearchOutlined />} suffix={<SearchOutlined />}
@@ -238,25 +320,36 @@ 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">
<Table<IProjectViewModel> {viewMode === ProjectViewType.LIST ? (
columns={TableColumns({ <Table<IProjectViewModel>
navigate, columns={TableColumns({
filteredInfo, navigate,
})} filteredInfo,
dataSource={projectsData?.body?.data || []} })}
rowKey={record => record.id || ''} dataSource={projectsData?.body?.data || []}
loading={loadingProjects} rowKey={record => record.id || ''}
size="small" loading={loadingProjects}
onChange={handleTableChange} size="small"
pagination={paginationConfig} onChange={handleTableChange}
locale={{ emptyText: <Empty description={t('noProjects')} /> }} pagination={paginationConfig}
onRow={record => ({ locale={{ emptyText: <Empty description={t('noProjects')} />}}
onClick: () => navigateToProject(record.id, record.team_member_default_view), // Navigate to project on row click onRow={record => ({
})} onClick: () => navigateToProject(record.id, record.team_member_default_view),
/> })}
/>
) : (
<ProjectGroupList
groups={groupProjects(projectsData?.body?.data || [], groupBy)}
navigate={navigate}
onProjectSelect={id => navigateToProject(id, undefined)}
onArchive={() => {}}
isOwnerOrAdmin={isOwnerOrAdmin}
loading={loadingProjects}
t={t}
/>
)}
</Skeleton> </Skeleton>
</Card> </Card>
{createPortal(<ProjectDrawer onClose={handleDrawerClose} />, document.body, 'project-drawer')} {createPortal(<ProjectDrawer onClose={handleDrawerClose} />, document.body, 'project-drawer')}
@@ -264,4 +357,4 @@ const ProjectList: React.FC = () => {
); );
}; };
export default ProjectList; export default ProjectList;

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { nanoid } from 'nanoid'; import { nanoid } from "nanoid";
import { PhaseColorCodes } from '../../../../../../../../shared/constants'; import { PhaseColorCodes } from '../../../../../../../../shared/constants';
import { Button, Flex, Input, Select, Tag, Typography } from 'antd'; import { Button, Flex, Input, Select, Tag, Typography } from 'antd';
import { CloseCircleOutlined, HolderOutlined } from '@ant-design/icons'; import { CloseCircleOutlined, HolderOutlined } from '@ant-design/icons';

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { nanoid } from 'nanoid'; import { nanoid } from "nanoid";
import { PhaseColorCodes } from '../../../../../../../../shared/constants'; import { PhaseColorCodes } from '../../../../../../../../shared/constants';
import { Button, Flex, Input, Select, Tag, Typography } from 'antd'; import { Button, Flex, Input, Select, Tag, Typography } from 'antd';
import { CloseCircleOutlined, HolderOutlined } from '@ant-design/icons'; import { CloseCircleOutlined, HolderOutlined } from '@ant-design/icons';

View File

@@ -1,5 +1,10 @@
import { IProjectCategory } from '@/types/project/projectCategory.types'; import { IProjectCategory } from '@/types/project/projectCategory.types';
import { IProjectStatus } from '@/types/project/projectStatus.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 { export interface IProject {
id?: string; id?: string;
@@ -45,3 +50,102 @@ export enum IProjectFilter {
Favourites = 'Favorites', Favourites = 'Favorites',
Archived = 'Archived', 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<string, FilterValue | null>,
sorter: SorterResult<IProjectViewModel> | SorterResult<IProjectViewModel>[]
) => void;
onProjectSelect: (id: string) => void;
onArchive: (id: string) => void;
}
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 ProjectViewControlsProps {
viewState: ProjectViewState;
onViewChange: (state: ProjectViewState) => void;
availableGroupByOptions?: ProjectGroupBy[];
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;
}
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

@@ -0,0 +1,47 @@
import { GroupedProject, ProjectGroupBy } from "@/types/project/project.types";
import { IProjectViewModel } from "@/types/project/projectViewModel.types";
export const groupProjects = (
projects: IProjectViewModel[],
groupBy: ProjectGroupBy
): GroupedProject[] => {
const grouped: Record<string, GroupedProject> = {};
projects?.forEach(project => {
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,
totalTasks: 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);
};