feat(projects): implement grouped project retrieval and UI enhancements
- Added a new endpoint for retrieving projects grouped by category, client, or status. - Enhanced the ProjectsController with a method to handle grouped project queries. - Updated the projects API router to include the new grouped endpoint. - Improved the frontend to support displaying grouped projects with pagination and filtering options. - Updated localization files for English, Spanish, and Portuguese to include new grouping options. - Refactored project list components to accommodate the new grouped view and improved UI elements.
This commit is contained in:
@@ -3,12 +3,14 @@ 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 debounce from 'lodash/debounce';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Empty,
|
||||
Flex,
|
||||
Input,
|
||||
Pagination,
|
||||
Segmented,
|
||||
Select,
|
||||
Skeleton,
|
||||
@@ -27,7 +29,16 @@ import type { FilterValue, SorterResult } from 'antd/es/table/interface';
|
||||
|
||||
import ProjectDrawer from '@/components/projects/project-drawer/project-drawer';
|
||||
import CreateProjectButton from '@/components/projects/project-create-button/project-create-button';
|
||||
import TableColumns from '@/components/project-list/TableColumns';
|
||||
import { ColumnsType } from 'antd/es/table';
|
||||
import { ColumnFilterItem } from 'antd/es/table/interface';
|
||||
import Avatars from '@/components/avatars/avatars';
|
||||
import { ActionButtons } from '@/components/project-list/project-list-table/project-list-actions/project-list-actions';
|
||||
import { CategoryCell } from '@/components/project-list/project-list-table/project-list-category/project-list-category';
|
||||
import { ProgressListProgress } from '@/components/project-list/project-list-table/project-list-progress/progress-list-progress';
|
||||
import { ProjectListUpdatedAt } from '@/components/project-list/project-list-table/project-list-updated-at/project-list-updated';
|
||||
import { ProjectNameCell } from '@/components/project-list/project-list-table/project-name/project-name-cell';
|
||||
import { ProjectRateCell } from '@/components/project-list/project-list-table/project-list-favorite/project-rate-cell';
|
||||
import { InlineMember } from '@/types/teamMembers/inlineMember.types';
|
||||
|
||||
import { useGetProjectsQuery } from '@/api/projects/projects.v1.api.service';
|
||||
|
||||
@@ -50,6 +61,8 @@ import {
|
||||
setFilteredCategories,
|
||||
setFilteredStatuses,
|
||||
setRequestParams,
|
||||
setGroupedRequestParams,
|
||||
fetchGroupedProjects,
|
||||
} from '@/features/projects/projectsSlice';
|
||||
import { fetchProjectStatuses } from '@/features/projects/lookups/projectStatuses/projectStatusesSlice';
|
||||
import { fetchProjectCategories } from '@/features/projects/lookups/projectCategories/projectCategoriesSlice';
|
||||
@@ -66,6 +79,9 @@ import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||
import ProjectGroupList from '@/components/project-list/project-group/project-group-list';
|
||||
import { groupProjects } from '@/utils/project-group';
|
||||
|
||||
const createFilters = (items: { id: string; name: string }[]) =>
|
||||
items.map(item => ({ text: item.name, value: item.id })) as ColumnFilterItem[];
|
||||
|
||||
const ProjectList: React.FC = () => {
|
||||
const [filteredInfo, setFilteredInfo] = useState<Record<string, FilterValue | null>>({});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -79,10 +95,13 @@ const ProjectList: React.FC = () => {
|
||||
|
||||
// Get view state from Redux
|
||||
const { mode: viewMode, groupBy } = useAppSelector((state) => state.projectViewReducer);
|
||||
const { requestParams } = useAppSelector(state => state.projectsReducer);
|
||||
const { requestParams, groupedRequestParams, groupedProjects } = useAppSelector(state => state.projectsReducer);
|
||||
const { projectStatuses } = useAppSelector(state => state.projectStatusesReducer);
|
||||
const { projectHealths } = useAppSelector(state => state.projectHealthReducer);
|
||||
const { projectCategories } = useAppSelector(state => state.projectCategoriesReducer);
|
||||
const { filteredCategories, filteredStatuses } = useAppSelector(
|
||||
state => state.projectsReducer
|
||||
);
|
||||
|
||||
const {
|
||||
data: projectsData,
|
||||
@@ -155,6 +174,22 @@ const ProjectList: React.FC = () => {
|
||||
[t]
|
||||
);
|
||||
|
||||
// Memoize category filters to prevent unnecessary recalculations
|
||||
const categoryFilters = useMemo(() =>
|
||||
createFilters(
|
||||
projectCategories.map(category => ({ id: category.id || '', name: category.name || '' }))
|
||||
),
|
||||
[projectCategories]
|
||||
);
|
||||
|
||||
// Memoize status filters to prevent unnecessary recalculations
|
||||
const statusFilters = useMemo(() =>
|
||||
createFilters(
|
||||
projectStatuses.map(status => ({ id: status.id || '', name: status.name || '' }))
|
||||
),
|
||||
[projectStatuses]
|
||||
);
|
||||
|
||||
const paginationConfig = useMemo(
|
||||
() => ({
|
||||
current: requestParams.index,
|
||||
@@ -168,6 +203,60 @@ const ProjectList: React.FC = () => {
|
||||
[requestParams.index, requestParams.size, projectsData?.body?.total]
|
||||
);
|
||||
|
||||
const groupedPaginationConfig = useMemo(
|
||||
() => ({
|
||||
current: groupedRequestParams.index,
|
||||
pageSize: groupedRequestParams.size,
|
||||
showSizeChanger: true,
|
||||
defaultPageSize: DEFAULT_PAGE_SIZE,
|
||||
pageSizeOptions: PAGE_SIZE_OPTIONS,
|
||||
size: 'small' as const,
|
||||
total: groupedProjects.data?.total_groups || 0,
|
||||
}),
|
||||
[groupedRequestParams.index, groupedRequestParams.size, groupedProjects.data?.total_groups]
|
||||
);
|
||||
|
||||
// Memoize the project count calculation for the header
|
||||
const projectCount = useMemo(() => {
|
||||
if (viewMode === ProjectViewType.LIST) {
|
||||
return projectsData?.body?.total || 0;
|
||||
} else {
|
||||
return groupedProjects.data?.data?.reduce((total, group) => total + group.project_count, 0) || 0;
|
||||
}
|
||||
}, [viewMode, projectsData?.body?.total, groupedProjects.data?.data]);
|
||||
|
||||
// Memoize the grouped projects data transformation
|
||||
const transformedGroupedProjects = useMemo(() => {
|
||||
return groupedProjects.data?.data?.map(group => ({
|
||||
groupKey: group.group_key,
|
||||
groupName: group.group_name,
|
||||
groupColor: group.group_color,
|
||||
projects: group.projects,
|
||||
count: group.project_count,
|
||||
totalProgress: 0,
|
||||
totalTasks: 0
|
||||
})) || [];
|
||||
}, [groupedProjects.data?.data]);
|
||||
|
||||
// Memoize the table data source
|
||||
const tableDataSource = useMemo(() =>
|
||||
projectsData?.body?.data || [],
|
||||
[projectsData?.body?.data]
|
||||
);
|
||||
|
||||
// Memoize the empty text component
|
||||
const emptyText = useMemo(() =>
|
||||
<Empty description={t('noProjects')} />,
|
||||
[t]
|
||||
);
|
||||
|
||||
// Memoize the pagination show total function
|
||||
const paginationShowTotal = useMemo(() =>
|
||||
(total: number, range: [number, number]) =>
|
||||
`${range[0]}-${range[1]} of ${total} groups`,
|
||||
[]
|
||||
);
|
||||
|
||||
const handleTableChange = useCallback(
|
||||
(
|
||||
newPagination: TablePaginationConfig,
|
||||
@@ -202,39 +291,138 @@ const ProjectList: React.FC = () => {
|
||||
newParams.size = newPagination.pageSize || DEFAULT_PAGE_SIZE;
|
||||
|
||||
dispatch(setRequestParams(newParams));
|
||||
|
||||
// Also update grouped request params to keep them in sync
|
||||
dispatch(setGroupedRequestParams({
|
||||
...groupedRequestParams,
|
||||
statuses: newParams.statuses,
|
||||
categories: newParams.categories,
|
||||
order: newParams.order,
|
||||
field: newParams.field,
|
||||
index: newParams.index,
|
||||
size: newParams.size,
|
||||
}));
|
||||
|
||||
setFilteredInfo(filters);
|
||||
},
|
||||
[dispatch, setSortingValues]
|
||||
[dispatch, setSortingValues, groupedRequestParams]
|
||||
);
|
||||
|
||||
const handleGroupedTableChange = useCallback(
|
||||
(newPagination: TablePaginationConfig) => {
|
||||
const newParams: Partial<typeof groupedRequestParams> = {
|
||||
index: newPagination.current || 1,
|
||||
size: newPagination.pageSize || DEFAULT_PAGE_SIZE,
|
||||
};
|
||||
dispatch(setGroupedRequestParams(newParams));
|
||||
},
|
||||
[dispatch, groupedRequestParams]
|
||||
);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
trackMixpanelEvent(evt_projects_refresh_click);
|
||||
refetchProjects();
|
||||
}, [trackMixpanelEvent, refetchProjects]);
|
||||
if (viewMode === ProjectViewType.LIST) {
|
||||
refetchProjects();
|
||||
} else if (viewMode === ProjectViewType.GROUP && groupBy) {
|
||||
dispatch(fetchGroupedProjects(groupedRequestParams));
|
||||
}
|
||||
}, [trackMixpanelEvent, refetchProjects, viewMode, groupBy, dispatch, groupedRequestParams]);
|
||||
|
||||
const handleSegmentChange = useCallback(
|
||||
(value: IProjectFilter) => {
|
||||
const newFilterIndex = filters.indexOf(value);
|
||||
setFilterIndex(newFilterIndex);
|
||||
|
||||
// Update both request params for consistency
|
||||
dispatch(setRequestParams({ filter: newFilterIndex }));
|
||||
refetchProjects();
|
||||
dispatch(setGroupedRequestParams({
|
||||
...groupedRequestParams,
|
||||
filter: newFilterIndex,
|
||||
index: 1 // Reset to first page when changing filter
|
||||
}));
|
||||
|
||||
// Refresh data based on current view mode
|
||||
if (viewMode === ProjectViewType.LIST) {
|
||||
refetchProjects();
|
||||
} else if (viewMode === ProjectViewType.GROUP && groupBy) {
|
||||
dispatch(fetchGroupedProjects({
|
||||
...groupedRequestParams,
|
||||
filter: newFilterIndex,
|
||||
index: 1
|
||||
}));
|
||||
}
|
||||
},
|
||||
[filters, setFilterIndex, dispatch, refetchProjects]
|
||||
[filters, setFilterIndex, dispatch, refetchProjects, viewMode, groupBy, groupedRequestParams]
|
||||
);
|
||||
|
||||
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
trackMixpanelEvent(evt_projects_search);
|
||||
const value = e.target.value;
|
||||
dispatch(setRequestParams({ search: value }));
|
||||
}, [trackMixpanelEvent, dispatch]);
|
||||
// Debounced search for grouped projects
|
||||
const debouncedGroupedSearch = useCallback(
|
||||
debounce((params: typeof groupedRequestParams) => {
|
||||
if (groupBy) {
|
||||
dispatch(fetchGroupedProjects(params));
|
||||
}
|
||||
}, 300),
|
||||
[dispatch, groupBy]
|
||||
);
|
||||
|
||||
const handleViewToggle = useCallback((value: ProjectViewType) => {
|
||||
dispatch(setViewMode(value));
|
||||
}, [dispatch]);
|
||||
const handleSearchChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const searchValue = e.target.value;
|
||||
trackMixpanelEvent(evt_projects_search);
|
||||
|
||||
// Update both request params for consistency
|
||||
dispatch(setRequestParams({ search: searchValue, index: 1 }));
|
||||
|
||||
if (viewMode === ProjectViewType.GROUP) {
|
||||
const newGroupedParams = {
|
||||
...groupedRequestParams,
|
||||
search: searchValue,
|
||||
index: 1
|
||||
};
|
||||
dispatch(setGroupedRequestParams(newGroupedParams));
|
||||
|
||||
// Trigger debounced search in group mode
|
||||
debouncedGroupedSearch(newGroupedParams);
|
||||
}
|
||||
},
|
||||
[dispatch, trackMixpanelEvent, viewMode, groupedRequestParams, debouncedGroupedSearch]
|
||||
);
|
||||
|
||||
const handleGroupByChange = useCallback((value: ProjectGroupBy) => {
|
||||
dispatch(setGroupBy(value));
|
||||
}, [dispatch]);
|
||||
const handleViewToggle = useCallback(
|
||||
(value: ProjectViewType) => {
|
||||
dispatch(setViewMode(value));
|
||||
if (value === ProjectViewType.GROUP) {
|
||||
// Initialize grouped request params when switching to group view
|
||||
const newGroupedParams = {
|
||||
...groupedRequestParams,
|
||||
groupBy: groupBy || ProjectGroupBy.CATEGORY,
|
||||
search: requestParams.search,
|
||||
filter: requestParams.filter,
|
||||
statuses: requestParams.statuses,
|
||||
categories: requestParams.categories,
|
||||
};
|
||||
dispatch(setGroupedRequestParams(newGroupedParams));
|
||||
// Fetch grouped data immediately
|
||||
dispatch(fetchGroupedProjects(newGroupedParams));
|
||||
}
|
||||
},
|
||||
[dispatch, groupBy, groupedRequestParams, requestParams]
|
||||
);
|
||||
|
||||
const handleGroupByChange = useCallback(
|
||||
(value: ProjectGroupBy) => {
|
||||
dispatch(setGroupBy(value));
|
||||
const newGroupedParams = {
|
||||
...groupedRequestParams,
|
||||
groupBy: value,
|
||||
index: 1, // Reset to first page when changing grouping
|
||||
};
|
||||
dispatch(setGroupedRequestParams(newGroupedParams));
|
||||
// Fetch new grouped data
|
||||
dispatch(fetchGroupedProjects(newGroupedParams));
|
||||
},
|
||||
[dispatch, groupedRequestParams]
|
||||
);
|
||||
|
||||
const handleDrawerClose = useCallback(() => {
|
||||
dispatch(setProject({} as IProjectViewModel));
|
||||
@@ -249,19 +437,131 @@ const ProjectList: React.FC = () => {
|
||||
}
|
||||
}, [navigate]);
|
||||
|
||||
// Define table columns directly in the component to avoid hooks order issues
|
||||
const tableColumns: ColumnsType<IProjectViewModel> = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: '',
|
||||
dataIndex: 'favorite',
|
||||
key: 'favorite',
|
||||
render: (text: string, record: IProjectViewModel) => (
|
||||
<ProjectRateCell key={record.id} t={t} record={record} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('name'),
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
sorter: true,
|
||||
showSorterTooltip: false,
|
||||
defaultSortOrder: 'ascend',
|
||||
render: (text: string, record: IProjectViewModel) => (
|
||||
<ProjectNameCell navigate={navigate} key={record.id} t={t} record={record} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('client'),
|
||||
dataIndex: 'client_name',
|
||||
key: 'client_name',
|
||||
sorter: true,
|
||||
showSorterTooltip: false,
|
||||
},
|
||||
{
|
||||
title: t('category'),
|
||||
dataIndex: 'category',
|
||||
key: 'category_id',
|
||||
filters: categoryFilters,
|
||||
filteredValue: filteredInfo.category_id || filteredCategories || [],
|
||||
filterMultiple: true,
|
||||
render: (text: string, record: IProjectViewModel) => (
|
||||
<CategoryCell key={record.id} t={t} record={record} />
|
||||
),
|
||||
sorter: true,
|
||||
},
|
||||
{
|
||||
title: t('status'),
|
||||
dataIndex: 'status',
|
||||
key: 'status_id',
|
||||
filters: statusFilters,
|
||||
filteredValue: filteredInfo.status_id || [],
|
||||
filterMultiple: true,
|
||||
sorter: true,
|
||||
},
|
||||
{
|
||||
title: t('tasksProgress'),
|
||||
dataIndex: 'tasksProgress',
|
||||
key: 'tasksProgress',
|
||||
render: (_: string, record: IProjectViewModel) => <ProgressListProgress record={record} />,
|
||||
},
|
||||
{
|
||||
title: t('updated_at'),
|
||||
dataIndex: 'updated_at',
|
||||
key: 'updated_at',
|
||||
sorter: true,
|
||||
showSorterTooltip: false,
|
||||
render: (_: string, record: IProjectViewModel) => <ProjectListUpdatedAt record={record} />,
|
||||
},
|
||||
{
|
||||
title: t('members'),
|
||||
dataIndex: 'names',
|
||||
key: 'members',
|
||||
render: (members: InlineMember[]) => <Avatars members={members} />,
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
key: 'button',
|
||||
dataIndex: '',
|
||||
render: (record: IProjectViewModel) => (
|
||||
<ActionButtons
|
||||
t={t}
|
||||
record={record}
|
||||
dispatch={dispatch}
|
||||
isOwnerOrAdmin={isOwnerOrAdmin}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
[t, categoryFilters, statusFilters, filteredInfo, filteredCategories, filteredStatuses, navigate, dispatch, isOwnerOrAdmin]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(loadingProjects || isFetchingProjects);
|
||||
}, [loadingProjects, isFetchingProjects]);
|
||||
if (viewMode === ProjectViewType.LIST) {
|
||||
setIsLoading(loadingProjects || isFetchingProjects);
|
||||
} else {
|
||||
setIsLoading(groupedProjects.loading);
|
||||
}
|
||||
}, [loadingProjects, isFetchingProjects, viewMode, groupedProjects.loading]);
|
||||
|
||||
useEffect(() => {
|
||||
const filterIndex = getFilterIndex();
|
||||
dispatch(setRequestParams({ filter: filterIndex }));
|
||||
// Also sync with grouped request params on initial load
|
||||
dispatch(setGroupedRequestParams({
|
||||
filter: filterIndex,
|
||||
index: 1,
|
||||
size: DEFAULT_PAGE_SIZE,
|
||||
field: 'name',
|
||||
order: 'ascend',
|
||||
search: '',
|
||||
groupBy: '',
|
||||
statuses: null,
|
||||
categories: null,
|
||||
}));
|
||||
}, [dispatch, getFilterIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
trackMixpanelEvent(evt_projects_page_visit);
|
||||
refetchProjects();
|
||||
}, [requestParams, refetchProjects, trackMixpanelEvent]);
|
||||
if (viewMode === ProjectViewType.LIST) {
|
||||
refetchProjects();
|
||||
}
|
||||
}, [requestParams, refetchProjects, trackMixpanelEvent, viewMode]);
|
||||
|
||||
// Separate useEffect for grouped projects
|
||||
useEffect(() => {
|
||||
if (viewMode === ProjectViewType.GROUP && groupBy) {
|
||||
dispatch(fetchGroupedProjects(groupedRequestParams));
|
||||
}
|
||||
}, [dispatch, viewMode, groupBy, groupedRequestParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (projectStatuses.length === 0) dispatch(fetchProjectStatuses());
|
||||
@@ -273,7 +573,7 @@ const ProjectList: React.FC = () => {
|
||||
<div style={{ marginBlock: 65, minHeight: '90vh' }}>
|
||||
<PageHeader
|
||||
className="site-page-header"
|
||||
title={`${projectsData?.body?.total || 0} ${t('projects')}`}
|
||||
title={`${projectCount} ${t('projects')}`}
|
||||
style={{ padding: '16px 0' }}
|
||||
extra={
|
||||
<Flex gap={8} align="center">
|
||||
@@ -294,10 +594,6 @@ const ProjectList: React.FC = () => {
|
||||
options={viewToggleOptions}
|
||||
value={viewMode}
|
||||
onChange={handleViewToggle}
|
||||
style={{
|
||||
backgroundColor: '#1f2937',
|
||||
border: 'none',
|
||||
}}
|
||||
/>
|
||||
{viewMode === ProjectViewType.GROUP && (
|
||||
<Select
|
||||
@@ -323,31 +619,39 @@ const ProjectList: React.FC = () => {
|
||||
<Skeleton active loading={isLoading} className="mt-4 p-4">
|
||||
{viewMode === ProjectViewType.LIST ? (
|
||||
<Table<IProjectViewModel>
|
||||
columns={TableColumns({
|
||||
navigate,
|
||||
filteredInfo,
|
||||
})}
|
||||
dataSource={projectsData?.body?.data || []}
|
||||
columns={tableColumns}
|
||||
dataSource={tableDataSource}
|
||||
rowKey={record => record.id || ''}
|
||||
loading={loadingProjects}
|
||||
size="small"
|
||||
onChange={handleTableChange}
|
||||
pagination={paginationConfig}
|
||||
locale={{ emptyText: <Empty description={t('noProjects')} />}}
|
||||
locale={{ emptyText }}
|
||||
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}
|
||||
/>
|
||||
<div>
|
||||
<ProjectGroupList
|
||||
groups={transformedGroupedProjects}
|
||||
navigate={navigate}
|
||||
onProjectSelect={id => navigateToProject(id, undefined)}
|
||||
onArchive={() => {}}
|
||||
isOwnerOrAdmin={isOwnerOrAdmin}
|
||||
loading={groupedProjects.loading}
|
||||
t={t}
|
||||
/>
|
||||
{!groupedProjects.loading && groupedProjects.data?.data && groupedProjects.data.data.length > 0 && (
|
||||
<div style={{ marginTop: '24px', textAlign: 'center' }}>
|
||||
<Pagination
|
||||
{...groupedPaginationConfig}
|
||||
onChange={(page, pageSize) => handleGroupedTableChange({ current: page, pageSize })}
|
||||
showTotal={paginationShowTotal}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Skeleton>
|
||||
</Card>
|
||||
|
||||
@@ -168,6 +168,61 @@ const Projects: React.FC = () => {
|
||||
[filteredProjects, allSelected]
|
||||
);
|
||||
|
||||
// Memoize group by options
|
||||
const groupByOptions = useMemo(() => [
|
||||
{ value: 'none', label: t('groupByNone') },
|
||||
{ value: 'category', label: t('groupByCategory') },
|
||||
{ value: 'team', label: t('groupByTeam') },
|
||||
{ value: 'status', label: t('groupByStatus') },
|
||||
], [t]);
|
||||
|
||||
// Memoize dropdown styles to prevent recalculation on every render
|
||||
const dropdownStyles = useMemo(() => ({
|
||||
dropdown: {
|
||||
background: token.colorBgContainer,
|
||||
borderRadius: token.borderRadius,
|
||||
boxShadow: token.boxShadowSecondary,
|
||||
border: `1px solid ${token.colorBorder}`,
|
||||
},
|
||||
groupHeader: {
|
||||
backgroundColor: getThemeAwareColor(token.colorFillTertiary, token.colorFillQuaternary),
|
||||
borderRadius: token.borderRadiusSM,
|
||||
padding: '8px 12px',
|
||||
marginBottom: '4px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
border: `1px solid ${getThemeAwareColor(token.colorBorderSecondary, token.colorBorder)}`,
|
||||
},
|
||||
projectItem: {
|
||||
padding: '8px 12px',
|
||||
borderRadius: token.borderRadiusSM,
|
||||
transition: 'all 0.2s ease',
|
||||
cursor: 'pointer',
|
||||
border: `1px solid transparent`,
|
||||
},
|
||||
toggleIcon: {
|
||||
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary),
|
||||
fontSize: '12px',
|
||||
transition: 'all 0.2s ease',
|
||||
},
|
||||
expandedToggleIcon: {
|
||||
color: getThemeAwareColor(token.colorPrimary, token.colorPrimaryActive),
|
||||
fontSize: '12px',
|
||||
transition: 'all 0.2s ease',
|
||||
}
|
||||
}), [token, getThemeAwareColor]);
|
||||
|
||||
// Memoize search placeholder and clear tooltip
|
||||
const searchPlaceholder = useMemo(() => t('searchByProject'), [t]);
|
||||
const clearTooltip = useMemo(() => t('clearSearch'), [t]);
|
||||
const showSelectedTooltip = useMemo(() => t('showSelected'), [t]);
|
||||
const selectAllText = useMemo(() => t('selectAll'), [t]);
|
||||
const projectsSelectedText = useMemo(() => t('projectsSelected'), [t]);
|
||||
const noProjectsText = useMemo(() => t('noProjects'), [t]);
|
||||
const noDataText = useMemo(() => t('noData'), [t]);
|
||||
const expandAllText = useMemo(() => t('expandAll'), [t]);
|
||||
const collapseAllText = useMemo(() => t('collapseAll'), [t]);
|
||||
|
||||
// Handle checkbox change for individual items
|
||||
const handleCheckboxChange = useCallback((key: string, checked: boolean) => {
|
||||
dispatch(setSelectOrDeselectProject({ id: key, selected: checked }));
|
||||
@@ -202,54 +257,7 @@ const Projects: React.FC = () => {
|
||||
}
|
||||
}, [groupedProjects]);
|
||||
|
||||
// Get theme-aware styles
|
||||
const getThemeStyles = useCallback(() => {
|
||||
const isDark = themeMode === 'dark';
|
||||
return {
|
||||
dropdown: {
|
||||
background: token.colorBgContainer,
|
||||
borderRadius: token.borderRadius,
|
||||
boxShadow: token.boxShadowSecondary,
|
||||
border: `1px solid ${token.colorBorder}`,
|
||||
},
|
||||
groupHeader: {
|
||||
backgroundColor: getThemeAwareColor(token.colorFillTertiary, token.colorFillQuaternary),
|
||||
borderRadius: token.borderRadiusSM,
|
||||
padding: '8px 12px',
|
||||
marginBottom: '4px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
border: `1px solid ${getThemeAwareColor(token.colorBorderSecondary, token.colorBorder)}`,
|
||||
'&:hover': {
|
||||
backgroundColor: getThemeAwareColor(token.colorFillSecondary, token.colorFillTertiary),
|
||||
borderColor: getThemeAwareColor(token.colorBorder, token.colorBorderSecondary),
|
||||
}
|
||||
},
|
||||
projectItem: {
|
||||
padding: '8px 12px',
|
||||
borderRadius: token.borderRadiusSM,
|
||||
transition: 'all 0.2s ease',
|
||||
cursor: 'pointer',
|
||||
border: `1px solid transparent`,
|
||||
'&:hover': {
|
||||
backgroundColor: getThemeAwareColor(token.colorFillAlter, token.colorFillQuaternary),
|
||||
borderColor: getThemeAwareColor(token.colorBorderSecondary, token.colorBorder),
|
||||
}
|
||||
},
|
||||
toggleIcon: {
|
||||
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary),
|
||||
fontSize: '12px',
|
||||
transition: 'all 0.2s ease',
|
||||
},
|
||||
expandedToggleIcon: {
|
||||
color: getThemeAwareColor(token.colorPrimary, token.colorPrimaryActive),
|
||||
fontSize: '12px',
|
||||
transition: 'all 0.2s ease',
|
||||
}
|
||||
};
|
||||
}, [token, themeMode, getThemeAwareColor]);
|
||||
|
||||
const styles = getThemeStyles();
|
||||
|
||||
// Render project group
|
||||
const renderProjectGroup = (group: ProjectGroup) => {
|
||||
@@ -261,10 +269,10 @@ const Projects: React.FC = () => {
|
||||
{groupBy !== 'none' && (
|
||||
<div
|
||||
style={{
|
||||
...styles.groupHeader,
|
||||
...dropdownStyles.groupHeader,
|
||||
backgroundColor: isExpanded
|
||||
? getThemeAwareColor(token.colorFillSecondary, token.colorFillTertiary)
|
||||
: styles.groupHeader.backgroundColor
|
||||
: dropdownStyles.groupHeader.backgroundColor
|
||||
}}
|
||||
onClick={() => toggleGroupExpansion(group.key)}
|
||||
onMouseEnter={(e) => {
|
||||
@@ -274,15 +282,15 @@ const Projects: React.FC = () => {
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = isExpanded
|
||||
? getThemeAwareColor(token.colorFillSecondary, token.colorFillTertiary)
|
||||
: styles.groupHeader.backgroundColor;
|
||||
: dropdownStyles.groupHeader.backgroundColor;
|
||||
e.currentTarget.style.borderColor = getThemeAwareColor(token.colorBorderSecondary, token.colorBorder);
|
||||
}}
|
||||
>
|
||||
<Space>
|
||||
{isExpanded ? (
|
||||
<DownOutlined style={styles.expandedToggleIcon} />
|
||||
<DownOutlined style={dropdownStyles.expandedToggleIcon} />
|
||||
) : (
|
||||
<RightOutlined style={styles.toggleIcon} />
|
||||
<RightOutlined style={dropdownStyles.toggleIcon} />
|
||||
)}
|
||||
<div style={{
|
||||
width: '12px',
|
||||
@@ -314,7 +322,7 @@ const Projects: React.FC = () => {
|
||||
{group.projects.map(project => (
|
||||
<div
|
||||
key={project.id}
|
||||
style={styles.projectItem}
|
||||
style={dropdownStyles.projectItem}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = getThemeAwareColor(token.colorFillAlter, token.colorFillQuaternary);
|
||||
e.currentTarget.style.borderColor = getThemeAwareColor(token.colorBorderSecondary, token.colorBorder);
|
||||
@@ -362,7 +370,7 @@ const Projects: React.FC = () => {
|
||||
open={dropdownVisible}
|
||||
dropdownRender={() => (
|
||||
<div style={{
|
||||
...styles.dropdown,
|
||||
...dropdownStyles.dropdown,
|
||||
padding: '8px 0',
|
||||
maxHeight: '500px',
|
||||
width: '400px',
|
||||
@@ -374,12 +382,12 @@ const Projects: React.FC = () => {
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="small">
|
||||
{/* Search input */}
|
||||
<Input
|
||||
placeholder={t('searchByProject')}
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchText}
|
||||
onChange={e => setSearchText(e.target.value)}
|
||||
prefix={<SearchOutlined style={{ color: getThemeAwareColor(token.colorTextTertiary, token.colorTextQuaternary) }} />}
|
||||
suffix={searchText && (
|
||||
<Tooltip title={t('clearSearch')}>
|
||||
<Tooltip title={clearTooltip}>
|
||||
<ClearOutlined
|
||||
onClick={clearSearch}
|
||||
style={{
|
||||
@@ -407,12 +415,7 @@ const Projects: React.FC = () => {
|
||||
onChange={setGroupBy}
|
||||
size="small"
|
||||
style={{ width: '120px' }}
|
||||
options={[
|
||||
{ value: 'none', label: t('groupByNone') },
|
||||
{ value: 'category', label: t('groupByCategory') },
|
||||
{ value: 'team', label: t('groupByTeam') },
|
||||
{ value: 'status', label: t('groupByStatus') },
|
||||
]}
|
||||
options={groupByOptions}
|
||||
/>
|
||||
|
||||
{groupBy !== 'none' && (
|
||||
@@ -425,7 +428,7 @@ const Projects: React.FC = () => {
|
||||
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary)
|
||||
}}
|
||||
>
|
||||
{t('expandAll')}
|
||||
{expandAllText}
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
@@ -435,13 +438,13 @@ const Projects: React.FC = () => {
|
||||
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary)
|
||||
}}
|
||||
>
|
||||
{t('collapseAll')}
|
||||
{collapseAllText}
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
<Tooltip title={t('showSelected')}>
|
||||
<Tooltip title={showSelectedTooltip}>
|
||||
<Button
|
||||
type={showSelectedOnly ? 'primary' : 'text'}
|
||||
size="small"
|
||||
@@ -468,7 +471,7 @@ const Projects: React.FC = () => {
|
||||
<Text style={{
|
||||
color: getThemeAwareColor(token.colorText, token.colorTextBase)
|
||||
}}>
|
||||
{t('selectAll')}
|
||||
{selectAllText}
|
||||
</Text>
|
||||
{selectedCount > 0 && (
|
||||
<Badge
|
||||
@@ -499,7 +502,7 @@ const Projects: React.FC = () => {
|
||||
<Text style={{
|
||||
color: getThemeAwareColor(token.colorTextTertiary, token.colorTextQuaternary)
|
||||
}}>
|
||||
{searchText ? t('noProjects') : t('noData')}
|
||||
{searchText ? noProjectsText : noDataText}
|
||||
</Text>
|
||||
}
|
||||
style={{ margin: '20px 0' }}
|
||||
@@ -524,7 +527,7 @@ const Projects: React.FC = () => {
|
||||
fontSize: '12px',
|
||||
color: getThemeAwareColor(token.colorTextTertiary, token.colorTextQuaternary)
|
||||
}}>
|
||||
{selectedCount} {t('projectsSelected')}
|
||||
{selectedCount} {projectsSelectedText}
|
||||
</Text>
|
||||
</div>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user