Merge pull request #298 from Worklenz/feature/holiday-calendar-integration
feat(localization): update and enhance localization files for multipl…
This commit is contained in:
@@ -7,7 +7,9 @@
|
||||
"Bash(npm run:*)",
|
||||
"Bash(mkdir:*)",
|
||||
"Bash(cp:*)",
|
||||
"Bash(ls:*)"
|
||||
"Bash(ls:*)",
|
||||
"WebFetch(domain:www.npmjs.com)",
|
||||
"WebFetch(domain:github.com)"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
|
||||
@@ -349,7 +349,7 @@ export default class HolidayController extends WorklenzControllerBase {
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
errors.push(`${country.name}: ${error.message}`);
|
||||
errors.push(`${country.name}: ${error?.message || "Unknown error"}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,9 +34,27 @@
|
||||
<link rel="dns-prefetch" href="https://js.hs-scripts.com" />
|
||||
|
||||
<!-- Preload critical resources -->
|
||||
<link rel="preload" href="/locales/en/common.json" as="fetch" type="application/json" crossorigin />
|
||||
<link rel="preload" href="/locales/en/auth/login.json" as="fetch" type="application/json" crossorigin />
|
||||
<link rel="preload" href="/locales/en/navbar.json" as="fetch" type="application/json" crossorigin />
|
||||
<link
|
||||
rel="preload"
|
||||
href="/locales/en/common.json"
|
||||
as="fetch"
|
||||
type="application/json"
|
||||
crossorigin
|
||||
/>
|
||||
<link
|
||||
rel="preload"
|
||||
href="/locales/en/auth/login.json"
|
||||
as="fetch"
|
||||
type="application/json"
|
||||
crossorigin
|
||||
/>
|
||||
<link
|
||||
rel="preload"
|
||||
href="/locales/en/navbar.json"
|
||||
as="fetch"
|
||||
type="application/json"
|
||||
crossorigin
|
||||
/>
|
||||
|
||||
<!-- Optimized font loading with font-display: swap -->
|
||||
<link
|
||||
|
||||
@@ -66,7 +66,7 @@ class AnalyticsManager {
|
||||
|
||||
// Add event listener to button
|
||||
const btn = notice.querySelector('#analytics-notice-btn');
|
||||
btn.addEventListener('click', (e) => {
|
||||
btn.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
localStorage.setItem('privacyNoticeShown', 'true');
|
||||
notice.remove();
|
||||
|
||||
@@ -64,7 +64,7 @@ class HubSpotManager {
|
||||
const observer = new MutationObserver(applyTheme);
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class']
|
||||
attributeFilter: ['class'],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ const CACHE_NAMES = {
|
||||
STATIC: `worklenz-static-${CACHE_VERSION}`,
|
||||
DYNAMIC: `worklenz-dynamic-${CACHE_VERSION}`,
|
||||
API: `worklenz-api-${CACHE_VERSION}`,
|
||||
IMAGES: `worklenz-images-${CACHE_VERSION}`
|
||||
IMAGES: `worklenz-images-${CACHE_VERSION}`,
|
||||
};
|
||||
|
||||
// Resources to cache immediately on install
|
||||
@@ -75,9 +75,7 @@ self.addEventListener('activate', event => {
|
||||
Object.values(CACHE_NAMES).every(currentCache => currentCache !== name)
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
oldCaches.map(cacheName => caches.delete(cacheName))
|
||||
);
|
||||
await Promise.all(oldCaches.map(cacheName => caches.delete(cacheName)));
|
||||
|
||||
console.log('Service Worker: Old caches cleaned up');
|
||||
|
||||
@@ -130,7 +128,6 @@ async function handleFetchRequest(request) {
|
||||
|
||||
// Everything else - Network First
|
||||
return await networkFirstStrategy(request, CACHE_NAMES.DYNAMIC);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Service Worker: Fetch failed', error);
|
||||
return createOfflineResponse(request);
|
||||
@@ -192,13 +189,15 @@ async function staleWhileRevalidateStrategy(request, cacheName) {
|
||||
const cachedResponse = await cache.match(request);
|
||||
|
||||
// Fetch from network in background
|
||||
const networkResponsePromise = fetch(request).then(async networkResponse => {
|
||||
const networkResponsePromise = fetch(request)
|
||||
.then(async networkResponse => {
|
||||
if (networkResponse.status === 200) {
|
||||
const responseClone = networkResponse.clone();
|
||||
await cache.put(request, responseClone);
|
||||
}
|
||||
return networkResponse;
|
||||
}).catch(error => {
|
||||
})
|
||||
.catch(error => {
|
||||
console.warn('Stale While Revalidate: Background update failed', error);
|
||||
});
|
||||
|
||||
@@ -213,22 +212,27 @@ async function staleWhileRevalidateStrategy(request, cacheName) {
|
||||
|
||||
// Helper functions to identify resource types
|
||||
function isStaticAsset(url) {
|
||||
return /\.(js|css|woff2?|ttf|eot)$/.test(url.pathname) ||
|
||||
return (
|
||||
/\.(js|css|woff2?|ttf|eot)$/.test(url.pathname) ||
|
||||
url.pathname.includes('/assets/') ||
|
||||
url.pathname === '/' ||
|
||||
url.pathname === '/index.html' ||
|
||||
url.pathname === '/favicon.ico' ||
|
||||
url.pathname === '/env-config.js';
|
||||
url.pathname === '/env-config.js'
|
||||
);
|
||||
}
|
||||
|
||||
function isImageRequest(url) {
|
||||
return /\.(png|jpg|jpeg|gif|svg|webp|ico)$/.test(url.pathname) ||
|
||||
url.pathname.includes('/file-types/');
|
||||
return (
|
||||
/\.(png|jpg|jpeg|gif|svg|webp|ico)$/.test(url.pathname) || url.pathname.includes('/file-types/')
|
||||
);
|
||||
}
|
||||
|
||||
function isAPIRequest(url) {
|
||||
return url.pathname.startsWith('/api/') ||
|
||||
CACHEABLE_API_PATTERNS.some(pattern => pattern.test(url.pathname));
|
||||
return (
|
||||
url.pathname.startsWith('/api/') ||
|
||||
CACHEABLE_API_PATTERNS.some(pattern => pattern.test(url.pathname))
|
||||
);
|
||||
}
|
||||
|
||||
function isHTMLRequest(request) {
|
||||
@@ -247,19 +251,22 @@ function createOfflineResponse(request) {
|
||||
</svg>`;
|
||||
|
||||
return new Response(svg, {
|
||||
headers: { 'Content-Type': 'image/svg+xml' }
|
||||
headers: { 'Content-Type': 'image/svg+xml' },
|
||||
});
|
||||
}
|
||||
|
||||
if (isAPIRequest(new URL(request.url))) {
|
||||
// Return empty array or error for API requests
|
||||
return new Response(JSON.stringify({
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: 'Offline',
|
||||
message: 'This feature requires an internet connection'
|
||||
}), {
|
||||
message: 'This feature requires an internet connection',
|
||||
}),
|
||||
{
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// For HTML requests, try to return cached index.html
|
||||
@@ -294,22 +301,18 @@ self.addEventListener('push', event => {
|
||||
vibrate: [200, 100, 200],
|
||||
data: {
|
||||
dateOfArrival: Date.now(),
|
||||
primaryKey: 1
|
||||
}
|
||||
primaryKey: 1,
|
||||
},
|
||||
};
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification('Worklenz', options)
|
||||
);
|
||||
event.waitUntil(self.registration.showNotification('Worklenz', options));
|
||||
});
|
||||
|
||||
// Handle notification click events
|
||||
self.addEventListener('notificationclick', event => {
|
||||
event.notification.close();
|
||||
|
||||
event.waitUntil(
|
||||
self.clients.openWindow('/')
|
||||
);
|
||||
event.waitUntil(self.clients.openWindow('/'));
|
||||
});
|
||||
|
||||
// Message handling for communication with main thread
|
||||
|
||||
@@ -22,7 +22,11 @@ import logger from './utils/errorLogger';
|
||||
import { SuspenseFallback } from './components/suspense-fallback/suspense-fallback';
|
||||
|
||||
// Performance optimizations
|
||||
import { CSSPerformanceMonitor, LayoutStabilizer, CriticalCSSManager } from './utils/css-optimizations';
|
||||
import {
|
||||
CSSPerformanceMonitor,
|
||||
LayoutStabilizer,
|
||||
CriticalCSSManager,
|
||||
} from './utils/css-optimizations';
|
||||
|
||||
// Service Worker
|
||||
import { registerSW } from './utils/serviceWorkerRegistration';
|
||||
@@ -168,19 +172,21 @@ const App: React.FC = memo(() => {
|
||||
// Register service worker
|
||||
useEffect(() => {
|
||||
registerSW({
|
||||
onSuccess: (registration) => {
|
||||
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.');
|
||||
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) => {
|
||||
onError: error => {
|
||||
logger.error('Service Worker registration failed:', error);
|
||||
}
|
||||
},
|
||||
});
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
IFreePlanSettings,
|
||||
IBillingAccountStorage,
|
||||
} from '@/types/admin-center/admin-center.types';
|
||||
import { IOrganizationHolidaySettings } from '@/types/holiday/holiday.types';
|
||||
import { IClient } from '@/types/client.types';
|
||||
import { toQueryString } from '@/utils/toQueryString';
|
||||
|
||||
@@ -292,4 +293,14 @@ export const adminCenterApiService = {
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async updateOrganizationHolidaySettings(
|
||||
settings: IOrganizationHolidaySettings
|
||||
): Promise<IServerResponse<any>> {
|
||||
const response = await apiClient.put<IServerResponse<any>>(
|
||||
`${rootUrl}/organization/holiday-settings`,
|
||||
settings
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -10,65 +10,800 @@ import {
|
||||
IUpdateHolidayRequest,
|
||||
IImportCountryHolidaysRequest,
|
||||
IHolidayCalendarEvent,
|
||||
IOrganizationHolidaySettings,
|
||||
ICountryWithStates,
|
||||
ICombinedHolidaysRequest,
|
||||
IHolidayDateRange,
|
||||
} from '@/types/holiday/holiday.types';
|
||||
|
||||
const rootUrl = `${API_BASE_URL}/holidays`;
|
||||
|
||||
export const holidayApiService = {
|
||||
// Holiday types
|
||||
// Holiday types - PLACEHOLDER with Sri Lankan specific types
|
||||
getHolidayTypes: async (): Promise<IServerResponse<IHolidayType[]>> => {
|
||||
const response = await apiClient.get<IServerResponse<IHolidayType[]>>(`${rootUrl}/types`);
|
||||
return response.data;
|
||||
// Return holiday types including Sri Lankan specific types
|
||||
const holidayTypes = [
|
||||
{ id: '1', name: 'Public Holiday', color_code: '#DC143C' },
|
||||
{ id: '2', name: 'Religious Holiday', color_code: '#4ecdc4' },
|
||||
{ id: '3', name: 'National Holiday', color_code: '#45b7d1' },
|
||||
{ id: '4', name: 'Company Holiday', color_code: '#f9ca24' },
|
||||
{ id: '5', name: 'Personal Holiday', color_code: '#6c5ce7' },
|
||||
{ id: '6', name: 'Bank Holiday', color_code: '#4682B4' },
|
||||
{ id: '7', name: 'Mercantile Holiday', color_code: '#32CD32' },
|
||||
{ id: '8', name: 'Poya Day', color_code: '#8B4513' },
|
||||
];
|
||||
|
||||
return {
|
||||
done: true,
|
||||
body: holidayTypes,
|
||||
} as IServerResponse<IHolidayType[]>;
|
||||
},
|
||||
|
||||
// Organization holidays
|
||||
getOrganizationHolidays: async (year?: number): Promise<IServerResponse<IOrganizationHoliday[]>> => {
|
||||
const params = year ? `?year=${year}` : '';
|
||||
const response = await apiClient.get<IServerResponse<IOrganizationHoliday[]>>(`${rootUrl}/organization${params}`);
|
||||
return response.data;
|
||||
// Organization holidays - PLACEHOLDER until backend implements
|
||||
getOrganizationHolidays: async (
|
||||
year?: number
|
||||
): Promise<IServerResponse<IOrganizationHoliday[]>> => {
|
||||
// Return empty array for now to prevent 404 errors
|
||||
return {
|
||||
done: true,
|
||||
body: [],
|
||||
} as IServerResponse<IOrganizationHoliday[]>;
|
||||
},
|
||||
|
||||
// Holiday CRUD operations - PLACEHOLDER until backend implements
|
||||
createOrganizationHoliday: async (data: ICreateHolidayRequest): Promise<IServerResponse<any>> => {
|
||||
const response = await apiClient.post<IServerResponse<any>>(`${rootUrl}/organization`, data);
|
||||
return response.data;
|
||||
// Return success for now to prevent UI errors
|
||||
return {
|
||||
done: true,
|
||||
body: { id: Date.now().toString(), ...data },
|
||||
} as IServerResponse<any>;
|
||||
},
|
||||
|
||||
updateOrganizationHoliday: async (id: string, data: IUpdateHolidayRequest): Promise<IServerResponse<any>> => {
|
||||
const response = await apiClient.put<IServerResponse<any>>(`${rootUrl}/organization/${id}`, data);
|
||||
return response.data;
|
||||
updateOrganizationHoliday: async (
|
||||
id: string,
|
||||
data: IUpdateHolidayRequest
|
||||
): Promise<IServerResponse<any>> => {
|
||||
// Return success for now to prevent UI errors
|
||||
return {
|
||||
done: true,
|
||||
body: { id, ...data },
|
||||
} as IServerResponse<any>;
|
||||
},
|
||||
|
||||
deleteOrganizationHoliday: async (id: string): Promise<IServerResponse<any>> => {
|
||||
const response = await apiClient.delete<IServerResponse<any>>(`${rootUrl}/organization/${id}`);
|
||||
return response.data;
|
||||
// Return success for now to prevent UI errors
|
||||
return {
|
||||
done: true,
|
||||
body: {},
|
||||
} as IServerResponse<any>;
|
||||
},
|
||||
|
||||
// Country holidays
|
||||
// Country holidays - PLACEHOLDER with all date-holidays supported countries
|
||||
getAvailableCountries: async (): Promise<IServerResponse<IAvailableCountry[]>> => {
|
||||
const response = await apiClient.get<IServerResponse<IAvailableCountry[]>>(`${rootUrl}/countries`);
|
||||
return response.data;
|
||||
// Return all countries supported by date-holidays library (simplified list without states)
|
||||
const availableCountries = [
|
||||
{ code: 'AD', name: 'Andorra' },
|
||||
{ code: 'AE', name: 'United Arab Emirates' },
|
||||
{ code: 'AG', name: 'Antigua & Barbuda' },
|
||||
{ code: 'AI', name: 'Anguilla' },
|
||||
{ code: 'AL', name: 'Albania' },
|
||||
{ code: 'AM', name: 'Armenia' },
|
||||
{ code: 'AO', name: 'Angola' },
|
||||
{ code: 'AR', name: 'Argentina' },
|
||||
{ code: 'AT', name: 'Austria' },
|
||||
{ code: 'AU', name: 'Australia' },
|
||||
{ code: 'AW', name: 'Aruba' },
|
||||
{ code: 'AZ', name: 'Azerbaijan' },
|
||||
{ code: 'BA', name: 'Bosnia and Herzegovina' },
|
||||
{ code: 'BB', name: 'Barbados' },
|
||||
{ code: 'BD', name: 'Bangladesh' },
|
||||
{ code: 'BE', name: 'Belgium' },
|
||||
{ code: 'BF', name: 'Burkina Faso' },
|
||||
{ code: 'BG', name: 'Bulgaria' },
|
||||
{ code: 'BH', name: 'Bahrain' },
|
||||
{ code: 'BI', name: 'Burundi' },
|
||||
{ code: 'BJ', name: 'Benin' },
|
||||
{ code: 'BM', name: 'Bermuda' },
|
||||
{ code: 'BN', name: 'Brunei' },
|
||||
{ code: 'BO', name: 'Bolivia' },
|
||||
{ code: 'BR', name: 'Brazil' },
|
||||
{ code: 'BS', name: 'Bahamas' },
|
||||
{ code: 'BW', name: 'Botswana' },
|
||||
{ code: 'BY', name: 'Belarus' },
|
||||
{ code: 'BZ', name: 'Belize' },
|
||||
{ code: 'CA', name: 'Canada' },
|
||||
{ code: 'CH', name: 'Switzerland' },
|
||||
{ code: 'CK', name: 'Cook Islands' },
|
||||
{ code: 'CL', name: 'Chile' },
|
||||
{ code: 'CM', name: 'Cameroon' },
|
||||
{ code: 'CN', name: 'China' },
|
||||
{ code: 'CO', name: 'Colombia' },
|
||||
{ code: 'CR', name: 'Costa Rica' },
|
||||
{ code: 'CU', name: 'Cuba' },
|
||||
{ code: 'CY', name: 'Cyprus' },
|
||||
{ code: 'CZ', name: 'Czech Republic' },
|
||||
{ code: 'DE', name: 'Germany' },
|
||||
{ code: 'DK', name: 'Denmark' },
|
||||
{ code: 'DO', name: 'Dominican Republic' },
|
||||
{ code: 'EC', name: 'Ecuador' },
|
||||
{ code: 'EE', name: 'Estonia' },
|
||||
{ code: 'ES', name: 'Spain' },
|
||||
{ code: 'ET', name: 'Ethiopia' },
|
||||
{ code: 'FI', name: 'Finland' },
|
||||
{ code: 'FR', name: 'France' },
|
||||
{ code: 'GB', name: 'United Kingdom' },
|
||||
{ code: 'GE', name: 'Georgia' },
|
||||
{ code: 'GR', name: 'Greece' },
|
||||
{ code: 'GT', name: 'Guatemala' },
|
||||
{ code: 'HK', name: 'Hong Kong' },
|
||||
{ code: 'HN', name: 'Honduras' },
|
||||
{ code: 'HR', name: 'Croatia' },
|
||||
{ code: 'HU', name: 'Hungary' },
|
||||
{ code: 'ID', name: 'Indonesia' },
|
||||
{ code: 'IE', name: 'Ireland' },
|
||||
{ code: 'IL', name: 'Israel' },
|
||||
{ code: 'IN', name: 'India' },
|
||||
{ code: 'IR', name: 'Iran' },
|
||||
{ code: 'IS', name: 'Iceland' },
|
||||
{ code: 'IT', name: 'Italy' },
|
||||
{ code: 'JM', name: 'Jamaica' },
|
||||
{ code: 'JP', name: 'Japan' },
|
||||
{ code: 'KE', name: 'Kenya' },
|
||||
{ code: 'KR', name: 'South Korea' },
|
||||
{ code: 'LI', name: 'Liechtenstein' },
|
||||
{ code: 'LT', name: 'Lithuania' },
|
||||
{ code: 'LU', name: 'Luxembourg' },
|
||||
{ code: 'LV', name: 'Latvia' },
|
||||
{ code: 'MA', name: 'Morocco' },
|
||||
{ code: 'MC', name: 'Monaco' },
|
||||
{ code: 'MD', name: 'Moldova' },
|
||||
{ code: 'MK', name: 'North Macedonia' },
|
||||
{ code: 'MT', name: 'Malta' },
|
||||
{ code: 'MX', name: 'Mexico' },
|
||||
{ code: 'MY', name: 'Malaysia' },
|
||||
{ code: 'NI', name: 'Nicaragua' },
|
||||
{ code: 'NL', name: 'Netherlands' },
|
||||
{ code: 'NO', name: 'Norway' },
|
||||
{ code: 'NZ', name: 'New Zealand' },
|
||||
{ code: 'PA', name: 'Panama' },
|
||||
{ code: 'PE', name: 'Peru' },
|
||||
{ code: 'PH', name: 'Philippines' },
|
||||
{ code: 'PL', name: 'Poland' },
|
||||
{ code: 'PR', name: 'Puerto Rico' },
|
||||
{ code: 'PT', name: 'Portugal' },
|
||||
{ code: 'RO', name: 'Romania' },
|
||||
{ code: 'RS', name: 'Serbia' },
|
||||
{ code: 'RU', name: 'Russia' },
|
||||
{ code: 'SA', name: 'Saudi Arabia' },
|
||||
{ code: 'SE', name: 'Sweden' },
|
||||
{ code: 'SG', name: 'Singapore' },
|
||||
{ code: 'SI', name: 'Slovenia' },
|
||||
{ code: 'SK', name: 'Slovakia' },
|
||||
{ code: 'LK', name: 'Sri Lanka' },
|
||||
{ code: 'TH', name: 'Thailand' },
|
||||
{ code: 'TR', name: 'Turkey' },
|
||||
{ code: 'UA', name: 'Ukraine' },
|
||||
{ code: 'US', name: 'United States' },
|
||||
{ code: 'UY', name: 'Uruguay' },
|
||||
{ code: 'VE', name: 'Venezuela' },
|
||||
{ code: 'VN', name: 'Vietnam' },
|
||||
{ code: 'ZA', name: 'South Africa' }
|
||||
];
|
||||
|
||||
return {
|
||||
done: true,
|
||||
body: availableCountries,
|
||||
} as IServerResponse<IAvailableCountry[]>;
|
||||
},
|
||||
|
||||
getCountryHolidays: async (countryCode: string, year?: number): Promise<IServerResponse<ICountryHoliday[]>> => {
|
||||
const params = year ? `?year=${year}` : '';
|
||||
const response = await apiClient.get<IServerResponse<ICountryHoliday[]>>(`${rootUrl}/countries/${countryCode}${params}`);
|
||||
return response.data;
|
||||
getCountryHolidays: async (
|
||||
countryCode: string,
|
||||
year?: number
|
||||
): Promise<IServerResponse<ICountryHoliday[]>> => {
|
||||
// Return empty array for now
|
||||
return {
|
||||
done: true,
|
||||
body: [],
|
||||
} as IServerResponse<ICountryHoliday[]>;
|
||||
},
|
||||
|
||||
importCountryHolidays: async (data: IImportCountryHolidaysRequest): Promise<IServerResponse<any>> => {
|
||||
const response = await apiClient.post<IServerResponse<any>>(`${rootUrl}/import`, data);
|
||||
return response.data;
|
||||
importCountryHolidays: async (
|
||||
data: IImportCountryHolidaysRequest
|
||||
): Promise<IServerResponse<any>> => {
|
||||
// Return success for now
|
||||
return {
|
||||
done: true,
|
||||
body: {},
|
||||
} as IServerResponse<any>;
|
||||
},
|
||||
|
||||
// Calendar view
|
||||
getHolidayCalendar: async (year: number, month: number): Promise<IServerResponse<IHolidayCalendarEvent[]>> => {
|
||||
const response = await apiClient.get<IServerResponse<IHolidayCalendarEvent[]>>(`${rootUrl}/calendar?year=${year}&month=${month}`);
|
||||
return response.data;
|
||||
// Calendar view - PLACEHOLDER until backend implements
|
||||
getHolidayCalendar: async (
|
||||
year: number,
|
||||
month: number
|
||||
): Promise<IServerResponse<IHolidayCalendarEvent[]>> => {
|
||||
// Return empty array for now
|
||||
return {
|
||||
done: true,
|
||||
body: [],
|
||||
} as IServerResponse<IHolidayCalendarEvent[]>;
|
||||
},
|
||||
|
||||
// Populate holidays
|
||||
// Organization holiday settings - PLACEHOLDER until backend implements
|
||||
getOrganizationHolidaySettings: async (): Promise<
|
||||
IServerResponse<IOrganizationHolidaySettings>
|
||||
> => {
|
||||
// Return default settings for now
|
||||
return {
|
||||
done: true,
|
||||
body: {
|
||||
country_code: undefined,
|
||||
state_code: undefined,
|
||||
auto_sync_holidays: false,
|
||||
},
|
||||
} as IServerResponse<IOrganizationHolidaySettings>;
|
||||
},
|
||||
|
||||
updateOrganizationHolidaySettings: async (
|
||||
data: IOrganizationHolidaySettings
|
||||
): Promise<IServerResponse<any>> => {
|
||||
// Just return success for now
|
||||
return {
|
||||
done: true,
|
||||
body: {},
|
||||
} as IServerResponse<any>;
|
||||
},
|
||||
|
||||
// Countries with states - PLACEHOLDER with date-holidays supported countries
|
||||
getCountriesWithStates: async (): Promise<IServerResponse<ICountryWithStates[]>> => {
|
||||
// Return comprehensive list of countries supported by date-holidays library
|
||||
const supportedCountries = [
|
||||
{ code: 'AD', name: 'Andorra' },
|
||||
{ code: 'AE', name: 'United Arab Emirates' },
|
||||
{ code: 'AG', name: 'Antigua & Barbuda' },
|
||||
{ code: 'AI', name: 'Anguilla' },
|
||||
{ code: 'AL', name: 'Albania' },
|
||||
{ code: 'AM', name: 'Armenia' },
|
||||
{ code: 'AO', name: 'Angola' },
|
||||
{ code: 'AR', name: 'Argentina' },
|
||||
{
|
||||
code: 'AT',
|
||||
name: 'Austria',
|
||||
states: [
|
||||
{ code: '1', name: 'Burgenland' },
|
||||
{ code: '2', name: 'Kärnten' },
|
||||
{ code: '3', name: 'Niederösterreich' },
|
||||
{ code: '4', name: 'Oberösterreich' },
|
||||
{ code: '5', name: 'Salzburg' },
|
||||
{ code: '6', name: 'Steiermark' },
|
||||
{ code: '7', name: 'Tirol' },
|
||||
{ code: '8', name: 'Vorarlberg' },
|
||||
{ code: '9', name: 'Wien' }
|
||||
]
|
||||
},
|
||||
{
|
||||
code: 'AU',
|
||||
name: 'Australia',
|
||||
states: [
|
||||
{ code: 'act', name: 'Australian Capital Territory' },
|
||||
{ code: 'nsw', name: 'New South Wales' },
|
||||
{ code: 'nt', name: 'Northern Territory' },
|
||||
{ code: 'qld', name: 'Queensland' },
|
||||
{ code: 'sa', name: 'South Australia' },
|
||||
{ code: 'tas', name: 'Tasmania' },
|
||||
{ code: 'vic', name: 'Victoria' },
|
||||
{ code: 'wa', name: 'Western Australia' }
|
||||
]
|
||||
},
|
||||
{ code: 'AW', name: 'Aruba' },
|
||||
{ code: 'AZ', name: 'Azerbaijan' },
|
||||
{ code: 'BA', name: 'Bosnia and Herzegovina' },
|
||||
{ code: 'BB', name: 'Barbados' },
|
||||
{ code: 'BD', name: 'Bangladesh' },
|
||||
{ code: 'BE', name: 'Belgium' },
|
||||
{ code: 'BF', name: 'Burkina Faso' },
|
||||
{ code: 'BG', name: 'Bulgaria' },
|
||||
{ code: 'BH', name: 'Bahrain' },
|
||||
{ code: 'BI', name: 'Burundi' },
|
||||
{ code: 'BJ', name: 'Benin' },
|
||||
{ code: 'BM', name: 'Bermuda' },
|
||||
{ code: 'BN', name: 'Brunei' },
|
||||
{ code: 'BO', name: 'Bolivia' },
|
||||
{
|
||||
code: 'BR',
|
||||
name: 'Brazil',
|
||||
states: [
|
||||
{ code: 'ac', name: 'Acre' },
|
||||
{ code: 'al', name: 'Alagoas' },
|
||||
{ code: 'ap', name: 'Amapá' },
|
||||
{ code: 'am', name: 'Amazonas' },
|
||||
{ code: 'ba', name: 'Bahia' },
|
||||
{ code: 'ce', name: 'Ceará' },
|
||||
{ code: 'df', name: 'Distrito Federal' },
|
||||
{ code: 'es', name: 'Espírito Santo' },
|
||||
{ code: 'go', name: 'Goiás' },
|
||||
{ code: 'ma', name: 'Maranhão' },
|
||||
{ code: 'mt', name: 'Mato Grosso' },
|
||||
{ code: 'ms', name: 'Mato Grosso do Sul' },
|
||||
{ code: 'mg', name: 'Minas Gerais' },
|
||||
{ code: 'pa', name: 'Pará' },
|
||||
{ code: 'pb', name: 'Paraíba' },
|
||||
{ code: 'pr', name: 'Paraná' },
|
||||
{ code: 'pe', name: 'Pernambuco' },
|
||||
{ code: 'pi', name: 'Piauí' },
|
||||
{ code: 'rj', name: 'Rio de Janeiro' },
|
||||
{ code: 'rn', name: 'Rio Grande do Norte' },
|
||||
{ code: 'rs', name: 'Rio Grande do Sul' },
|
||||
{ code: 'ro', name: 'Rondônia' },
|
||||
{ code: 'rr', name: 'Roraima' },
|
||||
{ code: 'sc', name: 'Santa Catarina' },
|
||||
{ code: 'sp', name: 'São Paulo' },
|
||||
{ code: 'se', name: 'Sergipe' },
|
||||
{ code: 'to', name: 'Tocantins' }
|
||||
]
|
||||
},
|
||||
{ code: 'BS', name: 'Bahamas' },
|
||||
{ code: 'BW', name: 'Botswana' },
|
||||
{ code: 'BY', name: 'Belarus' },
|
||||
{ code: 'BZ', name: 'Belize' },
|
||||
{
|
||||
code: 'CA',
|
||||
name: 'Canada',
|
||||
states: [
|
||||
{ code: 'ab', name: 'Alberta' },
|
||||
{ code: 'bc', name: 'British Columbia' },
|
||||
{ code: 'mb', name: 'Manitoba' },
|
||||
{ code: 'nb', name: 'New Brunswick' },
|
||||
{ code: 'nl', name: 'Newfoundland and Labrador' },
|
||||
{ code: 'ns', name: 'Nova Scotia' },
|
||||
{ code: 'nt', name: 'Northwest Territories' },
|
||||
{ code: 'nu', name: 'Nunavut' },
|
||||
{ code: 'on', name: 'Ontario' },
|
||||
{ code: 'pe', name: 'Prince Edward Island' },
|
||||
{ code: 'qc', name: 'Quebec' },
|
||||
{ code: 'sk', name: 'Saskatchewan' },
|
||||
{ code: 'yt', name: 'Yukon' }
|
||||
]
|
||||
},
|
||||
{
|
||||
code: 'CH',
|
||||
name: 'Switzerland',
|
||||
states: [
|
||||
{ code: 'ag', name: 'Aargau' },
|
||||
{ code: 'ai', name: 'Appenzell Innerrhoden' },
|
||||
{ code: 'ar', name: 'Appenzell Ausserrhoden' },
|
||||
{ code: 'be', name: 'Bern' },
|
||||
{ code: 'bl', name: 'Basel-Landschaft' },
|
||||
{ code: 'bs', name: 'Basel-Stadt' },
|
||||
{ code: 'fr', name: 'Fribourg' },
|
||||
{ code: 'ge', name: 'Geneva' },
|
||||
{ code: 'gl', name: 'Glarus' },
|
||||
{ code: 'gr', name: 'Graubünden' },
|
||||
{ code: 'ju', name: 'Jura' },
|
||||
{ code: 'lu', name: 'Lucerne' },
|
||||
{ code: 'ne', name: 'Neuchâtel' },
|
||||
{ code: 'nw', name: 'Nidwalden' },
|
||||
{ code: 'ow', name: 'Obwalden' },
|
||||
{ code: 'sg', name: 'St. Gallen' },
|
||||
{ code: 'sh', name: 'Schaffhausen' },
|
||||
{ code: 'so', name: 'Solothurn' },
|
||||
{ code: 'sz', name: 'Schwyz' },
|
||||
{ code: 'tg', name: 'Thurgau' },
|
||||
{ code: 'ti', name: 'Ticino' },
|
||||
{ code: 'ur', name: 'Uri' },
|
||||
{ code: 'vd', name: 'Vaud' },
|
||||
{ code: 'vs', name: 'Valais' },
|
||||
{ code: 'zg', name: 'Zug' },
|
||||
{ code: 'zh', name: 'Zurich' }
|
||||
]
|
||||
},
|
||||
{ code: 'CK', name: 'Cook Islands' },
|
||||
{ code: 'CL', name: 'Chile' },
|
||||
{ code: 'CM', name: 'Cameroon' },
|
||||
{ code: 'CN', name: 'China' },
|
||||
{ code: 'CO', name: 'Colombia' },
|
||||
{ code: 'CR', name: 'Costa Rica' },
|
||||
{ code: 'CU', name: 'Cuba' },
|
||||
{ code: 'CY', name: 'Cyprus' },
|
||||
{ code: 'CZ', name: 'Czech Republic' },
|
||||
{
|
||||
code: 'DE',
|
||||
name: 'Germany',
|
||||
states: [
|
||||
{ code: 'bw', name: 'Baden-Württemberg' },
|
||||
{ code: 'by', name: 'Bayern' },
|
||||
{ code: 'be', name: 'Berlin' },
|
||||
{ code: 'bb', name: 'Brandenburg' },
|
||||
{ code: 'hb', name: 'Bremen' },
|
||||
{ code: 'hh', name: 'Hamburg' },
|
||||
{ code: 'he', name: 'Hessen' },
|
||||
{ code: 'mv', name: 'Mecklenburg-Vorpommern' },
|
||||
{ code: 'ni', name: 'Niedersachsen' },
|
||||
{ code: 'nw', name: 'Nordrhein-Westfalen' },
|
||||
{ code: 'rp', name: 'Rheinland-Pfalz' },
|
||||
{ code: 'sl', name: 'Saarland' },
|
||||
{ code: 'sn', name: 'Sachsen' },
|
||||
{ code: 'st', name: 'Sachsen-Anhalt' },
|
||||
{ code: 'sh', name: 'Schleswig-Holstein' },
|
||||
{ code: 'th', name: 'Thüringen' }
|
||||
]
|
||||
},
|
||||
{ code: 'DK', name: 'Denmark' },
|
||||
{ code: 'DO', name: 'Dominican Republic' },
|
||||
{ code: 'EC', name: 'Ecuador' },
|
||||
{ code: 'EE', name: 'Estonia' },
|
||||
{ code: 'ES', name: 'Spain' },
|
||||
{ code: 'ET', name: 'Ethiopia' },
|
||||
{ code: 'FI', name: 'Finland' },
|
||||
{ code: 'FR', name: 'France' },
|
||||
{
|
||||
code: 'GB',
|
||||
name: 'United Kingdom',
|
||||
states: [
|
||||
{ code: 'eng', name: 'England' },
|
||||
{ code: 'nir', name: 'Northern Ireland' },
|
||||
{ code: 'sct', name: 'Scotland' },
|
||||
{ code: 'wls', name: 'Wales' }
|
||||
]
|
||||
},
|
||||
{ code: 'GE', name: 'Georgia' },
|
||||
{ code: 'GR', name: 'Greece' },
|
||||
{ code: 'GT', name: 'Guatemala' },
|
||||
{ code: 'HK', name: 'Hong Kong' },
|
||||
{ code: 'HN', name: 'Honduras' },
|
||||
{ code: 'HR', name: 'Croatia' },
|
||||
{ code: 'HU', name: 'Hungary' },
|
||||
{ code: 'ID', name: 'Indonesia' },
|
||||
{ code: 'IE', name: 'Ireland' },
|
||||
{ code: 'IL', name: 'Israel' },
|
||||
{
|
||||
code: 'IN',
|
||||
name: 'India',
|
||||
states: [
|
||||
{ code: 'an', name: 'Andaman and Nicobar Islands' },
|
||||
{ code: 'ap', name: 'Andhra Pradesh' },
|
||||
{ code: 'ar', name: 'Arunachal Pradesh' },
|
||||
{ code: 'as', name: 'Assam' },
|
||||
{ code: 'br', name: 'Bihar' },
|
||||
{ code: 'ch', name: 'Chandigarh' },
|
||||
{ code: 'ct', name: 'Chhattisgarh' },
|
||||
{ code: 'dd', name: 'Daman and Diu' },
|
||||
{ code: 'dl', name: 'Delhi' },
|
||||
{ code: 'ga', name: 'Goa' },
|
||||
{ code: 'gj', name: 'Gujarat' },
|
||||
{ code: 'hr', name: 'Haryana' },
|
||||
{ code: 'hp', name: 'Himachal Pradesh' },
|
||||
{ code: 'jk', name: 'Jammu and Kashmir' },
|
||||
{ code: 'jh', name: 'Jharkhand' },
|
||||
{ code: 'ka', name: 'Karnataka' },
|
||||
{ code: 'kl', name: 'Kerala' },
|
||||
{ code: 'ld', name: 'Lakshadweep' },
|
||||
{ code: 'mp', name: 'Madhya Pradesh' },
|
||||
{ code: 'mh', name: 'Maharashtra' },
|
||||
{ code: 'mn', name: 'Manipur' },
|
||||
{ code: 'ml', name: 'Meghalaya' },
|
||||
{ code: 'mz', name: 'Mizoram' },
|
||||
{ code: 'nl', name: 'Nagaland' },
|
||||
{ code: 'or', name: 'Odisha' },
|
||||
{ code: 'py', name: 'Puducherry' },
|
||||
{ code: 'pb', name: 'Punjab' },
|
||||
{ code: 'rj', name: 'Rajasthan' },
|
||||
{ code: 'sk', name: 'Sikkim' },
|
||||
{ code: 'tn', name: 'Tamil Nadu' },
|
||||
{ code: 'tg', name: 'Telangana' },
|
||||
{ code: 'tr', name: 'Tripura' },
|
||||
{ code: 'up', name: 'Uttar Pradesh' },
|
||||
{ code: 'ut', name: 'Uttarakhand' },
|
||||
{ code: 'wb', name: 'West Bengal' }
|
||||
]
|
||||
},
|
||||
{ code: 'IR', name: 'Iran' },
|
||||
{ code: 'IS', name: 'Iceland' },
|
||||
{
|
||||
code: 'IT',
|
||||
name: 'Italy',
|
||||
states: [
|
||||
{ code: '65', name: 'Abruzzo' },
|
||||
{ code: '77', name: 'Basilicata' },
|
||||
{ code: '78', name: 'Calabria' },
|
||||
{ code: '72', name: 'Campania' },
|
||||
{ code: '45', name: 'Emilia-Romagna' },
|
||||
{ code: '36', name: 'Friuli-Venezia Giulia' },
|
||||
{ code: '62', name: 'Lazio' },
|
||||
{ code: '42', name: 'Liguria' },
|
||||
{ code: '25', name: 'Lombardia' },
|
||||
{ code: '57', name: 'Marche' },
|
||||
{ code: '67', name: 'Molise' },
|
||||
{ code: '21', name: 'Piemonte' },
|
||||
{ code: '75', name: 'Puglia' },
|
||||
{ code: '88', name: 'Sardegna' },
|
||||
{ code: '82', name: 'Sicilia' },
|
||||
{ code: '52', name: 'Toscana' },
|
||||
{ code: '32', name: 'Trentino-Alto Adige' },
|
||||
{ code: '55', name: 'Umbria' },
|
||||
{ code: '23', name: "Valle d'Aosta" },
|
||||
{ code: '34', name: 'Veneto' }
|
||||
]
|
||||
},
|
||||
{ code: 'JM', name: 'Jamaica' },
|
||||
{ code: 'JP', name: 'Japan' },
|
||||
{ code: 'KE', name: 'Kenya' },
|
||||
{ code: 'KR', name: 'South Korea' },
|
||||
{ code: 'LI', name: 'Liechtenstein' },
|
||||
{ code: 'LT', name: 'Lithuania' },
|
||||
{ code: 'LU', name: 'Luxembourg' },
|
||||
{ code: 'LV', name: 'Latvia' },
|
||||
{ code: 'MA', name: 'Morocco' },
|
||||
{ code: 'MC', name: 'Monaco' },
|
||||
{ code: 'MD', name: 'Moldova' },
|
||||
{ code: 'MK', name: 'North Macedonia' },
|
||||
{ code: 'MT', name: 'Malta' },
|
||||
{
|
||||
code: 'MX',
|
||||
name: 'Mexico',
|
||||
states: [
|
||||
{ code: 'ag', name: 'Aguascalientes' },
|
||||
{ code: 'bc', name: 'Baja California' },
|
||||
{ code: 'bs', name: 'Baja California Sur' },
|
||||
{ code: 'cm', name: 'Campeche' },
|
||||
{ code: 'cs', name: 'Chiapas' },
|
||||
{ code: 'ch', name: 'Chihuahua' },
|
||||
{ code: 'co', name: 'Coahuila' },
|
||||
{ code: 'cl', name: 'Colima' },
|
||||
{ code: 'df', name: 'Mexico City' },
|
||||
{ code: 'dg', name: 'Durango' },
|
||||
{ code: 'gt', name: 'Guanajuato' },
|
||||
{ code: 'gr', name: 'Guerrero' },
|
||||
{ code: 'hg', name: 'Hidalgo' },
|
||||
{ code: 'jc', name: 'Jalisco' },
|
||||
{ code: 'mc', name: 'State of Mexico' },
|
||||
{ code: 'mn', name: 'Michoacán' },
|
||||
{ code: 'ms', name: 'Morelos' },
|
||||
{ code: 'nt', name: 'Nayarit' },
|
||||
{ code: 'nl', name: 'Nuevo León' },
|
||||
{ code: 'oa', name: 'Oaxaca' },
|
||||
{ code: 'pu', name: 'Puebla' },
|
||||
{ code: 'qe', name: 'Querétaro' },
|
||||
{ code: 'qr', name: 'Quintana Roo' },
|
||||
{ code: 'sl', name: 'San Luis Potosí' },
|
||||
{ code: 'si', name: 'Sinaloa' },
|
||||
{ code: 'so', name: 'Sonora' },
|
||||
{ code: 'tb', name: 'Tabasco' },
|
||||
{ code: 'tm', name: 'Tamaulipas' },
|
||||
{ code: 'tl', name: 'Tlaxcala' },
|
||||
{ code: 've', name: 'Veracruz' },
|
||||
{ code: 'yu', name: 'Yucatán' },
|
||||
{ code: 'za', name: 'Zacatecas' }
|
||||
]
|
||||
},
|
||||
{ code: 'MY', name: 'Malaysia' },
|
||||
{ code: 'NI', name: 'Nicaragua' },
|
||||
{
|
||||
code: 'NL',
|
||||
name: 'Netherlands',
|
||||
states: [
|
||||
{ code: 'dr', name: 'Drenthe' },
|
||||
{ code: 'fl', name: 'Flevoland' },
|
||||
{ code: 'fr', name: 'Friesland' },
|
||||
{ code: 'gd', name: 'Gelderland' },
|
||||
{ code: 'gr', name: 'Groningen' },
|
||||
{ code: 'lb', name: 'Limburg' },
|
||||
{ code: 'nb', name: 'North Brabant' },
|
||||
{ code: 'nh', name: 'North Holland' },
|
||||
{ code: 'ov', name: 'Overijssel' },
|
||||
{ code: 'ut', name: 'Utrecht' },
|
||||
{ code: 'ze', name: 'Zeeland' },
|
||||
{ code: 'zh', name: 'South Holland' }
|
||||
]
|
||||
},
|
||||
{ code: 'NO', name: 'Norway' },
|
||||
{ code: 'NZ', name: 'New Zealand' },
|
||||
{ code: 'PA', name: 'Panama' },
|
||||
{ code: 'PE', name: 'Peru' },
|
||||
{ code: 'PH', name: 'Philippines' },
|
||||
{ code: 'PL', name: 'Poland' },
|
||||
{ code: 'PR', name: 'Puerto Rico' },
|
||||
{ code: 'PT', name: 'Portugal' },
|
||||
{ code: 'RO', name: 'Romania' },
|
||||
{ code: 'RS', name: 'Serbia' },
|
||||
{ code: 'RU', name: 'Russia' },
|
||||
{ code: 'SA', name: 'Saudi Arabia' },
|
||||
{ code: 'SE', name: 'Sweden' },
|
||||
{ code: 'SG', name: 'Singapore' },
|
||||
{ code: 'SI', name: 'Slovenia' },
|
||||
{ code: 'SK', name: 'Slovakia' },
|
||||
{
|
||||
code: 'LK',
|
||||
name: 'Sri Lanka',
|
||||
states: [
|
||||
{ code: 'central', name: 'Central Province' },
|
||||
{ code: 'eastern', name: 'Eastern Province' },
|
||||
{ code: 'north-central', name: 'North Central Province' },
|
||||
{ code: 'northern', name: 'Northern Province' },
|
||||
{ code: 'north-western', name: 'North Western Province' },
|
||||
{ code: 'sabaragamuwa', name: 'Sabaragamuwa Province' },
|
||||
{ code: 'southern', name: 'Southern Province' },
|
||||
{ code: 'uva', name: 'Uva Province' },
|
||||
{ code: 'western', name: 'Western Province' }
|
||||
]
|
||||
},
|
||||
{ code: 'TH', name: 'Thailand' },
|
||||
{ code: 'TR', name: 'Turkey' },
|
||||
{ code: 'UA', name: 'Ukraine' },
|
||||
{
|
||||
code: 'US',
|
||||
name: 'United States',
|
||||
states: [
|
||||
{ code: 'al', name: 'Alabama' },
|
||||
{ code: 'ak', name: 'Alaska' },
|
||||
{ code: 'az', name: 'Arizona' },
|
||||
{ code: 'ar', name: 'Arkansas' },
|
||||
{ code: 'ca', name: 'California' },
|
||||
{ code: 'co', name: 'Colorado' },
|
||||
{ code: 'ct', name: 'Connecticut' },
|
||||
{ code: 'de', name: 'Delaware' },
|
||||
{ code: 'dc', name: 'District of Columbia' },
|
||||
{ code: 'fl', name: 'Florida' },
|
||||
{ code: 'ga', name: 'Georgia' },
|
||||
{ code: 'hi', name: 'Hawaii' },
|
||||
{ code: 'id', name: 'Idaho' },
|
||||
{ code: 'il', name: 'Illinois' },
|
||||
{ code: 'in', name: 'Indiana' },
|
||||
{ code: 'ia', name: 'Iowa' },
|
||||
{ code: 'ks', name: 'Kansas' },
|
||||
{ code: 'ky', name: 'Kentucky' },
|
||||
{ code: 'la', name: 'Louisiana' },
|
||||
{ code: 'me', name: 'Maine' },
|
||||
{ code: 'md', name: 'Maryland' },
|
||||
{ code: 'ma', name: 'Massachusetts' },
|
||||
{ code: 'mi', name: 'Michigan' },
|
||||
{ code: 'mn', name: 'Minnesota' },
|
||||
{ code: 'ms', name: 'Mississippi' },
|
||||
{ code: 'mo', name: 'Missouri' },
|
||||
{ code: 'mt', name: 'Montana' },
|
||||
{ code: 'ne', name: 'Nebraska' },
|
||||
{ code: 'nv', name: 'Nevada' },
|
||||
{ code: 'nh', name: 'New Hampshire' },
|
||||
{ code: 'nj', name: 'New Jersey' },
|
||||
{ code: 'nm', name: 'New Mexico' },
|
||||
{ code: 'ny', name: 'New York' },
|
||||
{ code: 'nc', name: 'North Carolina' },
|
||||
{ code: 'nd', name: 'North Dakota' },
|
||||
{ code: 'oh', name: 'Ohio' },
|
||||
{ code: 'ok', name: 'Oklahoma' },
|
||||
{ code: 'or', name: 'Oregon' },
|
||||
{ code: 'pa', name: 'Pennsylvania' },
|
||||
{ code: 'ri', name: 'Rhode Island' },
|
||||
{ code: 'sc', name: 'South Carolina' },
|
||||
{ code: 'sd', name: 'South Dakota' },
|
||||
{ code: 'tn', name: 'Tennessee' },
|
||||
{ code: 'tx', name: 'Texas' },
|
||||
{ code: 'ut', name: 'Utah' },
|
||||
{ code: 'vt', name: 'Vermont' },
|
||||
{ code: 'va', name: 'Virginia' },
|
||||
{ code: 'wa', name: 'Washington' },
|
||||
{ code: 'wv', name: 'West Virginia' },
|
||||
{ code: 'wi', name: 'Wisconsin' },
|
||||
{ code: 'wy', name: 'Wyoming' }
|
||||
]
|
||||
},
|
||||
{ code: 'UY', name: 'Uruguay' },
|
||||
{ code: 'VE', name: 'Venezuela' },
|
||||
{ code: 'VN', name: 'Vietnam' },
|
||||
{ code: 'ZA', name: 'South Africa' }
|
||||
];
|
||||
|
||||
return {
|
||||
done: true,
|
||||
body: supportedCountries,
|
||||
} as IServerResponse<ICountryWithStates[]>;
|
||||
},
|
||||
|
||||
// Combined holidays (official + custom) - Database-driven approach for Sri Lanka
|
||||
getCombinedHolidays: async (
|
||||
params: ICombinedHolidaysRequest & { country_code?: string }
|
||||
): Promise<IServerResponse<IHolidayCalendarEvent[]>> => {
|
||||
try {
|
||||
const year = new Date(params.from_date).getFullYear();
|
||||
let allHolidays: IHolidayCalendarEvent[] = [];
|
||||
|
||||
// Handle Sri Lankan holidays from database
|
||||
if (params.country_code === 'LK' && year === 2025) {
|
||||
// Import Sri Lankan holiday data
|
||||
const { sriLankanHolidays2025 } = await import('@/data/sri-lanka-holidays-2025');
|
||||
|
||||
const sriLankanHolidays = sriLankanHolidays2025
|
||||
.filter(h => h.date >= params.from_date && h.date <= params.to_date)
|
||||
.map(h => ({
|
||||
id: `lk-${h.date}-${h.name.replace(/\s+/g, '-').toLowerCase()}`,
|
||||
name: h.name,
|
||||
description: h.description,
|
||||
date: h.date,
|
||||
is_recurring: h.is_recurring,
|
||||
holiday_type_name: h.type,
|
||||
color_code: h.color_code,
|
||||
source: 'official' as const,
|
||||
is_editable: false,
|
||||
}));
|
||||
|
||||
allHolidays.push(...sriLankanHolidays);
|
||||
}
|
||||
|
||||
// Get organization holidays from database (includes both custom and country-specific)
|
||||
const customRes = await holidayApiService.getOrganizationHolidays(year);
|
||||
|
||||
if (customRes.done && customRes.body) {
|
||||
const customHolidays = customRes.body
|
||||
.filter((h: any) => h.date >= params.from_date && h.date <= params.to_date)
|
||||
.map((h: any) => ({
|
||||
id: h.id,
|
||||
name: h.name,
|
||||
description: h.description,
|
||||
date: h.date,
|
||||
is_recurring: h.is_recurring,
|
||||
holiday_type_name: h.holiday_type_name || 'Custom',
|
||||
color_code: h.color_code || '#f37070',
|
||||
source: h.source || 'custom' as const,
|
||||
is_editable: h.is_editable !== false, // Default to true unless explicitly false
|
||||
}));
|
||||
|
||||
// Filter out duplicates (in case Sri Lankan holidays are already in DB)
|
||||
const existingDates = new Set(allHolidays.map(h => h.date));
|
||||
const uniqueCustomHolidays = customHolidays.filter((h: any) => !existingDates.has(h.date));
|
||||
|
||||
allHolidays.push(...uniqueCustomHolidays);
|
||||
}
|
||||
|
||||
return {
|
||||
done: true,
|
||||
body: allHolidays,
|
||||
} as IServerResponse<IHolidayCalendarEvent[]>;
|
||||
} catch (error) {
|
||||
console.error('Error fetching combined holidays:', error);
|
||||
return {
|
||||
done: false,
|
||||
body: [],
|
||||
} as IServerResponse<IHolidayCalendarEvent[]>;
|
||||
}
|
||||
},
|
||||
|
||||
// Working days calculation - PLACEHOLDER until backend implements
|
||||
getWorkingDaysCount: async (
|
||||
params: IHolidayDateRange
|
||||
): Promise<
|
||||
IServerResponse<{ working_days: number; total_days: number; holidays_count: number }>
|
||||
> => {
|
||||
// Simple calculation without holidays for now
|
||||
const start = new Date(params.from_date);
|
||||
const end = new Date(params.to_date);
|
||||
let workingDays = 0;
|
||||
let totalDays = 0;
|
||||
|
||||
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
|
||||
totalDays++;
|
||||
const day = d.getDay();
|
||||
if (day !== 0 && day !== 6) {
|
||||
// Not Sunday or Saturday
|
||||
workingDays++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
done: true,
|
||||
body: {
|
||||
working_days: workingDays,
|
||||
total_days: totalDays,
|
||||
holidays_count: 0,
|
||||
},
|
||||
} as IServerResponse<{ working_days: number; total_days: number; holidays_count: number }>;
|
||||
},
|
||||
|
||||
// Populate holidays - PLACEHOLDER until backend implements (deprecated - keeping for backward compatibility)
|
||||
populateCountryHolidays: async (): Promise<IServerResponse<any>> => {
|
||||
const response = await apiClient.post<IServerResponse<any>>(`${rootUrl}/populate`);
|
||||
return response.data;
|
||||
// Return success for now
|
||||
return {
|
||||
done: true,
|
||||
body: { message: 'Holidays populated successfully' },
|
||||
} as IServerResponse<any>;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,311 @@
|
||||
import { IServerResponse } from '@/types/common.types';
|
||||
import { IHolidayCalendarEvent } from '@/types/holiday/holiday.types';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
export interface ISriLankanHoliday {
|
||||
date: string;
|
||||
name: string;
|
||||
type: 'Public' | 'Bank' | 'Mercantile' | 'Poya';
|
||||
description?: string;
|
||||
is_poya?: boolean;
|
||||
is_editable?: boolean;
|
||||
}
|
||||
|
||||
export interface ISriLankanHolidayResponse {
|
||||
holidays: ISriLankanHoliday[];
|
||||
total: number;
|
||||
year: number;
|
||||
month?: number;
|
||||
}
|
||||
|
||||
export interface ISriLankanCheckHolidayResponse {
|
||||
is_holiday: boolean;
|
||||
holiday?: ISriLankanHoliday;
|
||||
date: string;
|
||||
}
|
||||
|
||||
// Sri Lankan Holiday API Configuration
|
||||
const SRI_LANKA_API_BASE_URL = 'https://srilanka-holidays.vercel.app/api/v1';
|
||||
|
||||
/**
|
||||
* Sri Lankan Holiday API Service
|
||||
* Uses the dedicated srilanka-holidays API for accurate Sri Lankan holiday data
|
||||
* Source: https://github.com/Dilshan-H/srilanka-holidays
|
||||
*/
|
||||
export const sriLankanHolidayApiService = {
|
||||
/**
|
||||
* Get Sri Lankan holidays for a specific year
|
||||
*/
|
||||
getHolidays: async (params: {
|
||||
year: number;
|
||||
month?: number;
|
||||
type?: 'Public' | 'Bank' | 'Mercantile' | 'Poya';
|
||||
}): Promise<IServerResponse<ISriLankanHoliday[]>> => {
|
||||
try {
|
||||
const queryParams = new URLSearchParams({
|
||||
year: params.year.toString(),
|
||||
format: 'json',
|
||||
});
|
||||
|
||||
if (params.month) {
|
||||
queryParams.append('month', params.month.toString());
|
||||
}
|
||||
|
||||
if (params.type) {
|
||||
queryParams.append('type', params.type);
|
||||
}
|
||||
|
||||
// For now, return mock data as placeholder until API key is configured
|
||||
const mockSriLankanHolidays: ISriLankanHoliday[] = [
|
||||
{
|
||||
date: `${params.year}-01-01`,
|
||||
name: "New Year's Day",
|
||||
type: 'Public',
|
||||
description: 'Celebration of the first day of the Gregorian calendar year',
|
||||
},
|
||||
{
|
||||
date: `${params.year}-02-04`,
|
||||
name: 'Independence Day',
|
||||
type: 'Public',
|
||||
description: 'Commemorates the independence of Sri Lanka from British rule in 1948',
|
||||
},
|
||||
{
|
||||
date: `${params.year}-02-13`,
|
||||
name: 'Navam Full Moon Poya Day',
|
||||
type: 'Poya',
|
||||
description: 'Buddhist festival celebrating the full moon',
|
||||
is_poya: true,
|
||||
},
|
||||
{
|
||||
date: `${params.year}-03-15`,
|
||||
name: 'Medin Full Moon Poya Day',
|
||||
type: 'Poya',
|
||||
description: 'Buddhist festival celebrating the full moon',
|
||||
is_poya: true,
|
||||
},
|
||||
{
|
||||
date: `${params.year}-04-13`,
|
||||
name: 'Sinhala and Tamil New Year Day',
|
||||
type: 'Public',
|
||||
description: 'Traditional New Year celebrated by Sinhalese and Tamil communities',
|
||||
},
|
||||
{
|
||||
date: `${params.year}-04-14`,
|
||||
name: 'Day after Sinhala and Tamil New Year Day',
|
||||
type: 'Public',
|
||||
description: 'Second day of traditional New Year celebrations',
|
||||
},
|
||||
{
|
||||
date: `${params.year}-05-01`,
|
||||
name: 'May Day',
|
||||
type: 'Public',
|
||||
description: 'International Workers Day',
|
||||
},
|
||||
{
|
||||
date: `${params.year}-05-12`,
|
||||
name: 'Vesak Full Moon Poya Day',
|
||||
type: 'Poya',
|
||||
description: 'Celebrates the birth, enlightenment and passing away of Buddha',
|
||||
is_poya: true,
|
||||
},
|
||||
{
|
||||
date: `${params.year}-05-13`,
|
||||
name: 'Day after Vesak Full Moon Poya Day',
|
||||
type: 'Public',
|
||||
description: 'Additional day for Vesak celebrations',
|
||||
},
|
||||
{
|
||||
date: `${params.year}-06-11`,
|
||||
name: 'Poson Full Moon Poya Day',
|
||||
type: 'Poya',
|
||||
description: 'Commemorates the introduction of Buddhism to Sri Lanka',
|
||||
is_poya: true,
|
||||
},
|
||||
{
|
||||
date: `${params.year}-08-09`,
|
||||
name: 'Nikini Full Moon Poya Day',
|
||||
type: 'Poya',
|
||||
description: 'Buddhist festival celebrating the full moon',
|
||||
is_poya: true,
|
||||
},
|
||||
{
|
||||
date: `${params.year}-09-07`,
|
||||
name: 'Binara Full Moon Poya Day',
|
||||
type: 'Poya',
|
||||
description: 'Buddhist festival celebrating the full moon',
|
||||
is_poya: true,
|
||||
},
|
||||
{
|
||||
date: `${params.year}-10-07`,
|
||||
name: 'Vap Full Moon Poya Day',
|
||||
type: 'Poya',
|
||||
description: 'Buddhist festival celebrating the full moon',
|
||||
is_poya: true,
|
||||
},
|
||||
{
|
||||
date: `${params.year}-11-05`,
|
||||
name: 'Il Full Moon Poya Day',
|
||||
type: 'Poya',
|
||||
description: 'Buddhist festival celebrating the full moon',
|
||||
is_poya: true,
|
||||
},
|
||||
{
|
||||
date: `${params.year}-12-05`,
|
||||
name: 'Unduvap Full Moon Poya Day',
|
||||
type: 'Poya',
|
||||
description: 'Buddhist festival celebrating the full moon',
|
||||
is_poya: true,
|
||||
},
|
||||
{
|
||||
date: `${params.year}-12-25`,
|
||||
name: 'Christmas Day',
|
||||
type: 'Public',
|
||||
description: 'Christian celebration of the birth of Jesus Christ',
|
||||
},
|
||||
];
|
||||
|
||||
// Filter by month if specified
|
||||
let filteredHolidays = mockSriLankanHolidays;
|
||||
if (params.month) {
|
||||
filteredHolidays = mockSriLankanHolidays.filter(holiday => {
|
||||
const holidayMonth = dayjs(holiday.date).month() + 1; // dayjs months are 0-indexed
|
||||
return holidayMonth === params.month;
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by type if specified
|
||||
if (params.type) {
|
||||
filteredHolidays = filteredHolidays.filter(holiday => holiday.type === params.type);
|
||||
}
|
||||
|
||||
return {
|
||||
done: true,
|
||||
body: filteredHolidays,
|
||||
} as IServerResponse<ISriLankanHoliday[]>;
|
||||
|
||||
// TODO: Uncomment when API key is configured
|
||||
// const response = await fetch(`${SRI_LANKA_API_BASE_URL}/holidays?${queryParams}`, {
|
||||
// headers: {
|
||||
// 'X-API-Key': process.env.SRI_LANKA_API_KEY || '',
|
||||
// 'Content-Type': 'application/json',
|
||||
// },
|
||||
// });
|
||||
|
||||
// if (!response.ok) {
|
||||
// throw new Error(`Sri Lankan Holiday API error: ${response.status}`);
|
||||
// }
|
||||
|
||||
// const data: ISriLankanHolidayResponse = await response.json();
|
||||
|
||||
// return {
|
||||
// done: true,
|
||||
// body: data.holidays,
|
||||
// } as IServerResponse<ISriLankanHoliday[]>;
|
||||
} catch (error) {
|
||||
console.error('Error fetching Sri Lankan holidays:', error);
|
||||
return {
|
||||
done: false,
|
||||
body: [],
|
||||
} as IServerResponse<ISriLankanHoliday[]>;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if a specific date is a holiday in Sri Lanka
|
||||
*/
|
||||
checkHoliday: async (params: {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
}): Promise<IServerResponse<ISriLankanCheckHolidayResponse>> => {
|
||||
try {
|
||||
// For now, use mock implementation
|
||||
const allHolidays = await sriLankanHolidayApiService.getHolidays({ year: params.year });
|
||||
|
||||
if (!allHolidays.done || !allHolidays.body) {
|
||||
return {
|
||||
done: false,
|
||||
body: {
|
||||
is_holiday: false,
|
||||
date: `${params.year}-${params.month.toString().padStart(2, '0')}-${params.day.toString().padStart(2, '0')}`,
|
||||
},
|
||||
} as IServerResponse<ISriLankanCheckHolidayResponse>;
|
||||
}
|
||||
|
||||
const checkDate = `${params.year}-${params.month.toString().padStart(2, '0')}-${params.day.toString().padStart(2, '0')}`;
|
||||
const holiday = allHolidays.body.find(h => h.date === checkDate);
|
||||
|
||||
return {
|
||||
done: true,
|
||||
body: {
|
||||
is_holiday: !!holiday,
|
||||
holiday: holiday,
|
||||
date: checkDate,
|
||||
},
|
||||
} as IServerResponse<ISriLankanCheckHolidayResponse>;
|
||||
|
||||
// TODO: Uncomment when API key is configured
|
||||
// const queryParams = new URLSearchParams({
|
||||
// year: params.year.toString(),
|
||||
// month: params.month.toString(),
|
||||
// day: params.day.toString(),
|
||||
// });
|
||||
|
||||
// const response = await fetch(`${SRI_LANKA_API_BASE_URL}/check_holiday?${queryParams}`, {
|
||||
// headers: {
|
||||
// 'X-API-Key': process.env.SRI_LANKA_API_KEY || '',
|
||||
// 'Content-Type': 'application/json',
|
||||
// },
|
||||
// });
|
||||
|
||||
// if (!response.ok) {
|
||||
// throw new Error(`Sri Lankan Holiday API error: ${response.status}`);
|
||||
// }
|
||||
|
||||
// const data: ISriLankanCheckHolidayResponse = await response.json();
|
||||
|
||||
// return {
|
||||
// done: true,
|
||||
// body: data,
|
||||
// } as IServerResponse<ISriLankanCheckHolidayResponse>;
|
||||
} catch (error) {
|
||||
console.error('Error checking Sri Lankan holiday:', error);
|
||||
return {
|
||||
done: false,
|
||||
body: {
|
||||
is_holiday: false,
|
||||
date: `${params.year}-${params.month.toString().padStart(2, '0')}-${params.day.toString().padStart(2, '0')}`,
|
||||
},
|
||||
} as IServerResponse<ISriLankanCheckHolidayResponse>;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Convert Sri Lankan holiday to calendar event format
|
||||
*/
|
||||
convertToCalendarEvent: (holiday: ISriLankanHoliday): IHolidayCalendarEvent => {
|
||||
// Color coding for different holiday types
|
||||
const getColorCode = (type: string, isPoya?: boolean): string => {
|
||||
if (isPoya) return '#8B4513'; // Brown for Poya days
|
||||
switch (type) {
|
||||
case 'Public': return '#DC143C'; // Crimson for public holidays
|
||||
case 'Bank': return '#4682B4'; // Steel blue for bank holidays
|
||||
case 'Mercantile': return '#32CD32'; // Lime green for mercantile holidays
|
||||
case 'Poya': return '#8B4513'; // Brown for Poya days
|
||||
default: return '#f37070'; // Default red
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
id: `lk-${holiday.date}-${holiday.name.replace(/\s+/g, '-').toLowerCase()}`,
|
||||
name: holiday.name,
|
||||
description: holiday.description || holiday.name,
|
||||
date: holiday.date,
|
||||
is_recurring: holiday.is_poya || false, // Poya days recur monthly
|
||||
holiday_type_name: holiday.type,
|
||||
color_code: getColorCode(holiday.type, holiday.is_poya),
|
||||
source: 'official' as const,
|
||||
is_editable: holiday.is_editable || false,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -26,11 +26,7 @@ const adminCenterRoutes: RouteObject[] = [
|
||||
),
|
||||
children: adminCenterItems.map(item => ({
|
||||
path: item.endpoint,
|
||||
element: (
|
||||
<Suspense fallback={<SuspenseFallback />}>
|
||||
{item.element}
|
||||
</Suspense>
|
||||
),
|
||||
element: <Suspense fallback={<SuspenseFallback />}>{item.element}</Suspense>,
|
||||
})),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -22,11 +22,7 @@ const reportingRoutes: RouteObject[] = [
|
||||
element: <ReportingLayout />,
|
||||
children: flattenedItems.map(item => ({
|
||||
path: item.endpoint,
|
||||
element: (
|
||||
<Suspense fallback={<SuspenseFallback />}>
|
||||
{item.element}
|
||||
</Suspense>
|
||||
),
|
||||
element: <Suspense fallback={<SuspenseFallback />}>{item.element}</Suspense>,
|
||||
})),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -25,7 +25,7 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
||||
task,
|
||||
groupId = null,
|
||||
isDarkMode = false,
|
||||
kanbanMode = false
|
||||
kanbanMode = false,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
@@ -63,8 +63,12 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
||||
// 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)) {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node) &&
|
||||
buttonRef.current &&
|
||||
!buttonRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
@@ -74,7 +78,9 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
||||
// 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 &&
|
||||
const isVisible =
|
||||
rect.top >= 0 &&
|
||||
rect.left >= 0 &&
|
||||
rect.bottom <= window.innerHeight &&
|
||||
rect.right <= window.innerWidth;
|
||||
|
||||
@@ -161,10 +167,8 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
||||
setTeamMembers(prev => ({
|
||||
...prev,
|
||||
data: (prev.data || []).map(member =>
|
||||
member.id === memberId
|
||||
? { ...member, selected: checked }
|
||||
: member
|
||||
)
|
||||
member.id === memberId ? { ...member, selected: checked } : member
|
||||
),
|
||||
}));
|
||||
|
||||
const body = {
|
||||
@@ -178,12 +182,9 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
||||
|
||||
// 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) => {
|
||||
socket?.once(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), (data: any) => {
|
||||
dispatch(updateEnhancedKanbanTaskAssignees(data));
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Remove from pending changes after a short delay (optimistic)
|
||||
setTimeout(() => {
|
||||
@@ -198,7 +199,8 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
||||
const checkMemberSelected = (memberId: string) => {
|
||||
if (!memberId) return false;
|
||||
// Use optimistic assignees if available, otherwise fall back to task assignees
|
||||
const assignees = optimisticAssignees.length > 0
|
||||
const assignees =
|
||||
optimisticAssignees.length > 0
|
||||
? optimisticAssignees
|
||||
: task?.assignees?.map(assignee => assignee.team_member_id) || [];
|
||||
return assignees.includes(memberId);
|
||||
@@ -217,7 +219,8 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
||||
className={`
|
||||
w-5 h-5 rounded-full border border-dashed flex items-center justify-center
|
||||
transition-colors duration-200
|
||||
${isOpen
|
||||
${
|
||||
isOpen
|
||||
? isDarkMode
|
||||
? 'border-blue-500 bg-blue-900/20 text-blue-400'
|
||||
: 'border-blue-500 bg-blue-50 text-blue-600'
|
||||
@@ -230,16 +233,14 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
||||
<PlusOutlined className="text-xs" />
|
||||
</button>
|
||||
|
||||
{isOpen && createPortal(
|
||||
{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'
|
||||
}
|
||||
${isDarkMode ? 'bg-gray-800 border-gray-600' : 'bg-white border-gray-200'}
|
||||
`}
|
||||
style={{
|
||||
top: dropdownPosition.top,
|
||||
@@ -252,11 +253,12 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
placeholder="Search members..."
|
||||
className={`
|
||||
w-full px-2 py-1 text-xs rounded border
|
||||
${isDarkMode
|
||||
${
|
||||
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'
|
||||
}
|
||||
@@ -268,12 +270,13 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
||||
{/* Members List */}
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
{filteredMembers && filteredMembers.length > 0 ? (
|
||||
filteredMembers.map((member) => (
|
||||
filteredMembers.map(member => (
|
||||
<div
|
||||
key={member.id}
|
||||
className={`
|
||||
flex items-center gap-2 p-2 cursor-pointer transition-colors
|
||||
${member.pending_invitation
|
||||
${
|
||||
member.pending_invitation
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: isDarkMode
|
||||
? 'hover:bg-gray-700'
|
||||
@@ -295,18 +298,24 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
||||
<span onClick={e => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={checkMemberSelected(member.id || '')}
|
||||
onChange={(checked) => handleMemberToggle(member.id || '', checked)}
|
||||
disabled={member.pending_invitation || pendingChanges.has(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 ${
|
||||
<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 ${
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-3 h-3 border border-t-transparent rounded-full animate-spin ${
|
||||
isDarkMode ? 'border-blue-400' : 'border-blue-600'
|
||||
}`} />
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -319,10 +328,14 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`text-xs font-medium truncate ${isDarkMode ? 'text-gray-100' : 'text-gray-900'}`}>
|
||||
<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'}`}>
|
||||
<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>
|
||||
@@ -332,7 +345,9 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className={`p-4 text-center ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
<div
|
||||
className={`p-4 text-center ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}
|
||||
>
|
||||
<div className="text-xs">No members found</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -344,10 +359,7 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
||||
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'
|
||||
}
|
||||
${isDarkMode ? 'text-blue-400 hover:bg-gray-700' : 'text-blue-600 hover:bg-blue-50'}
|
||||
`}
|
||||
onClick={handleInviteProjectMemberDrawer}
|
||||
>
|
||||
|
||||
@@ -12,7 +12,9 @@ interface CustomNumberLabelProps {
|
||||
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 backgroundColor =
|
||||
color ||
|
||||
(() => {
|
||||
const firstDigit = namesString.match(/\d/)?.[0] || '0';
|
||||
return NumbersColorMap[firstDigit] || NumbersColorMap['0'];
|
||||
})();
|
||||
|
||||
@@ -228,7 +228,7 @@ const LabelsSelector: React.FC<LabelsSelectorProps> = ({ task, isDarkMode = fals
|
||||
flex items-center gap-2 px-2 py-1 cursor-pointer transition-colors
|
||||
${isDarkMode ? 'hover:bg-gray-700' : 'hover:bg-gray-50'}
|
||||
`}
|
||||
onClick={(e) => {
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
handleLabelToggle(label);
|
||||
}}
|
||||
@@ -281,7 +281,9 @@ const LabelsSelector: React.FC<LabelsSelectorProps> = ({ task, isDarkMode = fals
|
||||
|
||||
{/* 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'}`}>
|
||||
<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>
|
||||
|
||||
@@ -71,32 +71,26 @@ class ModuleErrorBoundary extends Component<Props, State> {
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div style={{
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh',
|
||||
padding: '20px'
|
||||
}}>
|
||||
padding: '20px',
|
||||
}}
|
||||
>
|
||||
<Result
|
||||
status="error"
|
||||
title="Module Loading Error"
|
||||
subTitle="There was an issue loading the application. This usually happens after updates or during logout."
|
||||
extra={[
|
||||
<Button
|
||||
type="primary"
|
||||
key="retry"
|
||||
onClick={this.handleRetry}
|
||||
loading={false}
|
||||
>
|
||||
<Button type="primary" key="retry" onClick={this.handleRetry} loading={false}>
|
||||
Retry
|
||||
</Button>,
|
||||
<Button
|
||||
key="reload"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
<Button key="reload" onClick={() => window.location.reload()}>
|
||||
Reload Page
|
||||
</Button>
|
||||
</Button>,
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -77,7 +77,9 @@ export const ProjectStep: React.FC<Props> = ({ onEnter, styles, isDarkMode = fal
|
||||
|
||||
// Refresh user session to update setup_completed status
|
||||
try {
|
||||
const authResponse = await dispatch(verifyAuthentication()).unwrap() as IAuthorizeResponse;
|
||||
const authResponse = (await dispatch(
|
||||
verifyAuthentication()
|
||||
).unwrap()) as IAuthorizeResponse;
|
||||
if (authResponse?.authenticated && authResponse?.user) {
|
||||
setSession(authResponse.user);
|
||||
dispatch(setUser(authResponse.user));
|
||||
|
||||
@@ -78,7 +78,8 @@ const CurrentPlanDetails = () => {
|
||||
return options;
|
||||
}, []);
|
||||
|
||||
const handleSubscriptionAction = useCallback(async (action: SubscriptionAction) => {
|
||||
const handleSubscriptionAction = useCallback(
|
||||
async (action: SubscriptionAction) => {
|
||||
const isResume = action === 'resume';
|
||||
const setLoadingState = isResume ? setCancellingPlan : setPausingPlan;
|
||||
const apiMethod = isResume
|
||||
@@ -101,7 +102,9 @@ const CurrentPlanDetails = () => {
|
||||
logger.error(`Error ${action}ing subscription`, error);
|
||||
setLoadingState(false);
|
||||
}
|
||||
}, [dispatch, trackMixpanelEvent]);
|
||||
},
|
||||
[dispatch, trackMixpanelEvent]
|
||||
);
|
||||
|
||||
const handleAddMoreSeats = useCallback(() => {
|
||||
setIsMoreSeatsModalVisible(true);
|
||||
@@ -157,10 +160,13 @@ const CurrentPlanDetails = () => {
|
||||
setSelectedSeatCount(getDefaultSeatCount);
|
||||
}, [getDefaultSeatCount]);
|
||||
|
||||
const checkSubscriptionStatus = useCallback((allowedStatuses: string[]) => {
|
||||
const checkSubscriptionStatus = useCallback(
|
||||
(allowedStatuses: string[]) => {
|
||||
if (!billingInfo?.status || billingInfo.is_ltd_user) return false;
|
||||
return allowedStatuses.includes(billingInfo.status);
|
||||
}, [billingInfo?.status, billingInfo?.is_ltd_user]);
|
||||
},
|
||||
[billingInfo?.status, billingInfo?.is_ltd_user]
|
||||
);
|
||||
|
||||
const shouldShowRedeemButton = useMemo(() => {
|
||||
if (billingInfo?.trial_in_progress) return true;
|
||||
@@ -261,7 +267,8 @@ const CurrentPlanDetails = () => {
|
||||
return today > trialExpireDate;
|
||||
}, [billingInfo?.trial_expire_date]);
|
||||
|
||||
const getExpirationMessage = useCallback((expireDate: string) => {
|
||||
const getExpirationMessage = useCallback(
|
||||
(expireDate: string) => {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
@@ -282,7 +289,9 @@ const CurrentPlanDetails = () => {
|
||||
} else {
|
||||
return calculateTimeGap(expireDate);
|
||||
}
|
||||
}, [t]);
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const renderTrialDetails = useCallback(() => {
|
||||
const isExpired = checkIfTrialExpired();
|
||||
@@ -309,7 +318,8 @@ const CurrentPlanDetails = () => {
|
||||
);
|
||||
}, [billingInfo?.trial_expire_date, checkIfTrialExpired, getExpirationMessage, t]);
|
||||
|
||||
const renderFreePlan = useCallback(() => (
|
||||
const renderFreePlan = useCallback(
|
||||
() => (
|
||||
<Flex vertical>
|
||||
<Typography.Text strong>{t('freePlan')}</Typography.Text>
|
||||
<Typography.Text>
|
||||
@@ -321,7 +331,9 @@ const CurrentPlanDetails = () => {
|
||||
<br />- {freePlanSettings?.free_tier_storage} MB {t('storage')}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
), [freePlanSettings, t]);
|
||||
),
|
||||
[freePlanSettings, t]
|
||||
);
|
||||
|
||||
const renderPaddleSubscriptionInfo = useCallback(() => {
|
||||
return (
|
||||
@@ -439,9 +451,7 @@ const CurrentPlanDetails = () => {
|
||||
extra={renderExtra()}
|
||||
>
|
||||
<Flex vertical>
|
||||
<div style={{ marginBottom: '14px' }}>
|
||||
{renderSubscriptionContent()}
|
||||
</div>
|
||||
<div style={{ marginBottom: '14px' }}>{renderSubscriptionContent()}</div>
|
||||
|
||||
{shouldShowRedeemButton && (
|
||||
<>
|
||||
@@ -479,9 +489,11 @@ const CurrentPlanDetails = () => {
|
||||
style={{ fontSize: '16px', margin: '0 0 16px 0', fontWeight: 500 }}
|
||||
>
|
||||
{billingInfo?.total_used === 1
|
||||
? t('purchaseSeatsTextSingle', "Add more seats to invite team members to your workspace.")
|
||||
: t('purchaseSeatsText', "To continue, you'll need to purchase additional seats.")
|
||||
}
|
||||
? t(
|
||||
'purchaseSeatsTextSingle',
|
||||
'Add more seats to invite team members to your workspace.'
|
||||
)
|
||||
: t('purchaseSeatsText', "To continue, you'll need to purchase additional seats.")}
|
||||
</Typography.Paragraph>
|
||||
|
||||
<Typography.Paragraph style={{ margin: '0 0 16px 0' }}>
|
||||
@@ -497,9 +509,11 @@ const CurrentPlanDetails = () => {
|
||||
|
||||
<Typography.Paragraph style={{ margin: '0 0 24px 0' }}>
|
||||
{billingInfo?.total_used === 1
|
||||
? t('selectSeatsTextSingle', 'Select how many additional seats you need for new team members.')
|
||||
: t('selectSeatsText', 'Please select the number of additional seats to purchase.')
|
||||
}
|
||||
? t(
|
||||
'selectSeatsTextSingle',
|
||||
'Select how many additional seats you need for new team members.'
|
||||
)
|
||||
: t('selectSeatsText', 'Please select the number of additional seats to purchase.')}
|
||||
</Typography.Paragraph>
|
||||
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
@@ -511,7 +525,6 @@ const CurrentPlanDetails = () => {
|
||||
options={seatCountOptions}
|
||||
style={{ width: '300px' }}
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
<Flex justify="end">
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
import { Button, Card, Col, Form, Input, notification, Row, Tag, Typography } from '@/shared/antd-imports';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Form,
|
||||
Input,
|
||||
notification,
|
||||
Row,
|
||||
Tag,
|
||||
Typography,
|
||||
} from '@/shared/antd-imports';
|
||||
import React, { useState } from 'react';
|
||||
import './upgrade-plans-lkr.css';
|
||||
import { CheckCircleFilled } from '@/shared/antd-imports';
|
||||
|
||||
@@ -516,7 +516,9 @@ const UpgradePlans = () => {
|
||||
>
|
||||
{billingInfo?.status === SUBSCRIPTION_STATUS.ACTIVE
|
||||
? t('changeToPlan', 'Change to {{plan}}', { plan: t('annualPlan', 'Annual Plan') })
|
||||
: t('continueWith', 'Continue with {{plan}}', { plan: t('annualPlan', 'Annual Plan') })}
|
||||
: t('continueWith', 'Continue with {{plan}}', {
|
||||
plan: t('annualPlan', 'Annual Plan'),
|
||||
})}
|
||||
</Button>
|
||||
)}
|
||||
{selectedPlan === paddlePlans.MONTHLY && (
|
||||
@@ -529,7 +531,9 @@ const UpgradePlans = () => {
|
||||
>
|
||||
{billingInfo?.status === SUBSCRIPTION_STATUS.ACTIVE
|
||||
? t('changeToPlan', 'Change to {{plan}}', { plan: t('monthlyPlan', 'Monthly Plan') })
|
||||
: t('continueWith', 'Continue with {{plan}}', { plan: t('monthlyPlan', 'Monthly Plan') })}
|
||||
: t('continueWith', 'Continue with {{plan}}', {
|
||||
plan: t('monthlyPlan', 'Monthly Plan'),
|
||||
})}
|
||||
</Button>
|
||||
)}
|
||||
</Row>
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.holiday-calendar.dark .ant-picker-calendar-date {
|
||||
@@ -62,6 +63,12 @@
|
||||
white-space: nowrap;
|
||||
border: none;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.holiday-cell .ant-tag:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.holiday-calendar .ant-picker-calendar-date-today {
|
||||
@@ -211,7 +218,10 @@
|
||||
/* Card styles */
|
||||
.holiday-calendar .ant-card {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.03), 0 1px 6px -1px rgba(0, 0, 0, 0.02), 0 2px 4px 0 rgba(0, 0, 0, 0.02);
|
||||
box-shadow:
|
||||
0 1px 2px 0 rgba(0, 0, 0, 0.03),
|
||||
0 1px 6px -1px rgba(0, 0, 0, 0.02),
|
||||
0 2px 4px 0 rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.holiday-calendar.dark .ant-card {
|
||||
|
||||
@@ -1,16 +1,34 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Calendar, Card, Typography, Button, Modal, Form, Input, Select, DatePicker, Switch, Space, Tag, Popconfirm, message } from 'antd';
|
||||
import { PlusOutlined, DeleteOutlined, EditOutlined, GlobalOutlined } from '@ant-design/icons';
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import {
|
||||
Calendar,
|
||||
Card,
|
||||
Typography,
|
||||
Button,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
DatePicker,
|
||||
Switch,
|
||||
Space,
|
||||
Tag,
|
||||
Popconfirm,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { PlusOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import { holidayApiService } from '@/api/holiday/holiday.api.service';
|
||||
import {
|
||||
IHolidayType,
|
||||
IOrganizationHoliday,
|
||||
IAvailableCountry,
|
||||
ICreateHolidayRequest,
|
||||
IUpdateHolidayRequest,
|
||||
IHolidayCalendarEvent,
|
||||
} from '@/types/holiday/holiday.types';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { RootState } from '@/app/store';
|
||||
import { fetchHolidays } from '@/features/admin-center/admin-center.slice';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import './holiday-calendar.css';
|
||||
|
||||
@@ -24,17 +42,17 @@ interface HolidayCalendarProps {
|
||||
|
||||
const HolidayCalendar: React.FC<HolidayCalendarProps> = ({ themeMode }) => {
|
||||
const { t } = useTranslation('admin-center/overview');
|
||||
const dispatch = useAppDispatch();
|
||||
const { holidays, loadingHolidays, holidaySettings } = useAppSelector(
|
||||
(state: RootState) => state.adminCenterReducer
|
||||
);
|
||||
const [form] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
|
||||
const [holidayTypes, setHolidayTypes] = useState<IHolidayType[]>([]);
|
||||
const [organizationHolidays, setOrganizationHolidays] = useState<IOrganizationHoliday[]>([]);
|
||||
const [availableCountries, setAvailableCountries] = useState<IAvailableCountry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [editModalVisible, setEditModalVisible] = useState(false);
|
||||
const [importModalVisible, setImportModalVisible] = useState(false);
|
||||
const [selectedHoliday, setSelectedHoliday] = useState<IOrganizationHoliday | null>(null);
|
||||
const [selectedHoliday, setSelectedHoliday] = useState<IHolidayCalendarEvent | null>(null);
|
||||
const [currentDate, setCurrentDate] = useState<Dayjs>(dayjs());
|
||||
|
||||
const fetchHolidayTypes = async () => {
|
||||
@@ -48,37 +66,28 @@ const HolidayCalendar: React.FC<HolidayCalendarProps> = ({ themeMode }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchOrganizationHolidays = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await holidayApiService.getOrganizationHolidays(currentDate.year());
|
||||
if (res.done) {
|
||||
setOrganizationHolidays(res.body);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching organization holidays', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const fetchHolidaysForDateRange = () => {
|
||||
const startOfYear = currentDate.startOf('year');
|
||||
const endOfYear = currentDate.endOf('year');
|
||||
|
||||
const fetchAvailableCountries = async () => {
|
||||
try {
|
||||
const res = await holidayApiService.getAvailableCountries();
|
||||
if (res.done) {
|
||||
setAvailableCountries(res.body);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching available countries', error);
|
||||
}
|
||||
dispatch(
|
||||
fetchHolidays({
|
||||
from_date: startOfYear.format('YYYY-MM-DD'),
|
||||
to_date: endOfYear.format('YYYY-MM-DD'),
|
||||
include_custom: true,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchHolidayTypes();
|
||||
fetchOrganizationHolidays();
|
||||
fetchAvailableCountries();
|
||||
fetchHolidaysForDateRange();
|
||||
}, [currentDate.year()]);
|
||||
|
||||
const customHolidays = useMemo(() => {
|
||||
return holidays.filter(holiday => holiday.source === 'custom');
|
||||
}, [holidays]);
|
||||
|
||||
const handleCreateHoliday = async (values: any) => {
|
||||
try {
|
||||
const holidayData: ICreateHolidayRequest = {
|
||||
@@ -94,7 +103,7 @@ const HolidayCalendar: React.FC<HolidayCalendarProps> = ({ themeMode }) => {
|
||||
message.success(t('holidayCreated'));
|
||||
setModalVisible(false);
|
||||
form.resetFields();
|
||||
fetchOrganizationHolidays();
|
||||
fetchHolidaysForDateRange();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error creating holiday', error);
|
||||
@@ -115,13 +124,16 @@ const HolidayCalendar: React.FC<HolidayCalendarProps> = ({ themeMode }) => {
|
||||
is_recurring: values.is_recurring,
|
||||
};
|
||||
|
||||
const res = await holidayApiService.updateOrganizationHoliday(selectedHoliday.id, holidayData);
|
||||
const res = await holidayApiService.updateOrganizationHoliday(
|
||||
selectedHoliday.id,
|
||||
holidayData
|
||||
);
|
||||
if (res.done) {
|
||||
message.success(t('holidayUpdated'));
|
||||
setEditModalVisible(false);
|
||||
editForm.resetFields();
|
||||
setSelectedHoliday(null);
|
||||
fetchOrganizationHolidays();
|
||||
fetchHolidaysForDateRange();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error updating holiday', error);
|
||||
@@ -134,7 +146,7 @@ const HolidayCalendar: React.FC<HolidayCalendarProps> = ({ themeMode }) => {
|
||||
const res = await holidayApiService.deleteOrganizationHoliday(holidayId);
|
||||
if (res.done) {
|
||||
message.success(t('holidayDeleted'));
|
||||
fetchOrganizationHolidays();
|
||||
fetchHolidaysForDateRange();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error deleting holiday', error);
|
||||
@@ -142,53 +154,47 @@ const HolidayCalendar: React.FC<HolidayCalendarProps> = ({ themeMode }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportCountryHolidays = async (values: any) => {
|
||||
try {
|
||||
const res = await holidayApiService.importCountryHolidays({
|
||||
country_code: values.country_code,
|
||||
year: values.year || currentDate.year(),
|
||||
});
|
||||
if (res.done) {
|
||||
message.success(t('holidaysImported', { count: res.body.imported_count }));
|
||||
setImportModalVisible(false);
|
||||
fetchOrganizationHolidays();
|
||||
const handleEditHoliday = (holiday: IHolidayCalendarEvent) => {
|
||||
// Only allow editing custom holidays
|
||||
if (holiday.source !== 'custom' || !holiday.is_editable) {
|
||||
message.warning(t('cannotEditOfficialHoliday') || 'Cannot edit official holidays');
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error importing country holidays', error);
|
||||
message.error(t('errorImportingHolidays'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditHoliday = (holiday: IOrganizationHoliday) => {
|
||||
setSelectedHoliday(holiday);
|
||||
editForm.setFieldsValue({
|
||||
name: holiday.name,
|
||||
description: holiday.description,
|
||||
date: dayjs(holiday.date),
|
||||
holiday_type_id: holiday.holiday_type_id,
|
||||
holiday_type_id: holiday.holiday_type_name, // This might need adjustment based on backend
|
||||
is_recurring: holiday.is_recurring,
|
||||
});
|
||||
setEditModalVisible(true);
|
||||
};
|
||||
|
||||
const getHolidayDateCellRender = (date: Dayjs) => {
|
||||
const holiday = organizationHolidays.find(h => dayjs(h.date).isSame(date, 'day'));
|
||||
const dateHolidays = holidays.filter(h => dayjs(h.date).isSame(date, 'day'));
|
||||
|
||||
if (holiday) {
|
||||
const holidayType = holidayTypes.find(ht => ht.id === holiday.holiday_type_id);
|
||||
if (dateHolidays.length > 0) {
|
||||
return (
|
||||
<div className="holiday-cell">
|
||||
{dateHolidays.map((holiday, index) => (
|
||||
<Tag
|
||||
color={holidayType?.color_code || '#f37070'}
|
||||
key={`${holiday.id}-${index}`}
|
||||
color={holiday.color_code || (holiday.source === 'official' ? '#1890ff' : '#f37070')}
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
padding: '1px 4px',
|
||||
margin: 0,
|
||||
borderRadius: '2px'
|
||||
margin: '1px 0',
|
||||
borderRadius: '2px',
|
||||
display: 'block',
|
||||
opacity: holiday.source === 'official' ? 0.8 : 1,
|
||||
}}
|
||||
title={`${holiday.name}${holiday.source === 'official' ? ' (Official)' : ' (Custom)'}`}
|
||||
>
|
||||
{holiday.name}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -199,36 +205,61 @@ const HolidayCalendar: React.FC<HolidayCalendarProps> = ({ themeMode }) => {
|
||||
setCurrentDate(value);
|
||||
};
|
||||
|
||||
const onDateSelect = (date: Dayjs) => {
|
||||
// Check if there's already a custom holiday on this date
|
||||
const existingCustomHoliday = holidays.find(
|
||||
h => dayjs(h.date).isSame(date, 'day') && h.source === 'custom' && h.is_editable
|
||||
);
|
||||
|
||||
if (existingCustomHoliday) {
|
||||
// If custom holiday exists, open edit modal
|
||||
handleEditHoliday(existingCustomHoliday);
|
||||
} else {
|
||||
// If no custom holiday, open create modal with pre-filled date
|
||||
form.setFieldValue('date', date);
|
||||
setModalVisible(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
{t('holidayCalendar')}
|
||||
</Title>
|
||||
<Space>
|
||||
<Button
|
||||
icon={<GlobalOutlined />}
|
||||
onClick={() => setImportModalVisible(true)}
|
||||
size="small"
|
||||
>
|
||||
{t('importCountryHolidays')}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setModalVisible(true)}
|
||||
size="small"
|
||||
>
|
||||
{t('addHoliday')}
|
||||
{t('addCustomHoliday') || 'Add Custom Holiday'}
|
||||
</Button>
|
||||
{holidaySettings?.country_code && (
|
||||
<Typography.Text type="secondary" style={{ fontSize: '12px' }}>
|
||||
{t('officialHolidaysFrom') || 'Official holidays from'}:{' '}
|
||||
{holidaySettings.country_code}
|
||||
{holidaySettings.state_code && ` (${holidaySettings.state_code})`}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Calendar
|
||||
value={currentDate}
|
||||
onPanelChange={onPanelChange}
|
||||
onSelect={onDateSelect}
|
||||
dateCellRender={getHolidayDateCellRender}
|
||||
className={`holiday-calendar ${themeMode}`}
|
||||
loading={loadingHolidays}
|
||||
/>
|
||||
|
||||
{/* Create Holiday Modal */}
|
||||
@@ -278,7 +309,7 @@ const HolidayCalendar: React.FC<HolidayCalendarProps> = ({ themeMode }) => {
|
||||
height: 12,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: type.color_code,
|
||||
marginRight: 8
|
||||
marginRight: 8,
|
||||
}}
|
||||
/>
|
||||
{type.name}
|
||||
@@ -297,10 +328,12 @@ const HolidayCalendar: React.FC<HolidayCalendarProps> = ({ themeMode }) => {
|
||||
<Button type="primary" htmlType="submit">
|
||||
{t('save')}
|
||||
</Button>
|
||||
<Button onClick={() => {
|
||||
<Button
|
||||
onClick={() => {
|
||||
setModalVisible(false);
|
||||
form.resetFields();
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
</Space>
|
||||
@@ -356,7 +389,7 @@ const HolidayCalendar: React.FC<HolidayCalendarProps> = ({ themeMode }) => {
|
||||
height: 12,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: type.color_code,
|
||||
marginRight: 8
|
||||
marginRight: 8,
|
||||
}}
|
||||
/>
|
||||
{type.name}
|
||||
@@ -375,51 +408,13 @@ const HolidayCalendar: React.FC<HolidayCalendarProps> = ({ themeMode }) => {
|
||||
<Button type="primary" htmlType="submit">
|
||||
{t('update')}
|
||||
</Button>
|
||||
<Button onClick={() => {
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditModalVisible(false);
|
||||
editForm.resetFields();
|
||||
setSelectedHoliday(null);
|
||||
}}>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* Import Country Holidays Modal */}
|
||||
<Modal
|
||||
title={t('importCountryHolidays')}
|
||||
open={importModalVisible}
|
||||
onCancel={() => setImportModalVisible(false)}
|
||||
footer={null}
|
||||
destroyOnClose
|
||||
}}
|
||||
>
|
||||
<Form layout="vertical" onFinish={handleImportCountryHolidays}>
|
||||
<Form.Item
|
||||
name="country_code"
|
||||
label={t('country')}
|
||||
rules={[{ required: true, message: t('countryRequired') }]}
|
||||
>
|
||||
<Select placeholder={t('selectCountry')}>
|
||||
{availableCountries.map(country => (
|
||||
<Option key={country.code} value={country.code}>
|
||||
{country.name}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="year" label={t('year')}>
|
||||
<DatePicker picker="year" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">
|
||||
{t('import')}
|
||||
</Button>
|
||||
<Button onClick={() => setImportModalVisible(false)}>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
@@ -15,7 +15,7 @@ interface OrganizationCalculationMethodProps {
|
||||
|
||||
const OrganizationCalculationMethod: React.FC<OrganizationCalculationMethodProps> = ({
|
||||
organization,
|
||||
refetch
|
||||
refetch,
|
||||
}) => {
|
||||
const { t } = useTranslation('admin-center/overview');
|
||||
const [updating, setUpdating] = useState(false);
|
||||
|
||||
@@ -6,7 +6,16 @@ import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { IOrganizationTeam } from '@/types/admin-center/admin-center.types';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { SettingOutlined, DeleteOutlined } from '@/shared/antd-imports';
|
||||
import { Badge, Button, Card, Popconfirm, Table, TableProps, Tooltip, Typography } from '@/shared/antd-imports';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Popconfirm,
|
||||
Table,
|
||||
TableProps,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@/shared/antd-imports';
|
||||
import { TFunction } from 'i18next';
|
||||
import { useState } from 'react';
|
||||
import { useMediaQuery } from 'react-responsive';
|
||||
|
||||
@@ -34,7 +34,8 @@ const renderAvatar = (member: InlineMember, index: number, allowClickThrough: bo
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const Avatars: React.FC<AvatarsProps> = React.memo(({ members, maxCount, allowClickThrough = false }) => {
|
||||
const Avatars: React.FC<AvatarsProps> = React.memo(
|
||||
({ members, maxCount, allowClickThrough = false }) => {
|
||||
const visibleMembers = maxCount ? members.slice(0, maxCount) : members;
|
||||
return (
|
||||
<div onClick={allowClickThrough ? undefined : (e: React.MouseEvent) => e.stopPropagation()}>
|
||||
@@ -43,7 +44,8 @@ const Avatars: React.FC<AvatarsProps> = React.memo(({ members, maxCount, allowCl
|
||||
</Avatar.Group>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
Avatars.displayName = 'Avatars';
|
||||
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Avatar, Col, DatePicker, Divider, Flex, Row, Tooltip, Typography } from '@/shared/antd-imports';
|
||||
import {
|
||||
Avatar,
|
||||
Col,
|
||||
DatePicker,
|
||||
Divider,
|
||||
Flex,
|
||||
Row,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@/shared/antd-imports';
|
||||
import StatusDropdown from '../../taskListCommon/statusDropdown/StatusDropdown';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -25,15 +25,17 @@ const LazyGanttChart = lazy(() =>
|
||||
|
||||
// Chart loading fallback
|
||||
const ChartLoadingFallback = () => (
|
||||
<div style={{
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '300px',
|
||||
background: '#fafafa',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #f0f0f0'
|
||||
}}>
|
||||
border: '1px solid #f0f0f0',
|
||||
}}
|
||||
>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
import { AutoComplete, Button, Drawer, Flex, Form, message, Select, Spin, Typography } from '@/shared/antd-imports';
|
||||
import {
|
||||
AutoComplete,
|
||||
Button,
|
||||
Drawer,
|
||||
Flex,
|
||||
Form,
|
||||
message,
|
||||
Select,
|
||||
Spin,
|
||||
Typography,
|
||||
} from '@/shared/antd-imports';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import {
|
||||
|
||||
100
worklenz-frontend/src/components/debug/HolidayDebugInfo.tsx
Normal file
100
worklenz-frontend/src/components/debug/HolidayDebugInfo.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import React from 'react';
|
||||
import { Card, Typography, Tag, Space } from 'antd';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { RootState } from '@/app/store';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface HolidayDebugInfoProps {
|
||||
show?: boolean;
|
||||
}
|
||||
|
||||
const HolidayDebugInfo: React.FC<HolidayDebugInfoProps> = ({ show = false }) => {
|
||||
const { holidays, loadingHolidays, holidaysDateRange, holidaySettings } = useAppSelector(
|
||||
(state: RootState) => state.adminCenterReducer
|
||||
);
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
title="Holiday Debug Info"
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
fontSize: '12px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
border: '1px dashed #ccc',
|
||||
}}
|
||||
>
|
||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Text strong>Holiday Settings:</Text>
|
||||
<div style={{ marginLeft: 8 }}>
|
||||
<Text>Country: {holidaySettings?.country_code || 'Not set'}</Text>
|
||||
<br />
|
||||
<Text>State: {holidaySettings?.state_code || 'Not set'}</Text>
|
||||
<br />
|
||||
<Text>Auto Sync: {holidaySettings?.auto_sync_holidays ? 'Yes' : 'No'}</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text strong>Current Date Range:</Text>
|
||||
{holidaysDateRange ? (
|
||||
<div style={{ marginLeft: 8 }}>
|
||||
<Text>From: {holidaysDateRange.from}</Text>
|
||||
<br />
|
||||
<Text>To: {holidaysDateRange.to}</Text>
|
||||
</div>
|
||||
) : (
|
||||
<Text> Not loaded</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text strong>Holidays Loaded:</Text>
|
||||
<Space wrap style={{ marginLeft: 8 }}>
|
||||
{loadingHolidays ? (
|
||||
<Tag color="blue">Loading...</Tag>
|
||||
) : holidays.length > 0 ? (
|
||||
<>
|
||||
<Tag color="green">Total: {holidays.length}</Tag>
|
||||
<Tag color="orange">
|
||||
Official: {holidays.filter(h => h.source === 'official').length}
|
||||
</Tag>
|
||||
<Tag color="purple">
|
||||
Custom: {holidays.filter(h => h.source === 'custom').length}
|
||||
</Tag>
|
||||
</>
|
||||
) : (
|
||||
<Tag color="red">No holidays loaded</Tag>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{holidays.length > 0 && (
|
||||
<div>
|
||||
<Text strong>Recent Holidays:</Text>
|
||||
<div style={{ marginLeft: 8, maxHeight: 100, overflow: 'auto' }}>
|
||||
{holidays.slice(0, 5).map((holiday, index) => (
|
||||
<div key={`${holiday.id}-${index}`} style={{ fontSize: '11px' }}>
|
||||
<Tag size="small" color={holiday.source === 'official' ? 'blue' : 'orange'}>
|
||||
{holiday.source}
|
||||
</Tag>
|
||||
{dayjs(holiday.date).format('MMM DD')}: {holiday.name}
|
||||
</div>
|
||||
))}
|
||||
{holidays.length > 5 && (
|
||||
<Text type="secondary">... and {holidays.length - 5} more</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default HolidayDebugInfo;
|
||||
@@ -8,7 +8,15 @@ 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 {
|
||||
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';
|
||||
@@ -29,18 +37,18 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project
|
||||
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 { 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);
|
||||
const { statusCategories, status: existingStatuses } = useAppSelector(
|
||||
state => state.taskStatusReducer
|
||||
);
|
||||
|
||||
// Set up socket event handlers for real-time updates
|
||||
useTaskSocketHandlers();
|
||||
@@ -89,7 +97,9 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project
|
||||
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);
|
||||
dispatch(
|
||||
reorderEnhancedKanbanGroups({ fromIndex: fromIdx, toIndex: toIdx, reorderedGroups }) as any
|
||||
);
|
||||
|
||||
// API call for group order
|
||||
try {
|
||||
@@ -101,7 +111,9 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project
|
||||
const revertedGroups = [...reorderedGroups];
|
||||
const [movedBackGroup] = revertedGroups.splice(toIdx, 1);
|
||||
revertedGroups.splice(fromIdx, 0, movedBackGroup);
|
||||
dispatch(reorderGroups({ fromIndex: toIdx, toIndex: fromIdx, reorderedGroups: revertedGroups }));
|
||||
dispatch(
|
||||
reorderGroups({ fromIndex: toIdx, toIndex: fromIdx, reorderedGroups: revertedGroups })
|
||||
);
|
||||
alertService.error('Failed to update column order', 'Please try again');
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -109,7 +121,9 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project
|
||||
const revertedGroups = [...reorderedGroups];
|
||||
const [movedBackGroup] = revertedGroups.splice(toIdx, 1);
|
||||
revertedGroups.splice(fromIdx, 0, movedBackGroup);
|
||||
dispatch(reorderGroups({ fromIndex: toIdx, toIndex: fromIdx, reorderedGroups: revertedGroups }));
|
||||
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);
|
||||
}
|
||||
@@ -135,12 +149,17 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project
|
||||
setHoveredTaskIdx(0);
|
||||
} else {
|
||||
setHoveredTaskIdx(taskIdx);
|
||||
}
|
||||
};
|
||||
};
|
||||
const handleTaskDrop = async (e: React.DragEvent, targetGroupId: string, targetTaskIdx: number | null) => {
|
||||
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;
|
||||
if (!draggedTaskId || !draggedTaskGroupId || hoveredGroupId === null || hoveredTaskIdx === null)
|
||||
return;
|
||||
|
||||
// Calculate new order and dispatch
|
||||
const sourceGroup = taskGroups.find(g => g.id === draggedTaskGroupId);
|
||||
@@ -183,7 +202,8 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project
|
||||
|
||||
updatedTasks.splice(insertIdx, 0, movedTask); // Insert at new position
|
||||
|
||||
dispatch(reorderTasks({
|
||||
dispatch(
|
||||
reorderTasks({
|
||||
activeGroupId: sourceGroup.id,
|
||||
overGroupId: targetGroup.id,
|
||||
fromIndex: taskIdx,
|
||||
@@ -191,8 +211,10 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project
|
||||
task: movedTask,
|
||||
updatedSourceTasks: updatedTasks,
|
||||
updatedTargetTasks: updatedTasks,
|
||||
}));
|
||||
dispatch(reorderEnhancedKanbanTasks({
|
||||
})
|
||||
);
|
||||
dispatch(
|
||||
reorderEnhancedKanbanTasks({
|
||||
activeGroupId: sourceGroup.id,
|
||||
overGroupId: targetGroup.id,
|
||||
fromIndex: taskIdx,
|
||||
@@ -200,7 +222,8 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project
|
||||
task: movedTask,
|
||||
updatedSourceTasks: updatedTasks,
|
||||
updatedTargetTasks: updatedTasks,
|
||||
}) as any);
|
||||
}) as any
|
||||
);
|
||||
} else {
|
||||
// Handle cross-group reordering
|
||||
const updatedSourceTasks = [...sourceGroup.tasks];
|
||||
@@ -211,7 +234,8 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project
|
||||
if (insertIdx > updatedTargetTasks.length) insertIdx = updatedTargetTasks.length;
|
||||
updatedTargetTasks.splice(insertIdx, 0, movedTask);
|
||||
|
||||
dispatch(reorderTasks({
|
||||
dispatch(
|
||||
reorderTasks({
|
||||
activeGroupId: sourceGroup.id,
|
||||
overGroupId: targetGroup.id,
|
||||
fromIndex: taskIdx,
|
||||
@@ -219,8 +243,10 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project
|
||||
task: movedTask,
|
||||
updatedSourceTasks,
|
||||
updatedTargetTasks,
|
||||
}));
|
||||
dispatch(reorderEnhancedKanbanTasks({
|
||||
})
|
||||
);
|
||||
dispatch(
|
||||
reorderEnhancedKanbanTasks({
|
||||
activeGroupId: sourceGroup.id,
|
||||
overGroupId: targetGroup.id,
|
||||
fromIndex: taskIdx,
|
||||
@@ -228,7 +254,8 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project
|
||||
task: movedTask,
|
||||
updatedSourceTasks,
|
||||
updatedTargetTasks,
|
||||
}) as any);
|
||||
}) as any
|
||||
);
|
||||
}
|
||||
|
||||
// Socket emit for task order
|
||||
@@ -306,10 +333,22 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project
|
||||
<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
|
||||
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>
|
||||
|
||||
@@ -45,7 +45,8 @@ interface KanbanGroupProps {
|
||||
hoveredGroupId: string | null;
|
||||
}
|
||||
|
||||
const KanbanGroup: React.FC<KanbanGroupProps> = memo(({
|
||||
const KanbanGroup: React.FC<KanbanGroupProps> = memo(
|
||||
({
|
||||
group,
|
||||
onGroupDragStart,
|
||||
onGroupDragOver,
|
||||
@@ -55,7 +56,7 @@ const KanbanGroup: React.FC<KanbanGroupProps> = memo(({
|
||||
onTaskDrop,
|
||||
onDragEnd,
|
||||
hoveredTaskIdx,
|
||||
hoveredGroupId
|
||||
hoveredGroupId,
|
||||
}) => {
|
||||
const [isHover, setIsHover] = useState<boolean>(false);
|
||||
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
|
||||
@@ -240,8 +241,7 @@ const KanbanGroup: React.FC<KanbanGroupProps> = memo(({
|
||||
}, [showDropdown]);
|
||||
|
||||
return (
|
||||
<div className="enhanced-kanban-group" style={{ position: 'relative' }}
|
||||
>
|
||||
<div className="enhanced-kanban-group" style={{ position: 'relative' }}>
|
||||
{/* Background layer - z-index 0 */}
|
||||
<div
|
||||
className="enhanced-kanban-group-background"
|
||||
@@ -253,10 +253,16 @@ const KanbanGroup: React.FC<KanbanGroupProps> = memo(({
|
||||
height: '100%',
|
||||
border: `0.1px solid ${themeMode === 'dark' ? '#404040' : '#e0e0e0'}`,
|
||||
borderRadius: '8px',
|
||||
zIndex: 0
|
||||
zIndex: 0,
|
||||
}}
|
||||
onDragOver={e => {
|
||||
e.preventDefault();
|
||||
onTaskDragOver(e, group.id, null);
|
||||
}}
|
||||
onDrop={e => {
|
||||
e.preventDefault();
|
||||
onTaskDrop(e, group.id, null);
|
||||
}}
|
||||
onDragOver={e => { e.preventDefault(); onTaskDragOver(e, group.id, null); }}
|
||||
onDrop={e => { e.preventDefault(); onTaskDrop(e, group.id, null); }}
|
||||
/>
|
||||
|
||||
{/* Content layer - z-index 1 */}
|
||||
@@ -295,7 +301,8 @@ const KanbanGroup: React.FC<KanbanGroupProps> = memo(({
|
||||
<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'
|
||||
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}
|
||||
@@ -309,7 +316,8 @@ const KanbanGroup: React.FC<KanbanGroupProps> = memo(({
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={`min-w-[185px] text-sm font-semibold capitalize truncate ${themeMode === 'dark' ? 'text-gray-800' : 'text-gray-900'
|
||||
className={`min-w-[185px] text-sm font-semibold capitalize truncate ${
|
||||
themeMode === 'dark' ? 'text-gray-800' : 'text-gray-900'
|
||||
}`}
|
||||
title={isEllipsisActive ? name : undefined}
|
||||
onMouseDown={e => {
|
||||
@@ -337,8 +345,18 @@ const KanbanGroup: React.FC<KanbanGroupProps> = memo(({
|
||||
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
|
||||
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>
|
||||
|
||||
@@ -349,8 +367,18 @@ const KanbanGroup: React.FC<KanbanGroupProps> = memo(({
|
||||
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
|
||||
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>
|
||||
|
||||
@@ -362,8 +390,18 @@ const KanbanGroup: React.FC<KanbanGroupProps> = memo(({
|
||||
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
|
||||
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>
|
||||
@@ -384,7 +422,9 @@ const KanbanGroup: React.FC<KanbanGroupProps> = memo(({
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: status.color_code }}
|
||||
></div>
|
||||
<span className={group.category_id === status.id ? 'font-bold' : ''}>
|
||||
<span
|
||||
className={group.category_id === status.id ? 'font-bold' : ''}
|
||||
>
|
||||
{status.name}
|
||||
</span>
|
||||
</button>
|
||||
@@ -402,8 +442,18 @@ const KanbanGroup: React.FC<KanbanGroupProps> = memo(({
|
||||
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
|
||||
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>
|
||||
@@ -432,12 +482,24 @@ const KanbanGroup: React.FC<KanbanGroupProps> = memo(({
|
||||
<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
|
||||
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'}`}>
|
||||
<h3
|
||||
className={`text-base font-medium ${themeMode === 'dark' ? 'text-white' : 'text-gray-900'}`}
|
||||
>
|
||||
{t('deleteConfirmationTitle')}
|
||||
</h3>
|
||||
</div>
|
||||
@@ -445,7 +507,8 @@ const KanbanGroup: React.FC<KanbanGroupProps> = memo(({
|
||||
<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'
|
||||
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'
|
||||
}`}
|
||||
@@ -480,7 +543,10 @@ const KanbanGroup: React.FC<KanbanGroupProps> = memo(({
|
||||
)}
|
||||
|
||||
{/* If group is empty, render a drop zone */}
|
||||
{group.tasks.length === 0 && !showNewCardTop && !showNewCardBottom && hoveredGroupId !== group.id && (
|
||||
{group.tasks.length === 0 &&
|
||||
!showNewCardTop &&
|
||||
!showNewCardBottom &&
|
||||
hoveredGroupId !== group.id && (
|
||||
<div
|
||||
className="empty-drop-zone"
|
||||
style={{
|
||||
@@ -500,10 +566,18 @@ const KanbanGroup: React.FC<KanbanGroupProps> = memo(({
|
||||
color: '#888',
|
||||
fontStyle: 'italic',
|
||||
}}
|
||||
onDragOver={e => { e.preventDefault(); onTaskDragOver(e, group.id, 0); }}
|
||||
onDrop={e => { e.preventDefault(); onTaskDrop(e, group.id, 0); }}
|
||||
onDragOver={e => {
|
||||
e.preventDefault();
|
||||
onTaskDragOver(e, group.id, 0);
|
||||
}}
|
||||
onDrop={e => {
|
||||
e.preventDefault();
|
||||
onTaskDrop(e, group.id, 0);
|
||||
}}
|
||||
>
|
||||
{(isOwnerOrAdmin || isProjectManager) && !showNewCardTop && !showNewCardBottom && (
|
||||
{(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"
|
||||
@@ -512,16 +586,24 @@ const KanbanGroup: React.FC<KanbanGroupProps> = memo(({
|
||||
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
|
||||
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 && (
|
||||
@@ -538,12 +620,15 @@ const KanbanGroup: React.FC<KanbanGroupProps> = memo(({
|
||||
onDragOver={e => onTaskDragOver(e, group.id, idx)}
|
||||
onDrop={e => onTaskDrop(e, group.id, idx)}
|
||||
>
|
||||
<div className="w-full h-full bg-red-500" style={{
|
||||
<div
|
||||
className="w-full h-full bg-red-500"
|
||||
style={{
|
||||
height: 80,
|
||||
background: themeMode === 'dark' ? '#2a2a2a' : '#E2EAF4',
|
||||
borderRadius: 6,
|
||||
border: `5px`
|
||||
}}></div>
|
||||
border: `5px`,
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
)}
|
||||
<TaskCard
|
||||
@@ -563,12 +648,15 @@ const KanbanGroup: React.FC<KanbanGroupProps> = memo(({
|
||||
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={{
|
||||
<div
|
||||
className="w-full h-full bg-red-500"
|
||||
style={{
|
||||
height: 80,
|
||||
background: themeMode === 'dark' ? '#2a2a2a' : '#E2EAF4',
|
||||
borderRadius: 6,
|
||||
border: `5px`
|
||||
}}></div>
|
||||
border: `5px`,
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -592,7 +680,12 @@ const KanbanGroup: React.FC<KanbanGroupProps> = memo(({
|
||||
}}
|
||||
>
|
||||
<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" />
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
{t('addTask')}
|
||||
</button>
|
||||
@@ -601,7 +694,8 @@ const KanbanGroup: React.FC<KanbanGroupProps> = memo(({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
KanbanGroup.displayName = 'KanbanGroup';
|
||||
|
||||
|
||||
@@ -14,7 +14,10 @@ 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';
|
||||
import {
|
||||
toggleTaskExpansion,
|
||||
fetchBoardSubTasks,
|
||||
} from '@/features/enhanced-kanban/enhanced-kanban.slice';
|
||||
import TaskProgressCircle from './TaskProgressCircle';
|
||||
|
||||
// Simple Portal component
|
||||
@@ -41,14 +44,15 @@ function getFirstDayOfWeek(year: number, month: number) {
|
||||
return new Date(year, month, 1).getDay();
|
||||
}
|
||||
|
||||
const TaskCard: React.FC<TaskCardProps> = memo(({
|
||||
const TaskCard: React.FC<TaskCardProps> = memo(
|
||||
({
|
||||
task,
|
||||
onTaskDragStart,
|
||||
onTaskDragOver,
|
||||
onTaskDrop,
|
||||
groupId,
|
||||
idx,
|
||||
onDragEnd // <-- add this
|
||||
onDragEnd, // <-- add this
|
||||
}) => {
|
||||
const { socket } = useSocket();
|
||||
const themeMode = useSelector((state: RootState) => state.themeReducer.mode);
|
||||
@@ -65,7 +69,9 @@ const TaskCard: React.FC<TaskCardProps> = memo(({
|
||||
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 [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);
|
||||
@@ -102,11 +108,14 @@ const TaskCard: React.FC<TaskCardProps> = memo(({
|
||||
}
|
||||
}, [showDatePicker]);
|
||||
|
||||
const handleCardClick = useCallback((e: React.MouseEvent, id: string) => {
|
||||
const handleCardClick = useCallback(
|
||||
(e: React.MouseEvent, id: string) => {
|
||||
e.stopPropagation();
|
||||
dispatch(setSelectedTaskId(id));
|
||||
dispatch(setShowTaskDrawer(true));
|
||||
}, [dispatch]);
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handleDateClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
@@ -162,7 +171,12 @@ const TaskCard: React.FC<TaskCardProps> = memo(({
|
||||
|
||||
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) {
|
||||
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));
|
||||
@@ -173,10 +187,13 @@ const TaskCard: React.FC<TaskCardProps> = memo(({
|
||||
}
|
||||
}, [task, projectId, dispatch]);
|
||||
|
||||
const handleSubtaskButtonClick = useCallback((e: React.MouseEvent) => {
|
||||
const handleSubtaskButtonClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
handleSubTaskExpand();
|
||||
}, [handleSubTaskExpand]);
|
||||
},
|
||||
[handleSubTaskExpand]
|
||||
);
|
||||
|
||||
// Calendar rendering helpers
|
||||
const year = calendarMonth.getFullYear();
|
||||
@@ -202,7 +219,10 @@ const TaskCard: React.FC<TaskCardProps> = memo(({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="enhanced-kanban-task-card" style={{ background, color, display: 'block', position: 'relative' }} >
|
||||
<div
|
||||
className="enhanced-kanban-task-card"
|
||||
style={{ background, color, display: 'block', position: 'relative' }}
|
||||
>
|
||||
{/* Progress circle at top right */}
|
||||
<div style={{ position: 'absolute', top: 6, right: 6, zIndex: 2 }}>
|
||||
<TaskProgressCircle task={task} size={20} />
|
||||
@@ -237,7 +257,7 @@ const TaskCard: React.FC<TaskCardProps> = memo(({
|
||||
fontSize: 10,
|
||||
marginRight: 4,
|
||||
whiteSpace: 'nowrap',
|
||||
minWidth: 0
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{label.name}
|
||||
@@ -247,12 +267,27 @@ const TaskCard: React.FC<TaskCardProps> = memo(({
|
||||
<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') }}
|
||||
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 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="task-assignees-row"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={dateButtonRef}
|
||||
@@ -269,8 +304,10 @@ const TaskCard: React.FC<TaskCardProps> = memo(({
|
||||
>
|
||||
{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')
|
||||
) : (
|
||||
selectedDate ? format(selectedDate, 'MMM d, yyyy') : t('noDueDate')
|
||||
t('noDueDate')
|
||||
)}
|
||||
</div>
|
||||
{/* Custom Calendar Popup */}
|
||||
@@ -307,13 +344,19 @@ const TaskCard: React.FC<TaskCardProps> = memo(({
|
||||
</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>
|
||||
<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();
|
||||
const isSelected =
|
||||
date &&
|
||||
selectedDate &&
|
||||
date.toDateString() === selectedDate.toDateString();
|
||||
const isToday =
|
||||
date && date.toDateString() === today.toDateString();
|
||||
return (
|
||||
<button
|
||||
key={j}
|
||||
@@ -380,45 +423,77 @@ const TaskCard: React.FC<TaskCardProps> = memo(({
|
||||
isDarkMode={themeMode === 'dark'}
|
||||
size={24}
|
||||
/>
|
||||
<LazyAssigneeSelectorWrapper task={task} groupId={groupId} isDarkMode={themeMode === 'dark'} kanbanMode={true} />
|
||||
<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 " +
|
||||
'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")
|
||||
? '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",
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
}}
|
||||
onClick={handleSubtaskButtonClick}
|
||||
title={task.show_sub_tasks ? t('hideSubtasks') || 'Hide Subtasks' : t('showSubtasks') || 'Show Subtasks'}
|
||||
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">
|
||||
<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={{
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: '#888',
|
||||
whiteSpace: 'nowrap',
|
||||
display: 'inline-block',
|
||||
}}>{task.sub_tasks_count ?? 0}</span>
|
||||
}}
|
||||
>
|
||||
{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">
|
||||
<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">
|
||||
<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>
|
||||
)}
|
||||
@@ -444,20 +519,34 @@ const TaskCard: React.FC<TaskCardProps> = memo(({
|
||||
<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 && (
|
||||
{!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">
|
||||
<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') }}
|
||||
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" title={sub.name}>{sub.name}</span>
|
||||
<span
|
||||
className="task-due-date ml-2 text-[10px] text-gray-500 dark:text-gray-400"
|
||||
className="flex-1 truncate text-xs text-gray-800 dark:text-gray-100"
|
||||
title={sub.name}
|
||||
>
|
||||
{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">
|
||||
@@ -469,22 +558,31 @@ const TaskCard: React.FC<TaskCardProps> = memo(({
|
||||
size={18}
|
||||
/>
|
||||
)}
|
||||
<LazyAssigneeSelectorWrapper task={sub} groupId={groupId} isDarkMode={themeMode === 'dark'} kanbanMode={true} />
|
||||
<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>
|
||||
{!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';
|
||||
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
import { IProjectTask } from "@/types/project/projectTasksViewModel.types";
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
|
||||
// Add a simple circular progress component
|
||||
const TaskProgressCircle: React.FC<{ task: IProjectTask; size?: number }> = ({ task, size = 28 }) => {
|
||||
const progress = typeof task.complete_ratio === 'number'
|
||||
const TaskProgressCircle: React.FC<{ task: IProjectTask; size?: number }> = ({
|
||||
task,
|
||||
size = 28,
|
||||
}) => {
|
||||
const progress =
|
||||
typeof task.complete_ratio === 'number'
|
||||
? task.complete_ratio
|
||||
: (typeof task.progress === 'number' ? task.progress : 0);
|
||||
: typeof task.progress === 'number'
|
||||
? task.progress
|
||||
: 0;
|
||||
const strokeWidth = 1.5;
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const offset = circumference - (progress / 100) * circumference;
|
||||
return (
|
||||
<svg width={size} height={size} style={{ display: 'block' }}>
|
||||
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
stroke={progress === 100 ? "#22c55e" : "#3b82f6"}
|
||||
stroke={progress === 100 ? '#22c55e' : '#3b82f6'}
|
||||
strokeWidth={strokeWidth}
|
||||
fill="none"
|
||||
strokeDasharray={circumference}
|
||||
@@ -28,11 +33,25 @@ const TaskProgressCircle: React.FC<{ task: IProjectTask; size?: number }> = ({ t
|
||||
// Green checkmark icon
|
||||
<g>
|
||||
<circle cx={size / 2} cy={size / 2} r={radius} fill="#22c55e" opacity="0.15" />
|
||||
<svg x={(size/2)-(size*0.22)} y={(size/2)-(size*0.22)} width={size*0.44} height={size*0.44} viewBox="0 0 24 24">
|
||||
<path d="M5 13l4 4L19 7" stroke="#22c55e" strokeWidth="2.2" fill="none" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<svg
|
||||
x={size / 2 - size * 0.22}
|
||||
y={size / 2 - size * 0.22}
|
||||
width={size * 0.44}
|
||||
height={size * 0.44}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M5 13l4 4L19 7"
|
||||
stroke="#22c55e"
|
||||
strokeWidth="2.2"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</g>
|
||||
) : progress > 0 && (
|
||||
) : (
|
||||
progress > 0 && (
|
||||
<text
|
||||
x="50%"
|
||||
y="50%"
|
||||
@@ -44,6 +63,7 @@ const TaskProgressCircle: React.FC<{ task: IProjectTask; size?: number }> = ({ t
|
||||
>
|
||||
{Math.round(progress)}
|
||||
</text>
|
||||
)
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -68,7 +68,8 @@ const EnhancedKanbanCreateSection: React.FC = () => {
|
||||
!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))
|
||||
(!categoryDropdownRef.current ||
|
||||
!categoryDropdownRef.current.contains(event.target as Node))
|
||||
) {
|
||||
setIsAdding(false);
|
||||
setSectionName('');
|
||||
@@ -109,7 +110,11 @@ const EnhancedKanbanCreateSection: React.FC = () => {
|
||||
setIsAdding(true);
|
||||
setSectionName('');
|
||||
// Default to first category if available
|
||||
if (statusCategories && statusCategories.length > 0 && typeof statusCategories[0].id === 'string') {
|
||||
if (
|
||||
statusCategories &&
|
||||
statusCategories.length > 0 &&
|
||||
typeof statusCategories[0].id === 'string'
|
||||
) {
|
||||
setSelectedCategoryId(statusCategories[0].id);
|
||||
} else {
|
||||
setSelectedCategoryId('');
|
||||
@@ -217,10 +222,15 @@ const EnhancedKanbanCreateSection: React.FC = () => {
|
||||
style={{ minWidth: 80 }}
|
||||
onClick={() => setShowCategoryDropdown(v => !v)}
|
||||
>
|
||||
<span className={themeMode === 'dark' ? 'text-gray-800' : 'text-gray-900'} style={{ fontSize: 13 }}>
|
||||
<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' }} />
|
||||
<DownOutlined
|
||||
style={{ fontSize: 12, color: themeMode === 'dark' ? '#555' : '#555' }}
|
||||
/>
|
||||
</button>
|
||||
{showCategoryDropdown && (
|
||||
<div
|
||||
@@ -228,7 +238,9 @@ const EnhancedKanbanCreateSection: React.FC = () => {
|
||||
style={{ zIndex: 1000 }}
|
||||
>
|
||||
<div className="py-1">
|
||||
{statusCategories.filter(cat => typeof cat.id === 'string').map(cat => (
|
||||
{statusCategories
|
||||
.filter(cat => typeof cat.id === 'string')
|
||||
.map(cat => (
|
||||
<button
|
||||
key={cat.id}
|
||||
type="button"
|
||||
@@ -242,7 +254,9 @@ const EnhancedKanbanCreateSection: React.FC = () => {
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: cat.color_code }}
|
||||
></div>
|
||||
<span className={selectedCategoryId === cat.id ? 'font-bold' : ''}>{cat.name}</span>
|
||||
<span className={selectedCategoryId === cat.id ? 'font-bold' : ''}>
|
||||
{cat.name}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -263,7 +277,12 @@ const EnhancedKanbanCreateSection: React.FC = () => {
|
||||
<Button
|
||||
type="default"
|
||||
size="small"
|
||||
onClick={() => { setIsAdding(false); setSectionName(''); setSelectedCategoryId(''); setShowCategoryDropdown(false); }}
|
||||
onClick={() => {
|
||||
setIsAdding(false);
|
||||
setSectionName('');
|
||||
setSelectedCategoryId('');
|
||||
setShowCategoryDropdown(false);
|
||||
}}
|
||||
>
|
||||
{t('deleteConfirmationCancel')}
|
||||
</Button>
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
|
||||
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);
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -182,14 +182,24 @@ const EnhancedKanbanTaskCard: React.FC<EnhancedKanbanTaskCardProps> = React.memo
|
||||
isDarkMode={themeMode === 'dark'}
|
||||
size={24}
|
||||
/>
|
||||
<LazyAssigneeSelectorWrapper task={task} groupId={sectionId} isDarkMode={themeMode === 'dark'} kanbanMode={true} />
|
||||
<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) })}>
|
||||
<Tooltip
|
||||
title={t(
|
||||
`indicators.tooltips.subtasks${Number(task.sub_tasks_count) === 1 ? '' : '_plural'}`,
|
||||
{ count: Number(task.sub_tasks_count) }
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
onClick={handleSubtaskButtonClick}
|
||||
size="small"
|
||||
|
||||
@@ -198,14 +198,24 @@ const KanbanTaskCard: React.FC<TaskRowProps> = ({
|
||||
</span>
|
||||
)}
|
||||
{task.comments_count && task.comments_count > 1 && (
|
||||
<Tooltip title={t(`indicators.tooltips.comments${task.comments_count === 1 ? '' : '_plural'}`, { count: task.comments_count })}>
|
||||
<Tooltip
|
||||
title={t(
|
||||
`indicators.tooltips.comments${task.comments_count === 1 ? '' : '_plural'}`,
|
||||
{ count: task.comments_count }
|
||||
)}
|
||||
>
|
||||
<span className="kanban-task-indicator">
|
||||
<MessageOutlined /> {task.comments_count}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{task.attachments_count && task.attachments_count > 1 && (
|
||||
<Tooltip title={t(`indicators.tooltips.attachments${task.attachments_count === 1 ? '' : '_plural'}`, { count: task.attachments_count })}>
|
||||
<Tooltip
|
||||
title={t(
|
||||
`indicators.tooltips.attachments${task.attachments_count === 1 ? '' : '_plural'}`,
|
||||
{ count: task.attachments_count }
|
||||
)}
|
||||
>
|
||||
<span className="kanban-task-indicator">
|
||||
<PaperClipOutlined /> {task.attachments_count}
|
||||
</span>
|
||||
|
||||
@@ -131,12 +131,12 @@ const ProjectGroupList: React.FC<ProjectGroupListProps> = ({
|
||||
// Then fetch project data
|
||||
dispatch(fetchProjectData(projectId))
|
||||
.unwrap()
|
||||
.then((projectData) => {
|
||||
.then(projectData => {
|
||||
console.log('Project data fetched successfully from project group:', projectData);
|
||||
// Open drawer after data is fetched
|
||||
dispatch(toggleProjectDrawer());
|
||||
})
|
||||
.catch((error) => {
|
||||
.catch(error => {
|
||||
console.error('Failed to fetch project data from project group:', error);
|
||||
// Still open drawer even if fetch fails, so user can see error state
|
||||
dispatch(toggleProjectDrawer());
|
||||
|
||||
@@ -54,12 +54,12 @@ export const ActionButtons: React.FC<ActionButtonsProps> = ({
|
||||
// Then fetch project data
|
||||
dispatch(fetchProjectData(record.id))
|
||||
.unwrap()
|
||||
.then((projectData) => {
|
||||
.then(projectData => {
|
||||
console.log('Project data fetched successfully:', projectData);
|
||||
// Open drawer after data is fetched
|
||||
dispatch(toggleProjectDrawer());
|
||||
})
|
||||
.catch((error) => {
|
||||
.catch(error => {
|
||||
console.error('Failed to fetch project data:', error);
|
||||
// Still open drawer even if fetch fails, so user can see error state
|
||||
dispatch(toggleProjectDrawer());
|
||||
|
||||
@@ -19,11 +19,7 @@ const CreateStatusButton = () => {
|
||||
className="borderless-icon-btn"
|
||||
style={{ backgroundColor: colors.transparent, boxShadow: 'none' }}
|
||||
onClick={() => dispatch(toggleDrawer())}
|
||||
icon={
|
||||
<SettingOutlined
|
||||
style={{ color: themeMode === 'dark' ? colors.white : 'black' }}
|
||||
/>
|
||||
}
|
||||
icon={<SettingOutlined style={{ color: themeMode === 'dark' ? colors.white : 'black' }} />}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -14,7 +14,11 @@ import { toggleDrawer } from '@/features/projects/status/StatusSlice';
|
||||
|
||||
import './create-status-drawer.css';
|
||||
|
||||
import { createStatus, fetchStatusesCategories, fetchStatuses } from '@/features/taskAttributes/taskStatusSlice';
|
||||
import {
|
||||
createStatus,
|
||||
fetchStatusesCategories,
|
||||
fetchStatuses,
|
||||
} from '@/features/taskAttributes/taskStatusSlice';
|
||||
import { ITaskStatusCategory } from '@/types/status.types';
|
||||
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||
import useTabSearchParam from '@/hooks/useTabSearchParam';
|
||||
|
||||
@@ -12,10 +12,7 @@ import { deleteStatusToggleDrawer } from '@/features/projects/status/DeleteStatu
|
||||
import { Drawer, Alert, Card, Select, Button, Typography, Badge } from '@/shared/antd-imports';
|
||||
import { DownOutlined } from '@/shared/antd-imports';
|
||||
import { useSelector } from 'react-redux';
|
||||
import {
|
||||
deleteSection,
|
||||
IGroupBy,
|
||||
} from '@features/enhanced-kanban/enhanced-kanban.slice';
|
||||
import { deleteSection, IGroupBy } from '@features/enhanced-kanban/enhanced-kanban.slice';
|
||||
import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
|
||||
import { phasesApiService } from '@/api/taskAttributes/phases/phases.api.service';
|
||||
import logger from '@/utils/errorLogger';
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Checkbox, Button, Flex, Typography, Space, Divider, message } from '@/shared/antd-imports';
|
||||
import {
|
||||
Modal,
|
||||
Checkbox,
|
||||
Button,
|
||||
Flex,
|
||||
Typography,
|
||||
Space,
|
||||
Divider,
|
||||
message,
|
||||
} from '@/shared/antd-imports';
|
||||
import { SettingOutlined, UpOutlined, DownOutlined } from '@/shared/antd-imports';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
List,
|
||||
Space,
|
||||
Typography,
|
||||
InputRef
|
||||
InputRef,
|
||||
} from '@/shared/antd-imports';
|
||||
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { CaretDownFilled, SortAscendingOutlined, SortDescendingOutlined } from '@/shared/antd-imports';
|
||||
import {
|
||||
CaretDownFilled,
|
||||
SortAscendingOutlined,
|
||||
SortDescendingOutlined,
|
||||
} from '@/shared/antd-imports';
|
||||
|
||||
import Badge from 'antd/es/badge';
|
||||
import Button from 'antd/es/button';
|
||||
|
||||
@@ -25,7 +25,8 @@ const ImportRateCardsDrawer: React.FC = () => {
|
||||
const fallbackCurrency = useAppSelector(state => state.financeReducer.currency);
|
||||
const currency = (projectCurrency || fallbackCurrency || 'USD').toUpperCase();
|
||||
|
||||
const rolesRedux = useAppSelector(state => state.projectFinanceRateCardReducer.rateCardRoles) || [];
|
||||
const rolesRedux =
|
||||
useAppSelector(state => state.projectFinanceRateCardReducer.rateCardRoles) || [];
|
||||
|
||||
// Loading states
|
||||
const isRatecardsLoading = useAppSelector(state => state.financeReducer.isRatecardsLoading);
|
||||
|
||||
@@ -3,7 +3,15 @@ import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { IClientsViewModel } from '@/types/client.types';
|
||||
import { IProjectViewModel } from '@/types/project/projectViewModel.types';
|
||||
import { QuestionCircleOutlined } from '@/shared/antd-imports';
|
||||
import { AutoComplete, Flex, Form, FormInstance, Spin, Tooltip, Typography } from '@/shared/antd-imports';
|
||||
import {
|
||||
AutoComplete,
|
||||
Flex,
|
||||
Form,
|
||||
FormInstance,
|
||||
Spin,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@/shared/antd-imports';
|
||||
import { TFunction } from 'i18next';
|
||||
import { useState } from 'react';
|
||||
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { Button, ConfigProvider, Flex, PaginationProps, Table, TableColumnsType } from '@/shared/antd-imports';
|
||||
import {
|
||||
Button,
|
||||
ConfigProvider,
|
||||
Flex,
|
||||
PaginationProps,
|
||||
Table,
|
||||
TableColumnsType,
|
||||
} from '@/shared/antd-imports';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ExpandAltOutlined } from '@/shared/antd-imports';
|
||||
|
||||
@@ -65,12 +65,10 @@ const Billable: React.FC = () => {
|
||||
successColor: isDark ? '#52c41a' : '#52c41a',
|
||||
errorColor: isDark ? '#ff4d4f' : '#ff4d4f',
|
||||
buttonBorder: isDark ? '#303030' : '#d9d9d9',
|
||||
buttonText: activeFiltersCount > 0
|
||||
? (isDark ? 'white' : '#262626')
|
||||
: (isDark ? '#d9d9d9' : '#595959'),
|
||||
buttonBg: activeFiltersCount > 0
|
||||
? (isDark ? '#434343' : '#f5f5f5')
|
||||
: (isDark ? '#141414' : 'white'),
|
||||
buttonText:
|
||||
activeFiltersCount > 0 ? (isDark ? 'white' : '#262626') : isDark ? '#d9d9d9' : '#595959',
|
||||
buttonBg:
|
||||
activeFiltersCount > 0 ? (isDark ? '#434343' : '#f5f5f5') : isDark ? '#141414' : 'white',
|
||||
dropdownBg: isDark ? '#1f1f1f' : 'white',
|
||||
dropdownBorder: isDark ? '#303030' : '#d9d9d9',
|
||||
};
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
CaretDownFilled,
|
||||
FilterOutlined,
|
||||
CheckCircleFilled,
|
||||
CheckboxChangeEvent
|
||||
CheckboxChangeEvent,
|
||||
} from '@/shared/antd-imports';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -60,12 +60,10 @@ const Categories: React.FC = () => {
|
||||
successColor: isDark ? '#52c41a' : '#52c41a',
|
||||
errorColor: isDark ? '#ff4d4f' : '#ff4d4f',
|
||||
buttonBorder: isDark ? '#303030' : '#d9d9d9',
|
||||
buttonText: activeFiltersCount > 0
|
||||
? (isDark ? 'white' : '#262626')
|
||||
: (isDark ? '#d9d9d9' : '#595959'),
|
||||
buttonBg: activeFiltersCount > 0
|
||||
? (isDark ? '#434343' : '#f5f5f5')
|
||||
: (isDark ? '#141414' : 'white'),
|
||||
buttonText:
|
||||
activeFiltersCount > 0 ? (isDark ? 'white' : '#262626') : isDark ? '#d9d9d9' : '#595959',
|
||||
buttonBg:
|
||||
activeFiltersCount > 0 ? (isDark ? '#434343' : '#f5f5f5') : isDark ? '#141414' : 'white',
|
||||
dropdownBg: isDark ? '#1f1f1f' : 'white',
|
||||
dropdownBorder: isDark ? '#303030' : '#d9d9d9',
|
||||
};
|
||||
|
||||
@@ -55,12 +55,10 @@ const Members: React.FC = () => {
|
||||
successColor: isDark ? '#52c41a' : '#52c41a',
|
||||
errorColor: isDark ? '#ff4d4f' : '#ff4d4f',
|
||||
buttonBorder: isDark ? '#303030' : '#d9d9d9',
|
||||
buttonText: activeFiltersCount > 0
|
||||
? (isDark ? 'white' : '#262626')
|
||||
: (isDark ? '#d9d9d9' : '#595959'),
|
||||
buttonBg: activeFiltersCount > 0
|
||||
? (isDark ? '#434343' : '#f5f5f5')
|
||||
: (isDark ? '#141414' : 'white'),
|
||||
buttonText:
|
||||
activeFiltersCount > 0 ? (isDark ? 'white' : '#262626') : isDark ? '#d9d9d9' : '#595959',
|
||||
buttonBg:
|
||||
activeFiltersCount > 0 ? (isDark ? '#434343' : '#f5f5f5') : isDark ? '#141414' : 'white',
|
||||
dropdownBg: isDark ? '#1f1f1f' : 'white',
|
||||
dropdownBorder: isDark ? '#303030' : '#d9d9d9',
|
||||
};
|
||||
|
||||
@@ -54,12 +54,10 @@ const Projects: React.FC = () => {
|
||||
successColor: isDark ? '#52c41a' : '#52c41a',
|
||||
errorColor: isDark ? '#ff4d4f' : '#ff4d4f',
|
||||
buttonBorder: isDark ? '#303030' : '#d9d9d9',
|
||||
buttonText: activeFiltersCount > 0
|
||||
? (isDark ? 'white' : '#262626')
|
||||
: (isDark ? '#d9d9d9' : '#595959'),
|
||||
buttonBg: activeFiltersCount > 0
|
||||
? (isDark ? '#434343' : '#f5f5f5')
|
||||
: (isDark ? '#141414' : 'white'),
|
||||
buttonText:
|
||||
activeFiltersCount > 0 ? (isDark ? 'white' : '#262626') : isDark ? '#d9d9d9' : '#595959',
|
||||
buttonBg:
|
||||
activeFiltersCount > 0 ? (isDark ? '#434343' : '#f5f5f5') : isDark ? '#141414' : 'white',
|
||||
dropdownBg: isDark ? '#1f1f1f' : 'white',
|
||||
dropdownBorder: isDark ? '#303030' : '#d9d9d9',
|
||||
};
|
||||
|
||||
@@ -55,12 +55,10 @@ const Team: React.FC = () => {
|
||||
successColor: isDark ? '#52c41a' : '#52c41a',
|
||||
errorColor: isDark ? '#ff4d4f' : '#ff4d4f',
|
||||
buttonBorder: isDark ? '#303030' : '#d9d9d9',
|
||||
buttonText: activeFiltersCount > 0
|
||||
? (isDark ? 'white' : '#262626')
|
||||
: (isDark ? '#d9d9d9' : '#595959'),
|
||||
buttonBg: activeFiltersCount > 0
|
||||
? (isDark ? '#434343' : '#f5f5f5')
|
||||
: (isDark ? '#141414' : 'white'),
|
||||
buttonText:
|
||||
activeFiltersCount > 0 ? (isDark ? 'white' : '#262626') : isDark ? '#d9d9d9' : '#595959',
|
||||
buttonBg:
|
||||
activeFiltersCount > 0 ? (isDark ? '#434343' : '#f5f5f5') : isDark ? '#141414' : 'white',
|
||||
dropdownBg: isDark ? '#1f1f1f' : 'white',
|
||||
dropdownBorder: isDark ? '#303030' : '#d9d9d9',
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import Team from './Team';
|
||||
|
||||
@@ -56,12 +56,10 @@ const Utilization: React.FC = () => {
|
||||
successColor: isDark ? '#52c41a' : '#52c41a',
|
||||
errorColor: isDark ? '#ff4d4f' : '#ff4d4f',
|
||||
buttonBorder: isDark ? '#303030' : '#d9d9d9',
|
||||
buttonText: activeFiltersCount > 0
|
||||
? (isDark ? 'white' : '#262626')
|
||||
: (isDark ? '#d9d9d9' : '#595959'),
|
||||
buttonBg: activeFiltersCount > 0
|
||||
? (isDark ? '#434343' : '#f5f5f5')
|
||||
: (isDark ? '#141414' : 'white'),
|
||||
buttonText:
|
||||
activeFiltersCount > 0 ? (isDark ? 'white' : '#262626') : isDark ? '#d9d9d9' : '#595959',
|
||||
buttonBg:
|
||||
activeFiltersCount > 0 ? (isDark ? '#434343' : '#f5f5f5') : isDark ? '#141414' : 'white',
|
||||
dropdownBg: isDark ? '#1f1f1f' : 'white',
|
||||
dropdownBorder: isDark ? '#303030' : '#d9d9d9',
|
||||
};
|
||||
|
||||
@@ -9,24 +9,74 @@ import {
|
||||
ArrowDownOutlined,
|
||||
CheckCircleOutlined,
|
||||
} from '@/shared/antd-imports';
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useMemo, useEffect, useState } from 'react';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IRPTTimeTotals } from '@/types/reporting/reporting.types';
|
||||
import { useReportingUtilization } from '@/hooks/useUtilizationCalculation';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
interface TotalTimeUtilizationProps {
|
||||
totals: IRPTTimeTotals;
|
||||
dateRange?: string[];
|
||||
}
|
||||
|
||||
const TotalTimeUtilization: React.FC<TotalTimeUtilizationProps> = ({ totals }) => {
|
||||
const TotalTimeUtilization: React.FC<TotalTimeUtilizationProps> = ({ totals, dateRange }) => {
|
||||
const { t } = useTranslation('time-report');
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const isDark = themeMode === 'dark';
|
||||
const [holidayInfo, setHolidayInfo] = useState<{ count: number; adjustedHours: number } | null>(
|
||||
null
|
||||
);
|
||||
|
||||
// Get current date range or default to this month
|
||||
const currentDateRange = useMemo(() => {
|
||||
if (dateRange && dateRange.length >= 2) {
|
||||
return {
|
||||
from: dayjs(dateRange[0]).format('YYYY-MM-DD'),
|
||||
to: dayjs(dateRange[1]).format('YYYY-MM-DD'),
|
||||
};
|
||||
}
|
||||
return {
|
||||
from: dayjs().startOf('month').format('YYYY-MM-DD'),
|
||||
to: dayjs().endOf('month').format('YYYY-MM-DD'),
|
||||
};
|
||||
}, [dateRange]);
|
||||
|
||||
// Temporarily disable holiday integration to prevent API spam
|
||||
// TODO: Re-enable once backend endpoints are properly implemented
|
||||
const holidayIntegrationEnabled = false;
|
||||
|
||||
useEffect(() => {
|
||||
if (!holidayIntegrationEnabled) {
|
||||
// For now, just show a placeholder holiday count
|
||||
setHolidayInfo({
|
||||
count: 0,
|
||||
adjustedHours: parseFloat(totals.total_estimated_hours || '0'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Holiday integration code will be re-enabled once backend is ready
|
||||
// ... (previous holiday calculation code)
|
||||
}, [
|
||||
currentDateRange.from,
|
||||
currentDateRange.to,
|
||||
totals.total_estimated_hours,
|
||||
holidayIntegrationEnabled,
|
||||
]);
|
||||
|
||||
const utilizationData = useMemo(() => {
|
||||
const timeLogged = parseFloat(totals.total_time_logs || '0');
|
||||
const estimatedHours = parseFloat(totals.total_estimated_hours || '0');
|
||||
const utilizationPercent = parseFloat(totals.total_utilization || '0');
|
||||
let estimatedHours = parseFloat(totals.total_estimated_hours || '0');
|
||||
|
||||
// Use holiday-adjusted hours if available
|
||||
if (holidayInfo?.adjustedHours && holidayInfo.adjustedHours > 0) {
|
||||
estimatedHours = holidayInfo.adjustedHours;
|
||||
}
|
||||
|
||||
// Recalculate utilization with holiday adjustment
|
||||
const utilizationPercent = estimatedHours > 0 ? (timeLogged / estimatedHours) * 100 : 0;
|
||||
|
||||
// Determine utilization status and color
|
||||
let status: 'under' | 'optimal' | 'over' = 'optimal';
|
||||
@@ -49,13 +99,13 @@ const TotalTimeUtilization: React.FC<TotalTimeUtilizationProps> = ({ totals }) =
|
||||
return {
|
||||
timeLogged,
|
||||
estimatedHours,
|
||||
utilizationPercent,
|
||||
utilizationPercent: Math.round(utilizationPercent * 100) / 100,
|
||||
status,
|
||||
statusColor,
|
||||
statusIcon,
|
||||
statusText,
|
||||
};
|
||||
}, [totals, t]);
|
||||
}, [totals, t, holidayInfo]);
|
||||
|
||||
const getThemeColors = useMemo(
|
||||
() => ({
|
||||
@@ -201,7 +251,7 @@ const TotalTimeUtilization: React.FC<TotalTimeUtilizationProps> = ({ totals }) =
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{totals.total_estimated_hours}h
|
||||
{utilizationData.estimatedHours.toFixed(1)}h
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
@@ -210,7 +260,9 @@ const TotalTimeUtilization: React.FC<TotalTimeUtilizationProps> = ({ totals }) =
|
||||
marginTop: '2px',
|
||||
}}
|
||||
>
|
||||
{t('basedOnWorkingSchedule')}
|
||||
{holidayInfo?.count
|
||||
? `${t('basedOnWorkingSchedule')} (${holidayInfo.count} ${t('holidaysExcluded')})`
|
||||
: t('basedOnWorkingSchedule')}
|
||||
</div>
|
||||
</div>
|
||||
</Flex>
|
||||
@@ -281,7 +333,7 @@ const TotalTimeUtilization: React.FC<TotalTimeUtilizationProps> = ({ totals }) =
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
{totals.total_utilization}%
|
||||
{utilizationData.utilizationPercent}%
|
||||
</div>
|
||||
<Progress
|
||||
percent={Math.min(utilizationData.utilizationPercent, 150)} // Cap at 150% for display
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { DownOutlined } from '@/shared/antd-imports';
|
||||
import { Button, Card, DatePicker, Divider, Dropdown, Flex, List, Typography } from '@/shared/antd-imports';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
DatePicker,
|
||||
Divider,
|
||||
Dropdown,
|
||||
Flex,
|
||||
List,
|
||||
Typography,
|
||||
} from '@/shared/antd-imports';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Badge, Button, Space, Tooltip, message } from '@/shared/antd-imports';
|
||||
import { WifiOutlined, DisconnectOutlined, ReloadOutlined, DeleteOutlined } from '@/shared/antd-imports';
|
||||
import {
|
||||
WifiOutlined,
|
||||
DisconnectOutlined,
|
||||
ReloadOutlined,
|
||||
DeleteOutlined,
|
||||
} from '@/shared/antd-imports';
|
||||
import { useServiceWorker } from '../../utils/serviceWorkerRegistration';
|
||||
|
||||
interface ServiceWorkerStatusProps {
|
||||
@@ -13,7 +18,7 @@ interface ServiceWorkerStatusProps {
|
||||
|
||||
const ServiceWorkerStatus: React.FC<ServiceWorkerStatusProps> = ({
|
||||
minimal = false,
|
||||
showControls = false
|
||||
showControls = false,
|
||||
}) => {
|
||||
const { isOffline, swManager, clearCache, forceUpdate, getVersion } = useServiceWorker();
|
||||
const [swVersion, setSwVersion] = React.useState<string>('');
|
||||
@@ -25,9 +30,11 @@ const ServiceWorkerStatus: React.FC<ServiceWorkerStatusProps> = ({
|
||||
if (getVersion) {
|
||||
const versionPromise = getVersion();
|
||||
if (versionPromise) {
|
||||
versionPromise.then(version => {
|
||||
versionPromise
|
||||
.then(version => {
|
||||
setSwVersion(version);
|
||||
}).catch(() => {
|
||||
})
|
||||
.catch(() => {
|
||||
// Ignore errors when getting version
|
||||
});
|
||||
}
|
||||
@@ -69,10 +76,7 @@ const ServiceWorkerStatus: React.FC<ServiceWorkerStatusProps> = ({
|
||||
if (minimal) {
|
||||
return (
|
||||
<Tooltip title={isOffline ? 'You are offline' : 'You are online'}>
|
||||
<Badge
|
||||
status={isOffline ? 'error' : 'success'}
|
||||
text={isOffline ? 'Offline' : 'Online'}
|
||||
/>
|
||||
<Badge status={isOffline ? 'error' : 'success'} text={isOffline ? 'Offline' : 'Online'} />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -87,23 +91,15 @@ const ServiceWorkerStatus: React.FC<ServiceWorkerStatusProps> = ({
|
||||
) : (
|
||||
<WifiOutlined style={{ color: '#52c41a' }} />
|
||||
)}
|
||||
<span style={{ fontSize: '14px' }}>
|
||||
{isOffline ? 'Offline Mode' : 'Online'}
|
||||
</span>
|
||||
{swVersion && (
|
||||
<span style={{ fontSize: '12px', color: '#8c8c8c' }}>
|
||||
v{swVersion}
|
||||
</span>
|
||||
)}
|
||||
<span style={{ fontSize: '14px' }}>{isOffline ? 'Offline Mode' : 'Online'}</span>
|
||||
{swVersion && <span style={{ fontSize: '12px', color: '#8c8c8c' }}>v{swVersion}</span>}
|
||||
</div>
|
||||
|
||||
{/* Information */}
|
||||
<div style={{ fontSize: '12px', color: '#8c8c8c' }}>
|
||||
{isOffline ? (
|
||||
'App is running from cache. Changes will sync when online.'
|
||||
) : (
|
||||
'App is cached for offline use. Ready to work anywhere!'
|
||||
)}
|
||||
{isOffline
|
||||
? 'App is running from cache. Changes will sync when online.'
|
||||
: 'App is cached for offline use. Ready to work anywhere!'}
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { Timeline, Typography, Flex, ConfigProvider, Tag, Tooltip, Skeleton } from '@/shared/antd-imports';
|
||||
import {
|
||||
Timeline,
|
||||
Typography,
|
||||
Flex,
|
||||
ConfigProvider,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Skeleton,
|
||||
} from '@/shared/antd-imports';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowRightOutlined } from '@/shared/antd-imports';
|
||||
@@ -73,7 +81,11 @@ const TaskDrawerActivityLog = () => {
|
||||
</Tag>
|
||||
<ArrowRightOutlined />
|
||||
|
||||
<Tag color={'default'}>{activity.log_type === 'create' ? t('taskActivityLogTab.add') : t('taskActivityLogTab.remove')}</Tag>
|
||||
<Tag color={'default'}>
|
||||
{activity.log_type === 'create'
|
||||
? t('taskActivityLogTab.add')
|
||||
: t('taskActivityLogTab.remove')}
|
||||
</Tag>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
@@ -156,20 +168,28 @@ const TaskDrawerActivityLog = () => {
|
||||
case IActivityLogAttributeTypes.WEIGHT:
|
||||
return (
|
||||
<Flex gap={4} align="center">
|
||||
<Tag color="purple">{t('taskActivityLogTab.weight')}: {activity.previous || '100'}</Tag>
|
||||
<Tag color="purple">
|
||||
{t('taskActivityLogTab.weight')}: {activity.previous || '100'}
|
||||
</Tag>
|
||||
<ArrowRightOutlined />
|
||||
|
||||
<Tag color="purple">{t('taskActivityLogTab.weight')}: {activity.current || '100'}</Tag>
|
||||
<Tag color="purple">
|
||||
{t('taskActivityLogTab.weight')}: {activity.current || '100'}
|
||||
</Tag>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<Flex gap={4} align="center">
|
||||
<Tag color={'default'}>{truncateText(activity.previous) || t('taskActivityLogTab.none')}</Tag>
|
||||
<Tag color={'default'}>
|
||||
{truncateText(activity.previous) || t('taskActivityLogTab.none')}
|
||||
</Tag>
|
||||
<ArrowRightOutlined />
|
||||
|
||||
<Tag color={'default'}>{truncateText(activity.current) || t('taskActivityLogTab.none')}</Tag>
|
||||
<Tag color={'default'}>
|
||||
{truncateText(activity.current) || t('taskActivityLogTab.none')}
|
||||
</Tag>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { useState } from 'react';
|
||||
import { ITaskAttachmentViewModel } from '@/types/tasks/task-attachment-view-model';
|
||||
import { Button, Modal, Spin, Tooltip, Typography, Popconfirm, message } from '@/shared/antd-imports';
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
Spin,
|
||||
Tooltip,
|
||||
Typography,
|
||||
Popconfirm,
|
||||
message,
|
||||
} from '@/shared/antd-imports';
|
||||
import {
|
||||
EyeOutlined,
|
||||
DownloadOutlined,
|
||||
|
||||
@@ -123,12 +123,14 @@ const TaskComments = ({ taskId, t }: { taskId?: string; t: TFunction }) => {
|
||||
setComments(sortedComments);
|
||||
|
||||
// Update Redux state with the current comment count
|
||||
dispatch(updateTaskCounts({
|
||||
dispatch(
|
||||
updateTaskCounts({
|
||||
taskId,
|
||||
counts: {
|
||||
comments_count: sortedComments.length
|
||||
}
|
||||
}));
|
||||
comments_count: sortedComments.length,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
@@ -244,9 +246,11 @@ const TaskComments = ({ taskId, t }: { taskId?: string; t: TFunction }) => {
|
||||
await getComments(false);
|
||||
|
||||
// Dispatch event to notify that an attachment was deleted
|
||||
document.dispatchEvent(new CustomEvent('task-comment-update', {
|
||||
detail: { taskId }
|
||||
}));
|
||||
document.dispatchEvent(
|
||||
new CustomEvent('task-comment-update', {
|
||||
detail: { taskId },
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Error deleting attachment', e);
|
||||
|
||||
@@ -58,9 +58,11 @@ const TaskViewCommentEdit = ({ commentData, onUpdated }: TaskViewCommentEditProp
|
||||
onUpdated(commentData);
|
||||
|
||||
// Dispatch event to notify that a comment was updated
|
||||
document.dispatchEvent(new CustomEvent('task-comment-update', {
|
||||
detail: { taskId: commentData.task_id }
|
||||
}));
|
||||
document.dispatchEvent(
|
||||
new CustomEvent('task-comment-update', {
|
||||
detail: { taskId: commentData.task_id },
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Error updating comment', e);
|
||||
|
||||
@@ -65,12 +65,14 @@ const DependenciesTable = ({
|
||||
setSearchTerm('');
|
||||
|
||||
// Update Redux state with dependency status
|
||||
dispatch(updateTaskCounts({
|
||||
dispatch(
|
||||
updateTaskCounts({
|
||||
taskId: task.id,
|
||||
counts: {
|
||||
has_dependencies: true
|
||||
}
|
||||
}));
|
||||
has_dependencies: true,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error adding dependency:', error);
|
||||
@@ -104,12 +106,14 @@ const DependenciesTable = ({
|
||||
// Update Redux state with dependency status
|
||||
// Check if there are any remaining dependencies
|
||||
const remainingDependencies = taskDependencies.filter(dep => dep.id !== dependencyId);
|
||||
dispatch(updateTaskCounts({
|
||||
dispatch(
|
||||
updateTaskCounts({
|
||||
taskId: task.id,
|
||||
counts: {
|
||||
has_dependencies: remainingDependencies.length > 0
|
||||
}
|
||||
}));
|
||||
has_dependencies: remainingDependencies.length > 0,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting dependency:', error);
|
||||
|
||||
@@ -87,7 +87,9 @@ const DescriptionEditor = ({ description, taskId, parentTaskId }: DescriptionEdi
|
||||
const isClickedInsideWrapper = wrapper && wrapper.contains(target);
|
||||
const isClickedInsideEditor = document.querySelector('.tox-tinymce')?.contains(target);
|
||||
const isClickedInsideToolbarPopup = document
|
||||
.querySelector('.tox-menu, .tox-pop, .tox-collection, .tox-dialog, .tox-dialog-wrap, .tox-silver-sink')
|
||||
.querySelector(
|
||||
'.tox-menu, .tox-pop, .tox-collection, .tox-dialog, .tox-dialog-wrap, .tox-silver-sink'
|
||||
)
|
||||
?.contains(target);
|
||||
|
||||
if (
|
||||
|
||||
@@ -102,12 +102,14 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
|
||||
);
|
||||
|
||||
// Update Redux state with recurring task status
|
||||
dispatch(updateTaskCounts({
|
||||
dispatch(
|
||||
updateTaskCounts({
|
||||
taskId: task.id,
|
||||
counts: {
|
||||
schedule_id: schedule.id as string || null
|
||||
}
|
||||
}));
|
||||
schedule_id: (schedule.id as string) || null,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
setRecurring(checked);
|
||||
if (!checked) setShowConfig(false);
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import { Button, Flex, Form, Mentions, Space, Tooltip, Typography, message } from '@/shared/antd-imports';
|
||||
import {
|
||||
Button,
|
||||
Flex,
|
||||
Form,
|
||||
Mentions,
|
||||
Space,
|
||||
Tooltip,
|
||||
Typography,
|
||||
message,
|
||||
} from '@/shared/antd-imports';
|
||||
import { useCallback, useEffect, useRef, useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PaperClipOutlined, DeleteOutlined, PlusOutlined } from '@/shared/antd-imports';
|
||||
@@ -6,9 +15,7 @@ import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||
import {
|
||||
IMentionMemberSelectOption,
|
||||
} from '@/types/project/projectComments.types';
|
||||
import { IMentionMemberSelectOption } from '@/types/project/projectComments.types';
|
||||
import { ITaskCommentsCreateRequest } from '@/types/tasks/task-comments.types';
|
||||
import { ITaskAttachment } from '@/types/tasks/task-attachment-view-model';
|
||||
import logger from '@/utils/errorLogger';
|
||||
@@ -186,9 +193,11 @@ const InfoTabFooter = () => {
|
||||
|
||||
// Dispatch event to notify that a comment was created
|
||||
// This will trigger the task comments component to refresh and update Redux
|
||||
document.dispatchEvent(new CustomEvent('task-comment-create', {
|
||||
detail: { taskId: selectedTaskId }
|
||||
}));
|
||||
document.dispatchEvent(
|
||||
new CustomEvent('task-comment-create', {
|
||||
detail: { taskId: selectedTaskId },
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to create comment:', error);
|
||||
@@ -478,30 +487,18 @@ const InfoTabFooter = () => {
|
||||
)}
|
||||
|
||||
<Flex align="center" justify="space-between" style={{ width: '100%', marginTop: 8 }}>
|
||||
<Tooltip
|
||||
title={
|
||||
createdFromNow !== 'N/A'
|
||||
? `Created ${createdFromNow}`
|
||||
: 'N/A'
|
||||
}
|
||||
>
|
||||
<Tooltip title={createdFromNow !== 'N/A' ? `Created ${createdFromNow}` : 'N/A'}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{t('taskInfoTab.comments.createdBy', {
|
||||
time: createdFromNow,
|
||||
user: taskFormViewModel?.task?.reporter || ''
|
||||
user: taskFormViewModel?.task?.reporter || '',
|
||||
})}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
title={
|
||||
updatedFromNow !== 'N/A'
|
||||
? `Updated ${updatedFromNow}`
|
||||
: 'N/A'
|
||||
}
|
||||
>
|
||||
<Tooltip title={updatedFromNow !== 'N/A' ? `Updated ${updatedFromNow}` : 'N/A'}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{t('taskInfoTab.comments.updatedTime', {
|
||||
time: updatedFromNow
|
||||
time: updatedFromNow,
|
||||
})}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
|
||||
@@ -103,12 +103,14 @@ const NotifyMemberSelector = ({ task, t }: NotifyMemberSelectorProps) => {
|
||||
dispatch(setTaskSubscribers(data));
|
||||
|
||||
// Update Redux state with subscriber status
|
||||
dispatch(updateTaskCounts({
|
||||
dispatch(
|
||||
updateTaskCounts({
|
||||
taskId: task.id,
|
||||
counts: {
|
||||
has_subscribers: data && data.length > 0
|
||||
}
|
||||
}));
|
||||
has_subscribers: data && data.length > 0,
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error notifying member:', error);
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import { Button, Flex, Input, Popconfirm, Progress, Table, Tag, Tooltip } from '@/shared/antd-imports';
|
||||
import {
|
||||
Button,
|
||||
Flex,
|
||||
Input,
|
||||
Popconfirm,
|
||||
Progress,
|
||||
Table,
|
||||
Tag,
|
||||
Tooltip,
|
||||
} from '@/shared/antd-imports';
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { DeleteOutlined, EditOutlined, ExclamationCircleFilled } from '@/shared/antd-imports';
|
||||
import { nanoid } from '@reduxjs/toolkit';
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { Button, Collapse, CollapseProps, Flex, Skeleton, Tooltip, Typography } from '@/shared/antd-imports';
|
||||
import {
|
||||
Button,
|
||||
Collapse,
|
||||
CollapseProps,
|
||||
Flex,
|
||||
Skeleton,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@/shared/antd-imports';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { ReloadOutlined } from '@/shared/antd-imports';
|
||||
import DescriptionEditor from './description-editor';
|
||||
@@ -150,7 +158,7 @@ const TaskDrawerInfoTab = ({ t }: TaskDrawerInfoTabProps) => {
|
||||
label: <Typography.Text strong>{t('taskInfoTab.dependencies.title')}</Typography.Text>,
|
||||
children: (
|
||||
<DependenciesTable
|
||||
task={(taskFormViewModel?.task as ITaskViewModel) || {} as ITaskViewModel}
|
||||
task={(taskFormViewModel?.task as ITaskViewModel) || ({} as ITaskViewModel)}
|
||||
t={t}
|
||||
taskDependencies={taskDependencies}
|
||||
loadingTaskDependencies={loadingTaskDependencies}
|
||||
@@ -218,12 +226,14 @@ const TaskDrawerInfoTab = ({ t }: TaskDrawerInfoTabProps) => {
|
||||
setTaskDependencies(res.body);
|
||||
|
||||
// Update Redux state with the current dependency status
|
||||
dispatch(updateTaskCounts({
|
||||
dispatch(
|
||||
updateTaskCounts({
|
||||
taskId: selectedTaskId,
|
||||
counts: {
|
||||
has_dependencies: res.body.length > 0
|
||||
}
|
||||
}));
|
||||
has_dependencies: res.body.length > 0,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching task dependencies:', error);
|
||||
@@ -241,12 +251,14 @@ const TaskDrawerInfoTab = ({ t }: TaskDrawerInfoTabProps) => {
|
||||
setTaskAttachments(res.body);
|
||||
|
||||
// Update Redux state with the current attachment count
|
||||
dispatch(updateTaskCounts({
|
||||
dispatch(
|
||||
updateTaskCounts({
|
||||
taskId: selectedTaskId,
|
||||
counts: {
|
||||
attachments_count: res.body.length
|
||||
}
|
||||
}));
|
||||
attachments_count: res.body.length,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching task attachments:', error);
|
||||
|
||||
@@ -230,7 +230,9 @@ const TimeLogForm = ({
|
||||
<Form.Item
|
||||
name="startTime"
|
||||
label={t('taskTimeLogTab.timeLogForm.startTime')}
|
||||
rules={[{ required: true, message: t('taskTimeLogTab.timeLogForm.selectStartTimeError') }]}
|
||||
rules={[
|
||||
{ required: true, message: t('taskTimeLogTab.timeLogForm.selectStartTimeError') },
|
||||
]}
|
||||
>
|
||||
<TimePicker format="HH:mm" />
|
||||
</Form.Item>
|
||||
@@ -238,14 +240,20 @@ const TimeLogForm = ({
|
||||
<Form.Item
|
||||
name="endTime"
|
||||
label={t('taskTimeLogTab.timeLogForm.endTime')}
|
||||
rules={[{ required: true, message: t('taskTimeLogTab.timeLogForm.selectEndTimeError') }]}
|
||||
rules={[
|
||||
{ required: true, message: t('taskTimeLogTab.timeLogForm.selectEndTimeError') },
|
||||
]}
|
||||
>
|
||||
<TimePicker format="HH:mm" />
|
||||
</Form.Item>
|
||||
</Flex>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="description" label={t('taskTimeLogTab.timeLogForm.workDescription')} style={{ marginBlockEnd: 12 }}>
|
||||
<Form.Item
|
||||
name="description"
|
||||
label={t('taskTimeLogTab.timeLogForm.workDescription')}
|
||||
style={{ marginBlockEnd: 12 }}
|
||||
>
|
||||
<Input.TextArea placeholder={t('taskTimeLogTab.timeLogForm.descriptionPlaceholder')} />
|
||||
</Form.Item>
|
||||
|
||||
@@ -258,7 +266,9 @@ const TimeLogForm = ({
|
||||
disabled={!isFormValid()}
|
||||
htmlType="submit"
|
||||
>
|
||||
{mode === 'edit' ? t('taskTimeLogTab.timeLogForm.updateTime') : t('taskTimeLogTab.timeLogForm.logTime')}
|
||||
{mode === 'edit'
|
||||
? t('taskTimeLogTab.timeLogForm.updateTime')
|
||||
: t('taskTimeLogTab.timeLogForm.logTime')}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Form.Item>
|
||||
|
||||
@@ -29,6 +29,6 @@
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .task-name-display:hover {
|
||||
[data-theme="dark"] .task-name-display:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,10 @@ import { deleteTask } from '@/features/tasks/tasks.slice';
|
||||
import { deleteTask as deleteTaskFromManagement } from '@/features/task-management/task-management.slice';
|
||||
import { deselectTask } from '@/features/task-management/selection.slice';
|
||||
import { deleteBoardTask } from '@/features/board/board-slice';
|
||||
import { deleteTask as deleteKanbanTask, updateEnhancedKanbanSubtask } from '@/features/enhanced-kanban/enhanced-kanban.slice';
|
||||
import {
|
||||
deleteTask as deleteKanbanTask,
|
||||
updateEnhancedKanbanSubtask,
|
||||
} from '@/features/enhanced-kanban/enhanced-kanban.slice';
|
||||
import useTabSearchParam from '@/hooks/useTabSearchParam';
|
||||
import { ITaskViewModel } from '@/types/tasks/task.types';
|
||||
import TaskHierarchyBreadcrumb from '../task-hierarchy-breadcrumb/task-hierarchy-breadcrumb';
|
||||
@@ -46,7 +49,8 @@ const TaskDrawerHeader = ({ inputRef, t }: TaskDrawerHeaderProps) => {
|
||||
const currentSession = useAuthService().getCurrentSession();
|
||||
|
||||
// Check if current task is a sub-task
|
||||
const isSubTask = taskFormViewModel?.task?.is_sub_task || !!taskFormViewModel?.task?.parent_task_id;
|
||||
const isSubTask =
|
||||
taskFormViewModel?.task?.is_sub_task || !!taskFormViewModel?.task?.parent_task_id;
|
||||
|
||||
useEffect(() => {
|
||||
setTaskName(taskFormViewModel?.task?.name ?? '');
|
||||
@@ -75,11 +79,17 @@ const TaskDrawerHeader = ({ inputRef, t }: TaskDrawerHeaderProps) => {
|
||||
dispatch(deleteTask({ taskId: selectedTaskId }));
|
||||
dispatch(deleteBoardTask({ sectionId: '', taskId: selectedTaskId }));
|
||||
if (taskFormViewModel?.task?.is_sub_task) {
|
||||
dispatch(updateEnhancedKanbanSubtask({
|
||||
dispatch(
|
||||
updateEnhancedKanbanSubtask({
|
||||
sectionId: '',
|
||||
subtask: { id: selectedTaskId, parent_task_id: taskFormViewModel?.task?.parent_task_id || '', manual_progress: false },
|
||||
subtask: {
|
||||
id: selectedTaskId,
|
||||
parent_task_id: taskFormViewModel?.task?.parent_task_id || '',
|
||||
manual_progress: false,
|
||||
},
|
||||
mode: 'delete',
|
||||
}));
|
||||
})
|
||||
);
|
||||
} else {
|
||||
dispatch(deleteKanbanTask(selectedTaskId)); // <-- Add this line
|
||||
}
|
||||
@@ -166,10 +176,7 @@ const TaskDrawerHeader = ({ inputRef, t }: TaskDrawerHeaderProps) => {
|
||||
/>
|
||||
) : (
|
||||
<Tooltip title={shouldShowTooltip ? displayTaskName : ''} trigger="hover">
|
||||
<p
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="task-name-display"
|
||||
>
|
||||
<p onClick={() => setIsEditing(true)} className="task-name-display">
|
||||
{truncatedTaskName}
|
||||
</p>
|
||||
</Tooltip>
|
||||
@@ -182,7 +189,10 @@ const TaskDrawerHeader = ({ inputRef, t }: TaskDrawerHeaderProps) => {
|
||||
teamId={currentSession?.team_id ?? ''}
|
||||
/>
|
||||
|
||||
<Dropdown overlayClassName={'delete-task-dropdown'} menu={{ items: deletTaskDropdownItems }}>
|
||||
<Dropdown
|
||||
overlayClassName={'delete-task-dropdown'}
|
||||
menu={{ items: deletTaskDropdownItems }}
|
||||
>
|
||||
<Button type="text" icon={<EllipsisOutlined />} />
|
||||
</Dropdown>
|
||||
</Flex>
|
||||
|
||||
@@ -60,10 +60,12 @@ const TaskDrawer = () => {
|
||||
if (taskFormViewModel?.task?.parent_task_id && projectId) {
|
||||
// Navigate to parent task
|
||||
dispatch(setSelectedTaskId(taskFormViewModel.task.parent_task_id));
|
||||
dispatch(fetchTask({
|
||||
dispatch(
|
||||
fetchTask({
|
||||
taskId: taskFormViewModel.task.parent_task_id,
|
||||
projectId
|
||||
}));
|
||||
projectId,
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -217,7 +219,8 @@ const TaskDrawer = () => {
|
||||
};
|
||||
|
||||
// Check if current task is a sub-task
|
||||
const isSubTask = taskFormViewModel?.task?.is_sub_task || !!taskFormViewModel?.task?.parent_task_id;
|
||||
const isSubTask =
|
||||
taskFormViewModel?.task?.is_sub_task || !!taskFormViewModel?.task?.parent_task_id;
|
||||
|
||||
// Custom close icon based on whether it's a sub-task
|
||||
const getCloseIcon = () => {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
}
|
||||
|
||||
/* Dark mode styles */
|
||||
[data-theme='dark'] .task-hierarchy-breadcrumb .ant-breadcrumb-separator {
|
||||
[data-theme="dark"] .task-hierarchy-breadcrumb .ant-breadcrumb-separator {
|
||||
color: #595959;
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
color: #40a9ff;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .task-hierarchy-breadcrumb .ant-btn-link:hover {
|
||||
[data-theme="dark"] .task-hierarchy-breadcrumb .ant-btn-link:hover {
|
||||
color: #40a9ff;
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .task-hierarchy-breadcrumb .current-task-name {
|
||||
[data-theme="dark"] .task-hierarchy-breadcrumb .current-task-name {
|
||||
color: #ffffffd9;
|
||||
}
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ const TaskHierarchyBreadcrumb: React.FC<TaskHierarchyBreadcrumbProps> = ({ t, on
|
||||
path.unshift({
|
||||
id: taskData.id,
|
||||
name: taskData.name || '',
|
||||
parent_task_id: taskData.parent_task_id || undefined
|
||||
parent_task_id: taskData.parent_task_id || undefined,
|
||||
});
|
||||
|
||||
// Move to parent task
|
||||
|
||||
@@ -13,7 +13,7 @@ const GroupProgressBar: React.FC<GroupProgressBarProps> = ({
|
||||
todoProgress,
|
||||
doingProgress,
|
||||
doneProgress,
|
||||
groupType
|
||||
groupType,
|
||||
}) => {
|
||||
const { t } = useTranslation('task-management');
|
||||
console.log(todoProgress, doingProgress, doneProgress);
|
||||
@@ -33,9 +33,15 @@ const GroupProgressBar: React.FC<GroupProgressBarProps> = ({
|
||||
// Tooltip content with all values in rows
|
||||
const tooltipContent = (
|
||||
<div>
|
||||
<div>{t('todo')}: {todoProgress || 0}%</div>
|
||||
<div>{t('inProgress')}: {doingProgress || 0}%</div>
|
||||
<div>{t('done')}: {doneProgress || 0}%</div>
|
||||
<div>
|
||||
{t('todo')}: {todoProgress || 0}%
|
||||
</div>
|
||||
<div>
|
||||
{t('inProgress')}: {doingProgress || 0}%
|
||||
</div>
|
||||
<div>
|
||||
{t('done')}: {doneProgress || 0}%
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -83,23 +89,17 @@ const GroupProgressBar: React.FC<GroupProgressBarProps> = ({
|
||||
<div className="flex items-center gap-1">
|
||||
{todoProgress > 0 && (
|
||||
<Tooltip title={tooltipContent} placement="top">
|
||||
<div
|
||||
className="w-1.5 h-1.5 bg-green-200 dark:bg-green-800 rounded-full"
|
||||
/>
|
||||
<div className="w-1.5 h-1.5 bg-green-200 dark:bg-green-800 rounded-full" />
|
||||
</Tooltip>
|
||||
)}
|
||||
{doingProgress > 0 && (
|
||||
<Tooltip title={tooltipContent} placement="top">
|
||||
<div
|
||||
className="w-1.5 h-1.5 bg-green-400 dark:bg-green-600 rounded-full"
|
||||
/>
|
||||
<div className="w-1.5 h-1.5 bg-green-400 dark:bg-green-600 rounded-full" />
|
||||
</Tooltip>
|
||||
)}
|
||||
{doneProgress > 0 && (
|
||||
<Tooltip title={tooltipContent} placement="top">
|
||||
<div
|
||||
className="w-1.5 h-1.5 bg-green-600 dark:bg-green-400 rounded-full"
|
||||
/>
|
||||
<div className="w-1.5 h-1.5 bg-green-600 dark:bg-green-400 rounded-full" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -132,9 +132,7 @@ const SubtaskLoadingSkeleton: React.FC<SubtaskLoadingSkeletonProps> = ({ visible
|
||||
<div className="bg-gray-50 dark:bg-gray-800/50 border-l-2 border-blue-200 dark:border-blue-700">
|
||||
<div className="flex items-center min-w-max px-4 py-2 border-b border-gray-200 dark:border-gray-700">
|
||||
{visibleColumns.map((column, index) => (
|
||||
<div key={column.id}>
|
||||
{renderColumn(column.id, column.width)}
|
||||
</div>
|
||||
<div key={column.id}>{renderColumn(column.id, column.width)}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -511,7 +511,11 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({
|
||||
|
||||
{/* Progress Bar - sticky to the right edge during horizontal scroll */}
|
||||
{(currentGrouping === 'priority' || currentGrouping === 'phase') &&
|
||||
!(groupProgressValues.todoProgress === 0 && groupProgressValues.doingProgress === 0 && groupProgressValues.doneProgress === 0) && (
|
||||
!(
|
||||
groupProgressValues.todoProgress === 0 &&
|
||||
groupProgressValues.doingProgress === 0 &&
|
||||
groupProgressValues.doneProgress === 0
|
||||
) && (
|
||||
<div
|
||||
className="flex items-center bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-md shadow-sm px-3 py-1.5 ml-auto"
|
||||
style={{
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import ImprovedTaskFilters from "../task-management/improved-task-filters";
|
||||
import TaskListV2Section from "./TaskListV2Table";
|
||||
import ImprovedTaskFilters from '../task-management/improved-task-filters';
|
||||
import TaskListV2Section from './TaskListV2Table';
|
||||
|
||||
const TaskListV2: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Task Filters */}
|
||||
|
||||
@@ -89,7 +89,10 @@ const EmptyGroupDropZone: React.FC<{
|
||||
isOver && active ? 'bg-blue-50 dark:bg-blue-900/20' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center min-w-max px-1 border-t border-b border-gray-200 dark:border-gray-700" style={{ height: '40px' }}>
|
||||
<div
|
||||
className="flex items-center min-w-max px-1 border-t border-b border-gray-200 dark:border-gray-700"
|
||||
style={{ height: '40px' }}
|
||||
>
|
||||
{visibleColumns.map((column, index) => {
|
||||
const emptyColumnStyle = {
|
||||
width: column.width,
|
||||
@@ -460,7 +463,7 @@ const TaskListV2Section: React.FC = () => {
|
||||
const newRowId = `add-task-${groupId}-${currentRows.length + 1}`;
|
||||
return {
|
||||
...prev,
|
||||
[groupId]: [...currentRows, newRowId]
|
||||
[groupId]: [...currentRows, newRowId],
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
@@ -513,7 +516,7 @@ const TaskListV2Section: React.FC = () => {
|
||||
projectId: urlProjectId,
|
||||
rowId: rowId,
|
||||
autoFocus: index === groupAddRows.length - 1, // Auto-focus the latest row
|
||||
}))
|
||||
})),
|
||||
]
|
||||
: [];
|
||||
|
||||
@@ -542,7 +545,6 @@ const TaskListV2Section: React.FC = () => {
|
||||
return virtuosoGroups.flatMap(group => group.tasks);
|
||||
}, [virtuosoGroups]);
|
||||
|
||||
|
||||
// Render functions
|
||||
const renderGroup = useCallback(
|
||||
(groupIndex: number) => {
|
||||
@@ -564,11 +566,7 @@ const TaskListV2Section: React.FC = () => {
|
||||
projectId={urlProjectId || ''}
|
||||
/>
|
||||
{isGroupEmpty && !isGroupCollapsed && (
|
||||
<EmptyGroupDropZone
|
||||
groupId={group.id}
|
||||
visibleColumns={visibleColumns}
|
||||
t={t}
|
||||
/>
|
||||
<EmptyGroupDropZone groupId={group.id} visibleColumns={visibleColumns} t={t} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -703,7 +701,7 @@ const TaskListV2Section: React.FC = () => {
|
||||
color: '#fbc84c69',
|
||||
actualCount: 0,
|
||||
count: 1, // For the add task row
|
||||
startIndex: 0
|
||||
startIndex: 0,
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -841,12 +839,13 @@ const TaskListV2Section: React.FC = () => {
|
||||
{renderGroup(groupIndex)}
|
||||
|
||||
{/* Group Tasks */}
|
||||
{!collapsedGroups.has(group.id) && (
|
||||
group.tasks.length > 0 ? (
|
||||
group.tasks.map((task, taskIndex) => {
|
||||
{!collapsedGroups.has(group.id) &&
|
||||
(group.tasks.length > 0
|
||||
? group.tasks.map((task, taskIndex) => {
|
||||
const globalTaskIndex =
|
||||
virtuosoGroups.slice(0, groupIndex).reduce((sum, g) => sum + g.count, 0) +
|
||||
taskIndex;
|
||||
virtuosoGroups
|
||||
.slice(0, groupIndex)
|
||||
.reduce((sum, g) => sum + g.count, 0) + taskIndex;
|
||||
|
||||
// Check if this is the first actual task in the group (not AddTaskRow)
|
||||
const isFirstTaskInGroup = taskIndex === 0 && !('isAddTaskRow' in task);
|
||||
@@ -854,38 +853,49 @@ const TaskListV2Section: React.FC = () => {
|
||||
// Check if we should show drop indicators
|
||||
const isTaskBeingDraggedOver = overId === task.id;
|
||||
const isGroupBeingDraggedOver = overId === group.id;
|
||||
const isFirstTaskInGroupBeingDraggedOver = isFirstTaskInGroup && isTaskBeingDraggedOver;
|
||||
const isFirstTaskInGroupBeingDraggedOver =
|
||||
isFirstTaskInGroup && isTaskBeingDraggedOver;
|
||||
|
||||
return (
|
||||
<div key={task.id || `add-task-${group.id}-${taskIndex}`}>
|
||||
{/* Placeholder drop indicator before first task in group */}
|
||||
{isFirstTaskInGroupBeingDraggedOver && (
|
||||
<PlaceholderDropIndicator isVisible={true} visibleColumns={visibleColumns} />
|
||||
<PlaceholderDropIndicator
|
||||
isVisible={true}
|
||||
visibleColumns={visibleColumns}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Placeholder drop indicator between tasks */}
|
||||
{isTaskBeingDraggedOver && !isFirstTaskInGroup && (
|
||||
<PlaceholderDropIndicator isVisible={true} visibleColumns={visibleColumns} />
|
||||
<PlaceholderDropIndicator
|
||||
isVisible={true}
|
||||
visibleColumns={visibleColumns}
|
||||
/>
|
||||
)}
|
||||
|
||||
{renderTask(globalTaskIndex, isFirstTaskInGroup)}
|
||||
|
||||
{/* Placeholder drop indicator at end of group when dragging over group */}
|
||||
{isGroupBeingDraggedOver && taskIndex === group.tasks.length - 1 && (
|
||||
<PlaceholderDropIndicator isVisible={true} visibleColumns={visibleColumns} />
|
||||
{isGroupBeingDraggedOver &&
|
||||
taskIndex === group.tasks.length - 1 && (
|
||||
<PlaceholderDropIndicator
|
||||
isVisible={true}
|
||||
visibleColumns={visibleColumns}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
// Handle empty groups with placeholder drop indicator
|
||||
: // Handle empty groups with placeholder drop indicator
|
||||
overId === group.id && (
|
||||
<div style={{ minWidth: 'max-content' }}>
|
||||
<PlaceholderDropIndicator isVisible={true} visibleColumns={visibleColumns} />
|
||||
<PlaceholderDropIndicator
|
||||
isVisible={true}
|
||||
visibleColumns={visibleColumns}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -27,14 +27,15 @@ interface TaskRowProps {
|
||||
depth?: number;
|
||||
}
|
||||
|
||||
const TaskRow: React.FC<TaskRowProps> = memo(({
|
||||
const TaskRow: React.FC<TaskRowProps> = memo(
|
||||
({
|
||||
taskId,
|
||||
projectId,
|
||||
visibleColumns,
|
||||
isSubtask = false,
|
||||
isFirstInGroup = false,
|
||||
updateTaskCustomColumnValue,
|
||||
depth = 0
|
||||
depth = 0,
|
||||
}) => {
|
||||
// Get task data and selection state from Redux
|
||||
const task = useAppSelector(state => selectTaskById(state, taskId));
|
||||
@@ -62,11 +63,7 @@ const TaskRow: React.FC<TaskRowProps> = memo(({
|
||||
labelsAdapter,
|
||||
} = useTaskRowState(task);
|
||||
|
||||
const {
|
||||
handleCheckboxChange,
|
||||
handleTaskNameSave,
|
||||
handleTaskNameEdit,
|
||||
} = useTaskRowActions({
|
||||
const { handleCheckboxChange, handleTaskNameSave, handleTaskNameEdit } = useTaskRowActions({
|
||||
task,
|
||||
taskId,
|
||||
taskName,
|
||||
@@ -113,11 +110,14 @@ const TaskRow: React.FC<TaskRowProps> = memo(({
|
||||
});
|
||||
|
||||
// Memoize style object to prevent unnecessary re-renders
|
||||
const style = useMemo(() => ({
|
||||
const style = useMemo(
|
||||
() => ({
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0 : 1, // Completely hide the original task while dragging
|
||||
}), [transform, transition, isDragging]);
|
||||
}),
|
||||
[transform, transition, isDragging]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -125,9 +125,7 @@ const TaskRow: React.FC<TaskRowProps> = memo(({
|
||||
style={{ ...style, height: '40px' }}
|
||||
className={`flex items-center min-w-max px-1 border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 ${
|
||||
isFirstInGroup ? 'border-t border-gray-200 dark:border-gray-700' : ''
|
||||
} ${
|
||||
isDragging ? 'shadow-lg border border-blue-300' : ''
|
||||
}`}
|
||||
} ${isDragging ? 'shadow-lg border border-blue-300' : ''}`}
|
||||
>
|
||||
{visibleColumns.map((column, index) => (
|
||||
<React.Fragment key={column.id}>
|
||||
@@ -136,7 +134,8 @@ const TaskRow: React.FC<TaskRowProps> = memo(({
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
TaskRow.displayName = 'TaskRow';
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import React, { memo, useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { selectTaskById, createSubtask, selectSubtaskLoading } from '@/features/task-management/task-management.slice';
|
||||
import {
|
||||
selectTaskById,
|
||||
createSubtask,
|
||||
selectSubtaskLoading,
|
||||
} from '@/features/task-management/task-management.slice';
|
||||
import TaskRow from './TaskRow';
|
||||
import SubtaskLoadingSkeleton from './SubtaskLoadingSkeleton';
|
||||
import { Task } from '@/types/task-management.types';
|
||||
@@ -42,7 +46,8 @@ interface AddSubtaskRowProps {
|
||||
depth?: number; // Add depth prop for proper indentation
|
||||
}
|
||||
|
||||
const AddSubtaskRow: React.FC<AddSubtaskRowProps> = memo(({
|
||||
const AddSubtaskRow: React.FC<AddSubtaskRowProps> = memo(
|
||||
({
|
||||
parentTaskId,
|
||||
projectId,
|
||||
visibleColumns,
|
||||
@@ -51,7 +56,7 @@ const AddSubtaskRow: React.FC<AddSubtaskRowProps> = memo(({
|
||||
autoFocus = false,
|
||||
isActive = true,
|
||||
onActivate,
|
||||
depth = 0
|
||||
depth = 0,
|
||||
}) => {
|
||||
const { t } = useTranslation('task-list-table');
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
@@ -71,11 +76,13 @@ const AddSubtaskRow: React.FC<AddSubtaskRowProps> = memo(({
|
||||
if (!subtaskName.trim() || !currentSession) return;
|
||||
|
||||
// Create optimistic subtask immediately for better UX
|
||||
dispatch(createSubtask({
|
||||
dispatch(
|
||||
createSubtask({
|
||||
parentTaskId,
|
||||
name: subtaskName.trim(),
|
||||
projectId
|
||||
}));
|
||||
projectId,
|
||||
})
|
||||
);
|
||||
|
||||
// Emit socket event for server-side creation
|
||||
if (connected && socket) {
|
||||
@@ -103,7 +110,16 @@ const AddSubtaskRow: React.FC<AddSubtaskRowProps> = memo(({
|
||||
|
||||
// Notify parent that subtask was added
|
||||
onSubtaskAdded();
|
||||
}, [subtaskName, dispatch, parentTaskId, projectId, connected, socket, currentSession, onSubtaskAdded]);
|
||||
}, [
|
||||
subtaskName,
|
||||
dispatch,
|
||||
parentTaskId,
|
||||
projectId,
|
||||
connected,
|
||||
socket,
|
||||
currentSession,
|
||||
onSubtaskAdded,
|
||||
]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setSubtaskName('');
|
||||
@@ -117,13 +133,17 @@ const AddSubtaskRow: React.FC<AddSubtaskRowProps> = memo(({
|
||||
}
|
||||
}, [subtaskName, handleCancel]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
handleCancel();
|
||||
}
|
||||
}, [handleCancel]);
|
||||
},
|
||||
[handleCancel]
|
||||
);
|
||||
|
||||
const renderColumn = useCallback((columnId: string, width: string) => {
|
||||
const renderColumn = useCallback(
|
||||
(columnId: string, width: string) => {
|
||||
const baseStyle = { width };
|
||||
|
||||
switch (columnId) {
|
||||
@@ -165,7 +185,7 @@ const AddSubtaskRow: React.FC<AddSubtaskRowProps> = memo(({
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={subtaskName}
|
||||
onChange={(e) => setSubtaskName(e.target.value)}
|
||||
onChange={e => setSubtaskName(e.target.value)}
|
||||
onPressEnter={handleAddSubtask}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
@@ -175,7 +195,7 @@ const AddSubtaskRow: React.FC<AddSubtaskRowProps> = memo(({
|
||||
height: '100%',
|
||||
minHeight: '32px',
|
||||
padding: '4px 8px',
|
||||
fontSize: '14px'
|
||||
fontSize: '14px',
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
@@ -190,18 +210,30 @@ const AddSubtaskRow: React.FC<AddSubtaskRowProps> = memo(({
|
||||
default:
|
||||
return <div style={baseStyle} />;
|
||||
}
|
||||
}, [isAdding, subtaskName, handleAddSubtask, handleCancel, handleBlur, handleKeyDown, t, isActive, onActivate, depth]);
|
||||
},
|
||||
[
|
||||
isAdding,
|
||||
subtaskName,
|
||||
handleAddSubtask,
|
||||
handleCancel,
|
||||
handleBlur,
|
||||
handleKeyDown,
|
||||
t,
|
||||
isActive,
|
||||
onActivate,
|
||||
depth,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center min-w-max px-1 py-0.5 hover:bg-gray-50 dark:hover:bg-gray-800 min-h-[36px] border-b border-gray-200 dark:border-gray-700">
|
||||
{visibleColumns.map((column, index) => (
|
||||
<React.Fragment key={column.id}>
|
||||
{renderColumn(column.id, column.width)}
|
||||
</React.Fragment>
|
||||
<React.Fragment key={column.id}>{renderColumn(column.id, column.width)}</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
AddSubtaskRow.displayName = 'AddSubtaskRow';
|
||||
|
||||
@@ -233,14 +265,15 @@ const getBorderColor = (depth: number) => {
|
||||
}
|
||||
};
|
||||
|
||||
const TaskRowWithSubtasks: React.FC<TaskRowWithSubtasksProps> = memo(({
|
||||
const TaskRowWithSubtasks: React.FC<TaskRowWithSubtasksProps> = memo(
|
||||
({
|
||||
taskId,
|
||||
projectId,
|
||||
visibleColumns,
|
||||
isFirstInGroup = false,
|
||||
updateTaskCustomColumnValue,
|
||||
depth = 0,
|
||||
maxDepth = 3
|
||||
maxDepth = 3,
|
||||
}) => {
|
||||
const task = useAppSelector(state => selectTaskById(state, taskId));
|
||||
const isLoadingSubtasks = useAppSelector(state => selectSubtaskLoading(state, taskId));
|
||||
@@ -282,8 +315,12 @@ const TaskRowWithSubtasks: React.FC<TaskRowWithSubtasksProps> = memo(({
|
||||
)}
|
||||
|
||||
{/* Render existing subtasks when not loading - RECURSIVELY */}
|
||||
{!isLoadingSubtasks && task.sub_tasks?.map((subtask: Task) => (
|
||||
<div key={subtask.id} className={`${getSubtaskBackgroundColor(depth + 1)} border-l-2 ${getBorderColor(depth + 1)}`}>
|
||||
{!isLoadingSubtasks &&
|
||||
task.sub_tasks?.map((subtask: Task) => (
|
||||
<div
|
||||
key={subtask.id}
|
||||
className={`${getSubtaskBackgroundColor(depth + 1)} border-l-2 ${getBorderColor(depth + 1)}`}
|
||||
>
|
||||
<TaskRowWithSubtasks
|
||||
taskId={subtask.id}
|
||||
projectId={projectId}
|
||||
@@ -297,7 +334,9 @@ const TaskRowWithSubtasks: React.FC<TaskRowWithSubtasksProps> = memo(({
|
||||
|
||||
{/* Add subtask row - only show when not loading */}
|
||||
{!isLoadingSubtasks && (
|
||||
<div className={`${getSubtaskBackgroundColor(depth + 1)} border-l-2 ${getBorderColor(depth + 1)}`}>
|
||||
<div
|
||||
className={`${getSubtaskBackgroundColor(depth + 1)} border-l-2 ${getBorderColor(depth + 1)}`}
|
||||
>
|
||||
<AddSubtaskRow
|
||||
parentTaskId={taskId}
|
||||
projectId={projectId}
|
||||
@@ -315,7 +354,8 @@ const TaskRowWithSubtasks: React.FC<TaskRowWithSubtasksProps> = memo(({
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
TaskRowWithSubtasks.displayName = 'TaskRowWithSubtasks';
|
||||
|
||||
|
||||
@@ -21,7 +21,8 @@ interface AddTaskRowProps {
|
||||
autoFocus?: boolean; // Whether this row should auto-focus on mount
|
||||
}
|
||||
|
||||
const AddTaskRow: React.FC<AddTaskRowProps> = memo(({
|
||||
const AddTaskRow: React.FC<AddTaskRowProps> = memo(
|
||||
({
|
||||
groupId,
|
||||
groupType,
|
||||
groupValue,
|
||||
@@ -29,7 +30,7 @@ const AddTaskRow: React.FC<AddTaskRowProps> = memo(({
|
||||
visibleColumns,
|
||||
onTaskAdded,
|
||||
rowId,
|
||||
autoFocus = false
|
||||
autoFocus = false,
|
||||
}) => {
|
||||
const [isAdding, setIsAdding] = useState(autoFocus);
|
||||
const [taskName, setTaskName] = useState('');
|
||||
@@ -93,7 +94,17 @@ const AddTaskRow: React.FC<AddTaskRowProps> = memo(({
|
||||
} catch (error) {
|
||||
console.error('Error creating task:', error);
|
||||
}
|
||||
}, [taskName, projectId, groupType, groupValue, socket, connected, currentSession, onTaskAdded, rowId]);
|
||||
}, [
|
||||
taskName,
|
||||
projectId,
|
||||
groupType,
|
||||
groupValue,
|
||||
socket,
|
||||
connected,
|
||||
currentSession,
|
||||
onTaskAdded,
|
||||
rowId,
|
||||
]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
if (taskName.trim() === '') {
|
||||
@@ -102,13 +113,17 @@ const AddTaskRow: React.FC<AddTaskRowProps> = memo(({
|
||||
}
|
||||
}, [taskName]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
handleCancel();
|
||||
}
|
||||
}, [handleCancel]);
|
||||
},
|
||||
[handleCancel]
|
||||
);
|
||||
|
||||
const renderColumn = useCallback((columnId: string, width: string) => {
|
||||
const renderColumn = useCallback(
|
||||
(columnId: string, width: string) => {
|
||||
const baseStyle = { width };
|
||||
|
||||
switch (columnId) {
|
||||
@@ -116,13 +131,17 @@ const AddTaskRow: React.FC<AddTaskRowProps> = memo(({
|
||||
case 'checkbox':
|
||||
case 'taskKey':
|
||||
case 'description':
|
||||
return <div className="border-r border-gray-200 dark:border-gray-700" style={baseStyle} />;
|
||||
return (
|
||||
<div className="border-r border-gray-200 dark:border-gray-700" style={baseStyle} />
|
||||
);
|
||||
case 'labels':
|
||||
const labelsStyle = {
|
||||
...baseStyle,
|
||||
...(width === 'auto' ? { minWidth: '200px', flexGrow: 1 } : {})
|
||||
...(width === 'auto' ? { minWidth: '200px', flexGrow: 1 } : {}),
|
||||
};
|
||||
return <div className="border-r border-gray-200 dark:border-gray-700" style={labelsStyle} />;
|
||||
return (
|
||||
<div className="border-r border-gray-200 dark:border-gray-700" style={labelsStyle} />
|
||||
);
|
||||
case 'title':
|
||||
return (
|
||||
<div className="flex items-center h-full" style={baseStyle}>
|
||||
@@ -141,7 +160,7 @@ const AddTaskRow: React.FC<AddTaskRowProps> = memo(({
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={taskName}
|
||||
onChange={(e) => setTaskName(e.target.value)}
|
||||
onChange={e => setTaskName(e.target.value)}
|
||||
onPressEnter={handleAddTask}
|
||||
onBlur={handleCancel}
|
||||
onKeyDown={handleKeyDown}
|
||||
@@ -151,7 +170,7 @@ const AddTaskRow: React.FC<AddTaskRowProps> = memo(({
|
||||
height: '100%',
|
||||
minHeight: '32px',
|
||||
padding: '4px 8px',
|
||||
fontSize: '14px'
|
||||
fontSize: '14px',
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
@@ -160,20 +179,23 @@ const AddTaskRow: React.FC<AddTaskRowProps> = memo(({
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return <div className="border-r border-gray-200 dark:border-gray-700" style={baseStyle} />;
|
||||
return (
|
||||
<div className="border-r border-gray-200 dark:border-gray-700" style={baseStyle} />
|
||||
);
|
||||
}
|
||||
}, [isAdding, taskName, handleAddTask, handleCancel, handleKeyDown, t]);
|
||||
},
|
||||
[isAdding, taskName, handleAddTask, handleCancel, handleKeyDown, t]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center min-w-max px-1 py-0.5 hover:bg-gray-50 dark:hover:bg-gray-800 min-h-[36px]">
|
||||
{visibleColumns.map((column, index) => (
|
||||
<React.Fragment key={column.id}>
|
||||
{renderColumn(column.id, column.width)}
|
||||
</React.Fragment>
|
||||
<React.Fragment key={column.id}>{renderColumn(column.id, column.width)}</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
AddTaskRow.displayName = 'AddTaskRow';
|
||||
|
||||
|
||||
@@ -31,7 +31,8 @@ export const AddCustomColumnButton: React.FC = memo(() => {
|
||||
className={`
|
||||
group relative w-9 h-9 rounded-lg border-2 border-dashed transition-all duration-200
|
||||
flex items-center justify-center
|
||||
${isDarkMode
|
||||
${
|
||||
isDarkMode
|
||||
? 'border-gray-600 hover:border-blue-500 hover:bg-blue-500/10 text-gray-500 hover:text-blue-400'
|
||||
: 'border-gray-300 hover:border-blue-500 hover:bg-blue-50 text-gray-400 hover:text-blue-600'
|
||||
}
|
||||
@@ -40,13 +41,16 @@ export const AddCustomColumnButton: React.FC = memo(() => {
|
||||
<PlusOutlined className="text-sm transition-transform duration-200 group-hover:scale-110" />
|
||||
|
||||
{/* Subtle glow effect on hover */}
|
||||
<div className={`
|
||||
<div
|
||||
className={`
|
||||
absolute inset-0 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200
|
||||
${isDarkMode
|
||||
${
|
||||
isDarkMode
|
||||
? 'bg-blue-500/5 shadow-lg shadow-blue-500/20'
|
||||
: 'bg-blue-500/5 shadow-lg shadow-blue-500/10'
|
||||
}
|
||||
`} />
|
||||
`}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -62,7 +66,8 @@ export const CustomColumnHeader: React.FC<{
|
||||
const { t } = useTranslation('task-list-table');
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const displayName = column.name ||
|
||||
const displayName =
|
||||
column.name ||
|
||||
column.label ||
|
||||
column.custom_column_obj?.fieldTitle ||
|
||||
column.custom_column_obj?.field_title ||
|
||||
@@ -77,7 +82,9 @@ export const CustomColumnHeader: React.FC<{
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
onClick={() => onSettingsClick(column.key || column.id)}
|
||||
>
|
||||
<span title={displayName} className="truncate flex-1 mr-2">{displayName}</span>
|
||||
<span title={displayName} className="truncate flex-1 mr-2">
|
||||
{displayName}
|
||||
</span>
|
||||
<Tooltip title={t('customColumns.customColumnSettings')}>
|
||||
<SettingOutlined
|
||||
className={`hover:text-blue-600 dark:hover:text-blue-400 transition-all duration-200 flex-shrink-0 ${
|
||||
@@ -145,7 +152,9 @@ export const CustomColumnCell: React.FC<{
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <span className="text-sm text-gray-400 px-2">{t('customColumns.unsupportedField')}</span>;
|
||||
return (
|
||||
<span className="text-sm text-gray-400 px-2">{t('customColumns.unsupportedField')}</span>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -199,7 +208,8 @@ export const PeopleCustomColumnCell: React.FC<{
|
||||
return members.data.filter(member => displayedMemberIds.includes(member.id));
|
||||
}, [members, displayedMemberIds]);
|
||||
|
||||
const handleMemberToggle = useCallback((memberId: string, checked: boolean) => {
|
||||
const handleMemberToggle = useCallback(
|
||||
(memberId: string, checked: boolean) => {
|
||||
// Add to pending changes for visual feedback
|
||||
setPendingChanges(prev => new Set(prev).add(memberId));
|
||||
|
||||
@@ -223,7 +233,9 @@ export const PeopleCustomColumnCell: React.FC<{
|
||||
return newSet;
|
||||
});
|
||||
}, 1500); // Even longer delay to ensure socket update is fully processed
|
||||
}, [selectedMemberIds, task.id, columnKey, updateTaskCustomColumnValue]);
|
||||
},
|
||||
[selectedMemberIds, task.id, columnKey, updateTaskCustomColumnValue]
|
||||
);
|
||||
|
||||
const loadMembers = useCallback(async () => {
|
||||
if (members?.data?.length === 0) {
|
||||
@@ -291,7 +303,7 @@ export const DateCustomColumnCell: React.FC<{
|
||||
onOpenChange={setIsOpen}
|
||||
value={dateValue}
|
||||
onChange={handleDateChange}
|
||||
placeholder={dateValue ? "" : "Set date"}
|
||||
placeholder={dateValue ? '' : 'Set date'}
|
||||
format="MMM DD, YYYY"
|
||||
suffixIcon={null}
|
||||
size="small"
|
||||
@@ -302,7 +314,7 @@ export const DateCustomColumnCell: React.FC<{
|
||||
`}
|
||||
popupClassName={isDarkMode ? 'dark-date-picker' : 'light-date-picker'}
|
||||
inputReadOnly
|
||||
getPopupContainer={(trigger) => trigger.parentElement || document.body}
|
||||
getPopupContainer={trigger => trigger.parentElement || document.body}
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
@@ -392,7 +404,9 @@ export const NumberCustomColumnCell: React.FC<{
|
||||
case 'percentage':
|
||||
return `${numValue.toFixed(decimals)}%`;
|
||||
case 'withLabel':
|
||||
return labelPosition === 'left' ? `${label} ${numValue.toFixed(decimals)}` : `${numValue.toFixed(decimals)} ${label}`;
|
||||
return labelPosition === 'left'
|
||||
? `${label} ${numValue.toFixed(decimals)}`
|
||||
: `${numValue.toFixed(decimals)} ${label}`;
|
||||
default:
|
||||
return numValue.toString();
|
||||
}
|
||||
@@ -443,7 +457,9 @@ export const SelectionCustomColumnCell: React.FC<{
|
||||
const isDarkMode = useAppSelector(state => state.themeReducer.mode === 'dark');
|
||||
const selectionsList = columnObj?.selectionsList || [];
|
||||
|
||||
const selectedOption = selectionsList.find((option: any) => option.selection_name === customValue);
|
||||
const selectedOption = selectionsList.find(
|
||||
(option: any) => option.selection_name === customValue
|
||||
);
|
||||
|
||||
const handleOptionSelect = async (option: any) => {
|
||||
if (!task.id) return;
|
||||
@@ -466,21 +482,23 @@ export const SelectionCustomColumnCell: React.FC<{
|
||||
};
|
||||
|
||||
const dropdownContent = (
|
||||
<div className={`
|
||||
<div
|
||||
className={`
|
||||
rounded-lg shadow-xl border min-w-[180px] max-h-64 overflow-y-auto custom-column-dropdown
|
||||
${isDarkMode
|
||||
? 'bg-gray-800 border-gray-600'
|
||||
: 'bg-white border-gray-200'
|
||||
}
|
||||
`}>
|
||||
${isDarkMode ? 'bg-gray-800 border-gray-600' : 'bg-white border-gray-200'}
|
||||
`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className={`
|
||||
<div
|
||||
className={`
|
||||
px-3 py-2 border-b text-xs font-medium
|
||||
${isDarkMode
|
||||
${
|
||||
isDarkMode
|
||||
? 'border-gray-600 text-gray-300 bg-gray-750'
|
||||
: 'border-gray-200 text-gray-600 bg-gray-50'
|
||||
}
|
||||
`}>
|
||||
`}
|
||||
>
|
||||
Select option
|
||||
</div>
|
||||
|
||||
@@ -492,7 +510,8 @@ export const SelectionCustomColumnCell: React.FC<{
|
||||
onClick={() => handleOptionSelect(option)}
|
||||
className={`
|
||||
flex items-center gap-3 p-2 rounded-md cursor-pointer transition-all duration-200
|
||||
${selectedOption?.selection_id === option.selection_id
|
||||
${
|
||||
selectedOption?.selection_id === option.selection_id
|
||||
? isDarkMode
|
||||
? 'bg-blue-900/50 text-blue-200'
|
||||
: 'bg-blue-50 text-blue-700'
|
||||
@@ -508,12 +527,18 @@ export const SelectionCustomColumnCell: React.FC<{
|
||||
/>
|
||||
<span className="text-sm font-medium flex-1">{option.selection_name}</span>
|
||||
{selectedOption?.selection_id === option.selection_id && (
|
||||
<div className={`
|
||||
<div
|
||||
className={`
|
||||
w-4 h-4 rounded-full flex items-center justify-center
|
||||
${isDarkMode ? 'bg-blue-600' : 'bg-blue-500'}
|
||||
`}>
|
||||
`}
|
||||
>
|
||||
<svg className="w-2.5 h-2.5 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" />
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
@@ -521,10 +546,12 @@ export const SelectionCustomColumnCell: React.FC<{
|
||||
))}
|
||||
|
||||
{selectionsList.length === 0 && (
|
||||
<div className={`
|
||||
<div
|
||||
className={`
|
||||
text-center py-8 text-sm
|
||||
${isDarkMode ? 'text-gray-500' : 'text-gray-400'}
|
||||
`}>
|
||||
`}
|
||||
>
|
||||
<div className="mb-2">📋</div>
|
||||
<div>No options available</div>
|
||||
</div>
|
||||
@@ -534,7 +561,9 @@ export const SelectionCustomColumnCell: React.FC<{
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`px-2 relative custom-column-cell ${isDropdownOpen ? 'custom-column-focused' : ''}`}>
|
||||
<div
|
||||
className={`px-2 relative custom-column-cell ${isDropdownOpen ? 'custom-column-focused' : ''}`}
|
||||
>
|
||||
<Dropdown
|
||||
open={isDropdownOpen}
|
||||
onOpenChange={setIsDropdownOpen}
|
||||
@@ -542,11 +571,13 @@ export const SelectionCustomColumnCell: React.FC<{
|
||||
trigger={['click']}
|
||||
placement="bottomLeft"
|
||||
overlayClassName="custom-selection-dropdown"
|
||||
getPopupContainer={(trigger) => trigger.parentElement || document.body}
|
||||
getPopupContainer={trigger => trigger.parentElement || document.body}
|
||||
>
|
||||
<div className={`
|
||||
<div
|
||||
className={`
|
||||
flex items-center gap-2 cursor-pointer rounded-md px-2 py-1 min-h-[28px] transition-all duration-200 relative
|
||||
${isDropdownOpen
|
||||
${
|
||||
isDropdownOpen
|
||||
? isDarkMode
|
||||
? 'bg-gray-700 ring-1 ring-blue-500/50'
|
||||
: 'bg-gray-100 ring-1 ring-blue-500/50'
|
||||
@@ -554,13 +585,16 @@ export const SelectionCustomColumnCell: React.FC<{
|
||||
? 'hover:bg-gray-700/50'
|
||||
: 'hover:bg-gray-100/50'
|
||||
}
|
||||
`}>
|
||||
`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`
|
||||
<div
|
||||
className={`
|
||||
w-3 h-3 rounded-full animate-spin border-2 border-transparent
|
||||
${isDarkMode ? 'border-t-gray-400' : 'border-t-gray-600'}
|
||||
`} />
|
||||
`}
|
||||
/>
|
||||
<span className={`text-sm ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
Updating...
|
||||
</span>
|
||||
@@ -571,21 +605,45 @@ export const SelectionCustomColumnCell: React.FC<{
|
||||
className="w-3 h-3 rounded-full border border-white/20 shadow-sm"
|
||||
style={{ backgroundColor: selectedOption.selection_color || '#6b7280' }}
|
||||
/>
|
||||
<span className={`text-sm font-medium ${isDarkMode ? 'text-gray-200' : 'text-gray-900'}`}>
|
||||
<span
|
||||
className={`text-sm font-medium ${isDarkMode ? 'text-gray-200' : 'text-gray-900'}`}
|
||||
>
|
||||
{selectedOption.selection_name}
|
||||
</span>
|
||||
<svg className={`w-4 h-4 ml-auto transition-transform duration-200 ${isDropdownOpen ? 'rotate-180' : ''} ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
<svg
|
||||
className={`w-4 h-4 ml-auto transition-transform duration-200 ${isDropdownOpen ? 'rotate-180' : ''} ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className={`w-3 h-3 rounded-full border-2 border-dashed ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`} />
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full border-2 border-dashed ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`}
|
||||
/>
|
||||
<span className={`text-sm ${isDarkMode ? 'text-gray-500' : 'text-gray-400'}`}>
|
||||
Select
|
||||
</span>
|
||||
<svg className={`w-4 h-4 ml-auto transition-transform duration-200 ${isDropdownOpen ? 'rotate-180' : ''} ${isDarkMode ? 'text-gray-500' : 'text-gray-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
<svg
|
||||
className={`w-4 h-4 ml-auto transition-transform duration-200 ${isDropdownOpen ? 'rotate-180' : ''} ${isDarkMode ? 'text-gray-500' : 'text-gray-400'}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -18,7 +18,8 @@ interface DatePickerColumnProps {
|
||||
onActiveDatePickerChange: (field: string | null) => void;
|
||||
}
|
||||
|
||||
export const DatePickerColumn: React.FC<DatePickerColumnProps> = memo(({
|
||||
export const DatePickerColumn: React.FC<DatePickerColumnProps> = memo(
|
||||
({
|
||||
width,
|
||||
task,
|
||||
field,
|
||||
@@ -26,7 +27,7 @@ export const DatePickerColumn: React.FC<DatePickerColumnProps> = memo(({
|
||||
dateValue,
|
||||
isDarkMode,
|
||||
activeDatePicker,
|
||||
onActiveDatePickerChange
|
||||
onActiveDatePickerChange,
|
||||
}) => {
|
||||
const { socket, connected } = useSocket();
|
||||
const { t } = useTranslation('task-list-table');
|
||||
@@ -59,11 +60,14 @@ export const DatePickerColumn: React.FC<DatePickerColumnProps> = memo(({
|
||||
);
|
||||
|
||||
// Handle clear date
|
||||
const handleClearDate = useCallback((e: React.MouseEvent) => {
|
||||
const handleClearDate = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleDateChange(null);
|
||||
}, [handleDateChange]);
|
||||
},
|
||||
[handleDateChange]
|
||||
);
|
||||
|
||||
// Handle open date picker
|
||||
const handleOpenDatePicker = useCallback(() => {
|
||||
@@ -76,7 +80,10 @@ export const DatePickerColumn: React.FC<DatePickerColumnProps> = memo(({
|
||||
const setTitle = field === 'dueDate' ? t('setDueDate') : t('setStartDate');
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center px-2 relative group border-r border-gray-200 dark:border-gray-700" style={{ width }}>
|
||||
<div
|
||||
className="flex items-center justify-center px-2 relative group border-r border-gray-200 dark:border-gray-700"
|
||||
style={{ width }}
|
||||
>
|
||||
{isActive ? (
|
||||
<div className="w-full relative">
|
||||
<DatePicker
|
||||
@@ -88,7 +95,7 @@ export const DatePickerColumn: React.FC<DatePickerColumnProps> = memo(({
|
||||
allowClear={false}
|
||||
suffixIcon={null}
|
||||
open={true}
|
||||
onOpenChange={(open) => {
|
||||
onOpenChange={open => {
|
||||
if (!open) {
|
||||
onActiveDatePickerChange(null);
|
||||
}
|
||||
@@ -113,7 +120,7 @@ export const DatePickerColumn: React.FC<DatePickerColumnProps> = memo(({
|
||||
) : (
|
||||
<div
|
||||
className="cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 rounded px-2 py-1 transition-colors text-center"
|
||||
onClick={(e) => {
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
handleOpenDatePicker();
|
||||
}}
|
||||
@@ -131,6 +138,7 @@ export const DatePickerColumn: React.FC<DatePickerColumnProps> = memo(({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
DatePickerColumn.displayName = 'DatePickerColumn';
|
||||
@@ -136,7 +136,16 @@ const TaskContextMenu: React.FC<TaskContextMenuProps> = ({
|
||||
setUpdatingAssignToMe(false);
|
||||
onClose();
|
||||
}
|
||||
}, [projectId, task.id, task.assignees, task.assignee_names, currentSession, dispatch, onClose, trackMixpanelEvent]);
|
||||
}, [
|
||||
projectId,
|
||||
task.id,
|
||||
task.assignees,
|
||||
task.assignee_names,
|
||||
currentSession,
|
||||
dispatch,
|
||||
onClose,
|
||||
trackMixpanelEvent,
|
||||
]);
|
||||
|
||||
const handleArchive = useCallback(async () => {
|
||||
if (!projectId || !task.id) return;
|
||||
@@ -256,7 +265,9 @@ const TaskContextMenu: React.FC<TaskContextMenuProps> = ({
|
||||
let options: { key: string; label: React.ReactNode; onClick: () => void }[] = [];
|
||||
|
||||
if (currentGrouping === IGroupBy.STATUS) {
|
||||
options = statusList.filter(status => status.id).map(status => ({
|
||||
options = statusList
|
||||
.filter(status => status.id)
|
||||
.map(status => ({
|
||||
key: status.id!,
|
||||
label: (
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -270,7 +281,9 @@ const TaskContextMenu: React.FC<TaskContextMenuProps> = ({
|
||||
onClick: () => handleStatusMoveTo(status.id!),
|
||||
}));
|
||||
} else if (currentGrouping === IGroupBy.PRIORITY) {
|
||||
options = priorityList.filter(priority => priority.id).map(priority => ({
|
||||
options = priorityList
|
||||
.filter(priority => priority.id)
|
||||
.map(priority => ({
|
||||
key: priority.id!,
|
||||
label: (
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -284,7 +297,9 @@ const TaskContextMenu: React.FC<TaskContextMenuProps> = ({
|
||||
onClick: () => handlePriorityMoveTo(priority.id!),
|
||||
}));
|
||||
} else if (currentGrouping === IGroupBy.PHASE) {
|
||||
options = phaseList.filter(phase => phase.id).map(phase => ({
|
||||
options = phaseList
|
||||
.filter(phase => phase.id)
|
||||
.map(phase => ({
|
||||
key: phase.id!,
|
||||
label: (
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -430,7 +445,8 @@ const TaskContextMenu: React.FC<TaskContextMenuProps> = ({
|
||||
total_minutes: task.timeTracking?.logged || 0,
|
||||
progress: task.progress,
|
||||
sub_tasks_count: task.sub_tasks_count || 0,
|
||||
assignees: task.assignees?.map((assigneeId: string) => ({
|
||||
assignees:
|
||||
task.assignees?.map((assigneeId: string) => ({
|
||||
id: assigneeId,
|
||||
name: '',
|
||||
email: '',
|
||||
|
||||
@@ -27,7 +27,10 @@ const TaskListSkeleton: React.FC<TaskListSkeletonProps> = ({ visibleColumns }) =
|
||||
|
||||
// Generate multiple skeleton rows
|
||||
const skeletonRows = Array.from({ length: 8 }, (_, index) => (
|
||||
<div key={index} className="flex items-center min-w-max px-1 py-3 border-b border-gray-100 dark:border-gray-800">
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center min-w-max px-1 py-3 border-b border-gray-100 dark:border-gray-800"
|
||||
>
|
||||
{columns.map((column, colIndex) => {
|
||||
const columnStyle = {
|
||||
width: column.width,
|
||||
|
||||
@@ -55,11 +55,7 @@ export const TaskLabelsCell: React.FC<TaskLabelsCellProps> = memo(({ labels, isD
|
||||
color={label.color}
|
||||
/>
|
||||
) : (
|
||||
<CustomColordLabel
|
||||
key={`${label.id}-${index}`}
|
||||
label={label}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
<CustomColordLabel key={`${label.id}-${index}`} label={label} isDarkMode={isDarkMode} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -75,7 +71,8 @@ interface DragHandleColumnProps {
|
||||
listeners: any;
|
||||
}
|
||||
|
||||
export const DragHandleColumn: React.FC<DragHandleColumnProps> = memo(({ width, isSubtask, attributes, listeners }) => (
|
||||
export const DragHandleColumn: React.FC<DragHandleColumnProps> = memo(
|
||||
({ width, isSubtask, attributes, listeners }) => (
|
||||
<div
|
||||
className="flex items-center justify-center"
|
||||
style={{ width }}
|
||||
@@ -83,7 +80,8 @@ export const DragHandleColumn: React.FC<DragHandleColumnProps> = memo(({ width,
|
||||
>
|
||||
{!isSubtask && <HolderOutlined className="text-gray-400 hover:text-gray-600" />}
|
||||
</div>
|
||||
));
|
||||
)
|
||||
);
|
||||
|
||||
DragHandleColumn.displayName = 'DragHandleColumn';
|
||||
|
||||
@@ -93,15 +91,17 @@ interface CheckboxColumnProps {
|
||||
onCheckboxChange: (e: any) => void;
|
||||
}
|
||||
|
||||
export const CheckboxColumn: React.FC<CheckboxColumnProps> = memo(({ width, isSelected, onCheckboxChange }) => (
|
||||
export const CheckboxColumn: React.FC<CheckboxColumnProps> = memo(
|
||||
({ width, isSelected, onCheckboxChange }) => (
|
||||
<div className="flex items-center justify-center dark:border-gray-700" style={{ width }}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={onCheckboxChange}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
)
|
||||
);
|
||||
|
||||
CheckboxColumn.displayName = 'CheckboxColumn';
|
||||
|
||||
@@ -111,7 +111,10 @@ interface TaskKeyColumnProps {
|
||||
}
|
||||
|
||||
export const TaskKeyColumn: React.FC<TaskKeyColumnProps> = memo(({ width, taskKey }) => (
|
||||
<div className="flex items-center pl-3 border-r border-gray-200 dark:border-gray-700" style={{ width }}>
|
||||
<div
|
||||
className="flex items-center pl-3 border-r border-gray-200 dark:border-gray-700"
|
||||
style={{ width }}
|
||||
>
|
||||
<span className="text-xs font-medium px-2 py-1 rounded-md bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 whitespace-nowrap border border-gray-200 dark:border-gray-600">
|
||||
{taskKey || 'N/A'}
|
||||
</span>
|
||||
@@ -125,8 +128,12 @@ interface DescriptionColumnProps {
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const DescriptionColumn: React.FC<DescriptionColumnProps> = memo(({ width, description }) => (
|
||||
<div className="flex items-center px-2 border-r border-gray-200 dark:border-gray-700" style={{ width }}>
|
||||
export const DescriptionColumn: React.FC<DescriptionColumnProps> = memo(
|
||||
({ width, description }) => (
|
||||
<div
|
||||
className="flex items-center px-2 border-r border-gray-200 dark:border-gray-700"
|
||||
style={{ width }}
|
||||
>
|
||||
<div
|
||||
className="text-sm text-gray-600 dark:text-gray-400 truncate w-full"
|
||||
style={{
|
||||
@@ -140,7 +147,8 @@ export const DescriptionColumn: React.FC<DescriptionColumnProps> = memo(({ width
|
||||
dangerouslySetInnerHTML={{ __html: description || '' }}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
)
|
||||
);
|
||||
|
||||
DescriptionColumn.displayName = 'DescriptionColumn';
|
||||
|
||||
@@ -151,15 +159,16 @@ interface StatusColumnProps {
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
export const StatusColumn: React.FC<StatusColumnProps> = memo(({ width, task, projectId, isDarkMode }) => (
|
||||
<div className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700" style={{ width }}>
|
||||
<TaskStatusDropdown
|
||||
task={task}
|
||||
projectId={projectId}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
export const StatusColumn: React.FC<StatusColumnProps> = memo(
|
||||
({ width, task, projectId, isDarkMode }) => (
|
||||
<div
|
||||
className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700"
|
||||
style={{ width }}
|
||||
>
|
||||
<TaskStatusDropdown task={task} projectId={projectId} isDarkMode={isDarkMode} />
|
||||
</div>
|
||||
));
|
||||
)
|
||||
);
|
||||
|
||||
StatusColumn.displayName = 'StatusColumn';
|
||||
|
||||
@@ -170,21 +179,22 @@ interface AssigneesColumnProps {
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
export const AssigneesColumn: React.FC<AssigneesColumnProps> = memo(({ width, task, convertedTask, isDarkMode }) => (
|
||||
<div className="flex items-center gap-1 px-2 border-r border-gray-200 dark:border-gray-700" style={{ width }}>
|
||||
export const AssigneesColumn: React.FC<AssigneesColumnProps> = memo(
|
||||
({ width, task, convertedTask, isDarkMode }) => (
|
||||
<div
|
||||
className="flex items-center gap-1 px-2 border-r border-gray-200 dark:border-gray-700"
|
||||
style={{ width }}
|
||||
>
|
||||
<AvatarGroup
|
||||
members={task.assignee_names || []}
|
||||
maxCount={3}
|
||||
isDarkMode={isDarkMode}
|
||||
size={24}
|
||||
/>
|
||||
<AssigneeSelector
|
||||
task={convertedTask}
|
||||
groupId={null}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
<AssigneeSelector task={convertedTask} groupId={null} isDarkMode={isDarkMode} />
|
||||
</div>
|
||||
));
|
||||
)
|
||||
);
|
||||
|
||||
AssigneesColumn.displayName = 'AssigneesColumn';
|
||||
|
||||
@@ -195,15 +205,16 @@ interface PriorityColumnProps {
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
export const PriorityColumn: React.FC<PriorityColumnProps> = memo(({ width, task, projectId, isDarkMode }) => (
|
||||
<div className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700" style={{ width }}>
|
||||
<TaskPriorityDropdown
|
||||
task={task}
|
||||
projectId={projectId}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
export const PriorityColumn: React.FC<PriorityColumnProps> = memo(
|
||||
({ width, task, projectId, isDarkMode }) => (
|
||||
<div
|
||||
className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700"
|
||||
style={{ width }}
|
||||
>
|
||||
<TaskPriorityDropdown task={task} projectId={projectId} isDarkMode={isDarkMode} />
|
||||
</div>
|
||||
));
|
||||
)
|
||||
);
|
||||
|
||||
PriorityColumn.displayName = 'PriorityColumn';
|
||||
|
||||
@@ -213,7 +224,10 @@ interface ProgressColumnProps {
|
||||
}
|
||||
|
||||
export const ProgressColumn: React.FC<ProgressColumnProps> = memo(({ width, task }) => (
|
||||
<div className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700" style={{ width }}>
|
||||
<div
|
||||
className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700"
|
||||
style={{ width }}
|
||||
>
|
||||
{task.progress !== undefined &&
|
||||
task.progress >= 0 &&
|
||||
(task.progress === 100 ? (
|
||||
@@ -227,10 +241,7 @@ export const ProgressColumn: React.FC<ProgressColumnProps> = memo(({ width, task
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<TaskProgress
|
||||
progress={task.progress}
|
||||
numberOfSubTasks={task.sub_tasks?.length || 0}
|
||||
/>
|
||||
<TaskProgress progress={task.progress} numberOfSubTasks={task.sub_tasks?.length || 0} />
|
||||
))}
|
||||
</div>
|
||||
));
|
||||
@@ -245,19 +256,24 @@ interface LabelsColumnProps {
|
||||
visibleColumns: any[];
|
||||
}
|
||||
|
||||
export const LabelsColumn: React.FC<LabelsColumnProps> = memo(({ width, task, labelsAdapter, isDarkMode, visibleColumns }) => {
|
||||
export const LabelsColumn: React.FC<LabelsColumnProps> = memo(
|
||||
({ width, task, labelsAdapter, isDarkMode, visibleColumns }) => {
|
||||
const labelsStyle = {
|
||||
width,
|
||||
flexShrink: 0
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-0.5 flex-wrap min-w-0 px-2 border-r border-gray-200 dark:border-gray-700" style={labelsStyle}>
|
||||
<div
|
||||
className="flex items-center gap-0.5 flex-wrap min-w-0 px-2 border-r border-gray-200 dark:border-gray-700"
|
||||
style={labelsStyle}
|
||||
>
|
||||
<TaskLabelsCell labels={task.labels} isDarkMode={isDarkMode} />
|
||||
<LabelsSelector task={labelsAdapter} isDarkMode={isDarkMode} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
LabelsColumn.displayName = 'LabelsColumn';
|
||||
|
||||
@@ -268,15 +284,16 @@ interface PhaseColumnProps {
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
export const PhaseColumn: React.FC<PhaseColumnProps> = memo(({ width, task, projectId, isDarkMode }) => (
|
||||
<div className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700" style={{ width }}>
|
||||
<TaskPhaseDropdown
|
||||
task={task}
|
||||
projectId={projectId}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
export const PhaseColumn: React.FC<PhaseColumnProps> = memo(
|
||||
({ width, task, projectId, isDarkMode }) => (
|
||||
<div
|
||||
className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700"
|
||||
style={{ width }}
|
||||
>
|
||||
<TaskPhaseDropdown task={task} projectId={projectId} isDarkMode={isDarkMode} />
|
||||
</div>
|
||||
));
|
||||
)
|
||||
);
|
||||
|
||||
PhaseColumn.displayName = 'PhaseColumn';
|
||||
|
||||
@@ -286,11 +303,16 @@ interface TimeTrackingColumnProps {
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
export const TimeTrackingColumn: React.FC<TimeTrackingColumnProps> = memo(({ width, taskId, isDarkMode }) => (
|
||||
<div className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700" style={{ width }}>
|
||||
export const TimeTrackingColumn: React.FC<TimeTrackingColumnProps> = memo(
|
||||
({ width, taskId, isDarkMode }) => (
|
||||
<div
|
||||
className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700"
|
||||
style={{ width }}
|
||||
>
|
||||
<TaskTimeTracking taskId={taskId} isDarkMode={isDarkMode} />
|
||||
</div>
|
||||
));
|
||||
)
|
||||
);
|
||||
|
||||
TimeTrackingColumn.displayName = 'TimeTrackingColumn';
|
||||
|
||||
@@ -320,15 +342,14 @@ export const EstimationColumn: React.FC<EstimationColumnProps> = memo(({ width,
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700" style={{ width }}>
|
||||
<div
|
||||
className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700"
|
||||
style={{ width }}
|
||||
>
|
||||
{estimationDisplay ? (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{estimationDisplay}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">{estimationDisplay}</span>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400 dark:text-gray-500">
|
||||
-
|
||||
</span>
|
||||
<span className="text-sm text-gray-400 dark:text-gray-500">-</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -342,17 +363,24 @@ interface DateColumnProps {
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export const DateColumn: React.FC<DateColumnProps> = memo(({ width, formattedDate, placeholder = '-' }) => (
|
||||
<div className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700" style={{ width }}>
|
||||
export const DateColumn: React.FC<DateColumnProps> = memo(
|
||||
({ width, formattedDate, placeholder = '-' }) => (
|
||||
<div
|
||||
className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700"
|
||||
style={{ width }}
|
||||
>
|
||||
{formattedDate ? (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||
{formattedDate}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400 dark:text-gray-500 whitespace-nowrap">{placeholder}</span>
|
||||
<span className="text-sm text-gray-400 dark:text-gray-500 whitespace-nowrap">
|
||||
{placeholder}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
));
|
||||
)
|
||||
);
|
||||
|
||||
DateColumn.displayName = 'DateColumn';
|
||||
|
||||
@@ -362,7 +390,10 @@ interface ReporterColumnProps {
|
||||
}
|
||||
|
||||
export const ReporterColumn: React.FC<ReporterColumnProps> = memo(({ width, reporter }) => (
|
||||
<div className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700" style={{ width }}>
|
||||
<div
|
||||
className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700"
|
||||
style={{ width }}
|
||||
>
|
||||
{reporter ? (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{reporter}</span>
|
||||
) : (
|
||||
@@ -380,11 +411,15 @@ interface CustomColumnProps {
|
||||
updateTaskCustomColumnValue?: (taskId: string, columnKey: string, value: string) => void;
|
||||
}
|
||||
|
||||
export const CustomColumn: React.FC<CustomColumnProps> = memo(({ width, column, task, updateTaskCustomColumnValue }) => {
|
||||
export const CustomColumn: React.FC<CustomColumnProps> = memo(
|
||||
({ width, column, task, updateTaskCustomColumnValue }) => {
|
||||
if (!updateTaskCustomColumnValue) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700" style={{ width }}>
|
||||
<div
|
||||
className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700"
|
||||
style={{ width }}
|
||||
>
|
||||
<CustomColumnCell
|
||||
column={column}
|
||||
task={task}
|
||||
@@ -392,6 +427,7 @@ export const CustomColumn: React.FC<CustomColumnProps> = memo(({ width, column,
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
CustomColumn.displayName = 'CustomColumn';
|
||||
@@ -1,11 +1,23 @@
|
||||
import React, { memo, useCallback, useState, useRef, useEffect } from 'react';
|
||||
import { RightOutlined, DoubleRightOutlined, ArrowsAltOutlined, CommentOutlined, EyeOutlined, PaperClipOutlined, MinusCircleOutlined, RetweetOutlined } from '@/shared/antd-imports';
|
||||
import {
|
||||
RightOutlined,
|
||||
DoubleRightOutlined,
|
||||
ArrowsAltOutlined,
|
||||
CommentOutlined,
|
||||
EyeOutlined,
|
||||
PaperClipOutlined,
|
||||
MinusCircleOutlined,
|
||||
RetweetOutlined,
|
||||
} from '@/shared/antd-imports';
|
||||
import { Input, Tooltip } from '@/shared/antd-imports';
|
||||
import type { InputRef } from '@/shared/antd-imports';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Task } from '@/types/task-management.types';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { toggleTaskExpansion, fetchSubTasks } from '@/features/task-management/task-management.slice';
|
||||
import {
|
||||
toggleTaskExpansion,
|
||||
fetchSubTasks,
|
||||
} from '@/features/task-management/task-management.slice';
|
||||
import { setSelectedTaskId, setShowTaskDrawer } from '@/features/task-drawer/task-drawer.slice';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
@@ -27,7 +39,8 @@ interface TitleColumnProps {
|
||||
depth?: number;
|
||||
}
|
||||
|
||||
export const TitleColumn: React.FC<TitleColumnProps> = memo(({
|
||||
export const TitleColumn: React.FC<TitleColumnProps> = memo(
|
||||
({
|
||||
width,
|
||||
task,
|
||||
projectId,
|
||||
@@ -38,7 +51,7 @@ export const TitleColumn: React.FC<TitleColumnProps> = memo(({
|
||||
onEditTaskName,
|
||||
onTaskNameChange,
|
||||
onTaskNameSave,
|
||||
depth = 0
|
||||
depth = 0,
|
||||
}) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { socket, connected } = useSocket();
|
||||
@@ -51,7 +64,8 @@ export const TitleColumn: React.FC<TitleColumnProps> = memo(({
|
||||
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 });
|
||||
|
||||
// Handle task expansion toggle
|
||||
const handleToggleExpansion = useCallback((e: React.MouseEvent) => {
|
||||
const handleToggleExpansion = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// Always try to fetch subtasks when expanding, regardless of count
|
||||
@@ -61,12 +75,18 @@ export const TitleColumn: React.FC<TitleColumnProps> = memo(({
|
||||
|
||||
// Toggle expansion state
|
||||
dispatch(toggleTaskExpansion(task.id));
|
||||
}, [dispatch, task.id, task.sub_tasks, task.show_sub_tasks, projectId]);
|
||||
},
|
||||
[dispatch, task.id, task.sub_tasks, task.show_sub_tasks, projectId]
|
||||
);
|
||||
|
||||
// Handle task name save
|
||||
const handleTaskNameSave = useCallback(() => {
|
||||
const newTaskName = inputRef.current?.input?.value || taskName;
|
||||
if (newTaskName?.trim() !== '' && connected && newTaskName.trim() !== (task.title || task.name || '').trim()) {
|
||||
if (
|
||||
newTaskName?.trim() !== '' &&
|
||||
connected &&
|
||||
newTaskName.trim() !== (task.title || task.name || '').trim()
|
||||
) {
|
||||
socket?.emit(
|
||||
SocketEvents.TASK_NAME_CHANGE.toString(),
|
||||
JSON.stringify({
|
||||
@@ -77,7 +97,16 @@ export const TitleColumn: React.FC<TitleColumnProps> = memo(({
|
||||
);
|
||||
}
|
||||
onEditTaskName(false);
|
||||
}, [taskName, connected, socket, task.id, task.parent_task_id, task.title, task.name, onEditTaskName]);
|
||||
}, [
|
||||
taskName,
|
||||
connected,
|
||||
socket,
|
||||
task.id,
|
||||
task.parent_task_id,
|
||||
task.title,
|
||||
task.name,
|
||||
onEditTaskName,
|
||||
]);
|
||||
|
||||
// Handle context menu
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
||||
@@ -87,7 +116,7 @@ export const TitleColumn: React.FC<TitleColumnProps> = memo(({
|
||||
// Use clientX and clientY directly for fixed positioning
|
||||
setContextMenuPosition({
|
||||
x: e.clientX,
|
||||
y: e.clientY
|
||||
y: e.clientY,
|
||||
});
|
||||
setContextMenuVisible(true);
|
||||
}, []);
|
||||
@@ -127,7 +156,7 @@ export const TitleColumn: React.FC<TitleColumnProps> = memo(({
|
||||
ref={inputRef}
|
||||
variant="borderless"
|
||||
value={taskName}
|
||||
onChange={(e) => onTaskNameChange(e.target.value)}
|
||||
onChange={e => onTaskNameChange(e.target.value)}
|
||||
autoFocus
|
||||
onPressEnter={handleTaskNameSave}
|
||||
onBlur={handleTaskNameSave}
|
||||
@@ -174,7 +203,7 @@ export const TitleColumn: React.FC<TitleColumnProps> = memo(({
|
||||
className="transition-transform duration-300 ease-out"
|
||||
style={{
|
||||
transform: task.show_sub_tasks ? 'rotate(90deg)' : 'rotate(0deg)',
|
||||
transformOrigin: 'center'
|
||||
transformOrigin: 'center',
|
||||
}}
|
||||
>
|
||||
<RightOutlined className="text-gray-600 dark:text-gray-400" />
|
||||
@@ -195,7 +224,7 @@ export const TitleColumn: React.FC<TitleColumnProps> = memo(({
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onEditTaskName(true);
|
||||
@@ -211,19 +240,32 @@ export const TitleColumn: React.FC<TitleColumnProps> = memo(({
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{/* Subtask count indicator - show for any task that can have sub-tasks */}
|
||||
{depth < 2 && task.sub_tasks_count != null && task.sub_tasks_count > 0 && (
|
||||
<Tooltip title={t(`indicators.tooltips.subtasks${task.sub_tasks_count === 1 ? '' : '_plural'}`, { count: task.sub_tasks_count })}>
|
||||
<Tooltip
|
||||
title={t(
|
||||
`indicators.tooltips.subtasks${task.sub_tasks_count === 1 ? '' : '_plural'}`,
|
||||
{ count: task.sub_tasks_count }
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1 px-1.5 py-0.5 bg-blue-50 dark:bg-blue-900/20 rounded text-xs">
|
||||
<span className="text-blue-600 dark:text-blue-400 font-medium">
|
||||
{task.sub_tasks_count}
|
||||
</span>
|
||||
<DoubleRightOutlined className="text-blue-600 dark:text-blue-400" style={{ fontSize: 10 }} />
|
||||
<DoubleRightOutlined
|
||||
className="text-blue-600 dark:text-blue-400"
|
||||
style={{ fontSize: 10 }}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Task indicators - compact layout */}
|
||||
{task.comments_count != null && task.comments_count !== 0 && (
|
||||
<Tooltip title={t(`indicators.tooltips.comments${task.comments_count === 1 ? '' : '_plural'}`, { count: task.comments_count })}>
|
||||
<Tooltip
|
||||
title={t(
|
||||
`indicators.tooltips.comments${task.comments_count === 1 ? '' : '_plural'}`,
|
||||
{ count: task.comments_count }
|
||||
)}
|
||||
>
|
||||
<CommentOutlined
|
||||
className="text-gray-500 dark:text-gray-400"
|
||||
style={{ fontSize: 12 }}
|
||||
@@ -241,7 +283,12 @@ export const TitleColumn: React.FC<TitleColumnProps> = memo(({
|
||||
)}
|
||||
|
||||
{task.attachments_count != null && task.attachments_count !== 0 && (
|
||||
<Tooltip title={t(`indicators.tooltips.attachments${task.attachments_count === 1 ? '' : '_plural'}`, { count: task.attachments_count })}>
|
||||
<Tooltip
|
||||
title={t(
|
||||
`indicators.tooltips.attachments${task.attachments_count === 1 ? '' : '_plural'}`,
|
||||
{ count: task.attachments_count }
|
||||
)}
|
||||
>
|
||||
<PaperClipOutlined
|
||||
className="text-gray-500 dark:text-gray-400"
|
||||
style={{ fontSize: 12 }}
|
||||
@@ -272,7 +319,7 @@ export const TitleColumn: React.FC<TitleColumnProps> = memo(({
|
||||
|
||||
<button
|
||||
className="opacity-0 group-hover:opacity-100 transition-all duration-200 ml-2 mr-2 px-3 py-1.5 text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 cursor-pointer rounded-md shadow-sm hover:shadow-md flex items-center gap-1 flex-shrink-0"
|
||||
onClick={(e) => {
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
dispatch(setSelectedTaskId(task.id));
|
||||
dispatch(setShowTaskDrawer(true));
|
||||
@@ -285,7 +332,8 @@ export const TitleColumn: React.FC<TitleColumnProps> = memo(({
|
||||
)}
|
||||
|
||||
{/* Context Menu */}
|
||||
{contextMenuVisible && createPortal(
|
||||
{contextMenuVisible &&
|
||||
createPortal(
|
||||
<TaskContextMenu
|
||||
task={task}
|
||||
projectId={projectId}
|
||||
@@ -296,6 +344,7 @@ export const TitleColumn: React.FC<TitleColumnProps> = memo(({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
TitleColumn.displayName = 'TitleColumn';
|
||||
@@ -15,7 +15,14 @@ export type ColumnStyle = {
|
||||
export const BASE_COLUMNS = [
|
||||
{ id: 'dragHandle', label: '', width: '20px', isSticky: true, key: 'dragHandle' },
|
||||
{ id: 'checkbox', label: '', width: '28px', isSticky: true, key: 'checkbox' },
|
||||
{ id: 'taskKey', label: 'keyColumn', width: '100px', key: COLUMN_KEYS.KEY, minWidth: '100px', maxWidth: '150px' },
|
||||
{
|
||||
id: 'taskKey',
|
||||
label: 'keyColumn',
|
||||
width: '100px',
|
||||
key: COLUMN_KEYS.KEY,
|
||||
minWidth: '100px',
|
||||
maxWidth: '150px',
|
||||
},
|
||||
{ id: 'title', label: 'taskColumn', width: '470px', isSticky: true, key: COLUMN_KEYS.NAME },
|
||||
{ id: 'description', label: 'descriptionColumn', width: '260px', key: COLUMN_KEYS.DESCRIPTION },
|
||||
{ id: 'progress', label: 'progressColumn', width: '120px', key: COLUMN_KEYS.PROGRESS },
|
||||
@@ -24,12 +31,22 @@ export const BASE_COLUMNS = [
|
||||
{ id: 'labels', label: 'labelsColumn', width: '250px', key: COLUMN_KEYS.LABELS },
|
||||
{ id: 'phase', label: 'phaseColumn', width: '120px', key: COLUMN_KEYS.PHASE },
|
||||
{ id: 'priority', label: 'priorityColumn', width: '120px', key: COLUMN_KEYS.PRIORITY },
|
||||
{ id: 'timeTracking', label: 'timeTrackingColumn', width: '120px', key: COLUMN_KEYS.TIME_TRACKING },
|
||||
{
|
||||
id: 'timeTracking',
|
||||
label: 'timeTrackingColumn',
|
||||
width: '120px',
|
||||
key: COLUMN_KEYS.TIME_TRACKING,
|
||||
},
|
||||
{ id: 'estimation', label: 'estimationColumn', width: '120px', key: COLUMN_KEYS.ESTIMATION },
|
||||
{ id: 'startDate', label: 'startDateColumn', width: '140px', key: COLUMN_KEYS.START_DATE },
|
||||
{ id: 'dueDate', label: 'dueDateColumn', width: '140px', key: COLUMN_KEYS.DUE_DATE },
|
||||
{ id: 'dueTime', label: 'dueTimeColumn', width: '120px', key: COLUMN_KEYS.DUE_TIME },
|
||||
{ id: 'completedDate', label: 'completedDateColumn', width: '140px', key: COLUMN_KEYS.COMPLETED_DATE },
|
||||
{
|
||||
id: 'completedDate',
|
||||
label: 'completedDateColumn',
|
||||
width: '140px',
|
||||
key: COLUMN_KEYS.COMPLETED_DATE,
|
||||
},
|
||||
{ id: 'createdDate', label: 'createdDateColumn', width: '140px', key: COLUMN_KEYS.CREATED_DATE },
|
||||
{ id: 'lastUpdated', label: 'lastUpdatedColumn', width: '140px', key: COLUMN_KEYS.LAST_UPDATED },
|
||||
{ id: 'reporter', label: 'reporterColumn', width: '120px', key: COLUMN_KEYS.REPORTER },
|
||||
|
||||
@@ -67,7 +67,8 @@ export const useBulkActions = () => {
|
||||
dispatch(clearSelection());
|
||||
}, [dispatch]);
|
||||
|
||||
const handleBulkStatusChange = useCallback(async (statusId: string, selectedTaskIds: string[]) => {
|
||||
const handleBulkStatusChange = useCallback(
|
||||
async (statusId: string, selectedTaskIds: string[]) => {
|
||||
if (!statusId || !projectId || !selectedTaskIds.length) return;
|
||||
|
||||
try {
|
||||
@@ -108,9 +109,12 @@ export const useBulkActions = () => {
|
||||
} finally {
|
||||
updateLoadingState('status', false);
|
||||
}
|
||||
}, [projectId, trackMixpanelEvent, dispatch, refetchTasks, updateLoadingState]);
|
||||
},
|
||||
[projectId, trackMixpanelEvent, dispatch, refetchTasks, updateLoadingState]
|
||||
);
|
||||
|
||||
const handleBulkPriorityChange = useCallback(async (priorityId: string, selectedTaskIds: string[]) => {
|
||||
const handleBulkPriorityChange = useCallback(
|
||||
async (priorityId: string, selectedTaskIds: string[]) => {
|
||||
if (!priorityId || !projectId || !selectedTaskIds.length) return;
|
||||
|
||||
try {
|
||||
@@ -132,9 +136,12 @@ export const useBulkActions = () => {
|
||||
} finally {
|
||||
updateLoadingState('priority', false);
|
||||
}
|
||||
}, [projectId, trackMixpanelEvent, dispatch, refetchTasks, updateLoadingState]);
|
||||
},
|
||||
[projectId, trackMixpanelEvent, dispatch, refetchTasks, updateLoadingState]
|
||||
);
|
||||
|
||||
const handleBulkPhaseChange = useCallback(async (phaseId: string, selectedTaskIds: string[]) => {
|
||||
const handleBulkPhaseChange = useCallback(
|
||||
async (phaseId: string, selectedTaskIds: string[]) => {
|
||||
if (!phaseId || !projectId || !selectedTaskIds.length) return;
|
||||
|
||||
try {
|
||||
@@ -156,9 +163,12 @@ export const useBulkActions = () => {
|
||||
} finally {
|
||||
updateLoadingState('phase', false);
|
||||
}
|
||||
}, [projectId, trackMixpanelEvent, dispatch, refetchTasks, updateLoadingState]);
|
||||
},
|
||||
[projectId, trackMixpanelEvent, dispatch, refetchTasks, updateLoadingState]
|
||||
);
|
||||
|
||||
const handleBulkAssignToMe = useCallback(async (selectedTaskIds: string[]) => {
|
||||
const handleBulkAssignToMe = useCallback(
|
||||
async (selectedTaskIds: string[]) => {
|
||||
if (!projectId || !selectedTaskIds.length) return;
|
||||
|
||||
try {
|
||||
@@ -180,9 +190,12 @@ export const useBulkActions = () => {
|
||||
} finally {
|
||||
updateLoadingState('assignToMe', false);
|
||||
}
|
||||
}, [projectId, trackMixpanelEvent, dispatch, refetchTasks, updateLoadingState]);
|
||||
},
|
||||
[projectId, trackMixpanelEvent, dispatch, refetchTasks, updateLoadingState]
|
||||
);
|
||||
|
||||
const handleBulkAssignMembers = useCallback(async (memberIds: string[], selectedTaskIds: string[]) => {
|
||||
const handleBulkAssignMembers = useCallback(
|
||||
async (memberIds: string[], selectedTaskIds: string[]) => {
|
||||
if (!projectId || !selectedTaskIds.length) return;
|
||||
|
||||
try {
|
||||
@@ -212,9 +225,12 @@ export const useBulkActions = () => {
|
||||
} finally {
|
||||
updateLoadingState('assignMembers', false);
|
||||
}
|
||||
}, [projectId, trackMixpanelEvent, dispatch, refetchTasks, updateLoadingState]);
|
||||
},
|
||||
[projectId, trackMixpanelEvent, dispatch, refetchTasks, updateLoadingState]
|
||||
);
|
||||
|
||||
const handleBulkAddLabels = useCallback(async (labelIds: string[], selectedTaskIds: string[]) => {
|
||||
const handleBulkAddLabels = useCallback(
|
||||
async (labelIds: string[], selectedTaskIds: string[]) => {
|
||||
if (!projectId || !selectedTaskIds.length) return;
|
||||
|
||||
try {
|
||||
@@ -240,9 +256,12 @@ export const useBulkActions = () => {
|
||||
} finally {
|
||||
updateLoadingState('labels', false);
|
||||
}
|
||||
}, [projectId, trackMixpanelEvent, dispatch, refetchTasks, updateLoadingState]);
|
||||
},
|
||||
[projectId, trackMixpanelEvent, dispatch, refetchTasks, updateLoadingState]
|
||||
);
|
||||
|
||||
const handleBulkArchive = useCallback(async (selectedTaskIds: string[]) => {
|
||||
const handleBulkArchive = useCallback(
|
||||
async (selectedTaskIds: string[]) => {
|
||||
if (!projectId || !selectedTaskIds.length) return;
|
||||
|
||||
try {
|
||||
@@ -264,9 +283,12 @@ export const useBulkActions = () => {
|
||||
} finally {
|
||||
updateLoadingState('archive', false);
|
||||
}
|
||||
}, [projectId, archived, trackMixpanelEvent, dispatch, refetchTasks, updateLoadingState]);
|
||||
},
|
||||
[projectId, archived, trackMixpanelEvent, dispatch, refetchTasks, updateLoadingState]
|
||||
);
|
||||
|
||||
const handleBulkDelete = useCallback(async (selectedTaskIds: string[]) => {
|
||||
const handleBulkDelete = useCallback(
|
||||
async (selectedTaskIds: string[]) => {
|
||||
if (!projectId || !selectedTaskIds.length) return;
|
||||
|
||||
try {
|
||||
@@ -288,9 +310,12 @@ export const useBulkActions = () => {
|
||||
} finally {
|
||||
updateLoadingState('delete', false);
|
||||
}
|
||||
}, [projectId, trackMixpanelEvent, dispatch, refetchTasks, updateLoadingState]);
|
||||
},
|
||||
[projectId, trackMixpanelEvent, dispatch, refetchTasks, updateLoadingState]
|
||||
);
|
||||
|
||||
const handleBulkDuplicate = useCallback(async (selectedTaskIds: string[]) => {
|
||||
const handleBulkDuplicate = useCallback(
|
||||
async (selectedTaskIds: string[]) => {
|
||||
if (!projectId || !selectedTaskIds.length) return;
|
||||
|
||||
try {
|
||||
@@ -305,9 +330,12 @@ export const useBulkActions = () => {
|
||||
} finally {
|
||||
updateLoadingState('duplicate', false);
|
||||
}
|
||||
}, [projectId, dispatch, refetchTasks, updateLoadingState]);
|
||||
},
|
||||
[projectId, dispatch, refetchTasks, updateLoadingState]
|
||||
);
|
||||
|
||||
const handleBulkExport = useCallback(async (selectedTaskIds: string[]) => {
|
||||
const handleBulkExport = useCallback(
|
||||
async (selectedTaskIds: string[]) => {
|
||||
if (!projectId || !selectedTaskIds.length) return;
|
||||
|
||||
try {
|
||||
@@ -319,9 +347,12 @@ export const useBulkActions = () => {
|
||||
} finally {
|
||||
updateLoadingState('export', false);
|
||||
}
|
||||
}, [projectId, updateLoadingState]);
|
||||
},
|
||||
[projectId, updateLoadingState]
|
||||
);
|
||||
|
||||
const handleBulkSetDueDate = useCallback(async (date: string, selectedTaskIds: string[]) => {
|
||||
const handleBulkSetDueDate = useCallback(
|
||||
async (date: string, selectedTaskIds: string[]) => {
|
||||
if (!projectId || !selectedTaskIds.length) return;
|
||||
|
||||
try {
|
||||
@@ -336,7 +367,9 @@ export const useBulkActions = () => {
|
||||
} finally {
|
||||
updateLoadingState('dueDate', false);
|
||||
}
|
||||
}, [projectId, dispatch, refetchTasks, updateLoadingState]);
|
||||
},
|
||||
[projectId, dispatch, refetchTasks, updateLoadingState]
|
||||
);
|
||||
|
||||
return {
|
||||
handleClearSelection,
|
||||
|
||||
@@ -43,7 +43,7 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
|
||||
// Create a copy of all groups and perform the move operation
|
||||
const updatedGroups = groups.map(group => ({
|
||||
...group,
|
||||
taskIds: [...group.taskIds]
|
||||
taskIds: [...group.taskIds],
|
||||
}));
|
||||
|
||||
// Find the source and target groups in our copy
|
||||
@@ -73,7 +73,7 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
|
||||
group.taskIds.forEach(id => {
|
||||
const update: any = {
|
||||
task_id: id,
|
||||
sort_order: currentSortOrder
|
||||
sort_order: currentSortOrder,
|
||||
};
|
||||
|
||||
// Add group-specific fields for the moved task if it changed groups
|
||||
@@ -120,18 +120,24 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
|
||||
});
|
||||
} else if (currentGrouping === 'priority') {
|
||||
// Emit priority change event
|
||||
socket.emit(SocketEvents.TASK_PRIORITY_CHANGE.toString(), JSON.stringify({
|
||||
socket.emit(
|
||||
SocketEvents.TASK_PRIORITY_CHANGE.toString(),
|
||||
JSON.stringify({
|
||||
task_id: taskId,
|
||||
priority_id: targetGroup.id,
|
||||
team_id: teamId,
|
||||
}));
|
||||
})
|
||||
);
|
||||
} else if (currentGrouping === 'status') {
|
||||
// Emit status change event
|
||||
socket.emit(SocketEvents.TASK_STATUS_CHANGE.toString(), JSON.stringify({
|
||||
socket.emit(
|
||||
SocketEvents.TASK_STATUS_CHANGE.toString(),
|
||||
JSON.stringify({
|
||||
task_id: taskId,
|
||||
status_id: targetGroup.id,
|
||||
team_id: teamId,
|
||||
}));
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -24,14 +24,21 @@ export const useTaskRowActions = ({
|
||||
const { socket, connected } = useSocket();
|
||||
|
||||
// Handle checkbox change
|
||||
const handleCheckboxChange = useCallback((e: any) => {
|
||||
const handleCheckboxChange = useCallback(
|
||||
(e: any) => {
|
||||
e.stopPropagation(); // Prevent row click when clicking checkbox
|
||||
dispatch(toggleTaskSelection(taskId));
|
||||
}, [dispatch, taskId]);
|
||||
},
|
||||
[dispatch, taskId]
|
||||
);
|
||||
|
||||
// Handle task name save
|
||||
const handleTaskNameSave = useCallback(() => {
|
||||
if (taskName?.trim() !== '' && connected && taskName.trim() !== (task.title || task.name || '').trim()) {
|
||||
if (
|
||||
taskName?.trim() !== '' &&
|
||||
connected &&
|
||||
taskName.trim() !== (task.title || task.name || '').trim()
|
||||
) {
|
||||
socket?.emit(
|
||||
SocketEvents.TASK_NAME_CHANGE.toString(),
|
||||
JSON.stringify({
|
||||
@@ -42,7 +49,16 @@ export const useTaskRowActions = ({
|
||||
);
|
||||
}
|
||||
setEditTaskName(false);
|
||||
}, [taskName, connected, socket, task.id, task.parent_task_id, task.title, task.name, setEditTaskName]);
|
||||
}, [
|
||||
taskName,
|
||||
connected,
|
||||
socket,
|
||||
task.id,
|
||||
task.parent_task_id,
|
||||
task.title,
|
||||
task.name,
|
||||
setEditTaskName,
|
||||
]);
|
||||
|
||||
// Handle task name edit start
|
||||
const handleTaskNameEdit = useCallback(() => {
|
||||
|
||||
@@ -89,8 +89,8 @@ export const useTaskRowColumns = ({
|
||||
listeners,
|
||||
depth = 0,
|
||||
}: UseTaskRowColumnsProps) => {
|
||||
|
||||
const renderColumn = useCallback((columnId: string, width: string, isSticky?: boolean, index?: number) => {
|
||||
const renderColumn = useCallback(
|
||||
(columnId: string, width: string, isSticky?: boolean, index?: number) => {
|
||||
switch (columnId) {
|
||||
case 'dragHandle':
|
||||
return (
|
||||
@@ -112,12 +112,7 @@ export const useTaskRowColumns = ({
|
||||
);
|
||||
|
||||
case 'taskKey':
|
||||
return (
|
||||
<TaskKeyColumn
|
||||
width={width}
|
||||
taskKey={task.task_key || ''}
|
||||
/>
|
||||
);
|
||||
return <TaskKeyColumn width={width} taskKey={task.task_key || ''} />;
|
||||
|
||||
case 'title':
|
||||
return (
|
||||
@@ -137,21 +132,11 @@ export const useTaskRowColumns = ({
|
||||
);
|
||||
|
||||
case 'description':
|
||||
return (
|
||||
<DescriptionColumn
|
||||
width={width}
|
||||
description={task.description || ''}
|
||||
/>
|
||||
);
|
||||
return <DescriptionColumn width={width} description={task.description || ''} />;
|
||||
|
||||
case 'status':
|
||||
return (
|
||||
<StatusColumn
|
||||
width={width}
|
||||
task={task}
|
||||
projectId={projectId}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
<StatusColumn width={width} task={task} projectId={projectId} isDarkMode={isDarkMode} />
|
||||
);
|
||||
|
||||
case 'assignees':
|
||||
@@ -203,12 +188,7 @@ export const useTaskRowColumns = ({
|
||||
);
|
||||
|
||||
case 'progress':
|
||||
return (
|
||||
<ProgressColumn
|
||||
width={width}
|
||||
task={task}
|
||||
/>
|
||||
);
|
||||
return <ProgressColumn width={width} task={task} />;
|
||||
|
||||
case 'labels':
|
||||
return (
|
||||
@@ -223,62 +203,28 @@ export const useTaskRowColumns = ({
|
||||
|
||||
case 'phase':
|
||||
return (
|
||||
<PhaseColumn
|
||||
width={width}
|
||||
task={task}
|
||||
projectId={projectId}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
<PhaseColumn width={width} task={task} projectId={projectId} isDarkMode={isDarkMode} />
|
||||
);
|
||||
|
||||
case 'timeTracking':
|
||||
return (
|
||||
<TimeTrackingColumn
|
||||
width={width}
|
||||
taskId={task.id || ''}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
<TimeTrackingColumn width={width} taskId={task.id || ''} isDarkMode={isDarkMode} />
|
||||
);
|
||||
|
||||
case 'estimation':
|
||||
return (
|
||||
<EstimationColumn
|
||||
width={width}
|
||||
task={task}
|
||||
/>
|
||||
);
|
||||
return <EstimationColumn width={width} task={task} />;
|
||||
|
||||
case 'completedDate':
|
||||
return (
|
||||
<DateColumn
|
||||
width={width}
|
||||
formattedDate={formattedDates.completed}
|
||||
/>
|
||||
);
|
||||
return <DateColumn width={width} formattedDate={formattedDates.completed} />;
|
||||
|
||||
case 'createdDate':
|
||||
return (
|
||||
<DateColumn
|
||||
width={width}
|
||||
formattedDate={formattedDates.created}
|
||||
/>
|
||||
);
|
||||
return <DateColumn width={width} formattedDate={formattedDates.created} />;
|
||||
|
||||
case 'lastUpdated':
|
||||
return (
|
||||
<DateColumn
|
||||
width={width}
|
||||
formattedDate={formattedDates.updated}
|
||||
/>
|
||||
);
|
||||
return <DateColumn width={width} formattedDate={formattedDates.updated} />;
|
||||
|
||||
case 'reporter':
|
||||
return (
|
||||
<ReporterColumn
|
||||
width={width}
|
||||
reporter={task.reporter || ''}
|
||||
/>
|
||||
);
|
||||
return <ReporterColumn width={width} reporter={task.reporter || ''} />;
|
||||
|
||||
default:
|
||||
// Handle custom columns
|
||||
@@ -295,7 +241,8 @@ export const useTaskRowColumns = ({
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}, [
|
||||
},
|
||||
[
|
||||
task,
|
||||
projectId,
|
||||
isSubtask,
|
||||
@@ -319,7 +266,8 @@ export const useTaskRowColumns = ({
|
||||
handleTaskNameEdit,
|
||||
attributes,
|
||||
listeners,
|
||||
]);
|
||||
]
|
||||
);
|
||||
|
||||
return { renderColumn };
|
||||
};
|
||||
@@ -18,10 +18,14 @@ export const useTaskRowState = (task: Task) => {
|
||||
}, [task.title, task.name]);
|
||||
|
||||
// Memoize task display name
|
||||
const taskDisplayName = useMemo(() => getTaskDisplayName(task), [task.title, task.name, task.task_key]);
|
||||
const taskDisplayName = useMemo(
|
||||
() => getTaskDisplayName(task),
|
||||
[task.title, task.name, task.task_key]
|
||||
);
|
||||
|
||||
// Memoize converted task for AssigneeSelector to prevent recreation
|
||||
const convertedTask = useMemo(() => ({
|
||||
const convertedTask = useMemo(
|
||||
() => ({
|
||||
id: task.id,
|
||||
name: taskDisplayName,
|
||||
task_key: task.task_key || taskDisplayName,
|
||||
@@ -36,46 +40,74 @@ export const useTaskRowState = (task: Task) => {
|
||||
status_id: undefined,
|
||||
project_id: undefined,
|
||||
manual_progress: undefined,
|
||||
}), [task.id, taskDisplayName, task.task_key, task.assignee_names, task.parent_task_id]);
|
||||
}),
|
||||
[task.id, taskDisplayName, task.task_key, task.assignee_names, task.parent_task_id]
|
||||
);
|
||||
|
||||
// Memoize formatted dates
|
||||
const formattedDates = useMemo(() => ({
|
||||
const formattedDates = useMemo(
|
||||
() => ({
|
||||
due: (() => {
|
||||
const dateValue = task.dueDate || task.due_date;
|
||||
return dateValue ? formatDate(dateValue) : null;
|
||||
})(),
|
||||
start: task.startDate ? formatDate(task.startDate) : null,
|
||||
completed: task.completedAt ? formatDate(task.completedAt) : null,
|
||||
created: (task.createdAt || task.created_at) ? formatDate(task.createdAt || task.created_at) : null,
|
||||
created:
|
||||
task.createdAt || task.created_at ? formatDate(task.createdAt || task.created_at) : null,
|
||||
updated: task.updatedAt ? formatDate(task.updatedAt) : null,
|
||||
}), [task.dueDate, task.due_date, task.startDate, task.completedAt, task.createdAt, task.created_at, task.updatedAt]);
|
||||
}),
|
||||
[
|
||||
task.dueDate,
|
||||
task.due_date,
|
||||
task.startDate,
|
||||
task.completedAt,
|
||||
task.createdAt,
|
||||
task.created_at,
|
||||
task.updatedAt,
|
||||
]
|
||||
);
|
||||
|
||||
// Memoize date values for DatePicker
|
||||
const dateValues = useMemo(
|
||||
() => ({
|
||||
start: task.startDate ? dayjs(task.startDate) : undefined,
|
||||
due: (task.dueDate || task.due_date) ? dayjs(task.dueDate || task.due_date) : undefined,
|
||||
due: task.dueDate || task.due_date ? dayjs(task.dueDate || task.due_date) : undefined,
|
||||
}),
|
||||
[task.startDate, task.dueDate, task.due_date]
|
||||
);
|
||||
|
||||
// Create labels adapter for LabelsSelector
|
||||
const labelsAdapter = useMemo(() => ({
|
||||
const labelsAdapter = useMemo(
|
||||
() => ({
|
||||
id: task.id,
|
||||
name: task.title || task.name,
|
||||
parent_task_id: task.parent_task_id,
|
||||
manual_progress: false,
|
||||
all_labels: task.all_labels?.map(label => ({
|
||||
all_labels:
|
||||
task.all_labels?.map(label => ({
|
||||
id: label.id,
|
||||
name: label.name,
|
||||
color_code: label.color_code,
|
||||
})) || [],
|
||||
labels: task.labels?.map(label => ({
|
||||
labels:
|
||||
task.labels?.map(label => ({
|
||||
id: label.id,
|
||||
name: label.name,
|
||||
color_code: label.color,
|
||||
})) || [],
|
||||
}), [task.id, task.title, task.name, task.parent_task_id, task.all_labels, task.labels, task.all_labels?.length, task.labels?.length]);
|
||||
}),
|
||||
[
|
||||
task.id,
|
||||
task.title,
|
||||
task.name,
|
||||
task.parent_task_id,
|
||||
task.all_labels,
|
||||
task.labels,
|
||||
task.all_labels?.length,
|
||||
task.labels?.length,
|
||||
]
|
||||
);
|
||||
|
||||
return {
|
||||
// State
|
||||
|
||||
@@ -106,7 +106,9 @@
|
||||
|
||||
/* Sortable item styling */
|
||||
.sortable-status-item {
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.sortable-status-item.is-dragging {
|
||||
@@ -173,7 +175,9 @@
|
||||
.status-item-enter-active {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
transition:
|
||||
opacity 0.3s ease,
|
||||
transform 0.3s ease;
|
||||
}
|
||||
|
||||
.status-item-exit {
|
||||
@@ -184,5 +188,7 @@
|
||||
.status-item-exit-active {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
transition:
|
||||
opacity 0.3s ease,
|
||||
transform 0.3s ease;
|
||||
}
|
||||
@@ -1,5 +1,17 @@
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { Modal, Form, Input, Button, Tabs, Space, Divider, Typography, Flex, DatePicker, Select } from '@/shared/antd-imports';
|
||||
import {
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Button,
|
||||
Tabs,
|
||||
Space,
|
||||
Divider,
|
||||
Typography,
|
||||
Flex,
|
||||
DatePicker,
|
||||
Select,
|
||||
} from '@/shared/antd-imports';
|
||||
import { PlusOutlined, DragOutlined } from '@/shared/antd-imports';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
|
||||
@@ -11,7 +23,11 @@ import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import TaskDetailsForm from '@/components/task-drawer/shared/info-tab/task-details-form';
|
||||
import AssigneeSelector from '@/components/AssigneeSelector';
|
||||
import LabelsSelector from '@/components/LabelsSelector';
|
||||
import { createStatus, fetchStatuses, fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice';
|
||||
import {
|
||||
createStatus,
|
||||
fetchStatuses,
|
||||
fetchStatusesCategories,
|
||||
} from '@/features/taskAttributes/taskStatusSlice';
|
||||
import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
|
||||
import { ITaskStatusUpdateModel } from '@/types/tasks/task-status-update-model.types';
|
||||
import { Modal as AntModal } from '@/shared/antd-imports';
|
||||
@@ -49,14 +65,9 @@ const SortableStatusItem: React.FC<StatusItemProps & { id: string }> = ({
|
||||
const [editName, setEditName] = useState(status.name || '');
|
||||
const inputRef = useRef<any>(null);
|
||||
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id });
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id,
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
@@ -76,13 +87,16 @@ const SortableStatusItem: React.FC<StatusItemProps & { id: string }> = ({
|
||||
setIsEditing(false);
|
||||
}, [status.name]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSave();
|
||||
} else if (e.key === 'Escape') {
|
||||
handleCancel();
|
||||
}
|
||||
}, [handleSave, handleCancel]);
|
||||
},
|
||||
[handleSave, handleCancel]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing && inputRef.current) {
|
||||
@@ -127,7 +141,7 @@ const SortableStatusItem: React.FC<StatusItemProps & { id: string }> = ({
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
onChange={e => setEditName(e.target.value)}
|
||||
onBlur={handleSave}
|
||||
onKeyDown={handleKeyDown}
|
||||
size="small"
|
||||
@@ -151,7 +165,9 @@ const SortableStatusItem: React.FC<StatusItemProps & { id: string }> = ({
|
||||
type="text"
|
||||
size="small"
|
||||
onClick={() => setIsEditing(true)}
|
||||
className={isDarkMode ? 'text-gray-400 hover:text-gray-300' : 'text-gray-500 hover:text-gray-600'}
|
||||
className={
|
||||
isDarkMode ? 'text-gray-400 hover:text-gray-300' : 'text-gray-500 hover:text-gray-600'
|
||||
}
|
||||
>
|
||||
Rename
|
||||
</Button>
|
||||
@@ -201,9 +217,9 @@ const StatusManagement: React.FC<{
|
||||
return;
|
||||
}
|
||||
|
||||
setLocalStatuses((items) => {
|
||||
const oldIndex = items.findIndex((item) => item.id === active.id);
|
||||
const newIndex = items.findIndex((item) => item.id === over.id);
|
||||
setLocalStatuses(items => {
|
||||
const oldIndex = items.findIndex(item => item.id === active.id);
|
||||
const newIndex = items.findIndex(item => item.id === over.id);
|
||||
|
||||
if (oldIndex === -1 || newIndex === -1) return items;
|
||||
|
||||
@@ -250,7 +266,8 @@ const StatusManagement: React.FC<{
|
||||
}
|
||||
}, [newStatusName, projectId, dispatch]);
|
||||
|
||||
const handleRenameStatus = useCallback(async (id: string, name: string) => {
|
||||
const handleRenameStatus = useCallback(
|
||||
async (id: string, name: string) => {
|
||||
try {
|
||||
const body: ITaskStatusUpdateModel = {
|
||||
name: name.trim(),
|
||||
@@ -262,9 +279,12 @@ const StatusManagement: React.FC<{
|
||||
} catch (error) {
|
||||
console.error('Error renaming status:', error);
|
||||
}
|
||||
}, [projectId, dispatch]);
|
||||
},
|
||||
[projectId, dispatch]
|
||||
);
|
||||
|
||||
const handleDeleteStatus = useCallback(async (id: string) => {
|
||||
const handleDeleteStatus = useCallback(
|
||||
async (id: string) => {
|
||||
AntModal.confirm({
|
||||
title: 'Delete Status',
|
||||
content: 'Are you sure you want to delete this status? This action cannot be undone.',
|
||||
@@ -278,7 +298,9 @@ const StatusManagement: React.FC<{
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [localStatuses, projectId, dispatch]);
|
||||
},
|
||||
[localStatuses, projectId, dispatch]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -296,7 +318,7 @@ const StatusManagement: React.FC<{
|
||||
<Input
|
||||
placeholder="Enter status name"
|
||||
value={newStatusName}
|
||||
onChange={(e) => setNewStatusName(e.target.value)}
|
||||
onChange={e => setNewStatusName(e.target.value)}
|
||||
onPressEnter={handleCreateStatus}
|
||||
className="flex-1"
|
||||
/>
|
||||
@@ -319,7 +341,9 @@ const StatusManagement: React.FC<{
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{localStatuses.filter(status => status.id).map((status) => (
|
||||
{localStatuses
|
||||
.filter(status => status.id)
|
||||
.map(status => (
|
||||
<SortableStatusItem
|
||||
key={status.id}
|
||||
id={status.id!}
|
||||
@@ -342,11 +366,7 @@ const StatusManagement: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
projectId,
|
||||
}) => {
|
||||
const CreateTaskModal: React.FC<CreateTaskModalProps> = ({ open, onClose, projectId }) => {
|
||||
const { t } = useTranslation('task-drawer/task-drawer');
|
||||
const [form] = Form.useForm();
|
||||
const [activeTab, setActiveTab] = useState('task-info');
|
||||
@@ -391,7 +411,6 @@ const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
|
||||
form.resetFields();
|
||||
setActiveTab('task-info');
|
||||
onClose();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Form validation failed:', error);
|
||||
}
|
||||
@@ -423,9 +442,7 @@ const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
|
||||
<Flex justify="space-between" align="center">
|
||||
<div></div>
|
||||
<Space>
|
||||
<Button onClick={handleCancel}>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleCancel}>{t('cancel')}</Button>
|
||||
<Button type="primary" onClick={handleSubmit}>
|
||||
{t('createTask')}
|
||||
</Button>
|
||||
@@ -465,10 +482,7 @@ const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="description"
|
||||
label={t('description')}
|
||||
>
|
||||
<Form.Item name="description" label={t('description')}>
|
||||
<Input.TextArea
|
||||
placeholder={t('descriptionPlaceholder')}
|
||||
rows={4}
|
||||
@@ -492,10 +506,7 @@ const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
|
||||
</Form.Item>
|
||||
|
||||
{/* Priority Selection */}
|
||||
<Form.Item
|
||||
name="priority"
|
||||
label={t('priority')}
|
||||
>
|
||||
<Form.Item name="priority" label={t('priority')}>
|
||||
<Select placeholder="Select priority">
|
||||
<Select.Option value="low">Low</Select.Option>
|
||||
<Select.Option value="medium">Medium</Select.Option>
|
||||
@@ -504,26 +515,16 @@ const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
|
||||
</Form.Item>
|
||||
|
||||
{/* Assignees */}
|
||||
<Form.Item
|
||||
name="assignees"
|
||||
label={t('assignees')}
|
||||
>
|
||||
<Form.Item name="assignees" label={t('assignees')}>
|
||||
<Select mode="multiple" placeholder="Select assignees">
|
||||
{/* TODO: Populate with team members */}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
{/* Due Date */}
|
||||
<Form.Item
|
||||
name="dueDate"
|
||||
label={t('dueDate')}
|
||||
>
|
||||
<DatePicker
|
||||
className="w-full"
|
||||
placeholder="Select due date"
|
||||
/>
|
||||
<Form.Item name="dueDate" label={t('dueDate')}>
|
||||
<DatePicker className="w-full" placeholder="Select due date" />
|
||||
</Form.Item>
|
||||
|
||||
</Form>
|
||||
</div>
|
||||
),
|
||||
@@ -533,10 +534,7 @@ const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
|
||||
label: t('manageStatuses'),
|
||||
children: finalProjectId ? (
|
||||
<div className="py-4">
|
||||
<StatusManagement
|
||||
projectId={finalProjectId}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
<StatusManagement projectId={finalProjectId} isDarkMode={isDarkMode} />
|
||||
</div>
|
||||
) : (
|
||||
<div className={`text-center py-8 ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
|
||||
@@ -276,7 +276,9 @@
|
||||
|
||||
/* Sortable item styling */
|
||||
.sortable-phase-item {
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.sortable-phase-item.is-dragging {
|
||||
@@ -354,7 +356,9 @@
|
||||
.phase-item-enter-active {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
transition:
|
||||
opacity 0.3s ease,
|
||||
transform 0.3s ease;
|
||||
}
|
||||
|
||||
.phase-item-exit {
|
||||
@@ -365,7 +369,9 @@
|
||||
.phase-item-exit-active {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
transition:
|
||||
opacity 0.3s ease,
|
||||
transform 0.3s ease;
|
||||
}
|
||||
|
||||
/* Loading state styling */
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { Modal, Form, Input, Button, Space, Divider, Typography, Flex, ColorPicker, Tooltip } from '@/shared/antd-imports';
|
||||
import {
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Button,
|
||||
Space,
|
||||
Divider,
|
||||
Typography,
|
||||
Flex,
|
||||
ColorPicker,
|
||||
Tooltip,
|
||||
} from '@/shared/antd-imports';
|
||||
import { PlusOutlined, HolderOutlined, EditOutlined, DeleteOutlined } from '@/shared/antd-imports';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
|
||||
@@ -58,14 +69,9 @@ const SortablePhaseItem: React.FC<PhaseItemProps & { id: string }> = ({
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const inputRef = useRef<any>(null);
|
||||
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id });
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id,
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
@@ -85,13 +91,16 @@ const SortablePhaseItem: React.FC<PhaseItemProps & { id: string }> = ({
|
||||
setIsEditing(false);
|
||||
}, [phase.name]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSave();
|
||||
} else if (e.key === 'Escape') {
|
||||
handleCancel();
|
||||
}
|
||||
}, [handleSave, handleCancel]);
|
||||
},
|
||||
[handleSave, handleCancel]
|
||||
);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
setIsEditing(true);
|
||||
@@ -144,7 +153,7 @@ const SortablePhaseItem: React.FC<PhaseItemProps & { id: string }> = ({
|
||||
<div className="flex-shrink-0 flex items-center gap-1">
|
||||
<ColorPicker
|
||||
value={color}
|
||||
onChange={(value) => setColor(value.toHexString())}
|
||||
onChange={value => setColor(value.toHexString())}
|
||||
onChangeComplete={handleColorChangeComplete}
|
||||
size="small"
|
||||
className="phase-color-picker"
|
||||
@@ -164,7 +173,7 @@ const SortablePhaseItem: React.FC<PhaseItemProps & { id: string }> = ({
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
onChange={e => setEditName(e.target.value)}
|
||||
onBlur={handleSave}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={`font-medium text-xs border-0 px-1 py-1 shadow-none ${
|
||||
@@ -177,7 +186,9 @@ const SortablePhaseItem: React.FC<PhaseItemProps & { id: string }> = ({
|
||||
) : (
|
||||
<Text
|
||||
className={`text-xs font-medium cursor-pointer transition-colors select-none ${
|
||||
isDarkMode ? 'text-gray-200 hover:text-gray-100' : 'text-gray-800 hover:text-gray-900'
|
||||
isDarkMode
|
||||
? 'text-gray-200 hover:text-gray-100'
|
||||
: 'text-gray-800 hover:text-gray-900'
|
||||
}`}
|
||||
onClick={handleClick}
|
||||
title={t('rename')}
|
||||
@@ -188,9 +199,11 @@ const SortablePhaseItem: React.FC<PhaseItemProps & { id: string }> = ({
|
||||
</div>
|
||||
|
||||
{/* Hover Actions */}
|
||||
<div className={`flex items-center gap-1 transition-all duration-200 ${
|
||||
<div
|
||||
className={`flex items-center gap-1 transition-all duration-200 ${
|
||||
isHovered || isEditing ? 'opacity-100' : 'opacity-0'
|
||||
}`}>
|
||||
}`}
|
||||
>
|
||||
<Tooltip title={t('rename')}>
|
||||
<Button
|
||||
type="text"
|
||||
@@ -223,11 +236,7 @@ const SortablePhaseItem: React.FC<PhaseItemProps & { id: string }> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
projectId,
|
||||
}) => {
|
||||
const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({ open, onClose, projectId }) => {
|
||||
const { t } = useTranslation('phases-drawer');
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
@@ -270,7 +279,8 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
|
||||
}
|
||||
}, [finalProjectId, dispatch]);
|
||||
|
||||
const handleDragEnd = useCallback(async (event: DragEndEvent) => {
|
||||
const handleDragEnd = useCallback(
|
||||
async (event: DragEndEvent) => {
|
||||
if (!finalProjectId) return;
|
||||
const { active, over } = event;
|
||||
|
||||
@@ -302,7 +312,9 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
|
||||
setSorting(false);
|
||||
}
|
||||
}
|
||||
}, [finalProjectId, phaseList, dispatch, refreshTasks]);
|
||||
},
|
||||
[finalProjectId, phaseList, dispatch, refreshTasks]
|
||||
);
|
||||
|
||||
const handleCreatePhase = useCallback(async () => {
|
||||
if (!newPhaseName.trim() || !finalProjectId) return;
|
||||
@@ -318,16 +330,20 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
|
||||
}
|
||||
}, [finalProjectId, dispatch, refreshTasks, newPhaseName]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleCreatePhase();
|
||||
} else if (e.key === 'Escape') {
|
||||
setNewPhaseName('');
|
||||
setShowAddForm(false);
|
||||
}
|
||||
}, [handleCreatePhase]);
|
||||
},
|
||||
[handleCreatePhase]
|
||||
);
|
||||
|
||||
const handleRenamePhase = useCallback(async (id: string, name: string) => {
|
||||
const handleRenamePhase = useCallback(
|
||||
async (id: string, name: string) => {
|
||||
if (!finalProjectId) return;
|
||||
|
||||
try {
|
||||
@@ -350,9 +366,12 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
|
||||
} catch (error) {
|
||||
console.error('Error renaming phase:', error);
|
||||
}
|
||||
}, [finalProjectId, phaseList, dispatch, refreshTasks]);
|
||||
},
|
||||
[finalProjectId, phaseList, dispatch, refreshTasks]
|
||||
);
|
||||
|
||||
const handleDeletePhase = useCallback(async (id: string) => {
|
||||
const handleDeletePhase = useCallback(
|
||||
async (id: string) => {
|
||||
if (!finalProjectId) return;
|
||||
|
||||
AntModal.confirm({
|
||||
@@ -376,9 +395,12 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [finalProjectId, dispatch, refreshTasks, t]);
|
||||
},
|
||||
[finalProjectId, dispatch, refreshTasks, t]
|
||||
);
|
||||
|
||||
const handleColorChange = useCallback(async (id: string, color: string) => {
|
||||
const handleColorChange = useCallback(
|
||||
async (id: string, color: string) => {
|
||||
if (!finalProjectId) return;
|
||||
|
||||
try {
|
||||
@@ -397,7 +419,9 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
|
||||
} catch (error) {
|
||||
console.error('Error changing phase color:', error);
|
||||
}
|
||||
}, [finalProjectId, phaseList, dispatch, refreshTasks]);
|
||||
},
|
||||
[finalProjectId, phaseList, dispatch, refreshTasks]
|
||||
);
|
||||
|
||||
const handlePhaseNameBlur = useCallback(async () => {
|
||||
if (!finalProjectId || phaseName === initialPhaseName) return;
|
||||
@@ -427,9 +451,10 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<Title level={4} className={`m-0 font-semibold ${
|
||||
isDarkMode ? 'text-gray-100' : 'text-gray-800'
|
||||
}`}>
|
||||
<Title
|
||||
level={4}
|
||||
className={`m-0 font-semibold ${isDarkMode ? 'text-gray-100' : 'text-gray-800'}`}
|
||||
>
|
||||
{t('configure')} {phaseName || project?.phase_label || t('phasesText')}
|
||||
</Title>
|
||||
}
|
||||
@@ -445,9 +470,9 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
|
||||
},
|
||||
}}
|
||||
footer={
|
||||
<div className={`flex justify-end pt-3 ${
|
||||
isDarkMode ? 'border-gray-700' : 'border-gray-200'
|
||||
}`}>
|
||||
<div
|
||||
className={`flex justify-end pt-3 ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}
|
||||
>
|
||||
<Button
|
||||
onClick={handleClose}
|
||||
className={`font-medium ${
|
||||
@@ -465,15 +490,17 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Phase Label Configuration */}
|
||||
<div className={`p-3 rounded border transition-all duration-200 ${
|
||||
<div
|
||||
className={`p-3 rounded border transition-all duration-200 ${
|
||||
isDarkMode
|
||||
? 'bg-gray-800 border-gray-700 text-gray-300'
|
||||
: 'bg-blue-50 border-blue-200 text-blue-700'
|
||||
}`}>
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Text className={`text-xs font-medium ${
|
||||
isDarkMode ? 'text-gray-300' : 'text-blue-700'
|
||||
}`}>
|
||||
<Text
|
||||
className={`text-xs font-medium ${isDarkMode ? 'text-gray-300' : 'text-blue-700'}`}
|
||||
>
|
||||
{t('phaseLabel')}
|
||||
</Text>
|
||||
<Input
|
||||
@@ -489,30 +516,36 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Info Banner */}
|
||||
<div className={`p-3 rounded border transition-all duration-200 ${
|
||||
<div
|
||||
className={`p-3 rounded border transition-all duration-200 ${
|
||||
isDarkMode
|
||||
? 'bg-gray-800 border-gray-700 text-gray-300'
|
||||
: 'bg-blue-50 border-blue-200 text-blue-700'
|
||||
}`}>
|
||||
<Text className={`text-xs font-medium ${
|
||||
isDarkMode ? 'text-gray-300' : 'text-blue-700'
|
||||
}`}>
|
||||
🎨 Drag {(phaseName || project?.phase_label || t('phasesText')).toLowerCase()} to reorder them. Click on a {(phaseName || project?.phase_label || t('phaseText')).toLowerCase()} name to rename it. Each {(phaseName || project?.phase_label || t('phaseText')).toLowerCase()} can have a custom color.
|
||||
}`}
|
||||
>
|
||||
<Text className={`text-xs font-medium ${isDarkMode ? 'text-gray-300' : 'text-blue-700'}`}>
|
||||
🎨 Drag {(phaseName || project?.phase_label || t('phasesText')).toLowerCase()} to
|
||||
reorder them. Click on a{' '}
|
||||
{(phaseName || project?.phase_label || t('phaseText')).toLowerCase()} name to rename it.
|
||||
Each {(phaseName || project?.phase_label || t('phaseText')).toLowerCase()} can have a
|
||||
custom color.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Add New Phase Form */}
|
||||
{showAddForm && (
|
||||
<div className={`p-2 rounded border-2 border-dashed transition-all duration-200 ${
|
||||
<div
|
||||
className={`p-2 rounded border-2 border-dashed transition-all duration-200 ${
|
||||
isDarkMode
|
||||
? 'border-gray-600 bg-gray-700 hover:border-gray-500'
|
||||
: 'border-gray-300 bg-white hover:border-gray-400'
|
||||
} shadow-sm`}>
|
||||
} shadow-sm`}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder={t('enterNewPhaseName')}
|
||||
value={newPhaseName}
|
||||
onChange={(e) => setNewPhaseName(e.target.value)}
|
||||
onChange={e => setNewPhaseName(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={`flex-1 ${
|
||||
isDarkMode
|
||||
@@ -551,15 +584,17 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
|
||||
|
||||
{/* Add Phase Button */}
|
||||
{!showAddForm && (
|
||||
<div className={`p-3 rounded border-2 border-dashed transition-colors ${
|
||||
<div
|
||||
className={`p-3 rounded border-2 border-dashed transition-colors ${
|
||||
isDarkMode
|
||||
? 'border-gray-600 bg-gray-800/50 hover:border-gray-500'
|
||||
: 'border-gray-300 bg-gray-50/50 hover:border-gray-400'
|
||||
}`}>
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<Text className={`text-xs font-medium ${
|
||||
isDarkMode ? 'text-gray-300' : 'text-gray-700'
|
||||
}`}>
|
||||
<Text
|
||||
className={`text-xs font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}
|
||||
>
|
||||
{phaseName || project?.phase_label || t('phasesText')} {t('optionsText')}
|
||||
</Text>
|
||||
<Button
|
||||
@@ -583,7 +618,7 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
{phaseList.map((phase) => (
|
||||
{phaseList.map(phase => (
|
||||
<SortablePhaseItem
|
||||
key={phase.id}
|
||||
id={phase.id}
|
||||
@@ -599,11 +634,14 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
|
||||
</DndContext>
|
||||
|
||||
{phaseList.length === 0 && (
|
||||
<div className={`text-center py-8 transition-colors ${
|
||||
<div
|
||||
className={`text-center py-8 transition-colors ${
|
||||
isDarkMode ? 'text-gray-400' : 'text-gray-500'
|
||||
}`}>
|
||||
}`}
|
||||
>
|
||||
<Text className="text-sm font-medium">
|
||||
{t('no')} {(phaseName || project?.phase_label || t('phasesText')).toLowerCase()} {t('found')}
|
||||
{t('no')} {(phaseName || project?.phase_label || t('phasesText')).toLowerCase()}{' '}
|
||||
{t('found')}
|
||||
</Text>
|
||||
<br />
|
||||
<Button
|
||||
|
||||
@@ -292,7 +292,9 @@
|
||||
|
||||
/* Sortable item styling */
|
||||
.sortable-status-item {
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.sortable-status-item.is-dragging {
|
||||
@@ -340,7 +342,9 @@
|
||||
.status-item-enter-active {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
transition:
|
||||
opacity 0.3s ease,
|
||||
transform 0.3s ease;
|
||||
}
|
||||
|
||||
.status-item-exit {
|
||||
@@ -351,5 +355,7 @@
|
||||
.status-item-exit-active {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
transition:
|
||||
opacity 0.3s ease,
|
||||
transform 0.3s ease;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user