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,31 @@
import { Flex, Skeleton } from 'antd';
import React, { useEffect, useMemo, useState } from 'react';
import CustomSearchbar from '../../../../CustomSearchbar';
import { useTranslation } from 'react-i18next';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import OverviewReportsMembersTable from './reporting-overview-members-table';
import { IRPTMember } from '@/types/reporting/reporting.types';
import { reportingApiService } from '@/api/reporting/reporting.api.service';
type OverviewReportsMembersTabProps = { teamsId?: string | null };
const OverviewReportsMembersTab = ({ teamsId = null }: OverviewReportsMembersTabProps) => {
const { t } = useTranslation('reporting-overview-drawer');
const [searchQuery, setSearchQuery] = useState<string>('');
return (
<Flex vertical gap={24}>
<CustomSearchbar
placeholderText={t('searchByNameInputPlaceholder')}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
/>
{<OverviewReportsMembersTable teamsId={teamsId} searchQuery={searchQuery} />}
</Flex>
);
};
export default OverviewReportsMembersTab;

View File

@@ -0,0 +1,127 @@
import React, { memo, useEffect, useMemo, useState } from 'react';
import { ConfigProvider, Table, TableColumnsType } from 'antd';
import { useTranslation } from 'react-i18next';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import CustomTableTitle from '../../../../CustomTableTitle';
import { reportingApiService } from '@/api/reporting/reporting.api.service';
import { IRPTMember } from '@/types/reporting/reporting.types';
type OverviewReportsMembersReportsTableProps = {
teamsId: string | null;
searchQuery: string;
};
const OverviewReportsMembersReportsTable = ({
teamsId,
searchQuery,
}: OverviewReportsMembersReportsTableProps) => {
const [selectedId, setSelectedId] = useState<string | null>(null);
const [membersList, setMembersList] = useState<IRPTMember[]>([]);
// localization
const { t } = useTranslation('reporting-overview-drawer');
const dispatch = useAppDispatch();
// function to handle drawer toggle
const handleDrawerOpen = (id: string) => {
setSelectedId(id);
// dispatch(toggleMembersReportsDrawer());
};
const getMembersList = async () => {
if (!teamsId) return;
const res = await reportingApiService.getOverviewMembersByTeam(teamsId, false);
if (res.done) {
setMembersList(res.body);
}
};
const filteredMembersList = useMemo(() => {
return membersList?.filter(item => item.name.toLowerCase().includes(searchQuery.toLowerCase()));
}, [searchQuery, membersList]);
useEffect(() => {
getMembersList();
}, []);
const columns: TableColumnsType = [
{
key: 'name',
title: <CustomTableTitle title={t('nameColumn')} />,
className: 'group-hover:text-[#1890ff]',
dataIndex: 'name',
},
{
key: 'email',
title: <CustomTableTitle title={t('emailColumn')} />,
className: 'group-hover:text-[#1890ff]',
dataIndex: 'email',
},
{
key: 'projects',
title: <CustomTableTitle title={t('projectsColumn')} />,
className: 'text-center group-hover:text-[#1890ff]',
dataIndex: 'projects',
width: 80,
},
{
key: 'tasks',
title: <CustomTableTitle title={t('tasksColumn')} />,
className: 'text-center group-hover:text-[#1890ff]',
dataIndex: 'tasks',
width: 80,
},
{
key: 'overdueTasks',
title: <CustomTableTitle title={t('overdueTasksColumn')} />,
className: 'text-center group-hover:text-[#1890ff]',
dataIndex: 'overdue',
width: 120,
},
{
key: 'completedTasks',
title: <CustomTableTitle title={t('completedTasksColumn')} />,
className: 'text-center group-hover:text-[#1890ff]',
dataIndex: 'completed',
width: 140,
},
{
key: 'ongoingTasks',
title: <CustomTableTitle title={t('ongoingTasksColumn')} />,
className: 'text-center group-hover:text-[#1890ff]',
dataIndex: 'ongoing',
width: 120,
},
];
return (
<ConfigProvider
theme={{
components: {
Table: {
cellPaddingBlock: 8,
cellPaddingInline: 10,
},
},
}}
>
<Table
columns={columns}
dataSource={filteredMembersList}
scroll={{ x: 'max-content' }}
rowKey={record => record.id}
onRow={record => {
return {
style: { height: 38, cursor: 'pointer' },
className: 'group even:bg-[#4e4e4e10]',
};
}}
/>
{/* <MembersReportsDrawer memberId={selectedId} /> */}
</ConfigProvider>
);
};
export default memo(OverviewReportsMembersReportsTable);

View File

@@ -0,0 +1,106 @@
import React from 'react';
import { Doughnut } from 'react-chartjs-2';
import { Chart, ArcElement, Tooltip, ChartOptions } from 'chart.js';
import { Badge, Card, Flex, Typography } from 'antd';
import { useTranslation } from 'react-i18next';
import { IRPTOverviewTeamChartData } from '@/types/reporting/reporting.types';
Chart.register(ArcElement, Tooltip);
const OverviewReportsProjectCategoryGraph = ({
data,
}: {
data: IRPTOverviewTeamChartData | undefined;
}) => {
// localization
const { t } = useTranslation('reporting-overview-drawer');
type CategoryGraphItemType = {
name: string;
color: string;
count: number;
};
// mock data
const categoryGraphItems: CategoryGraphItemType[] =
data?.data.map(category => ({
name: category.label,
color: category.color,
count: category.count,
})) ?? [];
// chart data
const chartData = {
labels: categoryGraphItems.map(item => item.name),
datasets: [
{
label: t('projectsText'),
data: categoryGraphItems.map(item => item.count),
backgroundColor: categoryGraphItems.map(item => item.color),
},
],
};
const totalTasks = categoryGraphItems.reduce((sum, item) => sum + item.count, 0);
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' : ''}`;
},
},
},
},
};
return (
<Card
title={
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
{t('projectsByCategoryText')}
</Typography.Text>
}
>
<div className="flex flex-wrap items-center justify-center gap-6 xl:flex-nowrap">
<Doughnut
data={chartData}
options={options}
className="max-h-[200px] w-full max-w-[200px]"
/>
<div className="flex flex-row flex-wrap gap-3 xl:flex-col">
{/* total tasks */}
<Flex gap={4} align="center">
<Badge color="#a9a9a9" />
<Typography.Text ellipsis>
{t('allText')} ({totalTasks})
</Typography.Text>
</Flex>
{/* category-specific tasks */}
{categoryGraphItems.map(item => (
<Flex key={item.name} gap={4} align="center">
<Badge color={item.color} />
<Typography.Text ellipsis>
{item.name} ({item.count})
</Typography.Text>
</Flex>
))}
</div>
</div>
</Card>
);
};
export default OverviewReportsProjectCategoryGraph;

View File

@@ -0,0 +1,105 @@
import React from 'react';
import { Doughnut } from 'react-chartjs-2';
import { Chart, ArcElement, Tooltip, ChartOptions } from 'chart.js';
import { Badge, Card, Flex, Typography } from 'antd';
import { useTranslation } from 'react-i18next';
import {
IRPTOverviewTeamByHealth,
IRPTOverviewTeamChartData,
} from '@/types/reporting/reporting.types';
import { ALPHA_CHANNEL } from '@/shared/constants';
Chart.register(ArcElement, Tooltip);
const OverviewReportsProjectHealthGraph = ({
data,
}: {
data: IRPTOverviewTeamByHealth | undefined;
}) => {
const { t } = useTranslation('reporting-overview-drawer');
type HealthGraphItemType = {
name: string;
color: string;
count: number;
};
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' : ''}`;
},
},
},
},
};
// mock data
const healthGraphItems: HealthGraphItemType[] = [
{ name: 'notSet', color: '#a9a9a9', count: data?.not_set ?? 0 },
{ name: 'needsAttention', color: '#f37070', count: data?.needs_attention ?? 0 },
{ name: 'atRisk', color: '#fbc84c', count: data?.at_risk ?? 0 },
{ name: 'good', color: '#75c997', count: data?.good ?? 0 },
];
const chartData = {
labels: healthGraphItems.map(item => item.name),
datasets: [
{
data: healthGraphItems.map(item => item.count),
backgroundColor: healthGraphItems.map(item => item.color + ALPHA_CHANNEL),
},
],
};
return (
<Card
title={
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
{t('projectsByHealthText')}
</Typography.Text>
}
>
<div className="flex flex-wrap items-center justify-center gap-6 xl:flex-nowrap">
<Doughnut
data={chartData}
options={options}
className="max-h-[200px] w-full max-w-[200px]"
/>
<div className="flex flex-row flex-wrap gap-3 xl:flex-col">
{/* total tasks */}
<Flex gap={4} align="center">
<Badge color="#a9a9a9" />
<Typography.Text ellipsis>
{t('allText')} ({data?.all})
</Typography.Text>
</Flex>
{/* health-specific tasks */}
{healthGraphItems.map(item => (
<Flex key={item.name} gap={4} align="center">
<Badge color={item.color} />
<Typography.Text ellipsis>
{t(`${item.name}Text`)} ({item.count})
</Typography.Text>
</Flex>
))}
</div>
</div>
</Card>
);
};
export default OverviewReportsProjectHealthGraph;

View File

@@ -0,0 +1,106 @@
import React from 'react';
import { Doughnut } from 'react-chartjs-2';
import { Chart, ArcElement, Tooltip, ChartOptions } from 'chart.js';
import { Badge, Card, Flex, Typography } from 'antd';
import { useTranslation } from 'react-i18next';
import { IRPTOverviewTeamByStatus, IRPTOverviewTeamInfo } from '@/types/reporting/reporting.types';
import { ALPHA_CHANNEL } from '@/shared/constants';
Chart.register(ArcElement, Tooltip);
const OverviewReportsProjectStatusGraph = ({
data,
}: {
data: IRPTOverviewTeamByStatus | undefined;
}) => {
const { t } = useTranslation('reporting-overview-drawer');
type StatusGraphItemType = {
name: string;
color: string;
count: number;
};
const statusGraphItems: StatusGraphItemType[] = [
{ name: 'inProgress', color: '#80ca79', count: data?.in_progress ?? 0 },
{ name: 'inPlanning', color: '#cbc8a1', count: data?.in_planning ?? 0 },
{ name: 'completed', color: '#80ca79', count: data?.completed ?? 0 },
{ name: 'proposed', color: '#cbc8a1', count: data?.proposed ?? 0 },
{ name: 'onHold', color: '#cbc8a1', count: data?.on_hold ?? 0 },
{ name: 'blocked', color: '#cbc8a1', count: data?.blocked ?? 0 },
{ name: 'cancelled', color: '#f37070', count: data?.cancelled ?? 0 },
];
const chartData = {
labels: statusGraphItems.map(item => item.name),
datasets: [
{
data: statusGraphItems.map(item => item.count),
backgroundColor: statusGraphItems.map(item => item.color + ALPHA_CHANNEL),
},
],
};
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 totalTasks = statusGraphItems.reduce((sum, item) => sum + item.count, 0);
return (
<Card
title={
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
{t('projectsByStatusText')}
</Typography.Text>
}
>
<div className="flex flex-wrap items-center justify-center gap-6 xl:flex-nowrap">
<Doughnut
data={chartData}
options={options}
className="max-h-[200px] w-full max-w-[200px]"
/>
<div className="flex flex-row flex-wrap gap-3 xl:flex-col">
{/* total tasks */}
<Flex gap={4} align="center">
<Badge color="#a9a9a9" />
<Typography.Text ellipsis>
{t('allText')} ({data?.all})
</Typography.Text>
</Flex>
{/* status-specific tasks */}
{statusGraphItems.map(item => (
<Flex key={item.name} gap={4} align="center">
<Badge color={item.color} />
<Typography.Text ellipsis>
{t(`${item.name}Text`)} ({item.count})
</Typography.Text>
</Flex>
))}
</div>
</div>
</Card>
);
};
export default OverviewReportsProjectStatusGraph;

View File

@@ -0,0 +1,46 @@
import React, { useEffect, useState } from 'react';
import ReportsOverviewStatusGraph from './reports-overview-status-graph';
import OverviewReportsProjectCategoryGraph from './reports-overview-category-graph';
import OverviewReportsProjectHealthGraph from './reports-overview-project-health-graph';
import { IRPTOverviewTeamInfo } from '@/types/reporting/reporting.types';
import { reportingApiService } from '@/api/reporting/reporting.api.service';
import { useAppSelector } from '@/hooks/useAppSelector';
type OverviewReportsOverviewTabProps = {
teamId?: string | null;
};
const OverviewReportsOverviewTab = ({ teamId = null }: OverviewReportsOverviewTabProps) => {
const [model, setModel] = useState<IRPTOverviewTeamInfo | null>(null);
const [loading, setLoading] = useState(false);
const { includeArchivedProjects } = useAppSelector(state => state.reportingReducer);
const getModelData = async () => {
if (!teamId) return;
try {
setLoading(true);
const { done, body } = await reportingApiService.getTeamInfo(teamId, includeArchivedProjects);
if (done) {
setModel(body);
}
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
useEffect(() => {
getModelData();
}, [includeArchivedProjects]);
return (
<div className="grid gap-4 sm:grid-cols-2">
<ReportsOverviewStatusGraph data={model?.by_status} />
<OverviewReportsProjectCategoryGraph data={model?.by_category} />
<OverviewReportsProjectHealthGraph data={model?.by_health} />
</div>
);
};
export default OverviewReportsOverviewTab;

View File

@@ -0,0 +1,36 @@
import { Tabs } from 'antd';
import { TabsProps } from 'antd/lib';
import { useTranslation } from 'react-i18next';
import OverviewReportsOverviewTab from './overview-tab/reports-overview-tab';
import OverviewReportsProjectsTab from './projects-tab/reporting-overview-projects-tab';
import OverviewReportsMembersTab from './members-tab/reporting-overview-members-tab';
type OverviewTeamInfoDrawerProps = {
teamsId?: string | null;
};
const OverviewTeamInfoDrawerTabs = ({ teamsId = null }: OverviewTeamInfoDrawerProps) => {
const { t } = useTranslation('reporting-overview-drawer');
const tabItems: TabsProps['items'] = [
{
key: 'overview',
label: t('overviewTab'),
children: <OverviewReportsOverviewTab teamId={teamsId} />,
},
{
key: 'projects',
label: t('projectsTab'),
children: <OverviewReportsProjectsTab teamsId={teamsId} />,
},
{
key: 'members',
label: t('membersTab'),
children: <OverviewReportsMembersTab teamsId={teamsId} />,
},
];
return <Tabs type="card" items={tabItems} destroyInactiveTabPane defaultActiveKey="overview" />;
};
export default OverviewTeamInfoDrawerTabs;

View File

@@ -0,0 +1,63 @@
import { Drawer, Typography, Flex, Button, Dropdown } from 'antd';
import React, { useState } from 'react';
import { useAppSelector } from '../../../../hooks/useAppSelector';
import { useAppDispatch } from '../../../../hooks/useAppDispatch';
import { BankOutlined, DownOutlined } from '@ant-design/icons';
import { colors } from '../../../../styles/colors';
import { useTranslation } from 'react-i18next';
import OverviewTeamInfoDrawerTabs from './overview-team-info-drawer-tabs';
import { toggleOverViewTeamDrawer } from '@/features/reporting/reporting.slice';
import { IRPTTeam } from '@/types/reporting/reporting.types';
type OverviewTeamInfoDrawerProps = {
team: IRPTTeam | null;
};
const OverviewTeamInfoDrawer = ({ team }: OverviewTeamInfoDrawerProps) => {
const { t } = useTranslation('reporting-overview-drawer');
const dispatch = useAppDispatch();
const isDrawerOpen = useAppSelector(state => state.reportingReducer.showOverViewTeamDrawer);
const handleClose = () => {
dispatch(toggleOverViewTeamDrawer());
};
return (
<Drawer
open={isDrawerOpen}
destroyOnClose
onClose={handleClose}
width={900}
title={
team && (
<Flex align="center" justify="space-between">
<Flex gap={4} align="center" style={{ fontWeight: 500 }}>
<BankOutlined style={{ color: colors.lightGray }} />
<Typography.Text style={{ fontSize: 16 }}>{team.name}</Typography.Text>
</Flex>
{/* <Dropdown
menu={{
items: [
{ key: '1', label: t('projectsButton') },
{ key: '2', label: t('membersButton') },
],
}}
>
<Button type="primary" icon={<DownOutlined />} iconPosition="end">
{t('exportButton')}
</Button>
</Dropdown> */}
</Flex>
)
}
>
<OverviewTeamInfoDrawerTabs teamsId={team?.id} />
</Drawer>
);
};
export default OverviewTeamInfoDrawer;

View File

@@ -0,0 +1,57 @@
[data-theme="dark"] {
--table-cell-bg: #181818;
--hover-bg: #000;
--base-bg: #181818;
}
[data-theme="default"] {
--table-cell-bg: #4e4e4e10;
--hover-bg: #edebf0;
--base-bg: #ffffff;
}
/* change the scroll bar in the table */
:where(.css-dev-only-do-not-override-ifo476).ant-table-wrapper .ant-table {
scrollbar-color: unset;
}
/* Fix sticky column background */
.ant-table-wrapper .ant-table-cell-fix-left {
background-color: var(--base-bg) !important;
z-index: 2 !important;
}
.ant-table-wrapper .ant-table-row:nth-child(even) .ant-table-cell-fix-left {
background-color: var(--table-cell-bg) !important;
}
.ant-table-wrapper .ant-table-row:hover .ant-table-cell-fix-left {
background-color: var(--hover-bg) !important;
}
/* Shadow for sticky columns */
:where(.css-dev-only-do-not-override-aw0rds).ant-table-wrapper
.ant-table-ping-left
.ant-table-cell-fix-left-first::after {
box-shadow: inset 10px 0 8px -8px rgba(0, 0, 0, 0.15);
right: -1px;
}
:where(.css-dev-only-do-not-override-aw0rds).ant-table-wrapper
.ant-table-ping-right:not(.ant-table-has-fix-right)
.ant-table-container::after {
box-shadow: inset -10px 0 8px -8px rgba(0, 0, 0, 0.15);
}
/* remove select border */
:where(.css-dev-only-do-not-override-tmno9t).ant-select-outlined:not(.ant-select-customize-input)
.ant-select-selector {
border: none;
}
.ant-select-focused:where(.css-dev-only-do-not-override-tmno9t).ant-select-outlined:not(
.ant-select-disabled
):not(.ant-select-customize-input):not(.ant-pagination-size-changer)
.ant-select-selector {
box-shadow: none;
}

View File

@@ -0,0 +1,30 @@
import { Flex } from 'antd';
import { useState } from 'react';
import CustomSearchbar from '@components/CustomSearchbar';
import { useTranslation } from 'react-i18next';
import ReportingOverviewProjectsTable from './reporting-overview-projects-table';
interface OverviewReportsProjectsTabProps {
teamsId?: string | null;
}
const OverviewReportsProjectsTab = ({ teamsId = null }: OverviewReportsProjectsTabProps) => {
const { t } = useTranslation('reporting-projects-drawer');
const [searchQuery, setSearchQuery] = useState('');
return (
<Flex vertical gap={24}>
<CustomSearchbar
placeholderText={t('searchByNameInputPlaceholder')}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
/>
<ReportingOverviewProjectsTable searchQuery={searchQuery} teamsId={teamsId} />
</Flex>
);
};
export default OverviewReportsProjectsTab;

View File

@@ -0,0 +1,331 @@
import { useEffect, useState, useMemo } from 'react';
import { Button, ConfigProvider, Flex, PaginationProps, Table, TableColumnsType } from 'antd';
import { useTranslation } from 'react-i18next';
import { createPortal } from 'react-dom';
import { ExpandAltOutlined } from '@ant-design/icons';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import ProjectCell from '@/pages/reporting/projects-reports/projects-reports-table/table-cells/project-cell/project-cell';
import EstimatedVsActualCell from '@/pages/reporting/projects-reports/projects-reports-table/table-cells/estimated-vs-actual-cell/estimated-vs-actual-cell';
import TasksProgressCell from '@/pages/reporting/projects-reports/projects-reports-table/table-cells/tasks-progress-cell/tasks-progress-cell';
import LastActivityCell from '@/pages/reporting/projects-reports/projects-reports-table/table-cells/last-activity-cell/last-activity-cell';
import ProjectStatusCell from '@/pages/reporting/projects-reports/projects-reports-table/table-cells/project-status-cell/project-status-cell';
import ProjectClientCell from '@/pages/reporting/projects-reports/projects-reports-table/table-cells/project-client-cell/project-client-cell';
import ProjectTeamCell from '@/pages/reporting/projects-reports/projects-reports-table/table-cells/project-team-cell/project-team-cell';
import ProjectManagerCell from '@/pages/reporting/projects-reports/projects-reports-table/table-cells/project-manager-cell/project-manager-cell';
import ProjectDatesCell from '@/pages/reporting/projects-reports/projects-reports-table/table-cells/project-dates-cell/project-dates-cell';
import ProjectHealthCell from '@/pages/reporting/projects-reports/projects-reports-table/table-cells/project-health-cell/project-health-cell';
import ProjectCategoryCell from '@/pages/reporting/projects-reports/projects-reports-table/table-cells/project-category-cell/project-category-cell';
import ProjectDaysLeftAndOverdueCell from '@/pages/reporting/projects-reports/projects-reports-table/table-cells/project-days-left-and-overdue-cell/project-days-left-and-overdue-cell';
import ProjectUpdateCell from '@/pages/reporting/projects-reports/projects-reports-table/table-cells/project-update-cell/project-update-cell';
import {
resetProjectReports,
setField,
setIndex,
setOrder,
setPageSize,
toggleProjectReportsDrawer,
} from '@/features/reporting/projectReports/project-reports-slice';
import { colors } from '@/styles/colors';
import CustomTableTitle from '@/components/CustomTableTitle';
import { IRPTProject } from '@/types/reporting/reporting.types';
import ProjectReportsDrawer from '@/features/reporting/projectReports/projectReportsDrawer/ProjectReportsDrawer';
import { DEFAULT_PAGE_SIZE, PAGE_SIZE_OPTIONS } from '@/shared/constants';
import './projects-reports-table.css';
import { fetchProjectStatuses } from '@/features/projects/lookups/projectStatuses/projectStatusesSlice';
import logger from '@/utils/errorLogger';
import { reportingApiService } from '@/api/reporting/reporting.api.service';
interface ReportingOverviewProjectsTableProps {
searchQuery: string;
teamsId: string | null;
}
const ReportingOverviewProjectsTable = ({
searchQuery,
teamsId,
}: ReportingOverviewProjectsTableProps) => {
const dispatch = useAppDispatch();
const { t } = useTranslation('reporting-projects');
const { includeArchivedProjects } = useAppSelector(state => state.reportingReducer);
const [projectList, setProjectList] = useState<IRPTProject[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [pagination, setPagination] = useState<PaginationProps>({
current: 1,
pageSize: DEFAULT_PAGE_SIZE,
total: 0,
});
const [order, setOrder] = useState<'asc' | 'desc'>('asc');
const [field, setField] = useState<string>('name');
const [selectedProject, setSelectedProject] = useState<IRPTProject | null>(null);
const { projectStatuses, loading: projectStatusesLoading } = useAppSelector(
state => state.projectStatusesReducer
);
const handleDrawerOpen = (record: IRPTProject) => {
setSelectedProject(record);
dispatch(toggleProjectReportsDrawer());
};
const columns: TableColumnsType<IRPTProject> = useMemo(
() => [
{
key: 'name',
dataIndex: 'name',
title: <CustomTableTitle title={t('projectColumn')} />,
width: 300,
sorter: true,
defaultSortOrder: order === 'asc' ? 'ascend' : 'descend',
fixed: 'left' as const,
onCell: record => ({
onClick: () => handleDrawerOpen(record as IRPTProject),
}),
render: (_, record: { id: string; name: string; color_code: string }) => (
<Flex gap={16} align="center" justify="space-between">
<ProjectCell
projectId={record.id}
project={record.name}
projectColor={record.color_code}
/>
<Button
className="hidden group-hover:flex"
type="text"
style={{
backgroundColor: colors.transparent,
padding: 0,
height: 22,
alignItems: 'center',
gap: 8,
}}
>
{t('openButton')} <ExpandAltOutlined />
</Button>
</Flex>
),
},
{
key: 'estimatedVsActual',
title: <CustomTableTitle title={t('estimatedVsActualColumn')} />,
render: record => (
<EstimatedVsActualCell
actualTime={record.actual_time || 0}
actualTimeString={record.actual_time_string}
estimatedTime={record.estimated_time * 60 || 0}
estimatedTimeString={record.estimated_time_string}
/>
),
width: 230,
},
{
key: 'tasksProgress',
title: <CustomTableTitle title={t('tasksProgressColumn')} />,
render: record => <TasksProgressCell tasksStat={record.tasks_stat} />,
width: 200,
},
{
key: 'lastActivity',
title: <CustomTableTitle title={t('lastActivityColumn')} />,
render: record => (
<LastActivityCell activity={record.last_activity?.last_activity_string} />
),
width: 200,
},
{
key: 'status',
dataIndex: 'status_id',
defaultSortOrder: order === 'asc' ? 'ascend' : 'descend',
title: <CustomTableTitle title={t('statusColumn')} />,
render: (_, record: IRPTProject) => (
<ProjectStatusCell currentStatus={record.status_id} projectId={record.id} />
),
width: 200,
sorter: true,
},
{
key: 'dates',
title: <CustomTableTitle title={t('datesColumn')} />,
render: record => (
<ProjectDatesCell
projectId={record.id}
startDate={record.start_date}
endDate={record.end_date}
/>
),
width: 275,
},
{
key: 'daysLeft',
title: <CustomTableTitle title={t('daysLeftColumn')} />,
render: record => (
<ProjectDaysLeftAndOverdueCell
daysLeft={record.days_left}
isOverdue={record.is_overdue}
isToday={record.is_today}
/>
),
width: 200,
},
{
key: 'projectHealth',
dataIndex: 'project_health',
defaultSortOrder: order === 'asc' ? 'ascend' : 'descend',
title: <CustomTableTitle title={t('projectHealthColumn')} />,
sorter: true,
render: (_, record: IRPTProject) => (
<ProjectHealthCell
value={record.project_health}
label={record.health_name}
color={record.health_color}
projectId={record.id}
/>
),
width: 200,
},
{
key: 'category',
title: <CustomTableTitle title={t('categoryColumn')} />,
render: (_, record: IRPTProject) => (
<ProjectCategoryCell
projectId={record.id}
id={record.category_id || ''}
name={record.category_name || ''}
color_code={record.category_color || ''}
/>
),
width: 200,
},
{
key: 'projectUpdate',
title: <CustomTableTitle title={t('projectUpdateColumn')} />,
render: (_, record: IRPTProject) =>
record.comment ? <ProjectUpdateCell updates={record.comment} /> : '-',
width: 200,
},
{
key: 'client',
dataIndex: 'client',
defaultSortOrder: order === 'asc' ? 'ascend' : 'descend',
title: <CustomTableTitle title={t('clientColumn')} />,
render: (_, record: IRPTProject) =>
record?.client ? <ProjectClientCell client={record.client} /> : '-',
sorter: true,
width: 200,
},
{
key: 'team',
dataIndex: 'team_name',
defaultSortOrder: order === 'asc' ? 'ascend' : 'descend',
title: <CustomTableTitle title={t('teamColumn')} />,
render: (_, record: IRPTProject) =>
record.team_name ? <ProjectTeamCell team={record.team_name} /> : '-',
sorter: true,
width: 200,
},
{
key: 'projectManager',
title: <CustomTableTitle title={t('projectManagerColumn')} />,
render: (_, record: IRPTProject) =>
record.project_manager ? <ProjectManagerCell manager={record.project_manager} /> : '-',
width: 200,
},
],
[t, order]
);
const handleTableChange = (pagination: PaginationProps, filters: any, sorter: any) => {
if (sorter.order) setOrder(sorter.order);
if (sorter.field) setField(sorter.field);
setPagination({ ...pagination, current: pagination.current });
setPagination({ ...pagination, pageSize: pagination.pageSize });
};
useEffect(() => {
if (projectStatuses.length === 0 && !projectStatusesLoading) dispatch(fetchProjectStatuses());
}, []);
useEffect(() => {
return () => {
dispatch(resetProjectReports());
};
}, []);
const tableRowProps = useMemo(
() => ({
style: { height: 56, cursor: 'pointer' },
className: 'group even:bg-[#4e4e4e10]',
}),
[]
);
const tableConfig = useMemo(
() => ({
theme: {
components: {
Table: {
cellPaddingBlock: 12,
cellPaddingInline: 10,
},
},
},
}),
[]
);
const fetchOverviewProjects = async () => {
setIsLoading(true);
try {
const params = {
team: teamsId,
index: pagination.current,
size: pagination.pageSize,
search: searchQuery,
filter: 0,
order: order,
field: field,
archived: includeArchivedProjects,
};
const response = await reportingApiService.getOverviewProjects(params);
if (response.done) {
setProjectList(response.body.projects || []);
setPagination({ ...pagination, total: response.body.total });
}
} catch (error) {
logger.error('fetchOverviewProjects', error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchOverviewProjects();
}, [searchQuery, order, field]);
return (
<ConfigProvider {...tableConfig}>
<Table
columns={columns}
dataSource={projectList}
pagination={{
showSizeChanger: true,
defaultPageSize: 10,
total: pagination.total,
current: pagination.current,
pageSizeOptions: PAGE_SIZE_OPTIONS,
}}
scroll={{ x: 'max-content' }}
loading={isLoading}
onChange={handleTableChange}
rowKey={record => record.id}
onRow={() => tableRowProps}
/>
{createPortal(<ProjectReportsDrawer selectedProject={selectedProject} />, document.body)}
</ConfigProvider>
);
};
export default ReportingOverviewProjectsTable;

View File

@@ -0,0 +1,188 @@
import { CaretDownFilled, DownOutlined } from '@ant-design/icons';
import { Button, Card, DatePicker, Divider, Dropdown, Flex, List, Typography } from 'antd';
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import dayjs from 'dayjs';
import { colors } from '@/styles/colors';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { durations } from '@/shared/constants';
import { setDateRange, setDuration } from '@/features/reporting/reporting.slice';
const TimeWiseFilter = () => {
const { t } = useTranslation('reporting-members');
const { mode: themeMode } = useAppSelector(state => state.themeReducer);
const dispatch = useAppDispatch();
// Get values from Redux store
const { duration, dateRange } = useAppSelector(state => state.reportingReducer);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [selectedTimeFrame, setSelectedTimeFrame] = useState<string>(
durations.find(item => item.key === duration)?.label || 'lastSevenDaysText'
);
// const [customRange, setCustomRange] = useState<[string, string] | null>(
// dateRange.length === 2 ? [dateRange[0], dateRange[1]] : null
// );
const [customRange, setCustomRange] = useState<[string, string] | null>(null);
// Format customRange for display
const getDisplayLabel = () => {
const f = 'YY-MM-DD';
if (customRange && customRange.length === 2) {
return `${dayjs(customRange[0]).format(f)} - ${dayjs(customRange[1]).format(f)}`;
}
return t(selectedTimeFrame);
};
// Apply changes when date range is selected
const handleDateRangeChange = (dates: any, dateStrings: [string, string]) => {
if (dates) {
setSelectedTimeFrame('');
setCustomRange([dates[0].$d.toString(), dates[1].$d.toString()]);
} else {
setCustomRange(null);
}
};
// Apply custom date filter
const applyCustomDateFilter = () => {
if (customRange) {
setSelectedTimeFrame('customRange');
setIsDropdownOpen(false);
dispatch(setDateRange([customRange[0], customRange[1]]));
}
};
// Handle duration item selection
const handleDurationSelect = (item: any) => {
setSelectedTimeFrame(item.label);
setCustomRange(null);
dispatch(setDuration(item.key));
if (item.key === 'YESTERDAY') {
const yesterday = dayjs().subtract(1, 'day').format('YYYY-MM-DD');
dispatch(setDateRange([yesterday, yesterday]));
} else if (item.dates) {
const [startDate, endDate] = item.dates.split(' - ');
dispatch(setDateRange([startDate, endDate]));
} else {
// For ALL_TIME or any other case without specific dates, use a default range
const defaultStartDate = dayjs().subtract(1, 'year').format('YYYY-MM-DD');
const defaultEndDate = dayjs().format('YYYY-MM-DD');
dispatch(setDateRange([defaultStartDate, defaultEndDate]));
}
setIsDropdownOpen(false);
};
useEffect(() => {
const selectedDuration = durations.find(item => item.key === duration);
if (selectedDuration?.dates) {
if (duration === 'YESTERDAY') {
const yesterday = dayjs().subtract(1, 'day').format('YYYY-MM-DD');
dispatch(setDateRange([yesterday, yesterday]));
} else {
const [startDate, endDate] = selectedDuration.dates.split(' - ');
dispatch(setDateRange([startDate, endDate]));
}
} else {
dispatch(setDateRange([]));
}
}, [duration]);
// custom dropdown content
const timeWiseDropdownContent = (
<Card
className="custom-card"
styles={{
body: {
padding: 0,
minWidth: 320,
maxHeight: 330,
overflowY: 'auto',
},
}}
>
<List style={{ padding: 0 }}>
{durations.map(item => (
<List.Item
className={`custom-list-item ${themeMode === 'dark' ? 'dark' : ''}`}
key={item.key}
style={{
display: 'flex',
justifyContent: 'space-between',
gap: 24,
padding: '4px 8px',
backgroundColor:
selectedTimeFrame === item.label && themeMode === 'dark'
? '#424242'
: selectedTimeFrame === item.label && themeMode === 'light'
? colors.paleBlue
: colors.transparent,
border: 'none',
cursor: 'pointer',
}}
onClick={() => handleDurationSelect(item)}
>
<Typography.Text
style={{
color: selectedTimeFrame === item.label ? colors.skyBlue : 'inherit',
}}
>
{t(item.label)}
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{item.dates ? dayjs(item.dates.split(' - ')[0]).format('MMM DD, YYYY') + ' - ' + dayjs(item.dates.split(' - ')[1]).format('MMM DD, YYYY') : ''}
</Typography.Text>
</List.Item>
))}
</List>
<Divider style={{ marginBlock: 12 }} />
<Flex vertical gap={8} style={{ padding: 8 }}>
<Typography.Text>{t('customRangeText')}</Typography.Text>
<DatePicker.RangePicker
format={'MMM DD, YYYY'}
onChange={handleDateRangeChange}
value={customRange ? [dayjs(customRange[0]), dayjs(customRange[1])] : null}
/>
<Button
type="primary"
size="small"
style={{ width: 'fit-content', alignSelf: 'flex-end' }}
onClick={applyCustomDateFilter}
disabled={!customRange}
>
{t('filterButton')}
</Button>
</Flex>
</Card>
);
return (
<Dropdown
overlayClassName="custom-dropdown"
trigger={['click']}
dropdownRender={() => timeWiseDropdownContent}
onOpenChange={open => setIsDropdownOpen(open)}
open={isDropdownOpen}
>
<Button
icon={<DownOutlined />}
iconPosition="end"
className={`transition-colors duration-300 ${
isDropdownOpen
? 'border-[#1890ff] text-[#1890ff]'
: 'hover:text-[#1890ff hover:border-[#1890ff]'
}`}
>
{getDisplayLabel()}
</Button>
</Dropdown>
);
};
export default TimeWiseFilter;