init
This commit is contained in:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user