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.
This commit is contained in:
Chamika J
2025-08-01 16:54:58 +05:30
parent 1a64115063
commit 8f407b45a9
12 changed files with 121 additions and 59 deletions

View File

@@ -4,6 +4,6 @@
"teams": "Teams",
"billing": "Billing",
"projects": "Projects",
"settings": "Settings",
"settings": "Utilization Settings",
"adminCenter": "Admin Center"
}

View File

@@ -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<IHolidayCalendarEvent[]>;
} catch (error) {
console.error('Error fetching combined holidays:', error);
logger.error('Error fetching combined holidays:', error);
return {
done: false,
body: [],

View File

@@ -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';

View File

@@ -44,7 +44,7 @@ interface HolidayCalendarProps {
const HolidayCalendar: React.FC<HolidayCalendarProps> = ({ 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<HolidayCalendarProps> = ({ themeMode, workingDay
const [currentDate, setCurrentDate] = useState<Dayjs>(dayjs());
const [isPopulatingHolidays, setIsPopulatingHolidays] = useState(false);
const [hasAttemptedPopulation, setHasAttemptedPopulation] = useState(false);
const [isNavigating, setIsNavigating] = useState(false);
const fetchHolidayTypes = async () => {
try {
@@ -79,18 +80,16 @@ const HolidayCalendar: React.FC<HolidayCalendarProps> = ({ themeMode, workingDay
!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<HolidayCalendarProps> = ({ 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<HolidayCalendarProps> = ({ 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 (
<div className={`calendar-cell ${isWorkingDay ? 'working-day' : 'non-working-day'} ${isToday ? 'today' : ''} ${!isCurrentMonth ? 'other-month' : ''}`}>
<div
className={`calendar-cell ${isWorkingDay ? 'working-day' : 'non-working-day'} ${isToday ? 'today' : ''} ${!isCurrentMonth ? 'other-month' : ''}`}
>
{dateHolidays.length > 0 && (
<div className="holiday-cell">
{dateHolidays.map((holiday, index) => {
@@ -250,16 +252,22 @@ const HolidayCalendar: React.FC<HolidayCalendarProps> = ({ 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 && (
<span className="custom-holiday-icon" style={{ marginRight: '2px' }}></span>
<span className="custom-holiday-icon" style={{ marginRight: '2px' }}>
</span>
)}
{isOfficial && (
<span className="official-holiday-icon" style={{ marginRight: '2px' }}>🏛</span>
<span className="official-holiday-icon" style={{ marginRight: '2px' }}>
🏛
</span>
)}
{holiday.name}
</Tag>
@@ -277,10 +285,23 @@ const HolidayCalendar: React.FC<HolidayCalendarProps> = ({ 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<HolidayCalendarProps> = ({ 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<HolidayCalendarProps> = ({ themeMode, workingDay
</Button>
</div>
<div className="calendar-container">
<div className={`calendar-container holiday-calendar ${themeMode}`}>
<Calendar
value={currentDate}
onPanelChange={onPanelChange}
@@ -474,7 +495,7 @@ const HolidayCalendar: React.FC<HolidayCalendarProps> = ({ themeMode, workingDay
setSelectedHoliday(null);
}}
footer={null}
destroyOnClose
destroyOnHidden
>
<Form form={editForm} layout="vertical" onFinish={handleUpdateHoliday}>
<Form.Item
@@ -540,18 +561,22 @@ const HolidayCalendar: React.FC<HolidayCalendarProps> = ({ themeMode, workingDay
>
{t('cancel')}
</Button>
{selectedHoliday && selectedHoliday.source === 'custom' && selectedHoliday.is_editable && (
<Popconfirm
title={t('deleteHolidayConfirm') || 'Are you sure you want to delete this holiday?'}
onConfirm={() => handleDeleteHoliday(selectedHoliday.id)}
okText={t('yes') || 'Yes'}
cancelText={t('no') || 'No'}
>
<Button type="primary" danger icon={<DeleteOutlined />}>
{t('delete') || 'Delete'}
</Button>
</Popconfirm>
)}
{selectedHoliday &&
selectedHoliday.source === 'custom' &&
selectedHoliday.is_editable && (
<Popconfirm
title={
t('deleteHolidayConfirm') || 'Are you sure you want to delete this holiday?'
}
onConfirm={() => handleDeleteHoliday(selectedHoliday.id)}
okText={t('yes') || 'Yes'}
cancelText={t('no') || 'No'}
>
<Button type="primary" danger icon={<DeleteOutlined />}>
{t('delete') || 'Delete'}
</Button>
</Popconfirm>
)}
</Space>
</Form.Item>
</Form>

View File

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

View File

@@ -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 (
<div
style={{
marginBlock: 96,
marginBlock: 24,
minHeight: '90vh',
marginLeft: `${isMarginAvailable ? '5%' : ''}`,
marginRight: `${isMarginAvailable ? '5%' : ''}`,
}}
>
<Typography.Title level={4}>{t('adminCenter')}</Typography.Title>

View File

@@ -12,7 +12,7 @@ const SettingsLayout = () => {
const navigate = useNavigate();
return (
<div style={{ marginBlock: 96, minHeight: '90vh' }}>
<div style={{ marginBlock: 24, minHeight: '90vh' }}>
<Typography.Title level={4}>Settings</Typography.Title>
{isTablet ? (

View File

@@ -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 = {

View File

@@ -1 +0,0 @@
export { default } from './settings';

View File

@@ -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';

View File

@@ -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 = () => {

View File

@@ -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';