feat(projects): enhance project selection and grouping functionality
- Added grouping options for projects by category, team, and status in the project list. - Implemented search functionality with a clear search option. - Improved UI with expandable/collapsible project groups and selection summary. - Updated localization files for English, Spanish, and Portuguese to include new grouping and UI strings. - Enhanced project type definitions to support additional grouping properties.
This commit is contained in:
@@ -40,5 +40,18 @@
|
|||||||
"noCategory": "No Category",
|
"noCategory": "No Category",
|
||||||
"noProjects": "No projects found",
|
"noProjects": "No projects found",
|
||||||
"noTeams": "No teams found",
|
"noTeams": "No teams found",
|
||||||
"noData": "No data found"
|
"noData": "No data found",
|
||||||
|
|
||||||
|
"groupBy": "Group by",
|
||||||
|
"groupByCategory": "Category",
|
||||||
|
"groupByTeam": "Team",
|
||||||
|
"groupByStatus": "Status",
|
||||||
|
"groupByNone": "None",
|
||||||
|
"clearSearch": "Clear search",
|
||||||
|
"selectedProjects": "Selected Projects",
|
||||||
|
"projectsSelected": "projects selected",
|
||||||
|
"showSelected": "Show Selected Only",
|
||||||
|
"expandAll": "Expand All",
|
||||||
|
"collapseAll": "Collapse All",
|
||||||
|
"ungrouped": "Ungrouped"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"selectAll": "Seleccionar Todo",
|
"selectAll": "Seleccionar Todo",
|
||||||
"teams": "Equipos",
|
"teams": "Equipos",
|
||||||
|
|
||||||
"searchByProject": "Buscar por nombre de proyecto",
|
"searchByProject": "Buscar por nombre del proyecto",
|
||||||
"projects": "Proyectos",
|
"projects": "Proyectos",
|
||||||
|
|
||||||
"searchByCategory": "Buscar por nombre de categoría",
|
"searchByCategory": "Buscar por nombre de categoría",
|
||||||
@@ -37,8 +37,21 @@
|
|||||||
"actualDays": "Días Reales",
|
"actualDays": "Días Reales",
|
||||||
|
|
||||||
"noCategories": "No se encontraron categorías",
|
"noCategories": "No se encontraron categorías",
|
||||||
"noCategory": "No Categoría",
|
"noCategory": "Sin Categoría",
|
||||||
"noProjects": "No se encontraron proyectos",
|
"noProjects": "No se encontraron proyectos",
|
||||||
"noTeams": "No se encontraron equipos",
|
"noTeams": "No se encontraron equipos",
|
||||||
"noData": "No se encontraron datos"
|
"noData": "No se encontraron datos",
|
||||||
|
|
||||||
|
"groupBy": "Agrupar por",
|
||||||
|
"groupByCategory": "Categoría",
|
||||||
|
"groupByTeam": "Equipo",
|
||||||
|
"groupByStatus": "Estado",
|
||||||
|
"groupByNone": "Ninguno",
|
||||||
|
"clearSearch": "Limpiar búsqueda",
|
||||||
|
"selectedProjects": "Proyectos Seleccionados",
|
||||||
|
"projectsSelected": "proyectos seleccionados",
|
||||||
|
"showSelected": "Mostrar Solo Seleccionados",
|
||||||
|
"expandAll": "Expandir Todo",
|
||||||
|
"collapseAll": "Contraer Todo",
|
||||||
|
"ungrouped": "Sin Agrupar"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"timeSheet": "Folha de Tempo",
|
"timeSheet": "Folha de Tempo",
|
||||||
|
|
||||||
"searchByName": "Pesquisar por nome",
|
"searchByName": "Pesquisar por nome",
|
||||||
"selectAll": "Selecionar Todos",
|
"selectAll": "Selecionar Tudo",
|
||||||
"teams": "Equipes",
|
"teams": "Equipes",
|
||||||
|
|
||||||
"searchByProject": "Pesquisar por nome do projeto",
|
"searchByProject": "Pesquisar por nome do projeto",
|
||||||
@@ -13,32 +13,45 @@
|
|||||||
"searchByCategory": "Pesquisar por nome da categoria",
|
"searchByCategory": "Pesquisar por nome da categoria",
|
||||||
"categories": "Categorias",
|
"categories": "Categorias",
|
||||||
|
|
||||||
"billable": "Cobrável",
|
"billable": "Faturável",
|
||||||
"nonBillable": "Não Cobrável",
|
"nonBillable": "Não Faturável",
|
||||||
|
|
||||||
"total": "Total",
|
"total": "Total",
|
||||||
|
|
||||||
"projectsTimeSheet": "Folha de Tempo dos Projetos",
|
"projectsTimeSheet": "Folha de Tempo de Projetos",
|
||||||
|
|
||||||
"loggedTime": "Tempo Registrado (horas)",
|
"loggedTime": "Tempo Registrado(horas)",
|
||||||
|
|
||||||
"exportToExcel": "Exportar para Excel",
|
"exportToExcel": "Exportar para Excel",
|
||||||
"logged": "registrado",
|
"logged": "registrado",
|
||||||
"for": "para",
|
"for": "para",
|
||||||
|
|
||||||
"membersTimeSheet": "Folha de Tempo dos Membros",
|
"membersTimeSheet": "Folha de Tempo de Membros",
|
||||||
"member": "Membro",
|
"member": "Membro",
|
||||||
|
|
||||||
"estimatedVsActual": "Estimado vs Real",
|
"estimatedVsActual": "Estimado vs Real",
|
||||||
"workingDays": "Dias de Trabalho",
|
"workingDays": "Dias Úteis",
|
||||||
"manDays": "Dias-Homem",
|
"manDays": "Dias Homem",
|
||||||
"days": "Dias",
|
"days": "Dias",
|
||||||
"estimatedDays": "Dias Estimados",
|
"estimatedDays": "Dias Estimados",
|
||||||
"actualDays": "Dias Reais",
|
"actualDays": "Dias Reais",
|
||||||
|
|
||||||
"noCategories": "Nenhuma categoria encontrada",
|
"noCategories": "Nenhuma categoria encontrada",
|
||||||
"noCategory": "Nenhuma Categoria",
|
"noCategory": "Sem Categoria",
|
||||||
"noProjects": "Nenhum projeto encontrado",
|
"noProjects": "Nenhum projeto encontrado",
|
||||||
"noTeams": "Nenhum time encontrado",
|
"noTeams": "Nenhuma equipe encontrada",
|
||||||
"noData": "Nenhum dado encontrado"
|
"noData": "Nenhum dado encontrado",
|
||||||
|
|
||||||
|
"groupBy": "Agrupar por",
|
||||||
|
"groupByCategory": "Categoria",
|
||||||
|
"groupByTeam": "Equipe",
|
||||||
|
"groupByStatus": "Status",
|
||||||
|
"groupByNone": "Nenhum",
|
||||||
|
"clearSearch": "Limpar pesquisa",
|
||||||
|
"selectedProjects": "Projetos Selecionados",
|
||||||
|
"projectsSelected": "projetos selecionados",
|
||||||
|
"showSelected": "Mostrar Apenas Selecionados",
|
||||||
|
"expandAll": "Expandir Tudo",
|
||||||
|
"collapseAll": "Recolher Tudo",
|
||||||
|
"ungrouped": "Não Agrupado"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,356 @@
|
|||||||
import { setSelectOrDeselectAllProjects, setSelectOrDeselectProject } from '@/features/reporting/time-reports/time-reports-overview.slice';
|
import { setSelectOrDeselectAllProjects, setSelectOrDeselectProject } from '@/features/reporting/time-reports/time-reports-overview.slice';
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
import { CaretDownFilled } from '@ant-design/icons';
|
import { CaretDownFilled, SearchOutlined, ClearOutlined, DownOutlined, RightOutlined, FilterOutlined } from '@ant-design/icons';
|
||||||
import { Button, Checkbox, Divider, Dropdown, Input, theme } from 'antd';
|
import { Button, Checkbox, Divider, Dropdown, Input, theme, Typography, Badge, Collapse, Select, Space, Tooltip, Empty } from 'antd';
|
||||||
import { CheckboxChangeEvent } from 'antd/es/checkbox';
|
import { CheckboxChangeEvent } from 'antd/es/checkbox';
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useMemo, useCallback } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { ISelectableProject } from '@/types/reporting/reporting-filters.types';
|
||||||
|
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||||
|
|
||||||
|
const { Panel } = Collapse;
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
type GroupByOption = 'none' | 'category' | 'team' | 'status';
|
||||||
|
|
||||||
|
interface ProjectGroup {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
color?: string;
|
||||||
|
projects: ISelectableProject[];
|
||||||
|
}
|
||||||
|
|
||||||
const Projects: React.FC = () => {
|
const Projects: React.FC = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const [checkedList, setCheckedList] = useState<string[]>([]);
|
|
||||||
const [searchText, setSearchText] = useState('');
|
const [searchText, setSearchText] = useState('');
|
||||||
const [selectAll, setSelectAll] = useState(true);
|
const [groupBy, setGroupBy] = useState<GroupByOption>('none');
|
||||||
|
const [showSelectedOnly, setShowSelectedOnly] = useState(false);
|
||||||
|
const [expandedGroups, setExpandedGroups] = useState<string[]>([]);
|
||||||
const { t } = useTranslation('time-report');
|
const { t } = useTranslation('time-report');
|
||||||
const [dropdownVisible, setDropdownVisible] = useState(false);
|
const [dropdownVisible, setDropdownVisible] = useState(false);
|
||||||
const { projects, loadingProjects } = useAppSelector(state => state.timeReportsOverviewReducer);
|
const { projects, loadingProjects } = useAppSelector(state => state.timeReportsOverviewReducer);
|
||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
|
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||||
|
|
||||||
// Filter items based on search text
|
// Theme-aware color utilities
|
||||||
const filteredItems = projects.filter(item =>
|
const getThemeAwareColor = useCallback((lightColor: string, darkColor: string) => {
|
||||||
|
return themeWiseColor(lightColor, darkColor, themeMode);
|
||||||
|
}, [themeMode]);
|
||||||
|
|
||||||
|
// Enhanced color processing for project/group colors
|
||||||
|
const processColor = useCallback((color: string | undefined, fallback?: string) => {
|
||||||
|
if (!color) return fallback || token.colorPrimary;
|
||||||
|
|
||||||
|
// If it's a hex color, ensure it has good contrast in both themes
|
||||||
|
if (color.startsWith('#')) {
|
||||||
|
// For dark mode, lighten dark colors and darken light colors for better visibility
|
||||||
|
if (themeMode === 'dark') {
|
||||||
|
// Simple brightness adjustment for dark mode
|
||||||
|
const hex = color.replace('#', '');
|
||||||
|
const r = parseInt(hex.substr(0, 2), 16);
|
||||||
|
const g = parseInt(hex.substr(2, 2), 16);
|
||||||
|
const b = parseInt(hex.substr(4, 2), 16);
|
||||||
|
|
||||||
|
// Calculate brightness (0-255)
|
||||||
|
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
|
||||||
|
|
||||||
|
// If color is too dark in dark mode, lighten it
|
||||||
|
if (brightness < 100) {
|
||||||
|
const factor = 1.5;
|
||||||
|
const newR = Math.min(255, Math.floor(r * factor));
|
||||||
|
const newG = Math.min(255, Math.floor(g * factor));
|
||||||
|
const newB = Math.min(255, Math.floor(b * factor));
|
||||||
|
return `#${newR.toString(16).padStart(2, '0')}${newG.toString(16).padStart(2, '0')}${newB.toString(16).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For light mode, ensure colors aren't too light
|
||||||
|
const hex = color.replace('#', '');
|
||||||
|
const r = parseInt(hex.substr(0, 2), 16);
|
||||||
|
const g = parseInt(hex.substr(2, 2), 16);
|
||||||
|
const b = parseInt(hex.substr(4, 2), 16);
|
||||||
|
|
||||||
|
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
|
||||||
|
|
||||||
|
// If color is too light in light mode, darken it
|
||||||
|
if (brightness > 200) {
|
||||||
|
const factor = 0.7;
|
||||||
|
const newR = Math.floor(r * factor);
|
||||||
|
const newG = Math.floor(g * factor);
|
||||||
|
const newB = Math.floor(b * factor);
|
||||||
|
return `#${newR.toString(16).padStart(2, '0')}${newG.toString(16).padStart(2, '0')}${newB.toString(16).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return color;
|
||||||
|
}, [themeMode, token.colorPrimary]);
|
||||||
|
|
||||||
|
// Memoized filtered projects
|
||||||
|
const filteredProjects = useMemo(() => {
|
||||||
|
let filtered = projects.filter(item =>
|
||||||
item.name?.toLowerCase().includes(searchText.toLowerCase())
|
item.name?.toLowerCase().includes(searchText.toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle checkbox change for individual items
|
if (showSelectedOnly) {
|
||||||
const handleCheckboxChange = (key: string, checked: boolean) => {
|
filtered = filtered.filter(item => item.selected);
|
||||||
dispatch(setSelectOrDeselectProject({ id: key, selected: checked }));
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}, [projects, searchText, showSelectedOnly]);
|
||||||
|
|
||||||
|
// Memoized grouped projects
|
||||||
|
const groupedProjects = useMemo(() => {
|
||||||
|
if (groupBy === 'none') {
|
||||||
|
return [{
|
||||||
|
key: 'all',
|
||||||
|
name: t('projects'),
|
||||||
|
projects: filteredProjects
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups: { [key: string]: ProjectGroup } = {};
|
||||||
|
|
||||||
|
filteredProjects.forEach(project => {
|
||||||
|
let groupKey: string;
|
||||||
|
let groupName: string;
|
||||||
|
let groupColor: string | undefined;
|
||||||
|
|
||||||
|
switch (groupBy) {
|
||||||
|
case 'category':
|
||||||
|
groupKey = (project as any).category_id || 'uncategorized';
|
||||||
|
groupName = (project as any).category_name || t('noCategory');
|
||||||
|
groupColor = (project as any).category_color;
|
||||||
|
break;
|
||||||
|
case 'team':
|
||||||
|
groupKey = (project as any).team_id || 'no-team';
|
||||||
|
groupName = (project as any).team_name || t('ungrouped');
|
||||||
|
groupColor = (project as any).team_color;
|
||||||
|
break;
|
||||||
|
case 'status':
|
||||||
|
groupKey = (project as any).status_id || 'no-status';
|
||||||
|
groupName = (project as any).status_name || t('ungrouped');
|
||||||
|
groupColor = (project as any).status_color;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
groupKey = 'all';
|
||||||
|
groupName = t('projects');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!groups[groupKey]) {
|
||||||
|
groups[groupKey] = {
|
||||||
|
key: groupKey,
|
||||||
|
name: groupName,
|
||||||
|
color: processColor(groupColor),
|
||||||
|
projects: []
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
groups[groupKey].projects.push(project);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.values(groups).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}, [filteredProjects, groupBy, t, processColor]);
|
||||||
|
|
||||||
|
// Selected projects count
|
||||||
|
const selectedCount = useMemo(() =>
|
||||||
|
projects.filter(p => p.selected).length,
|
||||||
|
[projects]
|
||||||
|
);
|
||||||
|
|
||||||
|
const allSelected = useMemo(() =>
|
||||||
|
filteredProjects.length > 0 && filteredProjects.every(p => p.selected),
|
||||||
|
[filteredProjects]
|
||||||
|
);
|
||||||
|
|
||||||
|
const indeterminate = useMemo(() =>
|
||||||
|
filteredProjects.some(p => p.selected) && !allSelected,
|
||||||
|
[filteredProjects, allSelected]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle checkbox change for individual items
|
||||||
|
const handleCheckboxChange = useCallback((key: string, checked: boolean) => {
|
||||||
|
dispatch(setSelectOrDeselectProject({ id: key, selected: checked }));
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
// Handle "Select All" checkbox change
|
// Handle "Select All" checkbox change
|
||||||
const handleSelectAllChange = (e: CheckboxChangeEvent) => {
|
const handleSelectAllChange = useCallback((e: CheckboxChangeEvent) => {
|
||||||
const isChecked = e.target.checked;
|
const isChecked = e.target.checked;
|
||||||
setSelectAll(isChecked);
|
|
||||||
dispatch(setSelectOrDeselectAllProjects(isChecked));
|
dispatch(setSelectOrDeselectAllProjects(isChecked));
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
// Clear search
|
||||||
|
const clearSearch = useCallback(() => {
|
||||||
|
setSearchText('');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Toggle group expansion
|
||||||
|
const toggleGroupExpansion = useCallback((groupKey: string) => {
|
||||||
|
setExpandedGroups(prev =>
|
||||||
|
prev.includes(groupKey)
|
||||||
|
? prev.filter(key => key !== groupKey)
|
||||||
|
: [...prev, groupKey]
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Expand/Collapse all groups
|
||||||
|
const toggleAllGroups = useCallback((expand: boolean) => {
|
||||||
|
if (expand) {
|
||||||
|
setExpandedGroups(groupedProjects.map(g => g.key));
|
||||||
|
} else {
|
||||||
|
setExpandedGroups([]);
|
||||||
|
}
|
||||||
|
}, [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) => {
|
||||||
|
const isExpanded = expandedGroups.includes(group.key) || groupBy === 'none';
|
||||||
|
const groupSelectedCount = group.projects.filter(p => p.selected).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={group.key} style={{ marginBottom: '8px' }}>
|
||||||
|
{groupBy !== 'none' && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...styles.groupHeader,
|
||||||
|
backgroundColor: isExpanded
|
||||||
|
? getThemeAwareColor(token.colorFillSecondary, token.colorFillTertiary)
|
||||||
|
: styles.groupHeader.backgroundColor
|
||||||
|
}}
|
||||||
|
onClick={() => toggleGroupExpansion(group.key)}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = getThemeAwareColor(token.colorFillSecondary, token.colorFillTertiary);
|
||||||
|
e.currentTarget.style.borderColor = getThemeAwareColor(token.colorBorder, token.colorBorderSecondary);
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = isExpanded
|
||||||
|
? getThemeAwareColor(token.colorFillSecondary, token.colorFillTertiary)
|
||||||
|
: styles.groupHeader.backgroundColor;
|
||||||
|
e.currentTarget.style.borderColor = getThemeAwareColor(token.colorBorderSecondary, token.colorBorder);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space>
|
||||||
|
{isExpanded ? (
|
||||||
|
<DownOutlined style={styles.expandedToggleIcon} />
|
||||||
|
) : (
|
||||||
|
<RightOutlined style={styles.toggleIcon} />
|
||||||
|
)}
|
||||||
|
<div style={{
|
||||||
|
width: '12px',
|
||||||
|
height: '12px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: group.color || processColor(undefined, token.colorPrimary),
|
||||||
|
flexShrink: 0,
|
||||||
|
border: `1px solid ${getThemeAwareColor('rgba(0,0,0,0.1)', 'rgba(255,255,255,0.2)')}`
|
||||||
|
}} />
|
||||||
|
<Text strong style={{
|
||||||
|
color: getThemeAwareColor(token.colorText, token.colorTextBase)
|
||||||
|
}}>
|
||||||
|
{group.name}
|
||||||
|
</Text>
|
||||||
|
<Badge
|
||||||
|
count={groupSelectedCount}
|
||||||
|
size="small"
|
||||||
|
style={{
|
||||||
|
backgroundColor: getThemeAwareColor(token.colorPrimary, token.colorPrimaryActive),
|
||||||
|
color: getThemeAwareColor('#fff', token.colorTextLightSolid)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div style={{ paddingLeft: groupBy !== 'none' ? '24px' : '0' }}>
|
||||||
|
{group.projects.map(project => (
|
||||||
|
<div
|
||||||
|
key={project.id}
|
||||||
|
style={styles.projectItem}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = getThemeAwareColor(token.colorFillAlter, token.colorFillQuaternary);
|
||||||
|
e.currentTarget.style.borderColor = getThemeAwareColor(token.colorBorderSecondary, token.colorBorder);
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'transparent';
|
||||||
|
e.currentTarget.style.borderColor = 'transparent';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
checked={project.selected}
|
||||||
|
onChange={e => handleCheckboxChange(project.id || '', e.target.checked)}
|
||||||
|
>
|
||||||
|
<Space>
|
||||||
|
<div style={{
|
||||||
|
width: '8px',
|
||||||
|
height: '8px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: processColor((project as any).color_code, token.colorPrimary),
|
||||||
|
flexShrink: 0,
|
||||||
|
border: `1px solid ${getThemeAwareColor('rgba(0,0,0,0.1)', 'rgba(255,255,255,0.2)')}`
|
||||||
|
}} />
|
||||||
|
<Text style={{
|
||||||
|
color: getThemeAwareColor(token.colorText, token.colorTextBase)
|
||||||
|
}}>
|
||||||
|
{project.name}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
</Checkbox>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -40,71 +359,194 @@ const Projects: React.FC = () => {
|
|||||||
menu={undefined}
|
menu={undefined}
|
||||||
placement="bottomLeft"
|
placement="bottomLeft"
|
||||||
trigger={['click']}
|
trigger={['click']}
|
||||||
|
open={dropdownVisible}
|
||||||
dropdownRender={() => (
|
dropdownRender={() => (
|
||||||
<div style={{
|
<div style={{
|
||||||
background: token.colorBgContainer,
|
...styles.dropdown,
|
||||||
borderRadius: token.borderRadius,
|
padding: '8px 0',
|
||||||
boxShadow: token.boxShadow,
|
maxHeight: '500px',
|
||||||
padding: '4px 0',
|
width: '400px',
|
||||||
maxHeight: '330px',
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column'
|
flexDirection: 'column'
|
||||||
}}>
|
}}>
|
||||||
<div style={{ padding: '8px', flexShrink: 0 }}>
|
{/* Header with search and controls */}
|
||||||
|
<div style={{ padding: '8px 12px', flexShrink: 0 }}>
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="small">
|
||||||
|
{/* Search input */}
|
||||||
<Input
|
<Input
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
placeholder={t('searchByProject')}
|
placeholder={t('searchByProject')}
|
||||||
value={searchText}
|
value={searchText}
|
||||||
onChange={e => setSearchText(e.target.value)}
|
onChange={e => setSearchText(e.target.value)}
|
||||||
|
prefix={<SearchOutlined style={{ color: getThemeAwareColor(token.colorTextTertiary, token.colorTextQuaternary) }} />}
|
||||||
|
suffix={searchText && (
|
||||||
|
<Tooltip title={t('clearSearch')}>
|
||||||
|
<ClearOutlined
|
||||||
|
onClick={clearSearch}
|
||||||
|
style={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: getThemeAwareColor(token.colorTextTertiary, token.colorTextQuaternary),
|
||||||
|
transition: 'color 0.2s ease'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.color = getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary);
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.color = getThemeAwareColor(token.colorTextTertiary, token.colorTextQuaternary);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Controls row */}
|
||||||
|
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||||
|
<Space size="small">
|
||||||
|
<Select
|
||||||
|
value={groupBy}
|
||||||
|
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') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{groupBy !== 'none' && (
|
||||||
|
<Space size="small">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
onClick={() => toggleAllGroups(true)}
|
||||||
|
style={{
|
||||||
|
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('expandAll')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
onClick={() => toggleAllGroups(false)}
|
||||||
|
style={{
|
||||||
|
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('collapseAll')}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Tooltip title={t('showSelected')}>
|
||||||
|
<Button
|
||||||
|
type={showSelectedOnly ? 'primary' : 'text'}
|
||||||
|
size="small"
|
||||||
|
icon={<FilterOutlined />}
|
||||||
|
onClick={() => setShowSelectedOnly(!showSelectedOnly)}
|
||||||
|
style={!showSelectedOnly ? {
|
||||||
|
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary)
|
||||||
|
} : {}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: '0 12px', flexShrink: 0 }}>
|
|
||||||
|
{/* Select All checkbox */}
|
||||||
|
<div style={{ padding: '8px 12px', flexShrink: 0 }}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
onClick={e => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
onChange={handleSelectAllChange}
|
onChange={handleSelectAllChange}
|
||||||
checked={selectAll}
|
checked={allSelected}
|
||||||
|
indeterminate={indeterminate}
|
||||||
>
|
>
|
||||||
|
<Space>
|
||||||
|
<Text style={{
|
||||||
|
color: getThemeAwareColor(token.colorText, token.colorTextBase)
|
||||||
|
}}>
|
||||||
{t('selectAll')}
|
{t('selectAll')}
|
||||||
|
</Text>
|
||||||
|
{selectedCount > 0 && (
|
||||||
|
<Badge
|
||||||
|
count={selectedCount}
|
||||||
|
size="small"
|
||||||
|
style={{
|
||||||
|
backgroundColor: getThemeAwareColor(token.colorSuccess, token.colorSuccessActive),
|
||||||
|
color: getThemeAwareColor('#fff', token.colorTextLightSolid)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
</div>
|
</div>
|
||||||
<Divider style={{ margin: '4px 0', flexShrink: 0 }} />
|
|
||||||
|
<Divider style={{ margin: '8px 0', flexShrink: 0 }} />
|
||||||
|
|
||||||
|
{/* Projects list */}
|
||||||
<div style={{
|
<div style={{
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
flex: 1
|
flex: 1,
|
||||||
|
padding: '0 12px'
|
||||||
}}>
|
}}>
|
||||||
{filteredItems.map(item => (
|
{filteredProjects.length === 0 ? (
|
||||||
<div
|
<Empty
|
||||||
key={item.id}
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
style={{
|
description={
|
||||||
padding: '8px 12px',
|
<Text style={{
|
||||||
cursor: 'pointer',
|
color: getThemeAwareColor(token.colorTextTertiary, token.colorTextQuaternary)
|
||||||
'&:hover': {
|
}}>
|
||||||
backgroundColor: token.colorBgTextHover
|
{searchText ? t('noProjects') : t('noData')}
|
||||||
|
</Text>
|
||||||
}
|
}
|
||||||
}}
|
style={{ margin: '20px 0' }}
|
||||||
>
|
/>
|
||||||
<Checkbox
|
) : (
|
||||||
onClick={e => e.stopPropagation()}
|
groupedProjects.map(renderProjectGroup)
|
||||||
checked={item.selected}
|
)}
|
||||||
onChange={e => handleCheckboxChange(item.id || '', e.target.checked)}
|
|
||||||
>
|
|
||||||
{item.name}
|
|
||||||
</Checkbox>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
|
{/* Footer with selection summary */}
|
||||||
|
{selectedCount > 0 && (
|
||||||
|
<>
|
||||||
|
<Divider style={{ margin: '8px 0', flexShrink: 0 }} />
|
||||||
|
<div style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
flexShrink: 0,
|
||||||
|
backgroundColor: getThemeAwareColor(token.colorFillAlter, token.colorFillQuaternary),
|
||||||
|
borderRadius: `0 0 ${token.borderRadius}px ${token.borderRadius}px`,
|
||||||
|
borderTop: `1px solid ${getThemeAwareColor(token.colorBorderSecondary, token.colorBorder)}`
|
||||||
|
}}>
|
||||||
|
<Text type="secondary" style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
color: getThemeAwareColor(token.colorTextTertiary, token.colorTextQuaternary)
|
||||||
|
}}>
|
||||||
|
{selectedCount} {t('projectsSelected')}
|
||||||
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
onOpenChange={visible => {
|
onOpenChange={visible => {
|
||||||
setDropdownVisible(visible);
|
setDropdownVisible(visible);
|
||||||
if (!visible) {
|
if (!visible) {
|
||||||
setSearchText('');
|
setSearchText('');
|
||||||
|
setShowSelectedOnly(false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<Badge count={selectedCount} size="small" offset={[-5, 5]}>
|
||||||
<Button loading={loadingProjects}>
|
<Button loading={loadingProjects}>
|
||||||
{t('projects')} <CaretDownFilled />
|
<Space>
|
||||||
|
{t('projects')}
|
||||||
|
<CaretDownFilled />
|
||||||
|
</Space>
|
||||||
</Button>
|
</Button>
|
||||||
|
</Badge>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ import { ITeam } from '@/types/teams/team.type';
|
|||||||
|
|
||||||
export interface ISelectableProject extends IProject {
|
export interface ISelectableProject extends IProject {
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
|
// Additional properties for grouping
|
||||||
|
category_name?: string;
|
||||||
|
category_color?: string;
|
||||||
|
team_name?: string;
|
||||||
|
team_color?: string;
|
||||||
|
status_name?: string;
|
||||||
|
status_color?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ISelectableTeam extends ITeam {
|
export interface ISelectableTeam extends ITeam {
|
||||||
|
|||||||
Reference in New Issue
Block a user