Merge branch 'main' of https://github.com/Worklenz/worklenz into feature/task-activities-by-user
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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}`;
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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() : ''}`
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
123
worklenz-frontend/src/app/performance-monitor.ts
Normal file
123
worklenz-frontend/src/app/performance-monitor.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
})),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
})),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
})),
|
||||
},
|
||||
|
||||
17
worklenz-frontend/src/app/routes/utils.ts
Normal file
17
worklenz-frontend/src/app/routes/utils.ts
Normal 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;
|
||||
};
|
||||
75
worklenz-frontend/src/app/selectors.ts
Normal file
75
worklenz-frontend/src/app/selectors.ts
Normal 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;
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
BIN
worklenz-frontend/src/assets/images/empty-box.webp
Normal file
BIN
worklenz-frontend/src/assets/images/empty-box.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
BIN
worklenz-frontend/src/assets/images/worklenz-dark-mode.png
Normal file
BIN
worklenz-frontend/src/assets/images/worklenz-dark-mode.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.3 KiB |
BIN
worklenz-frontend/src/assets/images/worklenz-light-mode.png
Normal file
BIN
worklenz-frontend/src/assets/images/worklenz-light-mode.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.1 KiB |
365
worklenz-frontend/src/components/AssigneeSelector.tsx
Normal file
365
worklenz-frontend/src/components/AssigneeSelector.tsx
Normal 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;
|
||||
@@ -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 = {
|
||||
|
||||
105
worklenz-frontend/src/components/Avatar.tsx
Normal file
105
worklenz-frontend/src/components/Avatar.tsx
Normal 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;
|
||||
113
worklenz-frontend/src/components/AvatarGroup.tsx
Normal file
113
worklenz-frontend/src/components/AvatarGroup.tsx
Normal 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;
|
||||
64
worklenz-frontend/src/components/Button.tsx
Normal file
64
worklenz-frontend/src/components/Button.tsx
Normal 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;
|
||||
61
worklenz-frontend/src/components/Checkbox.tsx
Normal file
61
worklenz-frontend/src/components/Checkbox.tsx
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
58
worklenz-frontend/src/components/CustomColordLabel.tsx
Normal file
58
worklenz-frontend/src/components/CustomColordLabel.tsx
Normal 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;
|
||||
36
worklenz-frontend/src/components/CustomNumberLabel.tsx
Normal file
36
worklenz-frontend/src/components/CustomNumberLabel.tsx
Normal 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;
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
24
worklenz-frontend/src/components/HubSpot.tsx
Normal file
24
worklenz-frontend/src/components/HubSpot.tsx
Normal 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;
|
||||
296
worklenz-frontend/src/components/LabelsSelector.tsx
Normal file
296
worklenz-frontend/src/components/LabelsSelector.tsx
Normal 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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
88
worklenz-frontend/src/components/Progress.tsx
Normal file
88
worklenz-frontend/src/components/Progress.tsx
Normal 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;
|
||||
51
worklenz-frontend/src/components/Tag.tsx
Normal file
51
worklenz-frontend/src/components/Tag.tsx
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
37
worklenz-frontend/src/components/Tooltip.tsx
Normal file
37
worklenz-frontend/src/components/Tooltip.tsx
Normal 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;
|
||||
@@ -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>) => {
|
||||
|
||||
@@ -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>}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
{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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
{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}
|
||||
{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={{
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
};
|
||||
29
worklenz-frontend/src/components/charts/chart-loader.tsx
Normal file
29
worklenz-frontend/src/components/charts/chart-loader.tsx
Normal 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;
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -91,4 +91,3 @@
|
||||
.custom-template-list .selected-custom-template:hover {
|
||||
background-color: var(--color-paleBlue);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
>
|
||||
<
|
||||
</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"
|
||||
>
|
||||
>
|
||||
</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;
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default } from './EnhancedKanbanBoardNativeDnD';
|
||||
export { default as TaskCard } from './TaskCard';
|
||||
export { default as KanbanGroup } from './KanbanGroup';
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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
Reference in New Issue
Block a user