Merge pull request #298 from Worklenz/feature/holiday-calendar-integration

feat(localization): update and enhance localization files for multipl…
This commit is contained in:
Chamika J
2025-07-28 10:01:33 +05:30
committed by GitHub
315 changed files with 9955 additions and 6115 deletions

View File

@@ -7,7 +7,9 @@
"Bash(npm run:*)", "Bash(npm run:*)",
"Bash(mkdir:*)", "Bash(mkdir:*)",
"Bash(cp:*)", "Bash(cp:*)",
"Bash(ls:*)" "Bash(ls:*)",
"WebFetch(domain:www.npmjs.com)",
"WebFetch(domain:github.com)"
], ],
"deny": [] "deny": []
} }

View File

@@ -349,7 +349,7 @@ export default class HolidayController extends WorklenzControllerBase {
} }
} }
} catch (error: any) { } catch (error: any) {
errors.push(`${country.name}: ${error.message}`); errors.push(`${country.name}: ${error?.message || "Unknown error"}`);
} }
} }

View File

@@ -34,9 +34,27 @@
<link rel="dns-prefetch" href="https://js.hs-scripts.com" /> <link rel="dns-prefetch" href="https://js.hs-scripts.com" />
<!-- Preload critical resources --> <!-- Preload critical resources -->
<link rel="preload" href="/locales/en/common.json" as="fetch" type="application/json" crossorigin /> <link
<link rel="preload" href="/locales/en/auth/login.json" as="fetch" type="application/json" crossorigin /> rel="preload"
<link rel="preload" href="/locales/en/navbar.json" as="fetch" type="application/json" crossorigin /> 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 --> <!-- Optimized font loading with font-display: swap -->
<link <link

View File

@@ -66,7 +66,7 @@ class AnalyticsManager {
// Add event listener to button // Add event listener to button
const btn = notice.querySelector('#analytics-notice-btn'); const btn = notice.querySelector('#analytics-notice-btn');
btn.addEventListener('click', (e) => { btn.addEventListener('click', e => {
e.preventDefault(); e.preventDefault();
localStorage.setItem('privacyNoticeShown', 'true'); localStorage.setItem('privacyNoticeShown', 'true');
notice.remove(); notice.remove();

View File

@@ -64,7 +64,7 @@ class HubSpotManager {
const observer = new MutationObserver(applyTheme); const observer = new MutationObserver(applyTheme);
observer.observe(document.documentElement, { observer.observe(document.documentElement, {
attributes: true, attributes: true,
attributeFilter: ['class'] attributeFilter: ['class'],
}); });
} }

View File

@@ -6,7 +6,7 @@ const CACHE_NAMES = {
STATIC: `worklenz-static-${CACHE_VERSION}`, STATIC: `worklenz-static-${CACHE_VERSION}`,
DYNAMIC: `worklenz-dynamic-${CACHE_VERSION}`, DYNAMIC: `worklenz-dynamic-${CACHE_VERSION}`,
API: `worklenz-api-${CACHE_VERSION}`, API: `worklenz-api-${CACHE_VERSION}`,
IMAGES: `worklenz-images-${CACHE_VERSION}` IMAGES: `worklenz-images-${CACHE_VERSION}`,
}; };
// Resources to cache immediately on install // Resources to cache immediately on install
@@ -75,9 +75,7 @@ self.addEventListener('activate', event => {
Object.values(CACHE_NAMES).every(currentCache => currentCache !== name) Object.values(CACHE_NAMES).every(currentCache => currentCache !== name)
); );
await Promise.all( await Promise.all(oldCaches.map(cacheName => caches.delete(cacheName)));
oldCaches.map(cacheName => caches.delete(cacheName))
);
console.log('Service Worker: Old caches cleaned up'); console.log('Service Worker: Old caches cleaned up');
@@ -130,7 +128,6 @@ async function handleFetchRequest(request) {
// Everything else - Network First // Everything else - Network First
return await networkFirstStrategy(request, CACHE_NAMES.DYNAMIC); return await networkFirstStrategy(request, CACHE_NAMES.DYNAMIC);
} catch (error) { } catch (error) {
console.error('Service Worker: Fetch failed', error); console.error('Service Worker: Fetch failed', error);
return createOfflineResponse(request); return createOfflineResponse(request);
@@ -192,13 +189,15 @@ async function staleWhileRevalidateStrategy(request, cacheName) {
const cachedResponse = await cache.match(request); const cachedResponse = await cache.match(request);
// Fetch from network in background // Fetch from network in background
const networkResponsePromise = fetch(request).then(async networkResponse => { const networkResponsePromise = fetch(request)
.then(async networkResponse => {
if (networkResponse.status === 200) { if (networkResponse.status === 200) {
const responseClone = networkResponse.clone(); const responseClone = networkResponse.clone();
await cache.put(request, responseClone); await cache.put(request, responseClone);
} }
return networkResponse; return networkResponse;
}).catch(error => { })
.catch(error => {
console.warn('Stale While Revalidate: Background update failed', 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 // Helper functions to identify resource types
function isStaticAsset(url) { 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.includes('/assets/') ||
url.pathname === '/' || url.pathname === '/' ||
url.pathname === '/index.html' || url.pathname === '/index.html' ||
url.pathname === '/favicon.ico' || url.pathname === '/favicon.ico' ||
url.pathname === '/env-config.js'; url.pathname === '/env-config.js'
);
} }
function isImageRequest(url) { function isImageRequest(url) {
return /\.(png|jpg|jpeg|gif|svg|webp|ico)$/.test(url.pathname) || return (
url.pathname.includes('/file-types/'); /\.(png|jpg|jpeg|gif|svg|webp|ico)$/.test(url.pathname) || url.pathname.includes('/file-types/')
);
} }
function isAPIRequest(url) { function isAPIRequest(url) {
return url.pathname.startsWith('/api/') || return (
CACHEABLE_API_PATTERNS.some(pattern => pattern.test(url.pathname)); url.pathname.startsWith('/api/') ||
CACHEABLE_API_PATTERNS.some(pattern => pattern.test(url.pathname))
);
} }
function isHTMLRequest(request) { function isHTMLRequest(request) {
@@ -247,19 +251,22 @@ function createOfflineResponse(request) {
</svg>`; </svg>`;
return new Response(svg, { return new Response(svg, {
headers: { 'Content-Type': 'image/svg+xml' } headers: { 'Content-Type': 'image/svg+xml' },
}); });
} }
if (isAPIRequest(new URL(request.url))) { if (isAPIRequest(new URL(request.url))) {
// Return empty array or error for API requests // Return empty array or error for API requests
return new Response(JSON.stringify({ return new Response(
JSON.stringify({
error: 'Offline', error: 'Offline',
message: 'This feature requires an internet connection' message: 'This feature requires an internet connection',
}), { }),
{
status: 503, status: 503,
headers: { 'Content-Type': 'application/json' } headers: { 'Content-Type': 'application/json' },
}); }
);
} }
// For HTML requests, try to return cached index.html // For HTML requests, try to return cached index.html
@@ -294,22 +301,18 @@ self.addEventListener('push', event => {
vibrate: [200, 100, 200], vibrate: [200, 100, 200],
data: { data: {
dateOfArrival: Date.now(), dateOfArrival: Date.now(),
primaryKey: 1 primaryKey: 1,
} },
}; };
event.waitUntil( event.waitUntil(self.registration.showNotification('Worklenz', options));
self.registration.showNotification('Worklenz', options)
);
}); });
// Handle notification click events // Handle notification click events
self.addEventListener('notificationclick', event => { self.addEventListener('notificationclick', event => {
event.notification.close(); event.notification.close();
event.waitUntil( event.waitUntil(self.clients.openWindow('/'));
self.clients.openWindow('/')
);
}); });
// Message handling for communication with main thread // Message handling for communication with main thread

View File

@@ -22,7 +22,11 @@ import logger from './utils/errorLogger';
import { SuspenseFallback } from './components/suspense-fallback/suspense-fallback'; import { SuspenseFallback } from './components/suspense-fallback/suspense-fallback';
// Performance optimizations // Performance optimizations
import { CSSPerformanceMonitor, LayoutStabilizer, CriticalCSSManager } from './utils/css-optimizations'; import {
CSSPerformanceMonitor,
LayoutStabilizer,
CriticalCSSManager,
} from './utils/css-optimizations';
// Service Worker // Service Worker
import { registerSW } from './utils/serviceWorkerRegistration'; import { registerSW } from './utils/serviceWorkerRegistration';
@@ -168,19 +172,21 @@ const App: React.FC = memo(() => {
// Register service worker // Register service worker
useEffect(() => { useEffect(() => {
registerSW({ registerSW({
onSuccess: (registration) => { onSuccess: registration => {
console.log('Service Worker registered successfully', registration); console.log('Service Worker registered successfully', registration);
}, },
onUpdate: (registration) => { onUpdate: registration => {
console.log('New content is available and will be used when all tabs for this page are closed.'); 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 // You could show a toast notification here for user to refresh
}, },
onOfflineReady: () => { onOfflineReady: () => {
console.log('This web app has been cached for offline use.'); console.log('This web app has been cached for offline use.');
}, },
onError: (error) => { onError: error => {
logger.error('Service Worker registration failed:', error); logger.error('Service Worker registration failed:', error);
} },
}); });
}, []); }, []);

View File

@@ -19,6 +19,7 @@ import {
IFreePlanSettings, IFreePlanSettings,
IBillingAccountStorage, IBillingAccountStorage,
} from '@/types/admin-center/admin-center.types'; } from '@/types/admin-center/admin-center.types';
import { IOrganizationHolidaySettings } from '@/types/holiday/holiday.types';
import { IClient } from '@/types/client.types'; import { IClient } from '@/types/client.types';
import { toQueryString } from '@/utils/toQueryString'; import { toQueryString } from '@/utils/toQueryString';
@@ -292,4 +293,14 @@ export const adminCenterApiService = {
); );
return response.data; 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;
},
}; };

View File

@@ -10,65 +10,800 @@ import {
IUpdateHolidayRequest, IUpdateHolidayRequest,
IImportCountryHolidaysRequest, IImportCountryHolidaysRequest,
IHolidayCalendarEvent, IHolidayCalendarEvent,
IOrganizationHolidaySettings,
ICountryWithStates,
ICombinedHolidaysRequest,
IHolidayDateRange,
} from '@/types/holiday/holiday.types'; } from '@/types/holiday/holiday.types';
const rootUrl = `${API_BASE_URL}/holidays`; const rootUrl = `${API_BASE_URL}/holidays`;
export const holidayApiService = { export const holidayApiService = {
// Holiday types // Holiday types - PLACEHOLDER with Sri Lankan specific types
getHolidayTypes: async (): Promise<IServerResponse<IHolidayType[]>> => { getHolidayTypes: async (): Promise<IServerResponse<IHolidayType[]>> => {
const response = await apiClient.get<IServerResponse<IHolidayType[]>>(`${rootUrl}/types`); // Return holiday types including Sri Lankan specific types
return response.data; 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 // Organization holidays - PLACEHOLDER until backend implements
getOrganizationHolidays: async (year?: number): Promise<IServerResponse<IOrganizationHoliday[]>> => { getOrganizationHolidays: async (
const params = year ? `?year=${year}` : ''; year?: number
const response = await apiClient.get<IServerResponse<IOrganizationHoliday[]>>(`${rootUrl}/organization${params}`); ): Promise<IServerResponse<IOrganizationHoliday[]>> => {
return response.data; // 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>> => { createOrganizationHoliday: async (data: ICreateHolidayRequest): Promise<IServerResponse<any>> => {
const response = await apiClient.post<IServerResponse<any>>(`${rootUrl}/organization`, data); // Return success for now to prevent UI errors
return response.data; return {
done: true,
body: { id: Date.now().toString(), ...data },
} as IServerResponse<any>;
}, },
updateOrganizationHoliday: async (id: string, data: IUpdateHolidayRequest): Promise<IServerResponse<any>> => { updateOrganizationHoliday: async (
const response = await apiClient.put<IServerResponse<any>>(`${rootUrl}/organization/${id}`, data); id: string,
return response.data; 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>> => { deleteOrganizationHoliday: async (id: string): Promise<IServerResponse<any>> => {
const response = await apiClient.delete<IServerResponse<any>>(`${rootUrl}/organization/${id}`); // Return success for now to prevent UI errors
return response.data; return {
done: true,
body: {},
} as IServerResponse<any>;
}, },
// Country holidays // Country holidays - PLACEHOLDER with all date-holidays supported countries
getAvailableCountries: async (): Promise<IServerResponse<IAvailableCountry[]>> => { getAvailableCountries: async (): Promise<IServerResponse<IAvailableCountry[]>> => {
const response = await apiClient.get<IServerResponse<IAvailableCountry[]>>(`${rootUrl}/countries`); // Return all countries supported by date-holidays library (simplified list without states)
return response.data; 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[]>> => { getCountryHolidays: async (
const params = year ? `?year=${year}` : ''; countryCode: string,
const response = await apiClient.get<IServerResponse<ICountryHoliday[]>>(`${rootUrl}/countries/${countryCode}${params}`); year?: number
return response.data; ): Promise<IServerResponse<ICountryHoliday[]>> => {
// Return empty array for now
return {
done: true,
body: [],
} as IServerResponse<ICountryHoliday[]>;
}, },
importCountryHolidays: async (data: IImportCountryHolidaysRequest): Promise<IServerResponse<any>> => { importCountryHolidays: async (
const response = await apiClient.post<IServerResponse<any>>(`${rootUrl}/import`, data); data: IImportCountryHolidaysRequest
return response.data; ): Promise<IServerResponse<any>> => {
// Return success for now
return {
done: true,
body: {},
} as IServerResponse<any>;
}, },
// Calendar view // Calendar view - PLACEHOLDER until backend implements
getHolidayCalendar: async (year: number, month: number): Promise<IServerResponse<IHolidayCalendarEvent[]>> => { getHolidayCalendar: async (
const response = await apiClient.get<IServerResponse<IHolidayCalendarEvent[]>>(`${rootUrl}/calendar?year=${year}&month=${month}`); year: number,
return response.data; 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>> => { populateCountryHolidays: async (): Promise<IServerResponse<any>> => {
const response = await apiClient.post<IServerResponse<any>>(`${rootUrl}/populate`); // Return success for now
return response.data; return {
done: true,
body: { message: 'Holidays populated successfully' },
} as IServerResponse<any>;
}, },
}; };

View File

@@ -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,
};
},
};

View File

@@ -26,11 +26,7 @@ const adminCenterRoutes: RouteObject[] = [
), ),
children: adminCenterItems.map(item => ({ children: adminCenterItems.map(item => ({
path: item.endpoint, path: item.endpoint,
element: ( element: <Suspense fallback={<SuspenseFallback />}>{item.element}</Suspense>,
<Suspense fallback={<SuspenseFallback />}>
{item.element}
</Suspense>
),
})), })),
}, },
]; ];

View File

@@ -22,11 +22,7 @@ const reportingRoutes: RouteObject[] = [
element: <ReportingLayout />, element: <ReportingLayout />,
children: flattenedItems.map(item => ({ children: flattenedItems.map(item => ({
path: item.endpoint, path: item.endpoint,
element: ( element: <Suspense fallback={<SuspenseFallback />}>{item.element}</Suspense>,
<Suspense fallback={<SuspenseFallback />}>
{item.element}
</Suspense>
),
})), })),
}, },
]; ];

View File

@@ -25,7 +25,7 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
task, task,
groupId = null, groupId = null,
isDarkMode = false, isDarkMode = false,
kanbanMode = false kanbanMode = false,
}) => { }) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
@@ -63,8 +63,12 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
// Close dropdown when clicking outside and handle scroll // Close dropdown when clicking outside and handle scroll
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node) && if (
buttonRef.current && !buttonRef.current.contains(event.target as Node)) { dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
buttonRef.current &&
!buttonRef.current.contains(event.target as Node)
) {
setIsOpen(false); setIsOpen(false);
} }
}; };
@@ -74,7 +78,9 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
// Check if the button is still visible in the viewport // Check if the button is still visible in the viewport
if (buttonRef.current) { if (buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect(); 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.bottom <= window.innerHeight &&
rect.right <= window.innerWidth; rect.right <= window.innerWidth;
@@ -161,10 +167,8 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
setTeamMembers(prev => ({ setTeamMembers(prev => ({
...prev, ...prev,
data: (prev.data || []).map(member => data: (prev.data || []).map(member =>
member.id === memberId member.id === memberId ? { ...member, selected: checked } : member
? { ...member, selected: checked } ),
: member
)
})); }));
const body = { const body = {
@@ -178,12 +182,9 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
// Emit socket event - the socket handler will update Redux with proper types // Emit socket event - the socket handler will update Redux with proper types
socket?.emit(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), JSON.stringify(body)); socket?.emit(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), JSON.stringify(body));
socket?.once( socket?.once(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), (data: any) => {
SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(),
(data: any) => {
dispatch(updateEnhancedKanbanTaskAssignees(data)); dispatch(updateEnhancedKanbanTaskAssignees(data));
} });
);
// Remove from pending changes after a short delay (optimistic) // Remove from pending changes after a short delay (optimistic)
setTimeout(() => { setTimeout(() => {
@@ -198,7 +199,8 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
const checkMemberSelected = (memberId: string) => { const checkMemberSelected = (memberId: string) => {
if (!memberId) return false; if (!memberId) return false;
// Use optimistic assignees if available, otherwise fall back to task assignees // Use optimistic assignees if available, otherwise fall back to task assignees
const assignees = optimisticAssignees.length > 0 const assignees =
optimisticAssignees.length > 0
? optimisticAssignees ? optimisticAssignees
: task?.assignees?.map(assignee => assignee.team_member_id) || []; : task?.assignees?.map(assignee => assignee.team_member_id) || [];
return assignees.includes(memberId); return assignees.includes(memberId);
@@ -217,7 +219,8 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
className={` className={`
w-5 h-5 rounded-full border border-dashed flex items-center justify-center w-5 h-5 rounded-full border border-dashed flex items-center justify-center
transition-colors duration-200 transition-colors duration-200
${isOpen ${
isOpen
? isDarkMode ? isDarkMode
? 'border-blue-500 bg-blue-900/20 text-blue-400' ? 'border-blue-500 bg-blue-900/20 text-blue-400'
: 'border-blue-500 bg-blue-50 text-blue-600' : 'border-blue-500 bg-blue-50 text-blue-600'
@@ -230,16 +233,14 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
<PlusOutlined className="text-xs" /> <PlusOutlined className="text-xs" />
</button> </button>
{isOpen && createPortal( {isOpen &&
createPortal(
<div <div
ref={dropdownRef} ref={dropdownRef}
onClick={e => e.stopPropagation()} onClick={e => e.stopPropagation()}
className={` className={`
fixed z-[99999] w-72 rounded-md shadow-lg border fixed z-[99999] w-72 rounded-md shadow-lg border
${isDarkMode ${isDarkMode ? 'bg-gray-800 border-gray-600' : 'bg-white border-gray-200'}
? 'bg-gray-800 border-gray-600'
: 'bg-white border-gray-200'
}
`} `}
style={{ style={{
top: dropdownPosition.top, top: dropdownPosition.top,
@@ -252,11 +253,12 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
ref={searchInputRef} ref={searchInputRef}
type="text" type="text"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={e => setSearchQuery(e.target.value)}
placeholder="Search members..." placeholder="Search members..."
className={` className={`
w-full px-2 py-1 text-xs rounded border 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-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' : '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 */} {/* Members List */}
<div className="max-h-48 overflow-y-auto"> <div className="max-h-48 overflow-y-auto">
{filteredMembers && filteredMembers.length > 0 ? ( {filteredMembers && filteredMembers.length > 0 ? (
filteredMembers.map((member) => ( filteredMembers.map(member => (
<div <div
key={member.id} key={member.id}
className={` className={`
flex items-center gap-2 p-2 cursor-pointer transition-colors flex items-center gap-2 p-2 cursor-pointer transition-colors
${member.pending_invitation ${
member.pending_invitation
? 'opacity-50 cursor-not-allowed' ? 'opacity-50 cursor-not-allowed'
: isDarkMode : isDarkMode
? 'hover:bg-gray-700' ? 'hover:bg-gray-700'
@@ -295,18 +298,24 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
<span onClick={e => e.stopPropagation()}> <span onClick={e => e.stopPropagation()}>
<Checkbox <Checkbox
checked={checkMemberSelected(member.id || '')} checked={checkMemberSelected(member.id || '')}
onChange={(checked) => handleMemberToggle(member.id || '', checked)} onChange={checked => handleMemberToggle(member.id || '', checked)}
disabled={member.pending_invitation || pendingChanges.has(member.id || '')} disabled={
member.pending_invitation || pendingChanges.has(member.id || '')
}
isDarkMode={isDarkMode} isDarkMode={isDarkMode}
/> />
</span> </span>
{pendingChanges.has(member.id || '') && ( {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' 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' isDarkMode ? 'border-blue-400' : 'border-blue-600'
}`} /> }`}
/>
</div> </div>
)} )}
</div> </div>
@@ -319,10 +328,14 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
/> />
<div className="flex-1 min-w-0"> <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} {member.name}
</div> </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.email}
{member.pending_invitation && ( {member.pending_invitation && (
<span className="text-red-400 ml-1">(Pending)</span> <span className="text-red-400 ml-1">(Pending)</span>
@@ -332,7 +345,9 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
</div> </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 className="text-xs">No members found</div>
</div> </div>
)} )}
@@ -344,10 +359,7 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
className={` className={`
w-full flex items-center justify-center gap-1 px-2 py-1 text-xs rounded w-full flex items-center justify-center gap-1 px-2 py-1 text-xs rounded
transition-colors transition-colors
${isDarkMode ${isDarkMode ? 'text-blue-400 hover:bg-gray-700' : 'text-blue-600 hover:bg-blue-50'}
? 'text-blue-400 hover:bg-gray-700'
: 'text-blue-600 hover:bg-blue-50'
}
`} `}
onClick={handleInviteProjectMemberDrawer} onClick={handleInviteProjectMemberDrawer}
> >

View File

@@ -12,7 +12,9 @@ interface CustomNumberLabelProps {
const CustomNumberLabel = React.forwardRef<HTMLSpanElement, CustomNumberLabelProps>( const CustomNumberLabel = React.forwardRef<HTMLSpanElement, CustomNumberLabelProps>(
({ labelList, namesString, isDarkMode = false, color }, ref) => { ({ labelList, namesString, isDarkMode = false, color }, ref) => {
// Use provided color, or fall back to NumbersColorMap based on first digit // 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'; const firstDigit = namesString.match(/\d/)?.[0] || '0';
return NumbersColorMap[firstDigit] || NumbersColorMap['0']; return NumbersColorMap[firstDigit] || NumbersColorMap['0'];
})(); })();

View File

@@ -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 flex items-center gap-2 px-2 py-1 cursor-pointer transition-colors
${isDarkMode ? 'hover:bg-gray-700' : 'hover:bg-gray-50'} ${isDarkMode ? 'hover:bg-gray-700' : 'hover:bg-gray-50'}
`} `}
onClick={(e) => { onClick={e => {
e.stopPropagation(); e.stopPropagation();
handleLabelToggle(label); handleLabelToggle(label);
}} }}
@@ -281,7 +281,9 @@ const LabelsSelector: React.FC<LabelsSelectorProps> = ({ task, isDarkMode = fals
{/* Footer */} {/* Footer */}
<div className={`p-2 border-t ${isDarkMode ? 'border-gray-600' : 'border-gray-200'}`}> <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 /> <TagOutlined />
{t('manageLabelsPath')} {t('manageLabelsPath')}
</div> </div>

View File

@@ -71,32 +71,26 @@ class ModuleErrorBoundary extends Component<Props, State> {
render() { render() {
if (this.state.hasError) { if (this.state.hasError) {
return ( return (
<div style={{ <div
style={{
display: 'flex', display: 'flex',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
minHeight: '100vh', minHeight: '100vh',
padding: '20px' padding: '20px',
}}> }}
>
<Result <Result
status="error" status="error"
title="Module Loading Error" title="Module Loading Error"
subTitle="There was an issue loading the application. This usually happens after updates or during logout." subTitle="There was an issue loading the application. This usually happens after updates or during logout."
extra={[ extra={[
<Button <Button type="primary" key="retry" onClick={this.handleRetry} loading={false}>
type="primary"
key="retry"
onClick={this.handleRetry}
loading={false}
>
Retry Retry
</Button>, </Button>,
<Button <Button key="reload" onClick={() => window.location.reload()}>
key="reload"
onClick={() => window.location.reload()}
>
Reload Page Reload Page
</Button> </Button>,
]} ]}
/> />
</div> </div>

View File

@@ -77,7 +77,9 @@ export const ProjectStep: React.FC<Props> = ({ onEnter, styles, isDarkMode = fal
// Refresh user session to update setup_completed status // Refresh user session to update setup_completed status
try { try {
const authResponse = await dispatch(verifyAuthentication()).unwrap() as IAuthorizeResponse; const authResponse = (await dispatch(
verifyAuthentication()
).unwrap()) as IAuthorizeResponse;
if (authResponse?.authenticated && authResponse?.user) { if (authResponse?.authenticated && authResponse?.user) {
setSession(authResponse.user); setSession(authResponse.user);
dispatch(setUser(authResponse.user)); dispatch(setUser(authResponse.user));

View File

@@ -78,7 +78,8 @@ const CurrentPlanDetails = () => {
return options; return options;
}, []); }, []);
const handleSubscriptionAction = useCallback(async (action: SubscriptionAction) => { const handleSubscriptionAction = useCallback(
async (action: SubscriptionAction) => {
const isResume = action === 'resume'; const isResume = action === 'resume';
const setLoadingState = isResume ? setCancellingPlan : setPausingPlan; const setLoadingState = isResume ? setCancellingPlan : setPausingPlan;
const apiMethod = isResume const apiMethod = isResume
@@ -101,7 +102,9 @@ const CurrentPlanDetails = () => {
logger.error(`Error ${action}ing subscription`, error); logger.error(`Error ${action}ing subscription`, error);
setLoadingState(false); setLoadingState(false);
} }
}, [dispatch, trackMixpanelEvent]); },
[dispatch, trackMixpanelEvent]
);
const handleAddMoreSeats = useCallback(() => { const handleAddMoreSeats = useCallback(() => {
setIsMoreSeatsModalVisible(true); setIsMoreSeatsModalVisible(true);
@@ -157,10 +160,13 @@ const CurrentPlanDetails = () => {
setSelectedSeatCount(getDefaultSeatCount); setSelectedSeatCount(getDefaultSeatCount);
}, [getDefaultSeatCount]); }, [getDefaultSeatCount]);
const checkSubscriptionStatus = useCallback((allowedStatuses: string[]) => { const checkSubscriptionStatus = useCallback(
(allowedStatuses: string[]) => {
if (!billingInfo?.status || billingInfo.is_ltd_user) return false; if (!billingInfo?.status || billingInfo.is_ltd_user) return false;
return allowedStatuses.includes(billingInfo.status); return allowedStatuses.includes(billingInfo.status);
}, [billingInfo?.status, billingInfo?.is_ltd_user]); },
[billingInfo?.status, billingInfo?.is_ltd_user]
);
const shouldShowRedeemButton = useMemo(() => { const shouldShowRedeemButton = useMemo(() => {
if (billingInfo?.trial_in_progress) return true; if (billingInfo?.trial_in_progress) return true;
@@ -261,7 +267,8 @@ const CurrentPlanDetails = () => {
return today > trialExpireDate; return today > trialExpireDate;
}, [billingInfo?.trial_expire_date]); }, [billingInfo?.trial_expire_date]);
const getExpirationMessage = useCallback((expireDate: string) => { const getExpirationMessage = useCallback(
(expireDate: string) => {
const today = new Date(); const today = new Date();
today.setHours(0, 0, 0, 0); today.setHours(0, 0, 0, 0);
@@ -282,7 +289,9 @@ const CurrentPlanDetails = () => {
} else { } else {
return calculateTimeGap(expireDate); return calculateTimeGap(expireDate);
} }
}, [t]); },
[t]
);
const renderTrialDetails = useCallback(() => { const renderTrialDetails = useCallback(() => {
const isExpired = checkIfTrialExpired(); const isExpired = checkIfTrialExpired();
@@ -309,7 +318,8 @@ const CurrentPlanDetails = () => {
); );
}, [billingInfo?.trial_expire_date, checkIfTrialExpired, getExpirationMessage, t]); }, [billingInfo?.trial_expire_date, checkIfTrialExpired, getExpirationMessage, t]);
const renderFreePlan = useCallback(() => ( const renderFreePlan = useCallback(
() => (
<Flex vertical> <Flex vertical>
<Typography.Text strong>{t('freePlan')}</Typography.Text> <Typography.Text strong>{t('freePlan')}</Typography.Text>
<Typography.Text> <Typography.Text>
@@ -321,7 +331,9 @@ const CurrentPlanDetails = () => {
<br />- {freePlanSettings?.free_tier_storage} MB {t('storage')} <br />- {freePlanSettings?.free_tier_storage} MB {t('storage')}
</Typography.Text> </Typography.Text>
</Flex> </Flex>
), [freePlanSettings, t]); ),
[freePlanSettings, t]
);
const renderPaddleSubscriptionInfo = useCallback(() => { const renderPaddleSubscriptionInfo = useCallback(() => {
return ( return (
@@ -439,9 +451,7 @@ const CurrentPlanDetails = () => {
extra={renderExtra()} extra={renderExtra()}
> >
<Flex vertical> <Flex vertical>
<div style={{ marginBottom: '14px' }}> <div style={{ marginBottom: '14px' }}>{renderSubscriptionContent()}</div>
{renderSubscriptionContent()}
</div>
{shouldShowRedeemButton && ( {shouldShowRedeemButton && (
<> <>
@@ -479,9 +489,11 @@ const CurrentPlanDetails = () => {
style={{ fontSize: '16px', margin: '0 0 16px 0', fontWeight: 500 }} style={{ fontSize: '16px', margin: '0 0 16px 0', fontWeight: 500 }}
> >
{billingInfo?.total_used === 1 {billingInfo?.total_used === 1
? t('purchaseSeatsTextSingle', "Add more seats to invite team members to your workspace.") ? t(
: t('purchaseSeatsText', "To continue, you'll need to purchase additional seats.") '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>
<Typography.Paragraph style={{ margin: '0 0 16px 0' }}> <Typography.Paragraph style={{ margin: '0 0 16px 0' }}>
@@ -497,9 +509,11 @@ const CurrentPlanDetails = () => {
<Typography.Paragraph style={{ margin: '0 0 24px 0' }}> <Typography.Paragraph style={{ margin: '0 0 24px 0' }}>
{billingInfo?.total_used === 1 {billingInfo?.total_used === 1
? t('selectSeatsTextSingle', 'Select how many additional seats you need for new team members.') ? t(
: t('selectSeatsText', 'Please select the number of additional seats to purchase.') '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> </Typography.Paragraph>
<div style={{ marginBottom: '24px' }}> <div style={{ marginBottom: '24px' }}>
@@ -511,7 +525,6 @@ const CurrentPlanDetails = () => {
options={seatCountOptions} options={seatCountOptions}
style={{ width: '300px' }} style={{ width: '300px' }}
/> />
</div> </div>
<Flex justify="end"> <Flex justify="end">

View File

@@ -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 React, { useState } from 'react';
import './upgrade-plans-lkr.css'; import './upgrade-plans-lkr.css';
import { CheckCircleFilled } from '@/shared/antd-imports'; import { CheckCircleFilled } from '@/shared/antd-imports';

View File

@@ -516,7 +516,9 @@ const UpgradePlans = () => {
> >
{billingInfo?.status === SUBSCRIPTION_STATUS.ACTIVE {billingInfo?.status === SUBSCRIPTION_STATUS.ACTIVE
? t('changeToPlan', 'Change to {{plan}}', { plan: t('annualPlan', 'Annual Plan') }) ? 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> </Button>
)} )}
{selectedPlan === paddlePlans.MONTHLY && ( {selectedPlan === paddlePlans.MONTHLY && (
@@ -529,7 +531,9 @@ const UpgradePlans = () => {
> >
{billingInfo?.status === SUBSCRIPTION_STATUS.ACTIVE {billingInfo?.status === SUBSCRIPTION_STATUS.ACTIVE
? t('changeToPlan', 'Change to {{plan}}', { plan: t('monthlyPlan', 'Monthly Plan') }) ? 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> </Button>
)} )}
</Row> </Row>

View File

@@ -17,6 +17,7 @@
border: 1px solid #f0f0f0; border: 1px solid #f0f0f0;
border-radius: 6px; border-radius: 6px;
transition: all 0.3s; transition: all 0.3s;
cursor: pointer;
} }
.holiday-calendar.dark .ant-picker-calendar-date { .holiday-calendar.dark .ant-picker-calendar-date {
@@ -62,6 +63,12 @@
white-space: nowrap; white-space: nowrap;
border: none; border: none;
font-weight: 500; 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 { .holiday-calendar .ant-picker-calendar-date-today {
@@ -211,7 +218,10 @@
/* Card styles */ /* Card styles */
.holiday-calendar .ant-card { .holiday-calendar .ant-card {
border-radius: 8px; 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 { .holiday-calendar.dark .ant-card {

View File

@@ -1,16 +1,34 @@
import React, { useEffect, useState } from 'react'; 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 {
import { PlusOutlined, DeleteOutlined, EditOutlined, GlobalOutlined } from '@ant-design/icons'; 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 { useTranslation } from 'react-i18next';
import dayjs, { Dayjs } from 'dayjs'; import dayjs, { Dayjs } from 'dayjs';
import { holidayApiService } from '@/api/holiday/holiday.api.service'; import { holidayApiService } from '@/api/holiday/holiday.api.service';
import { import {
IHolidayType, IHolidayType,
IOrganizationHoliday,
IAvailableCountry,
ICreateHolidayRequest, ICreateHolidayRequest,
IUpdateHolidayRequest, IUpdateHolidayRequest,
IHolidayCalendarEvent,
} from '@/types/holiday/holiday.types'; } 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 logger from '@/utils/errorLogger';
import './holiday-calendar.css'; import './holiday-calendar.css';
@@ -24,17 +42,17 @@ interface HolidayCalendarProps {
const HolidayCalendar: React.FC<HolidayCalendarProps> = ({ themeMode }) => { const HolidayCalendar: React.FC<HolidayCalendarProps> = ({ themeMode }) => {
const { t } = useTranslation('admin-center/overview'); const { t } = useTranslation('admin-center/overview');
const dispatch = useAppDispatch();
const { holidays, loadingHolidays, holidaySettings } = useAppSelector(
(state: RootState) => state.adminCenterReducer
);
const [form] = Form.useForm(); const [form] = Form.useForm();
const [editForm] = Form.useForm(); const [editForm] = Form.useForm();
const [holidayTypes, setHolidayTypes] = useState<IHolidayType[]>([]); 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 [modalVisible, setModalVisible] = useState(false);
const [editModalVisible, setEditModalVisible] = useState(false); const [editModalVisible, setEditModalVisible] = useState(false);
const [importModalVisible, setImportModalVisible] = useState(false); const [selectedHoliday, setSelectedHoliday] = useState<IHolidayCalendarEvent | null>(null);
const [selectedHoliday, setSelectedHoliday] = useState<IOrganizationHoliday | null>(null);
const [currentDate, setCurrentDate] = useState<Dayjs>(dayjs()); const [currentDate, setCurrentDate] = useState<Dayjs>(dayjs());
const fetchHolidayTypes = async () => { const fetchHolidayTypes = async () => {
@@ -48,37 +66,28 @@ const HolidayCalendar: React.FC<HolidayCalendarProps> = ({ themeMode }) => {
} }
}; };
const fetchOrganizationHolidays = async () => { const fetchHolidaysForDateRange = () => {
setLoading(true); const startOfYear = currentDate.startOf('year');
try { const endOfYear = currentDate.endOf('year');
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 fetchAvailableCountries = async () => { dispatch(
try { fetchHolidays({
const res = await holidayApiService.getAvailableCountries(); from_date: startOfYear.format('YYYY-MM-DD'),
if (res.done) { to_date: endOfYear.format('YYYY-MM-DD'),
setAvailableCountries(res.body); include_custom: true,
} })
} catch (error) { );
logger.error('Error fetching available countries', error);
}
}; };
useEffect(() => { useEffect(() => {
fetchHolidayTypes(); fetchHolidayTypes();
fetchOrganizationHolidays(); fetchHolidaysForDateRange();
fetchAvailableCountries();
}, [currentDate.year()]); }, [currentDate.year()]);
const customHolidays = useMemo(() => {
return holidays.filter(holiday => holiday.source === 'custom');
}, [holidays]);
const handleCreateHoliday = async (values: any) => { const handleCreateHoliday = async (values: any) => {
try { try {
const holidayData: ICreateHolidayRequest = { const holidayData: ICreateHolidayRequest = {
@@ -94,7 +103,7 @@ const HolidayCalendar: React.FC<HolidayCalendarProps> = ({ themeMode }) => {
message.success(t('holidayCreated')); message.success(t('holidayCreated'));
setModalVisible(false); setModalVisible(false);
form.resetFields(); form.resetFields();
fetchOrganizationHolidays(); fetchHolidaysForDateRange();
} }
} catch (error) { } catch (error) {
logger.error('Error creating holiday', error); logger.error('Error creating holiday', error);
@@ -115,13 +124,16 @@ const HolidayCalendar: React.FC<HolidayCalendarProps> = ({ themeMode }) => {
is_recurring: values.is_recurring, is_recurring: values.is_recurring,
}; };
const res = await holidayApiService.updateOrganizationHoliday(selectedHoliday.id, holidayData); const res = await holidayApiService.updateOrganizationHoliday(
selectedHoliday.id,
holidayData
);
if (res.done) { if (res.done) {
message.success(t('holidayUpdated')); message.success(t('holidayUpdated'));
setEditModalVisible(false); setEditModalVisible(false);
editForm.resetFields(); editForm.resetFields();
setSelectedHoliday(null); setSelectedHoliday(null);
fetchOrganizationHolidays(); fetchHolidaysForDateRange();
} }
} catch (error) { } catch (error) {
logger.error('Error updating holiday', error); logger.error('Error updating holiday', error);
@@ -134,7 +146,7 @@ const HolidayCalendar: React.FC<HolidayCalendarProps> = ({ themeMode }) => {
const res = await holidayApiService.deleteOrganizationHoliday(holidayId); const res = await holidayApiService.deleteOrganizationHoliday(holidayId);
if (res.done) { if (res.done) {
message.success(t('holidayDeleted')); message.success(t('holidayDeleted'));
fetchOrganizationHolidays(); fetchHolidaysForDateRange();
} }
} catch (error) { } catch (error) {
logger.error('Error deleting holiday', error); logger.error('Error deleting holiday', error);
@@ -142,53 +154,47 @@ const HolidayCalendar: React.FC<HolidayCalendarProps> = ({ themeMode }) => {
} }
}; };
const handleImportCountryHolidays = async (values: any) => { const handleEditHoliday = (holiday: IHolidayCalendarEvent) => {
try { // Only allow editing custom holidays
const res = await holidayApiService.importCountryHolidays({ if (holiday.source !== 'custom' || !holiday.is_editable) {
country_code: values.country_code, message.warning(t('cannotEditOfficialHoliday') || 'Cannot edit official holidays');
year: values.year || currentDate.year(), return;
});
if (res.done) {
message.success(t('holidaysImported', { count: res.body.imported_count }));
setImportModalVisible(false);
fetchOrganizationHolidays();
} }
} catch (error) {
logger.error('Error importing country holidays', error);
message.error(t('errorImportingHolidays'));
}
};
const handleEditHoliday = (holiday: IOrganizationHoliday) => {
setSelectedHoliday(holiday); setSelectedHoliday(holiday);
editForm.setFieldsValue({ editForm.setFieldsValue({
name: holiday.name, name: holiday.name,
description: holiday.description, description: holiday.description,
date: dayjs(holiday.date), 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, is_recurring: holiday.is_recurring,
}); });
setEditModalVisible(true); setEditModalVisible(true);
}; };
const getHolidayDateCellRender = (date: Dayjs) => { 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) { if (dateHolidays.length > 0) {
const holidayType = holidayTypes.find(ht => ht.id === holiday.holiday_type_id);
return ( return (
<div className="holiday-cell"> <div className="holiday-cell">
{dateHolidays.map((holiday, index) => (
<Tag <Tag
color={holidayType?.color_code || '#f37070'} key={`${holiday.id}-${index}`}
color={holiday.color_code || (holiday.source === 'official' ? '#1890ff' : '#f37070')}
style={{ style={{
fontSize: '10px', fontSize: '10px',
padding: '1px 4px', padding: '1px 4px',
margin: 0, margin: '1px 0',
borderRadius: '2px' borderRadius: '2px',
display: 'block',
opacity: holiday.source === 'official' ? 0.8 : 1,
}} }}
title={`${holiday.name}${holiday.source === 'official' ? ' (Official)' : ' (Custom)'}`}
> >
{holiday.name} {holiday.name}
</Tag> </Tag>
))}
</div> </div>
); );
} }
@@ -199,36 +205,61 @@ const HolidayCalendar: React.FC<HolidayCalendarProps> = ({ themeMode }) => {
setCurrentDate(value); 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 ( return (
<Card> <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 }}> <Title level={5} style={{ margin: 0 }}>
{t('holidayCalendar')} {t('holidayCalendar')}
</Title> </Title>
<Space> <Space>
<Button
icon={<GlobalOutlined />}
onClick={() => setImportModalVisible(true)}
size="small"
>
{t('importCountryHolidays')}
</Button>
<Button <Button
type="primary" type="primary"
icon={<PlusOutlined />} icon={<PlusOutlined />}
onClick={() => setModalVisible(true)} onClick={() => setModalVisible(true)}
size="small" size="small"
> >
{t('addHoliday')} {t('addCustomHoliday') || 'Add Custom Holiday'}
</Button> </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> </Space>
</div> </div>
<Calendar <Calendar
value={currentDate} value={currentDate}
onPanelChange={onPanelChange} onPanelChange={onPanelChange}
onSelect={onDateSelect}
dateCellRender={getHolidayDateCellRender} dateCellRender={getHolidayDateCellRender}
className={`holiday-calendar ${themeMode}`} className={`holiday-calendar ${themeMode}`}
loading={loadingHolidays}
/> />
{/* Create Holiday Modal */} {/* Create Holiday Modal */}
@@ -278,7 +309,7 @@ const HolidayCalendar: React.FC<HolidayCalendarProps> = ({ themeMode }) => {
height: 12, height: 12,
borderRadius: '50%', borderRadius: '50%',
backgroundColor: type.color_code, backgroundColor: type.color_code,
marginRight: 8 marginRight: 8,
}} }}
/> />
{type.name} {type.name}
@@ -297,10 +328,12 @@ const HolidayCalendar: React.FC<HolidayCalendarProps> = ({ themeMode }) => {
<Button type="primary" htmlType="submit"> <Button type="primary" htmlType="submit">
{t('save')} {t('save')}
</Button> </Button>
<Button onClick={() => { <Button
onClick={() => {
setModalVisible(false); setModalVisible(false);
form.resetFields(); form.resetFields();
}}> }}
>
{t('cancel')} {t('cancel')}
</Button> </Button>
</Space> </Space>
@@ -356,7 +389,7 @@ const HolidayCalendar: React.FC<HolidayCalendarProps> = ({ themeMode }) => {
height: 12, height: 12,
borderRadius: '50%', borderRadius: '50%',
backgroundColor: type.color_code, backgroundColor: type.color_code,
marginRight: 8 marginRight: 8,
}} }}
/> />
{type.name} {type.name}
@@ -375,51 +408,13 @@ const HolidayCalendar: React.FC<HolidayCalendarProps> = ({ themeMode }) => {
<Button type="primary" htmlType="submit"> <Button type="primary" htmlType="submit">
{t('update')} {t('update')}
</Button> </Button>
<Button onClick={() => { <Button
onClick={() => {
setEditModalVisible(false); setEditModalVisible(false);
editForm.resetFields(); editForm.resetFields();
setSelectedHoliday(null); 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')} {t('cancel')}
</Button> </Button>
</Space> </Space>

View File

@@ -15,7 +15,7 @@ interface OrganizationCalculationMethodProps {
const OrganizationCalculationMethod: React.FC<OrganizationCalculationMethodProps> = ({ const OrganizationCalculationMethod: React.FC<OrganizationCalculationMethodProps> = ({
organization, organization,
refetch refetch,
}) => { }) => {
const { t } = useTranslation('admin-center/overview'); const { t } = useTranslation('admin-center/overview');
const [updating, setUpdating] = useState(false); const [updating, setUpdating] = useState(false);

View File

@@ -6,7 +6,16 @@ import { useAppDispatch } from '@/hooks/useAppDispatch';
import { IOrganizationTeam } from '@/types/admin-center/admin-center.types'; import { IOrganizationTeam } from '@/types/admin-center/admin-center.types';
import logger from '@/utils/errorLogger'; import logger from '@/utils/errorLogger';
import { SettingOutlined, DeleteOutlined } from '@/shared/antd-imports'; 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 { TFunction } from 'i18next';
import { useState } from 'react'; import { useState } from 'react';
import { useMediaQuery } from 'react-responsive'; import { useMediaQuery } from 'react-responsive';

View File

@@ -34,7 +34,8 @@ const renderAvatar = (member: InlineMember, index: number, allowClickThrough: bo
</Tooltip> </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; const visibleMembers = maxCount ? members.slice(0, maxCount) : members;
return ( return (
<div onClick={allowClickThrough ? undefined : (e: React.MouseEvent) => e.stopPropagation()}> <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> </Avatar.Group>
</div> </div>
); );
}); }
);
Avatars.displayName = 'Avatars'; Avatars.displayName = 'Avatars';

View File

@@ -1,5 +1,14 @@
import React, { useEffect, useState } from 'react'; 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 StatusDropdown from '../../taskListCommon/statusDropdown/StatusDropdown';
import dayjs, { Dayjs } from 'dayjs'; import dayjs, { Dayjs } from 'dayjs';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';

View File

@@ -25,15 +25,17 @@ const LazyGanttChart = lazy(() =>
// Chart loading fallback // Chart loading fallback
const ChartLoadingFallback = () => ( const ChartLoadingFallback = () => (
<div style={{ <div
style={{
display: 'flex', display: 'flex',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
height: '300px', height: '300px',
background: '#fafafa', background: '#fafafa',
borderRadius: '8px', borderRadius: '8px',
border: '1px solid #f0f0f0' border: '1px solid #f0f0f0',
}}> }}
>
<Spin size="large" /> <Spin size="large" />
</div> </div>
); );

View File

@@ -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 { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch'; import { useAppDispatch } from '@/hooks/useAppDispatch';
import { import {

View 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;

View File

@@ -8,7 +8,15 @@ import ImprovedTaskFilters from '../../task-management/improved-task-filters';
import Card from 'antd/es/card'; import Card from 'antd/es/card';
import Spin from 'antd/es/spin'; import Spin from 'antd/es/spin';
import Empty from 'antd/es/empty'; 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 { fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice';
import { useAppSelector } from '@/hooks/useAppSelector'; import { useAppSelector } from '@/hooks/useAppSelector';
import KanbanGroup from './KanbanGroup'; import KanbanGroup from './KanbanGroup';
@@ -29,18 +37,18 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project
const project = useAppSelector((state: RootState) => state.projectReducer.project); const project = useAppSelector((state: RootState) => state.projectReducer.project);
const groupBy = useSelector((state: RootState) => state.enhancedKanbanReducer.groupBy); const groupBy = useSelector((state: RootState) => state.enhancedKanbanReducer.groupBy);
const teamId = authService.getCurrentSession()?.team_id; const teamId = authService.getCurrentSession()?.team_id;
const { const { taskGroups, loadingGroups, error } = useSelector(
taskGroups, (state: RootState) => state.enhancedKanbanReducer
loadingGroups, );
error,
} = useSelector((state: RootState) => state.enhancedKanbanReducer);
const [draggedGroupId, setDraggedGroupId] = useState<string | null>(null); const [draggedGroupId, setDraggedGroupId] = useState<string | null>(null);
const [draggedTaskId, setDraggedTaskId] = useState<string | null>(null); const [draggedTaskId, setDraggedTaskId] = useState<string | null>(null);
const [draggedTaskGroupId, setDraggedTaskGroupId] = useState<string | null>(null); const [draggedTaskGroupId, setDraggedTaskGroupId] = useState<string | null>(null);
const [hoveredGroupId, setHoveredGroupId] = useState<string | null>(null); const [hoveredGroupId, setHoveredGroupId] = useState<string | null>(null);
const [hoveredTaskIdx, setHoveredTaskIdx] = useState<number | null>(null); const [hoveredTaskIdx, setHoveredTaskIdx] = useState<number | null>(null);
const [dragType, setDragType] = useState<'group' | 'task' | 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 // Set up socket event handlers for real-time updates
useTaskSocketHandlers(); useTaskSocketHandlers();
@@ -89,7 +97,9 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project
const [moved] = reorderedGroups.splice(fromIdx, 1); const [moved] = reorderedGroups.splice(fromIdx, 1);
reorderedGroups.splice(toIdx, 0, moved); reorderedGroups.splice(toIdx, 0, moved);
dispatch(reorderGroups({ fromIndex: fromIdx, toIndex: toIdx, reorderedGroups })); 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 // API call for group order
try { try {
@@ -101,7 +111,9 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project
const revertedGroups = [...reorderedGroups]; const revertedGroups = [...reorderedGroups];
const [movedBackGroup] = revertedGroups.splice(toIdx, 1); const [movedBackGroup] = revertedGroups.splice(toIdx, 1);
revertedGroups.splice(fromIdx, 0, movedBackGroup); 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'); alertService.error('Failed to update column order', 'Please try again');
} }
} catch (error) { } catch (error) {
@@ -109,7 +121,9 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project
const revertedGroups = [...reorderedGroups]; const revertedGroups = [...reorderedGroups];
const [movedBackGroup] = revertedGroups.splice(toIdx, 1); const [movedBackGroup] = revertedGroups.splice(toIdx, 1);
revertedGroups.splice(fromIdx, 0, movedBackGroup); 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'); alertService.error('Failed to update column order', 'Please try again');
logger.error('Failed to update column order', error); logger.error('Failed to update column order', error);
} }
@@ -135,12 +149,17 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project
setHoveredTaskIdx(0); setHoveredTaskIdx(0);
} else { } else {
setHoveredTaskIdx(taskIdx); setHoveredTaskIdx(taskIdx);
}
}; };
}; const handleTaskDrop = async (
const handleTaskDrop = async (e: React.DragEvent, targetGroupId: string, targetTaskIdx: number | null) => { e: React.DragEvent,
targetGroupId: string,
targetTaskIdx: number | null
) => {
if (dragType !== 'task') return; if (dragType !== 'task') return;
e.preventDefault(); e.preventDefault();
if (!draggedTaskId || !draggedTaskGroupId || hoveredGroupId === null || hoveredTaskIdx === null) return; if (!draggedTaskId || !draggedTaskGroupId || hoveredGroupId === null || hoveredTaskIdx === null)
return;
// Calculate new order and dispatch // Calculate new order and dispatch
const sourceGroup = taskGroups.find(g => g.id === draggedTaskGroupId); 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 updatedTasks.splice(insertIdx, 0, movedTask); // Insert at new position
dispatch(reorderTasks({ dispatch(
reorderTasks({
activeGroupId: sourceGroup.id, activeGroupId: sourceGroup.id,
overGroupId: targetGroup.id, overGroupId: targetGroup.id,
fromIndex: taskIdx, fromIndex: taskIdx,
@@ -191,8 +211,10 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project
task: movedTask, task: movedTask,
updatedSourceTasks: updatedTasks, updatedSourceTasks: updatedTasks,
updatedTargetTasks: updatedTasks, updatedTargetTasks: updatedTasks,
})); })
dispatch(reorderEnhancedKanbanTasks({ );
dispatch(
reorderEnhancedKanbanTasks({
activeGroupId: sourceGroup.id, activeGroupId: sourceGroup.id,
overGroupId: targetGroup.id, overGroupId: targetGroup.id,
fromIndex: taskIdx, fromIndex: taskIdx,
@@ -200,7 +222,8 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project
task: movedTask, task: movedTask,
updatedSourceTasks: updatedTasks, updatedSourceTasks: updatedTasks,
updatedTargetTasks: updatedTasks, updatedTargetTasks: updatedTasks,
}) as any); }) as any
);
} else { } else {
// Handle cross-group reordering // Handle cross-group reordering
const updatedSourceTasks = [...sourceGroup.tasks]; const updatedSourceTasks = [...sourceGroup.tasks];
@@ -211,7 +234,8 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project
if (insertIdx > updatedTargetTasks.length) insertIdx = updatedTargetTasks.length; if (insertIdx > updatedTargetTasks.length) insertIdx = updatedTargetTasks.length;
updatedTargetTasks.splice(insertIdx, 0, movedTask); updatedTargetTasks.splice(insertIdx, 0, movedTask);
dispatch(reorderTasks({ dispatch(
reorderTasks({
activeGroupId: sourceGroup.id, activeGroupId: sourceGroup.id,
overGroupId: targetGroup.id, overGroupId: targetGroup.id,
fromIndex: taskIdx, fromIndex: taskIdx,
@@ -219,8 +243,10 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project
task: movedTask, task: movedTask,
updatedSourceTasks, updatedSourceTasks,
updatedTargetTasks, updatedTargetTasks,
})); })
dispatch(reorderEnhancedKanbanTasks({ );
dispatch(
reorderEnhancedKanbanTasks({
activeGroupId: sourceGroup.id, activeGroupId: sourceGroup.id,
overGroupId: targetGroup.id, overGroupId: targetGroup.id,
fromIndex: taskIdx, fromIndex: taskIdx,
@@ -228,7 +254,8 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project
task: movedTask, task: movedTask,
updatedSourceTasks, updatedSourceTasks,
updatedTargetTasks, updatedTargetTasks,
}) as any); }) as any
);
} }
// Socket emit for task order // Socket emit for task order
@@ -306,10 +333,22 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project
<div className="enhanced-kanban-board"> <div className="enhanced-kanban-board">
{loadingGroups ? ( {loadingGroups ? (
<div className="flex flex-row gap-2 h-[600px]"> <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
<div className="rounded bg-gray-200 dark:bg-gray-700 animate-pulse w-1/4" style={{ height: '100%' }} /> className="rounded bg-gray-200 dark:bg-gray-700 animate-pulse w-1/4"
<div className="rounded bg-gray-200 dark:bg-gray-700 animate-pulse w-1/4" style={{ height: '80%' }} /> style={{ height: '60%' }}
<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: '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> </div>
) : taskGroups.length === 0 ? ( ) : taskGroups.length === 0 ? (
<Card> <Card>

View File

@@ -45,7 +45,8 @@ interface KanbanGroupProps {
hoveredGroupId: string | null; hoveredGroupId: string | null;
} }
const KanbanGroup: React.FC<KanbanGroupProps> = memo(({ const KanbanGroup: React.FC<KanbanGroupProps> = memo(
({
group, group,
onGroupDragStart, onGroupDragStart,
onGroupDragOver, onGroupDragOver,
@@ -55,8 +56,8 @@ const KanbanGroup: React.FC<KanbanGroupProps> = memo(({
onTaskDrop, onTaskDrop,
onDragEnd, onDragEnd,
hoveredTaskIdx, hoveredTaskIdx,
hoveredGroupId hoveredGroupId,
}) => { }) => {
const [isHover, setIsHover] = useState<boolean>(false); const [isHover, setIsHover] = useState<boolean>(false);
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin(); const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
const [isEditable, setIsEditable] = useState(false); const [isEditable, setIsEditable] = useState(false);
@@ -240,8 +241,7 @@ const KanbanGroup: React.FC<KanbanGroupProps> = memo(({
}, [showDropdown]); }, [showDropdown]);
return ( return (
<div className="enhanced-kanban-group" style={{ position: 'relative' }} <div className="enhanced-kanban-group" style={{ position: 'relative' }}>
>
{/* Background layer - z-index 0 */} {/* Background layer - z-index 0 */}
<div <div
className="enhanced-kanban-group-background" className="enhanced-kanban-group-background"
@@ -253,10 +253,16 @@ const KanbanGroup: React.FC<KanbanGroupProps> = memo(({
height: '100%', height: '100%',
border: `0.1px solid ${themeMode === 'dark' ? '#404040' : '#e0e0e0'}`, border: `0.1px solid ${themeMode === 'dark' ? '#404040' : '#e0e0e0'}`,
borderRadius: '8px', 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 */} {/* Content layer - z-index 1 */}
@@ -295,7 +301,8 @@ const KanbanGroup: React.FC<KanbanGroupProps> = memo(({
<input <input
ref={inputRef} ref={inputRef}
value={name} 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} onChange={handleChange}
onBlur={handleBlur} onBlur={handleBlur}
@@ -309,7 +316,8 @@ const KanbanGroup: React.FC<KanbanGroupProps> = memo(({
/> />
) : ( ) : (
<div <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} title={isEllipsisActive ? name : undefined}
onMouseDown={e => { onMouseDown={e => {
@@ -337,8 +345,18 @@ const KanbanGroup: React.FC<KanbanGroupProps> = memo(({
setShowNewCardBottom(false); setShowNewCardBottom(false);
}} }}
> >
<svg className="w-4 h-4 text-gray-800" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /> 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> </svg>
</button> </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" className="w-7 h-7 flex items-center justify-center rounded-full hover:bg-black/10 transition-colors"
onClick={() => setShowDropdown(!showDropdown)} onClick={() => setShowDropdown(!showDropdown)}
> >
<svg className="w-4 h-4 text-gray-800 rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<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" /> 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> </svg>
</button> </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" 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} onClick={handleRename}
> >
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<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" /> 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> </svg>
{t('rename')} {t('rename')}
</button> </button>
@@ -384,7 +422,9 @@ const KanbanGroup: React.FC<KanbanGroupProps> = memo(({
className="w-3 h-3 rounded-full" className="w-3 h-3 rounded-full"
style={{ backgroundColor: status.color_code }} style={{ backgroundColor: status.color_code }}
></div> ></div>
<span className={group.category_id === status.id ? 'font-bold' : ''}> <span
className={group.category_id === status.id ? 'font-bold' : ''}
>
{status.name} {status.name}
</span> </span>
</button> </button>
@@ -402,8 +442,18 @@ const KanbanGroup: React.FC<KanbanGroupProps> = memo(({
handleDelete(); handleDelete();
}} }}
> >
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<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" /> 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> </svg>
{t('delete')} {t('delete')}
</button> </button>
@@ -432,12 +482,24 @@ const KanbanGroup: React.FC<KanbanGroupProps> = memo(({
<div className="p-4"> <div className="p-4">
<div className="flex items-center gap-3 mb-3"> <div className="flex items-center gap-3 mb-3">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<svg className="w-5 h-5 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<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" /> 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> </svg>
</div> </div>
<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')} {t('deleteConfirmationTitle')}
</h3> </h3>
</div> </div>
@@ -445,7 +507,8 @@ const KanbanGroup: React.FC<KanbanGroupProps> = memo(({
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<button <button
type="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-600 text-gray-300 hover:bg-gray-600'
: 'border-gray-300 text-gray-700 hover:bg-gray-50' : '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 */} {/* 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 <div
className="empty-drop-zone" className="empty-drop-zone"
style={{ style={{
@@ -500,10 +566,18 @@ const KanbanGroup: React.FC<KanbanGroupProps> = memo(({
color: '#888', color: '#888',
fontStyle: 'italic', fontStyle: 'italic',
}} }}
onDragOver={e => { e.preventDefault(); onTaskDragOver(e, group.id, 0); }} onDragOver={e => {
onDrop={e => { e.preventDefault(); onTaskDrop(e, group.id, 0); }} 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 <button
type="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" 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); setShowNewCardTop(true);
}} }}
> >
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /> 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> </svg>
{t('addTask')} {t('addTask')}
</button> </button>
)} )}
</div> </div>
) )}
}
{/* Drop indicator at the top of the group */} {/* Drop indicator at the top of the group */}
{hoveredGroupId === group.id && hoveredTaskIdx === 0 && ( {hoveredGroupId === group.id && hoveredTaskIdx === 0 && (
@@ -538,12 +620,15 @@ const KanbanGroup: React.FC<KanbanGroupProps> = memo(({
onDragOver={e => onTaskDragOver(e, group.id, idx)} onDragOver={e => onTaskDragOver(e, group.id, idx)}
onDrop={e => onTaskDrop(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, height: 80,
background: themeMode === 'dark' ? '#2a2a2a' : '#E2EAF4', background: themeMode === 'dark' ? '#2a2a2a' : '#E2EAF4',
borderRadius: 6, borderRadius: 6,
border: `5px` border: `5px`,
}}></div> }}
></div>
</div> </div>
)} )}
<TaskCard <TaskCard
@@ -563,12 +648,15 @@ const KanbanGroup: React.FC<KanbanGroupProps> = memo(({
onDragOver={e => onTaskDragOver(e, group.id, group.tasks.length)} onDragOver={e => onTaskDragOver(e, group.id, group.tasks.length)}
onDrop={e => onTaskDrop(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, height: 80,
background: themeMode === 'dark' ? '#2a2a2a' : '#E2EAF4', background: themeMode === 'dark' ? '#2a2a2a' : '#E2EAF4',
borderRadius: 6, borderRadius: 6,
border: `5px` border: `5px`,
}}></div> }}
></div>
</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"> <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> </svg>
{t('addTask')} {t('addTask')}
</button> </button>
@@ -601,7 +694,8 @@ const KanbanGroup: React.FC<KanbanGroupProps> = memo(({
</div> </div>
</div> </div>
); );
}); }
);
KanbanGroup.displayName = 'KanbanGroup'; KanbanGroup.displayName = 'KanbanGroup';

View File

@@ -14,7 +14,10 @@ import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events'; import { SocketEvents } from '@/shared/socket-events';
import { getUserSession } from '@/utils/session-helper'; import { getUserSession } from '@/utils/session-helper';
import { themeWiseColor } from '@/utils/themeWiseColor'; 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'; import TaskProgressCircle from './TaskProgressCircle';
// Simple Portal component // Simple Portal component
@@ -41,15 +44,16 @@ function getFirstDayOfWeek(year: number, month: number) {
return new Date(year, month, 1).getDay(); return new Date(year, month, 1).getDay();
} }
const TaskCard: React.FC<TaskCardProps> = memo(({ const TaskCard: React.FC<TaskCardProps> = memo(
({
task, task,
onTaskDragStart, onTaskDragStart,
onTaskDragOver, onTaskDragOver,
onTaskDrop, onTaskDrop,
groupId, groupId,
idx, idx,
onDragEnd // <-- add this onDragEnd, // <-- add this
}) => { }) => {
const { socket } = useSocket(); const { socket } = useSocket();
const themeMode = useSelector((state: RootState) => state.themeReducer.mode); const themeMode = useSelector((state: RootState) => state.themeReducer.mode);
const { projectId } = useSelector((state: RootState) => state.projectReducer); const { projectId } = useSelector((state: RootState) => state.projectReducer);
@@ -65,7 +69,9 @@ const TaskCard: React.FC<TaskCardProps> = memo(({
const [isUpdating, setIsUpdating] = useState(false); const [isUpdating, setIsUpdating] = useState(false);
const datePickerRef = useRef<HTMLDivElement>(null); const datePickerRef = useRef<HTMLDivElement>(null);
const dateButtonRef = 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 [calendarMonth, setCalendarMonth] = useState(() => {
const d = selectedDate || new Date(); const d = selectedDate || new Date();
return new Date(d.getFullYear(), d.getMonth(), 1); return new Date(d.getFullYear(), d.getMonth(), 1);
@@ -102,11 +108,14 @@ const TaskCard: React.FC<TaskCardProps> = memo(({
} }
}, [showDatePicker]); }, [showDatePicker]);
const handleCardClick = useCallback((e: React.MouseEvent, id: string) => { const handleCardClick = useCallback(
(e: React.MouseEvent, id: string) => {
e.stopPropagation(); e.stopPropagation();
dispatch(setSelectedTaskId(id)); dispatch(setSelectedTaskId(id));
dispatch(setShowTaskDrawer(true)); dispatch(setShowTaskDrawer(true));
}, [dispatch]); },
[dispatch]
);
const handleDateClick = useCallback((e: React.MouseEvent) => { const handleDateClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
@@ -162,7 +171,12 @@ const TaskCard: React.FC<TaskCardProps> = memo(({
const handleSubTaskExpand = useCallback(() => { const handleSubTaskExpand = useCallback(() => {
if (task && task.id && projectId) { 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)); dispatch(toggleTaskExpansion(task.id));
} else if (task.sub_tasks_count && task.sub_tasks_count > 0) { } else if (task.sub_tasks_count && task.sub_tasks_count > 0) {
dispatch(toggleTaskExpansion(task.id)); dispatch(toggleTaskExpansion(task.id));
@@ -173,10 +187,13 @@ const TaskCard: React.FC<TaskCardProps> = memo(({
} }
}, [task, projectId, dispatch]); }, [task, projectId, dispatch]);
const handleSubtaskButtonClick = useCallback((e: React.MouseEvent) => { const handleSubtaskButtonClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
handleSubTaskExpand(); handleSubTaskExpand();
}, [handleSubTaskExpand]); },
[handleSubTaskExpand]
);
// Calendar rendering helpers // Calendar rendering helpers
const year = calendarMonth.getFullYear(); const year = calendarMonth.getFullYear();
@@ -202,7 +219,10 @@ const TaskCard: React.FC<TaskCardProps> = memo(({
return ( 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 */} {/* Progress circle at top right */}
<div style={{ position: 'absolute', top: 6, right: 6, zIndex: 2 }}> <div style={{ position: 'absolute', top: 6, right: 6, zIndex: 2 }}>
<TaskProgressCircle task={task} size={20} /> <TaskProgressCircle task={task} size={20} />
@@ -237,7 +257,7 @@ const TaskCard: React.FC<TaskCardProps> = memo(({
fontSize: 10, fontSize: 10,
marginRight: 4, marginRight: 4,
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
minWidth: 0 minWidth: 0,
}} }}
> >
{label.name} {label.name}
@@ -247,12 +267,27 @@ const TaskCard: React.FC<TaskCardProps> = memo(({
<div className="task-content" style={{ display: 'flex', alignItems: 'center' }}> <div className="task-content" style={{ display: 'flex', alignItems: 'center' }}>
<span <span
className="w-2 h-2 rounded-full inline-block" 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> ></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>
<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 className="relative">
<div <div
ref={dateButtonRef} ref={dateButtonRef}
@@ -269,8 +304,10 @@ const TaskCard: React.FC<TaskCardProps> = memo(({
> >
{isUpdating ? ( {isUpdating ? (
<div className="w-3 h-3 border border-gray-300 border-t-blue-600 rounded-full animate-spin"></div> <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> </div>
{/* Custom Calendar Popup */} {/* Custom Calendar Popup */}
@@ -307,13 +344,19 @@ const TaskCard: React.FC<TaskCardProps> = memo(({
</div> </div>
<div className="grid grid-cols-7 gap-0.5 mb-0.5 text-[10px] text-center"> <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 => ( {['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) => ( {weeks.map((week, i) => (
<React.Fragment key={i}> <React.Fragment key={i}>
{week.map((date, j) => { {week.map((date, j) => {
const isSelected = date && selectedDate && date.toDateString() === selectedDate.toDateString(); const isSelected =
const isToday = date && date.toDateString() === today.toDateString(); date &&
selectedDate &&
date.toDateString() === selectedDate.toDateString();
const isToday =
date && date.toDateString() === today.toDateString();
return ( return (
<button <button
key={j} key={j}
@@ -380,45 +423,77 @@ const TaskCard: React.FC<TaskCardProps> = memo(({
isDarkMode={themeMode === 'dark'} isDarkMode={themeMode === 'dark'}
size={24} 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 && ( {(task.sub_tasks_count ?? 0) > 0 && (
<button <button
type="button" type="button"
className={ 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 (task.show_sub_tasks
? "bg-gray-100 dark:bg-gray-800" ? 'bg-gray-100 dark:bg-gray-800'
: "bg-white dark:bg-[#1e1e1e] hover:bg-gray-50 dark:hover:bg-gray-700") : 'bg-white dark:bg-[#1e1e1e] hover:bg-gray-50 dark:hover:bg-gray-700')
} }
style={{ style={{
backgroundColor: themeWiseColor('white', '#1e1e1e', themeMode), backgroundColor: themeWiseColor('white', '#1e1e1e', themeMode),
border: "none", border: 'none',
outline: "none", outline: 'none',
}} }}
onClick={handleSubtaskButtonClick} 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 */} {/* 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" /> <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="6" cy="3" r="2" fill="currentColor" />
<circle cx="16" cy="9" r="2" fill="currentColor" /> <circle cx="16" cy="9" r="2" fill="currentColor" />
<circle cx="6" cy="17" r="2" fill="currentColor" /> <circle cx="6" cy="17" r="2" fill="currentColor" />
<path d="M6 5v10" strokeLinecap="round" /> <path d="M6 5v10" strokeLinecap="round" />
</svg> </svg>
<span style={{ <span
style={{
fontSize: 10, fontSize: 10,
color: '#888', color: '#888',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
display: 'inline-block', display: 'inline-block',
}}>{task.sub_tasks_count ?? 0}</span> }}
>
{task.sub_tasks_count ?? 0}
</span>
{/* Caret icon */} {/* Caret icon */}
{task.show_sub_tasks ? ( {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" /> <path d="M6 8l4 4 4-4" strokeLinecap="round" strokeLinejoin="round" />
</svg> </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" /> <path d="M8 6l4 4-4 4" strokeLinecap="round" strokeLinejoin="round" />
</svg> </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" /> <div className="h-4 rounded bg-gray-200 dark:bg-gray-700 animate-pulse" />
)} )}
{/* Loaded subtasks */} {/* 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"> <ul className="space-y-1">
{task.sub_tasks.map(sub => ( {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 ? ( {sub.priority_color || sub.priority_color_dark ? (
<span <span
className="w-2 h-2 rounded-full inline-block" 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> ></span>
) : null} ) : null}
<span className="flex-1 truncate text-xs text-gray-800 dark:text-gray-100" title={sub.name}>{sub.name}</span>
<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') : ''} {sub.end_date ? format(new Date(sub.end_date), 'MMM d, yyyy') : ''}
</span> </span>
<span className="flex items-center"> <span className="flex items-center">
@@ -469,22 +558,31 @@ const TaskCard: React.FC<TaskCardProps> = memo(({
size={18} size={18}
/> />
)} )}
<LazyAssigneeSelectorWrapper task={sub} groupId={groupId} isDarkMode={themeMode === 'dark'} kanbanMode={true} /> <LazyAssigneeSelectorWrapper
task={sub}
groupId={groupId}
isDarkMode={themeMode === 'dark'}
kanbanMode={true}
/>
</span> </span>
</li> </li>
))} ))}
</ul> </ul>
)} )}
{/* Empty state */} {/* Empty state */}
{!task.sub_tasks_loading && (!Array.isArray(task.sub_tasks) || task.sub_tasks.length === 0) && ( {!task.sub_tasks_loading &&
<div className="py-2 text-xs text-gray-400 dark:text-gray-500">{t('noSubtasks', 'No subtasks')}</div> (!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> </div>
</div> </div>
</> </>
); );
}); }
);
TaskCard.displayName = 'TaskCard'; TaskCard.displayName = 'TaskCard';

View File

@@ -1,22 +1,27 @@
import { IProjectTask } from "@/types/project/projectTasksViewModel.types"; import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
// Add a simple circular progress component // Add a simple circular progress component
const TaskProgressCircle: React.FC<{ task: IProjectTask; size?: number }> = ({ task, size = 28 }) => { const TaskProgressCircle: React.FC<{ task: IProjectTask; size?: number }> = ({
const progress = typeof task.complete_ratio === 'number' task,
size = 28,
}) => {
const progress =
typeof task.complete_ratio === 'number'
? task.complete_ratio ? task.complete_ratio
: (typeof task.progress === 'number' ? task.progress : 0); : typeof task.progress === 'number'
? task.progress
: 0;
const strokeWidth = 1.5; const strokeWidth = 1.5;
const radius = (size - strokeWidth) / 2; const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius; const circumference = 2 * Math.PI * radius;
const offset = circumference - (progress / 100) * circumference; const offset = circumference - (progress / 100) * circumference;
return ( return (
<svg width={size} height={size} style={{ display: 'block' }}> <svg width={size} height={size} style={{ display: 'block' }}>
<circle <circle
cx={size / 2} cx={size / 2}
cy={size / 2} cy={size / 2}
r={radius} r={radius}
stroke={progress === 100 ? "#22c55e" : "#3b82f6"} stroke={progress === 100 ? '#22c55e' : '#3b82f6'}
strokeWidth={strokeWidth} strokeWidth={strokeWidth}
fill="none" fill="none"
strokeDasharray={circumference} strokeDasharray={circumference}
@@ -28,11 +33,25 @@ const TaskProgressCircle: React.FC<{ task: IProjectTask; size?: number }> = ({ t
// Green checkmark icon // Green checkmark icon
<g> <g>
<circle cx={size / 2} cy={size / 2} r={radius} fill="#22c55e" opacity="0.15" /> <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"> <svg
<path d="M5 13l4 4L19 7" stroke="#22c55e" strokeWidth="2.2" fill="none" strokeLinecap="round" strokeLinejoin="round"/> 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> </svg>
</g> </g>
) : progress > 0 && ( ) : (
progress > 0 && (
<text <text
x="50%" x="50%"
y="50%" y="50%"
@@ -44,6 +63,7 @@ const TaskProgressCircle: React.FC<{ task: IProjectTask; size?: number }> = ({ t
> >
{Math.round(progress)} {Math.round(progress)}
</text> </text>
)
)} )}
</svg> </svg>
); );

View File

@@ -68,7 +68,8 @@ const EnhancedKanbanCreateSection: React.FC = () => {
!dropdownRef.current.contains(event.target as Node) && !dropdownRef.current.contains(event.target as Node) &&
inputRef.current && inputRef.current &&
!inputRef.current.contains(event.target as Node) && !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); setIsAdding(false);
setSectionName(''); setSectionName('');
@@ -109,7 +110,11 @@ const EnhancedKanbanCreateSection: React.FC = () => {
setIsAdding(true); setIsAdding(true);
setSectionName(''); setSectionName('');
// Default to first category if available // 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); setSelectedCategoryId(statusCategories[0].id);
} else { } else {
setSelectedCategoryId(''); setSelectedCategoryId('');
@@ -217,10 +222,15 @@ const EnhancedKanbanCreateSection: React.FC = () => {
style={{ minWidth: 80 }} style={{ minWidth: 80 }}
onClick={() => setShowCategoryDropdown(v => !v)} 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')} {selectedCategory?.name || t('changeCategory')}
</span> </span>
<DownOutlined style={{ fontSize: 12, color: themeMode === 'dark' ? '#555' : '#555' }} /> <DownOutlined
style={{ fontSize: 12, color: themeMode === 'dark' ? '#555' : '#555' }}
/>
</button> </button>
{showCategoryDropdown && ( {showCategoryDropdown && (
<div <div
@@ -228,7 +238,9 @@ const EnhancedKanbanCreateSection: React.FC = () => {
style={{ zIndex: 1000 }} style={{ zIndex: 1000 }}
> >
<div className="py-1"> <div className="py-1">
{statusCategories.filter(cat => typeof cat.id === 'string').map(cat => ( {statusCategories
.filter(cat => typeof cat.id === 'string')
.map(cat => (
<button <button
key={cat.id} key={cat.id}
type="button" type="button"
@@ -242,7 +254,9 @@ const EnhancedKanbanCreateSection: React.FC = () => {
className="w-3 h-3 rounded-full" className="w-3 h-3 rounded-full"
style={{ backgroundColor: cat.color_code }} style={{ backgroundColor: cat.color_code }}
></div> ></div>
<span className={selectedCategoryId === cat.id ? 'font-bold' : ''}>{cat.name}</span> <span className={selectedCategoryId === cat.id ? 'font-bold' : ''}>
{cat.name}
</span>
</button> </button>
))} ))}
</div> </div>
@@ -263,7 +277,12 @@ const EnhancedKanbanCreateSection: React.FC = () => {
<Button <Button
type="default" type="default"
size="small" size="small"
onClick={() => { setIsAdding(false); setSectionName(''); setSelectedCategoryId(''); setShowCategoryDropdown(false); }} onClick={() => {
setIsAdding(false);
setSectionName('');
setSelectedCategoryId('');
setShowCategoryDropdown(false);
}}
> >
{t('deleteConfirmationCancel')} {t('deleteConfirmationCancel')}
</Button> </Button>

View File

@@ -15,7 +15,9 @@
html.light .enhanced-kanban-task-card { html.light .enhanced-kanban-task-card {
border: 1.5px solid #e1e4e8 !important; /* Asana-like light border */ 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; background: #fff !important;
} }

View File

@@ -182,14 +182,24 @@ const EnhancedKanbanTaskCard: React.FC<EnhancedKanbanTaskCardProps> = React.memo
isDarkMode={themeMode === 'dark'} isDarkMode={themeMode === 'dark'}
size={24} size={24}
/> />
<LazyAssigneeSelectorWrapper task={task} groupId={sectionId} isDarkMode={themeMode === 'dark'} kanbanMode={true} /> <LazyAssigneeSelectorWrapper
task={task}
groupId={sectionId}
isDarkMode={themeMode === 'dark'}
kanbanMode={true}
/>
</Flex> </Flex>
<Flex gap={4} align="center"> <Flex gap={4} align="center">
<CustomDueDatePicker task={task} onDateChange={setDueDate} /> <CustomDueDatePicker task={task} onDateChange={setDueDate} />
{/* Subtask Section - only show if count > 1 */} {/* Subtask Section - only show if count > 1 */}
{task.sub_tasks_count != null && Number(task.sub_tasks_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 <Button
onClick={handleSubtaskButtonClick} onClick={handleSubtaskButtonClick}
size="small" size="small"

View File

@@ -198,14 +198,24 @@ const KanbanTaskCard: React.FC<TaskRowProps> = ({
</span> </span>
)} )}
{task.comments_count && task.comments_count > 1 && ( {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"> <span className="kanban-task-indicator">
<MessageOutlined /> {task.comments_count} <MessageOutlined /> {task.comments_count}
</span> </span>
</Tooltip> </Tooltip>
)} )}
{task.attachments_count && task.attachments_count > 1 && ( {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"> <span className="kanban-task-indicator">
<PaperClipOutlined /> {task.attachments_count} <PaperClipOutlined /> {task.attachments_count}
</span> </span>

View File

@@ -131,12 +131,12 @@ const ProjectGroupList: React.FC<ProjectGroupListProps> = ({
// Then fetch project data // Then fetch project data
dispatch(fetchProjectData(projectId)) dispatch(fetchProjectData(projectId))
.unwrap() .unwrap()
.then((projectData) => { .then(projectData => {
console.log('Project data fetched successfully from project group:', projectData); console.log('Project data fetched successfully from project group:', projectData);
// Open drawer after data is fetched // Open drawer after data is fetched
dispatch(toggleProjectDrawer()); dispatch(toggleProjectDrawer());
}) })
.catch((error) => { .catch(error => {
console.error('Failed to fetch project data from project group:', 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 // Still open drawer even if fetch fails, so user can see error state
dispatch(toggleProjectDrawer()); dispatch(toggleProjectDrawer());

View File

@@ -54,12 +54,12 @@ export const ActionButtons: React.FC<ActionButtonsProps> = ({
// Then fetch project data // Then fetch project data
dispatch(fetchProjectData(record.id)) dispatch(fetchProjectData(record.id))
.unwrap() .unwrap()
.then((projectData) => { .then(projectData => {
console.log('Project data fetched successfully:', projectData); console.log('Project data fetched successfully:', projectData);
// Open drawer after data is fetched // Open drawer after data is fetched
dispatch(toggleProjectDrawer()); dispatch(toggleProjectDrawer());
}) })
.catch((error) => { .catch(error => {
console.error('Failed to fetch project data:', error); console.error('Failed to fetch project data:', error);
// Still open drawer even if fetch fails, so user can see error state // Still open drawer even if fetch fails, so user can see error state
dispatch(toggleProjectDrawer()); dispatch(toggleProjectDrawer());

View File

@@ -19,11 +19,7 @@ const CreateStatusButton = () => {
className="borderless-icon-btn" className="borderless-icon-btn"
style={{ backgroundColor: colors.transparent, boxShadow: 'none' }} style={{ backgroundColor: colors.transparent, boxShadow: 'none' }}
onClick={() => dispatch(toggleDrawer())} onClick={() => dispatch(toggleDrawer())}
icon={ icon={<SettingOutlined style={{ color: themeMode === 'dark' ? colors.white : 'black' }} />}
<SettingOutlined
style={{ color: themeMode === 'dark' ? colors.white : 'black' }}
/>
}
/> />
</Tooltip> </Tooltip>
); );

View File

@@ -14,7 +14,11 @@ import { toggleDrawer } from '@/features/projects/status/StatusSlice';
import './create-status-drawer.css'; 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 { ITaskStatusCategory } from '@/types/status.types';
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking'; import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
import useTabSearchParam from '@/hooks/useTabSearchParam'; import useTabSearchParam from '@/hooks/useTabSearchParam';

View File

@@ -12,10 +12,7 @@ import { deleteStatusToggleDrawer } from '@/features/projects/status/DeleteStatu
import { Drawer, Alert, Card, Select, Button, Typography, Badge } from '@/shared/antd-imports'; import { Drawer, Alert, Card, Select, Button, Typography, Badge } from '@/shared/antd-imports';
import { DownOutlined } from '@/shared/antd-imports'; import { DownOutlined } from '@/shared/antd-imports';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { import { deleteSection, IGroupBy } from '@features/enhanced-kanban/enhanced-kanban.slice';
deleteSection,
IGroupBy,
} from '@features/enhanced-kanban/enhanced-kanban.slice';
import { statusApiService } from '@/api/taskAttributes/status/status.api.service'; import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
import { phasesApiService } from '@/api/taskAttributes/phases/phases.api.service'; import { phasesApiService } from '@/api/taskAttributes/phases/phases.api.service';
import logger from '@/utils/errorLogger'; import logger from '@/utils/errorLogger';

View File

@@ -1,5 +1,14 @@
import React, { useState, useEffect } from 'react'; 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 { SettingOutlined, UpOutlined, DownOutlined } from '@/shared/antd-imports';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';

View File

@@ -13,7 +13,7 @@ import {
List, List,
Space, Space,
Typography, Typography,
InputRef InputRef,
} from '@/shared/antd-imports'; } from '@/shared/antd-imports';
import { useAppDispatch } from '@/hooks/useAppDispatch'; import { useAppDispatch } from '@/hooks/useAppDispatch';

View File

@@ -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 Badge from 'antd/es/badge';
import Button from 'antd/es/button'; import Button from 'antd/es/button';

View File

@@ -25,7 +25,8 @@ const ImportRateCardsDrawer: React.FC = () => {
const fallbackCurrency = useAppSelector(state => state.financeReducer.currency); const fallbackCurrency = useAppSelector(state => state.financeReducer.currency);
const currency = (projectCurrency || fallbackCurrency || 'USD').toUpperCase(); const currency = (projectCurrency || fallbackCurrency || 'USD').toUpperCase();
const rolesRedux = useAppSelector(state => state.projectFinanceRateCardReducer.rateCardRoles) || []; const rolesRedux =
useAppSelector(state => state.projectFinanceRateCardReducer.rateCardRoles) || [];
// Loading states // Loading states
const isRatecardsLoading = useAppSelector(state => state.financeReducer.isRatecardsLoading); const isRatecardsLoading = useAppSelector(state => state.financeReducer.isRatecardsLoading);

View File

@@ -3,7 +3,15 @@ import { useAppDispatch } from '@/hooks/useAppDispatch';
import { IClientsViewModel } from '@/types/client.types'; import { IClientsViewModel } from '@/types/client.types';
import { IProjectViewModel } from '@/types/project/projectViewModel.types'; import { IProjectViewModel } from '@/types/project/projectViewModel.types';
import { QuestionCircleOutlined } from '@/shared/antd-imports'; 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 { TFunction } from 'i18next';
import { useState } from 'react'; import { useState } from 'react';

View File

@@ -1,5 +1,12 @@
import { useEffect, useState, useMemo } from 'react'; 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 { useTranslation } from 'react-i18next';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { ExpandAltOutlined } from '@/shared/antd-imports'; import { ExpandAltOutlined } from '@/shared/antd-imports';

View File

@@ -65,12 +65,10 @@ const Billable: React.FC = () => {
successColor: isDark ? '#52c41a' : '#52c41a', successColor: isDark ? '#52c41a' : '#52c41a',
errorColor: isDark ? '#ff4d4f' : '#ff4d4f', errorColor: isDark ? '#ff4d4f' : '#ff4d4f',
buttonBorder: isDark ? '#303030' : '#d9d9d9', buttonBorder: isDark ? '#303030' : '#d9d9d9',
buttonText: activeFiltersCount > 0 buttonText:
? (isDark ? 'white' : '#262626') activeFiltersCount > 0 ? (isDark ? 'white' : '#262626') : isDark ? '#d9d9d9' : '#595959',
: (isDark ? '#d9d9d9' : '#595959'), buttonBg:
buttonBg: activeFiltersCount > 0 activeFiltersCount > 0 ? (isDark ? '#434343' : '#f5f5f5') : isDark ? '#141414' : 'white',
? (isDark ? '#434343' : '#f5f5f5')
: (isDark ? '#141414' : 'white'),
dropdownBg: isDark ? '#1f1f1f' : 'white', dropdownBg: isDark ? '#1f1f1f' : 'white',
dropdownBorder: isDark ? '#303030' : '#d9d9d9', dropdownBorder: isDark ? '#303030' : '#d9d9d9',
}; };

View File

@@ -17,7 +17,7 @@ import {
CaretDownFilled, CaretDownFilled,
FilterOutlined, FilterOutlined,
CheckCircleFilled, CheckCircleFilled,
CheckboxChangeEvent CheckboxChangeEvent,
} from '@/shared/antd-imports'; } from '@/shared/antd-imports';
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -60,12 +60,10 @@ const Categories: React.FC = () => {
successColor: isDark ? '#52c41a' : '#52c41a', successColor: isDark ? '#52c41a' : '#52c41a',
errorColor: isDark ? '#ff4d4f' : '#ff4d4f', errorColor: isDark ? '#ff4d4f' : '#ff4d4f',
buttonBorder: isDark ? '#303030' : '#d9d9d9', buttonBorder: isDark ? '#303030' : '#d9d9d9',
buttonText: activeFiltersCount > 0 buttonText:
? (isDark ? 'white' : '#262626') activeFiltersCount > 0 ? (isDark ? 'white' : '#262626') : isDark ? '#d9d9d9' : '#595959',
: (isDark ? '#d9d9d9' : '#595959'), buttonBg:
buttonBg: activeFiltersCount > 0 activeFiltersCount > 0 ? (isDark ? '#434343' : '#f5f5f5') : isDark ? '#141414' : 'white',
? (isDark ? '#434343' : '#f5f5f5')
: (isDark ? '#141414' : 'white'),
dropdownBg: isDark ? '#1f1f1f' : 'white', dropdownBg: isDark ? '#1f1f1f' : 'white',
dropdownBorder: isDark ? '#303030' : '#d9d9d9', dropdownBorder: isDark ? '#303030' : '#d9d9d9',
}; };

View File

@@ -55,12 +55,10 @@ const Members: React.FC = () => {
successColor: isDark ? '#52c41a' : '#52c41a', successColor: isDark ? '#52c41a' : '#52c41a',
errorColor: isDark ? '#ff4d4f' : '#ff4d4f', errorColor: isDark ? '#ff4d4f' : '#ff4d4f',
buttonBorder: isDark ? '#303030' : '#d9d9d9', buttonBorder: isDark ? '#303030' : '#d9d9d9',
buttonText: activeFiltersCount > 0 buttonText:
? (isDark ? 'white' : '#262626') activeFiltersCount > 0 ? (isDark ? 'white' : '#262626') : isDark ? '#d9d9d9' : '#595959',
: (isDark ? '#d9d9d9' : '#595959'), buttonBg:
buttonBg: activeFiltersCount > 0 activeFiltersCount > 0 ? (isDark ? '#434343' : '#f5f5f5') : isDark ? '#141414' : 'white',
? (isDark ? '#434343' : '#f5f5f5')
: (isDark ? '#141414' : 'white'),
dropdownBg: isDark ? '#1f1f1f' : 'white', dropdownBg: isDark ? '#1f1f1f' : 'white',
dropdownBorder: isDark ? '#303030' : '#d9d9d9', dropdownBorder: isDark ? '#303030' : '#d9d9d9',
}; };

View File

@@ -54,12 +54,10 @@ const Projects: React.FC = () => {
successColor: isDark ? '#52c41a' : '#52c41a', successColor: isDark ? '#52c41a' : '#52c41a',
errorColor: isDark ? '#ff4d4f' : '#ff4d4f', errorColor: isDark ? '#ff4d4f' : '#ff4d4f',
buttonBorder: isDark ? '#303030' : '#d9d9d9', buttonBorder: isDark ? '#303030' : '#d9d9d9',
buttonText: activeFiltersCount > 0 buttonText:
? (isDark ? 'white' : '#262626') activeFiltersCount > 0 ? (isDark ? 'white' : '#262626') : isDark ? '#d9d9d9' : '#595959',
: (isDark ? '#d9d9d9' : '#595959'), buttonBg:
buttonBg: activeFiltersCount > 0 activeFiltersCount > 0 ? (isDark ? '#434343' : '#f5f5f5') : isDark ? '#141414' : 'white',
? (isDark ? '#434343' : '#f5f5f5')
: (isDark ? '#141414' : 'white'),
dropdownBg: isDark ? '#1f1f1f' : 'white', dropdownBg: isDark ? '#1f1f1f' : 'white',
dropdownBorder: isDark ? '#303030' : '#d9d9d9', dropdownBorder: isDark ? '#303030' : '#d9d9d9',
}; };

View File

@@ -55,12 +55,10 @@ const Team: React.FC = () => {
successColor: isDark ? '#52c41a' : '#52c41a', successColor: isDark ? '#52c41a' : '#52c41a',
errorColor: isDark ? '#ff4d4f' : '#ff4d4f', errorColor: isDark ? '#ff4d4f' : '#ff4d4f',
buttonBorder: isDark ? '#303030' : '#d9d9d9', buttonBorder: isDark ? '#303030' : '#d9d9d9',
buttonText: activeFiltersCount > 0 buttonText:
? (isDark ? 'white' : '#262626') activeFiltersCount > 0 ? (isDark ? 'white' : '#262626') : isDark ? '#d9d9d9' : '#595959',
: (isDark ? '#d9d9d9' : '#595959'), buttonBg:
buttonBg: activeFiltersCount > 0 activeFiltersCount > 0 ? (isDark ? '#434343' : '#f5f5f5') : isDark ? '#141414' : 'white',
? (isDark ? '#434343' : '#f5f5f5')
: (isDark ? '#141414' : 'white'),
dropdownBg: isDark ? '#1f1f1f' : 'white', dropdownBg: isDark ? '#1f1f1f' : 'white',
dropdownBorder: isDark ? '#303030' : '#d9d9d9', dropdownBorder: isDark ? '#303030' : '#d9d9d9',
}; };

View File

@@ -1,4 +1,3 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import Team from './Team'; import Team from './Team';
@@ -45,7 +44,7 @@ const TimeReportPageHeader: React.FC = () => {
<Categories /> <Categories />
<Projects /> <Projects />
<Billable /> <Billable />
{isMembersTimeSheet && <Members/>} {isMembersTimeSheet && <Members />}
{isMembersTimeSheet && <Utilization />} {isMembersTimeSheet && <Utilization />}
</div> </div>
); );

View File

@@ -56,12 +56,10 @@ const Utilization: React.FC = () => {
successColor: isDark ? '#52c41a' : '#52c41a', successColor: isDark ? '#52c41a' : '#52c41a',
errorColor: isDark ? '#ff4d4f' : '#ff4d4f', errorColor: isDark ? '#ff4d4f' : '#ff4d4f',
buttonBorder: isDark ? '#303030' : '#d9d9d9', buttonBorder: isDark ? '#303030' : '#d9d9d9',
buttonText: activeFiltersCount > 0 buttonText:
? (isDark ? 'white' : '#262626') activeFiltersCount > 0 ? (isDark ? 'white' : '#262626') : isDark ? '#d9d9d9' : '#595959',
: (isDark ? '#d9d9d9' : '#595959'), buttonBg:
buttonBg: activeFiltersCount > 0 activeFiltersCount > 0 ? (isDark ? '#434343' : '#f5f5f5') : isDark ? '#141414' : 'white',
? (isDark ? '#434343' : '#f5f5f5')
: (isDark ? '#141414' : 'white'),
dropdownBg: isDark ? '#1f1f1f' : 'white', dropdownBg: isDark ? '#1f1f1f' : 'white',
dropdownBorder: isDark ? '#303030' : '#d9d9d9', dropdownBorder: isDark ? '#303030' : '#d9d9d9',
}; };

View File

@@ -9,24 +9,74 @@ import {
ArrowDownOutlined, ArrowDownOutlined,
CheckCircleOutlined, CheckCircleOutlined,
} from '@/shared/antd-imports'; } from '@/shared/antd-imports';
import React, { useMemo } from 'react'; import React, { useMemo, useEffect, useState } from 'react';
import { useAppSelector } from '@/hooks/useAppSelector'; import { useAppSelector } from '@/hooks/useAppSelector';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { IRPTTimeTotals } from '@/types/reporting/reporting.types'; import { IRPTTimeTotals } from '@/types/reporting/reporting.types';
import { useReportingUtilization } from '@/hooks/useUtilizationCalculation';
import dayjs from 'dayjs';
interface TotalTimeUtilizationProps { interface TotalTimeUtilizationProps {
totals: IRPTTimeTotals; totals: IRPTTimeTotals;
dateRange?: string[];
} }
const TotalTimeUtilization: React.FC<TotalTimeUtilizationProps> = ({ totals }) => { const TotalTimeUtilization: React.FC<TotalTimeUtilizationProps> = ({ totals, dateRange }) => {
const { t } = useTranslation('time-report'); const { t } = useTranslation('time-report');
const themeMode = useAppSelector(state => state.themeReducer.mode); const themeMode = useAppSelector(state => state.themeReducer.mode);
const isDark = themeMode === 'dark'; 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 utilizationData = useMemo(() => {
const timeLogged = parseFloat(totals.total_time_logs || '0'); const timeLogged = parseFloat(totals.total_time_logs || '0');
const estimatedHours = parseFloat(totals.total_estimated_hours || '0'); let estimatedHours = parseFloat(totals.total_estimated_hours || '0');
const utilizationPercent = parseFloat(totals.total_utilization || '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 // Determine utilization status and color
let status: 'under' | 'optimal' | 'over' = 'optimal'; let status: 'under' | 'optimal' | 'over' = 'optimal';
@@ -49,13 +99,13 @@ const TotalTimeUtilization: React.FC<TotalTimeUtilizationProps> = ({ totals }) =
return { return {
timeLogged, timeLogged,
estimatedHours, estimatedHours,
utilizationPercent, utilizationPercent: Math.round(utilizationPercent * 100) / 100,
status, status,
statusColor, statusColor,
statusIcon, statusIcon,
statusText, statusText,
}; };
}, [totals, t]); }, [totals, t, holidayInfo]);
const getThemeColors = useMemo( const getThemeColors = useMemo(
() => ({ () => ({
@@ -201,7 +251,7 @@ const TotalTimeUtilization: React.FC<TotalTimeUtilizationProps> = ({ totals }) =
lineHeight: 1, lineHeight: 1,
}} }}
> >
{totals.total_estimated_hours}h {utilizationData.estimatedHours.toFixed(1)}h
</div> </div>
<div <div
style={{ style={{
@@ -210,7 +260,9 @@ const TotalTimeUtilization: React.FC<TotalTimeUtilizationProps> = ({ totals }) =
marginTop: '2px', marginTop: '2px',
}} }}
> >
{t('basedOnWorkingSchedule')} {holidayInfo?.count
? `${t('basedOnWorkingSchedule')} (${holidayInfo.count} ${t('holidaysExcluded')})`
: t('basedOnWorkingSchedule')}
</div> </div>
</div> </div>
</Flex> </Flex>
@@ -281,7 +333,7 @@ const TotalTimeUtilization: React.FC<TotalTimeUtilizationProps> = ({ totals }) =
marginBottom: '8px', marginBottom: '8px',
}} }}
> >
{totals.total_utilization}% {utilizationData.utilizationPercent}%
</div> </div>
<Progress <Progress
percent={Math.min(utilizationData.utilizationPercent, 150)} // Cap at 150% for display percent={Math.min(utilizationData.utilizationPercent, 150)} // Cap at 150% for display

View File

@@ -1,5 +1,14 @@
import { DownOutlined } from '@/shared/antd-imports'; 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 { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import dayjs from 'dayjs'; import dayjs from 'dayjs';

View File

@@ -3,7 +3,12 @@
import React from 'react'; import React from 'react';
import { Badge, Button, Space, Tooltip, message } from '@/shared/antd-imports'; 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'; import { useServiceWorker } from '../../utils/serviceWorkerRegistration';
interface ServiceWorkerStatusProps { interface ServiceWorkerStatusProps {
@@ -13,7 +18,7 @@ interface ServiceWorkerStatusProps {
const ServiceWorkerStatus: React.FC<ServiceWorkerStatusProps> = ({ const ServiceWorkerStatus: React.FC<ServiceWorkerStatusProps> = ({
minimal = false, minimal = false,
showControls = false showControls = false,
}) => { }) => {
const { isOffline, swManager, clearCache, forceUpdate, getVersion } = useServiceWorker(); const { isOffline, swManager, clearCache, forceUpdate, getVersion } = useServiceWorker();
const [swVersion, setSwVersion] = React.useState<string>(''); const [swVersion, setSwVersion] = React.useState<string>('');
@@ -25,9 +30,11 @@ const ServiceWorkerStatus: React.FC<ServiceWorkerStatusProps> = ({
if (getVersion) { if (getVersion) {
const versionPromise = getVersion(); const versionPromise = getVersion();
if (versionPromise) { if (versionPromise) {
versionPromise.then(version => { versionPromise
.then(version => {
setSwVersion(version); setSwVersion(version);
}).catch(() => { })
.catch(() => {
// Ignore errors when getting version // Ignore errors when getting version
}); });
} }
@@ -69,10 +76,7 @@ const ServiceWorkerStatus: React.FC<ServiceWorkerStatusProps> = ({
if (minimal) { if (minimal) {
return ( return (
<Tooltip title={isOffline ? 'You are offline' : 'You are online'}> <Tooltip title={isOffline ? 'You are offline' : 'You are online'}>
<Badge <Badge status={isOffline ? 'error' : 'success'} text={isOffline ? 'Offline' : 'Online'} />
status={isOffline ? 'error' : 'success'}
text={isOffline ? 'Offline' : 'Online'}
/>
</Tooltip> </Tooltip>
); );
} }
@@ -87,23 +91,15 @@ const ServiceWorkerStatus: React.FC<ServiceWorkerStatusProps> = ({
) : ( ) : (
<WifiOutlined style={{ color: '#52c41a' }} /> <WifiOutlined style={{ color: '#52c41a' }} />
)} )}
<span style={{ fontSize: '14px' }}> <span style={{ fontSize: '14px' }}>{isOffline ? 'Offline Mode' : 'Online'}</span>
{isOffline ? 'Offline Mode' : 'Online'} {swVersion && <span style={{ fontSize: '12px', color: '#8c8c8c' }}>v{swVersion}</span>}
</span>
{swVersion && (
<span style={{ fontSize: '12px', color: '#8c8c8c' }}>
v{swVersion}
</span>
)}
</div> </div>
{/* Information */} {/* Information */}
<div style={{ fontSize: '12px', color: '#8c8c8c' }}> <div style={{ fontSize: '12px', color: '#8c8c8c' }}>
{isOffline ? ( {isOffline
'App is running from cache. Changes will sync when online.' ? 'App is running from cache. Changes will sync when online.'
) : ( : 'App is cached for offline use. Ready to work anywhere!'}
'App is cached for offline use. Ready to work anywhere!'
)}
</div> </div>
{/* Controls */} {/* Controls */}

View File

@@ -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 { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ArrowRightOutlined } from '@/shared/antd-imports'; import { ArrowRightOutlined } from '@/shared/antd-imports';
@@ -73,7 +81,11 @@ const TaskDrawerActivityLog = () => {
</Tag> </Tag>
<ArrowRightOutlined /> <ArrowRightOutlined />
&nbsp; &nbsp;
<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> </Flex>
); );
@@ -156,20 +168,28 @@ const TaskDrawerActivityLog = () => {
case IActivityLogAttributeTypes.WEIGHT: case IActivityLogAttributeTypes.WEIGHT:
return ( return (
<Flex gap={4} align="center"> <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 /> <ArrowRightOutlined />
&nbsp; &nbsp;
<Tag color="purple">{t('taskActivityLogTab.weight')}: {activity.current || '100'}</Tag> <Tag color="purple">
{t('taskActivityLogTab.weight')}: {activity.current || '100'}
</Tag>
</Flex> </Flex>
); );
default: default:
return ( return (
<Flex gap={4} align="center"> <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 /> <ArrowRightOutlined />
&nbsp; &nbsp;
<Tag color={'default'}>{truncateText(activity.current) || t('taskActivityLogTab.none')}</Tag> <Tag color={'default'}>
{truncateText(activity.current) || t('taskActivityLogTab.none')}
</Tag>
</Flex> </Flex>
); );
} }

View File

@@ -1,6 +1,14 @@
import { useState } from 'react'; import { useState } from 'react';
import { ITaskAttachmentViewModel } from '@/types/tasks/task-attachment-view-model'; 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 { import {
EyeOutlined, EyeOutlined,
DownloadOutlined, DownloadOutlined,

View File

@@ -123,12 +123,14 @@ const TaskComments = ({ taskId, t }: { taskId?: string; t: TFunction }) => {
setComments(sortedComments); setComments(sortedComments);
// Update Redux state with the current comment count // Update Redux state with the current comment count
dispatch(updateTaskCounts({ dispatch(
updateTaskCounts({
taskId, taskId,
counts: { counts: {
comments_count: sortedComments.length comments_count: sortedComments.length,
} },
})); })
);
} }
setLoading(false); setLoading(false);
@@ -244,9 +246,11 @@ const TaskComments = ({ taskId, t }: { taskId?: string; t: TFunction }) => {
await getComments(false); await getComments(false);
// Dispatch event to notify that an attachment was deleted // Dispatch event to notify that an attachment was deleted
document.dispatchEvent(new CustomEvent('task-comment-update', { document.dispatchEvent(
detail: { taskId } new CustomEvent('task-comment-update', {
})); detail: { taskId },
})
);
} }
} catch (e) { } catch (e) {
logger.error('Error deleting attachment', e); logger.error('Error deleting attachment', e);

View File

@@ -58,9 +58,11 @@ const TaskViewCommentEdit = ({ commentData, onUpdated }: TaskViewCommentEditProp
onUpdated(commentData); onUpdated(commentData);
// Dispatch event to notify that a comment was updated // Dispatch event to notify that a comment was updated
document.dispatchEvent(new CustomEvent('task-comment-update', { document.dispatchEvent(
detail: { taskId: commentData.task_id } new CustomEvent('task-comment-update', {
})); detail: { taskId: commentData.task_id },
})
);
} }
} catch (e) { } catch (e) {
logger.error('Error updating comment', e); logger.error('Error updating comment', e);

View File

@@ -65,12 +65,14 @@ const DependenciesTable = ({
setSearchTerm(''); setSearchTerm('');
// Update Redux state with dependency status // Update Redux state with dependency status
dispatch(updateTaskCounts({ dispatch(
updateTaskCounts({
taskId: task.id, taskId: task.id,
counts: { counts: {
has_dependencies: true has_dependencies: true,
} },
})); })
);
} }
} catch (error) { } catch (error) {
console.error('Error adding dependency:', error); console.error('Error adding dependency:', error);
@@ -104,12 +106,14 @@ const DependenciesTable = ({
// Update Redux state with dependency status // Update Redux state with dependency status
// Check if there are any remaining dependencies // Check if there are any remaining dependencies
const remainingDependencies = taskDependencies.filter(dep => dep.id !== dependencyId); const remainingDependencies = taskDependencies.filter(dep => dep.id !== dependencyId);
dispatch(updateTaskCounts({ dispatch(
updateTaskCounts({
taskId: task.id, taskId: task.id,
counts: { counts: {
has_dependencies: remainingDependencies.length > 0 has_dependencies: remainingDependencies.length > 0,
} },
})); })
);
} }
} catch (error) { } catch (error) {
console.error('Error deleting dependency:', error); console.error('Error deleting dependency:', error);

View File

@@ -87,7 +87,9 @@ const DescriptionEditor = ({ description, taskId, parentTaskId }: DescriptionEdi
const isClickedInsideWrapper = wrapper && wrapper.contains(target); const isClickedInsideWrapper = wrapper && wrapper.contains(target);
const isClickedInsideEditor = document.querySelector('.tox-tinymce')?.contains(target); const isClickedInsideEditor = document.querySelector('.tox-tinymce')?.contains(target);
const isClickedInsideToolbarPopup = document 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); ?.contains(target);
if ( if (

View File

@@ -102,12 +102,14 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
); );
// Update Redux state with recurring task status // Update Redux state with recurring task status
dispatch(updateTaskCounts({ dispatch(
updateTaskCounts({
taskId: task.id, taskId: task.id,
counts: { counts: {
schedule_id: schedule.id as string || null schedule_id: (schedule.id as string) || null,
} },
})); })
);
setRecurring(checked); setRecurring(checked);
if (!checked) setShowConfig(false); if (!checked) setShowConfig(false);

View File

@@ -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 { useCallback, useEffect, useRef, useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { PaperClipOutlined, DeleteOutlined, PlusOutlined } from '@/shared/antd-imports'; import { PaperClipOutlined, DeleteOutlined, PlusOutlined } from '@/shared/antd-imports';
@@ -6,9 +15,7 @@ import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch'; import { useAppDispatch } from '@/hooks/useAppDispatch';
import { colors } from '@/styles/colors'; import { colors } from '@/styles/colors';
import { themeWiseColor } from '@/utils/themeWiseColor'; import { themeWiseColor } from '@/utils/themeWiseColor';
import { import { IMentionMemberSelectOption } from '@/types/project/projectComments.types';
IMentionMemberSelectOption,
} from '@/types/project/projectComments.types';
import { ITaskCommentsCreateRequest } from '@/types/tasks/task-comments.types'; import { ITaskCommentsCreateRequest } from '@/types/tasks/task-comments.types';
import { ITaskAttachment } from '@/types/tasks/task-attachment-view-model'; import { ITaskAttachment } from '@/types/tasks/task-attachment-view-model';
import logger from '@/utils/errorLogger'; import logger from '@/utils/errorLogger';
@@ -186,9 +193,11 @@ const InfoTabFooter = () => {
// Dispatch event to notify that a comment was created // Dispatch event to notify that a comment was created
// This will trigger the task comments component to refresh and update Redux // This will trigger the task comments component to refresh and update Redux
document.dispatchEvent(new CustomEvent('task-comment-create', { document.dispatchEvent(
detail: { taskId: selectedTaskId } new CustomEvent('task-comment-create', {
})); detail: { taskId: selectedTaskId },
})
);
} }
} catch (error) { } catch (error) {
logger.error('Failed to create comment:', error); logger.error('Failed to create comment:', error);
@@ -335,7 +344,7 @@ const InfoTabFooter = () => {
{selectedFiles.length > 0 && ( {selectedFiles.length > 0 && (
<Flex vertical gap={8} style={{ marginTop: 12 }}> <Flex vertical gap={8} style={{ marginTop: 12 }}>
<Typography.Title level={5} style={{ margin: 0 }}> <Typography.Title level={5} style={{ margin: 0 }}>
{t('taskInfoTab.comments.selectedFiles', { count: MAXIMUM_FILE_COUNT })} {t('taskInfoTab.comments.selectedFiles', { count: MAXIMUM_FILE_COUNT })}
</Typography.Title> </Typography.Title>
<Flex <Flex
vertical vertical
@@ -478,30 +487,18 @@ const InfoTabFooter = () => {
)} )}
<Flex align="center" justify="space-between" style={{ width: '100%', marginTop: 8 }}> <Flex align="center" justify="space-between" style={{ width: '100%', marginTop: 8 }}>
<Tooltip <Tooltip title={createdFromNow !== 'N/A' ? `Created ${createdFromNow}` : 'N/A'}>
title={
createdFromNow !== 'N/A'
? `Created ${createdFromNow}`
: 'N/A'
}
>
<Typography.Text type="secondary" style={{ fontSize: 12 }}> <Typography.Text type="secondary" style={{ fontSize: 12 }}>
{t('taskInfoTab.comments.createdBy', { {t('taskInfoTab.comments.createdBy', {
time: createdFromNow, time: createdFromNow,
user: taskFormViewModel?.task?.reporter || '' user: taskFormViewModel?.task?.reporter || '',
})} })}
</Typography.Text> </Typography.Text>
</Tooltip> </Tooltip>
<Tooltip <Tooltip title={updatedFromNow !== 'N/A' ? `Updated ${updatedFromNow}` : 'N/A'}>
title={
updatedFromNow !== 'N/A'
? `Updated ${updatedFromNow}`
: 'N/A'
}
>
<Typography.Text type="secondary" style={{ fontSize: 12 }}> <Typography.Text type="secondary" style={{ fontSize: 12 }}>
{t('taskInfoTab.comments.updatedTime', { {t('taskInfoTab.comments.updatedTime', {
time: updatedFromNow time: updatedFromNow,
})} })}
</Typography.Text> </Typography.Text>
</Tooltip> </Tooltip>

View File

@@ -103,12 +103,14 @@ const NotifyMemberSelector = ({ task, t }: NotifyMemberSelectorProps) => {
dispatch(setTaskSubscribers(data)); dispatch(setTaskSubscribers(data));
// Update Redux state with subscriber status // Update Redux state with subscriber status
dispatch(updateTaskCounts({ dispatch(
updateTaskCounts({
taskId: task.id, taskId: task.id,
counts: { counts: {
has_subscribers: data && data.length > 0 has_subscribers: data && data.length > 0,
} },
})); })
);
}); });
} catch (error) { } catch (error) {
logger.error('Error notifying member:', error); logger.error('Error notifying member:', error);

View File

@@ -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 { useState, useMemo, useEffect } from 'react';
import { DeleteOutlined, EditOutlined, ExclamationCircleFilled } from '@/shared/antd-imports'; import { DeleteOutlined, EditOutlined, ExclamationCircleFilled } from '@/shared/antd-imports';
import { nanoid } from '@reduxjs/toolkit'; import { nanoid } from '@reduxjs/toolkit';

View File

@@ -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 React, { useEffect, useState, useRef } from 'react';
import { ReloadOutlined } from '@/shared/antd-imports'; import { ReloadOutlined } from '@/shared/antd-imports';
import DescriptionEditor from './description-editor'; import DescriptionEditor from './description-editor';
@@ -150,7 +158,7 @@ const TaskDrawerInfoTab = ({ t }: TaskDrawerInfoTabProps) => {
label: <Typography.Text strong>{t('taskInfoTab.dependencies.title')}</Typography.Text>, label: <Typography.Text strong>{t('taskInfoTab.dependencies.title')}</Typography.Text>,
children: ( children: (
<DependenciesTable <DependenciesTable
task={(taskFormViewModel?.task as ITaskViewModel) || {} as ITaskViewModel} task={(taskFormViewModel?.task as ITaskViewModel) || ({} as ITaskViewModel)}
t={t} t={t}
taskDependencies={taskDependencies} taskDependencies={taskDependencies}
loadingTaskDependencies={loadingTaskDependencies} loadingTaskDependencies={loadingTaskDependencies}
@@ -218,12 +226,14 @@ const TaskDrawerInfoTab = ({ t }: TaskDrawerInfoTabProps) => {
setTaskDependencies(res.body); setTaskDependencies(res.body);
// Update Redux state with the current dependency status // Update Redux state with the current dependency status
dispatch(updateTaskCounts({ dispatch(
updateTaskCounts({
taskId: selectedTaskId, taskId: selectedTaskId,
counts: { counts: {
has_dependencies: res.body.length > 0 has_dependencies: res.body.length > 0,
} },
})); })
);
} }
} catch (error) { } catch (error) {
logger.error('Error fetching task dependencies:', error); logger.error('Error fetching task dependencies:', error);
@@ -241,12 +251,14 @@ const TaskDrawerInfoTab = ({ t }: TaskDrawerInfoTabProps) => {
setTaskAttachments(res.body); setTaskAttachments(res.body);
// Update Redux state with the current attachment count // Update Redux state with the current attachment count
dispatch(updateTaskCounts({ dispatch(
updateTaskCounts({
taskId: selectedTaskId, taskId: selectedTaskId,
counts: { counts: {
attachments_count: res.body.length attachments_count: res.body.length,
} },
})); })
);
} }
} catch (error) { } catch (error) {
logger.error('Error fetching task attachments:', error); logger.error('Error fetching task attachments:', error);

View File

@@ -230,7 +230,9 @@ const TimeLogForm = ({
<Form.Item <Form.Item
name="startTime" name="startTime"
label={t('taskTimeLogTab.timeLogForm.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" /> <TimePicker format="HH:mm" />
</Form.Item> </Form.Item>
@@ -238,14 +240,20 @@ const TimeLogForm = ({
<Form.Item <Form.Item
name="endTime" name="endTime"
label={t('taskTimeLogTab.timeLogForm.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" /> <TimePicker format="HH:mm" />
</Form.Item> </Form.Item>
</Flex> </Flex>
</Form.Item> </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')} /> <Input.TextArea placeholder={t('taskTimeLogTab.timeLogForm.descriptionPlaceholder')} />
</Form.Item> </Form.Item>
@@ -258,7 +266,9 @@ const TimeLogForm = ({
disabled={!isFormValid()} disabled={!isFormValid()}
htmlType="submit" htmlType="submit"
> >
{mode === 'edit' ? t('taskTimeLogTab.timeLogForm.updateTime') : t('taskTimeLogTab.timeLogForm.logTime')} {mode === 'edit'
? t('taskTimeLogTab.timeLogForm.updateTime')
: t('taskTimeLogTab.timeLogForm.logTime')}
</Button> </Button>
</Flex> </Flex>
</Form.Item> </Form.Item>

View File

@@ -29,6 +29,6 @@
border-radius: 4px; 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); background-color: rgba(255, 255, 255, 0.05);
} }

View File

@@ -18,7 +18,10 @@ import { deleteTask } from '@/features/tasks/tasks.slice';
import { deleteTask as deleteTaskFromManagement } from '@/features/task-management/task-management.slice'; import { deleteTask as deleteTaskFromManagement } from '@/features/task-management/task-management.slice';
import { deselectTask } from '@/features/task-management/selection.slice'; import { deselectTask } from '@/features/task-management/selection.slice';
import { deleteBoardTask } from '@/features/board/board-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 useTabSearchParam from '@/hooks/useTabSearchParam';
import { ITaskViewModel } from '@/types/tasks/task.types'; import { ITaskViewModel } from '@/types/tasks/task.types';
import TaskHierarchyBreadcrumb from '../task-hierarchy-breadcrumb/task-hierarchy-breadcrumb'; import TaskHierarchyBreadcrumb from '../task-hierarchy-breadcrumb/task-hierarchy-breadcrumb';
@@ -46,7 +49,8 @@ const TaskDrawerHeader = ({ inputRef, t }: TaskDrawerHeaderProps) => {
const currentSession = useAuthService().getCurrentSession(); const currentSession = useAuthService().getCurrentSession();
// Check if current task is a sub-task // 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(() => { useEffect(() => {
setTaskName(taskFormViewModel?.task?.name ?? ''); setTaskName(taskFormViewModel?.task?.name ?? '');
@@ -75,11 +79,17 @@ const TaskDrawerHeader = ({ inputRef, t }: TaskDrawerHeaderProps) => {
dispatch(deleteTask({ taskId: selectedTaskId })); dispatch(deleteTask({ taskId: selectedTaskId }));
dispatch(deleteBoardTask({ sectionId: '', taskId: selectedTaskId })); dispatch(deleteBoardTask({ sectionId: '', taskId: selectedTaskId }));
if (taskFormViewModel?.task?.is_sub_task) { if (taskFormViewModel?.task?.is_sub_task) {
dispatch(updateEnhancedKanbanSubtask({ dispatch(
updateEnhancedKanbanSubtask({
sectionId: '', 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', mode: 'delete',
})); })
);
} else { } else {
dispatch(deleteKanbanTask(selectedTaskId)); // <-- Add this line dispatch(deleteKanbanTask(selectedTaskId)); // <-- Add this line
} }
@@ -166,10 +176,7 @@ const TaskDrawerHeader = ({ inputRef, t }: TaskDrawerHeaderProps) => {
/> />
) : ( ) : (
<Tooltip title={shouldShowTooltip ? displayTaskName : ''} trigger="hover"> <Tooltip title={shouldShowTooltip ? displayTaskName : ''} trigger="hover">
<p <p onClick={() => setIsEditing(true)} className="task-name-display">
onClick={() => setIsEditing(true)}
className="task-name-display"
>
{truncatedTaskName} {truncatedTaskName}
</p> </p>
</Tooltip> </Tooltip>
@@ -182,7 +189,10 @@ const TaskDrawerHeader = ({ inputRef, t }: TaskDrawerHeaderProps) => {
teamId={currentSession?.team_id ?? ''} 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 />} /> <Button type="text" icon={<EllipsisOutlined />} />
</Dropdown> </Dropdown>
</Flex> </Flex>

View File

@@ -60,10 +60,12 @@ const TaskDrawer = () => {
if (taskFormViewModel?.task?.parent_task_id && projectId) { if (taskFormViewModel?.task?.parent_task_id && projectId) {
// Navigate to parent task // Navigate to parent task
dispatch(setSelectedTaskId(taskFormViewModel.task.parent_task_id)); dispatch(setSelectedTaskId(taskFormViewModel.task.parent_task_id));
dispatch(fetchTask({ dispatch(
fetchTask({
taskId: taskFormViewModel.task.parent_task_id, taskId: taskFormViewModel.task.parent_task_id,
projectId projectId,
})); })
);
} }
}; };
@@ -217,7 +219,8 @@ const TaskDrawer = () => {
}; };
// Check if current task is a sub-task // 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 // Custom close icon based on whether it's a sub-task
const getCloseIcon = () => { const getCloseIcon = () => {

View File

@@ -17,7 +17,7 @@
} }
/* Dark mode styles */ /* Dark mode styles */
[data-theme='dark'] .task-hierarchy-breadcrumb .ant-breadcrumb-separator { [data-theme="dark"] .task-hierarchy-breadcrumb .ant-breadcrumb-separator {
color: #595959; color: #595959;
} }
@@ -43,7 +43,7 @@
color: #40a9ff; color: #40a9ff;
} }
[data-theme='dark'] .task-hierarchy-breadcrumb .ant-btn-link:hover { [data-theme="dark"] .task-hierarchy-breadcrumb .ant-btn-link:hover {
color: #40a9ff; color: #40a9ff;
} }
@@ -60,7 +60,7 @@
line-height: 1.3; line-height: 1.3;
} }
[data-theme='dark'] .task-hierarchy-breadcrumb .current-task-name { [data-theme="dark"] .task-hierarchy-breadcrumb .current-task-name {
color: #ffffffd9; color: #ffffffd9;
} }

View File

@@ -52,7 +52,7 @@ const TaskHierarchyBreadcrumb: React.FC<TaskHierarchyBreadcrumbProps> = ({ t, on
path.unshift({ path.unshift({
id: taskData.id, id: taskData.id,
name: taskData.name || '', name: taskData.name || '',
parent_task_id: taskData.parent_task_id || undefined parent_task_id: taskData.parent_task_id || undefined,
}); });
// Move to parent task // Move to parent task

View File

@@ -13,7 +13,7 @@ const GroupProgressBar: React.FC<GroupProgressBarProps> = ({
todoProgress, todoProgress,
doingProgress, doingProgress,
doneProgress, doneProgress,
groupType groupType,
}) => { }) => {
const { t } = useTranslation('task-management'); const { t } = useTranslation('task-management');
console.log(todoProgress, doingProgress, doneProgress); console.log(todoProgress, doingProgress, doneProgress);
@@ -33,9 +33,15 @@ const GroupProgressBar: React.FC<GroupProgressBarProps> = ({
// Tooltip content with all values in rows // Tooltip content with all values in rows
const tooltipContent = ( const tooltipContent = (
<div> <div>
<div>{t('todo')}: {todoProgress || 0}%</div> <div>
<div>{t('inProgress')}: {doingProgress || 0}%</div> {t('todo')}: {todoProgress || 0}%
<div>{t('done')}: {doneProgress || 0}%</div> </div>
<div>
{t('inProgress')}: {doingProgress || 0}%
</div>
<div>
{t('done')}: {doneProgress || 0}%
</div>
</div> </div>
); );
@@ -83,23 +89,17 @@ const GroupProgressBar: React.FC<GroupProgressBarProps> = ({
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{todoProgress > 0 && ( {todoProgress > 0 && (
<Tooltip title={tooltipContent} placement="top"> <Tooltip title={tooltipContent} placement="top">
<div <div className="w-1.5 h-1.5 bg-green-200 dark:bg-green-800 rounded-full" />
className="w-1.5 h-1.5 bg-green-200 dark:bg-green-800 rounded-full"
/>
</Tooltip> </Tooltip>
)} )}
{doingProgress > 0 && ( {doingProgress > 0 && (
<Tooltip title={tooltipContent} placement="top"> <Tooltip title={tooltipContent} placement="top">
<div <div className="w-1.5 h-1.5 bg-green-400 dark:bg-green-600 rounded-full" />
className="w-1.5 h-1.5 bg-green-400 dark:bg-green-600 rounded-full"
/>
</Tooltip> </Tooltip>
)} )}
{doneProgress > 0 && ( {doneProgress > 0 && (
<Tooltip title={tooltipContent} placement="top"> <Tooltip title={tooltipContent} placement="top">
<div <div className="w-1.5 h-1.5 bg-green-600 dark:bg-green-400 rounded-full" />
className="w-1.5 h-1.5 bg-green-600 dark:bg-green-400 rounded-full"
/>
</Tooltip> </Tooltip>
)} )}
</div> </div>

View File

@@ -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="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"> <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) => ( {visibleColumns.map((column, index) => (
<div key={column.id}> <div key={column.id}>{renderColumn(column.id, column.width)}</div>
{renderColumn(column.id, column.width)}
</div>
))} ))}
</div> </div>
</div> </div>

View File

@@ -511,7 +511,11 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({
{/* Progress Bar - sticky to the right edge during horizontal scroll */} {/* Progress Bar - sticky to the right edge during horizontal scroll */}
{(currentGrouping === 'priority' || currentGrouping === 'phase') && {(currentGrouping === 'priority' || currentGrouping === 'phase') &&
!(groupProgressValues.todoProgress === 0 && groupProgressValues.doingProgress === 0 && groupProgressValues.doneProgress === 0) && ( !(
groupProgressValues.todoProgress === 0 &&
groupProgressValues.doingProgress === 0 &&
groupProgressValues.doneProgress === 0
) && (
<div <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" 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={{ style={{

View File

@@ -1,8 +1,7 @@
import ImprovedTaskFilters from "../task-management/improved-task-filters"; import ImprovedTaskFilters from '../task-management/improved-task-filters';
import TaskListV2Section from "./TaskListV2Table"; import TaskListV2Section from './TaskListV2Table';
const TaskListV2: React.FC = () => { const TaskListV2: React.FC = () => {
return ( return (
<div> <div>
{/* Task Filters */} {/* Task Filters */}

View File

@@ -89,7 +89,10 @@ const EmptyGroupDropZone: React.FC<{
isOver && active ? 'bg-blue-50 dark:bg-blue-900/20' : '' 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) => { {visibleColumns.map((column, index) => {
const emptyColumnStyle = { const emptyColumnStyle = {
width: column.width, width: column.width,
@@ -208,7 +211,7 @@ const TaskListV2Section: React.FC = () => {
// State hooks // State hooks
const [initializedFromDatabase, setInitializedFromDatabase] = useState(false); const [initializedFromDatabase, setInitializedFromDatabase] = useState(false);
const [addTaskRows, setAddTaskRows] = useState<{[groupId: string]: string[]}>({}); const [addTaskRows, setAddTaskRows] = useState<{ [groupId: string]: string[] }>({});
// Configure sensors for drag and drop // Configure sensors for drag and drop
const sensors = useSensors( const sensors = useSensors(
@@ -460,7 +463,7 @@ const TaskListV2Section: React.FC = () => {
const newRowId = `add-task-${groupId}-${currentRows.length + 1}`; const newRowId = `add-task-${groupId}-${currentRows.length + 1}`;
return { return {
...prev, ...prev,
[groupId]: [...currentRows, newRowId] [groupId]: [...currentRows, newRowId],
}; };
}); });
}, []); }, []);
@@ -513,7 +516,7 @@ const TaskListV2Section: React.FC = () => {
projectId: urlProjectId, projectId: urlProjectId,
rowId: rowId, rowId: rowId,
autoFocus: index === groupAddRows.length - 1, // Auto-focus the latest row autoFocus: index === groupAddRows.length - 1, // Auto-focus the latest row
})) })),
] ]
: []; : [];
@@ -542,7 +545,6 @@ const TaskListV2Section: React.FC = () => {
return virtuosoGroups.flatMap(group => group.tasks); return virtuosoGroups.flatMap(group => group.tasks);
}, [virtuosoGroups]); }, [virtuosoGroups]);
// Render functions // Render functions
const renderGroup = useCallback( const renderGroup = useCallback(
(groupIndex: number) => { (groupIndex: number) => {
@@ -564,11 +566,7 @@ const TaskListV2Section: React.FC = () => {
projectId={urlProjectId || ''} projectId={urlProjectId || ''}
/> />
{isGroupEmpty && !isGroupCollapsed && ( {isGroupEmpty && !isGroupCollapsed && (
<EmptyGroupDropZone <EmptyGroupDropZone groupId={group.id} visibleColumns={visibleColumns} t={t} />
groupId={group.id}
visibleColumns={visibleColumns}
t={t}
/>
)} )}
</div> </div>
); );
@@ -703,7 +701,7 @@ const TaskListV2Section: React.FC = () => {
color: '#fbc84c69', color: '#fbc84c69',
actualCount: 0, actualCount: 0,
count: 1, // For the add task row count: 1, // For the add task row
startIndex: 0 startIndex: 0,
}; };
return ( return (
@@ -841,12 +839,13 @@ const TaskListV2Section: React.FC = () => {
{renderGroup(groupIndex)} {renderGroup(groupIndex)}
{/* Group Tasks */} {/* Group Tasks */}
{!collapsedGroups.has(group.id) && ( {!collapsedGroups.has(group.id) &&
group.tasks.length > 0 ? ( (group.tasks.length > 0
group.tasks.map((task, taskIndex) => { ? group.tasks.map((task, taskIndex) => {
const globalTaskIndex = const globalTaskIndex =
virtuosoGroups.slice(0, groupIndex).reduce((sum, g) => sum + g.count, 0) + virtuosoGroups
taskIndex; .slice(0, groupIndex)
.reduce((sum, g) => sum + g.count, 0) + taskIndex;
// Check if this is the first actual task in the group (not AddTaskRow) // Check if this is the first actual task in the group (not AddTaskRow)
const isFirstTaskInGroup = taskIndex === 0 && !('isAddTaskRow' in task); const isFirstTaskInGroup = taskIndex === 0 && !('isAddTaskRow' in task);
@@ -854,38 +853,49 @@ const TaskListV2Section: React.FC = () => {
// Check if we should show drop indicators // Check if we should show drop indicators
const isTaskBeingDraggedOver = overId === task.id; const isTaskBeingDraggedOver = overId === task.id;
const isGroupBeingDraggedOver = overId === group.id; const isGroupBeingDraggedOver = overId === group.id;
const isFirstTaskInGroupBeingDraggedOver = isFirstTaskInGroup && isTaskBeingDraggedOver; const isFirstTaskInGroupBeingDraggedOver =
isFirstTaskInGroup && isTaskBeingDraggedOver;
return ( return (
<div key={task.id || `add-task-${group.id}-${taskIndex}`}> <div key={task.id || `add-task-${group.id}-${taskIndex}`}>
{/* Placeholder drop indicator before first task in group */} {/* Placeholder drop indicator before first task in group */}
{isFirstTaskInGroupBeingDraggedOver && ( {isFirstTaskInGroupBeingDraggedOver && (
<PlaceholderDropIndicator isVisible={true} visibleColumns={visibleColumns} /> <PlaceholderDropIndicator
isVisible={true}
visibleColumns={visibleColumns}
/>
)} )}
{/* Placeholder drop indicator between tasks */} {/* Placeholder drop indicator between tasks */}
{isTaskBeingDraggedOver && !isFirstTaskInGroup && ( {isTaskBeingDraggedOver && !isFirstTaskInGroup && (
<PlaceholderDropIndicator isVisible={true} visibleColumns={visibleColumns} /> <PlaceholderDropIndicator
isVisible={true}
visibleColumns={visibleColumns}
/>
)} )}
{renderTask(globalTaskIndex, isFirstTaskInGroup)} {renderTask(globalTaskIndex, isFirstTaskInGroup)}
{/* Placeholder drop indicator at end of group when dragging over group */} {/* Placeholder drop indicator at end of group when dragging over group */}
{isGroupBeingDraggedOver && taskIndex === group.tasks.length - 1 && ( {isGroupBeingDraggedOver &&
<PlaceholderDropIndicator isVisible={true} visibleColumns={visibleColumns} /> taskIndex === group.tasks.length - 1 && (
<PlaceholderDropIndicator
isVisible={true}
visibleColumns={visibleColumns}
/>
)} )}
</div> </div>
); );
}) })
) : ( : // Handle empty groups with placeholder drop indicator
// Handle empty groups with placeholder drop indicator
overId === group.id && ( overId === group.id && (
<div style={{ minWidth: 'max-content' }}> <div style={{ minWidth: 'max-content' }}>
<PlaceholderDropIndicator isVisible={true} visibleColumns={visibleColumns} /> <PlaceholderDropIndicator
isVisible={true}
visibleColumns={visibleColumns}
/>
</div> </div>
) ))}
)
)}
</div> </div>
))} ))}
</div> </div>

View File

@@ -27,15 +27,16 @@ interface TaskRowProps {
depth?: number; depth?: number;
} }
const TaskRow: React.FC<TaskRowProps> = memo(({ const TaskRow: React.FC<TaskRowProps> = memo(
({
taskId, taskId,
projectId, projectId,
visibleColumns, visibleColumns,
isSubtask = false, isSubtask = false,
isFirstInGroup = false, isFirstInGroup = false,
updateTaskCustomColumnValue, updateTaskCustomColumnValue,
depth = 0 depth = 0,
}) => { }) => {
// Get task data and selection state from Redux // Get task data and selection state from Redux
const task = useAppSelector(state => selectTaskById(state, taskId)); const task = useAppSelector(state => selectTaskById(state, taskId));
const isSelected = useAppSelector(state => selectIsTaskSelected(state, taskId)); const isSelected = useAppSelector(state => selectIsTaskSelected(state, taskId));
@@ -62,11 +63,7 @@ const TaskRow: React.FC<TaskRowProps> = memo(({
labelsAdapter, labelsAdapter,
} = useTaskRowState(task); } = useTaskRowState(task);
const { const { handleCheckboxChange, handleTaskNameSave, handleTaskNameEdit } = useTaskRowActions({
handleCheckboxChange,
handleTaskNameSave,
handleTaskNameEdit,
} = useTaskRowActions({
task, task,
taskId, taskId,
taskName, taskName,
@@ -113,11 +110,14 @@ const TaskRow: React.FC<TaskRowProps> = memo(({
}); });
// Memoize style object to prevent unnecessary re-renders // Memoize style object to prevent unnecessary re-renders
const style = useMemo(() => ({ const style = useMemo(
() => ({
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
transition, transition,
opacity: isDragging ? 0 : 1, // Completely hide the original task while dragging opacity: isDragging ? 0 : 1, // Completely hide the original task while dragging
}), [transform, transition, isDragging]); }),
[transform, transition, isDragging]
);
return ( return (
<div <div
@@ -125,9 +125,7 @@ const TaskRow: React.FC<TaskRowProps> = memo(({
style={{ ...style, height: '40px' }} 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 ${ 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' : '' 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) => ( {visibleColumns.map((column, index) => (
<React.Fragment key={column.id}> <React.Fragment key={column.id}>
@@ -136,7 +134,8 @@ const TaskRow: React.FC<TaskRowProps> = memo(({
))} ))}
</div> </div>
); );
}); }
);
TaskRow.displayName = 'TaskRow'; TaskRow.displayName = 'TaskRow';

View File

@@ -1,7 +1,11 @@
import React, { memo, useState, useCallback, useRef, useEffect } from 'react'; import React, { memo, useState, useCallback, useRef, useEffect } from 'react';
import { useAppSelector } from '@/hooks/useAppSelector'; import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch'; 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 TaskRow from './TaskRow';
import SubtaskLoadingSkeleton from './SubtaskLoadingSkeleton'; import SubtaskLoadingSkeleton from './SubtaskLoadingSkeleton';
import { Task } from '@/types/task-management.types'; import { Task } from '@/types/task-management.types';
@@ -42,7 +46,8 @@ interface AddSubtaskRowProps {
depth?: number; // Add depth prop for proper indentation depth?: number; // Add depth prop for proper indentation
} }
const AddSubtaskRow: React.FC<AddSubtaskRowProps> = memo(({ const AddSubtaskRow: React.FC<AddSubtaskRowProps> = memo(
({
parentTaskId, parentTaskId,
projectId, projectId,
visibleColumns, visibleColumns,
@@ -51,8 +56,8 @@ const AddSubtaskRow: React.FC<AddSubtaskRowProps> = memo(({
autoFocus = false, autoFocus = false,
isActive = true, isActive = true,
onActivate, onActivate,
depth = 0 depth = 0,
}) => { }) => {
const { t } = useTranslation('task-list-table'); const { t } = useTranslation('task-list-table');
const [isAdding, setIsAdding] = useState(false); const [isAdding, setIsAdding] = useState(false);
const [subtaskName, setSubtaskName] = useState(''); const [subtaskName, setSubtaskName] = useState('');
@@ -71,11 +76,13 @@ const AddSubtaskRow: React.FC<AddSubtaskRowProps> = memo(({
if (!subtaskName.trim() || !currentSession) return; if (!subtaskName.trim() || !currentSession) return;
// Create optimistic subtask immediately for better UX // Create optimistic subtask immediately for better UX
dispatch(createSubtask({ dispatch(
createSubtask({
parentTaskId, parentTaskId,
name: subtaskName.trim(), name: subtaskName.trim(),
projectId projectId,
})); })
);
// Emit socket event for server-side creation // Emit socket event for server-side creation
if (connected && socket) { if (connected && socket) {
@@ -103,7 +110,16 @@ const AddSubtaskRow: React.FC<AddSubtaskRowProps> = memo(({
// Notify parent that subtask was added // Notify parent that subtask was added
onSubtaskAdded(); onSubtaskAdded();
}, [subtaskName, dispatch, parentTaskId, projectId, connected, socket, currentSession, onSubtaskAdded]); }, [
subtaskName,
dispatch,
parentTaskId,
projectId,
connected,
socket,
currentSession,
onSubtaskAdded,
]);
const handleCancel = useCallback(() => { const handleCancel = useCallback(() => {
setSubtaskName(''); setSubtaskName('');
@@ -117,13 +133,17 @@ const AddSubtaskRow: React.FC<AddSubtaskRowProps> = memo(({
} }
}, [subtaskName, handleCancel]); }, [subtaskName, handleCancel]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => { const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
handleCancel(); handleCancel();
} }
}, [handleCancel]); },
[handleCancel]
);
const renderColumn = useCallback((columnId: string, width: string) => { const renderColumn = useCallback(
(columnId: string, width: string) => {
const baseStyle = { width }; const baseStyle = { width };
switch (columnId) { switch (columnId) {
@@ -165,7 +185,7 @@ const AddSubtaskRow: React.FC<AddSubtaskRowProps> = memo(({
<Input <Input
ref={inputRef} ref={inputRef}
value={subtaskName} value={subtaskName}
onChange={(e) => setSubtaskName(e.target.value)} onChange={e => setSubtaskName(e.target.value)}
onPressEnter={handleAddSubtask} onPressEnter={handleAddSubtask}
onBlur={handleBlur} onBlur={handleBlur}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
@@ -175,7 +195,7 @@ const AddSubtaskRow: React.FC<AddSubtaskRowProps> = memo(({
height: '100%', height: '100%',
minHeight: '32px', minHeight: '32px',
padding: '4px 8px', padding: '4px 8px',
fontSize: '14px' fontSize: '14px',
}} }}
autoFocus autoFocus
/> />
@@ -190,18 +210,30 @@ const AddSubtaskRow: React.FC<AddSubtaskRowProps> = memo(({
default: default:
return <div style={baseStyle} />; 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 ( 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"> <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) => ( {visibleColumns.map((column, index) => (
<React.Fragment key={column.id}> <React.Fragment key={column.id}>{renderColumn(column.id, column.width)}</React.Fragment>
{renderColumn(column.id, column.width)}
</React.Fragment>
))} ))}
</div> </div>
); );
}); }
);
AddSubtaskRow.displayName = 'AddSubtaskRow'; AddSubtaskRow.displayName = 'AddSubtaskRow';
@@ -233,15 +265,16 @@ const getBorderColor = (depth: number) => {
} }
}; };
const TaskRowWithSubtasks: React.FC<TaskRowWithSubtasksProps> = memo(({ const TaskRowWithSubtasks: React.FC<TaskRowWithSubtasksProps> = memo(
({
taskId, taskId,
projectId, projectId,
visibleColumns, visibleColumns,
isFirstInGroup = false, isFirstInGroup = false,
updateTaskCustomColumnValue, updateTaskCustomColumnValue,
depth = 0, depth = 0,
maxDepth = 3 maxDepth = 3,
}) => { }) => {
const task = useAppSelector(state => selectTaskById(state, taskId)); const task = useAppSelector(state => selectTaskById(state, taskId));
const isLoadingSubtasks = useAppSelector(state => selectSubtaskLoading(state, taskId)); const isLoadingSubtasks = useAppSelector(state => selectSubtaskLoading(state, taskId));
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@@ -282,8 +315,12 @@ const TaskRowWithSubtasks: React.FC<TaskRowWithSubtasksProps> = memo(({
)} )}
{/* Render existing subtasks when not loading - RECURSIVELY */} {/* Render existing subtasks when not loading - RECURSIVELY */}
{!isLoadingSubtasks && task.sub_tasks?.map((subtask: Task) => ( {!isLoadingSubtasks &&
<div key={subtask.id} className={`${getSubtaskBackgroundColor(depth + 1)} border-l-2 ${getBorderColor(depth + 1)}`}> task.sub_tasks?.map((subtask: Task) => (
<div
key={subtask.id}
className={`${getSubtaskBackgroundColor(depth + 1)} border-l-2 ${getBorderColor(depth + 1)}`}
>
<TaskRowWithSubtasks <TaskRowWithSubtasks
taskId={subtask.id} taskId={subtask.id}
projectId={projectId} projectId={projectId}
@@ -297,7 +334,9 @@ const TaskRowWithSubtasks: React.FC<TaskRowWithSubtasksProps> = memo(({
{/* Add subtask row - only show when not loading */} {/* Add subtask row - only show when not loading */}
{!isLoadingSubtasks && ( {!isLoadingSubtasks && (
<div className={`${getSubtaskBackgroundColor(depth + 1)} border-l-2 ${getBorderColor(depth + 1)}`}> <div
className={`${getSubtaskBackgroundColor(depth + 1)} border-l-2 ${getBorderColor(depth + 1)}`}
>
<AddSubtaskRow <AddSubtaskRow
parentTaskId={taskId} parentTaskId={taskId}
projectId={projectId} projectId={projectId}
@@ -315,7 +354,8 @@ const TaskRowWithSubtasks: React.FC<TaskRowWithSubtasksProps> = memo(({
)} )}
</> </>
); );
}); }
);
TaskRowWithSubtasks.displayName = 'TaskRowWithSubtasks'; TaskRowWithSubtasks.displayName = 'TaskRowWithSubtasks';

View File

@@ -21,7 +21,8 @@ interface AddTaskRowProps {
autoFocus?: boolean; // Whether this row should auto-focus on mount autoFocus?: boolean; // Whether this row should auto-focus on mount
} }
const AddTaskRow: React.FC<AddTaskRowProps> = memo(({ const AddTaskRow: React.FC<AddTaskRowProps> = memo(
({
groupId, groupId,
groupType, groupType,
groupValue, groupValue,
@@ -29,8 +30,8 @@ const AddTaskRow: React.FC<AddTaskRowProps> = memo(({
visibleColumns, visibleColumns,
onTaskAdded, onTaskAdded,
rowId, rowId,
autoFocus = false autoFocus = false,
}) => { }) => {
const [isAdding, setIsAdding] = useState(autoFocus); const [isAdding, setIsAdding] = useState(autoFocus);
const [taskName, setTaskName] = useState(''); const [taskName, setTaskName] = useState('');
const inputRef = useRef<any>(null); const inputRef = useRef<any>(null);
@@ -93,7 +94,17 @@ const AddTaskRow: React.FC<AddTaskRowProps> = memo(({
} catch (error) { } catch (error) {
console.error('Error creating task:', 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(() => { const handleCancel = useCallback(() => {
if (taskName.trim() === '') { if (taskName.trim() === '') {
@@ -102,13 +113,17 @@ const AddTaskRow: React.FC<AddTaskRowProps> = memo(({
} }
}, [taskName]); }, [taskName]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => { const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
handleCancel(); handleCancel();
} }
}, [handleCancel]); },
[handleCancel]
);
const renderColumn = useCallback((columnId: string, width: string) => { const renderColumn = useCallback(
(columnId: string, width: string) => {
const baseStyle = { width }; const baseStyle = { width };
switch (columnId) { switch (columnId) {
@@ -116,13 +131,17 @@ const AddTaskRow: React.FC<AddTaskRowProps> = memo(({
case 'checkbox': case 'checkbox':
case 'taskKey': case 'taskKey':
case 'description': 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': case 'labels':
const labelsStyle = { const labelsStyle = {
...baseStyle, ...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': case 'title':
return ( return (
<div className="flex items-center h-full" style={baseStyle}> <div className="flex items-center h-full" style={baseStyle}>
@@ -141,7 +160,7 @@ const AddTaskRow: React.FC<AddTaskRowProps> = memo(({
<Input <Input
ref={inputRef} ref={inputRef}
value={taskName} value={taskName}
onChange={(e) => setTaskName(e.target.value)} onChange={e => setTaskName(e.target.value)}
onPressEnter={handleAddTask} onPressEnter={handleAddTask}
onBlur={handleCancel} onBlur={handleCancel}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
@@ -151,7 +170,7 @@ const AddTaskRow: React.FC<AddTaskRowProps> = memo(({
height: '100%', height: '100%',
minHeight: '32px', minHeight: '32px',
padding: '4px 8px', padding: '4px 8px',
fontSize: '14px' fontSize: '14px',
}} }}
autoFocus autoFocus
/> />
@@ -160,20 +179,23 @@ const AddTaskRow: React.FC<AddTaskRowProps> = memo(({
</div> </div>
); );
default: 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 ( 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]"> <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) => ( {visibleColumns.map((column, index) => (
<React.Fragment key={column.id}> <React.Fragment key={column.id}>{renderColumn(column.id, column.width)}</React.Fragment>
{renderColumn(column.id, column.width)}
</React.Fragment>
))} ))}
</div> </div>
); );
}); }
);
AddTaskRow.displayName = 'AddTaskRow'; AddTaskRow.displayName = 'AddTaskRow';

View File

@@ -31,7 +31,8 @@ export const AddCustomColumnButton: React.FC = memo(() => {
className={` className={`
group relative w-9 h-9 rounded-lg border-2 border-dashed transition-all duration-200 group relative w-9 h-9 rounded-lg border-2 border-dashed transition-all duration-200
flex items-center justify-center 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-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' : '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" /> <PlusOutlined className="text-sm transition-transform duration-200 group-hover:scale-110" />
{/* Subtle glow effect on hover */} {/* Subtle glow effect on hover */}
<div className={` <div
className={`
absolute inset-0 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 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/20'
: 'bg-blue-500/5 shadow-lg shadow-blue-500/10' : 'bg-blue-500/5 shadow-lg shadow-blue-500/10'
} }
`} /> `}
/>
</button> </button>
</Tooltip> </Tooltip>
); );
@@ -62,7 +66,8 @@ export const CustomColumnHeader: React.FC<{
const { t } = useTranslation('task-list-table'); const { t } = useTranslation('task-list-table');
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const displayName = column.name || const displayName =
column.name ||
column.label || column.label ||
column.custom_column_obj?.fieldTitle || column.custom_column_obj?.fieldTitle ||
column.custom_column_obj?.field_title || column.custom_column_obj?.field_title ||
@@ -77,7 +82,9 @@ export const CustomColumnHeader: React.FC<{
onMouseLeave={() => setIsHovered(false)} onMouseLeave={() => setIsHovered(false)}
onClick={() => onSettingsClick(column.key || column.id)} 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')}> <Tooltip title={t('customColumns.customColumnSettings')}>
<SettingOutlined <SettingOutlined
className={`hover:text-blue-600 dark:hover:text-blue-400 transition-all duration-200 flex-shrink-0 ${ 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: 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)); return members.data.filter(member => displayedMemberIds.includes(member.id));
}, [members, displayedMemberIds]); }, [members, displayedMemberIds]);
const handleMemberToggle = useCallback((memberId: string, checked: boolean) => { const handleMemberToggle = useCallback(
(memberId: string, checked: boolean) => {
// Add to pending changes for visual feedback // Add to pending changes for visual feedback
setPendingChanges(prev => new Set(prev).add(memberId)); setPendingChanges(prev => new Set(prev).add(memberId));
@@ -223,7 +233,9 @@ export const PeopleCustomColumnCell: React.FC<{
return newSet; return newSet;
}); });
}, 1500); // Even longer delay to ensure socket update is fully processed }, 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 () => { const loadMembers = useCallback(async () => {
if (members?.data?.length === 0) { if (members?.data?.length === 0) {
@@ -291,7 +303,7 @@ export const DateCustomColumnCell: React.FC<{
onOpenChange={setIsOpen} onOpenChange={setIsOpen}
value={dateValue} value={dateValue}
onChange={handleDateChange} onChange={handleDateChange}
placeholder={dateValue ? "" : "Set date"} placeholder={dateValue ? '' : 'Set date'}
format="MMM DD, YYYY" format="MMM DD, YYYY"
suffixIcon={null} suffixIcon={null}
size="small" size="small"
@@ -302,7 +314,7 @@ export const DateCustomColumnCell: React.FC<{
`} `}
popupClassName={isDarkMode ? 'dark-date-picker' : 'light-date-picker'} popupClassName={isDarkMode ? 'dark-date-picker' : 'light-date-picker'}
inputReadOnly inputReadOnly
getPopupContainer={(trigger) => trigger.parentElement || document.body} getPopupContainer={trigger => trigger.parentElement || document.body}
style={{ style={{
backgroundColor: 'transparent', backgroundColor: 'transparent',
border: 'none', border: 'none',
@@ -392,7 +404,9 @@ export const NumberCustomColumnCell: React.FC<{
case 'percentage': case 'percentage':
return `${numValue.toFixed(decimals)}%`; return `${numValue.toFixed(decimals)}%`;
case 'withLabel': 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: default:
return numValue.toString(); return numValue.toString();
} }
@@ -443,7 +457,9 @@ export const SelectionCustomColumnCell: React.FC<{
const isDarkMode = useAppSelector(state => state.themeReducer.mode === 'dark'); const isDarkMode = useAppSelector(state => state.themeReducer.mode === 'dark');
const selectionsList = columnObj?.selectionsList || []; 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) => { const handleOptionSelect = async (option: any) => {
if (!task.id) return; if (!task.id) return;
@@ -466,21 +482,23 @@ export const SelectionCustomColumnCell: React.FC<{
}; };
const dropdownContent = ( const dropdownContent = (
<div className={` <div
className={`
rounded-lg shadow-xl border min-w-[180px] max-h-64 overflow-y-auto custom-column-dropdown rounded-lg shadow-xl border min-w-[180px] max-h-64 overflow-y-auto custom-column-dropdown
${isDarkMode ${isDarkMode ? 'bg-gray-800 border-gray-600' : 'bg-white border-gray-200'}
? 'bg-gray-800 border-gray-600' `}
: 'bg-white border-gray-200' >
}
`}>
{/* Header */} {/* Header */}
<div className={` <div
className={`
px-3 py-2 border-b text-xs font-medium px-3 py-2 border-b text-xs font-medium
${isDarkMode ${
isDarkMode
? 'border-gray-600 text-gray-300 bg-gray-750' ? 'border-gray-600 text-gray-300 bg-gray-750'
: 'border-gray-200 text-gray-600 bg-gray-50' : 'border-gray-200 text-gray-600 bg-gray-50'
} }
`}> `}
>
Select option Select option
</div> </div>
@@ -492,7 +510,8 @@ export const SelectionCustomColumnCell: React.FC<{
onClick={() => handleOptionSelect(option)} onClick={() => handleOptionSelect(option)}
className={` className={`
flex items-center gap-3 p-2 rounded-md cursor-pointer transition-all duration-200 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 ? isDarkMode
? 'bg-blue-900/50 text-blue-200' ? 'bg-blue-900/50 text-blue-200'
: 'bg-blue-50 text-blue-700' : '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> <span className="text-sm font-medium flex-1">{option.selection_name}</span>
{selectedOption?.selection_id === option.selection_id && ( {selectedOption?.selection_id === option.selection_id && (
<div className={` <div
className={`
w-4 h-4 rounded-full flex items-center justify-center w-4 h-4 rounded-full flex items-center justify-center
${isDarkMode ? 'bg-blue-600' : 'bg-blue-500'} ${isDarkMode ? 'bg-blue-600' : 'bg-blue-500'}
`}> `}
>
<svg className="w-2.5 h-2.5 text-white" fill="currentColor" viewBox="0 0 20 20"> <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> </svg>
</div> </div>
)} )}
@@ -521,10 +546,12 @@ export const SelectionCustomColumnCell: React.FC<{
))} ))}
{selectionsList.length === 0 && ( {selectionsList.length === 0 && (
<div className={` <div
className={`
text-center py-8 text-sm text-center py-8 text-sm
${isDarkMode ? 'text-gray-500' : 'text-gray-400'} ${isDarkMode ? 'text-gray-500' : 'text-gray-400'}
`}> `}
>
<div className="mb-2">📋</div> <div className="mb-2">📋</div>
<div>No options available</div> <div>No options available</div>
</div> </div>
@@ -534,7 +561,9 @@ export const SelectionCustomColumnCell: React.FC<{
); );
return ( 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 <Dropdown
open={isDropdownOpen} open={isDropdownOpen}
onOpenChange={setIsDropdownOpen} onOpenChange={setIsDropdownOpen}
@@ -542,11 +571,13 @@ export const SelectionCustomColumnCell: React.FC<{
trigger={['click']} trigger={['click']}
placement="bottomLeft" placement="bottomLeft"
overlayClassName="custom-selection-dropdown" 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 flex items-center gap-2 cursor-pointer rounded-md px-2 py-1 min-h-[28px] transition-all duration-200 relative
${isDropdownOpen ${
isDropdownOpen
? isDarkMode ? isDarkMode
? 'bg-gray-700 ring-1 ring-blue-500/50' ? 'bg-gray-700 ring-1 ring-blue-500/50'
: 'bg-gray-100 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-700/50'
: 'hover:bg-gray-100/50' : 'hover:bg-gray-100/50'
} }
`}> `}
>
{isLoading ? ( {isLoading ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className={` <div
className={`
w-3 h-3 rounded-full animate-spin border-2 border-transparent w-3 h-3 rounded-full animate-spin border-2 border-transparent
${isDarkMode ? 'border-t-gray-400' : 'border-t-gray-600'} ${isDarkMode ? 'border-t-gray-400' : 'border-t-gray-600'}
`} /> `}
/>
<span className={`text-sm ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}> <span className={`text-sm ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
Updating... Updating...
</span> </span>
@@ -571,21 +605,45 @@ export const SelectionCustomColumnCell: React.FC<{
className="w-3 h-3 rounded-full border border-white/20 shadow-sm" className="w-3 h-3 rounded-full border border-white/20 shadow-sm"
style={{ backgroundColor: selectedOption.selection_color || '#6b7280' }} 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} {selectedOption.selection_name}
</span> </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"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> 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> </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'}`}> <span className={`text-sm ${isDarkMode ? 'text-gray-500' : 'text-gray-400'}`}>
Select Select
</span> </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"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> 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> </svg>
</> </>
)} )}

View File

@@ -18,7 +18,8 @@ interface DatePickerColumnProps {
onActiveDatePickerChange: (field: string | null) => void; onActiveDatePickerChange: (field: string | null) => void;
} }
export const DatePickerColumn: React.FC<DatePickerColumnProps> = memo(({ export const DatePickerColumn: React.FC<DatePickerColumnProps> = memo(
({
width, width,
task, task,
field, field,
@@ -26,8 +27,8 @@ export const DatePickerColumn: React.FC<DatePickerColumnProps> = memo(({
dateValue, dateValue,
isDarkMode, isDarkMode,
activeDatePicker, activeDatePicker,
onActiveDatePickerChange onActiveDatePickerChange,
}) => { }) => {
const { socket, connected } = useSocket(); const { socket, connected } = useSocket();
const { t } = useTranslation('task-list-table'); const { t } = useTranslation('task-list-table');
@@ -59,11 +60,14 @@ export const DatePickerColumn: React.FC<DatePickerColumnProps> = memo(({
); );
// Handle clear date // Handle clear date
const handleClearDate = useCallback((e: React.MouseEvent) => { const handleClearDate = useCallback(
(e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
handleDateChange(null); handleDateChange(null);
}, [handleDateChange]); },
[handleDateChange]
);
// Handle open date picker // Handle open date picker
const handleOpenDatePicker = useCallback(() => { const handleOpenDatePicker = useCallback(() => {
@@ -76,7 +80,10 @@ export const DatePickerColumn: React.FC<DatePickerColumnProps> = memo(({
const setTitle = field === 'dueDate' ? t('setDueDate') : t('setStartDate'); const setTitle = field === 'dueDate' ? t('setDueDate') : t('setStartDate');
return ( 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 ? ( {isActive ? (
<div className="w-full relative"> <div className="w-full relative">
<DatePicker <DatePicker
@@ -88,7 +95,7 @@ export const DatePickerColumn: React.FC<DatePickerColumnProps> = memo(({
allowClear={false} allowClear={false}
suffixIcon={null} suffixIcon={null}
open={true} open={true}
onOpenChange={(open) => { onOpenChange={open => {
if (!open) { if (!open) {
onActiveDatePickerChange(null); onActiveDatePickerChange(null);
} }
@@ -113,7 +120,7 @@ export const DatePickerColumn: React.FC<DatePickerColumnProps> = memo(({
) : ( ) : (
<div <div
className="cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 rounded px-2 py-1 transition-colors text-center" 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(); e.stopPropagation();
handleOpenDatePicker(); handleOpenDatePicker();
}} }}
@@ -131,6 +138,7 @@ export const DatePickerColumn: React.FC<DatePickerColumnProps> = memo(({
)} )}
</div> </div>
); );
}); }
);
DatePickerColumn.displayName = 'DatePickerColumn'; DatePickerColumn.displayName = 'DatePickerColumn';

View File

@@ -136,7 +136,16 @@ const TaskContextMenu: React.FC<TaskContextMenuProps> = ({
setUpdatingAssignToMe(false); setUpdatingAssignToMe(false);
onClose(); 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 () => { const handleArchive = useCallback(async () => {
if (!projectId || !task.id) return; if (!projectId || !task.id) return;
@@ -256,7 +265,9 @@ const TaskContextMenu: React.FC<TaskContextMenuProps> = ({
let options: { key: string; label: React.ReactNode; onClick: () => void }[] = []; let options: { key: string; label: React.ReactNode; onClick: () => void }[] = [];
if (currentGrouping === IGroupBy.STATUS) { if (currentGrouping === IGroupBy.STATUS) {
options = statusList.filter(status => status.id).map(status => ({ options = statusList
.filter(status => status.id)
.map(status => ({
key: status.id!, key: status.id!,
label: ( label: (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -270,7 +281,9 @@ const TaskContextMenu: React.FC<TaskContextMenuProps> = ({
onClick: () => handleStatusMoveTo(status.id!), onClick: () => handleStatusMoveTo(status.id!),
})); }));
} else if (currentGrouping === IGroupBy.PRIORITY) { } else if (currentGrouping === IGroupBy.PRIORITY) {
options = priorityList.filter(priority => priority.id).map(priority => ({ options = priorityList
.filter(priority => priority.id)
.map(priority => ({
key: priority.id!, key: priority.id!,
label: ( label: (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -284,7 +297,9 @@ const TaskContextMenu: React.FC<TaskContextMenuProps> = ({
onClick: () => handlePriorityMoveTo(priority.id!), onClick: () => handlePriorityMoveTo(priority.id!),
})); }));
} else if (currentGrouping === IGroupBy.PHASE) { } else if (currentGrouping === IGroupBy.PHASE) {
options = phaseList.filter(phase => phase.id).map(phase => ({ options = phaseList
.filter(phase => phase.id)
.map(phase => ({
key: phase.id!, key: phase.id!,
label: ( label: (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -430,7 +445,8 @@ const TaskContextMenu: React.FC<TaskContextMenuProps> = ({
total_minutes: task.timeTracking?.logged || 0, total_minutes: task.timeTracking?.logged || 0,
progress: task.progress, progress: task.progress,
sub_tasks_count: task.sub_tasks_count || 0, sub_tasks_count: task.sub_tasks_count || 0,
assignees: task.assignees?.map((assigneeId: string) => ({ assignees:
task.assignees?.map((assigneeId: string) => ({
id: assigneeId, id: assigneeId,
name: '', name: '',
email: '', email: '',

View File

@@ -27,7 +27,10 @@ const TaskListSkeleton: React.FC<TaskListSkeletonProps> = ({ visibleColumns }) =
// Generate multiple skeleton rows // Generate multiple skeleton rows
const skeletonRows = Array.from({ length: 8 }, (_, index) => ( 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) => { {columns.map((column, colIndex) => {
const columnStyle = { const columnStyle = {
width: column.width, width: column.width,

View File

@@ -55,11 +55,7 @@ export const TaskLabelsCell: React.FC<TaskLabelsCellProps> = memo(({ labels, isD
color={label.color} color={label.color}
/> />
) : ( ) : (
<CustomColordLabel <CustomColordLabel key={`${label.id}-${index}`} label={label} isDarkMode={isDarkMode} />
key={`${label.id}-${index}`}
label={label}
isDarkMode={isDarkMode}
/>
); );
})} })}
</div> </div>
@@ -75,7 +71,8 @@ interface DragHandleColumnProps {
listeners: any; 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 <div
className="flex items-center justify-center" className="flex items-center justify-center"
style={{ width }} style={{ width }}
@@ -83,7 +80,8 @@ export const DragHandleColumn: React.FC<DragHandleColumnProps> = memo(({ width,
> >
{!isSubtask && <HolderOutlined className="text-gray-400 hover:text-gray-600" />} {!isSubtask && <HolderOutlined className="text-gray-400 hover:text-gray-600" />}
</div> </div>
)); )
);
DragHandleColumn.displayName = 'DragHandleColumn'; DragHandleColumn.displayName = 'DragHandleColumn';
@@ -93,15 +91,17 @@ interface CheckboxColumnProps {
onCheckboxChange: (e: any) => void; 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 }}> <div className="flex items-center justify-center dark:border-gray-700" style={{ width }}>
<Checkbox <Checkbox
checked={isSelected} checked={isSelected}
onChange={onCheckboxChange} onChange={onCheckboxChange}
onClick={(e) => e.stopPropagation()} onClick={e => e.stopPropagation()}
/> />
</div> </div>
)); )
);
CheckboxColumn.displayName = 'CheckboxColumn'; CheckboxColumn.displayName = 'CheckboxColumn';
@@ -111,7 +111,10 @@ interface TaskKeyColumnProps {
} }
export const TaskKeyColumn: React.FC<TaskKeyColumnProps> = memo(({ width, taskKey }) => ( 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"> <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'} {taskKey || 'N/A'}
</span> </span>
@@ -125,8 +128,12 @@ interface DescriptionColumnProps {
description: string; description: string;
} }
export const DescriptionColumn: React.FC<DescriptionColumnProps> = memo(({ width, description }) => ( export const DescriptionColumn: React.FC<DescriptionColumnProps> = memo(
<div className="flex items-center px-2 border-r border-gray-200 dark:border-gray-700" style={{ width }}> ({ width, description }) => (
<div
className="flex items-center px-2 border-r border-gray-200 dark:border-gray-700"
style={{ width }}
>
<div <div
className="text-sm text-gray-600 dark:text-gray-400 truncate w-full" className="text-sm text-gray-600 dark:text-gray-400 truncate w-full"
style={{ style={{
@@ -140,7 +147,8 @@ export const DescriptionColumn: React.FC<DescriptionColumnProps> = memo(({ width
dangerouslySetInnerHTML={{ __html: description || '' }} dangerouslySetInnerHTML={{ __html: description || '' }}
/> />
</div> </div>
)); )
);
DescriptionColumn.displayName = 'DescriptionColumn'; DescriptionColumn.displayName = 'DescriptionColumn';
@@ -151,15 +159,16 @@ interface StatusColumnProps {
isDarkMode: boolean; isDarkMode: boolean;
} }
export const StatusColumn: React.FC<StatusColumnProps> = memo(({ width, task, projectId, isDarkMode }) => ( export const StatusColumn: React.FC<StatusColumnProps> = memo(
<div className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700" style={{ width }}> ({ width, task, projectId, isDarkMode }) => (
<TaskStatusDropdown <div
task={task} className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700"
projectId={projectId} style={{ width }}
isDarkMode={isDarkMode} >
/> <TaskStatusDropdown task={task} projectId={projectId} isDarkMode={isDarkMode} />
</div> </div>
)); )
);
StatusColumn.displayName = 'StatusColumn'; StatusColumn.displayName = 'StatusColumn';
@@ -170,21 +179,22 @@ interface AssigneesColumnProps {
isDarkMode: boolean; isDarkMode: boolean;
} }
export const AssigneesColumn: React.FC<AssigneesColumnProps> = memo(({ width, task, convertedTask, isDarkMode }) => ( export const AssigneesColumn: React.FC<AssigneesColumnProps> = memo(
<div className="flex items-center gap-1 px-2 border-r border-gray-200 dark:border-gray-700" style={{ width }}> ({ 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 <AvatarGroup
members={task.assignee_names || []} members={task.assignee_names || []}
maxCount={3} maxCount={3}
isDarkMode={isDarkMode} isDarkMode={isDarkMode}
size={24} size={24}
/> />
<AssigneeSelector <AssigneeSelector task={convertedTask} groupId={null} isDarkMode={isDarkMode} />
task={convertedTask}
groupId={null}
isDarkMode={isDarkMode}
/>
</div> </div>
)); )
);
AssigneesColumn.displayName = 'AssigneesColumn'; AssigneesColumn.displayName = 'AssigneesColumn';
@@ -195,15 +205,16 @@ interface PriorityColumnProps {
isDarkMode: boolean; isDarkMode: boolean;
} }
export const PriorityColumn: React.FC<PriorityColumnProps> = memo(({ width, task, projectId, isDarkMode }) => ( export const PriorityColumn: React.FC<PriorityColumnProps> = memo(
<div className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700" style={{ width }}> ({ width, task, projectId, isDarkMode }) => (
<TaskPriorityDropdown <div
task={task} className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700"
projectId={projectId} style={{ width }}
isDarkMode={isDarkMode} >
/> <TaskPriorityDropdown task={task} projectId={projectId} isDarkMode={isDarkMode} />
</div> </div>
)); )
);
PriorityColumn.displayName = 'PriorityColumn'; PriorityColumn.displayName = 'PriorityColumn';
@@ -213,7 +224,10 @@ interface ProgressColumnProps {
} }
export const ProgressColumn: React.FC<ProgressColumnProps> = memo(({ width, task }) => ( 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 !== undefined &&
task.progress >= 0 && task.progress >= 0 &&
(task.progress === 100 ? ( (task.progress === 100 ? (
@@ -227,10 +241,7 @@ export const ProgressColumn: React.FC<ProgressColumnProps> = memo(({ width, task
/> />
</div> </div>
) : ( ) : (
<TaskProgress <TaskProgress progress={task.progress} numberOfSubTasks={task.sub_tasks?.length || 0} />
progress={task.progress}
numberOfSubTasks={task.sub_tasks?.length || 0}
/>
))} ))}
</div> </div>
)); ));
@@ -245,19 +256,24 @@ interface LabelsColumnProps {
visibleColumns: any[]; 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 = { const labelsStyle = {
width, width,
flexShrink: 0 flexShrink: 0,
}; };
return ( 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} /> <TaskLabelsCell labels={task.labels} isDarkMode={isDarkMode} />
<LabelsSelector task={labelsAdapter} isDarkMode={isDarkMode} /> <LabelsSelector task={labelsAdapter} isDarkMode={isDarkMode} />
</div> </div>
); );
}); }
);
LabelsColumn.displayName = 'LabelsColumn'; LabelsColumn.displayName = 'LabelsColumn';
@@ -268,15 +284,16 @@ interface PhaseColumnProps {
isDarkMode: boolean; isDarkMode: boolean;
} }
export const PhaseColumn: React.FC<PhaseColumnProps> = memo(({ width, task, projectId, isDarkMode }) => ( export const PhaseColumn: React.FC<PhaseColumnProps> = memo(
<div className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700" style={{ width }}> ({ width, task, projectId, isDarkMode }) => (
<TaskPhaseDropdown <div
task={task} className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700"
projectId={projectId} style={{ width }}
isDarkMode={isDarkMode} >
/> <TaskPhaseDropdown task={task} projectId={projectId} isDarkMode={isDarkMode} />
</div> </div>
)); )
);
PhaseColumn.displayName = 'PhaseColumn'; PhaseColumn.displayName = 'PhaseColumn';
@@ -286,11 +303,16 @@ interface TimeTrackingColumnProps {
isDarkMode: boolean; isDarkMode: boolean;
} }
export const TimeTrackingColumn: React.FC<TimeTrackingColumnProps> = memo(({ width, taskId, isDarkMode }) => ( export const TimeTrackingColumn: React.FC<TimeTrackingColumnProps> = memo(
<div className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700" style={{ width }}> ({ 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} /> <TaskTimeTracking taskId={taskId} isDarkMode={isDarkMode} />
</div> </div>
)); )
);
TimeTrackingColumn.displayName = 'TimeTrackingColumn'; TimeTrackingColumn.displayName = 'TimeTrackingColumn';
@@ -320,15 +342,14 @@ export const EstimationColumn: React.FC<EstimationColumnProps> = memo(({ width,
})(); })();
return ( 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 ? ( {estimationDisplay ? (
<span className="text-sm text-gray-500 dark:text-gray-400"> <span className="text-sm text-gray-500 dark:text-gray-400">{estimationDisplay}</span>
{estimationDisplay}
</span>
) : ( ) : (
<span className="text-sm text-gray-400 dark:text-gray-500"> <span className="text-sm text-gray-400 dark:text-gray-500">-</span>
-
</span>
)} )}
</div> </div>
); );
@@ -342,17 +363,24 @@ interface DateColumnProps {
placeholder?: string; placeholder?: string;
} }
export const DateColumn: React.FC<DateColumnProps> = memo(({ width, formattedDate, placeholder = '-' }) => ( export const DateColumn: React.FC<DateColumnProps> = memo(
<div className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700" style={{ width }}> ({ width, formattedDate, placeholder = '-' }) => (
<div
className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700"
style={{ width }}
>
{formattedDate ? ( {formattedDate ? (
<span className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap"> <span className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
{formattedDate} {formattedDate}
</span> </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> </div>
)); )
);
DateColumn.displayName = 'DateColumn'; DateColumn.displayName = 'DateColumn';
@@ -362,7 +390,10 @@ interface ReporterColumnProps {
} }
export const ReporterColumn: React.FC<ReporterColumnProps> = memo(({ width, reporter }) => ( 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 ? ( {reporter ? (
<span className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{reporter}</span> <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; 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; if (!updateTaskCustomColumnValue) return null;
return ( 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 <CustomColumnCell
column={column} column={column}
task={task} task={task}
@@ -392,6 +427,7 @@ export const CustomColumn: React.FC<CustomColumnProps> = memo(({ width, column,
/> />
</div> </div>
); );
}); }
);
CustomColumn.displayName = 'CustomColumn'; CustomColumn.displayName = 'CustomColumn';

View File

@@ -1,11 +1,23 @@
import React, { memo, useCallback, useState, useRef, useEffect } from 'react'; 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 { Input, Tooltip } from '@/shared/antd-imports';
import type { InputRef } from '@/shared/antd-imports'; import type { InputRef } from '@/shared/antd-imports';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { Task } from '@/types/task-management.types'; import { Task } from '@/types/task-management.types';
import { useAppDispatch } from '@/hooks/useAppDispatch'; 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 { setSelectedTaskId, setShowTaskDrawer } from '@/features/task-drawer/task-drawer.slice';
import { useSocket } from '@/socket/socketContext'; import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events'; import { SocketEvents } from '@/shared/socket-events';
@@ -27,7 +39,8 @@ interface TitleColumnProps {
depth?: number; depth?: number;
} }
export const TitleColumn: React.FC<TitleColumnProps> = memo(({ export const TitleColumn: React.FC<TitleColumnProps> = memo(
({
width, width,
task, task,
projectId, projectId,
@@ -38,8 +51,8 @@ export const TitleColumn: React.FC<TitleColumnProps> = memo(({
onEditTaskName, onEditTaskName,
onTaskNameChange, onTaskNameChange,
onTaskNameSave, onTaskNameSave,
depth = 0 depth = 0,
}) => { }) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { socket, connected } = useSocket(); const { socket, connected } = useSocket();
const { t } = useTranslation('task-list-table'); const { t } = useTranslation('task-list-table');
@@ -51,7 +64,8 @@ export const TitleColumn: React.FC<TitleColumnProps> = memo(({
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 }); const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 });
// Handle task expansion toggle // Handle task expansion toggle
const handleToggleExpansion = useCallback((e: React.MouseEvent) => { const handleToggleExpansion = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
// Always try to fetch subtasks when expanding, regardless of count // Always try to fetch subtasks when expanding, regardless of count
@@ -61,12 +75,18 @@ export const TitleColumn: React.FC<TitleColumnProps> = memo(({
// Toggle expansion state // Toggle expansion state
dispatch(toggleTaskExpansion(task.id)); 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 // Handle task name save
const handleTaskNameSave = useCallback(() => { const handleTaskNameSave = useCallback(() => {
const newTaskName = inputRef.current?.input?.value || taskName; 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( socket?.emit(
SocketEvents.TASK_NAME_CHANGE.toString(), SocketEvents.TASK_NAME_CHANGE.toString(),
JSON.stringify({ JSON.stringify({
@@ -77,7 +97,16 @@ export const TitleColumn: React.FC<TitleColumnProps> = memo(({
); );
} }
onEditTaskName(false); 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 // Handle context menu
const handleContextMenu = useCallback((e: React.MouseEvent) => { 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 // Use clientX and clientY directly for fixed positioning
setContextMenuPosition({ setContextMenuPosition({
x: e.clientX, x: e.clientX,
y: e.clientY y: e.clientY,
}); });
setContextMenuVisible(true); setContextMenuVisible(true);
}, []); }, []);
@@ -127,7 +156,7 @@ export const TitleColumn: React.FC<TitleColumnProps> = memo(({
ref={inputRef} ref={inputRef}
variant="borderless" variant="borderless"
value={taskName} value={taskName}
onChange={(e) => onTaskNameChange(e.target.value)} onChange={e => onTaskNameChange(e.target.value)}
autoFocus autoFocus
onPressEnter={handleTaskNameSave} onPressEnter={handleTaskNameSave}
onBlur={handleTaskNameSave} onBlur={handleTaskNameSave}
@@ -174,7 +203,7 @@ export const TitleColumn: React.FC<TitleColumnProps> = memo(({
className="transition-transform duration-300 ease-out" className="transition-transform duration-300 ease-out"
style={{ style={{
transform: task.show_sub_tasks ? 'rotate(90deg)' : 'rotate(0deg)', transform: task.show_sub_tasks ? 'rotate(90deg)' : 'rotate(0deg)',
transformOrigin: 'center' transformOrigin: 'center',
}} }}
> >
<RightOutlined className="text-gray-600 dark:text-gray-400" /> <RightOutlined className="text-gray-600 dark:text-gray-400" />
@@ -195,7 +224,7 @@ export const TitleColumn: React.FC<TitleColumnProps> = memo(({
overflow: 'hidden', overflow: 'hidden',
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
}} }}
onClick={(e) => { onClick={e => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
onEditTaskName(true); onEditTaskName(true);
@@ -211,19 +240,32 @@ export const TitleColumn: React.FC<TitleColumnProps> = memo(({
<div className="flex items-center gap-1 flex-shrink-0"> <div className="flex items-center gap-1 flex-shrink-0">
{/* Subtask count indicator - show for any task that can have sub-tasks */} {/* Subtask count indicator - show for any task that can have sub-tasks */}
{depth < 2 && task.sub_tasks_count != null && task.sub_tasks_count > 0 && ( {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"> <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"> <span className="text-blue-600 dark:text-blue-400 font-medium">
{task.sub_tasks_count} {task.sub_tasks_count}
</span> </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> </div>
</Tooltip> </Tooltip>
)} )}
{/* Task indicators - compact layout */} {/* Task indicators - compact layout */}
{task.comments_count != null && task.comments_count !== 0 && ( {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 <CommentOutlined
className="text-gray-500 dark:text-gray-400" className="text-gray-500 dark:text-gray-400"
style={{ fontSize: 12 }} style={{ fontSize: 12 }}
@@ -241,7 +283,12 @@ export const TitleColumn: React.FC<TitleColumnProps> = memo(({
)} )}
{task.attachments_count != null && task.attachments_count !== 0 && ( {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 <PaperClipOutlined
className="text-gray-500 dark:text-gray-400" className="text-gray-500 dark:text-gray-400"
style={{ fontSize: 12 }} style={{ fontSize: 12 }}
@@ -272,7 +319,7 @@ export const TitleColumn: React.FC<TitleColumnProps> = memo(({
<button <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" 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(); e.stopPropagation();
dispatch(setSelectedTaskId(task.id)); dispatch(setSelectedTaskId(task.id));
dispatch(setShowTaskDrawer(true)); dispatch(setShowTaskDrawer(true));
@@ -285,7 +332,8 @@ export const TitleColumn: React.FC<TitleColumnProps> = memo(({
)} )}
{/* Context Menu */} {/* Context Menu */}
{contextMenuVisible && createPortal( {contextMenuVisible &&
createPortal(
<TaskContextMenu <TaskContextMenu
task={task} task={task}
projectId={projectId} projectId={projectId}
@@ -296,6 +344,7 @@ export const TitleColumn: React.FC<TitleColumnProps> = memo(({
)} )}
</div> </div>
); );
}); }
);
TitleColumn.displayName = 'TitleColumn'; TitleColumn.displayName = 'TitleColumn';

View File

@@ -15,7 +15,14 @@ export type ColumnStyle = {
export const BASE_COLUMNS = [ export const BASE_COLUMNS = [
{ id: 'dragHandle', label: '', width: '20px', isSticky: true, key: 'dragHandle' }, { id: 'dragHandle', label: '', width: '20px', isSticky: true, key: 'dragHandle' },
{ id: 'checkbox', label: '', width: '28px', isSticky: true, key: 'checkbox' }, { 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: 'title', label: 'taskColumn', width: '470px', isSticky: true, key: COLUMN_KEYS.NAME },
{ id: 'description', label: 'descriptionColumn', width: '260px', key: COLUMN_KEYS.DESCRIPTION }, { id: 'description', label: 'descriptionColumn', width: '260px', key: COLUMN_KEYS.DESCRIPTION },
{ id: 'progress', label: 'progressColumn', width: '120px', key: COLUMN_KEYS.PROGRESS }, { 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: 'labels', label: 'labelsColumn', width: '250px', key: COLUMN_KEYS.LABELS },
{ id: 'phase', label: 'phaseColumn', width: '120px', key: COLUMN_KEYS.PHASE }, { id: 'phase', label: 'phaseColumn', width: '120px', key: COLUMN_KEYS.PHASE },
{ id: 'priority', label: 'priorityColumn', width: '120px', key: COLUMN_KEYS.PRIORITY }, { 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: 'estimation', label: 'estimationColumn', width: '120px', key: COLUMN_KEYS.ESTIMATION },
{ id: 'startDate', label: 'startDateColumn', width: '140px', key: COLUMN_KEYS.START_DATE }, { id: 'startDate', label: 'startDateColumn', width: '140px', key: COLUMN_KEYS.START_DATE },
{ id: 'dueDate', label: 'dueDateColumn', width: '140px', key: COLUMN_KEYS.DUE_DATE }, { id: 'dueDate', label: 'dueDateColumn', width: '140px', key: COLUMN_KEYS.DUE_DATE },
{ id: 'dueTime', label: 'dueTimeColumn', width: '120px', key: COLUMN_KEYS.DUE_TIME }, { 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: 'createdDate', label: 'createdDateColumn', width: '140px', key: COLUMN_KEYS.CREATED_DATE },
{ id: 'lastUpdated', label: 'lastUpdatedColumn', width: '140px', key: COLUMN_KEYS.LAST_UPDATED }, { id: 'lastUpdated', label: 'lastUpdatedColumn', width: '140px', key: COLUMN_KEYS.LAST_UPDATED },
{ id: 'reporter', label: 'reporterColumn', width: '120px', key: COLUMN_KEYS.REPORTER }, { id: 'reporter', label: 'reporterColumn', width: '120px', key: COLUMN_KEYS.REPORTER },

View File

@@ -67,7 +67,8 @@ export const useBulkActions = () => {
dispatch(clearSelection()); dispatch(clearSelection());
}, [dispatch]); }, [dispatch]);
const handleBulkStatusChange = useCallback(async (statusId: string, selectedTaskIds: string[]) => { const handleBulkStatusChange = useCallback(
async (statusId: string, selectedTaskIds: string[]) => {
if (!statusId || !projectId || !selectedTaskIds.length) return; if (!statusId || !projectId || !selectedTaskIds.length) return;
try { try {
@@ -108,9 +109,12 @@ export const useBulkActions = () => {
} finally { } finally {
updateLoadingState('status', false); 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; if (!priorityId || !projectId || !selectedTaskIds.length) return;
try { try {
@@ -132,9 +136,12 @@ export const useBulkActions = () => {
} finally { } finally {
updateLoadingState('priority', false); 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; if (!phaseId || !projectId || !selectedTaskIds.length) return;
try { try {
@@ -156,9 +163,12 @@ export const useBulkActions = () => {
} finally { } finally {
updateLoadingState('phase', false); 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; if (!projectId || !selectedTaskIds.length) return;
try { try {
@@ -180,9 +190,12 @@ export const useBulkActions = () => {
} finally { } finally {
updateLoadingState('assignToMe', false); 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; if (!projectId || !selectedTaskIds.length) return;
try { try {
@@ -212,9 +225,12 @@ export const useBulkActions = () => {
} finally { } finally {
updateLoadingState('assignMembers', false); 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; if (!projectId || !selectedTaskIds.length) return;
try { try {
@@ -240,9 +256,12 @@ export const useBulkActions = () => {
} finally { } finally {
updateLoadingState('labels', false); 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; if (!projectId || !selectedTaskIds.length) return;
try { try {
@@ -264,9 +283,12 @@ export const useBulkActions = () => {
} finally { } finally {
updateLoadingState('archive', false); 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; if (!projectId || !selectedTaskIds.length) return;
try { try {
@@ -288,9 +310,12 @@ export const useBulkActions = () => {
} finally { } finally {
updateLoadingState('delete', false); 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; if (!projectId || !selectedTaskIds.length) return;
try { try {
@@ -305,9 +330,12 @@ export const useBulkActions = () => {
} finally { } finally {
updateLoadingState('duplicate', false); 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; if (!projectId || !selectedTaskIds.length) return;
try { try {
@@ -319,9 +347,12 @@ export const useBulkActions = () => {
} finally { } finally {
updateLoadingState('export', false); 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; if (!projectId || !selectedTaskIds.length) return;
try { try {
@@ -336,7 +367,9 @@ export const useBulkActions = () => {
} finally { } finally {
updateLoadingState('dueDate', false); updateLoadingState('dueDate', false);
} }
}, [projectId, dispatch, refetchTasks, updateLoadingState]); },
[projectId, dispatch, refetchTasks, updateLoadingState]
);
return { return {
handleClearSelection, handleClearSelection,

View File

@@ -43,7 +43,7 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
// Create a copy of all groups and perform the move operation // Create a copy of all groups and perform the move operation
const updatedGroups = groups.map(group => ({ const updatedGroups = groups.map(group => ({
...group, ...group,
taskIds: [...group.taskIds] taskIds: [...group.taskIds],
})); }));
// Find the source and target groups in our copy // Find the source and target groups in our copy
@@ -73,7 +73,7 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
group.taskIds.forEach(id => { group.taskIds.forEach(id => {
const update: any = { const update: any = {
task_id: id, task_id: id,
sort_order: currentSortOrder sort_order: currentSortOrder,
}; };
// Add group-specific fields for the moved task if it changed groups // 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') { } else if (currentGrouping === 'priority') {
// Emit priority change event // 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, task_id: taskId,
priority_id: targetGroup.id, priority_id: targetGroup.id,
team_id: teamId, team_id: teamId,
})); })
);
} else if (currentGrouping === 'status') { } else if (currentGrouping === 'status') {
// Emit status change event // 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, task_id: taskId,
status_id: targetGroup.id, status_id: targetGroup.id,
team_id: teamId, team_id: teamId,
})); })
);
} }
} }
}, },

View File

@@ -24,14 +24,21 @@ export const useTaskRowActions = ({
const { socket, connected } = useSocket(); const { socket, connected } = useSocket();
// Handle checkbox change // Handle checkbox change
const handleCheckboxChange = useCallback((e: any) => { const handleCheckboxChange = useCallback(
(e: any) => {
e.stopPropagation(); // Prevent row click when clicking checkbox e.stopPropagation(); // Prevent row click when clicking checkbox
dispatch(toggleTaskSelection(taskId)); dispatch(toggleTaskSelection(taskId));
}, [dispatch, taskId]); },
[dispatch, taskId]
);
// Handle task name save // Handle task name save
const handleTaskNameSave = useCallback(() => { 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( socket?.emit(
SocketEvents.TASK_NAME_CHANGE.toString(), SocketEvents.TASK_NAME_CHANGE.toString(),
JSON.stringify({ JSON.stringify({
@@ -42,7 +49,16 @@ export const useTaskRowActions = ({
); );
} }
setEditTaskName(false); 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 // Handle task name edit start
const handleTaskNameEdit = useCallback(() => { const handleTaskNameEdit = useCallback(() => {

View File

@@ -89,8 +89,8 @@ export const useTaskRowColumns = ({
listeners, listeners,
depth = 0, depth = 0,
}: UseTaskRowColumnsProps) => { }: UseTaskRowColumnsProps) => {
const renderColumn = useCallback(
const renderColumn = useCallback((columnId: string, width: string, isSticky?: boolean, index?: number) => { (columnId: string, width: string, isSticky?: boolean, index?: number) => {
switch (columnId) { switch (columnId) {
case 'dragHandle': case 'dragHandle':
return ( return (
@@ -112,12 +112,7 @@ export const useTaskRowColumns = ({
); );
case 'taskKey': case 'taskKey':
return ( return <TaskKeyColumn width={width} taskKey={task.task_key || ''} />;
<TaskKeyColumn
width={width}
taskKey={task.task_key || ''}
/>
);
case 'title': case 'title':
return ( return (
@@ -137,21 +132,11 @@ export const useTaskRowColumns = ({
); );
case 'description': case 'description':
return ( return <DescriptionColumn width={width} description={task.description || ''} />;
<DescriptionColumn
width={width}
description={task.description || ''}
/>
);
case 'status': case 'status':
return ( return (
<StatusColumn <StatusColumn width={width} task={task} projectId={projectId} isDarkMode={isDarkMode} />
width={width}
task={task}
projectId={projectId}
isDarkMode={isDarkMode}
/>
); );
case 'assignees': case 'assignees':
@@ -203,12 +188,7 @@ export const useTaskRowColumns = ({
); );
case 'progress': case 'progress':
return ( return <ProgressColumn width={width} task={task} />;
<ProgressColumn
width={width}
task={task}
/>
);
case 'labels': case 'labels':
return ( return (
@@ -223,62 +203,28 @@ export const useTaskRowColumns = ({
case 'phase': case 'phase':
return ( return (
<PhaseColumn <PhaseColumn width={width} task={task} projectId={projectId} isDarkMode={isDarkMode} />
width={width}
task={task}
projectId={projectId}
isDarkMode={isDarkMode}
/>
); );
case 'timeTracking': case 'timeTracking':
return ( return (
<TimeTrackingColumn <TimeTrackingColumn width={width} taskId={task.id || ''} isDarkMode={isDarkMode} />
width={width}
taskId={task.id || ''}
isDarkMode={isDarkMode}
/>
); );
case 'estimation': case 'estimation':
return ( return <EstimationColumn width={width} task={task} />;
<EstimationColumn
width={width}
task={task}
/>
);
case 'completedDate': case 'completedDate':
return ( return <DateColumn width={width} formattedDate={formattedDates.completed} />;
<DateColumn
width={width}
formattedDate={formattedDates.completed}
/>
);
case 'createdDate': case 'createdDate':
return ( return <DateColumn width={width} formattedDate={formattedDates.created} />;
<DateColumn
width={width}
formattedDate={formattedDates.created}
/>
);
case 'lastUpdated': case 'lastUpdated':
return ( return <DateColumn width={width} formattedDate={formattedDates.updated} />;
<DateColumn
width={width}
formattedDate={formattedDates.updated}
/>
);
case 'reporter': case 'reporter':
return ( return <ReporterColumn width={width} reporter={task.reporter || ''} />;
<ReporterColumn
width={width}
reporter={task.reporter || ''}
/>
);
default: default:
// Handle custom columns // Handle custom columns
@@ -295,7 +241,8 @@ export const useTaskRowColumns = ({
} }
return null; return null;
} }
}, [ },
[
task, task,
projectId, projectId,
isSubtask, isSubtask,
@@ -319,7 +266,8 @@ export const useTaskRowColumns = ({
handleTaskNameEdit, handleTaskNameEdit,
attributes, attributes,
listeners, listeners,
]); ]
);
return { renderColumn }; return { renderColumn };
}; };

View File

@@ -18,10 +18,14 @@ export const useTaskRowState = (task: Task) => {
}, [task.title, task.name]); }, [task.title, task.name]);
// Memoize task display 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 // Memoize converted task for AssigneeSelector to prevent recreation
const convertedTask = useMemo(() => ({ const convertedTask = useMemo(
() => ({
id: task.id, id: task.id,
name: taskDisplayName, name: taskDisplayName,
task_key: task.task_key || taskDisplayName, task_key: task.task_key || taskDisplayName,
@@ -36,46 +40,74 @@ export const useTaskRowState = (task: Task) => {
status_id: undefined, status_id: undefined,
project_id: undefined, project_id: undefined,
manual_progress: 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 // Memoize formatted dates
const formattedDates = useMemo(() => ({ const formattedDates = useMemo(
() => ({
due: (() => { due: (() => {
const dateValue = task.dueDate || task.due_date; const dateValue = task.dueDate || task.due_date;
return dateValue ? formatDate(dateValue) : null; return dateValue ? formatDate(dateValue) : null;
})(), })(),
start: task.startDate ? formatDate(task.startDate) : null, start: task.startDate ? formatDate(task.startDate) : null,
completed: task.completedAt ? formatDate(task.completedAt) : 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, 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 // Memoize date values for DatePicker
const dateValues = useMemo( const dateValues = useMemo(
() => ({ () => ({
start: task.startDate ? dayjs(task.startDate) : undefined, 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] [task.startDate, task.dueDate, task.due_date]
); );
// Create labels adapter for LabelsSelector // Create labels adapter for LabelsSelector
const labelsAdapter = useMemo(() => ({ const labelsAdapter = useMemo(
() => ({
id: task.id, id: task.id,
name: task.title || task.name, name: task.title || task.name,
parent_task_id: task.parent_task_id, parent_task_id: task.parent_task_id,
manual_progress: false, manual_progress: false,
all_labels: task.all_labels?.map(label => ({ all_labels:
task.all_labels?.map(label => ({
id: label.id, id: label.id,
name: label.name, name: label.name,
color_code: label.color_code, color_code: label.color_code,
})) || [], })) || [],
labels: task.labels?.map(label => ({ labels:
task.labels?.map(label => ({
id: label.id, id: label.id,
name: label.name, name: label.name,
color_code: label.color, 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 { return {
// State // State

View File

@@ -106,7 +106,9 @@
/* Sortable item styling */ /* Sortable item styling */
.sortable-status-item { .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 { .sortable-status-item.is-dragging {
@@ -173,7 +175,9 @@
.status-item-enter-active { .status-item-enter-active {
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0);
transition: opacity 0.3s ease, transform 0.3s ease; transition:
opacity 0.3s ease,
transform 0.3s ease;
} }
.status-item-exit { .status-item-exit {
@@ -184,5 +188,7 @@
.status-item-exit-active { .status-item-exit-active {
opacity: 0; opacity: 0;
transform: translateY(-10px); transform: translateY(-10px);
transition: opacity 0.3s ease, transform 0.3s ease; transition:
opacity 0.3s ease,
transform 0.3s ease;
} }

View File

@@ -1,5 +1,17 @@
import React, { useState, useCallback, useRef, useEffect } from 'react'; 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 { PlusOutlined, DragOutlined } from '@/shared/antd-imports';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; 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 TaskDetailsForm from '@/components/task-drawer/shared/info-tab/task-details-form';
import AssigneeSelector from '@/components/AssigneeSelector'; import AssigneeSelector from '@/components/AssigneeSelector';
import LabelsSelector from '@/components/LabelsSelector'; 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 { statusApiService } from '@/api/taskAttributes/status/status.api.service';
import { ITaskStatusUpdateModel } from '@/types/tasks/task-status-update-model.types'; import { ITaskStatusUpdateModel } from '@/types/tasks/task-status-update-model.types';
import { Modal as AntModal } from '@/shared/antd-imports'; 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 [editName, setEditName] = useState(status.name || '');
const inputRef = useRef<any>(null); const inputRef = useRef<any>(null);
const { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
attributes, id,
listeners, });
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style = { const style = {
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
@@ -76,13 +87,16 @@ const SortableStatusItem: React.FC<StatusItemProps & { id: string }> = ({
setIsEditing(false); setIsEditing(false);
}, [status.name]); }, [status.name]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => { const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
handleSave(); handleSave();
} else if (e.key === 'Escape') { } else if (e.key === 'Escape') {
handleCancel(); handleCancel();
} }
}, [handleSave, handleCancel]); },
[handleSave, handleCancel]
);
useEffect(() => { useEffect(() => {
if (isEditing && inputRef.current) { if (isEditing && inputRef.current) {
@@ -127,7 +141,7 @@ const SortableStatusItem: React.FC<StatusItemProps & { id: string }> = ({
<Input <Input
ref={inputRef} ref={inputRef}
value={editName} value={editName}
onChange={(e) => setEditName(e.target.value)} onChange={e => setEditName(e.target.value)}
onBlur={handleSave} onBlur={handleSave}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
size="small" size="small"
@@ -151,7 +165,9 @@ const SortableStatusItem: React.FC<StatusItemProps & { id: string }> = ({
type="text" type="text"
size="small" size="small"
onClick={() => setIsEditing(true)} 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 Rename
</Button> </Button>
@@ -201,9 +217,9 @@ const StatusManagement: React.FC<{
return; return;
} }
setLocalStatuses((items) => { setLocalStatuses(items => {
const oldIndex = items.findIndex((item) => item.id === active.id); const oldIndex = items.findIndex(item => item.id === active.id);
const newIndex = items.findIndex((item) => item.id === over.id); const newIndex = items.findIndex(item => item.id === over.id);
if (oldIndex === -1 || newIndex === -1) return items; if (oldIndex === -1 || newIndex === -1) return items;
@@ -250,7 +266,8 @@ const StatusManagement: React.FC<{
} }
}, [newStatusName, projectId, dispatch]); }, [newStatusName, projectId, dispatch]);
const handleRenameStatus = useCallback(async (id: string, name: string) => { const handleRenameStatus = useCallback(
async (id: string, name: string) => {
try { try {
const body: ITaskStatusUpdateModel = { const body: ITaskStatusUpdateModel = {
name: name.trim(), name: name.trim(),
@@ -262,9 +279,12 @@ const StatusManagement: React.FC<{
} catch (error) { } catch (error) {
console.error('Error renaming status:', 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({ AntModal.confirm({
title: 'Delete Status', title: 'Delete Status',
content: 'Are you sure you want to delete this status? This action cannot be undone.', 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 ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -296,7 +318,7 @@ const StatusManagement: React.FC<{
<Input <Input
placeholder="Enter status name" placeholder="Enter status name"
value={newStatusName} value={newStatusName}
onChange={(e) => setNewStatusName(e.target.value)} onChange={e => setNewStatusName(e.target.value)}
onPressEnter={handleCreateStatus} onPressEnter={handleCreateStatus}
className="flex-1" className="flex-1"
/> />
@@ -319,7 +341,9 @@ const StatusManagement: React.FC<{
strategy={verticalListSortingStrategy} strategy={verticalListSortingStrategy}
> >
<div className="space-y-2"> <div className="space-y-2">
{localStatuses.filter(status => status.id).map((status) => ( {localStatuses
.filter(status => status.id)
.map(status => (
<SortableStatusItem <SortableStatusItem
key={status.id} key={status.id}
id={status.id!} id={status.id!}
@@ -342,11 +366,7 @@ const StatusManagement: React.FC<{
); );
}; };
const CreateTaskModal: React.FC<CreateTaskModalProps> = ({ const CreateTaskModal: React.FC<CreateTaskModalProps> = ({ open, onClose, projectId }) => {
open,
onClose,
projectId,
}) => {
const { t } = useTranslation('task-drawer/task-drawer'); const { t } = useTranslation('task-drawer/task-drawer');
const [form] = Form.useForm(); const [form] = Form.useForm();
const [activeTab, setActiveTab] = useState('task-info'); const [activeTab, setActiveTab] = useState('task-info');
@@ -391,7 +411,6 @@ const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
form.resetFields(); form.resetFields();
setActiveTab('task-info'); setActiveTab('task-info');
onClose(); onClose();
} catch (error) { } catch (error) {
console.error('Form validation failed:', error); console.error('Form validation failed:', error);
} }
@@ -423,9 +442,7 @@ const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
<Flex justify="space-between" align="center"> <Flex justify="space-between" align="center">
<div></div> <div></div>
<Space> <Space>
<Button onClick={handleCancel}> <Button onClick={handleCancel}>{t('cancel')}</Button>
{t('cancel')}
</Button>
<Button type="primary" onClick={handleSubmit}> <Button type="primary" onClick={handleSubmit}>
{t('createTask')} {t('createTask')}
</Button> </Button>
@@ -465,10 +482,7 @@ const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
/> />
</Form.Item> </Form.Item>
<Form.Item <Form.Item name="description" label={t('description')}>
name="description"
label={t('description')}
>
<Input.TextArea <Input.TextArea
placeholder={t('descriptionPlaceholder')} placeholder={t('descriptionPlaceholder')}
rows={4} rows={4}
@@ -492,10 +506,7 @@ const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
</Form.Item> </Form.Item>
{/* Priority Selection */} {/* Priority Selection */}
<Form.Item <Form.Item name="priority" label={t('priority')}>
name="priority"
label={t('priority')}
>
<Select placeholder="Select priority"> <Select placeholder="Select priority">
<Select.Option value="low">Low</Select.Option> <Select.Option value="low">Low</Select.Option>
<Select.Option value="medium">Medium</Select.Option> <Select.Option value="medium">Medium</Select.Option>
@@ -504,26 +515,16 @@ const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
</Form.Item> </Form.Item>
{/* Assignees */} {/* Assignees */}
<Form.Item <Form.Item name="assignees" label={t('assignees')}>
name="assignees"
label={t('assignees')}
>
<Select mode="multiple" placeholder="Select assignees"> <Select mode="multiple" placeholder="Select assignees">
{/* TODO: Populate with team members */} {/* TODO: Populate with team members */}
</Select> </Select>
</Form.Item> </Form.Item>
{/* Due Date */} {/* Due Date */}
<Form.Item <Form.Item name="dueDate" label={t('dueDate')}>
name="dueDate" <DatePicker className="w-full" placeholder="Select due date" />
label={t('dueDate')}
>
<DatePicker
className="w-full"
placeholder="Select due date"
/>
</Form.Item> </Form.Item>
</Form> </Form>
</div> </div>
), ),
@@ -533,10 +534,7 @@ const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
label: t('manageStatuses'), label: t('manageStatuses'),
children: finalProjectId ? ( children: finalProjectId ? (
<div className="py-4"> <div className="py-4">
<StatusManagement <StatusManagement projectId={finalProjectId} isDarkMode={isDarkMode} />
projectId={finalProjectId}
isDarkMode={isDarkMode}
/>
</div> </div>
) : ( ) : (
<div className={`text-center py-8 ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}> <div className={`text-center py-8 ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>

View File

@@ -276,7 +276,9 @@
/* Sortable item styling */ /* Sortable item styling */
.sortable-phase-item { .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 { .sortable-phase-item.is-dragging {
@@ -354,7 +356,9 @@
.phase-item-enter-active { .phase-item-enter-active {
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0);
transition: opacity 0.3s ease, transform 0.3s ease; transition:
opacity 0.3s ease,
transform 0.3s ease;
} }
.phase-item-exit { .phase-item-exit {
@@ -365,7 +369,9 @@
.phase-item-exit-active { .phase-item-exit-active {
opacity: 0; opacity: 0;
transform: translateY(-10px); transform: translateY(-10px);
transition: opacity 0.3s ease, transform 0.3s ease; transition:
opacity 0.3s ease,
transform 0.3s ease;
} }
/* Loading state styling */ /* Loading state styling */

View File

@@ -1,5 +1,16 @@
import React, { useState, useCallback, useRef, useEffect } from 'react'; 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 { PlusOutlined, HolderOutlined, EditOutlined, DeleteOutlined } from '@/shared/antd-imports';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; 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 [isHovered, setIsHovered] = useState(false);
const inputRef = useRef<any>(null); const inputRef = useRef<any>(null);
const { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
attributes, id,
listeners, });
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style = { const style = {
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
@@ -85,13 +91,16 @@ const SortablePhaseItem: React.FC<PhaseItemProps & { id: string }> = ({
setIsEditing(false); setIsEditing(false);
}, [phase.name]); }, [phase.name]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => { const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
handleSave(); handleSave();
} else if (e.key === 'Escape') { } else if (e.key === 'Escape') {
handleCancel(); handleCancel();
} }
}, [handleSave, handleCancel]); },
[handleSave, handleCancel]
);
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
setIsEditing(true); setIsEditing(true);
@@ -144,7 +153,7 @@ const SortablePhaseItem: React.FC<PhaseItemProps & { id: string }> = ({
<div className="flex-shrink-0 flex items-center gap-1"> <div className="flex-shrink-0 flex items-center gap-1">
<ColorPicker <ColorPicker
value={color} value={color}
onChange={(value) => setColor(value.toHexString())} onChange={value => setColor(value.toHexString())}
onChangeComplete={handleColorChangeComplete} onChangeComplete={handleColorChangeComplete}
size="small" size="small"
className="phase-color-picker" className="phase-color-picker"
@@ -164,7 +173,7 @@ const SortablePhaseItem: React.FC<PhaseItemProps & { id: string }> = ({
<Input <Input
ref={inputRef} ref={inputRef}
value={editName} value={editName}
onChange={(e) => setEditName(e.target.value)} onChange={e => setEditName(e.target.value)}
onBlur={handleSave} onBlur={handleSave}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
className={`font-medium text-xs border-0 px-1 py-1 shadow-none ${ 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 <Text
className={`text-xs font-medium cursor-pointer transition-colors select-none ${ 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} onClick={handleClick}
title={t('rename')} title={t('rename')}
@@ -188,9 +199,11 @@ const SortablePhaseItem: React.FC<PhaseItemProps & { id: string }> = ({
</div> </div>
{/* Hover Actions */} {/* 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' isHovered || isEditing ? 'opacity-100' : 'opacity-0'
}`}> }`}
>
<Tooltip title={t('rename')}> <Tooltip title={t('rename')}>
<Button <Button
type="text" type="text"
@@ -223,11 +236,7 @@ const SortablePhaseItem: React.FC<PhaseItemProps & { id: string }> = ({
); );
}; };
const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({ open, onClose, projectId }) => {
open,
onClose,
projectId,
}) => {
const { t } = useTranslation('phases-drawer'); const { t } = useTranslation('phases-drawer');
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@@ -270,7 +279,8 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
} }
}, [finalProjectId, dispatch]); }, [finalProjectId, dispatch]);
const handleDragEnd = useCallback(async (event: DragEndEvent) => { const handleDragEnd = useCallback(
async (event: DragEndEvent) => {
if (!finalProjectId) return; if (!finalProjectId) return;
const { active, over } = event; const { active, over } = event;
@@ -302,7 +312,9 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
setSorting(false); setSorting(false);
} }
} }
}, [finalProjectId, phaseList, dispatch, refreshTasks]); },
[finalProjectId, phaseList, dispatch, refreshTasks]
);
const handleCreatePhase = useCallback(async () => { const handleCreatePhase = useCallback(async () => {
if (!newPhaseName.trim() || !finalProjectId) return; if (!newPhaseName.trim() || !finalProjectId) return;
@@ -318,16 +330,20 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
} }
}, [finalProjectId, dispatch, refreshTasks, newPhaseName]); }, [finalProjectId, dispatch, refreshTasks, newPhaseName]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => { const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
handleCreatePhase(); handleCreatePhase();
} else if (e.key === 'Escape') { } else if (e.key === 'Escape') {
setNewPhaseName(''); setNewPhaseName('');
setShowAddForm(false); setShowAddForm(false);
} }
}, [handleCreatePhase]); },
[handleCreatePhase]
);
const handleRenamePhase = useCallback(async (id: string, name: string) => { const handleRenamePhase = useCallback(
async (id: string, name: string) => {
if (!finalProjectId) return; if (!finalProjectId) return;
try { try {
@@ -350,9 +366,12 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
} catch (error) { } catch (error) {
console.error('Error renaming phase:', 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; if (!finalProjectId) return;
AntModal.confirm({ 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; if (!finalProjectId) return;
try { try {
@@ -397,7 +419,9 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
} catch (error) { } catch (error) {
console.error('Error changing phase color:', error); console.error('Error changing phase color:', error);
} }
}, [finalProjectId, phaseList, dispatch, refreshTasks]); },
[finalProjectId, phaseList, dispatch, refreshTasks]
);
const handlePhaseNameBlur = useCallback(async () => { const handlePhaseNameBlur = useCallback(async () => {
if (!finalProjectId || phaseName === initialPhaseName) return; if (!finalProjectId || phaseName === initialPhaseName) return;
@@ -427,9 +451,10 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
return ( return (
<Modal <Modal
title={ title={
<Title level={4} className={`m-0 font-semibold ${ <Title
isDarkMode ? 'text-gray-100' : 'text-gray-800' level={4}
}`}> className={`m-0 font-semibold ${isDarkMode ? 'text-gray-100' : 'text-gray-800'}`}
>
{t('configure')} {phaseName || project?.phase_label || t('phasesText')} {t('configure')} {phaseName || project?.phase_label || t('phasesText')}
</Title> </Title>
} }
@@ -445,9 +470,9 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
}, },
}} }}
footer={ footer={
<div className={`flex justify-end pt-3 ${ <div
isDarkMode ? 'border-gray-700' : 'border-gray-200' className={`flex justify-end pt-3 ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}
}`}> >
<Button <Button
onClick={handleClose} onClick={handleClose}
className={`font-medium ${ className={`font-medium ${
@@ -465,15 +490,17 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
> >
<div className="space-y-4"> <div className="space-y-4">
{/* Phase Label Configuration */} {/* Phase Label Configuration */}
<div className={`p-3 rounded border transition-all duration-200 ${ <div
className={`p-3 rounded border transition-all duration-200 ${
isDarkMode isDarkMode
? 'bg-gray-800 border-gray-700 text-gray-300' ? 'bg-gray-800 border-gray-700 text-gray-300'
: 'bg-blue-50 border-blue-200 text-blue-700' : 'bg-blue-50 border-blue-200 text-blue-700'
}`}> }`}
>
<div className="space-y-2"> <div className="space-y-2">
<Text className={`text-xs font-medium ${ <Text
isDarkMode ? 'text-gray-300' : 'text-blue-700' className={`text-xs font-medium ${isDarkMode ? 'text-gray-300' : 'text-blue-700'}`}
}`}> >
{t('phaseLabel')} {t('phaseLabel')}
</Text> </Text>
<Input <Input
@@ -489,30 +516,36 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
</div> </div>
{/* Info Banner */} {/* Info Banner */}
<div className={`p-3 rounded border transition-all duration-200 ${ <div
className={`p-3 rounded border transition-all duration-200 ${
isDarkMode isDarkMode
? 'bg-gray-800 border-gray-700 text-gray-300' ? 'bg-gray-800 border-gray-700 text-gray-300'
: 'bg-blue-50 border-blue-200 text-blue-700' : 'bg-blue-50 border-blue-200 text-blue-700'
}`}> }`}
<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'}`}>
}`}> 🎨 Drag {(phaseName || project?.phase_label || t('phasesText')).toLowerCase()} to
🎨 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. 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> </Text>
</div> </div>
{/* Add New Phase Form */} {/* Add New Phase Form */}
{showAddForm && ( {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 isDarkMode
? 'border-gray-600 bg-gray-700 hover:border-gray-500' ? 'border-gray-600 bg-gray-700 hover:border-gray-500'
: 'border-gray-300 bg-white hover:border-gray-400' : 'border-gray-300 bg-white hover:border-gray-400'
} shadow-sm`}> } shadow-sm`}
>
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
placeholder={t('enterNewPhaseName')} placeholder={t('enterNewPhaseName')}
value={newPhaseName} value={newPhaseName}
onChange={(e) => setNewPhaseName(e.target.value)} onChange={e => setNewPhaseName(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
className={`flex-1 ${ className={`flex-1 ${
isDarkMode isDarkMode
@@ -551,15 +584,17 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
{/* Add Phase Button */} {/* Add Phase Button */}
{!showAddForm && ( {!showAddForm && (
<div className={`p-3 rounded border-2 border-dashed transition-colors ${ <div
className={`p-3 rounded border-2 border-dashed transition-colors ${
isDarkMode isDarkMode
? 'border-gray-600 bg-gray-800/50 hover:border-gray-500' ? 'border-gray-600 bg-gray-800/50 hover:border-gray-500'
: 'border-gray-300 bg-gray-50/50 hover:border-gray-400' : 'border-gray-300 bg-gray-50/50 hover:border-gray-400'
}`}> }`}
>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Text className={`text-xs font-medium ${ <Text
isDarkMode ? 'text-gray-300' : 'text-gray-700' className={`text-xs font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}
}`}> >
{phaseName || project?.phase_label || t('phasesText')} {t('optionsText')} {phaseName || project?.phase_label || t('phasesText')} {t('optionsText')}
</Text> </Text>
<Button <Button
@@ -583,7 +618,7 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
strategy={verticalListSortingStrategy} strategy={verticalListSortingStrategy}
> >
<div className="space-y-1"> <div className="space-y-1">
{phaseList.map((phase) => ( {phaseList.map(phase => (
<SortablePhaseItem <SortablePhaseItem
key={phase.id} key={phase.id}
id={phase.id} id={phase.id}
@@ -599,11 +634,14 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
</DndContext> </DndContext>
{phaseList.length === 0 && ( {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' isDarkMode ? 'text-gray-400' : 'text-gray-500'
}`}> }`}
>
<Text className="text-sm font-medium"> <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> </Text>
<br /> <br />
<Button <Button

View File

@@ -292,7 +292,9 @@
/* Sortable item styling */ /* Sortable item styling */
.sortable-status-item { .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 { .sortable-status-item.is-dragging {
@@ -340,7 +342,9 @@
.status-item-enter-active { .status-item-enter-active {
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0);
transition: opacity 0.3s ease, transform 0.3s ease; transition:
opacity 0.3s ease,
transform 0.3s ease;
} }
.status-item-exit { .status-item-exit {
@@ -351,5 +355,7 @@
.status-item-exit-active { .status-item-exit-active {
opacity: 0; opacity: 0;
transform: translateY(-10px); 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