current updates
This commit is contained in:
@@ -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<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}
|
||||||
|
<Text type="secondary" className="group-stats">
|
||||||
|
({group.count} projects • {group.averageProgress}% avg • {group.totalTasks} tasks)
|
||||||
|
</Text>
|
||||||
|
</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;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// 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';
|
||||||
@@ -13,9 +14,15 @@ import {
|
|||||||
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 { 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 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';
|
||||||
@@ -31,7 +38,7 @@ import {
|
|||||||
PROJECT_SORT_FIELD,
|
PROJECT_SORT_FIELD,
|
||||||
PROJECT_SORT_ORDER,
|
PROJECT_SORT_ORDER,
|
||||||
} from '@/shared/constants';
|
} 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 { IProjectViewModel } from '@/types/project/projectViewModel.types';
|
||||||
|
|
||||||
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
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 { 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 { groupProjectsByCategory, groupProjectsByClient } from '@/utils/project-group';
|
||||||
|
|
||||||
const ProjectList: React.FC = () => {
|
const ProjectList: React.FC = () => {
|
||||||
const [filteredInfo, setFilteredInfo] = useState<Record<string, FilterValue | null>>({});
|
// All hooks must be called at the top level, in the same order every time
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [viewMode, setViewMode] = useState<'list' | 'group'>('list');
|
|
||||||
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
|
||||||
|
const [filteredInfo, setFilteredInfo] = useState<Record<string, FilterValue | null>>({});
|
||||||
|
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 { 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(() => {
|
const getFilterIndex = useCallback(() => {
|
||||||
return +(localStorage.getItem(FILTER_INDEX_KEY) || 0);
|
return +(localStorage.getItem(FILTER_INDEX_KEY) || 0);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -77,51 +111,80 @@ const ProjectList: React.FC = () => {
|
|||||||
localStorage.setItem(PROJECT_SORT_ORDER, order);
|
localStorage.setItem(PROJECT_SORT_ORDER, order);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const { requestParams } = useAppSelector(state => state.projectsReducer);
|
// Memoized values
|
||||||
|
|
||||||
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
|
// 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]);
|
||||||
|
|
||||||
// Toggle options for List/Group view
|
// Toggle options for List/Group view
|
||||||
const viewToggleOptions = useMemo(() => [
|
const viewToggleOptions = useMemo(
|
||||||
{
|
() => [
|
||||||
value: 'list' as const,
|
{
|
||||||
label: (
|
value: 'list' as const,
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
label: (
|
||||||
<UnorderedListOutlined />
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
List
|
<UnorderedListOutlined />
|
||||||
</div>
|
List
|
||||||
)
|
</div>
|
||||||
},
|
),
|
||||||
{
|
},
|
||||||
value: 'group' as const,
|
{
|
||||||
label: (
|
value: 'group' as const,
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
label: (
|
||||||
<AppstoreOutlined />
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
Group
|
<AppstoreOutlined />
|
||||||
</div>
|
Group
|
||||||
)
|
</div>
|
||||||
}
|
),
|
||||||
], []);
|
},
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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(() => {
|
useEffect(() => {
|
||||||
setIsLoading(loadingProjects || isFetchingProjects);
|
setIsLoading(loadingProjects || isFetchingProjects);
|
||||||
}, [loadingProjects, isFetchingProjects]);
|
}, [loadingProjects, isFetchingProjects]);
|
||||||
@@ -134,8 +197,15 @@ const ProjectList: React.FC = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
trackMixpanelEvent(evt_projects_page_visit);
|
trackMixpanelEvent(evt_projects_page_visit);
|
||||||
refetchProjects();
|
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(
|
const handleTableChange = useCallback(
|
||||||
(
|
(
|
||||||
newPagination: TablePaginationConfig,
|
newPagination: TablePaginationConfig,
|
||||||
@@ -147,7 +217,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(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,7 +224,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(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,13 +242,13 @@ const ProjectList: React.FC = () => {
|
|||||||
dispatch(setRequestParams(newParams));
|
dispatch(setRequestParams(newParams));
|
||||||
setFilteredInfo(filters);
|
setFilteredInfo(filters);
|
||||||
},
|
},
|
||||||
[setSortingValues]
|
[dispatch, setSortingValues, requestParams]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRefresh = useCallback(() => {
|
const handleRefresh = useCallback(() => {
|
||||||
trackMixpanelEvent(evt_projects_refresh_click);
|
trackMixpanelEvent(evt_projects_refresh_click);
|
||||||
refetchProjects();
|
refetchProjects();
|
||||||
}, [refetchProjects, requestParams]);
|
}, [refetchProjects, trackMixpanelEvent]);
|
||||||
|
|
||||||
const handleSegmentChange = useCallback(
|
const handleSegmentChange = useCallback(
|
||||||
(value: IProjectFilter) => {
|
(value: IProjectFilter) => {
|
||||||
@@ -189,48 +257,35 @@ 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 }));
|
||||||
}, []);
|
}, [dispatch, trackMixpanelEvent]);
|
||||||
|
|
||||||
const handleViewToggle = useCallback((value: 'list' | 'group') => {
|
const handleViewToggle = useCallback((value: 'list' | 'group') => {
|
||||||
setViewMode(value);
|
setViewMode(value);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const paginationConfig = useMemo(
|
const handleGroupByChange = useCallback((value: ProjectGroupBy) => {
|
||||||
() => ({
|
setGroupBy(value);
|
||||||
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 handleDrawerClose = () => {
|
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(() => {
|
|
||||||
if (projectStatuses.length === 0) dispatch(fetchProjectStatuses());
|
|
||||||
if (projectCategories.length === 0) dispatch(fetchProjectCategories());
|
|
||||||
if (projectHealths.length === 0) dispatch(fetchProjectHealth());
|
|
||||||
}, [requestParams]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ marginBlock: 65, minHeight: '90vh' }}>
|
<div style={{ marginBlock: 65, minHeight: '90vh' }}>
|
||||||
@@ -262,6 +317,15 @@ const ProjectList: React.FC = () => {
|
|||||||
border: 'none',
|
border: 'none',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{viewMode === 'group' && (
|
||||||
|
<Select
|
||||||
|
value={groupBy}
|
||||||
|
onChange={handleGroupByChange}
|
||||||
|
options={groupByOptions}
|
||||||
|
style={{ width: 120 }}
|
||||||
|
size="middle"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Input
|
<Input
|
||||||
placeholder={t('placeholder')}
|
placeholder={t('placeholder')}
|
||||||
suffix={<SearchOutlined />}
|
suffix={<SearchOutlined />}
|
||||||
@@ -275,25 +339,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 === '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={groupedProjects}
|
||||||
|
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')}
|
||||||
|
|||||||
@@ -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,105 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
69
worklenz-frontend/src/utils/project-group.ts
Normal file
69
worklenz-frontend/src/utils/project-group.ts
Normal file
@@ -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<string, GroupedProject> = {};
|
||||||
|
|
||||||
|
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<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);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user