Merge branch 'main' of https://github.com/Worklenz/worklenz into feature/task-activities-by-user

This commit is contained in:
chamikaJ
2025-07-14 12:46:18 +05:30
1166 changed files with 82289 additions and 15230 deletions

View File

@@ -1,11 +1,10 @@
// Core dependencies
import React, { Suspense, useEffect } from 'react';
import React, { Suspense, useEffect, memo, useMemo, useCallback } from 'react';
import { RouterProvider } from 'react-router-dom';
import i18next from 'i18next';
// Components
import ThemeWrapper from './features/theme/ThemeWrapper';
import PreferenceSelector from './components/PreferenceSelector';
// Routes
import router from './app/routes';
@@ -13,36 +12,155 @@ import router from './app/routes';
// Hooks & Utils
import { useAppSelector } from './hooks/useAppSelector';
import { initMixpanel } from './utils/mixpanelInit';
import { initializeCsrfToken } from './api/api-client';
// Types & Constants
import { Language } from './features/i18n/localesSlice';
import logger from './utils/errorLogger';
import { SuspenseFallback } from './components/suspense-fallback/suspense-fallback';
const App: React.FC<{ children: React.ReactNode }> = ({ children }) => {
// Performance optimizations
import { CSSPerformanceMonitor, LayoutStabilizer, CriticalCSSManager } from './utils/css-optimizations';
// Service Worker
import { registerSW } from './utils/serviceWorkerRegistration';
/**
* Main App Component - Performance Optimized
*
* Performance optimizations applied:
* 1. React.memo() - Prevents unnecessary re-renders
* 2. useMemo() - Memoizes expensive computations
* 3. useCallback() - Memoizes event handlers
* 4. Lazy loading - All route components loaded on demand
* 5. Suspense boundaries - Better loading states
* 6. Optimized guard components with memoization
* 7. Deferred initialization - Non-critical operations moved to background
*/
const App: React.FC = memo(() => {
const themeMode = useAppSelector(state => state.themeReducer.mode);
const language = useAppSelector(state => state.localesReducer.lng);
initMixpanel(import.meta.env.VITE_MIXPANEL_TOKEN as string);
// Memoize mixpanel initialization to prevent re-initialization
const mixpanelToken = useMemo(() => import.meta.env.VITE_MIXPANEL_TOKEN as string, []);
// Defer mixpanel initialization to not block initial render
useEffect(() => {
const initializeMixpanel = () => {
try {
initMixpanel(mixpanelToken);
} catch (error) {
logger.error('Failed to initialize Mixpanel:', error);
}
};
// Use requestIdleCallback to defer mixpanel initialization
if ('requestIdleCallback' in window) {
requestIdleCallback(initializeMixpanel, { timeout: 2000 });
} else {
setTimeout(initializeMixpanel, 1000);
}
}, [mixpanelToken]);
// Memoize language change handler
const handleLanguageChange = useCallback((lng: string) => {
i18next.changeLanguage(lng, err => {
if (err) return logger.error('Error changing language', err);
});
}, []);
// Apply theme immediately to prevent flash
useEffect(() => {
document.documentElement.setAttribute('data-theme', themeMode);
}, [themeMode]);
// Handle language changes
useEffect(() => {
i18next.changeLanguage(language || Language.EN, err => {
if (err) return logger.error('Error changing language', err);
handleLanguageChange(language || Language.EN);
}, [language, handleLanguageChange]);
// Initialize critical app functionality
useEffect(() => {
let isMounted = true;
const initializeCriticalApp = async () => {
try {
// Initialize CSRF token immediately as it's needed for API calls
await initializeCsrfToken();
// Start CSS performance monitoring
CSSPerformanceMonitor.monitorLayoutShifts();
CSSPerformanceMonitor.monitorRenderBlocking();
// Preload critical fonts to prevent layout shifts
LayoutStabilizer.preloadFonts([
{ family: 'Inter', weight: '400' },
{ family: 'Inter', weight: '500' },
{ family: 'Inter', weight: '600' },
]);
} catch (error) {
if (isMounted) {
logger.error('Failed to initialize critical app functionality:', error);
}
}
};
// Initialize critical functionality immediately
initializeCriticalApp();
return () => {
isMounted = false;
};
}, []);
// Register service worker
useEffect(() => {
registerSW({
onSuccess: (registration) => {
console.log('Service Worker registered successfully', registration);
},
onUpdate: (registration) => {
console.log('New content is available and will be used when all tabs for this page are closed.');
// You could show a toast notification here for user to refresh
},
onOfflineReady: () => {
console.log('This web app has been cached for offline use.');
},
onError: (error) => {
logger.error('Service Worker registration failed:', error);
}
});
}, [language]);
}, []);
// Defer non-critical initialization
useEffect(() => {
const initializeNonCriticalApp = () => {
// Any non-critical initialization can go here
// For example: analytics, feature flags, etc.
};
// Defer non-critical initialization to not block initial render
if ('requestIdleCallback' in window) {
requestIdleCallback(initializeNonCriticalApp, { timeout: 3000 });
} else {
setTimeout(initializeNonCriticalApp, 1500);
}
}, []);
return (
<Suspense fallback={<SuspenseFallback />}>
<ThemeWrapper>
<RouterProvider router={router} future={{ v7_startTransition: true }} />
<PreferenceSelector />
<RouterProvider
router={router}
future={{
v7_startTransition: true,
}}
/>
</ThemeWrapper>
</Suspense>
);
};
});
App.displayName = 'App';
export default App;

View File

@@ -112,11 +112,11 @@ export const adminCenterApiService = {
async updateTeam(
team_id: string,
team_members: IOrganizationUser[]
body: { name: string; teamMembers: IOrganizationUser[] }
): Promise<IServerResponse<IOrganization>> {
const response = await apiClient.put<IServerResponse<IOrganization>>(
`${rootUrl}/organization/team/${team_id}`,
team_members
body
);
return response.data;
},
@@ -152,7 +152,6 @@ export const adminCenterApiService = {
return response.data;
},
// Billing - Configuration
async getCountries(): Promise<IServerResponse<IBillingConfigurationCountry[]>> {
const response = await apiClient.get<IServerResponse<IBillingConfigurationCountry[]>>(
@@ -168,7 +167,9 @@ export const adminCenterApiService = {
return response.data;
},
async updateBillingConfiguration(body: IBillingConfiguration): Promise<IServerResponse<IBillingConfiguration>> {
async updateBillingConfiguration(
body: IBillingConfiguration
): Promise<IServerResponse<IBillingConfiguration>> {
const response = await apiClient.put<IServerResponse<IBillingConfiguration>>(
`${rootUrl}/billing/configuration`,
body
@@ -178,42 +179,58 @@ export const adminCenterApiService = {
// Billing - Current Bill
async getCharges(): Promise<IServerResponse<IBillingChargesResponse>> {
const response = await apiClient.get<IServerResponse<IBillingChargesResponse>>(`${rootUrl}/billing/charges`);
const response = await apiClient.get<IServerResponse<IBillingChargesResponse>>(
`${rootUrl}/billing/charges`
);
return response.data;
},
async getTransactions(): Promise<IServerResponse<IBillingTransaction[]>> {
const response = await apiClient.get<IServerResponse<IBillingTransaction[]>>(`${rootUrl}/billing/transactions`);
const response = await apiClient.get<IServerResponse<IBillingTransaction[]>>(
`${rootUrl}/billing/transactions`
);
return response.data;
},
async getBillingAccountInfo(): Promise<IServerResponse<IBillingAccountInfo>> {
const response = await apiClient.get<IServerResponse<IBillingAccountInfo>>(`${rootUrl}/billing/info`);
const response = await apiClient.get<IServerResponse<IBillingAccountInfo>>(
`${rootUrl}/billing/info`
);
return response.data;
},
async getFreePlanSettings(): Promise<IServerResponse<IFreePlanSettings>> {
const response = await apiClient.get<IServerResponse<IFreePlanSettings>>(`${rootUrl}/billing/free-plan`);
const response = await apiClient.get<IServerResponse<IFreePlanSettings>>(
`${rootUrl}/billing/free-plan`
);
return response.data;
},
async upgradePlan(plan: string): Promise<IServerResponse<IUpgradeSubscriptionPlanResponse>> {
const response = await apiClient.get<IServerResponse<IUpgradeSubscriptionPlanResponse>>(`${rootUrl}/billing/upgrade-plan${toQueryString({plan})}`);
const response = await apiClient.get<IServerResponse<IUpgradeSubscriptionPlanResponse>>(
`${rootUrl}/billing/upgrade-plan${toQueryString({ plan })}`
);
return response.data;
},
async changePlan(plan: string): Promise<IServerResponse<IUpgradeSubscriptionPlanResponse>> {
const response = await apiClient.get<IServerResponse<IUpgradeSubscriptionPlanResponse>>(`${rootUrl}/billing/change-plan${toQueryString({plan})}`);
const response = await apiClient.get<IServerResponse<IUpgradeSubscriptionPlanResponse>>(
`${rootUrl}/billing/change-plan${toQueryString({ plan })}`
);
return response.data;
},
async getPlans(): Promise<IServerResponse<IPricingPlans>> {
const response = await apiClient.get<IServerResponse<IPricingPlans>>(`${rootUrl}/billing/plans`);
const response = await apiClient.get<IServerResponse<IPricingPlans>>(
`${rootUrl}/billing/plans`
);
return response.data;
},
async getStorageInfo(): Promise<IServerResponse<IStorageInfo>> {
const response = await apiClient.get<IServerResponse<IStorageInfo>>(`${rootUrl}/billing/storage`);
const response = await apiClient.get<IServerResponse<IStorageInfo>>(
`${rootUrl}/billing/storage`
);
return response.data;
},
@@ -225,7 +242,7 @@ export const adminCenterApiService = {
async resumeSubscription(): Promise<IServerResponse<any>> {
const response = await apiClient.get<IServerResponse<any>>(`${rootUrl}/billing/resume-plan`);
return response.data;
},
},
async cancelSubscription(): Promise<IServerResponse<any>> {
const response = await apiClient.get<IServerResponse<any>>(`${rootUrl}/billing/cancel-plan`);
@@ -233,26 +250,34 @@ export const adminCenterApiService = {
},
async addMoreSeats(totalSeats: number): Promise<IServerResponse<any>> {
const response = await apiClient.post<IServerResponse<any>>(`${rootUrl}/billing/purchase-more-seats`, {seatCount: totalSeats});
const response = await apiClient.post<IServerResponse<any>>(
`${rootUrl}/billing/purchase-more-seats`,
{ seatCount: totalSeats }
);
return response.data;
},
async redeemCode(code: string): Promise<IServerResponse<IUpgradeSubscriptionPlanResponse>> {
const response = await apiClient.post<IServerResponse<IUpgradeSubscriptionPlanResponse>>(`${rootUrl}/billing/redeem`, {
code,
});
const response = await apiClient.post<IServerResponse<IUpgradeSubscriptionPlanResponse>>(
`${rootUrl}/billing/redeem`,
{
code,
}
);
return response.data;
},
async getAccountStorage(): Promise<IServerResponse<IBillingAccountStorage>> {
const response = await apiClient.get<IServerResponse<IBillingAccountStorage>>(`${rootUrl}/billing/account-storage`);
const response = await apiClient.get<IServerResponse<IBillingAccountStorage>>(
`${rootUrl}/billing/account-storage`
);
return response.data;
},
async switchToFreePlan(teamId: string): Promise<IServerResponse<any>> {
const response = await apiClient.get<IServerResponse<any>>(`${rootUrl}/billing/switch-to-free-plan/${teamId}`);
const response = await apiClient.get<IServerResponse<any>>(
`${rootUrl}/billing/switch-to-free-plan/${teamId}`
);
return response.data;
},
};

View File

@@ -6,7 +6,10 @@ import { IUpgradeSubscriptionPlanResponse } from '@/types/admin-center/admin-cen
const rootUrl = `${API_BASE_URL}/billing`;
export const billingApiService = {
async upgradeToPaidPlan(plan: string, seatCount: number): Promise<IServerResponse<IUpgradeSubscriptionPlanResponse>> {
async upgradeToPaidPlan(
plan: string,
seatCount: number
): Promise<IServerResponse<IUpgradeSubscriptionPlanResponse>> {
const q = toQueryString({ plan, seatCount });
const response = await apiClient.get<IServerResponse<any>>(
`${rootUrl}/upgrade-to-paid-plan${q}`
@@ -14,7 +17,9 @@ export const billingApiService = {
return response.data;
},
async purchaseMoreSeats(seatCount: number): Promise<IServerResponse<IUpgradeSubscriptionPlanResponse>> {
async purchaseMoreSeats(
seatCount: number
): Promise<IServerResponse<IUpgradeSubscriptionPlanResponse>> {
const response = await apiClient.post<IServerResponse<IUpgradeSubscriptionPlanResponse>>(
`${rootUrl}/purchase-more-seats`,
{ seatCount }
@@ -27,9 +32,5 @@ export const billingApiService = {
`${rootUrl}/contact-us${toQueryString({ contactNo })}`
);
return response.data;
}
},
};

View File

@@ -4,45 +4,79 @@ import alertService from '@/services/alerts/alertService';
import logger from '@/utils/errorLogger';
import config from '@/config/env';
export const getCsrfToken = (): string | null => {
const match = document.cookie.split('; ').find(cookie => cookie.startsWith('XSRF-TOKEN='));
// Store CSRF token in memory (since csrf-sync uses session-based tokens)
let csrfToken: string | null = null;
if (!match) {
return null;
}
return decodeURIComponent(match.split('=')[1]);
export const getCsrfToken = (): string | null => {
return csrfToken;
};
// Function to refresh CSRF token if needed
// Function to refresh CSRF token from server
export const refreshCsrfToken = async (): Promise<string | null> => {
try {
// Make a GET request to the server to get a fresh CSRF token
await axios.get(`${config.apiUrl}/csrf-token`, { withCredentials: true });
return getCsrfToken();
} catch (error) {
console.error('Failed to refresh CSRF token:', error);
const tokenStart = performance.now();
console.log('[CSRF] Starting CSRF token refresh...');
// Make a GET request to the server to get a fresh CSRF token with timeout
const response = await axios.get(`${config.apiUrl}/csrf-token`, {
withCredentials: true,
timeout: 10000, // 10 second timeout for CSRF token requests
});
const tokenEnd = performance.now();
console.log(`[CSRF] CSRF token refresh completed in ${(tokenEnd - tokenStart).toFixed(2)}ms`);
if (response.data && response.data.token) {
csrfToken = response.data.token;
console.log('[CSRF] CSRF token successfully refreshed');
return csrfToken;
} else {
console.warn('[CSRF] No token in response:', response.data);
}
return null;
} catch (error) {
console.error('[CSRF] Failed to refresh CSRF token:', error);
return null;
}
};
// Initialize CSRF token on app load
export const initializeCsrfToken = async (): Promise<void> => {
if (!csrfToken) {
await refreshCsrfToken();
}
};
const apiClient = axios.create({
baseURL: config.apiUrl,
withCredentials: true,
timeout: 30000, // 30 second timeout to prevent hanging requests
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
});
// Request interceptor
// Request interceptor with performance optimization
apiClient.interceptors.request.use(
config => {
const token = getCsrfToken();
if (token) {
config.headers['X-CSRF-Token'] = token;
} else {
console.warn('No CSRF token found');
async config => {
const requestStart = performance.now();
// Ensure we have a CSRF token before making requests
if (!csrfToken) {
const tokenStart = performance.now();
await refreshCsrfToken();
const tokenEnd = performance.now();
}
if (csrfToken) {
config.headers['X-CSRF-Token'] = csrfToken;
} else {
console.warn('No CSRF token available after refresh attempt');
}
const requestEnd = performance.now();
return config;
},
error => Promise.reject(error)
@@ -80,21 +114,24 @@ apiClient.interceptors.response.use(
const errorResponse = error.response;
// Handle CSRF token errors
if (errorResponse?.status === 403 &&
(typeof errorResponse.data === 'object' &&
errorResponse.data !== null &&
'message' in errorResponse.data &&
errorResponse.data.message === 'Invalid CSRF token' ||
(error as any).code === 'EBADCSRFTOKEN')) {
if (
errorResponse?.status === 403 &&
((typeof errorResponse.data === 'object' &&
errorResponse.data !== null &&
'message' in errorResponse.data &&
(errorResponse.data.message === 'invalid csrf token' ||
errorResponse.data.message === 'Invalid CSRF token')) ||
(error as any).code === 'EBADCSRFTOKEN')
) {
alertService.error('Security Error', 'Invalid security token. Refreshing your session...');
// Try to refresh the CSRF token and retry the request
const newToken = await refreshCsrfToken();
if (newToken && error.config) {
// Update the token in the failed request
error.config.headers['X-CSRF-Token'] = newToken;
// Retry the original request with the new token
return axios(error.config);
return apiClient(error.config);
} else {
// If token refresh failed, redirect to login
window.location.href = '/auth/login';

View File

@@ -1,25 +1,37 @@
import { IServerResponse } from "@/types/common.types";
import { IProjectAttachmentsViewModel } from "@/types/tasks/task-attachment-view-model";
import apiClient from "../api-client";
import { API_BASE_URL } from "@/shared/constants";
import { toQueryString } from "@/utils/toQueryString";
import { IServerResponse } from '@/types/common.types';
import { IProjectAttachmentsViewModel } from '@/types/tasks/task-attachment-view-model';
import apiClient from '../api-client';
import { API_BASE_URL } from '@/shared/constants';
import { toQueryString } from '@/utils/toQueryString';
const rootUrl = `${API_BASE_URL}/attachments`;
export const attachmentsApiService = {
getTaskAttachments: async (taskId: string): Promise<IServerResponse<IProjectAttachmentsViewModel>> => {
const response = await apiClient.get<IServerResponse<IProjectAttachmentsViewModel>>(`${rootUrl}/tasks/${taskId}`);
getTaskAttachments: async (
taskId: string
): Promise<IServerResponse<IProjectAttachmentsViewModel>> => {
const response = await apiClient.get<IServerResponse<IProjectAttachmentsViewModel>>(
`${rootUrl}/tasks/${taskId}`
);
return response.data;
},
getProjectAttachments: async (projectId: string, index: number, size: number): Promise<IServerResponse<IProjectAttachmentsViewModel>> => {
getProjectAttachments: async (
projectId: string,
index: number,
size: number
): Promise<IServerResponse<IProjectAttachmentsViewModel>> => {
const q = toQueryString({ index, size });
const response = await apiClient.get<IServerResponse<IProjectAttachmentsViewModel>>(`${rootUrl}/project/${projectId}${q}`);
const response = await apiClient.get<IServerResponse<IProjectAttachmentsViewModel>>(
`${rootUrl}/project/${projectId}${q}`
);
return response.data;
},
downloadAttachment: async (id: string, filename: string): Promise<IServerResponse<string>> => {
const response = await apiClient.get<IServerResponse<string>>(`${rootUrl}/download?id=${id}&file=${filename}`);
const response = await apiClient.get<IServerResponse<string>>(
`${rootUrl}/download?id=${id}&file=${filename}`
);
return response.data;
},
@@ -27,7 +39,4 @@ export const attachmentsApiService = {
const response = await apiClient.delete<IServerResponse<string>>(`${rootUrl}/tasks/${id}`);
return response.data;
},
};

View File

@@ -5,7 +5,7 @@ import { toQueryString } from '@/utils/toQueryString';
import { IHomeTasksModel, IHomeTasksConfig } from '@/types/home/home-page.types';
import { IMyTask } from '@/types/home/my-tasks.types';
import { IProject } from '@/types/project/project.types';
import { getCsrfToken } from '../api-client';
import { getCsrfToken, refreshCsrfToken } from '../api-client';
import config from '@/config/env';
const rootUrl = '/home';
@@ -14,9 +14,18 @@ const api = createApi({
reducerPath: 'homePageApi',
baseQuery: fetchBaseQuery({
baseUrl: `${config.apiUrl}${API_BASE_URL}`,
prepareHeaders: headers => {
headers.set('X-CSRF-Token', getCsrfToken() || '');
prepareHeaders: async headers => {
// Get CSRF token, refresh if needed
let token = getCsrfToken();
if (!token) {
token = await refreshCsrfToken();
}
if (token) {
headers.set('X-CSRF-Token', token);
}
headers.set('Content-Type', 'application/json');
return headers;
},
credentials: 'include',
}),

View File

@@ -10,7 +10,7 @@ export const projectMembersApiService = {
createProjectMember: async (
body: IProjectMemberViewModel
): Promise<IServerResponse<IProjectMemberViewModel>> => {
const q = toQueryString({current_project_id: body.project_id});
const q = toQueryString({ current_project_id: body.project_id });
const response = await apiClient.post<IServerResponse<IProjectMemberViewModel>>(
`${rootUrl}${q}`,

View File

@@ -34,7 +34,9 @@ export const projectTemplatesApiService = {
return response.data;
},
createCustomTemplate: async (body: { template_id: string }): Promise<IServerResponse<IProjectTemplate>> => {
createCustomTemplate: async (body: {
template_id: string;
}): Promise<IServerResponse<IProjectTemplate>> => {
const response = await apiClient.post(`${rootUrl}/custom-template`, body);
return response.data;
},
@@ -44,15 +46,17 @@ export const projectTemplatesApiService = {
return response.data;
},
createFromWorklenzTemplate: async (body: { template_id: string }): Promise<IServerResponse<IProjectTemplate>> => {
createFromWorklenzTemplate: async (body: {
template_id: string;
}): Promise<IServerResponse<IProjectTemplate>> => {
const response = await apiClient.post(`${rootUrl}/import-template`, body);
return response.data;
},
createFromCustomTemplate: async (body: { template_id: string }): Promise<IServerResponse<IProjectTemplate>> => {
const response = await apiClient.post(`${rootUrl}/import-custom-template`, body);
return response.data;
},
createFromCustomTemplate: async (body: {
template_id: string;
}): Promise<IServerResponse<IProjectTemplate>> => {
const response = await apiClient.post(`${rootUrl}/import-custom-template`, body);
return response.data;
},
};

View File

@@ -7,9 +7,15 @@ import { IProjectViewModel } from '@/types/project/projectViewModel.types';
import { ITeamMemberOverviewGetResponse } from '@/types/project/project-insights.types';
import { IProjectMembersViewModel } from '@/types/projectMember.types';
import { IProjectManager } from '@/types/project/projectManager.types';
import { IGroupedProjectsViewModel } from '@/types/project/groupedProjectsViewModel.types';
const rootUrl = `${API_BASE_URL}/projects`;
interface UpdateProjectPayload {
id: string;
[key: string]: any;
}
export const projectsApiService = {
getProjects: async (
index: number,
@@ -27,6 +33,23 @@ export const projectsApiService = {
return response.data;
},
getGroupedProjects: async (
index: number,
size: number,
field: string | null,
order: string | null,
search: string | null,
groupBy: string,
filter: number | null = null,
statuses: string | null = null,
categories: string | null = null
): Promise<IServerResponse<IGroupedProjectsViewModel>> => {
const s = encodeURIComponent(search || '');
const url = `${rootUrl}/grouped${toQueryString({ index, size, field, order, search: s, groupBy, filter, statuses, categories })}`;
const response = await apiClient.get<IServerResponse<IGroupedProjectsViewModel>>(`${url}`);
return response.data;
},
getProject: async (id: string): Promise<IServerResponse<IProjectViewModel>> => {
const url = `${rootUrl}/${id}`;
const response = await apiClient.get<IServerResponse<IProjectViewModel>>(`${url}`);
@@ -79,12 +102,12 @@ export const projectsApiService = {
},
updateProject: async (
id: string,
project: IProjectViewModel
payload: UpdateProjectPayload
): Promise<IServerResponse<IProjectViewModel>> => {
const { id, ...data } = payload;
const q = toQueryString({ current_project_id: id });
const url = `${rootUrl}/${id}${q}`;
const response = await apiClient.put<IServerResponse<IProjectViewModel>>(`${url}`, project);
const url = `${API_BASE_URL}/projects/${id}${q}`;
const response = await apiClient.patch<IServerResponse<IProjectViewModel>>(url, data);
return response.data;
},
@@ -106,7 +129,10 @@ export const projectsApiService = {
return response.data;
},
updateDefaultTab: async (body: { project_id: string; default_view: string }): Promise<IServerResponse<any>> => {
updateDefaultTab: async (body: {
project_id: string;
default_view: string;
}): Promise<IServerResponse<any>> => {
const url = `${rootUrl}/update-pinned-view`;
const response = await apiClient.put<IServerResponse<IProjectViewModel>>(`${url}`, body);
return response.data;
@@ -118,4 +144,3 @@ export const projectsApiService = {
return response.data;
},
};

View File

@@ -5,7 +5,7 @@ import { IProjectCategory } from '@/types/project/projectCategory.types';
import { IProjectsViewModel } from '@/types/project/projectsViewModel.types';
import { IServerResponse } from '@/types/common.types';
import { IProjectMembersViewModel } from '@/types/projectMember.types';
import { getCsrfToken } from '../api-client';
import { getCsrfToken, refreshCsrfToken } from '../api-client';
import config from '@/config/env';
const rootUrl = '/projects';
@@ -14,9 +14,18 @@ export const projectsApi = createApi({
reducerPath: 'projectsApi',
baseQuery: fetchBaseQuery({
baseUrl: `${config.apiUrl}${API_BASE_URL}`,
prepareHeaders: headers => {
headers.set('X-CSRF-Token', getCsrfToken() || '');
prepareHeaders: async headers => {
// Get CSRF token, refresh if needed
let token = getCsrfToken();
if (!token) {
token = await refreshCsrfToken();
}
if (token) {
headers.set('X-CSRF-Token', token);
}
headers.set('Content-Type', 'application/json');
return headers;
},
credentials: 'include',
}),

View File

@@ -1,5 +1,10 @@
import { IServerResponse } from '@/types/common.types';
import { IGetProjectsRequestBody, IRPTMembersViewModel, IRPTOverviewProjectMember, IRPTProjectsViewModel } from '@/types/reporting/reporting.types';
import {
IGetProjectsRequestBody,
IRPTMembersViewModel,
IRPTOverviewProjectMember,
IRPTProjectsViewModel,
} from '@/types/reporting/reporting.types';
import apiClient from '../api-client';
import { API_BASE_URL } from '@/shared/constants';
import { toQueryString } from '@/utils/toQueryString';
@@ -7,9 +12,7 @@ import { toQueryString } from '@/utils/toQueryString';
const rootUrl = `${API_BASE_URL}/reporting/members`;
export const reportingMembersApiService = {
getMembers: async (
body: any
): Promise<IServerResponse<IRPTMembersViewModel>> => {
getMembers: async (body: any): Promise<IServerResponse<IRPTMembersViewModel>> => {
const q = toQueryString(body);
const url = `${rootUrl}${q}`;
const response = await apiClient.get<IServerResponse<IRPTMembersViewModel>>(url);

View File

@@ -1,5 +1,10 @@
import { IServerResponse } from '@/types/common.types';
import { IGetProjectsRequestBody, IRPTOverviewProjectInfo, IRPTOverviewProjectMember, IRPTProjectsViewModel } from '@/types/reporting/reporting.types';
import {
IGetProjectsRequestBody,
IRPTOverviewProjectInfo,
IRPTOverviewProjectMember,
IRPTProjectsViewModel,
} from '@/types/reporting/reporting.types';
import apiClient from '../api-client';
import { API_BASE_URL } from '@/shared/constants';
import { toQueryString } from '@/utils/toQueryString';
@@ -33,8 +38,11 @@ export const reportingProjectsApiService = {
return response.data;
},
getTasks: async (projectId: string, groupBy: string): Promise<IServerResponse<ITaskListGroup[]>> => {
const q = toQueryString({group: groupBy})
getTasks: async (
projectId: string,
groupBy: string
): Promise<IServerResponse<ITaskListGroup[]>> => {
const q = toQueryString({ group: groupBy });
const url = `${API_BASE_URL}/reporting/overview/project/tasks/${projectId}${q}`;
const response = await apiClient.get<IServerResponse<ITaskListGroup[]>>(url);

View File

@@ -3,12 +3,20 @@ import { toQueryString } from '@/utils/toQueryString';
import apiClient from '../api-client';
import { IServerResponse } from '@/types/common.types';
import { IAllocationViewModel } from '@/types/reporting/reporting-allocation.types';
import { IProjectLogsBreakdown, IRPTTimeMember, IRPTTimeProject, ITimeLogBreakdownReq } from '@/types/reporting/reporting.types';
import {
IProjectLogsBreakdown,
IRPTTimeMember,
IRPTTimeProject,
ITimeLogBreakdownReq,
} from '@/types/reporting/reporting.types';
const rootUrl = `${API_BASE_URL}/reporting`;
export const reportingTimesheetApiService = {
getTimeSheetData: async (body = {}, archived = false): Promise<IServerResponse<IAllocationViewModel>> => {
getTimeSheetData: async (
body = {},
archived = false
): Promise<IServerResponse<IAllocationViewModel>> => {
const q = toQueryString({ archived });
const response = await apiClient.post(`${rootUrl}/allocation/${q}`, body);
return response.data;
@@ -19,24 +27,35 @@ export const reportingTimesheetApiService = {
return response.data;
},
getProjectTimeSheets: async (body = {}, archived = false): Promise<IServerResponse<IRPTTimeProject[]>> => {
getProjectTimeSheets: async (
body = {},
archived = false
): Promise<IServerResponse<IRPTTimeProject[]>> => {
const q = toQueryString({ archived });
const response = await apiClient.post(`${rootUrl}/time-reports/projects/${q}`, body);
return response.data;
},
getMemberTimeSheets: async (body = {}, archived = false): Promise<IServerResponse<IRPTTimeMember[]>> => {
getMemberTimeSheets: async (
body = {},
archived = false
): Promise<IServerResponse<IRPTTimeMember[]>> => {
const q = toQueryString({ archived });
const response = await apiClient.post(`${rootUrl}/time-reports/members/${q}`, body);
return response.data;
},
getProjectTimeLogs: async (body: ITimeLogBreakdownReq): Promise<IServerResponse<IProjectLogsBreakdown[]>> => {
getProjectTimeLogs: async (
body: ITimeLogBreakdownReq
): Promise<IServerResponse<IProjectLogsBreakdown[]>> => {
const response = await apiClient.post(`${rootUrl}/project-timelogs`, body);
return response.data;
},
getProjectEstimatedVsActual: async (body = {}, archived = false): Promise<IServerResponse<IRPTTimeProject[]>> => {
getProjectEstimatedVsActual: async (
body = {},
archived = false
): Promise<IServerResponse<IRPTTimeProject[]>> => {
const q = toQueryString({ archived });
const response = await apiClient.post(`${rootUrl}/time-reports/estimated-vs-actual${q}`, body);
return response.data;

View File

@@ -2,7 +2,13 @@ import { API_BASE_URL } from '@/shared/constants';
import apiClient from '../api-client';
import { IServerResponse } from '@/types/common.types';
import { ITeamMemberViewModel } from '@/types/teamMembers/teamMembersGetResponse.types';
import { DateList, Member, Project, ScheduleData, Settings } from '@/types/schedule/schedule-v2.types';
import {
DateList,
Member,
Project,
ScheduleData,
Settings,
} from '@/types/schedule/schedule-v2.types';
const rootUrl = `${API_BASE_URL}/schedule-gannt-v2`;
@@ -45,16 +51,18 @@ export const scheduleAPIService = {
},
fetchMemberProjects: async ({ id }: { id: string }): Promise<IServerResponse<Project>> => {
const response = await apiClient.get<IServerResponse<Project>>(`${rootUrl}/members/projects/${id}`);
const response = await apiClient.get<IServerResponse<Project>>(
`${rootUrl}/members/projects/${id}`
);
return response.data;
},
submitScheduleData: async ({
schedule
schedule,
}: {
schedule: ScheduleData
schedule: ScheduleData;
}): Promise<IServerResponse<any>> => {
const response = await apiClient.post<IServerResponse<any>>(`${rootUrl}/schedule`, schedule);
return response.data;
}
},
};

View File

@@ -52,8 +52,11 @@ export const profileSettingsApiService = {
return response.data;
},
updateTeamName: async (id: string, body: ITeam): Promise<IServerResponse<ITeam>> => {
const response = await apiClient.put<IServerResponse<ITeam>>(`${rootUrl}/team-name/${id}`, body);
updateTeamName: async (id: string, body: ITeam): Promise<IServerResponse<ITeam>> => {
const response = await apiClient.put<IServerResponse<ITeam>>(
`${rootUrl}/team-name/${id}`,
body
);
return response.data;
},

View File

@@ -21,12 +21,18 @@ export const taskTemplatesApiService = {
const response = await apiClient.get<IServerResponse<ITaskTemplateGetResponse>>(`${url}`);
return response.data;
},
createTemplate: async (body: { name: string, tasks: IProjectTask[] }): Promise<IServerResponse<ITask>> => {
createTemplate: async (body: {
name: string;
tasks: IProjectTask[];
}): Promise<IServerResponse<ITask>> => {
const url = `${rootUrl}`;
const response = await apiClient.post<IServerResponse<ITask>>(`${url}`, body);
return response.data;
},
updateTemplate: async (id: string, body: { name: string, tasks: IProjectTask[] }): Promise<IServerResponse<ITask>> => {
updateTemplate: async (
id: string,
body: { name: string; tasks: IProjectTask[] }
): Promise<IServerResponse<ITask>> => {
const url = `${rootUrl}/${id}`;
const response = await apiClient.put<IServerResponse<ITask>>(`${url}`, body);
return response.data;

View File

@@ -1,12 +1,12 @@
import apiClient from '@/api/api-client';
import { API_BASE_URL } from '@/shared/constants';
import { IServerResponse } from '@/types/common.types';
import apiClient from '@api/api-client';
import { API_BASE_URL } from '@/shared/constants';
import { ITaskPhase } from '@/types/tasks/taskPhase.types';
import { toQueryString } from '@/utils/toQueryString';
const rootUrl = `${API_BASE_URL}/task-phases`;
interface UpdateSortOrderBody {
export interface UpdateSortOrderBody {
from_index: number;
to_index: number;
phases: ITaskPhase[];
@@ -14,9 +14,10 @@ interface UpdateSortOrderBody {
}
export const phasesApiService = {
addPhaseOption: async (projectId: string) => {
addPhaseOption: async (projectId: string, name?: string) => {
const q = toQueryString({ id: projectId, current_project_id: projectId });
const response = await apiClient.post<IServerResponse<ITaskPhase>>(`${rootUrl}${q}`);
const body = name ? { name } : {};
const response = await apiClient.post<IServerResponse<ITaskPhase>>(`${rootUrl}${q}`, body);
return response.data;
},
@@ -43,7 +44,7 @@ export const phasesApiService = {
return response.data;
},
updateNameOfPhase: async (phaseId: string, body: ITaskPhase, projectId: string,) => {
updateNameOfPhase: async (phaseId: string, body: ITaskPhase, projectId: string) => {
const q = toQueryString({ id: projectId, current_project_id: projectId });
const response = await apiClient.put<IServerResponse<ITaskPhase>>(
`${rootUrl}/${phaseId}${q}`,

View File

@@ -60,6 +60,20 @@ export const statusApiService = {
return response.data;
},
updateStatusCategory: async (
statusId: string,
categoryId: string,
currentProjectId: string
): Promise<IServerResponse<ITaskStatus>> => {
const q = toQueryString({ current_project_id: currentProjectId });
const response = await apiClient.put<IServerResponse<ITaskStatus>>(
`${rootUrl}/category/${statusId}${q}`,
{ category_id: categoryId }
);
return response.data;
},
updateStatusOrder: async (
body: ITaskStatusCreateRequest,
currentProjectId: string
@@ -69,8 +83,16 @@ export const statusApiService = {
return response.data;
},
deleteStatus: async (statusId: string, projectId: string, replacingStatusId: string): Promise<IServerResponse<void>> => {
const q = toQueryString({ project: projectId, current_project_id: projectId, replace: replacingStatusId || null });
deleteStatus: async (
statusId: string,
projectId: string,
replacingStatusId: string
): Promise<IServerResponse<void>> => {
const q = toQueryString({
project: projectId,
current_project_id: projectId,
replace: replacingStatusId || null,
});
const response = await apiClient.delete<IServerResponse<void>>(`${rootUrl}/${statusId}${q}`);
return response.data;
},

View File

@@ -1,7 +1,7 @@
import { API_BASE_URL } from "@/shared/constants";
import apiClient from "../api-client";
import { IServerResponse } from "@/types/common.types";
import { ISubTask } from "@/types/tasks/subTask.types";
import { API_BASE_URL } from '@/shared/constants';
import apiClient from '../api-client';
import { IServerResponse } from '@/types/common.types';
import { ISubTask } from '@/types/tasks/subTask.types';
const root = `${API_BASE_URL}/sub-tasks`;
@@ -10,7 +10,4 @@ export const subTasksApiService = {
const response = await apiClient.get(`${root}/${parentTaskId}`);
return response.data;
},
};

View File

@@ -1,5 +1,9 @@
import { IServerResponse } from '@/types/common.types';
import { IProjectAttachmentsViewModel, ITaskAttachment, ITaskAttachmentViewModel } from '@/types/tasks/task-attachment-view-model';
import {
IProjectAttachmentsViewModel,
ITaskAttachment,
ITaskAttachmentViewModel,
} from '@/types/tasks/task-attachment-view-model';
import apiClient from '../api-client';
import { API_BASE_URL } from '@/shared/constants';
import { IAvatarAttachment } from '@/types/avatarAttachment.types';
@@ -8,7 +12,6 @@ import { toQueryString } from '@/utils/toQueryString';
const rootUrl = `${API_BASE_URL}/attachments`;
const taskAttachmentsApiService = {
createTaskAttachment: async (
body: ITaskAttachment
): Promise<IServerResponse<ITaskAttachmentViewModel>> => {
@@ -16,18 +19,26 @@ const taskAttachmentsApiService = {
return response.data;
},
createAvatarAttachment: async (body: IAvatarAttachment): Promise<IServerResponse<{ url: string; }>> => {
createAvatarAttachment: async (
body: IAvatarAttachment
): Promise<IServerResponse<{ url: string }>> => {
const response = await apiClient.post(`${rootUrl}/avatar`, body);
return response.data;
},
getTaskAttachments: async (taskId: string): Promise<IServerResponse<ITaskAttachmentViewModel[]>> => {
getTaskAttachments: async (
taskId: string
): Promise<IServerResponse<ITaskAttachmentViewModel[]>> => {
const response = await apiClient.get(`${rootUrl}/tasks/${taskId}`);
return response.data;
},
getProjectAttachments: async (projectId: string, index: number, size: number ): Promise<IServerResponse<IProjectAttachmentsViewModel>> => {
const q = toQueryString({ index, size });
getProjectAttachments: async (
projectId: string,
index: number,
size: number
): Promise<IServerResponse<IProjectAttachmentsViewModel>> => {
const q = toQueryString({ index, size });
const response = await apiClient.get(`${rootUrl}/project/${projectId}${q}`);
return response.data;
},

View File

@@ -2,10 +2,16 @@ import apiClient from '@api/api-client';
import { API_BASE_URL } from '@/shared/constants';
import { IServerResponse } from '@/types/common.types';
import { toQueryString } from '@/utils/toQueryString';
import { ITaskComment, ITaskCommentsCreateRequest, ITaskCommentViewModel } from '@/types/tasks/task-comments.types';
import {
ITaskComment,
ITaskCommentsCreateRequest,
ITaskCommentViewModel,
} from '@/types/tasks/task-comments.types';
const taskCommentsApiService = {
create: async (data: ITaskCommentsCreateRequest): Promise<IServerResponse<ITaskCommentsCreateRequest>> => {
create: async (
data: ITaskCommentsCreateRequest
): Promise<IServerResponse<ITaskCommentsCreateRequest>> => {
const response = await apiClient.post(`${API_BASE_URL}/task-comments`, data);
return response.data;
},
@@ -21,12 +27,16 @@ const taskCommentsApiService = {
},
deleteAttachment: async (id: string, taskId: string): Promise<IServerResponse<ITaskComment>> => {
const response = await apiClient.delete(`${API_BASE_URL}/task-comments/attachment/${id}/${taskId}`);
const response = await apiClient.delete(
`${API_BASE_URL}/task-comments/attachment/${id}/${taskId}`
);
return response.data;
},
download: async (id: string, filename: string): Promise<IServerResponse<any>> => {
const response = await apiClient.get(`${API_BASE_URL}/task-comments/download?id=${id}&file=${filename}`);
const response = await apiClient.get(
`${API_BASE_URL}/task-comments/download?id=${id}&file=${filename}`
);
return response.data;
},
@@ -35,8 +45,13 @@ const taskCommentsApiService = {
return response.data;
},
updateReaction: async (id: string, body: {reaction_type: string, task_id: string}): Promise<IServerResponse<ITaskComment>> => {
const response = await apiClient.put(`${API_BASE_URL}/task-comments/reaction/${id}${toQueryString(body)}`);
updateReaction: async (
id: string,
body: { reaction_type: string; task_id: string }
): Promise<IServerResponse<ITaskComment>> => {
const response = await apiClient.put(
`${API_BASE_URL}/task-comments/reaction/${id}${toQueryString(body)}`
);
return response.data;
},
};

View File

@@ -1,7 +1,7 @@
import { API_BASE_URL } from "@/shared/constants";
import apiClient from "../api-client";
import { ITaskDependency } from "@/types/tasks/task-dependency.types";
import { IServerResponse } from "@/types/common.types";
import { API_BASE_URL } from '@/shared/constants';
import apiClient from '../api-client';
import { ITaskDependency } from '@/types/tasks/task-dependency.types';
import { IServerResponse } from '@/types/common.types';
const rootUrl = `${API_BASE_URL}/task-dependencies`;
@@ -10,7 +10,9 @@ export const taskDependenciesApiService = {
const response = await apiClient.get(`${rootUrl}/${taskId}`);
return response.data;
},
createTaskDependency: async (body: ITaskDependency): Promise<IServerResponse<ITaskDependency>> => {
createTaskDependency: async (
body: ITaskDependency
): Promise<IServerResponse<ITaskDependency>> => {
const response = await apiClient.post(`${rootUrl}`, body);
return response.data;
},
@@ -18,4 +20,4 @@ export const taskDependenciesApiService = {
const response = await apiClient.delete(`${rootUrl}/${dependencyId}`);
return response.data;
},
};
};

View File

@@ -0,0 +1,21 @@
import { API_BASE_URL } from '@/shared/constants';
import { IServerResponse } from '@/types/common.types';
import { ITaskRecurringSchedule } from '@/types/tasks/task-recurring-schedule';
import apiClient from '../api-client';
const rootUrl = `${API_BASE_URL}/task-recurring`;
export const taskRecurringApiService = {
getTaskRecurringData: async (
schedule_id: string
): Promise<IServerResponse<ITaskRecurringSchedule>> => {
const response = await apiClient.get(`${rootUrl}/${schedule_id}`);
return response.data;
},
updateTaskRecurringData: async (
schedule_id: string,
body: any
): Promise<IServerResponse<ITaskRecurringSchedule>> => {
return apiClient.put(`${rootUrl}/${schedule_id}`, body);
},
};

View File

@@ -1,17 +1,27 @@
import { API_BASE_URL } from "@/shared/constants";
import apiClient from "../api-client";
import { IServerResponse } from "@/types/common.types";
import { ITaskLogViewModel } from "@/types/tasks/task-log-view.types";
import { API_BASE_URL } from '@/shared/constants';
import apiClient from '../api-client';
import { IServerResponse } from '@/types/common.types';
import { ITaskLogViewModel } from '@/types/tasks/task-log-view.types';
const rootUrl = `${API_BASE_URL}/task-time-log`;
export interface IRunningTimer {
task_id: string;
start_time: string;
task_name: string;
project_id: string;
project_name: string;
parent_task_id?: string;
parent_task_name?: string;
}
export const taskTimeLogsApiService = {
getByTask: async (id: string) : Promise<IServerResponse<ITaskLogViewModel[]>> => {
getByTask: async (id: string): Promise<IServerResponse<ITaskLogViewModel[]>> => {
const response = await apiClient.get(`${rootUrl}/task/${id}`);
return response.data;
},
delete: async (id: string, taskId: string) : Promise<IServerResponse<void>> => {
delete: async (id: string, taskId: string): Promise<IServerResponse<void>> => {
const response = await apiClient.delete(`${rootUrl}/${id}?task=${taskId}`);
return response.data;
},
@@ -26,6 +36,11 @@ export const taskTimeLogsApiService = {
return response.data;
},
getRunningTimers: async (): Promise<IServerResponse<IRunningTimer[]>> => {
const response = await apiClient.get(`${rootUrl}/running-timers`);
return response.data;
},
exportToExcel(taskId: string) {
window.location.href = `${import.meta.env.VITE_API_URL}${API_BASE_URL}/task-time-log/export/${taskId}`;
},

View File

@@ -1,23 +1,23 @@
import { ITaskListColumn } from "@/types/tasks/taskList.types";
import apiClient from "../api-client";
import { IServerResponse } from "@/types/common.types";
import { ITaskListColumn } from '@/types/tasks/taskList.types';
import apiClient from '../api-client';
import { IServerResponse } from '@/types/common.types';
export const tasksCustomColumnsService = {
getCustomColumns: async (projectId: string): Promise<IServerResponse<ITaskListColumn[]>> => {
const response = await apiClient.get(`/api/v1/custom-columns/project/${projectId}/columns`);
return response.data;
},
updateTaskCustomColumnValue: async (
taskId: string,
columnKey: string,
taskId: string,
columnKey: string,
value: string | number | boolean,
projectId: string
): Promise<IServerResponse<any>> => {
const response = await apiClient.put(`/api/v1/tasks/${taskId}/custom-column`, {
column_key: columnKey,
value: value,
project_id: projectId
project_id: projectId,
});
return response.data;
},
@@ -35,7 +35,7 @@ export const tasksCustomColumnsService = {
): Promise<IServerResponse<any>> => {
const response = await apiClient.post('/api/v1/custom-columns', {
project_id: projectId,
...columnData
...columnData,
});
return response.data;
},
@@ -63,7 +63,10 @@ export const tasksCustomColumnsService = {
projectId: string,
item: ITaskListColumn
): Promise<IServerResponse<ITaskListColumn>> => {
const response = await apiClient.put(`/api/v1/custom-columns/project/${projectId}/columns`, item);
const response = await apiClient.put(
`/api/v1/custom-columns/project/${projectId}/columns`,
item
);
return response.data;
}
},
};

View File

@@ -28,6 +28,24 @@ export interface ITaskListConfigV2 {
parent_task?: string;
group?: string;
isSubtasksInclude: boolean;
include_empty?: string; // Include empty groups in response
customColumns?: boolean; // Include custom column values in response
}
export interface ITaskListV3Response {
groups: Array<{
id: string;
title: string;
groupType: 'status' | 'priority' | 'phase';
groupValue: string;
collapsed: boolean;
tasks: any[];
taskIds: string[];
color: string;
}>;
allTasks: any[];
grouping: string;
totalTasks: number;
}
export const tasksApiService = {
@@ -114,9 +132,70 @@ export const tasksApiService = {
return response.data;
},
getTaskDependencyStatus: async (taskId: string, statusId: string): Promise<IServerResponse<{ can_continue: boolean }>> => {
const q = toQueryString({taskId, statusId});
getTaskDependencyStatus: async (
taskId: string,
statusId: string
): Promise<IServerResponse<{ can_continue: boolean }>> => {
const q = toQueryString({ taskId, statusId });
const response = await apiClient.get(`${rootUrl}/dependency-status${q}`);
return response.data;
},
getTaskListV3: async (
config: ITaskListConfigV2
): Promise<IServerResponse<ITaskListV3Response>> => {
const q = toQueryString({ ...config, include_empty: 'true' });
const response = await apiClient.get(`${rootUrl}/list/v3/${config.id}${q}`);
return response.data;
},
refreshTaskProgress: async (projectId: string): Promise<IServerResponse<{ message: string }>> => {
const response = await apiClient.post(`${rootUrl}/refresh-progress/${projectId}`);
return response.data;
},
getTaskProgressStatus: async (
projectId: string
): Promise<
IServerResponse<{
projectId: string;
totalTasks: number;
completedTasks: number;
avgProgress: number;
lastUpdated: string;
completionPercentage: number;
}>
> => {
const response = await apiClient.get(`${rootUrl}/progress-status/${projectId}`);
return response.data;
},
// API method to reorder tasks
reorderTasks: async (params: {
taskIds: string[];
newOrder: number[];
projectId: string;
}): Promise<IServerResponse<{ done: boolean }>> => {
const response = await apiClient.post(`${rootUrl}/reorder`, {
task_ids: params.taskIds,
new_order: params.newOrder,
project_id: params.projectId,
});
return response.data;
},
// API method to update task group (status, priority, phase)
updateTaskGroup: async (params: {
taskId: string;
groupType: 'status' | 'priority' | 'phase';
groupValue: string;
projectId: string;
}): Promise<IServerResponse<{ done: boolean }>> => {
const response = await apiClient.put(`${rootUrl}/${params.taskId}/group`, {
group_type: params.groupType,
group_value: params.groupValue,
project_id: params.projectId,
});
return response.data;
},
};

View File

@@ -44,7 +44,9 @@ export const teamMembersApiService = {
return response.data;
},
getAll: async (projectId: string | null = null): Promise<IServerResponse<ITeamMemberViewModel[]>> => {
getAll: async (
projectId: string | null = null
): Promise<IServerResponse<ITeamMemberViewModel[]>> => {
const params = new URLSearchParams(projectId ? { project: projectId } : {});
const response = await apiClient.get<IServerResponse<ITeamMemberViewModel[]>>(
`${rootUrl}/all${params.toString() ? '?' + params.toString() : ''}`

View File

@@ -14,11 +14,8 @@ const rootUrl = `${API_BASE_URL}/teams`;
export const teamsApiService = {
getTeams: async (): Promise<IServerResponse<ITeamGetResponse[]>> => {
const response = await apiClient.get<IServerResponse<ITeamGetResponse[]>>(
`${rootUrl}`
);
const response = await apiClient.get<IServerResponse<ITeamGetResponse[]>>(`${rootUrl}`);
return response.data;
},
setActiveTeam: async (teamId: string): Promise<IServerResponse<ITeamActivateResponse>> => {
@@ -29,23 +26,18 @@ export const teamsApiService = {
return response.data;
},
createTeam: async (team: IOrganizationTeam): Promise<IServerResponse<ITeam>> => {
const response = await apiClient.post<IServerResponse<ITeam>>(`${rootUrl}`, team);
return response.data;
},
getInvitations: async (): Promise<IServerResponse<ITeamInvites[]>> => {
const response = await apiClient.get<IServerResponse<ITeamInvites[]>>(
`${rootUrl}/invites`
);
const response = await apiClient.get<IServerResponse<ITeamInvites[]>>(`${rootUrl}/invites`);
return response.data;
},
acceptInvitation: async (body: IAcceptTeamInvite): Promise<IServerResponse<ITeamInvites>> => {
const response = await apiClient.put<IServerResponse<ITeamInvites>>(`${rootUrl}`, body);
return response.data;
}
},
};

View File

@@ -0,0 +1,123 @@
import { Middleware } from '@reduxjs/toolkit';
// Performance monitoring for Redux store
export interface PerformanceMetrics {
actionType: string;
duration: number;
timestamp: number;
stateSize: number;
}
class ReduxPerformanceMonitor {
private metrics: PerformanceMetrics[] = [];
private maxMetrics = 100; // Keep last 100 metrics
private slowActionThreshold = 50; // Log actions taking more than 50ms
logMetric(metric: PerformanceMetrics) {
this.metrics.push(metric);
// Keep only recent metrics
if (this.metrics.length > this.maxMetrics) {
this.metrics = this.metrics.slice(-this.maxMetrics);
}
// Log slow actions in development
if (process.env.NODE_ENV === 'development' && metric.duration > this.slowActionThreshold) {
console.warn(`Slow Redux action detected: ${metric.actionType} took ${metric.duration}ms`);
}
}
getMetrics() {
return [...this.metrics];
}
getSlowActions(threshold = this.slowActionThreshold) {
return this.metrics.filter(m => m.duration > threshold);
}
getAverageActionTime() {
if (this.metrics.length === 0) return 0;
const total = this.metrics.reduce((sum, m) => sum + m.duration, 0);
return total / this.metrics.length;
}
reset() {
this.metrics = [];
}
}
export const performanceMonitor = new ReduxPerformanceMonitor();
// Redux middleware for performance monitoring
export const performanceMiddleware: Middleware = store => next => (action: any) => {
const start = performance.now();
const result = next(action);
const end = performance.now();
const duration = end - start;
// Calculate approximate state size (in development only)
let stateSize = 0;
if (process.env.NODE_ENV === 'development') {
try {
stateSize = JSON.stringify(store.getState()).length;
} catch (e) {
stateSize = -1; // Indicates serialization error
}
}
performanceMonitor.logMetric({
actionType: action.type || 'unknown',
duration,
timestamp: Date.now(),
stateSize,
});
return result;
};
// Hook to access performance metrics in components
export function useReduxPerformance() {
return {
metrics: performanceMonitor.getMetrics(),
slowActions: performanceMonitor.getSlowActions(),
averageTime: performanceMonitor.getAverageActionTime(),
reset: () => performanceMonitor.reset(),
};
}
// Utility to detect potential performance issues
export function analyzeReduxPerformance() {
const metrics = performanceMonitor.getMetrics();
const analysis = {
totalActions: metrics.length,
slowActions: performanceMonitor.getSlowActions().length,
averageActionTime: performanceMonitor.getAverageActionTime(),
largestStateSize: Math.max(...metrics.map(m => m.stateSize)),
mostFrequentActions: {} as Record<string, number>,
recommendations: [] as string[],
};
// Count action frequencies
metrics.forEach(m => {
analysis.mostFrequentActions[m.actionType] =
(analysis.mostFrequentActions[m.actionType] || 0) + 1;
});
// Generate recommendations
if (analysis.slowActions > analysis.totalActions * 0.1) {
analysis.recommendations.push('Consider optimizing selectors with createSelector');
}
if (analysis.largestStateSize > 1000000) {
// 1MB
analysis.recommendations.push('State size is large - consider normalizing data');
}
if (analysis.averageActionTime > 20) {
analysis.recommendations.push('Average action time is high - check for expensive reducers');
}
return analysis;
}

View File

@@ -1,9 +1,15 @@
import { RouteObject } from 'react-router-dom';
import AccountSetup from '@/pages/account-setup/account-setup';
import { lazy, Suspense } from 'react';
import { SuspenseFallback } from '@/components/suspense-fallback/suspense-fallback';
const AccountSetup = lazy(() => import('@/pages/account-setup/account-setup'));
const accountSetupRoute: RouteObject = {
path: '/worklenz/setup',
element: <AccountSetup />,
element: (
<Suspense fallback={<SuspenseFallback />}>
<AccountSetup />
</Suspense>
),
};
export default accountSetupRoute;

View File

@@ -1,8 +1,10 @@
import { RouteObject } from 'react-router-dom';
import AdminCenterLayout from '@/layouts/admin-center-layout';
import { Suspense } from 'react';
import { adminCenterItems } from '@/pages/admin-center/admin-center-constants';
import { Navigate } from 'react-router-dom';
import { useAuthService } from '@/hooks/useAuth';
import { SuspenseFallback } from '@/components/suspense-fallback/suspense-fallback';
import AdminCenterLayout from '@/layouts/AdminCenterLayout';
const AdminCenterGuard = ({ children }: { children: React.ReactNode }) => {
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
@@ -24,7 +26,11 @@ const adminCenterRoutes: RouteObject[] = [
),
children: adminCenterItems.map(item => ({
path: item.endpoint,
element: item.element,
element: (
<Suspense fallback={<SuspenseFallback />}>
{item.element}
</Suspense>
),
})),
},
];

View File

@@ -1,20 +1,20 @@
import { lazy, Suspense } from 'react';
import AuthLayout from '@/layouts/AuthLayout';
import LoginPage from '@/pages/auth/login-page';
import SignupPage from '@/pages/auth/signup-page';
import ForgotPasswordPage from '@/pages/auth/forgot-password-page';
import LoggingOutPage from '@/pages/auth/logging-out';
import AuthenticatingPage from '@/pages/auth/authenticating';
import { Navigate } from 'react-router-dom';
import VerifyResetEmailPage from '@/pages/auth/verify-reset-email';
import { Suspense } from 'react';
import { SuspenseFallback } from '@/components/suspense-fallback/suspense-fallback';
// Lazy load auth page components for better code splitting
const LoginPage = lazy(() => import('@/pages/auth/login-page'));
const SignupPage = lazy(() => import('@/pages/auth/signup-page'));
const ForgotPasswordPage = lazy(() => import('@/pages/auth/forgot-password-page'));
const LoggingOutPage = lazy(() => import('@/pages/auth/logging-out'));
const AuthenticatingPage = lazy(() => import('@/pages/auth/authenticating'));
const VerifyResetEmailPage = lazy(() => import('@/pages/auth/verify-reset-email'));
const authRoutes = [
{
path: '/auth',
element: (
<AuthLayout />
),
element: <AuthLayout />,
children: [
{
path: '',
@@ -22,27 +22,51 @@ const authRoutes = [
},
{
path: 'login',
element: <LoginPage />,
element: (
<Suspense fallback={<SuspenseFallback />}>
<LoginPage />
</Suspense>
),
},
{
path: 'signup',
element: <SignupPage />,
element: (
<Suspense fallback={<SuspenseFallback />}>
<SignupPage />
</Suspense>
),
},
{
path: 'forgot-password',
element: <ForgotPasswordPage />,
element: (
<Suspense fallback={<SuspenseFallback />}>
<ForgotPasswordPage />
</Suspense>
),
},
{
path: 'logging-out',
element: <LoggingOutPage />,
element: (
<Suspense fallback={<SuspenseFallback />}>
<LoggingOutPage />
</Suspense>
),
},
{
path: 'authenticating',
element: <AuthenticatingPage />,
element: (
<Suspense fallback={<SuspenseFallback />}>
<AuthenticatingPage />
</Suspense>
),
},
{
path: 'verify-reset-email/:user/:hash',
element: <VerifyResetEmailPage />,
element: (
<Suspense fallback={<SuspenseFallback />}>
<VerifyResetEmailPage />
</Suspense>
),
},
],
},

View File

@@ -1,4 +1,5 @@
import { createBrowserRouter, Navigate, RouteObject, useLocation } from 'react-router-dom';
import { lazy, Suspense, memo, useMemo } from 'react';
import rootRoutes from './root-routes';
import authRoutes from './auth-routes';
import mainRoutes, { licenseExpiredRoute } from './main-routes';
@@ -8,116 +9,88 @@ import reportingRoutes from './reporting-routes';
import { useAuthService } from '@/hooks/useAuth';
import { AuthenticatedLayout } from '@/layouts/AuthenticatedLayout';
import ErrorBoundary from '@/components/ErrorBoundary';
import NotFoundPage from '@/pages/404-page/404-page';
import { SuspenseFallback } from '@/components/suspense-fallback/suspense-fallback';
import { ISUBSCRIPTION_TYPE } from '@/shared/constants';
import LicenseExpired from '@/pages/license-expired/license-expired';
// Lazy load the NotFoundPage component for better code splitting
const NotFoundPage = lazy(() => import('@/pages/404-page/404-page'));
interface GuardProps {
children: React.ReactNode;
}
export const AuthGuard = ({ children }: GuardProps) => {
const isAuthenticated = useAuthService().isAuthenticated();
const location = useLocation();
// Route-based code splitting utility
const withCodeSplitting = (Component: React.LazyExoticComponent<React.ComponentType<any>>) => {
return memo(() => (
<Suspense fallback={<SuspenseFallback />}>
<Component />
</Suspense>
));
};
// Memoized guard components with defensive programming
import { useAuthStatus } from '@/hooks/useAuthStatus';
export const AuthGuard = memo(({ children }: GuardProps) => {
const { isAuthenticated, location } = useAuthStatus();
if (!isAuthenticated) {
return <Navigate to="/auth" state={{ from: location }} replace />;
}
return <>{children}</>;
};
});
export const AdminGuard = ({ children }: GuardProps) => {
const isAuthenticated = useAuthService().isAuthenticated();
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
const currentSession = useAuthService().getCurrentSession();
const isFreePlan = currentSession?.subscription_type === ISUBSCRIPTION_TYPE.FREE;
const location = useLocation();
AuthGuard.displayName = 'AuthGuard';
export const AdminGuard = memo(({ children }: GuardProps) => {
const { isAuthenticated, isAdmin, location } = useAuthStatus();
if (!isAuthenticated) {
return <Navigate to="/auth" state={{ from: location }} replace />;
}
if (!isOwnerOrAdmin || isFreePlan) {
return <Navigate to="/worklenz/unauthorized" replace />;
if (!isAdmin) {
return <Navigate to="/worklenz/unauthorized" />;
}
return <>{children}</>;
};
});
AdminGuard.displayName = 'AdminGuard';
export const LicenseExpiryGuard = memo(({ children }: GuardProps) => {
const { isLicenseExpired, location } = useAuthStatus();
export const LicenseExpiryGuard = ({ children }: GuardProps) => {
const isAuthenticated = useAuthService().isAuthenticated();
const currentSession = useAuthService().getCurrentSession();
const location = useLocation();
const isAdminCenterRoute = location.pathname.includes('/worklenz/admin-center');
const isLicenseExpiredRoute = location.pathname === '/worklenz/license-expired';
// Don't check or redirect if we're already on the license-expired page
if (isLicenseExpiredRoute) {
return <>{children}</>;
}
// Check if trial is expired more than 7 days or if is_expired flag is set
const isLicenseExpiredMoreThan7Days = () => {
// Quick bail if no session data is available
if (!currentSession) {
return false;
}
// Check is_expired flag first
if (currentSession.is_expired) {
// If no trial_expire_date exists but is_expired is true, defer to backend check
if (!currentSession.trial_expire_date) {
return true;
}
// If there is a trial_expire_date, check if it's more than 7 days past
const today = new Date();
const expiryDate = new Date(currentSession.trial_expire_date);
const diffTime = today.getTime() - expiryDate.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
// Redirect if more than 7 days past expiration
return diffDays > 7;
}
// If not marked as expired but has trial_expire_date, do a date check
if (currentSession.subscription_type === ISUBSCRIPTION_TYPE.TRIAL && currentSession.trial_expire_date) {
const today = new Date();
const expiryDate = new Date(currentSession.trial_expire_date);
const diffTime = today.getTime() - expiryDate.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
// If expired more than 7 days, redirect
return diffDays > 7;
}
// No expiration data found
return false;
};
// Add this explicit check and log the result
const shouldRedirect = isAuthenticated && isLicenseExpiredMoreThan7Days() && !isAdminCenterRoute;
if (shouldRedirect) {
if (isLicenseExpired && !isAdminCenterRoute && !isLicenseExpiredRoute) {
return <Navigate to="/worklenz/license-expired" replace />;
}
return <>{children}</>;
};
});
export const SetupGuard = ({ children }: GuardProps) => {
const isAuthenticated = useAuthService().isAuthenticated();
const location = useLocation();
LicenseExpiryGuard.displayName = 'LicenseExpiryGuard';
export const SetupGuard = memo(({ children }: GuardProps) => {
const { isAuthenticated, isSetupComplete, location } = useAuthStatus();
if (!isAuthenticated) {
return <Navigate to="/auth" state={{ from: location }} replace />;
}
return <>{children}</>;
};
if (!isSetupComplete) {
return <Navigate to="/worklenz/setup" />;
}
// Helper to wrap routes with guards
return <>{children}</>;
});
SetupGuard.displayName = 'SetupGuard';
// Optimized route wrapping function with Suspense boundaries
const wrapRoutes = (
routes: RouteObject[],
Guard: React.ComponentType<{ children: React.ReactNode }>
@@ -125,7 +98,11 @@ const wrapRoutes = (
return routes.map(route => {
const wrappedRoute = {
...route,
element: <Guard>{route.element}</Guard>,
element: (
<Suspense fallback={<SuspenseFallback />}>
<Guard>{route.element}</Guard>
</Suspense>
),
};
if (route.children) {
@@ -140,34 +117,37 @@ const wrapRoutes = (
});
};
// Static license expired component that doesn't rely on translations or authentication
const StaticLicenseExpired = () => {
// Optimized static license expired component
const StaticLicenseExpired = memo(() => {
return (
<div style={{
marginTop: 65,
minHeight: '90vh',
backgroundColor: '#f5f5f5',
padding: '20px',
display: 'flex',
justifyContent: 'center',
alignItems: 'center'
}}>
<div style={{
background: 'white',
padding: '30px',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
textAlign: 'center',
maxWidth: '600px'
}}>
<div
style={{
marginTop: 65,
minHeight: '90vh',
backgroundColor: '#f5f5f5',
padding: '20px',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
>
<div
style={{
background: 'white',
padding: '30px',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
textAlign: 'center',
maxWidth: '600px',
}}
>
<h1 style={{ fontSize: '24px', color: '#faad14', marginBottom: '16px' }}>
Your Worklenz trial has expired!
</h1>
<p style={{ fontSize: '16px', color: '#555', marginBottom: '24px' }}>
Please upgrade now to continue using Worklenz.
</p>
<button
<button
style={{
backgroundColor: '#1890ff',
color: 'white',
@@ -175,32 +155,36 @@ const StaticLicenseExpired = () => {
padding: '8px 16px',
borderRadius: '4px',
fontSize: '16px',
cursor: 'pointer'
cursor: 'pointer',
}}
onClick={() => window.location.href = '/worklenz/admin-center/billing'}
onClick={() => (window.location.href = '/worklenz/admin-center/billing')}
>
Upgrade now
</button>
</div>
</div>
);
};
});
StaticLicenseExpired.displayName = 'StaticLicenseExpired';
// Create route arrays (moved outside of useMemo to avoid hook violations)
const publicRoutes = [...rootRoutes, ...authRoutes, notFoundRoute];
const publicRoutes = [
...rootRoutes,
...authRoutes,
notFoundRoute
];
const protectedMainRoutes = wrapRoutes(mainRoutes, AuthGuard);
const adminRoutes = wrapRoutes(reportingRoutes, AdminGuard);
const setupRoutes = wrapRoutes([accountSetupRoute], SetupGuard);
// Apply LicenseExpiryGuard to all protected routes
// License expiry check function
const withLicenseExpiryCheck = (routes: RouteObject[]): RouteObject[] => {
return routes.map(route => {
const wrappedRoute = {
...route,
element: <LicenseExpiryGuard>{route.element}</LicenseExpiryGuard>,
element: (
<Suspense fallback={<SuspenseFallback />}>
<LicenseExpiryGuard>{route.element}</LicenseExpiryGuard>
</Suspense>
),
};
if (route.children) {
@@ -213,18 +197,36 @@ const withLicenseExpiryCheck = (routes: RouteObject[]): RouteObject[] => {
const licenseCheckedMainRoutes = withLicenseExpiryCheck(protectedMainRoutes);
const router = createBrowserRouter([
// Create optimized router with future flags for better performance
const router = createBrowserRouter(
[
{
element: (
<ErrorBoundary>
<AuthenticatedLayout />
</ErrorBoundary>
),
errorElement: (
<ErrorBoundary>
<Suspense fallback={<SuspenseFallback />}>
<NotFoundPage />
</Suspense>
</ErrorBoundary>
),
children: [...licenseCheckedMainRoutes, ...adminRoutes, ...setupRoutes, licenseExpiredRoute],
},
...publicRoutes,
],
{
element: <ErrorBoundary><AuthenticatedLayout /></ErrorBoundary>,
errorElement: <ErrorBoundary><NotFoundPage /></ErrorBoundary>,
children: [
...licenseCheckedMainRoutes,
...adminRoutes,
...setupRoutes,
licenseExpiredRoute,
],
},
...publicRoutes,
]);
// Enable React Router future features for better performance
future: {
v7_relativeSplatPath: true,
v7_fetcherPersist: true,
v7_normalizeFormMethod: true,
v7_partialHydration: true,
v7_skipActionErrorRevalidation: true,
},
}
);
export default router;

View File

@@ -1,32 +1,53 @@
import { RouteObject } from 'react-router-dom';
import { lazy, Suspense } from 'react';
import MainLayout from '@/layouts/MainLayout';
import HomePage from '@/pages/home/home-page';
import ProjectList from '@/pages/projects/project-list';
import settingsRoutes from './settings-routes';
import adminCenterRoutes from './admin-center-routes';
import Schedule from '@/pages/schedule/schedule';
import ProjectTemplateEditView from '@/pages/settings/project-templates/projectTemplateEditView/ProjectTemplateEditView';
import LicenseExpired from '@/pages/license-expired/license-expired';
import ProjectView from '@/pages/projects/projectView/project-view';
import Unauthorized from '@/pages/unauthorized/unauthorized';
import { useAuthService } from '@/hooks/useAuth';
import { Navigate, useLocation } from 'react-router-dom';
import { SuspenseFallback } from '@/components/suspense-fallback/suspense-fallback';
// Define AdminGuard component first
// Lazy load page components for better code splitting
const HomePage = lazy(() => import('@/pages/home/home-page'));
const ProjectList = lazy(() => import('@/pages/projects/project-list'));
const Schedule = lazy(() => import('@/pages/schedule/schedule'));
const ProjectTemplateEditView = lazy(
() => import('@/pages/settings/project-templates/projectTemplateEditView/ProjectTemplateEditView')
);
const LicenseExpired = lazy(() => import('@/pages/license-expired/license-expired'));
const ProjectView = lazy(() => import('@/pages/projects/projectView/project-view'));
const Unauthorized = lazy(() => import('@/pages/unauthorized/unauthorized'));
// Define AdminGuard component with defensive programming
const AdminGuard = ({ children }: { children: React.ReactNode }) => {
const isAuthenticated = useAuthService().isAuthenticated();
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
const authService = useAuthService();
const location = useLocation();
if (!isAuthenticated) {
return <Navigate to="/auth" state={{ from: location }} replace />;
}
try {
// Defensive checks to ensure authService and its methods exist
if (
!authService ||
typeof authService.isAuthenticated !== 'function' ||
typeof authService.isOwnerOrAdmin !== 'function'
) {
// If auth service is not ready, render children (don't block)
return <>{children}</>;
}
if (!isOwnerOrAdmin) {
return <Navigate to="/worklenz/unauthorized" replace />;
}
if (!authService.isAuthenticated()) {
return <Navigate to="/auth" state={{ from: location }} replace />;
}
return <>{children}</>;
if (!authService.isOwnerOrAdmin()) {
return <Navigate to="/worklenz/unauthorized" replace />;
}
return <>{children}</>;
} catch (error) {
console.error('Error in AdminGuard (main-routes):', error);
// On error, render children to prevent complete blocking
return <>{children}</>;
}
};
const mainRoutes: RouteObject[] = [
@@ -34,18 +55,57 @@ const mainRoutes: RouteObject[] = [
path: '/worklenz',
element: <MainLayout />,
children: [
{ path: 'home', element: <HomePage /> },
{ path: 'projects', element: <ProjectList /> },
{ index: true, element: <Navigate to="home" replace /> },
{
path: 'home',
element: (
<Suspense fallback={<SuspenseFallback />}>
<HomePage />
</Suspense>
),
},
{
path: 'projects',
element: (
<Suspense fallback={<SuspenseFallback />}>
<ProjectList />
</Suspense>
),
},
{
path: 'schedule',
element: <AdminGuard><Schedule /></AdminGuard>
element: (
<Suspense fallback={<SuspenseFallback />}>
<AdminGuard>
<Schedule />
</AdminGuard>
</Suspense>
),
},
{
path: `projects/:projectId`,
element: (
<Suspense fallback={<SuspenseFallback />}>
<ProjectView />
</Suspense>
),
},
{ path: `projects/:projectId`, element: <ProjectView /> },
{
path: `settings/project-templates/edit/:templateId/:templateName`,
element: <ProjectTemplateEditView />,
element: (
<Suspense fallback={<SuspenseFallback />}>
<ProjectTemplateEditView />
</Suspense>
),
},
{
path: 'unauthorized',
element: (
<Suspense fallback={<SuspenseFallback />}>
<Unauthorized />
</Suspense>
),
},
{ path: 'unauthorized', element: <Unauthorized /> },
...settingsRoutes,
...adminCenterRoutes,
],
@@ -57,8 +117,15 @@ export const licenseExpiredRoute: RouteObject = {
path: '/worklenz',
element: <MainLayout />,
children: [
{ path: 'license-expired', element: <LicenseExpired /> }
]
{
path: 'license-expired',
element: (
<Suspense fallback={<SuspenseFallback />}>
<LicenseExpired />
</Suspense>
),
},
],
};
export default mainRoutes;

View File

@@ -1,10 +1,16 @@
import React from 'react';
import React, { lazy, Suspense } from 'react';
import { RouteObject } from 'react-router-dom';
import NotFoundPage from '@/pages/404-page/404-page';
import { SuspenseFallback } from '@/components/suspense-fallback/suspense-fallback';
const NotFoundPage = lazy(() => import('@/pages/404-page/404-page'));
const notFoundRoute: RouteObject = {
path: '*',
element: <NotFoundPage />,
element: (
<Suspense fallback={<SuspenseFallback />}>
<NotFoundPage />
</Suspense>
),
};
export default notFoundRoute;

View File

@@ -1,6 +1,8 @@
import { RouteObject } from 'react-router-dom';
import { Suspense } from 'react';
import ReportingLayout from '@/layouts/ReportingLayout';
import { ReportingMenuItems, reportingsItems } from '@/lib/reporting/reporting-constants';
import { SuspenseFallback } from '@/components/suspense-fallback/suspense-fallback';
// function to flatten nested menu items
const flattenItems = (items: ReportingMenuItems[]): ReportingMenuItems[] => {
@@ -20,7 +22,11 @@ const reportingRoutes: RouteObject[] = [
element: <ReportingLayout />,
children: flattenedItems.map(item => ({
path: item.endpoint,
element: item.element,
element: (
<Suspense fallback={<SuspenseFallback />}>
{item.element}
</Suspense>
),
})),
},
];

View File

@@ -1,10 +1,18 @@
import { RouteObject } from 'react-router-dom';
import { Navigate } from 'react-router-dom';
import { Suspense } from 'react';
import SettingsLayout from '@/layouts/SettingsLayout';
import { settingsItems } from '@/lib/settings/settings-constants';
import { useAuthService } from '@/hooks/useAuth';
import { SuspenseFallback } from '@/components/suspense-fallback/suspense-fallback';
const SettingsGuard = ({ children, adminRequired }: { children: React.ReactNode; adminRequired: boolean }) => {
const SettingsGuard = ({
children,
adminRequired,
}: {
children: React.ReactNode;
adminRequired: boolean;
}) => {
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
if (adminRequired && !isOwnerOrAdmin) {
@@ -21,9 +29,9 @@ const settingsRoutes: RouteObject[] = [
children: settingsItems.map(item => ({
path: item.endpoint,
element: (
<SettingsGuard adminRequired={!!item.adminOnly}>
{item.element}
</SettingsGuard>
<Suspense fallback={<SuspenseFallback />}>
<SettingsGuard adminRequired={!!item.adminOnly}>{item.element}</SettingsGuard>
</Suspense>
),
})),
},

View File

@@ -0,0 +1,17 @@
import { redirect } from 'react-router-dom';
import { store } from '../store';
import { verifyAuthentication } from '@/features/auth/authSlice';
export const authLoader = async () => {
const session = await store.dispatch(verifyAuthentication()).unwrap();
if (!session.user) {
return redirect('/auth/login');
}
if (session.user.is_expired) {
return redirect('/worklenz/license-expired');
}
return session;
};

View File

@@ -0,0 +1,75 @@
import { createSelector } from '@reduxjs/toolkit';
import { RootState } from './store';
// Memoized selectors for better performance
// These prevent unnecessary re-renders when state hasn't actually changed
// Auth selectors
export const selectAuth = (state: RootState) => state.auth;
export const selectUser = (state: RootState) => state.userReducer;
export const selectIsAuthenticated = createSelector([selectAuth], auth => !!auth.user);
// Project selectors
export const selectProjects = (state: RootState) => state.projectsReducer;
export const selectCurrentProject = (state: RootState) => state.projectReducer;
export const selectProjectMembers = (state: RootState) => state.projectMemberReducer;
// Task selectors
export const selectTasks = (state: RootState) => state.taskReducer;
export const selectTaskManagement = (state: RootState) => state.taskManagement;
export const selectTaskSelection = (state: RootState) => state.taskManagementSelection;
// UI State selectors
export const selectTheme = (state: RootState) => state.themeReducer;
export const selectLocale = (state: RootState) => state.localesReducer;
export const selectAlerts = (state: RootState) => state.alertsReducer;
// Board and Project View selectors
export const selectBoard = (state: RootState) => state.boardReducer;
export const selectProjectView = (state: RootState) => state.projectViewReducer;
export const selectProjectDrawer = (state: RootState) => state.projectDrawerReducer;
// Task attributes selectors
export const selectTaskPriorities = (state: RootState) => state.priorityReducer;
export const selectTaskLabels = (state: RootState) => state.taskLabelsReducer;
export const selectTaskStatuses = (state: RootState) => state.taskStatusReducer;
export const selectTaskDrawer = (state: RootState) => state.taskDrawerReducer;
// Settings selectors
export const selectMembers = (state: RootState) => state.memberReducer;
export const selectClients = (state: RootState) => state.clientReducer;
export const selectJobs = (state: RootState) => state.jobReducer;
export const selectTeams = (state: RootState) => state.teamReducer;
export const selectCategories = (state: RootState) => state.categoriesReducer;
export const selectLabels = (state: RootState) => state.labelReducer;
// Reporting selectors
export const selectReporting = (state: RootState) => state.reportingReducer;
export const selectProjectReports = (state: RootState) => state.projectReportsReducer;
export const selectMemberReports = (state: RootState) => state.membersReportsReducer;
export const selectTimeReports = (state: RootState) => state.timeReportsOverviewReducer;
// Admin and billing selectors
export const selectAdminCenter = (state: RootState) => state.adminCenterReducer;
export const selectBilling = (state: RootState) => state.billingReducer;
// Schedule and date selectors
export const selectSchedule = (state: RootState) => state.scheduleReducer;
export const selectDate = (state: RootState) => state.dateReducer;
// Feature-specific selectors
export const selectHomePage = (state: RootState) => state.homePageReducer;
export const selectAccountSetup = (state: RootState) => state.accountSetupReducer;
export const selectRoadmap = (state: RootState) => state.roadmapReducer;
export const selectGroupByFilter = (state: RootState) => state.groupByFilterDropdownReducer;
// Memoized computed selectors for common use cases
export const selectHasActiveProject = createSelector(
[selectCurrentProject],
project => !!project && Object.keys(project).length > 0
);
export const selectIsLoading = createSelector([selectTasks, selectProjects], (tasks, projects) => {
// Check if any major feature is loading
return (tasks as any)?.loading || (projects as any)?.loading;
});

View File

@@ -1,4 +1,5 @@
import { configureStore } from '@reduxjs/toolkit';
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
// Auth & User
import authReducer from '@features/auth/authSlice';
@@ -43,6 +44,7 @@ import priorityReducer from '@features/taskAttributes/taskPrioritySlice';
import taskLabelsReducer from '@features/taskAttributes/taskLabelSlice';
import taskStatusReducer, { deleteStatus } from '@features/taskAttributes/taskStatusSlice';
import taskDrawerReducer from '@features/task-drawer/task-drawer.slice';
import enhancedKanbanReducer from '@features/enhanced-kanban/enhanced-kanban.slice';
// Settings & Management
import memberReducer from '@features/settings/member/memberSlice';
@@ -74,10 +76,18 @@ import timeReportsOverviewReducer from '@features/reporting/time-reports/time-re
import roadmapReducer from '../features/roadmap/roadmap-slice';
import teamMembersReducer from '@features/team-members/team-members.slice';
import groupByFilterDropdownReducer from '../features/group-by-filter-dropdown/group-by-filter-dropdown-slice';
// Task Management System
import taskManagementReducer from '@/features/task-management/task-management.slice';
import groupingReducer from '@/features/task-management/grouping.slice';
import selectionReducer from '@/features/task-management/selection.slice';
import homePageApiService from '@/api/home-page/home-page.api.service';
import { projectsApi } from '@/api/projects/projects.v1.api.service';
import { userActivityApiService } from '@/api/home-page/user-activity.api.service';
import projectViewReducer from '@features/project/project-view-slice';
import taskManagementFieldsReducer from '@features/task-management/taskListFields.slice';
export const store = configureStore({
middleware: getDefaultMiddleware =>
getDefaultMiddleware({
@@ -119,6 +129,8 @@ export const store = configureStore({
boardReducer: boardReducer,
projectDrawerReducer: projectDrawerReducer,
projectViewReducer: projectViewReducer,
// Project Lookups
projectCategoriesReducer: projectCategoriesReducer,
projectStatusesReducer: projectStatusesReducer,
@@ -131,6 +143,7 @@ export const store = configureStore({
taskLabelsReducer: taskLabelsReducer,
taskStatusReducer: taskStatusReducer,
taskDrawerReducer: taskDrawerReducer,
enhancedKanbanReducer: enhancedKanbanReducer,
// Settings & Management
memberReducer: memberReducer,
@@ -160,8 +173,18 @@ export const store = configureStore({
roadmapReducer: roadmapReducer,
groupByFilterDropdownReducer: groupByFilterDropdownReducer,
timeReportsOverviewReducer: timeReportsOverviewReducer,
// Task Management System
taskManagement: taskManagementReducer,
grouping: groupingReducer,
taskManagementSelection: selectionReducer,
taskManagementFields: taskManagementFieldsReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

View File

@@ -0,0 +1,365 @@
import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { useSelector } from 'react-redux';
import { PlusOutlined, UserAddOutlined } from '@ant-design/icons';
import { RootState } from '@/app/store';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import { ITeamMembersViewModel } from '@/types/teamMembers/teamMembersViewModel.types';
import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events';
import { useAuthService } from '@/hooks/useAuth';
import { Avatar, Button, Checkbox } from '@/components';
import { sortTeamMembers } from '@/utils/sort-team-members';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { toggleProjectMemberDrawer } from '@/features/projects/singleProject/members/projectMembersSlice';
import { updateEnhancedKanbanTaskAssignees } from '@/features/enhanced-kanban/enhanced-kanban.slice';
interface AssigneeSelectorProps {
task: IProjectTask;
groupId?: string | null;
isDarkMode?: boolean;
kanbanMode?: boolean;
}
const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
task,
groupId = null,
isDarkMode = false,
kanbanMode = false
}) => {
const [isOpen, setIsOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [teamMembers, setTeamMembers] = useState<ITeamMembersViewModel>({ data: [], total: 0 });
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 });
const [optimisticAssignees, setOptimisticAssignees] = useState<string[]>([]); // For optimistic updates
const [pendingChanges, setPendingChanges] = useState<Set<string>>(new Set()); // Track pending member changes
const dropdownRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const { projectId } = useSelector((state: RootState) => state.projectReducer);
const members = useSelector((state: RootState) => state.teamMembersReducer.teamMembers);
const currentSession = useAuthService().getCurrentSession();
const { socket } = useSocket();
const dispatch = useAppDispatch();
const filteredMembers = useMemo(() => {
return teamMembers?.data?.filter(member =>
member.name?.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [teamMembers, searchQuery]);
// Update dropdown position
const updateDropdownPosition = useCallback(() => {
if (buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect();
setDropdownPosition({
top: rect.bottom + window.scrollY + 2,
left: rect.left + window.scrollX,
});
}
}, []);
// Close dropdown when clicking outside and handle scroll
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node) &&
buttonRef.current && !buttonRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
const handleScroll = () => {
if (isOpen) {
// Check if the button is still visible in the viewport
if (buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect();
const isVisible = rect.top >= 0 && rect.left >= 0 &&
rect.bottom <= window.innerHeight &&
rect.right <= window.innerWidth;
if (isVisible) {
updateDropdownPosition();
} else {
// Hide dropdown if button is not visible
setIsOpen(false);
}
}
}
};
const handleResize = () => {
if (isOpen) {
updateDropdownPosition();
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
window.addEventListener('scroll', handleScroll, true);
window.addEventListener('resize', handleResize);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
window.removeEventListener('scroll', handleScroll, true);
window.removeEventListener('resize', handleResize);
};
} else {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [isOpen, updateDropdownPosition]);
const handleDropdownToggle = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!isOpen) {
updateDropdownPosition();
// Prepare team members data when opening
const assignees = task?.assignees?.map(assignee => assignee.team_member_id);
const membersData = (members?.data || []).map(member => ({
...member,
selected: assignees?.includes(member.id),
}));
const sortedMembers = sortTeamMembers(membersData);
setTeamMembers({ data: sortedMembers });
setIsOpen(true);
// Focus search input after opening
setTimeout(() => {
searchInputRef.current?.focus();
}, 0);
} else {
setIsOpen(false);
}
};
const handleMemberToggle = (memberId: string, checked: boolean) => {
if (!memberId || !projectId || !task?.id || !currentSession?.id) return;
// Add to pending changes for visual feedback
setPendingChanges(prev => new Set(prev).add(memberId));
// OPTIMISTIC UPDATE: Update local state immediately for instant UI feedback
const currentAssignees = task?.assignees?.map(a => a.team_member_id) || [];
let newAssigneeIds: string[];
if (checked) {
// Adding assignee
newAssigneeIds = [...currentAssignees, memberId];
} else {
// Removing assignee
newAssigneeIds = currentAssignees.filter(id => id !== memberId);
}
// Update optimistic state for immediate UI feedback in dropdown
setOptimisticAssignees(newAssigneeIds);
// Update local team members state for dropdown UI
setTeamMembers(prev => ({
...prev,
data: (prev.data || []).map(member =>
member.id === memberId
? { ...member, selected: checked }
: member
)
}));
const body = {
team_member_id: memberId,
project_id: projectId,
task_id: task.id,
reporter_id: currentSession.id,
mode: checked ? 0 : 1,
parent_task: task.parent_task_id,
};
// Emit socket event - the socket handler will update Redux with proper types
socket?.emit(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), JSON.stringify(body));
socket?.once(
SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(),
(data: any) => {
dispatch(updateEnhancedKanbanTaskAssignees(data));
}
);
// Remove from pending changes after a short delay (optimistic)
setTimeout(() => {
setPendingChanges(prev => {
const newSet = new Set(prev);
newSet.delete(memberId);
return newSet;
});
}, 500); // Remove pending state after 500ms
};
const checkMemberSelected = (memberId: string) => {
if (!memberId) return false;
// Use optimistic assignees if available, otherwise fall back to task assignees
const assignees = optimisticAssignees.length > 0
? optimisticAssignees
: task?.assignees?.map(assignee => assignee.team_member_id) || [];
return assignees.includes(memberId);
};
const handleInviteProjectMemberDrawer = () => {
setIsOpen(false); // Close the assignee dropdown first
dispatch(toggleProjectMemberDrawer()); // Then open the invite drawer
};
return (
<>
<button
ref={buttonRef}
onClick={handleDropdownToggle}
className={`
w-5 h-5 rounded-full border border-dashed flex items-center justify-center
transition-colors duration-200
${isOpen
? isDarkMode
? 'border-blue-500 bg-blue-900/20 text-blue-400'
: 'border-blue-500 bg-blue-50 text-blue-600'
: isDarkMode
? 'border-gray-600 hover:border-gray-500 hover:bg-gray-800 text-gray-400'
: 'border-gray-300 hover:border-gray-400 hover:bg-gray-100 text-gray-600'
}
`}
>
<PlusOutlined className="text-xs" />
</button>
{isOpen && createPortal(
<div
ref={dropdownRef}
onClick={e => e.stopPropagation()}
className={`
fixed z-[99999] w-72 rounded-md shadow-lg border
${isDarkMode
? 'bg-gray-800 border-gray-600'
: 'bg-white border-gray-200'
}
`}
style={{
top: dropdownPosition.top,
left: dropdownPosition.left,
}}
>
{/* Header */}
<div className={`p-2 border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`}>
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search members..."
className={`
w-full px-2 py-1 text-xs rounded border
${isDarkMode
? 'bg-gray-700 border-gray-600 text-gray-100 placeholder-gray-400 focus:border-blue-500'
: 'bg-white border-gray-300 text-gray-900 placeholder-gray-500 focus:border-blue-500'
}
focus:outline-none focus:ring-1 focus:ring-blue-500
`}
/>
</div>
{/* Members List */}
<div className="max-h-48 overflow-y-auto">
{filteredMembers && filteredMembers.length > 0 ? (
filteredMembers.map((member) => (
<div
key={member.id}
className={`
flex items-center gap-2 p-2 cursor-pointer transition-colors
${member.pending_invitation
? 'opacity-50 cursor-not-allowed'
: isDarkMode
? 'hover:bg-gray-700'
: 'hover:bg-gray-50'
}
`}
onClick={() => {
if (!member.pending_invitation) {
const isSelected = checkMemberSelected(member.id || '');
handleMemberToggle(member.id || '', !isSelected);
}
}}
style={{
// Add visual feedback for immediate response
transition: 'all 0.15s ease-in-out',
}}
>
<div className="relative">
<span onClick={e => e.stopPropagation()}>
<Checkbox
checked={checkMemberSelected(member.id || '')}
onChange={(checked) => handleMemberToggle(member.id || '', checked)}
disabled={member.pending_invitation || pendingChanges.has(member.id || '')}
isDarkMode={isDarkMode}
/>
</span>
{pendingChanges.has(member.id || '') && (
<div className={`absolute inset-0 flex items-center justify-center ${
isDarkMode ? 'bg-gray-800/50' : 'bg-white/50'
}`}>
<div className={`w-3 h-3 border border-t-transparent rounded-full animate-spin ${
isDarkMode ? 'border-blue-400' : 'border-blue-600'
}`} />
</div>
)}
</div>
<Avatar
src={member.avatar_url}
name={member.name || ''}
size={24}
isDarkMode={isDarkMode}
/>
<div className="flex-1 min-w-0">
<div className={`text-xs font-medium truncate ${isDarkMode ? 'text-gray-100' : 'text-gray-900'}`}>
{member.name}
</div>
<div className={`text-xs truncate ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
{member.email}
{member.pending_invitation && (
<span className="text-red-400 ml-1">(Pending)</span>
)}
</div>
</div>
</div>
))
) : (
<div className={`p-4 text-center ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
<div className="text-xs">No members found</div>
</div>
)}
</div>
{/* Footer */}
<div className={`p-2 border-t ${isDarkMode ? 'border-gray-600' : 'border-gray-200'}`}>
<button
className={`
w-full flex items-center justify-center gap-1 px-2 py-1 text-xs rounded
transition-colors
${isDarkMode
? 'text-blue-400 hover:bg-gray-700'
: 'text-blue-600 hover:bg-blue-50'
}
`}
onClick={handleInviteProjectMemberDrawer}
>
<UserAddOutlined />
Invite member
</button>
</div>
</div>,
document.body
)}
</>
);
};
export default AssigneeSelector;

View File

@@ -1,6 +1,6 @@
import { Flex, Typography } from 'antd';
import logo from '../assets/images/logo.png';
import logoDark from '@/assets/images/logo-dark-mode.png';
import logo from '@/assets/images/worklenz-light-mode.png';
import logoDark from '@/assets/images/worklenz-dark-mode.png';
import { useAppSelector } from '@/hooks/useAppSelector';
type AuthPageHeaderProp = {

View File

@@ -0,0 +1,105 @@
import React from 'react';
interface AvatarProps {
name?: string;
size?: number | 'small' | 'default' | 'large';
isDarkMode?: boolean;
className?: string;
src?: string;
backgroundColor?: string;
onClick?: (e: React.MouseEvent) => void;
style?: React.CSSProperties;
}
const Avatar: React.FC<AvatarProps> = ({
name = '',
size = 'default',
isDarkMode = false,
className = '',
src,
backgroundColor,
onClick,
style = {},
}) => {
// Handle both numeric and string sizes
const getSize = () => {
if (typeof size === 'number') {
return { width: size, height: size, fontSize: `${size * 0.4}px` };
}
const sizeMap = {
small: { width: 24, height: 24, fontSize: '10px' },
default: { width: 32, height: 32, fontSize: '14px' },
large: { width: 48, height: 48, fontSize: '18px' },
};
return sizeMap[size];
};
const sizeStyle = getSize();
const lightColors = [
'#f56565',
'#4299e1',
'#48bb78',
'#ed8936',
'#9f7aea',
'#ed64a6',
'#667eea',
'#38b2ac',
'#f6ad55',
'#4fd1c7',
];
const darkColors = [
'#e53e3e',
'#3182ce',
'#38a169',
'#dd6b20',
'#805ad5',
'#d53f8c',
'#5a67d8',
'#319795',
'#d69e2e',
'#319795',
];
const colors = isDarkMode ? darkColors : lightColors;
const colorIndex = name.charCodeAt(0) % colors.length;
const defaultBgColor = backgroundColor || colors[colorIndex];
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
onClick?.(e);
};
const avatarStyle = {
...sizeStyle,
backgroundColor: defaultBgColor,
...style,
};
if (src) {
return (
<img
src={src}
alt={name}
onClick={handleClick}
className={`rounded-full object-cover shadow-sm cursor-pointer ${className}`}
style={avatarStyle}
/>
);
}
return (
<div
onClick={handleClick}
className={`rounded-full flex items-center justify-center text-white font-medium shadow-sm cursor-pointer ${className}`}
style={avatarStyle}
>
{name.charAt(0)?.toUpperCase() || '?'}
</div>
);
};
export default Avatar;

View File

@@ -0,0 +1,113 @@
import React, { useCallback, useMemo } from 'react';
import { Avatar, Tooltip } from './index';
interface Member {
id?: string;
team_member_id?: string;
name?: string;
names?: string[];
avatar_url?: string;
color_code?: string;
end?: boolean;
}
interface AvatarGroupProps {
members: Member[];
maxCount?: number;
size?: number | 'small' | 'default' | 'large';
isDarkMode?: boolean;
className?: string;
onClick?: (e: React.MouseEvent) => void;
}
const AvatarGroup: React.FC<AvatarGroupProps> = ({
members,
maxCount,
size = 28,
isDarkMode = false,
className = '',
onClick,
}) => {
const stopPropagation = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onClick?.(e);
},
[onClick]
);
const renderAvatar = useCallback(
(member: Member, index: number) => {
const memberName = member.end && member.names ? member.names.join(', ') : member.name || '';
const displayName =
member.end && member.names ? member.name : member.name?.charAt(0).toUpperCase();
return (
<Tooltip
key={member.team_member_id || member.id || index}
title={memberName}
isDarkMode={isDarkMode}
>
<Avatar
name={member.name || ''}
src={member.avatar_url}
size={size}
isDarkMode={isDarkMode}
backgroundColor={member.color_code}
onClick={stopPropagation}
className="border-2 border-white"
style={isDarkMode ? { borderColor: '#374151' } : {}}
/>
</Tooltip>
);
},
[stopPropagation, size, isDarkMode]
);
const visibleMembers = useMemo(() => {
return maxCount ? members.slice(0, maxCount) : members;
}, [members, maxCount]);
const remainingCount = useMemo(() => {
return maxCount ? Math.max(0, members.length - maxCount) : 0;
}, [members.length, maxCount]);
const avatarElements = useMemo(() => {
return visibleMembers.map((member, index) => renderAvatar(member, index));
}, [visibleMembers, renderAvatar]);
const getSizeStyle = () => {
if (typeof size === 'number') {
return { width: size, height: size, fontSize: `${size * 0.4}px` };
}
const sizeMap = {
small: { width: 24, height: 24, fontSize: '10px' },
default: { width: 32, height: 32, fontSize: '14px' },
large: { width: 48, height: 48, fontSize: '18px' },
};
return sizeMap[size];
};
return (
<div onClick={stopPropagation} className={`flex -space-x-1 ${className}`}>
{avatarElements}
{remainingCount > 0 && (
<Tooltip title={`${remainingCount} more`} isDarkMode={isDarkMode}>
<div
className={`rounded-full flex items-center justify-center text-white font-medium shadow-sm border-2 cursor-pointer ${
isDarkMode ? 'bg-gray-600 border-gray-700' : 'bg-gray-400 border-white'
}`}
style={getSizeStyle()}
onClick={stopPropagation}
>
+{remainingCount}
</div>
</Tooltip>
)}
</div>
);
};
export default AvatarGroup;

View File

@@ -0,0 +1,64 @@
import React from 'react';
interface ButtonProps {
children?: React.ReactNode;
onClick?: () => void;
variant?: 'text' | 'default' | 'primary' | 'danger';
size?: 'small' | 'default' | 'large';
className?: string;
icon?: React.ReactNode;
isDarkMode?: boolean;
disabled?: boolean;
type?: 'button' | 'submit' | 'reset';
}
const Button: React.FC<ButtonProps & React.ButtonHTMLAttributes<HTMLButtonElement>> = ({
children,
onClick,
variant = 'default',
size = 'default',
className = '',
icon,
isDarkMode = false,
disabled = false,
type = 'button',
...props
}) => {
const baseClasses = `inline-flex items-center justify-center font-medium transition-colors duration-200 focus:outline-none focus:ring-2 ${isDarkMode ? 'focus:ring-blue-400' : 'focus:ring-blue-500'} focus:ring-offset-2 ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`;
const variantClasses = {
text: isDarkMode
? 'text-gray-400 hover:text-gray-200 hover:bg-gray-700/50'
: 'text-gray-600 hover:text-gray-800 hover:bg-gray-100',
default: isDarkMode
? 'bg-gray-800 border border-gray-600 text-gray-200 hover:bg-gray-700'
: 'bg-white border border-gray-300 text-gray-700 hover:bg-gray-50',
primary: isDarkMode
? 'bg-blue-600 text-white hover:bg-blue-700'
: 'bg-blue-500 text-white hover:bg-blue-600',
danger: isDarkMode
? 'bg-red-600 text-white hover:bg-red-700'
: 'bg-red-500 text-white hover:bg-red-600',
};
const sizeClasses = {
small: 'px-2 py-1 text-xs rounded-sm',
default: 'px-3 py-2 text-sm rounded-md',
large: 'px-4 py-3 text-base rounded-lg',
};
return (
<button
type={type}
onClick={onClick}
disabled={disabled}
className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`}
{...props}
>
{icon && <span className={children ? 'mr-1' : ''}>{icon}</span>}
{children}
</button>
);
};
export default Button;

View File

@@ -0,0 +1,61 @@
import React from 'react';
interface CheckboxProps {
checked: boolean;
onChange: (checked: boolean) => void;
isDarkMode?: boolean;
className?: string;
disabled?: boolean;
indeterminate?: boolean;
}
const Checkbox: React.FC<CheckboxProps> = ({
checked,
onChange,
isDarkMode = false,
className = '',
disabled = false,
indeterminate = false,
}) => {
return (
<label
className={`inline-flex items-center cursor-pointer ${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className}`}
>
<input
type="checkbox"
checked={checked}
onChange={e => !disabled && onChange(e.target.checked)}
disabled={disabled}
className="sr-only"
/>
<div
className={`w-4 h-4 border-2 rounded transition-all duration-200 flex items-center justify-center ${
checked || indeterminate
? `${isDarkMode ? 'bg-blue-600 border-blue-600' : 'bg-blue-500 border-blue-500'}`
: `${isDarkMode ? 'bg-gray-800 border-gray-600 hover:border-gray-500' : 'bg-white border-gray-300 hover:border-gray-400'}`
} ${disabled ? 'cursor-not-allowed' : ''}`}
>
{checked && !indeterminate && (
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
)}
{indeterminate && (
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z"
clipRule="evenodd"
/>
</svg>
)}
</div>
</label>
);
};
export default Checkbox;

View File

@@ -1,25 +1,37 @@
import React from 'react';
import Tooltip from 'antd/es/tooltip';
import Avatar from 'antd/es/avatar';
import { AvatarNamesMap } from '../shared/constants';
const CustomAvatar = ({ avatarName, size = 32 }: { avatarName: string; size?: number }) => {
const avatarCharacter = avatarName[0].toUpperCase();
interface CustomAvatarProps {
avatarName: string;
size?: number;
}
return (
<Tooltip title={avatarName}>
<Avatar
style={{
backgroundColor: AvatarNamesMap[avatarCharacter],
verticalAlign: 'middle',
width: size,
height: size,
}}
>
{avatarCharacter}
</Avatar>
</Tooltip>
);
};
const CustomAvatar = React.forwardRef<HTMLDivElement, CustomAvatarProps>(
({ avatarName, size = 32 }, ref) => {
const avatarCharacter = avatarName[0].toUpperCase();
return (
<Tooltip title={avatarName}>
<div ref={ref} style={{ display: 'inline-block' }}>
<Avatar
style={{
backgroundColor: AvatarNamesMap[avatarCharacter],
verticalAlign: 'middle',
width: size,
height: size,
}}
>
{avatarCharacter}
</Avatar>
</div>
</Tooltip>
);
}
);
CustomAvatar.displayName = 'CustomAvatar';
export default CustomAvatar;

View File

@@ -0,0 +1,58 @@
import React from 'react';
import { Tooltip } from 'antd';
import { Label } from '@/types/task-management.types';
import { ITaskLabel } from '@/types/tasks/taskLabel.types';
interface CustomColordLabelProps {
label: Label | ITaskLabel;
isDarkMode?: boolean;
}
const CustomColordLabel = React.forwardRef<HTMLSpanElement, CustomColordLabelProps>(
({ label, isDarkMode = false }, ref) => {
const truncatedName =
label.name && label.name.length > 10 ? `${label.name.substring(0, 10)}...` : label.name;
// Handle different color property names for different types
const backgroundColor = (label as Label).color || (label as ITaskLabel).color_code || '#6b7280'; // Default to gray-500 if no color
// Function to determine if we should use white or black text based on background color
const getTextColor = (bgColor: string): string => {
// Remove # if present
const color = bgColor.replace('#', '');
// Convert to RGB
const r = parseInt(color.substr(0, 2), 16);
const g = parseInt(color.substr(2, 2), 16);
const b = parseInt(color.substr(4, 2), 16);
// Calculate luminance
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
// Return white for dark backgrounds, black for light backgrounds
return luminance > 0.5 ? '#000000' : '#ffffff';
};
const textColor = getTextColor(backgroundColor);
return (
<Tooltip title={label.name}>
<span
ref={ref}
className="inline-flex items-center px-2 py-0.5 rounded-sm text-xs font-medium shrink-0 max-w-[120px]"
style={{
backgroundColor,
color: textColor,
border: `1px solid ${backgroundColor}`,
}}
>
<span className="truncate">{truncatedName}</span>
</span>
</Tooltip>
);
}
);
CustomColordLabel.displayName = 'CustomColordLabel';
export default CustomColordLabel;

View File

@@ -0,0 +1,36 @@
import React from 'react';
import { Tooltip } from 'antd';
import { NumbersColorMap } from '@/shared/constants';
interface CustomNumberLabelProps {
labelList: string[];
namesString: string;
isDarkMode?: boolean;
color?: string; // Add color prop for label color
}
const CustomNumberLabel = React.forwardRef<HTMLSpanElement, CustomNumberLabelProps>(
({ labelList, namesString, isDarkMode = false, color }, ref) => {
// Use provided color, or fall back to NumbersColorMap based on first digit
const backgroundColor = color || (() => {
const firstDigit = namesString.match(/\d/)?.[0] || '0';
return NumbersColorMap[firstDigit] || NumbersColorMap['0'];
})();
return (
<Tooltip title={labelList.join(', ')}>
<span
ref={ref}
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium text-white cursor-help"
style={{ backgroundColor }}
>
{namesString}
</span>
</Tooltip>
);
}
);
CustomNumberLabel.displayName = 'CustomNumberLabel';
export default CustomNumberLabel;

View File

@@ -8,7 +8,7 @@ type EmptyListPlaceholderProps = {
};
const EmptyListPlaceholder = ({
imageSrc = '/assets/images/empty-box.webp',
imageSrc = 'https://s3.us-west-2.amazonaws.com/worklenz.com/assets/empty-box.webp',
imageHeight = 60,
text,
}: EmptyListPlaceholderProps) => {

View File

@@ -27,7 +27,7 @@ class ErrorBoundary extends React.Component<Props, State> {
logger.error('Error caught by ErrorBoundary:', {
error: error.message,
stack: error.stack,
componentStack: errorInfo.componentStack
componentStack: errorInfo.componentStack,
});
console.error('Error caught by ErrorBoundary:', error);
}
@@ -70,4 +70,4 @@ const ErrorFallback: React.FC<{ error?: Error }> = ({ error }) => {
);
};
export default ErrorBoundary;
export default ErrorBoundary;

View File

@@ -0,0 +1,24 @@
import { useEffect } from 'react';
const HubSpot = () => {
useEffect(() => {
const script = document.createElement('script');
script.type = 'text/javascript';
script.id = 'hs-script-loader';
script.async = true;
script.defer = true;
script.src = '//js.hs-scripts.com/22348300.js';
document.body.appendChild(script);
return () => {
const existingScript = document.getElementById('hs-script-loader');
if (existingScript) {
existingScript.remove();
}
};
}, []);
return null;
};
export default HubSpot;

View File

@@ -0,0 +1,296 @@
import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { useSelector } from 'react-redux';
import { PlusOutlined, TagOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import { RootState } from '@/app/store';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import { ITaskLabel } from '@/types/tasks/taskLabel.types';
import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events';
import { useAuthService } from '@/hooks/useAuth';
import { Button, Checkbox, Tag } from '@/components';
interface LabelsSelectorProps {
task: IProjectTask;
isDarkMode?: boolean;
}
const LabelsSelector: React.FC<LabelsSelectorProps> = ({ task, isDarkMode = false }) => {
const [isOpen, setIsOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 });
const dropdownRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const { labels } = useSelector((state: RootState) => state.taskLabelsReducer);
const currentSession = useAuthService().getCurrentSession();
const { socket } = useSocket();
const { t } = useTranslation('task-list-table');
const filteredLabels = useMemo(() => {
return (
(labels as ITaskLabel[])?.filter(label =>
label.name?.toLowerCase().includes(searchQuery.toLowerCase())
) || []
);
}, [labels, searchQuery]);
// Update dropdown position
const updateDropdownPosition = useCallback(() => {
if (buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect();
const dropdownHeight = 300; // Approximate height of dropdown (max-height + padding)
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
// Position dropdown above button if there's not enough space below
const shouldPositionAbove = spaceBelow < dropdownHeight && spaceAbove > dropdownHeight;
if (shouldPositionAbove) {
setDropdownPosition({
top: rect.top + window.scrollY - dropdownHeight - 2,
left: rect.left + window.scrollX,
});
} else {
setDropdownPosition({
top: rect.bottom + window.scrollY + 2,
left: rect.left + window.scrollX,
});
}
}
}, []);
// Close dropdown when clicking outside and handle scroll
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
buttonRef.current &&
!buttonRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
const handleScroll = (event: Event) => {
if (isOpen) {
// Only close dropdown if scrolling happens outside the dropdown
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
};
const handleResize = () => {
if (isOpen) {
updateDropdownPosition();
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
window.addEventListener('scroll', handleScroll, true);
window.addEventListener('resize', handleResize);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
window.removeEventListener('scroll', handleScroll, true);
window.removeEventListener('resize', handleResize);
};
} else {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [isOpen, updateDropdownPosition]);
const handleDropdownToggle = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
console.log('Labels dropdown toggle clicked, current state:', isOpen);
if (!isOpen) {
updateDropdownPosition();
setIsOpen(true);
// Focus search input after opening
setTimeout(() => {
searchInputRef.current?.focus();
}, 0);
} else {
setIsOpen(false);
}
};
const handleLabelToggle = (label: ITaskLabel) => {
const labelData = {
task_id: task.id,
label_id: label.id,
parent_task: task.parent_task_id,
team_id: currentSession?.team_id,
};
socket?.emit(SocketEvents.TASK_LABELS_CHANGE.toString(), JSON.stringify(labelData));
};
const handleCreateLabel = () => {
if (!searchQuery.trim()) return;
const labelData = {
task_id: task.id,
label: searchQuery.trim(),
parent_task: task.parent_task_id,
team_id: currentSession?.team_id,
};
socket?.emit(SocketEvents.CREATE_LABEL.toString(), JSON.stringify(labelData));
setSearchQuery('');
};
const checkLabelSelected = (labelId: string) => {
return task?.all_labels?.some(existingLabel => existingLabel.id === labelId) || false;
};
const handleKeyDown = (e: React.KeyboardEvent) => {
const existingLabel = filteredLabels.find(
label => label.name?.toLowerCase() === searchQuery.toLowerCase()
);
if (!existingLabel && e.key === 'Enter') {
handleCreateLabel();
}
};
return (
<>
<button
ref={buttonRef}
onClick={handleDropdownToggle}
className={`
w-5 h-5 rounded border border-dashed flex items-center justify-center
transition-colors duration-200
${
isOpen
? isDarkMode
? 'border-blue-500 bg-blue-900/20 text-blue-400'
: 'border-blue-500 bg-blue-50 text-blue-600'
: isDarkMode
? 'border-gray-600 hover:border-gray-500 hover:bg-gray-800 text-gray-400'
: 'border-gray-300 hover:border-gray-400 hover:bg-gray-100 text-gray-600'
}
`}
>
<PlusOutlined className="text-xs" />
</button>
{isOpen &&
createPortal(
<div
ref={dropdownRef}
className={`
fixed z-9999 w-72 rounded-md shadow-lg border
${isDarkMode ? 'bg-gray-800 border-gray-600' : 'bg-white border-gray-200'}
`}
style={{
top: dropdownPosition.top,
left: dropdownPosition.left,
}}
>
{/* Header */}
<div className={`p-2 border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`}>
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t('searchLabelsPlaceholder')}
className={`
w-full px-2 py-1 text-xs rounded border
${
isDarkMode
? 'bg-gray-700 border-gray-600 text-gray-100 placeholder-gray-400 focus:border-blue-500'
: 'bg-white border-gray-300 text-gray-900 placeholder-gray-500 focus:border-blue-500'
}
focus:outline-none focus:ring-1 focus:ring-blue-500
`}
/>
</div>
{/* Labels List */}
<div className="max-h-48 overflow-y-auto">
{filteredLabels && filteredLabels.length > 0 ? (
filteredLabels.map(label => (
<div
key={label.id}
className={`
flex items-center gap-2 px-2 py-1 cursor-pointer transition-colors
${isDarkMode ? 'hover:bg-gray-700' : 'hover:bg-gray-50'}
`}
onClick={(e) => {
e.stopPropagation();
handleLabelToggle(label);
}}
>
<div style={{ pointerEvents: 'none' }}>
<Checkbox
checked={checkLabelSelected(label.id || '')}
onChange={() => {}} // Empty handler since we handle click on the div
isDarkMode={isDarkMode}
/>
</div>
<div
className="w-3 h-3 rounded-full shrink-0"
style={{ backgroundColor: label.color_code }}
/>
<div className="flex-1 min-w-0">
<div
className={`text-xs font-medium truncate ${isDarkMode ? 'text-gray-100' : 'text-gray-900'}`}
>
{label.name}
</div>
</div>
</div>
))
) : (
<div
className={`p-4 text-center ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}
>
<div className="text-xs">{t('noLabelsFound')}</div>
{searchQuery.trim() && (
<button
onClick={handleCreateLabel}
className={`
mt-2 px-3 py-1 text-xs rounded border transition-colors
${
isDarkMode
? 'border-gray-600 text-gray-300 hover:bg-gray-700'
: 'border-gray-300 text-gray-600 hover:bg-gray-50'
}
`}
>
{t('createLabelButton', { name: searchQuery.trim() })}
</button>
)}
</div>
)}
</div>
{/* Footer */}
<div className={`p-2 border-t ${isDarkMode ? 'border-gray-600' : 'border-gray-200'}`}>
<div className={`flex items-center justify-center gap-1 px-2 py-1 text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
<TagOutlined />
{t('manageLabelsPath')}
</div>
</div>
</div>,
document.body
)}
</>
);
};
export default LabelsSelector;

View File

@@ -5,8 +5,15 @@ import { PushpinFilled, PushpinOutlined } from '@ant-design/icons';
import { colors } from '../styles/colors';
import { navRoutes, NavRoutesType } from '../features/navbar/navRoutes';
// Props type for the component
type PinRouteToNavbarButtonProps = {
name: string;
path: string;
adminOnly?: boolean;
};
// this component pin the given path to navbar
const PinRouteToNavbarButton = ({ name, path }: NavRoutesType) => {
const PinRouteToNavbarButton = ({ name, path, adminOnly = false }: PinRouteToNavbarButtonProps) => {
const navRoutesList: NavRoutesType[] = getJSONFromLocalStorage('navRoutes') || navRoutes;
const [isPinned, setIsPinned] = useState(
@@ -18,7 +25,7 @@ const PinRouteToNavbarButton = ({ name, path }: NavRoutesType) => {
const handlePinToNavbar = (name: string, path: string) => {
let newNavRoutesList;
const route: NavRoutesType = { name, path };
const route: NavRoutesType = { name, path, adminOnly };
if (isPinned) {
newNavRoutesList = navRoutesList.filter(item => item.name !== route.name);

View File

@@ -1,7 +1,7 @@
import { FloatButton, Space, Tooltip } from 'antd';
import { FormatPainterOutlined } from '@ant-design/icons';
import LanguageSelector from '../features/i18n/language-selector';
import ThemeSelector from '../features/theme/ThemeSelector';
// import LanguageSelector from '../features/i18n/language-selector';
// import ThemeSelector from '../features/theme/ThemeSelector';
const PreferenceSelector = () => {
return (
@@ -17,7 +17,7 @@ const PreferenceSelector = () => {
justifyContent: 'center',
}}
>
<ThemeSelector />
{/* <ThemeSelector /> */}
</Space>
</FloatButton.Group>
</div>

View File

@@ -0,0 +1,88 @@
import React from 'react';
interface ProgressProps {
percent: number;
type?: 'line' | 'circle';
size?: number;
strokeColor?: string;
strokeWidth?: number;
showInfo?: boolean;
isDarkMode?: boolean;
className?: string;
}
const Progress: React.FC<ProgressProps> = ({
percent,
type = 'line',
size = 24,
strokeColor = '#1890ff',
strokeWidth = 2,
showInfo = true,
isDarkMode = false,
className = '',
}) => {
// Ensure percent is between 0 and 100
const normalizedPercent = Math.min(Math.max(percent, 0), 100);
if (type === 'circle') {
const radius = (size - strokeWidth) / 2;
const circumference = radius * 2 * Math.PI;
const strokeDasharray = circumference;
const strokeDashoffset = circumference - (normalizedPercent / 100) * circumference;
return (
<div className={`relative inline-flex items-center justify-center ${className}`}>
<svg width={size} height={size} className="transform -rotate-90">
<circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke={isDarkMode ? '#4b5563' : '#e5e7eb'}
strokeWidth={strokeWidth}
fill="none"
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke={normalizedPercent === 100 ? '#52c41a' : strokeColor}
strokeWidth={strokeWidth}
fill="none"
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
strokeLinecap="round"
className="transition-all duration-300"
/>
</svg>
{showInfo && (
<span
className={`absolute text-xs font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}
>
{normalizedPercent}%
</span>
)}
</div>
);
}
return (
<div
className={`w-full rounded-full h-2 ${isDarkMode ? 'bg-gray-600' : 'bg-gray-200'} ${className}`}
>
<div
className="h-2 rounded-full transition-all duration-300"
style={{
width: `${normalizedPercent}%`,
backgroundColor: normalizedPercent === 100 ? '#52c41a' : strokeColor,
}}
/>
{showInfo && (
<div className={`mt-1 text-xs ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
{normalizedPercent}%
</div>
)}
</div>
);
};
export default Progress;

View File

@@ -0,0 +1,51 @@
import React from 'react';
interface TagProps {
children: React.ReactNode;
color?: string;
backgroundColor?: string;
className?: string;
size?: 'small' | 'default';
variant?: 'default' | 'outlined';
isDarkMode?: boolean;
}
const Tag: React.FC<TagProps> = ({
children,
color = 'white',
backgroundColor = '#1890ff',
className = '',
size = 'default',
variant = 'default',
isDarkMode = false,
}) => {
const sizeClasses = {
small: 'px-1 py-0.5 text-xs',
default: 'px-2 py-1 text-xs',
};
const baseClasses = `inline-flex items-center font-medium rounded-sm ${sizeClasses[size]}`;
if (variant === 'outlined') {
return (
<span
className={`${baseClasses} border ${className}`}
style={{
borderColor: backgroundColor,
color: backgroundColor,
backgroundColor: 'transparent',
}}
>
{children}
</span>
);
}
return (
<span className={`${baseClasses} ${className}`} style={{ backgroundColor, color }}>
{children}
</span>
);
};
export default Tag;

View File

@@ -20,7 +20,7 @@ const TawkTo: React.FC<TawkToProps> = ({ propertyId, widgetId }) => {
s1.async = true;
s1.src = `https://embed.tawk.to/${propertyId}/${widgetId}`;
s1.setAttribute('crossorigin', '*');
const s0 = document.getElementsByTagName('script')[0];
s0.parentNode?.insertBefore(s1, s0);
@@ -31,13 +31,13 @@ const TawkTo: React.FC<TawkToProps> = ({ propertyId, widgetId }) => {
if (tawkScript && tawkScript.parentNode) {
tawkScript.parentNode.removeChild(tawkScript);
}
// Remove the tawk.to iframe
const tawkIframe = document.getElementById('tawk-iframe');
if (tawkIframe) {
tawkIframe.remove();
}
// Reset Tawk globals
delete window.Tawk_API;
delete window.Tawk_LoadStart;
@@ -47,4 +47,4 @@ const TawkTo: React.FC<TawkToProps> = ({ propertyId, widgetId }) => {
return null;
};
export default TawkTo;
export default TawkTo;

View File

@@ -0,0 +1,37 @@
import React from 'react';
interface TooltipProps {
title: string | React.ReactNode;
children: React.ReactNode;
isDarkMode?: boolean;
placement?: 'top' | 'bottom' | 'left' | 'right';
className?: string;
}
const Tooltip: React.FC<TooltipProps> = ({
title,
children,
isDarkMode = false,
placement = 'top',
className = '',
}) => {
const placementClasses = {
top: 'bottom-full left-1/2 transform -translate-x-1/2 mb-2',
bottom: 'top-full left-1/2 transform -translate-x-1/2 mt-2',
left: 'right-full top-1/2 transform -translate-y-1/2 mr-2',
right: 'left-full top-1/2 transform -translate-y-1/2 ml-2',
};
return (
<div className={`relative group ${className}`}>
{children}
<div
className={`absolute ${placementClasses[placement]} px-2 py-1 text-xs text-white ${isDarkMode ? 'bg-gray-700' : 'bg-gray-900'} rounded-sm shadow-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 z-50 pointer-events-none min-w-max`}
>
{title}
</div>
</div>
);
};
export default Tooltip;

View File

@@ -39,7 +39,9 @@ export const TasksStep: React.FC<Props> = ({ onEnter, styles, isDarkMode }) => {
const updateTask = (id: number, value: string) => {
const sanitizedValue = sanitizeInput(value);
dispatch(setTasks(tasks.map(task => (task.id === id ? { ...task, value: sanitizedValue } : task))));
dispatch(
setTasks(tasks.map(task => (task.id === id ? { ...task, value: sanitizedValue } : task)))
);
};
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {

View File

@@ -18,7 +18,9 @@ const AccountStorage = ({ themeMode }: IAccountStorageProps) => {
const dispatch = useAppDispatch();
const [subscriptionType, setSubscriptionType] = useState<string>(SUBSCRIPTION_STATUS.TRIALING);
const { loadingBillingInfo, billingInfo, storageInfo } = useAppSelector(state => state.adminCenterReducer);
const { loadingBillingInfo, billingInfo, storageInfo } = useAppSelector(
state => state.adminCenterReducer
);
const formatBytes = useMemo(
() =>
@@ -68,7 +70,7 @@ const AccountStorage = ({ themeMode }: IAccountStorageProps) => {
<div style={{ display: 'flex', alignItems: 'center' }}>
<div style={{ padding: '0 16px' }}>
<Progress
percent={billingInfo?.usedPercentage ?? 0}
percent={billingInfo?.used_percent ?? 0}
type="circle"
format={percent => <span style={{ fontSize: '13px' }}>{percent}% Used</span>}
/>

View File

@@ -1,21 +1,20 @@
import { Button, Card, Col, Modal, Row, Tooltip, Typography } from 'antd';
import { Card, Col, Row, Tooltip, Typography } from 'antd';
import React, { useEffect } from 'react';
import './current-bill.css';
import { InfoCircleTwoTone } from '@ant-design/icons';
import ChargesTable from './billing-tables/charges-table';
import InvoicesTable from './billing-tables/invoices-table';
import UpgradePlansLKR from './drawers/upgrade-plans-lkr/upgrade-plans-lkr';
import UpgradePlans from './drawers/upgrade-plans/upgrade-plans';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useMediaQuery } from 'react-responsive';
import { useTranslation } from 'react-i18next';
import {
toggleDrawer,
toggleUpgradeModal,
} from '@/features/admin-center/billing/billing.slice';
import { fetchBillingInfo, fetchFreePlanSettings } from '@/features/admin-center/admin-center.slice';
import RedeemCodeDrawer from './drawers/redeem-code-drawer/redeem-code-drawer';
fetchBillingInfo,
fetchFreePlanSettings,
} from '@/features/admin-center/admin-center.slice';
import CurrentPlanDetails from './current-plan-details/current-plan-details';
import AccountStorage from './account-storage/account-storage';
import { useAuthService } from '@/hooks/useAuth';
@@ -25,9 +24,7 @@ const CurrentBill: React.FC = () => {
const dispatch = useAppDispatch();
const { t } = useTranslation('admin-center/current-bill');
const themeMode = useAppSelector(state => state.themeReducer.mode);
const { isUpgradeModalOpen } = useAppSelector(state => state.adminCenterReducer);
const isTablet = useMediaQuery({ query: '(min-width: 1025px)' });
const browserTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const currentSession = useAuthService().getCurrentSession();
useEffect(() => {
@@ -46,42 +43,7 @@ const CurrentBill: React.FC = () => {
const renderMobileView = () => (
<div>
<Col span={24}>
<Card
style={{ height: '100%' }}
title={<span style={titleStyle}>{t('currentPlanDetails')}</span>}
extra={
<div style={{ marginTop: '8px', marginRight: '8px' }}>
<Button type="primary" onClick={() => dispatch(toggleUpgradeModal())}>
{t('upgradePlan')}
</Button>
<Modal
open={isUpgradeModalOpen}
onCancel={() => dispatch(toggleUpgradeModal())}
width={1000}
centered
okButtonProps={{ hidden: true }}
cancelButtonProps={{ hidden: true }}
>
{browserTimeZone === 'Asia/Colombo' ? <UpgradePlansLKR /> : <UpgradePlans />}
</Modal>
</div>
}
>
<div style={{ display: 'flex', flexDirection: 'column', width: '50%', padding: '0 12px' }}>
<div style={{ marginBottom: '14px' }}>
<Typography.Text style={{ fontWeight: 700 }}>{t('cardBodyText01')}</Typography.Text>
<Typography.Text>{t('cardBodyText02')}</Typography.Text>
</div>
<Button
type="link"
style={{ margin: 0, padding: 0, width: '90px' }}
onClick={() => dispatch(toggleDrawer())}
>
{t('redeemCode')}
</Button>
<RedeemCodeDrawer />
</div>
</Card>
<CurrentPlanDetails />
</Col>
<Col span={24} style={{ marginTop: '1.5rem' }}>
@@ -109,10 +71,7 @@ const CurrentBill: React.FC = () => {
</div>
<div style={{ marginTop: '1.5rem' }}>
<Card
title={<span style={titleStyle}>{t('invoices')}</span>}
style={{ marginTop: '16px' }}
>
<Card title={<span style={titleStyle}>{t('invoices')}</span>} style={{ marginTop: '16px' }}>
<InvoicesTable />
</Card>
</div>
@@ -133,7 +92,8 @@ const CurrentBill: React.FC = () => {
) : (
renderMobileView()
)}
{currentSession?.subscription_type === ISUBSCRIPTION_TYPE.PADDLE && renderChargesAndInvoices()}
{currentSession?.subscription_type === ISUBSCRIPTION_TYPE.PADDLE &&
renderChargesAndInvoices()}
</div>
);
};

View File

@@ -7,7 +7,20 @@ import {
} from '@/shared/worklenz-analytics-events';
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
import logger from '@/utils/errorLogger';
import { Button, Card, Flex, Modal, Space, Tooltip, Typography, Statistic, Select, Form, Row, Col } from 'antd/es';
import {
Button,
Card,
Flex,
Modal,
Space,
Tooltip,
Typography,
Statistic,
Select,
Form,
Row,
Col,
} from 'antd/es';
import RedeemCodeDrawer from '../drawers/redeem-code-drawer/redeem-code-drawer';
import {
fetchBillingInfo,
@@ -44,8 +57,9 @@ const CurrentPlanDetails = () => {
const browserTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
type SeatOption = { label: string; value: number | string };
const seatCountOptions: SeatOption[] = [1, 2, 3, 4, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90]
.map(value => ({ label: value.toString(), value }));
const seatCountOptions: SeatOption[] = [
1, 2, 3, 4, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90,
].map(value => ({ label: value.toString(), value }));
seatCountOptions.push({ label: '100+', value: '100+' });
const handleSubscriptionAction = async (action: 'pause' | 'resume') => {
@@ -127,8 +141,10 @@ const CurrentPlanDetails = () => {
const shouldShowAddSeats = () => {
if (!billingInfo) return false;
return billingInfo.subscription_type === ISUBSCRIPTION_TYPE.PADDLE &&
billingInfo.status === SUBSCRIPTION_STATUS.ACTIVE;
return (
billingInfo.subscription_type === ISUBSCRIPTION_TYPE.PADDLE &&
billingInfo.status === SUBSCRIPTION_STATUS.ACTIVE
);
};
const renderExtra = () => {
@@ -199,13 +215,13 @@ const CurrentPlanDetails = () => {
const getExpirationMessage = (expireDate: string) => {
const today = new Date();
today.setHours(0, 0, 0, 0); // Set to start of day for comparison
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const expDate = new Date(expireDate);
expDate.setHours(0, 0, 0, 0); // Set to start of day for comparison
if (expDate.getTime() === today.getTime()) {
return t('expirestoday', 'today');
} else if (expDate.getTime() === tomorrow.getTime()) {
@@ -230,14 +246,13 @@ const CurrentPlanDetails = () => {
</Typography.Text>
<Tooltip title={formatDate(new Date(trialExpireDate))}>
<Typography.Text>
{isExpired
{isExpired
? t('trialExpired', {
trial_expire_string: getExpirationMessage(trialExpireDate)
trial_expire_string: getExpirationMessage(trialExpireDate),
})
: t('trialInProgress', {
trial_expire_string: getExpirationMessage(trialExpireDate)
})
}
trial_expire_string: getExpirationMessage(trialExpireDate),
})}
</Typography.Text>
</Tooltip>
</Flex>
@@ -246,7 +261,7 @@ const CurrentPlanDetails = () => {
const renderFreePlan = () => (
<Flex vertical>
<Typography.Text strong>Free Plan</Typography.Text>
<Typography.Text strong>{t('freePlan')}</Typography.Text>
<Typography.Text>
<br />-{' '}
{freePlanSettings?.team_member_limit === 0
@@ -268,25 +283,24 @@ const CurrentPlanDetails = () => {
{billingInfo?.billing_type === 'year'
? billingInfo.unit_price_per_month
: billingInfo?.unit_price}
&nbsp;{t('perMonthPerUser')}
</Typography.Text>
</Flex>
{shouldShowAddSeats() && billingInfo?.total_seats && (
<div style={{ marginTop: '16px' }}>
<Row gutter={16} align="middle">
<Col span={6}>
<Statistic
title={t('totalSeats')}
value={billingInfo.total_seats}
<Statistic
title={t('totalSeats')}
value={billingInfo.total_seats}
valueStyle={{ fontSize: '24px', fontWeight: 'bold' }}
/>
</Col>
<Col span={8}>
<Button
type="primary"
icon={<PlusOutlined />}
<Button
type="primary"
icon={<PlusOutlined />}
onClick={handleAddMoreSeats}
style={{ backgroundColor: '#1890ff', borderColor: '#1890ff' }}
>
@@ -294,9 +308,9 @@ const CurrentPlanDetails = () => {
</Button>
</Col>
<Col span={6}>
<Statistic
title={t('availableSeats')}
value={calculateRemainingSeats()}
<Statistic
title={t('availableSeats')}
value={calculateRemainingSeats()}
valueStyle={{ fontSize: '24px', fontWeight: 'bold' }}
/>
</Col>
@@ -308,17 +322,25 @@ const CurrentPlanDetails = () => {
};
const renderCreditSubscriptionInfo = () => {
return <Flex vertical>
<Typography.Text strong>Credit Plan</Typography.Text>
</Flex>
};
return (
<Flex vertical>
<Typography.Text strong>{t('creditPlan', 'Credit Plan')}</Typography.Text>
</Flex>
);
};
const renderCustomSubscriptionInfo = () => {
return <Flex vertical>
<Typography.Text strong>Custom Plan</Typography.Text>
<Typography.Text>Your plan is valid till {billingInfo?.valid_till_date}</Typography.Text>
</Flex>
};
return (
<Flex vertical>
<Typography.Text strong>{t('customPlan', 'Custom Plan')}</Typography.Text>
<Typography.Text>
{t('planValidTill', 'Your plan is valid till {{date}}', {
date: billingInfo?.valid_till_date,
})}
</Typography.Text>
</Flex>
);
};
return (
<Card
@@ -326,7 +348,6 @@ const CurrentPlanDetails = () => {
title={
<Typography.Text
style={{
color: themeMode === 'dark' ? '#ffffffd9' : '#000000d9',
fontWeight: 500,
fontSize: '16px',
@@ -340,12 +361,16 @@ const CurrentPlanDetails = () => {
>
<Flex vertical>
<div style={{ marginBottom: '14px' }}>
{billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.LIFE_TIME_DEAL && renderLtdDetails()}
{billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.LIFE_TIME_DEAL &&
renderLtdDetails()}
{billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.TRIAL && renderTrialDetails()}
{billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.FREE && renderFreePlan()}
{billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.PADDLE && renderPaddleSubscriptionInfo()}
{billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.CREDIT && renderCreditSubscriptionInfo()}
{billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.CUSTOM && renderCustomSubscriptionInfo()}
{billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.PADDLE &&
renderPaddleSubscriptionInfo()}
{billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.CREDIT &&
renderCreditSubscriptionInfo()}
{billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.CUSTOM &&
renderCustomSubscriptionInfo()}
</div>
{shouldShowRedeemButton() && (
@@ -370,7 +395,7 @@ const CurrentPlanDetails = () => {
>
{browserTimeZone === 'Asia/Colombo' ? <UpgradePlansLKR /> : <UpgradePlans />}
</Modal>
<Modal
title={t('addMoreSeats')}
open={isMoreSeatsModalVisible}
@@ -380,18 +405,22 @@ const CurrentPlanDetails = () => {
centered
>
<Flex vertical gap="middle" style={{ marginTop: '8px' }}>
<Typography.Paragraph style={{ fontSize: '16px', margin: '0 0 16px 0', fontWeight: 500 }}>
To continue, you'll need to purchase additional seats.
<Typography.Paragraph
style={{ fontSize: '16px', margin: '0 0 16px 0', fontWeight: 500 }}
>
{t('purchaseSeatsText', "To continue, you'll need to purchase additional seats.")}
</Typography.Paragraph>
<Typography.Paragraph style={{ margin: '0 0 16px 0' }}>
You currently have {billingInfo?.total_seats} seats available.
{t('currentSeatsText', 'You currently have {{seats}} seats available.', {
seats: billingInfo?.total_seats,
})}
</Typography.Paragraph>
<Typography.Paragraph style={{ margin: '0 0 24px 0' }}>
Please select the number of additional seats to purchase.
{t('selectSeatsText', 'Please select the number of additional seats to purchase.')}
</Typography.Paragraph>
<div style={{ marginBottom: '24px' }}>
<span style={{ color: '#ff4d4f', marginRight: '4px' }}>*</span>
<span style={{ marginRight: '8px' }}>Seats:</span>
@@ -402,28 +431,25 @@ const CurrentPlanDetails = () => {
style={{ width: '300px' }}
/>
</div>
<Flex justify="end">
{selectedSeatCount.toString() !== '100+' ? (
<Button
type="primary"
<Button
type="primary"
loading={addingSeats}
onClick={handlePurchaseMoreSeats}
style={{
minWidth: '100px',
style={{
minWidth: '100px',
backgroundColor: '#1890ff',
borderColor: '#1890ff',
borderRadius: '2px'
borderRadius: '2px',
}}
>
Purchase
{t('purchase', 'Purchase')}
</Button>
) : (
<Button
type="primary"
size="middle"
>
Contact sales
<Button type="primary" size="middle">
{t('contactSales', 'Contact sales')}
</Button>
)}
</Flex>

View File

@@ -1,5 +1,17 @@
import { useEffect, useState } from 'react';
import { Button, Card, Col, Flex, Form, Row, Select, Tag, Tooltip, Typography, message } from 'antd/es';
import {
Button,
Card,
Col,
Flex,
Form,
Row,
Select,
Tag,
Tooltip,
Typography,
message,
} from 'antd/es';
import { useTranslation } from 'react-i18next';
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
@@ -106,7 +118,7 @@ const UpgradePlans = () => {
const handlePaddleCallback = (data: any) => {
console.log('Paddle event:', data);
switch (data.event) {
case 'Checkout.Loaded':
setSwitchingToPaddlePlan(false);
@@ -144,13 +156,13 @@ const UpgradePlans = () => {
const initializePaddle = (data: IUpgradeSubscriptionPlanResponse) => {
setPaddleLoading(true);
setPaddleError(null);
// Check if Paddle is already loaded
if (window.Paddle) {
configurePaddle(data);
return;
}
const script = document.createElement('script');
script.src = 'https://cdn.paddle.com/paddle/paddle.js';
script.type = 'text/javascript';
@@ -159,7 +171,7 @@ const UpgradePlans = () => {
script.onload = () => {
configurePaddle(data);
};
script.onerror = () => {
setPaddleLoading(false);
setPaddleError('Failed to load Paddle checkout');
@@ -169,7 +181,7 @@ const UpgradePlans = () => {
document.getElementsByTagName('head')[0].appendChild(script);
};
const configurePaddle = (data: IUpgradeSubscriptionPlanResponse) => {
try {
if (data.sandbox) Paddle.Environment.set('sandbox');
@@ -193,7 +205,7 @@ const UpgradePlans = () => {
setSwitchingToPaddlePlan(true);
setPaddleLoading(true);
setPaddleError(null);
if (billingInfo?.trial_in_progress && billingInfo.status === SUBSCRIPTION_STATUS.TRIALING) {
const res = await billingApiService.upgradeToPaidPlan(planId, selectedSeatCount);
if (res.done) {
@@ -264,7 +276,6 @@ const UpgradePlans = () => {
const isSelected = (cardIndex: IPaddlePlans) =>
selectedPlan === cardIndex ? { border: '2px solid #1890ff' } : {};
const cardStyles = {
title: {
color: themeMode === 'dark' ? '#ffffffd9' : '#000000d9',
@@ -363,7 +374,6 @@ const UpgradePlans = () => {
title={<span style={cardStyles.title}>{t('freePlan')}</span>}
onClick={() => setSelectedCard(paddlePlans.FREE)}
>
<div style={cardStyles.priceContainer}>
<Flex justify="space-between" align="center">
<Typography.Title level={1}>$ 0.00</Typography.Title>
@@ -389,7 +399,6 @@ const UpgradePlans = () => {
<Card
style={{ ...isSelected(paddlePlans.ANNUAL), height: '100%' }}
hoverable
title={
<span style={cardStyles.title}>
{t('annualPlan')}{' '}
@@ -401,7 +410,6 @@ const UpgradePlans = () => {
onClick={() => setSelectedCard(paddlePlans.ANNUAL)}
>
<div style={cardStyles.priceContainer}>
<Flex justify="space-between" align="center">
<Typography.Title level={1}>$ {plans.annual_price}</Typography.Title>
<Typography.Text>seat / month</Typography.Text>
@@ -442,7 +450,6 @@ const UpgradePlans = () => {
hoverable
title={<span style={cardStyles.title}>{t('monthlyPlan')}</span>}
onClick={() => setSelectedCard(paddlePlans.MONTHLY)}
>
<div style={cardStyles.priceContainer}>
<Flex justify="space-between" align="center">
@@ -501,7 +508,9 @@ const UpgradePlans = () => {
onClick={continueWithPaddlePlan}
disabled={billingInfo?.plan_id === plans.annual_plan_id}
>
{billingInfo?.status === SUBSCRIPTION_STATUS.ACTIVE ? t('changeToPlan', {plan: t('annualPlan')}) : t('continueWith', {plan: t('annualPlan')})}
{billingInfo?.status === SUBSCRIPTION_STATUS.ACTIVE
? t('changeToPlan', { plan: t('annualPlan') })
: t('continueWith', { plan: t('annualPlan') })}
</Button>
)}
{selectedPlan === paddlePlans.MONTHLY && (
@@ -512,7 +521,9 @@ const UpgradePlans = () => {
onClick={continueWithPaddlePlan}
disabled={billingInfo?.plan_id === plans.monthly_plan_id}
>
{billingInfo?.status === SUBSCRIPTION_STATUS.ACTIVE ? t('changeToPlan', {plan: t('monthlyPlan')}) : t('continueWith', {plan: t('monthlyPlan')})}
{billingInfo?.status === SUBSCRIPTION_STATUS.ACTIVE
? t('changeToPlan', { plan: t('monthlyPlan') })
: t('continueWith', { plan: t('monthlyPlan') })}
</Button>
)}
</Row>

View File

@@ -39,7 +39,7 @@ const Configuration: React.FC = () => {
}, []);
const handleSave = async (values: any) => {
try {
try {
setLoading(true);
const res = await adminCenterApiService.updateBillingConfiguration(values);
if (res.done) {
@@ -75,11 +75,7 @@ const Configuration: React.FC = () => {
}
style={{ marginTop: '16px' }}
>
<Form
form={form}
initialValues={configuration}
onFinish={handleSave}
>
<Form form={form} initialValues={configuration} onFinish={handleSave}>
<Row>
<Col span={8} style={{ padding: '0 12px', height: '86px' }}>
<Form.Item
@@ -180,7 +176,6 @@ const Configuration: React.FC = () => {
showSearch
placeholder="Country"
optionFilterProp="label"
allowClear
options={countryOptions}
/>

View File

@@ -10,22 +10,17 @@ import {
Table,
TableProps,
Typography,
Tooltip,
} from 'antd';
import React, { useState } from 'react';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { toggleSettingDrawer, updateTeam } from '@/features/teams/teamSlice';
import { TeamsType } from '@/types/admin-center/team.types';
import './settings-drawer.css';
import CustomAvatar from '@/components/CustomAvatar';
import { teamsApiService } from '@/api/teams/teams.api.service';
import logger from '@/utils/errorLogger';
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
import {
IOrganizationTeam,
IOrganizationTeamMember,
} from '@/types/admin-center/admin-center.types';
import Avatars from '@/components/avatars/avatars';
import { AvatarNamesMap } from '@/shared/constants';
import SingleAvatar from '@/components/common/single-avatar/single-avatar';
import { useTranslation } from 'react-i18next';
@@ -68,26 +63,30 @@ const SettingTeamDrawer: React.FC<SettingTeamDrawerProps> = ({
};
const handleFormSubmit = async (values: any) => {
console.log(values);
// const newTeam: TeamsType = {
// teamId: teamId,
// teamName: values.name,
// membersCount: team?.membersCount || 1,
// members: team?.members || ['Raveesha Dilanka'],
// owner: values.name,
// created: team?.created || new Date(),
// isActive: false,
// };
// dispatch(updateTeam(newTeam));
// dispatch(toggleSettingDrawer());
// form.resetFields();
// message.success('Team updated!');
try {
setUpdatingTeam(true);
const body = {
name: values.name,
teamMembers: teamData?.team_members || [],
};
const response = await adminCenterApiService.updateTeam(teamId, body);
if (response.done) {
setIsSettingDrawerOpen(false);
}
} catch (error) {
logger.error('Error updating team', error);
} finally {
setUpdatingTeam(false);
}
};
const roleOptions = [
{ value: 'Admin', label: t('admin') },
{ value: 'Member', label: t('member') },
{ value: 'Owner', label: t('owner') },
{ key: 'Admin', value: 'Admin', label: t('admin') },
{ key: 'Member', value: 'Member', label: t('member') },
{ key: 'Owner', value: 'Owner', label: t('owner'), disabled: true },
];
const columns: TableProps['columns'] = [
@@ -104,16 +103,56 @@ const SettingTeamDrawer: React.FC<SettingTeamDrawerProps> = ({
{
title: t('role'),
key: 'role',
render: (_, record: IOrganizationTeamMember) => (
<div>
render: (_, record: IOrganizationTeamMember) => {
const handleRoleChange = (value: string) => {
if (value === 'Owner') {
return;
}
// Update the team member's role in teamData
if (teamData && teamData.team_members) {
const updatedMembers = teamData.team_members.map(member => {
if (member.id === record.id) {
return { ...member, role_name: value };
}
return member;
});
setTeamData({
...teamData,
team_members: updatedMembers,
});
}
};
const isDisabled = record.role_name === 'Owner' || record.pending_invitation;
const tooltipTitle =
record.role_name === 'Owner'
? t('cannotChangeOwnerRole')
: record.pending_invitation
? t('pendingInvitation')
: '';
const selectComponent = (
<Select
style={{ width: '150px', height: '32px' }}
options={roleOptions.map(option => ({ ...option, key: option.value }))}
options={roleOptions}
defaultValue={record.role_name || ''}
disabled={record.role_name === 'Owner'}
disabled={isDisabled}
onChange={handleRoleChange}
/>
</div>
),
);
return (
<div>
{isDisabled ? (
<Tooltip title={tooltipTitle}>{selectComponent}</Tooltip>
) : (
selectComponent
)}
</div>
);
},
},
];

View File

@@ -1,4 +1,5 @@
import { Avatar, Tooltip } from 'antd';
import React, { useCallback, useMemo } from 'react';
import { InlineMember } from '@/types/teamMembers/inlineMember.types';
interface AvatarsProps {
@@ -6,41 +7,55 @@ interface AvatarsProps {
maxCount?: number;
}
const renderAvatar = (member: InlineMember, index: number) => (
<Tooltip
key={member.team_member_id || index}
title={member.end && member.names ? member.names.join(', ') : member.name}
>
{member.avatar_url ? (
<span onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<Avatar src={member.avatar_url} size={28} key={member.team_member_id || index} />
</span>
) : (
<span onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<Avatar
size={28}
key={member.team_member_id || index}
style={{
backgroundColor: member.color_code || '#ececec',
fontSize: '14px',
}}
>
{member.end && member.names ? member.name : member.name?.charAt(0).toUpperCase()}
</Avatar>
</span>
)}
</Tooltip>
);
const Avatars: React.FC<AvatarsProps> = React.memo(({ members, maxCount }) => {
const stopPropagation = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
}, []);
const renderAvatar = useCallback(
(member: InlineMember, index: number) => (
<Tooltip
key={member.team_member_id || index}
title={member.end && member.names ? member.names.join(', ') : member.name}
>
{member.avatar_url ? (
<span onClick={stopPropagation}>
<Avatar src={member.avatar_url} size={28} key={member.team_member_id || index} />
</span>
) : (
<span onClick={stopPropagation}>
<Avatar
size={28}
key={member.team_member_id || index}
style={{
backgroundColor: member.color_code || '#ececec',
fontSize: '14px',
}}
>
{member.end && member.names ? member.name : member.name?.charAt(0).toUpperCase()}
</Avatar>
</span>
)}
</Tooltip>
),
[stopPropagation]
);
const visibleMembers = useMemo(() => {
return maxCount ? members.slice(0, maxCount) : members;
}, [members, maxCount]);
const avatarElements = useMemo(() => {
return visibleMembers.map((member, index) => renderAvatar(member, index));
}, [visibleMembers, renderAvatar]);
const Avatars: React.FC<AvatarsProps> = ({ members, maxCount }) => {
const visibleMembers = maxCount ? members.slice(0, maxCount) : members;
return (
<div onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<Avatar.Group>
{visibleMembers.map((member, index) => renderAvatar(member, index))}
</Avatar.Group>
<div onClick={stopPropagation}>
<Avatar.Group>{avatarElements}</Avatar.Group>
</div>
);
};
});
Avatars.displayName = 'Avatars';
export default Avatars;

View File

@@ -101,7 +101,6 @@ const BoardAssigneeSelector = ({ task, groupId = null }: BoardAssigneeSelectorPr
const assignees = task?.assignees?.map(assignee => assignee.team_member_id);
return assignees?.includes(memberId);
};
const membersDropdownContent = (
<Card className="custom-card" styles={{ body: { padding: 8 } }}>
@@ -143,16 +142,16 @@ const BoardAssigneeSelector = ({ task, groupId = null }: BoardAssigneeSelectorPr
/>
</div>
<Flex vertical>
<Typography.Text>{member.name}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{member.email}&nbsp;
{member.pending_invitation && (
<Typography.Text type="danger" style={{ fontSize: 10 }}>
({t('pendingInvitation')})
</Typography.Text>
)}
</Typography.Text>
</Flex>
<Typography.Text>{member.name}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{member.email}&nbsp;
{member.pending_invitation && (
<Typography.Text type="danger" style={{ fontSize: 10 }}>
({t('pendingInvitation')})
</Typography.Text>
)}
</Typography.Text>
</Flex>
</List.Item>
))
) : (
@@ -201,7 +200,7 @@ const BoardAssigneeSelector = ({ task, groupId = null }: BoardAssigneeSelectorPr
type="dashed"
shape="circle"
size="small"
onClick={(e) => e.stopPropagation()}
onClick={e => e.stopPropagation()}
icon={
<PlusOutlined
style={{

View File

@@ -14,7 +14,7 @@ const CustomAvatarGroup = ({ task, sectionId }: CustomAvatarGroupProps) => {
<Flex
gap={4}
align="center"
onClick={(e) => e.stopPropagation()}
onClick={e => e.stopPropagation()}
style={{
borderRadius: 4,
cursor: 'pointer',

View File

@@ -86,7 +86,7 @@ const CustomDueDatePicker = ({
width: 26,
height: 26,
}}
onClick={(e) => {
onClick={e => {
e.stopPropagation(); // Keep this as a backup
setIsDatePickerOpen(true);
}}
@@ -98,4 +98,4 @@ const CustomDueDatePicker = ({
);
};
export default CustomDueDatePicker;
export default CustomDueDatePicker;

View File

@@ -284,7 +284,7 @@ const TaskCard: React.FC<taskProps> = ({ task }) => {
format={value => formatDate(value)}
/>
</div>
{task.sub_tasks_count && task.sub_tasks_count > 0 && (
{task.sub_tasks_count && task.sub_tasks_count > 1 && (
<Button
onClick={() => setIsSubTaskShow(!isSubTaskShow)}
size="small"
@@ -306,7 +306,7 @@ const TaskCard: React.FC<taskProps> = ({ task }) => {
{isSubTaskShow &&
task.sub_tasks_count &&
task.sub_tasks_count > 0 &&
task.sub_tasks_count > 1 &&
task.sub_tasks?.map(subtask => <SubTaskCard subtask={subtask} />)}
</div>
</div>

View File

@@ -0,0 +1,19 @@
.priority-dropdown .ant-dropdown-menu {
padding: 0 !important;
margin-top: 8px !important;
overflow: hidden;
}
.priority-dropdown .ant-dropdown-menu-item {
padding: 0 !important;
}
.priority-dropdown-card .ant-card-body {
padding: 0 !important;
}
.priority-menu .ant-menu-item {
display: flex;
align-items: center;
height: 32px;
}

View File

@@ -0,0 +1,67 @@
import { Flex, Typography } from 'antd';
import './priority-section.css';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useState, useEffect, useMemo } from 'react';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import { ITaskPriority } from '@/types/tasks/taskPriority.types';
import { DoubleLeftOutlined, MinusOutlined, PauseOutlined } from '@ant-design/icons';
type PrioritySectionProps = {
task: IProjectTask;
};
const PrioritySection = ({ task }: PrioritySectionProps) => {
const [selectedPriority, setSelectedPriority] = useState<ITaskPriority | undefined>(undefined);
const priorityList = useAppSelector(state => state.priorityReducer.priorities);
const themeMode = useAppSelector(state => state.themeReducer.mode);
// Update selectedPriority whenever task.priority or priorityList changes
useEffect(() => {
if (!task.priority || !priorityList.length) {
setSelectedPriority(undefined);
return;
}
const foundPriority = priorityList.find(priority => priority.id === task.priority);
setSelectedPriority(foundPriority);
}, [task.priority, priorityList]);
const priorityIcon = useMemo(() => {
if (!selectedPriority) return null;
const iconProps = {
style: {
color:
themeMode === 'dark' ? selectedPriority.color_code_dark : selectedPriority.color_code,
marginRight: '0.25rem',
},
};
switch (selectedPriority.name) {
case 'Low':
return <MinusOutlined {...iconProps} />;
case 'Medium':
return (
<PauseOutlined
{...iconProps}
style={{ ...iconProps.style, transform: 'rotate(90deg)' }}
/>
);
case 'High':
return (
<DoubleLeftOutlined
{...iconProps}
style={{ ...iconProps.style, transform: 'rotate(90deg)' }}
/>
);
default:
return null;
}
}, [selectedPriority, themeMode]);
if (!task.priority || !selectedPriority) return null;
return <Flex gap={4}>{priorityIcon}</Flex>;
};
export default PrioritySection;

View File

@@ -0,0 +1,84 @@
import { lazy, Suspense } from 'react';
import { Spin } from 'antd';
// Lazy load Chart.js components
const LazyBarChart = lazy(() =>
import('react-chartjs-2').then(module => ({ default: module.Bar }))
);
const LazyLineChart = lazy(() =>
import('react-chartjs-2').then(module => ({ default: module.Line }))
);
const LazyPieChart = lazy(() =>
import('react-chartjs-2').then(module => ({ default: module.Pie }))
);
const LazyDoughnutChart = lazy(() =>
import('react-chartjs-2').then(module => ({ default: module.Doughnut }))
);
// Lazy load Gantt components
const LazyGanttChart = lazy(() =>
import('gantt-task-react').then(module => ({ default: module.Gantt }))
);
// Chart loading fallback
const ChartLoadingFallback = () => (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '300px',
background: '#fafafa',
borderRadius: '8px',
border: '1px solid #f0f0f0'
}}>
<Spin size="large" />
</div>
);
// Wrapped components with Suspense
export const BarChart = (props: any) => (
<Suspense fallback={<ChartLoadingFallback />}>
<LazyBarChart {...props} />
</Suspense>
);
export const LineChart = (props: any) => (
<Suspense fallback={<ChartLoadingFallback />}>
<LazyLineChart {...props} />
</Suspense>
);
export const PieChart = (props: any) => (
<Suspense fallback={<ChartLoadingFallback />}>
<LazyPieChart {...props} />
</Suspense>
);
export const DoughnutChart = (props: any) => (
<Suspense fallback={<ChartLoadingFallback />}>
<LazyDoughnutChart {...props} />
</Suspense>
);
export const GanttChart = (props: any) => (
<Suspense fallback={<ChartLoadingFallback />}>
<LazyGanttChart {...props} />
</Suspense>
);
// Hook to preload chart libraries when needed
export const usePreloadCharts = () => {
const preloadCharts = () => {
// Preload Chart.js
import('react-chartjs-2');
import('chart.js');
// Preload Gantt
import('gantt-task-react');
};
return { preloadCharts };
};

View File

@@ -0,0 +1,29 @@
import React, { Suspense } from 'react';
import { Spin } from 'antd';
// Lazy load chart components to reduce initial bundle size
const LazyBar = React.lazy(() =>
import('react-chartjs-2').then(module => ({ default: module.Bar }))
);
const LazyDoughnut = React.lazy(() =>
import('react-chartjs-2').then(module => ({ default: module.Doughnut }))
);
interface ChartLoaderProps {
type: 'bar' | 'doughnut';
data: any;
options?: any;
[key: string]: any;
}
const ChartLoader: React.FC<ChartLoaderProps> = ({ type, ...props }) => {
const ChartComponent = type === 'bar' ? LazyBar : LazyDoughnut;
return (
<Suspense fallback={<Spin size="large" />}>
<ChartComponent {...props} />
</Suspense>
);
};
export default ChartLoader;

View File

@@ -15,7 +15,9 @@ const Collapsible = ({ isOpen, children, className = '', color }: CollapsiblePro
marginTop: '6px',
}}
className={`transition-all duration-300 ease-in-out ${
isOpen ? 'max-h-[2000px] opacity-100 overflow-x-scroll' : 'max-h-0 opacity-0 overflow-hidden'
isOpen
? 'max-h-[2000px] opacity-100 overflow-x-scroll'
: 'max-h-0 opacity-0 overflow-hidden'
} ${className}`}
>
{children}

View File

@@ -1,7 +1,10 @@
import { AutoComplete, Button, Drawer, Flex, Form, message, Select, Spin, Typography } from 'antd';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { toggleInviteMemberDrawer, triggerTeamMembersRefresh } from '../../../features/settings/member/memberSlice';
import {
toggleInviteMemberDrawer,
triggerTeamMembersRefresh,
} from '../../../features/settings/member/memberSlice';
import { useTranslation } from 'react-i18next';
import { useState, useEffect, useCallback } from 'react';
import { jobTitlesApiService } from '@/api/settings/job-titles/job-titles.api.service';
@@ -177,4 +180,4 @@ const InviteTeamMembers = () => {
);
};
export default InviteTeamMembers;
export default InviteTeamMembers;

View File

@@ -0,0 +1,341 @@
import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { PlusOutlined, UserAddOutlined } from '@ant-design/icons';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { toggleProjectMemberDrawer } from '@/features/projects/singleProject/members/projectMembersSlice';
import { ITeamMembersViewModel } from '@/types/teamMembers/teamMembersViewModel.types';
import { sortTeamMembers } from '@/utils/sort-team-members';
import { Avatar, Checkbox } from '@/components';
interface PeopleDropdownProps {
selectedMemberIds: string[];
onMemberToggle: (memberId: string, checked: boolean) => void;
onInviteClick?: () => void;
isDarkMode?: boolean;
className?: string;
buttonClassName?: string;
isLoading?: boolean;
loadMembers?: () => void;
pendingChanges?: Set<string>;
}
const PeopleDropdown: React.FC<PeopleDropdownProps> = ({
selectedMemberIds,
onMemberToggle,
onInviteClick,
isDarkMode = false,
className = '',
buttonClassName = '',
isLoading = false,
loadMembers,
pendingChanges = new Set(),
}) => {
const [isOpen, setIsOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [teamMembers, setTeamMembers] = useState<ITeamMembersViewModel>({ data: [], total: 0 });
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 });
const [hasLoadedMembers, setHasLoadedMembers] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const dispatch = useAppDispatch();
const members = useAppSelector(state => state.teamMembersReducer.teamMembers);
// Load members on demand when dropdown opens
useEffect(() => {
if (!hasLoadedMembers && loadMembers && isOpen) {
loadMembers();
setHasLoadedMembers(true);
}
}, [hasLoadedMembers, loadMembers, isOpen]);
const filteredMembers = useMemo(() => {
return teamMembers?.data?.filter(member =>
member.name?.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [teamMembers, searchQuery]);
// Update dropdown position
const updateDropdownPosition = useCallback(() => {
if (buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const dropdownHeight = 280; // More accurate height: header(40) + max-height(192) + footer(40) + padding
// Check if dropdown would go below viewport
const spaceBelow = viewportHeight - rect.bottom;
const shouldShowAbove = spaceBelow < dropdownHeight && rect.top > dropdownHeight;
setDropdownPosition({
top: shouldShowAbove ? rect.top - dropdownHeight - 4 : rect.bottom + 4,
left: rect.left,
});
}
}, []);
// Close dropdown when clicking outside and handle scroll
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
buttonRef.current &&
!buttonRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
const handleScroll = (event: Event) => {
if (isOpen) {
// Only close dropdown if scrolling happens outside the dropdown
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
};
const handleResize = () => {
if (isOpen) {
updateDropdownPosition();
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
window.addEventListener('scroll', handleScroll, true);
window.addEventListener('resize', handleResize);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
window.removeEventListener('scroll', handleScroll, true);
window.removeEventListener('resize', handleResize);
};
} else {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [isOpen, updateDropdownPosition]);
const handleDropdownToggle = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!isOpen) {
updateDropdownPosition();
// Prepare team members data when opening
const membersData = (members?.data || []).map(member => ({
...member,
selected: selectedMemberIds.includes(member.id || ''),
}));
const sortedMembers = sortTeamMembers(membersData);
setTeamMembers({ data: sortedMembers });
setIsOpen(true);
// Focus search input after opening
setTimeout(() => {
searchInputRef.current?.focus();
}, 0);
} else {
setIsOpen(false);
}
};
const handleMemberToggle = (memberId: string, checked: boolean) => {
if (!memberId) return;
onMemberToggle(memberId, checked);
// Update local team members state for dropdown UI
setTeamMembers(prev => ({
...prev,
data: (prev.data || []).map(member =>
member.id === memberId ? { ...member, selected: checked } : member
),
}));
};
const checkMemberSelected = (memberId: string) => {
if (!memberId) return false;
return selectedMemberIds.includes(memberId);
};
const handleInviteProjectMemberDrawer = () => {
setIsOpen(false); // Close the dropdown first
if (onInviteClick) {
onInviteClick();
} else {
dispatch(toggleProjectMemberDrawer()); // Then open the invite drawer
}
};
return (
<>
<button
ref={buttonRef}
onClick={handleDropdownToggle}
className={`
w-5 h-5 rounded-full border border-dashed flex items-center justify-center
transition-colors duration-200
${buttonClassName}
${
isOpen
? isDarkMode
? 'border-blue-500 bg-blue-900/20 text-blue-400'
: 'border-blue-500 bg-blue-50 text-blue-600'
: isDarkMode
? 'border-gray-600 hover:border-gray-500 hover:bg-gray-800 text-gray-400'
: 'border-gray-300 hover:border-gray-400 hover:bg-gray-100 text-gray-600'
}
`}
>
<PlusOutlined className="text-xs" />
</button>
{isOpen &&
createPortal(
<div
ref={dropdownRef}
onClick={e => e.stopPropagation()}
className={`
fixed w-72 rounded-md shadow-lg border people-dropdown-portal ${className}
${isDarkMode ? 'bg-gray-800 border-gray-600' : 'bg-white border-gray-200'}
`}
style={{
top: dropdownPosition.top,
left: dropdownPosition.left,
}}
>
{/* Header */}
<div className={`p-2 border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`}>
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder="Search members..."
className={`
w-full px-2 py-1 text-xs rounded border
${
isDarkMode
? 'bg-gray-700 border-gray-600 text-gray-100 placeholder-gray-400 focus:border-blue-500'
: 'bg-white border-gray-300 text-gray-900 placeholder-gray-500 focus:border-blue-500'
}
focus:outline-none focus:ring-1 focus:ring-blue-500
`}
/>
</div>
{/* Members List */}
<div className="max-h-48 overflow-y-auto">
{filteredMembers && filteredMembers.length > 0 ? (
filteredMembers.map(member => (
<div
key={member.id}
className={`
flex items-center gap-2 p-2 cursor-pointer transition-colors
${
member.pending_invitation
? 'opacity-50 cursor-not-allowed'
: isDarkMode
? 'hover:bg-gray-700'
: 'hover:bg-gray-50'
}
`}
onClick={() => {
if (!member.pending_invitation) {
const isSelected = checkMemberSelected(member.id || '');
handleMemberToggle(member.id || '', !isSelected);
}
}}
style={{
// Add visual feedback for immediate response
transition: 'all 0.15s ease-in-out',
}}
>
<div className="relative">
<span onClick={e => e.stopPropagation()}>
<Checkbox
checked={checkMemberSelected(member.id || '')}
onChange={checked => handleMemberToggle(member.id || '', checked)}
disabled={
member.pending_invitation || pendingChanges.has(member.id || '')
}
isDarkMode={isDarkMode}
/>
</span>
{pendingChanges.has(member.id || '') && (
<div
className={`absolute inset-0 flex items-center justify-center ${
isDarkMode ? 'bg-gray-800/50' : 'bg-white/50'
}`}
>
<div
className={`w-3 h-3 border border-t-transparent rounded-full animate-spin ${
isDarkMode ? 'border-blue-400' : 'border-blue-600'
}`}
/>
</div>
)}
</div>
<Avatar
src={member.avatar_url}
name={member.name || ''}
size={24}
isDarkMode={isDarkMode}
/>
<div className="flex-1 min-w-0">
<div
className={`text-xs font-medium truncate ${isDarkMode ? 'text-gray-100' : 'text-gray-900'}`}
>
{member.name}
</div>
<div
className={`text-xs truncate ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}
>
{member.email}
{member.pending_invitation && (
<span className="text-red-400 ml-1">(Pending)</span>
)}
</div>
</div>
</div>
))
) : (
<div
className={`p-4 text-center ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}
>
<div className="text-xs">
{isLoading ? 'Loading members...' : 'No members found'}
</div>
</div>
)}
</div>
{/* Footer */}
<div className={`p-2 border-t ${isDarkMode ? 'border-gray-600' : 'border-gray-200'}`}>
<button
className={`
w-full flex items-center justify-center gap-1 px-2 py-1 text-xs rounded
transition-colors
${isDarkMode ? 'text-blue-400 hover:bg-gray-700' : 'text-blue-600 hover:bg-blue-50'}
`}
onClick={handleInviteProjectMemberDrawer}
>
<UserAddOutlined />
Invite member
</button>
</div>
</div>,
document.body
)}
</>
);
};
export default PeopleDropdown;

View File

@@ -1,3 +1,4 @@
import React from 'react';
import Icon, {
CheckCircleOutlined,
ClockCircleOutlined,
@@ -12,10 +13,23 @@ const iconMap = {
'check-circle': CheckCircleOutlined,
};
const ProjectStatusIcon = ({ iconName, color }: { iconName: string; color: string }) => {
const IconComponent = iconMap[iconName as keyof typeof iconMap];
if (!IconComponent) return null;
return <IconComponent style={{ color: color }} />;
};
interface ProjectStatusIconProps {
iconName: string;
color: string;
}
const ProjectStatusIcon = React.forwardRef<HTMLSpanElement, ProjectStatusIconProps>(
({ iconName, color }, ref) => {
const IconComponent = iconMap[iconName as keyof typeof iconMap];
if (!IconComponent) return null;
return (
<span ref={ref} style={{ display: 'inline-block' }}>
<IconComponent style={{ color: color }} />
</span>
);
}
);
ProjectStatusIcon.displayName = 'ProjectStatusIcon';
export default ProjectStatusIcon;

View File

@@ -91,4 +91,3 @@
.custom-template-list .selected-custom-template:hover {
background-color: var(--color-paleBlue);
}

View File

@@ -0,0 +1,28 @@
import React from 'react';
import { Tooltip, TooltipProps } from 'antd';
interface TooltipWrapperProps extends Omit<TooltipProps, 'children'> {
children: React.ReactElement;
}
/**
* TooltipWrapper - A wrapper component that helps avoid findDOMNode warnings in React StrictMode
*
* This component ensures that the child element can properly receive refs from Ant Design's Tooltip
* by wrapping it in a div with a ref when necessary.
*/
const TooltipWrapper = React.forwardRef<HTMLDivElement, TooltipWrapperProps>(
({ children, ...tooltipProps }, ref) => {
return (
<Tooltip {...tooltipProps}>
<div ref={ref} style={{ display: 'inline-block' }}>
{children}
</div>
</Tooltip>
);
}
);
TooltipWrapper.displayName = 'TooltipWrapper';
export default TooltipWrapper;

View File

@@ -0,0 +1,43 @@
.enhanced-kanban-board {
width: 100%;
height: 100%;
overflow-x: auto;
overflow-y: hidden;
padding: 16px;
background: var(--ant-color-bg-container);
color: var(--ant-color-text);
}
.kanban-groups-container {
display: flex;
gap: 16px;
min-height: calc(100vh - 350px);
padding-bottom: 16px;
}
/* Ensure groups have proper spacing for drop indicators */
.enhanced-kanban-group {
flex-shrink: 0;
}
/* Smooth transitions for all drag and drop interactions */
.enhanced-kanban-board * {
transition: all 0.2s ease;
}
/* Loading state */
.enhanced-kanban-board .ant-spin {
display: flex;
justify-content: center;
align-items: center;
min-height: 200px;
}
/* Empty state */
.enhanced-kanban-board .ant-empty {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 200px;
}

View File

@@ -0,0 +1,511 @@
import React, { useEffect, useState, useMemo } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { Card, Spin, Empty } from 'antd';
import {
DndContext,
DragOverlay,
DragStartEvent,
DragEndEvent,
DragOverEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
UniqueIdentifier,
getFirstCollision,
pointerWithin,
rectIntersection,
} from '@dnd-kit/core';
import { SortableContext, horizontalListSortingStrategy } from '@dnd-kit/sortable';
import { RootState } from '@/app/store';
import {
fetchEnhancedKanbanGroups,
reorderEnhancedKanbanTasks,
reorderEnhancedKanbanGroups,
setDragState,
reorderTasks,
reorderGroups,
fetchEnhancedKanbanTaskAssignees,
fetchEnhancedKanbanLabels,
} from '@/features/enhanced-kanban/enhanced-kanban.slice';
import EnhancedKanbanGroup from './EnhancedKanbanGroup';
import './EnhancedKanbanBoard.css';
import { useSocket } from '@/socket/socketContext';
import { useAppSelector } from '@/hooks/useAppSelector';
import { SocketEvents } from '@/shared/socket-events';
import logger from '@/utils/errorLogger';
import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
import { ITaskStatusCreateRequest } from '@/types/tasks/task-status-create-request';
import alertService from '@/services/alerts/alertService';
import { IGroupBy } from '@/features/enhanced-kanban/enhanced-kanban.slice';
import EnhancedKanbanCreateSection from './EnhancedKanbanCreateSection';
import ImprovedTaskFilters from '../task-management/improved-task-filters';
import { fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice';
import { useFilterDataLoader } from '@/hooks/useFilterDataLoader';
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
import { useAuthService } from '@/hooks/useAuth';
// Import the TaskListFilters component
const TaskListFilters = React.lazy(
() => import('@/pages/projects/projectView/taskList/task-list-filters/task-list-filters')
);
interface EnhancedKanbanBoardProps {
projectId: string;
className?: string;
}
const EnhancedKanbanBoard: React.FC<EnhancedKanbanBoardProps> = ({ projectId, className = '' }) => {
const dispatch = useDispatch();
const { taskGroups, loadingGroups, error, dragState, performanceMetrics } = useSelector(
(state: RootState) => state.enhancedKanbanReducer
);
const { socket } = useSocket();
const authService = useAuthService();
const teamId = authService.getCurrentSession()?.team_id;
const groupBy = useSelector((state: RootState) => state.enhancedKanbanReducer.groupBy);
const project = useAppSelector((state: RootState) => state.projectReducer.project);
const { statusCategories, status: existingStatuses } = useAppSelector(
state => state.taskStatusReducer
);
const themeMode = useAppSelector(state => state.themeReducer.mode);
// Load filter data
useFilterDataLoader();
// Set up socket event handlers for real-time updates
useTaskSocketHandlers();
// Local state for drag overlay
const [activeTask, setActiveTask] = useState<any>(null);
const [activeGroup, setActiveGroup] = useState<any>(null);
const [overId, setOverId] = useState<UniqueIdentifier | null>(null);
// Sensors for drag and drop
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor)
);
useEffect(() => {
if (projectId) {
dispatch(fetchEnhancedKanbanGroups(projectId) as any);
// Load filter data for enhanced kanban
dispatch(fetchEnhancedKanbanTaskAssignees(projectId) as any);
dispatch(fetchEnhancedKanbanLabels(projectId) as any);
}
if (!statusCategories.length) {
dispatch(fetchStatusesCategories() as any);
}
}, [dispatch, projectId]);
// Get all task IDs for sortable context
const allTaskIds = useMemo(
() => taskGroups.flatMap(group => group.tasks.map(task => task.id!)),
[taskGroups]
);
const allGroupIds = useMemo(() => taskGroups.map(group => group.id), [taskGroups]);
// Enhanced collision detection
const collisionDetectionStrategy = (args: any) => {
// First, let's see if we're colliding with any droppable areas
const pointerIntersections = pointerWithin(args);
const intersections =
pointerIntersections.length > 0 ? pointerIntersections : rectIntersection(args);
let overId = getFirstCollision(intersections, 'id');
if (overId) {
// Check if we're over a task or a group
const overGroup = taskGroups.find(g => g.id === overId);
if (overGroup) {
// We're over a group, check if there are tasks in it
if (overGroup.tasks.length > 0) {
// Find the closest task within this group
const taskIntersections = pointerWithin({
...args,
droppableContainers: args.droppableContainers.filter(
(container: any) => container.data.current?.type === 'task'
),
});
if (taskIntersections.length > 0) {
overId = taskIntersections[0].id;
}
}
}
}
return overId ? [{ id: overId }] : [];
};
const handleDragStart = (event: DragStartEvent) => {
const { active } = event;
const activeId = active.id as string;
const activeData = active.data.current;
// Check if dragging a group or a task
if (activeData?.type === 'group') {
// Dragging a group
const foundGroup = taskGroups.find(g => g.id === activeId);
setActiveGroup(foundGroup);
setActiveTask(null);
dispatch(
setDragState({
activeTaskId: null,
activeGroupId: activeId,
isDragging: true,
})
);
} else {
// Dragging a task
let foundTask = null;
let foundGroup = null;
for (const group of taskGroups) {
const task = group.tasks.find(t => t.id === activeId);
if (task) {
foundTask = task;
foundGroup = group;
break;
}
}
setActiveTask(foundTask);
setActiveGroup(null);
dispatch(
setDragState({
activeTaskId: activeId,
activeGroupId: foundGroup?.id || null,
isDragging: true,
})
);
}
};
const handleDragOver = (event: DragOverEvent) => {
const { active, over } = event;
if (!over) {
setOverId(null);
dispatch(setDragState({ overId: null }));
return;
}
const activeId = active.id as string;
const overId = over.id as string;
setOverId(overId);
// Update over ID in Redux
dispatch(setDragState({ overId }));
};
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
const activeData = active.data.current;
// Reset local state
setActiveTask(null);
setActiveGroup(null);
setOverId(null);
// Reset Redux drag state
dispatch(
setDragState({
activeTaskId: null,
activeGroupId: null,
overId: null,
isDragging: false,
})
);
if (!over) return;
const activeId = active.id as string;
const overId = over.id as string;
// Handle group (column) reordering
if (activeData?.type === 'group') {
// Don't allow reordering if groupBy is phases
if (groupBy === IGroupBy.PHASE) {
return;
}
const fromIndex = taskGroups.findIndex(g => g.id === activeId);
const toIndex = taskGroups.findIndex(g => g.id === overId);
if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
// Create new array with reordered groups
const reorderedGroups = [...taskGroups];
const [movedGroup] = reorderedGroups.splice(fromIndex, 1);
reorderedGroups.splice(toIndex, 0, movedGroup);
// Synchronous UI update for immediate feedback
dispatch(reorderGroups({ fromIndex, toIndex, reorderedGroups }));
dispatch(reorderEnhancedKanbanGroups({ fromIndex, toIndex, reorderedGroups }) as any);
// Prepare column order for API
const columnOrder = reorderedGroups.map(group => group.id);
// Call API to update status order
try {
const requestBody: ITaskStatusCreateRequest = {
status_order: columnOrder,
};
const response = await statusApiService.updateStatusOrder(requestBody, projectId);
if (!response.done) {
// Revert the change if API call fails
const revertedGroups = [...reorderedGroups];
const [movedBackGroup] = revertedGroups.splice(toIndex, 1);
revertedGroups.splice(fromIndex, 0, movedBackGroup);
dispatch(
reorderGroups({
fromIndex: toIndex,
toIndex: fromIndex,
reorderedGroups: revertedGroups,
})
);
alertService.error('Failed to update column order', 'Please try again');
}
} catch (error) {
// Revert the change if API call fails
const revertedGroups = [...reorderedGroups];
const [movedBackGroup] = revertedGroups.splice(toIndex, 1);
revertedGroups.splice(fromIndex, 0, movedBackGroup);
dispatch(
reorderGroups({
fromIndex: toIndex,
toIndex: fromIndex,
reorderedGroups: revertedGroups,
})
);
alertService.error('Failed to update column order', 'Please try again');
logger.error('Failed to update column order', error);
}
}
return;
}
// Handle task reordering (within or between groups)
let sourceGroup = null;
let targetGroup = null;
let sourceIndex = -1;
let targetIndex = -1;
// Find source group and index
for (const group of taskGroups) {
const taskIndex = group.tasks.findIndex(t => t.id === activeId);
if (taskIndex !== -1) {
sourceGroup = group;
sourceIndex = taskIndex;
break;
}
}
// Find target group and index
for (const group of taskGroups) {
const taskIndex = group.tasks.findIndex(t => t.id === overId);
if (taskIndex !== -1) {
targetGroup = group;
targetIndex = taskIndex;
break;
}
}
// If dropping on a group (not a task)
if (!targetGroup) {
targetGroup = taskGroups.find(g => g.id === overId);
if (targetGroup) {
targetIndex = targetGroup.tasks.length; // Add to end of group
}
}
if (!sourceGroup || !targetGroup || sourceIndex === -1) return;
// Don't do anything if dropping in the same position
if (sourceGroup.id === targetGroup.id && sourceIndex === targetIndex) return;
// Create updated task arrays
const updatedSourceTasks = [...sourceGroup.tasks];
const [movedTask] = updatedSourceTasks.splice(sourceIndex, 1);
let updatedTargetTasks: any[];
if (sourceGroup.id === targetGroup.id) {
// Moving within the same group
updatedTargetTasks = updatedSourceTasks;
updatedTargetTasks.splice(targetIndex, 0, movedTask);
} else {
// Moving between different groups
updatedTargetTasks = [...targetGroup.tasks];
updatedTargetTasks.splice(targetIndex, 0, movedTask);
}
// Synchronous UI update
dispatch(
reorderTasks({
activeGroupId: sourceGroup.id,
overGroupId: targetGroup.id,
fromIndex: sourceIndex,
toIndex: targetIndex,
task: movedTask,
updatedSourceTasks,
updatedTargetTasks,
})
);
dispatch(
reorderEnhancedKanbanTasks({
activeGroupId: sourceGroup.id,
overGroupId: targetGroup.id,
fromIndex: sourceIndex,
toIndex: targetIndex,
task: movedTask,
updatedSourceTasks,
updatedTargetTasks,
}) as any
);
// --- Socket emit for task sort order ---
if (socket && projectId && movedTask) {
// Find sort_order for from and to
const fromSortOrder = movedTask.sort_order;
let toSortOrder = -1;
let toLastIndex = false;
if (targetIndex === targetGroup.tasks.length) {
// Dropping at the end
toSortOrder = -1;
toLastIndex = true;
} else if (targetGroup.tasks[targetIndex]) {
toSortOrder =
typeof targetGroup.tasks[targetIndex].sort_order === 'number'
? targetGroup.tasks[targetIndex].sort_order!
: -1;
toLastIndex = false;
} else if (targetGroup.tasks.length > 0) {
const lastSortOrder = targetGroup.tasks[targetGroup.tasks.length - 1].sort_order;
toSortOrder = typeof lastSortOrder === 'number' ? lastSortOrder! : -1;
toLastIndex = false;
}
const body = {
project_id: projectId,
from_index: fromSortOrder,
to_index: toSortOrder,
to_last_index: toLastIndex,
from_group: sourceGroup.id,
to_group: targetGroup.id,
group_by: groupBy || 'status',
task: movedTask,
team_id: teamId || project?.team_id || '',
};
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), body);
}
};
if (error) {
return (
<Card className={className}>
<Empty description={`Error loading tasks: ${error}`} image={Empty.PRESENTED_IMAGE_SIMPLE} />
</Card>
);
}
return (
<>
{/* Task Filters */}
<div className="mb-4">
<React.Suspense fallback={<div>Loading filters...</div>}>
<ImprovedTaskFilters position="board" />
</React.Suspense>
</div>
<div className={`enhanced-kanban-board ${className}`}>
{/* Performance Monitor - only show for large datasets */}
{/* {performanceMetrics.totalTasks > 100 && <PerformanceMonitor />} */}
{loadingGroups ? (
<Card>
<div className="flex justify-center items-center py-8">
<Spin size="large" />
</div>
</Card>
) : taskGroups.length === 0 ? (
<Card>
<Empty description="No tasks found" image={Empty.PRESENTED_IMAGE_SIMPLE} />
</Card>
) : (
<DndContext
sensors={sensors}
collisionDetection={collisionDetectionStrategy}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<SortableContext items={allGroupIds} strategy={horizontalListSortingStrategy}>
<div className="kanban-groups-container">
{taskGroups.map(group => (
<EnhancedKanbanGroup
key={group.id}
group={group}
activeTaskId={dragState.activeTaskId}
overId={overId as string | null}
/>
))}
<EnhancedKanbanCreateSection />
</div>
</SortableContext>
<DragOverlay>
{activeTask && (
<div
style={{
background: themeMode === 'dark' ? '#23272f' : '#fff',
borderRadius: 8,
boxShadow: '0 4px 16px rgba(0,0,0,0.12)',
padding: '12px 20px',
minWidth: 180,
maxWidth: 340,
opacity: 0.95,
fontWeight: 600,
fontSize: 16,
color: themeMode === 'dark' ? '#fff' : '#23272f',
display: 'flex',
alignItems: 'center',
}}
>
{activeTask.name}
</div>
)}
{activeGroup && (
<div
style={{
background: themeMode === 'dark' ? '#23272f' : '#fff',
borderRadius: 8,
boxShadow: '0 4px 16px rgba(0,0,0,0.12)',
padding: '16px 24px',
minWidth: 220,
maxWidth: 320,
display: 'flex',
alignItems: 'center',
gap: 12,
opacity: 0.95,
}}
>
<h3 style={{ margin: 0, fontWeight: 600, fontSize: 18 }}>{activeGroup.name}</h3>
<span style={{ fontSize: 15, color: '#888' }}>({activeGroup.tasks.length})</span>
</div>
)}
</DragOverlay>
</DndContext>
)}
</div>
</>
);
};
export default EnhancedKanbanBoard;

View File

@@ -0,0 +1,331 @@
import React, { useState, useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { RootState } from '@/app/store';
import '../EnhancedKanbanBoard.css';
import '../EnhancedKanbanGroup.css';
import '../EnhancedKanbanTaskCard.css';
import ImprovedTaskFilters from '../../task-management/improved-task-filters';
import Card from 'antd/es/card';
import Spin from 'antd/es/spin';
import Empty from 'antd/es/empty';
import { reorderGroups, reorderEnhancedKanbanGroups, reorderTasks, reorderEnhancedKanbanTasks, fetchEnhancedKanbanLabels, fetchEnhancedKanbanGroups, fetchEnhancedKanbanTaskAssignees } from '@/features/enhanced-kanban/enhanced-kanban.slice';
import { fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice';
import { useAppSelector } from '@/hooks/useAppSelector';
import KanbanGroup from './KanbanGroup';
import EnhancedKanbanCreateSection from '../EnhancedKanbanCreateSection';
import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events';
import { useAuthService } from '@/hooks/useAuth';
import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
import alertService from '@/services/alerts/alertService';
import logger from '@/utils/errorLogger';
import Skeleton from 'antd/es/skeleton/Skeleton';
import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status';
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ projectId }) => {
const dispatch = useDispatch();
const authService = useAuthService();
const { socket } = useSocket();
const project = useAppSelector((state: RootState) => state.projectReducer.project);
const groupBy = useSelector((state: RootState) => state.enhancedKanbanReducer.groupBy);
const teamId = authService.getCurrentSession()?.team_id;
const {
taskGroups,
loadingGroups,
error,
} = useSelector((state: RootState) => state.enhancedKanbanReducer);
const [draggedGroupId, setDraggedGroupId] = useState<string | null>(null);
const [draggedTaskId, setDraggedTaskId] = useState<string | null>(null);
const [draggedTaskGroupId, setDraggedTaskGroupId] = useState<string | null>(null);
const [hoveredGroupId, setHoveredGroupId] = useState<string | null>(null);
const [hoveredTaskIdx, setHoveredTaskIdx] = useState<number | null>(null);
const [dragType, setDragType] = useState<'group' | 'task' | null>(null);
const { statusCategories, status: existingStatuses } = useAppSelector((state) => state.taskStatusReducer);
// Set up socket event handlers for real-time updates
useTaskSocketHandlers();
useEffect(() => {
if (projectId) {
dispatch(fetchEnhancedKanbanGroups(projectId) as any);
// Load filter data for enhanced kanban
dispatch(fetchEnhancedKanbanTaskAssignees(projectId) as any);
dispatch(fetchEnhancedKanbanLabels(projectId) as any);
}
if (!statusCategories.length) {
dispatch(fetchStatusesCategories() as any);
}
}, [dispatch, projectId]);
// Reset drag state if taskGroups changes (e.g., real-time update)
useEffect(() => {
setDraggedGroupId(null);
setDraggedTaskId(null);
setDraggedTaskGroupId(null);
setHoveredGroupId(null);
setHoveredTaskIdx(null);
setDragType(null);
}, [taskGroups]);
// Group drag handlers
const handleGroupDragStart = (e: React.DragEvent, groupId: string) => {
setDraggedGroupId(groupId);
setDragType('group');
e.dataTransfer.effectAllowed = 'move';
};
const handleGroupDragOver = (e: React.DragEvent) => {
if (dragType !== 'group') return;
e.preventDefault();
};
const handleGroupDrop = async (e: React.DragEvent, targetGroupId: string) => {
if (dragType !== 'group') return;
e.preventDefault();
if (!draggedGroupId || draggedGroupId === targetGroupId) return;
// Calculate new order and dispatch
const fromIdx = taskGroups.findIndex(g => g.id === draggedGroupId);
const toIdx = taskGroups.findIndex(g => g.id === targetGroupId);
if (fromIdx === -1 || toIdx === -1) return;
const reorderedGroups = [...taskGroups];
const [moved] = reorderedGroups.splice(fromIdx, 1);
reorderedGroups.splice(toIdx, 0, moved);
dispatch(reorderGroups({ fromIndex: fromIdx, toIndex: toIdx, reorderedGroups }));
dispatch(reorderEnhancedKanbanGroups({ fromIndex: fromIdx, toIndex: toIdx, reorderedGroups }) as any);
// API call for group order
try {
const columnOrder = reorderedGroups.map(group => group.id);
const requestBody = { status_order: columnOrder };
const response = await statusApiService.updateStatusOrder(requestBody, projectId);
if (!response.done) {
// Revert the change if API call fails
const revertedGroups = [...reorderedGroups];
const [movedBackGroup] = revertedGroups.splice(toIdx, 1);
revertedGroups.splice(fromIdx, 0, movedBackGroup);
dispatch(reorderGroups({ fromIndex: toIdx, toIndex: fromIdx, reorderedGroups: revertedGroups }));
alertService.error('Failed to update column order', 'Please try again');
}
} catch (error) {
// Revert the change if API call fails
const revertedGroups = [...reorderedGroups];
const [movedBackGroup] = revertedGroups.splice(toIdx, 1);
revertedGroups.splice(fromIdx, 0, movedBackGroup);
dispatch(reorderGroups({ fromIndex: toIdx, toIndex: fromIdx, reorderedGroups: revertedGroups }));
alertService.error('Failed to update column order', 'Please try again');
logger.error('Failed to update column order', error);
}
setDraggedGroupId(null);
setDragType(null);
};
// Task drag handlers
const handleTaskDragStart = (e: React.DragEvent, taskId: string, groupId: string) => {
setDraggedTaskId(taskId);
setDraggedTaskGroupId(groupId);
setDragType('task');
e.dataTransfer.effectAllowed = 'move';
};
const handleTaskDragOver = (e: React.DragEvent, groupId: string, taskIdx: number | null) => {
if (dragType !== 'task') return;
e.preventDefault();
if (draggedTaskId) {
setHoveredGroupId(groupId);
}
if (taskIdx === null) {
setHoveredTaskIdx(0);
} else {
setHoveredTaskIdx(taskIdx);
};
};
const handleTaskDrop = async (e: React.DragEvent, targetGroupId: string, targetTaskIdx: number | null) => {
if (dragType !== 'task') return;
e.preventDefault();
if (!draggedTaskId || !draggedTaskGroupId || hoveredGroupId === null || hoveredTaskIdx === null) return;
// Calculate new order and dispatch
const sourceGroup = taskGroups.find(g => g.id === draggedTaskGroupId);
const targetGroup = taskGroups.find(g => g.id === targetGroupId);
if (!sourceGroup || !targetGroup) return;
const taskIdx = sourceGroup.tasks.findIndex(t => t.id === draggedTaskId);
if (taskIdx === -1) return;
const movedTask = sourceGroup.tasks[taskIdx];
if (groupBy === 'status' && movedTask.id) {
if (sourceGroup.id !== targetGroup.id) {
const canContinue = await checkTaskDependencyStatus(movedTask.id, targetGroupId);
if (!canContinue) {
alertService.error(
'Task is not completed',
'Please complete the task dependencies before proceeding'
);
return;
}
}
}
let insertIdx = hoveredTaskIdx;
// Handle same group reordering
if (sourceGroup.id === targetGroup.id) {
// Create a single updated array for the same group
const updatedTasks = [...sourceGroup.tasks];
updatedTasks.splice(taskIdx, 1); // Remove from original position
// Adjust insert index if moving forward in the same array
if (taskIdx < insertIdx) {
insertIdx--;
}
if (insertIdx < 0) insertIdx = 0;
if (insertIdx > updatedTasks.length) insertIdx = updatedTasks.length;
updatedTasks.splice(insertIdx, 0, movedTask); // Insert at new position
dispatch(reorderTasks({
activeGroupId: sourceGroup.id,
overGroupId: targetGroup.id,
fromIndex: taskIdx,
toIndex: insertIdx,
task: movedTask,
updatedSourceTasks: updatedTasks,
updatedTargetTasks: updatedTasks,
}));
dispatch(reorderEnhancedKanbanTasks({
activeGroupId: sourceGroup.id,
overGroupId: targetGroup.id,
fromIndex: taskIdx,
toIndex: insertIdx,
task: movedTask,
updatedSourceTasks: updatedTasks,
updatedTargetTasks: updatedTasks,
}) as any);
} else {
// Handle cross-group reordering
const updatedSourceTasks = [...sourceGroup.tasks];
updatedSourceTasks.splice(taskIdx, 1);
const updatedTargetTasks = [...targetGroup.tasks];
if (insertIdx < 0) insertIdx = 0;
if (insertIdx > updatedTargetTasks.length) insertIdx = updatedTargetTasks.length;
updatedTargetTasks.splice(insertIdx, 0, movedTask);
dispatch(reorderTasks({
activeGroupId: sourceGroup.id,
overGroupId: targetGroup.id,
fromIndex: taskIdx,
toIndex: insertIdx,
task: movedTask,
updatedSourceTasks,
updatedTargetTasks,
}));
dispatch(reorderEnhancedKanbanTasks({
activeGroupId: sourceGroup.id,
overGroupId: targetGroup.id,
fromIndex: taskIdx,
toIndex: insertIdx,
task: movedTask,
updatedSourceTasks,
updatedTargetTasks,
}) as any);
}
// Socket emit for task order
if (socket && projectId && teamId && movedTask) {
let toSortOrder = -1;
let toLastIndex = false;
if (insertIdx === targetGroup.tasks.length) {
toSortOrder = -1;
toLastIndex = true;
} else if (targetGroup.tasks[insertIdx]) {
const sortOrder = targetGroup.tasks[insertIdx].sort_order;
toSortOrder = typeof sortOrder === 'number' ? sortOrder : 0;
toLastIndex = false;
} else if (targetGroup.tasks.length > 0) {
const lastSortOrder = targetGroup.tasks[targetGroup.tasks.length - 1].sort_order;
toSortOrder = typeof lastSortOrder === 'number' ? lastSortOrder : 0;
toLastIndex = false;
}
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), {
project_id: projectId,
from_index: movedTask.sort_order ?? 0,
to_index: toSortOrder,
to_last_index: toLastIndex,
from_group: sourceGroup.id,
to_group: targetGroup.id,
group_by: groupBy || 'status',
task: movedTask,
team_id: teamId,
});
}
setDraggedTaskId(null);
setDraggedTaskGroupId(null);
setHoveredGroupId(null);
setHoveredTaskIdx(null);
setDragType(null);
};
const handleDragEnd = () => {
setHoveredGroupId(null);
setHoveredTaskIdx(null);
};
// Note: Socket event handlers are now managed by useTaskSocketHandlers hook
// This includes TASK_NAME_CHANGE, QUICK_TASK, and other real-time updates
if (error) {
return (
<Card>
<Empty description={`Error loading tasks: ${error}`} image={Empty.PRESENTED_IMAGE_SIMPLE} />
</Card>
);
}
return (
<>
<div className="mb-4">
<React.Suspense fallback={<div>Loading filters...</div>}>
<ImprovedTaskFilters position="board" />
</React.Suspense>
</div>
<div className="enhanced-kanban-board">
{loadingGroups ? (
<div className="flex flex-row gap-2 h-[600px]">
<div className="rounded bg-gray-200 dark:bg-gray-700 animate-pulse w-1/4" style={{ height: '60%' }} />
<div className="rounded bg-gray-200 dark:bg-gray-700 animate-pulse w-1/4" style={{ height: '100%' }} />
<div className="rounded bg-gray-200 dark:bg-gray-700 animate-pulse w-1/4" style={{ height: '80%' }} />
<div className="rounded bg-gray-200 dark:bg-gray-700 animate-pulse w-1/4" style={{ height: '40%' }} />
</div>
) : taskGroups.length === 0 ? (
<Card>
<Empty description="No tasks found" image={Empty.PRESENTED_IMAGE_SIMPLE} />
</Card>
) : (
<div className="kanban-groups-container">
{taskGroups.map(group => (
<KanbanGroup
key={group.id}
group={group}
onGroupDragStart={handleGroupDragStart}
onGroupDragOver={handleGroupDragOver}
onGroupDrop={handleGroupDrop}
onTaskDragStart={handleTaskDragStart}
onTaskDragOver={handleTaskDragOver}
onTaskDrop={handleTaskDrop}
onDragEnd={handleDragEnd}
hoveredTaskIdx={hoveredGroupId === group.id ? hoveredTaskIdx : null}
hoveredGroupId={hoveredGroupId}
/>
))}
<EnhancedKanbanCreateSection />
</div>
)}
</div>
</>
);
};
export default EnhancedKanbanBoardNativeDnD;

View File

@@ -0,0 +1,608 @@
import React, { memo, useMemo, useState, useRef, useEffect } from 'react';
import { useAppSelector } from '@/hooks/useAppSelector';
import { ITaskListGroup } from '@/types/tasks/taskList.types';
import TaskCard from './TaskCard';
import { themeWiseColor } from '@/utils/themeWiseColor';
import EnhancedKanbanCreateTaskCard from '../EnhancedKanbanCreateTaskCard';
import { useTranslation } from 'react-i18next';
import { useAuthService } from '@/hooks/useAuth';
import useIsProjectManager from '@/hooks/useIsProjectManager';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { ITaskStatusUpdateModel } from '@/types/tasks/task-status-update-model.types';
import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
import { fetchStatuses } from '@/features/taskAttributes/taskStatusSlice';
import logger from '@/utils/errorLogger';
import { evt_project_board_column_setting_click } from '@/shared/worklenz-analytics-events';
import { phasesApiService } from '@/api/taskAttributes/phases/phases.api.service';
import { ITaskPhase } from '@/types/tasks/taskPhase.types';
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
import {
deleteStatusToggleDrawer,
seletedStatusCategory,
} from '@/features/projects/status/DeleteStatusSlice';
import {
fetchEnhancedKanbanGroups,
IGroupBy,
} from '@/features/enhanced-kanban/enhanced-kanban.slice';
import { createPortal } from 'react-dom';
// Simple Portal component
const Portal: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const portalRoot = document.getElementById('portal-root') || document.body;
return createPortal(children, portalRoot);
};
interface KanbanGroupProps {
group: ITaskListGroup;
onGroupDragStart: (e: React.DragEvent, groupId: string) => void;
onGroupDragOver: (e: React.DragEvent) => void;
onGroupDrop: (e: React.DragEvent, groupId: string) => void;
onTaskDragStart: (e: React.DragEvent, taskId: string, groupId: string) => void;
onTaskDragOver: (e: React.DragEvent, groupId: string, taskIdx: number | null) => void;
onTaskDrop: (e: React.DragEvent, groupId: string, taskIdx: number | null) => void;
onDragEnd: (e: React.DragEvent) => void;
hoveredTaskIdx: number | null;
hoveredGroupId: string | null;
}
const KanbanGroup: React.FC<KanbanGroupProps> = memo(({
group,
onGroupDragStart,
onGroupDragOver,
onGroupDrop,
onTaskDragStart,
onTaskDragOver,
onTaskDrop,
onDragEnd,
hoveredTaskIdx,
hoveredGroupId
}) => {
const [isHover, setIsHover] = useState<boolean>(false);
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
const [isEditable, setIsEditable] = useState(false);
const isProjectManager = useIsProjectManager();
const [isLoading, setIsLoading] = useState(false);
const [name, setName] = useState(group.name);
const inputRef = useRef<HTMLInputElement>(null);
const [editName, setEdit] = useState(group.name);
const [isEllipsisActive, setIsEllipsisActive] = useState(false);
const [showDropdown, setShowDropdown] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const themeMode = useAppSelector(state => state.themeReducer.mode);
const dispatch = useAppDispatch();
const { projectId } = useAppSelector(state => state.projectReducer);
const { groupBy } = useAppSelector(state => state.enhancedKanbanReducer);
const { statusCategories, status } = useAppSelector(state => state.taskStatusReducer);
const { trackMixpanelEvent } = useMixpanelTracking();
const [showNewCardTop, setShowNewCardTop] = useState(false);
const [showNewCardBottom, setShowNewCardBottom] = useState(false);
const { t } = useTranslation('kanban-board');
const headerBackgroundColor = useMemo(() => {
if (themeMode === 'dark') {
return group.color_code_dark || group.color_code || '#1e1e1e';
}
return group.color_code || '#f5f5f5';
}, [themeMode, group.color_code, group.color_code_dark]);
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;
};
const updateStatus = async (category = group.category_id ?? null) => {
if (!category || !projectId || !group.id) return;
// const sectionName = getUniqueSectionName(name);
const body: ITaskStatusUpdateModel = {
name: name.trim(),
project_id: projectId,
category_id: category,
};
const res = await statusApiService.updateStatus(group.id, body, projectId);
if (res.done) {
dispatch(fetchEnhancedKanbanGroups(projectId));
dispatch(fetchStatuses(projectId));
setName(name.trim());
} else {
setName(editName);
logger.error('Error updating status', res.message);
}
};
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const taskName = e.target.value;
setName(taskName);
};
const handleBlur = async () => {
setIsEditable(false);
if (name === editName) return;
if (name === t('untitledSection')) {
dispatch(fetchEnhancedKanbanGroups(projectId ?? ''));
}
if (!projectId || !group.id) return;
if (groupBy === IGroupBy.STATUS) {
await updateStatus();
}
if (groupBy === IGroupBy.PHASE) {
const body = {
id: group.id,
name: name,
};
const res = await phasesApiService.updateNameOfPhase(
group.id,
body as ITaskPhase,
projectId
);
if (res.done) {
trackMixpanelEvent(evt_project_board_column_setting_click, { Rename: 'Phase' });
dispatch(fetchEnhancedKanbanGroups(projectId));
}
}
};
const handlePressEnter = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
setShowNewCardTop(true);
setShowNewCardBottom(false);
handleBlur();
}
};
const handleDeleteSection = async () => {
if (!projectId || !group.id) return;
try {
if (groupBy === IGroupBy.STATUS) {
const replacingStatusId = '';
const res = await statusApiService.deleteStatus(group.id, projectId, replacingStatusId);
if (res.message === 'At least one status should exists under each category.') return;
if (res.done) {
dispatch(fetchEnhancedKanbanGroups(projectId));
} else {
dispatch(
seletedStatusCategory({
id: group.id,
name: name,
category_id: group.category_id ?? '',
message: res.message ?? '',
})
);
dispatch(deleteStatusToggleDrawer());
}
} else if (groupBy === IGroupBy.PHASE) {
const res = await phasesApiService.deletePhaseOption(group.id, projectId);
if (res.done) {
dispatch(fetchEnhancedKanbanGroups(projectId));
}
}
} catch (error) {
logger.error('Error deleting section', error);
}
};
const handleRename = () => {
setIsEditable(true);
setShowDropdown(false);
setTimeout(() => {
if (inputRef.current) {
inputRef.current.focus();
inputRef.current.select(); // Select all text on focus
}
}, 100);
};
const handleCategoryChange = (categoryId: string) => {
updateStatus(categoryId);
setShowDropdown(false);
};
const handleDelete = () => {
setShowDeleteConfirm(true);
setShowDropdown(false);
};
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setShowDropdown(false);
}
};
if (showDropdown) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [showDropdown]);
return (
<div className="enhanced-kanban-group" style={{ position: 'relative' }}
>
{/* Background layer - z-index 0 */}
<div
className="enhanced-kanban-group-background"
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
border: `0.1px solid ${themeMode === 'dark' ? '#404040' : '#e0e0e0'}`,
borderRadius: '8px',
zIndex: 0
}}
onDragOver={e => { e.preventDefault(); onTaskDragOver(e, group.id, null); }}
onDrop={e => { e.preventDefault(); onTaskDrop(e, group.id, null); }}
/>
{/* Content layer - z-index 1 */}
<div style={{ position: 'relative', zIndex: 1 }}>
<div
className="enhanced-kanban-group-header"
style={{
backgroundColor: headerBackgroundColor,
}}
draggable
onDragStart={e => onGroupDragStart(e, group.id)}
onDragOver={onGroupDragOver}
onDrop={e => onGroupDrop(e, group.id)}
onDragEnd={onDragEnd}
>
<div
className="flex items-center justify-between w-full font-semibold rounded-md"
onMouseEnter={() => setIsHover(true)}
onMouseLeave={() => setIsHover(false)}
>
<div
className="flex items-center gap-2 cursor-pointer"
onClick={e => {
e.stopPropagation();
if ((isProjectManager || isOwnerOrAdmin) && group.name !== t('unmapped'))
setIsEditable(true);
}}
onMouseDown={e => {
e.stopPropagation();
}}
>
{isLoading && (
<div className="w-4 h-4 border-2 border-gray-300 border-t-blue-600 rounded-full animate-spin"></div>
)}
{isEditable ? (
<input
ref={inputRef}
value={name}
className={`bg-transparent border-none outline-none text-sm font-semibold capitalize min-w-[185px] ${themeMode === 'dark' ? 'text-gray-800' : 'text-gray-900'
}`}
onChange={handleChange}
onBlur={handleBlur}
onKeyDown={handlePressEnter}
onMouseDown={e => {
e.stopPropagation();
}}
onClick={e => {
e.stopPropagation();
}}
/>
) : (
<div
className={`min-w-[185px] text-sm font-semibold capitalize truncate ${themeMode === 'dark' ? 'text-gray-800' : 'text-gray-900'
}`}
title={isEllipsisActive ? name : undefined}
onMouseDown={e => {
e.stopPropagation();
e.preventDefault();
}}
onMouseUp={e => {
e.stopPropagation();
}}
onClick={e => {
e.stopPropagation();
}}
>
{name} ({group.tasks.length})
</div>
)}
</div>
<div className="flex items-center gap-1">
<button
type="button"
className="w-7 h-7 flex items-center justify-center rounded-full hover:bg-black/10 transition-colors"
onClick={() => {
setShowNewCardTop(true);
setShowNewCardBottom(false);
}}
>
<svg className="w-4 h-4 text-gray-800" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</button>
{(isOwnerOrAdmin || isProjectManager) && name !== t('unmapped') && (
<div className="relative" ref={dropdownRef}>
<button
type="button"
className="w-7 h-7 flex items-center justify-center rounded-full hover:bg-black/10 transition-colors"
onClick={() => setShowDropdown(!showDropdown)}
>
<svg className="w-4 h-4 text-gray-800 rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
</svg>
</button>
{showDropdown && (
<div className="absolute right-0 top-full mt-1 w-48 bg-white dark:bg-gray-800 rounded-md shadow-lg border border-gray-200 dark:border-gray-700 z-50">
<div className="py-1">
<button
type="button"
className="w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2"
onClick={handleRename}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
{t('rename')}
</button>
{groupBy === IGroupBy.STATUS && statusCategories && (
<div className="border-t border-gray-200 dark:border-gray-700">
<div className="px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wide">
{t('changeCategory')}
</div>
{statusCategories.map(status => (
<button
key={status.id}
type="button"
className="w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2"
onClick={() => status.id && handleCategoryChange(status.id)}
>
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: status.color_code }}
></div>
<span className={group.category_id === status.id ? 'font-bold' : ''}>
{status.name}
</span>
</button>
))}
</div>
)}
{groupBy !== IGroupBy.PRIORITY && (
<div className="border-t border-gray-200 dark:border-gray-700">
<button
type="button"
className="w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2 text-red-600 dark:text-red-400"
onClick={e => {
e.stopPropagation();
handleDelete();
}}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
{t('delete')}
</button>
</div>
)}
</div>
</div>
)}
</div>
)}
</div>
</div>
</div>
{/* Simple Delete Confirmation */}
{showDeleteConfirm && (
<Portal>
<div
className="fixed inset-0 bg-black bg-opacity-25 flex items-center justify-center z-[99999]"
onClick={() => setShowDeleteConfirm(false)}
>
<div
className="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 max-w-sm w-full mx-4"
onClick={e => e.stopPropagation()}
>
<div className="p-4">
<div className="flex items-center gap-3 mb-3">
<div className="flex-shrink-0">
<svg className="w-5 h-5 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<div>
<h3 className={`text-base font-medium ${themeMode === 'dark' ? 'text-white' : 'text-gray-900'}`}>
{t('deleteConfirmationTitle')}
</h3>
</div>
</div>
<div className="flex justify-end gap-2">
<button
type="button"
className={`px-3 py-1.5 text-sm font-medium rounded border transition-colors ${themeMode === 'dark'
? 'border-gray-600 text-gray-300 hover:bg-gray-600'
: 'border-gray-300 text-gray-700 hover:bg-gray-50'
}`}
onClick={() => setShowDeleteConfirm(false)}
>
{t('deleteConfirmationCancel')}
</button>
<button
type="button"
className="px-3 py-1.5 text-sm font-medium text-white bg-red-600 border border-transparent rounded hover:bg-red-700 transition-colors"
onClick={() => {
handleDeleteSection();
setShowDeleteConfirm(false);
}}
>
{t('deleteConfirmationOk')}
</button>
</div>
</div>
</div>
</div>
</Portal>
)}
<div className="enhanced-kanban-group-tasks">
{/* Create card at top */}
{showNewCardTop && (
<EnhancedKanbanCreateTaskCard
sectionId={group.id}
setShowNewCard={setShowNewCardTop}
position="top"
/>
)}
{/* If group is empty, render a drop zone */}
{group.tasks.length === 0 && !showNewCardTop && !showNewCardBottom && hoveredGroupId !== group.id && (
<div
className="empty-drop-zone"
style={{
padding: 8,
height: 500,
background: themeWiseColor(
'linear-gradient( 180deg,#E2EAF4, rgba(245, 243, 243, 0))',
'linear-gradient( 180deg, #2a2a2a, rgba(42, 43, 45, 0))',
themeMode
),
borderRadius: 6,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'flex-start',
paddingTop: 8,
color: '#888',
fontStyle: 'italic',
}}
onDragOver={e => { e.preventDefault(); onTaskDragOver(e, group.id, 0); }}
onDrop={e => { e.preventDefault(); onTaskDrop(e, group.id, 0); }}
>
{(isOwnerOrAdmin || isProjectManager) && !showNewCardTop && !showNewCardBottom && (
<button
type="button"
className="h-10 w-full rounded-md border-2 border-dashed border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500 transition-colors flex items-center justify-center gap-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
onClick={() => {
setShowNewCardBottom(false);
setShowNewCardTop(true);
}}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
{t('addTask')}
</button>
)}
</div>
)
}
{/* Drop indicator at the top of the group */}
{hoveredGroupId === group.id && hoveredTaskIdx === 0 && (
<div className="drop-preview-indicator">
<div className="drop-line" />
</div>
)}
{group.tasks.map((task, idx) => (
<React.Fragment key={task.id}>
{/* Drop indicator before this card */}
{hoveredGroupId === group.id && hoveredTaskIdx === idx && (
<div
onDragOver={e => onTaskDragOver(e, group.id, idx)}
onDrop={e => onTaskDrop(e, group.id, idx)}
>
<div className="w-full h-full bg-red-500" style={{
height: 80,
background: themeMode === 'dark' ? '#2a2a2a' : '#E2EAF4',
borderRadius: 6,
border: `5px`
}}></div>
</div>
)}
<TaskCard
task={task}
onTaskDragStart={onTaskDragStart}
onTaskDragOver={onTaskDragOver}
onTaskDrop={onTaskDrop}
groupId={group.id}
idx={idx}
onDragEnd={onDragEnd}
/>
</React.Fragment>
))}
{/* Drop indicator at the end of the group */}
{hoveredGroupId === group.id && hoveredTaskIdx === group.tasks.length && (
<div
onDragOver={e => onTaskDragOver(e, group.id, group.tasks.length)}
onDrop={e => onTaskDrop(e, group.id, group.tasks.length)}
>
<div className="w-full h-full bg-red-500" style={{
height: 80,
background: themeMode === 'dark' ? '#2a2a2a' : '#E2EAF4',
borderRadius: 6,
border: `5px`
}}></div>
</div>
)}
{/* Create card at bottom */}
{showNewCardBottom && (
<EnhancedKanbanCreateTaskCard
sectionId={group.id}
setShowNewCard={setShowNewCardBottom}
position="bottom"
/>
)}
{/* Footer Add Task Button */}
{!showNewCardTop && !showNewCardBottom && group.tasks.length > 0 && (
<button
type="button"
className="h-10 w-full rounded-md border-2 border-dashed border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500 transition-colors flex items-center justify-center gap-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 mt-2"
onClick={() => {
setShowNewCardBottom(true);
setShowNewCardTop(false);
}}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
{t('addTask')}
</button>
)}
</div>
</div>
</div>
);
});
KanbanGroup.displayName = 'KanbanGroup';
export default KanbanGroup;

View File

@@ -0,0 +1,487 @@
import React, { memo, useCallback, useState, useRef, useEffect } from 'react';
import { useSelector } from 'react-redux';
import { RootState } from '@/app/store';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { setSelectedTaskId, setShowTaskDrawer } from '@/features/task-drawer/task-drawer.slice';
import { useTranslation } from 'react-i18next';
import AvatarGroup from '@/components/AvatarGroup';
import LazyAssigneeSelectorWrapper from '@/components/task-management/lazy-assignee-selector';
import { format } from 'date-fns';
import logger from '@/utils/errorLogger';
import { createPortal } from 'react-dom';
import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events';
import { getUserSession } from '@/utils/session-helper';
import { themeWiseColor } from '@/utils/themeWiseColor';
import { toggleTaskExpansion, fetchBoardSubTasks } from '@/features/enhanced-kanban/enhanced-kanban.slice';
// Simple Portal component
const Portal: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const portalRoot = document.getElementById('portal-root') || document.body;
return createPortal(children, portalRoot);
};
interface TaskCardProps {
task: IProjectTask;
onTaskDragStart: (e: React.DragEvent, taskId: string, groupId: string) => void;
onTaskDragOver: (e: React.DragEvent, groupId: string, taskIdx: number) => void;
onTaskDrop: (e: React.DragEvent, groupId: string, taskIdx: number) => void;
groupId: string;
idx: number;
onDragEnd: (e: React.DragEvent) => void; // <-- add this
}
function getDaysInMonth(year: number, month: number) {
return new Date(year, month + 1, 0).getDate();
}
function getFirstDayOfWeek(year: number, month: number) {
return new Date(year, month, 1).getDay();
}
const TaskCard: React.FC<TaskCardProps> = memo(({
task,
onTaskDragStart,
onTaskDragOver,
onTaskDrop,
groupId,
idx,
onDragEnd // <-- add this
}) => {
const { socket } = useSocket();
const themeMode = useSelector((state: RootState) => state.themeReducer.mode);
const { projectId } = useSelector((state: RootState) => state.projectReducer);
const background = themeMode === 'dark' ? '#23272f' : '#fff';
const color = themeMode === 'dark' ? '#fff' : '#23272f';
const dispatch = useAppDispatch();
const { t } = useTranslation('kanban-board');
const [showDatePicker, setShowDatePicker] = useState(false);
const [selectedDate, setSelectedDate] = useState<Date | null>(
task.end_date ? new Date(task.end_date) : null
);
const [isUpdating, setIsUpdating] = useState(false);
const datePickerRef = useRef<HTMLDivElement>(null);
const dateButtonRef = useRef<HTMLDivElement>(null);
const [dropdownPosition, setDropdownPosition] = useState<{ top: number; left: number } | null>(null);
const [calendarMonth, setCalendarMonth] = useState(() => {
const d = selectedDate || new Date();
return new Date(d.getFullYear(), d.getMonth(), 1);
});
const [showSubtasks, setShowSubtasks] = useState(false);
useEffect(() => {
setSelectedDate(task.end_date ? new Date(task.end_date) : null);
}, [task.end_date]);
// Close date picker when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (datePickerRef.current && !datePickerRef.current.contains(event.target as Node)) {
setShowDatePicker(false);
}
};
if (showDatePicker) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [showDatePicker]);
useEffect(() => {
if (showDatePicker && dateButtonRef.current) {
const rect = dateButtonRef.current.getBoundingClientRect();
setDropdownPosition({
top: rect.bottom + window.scrollY,
left: rect.left + window.scrollX,
});
}
}, [showDatePicker]);
const handleCardClick = useCallback((e: React.MouseEvent, id: string) => {
e.stopPropagation();
dispatch(setSelectedTaskId(id));
dispatch(setShowTaskDrawer(true));
}, [dispatch]);
const handleDateClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
setShowDatePicker(true);
}, []);
const handleDateChange = useCallback(
(date: Date | null) => {
if (!task.id || !projectId) return;
setIsUpdating(true);
try {
setSelectedDate(date);
socket?.emit(
SocketEvents.TASK_END_DATE_CHANGE.toString(),
JSON.stringify({
task_id: task.id,
end_date: date,
parent_task: task.parent_task_id,
time_zone: getUserSession()?.timezone_name
? getUserSession()?.timezone_name
: Intl.DateTimeFormat().resolvedOptions().timeZone,
})
);
} catch (error) {
logger.error('Failed to update due date:', error);
} finally {
setIsUpdating(false);
setShowDatePicker(false);
}
},
[task.id, projectId, socket]
);
const handleClearDate = useCallback(() => {
handleDateChange(null);
}, [handleDateChange]);
const handleToday = useCallback(() => {
handleDateChange(new Date());
}, [handleDateChange]);
const handleTomorrow = useCallback(() => {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
handleDateChange(tomorrow);
}, [handleDateChange]);
const handleNextWeek = useCallback(() => {
const nextWeek = new Date();
nextWeek.setDate(nextWeek.getDate() + 7);
handleDateChange(nextWeek);
}, [handleDateChange]);
const handleSubTaskExpand = useCallback(() => {
if (task && task.id && projectId) {
if (task.sub_tasks && task.sub_tasks.length > 0 && task.sub_tasks_count && task.sub_tasks_count > 0) {
dispatch(toggleTaskExpansion(task.id));
} else if (task.sub_tasks_count && task.sub_tasks_count > 0) {
dispatch(toggleTaskExpansion(task.id));
dispatch(fetchBoardSubTasks({ taskId: task.id, projectId }));
} else {
dispatch(toggleTaskExpansion(task.id));
}
}
}, [task, projectId, dispatch]);
const handleSubtaskButtonClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
handleSubTaskExpand();
}, [handleSubTaskExpand]);
// Calendar rendering helpers
const year = calendarMonth.getFullYear();
const month = calendarMonth.getMonth();
const daysInMonth = getDaysInMonth(year, month);
const firstDayOfWeek = (getFirstDayOfWeek(year, month) + 6) % 7; // Make Monday first
const today = new Date();
const weeks: (Date | null)[][] = [];
let week: (Date | null)[] = Array(firstDayOfWeek).fill(null);
for (let day = 1; day <= daysInMonth; day++) {
week.push(new Date(year, month, day));
if (week.length === 7) {
weeks.push(week);
week = [];
}
}
if (week.length > 0) {
while (week.length < 7) week.push(null);
weeks.push(week);
}
const [isDown, setIsDown] = useState(false);
return (
<>
<div className="enhanced-kanban-task-card" style={{ background, color, display: 'block' }} >
<div
draggable
onDragStart={e => onTaskDragStart(e, task.id!, groupId)}
onDragOver={e => {
e.preventDefault();
const rect = e.currentTarget.getBoundingClientRect();
const offsetY = e.clientY - rect.top;
const isDown = offsetY > rect.height / 2;
setIsDown(isDown);
onTaskDragOver(e, groupId, isDown ? idx + 1 : idx);
}}
onDrop={e => onTaskDrop(e, groupId, idx)}
onDragEnd={onDragEnd} // <-- add this
onClick={e => handleCardClick(e, task.id!)}
>
<div className="task-content">
<div className="task_labels" style={{ display: 'flex', gap: 4, marginBottom: 4 }}>
{task.labels?.map(label => (
<div
key={label.id}
className="task-label"
style={{
backgroundColor: label.color_code,
display: 'inline-block',
borderRadius: '2px',
padding: '0px 4px',
color: themeMode === 'dark' ? '#181818' : '#fff',
fontSize: 10,
marginRight: 4,
whiteSpace: 'nowrap',
minWidth: 0
}}
>
{label.name}
</div>
))}
</div>
<div className="task-content" style={{ display: 'flex', alignItems: 'center' }}>
<span
className="w-2 h-2 rounded-full inline-block"
style={{ backgroundColor: themeMode === 'dark' ? (task.priority_color_dark || task.priority_color || '#d9d9d9') : (task.priority_color || '#d9d9d9') }}
></span>
<div className="task-title" title={task.name} style={{ marginLeft: 8 }}>{task.name}</div>
</div>
<div className="task-assignees-row" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
<div className="relative">
<div
ref={dateButtonRef}
className="task-due-date cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 rounded px-1 py-0.5 transition-colors"
style={{
fontSize: 10,
color: '#888',
marginRight: 8,
whiteSpace: 'nowrap',
display: 'inline-block',
}}
onClick={handleDateClick}
title={t('clickToChangeDate')}
>
{isUpdating ? (
<div className="w-3 h-3 border border-gray-300 border-t-blue-600 rounded-full animate-spin"></div>
) : (
selectedDate ? format(selectedDate, 'MMM d, yyyy') : t('noDueDate')
)}
</div>
{/* Custom Calendar Popup */}
{showDatePicker && dropdownPosition && (
<Portal>
<div
className="w-52 bg-white dark:bg-gray-800 rounded-md shadow-lg border border-gray-200 dark:border-gray-700 z-[9999] p-1"
style={{
position: 'absolute',
top: dropdownPosition.top,
left: dropdownPosition.left,
}}
ref={datePickerRef}
onClick={e => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-0.5">
<button
className="px-0.5 py-0.5 text-[10px] rounded hover:bg-gray-100 dark:hover:bg-gray-700"
onClick={() => setCalendarMonth(new Date(year, month - 1, 1))}
type="button"
>
&lt;
</button>
<span className="font-semibold text-xs text-gray-800 dark:text-gray-100">
{calendarMonth.toLocaleString('default', { month: 'long' })} {year}
</span>
<button
className="px-0.5 py-0.5 text-[10px] rounded hover:bg-gray-100 dark:hover:bg-gray-700"
onClick={() => setCalendarMonth(new Date(year, month + 1, 1))}
type="button"
>
&gt;
</button>
</div>
<div className="grid grid-cols-7 gap-0.5 mb-0.5 text-[10px] text-center">
{['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map(d => (
<div key={d} className="font-medium text-gray-500 dark:text-gray-400">{d}</div>
))}
{weeks.map((week, i) => (
<React.Fragment key={i}>
{week.map((date, j) => {
const isSelected = date && selectedDate && date.toDateString() === selectedDate.toDateString();
const isToday = date && date.toDateString() === today.toDateString();
return (
<button
key={j}
className={
'w-5 h-5 rounded-full flex items-center justify-center text-[10px] ' +
(isSelected
? 'bg-blue-600 text-white'
: isToday
? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-200'
: 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-800 dark:text-gray-100')
}
style={{ outline: 'none' }}
disabled={!date}
onClick={() => date && handleDateChange(date)}
type="button"
>
{date ? date.getDate() : ''}
</button>
);
})}
</React.Fragment>
))}
</div>
<div className="flex gap-0.5 mt-1">
<button
type="button"
className="flex-1 px-0.5 py-0.5 text-[10px] bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
onClick={handleToday}
>
{t('today')}
</button>
<button
type="button"
className="px-1 py-0.5 text-xs text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors"
onClick={handleClearDate}
>
{t('clear')}
</button>
</div>
<div className="flex gap-1 mt-1">
<button
type="button"
className="flex-1 px-1 py-0.5 text-xs bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-100 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
onClick={handleTomorrow}
>
{t('tomorrow')}
</button>
<button
type="button"
className="flex-1 px-1 py-0.5 text-xs bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-100 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
onClick={handleNextWeek}
>
{t('nextWeek')}
</button>
</div>
</div>
</Portal>
)}
</div>
<div className="task-assignees" style={{ display: 'flex', alignItems: 'center' }}>
<AvatarGroup
members={task.names || []}
maxCount={3}
isDarkMode={themeMode === 'dark'}
size={24}
/>
<LazyAssigneeSelectorWrapper task={task} groupId={groupId} isDarkMode={themeMode === 'dark'} kanbanMode={true} />
{(task.sub_tasks_count ?? 0) > 0 && (
<button
type="button"
className={
"ml-2 px-2 py-0.5 rounded-full flex items-center gap-1 text-xs font-medium transition-colors " +
(task.show_sub_tasks
? "bg-gray-100 dark:bg-gray-800"
: "bg-white dark:bg-[#1e1e1e] hover:bg-gray-50 dark:hover:bg-gray-700")
}
style={{
backgroundColor: themeWiseColor('white', '#1e1e1e', themeMode),
border: "none",
outline: "none",
}}
onClick={handleSubtaskButtonClick}
title={task.show_sub_tasks ? t('hideSubtasks') || 'Hide Subtasks' : t('showSubtasks') || 'Show Subtasks'}
>
{/* Fork/branch icon */}
<svg style={{ color: '#888' }} className="w-2 h-2" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 20 20">
<path d="M6 3v2a2 2 0 002 2h4a2 2 0 012 2v2" strokeLinecap="round" />
<circle cx="6" cy="3" r="2" fill="currentColor" />
<circle cx="16" cy="9" r="2" fill="currentColor" />
<circle cx="6" cy="17" r="2" fill="currentColor" />
<path d="M6 5v10" strokeLinecap="round" />
</svg>
<span style={{
fontSize: 10,
color: '#888',
whiteSpace: 'nowrap',
display: 'inline-block',
}}>{task.sub_tasks_count ?? 0}</span>
{/* Caret icon */}
{task.show_sub_tasks ? (
<svg className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 20 20">
<path d="M6 8l4 4 4-4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
) : (
<svg className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 20 20">
<path d="M8 6l4 4-4 4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</button>
)}
</div>
</div>
</div>
</div>
<div
className="subtasks-container"
style={{
overflow: 'hidden',
transition: 'all 0.3s ease-in-out',
maxHeight: task.show_sub_tasks ? '500px' : '0px',
opacity: task.show_sub_tasks ? 1 : 0,
transform: task.show_sub_tasks ? 'translateY(0)' : 'translateY(-10px)',
}}
>
<div className="mt-2 border-t border-gray-100 dark:border-gray-700 pt-2">
{/* Loading state */}
{task.sub_tasks_loading && (
<div className="h-4 rounded bg-gray-200 dark:bg-gray-700 animate-pulse" />
)}
{/* Loaded subtasks */}
{!task.sub_tasks_loading && Array.isArray(task.sub_tasks) && task.sub_tasks.length > 0 && (
<ul className="space-y-1">
{task.sub_tasks.map(sub => (
<li key={sub.id} onClick={e => handleCardClick(e, sub.id!)} className="flex items-center gap-2 px-2 py-1 rounded hover:bg-gray-50 dark:hover:bg-gray-800">
{sub.priority_color || sub.priority_color_dark ? (
<span
className="w-2 h-2 rounded-full inline-block"
style={{ backgroundColor: themeMode === 'dark' ? (sub.priority_color_dark || sub.priority_color || '#d9d9d9') : (sub.priority_color || '#d9d9d9') }}
></span>
) : null}
<span className="flex-1 truncate text-xs text-gray-800 dark:text-gray-100">{sub.name}</span>
<span
className="task-due-date ml-2 text-[10px] text-gray-500 dark:text-gray-400"
>
{sub.end_date ? format(new Date(sub.end_date), 'MMM d, yyyy') : ''}
</span>
<span className="flex items-center">
{sub.names && sub.names.length > 0 && (
<AvatarGroup
members={sub.names}
maxCount={2}
isDarkMode={themeMode === 'dark'}
size={18}
/>
)}
<LazyAssigneeSelectorWrapper task={sub} groupId={groupId} isDarkMode={themeMode === 'dark'} kanbanMode={true} />
</span>
</li>
))}
</ul>
)}
{/* Empty state */}
{!task.sub_tasks_loading && (!Array.isArray(task.sub_tasks) || task.sub_tasks.length === 0) && (
<div className="py-2 text-xs text-gray-400 dark:text-gray-500">{t('noSubtasks', 'No subtasks')}</div>
)}
</div>
</div>
</div>
</>
);
});
TaskCard.displayName = 'TaskCard';
export default TaskCard;

View File

@@ -0,0 +1,3 @@
export { default } from './EnhancedKanbanBoardNativeDnD';
export { default as TaskCard } from './TaskCard';
export { default as KanbanGroup } from './KanbanGroup';

View File

@@ -0,0 +1,292 @@
import React, { useState, useRef, useEffect, useMemo } from 'react';
import { Button, Flex } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import { nanoid } from '@reduxjs/toolkit';
import { DownOutlined } from '@ant-design/icons';
import { useAppSelector } from '@/hooks/useAppSelector';
import { themeWiseColor } from '@/utils/themeWiseColor';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import {
IGroupBy,
fetchEnhancedKanbanGroups,
} from '@/features/enhanced-kanban/enhanced-kanban.slice';
import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
import { createStatus, fetchStatuses } from '@/features/taskAttributes/taskStatusSlice';
import { ALPHA_CHANNEL } from '@/shared/constants';
import logger from '@/utils/errorLogger';
import { phasesApiService } from '@/api/taskAttributes/phases/phases.api.service';
import { useAuthService } from '@/hooks/useAuth';
import useIsProjectManager from '@/hooks/useIsProjectManager';
const EnhancedKanbanCreateSection: React.FC = () => {
const { t } = useTranslation('kanban-board');
const themeMode = useAppSelector(state => state.themeReducer.mode);
const { projectId } = useAppSelector(state => state.projectReducer);
const groupBy = useAppSelector(state => state.enhancedKanbanReducer.groupBy);
const { statusCategories, status: existingStatuses } = useAppSelector(
state => state.taskStatusReducer
);
const dispatch = useAppDispatch();
const isOwnerorAdmin = useAuthService().isOwnerOrAdmin();
const isProjectManager = useIsProjectManager();
const [isAdding, setIsAdding] = useState(false);
const [sectionName, setSectionName] = useState('');
const [selectedCategoryId, setSelectedCategoryId] = useState<string>('');
const [showCategoryDropdown, setShowCategoryDropdown] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const categoryDropdownRef = useRef<HTMLDivElement>(null);
// Find selected category object
const selectedCategory = statusCategories?.find(cat => cat.id === selectedCategoryId);
// Compute header background color
const headerBackgroundColor = React.useMemo(() => {
if (!selectedCategory) return themeWiseColor('#f5f5f5', '#1e1e1e', themeMode);
return selectedCategory.color_code || (themeMode === 'dark' ? '#1e1e1e' : '#f5f5f5');
}, [themeMode, selectedCategory]);
// Focus input when adding
useEffect(() => {
if (isAdding && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isAdding]);
// Close on outside click (for both input and category dropdown)
useEffect(() => {
if (!isAdding && !showCategoryDropdown) return;
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
inputRef.current &&
!inputRef.current.contains(event.target as Node) &&
(!categoryDropdownRef.current || !categoryDropdownRef.current.contains(event.target as Node))
) {
setIsAdding(false);
setSectionName('');
setSelectedCategoryId('');
setShowCategoryDropdown(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isAdding, showCategoryDropdown]);
// Don't show for priority grouping or if user doesn't have permissions
if (groupBy === IGroupBy.PRIORITY || (!isOwnerorAdmin && !isProjectManager)) {
return null;
}
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;
};
const handleAddSection = async () => {
setIsAdding(true);
setSectionName('');
// Default to first category if available
if (statusCategories && statusCategories.length > 0 && typeof statusCategories[0].id === 'string') {
setSelectedCategoryId(statusCategories[0].id);
} else {
setSelectedCategoryId('');
}
};
const handleCreateSection = async () => {
if (!sectionName.trim() || !projectId) return;
const name = getUniqueSectionName(sectionName.trim());
if (groupBy === IGroupBy.STATUS && selectedCategoryId) {
const body = {
name,
project_id: projectId,
category_id: selectedCategoryId,
};
try {
const response = await dispatch(
createStatus({ body, currentProjectId: projectId })
).unwrap();
if (response.done && response.body) {
dispatch(fetchEnhancedKanbanGroups(projectId));
dispatch(fetchStatuses(projectId));
}
} catch (error) {
logger.error('Failed to create status:', error);
}
}
if (groupBy === IGroupBy.PHASE) {
try {
const response = await phasesApiService.addPhaseOption(projectId, name);
if (response.done && response.body) {
dispatch(fetchEnhancedKanbanGroups(projectId));
}
} catch (error) {
logger.error('Failed to create phase:', error);
}
}
setIsAdding(false);
setSectionName('');
setSelectedCategoryId('');
};
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleCreateSection();
} else if (e.key === 'Escape') {
setIsAdding(false);
setSectionName('');
setSelectedCategoryId('');
}
};
return (
<Flex
vertical
gap={16}
style={{
minWidth: 325,
padding: 8,
borderRadius: 12,
}}
className="h-[400px] max-h-[400px] overflow-y-scroll"
>
<div
style={{
borderRadius: 6,
padding: 8,
height: 300,
background: themeWiseColor(
'linear-gradient( 180deg, #fafafa, rgba(245, 243, 243, 0))',
'linear-gradient( 180deg, #2a2b2d, rgba(42, 43, 45, 0))',
themeMode
),
}}
>
{isAdding ? (
<div ref={dropdownRef} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{/* Header-like area */}
<div
className="enhanced-kanban-group-header flex items-center gap-2"
style={{
backgroundColor: headerBackgroundColor,
borderRadius: 6,
padding: '8px 12px',
marginBottom: 8,
minHeight: 36,
}}
>
{/* Borderless input */}
<input
ref={inputRef}
value={sectionName}
onChange={e => setSectionName(e.target.value)}
onKeyDown={handleInputKeyDown}
className={`bg-transparent border-none outline-none text-sm font-semibold capitalize min-w-[120px] flex-1 ${themeMode === 'dark' ? 'text-gray-800 placeholder-gray-800' : 'text-gray-800 placeholder-gray-600'}`}
placeholder={t('untitledSection')}
style={{ marginBottom: 0 }}
/>
{/* Category selector dropdown */}
{groupBy === IGroupBy.STATUS && statusCategories && statusCategories.length > 0 && (
<div className="relative" ref={categoryDropdownRef}>
<button
type="button"
className="flex items-center gap-1 px-2 py-1 rounded hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
style={{ minWidth: 80 }}
onClick={() => setShowCategoryDropdown(v => !v)}
>
<span className={themeMode === 'dark' ? 'text-gray-800' : 'text-gray-900'} style={{ fontSize: 13 }}>
{selectedCategory?.name || t('changeCategory')}
</span>
<DownOutlined style={{ fontSize: 12, color: themeMode === 'dark' ? '#555' : '#555' }} />
</button>
{showCategoryDropdown && (
<div
className="absolute right-0 mt-1 w-30 bg-white dark:bg-gray-800 rounded-md shadow-lg border border-gray-200 dark:border-gray-700 z-50"
style={{ zIndex: 1000 }}
>
<div className="py-1">
{statusCategories.filter(cat => typeof cat.id === 'string').map(cat => (
<button
key={cat.id}
type="button"
className="w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2"
onClick={() => {
if (typeof cat.id === 'string') setSelectedCategoryId(cat.id);
setShowCategoryDropdown(false);
}}
>
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: cat.color_code }}
></div>
<span className={selectedCategoryId === cat.id ? 'font-bold' : ''}>{cat.name}</span>
</button>
))}
</div>
</div>
)}
</div>
)}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<Button
type="primary"
size="small"
onClick={handleCreateSection}
disabled={!sectionName.trim()}
>
{t('addSectionButton')}
</Button>
<Button
type="default"
size="small"
onClick={() => { setIsAdding(false); setSectionName(''); setSelectedCategoryId(''); setShowCategoryDropdown(false); }}
>
{t('deleteConfirmationCancel')}
</Button>
</div>
</div>
) : (
<Button
type="text"
style={{
height: '38px',
width: '100%',
borderRadius: 6,
boxShadow: 'none',
}}
icon={<PlusOutlined />}
onClick={handleAddSection}
>
{t('addSectionButton')}
</Button>
)}
</div>
</Flex>
);
};
export default React.memo(EnhancedKanbanCreateSection);

View File

@@ -0,0 +1,178 @@
import { Flex, Input, InputRef } from 'antd';
import React, { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { updateEnhancedKanbanTaskProgress } from '@/features/enhanced-kanban/enhanced-kanban.slice';
import { themeWiseColor } from '@/utils/themeWiseColor';
import { useAppSelector } from '@/hooks/useAppSelector';
import { getCurrentGroup } from '@/features/enhanced-kanban/enhanced-kanban.slice';
import { useAuthService } from '@/hooks/useAuth';
import { ITaskCreateRequest } from '@/types/tasks/task-create-request.types';
import { useParams } from 'react-router-dom';
import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import logger from '@/utils/errorLogger';
type EnhancedKanbanCreateSubtaskCardProps = {
sectionId: string;
parentTaskId: string;
setShowNewSubtaskCard: (x: boolean) => void;
};
const EnhancedKanbanCreateSubtaskCard = ({
sectionId,
parentTaskId,
setShowNewSubtaskCard,
}: EnhancedKanbanCreateSubtaskCardProps) => {
const { socket, connected } = useSocket();
const dispatch = useAppDispatch();
const [creatingTask, setCreatingTask] = useState<boolean>(false);
const [newSubtaskName, setNewSubtaskName] = useState<string>('');
const [isEnterKeyPressed, setIsEnterKeyPressed] = useState<boolean>(false);
const cardRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<InputRef>(null);
const { t } = useTranslation('kanban-board');
const themeMode = useAppSelector(state => state.themeReducer.mode);
const { projectId } = useParams();
const currentSession = useAuthService().getCurrentSession();
const createRequestBody = (): ITaskCreateRequest | null => {
if (!projectId || !currentSession) return null;
const body: ITaskCreateRequest = {
project_id: projectId,
name: newSubtaskName,
reporter_id: currentSession.id,
team_id: currentSession.team_id,
};
const groupBy = getCurrentGroup();
if (groupBy === 'status') {
body.status_id = sectionId || undefined;
} else if (groupBy === 'priority') {
body.priority_id = sectionId || undefined;
} else if (groupBy === 'phase') {
body.phase_id = sectionId || undefined;
}
if (parentTaskId) {
body.parent_task_id = parentTaskId;
}
return body;
};
const handleAddSubtask = () => {
if (creatingTask || !projectId || !currentSession || newSubtaskName.trim() === '' || !connected)
return;
try {
setCreatingTask(true);
const body = createRequestBody();
if (!body) return;
socket?.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(body));
socket?.once(SocketEvents.QUICK_TASK.toString(), (task: IProjectTask) => {
if (!task) return;
setCreatingTask(false);
setNewSubtaskName('');
setTimeout(() => {
inputRef.current?.focus();
}, 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(
updateEnhancedKanbanTaskProgress({
id: task.id || '',
complete_ratio: data.complete_ratio,
completed_count: data.completed_count,
total_tasks_count: data.total_tasks_count,
parent_task: data.parent_task,
})
);
}
);
}
});
} catch (error) {
logger.error('Error adding task:', error);
setCreatingTask(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
setIsEnterKeyPressed(true);
handleAddSubtask();
}
};
const handleInputBlur = () => {
if (!isEnterKeyPressed && newSubtaskName.length > 0) {
handleAddSubtask();
}
setIsEnterKeyPressed(false);
};
const handleCancelNewCard = (e: React.FocusEvent<HTMLDivElement>) => {
if (cardRef.current && !cardRef.current.contains(e.relatedTarget)) {
setNewSubtaskName('');
setShowNewSubtaskCard(false);
}
};
return (
<Flex
ref={cardRef}
vertical
gap={4}
style={{
width: '100%',
padding: 2,
backgroundColor: themeMode === 'dark' ? '#292929' : '#fafafa',
borderRadius: 6,
cursor: 'pointer',
overflow: 'hidden',
}}
// className={`outline-1 ${themeWiseColor('outline-[#edeae9]', 'outline-[#6a696a]', themeMode)} hover:outline`}
onBlur={handleCancelNewCard}
>
<Input
ref={inputRef}
value={newSubtaskName}
onClick={e => e.stopPropagation()}
onChange={e => setNewSubtaskName(e.target.value)}
onKeyDown={e => {
e.stopPropagation();
if (e.key === 'Enter') {
setIsEnterKeyPressed(true);
handleAddSubtask();
}
}}
onKeyUp={e => e.stopPropagation()}
onKeyPress={e => e.stopPropagation()}
onBlur={handleInputBlur}
placeholder={t('newSubtaskNamePlaceholder')}
className={`enhanced-kanban-create-subtask-input ${themeMode === 'dark' ? 'dark' : ''}`}
disabled={creatingTask}
autoFocus
/>
</Flex>
);
};
export default EnhancedKanbanCreateSubtaskCard;

View File

@@ -0,0 +1,162 @@
import React, { useRef, useState, useEffect } from 'react';
import { Button, Flex, Input, InputRef } from 'antd';
import { useTranslation } from 'react-i18next';
import { nanoid } from '@reduxjs/toolkit';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useAppSelector } from '@/hooks/useAppSelector';
import { themeWiseColor } from '@/utils/themeWiseColor';
import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events';
import { useAuthService } from '@/hooks/useAuth';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import { addTaskToGroup } from '@/features/enhanced-kanban/enhanced-kanban.slice';
import { ITaskCreateRequest } from '@/types/tasks/task-create-request.types';
interface EnhancedKanbanCreateTaskCardProps {
sectionId: string;
setShowNewCard: (x: boolean) => void;
position?: 'top' | 'bottom';
}
const EnhancedKanbanCreateTaskCard: React.FC<EnhancedKanbanCreateTaskCardProps> = ({
sectionId,
setShowNewCard,
position = 'bottom',
}) => {
const { t } = useTranslation('kanban-board');
const dispatch = useAppDispatch();
const { socket } = useSocket();
const currentSession = useAuthService().getCurrentSession();
const [newTaskName, setNewTaskName] = useState('');
const [creatingTask, setCreatingTask] = useState(false);
const cardRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<InputRef>(null);
const themeMode = useAppSelector(state => state.themeReducer.mode);
const projectId = useAppSelector(state => state.projectReducer.projectId);
const groupBy = useAppSelector(state => state.enhancedKanbanReducer.groupBy);
useEffect(() => {
const timer = setTimeout(() => {
inputRef.current?.focus();
}, 100);
return () => clearTimeout(timer);
}, []);
const createRequestBody = (): ITaskCreateRequest | null => {
if (!projectId || !currentSession) return null;
const body: ITaskCreateRequest = {
project_id: projectId,
name: newTaskName.trim(),
reporter_id: currentSession.id,
team_id: currentSession.team_id,
};
if (groupBy === 'status') body.status_id = sectionId;
else if (groupBy === 'priority') body.priority_id = sectionId;
else if (groupBy === 'phase') body.phase_id = sectionId;
return body;
};
const resetForm = () => {
setNewTaskName('');
setCreatingTask(false);
setShowNewCard(false);
setTimeout(() => {
inputRef.current?.focus();
}, 100);
};
const resetForNextTask = () => {
setNewTaskName('');
setCreatingTask(false);
// Keep the card visible for creating the next task
setTimeout(() => {
inputRef.current?.focus();
}, 100);
};
const handleAddTask = async () => {
if (creatingTask || !projectId || !currentSession || newTaskName.trim() === '') return;
const body = createRequestBody();
if (!body) {
setCreatingTask(true);
setShowNewCard(true);
return;
}
// Real-time socket event handler
const eventHandler = (task: IProjectTask) => {
// Only reset the form - the global handler will add the task to Redux
socket?.off(SocketEvents.QUICK_TASK.toString(), eventHandler);
resetForNextTask();
};
socket?.once(SocketEvents.QUICK_TASK.toString(), eventHandler);
socket?.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(body));
};
const handleCancel = () => {
setNewTaskName('');
setShowNewCard(false);
setCreatingTask(false);
};
const handleBlur = () => {
if (newTaskName.trim() === '') {
setCreatingTask(false);
setShowNewCard(false);
}
};
return (
<Flex
ref={cardRef}
vertical
gap={12}
style={{
width: '100%',
padding: 12,
backgroundColor: themeWiseColor('#fafafa', '#292929', themeMode),
borderRadius: 6,
cursor: 'pointer',
overflow: 'hidden',
minHeight: 100,
zIndex: 1,
boxShadow: themeMode === 'dark' ? '0 2px 8px #1118' : '0 2px 8px #ccc8',
marginBottom: 8,
marginTop: 8,
}}
className={`outline-1 ${themeWiseColor('outline-[#edeae9]', 'outline-[#6a696a]', themeMode)} hover:outline-solid`}
>
<Input
ref={inputRef}
autoFocus
value={newTaskName}
onChange={e => setNewTaskName(e.target.value)}
onPressEnter={handleAddTask}
onBlur={handleBlur}
placeholder={t('newTaskNamePlaceholder')}
style={{
width: '100%',
borderRadius: 6,
padding: 8,
}}
disabled={creatingTask}
/>
{newTaskName.trim() && (
<Flex gap={8} justify="flex-end">
<Button size="small" onClick={handleCancel}>
{t('cancel')}
</Button>
<Button type="primary" size="small" onClick={handleAddTask} loading={creatingTask}>
{t('addTask')}
</Button>
</Flex>
)}
</Flex>
);
};
export default EnhancedKanbanCreateTaskCard;

View File

@@ -0,0 +1,243 @@
.enhanced-kanban-group {
width: 300px;
min-width: 300px;
max-width: 300px;
background: var(--ant-color-bg-elevated);
border-radius: 8px;
padding: 12px;
border: 1px solid var(--ant-color-border);
box-shadow: 0 1px 2px var(--ant-color-shadow);
transition: all 0.2s ease;
display: flex;
flex-direction: column;
}
.enhanced-kanban-group.drag-over {
border-color: var(--ant-color-primary);
box-shadow: 0 0 0 2px var(--ant-color-primary-border);
}
.enhanced-kanban-group.group-dragging {
opacity: 0.5;
z-index: 1000;
box-shadow: 0 8px 24px var(--ant-color-shadow);
}
.enhanced-kanban-group.group-dragging .enhanced-kanban-group-tasks {
background: var(--ant-color-bg-elevated);
}
.enhanced-kanban-group-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--ant-color-border);
cursor: grab;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
transition: all 0.2s ease;
border-radius: 6px;
padding: 8px 12px;
margin: -8px -8px 4px -8px;
}
.enhanced-kanban-group-header:active {
cursor: grabbing;
}
.enhanced-kanban-group-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: inherit;
text-shadow: 0 1px 2px var(--ant-color-shadow);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 180px;
display: inline-block;
vertical-align: middle;
}
.task-count {
background: var(--ant-color-bg-container);
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
color: var(--ant-color-text);
font-weight: 500;
border: 1px solid var(--ant-color-border);
opacity: 0.8;
}
.virtualization-indicator {
background: var(--ant-color-warning);
color: var(--ant-color-warning-text);
padding: 2px 6px;
border-radius: 8px;
font-size: 10px;
font-weight: 600;
cursor: help;
transition: all 0.2s ease;
}
.virtualization-indicator:hover {
background: var(--ant-color-warning-hover);
transform: scale(1.1);
}
.enhanced-kanban-group-tasks {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 200px;
max-height: 600px;
transition: all 0.2s ease;
overflow-y: auto;
overflow-x: hidden;
background: transparent;
}
/* Performance optimizations for large lists */
.enhanced-kanban-group-tasks.large-list {
contain: layout style paint;
will-change: transform;
}
/* Drop preview indicators */
.drop-preview-indicator {
height: 4px;
margin: 4px 0;
display: flex;
align-items: center;
justify-content: center;
}
.drop-line {
height: 2px;
background: var(--ant-color-primary);
border-radius: 1px;
width: 100%;
box-shadow: 0 0 4px var(--ant-color-primary);
animation: dropPulse 1.5s ease-in-out infinite;
}
@keyframes dropPulse {
0%,
100% {
opacity: 0.6;
transform: scaleX(0.8);
}
50% {
opacity: 1;
transform: scaleX(1);
}
}
/* Empty state drop zone */
.drop-preview-empty {
min-height: 80px;
display: flex;
align-items: center;
justify-content: center;
border: 2px dashed var(--ant-color-border);
border-radius: 6px;
background: var(--ant-color-bg-container);
transition: all 0.2s ease;
}
.enhanced-kanban-group.drag-over .drop-preview-empty {
border-color: var(--ant-color-primary);
}
.drop-indicator {
color: var(--ant-color-text-secondary);
font-size: 14px;
font-weight: 500;
}
.enhanced-kanban-group.drag-over .drop-indicator {
color: var(--ant-color-primary);
}
/* Group drag overlay */
.group-drag-overlay {
background: var(--ant-color-bg-elevated);
border: 1px solid var(--ant-color-border);
border-radius: 8px;
padding: 12px;
box-shadow: 0 8px 24px var(--ant-color-shadow);
min-width: 280px;
max-width: 320px;
opacity: 0.9;
pointer-events: none;
z-index: 1000;
}
.group-drag-overlay .group-header-content {
display: flex;
align-items: center;
gap: 8px;
}
.group-drag-overlay h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--ant-color-text);
}
.group-drag-overlay .task-count {
background: var(--ant-color-bg-container);
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
color: var(--ant-color-text);
border: 1px solid var(--ant-color-border);
}
/* Responsive design for different screen sizes */
@media (max-width: 768px) {
.enhanced-kanban-group {
min-width: 240px;
max-width: 280px;
}
.enhanced-kanban-group-tasks {
max-height: 400px;
}
}
@media (max-width: 480px) {
.enhanced-kanban-group {
min-width: 200px;
max-width: 240px;
}
.enhanced-kanban-group-tasks {
max-height: 300px;
}
}
.enhanced-kanban-task-card {
width: 100%;
box-sizing: border-box;
}
.task-title {
font-weight: 500;
color: var(--ant-color-text);
margin-bottom: 4px;
line-height: 1.4;
word-break: break-word;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 220px;
display: inline-block;
vertical-align: middle;
}

View File

@@ -0,0 +1,571 @@
import React, { useMemo, useRef, useEffect, useState } from 'react';
import { useDroppable } from '@dnd-kit/core';
import {
SortableContext,
verticalListSortingStrategy,
useSortable,
defaultAnimateLayoutChanges,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { ITaskListGroup } from '@/types/tasks/taskList.types';
import EnhancedKanbanTaskCard from './EnhancedKanbanTaskCard';
import VirtualizedTaskList from './VirtualizedTaskList';
import { useAppSelector } from '@/hooks/useAppSelector';
import './EnhancedKanbanGroup.css';
import { Badge, Flex, InputRef, MenuProps, Popconfirm } from 'antd';
import { themeWiseColor } from '@/utils/themeWiseColor';
import useIsProjectManager from '@/hooks/useIsProjectManager';
import { useAuthService } from '@/hooks/useAuth';
import {
DeleteOutlined,
ExclamationCircleFilled,
EditOutlined,
LoadingOutlined,
RetweetOutlined,
MoreOutlined,
} from '@ant-design/icons/lib/icons';
import { colors } from '@/styles/colors';
import { Input } from 'antd';
import { Tooltip } from 'antd';
import { Typography } from 'antd';
import { Dropdown } from 'antd';
import { Button } from 'antd';
import { PlusOutlined } from '@ant-design/icons/lib/icons';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useTranslation } from 'react-i18next';
import { ITaskStatusUpdateModel } from '@/types/tasks/task-status-update-model.types';
import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
import { fetchStatuses } from '@/features/taskAttributes/taskStatusSlice';
import logger from '@/utils/errorLogger';
import { evt_project_board_column_setting_click } from '@/shared/worklenz-analytics-events';
import { phasesApiService } from '@/api/taskAttributes/phases/phases.api.service';
import { ITaskPhase } from '@/types/tasks/taskPhase.types';
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
import {
deleteStatusToggleDrawer,
seletedStatusCategory,
} from '@/features/projects/status/DeleteStatusSlice';
import {
fetchEnhancedKanbanGroups,
IGroupBy,
} from '@/features/enhanced-kanban/enhanced-kanban.slice';
import EnhancedKanbanCreateTaskCard from './EnhancedKanbanCreateTaskCard';
interface EnhancedKanbanGroupProps {
group: ITaskListGroup;
activeTaskId?: string | null;
overId?: string | null;
}
// Performance threshold for virtualization
const VIRTUALIZATION_THRESHOLD = 50;
const EnhancedKanbanGroup: React.FC<EnhancedKanbanGroupProps> = React.memo(
({ group, activeTaskId, overId }) => {
const [isHover, setIsHover] = useState<boolean>(false);
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
const [isEditable, setIsEditable] = useState(false);
const isProjectManager = useIsProjectManager();
const [isLoading, setIsLoading] = useState(false);
const [name, setName] = useState(group.name);
const inputRef = useRef<InputRef>(null);
const [editName, setEdit] = useState(group.name);
const [isEllipsisActive, setIsEllipsisActive] = useState(false);
const themeMode = useAppSelector(state => state.themeReducer.mode);
const dispatch = useAppDispatch();
const { projectId } = useAppSelector(state => state.projectReducer);
const { groupBy } = useAppSelector(state => state.enhancedKanbanReducer);
const { statusCategories, status } = useAppSelector(state => state.taskStatusReducer);
const { trackMixpanelEvent } = useMixpanelTracking();
const [showNewCardTop, setShowNewCardTop] = useState(false);
const [showNewCardBottom, setShowNewCardBottom] = useState(false);
const { t } = useTranslation('kanban-board');
const { setNodeRef: setDroppableRef, isOver } = useDroppable({
id: group.id,
data: {
type: 'group',
group,
},
});
// Add sortable functionality for group header
const {
attributes,
listeners,
setNodeRef: setSortableRef,
transform,
transition,
isDragging: isGroupDragging,
} = useSortable({
id: group.id,
data: {
type: 'group',
group,
},
animateLayoutChanges: defaultAnimateLayoutChanges,
});
const groupRef = useRef<HTMLDivElement>(null);
const [groupHeight, setGroupHeight] = useState(400);
// Get task IDs for sortable context
const taskIds = group.tasks.map(task => task.id!);
// Check if this group is the target for dropping
const isTargetGroup = overId === group.id;
const isDraggingOver = isOver || isTargetGroup;
// Determine if virtualization should be used
const shouldVirtualize = useMemo(() => {
return group.tasks.length > VIRTUALIZATION_THRESHOLD;
}, [group.tasks.length]);
// Calculate optimal height for virtualization
useEffect(() => {
if (groupRef.current) {
const containerHeight = Math.min(
Math.max(group.tasks.length * 80, 200), // Minimum 200px, scale with tasks
600 // Maximum 600px
);
setGroupHeight(containerHeight);
}
}, [group.tasks.length]);
// Memoize task rendering to prevent unnecessary re-renders
const renderTask = useMemo(
() => (task: any, index: number) => (
<EnhancedKanbanTaskCard
key={task.id}
sectionId={group.id}
task={task}
isActive={task.id === activeTaskId}
isDropTarget={overId === task.id}
/>
),
[activeTaskId, overId]
);
// Performance optimization: Only render drop indicators when needed
const shouldShowDropIndicators = isDraggingOver && !shouldVirtualize;
// Combine refs for the main container
const setRefs = (el: HTMLElement | null) => {
setDroppableRef(el);
setSortableRef(el);
};
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isGroupDragging ? 0.5 : 1,
};
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;
};
const updateStatus = async (category = group.category_id ?? null) => {
if (!category || !projectId || !group.id) return;
const sectionName = getUniqueSectionName(name);
const body: ITaskStatusUpdateModel = {
name: sectionName,
project_id: projectId,
category_id: category,
};
const res = await statusApiService.updateStatus(group.id, body, projectId);
if (res.done) {
dispatch(fetchEnhancedKanbanGroups(projectId));
dispatch(fetchStatuses(projectId));
setName(sectionName);
} else {
setName(editName);
logger.error('Error updating status', res.message);
}
};
// Get the appropriate background color based on theme
const headerBackgroundColor = useMemo(() => {
if (themeMode === 'dark') {
return group.color_code_dark || group.color_code || '#1e1e1e';
}
return group.color_code || '#f5f5f5';
}, [themeMode, group.color_code, group.color_code_dark]);
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const taskName = e.target.value;
setName(taskName);
};
const handleBlur = async () => {
if (name === 'Untitled section') {
dispatch(fetchEnhancedKanbanGroups(projectId ?? ''));
}
setIsEditable(false);
if (!projectId || !group.id) return;
if (groupBy === IGroupBy.STATUS) {
await updateStatus();
}
if (groupBy === IGroupBy.PHASE) {
const body = {
id: group.id,
name: name,
};
const res = await phasesApiService.updateNameOfPhase(
group.id,
body as ITaskPhase,
projectId
);
if (res.done) {
trackMixpanelEvent(evt_project_board_column_setting_click, { Rename: 'Phase' });
dispatch(fetchEnhancedKanbanGroups(projectId));
}
}
};
const handlePressEnter = () => {
setShowNewCardTop(true);
setShowNewCardBottom(false);
handleBlur();
};
const handleDeleteSection = async () => {
if (!projectId || !group.id) return;
try {
if (groupBy === IGroupBy.STATUS) {
const replacingStatusId = '';
const res = await statusApiService.deleteStatus(group.id, projectId, replacingStatusId);
if (res.message === 'At least one status should exists under each category.') return;
if (res.done) {
dispatch(fetchEnhancedKanbanGroups(projectId));
} else {
dispatch(
seletedStatusCategory({
id: group.id,
name: name,
category_id: group.category_id ?? '',
message: res.message ?? '',
})
);
dispatch(deleteStatusToggleDrawer());
}
} else if (groupBy === IGroupBy.PHASE) {
const res = await phasesApiService.deletePhaseOption(group.id, projectId);
if (res.done) {
dispatch(fetchEnhancedKanbanGroups(projectId));
}
}
} catch (error) {
logger.error('Error deleting section', error);
}
};
const items: MenuProps['items'] = [
{
key: '1',
label: (
<div
style={{
display: 'flex',
justifyContent: 'flex-start',
width: '100%',
gap: '8px',
}}
onClick={() => setIsEditable(true)}
>
<EditOutlined /> <span>{t('rename')}</span>
</div>
),
},
groupBy === IGroupBy.STATUS && {
key: '2',
icon: <RetweetOutlined />,
label: 'Change category',
children: statusCategories?.map(status => ({
key: status.id,
label: (
<Flex
gap={8}
onClick={() => status.id && updateStatus(status.id)}
style={group.category_id === status.id ? { fontWeight: 700 } : {}}
>
<Badge color={status.color_code} />
{status.name}
</Flex>
),
})),
},
groupBy !== IGroupBy.PRIORITY && {
key: '3',
label: (
<Popconfirm
title={t('deleteConfirmationTitle')}
icon={<ExclamationCircleFilled style={{ color: colors.vibrantOrange }} />}
okText={t('deleteConfirmationOk')}
cancelText={t('deleteConfirmationCancel')}
onConfirm={handleDeleteSection}
>
<Flex gap={8} align="center" style={{ width: '100%' }}>
<DeleteOutlined />
{t('delete')}
</Flex>
</Popconfirm>
),
},
].filter(Boolean) as MenuProps['items'];
return (
<div
ref={setRefs}
style={style}
className={`enhanced-kanban-group ${isDraggingOver ? 'drag-over' : ''} ${isGroupDragging ? 'group-dragging' : ''}`}
>
{/* section header */}
<div
className="enhanced-kanban-group-header"
style={{
backgroundColor: headerBackgroundColor,
}}
{...attributes}
{...listeners}
>
{/* <span className="task-count">({group.tasks.length})</span> */}
<Flex
style={{
fontWeight: 600,
borderRadius: 6,
width: '100%',
alignItems: 'center',
justifyContent: 'space-between',
}}
onMouseEnter={() => setIsHover(true)}
onMouseLeave={() => setIsHover(false)}
>
<Flex
gap={6}
align="center"
style={{ cursor: 'pointer' }}
onClick={e => {
e.stopPropagation();
if ((isProjectManager || isOwnerOrAdmin) && group.name !== 'Unmapped')
setIsEditable(true);
}}
onMouseDown={e => {
e.stopPropagation();
}}
>
{isLoading && <LoadingOutlined style={{ color: colors.darkGray }} />}
{isEditable ? (
<Input
ref={inputRef}
value={name}
variant="borderless"
style={{
backgroundColor: themeWiseColor('white', '#1e1e1e', themeMode),
}}
onChange={handleChange}
onBlur={handleBlur}
onPressEnter={handlePressEnter}
onMouseDown={e => {
e.stopPropagation();
}}
onKeyDown={e => {
e.stopPropagation();
}}
onClick={e => {
e.stopPropagation();
}}
/>
) : (
<Tooltip title={isEllipsisActive ? name : null}>
<Typography.Text
ellipsis={{
tooltip: false,
onEllipsis: ellipsed => setIsEllipsisActive(ellipsed),
}}
style={{
minWidth: 185,
textTransform: 'capitalize',
color: themeMode === 'dark' ? '#383838' : '',
display: 'inline-block',
overflow: 'hidden',
userSelect: 'text',
}}
onMouseDown={e => {
e.stopPropagation();
e.preventDefault();
}}
onMouseUp={e => {
e.stopPropagation();
}}
onClick={e => {
e.stopPropagation();
}}
>
{name} ({group.tasks.length})
</Typography.Text>
</Tooltip>
)}
</Flex>
<div style={{ display: 'flex' }}>
<Button
type="text"
size="small"
shape="circle"
// style={{ color: themeMode === 'dark' ? '#383838' : '' }}
onClick={() => {
setShowNewCardTop(true);
setShowNewCardBottom(false);
}}
>
<PlusOutlined />
</Button>
{(isOwnerOrAdmin || isProjectManager) && name !== 'Unmapped' && (
<Dropdown
overlayClassName="todo-threedot-dropdown"
trigger={['click']}
menu={{ items }}
placement="bottomLeft"
>
<Button type="text" size="small" shape="circle">
<MoreOutlined
style={{
rotate: '90deg',
// fontSize: '25px',
// color: themeMode === 'dark' ? '#383838' : '',
}}
/>
</Button>
</Dropdown>
)}
</div>
</Flex>
{/* <h3 title={group.name} style={{ fontSize: 14, fontWeight: 600, color: themeWiseColor('black', '#1e1e1e', themeMode) }}>{group.name}</h3> */}
{/* {shouldVirtualize && (
<span className="virtualization-indicator" title="Virtualized for performance">
</span>
)} */}
</div>
<div className="enhanced-kanban-group-tasks" ref={groupRef}>
{/* Create card at top */}
{showNewCardTop && (isOwnerOrAdmin || isProjectManager) && (
<EnhancedKanbanCreateTaskCard
sectionId={group.id}
setShowNewCard={setShowNewCardTop}
position="top"
/>
)}
{group.tasks.length === 0 && isDraggingOver && (
<div className="drop-preview-empty">
<div className="drop-indicator">Drop here</div>
</div>
)}
{shouldVirtualize ? (
// Use virtualization for large task lists
<SortableContext items={taskIds} strategy={verticalListSortingStrategy}>
<VirtualizedTaskList
tasks={group.tasks}
height={groupHeight}
itemHeight={80}
activeTaskId={activeTaskId}
overId={overId}
onTaskRender={renderTask}
/>
</SortableContext>
) : (
// Use standard rendering for smaller lists
<SortableContext items={taskIds} strategy={verticalListSortingStrategy}>
{group.tasks.map((task, index) => (
<React.Fragment key={task.id}>
{/* Drop indicator before the card if this is the drop target */}
{overId === task.id && (
<div
style={{
height: 20,
background: themeMode === 'dark' ? '#444' : '#e0e0e0',
borderRadius: 4,
margin: '4px 0',
transition: 'background 0.2s',
}}
/>
)}
<EnhancedKanbanTaskCard
task={task}
sectionId={group.id}
isActive={task.id === activeTaskId}
isDropTarget={overId === task.id}
/>
{/* Drop indicator at the end if dropping at the end of the group */}
{index === group.tasks.length - 1 && overId === group.id && (
<div
style={{
height: 12,
background: themeMode === 'dark' ? '#444' : '#e0e0e0',
borderRadius: 4,
margin: '8px 0',
transition: 'background 0.2s',
}}
/>
)}
</React.Fragment>
))}
</SortableContext>
)}
{/* Create card at bottom */}
{showNewCardBottom && (isOwnerOrAdmin || isProjectManager) && (
<EnhancedKanbanCreateTaskCard
sectionId={group.id}
setShowNewCard={setShowNewCardBottom}
position="bottom"
/>
)}
{/* Footer Add Task Button */}
{(isOwnerOrAdmin || isProjectManager) && !showNewCardTop && !showNewCardBottom && (
<Button
type="text"
style={{
height: '38px',
width: '100%',
borderRadius: 6,
boxShadow: 'none',
marginTop: 8,
}}
icon={<PlusOutlined />}
onClick={() => {
setShowNewCardBottom(true);
setShowNewCardTop(false);
}}
>
{t('addTask')}
</Button>
)}
</div>
</div>
);
}
);
export default EnhancedKanbanGroup;

View File

@@ -0,0 +1,127 @@
.enhanced-kanban-task-card {
background: var(--ant-color-bg-container);
border: 1px solid var(--ant-color-border);
border-radius: 6px;
padding: 12px;
margin-bottom: 8px;
box-shadow: 0 1px 3px var(--ant-color-shadow);
cursor: grab;
transition: all 0.2s ease;
display: flex;
align-items: flex-start;
gap: 8px;
position: relative;
}
html.light .enhanced-kanban-task-card {
border: 1.5px solid #e1e4e8 !important; /* Asana-like light border */
box-shadow: 0 1px 4px 0 rgba(60, 64, 67, 0.08), 0 0.5px 1.5px 0 rgba(60, 64, 67, 0.03);
background: #fff !important;
}
.enhanced-kanban-task-card:hover {
box-shadow: 0 2px 6px var(--ant-color-shadow);
transform: translateY(-1px);
}
.enhanced-kanban-task-card:active {
cursor: grabbing;
}
.enhanced-kanban-task-card.dragging {
opacity: 0.5;
box-shadow: 0 4px 12px var(--ant-color-shadow);
}
.enhanced-kanban-task-card.active {
border-color: var(--ant-color-primary);
box-shadow: 0 0 0 2px var(--ant-color-primary-border);
}
.enhanced-kanban-task-card.drag-overlay {
cursor: grabbing;
box-shadow: 0 8px 24px var(--ant-color-shadow);
z-index: 1000;
}
/* Drop target visual feedback */
.enhanced-kanban-task-card.drop-target {
border-color: var(--ant-color-primary);
background: var(--ant-color-primary-bg);
box-shadow: 0 0 0 2px var(--ant-color-primary-border);
transform: scale(1.02);
}
.enhanced-kanban-task-card.drop-target::before {
content: "";
position: absolute;
top: -2px;
left: -2px;
right: -2px;
bottom: -2px;
border: 2px solid var(--ant-color-primary);
border-radius: 8px;
animation: dropTargetPulse 1s ease-in-out infinite;
pointer-events: none;
}
@keyframes dropTargetPulse {
0%,
100% {
opacity: 0.3;
transform: scale(1);
}
50% {
opacity: 0.6;
transform: scale(1.02);
}
}
.task-drag-handle {
flex-shrink: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
cursor: grab;
opacity: 0.6;
transition: opacity 0.2s ease;
}
.enhanced-kanban-task-card:hover .task-drag-handle {
opacity: 1;
}
.drag-indicator {
font-size: 12px;
color: var(--ant-color-text-secondary);
line-height: 1;
user-select: none;
}
.task-content {
flex: 1;
min-width: 0;
}
.task-title {
font-weight: 500;
color: var(--ant-color-text);
margin-bottom: 4px;
line-height: 1.4;
word-break: break-word;
}
.task-key {
font-size: 12px;
color: var(--ant-color-text-secondary);
font-family: monospace;
margin-bottom: 4px;
}
.task-assignees {
font-size: 12px;
color: var(--ant-color-text-tertiary);
margin-top: 4px;
}

View File

@@ -0,0 +1,281 @@
import React, { useCallback, useMemo, useState } from 'react';
import { useSortable, defaultAnimateLayoutChanges } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import { useAppSelector } from '@/hooks/useAppSelector';
import './EnhancedKanbanTaskCard.css';
import Flex from 'antd/es/flex';
import Tag from 'antd/es/tag';
import Tooltip from 'antd/es/tooltip';
import Progress from 'antd/es/progress';
import Button from 'antd/es/button';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { setShowTaskDrawer, setSelectedTaskId } from '@/features/task-drawer/task-drawer.slice';
import PrioritySection from '../board/taskCard/priority-section/priority-section';
import Typography from 'antd/es/typography';
import CustomDueDatePicker from '../board/custom-due-date-picker';
import { themeWiseColor } from '@/utils/themeWiseColor';
import { ForkOutlined } from '@ant-design/icons';
import { Dayjs } from 'dayjs';
import dayjs from 'dayjs';
import { CaretDownFilled, CaretRightFilled } from '@ant-design/icons';
import {
fetchBoardSubTasks,
toggleTaskExpansion,
} from '@/features/enhanced-kanban/enhanced-kanban.slice';
import { Divider } from 'antd';
import { List } from 'antd';
import { Skeleton } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import BoardSubTaskCard from '@/pages/projects/projectView/board/board-section/board-sub-task-card/board-sub-task-card';
import BoardCreateSubtaskCard from '@/pages/projects/projectView/board/board-section/board-sub-task-card/board-create-sub-task-card';
import { useTranslation } from 'react-i18next';
import EnhancedKanbanCreateSubtaskCard from './EnhancedKanbanCreateSubtaskCard';
import LazyAssigneeSelectorWrapper from '@/components/task-management/lazy-assignee-selector';
import AvatarGroup from '@/components/AvatarGroup';
interface EnhancedKanbanTaskCardProps {
task: IProjectTask;
sectionId: string;
isActive?: boolean;
isDragOverlay?: boolean;
isDropTarget?: boolean;
}
// Priority and status colors - moved outside component to avoid recreation
const PRIORITY_COLORS = {
critical: '#ff4d4f',
high: '#ff7a45',
medium: '#faad14',
low: '#52c41a',
} as const;
const EnhancedKanbanTaskCard: React.FC<EnhancedKanbanTaskCardProps> = React.memo(
({ task, sectionId, isActive = false, isDragOverlay = false, isDropTarget = false }) => {
const dispatch = useAppDispatch();
const { t } = useTranslation('kanban-board');
const themeMode = useAppSelector(state => state.themeReducer.mode);
const [showNewSubtaskCard, setShowNewSubtaskCard] = useState(false);
const [dueDate, setDueDate] = useState<Dayjs | null>(
task?.end_date ? dayjs(task?.end_date) : null
);
const projectId = useAppSelector(state => state.projectReducer.projectId);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: task.id!,
data: {
type: 'task',
task,
},
disabled: isDragOverlay,
animateLayoutChanges: defaultAnimateLayoutChanges,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
backgroundColor: themeMode === 'dark' ? '#292929' : '#fafafa',
};
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;
dispatch(setSelectedTaskId(id));
dispatch(setShowTaskDrawer(true));
},
[dispatch, isDragging]
);
const renderLabels = useMemo(() => {
if (!task?.labels?.length) return null;
return (
<>
{task.labels.slice(0, 2).map((label: any) => (
<Tag key={label.id} style={{ marginRight: '2px' }} color={label?.color_code}>
<span style={{ color: themeMode === 'dark' ? '#383838' : '', fontSize: 10 }}>
{label.name}
</span>
</Tag>
))}
{task.labels.length > 2 && <Tag>+ {task.labels.length - 2}</Tag>}
</>
);
}, [task.labels, themeMode]);
const handleSubTaskExpand = useCallback(() => {
if (task && task.id && projectId) {
// Check if subtasks are already loaded and we have subtask data
if (task.sub_tasks && task.sub_tasks.length > 0 && task.sub_tasks_count > 0) {
// If subtasks are already loaded, just toggle visibility
dispatch(toggleTaskExpansion(task.id));
} else if (task.sub_tasks_count > 0) {
// If we have a subtask count but no loaded subtasks, fetch them
dispatch(toggleTaskExpansion(task.id));
dispatch(fetchBoardSubTasks({ taskId: task.id, projectId }));
} else {
// If no subtasks exist, just toggle visibility (will show empty state)
dispatch(toggleTaskExpansion(task.id));
}
}
}, [task, projectId, dispatch]);
const handleSubtaskButtonClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
handleSubTaskExpand();
},
[handleSubTaskExpand]
);
const handleAddSubtaskClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
setShowNewSubtaskCard(true);
}, []);
return (
<div
ref={setNodeRef}
style={style}
className={`enhanced-kanban-task-card ${isActive ? 'active' : ''} ${isDragging ? 'dragging' : ''} ${isDragOverlay ? 'drag-overlay' : ''} ${isDropTarget ? 'drop-target' : ''}`}
{...attributes}
{...listeners}
>
<div className="task-content" onClick={e => handleCardClick(e, task.id || '')}>
<Flex align="center" justify="space-between" className="mb-2">
<Flex>{renderLabels}</Flex>
<Tooltip title={` ${task?.completed_count} / ${task?.total_tasks_count}`}>
<Progress
type="circle"
percent={task?.complete_ratio}
size={24}
strokeWidth={(task.complete_ratio || 0) >= 100 ? 9 : 7}
/>
</Tooltip>
</Flex>
<Flex gap={4} align="center">
{/* Action Icons */}
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: task.priority_color || '#d9d9d9' }}
/>
<Typography.Text style={{ fontWeight: 500 }} ellipsis={{ tooltip: task.name }}>
{task.name}
</Typography.Text>
</Flex>
<Flex
align="center"
justify="space-between"
style={{
marginBlock: 8,
}}
>
<Flex align="center" gap={2}>
<AvatarGroup
members={task.names || []}
maxCount={3}
isDarkMode={themeMode === 'dark'}
size={24}
/>
<LazyAssigneeSelectorWrapper task={task} groupId={sectionId} isDarkMode={themeMode === 'dark'} kanbanMode={true} />
</Flex>
<Flex gap={4} align="center">
<CustomDueDatePicker task={task} onDateChange={setDueDate} />
{/* Subtask Section - only show if count > 1 */}
{task.sub_tasks_count != null && Number(task.sub_tasks_count) > 1 && (
<Tooltip title={t(`indicators.tooltips.subtasks${Number(task.sub_tasks_count) === 1 ? '' : '_plural'}`, { count: Number(task.sub_tasks_count) })}>
<Button
onClick={handleSubtaskButtonClick}
size="small"
style={{
padding: 0,
}}
type="text"
>
<Tag
bordered={false}
style={{
display: 'flex',
alignItems: 'center',
margin: 0,
backgroundColor: themeWiseColor('white', '#1e1e1e', themeMode),
}}
>
<ForkOutlined rotate={90} />
<span>{task.sub_tasks_count || 0}</span>
{task.show_sub_tasks ? <CaretDownFilled /> : <CaretRightFilled />}
</Tag>
</Button>
</Tooltip>
)}
</Flex>
</Flex>
<Flex vertical gap={8}>
{task.show_sub_tasks && (
<Flex vertical>
<Divider style={{ marginBlock: 0 }} />
<List>
{task.sub_tasks_loading && (
<List.Item>
<Skeleton
active
paragraph={{ rows: 2 }}
title={false}
style={{ marginTop: 8 }}
/>
</List.Item>
)}
{!task.sub_tasks_loading &&
task?.sub_tasks &&
task.sub_tasks.length > 0 &&
task.sub_tasks.map((subtask: any) => (
<BoardSubTaskCard key={subtask.id} subtask={subtask} sectionId={sectionId} />
))}
{!task.sub_tasks_loading &&
(!task?.sub_tasks || task.sub_tasks.length === 0) &&
task.sub_tasks_count === 0 && (
<List.Item>
<div style={{ padding: '8px 0', color: '#999', fontSize: '12px' }}>
{t('noSubtasks', 'No subtasks')}
</div>
</List.Item>
)}
{showNewSubtaskCard && (
<EnhancedKanbanCreateSubtaskCard
sectionId={sectionId}
parentTaskId={task.id || ''}
setShowNewSubtaskCard={setShowNewSubtaskCard}
/>
)}
</List>
<Button
type="text"
style={{
width: 'fit-content',
borderRadius: 6,
boxShadow: 'none',
}}
icon={<PlusOutlined />}
onClick={handleAddSubtaskClick}
>
{t('addSubtask', 'Add Subtask')}
</Button>
</Flex>
)}
</Flex>
</div>
</div>
);
}
);
export default EnhancedKanbanTaskCard;

View File

@@ -0,0 +1,101 @@
.performance-monitor {
position: fixed;
top: 80px;
right: 16px;
width: 280px;
z-index: 1000;
background: var(--ant-color-bg-elevated);
border: 1px solid var(--ant-color-border);
box-shadow: 0 4px 12px var(--ant-color-shadow);
}
.performance-monitor-header {
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 600;
color: var(--ant-color-text);
}
.performance-status {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.performance-metrics {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 12px;
}
.performance-metrics .ant-statistic {
text-align: center;
}
.performance-metrics .ant-statistic-title {
font-size: 12px;
color: var(--ant-color-text-secondary);
margin-bottom: 4px;
}
.performance-metrics .ant-statistic-content {
font-size: 14px;
color: var(--ant-color-text);
}
.virtualization-status {
grid-column: 1 / -1;
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-top: 1px solid var(--ant-color-border);
}
.status-label {
font-size: 12px;
color: var(--ant-color-text-secondary);
font-weight: 500;
}
.performance-tips {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--ant-color-border);
}
.performance-tips h4 {
font-size: 12px;
color: var(--ant-color-text);
margin-bottom: 8px;
font-weight: 600;
}
.performance-tips ul {
margin: 0;
padding-left: 16px;
}
.performance-tips li {
font-size: 11px;
color: var(--ant-color-text-secondary);
margin-bottom: 4px;
line-height: 1.4;
}
/* Responsive design */
@media (max-width: 768px) {
.performance-monitor {
position: static;
width: 100%;
margin-bottom: 16px;
}
.performance-metrics {
grid-template-columns: 1fr;
gap: 8px;
}
}

View File

@@ -0,0 +1,108 @@
import React from 'react';
import { Card, Statistic, Tooltip, Badge } from 'antd';
import { useSelector } from 'react-redux';
import { RootState } from '@/app/store';
import './PerformanceMonitor.css';
const PerformanceMonitor: React.FC = () => {
const { performanceMetrics } = useSelector((state: RootState) => state.enhancedKanbanReducer);
// Only show if there are tasks loaded
if (performanceMetrics.totalTasks === 0) {
return null;
}
const getPerformanceStatus = () => {
if (performanceMetrics.totalTasks > 1000) return 'critical';
if (performanceMetrics.totalTasks > 500) return 'warning';
if (performanceMetrics.totalTasks > 100) return 'good';
return 'excellent';
};
const getStatusColor = (status: string) => {
switch (status) {
case 'critical':
return 'red';
case 'warning':
return 'orange';
case 'good':
return 'blue';
case 'excellent':
return 'green';
default:
return 'default';
}
};
const status = getPerformanceStatus();
const statusColor = getStatusColor(status);
return (
<Card
size="small"
className="performance-monitor"
title={
<div className="performance-monitor-header">
<span>Performance Monitor</span>
<Badge
status={statusColor as any}
text={status.toUpperCase()}
className="performance-status"
/>
</div>
}
>
<div className="performance-metrics">
<Tooltip title="Total number of tasks across all groups">
<Statistic
title="Total Tasks"
value={performanceMetrics.totalTasks}
suffix="tasks"
valueStyle={{ fontSize: '16px' }}
/>
</Tooltip>
<Tooltip title="Largest group by number of tasks">
<Statistic
title="Largest Group"
value={performanceMetrics.largestGroupSize}
suffix="tasks"
valueStyle={{ fontSize: '16px' }}
/>
</Tooltip>
<Tooltip title="Average tasks per group">
<Statistic
title="Average Group"
value={Math.round(performanceMetrics.averageGroupSize)}
suffix="tasks"
valueStyle={{ fontSize: '16px' }}
/>
</Tooltip>
<Tooltip title="Virtualization is enabled for groups with more than 50 tasks">
<div className="virtualization-status">
<span className="status-label">Virtualization:</span>
<Badge
status={performanceMetrics.virtualizationEnabled ? 'success' : 'default'}
text={performanceMetrics.virtualizationEnabled ? 'Enabled' : 'Disabled'}
/>
</div>
</Tooltip>
</div>
{performanceMetrics.totalTasks > 500 && (
<div className="performance-tips">
<h4>Performance Tips:</h4>
<ul>
<li>Use filters to reduce the number of visible tasks</li>
<li>Consider grouping by different criteria</li>
<li>Virtualization is automatically enabled for large groups</li>
</ul>
</div>
)}
</Card>
);
};
export default React.memo(PerformanceMonitor);

View File

@@ -0,0 +1,60 @@
.virtualized-task-list {
background: transparent;
border-radius: 6px;
overflow: hidden;
}
.virtualized-task-row {
padding: 4px 0;
display: flex;
align-items: stretch;
}
.virtualized-empty-state {
display: flex;
align-items: center;
justify-content: center;
background: var(--ant-color-bg-container);
border-radius: 6px;
border: 2px dashed var(--ant-color-border);
}
.empty-message {
color: var(--ant-color-text-secondary);
font-size: 14px;
font-weight: 500;
}
/* Ensure virtualized list works well with drag and drop */
.virtualized-task-list .react-window__inner {
overflow: visible !important;
}
/* Performance optimizations */
.virtualized-task-list * {
will-change: transform;
}
/* Smooth scrolling */
.virtualized-task-list {
scroll-behavior: smooth;
}
/* Custom scrollbar for better UX */
.virtualized-task-list::-webkit-scrollbar {
width: 6px;
}
.virtualized-task-list::-webkit-scrollbar-track {
background: var(--ant-color-bg-container);
border-radius: 3px;
}
.virtualized-task-list::-webkit-scrollbar-thumb {
background: var(--ant-color-border);
border-radius: 3px;
}
.virtualized-task-list::-webkit-scrollbar-thumb:hover {
background: var(--ant-color-text-tertiary);
}

Some files were not shown because too many files have changed in this diff Show More