From 8f407b45a94dece9cd77e6caa8caa5539e1c3427 Mon Sep 17 00:00:00 2001 From: Chamika J <75464293+chamikaJ@users.noreply.github.com> Date: Fri, 1 Aug 2025 16:54:58 +0530 Subject: [PATCH] feat(holiday-calendar): implement holiday calendar component with CRUD functionality - Added a new `HolidayCalendar` component for managing custom and official holidays. - Integrated holiday type fetching and population logic to streamline holiday management. - Enhanced UI with modals for creating, editing, and deleting holidays, including validation and user feedback. - Updated styles for improved visual presentation and user interaction. - Refactored sidebar and routes to accommodate new component structure and localization updates. --- .../locales/en/admin-center/sidebar.json | 2 +- .../src/api/holiday/holiday.api.service.ts | 12 +-- .../src/app/routes/admin-center-routes.tsx | 2 +- ...liday-calendar.tsx => HolidayCalendar.tsx} | 87 ++++++++++++------- .../holiday-calendar/holiday-calendar.css | 51 ++++++++++- .../src/layouts/AdminCenterLayout.tsx | 6 +- .../src/layouts/SettingsLayout.tsx | 2 +- .../admin-center-constants.ts | 12 +-- .../src/pages/admin-center/settings/index.ts | 1 - .../pages/admin-center/settings/settings.tsx | 2 +- .../pages/admin-center/sidebar/sidebar.tsx | 2 +- .../src/types/holiday/holiday.types.ts | 1 + 12 files changed, 121 insertions(+), 59 deletions(-) rename worklenz-frontend/src/components/admin-center/overview/holiday-calendar/{holiday-calendar.tsx => HolidayCalendar.tsx} (88%) rename worklenz-frontend/src/{pages/admin-center => lib}/admin-center-constants.ts (76%) delete mode 100644 worklenz-frontend/src/pages/admin-center/settings/index.ts diff --git a/worklenz-frontend/public/locales/en/admin-center/sidebar.json b/worklenz-frontend/public/locales/en/admin-center/sidebar.json index 3ed41e1b..16321777 100644 --- a/worklenz-frontend/public/locales/en/admin-center/sidebar.json +++ b/worklenz-frontend/public/locales/en/admin-center/sidebar.json @@ -4,6 +4,6 @@ "teams": "Teams", "billing": "Billing", "projects": "Projects", - "settings": "Settings", + "settings": "Utilization Settings", "adminCenter": "Admin Center" } diff --git a/worklenz-frontend/src/api/holiday/holiday.api.service.ts b/worklenz-frontend/src/api/holiday/holiday.api.service.ts index 32e1fbca..7d09279a 100644 --- a/worklenz-frontend/src/api/holiday/holiday.api.service.ts +++ b/worklenz-frontend/src/api/holiday/holiday.api.service.ts @@ -15,6 +15,8 @@ import { ICombinedHolidaysRequest, IHolidayDateRange, } from '@/types/holiday/holiday.types'; +import logger from '@/utils/errorLogger'; +import { error } from 'console'; const rootUrl = `${API_BASE_URL}/holidays`; @@ -764,9 +766,7 @@ export const holidayApiService = { } // Get organization holidays from database (includes both custom and country-specific) - console.log(`🏢 Fetching organization holidays for year: ${year}`); const customRes = await holidayApiService.getOrganizationHolidays(year); - console.log('🏢 Organization holidays response:', customRes); if (customRes.done && customRes.body) { const customHolidays = customRes.body @@ -777,6 +777,7 @@ export const holidayApiService = { description: h.description, date: h.date, is_recurring: h.is_recurring, + holiday_type_id: h.holiday_type_id, holiday_type_name: h.holiday_type_name || 'Custom', color_code: h.color_code || '#f37070', source: h.source || 'custom' as const, @@ -787,19 +788,14 @@ export const holidayApiService = { const existingDates = new Set(allHolidays.map(h => h.date)); const uniqueCustomHolidays = customHolidays.filter((h: any) => !existingDates.has(h.date)); - console.log(`✅ Found ${customHolidays.length} organization holidays (${uniqueCustomHolidays.length} unique)`); allHolidays.push(...uniqueCustomHolidays); - } else { - console.log('⚠️ No organization holidays returned from API'); } - - console.log(`🎉 Total holidays combined: ${allHolidays.length}`, allHolidays); return { done: true, body: allHolidays, } as IServerResponse; } catch (error) { - console.error('Error fetching combined holidays:', error); + logger.error('Error fetching combined holidays:', error); return { done: false, body: [], diff --git a/worklenz-frontend/src/app/routes/admin-center-routes.tsx b/worklenz-frontend/src/app/routes/admin-center-routes.tsx index c8b77197..9cca0e7d 100644 --- a/worklenz-frontend/src/app/routes/admin-center-routes.tsx +++ b/worklenz-frontend/src/app/routes/admin-center-routes.tsx @@ -1,6 +1,6 @@ import { RouteObject } from 'react-router-dom'; import { Suspense } from 'react'; -import { adminCenterItems } from '@/pages/admin-center/admin-center-constants'; +import { adminCenterItems } from '@/lib/admin-center-constants'; import { Navigate } from 'react-router-dom'; import { useAuthService } from '@/hooks/useAuth'; import { SuspenseFallback } from '@/components/suspense-fallback/suspense-fallback'; diff --git a/worklenz-frontend/src/components/admin-center/overview/holiday-calendar/holiday-calendar.tsx b/worklenz-frontend/src/components/admin-center/overview/holiday-calendar/HolidayCalendar.tsx similarity index 88% rename from worklenz-frontend/src/components/admin-center/overview/holiday-calendar/holiday-calendar.tsx rename to worklenz-frontend/src/components/admin-center/overview/holiday-calendar/HolidayCalendar.tsx index 3568c44f..c9bd2f3d 100644 --- a/worklenz-frontend/src/components/admin-center/overview/holiday-calendar/holiday-calendar.tsx +++ b/worklenz-frontend/src/components/admin-center/overview/holiday-calendar/HolidayCalendar.tsx @@ -44,7 +44,7 @@ interface HolidayCalendarProps { const HolidayCalendar: React.FC = ({ themeMode, workingDays = [] }) => { const { t } = useTranslation('admin-center/overview'); const dispatch = useAppDispatch(); - const { holidays, loadingHolidays, holidaySettings } = useAppSelector( + const { holidays, holidaySettings } = useAppSelector( (state: RootState) => state.adminCenterReducer ); const [form] = Form.useForm(); @@ -57,6 +57,7 @@ const HolidayCalendar: React.FC = ({ themeMode, workingDay const [currentDate, setCurrentDate] = useState(dayjs()); const [isPopulatingHolidays, setIsPopulatingHolidays] = useState(false); const [hasAttemptedPopulation, setHasAttemptedPopulation] = useState(false); + const [isNavigating, setIsNavigating] = useState(false); const fetchHolidayTypes = async () => { try { @@ -73,24 +74,22 @@ const HolidayCalendar: React.FC = ({ themeMode, workingDay // Check if we have holiday settings with a country code but no holidays // Also check if we haven't already attempted population and we're not currently populating if ( - holidaySettings?.country_code && - holidays.length === 0 && - !hasAttemptedPopulation && + holidaySettings?.country_code && + holidays.length === 0 && + !hasAttemptedPopulation && !isPopulatingHolidays ) { try { - console.log('🔄 No holidays found, attempting to populate official holidays...'); setIsPopulatingHolidays(true); setHasAttemptedPopulation(true); - + const populateRes = await holidayApiService.populateCountryHolidays(); if (populateRes.done) { - console.log('✅ Official holidays populated successfully'); // Refresh holidays after population fetchHolidaysForDateRange(true); } } catch (error) { - console.warn('⚠️ Could not populate official holidays:', error); + logger.error('populateHolidaysIfNeeded', error); } finally { setIsPopulatingHolidays(false); } @@ -217,7 +216,7 @@ const HolidayCalendar: React.FC = ({ themeMode, workingDay name: holiday.name, description: holiday.description, date: dayjs(holiday.date), - holiday_type_id: holiday.holiday_type_name, // This might need adjustment based on backend + holiday_type_id: holiday.holiday_type_id, is_recurring: holiday.is_recurring, }); setEditModalVisible(true); @@ -227,12 +226,15 @@ const HolidayCalendar: React.FC = ({ themeMode, workingDay const dateHolidays = holidays.filter(h => dayjs(h.date).isSame(date, 'day')); const dayName = date.format('dddd'); // Check if this day is in the working days array from API response - const isWorkingDay = workingDays && workingDays.length > 0 ? workingDays.includes(dayName) : false; + const isWorkingDay = + workingDays && workingDays.length > 0 ? workingDays.includes(dayName) : false; const isToday = date.isSame(dayjs(), 'day'); const isCurrentMonth = date.isSame(currentDate, 'month'); return ( -
+
{dateHolidays.length > 0 && (
{dateHolidays.map((holiday, index) => { @@ -250,16 +252,22 @@ const HolidayCalendar: React.FC = ({ themeMode, workingDay borderRadius: '4px', display: 'block', fontWeight: isCustom ? 600 : 500, - border: isCustom ? '1px solid rgba(82, 196, 26, 0.6)' : '1px solid rgba(24, 144, 255, 0.4)', + border: isCustom + ? '1px solid rgba(82, 196, 26, 0.6)' + : '1px solid rgba(24, 144, 255, 0.4)', position: 'relative', }} title={`${holiday.name}${isOfficial ? ' (Official Holiday)' : ' (Custom Holiday)'}`} > {isCustom && ( - + + ⭐ + )} {isOfficial && ( - 🏛️ + + 🏛️ + )} {holiday.name} @@ -277,10 +285,23 @@ const HolidayCalendar: React.FC = ({ themeMode, workingDay }; const onPanelChange = (value: Dayjs) => { + setIsNavigating(true); setCurrentDate(value); + // Reset navigation flag after a short delay to allow the onSelect event to check it + setTimeout(() => setIsNavigating(false), 100); }; const onDateSelect = (date: Dayjs) => { + // Prevent modal from opening during navigation (month/year changes) + if (isNavigating) { + return; + } + + // Prevent modal from opening if the date is from a different month (navigation click) + if (!date.isSame(currentDate, 'month')) { + return; + } + // 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 @@ -336,11 +357,11 @@ const HolidayCalendar: React.FC = ({ themeMode, workingDay boxShadow: '0 2px 8px rgba(24, 144, 255, 0.2)', transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)', }} - onMouseEnter={(e) => { + onMouseEnter={e => { e.currentTarget.style.transform = 'translateY(-1px)'; e.currentTarget.style.boxShadow = '0 4px 12px rgba(24, 144, 255, 0.3)'; }} - onMouseLeave={(e) => { + onMouseLeave={e => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(24, 144, 255, 0.2)'; }} @@ -349,7 +370,7 @@ const HolidayCalendar: React.FC = ({ themeMode, workingDay
-
+
= ({ themeMode, workingDay dateCellRender={getHolidayDateCellRender} className={`holiday-calendar ${themeMode}`} /> - + {/* Calendar Legend */}
@@ -474,7 +495,7 @@ const HolidayCalendar: React.FC = ({ themeMode, workingDay setSelectedHoliday(null); }} footer={null} - destroyOnClose + destroyOnHidden >
= ({ themeMode, workingDay > {t('cancel')} - {selectedHoliday && selectedHoliday.source === 'custom' && selectedHoliday.is_editable && ( - handleDeleteHoliday(selectedHoliday.id)} - okText={t('yes') || 'Yes'} - cancelText={t('no') || 'No'} - > - - - )} + {selectedHoliday && + selectedHoliday.source === 'custom' && + selectedHoliday.is_editable && ( + handleDeleteHoliday(selectedHoliday.id)} + okText={t('yes') || 'Yes'} + cancelText={t('no') || 'No'} + > + + + )}
diff --git a/worklenz-frontend/src/components/admin-center/overview/holiday-calendar/holiday-calendar.css b/worklenz-frontend/src/components/admin-center/overview/holiday-calendar/holiday-calendar.css index 350a2fd7..0743890c 100644 --- a/worklenz-frontend/src/components/admin-center/overview/holiday-calendar/holiday-calendar.css +++ b/worklenz-frontend/src/components/admin-center/overview/holiday-calendar/holiday-calendar.css @@ -493,11 +493,13 @@ background: #fafafa; border-radius: 6px; border: 1px solid #d9d9d9; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); } .holiday-calendar.dark .calendar-legend { background: #1f1f1f; border-color: #434343; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); } .legend-item { @@ -505,11 +507,33 @@ align-items: center; gap: 8px; font-size: 12px; - color: rgba(0, 0, 0, 0.65); + color: rgba(0, 0, 0, 0.85); + font-weight: 500; } .holiday-calendar.dark .legend-item { - color: rgba(255, 255, 255, 0.65); + color: rgba(255, 255, 255, 0.85); +} + +/* Legend item hover effects */ +.legend-item:hover { + transform: translateY(-1px); + transition: transform 0.2s ease; +} + +.legend-item:hover .legend-badge { + transform: scale(1.1); + transition: transform 0.2s ease; +} + +.legend-item:hover .legend-tag { + transform: scale(1.05); + transition: transform 0.2s ease; +} + +.legend-item:hover .legend-dot { + transform: scale(1.2); + transition: transform 0.2s ease; } .legend-dot { @@ -531,6 +555,7 @@ .legend-dot.today-dot { background: #1890ff; box-shadow: 0 0 0 1px rgba(24, 144, 255, 0.2); + animation: todayPulse 2s ease-in-out infinite; } .holiday-calendar.dark .legend-dot.working-day-dot { @@ -546,6 +571,7 @@ .holiday-calendar.dark .legend-dot.today-dot { background: #1890ff; box-shadow: 0 0 0 1px rgba(24, 144, 255, 0.3); + animation: todayPulse 2s ease-in-out infinite; } /* Legend badge and tag styles */ @@ -559,19 +585,22 @@ display: flex; align-items: center; justify-content: center; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15); + transition: all 0.2s ease; } .legend-badge.working-day-badge { background: #52c41a; border: 1px solid #d9f7be; color: #ffffff; + box-shadow: 0 1px 3px rgba(82, 196, 26, 0.2); } .holiday-calendar.dark .legend-badge.working-day-badge { background: #52c41a; border: 1px solid #237804; color: #ffffff; + box-shadow: 0 1px 3px rgba(82, 196, 26, 0.3); } .legend-tag { @@ -583,35 +612,41 @@ font-size: 9px; font-weight: 500; border: 1px solid; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); } .legend-tag-text { font-size: 8px; line-height: 1; + font-weight: 600; } .custom-holiday-legend { background: #f6ffed; border: 1px solid #b7eb8f; color: #52c41a; + box-shadow: 0 1px 2px rgba(82, 196, 26, 0.1); } .official-holiday-legend { background: #e6f7ff; border: 1px solid #91d5ff; color: #1890ff; + box-shadow: 0 1px 2px rgba(24, 144, 255, 0.1); } .holiday-calendar.dark .custom-holiday-legend { background: #162312; border: 1px solid #274916; color: #95de64; + box-shadow: 0 1px 2px rgba(149, 222, 100, 0.2); } .holiday-calendar.dark .official-holiday-legend { background: #111b26; border: 1px solid #13334c; color: #69c0ff; + box-shadow: 0 1px 2px rgba(105, 192, 255, 0.2); } .legend-tag .custom-holiday-icon, @@ -646,6 +681,16 @@ padding: 10px 12px; } + .legend-item:hover { + transform: none; + } + + .legend-item:hover .legend-badge, + .legend-item:hover .legend-tag, + .legend-item:hover .legend-dot { + transform: none; + } + .working-day-badge { width: 14px; height: 14px; diff --git a/worklenz-frontend/src/layouts/AdminCenterLayout.tsx b/worklenz-frontend/src/layouts/AdminCenterLayout.tsx index d436e28d..2f947357 100644 --- a/worklenz-frontend/src/layouts/AdminCenterLayout.tsx +++ b/worklenz-frontend/src/layouts/AdminCenterLayout.tsx @@ -7,18 +7,14 @@ import { useTranslation } from 'react-i18next'; import { useAppDispatch } from '@/hooks/useAppDispatch'; const AdminCenterLayout: React.FC = () => { - const dispatch = useAppDispatch(); const isTablet = useMediaQuery({ query: '(min-width:768px)' }); - const isMarginAvailable = useMediaQuery({ query: '(min-width: 1000px)' }); const { t } = useTranslation('admin-center/sidebar'); return (
{t('adminCenter')} diff --git a/worklenz-frontend/src/layouts/SettingsLayout.tsx b/worklenz-frontend/src/layouts/SettingsLayout.tsx index 7bcb6b9e..776f2ee5 100644 --- a/worklenz-frontend/src/layouts/SettingsLayout.tsx +++ b/worklenz-frontend/src/layouts/SettingsLayout.tsx @@ -12,7 +12,7 @@ const SettingsLayout = () => { const navigate = useNavigate(); return ( -
+
Settings {isTablet ? ( diff --git a/worklenz-frontend/src/pages/admin-center/admin-center-constants.ts b/worklenz-frontend/src/lib/admin-center-constants.ts similarity index 76% rename from worklenz-frontend/src/pages/admin-center/admin-center-constants.ts rename to worklenz-frontend/src/lib/admin-center-constants.ts index 9a320966..db5c477b 100644 --- a/worklenz-frontend/src/pages/admin-center/admin-center-constants.ts +++ b/worklenz-frontend/src/lib/admin-center-constants.ts @@ -7,12 +7,12 @@ import { SettingOutlined, } from '@/shared/antd-imports'; import React, { ReactNode, lazy } from 'react'; -const Overview = lazy(() => import('./overview/overview')); -const Users = lazy(() => import('./users/users')); -const Teams = lazy(() => import('./teams/teams')); -const Billing = lazy(() => import('./billing/billing')); -const Projects = lazy(() => import('./projects/projects')); -const Settings = lazy(() => import('./settings/settings')); +const Overview = lazy(() => import('../pages/admin-center/overview/overview')); +const Users = lazy(() => import('../pages/admin-center/users/users')); +const Teams = lazy(() => import('../pages/admin-center/teams/teams')); +const Billing = lazy(() => import('../pages/admin-center/billing/billing')); +const Projects = lazy(() => import('../pages/admin-center/projects/projects')); +const Settings = lazy(() => import('../pages/admin-center/settings/Settings')); // type of a menu item in admin center sidebar type AdminCenterMenuItems = { diff --git a/worklenz-frontend/src/pages/admin-center/settings/index.ts b/worklenz-frontend/src/pages/admin-center/settings/index.ts deleted file mode 100644 index 1e817364..00000000 --- a/worklenz-frontend/src/pages/admin-center/settings/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './settings'; diff --git a/worklenz-frontend/src/pages/admin-center/settings/settings.tsx b/worklenz-frontend/src/pages/admin-center/settings/settings.tsx index d57d497d..2e6d4d1b 100644 --- a/worklenz-frontend/src/pages/admin-center/settings/settings.tsx +++ b/worklenz-frontend/src/pages/admin-center/settings/settings.tsx @@ -20,7 +20,7 @@ import { scheduleAPIService } from '@/api/schedule/schedule.api.service'; import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service'; import { Settings } from '@/types/schedule/schedule-v2.types'; import OrganizationCalculationMethod from '@/components/admin-center/overview/organization-calculation-method/organization-calculation-method'; -import HolidayCalendar from '@/components/admin-center/overview/holiday-calendar/holiday-calendar'; +import HolidayCalendar from '@/components/admin-center/overview/holiday-calendar/HolidayCalendar'; import { useAppDispatch } from '@/hooks/useAppDispatch'; import { useAppSelector } from '@/hooks/useAppSelector'; import { RootState } from '@/app/store'; diff --git a/worklenz-frontend/src/pages/admin-center/sidebar/sidebar.tsx b/worklenz-frontend/src/pages/admin-center/sidebar/sidebar.tsx index 8103790c..9e6f6d62 100644 --- a/worklenz-frontend/src/pages/admin-center/sidebar/sidebar.tsx +++ b/worklenz-frontend/src/pages/admin-center/sidebar/sidebar.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { Link, useLocation } from 'react-router-dom'; import { colors } from '../../../styles/colors'; import { useTranslation } from 'react-i18next'; -import { adminCenterItems } from '../admin-center-constants'; +import { adminCenterItems } from '../../../lib/admin-center-constants'; import './sidebar.css'; const AdminCenterSidebar: React.FC = () => { diff --git a/worklenz-frontend/src/types/holiday/holiday.types.ts b/worklenz-frontend/src/types/holiday/holiday.types.ts index 944ca7bd..4f47c94d 100644 --- a/worklenz-frontend/src/types/holiday/holiday.types.ts +++ b/worklenz-frontend/src/types/holiday/holiday.types.ts @@ -91,6 +91,7 @@ export interface IHolidayCalendarEvent { description?: string; date: string; is_recurring: boolean; + holiday_type_id?: string; holiday_type_name: string; color_code: string; source: 'official' | 'custom';