current updates

This commit is contained in:
Omindu Hirushka
2025-06-06 09:47:42 +05:30
parent 0e67434515
commit 585a65be31
5 changed files with 531 additions and 92 deletions

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -1,3 +1,4 @@
// Updated project-list.tsx
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
@@ -13,9 +14,15 @@ import {
Table,
TablePaginationConfig,
Tooltip,
Select,
} from 'antd';
import { PageHeader } from '@ant-design/pro-components';
import { SearchOutlined, SyncOutlined, UnorderedListOutlined, AppstoreOutlined } from '@ant-design/icons';
import {
SearchOutlined,
SyncOutlined,
UnorderedListOutlined,
AppstoreOutlined,
} from '@ant-design/icons';
import type { FilterValue, SorterResult } from 'antd/es/table/interface';
import ProjectDrawer from '@/components/projects/project-drawer/project-drawer';
@@ -31,7 +38,7 @@ import {
PROJECT_SORT_FIELD,
PROJECT_SORT_ORDER,
} from '@/shared/constants';
import { IProjectFilter } from '@/types/project/project.types';
import { IProjectFilter, ProjectGroupBy } from '@/types/project/project.types';
import { IProjectViewModel } from '@/types/project/projectViewModel.types';
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
@@ -50,20 +57,47 @@ import { fetchProjectHealth } from '@/features/projects/lookups/projectHealth/pr
import { setProjectId, setStatuses } from '@/features/project/project.slice';
import { setProject } from '@/features/project/project.slice';
import { createPortal } from 'react-dom';
import { evt_projects_page_visit, evt_projects_refresh_click, evt_projects_search } from '@/shared/worklenz-analytics-events';
import {
evt_projects_page_visit,
evt_projects_refresh_click,
evt_projects_search,
} from '@/shared/worklenz-analytics-events';
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
import ProjectGroupList from '@/components/project-list/project-group/project-group-list';
import { groupProjectsByCategory, groupProjectsByClient } from '@/utils/project-group';
const ProjectList: React.FC = () => {
const [filteredInfo, setFilteredInfo] = useState<Record<string, FilterValue | null>>({});
const [isLoading, setIsLoading] = useState(false);
const [viewMode, setViewMode] = useState<'list' | 'group'>('list');
// All hooks must be called at the top level, in the same order every time
const { t } = useTranslation('all-project-list');
const dispatch = useAppDispatch();
const navigate = useNavigate();
useDocumentTitle('Projects');
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
const { trackMixpanelEvent } = useMixpanelTracking();
// State hooks
const [filteredInfo, setFilteredInfo] = useState<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(() => {
return +(localStorage.getItem(FILTER_INDEX_KEY) || 0);
}, []);
@@ -77,51 +111,80 @@ const ProjectList: React.FC = () => {
localStorage.setItem(PROJECT_SORT_ORDER, order);
}, []);
const { requestParams } = useAppSelector(state => state.projectsReducer);
const { projectStatuses } = useAppSelector(state => state.projectStatusesReducer);
const { projectHealths } = useAppSelector(state => state.projectHealthReducer);
const { projectCategories } = useAppSelector(state => state.projectCategoriesReducer);
const {
data: projectsData,
isLoading: loadingProjects,
isFetching: isFetchingProjects,
refetch: refetchProjects,
} = useGetProjectsQuery(requestParams);
// Memoized values
const filters = useMemo(() => Object.values(IProjectFilter), []);
// Create translated segment options for the filters
const segmentOptions = useMemo(() => {
return filters.map(filter => ({
value: filter,
label: t(filter.toLowerCase())
label: t(filter.toLowerCase()),
}));
}, [filters, t]);
// Toggle options for List/Group view
const viewToggleOptions = useMemo(() => [
{
value: 'list' as const,
label: (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<UnorderedListOutlined />
List
</div>
)
},
{
value: 'group' as const,
label: (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<AppstoreOutlined />
Group
</div>
)
}
], []);
const viewToggleOptions = useMemo(
() => [
{
value: 'list' as const,
label: (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<UnorderedListOutlined />
List
</div>
),
},
{
value: 'group' as const,
label: (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<AppstoreOutlined />
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(() => {
setIsLoading(loadingProjects || isFetchingProjects);
}, [loadingProjects, isFetchingProjects]);
@@ -134,8 +197,15 @@ const ProjectList: React.FC = () => {
useEffect(() => {
trackMixpanelEvent(evt_projects_page_visit);
refetchProjects();
}, [requestParams, refetchProjects]);
}, [requestParams, refetchProjects, trackMixpanelEvent]);
useEffect(() => {
if (projectStatuses.length === 0) dispatch(fetchProjectStatuses());
if (projectCategories.length === 0) dispatch(fetchProjectCategories());
if (projectHealths.length === 0) dispatch(fetchProjectHealth());
}, [dispatch, projectStatuses.length, projectCategories.length, projectHealths.length]);
// Event handlers
const handleTableChange = useCallback(
(
newPagination: TablePaginationConfig,
@@ -147,7 +217,6 @@ const ProjectList: React.FC = () => {
newParams.statuses = null;
dispatch(setFilteredStatuses([]));
} else {
// dispatch(setFilteredStatuses(filters.status_id as Array<string>));
newParams.statuses = filters.status_id.join(' ');
}
@@ -155,7 +224,6 @@ const ProjectList: React.FC = () => {
newParams.categories = null;
dispatch(setFilteredCategories([]));
} else {
// dispatch(setFilteredCategories(filters.category_id as Array<string>));
newParams.categories = filters.category_id.join(' ');
}
@@ -174,13 +242,13 @@ const ProjectList: React.FC = () => {
dispatch(setRequestParams(newParams));
setFilteredInfo(filters);
},
[setSortingValues]
[dispatch, setSortingValues, requestParams]
);
const handleRefresh = useCallback(() => {
trackMixpanelEvent(evt_projects_refresh_click);
refetchProjects();
}, [refetchProjects, requestParams]);
}, [refetchProjects, trackMixpanelEvent]);
const handleSegmentChange = useCallback(
(value: IProjectFilter) => {
@@ -189,48 +257,35 @@ const ProjectList: React.FC = () => {
dispatch(setRequestParams({ filter: newFilterIndex }));
refetchProjects();
},
[filters, setFilterIndex, refetchProjects]
[filters, setFilterIndex, dispatch, refetchProjects]
);
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
trackMixpanelEvent(evt_projects_search);
const value = e.target.value;
dispatch(setRequestParams({ search: value }));
}, []);
}, [dispatch, trackMixpanelEvent]);
const handleViewToggle = useCallback((value: 'list' | 'group') => {
setViewMode(value);
}, []);
const paginationConfig = useMemo(
() => ({
current: requestParams.index,
pageSize: requestParams.size,
showSizeChanger: true,
defaultPageSize: DEFAULT_PAGE_SIZE,
pageSizeOptions: PAGE_SIZE_OPTIONS,
size: 'small' as const,
total: projectsData?.body?.total,
}),
[requestParams.index, requestParams.size, projectsData?.body?.total]
);
const handleGroupByChange = useCallback((value: ProjectGroupBy) => {
setGroupBy(value);
}, []);
const handleDrawerClose = () => {
const handleDrawerClose = useCallback(() => {
dispatch(setProject({} as IProjectViewModel));
dispatch(setProjectId(null));
};
const navigateToProject = (project_id: string | undefined, default_view: string | undefined) => {
if (project_id) {
navigate(`/worklenz/projects/${project_id}?tab=${default_view === 'BOARD' ? 'board' : 'tasks-list'}&pinned_tab=${default_view === 'BOARD' ? 'board' : 'tasks-list'}`); // Update the route as per your project structure
}
};
}, [dispatch]);
useEffect(() => {
if (projectStatuses.length === 0) dispatch(fetchProjectStatuses());
if (projectCategories.length === 0) dispatch(fetchProjectCategories());
if (projectHealths.length === 0) dispatch(fetchProjectHealth());
}, [requestParams]);
const navigateToProject = useCallback((project_id: string | undefined, default_view: string | undefined) => {
if (project_id) {
navigate(
`/worklenz/projects/${project_id}?tab=${default_view === 'BOARD' ? 'board' : 'tasks-list'}&pinned_tab=${default_view === 'BOARD' ? 'board' : 'tasks-list'}`
);
}
}, [navigate]);
return (
<div style={{ marginBlock: 65, minHeight: '90vh' }}>
@@ -262,6 +317,15 @@ const ProjectList: React.FC = () => {
border: 'none',
}}
/>
{viewMode === 'group' && (
<Select
value={groupBy}
onChange={handleGroupByChange}
options={groupByOptions}
style={{ width: 120 }}
size="middle"
/>
)}
<Input
placeholder={t('placeholder')}
suffix={<SearchOutlined />}
@@ -275,25 +339,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 === '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={groupedProjects}
navigate={navigate}
onProjectSelect={id => navigateToProject(id, undefined)}
onArchive={() => {}}
isOwnerOrAdmin={isOwnerOrAdmin}
loading={loadingProjects}
t={t}
/>
)}
</Skeleton>
</Card>
{createPortal(<ProjectDrawer onClose={handleDrawerClose} />, document.body, 'project-drawer')}

View File

@@ -1,5 +1,10 @@
import { IProjectCategory } from '@/types/project/projectCategory.types';
import { IProjectStatus } from '@/types/project/projectStatus.types';
import { IProjectViewModel } from './projectViewModel.types';
import { NavigateFunction } from 'react-router-dom';
import { AppDispatch } from '@/app/store';
import { TablePaginationConfig } from 'antd';
import { FilterValue, SorterResult } from 'antd/es/table/interface';
export interface IProject {
id?: string;
@@ -45,3 +50,105 @@ export enum IProjectFilter {
Favourites = 'Favorites',
Archived = 'Archived',
}
export interface ProjectNameCellProps {
record: IProjectViewModel;
navigate: NavigateFunction;
}
export interface CategoryCellProps {
record: IProjectViewModel;
}
export interface ActionButtonsProps {
t: (key: string) => string;
record: IProjectViewModel;
setProjectId: (id: string) => void;
dispatch: AppDispatch;
isOwnerOrAdmin: boolean;
}
export interface TableColumnsProps {
navigate: NavigateFunction;
statuses: IProjectStatus[];
categories: IProjectCategory[];
setProjectId: (id: string) => void;
}
export interface ProjectListTableProps {
loading: boolean;
projects: IProjectViewModel[];
statuses: IProjectStatus[];
categories: IProjectCategory[];
pagination: TablePaginationConfig;
onTableChange: (
pagination: TablePaginationConfig,
filters: Record<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;
}

View 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);
};