Merge pull request #152 from OminduHirushka/upstream/feature/project-groupby
Projects - List / Group View
This commit is contained in:
6
package-lock.json
generated
Normal file
6
package-lock.json
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "worklenz",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
9131
worklenz-backend/package-lock.json
generated
9131
worklenz-backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -68,6 +68,7 @@
|
||||
"express-rate-limit": "^6.8.0",
|
||||
"express-session": "^1.17.3",
|
||||
"express-validator": "^6.15.0",
|
||||
"grunt-cli": "^1.5.0",
|
||||
"helmet": "^6.2.0",
|
||||
"hpp": "^0.2.3",
|
||||
"http-errors": "^2.0.0",
|
||||
@@ -93,8 +94,10 @@
|
||||
"sharp": "^0.32.6",
|
||||
"slugify": "^1.6.6",
|
||||
"socket.io": "^4.7.1",
|
||||
"tinymce": "^7.8.0",
|
||||
"uglify-js": "^3.17.4",
|
||||
"winston": "^3.10.0",
|
||||
"worklenz-backend": "file:",
|
||||
"xss-filters": "^1.2.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -102,15 +105,17 @@
|
||||
"@babel/preset-typescript": "^7.22.5",
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
"@types/bluebird": "^3.5.38",
|
||||
"@types/body-parser": "^1.19.2",
|
||||
"@types/compression": "^1.7.2",
|
||||
"@types/connect-flash": "^0.0.37",
|
||||
"@types/cookie-parser": "^1.4.3",
|
||||
"@types/cron": "^2.0.1",
|
||||
"@types/crypto-js": "^4.2.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-redis": "^0.0.4",
|
||||
"@types/express-serve-static-core": "^4.17.34",
|
||||
"@types/express-session": "^1.17.7",
|
||||
"@types/fs-extra": "^9.0.13",
|
||||
"@types/hpp": "^0.2.2",
|
||||
|
||||
20
worklenz-backend/src/public/tinymce/package-lock.json
generated
Normal file
20
worklenz-backend/src/public/tinymce/package-lock.json
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,5 +28,8 @@
|
||||
"homepage": "https://www.tiny.cloud/",
|
||||
"bugs": {
|
||||
"url": "https://github.com/tinymce/tinymce/issues"
|
||||
},
|
||||
"dependencies": {
|
||||
"tinymce": "file:"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1527
worklenz-frontend/package-lock.json
generated
1527
worklenz-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,8 @@
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
"start": "vite dev",
|
||||
"dev": "vite dev",
|
||||
"prebuild": "node scripts/copy-tinymce.js",
|
||||
"build": "vite build",
|
||||
"dev-build": "vite build",
|
||||
@@ -13,7 +14,7 @@
|
||||
"dependencies": {
|
||||
"@ant-design/colors": "^7.1.0",
|
||||
"@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",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
@@ -29,6 +30,7 @@
|
||||
"axios": "^1.9.0",
|
||||
"chart.js": "^4.4.7",
|
||||
"chartjs-plugin-datalabels": "^2.2.0",
|
||||
"cors": "^2.8.5",
|
||||
"date-fns": "^4.1.0",
|
||||
"dompurify": "^3.2.5",
|
||||
"gantt-task-react": "^0.3.9",
|
||||
@@ -38,6 +40,7 @@
|
||||
"i18next-http-backend": "^2.7.3",
|
||||
"jspdf": "^3.0.0",
|
||||
"mixpanel-browser": "^2.56.0",
|
||||
"nanoid": "^5.1.5",
|
||||
"primereact": "^10.8.4",
|
||||
"re-resizable": "^6.10.3",
|
||||
"react": "^18.3.1",
|
||||
@@ -52,7 +55,8 @@
|
||||
"react-window": "^1.8.11",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"tinymce": "^7.7.2",
|
||||
"web-vitals": "^4.2.4"
|
||||
"web-vitals": "^4.2.4",
|
||||
"worklenz": "file:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
@@ -70,6 +74,7 @@
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.5.2",
|
||||
"prettier-plugin-tailwindcss": "^0.6.8",
|
||||
"rollup": "^4.40.2",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"terser": "^5.39.0",
|
||||
"typescript": "^5.7.3",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
47
worklenz-frontend/src/features/project/project-view-slice.ts
Normal file
47
worklenz-frontend/src/features/project/project-view-slice.ts
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
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,
|
||||
@@ -9,13 +10,19 @@ import {
|
||||
Flex,
|
||||
Input,
|
||||
Segmented,
|
||||
Select,
|
||||
Skeleton,
|
||||
Table,
|
||||
TablePaginationConfig,
|
||||
Tooltip,
|
||||
} from 'antd';
|
||||
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 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 { 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 { groupProjects } from '@/utils/project-group';
|
||||
|
||||
const ProjectList: React.FC = () => {
|
||||
const [filteredInfo, setFilteredInfo] = useState<Record<string, FilterValue | null>>({});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const { t } = useTranslation('all-project-list');
|
||||
const dispatch = useAppDispatch();
|
||||
const navigate = useNavigate();
|
||||
@@ -63,6 +77,20 @@ const ProjectList: React.FC = () => {
|
||||
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
|
||||
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(() => {
|
||||
return +(localStorage.getItem(FILTER_INDEX_KEY) || 0);
|
||||
}, []);
|
||||
@@ -76,42 +104,69 @@ 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);
|
||||
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(loadingProjects || isFetchingProjects);
|
||||
}, [loadingProjects, isFetchingProjects]);
|
||||
const viewToggleOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
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 filterIndex = getFilterIndex();
|
||||
dispatch(setRequestParams({ filter: filterIndex }));
|
||||
}, [dispatch, getFilterIndex]);
|
||||
const groupByOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
value: ProjectGroupBy.CATEGORY,
|
||||
label: t('groupBy.category'),
|
||||
},
|
||||
{
|
||||
value: ProjectGroupBy.CLIENT,
|
||||
label: t('groupBy.client'),
|
||||
},
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
trackMixpanelEvent(evt_projects_page_visit);
|
||||
refetchProjects();
|
||||
}, [requestParams, refetchProjects]);
|
||||
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 handleTableChange = useCallback(
|
||||
(
|
||||
@@ -124,7 +179,6 @@ const ProjectList: React.FC = () => {
|
||||
newParams.statuses = null;
|
||||
dispatch(setFilteredStatuses([]));
|
||||
} else {
|
||||
// dispatch(setFilteredStatuses(filters.status_id as Array<string>));
|
||||
newParams.statuses = filters.status_id.join(' ');
|
||||
}
|
||||
|
||||
@@ -132,7 +186,6 @@ const ProjectList: React.FC = () => {
|
||||
newParams.categories = null;
|
||||
dispatch(setFilteredCategories([]));
|
||||
} else {
|
||||
// dispatch(setFilteredCategories(filters.category_id as Array<string>));
|
||||
newParams.categories = filters.category_id.join(' ');
|
||||
}
|
||||
|
||||
@@ -151,13 +204,13 @@ const ProjectList: React.FC = () => {
|
||||
dispatch(setRequestParams(newParams));
|
||||
setFilteredInfo(filters);
|
||||
},
|
||||
[setSortingValues]
|
||||
[dispatch, setSortingValues]
|
||||
);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
trackMixpanelEvent(evt_projects_refresh_click);
|
||||
refetchProjects();
|
||||
}, [refetchProjects, requestParams]);
|
||||
}, [trackMixpanelEvent, refetchProjects]);
|
||||
|
||||
const handleSegmentChange = useCallback(
|
||||
(value: IProjectFilter) => {
|
||||
@@ -166,43 +219,55 @@ const ProjectList: React.FC = () => {
|
||||
dispatch(setRequestParams({ filter: newFilterIndex }));
|
||||
refetchProjects();
|
||||
},
|
||||
[filters, setFilterIndex, refetchProjects]
|
||||
[filters, setFilterIndex, dispatch, refetchProjects]
|
||||
);
|
||||
|
||||
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
trackMixpanelEvent(evt_projects_search);
|
||||
const value = e.target.value;
|
||||
dispatch(setRequestParams({ search: value }));
|
||||
}, []);
|
||||
}, [trackMixpanelEvent, dispatch]);
|
||||
|
||||
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 handleViewToggle = useCallback((value: ProjectViewType) => {
|
||||
dispatch(setViewMode(value));
|
||||
}, [dispatch]);
|
||||
|
||||
const handleDrawerClose = () => {
|
||||
const handleGroupByChange = useCallback((value: ProjectGroupBy) => {
|
||||
dispatch(setGroupBy(value));
|
||||
}, [dispatch]);
|
||||
|
||||
const handleDrawerClose = useCallback(() => {
|
||||
dispatch(setProject({} as IProjectViewModel));
|
||||
dispatch(setProjectId(null));
|
||||
};
|
||||
const navigateToProject = (project_id: string | undefined, default_view: string | undefined) => {
|
||||
}, [dispatch]);
|
||||
|
||||
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'}`); // 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(() => {
|
||||
if (projectStatuses.length === 0) dispatch(fetchProjectStatuses());
|
||||
if (projectCategories.length === 0) dispatch(fetchProjectCategories());
|
||||
if (projectHealths.length === 0) dispatch(fetchProjectHealth());
|
||||
}, [requestParams]);
|
||||
}, [dispatch, projectStatuses.length, projectCategories.length, projectHealths.length]);
|
||||
|
||||
return (
|
||||
<div style={{ marginBlock: 65, minHeight: '90vh' }}>
|
||||
@@ -225,6 +290,23 @@ const ProjectList: React.FC = () => {
|
||||
defaultValue={filters[getFilterIndex()] ?? filters[0]}
|
||||
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
|
||||
placeholder={t('placeholder')}
|
||||
suffix={<SearchOutlined />}
|
||||
@@ -238,25 +320,36 @@ const ProjectList: React.FC = () => {
|
||||
}
|
||||
/>
|
||||
<Card className="project-card">
|
||||
<Skeleton active loading={isLoading} className='mt-4 p-4'>
|
||||
<Table<IProjectViewModel>
|
||||
columns={TableColumns({
|
||||
navigate,
|
||||
filteredInfo,
|
||||
})}
|
||||
dataSource={projectsData?.body?.data || []}
|
||||
rowKey={record => record.id || ''}
|
||||
loading={loadingProjects}
|
||||
size="small"
|
||||
onChange={handleTableChange}
|
||||
pagination={paginationConfig}
|
||||
locale={{ emptyText: <Empty description={t('noProjects')} /> }}
|
||||
onRow={record => ({
|
||||
onClick: () => navigateToProject(record.id, record.team_member_default_view), // Navigate to project on row click
|
||||
})}
|
||||
/>
|
||||
<Skeleton active loading={isLoading} className="mt-4 p-4">
|
||||
{viewMode === ProjectViewType.LIST ? (
|
||||
<Table<IProjectViewModel>
|
||||
columns={TableColumns({
|
||||
navigate,
|
||||
filteredInfo,
|
||||
})}
|
||||
dataSource={projectsData?.body?.data || []}
|
||||
rowKey={record => record.id || ''}
|
||||
loading={loadingProjects}
|
||||
size="small"
|
||||
onChange={handleTableChange}
|
||||
pagination={paginationConfig}
|
||||
locale={{ emptyText: <Empty description={t('noProjects')} />}}
|
||||
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>
|
||||
|
||||
</Card>
|
||||
|
||||
{createPortal(<ProjectDrawer onClose={handleDrawerClose} />, document.body, 'project-drawer')}
|
||||
@@ -264,4 +357,4 @@ const ProjectList: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectList;
|
||||
export default ProjectList;
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { nanoid } from "nanoid";
|
||||
import { PhaseColorCodes } from '../../../../../../../../shared/constants';
|
||||
import { Button, Flex, Input, Select, Tag, Typography } from 'antd';
|
||||
import { CloseCircleOutlined, HolderOutlined } from '@ant-design/icons';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { nanoid } from "nanoid";
|
||||
import { PhaseColorCodes } from '../../../../../../../../shared/constants';
|
||||
import { Button, Flex, Input, Select, Tag, Typography } from 'antd';
|
||||
import { CloseCircleOutlined, HolderOutlined } from '@ant-design/icons';
|
||||
|
||||
@@ -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,102 @@ 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<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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
47
worklenz-frontend/src/utils/project-group.ts
Normal file
47
worklenz-frontend/src/utils/project-group.ts
Normal 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);
|
||||
};
|
||||
Reference in New Issue
Block a user