init
This commit is contained in:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
66
worklenz-frontend/src/features/reporting/reporting.slice.ts
Normal file
66
worklenz-frontend/src/features/reporting/reporting.slice.ts
Normal 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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user