init
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
import { Card, Flex, Typography } from 'antd';
|
||||
import TaskByMembersTable from './tables/tasks-by-members';
|
||||
|
||||
import MemberStats from '../member-stats/member-stats';
|
||||
import { TFunction } from 'i18next';
|
||||
|
||||
const InsightsMembers = ({ t }: { t: TFunction }) => {
|
||||
return (
|
||||
<Flex vertical gap={24}>
|
||||
<MemberStats />
|
||||
|
||||
<Card
|
||||
className="custom-insights-card"
|
||||
title={
|
||||
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
|
||||
{t('members.tasksByMembers')}
|
||||
</Typography.Text>
|
||||
}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<TaskByMembersTable />
|
||||
</Card>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default InsightsMembers;
|
||||
@@ -0,0 +1,141 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Flex, Tooltip, Typography } from 'antd';
|
||||
|
||||
import { projectInsightsApiService } from '@/api/projects/insights/project-insights.api.service';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { IInsightTasks } from '@/types/project/projectInsights.types';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { simpleDateFormat } from '@/utils/simpleDateFormat';
|
||||
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||
|
||||
interface AssignedTasksListTableProps {
|
||||
memberId: string;
|
||||
projectId: string;
|
||||
archived: boolean;
|
||||
}
|
||||
|
||||
const columnsList = [
|
||||
{ key: 'name', columnHeader: 'Name', width: 280 },
|
||||
{ key: 'status', columnHeader: 'Status', width: 100 },
|
||||
{ key: 'dueDate', columnHeader: 'Due Date', width: 150 },
|
||||
{ key: 'overdue', columnHeader: 'Days Overdue', width: 150 },
|
||||
{ key: 'completedDate', columnHeader: 'Completed Date', width: 150 },
|
||||
{ key: 'totalAllocation', columnHeader: 'Total Allocation', width: 150 },
|
||||
{ key: 'overLoggedTime', columnHeader: 'Over Logged Time', width: 150 },
|
||||
];
|
||||
|
||||
const AssignedTasksListTable: React.FC<AssignedTasksListTableProps> = ({
|
||||
memberId,
|
||||
projectId,
|
||||
archived,
|
||||
}) => {
|
||||
const [memberTasks, setMemberTasks] = useState<IInsightTasks[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
useEffect(() => {
|
||||
const getTasksByMemberId = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await projectInsightsApiService.getMemberTasks({
|
||||
member_id: memberId,
|
||||
project_id: projectId,
|
||||
archived,
|
||||
});
|
||||
if (res.done) {
|
||||
setMemberTasks(res.body);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching member tasks:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
getTasksByMemberId();
|
||||
}, [memberId, projectId, archived]);
|
||||
|
||||
const renderColumnContent = (key: string, task: IInsightTasks) => {
|
||||
switch (key) {
|
||||
case 'name':
|
||||
return (
|
||||
<Tooltip title={task.name}>
|
||||
<Typography.Text>{task.name}</Typography.Text>
|
||||
</Tooltip>
|
||||
);
|
||||
case 'status':
|
||||
return (
|
||||
<Flex
|
||||
gap={4}
|
||||
style={{
|
||||
width: 'fit-content',
|
||||
borderRadius: 24,
|
||||
paddingInline: 6,
|
||||
backgroundColor: task.status_color,
|
||||
color: colors.darkGray,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Typography.Text
|
||||
ellipsis={{ expanded: false }}
|
||||
style={{
|
||||
color: colors.darkGray,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{task.status}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
);
|
||||
case 'dueDate':
|
||||
return task.end_date ? simpleDateFormat(task.end_date) : 'N/A';
|
||||
case 'overdue':
|
||||
return task.days_overdue ?? 'N/A';
|
||||
case 'completedDate':
|
||||
return task.completed_at ? simpleDateFormat(task.completed_at) : 'N/A';
|
||||
case 'totalAllocation':
|
||||
return task.total_minutes ?? 'N/A';
|
||||
case 'overLoggedTime':
|
||||
return task.overlogged_time ?? 'N/A';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-0 max-w-full overflow-x-auto py-2 pl-12 pr-4"
|
||||
style={{ backgroundColor: themeWiseColor('#f0f2f5', '#000', themeMode) }}
|
||||
>
|
||||
<table className="w-full min-w-max border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
{columnsList.map(column => (
|
||||
<th
|
||||
key={column.key}
|
||||
className="p-2 text-left"
|
||||
style={{ width: column.width, fontWeight: 500 }}
|
||||
>
|
||||
{column.columnHeader}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{memberTasks.map(task => (
|
||||
<tr key={task.id} className="h-[42px] border-t">
|
||||
{columnsList.map(column => (
|
||||
<td key={column.key} className="p-2" style={{ width: column.width }}>
|
||||
{renderColumnContent(column.key, task)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AssignedTasksListTable;
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Flex, Progress } from 'antd';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||
import { DownOutlined, ExclamationCircleOutlined, RightOutlined } from '@ant-design/icons';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { projectsApiService } from '@/api/projects/projects.api.service';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IProjectOverviewStats } from '@/types/project/projectsViewModel.types';
|
||||
import { ITeamMemberOverviewGetResponse } from '@/types/project/project-insights.types';
|
||||
import React from 'react';
|
||||
import AssignedTasksListTable from './assigned-tasks-list';
|
||||
|
||||
const TaskByMembersTable = () => {
|
||||
const { includeArchivedTasks, projectId } = useAppSelector(state => state.projectInsightsReducer);
|
||||
|
||||
const [expandedRows, setExpandedRows] = useState<string[]>([]);
|
||||
const [memberList, setMemberList] = useState<ITeamMemberOverviewGetResponse[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
const { t } = useTranslation('project-view-insights');
|
||||
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
const getProjectOverviewMembers = async () => {
|
||||
if (!projectId) return;
|
||||
try {
|
||||
const res = await projectsApiService.getOverViewMembersById(projectId);
|
||||
if (res.done) {
|
||||
setMemberList(res.body);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching member tasks:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
setLoading(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getProjectOverviewMembers();
|
||||
}, [projectId,refreshTimestamp]);
|
||||
|
||||
// toggle members row expansions
|
||||
const toggleRowExpansion = (memberId: string) => {
|
||||
setExpandedRows(prev =>
|
||||
prev.includes(memberId) ? prev.filter(id => id !== memberId) : [...prev, memberId]
|
||||
);
|
||||
};
|
||||
|
||||
// columns list
|
||||
const columnsList = [
|
||||
{ key: 'name', columnHeader: t('members.name'), width: 200 },
|
||||
{ key: 'taskCount', columnHeader: t('members.taskCount'), width: 100 },
|
||||
{ key: 'contribution', columnHeader: t('members.contribution'), width: 120 },
|
||||
{ key: 'completed', columnHeader: t('members.completed'), width: 100 },
|
||||
{ key: 'incomplete', columnHeader: t('members.incomplete'), width: 100 },
|
||||
{ key: 'overdue', columnHeader: t('members.overdue'), width: 100 },
|
||||
{ key: 'progress', columnHeader: t('members.progress'), width: 150 },
|
||||
];
|
||||
|
||||
// render content, based on column type
|
||||
const renderColumnContent = (key: string, member: ITeamMemberOverviewGetResponse) => {
|
||||
switch (key) {
|
||||
case 'name':
|
||||
return (
|
||||
<Flex gap={8} align="center">
|
||||
{member?.task_count && (
|
||||
<button onClick={() => toggleRowExpansion(member.id)}>
|
||||
{expandedRows.includes(member.id) ? <DownOutlined /> : <RightOutlined />}
|
||||
</button>
|
||||
)}
|
||||
{member.overdue_task_count ? (
|
||||
<ExclamationCircleOutlined style={{ color: colors.vibrantOrange }} />
|
||||
) : (
|
||||
<div style={{ width: 14, height: 14 }}></div>
|
||||
)}
|
||||
{member.name}
|
||||
</Flex>
|
||||
);
|
||||
case 'taskCount':
|
||||
return member.task_count;
|
||||
case 'contribution':
|
||||
return `${member.contribution}%`;
|
||||
case 'completed':
|
||||
return member.done_task_count;
|
||||
case 'incomplete':
|
||||
return member.pending_task_count;
|
||||
case 'overdue':
|
||||
return member.overdue_task_count;
|
||||
case 'progress':
|
||||
return (
|
||||
<Progress
|
||||
percent={Math.floor(((member.done_task_count ?? 0) / (member.task_count ?? 1)) * 100)}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="memberList-container min-h-0 max-w-full overflow-x-auto">
|
||||
<table className="w-full min-w-max border-collapse rounded">
|
||||
<thead
|
||||
style={{
|
||||
height: 42,
|
||||
backgroundColor: themeWiseColor('#f8f7f9', '#1d1d1d', themeMode),
|
||||
}}
|
||||
>
|
||||
<tr>
|
||||
{columnsList.map(column => (
|
||||
<th
|
||||
key={column.key}
|
||||
className={`p-2`}
|
||||
style={{ width: column.width, fontWeight: 500 }}
|
||||
>
|
||||
{column.columnHeader}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{memberList?.map(member => (
|
||||
<React.Fragment key={member.id}>
|
||||
<tr key={member.id} className="h-[42px] cursor-pointer">
|
||||
{columnsList.map(column => (
|
||||
<td
|
||||
key={column.key}
|
||||
className={`border-t p-2 text-center`}
|
||||
style={{
|
||||
width: column.width,
|
||||
}}
|
||||
>
|
||||
{renderColumnContent(column.key, member)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
|
||||
{expandedRows.includes(member.id) && (
|
||||
<tr>
|
||||
<td colSpan={columnsList.length}>
|
||||
<AssignedTasksListTable
|
||||
memberId={member.id}
|
||||
projectId={projectId}
|
||||
archived={includeArchivedTasks}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskByMembersTable;
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Bar } from 'react-chartjs-2';
|
||||
import { Chart, ArcElement, Tooltip, CategoryScale, LinearScale, BarElement } from 'chart.js';
|
||||
import { ChartOptions } from 'chart.js';
|
||||
import { Flex } from 'antd';
|
||||
import { ITaskPriorityCounts } from '@/types/project/project-insights.types';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { projectInsightsApiService } from '@/api/projects/insights/project-insights.api.service';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { Spin } from 'antd/lib';
|
||||
|
||||
Chart.register(ArcElement, Tooltip, CategoryScale, LinearScale, BarElement);
|
||||
|
||||
const PriorityOverview = () => {
|
||||
const { includeArchivedTasks, projectId } = useAppSelector(state => state.projectInsightsReducer);
|
||||
|
||||
const [stats, setStats] = useState<ITaskPriorityCounts[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
|
||||
const getTaskPriorityCounts = async () => {
|
||||
if (!projectId) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await projectInsightsApiService.getPriorityOverview(
|
||||
projectId,
|
||||
includeArchivedTasks
|
||||
);
|
||||
if (res.done) {
|
||||
setStats(res.body);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching task priority counts:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getTaskPriorityCounts();
|
||||
}, [projectId, includeArchivedTasks, refreshTimestamp]);
|
||||
|
||||
const options: ChartOptions<'bar'> = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: {
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Priority',
|
||||
align: 'end',
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(200, 200, 200, 0.5)',
|
||||
},
|
||||
},
|
||||
y: {
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Task Count',
|
||||
align: 'end',
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(200, 200, 200, 0.5)',
|
||||
},
|
||||
beginAtZero: true,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
position: 'top' as const,
|
||||
},
|
||||
datalabels: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const data = {
|
||||
labels: stats.map(stat => stat.name),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Tasks',
|
||||
data: stats.map(stat => stat.data),
|
||||
backgroundColor: stats.map(stat => stat.color),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockPriorityData = {
|
||||
labels: ['Low', 'Medium', 'High'],
|
||||
datasets: [
|
||||
{
|
||||
label: 'Tasks',
|
||||
data: [6, 12, 2],
|
||||
backgroundColor: ['#75c997', '#fbc84c', '#f37070'],
|
||||
hoverBackgroundColor: ['#46d980', '#ffc227', '#ff4141'],
|
||||
},
|
||||
],
|
||||
};
|
||||
if (loading) {
|
||||
return (
|
||||
<Flex justify="center" align="center" style={{ height: 350 }}>
|
||||
<Spin size="large" />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex justify="center">
|
||||
{loading && <Spin />}
|
||||
<Bar options={options} data={data} className="h-[350px] w-full md:max-w-[580px]" />
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default PriorityOverview;
|
||||
@@ -0,0 +1,107 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Doughnut } from 'react-chartjs-2';
|
||||
import { Chart, ArcElement } from 'chart.js';
|
||||
import { Badge, Flex, Tooltip, Typography, Spin } from 'antd';
|
||||
import { ChartOptions } from 'chart.js';
|
||||
import { projectInsightsApiService } from '@/api/projects/insights/project-insights.api.service';
|
||||
import { ITaskStatusCounts } from '@/types/project/project-insights.types';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
|
||||
Chart.register(ArcElement);
|
||||
|
||||
const StatusOverview = () => {
|
||||
const { includeArchivedTasks, projectId } = useAppSelector(state => state.projectInsightsReducer);
|
||||
|
||||
const [stats, setStats] = useState<ITaskStatusCounts[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
|
||||
const getTaskStatusCounts = async () => {
|
||||
if (!projectId) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await projectInsightsApiService.getTaskStatusCounts(
|
||||
projectId,
|
||||
includeArchivedTasks
|
||||
);
|
||||
if (res.done) {
|
||||
setStats(res.body);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching task status counts:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getTaskStatusCounts();
|
||||
}, [projectId, includeArchivedTasks,refreshTimestamp]);
|
||||
|
||||
const options: ChartOptions<'doughnut'> = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
datalabels: {
|
||||
display: false,
|
||||
},
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: context => {
|
||||
const value = context.raw as number;
|
||||
return `${context.label}: ${value} task${value !== 1 ? 's' : ''}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const data = {
|
||||
labels: stats.map(status => status.name),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Tasks',
|
||||
data: stats.map(status => status.y),
|
||||
backgroundColor: stats.map(status => status.color),
|
||||
hoverBackgroundColor: stats.map(status => status.color + '90'),
|
||||
borderWidth: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Flex justify="center" align="center" style={{ height: 350 }}>
|
||||
<Spin size="large" />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex gap={24} wrap="wrap-reverse" justify="center">
|
||||
{loading && <Spin />}
|
||||
<div style={{ position: 'relative', height: 350, width: '100%', maxWidth: 350 }}>
|
||||
<Doughnut options={options} data={data} />
|
||||
</div>
|
||||
|
||||
<Flex gap={12} style={{ marginBlockStart: 12 }} wrap="wrap" className="flex-row xl:flex-col">
|
||||
{stats.map(status => (
|
||||
<Flex key={status.name} gap={8} align="center">
|
||||
<Badge color={status.color} />
|
||||
<Typography.Text>
|
||||
{status.name}
|
||||
<span style={{ marginLeft: 4 }}>({status.y})</span>
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
))}
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatusOverview;
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Button, Card, Flex, Typography } from 'antd';
|
||||
|
||||
import StatusOverview from './graphs/status-overview';
|
||||
import PriorityOverview from './graphs/priority-overview';
|
||||
import LastUpdatedTasks from './tables/last-updated-tasks';
|
||||
import ProjectDeadline from './tables/project-deadline';
|
||||
import ProjectStats from '../project-stats/project-stats';
|
||||
import { TFunction } from 'i18next';
|
||||
|
||||
const InsightsOverview = ({ t }: { t: TFunction }) => {
|
||||
return (
|
||||
<Flex vertical gap={24}>
|
||||
<ProjectStats t={t} />
|
||||
|
||||
<Flex gap={24} className="grid md:grid-cols-2">
|
||||
<Card
|
||||
className="custom-insights-card"
|
||||
title={
|
||||
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
|
||||
{t('overview.statusOverview')}
|
||||
</Typography.Text>
|
||||
}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<StatusOverview />
|
||||
</Card>
|
||||
<Card
|
||||
className="custom-insights-card"
|
||||
title={
|
||||
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
|
||||
{t('overview.priorityOverview')}
|
||||
</Typography.Text>
|
||||
}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<PriorityOverview />
|
||||
</Card>
|
||||
</Flex>
|
||||
|
||||
<Flex gap={24} className="grid lg:grid-cols-2">
|
||||
<Card
|
||||
className="custom-insights-card"
|
||||
title={
|
||||
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
|
||||
{t('overview.lastUpdatedTasks')}
|
||||
</Typography.Text>
|
||||
}
|
||||
extra={<Button type="link">{t('common.seeAll')}</Button>}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<LastUpdatedTasks />
|
||||
</Card>
|
||||
|
||||
<ProjectDeadline />
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default InsightsOverview;
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Flex, Table, Tooltip, Typography } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { TableProps } from 'antd/lib';
|
||||
import { simpleDateFormat } from '@/utils/simpleDateFormat';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { IInsightTasks } from '@/types/project/projectInsights.types';
|
||||
import { projectInsightsApiService } from '@/api/projects/insights/project-insights.api.service';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { formatDateTimeWithLocale } from '@/utils/format-date-time-with-locale';
|
||||
import { calculateTimeDifference } from '@/utils/calculate-time-difference';
|
||||
|
||||
const LastUpdatedTasks = () => {
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const { includeArchivedTasks, projectId } = useAppSelector(state => state.projectInsightsReducer);
|
||||
|
||||
const [data, setData] = useState<IInsightTasks[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
|
||||
const getLastUpdatedTasks = async () => {
|
||||
if (!projectId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await projectInsightsApiService.getLastUpdatedTasks(
|
||||
projectId,
|
||||
includeArchivedTasks
|
||||
);
|
||||
if (res.done) {
|
||||
setData(res.body);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('getLastUpdatedTasks', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getLastUpdatedTasks();
|
||||
}, [projectId, includeArchivedTasks,refreshTimestamp]);
|
||||
|
||||
// table columns
|
||||
const columns: TableProps['columns'] = [
|
||||
{
|
||||
key: 'name',
|
||||
title: 'Name',
|
||||
render: (record: IInsightTasks) => <Typography.Text>{record.name}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
title: 'Status',
|
||||
render: (record: IInsightTasks) => (
|
||||
<Flex
|
||||
gap={4}
|
||||
style={{
|
||||
width: 'fit-content',
|
||||
borderRadius: 24,
|
||||
paddingInline: 6,
|
||||
backgroundColor: record.status_color,
|
||||
color: colors.darkGray,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Typography.Text
|
||||
ellipsis={{ expanded: false }}
|
||||
style={{
|
||||
color: colors.darkGray,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{record.status}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'dueDate',
|
||||
title: 'Due Date',
|
||||
render: (record: IInsightTasks) => (
|
||||
<Typography.Text>
|
||||
{record.end_date ? simpleDateFormat(record.end_date) : 'N/A'}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'lastUpdated',
|
||||
title: 'Last Updated',
|
||||
render: (record: IInsightTasks) => (
|
||||
<Tooltip title={record.updated_at ? formatDateTimeWithLocale(record.updated_at) : 'N/A'}>
|
||||
<Typography.Text>
|
||||
{record.updated_at ? calculateTimeDifference(record.updated_at) : 'N/A'}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const dataSource = data.map(record => ({
|
||||
...record,
|
||||
key: record.id,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Table
|
||||
className="custom-two-colors-row-table"
|
||||
dataSource={dataSource}
|
||||
columns={columns}
|
||||
rowKey={record => record.id}
|
||||
pagination={{
|
||||
showSizeChanger: true,
|
||||
defaultPageSize: 20,
|
||||
}}
|
||||
loading={loading}
|
||||
onRow={() => {
|
||||
return {
|
||||
style: {
|
||||
cursor: 'pointer',
|
||||
height: 36,
|
||||
},
|
||||
};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default LastUpdatedTasks;
|
||||
@@ -0,0 +1,137 @@
|
||||
import { Card, Flex, Skeleton, Table, Typography } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { TableProps } from 'antd/lib';
|
||||
import { simpleDateFormat } from '@/utils/simpleDateFormat';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { projectInsightsApiService } from '@/api/projects/insights/project-insights.api.service';
|
||||
import { IDeadlineTaskStats, IInsightTasks } from '@/types/project/project-insights.types';
|
||||
import ProjectStatsCard from '@/components/projects/project-stats-card';
|
||||
import warningIcon from '@assets/icons/insightsIcons/warning.png';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
|
||||
const ProjectDeadline = () => {
|
||||
const { includeArchivedTasks, projectId } = useAppSelector(state => state.projectInsightsReducer);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [data, setData] = useState<IDeadlineTaskStats | null>(null);
|
||||
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
const getProjectDeadline = async () => {
|
||||
if (!projectId) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await projectInsightsApiService.getProjectDeadlineStats(
|
||||
projectId,
|
||||
includeArchivedTasks
|
||||
);
|
||||
if (res.done) {
|
||||
setData(res.body);
|
||||
}
|
||||
} catch {
|
||||
logger.error('Error fetching project deadline stats', { projectId, includeArchivedTasks });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getProjectDeadline();
|
||||
}, [projectId, includeArchivedTasks,refreshTimestamp]);
|
||||
|
||||
// table columns
|
||||
const columns: TableProps['columns'] = [
|
||||
{
|
||||
key: 'name',
|
||||
title: 'Name',
|
||||
render: (record: IInsightTasks) => <Typography.Text>{record.name}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
title: 'Status',
|
||||
render: (record: IInsightTasks) => (
|
||||
<Flex
|
||||
gap={4}
|
||||
style={{
|
||||
width: 'fit-content',
|
||||
borderRadius: 24,
|
||||
paddingInline: 6,
|
||||
backgroundColor: record.status_color,
|
||||
color: colors.darkGray,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Typography.Text
|
||||
ellipsis={{ expanded: false }}
|
||||
style={{
|
||||
color: colors.darkGray,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{record.status}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'dueDate',
|
||||
title: 'Due Date',
|
||||
render: (record: IInsightTasks) => (
|
||||
<Typography.Text>
|
||||
{record.end_date ? simpleDateFormat(record.end_date) : 'N/A'}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="custom-insights-card"
|
||||
title={
|
||||
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
|
||||
Project Deadline <span style={{ color: colors.lightGray }}>{data?.project_end_date}</span>
|
||||
</Typography.Text>
|
||||
}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Flex vertical gap={24}>
|
||||
<Flex gap={12} style={{ width: '100%' }}>
|
||||
<Skeleton active loading={loading}>
|
||||
<ProjectStatsCard
|
||||
icon={warningIcon}
|
||||
title="Overdue tasks (hours)"
|
||||
tooltip={'Tasks that has time logged past the end date of the project'}
|
||||
children={data?.deadline_logged_hours_string || 'N/A'}
|
||||
/>
|
||||
<ProjectStatsCard
|
||||
icon={warningIcon}
|
||||
title="Overdue tasks"
|
||||
tooltip={'Tasks that are past the end date of the project'}
|
||||
children={data?.deadline_tasks_count || 'N/A'}
|
||||
/>
|
||||
</Skeleton>
|
||||
</Flex>
|
||||
<Table
|
||||
className="custom-two-colors-row-table"
|
||||
dataSource={data?.tasks}
|
||||
columns={columns}
|
||||
rowKey={record => record.taskId}
|
||||
pagination={{
|
||||
showSizeChanger: true,
|
||||
defaultPageSize: 20,
|
||||
}}
|
||||
onRow={record => {
|
||||
return {
|
||||
style: {
|
||||
cursor: 'pointer',
|
||||
height: 36,
|
||||
},
|
||||
};
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectDeadline;
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Button, Card, Flex, Tooltip, Typography } from 'antd';
|
||||
import { ExclamationCircleOutlined } from '@ant-design/icons';
|
||||
import { colors } from '@/styles/colors';
|
||||
import OverdueTasksTable from './tables/overdue-tasks-table';
|
||||
import OverLoggedTasksTable from './tables/over-logged-tasks-table';
|
||||
import TaskCompletedEarlyTable from './tables/task-completed-early-table';
|
||||
import TaskCompletedLateTable from './tables/task-completed-late-table';
|
||||
import ProjectStats from '../project-stats/project-stats';
|
||||
import { TFunction } from 'i18next';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
|
||||
const InsightsTasks = ({ t }: { t: TFunction }) => {
|
||||
const { includeArchivedTasks, projectId } = useAppSelector(state => state.projectInsightsReducer);
|
||||
|
||||
return (
|
||||
<Flex vertical gap={24}>
|
||||
<ProjectStats t={t} />
|
||||
|
||||
<Flex gap={24} className="grid lg:grid-cols-2">
|
||||
<Card
|
||||
className="custom-insights-card"
|
||||
title={
|
||||
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
|
||||
{t('tasks.overdueTasks')}
|
||||
<Tooltip title={t('tasks.overdueTasksTooltip')}>
|
||||
<ExclamationCircleOutlined
|
||||
style={{
|
||||
color: colors.skyBlue,
|
||||
fontSize: 13,
|
||||
marginInlineStart: 4,
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Typography.Text>
|
||||
}
|
||||
extra={<Button type="link">{t('common.seeAll')}</Button>}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<OverdueTasksTable projectId={projectId} includeArchivedTasks={includeArchivedTasks} />
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className="custom-insights-card"
|
||||
title={
|
||||
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
|
||||
{t('tasks.overLoggedTasks')}
|
||||
<Tooltip title={t('tasks.overLoggedTasksTooltip')}>
|
||||
<ExclamationCircleOutlined
|
||||
style={{
|
||||
color: colors.skyBlue,
|
||||
fontSize: 13,
|
||||
marginInlineStart: 4,
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Typography.Text>
|
||||
}
|
||||
extra={<Button type="link">{t('common.seeAll')}</Button>}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<OverLoggedTasksTable projectId={projectId} includeArchivedTasks={includeArchivedTasks} />
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className="custom-insights-card"
|
||||
title={
|
||||
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
|
||||
{t('tasks.tasksCompletedEarly')}
|
||||
</Typography.Text>
|
||||
}
|
||||
extra={<Button type="link">{t('common.seeAll')}</Button>}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<TaskCompletedEarlyTable
|
||||
projectId={projectId}
|
||||
includeArchivedTasks={includeArchivedTasks}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className="custom-insights-card"
|
||||
title={
|
||||
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
|
||||
{t('tasks.tasksCompletedLate')}
|
||||
</Typography.Text>
|
||||
}
|
||||
extra={<Button type="link">{t('common.seeAll')}</Button>}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<TaskCompletedLateTable
|
||||
projectId={projectId}
|
||||
includeArchivedTasks={includeArchivedTasks}
|
||||
/>
|
||||
</Card>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default InsightsTasks;
|
||||
@@ -0,0 +1,137 @@
|
||||
import { Avatar, Button, Flex, Table, Typography } from 'antd';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { TableProps } from 'antd/lib';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { IInsightTasks } from '@/types/project/projectInsights.types';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { projectInsightsApiService } from '@/api/projects/insights/project-insights.api.service';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
|
||||
const OverLoggedTasksTable = () => {
|
||||
const { includeArchivedTasks, projectId } = useAppSelector(state => state.projectInsightsReducer);
|
||||
|
||||
const [overLoggedTaskList, setOverLoggedTaskList] = useState<IInsightTasks[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
|
||||
const getOverLoggedTasks = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await projectInsightsApiService.getOverloggedTasks(
|
||||
projectId,
|
||||
includeArchivedTasks
|
||||
);
|
||||
if (res.done) {
|
||||
setOverLoggedTaskList(res.body);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching over logged tasks', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getOverLoggedTasks();
|
||||
}, [projectId, includeArchivedTasks,refreshTimestamp]);
|
||||
|
||||
// table columns
|
||||
const columns: TableProps['columns'] = [
|
||||
{
|
||||
key: 'name',
|
||||
title: 'Name',
|
||||
render: (record: IInsightTasks) => <Typography.Text>{record.name}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
title: 'Status',
|
||||
render: (record: IInsightTasks) => (
|
||||
<Flex
|
||||
gap={4}
|
||||
style={{
|
||||
width: 'fit-content',
|
||||
borderRadius: 24,
|
||||
paddingInline: 6,
|
||||
backgroundColor: record.status_color,
|
||||
color: colors.darkGray,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Typography.Text
|
||||
ellipsis={{ expanded: false }}
|
||||
style={{
|
||||
color: colors.darkGray,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{record.status_name}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'members',
|
||||
title: 'Members',
|
||||
render: (record: IInsightTasks) =>
|
||||
record.status_name ? (
|
||||
<Avatar.Group>
|
||||
{/* {record.names.map((member) => (
|
||||
<CustomAvatar avatarName={member.memberName} size={26} />
|
||||
))} */}
|
||||
</Avatar.Group>
|
||||
) : (
|
||||
<Button
|
||||
disabled
|
||||
type="dashed"
|
||||
shape="circle"
|
||||
size="small"
|
||||
icon={
|
||||
<PlusOutlined
|
||||
style={{
|
||||
fontSize: 12,
|
||||
width: 22,
|
||||
height: 22,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'overLoggedTime',
|
||||
title: 'Over Logged Time',
|
||||
render: (record: IInsightTasks) => (
|
||||
<Typography.Text>{record.overlogged_time}</Typography.Text>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Table
|
||||
className="custom-two-colors-row-table"
|
||||
dataSource={overLoggedTaskList}
|
||||
columns={columns}
|
||||
rowKey={record => record.taskId}
|
||||
pagination={{
|
||||
showSizeChanger: false,
|
||||
defaultPageSize: 10,
|
||||
}}
|
||||
loading={loading}
|
||||
onRow={record => {
|
||||
return {
|
||||
style: {
|
||||
cursor: 'pointer',
|
||||
height: 36,
|
||||
},
|
||||
};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default OverLoggedTasksTable;
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Flex, Table, Typography } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { TableProps } from 'antd/lib';
|
||||
import { simpleDateFormat } from '@/utils/simpleDateFormat';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { IInsightTasks } from '@/types/project/projectInsights.types';
|
||||
import { projectInsightsApiService } from '@/api/projects/insights/project-insights.api.service';
|
||||
|
||||
const OverdueTasksTable = ({
|
||||
projectId,
|
||||
includeArchivedTasks,
|
||||
}: {
|
||||
projectId: string;
|
||||
includeArchivedTasks: boolean;
|
||||
}) => {
|
||||
const [overdueTaskList, setOverdueTaskList] = useState<IInsightTasks[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
|
||||
const getOverdueTasks = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await projectInsightsApiService.getOverdueTasks(projectId, includeArchivedTasks);
|
||||
if (res.done) {
|
||||
setOverdueTaskList(res.body);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching overdue tasks:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getOverdueTasks();
|
||||
}, [projectId, includeArchivedTasks,refreshTimestamp]);
|
||||
|
||||
// table columns
|
||||
const columns: TableProps['columns'] = [
|
||||
{
|
||||
key: 'name',
|
||||
title: 'Name',
|
||||
render: (record: IInsightTasks) => <Typography.Text>{record.name}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
title: 'Status',
|
||||
render: (record: IInsightTasks) => (
|
||||
<Flex
|
||||
gap={4}
|
||||
style={{
|
||||
width: 'fit-content',
|
||||
borderRadius: 24,
|
||||
paddingInline: 6,
|
||||
backgroundColor: record.status_color,
|
||||
color: colors.darkGray,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Typography.Text
|
||||
ellipsis={{ expanded: false }}
|
||||
style={{
|
||||
color: colors.darkGray,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{record.status_name}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'dueDate',
|
||||
title: 'End Date',
|
||||
render: (record: IInsightTasks) => (
|
||||
<Typography.Text>
|
||||
{record.end_date ? simpleDateFormat(record.end_date) : 'N/A'}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'daysOverdue',
|
||||
title: 'Days overdue',
|
||||
render: (record: IInsightTasks) => <Typography.Text>{record.days_overdue}</Typography.Text>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Table
|
||||
className="custom-two-colors-row-table"
|
||||
dataSource={overdueTaskList}
|
||||
columns={columns}
|
||||
rowKey={record => record.taskId}
|
||||
pagination={{
|
||||
showSizeChanger: false,
|
||||
defaultPageSize: 10,
|
||||
}}
|
||||
loading={loading}
|
||||
onRow={record => {
|
||||
return {
|
||||
style: {
|
||||
cursor: 'pointer',
|
||||
height: 36,
|
||||
},
|
||||
};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default OverdueTasksTable;
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Flex, Table, Typography } from 'antd';
|
||||
import { TableProps } from 'antd/lib';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { projectInsightsApiService } from '@/api/projects/insights/project-insights.api.service';
|
||||
import { IInsightTasks } from '@/types/project/projectInsights.types';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { simpleDateFormat } from '@/utils/simpleDateFormat';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
|
||||
const TaskCompletedEarlyTable = ({
|
||||
projectId,
|
||||
includeArchivedTasks,
|
||||
}: {
|
||||
projectId: string;
|
||||
includeArchivedTasks: boolean;
|
||||
}) => {
|
||||
const [earlyCompletedTaskList, setEarlyCompletedTaskList] = useState<IInsightTasks[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
|
||||
const getEarlyCompletedTasks = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await projectInsightsApiService.getTasksCompletedEarly(
|
||||
projectId,
|
||||
includeArchivedTasks
|
||||
);
|
||||
if (res.done) {
|
||||
setEarlyCompletedTaskList(res.body);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching early completed tasks', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getEarlyCompletedTasks();
|
||||
}, [projectId, includeArchivedTasks, refreshTimestamp]);
|
||||
|
||||
const columns: TableProps['columns'] = [
|
||||
{
|
||||
key: 'name',
|
||||
title: 'Name',
|
||||
render: (record: IInsightTasks) => <Typography.Text>{record.name}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
title: 'Status',
|
||||
render: (record: IInsightTasks) => (
|
||||
<Flex
|
||||
gap={4}
|
||||
style={{
|
||||
width: 'fit-content',
|
||||
borderRadius: 24,
|
||||
paddingInline: 6,
|
||||
backgroundColor: record.status_color,
|
||||
color: colors.darkGray,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Typography.Text
|
||||
ellipsis={{ expanded: false }}
|
||||
style={{
|
||||
color: colors.darkGray,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{record.status_name}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'dueDate',
|
||||
title: 'End Date',
|
||||
render: (record: IInsightTasks) => (
|
||||
<Typography.Text>
|
||||
{record.end_date ? simpleDateFormat(record.end_date) : 'N/A'}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'completedDate',
|
||||
title: 'Completed At',
|
||||
render: (record: IInsightTasks) => (
|
||||
<Typography.Text>{simpleDateFormat(record.completed_at || null)}</Typography.Text>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Table
|
||||
className="custom-two-colors-row-table"
|
||||
dataSource={earlyCompletedTaskList}
|
||||
columns={columns}
|
||||
rowKey={record => record.taskId}
|
||||
loading={loading}
|
||||
pagination={{
|
||||
showSizeChanger: false,
|
||||
defaultPageSize: 10,
|
||||
}}
|
||||
onRow={record => ({
|
||||
style: {
|
||||
cursor: 'pointer',
|
||||
height: 36,
|
||||
},
|
||||
})}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskCompletedEarlyTable;
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Flex, Table, Typography } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { TableProps } from 'antd/lib';
|
||||
import { simpleDateFormat } from '@/utils/simpleDateFormat';
|
||||
import { IInsightTasks } from '@/types/project/projectInsights.types';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { projectInsightsApiService } from '@/api/projects/insights/project-insights.api.service';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
|
||||
const TaskCompletedLateTable = ({
|
||||
projectId,
|
||||
includeArchivedTasks,
|
||||
}: {
|
||||
projectId: string;
|
||||
includeArchivedTasks: boolean;
|
||||
}) => {
|
||||
const [lateCompletedTaskList, setLateCompletedTaskList] = useState<IInsightTasks[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
|
||||
const getLateCompletedTasks = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await projectInsightsApiService.getTasksCompletedLate(
|
||||
projectId,
|
||||
includeArchivedTasks
|
||||
);
|
||||
if (res.done) {
|
||||
setLateCompletedTaskList(res.body);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching late completed tasks', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getLateCompletedTasks();
|
||||
}, [projectId, includeArchivedTasks,refreshTimestamp]);
|
||||
|
||||
// table columns
|
||||
const columns: TableProps['columns'] = [
|
||||
{
|
||||
key: 'name',
|
||||
title: 'Name',
|
||||
render: (record: IInsightTasks) => <Typography.Text>{record.name}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
title: 'Status',
|
||||
render: (record: IInsightTasks) => (
|
||||
<Flex
|
||||
gap={4}
|
||||
style={{
|
||||
width: 'fit-content',
|
||||
borderRadius: 24,
|
||||
paddingInline: 6,
|
||||
backgroundColor: record.status_color,
|
||||
color: colors.darkGray,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Typography.Text
|
||||
ellipsis={{ expanded: false }}
|
||||
style={{
|
||||
color: colors.darkGray,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{record.status_name}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'dueDate',
|
||||
title: 'End Date',
|
||||
render: (record: IInsightTasks) => (
|
||||
<Typography.Text>{simpleDateFormat(record.end_date || null)}</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'completedDate',
|
||||
title: 'Completed At',
|
||||
render: (record: IInsightTasks) => (
|
||||
<Typography.Text>{simpleDateFormat(record.completed_at || null)}</Typography.Text>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Table
|
||||
className="custom-two-colors-row-table"
|
||||
dataSource={lateCompletedTaskList}
|
||||
columns={columns}
|
||||
rowKey={record => record.taskId}
|
||||
pagination={{
|
||||
showSizeChanger: false,
|
||||
defaultPageSize: 10,
|
||||
}}
|
||||
loading={loading}
|
||||
size="small"
|
||||
onRow={record => {
|
||||
return {
|
||||
style: {
|
||||
cursor: 'pointer',
|
||||
height: 36,
|
||||
},
|
||||
};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskCompletedLateTable;
|
||||
@@ -0,0 +1,64 @@
|
||||
import ProjectStatsCard from '@/components/projects/project-stats-card';
|
||||
import { Flex } from 'antd';
|
||||
import groupIcon from '@/assets/icons/insightsIcons/group.png';
|
||||
import warningIcon from '@/assets/icons/insightsIcons/warning.png';
|
||||
import unassignedIcon from '@/assets/icons/insightsIcons/block-user.png';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { projectInsightsApiService } from '@/api/projects/insights/project-insights.api.service';
|
||||
import { IProjectMemberStats } from '@/types/project/project-insights.types';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
|
||||
const MemberStats = () => {
|
||||
const { includeArchivedTasks, projectId } = useAppSelector(state => state.projectInsightsReducer);
|
||||
|
||||
const [memberStats, setMemberStats] = useState<IProjectMemberStats | null>(null);
|
||||
const [loadingStats, setLoadingStats] = useState(false);
|
||||
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
const fetchMemberStats = async () => {
|
||||
setLoadingStats(true);
|
||||
try {
|
||||
const res = await projectInsightsApiService.getMemberInsightAStats(
|
||||
projectId,
|
||||
includeArchivedTasks
|
||||
);
|
||||
if (res.done) {
|
||||
setMemberStats(res.body);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching member stats:', error);
|
||||
} finally {
|
||||
setLoadingStats(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchMemberStats();
|
||||
}, [projectId, includeArchivedTasks,refreshTimestamp]);
|
||||
|
||||
return (
|
||||
<Flex gap={24} className="grid sm:grid-cols-2 sm:grid-rows-2 lg:grid-cols-3 lg:grid-rows-1">
|
||||
<ProjectStatsCard
|
||||
icon={groupIcon}
|
||||
title="Project Members"
|
||||
children={memberStats?.total_members_count}
|
||||
loading={loadingStats}
|
||||
/>
|
||||
<ProjectStatsCard
|
||||
icon={warningIcon}
|
||||
title="Assignees with overdue tasks"
|
||||
children={memberStats?.overdue_members}
|
||||
loading={loadingStats}
|
||||
/>
|
||||
<ProjectStatsCard
|
||||
icon={unassignedIcon}
|
||||
title="Unassigned Members"
|
||||
children={memberStats?.unassigned_members}
|
||||
loading={loadingStats}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemberStats;
|
||||
@@ -0,0 +1,96 @@
|
||||
import ProjectStatsCard from '@/components/projects/project-stats-card';
|
||||
import { Flex, Tooltip } from 'antd';
|
||||
import checkIcon from '@assets/icons/insightsIcons/insights-check.png';
|
||||
import clipboardIcon from '@assets/icons/insightsIcons/clipboard.png';
|
||||
import clockIcon from '@assets/icons/insightsIcons/clock-green.png';
|
||||
import warningIcon from '@assets/icons/insightsIcons/warning.png';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { projectInsightsApiService } from '@/api/projects/insights/project-insights.api.service';
|
||||
import { IProjectInsightsGetRequest } from '@/types/project/projectInsights.types';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { TFunction } from 'i18next';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
|
||||
const ProjectStats = ({ t }: { t: TFunction }) => {
|
||||
const { includeArchivedTasks, projectId } = useAppSelector(state => state.projectInsightsReducer);
|
||||
const [stats, setStats] = useState<IProjectInsightsGetRequest>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
|
||||
const getProjectStats = async () => {
|
||||
if (!projectId) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await projectInsightsApiService.getProjectOverviewData(
|
||||
projectId,
|
||||
includeArchivedTasks
|
||||
);
|
||||
if (res.done) {
|
||||
setStats(res.body);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Error fetching project stats:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getProjectStats();
|
||||
}, [projectId, includeArchivedTasks,refreshTimestamp]);
|
||||
|
||||
const tooltipTable = (
|
||||
<table>
|
||||
<tbody>
|
||||
<tr style={{ display: 'flex', gap: 12 }}>
|
||||
<td style={{ width: 120 }}>{t('common.totalEstimation')}</td>
|
||||
<td>{stats.total_estimated_hours_string || '0h'}</td>
|
||||
</tr>
|
||||
<tr style={{ display: 'flex', gap: 12 }}>
|
||||
<td style={{ width: 120 }}>{t('common.totalLogged')}</td>
|
||||
<td>{stats.total_logged_hours_string || '0h'}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex gap={24} className="grid sm:grid-cols-2 sm:grid-rows-2 lg:grid-cols-4 lg:grid-rows-1">
|
||||
<ProjectStatsCard
|
||||
icon={checkIcon}
|
||||
title={t('common.completedTasks')}
|
||||
loading={loading}
|
||||
children={stats.completed_tasks_count ?? 0}
|
||||
/>
|
||||
<ProjectStatsCard
|
||||
icon={clipboardIcon}
|
||||
title={t('common.incompleteTasks')}
|
||||
loading={loading}
|
||||
children={stats.todo_tasks_count ?? 0}
|
||||
/>
|
||||
<ProjectStatsCard
|
||||
icon={warningIcon}
|
||||
title={t('common.overdueTasks')}
|
||||
tooltip={t('common.overdueTasksTooltip')}
|
||||
loading={loading}
|
||||
children={stats.overdue_count ?? 0}
|
||||
/>
|
||||
<ProjectStatsCard
|
||||
icon={clockIcon}
|
||||
title={t('common.totalLoggedHours')}
|
||||
tooltip={t('common.totalLoggedHoursTooltip')}
|
||||
loading={loading}
|
||||
children={
|
||||
<Tooltip title={tooltipTable} trigger={'hover'}>
|
||||
{stats.total_logged_hours_string || '0h'}
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectStats;
|
||||
@@ -0,0 +1,179 @@
|
||||
import { DownloadOutlined } from '@ant-design/icons';
|
||||
import { Badge, Button, Checkbox, Flex, Segmented } from 'antd';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { colors } from '@/styles/colors';
|
||||
import InsightsMembers from './insights-members/insights-members';
|
||||
import InsightsOverview from './insights-overview/insights-overview';
|
||||
import InsightsTasks from './insights-tasks/insights-tasks';
|
||||
import {
|
||||
setActiveSegment,
|
||||
setIncludeArchivedTasks,
|
||||
setProjectId,
|
||||
} from '@/features/projects/insights/project-insights.slice';
|
||||
import { format } from 'date-fns';
|
||||
import html2canvas from 'html2canvas';
|
||||
import jsPDF from 'jspdf';
|
||||
import logo from '@/assets/images/logo.png';
|
||||
import { evt_project_insights_members_visit, evt_project_insights_overview_visit, evt_project_insights_tasks_visit } from '@/shared/worklenz-analytics-events';
|
||||
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||
|
||||
type SegmentType = 'Overview' | 'Members' | 'Tasks';
|
||||
|
||||
const ProjectViewInsights = () => {
|
||||
const { projectId } = useParams();
|
||||
const { t } = useTranslation('project-view-insights');
|
||||
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||
const exportRef = useRef<HTMLDivElement>(null);
|
||||
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const dispatch = useAppDispatch();
|
||||
const [exportLoading, setExportLoading] = useState(false);
|
||||
const { activeSegment, includeArchivedTasks } = useAppSelector(
|
||||
state => state.projectInsightsReducer
|
||||
);
|
||||
const {
|
||||
project: selectedProject,
|
||||
} = useAppSelector(state => state.projectReducer);
|
||||
|
||||
const handleSegmentChange = (value: SegmentType) => {
|
||||
dispatch(setActiveSegment(value));
|
||||
};
|
||||
|
||||
const toggleArchivedTasks = () => {
|
||||
dispatch(setIncludeArchivedTasks(!includeArchivedTasks));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
dispatch(setProjectId(projectId));
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const renderSegmentContent = () => {
|
||||
if (!projectId) return null;
|
||||
|
||||
switch (activeSegment) {
|
||||
case 'Overview':
|
||||
trackMixpanelEvent(evt_project_insights_overview_visit);
|
||||
return <InsightsOverview t={t} />;
|
||||
case 'Members':
|
||||
trackMixpanelEvent(evt_project_insights_members_visit);
|
||||
return <InsightsMembers t={t} />;
|
||||
case 'Tasks':
|
||||
trackMixpanelEvent(evt_project_insights_tasks_visit);
|
||||
return <InsightsTasks t={t} />;
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
if (!projectId) return;
|
||||
try {
|
||||
setExportLoading(true);
|
||||
await dispatch(setActiveSegment(activeSegment));
|
||||
await exportPdf(selectedProject?.name || '', activeSegment);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setExportLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const exportPdf = async (projectName: string | null, activeSegment: string | '') => {
|
||||
if (!exportRef.current) return;
|
||||
const element = exportRef.current;
|
||||
const canvas = await html2canvas(element);
|
||||
const imgData = canvas.toDataURL('image/png');
|
||||
|
||||
const pdf = new jsPDF('p', 'mm', 'a4');
|
||||
const bufferX = 5;
|
||||
const bufferY = 28;
|
||||
const imgProps = pdf.getImageProperties(imgData);
|
||||
const pdfWidth = pdf.internal.pageSize.getWidth() - 2 * bufferX;
|
||||
const pdfHeight = (imgProps.height * pdfWidth) / imgProps.width;
|
||||
|
||||
const logoImg = new Image();
|
||||
logoImg.src = logo;
|
||||
logoImg.onload = () => {
|
||||
pdf.addImage(logoImg, 'PNG', pdf.internal.pageSize.getWidth() / 2 - 12, 5, 30, 6.5);
|
||||
|
||||
pdf.setFontSize(14);
|
||||
pdf.setTextColor(0, 0, 0, 0.85);
|
||||
pdf.text(
|
||||
[`Insights - ${projectName} - ${activeSegment}`, format(new Date(), 'yyyy-MM-dd')],
|
||||
pdf.internal.pageSize.getWidth() / 2,
|
||||
17,
|
||||
{ align: 'center' }
|
||||
);
|
||||
|
||||
pdf.addImage(imgData, 'PNG', bufferX, bufferY, pdfWidth, pdfHeight);
|
||||
pdf.save(`${activeSegment} ${format(new Date(), 'yyyy-MM-dd')}.pdf`);
|
||||
};
|
||||
|
||||
logoImg.onerror = (error) => {
|
||||
pdf.setFontSize(14);
|
||||
pdf.setTextColor(0, 0, 0, 0.85);
|
||||
pdf.text(
|
||||
[`Insights - ${projectName} - ${activeSegment}`, format(new Date(), 'yyyy-MM-dd')],
|
||||
pdf.internal.pageSize.getWidth() / 2,
|
||||
17,
|
||||
{ align: 'center' }
|
||||
);
|
||||
pdf.addImage(imgData, 'PNG', bufferX, bufferY, pdfWidth, pdfHeight);
|
||||
pdf.save(`${activeSegment} ${format(new Date(), 'yyyy-MM-dd')}.pdf`);
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(()=>{
|
||||
if(projectId){
|
||||
dispatch(setActiveSegment('Overview'));
|
||||
}
|
||||
},[refreshTimestamp])
|
||||
|
||||
return (
|
||||
<Flex vertical gap={24}>
|
||||
<Flex align="center" justify="space-between">
|
||||
<Segmented
|
||||
options={['Overview', 'Members', 'Tasks']}
|
||||
defaultValue={activeSegment}
|
||||
value={activeSegment}
|
||||
onChange={handleSegmentChange}
|
||||
/>
|
||||
|
||||
<Flex gap={8}>
|
||||
<Flex
|
||||
gap={8}
|
||||
align="center"
|
||||
style={{
|
||||
backgroundColor: themeMode === 'dark' ? '#141414' : '#f5f5f5',
|
||||
padding: '6px 15px',
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={includeArchivedTasks} onClick={toggleArchivedTasks} />
|
||||
<Badge color={includeArchivedTasks ? colors.limeGreen : colors.vibrantOrange} dot>
|
||||
{t('common.includeArchivedTasks')}
|
||||
</Badge>
|
||||
</Flex>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={handleExport}
|
||||
loading={exportLoading}
|
||||
>
|
||||
{t('common.export')}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<div ref={exportRef}>
|
||||
{renderSegmentContent()}
|
||||
</div>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectViewInsights;
|
||||
Reference in New Issue
Block a user