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.
This commit is contained in:
chamikaJ
2025-06-13 16:04:32 +05:30
parent d0c231ee43
commit 4426b5f3ef
7 changed files with 423 additions and 155 deletions

View File

@@ -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) => (
<Tooltip
key={member.team_member_id || index}
title={member.end && member.names ? member.names.join(', ') : member.name}
>
{member.avatar_url ? (
<span onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<Avatar src={member.avatar_url} size={28} key={member.team_member_id || index} />
</span>
) : (
<span onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<Avatar
size={28}
key={member.team_member_id || index}
style={{
backgroundColor: member.color_code || '#ececec',
fontSize: '14px',
}}
>
{member.end && member.names ? member.name : member.name?.charAt(0).toUpperCase()}
</Avatar>
</span>
)}
</Tooltip>
);
const Avatars: React.FC<AvatarsProps> = React.memo(({ members, maxCount }) => {
const stopPropagation = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
}, []);
const renderAvatar = useCallback((member: InlineMember, index: number) => (
<Tooltip
key={member.team_member_id || index}
title={member.end && member.names ? member.names.join(', ') : member.name}
>
{member.avatar_url ? (
<span onClick={stopPropagation}>
<Avatar src={member.avatar_url} size={28} key={member.team_member_id || index} />
</span>
) : (
<span onClick={stopPropagation}>
<Avatar
size={28}
key={member.team_member_id || index}
style={{
backgroundColor: member.color_code || '#ececec',
fontSize: '14px',
}}
>
{member.end && member.names ? member.name : member.name?.charAt(0).toUpperCase()}
</Avatar>
</span>
)}
</Tooltip>
), [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<AvatarsProps> = ({ members, maxCount }) => {
const visibleMembers = maxCount ? members.slice(0, maxCount) : members;
return (
<div onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<div onClick={stopPropagation}>
<Avatar.Group>
{visibleMembers.map((member, index) => renderAvatar(member, index))}
{avatarElements}
</Avatar.Group>
</div>
);
};
});
Avatars.displayName = 'Avatars';
export default Avatars;

View File

@@ -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(() => (
<Button type="text" onClick={handleArchiveToggle}>
<Checkbox checked={includeArchivedProjects} />
<Typography.Text>{t('includeArchivedButton')}</Typography.Text>
</Button>
), [handleArchiveToggle, includeArchivedProjects, t]);
// Memoize the teams text to prevent unnecessary re-renders
const teamsText = useMemo(() => (
<Typography.Text strong style={{ fontSize: 16 }}>
{t('teamsText')}
</Typography.Text>
), [t]);
return (
<Flex vertical gap={24}>
<CustomPageHeader
title={t('overviewTitle')}
children={
<Button type="text" onClick={handleArchiveToggle}>
<Checkbox checked={includeArchivedProjects} />
<Typography.Text>{t('includeArchivedButton')}</Typography.Text>
</Button>
}
children={headerChildren}
/>
<OverviewStats />
<Card>
<Flex vertical gap={12}>
<Typography.Text strong style={{ fontSize: 16 }}>
{t('teamsText')}
</Typography.Text>
{teamsText}
<OverviewReportsTable />
</Flex>
</Card>

View File

@@ -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 (
<Card
className="custom-insights-card"
style={{ width: '100%' }}
styles={{ body: { paddingInline: 16 } }}
loading={loading}
<div
className={`overview-stat-card ${isDarkMode ? 'dark-mode' : 'light-mode'}`}
style={{
backgroundColor: isDarkMode ? '#1f1f1f' : '#ffffff',
border: isDarkMode ? '1px solid #303030' : '1px solid #f0f0f0',
borderRadius: '0px',
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',
cursor: 'default',
width: '100%',
}}
>
<Flex gap={16} align="flex-start">
{icon}
<Card
style={{
backgroundColor: 'transparent',
border: 'none',
borderRadius: '0px',
}}
styles={{
body: {
padding: '24px',
backgroundColor: 'transparent',
}
}}
loading={loading}
>
<Flex gap={20} align="flex-start">
<div style={iconContainerStyle}>
{icon}
</div>
<Flex vertical gap={12}>
<Typography.Text style={{ fontSize: 16 }}>{title}</Typography.Text>
<Flex vertical gap={8} style={{ flex: 1, minWidth: 0 }}>
<Typography.Text style={titleStyle}>
{title}
</Typography.Text>
<>{children}</>
<div style={{
display: 'flex',
flexDirection: 'column',
gap: '6px',
marginTop: '4px'
}}>
{children}
</div>
</Flex>
</Flex>
</Flex>
</Card>
{/* Decorative element */}
<div style={decorativeStyle} />
</Card>
</div>
);
};
});
OverviewStatCard.displayName = 'OverviewStatCard';
export default OverviewStatCard;

View File

@@ -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<IRPTOverviewStatistics>({});
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 = () => {
>
<Flex vertical>
{stats.map((stat, index) => (
<Typography.Text key={index} type={stat.type}>
<Typography.Text
key={index}
type={stat.type}
style={{
fontSize: '14px',
lineHeight: '1.5',
color: stat.type === 'danger'
? '#ff4d4f'
: stat.type === 'secondary'
? token.colorTextSecondary
: token.colorText
}}
>
{stat.text}
</Typography.Text>
))}
</Flex>
</OverviewStatCard>
);
), [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(() => (
<BankOutlined style={{
color: colors.skyBlue,
fontSize: 42,
filter: 'drop-shadow(0 2px 4px rgba(24, 144, 255, 0.3))'
}} />
), []);
const projectIcon = useMemo(() => (
<FileOutlined style={{
color: colors.limeGreen,
fontSize: 42,
filter: 'drop-shadow(0 2px 4px rgba(82, 196, 26, 0.3))'
}} />
), []);
const memberIcon = useMemo(() => (
<UsergroupAddOutlined style={{
color: colors.lightGray,
fontSize: 42,
filter: 'drop-shadow(0 2px 4px rgba(112, 112, 112, 0.3))'
}} />
), []);
return (
<Flex gap={24}>
{renderStatCard(
<BankOutlined style={{ color: colors.skyBlue, fontSize: 42 }} />,
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(
<FileOutlined style={{ color: colors.limeGreen, fontSize: 42 }} />,
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(
<UsergroupAddOutlined style={{ color: colors.lightGray, fontSize: 42 }} />,
memberIcon,
stats?.members?.count,
'memberCount',
[
{
text: renderStatText(
stats?.members?.unassigned,
'unassignedMemberCount',
'unassignedMemberCountPlural'
),
type: 'secondary',
},
{
text: renderStatText(
stats?.members?.overdue,
'memberWithOverdueTaskCount',
'memberWithOverdueTaskCountPlural'
),
type: 'danger',
},
]
memberStats
)}
</Flex>
);
};
export default OverviewStats;
export default React.memo(OverviewStats);

View File

@@ -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<IRPTTeam[]>([]);
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<IRPTTeam> = useMemo(() => [
{
key: 'name',
title: <CustomTableTitle title={t('nameColumn')} />,
@@ -61,39 +62,45 @@ const OverviewReportsTable = () => {
{
key: 'members',
title: <CustomTableTitle title={t('membersColumn')} />,
render: record => <Avatars members={record.members} maxCount={3} />,
render: (record: IRPTTeam) => <Avatars members={record.members} maxCount={3} />,
},
];
], [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 (
<ConfigProvider
theme={{
components: {
Table: {
cellPaddingBlock: 8,
cellPaddingInline: 10,
},
},
}}
>
<ConfigProvider {...tableConfig}>
<Table
columns={columns}
dataSource={teams}
scroll={{ x: 'max-content' }}
rowKey={record => record.id}
loading={loading}
onRow={record => {
return {
onClick: () => handleDrawerOpen(record as IRPTTeam),
style: { height: 48, cursor: 'pointer' },
className: 'group even:bg-[#4e4e4e10]',
};
}}
onRow={getRowProps}
/>
<OverviewTeamInfoDrawer team={selectedTeam} />
</ConfigProvider>
);
};
});
export default memo(OverviewReportsTable);
OverviewReportsTable.displayName = 'OverviewReportsTable';
export default OverviewReportsTable;

View File

@@ -42,10 +42,6 @@ const ReportingSider = () => {
theme={{
components: {
Menu: {
itemHoverBg: colors.transparent,
itemHoverColor: colors.skyBlue,
borderRadius: 12,
itemMarginBlock: 4,
subMenuItemBg: colors.transparent,
},
},

View File

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