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

View File

@@ -0,0 +1,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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;