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

View File

@@ -0,0 +1,104 @@
import { Card, ConfigProvider, Tag, Timeline, Typography } from 'antd';
import { simpleDateFormat } from '@/utils/simpleDateFormat';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { colors } from '../../../../../styles/colors';
import { useTranslation } from 'react-i18next';
import { fetchTask, setSelectedTaskId, setShowTaskDrawer } from '@/features/task-drawer/task-drawer.slice';
import { ISingleMemberActivityLog, ISingleMemberActivityLogs } from '@/types/reporting/reporting.types';
import { fetchPhasesByProjectId } from '@/features/projects/singleProject/phase/phases.slice';
type TaskStatus = {
name: string;
color_code: string;
};
type ActivityLogCardProps = {
data: ISingleMemberActivityLogs;
};
const ActivityLogCard = ({ data }: ActivityLogCardProps) => {
// localization
const { t } = useTranslation('reporting-members-drawer');
const dispatch = useAppDispatch();
const handleUpdateTaskDrawer = (id: string, projectId: string) => {
if (!id || !projectId) return;
dispatch(setSelectedTaskId(id));
dispatch(fetchPhasesByProjectId(projectId));
dispatch(fetchTask({ taskId: id, projectId: projectId }));
dispatch(setShowTaskDrawer(true));
};
// this function format the attribute type
const formatAttributeType = (attribute: string) =>
attribute.replace('_', ' ').replace(/\b\w/g, char => char.toUpperCase());
// this function render the colord tag
const renderStyledTag = (value: TaskStatus | null) => {
if (!value) return <Tag>None</Tag>;
return (
<Tag style={{ color: colors.darkGray, borderRadius: 48 }} color={value.color_code}>
{value.name}
</Tag>
);
};
// this function render the default normal tag
const renderDefaultTag = (value: string | null) => <Tag>{value || 'None'}</Tag>;
// this function render the tag conditionally if type status, priority or phases then return colord tag else return default tag
const renderTag = (log: ISingleMemberActivityLog, type: 'previous' | 'current') => {
if (!log.attribute_type) return null;
const isStatus = log.attribute_type === 'status';
const isPriority = log.attribute_type === 'priority';
const isPhase = log.attribute_type === 'phase';
if (isStatus) {
return renderStyledTag(type === 'previous' ? log.previous_status : log.next_status);
} else if (isPriority) {
return renderStyledTag(type === 'previous' ? log.previous_priority : log.next_priority);
} else if (isPhase) {
return renderStyledTag(type === 'previous' ? log.previous_phase : log.next_phase);
} else {
return renderDefaultTag(type === 'previous' ? log.previous : log.current);
}
};
return (
<ConfigProvider
theme={{
components: {
Timeline: { itemPaddingBottom: 24, dotBorderWidth: '2px' },
},
}}
>
<Card
title={
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
{simpleDateFormat(data.log_day)}
</Typography.Text>
}
>
<Timeline>
{data.logs.map((log, index) => (
<Timeline.Item key={index}>
<Typography.Text
className="cursor-pointer hover:text-[#1899ff]"
onClick={() => handleUpdateTaskDrawer(log.task_id, log.project_id)}
>
{t('updatedText')} <strong>{formatAttributeType(log.attribute_type)}</strong>{' '}
{t('fromText')} {renderTag(log, 'previous')} {t('toText')}{' '}
{renderTag(log, 'current')} {t('inText')} <strong>{log.task_name}</strong>{' '}
{t('withinText')} <strong>{log.project_name}</strong> <Tag>{log.task_key}</Tag>
</Typography.Text>
</Timeline.Item>
))}
</Timeline>
</Card>
</ConfigProvider>
);
};
export default ActivityLogCard;

View File

@@ -0,0 +1,73 @@
import { Flex, Skeleton } from 'antd';
import React, { useEffect, useMemo, useState } from 'react';
import EmptyListPlaceholder from '@/components/EmptyListPlaceholder';
import ActivityLogCard from './activity-log-card';
import { useTranslation } from 'react-i18next';
import { createPortal } from 'react-dom';
import { ISingleMemberActivityLogs } from '@/types/reporting/reporting.types';
import { reportingApiService } from '@/api/reporting/reporting.api.service';
import { useAuthService } from '@/hooks/useAuth';
import logger from '@/utils/errorLogger';
import { useAppSelector } from '@/hooks/useAppSelector';
const TaskDrawer = React.lazy(() => import('@components/task-drawer/task-drawer'));
type MembersReportsActivityLogsTabProps = {
memberId: string | null;
};
const MembersReportsActivityLogsTab = ({ memberId = null }: MembersReportsActivityLogsTabProps) => {
const { t } = useTranslation('reporting-members-drawer');
const currentSession = useAuthService().getCurrentSession();
const [activityLogsData, setActivityLogsData] = useState<ISingleMemberActivityLogs[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const { duration, dateRange } = useAppSelector(state => state.reportingReducer);
const { archived } = useAppSelector(state => state.membersReportsReducer);
const fetchActivityLogsData = async () => {
if (!memberId || !currentSession?.team_id) return;
try {
setLoading(true);
const body = {
team_member_id: memberId,
team_id: currentSession?.team_id as string,
duration: duration,
date_range: dateRange,
archived: archived,
};
const response = await reportingApiService.getSingleMemberActivities(body);
if (response.done) {
setActivityLogsData(response.body);
}
} catch (error) {
logger.error('fetchActivityLogsData', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchActivityLogsData();
}, [memberId, duration, dateRange, archived]);
return (
<Skeleton active loading={loading} paragraph={{ rows: 10 }}>
{activityLogsData.length > 0 ? (
<Flex vertical gap={24}>
{activityLogsData.map(logs => (
<ActivityLogCard key={logs.log_day} data={logs} />
))}
</Flex>
) : (
<EmptyListPlaceholder text={t('activityLogsEmptyPlaceholder')} />
)}
{/* update task drawer */}
{createPortal(<TaskDrawer />, document.body)}
</Skeleton>
);
};
export default MembersReportsActivityLogsTab;

View File

@@ -0,0 +1,62 @@
import { Tabs } from 'antd';
import { TabsProps } from 'antd/lib';
import React from 'react';
import MembersReportsOverviewTab from './overviewTab/MembersReportsOverviewTab';
import MembersReportsTimeLogsTab from './time-log-tab/members-reports-time-logs-tab';
import MembersReportsActivityLogsTab from './activity-log-tab/members-reports-activity-logs-tab';
import MembersReportsTasksTab from './taskTab/MembersReportsTasksTab';
import { useTranslation } from 'react-i18next';
import { useAppSelector } from '../../../../hooks/useAppSelector';
import { useAppDispatch } from '../../../../hooks/useAppDispatch';
import { setMemberReportingDrawerActiveTab } from '../membersReportsSlice';
type MembersReportsDrawerProps = {
memberId?: string | null;
};
type TabsType = 'overview' | 'timeLogs' | 'activityLogs' | 'tasks';
const MembersReportsDrawerTabs = ({ memberId = null }: MembersReportsDrawerProps) => {
// localization
const { t } = useTranslation('reporting-members-drawer');
const dispatch = useAppDispatch();
// get active tab state from member reporting reducer
const activeTab = useAppSelector(state => state.membersReportsReducer.activeTab);
const tabItems: TabsProps['items'] = [
{
key: 'overview',
label: t('overviewTab'),
children: <MembersReportsOverviewTab memberId={memberId} />,
},
{
key: 'timeLogs',
label: t('timeLogsTab'),
children: <MembersReportsTimeLogsTab memberId={memberId} />,
},
{
key: 'activityLogs',
label: t('activityLogsTab'),
children: <MembersReportsActivityLogsTab memberId={memberId} />,
},
{
key: 'tasks',
label: t('tasksTab'),
children: <MembersReportsTasksTab memberId={memberId} />,
},
];
return (
<Tabs
type="card"
items={tabItems}
activeKey={activeTab}
destroyInactiveTabPane
onTabClick={key => dispatch(setMemberReportingDrawerActiveTab(key as TabsType))}
/>
);
};
export default MembersReportsDrawerTabs;

View File

@@ -0,0 +1,166 @@
import { Drawer, Typography, Flex, Button, Space, Dropdown } from 'antd';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { toggleMembersReportsDrawer } from '../membersReportsSlice';
import { DownOutlined } from '@ant-design/icons';
import MembersReportsDrawerTabs from './members-reports-drawer-tabs';
import { useTranslation } from 'react-i18next';
import MembersOverviewTasksStatsDrawer from './overviewTab/members-overview-tasks-stats-drawer/members-overview-tasks-stats-drawer';
import MembersOverviewProjectsStatsDrawer from './overviewTab/members-overview-projects-stats-drawer/members-overview-projects-stats-drawer';
import TimeWiseFilter from '@/components/reporting/time-wise-filter';
import { useState } from 'react';
import { useAuthService } from '@/hooks/useAuth';
import { reportingExportApiService } from '@/api/reporting/reporting-export.api.service';
import logger from '@/utils/errorLogger';
type MembersReportsDrawerProps = {
memberId: string | null;
};
const MembersReportsDrawer = ({ memberId }: MembersReportsDrawerProps) => {
const { t } = useTranslation('reporting-members-drawer');
const dispatch = useAppDispatch();
const currentSession = useAuthService().getCurrentSession();
const [exporting, setExporting] = useState<boolean>(false);
const isDrawerOpen = useAppSelector(
state => state.membersReportsReducer.isMembersReportsDrawerOpen
);
const { membersList, archived } = useAppSelector(state => state.membersReportsReducer);
const activeTab = useAppSelector(state => state.membersReportsReducer.activeTab);
const { duration, dateRange } = useAppSelector(state => state.reportingReducer);
const selectedMember = membersList?.find(member => member.id === memberId);
const handleClose = () => {
dispatch(toggleMembersReportsDrawer());
};
const exportTimeLogs = () => {
if (!memberId || !currentSession?.team_id) return;
try {
setExporting(true);
const body = {
team_member_id: memberId,
team_id: currentSession?.team_id as string,
duration: duration,
date_range: dateRange,
archived: archived,
member_name: selectedMember?.name,
team_name: currentSession?.team_name,
};
reportingExportApiService.exportMemberTimeLogs(body);
} catch (e) {
logger.error('exportTimeLogs', e);
} finally {
setExporting(false);
}
};
const exportActivityLogs = () => {
if (!memberId || !currentSession?.team_id) return;
try {
setExporting(true);
const body = {
team_member_id: memberId,
team_id: currentSession?.team_id as string,
duration: duration,
date_range: dateRange,
member_name: selectedMember?.name,
team_name: currentSession?.team_name,
archived: archived,
};
reportingExportApiService.exportMemberActivityLogs(body);
} catch (e) {
logger.error('exportActivityLogs', e);
} finally {
setExporting(false);
}
};
const exportTasks = () => {
if (!memberId || !currentSession?.team_id) return;
try {
setExporting(true);
const additionalBody = {
duration: duration,
date_range: dateRange,
only_single_member: true,
archived,
};
reportingExportApiService.exportMemberTasks(
memberId,
selectedMember?.name,
currentSession?.team_name,
additionalBody
);
} catch (e) {
logger.error('exportTasks', e);
} finally {
setExporting(false);
}
};
const handleExport = (key: string) => {
switch (key) {
case '1': // Time Logs
exportTimeLogs();
break;
case '2': // Activity Logs
exportActivityLogs();
break;
case '3': // Tasks
exportTasks();
break;
default:
break;
}
};
return (
<Drawer
open={isDrawerOpen}
onClose={handleClose}
width={900}
destroyOnClose
title={
selectedMember && (
<Flex align="center" justify="space-between">
<Flex gap={8} align="center" style={{ fontWeight: 500 }}>
<Typography.Text>{selectedMember.name}</Typography.Text>
</Flex>
<Space>
<TimeWiseFilter />
<Dropdown
menu={{
items: [
{ key: '1', label: t('timeLogsButton') },
{ key: '2', label: t('activityLogsButton') },
{ key: '3', label: t('tasksButton') },
],
onClick: ({ key }) => handleExport(key),
}}
>
<Button
type="primary"
loading={exporting}
icon={<DownOutlined />}
iconPosition="end"
>
{t('exportButton')}
</Button>
</Dropdown>
</Space>
</Flex>
)
}
>
{selectedMember && <MembersReportsDrawerTabs memberId={selectedMember.id} />}
{selectedMember && <MembersOverviewTasksStatsDrawer memberId={selectedMember.id} />}
{selectedMember && <MembersOverviewProjectsStatsDrawer memberId={selectedMember.id} />}
</Drawer>
);
};
export default MembersReportsDrawer;

View File

@@ -0,0 +1,58 @@
import React, { useEffect } from 'react';
import MembersReportsStatCard from './members-reports-stat-card';
import MembersReportsStatusGraph from './MembersReportsStatusGraph';
import MembersReportsPriorityGraph from './MembersReportsPriorityGraph';
import MembersReportsProjectGraph from './MembersReportsProjectGraph';
import { IRPTOverviewMemberInfo } from '@/types/reporting/reporting.types';
import { reportingApiService } from '@/api/reporting/reporting.api.service';
import logger from '@/utils/errorLogger';
import { useAppSelector } from '@/hooks/useAppSelector';
import { set } from 'date-fns';
type MembersReportsOverviewTabProps = {
memberId: string | null;
};
const MembersReportsOverviewTab = ({ memberId }: MembersReportsOverviewTabProps) => {
const [model, setModel] = React.useState<IRPTOverviewMemberInfo>({});
const [loadingModel, setLoadingModel] = React.useState<boolean>(true);
const { duration, dateRange } = useAppSelector(state => state.reportingReducer);
const { archived } = useAppSelector(state => state.membersReportsReducer);
const fetchStatsModelData = async () => {
if (!memberId || !duration || !dateRange) return;
try {
setLoadingModel(true);
const body = {
teamMemberId: memberId,
duration: duration,
date_range: dateRange,
archived
};
const response = await reportingApiService.getMemberInfo(body);
if (response.done) {
setModel(response.body);
}
} catch (error) {
logger.error('fetchStatsModelData', error);
} finally {
setLoadingModel(false);
}
};
useEffect(() => {
fetchStatsModelData();
}, [memberId, duration, dateRange]);
return (
<div className="grid gap-4 sm:grid-cols-2">
<MembersReportsStatCard statsModel={model.stats} loading={loadingModel} />
<MembersReportsProjectGraph model={model.by_project} loading={loadingModel} />
<MembersReportsStatusGraph model={model.by_status} loading={loadingModel} />
<MembersReportsPriorityGraph model={model.by_priority} loading={loadingModel} />
</div>
);
};
export default MembersReportsOverviewTab;

View File

@@ -0,0 +1,83 @@
import React from 'react';
import { Doughnut } from 'react-chartjs-2';
import { Chart, ArcElement, Tooltip } from 'chart.js';
import { Badge, Card, Flex, Typography } from 'antd';
import { useTranslation } from 'react-i18next';
import { IRPTOverviewMemberChartData } from '@/types/reporting/reporting.types';
Chart.register(ArcElement, Tooltip);
interface MembersReportsPriorityGraphProps {
model: IRPTOverviewMemberChartData | undefined;
loading: boolean;
}
const MembersReportsPriorityGraph = ({ model, loading }: MembersReportsPriorityGraphProps) => {
// localization
const { t } = useTranslation('reporting-members-drawer');
const chartData = {
labels: model?.chart.map(item => t(`${item.name}Text`)),
datasets: [
{
label: t('tasksText'),
data: model?.chart.map(item => item.y),
backgroundColor: model?.chart.map(item => item.color),
},
],
};
const options = {
responsive: true,
plugins: {
legend: {
display: false,
position: 'top' as const,
},
datalabels: {
display: false,
},
},
};
return (
<Card
loading={loading}
title={
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
{t('tasksByPriorityText')}
</Typography.Text>
}
>
<div className="flex flex-wrap items-center justify-center gap-6 xl:flex-nowrap">
<Doughnut
data={chartData}
options={options}
className="max-h-[200px] w-full max-w-[200px]"
/>
<div className="flex flex-row flex-wrap gap-3 xl:flex-col">
{/* total tasks */}
<Flex gap={4} align="center">
<Badge color="#000" />
<Typography.Text ellipsis>
{t('allText')} ({model?.total})
</Typography.Text>
</Flex>
{/* priority-specific tasks */}
{model?.chart.map(item => (
<Flex key={item.name} gap={4} align="center">
<Badge color={item.color} />
<Typography.Text ellipsis>
{t(`${item.name}`)}({item.y})
</Typography.Text>
</Flex>
))}
</div>
</div>
</Card>
);
};
export default MembersReportsPriorityGraph;

View File

@@ -0,0 +1,83 @@
import { Doughnut } from 'react-chartjs-2';
import { Chart, ArcElement, Tooltip } from 'chart.js';
import { Badge, Card, Flex, Typography, Tooltip as AntTooltip } from 'antd';
import { useTranslation } from 'react-i18next';
import { IRPTOverviewMemberChartData } from '@/types/reporting/reporting.types';
Chart.register(ArcElement, Tooltip);
interface MembersReportsProjectGraphProps {
model: IRPTOverviewMemberChartData | undefined;
loading: boolean;
}
const MembersReportsProjectGraph = ({ model, loading }: MembersReportsProjectGraphProps) => {
// localization
const { t } = useTranslation('reporting-members-drawer');
// chart data
const chartData = {
labels: model?.chart.map(item => item.name),
datasets: [
{
label: t('tasksText'),
data: model?.chart.map(item => item.y),
backgroundColor: model?.chart.map(item => item.color),
},
],
};
const options = {
responsive: true,
plugins: {
legend: {
display: false,
position: 'top' as const,
},
datalabels: {
display: false,
},
},
};
return (
<Card
loading={loading}
title={
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
{t('tasksByProjectsText')}
</Typography.Text>
}
>
<div className="flex flex-wrap items-center justify-center gap-6 xl:flex-nowrap">
<Doughnut
data={chartData}
options={options}
className="max-h-[200px] w-full max-w-[200px]"
/>
<div className="flex flex-row flex-wrap gap-3 xl:flex-col">
{/* total tasks */}
<Flex gap={4} align="center">
<Badge color="#000" />
<Typography.Text ellipsis>
{t('allText')} ({model?.total})
</Typography.Text>
</Flex>
{/* project-specific tasks */}
{model?.chart.map((item, index) => (
<AntTooltip key={index} title={`${item.name} (${item.y})`}>
<Flex key={item.name} gap={4} align="center" style={{ maxWidth: 120 }}>
<Badge color={item.color} />
<Typography.Text ellipsis>{item.name}</Typography.Text>({item.y})
</Flex>
</AntTooltip>
))}
</div>
</div>
</Card>
);
};
export default MembersReportsProjectGraph;

View File

@@ -0,0 +1,84 @@
import React from 'react';
import { Doughnut } from 'react-chartjs-2';
import { Chart, ArcElement, Tooltip } from 'chart.js';
import { Badge, Card, Flex, Typography } from 'antd';
import { useTranslation } from 'react-i18next';
import { IRPTOverviewMemberChartData } from '@/types/reporting/reporting.types';
Chart.register(ArcElement, Tooltip);
interface MembersReportsStatusGraphProps {
model: IRPTOverviewMemberChartData | undefined;
loading: boolean;
}
const MembersReportsStatusGraph = ({ model, loading }: MembersReportsStatusGraphProps) => {
// localization
const { t } = useTranslation('reporting-members-drawer');
// chart data
const chartData = {
labels: model?.chart.map(item => t(`${item.name}Text`)),
datasets: [
{
label: t('tasksText'),
data: model?.chart.map(item => item.y),
backgroundColor: model?.chart.map(item => item.color),
},
],
};
const options = {
responsive: true,
plugins: {
legend: {
display: false,
position: 'top' as const,
},
datalabels: {
display: false,
},
},
};
return (
<Card
loading={loading}
title={
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
{t('tasksByStatusText')}
</Typography.Text>
}
>
<div className="flex flex-wrap items-center justify-center gap-6 xl:flex-nowrap">
<Doughnut
data={chartData}
options={options}
className="max-h-[200px] w-full max-w-[200px]"
/>
<div className="flex flex-row flex-wrap gap-3 xl:flex-col">
{/* total tasks */}
<Flex gap={4} align="center">
<Badge color="#000" />
<Typography.Text ellipsis>
{t('allText')} ({model?.total})
</Typography.Text>
</Flex>
{/* status-specific tasks */}
{model?.chart.map(item => (
<Flex key={item.name} gap={4} align="center">
<Badge color={item.color} />
<Typography.Text ellipsis>
{t(`${item.name}`)}({item.y})
</Typography.Text>
</Flex>
))}
</div>
</div>
</Card>
);
};
export default MembersReportsStatusGraph;

View File

@@ -0,0 +1,85 @@
import { Drawer, Typography } from 'antd';
import React, { useEffect, useState } from 'react';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useTranslation } from 'react-i18next';
import { toggleMembersOverviewProjectsStatsDrawer } from '../../../membersReportsSlice';
import MembersOverviewProjectsStatsTable from './members-overview-projects-stats-table';
import { reportingApiService } from '@/api/reporting/reporting.api.service';
type MembersOverviewProjectsStatsDrawerProps = {
memberId: string | null;
};
const MembersOverviewProjectsStatsDrawer = ({
memberId,
}: MembersOverviewProjectsStatsDrawerProps) => {
const [projectsData, setProjectsData] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
// localization
const { t } = useTranslation('reporting-members-drawer');
const dispatch = useAppDispatch();
const isDrawerOpen = useAppSelector(
state => state.membersReportsReducer.isMembersOverviewProjectsStatsDrawerOpen
);
const { membersList } = useAppSelector(state => state.membersReportsReducer);
const selectedMember = membersList.find(member => member.id === memberId);
const handleClose = () => {
dispatch(toggleMembersOverviewProjectsStatsDrawer());
};
useEffect(() => {
const fetchProjectsData = async () => {
if (!memberId || !isDrawerOpen) return;
try {
setLoading(true);
const body = {
team_member_id: memberId,
archived: false,
};
const response = await reportingApiService.getSingleMemberProjects(body);
if (response.done){
setProjectsData(response.body.projects || []);
} else {
setProjectsData([]);
}
} catch (error) {
console.error('Error fetching member projects:', error);
setProjectsData([]);
} finally {
setLoading(false);
}
};
fetchProjectsData();
}, [memberId, isDrawerOpen]);
return (
<Drawer
open={isDrawerOpen}
onClose={handleClose}
width={900}
title={
selectedMember && (
<Typography.Text>
{selectedMember.name}
{t('projectsStatsOverviewDrawerTitle')}
</Typography.Text>
)
}
>
<MembersOverviewProjectsStatsTable
projectList={projectsData}
loading={loading}
/>
</Drawer>
);
};
export default MembersOverviewProjectsStatsDrawer;

View File

@@ -0,0 +1,187 @@
import { memo } from 'react';
import { ConfigProvider, Flex, Skeleton, Spin, Table, TableColumnsType, Typography } from 'antd';
import { useTranslation } from 'react-i18next';
import CustomTableTitle from '@components/CustomTableTitle';
import { simpleDateFormat } from '@/utils/simpleDateFormat';
import { colors } from '@/styles/colors';
import { toCamelCase } from '@/utils/toCamelCase';
import ProjectCell from '@/pages/reporting/projects-reports/projects-reports-table/table-cells/project-cell/project-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 ProjectManagerCell from '@/pages/reporting/projects-reports/projects-reports-table/table-cells/project-manager-cell/project-manager-cell';
type ProjectReportsTableProps = {
projectList: any[];
loading: Boolean;
};
const MembersOverviewProjectsStatsTable = ({ projectList, loading }: ProjectReportsTableProps) => {
// localization
const { t } = useTranslation('reporting-members-drawer');
const columns: TableColumnsType = [
{
key: 'name',
title: <CustomTableTitle title={t('nameColumn')} />,
width: 300,
render: record => (
<ProjectCell projectId={record.id} project={record.name} projectColor={record.color_code} />
),
fixed: 'left' as const,
},
{
key: 'startDate',
title: <CustomTableTitle title={t('startDateColumn')} />,
render: record => (
<Typography.Text className="group-hover:text-[#1890ff]">
{record?.start_date ? simpleDateFormat(record?.start_date) : '-'}
</Typography.Text>
),
width: 120,
},
{
key: 'endDate',
title: <CustomTableTitle title={t('endDateColumn')} />,
render: record => (
<Typography.Text className="group-hover:text-[#1890ff]">
{record?.start_date ? simpleDateFormat(record?.end_date) : '-'}
</Typography.Text>
),
width: 120,
},
{
key: 'daysLeft',
title: <CustomTableTitle title={t('daysLeftColumn')} />,
// render: record => <ProjectDaysLeftAndOverdueCell daysLeft={record.days_left} />,
width: 150,
},
{
key: 'estimatedTime',
title: <CustomTableTitle title={t('estimatedTimeColumn')} />,
render: record => (
<Typography.Text className="group-hover:text-[#1890ff]">
{record.estimated_time_string}
</Typography.Text>
),
width: 120,
},
{
key: 'actualTime',
title: <CustomTableTitle title={t('actualTimeColumn')} />,
render: record => (
<Typography.Text className="group-hover:text-[#1890ff]">
{record.actual_time_string}
</Typography.Text>
),
width: 120,
},
{
key: 'status',
title: <CustomTableTitle title={t('statusColumn')} />,
// render: record => {
// const statusItem = statusData.find(item => item.label === record.status_name);
// return statusItem ? (
// <Typography.Text
// style={{ display: 'flex', alignItems: 'center', gap: 4 }}
// className="group-hover:text-[#1890ff]"
// >
// {statusItem.icon}
// {t(`${statusItem.value}Text`)}
// </Typography.Text>
// ) : (
// <Typography.Text>-</Typography.Text>
// );
// },
width: 120,
},
{
key: 'projectHealth',
title: <CustomTableTitle title={t('projectHealthColumn')} />,
render: record => (
<Flex
gap={6}
align="center"
style={{
width: 'fit-content',
borderRadius: 24,
paddingInline: 8,
height: 30,
backgroundColor: record.health_color,
color: colors.darkGray,
cursor: 'pointer',
}}
>
<Typography.Text
style={{
color: colors.darkGray,
fontSize: 13,
}}
>
{record.health_name ? t(`${toCamelCase(record.health_name)}Text`) : '-'}
</Typography.Text>
</Flex>
),
width: 120,
},
{
key: 'category',
title: <CustomTableTitle title="Category" />,
render: record => (
<Flex
gap={6}
align="center"
style={{
width: 'fit-content',
borderRadius: 24,
paddingInline: 8,
textTransform: 'capitalize',
fontSize: 13,
height: 22,
backgroundColor: record.category_color,
}}
>
{record.category_name ? record.category_name : '-'}
</Flex>
),
width: 120,
},
{
key: 'projectManager',
title: <CustomTableTitle title={t('projectManagerColumn')} />,
render: record => <ProjectManagerCell manager={record.project_manager} />,
width: 180,
},
];
return (
<ConfigProvider
theme={{
components: {
Table: {
cellPaddingBlock: 8,
cellPaddingInline: 8,
},
},
}}
>
{loading ? (
<Skeleton style={{ paddingTop: 16 }} />
) : (
<Table
columns={columns}
dataSource={projectList}
pagination={{ showSizeChanger: true, defaultPageSize: 10 }}
scroll={{ x: 'max-content' }}
onRow={record => {
return {
style: { height: 38, cursor: 'pointer' },
className: 'group even:bg-[#4e4e4e10]',
};
}}
/>
)}
</ConfigProvider>
);
};
export default memo(MembersOverviewProjectsStatsTable);

View File

@@ -0,0 +1,74 @@
import { Drawer, Typography } from 'antd';
import React, { useMemo, useState } from 'react';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useTranslation } from 'react-i18next';
import { toggleMembersOverviewTasksStatsDrawer } from '../../../membersReportsSlice';
import { fetchData } from '@/utils/fetchData';
import MembersOverviewTasksStatsTable from './members-overview-tasks-stats-table';
const TaskDrawer = React.lazy(() => import('@components/task-drawer/task-drawer'));
type MembersOverviewTasksStatsDrawerProps = {
memberId: string | null;
};
const MembersOverviewTasksStatsDrawer = ({ memberId }: MembersOverviewTasksStatsDrawerProps) => {
const [tasksData, setTasksData] = useState<any[]>([]);
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
// localization
const { t } = useTranslation('reporting-members-drawer');
const dispatch = useAppDispatch();
// get drawer open state from the member reports reducer
const isDrawerOpen = useAppSelector(
state => state.membersReportsReducer.isMembersOverviewTasksStatsDrawerOpen
);
const { membersList } = useAppSelector(state => state.membersReportsReducer);
// find the selected member based on memberId
const selectedMember = membersList.find(member => member.id === memberId);
// function to handle drawer close
const handleClose = () => {
dispatch(toggleMembersOverviewTasksStatsDrawer());
};
// useMemo for memoizing the fetch functions
useMemo(() => {
fetchData('/reportingMockData/membersReports/tasksStatsOverview.json', setTasksData);
}, []);
return (
<Drawer
open={isDrawerOpen}
onClose={handleClose}
width={900}
title={
selectedMember && (
<Typography.Text>
{selectedMember.name}
{t('tasksStatsOverviewDrawerTitle')}
</Typography.Text>
)
}
>
{tasksData &&
tasksData.map((item, index) => (
<MembersOverviewTasksStatsTable
key={index}
title={item.name}
color={item.color_code}
tasksData={item.tasks}
setSeletedTaskId={setSelectedTaskId}
/>
))}
<TaskDrawer />
</Drawer>
);
};
export default MembersOverviewTasksStatsDrawer;

View File

@@ -0,0 +1,173 @@
import { Badge, Collapse, Flex, Table, TableColumnsType, Tag, Typography } from 'antd';
import CustomTableTitle from '@components/CustomTableTitle';
import { colors } from '@/styles/colors';
import dayjs from 'dayjs';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { DoubleRightOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import { setShowTaskDrawer } from '@/features/task-drawer/task-drawer.slice';
type MembersOverviewTasksStatsTableProps = {
tasksData: any[];
title: string;
color: string;
setSeletedTaskId: (id: string) => void;
};
const MembersOverviewTasksStatsTable = ({
tasksData,
title,
color,
setSeletedTaskId,
}: MembersOverviewTasksStatsTableProps) => {
// localization
const { t } = useTranslation('reporting-members-drawer');
const dispatch = useAppDispatch();
// function to handle task drawer open
const handleUpdateTaskDrawer = (id: string) => {
setSeletedTaskId(id);
dispatch(setShowTaskDrawer(true));
};
const columns: TableColumnsType = [
{
key: 'task',
title: <CustomTableTitle title={t('taskColumn')} />,
onCell: record => {
return {
onClick: () => handleUpdateTaskDrawer(record.id),
};
},
render: record => (
<Flex>
{Number(record.sub_tasks_count) > 0 && <DoubleRightOutlined />}
<Typography.Text className="group-hover:text-[#1890ff]">{record.name}</Typography.Text>
</Flex>
),
width: 260,
className: 'group-hover:text-[#1890ff]',
fixed: 'left' as const,
},
{
key: 'status',
title: <CustomTableTitle title={t('statusColumn')} />,
render: record => (
<Tag
style={{ color: colors.darkGray, borderRadius: 48 }}
color={record.status_color}
children={record.status_name}
/>
),
width: 120,
},
{
key: 'priority',
title: <CustomTableTitle title={t('priorityColumn')} />,
render: record => (
<Tag
style={{ color: colors.darkGray, borderRadius: 48 }}
color={record.priority_color}
children={record.priority_name}
/>
),
width: 120,
},
{
key: 'phase',
title: <CustomTableTitle title={t('phaseColumn')} />,
render: record => (
<Tag
style={{ color: colors.darkGray, borderRadius: 48 }}
color={record.phase_color}
children={record.phase_name}
/>
),
width: 120,
},
{
key: 'dueDate',
title: <CustomTableTitle title={t('dueDateColumn')} />,
render: record => (
<Typography.Text className="text-center group-hover:text-[#1890ff]">
{record.due_date ? `${dayjs(record.due_date).format('MMM DD, YYYY')}` : '-'}
</Typography.Text>
),
width: 120,
},
{
key: 'completedOn',
title: <CustomTableTitle title={t('completedOnColumn')} />,
render: record => (
<Typography.Text className="text-center group-hover:text-[#1890ff]">
{record.completed_date ? `${dayjs(record.completed_date).format('MMM DD, YYYY')}` : '-'}
</Typography.Text>
),
width: 120,
},
{
key: 'daysOverdue',
title: <CustomTableTitle title={t('daysOverdueColumn')} />,
className: 'text-center group-hover:text-[#1890ff]',
dataIndex: 'overdue_days',
width: 120,
},
{
key: 'estimatedTime',
title: <CustomTableTitle title={t('estimatedTimeColumn')} />,
className: 'text-center group-hover:text-[#1890ff]',
dataIndex: 'total_time_string',
width: 130,
},
{
key: 'loggedTime',
title: <CustomTableTitle title={t('loggedTimeColumn')} />,
className: 'text-center group-hover:text-[#1890ff]',
dataIndex: 'time_spent_string',
width: 130,
},
{
key: 'overloggedTime',
title: <CustomTableTitle title={t('overloggedTimeColumn')} />,
className: 'text-center group-hover:text-[#1890ff]',
dataIndex: 'overlogged_time_string',
width: 150,
},
];
return (
<Collapse
bordered={false}
ghost={true}
size="small"
items={[
{
key: '1',
label: (
<Flex gap={8} align="center">
<Badge color={color} />
<Typography.Text strong>{`${title} (${tasksData.length})`}</Typography.Text>
</Flex>
),
children: (
<Table
columns={columns}
dataSource={tasksData}
pagination={false}
scroll={{ x: 'max-content' }}
onRow={record => {
return {
style: { height: 38, cursor: 'pointer' },
className: 'group even:bg-[#4e4e4e10]',
};
}}
/>
),
},
]}
/>
);
};
export default MembersOverviewTasksStatsTable;

View File

@@ -0,0 +1,114 @@
import {
ClockCircleOutlined,
ExclamationCircleOutlined,
FileExcelOutlined,
} from '@ant-design/icons';
import { Button, Card, Flex } from 'antd';
import React, { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import {
setMemberReportingDrawerActiveTab,
toggleMembersOverviewTasksStatsDrawer,
toggleMembersOverviewProjectsStatsDrawer,
} from '../../membersReportsSlice';
import { useAppSelector } from '@/hooks/useAppSelector';
import { IRPTOverviewMemberStats } from '@/types/reporting/reporting.types';
interface StatCardProps {
statsModel: IRPTOverviewMemberStats | undefined;
loading: boolean;
}
const MembersReportsStatCard = ({ statsModel, loading }: StatCardProps) => {
// localization
const { t } = useTranslation('reporting-members-drawer');
const dispatch = useAppDispatch();
const themeMode = useAppSelector(state => state.themeReducer.mode);
// function to handle members overview tasks stat drawer open
const handleMembersOverviewTasksStatsDrawerToggle = () => {
dispatch(toggleMembersOverviewTasksStatsDrawer());
};
// function to handle members overview projects stat drawer open
const handleMembersOverviewProjectsStatsDrawerToggle = () => {
dispatch(toggleMembersOverviewProjectsStatsDrawer());
};
// fuction to handle tab change
const handleNavigateTimeLogsTab = () => {
dispatch(setMemberReportingDrawerActiveTab('timeLogs'));
};
type StatItemsType = {
name: string;
icon: ReactNode;
value: string;
onClick: () => void;
};
// stat items array
const statItems: StatItemsType[] = [
{
name: 'projects',
icon: <FileExcelOutlined style={{ fontSize: 24, color: '#f6ce69' }} />,
value: statsModel?.projects.toString() || '0',
onClick: handleMembersOverviewProjectsStatsDrawerToggle,
},
{
name: 'totalTasks',
icon: <ExclamationCircleOutlined style={{ fontSize: 24, color: '#70eded' }} />,
value: statsModel?.total_tasks.toString() || '0',
onClick: handleMembersOverviewTasksStatsDrawerToggle,
},
{
name: 'assignedTasks',
icon: <ExclamationCircleOutlined style={{ fontSize: 24, color: '#7590c9' }} />,
value: statsModel?.assigned.toString() || '0',
onClick: handleMembersOverviewTasksStatsDrawerToggle,
},
{
name: 'completedTasks',
icon: <ExclamationCircleOutlined style={{ fontSize: 24, color: '#75c997' }} />,
value: statsModel?.completed.toString() || '0',
onClick: handleMembersOverviewTasksStatsDrawerToggle,
},
{
name: 'ongoingTasks',
icon: <ClockCircleOutlined style={{ fontSize: 24, color: '#7cb5ec' }} />,
value: statsModel?.ongoing.toString() || '0',
onClick: handleMembersOverviewTasksStatsDrawerToggle,
},
{
name: 'overdueTasks',
icon: <ClockCircleOutlined style={{ fontSize: 24, color: '#eb6363' }} />,
value: statsModel?.overdue.toString() || '0',
onClick: handleMembersOverviewTasksStatsDrawerToggle,
},
{
name: 'loggedHours',
icon: <ClockCircleOutlined style={{ fontSize: 24, color: '#75c997' }} />,
value: statsModel?.total_logged.toString() || '0',
onClick: handleNavigateTimeLogsTab,
},
];
return (
<Card style={{ width: '100%' }} loading={loading}>
<Flex vertical gap={8} style={{ padding: '12px 24px' }}>
{statItems.map((item, index) => (
<Flex key={index} gap={12} align="center">
{item.icon}
<Button type="text" onClick={item.onClick}>
{item.value} {t(`${item.name}Text`)}
</Button>
</Flex>
))}
</Flex>
</Card>
);
};
export default MembersReportsStatCard;

View File

@@ -0,0 +1,107 @@
import { Flex } from 'antd';
import React, { useEffect, useMemo, useState } from 'react';
import CustomSearchbar from '../../../../../components/CustomSearchbar';
import { fetchData } from '@/utils/fetchData';
import MembersReportsTasksTable from './MembersReportsTasksTable';
import ProjectFilter from './ProjectFilter';
import { useTranslation } from 'react-i18next';
import { useAuthService } from '@/hooks/useAuth';
import { reportingApiService } from '@/api/reporting/reporting.api.service';
import { IRPTOverviewProject } from '@/types/reporting/reporting.types';
import { useAppSelector } from '@/hooks/useAppSelector';
const TaskDrawer = React.lazy(() => import('@components/task-drawer/task-drawer'));
type MembersReportsTasksTabProps = {
memberId: string | null;
};
const MembersReportsTasksTab = ({ memberId }: MembersReportsTasksTabProps) => {
const { t } = useTranslation('reporting-members-drawer');
const currentSession = useAuthService().getCurrentSession();
const { duration, dateRange } = useAppSelector(state => state.reportingReducer);
const { archived } = useAppSelector(state => state.membersReportsReducer);
const [searchQuery, setSearchQuery] = useState<string>('');
const [tasksList, setTasksList] = useState<any[]>([]);
const [loadingTasks, setLoadingTasks] = useState<boolean>(false);
const [projectsList, setProjectsList] = useState<IRPTOverviewProject[]>([]);
const [loadingProjects, setLoadingProjects] = useState<boolean>(false);
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
const filteredTasks = useMemo(() => {
return tasksList.filter(task => task.name.toLowerCase().includes(searchQuery.toLowerCase()));
}, [tasksList, searchQuery]);
const fetchProjects = async () => {
if (!currentSession?.team_id) return;
try {
setLoadingProjects(true);
const response = await reportingApiService.getOverviewProjectsByTeam(currentSession.team_id);
if (response.done) {
setProjectsList(response.body);
}
} catch (error) {
console.error(error);
} finally {
setLoadingProjects(false);
}
};
const fetchTasks = async () => {
if (!currentSession?.team_id || !memberId) return;
try {
setLoadingTasks(true);
const additionalBody = {
duration: duration,
date_range: dateRange,
only_single_member: true,
archived,
};
const response = await reportingApiService.getTasksByMember(
memberId,
selectedProjectId,
false,
null,
additionalBody
);
if (response.done) {
setTasksList(response.body);
}
} catch (error) {
console.error(error);
} finally {
setLoadingTasks(false);
}
};
useEffect(() => {
fetchProjects();
fetchTasks();
}, [selectedProjectId, duration, dateRange]);
return (
<Flex vertical gap={24}>
<Flex gap={24} align="center" justify="space-between">
<CustomSearchbar
placeholderText={t('searchByNameInputPlaceholder')}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
/>
<ProjectFilter
projectList={projectsList}
loading={loadingProjects}
onSelect={value => setSelectedProjectId(value)}
/>
</Flex>
<MembersReportsTasksTable tasksData={filteredTasks} loading={loadingTasks} />
<TaskDrawer />
</Flex>
);
};
export default MembersReportsTasksTab;

View File

@@ -0,0 +1,144 @@
import { Badge, Flex, Table, TableColumnsType, Tag, Typography } from 'antd';
import React from 'react';
import dayjs from 'dayjs';
import { DoubleRightOutlined } from '@ant-design/icons';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { setSelectedTaskId, setShowTaskDrawer } from '@/features/task-drawer/task-drawer.slice';
import CustomTableTitle from '@/components/CustomTableTitle';
import { colors } from '@/styles/colors';
import { useTranslation } from 'react-i18next';
type MembersReportsTasksTableProps = {
tasksData: any[];
loading: boolean;
};
const MembersReportsTasksTable = ({
tasksData,
loading,
}: MembersReportsTasksTableProps) => {
// localization
const { t } = useTranslation('reporting-members-drawer');
const dispatch = useAppDispatch();
// function to handle task drawer open
const handleUpdateTaskDrawer = (id: string) => {
dispatch(setSelectedTaskId(id));
dispatch(setShowTaskDrawer(true));
};
const columns: TableColumnsType = [
{
key: 'task',
title: <CustomTableTitle title={t('taskColumn')} />,
onCell: record => {
return {
onClick: () => handleUpdateTaskDrawer(record.id),
};
},
render: record => (
<Flex>
{Number(record.sub_tasks_count) > 0 && <DoubleRightOutlined />}
<Typography.Text className="group-hover:text-[#1890ff]">{record.name}</Typography.Text>
</Flex>
),
width: 260,
fixed: 'left' as const,
},
{
key: 'project',
title: <CustomTableTitle title={t('projectColumn')} />,
render: record => (
<Flex gap={8} align="center">
<Badge color={record.project_color} />
<Typography.Text ellipsis={{ expanded: false }}>{record.project_name}</Typography.Text>
</Flex>
),
width: 120,
},
{
key: 'status',
title: <CustomTableTitle title={t('statusColumn')} />,
render: record => (
<Tag
style={{ color: colors.darkGray, borderRadius: 48 }}
color={record.status_color}
children={record.status_name}
/>
),
width: 120,
},
{
key: 'priority',
title: <CustomTableTitle title={t('priorityColumn')} />,
render: record => (
<Tag
style={{ color: colors.darkGray, borderRadius: 48 }}
color={record.priority_color}
children={record.priority_name}
/>
),
width: 120,
},
{
key: 'dueDate',
title: <CustomTableTitle title={t('dueDateColumn')} />,
render: record => (
<Typography.Text className="text-center group-hover:text-[#1890ff]">
{record.end_date ? `${dayjs(record.end_date).format('MMM DD, YYYY')}` : '-'}
</Typography.Text>
),
width: 120,
},
{
key: 'completedDate',
title: <CustomTableTitle title={t('completedDateColumn')} />,
render: record => (
<Typography.Text className="text-center group-hover:text-[#1890ff]">
{record.completed_at ? `${dayjs(record.completed_at).format('MMM DD, YYYY')}` : '-'}
</Typography.Text>
),
width: 120,
},
{
key: 'estimatedTime',
title: <CustomTableTitle title={t('estimatedTimeColumn')} />,
className: 'text-center group-hover:text-[#1890ff]',
dataIndex: 'estimated_string',
width: 130,
},
{
key: 'loggedTime',
title: <CustomTableTitle title={t('loggedTimeColumn')} />,
className: 'text-center group-hover:text-[#1890ff]',
dataIndex: 'time_spent_string',
width: 130,
},
{
key: 'overloggedTime',
title: <CustomTableTitle title={t('overloggedTimeColumn')} />,
className: 'text-center group-hover:text-[#1890ff]',
dataIndex: 'overlogged_time',
width: 150,
},
];
return (
<Table
columns={columns}
dataSource={tasksData}
scroll={{ x: 'max-content' }}
rowKey={record => record.id}
loading={loading}
onRow={record => {
return {
style: { height: 38, cursor: 'pointer' },
className: 'group even:bg-[#4e4e4e10]',
};
}}
/>
);
};
export default MembersReportsTasksTable;

View File

@@ -0,0 +1,34 @@
import { IRPTOverviewProject } from '@/types/reporting/reporting.types';
import { Flex, Select, Typography } from 'antd';
import { useTranslation } from 'react-i18next';
type ProjectFilterProps = {
projectList: IRPTOverviewProject[];
loading: boolean;
onSelect: (value: string) => void;
};
const ProjectFilter = ({ projectList, loading, onSelect }: ProjectFilterProps) => {
const { t } = useTranslation('reporting-members-drawer');
const selectOptions = projectList.map(project => ({
key: project.id,
value: project.id,
label: project.name,
}));
return (
<Flex gap={4} align="center">
<Typography.Text>{t('filterByText')}</Typography.Text>
<Select
placeholder={t('selectProjectPlaceholder')}
options={selectOptions}
loading={loading}
onChange={onSelect}
allowClear
/>
</Flex>
);
};
export default ProjectFilter;

View File

@@ -0,0 +1,84 @@
import { CaretDownFilled } from '@ant-design/icons';
import { Button, Card, Checkbox, Dropdown, List, Space } from 'antd';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
interface BillableFilterProps {
billable: { billable: boolean; nonBillable: boolean };
onBillableChange: (value: { billable: boolean; nonBillable: boolean }) => void;
}
const BillableFilter = ({ billable, onBillableChange }: BillableFilterProps) => {
// state to track dropdown open status
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
// localization
const { t } = useTranslation('reporting-members-drawer');
// billable dropdown items
type BillableFieldsType = {
key: string;
label: string;
};
const billableFieldsList: BillableFieldsType[] = [
{ key: 'billable', label: 'Billable' },
{ key: 'nonBillable', label: 'Non Billable' },
];
// custom dropdown content
const billableDropdownContent = (
<Card className="custom-card" styles={{ body: { padding: 0 } }}>
<List style={{ padding: 0 }}>
{billableFieldsList.map(item => (
<List.Item
className="custom-list-item"
key={item.key}
style={{
display: 'flex',
gap: 8,
padding: '4px 8px',
border: 'none',
}}
>
<Space>
<Checkbox
id={item.key}
checked={billable[item.key as keyof typeof billable]}
onChange={() => onBillableChange({
...billable,
[item.key as keyof typeof billable]: !billable[item.key as keyof typeof billable]
})}
/>
{t(`${item.key}Text`)}
</Space>
</List.Item>
))}
</List>
</Card>
);
return (
<Dropdown
overlayClassName="custom-dropdown"
trigger={['click']}
dropdownRender={() => billableDropdownContent}
onOpenChange={open => setIsDropdownOpen(open)}
>
<Button
icon={<CaretDownFilled />}
iconPosition="end"
style={{ width: 'fit-content' }}
className={`transition-colors duration-300 ${
isDropdownOpen
? 'border-[#1890ff] text-[#1890ff]'
: 'hover:text-[#1890ff hover:border-[#1890ff]'
}`}
>
{t('billableButton')}
</Button>
</Dropdown>
);
};
export default BillableFilter;

View File

@@ -0,0 +1,89 @@
import { Flex, Skeleton } from 'antd';
import React, { useEffect, useState } from 'react';
import BillableFilter from './billable-filter';
import { fetchData } from '@/utils/fetchData';
import TimeLogCard from './time-log-card';
import EmptyListPlaceholder from '../../../../../components/EmptyListPlaceholder';
import { useTranslation } from 'react-i18next';
import { reportingApiService } from '@/api/reporting/reporting.api.service';
import logger from '@/utils/errorLogger';
import { ISingleMemberLogs } from '@/types/reporting/reporting.types';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAuthService } from '@/hooks/useAuth';
import { createPortal } from 'react-dom';
import { reportingExportApiService } from '@/api/reporting/reporting-export.api.service';
const TaskDrawer = React.lazy(() => import('@components/task-drawer/task-drawer'));
type MembersReportsTimeLogsTabProps = {
memberId: string | null;
};
const MembersReportsTimeLogsTab = ({ memberId = null }: MembersReportsTimeLogsTabProps) => {
const { t } = useTranslation('reporting-members-drawer');
const currentSession = useAuthService().getCurrentSession();
const [timeLogsData, setTimeLogsData] = useState<ISingleMemberLogs[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const { duration, dateRange } = useAppSelector(state => state.reportingReducer);
const { archived } = useAppSelector(state => state.membersReportsReducer);
const [billable, setBillable] = useState<{ billable: boolean; nonBillable: boolean }>({
billable: true,
nonBillable: true,
});
const fetchTimeLogsData = async () => {
if (!memberId || !currentSession?.team_id) return;
try {
setLoading(true);
const body = {
team_member_id: memberId,
team_id: currentSession?.team_id as string,
duration: duration,
date_range: dateRange,
archived: archived,
billable: billable,
};
const response = await reportingApiService.getSingleMemberTimeLogs(body);
if (response.done) {
response.body.sort((a: any, b: any) => {
const dateA = new Date(a.log_day);
const dateB = new Date(b.log_day);
return dateB.getTime() - dateA.getTime();
});
setTimeLogsData(response.body);
}
} catch (error) {
logger.error('fetchTimeLogsData', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchTimeLogsData();
}, [memberId, duration, dateRange, archived, billable]);
return (
<Flex vertical gap={24}>
<BillableFilter billable={billable} onBillableChange={setBillable} />
<Skeleton active loading={loading} paragraph={{ rows: 10 }}>
{timeLogsData.length > 0 ? (
<Flex vertical gap={24}>
{timeLogsData.map((logs, index) => (
<TimeLogCard key={index} data={logs} />
))}
</Flex>
) : (
<EmptyListPlaceholder text={t('timeLogsEmptyPlaceholder')} />
)}
</Skeleton>
{createPortal(<TaskDrawer />, document.body)}
</Flex>
);
};
export default MembersReportsTimeLogsTab;

View File

@@ -0,0 +1,61 @@
import { Card, ConfigProvider, Tag, Timeline, Typography } from 'antd';
import { simpleDateFormat } from '@/utils/simpleDateFormat';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useTranslation } from 'react-i18next';
import { fetchTask, setSelectedTaskId, setShowTaskDrawer } from '@/features/task-drawer/task-drawer.slice';
import { ISingleMemberLogs } from '@/types/reporting/reporting.types';
import { fetchPhasesByProjectId } from '@/features/projects/singleProject/phase/phases.slice';
type TimeLogCardProps = {
data: ISingleMemberLogs;
};
const TimeLogCard = ({ data }: TimeLogCardProps) => {
const { t } = useTranslation('reporting-members-drawer');
const dispatch = useAppDispatch();
const handleUpdateTaskDrawer = (id: string, projectId: string) => {
if (!id || !projectId) return;
dispatch(setSelectedTaskId(id));
dispatch(fetchPhasesByProjectId(projectId));
dispatch(fetchTask({ taskId: id, projectId: projectId }));
dispatch(setShowTaskDrawer(true));
};
return (
<ConfigProvider
theme={{
components: {
Timeline: { dotBorderWidth: '2px' },
},
}}
>
<Card
title={
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
{simpleDateFormat(data.log_day)}
</Typography.Text>
}
>
<Timeline>
{data.logs.map((log, index) => (
<Timeline.Item key={index} style={{ paddingBottom: 8 }}>
<Typography.Text
className="cursor-pointer hover:text-[#1899ff]"
onClick={() => handleUpdateTaskDrawer(log.task_id, log.project_id)}
>
{t('loggedText')} <strong>{log.time_spent_string}</strong> {t('forText')}{' '}
<strong>{log.task_name}</strong> {t('inText')} <strong>{log.project_name}</strong>{' '}
<Tag>{log.task_key}</Tag>
</Typography.Text>
</Timeline.Item>
))}
</Timeline>
</Card>
</ConfigProvider>
);
};
export default TimeLogCard;

View File

@@ -0,0 +1,143 @@
import { reportingMembersApiService } from '@/api/reporting/reporting-members.api.service';
import { durations } from '@/shared/constants';
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
type MembersReportsState = {
isMembersReportsDrawerOpen: boolean;
isMembersOverviewTasksStatsDrawerOpen: boolean;
isMembersOverviewProjectsStatsDrawerOpen: boolean;
activeTab: 'overview' | 'timeLogs' | 'activityLogs' | 'tasks';
total: number;
membersList: any[];
isLoading: boolean;
error: string | null;
// filters
archived: boolean;
searchQuery: string;
index: number;
pageSize: number;
field: string;
order: string;
duration: string;
dateRange: string;
};
const initialState: MembersReportsState = {
isMembersReportsDrawerOpen: false,
isMembersOverviewTasksStatsDrawerOpen: false,
isMembersOverviewProjectsStatsDrawerOpen: false,
activeTab: 'overview',
total: 0,
membersList: [],
isLoading: false,
error: null,
// filters
archived: false,
searchQuery: '',
index: 1,
pageSize: 10,
field: 'name',
order: 'asc',
duration: durations[1].key,
dateRange: '',
};
export const fetchMembersData = createAsyncThunk(
'membersReports/fetchMembersData',
async ({ duration, dateRange }: { duration: string; dateRange: string[] }, { getState }) => {
const state = (getState() as any).membersReportsReducer;
const body = {
index: state.index,
size: state.pageSize,
field: state.field,
order: state.order,
search: state.searchQuery,
archived: state.archived,
duration: duration || state.duration,
date_range: dateRange || state.dateRange,
};
const response = await reportingMembersApiService.getMembers(body);
return response.body;
}
);
const membersReportsSlice = createSlice({
name: 'membersReportsReducer',
initialState,
reducers: {
toggleMembersReportsDrawer: state => {
state.isMembersReportsDrawerOpen = !state.isMembersReportsDrawerOpen;
},
toggleMembersOverviewTasksStatsDrawer: state => {
state.isMembersOverviewTasksStatsDrawerOpen = !state.isMembersOverviewTasksStatsDrawerOpen;
},
toggleMembersOverviewProjectsStatsDrawer: state => {
state.isMembersOverviewProjectsStatsDrawerOpen =
!state.isMembersOverviewProjectsStatsDrawerOpen;
},
setMemberReportingDrawerActiveTab: (
state,
action: PayloadAction<'overview' | 'timeLogs' | 'activityLogs' | 'tasks'>
) => {
state.activeTab = action.payload;
},
setArchived: (state, action) => {
state.archived = action.payload;
},
setSearchQuery: (state, action) => {
state.searchQuery = action.payload;
},
setIndex: (state, action) => {
state.index = action.payload;
},
setPageSize: (state, action) => {
state.pageSize = action.payload;
},
setField: (state, action) => {
state.field = action.payload;
},
setOrder: (state, action) => {
state.order = action.payload;
},
setDuration: (state, action) => {
state.duration = action.payload;
},
setDateRange: (state, action) => {
state.dateRange = action.payload;
},
},
extraReducers: builder => {
builder
.addCase(fetchMembersData.pending, state => {
state.isLoading = true;
state.error = null;
})
.addCase(fetchMembersData.fulfilled, (state, action) => {
state.isLoading = false;
state.membersList = action.payload.members || [];
state.total = action.payload.total || 0;
})
.addCase(fetchMembersData.rejected, (state, action) => {
state.isLoading = false;
state.error = action.error.message || 'Failed to fetch members data';
});
},
});
export const {
toggleMembersReportsDrawer,
toggleMembersOverviewTasksStatsDrawer,
toggleMembersOverviewProjectsStatsDrawer,
setMemberReportingDrawerActiveTab,
setArchived,
setSearchQuery,
setIndex,
setPageSize,
setField,
setOrder,
setDuration,
setDateRange,
} = membersReportsSlice.actions;
export default membersReportsSlice.reducer;

View File

@@ -0,0 +1,271 @@
import { reportingProjectsApiService } from '@/api/reporting/reporting-projects.api.service';
import { DEFAULT_PAGE_SIZE, FILTER_INDEX_KEY } from '@/shared/constants';
import { IProjectCategory } from '@/types/project/projectCategory.types';
import { IProjectHealth } from '@/types/project/projectHealth.types';
import { IProjectManager } from '@/types/project/projectManager.types';
import { IProjectStatus } from '@/types/project/projectStatus.types';
import { IGetProjectsRequestBody, IRPTOverviewProject, IRPTOverviewProjectMember, IRPTProject } from '@/types/reporting/reporting.types';
import { getFromLocalStorage } from '@/utils/localStorageFunctions';
import { createAsyncThunk, createSlice, createAction } from '@reduxjs/toolkit';
const filterIndex = () => {
return +(getFromLocalStorage(FILTER_INDEX_KEY.toString()) || 0);
};
type ProjectReportsState = {
isProjectReportsDrawerOpen: boolean;
isProjectReportsMembersTaskDrawerOpen: boolean;
selectedMember: IRPTOverviewProjectMember | null;
selectedProject: IRPTOverviewProject | null;
projectList: IRPTProject[];
total: number;
isLoading: boolean;
error: string | null;
// filters
index: number;
pageSize: number;
field: string;
order: string;
searchQuery: string;
filterIndex: number;
archived: boolean;
selectedProjectStatuses: IProjectStatus[];
selectedProjectHealths: IProjectHealth[];
selectedProjectCategories: IProjectCategory[];
selectedProjectManagers: IProjectManager[];
};
export const fetchProjectData = createAsyncThunk(
'projectReports/fetchProjectData',
async (_, { getState }) => {
const state = (getState() as any).projectReportsReducer;
const body: IGetProjectsRequestBody = {
index: state.index,
size: state.pageSize,
field: state.field,
order: state.order,
search: state.searchQuery,
filter: state.filterIndex.toString(),
statuses: state.selectedProjectStatuses.map((s: IProjectStatus) => s.id || ''),
healths: state.selectedProjectHealths.map((h: IProjectHealth) => h.id || ''),
categories: state.selectedProjectCategories.map((c: IProjectCategory) => c.id || ''),
project_managers: state.selectedProjectManagers.map((m: IProjectManager) => m.id || ''),
archived: state.archived,
};
const response = await reportingProjectsApiService.getProjects(body);
return response.body;
}
);
export const updateProjectCategory = createAction<{
projectId: string;
category: IProjectCategory;
}>('projectReports/updateProjectCategory');
export const updateProjectStatus = createAction<{
projectId: string;
status: IProjectStatus;
}>('projectReports/updateProjectStatus');
const initialState: ProjectReportsState = {
isProjectReportsDrawerOpen: false,
isProjectReportsMembersTaskDrawerOpen: false,
selectedMember: null,
selectedProject: null,
projectList: [],
total: 0,
isLoading: false,
error: null,
// filters
index: 1,
pageSize: 10,
field: 'name',
order: 'asc',
searchQuery: '',
filterIndex: filterIndex(),
archived: false,
selectedProjectStatuses: [],
selectedProjectHealths: [],
selectedProjectCategories: [],
selectedProjectManagers: [],
};
const projectReportsSlice = createSlice({
name: 'projectReportsReducer',
initialState,
reducers: {
toggleProjectReportsDrawer: state => {
state.isProjectReportsDrawerOpen = !state.isProjectReportsDrawerOpen;
},
toggleProjectReportsMembersTaskDrawer: state => {
state.isProjectReportsMembersTaskDrawerOpen = !state.isProjectReportsMembersTaskDrawerOpen;
},
setSearchQuery: (state, action) => {
state.searchQuery = action.payload;
state.index = 1;
},
setSelectedProjectStatuses: (state, action) => {
state.selectedProjectStatuses = action.payload;
},
setSelectedProjectHealths: (state, action) => {
state.selectedProjectHealths = action.payload;
},
setSelectedProjectCategories: (state, action) => {
const category = action.payload;
const index = state.selectedProjectCategories.findIndex(c => c.id === category.id);
if (index >= 0) {
state.selectedProjectCategories.splice(index, 1);
} else {
state.selectedProjectCategories.push(category);
}
},
setSelectedProjectManagers: (state, action) => {
const manager = action.payload;
const index = state.selectedProjectManagers.findIndex(m => m.id === manager.id);
if (index >= 0) {
state.selectedProjectManagers.splice(index, 1);
} else {
state.selectedProjectManagers.push(manager);
}
},
setArchived: (state, action) => {
state.archived = action.payload;
},
setIndex: (state, action) => {
state.index = action.payload;
},
setPageSize: (state, action) => {
state.pageSize = action.payload;
},
setField: (state, action) => {
state.field = action.payload;
},
setOrder: (state, action) => {
state.order = action.payload;
},
setProjectHealth: (state, action) => {
const health = action.payload;
const project = state.projectList.find(p => p.id === health.id);
if (project) {
project.project_health = health.id;
project.health_name = health.name;
project.health_color = health.color_code;
}
},
setProjectStatus: (state, action) => {
const status = action.payload;
const project = state.projectList.find(p => p.id === status.id);
if (project) {
project.status_id = status.id;
project.status_name = status.name;
project.status_color = status.color_code;
}
},
setProjectStartDate: (state, action) => {
const project = state.projectList.find(p => p.id === action.payload.id);
if (project) {
project.start_date = action.payload.start_date;
}
},
setProjectEndDate: (state, action) => {
const project = state.projectList.find(p => p.id === action.payload.id);
if (project) {
project.end_date = action.payload.end_date;
}
},
setSelectedMember: (state, action) => {
state.selectedMember = action.payload;
},
setSelectedProject: (state, action) => {
state.selectedProject = action.payload;
},
setSelectedProjectCategory: (state, action) => {
const category = action.payload;
const project = state.projectList.find(p => p.id === category.id);
if (project) {
project.category_id = category.id;
project.category_name = category.name;
project.category_color = category.color_code;
}
},
resetProjectReports: state => {
state.projectList = [];
state.total = 0;
state.isLoading = false;
state.error = null;
state.index = 1;
state.pageSize = 10;
state.field = 'name';
state.order = 'asc';
state.searchQuery = '';
state.filterIndex = filterIndex();
state.archived = false;
},
},
extraReducers: builder => {
builder
.addCase(fetchProjectData.pending, state => {
state.isLoading = true;
state.error = null;
})
.addCase(fetchProjectData.fulfilled, (state, action) => {
state.isLoading = false;
state.total = action.payload.total || 0;
state.projectList = action.payload.projects || [];
})
.addCase(fetchProjectData.rejected, (state, action) => {
state.isLoading = false;
state.error = action.error.message || 'Failed to fetch project data';
})
.addCase(updateProjectCategory, (state, action) => {
const { projectId, category } = action.payload;
const projectIndex = state.projectList.findIndex(project => project.id === projectId);
if (projectIndex !== -1) {
state.projectList[projectIndex].category_id = category.id || null;
state.projectList[projectIndex].category_name = category.name ?? '';
state.projectList[projectIndex].category_color = category.color_code ?? '';
}
})
.addCase(updateProjectStatus, (state, action) => {
const { projectId, status } = action.payload;
const projectIndex = state.projectList.findIndex(project => project.id === projectId);
if (projectIndex !== -1) {
state.projectList[projectIndex].status_id = status.id || '';
state.projectList[projectIndex].status_name = status.name ?? '';
state.projectList[projectIndex].status_color = status.color_code ?? '';
}
});
},
});
export const {
toggleProjectReportsDrawer,
toggleProjectReportsMembersTaskDrawer,
setSearchQuery,
setSelectedProjectStatuses,
setSelectedProjectHealths,
setSelectedProjectCategories,
setSelectedProjectManagers,
setArchived,
setProjectStartDate,
setProjectEndDate,
setIndex,
setPageSize,
setField,
setOrder,
setProjectHealth,
setProjectStatus,
setSelectedMember,
setSelectedProject,
setSelectedProjectCategory,
resetProjectReports,
} = projectReportsSlice.actions;
export default projectReportsSlice.reducer;

View File

@@ -0,0 +1,45 @@
import { PROJECT_LIST_COLUMNS } from '@/shared/constants';
import { getJSONFromLocalStorage, saveJSONToLocalStorage, saveToLocalStorage } from '@/utils/localStorageFunctions';
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
type ColumnsVisibilityState = {
[key: string]: boolean;
};
const getInitialState = () => {
const savedState = getJSONFromLocalStorage(PROJECT_LIST_COLUMNS);
return savedState || {
name: true,
projectHealth: true,
category: true,
projectUpdate: true,
client: true,
team: true,
projectManager: true,
estimatedVsActual: true,
tasksProgress: true,
lastActivity: true,
status: true,
dates: true,
daysLeft: true,
};
};
const initialState: ColumnsVisibilityState = getInitialState();
const projectReportsTableColumnsSlice = createSlice({
name: 'projectReportsTableColumns',
initialState,
reducers: {
toggleColumnHidden: (state, action: PayloadAction<string>) => {
const columnKey = action.payload;
if (columnKey in state) {
state[columnKey] = !state[columnKey];
}
saveJSONToLocalStorage(PROJECT_LIST_COLUMNS, state);
},
},
});
export const { toggleColumnHidden } = projectReportsTableColumnsSlice.actions;
export default projectReportsTableColumnsSlice.reducer;

View File

@@ -0,0 +1,73 @@
import { Drawer, Typography, Flex, Button, Dropdown } from 'antd';
import React, { useState } from 'react';
import { useAppSelector } from '../../../../hooks/useAppSelector';
import { useAppDispatch } from '../../../../hooks/useAppDispatch';
import { setSelectedProject, toggleProjectReportsDrawer } from '../project-reports-slice';
import { BankOutlined, DownOutlined } from '@ant-design/icons';
import ProjectReportsDrawerTabs from './ProjectReportsDrawerTabs';
import { colors } from '../../../../styles/colors';
import { useTranslation } from 'react-i18next';
import { IRPTProject } from '@/types/reporting/reporting.types';
type ProjectReportsDrawerProps = {
selectedProject: IRPTProject | null;
};
const ProjectReportsDrawer = ({ selectedProject }: ProjectReportsDrawerProps) => {
const { t } = useTranslation('reporting-projects-drawer');
const dispatch = useAppDispatch();
// get drawer open state and project list from the reducer
const isDrawerOpen = useAppSelector(
state => state.projectReportsReducer.isProjectReportsDrawerOpen
);
const { projectList } = useAppSelector(state => state.projectReportsReducer);
// function to handle drawer close
const handleClose = () => {
dispatch(toggleProjectReportsDrawer());
};
const handleAfterOpenChange = (open: boolean) => {
if (open) {
dispatch(setSelectedProject(selectedProject));
}
};
return (
<Drawer
open={isDrawerOpen}
onClose={handleClose}
afterOpenChange={handleAfterOpenChange}
destroyOnClose
width={900}
title={
<Flex align="center" justify="space-between">
<Flex gap={8} align="center" style={{ fontWeight: 500 }}>
<BankOutlined style={{ color: colors.lightGray }} />
<Typography.Text>/</Typography.Text>
<Typography.Text>{selectedProject?.name}</Typography.Text>
</Flex>
<Dropdown
menu={{
items: [
{ key: '1', label: t('membersButton') },
{ key: '2', label: t('tasksButton') },
],
}}
>
<Button type="primary" icon={<DownOutlined />} iconPosition="end">
{t('exportButton')}
</Button>
</Dropdown>
</Flex>
}
>
{selectedProject && <ProjectReportsDrawerTabs projectId={selectedProject.id} />}
</Drawer>
);
};
export default ProjectReportsDrawer;

View File

@@ -0,0 +1,38 @@
import { Tabs } from 'antd';
import { TabsProps } from 'antd/lib';
import { useTranslation } from 'react-i18next';
import ProjectReportsOverviewTab from './overviewTab/ProjectReportsOverviewTab';
import ProjectReportsMembersTab from './membersTab/ProjectReportsMembersTab';
import ProjectReportsTasksTab from './tasksTab/ProjectReportsTasksTab';
type ProjectReportsDrawerProps = {
projectId?: string | null;
};
const ProjectReportsDrawerTabs = ({ projectId = null }: ProjectReportsDrawerProps) => {
// localization
const { t } = useTranslation('reporting-projects-drawer');
const tabItems: TabsProps['items'] = [
{
key: 'overview',
label: t('overviewTab'),
children: <ProjectReportsOverviewTab projectId={projectId} />,
},
{
key: 'members',
label: t('membersTab'),
children: <ProjectReportsMembersTab projectId={projectId} />,
},
{
key: 'tasks',
label: t('tasksTab'),
children: <ProjectReportsTasksTab projectId={projectId} />,
},
];
return <Tabs type="card" items={tabItems} destroyInactiveTabPane />;
};
export default ProjectReportsDrawerTabs;

View File

@@ -0,0 +1,57 @@
import { Flex } from 'antd';
import { useEffect, useMemo, useState } from 'react';
import CustomSearchbar from '../../../../../components/CustomSearchbar';
import ProjectReportsMembersTable from './ProjectReportsMembersTable';
import { useTranslation } from 'react-i18next';
import { IRPTOverviewProjectMember } from '@/types/reporting/reporting.types';
import { reportingProjectsApiService } from '@/api/reporting/reporting-projects.api.service';
type ProjectReportsMembersTabProps = {
projectId?: string | null;
};
const ProjectReportsMembersTab = ({ projectId = null }: ProjectReportsMembersTabProps) => {
const { t } = useTranslation('reporting-projects-drawer');
const [membersData, setMembersData] = useState<IRPTOverviewProjectMember[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [searchQuery, setSearchQuery] = useState<string>('');
const filteredMembersData = useMemo(() => {
return membersData.filter(item => item.name.toLowerCase().includes(searchQuery.toLowerCase()));
}, [searchQuery, membersData]);
const fetchMembersData = async () => {
if (!projectId || loading) return;
try {
setLoading(true);
const res = await reportingProjectsApiService.getProjectMembers(projectId);
if (res.done) {
setMembersData(res.body);
}
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchMembersData();
}, [projectId]);
return (
<Flex vertical gap={24}>
<CustomSearchbar
placeholderText={t('searchByNameInputPlaceholder')}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
/>
<ProjectReportsMembersTable membersData={filteredMembersData} loading={loading} />
</Flex>
);
};
export default ProjectReportsMembersTab;

View File

@@ -0,0 +1,115 @@
import { Progress, Table, TableColumnsType } from 'antd';
import React from 'react';
import CustomTableTitle from '../../../../../components/CustomTableTitle';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { setSelectedMember, toggleProjectReportsMembersTaskDrawer } from '../../project-reports-slice';
import { useTranslation } from 'react-i18next';
import ProjectReportsMembersTaskDrawer from './projectReportsMembersTaskDrawer/ProjectReportsMembersTaskDrawer';
import { createPortal } from 'react-dom';
import { IRPTOverviewProjectMember } from '@/types/reporting/reporting.types';
type ProjectReportsMembersTableProps = {
membersData: any[];
loading: boolean;
};
const ProjectReportsMembersTable = ({ membersData, loading }: ProjectReportsMembersTableProps) => {
// localization
const { t } = useTranslation('reporting-projects-drawer');
const dispatch = useAppDispatch();
// function to handle task drawer open
const handleProjectReportsMembersTaskDrawer = (record: IRPTOverviewProjectMember) => {
dispatch(setSelectedMember(record));
dispatch(toggleProjectReportsMembersTaskDrawer());
};
const columns: TableColumnsType = [
{
key: 'name',
title: <CustomTableTitle title={t('nameColumn')} />,
onCell: (record: any) => {
return {
onClick: () => handleProjectReportsMembersTaskDrawer(record as IRPTOverviewProjectMember),
};
},
dataIndex: 'name',
width: 260,
className: 'group-hover:text-[#1890ff]',
fixed: 'left' as const,
},
{
key: 'tasksCount',
title: <CustomTableTitle title={t('tasksCountColumn')} />,
className: 'text-center group-hover:text-[#1890ff]',
dataIndex: 'tasks_count',
width: 120,
},
{
key: 'completedTasks',
title: <CustomTableTitle title={t('completedTasksColumn')} />,
className: 'text-center group-hover:text-[#1890ff]',
dataIndex: 'completed',
width: 120,
},
{
key: 'incompleteTasks',
title: <CustomTableTitle title={t('incompleteTasksColumn')} />,
className: 'text-center group-hover:text-[#1890ff]',
dataIndex: 'incompleted',
width: 120,
},
{
key: 'overdueTasks',
title: <CustomTableTitle title={t('overdueTasksColumn')} />,
className: 'text-center group-hover:text-[#1890ff]',
dataIndex: 'overdue',
width: 120,
},
{
key: 'contribution',
title: <CustomTableTitle title={t('contributionColumn')} />,
render: record => {
return <Progress percent={record.contribution} />;
},
width: 180,
},
{
key: 'progress',
title: <CustomTableTitle title={t('progressColumn')} />,
render: record => {
return <Progress percent={record.progress} />;
},
width: 180,
},
{
key: 'loggedTime',
title: <CustomTableTitle title={t('loggedTimeColumn')} />,
className: 'text-center group-hover:text-[#1890ff]',
dataIndex: 'time_logged',
width: 120,
},
];
return (
<>
<Table
columns={columns}
dataSource={membersData}
pagination={false}
scroll={{ x: 'max-content' }}
loading={loading}
onRow={record => {
return {
style: { height: 38, cursor: 'pointer' },
className: 'group even:bg-[#4e4e4e10]',
};
}}
/>
{createPortal(<ProjectReportsMembersTaskDrawer />, document.body, 'project-reports-members-task-drawer')}
</>
);
};
export default ProjectReportsMembersTable;

View File

@@ -0,0 +1,72 @@
import { Drawer, Typography, Flex, Button } from 'antd';
import React, { useMemo, useState } from 'react';
import { FileOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useAppSelector } from '@/hooks/useAppSelector';
import { toggleProjectReportsMembersTaskDrawer } from '../../../project-reports-slice';
import { colors } from '@/styles/colors';
import ProjectReportsMembersTasksTable from './ProjectReportsMembersTaskTable';
import CustomSearchbar from '@/components/CustomSearchbar';
const ProjectReportsMembersTaskDrawer = () => {
const { t } = useTranslation('reporting-projects-drawer');
const dispatch = useAppDispatch();
const [taskData, setTaskData] = useState<any[]>([]);
const [searchQuery, setSearchQuery] = useState<string>('');
const { isProjectReportsMembersTaskDrawerOpen, selectedProject, selectedMember } = useAppSelector(
state => state.projectReportsReducer
);
const handleClose = () => {
dispatch(toggleProjectReportsMembersTaskDrawer());
};
const handleAfterOpenChange = (open: boolean) => {
if (open) {
}
};
const filteredTaskData = useMemo(() => {
return taskData.filter(item => item.name.toLowerCase().includes(searchQuery.toLowerCase()));
}, [searchQuery, taskData]);
return (
<Drawer
open={isProjectReportsMembersTaskDrawerOpen}
onClose={handleClose}
afterOpenChange={handleAfterOpenChange}
destroyOnClose
width={900}
title={
<Flex align="center" justify="space-between">
<Flex gap={8} align="center" style={{ fontWeight: 500 }}>
<FileOutlined style={{ color: colors.lightGray }} />
<Typography.Text>{selectedProject?.name} /</Typography.Text>
<Typography.Text>{selectedMember?.name}</Typography.Text>
</Flex>
<Button type="primary">{t('exportButton')}</Button>
</Flex>
}
>
<Flex vertical gap={24}>
<CustomSearchbar
placeholderText={t('searchByNameInputPlaceholder')}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
/>
<ProjectReportsMembersTasksTable
tasksData={filteredTaskData}
/>
</Flex>
</Drawer>
);
};
export default ProjectReportsMembersTaskDrawer;

View File

@@ -0,0 +1,145 @@
import { useTranslation } from 'react-i18next';
import React from 'react';
import { createPortal } from 'react-dom';
import { Badge, Flex, Table, TableColumnsType, Tag, Typography } from 'antd';
import dayjs from 'dayjs';
import { DoubleRightOutlined } from '@ant-design/icons';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { setShowTaskDrawer } from '@/features/task-drawer/task-drawer.slice';
import CustomTableTitle from '@components/CustomTableTitle';
import { colors } from '@/styles/colors';
const TaskDrawer = React.lazy(() => import('@components/task-drawer/task-drawer'));
type ProjectReportsMembersTasksTableProps = {
tasksData: any[];
};
const ProjectReportsMembersTasksTable = ({ tasksData }: ProjectReportsMembersTasksTableProps) => {
// localization
const { t } = useTranslation('reporting-projects-drawer');
const dispatch = useAppDispatch();
// function to handle task drawer open
const handleUpdateTaskDrawer = (id: string) => {
dispatch(setShowTaskDrawer(true));
};
const columns: TableColumnsType = [
{
key: 'task',
title: <CustomTableTitle title={t('taskColumn')} />,
onCell: record => {
return {
onClick: () => handleUpdateTaskDrawer(record.id),
};
},
render: record => (
<Flex>
{Number(record.sub_tasks_count) > 0 && <DoubleRightOutlined />}
<Typography.Text className="group-hover:text-[#1890ff]">{record.name}</Typography.Text>
</Flex>
),
width: 260,
fixed: 'left' as const,
},
{
key: 'project',
title: <CustomTableTitle title={t('projectColumn')} />,
render: record => (
<Flex gap={8} align="center">
<Badge color={record.project_color} />
<Typography.Text>{record.project_name}</Typography.Text>
</Flex>
),
width: 120,
},
{
key: 'status',
title: <CustomTableTitle title={t('statusColumn')} />,
render: record => (
<Tag
style={{ color: colors.darkGray, borderRadius: 48 }}
color={record.status_color}
children={record.status_name}
/>
),
width: 120,
},
{
key: 'priority',
title: <CustomTableTitle title={t('priorityColumn')} />,
render: record => (
<Tag
style={{ color: colors.darkGray, borderRadius: 48 }}
color={record.priority_color}
children={record.priority_name}
/>
),
width: 120,
},
{
key: 'dueDate',
title: <CustomTableTitle title={t('dueDateColumn')} />,
render: record => (
<Typography.Text className="text-center group-hover:text-[#1890ff]">
{record.end_date ? `${dayjs(record.end_date).format('MMM DD, YYYY')}` : '-'}
</Typography.Text>
),
width: 120,
},
{
key: 'completedDate',
title: <CustomTableTitle title={t('completedDateColumn')} />,
render: record => (
<Typography.Text className="text-center group-hover:text-[#1890ff]">
{record.completed_at ? `${dayjs(record.completed_at).format('MMM DD, YYYY')}` : '-'}
</Typography.Text>
),
width: 120,
},
{
key: 'estimatedTime',
title: <CustomTableTitle title={t('estimatedTimeColumn')} />,
className: 'text-center group-hover:text-[#1890ff]',
dataIndex: 'estimated_string',
width: 130,
},
{
key: 'loggedTime',
title: <CustomTableTitle title={t('loggedTimeColumn')} />,
className: 'text-center group-hover:text-[#1890ff]',
dataIndex: 'time_spent_string',
width: 130,
},
{
key: 'overloggedTime',
title: <CustomTableTitle title={t('overloggedTimeColumn')} />,
className: 'text-center group-hover:text-[#1890ff]',
dataIndex: 'overlogged_time',
width: 150,
},
];
return (
<>
<Table
columns={columns}
dataSource={tasksData}
pagination={false}
scroll={{ x: 'max-content' }}
onRow={record => {
return {
style: { height: 38, cursor: 'pointer' },
className: 'group even:bg-[#4e4e4e10]',
};
}}
/>
{createPortal(<TaskDrawer />, document.body, 'task-drawer')}
</>
);
};
export default ProjectReportsMembersTasksTable;

View File

@@ -0,0 +1,84 @@
import React from 'react';
import { Doughnut } from 'react-chartjs-2';
import { Chart, ArcElement, Tooltip } from 'chart.js';
import { Badge, Card, Flex, Typography } from 'antd';
import { useTranslation } from 'react-i18next';
import { IRPTOverviewProjectTasksByDue } from '@/types/reporting/reporting.types';
Chart.register(ArcElement, Tooltip);
const ProjectReportsDueDateGraph = ({
values,
loading,
}: {
values: IRPTOverviewProjectTasksByDue;
loading: boolean;
}) => {
const { t } = useTranslation('reporting-projects-drawer');
// chart data
const chartData = {
labels: values.chart.map(item => t(`${item.name}`)),
datasets: [
{
label: t('tasksText'),
data: values.chart.map(item => item.y),
backgroundColor: values.chart.map(item => item.color),
},
],
};
const options = {
responsive: true,
plugins: {
legend: {
display: false,
position: 'top' as const,
},
datalabels: {
display: false,
},
},
};
return (
<Card
loading={loading}
title={
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
{t('tasksByDueDateText')}
</Typography.Text>
}
>
<div className="flex flex-wrap items-center justify-center gap-6 xl:flex-nowrap">
<Doughnut
data={chartData}
options={options}
className="max-h-[200px] w-full max-w-[200px]"
/>
<div className="flex flex-row flex-wrap gap-3 xl:flex-col">
{/* total tasks */}
<Flex gap={4} align="center">
<Badge color="#000" />
<Typography.Text ellipsis>
{t('allText')} ({values.all})
</Typography.Text>
</Flex>
{/* due Date-specific tasks */}
{values.chart.map(item => (
<Flex key={item.name} gap={4} align="center">
<Badge color={item.color} />
<Typography.Text ellipsis>
{t(`${item.name}`)} ({item.y})
</Typography.Text>
</Flex>
))}
</div>
</div>
</Card>
);
};
export default ProjectReportsDueDateGraph;

View File

@@ -0,0 +1,92 @@
import React, { useEffect, useState } from 'react';
import ProjectReportsStatCard from './ProjectReportsStatCard';
import ProjectReportsStatusGraph from './ProjectReportsStatusGraph';
import ProjectReportsPriorityGraph from './ProjectReportsPriorityGraph';
import ProjectReportsDueDateGraph from './ProjectReportsDueDateGraph';
import { reportingProjectsApiService } from '@/api/reporting/reporting-projects.api.service';
import { IRPTOverviewProjectInfo } from '@/types/reporting/reporting.types';
type ProjectReportsOverviewTabProps = {
projectId?: string | null;
};
const ProjectReportsOverviewTab = ({ projectId = null }: ProjectReportsOverviewTabProps) => {
const [overviewData, setOverviewData] = useState<IRPTOverviewProjectInfo | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const fetchOverviewData = async () => {
if (!projectId || loading) return;
try {
setLoading(true);
const res = await reportingProjectsApiService.getProjectOverview(projectId);
if (res.done) {
setOverviewData(res.body);
}
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchOverviewData();
}, [projectId]);
return (
<div className="grid gap-4 sm:grid-cols-2">
<ProjectReportsStatCard
loading={loading}
values={
overviewData?.stats || {
completed: 0,
incompleted: 0,
overdue: 0,
total_allocated: 0,
total_logged: 0,
}
}
/>
<ProjectReportsStatusGraph
loading={loading}
values={
overviewData?.by_status || {
todo: 0,
doing: 0,
done: 0,
all: 0,
chart: [],
}
}
/>
<ProjectReportsPriorityGraph
loading={loading}
values={
overviewData?.by_priority || {
high: 0,
medium: 0,
low: 0,
all: 0,
chart: [],
}
}
/>
<ProjectReportsDueDateGraph
loading={loading}
values={
overviewData?.by_due || {
completed: 0,
upcoming: 0,
overdue: 0,
no_due: 0,
all: 0,
chart: [],
}
}
/>
</div>
);
};
export default ProjectReportsOverviewTab;

View File

@@ -0,0 +1,91 @@
import React from 'react';
import { Doughnut } from 'react-chartjs-2';
import { Chart, ArcElement, Tooltip } from 'chart.js';
import { Badge, Card, Flex, Typography } from 'antd';
import { useTranslation } from 'react-i18next';
import { IRPTOverviewProjectTasksByPriority } from '@/types/reporting/reporting.types';
Chart.register(ArcElement, Tooltip);
const ProjectReportsPriorityGraph = ({
values,
loading,
}: {
values: IRPTOverviewProjectTasksByPriority;
loading: boolean;
}) => {
// localization
const { t } = useTranslation('reporting-projects-drawer');
type PriorityGraphItemType = {
name: string;
color: string;
count: number;
};
// chart data
const chartData = {
labels: values.chart.map(item => t(`${item.name}`)),
datasets: [
{
label: t('tasksText'),
data: values.chart.map(item => item.y),
backgroundColor: values.chart.map(item => item.color),
},
],
};
const options = {
responsive: true,
plugins: {
legend: {
display: false,
position: 'top' as const,
},
datalabels: {
display: false,
},
},
};
return (
<Card
loading={loading}
title={
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
{t('tasksByPriorityText')}
</Typography.Text>
}
>
<div className="flex flex-wrap items-center justify-center gap-6 xl:flex-nowrap">
<Doughnut
data={chartData}
options={options}
className="max-h-[200px] w-full max-w-[200px]"
/>
<div className="flex flex-row flex-wrap gap-3 xl:flex-col">
{/* total tasks */}
<Flex gap={4} align="center">
<Badge color="#000" />
<Typography.Text ellipsis>
{t('allText')} ({values.all})
</Typography.Text>
</Flex>
{/* priority-specific tasks */}
{values.chart.map(item => (
<Flex key={item.name} gap={4} align="center">
<Badge color={item.color} />
<Typography.Text ellipsis>
{t(`${item.name}`)} ({item.y})
</Typography.Text>
</Flex>
))}
</div>
</div>
</Card>
);
};
export default ProjectReportsPriorityGraph;

View File

@@ -0,0 +1,74 @@
import {
CheckCircleOutlined,
ClockCircleOutlined,
ExclamationCircleOutlined,
FileExcelOutlined,
} from '@ant-design/icons';
import { Card, Flex, Typography } from 'antd';
import React, { ReactNode } from 'react';
import { colors } from '../../../../../styles/colors';
import { useTranslation } from 'react-i18next';
import { IRPTOverviewProjectTasksStats } from '@/types/reporting/reporting.types';
const ProjectReportsStatCard = ({
values,
loading,
}: {
values: IRPTOverviewProjectTasksStats;
loading: boolean;
}) => {
// localization
const { t } = useTranslation('reporting-projects-drawer');
type StatItemsType = {
name: string;
icon: ReactNode;
value: number;
};
// stat items array
const statItems: StatItemsType[] = [
{
name: 'completedTasks',
icon: <CheckCircleOutlined style={{ fontSize: 24, color: '#75c997' }} />,
value: values.completed || 0,
},
{
name: 'incompleteTasks',
icon: <FileExcelOutlined style={{ fontSize: 24, color: '#f6ce69' }} />,
value: values.incompleted || 0,
},
{
name: 'overdueTasks',
icon: <ExclamationCircleOutlined style={{ fontSize: 24, color: '#eb6363' }} />,
value: values.overdue || 0,
},
{
name: 'allocatedHours',
icon: <ClockCircleOutlined style={{ fontSize: 24, color: colors.skyBlue }} />,
value: values.total_allocated || 0,
},
{
name: 'loggedHours',
icon: <ClockCircleOutlined style={{ fontSize: 24, color: '#75c997' }} />,
value: values.total_logged || 0,
},
];
return (
<Card style={{ width: '100%' }} loading={loading}>
<Flex vertical gap={16} style={{ padding: '12px 24px' }}>
{statItems.map(item => (
<Flex gap={12} align="center">
{item.icon}
<Typography.Text>
{item.value} {t(`${item.name}Text`)}
</Typography.Text>
</Flex>
))}
</Flex>
</Card>
);
};
export default ProjectReportsStatCard;

View File

@@ -0,0 +1,83 @@
import React from 'react';
import { Doughnut } from 'react-chartjs-2';
import { Chart, ArcElement, Tooltip } from 'chart.js';
import { Badge, Card, Flex, Typography } from 'antd';
import { useTranslation } from 'react-i18next';
import { IRPTOverviewProjectTasksByStatus } from '@/types/reporting/reporting.types';
Chart.register(ArcElement, Tooltip);
const ProjectReportsStatusGraph = ({
values,
loading,
}: {
values: IRPTOverviewProjectTasksByStatus;
loading: boolean;
}) => {
const { t } = useTranslation('reporting-projects-drawer');
const chartData = {
labels: values.chart.map(item => t(`${item.name}Text`)),
datasets: [
{
label: t('tasksText'),
data: values.chart.map(item => item.y),
backgroundColor: values.chart.map(item => item.color),
},
],
};
const options = {
responsive: true,
plugins: {
legend: {
display: false,
position: 'top' as const,
},
datalabels: {
display: false,
},
},
};
return (
<Card
loading={loading}
title={
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
{t('tasksByStatusText')}
</Typography.Text>
}
>
<div className="flex flex-wrap items-center justify-center gap-6 xl:flex-nowrap">
<Doughnut
data={chartData}
options={options}
className="max-h-[200px] w-full max-w-[200px]"
/>
<div className="flex flex-row flex-wrap gap-3 xl:flex-col">
{/* total tasks */}
<Flex gap={4} align="center">
<Badge color="#000" />
<Typography.Text ellipsis>
{t('allText')} ({values.all})
</Typography.Text>
</Flex>
{/* status-specific tasks */}
{values.chart.map(item => (
<Flex key={item.name} gap={4} align="center">
<Badge color={item.color} />
<Typography.Text ellipsis>
{t(`${item.name}`)}({item.y})
</Typography.Text>
</Flex>
))}
</div>
</div>
</Card>
);
};
export default ProjectReportsStatusGraph;

View File

@@ -0,0 +1,199 @@
import { Badge, Collapse, Flex, Table, TableColumnsType, Tag, Typography } from 'antd';
import { useEffect } from 'react';
import CustomTableTitle from '@/components/CustomTableTitle';
import { colors } from '@/styles/colors';
import dayjs from 'dayjs';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { setShowTaskDrawer, fetchTask, setSelectedTaskId } from '@/features/task-drawer/task-drawer.slice';
import { DoubleRightOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import { fetchPriorities } from '@/features/taskAttributes/taskPrioritySlice';
import { fetchPhasesByProjectId } from '@/features/projects/singleProject/phase/phases.slice';
import { fetchLabels } from '@/features/taskAttributes/taskLabelSlice';
import { getTeamMembers } from '@/features/team-members/team-members.slice';
import { setProjectId } from '@/features/project/project.slice';
type ProjectReportsTasksTableProps = {
tasksData: any[];
title: string;
color: string;
type: string;
projectId: string;
};
const ProjectReportsTasksTable = ({
tasksData,
title,
color,
type,
projectId,
}: ProjectReportsTasksTableProps) => {
// localization
const { t } = useTranslation('reporting-projects-drawer');
const dispatch = useAppDispatch();
useEffect(()=>{
dispatch(fetchPriorities());
dispatch(fetchLabels());
dispatch(getTeamMembers({ index: 0, size: 100, field: null, order: null, search: null, all: true }));
},[dispatch])
// function to handle task drawer open
const handleUpdateTaskDrawer = (id: string) => {
if (!id && !projectId) return;
dispatch(setSelectedTaskId(id));
dispatch(setProjectId(projectId));
dispatch(fetchPhasesByProjectId(projectId));
dispatch(fetchTask({ taskId: id, projectId: projectId }));
dispatch(setShowTaskDrawer(true));
};
const columns: TableColumnsType = [
{
key: 'task',
title: <CustomTableTitle title={t('taskColumn')} />,
onCell: record => {
return {
onClick: () => handleUpdateTaskDrawer(record.id),
};
},
render: record => (
<Flex>
{Number(record.sub_tasks_count) > 0 && <DoubleRightOutlined />}
<Typography.Text className="group-hover:text-[#1890ff]">{record.name}</Typography.Text>
</Flex>
),
width: 260,
className: 'group-hover:text-[#1890ff]',
fixed: 'left' as const,
},
{
key: 'status',
title: <CustomTableTitle title={t('statusColumn')} />,
render: record => (
<Tag
style={{ color: colors.darkGray, borderRadius: 48 }}
color={record.status_color}
children={record.status_name}
/>
),
width: 120,
},
{
key: 'priority',
title: <CustomTableTitle title={t('priorityColumn')} />,
render: record => (
<Tag
style={{ color: colors.darkGray, borderRadius: 48 }}
color={record.priority_color}
children={record.priority_name}
/>
),
width: 120,
},
{
key: 'phase',
title: <CustomTableTitle title={t('phaseColumn')} />,
render: record => (
<Tag
style={{ color: colors.darkGray, borderRadius: 48 }}
color={record.phase_color}
children={record.phase_name}
/>
),
width: 120,
},
{
key: 'dueDate',
title: <CustomTableTitle title={t('dueDateColumn')} />,
render: record => (
<Typography.Text className="text-center group-hover:text-[#1890ff]">
{record.end_date ? `${dayjs(record.end_date).format('MMM DD, YYYY')}` : '-'}
</Typography.Text>
),
width: 120,
},
{
key: 'completedOn',
title: <CustomTableTitle title={t('completedOnColumn')} />,
render: record => (
<Typography.Text className="text-center group-hover:text-[#1890ff]">
{record.completed_at ? `${dayjs(record.completed_at).format('MMM DD, YYYY')}` : '-'}
</Typography.Text>
),
width: 120,
},
{
key: 'daysOverdue',
title: <CustomTableTitle title={t('daysOverdueColumn')} />,
className: 'text-center group-hover:text-[#1890ff]',
dataIndex: 'overdue_days',
width: 120,
},
{
key: 'estimatedTime',
title: <CustomTableTitle title={t('estimatedTimeColumn')} />,
className: 'text-center group-hover:text-[#1890ff]',
dataIndex: 'total_time_string',
width: 130,
},
{
key: 'loggedTime',
title: <CustomTableTitle title={t('loggedTimeColumn')} />,
className: 'text-center group-hover:text-[#1890ff]',
dataIndex: 'time_spent_string',
width: 130,
},
{
key: 'overloggedTime',
title: <CustomTableTitle title={t('overloggedTimeColumn')} />,
className: 'text-center group-hover:text-[#1890ff]',
dataIndex: 'overlogged_time_string',
width: 150,
},
];
// conditionaly show columns with the group type
const visibleColumns = () => {
if (type === 'status') return columns.filter(el => el.key !== 'status');
else if (type === 'priority') return columns.filter(el => el.key !== 'priority');
else if (type === 'phase') return columns.filter(el => el.key !== 'phase');
else return columns;
};
return (
<Collapse
bordered={false}
ghost={true}
size="small"
items={[
{
key: '1',
label: (
<Flex gap={8} align="center">
<Badge color={color} />
<Typography.Text strong>{`${title} (${tasksData.length})`}</Typography.Text>
</Flex>
),
children: (
<Table
columns={visibleColumns()}
dataSource={tasksData}
pagination={false}
scroll={{ x: 'max-content' }}
onRow={record => {
return {
style: { height: 38, cursor: 'pointer' },
className: 'group even:bg-[#4e4e4e10]',
};
}}
/>
),
},
]}
/>
);
};
export default ProjectReportsTasksTable;

View File

@@ -0,0 +1,90 @@
import { Flex } from 'antd';
import React, { useEffect, useMemo, useState } from 'react';
import CustomSearchbar from '@components/CustomSearchbar';
import GroupByFilter from './group-by-filter';
import ProjectReportsTasksTable from './ProjectReportsTaskTable';
import { fetchData } from '@/utils/fetchData';
import { useTranslation } from 'react-i18next';
import logger from '@/utils/errorLogger';
import { reportingProjectsApiService } from '@/api/reporting/reporting-projects.api.service';
import { IGroupByOption, ITaskListGroup } from '@/types/tasks/taskList.types';
import { GROUP_BY_STATUS_VALUE, IGroupBy } from '@/features/board/board-slice';
import { createPortal } from 'react-dom';
const TaskDrawer = React.lazy(() => import('@components/task-drawer/task-drawer'));
type ProjectReportsTasksTabProps = {
projectId?: string | null;
};
const ProjectReportsTasksTab = ({ projectId = null }: ProjectReportsTasksTabProps) => {
const [searchQuery, setSearhQuery] = useState<string>('');
const [loading, setLoading] = useState<boolean>(false);
const [groups, setGroups] = useState<ITaskListGroup[]>([]);
const [groupBy, setGroupBy] = useState<IGroupBy>(GROUP_BY_STATUS_VALUE);
const { t } = useTranslation('reporting-projects-drawer');
const filteredGroups = useMemo(() => {
return groups
.filter(item => item.tasks.length > 0)
.map(item => ({
...item,
tasks: item.tasks.filter(task =>
task.name?.toLowerCase().includes(searchQuery.toLowerCase())
)
}))
.filter(item => item.tasks.length > 0);
}, [groups, searchQuery]);
const fetchTasksData = async () => {
if (!projectId || loading) return;
try {
setLoading(true);
const res = await reportingProjectsApiService.getTasks(projectId, groupBy);
if (res.done) {
setGroups(res.body);
}
} catch (error) {
logger.error('Error fetching tasks data', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchTasksData();
}, [projectId, groupBy]);
return (
<Flex vertical gap={24}>
<Flex gap={24} align="center" justify="space-between">
<CustomSearchbar
placeholderText={t('searchByNameInputPlaceholder')}
searchQuery={searchQuery}
setSearchQuery={setSearhQuery}
/>
<GroupByFilter setActiveGroup={setGroupBy} />
</Flex>
<Flex vertical gap={12}>
{filteredGroups.map(item => (
<ProjectReportsTasksTable
key={item.id}
tasksData={item.tasks}
title={item.name}
color={item.color_code}
type={groupBy}
projectId={projectId || ''}
/>
))}
</Flex>
{createPortal(<TaskDrawer />, document.body, 'task-drawer')}
</Flex>
);
};
export default ProjectReportsTasksTab;

View File

@@ -0,0 +1,42 @@
import { IGroupBy } from '@/features/board/board-slice';
import { CaretDownFilled } from '@ant-design/icons';
import { Flex, Select } from 'antd';
import React from 'react';
import { useTranslation } from 'react-i18next';
type GroupByFilterProps = {
setActiveGroup: (group: IGroupBy) => void;
};
const GroupByFilter = ({ setActiveGroup }: GroupByFilterProps) => {
// localization
const { t } = useTranslation('reporting-projects-drawer');
const handleChange = (value: string) => {
setActiveGroup(value as IGroupBy);
};
const groupDropdownMenuItems = [
{ key: 'status', value: 'status', label: t('statusText') },
{ key: 'priority', value: 'priority', label: t('priorityText') },
{
key: 'phase',
value: 'phase',
label: t('phaseText'),
},
];
return (
<Flex align="center" gap={4} style={{ marginInlineStart: 12 }}>
{t('groupByText')}
<Select
defaultValue={'status'}
options={groupDropdownMenuItems}
onChange={handleChange}
suffixIcon={<CaretDownFilled />}
/>
</Flex>
);
};
export default GroupByFilter;

View File

@@ -0,0 +1,66 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface ReportingState {
includeArchivedProjects: boolean;
selectedProjectIds: string[];
selectedTeamIds: string[];
showOverViewTeamDrawer: boolean;
duration: string;
dateRange: string[];
currentOrganization: string;
}
const initialState: ReportingState = {
includeArchivedProjects: false,
selectedProjectIds: [],
selectedTeamIds: [],
showOverViewTeamDrawer: false,
duration: 'LAST_WEEK', // Default value
dateRange: [],
currentOrganization: '',
};
const reportingSlice = createSlice({
name: 'reporting',
initialState,
reducers: {
toggleIncludeArchived: state => {
state.includeArchivedProjects = !state.includeArchivedProjects;
},
setSelectedProjects: (state, action: PayloadAction<string[]>) => {
state.selectedProjectIds = action.payload;
},
setSelectedTeams: (state, action: PayloadAction<string[]>) => {
state.selectedTeamIds = action.payload;
},
clearSelections: state => {
state.selectedProjectIds = [];
state.selectedTeamIds = [];
},
toggleOverViewTeamDrawer: state => {
state.showOverViewTeamDrawer = !state.showOverViewTeamDrawer;
},
setDuration: (state, action: PayloadAction<string>) => {
state.duration = action.payload;
},
setDateRange: (state, action: PayloadAction<string[]>) => {
state.dateRange = action.payload;
},
setCurrentOrganization: (state, action: PayloadAction<string>) => {
state.currentOrganization = action.payload;
},
},
});
export const {
toggleIncludeArchived,
setSelectedProjects,
setSelectedTeams,
clearSelections,
toggleOverViewTeamDrawer,
setDuration,
setDateRange,
setCurrentOrganization,
} = reportingSlice.actions;
export default reportingSlice.reducer;

View File

@@ -0,0 +1,203 @@
import { reportingApiService } from '@/api/reporting/reporting.api.service';
import {
ISelectableCategory,
ISelectableProject,
ISelectableTeam,
} from '@/types/reporting/reporting-filters.types';
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
interface ITimeReportsOverviewState {
archived: boolean;
teams: ISelectableTeam[];
loadingTeams: boolean;
categories: ISelectableCategory[];
noCategory: boolean;
loadingCategories: boolean;
projects: ISelectableProject[];
loadingProjects: boolean;
billable: {
billable: boolean;
nonBillable: boolean;
};
}
const initialState: ITimeReportsOverviewState = {
archived: false,
teams: [],
loadingTeams: false,
categories: [],
noCategory: true,
loadingCategories: false,
projects: [],
loadingProjects: false,
billable: {
billable: true,
nonBillable: true,
},
};
const selectedTeams = (state: ITimeReportsOverviewState) => {
return state.teams.filter(team => team.selected).map(team => team.id) as string[];
};
const selectedCategories = (state: ITimeReportsOverviewState) => {
return state.categories
.filter(category => category.selected)
.map(category => category.id) as string[];
};
export const fetchReportingTeams = createAsyncThunk(
'timeReportsOverview/fetchReportingTeams',
async () => {
const res = await reportingApiService.getOverviewTeams();
return res.body;
}
);
export const fetchReportingCategories = createAsyncThunk(
'timeReportsOverview/fetchReportingCategories',
async (_, { rejectWithValue, getState, dispatch }) => {
const state = getState() as { timeReportsOverviewReducer: ITimeReportsOverviewState };
const { timeReportsOverviewReducer } = state;
const res = await reportingApiService.getCategories(selectedTeams(timeReportsOverviewReducer));
return res.body;
}
);
export const fetchReportingProjects = createAsyncThunk(
'timeReportsOverview/fetchReportingProjects',
async (_, { rejectWithValue, getState, dispatch }) => {
const state = getState() as { timeReportsOverviewReducer: ITimeReportsOverviewState };
const { timeReportsOverviewReducer } = state;
const res = await reportingApiService.getAllocationProjects(
selectedTeams(timeReportsOverviewReducer),
selectedCategories(timeReportsOverviewReducer),
timeReportsOverviewReducer.noCategory
);
return res.body;
}
);
const timeReportsOverviewSlice = createSlice({
name: 'timeReportsOverview',
initialState,
reducers: {
setTeams: (state, action) => {
state.teams = action.payload;
},
setSelectOrDeselectAllTeams: (state, action) => {
state.teams.forEach(team => {
team.selected = action.payload;
});
},
setSelectOrDeselectTeam: (state, action: PayloadAction<{ id: string; selected: boolean }>) => {
const team = state.teams.find(team => team.id === action.payload.id);
if (team) {
team.selected = action.payload.selected;
}
},
setSelectOrDeselectCategory: (
state,
action: PayloadAction<{ id: string; selected: boolean }>
) => {
const category = state.categories.find(category => category.id === action.payload.id);
if (category) {
category.selected = action.payload.selected;
}
},
setSelectOrDeselectAllCategories: (state, action) => {
state.categories.forEach(category => {
category.selected = action.payload;
});
},
setSelectOrDeselectProject: (state, action) => {
const project = state.projects.find(project => project.id === action.payload.id);
if (project) {
project.selected = action.payload.selected;
}
},
setSelectOrDeselectAllProjects: (state, action) => {
state.projects.forEach(project => {
project.selected = action.payload;
});
},
setSelectOrDeselectBillable: (state, action) => {
state.billable = action.payload;
},
setNoCategory: (state, action: PayloadAction<boolean>) => {
state.noCategory = action.payload;
},
setArchived: (state, action: PayloadAction<boolean>) => {
state.archived = action.payload;
},
},
extraReducers: builder => {
builder.addCase(fetchReportingTeams.fulfilled, (state, action) => {
const teams = [];
for (const team of action.payload) {
teams.push({ selected: true, name: team.name, id: team.id });
}
state.teams = teams;
state.loadingTeams = false;
});
builder.addCase(fetchReportingTeams.pending, state => {
state.loadingTeams = true;
});
builder.addCase(fetchReportingTeams.rejected, state => {
state.loadingTeams = false;
});
builder.addCase(fetchReportingCategories.fulfilled, (state, action) => {
const categories = [];
for (const category of action.payload) {
categories.push({ selected: true, name: category.name, id: category.id });
}
state.categories = categories;
state.loadingCategories = false;
});
builder.addCase(fetchReportingCategories.pending, state => {
state.loadingCategories = true;
});
builder.addCase(fetchReportingCategories.rejected, state => {
state.loadingCategories = false;
});
builder.addCase(fetchReportingProjects.fulfilled, (state, action) => {
const projects = [];
for (const project of action.payload) {
projects.push({ selected: true, name: project.name, id: project.id });
}
state.projects = projects;
state.loadingProjects = false;
});
builder.addCase(fetchReportingProjects.pending, state => {
state.loadingProjects = true;
});
builder.addCase(fetchReportingProjects.rejected, state => {
state.loadingProjects = false;
});
},
});
export const {
setTeams,
setSelectOrDeselectAllTeams,
setSelectOrDeselectTeam,
setSelectOrDeselectCategory,
setSelectOrDeselectAllCategories,
setSelectOrDeselectProject,
setSelectOrDeselectAllProjects,
setSelectOrDeselectBillable,
setNoCategory,
setArchived,
} = timeReportsOverviewSlice.actions;
export default timeReportsOverviewSlice.reducer;