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:
@@ -1,4 +1,5 @@
|
|||||||
import { Avatar, Tooltip } from 'antd';
|
import { Avatar, Tooltip } from 'antd';
|
||||||
|
import React, { useCallback, useMemo } from 'react';
|
||||||
import { InlineMember } from '@/types/teamMembers/inlineMember.types';
|
import { InlineMember } from '@/types/teamMembers/inlineMember.types';
|
||||||
|
|
||||||
interface AvatarsProps {
|
interface AvatarsProps {
|
||||||
@@ -6,41 +7,54 @@ interface AvatarsProps {
|
|||||||
maxCount?: number;
|
maxCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderAvatar = (member: InlineMember, index: number) => (
|
const Avatars: React.FC<AvatarsProps> = React.memo(({ members, maxCount }) => {
|
||||||
<Tooltip
|
const stopPropagation = useCallback((e: React.MouseEvent) => {
|
||||||
key={member.team_member_id || index}
|
e.stopPropagation();
|
||||||
title={member.end && member.names ? member.names.join(', ') : member.name}
|
}, []);
|
||||||
>
|
|
||||||
{member.avatar_url ? (
|
const renderAvatar = useCallback((member: InlineMember, index: number) => (
|
||||||
<span onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
<Tooltip
|
||||||
<Avatar src={member.avatar_url} size={28} key={member.team_member_id || index} />
|
key={member.team_member_id || index}
|
||||||
</span>
|
title={member.end && member.names ? member.names.join(', ') : member.name}
|
||||||
) : (
|
>
|
||||||
<span onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
{member.avatar_url ? (
|
||||||
<Avatar
|
<span onClick={stopPropagation}>
|
||||||
size={28}
|
<Avatar src={member.avatar_url} size={28} key={member.team_member_id || index} />
|
||||||
key={member.team_member_id || index}
|
</span>
|
||||||
style={{
|
) : (
|
||||||
backgroundColor: member.color_code || '#ececec',
|
<span onClick={stopPropagation}>
|
||||||
fontSize: '14px',
|
<Avatar
|
||||||
}}
|
size={28}
|
||||||
>
|
key={member.team_member_id || index}
|
||||||
{member.end && member.names ? member.name : member.name?.charAt(0).toUpperCase()}
|
style={{
|
||||||
</Avatar>
|
backgroundColor: member.color_code || '#ececec',
|
||||||
</span>
|
fontSize: '14px',
|
||||||
)}
|
}}
|
||||||
</Tooltip>
|
>
|
||||||
);
|
{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 (
|
return (
|
||||||
<div onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
<div onClick={stopPropagation}>
|
||||||
<Avatar.Group>
|
<Avatar.Group>
|
||||||
{visibleMembers.map((member, index) => renderAvatar(member, index))}
|
{avatarElements}
|
||||||
</Avatar.Group>
|
</Avatar.Group>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
|
Avatars.displayName = 'Avatars';
|
||||||
|
|
||||||
export default Avatars;
|
export default Avatars;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useCallback, useMemo } from 'react';
|
||||||
import { Button, Card, Checkbox, Flex, Typography } from 'antd';
|
import { Button, Card, Checkbox, Flex, Typography } from 'antd';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
||||||
@@ -25,29 +25,37 @@ const OverviewReports = () => {
|
|||||||
trackMixpanelEvent(evt_reporting_overview);
|
trackMixpanelEvent(evt_reporting_overview);
|
||||||
}, [trackMixpanelEvent]);
|
}, [trackMixpanelEvent]);
|
||||||
|
|
||||||
const handleArchiveToggle = () => {
|
const handleArchiveToggle = useCallback(() => {
|
||||||
dispatch(toggleIncludeArchived());
|
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 (
|
return (
|
||||||
<Flex vertical gap={24}>
|
<Flex vertical gap={24}>
|
||||||
<CustomPageHeader
|
<CustomPageHeader
|
||||||
title={t('overviewTitle')}
|
title={t('overviewTitle')}
|
||||||
children={
|
children={headerChildren}
|
||||||
<Button type="text" onClick={handleArchiveToggle}>
|
|
||||||
<Checkbox checked={includeArchivedProjects} />
|
|
||||||
<Typography.Text>{t('includeArchivedButton')}</Typography.Text>
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<OverviewStats />
|
<OverviewStats />
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<Flex vertical gap={12}>
|
<Flex vertical gap={12}>
|
||||||
<Typography.Text strong style={{ fontSize: 16 }}>
|
{teamsText}
|
||||||
{t('teamsText')}
|
|
||||||
</Typography.Text>
|
|
||||||
<OverviewReportsTable />
|
<OverviewReportsTable />
|
||||||
</Flex>
|
</Flex>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,32 +1,151 @@
|
|||||||
import { ReactNode } from 'react';
|
import { Card, Flex, Typography, theme } from 'antd';
|
||||||
import { Card, Flex, Typography } from 'antd';
|
import React, { useMemo } from 'react';
|
||||||
|
|
||||||
type InsightCardProps = {
|
interface InsightCardProps {
|
||||||
icon: ReactNode;
|
icon: React.ReactNode;
|
||||||
title: string;
|
title: string;
|
||||||
children: ReactNode;
|
children: React.ReactNode;
|
||||||
loading?: boolean;
|
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 (
|
return (
|
||||||
<Card
|
<div
|
||||||
className="custom-insights-card"
|
className={`overview-stat-card ${isDarkMode ? 'dark-mode' : 'light-mode'}`}
|
||||||
style={{ width: '100%' }}
|
style={{
|
||||||
styles={{ body: { paddingInline: 16 } }}
|
backgroundColor: isDarkMode ? '#1f1f1f' : '#ffffff',
|
||||||
loading={loading}
|
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">
|
<Card
|
||||||
{icon}
|
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}>
|
<Flex vertical gap={8} style={{ flex: 1, minWidth: 0 }}>
|
||||||
<Typography.Text style={{ fontSize: 16 }}>{title}</Typography.Text>
|
<Typography.Text style={titleStyle}>
|
||||||
|
{title}
|
||||||
|
</Typography.Text>
|
||||||
|
|
||||||
<>{children}</>
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '6px',
|
||||||
|
marginTop: '4px'
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
|
||||||
</Card>
|
{/* Decorative element */}
|
||||||
|
<div style={decorativeStyle} />
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
|
OverviewStatCard.displayName = 'OverviewStatCard';
|
||||||
|
|
||||||
export default OverviewStatCard;
|
export default OverviewStatCard;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Flex, Typography } from 'antd';
|
import { Flex, Typography, theme } from 'antd';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
||||||
import OverviewStatCard from './overview-stat-card';
|
import OverviewStatCard from './overview-stat-card';
|
||||||
import { BankOutlined, FileOutlined, UsergroupAddOutlined } from '@ant-design/icons';
|
import { BankOutlined, FileOutlined, UsergroupAddOutlined } from '@ant-design/icons';
|
||||||
import { colors } from '@/styles/colors';
|
import { colors } from '@/styles/colors';
|
||||||
@@ -12,11 +12,12 @@ const OverviewStats = () => {
|
|||||||
const [stats, setStats] = useState<IRPTOverviewStatistics>({});
|
const [stats, setStats] = useState<IRPTOverviewStatistics>({});
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const { t } = useTranslation('reporting-overview');
|
const { t } = useTranslation('reporting-overview');
|
||||||
|
const { token } = theme.useToken();
|
||||||
const includeArchivedProjects = useAppSelector(
|
const includeArchivedProjects = useAppSelector(
|
||||||
state => state.reportingReducer.includeArchivedProjects
|
state => state.reportingReducer.includeArchivedProjects
|
||||||
);
|
);
|
||||||
|
|
||||||
const getOverviewStats = async () => {
|
const getOverviewStats = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const { done, body } =
|
const { done, body } =
|
||||||
@@ -29,17 +30,17 @@ const OverviewStats = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [includeArchivedProjects]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getOverviewStats();
|
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)}`;
|
return `${count} ${count === 1 ? t(singularKey) : t(pluralKey)}`;
|
||||||
};
|
}, [t]);
|
||||||
|
|
||||||
const renderStatCard = (
|
const renderStatCard = useCallback((
|
||||||
icon: React.ReactNode,
|
icon: React.ReactNode,
|
||||||
mainCount: number = 0,
|
mainCount: number = 0,
|
||||||
mainKey: string,
|
mainKey: string,
|
||||||
@@ -52,81 +53,127 @@ const OverviewStats = () => {
|
|||||||
>
|
>
|
||||||
<Flex vertical>
|
<Flex vertical>
|
||||||
{stats.map((stat, index) => (
|
{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}
|
{stat.text}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
))}
|
))}
|
||||||
</Flex>
|
</Flex>
|
||||||
</OverviewStatCard>
|
</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 (
|
return (
|
||||||
<Flex gap={24}>
|
<Flex gap={24}>
|
||||||
{renderStatCard(
|
{renderStatCard(
|
||||||
<BankOutlined style={{ color: colors.skyBlue, fontSize: 42 }} />,
|
teamIcon,
|
||||||
stats?.teams?.count,
|
stats?.teams?.count,
|
||||||
'teamCount',
|
'teamCount',
|
||||||
[
|
teamStats
|
||||||
{
|
|
||||||
text: renderStatText(stats?.teams?.projects, 'projectCount', 'projectCountPlural'),
|
|
||||||
type: 'secondary',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: renderStatText(stats?.teams?.members, 'memberCount', 'memberCountPlural'),
|
|
||||||
type: 'secondary',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{renderStatCard(
|
{renderStatCard(
|
||||||
<FileOutlined style={{ color: colors.limeGreen, fontSize: 42 }} />,
|
projectIcon,
|
||||||
stats?.projects?.count,
|
stats?.projects?.count,
|
||||||
'projectCount',
|
'projectCount',
|
||||||
[
|
projectStats
|
||||||
{
|
|
||||||
text: renderStatText(
|
|
||||||
stats?.projects?.active,
|
|
||||||
'activeProjectCount',
|
|
||||||
'activeProjectCountPlural'
|
|
||||||
),
|
|
||||||
type: 'secondary',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: renderStatText(
|
|
||||||
stats?.projects?.overdue,
|
|
||||||
'overdueProjectCount',
|
|
||||||
'overdueProjectCountPlural'
|
|
||||||
),
|
|
||||||
type: 'danger',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{renderStatCard(
|
{renderStatCard(
|
||||||
<UsergroupAddOutlined style={{ color: colors.lightGray, fontSize: 42 }} />,
|
memberIcon,
|
||||||
stats?.members?.count,
|
stats?.members?.count,
|
||||||
'memberCount',
|
'memberCount',
|
||||||
[
|
memberStats
|
||||||
{
|
|
||||||
text: renderStatText(
|
|
||||||
stats?.members?.unassigned,
|
|
||||||
'unassignedMemberCount',
|
|
||||||
'unassignedMemberCountPlural'
|
|
||||||
),
|
|
||||||
type: 'secondary',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: renderStatText(
|
|
||||||
stats?.members?.overdue,
|
|
||||||
'memberWithOverdueTaskCount',
|
|
||||||
'memberWithOverdueTaskCountPlural'
|
|
||||||
),
|
|
||||||
type: 'danger',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
)}
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default OverviewStats;
|
export default React.memo(OverviewStats);
|
||||||
|
|||||||
@@ -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 { ConfigProvider, Table, TableColumnsType } from 'antd';
|
||||||
import { useAppDispatch } from '../../../../hooks/useAppDispatch';
|
import { useAppDispatch } from '../../../../hooks/useAppDispatch';
|
||||||
import CustomTableTitle from '../../../../components/CustomTableTitle';
|
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 OverviewTeamInfoDrawer from '@/components/reporting/drawers/overview-team-info/overview-team-info-drawer';
|
||||||
import { toggleOverViewTeamDrawer } from '@/features/reporting/reporting.slice';
|
import { toggleOverViewTeamDrawer } from '@/features/reporting/reporting.slice';
|
||||||
|
|
||||||
const OverviewReportsTable = () => {
|
const OverviewReportsTable = memo(() => {
|
||||||
const { t } = useTranslation('reporting-overview');
|
const { t } = useTranslation('reporting-overview');
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ const OverviewReportsTable = () => {
|
|||||||
const [teams, setTeams] = useState<IRPTTeam[]>([]);
|
const [teams, setTeams] = useState<IRPTTeam[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const getTeams = async () => {
|
const getTeams = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const { done, body } = await reportingApiService.getOverviewTeams(includeArchivedProjects);
|
const { done, body } = await reportingApiService.getOverviewTeams(includeArchivedProjects);
|
||||||
@@ -34,18 +34,19 @@ const OverviewReportsTable = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [includeArchivedProjects]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getTeams();
|
getTeams();
|
||||||
}, [includeArchivedProjects]);
|
}, [getTeams]);
|
||||||
|
|
||||||
const handleDrawerOpen = (team: IRPTTeam) => {
|
const handleDrawerOpen = useCallback((team: IRPTTeam) => {
|
||||||
setSelectedTeam(team);
|
setSelectedTeam(team);
|
||||||
dispatch(toggleOverViewTeamDrawer());
|
dispatch(toggleOverViewTeamDrawer());
|
||||||
};
|
}, [dispatch]);
|
||||||
|
|
||||||
const columns: TableColumnsType = [
|
// Memoize table columns to prevent recreation on every render
|
||||||
|
const columns: TableColumnsType<IRPTTeam> = useMemo(() => [
|
||||||
{
|
{
|
||||||
key: 'name',
|
key: 'name',
|
||||||
title: <CustomTableTitle title={t('nameColumn')} />,
|
title: <CustomTableTitle title={t('nameColumn')} />,
|
||||||
@@ -61,39 +62,45 @@ const OverviewReportsTable = () => {
|
|||||||
{
|
{
|
||||||
key: 'members',
|
key: 'members',
|
||||||
title: <CustomTableTitle title={t('membersColumn')} />,
|
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 (
|
return (
|
||||||
<ConfigProvider
|
<ConfigProvider {...tableConfig}>
|
||||||
theme={{
|
|
||||||
components: {
|
|
||||||
Table: {
|
|
||||||
cellPaddingBlock: 8,
|
|
||||||
cellPaddingInline: 10,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Table
|
<Table
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={teams}
|
dataSource={teams}
|
||||||
scroll={{ x: 'max-content' }}
|
scroll={{ x: 'max-content' }}
|
||||||
rowKey={record => record.id}
|
rowKey={record => record.id}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onRow={record => {
|
onRow={getRowProps}
|
||||||
return {
|
|
||||||
onClick: () => handleDrawerOpen(record as IRPTTeam),
|
|
||||||
style: { height: 48, cursor: 'pointer' },
|
|
||||||
className: 'group even:bg-[#4e4e4e10]',
|
|
||||||
};
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<OverviewTeamInfoDrawer team={selectedTeam} />
|
<OverviewTeamInfoDrawer team={selectedTeam} />
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
export default memo(OverviewReportsTable);
|
OverviewReportsTable.displayName = 'OverviewReportsTable';
|
||||||
|
|
||||||
|
export default OverviewReportsTable;
|
||||||
|
|||||||
@@ -42,10 +42,6 @@ const ReportingSider = () => {
|
|||||||
theme={{
|
theme={{
|
||||||
components: {
|
components: {
|
||||||
Menu: {
|
Menu: {
|
||||||
itemHoverBg: colors.transparent,
|
|
||||||
itemHoverColor: colors.skyBlue,
|
|
||||||
borderRadius: 12,
|
|
||||||
itemMarginBlock: 4,
|
|
||||||
subMenuItemBg: colors.transparent,
|
subMenuItemBg: colors.transparent,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -79,6 +79,83 @@
|
|||||||
border: 1px solid #d3d3d3;
|
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 */
|
/* reporting sidebar */
|
||||||
.custom-reporting-sider .ant-menu-item-selected {
|
.custom-reporting-sider .ant-menu-item-selected {
|
||||||
border-inline-end: 3px solid #1890ff !important;
|
border-inline-end: 3px solid #1890ff !important;
|
||||||
|
|||||||
Reference in New Issue
Block a user