expand sub tasks
This commit is contained in:
@@ -52,7 +52,8 @@ const AccountSetup: React.FC = () => {
|
||||
trackMixpanelEvent(evt_account_setup_visit);
|
||||
const verifyAuthStatus = async () => {
|
||||
try {
|
||||
const response = (await dispatch(verifyAuthentication()).unwrap()).payload as IAuthorizeResponse;
|
||||
const response = (await dispatch(verifyAuthentication()).unwrap())
|
||||
.payload as IAuthorizeResponse;
|
||||
if (response?.authenticated) {
|
||||
setSession(response.user);
|
||||
dispatch(setUser(response.user));
|
||||
@@ -152,9 +153,7 @@ const AccountSetup: React.FC = () => {
|
||||
const model: IAccountSetupRequest = {
|
||||
team_name: sanitizeInput(organizationName),
|
||||
project_name: sanitizeInput(projectName),
|
||||
tasks: tasks
|
||||
.map(task => sanitizeInput(task.value.trim()))
|
||||
.filter(task => task !== ''),
|
||||
tasks: tasks.map(task => sanitizeInput(task.value.trim())).filter(task => task !== ''),
|
||||
team_members: skip
|
||||
? []
|
||||
: teamMembers
|
||||
|
||||
@@ -6,7 +6,7 @@ import Configuration from '@/components/admin-center/configuration/configuration
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const Billing: React.FC = () => {
|
||||
const { t } = useTranslation('admin-center/current-bill');;
|
||||
const { t } = useTranslation('admin-center/current-bill');
|
||||
|
||||
const items: TabsProps['items'] = [
|
||||
{
|
||||
|
||||
@@ -61,9 +61,7 @@ const Users: React.FC = () => {
|
||||
key: 'email',
|
||||
render: text => (
|
||||
<span className="email-hover">
|
||||
<Typography.Text copyable={{ text }}>
|
||||
{text}
|
||||
</Typography.Text>
|
||||
<Typography.Text copyable={{ text }}>{text}</Typography.Text>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -9,7 +9,10 @@ import PageHeader from '@components/AuthPageHeader';
|
||||
|
||||
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
||||
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||
import { evt_forgot_password_page_visit, evt_reset_password_click } from '@/shared/worklenz-analytics-events';
|
||||
import {
|
||||
evt_forgot_password_page_visit,
|
||||
evt_reset_password_click,
|
||||
} from '@/shared/worklenz-analytics-events';
|
||||
import { resetPassword, verifyAuthentication } from '@features/auth/authSlice';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { setSession } from '@/utils/session-helper';
|
||||
|
||||
@@ -79,7 +79,7 @@ const LoginPage: React.FC = () => {
|
||||
useEffect(() => {
|
||||
// Check and unregister ngsw-worker if present
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.getRegistrations().then(function(registrations) {
|
||||
navigator.serviceWorker.getRegistrations().then(function (registrations) {
|
||||
const ngswWorker = registrations.find(reg => reg.active?.scriptURL.includes('ngsw-worker'));
|
||||
if (ngswWorker) {
|
||||
ngswWorker.unregister().then(() => {
|
||||
|
||||
@@ -68,7 +68,10 @@ const SignupPage = () => {
|
||||
};
|
||||
|
||||
const enableGoogleLogin = import.meta.env.VITE_ENABLE_GOOGLE_LOGIN === 'true' || false;
|
||||
const enableRecaptcha = import.meta.env.VITE_ENABLE_RECAPTCHA === 'true' && import.meta.env.VITE_RECAPTCHA_SITE_KEY && import.meta.env.VITE_RECAPTCHA_SITE_KEY !== 'recaptcha-site-key';
|
||||
const enableRecaptcha =
|
||||
import.meta.env.VITE_ENABLE_RECAPTCHA === 'true' &&
|
||||
import.meta.env.VITE_RECAPTCHA_SITE_KEY &&
|
||||
import.meta.env.VITE_RECAPTCHA_SITE_KEY !== 'recaptcha-site-key';
|
||||
|
||||
useEffect(() => {
|
||||
trackMixpanelEvent(evt_signup_page_visit);
|
||||
@@ -94,10 +97,12 @@ const SignupPage = () => {
|
||||
if (enableRecaptcha && import.meta.env.VITE_RECAPTCHA_SITE_KEY) {
|
||||
// Check if site key is not the placeholder value
|
||||
if (import.meta.env.VITE_RECAPTCHA_SITE_KEY === 'recaptcha-site-key') {
|
||||
console.warn('Using placeholder reCAPTCHA site key. Please set a valid key in your environment variables.');
|
||||
console.warn(
|
||||
'Using placeholder reCAPTCHA site key. Please set a valid key in your environment variables.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = `https://www.google.com/recaptcha/api.js?render=${import.meta.env.VITE_RECAPTCHA_SITE_KEY}`;
|
||||
script.async = true;
|
||||
@@ -108,7 +113,7 @@ const SignupPage = () => {
|
||||
if (script && script.parentNode) {
|
||||
script.parentNode.removeChild(script);
|
||||
}
|
||||
|
||||
|
||||
const recaptchaElements = document.getElementsByClassName('grecaptcha-badge');
|
||||
while (recaptchaElements.length > 0) {
|
||||
const element = recaptchaElements[0];
|
||||
@@ -130,23 +135,26 @@ const SignupPage = () => {
|
||||
|
||||
const getRecaptchaToken = async () => {
|
||||
if (!enableRecaptcha) return '';
|
||||
|
||||
|
||||
// Check if site key is valid
|
||||
if (!import.meta.env.VITE_RECAPTCHA_SITE_KEY || import.meta.env.VITE_RECAPTCHA_SITE_KEY === 'recaptcha-site-key') {
|
||||
if (
|
||||
!import.meta.env.VITE_RECAPTCHA_SITE_KEY ||
|
||||
import.meta.env.VITE_RECAPTCHA_SITE_KEY === 'recaptcha-site-key'
|
||||
) {
|
||||
console.warn('Invalid reCAPTCHA site key. Skipping reCAPTCHA verification.');
|
||||
return 'skip-verification';
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
if (!window.grecaptcha) {
|
||||
reject('reCAPTCHA not loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
window.grecaptcha.ready(() => {
|
||||
window.grecaptcha!
|
||||
.execute(import.meta.env.VITE_RECAPTCHA_SITE_KEY, { action: 'signup' })
|
||||
window
|
||||
.grecaptcha!.execute(import.meta.env.VITE_RECAPTCHA_SITE_KEY, { action: 'signup' })
|
||||
.then((token: string) => {
|
||||
resolve(token);
|
||||
})
|
||||
@@ -165,17 +173,20 @@ const SignupPage = () => {
|
||||
const onFinish = async (values: IUserSignUpRequest) => {
|
||||
try {
|
||||
setValidating(true);
|
||||
|
||||
|
||||
if (enableRecaptcha) {
|
||||
try {
|
||||
const token = await getRecaptchaToken();
|
||||
|
||||
if (!token) {
|
||||
logger.error('Failed to get reCAPTCHA token');
|
||||
alertService.error(t('reCAPTCHAVerificationError'), t('reCAPTCHAVerificationErrorMessage'));
|
||||
alertService.error(
|
||||
t('reCAPTCHAVerificationError'),
|
||||
t('reCAPTCHAVerificationErrorMessage')
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Skip verification if we're using the special token due to invalid site key
|
||||
if (token !== 'skip-verification') {
|
||||
const verifyToken = await authApiService.verifyRecaptchaToken(token);
|
||||
@@ -191,7 +202,10 @@ const SignupPage = () => {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('Continuing signup despite reCAPTCHA error in development mode');
|
||||
} else {
|
||||
alertService.error(t('reCAPTCHAVerificationError'), t('reCAPTCHAVerificationErrorMessage'));
|
||||
alertService.error(
|
||||
t('reCAPTCHAVerificationError'),
|
||||
t('reCAPTCHAVerificationErrorMessage')
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -352,17 +366,14 @@ const SignupPage = () => {
|
||||
<Form.Item>
|
||||
<Typography.Paragraph style={{ fontSize: 14 }}>
|
||||
{t('bySigningUpText')}{' '}
|
||||
<a
|
||||
href="https://worklenz.com/privacy/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>{t('privacyPolicyLink')}</a>{' '}
|
||||
<a href="https://worklenz.com/privacy/" target="_blank" rel="noopener noreferrer">
|
||||
{t('privacyPolicyLink')}
|
||||
</a>{' '}
|
||||
{t('andText')}{' '}
|
||||
<a
|
||||
href="https://worklenz.com/terms/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>{t('termsOfUseLink')}</a>.
|
||||
<a href="https://worklenz.com/terms/" target="_blank" rel="noopener noreferrer">
|
||||
{t('termsOfUseLink')}
|
||||
</a>
|
||||
.
|
||||
</Typography.Paragraph>
|
||||
</Form.Item>
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ const HomePage = memo(() => {
|
||||
const dispatch = useAppDispatch();
|
||||
const isDesktop = useMediaQuery({ query: `(min-width: ${DESKTOP_MIN_WIDTH}px)` });
|
||||
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
|
||||
|
||||
|
||||
useDocumentTitle('Home');
|
||||
|
||||
// Memoize fetch function to prevent recreation on every render
|
||||
@@ -55,20 +55,26 @@ const HomePage = memo(() => {
|
||||
const handleProjectDrawerClose = useCallback(() => {}, []);
|
||||
|
||||
// Memoize desktop flex styles to prevent object recreation
|
||||
const desktopFlexStyle = useMemo(() => ({
|
||||
minWidth: TASK_LIST_MIN_WIDTH,
|
||||
width: '100%'
|
||||
}), []);
|
||||
const desktopFlexStyle = useMemo(
|
||||
() => ({
|
||||
minWidth: TASK_LIST_MIN_WIDTH,
|
||||
width: '100%',
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const sidebarFlexStyle = useMemo(() => ({
|
||||
width: '100%',
|
||||
maxWidth: SIDEBAR_MAX_WIDTH
|
||||
}), []);
|
||||
const sidebarFlexStyle = useMemo(
|
||||
() => ({
|
||||
width: '100%',
|
||||
maxWidth: SIDEBAR_MAX_WIDTH,
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
// Memoize components to prevent unnecessary re-renders
|
||||
const CreateProjectButtonComponent = useMemo(() => {
|
||||
if (!isOwnerOrAdmin) return null;
|
||||
|
||||
|
||||
return isDesktop ? (
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2">
|
||||
<CreateProjectButton />
|
||||
@@ -106,15 +112,15 @@ const HomePage = memo(() => {
|
||||
</Col>
|
||||
|
||||
{MainContent}
|
||||
|
||||
|
||||
{/* Use Suspense for lazy-loaded components */}
|
||||
<Suspense fallback={null}>
|
||||
{createPortal(<TaskDrawer />, document.body, 'home-task-drawer')}
|
||||
</Suspense>
|
||||
|
||||
|
||||
{createPortal(
|
||||
<ProjectDrawer onClose={handleProjectDrawerClose} />,
|
||||
document.body,
|
||||
<ProjectDrawer onClose={handleProjectDrawerClose} />,
|
||||
document.body,
|
||||
'project-drawer'
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -23,7 +23,7 @@ const MY_PROJECTS_FILTER_KEY = 'my-dashboard-active-projects-filter';
|
||||
const RecentAndFavouriteProjectList = () => {
|
||||
const { t } = useTranslation('home');
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
||||
const [projectSegment, setProjectSegment] = useState<'Recent' | 'Favourites'>('Recent');
|
||||
|
||||
const getActiveProjectsFilter = useCallback(() => {
|
||||
@@ -77,7 +77,9 @@ const RecentAndFavouriteProjectList = () => {
|
||||
<Typography.Paragraph
|
||||
key={record.id}
|
||||
style={{ margin: 0, paddingInlineEnd: 6, cursor: 'pointer' }}
|
||||
onClick={() => navigate(`/worklenz/projects/${record.id}?tab=tasks-list&pinned_tab=tasks-list`)}
|
||||
onClick={() =>
|
||||
navigate(`/worklenz/projects/${record.id}?tab=tasks-list&pinned_tab=tasks-list`)
|
||||
}
|
||||
>
|
||||
<Badge color={record.color_code} style={{ marginInlineEnd: 4 }} />
|
||||
{record.name}
|
||||
@@ -119,7 +121,7 @@ const RecentAndFavouriteProjectList = () => {
|
||||
<Segmented<'Recent' | 'Favourites'>
|
||||
options={[
|
||||
{ value: 'Recent', label: t('projects.recent') },
|
||||
{ value: 'Favourites', label: t('projects.favourites') }
|
||||
{ value: 'Favourites', label: t('projects.favourites') },
|
||||
]}
|
||||
defaultValue={getActiveProjectsFilter() === 0 ? 'Recent' : 'Favourites'}
|
||||
onChange={handleSegmentChange}
|
||||
|
||||
@@ -60,7 +60,7 @@ const AddTaskInlineForm = ({ t, calendarView }: AddTaskInlineFormProps) => {
|
||||
const calculateEndDate = (dueDate: string): string | undefined => {
|
||||
const today = new Date();
|
||||
let targetDate: Date;
|
||||
|
||||
|
||||
switch (dueDate) {
|
||||
case 'Today':
|
||||
targetDate = new Date(today);
|
||||
@@ -80,7 +80,7 @@ const AddTaskInlineForm = ({ t, calendarView }: AddTaskInlineFormProps) => {
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
return targetDate.toISOString().split('T')[0]; // Returns YYYY-MM-DD format
|
||||
};
|
||||
|
||||
@@ -93,10 +93,10 @@ const AddTaskInlineForm = ({ t, calendarView }: AddTaskInlineFormProps) => {
|
||||
];
|
||||
|
||||
const handleTaskSubmit = (values: { name: string; project: string; dueDate: string }) => {
|
||||
const endDate = calendarView
|
||||
? homeTasksConfig.selected_date?.format('YYYY-MM-DD')
|
||||
const endDate = calendarView
|
||||
? homeTasksConfig.selected_date?.format('YYYY-MM-DD')
|
||||
: calculateEndDate(values.dueDate);
|
||||
|
||||
|
||||
const newTask = {
|
||||
name: values.name,
|
||||
project_id: values.project,
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
import React, { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
|
||||
import ListView from './list-view';
|
||||
import CalendarView from './calendar-view';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
@@ -25,7 +24,11 @@ import EmptyListPlaceholder from '@components/EmptyListPlaceholder';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { setHomeTasksConfig } from '@/features/home-page/home-page.slice';
|
||||
import { IMyTask } from '@/types/home/my-tasks.types';
|
||||
import { setSelectedTaskId, setShowTaskDrawer, fetchTask } from '@/features/task-drawer/task-drawer.slice';
|
||||
import {
|
||||
setSelectedTaskId,
|
||||
setShowTaskDrawer,
|
||||
fetchTask,
|
||||
} from '@/features/task-drawer/task-drawer.slice';
|
||||
import { useGetMyTasksQuery } from '@/api/home-page/home-page.api.service';
|
||||
import { IHomeTasksModel } from '@/types/home/home-page.types';
|
||||
import './tasks-list.css';
|
||||
@@ -36,7 +39,6 @@ import { fetchPriorities } from '@/features/taskAttributes/taskPrioritySlice';
|
||||
import { setProjectId } from '@/features/project/project.slice';
|
||||
import { getTeamMembers } from '@/features/team-members/team-members.slice';
|
||||
|
||||
|
||||
const TasksList: React.FC = React.memo(() => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
@@ -56,7 +58,7 @@ const TasksList: React.FC = React.memo(() => {
|
||||
skip: skipAutoRefetch,
|
||||
refetchOnMountOrArgChange: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnFocus: false
|
||||
refetchOnFocus: false,
|
||||
});
|
||||
|
||||
const { t } = useTranslation('home');
|
||||
@@ -86,16 +88,21 @@ const TasksList: React.FC = React.memo(() => {
|
||||
useEffect(() => {
|
||||
dispatch(fetchLabels());
|
||||
dispatch(fetchPriorities());
|
||||
dispatch(getTeamMembers({ index: 0, size: 100, field: null, order: null, search: null, all: true }));
|
||||
dispatch(
|
||||
getTeamMembers({ index: 0, size: 100, field: null, order: null, search: null, all: true })
|
||||
);
|
||||
}, [dispatch]);
|
||||
|
||||
const handleSelectTask = useCallback((task: IMyTask) => {
|
||||
dispatch(setSelectedTaskId(task.id || ''));
|
||||
dispatch(fetchTask({ taskId: task.id || '', projectId: task.project_id || '' }));
|
||||
dispatch(setProjectId(task.project_id || ''));
|
||||
dispatch(setShowTaskDrawer(true));
|
||||
dispatch(setHomeTasksConfig({ ...homeTasksConfig, selected_task_id: task.id || '' }));
|
||||
}, [dispatch, setSelectedTaskId, setShowTaskDrawer, fetchTask, homeTasksConfig]);
|
||||
const handleSelectTask = useCallback(
|
||||
(task: IMyTask) => {
|
||||
dispatch(setSelectedTaskId(task.id || ''));
|
||||
dispatch(fetchTask({ taskId: task.id || '', projectId: task.project_id || '' }));
|
||||
dispatch(setProjectId(task.project_id || ''));
|
||||
dispatch(setShowTaskDrawer(true));
|
||||
dispatch(setHomeTasksConfig({ ...homeTasksConfig, selected_task_id: task.id || '' }));
|
||||
},
|
||||
[dispatch, setSelectedTaskId, setShowTaskDrawer, fetchTask, homeTasksConfig]
|
||||
);
|
||||
|
||||
const refetch = useCallback(() => {
|
||||
setSkipAutoRefetch(false);
|
||||
@@ -120,10 +127,7 @@ const TasksList: React.FC = React.memo(() => {
|
||||
render: (_, record) => (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Tooltip title={record.name}>
|
||||
<Typography.Text
|
||||
ellipsis={{ tooltip: true }}
|
||||
style={{ maxWidth: 150 }}
|
||||
>
|
||||
<Typography.Text ellipsis={{ tooltip: true }} style={{ maxWidth: 150 }}>
|
||||
{record.name}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
@@ -155,7 +159,10 @@ const TasksList: React.FC = React.memo(() => {
|
||||
render: (_, record) => {
|
||||
return (
|
||||
<Tooltip title={record.project_name}>
|
||||
<Typography.Paragraph style={{ margin: 0, paddingInlineEnd: 6, maxWidth: 120 }} ellipsis={{ tooltip: true }}>
|
||||
<Typography.Paragraph
|
||||
style={{ margin: 0, paddingInlineEnd: 6, maxWidth: 120 }}
|
||||
ellipsis={{ tooltip: true }}
|
||||
>
|
||||
<Badge color={record.phase_color || 'blue'} style={{ marginInlineEnd: 4 }} />
|
||||
{record.project_name}
|
||||
</Typography.Paragraph>
|
||||
@@ -176,9 +183,7 @@ const TasksList: React.FC = React.memo(() => {
|
||||
title: t('tasks.dueDate'),
|
||||
width: '180px',
|
||||
dataIndex: 'end_date',
|
||||
render: (_, record) => (
|
||||
<HomeTasksDatePicker record={record} />
|
||||
),
|
||||
render: (_, record) => <HomeTasksDatePicker record={record} />,
|
||||
},
|
||||
],
|
||||
[t, data?.body?.total, currentPage, pageSize, handlePageChange]
|
||||
@@ -201,7 +206,9 @@ const TasksList: React.FC = React.memo(() => {
|
||||
useEffect(() => {
|
||||
dispatch(fetchLabels());
|
||||
dispatch(fetchPriorities());
|
||||
dispatch(getTeamMembers({ index: 0, size: 100, field: null, order: null, search: null, all: true }));
|
||||
dispatch(
|
||||
getTeamMembers({ index: 0, size: 100, field: null, order: null, search: null, all: true })
|
||||
);
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
@@ -231,7 +238,7 @@ const TasksList: React.FC = React.memo(() => {
|
||||
<Segmented<'List' | 'Calendar'>
|
||||
options={[
|
||||
{ value: 'List', label: t('tasks.list') },
|
||||
{ value: 'Calendar', label: t('tasks.calendar') }
|
||||
{ value: 'Calendar', label: t('tasks.calendar') },
|
||||
]}
|
||||
defaultValue="List"
|
||||
onChange={handleSegmentChange}
|
||||
@@ -266,7 +273,11 @@ const TasksList: React.FC = React.memo(() => {
|
||||
<>
|
||||
<Table
|
||||
className="custom-two-colors-row-table"
|
||||
dataSource={data?.body.tasks ? data.body.tasks.slice((currentPage - 1) * pageSize, currentPage * pageSize) : []}
|
||||
dataSource={
|
||||
data?.body.tasks
|
||||
? data.body.tasks.slice((currentPage - 1) * pageSize, currentPage * pageSize)
|
||||
: []
|
||||
}
|
||||
rowKey={record => record.id || ''}
|
||||
columns={columns as TableProps<IMyTask>['columns']}
|
||||
size="middle"
|
||||
@@ -275,7 +286,14 @@ const TasksList: React.FC = React.memo(() => {
|
||||
pagination={false}
|
||||
/>
|
||||
|
||||
<div style={{ marginTop: 16, textAlign: 'right', display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
textAlign: 'right',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
}}
|
||||
>
|
||||
<Pagination
|
||||
current={currentPage}
|
||||
pageSize={pageSize}
|
||||
|
||||
@@ -8,21 +8,23 @@ const LicenseExpired = () => {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('license-expired');
|
||||
const authService = useAuthService();
|
||||
|
||||
|
||||
// Direct fallback content in case of translation issues
|
||||
const fallbackTitle = "Your Worklenz trial has expired!";
|
||||
const fallbackSubtitle = "Please upgrade now.";
|
||||
const fallbackButton = "Upgrade now";
|
||||
const fallbackTitle = 'Your Worklenz trial has expired!';
|
||||
const fallbackSubtitle = 'Please upgrade now.';
|
||||
const fallbackButton = 'Upgrade now';
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
marginBlock: 65,
|
||||
minHeight: '90vh',
|
||||
padding: '20px',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
marginBlock: 65,
|
||||
minHeight: '90vh',
|
||||
padding: '20px',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Result
|
||||
status="warning"
|
||||
title={t('title') || fallbackTitle}
|
||||
|
||||
@@ -105,4 +105,4 @@
|
||||
margin-top: 8px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ const createFilters = (items: { id: string; name: string }[]) =>
|
||||
const ProjectList: React.FC = () => {
|
||||
const [filteredInfo, setFilteredInfo] = useState<Record<string, FilterValue | null>>({});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
|
||||
const { t } = useTranslation('all-project-list');
|
||||
const dispatch = useAppDispatch();
|
||||
const navigate = useNavigate();
|
||||
@@ -94,14 +94,14 @@ const ProjectList: React.FC = () => {
|
||||
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||
|
||||
// Get view state from Redux
|
||||
const { mode: viewMode, groupBy } = useAppSelector((state) => state.projectViewReducer);
|
||||
const { requestParams, groupedRequestParams, groupedProjects } = useAppSelector(state => state.projectsReducer);
|
||||
const { mode: viewMode, groupBy } = useAppSelector(state => state.projectViewReducer);
|
||||
const { requestParams, groupedRequestParams, groupedProjects } = useAppSelector(
|
||||
state => state.projectsReducer
|
||||
);
|
||||
const { projectStatuses } = useAppSelector(state => state.projectStatusesReducer);
|
||||
const { projectHealths } = useAppSelector(state => state.projectHealthReducer);
|
||||
const { projectCategories } = useAppSelector(state => state.projectCategoriesReducer);
|
||||
const { filteredCategories, filteredStatuses } = useAppSelector(
|
||||
state => state.projectsReducer
|
||||
);
|
||||
const { filteredCategories, filteredStatuses } = useAppSelector(state => state.projectsReducer);
|
||||
|
||||
const {
|
||||
data: projectsData,
|
||||
@@ -175,18 +175,20 @@ const ProjectList: React.FC = () => {
|
||||
);
|
||||
|
||||
// Memoize category filters to prevent unnecessary recalculations
|
||||
const categoryFilters = useMemo(() =>
|
||||
createFilters(
|
||||
projectCategories.map(category => ({ id: category.id || '', name: category.name || '' }))
|
||||
),
|
||||
const categoryFilters = useMemo(
|
||||
() =>
|
||||
createFilters(
|
||||
projectCategories.map(category => ({ id: category.id || '', name: category.name || '' }))
|
||||
),
|
||||
[projectCategories]
|
||||
);
|
||||
|
||||
// Memoize status filters to prevent unnecessary recalculations
|
||||
const statusFilters = useMemo(() =>
|
||||
createFilters(
|
||||
projectStatuses.map(status => ({ id: status.id || '', name: status.name || '' }))
|
||||
),
|
||||
const statusFilters = useMemo(
|
||||
() =>
|
||||
createFilters(
|
||||
projectStatuses.map(status => ({ id: status.id || '', name: status.name || '' }))
|
||||
),
|
||||
[projectStatuses]
|
||||
);
|
||||
|
||||
@@ -221,39 +223,36 @@ const ProjectList: React.FC = () => {
|
||||
if (viewMode === ProjectViewType.LIST) {
|
||||
return projectsData?.body?.total || 0;
|
||||
} else {
|
||||
return groupedProjects.data?.data?.reduce((total, group) => total + group.project_count, 0) || 0;
|
||||
return (
|
||||
groupedProjects.data?.data?.reduce((total, group) => total + group.project_count, 0) || 0
|
||||
);
|
||||
}
|
||||
}, [viewMode, projectsData?.body?.total, groupedProjects.data?.data]);
|
||||
|
||||
// Memoize the grouped projects data transformation
|
||||
const transformedGroupedProjects = useMemo(() => {
|
||||
return groupedProjects.data?.data?.map(group => ({
|
||||
groupKey: group.group_key,
|
||||
groupName: group.group_name,
|
||||
groupColor: group.group_color,
|
||||
projects: group.projects,
|
||||
count: group.project_count,
|
||||
totalProgress: 0,
|
||||
totalTasks: 0
|
||||
})) || [];
|
||||
return (
|
||||
groupedProjects.data?.data?.map(group => ({
|
||||
groupKey: group.group_key,
|
||||
groupName: group.group_name,
|
||||
groupColor: group.group_color,
|
||||
projects: group.projects,
|
||||
count: group.project_count,
|
||||
totalProgress: 0,
|
||||
totalTasks: 0,
|
||||
})) || []
|
||||
);
|
||||
}, [groupedProjects.data?.data]);
|
||||
|
||||
// Memoize the table data source
|
||||
const tableDataSource = useMemo(() =>
|
||||
projectsData?.body?.data || [],
|
||||
[projectsData?.body?.data]
|
||||
);
|
||||
const tableDataSource = useMemo(() => projectsData?.body?.data || [], [projectsData?.body?.data]);
|
||||
|
||||
// Memoize the empty text component
|
||||
const emptyText = useMemo(() =>
|
||||
<Empty description={t('noProjects')} />,
|
||||
[t]
|
||||
);
|
||||
const emptyText = useMemo(() => <Empty description={t('noProjects')} />, [t]);
|
||||
|
||||
// Memoize the pagination show total function
|
||||
const paginationShowTotal = useMemo(() =>
|
||||
(total: number, range: [number, number]) =>
|
||||
`${range[0]}-${range[1]} of ${total} groups`,
|
||||
const paginationShowTotal = useMemo(
|
||||
() => (total: number, range: [number, number]) => `${range[0]}-${range[1]} of ${total} groups`,
|
||||
[]
|
||||
);
|
||||
|
||||
@@ -291,18 +290,20 @@ const ProjectList: React.FC = () => {
|
||||
newParams.size = newPagination.pageSize || DEFAULT_PAGE_SIZE;
|
||||
|
||||
dispatch(setRequestParams(newParams));
|
||||
|
||||
|
||||
// Also update grouped request params to keep them in sync
|
||||
dispatch(setGroupedRequestParams({
|
||||
...groupedRequestParams,
|
||||
statuses: newParams.statuses,
|
||||
categories: newParams.categories,
|
||||
order: newParams.order,
|
||||
field: newParams.field,
|
||||
index: newParams.index,
|
||||
size: newParams.size,
|
||||
}));
|
||||
|
||||
dispatch(
|
||||
setGroupedRequestParams({
|
||||
...groupedRequestParams,
|
||||
statuses: newParams.statuses,
|
||||
categories: newParams.categories,
|
||||
order: newParams.order,
|
||||
field: newParams.field,
|
||||
index: newParams.index,
|
||||
size: newParams.size,
|
||||
})
|
||||
);
|
||||
|
||||
setFilteredInfo(filters);
|
||||
},
|
||||
[dispatch, setSortingValues, groupedRequestParams]
|
||||
@@ -332,24 +333,28 @@ const ProjectList: React.FC = () => {
|
||||
(value: IProjectFilter) => {
|
||||
const newFilterIndex = filters.indexOf(value);
|
||||
setFilterIndex(newFilterIndex);
|
||||
|
||||
|
||||
// Update both request params for consistency
|
||||
dispatch(setRequestParams({ filter: newFilterIndex }));
|
||||
dispatch(setGroupedRequestParams({
|
||||
...groupedRequestParams,
|
||||
filter: newFilterIndex,
|
||||
index: 1 // Reset to first page when changing filter
|
||||
}));
|
||||
|
||||
dispatch(
|
||||
setGroupedRequestParams({
|
||||
...groupedRequestParams,
|
||||
filter: newFilterIndex,
|
||||
index: 1, // Reset to first page when changing filter
|
||||
})
|
||||
);
|
||||
|
||||
// Refresh data based on current view mode
|
||||
if (viewMode === ProjectViewType.LIST) {
|
||||
refetchProjects();
|
||||
} else if (viewMode === ProjectViewType.GROUP && groupBy) {
|
||||
dispatch(fetchGroupedProjects({
|
||||
...groupedRequestParams,
|
||||
filter: newFilterIndex,
|
||||
index: 1
|
||||
}));
|
||||
dispatch(
|
||||
fetchGroupedProjects({
|
||||
...groupedRequestParams,
|
||||
filter: newFilterIndex,
|
||||
index: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
},
|
||||
[filters, setFilterIndex, dispatch, refetchProjects, viewMode, groupBy, groupedRequestParams]
|
||||
@@ -369,18 +374,18 @@ const ProjectList: React.FC = () => {
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const searchValue = e.target.value;
|
||||
trackMixpanelEvent(evt_projects_search);
|
||||
|
||||
|
||||
// Update both request params for consistency
|
||||
dispatch(setRequestParams({ search: searchValue, index: 1 }));
|
||||
|
||||
|
||||
if (viewMode === ProjectViewType.GROUP) {
|
||||
const newGroupedParams = {
|
||||
...groupedRequestParams,
|
||||
search: searchValue,
|
||||
index: 1
|
||||
search: searchValue,
|
||||
index: 1,
|
||||
};
|
||||
dispatch(setGroupedRequestParams(newGroupedParams));
|
||||
|
||||
|
||||
// Trigger debounced search in group mode
|
||||
debouncedGroupedSearch(newGroupedParams);
|
||||
}
|
||||
@@ -429,13 +434,16 @@ const ProjectList: React.FC = () => {
|
||||
dispatch(setProjectId(null));
|
||||
}, [dispatch]);
|
||||
|
||||
const navigateToProject = useCallback((project_id: string | undefined, default_view: string | undefined) => {
|
||||
if (project_id) {
|
||||
navigate(
|
||||
`/worklenz/projects/${project_id}?tab=${default_view === 'BOARD' ? 'board' : 'tasks-list'}&pinned_tab=${default_view === 'BOARD' ? 'board' : 'tasks-list'}`
|
||||
);
|
||||
}
|
||||
}, [navigate]);
|
||||
const navigateToProject = useCallback(
|
||||
(project_id: string | undefined, default_view: string | undefined) => {
|
||||
if (project_id) {
|
||||
navigate(
|
||||
`/worklenz/projects/${project_id}?tab=${default_view === 'BOARD' ? 'board' : 'tasks-list'}&pinned_tab=${default_view === 'BOARD' ? 'board' : 'tasks-list'}`
|
||||
);
|
||||
}
|
||||
},
|
||||
[navigate]
|
||||
);
|
||||
|
||||
// Preload project view components on hover for smoother navigation
|
||||
const handleProjectHover = useCallback((project_id: string | undefined) => {
|
||||
@@ -444,7 +452,7 @@ const ProjectList: React.FC = () => {
|
||||
import('@/pages/projects/projectView/project-view').catch(() => {
|
||||
// Silently fail if preload doesn't work
|
||||
});
|
||||
|
||||
|
||||
// Also preload critical task management components
|
||||
import('@/components/task-management/task-list-board').catch(() => {
|
||||
// Silently fail if preload doesn't work
|
||||
@@ -536,7 +544,17 @@ const ProjectList: React.FC = () => {
|
||||
),
|
||||
},
|
||||
],
|
||||
[t, categoryFilters, statusFilters, filteredInfo, filteredCategories, filteredStatuses, navigate, dispatch, isOwnerOrAdmin]
|
||||
[
|
||||
t,
|
||||
categoryFilters,
|
||||
statusFilters,
|
||||
filteredInfo,
|
||||
filteredCategories,
|
||||
filteredStatuses,
|
||||
navigate,
|
||||
dispatch,
|
||||
isOwnerOrAdmin,
|
||||
]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -551,17 +569,19 @@ const ProjectList: React.FC = () => {
|
||||
const filterIndex = getFilterIndex();
|
||||
dispatch(setRequestParams({ filter: filterIndex }));
|
||||
// Also sync with grouped request params on initial load
|
||||
dispatch(setGroupedRequestParams({
|
||||
filter: filterIndex,
|
||||
index: 1,
|
||||
size: DEFAULT_PAGE_SIZE,
|
||||
field: 'name',
|
||||
order: 'ascend',
|
||||
search: '',
|
||||
groupBy: '',
|
||||
statuses: null,
|
||||
categories: null,
|
||||
}));
|
||||
dispatch(
|
||||
setGroupedRequestParams({
|
||||
filter: filterIndex,
|
||||
index: 1,
|
||||
size: DEFAULT_PAGE_SIZE,
|
||||
field: 'name',
|
||||
order: 'ascend',
|
||||
search: '',
|
||||
groupBy: '',
|
||||
statuses: null,
|
||||
categories: null,
|
||||
})
|
||||
);
|
||||
}, [dispatch, getFilterIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -605,11 +625,7 @@ const ProjectList: React.FC = () => {
|
||||
defaultValue={filters[getFilterIndex()] ?? filters[0]}
|
||||
onChange={handleSegmentChange}
|
||||
/>
|
||||
<Segmented
|
||||
options={viewToggleOptions}
|
||||
value={viewMode}
|
||||
onChange={handleViewToggle}
|
||||
/>
|
||||
<Segmented options={viewToggleOptions} value={viewMode} onChange={handleViewToggle} />
|
||||
{viewMode === ProjectViewType.GROUP && (
|
||||
<Select
|
||||
value={groupBy}
|
||||
@@ -658,15 +674,19 @@ const ProjectList: React.FC = () => {
|
||||
loading={groupedProjects.loading}
|
||||
t={t}
|
||||
/>
|
||||
{!groupedProjects.loading && groupedProjects.data?.data && groupedProjects.data.data.length > 0 && (
|
||||
<div style={{ marginTop: '24px', textAlign: 'center' }}>
|
||||
<Pagination
|
||||
{...groupedPaginationConfig}
|
||||
onChange={(page, pageSize) => handleGroupedTableChange({ current: page, pageSize })}
|
||||
showTotal={paginationShowTotal}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!groupedProjects.loading &&
|
||||
groupedProjects.data?.data &&
|
||||
groupedProjects.data.data.length > 0 && (
|
||||
<div style={{ marginTop: '24px', textAlign: 'center' }}>
|
||||
<Pagination
|
||||
{...groupedPaginationConfig}
|
||||
onChange={(page, pageSize) =>
|
||||
handleGroupedTableChange({ current: page, pageSize })
|
||||
}
|
||||
showTotal={paginationShowTotal}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Skeleton>
|
||||
@@ -677,4 +697,4 @@ const ProjectList: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectList;
|
||||
export default ProjectList;
|
||||
|
||||
@@ -13,11 +13,7 @@ import {
|
||||
TouchSensor,
|
||||
UniqueIdentifier,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
useSortable,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { sortableKeyboardCoordinates } from '@dnd-kit/sortable';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
@@ -35,28 +31,25 @@ import { evt_project_task_list_drag_and_move } from '@/shared/worklenz-analytics
|
||||
interface DraggableRowProps {
|
||||
task: IProjectTask;
|
||||
visibleColumns: Array<{ key: string; width: number }>;
|
||||
renderCell: (columnKey: string | number, task: IProjectTask, isSubtask?: boolean) => React.ReactNode;
|
||||
renderCell: (
|
||||
columnKey: string | number,
|
||||
task: IProjectTask,
|
||||
isSubtask?: boolean
|
||||
) => React.ReactNode;
|
||||
hoverRow: string | null;
|
||||
onRowHover: (taskId: string | null) => void;
|
||||
isSubtask?: boolean;
|
||||
}
|
||||
|
||||
const DraggableRow = ({
|
||||
task,
|
||||
visibleColumns,
|
||||
renderCell,
|
||||
hoverRow,
|
||||
const DraggableRow = ({
|
||||
task,
|
||||
visibleColumns,
|
||||
renderCell,
|
||||
hoverRow,
|
||||
onRowHover,
|
||||
isSubtask = false
|
||||
isSubtask = false,
|
||||
}: DraggableRowProps) => {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: task.id as UniqueIdentifier,
|
||||
data: {
|
||||
type: 'task',
|
||||
@@ -119,11 +112,11 @@ const TaskListTable = ({
|
||||
const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null);
|
||||
const tableRef = useRef<HTMLDivElement | null>(null);
|
||||
const parentRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const { projectId } = useAppSelector(state => state.projectReducer);
|
||||
const groupBy = useAppSelector(state => state.taskReducer.groupBy);
|
||||
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const { socket } = useSocket();
|
||||
const currentSession = useAuthService().getCurrentSession();
|
||||
@@ -176,7 +169,7 @@ const TaskListTable = ({
|
||||
);
|
||||
},
|
||||
task: () => (
|
||||
<Flex align="center" className={isSubtask ? "pl-6" : "pl-2"}>
|
||||
<Flex align="center" className={isSubtask ? 'pl-6' : 'pl-2'}>
|
||||
{task.name}
|
||||
</Flex>
|
||||
),
|
||||
@@ -195,69 +188,74 @@ const TaskListTable = ({
|
||||
}, []);
|
||||
|
||||
// Handle drag end with socket integration
|
||||
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
setActiveId(null);
|
||||
document.body.style.cursor = '';
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (!over || active.id === over.id) {
|
||||
return;
|
||||
}
|
||||
setActiveId(null);
|
||||
document.body.style.cursor = '';
|
||||
|
||||
const activeIndex = mainTasks.findIndex(task => task.id === active.id);
|
||||
const overIndex = mainTasks.findIndex(task => task.id === over.id);
|
||||
|
||||
if (activeIndex !== -1 && overIndex !== -1) {
|
||||
const activeTask = mainTasks[activeIndex];
|
||||
const overTask = mainTasks[overIndex];
|
||||
|
||||
// Create updated task arrays
|
||||
const updatedTasks = [...mainTasks];
|
||||
updatedTasks.splice(activeIndex, 1);
|
||||
updatedTasks.splice(overIndex, 0, activeTask);
|
||||
|
||||
// Dispatch Redux action for optimistic update
|
||||
dispatch(reorderTasks({
|
||||
activeGroupId: tableId,
|
||||
overGroupId: tableId,
|
||||
fromIndex: activeIndex,
|
||||
toIndex: overIndex,
|
||||
task: activeTask,
|
||||
updatedSourceTasks: updatedTasks,
|
||||
updatedTargetTasks: updatedTasks,
|
||||
}));
|
||||
|
||||
// Emit socket event for backend persistence
|
||||
if (socket && projectId && currentSession?.team_id) {
|
||||
const toPos = overTask?.sort_order || mainTasks[mainTasks.length - 1]?.sort_order || -1;
|
||||
|
||||
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), {
|
||||
project_id: projectId,
|
||||
from_index: activeTask.sort_order,
|
||||
to_index: toPos,
|
||||
to_last_index: overIndex === mainTasks.length - 1,
|
||||
from_group: tableId,
|
||||
to_group: tableId,
|
||||
group_by: groupBy,
|
||||
task: activeTask,
|
||||
team_id: currentSession.team_id,
|
||||
});
|
||||
|
||||
// Track analytics event
|
||||
trackMixpanelEvent(evt_project_task_list_drag_and_move);
|
||||
if (!over || active.id === over.id) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, [
|
||||
mainTasks,
|
||||
tableId,
|
||||
dispatch,
|
||||
socket,
|
||||
projectId,
|
||||
currentSession?.team_id,
|
||||
groupBy,
|
||||
trackMixpanelEvent
|
||||
]);
|
||||
|
||||
const activeIndex = mainTasks.findIndex(task => task.id === active.id);
|
||||
const overIndex = mainTasks.findIndex(task => task.id === over.id);
|
||||
|
||||
if (activeIndex !== -1 && overIndex !== -1) {
|
||||
const activeTask = mainTasks[activeIndex];
|
||||
const overTask = mainTasks[overIndex];
|
||||
|
||||
// Create updated task arrays
|
||||
const updatedTasks = [...mainTasks];
|
||||
updatedTasks.splice(activeIndex, 1);
|
||||
updatedTasks.splice(overIndex, 0, activeTask);
|
||||
|
||||
// Dispatch Redux action for optimistic update
|
||||
dispatch(
|
||||
reorderTasks({
|
||||
activeGroupId: tableId,
|
||||
overGroupId: tableId,
|
||||
fromIndex: activeIndex,
|
||||
toIndex: overIndex,
|
||||
task: activeTask,
|
||||
updatedSourceTasks: updatedTasks,
|
||||
updatedTargetTasks: updatedTasks,
|
||||
})
|
||||
);
|
||||
|
||||
// Emit socket event for backend persistence
|
||||
if (socket && projectId && currentSession?.team_id) {
|
||||
const toPos = overTask?.sort_order || mainTasks[mainTasks.length - 1]?.sort_order || -1;
|
||||
|
||||
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), {
|
||||
project_id: projectId,
|
||||
from_index: activeTask.sort_order,
|
||||
to_index: toPos,
|
||||
to_last_index: overIndex === mainTasks.length - 1,
|
||||
from_group: tableId,
|
||||
to_group: tableId,
|
||||
group_by: groupBy,
|
||||
task: activeTask,
|
||||
team_id: currentSession.team_id,
|
||||
});
|
||||
|
||||
// Track analytics event
|
||||
trackMixpanelEvent(evt_project_task_list_drag_and_move);
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
mainTasks,
|
||||
tableId,
|
||||
dispatch,
|
||||
socket,
|
||||
projectId,
|
||||
currentSession?.team_id,
|
||||
groupBy,
|
||||
trackMixpanelEvent,
|
||||
]
|
||||
);
|
||||
|
||||
// Memoize header rendering
|
||||
const TableHeader = useMemo(
|
||||
@@ -291,15 +289,14 @@ const TaskListTable = ({
|
||||
const activeTask = activeId ? flattenedTasks.find(task => task.id === activeId) : null;
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<DndContext sensors={sensors} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
||||
<div ref={parentRef} className="h-[400px] overflow-auto" onScroll={handleScroll}>
|
||||
{TableHeader}
|
||||
|
||||
<SortableContext items={mainTasks.map(task => task.id)} strategy={verticalListSortingStrategy}>
|
||||
<SortableContext
|
||||
items={mainTasks.map(task => task.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div ref={tableRef} style={{ width: '100%' }}>
|
||||
{flattenedTasks.map((task, index) => (
|
||||
<DraggableRow
|
||||
|
||||
@@ -40,10 +40,7 @@ const ProjectViewTaskList = () => {
|
||||
|
||||
return (
|
||||
<Flex vertical gap={16} style={{ overflowX: 'hidden' }}>
|
||||
<TaskListBoard
|
||||
projectId={projectId}
|
||||
className="task-list-board"
|
||||
/>
|
||||
<TaskListBoard projectId={projectId} className="task-list-board" />
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -28,10 +28,7 @@ const MembersFilterDropdown = () => {
|
||||
|
||||
const { t } = useTranslation('task-list-filters');
|
||||
|
||||
const membersList = [
|
||||
...members,
|
||||
useAppSelector(state => state.memberReducer.owner),
|
||||
];
|
||||
const membersList = [...members, useAppSelector(state => state.memberReducer.owner)];
|
||||
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ const ShowFieldsFilterDropdown = () => {
|
||||
key: col.key,
|
||||
columnHeader: col.custom_column_obj.columnHeader,
|
||||
isCustomColumn: col.custom_column,
|
||||
}))
|
||||
})),
|
||||
];
|
||||
|
||||
const columnsVisibility = useAppSelector(
|
||||
@@ -77,7 +77,7 @@ const ShowFieldsFilterDropdown = () => {
|
||||
}
|
||||
/>
|
||||
{col.custom_column
|
||||
? col.columnHeader
|
||||
? col.columnHeader
|
||||
: t(col.key === 'phases' ? 'phasesText' : `${col.columnHeader}Text`)}
|
||||
</Space>
|
||||
</List.Item>
|
||||
|
||||
@@ -50,7 +50,9 @@ const TaskListTable = ({
|
||||
const selectedProject = useSelectedProject();
|
||||
|
||||
// get columns list details
|
||||
const columnsVisibility = useAppSelector( state => state.projectViewTaskListColumnsReducer.columnList );
|
||||
const columnsVisibility = useAppSelector(
|
||||
state => state.projectViewTaskListColumnsReducer.columnList
|
||||
);
|
||||
const visibleColumns = columnList.filter(
|
||||
column => columnsVisibility[column.key as keyof typeof columnsVisibility]
|
||||
);
|
||||
|
||||
@@ -1,4 +1,17 @@
|
||||
import { Button, ConfigProvider, Flex, Form, Mentions, Skeleton, Space, Tooltip, Typography, Dropdown, Menu, Popconfirm } from 'antd';
|
||||
import {
|
||||
Button,
|
||||
ConfigProvider,
|
||||
Flex,
|
||||
Form,
|
||||
Mentions,
|
||||
Skeleton,
|
||||
Space,
|
||||
Tooltip,
|
||||
Typography,
|
||||
Dropdown,
|
||||
Menu,
|
||||
Popconfirm,
|
||||
} from 'antd';
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import DOMPurify from 'dompurify';
|
||||
@@ -24,13 +37,10 @@ const MAX_COMMENT_LENGTH = 2000;
|
||||
const urlRegex = /((https?:\/\/|www\.)[\w\-._~:/?#[\]@!$&'()*+,;=%]+)/gi;
|
||||
|
||||
function linkify(text: string): string {
|
||||
return text.replace(
|
||||
urlRegex,
|
||||
url => {
|
||||
const href = url.startsWith('http') ? url : `https://${url}`;
|
||||
return `<a href="${href}" target="_blank" rel="noopener noreferrer">${url}</a>`;
|
||||
}
|
||||
);
|
||||
return text.replace(urlRegex, url => {
|
||||
const href = url.startsWith('http') ? url : `https://${url}`;
|
||||
return `<a href="${href}" target="_blank" rel="noopener noreferrer">${url}</a>`;
|
||||
});
|
||||
}
|
||||
|
||||
const ProjectViewUpdates = () => {
|
||||
@@ -54,7 +64,14 @@ const ProjectViewUpdates = () => {
|
||||
if (!projectId) return;
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const res = await projectCommentsApiService.getMentionMembers(projectId, 1, 15, null, null, null);
|
||||
const res = await projectCommentsApiService.getMentionMembers(
|
||||
projectId,
|
||||
1,
|
||||
15,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
if (res.done) {
|
||||
setMembers(res.body as IMentionMemberViewModel[]);
|
||||
}
|
||||
@@ -85,7 +102,7 @@ const ProjectViewUpdates = () => {
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
|
||||
|
||||
if (!commentValue) {
|
||||
console.error('Comment content is empty');
|
||||
return;
|
||||
@@ -95,7 +112,7 @@ const ProjectViewUpdates = () => {
|
||||
project_id: projectId,
|
||||
team_id: getUserSession()?.team_id,
|
||||
content: commentValue.trim(),
|
||||
mentions: selectedMembers
|
||||
mentions: selectedMembers,
|
||||
};
|
||||
|
||||
const res = await projectCommentsApiService.createProjectComment(body);
|
||||
@@ -107,8 +124,11 @@ const ProjectViewUpdates = () => {
|
||||
created_by: getUserSession()?.name || '',
|
||||
created_at: new Date().toISOString(),
|
||||
content: commentValue.trim(),
|
||||
mentions: (res.body as IProjectUpdateCommentViewModel).mentions ?? [undefined, undefined],
|
||||
}
|
||||
mentions: (res.body as IProjectUpdateCommentViewModel).mentions ?? [
|
||||
undefined,
|
||||
undefined,
|
||||
],
|
||||
},
|
||||
]);
|
||||
handleCancel();
|
||||
}
|
||||
@@ -132,16 +152,18 @@ const ProjectViewUpdates = () => {
|
||||
setSelectedMembers([]);
|
||||
}, [form]);
|
||||
|
||||
const mentionsOptions = useMemo(() =>
|
||||
members?.map(member => ({
|
||||
value: member.id,
|
||||
label: member.name,
|
||||
})) ?? [], [members]
|
||||
const mentionsOptions = useMemo(
|
||||
() =>
|
||||
members?.map(member => ({
|
||||
value: member.id,
|
||||
label: member.name,
|
||||
})) ?? [],
|
||||
[members]
|
||||
);
|
||||
|
||||
const memberSelectHandler = useCallback((member: IMentionMemberSelectOption) => {
|
||||
if (!member?.value || !member?.label) return;
|
||||
|
||||
|
||||
setSelectedMembers(prev =>
|
||||
prev.some(mention => mention.id === member.value)
|
||||
? prev
|
||||
@@ -188,30 +210,36 @@ const ProjectViewUpdates = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const configProviderTheme = useMemo(() => ({
|
||||
components: {
|
||||
Button: {
|
||||
defaultColor: colors.lightGray,
|
||||
defaultHoverColor: colors.darkGray,
|
||||
const configProviderTheme = useMemo(
|
||||
() => ({
|
||||
components: {
|
||||
Button: {
|
||||
defaultColor: colors.lightGray,
|
||||
defaultHoverColor: colors.darkGray,
|
||||
},
|
||||
},
|
||||
},
|
||||
}), []);
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
// Context menu for each comment (memoized)
|
||||
const getCommentMenu = useCallback((commentId: string) => (
|
||||
<Menu>
|
||||
<Menu.Item key="delete">
|
||||
<Popconfirm
|
||||
title="Are you sure you want to delete this comment?"
|
||||
onConfirm={() => handleDeleteComment(commentId)}
|
||||
okText="Yes"
|
||||
cancelText="No"
|
||||
>
|
||||
Delete
|
||||
</Popconfirm>
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
), [handleDeleteComment]);
|
||||
const getCommentMenu = useCallback(
|
||||
(commentId: string) => (
|
||||
<Menu>
|
||||
<Menu.Item key="delete">
|
||||
<Popconfirm
|
||||
title="Are you sure you want to delete this comment?"
|
||||
onConfirm={() => handleDeleteComment(commentId)}
|
||||
okText="Yes"
|
||||
cancelText="No"
|
||||
>
|
||||
Delete
|
||||
</Popconfirm>
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
),
|
||||
[handleDeleteComment]
|
||||
);
|
||||
|
||||
const renderComment = useCallback(
|
||||
(comment: IProjectUpdateCommentViewModel) => {
|
||||
@@ -224,7 +252,7 @@ const ProjectViewUpdates = () => {
|
||||
<Dropdown
|
||||
key={comment.id ?? ''}
|
||||
overlay={getCommentMenu(comment.id ?? '')}
|
||||
trigger={["contextMenu"]}
|
||||
trigger={['contextMenu']}
|
||||
>
|
||||
<div>
|
||||
<Flex gap={8}>
|
||||
@@ -240,7 +268,10 @@ const ProjectViewUpdates = () => {
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
<Typography.Paragraph style={{ margin: '8px 0' }} ellipsis={{ rows: 3, expandable: true }}>
|
||||
<Typography.Paragraph
|
||||
style={{ margin: '8px 0' }}
|
||||
ellipsis={{ rows: 3, expandable: true }}
|
||||
>
|
||||
<div
|
||||
className={`mentions-${themeClass}`}
|
||||
dangerouslySetInnerHTML={{ __html: sanitizedContent }}
|
||||
@@ -256,18 +287,12 @@ const ProjectViewUpdates = () => {
|
||||
[theme, configProviderTheme, handleDeleteComment, handleCommentLinkClick]
|
||||
);
|
||||
|
||||
const commentsList = useMemo(() =>
|
||||
comments.map(renderComment), [comments, renderComment]
|
||||
);
|
||||
const commentsList = useMemo(() => comments.map(renderComment), [comments, renderComment]);
|
||||
|
||||
return (
|
||||
<Flex gap={24} vertical>
|
||||
<Flex vertical gap={16}>
|
||||
{isLoadingComments ? (
|
||||
<Skeleton active />
|
||||
) : (
|
||||
commentsList
|
||||
)}
|
||||
{isLoadingComments ? <Skeleton active /> : commentsList}
|
||||
</Flex>
|
||||
|
||||
<Form onFinish={handleAddComment}>
|
||||
|
||||
@@ -17,30 +17,32 @@ import { phasesApiService } from '@/api/taskAttributes/phases/phases.api.service
|
||||
const BoardCreateSectionCard = () => {
|
||||
const { t } = useTranslation('kanban-board');
|
||||
|
||||
const themeMode = useAppSelector((state) => state.themeReducer.mode);
|
||||
const { projectId } = useAppSelector((state) => state.projectReducer);
|
||||
const groupBy = useAppSelector((state) => state.boardReducer.groupBy);
|
||||
const { statusCategories, status: existingStatuses } = useAppSelector((state) => state.taskStatusReducer);
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const { projectId } = useAppSelector(state => state.projectReducer);
|
||||
const groupBy = useAppSelector(state => state.boardReducer.groupBy);
|
||||
const { statusCategories, status: existingStatuses } = useAppSelector(
|
||||
state => state.taskStatusReducer
|
||||
);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const getUniqueSectionName = (baseName: string): string => {
|
||||
// Check if the base name already exists
|
||||
const existingNames = existingStatuses.map(status => status.name?.toLowerCase());
|
||||
|
||||
|
||||
if (!existingNames.includes(baseName.toLowerCase())) {
|
||||
return baseName;
|
||||
}
|
||||
|
||||
|
||||
// If the base name exists, add a number suffix
|
||||
let counter = 1;
|
||||
let newName = `${baseName.trim()} (${counter})`;
|
||||
|
||||
|
||||
while (existingNames.includes(newName.toLowerCase())) {
|
||||
counter++;
|
||||
newName = `${baseName.trim()} (${counter})`;
|
||||
}
|
||||
|
||||
|
||||
return newName;
|
||||
};
|
||||
|
||||
@@ -48,14 +50,14 @@ const BoardCreateSectionCard = () => {
|
||||
const sectionId = nanoid();
|
||||
const baseNameSection = 'Untitled section';
|
||||
const sectionName = getUniqueSectionName(baseNameSection);
|
||||
|
||||
|
||||
if (groupBy === IGroupBy.STATUS && projectId) {
|
||||
// Find the "To do" category
|
||||
const todoCategory = statusCategories.find(category =>
|
||||
category.name?.toLowerCase() === 'to do' ||
|
||||
category.name?.toLowerCase() === 'todo'
|
||||
const todoCategory = statusCategories.find(
|
||||
category =>
|
||||
category.name?.toLowerCase() === 'to do' || category.name?.toLowerCase() === 'todo'
|
||||
);
|
||||
|
||||
|
||||
if (todoCategory && todoCategory.id) {
|
||||
// Create a new status
|
||||
const body = {
|
||||
@@ -63,21 +65,25 @@ const BoardCreateSectionCard = () => {
|
||||
project_id: projectId,
|
||||
category_id: todoCategory.id,
|
||||
};
|
||||
|
||||
|
||||
try {
|
||||
// Create the status
|
||||
const response = await dispatch(createStatus({ body, currentProjectId: projectId })).unwrap();
|
||||
|
||||
const response = await dispatch(
|
||||
createStatus({ body, currentProjectId: projectId })
|
||||
).unwrap();
|
||||
|
||||
if (response.done && response.body) {
|
||||
dispatch(
|
||||
addBoardSectionCard({
|
||||
id: response.body.id as string,
|
||||
name: sectionName,
|
||||
colorCode: (response.body.color_code || todoCategory.color_code || '#d8d7d8') + ALPHA_CHANNEL,
|
||||
colorCode:
|
||||
(response.body.color_code || todoCategory.color_code || '#d8d7d8') +
|
||||
ALPHA_CHANNEL,
|
||||
colorCodeDark: '#989898',
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
// Refresh the board to show the new section
|
||||
dispatch(fetchBoardTaskGroups(projectId));
|
||||
// Refresh statuses
|
||||
@@ -97,7 +103,7 @@ const BoardCreateSectionCard = () => {
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (groupBy === IGroupBy.PHASE && projectId) {
|
||||
const body = {
|
||||
@@ -105,7 +111,7 @@ const BoardCreateSectionCard = () => {
|
||||
project_id: projectId,
|
||||
};
|
||||
|
||||
try {
|
||||
try {
|
||||
const response = await phasesApiService.addPhaseOption(projectId);
|
||||
if (response.done && response.body) {
|
||||
dispatch(fetchBoardTaskGroups(projectId));
|
||||
|
||||
@@ -50,7 +50,10 @@ import { ITaskStatusUpdateModel } from '@/types/tasks/task-status-update-model.t
|
||||
import { update } from 'lodash';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { toggleDrawer } from '@/features/projects/status/StatusSlice';
|
||||
import { deleteStatusToggleDrawer, seletedStatusCategory } from '@/features/projects/status/DeleteStatusSlice';
|
||||
import {
|
||||
deleteStatusToggleDrawer,
|
||||
seletedStatusCategory,
|
||||
} from '@/features/projects/status/DeleteStatusSlice';
|
||||
|
||||
interface BoardSectionCardHeaderProps {
|
||||
groupId: string;
|
||||
@@ -110,20 +113,20 @@ const BoardSectionCardHeader: React.FC<BoardSectionCardHeaderProps> = ({
|
||||
const getUniqueSectionName = (baseName: string): string => {
|
||||
// Check if the base name already exists
|
||||
const existingNames = status.map(status => status.name?.toLowerCase());
|
||||
|
||||
|
||||
if (!existingNames.includes(baseName.toLowerCase())) {
|
||||
return baseName;
|
||||
}
|
||||
|
||||
|
||||
// If the base name exists, add a number suffix
|
||||
let counter = 1;
|
||||
let newName = `${baseName.trim()} (${counter})`;
|
||||
|
||||
|
||||
while (existingNames.includes(newName.toLowerCase())) {
|
||||
counter++;
|
||||
newName = `${baseName.trim()} (${counter})`;
|
||||
}
|
||||
|
||||
|
||||
return newName;
|
||||
};
|
||||
|
||||
@@ -198,11 +201,18 @@ const BoardSectionCardHeader: React.FC<BoardSectionCardHeaderProps> = ({
|
||||
if (groupBy === IGroupBy.STATUS) {
|
||||
const replacingStatusId = '';
|
||||
const res = await statusApiService.deleteStatus(groupId, projectId, replacingStatusId);
|
||||
if (res.message === 'At least one status should exists under each category.') return
|
||||
if (res.message === 'At least one status should exists under each category.') return;
|
||||
if (res.done) {
|
||||
dispatch(deleteSection({ sectionId: groupId }));
|
||||
} else {
|
||||
dispatch(seletedStatusCategory({ id: groupId, name: name, category_id: categoryId ?? '', message: res.message ?? '' }));
|
||||
dispatch(
|
||||
seletedStatusCategory({
|
||||
id: groupId,
|
||||
name: name,
|
||||
category_id: categoryId ?? '',
|
||||
message: res.message ?? '',
|
||||
})
|
||||
);
|
||||
dispatch(deleteStatusToggleDrawer());
|
||||
}
|
||||
} else if (groupBy === IGroupBy.PHASE) {
|
||||
@@ -373,5 +383,3 @@ const BoardSectionCardHeader: React.FC<BoardSectionCardHeaderProps> = ({
|
||||
};
|
||||
|
||||
export default BoardSectionCardHeader;
|
||||
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ const BoardSectionCardContainer = ({
|
||||
{datasource?.map((data: any) => <BoardSectionCard key={data.id} taskGroup={data} />)}
|
||||
</SortableContext>
|
||||
|
||||
{(group !== 'priority' && (isOwnerorAdmin || isProjectManager)) && <BoardCreateSectionCard />}
|
||||
{group !== 'priority' && (isOwnerorAdmin || isProjectManager) && <BoardCreateSectionCard />}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -85,7 +85,7 @@ const BoardCreateSubtaskCard = ({
|
||||
socket?.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(body));
|
||||
socket?.once(SocketEvents.QUICK_TASK.toString(), (task: IProjectTask) => {
|
||||
if (!task) return;
|
||||
|
||||
|
||||
dispatch(updateSubtask({ sectionId, subtask: task, mode: 'add' }));
|
||||
setCreatingTask(false);
|
||||
// Clear the input field after successful task creation
|
||||
@@ -96,16 +96,19 @@ const BoardCreateSubtaskCard = ({
|
||||
}, 0);
|
||||
if (task.parent_task_id) {
|
||||
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.parent_task_id);
|
||||
socket?.once(SocketEvents.GET_TASK_PROGRESS.toString(), (data: {
|
||||
id: string;
|
||||
complete_ratio: number;
|
||||
completed_count: number;
|
||||
total_tasks_count: number;
|
||||
parent_task: string;
|
||||
}) => {
|
||||
if (!data.parent_task) data.parent_task = task.parent_task_id || '';
|
||||
dispatch(updateTaskProgress(data));
|
||||
});
|
||||
socket?.once(
|
||||
SocketEvents.GET_TASK_PROGRESS.toString(),
|
||||
(data: {
|
||||
id: string;
|
||||
complete_ratio: number;
|
||||
completed_count: number;
|
||||
total_tasks_count: number;
|
||||
parent_task: string;
|
||||
}) => {
|
||||
if (!data.parent_task) data.parent_task = task.parent_task_id || '';
|
||||
dispatch(updateTaskProgress(data));
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import { Col, Flex, Typography, List, Dropdown, MenuProps, Popconfirm } from 'antd';
|
||||
import { UserAddOutlined, DeleteOutlined, ExclamationCircleFilled, InboxOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
UserAddOutlined,
|
||||
DeleteOutlined,
|
||||
ExclamationCircleFilled,
|
||||
InboxOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import CustomAvatarGroup from '@/components/board/custom-avatar-group';
|
||||
import CustomDueDatePicker from '@/components/board/custom-due-date-picker';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
@@ -97,7 +102,10 @@ const BoardSubTaskCard = ({ subtask, sectionId }: IBoardSubTaskCardProps) => {
|
||||
if (!projectId || !subtask.id) return;
|
||||
|
||||
try {
|
||||
const res = await taskListBulkActionsApiService.deleteTasks({ tasks: [subtask.id] }, projectId);
|
||||
const res = await taskListBulkActionsApiService.deleteTasks(
|
||||
{ tasks: [subtask.id] },
|
||||
projectId
|
||||
);
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_task_list_context_menu_delete);
|
||||
dispatch(deleteBoardTask({ sectionId, taskId: subtask.id }));
|
||||
|
||||
@@ -62,7 +62,7 @@ const BoardViewCreateTaskCard = ({
|
||||
|
||||
const createRequestBody = (): ITaskCreateRequest | null => {
|
||||
if (!projectId || !currentSession) return null;
|
||||
|
||||
|
||||
const body: ITaskCreateRequest = {
|
||||
project_id: projectId,
|
||||
name: newTaskName.trim(),
|
||||
@@ -108,7 +108,7 @@ const BoardViewCreateTaskCard = ({
|
||||
const eventHandler = (task: IProjectTask) => {
|
||||
// Set creating task to false
|
||||
setCreatingTask(false);
|
||||
|
||||
|
||||
// Add the task to the state at the top of the section
|
||||
dispatch(
|
||||
addTaskCardToTheTop({
|
||||
@@ -121,17 +121,17 @@ const BoardViewCreateTaskCard = ({
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
// Remove the event listener to prevent memory leaks
|
||||
socket?.off(SocketEvents.QUICK_TASK.toString(), eventHandler);
|
||||
|
||||
|
||||
// Reset the form
|
||||
resetForm();
|
||||
};
|
||||
|
||||
// Register the event handler before emitting
|
||||
socket?.once(SocketEvents.QUICK_TASK.toString(), eventHandler);
|
||||
|
||||
|
||||
// Emit the event
|
||||
socket?.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(body));
|
||||
} catch (error) {
|
||||
@@ -152,7 +152,7 @@ const BoardViewCreateTaskCard = ({
|
||||
const eventHandler = (task: IProjectTask) => {
|
||||
// Set creating task to false
|
||||
setCreatingTask(false);
|
||||
|
||||
|
||||
// Add the task to the state at the bottom of the section
|
||||
dispatch(
|
||||
addTaskCardToTheBottom({
|
||||
@@ -165,17 +165,17 @@ const BoardViewCreateTaskCard = ({
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
// Remove the event listener to prevent memory leaks
|
||||
socket?.off(SocketEvents.QUICK_TASK.toString(), eventHandler);
|
||||
|
||||
|
||||
// Reset the form
|
||||
resetForm();
|
||||
};
|
||||
|
||||
// Register the event handler before emitting
|
||||
socket?.once(SocketEvents.QUICK_TASK.toString(), eventHandler);
|
||||
|
||||
|
||||
// Emit the event
|
||||
socket?.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(body));
|
||||
} catch (error) {
|
||||
@@ -225,15 +225,12 @@ const BoardViewCreateTaskCard = ({
|
||||
/>
|
||||
{newTaskName.trim() && (
|
||||
<Flex gap={8} justify="flex-end">
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => setShowNewCard(false)}
|
||||
>
|
||||
<Button size="small" onClick={() => setShowNewCard(false)}>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
onClick={position === 'bottom' ? handleAddTaskToTheBottom : handleAddTaskToTheTop}
|
||||
loading={creatingTask}
|
||||
>
|
||||
|
||||
@@ -86,27 +86,33 @@ const BoardViewTaskCard = ({ task, sectionId }: IBoardViewTaskCardProps) => {
|
||||
},
|
||||
});
|
||||
|
||||
const style = useMemo(() => ({
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
}), [transform, transition, isDragging]);
|
||||
const style = useMemo(
|
||||
() => ({
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
}),
|
||||
[transform, transition, isDragging]
|
||||
);
|
||||
|
||||
const handleCardClick = useCallback((e: React.MouseEvent, id: string) => {
|
||||
// Prevent the event from propagating to parent elements
|
||||
e.stopPropagation();
|
||||
const handleCardClick = useCallback(
|
||||
(e: React.MouseEvent, id: string) => {
|
||||
// Prevent the event from propagating to parent elements
|
||||
e.stopPropagation();
|
||||
|
||||
// Don't handle click if we're dragging
|
||||
if (isDragging) return;
|
||||
// Don't handle click if we're dragging
|
||||
if (isDragging) return;
|
||||
|
||||
// Add a small delay to ensure it's a click and not the start of a drag
|
||||
const clickTimeout = setTimeout(() => {
|
||||
dispatch(setSelectedTaskId(id));
|
||||
dispatch(setShowTaskDrawer(true));
|
||||
}, 50);
|
||||
// Add a small delay to ensure it's a click and not the start of a drag
|
||||
const clickTimeout = setTimeout(() => {
|
||||
dispatch(setSelectedTaskId(id));
|
||||
dispatch(setShowTaskDrawer(true));
|
||||
}, 50);
|
||||
|
||||
return () => clearTimeout(clickTimeout);
|
||||
}, [dispatch, isDragging]);
|
||||
return () => clearTimeout(clickTimeout);
|
||||
},
|
||||
[dispatch, isDragging]
|
||||
);
|
||||
|
||||
const handleAssignToMe = useCallback(async () => {
|
||||
if (!projectId || !task.id || updatingAssignToMe) return;
|
||||
@@ -189,54 +195,58 @@ const BoardViewTaskCard = ({ task, sectionId }: IBoardViewTaskCardProps) => {
|
||||
setShowNewSubtaskCard(true);
|
||||
}, []);
|
||||
|
||||
const handleSubtaskButtonClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
handleSubTaskExpand();
|
||||
}, [handleSubTaskExpand]);
|
||||
|
||||
const items: MenuProps['items'] = useMemo(() => [
|
||||
{
|
||||
label: (
|
||||
<span>
|
||||
<UserAddOutlined />
|
||||
|
||||
<Typography.Text>{t('assignToMe')}</Typography.Text>
|
||||
</span>
|
||||
),
|
||||
key: '1',
|
||||
onClick: handleAssignToMe,
|
||||
disabled: updatingAssignToMe,
|
||||
const handleSubtaskButtonClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
handleSubTaskExpand();
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<span>
|
||||
<InboxOutlined />
|
||||
|
||||
<Typography.Text>{t('archive')}</Typography.Text>
|
||||
</span>
|
||||
),
|
||||
key: '2',
|
||||
onClick: handleArchive,
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Popconfirm
|
||||
title={t('deleteConfirmationTitle')}
|
||||
icon={<ExclamationCircleFilled style={{ color: colors.vibrantOrange }} />}
|
||||
okText={t('deleteConfirmationOk')}
|
||||
cancelText={t('deleteConfirmationCancel')}
|
||||
onConfirm={handleDelete}
|
||||
>
|
||||
<DeleteOutlined />
|
||||
|
||||
{t('delete')}
|
||||
</Popconfirm>
|
||||
),
|
||||
key: '3',
|
||||
},
|
||||
], [t, handleAssignToMe, handleArchive, handleDelete, updatingAssignToMe]);
|
||||
|
||||
[handleSubTaskExpand]
|
||||
);
|
||||
|
||||
const items: MenuProps['items'] = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: (
|
||||
<span>
|
||||
<UserAddOutlined />
|
||||
|
||||
<Typography.Text>{t('assignToMe')}</Typography.Text>
|
||||
</span>
|
||||
),
|
||||
key: '1',
|
||||
onClick: handleAssignToMe,
|
||||
disabled: updatingAssignToMe,
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<span>
|
||||
<InboxOutlined />
|
||||
|
||||
<Typography.Text>{t('archive')}</Typography.Text>
|
||||
</span>
|
||||
),
|
||||
key: '2',
|
||||
onClick: handleArchive,
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Popconfirm
|
||||
title={t('deleteConfirmationTitle')}
|
||||
icon={<ExclamationCircleFilled style={{ color: colors.vibrantOrange }} />}
|
||||
okText={t('deleteConfirmationOk')}
|
||||
cancelText={t('deleteConfirmationCancel')}
|
||||
onConfirm={handleDelete}
|
||||
>
|
||||
<DeleteOutlined />
|
||||
|
||||
{t('delete')}
|
||||
</Popconfirm>
|
||||
),
|
||||
key: '3',
|
||||
},
|
||||
],
|
||||
[t, handleAssignToMe, handleArchive, handleDelete, updatingAssignToMe]
|
||||
);
|
||||
|
||||
const renderLabels = useMemo(() => {
|
||||
if (!task?.labels?.length) return null;
|
||||
@@ -245,9 +255,7 @@ const BoardViewTaskCard = ({ task, sectionId }: IBoardViewTaskCardProps) => {
|
||||
<>
|
||||
{task.labels.slice(0, 2).map((label: any) => (
|
||||
<Tag key={label.id} style={{ marginRight: '4px' }} color={label?.color_code}>
|
||||
<span style={{ color: themeMode === 'dark' ? '#383838' : '' }}>
|
||||
{label.name}
|
||||
</span>
|
||||
<span style={{ color: themeMode === 'dark' ? '#383838' : '' }}>{label.name}</span>
|
||||
</Tag>
|
||||
))}
|
||||
{task.labels.length > 2 && <Tag>+ {task.labels.length - 2}</Tag>}
|
||||
@@ -273,29 +281,28 @@ const BoardViewTaskCard = ({ task, sectionId }: IBoardViewTaskCardProps) => {
|
||||
}}
|
||||
className={`group outline-1 ${themeWiseColor('outline-[#edeae9]', 'outline-[#6a696a]', themeMode)} hover:outline-solid board-task-card`}
|
||||
data-id={task.id}
|
||||
data-dragging={isDragging ? "true" : "false"}
|
||||
data-dragging={isDragging ? 'true' : 'false'}
|
||||
>
|
||||
<Dropdown menu={{ items }} trigger={['contextMenu']}>
|
||||
{/* Task Card */}
|
||||
<Flex vertical gap={8}
|
||||
onClick={e => handleCardClick(e, task.id || '')}>
|
||||
<Flex vertical gap={8} onClick={e => handleCardClick(e, task.id || '')}>
|
||||
{/* Labels and Progress */}
|
||||
<Flex align="center" justify="space-between">
|
||||
<Flex>
|
||||
{renderLabels}
|
||||
</Flex>
|
||||
<Flex>{renderLabels}</Flex>
|
||||
|
||||
<Tooltip title={` ${task?.completed_count} / ${task?.total_tasks_count}`}>
|
||||
<Progress type="circle" percent={task?.complete_ratio} size={26} strokeWidth={(task.complete_ratio || 0) >= 100 ? 9 : 7} />
|
||||
<Progress
|
||||
type="circle"
|
||||
percent={task?.complete_ratio}
|
||||
size={26}
|
||||
strokeWidth={(task.complete_ratio || 0) >= 100 ? 9 : 7}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
<Flex gap={4} align="center">
|
||||
{/* Action Icons */}
|
||||
<PrioritySection task={task} />
|
||||
<Typography.Text
|
||||
style={{ fontWeight: 500 }}
|
||||
ellipsis={{ tooltip: task.name }}
|
||||
>
|
||||
<Typography.Text style={{ fontWeight: 500 }} ellipsis={{ tooltip: task.name }}>
|
||||
{task.name}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
@@ -350,7 +357,8 @@ const BoardViewTaskCard = ({ task, sectionId }: IBoardViewTaskCardProps) => {
|
||||
</List.Item>
|
||||
)}
|
||||
|
||||
{!task.sub_tasks_loading && task?.sub_tasks &&
|
||||
{!task.sub_tasks_loading &&
|
||||
task?.sub_tasks &&
|
||||
task?.sub_tasks.map((subtask: any) => (
|
||||
<BoardSubTaskCard key={subtask.id} subtask={subtask} sectionId={sectionId} />
|
||||
))}
|
||||
@@ -379,7 +387,6 @@ const BoardViewTaskCard = ({ task, sectionId }: IBoardViewTaskCardProps) => {
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -36,7 +36,10 @@ import { useAuthService } from '@/hooks/useAuth';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import alertService from '@/services/alerts/alertService';
|
||||
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||
import { evt_project_board_visit, evt_project_task_list_drag_and_move } from '@/shared/worklenz-analytics-events';
|
||||
import {
|
||||
evt_project_board_visit,
|
||||
evt_project_task_list_drag_and_move,
|
||||
} from '@/shared/worklenz-analytics-events';
|
||||
import { ITaskStatusCreateRequest } from '@/types/tasks/task-status-create-request';
|
||||
import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
|
||||
import logger from '@/utils/errorLogger';
|
||||
@@ -66,7 +69,9 @@ const ProjectViewBoard = () => {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const { projectId } = useAppSelector(state => state.projectReducer);
|
||||
const { taskGroups, groupBy, loadingGroups, search, archived } = useAppSelector(state => state.boardReducer);
|
||||
const { taskGroups, groupBy, loadingGroups, search, archived } = useAppSelector(
|
||||
state => state.boardReducer
|
||||
);
|
||||
const { statusCategories, loading: loadingStatusCategories } = useAppSelector(
|
||||
state => state.taskStatusReducer
|
||||
);
|
||||
@@ -140,9 +145,7 @@ const ProjectViewBoard = () => {
|
||||
// Start by finding any intersecting droppable
|
||||
const pointerIntersections = pointerWithin(args);
|
||||
const intersections =
|
||||
pointerIntersections.length > 0
|
||||
? pointerIntersections
|
||||
: rectIntersection(args);
|
||||
pointerIntersections.length > 0 ? pointerIntersections : rectIntersection(args);
|
||||
let overId = getFirstCollision(intersections, 'id');
|
||||
|
||||
if (overId !== null) {
|
||||
@@ -151,17 +154,14 @@ const ProjectViewBoard = () => {
|
||||
);
|
||||
|
||||
if (overContainer?.data.current?.type === 'section') {
|
||||
const containerItems = taskGroups.find(
|
||||
(group) => group.id === overId
|
||||
)?.tasks || [];
|
||||
const containerItems = taskGroups.find(group => group.id === overId)?.tasks || [];
|
||||
|
||||
if (containerItems.length > 0) {
|
||||
overId = closestCenter({
|
||||
...args,
|
||||
droppableContainers: args.droppableContainers.filter(
|
||||
(container: DroppableContainer) =>
|
||||
container.id !== overId &&
|
||||
container.data.current?.type === 'task'
|
||||
container.id !== overId && container.data.current?.type === 'task'
|
||||
),
|
||||
})[0]?.id;
|
||||
}
|
||||
@@ -193,16 +193,19 @@ const ProjectViewBoard = () => {
|
||||
|
||||
// Debounced move task function to prevent rapid updates
|
||||
const debouncedMoveTask = useCallback(
|
||||
debounce((taskId: string, sourceGroupId: string, targetGroupId: string, targetIndex: number) => {
|
||||
dispatch(
|
||||
moveTaskBetweenGroups({
|
||||
taskId,
|
||||
sourceGroupId,
|
||||
targetGroupId,
|
||||
targetIndex,
|
||||
})
|
||||
);
|
||||
}, 100),
|
||||
debounce(
|
||||
(taskId: string, sourceGroupId: string, targetGroupId: string, targetIndex: number) => {
|
||||
dispatch(
|
||||
moveTaskBetweenGroups({
|
||||
taskId,
|
||||
sourceGroupId,
|
||||
targetGroupId,
|
||||
targetIndex,
|
||||
})
|
||||
);
|
||||
},
|
||||
100
|
||||
),
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
@@ -241,11 +244,7 @@ const ProjectViewBoard = () => {
|
||||
const overGroupId = findGroupForId(overId as string);
|
||||
|
||||
// Only move if both groups exist and are different, and the active is a task
|
||||
if (
|
||||
activeGroupId &&
|
||||
overGroupId &&
|
||||
active.data.current?.type === 'task'
|
||||
) {
|
||||
if (activeGroupId && overGroupId && active.data.current?.type === 'task') {
|
||||
// Find the target index in the over group
|
||||
const targetGroup = taskGroups.find(g => g.id === overGroupId);
|
||||
let targetIndex = 0;
|
||||
@@ -259,12 +258,7 @@ const ProjectViewBoard = () => {
|
||||
}
|
||||
}
|
||||
// Use debounced move task to prevent rapid updates
|
||||
debouncedMoveTask(
|
||||
activeId as string,
|
||||
activeGroupId,
|
||||
overGroupId,
|
||||
targetIndex
|
||||
);
|
||||
debouncedMoveTask(activeId as string, activeGroupId, overGroupId, targetIndex);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('handleDragOver error:', error);
|
||||
@@ -281,9 +275,12 @@ const ProjectViewBoard = () => {
|
||||
};
|
||||
|
||||
socket.emit(SocketEvents.TASK_PRIORITY_CHANGE.toString(), JSON.stringify(payload));
|
||||
socket.once(SocketEvents.TASK_PRIORITY_CHANGE.toString(), (data: ITaskListPriorityChangeResponse) => {
|
||||
dispatch(updateBoardTaskPriority(data));
|
||||
});
|
||||
socket.once(
|
||||
SocketEvents.TASK_PRIORITY_CHANGE.toString(),
|
||||
(data: ITaskListPriorityChangeResponse) => {
|
||||
dispatch(updateBoardTaskPriority(data));
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleDragEnd = async (event: DragEndEvent) => {
|
||||
@@ -373,7 +370,8 @@ const ProjectViewBoard = () => {
|
||||
}
|
||||
|
||||
// Calculate toPos similar to Angular implementation
|
||||
const toPos = targetGroup.tasks[toIndex]?.sort_order ||
|
||||
const toPos =
|
||||
targetGroup.tasks[toIndex]?.sort_order ||
|
||||
targetGroup.tasks[targetGroup.tasks.length - 1]?.sort_order ||
|
||||
-1;
|
||||
|
||||
@@ -387,7 +385,7 @@ const ProjectViewBoard = () => {
|
||||
to_group: targetGroupId,
|
||||
group_by: groupBy || 'status',
|
||||
task,
|
||||
team_id: currentSession?.team_id
|
||||
team_id: currentSession?.team_id,
|
||||
};
|
||||
|
||||
// Emit socket event
|
||||
@@ -428,7 +426,8 @@ const ProjectViewBoard = () => {
|
||||
}
|
||||
|
||||
// Calculate toPos similar to Angular implementation
|
||||
const toPos = targetGroup.tasks[toIndex]?.sort_order ||
|
||||
const toPos =
|
||||
targetGroup.tasks[toIndex]?.sort_order ||
|
||||
targetGroup.tasks[targetGroup.tasks.length - 1]?.sort_order ||
|
||||
-1;
|
||||
// Prepare socket event payload
|
||||
@@ -438,10 +437,10 @@ const ProjectViewBoard = () => {
|
||||
to_index: toPos,
|
||||
to_last_index: !toPos,
|
||||
from_group: sourceGroupId, // Use the direct IDs instead of group objects
|
||||
to_group: targetGroupId, // Use the direct IDs instead of group objects
|
||||
to_group: targetGroupId, // Use the direct IDs instead of group objects
|
||||
group_by: groupBy || 'status', // Use the current groupBy value
|
||||
task,
|
||||
team_id: currentSession?.team_id
|
||||
team_id: currentSession?.team_id,
|
||||
};
|
||||
// Emit socket event
|
||||
if (socket) {
|
||||
@@ -488,7 +487,7 @@ const ProjectViewBoard = () => {
|
||||
try {
|
||||
// Use the correct API endpoint based on the Angular code
|
||||
const requestBody: ITaskStatusCreateRequest = {
|
||||
status_order: columnOrder
|
||||
status_order: columnOrder,
|
||||
};
|
||||
|
||||
const response = await statusApiService.updateStatusOrder(requestBody, projectId);
|
||||
@@ -556,7 +555,7 @@ const ProjectViewBoard = () => {
|
||||
return (
|
||||
<Flex vertical gap={16}>
|
||||
<TaskListFilters position={'board'} />
|
||||
<Skeleton active loading={isLoading} className='mt-4 p-4'>
|
||||
<Skeleton active loading={isLoading} className="mt-4 p-4">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={collisionDetectionStrategy}
|
||||
|
||||
@@ -6,11 +6,7 @@ const ProjectViewEnhancedBoard: React.FC = () => {
|
||||
const { project } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
if (!project?.id) {
|
||||
return (
|
||||
<div className="p-4 text-center text-gray-500">
|
||||
Project not found
|
||||
</div>
|
||||
);
|
||||
return <div className="p-4 text-center text-gray-500">Project not found</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -20,4 +16,4 @@ const ProjectViewEnhancedBoard: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectViewEnhancedBoard;
|
||||
export default ProjectViewEnhancedBoard;
|
||||
|
||||
@@ -6,11 +6,7 @@ const ProjectViewEnhancedTasks: React.FC = () => {
|
||||
const { project } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
if (!project?.id) {
|
||||
return (
|
||||
<div className="p-4 text-center text-gray-500">
|
||||
Project not found
|
||||
</div>
|
||||
);
|
||||
return <div className="p-4 text-center text-gray-500">Project not found</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -20,4 +16,4 @@ const ProjectViewEnhancedTasks: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectViewEnhancedTasks;
|
||||
export default ProjectViewEnhancedTasks;
|
||||
|
||||
@@ -47,7 +47,6 @@ const ProjectViewFiles = () => {
|
||||
defaultPageSize: DEFAULT_PAGE_SIZE,
|
||||
});
|
||||
|
||||
|
||||
const fetchAttachments = async () => {
|
||||
if (!projectId) return;
|
||||
try {
|
||||
|
||||
@@ -41,7 +41,7 @@ const TaskByMembersTable = () => {
|
||||
|
||||
useEffect(() => {
|
||||
getProjectOverviewMembers();
|
||||
}, [projectId,refreshTimestamp]);
|
||||
}, [projectId, refreshTimestamp]);
|
||||
|
||||
// toggle members row expansions
|
||||
const toggleRowExpansion = (memberId: string) => {
|
||||
|
||||
@@ -17,7 +17,6 @@ const PriorityOverview = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
|
||||
const getTaskPriorityCounts = async () => {
|
||||
if (!projectId) return;
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ const StatusOverview = () => {
|
||||
const [stats, setStats] = useState<ITaskStatusCounts[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
|
||||
const getTaskStatusCounts = async () => {
|
||||
if (!projectId) return;
|
||||
@@ -38,7 +37,7 @@ const StatusOverview = () => {
|
||||
|
||||
useEffect(() => {
|
||||
getTaskStatusCounts();
|
||||
}, [projectId, includeArchivedTasks,refreshTimestamp]);
|
||||
}, [projectId, includeArchivedTasks, refreshTimestamp]);
|
||||
|
||||
const options: ChartOptions<'doughnut'> = {
|
||||
responsive: true,
|
||||
|
||||
@@ -18,7 +18,6 @@ const LastUpdatedTasks = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
|
||||
const getLastUpdatedTasks = async () => {
|
||||
if (!projectId) return;
|
||||
setLoading(true);
|
||||
@@ -39,7 +38,7 @@ const LastUpdatedTasks = () => {
|
||||
|
||||
useEffect(() => {
|
||||
getLastUpdatedTasks();
|
||||
}, [projectId, includeArchivedTasks,refreshTimestamp]);
|
||||
}, [projectId, includeArchivedTasks, refreshTimestamp]);
|
||||
|
||||
// table columns
|
||||
const columns: TableProps['columns'] = [
|
||||
|
||||
@@ -37,7 +37,7 @@ const ProjectDeadline = () => {
|
||||
|
||||
useEffect(() => {
|
||||
getProjectDeadline();
|
||||
}, [projectId, includeArchivedTasks,refreshTimestamp]);
|
||||
}, [projectId, includeArchivedTasks, refreshTimestamp]);
|
||||
|
||||
// table columns
|
||||
const columns: TableProps['columns'] = [
|
||||
|
||||
@@ -15,7 +15,6 @@ const OverLoggedTasksTable = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
|
||||
const getOverLoggedTasks = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -35,7 +34,7 @@ const OverLoggedTasksTable = () => {
|
||||
|
||||
useEffect(() => {
|
||||
getOverLoggedTasks();
|
||||
}, [projectId, includeArchivedTasks,refreshTimestamp]);
|
||||
}, [projectId, includeArchivedTasks, refreshTimestamp]);
|
||||
|
||||
// table columns
|
||||
const columns: TableProps['columns'] = [
|
||||
|
||||
@@ -18,7 +18,6 @@ const OverdueTasksTable = ({
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
|
||||
const getOverdueTasks = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -35,7 +34,7 @@ const OverdueTasksTable = ({
|
||||
|
||||
useEffect(() => {
|
||||
getOverdueTasks();
|
||||
}, [projectId, includeArchivedTasks,refreshTimestamp]);
|
||||
}, [projectId, includeArchivedTasks, refreshTimestamp]);
|
||||
|
||||
// table columns
|
||||
const columns: TableProps['columns'] = [
|
||||
|
||||
@@ -20,7 +20,6 @@ const TaskCompletedEarlyTable = ({
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
|
||||
const getEarlyCompletedTasks = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
@@ -18,7 +18,6 @@ const TaskCompletedLateTable = ({
|
||||
const [lateCompletedTaskList, setLateCompletedTaskList] = useState<IInsightTasks[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
|
||||
const getLateCompletedTasks = async () => {
|
||||
try {
|
||||
@@ -39,7 +38,7 @@ const TaskCompletedLateTable = ({
|
||||
|
||||
useEffect(() => {
|
||||
getLateCompletedTasks();
|
||||
}, [projectId, includeArchivedTasks,refreshTimestamp]);
|
||||
}, [projectId, includeArchivedTasks, refreshTimestamp]);
|
||||
|
||||
// table columns
|
||||
const columns: TableProps['columns'] = [
|
||||
|
||||
@@ -35,7 +35,7 @@ const MemberStats = () => {
|
||||
|
||||
useEffect(() => {
|
||||
fetchMemberStats();
|
||||
}, [projectId, includeArchivedTasks,refreshTimestamp]);
|
||||
}, [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">
|
||||
|
||||
@@ -18,7 +18,6 @@ const ProjectStats = ({ t }: { t: TFunction }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
|
||||
const getProjectStats = async () => {
|
||||
if (!projectId) return;
|
||||
|
||||
@@ -40,7 +39,7 @@ const ProjectStats = ({ t }: { t: TFunction }) => {
|
||||
|
||||
useEffect(() => {
|
||||
getProjectStats();
|
||||
}, [projectId, includeArchivedTasks,refreshTimestamp]);
|
||||
}, [projectId, includeArchivedTasks, refreshTimestamp]);
|
||||
|
||||
const tooltipTable = (
|
||||
<table>
|
||||
|
||||
@@ -18,7 +18,11 @@ import { format } from 'date-fns';
|
||||
import html2canvas from 'html2canvas';
|
||||
import jsPDF from 'jspdf';
|
||||
import logo from '@/assets/images/worklenz-light-mode.png';
|
||||
import { evt_project_insights_members_visit, evt_project_insights_overview_visit, evt_project_insights_tasks_visit } from '@/shared/worklenz-analytics-events';
|
||||
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';
|
||||
@@ -35,9 +39,7 @@ const ProjectViewInsights = () => {
|
||||
const { activeSegment, includeArchivedTasks } = useAppSelector(
|
||||
state => state.projectInsightsReducer
|
||||
);
|
||||
const {
|
||||
project: selectedProject,
|
||||
} = useAppSelector(state => state.projectReducer);
|
||||
const { project: selectedProject } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
const handleSegmentChange = (value: SegmentType) => {
|
||||
dispatch(setActiveSegment(value));
|
||||
@@ -113,7 +115,7 @@ const ProjectViewInsights = () => {
|
||||
pdf.save(`${activeSegment} ${format(new Date(), 'yyyy-MM-dd')}.pdf`);
|
||||
};
|
||||
|
||||
logoImg.onerror = (error) => {
|
||||
logoImg.onerror = error => {
|
||||
pdf.setFontSize(14);
|
||||
pdf.setTextColor(0, 0, 0, 0.85);
|
||||
pdf.text(
|
||||
@@ -127,11 +129,11 @@ const ProjectViewInsights = () => {
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(()=>{
|
||||
if(projectId){
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
dispatch(setActiveSegment('Overview'));
|
||||
}
|
||||
},[refreshTimestamp])
|
||||
}, [refreshTimestamp]);
|
||||
|
||||
return (
|
||||
<Flex vertical gap={24}>
|
||||
@@ -169,9 +171,7 @@ const ProjectViewInsights = () => {
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<div ref={exportRef}>
|
||||
{renderSegmentContent()}
|
||||
</div>
|
||||
<div ref={exportRef}>{renderSegmentContent()}</div>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -60,7 +60,7 @@ const ProjectViewMembers = () => {
|
||||
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||
|
||||
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
|
||||
// State
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [members, setMembers] = useState<IProjectMembersViewModel>();
|
||||
@@ -138,7 +138,14 @@ const ProjectViewMembers = () => {
|
||||
// Effects
|
||||
useEffect(() => {
|
||||
void getProjectMembers();
|
||||
}, [refreshTimestamp, projectId, pagination.current, pagination.pageSize, pagination.field, pagination.order]);
|
||||
}, [
|
||||
refreshTimestamp,
|
||||
projectId,
|
||||
pagination.current,
|
||||
pagination.pageSize,
|
||||
pagination.field,
|
||||
pagination.order,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
trackMixpanelEvent(evt_project_members_visit);
|
||||
@@ -151,9 +158,13 @@ const ProjectViewMembers = () => {
|
||||
title: t('nameColumn'),
|
||||
dataIndex: 'name',
|
||||
sorter: true,
|
||||
sortOrder: pagination.order === 'ascend' && pagination.field === 'name' ? 'ascend' :
|
||||
pagination.order === 'descend' && pagination.field === 'name' ? 'descend' : null,
|
||||
render: (_,record: IProjectMemberViewModel) => (
|
||||
sortOrder:
|
||||
pagination.order === 'ascend' && pagination.field === 'name'
|
||||
? 'ascend'
|
||||
: pagination.order === 'descend' && pagination.field === 'name'
|
||||
? 'descend'
|
||||
: null,
|
||||
render: (_, record: IProjectMemberViewModel) => (
|
||||
<Flex gap={8} align="center">
|
||||
<Avatar size={28} src={record.avatar_url}>
|
||||
{record.name?.charAt(0)}
|
||||
@@ -167,8 +178,12 @@ const ProjectViewMembers = () => {
|
||||
title: t('jobTitleColumn'),
|
||||
dataIndex: 'job_title',
|
||||
sorter: true,
|
||||
sortOrder: pagination.order === 'ascend' && pagination.field === 'job_title' ? 'ascend' :
|
||||
pagination.order === 'descend' && pagination.field === 'job_title' ? 'descend' : null,
|
||||
sortOrder:
|
||||
pagination.order === 'ascend' && pagination.field === 'job_title'
|
||||
? 'ascend'
|
||||
: pagination.order === 'descend' && pagination.field === 'job_title'
|
||||
? 'descend'
|
||||
: null,
|
||||
render: (_, record: IProjectMemberViewModel) => (
|
||||
<Typography.Text style={{ marginInlineStart: 12 }}>
|
||||
{record?.job_title || '-'}
|
||||
@@ -180,8 +195,12 @@ const ProjectViewMembers = () => {
|
||||
title: t('emailColumn'),
|
||||
dataIndex: 'email',
|
||||
sorter: true,
|
||||
sortOrder: pagination.order === 'ascend' && pagination.field === 'email' ? 'ascend' :
|
||||
pagination.order === 'descend' && pagination.field === 'email' ? 'descend' : null,
|
||||
sortOrder:
|
||||
pagination.order === 'ascend' && pagination.field === 'email'
|
||||
? 'ascend'
|
||||
: pagination.order === 'descend' && pagination.field === 'email'
|
||||
? 'descend'
|
||||
: null,
|
||||
render: (_, record: IProjectMemberViewModel) => (
|
||||
<Typography.Text>{record.email}</Typography.Text>
|
||||
),
|
||||
@@ -210,8 +229,12 @@ const ProjectViewMembers = () => {
|
||||
title: t('accessColumn'),
|
||||
dataIndex: 'access',
|
||||
sorter: true,
|
||||
sortOrder: pagination.order === 'ascend' && pagination.field === 'access' ? 'ascend' :
|
||||
pagination.order === 'descend' && pagination.field === 'access' ? 'descend' : null,
|
||||
sortOrder:
|
||||
pagination.order === 'ascend' && pagination.field === 'access'
|
||||
? 'ascend'
|
||||
: pagination.order === 'descend' && pagination.field === 'access'
|
||||
? 'descend'
|
||||
: null,
|
||||
render: (_, record: IProjectMemberViewModel) => (
|
||||
<Typography.Text style={{ textTransform: 'capitalize' }}>{record.access}</Typography.Text>
|
||||
),
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {
|
||||
Button,
|
||||
Dropdown,
|
||||
Flex,
|
||||
Tag,
|
||||
Tooltip,
|
||||
import {
|
||||
Button,
|
||||
Dropdown,
|
||||
Flex,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
ArrowLeftOutlined,
|
||||
BellFilled,
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
SaveOutlined,
|
||||
SettingOutlined,
|
||||
SyncOutlined,
|
||||
UsergroupAddOutlined
|
||||
UsergroupAddOutlined,
|
||||
} from '@/shared/antd-imports';
|
||||
import { PageHeader } from '@ant-design/pro-components';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -29,8 +29,18 @@ import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { setProject, setImportTaskTemplateDrawerOpen, setRefreshTimestamp, getProject } from '@features/project/project.slice';
|
||||
import { addTask, fetchTaskGroups, fetchTaskListColumns, IGroupBy } from '@features/tasks/tasks.slice';
|
||||
import {
|
||||
setProject,
|
||||
setImportTaskTemplateDrawerOpen,
|
||||
setRefreshTimestamp,
|
||||
getProject,
|
||||
} from '@features/project/project.slice';
|
||||
import {
|
||||
addTask,
|
||||
fetchTaskGroups,
|
||||
fetchTaskListColumns,
|
||||
IGroupBy,
|
||||
} from '@features/tasks/tasks.slice';
|
||||
import ProjectStatusIcon from '@/components/common/project-status-icon/project-status-icon';
|
||||
import { formatDate } from '@/utils/timeUtils';
|
||||
import { toggleSaveAsTemplateDrawer } from '@/features/projects/projectsSlice';
|
||||
@@ -85,9 +95,9 @@ const ProjectViewHeader = memo(() => {
|
||||
// Memoized refresh handler with optimized dependencies
|
||||
const handleRefresh = useCallback(() => {
|
||||
if (!projectId) return;
|
||||
|
||||
|
||||
dispatch(getProject(projectId));
|
||||
|
||||
|
||||
switch (tab) {
|
||||
case 'tasks-list':
|
||||
dispatch(fetchTaskListColumns(projectId));
|
||||
@@ -110,7 +120,7 @@ const ProjectViewHeader = memo(() => {
|
||||
// Optimized subscription handler with proper cleanup
|
||||
const handleSubscribe = useCallback(() => {
|
||||
if (!selectedProject?.id || !socket || subscriptionLoading) return;
|
||||
|
||||
|
||||
try {
|
||||
setSubscriptionLoading(true);
|
||||
const newSubscriptionState = !selectedProject.subscribed;
|
||||
@@ -131,16 +141,20 @@ const ProjectViewHeader = memo(() => {
|
||||
// Listen for response with cleanup
|
||||
const handleResponse = (response: any) => {
|
||||
try {
|
||||
dispatch(setProject({
|
||||
...selectedProject,
|
||||
subscribed: newSubscriptionState
|
||||
}));
|
||||
dispatch(
|
||||
setProject({
|
||||
...selectedProject,
|
||||
subscribed: newSubscriptionState,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Error handling project subscription response:', error);
|
||||
dispatch(setProject({
|
||||
...selectedProject,
|
||||
subscribed: selectedProject.subscribed
|
||||
}));
|
||||
dispatch(
|
||||
setProject({
|
||||
...selectedProject,
|
||||
subscribed: selectedProject.subscribed,
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
setSubscriptionLoading(false);
|
||||
if (subscriptionTimeoutRef.current) {
|
||||
@@ -158,7 +172,6 @@ const ProjectViewHeader = memo(() => {
|
||||
logger.error('Project subscription timeout - no response from server');
|
||||
subscriptionTimeoutRef.current = null;
|
||||
}, 5000);
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error updating project subscription:', error);
|
||||
setSubscriptionLoading(false);
|
||||
@@ -235,16 +248,19 @@ const ProjectViewHeader = memo(() => {
|
||||
}, [dispatch]);
|
||||
|
||||
// Memoized dropdown items
|
||||
const dropdownItems = useMemo(() => [
|
||||
{
|
||||
key: 'import',
|
||||
label: (
|
||||
<div style={{ width: '100%', margin: 0, padding: 0 }} onClick={handleImportTaskTemplate}>
|
||||
<ImportOutlined /> {t('importTask')}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
], [handleImportTaskTemplate, t]);
|
||||
const dropdownItems = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'import',
|
||||
label: (
|
||||
<div style={{ width: '100%', margin: 0, padding: 0 }} onClick={handleImportTaskTemplate}>
|
||||
<ImportOutlined /> {t('importTask')}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[handleImportTaskTemplate, t]
|
||||
);
|
||||
|
||||
// Memoized project attributes with optimized date formatting
|
||||
const projectAttributes = useMemo(() => {
|
||||
@@ -254,9 +270,9 @@ const ProjectViewHeader = memo(() => {
|
||||
|
||||
if (selectedProject.category_id) {
|
||||
elements.push(
|
||||
<Tag
|
||||
<Tag
|
||||
key="category"
|
||||
color={colors.vibrantOrange}
|
||||
color={colors.vibrantOrange}
|
||||
style={{ borderRadius: 24, paddingInline: 8, margin: 0 }}
|
||||
>
|
||||
{selectedProject.category_name}
|
||||
@@ -330,11 +346,7 @@ const ProjectViewHeader = memo(() => {
|
||||
if (isOwnerOrAdmin) {
|
||||
actions.push(
|
||||
<Tooltip key="template" title={t('saveAsTemplate')}>
|
||||
<Button
|
||||
shape="circle"
|
||||
icon={<SaveOutlined />}
|
||||
onClick={handleSaveAsTemplate}
|
||||
/>
|
||||
<Button shape="circle" icon={<SaveOutlined />} onClick={handleSaveAsTemplate} />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -363,12 +375,7 @@ const ProjectViewHeader = memo(() => {
|
||||
// Invite button (owner/admin/project manager only)
|
||||
if (isOwnerOrAdmin || isProjectManager) {
|
||||
actions.push(
|
||||
<Button
|
||||
key="invite"
|
||||
type="primary"
|
||||
icon={<UsergroupAddOutlined />}
|
||||
onClick={handleInvite}
|
||||
>
|
||||
<Button key="invite" type="primary" icon={<UsergroupAddOutlined />} onClick={handleInvite}>
|
||||
{t('invite')}
|
||||
</Button>
|
||||
);
|
||||
@@ -426,24 +433,27 @@ const ProjectViewHeader = memo(() => {
|
||||
]);
|
||||
|
||||
// Memoized page header title
|
||||
const pageHeaderTitle = useMemo(() => (
|
||||
<Flex gap={8} align="center">
|
||||
<ArrowLeftOutlined
|
||||
style={{ fontSize: 16 }}
|
||||
onClick={handleNavigateToProjects}
|
||||
/>
|
||||
<Typography.Title level={4} style={{ marginBlockEnd: 0, marginInlineStart: 12 }}>
|
||||
{selectedProject?.name}
|
||||
</Typography.Title>
|
||||
{projectAttributes}
|
||||
</Flex>
|
||||
), [handleNavigateToProjects, selectedProject?.name, projectAttributes]);
|
||||
const pageHeaderTitle = useMemo(
|
||||
() => (
|
||||
<Flex gap={8} align="center">
|
||||
<ArrowLeftOutlined style={{ fontSize: 16 }} onClick={handleNavigateToProjects} />
|
||||
<Typography.Title level={4} style={{ marginBlockEnd: 0, marginInlineStart: 12 }}>
|
||||
{selectedProject?.name}
|
||||
</Typography.Title>
|
||||
{projectAttributes}
|
||||
</Flex>
|
||||
),
|
||||
[handleNavigateToProjects, selectedProject?.name, projectAttributes]
|
||||
);
|
||||
|
||||
// Memoized page header styles
|
||||
const pageHeaderStyle = useMemo(() => ({
|
||||
paddingInline: 0,
|
||||
marginBlockEnd: 12,
|
||||
}), []);
|
||||
const pageHeaderStyle = useMemo(
|
||||
() => ({
|
||||
paddingInline: 0,
|
||||
marginBlockEnd: 12,
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
useEffect(() => {
|
||||
|
||||
@@ -54,7 +54,9 @@
|
||||
[data-theme="default"] .project-view-tabs .ant-tabs-tab-active {
|
||||
color: #1e40af !important;
|
||||
background: #ffffff !important;
|
||||
box-shadow: 0 -2px 8px rgba(59, 130, 246, 0.1), 0 4px 16px rgba(59, 130, 246, 0.1);
|
||||
box-shadow:
|
||||
0 -2px 8px rgba(59, 130, 246, 0.1),
|
||||
0 4px 16px rgba(59, 130, 246, 0.1);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@@ -74,7 +76,9 @@
|
||||
[data-theme="dark"] .project-view-tabs .ant-tabs-tab-active {
|
||||
color: #60a5fa !important;
|
||||
background: #1f1f1f !important;
|
||||
box-shadow: 0 -2px 8px rgba(96, 165, 250, 0.15), 0 4px 16px rgba(96, 165, 250, 0.15);
|
||||
box-shadow:
|
||||
0 -2px 8px rgba(96, 165, 250, 0.15),
|
||||
0 4px 16px rgba(96, 165, 250, 0.15);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@@ -151,14 +155,14 @@
|
||||
.project-view-tabs .ant-tabs-nav {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
|
||||
.project-view-tabs .ant-tabs-tab {
|
||||
margin: 0 2px 0 0;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
|
||||
.project-view-tabs .borderless-icon-btn {
|
||||
margin-left: 4px;
|
||||
padding: 1px;
|
||||
@@ -171,7 +175,7 @@
|
||||
font-size: 11px;
|
||||
min-height: 30px;
|
||||
}
|
||||
|
||||
|
||||
.project-view-tabs .borderless-icon-btn {
|
||||
display: none; /* Hide pin buttons on very small screens */
|
||||
}
|
||||
|
||||
@@ -3,16 +3,16 @@ import { useLocation, useNavigate, useParams, useSearchParams } from 'react-rout
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
// Centralized Ant Design imports
|
||||
import {
|
||||
Button,
|
||||
ConfigProvider,
|
||||
Flex,
|
||||
import {
|
||||
Button,
|
||||
ConfigProvider,
|
||||
Flex,
|
||||
Tooltip,
|
||||
Badge,
|
||||
Tabs,
|
||||
PushpinFilled,
|
||||
PushpinFilled,
|
||||
PushpinOutlined,
|
||||
type TabsProps
|
||||
type TabsProps,
|
||||
} from '@/shared/antd-imports';
|
||||
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
@@ -33,7 +33,11 @@ import { resetFields } from '@/features/task-management/taskListFields.slice';
|
||||
import { fetchLabels } from '@/features/taskAttributes/taskLabelSlice';
|
||||
import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice';
|
||||
import { tabItems } from '@/lib/project/project-view-constants';
|
||||
import { setSelectedTaskId, setShowTaskDrawer, resetTaskDrawer } from '@/features/task-drawer/task-drawer.slice';
|
||||
import {
|
||||
setSelectedTaskId,
|
||||
setShowTaskDrawer,
|
||||
resetTaskDrawer,
|
||||
} from '@/features/task-drawer/task-drawer.slice';
|
||||
import { resetState as resetEnhancedKanbanState } from '@/features/enhanced-kanban/enhanced-kanban.slice';
|
||||
import { setProjectId as setInsightsProjectId } from '@/features/projects/insights/project-insights.slice';
|
||||
import { SuspenseFallback } from '@/components/suspense-fallback/suspense-fallback';
|
||||
@@ -42,12 +46,16 @@ import { SuspenseFallback } from '@/components/suspense-fallback/suspense-fallba
|
||||
import TaskDrawer from '@components/task-drawer/task-drawer';
|
||||
|
||||
// Lazy load non-critical components with better error handling
|
||||
const DeleteStatusDrawer = React.lazy(() => import('@/components/project-task-filters/delete-status-drawer/delete-status-drawer'));
|
||||
const DeleteStatusDrawer = React.lazy(
|
||||
() => import('@/components/project-task-filters/delete-status-drawer/delete-status-drawer')
|
||||
);
|
||||
const PhaseDrawer = React.lazy(() => import('@/features/projects/singleProject/phase/PhaseDrawer'));
|
||||
const StatusDrawer = React.lazy(() => import('@/components/project-task-filters/create-status-drawer/create-status-drawer'));
|
||||
const ProjectMemberDrawer = React.lazy(() => import('@/components/projects/project-member-invite-drawer/project-member-invite-drawer'));
|
||||
|
||||
|
||||
const StatusDrawer = React.lazy(
|
||||
() => import('@/components/project-task-filters/create-status-drawer/create-status-drawer')
|
||||
);
|
||||
const ProjectMemberDrawer = React.lazy(
|
||||
() => import('@/components/projects/project-member-invite-drawer/project-member-invite-drawer')
|
||||
);
|
||||
|
||||
const ProjectView = React.memo(() => {
|
||||
const location = useLocation();
|
||||
@@ -59,16 +67,19 @@ const ProjectView = React.memo(() => {
|
||||
// Memoized selectors to prevent unnecessary re-renders
|
||||
const selectedProject = useAppSelector(state => state.projectReducer.project);
|
||||
const projectLoading = useAppSelector(state => state.projectReducer.projectLoading);
|
||||
|
||||
|
||||
// Optimize document title updates
|
||||
useDocumentTitle(selectedProject?.name || 'Project View');
|
||||
|
||||
|
||||
// Memoize URL params to prevent unnecessary state updates
|
||||
const urlParams = useMemo(() => ({
|
||||
tab: searchParams.get('tab') || tabItems[0].key,
|
||||
pinnedTab: searchParams.get('pinned_tab') || '',
|
||||
taskId: searchParams.get('task') || ''
|
||||
}), [searchParams]);
|
||||
const urlParams = useMemo(
|
||||
() => ({
|
||||
tab: searchParams.get('tab') || tabItems[0].key,
|
||||
pinnedTab: searchParams.get('pinned_tab') || '',
|
||||
taskId: searchParams.get('task') || '',
|
||||
}),
|
||||
[searchParams]
|
||||
);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<string>(urlParams.tab);
|
||||
const [pinnedTab, setPinnedTab] = useState<string>(urlParams.pinnedTab);
|
||||
@@ -94,10 +105,10 @@ const ProjectView = React.memo(() => {
|
||||
dispatch(resetSelection());
|
||||
dispatch(resetFields());
|
||||
dispatch(resetEnhancedKanbanState());
|
||||
|
||||
|
||||
// Reset project insights
|
||||
dispatch(setInsightsProjectId(''));
|
||||
|
||||
|
||||
// Reset task drawer completely
|
||||
dispatch(resetTaskDrawer());
|
||||
}, [dispatch]);
|
||||
@@ -113,7 +124,7 @@ const ProjectView = React.memo(() => {
|
||||
// Effect for handling route changes (when navigating away from project view)
|
||||
useEffect(() => {
|
||||
const currentPath = location.pathname;
|
||||
|
||||
|
||||
// If we're not on a project view path, clean up
|
||||
if (!currentPath.includes('/worklenz/projects/') || currentPath === '/worklenz/projects') {
|
||||
resetAllProjectData();
|
||||
@@ -131,15 +142,15 @@ const ProjectView = React.memo(() => {
|
||||
dispatch(resetTaskManagement());
|
||||
dispatch(resetEnhancedKanbanState());
|
||||
dispatch(deselectAll());
|
||||
|
||||
|
||||
// Load new project data
|
||||
dispatch(setProjectId(projectId));
|
||||
|
||||
|
||||
// Load project and essential data in parallel
|
||||
const [projectResult] = await Promise.allSettled([
|
||||
dispatch(getProject(projectId)),
|
||||
dispatch(fetchStatuses(projectId)),
|
||||
dispatch(fetchLabels())
|
||||
dispatch(fetchLabels()),
|
||||
]);
|
||||
|
||||
if (projectResult.status === 'fulfilled' && !projectResult.value.payload) {
|
||||
@@ -172,51 +183,63 @@ const ProjectView = React.memo(() => {
|
||||
}, [dispatch, taskid, isInitialized]);
|
||||
|
||||
// Optimized pin tab function with better error handling
|
||||
const pinToDefaultTab = useCallback(async (itemKey: string) => {
|
||||
if (!itemKey || !projectId) return;
|
||||
const pinToDefaultTab = useCallback(
|
||||
async (itemKey: string) => {
|
||||
if (!itemKey || !projectId) return;
|
||||
|
||||
try {
|
||||
const defaultView = itemKey === 'tasks-list' ? 'TASK_LIST' : 'BOARD';
|
||||
const res = await projectsApiService.updateDefaultTab({
|
||||
project_id: projectId,
|
||||
default_view: defaultView,
|
||||
});
|
||||
|
||||
if (res.done) {
|
||||
setPinnedTab(itemKey);
|
||||
|
||||
// Optimize tab items update
|
||||
tabItems.forEach(item => {
|
||||
item.isPinned = item.key === itemKey;
|
||||
try {
|
||||
const defaultView = itemKey === 'tasks-list' ? 'TASK_LIST' : 'BOARD';
|
||||
const res = await projectsApiService.updateDefaultTab({
|
||||
project_id: projectId,
|
||||
default_view: defaultView,
|
||||
});
|
||||
|
||||
navigate({
|
||||
pathname: `/worklenz/projects/${projectId}`,
|
||||
search: new URLSearchParams({
|
||||
tab: activeTab,
|
||||
pinned_tab: itemKey
|
||||
}).toString(),
|
||||
}, { replace: true }); // Use replace to avoid history pollution
|
||||
if (res.done) {
|
||||
setPinnedTab(itemKey);
|
||||
|
||||
// Optimize tab items update
|
||||
tabItems.forEach(item => {
|
||||
item.isPinned = item.key === itemKey;
|
||||
});
|
||||
|
||||
navigate(
|
||||
{
|
||||
pathname: `/worklenz/projects/${projectId}`,
|
||||
search: new URLSearchParams({
|
||||
tab: activeTab,
|
||||
pinned_tab: itemKey,
|
||||
}).toString(),
|
||||
},
|
||||
{ replace: true }
|
||||
); // Use replace to avoid history pollution
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating default tab:', error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating default tab:', error);
|
||||
}
|
||||
}, [projectId, activeTab, navigate]);
|
||||
},
|
||||
[projectId, activeTab, navigate]
|
||||
);
|
||||
|
||||
// Optimized tab change handler
|
||||
const handleTabChange = useCallback((key: string) => {
|
||||
setActiveTab(key);
|
||||
dispatch(setProjectView(key === 'board' ? 'kanban' : 'list'));
|
||||
|
||||
// Use replace for better performance and history management
|
||||
navigate({
|
||||
pathname: location.pathname,
|
||||
search: new URLSearchParams({
|
||||
tab: key,
|
||||
pinned_tab: pinnedTab,
|
||||
}).toString(),
|
||||
}, { replace: true });
|
||||
}, [dispatch, location.pathname, navigate, pinnedTab]);
|
||||
const handleTabChange = useCallback(
|
||||
(key: string) => {
|
||||
setActiveTab(key);
|
||||
dispatch(setProjectView(key === 'board' ? 'kanban' : 'list'));
|
||||
|
||||
// Use replace for better performance and history management
|
||||
navigate(
|
||||
{
|
||||
pathname: location.pathname,
|
||||
search: new URLSearchParams({
|
||||
tab: key,
|
||||
pinned_tab: pinnedTab,
|
||||
}).toString(),
|
||||
},
|
||||
{ replace: true }
|
||||
);
|
||||
},
|
||||
[dispatch, location.pathname, navigate, pinnedTab]
|
||||
);
|
||||
|
||||
// Memoized tab menu items with enhanced styling
|
||||
const tabMenuItems = useMemo(() => {
|
||||
@@ -242,22 +265,22 @@ const ProjectView = React.memo(() => {
|
||||
}}
|
||||
icon={
|
||||
item.key === pinnedTab ? (
|
||||
<PushpinFilled
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: 'currentColor',
|
||||
transform: 'rotate(-45deg)',
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<PushpinOutlined
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: 'currentColor',
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
/>
|
||||
<PushpinFilled
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: 'currentColor',
|
||||
transform: 'rotate(-45deg)',
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<PushpinOutlined
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: 'currentColor',
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
onClick={e => {
|
||||
@@ -290,22 +313,25 @@ const ProjectView = React.memo(() => {
|
||||
}, [isInitialized]);
|
||||
|
||||
// Optimized portal elements with better error boundaries
|
||||
const portalElements = useMemo(() => (
|
||||
<>
|
||||
{/* Critical component - load immediately without suspense */}
|
||||
{createPortal(<TaskDrawer />, document.body, 'task-drawer')}
|
||||
|
||||
{/* Non-critical components - load after delay with suspense fallback */}
|
||||
{shouldLoadSecondaryComponents && (
|
||||
<Suspense fallback={<SuspenseFallback />}>
|
||||
{createPortal(<ProjectMemberDrawer />, document.body, 'project-member-drawer')}
|
||||
{createPortal(<PhaseDrawer />, document.body, 'phase-drawer')}
|
||||
{createPortal(<StatusDrawer />, document.body, 'status-drawer')}
|
||||
{createPortal(<DeleteStatusDrawer />, document.body, 'delete-status-drawer')}
|
||||
</Suspense>
|
||||
)}
|
||||
</>
|
||||
), [shouldLoadSecondaryComponents]);
|
||||
const portalElements = useMemo(
|
||||
() => (
|
||||
<>
|
||||
{/* Critical component - load immediately without suspense */}
|
||||
{createPortal(<TaskDrawer />, document.body, 'task-drawer')}
|
||||
|
||||
{/* Non-critical components - load after delay with suspense fallback */}
|
||||
{shouldLoadSecondaryComponents && (
|
||||
<Suspense fallback={<SuspenseFallback />}>
|
||||
{createPortal(<ProjectMemberDrawer />, document.body, 'project-member-drawer')}
|
||||
{createPortal(<PhaseDrawer />, document.body, 'phase-drawer')}
|
||||
{createPortal(<StatusDrawer />, document.body, 'status-drawer')}
|
||||
{createPortal(<DeleteStatusDrawer />, document.body, 'delete-status-drawer')}
|
||||
</Suspense>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
[shouldLoadSecondaryComponents]
|
||||
);
|
||||
|
||||
// Show loading state while project is being fetched
|
||||
if (projectLoading || !isInitialized) {
|
||||
@@ -325,7 +351,7 @@ const ProjectView = React.memo(() => {
|
||||
activeKey={activeTab}
|
||||
onChange={handleTabChange}
|
||||
items={tabMenuItems}
|
||||
tabBarStyle={{
|
||||
tabBarStyle={{
|
||||
paddingInline: 0,
|
||||
marginBottom: 8,
|
||||
background: 'transparent',
|
||||
|
||||
@@ -37,12 +37,7 @@ interface TaskGroupProps {
|
||||
activeId?: string | null;
|
||||
}
|
||||
|
||||
const TaskGroup: React.FC<TaskGroupProps> = ({
|
||||
taskGroup,
|
||||
groupBy,
|
||||
color,
|
||||
activeId
|
||||
}) => {
|
||||
const TaskGroup: React.FC<TaskGroupProps> = ({ taskGroup, groupBy, color, activeId }) => {
|
||||
const { t } = useTranslation('task-list-table');
|
||||
const dispatch = useAppDispatch();
|
||||
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||
@@ -113,7 +108,7 @@ const TaskGroup: React.FC<TaskGroupProps> = ({
|
||||
category_id: category,
|
||||
project_id: projectId,
|
||||
});
|
||||
|
||||
|
||||
dispatch(fetchStatuses());
|
||||
trackMixpanelEvent(evt_project_board_column_setting_click, {
|
||||
column_id: taskGroup.id,
|
||||
@@ -146,7 +141,7 @@ const TaskGroup: React.FC<TaskGroupProps> = ({
|
||||
await phasesApiService.updatePhase(phaseData);
|
||||
dispatch(fetchPhasesByProjectId(projectId));
|
||||
}
|
||||
|
||||
|
||||
setIsRenaming(false);
|
||||
} catch (error) {
|
||||
logger.error('Error renaming group:', error);
|
||||
@@ -172,10 +167,12 @@ const TaskGroup: React.FC<TaskGroupProps> = ({
|
||||
dispatch(fetchPhasesByProjectId(projectId));
|
||||
}
|
||||
|
||||
dispatch(updateTaskGroupColor({
|
||||
groupId: taskGroup.id,
|
||||
color: baseColor,
|
||||
}));
|
||||
dispatch(
|
||||
updateTaskGroupColor({
|
||||
groupId: taskGroup.id,
|
||||
color: baseColor,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Error updating group color:', error);
|
||||
}
|
||||
@@ -215,7 +212,7 @@ const TaskGroup: React.FC<TaskGroupProps> = ({
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
|
||||
{dropdownItems.length > 0 && !isRenaming && (
|
||||
<Dropdown menu={{ items: dropdownItems }} trigger={['click']}>
|
||||
<Button icon={<EllipsisOutlined />} className="borderless-icon-btn" />
|
||||
@@ -238,4 +235,4 @@ const TaskGroup: React.FC<TaskGroupProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(TaskGroup);
|
||||
export default React.memo(TaskGroup);
|
||||
|
||||
@@ -90,9 +90,7 @@ const TaskGroupList = ({ taskGroups, groupBy }: TaskGroupListProps) => {
|
||||
selected: true,
|
||||
}));
|
||||
|
||||
const groupId = groups.find(group =>
|
||||
group.tasks.some(task => task.id === data.id)
|
||||
)?.id;
|
||||
const groupId = groups.find(group => group.tasks.some(task => task.id === data.id))?.id;
|
||||
|
||||
if (groupId) {
|
||||
dispatch(
|
||||
@@ -129,13 +127,13 @@ const TaskGroupList = ({ taskGroups, groupBy }: TaskGroupListProps) => {
|
||||
const overGroupId = over.data.current?.groupId;
|
||||
const activeTaskId = active.id;
|
||||
const overTaskId = over.id;
|
||||
|
||||
|
||||
setGroups(prevGroups => {
|
||||
// ... existing drag end logic ...
|
||||
});
|
||||
};
|
||||
|
||||
const getDropdownItems = (groupId: string): MenuProps['items'] => ([
|
||||
const getDropdownItems = (groupId: string): MenuProps['items'] => [
|
||||
{
|
||||
key: '1',
|
||||
icon: <EditOutlined />,
|
||||
@@ -157,7 +155,7 @@ const TaskGroupList = ({ taskGroups, groupBy }: TaskGroupListProps) => {
|
||||
type: 'group',
|
||||
})),
|
||||
},
|
||||
]);
|
||||
];
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
@@ -184,20 +182,25 @@ const TaskGroupList = ({ taskGroups, groupBy }: TaskGroupListProps) => {
|
||||
<Button
|
||||
className="custom-collapse-button"
|
||||
style={{
|
||||
backgroundColor: themeMode === 'dark' ? group.color_code_dark : group.color_code,
|
||||
backgroundColor:
|
||||
themeMode === 'dark' ? group.color_code_dark : group.color_code,
|
||||
border: 'none',
|
||||
borderBottomLeftRadius: expandedGroups[group.id] ? 0 : 4,
|
||||
borderBottomRightRadius: expandedGroups[group.id] ? 0 : 4,
|
||||
color: colors.darkGray,
|
||||
}}
|
||||
icon={<RightOutlined rotate={expandedGroups[group.id] ? 90 : 0} />}
|
||||
onClick={() => setExpandedGroups(prev => ({ ...prev, [group.id]: !prev[group.id] }))}
|
||||
onClick={() =>
|
||||
setExpandedGroups(prev => ({ ...prev, [group.id]: !prev[group.id] }))
|
||||
}
|
||||
>
|
||||
{renamingGroup === group.id ? (
|
||||
<Input
|
||||
size="small"
|
||||
value={groupNames[group.id]}
|
||||
onChange={e => setGroupNames(prev => ({ ...prev, [group.id]: e.target.value }))}
|
||||
onChange={e =>
|
||||
setGroupNames(prev => ({ ...prev, [group.id]: e.target.value }))
|
||||
}
|
||||
onBlur={() => setRenamingGroup(null)}
|
||||
onPressEnter={() => setRenamingGroup(null)}
|
||||
onClick={e => e.stopPropagation()}
|
||||
@@ -220,11 +223,7 @@ const TaskGroupList = ({ taskGroups, groupBy }: TaskGroupListProps) => {
|
||||
className="border-l-[3px] relative after:content after:absolute after:h-full after:w-1 after:z-10 after:top-0 after:left-0 mt-1"
|
||||
color={themeMode === 'dark' ? group.color_code_dark : group.color_code}
|
||||
>
|
||||
<TaskListTable
|
||||
taskList={group.tasks}
|
||||
tableId={group.id}
|
||||
activeId={activeId}
|
||||
/>
|
||||
<TaskListTable taskList={group.tasks} tableId={group.id} activeId={activeId} />
|
||||
</Collapsible>
|
||||
</Flex>
|
||||
</div>
|
||||
@@ -232,7 +231,6 @@ const TaskGroupList = ({ taskGroups, groupBy }: TaskGroupListProps) => {
|
||||
</Flex>
|
||||
</ConfigProvider>
|
||||
|
||||
|
||||
{createPortal(
|
||||
<TaskTemplateDrawer showDrawer={false} selectedTemplateId={''} onClose={() => {}} />,
|
||||
document.body,
|
||||
@@ -242,4 +240,4 @@ const TaskGroupList = ({ taskGroups, groupBy }: TaskGroupListProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskGroupList;
|
||||
export default TaskGroupList;
|
||||
|
||||
@@ -35,14 +35,14 @@ const ProjectViewTaskList = () => {
|
||||
|
||||
// Simplified loading state - only wait for essential data
|
||||
// Remove dependency on phases and status categories for initial render
|
||||
const isLoading = useMemo(() =>
|
||||
loadingGroups || !coreDataLoaded,
|
||||
const isLoading = useMemo(
|
||||
() => loadingGroups || !coreDataLoaded,
|
||||
[loadingGroups, coreDataLoaded]
|
||||
);
|
||||
|
||||
// Memoize the empty state check
|
||||
const isEmptyState = useMemo(() =>
|
||||
taskGroups && taskGroups.length === 0 && !isLoading,
|
||||
const isEmptyState = useMemo(
|
||||
() => taskGroups && taskGroups.length === 0 && !isLoading,
|
||||
[taskGroups, isLoading]
|
||||
);
|
||||
|
||||
@@ -117,11 +117,8 @@ const ProjectViewTaskList = () => {
|
||||
{isEmptyState ? (
|
||||
<Empty description="No tasks group found" />
|
||||
) : (
|
||||
<Skeleton active loading={isLoading} className='mt-4 p-4'>
|
||||
<TaskGroupWrapperOptimized
|
||||
taskGroups={memoizedTaskGroups}
|
||||
groupBy={groupBy}
|
||||
/>
|
||||
<Skeleton active loading={isLoading} className="mt-4 p-4">
|
||||
<TaskGroupWrapperOptimized taskGroups={memoizedTaskGroups} groupBy={groupBy} />
|
||||
</Skeleton>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
@@ -24,11 +24,12 @@ const TaskGroupWrapperOptimized = ({ taskGroups, groupBy }: TaskGroupWrapperOpti
|
||||
useTaskSocketHandlers();
|
||||
|
||||
// Memoize task groups with colors
|
||||
const taskGroupsWithColors = useMemo(() =>
|
||||
taskGroups?.map(taskGroup => ({
|
||||
...taskGroup,
|
||||
displayColor: themeMode === 'dark' ? taskGroup.color_code_dark : taskGroup.color_code,
|
||||
})) || [],
|
||||
const taskGroupsWithColors = useMemo(
|
||||
() =>
|
||||
taskGroups?.map(taskGroup => ({
|
||||
...taskGroup,
|
||||
displayColor: themeMode === 'dark' ? taskGroup.color_code_dark : taskGroup.color_code,
|
||||
})) || [],
|
||||
[taskGroups, themeMode]
|
||||
);
|
||||
|
||||
@@ -69,8 +70,6 @@ const TaskGroupWrapperOptimized = ({ taskGroups, groupBy }: TaskGroupWrapperOpti
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
|
||||
{createPortal(
|
||||
<TaskTemplateDrawer showDrawer={false} selectedTemplateId="" onClose={() => {}} />,
|
||||
document.body,
|
||||
@@ -80,4 +79,4 @@ const TaskGroupWrapperOptimized = ({ taskGroups, groupBy }: TaskGroupWrapperOpti
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(TaskGroupWrapperOptimized);
|
||||
export default React.memo(TaskGroupWrapperOptimized);
|
||||
|
||||
@@ -62,14 +62,16 @@ const TaskListFilters: React.FC<TaskListFiltersProps> = ({ position }) => {
|
||||
|
||||
// Load team members last (heaviest query)
|
||||
setTimeout(() => {
|
||||
dispatch(getTeamMembers({
|
||||
index: 0,
|
||||
size: 50, // Reduce initial load size
|
||||
field: null,
|
||||
order: null,
|
||||
search: null,
|
||||
all: false // Don't load all at once
|
||||
}));
|
||||
dispatch(
|
||||
getTeamMembers({
|
||||
index: 0,
|
||||
size: 50, // Reduce initial load size
|
||||
field: null,
|
||||
order: null,
|
||||
search: null,
|
||||
all: false, // Don't load all at once
|
||||
})
|
||||
);
|
||||
}, 300);
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -71,10 +71,9 @@ const TaskContextMenu = ({ visible, position, selectedTask, onClose, t }: TaskCo
|
||||
if (res.done) {
|
||||
const { id: taskId, assignees } = res.body;
|
||||
trackMixpanelEvent(evt_project_task_list_context_menu_assign_me);
|
||||
const groupId = taskGroups.find(group =>
|
||||
group.tasks.some(task =>
|
||||
task.id === taskId ||
|
||||
task.sub_tasks?.some(subtask => subtask.id === taskId)
|
||||
const groupId = taskGroups.find(group =>
|
||||
group.tasks.some(
|
||||
task => task.id === taskId || task.sub_tasks?.some(subtask => subtask.id === taskId)
|
||||
)
|
||||
)?.id;
|
||||
|
||||
@@ -115,7 +114,8 @@ const TaskContextMenu = ({ visible, position, selectedTask, onClose, t }: TaskCo
|
||||
trackMixpanelEvent(evt_project_task_list_context_menu_archive);
|
||||
dispatch(deleteTask({ taskId: selectedTask.id }));
|
||||
dispatch(deselectAll());
|
||||
if (selectedTask.parent_task_id) socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), selectedTask.parent_task_id);
|
||||
if (selectedTask.parent_task_id)
|
||||
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), selectedTask.parent_task_id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -135,7 +135,8 @@ const TaskContextMenu = ({ visible, position, selectedTask, onClose, t }: TaskCo
|
||||
trackMixpanelEvent(evt_project_task_list_context_menu_delete);
|
||||
dispatch(deleteTask({ taskId: selectedTask.id }));
|
||||
dispatch(deselectAll());
|
||||
if (selectedTask.parent_task_id) socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), selectedTask.parent_task_id);
|
||||
if (selectedTask.parent_task_id)
|
||||
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), selectedTask.parent_task_id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -264,14 +265,16 @@ const TaskContextMenu = ({ visible, position, selectedTask, onClose, t }: TaskCo
|
||||
label: t('contextMenu.moveTo'),
|
||||
children: getMoveToOptions(),
|
||||
},
|
||||
...(!selectedTask?.parent_task_id ? [
|
||||
{
|
||||
key: '3',
|
||||
icon: <InboxOutlined />,
|
||||
label: archived ? t('contextMenu.unarchive' ) : t('contextMenu.archive'),
|
||||
onClick: handleArchive,
|
||||
},
|
||||
] : []),
|
||||
...(!selectedTask?.parent_task_id
|
||||
? [
|
||||
{
|
||||
key: '3',
|
||||
icon: <InboxOutlined />,
|
||||
label: archived ? t('contextMenu.unarchive') : t('contextMenu.archive'),
|
||||
onClick: handleArchive,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(selectedTask?.sub_tasks_count === 0 && !selectedTask?.parent_task_id
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -7,14 +7,14 @@ import { useTranslation } from 'react-i18next';
|
||||
import { colors } from '../../../../../../../../styles/colors';
|
||||
import { ITaskLabel } from '@/types/tasks/taskLabel.types';
|
||||
|
||||
const CustomColumnLabelCell = ({
|
||||
labelsList,
|
||||
const CustomColumnLabelCell = ({
|
||||
labelsList,
|
||||
selectedLabels = [],
|
||||
onChange
|
||||
}: {
|
||||
labelsList: ITaskLabel[],
|
||||
selectedLabels?: string[],
|
||||
onChange?: (labels: string[]) => void
|
||||
onChange,
|
||||
}: {
|
||||
labelsList: ITaskLabel[];
|
||||
selectedLabels?: string[];
|
||||
onChange?: (labels: string[]) => void;
|
||||
}) => {
|
||||
const [currentLabelOption, setCurrentLabelOption] = useState<ITaskLabel | null>(null);
|
||||
|
||||
|
||||
@@ -8,14 +8,14 @@ import { colors } from '../../../../../../../../styles/colors';
|
||||
import { SelectionType } from '../../custom-column-modal/selection-type-column/selection-type-column';
|
||||
import { ALPHA_CHANNEL } from '@/shared/constants';
|
||||
|
||||
const CustomColumnSelectionCell = ({
|
||||
const CustomColumnSelectionCell = ({
|
||||
selectionsList,
|
||||
value,
|
||||
onChange
|
||||
}: {
|
||||
selectionsList: SelectionType[],
|
||||
value?: string,
|
||||
onChange?: (value: string) => void
|
||||
onChange,
|
||||
}: {
|
||||
selectionsList: SelectionType[];
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
}) => {
|
||||
const [currentSelectionOption, setCurrentSelectionOption] = useState<SelectionType | null>(null);
|
||||
|
||||
@@ -23,10 +23,10 @@ const CustomColumnSelectionCell = ({
|
||||
const { t } = useTranslation('task-list-table');
|
||||
|
||||
// Debug the selectionsList and value
|
||||
console.log('CustomColumnSelectionCell props:', {
|
||||
selectionsList,
|
||||
console.log('CustomColumnSelectionCell props:', {
|
||||
selectionsList,
|
||||
value,
|
||||
selectionsCount: selectionsList?.length || 0
|
||||
selectionsCount: selectionsList?.length || 0,
|
||||
});
|
||||
|
||||
// Set initial selection based on value prop
|
||||
@@ -61,7 +61,7 @@ const CustomColumnSelectionCell = ({
|
||||
// handle selection selection
|
||||
const handleSelectionOptionSelect: MenuProps['onClick'] = e => {
|
||||
if (e.key === 'noSelections') return;
|
||||
|
||||
|
||||
const selectedOption = selectionsList.find(option => option.selection_id === e.key);
|
||||
if (selectedOption) {
|
||||
setCurrentSelectionOption(selectedOption);
|
||||
@@ -105,7 +105,8 @@ const CustomColumnSelectionCell = ({
|
||||
paddingInline: 8,
|
||||
height: 22,
|
||||
fontSize: 13,
|
||||
backgroundColor: currentSelectionOption?.selection_color + ALPHA_CHANNEL || colors.transparent,
|
||||
backgroundColor:
|
||||
currentSelectionOption?.selection_color + ALPHA_CHANNEL || colors.transparent,
|
||||
color: colors.darkGray,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
import { Button, Divider, Flex, Form, Input, message, Modal, Select, Typography, Popconfirm } from 'antd';
|
||||
import {
|
||||
Button,
|
||||
Divider,
|
||||
Flex,
|
||||
Form,
|
||||
Input,
|
||||
message,
|
||||
Modal,
|
||||
Select,
|
||||
Typography,
|
||||
Popconfirm,
|
||||
} from 'antd';
|
||||
import SelectionTypeColumn from './selection-type-column/selection-type-column';
|
||||
import NumberTypeColumn from './number-type-column/number-type-column';
|
||||
import LabelTypeColumn from './label-type-column/label-type-column';
|
||||
@@ -30,7 +41,10 @@ import {
|
||||
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||
import KeyTypeColumn from './key-type-column/key-type-column';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { addCustomColumn, deleteCustomColumn as deleteCustomColumnFromTasks } from '@/features/tasks/tasks.slice';
|
||||
import {
|
||||
addCustomColumn,
|
||||
deleteCustomColumn as deleteCustomColumnFromTasks,
|
||||
} from '@/features/tasks/tasks.slice';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { tasksCustomColumnsService } from '@/api/tasks/tasks-custom-columns.service';
|
||||
import { ExclamationCircleFilled } from '@ant-design/icons';
|
||||
@@ -76,22 +90,22 @@ const CustomColumnModal = () => {
|
||||
// Function to handle deleting a custom column
|
||||
const handleDeleteColumn = async () => {
|
||||
if (!customColumnId) return;
|
||||
|
||||
|
||||
try {
|
||||
// Make API request to delete the custom column using the service
|
||||
await tasksCustomColumnsService.deleteCustomColumn(openedColumn?.id || customColumnId);
|
||||
|
||||
|
||||
// Dispatch actions to update the Redux store
|
||||
dispatch(deleteCustomColumnFromTasks(customColumnId));
|
||||
dispatch(deleteCustomColumnFromColumns(customColumnId));
|
||||
|
||||
|
||||
// Close the modal
|
||||
dispatch(toggleCustomColumnModalOpen(false));
|
||||
dispatch(setCustomColumnModalAttributes({ modalType: 'create', columnId: null }));
|
||||
|
||||
|
||||
// Show success message
|
||||
message.success('Custom column deleted successfully');
|
||||
|
||||
|
||||
// Reload the page to reflect the changes
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
@@ -211,14 +225,14 @@ const CustomColumnModal = () => {
|
||||
field_type: value.fieldType,
|
||||
width: 120,
|
||||
is_visible: true,
|
||||
configuration
|
||||
configuration,
|
||||
});
|
||||
|
||||
if (res.done) {
|
||||
if (res.body.id) newColumn.id = res.body.id;
|
||||
dispatch(addCustomColumn(newColumn));
|
||||
dispatch(setCustomColumnModalAttributes({ modalType: 'create', columnId: null }));
|
||||
dispatch(toggleCustomColumnModalOpen(false));
|
||||
dispatch(addCustomColumn(newColumn));
|
||||
dispatch(setCustomColumnModalAttributes({ modalType: 'create', columnId: null }));
|
||||
dispatch(toggleCustomColumnModalOpen(false));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error creating custom column:', error);
|
||||
@@ -290,7 +304,7 @@ const CustomColumnModal = () => {
|
||||
field_type: value.fieldType,
|
||||
width: 150,
|
||||
is_visible: true,
|
||||
configuration
|
||||
configuration,
|
||||
});
|
||||
|
||||
// Close modal
|
||||
@@ -329,24 +343,33 @@ const CustomColumnModal = () => {
|
||||
mainForm.resetFields();
|
||||
}}
|
||||
afterOpenChange={open => {
|
||||
if (open && customColumnModalType === 'edit' && openedColumn) {
|
||||
if (open && customColumnModalType === 'edit' && openedColumn) {
|
||||
// Set the field type first so the correct form fields are displayed
|
||||
dispatch(setCustomFieldType(openedColumn.custom_column_obj?.fieldType || 'people'));
|
||||
|
||||
|
||||
// Set other field values based on the custom column type
|
||||
if (openedColumn.custom_column_obj?.fieldType === 'number') {
|
||||
dispatch(setCustomFieldNumberType(openedColumn.custom_column_obj?.numberType || 'formatted'));
|
||||
dispatch(
|
||||
setCustomFieldNumberType(openedColumn.custom_column_obj?.numberType || 'formatted')
|
||||
);
|
||||
dispatch(setDecimals(openedColumn.custom_column_obj?.decimals || 0));
|
||||
dispatch(setLabel(openedColumn.custom_column_obj?.label || ''));
|
||||
dispatch(setLabelPosition(openedColumn.custom_column_obj?.labelPosition || 'left'));
|
||||
} else if (openedColumn.custom_column_obj?.fieldType === 'formula') {
|
||||
dispatch(setExpression(openedColumn.custom_column_obj?.expression || 'add'));
|
||||
dispatch(setFirstNumericColumn(openedColumn.custom_column_obj?.firstNumericColumn || null));
|
||||
dispatch(setSecondNumericColumn(openedColumn.custom_column_obj?.secondNumericColumn || null));
|
||||
dispatch(
|
||||
setFirstNumericColumn(openedColumn.custom_column_obj?.firstNumericColumn || null)
|
||||
);
|
||||
dispatch(
|
||||
setSecondNumericColumn(openedColumn.custom_column_obj?.secondNumericColumn || null)
|
||||
);
|
||||
} else if (openedColumn.custom_column_obj?.fieldType === 'selection') {
|
||||
// Directly set the selections list in the Redux store
|
||||
if (Array.isArray(openedColumn.custom_column_obj?.selectionsList)) {
|
||||
console.log('Setting selections list:', openedColumn.custom_column_obj.selectionsList);
|
||||
console.log(
|
||||
'Setting selections list:',
|
||||
openedColumn.custom_column_obj.selectionsList
|
||||
);
|
||||
dispatch(setSelectionsList(openedColumn.custom_column_obj.selectionsList));
|
||||
}
|
||||
} else if (openedColumn.custom_column_obj?.fieldType === 'labels') {
|
||||
@@ -356,7 +379,7 @@ const CustomColumnModal = () => {
|
||||
dispatch(setLabelsList(openedColumn.custom_column_obj.labelsList));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Set form values
|
||||
mainForm.setFieldsValue({
|
||||
fieldTitle: openedColumn.name || openedColumn.custom_column_obj?.fieldTitle,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { nanoid } from "nanoid";
|
||||
import { nanoid } from 'nanoid';
|
||||
import { PhaseColorCodes } from '../../../../../../../../shared/constants';
|
||||
import { Button, Flex, Input, Select, Tag, Typography } from 'antd';
|
||||
import { CloseCircleOutlined, HolderOutlined } from '@ant-design/icons';
|
||||
@@ -27,9 +27,9 @@ const LabelTypeColumn = () => {
|
||||
const { customColumnModalType, customColumnId } = useAppSelector(
|
||||
state => state.taskListCustomColumnsReducer
|
||||
);
|
||||
|
||||
|
||||
// Get the opened column data if in edit mode
|
||||
const openedColumn = useAppSelector(state =>
|
||||
const openedColumn = useAppSelector(state =>
|
||||
state.taskReducer.customColumns.find(col => col.key === customColumnId)
|
||||
);
|
||||
|
||||
@@ -127,11 +127,7 @@ const LabelTypeColumn = () => {
|
||||
))}
|
||||
</Flex>
|
||||
|
||||
<Button
|
||||
type="link"
|
||||
onClick={handleAddLabel}
|
||||
style={{ width: 'fit-content', padding: 0 }}
|
||||
>
|
||||
<Button type="link" onClick={handleAddLabel} style={{ width: 'fit-content', padding: 0 }}>
|
||||
+ Add a label
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { nanoid } from "nanoid";
|
||||
import { nanoid } from 'nanoid';
|
||||
import { PhaseColorCodes } from '../../../../../../../../shared/constants';
|
||||
import { Button, Flex, Input, Select, Tag, Typography } from 'antd';
|
||||
import { CloseCircleOutlined, HolderOutlined } from '@ant-design/icons';
|
||||
@@ -25,12 +25,14 @@ const SelectionTypeColumn = () => {
|
||||
]);
|
||||
|
||||
// Get the custom column modal type and column ID from the store
|
||||
const { customColumnModalType, customColumnId, selectionsList: storeSelectionsList } = useAppSelector(
|
||||
state => state.taskListCustomColumnsReducer
|
||||
);
|
||||
|
||||
const {
|
||||
customColumnModalType,
|
||||
customColumnId,
|
||||
selectionsList: storeSelectionsList,
|
||||
} = useAppSelector(state => state.taskListCustomColumnsReducer);
|
||||
|
||||
// Get the opened column data if in edit mode
|
||||
const openedColumn = useAppSelector(state =>
|
||||
const openedColumn = useAppSelector(state =>
|
||||
state.taskReducer.customColumns.find(col => col.key === customColumnId)
|
||||
);
|
||||
|
||||
@@ -39,7 +41,8 @@ const SelectionTypeColumn = () => {
|
||||
customColumnId,
|
||||
openedColumn,
|
||||
storeSelectionsList,
|
||||
'openedColumn?.custom_column_obj?.selectionsList': openedColumn?.custom_column_obj?.selectionsList
|
||||
'openedColumn?.custom_column_obj?.selectionsList':
|
||||
openedColumn?.custom_column_obj?.selectionsList,
|
||||
});
|
||||
|
||||
// Load existing selections when in edit mode
|
||||
@@ -47,7 +50,7 @@ const SelectionTypeColumn = () => {
|
||||
if (customColumnModalType === 'edit' && openedColumn?.custom_column_obj?.selectionsList) {
|
||||
const existingSelections = openedColumn.custom_column_obj.selectionsList;
|
||||
console.log('Loading existing selections:', existingSelections);
|
||||
|
||||
|
||||
if (Array.isArray(existingSelections) && existingSelections.length > 0) {
|
||||
setSelections(existingSelections);
|
||||
dispatch(setSelectionsList(existingSelections));
|
||||
@@ -86,7 +89,9 @@ const SelectionTypeColumn = () => {
|
||||
// update selection name
|
||||
const handleUpdateSelectionName = (selectionId: string, selectionName: string) => {
|
||||
const updatedSelections = selections.map(selection =>
|
||||
selection.selection_id === selectionId ? { ...selection, selection_name: selectionName } : selection
|
||||
selection.selection_id === selectionId
|
||||
? { ...selection, selection_name: selectionName }
|
||||
: selection
|
||||
);
|
||||
setSelections(updatedSelections);
|
||||
dispatch(setSelectionsList(updatedSelections)); // update the slice with the new selection name
|
||||
@@ -95,7 +100,9 @@ const SelectionTypeColumn = () => {
|
||||
// update selection color
|
||||
const handleUpdateSelectionColor = (selectionId: string, selectionColor: string) => {
|
||||
const updatedSelections = selections.map(selection =>
|
||||
selection.selection_id === selectionId ? { ...selection, selection_color: selectionColor } : selection
|
||||
selection.selection_id === selectionId
|
||||
? { ...selection, selection_color: selectionColor }
|
||||
: selection
|
||||
);
|
||||
setSelections(updatedSelections);
|
||||
dispatch(setSelectionsList(updatedSelections)); // update the slice with the new selection color
|
||||
@@ -103,7 +110,9 @@ const SelectionTypeColumn = () => {
|
||||
|
||||
// remove a selection
|
||||
const handleRemoveSelection = (selectionId: string) => {
|
||||
const updatedSelections = selections.filter(selection => selection.selection_id !== selectionId);
|
||||
const updatedSelections = selections.filter(
|
||||
selection => selection.selection_id !== selectionId
|
||||
);
|
||||
setSelections(updatedSelections);
|
||||
dispatch(setSelectionsList(updatedSelections)); // update the slice after selection removal
|
||||
};
|
||||
|
||||
@@ -117,10 +117,11 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
||||
(data: ITaskAssigneesUpdateResponse) => {
|
||||
if (!data) return;
|
||||
|
||||
const updatedAssignees = data.assignees?.map(assignee => ({
|
||||
...assignee,
|
||||
selected: true,
|
||||
})) || [];
|
||||
const updatedAssignees =
|
||||
data.assignees?.map(assignee => ({
|
||||
...assignee,
|
||||
selected: true,
|
||||
})) || [];
|
||||
|
||||
const groupId = groups?.find(group =>
|
||||
group.tasks?.some(
|
||||
@@ -158,7 +159,7 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
||||
const handleLabelsChange = useCallback(
|
||||
async (labels: ILabelsChangeResponse) => {
|
||||
if (!labels) return;
|
||||
|
||||
|
||||
await Promise.all([
|
||||
dispatch(updateTaskLabel(labels)),
|
||||
dispatch(setTaskLabels(labels)),
|
||||
@@ -226,11 +227,7 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
||||
|
||||
// Memoize socket event handlers
|
||||
const handleEndDateChange = useCallback(
|
||||
(task: {
|
||||
id: string;
|
||||
parent_task: string | null;
|
||||
end_date: string;
|
||||
}) => {
|
||||
(task: { id: string; parent_task: string | null; end_date: string }) => {
|
||||
if (!task) return;
|
||||
|
||||
const taskWithProgress = {
|
||||
@@ -267,11 +264,7 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
||||
|
||||
// Memoize socket event handlers
|
||||
const handleStartDateChange = useCallback(
|
||||
(task: {
|
||||
id: string;
|
||||
parent_task: string | null;
|
||||
start_date: string;
|
||||
}) => {
|
||||
(task: { id: string; parent_task: string | null; start_date: string }) => {
|
||||
if (!task) return;
|
||||
|
||||
const taskWithProgress = {
|
||||
@@ -297,11 +290,7 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
||||
|
||||
// Memoize socket event handlers
|
||||
const handleEstimationChange = useCallback(
|
||||
(task: {
|
||||
id: string;
|
||||
parent_task: string | null;
|
||||
estimation: number;
|
||||
}) => {
|
||||
(task: { id: string; parent_task: string | null; estimation: number }) => {
|
||||
if (!task) return;
|
||||
|
||||
const taskWithProgress = {
|
||||
@@ -316,11 +305,7 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
||||
|
||||
// Memoize socket event handlers
|
||||
const handleTaskDescriptionChange = useCallback(
|
||||
(data: {
|
||||
id: string;
|
||||
parent_task: string;
|
||||
description: string;
|
||||
}) => {
|
||||
(data: { id: string; parent_task: string; description: string }) => {
|
||||
if (!data) return;
|
||||
|
||||
dispatch(updateTaskDescription(data));
|
||||
@@ -342,11 +327,7 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
||||
|
||||
// Memoize socket event handlers
|
||||
const handleTaskProgressUpdated = useCallback(
|
||||
(data: {
|
||||
task_id: string;
|
||||
progress_value?: number;
|
||||
weight?: number;
|
||||
}) => {
|
||||
(data: { task_id: string; progress_value?: number; weight?: number }) => {
|
||||
if (!data || !taskGroups) return;
|
||||
|
||||
if (data.progress_value !== undefined) {
|
||||
@@ -686,8 +667,6 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
|
||||
{createPortal(
|
||||
<TaskTemplateDrawer showDrawer={false} selectedTemplateId="" onClose={() => {}} />,
|
||||
document.body,
|
||||
|
||||
@@ -6,7 +6,7 @@ const TaskListDescriptionCell = ({ description }: { description: string }) => {
|
||||
|
||||
return (
|
||||
<Typography.Paragraph
|
||||
ellipsis={{
|
||||
ellipsis={{
|
||||
expandable: false,
|
||||
rows: 1,
|
||||
tooltip: description,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Typography } from 'antd';
|
||||
import React from 'react';
|
||||
|
||||
interface ITaskListEstimationCellProps {
|
||||
task: IProjectTask
|
||||
task: IProjectTask;
|
||||
}
|
||||
const TaskListEstimationCell = ({ task }: ITaskListEstimationCellProps) => {
|
||||
return <Typography.Text>{task?.total_time_string}</Typography.Text>;
|
||||
|
||||
@@ -11,13 +11,17 @@ interface TaskListLabelsCellProps {
|
||||
const TaskListLabelsCell = ({ task }: TaskListLabelsCellProps) => {
|
||||
return (
|
||||
<Flex>
|
||||
{task.labels?.map((label, index) => (
|
||||
{task.labels?.map((label, index) =>
|
||||
label.end && label.names && label.name ? (
|
||||
<CustomNumberLabel key={`${label.id}-${index}`} labelList={label.names ?? []} namesString={label.name} />
|
||||
<CustomNumberLabel
|
||||
key={`${label.id}-${index}`}
|
||||
labelList={label.names ?? []}
|
||||
namesString={label.name}
|
||||
/>
|
||||
) : (
|
||||
<CustomColordLabel key={`${label.id}-${index}`} label={label} />
|
||||
)
|
||||
))}
|
||||
)}
|
||||
<LabelsSelector task={task} />
|
||||
</Flex>
|
||||
);
|
||||
|
||||
@@ -10,7 +10,10 @@ type TaskListProgressCellProps = {
|
||||
|
||||
const TaskListProgressCell = ({ task }: TaskListProgressCellProps) => {
|
||||
const { project } = useAppSelector(state => state.projectReducer);
|
||||
const isManualProgressEnabled = (task.project_use_manual_progress || task.project_use_weighted_progress || task.project_use_time_progress);;
|
||||
const isManualProgressEnabled =
|
||||
task.project_use_manual_progress ||
|
||||
task.project_use_weighted_progress ||
|
||||
task.project_use_time_progress;
|
||||
const isSubtask = task.is_sub_task;
|
||||
const hasManualProgress = task.manual_progress;
|
||||
|
||||
@@ -18,7 +21,7 @@ const TaskListProgressCell = ({ task }: TaskListProgressCellProps) => {
|
||||
// 1. For subtasks when manual progress is enabled, show the progress
|
||||
// 2. For parent tasks, always show progress
|
||||
// 3. For subtasks when manual progress is not enabled, don't show progress (null)
|
||||
|
||||
|
||||
if (isSubtask && !isManualProgressEnabled) {
|
||||
return null; // Don't show progress for subtasks when manual progress is disabled
|
||||
}
|
||||
@@ -40,11 +43,11 @@ const TaskListProgressCell = ({ task }: TaskListProgressCellProps) => {
|
||||
|
||||
// For subtasks with manual progress enabled, show the progress
|
||||
return (
|
||||
<Tooltip
|
||||
<Tooltip
|
||||
title={hasManualProgress ? `Manual: ${task.progress_value || 0}%` : `${task.progress || 0}%`}
|
||||
>
|
||||
<Progress
|
||||
percent={hasManualProgress ? (task.progress_value || 0) : (task.progress || 0)}
|
||||
percent={hasManualProgress ? task.progress_value || 0 : task.progress || 0}
|
||||
type="circle"
|
||||
size={22} // Slightly smaller for subtasks
|
||||
style={{ cursor: 'default' }}
|
||||
|
||||
@@ -38,7 +38,7 @@ const AddSubTaskListRow: React.FC<AddSubTaskListRowProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<tr className={`add-subtask-row${customBorderColor}`}>
|
||||
<tr className={`add-subtask-row${customBorderColor}`}>
|
||||
{visibleColumns.map(col => (
|
||||
<td key={col.key} style={{ padding: 0, background: 'inherit' }}>
|
||||
{col.key === taskColumnKey ? (
|
||||
|
||||
@@ -123,7 +123,7 @@ const AddTaskListRow = ({ groupId = null, parentTask = null }: IAddTaskListRowPr
|
||||
clearTimeout(taskCreationTimeout);
|
||||
setTaskCreationTimeout(null);
|
||||
}
|
||||
|
||||
|
||||
setIsEdit(true);
|
||||
|
||||
setTimeout(() => {
|
||||
@@ -142,12 +142,10 @@ const AddTaskListRow = ({ groupId = null, parentTask = null }: IAddTaskListRowPr
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
const addInstantTask = async () => {
|
||||
// Validation
|
||||
if (creatingTask || !projectId || !currentSession) return;
|
||||
|
||||
|
||||
const trimmedTaskName = taskName.trim();
|
||||
if (trimmedTaskName === '') {
|
||||
setError('Task name cannot be empty');
|
||||
@@ -158,7 +156,7 @@ const AddTaskListRow = ({ groupId = null, parentTask = null }: IAddTaskListRowPr
|
||||
try {
|
||||
setCreatingTask(true);
|
||||
setError('');
|
||||
|
||||
|
||||
const body = createRequestBody();
|
||||
if (!body) {
|
||||
setError('Failed to create task. Please try again.');
|
||||
@@ -171,17 +169,17 @@ const AddTaskListRow = ({ groupId = null, parentTask = null }: IAddTaskListRowPr
|
||||
setCreatingTask(false);
|
||||
setError('Task creation timed out. Please try again.');
|
||||
}, 10000);
|
||||
|
||||
|
||||
setTaskCreationTimeout(timeout);
|
||||
|
||||
socket?.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(body));
|
||||
|
||||
|
||||
// Handle success response - the global socket handler will handle task addition
|
||||
socket?.once(SocketEvents.QUICK_TASK.toString(), (task: IProjectTask) => {
|
||||
clearTimeout(timeout);
|
||||
setTaskCreationTimeout(null);
|
||||
setCreatingTask(false);
|
||||
|
||||
|
||||
if (task && task.id) {
|
||||
// Just reset the form - the global handler will add the task to Redux
|
||||
reset(false);
|
||||
@@ -204,7 +202,6 @@ const AddTaskListRow = ({ groupId = null, parentTask = null }: IAddTaskListRowPr
|
||||
const errorMessage = errorData?.message || 'Failed to create task';
|
||||
setError(errorMessage);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error adding task:', error);
|
||||
setCreatingTask(false);
|
||||
@@ -238,9 +235,9 @@ const AddTaskListRow = ({ groupId = null, parentTask = null }: IAddTaskListRowPr
|
||||
<div className="add-task-input-container">
|
||||
<Input
|
||||
className="add-task-input"
|
||||
style={{
|
||||
style={{
|
||||
borderColor: error ? '#ff4d4f' : colors.skyBlue,
|
||||
paddingRight: creatingTask ? '32px' : '12px'
|
||||
paddingRight: creatingTask ? '32px' : '12px',
|
||||
}}
|
||||
placeholder={t('addTaskInputPlaceholder')}
|
||||
value={taskName}
|
||||
@@ -252,29 +249,19 @@ const AddTaskListRow = ({ groupId = null, parentTask = null }: IAddTaskListRowPr
|
||||
/>
|
||||
{creatingTask && (
|
||||
<div className="add-task-loading">
|
||||
<Spin
|
||||
size="small"
|
||||
indicator={<LoadingOutlined style={{ fontSize: 14 }} spin />}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="add-task-error">
|
||||
{error}
|
||||
<Spin size="small" indicator={<LoadingOutlined style={{ fontSize: 14 }} spin />} />
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="add-task-error">{error}</div>}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="add-task-label"
|
||||
onClick={() => setIsEdit(true)}
|
||||
>
|
||||
<div className="add-task-label" onClick={() => setIsEdit(true)}>
|
||||
<span className="add-task-text">
|
||||
{parentTask ? t('addSubTaskText') : t('addTaskText')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<style>{`
|
||||
.add-task-row-container {
|
||||
width: 100%;
|
||||
|
||||
@@ -16,7 +16,12 @@ import TaskListTable from '../task-list-table';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
import Collapsible from '@/components/collapsible/collapsible';
|
||||
import { fetchTaskGroups, fetchTaskListColumns, IGroupBy, updateTaskGroupColor } from '@/features/tasks/tasks.slice';
|
||||
import {
|
||||
fetchTaskGroups,
|
||||
fetchTaskListColumns,
|
||||
IGroupBy,
|
||||
updateTaskGroupColor,
|
||||
} from '@/features/tasks/tasks.slice';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { ITaskStatusUpdateModel } from '@/types/tasks/task-status-update-model.types';
|
||||
@@ -131,7 +136,11 @@ const TaskListTableWrapper = ({
|
||||
await updateStatus();
|
||||
} else if (groupBy === IGroupBy.PHASE) {
|
||||
const body = { id: tableId, name: tableName.trim() };
|
||||
const res = await phasesApiService.updateNameOfPhase(tableId, body as ITaskPhase, projectId);
|
||||
const res = await phasesApiService.updateNameOfPhase(
|
||||
tableId,
|
||||
body as ITaskPhase,
|
||||
projectId
|
||||
);
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_board_column_setting_click, { Rename: 'Phase' });
|
||||
dispatch(fetchPhasesByProjectId(projectId));
|
||||
@@ -211,7 +220,7 @@ const TaskListTableWrapper = ({
|
||||
icon={<RightOutlined rotate={isExpanded ? 90 : 0} />}
|
||||
onClick={handlToggleExpand}
|
||||
>
|
||||
{(showRenameInput && name !== 'Unmapped') ? (
|
||||
{showRenameInput && name !== 'Unmapped' ? (
|
||||
<Input
|
||||
size="small"
|
||||
value={tableName}
|
||||
@@ -234,22 +243,30 @@ const TaskListTableWrapper = ({
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Button>
|
||||
{groupBy !== IGroupBy.PRIORITY && !showRenameInput && isEditable && name !== 'Unmapped' && (
|
||||
<Dropdown menu={{ items }}>
|
||||
<Button
|
||||
icon={<EllipsisOutlined />}
|
||||
className="borderless-icon-btn"
|
||||
title={isEditable ? undefined : t('noPermission')}
|
||||
/>
|
||||
</Dropdown>
|
||||
)}
|
||||
{groupBy !== IGroupBy.PRIORITY &&
|
||||
!showRenameInput &&
|
||||
isEditable &&
|
||||
name !== 'Unmapped' && (
|
||||
<Dropdown menu={{ items }}>
|
||||
<Button
|
||||
icon={<EllipsisOutlined />}
|
||||
className="borderless-icon-btn"
|
||||
title={isEditable ? undefined : t('noPermission')}
|
||||
/>
|
||||
</Dropdown>
|
||||
)}
|
||||
</Flex>
|
||||
<Collapsible
|
||||
isOpen={isExpanded}
|
||||
className={`border-l-[3px] relative after:content after:absolute after:h-full after:w-1 after:z-10 after:top-0 after:left-0`}
|
||||
color={color}
|
||||
>
|
||||
<TaskListTable taskList={taskList} tableId={tableId} activeId={activeId} groupBy={groupBy} />
|
||||
<TaskListTable
|
||||
taskList={taskList}
|
||||
tableId={tableId}
|
||||
activeId={activeId}
|
||||
groupBy={groupBy}
|
||||
/>
|
||||
</Collapsible>
|
||||
</Flex>
|
||||
</ConfigProvider>
|
||||
@@ -257,4 +274,4 @@ const TaskListTableWrapper = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskListTableWrapper;
|
||||
export default TaskListTableWrapper;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
||||
|
||||
@@ -124,7 +124,9 @@ const MembersReportsTable = () => {
|
||||
dataSource={membersList}
|
||||
rowKey={record => record.id}
|
||||
pagination={{ showSizeChanger: true, defaultPageSize: 10, total: total }}
|
||||
onChange={(pagination, filters, sorter, extra) => handleOnChange(pagination, filters, sorter, extra)}
|
||||
onChange={(pagination, filters, sorter, extra) =>
|
||||
handleOnChange(pagination, filters, sorter, extra)
|
||||
}
|
||||
scroll={{ x: 'max-content' }}
|
||||
loading={isLoading}
|
||||
onRow={record => {
|
||||
|
||||
@@ -22,7 +22,6 @@ const TasksProgressCell = ({ tasksStat }: TasksProgressCellProps) => {
|
||||
{ percent: donePercent, color: '#98d4b1', label: 'done' },
|
||||
{ percent: doingPercent, color: '#bce3cc', label: 'doing' },
|
||||
{ percent: todoPercent, color: '#e3f4ea', label: 'todo' },
|
||||
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -28,10 +28,14 @@ const MembersReports = () => {
|
||||
const { archived, searchQuery, total } = useAppSelector(state => state.membersReportsReducer);
|
||||
const { duration, dateRange } = useAppSelector(state => state.reportingReducer);
|
||||
|
||||
|
||||
const handleExport = () => {
|
||||
if (!currentSession?.team_name) return;
|
||||
reportingExportApiService.exportMembers(currentSession.team_name, duration, dateRange, archived);
|
||||
reportingExportApiService.exportMembers(
|
||||
currentSession.team_name,
|
||||
duration,
|
||||
dateRange,
|
||||
archived
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -30,26 +30,29 @@ const OverviewReports = () => {
|
||||
}, [dispatch]);
|
||||
|
||||
// Memoize the header children to prevent unnecessary re-renders
|
||||
const headerChildren = useMemo(() => (
|
||||
<Button type="text" onClick={handleArchiveToggle}>
|
||||
<Checkbox checked={includeArchivedProjects} />
|
||||
<Typography.Text>{t('includeArchivedButton')}</Typography.Text>
|
||||
</Button>
|
||||
), [handleArchiveToggle, includeArchivedProjects, t]);
|
||||
const headerChildren = useMemo(
|
||||
() => (
|
||||
<Button type="text" onClick={handleArchiveToggle}>
|
||||
<Checkbox checked={includeArchivedProjects} />
|
||||
<Typography.Text>{t('includeArchivedButton')}</Typography.Text>
|
||||
</Button>
|
||||
),
|
||||
[handleArchiveToggle, includeArchivedProjects, t]
|
||||
);
|
||||
|
||||
// Memoize the teams text to prevent unnecessary re-renders
|
||||
const teamsText = useMemo(() => (
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>
|
||||
{t('teamsText')}
|
||||
</Typography.Text>
|
||||
), [t]);
|
||||
const teamsText = useMemo(
|
||||
() => (
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>
|
||||
{t('teamsText')}
|
||||
</Typography.Text>
|
||||
),
|
||||
[t]
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex vertical gap={24}>
|
||||
<CustomPageHeader
|
||||
title={t('overviewTitle')}
|
||||
children={headerChildren}
|
||||
/>
|
||||
<CustomPageHeader title={t('overviewTitle')} children={headerChildren} />
|
||||
|
||||
<OverviewStats />
|
||||
|
||||
|
||||
@@ -8,143 +8,147 @@ interface InsightCardProps {
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const OverviewStatCard = React.memo(({ icon, title, children, loading = false }: InsightCardProps) => {
|
||||
const { token } = theme.useToken();
|
||||
// Better dark mode detection using multiple token properties
|
||||
const isDarkMode = token.colorBgContainer === '#1f1f1f' ||
|
||||
token.colorBgBase === '#141414' ||
|
||||
token.colorBgElevated === '#1f1f1f' ||
|
||||
document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||
document.body.classList.contains('dark');
|
||||
const OverviewStatCard = React.memo(
|
||||
({ icon, title, children, loading = false }: InsightCardProps) => {
|
||||
const { token } = theme.useToken();
|
||||
// Better dark mode detection using multiple token properties
|
||||
const isDarkMode =
|
||||
token.colorBgContainer === '#1f1f1f' ||
|
||||
token.colorBgBase === '#141414' ||
|
||||
token.colorBgElevated === '#1f1f1f' ||
|
||||
document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||
document.body.classList.contains('dark');
|
||||
|
||||
// Memoize enhanced card styles with dark mode support
|
||||
const cardStyles = useMemo(() => ({
|
||||
body: {
|
||||
padding: '24px',
|
||||
background: isDarkMode
|
||||
? '#1f1f1f !important'
|
||||
: '#ffffff !important',
|
||||
}
|
||||
}), [isDarkMode]);
|
||||
// Memoize enhanced card styles with dark mode support
|
||||
const cardStyles = useMemo(
|
||||
() => ({
|
||||
body: {
|
||||
padding: '24px',
|
||||
background: isDarkMode ? '#1f1f1f !important' : '#ffffff !important',
|
||||
},
|
||||
}),
|
||||
[isDarkMode]
|
||||
);
|
||||
|
||||
// Memoize card container styles with dark mode support
|
||||
const cardContainerStyle = useMemo(() => ({
|
||||
width: '100%',
|
||||
borderRadius: '0px',
|
||||
border: isDarkMode
|
||||
? '1px solid #303030'
|
||||
: '1px solid #f0f0f0',
|
||||
boxShadow: isDarkMode
|
||||
? '0 2px 8px rgba(0, 0, 0, 0.3)'
|
||||
: '0 2px 8px rgba(0, 0, 0, 0.06)',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
overflow: 'hidden',
|
||||
position: 'relative' as const,
|
||||
cursor: 'default',
|
||||
backgroundColor: isDarkMode ? '#1f1f1f !important' : '#ffffff !important',
|
||||
}), [isDarkMode]);
|
||||
|
||||
// Memoize icon container styles with dark mode support
|
||||
const iconContainerStyle = useMemo(() => ({
|
||||
padding: '12px',
|
||||
borderRadius: '0px',
|
||||
background: isDarkMode
|
||||
? '#2a2a2a'
|
||||
: '#f8f9ff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: '64px',
|
||||
minHeight: '64px',
|
||||
boxShadow: isDarkMode
|
||||
? '0 2px 4px rgba(24, 144, 255, 0.2)'
|
||||
: '0 2px 4px rgba(24, 144, 255, 0.1)',
|
||||
border: isDarkMode
|
||||
? '1px solid #404040'
|
||||
: '1px solid rgba(24, 144, 255, 0.1)',
|
||||
}), [isDarkMode]);
|
||||
|
||||
// Memoize title styles with dark mode support
|
||||
const titleStyle = useMemo(() => ({
|
||||
fontSize: '18px',
|
||||
fontWeight: 600,
|
||||
color: isDarkMode ? '#ffffff !important' : '#262626 !important',
|
||||
marginBottom: '8px',
|
||||
lineHeight: '1.4',
|
||||
}), [isDarkMode]);
|
||||
|
||||
// Memoize decorative element styles with dark mode support
|
||||
const decorativeStyle = useMemo(() => ({
|
||||
position: 'absolute' as const,
|
||||
top: 0,
|
||||
right: 0,
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
background: isDarkMode
|
||||
? 'linear-gradient(135deg, rgba(24, 144, 255, 0.15) 0%, rgba(24, 144, 255, 0.08) 100%)'
|
||||
: 'linear-gradient(135deg, rgba(24, 144, 255, 0.05) 0%, rgba(24, 144, 255, 0.02) 100%)',
|
||||
opacity: isDarkMode ? 0.8 : 0.6,
|
||||
clipPath: 'polygon(100% 0%, 0% 100%, 100% 100%)',
|
||||
}), [isDarkMode]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`overview-stat-card ${isDarkMode ? 'dark-mode' : 'light-mode'}`}
|
||||
style={{
|
||||
backgroundColor: isDarkMode ? '#1f1f1f' : '#ffffff',
|
||||
border: isDarkMode ? '1px solid #303030' : '1px solid #f0f0f0',
|
||||
// Memoize card container styles with dark mode support
|
||||
const cardContainerStyle = useMemo(
|
||||
() => ({
|
||||
width: '100%',
|
||||
borderRadius: '0px',
|
||||
boxShadow: isDarkMode
|
||||
? '0 2px 8px rgba(0, 0, 0, 0.3)'
|
||||
: '0 2px 8px rgba(0, 0, 0, 0.06)',
|
||||
border: isDarkMode ? '1px solid #303030' : '1px solid #f0f0f0',
|
||||
boxShadow: isDarkMode ? '0 2px 8px rgba(0, 0, 0, 0.3)' : '0 2px 8px rgba(0, 0, 0, 0.06)',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
position: 'relative' as const,
|
||||
cursor: 'default',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
backgroundColor: isDarkMode ? '#1f1f1f !important' : '#ffffff !important',
|
||||
}),
|
||||
[isDarkMode]
|
||||
);
|
||||
|
||||
// Memoize icon container styles with dark mode support
|
||||
const iconContainerStyle = useMemo(
|
||||
() => ({
|
||||
padding: '12px',
|
||||
borderRadius: '0px',
|
||||
background: isDarkMode ? '#2a2a2a' : '#f8f9ff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: '64px',
|
||||
minHeight: '64px',
|
||||
boxShadow: isDarkMode
|
||||
? '0 2px 4px rgba(24, 144, 255, 0.2)'
|
||||
: '0 2px 4px rgba(24, 144, 255, 0.1)',
|
||||
border: isDarkMode ? '1px solid #404040' : '1px solid rgba(24, 144, 255, 0.1)',
|
||||
}),
|
||||
[isDarkMode]
|
||||
);
|
||||
|
||||
// Memoize title styles with dark mode support
|
||||
const titleStyle = useMemo(
|
||||
() => ({
|
||||
fontSize: '18px',
|
||||
fontWeight: 600,
|
||||
color: isDarkMode ? '#ffffff !important' : '#262626 !important',
|
||||
marginBottom: '8px',
|
||||
lineHeight: '1.4',
|
||||
}),
|
||||
[isDarkMode]
|
||||
);
|
||||
|
||||
// Memoize decorative element styles with dark mode support
|
||||
const decorativeStyle = useMemo(
|
||||
() => ({
|
||||
position: 'absolute' as const,
|
||||
top: 0,
|
||||
right: 0,
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
background: isDarkMode
|
||||
? 'linear-gradient(135deg, rgba(24, 144, 255, 0.15) 0%, rgba(24, 144, 255, 0.08) 100%)'
|
||||
: 'linear-gradient(135deg, rgba(24, 144, 255, 0.05) 0%, rgba(24, 144, 255, 0.02) 100%)',
|
||||
opacity: isDarkMode ? 0.8 : 0.6,
|
||||
clipPath: 'polygon(100% 0%, 0% 100%, 100% 100%)',
|
||||
}),
|
||||
[isDarkMode]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`overview-stat-card ${isDarkMode ? 'dark-mode' : 'light-mode'}`}
|
||||
style={{
|
||||
backgroundColor: isDarkMode ? '#1f1f1f' : '#ffffff',
|
||||
border: isDarkMode ? '1px solid #303030' : '1px solid #f0f0f0',
|
||||
borderRadius: '0px',
|
||||
boxShadow: isDarkMode ? '0 2px 8px rgba(0, 0, 0, 0.3)' : '0 2px 8px rgba(0, 0, 0, 0.06)',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
cursor: 'default',
|
||||
width: '100%',
|
||||
}}
|
||||
styles={{
|
||||
body: {
|
||||
padding: '24px',
|
||||
backgroundColor: 'transparent',
|
||||
}
|
||||
}}
|
||||
loading={loading}
|
||||
>
|
||||
<Flex gap={20} align="flex-start">
|
||||
<div style={iconContainerStyle}>
|
||||
{icon}
|
||||
</div>
|
||||
<Card
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: '0px',
|
||||
}}
|
||||
styles={{
|
||||
body: {
|
||||
padding: '24px',
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
}}
|
||||
loading={loading}
|
||||
>
|
||||
<Flex gap={20} align="flex-start">
|
||||
<div style={iconContainerStyle}>{icon}</div>
|
||||
|
||||
<Flex vertical gap={8} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography.Text style={titleStyle}>
|
||||
{title}
|
||||
</Typography.Text>
|
||||
<Flex vertical gap={8} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography.Text style={titleStyle}>{title}</Typography.Text>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '6px',
|
||||
marginTop: '4px'
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '6px',
|
||||
marginTop: '4px',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
{/* Decorative element */}
|
||||
<div style={decorativeStyle} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
{/* Decorative element */}
|
||||
<div style={decorativeStyle} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
OverviewStatCard.displayName = 'OverviewStatCard';
|
||||
|
||||
|
||||
@@ -36,142 +36,158 @@ const OverviewStats = () => {
|
||||
getOverviewStats();
|
||||
}, [getOverviewStats]);
|
||||
|
||||
const renderStatText = useCallback((count: number = 0, singularKey: string, pluralKey: string) => {
|
||||
return `${count} ${count === 1 ? t(singularKey) : t(pluralKey)}`;
|
||||
}, [t]);
|
||||
const renderStatText = useCallback(
|
||||
(count: number = 0, singularKey: string, pluralKey: string) => {
|
||||
return `${count} ${count === 1 ? t(singularKey) : t(pluralKey)}`;
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const renderStatCard = useCallback((
|
||||
icon: React.ReactNode,
|
||||
mainCount: number = 0,
|
||||
mainKey: string,
|
||||
stats: { text: string; type?: 'secondary' | 'danger' }[]
|
||||
) => (
|
||||
<OverviewStatCard
|
||||
icon={icon}
|
||||
title={renderStatText(mainCount, mainKey, `${mainKey}Plural`)}
|
||||
loading={loading}
|
||||
>
|
||||
<Flex vertical>
|
||||
{stats.map((stat, index) => (
|
||||
<Typography.Text
|
||||
key={index}
|
||||
type={stat.type}
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.5',
|
||||
color: stat.type === 'danger'
|
||||
? '#ff4d4f'
|
||||
: stat.type === 'secondary'
|
||||
? token.colorTextSecondary
|
||||
: token.colorText
|
||||
}}
|
||||
>
|
||||
{stat.text}
|
||||
</Typography.Text>
|
||||
))}
|
||||
</Flex>
|
||||
</OverviewStatCard>
|
||||
), [renderStatText, loading, token]);
|
||||
const renderStatCard = useCallback(
|
||||
(
|
||||
icon: React.ReactNode,
|
||||
mainCount: number = 0,
|
||||
mainKey: string,
|
||||
stats: { text: string; type?: 'secondary' | 'danger' }[]
|
||||
) => (
|
||||
<OverviewStatCard
|
||||
icon={icon}
|
||||
title={renderStatText(mainCount, mainKey, `${mainKey}Plural`)}
|
||||
loading={loading}
|
||||
>
|
||||
<Flex vertical>
|
||||
{stats.map((stat, index) => (
|
||||
<Typography.Text
|
||||
key={index}
|
||||
type={stat.type}
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.5',
|
||||
color:
|
||||
stat.type === 'danger'
|
||||
? '#ff4d4f'
|
||||
: stat.type === 'secondary'
|
||||
? token.colorTextSecondary
|
||||
: token.colorText,
|
||||
}}
|
||||
>
|
||||
{stat.text}
|
||||
</Typography.Text>
|
||||
))}
|
||||
</Flex>
|
||||
</OverviewStatCard>
|
||||
),
|
||||
[renderStatText, loading, token]
|
||||
);
|
||||
|
||||
// Memoize team stats to prevent unnecessary recalculations
|
||||
const teamStats = useMemo(() => [
|
||||
{
|
||||
text: renderStatText(stats?.teams?.projects, 'projectCount', 'projectCountPlural'),
|
||||
type: 'secondary' as const,
|
||||
},
|
||||
{
|
||||
text: renderStatText(stats?.teams?.members, 'memberCount', 'memberCountPlural'),
|
||||
type: 'secondary' as const,
|
||||
},
|
||||
], [stats?.teams?.projects, stats?.teams?.members, renderStatText]);
|
||||
const teamStats = useMemo(
|
||||
() => [
|
||||
{
|
||||
text: renderStatText(stats?.teams?.projects, 'projectCount', 'projectCountPlural'),
|
||||
type: 'secondary' as const,
|
||||
},
|
||||
{
|
||||
text: renderStatText(stats?.teams?.members, 'memberCount', 'memberCountPlural'),
|
||||
type: 'secondary' as const,
|
||||
},
|
||||
],
|
||||
[stats?.teams?.projects, stats?.teams?.members, renderStatText]
|
||||
);
|
||||
|
||||
// Memoize project stats to prevent unnecessary recalculations
|
||||
const projectStats = useMemo(() => [
|
||||
{
|
||||
text: renderStatText(
|
||||
stats?.projects?.active,
|
||||
'activeProjectCount',
|
||||
'activeProjectCountPlural'
|
||||
),
|
||||
type: 'secondary' as const,
|
||||
},
|
||||
{
|
||||
text: renderStatText(
|
||||
stats?.projects?.overdue,
|
||||
'overdueProjectCount',
|
||||
'overdueProjectCountPlural'
|
||||
),
|
||||
type: 'danger' as const,
|
||||
},
|
||||
], [stats?.projects?.active, stats?.projects?.overdue, renderStatText]);
|
||||
const projectStats = useMemo(
|
||||
() => [
|
||||
{
|
||||
text: renderStatText(
|
||||
stats?.projects?.active,
|
||||
'activeProjectCount',
|
||||
'activeProjectCountPlural'
|
||||
),
|
||||
type: 'secondary' as const,
|
||||
},
|
||||
{
|
||||
text: renderStatText(
|
||||
stats?.projects?.overdue,
|
||||
'overdueProjectCount',
|
||||
'overdueProjectCountPlural'
|
||||
),
|
||||
type: 'danger' as const,
|
||||
},
|
||||
],
|
||||
[stats?.projects?.active, stats?.projects?.overdue, renderStatText]
|
||||
);
|
||||
|
||||
// Memoize member stats to prevent unnecessary recalculations
|
||||
const memberStats = useMemo(() => [
|
||||
{
|
||||
text: renderStatText(
|
||||
stats?.members?.unassigned,
|
||||
'unassignedMemberCount',
|
||||
'unassignedMemberCountPlural'
|
||||
),
|
||||
type: 'secondary' as const,
|
||||
},
|
||||
{
|
||||
text: renderStatText(
|
||||
stats?.members?.overdue,
|
||||
'memberWithOverdueTaskCount',
|
||||
'memberWithOverdueTaskCountPlural'
|
||||
),
|
||||
type: 'danger' as const,
|
||||
},
|
||||
], [stats?.members?.unassigned, stats?.members?.overdue, renderStatText]);
|
||||
const memberStats = useMemo(
|
||||
() => [
|
||||
{
|
||||
text: renderStatText(
|
||||
stats?.members?.unassigned,
|
||||
'unassignedMemberCount',
|
||||
'unassignedMemberCountPlural'
|
||||
),
|
||||
type: 'secondary' as const,
|
||||
},
|
||||
{
|
||||
text: renderStatText(
|
||||
stats?.members?.overdue,
|
||||
'memberWithOverdueTaskCount',
|
||||
'memberWithOverdueTaskCountPlural'
|
||||
),
|
||||
type: 'danger' as const,
|
||||
},
|
||||
],
|
||||
[stats?.members?.unassigned, stats?.members?.overdue, renderStatText]
|
||||
);
|
||||
|
||||
// Memoize icons with enhanced styling for better visibility
|
||||
const teamIcon = useMemo(() => (
|
||||
<BankOutlined style={{
|
||||
color: colors.skyBlue,
|
||||
fontSize: 42,
|
||||
filter: 'drop-shadow(0 2px 4px rgba(24, 144, 255, 0.3))'
|
||||
}} />
|
||||
), []);
|
||||
const teamIcon = useMemo(
|
||||
() => (
|
||||
<BankOutlined
|
||||
style={{
|
||||
color: colors.skyBlue,
|
||||
fontSize: 42,
|
||||
filter: 'drop-shadow(0 2px 4px rgba(24, 144, 255, 0.3))',
|
||||
}}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
const projectIcon = useMemo(() => (
|
||||
<FileOutlined style={{
|
||||
color: colors.limeGreen,
|
||||
fontSize: 42,
|
||||
filter: 'drop-shadow(0 2px 4px rgba(82, 196, 26, 0.3))'
|
||||
}} />
|
||||
), []);
|
||||
const projectIcon = useMemo(
|
||||
() => (
|
||||
<FileOutlined
|
||||
style={{
|
||||
color: colors.limeGreen,
|
||||
fontSize: 42,
|
||||
filter: 'drop-shadow(0 2px 4px rgba(82, 196, 26, 0.3))',
|
||||
}}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
const memberIcon = useMemo(() => (
|
||||
<UsergroupAddOutlined style={{
|
||||
color: colors.lightGray,
|
||||
fontSize: 42,
|
||||
filter: 'drop-shadow(0 2px 4px rgba(112, 112, 112, 0.3))'
|
||||
}} />
|
||||
), []);
|
||||
const memberIcon = useMemo(
|
||||
() => (
|
||||
<UsergroupAddOutlined
|
||||
style={{
|
||||
color: colors.lightGray,
|
||||
fontSize: 42,
|
||||
filter: 'drop-shadow(0 2px 4px rgba(112, 112, 112, 0.3))',
|
||||
}}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex gap={24}>
|
||||
{renderStatCard(
|
||||
teamIcon,
|
||||
stats?.teams?.count,
|
||||
'teamCount',
|
||||
teamStats
|
||||
)}
|
||||
{renderStatCard(teamIcon, stats?.teams?.count, 'teamCount', teamStats)}
|
||||
|
||||
{renderStatCard(
|
||||
projectIcon,
|
||||
stats?.projects?.count,
|
||||
'projectCount',
|
||||
projectStats
|
||||
)}
|
||||
{renderStatCard(projectIcon, stats?.projects?.count, 'projectCount', projectStats)}
|
||||
|
||||
{renderStatCard(
|
||||
memberIcon,
|
||||
stats?.members?.count,
|
||||
'memberCount',
|
||||
memberStats
|
||||
)}
|
||||
{renderStatCard(memberIcon, stats?.members?.count, 'memberCount', memberStats)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -40,50 +40,62 @@ const OverviewReportsTable = memo(() => {
|
||||
getTeams();
|
||||
}, [getTeams]);
|
||||
|
||||
const handleDrawerOpen = useCallback((team: IRPTTeam) => {
|
||||
setSelectedTeam(team);
|
||||
dispatch(toggleOverViewTeamDrawer());
|
||||
}, [dispatch]);
|
||||
const handleDrawerOpen = useCallback(
|
||||
(team: IRPTTeam) => {
|
||||
setSelectedTeam(team);
|
||||
dispatch(toggleOverViewTeamDrawer());
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
// Memoize table columns to prevent recreation on every render
|
||||
const columns: TableColumnsType<IRPTTeam> = useMemo(() => [
|
||||
{
|
||||
key: 'name',
|
||||
title: <CustomTableTitle title={t('nameColumn')} />,
|
||||
className: 'group-hover:text-[#1890ff]',
|
||||
dataIndex: 'name',
|
||||
},
|
||||
{
|
||||
key: 'projects',
|
||||
title: <CustomTableTitle title={t('projectsColumn')} />,
|
||||
className: 'group-hover:text-[#1890ff]',
|
||||
dataIndex: 'projects_count',
|
||||
},
|
||||
{
|
||||
key: 'members',
|
||||
title: <CustomTableTitle title={t('membersColumn')} />,
|
||||
render: (record: IRPTTeam) => <Avatars members={record.members} maxCount={3} />,
|
||||
},
|
||||
], [t]);
|
||||
const columns: TableColumnsType<IRPTTeam> = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'name',
|
||||
title: <CustomTableTitle title={t('nameColumn')} />,
|
||||
className: 'group-hover:text-[#1890ff]',
|
||||
dataIndex: 'name',
|
||||
},
|
||||
{
|
||||
key: 'projects',
|
||||
title: <CustomTableTitle title={t('projectsColumn')} />,
|
||||
className: 'group-hover:text-[#1890ff]',
|
||||
dataIndex: 'projects_count',
|
||||
},
|
||||
{
|
||||
key: 'members',
|
||||
title: <CustomTableTitle title={t('membersColumn')} />,
|
||||
render: (record: IRPTTeam) => <Avatars members={record.members} maxCount={3} />,
|
||||
},
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
// Memoize table configuration
|
||||
const tableConfig = useMemo(() => ({
|
||||
theme: {
|
||||
components: {
|
||||
Table: {
|
||||
cellPaddingBlock: 8,
|
||||
cellPaddingInline: 10,
|
||||
const tableConfig = useMemo(
|
||||
() => ({
|
||||
theme: {
|
||||
components: {
|
||||
Table: {
|
||||
cellPaddingBlock: 8,
|
||||
cellPaddingInline: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}), []);
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
// Memoize row props generator
|
||||
const getRowProps = useCallback((record: IRPTTeam) => ({
|
||||
onClick: () => handleDrawerOpen(record),
|
||||
style: { height: 48, cursor: 'pointer' },
|
||||
className: 'group even:bg-[#4e4e4e10]',
|
||||
}), [handleDrawerOpen]);
|
||||
const getRowProps = useCallback(
|
||||
(record: IRPTTeam) => ({
|
||||
onClick: () => handleDrawerOpen(record),
|
||||
style: { height: 48, cursor: 'pointer' },
|
||||
className: 'group even:bg-[#4e4e4e10]',
|
||||
}),
|
||||
[handleDrawerOpen]
|
||||
);
|
||||
|
||||
return (
|
||||
<ConfigProvider {...tableConfig}>
|
||||
|
||||
@@ -48,7 +48,6 @@ const ProjectCategoriesFilterDropdown = () => {
|
||||
|
||||
// Add filtered categories memo
|
||||
const filteredCategories = useMemo(() => {
|
||||
|
||||
if (!searchQuery.trim()) return orgCategories;
|
||||
|
||||
return orgCategories.filter(category =>
|
||||
@@ -92,22 +91,22 @@ const ProjectCategoriesFilterDropdown = () => {
|
||||
{filteredCategories.length ? (
|
||||
filteredCategories.map(category => (
|
||||
<List.Item
|
||||
className={`custom-list-item ${themeMode === 'dark' ? 'dark' : ''}`}
|
||||
key={category.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-start',
|
||||
gap: 8,
|
||||
padding: '4px 8px',
|
||||
border: 'none',
|
||||
}}
|
||||
className={`custom-list-item ${themeMode === 'dark' ? 'dark' : ''}`}
|
||||
key={category.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-start',
|
||||
gap: 8,
|
||||
padding: '4px 8px',
|
||||
border: 'none',
|
||||
}}
|
||||
>
|
||||
<Checkbox id={category.id} onChange={() => handleCategoryChange(category)}>
|
||||
<Flex gap={8}>
|
||||
<Badge color={category.color_code} />
|
||||
{category.name}
|
||||
</Flex>
|
||||
</Checkbox>
|
||||
<Checkbox id={category.id} onChange={() => handleCategoryChange(category)}>
|
||||
<Flex gap={8}>
|
||||
<Badge color={category.color_code} />
|
||||
{category.name}
|
||||
</Flex>
|
||||
</Checkbox>
|
||||
</List.Item>
|
||||
))
|
||||
) : (
|
||||
@@ -129,10 +128,11 @@ const ProjectCategoriesFilterDropdown = () => {
|
||||
icon={<CaretDownFilled />}
|
||||
iconPosition="end"
|
||||
loading={projectCategoriesLoading}
|
||||
className={`transition-colors duration-300 ${isDropdownOpen
|
||||
className={`transition-colors duration-300 ${
|
||||
isDropdownOpen
|
||||
? 'border-[#1890ff] text-[#1890ff]'
|
||||
: 'hover:text-[#1890ff hover:border-[#1890ff]'
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
{t('categoryText')}
|
||||
</Button>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { fetchProjectHealth } from '@/features/projects/lookups/projectHealth/projectHealthSlice';
|
||||
import { fetchProjectData, setSelectedProjectHealths } from '@/features/reporting/projectReports/project-reports-slice';
|
||||
import {
|
||||
fetchProjectData,
|
||||
setSelectedProjectHealths,
|
||||
} from '@/features/reporting/projectReports/project-reports-slice';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { IProjectHealth } from '@/types/project/projectHealth.types';
|
||||
@@ -23,22 +26,19 @@ const ProjectHealthFilterDropdown = () => {
|
||||
state => state.projectReportsReducer
|
||||
);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedHealths(selectedProjectHealths);
|
||||
}, [selectedProjectHealths]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectHealthsLoading) dispatch(fetchProjectHealth());
|
||||
}, [dispatch]);
|
||||
|
||||
|
||||
const debouncedUpdate = useCallback(
|
||||
debounce((healths: IProjectHealth[]) => {
|
||||
dispatch(setSelectedProjectHealths(healths));
|
||||
dispatch(fetchProjectData());
|
||||
}, 300),
|
||||
dispatch(fetchProjectData());
|
||||
}, 300),
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
@@ -52,8 +52,8 @@ const ProjectHealthFilterDropdown = () => {
|
||||
updatedHealths = [...selectedHealths, health];
|
||||
}
|
||||
|
||||
setSelectedHealths(updatedHealths);
|
||||
debouncedUpdate(updatedHealths);
|
||||
setSelectedHealths(updatedHealths);
|
||||
debouncedUpdate(updatedHealths);
|
||||
};
|
||||
|
||||
const projectHealthDropdownContent = (
|
||||
@@ -75,7 +75,7 @@ const ProjectHealthFilterDropdown = () => {
|
||||
id={item.id}
|
||||
checked={selectedHealths.some(h => h.id === item.id)}
|
||||
onChange={() => handleHealthChange(item)}
|
||||
disabled={projectLoading}
|
||||
disabled={projectLoading}
|
||||
>
|
||||
{item.name}
|
||||
</Checkbox>
|
||||
@@ -96,7 +96,7 @@ const ProjectHealthFilterDropdown = () => {
|
||||
<Button
|
||||
icon={<CaretDownFilled />}
|
||||
iconPosition="end"
|
||||
loading={projectHealthsLoading}
|
||||
loading={projectHealthsLoading}
|
||||
className={`transition-colors duration-300 ${
|
||||
isDropdownOpen
|
||||
? 'border-[#1890ff] text-[#1890ff]'
|
||||
@@ -109,4 +109,4 @@ const ProjectHealthFilterDropdown = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectHealthFilterDropdown;
|
||||
export default ProjectHealthFilterDropdown;
|
||||
|
||||
@@ -43,8 +43,8 @@ const ProjectManagersFilterDropdown = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectManagersLoading) dispatch(fetchProjectManagers());
|
||||
}, [dispatch]);
|
||||
|
||||
}, [dispatch]);
|
||||
|
||||
const projectManagerDropdownContent = (
|
||||
<Card className="custom-card" styles={{ body: { padding: 8, width: 260 } }}>
|
||||
<Flex vertical gap={8}>
|
||||
|
||||
@@ -17,31 +17,40 @@ const ProjectsReportsFilters = () => {
|
||||
const { searchQuery } = useAppSelector(state => state.projectReportsReducer);
|
||||
|
||||
// Memoize the search query handler to prevent recreation on every render
|
||||
const handleSearchQueryChange = useCallback((text: string) => {
|
||||
dispatch(setSearchQuery(text));
|
||||
}, [dispatch]);
|
||||
const handleSearchQueryChange = useCallback(
|
||||
(text: string) => {
|
||||
dispatch(setSearchQuery(text));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
// Memoize the filter dropdowns container to prevent recreation on every render
|
||||
const filterDropdowns = useMemo(() => (
|
||||
<Flex gap={8} wrap={'wrap'}>
|
||||
<ProjectStatusFilterDropdown />
|
||||
<ProjectHealthFilterDropdown />
|
||||
<ProjectCategoriesFilterDropdown />
|
||||
<ProjectManagersFilterDropdown />
|
||||
</Flex>
|
||||
), []);
|
||||
const filterDropdowns = useMemo(
|
||||
() => (
|
||||
<Flex gap={8} wrap={'wrap'}>
|
||||
<ProjectStatusFilterDropdown />
|
||||
<ProjectHealthFilterDropdown />
|
||||
<ProjectCategoriesFilterDropdown />
|
||||
<ProjectManagersFilterDropdown />
|
||||
</Flex>
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
// Memoize the right side controls to prevent recreation on every render
|
||||
const rightControls = useMemo(() => (
|
||||
<Flex gap={12}>
|
||||
<ProjectTableShowFieldsDropdown />
|
||||
<CustomSearchbar
|
||||
placeholderText={t('searchByNamePlaceholder')}
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={handleSearchQueryChange}
|
||||
/>
|
||||
</Flex>
|
||||
), [t, searchQuery, handleSearchQueryChange]);
|
||||
const rightControls = useMemo(
|
||||
() => (
|
||||
<Flex gap={12}>
|
||||
<ProjectTableShowFieldsDropdown />
|
||||
<CustomSearchbar
|
||||
placeholderText={t('searchByNamePlaceholder')}
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={handleSearchQueryChange}
|
||||
/>
|
||||
</Flex>
|
||||
),
|
||||
[t, searchQuery, handleSearchQueryChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex gap={8} align="center" justify="space-between">
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { fetchProjectStatuses } from '@/features/projects/lookups/projectStatuses/projectStatusesSlice';
|
||||
import { fetchProjectData, setSelectedProjectStatuses } from '@/features/reporting/projectReports/project-reports-slice';
|
||||
import {
|
||||
fetchProjectData,
|
||||
setSelectedProjectStatuses,
|
||||
} from '@/features/reporting/projectReports/project-reports-slice';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { IProjectStatus } from '@/types/project/projectStatus.types';
|
||||
@@ -30,7 +33,7 @@ const ProjectStatusFilterDropdown = () => {
|
||||
const debouncedUpdate = useCallback(
|
||||
debounce((statuses: IProjectStatus[]) => {
|
||||
dispatch(setSelectedProjectStatuses(statuses));
|
||||
dispatch(fetchProjectData());
|
||||
dispatch(fetchProjectData());
|
||||
}, 300),
|
||||
[dispatch]
|
||||
);
|
||||
@@ -45,7 +48,7 @@ const ProjectStatusFilterDropdown = () => {
|
||||
updatedStatuses = [...selectedStatuses, status];
|
||||
}
|
||||
|
||||
setSelectedStatuses(updatedStatuses);
|
||||
setSelectedStatuses(updatedStatuses);
|
||||
debouncedUpdate(updatedStatuses);
|
||||
};
|
||||
|
||||
@@ -65,11 +68,7 @@ const ProjectStatusFilterDropdown = () => {
|
||||
}}
|
||||
>
|
||||
<Space>
|
||||
<Checkbox
|
||||
id={item.id}
|
||||
key={item.id}
|
||||
onChange={e => handleProjectStatusClick(item)}
|
||||
>
|
||||
<Checkbox id={item.id} key={item.id} onChange={e => handleProjectStatusClick(item)}>
|
||||
{item.name}
|
||||
</Checkbox>
|
||||
</Space>
|
||||
|
||||
@@ -35,11 +35,7 @@ const ProjectTableShowFieldsDropdown = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
menu={menuItems}
|
||||
trigger={['click']}
|
||||
onOpenChange={open => setIsDropdownOpen(open)}
|
||||
>
|
||||
<Dropdown menu={menuItems} trigger={['click']} onOpenChange={open => setIsDropdownOpen(open)}>
|
||||
<Button
|
||||
icon={<MoreOutlined />}
|
||||
className={`transition-colors duration-300 ${
|
||||
|
||||
@@ -64,10 +64,13 @@ const ProjectsReportsTable = () => {
|
||||
const columnsVisibility = useAppSelector(state => state.projectReportsTableColumnsReducer);
|
||||
|
||||
// Memoize the drawer open handler to prevent recreation on every render
|
||||
const handleDrawerOpen = useCallback((record: IRPTProject) => {
|
||||
setSelectedProject(record);
|
||||
dispatch(toggleProjectReportsDrawer());
|
||||
}, [dispatch]);
|
||||
const handleDrawerOpen = useCallback(
|
||||
(record: IRPTProject) => {
|
||||
setSelectedProject(record);
|
||||
dispatch(toggleProjectReportsDrawer());
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const columns: TableColumnsType<IRPTProject> = useMemo(
|
||||
() => [
|
||||
@@ -242,12 +245,15 @@ const ProjectsReportsTable = () => {
|
||||
);
|
||||
|
||||
// Memoize the table change handler to prevent recreation on every render
|
||||
const handleTableChange = useCallback((pagination: PaginationProps, filters: any, sorter: any) => {
|
||||
if (sorter.order) dispatch(setOrder(sorter.order));
|
||||
if (sorter.field) dispatch(setField(sorter.field));
|
||||
dispatch(setIndex(pagination.current));
|
||||
dispatch(setPageSize(pagination.pageSize));
|
||||
}, [dispatch]);
|
||||
const handleTableChange = useCallback(
|
||||
(pagination: PaginationProps, filters: any, sorter: any) => {
|
||||
if (sorter.order) dispatch(setOrder(sorter.order));
|
||||
if (sorter.field) dispatch(setField(sorter.field));
|
||||
dispatch(setIndex(pagination.current));
|
||||
dispatch(setPageSize(pagination.pageSize));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading) dispatch(fetchProjectData());
|
||||
@@ -295,13 +301,16 @@ const ProjectsReportsTable = () => {
|
||||
);
|
||||
|
||||
// Memoize pagination configuration to prevent recreation on every render
|
||||
const paginationConfig = useMemo(() => ({
|
||||
showSizeChanger: true,
|
||||
defaultPageSize: 10,
|
||||
total: total,
|
||||
current: index,
|
||||
pageSizeOptions: PAGE_SIZE_OPTIONS,
|
||||
}), [total, index]);
|
||||
const paginationConfig = useMemo(
|
||||
() => ({
|
||||
showSizeChanger: true,
|
||||
defaultPageSize: 10,
|
||||
total: total,
|
||||
current: index,
|
||||
pageSizeOptions: PAGE_SIZE_OPTIONS,
|
||||
}),
|
||||
[total, index]
|
||||
);
|
||||
|
||||
// Memoize scroll configuration to prevent recreation on every render
|
||||
const scrollConfig = useMemo(() => ({ x: 'max-content' }), []);
|
||||
|
||||
@@ -58,20 +58,20 @@ const ProjectCategoryCell = ({ id, name, color_code, projectId }: ProjectCategor
|
||||
</Typography.Text>
|
||||
),
|
||||
}));
|
||||
|
||||
|
||||
// handle category select
|
||||
const onClick: MenuProps['onClick'] = e => {
|
||||
const newCategory = filteredCategoriesData.find(category => category.id === e.key);
|
||||
if (newCategory && connected && socket) {
|
||||
// Update local state immediately
|
||||
setSelectedCategory(newCategory);
|
||||
|
||||
|
||||
// Emit socket event
|
||||
socket.emit(
|
||||
SocketEvents.PROJECT_CATEGORY_CHANGE.toString(),
|
||||
JSON.stringify({
|
||||
project_id: projectId,
|
||||
category_id: newCategory.id
|
||||
category_id: newCategory.id,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -133,12 +133,14 @@ const ProjectCategoryCell = ({ id, name, color_code, projectId }: ProjectCategor
|
||||
if (parsedData && parsedData.project_id === projectId) {
|
||||
// Update local state
|
||||
setSelectedCategory(parsedData.category);
|
||||
|
||||
|
||||
// Update redux store
|
||||
dispatch(updateProjectCategory({
|
||||
projectId: parsedData.project_id,
|
||||
category: parsedData.category
|
||||
}));
|
||||
dispatch(
|
||||
updateProjectCategory({
|
||||
projectId: parsedData.project_id,
|
||||
category: parsedData.category,
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error handling category change response:', error);
|
||||
@@ -200,7 +202,7 @@ const ProjectCategoryCell = ({ id, name, color_code, projectId }: ProjectCategor
|
||||
// Action creator for updating project category
|
||||
const updateProjectCategory = (payload: { projectId: string; category: IProjectCategory }) => ({
|
||||
type: 'projects/updateCategory',
|
||||
payload
|
||||
payload,
|
||||
});
|
||||
|
||||
export default ProjectCategoryCell;
|
||||
|
||||
@@ -5,7 +5,10 @@ import dayjs, { Dayjs } from 'dayjs';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { setProjectEndDate, setProjectStartDate } from '@/features/reporting/projectReports/project-reports-slice';
|
||||
import {
|
||||
setProjectEndDate,
|
||||
setProjectStartDate,
|
||||
} from '@/features/reporting/projectReports/project-reports-slice';
|
||||
import logger from '@/utils/errorLogger';
|
||||
|
||||
type ProjectDatesCellProps = {
|
||||
@@ -41,10 +44,13 @@ const ProjectDatesCell = ({ projectId, startDate, endDate }: ProjectDatesCellPro
|
||||
if (!socket) {
|
||||
throw new Error('Socket connection not available');
|
||||
}
|
||||
socket.emit(SocketEvents.PROJECT_START_DATE_CHANGE.toString(), JSON.stringify({
|
||||
project_id: projectId,
|
||||
start_date: date?.format('YYYY-MM-DD'),
|
||||
}));
|
||||
socket.emit(
|
||||
SocketEvents.PROJECT_START_DATE_CHANGE.toString(),
|
||||
JSON.stringify({
|
||||
project_id: projectId,
|
||||
start_date: date?.format('YYYY-MM-DD'),
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Error sending start date change:', error);
|
||||
}
|
||||
@@ -55,10 +61,13 @@ const ProjectDatesCell = ({ projectId, startDate, endDate }: ProjectDatesCellPro
|
||||
if (!socket) {
|
||||
throw new Error('Socket connection not available');
|
||||
}
|
||||
socket.emit(SocketEvents.PROJECT_END_DATE_CHANGE.toString(), JSON.stringify({
|
||||
project_id: projectId,
|
||||
end_date: date?.format('YYYY-MM-DD'),
|
||||
}));
|
||||
socket.emit(
|
||||
SocketEvents.PROJECT_END_DATE_CHANGE.toString(),
|
||||
JSON.stringify({
|
||||
project_id: projectId,
|
||||
end_date: date?.format('YYYY-MM-DD'),
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Error sending end date change:', error);
|
||||
}
|
||||
@@ -70,8 +79,14 @@ const ProjectDatesCell = ({ projectId, startDate, endDate }: ProjectDatesCellPro
|
||||
socket.on(SocketEvents.PROJECT_END_DATE_CHANGE.toString(), handleEndDateChangeResponse);
|
||||
|
||||
return () => {
|
||||
socket.removeListener(SocketEvents.PROJECT_START_DATE_CHANGE.toString(), handleStartDateChangeResponse);
|
||||
socket.removeListener(SocketEvents.PROJECT_END_DATE_CHANGE.toString(), handleEndDateChangeResponse);
|
||||
socket.removeListener(
|
||||
SocketEvents.PROJECT_START_DATE_CHANGE.toString(),
|
||||
handleStartDateChangeResponse
|
||||
);
|
||||
socket.removeListener(
|
||||
SocketEvents.PROJECT_END_DATE_CHANGE.toString(),
|
||||
handleEndDateChangeResponse
|
||||
);
|
||||
};
|
||||
}
|
||||
}, [connected, socket]);
|
||||
|
||||
@@ -47,10 +47,13 @@ const ProjectHealthCell = ({ value, label, color, projectId }: HealthStatusDataT
|
||||
const onClick: MenuProps['onClick'] = e => {
|
||||
if (!e.key || !projectId) return;
|
||||
|
||||
socket?.emit(SocketEvents.PROJECT_HEALTH_CHANGE.toString(), JSON.stringify({
|
||||
project_id: projectId,
|
||||
health_id: e.key,
|
||||
}));
|
||||
socket?.emit(
|
||||
SocketEvents.PROJECT_HEALTH_CHANGE.toString(),
|
||||
JSON.stringify({
|
||||
project_id: projectId,
|
||||
health_id: e.key,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
// dropdown items
|
||||
|
||||
@@ -35,7 +35,7 @@ const ProjectStatusCell = ({ currentStatus, projectId }: ProjectStatusCellProps)
|
||||
{getStatusIcon(status.icon || '', status.color_code || '')}
|
||||
{t(`${status.name}`)}
|
||||
</Typography.Text>
|
||||
)
|
||||
),
|
||||
}));
|
||||
|
||||
const handleStatusChange = (value: string) => {
|
||||
|
||||
@@ -12,7 +12,7 @@ const ProjectUpdateCell = ({ updates }: ProjectUpdateCellProps) => {
|
||||
ellipsis={{ expanded: false }}
|
||||
className="group-hover:text-[#1890ff]"
|
||||
>
|
||||
<div dangerouslySetInnerHTML={{__html: updates}} />
|
||||
<div dangerouslySetInnerHTML={{ __html: updates }} />
|
||||
</Typography.Text>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -39,36 +39,37 @@ const ProjectsReports = () => {
|
||||
}, [dispatch, archived]);
|
||||
|
||||
// Memoize the dropdown menu items to prevent recreation on every render
|
||||
const dropdownMenuItems = useMemo(() => [
|
||||
{ key: '1', label: t('excelButton'), onClick: handleExcelExport }
|
||||
], [t, handleExcelExport]);
|
||||
const dropdownMenuItems = useMemo(
|
||||
() => [{ key: '1', label: t('excelButton'), onClick: handleExcelExport }],
|
||||
[t, handleExcelExport]
|
||||
);
|
||||
|
||||
// Memoize the header children to prevent recreation on every render
|
||||
const headerChildren = useMemo(() => (
|
||||
<Space>
|
||||
<Button>
|
||||
<Checkbox checked={archived} onChange={handleArchivedChange}>
|
||||
<Typography.Text>{t('includeArchivedButton')}</Typography.Text>
|
||||
</Checkbox>
|
||||
</Button>
|
||||
|
||||
<Dropdown menu={{ items: dropdownMenuItems }}>
|
||||
<Button type="primary" icon={<DownOutlined />} iconPosition="end">
|
||||
{t('exportButton')}
|
||||
const headerChildren = useMemo(
|
||||
() => (
|
||||
<Space>
|
||||
<Button>
|
||||
<Checkbox checked={archived} onChange={handleArchivedChange}>
|
||||
<Typography.Text>{t('includeArchivedButton')}</Typography.Text>
|
||||
</Checkbox>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
), [archived, handleArchivedChange, t, dropdownMenuItems]);
|
||||
|
||||
<Dropdown menu={{ items: dropdownMenuItems }}>
|
||||
<Button type="primary" icon={<DownOutlined />} iconPosition="end">
|
||||
{t('exportButton')}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
),
|
||||
[archived, handleArchivedChange, t, dropdownMenuItems]
|
||||
);
|
||||
|
||||
// Memoize the card title to prevent recreation on every render
|
||||
const cardTitle = useMemo(() => <ProjectsReportsFilters />, []);
|
||||
|
||||
return (
|
||||
<Flex vertical>
|
||||
<CustomPageHeader
|
||||
title={pageTitle}
|
||||
children={headerChildren}
|
||||
/>
|
||||
<CustomPageHeader title={pageTitle} children={headerChildren} />
|
||||
|
||||
<Card title={cardTitle}>
|
||||
<ProjectReportsTable />
|
||||
|
||||
@@ -40,7 +40,6 @@ const ReportingCollapsedButton = ({
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
getOrganizationDetails();
|
||||
}, []);
|
||||
@@ -66,7 +65,7 @@ const ReportingCollapsedButton = ({
|
||||
/>
|
||||
|
||||
<Typography.Text strong>
|
||||
{loading ? 'Loading...' : organization?.name || 'Unknown Organization'}
|
||||
{loading ? 'Loading...' : organization?.name || 'Unknown Organization'}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
</Tooltip>
|
||||
|
||||
@@ -19,15 +19,23 @@ import { reportingTimesheetApiService } from '@/api/reporting/reporting.timeshee
|
||||
|
||||
// Project color palette
|
||||
const PROJECT_COLORS = [
|
||||
'#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEEAD',
|
||||
'#D4A5A5', '#9B59B6', '#3498DB', '#F1C40F', '#1ABC9C'
|
||||
'#FF6B6B',
|
||||
'#4ECDC4',
|
||||
'#45B7D1',
|
||||
'#96CEB4',
|
||||
'#FFEEAD',
|
||||
'#D4A5A5',
|
||||
'#9B59B6',
|
||||
'#3498DB',
|
||||
'#F1C40F',
|
||||
'#1ABC9C',
|
||||
];
|
||||
|
||||
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend, ChartDataLabels);
|
||||
|
||||
enum IToggleOptions {
|
||||
'WORKING_DAYS',
|
||||
'MAN_DAYS'
|
||||
'MAN_DAYS',
|
||||
}
|
||||
|
||||
export interface EstimatedVsActualTimeSheetRef {
|
||||
@@ -38,18 +46,21 @@ interface IEstimatedVsActualTimeSheetProps {
|
||||
type: string;
|
||||
}
|
||||
|
||||
const EstimatedVsActualTimeSheet = forwardRef<EstimatedVsActualTimeSheetRef, IEstimatedVsActualTimeSheetProps>(({ type }, ref) => {
|
||||
const EstimatedVsActualTimeSheet = forwardRef<
|
||||
EstimatedVsActualTimeSheetRef,
|
||||
IEstimatedVsActualTimeSheetProps
|
||||
>(({ type }, ref) => {
|
||||
const chartRef = useRef<any>(null);
|
||||
|
||||
|
||||
// State for filters and data
|
||||
const [jsonData, setJsonData] = useState<IRPTTimeProject[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [chartHeight, setChartHeight] = useState(600);
|
||||
const [chartWidth, setChartWidth] = useState(1080);
|
||||
|
||||
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
|
||||
const {
|
||||
teams,
|
||||
loadingTeams,
|
||||
@@ -61,28 +72,29 @@ const EstimatedVsActualTimeSheet = forwardRef<EstimatedVsActualTimeSheetRef, IEs
|
||||
billable,
|
||||
archived,
|
||||
} = useAppSelector(state => state.timeReportsOverviewReducer);
|
||||
const {
|
||||
duration,
|
||||
dateRange,
|
||||
} = useAppSelector(state => state.reportingReducer);
|
||||
const { duration, dateRange } = useAppSelector(state => state.reportingReducer);
|
||||
|
||||
// Add type checking before mapping
|
||||
const labels = Array.isArray(jsonData) ? jsonData.map(item => item.name) : [];
|
||||
const actualDays = Array.isArray(jsonData) ? jsonData.map(item => {
|
||||
const value = item.value ? parseFloat(item.value) : 0;
|
||||
return (isNaN(value) ? 0 : value).toString();
|
||||
}) : [];
|
||||
const estimatedDays = Array.isArray(jsonData) ? jsonData.map(item => {
|
||||
const value = item.estimated_value ? parseFloat(item.estimated_value) : 0;
|
||||
return (isNaN(value) ? 0 : value).toString();
|
||||
}) : [];
|
||||
const actualDays = Array.isArray(jsonData)
|
||||
? jsonData.map(item => {
|
||||
const value = item.value ? parseFloat(item.value) : 0;
|
||||
return (isNaN(value) ? 0 : value).toString();
|
||||
})
|
||||
: [];
|
||||
const estimatedDays = Array.isArray(jsonData)
|
||||
? jsonData.map(item => {
|
||||
const value = item.estimated_value ? parseFloat(item.estimated_value) : 0;
|
||||
return (isNaN(value) ? 0 : value).toString();
|
||||
})
|
||||
: [];
|
||||
|
||||
// Format date helper
|
||||
const formatDate = (date: Date): string => {
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
@@ -126,7 +138,7 @@ const EstimatedVsActualTimeSheet = forwardRef<EstimatedVsActualTimeSheetRef, IEs
|
||||
}
|
||||
return '';
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
datalabels: {
|
||||
color: 'white',
|
||||
@@ -190,14 +202,14 @@ const EstimatedVsActualTimeSheet = forwardRef<EstimatedVsActualTimeSheetRef, IEs
|
||||
projects: selectedProjects.map(p => p.id),
|
||||
duration: duration,
|
||||
date_range: dateRange,
|
||||
billable
|
||||
billable,
|
||||
};
|
||||
const res = await reportingTimesheetApiService.getProjectEstimatedVsActual(body, archived);
|
||||
if (res.done) {
|
||||
// Ensure res.body is an array before setting it
|
||||
const dataArray = Array.isArray(res.body) ? res.body : [];
|
||||
setJsonData(dataArray);
|
||||
|
||||
|
||||
// Update chart dimensions based on data
|
||||
if (dataArray.length) {
|
||||
const containerWidth = window.innerWidth - 300;
|
||||
@@ -224,14 +236,14 @@ const EstimatedVsActualTimeSheet = forwardRef<EstimatedVsActualTimeSheetRef, IEs
|
||||
billable,
|
||||
archived,
|
||||
type,
|
||||
noCategory
|
||||
noCategory,
|
||||
]);
|
||||
|
||||
const exportChart = () => {
|
||||
if (chartRef.current) {
|
||||
// Get the canvas element
|
||||
const canvas = chartRef.current.canvas;
|
||||
|
||||
|
||||
// Create a temporary canvas to draw with background
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
const tempCtx = tempCanvas.getContext('2d');
|
||||
@@ -262,19 +274,20 @@ const EstimatedVsActualTimeSheet = forwardRef<EstimatedVsActualTimeSheetRef, IEs
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
exportChart
|
||||
exportChart,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
|
||||
{/* Outer container with fixed width */}
|
||||
<div style={{
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
overflowX: 'auto',
|
||||
overflowY: 'hidden'
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
overflowX: 'auto',
|
||||
overflowY: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Chart container */}
|
||||
<div
|
||||
style={{
|
||||
@@ -283,12 +296,12 @@ const EstimatedVsActualTimeSheet = forwardRef<EstimatedVsActualTimeSheetRef, IEs
|
||||
minWidth: 'max-content',
|
||||
}}
|
||||
>
|
||||
<Bar
|
||||
<Bar
|
||||
ref={chartRef}
|
||||
data={data}
|
||||
options={options}
|
||||
width={chartWidth}
|
||||
height={chartHeight}
|
||||
data={data}
|
||||
options={options}
|
||||
width={chartWidth}
|
||||
height={chartHeight}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -44,10 +44,12 @@ const MembersTimeSheet = forwardRef<MembersTimeSheetRef>((_, ref) => {
|
||||
const [jsonData, setJsonData] = useState<IRPTTimeMember[]>([]);
|
||||
|
||||
const labels = Array.isArray(jsonData) ? jsonData.map(item => item.name) : [];
|
||||
const dataValues = Array.isArray(jsonData) ? jsonData.map(item => {
|
||||
const loggedTimeInHours = parseFloat(item.logged_time || '0') / 3600;
|
||||
return loggedTimeInHours.toFixed(2);
|
||||
}) : [];
|
||||
const dataValues = Array.isArray(jsonData)
|
||||
? jsonData.map(item => {
|
||||
const loggedTimeInHours = parseFloat(item.logged_time || '0') / 3600;
|
||||
return loggedTimeInHours.toFixed(2);
|
||||
})
|
||||
: [];
|
||||
const colors = Array.isArray(jsonData) ? jsonData.map(item => item.color_code) : [];
|
||||
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
@@ -83,7 +85,7 @@ const MembersTimeSheet = forwardRef<MembersTimeSheetRef>((_, ref) => {
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context: any) {
|
||||
label: function (context: any) {
|
||||
const idx = context.dataIndex;
|
||||
const member = jsonData[idx];
|
||||
const hours = member?.utilized_hours || '0.00';
|
||||
@@ -92,11 +94,11 @@ const MembersTimeSheet = forwardRef<MembersTimeSheetRef>((_, ref) => {
|
||||
return [
|
||||
`${context.dataset.label}: ${hours} h`,
|
||||
`Utilization: ${percent}%`,
|
||||
`Over/Under Utilized: ${overUnder} h`
|
||||
`Over/Under Utilized: ${overUnder} h`,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
backgroundColor: 'black',
|
||||
indexAxis: 'y' as const,
|
||||
@@ -136,7 +138,7 @@ const MembersTimeSheet = forwardRef<MembersTimeSheetRef>((_, ref) => {
|
||||
const fetchChartData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
|
||||
const selectedTeams = teams.filter(team => team.selected);
|
||||
const selectedProjects = filterProjects.filter(project => project.selected);
|
||||
const selectedCategories = categories.filter(category => category.selected);
|
||||
@@ -169,7 +171,7 @@ const MembersTimeSheet = forwardRef<MembersTimeSheetRef>((_, ref) => {
|
||||
if (chartRef.current) {
|
||||
// Get the canvas element
|
||||
const canvas = chartRef.current.canvas;
|
||||
|
||||
|
||||
// Create a temporary canvas to draw with background
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
const tempCtx = tempCanvas.getContext('2d');
|
||||
@@ -195,7 +197,7 @@ const MembersTimeSheet = forwardRef<MembersTimeSheetRef>((_, ref) => {
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
exportChart
|
||||
exportChart,
|
||||
}));
|
||||
|
||||
return (
|
||||
|
||||
@@ -11,9 +11,7 @@ import {
|
||||
} from 'chart.js';
|
||||
import ChartDataLabels from 'chartjs-plugin-datalabels';
|
||||
import { useAppDispatch } from '../../../../hooks/useAppDispatch';
|
||||
import {
|
||||
setLabelAndToggleDrawer,
|
||||
} from '../../../../features/timeReport/projects/timeLogSlice';
|
||||
import { setLabelAndToggleDrawer } from '../../../../features/timeReport/projects/timeLogSlice';
|
||||
import ProjectTimeLogDrawer from '../../../../features/timeReport/projects/ProjectTimeLogDrawer';
|
||||
import { useAppSelector } from '../../../../hooks/useAppSelector';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -65,16 +63,22 @@ const ProjectTimeSheetChart = forwardRef<ProjectTimeSheetChartRef>((_, ref) => {
|
||||
|
||||
const data = {
|
||||
labels: Array.isArray(jsonData) ? jsonData.map(item => item?.name || '') : [],
|
||||
datasets: [{
|
||||
label: t('loggedTime'),
|
||||
data: Array.isArray(jsonData) ? jsonData.map(item => {
|
||||
const loggedTime = item?.logged_time || '0';
|
||||
const loggedTimeInHours = parseFloat(loggedTime) / 3600;
|
||||
return loggedTimeInHours.toFixed(2);
|
||||
}) : [],
|
||||
backgroundColor: Array.isArray(jsonData) ? jsonData.map(item => item?.color_code || '#000000') : [],
|
||||
barThickness: BAR_THICKNESS,
|
||||
}],
|
||||
datasets: [
|
||||
{
|
||||
label: t('loggedTime'),
|
||||
data: Array.isArray(jsonData)
|
||||
? jsonData.map(item => {
|
||||
const loggedTime = item?.logged_time || '0';
|
||||
const loggedTimeInHours = parseFloat(loggedTime) / 3600;
|
||||
return loggedTimeInHours.toFixed(2);
|
||||
})
|
||||
: [],
|
||||
backgroundColor: Array.isArray(jsonData)
|
||||
? jsonData.map(item => item?.color_code || '#000000')
|
||||
: [],
|
||||
barThickness: BAR_THICKNESS,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const options = {
|
||||
@@ -170,14 +174,14 @@ const ProjectTimeSheetChart = forwardRef<ProjectTimeSheetChartRef>((_, ref) => {
|
||||
archived,
|
||||
loadingTeams,
|
||||
loadingProjects,
|
||||
loadingCategories
|
||||
loadingCategories,
|
||||
]);
|
||||
|
||||
const exportChart = () => {
|
||||
if (chartRef.current) {
|
||||
// Get the canvas element
|
||||
const canvas = chartRef.current.canvas;
|
||||
|
||||
|
||||
// Create a temporary canvas to draw with background
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
const tempCtx = tempCanvas.getContext('2d');
|
||||
@@ -203,7 +207,7 @@ const ProjectTimeSheetChart = forwardRef<ProjectTimeSheetChartRef>((_, ref) => {
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
exportChart
|
||||
exportChart,
|
||||
}));
|
||||
|
||||
// if (loading) {
|
||||
@@ -224,11 +228,7 @@ const ProjectTimeSheetChart = forwardRef<ProjectTimeSheetChartRef>((_, ref) => {
|
||||
height: `${60 * data.labels.length}px`,
|
||||
}}
|
||||
>
|
||||
<Bar
|
||||
data={data}
|
||||
options={options}
|
||||
ref={chartRef}
|
||||
/>
|
||||
<Bar data={data} options={options} ref={chartRef} />
|
||||
</div>
|
||||
<ProjectTimeLogDrawer />
|
||||
</div>
|
||||
|
||||
@@ -51,7 +51,8 @@
|
||||
border-left: none !important;
|
||||
}
|
||||
|
||||
.member-name, .total-time {
|
||||
.member-name,
|
||||
.total-time {
|
||||
border-bottom: 2px solid var(--border-color, rgba(0, 0, 0, 0.06));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Card, Flex, Segmented } from 'antd';
|
||||
import TimeReportPageHeader from '@/pages/reporting/timeReports/page-header/time-report-page-header';
|
||||
import EstimatedVsActualTimeSheet, { EstimatedVsActualTimeSheetRef } from '@/pages/reporting/time-reports/estimated-vs-actual-time-sheet/estimated-vs-actual-time-sheet';
|
||||
import EstimatedVsActualTimeSheet, {
|
||||
EstimatedVsActualTimeSheetRef,
|
||||
} from '@/pages/reporting/time-reports/estimated-vs-actual-time-sheet/estimated-vs-actual-time-sheet';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
||||
import TimeReportingRightHeader from './timeReportingRightHeader/TimeReportingRightHeader';
|
||||
@@ -23,9 +25,7 @@ const EstimatedVsActualTimeReports = () => {
|
||||
<Flex vertical>
|
||||
<TimeReportingRightHeader
|
||||
title={t('estimatedVsActual')}
|
||||
exportType={[
|
||||
{ key: 'png', label: 'PNG' },
|
||||
]}
|
||||
exportType={[{ key: 'png', label: 'PNG' }]}
|
||||
export={handleExport}
|
||||
/>
|
||||
|
||||
@@ -42,13 +42,16 @@ const EstimatedVsActualTimeReports = () => {
|
||||
<TimeReportPageHeader />
|
||||
<Segmented
|
||||
style={{ fontWeight: 500 }}
|
||||
options={[{
|
||||
label: t('workingDays'),
|
||||
value: 'WORKING_DAYS',
|
||||
}, {
|
||||
label: t('manDays'),
|
||||
value: 'MAN_DAYS',
|
||||
}]}
|
||||
options={[
|
||||
{
|
||||
label: t('workingDays'),
|
||||
value: 'WORKING_DAYS',
|
||||
},
|
||||
{
|
||||
label: t('manDays'),
|
||||
value: 'MAN_DAYS',
|
||||
},
|
||||
]}
|
||||
onChange={value => setType(value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Card, Flex } from 'antd';
|
||||
import TimeReportPageHeader from '@/pages/reporting/timeReports/page-header/time-report-page-header';
|
||||
import MembersTimeSheet, { MembersTimeSheetRef } from '@/pages/reporting/time-reports/members-time-sheet/members-time-sheet';
|
||||
import MembersTimeSheet, {
|
||||
MembersTimeSheetRef,
|
||||
} from '@/pages/reporting/time-reports/members-time-sheet/members-time-sheet';
|
||||
import TimeReportingRightHeader from './timeReportingRightHeader/TimeReportingRightHeader';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { fetchReportingProjects, setNoCategory, setSelectOrDeselectAllCategories, setSelectOrDeselectCategory } from '@/features/reporting/time-reports/time-reports-overview.slice';
|
||||
import {
|
||||
fetchReportingProjects,
|
||||
setNoCategory,
|
||||
setSelectOrDeselectAllCategories,
|
||||
setSelectOrDeselectCategory,
|
||||
} from '@/features/reporting/time-reports/time-reports-overview.slice';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { CaretDownFilled } from '@ant-design/icons';
|
||||
@@ -36,7 +41,6 @@ const Categories: React.FC = () => {
|
||||
await dispatch(setNoCategory(isChecked));
|
||||
await dispatch(setSelectOrDeselectAllCategories(isChecked));
|
||||
await dispatch(fetchReportingProjects());
|
||||
|
||||
};
|
||||
|
||||
const handleNoCategoryChange = async (checked: boolean) => {
|
||||
@@ -51,15 +55,17 @@ const Categories: React.FC = () => {
|
||||
placement="bottomLeft"
|
||||
trigger={['click']}
|
||||
dropdownRender={() => (
|
||||
<div style={{
|
||||
background: token.colorBgContainer,
|
||||
borderRadius: token.borderRadius,
|
||||
boxShadow: token.boxShadow,
|
||||
padding: '4px 0',
|
||||
maxHeight: '330px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
background: token.colorBgContainer,
|
||||
borderRadius: token.borderRadius,
|
||||
boxShadow: token.boxShadow,
|
||||
padding: '4px 0',
|
||||
maxHeight: '330px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: '8px', flexShrink: 0 }}>
|
||||
<Input
|
||||
onClick={e => e.stopPropagation()}
|
||||
@@ -89,17 +95,19 @@ const Categories: React.FC = () => {
|
||||
</Checkbox>
|
||||
</div>
|
||||
<Divider style={{ margin: '4px 0', flexShrink: 0 }} />
|
||||
<div style={{
|
||||
overflowY: 'auto',
|
||||
flex: 1
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
overflowY: 'auto',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{filteredItems.length > 0 ? (
|
||||
filteredItems.map(item => (
|
||||
<div
|
||||
<div
|
||||
key={item.id}
|
||||
style={{
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer'
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
@@ -112,9 +120,7 @@ const Categories: React.FC = () => {
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div style={{ padding: '8px 12px' }}>
|
||||
{t('noCategories')}
|
||||
</div>
|
||||
<div style={{ padding: '8px 12px' }}>{t('noCategories')}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,32 @@
|
||||
import { setSelectOrDeselectAllProjects, setSelectOrDeselectProject } from '@/features/reporting/time-reports/time-reports-overview.slice';
|
||||
import {
|
||||
setSelectOrDeselectAllProjects,
|
||||
setSelectOrDeselectProject,
|
||||
} from '@/features/reporting/time-reports/time-reports-overview.slice';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { CaretDownFilled, SearchOutlined, ClearOutlined, DownOutlined, RightOutlined, FilterOutlined } from '@ant-design/icons';
|
||||
import { Button, Checkbox, Divider, Dropdown, Input, theme, Typography, Badge, Collapse, Select, Space, Tooltip, Empty } from 'antd';
|
||||
import {
|
||||
CaretDownFilled,
|
||||
SearchOutlined,
|
||||
ClearOutlined,
|
||||
DownOutlined,
|
||||
RightOutlined,
|
||||
FilterOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Dropdown,
|
||||
Input,
|
||||
theme,
|
||||
Typography,
|
||||
Badge,
|
||||
Collapse,
|
||||
Select,
|
||||
Space,
|
||||
Tooltip,
|
||||
Empty,
|
||||
} from 'antd';
|
||||
import { CheckboxChangeEvent } from 'antd/es/checkbox';
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -34,57 +58,63 @@ const Projects: React.FC = () => {
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
// Theme-aware color utilities
|
||||
const getThemeAwareColor = useCallback((lightColor: string, darkColor: string) => {
|
||||
return themeWiseColor(lightColor, darkColor, themeMode);
|
||||
}, [themeMode]);
|
||||
const getThemeAwareColor = useCallback(
|
||||
(lightColor: string, darkColor: string) => {
|
||||
return themeWiseColor(lightColor, darkColor, themeMode);
|
||||
},
|
||||
[themeMode]
|
||||
);
|
||||
|
||||
// Enhanced color processing for project/group colors
|
||||
const processColor = useCallback((color: string | undefined, fallback?: string) => {
|
||||
if (!color) return fallback || token.colorPrimary;
|
||||
|
||||
// If it's a hex color, ensure it has good contrast in both themes
|
||||
if (color.startsWith('#')) {
|
||||
// For dark mode, lighten dark colors and darken light colors for better visibility
|
||||
if (themeMode === 'dark') {
|
||||
// Simple brightness adjustment for dark mode
|
||||
const hex = color.replace('#', '');
|
||||
const r = parseInt(hex.substr(0, 2), 16);
|
||||
const g = parseInt(hex.substr(2, 2), 16);
|
||||
const b = parseInt(hex.substr(4, 2), 16);
|
||||
|
||||
// Calculate brightness (0-255)
|
||||
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
|
||||
|
||||
// If color is too dark in dark mode, lighten it
|
||||
if (brightness < 100) {
|
||||
const factor = 1.5;
|
||||
const newR = Math.min(255, Math.floor(r * factor));
|
||||
const newG = Math.min(255, Math.floor(g * factor));
|
||||
const newB = Math.min(255, Math.floor(b * factor));
|
||||
return `#${newR.toString(16).padStart(2, '0')}${newG.toString(16).padStart(2, '0')}${newB.toString(16).padStart(2, '0')}`;
|
||||
}
|
||||
} else {
|
||||
// For light mode, ensure colors aren't too light
|
||||
const hex = color.replace('#', '');
|
||||
const r = parseInt(hex.substr(0, 2), 16);
|
||||
const g = parseInt(hex.substr(2, 2), 16);
|
||||
const b = parseInt(hex.substr(4, 2), 16);
|
||||
|
||||
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
|
||||
|
||||
// If color is too light in light mode, darken it
|
||||
if (brightness > 200) {
|
||||
const factor = 0.7;
|
||||
const newR = Math.floor(r * factor);
|
||||
const newG = Math.floor(g * factor);
|
||||
const newB = Math.floor(b * factor);
|
||||
return `#${newR.toString(16).padStart(2, '0')}${newG.toString(16).padStart(2, '0')}${newB.toString(16).padStart(2, '0')}`;
|
||||
const processColor = useCallback(
|
||||
(color: string | undefined, fallback?: string) => {
|
||||
if (!color) return fallback || token.colorPrimary;
|
||||
|
||||
// If it's a hex color, ensure it has good contrast in both themes
|
||||
if (color.startsWith('#')) {
|
||||
// For dark mode, lighten dark colors and darken light colors for better visibility
|
||||
if (themeMode === 'dark') {
|
||||
// Simple brightness adjustment for dark mode
|
||||
const hex = color.replace('#', '');
|
||||
const r = parseInt(hex.substr(0, 2), 16);
|
||||
const g = parseInt(hex.substr(2, 2), 16);
|
||||
const b = parseInt(hex.substr(4, 2), 16);
|
||||
|
||||
// Calculate brightness (0-255)
|
||||
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
|
||||
|
||||
// If color is too dark in dark mode, lighten it
|
||||
if (brightness < 100) {
|
||||
const factor = 1.5;
|
||||
const newR = Math.min(255, Math.floor(r * factor));
|
||||
const newG = Math.min(255, Math.floor(g * factor));
|
||||
const newB = Math.min(255, Math.floor(b * factor));
|
||||
return `#${newR.toString(16).padStart(2, '0')}${newG.toString(16).padStart(2, '0')}${newB.toString(16).padStart(2, '0')}`;
|
||||
}
|
||||
} else {
|
||||
// For light mode, ensure colors aren't too light
|
||||
const hex = color.replace('#', '');
|
||||
const r = parseInt(hex.substr(0, 2), 16);
|
||||
const g = parseInt(hex.substr(2, 2), 16);
|
||||
const b = parseInt(hex.substr(4, 2), 16);
|
||||
|
||||
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
|
||||
|
||||
// If color is too light in light mode, darken it
|
||||
if (brightness > 200) {
|
||||
const factor = 0.7;
|
||||
const newR = Math.floor(r * factor);
|
||||
const newG = Math.floor(g * factor);
|
||||
const newB = Math.floor(b * factor);
|
||||
return `#${newR.toString(16).padStart(2, '0')}${newG.toString(16).padStart(2, '0')}${newB.toString(16).padStart(2, '0')}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return color;
|
||||
}, [themeMode, token.colorPrimary]);
|
||||
|
||||
return color;
|
||||
},
|
||||
[themeMode, token.colorPrimary]
|
||||
);
|
||||
|
||||
// Memoized filtered projects
|
||||
const filteredProjects = useMemo(() => {
|
||||
@@ -102,15 +132,17 @@ const Projects: React.FC = () => {
|
||||
// Memoized grouped projects
|
||||
const groupedProjects = useMemo(() => {
|
||||
if (groupBy === 'none') {
|
||||
return [{
|
||||
key: 'all',
|
||||
name: t('projects'),
|
||||
projects: filteredProjects
|
||||
}];
|
||||
return [
|
||||
{
|
||||
key: 'all',
|
||||
name: t('projects'),
|
||||
projects: filteredProjects,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const groups: { [key: string]: ProjectGroup } = {};
|
||||
|
||||
|
||||
filteredProjects.forEach(project => {
|
||||
let groupKey: string;
|
||||
let groupName: string;
|
||||
@@ -142,7 +174,7 @@ const Projects: React.FC = () => {
|
||||
key: groupKey,
|
||||
name: groupName,
|
||||
color: processColor(groupColor),
|
||||
projects: []
|
||||
projects: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -153,64 +185,67 @@ const Projects: React.FC = () => {
|
||||
}, [filteredProjects, groupBy, t, processColor]);
|
||||
|
||||
// Selected projects count
|
||||
const selectedCount = useMemo(() =>
|
||||
projects.filter(p => p.selected).length,
|
||||
[projects]
|
||||
);
|
||||
const selectedCount = useMemo(() => projects.filter(p => p.selected).length, [projects]);
|
||||
|
||||
const allSelected = useMemo(() =>
|
||||
filteredProjects.length > 0 && filteredProjects.every(p => p.selected),
|
||||
const allSelected = useMemo(
|
||||
() => filteredProjects.length > 0 && filteredProjects.every(p => p.selected),
|
||||
[filteredProjects]
|
||||
);
|
||||
|
||||
const indeterminate = useMemo(() =>
|
||||
filteredProjects.some(p => p.selected) && !allSelected,
|
||||
const indeterminate = useMemo(
|
||||
() => filteredProjects.some(p => p.selected) && !allSelected,
|
||||
[filteredProjects, allSelected]
|
||||
);
|
||||
|
||||
// Memoize group by options
|
||||
const groupByOptions = useMemo(() => [
|
||||
{ value: 'none', label: t('groupByNone') },
|
||||
{ value: 'category', label: t('groupByCategory') },
|
||||
{ value: 'team', label: t('groupByTeam') },
|
||||
{ value: 'status', label: t('groupByStatus') },
|
||||
], [t]);
|
||||
const groupByOptions = useMemo(
|
||||
() => [
|
||||
{ value: 'none', label: t('groupByNone') },
|
||||
{ value: 'category', label: t('groupByCategory') },
|
||||
{ value: 'team', label: t('groupByTeam') },
|
||||
{ value: 'status', label: t('groupByStatus') },
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
// Memoize dropdown styles to prevent recalculation on every render
|
||||
const dropdownStyles = useMemo(() => ({
|
||||
dropdown: {
|
||||
background: token.colorBgContainer,
|
||||
borderRadius: token.borderRadius,
|
||||
boxShadow: token.boxShadowSecondary,
|
||||
border: `1px solid ${token.colorBorder}`,
|
||||
},
|
||||
groupHeader: {
|
||||
backgroundColor: getThemeAwareColor(token.colorFillTertiary, token.colorFillQuaternary),
|
||||
borderRadius: token.borderRadiusSM,
|
||||
padding: '8px 12px',
|
||||
marginBottom: '4px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
border: `1px solid ${getThemeAwareColor(token.colorBorderSecondary, token.colorBorder)}`,
|
||||
},
|
||||
projectItem: {
|
||||
padding: '8px 12px',
|
||||
borderRadius: token.borderRadiusSM,
|
||||
transition: 'all 0.2s ease',
|
||||
cursor: 'pointer',
|
||||
border: `1px solid transparent`,
|
||||
},
|
||||
toggleIcon: {
|
||||
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary),
|
||||
fontSize: '12px',
|
||||
transition: 'all 0.2s ease',
|
||||
},
|
||||
expandedToggleIcon: {
|
||||
color: getThemeAwareColor(token.colorPrimary, token.colorPrimaryActive),
|
||||
fontSize: '12px',
|
||||
transition: 'all 0.2s ease',
|
||||
}
|
||||
}), [token, getThemeAwareColor]);
|
||||
const dropdownStyles = useMemo(
|
||||
() => ({
|
||||
dropdown: {
|
||||
background: token.colorBgContainer,
|
||||
borderRadius: token.borderRadius,
|
||||
boxShadow: token.boxShadowSecondary,
|
||||
border: `1px solid ${token.colorBorder}`,
|
||||
},
|
||||
groupHeader: {
|
||||
backgroundColor: getThemeAwareColor(token.colorFillTertiary, token.colorFillQuaternary),
|
||||
borderRadius: token.borderRadiusSM,
|
||||
padding: '8px 12px',
|
||||
marginBottom: '4px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
border: `1px solid ${getThemeAwareColor(token.colorBorderSecondary, token.colorBorder)}`,
|
||||
},
|
||||
projectItem: {
|
||||
padding: '8px 12px',
|
||||
borderRadius: token.borderRadiusSM,
|
||||
transition: 'all 0.2s ease',
|
||||
cursor: 'pointer',
|
||||
border: `1px solid transparent`,
|
||||
},
|
||||
toggleIcon: {
|
||||
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary),
|
||||
fontSize: '12px',
|
||||
transition: 'all 0.2s ease',
|
||||
},
|
||||
expandedToggleIcon: {
|
||||
color: getThemeAwareColor(token.colorPrimary, token.colorPrimaryActive),
|
||||
fontSize: '12px',
|
||||
transition: 'all 0.2s ease',
|
||||
},
|
||||
}),
|
||||
[token, getThemeAwareColor]
|
||||
);
|
||||
|
||||
// Memoize search placeholder and clear tooltip
|
||||
const searchPlaceholder = useMemo(() => t('searchByProject'), [t]);
|
||||
@@ -224,15 +259,21 @@ const Projects: React.FC = () => {
|
||||
const collapseAllText = useMemo(() => t('collapseAll'), [t]);
|
||||
|
||||
// Handle checkbox change for individual items
|
||||
const handleCheckboxChange = useCallback((key: string, checked: boolean) => {
|
||||
dispatch(setSelectOrDeselectProject({ id: key, selected: checked }));
|
||||
}, [dispatch]);
|
||||
const handleCheckboxChange = useCallback(
|
||||
(key: string, checked: boolean) => {
|
||||
dispatch(setSelectOrDeselectProject({ id: key, selected: checked }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
// Handle "Select All" checkbox change
|
||||
const handleSelectAllChange = useCallback((e: CheckboxChangeEvent) => {
|
||||
const isChecked = e.target.checked;
|
||||
dispatch(setSelectOrDeselectAllProjects(isChecked));
|
||||
}, [dispatch]);
|
||||
const handleSelectAllChange = useCallback(
|
||||
(e: CheckboxChangeEvent) => {
|
||||
const isChecked = e.target.checked;
|
||||
dispatch(setSelectOrDeselectAllProjects(isChecked));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
// Clear search
|
||||
const clearSearch = useCallback(() => {
|
||||
@@ -241,49 +282,57 @@ const Projects: React.FC = () => {
|
||||
|
||||
// Toggle group expansion
|
||||
const toggleGroupExpansion = useCallback((groupKey: string) => {
|
||||
setExpandedGroups(prev =>
|
||||
prev.includes(groupKey)
|
||||
? prev.filter(key => key !== groupKey)
|
||||
: [...prev, groupKey]
|
||||
setExpandedGroups(prev =>
|
||||
prev.includes(groupKey) ? prev.filter(key => key !== groupKey) : [...prev, groupKey]
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Expand/Collapse all groups
|
||||
const toggleAllGroups = useCallback((expand: boolean) => {
|
||||
if (expand) {
|
||||
setExpandedGroups(groupedProjects.map(g => g.key));
|
||||
} else {
|
||||
setExpandedGroups([]);
|
||||
}
|
||||
}, [groupedProjects]);
|
||||
|
||||
|
||||
const toggleAllGroups = useCallback(
|
||||
(expand: boolean) => {
|
||||
if (expand) {
|
||||
setExpandedGroups(groupedProjects.map(g => g.key));
|
||||
} else {
|
||||
setExpandedGroups([]);
|
||||
}
|
||||
},
|
||||
[groupedProjects]
|
||||
);
|
||||
|
||||
// Render project group
|
||||
const renderProjectGroup = (group: ProjectGroup) => {
|
||||
const isExpanded = expandedGroups.includes(group.key) || groupBy === 'none';
|
||||
const groupSelectedCount = group.projects.filter(p => p.selected).length;
|
||||
|
||||
|
||||
return (
|
||||
<div key={group.key} style={{ marginBottom: '8px' }}>
|
||||
{groupBy !== 'none' && (
|
||||
<div
|
||||
<div
|
||||
style={{
|
||||
...dropdownStyles.groupHeader,
|
||||
backgroundColor: isExpanded
|
||||
backgroundColor: isExpanded
|
||||
? getThemeAwareColor(token.colorFillSecondary, token.colorFillTertiary)
|
||||
: dropdownStyles.groupHeader.backgroundColor
|
||||
: dropdownStyles.groupHeader.backgroundColor,
|
||||
}}
|
||||
onClick={() => toggleGroupExpansion(group.key)}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = getThemeAwareColor(token.colorFillSecondary, token.colorFillTertiary);
|
||||
e.currentTarget.style.borderColor = getThemeAwareColor(token.colorBorder, token.colorBorderSecondary);
|
||||
onMouseEnter={e => {
|
||||
e.currentTarget.style.backgroundColor = getThemeAwareColor(
|
||||
token.colorFillSecondary,
|
||||
token.colorFillTertiary
|
||||
);
|
||||
e.currentTarget.style.borderColor = getThemeAwareColor(
|
||||
token.colorBorder,
|
||||
token.colorBorderSecondary
|
||||
);
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = isExpanded
|
||||
onMouseLeave={e => {
|
||||
e.currentTarget.style.backgroundColor = isExpanded
|
||||
? getThemeAwareColor(token.colorFillSecondary, token.colorFillTertiary)
|
||||
: dropdownStyles.groupHeader.backgroundColor;
|
||||
e.currentTarget.style.borderColor = getThemeAwareColor(token.colorBorderSecondary, token.colorBorder);
|
||||
e.currentTarget.style.borderColor = getThemeAwareColor(
|
||||
token.colorBorderSecondary,
|
||||
token.colorBorder
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Space>
|
||||
@@ -292,42 +341,53 @@ const Projects: React.FC = () => {
|
||||
) : (
|
||||
<RightOutlined style={dropdownStyles.toggleIcon} />
|
||||
)}
|
||||
<div style={{
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: group.color || processColor(undefined, token.colorPrimary),
|
||||
flexShrink: 0,
|
||||
border: `1px solid ${getThemeAwareColor('rgba(0,0,0,0.1)', 'rgba(255,255,255,0.2)')}`
|
||||
}} />
|
||||
<Text strong style={{
|
||||
color: getThemeAwareColor(token.colorText, token.colorTextBase)
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: group.color || processColor(undefined, token.colorPrimary),
|
||||
flexShrink: 0,
|
||||
border: `1px solid ${getThemeAwareColor('rgba(0,0,0,0.1)', 'rgba(255,255,255,0.2)')}`,
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
strong
|
||||
style={{
|
||||
color: getThemeAwareColor(token.colorText, token.colorTextBase),
|
||||
}}
|
||||
>
|
||||
{group.name}
|
||||
</Text>
|
||||
<Badge
|
||||
count={groupSelectedCount}
|
||||
size="small"
|
||||
style={{
|
||||
<Badge
|
||||
count={groupSelectedCount}
|
||||
size="small"
|
||||
style={{
|
||||
backgroundColor: getThemeAwareColor(token.colorPrimary, token.colorPrimaryActive),
|
||||
color: getThemeAwareColor('#fff', token.colorTextLightSolid)
|
||||
color: getThemeAwareColor('#fff', token.colorTextLightSolid),
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{isExpanded && (
|
||||
<div style={{ paddingLeft: groupBy !== 'none' ? '24px' : '0' }}>
|
||||
{group.projects.map(project => (
|
||||
<div
|
||||
<div
|
||||
key={project.id}
|
||||
style={dropdownStyles.projectItem}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = getThemeAwareColor(token.colorFillAlter, token.colorFillQuaternary);
|
||||
e.currentTarget.style.borderColor = getThemeAwareColor(token.colorBorderSecondary, token.colorBorder);
|
||||
onMouseEnter={e => {
|
||||
e.currentTarget.style.backgroundColor = getThemeAwareColor(
|
||||
token.colorFillAlter,
|
||||
token.colorFillQuaternary
|
||||
);
|
||||
e.currentTarget.style.borderColor = getThemeAwareColor(
|
||||
token.colorBorderSecondary,
|
||||
token.colorBorder
|
||||
);
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
onMouseLeave={e => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.borderColor = 'transparent';
|
||||
}}
|
||||
@@ -338,17 +398,24 @@ const Projects: React.FC = () => {
|
||||
onChange={e => handleCheckboxChange(project.id || '', e.target.checked)}
|
||||
>
|
||||
<Space>
|
||||
<div style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: processColor((project as any).color_code, token.colorPrimary),
|
||||
flexShrink: 0,
|
||||
border: `1px solid ${getThemeAwareColor('rgba(0,0,0,0.1)', 'rgba(255,255,255,0.2)')}`
|
||||
}} />
|
||||
<Text style={{
|
||||
color: getThemeAwareColor(token.colorText, token.colorTextBase)
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: processColor(
|
||||
(project as any).color_code,
|
||||
token.colorPrimary
|
||||
),
|
||||
flexShrink: 0,
|
||||
border: `1px solid ${getThemeAwareColor('rgba(0,0,0,0.1)', 'rgba(255,255,255,0.2)')}`,
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
color: getThemeAwareColor(token.colorText, token.colorTextBase),
|
||||
}}
|
||||
>
|
||||
{project.name}
|
||||
</Text>
|
||||
</Space>
|
||||
@@ -369,14 +436,16 @@ const Projects: React.FC = () => {
|
||||
trigger={['click']}
|
||||
open={dropdownVisible}
|
||||
dropdownRender={() => (
|
||||
<div style={{
|
||||
...dropdownStyles.dropdown,
|
||||
padding: '8px 0',
|
||||
maxHeight: '500px',
|
||||
width: '400px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
...dropdownStyles.dropdown,
|
||||
padding: '8px 0',
|
||||
maxHeight: '500px',
|
||||
width: '400px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Header with search and controls */}
|
||||
<div style={{ padding: '8px 12px', flexShrink: 0 }}>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="small">
|
||||
@@ -385,28 +454,48 @@ const Projects: React.FC = () => {
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchText}
|
||||
onChange={e => setSearchText(e.target.value)}
|
||||
prefix={<SearchOutlined style={{ color: getThemeAwareColor(token.colorTextTertiary, token.colorTextQuaternary) }} />}
|
||||
suffix={searchText && (
|
||||
<Tooltip title={clearTooltip}>
|
||||
<ClearOutlined
|
||||
onClick={clearSearch}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
color: getThemeAwareColor(token.colorTextTertiary, token.colorTextQuaternary),
|
||||
transition: 'color 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary);
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = getThemeAwareColor(token.colorTextTertiary, token.colorTextQuaternary);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
prefix={
|
||||
<SearchOutlined
|
||||
style={{
|
||||
color: getThemeAwareColor(
|
||||
token.colorTextTertiary,
|
||||
token.colorTextQuaternary
|
||||
),
|
||||
}}
|
||||
/>
|
||||
}
|
||||
suffix={
|
||||
searchText && (
|
||||
<Tooltip title={clearTooltip}>
|
||||
<ClearOutlined
|
||||
onClick={clearSearch}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
color: getThemeAwareColor(
|
||||
token.colorTextTertiary,
|
||||
token.colorTextQuaternary
|
||||
),
|
||||
transition: 'color 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
e.currentTarget.style.color = getThemeAwareColor(
|
||||
token.colorTextSecondary,
|
||||
token.colorTextTertiary
|
||||
);
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
e.currentTarget.style.color = getThemeAwareColor(
|
||||
token.colorTextTertiary,
|
||||
token.colorTextQuaternary
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
|
||||
|
||||
{/* Controls row */}
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<Space size="small">
|
||||
@@ -417,25 +506,31 @@ const Projects: React.FC = () => {
|
||||
style={{ width: '120px' }}
|
||||
options={groupByOptions}
|
||||
/>
|
||||
|
||||
|
||||
{groupBy !== 'none' && (
|
||||
<Space size="small">
|
||||
<Button
|
||||
type="text"
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
onClick={() => toggleAllGroups(true)}
|
||||
style={{
|
||||
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary)
|
||||
style={{
|
||||
color: getThemeAwareColor(
|
||||
token.colorTextSecondary,
|
||||
token.colorTextTertiary
|
||||
),
|
||||
}}
|
||||
>
|
||||
{expandAllText}
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
onClick={() => toggleAllGroups(false)}
|
||||
style={{
|
||||
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary)
|
||||
style={{
|
||||
color: getThemeAwareColor(
|
||||
token.colorTextSecondary,
|
||||
token.colorTextTertiary
|
||||
),
|
||||
}}
|
||||
>
|
||||
{collapseAllText}
|
||||
@@ -443,16 +538,23 @@ const Projects: React.FC = () => {
|
||||
</Space>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
|
||||
<Tooltip title={showSelectedTooltip}>
|
||||
<Button
|
||||
type={showSelectedOnly ? 'primary' : 'text'}
|
||||
size="small"
|
||||
icon={<FilterOutlined />}
|
||||
onClick={() => setShowSelectedOnly(!showSelectedOnly)}
|
||||
style={!showSelectedOnly ? {
|
||||
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary)
|
||||
} : {}}
|
||||
style={
|
||||
!showSelectedOnly
|
||||
? {
|
||||
color: getThemeAwareColor(
|
||||
token.colorTextSecondary,
|
||||
token.colorTextTertiary
|
||||
),
|
||||
}
|
||||
: {}
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
@@ -468,18 +570,23 @@ const Projects: React.FC = () => {
|
||||
indeterminate={indeterminate}
|
||||
>
|
||||
<Space>
|
||||
<Text style={{
|
||||
color: getThemeAwareColor(token.colorText, token.colorTextBase)
|
||||
}}>
|
||||
<Text
|
||||
style={{
|
||||
color: getThemeAwareColor(token.colorText, token.colorTextBase),
|
||||
}}
|
||||
>
|
||||
{selectAllText}
|
||||
</Text>
|
||||
{selectedCount > 0 && (
|
||||
<Badge
|
||||
count={selectedCount}
|
||||
<Badge
|
||||
count={selectedCount}
|
||||
size="small"
|
||||
style={{
|
||||
backgroundColor: getThemeAwareColor(token.colorSuccess, token.colorSuccessActive),
|
||||
color: getThemeAwareColor('#fff', token.colorTextLightSolid)
|
||||
style={{
|
||||
backgroundColor: getThemeAwareColor(
|
||||
token.colorSuccess,
|
||||
token.colorSuccessActive
|
||||
),
|
||||
color: getThemeAwareColor('#fff', token.colorTextLightSolid),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -490,18 +597,25 @@ const Projects: React.FC = () => {
|
||||
<Divider style={{ margin: '8px 0', flexShrink: 0 }} />
|
||||
|
||||
{/* Projects list */}
|
||||
<div style={{
|
||||
overflowY: 'auto',
|
||||
flex: 1,
|
||||
padding: '0 12px'
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
overflowY: 'auto',
|
||||
flex: 1,
|
||||
padding: '0 12px',
|
||||
}}
|
||||
>
|
||||
{filteredProjects.length === 0 ? (
|
||||
<Empty
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={
|
||||
<Text style={{
|
||||
color: getThemeAwareColor(token.colorTextTertiary, token.colorTextQuaternary)
|
||||
}}>
|
||||
<Text
|
||||
style={{
|
||||
color: getThemeAwareColor(
|
||||
token.colorTextTertiary,
|
||||
token.colorTextQuaternary
|
||||
),
|
||||
}}
|
||||
>
|
||||
{searchText ? noProjectsText : noDataText}
|
||||
</Text>
|
||||
}
|
||||
@@ -516,17 +630,25 @@ const Projects: React.FC = () => {
|
||||
{selectedCount > 0 && (
|
||||
<>
|
||||
<Divider style={{ margin: '8px 0', flexShrink: 0 }} />
|
||||
<div style={{
|
||||
padding: '8px 12px',
|
||||
flexShrink: 0,
|
||||
backgroundColor: getThemeAwareColor(token.colorFillAlter, token.colorFillQuaternary),
|
||||
borderRadius: `0 0 ${token.borderRadius}px ${token.borderRadius}px`,
|
||||
borderTop: `1px solid ${getThemeAwareColor(token.colorBorderSecondary, token.colorBorder)}`
|
||||
}}>
|
||||
<Text type="secondary" style={{
|
||||
fontSize: '12px',
|
||||
color: getThemeAwareColor(token.colorTextTertiary, token.colorTextQuaternary)
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
flexShrink: 0,
|
||||
backgroundColor: getThemeAwareColor(
|
||||
token.colorFillAlter,
|
||||
token.colorFillQuaternary
|
||||
),
|
||||
borderRadius: `0 0 ${token.borderRadius}px ${token.borderRadius}px`,
|
||||
borderTop: `1px solid ${getThemeAwareColor(token.colorBorderSecondary, token.colorBorder)}`,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: getThemeAwareColor(token.colorTextTertiary, token.colorTextQuaternary),
|
||||
}}
|
||||
>
|
||||
{selectedCount} {projectsSelectedText}
|
||||
</Text>
|
||||
</div>
|
||||
@@ -545,7 +667,7 @@ const Projects: React.FC = () => {
|
||||
<Badge count={selectedCount} size="small" offset={[-5, 5]}>
|
||||
<Button loading={loadingProjects}>
|
||||
<Space>
|
||||
{t('projects')}
|
||||
{t('projects')}
|
||||
<CaretDownFilled />
|
||||
</Space>
|
||||
</Button>
|
||||
|
||||
@@ -8,7 +8,13 @@ import { reportingApiService } from '@/api/reporting/reporting.api.service';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { fetchReportingCategories, fetchReportingProjects, fetchReportingTeams, setSelectOrDeselectAllTeams, setSelectOrDeselectTeam } from '@/features/reporting/time-reports/time-reports-overview.slice';
|
||||
import {
|
||||
fetchReportingCategories,
|
||||
fetchReportingProjects,
|
||||
fetchReportingTeams,
|
||||
setSelectOrDeselectAllTeams,
|
||||
setSelectOrDeselectTeam,
|
||||
} from '@/features/reporting/time-reports/time-reports-overview.slice';
|
||||
|
||||
const Team: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
@@ -46,15 +52,17 @@ const Team: React.FC = () => {
|
||||
placement="bottomLeft"
|
||||
trigger={['click']}
|
||||
dropdownRender={() => (
|
||||
<div style={{
|
||||
background: token.colorBgContainer,
|
||||
borderRadius: token.borderRadius,
|
||||
boxShadow: token.boxShadow,
|
||||
padding: '4px 0',
|
||||
maxHeight: '330px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
background: token.colorBgContainer,
|
||||
borderRadius: token.borderRadius,
|
||||
boxShadow: token.boxShadow,
|
||||
padding: '4px 0',
|
||||
maxHeight: '330px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: '8px', flexShrink: 0 }}>
|
||||
<Input
|
||||
placeholder={t('searchByName')}
|
||||
@@ -73,16 +81,18 @@ const Team: React.FC = () => {
|
||||
</Checkbox>
|
||||
</div>
|
||||
<Divider style={{ margin: '4px 0', flexShrink: 0 }} />
|
||||
<div style={{
|
||||
overflowY: 'auto',
|
||||
flex: 1
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
overflowY: 'auto',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{filteredItems.map(item => (
|
||||
<div
|
||||
<div
|
||||
key={item.id}
|
||||
style={{
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer'
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Card, Flex } from 'antd';
|
||||
import TimeReportPageHeader from '@/pages/reporting/timeReports/page-header/time-report-page-header';
|
||||
import ProjectTimeSheetChart, { ProjectTimeSheetChartRef } from '@/pages/reporting/time-reports/project-time-sheet/project-time-sheet-chart';
|
||||
import ProjectTimeSheetChart, {
|
||||
ProjectTimeSheetChartRef,
|
||||
} from '@/pages/reporting/time-reports/project-time-sheet/project-time-sheet-chart';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
||||
import TimeReportingRightHeader from './timeReportingRightHeader/TimeReportingRightHeader';
|
||||
@@ -22,9 +24,7 @@ const ProjectsTimeReports = () => {
|
||||
<Flex vertical>
|
||||
<TimeReportingRightHeader
|
||||
title={t('projectsTimeSheet')}
|
||||
exportType={[
|
||||
{ key: 'png', label: 'PNG' },
|
||||
]}
|
||||
exportType={[{ key: 'png', label: 'PNG' }]}
|
||||
export={handleExport}
|
||||
/>
|
||||
|
||||
|
||||
@@ -14,7 +14,11 @@ interface headerState {
|
||||
export: (key: string) => void;
|
||||
}
|
||||
|
||||
const TimeReportingRightHeader: React.FC<headerState> = ({ title, exportType, export: exportFn }) => {
|
||||
const TimeReportingRightHeader: React.FC<headerState> = ({
|
||||
title,
|
||||
exportType,
|
||||
export: exportFn,
|
||||
}) => {
|
||||
const { t } = useTranslation('time-report');
|
||||
const dispatch = useAppDispatch();
|
||||
const { archived } = useAppSelector(state => state.timeReportsOverviewReducer);
|
||||
@@ -22,7 +26,7 @@ const TimeReportingRightHeader: React.FC<headerState> = ({ title, exportType, ex
|
||||
const menuItems = exportType.map(item => ({
|
||||
key: item.key,
|
||||
label: item.label,
|
||||
onClick: () => exportFn(item.key)
|
||||
onClick: () => exportFn(item.key),
|
||||
}));
|
||||
|
||||
return (
|
||||
@@ -36,8 +40,8 @@ const TimeReportingRightHeader: React.FC<headerState> = ({ title, exportType, ex
|
||||
</Checkbox>
|
||||
</Button>
|
||||
<TimeWiseFilter />
|
||||
<Dropdown menu={{ items: menuItems }}>
|
||||
<Button type="primary" icon={<DownOutlined />} iconPosition="end">
|
||||
<Dropdown menu={{ items: menuItems }}>
|
||||
<Button type="primary" icon={<DownOutlined />} iconPosition="end">
|
||||
{t('export')}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
|
||||
@@ -41,4 +41,4 @@ const AppearanceSettings = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default AppearanceSettings;
|
||||
export default AppearanceSettings;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user