From 81f55adb41bf43746b53c8760eeebb6818df516a Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Fri, 13 Jun 2025 13:16:25 +0530 Subject: [PATCH] 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. --- .../public/locales/en/time-report.json | 15 +- .../public/locales/es/time-report.json | 19 +- .../public/locales/pt/time-report.json | 35 +- .../timeReports/page-header/projects.tsx | 546 ++++++++++++++++-- .../reporting/reporting-filters.types.ts | 7 + 5 files changed, 555 insertions(+), 67 deletions(-) diff --git a/worklenz-frontend/public/locales/en/time-report.json b/worklenz-frontend/public/locales/en/time-report.json index b5da8dd2..00aa3c7f 100644 --- a/worklenz-frontend/public/locales/en/time-report.json +++ b/worklenz-frontend/public/locales/en/time-report.json @@ -40,5 +40,18 @@ "noCategory": "No Category", "noProjects": "No projects 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" } diff --git a/worklenz-frontend/public/locales/es/time-report.json b/worklenz-frontend/public/locales/es/time-report.json index a602ec1d..2646520f 100644 --- a/worklenz-frontend/public/locales/es/time-report.json +++ b/worklenz-frontend/public/locales/es/time-report.json @@ -7,7 +7,7 @@ "selectAll": "Seleccionar Todo", "teams": "Equipos", - "searchByProject": "Buscar por nombre de proyecto", + "searchByProject": "Buscar por nombre del proyecto", "projects": "Proyectos", "searchByCategory": "Buscar por nombre de categoría", @@ -37,8 +37,21 @@ "actualDays": "Días Reales", "noCategories": "No se encontraron categorías", - "noCategory": "No Categoría", + "noCategory": "Sin Categoría", "noProjects": "No se encontraron proyectos", "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" } diff --git a/worklenz-frontend/public/locales/pt/time-report.json b/worklenz-frontend/public/locales/pt/time-report.json index 8d09db4c..b40546e9 100644 --- a/worklenz-frontend/public/locales/pt/time-report.json +++ b/worklenz-frontend/public/locales/pt/time-report.json @@ -4,7 +4,7 @@ "timeSheet": "Folha de Tempo", "searchByName": "Pesquisar por nome", - "selectAll": "Selecionar Todos", + "selectAll": "Selecionar Tudo", "teams": "Equipes", "searchByProject": "Pesquisar por nome do projeto", @@ -13,32 +13,45 @@ "searchByCategory": "Pesquisar por nome da categoria", "categories": "Categorias", - "billable": "Cobrável", - "nonBillable": "Não Cobrável", + "billable": "Faturável", + "nonBillable": "Não Faturável", "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", "logged": "registrado", "for": "para", - "membersTimeSheet": "Folha de Tempo dos Membros", + "membersTimeSheet": "Folha de Tempo de Membros", "member": "Membro", "estimatedVsActual": "Estimado vs Real", - "workingDays": "Dias de Trabalho", - "manDays": "Dias-Homem", + "workingDays": "Dias Úteis", + "manDays": "Dias Homem", "days": "Dias", "estimatedDays": "Dias Estimados", "actualDays": "Dias Reais", "noCategories": "Nenhuma categoria encontrada", - "noCategory": "Nenhuma Categoria", + "noCategory": "Sem Categoria", "noProjects": "Nenhum projeto encontrado", - "noTeams": "Nenhum time encontrado", - "noData": "Nenhum dado encontrado" + "noTeams": "Nenhuma equipe encontrada", + "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" } diff --git a/worklenz-frontend/src/pages/reporting/timeReports/page-header/projects.tsx b/worklenz-frontend/src/pages/reporting/timeReports/page-header/projects.tsx index bb1712cf..b6206d95 100644 --- a/worklenz-frontend/src/pages/reporting/timeReports/page-header/projects.tsx +++ b/worklenz-frontend/src/pages/reporting/timeReports/page-header/projects.tsx @@ -1,37 +1,356 @@ import { setSelectOrDeselectAllProjects, setSelectOrDeselectProject } from '@/features/reporting/time-reports/time-reports-overview.slice'; import { useAppDispatch } from '@/hooks/useAppDispatch'; import { useAppSelector } from '@/hooks/useAppSelector'; -import { CaretDownFilled } from '@ant-design/icons'; -import { Button, Checkbox, Divider, Dropdown, Input, theme } from 'antd'; +import { CaretDownFilled, SearchOutlined, ClearOutlined, DownOutlined, RightOutlined, FilterOutlined } from '@ant-design/icons'; +import { Button, Checkbox, Divider, Dropdown, Input, theme, Typography, Badge, Collapse, Select, Space, Tooltip, Empty } from 'antd'; import { CheckboxChangeEvent } from 'antd/es/checkbox'; -import React, { useState } from 'react'; +import React, { useState, useMemo, useCallback } from 'react'; 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 dispatch = useAppDispatch(); - const [checkedList, setCheckedList] = useState([]); const [searchText, setSearchText] = useState(''); - const [selectAll, setSelectAll] = useState(true); + const [groupBy, setGroupBy] = useState('none'); + const [showSelectedOnly, setShowSelectedOnly] = useState(false); + const [expandedGroups, setExpandedGroups] = useState([]); const { t } = useTranslation('time-report'); const [dropdownVisible, setDropdownVisible] = useState(false); const { projects, loadingProjects } = useAppSelector(state => state.timeReportsOverviewReducer); const { token } = theme.useToken(); + const themeMode = useAppSelector(state => state.themeReducer.mode); - // Filter items based on search text - const filteredItems = projects.filter(item => - item.name?.toLowerCase().includes(searchText.toLowerCase()) + // Theme-aware color utilities + 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()) + ); + + if (showSelectedOnly) { + filtered = filtered.filter(item => item.selected); + } + + 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 = (key: string, checked: boolean) => { + const handleCheckboxChange = useCallback((key: string, checked: boolean) => { dispatch(setSelectOrDeselectProject({ id: key, selected: checked })); - }; + }, [dispatch]); // Handle "Select All" checkbox change - const handleSelectAllChange = (e: CheckboxChangeEvent) => { + const handleSelectAllChange = useCallback((e: CheckboxChangeEvent) => { const isChecked = e.target.checked; - setSelectAll(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 ( +
+ {groupBy !== 'none' && ( +
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); + }} + > + + {isExpanded ? ( + + ) : ( + + )} +
+ + {group.name} + + + +
+ )} + + {isExpanded && ( +
+ {group.projects.map(project => ( +
{ + 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'; + }} + > + e.stopPropagation()} + checked={project.selected} + onChange={e => handleCheckboxChange(project.id || '', e.target.checked)} + > + +
+ + {project.name} + + + +
+ ))} +
+ )} +
+ ); }; return ( @@ -40,71 +359,194 @@ const Projects: React.FC = () => { menu={undefined} placement="bottomLeft" trigger={['click']} + open={dropdownVisible} dropdownRender={() => (
-
- e.stopPropagation()} - placeholder={t('searchByProject')} - value={searchText} - onChange={e => setSearchText(e.target.value)} - /> + {/* Header with search and controls */} +
+ + {/* Search input */} + setSearchText(e.target.value)} + prefix={} + suffix={searchText && ( + + { + e.currentTarget.style.color = getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary); + }} + onMouseLeave={(e) => { + e.currentTarget.style.color = getThemeAwareColor(token.colorTextTertiary, token.colorTextQuaternary); + }} + /> + + )} + onClick={e => e.stopPropagation()} + /> + + {/* Controls row */} + + +