This commit is contained in:
chamikaJ
2025-04-17 18:28:54 +05:30
parent f583291d8a
commit 8825b0410a
2837 changed files with 241385 additions and 127578 deletions

View File

@@ -0,0 +1,131 @@
import { useEffect, useState } from 'react';
import { ConfigProvider, Table, TableColumnsType } from 'antd';
import { useTranslation } from 'react-i18next';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import CustomTableTitle from '@/components/CustomTableTitle';
import TasksProgressCell from './tablesCells/tasksProgressCell/TasksProgressCell';
import MemberCell from './tablesCells/memberCell/MemberCell';
import { fetchMembersData, toggleMembersReportsDrawer } from '@/features/reporting/membersReports/membersReportsSlice';
import { useAppSelector } from '@/hooks/useAppSelector';
import MembersReportsDrawer from '@/features/reporting/membersReports/membersReportsDrawer/members-reports-drawer';
const MembersReportsTable = () => {
const { t } = useTranslation('reporting-members');
const dispatch = useAppDispatch();
const [selectedId, setSelectedId] = useState<string | null>(null);
const { duration, dateRange } = useAppSelector(state => state.reportingReducer);
const { membersList, isLoading, total, archived, searchQuery } = useAppSelector(state => state.membersReportsReducer);
// function to handle drawer toggle
const handleDrawerOpen = (id: string) => {
setSelectedId(id);
dispatch(toggleMembersReportsDrawer());
};
const columns: TableColumnsType = [
{
key: 'member',
title: <CustomTableTitle title={t('memberColumn')} />,
onCell: record => {
return {
onClick: () => handleDrawerOpen(record.id),
};
},
render: record => <MemberCell member={record} />,
},
{
key: 'tasksProgress',
title: <CustomTableTitle title={t('tasksProgressColumn')} />,
render: record => {
const { todo, doing, done } = record.tasks_stat;
return (todo || doing || done) ? <TasksProgressCell tasksStat={record.tasks_stat} /> : '-';
},
},
{
key: 'tasksAssigned',
title: (
<CustomTableTitle
title={t('tasksAssignedColumn')}
tooltip={t('tasksAssignedColumnTooltip')}
/>
),
className: 'text-center group-hover:text-[#1890ff]',
dataIndex: 'tasks',
width: 180,
},
{
key: 'overdueTasks',
title: (
<CustomTableTitle
title={t('overdueTasksColumn')}
tooltip={t('overdueTasksColumnTooltip')}
/>
),
className: 'text-center group-hover:text-[#1890ff]',
dataIndex: 'overdue',
width: 180,
},
{
key: 'completedTasks',
title: (
<CustomTableTitle
title={t('completedTasksColumn')}
tooltip={t('completedTasksColumnTooltip')}
/>
),
className: 'text-center group-hover:text-[#1890ff]',
dataIndex: 'completed',
width: 180,
},
{
key: 'ongoingTasks',
title: (
<CustomTableTitle
title={t('ongoingTasksColumn')}
tooltip={t('ongoingTasksColumnTooltip')}
/>
),
className: 'text-center group-hover:text-[#1890ff]',
dataIndex: 'ongoing',
width: 180,
},
];
useEffect(() => {
if (!isLoading) dispatch(fetchMembersData({ duration, dateRange }));
}, [dispatch, archived, searchQuery, dateRange]);
return (
<ConfigProvider
theme={{
components: {
Table: {
cellPaddingBlock: 8,
cellPaddingInline: 10,
},
},
}}
>
<Table
columns={columns}
dataSource={membersList}
rowKey={record => record.id}
pagination={{ showSizeChanger: true, defaultPageSize: 10, total: total }}
scroll={{ x: 'max-content' }}
loading={isLoading}
onRow={record => {
return {
style: { height: 48, cursor: 'pointer' },
className: 'group even:bg-[#4e4e4e10]',
};
}}
/>
<MembersReportsDrawer memberId={selectedId} />
</ConfigProvider>
);
};
export default MembersReportsTable;

View File

@@ -0,0 +1,28 @@
import { Avatar, Flex, Typography } from 'antd';
import CustomAvatar from '@components/CustomAvatar';
type ProjectMangerCellProps = {
member: { avatar_url: string; name: string } | null;
};
const MemberCell = ({ member }: ProjectMangerCellProps) => {
return (
<div>
{member ? (
<Flex gap={8} align="center">
{member?.avatar_url ? (
<Avatar src={member.avatar_url} />
) : (
<CustomAvatar avatarName={member.name} />
)}
<Typography.Text className="group-hover:text-[#1890ff]">{member.name}</Typography.Text>
</Flex>
) : (
<Typography.Text className="group-hover:text-[#1890ff]">-</Typography.Text>
)}
</div>
);
};
export default MemberCell;

View File

@@ -0,0 +1,79 @@
import { Flex, Tooltip, Typography } from 'antd';
import { colors } from '@/styles/colors';
import { useTranslation } from 'react-i18next';
type TasksProgressCellProps = {
tasksStat: { todo: number; doing: number; done: number } | null;
};
const TasksProgressCell = ({ tasksStat }: TasksProgressCellProps) => {
// localization
const { t } = useTranslation('reporting-members');
if (!tasksStat) return null;
const totalStat = tasksStat.todo + tasksStat.doing + tasksStat.done;
if (totalStat === 0) return null;
const todoPercent = Math.round((tasksStat.todo / totalStat) * 100);
const doingPercent = Math.round((tasksStat.doing / totalStat) * 100);
const donePercent = Math.round((tasksStat.done / totalStat) * 100);
const segments = [
{ percent: donePercent, color: '#98d4b1', label: 'done' },
{ percent: doingPercent, color: '#bce3cc', label: 'doing' },
{ percent: todoPercent, color: '#e3f4ea', label: 'todo' },
];
return (
<Tooltip
trigger={'hover'}
title={
<Flex vertical>
{segments.map((seg, index) => (
<Typography.Text
key={index}
style={{ color: colors.white }}
>{`${t(`${seg.label}Text`)}: ${seg.percent}%`}</Typography.Text>
))}
</Flex>
}
>
<Flex
align="center"
style={{
width: '100%',
maxWidth: 200,
height: 16,
borderRadius: 4,
overflow: 'hidden',
cursor: 'pointer',
}}
>
{segments.map(
(segment, index) =>
segment.percent > 0 && (
<Typography.Text
key={index}
ellipsis
style={{
textAlign: 'center',
fontSize: 10,
fontWeight: 500,
color: colors.darkGray,
padding: '2px 4px',
minWidth: 32,
flexBasis: `${segment.percent}%`,
backgroundColor: segment.color,
}}
>
{segment.percent}%
</Typography.Text>
)
)}
</Flex>
</Tooltip>
);
};
export default TasksProgressCell;

View File

@@ -0,0 +1,86 @@
import { Button, Card, Checkbox, Dropdown, Flex, Skeleton, Space, Typography } from 'antd';
import { DownOutlined } from '@ant-design/icons';
import MembersReportsTable from './members-reports-table/members-reports-table';
import TimeWiseFilter from '@/components/reporting/time-wise-filter';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useTranslation } from 'react-i18next';
import CustomSearchbar from '@components/CustomSearchbar';
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
import CustomPageHeader from '../page-header/custom-page-header';
import {
fetchMembersData,
setArchived,
setDuration,
setDateRange,
setSearchQuery,
} from '@/features/reporting/membersReports/membersReportsSlice';
import { useAuthService } from '@/hooks/useAuth';
import { reportingExportApiService } from '@/api/reporting/reporting-export.api.service';
import { useEffect } from 'react';
const MembersReports = () => {
const { t } = useTranslation('reporting-members');
const dispatch = useAppDispatch();
useDocumentTitle('Reporting - Members');
const currentSession = useAuthService().getCurrentSession();
const { archived, searchQuery } = 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);
};
useEffect(() => {
dispatch(setDuration(duration));
dispatch(setDateRange(dateRange));
}, [dateRange, duration]);
return (
<Flex vertical>
<CustomPageHeader
title={`Members`}
children={
<Space>
<Button>
<Checkbox checked={archived} onChange={() => dispatch(setArchived(!archived))}>
<Typography.Text>{t('includeArchivedButton')}</Typography.Text>
</Checkbox>
</Button>
<TimeWiseFilter />
<Dropdown
menu={{ items: [{ key: '1', label: t('excelButton') }], onClick: handleExport }}
>
<Button type="primary" icon={<DownOutlined />} iconPosition="end">
{t('exportButton')}
</Button>
</Dropdown>
</Space>
}
/>
<Card
title={
<Flex justify="flex-end">
<CustomSearchbar
placeholderText={t('searchByNameInputPlaceholder')}
searchQuery={searchQuery}
setSearchQuery={query => dispatch(setSearchQuery(query))}
/>
</Flex>
}
>
<MembersReportsTable />
</Card>
</Flex>
);
};
export default MembersReports;

View File

@@ -0,0 +1,58 @@
import { useEffect } from 'react';
import { Button, Card, Checkbox, Flex, Typography } from 'antd';
import { useTranslation } from 'react-i18next';
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
import { evt_reporting_overview } from '@/shared/worklenz-analytics-events';
import CustomPageHeader from '@/pages/reporting/page-header/custom-page-header';
import OverviewReportsTable from './overview-table/overview-reports-table';
import OverviewStats from './overview-stats';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useAppSelector } from '@/hooks/useAppSelector';
import { toggleIncludeArchived } from '@/features/reporting/reporting.slice';
const OverviewReports = () => {
const { t } = useTranslation('reporting-overview');
const dispatch = useAppDispatch();
const { trackMixpanelEvent } = useMixpanelTracking();
const includeArchivedProjects = useAppSelector(
state => state.reportingReducer.includeArchivedProjects
);
useDocumentTitle('Reporting - Overview');
useEffect(() => {
trackMixpanelEvent(evt_reporting_overview);
}, [trackMixpanelEvent]);
const handleArchiveToggle = () => {
dispatch(toggleIncludeArchived());
};
return (
<Flex vertical gap={24}>
<CustomPageHeader
title={t('overviewTitle')}
children={
<Button type="text" onClick={handleArchiveToggle}>
<Checkbox checked={includeArchivedProjects} />
<Typography.Text>{t('includeArchivedButton')}</Typography.Text>
</Button>
}
/>
<OverviewStats />
<Card>
<Flex vertical gap={12}>
<Typography.Text strong style={{ fontSize: 16 }}>
{t('teamsText')}
</Typography.Text>
<OverviewReportsTable />
</Flex>
</Card>
</Flex>
);
};
export default OverviewReports;

View File

@@ -0,0 +1,32 @@
import { ReactNode } from 'react';
import { Card, Flex, Typography } from 'antd';
type InsightCardProps = {
icon: ReactNode;
title: string;
children: ReactNode;
loading?: boolean;
};
const OverviewStatCard = ({ icon, title, children, loading = false }: InsightCardProps) => {
return (
<Card
className="custom-insights-card"
style={{ width: '100%' }}
styles={{ body: { paddingInline: 16 } }}
loading={loading}
>
<Flex gap={16} align="flex-start">
{icon}
<Flex vertical gap={12}>
<Typography.Text style={{ fontSize: 16 }}>{title}</Typography.Text>
<>{children}</>
</Flex>
</Flex>
</Card>
);
};
export default OverviewStatCard;

View File

@@ -0,0 +1,132 @@
import { Flex, Typography } from 'antd';
import React, { useEffect, useState } from 'react';
import OverviewStatCard from './overview-stat-card';
import { BankOutlined, FileOutlined, UsergroupAddOutlined } from '@ant-design/icons';
import { colors } from '@/styles/colors';
import { useTranslation } from 'react-i18next';
import { IRPTOverviewStatistics } from '@/types/reporting/reporting.types';
import { reportingApiService } from '@/api/reporting/reporting.api.service';
import { useAppSelector } from '@/hooks/useAppSelector';
const OverviewStats = () => {
const [stats, setStats] = useState<IRPTOverviewStatistics>({});
const [loading, setLoading] = useState(false);
const { t } = useTranslation('reporting-overview');
const includeArchivedProjects = useAppSelector(
state => state.reportingReducer.includeArchivedProjects
);
const getOverviewStats = async () => {
setLoading(true);
try {
const { done, body } =
await reportingApiService.getOverviewStatistics(includeArchivedProjects);
if (done) {
setStats(body);
}
} catch (error) {
console.error('Failed to fetch overview statistics:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
getOverviewStats();
}, [includeArchivedProjects]);
const renderStatText = (count: number = 0, singularKey: string, pluralKey: string) => {
return `${count} ${count === 1 ? t(singularKey) : t(pluralKey)}`;
};
const renderStatCard = (
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}>
{stat.text}
</Typography.Text>
))}
</Flex>
</OverviewStatCard>
);
return (
<Flex gap={24}>
{renderStatCard(
<BankOutlined style={{ color: colors.skyBlue, fontSize: 42 }} />,
stats?.teams?.count,
'teamCount',
[
{
text: renderStatText(stats?.teams?.projects, 'projectCount', 'projectCountPlural'),
type: 'secondary',
},
{
text: renderStatText(stats?.teams?.members, 'memberCount', 'memberCountPlural'),
type: 'secondary',
},
]
)}
{renderStatCard(
<FileOutlined style={{ color: colors.limeGreen, fontSize: 42 }} />,
stats?.projects?.count,
'projectCount',
[
{
text: renderStatText(
stats?.projects?.active,
'activeProjectCount',
'activeProjectCountPlural'
),
type: 'secondary',
},
{
text: renderStatText(
stats?.projects?.overdue,
'overdueProjectCount',
'overdueProjectCountPlural'
),
type: 'danger',
},
]
)}
{renderStatCard(
<UsergroupAddOutlined style={{ color: colors.lightGray, fontSize: 42 }} />,
stats?.members?.count,
'memberCount',
[
{
text: renderStatText(
stats?.members?.unassigned,
'unassignedMemberCount',
'unassignedMemberCountPlural'
),
type: 'secondary',
},
{
text: renderStatText(
stats?.members?.overdue,
'memberWithOverdueTaskCount',
'memberWithOverdueTaskCountPlural'
),
type: 'danger',
},
]
)}
</Flex>
);
};
export default OverviewStats;

View File

@@ -0,0 +1,99 @@
import { memo, useEffect, useState } from 'react';
import { ConfigProvider, Table, TableColumnsType } from 'antd';
import { useAppDispatch } from '../../../../hooks/useAppDispatch';
import CustomTableTitle from '../../../../components/CustomTableTitle';
import { useTranslation } from 'react-i18next';
import { IRPTTeam } from '@/types/reporting/reporting.types';
import { reportingApiService } from '@/api/reporting/reporting.api.service';
import { useAppSelector } from '@/hooks/useAppSelector';
import logger from '@/utils/errorLogger';
import Avatars from '@/components/avatars/avatars';
import OverviewTeamInfoDrawer from '@/components/reporting/drawers/overview-team-info/overview-team-info-drawer';
import { toggleOverViewTeamDrawer } from '@/features/reporting/reporting.slice';
const OverviewReportsTable = () => {
const { t } = useTranslation('reporting-overview');
const dispatch = useAppDispatch();
const includeArchivedProjects = useAppSelector(
state => state.reportingReducer.includeArchivedProjects
);
const [selectedTeam, setSelectedTeam] = useState<IRPTTeam | null>(null);
const [teams, setTeams] = useState<IRPTTeam[]>([]);
const [loading, setLoading] = useState(false);
const getTeams = async () => {
setLoading(true);
try {
const { done, body } = await reportingApiService.getOverviewTeams(includeArchivedProjects);
if (done) {
setTeams(body);
}
} catch (error) {
logger.error('getTeams', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
getTeams();
}, [includeArchivedProjects]);
const handleDrawerOpen = (team: IRPTTeam) => {
setSelectedTeam(team);
dispatch(toggleOverViewTeamDrawer());
};
const columns: TableColumnsType = [
{
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 => <Avatars members={record.members} maxCount={3} />,
},
];
return (
<ConfigProvider
theme={{
components: {
Table: {
cellPaddingBlock: 8,
cellPaddingInline: 10,
},
},
}}
>
<Table
columns={columns}
dataSource={teams}
scroll={{ x: 'max-content' }}
rowKey={record => record.id}
loading={loading}
onRow={record => {
return {
onClick: () => handleDrawerOpen(record as IRPTTeam),
style: { height: 48, cursor: 'pointer' },
className: 'group even:bg-[#4e4e4e10]',
};
}}
/>
<OverviewTeamInfoDrawer team={selectedTeam} />
</ConfigProvider>
);
};
export default memo(OverviewReportsTable);

View File

@@ -0,0 +1,20 @@
import { PageHeader } from '@ant-design/pro-components';
import React, { memo } from 'react';
interface CustomPageHeaderProps {
title: string;
children?: React.ReactNode;
className?: string;
style?: React.CSSProperties;
}
const CustomPageHeader: React.FC<CustomPageHeaderProps> = ({
title,
children,
className = 'site-page-header',
style = { padding: '16px 0' },
}) => {
return <PageHeader className={className} title={title} style={style} extra={children} />;
};
export default memo(CustomPageHeader);

View File

@@ -0,0 +1,143 @@
import { categoriesApiService } from '@/api/settings/categories/categories.api.service';
import { fetchProjectCategories } from '@/features/projects/lookups/projectCategories/projectCategoriesSlice';
import { setSelectedProjectCategories } from '@/features/reporting/projectReports/project-reports-slice';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useAppSelector } from '@/hooks/useAppSelector';
import { IProjectCategoryViewModel } from '@/types/project/projectCategory.types';
import { CaretDownFilled } from '@ant-design/icons';
import { Badge, Button, Card, Checkbox, Dropdown, Empty, Flex, Input, InputRef, List } from 'antd';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
const ProjectCategoriesFilterDropdown = () => {
const { t } = useTranslation('reporting-projects-filters');
const dispatch = useAppDispatch();
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const categoryInputRef = useRef<InputRef>(null);
const { mode: themeMode } = useAppSelector(state => state.themeReducer);
const [searchQuery, setSearchQuery] = useState<string>('');
const [orgCategories, setOrgCategories] = useState<IProjectCategoryViewModel[]>([]);
const [loading, setLoading] = useState(false);
const { projectCategories, loading: projectCategoriesLoading } = useAppSelector(
state => state.projectCategoriesReducer
);
const handleCategoryDropdownOpen = (open: boolean) => {
setIsDropdownOpen(open);
if (open) {
setTimeout(() => {
categoryInputRef.current?.focus();
}, 0);
}
};
const getOrgCategories = async () => {
setLoading(true);
const response = await categoriesApiService.getCategoriesByOrganization();
if (response.done) {
setOrgCategories(response.body as IProjectCategoryViewModel[]);
}
setLoading(false);
};
useEffect(() => {
getOrgCategories();
}, []);
// Add filtered categories memo
const filteredCategories = useMemo(() => {
if (!searchQuery.trim()) return orgCategories;
return orgCategories.filter(category =>
category.name?.toLowerCase().includes(searchQuery.toLowerCase().trim())
);
}, [orgCategories, searchQuery]);
const handleCategoryChange = (category: IProjectCategoryViewModel) => {
const isSelected = orgCategories.some(h => h.id === category.id);
let updatedCategory: IProjectCategoryViewModel[];
if (isSelected) {
updatedCategory = orgCategories.filter(h => h.id !== category.id);
} else {
updatedCategory = [...orgCategories, category];
}
dispatch(setSelectedProjectCategories(category));
};
useEffect(() => {
if (!projectCategoriesLoading) dispatch(fetchProjectCategories());
}, [dispatch]);
const projectCategoryDropdownContent = (
<Card className="custom-card" styles={{ body: { padding: 8, width: 260 } }}>
<Flex vertical gap={8}>
<Input
ref={categoryInputRef}
value={searchQuery}
onChange={e => setSearchQuery(e.currentTarget.value)}
placeholder={t('searchByCategoryPlaceholder')}
/>
<List
style={{
padding: 0,
maxHeight: 200,
overflowY: 'auto',
}}
>
{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',
}}
>
<Checkbox id={category.id} onChange={() => handleCategoryChange(category)}>
<Flex gap={8}>
<Badge color={category.color_code} />
{category.name}
</Flex>
</Checkbox>
</List.Item>
))
) : (
<Empty />
)}
</List>
</Flex>
</Card>
);
return (
<Dropdown
overlayClassName="custom-dropdown"
trigger={['click']}
dropdownRender={() => projectCategoryDropdownContent}
onOpenChange={handleCategoryDropdownOpen}
>
<Button
icon={<CaretDownFilled />}
iconPosition="end"
loading={projectCategoriesLoading}
className={`transition-colors duration-300 ${isDropdownOpen
? 'border-[#1890ff] text-[#1890ff]'
: 'hover:text-[#1890ff hover:border-[#1890ff]'
}`}
>
{t('categoryText')}
</Button>
</Dropdown>
);
};
export default ProjectCategoriesFilterDropdown;

View File

@@ -0,0 +1,112 @@
import { fetchProjectHealth } from '@/features/projects/lookups/projectHealth/projectHealthSlice';
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';
import { CaretDownFilled } from '@ant-design/icons';
import { Button, Card, Checkbox, Dropdown, List, Space } from 'antd';
import React, { useEffect, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import debounce from 'lodash/debounce'; // Install lodash if not already present
const ProjectHealthFilterDropdown = () => {
const dispatch = useAppDispatch();
const { t } = useTranslation('reporting-projects-filters');
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [selectedHealths, setSelectedHealths] = useState<IProjectHealth[]>([]);
const { projectHealths, loading: projectHealthsLoading } = useAppSelector(
state => state.projectHealthReducer
);
const { mode: themeMode } = useAppSelector(state => state.themeReducer);
const { selectedProjectHealths, isLoading: projectLoading } = useAppSelector(
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]
);
const handleHealthChange = (health: IProjectHealth) => {
const isSelected = selectedHealths.some(h => h.id === health.id);
let updatedHealths: IProjectHealth[];
if (isSelected) {
updatedHealths = selectedHealths.filter(h => h.id !== health.id);
} else {
updatedHealths = [...selectedHealths, health];
}
setSelectedHealths(updatedHealths);
debouncedUpdate(updatedHealths);
};
const projectHealthDropdownContent = (
<Card className="custom-card" styles={{ body: { padding: 0 } }}>
<List style={{ padding: 0 }}>
{projectHealths.map(item => (
<List.Item
className={`custom-list-item ${themeMode === 'dark' ? 'dark' : ''}`}
key={item.id}
style={{
display: 'flex',
gap: 8,
padding: '4px 8px',
border: 'none',
}}
>
<Space>
<Checkbox
id={item.id}
checked={selectedHealths.some(h => h.id === item.id)}
onChange={() => handleHealthChange(item)}
disabled={projectLoading}
>
{item.name}
</Checkbox>
</Space>
</List.Item>
))}
</List>
</Card>
);
return (
<Dropdown
overlayClassName="custom-dropdown"
trigger={['click']}
dropdownRender={() => projectHealthDropdownContent}
onOpenChange={open => setIsDropdownOpen(open)}
>
<Button
icon={<CaretDownFilled />}
iconPosition="end"
loading={projectHealthsLoading}
className={`transition-colors duration-300 ${
isDropdownOpen
? 'border-[#1890ff] text-[#1890ff]'
: 'hover:text-[#1890ff] hover:border-[#1890ff]'
}`}
>
{t('healthText')}
</Button>
</Dropdown>
);
};
export default ProjectHealthFilterDropdown;

View File

@@ -0,0 +1,111 @@
import { fetchProjectManagers } from '@/features/projects/projectsSlice';
import { setSelectedProjectManagers } from '@/features/reporting/projectReports/project-reports-slice';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useAppSelector } from '@/hooks/useAppSelector';
import { IProjectManager } from '@/types/project/projectManager.types';
import { CaretDownFilled } from '@ant-design/icons';
import { Button, Card, Checkbox, Dropdown, Empty, Flex, Input, InputRef, List } from 'antd';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
const ProjectManagersFilterDropdown = () => {
const dispatch = useAppDispatch();
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const projectManagerInputRef = useRef<InputRef>(null);
const [searchQuery, setSearchQuery] = useState<string>('');
const { projectManagers, projectManagersLoading } = useAppSelector(
state => state.projectsReducer
);
const { mode: themeMode } = useAppSelector(state => state.themeReducer);
const { t } = useTranslation('reporting-projects-filters');
const filteredProjectManagerData = useMemo(() => {
return projectManagers.filter(projectManager =>
projectManager.name.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [projectManagers, searchQuery]);
const handleProjectManagerDropdownOpen = (open: boolean) => {
setIsDropdownOpen(open);
if (open) {
setTimeout(() => {
projectManagerInputRef.current?.focus();
}, 0);
}
};
const handleProjectManagerChange = (projectManager: IProjectManager) => {
dispatch(setSelectedProjectManagers(projectManager));
};
useEffect(() => {
if (!projectManagersLoading) dispatch(fetchProjectManagers());
}, [dispatch]);
const projectManagerDropdownContent = (
<Card className="custom-card" styles={{ body: { padding: 8, width: 260 } }}>
<Flex vertical gap={8}>
<Input
ref={projectManagerInputRef}
value={searchQuery}
onChange={e => setSearchQuery(e.currentTarget.value)}
placeholder={t('searchByNamePlaceholder')}
/>
<List style={{ padding: 0 }} loading={projectManagersLoading}>
{filteredProjectManagerData.length ? (
filteredProjectManagerData.map(projectManager => (
<List.Item
className={`custom-list-item ${themeMode === 'dark' ? 'dark' : ''}`}
key={projectManager.id}
style={{
display: 'flex',
justifyContent: 'flex-start',
gap: 8,
padding: '4px 8px',
border: 'none',
}}
>
<Checkbox
id={projectManager.id}
onChange={() => handleProjectManagerChange(projectManager)}
>
{projectManager.name}
</Checkbox>
</List.Item>
))
) : (
<Empty />
)}
</List>
</Flex>
</Card>
);
return (
<Dropdown
overlayClassName="custom-dropdown"
trigger={['click']}
dropdownRender={() => projectManagerDropdownContent}
onOpenChange={handleProjectManagerDropdownOpen}
>
<Button
icon={<CaretDownFilled />}
iconPosition="end"
loading={projectManagersLoading}
className={`transition-colors duration-300 ${
isDropdownOpen
? 'border-[#1890ff] text-[#1890ff]'
: 'hover:text-[#1890ff hover:border-[#1890ff]'
}`}
>
{t('projectManagerText')}
</Button>
</Dropdown>
);
};
export default ProjectManagersFilterDropdown;

View File

@@ -0,0 +1,40 @@
import { Flex } from 'antd';
import { useTranslation } from 'react-i18next';
import ProjectStatusFilterDropdown from './project-status-filter-dropdown';
import ProjectHealthFilterDropdown from './project-health-filter-dropdown';
import ProjectCategoriesFilterDropdown from './project-categories-filter-dropdown';
import ProjectManagersFilterDropdown from './project-managers-filter-dropdown';
import ProjectTableShowFieldsDropdown from './project-table-show-fields-dropdown';
import CustomSearchbar from '@/components/CustomSearchbar';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { setSearchQuery } from '@/features/reporting/projectReports/project-reports-slice';
const ProjectsReportsFilters = () => {
const dispatch = useAppDispatch();
const { t } = useTranslation('reporting-projects-filters');
const { searchQuery } = useAppSelector(state => state.projectReportsReducer);
return (
<Flex gap={8} align="center" justify="space-between">
<Flex gap={8} wrap={'wrap'}>
<ProjectStatusFilterDropdown />
<ProjectHealthFilterDropdown />
<ProjectCategoriesFilterDropdown />
<ProjectManagersFilterDropdown />
</Flex>
<Flex gap={12}>
<ProjectTableShowFieldsDropdown />
<CustomSearchbar
placeholderText={t('searchByNamePlaceholder')}
searchQuery={searchQuery}
setSearchQuery={text => dispatch(setSearchQuery(text))}
/>
</Flex>
</Flex>
);
};
export default ProjectsReportsFilters;

View File

@@ -0,0 +1,106 @@
import { fetchProjectStatuses } from '@/features/projects/lookups/projectStatuses/projectStatusesSlice';
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';
import { CaretDownFilled } from '@ant-design/icons';
import { Button, Card, Checkbox, Dropdown, List, Space } from 'antd';
import { debounce } from 'lodash';
import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
const ProjectStatusFilterDropdown = () => {
const dispatch = useAppDispatch();
const { t } = useTranslation('reporting-projects-filters');
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [selectedStatuses, setSelectedStatuses] = useState<IProjectStatus[]>([]);
const { projectStatuses, loading: projectStatusesLoading } = useAppSelector(
state => state.projectStatusesReducer
);
const { mode: themeMode } = useAppSelector(state => state.themeReducer);
const { selectedProjectStatuses } = useAppSelector(state => state.projectReportsReducer);
useEffect(() => {
setSelectedStatuses(selectedProjectStatuses);
}, [selectedProjectStatuses]);
useEffect(() => {
if (!projectStatusesLoading) dispatch(fetchProjectStatuses());
}, [dispatch]);
const debouncedUpdate = useCallback(
debounce((statuses: IProjectStatus[]) => {
dispatch(setSelectedProjectStatuses(statuses));
dispatch(fetchProjectData());
}, 300),
[dispatch]
);
const handleProjectStatusClick = (status: IProjectStatus) => {
const isSelected = selectedStatuses.some(s => s.id === status.id);
let updatedStatuses: IProjectStatus[];
if (isSelected) {
updatedStatuses = selectedStatuses.filter(s => s.id !== status.id);
} else {
updatedStatuses = [...selectedStatuses, status];
}
setSelectedStatuses(updatedStatuses);
debouncedUpdate(updatedStatuses);
};
// custom dropdown content
const projectStatusDropdownContent = (
<Card className="custom-card" styles={{ body: { padding: 0 } }}>
<List style={{ padding: 0 }}>
{projectStatuses.map(item => (
<List.Item
className={`custom-list-item ${themeMode === 'dark' ? 'dark' : ''}`}
key={item.id}
style={{
display: 'flex',
gap: 8,
padding: '4px 8px',
border: 'none',
}}
>
<Space>
<Checkbox
id={item.id}
key={item.id}
onChange={e => handleProjectStatusClick(item)}
>
{item.name}
</Checkbox>
</Space>
</List.Item>
))}
</List>
</Card>
);
return (
<Dropdown
overlayClassName="custom-dropdown"
trigger={['click']}
dropdownRender={() => projectStatusDropdownContent}
onOpenChange={open => setIsDropdownOpen(open)}
>
<Button
type="default"
icon={<CaretDownFilled />}
iconPosition="end"
loading={projectStatusesLoading}
className={`transition-colors duration-300 ${
isDropdownOpen
? 'border-[#1890ff] text-[#1890ff]'
: 'hover:text-[#1890ff hover:border-[#1890ff]'
}`}
>
{t('statusText')}
</Button>
</Dropdown>
);
};
export default ProjectStatusFilterDropdown;

View File

@@ -0,0 +1,57 @@
import React, { useState } from 'react';
import { MoreOutlined } from '@ant-design/icons';
import { Button, Card, Checkbox, Dropdown, List, Space } from 'antd';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { toggleColumnHidden } from '@/features/reporting/projectReports/project-reports-table-column-slice/project-reports-table-column-slice';
import { useTranslation } from 'react-i18next';
const ProjectTableShowFieldsDropdown = () => {
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const { t } = useTranslation('reporting-projects-filters');
const columnsVisibility = useAppSelector(state => state.projectReportsTableColumnsReducer);
const dispatch = useAppDispatch();
const columnKeys = Object.keys(columnsVisibility).filter(
key => key !== 'project' && key !== 'projectManager'
);
// Replace the showFieldsDropdownContent with a menu items structure
const menuItems = {
items: columnKeys.map(key => ({
key,
label: (
<Space>
<Checkbox
checked={columnsVisibility[key]}
onClick={() => dispatch(toggleColumnHidden(key))}
>
{t(`${key}Text`)}
</Checkbox>
</Space>
),
})),
};
return (
<Dropdown
menu={menuItems}
trigger={['click']}
onOpenChange={open => setIsDropdownOpen(open)}
>
<Button
icon={<MoreOutlined />}
className={`transition-colors duration-300 ${
isDropdownOpen
? 'border-[#1890ff] text-[#1890ff]'
: 'hover:text-[#1890ff hover:border-[#1890ff]'
}`}
>
{t('showFieldsText')}
</Button>
</Dropdown>
);
};
export default ProjectTableShowFieldsDropdown;

View File

@@ -0,0 +1,57 @@
[data-theme="dark"] {
--table-cell-bg: #181818;
--hover-bg: #000;
--base-bg: #181818;
}
[data-theme="default"] {
--table-cell-bg: #4e4e4e10;
--hover-bg: #edebf0;
--base-bg: #ffffff;
}
/* change the scroll bar in the table */
:where(.css-dev-only-do-not-override-ifo476).ant-table-wrapper .ant-table {
scrollbar-color: unset;
}
/* Fix sticky column background */
.ant-table-wrapper .ant-table-cell-fix-left {
background-color: var(--base-bg) !important;
z-index: 2 !important;
}
.ant-table-wrapper .ant-table-row:nth-child(even) .ant-table-cell-fix-left {
background-color: var(--table-cell-bg) !important;
}
.ant-table-wrapper .ant-table-row:hover .ant-table-cell-fix-left {
background-color: var(--hover-bg) !important;
}
/* Shadow for sticky columns */
:where(.css-dev-only-do-not-override-aw0rds).ant-table-wrapper
.ant-table-ping-left
.ant-table-cell-fix-left-first::after {
box-shadow: inset 10px 0 8px -8px rgba(0, 0, 0, 0.15);
right: -1px;
}
:where(.css-dev-only-do-not-override-aw0rds).ant-table-wrapper
.ant-table-ping-right:not(.ant-table-has-fix-right)
.ant-table-container::after {
box-shadow: inset -10px 0 8px -8px rgba(0, 0, 0, 0.15);
}
/* remove select border */
:where(.css-dev-only-do-not-override-tmno9t).ant-select-outlined:not(.ant-select-customize-input)
.ant-select-selector {
border: none;
}
.ant-select-focused:where(.css-dev-only-do-not-override-tmno9t).ant-select-outlined:not(
.ant-select-disabled
):not(.ant-select-customize-input):not(.ant-pagination-size-changer)
.ant-select-selector {
box-shadow: none;
}

View File

@@ -0,0 +1,318 @@
import { useEffect, useState, useMemo } from 'react';
import { Button, ConfigProvider, Flex, PaginationProps, Table, TableColumnsType } from 'antd';
import { useTranslation } from 'react-i18next';
import { createPortal } from 'react-dom';
import { ExpandAltOutlined } from '@ant-design/icons';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import ProjectCell from '@/pages/reporting/projects-reports/projects-reports-table/table-cells/project-cell/project-cell';
import EstimatedVsActualCell from '@/pages/reporting/projects-reports/projects-reports-table/table-cells/estimated-vs-actual-cell/estimated-vs-actual-cell';
import TasksProgressCell from '@/pages/reporting/projects-reports/projects-reports-table/table-cells/tasks-progress-cell/tasks-progress-cell';
import LastActivityCell from '@/pages/reporting/projects-reports/projects-reports-table/table-cells/last-activity-cell/last-activity-cell';
import ProjectStatusCell from '@/pages/reporting/projects-reports/projects-reports-table/table-cells/project-status-cell/project-status-cell';
import ProjectClientCell from '@/pages/reporting/projects-reports/projects-reports-table/table-cells/project-client-cell/project-client-cell';
import ProjectTeamCell from '@/pages/reporting/projects-reports/projects-reports-table/table-cells/project-team-cell/project-team-cell';
import ProjectManagerCell from '@/pages/reporting/projects-reports/projects-reports-table/table-cells/project-manager-cell/project-manager-cell';
import ProjectDatesCell from '@/pages/reporting/projects-reports/projects-reports-table/table-cells/project-dates-cell/project-dates-cell';
import ProjectHealthCell from '@/pages/reporting/projects-reports/projects-reports-table/table-cells/project-health-cell/project-health-cell';
import ProjectCategoryCell from '@/pages/reporting/projects-reports/projects-reports-table/table-cells/project-category-cell/project-category-cell';
import ProjectDaysLeftAndOverdueCell from '@/pages/reporting/projects-reports/projects-reports-table/table-cells/project-days-left-and-overdue-cell/project-days-left-and-overdue-cell';
import ProjectUpdateCell from '@/pages/reporting/projects-reports/projects-reports-table/table-cells/project-update-cell/project-update-cell';
import {
fetchProjectData,
resetProjectReports,
setField,
setIndex,
setOrder,
setPageSize,
toggleProjectReportsDrawer,
} from '@/features/reporting/projectReports/project-reports-slice';
import { colors } from '@/styles/colors';
import CustomTableTitle from '@/components/CustomTableTitle';
import { IRPTProject } from '@/types/reporting/reporting.types';
import ProjectReportsDrawer from '@/features/reporting/projectReports/projectReportsDrawer/ProjectReportsDrawer';
import { PAGE_SIZE_OPTIONS } from '@/shared/constants';
import './projects-reports-table.css';
import { fetchProjectStatuses } from '@/features/projects/lookups/projectStatuses/projectStatusesSlice';
const ProjectsReportsTable = () => {
const dispatch = useAppDispatch();
const { t } = useTranslation('reporting-projects');
const [selectedProject, setSelectedProject] = useState<IRPTProject | null>(null);
const { projectStatuses, loading: projectStatusesLoading } = useAppSelector(
state => state.projectStatusesReducer
);
const {
projectList,
isLoading,
total,
index,
pageSize,
order,
field,
searchQuery,
selectedProjectStatuses,
selectedProjectHealths,
selectedProjectCategories,
selectedProjectManagers,
archived,
} = useAppSelector(state => state.projectReportsReducer);
const columnsVisibility = useAppSelector(state => state.projectReportsTableColumnsReducer);
const handleDrawerOpen = (record: IRPTProject) => {
setSelectedProject(record);
dispatch(toggleProjectReportsDrawer());
};
const columns: TableColumnsType<IRPTProject> = useMemo(
() => [
{
key: 'name',
dataIndex: 'name',
title: <CustomTableTitle title={t('projectColumn')} />,
width: 300,
sorter: true,
defaultSortOrder: order === 'asc' ? 'ascend' : 'descend',
fixed: 'left' as const,
onCell: record => ({
onClick: () => handleDrawerOpen(record as IRPTProject),
}),
render: (_, record: { id: string; name: string; color_code: string }) => (
<Flex gap={16} align="center" justify="space-between">
<ProjectCell
projectId={record.id}
project={record.name}
projectColor={record.color_code}
/>
<Button
className="hidden group-hover:flex"
type="text"
style={{
backgroundColor: colors.transparent,
padding: 0,
height: 22,
alignItems: 'center',
gap: 8,
}}
>
{t('openButton')} <ExpandAltOutlined />
</Button>
</Flex>
),
},
{
key: 'estimatedVsActual',
title: <CustomTableTitle title={t('estimatedVsActualColumn')} />,
render: record => (
<EstimatedVsActualCell
actualTime={record.actual_time || 0}
actualTimeString={record.actual_time_string}
estimatedTime={record.estimated_time * 60 || 0}
estimatedTimeString={record.estimated_time_string}
/>
),
width: 230,
},
{
key: 'tasksProgress',
title: <CustomTableTitle title={t('tasksProgressColumn')} />,
render: record => <TasksProgressCell tasksStat={record.tasks_stat} />,
width: 200,
},
{
key: 'lastActivity',
title: <CustomTableTitle title={t('lastActivityColumn')} />,
render: record => (
<LastActivityCell activity={record.last_activity?.last_activity_string} />
),
width: 200,
},
{
key: 'status',
dataIndex: 'status_id',
defaultSortOrder: order === 'asc' ? 'ascend' : 'descend',
title: <CustomTableTitle title={t('statusColumn')} />,
render: (_, record: IRPTProject) => (
<ProjectStatusCell currentStatus={record.status_id} projectId={record.id} />
),
width: 200,
sorter: true,
},
{
key: 'dates',
title: <CustomTableTitle title={t('datesColumn')} />,
render: record => (
<ProjectDatesCell
projectId={record.id}
startDate={record.start_date}
endDate={record.end_date}
/>
),
width: 275,
},
{
key: 'daysLeft',
title: <CustomTableTitle title={t('daysLeftColumn')} />,
render: record => (
<ProjectDaysLeftAndOverdueCell
daysLeft={record.days_left}
isOverdue={record.is_overdue}
isToday={record.is_today}
/>
),
width: 200,
},
{
key: 'projectHealth',
dataIndex: 'project_health',
defaultSortOrder: order === 'asc' ? 'ascend' : 'descend',
title: <CustomTableTitle title={t('projectHealthColumn')} />,
sorter: true,
render: (_, record: IRPTProject) => (
<ProjectHealthCell
value={record.project_health}
label={record.health_name}
color={record.health_color}
projectId={record.id}
/>
),
width: 200,
},
{
key: 'category',
title: <CustomTableTitle title={t('categoryColumn')} />,
render: (_, record: IRPTProject) => (
<ProjectCategoryCell
projectId={record.id}
id={record.category_id || ''}
name={record.category_name || ''}
color_code={record.category_color || ''}
/>
),
width: 200,
},
{
key: 'projectUpdate',
title: <CustomTableTitle title={t('projectUpdateColumn')} />,
render: (_, record: IRPTProject) =>
record.comment ? <ProjectUpdateCell updates={record.comment} /> : '-',
width: 200,
},
{
key: 'client',
dataIndex: 'client',
defaultSortOrder: order === 'asc' ? 'ascend' : 'descend',
title: <CustomTableTitle title={t('clientColumn')} />,
render: (_, record: IRPTProject) =>
record?.client ? <ProjectClientCell client={record.client} /> : '-',
sorter: true,
width: 200,
},
{
key: 'team',
dataIndex: 'team_name',
defaultSortOrder: order === 'asc' ? 'ascend' : 'descend',
title: <CustomTableTitle title={t('teamColumn')} />,
render: (_, record: IRPTProject) =>
record.team_name ? <ProjectTeamCell team={record.team_name} /> : '-',
sorter: true,
width: 200,
},
{
key: 'projectManager',
title: <CustomTableTitle title={t('projectManagerColumn')} />,
render: (_, record: IRPTProject) =>
record.project_manager ? <ProjectManagerCell manager={record.project_manager} /> : '-',
width: 200,
},
],
[t, order]
);
// filter columns based on the `hidden` state from Redux
const visibleColumns = useMemo(
() => columns.filter(col => columnsVisibility[col.key as string]),
[columns, columnsVisibility]
);
const handleTableChange = (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));
};
useEffect(() => {
if (!isLoading) dispatch(fetchProjectData());
if (projectStatuses.length === 0 && !projectStatusesLoading) dispatch(fetchProjectStatuses());
}, [
dispatch,
searchQuery,
selectedProjectStatuses,
selectedProjectHealths,
selectedProjectCategories,
selectedProjectManagers,
archived,
index,
pageSize,
order,
field,
]);
useEffect(() => {
return () => {
dispatch(resetProjectReports());
};
}, []);
const tableRowProps = useMemo(
() => ({
style: { height: 56, cursor: 'pointer' },
className: 'group even:bg-[#4e4e4e10]',
}),
[]
);
const tableConfig = useMemo(
() => ({
theme: {
components: {
Table: {
cellPaddingBlock: 12,
cellPaddingInline: 10,
},
},
},
}),
[]
);
return (
<ConfigProvider {...tableConfig}>
<Table
columns={visibleColumns}
dataSource={projectList}
pagination={{
showSizeChanger: true,
defaultPageSize: 10,
total: total,
current: index,
pageSizeOptions: PAGE_SIZE_OPTIONS,
}}
scroll={{ x: 'max-content' }}
loading={isLoading}
onChange={handleTableChange}
rowKey={record => record.id}
onRow={() => tableRowProps}
/>
{createPortal(<ProjectReportsDrawer selectedProject={selectedProject} />, document.body)}
</ConfigProvider>
);
};
export default ProjectsReportsTable;

View File

@@ -0,0 +1,93 @@
import React from 'react';
import { Bar } from 'react-chartjs-2';
import { Chart, BarElement, CategoryScale, LinearScale } from 'chart.js';
import { ChartOptions } from 'chart.js';
import { Typography } from 'antd';
import { useTranslation } from 'react-i18next';
Chart.register(BarElement, CategoryScale, LinearScale);
type EstimatedVsActualCellProps = {
actualTime: number | null;
actualTimeString: string | null;
estimatedTime: number | null;
estimatedTimeString: string | null;
};
const EstimatedVsActualCell = ({
actualTime,
actualTimeString,
estimatedTime,
estimatedTimeString,
}: EstimatedVsActualCellProps) => {
const { t } = useTranslation('reporting-projects');
const options: ChartOptions<'bar'> = {
indexAxis: 'y',
scales: {
x: {
display: false,
},
y: {
display: false,
},
},
plugins: {
legend: {
display: false,
position: 'top' as const,
},
datalabels: {
display: false,
},
tooltip: {
enabled: false,
},
},
};
// data for the chart
const graphData = {
labels: [t('estimatedText'), t('actualText')],
datasets: [
{
data: [estimatedTime, actualTime],
backgroundColor: ['#7a84df', '#c191cc'],
barThickness: 15,
height: 29,
},
],
};
return (
<div>
{actualTime || estimatedTime ? (
<div style={{ position: 'relative', width: '100%', maxWidth: '200px' }}>
<Bar options={options} data={graphData} style={{ maxHeight: 39 }} />
<Typography.Text
style={{
position: 'absolute',
fontSize: 12,
fontWeight: 500,
left: 8,
top: 1,
}}
>{`${t('estimatedText')}: ${estimatedTimeString}`}</Typography.Text>
<Typography.Text
style={{
position: 'absolute',
fontSize: 12,
fontWeight: 500,
left: 8,
top: 20,
}}
>{`${t('actualText')}: ${actualTimeString}`}</Typography.Text>
</div>
) : (
<Typography.Text>-</Typography.Text>
)}
</div>
);
};
export default EstimatedVsActualCell;

View File

@@ -0,0 +1,18 @@
import { Tooltip, Typography } from 'antd';
import React from 'react';
const LastActivityCell = ({ activity }: { activity: string }) => {
return (
<Tooltip title={activity?.length > 0 && activity}>
<Typography.Text
style={{ cursor: 'pointer' }}
ellipsis={{ expanded: false }}
className="group-hover:text-[#1890ff]"
>
{activity ? activity : '-'}
</Typography.Text>
</Tooltip>
);
};
export default LastActivityCell;

View File

@@ -0,0 +1,19 @@
.project-category-dropdown .ant-dropdown-menu {
padding: 4px !important;
margin-top: 8px !important;
overflow: hidden;
}
.project-category-dropdown .ant-dropdown-menu-item {
padding: 0 !important;
}
.project-category-dropdown-card .ant-card-body {
padding: 8px !important;
}
.project-category-menu .ant-menu-item {
display: flex;
align-items: center;
height: 32px;
}

View File

@@ -0,0 +1,206 @@
/* eslint-disable react-hooks/exhaustive-deps */
import { DownOutlined } from '@ant-design/icons';
import { Badge, Card, Dropdown, Flex, Input, InputRef, Menu, MenuProps, Typography } from 'antd';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useAppSelector } from '@/hooks/useAppSelector';
import { colors } from '@/styles/colors';
import './project-category-cell.css';
import { nanoid } from '@reduxjs/toolkit';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { addCategory } from '@features/settings/categories/categoriesSlice';
import { themeWiseColor } from '@utils/themeWiseColor';
import { IProjectCategory, IProjectCategoryViewModel } from '@/types/project/projectCategory.types';
import { useTranslation } from 'react-i18next';
import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events';
import { setSelectedProjectCategory } from '@/features/reporting/projectReports/project-reports-slice';
// Update the props interface to include projectId
interface ProjectCategoryCellProps {
id: string;
name: string;
color_code: string;
projectId: string;
}
const ProjectCategoryCell = ({ id, name, color_code, projectId }: ProjectCategoryCellProps) => {
const dispatch = useAppDispatch();
const { t } = useTranslation('reporting-projects');
const categoryInputRef = useRef<InputRef>(null);
const { socket, connected } = useSocket();
const [selectedCategory, setSelectedCategory] = useState<IProjectCategory>({
id,
name,
color_code,
});
// get categories list from the categories reducer
const { projectCategories, loading: projectCategoriesLoading } = useAppSelector(
state => state.projectCategoriesReducer
);
const themeMode = useAppSelector(state => state.themeReducer.mode);
const [searchQuery, setSearchQuery] = useState<string>('');
// filter categories based on search query
const filteredCategoriesData = useMemo(() => {
return projectCategories.filter(category =>
category.name?.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [projectCategories, searchQuery]);
// category selection options
const categoryOptions = filteredCategoriesData.map(category => ({
key: category.id,
label: (
<Typography.Text style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<Badge color={category.color_code} /> {category.name}
</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
})
);
}
};
// function to handle add a new category
const handleCreateCategory = (name: string) => {
if (name.length > 0) {
const newCategory: IProjectCategory = {
id: nanoid(),
name,
color_code: '#1E90FF',
};
dispatch(addCategory(newCategory));
setSearchQuery('');
}
};
// dropdown items
const projectCategoryCellItems: MenuProps['items'] = [
{
key: '1',
label: (
<Card className="project-category-dropdown-card" variant="borderless">
<Flex vertical gap={4}>
<Input
ref={categoryInputRef}
value={searchQuery}
onChange={e => setSearchQuery(e.currentTarget.value)}
placeholder={t('searchByNameInputPlaceholder')}
onKeyDown={e => {
const isCategory = filteredCategoriesData.findIndex(
category => category.name?.toLowerCase() === searchQuery.toLowerCase()
);
if (isCategory === -1 && e.key === 'Enter') {
// handle category creation logic
handleCreateCategory(searchQuery);
}
}}
/>
{filteredCategoriesData.length === 0 && (
<Typography.Text style={{ color: colors.lightGray }}>
Hit enter to create!
</Typography.Text>
)}
</Flex>
<Menu className="project-category-menu" items={categoryOptions} onClick={onClick} />
</Card>
),
},
];
// Update the socket response handler
const handleCategoryChangeResponse = (data: any) => {
try {
const parsedData = typeof data === 'string' ? JSON.parse(data) : data;
if (parsedData && parsedData.project_id === projectId) {
// Update local state
setSelectedCategory(parsedData.category);
// Update redux store
dispatch(updateProjectCategory({
projectId: parsedData.project_id,
category: parsedData.category
}));
}
} catch (error) {
console.error('Error handling category change response:', error);
}
};
const handleCategoryDropdownOpen = (open: boolean) => {
if (open) {
setTimeout(() => {
categoryInputRef.current?.focus();
}, 0);
}
};
useEffect(() => {
if (connected && socket) {
socket.on(SocketEvents.PROJECT_CATEGORY_CHANGE.toString(), handleCategoryChangeResponse);
return () => {
socket.off(SocketEvents.PROJECT_CATEGORY_CHANGE.toString(), handleCategoryChangeResponse);
};
}
}, [connected, socket]);
return (
<Dropdown
overlayClassName="custom-dropdown"
menu={{ items: projectCategoryCellItems }}
placement="bottomRight"
trigger={['click']}
onOpenChange={handleCategoryDropdownOpen}
>
<Flex
gap={6}
align="center"
style={{
width: 'fit-content',
borderRadius: 24,
paddingInline: 8,
textTransform: 'capitalize',
fontSize: 13,
height: 22,
backgroundColor: selectedCategory.id ? selectedCategory.color_code : colors.transparent,
color: selectedCategory.id
? themeWiseColor(colors.white, colors.darkGray, themeMode)
: themeWiseColor(colors.darkGray, colors.white, themeMode),
border: selectedCategory.id ? 'none' : `1px solid ${colors.deepLightGray}`,
cursor: 'pointer',
}}
>
{selectedCategory.id ? selectedCategory.name : t('setCategoryText')}
<DownOutlined />
</Flex>
</Dropdown>
);
};
// Action creator for updating project category
const updateProjectCategory = (payload: { projectId: string; category: IProjectCategory }) => ({
type: 'projects/updateCategory',
payload
});
export default ProjectCategoryCell;

View File

@@ -0,0 +1,29 @@
import { Badge, Flex, Space, Tooltip, Typography } from 'antd';
import React from 'react';
type ProjectCellProps = {
projectId: string;
project: string;
projectColor: string;
};
const ProjectCell = ({ project, projectColor }: ProjectCellProps) => {
return (
<Tooltip title={project}>
<Flex gap={16} align="center" justify="space-between">
<Space>
<Badge color={projectColor} />
<Typography.Text
style={{ width: 160 }}
ellipsis={{ expanded: false }}
className="group-hover:text-[#1890ff]"
>
{project}
</Typography.Text>
</Space>
</Flex>
</Tooltip>
);
};
export default ProjectCell;

View File

@@ -0,0 +1,16 @@
import { Typography } from 'antd';
import React from 'react';
const ProjectClientCell = ({ client }: { client: string }) => {
return (
<Typography.Text
style={{ cursor: 'pointer' }}
ellipsis={{ expanded: false }}
className="group-hover:text-[#1890ff]"
>
{client ? client : '-'}
</Typography.Text>
);
};
export default ProjectClientCell;

View File

@@ -0,0 +1,114 @@
import { DatePicker, Flex, Typography } from 'antd';
import { useEffect } from 'react';
import { colors } from '@/styles/colors';
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 logger from '@/utils/errorLogger';
type ProjectDatesCellProps = {
projectId: string;
startDate: Date | null;
endDate: Date | null;
};
const ProjectDatesCell = ({ projectId, startDate, endDate }: ProjectDatesCellProps) => {
const dispatch = useAppDispatch();
const startDayjs = startDate ? dayjs(startDate) : null;
const endDayjs = endDate ? dayjs(endDate) : null;
const { socket, connected } = useSocket();
const handleStartDateChangeResponse = (data: { project_id: string; start_date: string }) => {
try {
dispatch(setProjectStartDate(data));
} catch (error) {
logger.error('Error updating start date:', error);
}
};
const handleEndDateChangeResponse = (data: { project_id: string; end_date: string }) => {
try {
dispatch(setProjectEndDate(data));
} catch (error) {
logger.error('Error updating end date:', error);
}
};
const handleStartDateChange = (date: Dayjs | null) => {
try {
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'),
}));
} catch (error) {
logger.error('Error sending start date change:', error);
}
};
const handleEndDateChange = (date: Dayjs | null) => {
try {
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'),
}));
} catch (error) {
logger.error('Error sending end date change:', error);
}
};
useEffect(() => {
if (connected && socket) {
socket.on(SocketEvents.PROJECT_START_DATE_CHANGE.toString(), handleStartDateChangeResponse);
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);
};
}
}, [connected, socket]);
return (
<Flex gap={4}>
<DatePicker
disabledDate={current => current > (endDayjs || dayjs())}
placeholder="Set Start Date"
defaultValue={startDayjs}
format={'MMM DD, YYYY'}
suffixIcon={null}
onChange={handleStartDateChange}
style={{
backgroundColor: colors.transparent,
border: 'none',
boxShadow: 'none',
}}
/>
<Typography.Text>-</Typography.Text>
<DatePicker
disabledDate={current => current < (startDayjs || dayjs())}
placeholder="Set End Date"
defaultValue={endDayjs}
format={'MMM DD, YYYY'}
suffixIcon={null}
style={{
backgroundColor: colors.transparent,
border: 'none',
boxShadow: 'none',
}}
onChange={handleEndDateChange}
/>
</Flex>
);
};
export default ProjectDatesCell;

View File

@@ -0,0 +1,48 @@
import { Typography } from 'antd';
import React from 'react';
import { colors } from '../../../../../../styles/colors';
import { useTranslation } from 'react-i18next';
type ProjectDaysLeftAndOverdueCellProps = {
daysLeft: number | null;
isOverdue: boolean;
isToday: boolean;
};
const ProjectDaysLeftAndOverdueCell = ({
daysLeft,
isOverdue,
isToday,
}: ProjectDaysLeftAndOverdueCellProps) => {
const { t } = useTranslation('reporting-projects');
return (
<>
{daysLeft !== null ? (
<>
{isOverdue ? (
<Typography.Text style={{ cursor: 'pointer', color: '#f37070' }}>
{Math.abs(daysLeft)} {t('daysOverdueText')}
</Typography.Text>
) : (
<>
{isToday ? (
<Typography.Text style={{ cursor: 'pointer', color: colors.limeGreen }}>
{t('todayText')}
</Typography.Text>
) : (
<Typography.Text style={{ cursor: 'pointer', color: colors.limeGreen }}>
{daysLeft} {daysLeft === 1 ? t('dayLeftText') : t('daysLeftText')}
</Typography.Text>
)}
</>
)}
</>
) : (
<Typography.Text style={{ cursor: 'pointer' }}>-</Typography.Text>
)}
</>
);
};
export default ProjectDaysLeftAndOverdueCell;

View File

@@ -0,0 +1,19 @@
.project-health-dropdown .ant-dropdown-menu {
padding: 0 !important;
margin-top: 8px !important;
overflow: hidden;
}
.project-health-dropdown .ant-dropdown-menu-item {
padding: 0 !important;
}
.project-health-dropdown-card .ant-card-body {
padding: 0 !important;
}
.project-health-menu .ant-menu-item {
display: flex;
align-items: center;
height: 32px;
}

View File

@@ -0,0 +1,117 @@
import { Badge, Card, Dropdown, Flex, Menu, MenuProps, Typography } from 'antd';
import { useEffect, useState } from 'react';
import { DownOutlined } from '@ant-design/icons';
import { colors } from '@/styles/colors';
import './project-health-cell.css';
import { useTranslation } from 'react-i18next';
import { useAppSelector } from '@/hooks/useAppSelector';
import { IProjectHealth } from '@/types/project/projectHealth.types';
import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events';
import { setProjectHealth } from '@/features/reporting/projectReports/project-reports-slice';
import { useAppDispatch } from '@/hooks/useAppDispatch';
interface HealthStatusDataType {
value: string;
label: string;
color: string;
projectId: string;
}
const ProjectHealthCell = ({ value, label, color, projectId }: HealthStatusDataType) => {
const { t } = useTranslation('reporting-projects');
const dispatch = useAppDispatch();
const { socket, connected } = useSocket();
const { projectHealths } = useAppSelector(state => state.projectHealthReducer);
const projectHealth = projectHealths.find(status => status.id === value) || {
color_code: color,
id: value,
name: label,
};
const healthOptions = projectHealths.map(status => ({
key: status.id,
value: status.id,
label: (
<Typography.Text style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<Badge color={status.color_code} /> {t(`${status.name}`)}
</Typography.Text>
),
}));
const handleHealthChangeResponse = (data: IProjectHealth) => {
dispatch(setProjectHealth(data));
};
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,
}));
};
// dropdown items
const projectHealthCellItems: MenuProps['items'] = [
{
key: '1',
label: (
<Card className="project-health-dropdown-card" bordered={false}>
<Menu className="project-health-menu" items={healthOptions} onClick={onClick} />
</Card>
),
},
];
useEffect(() => {
if (socket && connected) {
socket.on(SocketEvents.PROJECT_HEALTH_CHANGE.toString(), handleHealthChangeResponse);
return () => {
socket.removeListener(
SocketEvents.PROJECT_HEALTH_CHANGE.toString(),
handleHealthChangeResponse
);
};
}
}, [socket, connected]);
return (
<Dropdown
overlayClassName="project-health-dropdown"
menu={{ items: projectHealthCellItems }}
placement="bottomRight"
trigger={['click']}
>
<Flex
gap={6}
align="center"
style={{
width: 'fit-content',
borderRadius: 24,
paddingInline: 8,
height: 30,
backgroundColor: projectHealth?.color_code || colors.transparent,
color: colors.darkGray,
cursor: 'pointer',
}}
>
<Typography.Text
style={{
textTransform: 'capitalize',
color: colors.darkGray,
fontSize: 13,
}}
>
{projectHealth?.name}
</Typography.Text>
<DownOutlined />
</Flex>
</Dropdown>
);
};
export default ProjectHealthCell;

View File

@@ -0,0 +1,26 @@
import { Avatar, Flex, Typography } from 'antd';
import CustomAvatar from '@components/CustomAvatar';
import { ITeamMemberViewModel } from '@/types/teamMembers/teamMembersGetResponse.types';
import SingleAvatar from '@/components/common/single-avatar/single-avatar';
type ProjectMangerCellProps = {
manager: ITeamMemberViewModel;
};
const ProjectManagerCell = ({ manager }: ProjectMangerCellProps) => {
return (
<div>
{manager ? (
<Flex gap={8} align="center">
<SingleAvatar name={manager.name} avatarUrl={manager.avatar_url} />
<Typography.Text className="group-hover:text-[#1890ff]">{manager.name}</Typography.Text>
</Flex>
) : (
<Typography.Text className="group-hover:text-[#1890ff]">-</Typography.Text>
)}
</div>
);
};
export default ProjectManagerCell;

View File

@@ -0,0 +1,96 @@
import { ConfigProvider, Select, Typography } from 'antd';
import { colors } from '@/styles/colors';
import { useTranslation } from 'react-i18next';
import { useAppSelector } from '@/hooks/useAppSelector';
import { getStatusIcon } from '@/utils/projectUtils';
import { useEffect, useState } from 'react';
import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { setProjectStatus } from '@/features/reporting/projectReports/project-reports-slice';
import logger from '@/utils/errorLogger';
interface ProjectStatusCellProps {
currentStatus: string;
projectId: string;
}
const ProjectStatusCell = ({ currentStatus, projectId }: ProjectStatusCellProps) => {
const { t } = useTranslation('reporting-projects');
const dispatch = useAppDispatch();
const { socket } = useSocket();
const { projectStatuses } = useAppSelector(state => state.projectStatusesReducer);
const [selectedStatus, setSelectedStatus] = useState(currentStatus);
// Find current status object
const currentStatusObject = projectStatuses.find(status => status.id === selectedStatus);
const statusOptions = projectStatuses.map(status => ({
value: status.id,
label: (
<Typography.Text
style={{ display: 'flex', alignItems: 'center', gap: 4 }}
className="group-hover:text-[#1890ff]"
>
{getStatusIcon(status.icon || '', status.color_code || '')}
{t(`${status.name}`)}
</Typography.Text>
)
}));
const handleStatusChange = (value: string) => {
try {
if (!value || !projectId) {
throw new Error('Invalid status value or project ID');
}
const newStatus = projectStatuses.find(status => status.id === value);
if (!newStatus) {
throw new Error('Status not found');
}
// Update local state immediately
setSelectedStatus(value);
// Update Redux store
dispatch(setProjectStatus({ projectId, status: newStatus }));
// Emit socket event
socket?.emit(
SocketEvents.PROJECT_STATUS_CHANGE.toString(),
JSON.stringify({
project_id: projectId,
status_id: value,
})
);
} catch (error) {
logger.error('Error changing project status:', error);
}
};
// Keep local state in sync with props
useEffect(() => {
setSelectedStatus(currentStatus);
}, [currentStatus]);
return (
<ConfigProvider
theme={{
components: {
Select: {
selectorBg: colors.transparent,
},
},
}}
>
<Select
variant="borderless"
options={statusOptions}
value={selectedStatus}
onChange={handleStatusChange}
/>
</ConfigProvider>
);
};
export default ProjectStatusCell;

View File

@@ -0,0 +1,20 @@
import { Tag } from 'antd';
import React from 'react';
import { colors } from '../../../../../../styles/colors';
const ProjectTeamCell = ({ team }: { team: string }) => {
return (
<Tag
color={colors.paleBlue}
style={{
borderColor: colors.skyBlue,
color: colors.skyBlue,
fontSize: 12,
}}
>
{team}
</Tag>
);
};
export default ProjectTeamCell;

View File

@@ -0,0 +1,20 @@
import { Typography } from 'antd';
import React from 'react';
type ProjectUpdateCellProps = {
updates: string;
};
const ProjectUpdateCell = ({ updates }: ProjectUpdateCellProps) => {
return (
<Typography.Text
style={{ cursor: 'pointer' }}
ellipsis={{ expanded: false }}
className="group-hover:text-[#1890ff]"
>
<div dangerouslySetInnerHTML={{__html: updates}} />
</Typography.Text>
);
};
export default ProjectUpdateCell;

View File

@@ -0,0 +1,79 @@
import { Flex, Tooltip, Typography } from 'antd';
import React from 'react';
import { colors } from '../../../../../../styles/colors';
import { useTranslation } from 'react-i18next';
type TasksProgressCellProps = {
tasksStat: { todo: number; doing: number; done: number } | null;
};
const TasksProgressCell = ({ tasksStat }: TasksProgressCellProps) => {
// localization
const { t } = useTranslation('reporting-projects');
if (!tasksStat) return null;
const totalStat = tasksStat.todo + tasksStat.doing + tasksStat.done;
if (totalStat === 0) return null;
const todoPercent = Math.floor((tasksStat.todo / totalStat) * 100);
const doingPercent = Math.floor((tasksStat.doing / totalStat) * 100);
const donePercent = Math.floor((tasksStat.done / totalStat) * 100);
const segments = [
{ percent: todoPercent, color: '#98d4b1', label: 'todo' },
{ percent: doingPercent, color: '#bce3cc', label: 'doing' },
{ percent: donePercent, color: '#e3f4ea', label: 'done' },
];
return (
<Tooltip
trigger={'hover'}
title={
<Flex vertical>
{segments.map((seg, index) => (
<Typography.Text
key={index}
style={{ color: colors.white }}
>{`${t(`${seg.label}Text`)}: ${seg.percent}%`}</Typography.Text>
))}
</Flex>
}
>
<Flex
align="center"
style={{
width: '100%',
height: 16,
borderRadius: 4,
overflow: 'hidden',
cursor: 'pointer',
}}
>
{segments.map(
(segment, index) =>
segment.percent > 0 && (
<Typography.Text
key={index}
ellipsis
style={{
textAlign: 'center',
fontSize: 10,
fontWeight: 500,
color: colors.darkGray,
padding: '2px 4px',
minWidth: 32,
flexBasis: `${segment.percent}%`,
backgroundColor: segment.color,
}}
>
{segment.percent}%
</Typography.Text>
)
)}
</Flex>
</Tooltip>
);
};
export default TasksProgressCell;

View File

@@ -0,0 +1,59 @@
import { Button, Card, Checkbox, Dropdown, Flex, Space, Typography } from 'antd';
import CustomPageHeader from '@/pages/reporting/page-header/custom-page-header';
import { DownOutlined } from '@ant-design/icons';
import ProjectReportsTable from './projects-reports-table/projects-reports-table';
import ProjectsReportsFilters from './projects-reports-filters/project-reports-filters';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useTranslation } from 'react-i18next';
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
import { setArchived } from '@/features/reporting/projectReports/project-reports-slice';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useAuthService } from '@/hooks/useAuth';
import { reportingExportApiService } from '@/api/reporting/reporting-export.api.service';
const ProjectsReports = () => {
const { t } = useTranslation('reporting-projects');
const dispatch = useAppDispatch();
const currentSession = useAuthService().getCurrentSession();
useDocumentTitle('Reporting - Projects');
const { total, archived } = useAppSelector(state => state.projectReportsReducer);
const handleExcelExport = () => {
if (currentSession?.team_name) {
reportingExportApiService.exportProjects(currentSession.team_name);
}
};
return (
<Flex vertical>
<CustomPageHeader
title={`${total === 1 ? `${total} ${t('projectCount')}` : `${total} ${t('projectCountPlural')}`} `}
children={
<Space>
<Button>
<Checkbox checked={archived} onChange={() => dispatch(setArchived(!archived))}>
<Typography.Text>{t('includeArchivedButton')}</Typography.Text>
</Checkbox>
</Button>
<Dropdown
menu={{ items: [{ key: '1', label: t('excelButton'), onClick: handleExcelExport }] }}
>
<Button type="primary" icon={<DownOutlined />} iconPosition="end">
{t('exportButton')}
</Button>
</Dropdown>
</Space>
}
/>
<Card title={<ProjectsReportsFilters />}>
<ProjectReportsTable />
</Card>
</Flex>
);
};
export default ProjectsReports;

View File

@@ -0,0 +1,106 @@
import { GlobalOutlined, LeftCircleOutlined, RightCircleOutlined } from '@ant-design/icons';
import React, { useEffect, useState } from 'react';
import { colors } from '@/styles/colors';
import { Button, Flex, Tooltip, Typography } from 'antd';
import { themeWiseColor } from '@utils/themeWiseColor';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useTranslation } from 'react-i18next';
import { IOrganization } from '@/types/admin-center/admin-center.types';
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
import logger from '@/utils/errorLogger';
const ReportingCollapsedButton = ({
isCollapsed,
handleCollapseToggler,
}: {
isCollapsed: boolean;
handleCollapseToggler: () => void;
}) => {
// localization
const { t } = useTranslation('reporting-sidebar');
const themeMode = useAppSelector(state => state.themeReducer.mode);
// State for organization name and loading
const [organization, setOrganization] = useState<IOrganization | null>(null);
const [loading, setLoading] = useState(false);
// Fetch organization details
const getOrganizationDetails = async () => {
setLoading(true);
try {
const res = await adminCenterApiService.getOrganizationDetails();
if (res.done) {
setOrganization(res.body);
}
} catch (error) {
logger.error('Error getting organization details', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
getOrganizationDetails();
}, []);
return (
<Flex
align="center"
justify="space-between"
style={{
marginBlockStart: 76,
marginBlockEnd: 24,
maxWidth: 160,
height: 40,
}}
>
{!isCollapsed && (
<Tooltip title={!isCollapsed && t('currentOrganizationTooltip')} trigger={'hover'}>
<Flex gap={8} align="center" style={{ marginInlineStart: 16 }}>
<GlobalOutlined
style={{
color: themeWiseColor(colors.darkGray, colors.white, themeMode),
}}
/>
<Typography.Text strong>
{loading ? 'Loading...' : organization?.name || 'Unknown Organization'}
</Typography.Text>
</Flex>
</Tooltip>
)}
<Button
className="borderless-icon-btn"
style={{
background: themeWiseColor(colors.white, colors.darkGray, themeMode),
boxShadow: 'none',
padding: 0,
zIndex: 120,
transform: 'translateX(50%)',
}}
onClick={() => handleCollapseToggler()}
icon={
isCollapsed ? (
<RightCircleOutlined
style={{
fontSize: 22,
color: themeWiseColor('#c5c5c5', colors.lightGray, themeMode),
}}
/>
) : (
<LeftCircleOutlined
style={{
fontSize: 22,
color: themeWiseColor('#c5c5c5', colors.lightGray, themeMode),
}}
/>
)
}
/>
</Flex>
);
};
export default ReportingCollapsedButton;

View File

@@ -0,0 +1,67 @@
import { ConfigProvider, Flex, Menu, MenuProps } from 'antd';
import { Link, useLocation } from 'react-router-dom';
import { colors } from '@/styles/colors';
import { useTranslation } from 'react-i18next';
import { reportingsItems } from '@/lib/reporting/reporting-constants';
import { useMemo } from 'react';
const ReportingSider = () => {
const location = useLocation();
const { t } = useTranslation('reporting-sidebar');
// Memoize the menu items since they only depend on translations
const menuItems = useMemo(
() =>
reportingsItems.map(item => {
if (item.children) {
return {
key: item.key,
label: t(`${item.name}`),
children: item.children.map(child => ({
key: child.key,
label: <Link to={`/worklenz/reporting/${child.endpoint}`}>{t(`${child.name}`)}</Link>,
})),
};
}
return {
key: item.key,
label: <Link to={`/worklenz/reporting/${item.endpoint}`}>{t(`${item.name}`)}</Link>,
};
}),
[t]
); // Only recompute when translations change
// Memoize the active key calculation
const activeKey = useMemo(() => {
const afterWorklenzString = location.pathname?.split('/worklenz/reporting/')[1];
return afterWorklenzString?.split('/')[0];
}, [location.pathname]);
return (
<ConfigProvider
theme={{
components: {
Menu: {
itemHoverBg: colors.transparent,
itemHoverColor: colors.skyBlue,
borderRadius: 12,
itemMarginBlock: 4,
subMenuItemBg: colors.transparent,
},
},
}}
>
<Flex gap={24} vertical>
<Menu
className="custom-reporting-sider"
items={menuItems}
selectedKeys={[activeKey]}
mode="inline"
style={{ width: '100%' }}
/>
</Flex>
</ConfigProvider>
);
};
export default ReportingSider;

View File

@@ -0,0 +1,301 @@
import React, { useEffect, useState, useRef, forwardRef, useImperativeHandle } from 'react';
import { Bar } from 'react-chartjs-2';
import {
BarElement,
CategoryScale,
Chart as ChartJS,
Legend,
LinearScale,
Title,
Tooltip,
ChartData,
} from 'chart.js';
import ChartDataLabels from 'chartjs-plugin-datalabels';
import { IRPTTimeProject } from '@/types/reporting/reporting.types';
import { useAppSelector } from '@/hooks/useAppSelector';
import logger from '@/utils/errorLogger';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { reportingTimesheetApiService } from '@/api/reporting/reporting.timesheet.api.service';
// Project color palette
const PROJECT_COLORS = [
'#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'
}
export interface EstimatedVsActualTimeSheetRef {
exportChart: () => void;
}
interface IEstimatedVsActualTimeSheetProps {
type: string;
}
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,
categories,
loadingCategories,
noCategory,
projects: filterProjects,
loadingProjects,
billable,
archived,
} = useAppSelector(state => state.timeReportsOverviewReducer);
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();
}) : [];
// Format date helper
const formatDate = (date: Date): string => {
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
};
// Get project color
const getProjectColor = (index: number): string => {
return PROJECT_COLORS[index % PROJECT_COLORS.length];
};
// Chart data with colors
const data = {
labels,
datasets: [
{
label: 'Estimated Days',
data: estimatedDays,
backgroundColor: jsonData.map((_, index) => getProjectColor(index) + '80'), // 80 for opacity
barThickness: 50,
},
{
label: 'Actual Days',
data: actualDays,
backgroundColor: jsonData.map((_, index) => getProjectColor(index)),
barThickness: 50,
},
],
};
// Chart options
const options = {
maintainAspectRatio: false,
plugins: {
tooltip: {
callbacks: {
footer: (items: any[]) => {
if (items.length > 0) {
const project = jsonData[items[0].dataIndex];
if (project.end_date) {
const endDate = new Date(project.end_date);
return 'Ends On: ' + formatDate(endDate);
}
}
return '';
},
}
},
datalabels: {
color: 'white',
anchor: 'start' as const,
align: 'start' as const,
offset: -30,
borderColor: '#000',
textStrokeColor: 'black',
textStrokeWidth: 4,
},
legend: {
display: false,
},
},
scales: {
x: {
type: 'category' as const,
title: {
display: true,
text: 'Project',
align: 'end' as const,
font: {
family: 'Helvetica',
},
},
ticks: {
maxRotation: 45,
minRotation: 45,
padding: 5,
font: {
size: 11,
},
},
},
y: {
type: 'linear' as const,
title: {
display: true,
text: 'Days',
align: 'end' as const,
font: {
family: 'Helvetica',
},
},
},
},
};
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);
const body = {
type: type === 'WORKING_DAYS' ? 'WORKING_DAYS' : 'MAN_DAYS',
teams: selectedTeams.map(t => t.id),
categories: selectedCategories.map(c => c.id),
selectNoCategory: noCategory,
projects: selectedProjects.map(p => p.id),
duration: duration,
date_range: dateRange,
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;
const virtualWidth = dataArray.length * 120;
setChartWidth(virtualWidth > containerWidth ? virtualWidth : window.innerWidth - 250);
}
}
} catch (error) {
logger.error('Error fetching chart data:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
setChartHeight(window.innerHeight - 300);
fetchChartData();
}, [
teams,
categories,
filterProjects,
duration,
dateRange,
billable,
archived,
type,
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');
if (!tempCtx) {
console.error('Failed to get canvas context');
return;
}
// Set dimensions
tempCanvas.width = canvas.width;
tempCanvas.height = canvas.height;
// Fill background based on theme
tempCtx.fillStyle = themeMode === 'dark' ? '#1f1f1f' : '#ffffff';
tempCtx.fillRect(0, 0, tempCanvas.width, tempCanvas.height);
// Draw the original chart on top
tempCtx.drawImage(canvas, 0, 0);
// Create download link
const link = document.createElement('a');
link.download = 'estimated-vs-actual-time-sheet.png';
link.href = tempCanvas.toDataURL('image/png');
link.click();
} else {
console.error('Chart ref is null');
}
};
useImperativeHandle(ref, () => ({
exportChart
}));
return (
<div style={{ position: 'relative' }}>
{/* Outer container with fixed width */}
<div style={{
width: '100%',
position: 'relative',
overflowX: 'auto',
overflowY: 'hidden'
}}>
{/* Chart container */}
<div
style={{
width: `${chartWidth}px`,
height: `${chartHeight}px`,
minWidth: 'max-content',
}}
>
<Bar
ref={chartRef}
data={data}
options={options}
width={chartWidth}
height={chartHeight}
/>
</div>
</div>
</div>
);
});
EstimatedVsActualTimeSheet.displayName = 'EstimatedVsActualTimeSheet';
export default EstimatedVsActualTimeSheet;

View File

@@ -0,0 +1,203 @@
import React, { useEffect, useState, forwardRef, useImperativeHandle } from 'react';
import { Bar } from 'react-chartjs-2';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend,
} from 'chart.js';
import ChartDataLabels from 'chartjs-plugin-datalabels';
import { useAppSelector } from '../../../../hooks/useAppSelector';
import { useTranslation } from 'react-i18next';
import { reportingTimesheetApiService } from '@/api/reporting/reporting.timesheet.api.service';
import { IRPTTimeMember } from '@/types/reporting/reporting.types';
import logger from '@/utils/errorLogger';
import { useAppDispatch } from '@/hooks/useAppDispatch';
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend, ChartDataLabels);
export interface MembersTimeSheetRef {
exportChart: () => void;
}
const MembersTimeSheet = forwardRef<MembersTimeSheetRef>((_, ref) => {
const { t } = useTranslation('time-report');
const dispatch = useAppDispatch();
const chartRef = React.useRef<ChartJS<'bar', string[], unknown>>(null);
const {
teams,
loadingTeams,
categories,
loadingCategories,
projects: filterProjects,
loadingProjects,
billable,
archived,
} = useAppSelector(state => state.timeReportsOverviewReducer);
const { duration, dateRange } = useAppSelector(state => state.reportingReducer);
const [loading, setLoading] = useState(false);
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 colors = Array.isArray(jsonData) ? jsonData.map(item => item.color_code) : [];
const themeMode = useAppSelector(state => state.themeReducer.mode);
// Chart data
const data = {
labels,
datasets: [
{
label: t('loggedTime'),
data: dataValues,
backgroundColor: colors,
barThickness: 40,
},
],
};
// Chart options
const options = {
maintainAspectRatio: false,
plugins: {
datalabels: {
color: 'white',
anchor: 'start' as const,
align: 'right' as const,
offset: 20,
textStrokeColor: 'black',
textStrokeWidth: 4,
},
legend: {
display: false,
position: 'top' as const,
},
},
backgroundColor: 'black',
indexAxis: 'y' as const,
responsive: true,
scales: {
x: {
title: {
display: true,
text: t('loggedTime'),
align: 'end' as const,
font: {
family: 'Helvetica',
},
},
grid: {
color: themeMode === 'dark' ? '#2c2f38' : '#e5e5e5',
lineWidth: 1,
},
},
y: {
title: {
display: true,
text: t('member'),
align: 'end' as const,
font: {
family: 'Helvetica',
},
},
grid: {
color: themeMode === 'dark' ? '#2c2f38' : '#e5e5e5',
lineWidth: 1,
},
},
},
};
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);
const body = {
teams: selectedTeams.map(t => t.id),
projects: selectedProjects.map(project => project.id),
categories: selectedCategories.map(category => category.id),
duration,
date_range: dateRange,
billable,
};
const res = await reportingTimesheetApiService.getMemberTimeSheets(body, archived);
if (res.done) {
setJsonData(res.body || []);
}
} catch (error) {
logger.error('Error fetching chart data:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchChartData();
}, [dispatch, duration, dateRange, billable, archived, teams, filterProjects, categories]);
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');
if (!tempCtx) return;
// Set dimensions
tempCanvas.width = canvas.width;
tempCanvas.height = canvas.height;
// Fill background based on theme
tempCtx.fillStyle = themeMode === 'dark' ? '#1f1f1f' : '#ffffff';
tempCtx.fillRect(0, 0, tempCanvas.width, tempCanvas.height);
// Draw the original chart on top
tempCtx.drawImage(canvas, 0, 0);
// Create download link
const link = document.createElement('a');
link.download = 'members-time-sheet.png';
link.href = tempCanvas.toDataURL('image/png');
link.click();
}
};
useImperativeHandle(ref, () => ({
exportChart
}));
return (
<div style={{ position: 'relative' }}>
<div
style={{
maxWidth: 'calc(100vw - 220px)',
minWidth: 'calc(100vw - 260px)',
minHeight: 'calc(100vh - 300px)',
height: `${60 * data.labels.length}px`,
}}
>
<Bar data={data} options={options} ref={chartRef} />
</div>
</div>
);
});
MembersTimeSheet.displayName = 'MembersTimeSheet';
export default MembersTimeSheet;

View File

@@ -0,0 +1,240 @@
import React, { useEffect, useState, forwardRef, useImperativeHandle } from 'react';
import { Bar } from 'react-chartjs-2';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend,
} from 'chart.js';
import ChartDataLabels from 'chartjs-plugin-datalabels';
import { useAppDispatch } from '../../../../hooks/useAppDispatch';
import {
setLabelAndToggleDrawer,
} from '../../../../features/timeReport/projects/timeLogSlice';
import ProjectTimeLogDrawer from '../../../../features/timeReport/projects/ProjectTimeLogDrawer';
import { useAppSelector } from '../../../../hooks/useAppSelector';
import { useTranslation } from 'react-i18next';
import { reportingTimesheetApiService } from '@/api/reporting/reporting.timesheet.api.service';
import { IRPTTimeProject } from '@/types/reporting/reporting.types';
import { Empty, Spin } from 'antd';
import logger from '@/utils/errorLogger';
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend, ChartDataLabels);
const BAR_THICKNESS = 40;
const STROKE_WIDTH = 4;
const MIN_HEIGHT = 'calc(100vh - 300px)';
const SIDEBAR_WIDTH = 220;
export interface ProjectTimeSheetChartRef {
exportChart: () => void;
}
const ProjectTimeSheetChart = forwardRef<ProjectTimeSheetChartRef>((_, ref) => {
const dispatch = useAppDispatch();
const { t } = useTranslation('time-report');
const [jsonData, setJsonData] = useState<IRPTTimeProject[]>([]);
const [loading, setLoading] = useState(false);
const chartRef = React.useRef<ChartJS<'bar', string[], unknown>>(null);
const themeMode = useAppSelector(state => state.themeReducer.mode);
const {
teams,
loadingTeams,
categories,
loadingCategories,
projects: filterProjects,
loadingProjects,
billable,
archived,
} = useAppSelector(state => state.timeReportsOverviewReducer);
const { duration, dateRange } = useAppSelector(state => state.reportingReducer);
const handleBarClick = (event: any, elements: any) => {
if (elements.length > 0) {
const elementIndex = elements[0].index;
const label = jsonData[elementIndex];
if (label) {
dispatch(setLabelAndToggleDrawer(label));
}
}
};
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,
}],
};
const options = {
maintainAspectRatio: false,
plugins: {
datalabels: {
color: 'white',
anchor: 'start' as const,
align: 'right' as const,
offset: 20,
textStrokeColor: 'black',
textStrokeWidth: STROKE_WIDTH,
},
legend: {
display: false,
position: 'top' as const,
},
},
backgroundColor: 'black',
indexAxis: 'y' as const,
responsive: true,
scales: {
x: {
title: {
display: true,
text: t('loggedTime'),
align: 'end' as const,
font: {
family: 'Helvetica',
},
},
grid: {
color: themeMode === 'dark' ? '#2c2f38' : '#e5e5e5',
lineWidth: 1,
},
},
y: {
title: {
display: true,
text: t('projects'),
align: 'end' as const,
font: {
family: 'Helvetica',
},
},
grid: {
color: themeMode === 'dark' ? '#2c2f38' : '#e5e5e5',
lineWidth: 1,
},
},
},
// onClick: handleBarClick,
};
const fetchChartData = async () => {
try {
const selectedTeams = teams.filter(team => team.selected);
const selectedProjects = filterProjects.filter(project => project.selected);
const selectedCategories = categories.filter(category => category.selected);
const body = {
teams: selectedTeams.map(t => t.id),
projects: selectedProjects.map(project => project.id),
categories: selectedCategories.map(category => category.id),
duration,
date_range: dateRange,
billable,
};
const res = await reportingTimesheetApiService.getProjectTimeSheets(body, archived);
if (res.done) {
setJsonData(res.body || []);
}
} catch (error) {
logger.error('Error fetching chart data:', error);
}
};
useEffect(() => {
if (!loadingTeams && !loadingProjects && !loadingCategories) {
setLoading(true);
fetchChartData().finally(() => {
setLoading(false);
});
}
}, [
teams,
filterProjects,
categories,
duration,
dateRange,
billable,
archived,
loadingTeams,
loadingProjects,
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');
if (!tempCtx) return;
// Set dimensions
tempCanvas.width = canvas.width;
tempCanvas.height = canvas.height;
// Fill background based on theme
tempCtx.fillStyle = themeMode === 'dark' ? '#1f1f1f' : '#ffffff';
tempCtx.fillRect(0, 0, tempCanvas.width, tempCanvas.height);
// Draw the original chart on top
tempCtx.drawImage(canvas, 0, 0);
// Create download link
const link = document.createElement('a');
link.download = 'project-time-sheet.png';
link.href = tempCanvas.toDataURL('image/png');
link.click();
}
};
useImperativeHandle(ref, () => ({
exportChart
}));
// if (loading) {
// return (
// <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
// <Spin />
// </div>
// );
// }
return (
<div>
<div
style={{
maxWidth: `calc(100vw - ${SIDEBAR_WIDTH}px)`,
minWidth: 'calc(100vw - 260px)',
minHeight: MIN_HEIGHT,
height: `${60 * data.labels.length}px`,
}}
>
<Bar
data={data}
options={options}
ref={chartRef}
/>
</div>
<ProjectTimeLogDrawer />
</div>
);
});
ProjectTimeSheetChart.displayName = 'ProjectTimeSheetChart';
export default ProjectTimeSheetChart;

View File

@@ -0,0 +1,110 @@
.project-name {
width: 200px;
position: sticky;
left: 0;
padding: 16px 8px 16px 12px;
z-index: 1;
background-color: var(--background-color, #fff);
border-right: 1px solid var(--border-color, rgba(0, 0, 0, 0.06));
border-bottom: 1px solid var(--border-color, rgba(0, 0, 0, 0.06));
}
.member-time,
.member-name,
.member-total-time {
width: 100px;
padding: 16px 6px 16px 6px;
border-right: 1px solid var(--border-color, rgba(0, 0, 0, 0.06));
border-bottom: 1px solid var(--border-color, rgba(0, 0, 0, 0.06));
}
.member-time {
font-size: 13px;
display: flex;
align-items: center;
justify-content: center;
}
.member-time.numeric {
justify-content: flex-end;
padding-right: 16px;
}
.member-name {
text-overflow: ellipsis;
overflow: hidden;
}
.header-row,
.table-row_ {
width: fit-content;
}
.header-row {
position: sticky;
top: 0;
z-index: 2;
background-color: var(--background-color, #fff);
border-bottom: 2px solid var(--border-color, rgba(0, 0, 0, 0.06));
.project-name {
border-left: none !important;
}
.member-name, .total-time {
border-bottom: 2px solid var(--border-color, rgba(0, 0, 0, 0.06));
}
}
.total-time {
width: 120px;
position: sticky;
right: 0;
text-align: right;
font-weight: 500;
padding: 16px 16px 16px 12px;
border-bottom: 1px solid var(--border-color, rgba(0, 0, 0, 0.06));
border-left: 1px solid var(--border-color, rgba(0, 0, 0, 0.06));
display: flex;
align-items: center;
justify-content: flex-end;
background-color: var(--background-color, #fff);
z-index: 1;
}
.bg-bold {
font-weight: 500;
}
.f-500 {
font-weight: 500;
}
.member-project-table {
overflow: auto;
max-height: calc(100vh - 250px);
}
.badge-custom {
margin-top: -3px !important;
margin-right: 4px;
}
.no-data-img-holder {
width: 100px;
margin-top: 42px;
}
.bottom-row {
position: sticky;
bottom: 0;
background-color: var(--background-color, #fff);
z-index: 1;
border-top: 2px solid var(--border-color, rgba(0, 0, 0, 0.06));
}
.member-total-time.numeric {
justify-content: flex-end;
padding-right: 16px;
text-align: right;
}

View File

@@ -0,0 +1,154 @@
import React, { useEffect, useState } from 'react';
import { MemberLoggedTimeType } from '@/types/timeSheet/project.types';
import { Empty, Progress, Spin } from 'antd';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useTranslation } from 'react-i18next';
import { reportingTimesheetApiService } from '@/api/reporting/reporting.timesheet.api.service';
import { IAllocationProject } from '@/types/reporting/reporting-allocation.types';
import './time-sheet-table.css';
const TimeSheetTable: React.FC = () => {
const [projects, setProjects] = useState<IAllocationProject[]>([]);
const [members, setMembers] = useState<MemberLoggedTimeType[]>([]);
const [loading, setLoading] = useState(false);
const themeMode = useAppSelector(state => state.themeReducer.mode);
const {
teams,
loadingTeams,
categories,
loadingCategories,
projects: filterProjects,
loadingProjects,
billable,
archived,
} = useAppSelector(state => state.timeReportsOverviewReducer);
const { duration, dateRange } = useAppSelector(state => state.reportingReducer);
const { t } = useTranslation('time-report');
const isNumeric = (value: string | undefined): boolean => {
if (!value) return false;
// Check if the value is a number or a time format (e.g., "1h 30m")
return /^[0-9]+([,.][0-9]+)?$|^[0-9]+h( [0-9]+m)?$|^[0-9]+m$/.test(value.trim());
};
const fetchTimeSheetData = 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);
const body = {
teams: selectedTeams.map(t => t.id) as string[],
projects: selectedProjects.map(project => project.id) || [],
categories: selectedCategories.map(category => category.id) || [],
duration,
date_range: dateRange,
archived,
billable,
};
const response = await reportingTimesheetApiService.getTimeSheetData(body, archived);
if (response.done) {
setProjects(response.body.projects);
setMembers(response.body.users);
}
} catch (error) {
console.error('Error fetching time sheet data:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (!loadingTeams && !loadingCategories && !loadingProjects) fetchTimeSheetData();
}, [teams, duration, dateRange, filterProjects, categories, billable, archived]);
// Set theme variables
useEffect(() => {
const root = document.documentElement;
if (themeMode === 'dark') {
root.style.setProperty('--border-color', 'rgba(255, 255, 255, 0.1)');
root.style.setProperty('--background-color', '#141414');
} else {
root.style.setProperty('--border-color', 'rgba(0, 0, 0, 0.06)');
root.style.setProperty('--background-color', '#fff');
}
}, [themeMode]);
return (
<Spin spinning={loading} tip="Loading...">
<div
style={{
overflow: 'auto',
width: 'max-content',
maxWidth: 'calc(100vw - 225px)',
}}
>
{members.length == 0 && projects.length == 0 && (
<div className="no-data">
<Empty description="No data" />
</div>
)}
{/* Columns */}
{members && members.length > 0 ? (
<div className="header-row d-flex">
<div className="project-name"></div>
{members.map(item => (
<div key={item.id} className="member-name f-500">
{item.name}
</div>
))}
<div className="total-time text-center">Total</div>
</div>
) : null}
{/* Rows */}
{projects.length > 0 ? (
<>
{projects.map((item, index) => (
<div key={index} className="table-row_ d-flex">
<div className="project-name">
<span className="anticon" style={{ color: item.status_color_code }}>
<i className={item.status_icon}></i>
</span>
<span className="ms-1">{item.name}</span>
<div className="d-block">
<Progress percent={item.progress} strokeColor={item.color_code} size="small" />
</div>
</div>
{item.time_logs?.map((log, index) => (
<div
key={index}
className={`member-time ${isNumeric(log.time_logged) ? 'numeric' : ''}`}
>
{log.time_logged}
</div>
))}
<div className="total-time">{item.total}</div>
</div>
))}
{/* total row */}
{members.length > 0 && (
<div className="table-row_ d-flex bottom-row">
<div className="project-name bg-bold">Total</div>
{members.map(item => (
<div
key={item.id}
className={`member-total-time bg-bold ${isNumeric(item.total_time) ? 'numeric' : ''}`}
>
{item.total_time}
</div>
))}
<div className="total-time"></div>
</div>
)}
</>
) : null}
</div>
</Spin>
);
};
export default TimeSheetTable;

View File

@@ -0,0 +1,70 @@
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 { useTranslation } from 'react-i18next';
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
import TimeReportingRightHeader from './timeReportingRightHeader/TimeReportingRightHeader';
import { useState, useRef } from 'react';
const EstimatedVsActualTimeReports = () => {
const { t } = useTranslation('time-report');
const [type, setType] = useState('WORKING_DAYS');
const chartRef = useRef<EstimatedVsActualTimeSheetRef>(null);
useDocumentTitle('Reporting - Allocation');
const handleExport = (type: string) => {
if (type === 'png') {
chartRef.current?.exportChart();
}
};
return (
<Flex vertical>
<TimeReportingRightHeader
title={t('estimatedVsActual')}
exportType={[
{ key: 'png', label: 'PNG' },
]}
export={handleExport}
/>
<Card
style={{ borderRadius: '4px' }}
title={
<div
style={{
padding: '16px 0',
display: 'flex',
justifyContent: 'space-between',
}}
>
<TimeReportPageHeader />
<Segmented
style={{ fontWeight: 500 }}
options={[{
label: t('workingDays'),
value: 'WORKING_DAYS',
}, {
label: t('manDays'),
value: 'MAN_DAYS',
}]}
onChange={value => setType(value)}
/>
</div>
}
styles={{
body: {
maxWidth: 'calc(100vw - 220px)',
overflowX: 'auto',
padding: '16px',
},
}}
>
<EstimatedVsActualTimeSheet type={type} ref={chartRef} />
</Card>
</Flex>
);
};
export default EstimatedVsActualTimeReports;

View File

@@ -0,0 +1,50 @@
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 TimeReportingRightHeader from './timeReportingRightHeader/TimeReportingRightHeader';
import { useTranslation } from 'react-i18next';
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
import { useRef } from 'react';
const MembersTimeReports = () => {
const { t } = useTranslation('time-report');
const chartRef = useRef<MembersTimeSheetRef>(null);
useDocumentTitle('Reporting - Allocation');
const handleExport = (type: string) => {
if (type === 'png') {
chartRef.current?.exportChart();
}
};
return (
<Flex vertical>
<TimeReportingRightHeader
title={t('Members Time Sheet')}
exportType={[{ key: 'png', label: 'PNG' }]}
export={handleExport}
/>
<Card
style={{ borderRadius: '4px' }}
title={
<div style={{ padding: '16px 0' }}>
<TimeReportPageHeader />
</div>
}
styles={{
body: {
maxHeight: 'calc(100vh - 300px)',
overflowY: 'auto',
padding: '16px',
},
}}
>
<MembersTimeSheet ref={chartRef} />
</Card>
</Flex>
);
};
export default MembersTimeReports;

View File

@@ -0,0 +1,67 @@
import React from 'react';
import TimeReportPageHeader from '@/pages/reporting/timeReports/page-header/time-report-page-header';
import { Flex } from 'antd';
import TimeSheetTable from '@/pages/reporting/time-reports/time-sheet-table/time-sheet-table';
import TimeReportingRightHeader from './timeReportingRightHeader/TimeReportingRightHeader';
import { useTranslation } from 'react-i18next';
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
import { useAppSelector } from '@/hooks/useAppSelector';
import { reportingExportApiService } from '@/api/reporting/reporting-export.api.service';
import logger from '@/utils/errorLogger';
const OverviewTimeReports: React.FC = () => {
const { t } = useTranslation('time-report');
const {
teams,
loadingTeams,
categories,
loadingCategories,
projects: filterProjects,
loadingProjects,
billable,
archived,
} = useAppSelector(state => state.timeReportsOverviewReducer);
const { duration, dateRange } = useAppSelector(state => state.reportingReducer);
useDocumentTitle('Reporting - Allocation');
const exportFn = async () => {
try {
const selectedTeams = teams.filter(team => team.selected);
const selectedProjects = filterProjects.filter(project => project.selected);
const selectedCategories = categories.filter(category => category.selected);
await reportingExportApiService.exportAllocation(
archived,
selectedTeams.map(t => t.id) as string[],
selectedProjects.map(project => project.id) as string[],
duration,
dateRange,
billable.billable,
billable.nonBillable
);
} catch (e) {
logger.error('Error exporting allocation', e);
}
};
return (
<Flex vertical>
<TimeReportingRightHeader
title={t('timeSheet')}
exportType={[{ key: 'excel', label: 'Excel' }]}
export={exportFn}
/>
<div>
<TimeReportPageHeader />
</div>
<div style={{ marginTop: '1rem' }}>
<TimeSheetTable />
</div>
</Flex>
);
};
export default OverviewTimeReports;

View File

@@ -0,0 +1,49 @@
import { setSelectOrDeselectBillable } from '@/features/reporting/time-reports/time-reports-overview.slice';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useAppSelector } from '@/hooks/useAppSelector';
import { CaretDownFilled } from '@ant-design/icons';
import { Button, Checkbox, Dropdown, MenuProps } from 'antd';
import React from 'react';
import { useTranslation } from 'react-i18next';
const Billable: React.FC = () => {
const { t } = useTranslation('time-report');
const dispatch = useAppDispatch();
const { billable } = useAppSelector(state => state.timeReportsOverviewReducer);
// Dropdown items for the menu
const menuItems: MenuProps['items'] = [
{
key: 'search',
label: <Checkbox checked={billable.billable}>{t('billable')}</Checkbox>,
onClick: () => {
dispatch(setSelectOrDeselectBillable({ ...billable, billable: !billable.billable }));
},
},
{
key: 'selectAll',
label: <Checkbox checked={billable.nonBillable}>{t('nonBillable')}</Checkbox>,
onClick: () => {
dispatch(setSelectOrDeselectBillable({ ...billable, nonBillable: !billable.nonBillable }));
},
},
];
return (
<div>
<Dropdown
menu={{ items: menuItems }}
placement="bottomLeft"
trigger={['click']}
overlayStyle={{ maxHeight: '330px', overflowY: 'auto' }}
>
<Button>
{t('billable')} <CaretDownFilled />
</Button>
</Dropdown>
</div>
);
};
export default Billable;

View File

@@ -0,0 +1,137 @@
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';
import { Button, Card, Checkbox, Divider, Dropdown, Input, theme } from 'antd';
import { CheckboxChangeEvent } from 'antd/es/checkbox';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
const Categories: React.FC = () => {
const dispatch = useAppDispatch();
const [searchText, setSearchText] = useState('');
const [selectAll, setSelectAll] = useState(true);
const { t } = useTranslation('time-report');
const [dropdownVisible, setDropdownVisible] = useState(false);
const { categories, loadingCategories, noCategory } = useAppSelector(
state => state.timeReportsOverviewReducer
);
const { token } = theme.useToken();
const filteredItems = categories.filter(item =>
item.name?.toLowerCase().includes(searchText.toLowerCase())
);
// Handle checkbox change for individual items
const handleCheckboxChange = async (key: string, checked: boolean) => {
await dispatch(setSelectOrDeselectCategory({ id: key, selected: checked }));
await dispatch(fetchReportingProjects());
};
// Handle "Select All" checkbox change
const handleSelectAllChange = async (e: CheckboxChangeEvent) => {
const isChecked = e.target.checked;
setSelectAll(isChecked);
await dispatch(setNoCategory(isChecked));
await dispatch(setSelectOrDeselectAllCategories(isChecked));
await dispatch(fetchReportingProjects());
};
const handleNoCategoryChange = async (checked: boolean) => {
await dispatch(setNoCategory(checked));
await dispatch(fetchReportingProjects());
};
return (
<div>
<Dropdown
menu={undefined}
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={{ padding: '8px', flexShrink: 0 }}>
<Input
onClick={e => e.stopPropagation()}
placeholder={t('searchByCategory')}
value={searchText}
onChange={e => setSearchText(e.target.value)}
/>
</div>
{categories.length > 0 && (
<div style={{ padding: '0 12px', flexShrink: 0 }}>
<Checkbox
onClick={e => e.stopPropagation()}
onChange={handleSelectAllChange}
checked={selectAll}
>
{t('selectAll')}
</Checkbox>
</div>
)}
<div style={{ padding: '8px 12px 4px 12px', flexShrink: 0 }}>
<Checkbox
onClick={e => e.stopPropagation()}
checked={noCategory}
onChange={e => handleNoCategoryChange(e.target.checked)}
>
{t('noCategory')}
</Checkbox>
</div>
<Divider style={{ margin: '4px 0', flexShrink: 0 }} />
<div style={{
overflowY: 'auto',
flex: 1
}}>
{filteredItems.length > 0 ? (
filteredItems.map(item => (
<div
key={item.id}
style={{
padding: '8px 12px',
cursor: 'pointer'
}}
>
<Checkbox
onClick={e => e.stopPropagation()}
checked={item.selected}
onChange={e => handleCheckboxChange(item.id || '', e.target.checked)}
>
{item.name}
</Checkbox>
</div>
))
) : (
<div style={{ padding: '8px 12px' }}>
{t('noCategories')}
</div>
)}
</div>
</div>
)}
onOpenChange={visible => {
setDropdownVisible(visible);
if (!visible) {
setSearchText('');
}
}}
>
<Button loading={loadingCategories}>
{t('categories')} <CaretDownFilled />
</Button>
</Dropdown>
</div>
);
};
export default Categories;

View File

@@ -0,0 +1,113 @@
import { setSelectOrDeselectAllProjects, setSelectOrDeselectProject } from '@/features/reporting/time-reports/time-reports-overview.slice';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useAppSelector } from '@/hooks/useAppSelector';
import { CaretDownFilled } from '@ant-design/icons';
import { Button, Checkbox, Divider, Dropdown, Input, theme } from 'antd';
import { CheckboxChangeEvent } from 'antd/es/checkbox';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
const Projects: React.FC = () => {
const dispatch = useAppDispatch();
const [checkedList, setCheckedList] = useState<string[]>([]);
const [searchText, setSearchText] = useState('');
const [selectAll, setSelectAll] = useState(true);
const { t } = useTranslation('time-report');
const [dropdownVisible, setDropdownVisible] = useState(false);
const { projects, loadingProjects } = useAppSelector(state => state.timeReportsOverviewReducer);
const { token } = theme.useToken();
// Filter items based on search text
const filteredItems = projects.filter(item =>
item.name?.toLowerCase().includes(searchText.toLowerCase())
);
// Handle checkbox change for individual items
const handleCheckboxChange = (key: string, checked: boolean) => {
dispatch(setSelectOrDeselectProject({ id: key, selected: checked }));
};
// Handle "Select All" checkbox change
const handleSelectAllChange = (e: CheckboxChangeEvent) => {
const isChecked = e.target.checked;
setSelectAll(isChecked);
dispatch(setSelectOrDeselectAllProjects(isChecked));
};
return (
<div>
<Dropdown
menu={undefined}
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={{ padding: '8px', flexShrink: 0 }}>
<Input
onClick={e => e.stopPropagation()}
placeholder={t('searchByProject')}
value={searchText}
onChange={e => setSearchText(e.target.value)}
/>
</div>
<div style={{ padding: '0 12px', flexShrink: 0 }}>
<Checkbox
onClick={e => e.stopPropagation()}
onChange={handleSelectAllChange}
checked={selectAll}
>
{t('selectAll')}
</Checkbox>
</div>
<Divider style={{ margin: '4px 0', flexShrink: 0 }} />
<div style={{
overflowY: 'auto',
flex: 1
}}>
{filteredItems.map(item => (
<div
key={item.id}
style={{
padding: '8px 12px',
cursor: 'pointer',
'&:hover': {
backgroundColor: token.colorBgTextHover
}
}}
>
<Checkbox
onClick={e => e.stopPropagation()}
checked={item.selected}
onChange={e => handleCheckboxChange(item.id || '', e.target.checked)}
>
{item.name}
</Checkbox>
</div>
))}
</div>
</div>
)}
onOpenChange={visible => {
setDropdownVisible(visible);
if (!visible) {
setSearchText('');
}
}}
>
<Button loading={loadingProjects}>
{t('projects')} <CaretDownFilled />
</Button>
</Dropdown>
</div>
);
};
export default Projects;

View File

@@ -0,0 +1,115 @@
import { CaretDownFilled } from '@ant-design/icons';
import { Button, Checkbox, Divider, Dropdown, Input, theme } from 'antd';
import React, { useEffect, useState } from 'react';
import type { CheckboxChangeEvent } from 'antd/es/checkbox';
import { useTranslation } from 'react-i18next';
import { ISelectableTeam } from '@/types/reporting/reporting-filters.types';
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';
const Team: React.FC = () => {
const dispatch = useAppDispatch();
const [checkedList, setCheckedList] = useState<string[]>([]);
const [searchText, setSearchText] = useState('');
const [selectAll, setSelectAll] = useState(true);
const { t } = useTranslation('time-report');
const [dropdownVisible, setDropdownVisible] = useState(false);
const { token } = theme.useToken();
const { teams, loadingTeams } = useAppSelector(state => state.timeReportsOverviewReducer);
const filteredItems = teams.filter(item =>
item.name?.toLowerCase().includes(searchText.toLowerCase())
);
const handleCheckboxChange = async (key: string, checked: boolean) => {
dispatch(setSelectOrDeselectTeam({ id: key, selected: checked }));
await dispatch(fetchReportingCategories());
await dispatch(fetchReportingProjects());
};
const handleSelectAllChange = async (e: CheckboxChangeEvent) => {
const isChecked = e.target.checked;
setSelectAll(isChecked);
dispatch(setSelectOrDeselectAllTeams(isChecked));
await dispatch(fetchReportingCategories());
await dispatch(fetchReportingProjects());
};
return (
<div>
<Dropdown
menu={undefined}
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={{ padding: '8px', flexShrink: 0 }}>
<Input
placeholder={t('searchByName')}
value={searchText}
onChange={e => setSearchText(e.target.value)}
onClick={e => e.stopPropagation()}
/>
</div>
<div style={{ padding: '0 12px', flexShrink: 0 }}>
<Checkbox
onClick={e => e.stopPropagation()}
onChange={handleSelectAllChange}
checked={selectAll}
>
{t('selectAll')}
</Checkbox>
</div>
<Divider style={{ margin: '4px 0', flexShrink: 0 }} />
<div style={{
overflowY: 'auto',
flex: 1
}}>
{filteredItems.map(item => (
<div
key={item.id}
style={{
padding: '8px 12px',
cursor: 'pointer'
}}
>
<Checkbox
onClick={e => e.stopPropagation()}
checked={item.selected}
onChange={e => handleCheckboxChange(item.id || '', e.target.checked)}
>
{item.name}
</Checkbox>
</div>
))}
</div>
</div>
)}
onOpenChange={visible => {
setDropdownVisible(visible);
if (!visible) {
setSearchText('');
}
}}
>
<Button loading={loadingTeams}>
{t('teams')} <CaretDownFilled />
</Button>
</Dropdown>
</div>
);
};
export default Team;

View File

@@ -0,0 +1,36 @@
import React, { useEffect } from 'react';
import Team from './team';
import Categories from './categories';
import Projects from './projects';
import Billable from './billable';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import {
fetchReportingTeams,
fetchReportingProjects,
fetchReportingCategories,
} from '@/features/reporting/time-reports/time-reports-overview.slice';
const TimeReportPageHeader: React.FC = () => {
const dispatch = useAppDispatch();
useEffect(() => {
const fetchData = async () => {
await dispatch(fetchReportingTeams());
await dispatch(fetchReportingCategories());
await dispatch(fetchReportingProjects());
};
fetchData();
}, [dispatch]);
return (
<div style={{ display: 'flex', gap: '8px' }}>
<Team />
<Categories />
<Projects />
<Billable />
</div>
);
};
export default TimeReportPageHeader;

View File

@@ -0,0 +1,52 @@
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 { useTranslation } from 'react-i18next';
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
import TimeReportingRightHeader from './timeReportingRightHeader/TimeReportingRightHeader';
import { useRef } from 'react';
const ProjectsTimeReports = () => {
const { t } = useTranslation('time-report');
const chartRef = useRef<ProjectTimeSheetChartRef>(null);
useDocumentTitle('Reporting - Allocation');
const handleExport = (type: string) => {
if (type === 'png') {
chartRef.current?.exportChart();
}
};
return (
<Flex vertical>
<TimeReportingRightHeader
title={t('projectsTimeSheet')}
exportType={[
{ key: 'png', label: 'PNG' },
]}
export={handleExport}
/>
<Card
style={{ borderRadius: '4px' }}
title={
<div style={{ padding: '16px 0' }}>
<TimeReportPageHeader />
</div>
}
styles={{
body: {
maxHeight: 'calc(100vh - 300px)',
overflowY: 'auto',
padding: '16px',
},
}}
>
<ProjectTimeSheetChart ref={chartRef} />
</Card>
</Flex>
);
};
export default ProjectsTimeReports;

View File

@@ -0,0 +1,50 @@
import React from 'react';
import { Button, Checkbox, Dropdown, Space, Typography } from 'antd';
import { DownOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import CustomPageHeader from '../../page-header/custom-page-header';
import TimeWiseFilter from '../../../../components/reporting/time-wise-filter';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useAppSelector } from '@/hooks/useAppSelector';
import { setArchived } from '@/features/reporting/time-reports/time-reports-overview.slice';
interface headerState {
title: string;
exportType: Array<{ key: string; label: string }>;
export: (key: string) => void;
}
const TimeReportingRightHeader: React.FC<headerState> = ({ title, exportType, export: exportFn }) => {
const { t } = useTranslation('time-report');
const dispatch = useAppDispatch();
const { archived } = useAppSelector(state => state.timeReportsOverviewReducer);
const menuItems = exportType.map(item => ({
key: item.key,
label: item.label,
onClick: () => exportFn(item.key)
}));
return (
<CustomPageHeader
title={title}
children={
<Space>
<Button>
<Checkbox checked={archived} onChange={e => dispatch(setArchived(e.target.checked))}>
<Typography.Text>{t('includeArchivedProjects')}</Typography.Text>
</Checkbox>
</Button>
<TimeWiseFilter />
<Dropdown menu={{ items: menuItems }}>
<Button type="primary" icon={<DownOutlined />} iconPosition="end">
{t('export')}
</Button>
</Dropdown>
</Space>
}
/>
);
};
export default TimeReportingRightHeader;