From 4426b5f3ef4f423acb68a5868fae71089371cef7 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Fri, 13 Jun 2025 16:04:32 +0530 Subject: [PATCH] feat(reporting): enhance overview reports with memoization and dark mode support - Refactored components in the reporting section to utilize React.memo, useCallback, and useMemo for improved performance and reduced unnecessary re-renders. - Updated the OverviewStatCard to support dark mode styling and added enhanced hover effects. - Improved the Avatars component by memoizing rendering logic and preventing event propagation. - Enhanced OverviewReportsTable with memoized columns and row props for better performance. - Applied consistent styling adjustments across various components to ensure a cohesive user experience. --- .../src/components/avatars/avatars.tsx | 74 ++++---- .../overview-reports/overview-reports.tsx | 32 ++-- .../overview-reports/overview-stat-card.tsx | 159 ++++++++++++++--- .../overview-reports/overview-stats.tsx | 167 +++++++++++------- .../overview-table/overview-reports-table.tsx | 65 ++++--- .../reporting/sidebar/reporting-sider.tsx | 4 - .../src/styles/customOverrides.css | 77 ++++++++ 7 files changed, 423 insertions(+), 155 deletions(-) diff --git a/worklenz-frontend/src/components/avatars/avatars.tsx b/worklenz-frontend/src/components/avatars/avatars.tsx index 753c6378..69130bfb 100644 --- a/worklenz-frontend/src/components/avatars/avatars.tsx +++ b/worklenz-frontend/src/components/avatars/avatars.tsx @@ -1,4 +1,5 @@ import { Avatar, Tooltip } from 'antd'; +import React, { useCallback, useMemo } from 'react'; import { InlineMember } from '@/types/teamMembers/inlineMember.types'; interface AvatarsProps { @@ -6,41 +7,54 @@ interface AvatarsProps { maxCount?: number; } -const renderAvatar = (member: InlineMember, index: number) => ( - - {member.avatar_url ? ( - e.stopPropagation()}> - - - ) : ( - e.stopPropagation()}> - - {member.end && member.names ? member.name : member.name?.charAt(0).toUpperCase()} - - - )} - -); +const Avatars: React.FC = React.memo(({ members, maxCount }) => { + const stopPropagation = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + }, []); + + const renderAvatar = useCallback((member: InlineMember, index: number) => ( + + {member.avatar_url ? ( + + + + ) : ( + + + {member.end && member.names ? member.name : member.name?.charAt(0).toUpperCase()} + + + )} + + ), [stopPropagation]); + + const visibleMembers = useMemo(() => { + return maxCount ? members.slice(0, maxCount) : members; + }, [members, maxCount]); + + const avatarElements = useMemo(() => { + return visibleMembers.map((member, index) => renderAvatar(member, index)); + }, [visibleMembers, renderAvatar]); -const Avatars: React.FC = ({ members, maxCount }) => { - const visibleMembers = maxCount ? members.slice(0, maxCount) : members; return ( -
e.stopPropagation()}> +
- {visibleMembers.map((member, index) => renderAvatar(member, index))} + {avatarElements}
); -}; +}); + +Avatars.displayName = 'Avatars'; export default Avatars; diff --git a/worklenz-frontend/src/pages/reporting/overview-reports/overview-reports.tsx b/worklenz-frontend/src/pages/reporting/overview-reports/overview-reports.tsx index 3ca14c17..ea91b756 100644 --- a/worklenz-frontend/src/pages/reporting/overview-reports/overview-reports.tsx +++ b/worklenz-frontend/src/pages/reporting/overview-reports/overview-reports.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect, useCallback, useMemo } from 'react'; import { Button, Card, Checkbox, Flex, Typography } from 'antd'; import { useTranslation } from 'react-i18next'; import { useDocumentTitle } from '@/hooks/useDoumentTItle'; @@ -25,29 +25,37 @@ const OverviewReports = () => { trackMixpanelEvent(evt_reporting_overview); }, [trackMixpanelEvent]); - const handleArchiveToggle = () => { + const handleArchiveToggle = useCallback(() => { dispatch(toggleIncludeArchived()); - }; + }, [dispatch]); + + // Memoize the header children to prevent unnecessary re-renders + const headerChildren = useMemo(() => ( + + ), [handleArchiveToggle, includeArchivedProjects, t]); + + // Memoize the teams text to prevent unnecessary re-renders + const teamsText = useMemo(() => ( + + {t('teamsText')} + + ), [t]); return ( - - {t('includeArchivedButton')} - - } + children={headerChildren} /> - - {t('teamsText')} - + {teamsText} diff --git a/worklenz-frontend/src/pages/reporting/overview-reports/overview-stat-card.tsx b/worklenz-frontend/src/pages/reporting/overview-reports/overview-stat-card.tsx index d437f5cb..418f676a 100644 --- a/worklenz-frontend/src/pages/reporting/overview-reports/overview-stat-card.tsx +++ b/worklenz-frontend/src/pages/reporting/overview-reports/overview-stat-card.tsx @@ -1,32 +1,151 @@ -import { ReactNode } from 'react'; -import { Card, Flex, Typography } from 'antd'; +import { Card, Flex, Typography, theme } from 'antd'; +import React, { useMemo } from 'react'; -type InsightCardProps = { - icon: ReactNode; +interface InsightCardProps { + icon: React.ReactNode; title: string; - children: ReactNode; + children: React.ReactNode; loading?: boolean; -}; +} + +const OverviewStatCard = React.memo(({ icon, title, children, loading = false }: InsightCardProps) => { + const { token } = theme.useToken(); + // Better dark mode detection using multiple token properties + const isDarkMode = token.colorBgContainer === '#1f1f1f' || + token.colorBgBase === '#141414' || + token.colorBgElevated === '#1f1f1f' || + document.documentElement.getAttribute('data-theme') === 'dark' || + document.body.classList.contains('dark'); + + // Memoize enhanced card styles with dark mode support + const cardStyles = useMemo(() => ({ + body: { + padding: '24px', + background: isDarkMode + ? '#1f1f1f !important' + : '#ffffff !important', + } + }), [isDarkMode]); + + // Memoize card container styles with dark mode support + const cardContainerStyle = useMemo(() => ({ + width: '100%', + borderRadius: '0px', + border: isDarkMode + ? '1px solid #303030' + : '1px solid #f0f0f0', + boxShadow: isDarkMode + ? '0 2px 8px rgba(0, 0, 0, 0.3)' + : '0 2px 8px rgba(0, 0, 0, 0.06)', + transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)', + overflow: 'hidden', + position: 'relative' as const, + cursor: 'default', + backgroundColor: isDarkMode ? '#1f1f1f !important' : '#ffffff !important', + }), [isDarkMode]); + + // Memoize icon container styles with dark mode support + const iconContainerStyle = useMemo(() => ({ + padding: '12px', + borderRadius: '0px', + background: isDarkMode + ? '#2a2a2a' + : '#f8f9ff', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + minWidth: '64px', + minHeight: '64px', + boxShadow: isDarkMode + ? '0 2px 4px rgba(24, 144, 255, 0.2)' + : '0 2px 4px rgba(24, 144, 255, 0.1)', + border: isDarkMode + ? '1px solid #404040' + : '1px solid rgba(24, 144, 255, 0.1)', + }), [isDarkMode]); + + // Memoize title styles with dark mode support + const titleStyle = useMemo(() => ({ + fontSize: '18px', + fontWeight: 600, + color: isDarkMode ? '#ffffff !important' : '#262626 !important', + marginBottom: '8px', + lineHeight: '1.4', + }), [isDarkMode]); + + // Memoize decorative element styles with dark mode support + const decorativeStyle = useMemo(() => ({ + position: 'absolute' as const, + top: 0, + right: 0, + width: '60px', + height: '60px', + background: isDarkMode + ? 'linear-gradient(135deg, rgba(24, 144, 255, 0.15) 0%, rgba(24, 144, 255, 0.08) 100%)' + : 'linear-gradient(135deg, rgba(24, 144, 255, 0.05) 0%, rgba(24, 144, 255, 0.02) 100%)', + opacity: isDarkMode ? 0.8 : 0.6, + clipPath: 'polygon(100% 0%, 0% 100%, 100% 100%)', + }), [isDarkMode]); -const OverviewStatCard = ({ icon, title, children, loading = false }: InsightCardProps) => { return ( - - - {icon} + + +
+ {icon} +
- - {title} + + + {title} + - <>{children} +
+ {children} +
+
-
-
+ + {/* Decorative element */} +
+ +
); -}; +}); + +OverviewStatCard.displayName = 'OverviewStatCard'; export default OverviewStatCard; diff --git a/worklenz-frontend/src/pages/reporting/overview-reports/overview-stats.tsx b/worklenz-frontend/src/pages/reporting/overview-reports/overview-stats.tsx index fd2a4f5c..9c889d3d 100644 --- a/worklenz-frontend/src/pages/reporting/overview-reports/overview-stats.tsx +++ b/worklenz-frontend/src/pages/reporting/overview-reports/overview-stats.tsx @@ -1,5 +1,5 @@ -import { Flex, Typography } from 'antd'; -import React, { useEffect, useState } from 'react'; +import { Flex, Typography, theme } from 'antd'; +import React, { useEffect, useState, useCallback, useMemo } from 'react'; import OverviewStatCard from './overview-stat-card'; import { BankOutlined, FileOutlined, UsergroupAddOutlined } from '@ant-design/icons'; import { colors } from '@/styles/colors'; @@ -12,11 +12,12 @@ const OverviewStats = () => { const [stats, setStats] = useState({}); const [loading, setLoading] = useState(false); const { t } = useTranslation('reporting-overview'); + const { token } = theme.useToken(); const includeArchivedProjects = useAppSelector( state => state.reportingReducer.includeArchivedProjects ); - const getOverviewStats = async () => { + const getOverviewStats = useCallback(async () => { setLoading(true); try { const { done, body } = @@ -29,17 +30,17 @@ const OverviewStats = () => { } finally { setLoading(false); } - }; + }, [includeArchivedProjects]); useEffect(() => { getOverviewStats(); - }, [includeArchivedProjects]); + }, [getOverviewStats]); - const renderStatText = (count: number = 0, singularKey: string, pluralKey: string) => { + const renderStatText = useCallback((count: number = 0, singularKey: string, pluralKey: string) => { return `${count} ${count === 1 ? t(singularKey) : t(pluralKey)}`; - }; + }, [t]); - const renderStatCard = ( + const renderStatCard = useCallback(( icon: React.ReactNode, mainCount: number = 0, mainKey: string, @@ -52,81 +53,127 @@ const OverviewStats = () => { > {stats.map((stat, index) => ( - + {stat.text} ))} - ); + ), [renderStatText, loading, token]); + + // Memoize team stats to prevent unnecessary recalculations + const teamStats = useMemo(() => [ + { + text: renderStatText(stats?.teams?.projects, 'projectCount', 'projectCountPlural'), + type: 'secondary' as const, + }, + { + text: renderStatText(stats?.teams?.members, 'memberCount', 'memberCountPlural'), + type: 'secondary' as const, + }, + ], [stats?.teams?.projects, stats?.teams?.members, renderStatText]); + + // Memoize project stats to prevent unnecessary recalculations + const projectStats = useMemo(() => [ + { + text: renderStatText( + stats?.projects?.active, + 'activeProjectCount', + 'activeProjectCountPlural' + ), + type: 'secondary' as const, + }, + { + text: renderStatText( + stats?.projects?.overdue, + 'overdueProjectCount', + 'overdueProjectCountPlural' + ), + type: 'danger' as const, + }, + ], [stats?.projects?.active, stats?.projects?.overdue, renderStatText]); + + // Memoize member stats to prevent unnecessary recalculations + const memberStats = useMemo(() => [ + { + text: renderStatText( + stats?.members?.unassigned, + 'unassignedMemberCount', + 'unassignedMemberCountPlural' + ), + type: 'secondary' as const, + }, + { + text: renderStatText( + stats?.members?.overdue, + 'memberWithOverdueTaskCount', + 'memberWithOverdueTaskCountPlural' + ), + type: 'danger' as const, + }, + ], [stats?.members?.unassigned, stats?.members?.overdue, renderStatText]); + + // Memoize icons with enhanced styling for better visibility + const teamIcon = useMemo(() => ( + + ), []); + + const projectIcon = useMemo(() => ( + + ), []); + + const memberIcon = useMemo(() => ( + + ), []); return ( {renderStatCard( - , + teamIcon, stats?.teams?.count, 'teamCount', - [ - { - text: renderStatText(stats?.teams?.projects, 'projectCount', 'projectCountPlural'), - type: 'secondary', - }, - { - text: renderStatText(stats?.teams?.members, 'memberCount', 'memberCountPlural'), - type: 'secondary', - }, - ] + teamStats )} {renderStatCard( - , + projectIcon, stats?.projects?.count, 'projectCount', - [ - { - text: renderStatText( - stats?.projects?.active, - 'activeProjectCount', - 'activeProjectCountPlural' - ), - type: 'secondary', - }, - { - text: renderStatText( - stats?.projects?.overdue, - 'overdueProjectCount', - 'overdueProjectCountPlural' - ), - type: 'danger', - }, - ] + projectStats )} {renderStatCard( - , + memberIcon, stats?.members?.count, 'memberCount', - [ - { - text: renderStatText( - stats?.members?.unassigned, - 'unassignedMemberCount', - 'unassignedMemberCountPlural' - ), - type: 'secondary', - }, - { - text: renderStatText( - stats?.members?.overdue, - 'memberWithOverdueTaskCount', - 'memberWithOverdueTaskCountPlural' - ), - type: 'danger', - }, - ] + memberStats )} ); }; -export default OverviewStats; +export default React.memo(OverviewStats); diff --git a/worklenz-frontend/src/pages/reporting/overview-reports/overview-table/overview-reports-table.tsx b/worklenz-frontend/src/pages/reporting/overview-reports/overview-table/overview-reports-table.tsx index fcb8dc14..fdc8149a 100644 --- a/worklenz-frontend/src/pages/reporting/overview-reports/overview-table/overview-reports-table.tsx +++ b/worklenz-frontend/src/pages/reporting/overview-reports/overview-table/overview-reports-table.tsx @@ -1,4 +1,4 @@ -import { memo, useEffect, useState } from 'react'; +import { memo, useEffect, useState, useCallback, useMemo } from 'react'; import { ConfigProvider, Table, TableColumnsType } from 'antd'; import { useAppDispatch } from '../../../../hooks/useAppDispatch'; import CustomTableTitle from '../../../../components/CustomTableTitle'; @@ -11,7 +11,7 @@ import Avatars from '@/components/avatars/avatars'; import OverviewTeamInfoDrawer from '@/components/reporting/drawers/overview-team-info/overview-team-info-drawer'; import { toggleOverViewTeamDrawer } from '@/features/reporting/reporting.slice'; -const OverviewReportsTable = () => { +const OverviewReportsTable = memo(() => { const { t } = useTranslation('reporting-overview'); const dispatch = useAppDispatch(); @@ -22,7 +22,7 @@ const OverviewReportsTable = () => { const [teams, setTeams] = useState([]); const [loading, setLoading] = useState(false); - const getTeams = async () => { + const getTeams = useCallback(async () => { setLoading(true); try { const { done, body } = await reportingApiService.getOverviewTeams(includeArchivedProjects); @@ -34,18 +34,19 @@ const OverviewReportsTable = () => { } finally { setLoading(false); } - }; + }, [includeArchivedProjects]); useEffect(() => { getTeams(); - }, [includeArchivedProjects]); + }, [getTeams]); - const handleDrawerOpen = (team: IRPTTeam) => { + const handleDrawerOpen = useCallback((team: IRPTTeam) => { setSelectedTeam(team); dispatch(toggleOverViewTeamDrawer()); - }; + }, [dispatch]); - const columns: TableColumnsType = [ + // Memoize table columns to prevent recreation on every render + const columns: TableColumnsType = useMemo(() => [ { key: 'name', title: , @@ -61,39 +62,45 @@ const OverviewReportsTable = () => { { key: 'members', title: , - render: record => , + render: (record: IRPTTeam) => , }, - ]; + ], [t]); + + // Memoize table configuration + const tableConfig = useMemo(() => ({ + theme: { + components: { + Table: { + cellPaddingBlock: 8, + cellPaddingInline: 10, + }, + }, + }, + }), []); + + // Memoize row props generator + const getRowProps = useCallback((record: IRPTTeam) => ({ + onClick: () => handleDrawerOpen(record), + style: { height: 48, cursor: 'pointer' }, + className: 'group even:bg-[#4e4e4e10]', + }), [handleDrawerOpen]); return ( - + record.id} loading={loading} - onRow={record => { - return { - onClick: () => handleDrawerOpen(record as IRPTTeam), - style: { height: 48, cursor: 'pointer' }, - className: 'group even:bg-[#4e4e4e10]', - }; - }} + onRow={getRowProps} /> ); -}; +}); -export default memo(OverviewReportsTable); +OverviewReportsTable.displayName = 'OverviewReportsTable'; + +export default OverviewReportsTable; diff --git a/worklenz-frontend/src/pages/reporting/sidebar/reporting-sider.tsx b/worklenz-frontend/src/pages/reporting/sidebar/reporting-sider.tsx index 591616fb..46cb2beb 100644 --- a/worklenz-frontend/src/pages/reporting/sidebar/reporting-sider.tsx +++ b/worklenz-frontend/src/pages/reporting/sidebar/reporting-sider.tsx @@ -42,10 +42,6 @@ const ReportingSider = () => { theme={{ components: { Menu: { - itemHoverBg: colors.transparent, - itemHoverColor: colors.skyBlue, - borderRadius: 12, - itemMarginBlock: 4, subMenuItemBg: colors.transparent, }, }, diff --git a/worklenz-frontend/src/styles/customOverrides.css b/worklenz-frontend/src/styles/customOverrides.css index f12a3186..b4332cfb 100644 --- a/worklenz-frontend/src/styles/customOverrides.css +++ b/worklenz-frontend/src/styles/customOverrides.css @@ -79,6 +79,83 @@ border: 1px solid #d3d3d3; } +/* Enhanced overview stat card styles */ +.overview-stat-card { + border-radius: 0 !important; +} + +.overview-stat-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12) !important; +} + +/* Light mode stat cards */ +.overview-stat-card.light-mode { + background-color: #ffffff !important; + border: 1px solid #f0f0f0 !important; +} + +.overview-stat-card.light-mode:hover { + border-color: #1890ff !important; + box-shadow: 0 4px 16px rgba(24, 144, 255, 0.15) !important; +} + +.overview-stat-card.light-mode .ant-card { + background-color: transparent !important; + border: none !important; +} + +.overview-stat-card.light-mode .ant-card-body { + background-color: transparent !important; +} + +/* Dark mode stat cards */ +.overview-stat-card.dark-mode { + background-color: #1f1f1f !important; + border: 1px solid #303030 !important; +} + +.overview-stat-card.dark-mode:hover { + border-color: #1890ff !important; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4) !important; +} + +.overview-stat-card.dark-mode .ant-card { + background-color: transparent !important; + border: none !important; +} + +.overview-stat-card.dark-mode .ant-card-body { + background-color: transparent !important; +} + +/* Force dark mode styles when body has dark class or data attribute */ +body.dark .overview-stat-card, +[data-theme="dark"] .overview-stat-card, +.ant-theme-dark .overview-stat-card { + background-color: #1f1f1f !important; + border: 1px solid #303030 !important; +} + +body.dark .overview-stat-card .ant-card, +[data-theme="dark"] .overview-stat-card .ant-card, +.ant-theme-dark .overview-stat-card .ant-card { + background-color: transparent !important; + border: none !important; +} + +body.dark .overview-stat-card .ant-card-body, +[data-theme="dark"] .overview-stat-card .ant-card-body, +.ant-theme-dark .overview-stat-card .ant-card-body { + background-color: transparent !important; +} + +/* Ensure no border radius on card components */ +.overview-stat-card .ant-card, +.overview-stat-card .ant-card-body { + border-radius: 0 !important; +} + /* reporting sidebar */ .custom-reporting-sider .ant-menu-item-selected { border-inline-end: 3px solid #1890ff !important;