expand sub tasks
This commit is contained in:
@@ -124,7 +124,9 @@ const MembersReportsTable = () => {
|
||||
dataSource={membersList}
|
||||
rowKey={record => record.id}
|
||||
pagination={{ showSizeChanger: true, defaultPageSize: 10, total: total }}
|
||||
onChange={(pagination, filters, sorter, extra) => handleOnChange(pagination, filters, sorter, extra)}
|
||||
onChange={(pagination, filters, sorter, extra) =>
|
||||
handleOnChange(pagination, filters, sorter, extra)
|
||||
}
|
||||
scroll={{ x: 'max-content' }}
|
||||
loading={isLoading}
|
||||
onRow={record => {
|
||||
|
||||
@@ -22,7 +22,6 @@ const TasksProgressCell = ({ tasksStat }: TasksProgressCellProps) => {
|
||||
{ percent: donePercent, color: '#98d4b1', label: 'done' },
|
||||
{ percent: doingPercent, color: '#bce3cc', label: 'doing' },
|
||||
{ percent: todoPercent, color: '#e3f4ea', label: 'todo' },
|
||||
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -28,10 +28,14 @@ const MembersReports = () => {
|
||||
const { archived, searchQuery, total } = useAppSelector(state => state.membersReportsReducer);
|
||||
const { duration, dateRange } = useAppSelector(state => state.reportingReducer);
|
||||
|
||||
|
||||
const handleExport = () => {
|
||||
if (!currentSession?.team_name) return;
|
||||
reportingExportApiService.exportMembers(currentSession.team_name, duration, dateRange, archived);
|
||||
reportingExportApiService.exportMembers(
|
||||
currentSession.team_name,
|
||||
duration,
|
||||
dateRange,
|
||||
archived
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -30,26 +30,29 @@ const OverviewReports = () => {
|
||||
}, [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]);
|
||||
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]);
|
||||
const teamsText = useMemo(
|
||||
() => (
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>
|
||||
{t('teamsText')}
|
||||
</Typography.Text>
|
||||
),
|
||||
[t]
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex vertical gap={24}>
|
||||
<CustomPageHeader
|
||||
title={t('overviewTitle')}
|
||||
children={headerChildren}
|
||||
/>
|
||||
<CustomPageHeader title={t('overviewTitle')} children={headerChildren} />
|
||||
|
||||
<OverviewStats />
|
||||
|
||||
|
||||
@@ -8,143 +8,147 @@ interface InsightCardProps {
|
||||
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');
|
||||
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 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]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`overview-stat-card ${isDarkMode ? 'dark-mode' : 'light-mode'}`}
|
||||
style={{
|
||||
backgroundColor: isDarkMode ? '#1f1f1f' : '#ffffff',
|
||||
border: isDarkMode ? '1px solid #303030' : '1px solid #f0f0f0',
|
||||
// Memoize card container styles with dark mode support
|
||||
const cardContainerStyle = useMemo(
|
||||
() => ({
|
||||
width: '100%',
|
||||
borderRadius: '0px',
|
||||
boxShadow: isDarkMode
|
||||
? '0 2px 8px rgba(0, 0, 0, 0.3)'
|
||||
: '0 2px 8px rgba(0, 0, 0, 0.06)',
|
||||
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',
|
||||
position: 'relative' as const,
|
||||
cursor: 'default',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
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]
|
||||
);
|
||||
|
||||
return (
|
||||
<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%',
|
||||
}}
|
||||
styles={{
|
||||
body: {
|
||||
padding: '24px',
|
||||
backgroundColor: 'transparent',
|
||||
}
|
||||
}}
|
||||
loading={loading}
|
||||
>
|
||||
<Flex gap={20} align="flex-start">
|
||||
<div style={iconContainerStyle}>
|
||||
{icon}
|
||||
</div>
|
||||
<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={8} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography.Text style={titleStyle}>
|
||||
{title}
|
||||
</Typography.Text>
|
||||
<Flex vertical gap={8} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography.Text style={titleStyle}>{title}</Typography.Text>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '6px',
|
||||
marginTop: '4px'
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '6px',
|
||||
marginTop: '4px',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
{/* Decorative element */}
|
||||
<div style={decorativeStyle} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
{/* Decorative element */}
|
||||
<div style={decorativeStyle} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
OverviewStatCard.displayName = 'OverviewStatCard';
|
||||
|
||||
|
||||
@@ -36,142 +36,158 @@ const OverviewStats = () => {
|
||||
getOverviewStats();
|
||||
}, [getOverviewStats]);
|
||||
|
||||
const renderStatText = useCallback((count: number = 0, singularKey: string, pluralKey: string) => {
|
||||
return `${count} ${count === 1 ? t(singularKey) : t(pluralKey)}`;
|
||||
}, [t]);
|
||||
const renderStatText = useCallback(
|
||||
(count: number = 0, singularKey: string, pluralKey: string) => {
|
||||
return `${count} ${count === 1 ? t(singularKey) : t(pluralKey)}`;
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const renderStatCard = useCallback((
|
||||
icon: React.ReactNode,
|
||||
mainCount: number = 0,
|
||||
mainKey: string,
|
||||
stats: { text: string; type?: 'secondary' | 'danger' }[]
|
||||
) => (
|
||||
<OverviewStatCard
|
||||
icon={icon}
|
||||
title={renderStatText(mainCount, mainKey, `${mainKey}Plural`)}
|
||||
loading={loading}
|
||||
>
|
||||
<Flex vertical>
|
||||
{stats.map((stat, index) => (
|
||||
<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]);
|
||||
const renderStatCard = useCallback(
|
||||
(
|
||||
icon: React.ReactNode,
|
||||
mainCount: number = 0,
|
||||
mainKey: string,
|
||||
stats: { text: string; type?: 'secondary' | 'danger' }[]
|
||||
) => (
|
||||
<OverviewStatCard
|
||||
icon={icon}
|
||||
title={renderStatText(mainCount, mainKey, `${mainKey}Plural`)}
|
||||
loading={loading}
|
||||
>
|
||||
<Flex vertical>
|
||||
{stats.map((stat, index) => (
|
||||
<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]);
|
||||
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]);
|
||||
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]);
|
||||
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 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 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))'
|
||||
}} />
|
||||
), []);
|
||||
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(
|
||||
teamIcon,
|
||||
stats?.teams?.count,
|
||||
'teamCount',
|
||||
teamStats
|
||||
)}
|
||||
{renderStatCard(teamIcon, stats?.teams?.count, 'teamCount', teamStats)}
|
||||
|
||||
{renderStatCard(
|
||||
projectIcon,
|
||||
stats?.projects?.count,
|
||||
'projectCount',
|
||||
projectStats
|
||||
)}
|
||||
{renderStatCard(projectIcon, stats?.projects?.count, 'projectCount', projectStats)}
|
||||
|
||||
{renderStatCard(
|
||||
memberIcon,
|
||||
stats?.members?.count,
|
||||
'memberCount',
|
||||
memberStats
|
||||
)}
|
||||
{renderStatCard(memberIcon, stats?.members?.count, 'memberCount', memberStats)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -40,50 +40,62 @@ const OverviewReportsTable = memo(() => {
|
||||
getTeams();
|
||||
}, [getTeams]);
|
||||
|
||||
const handleDrawerOpen = useCallback((team: IRPTTeam) => {
|
||||
setSelectedTeam(team);
|
||||
dispatch(toggleOverViewTeamDrawer());
|
||||
}, [dispatch]);
|
||||
const handleDrawerOpen = useCallback(
|
||||
(team: IRPTTeam) => {
|
||||
setSelectedTeam(team);
|
||||
dispatch(toggleOverViewTeamDrawer());
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
// Memoize table columns to prevent recreation on every render
|
||||
const columns: TableColumnsType<IRPTTeam> = useMemo(() => [
|
||||
{
|
||||
key: 'name',
|
||||
title: <CustomTableTitle title={t('nameColumn')} />,
|
||||
className: 'group-hover:text-[#1890ff]',
|
||||
dataIndex: 'name',
|
||||
},
|
||||
{
|
||||
key: 'projects',
|
||||
title: <CustomTableTitle title={t('projectsColumn')} />,
|
||||
className: 'group-hover:text-[#1890ff]',
|
||||
dataIndex: 'projects_count',
|
||||
},
|
||||
{
|
||||
key: 'members',
|
||||
title: <CustomTableTitle title={t('membersColumn')} />,
|
||||
render: (record: IRPTTeam) => <Avatars members={record.members} maxCount={3} />,
|
||||
},
|
||||
], [t]);
|
||||
const columns: TableColumnsType<IRPTTeam> = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'name',
|
||||
title: <CustomTableTitle title={t('nameColumn')} />,
|
||||
className: 'group-hover:text-[#1890ff]',
|
||||
dataIndex: 'name',
|
||||
},
|
||||
{
|
||||
key: 'projects',
|
||||
title: <CustomTableTitle title={t('projectsColumn')} />,
|
||||
className: 'group-hover:text-[#1890ff]',
|
||||
dataIndex: 'projects_count',
|
||||
},
|
||||
{
|
||||
key: 'members',
|
||||
title: <CustomTableTitle title={t('membersColumn')} />,
|
||||
render: (record: IRPTTeam) => <Avatars members={record.members} maxCount={3} />,
|
||||
},
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
// Memoize table configuration
|
||||
const tableConfig = useMemo(() => ({
|
||||
theme: {
|
||||
components: {
|
||||
Table: {
|
||||
cellPaddingBlock: 8,
|
||||
cellPaddingInline: 10,
|
||||
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]);
|
||||
const getRowProps = useCallback(
|
||||
(record: IRPTTeam) => ({
|
||||
onClick: () => handleDrawerOpen(record),
|
||||
style: { height: 48, cursor: 'pointer' },
|
||||
className: 'group even:bg-[#4e4e4e10]',
|
||||
}),
|
||||
[handleDrawerOpen]
|
||||
);
|
||||
|
||||
return (
|
||||
<ConfigProvider {...tableConfig}>
|
||||
|
||||
@@ -48,7 +48,6 @@ const ProjectCategoriesFilterDropdown = () => {
|
||||
|
||||
// Add filtered categories memo
|
||||
const filteredCategories = useMemo(() => {
|
||||
|
||||
if (!searchQuery.trim()) return orgCategories;
|
||||
|
||||
return orgCategories.filter(category =>
|
||||
@@ -92,22 +91,22 @@ const ProjectCategoriesFilterDropdown = () => {
|
||||
{filteredCategories.length ? (
|
||||
filteredCategories.map(category => (
|
||||
<List.Item
|
||||
className={`custom-list-item ${themeMode === 'dark' ? 'dark' : ''}`}
|
||||
key={category.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-start',
|
||||
gap: 8,
|
||||
padding: '4px 8px',
|
||||
border: 'none',
|
||||
}}
|
||||
className={`custom-list-item ${themeMode === 'dark' ? 'dark' : ''}`}
|
||||
key={category.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-start',
|
||||
gap: 8,
|
||||
padding: '4px 8px',
|
||||
border: 'none',
|
||||
}}
|
||||
>
|
||||
<Checkbox id={category.id} onChange={() => handleCategoryChange(category)}>
|
||||
<Flex gap={8}>
|
||||
<Badge color={category.color_code} />
|
||||
{category.name}
|
||||
</Flex>
|
||||
</Checkbox>
|
||||
<Checkbox id={category.id} onChange={() => handleCategoryChange(category)}>
|
||||
<Flex gap={8}>
|
||||
<Badge color={category.color_code} />
|
||||
{category.name}
|
||||
</Flex>
|
||||
</Checkbox>
|
||||
</List.Item>
|
||||
))
|
||||
) : (
|
||||
@@ -129,10 +128,11 @@ const ProjectCategoriesFilterDropdown = () => {
|
||||
icon={<CaretDownFilled />}
|
||||
iconPosition="end"
|
||||
loading={projectCategoriesLoading}
|
||||
className={`transition-colors duration-300 ${isDropdownOpen
|
||||
className={`transition-colors duration-300 ${
|
||||
isDropdownOpen
|
||||
? 'border-[#1890ff] text-[#1890ff]'
|
||||
: 'hover:text-[#1890ff hover:border-[#1890ff]'
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
{t('categoryText')}
|
||||
</Button>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { fetchProjectHealth } from '@/features/projects/lookups/projectHealth/projectHealthSlice';
|
||||
import { fetchProjectData, setSelectedProjectHealths } from '@/features/reporting/projectReports/project-reports-slice';
|
||||
import {
|
||||
fetchProjectData,
|
||||
setSelectedProjectHealths,
|
||||
} from '@/features/reporting/projectReports/project-reports-slice';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { IProjectHealth } from '@/types/project/projectHealth.types';
|
||||
@@ -23,22 +26,19 @@ const ProjectHealthFilterDropdown = () => {
|
||||
state => state.projectReportsReducer
|
||||
);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedHealths(selectedProjectHealths);
|
||||
}, [selectedProjectHealths]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectHealthsLoading) dispatch(fetchProjectHealth());
|
||||
}, [dispatch]);
|
||||
|
||||
|
||||
const debouncedUpdate = useCallback(
|
||||
debounce((healths: IProjectHealth[]) => {
|
||||
dispatch(setSelectedProjectHealths(healths));
|
||||
dispatch(fetchProjectData());
|
||||
}, 300),
|
||||
dispatch(fetchProjectData());
|
||||
}, 300),
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
@@ -52,8 +52,8 @@ const ProjectHealthFilterDropdown = () => {
|
||||
updatedHealths = [...selectedHealths, health];
|
||||
}
|
||||
|
||||
setSelectedHealths(updatedHealths);
|
||||
debouncedUpdate(updatedHealths);
|
||||
setSelectedHealths(updatedHealths);
|
||||
debouncedUpdate(updatedHealths);
|
||||
};
|
||||
|
||||
const projectHealthDropdownContent = (
|
||||
@@ -75,7 +75,7 @@ const ProjectHealthFilterDropdown = () => {
|
||||
id={item.id}
|
||||
checked={selectedHealths.some(h => h.id === item.id)}
|
||||
onChange={() => handleHealthChange(item)}
|
||||
disabled={projectLoading}
|
||||
disabled={projectLoading}
|
||||
>
|
||||
{item.name}
|
||||
</Checkbox>
|
||||
@@ -96,7 +96,7 @@ const ProjectHealthFilterDropdown = () => {
|
||||
<Button
|
||||
icon={<CaretDownFilled />}
|
||||
iconPosition="end"
|
||||
loading={projectHealthsLoading}
|
||||
loading={projectHealthsLoading}
|
||||
className={`transition-colors duration-300 ${
|
||||
isDropdownOpen
|
||||
? 'border-[#1890ff] text-[#1890ff]'
|
||||
@@ -109,4 +109,4 @@ const ProjectHealthFilterDropdown = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectHealthFilterDropdown;
|
||||
export default ProjectHealthFilterDropdown;
|
||||
|
||||
@@ -43,8 +43,8 @@ const ProjectManagersFilterDropdown = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectManagersLoading) dispatch(fetchProjectManagers());
|
||||
}, [dispatch]);
|
||||
|
||||
}, [dispatch]);
|
||||
|
||||
const projectManagerDropdownContent = (
|
||||
<Card className="custom-card" styles={{ body: { padding: 8, width: 260 } }}>
|
||||
<Flex vertical gap={8}>
|
||||
|
||||
@@ -17,31 +17,40 @@ const ProjectsReportsFilters = () => {
|
||||
const { searchQuery } = useAppSelector(state => state.projectReportsReducer);
|
||||
|
||||
// Memoize the search query handler to prevent recreation on every render
|
||||
const handleSearchQueryChange = useCallback((text: string) => {
|
||||
dispatch(setSearchQuery(text));
|
||||
}, [dispatch]);
|
||||
const handleSearchQueryChange = useCallback(
|
||||
(text: string) => {
|
||||
dispatch(setSearchQuery(text));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
// Memoize the filter dropdowns container to prevent recreation on every render
|
||||
const filterDropdowns = useMemo(() => (
|
||||
<Flex gap={8} wrap={'wrap'}>
|
||||
<ProjectStatusFilterDropdown />
|
||||
<ProjectHealthFilterDropdown />
|
||||
<ProjectCategoriesFilterDropdown />
|
||||
<ProjectManagersFilterDropdown />
|
||||
</Flex>
|
||||
), []);
|
||||
const filterDropdowns = useMemo(
|
||||
() => (
|
||||
<Flex gap={8} wrap={'wrap'}>
|
||||
<ProjectStatusFilterDropdown />
|
||||
<ProjectHealthFilterDropdown />
|
||||
<ProjectCategoriesFilterDropdown />
|
||||
<ProjectManagersFilterDropdown />
|
||||
</Flex>
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
// Memoize the right side controls to prevent recreation on every render
|
||||
const rightControls = useMemo(() => (
|
||||
<Flex gap={12}>
|
||||
<ProjectTableShowFieldsDropdown />
|
||||
<CustomSearchbar
|
||||
placeholderText={t('searchByNamePlaceholder')}
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={handleSearchQueryChange}
|
||||
/>
|
||||
</Flex>
|
||||
), [t, searchQuery, handleSearchQueryChange]);
|
||||
const rightControls = useMemo(
|
||||
() => (
|
||||
<Flex gap={12}>
|
||||
<ProjectTableShowFieldsDropdown />
|
||||
<CustomSearchbar
|
||||
placeholderText={t('searchByNamePlaceholder')}
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={handleSearchQueryChange}
|
||||
/>
|
||||
</Flex>
|
||||
),
|
||||
[t, searchQuery, handleSearchQueryChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex gap={8} align="center" justify="space-between">
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { fetchProjectStatuses } from '@/features/projects/lookups/projectStatuses/projectStatusesSlice';
|
||||
import { fetchProjectData, setSelectedProjectStatuses } from '@/features/reporting/projectReports/project-reports-slice';
|
||||
import {
|
||||
fetchProjectData,
|
||||
setSelectedProjectStatuses,
|
||||
} from '@/features/reporting/projectReports/project-reports-slice';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { IProjectStatus } from '@/types/project/projectStatus.types';
|
||||
@@ -30,7 +33,7 @@ const ProjectStatusFilterDropdown = () => {
|
||||
const debouncedUpdate = useCallback(
|
||||
debounce((statuses: IProjectStatus[]) => {
|
||||
dispatch(setSelectedProjectStatuses(statuses));
|
||||
dispatch(fetchProjectData());
|
||||
dispatch(fetchProjectData());
|
||||
}, 300),
|
||||
[dispatch]
|
||||
);
|
||||
@@ -45,7 +48,7 @@ const ProjectStatusFilterDropdown = () => {
|
||||
updatedStatuses = [...selectedStatuses, status];
|
||||
}
|
||||
|
||||
setSelectedStatuses(updatedStatuses);
|
||||
setSelectedStatuses(updatedStatuses);
|
||||
debouncedUpdate(updatedStatuses);
|
||||
};
|
||||
|
||||
@@ -65,11 +68,7 @@ const ProjectStatusFilterDropdown = () => {
|
||||
}}
|
||||
>
|
||||
<Space>
|
||||
<Checkbox
|
||||
id={item.id}
|
||||
key={item.id}
|
||||
onChange={e => handleProjectStatusClick(item)}
|
||||
>
|
||||
<Checkbox id={item.id} key={item.id} onChange={e => handleProjectStatusClick(item)}>
|
||||
{item.name}
|
||||
</Checkbox>
|
||||
</Space>
|
||||
|
||||
@@ -35,11 +35,7 @@ const ProjectTableShowFieldsDropdown = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
menu={menuItems}
|
||||
trigger={['click']}
|
||||
onOpenChange={open => setIsDropdownOpen(open)}
|
||||
>
|
||||
<Dropdown menu={menuItems} trigger={['click']} onOpenChange={open => setIsDropdownOpen(open)}>
|
||||
<Button
|
||||
icon={<MoreOutlined />}
|
||||
className={`transition-colors duration-300 ${
|
||||
|
||||
@@ -64,10 +64,13 @@ const ProjectsReportsTable = () => {
|
||||
const columnsVisibility = useAppSelector(state => state.projectReportsTableColumnsReducer);
|
||||
|
||||
// Memoize the drawer open handler to prevent recreation on every render
|
||||
const handleDrawerOpen = useCallback((record: IRPTProject) => {
|
||||
setSelectedProject(record);
|
||||
dispatch(toggleProjectReportsDrawer());
|
||||
}, [dispatch]);
|
||||
const handleDrawerOpen = useCallback(
|
||||
(record: IRPTProject) => {
|
||||
setSelectedProject(record);
|
||||
dispatch(toggleProjectReportsDrawer());
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const columns: TableColumnsType<IRPTProject> = useMemo(
|
||||
() => [
|
||||
@@ -242,12 +245,15 @@ const ProjectsReportsTable = () => {
|
||||
);
|
||||
|
||||
// Memoize the table change handler to prevent recreation on every render
|
||||
const handleTableChange = useCallback((pagination: PaginationProps, filters: any, sorter: any) => {
|
||||
if (sorter.order) dispatch(setOrder(sorter.order));
|
||||
if (sorter.field) dispatch(setField(sorter.field));
|
||||
dispatch(setIndex(pagination.current));
|
||||
dispatch(setPageSize(pagination.pageSize));
|
||||
}, [dispatch]);
|
||||
const handleTableChange = useCallback(
|
||||
(pagination: PaginationProps, filters: any, sorter: any) => {
|
||||
if (sorter.order) dispatch(setOrder(sorter.order));
|
||||
if (sorter.field) dispatch(setField(sorter.field));
|
||||
dispatch(setIndex(pagination.current));
|
||||
dispatch(setPageSize(pagination.pageSize));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading) dispatch(fetchProjectData());
|
||||
@@ -295,13 +301,16 @@ const ProjectsReportsTable = () => {
|
||||
);
|
||||
|
||||
// Memoize pagination configuration to prevent recreation on every render
|
||||
const paginationConfig = useMemo(() => ({
|
||||
showSizeChanger: true,
|
||||
defaultPageSize: 10,
|
||||
total: total,
|
||||
current: index,
|
||||
pageSizeOptions: PAGE_SIZE_OPTIONS,
|
||||
}), [total, index]);
|
||||
const paginationConfig = useMemo(
|
||||
() => ({
|
||||
showSizeChanger: true,
|
||||
defaultPageSize: 10,
|
||||
total: total,
|
||||
current: index,
|
||||
pageSizeOptions: PAGE_SIZE_OPTIONS,
|
||||
}),
|
||||
[total, index]
|
||||
);
|
||||
|
||||
// Memoize scroll configuration to prevent recreation on every render
|
||||
const scrollConfig = useMemo(() => ({ x: 'max-content' }), []);
|
||||
|
||||
@@ -58,20 +58,20 @@ const ProjectCategoryCell = ({ id, name, color_code, projectId }: ProjectCategor
|
||||
</Typography.Text>
|
||||
),
|
||||
}));
|
||||
|
||||
|
||||
// handle category select
|
||||
const onClick: MenuProps['onClick'] = e => {
|
||||
const newCategory = filteredCategoriesData.find(category => category.id === e.key);
|
||||
if (newCategory && connected && socket) {
|
||||
// Update local state immediately
|
||||
setSelectedCategory(newCategory);
|
||||
|
||||
|
||||
// Emit socket event
|
||||
socket.emit(
|
||||
SocketEvents.PROJECT_CATEGORY_CHANGE.toString(),
|
||||
JSON.stringify({
|
||||
project_id: projectId,
|
||||
category_id: newCategory.id
|
||||
category_id: newCategory.id,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -133,12 +133,14 @@ const ProjectCategoryCell = ({ id, name, color_code, projectId }: ProjectCategor
|
||||
if (parsedData && parsedData.project_id === projectId) {
|
||||
// Update local state
|
||||
setSelectedCategory(parsedData.category);
|
||||
|
||||
|
||||
// Update redux store
|
||||
dispatch(updateProjectCategory({
|
||||
projectId: parsedData.project_id,
|
||||
category: parsedData.category
|
||||
}));
|
||||
dispatch(
|
||||
updateProjectCategory({
|
||||
projectId: parsedData.project_id,
|
||||
category: parsedData.category,
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error handling category change response:', error);
|
||||
@@ -200,7 +202,7 @@ const ProjectCategoryCell = ({ id, name, color_code, projectId }: ProjectCategor
|
||||
// Action creator for updating project category
|
||||
const updateProjectCategory = (payload: { projectId: string; category: IProjectCategory }) => ({
|
||||
type: 'projects/updateCategory',
|
||||
payload
|
||||
payload,
|
||||
});
|
||||
|
||||
export default ProjectCategoryCell;
|
||||
|
||||
@@ -5,7 +5,10 @@ import dayjs, { Dayjs } from 'dayjs';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { setProjectEndDate, setProjectStartDate } from '@/features/reporting/projectReports/project-reports-slice';
|
||||
import {
|
||||
setProjectEndDate,
|
||||
setProjectStartDate,
|
||||
} from '@/features/reporting/projectReports/project-reports-slice';
|
||||
import logger from '@/utils/errorLogger';
|
||||
|
||||
type ProjectDatesCellProps = {
|
||||
@@ -41,10 +44,13 @@ const ProjectDatesCell = ({ projectId, startDate, endDate }: ProjectDatesCellPro
|
||||
if (!socket) {
|
||||
throw new Error('Socket connection not available');
|
||||
}
|
||||
socket.emit(SocketEvents.PROJECT_START_DATE_CHANGE.toString(), JSON.stringify({
|
||||
project_id: projectId,
|
||||
start_date: date?.format('YYYY-MM-DD'),
|
||||
}));
|
||||
socket.emit(
|
||||
SocketEvents.PROJECT_START_DATE_CHANGE.toString(),
|
||||
JSON.stringify({
|
||||
project_id: projectId,
|
||||
start_date: date?.format('YYYY-MM-DD'),
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Error sending start date change:', error);
|
||||
}
|
||||
@@ -55,10 +61,13 @@ const ProjectDatesCell = ({ projectId, startDate, endDate }: ProjectDatesCellPro
|
||||
if (!socket) {
|
||||
throw new Error('Socket connection not available');
|
||||
}
|
||||
socket.emit(SocketEvents.PROJECT_END_DATE_CHANGE.toString(), JSON.stringify({
|
||||
project_id: projectId,
|
||||
end_date: date?.format('YYYY-MM-DD'),
|
||||
}));
|
||||
socket.emit(
|
||||
SocketEvents.PROJECT_END_DATE_CHANGE.toString(),
|
||||
JSON.stringify({
|
||||
project_id: projectId,
|
||||
end_date: date?.format('YYYY-MM-DD'),
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Error sending end date change:', error);
|
||||
}
|
||||
@@ -70,8 +79,14 @@ const ProjectDatesCell = ({ projectId, startDate, endDate }: ProjectDatesCellPro
|
||||
socket.on(SocketEvents.PROJECT_END_DATE_CHANGE.toString(), handleEndDateChangeResponse);
|
||||
|
||||
return () => {
|
||||
socket.removeListener(SocketEvents.PROJECT_START_DATE_CHANGE.toString(), handleStartDateChangeResponse);
|
||||
socket.removeListener(SocketEvents.PROJECT_END_DATE_CHANGE.toString(), handleEndDateChangeResponse);
|
||||
socket.removeListener(
|
||||
SocketEvents.PROJECT_START_DATE_CHANGE.toString(),
|
||||
handleStartDateChangeResponse
|
||||
);
|
||||
socket.removeListener(
|
||||
SocketEvents.PROJECT_END_DATE_CHANGE.toString(),
|
||||
handleEndDateChangeResponse
|
||||
);
|
||||
};
|
||||
}
|
||||
}, [connected, socket]);
|
||||
|
||||
@@ -47,10 +47,13 @@ const ProjectHealthCell = ({ value, label, color, projectId }: HealthStatusDataT
|
||||
const onClick: MenuProps['onClick'] = e => {
|
||||
if (!e.key || !projectId) return;
|
||||
|
||||
socket?.emit(SocketEvents.PROJECT_HEALTH_CHANGE.toString(), JSON.stringify({
|
||||
project_id: projectId,
|
||||
health_id: e.key,
|
||||
}));
|
||||
socket?.emit(
|
||||
SocketEvents.PROJECT_HEALTH_CHANGE.toString(),
|
||||
JSON.stringify({
|
||||
project_id: projectId,
|
||||
health_id: e.key,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
// dropdown items
|
||||
|
||||
@@ -35,7 +35,7 @@ const ProjectStatusCell = ({ currentStatus, projectId }: ProjectStatusCellProps)
|
||||
{getStatusIcon(status.icon || '', status.color_code || '')}
|
||||
{t(`${status.name}`)}
|
||||
</Typography.Text>
|
||||
)
|
||||
),
|
||||
}));
|
||||
|
||||
const handleStatusChange = (value: string) => {
|
||||
|
||||
@@ -12,7 +12,7 @@ const ProjectUpdateCell = ({ updates }: ProjectUpdateCellProps) => {
|
||||
ellipsis={{ expanded: false }}
|
||||
className="group-hover:text-[#1890ff]"
|
||||
>
|
||||
<div dangerouslySetInnerHTML={{__html: updates}} />
|
||||
<div dangerouslySetInnerHTML={{ __html: updates }} />
|
||||
</Typography.Text>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -39,36 +39,37 @@ const ProjectsReports = () => {
|
||||
}, [dispatch, archived]);
|
||||
|
||||
// Memoize the dropdown menu items to prevent recreation on every render
|
||||
const dropdownMenuItems = useMemo(() => [
|
||||
{ key: '1', label: t('excelButton'), onClick: handleExcelExport }
|
||||
], [t, handleExcelExport]);
|
||||
const dropdownMenuItems = useMemo(
|
||||
() => [{ key: '1', label: t('excelButton'), onClick: handleExcelExport }],
|
||||
[t, handleExcelExport]
|
||||
);
|
||||
|
||||
// Memoize the header children to prevent recreation on every render
|
||||
const headerChildren = useMemo(() => (
|
||||
<Space>
|
||||
<Button>
|
||||
<Checkbox checked={archived} onChange={handleArchivedChange}>
|
||||
<Typography.Text>{t('includeArchivedButton')}</Typography.Text>
|
||||
</Checkbox>
|
||||
</Button>
|
||||
|
||||
<Dropdown menu={{ items: dropdownMenuItems }}>
|
||||
<Button type="primary" icon={<DownOutlined />} iconPosition="end">
|
||||
{t('exportButton')}
|
||||
const headerChildren = useMemo(
|
||||
() => (
|
||||
<Space>
|
||||
<Button>
|
||||
<Checkbox checked={archived} onChange={handleArchivedChange}>
|
||||
<Typography.Text>{t('includeArchivedButton')}</Typography.Text>
|
||||
</Checkbox>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
), [archived, handleArchivedChange, t, dropdownMenuItems]);
|
||||
|
||||
<Dropdown menu={{ items: dropdownMenuItems }}>
|
||||
<Button type="primary" icon={<DownOutlined />} iconPosition="end">
|
||||
{t('exportButton')}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
),
|
||||
[archived, handleArchivedChange, t, dropdownMenuItems]
|
||||
);
|
||||
|
||||
// Memoize the card title to prevent recreation on every render
|
||||
const cardTitle = useMemo(() => <ProjectsReportsFilters />, []);
|
||||
|
||||
return (
|
||||
<Flex vertical>
|
||||
<CustomPageHeader
|
||||
title={pageTitle}
|
||||
children={headerChildren}
|
||||
/>
|
||||
<CustomPageHeader title={pageTitle} children={headerChildren} />
|
||||
|
||||
<Card title={cardTitle}>
|
||||
<ProjectReportsTable />
|
||||
|
||||
@@ -40,7 +40,6 @@ const ReportingCollapsedButton = ({
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
getOrganizationDetails();
|
||||
}, []);
|
||||
@@ -66,7 +65,7 @@ const ReportingCollapsedButton = ({
|
||||
/>
|
||||
|
||||
<Typography.Text strong>
|
||||
{loading ? 'Loading...' : organization?.name || 'Unknown Organization'}
|
||||
{loading ? 'Loading...' : organization?.name || 'Unknown Organization'}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
</Tooltip>
|
||||
|
||||
@@ -19,15 +19,23 @@ import { reportingTimesheetApiService } from '@/api/reporting/reporting.timeshee
|
||||
|
||||
// Project color palette
|
||||
const PROJECT_COLORS = [
|
||||
'#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEEAD',
|
||||
'#D4A5A5', '#9B59B6', '#3498DB', '#F1C40F', '#1ABC9C'
|
||||
'#FF6B6B',
|
||||
'#4ECDC4',
|
||||
'#45B7D1',
|
||||
'#96CEB4',
|
||||
'#FFEEAD',
|
||||
'#D4A5A5',
|
||||
'#9B59B6',
|
||||
'#3498DB',
|
||||
'#F1C40F',
|
||||
'#1ABC9C',
|
||||
];
|
||||
|
||||
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend, ChartDataLabels);
|
||||
|
||||
enum IToggleOptions {
|
||||
'WORKING_DAYS',
|
||||
'MAN_DAYS'
|
||||
'MAN_DAYS',
|
||||
}
|
||||
|
||||
export interface EstimatedVsActualTimeSheetRef {
|
||||
@@ -38,18 +46,21 @@ interface IEstimatedVsActualTimeSheetProps {
|
||||
type: string;
|
||||
}
|
||||
|
||||
const EstimatedVsActualTimeSheet = forwardRef<EstimatedVsActualTimeSheetRef, IEstimatedVsActualTimeSheetProps>(({ type }, ref) => {
|
||||
const EstimatedVsActualTimeSheet = forwardRef<
|
||||
EstimatedVsActualTimeSheetRef,
|
||||
IEstimatedVsActualTimeSheetProps
|
||||
>(({ type }, ref) => {
|
||||
const chartRef = useRef<any>(null);
|
||||
|
||||
|
||||
// State for filters and data
|
||||
const [jsonData, setJsonData] = useState<IRPTTimeProject[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [chartHeight, setChartHeight] = useState(600);
|
||||
const [chartWidth, setChartWidth] = useState(1080);
|
||||
|
||||
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
|
||||
const {
|
||||
teams,
|
||||
loadingTeams,
|
||||
@@ -61,28 +72,29 @@ const EstimatedVsActualTimeSheet = forwardRef<EstimatedVsActualTimeSheetRef, IEs
|
||||
billable,
|
||||
archived,
|
||||
} = useAppSelector(state => state.timeReportsOverviewReducer);
|
||||
const {
|
||||
duration,
|
||||
dateRange,
|
||||
} = useAppSelector(state => state.reportingReducer);
|
||||
const { duration, dateRange } = useAppSelector(state => state.reportingReducer);
|
||||
|
||||
// Add type checking before mapping
|
||||
const labels = Array.isArray(jsonData) ? jsonData.map(item => item.name) : [];
|
||||
const actualDays = Array.isArray(jsonData) ? jsonData.map(item => {
|
||||
const value = item.value ? parseFloat(item.value) : 0;
|
||||
return (isNaN(value) ? 0 : value).toString();
|
||||
}) : [];
|
||||
const estimatedDays = Array.isArray(jsonData) ? jsonData.map(item => {
|
||||
const value = item.estimated_value ? parseFloat(item.estimated_value) : 0;
|
||||
return (isNaN(value) ? 0 : value).toString();
|
||||
}) : [];
|
||||
const actualDays = Array.isArray(jsonData)
|
||||
? jsonData.map(item => {
|
||||
const value = item.value ? parseFloat(item.value) : 0;
|
||||
return (isNaN(value) ? 0 : value).toString();
|
||||
})
|
||||
: [];
|
||||
const estimatedDays = Array.isArray(jsonData)
|
||||
? jsonData.map(item => {
|
||||
const value = item.estimated_value ? parseFloat(item.estimated_value) : 0;
|
||||
return (isNaN(value) ? 0 : value).toString();
|
||||
})
|
||||
: [];
|
||||
|
||||
// Format date helper
|
||||
const formatDate = (date: Date): string => {
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
@@ -126,7 +138,7 @@ const EstimatedVsActualTimeSheet = forwardRef<EstimatedVsActualTimeSheetRef, IEs
|
||||
}
|
||||
return '';
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
datalabels: {
|
||||
color: 'white',
|
||||
@@ -190,14 +202,14 @@ const EstimatedVsActualTimeSheet = forwardRef<EstimatedVsActualTimeSheetRef, IEs
|
||||
projects: selectedProjects.map(p => p.id),
|
||||
duration: duration,
|
||||
date_range: dateRange,
|
||||
billable
|
||||
billable,
|
||||
};
|
||||
const res = await reportingTimesheetApiService.getProjectEstimatedVsActual(body, archived);
|
||||
if (res.done) {
|
||||
// Ensure res.body is an array before setting it
|
||||
const dataArray = Array.isArray(res.body) ? res.body : [];
|
||||
setJsonData(dataArray);
|
||||
|
||||
|
||||
// Update chart dimensions based on data
|
||||
if (dataArray.length) {
|
||||
const containerWidth = window.innerWidth - 300;
|
||||
@@ -224,14 +236,14 @@ const EstimatedVsActualTimeSheet = forwardRef<EstimatedVsActualTimeSheetRef, IEs
|
||||
billable,
|
||||
archived,
|
||||
type,
|
||||
noCategory
|
||||
noCategory,
|
||||
]);
|
||||
|
||||
const exportChart = () => {
|
||||
if (chartRef.current) {
|
||||
// Get the canvas element
|
||||
const canvas = chartRef.current.canvas;
|
||||
|
||||
|
||||
// Create a temporary canvas to draw with background
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
const tempCtx = tempCanvas.getContext('2d');
|
||||
@@ -262,19 +274,20 @@ const EstimatedVsActualTimeSheet = forwardRef<EstimatedVsActualTimeSheetRef, IEs
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
exportChart
|
||||
exportChart,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
|
||||
{/* Outer container with fixed width */}
|
||||
<div style={{
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
overflowX: 'auto',
|
||||
overflowY: 'hidden'
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
overflowX: 'auto',
|
||||
overflowY: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Chart container */}
|
||||
<div
|
||||
style={{
|
||||
@@ -283,12 +296,12 @@ const EstimatedVsActualTimeSheet = forwardRef<EstimatedVsActualTimeSheetRef, IEs
|
||||
minWidth: 'max-content',
|
||||
}}
|
||||
>
|
||||
<Bar
|
||||
<Bar
|
||||
ref={chartRef}
|
||||
data={data}
|
||||
options={options}
|
||||
width={chartWidth}
|
||||
height={chartHeight}
|
||||
data={data}
|
||||
options={options}
|
||||
width={chartWidth}
|
||||
height={chartHeight}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -44,10 +44,12 @@ const MembersTimeSheet = forwardRef<MembersTimeSheetRef>((_, ref) => {
|
||||
const [jsonData, setJsonData] = useState<IRPTTimeMember[]>([]);
|
||||
|
||||
const labels = Array.isArray(jsonData) ? jsonData.map(item => item.name) : [];
|
||||
const dataValues = Array.isArray(jsonData) ? jsonData.map(item => {
|
||||
const loggedTimeInHours = parseFloat(item.logged_time || '0') / 3600;
|
||||
return loggedTimeInHours.toFixed(2);
|
||||
}) : [];
|
||||
const dataValues = Array.isArray(jsonData)
|
||||
? jsonData.map(item => {
|
||||
const loggedTimeInHours = parseFloat(item.logged_time || '0') / 3600;
|
||||
return loggedTimeInHours.toFixed(2);
|
||||
})
|
||||
: [];
|
||||
const colors = Array.isArray(jsonData) ? jsonData.map(item => item.color_code) : [];
|
||||
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
@@ -83,7 +85,7 @@ const MembersTimeSheet = forwardRef<MembersTimeSheetRef>((_, ref) => {
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context: any) {
|
||||
label: function (context: any) {
|
||||
const idx = context.dataIndex;
|
||||
const member = jsonData[idx];
|
||||
const hours = member?.utilized_hours || '0.00';
|
||||
@@ -92,11 +94,11 @@ const MembersTimeSheet = forwardRef<MembersTimeSheetRef>((_, ref) => {
|
||||
return [
|
||||
`${context.dataset.label}: ${hours} h`,
|
||||
`Utilization: ${percent}%`,
|
||||
`Over/Under Utilized: ${overUnder} h`
|
||||
`Over/Under Utilized: ${overUnder} h`,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
backgroundColor: 'black',
|
||||
indexAxis: 'y' as const,
|
||||
@@ -136,7 +138,7 @@ const MembersTimeSheet = forwardRef<MembersTimeSheetRef>((_, ref) => {
|
||||
const fetchChartData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
|
||||
const selectedTeams = teams.filter(team => team.selected);
|
||||
const selectedProjects = filterProjects.filter(project => project.selected);
|
||||
const selectedCategories = categories.filter(category => category.selected);
|
||||
@@ -169,7 +171,7 @@ const MembersTimeSheet = forwardRef<MembersTimeSheetRef>((_, ref) => {
|
||||
if (chartRef.current) {
|
||||
// Get the canvas element
|
||||
const canvas = chartRef.current.canvas;
|
||||
|
||||
|
||||
// Create a temporary canvas to draw with background
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
const tempCtx = tempCanvas.getContext('2d');
|
||||
@@ -195,7 +197,7 @@ const MembersTimeSheet = forwardRef<MembersTimeSheetRef>((_, ref) => {
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
exportChart
|
||||
exportChart,
|
||||
}));
|
||||
|
||||
return (
|
||||
|
||||
@@ -11,9 +11,7 @@ import {
|
||||
} from 'chart.js';
|
||||
import ChartDataLabels from 'chartjs-plugin-datalabels';
|
||||
import { useAppDispatch } from '../../../../hooks/useAppDispatch';
|
||||
import {
|
||||
setLabelAndToggleDrawer,
|
||||
} from '../../../../features/timeReport/projects/timeLogSlice';
|
||||
import { setLabelAndToggleDrawer } from '../../../../features/timeReport/projects/timeLogSlice';
|
||||
import ProjectTimeLogDrawer from '../../../../features/timeReport/projects/ProjectTimeLogDrawer';
|
||||
import { useAppSelector } from '../../../../hooks/useAppSelector';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -65,16 +63,22 @@ const ProjectTimeSheetChart = forwardRef<ProjectTimeSheetChartRef>((_, ref) => {
|
||||
|
||||
const data = {
|
||||
labels: Array.isArray(jsonData) ? jsonData.map(item => item?.name || '') : [],
|
||||
datasets: [{
|
||||
label: t('loggedTime'),
|
||||
data: Array.isArray(jsonData) ? jsonData.map(item => {
|
||||
const loggedTime = item?.logged_time || '0';
|
||||
const loggedTimeInHours = parseFloat(loggedTime) / 3600;
|
||||
return loggedTimeInHours.toFixed(2);
|
||||
}) : [],
|
||||
backgroundColor: Array.isArray(jsonData) ? jsonData.map(item => item?.color_code || '#000000') : [],
|
||||
barThickness: BAR_THICKNESS,
|
||||
}],
|
||||
datasets: [
|
||||
{
|
||||
label: t('loggedTime'),
|
||||
data: Array.isArray(jsonData)
|
||||
? jsonData.map(item => {
|
||||
const loggedTime = item?.logged_time || '0';
|
||||
const loggedTimeInHours = parseFloat(loggedTime) / 3600;
|
||||
return loggedTimeInHours.toFixed(2);
|
||||
})
|
||||
: [],
|
||||
backgroundColor: Array.isArray(jsonData)
|
||||
? jsonData.map(item => item?.color_code || '#000000')
|
||||
: [],
|
||||
barThickness: BAR_THICKNESS,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const options = {
|
||||
@@ -170,14 +174,14 @@ const ProjectTimeSheetChart = forwardRef<ProjectTimeSheetChartRef>((_, ref) => {
|
||||
archived,
|
||||
loadingTeams,
|
||||
loadingProjects,
|
||||
loadingCategories
|
||||
loadingCategories,
|
||||
]);
|
||||
|
||||
const exportChart = () => {
|
||||
if (chartRef.current) {
|
||||
// Get the canvas element
|
||||
const canvas = chartRef.current.canvas;
|
||||
|
||||
|
||||
// Create a temporary canvas to draw with background
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
const tempCtx = tempCanvas.getContext('2d');
|
||||
@@ -203,7 +207,7 @@ const ProjectTimeSheetChart = forwardRef<ProjectTimeSheetChartRef>((_, ref) => {
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
exportChart
|
||||
exportChart,
|
||||
}));
|
||||
|
||||
// if (loading) {
|
||||
@@ -224,11 +228,7 @@ const ProjectTimeSheetChart = forwardRef<ProjectTimeSheetChartRef>((_, ref) => {
|
||||
height: `${60 * data.labels.length}px`,
|
||||
}}
|
||||
>
|
||||
<Bar
|
||||
data={data}
|
||||
options={options}
|
||||
ref={chartRef}
|
||||
/>
|
||||
<Bar data={data} options={options} ref={chartRef} />
|
||||
</div>
|
||||
<ProjectTimeLogDrawer />
|
||||
</div>
|
||||
|
||||
@@ -51,7 +51,8 @@
|
||||
border-left: none !important;
|
||||
}
|
||||
|
||||
.member-name, .total-time {
|
||||
.member-name,
|
||||
.total-time {
|
||||
border-bottom: 2px solid var(--border-color, rgba(0, 0, 0, 0.06));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Card, Flex, Segmented } from 'antd';
|
||||
import TimeReportPageHeader from '@/pages/reporting/timeReports/page-header/time-report-page-header';
|
||||
import EstimatedVsActualTimeSheet, { EstimatedVsActualTimeSheetRef } from '@/pages/reporting/time-reports/estimated-vs-actual-time-sheet/estimated-vs-actual-time-sheet';
|
||||
import EstimatedVsActualTimeSheet, {
|
||||
EstimatedVsActualTimeSheetRef,
|
||||
} from '@/pages/reporting/time-reports/estimated-vs-actual-time-sheet/estimated-vs-actual-time-sheet';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
||||
import TimeReportingRightHeader from './timeReportingRightHeader/TimeReportingRightHeader';
|
||||
@@ -23,9 +25,7 @@ const EstimatedVsActualTimeReports = () => {
|
||||
<Flex vertical>
|
||||
<TimeReportingRightHeader
|
||||
title={t('estimatedVsActual')}
|
||||
exportType={[
|
||||
{ key: 'png', label: 'PNG' },
|
||||
]}
|
||||
exportType={[{ key: 'png', label: 'PNG' }]}
|
||||
export={handleExport}
|
||||
/>
|
||||
|
||||
@@ -42,13 +42,16 @@ const EstimatedVsActualTimeReports = () => {
|
||||
<TimeReportPageHeader />
|
||||
<Segmented
|
||||
style={{ fontWeight: 500 }}
|
||||
options={[{
|
||||
label: t('workingDays'),
|
||||
value: 'WORKING_DAYS',
|
||||
}, {
|
||||
label: t('manDays'),
|
||||
value: 'MAN_DAYS',
|
||||
}]}
|
||||
options={[
|
||||
{
|
||||
label: t('workingDays'),
|
||||
value: 'WORKING_DAYS',
|
||||
},
|
||||
{
|
||||
label: t('manDays'),
|
||||
value: 'MAN_DAYS',
|
||||
},
|
||||
]}
|
||||
onChange={value => setType(value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Card, Flex } from 'antd';
|
||||
import TimeReportPageHeader from '@/pages/reporting/timeReports/page-header/time-report-page-header';
|
||||
import MembersTimeSheet, { MembersTimeSheetRef } from '@/pages/reporting/time-reports/members-time-sheet/members-time-sheet';
|
||||
import MembersTimeSheet, {
|
||||
MembersTimeSheetRef,
|
||||
} from '@/pages/reporting/time-reports/members-time-sheet/members-time-sheet';
|
||||
import TimeReportingRightHeader from './timeReportingRightHeader/TimeReportingRightHeader';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { fetchReportingProjects, setNoCategory, setSelectOrDeselectAllCategories, setSelectOrDeselectCategory } from '@/features/reporting/time-reports/time-reports-overview.slice';
|
||||
import {
|
||||
fetchReportingProjects,
|
||||
setNoCategory,
|
||||
setSelectOrDeselectAllCategories,
|
||||
setSelectOrDeselectCategory,
|
||||
} 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';
|
||||
@@ -36,7 +41,6 @@ const Categories: React.FC = () => {
|
||||
await dispatch(setNoCategory(isChecked));
|
||||
await dispatch(setSelectOrDeselectAllCategories(isChecked));
|
||||
await dispatch(fetchReportingProjects());
|
||||
|
||||
};
|
||||
|
||||
const handleNoCategoryChange = async (checked: boolean) => {
|
||||
@@ -51,15 +55,17 @@ const Categories: React.FC = () => {
|
||||
placement="bottomLeft"
|
||||
trigger={['click']}
|
||||
dropdownRender={() => (
|
||||
<div style={{
|
||||
background: token.colorBgContainer,
|
||||
borderRadius: token.borderRadius,
|
||||
boxShadow: token.boxShadow,
|
||||
padding: '4px 0',
|
||||
maxHeight: '330px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
background: token.colorBgContainer,
|
||||
borderRadius: token.borderRadius,
|
||||
boxShadow: token.boxShadow,
|
||||
padding: '4px 0',
|
||||
maxHeight: '330px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: '8px', flexShrink: 0 }}>
|
||||
<Input
|
||||
onClick={e => e.stopPropagation()}
|
||||
@@ -89,17 +95,19 @@ const Categories: React.FC = () => {
|
||||
</Checkbox>
|
||||
</div>
|
||||
<Divider style={{ margin: '4px 0', flexShrink: 0 }} />
|
||||
<div style={{
|
||||
overflowY: 'auto',
|
||||
flex: 1
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
overflowY: 'auto',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{filteredItems.length > 0 ? (
|
||||
filteredItems.map(item => (
|
||||
<div
|
||||
<div
|
||||
key={item.id}
|
||||
style={{
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer'
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
@@ -112,9 +120,7 @@ const Categories: React.FC = () => {
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div style={{ padding: '8px 12px' }}>
|
||||
{t('noCategories')}
|
||||
</div>
|
||||
<div style={{ padding: '8px 12px' }}>{t('noCategories')}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,32 @@
|
||||
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 { useAppSelector } from '@/hooks/useAppSelector';
|
||||
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 {
|
||||
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, useMemo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -34,57 +58,63 @@ const Projects: React.FC = () => {
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
// Theme-aware color utilities
|
||||
const getThemeAwareColor = useCallback((lightColor: string, darkColor: string) => {
|
||||
return themeWiseColor(lightColor, darkColor, themeMode);
|
||||
}, [themeMode]);
|
||||
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')}`;
|
||||
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]);
|
||||
|
||||
return color;
|
||||
},
|
||||
[themeMode, token.colorPrimary]
|
||||
);
|
||||
|
||||
// Memoized filtered projects
|
||||
const filteredProjects = useMemo(() => {
|
||||
@@ -102,15 +132,17 @@ const Projects: React.FC = () => {
|
||||
// Memoized grouped projects
|
||||
const groupedProjects = useMemo(() => {
|
||||
if (groupBy === 'none') {
|
||||
return [{
|
||||
key: 'all',
|
||||
name: t('projects'),
|
||||
projects: filteredProjects
|
||||
}];
|
||||
return [
|
||||
{
|
||||
key: 'all',
|
||||
name: t('projects'),
|
||||
projects: filteredProjects,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const groups: { [key: string]: ProjectGroup } = {};
|
||||
|
||||
|
||||
filteredProjects.forEach(project => {
|
||||
let groupKey: string;
|
||||
let groupName: string;
|
||||
@@ -142,7 +174,7 @@ const Projects: React.FC = () => {
|
||||
key: groupKey,
|
||||
name: groupName,
|
||||
color: processColor(groupColor),
|
||||
projects: []
|
||||
projects: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -153,64 +185,67 @@ const Projects: React.FC = () => {
|
||||
}, [filteredProjects, groupBy, t, processColor]);
|
||||
|
||||
// Selected projects count
|
||||
const selectedCount = useMemo(() =>
|
||||
projects.filter(p => p.selected).length,
|
||||
[projects]
|
||||
);
|
||||
const selectedCount = useMemo(() => projects.filter(p => p.selected).length, [projects]);
|
||||
|
||||
const allSelected = useMemo(() =>
|
||||
filteredProjects.length > 0 && filteredProjects.every(p => p.selected),
|
||||
const allSelected = useMemo(
|
||||
() => filteredProjects.length > 0 && filteredProjects.every(p => p.selected),
|
||||
[filteredProjects]
|
||||
);
|
||||
|
||||
const indeterminate = useMemo(() =>
|
||||
filteredProjects.some(p => p.selected) && !allSelected,
|
||||
const indeterminate = useMemo(
|
||||
() => filteredProjects.some(p => p.selected) && !allSelected,
|
||||
[filteredProjects, allSelected]
|
||||
);
|
||||
|
||||
// Memoize group by options
|
||||
const groupByOptions = useMemo(() => [
|
||||
{ value: 'none', label: t('groupByNone') },
|
||||
{ value: 'category', label: t('groupByCategory') },
|
||||
{ value: 'team', label: t('groupByTeam') },
|
||||
{ value: 'status', label: t('groupByStatus') },
|
||||
], [t]);
|
||||
const groupByOptions = useMemo(
|
||||
() => [
|
||||
{ value: 'none', label: t('groupByNone') },
|
||||
{ value: 'category', label: t('groupByCategory') },
|
||||
{ value: 'team', label: t('groupByTeam') },
|
||||
{ value: 'status', label: t('groupByStatus') },
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
// Memoize dropdown styles to prevent recalculation on every render
|
||||
const dropdownStyles = useMemo(() => ({
|
||||
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)}`,
|
||||
},
|
||||
projectItem: {
|
||||
padding: '8px 12px',
|
||||
borderRadius: token.borderRadiusSM,
|
||||
transition: 'all 0.2s ease',
|
||||
cursor: 'pointer',
|
||||
border: `1px solid transparent`,
|
||||
},
|
||||
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, getThemeAwareColor]);
|
||||
const dropdownStyles = useMemo(
|
||||
() => ({
|
||||
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)}`,
|
||||
},
|
||||
projectItem: {
|
||||
padding: '8px 12px',
|
||||
borderRadius: token.borderRadiusSM,
|
||||
transition: 'all 0.2s ease',
|
||||
cursor: 'pointer',
|
||||
border: `1px solid transparent`,
|
||||
},
|
||||
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, getThemeAwareColor]
|
||||
);
|
||||
|
||||
// Memoize search placeholder and clear tooltip
|
||||
const searchPlaceholder = useMemo(() => t('searchByProject'), [t]);
|
||||
@@ -224,15 +259,21 @@ const Projects: React.FC = () => {
|
||||
const collapseAllText = useMemo(() => t('collapseAll'), [t]);
|
||||
|
||||
// Handle checkbox change for individual items
|
||||
const handleCheckboxChange = useCallback((key: string, checked: boolean) => {
|
||||
dispatch(setSelectOrDeselectProject({ id: key, selected: checked }));
|
||||
}, [dispatch]);
|
||||
const handleCheckboxChange = useCallback(
|
||||
(key: string, checked: boolean) => {
|
||||
dispatch(setSelectOrDeselectProject({ id: key, selected: checked }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
// Handle "Select All" checkbox change
|
||||
const handleSelectAllChange = useCallback((e: CheckboxChangeEvent) => {
|
||||
const isChecked = e.target.checked;
|
||||
dispatch(setSelectOrDeselectAllProjects(isChecked));
|
||||
}, [dispatch]);
|
||||
const handleSelectAllChange = useCallback(
|
||||
(e: CheckboxChangeEvent) => {
|
||||
const isChecked = e.target.checked;
|
||||
dispatch(setSelectOrDeselectAllProjects(isChecked));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
// Clear search
|
||||
const clearSearch = useCallback(() => {
|
||||
@@ -241,49 +282,57 @@ const Projects: React.FC = () => {
|
||||
|
||||
// Toggle group expansion
|
||||
const toggleGroupExpansion = useCallback((groupKey: string) => {
|
||||
setExpandedGroups(prev =>
|
||||
prev.includes(groupKey)
|
||||
? prev.filter(key => key !== groupKey)
|
||||
: [...prev, groupKey]
|
||||
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]);
|
||||
|
||||
|
||||
const toggleAllGroups = useCallback(
|
||||
(expand: boolean) => {
|
||||
if (expand) {
|
||||
setExpandedGroups(groupedProjects.map(g => g.key));
|
||||
} else {
|
||||
setExpandedGroups([]);
|
||||
}
|
||||
},
|
||||
[groupedProjects]
|
||||
);
|
||||
|
||||
// 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
|
||||
<div
|
||||
style={{
|
||||
...dropdownStyles.groupHeader,
|
||||
backgroundColor: isExpanded
|
||||
backgroundColor: isExpanded
|
||||
? getThemeAwareColor(token.colorFillSecondary, token.colorFillTertiary)
|
||||
: dropdownStyles.groupHeader.backgroundColor
|
||||
: dropdownStyles.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);
|
||||
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
|
||||
onMouseLeave={e => {
|
||||
e.currentTarget.style.backgroundColor = isExpanded
|
||||
? getThemeAwareColor(token.colorFillSecondary, token.colorFillTertiary)
|
||||
: dropdownStyles.groupHeader.backgroundColor;
|
||||
e.currentTarget.style.borderColor = getThemeAwareColor(token.colorBorderSecondary, token.colorBorder);
|
||||
e.currentTarget.style.borderColor = getThemeAwareColor(
|
||||
token.colorBorderSecondary,
|
||||
token.colorBorder
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Space>
|
||||
@@ -292,42 +341,53 @@ const Projects: React.FC = () => {
|
||||
) : (
|
||||
<RightOutlined style={dropdownStyles.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)
|
||||
}}>
|
||||
<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={{
|
||||
<Badge
|
||||
count={groupSelectedCount}
|
||||
size="small"
|
||||
style={{
|
||||
backgroundColor: getThemeAwareColor(token.colorPrimary, token.colorPrimaryActive),
|
||||
color: getThemeAwareColor('#fff', token.colorTextLightSolid)
|
||||
color: getThemeAwareColor('#fff', token.colorTextLightSolid),
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{isExpanded && (
|
||||
<div style={{ paddingLeft: groupBy !== 'none' ? '24px' : '0' }}>
|
||||
{group.projects.map(project => (
|
||||
<div
|
||||
<div
|
||||
key={project.id}
|
||||
style={dropdownStyles.projectItem}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = getThemeAwareColor(token.colorFillAlter, token.colorFillQuaternary);
|
||||
e.currentTarget.style.borderColor = getThemeAwareColor(token.colorBorderSecondary, token.colorBorder);
|
||||
onMouseEnter={e => {
|
||||
e.currentTarget.style.backgroundColor = getThemeAwareColor(
|
||||
token.colorFillAlter,
|
||||
token.colorFillQuaternary
|
||||
);
|
||||
e.currentTarget.style.borderColor = getThemeAwareColor(
|
||||
token.colorBorderSecondary,
|
||||
token.colorBorder
|
||||
);
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
onMouseLeave={e => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.borderColor = 'transparent';
|
||||
}}
|
||||
@@ -338,17 +398,24 @@ const Projects: React.FC = () => {
|
||||
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)
|
||||
}}>
|
||||
<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>
|
||||
@@ -369,14 +436,16 @@ const Projects: React.FC = () => {
|
||||
trigger={['click']}
|
||||
open={dropdownVisible}
|
||||
dropdownRender={() => (
|
||||
<div style={{
|
||||
...dropdownStyles.dropdown,
|
||||
padding: '8px 0',
|
||||
maxHeight: '500px',
|
||||
width: '400px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
...dropdownStyles.dropdown,
|
||||
padding: '8px 0',
|
||||
maxHeight: '500px',
|
||||
width: '400px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Header with search and controls */}
|
||||
<div style={{ padding: '8px 12px', flexShrink: 0 }}>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="small">
|
||||
@@ -385,28 +454,48 @@ const Projects: React.FC = () => {
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchText}
|
||||
onChange={e => setSearchText(e.target.value)}
|
||||
prefix={<SearchOutlined style={{ color: getThemeAwareColor(token.colorTextTertiary, token.colorTextQuaternary) }} />}
|
||||
suffix={searchText && (
|
||||
<Tooltip title={clearTooltip}>
|
||||
<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>
|
||||
)}
|
||||
prefix={
|
||||
<SearchOutlined
|
||||
style={{
|
||||
color: getThemeAwareColor(
|
||||
token.colorTextTertiary,
|
||||
token.colorTextQuaternary
|
||||
),
|
||||
}}
|
||||
/>
|
||||
}
|
||||
suffix={
|
||||
searchText && (
|
||||
<Tooltip title={clearTooltip}>
|
||||
<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">
|
||||
@@ -417,25 +506,31 @@ const Projects: React.FC = () => {
|
||||
style={{ width: '120px' }}
|
||||
options={groupByOptions}
|
||||
/>
|
||||
|
||||
|
||||
{groupBy !== 'none' && (
|
||||
<Space size="small">
|
||||
<Button
|
||||
type="text"
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
onClick={() => toggleAllGroups(true)}
|
||||
style={{
|
||||
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary)
|
||||
style={{
|
||||
color: getThemeAwareColor(
|
||||
token.colorTextSecondary,
|
||||
token.colorTextTertiary
|
||||
),
|
||||
}}
|
||||
>
|
||||
{expandAllText}
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
onClick={() => toggleAllGroups(false)}
|
||||
style={{
|
||||
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary)
|
||||
style={{
|
||||
color: getThemeAwareColor(
|
||||
token.colorTextSecondary,
|
||||
token.colorTextTertiary
|
||||
),
|
||||
}}
|
||||
>
|
||||
{collapseAllText}
|
||||
@@ -443,16 +538,23 @@ const Projects: React.FC = () => {
|
||||
</Space>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
|
||||
<Tooltip title={showSelectedTooltip}>
|
||||
<Button
|
||||
type={showSelectedOnly ? 'primary' : 'text'}
|
||||
size="small"
|
||||
icon={<FilterOutlined />}
|
||||
onClick={() => setShowSelectedOnly(!showSelectedOnly)}
|
||||
style={!showSelectedOnly ? {
|
||||
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary)
|
||||
} : {}}
|
||||
style={
|
||||
!showSelectedOnly
|
||||
? {
|
||||
color: getThemeAwareColor(
|
||||
token.colorTextSecondary,
|
||||
token.colorTextTertiary
|
||||
),
|
||||
}
|
||||
: {}
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
@@ -468,18 +570,23 @@ const Projects: React.FC = () => {
|
||||
indeterminate={indeterminate}
|
||||
>
|
||||
<Space>
|
||||
<Text style={{
|
||||
color: getThemeAwareColor(token.colorText, token.colorTextBase)
|
||||
}}>
|
||||
<Text
|
||||
style={{
|
||||
color: getThemeAwareColor(token.colorText, token.colorTextBase),
|
||||
}}
|
||||
>
|
||||
{selectAllText}
|
||||
</Text>
|
||||
{selectedCount > 0 && (
|
||||
<Badge
|
||||
count={selectedCount}
|
||||
<Badge
|
||||
count={selectedCount}
|
||||
size="small"
|
||||
style={{
|
||||
backgroundColor: getThemeAwareColor(token.colorSuccess, token.colorSuccessActive),
|
||||
color: getThemeAwareColor('#fff', token.colorTextLightSolid)
|
||||
style={{
|
||||
backgroundColor: getThemeAwareColor(
|
||||
token.colorSuccess,
|
||||
token.colorSuccessActive
|
||||
),
|
||||
color: getThemeAwareColor('#fff', token.colorTextLightSolid),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -490,18 +597,25 @@ const Projects: React.FC = () => {
|
||||
<Divider style={{ margin: '8px 0', flexShrink: 0 }} />
|
||||
|
||||
{/* Projects list */}
|
||||
<div style={{
|
||||
overflowY: 'auto',
|
||||
flex: 1,
|
||||
padding: '0 12px'
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
overflowY: 'auto',
|
||||
flex: 1,
|
||||
padding: '0 12px',
|
||||
}}
|
||||
>
|
||||
{filteredProjects.length === 0 ? (
|
||||
<Empty
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={
|
||||
<Text style={{
|
||||
color: getThemeAwareColor(token.colorTextTertiary, token.colorTextQuaternary)
|
||||
}}>
|
||||
<Text
|
||||
style={{
|
||||
color: getThemeAwareColor(
|
||||
token.colorTextTertiary,
|
||||
token.colorTextQuaternary
|
||||
),
|
||||
}}
|
||||
>
|
||||
{searchText ? noProjectsText : noDataText}
|
||||
</Text>
|
||||
}
|
||||
@@ -516,17 +630,25 @@ const Projects: React.FC = () => {
|
||||
{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)
|
||||
}}>
|
||||
<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} {projectsSelectedText}
|
||||
</Text>
|
||||
</div>
|
||||
@@ -545,7 +667,7 @@ const Projects: React.FC = () => {
|
||||
<Badge count={selectedCount} size="small" offset={[-5, 5]}>
|
||||
<Button loading={loadingProjects}>
|
||||
<Space>
|
||||
{t('projects')}
|
||||
{t('projects')}
|
||||
<CaretDownFilled />
|
||||
</Space>
|
||||
</Button>
|
||||
|
||||
@@ -8,7 +8,13 @@ import { reportingApiService } from '@/api/reporting/reporting.api.service';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { fetchReportingCategories, fetchReportingProjects, fetchReportingTeams, setSelectOrDeselectAllTeams, setSelectOrDeselectTeam } from '@/features/reporting/time-reports/time-reports-overview.slice';
|
||||
import {
|
||||
fetchReportingCategories,
|
||||
fetchReportingProjects,
|
||||
fetchReportingTeams,
|
||||
setSelectOrDeselectAllTeams,
|
||||
setSelectOrDeselectTeam,
|
||||
} from '@/features/reporting/time-reports/time-reports-overview.slice';
|
||||
|
||||
const Team: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
@@ -46,15 +52,17 @@ const Team: React.FC = () => {
|
||||
placement="bottomLeft"
|
||||
trigger={['click']}
|
||||
dropdownRender={() => (
|
||||
<div style={{
|
||||
background: token.colorBgContainer,
|
||||
borderRadius: token.borderRadius,
|
||||
boxShadow: token.boxShadow,
|
||||
padding: '4px 0',
|
||||
maxHeight: '330px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
background: token.colorBgContainer,
|
||||
borderRadius: token.borderRadius,
|
||||
boxShadow: token.boxShadow,
|
||||
padding: '4px 0',
|
||||
maxHeight: '330px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: '8px', flexShrink: 0 }}>
|
||||
<Input
|
||||
placeholder={t('searchByName')}
|
||||
@@ -73,16 +81,18 @@ const Team: React.FC = () => {
|
||||
</Checkbox>
|
||||
</div>
|
||||
<Divider style={{ margin: '4px 0', flexShrink: 0 }} />
|
||||
<div style={{
|
||||
overflowY: 'auto',
|
||||
flex: 1
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
overflowY: 'auto',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{filteredItems.map(item => (
|
||||
<div
|
||||
<div
|
||||
key={item.id}
|
||||
style={{
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer'
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Card, Flex } from 'antd';
|
||||
import TimeReportPageHeader from '@/pages/reporting/timeReports/page-header/time-report-page-header';
|
||||
import ProjectTimeSheetChart, { ProjectTimeSheetChartRef } from '@/pages/reporting/time-reports/project-time-sheet/project-time-sheet-chart';
|
||||
import ProjectTimeSheetChart, {
|
||||
ProjectTimeSheetChartRef,
|
||||
} from '@/pages/reporting/time-reports/project-time-sheet/project-time-sheet-chart';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
||||
import TimeReportingRightHeader from './timeReportingRightHeader/TimeReportingRightHeader';
|
||||
@@ -22,9 +24,7 @@ const ProjectsTimeReports = () => {
|
||||
<Flex vertical>
|
||||
<TimeReportingRightHeader
|
||||
title={t('projectsTimeSheet')}
|
||||
exportType={[
|
||||
{ key: 'png', label: 'PNG' },
|
||||
]}
|
||||
exportType={[{ key: 'png', label: 'PNG' }]}
|
||||
export={handleExport}
|
||||
/>
|
||||
|
||||
|
||||
@@ -14,7 +14,11 @@ interface headerState {
|
||||
export: (key: string) => void;
|
||||
}
|
||||
|
||||
const TimeReportingRightHeader: React.FC<headerState> = ({ title, exportType, export: exportFn }) => {
|
||||
const TimeReportingRightHeader: React.FC<headerState> = ({
|
||||
title,
|
||||
exportType,
|
||||
export: exportFn,
|
||||
}) => {
|
||||
const { t } = useTranslation('time-report');
|
||||
const dispatch = useAppDispatch();
|
||||
const { archived } = useAppSelector(state => state.timeReportsOverviewReducer);
|
||||
@@ -22,7 +26,7 @@ const TimeReportingRightHeader: React.FC<headerState> = ({ title, exportType, ex
|
||||
const menuItems = exportType.map(item => ({
|
||||
key: item.key,
|
||||
label: item.label,
|
||||
onClick: () => exportFn(item.key)
|
||||
onClick: () => exportFn(item.key),
|
||||
}));
|
||||
|
||||
return (
|
||||
@@ -36,8 +40,8 @@ const TimeReportingRightHeader: React.FC<headerState> = ({ title, exportType, ex
|
||||
</Checkbox>
|
||||
</Button>
|
||||
<TimeWiseFilter />
|
||||
<Dropdown menu={{ items: menuItems }}>
|
||||
<Button type="primary" icon={<DownOutlined />} iconPosition="end">
|
||||
<Dropdown menu={{ items: menuItems }}>
|
||||
<Button type="primary" icon={<DownOutlined />} iconPosition="end">
|
||||
{t('export')}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
|
||||
Reference in New Issue
Block a user