From f81d0f959473882ac918bd90ced44da748a6960f Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Mon, 28 Jul 2025 13:07:15 +0530 Subject: [PATCH] feat(holiday-settings): implement organization holiday settings management - Added SQL migration for creating organization holiday settings and state holidays tables with necessary constraints and indexes. - Implemented API endpoints in AdminCenterController for retrieving and updating organization holiday settings. - Updated admin-center API router to include routes for holiday settings management. - Enhanced localization files to support new holiday settings UI elements in multiple languages. - Improved holiday calendar component to display working days and integrate holiday settings. --- ...0000-add-organization-holiday-settings.sql | 63 +++ .../controllers/admin-center-controller.ts | 111 ++++ .../routes/apis/admin-center-api-router.ts | 5 + .../locales/alb/admin-center/overview.json | 42 +- .../locales/alb/admin-center/settings.json | 18 +- .../locales/de/admin-center/overview.json | 42 +- .../locales/de/admin-center/settings.json | 18 +- .../locales/en/admin-center/overview.json | 14 +- .../locales/en/admin-center/settings.json | 12 +- .../locales/es/admin-center/overview.json | 42 +- .../locales/es/admin-center/settings.json | 18 +- .../locales/pt/admin-center/overview.json | 42 +- .../locales/pt/admin-center/settings.json | 18 +- .../locales/zh/admin-center/overview.json | 42 +- .../locales/zh/admin-center/settings.json | 18 +- .../src/api/holiday/holiday.api.service.ts | 217 ++++---- .../holiday-calendar/holiday-calendar.css | 492 +++++++++++++++++- .../holiday-calendar/holiday-calendar.tsx | 228 +++++--- .../src/data/sri-lanka-holidays-2025.ts | 10 - .../admin-center/admin-center.slice.ts | 7 +- .../pages/admin-center/settings/settings.tsx | 13 +- 21 files changed, 1265 insertions(+), 207 deletions(-) create mode 100644 worklenz-backend/database/migrations/20250728000000-add-organization-holiday-settings.sql diff --git a/worklenz-backend/database/migrations/20250728000000-add-organization-holiday-settings.sql b/worklenz-backend/database/migrations/20250728000000-add-organization-holiday-settings.sql new file mode 100644 index 00000000..d1fc7c28 --- /dev/null +++ b/worklenz-backend/database/migrations/20250728000000-add-organization-holiday-settings.sql @@ -0,0 +1,63 @@ +-- Create organization holiday settings table +CREATE TABLE IF NOT EXISTS organization_holiday_settings ( + id UUID DEFAULT uuid_generate_v4() NOT NULL, + organization_id UUID NOT NULL, + country_code CHAR(2), + state_code TEXT, + auto_sync_holidays BOOLEAN DEFAULT TRUE NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL +); + +ALTER TABLE organization_holiday_settings + ADD CONSTRAINT organization_holiday_settings_pk + PRIMARY KEY (id); + +ALTER TABLE organization_holiday_settings + ADD CONSTRAINT organization_holiday_settings_organization_id_fk + FOREIGN KEY (organization_id) REFERENCES organizations + ON DELETE CASCADE; + +ALTER TABLE organization_holiday_settings + ADD CONSTRAINT organization_holiday_settings_country_code_fk + FOREIGN KEY (country_code) REFERENCES countries(code) + ON DELETE SET NULL; + +-- Ensure one settings record per organization +ALTER TABLE organization_holiday_settings + ADD CONSTRAINT organization_holiday_settings_organization_unique + UNIQUE (organization_id); + +-- Create index for better performance +CREATE INDEX IF NOT EXISTS idx_organization_holiday_settings_organization_id ON organization_holiday_settings(organization_id); + +-- Add state holidays table for more granular holiday data +CREATE TABLE IF NOT EXISTS state_holidays ( + id UUID DEFAULT uuid_generate_v4() NOT NULL, + country_code CHAR(2) NOT NULL, + state_code TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT, + date DATE NOT NULL, + is_recurring BOOLEAN DEFAULT TRUE NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL +); + +ALTER TABLE state_holidays + ADD CONSTRAINT state_holidays_pk + PRIMARY KEY (id); + +ALTER TABLE state_holidays + ADD CONSTRAINT state_holidays_country_code_fk + FOREIGN KEY (country_code) REFERENCES countries(code) + ON DELETE CASCADE; + +-- Add unique constraint to prevent duplicate holidays for the same state, name, and date +ALTER TABLE state_holidays + ADD CONSTRAINT state_holidays_state_name_date_unique + UNIQUE (country_code, state_code, name, date); + +-- Create indexes for better performance +CREATE INDEX IF NOT EXISTS idx_state_holidays_country_state ON state_holidays(country_code, state_code); +CREATE INDEX IF NOT EXISTS idx_state_holidays_date ON state_holidays(date); \ No newline at end of file diff --git a/worklenz-backend/src/controllers/admin-center-controller.ts b/worklenz-backend/src/controllers/admin-center-controller.ts index 3689f52d..97f1a134 100644 --- a/worklenz-backend/src/controllers/admin-center-controller.ts +++ b/worklenz-backend/src/controllers/admin-center-controller.ts @@ -1119,4 +1119,115 @@ export default class AdminCenterController extends WorklenzControllerBase { return res.status(200).send(new ServerResponse(true, response)); } + + @HandleExceptions() + public static async getOrganizationHolidaySettings( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ): Promise { + const q = `SELECT ohs.id, ohs.organization_id, ohs.country_code, ohs.state_code, + ohs.auto_sync_holidays, ohs.created_at, ohs.updated_at + FROM organization_holiday_settings ohs + JOIN organizations o ON ohs.organization_id = o.id + WHERE o.user_id = $1;`; + + const result = await db.query(q, [req.user?.owner_id]); + + // If no settings exist, return default settings + if (result.rows.length === 0) { + return res.status(200).send(new ServerResponse(true, { + country_code: null, + state_code: null, + auto_sync_holidays: true + })); + } + + return res.status(200).send(new ServerResponse(true, result.rows[0])); + } + + @HandleExceptions() + public static async updateOrganizationHolidaySettings( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ): Promise { + const { country_code, state_code, auto_sync_holidays } = req.body; + + // First, get the organization ID + const orgQ = `SELECT id FROM organizations WHERE user_id = $1;`; + const orgResult = await db.query(orgQ, [req.user?.owner_id]); + + if (orgResult.rows.length === 0) { + return res.status(404).send(new ServerResponse(false, "Organization not found")); + } + + const organizationId = orgResult.rows[0].id; + + // Check if settings already exist + const checkQ = `SELECT id FROM organization_holiday_settings WHERE organization_id = $1;`; + const checkResult = await db.query(checkQ, [organizationId]); + + let result; + if (checkResult.rows.length > 0) { + // Update existing settings + const updateQ = `UPDATE organization_holiday_settings + SET country_code = $2, + state_code = $3, + auto_sync_holidays = $4, + updated_at = CURRENT_TIMESTAMP + WHERE organization_id = $1 + RETURNING *;`; + result = await db.query(updateQ, [organizationId, country_code, state_code, auto_sync_holidays]); + } else { + // Insert new settings + const insertQ = `INSERT INTO organization_holiday_settings + (organization_id, country_code, state_code, auto_sync_holidays) + VALUES ($1, $2, $3, $4) + RETURNING *;`; + result = await db.query(insertQ, [organizationId, country_code, state_code, auto_sync_holidays]); + } + + return res.status(200).send(new ServerResponse(true, result.rows[0])); + } + + @HandleExceptions() + public static async getCountriesWithStates( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ): Promise { + // Get all countries + const countriesQ = `SELECT code, name FROM countries ORDER BY name;`; + const countriesResult = await db.query(countriesQ); + + // For now, we'll return a basic structure + // In a real implementation, you would have a states table + const countriesWithStates = countriesResult.rows.map(country => ({ + code: country.code, + name: country.name, + states: [] as Array<{ code: string; name: string }> // Would be populated from a states table + })); + + // Add some example states for US and Canada + const usIndex = countriesWithStates.findIndex(c => c.code === 'US'); + if (usIndex !== -1) { + countriesWithStates[usIndex].states = [ + { code: 'CA', name: 'California' }, + { code: 'NY', name: 'New York' }, + { code: 'TX', name: 'Texas' }, + { code: 'FL', name: 'Florida' }, + { code: 'WA', name: 'Washington' } + ]; + } + + const caIndex = countriesWithStates.findIndex(c => c.code === 'CA'); + if (caIndex !== -1) { + countriesWithStates[caIndex].states = [ + { code: 'ON', name: 'Ontario' }, + { code: 'QC', name: 'Quebec' }, + { code: 'BC', name: 'British Columbia' }, + { code: 'AB', name: 'Alberta' } + ]; + } + + return res.status(200).send(new ServerResponse(true, countriesWithStates)); + } } diff --git a/worklenz-backend/src/routes/apis/admin-center-api-router.ts b/worklenz-backend/src/routes/apis/admin-center-api-router.ts index 318bcf63..c66a81c2 100644 --- a/worklenz-backend/src/routes/apis/admin-center-api-router.ts +++ b/worklenz-backend/src/routes/apis/admin-center-api-router.ts @@ -14,6 +14,11 @@ adminCenterApiRouter.put("/organization", teamOwnerOrAdminValidator, organizatio adminCenterApiRouter.put("/organization/calculation-method", teamOwnerOrAdminValidator, safeControllerFunction(AdminCenterController.updateOrganizationCalculationMethod)); adminCenterApiRouter.put("/organization/owner/contact-number", teamOwnerOrAdminValidator, safeControllerFunction(AdminCenterController.updateOwnerContactNumber)); +// holiday settings +adminCenterApiRouter.get("/organization/holiday-settings", teamOwnerOrAdminValidator, safeControllerFunction(AdminCenterController.getOrganizationHolidaySettings)); +adminCenterApiRouter.put("/organization/holiday-settings", teamOwnerOrAdminValidator, safeControllerFunction(AdminCenterController.updateOrganizationHolidaySettings)); +adminCenterApiRouter.get("/countries-with-states", teamOwnerOrAdminValidator, safeControllerFunction(AdminCenterController.getCountriesWithStates)); + // users adminCenterApiRouter.get("/organization/users", teamOwnerOrAdminValidator, safeControllerFunction(AdminCenterController.getOrganizationUsers)); diff --git a/worklenz-frontend/public/locales/alb/admin-center/overview.json b/worklenz-frontend/public/locales/alb/admin-center/overview.json index 9a562e12..b757b492 100644 --- a/worklenz-frontend/public/locales/alb/admin-center/overview.json +++ b/worklenz-frontend/public/locales/alb/admin-center/overview.json @@ -28,5 +28,45 @@ "manDaysCalculationDescription": "Të gjitha kostot e projektit do të llogariten duke përdorur ditët e vlerësuara të njeriut × normat ditore", "calculationMethodTooltip": "Ky cilësim zbatohet për të gjitha projektet në organizatën tuaj", "calculationMethodUpdated": "Metoda e llogaritjes së organizatës u përditësua me sukses", - "calculationMethodUpdateError": "Dështoi përditësimi i metodës së llogaritjes" + "calculationMethodUpdateError": "Dështoi përditësimi i metodës së llogaritjes", + "holidayCalendar": "Kalnedari i Festave", + "addHoliday": "Shto Festë", + "editHoliday": "Redakto Festë", + "holidayName": "Emri i Festës", + "holidayNameRequired": "Ju lutemi shkruani emrin e festës", + "description": "Përshkrim", + "date": "Data", + "dateRequired": "Ju lutemi zgjidhni një datë", + "holidayType": "Lloji i Festës", + "holidayTypeRequired": "Ju lutemi zgjidhni një lloj feste", + "recurring": "Përsëritëse", + "save": "Ruaj", + "update": "Përditëso", + "cancel": "Anulo", + "holidayCreated": "Festa u krijua me sukses", + "holidayUpdated": "Festa u përditësua me sukses", + "holidayDeleted": "Festa u fshi me sukses", + "errorCreatingHoliday": "Gabim gjatë krijimit të festës", + "errorUpdatingHoliday": "Gabim gjatë përditësimit të festës", + "errorDeletingHoliday": "Gabim gjatë fshirjes së festës", + "importCountryHolidays": "Importo Festat e Vendit", + "country": "Vendi", + "countryRequired": "Ju lutemi zgjidhni një vend", + "selectCountry": "Zgjidhni një vend", + "year": "Viti", + "import": "Importo", + "holidaysImported": "U importuan me sukses {{count}} festa", + "errorImportingHolidays": "Gabim gjatë importimit të festave", + "addCustomHoliday": "Shto Festë të Përshtatur", + "officialHolidaysFrom": "Festat zyrtare nga", + "workingDay": "Ditë Pune", + "holiday": "Festë", + "today": "Sot", + "cannotEditOfficialHoliday": "Nuk mund të redaktoni festat zyrtare", + "customHoliday": "Festë e Përshtatur", + "officialHoliday": "Festë Zyrtare", + "delete": "Fshi", + "deleteHolidayConfirm": "A jeni i sigurt që dëshironi të fshini këtë festë?", + "yes": "Po", + "no": "Jo" } diff --git a/worklenz-frontend/public/locales/alb/admin-center/settings.json b/worklenz-frontend/public/locales/alb/admin-center/settings.json index 434735c2..cc9bbfe5 100644 --- a/worklenz-frontend/public/locales/alb/admin-center/settings.json +++ b/worklenz-frontend/public/locales/alb/admin-center/settings.json @@ -13,5 +13,21 @@ "sunday": "E Dielë", "saveButton": "Ruaj", "saved": "Cilësimet u ruajtën me sukses", - "errorSaving": "Gabim gjatë ruajtjes së cilësimeve" + "errorSaving": "Gabim gjatë ruajtjes së cilësimeve", + "holidaySettings": "Cilësimet e pushimeve", + "country": "Vendi", + "countryRequired": "Ju lutemi zgjidhni një vend", + "selectCountry": "Zgjidhni vendin", + "state": "Shteti/Provinca", + "selectState": "Zgjidhni shtetin/provincën (opsionale)", + "autoSyncHolidays": "Sinkronizo automatikisht pushimet zyrtare", + "saveHolidaySettings": "Ruaj cilësimet e pushimeve", + "holidaySettingsSaved": "Cilësimet e pushimeve u ruajtën me sukses", + "errorSavingHolidaySettings": "Gabim gjatë ruajtjes së cilësimeve të pushimeve", + "addCustomHoliday": "Shto Festë të Përshtatur", + "officialHolidaysFrom": "Festat zyrtare nga", + "workingDay": "Ditë Pune", + "holiday": "Festë", + "today": "Sot", + "cannotEditOfficialHoliday": "Nuk mund të redaktoni festat zyrtare" } diff --git a/worklenz-frontend/public/locales/de/admin-center/overview.json b/worklenz-frontend/public/locales/de/admin-center/overview.json index 23bc7faa..f8aaca4b 100644 --- a/worklenz-frontend/public/locales/de/admin-center/overview.json +++ b/worklenz-frontend/public/locales/de/admin-center/overview.json @@ -28,5 +28,45 @@ "manDaysCalculationDescription": "Alle Projektkosten werden anhand geschätzter Mann-Tage × Tagessätze berechnet", "calculationMethodTooltip": "Diese Einstellung gilt für alle Projekte in Ihrer Organisation", "calculationMethodUpdated": "Organisations-Berechnungsmethode erfolgreich aktualisiert", - "calculationMethodUpdateError": "Fehler beim Aktualisieren der Berechnungsmethode" + "calculationMethodUpdateError": "Fehler beim Aktualisieren der Berechnungsmethode", + "holidayCalendar": "Feiertagskalender", + "addHoliday": "Feiertag hinzufügen", + "editHoliday": "Feiertag bearbeiten", + "holidayName": "Feiertagsname", + "holidayNameRequired": "Bitte geben Sie den Feiertagsnamen ein", + "description": "Beschreibung", + "date": "Datum", + "dateRequired": "Bitte wählen Sie ein Datum aus", + "holidayType": "Feiertagstyp", + "holidayTypeRequired": "Bitte wählen Sie einen Feiertagstyp aus", + "recurring": "Wiederkehrend", + "save": "Speichern", + "update": "Aktualisieren", + "cancel": "Abbrechen", + "holidayCreated": "Feiertag erfolgreich erstellt", + "holidayUpdated": "Feiertag erfolgreich aktualisiert", + "holidayDeleted": "Feiertag erfolgreich gelöscht", + "errorCreatingHoliday": "Fehler beim Erstellen des Feiertags", + "errorUpdatingHoliday": "Fehler beim Aktualisieren des Feiertags", + "errorDeletingHoliday": "Fehler beim Löschen des Feiertags", + "importCountryHolidays": "Landesfeiertage importieren", + "country": "Land", + "countryRequired": "Bitte wählen Sie ein Land aus", + "selectCountry": "Ein Land auswählen", + "year": "Jahr", + "import": "Importieren", + "holidaysImported": "{{count}} Feiertage erfolgreich importiert", + "errorImportingHolidays": "Fehler beim Importieren der Feiertage", + "addCustomHoliday": "Benutzerdefinierten Feiertag hinzufügen", + "officialHolidaysFrom": "Offizielle Feiertage aus", + "workingDay": "Arbeitstag", + "holiday": "Feiertag", + "today": "Heute", + "cannotEditOfficialHoliday": "Offizielle Feiertage können nicht bearbeitet werden", + "customHoliday": "Benutzerdefinierter Feiertag", + "officialHoliday": "Offizieller Feiertag", + "delete": "Löschen", + "deleteHolidayConfirm": "Sind Sie sicher, dass Sie diesen Feiertag löschen möchten?", + "yes": "Ja", + "no": "Nein" } diff --git a/worklenz-frontend/public/locales/de/admin-center/settings.json b/worklenz-frontend/public/locales/de/admin-center/settings.json index 494245fd..0f6828e3 100644 --- a/worklenz-frontend/public/locales/de/admin-center/settings.json +++ b/worklenz-frontend/public/locales/de/admin-center/settings.json @@ -13,5 +13,21 @@ "sunday": "Sonntag", "saveButton": "Speichern", "saved": "Einstellungen erfolgreich gespeichert", - "errorSaving": "Fehler beim Speichern der Einstellungen" + "errorSaving": "Fehler beim Speichern der Einstellungen", + "holidaySettings": "Feiertagseinstellungen", + "country": "Land", + "countryRequired": "Bitte wählen Sie ein Land aus", + "selectCountry": "Land auswählen", + "state": "Bundesland/Provinz", + "selectState": "Bundesland/Provinz auswählen (optional)", + "autoSyncHolidays": "Offizielle Feiertage automatisch synchronisieren", + "saveHolidaySettings": "Feiertagseinstellungen speichern", + "holidaySettingsSaved": "Feiertagseinstellungen erfolgreich gespeichert", + "errorSavingHolidaySettings": "Fehler beim Speichern der Feiertagseinstellungen", + "addCustomHoliday": "Benutzerdefinierten Feiertag hinzufügen", + "officialHolidaysFrom": "Offizielle Feiertage aus", + "workingDay": "Arbeitstag", + "holiday": "Feiertag", + "today": "Heute", + "cannotEditOfficialHoliday": "Offizielle Feiertage können nicht bearbeitet werden" } diff --git a/worklenz-frontend/public/locales/en/admin-center/overview.json b/worklenz-frontend/public/locales/en/admin-center/overview.json index 594205ed..76aa7e4f 100644 --- a/worklenz-frontend/public/locales/en/admin-center/overview.json +++ b/worklenz-frontend/public/locales/en/admin-center/overview.json @@ -56,5 +56,17 @@ "year": "Year", "import": "Import", "holidaysImported": "Successfully imported {{count}} holidays", - "errorImportingHolidays": "Error importing holidays" + "errorImportingHolidays": "Error importing holidays", + "addCustomHoliday": "Add Custom Holiday", + "officialHolidaysFrom": "Official holidays from", + "workingDay": "Working Day", + "holiday": "Holiday", + "today": "Today", + "cannotEditOfficialHoliday": "Cannot edit official holidays", + "customHoliday": "Custom Holiday", + "officialHoliday": "Official Holiday", + "delete": "Delete", + "deleteHolidayConfirm": "Are you sure you want to delete this holiday?", + "yes": "Yes", + "no": "No" } diff --git a/worklenz-frontend/public/locales/en/admin-center/settings.json b/worklenz-frontend/public/locales/en/admin-center/settings.json index 13054638..25212239 100644 --- a/worklenz-frontend/public/locales/en/admin-center/settings.json +++ b/worklenz-frontend/public/locales/en/admin-center/settings.json @@ -13,5 +13,15 @@ "sunday": "Sunday", "saveButton": "Save", "saved": "Settings saved successfully", - "errorSaving": "Error saving settings" + "errorSaving": "Error saving settings", + "holidaySettings": "Holiday Settings", + "country": "Country", + "countryRequired": "Please select a country", + "selectCountry": "Select country", + "state": "State/Province", + "selectState": "Select state/province (optional)", + "autoSyncHolidays": "Automatically sync official holidays", + "saveHolidaySettings": "Save Holiday Settings", + "holidaySettingsSaved": "Holiday settings saved successfully", + "errorSavingHolidaySettings": "Error saving holiday settings" } diff --git a/worklenz-frontend/public/locales/es/admin-center/overview.json b/worklenz-frontend/public/locales/es/admin-center/overview.json index c1fc6309..fcb46c2f 100644 --- a/worklenz-frontend/public/locales/es/admin-center/overview.json +++ b/worklenz-frontend/public/locales/es/admin-center/overview.json @@ -28,5 +28,45 @@ "manDaysCalculationDescription": "Todos los costos del proyecto se calcularán usando días hombre estimados × tarifas diarias", "calculationMethodTooltip": "Esta configuración se aplica a todos los proyectos en su organización", "calculationMethodUpdated": "Método de cálculo de la organización actualizado exitosamente", - "calculationMethodUpdateError": "Error al actualizar el método de cálculo" + "calculationMethodUpdateError": "Error al actualizar el método de cálculo", + "holidayCalendar": "Calendario de Días Festivos", + "addHoliday": "Agregar Día Festivo", + "editHoliday": "Editar Día Festivo", + "holidayName": "Nombre del Día Festivo", + "holidayNameRequired": "Por favor ingrese el nombre del día festivo", + "description": "Descripción", + "date": "Fecha", + "dateRequired": "Por favor seleccione una fecha", + "holidayType": "Tipo de Día Festivo", + "holidayTypeRequired": "Por favor seleccione un tipo de día festivo", + "recurring": "Recurrente", + "save": "Guardar", + "update": "Actualizar", + "cancel": "Cancelar", + "holidayCreated": "Día festivo creado exitosamente", + "holidayUpdated": "Día festivo actualizado exitosamente", + "holidayDeleted": "Día festivo eliminado exitosamente", + "errorCreatingHoliday": "Error al crear el día festivo", + "errorUpdatingHoliday": "Error al actualizar el día festivo", + "errorDeletingHoliday": "Error al eliminar el día festivo", + "importCountryHolidays": "Importar Días Festivos del País", + "country": "País", + "countryRequired": "Por favor seleccione un país", + "selectCountry": "Seleccionar un país", + "year": "Año", + "import": "Importar", + "holidaysImported": "{{count}} días festivos importados exitosamente", + "errorImportingHolidays": "Error al importar días festivos", + "addCustomHoliday": "Agregar Día Festivo Personalizado", + "officialHolidaysFrom": "Días festivos oficiales de", + "workingDay": "Día Laboral", + "holiday": "Día Festivo", + "today": "Hoy", + "cannotEditOfficialHoliday": "No se pueden editar los días festivos oficiales", + "customHoliday": "Día Festivo Personalizado", + "officialHoliday": "Día Festivo Oficial", + "delete": "Eliminar", + "deleteHolidayConfirm": "¿Está seguro de que desea eliminar este día festivo?", + "yes": "Sí", + "no": "No" } diff --git a/worklenz-frontend/public/locales/es/admin-center/settings.json b/worklenz-frontend/public/locales/es/admin-center/settings.json index 6726feee..aea943ec 100644 --- a/worklenz-frontend/public/locales/es/admin-center/settings.json +++ b/worklenz-frontend/public/locales/es/admin-center/settings.json @@ -13,5 +13,21 @@ "sunday": "Domingo", "saveButton": "Guardar", "saved": "Configuración guardada exitosamente", - "errorSaving": "Error al guardar la configuración" + "errorSaving": "Error al guardar la configuración", + "holidaySettings": "Configuración de días festivos", + "country": "País", + "countryRequired": "Por favor seleccione un país", + "selectCountry": "Seleccionar país", + "state": "Estado/Provincia", + "selectState": "Seleccionar estado/provincia (opcional)", + "autoSyncHolidays": "Sincronizar automáticamente los días festivos oficiales", + "saveHolidaySettings": "Guardar configuración de días festivos", + "holidaySettingsSaved": "Configuración de días festivos guardada exitosamente", + "errorSavingHolidaySettings": "Error al guardar la configuración de días festivos", + "addCustomHoliday": "Agregar Día Festivo Personalizado", + "officialHolidaysFrom": "Días festivos oficiales de", + "workingDay": "Día Laboral", + "holiday": "Día Festivo", + "today": "Hoy", + "cannotEditOfficialHoliday": "No se pueden editar los días festivos oficiales" } diff --git a/worklenz-frontend/public/locales/pt/admin-center/overview.json b/worklenz-frontend/public/locales/pt/admin-center/overview.json index 767a8afe..cb7896b4 100644 --- a/worklenz-frontend/public/locales/pt/admin-center/overview.json +++ b/worklenz-frontend/public/locales/pt/admin-center/overview.json @@ -28,5 +28,45 @@ "manDaysCalculationDescription": "Todos os custos do projeto serão calculados usando dias homem estimados × taxas diárias", "calculationMethodTooltip": "Esta configuração se aplica a todos os projetos em sua organização", "calculationMethodUpdated": "Método de cálculo da organização atualizado com sucesso", - "calculationMethodUpdateError": "Erro ao atualizar o método de cálculo" + "calculationMethodUpdateError": "Erro ao atualizar o método de cálculo", + "holidayCalendar": "Calendário de Feriados", + "addHoliday": "Adicionar Feriado", + "editHoliday": "Editar Feriado", + "holidayName": "Nome do Feriado", + "holidayNameRequired": "Por favor, digite o nome do feriado", + "description": "Descrição", + "date": "Data", + "dateRequired": "Por favor, selecione uma data", + "holidayType": "Tipo de Feriado", + "holidayTypeRequired": "Por favor, selecione um tipo de feriado", + "recurring": "Recorrente", + "save": "Salvar", + "update": "Atualizar", + "cancel": "Cancelar", + "holidayCreated": "Feriado criado com sucesso", + "holidayUpdated": "Feriado atualizado com sucesso", + "holidayDeleted": "Feriado excluído com sucesso", + "errorCreatingHoliday": "Erro ao criar feriado", + "errorUpdatingHoliday": "Erro ao atualizar feriado", + "errorDeletingHoliday": "Erro ao excluir feriado", + "importCountryHolidays": "Importar Feriados do País", + "country": "País", + "countryRequired": "Por favor, selecione um país", + "selectCountry": "Selecionar um país", + "year": "Ano", + "import": "Importar", + "holidaysImported": "{{count}} feriados importados com sucesso", + "errorImportingHolidays": "Erro ao importar feriados", + "addCustomHoliday": "Adicionar Feriado Personalizado", + "officialHolidaysFrom": "Feriados oficiais de", + "workingDay": "Dia de Trabalho", + "holiday": "Feriado", + "today": "Hoje", + "cannotEditOfficialHoliday": "Não é possível editar feriados oficiais", + "customHoliday": "Feriado Personalizado", + "officialHoliday": "Feriado Oficial", + "delete": "Excluir", + "deleteHolidayConfirm": "Tem certeza de que deseja excluir este feriado?", + "yes": "Sim", + "no": "Não" } diff --git a/worklenz-frontend/public/locales/pt/admin-center/settings.json b/worklenz-frontend/public/locales/pt/admin-center/settings.json index 5b186292..15ea21b7 100644 --- a/worklenz-frontend/public/locales/pt/admin-center/settings.json +++ b/worklenz-frontend/public/locales/pt/admin-center/settings.json @@ -13,5 +13,21 @@ "sunday": "Domingo", "saveButton": "Salvar", "saved": "Configurações salvas com sucesso", - "errorSaving": "Erro ao salvar configurações" + "errorSaving": "Erro ao salvar configurações", + "holidaySettings": "Configurações de feriados", + "country": "País", + "countryRequired": "Por favor, selecione um país", + "selectCountry": "Selecionar país", + "state": "Estado/Província", + "selectState": "Selecionar estado/província (opcional)", + "autoSyncHolidays": "Sincronizar automaticamente feriados oficiais", + "saveHolidaySettings": "Salvar configurações de feriados", + "holidaySettingsSaved": "Configurações de feriados salvas com sucesso", + "errorSavingHolidaySettings": "Erro ao salvar configurações de feriados", + "addCustomHoliday": "Adicionar Feriado Personalizado", + "officialHolidaysFrom": "Feriados oficiais de", + "workingDay": "Dia de Trabalho", + "holiday": "Feriado", + "today": "Hoje", + "cannotEditOfficialHoliday": "Não é possível editar feriados oficiais" } diff --git a/worklenz-frontend/public/locales/zh/admin-center/overview.json b/worklenz-frontend/public/locales/zh/admin-center/overview.json index fae4838c..4ac04b95 100644 --- a/worklenz-frontend/public/locales/zh/admin-center/overview.json +++ b/worklenz-frontend/public/locales/zh/admin-center/overview.json @@ -28,5 +28,45 @@ "manDaysCalculationDescription": "所有项目成本将使用估算人天数 × 日费率计算", "calculationMethodTooltip": "此设置适用于您组织中的所有项目", "calculationMethodUpdated": "组织计算方法更新成功", - "calculationMethodUpdateError": "更新计算方法失败" + "calculationMethodUpdateError": "更新计算方法失败", + "holidayCalendar": "假期日历", + "addHoliday": "添加假期", + "editHoliday": "编辑假期", + "holidayName": "假期名称", + "holidayNameRequired": "请输入假期名称", + "description": "描述", + "date": "日期", + "dateRequired": "请选择日期", + "holidayType": "假期类型", + "holidayTypeRequired": "请选择假期类型", + "recurring": "循环", + "save": "保存", + "update": "更新", + "cancel": "取消", + "holidayCreated": "假期创建成功", + "holidayUpdated": "假期更新成功", + "holidayDeleted": "假期删除成功", + "errorCreatingHoliday": "创建假期时出错", + "errorUpdatingHoliday": "更新假期时出错", + "errorDeletingHoliday": "删除假期时出错", + "importCountryHolidays": "导入国家假期", + "country": "国家", + "countryRequired": "请选择国家", + "selectCountry": "选择国家", + "year": "年份", + "import": "导入", + "holidaysImported": "成功导入{{count}}个假期", + "errorImportingHolidays": "导入假期时出错", + "addCustomHoliday": "添加自定义假期", + "officialHolidaysFrom": "官方假期来自", + "workingDay": "工作日", + "holiday": "假期", + "today": "今天", + "cannotEditOfficialHoliday": "无法编辑官方假期", + "customHoliday": "自定义假期", + "officialHoliday": "官方假期", + "delete": "删除", + "deleteHolidayConfirm": "您确定要删除这个假期吗?", + "yes": "是", + "no": "否" } diff --git a/worklenz-frontend/public/locales/zh/admin-center/settings.json b/worklenz-frontend/public/locales/zh/admin-center/settings.json index 14da2685..7f6dc566 100644 --- a/worklenz-frontend/public/locales/zh/admin-center/settings.json +++ b/worklenz-frontend/public/locales/zh/admin-center/settings.json @@ -13,5 +13,21 @@ "sunday": "星期日", "saveButton": "保存", "saved": "设置保存成功", - "errorSaving": "保存设置时出错" + "errorSaving": "保存设置时出错", + "holidaySettings": "假期设置", + "country": "国家", + "countryRequired": "请选择一个国家", + "selectCountry": "选择国家", + "state": "州/省", + "selectState": "选择州/省(可选)", + "autoSyncHolidays": "自动同步官方假期", + "saveHolidaySettings": "保存假期设置", + "holidaySettingsSaved": "假期设置保存成功", + "errorSavingHolidaySettings": "保存假期设置时出错", + "addCustomHoliday": "添加自定义假期", + "officialHolidaysFrom": "官方假期来自", + "workingDay": "工作日", + "holiday": "假期", + "today": "今天", + "cannotEditOfficialHoliday": "无法编辑官方假期" } diff --git a/worklenz-frontend/src/api/holiday/holiday.api.service.ts b/worklenz-frontend/src/api/holiday/holiday.api.service.ts index 2cccf82d..32e1fbca 100644 --- a/worklenz-frontend/src/api/holiday/holiday.api.service.ts +++ b/worklenz-frontend/src/api/holiday/holiday.api.service.ts @@ -19,63 +19,51 @@ import { const rootUrl = `${API_BASE_URL}/holidays`; export const holidayApiService = { - // Holiday types - PLACEHOLDER with Sri Lankan specific types + // Holiday types getHolidayTypes: async (): Promise> => { - // Return holiday types including Sri Lankan specific types - const holidayTypes = [ - { id: '1', name: 'Public Holiday', color_code: '#DC143C' }, - { id: '2', name: 'Religious Holiday', color_code: '#4ecdc4' }, - { id: '3', name: 'National Holiday', color_code: '#45b7d1' }, - { id: '4', name: 'Company Holiday', color_code: '#f9ca24' }, - { id: '5', name: 'Personal Holiday', color_code: '#6c5ce7' }, - { id: '6', name: 'Bank Holiday', color_code: '#4682B4' }, - { id: '7', name: 'Mercantile Holiday', color_code: '#32CD32' }, - { id: '8', name: 'Poya Day', color_code: '#8B4513' }, - ]; - - return { - done: true, - body: holidayTypes, - } as IServerResponse; + const response = await apiClient.get>( + `${rootUrl}/types` + ); + return response.data; }, - // Organization holidays - PLACEHOLDER until backend implements + // Organization holidays getOrganizationHolidays: async ( year?: number ): Promise> => { - // Return empty array for now to prevent 404 errors - return { - done: true, - body: [], - } as IServerResponse; + const params = year ? { year } : {}; + const response = await apiClient.get>( + `${rootUrl}/organization`, + { params } + ); + return response.data; }, - // Holiday CRUD operations - PLACEHOLDER until backend implements + // Holiday CRUD operations createOrganizationHoliday: async (data: ICreateHolidayRequest): Promise> => { - // Return success for now to prevent UI errors - return { - done: true, - body: { id: Date.now().toString(), ...data }, - } as IServerResponse; + const response = await apiClient.post>( + `${rootUrl}/organization`, + data + ); + return response.data; }, updateOrganizationHoliday: async ( id: string, data: IUpdateHolidayRequest ): Promise> => { - // Return success for now to prevent UI errors - return { - done: true, - body: { id, ...data }, - } as IServerResponse; + const response = await apiClient.put>( + `${rootUrl}/organization/${id}`, + data + ); + return response.data; }, deleteOrganizationHoliday: async (id: string): Promise> => { - // Return success for now to prevent UI errors - return { - done: true, - body: {}, - } as IServerResponse; + const response = await apiClient.delete>( + `${rootUrl}/organization/${id}` + ); + return response.data; }, // Country holidays - PLACEHOLDER with all date-holidays supported countries @@ -200,11 +188,15 @@ export const holidayApiService = { countryCode: string, year?: number ): Promise> => { - // Return empty array for now - return { - done: true, - body: [], - } as IServerResponse; + const params: any = { country_code: countryCode }; + if (year) { + params.year = year; + } + const response = await apiClient.get>( + `${rootUrl}/countries/${countryCode}`, + { params } + ); + return response.data; }, importCountryHolidays: async ( @@ -229,35 +221,35 @@ export const holidayApiService = { } as IServerResponse; }, - // Organization holiday settings - PLACEHOLDER until backend implements + // Organization holiday settings getOrganizationHolidaySettings: async (): Promise< IServerResponse > => { - // Return default settings for now - return { - done: true, - body: { - country_code: undefined, - state_code: undefined, - auto_sync_holidays: false, - }, - } as IServerResponse; + const response = await apiClient.get>( + `${API_BASE_URL}/admin-center/organization/holiday-settings` + ); + return response.data; }, updateOrganizationHolidaySettings: async ( data: IOrganizationHolidaySettings ): Promise> => { - // Just return success for now - return { - done: true, - body: {}, - } as IServerResponse; + const response = await apiClient.put>( + `${API_BASE_URL}/admin-center/organization/holiday-settings`, + data + ); + return response.data; }, - // Countries with states - PLACEHOLDER with date-holidays supported countries + // Countries with states getCountriesWithStates: async (): Promise> => { - // Return comprehensive list of countries supported by date-holidays library - const supportedCountries = [ + const response = await apiClient.get>( + `${API_BASE_URL}/admin-center/countries-with-states` + ); + return response.data; + + // Fallback to static data if API fails + /*const supportedCountries = [ { code: 'AD', name: 'Andorra' }, { code: 'AE', name: 'United Arab Emirates' }, { code: 'AG', name: 'Antigua & Barbuda' }, @@ -696,41 +688,85 @@ export const holidayApiService = { return { done: true, body: supportedCountries, - } as IServerResponse; + } as IServerResponse;*/ }, - // Combined holidays (official + custom) - Database-driven approach for Sri Lanka + // Combined holidays (official + custom) - Database-driven approach getCombinedHolidays: async ( params: ICombinedHolidaysRequest & { country_code?: string } ): Promise> => { try { + console.log('🔍 getCombinedHolidays called with params:', params); 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'); + // Get official holidays - handle Sri Lanka specially, others from API + if (params.country_code) { + console.log(`🌐 Fetching official holidays for country: ${params.country_code}, year: ${year}`); - 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); + // Handle Sri Lankan holidays from static data + if (params.country_code === 'LK' && year === 2025) { + try { + console.log('🇱🇰 Loading Sri Lankan holidays from static 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, + })); + + console.log(`✅ Found ${sriLankanHolidays.length} Sri Lankan holidays`); + allHolidays.push(...sriLankanHolidays); + } catch (error) { + console.error('❌ Error loading Sri Lankan holidays:', error); + } + } else { + // Handle other countries from API + try { + const countryHolidaysRes = await holidayApiService.getCountryHolidays(params.country_code, year); + console.log('📅 Country holidays response:', countryHolidaysRes); + + if (countryHolidaysRes.done && countryHolidaysRes.body) { + const officialHolidays = countryHolidaysRes.body + .filter((h: any) => h.date >= params.from_date && h.date <= params.to_date) + .map((h: any) => ({ + id: `${params.country_code}-${h.id}`, + name: h.name, + description: h.description, + date: h.date, + is_recurring: h.is_recurring, + holiday_type_name: 'Official Holiday', + color_code: h.color_code || '#1890ff', + source: 'official' as const, + is_editable: false, + })); + + console.log(`✅ Found ${officialHolidays.length} official holidays from API`); + allHolidays.push(...officialHolidays); + } else { + console.log('⚠️ No official holidays returned from API'); + } + } catch (error) { + console.error('❌ Error fetching official holidays from API:', error); + } + } + } else { + console.log('⚠️ No country code provided, skipping official holidays'); } // 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 @@ -747,13 +783,17 @@ export const holidayApiService = { is_editable: h.is_editable !== false, // Default to true unless explicitly false })); - // Filter out duplicates (in case Sri Lankan holidays are already in DB) + // Filter out duplicates (in case official holidays are already in DB) 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, @@ -798,12 +838,9 @@ export const holidayApiService = { } as IServerResponse<{ working_days: number; total_days: number; holidays_count: number }>; }, - // Populate holidays - PLACEHOLDER until backend implements (deprecated - keeping for backward compatibility) + // Populate holidays - Populate the database with official holidays for various countries populateCountryHolidays: async (): Promise> => { - // Return success for now - return { - done: true, - body: { message: 'Holidays populated successfully' }, - } as IServerResponse; + const response = await apiClient.post>(`${rootUrl}/populate`); + return response.data; }, }; 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 8e583473..350a2fd7 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 @@ -4,20 +4,37 @@ .holiday-calendar .ant-picker-calendar { background: transparent; + border-radius: 12px; + overflow: hidden; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); +} + +.holiday-calendar.dark .ant-picker-calendar { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); } .holiday-calendar .ant-picker-calendar-header { - padding: 12px 0; + padding: 20px 24px; + background: linear-gradient(135deg, #fafbfc 0%, #f1f3f4 100%); + border-bottom: 1px solid #e8eaed; + border-radius: 12px 12px 0 0; +} + +.holiday-calendar.dark .ant-picker-calendar-header { + background: linear-gradient(135deg, #1f1f1f 0%, #262626 100%); + border-bottom-color: #303030; } .holiday-calendar .ant-picker-calendar-date { position: relative; - height: 80px; - padding: 4px 8px; + height: 85px; + padding: 6px 10px; border: 1px solid #f0f0f0; - border-radius: 6px; - transition: all 0.3s; + border-radius: 10px; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); cursor: pointer; + background: #ffffff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02); } .holiday-calendar.dark .ant-picker-calendar-date { @@ -25,18 +42,161 @@ background: #1f1f1f; } +/* Calendar cell wrapper */ +.calendar-cell { + position: relative; + height: 100%; + width: 100%; +} + +/* Working day styles */ +.calendar-cell.working-day { + background: #ffffff; + border: 1px solid #d9d9d9; + position: relative; +} + +.calendar-cell.working-day::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(82, 196, 26, 0.04); + border-radius: inherit; + pointer-events: none; + transition: opacity 0.3s ease; +} + +.holiday-calendar.dark .calendar-cell.working-day { + background: #1f1f1f; + border: 1px solid #434343; +} + +.holiday-calendar.dark .calendar-cell.working-day::before { + background: rgba(82, 196, 26, 0.06); +} + +/* Non-working day styles */ +.calendar-cell.non-working-day { + background: #fafafa; + border: 1px solid #f0f0f0; + opacity: 0.65; +} + +.holiday-calendar.dark .calendar-cell.non-working-day { + background: #141414; + border: 1px solid #303030; + opacity: 0.65; +} + +/* Today styles */ +.calendar-cell.today { + border: 2px solid #1890ff; + box-shadow: 0 0 0 3px rgba(24, 144, 255, 0.15), 0 4px 12px rgba(24, 144, 255, 0.1); + animation: todayPulse 3s ease-in-out infinite; +} + +.holiday-calendar.dark .calendar-cell.today { + border: 2px solid #177ddc; + box-shadow: 0 0 0 2px rgba(23, 125, 220, 0.3); +} + +/* Other month styles */ +.calendar-cell.other-month { + opacity: 0.4; +} + +.holiday-calendar.dark .calendar-cell.other-month { + opacity: 0.3; +} + .holiday-calendar .ant-picker-calendar-date:hover { - background: #f5f5f5; + transform: translateY(-1px) scale(1.02); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08), 0 2px 6px rgba(0, 0, 0, 0.04); + border-color: #d9d9d9; +} + +@media (hover: none) and (pointer: coarse) { + .holiday-calendar .ant-picker-calendar-date:hover { + transform: none; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02); + border-color: inherit; + } + + .holiday-cell .ant-tag:hover { + transform: none; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + } } .holiday-calendar.dark .ant-picker-calendar-date:hover { - background: #2a2a2a; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); +} + +/* Working day indicator */ +.working-day-indicator { + position: absolute; + top: 2px; + right: 2px; + z-index: 2; +} + +.working-day-badge { + width: 16px; + height: 16px; + border-radius: 3px; + background: #52c41a; + color: #ffffff; + font-size: 9px; + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 1px 3px rgba(82, 196, 26, 0.3); + transition: all 0.3s ease; + border: 1px solid #d9f7be; +} + +.holiday-calendar.dark .working-day-badge { + background: #52c41a; + box-shadow: 0 1px 3px rgba(82, 196, 26, 0.4); + border-color: #237804; +} + +@keyframes pulse { + 0% { + transform: scale(1); + opacity: 1; + } + 50% { + transform: scale(1.3); + opacity: 0.7; + } + 100% { + transform: scale(1); + opacity: 1; + } +} + +@keyframes todayPulse { + 0%, 100% { + box-shadow: 0 0 0 3px rgba(24, 144, 255, 0.15), 0 4px 12px rgba(24, 144, 255, 0.1); + } + 50% { + box-shadow: 0 0 0 4px rgba(24, 144, 255, 0.25), 0 6px 16px rgba(24, 144, 255, 0.15); + } } .holiday-calendar .ant-picker-calendar-date-value { - font-size: 12px; - font-weight: 500; - color: #262626; + font-size: 14px; + font-weight: 600; + color: #1f1f1f; + margin-bottom: 6px; + line-height: 1.2; + text-align: center; } .holiday-calendar.dark .ant-picker-calendar-date-value { @@ -44,16 +204,22 @@ } .holiday-calendar .ant-picker-calendar-date-content { - height: 100%; + height: calc(100% - 20px); overflow: hidden; + margin-top: 4px; } .holiday-cell { position: absolute; - bottom: 2px; - left: 2px; - right: 2px; + bottom: 6px; + left: 6px; + right: 6px; z-index: 1; + max-height: 65%; + overflow: hidden; + display: flex; + flex-direction: column; + gap: 2px; } .holiday-cell .ant-tag { @@ -61,14 +227,27 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - border: none; + border: 1px solid rgba(255, 255, 255, 0.2); font-weight: 500; cursor: pointer; + font-size: 10px; + line-height: 1.3; + padding: 2px 6px; + border-radius: 6px; + backdrop-filter: blur(4px); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); } .holiday-cell .ant-tag:hover { - transform: scale(1.05); - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + transform: scale(1.05) translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + border-color: rgba(255, 255, 255, 0.4); + z-index: 10; + overflow: visible; + white-space: normal; + text-overflow: unset; + max-width: 200px; + position: relative; } .holiday-calendar .ant-picker-calendar-date-today { @@ -253,20 +432,243 @@ /* Tag styles */ .holiday-calendar .ant-tag { - border-radius: 4px; + border-radius: 6px; font-size: 10px; - line-height: 1.2; - padding: 1px 4px; + line-height: 1.3; + padding: 2px 6px; margin: 0; - border: none; + border: 1px solid rgba(255, 255, 255, 0.2); font-weight: 500; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +/* Holiday tag specific styles */ +.holiday-tag.official-holiday { + background: #e6f7ff !important; + border-color: #91d5ff !important; + color: #1890ff !important; +} + +.holiday-tag.custom-holiday { + background: #f6ffed !important; + border-color: #b7eb8f !important; + color: #52c41a !important; +} + +.holiday-calendar.dark .holiday-tag.official-holiday { + background: #111b26 !important; + border-color: #13334c !important; + color: #69c0ff !important; +} + +.holiday-calendar.dark .holiday-tag.custom-holiday { + background: #162312 !important; + border-color: #274916 !important; + color: #95de64 !important; +} + +/* Holiday tag icons */ +.custom-holiday-icon { + font-size: 8px; + filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.2)); +} + +.official-holiday-icon { + font-size: 8px; + filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.2)); +} + +/* Calendar container and legend */ +.calendar-container { + position: relative; +} + +.calendar-legend { + display: flex; + justify-content: center; + align-items: center; + gap: 24px; + margin-top: 16px; + padding: 12px 16px; + background: #fafafa; + border-radius: 6px; + border: 1px solid #d9d9d9; +} + +.holiday-calendar.dark .calendar-legend { + background: #1f1f1f; + border-color: #434343; +} + +.legend-item { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: rgba(0, 0, 0, 0.65); +} + +.holiday-calendar.dark .legend-item { + color: rgba(255, 255, 255, 0.65); +} + +.legend-dot { + width: 8px; + height: 8px; + border-radius: 50%; +} + +.legend-dot.working-day-dot { + background: #52c41a; + box-shadow: 0 0 0 1px rgba(82, 196, 26, 0.2); +} + +.legend-dot.holiday-dot { + background: #ff4d4f; + box-shadow: 0 0 0 1px rgba(255, 77, 79, 0.2); +} + +.legend-dot.today-dot { + background: #1890ff; + box-shadow: 0 0 0 1px rgba(24, 144, 255, 0.2); +} + +.holiday-calendar.dark .legend-dot.working-day-dot { + background: #52c41a; + box-shadow: 0 0 0 1px rgba(82, 196, 26, 0.3); +} + +.holiday-calendar.dark .legend-dot.holiday-dot { + background: #ff4d4f; + box-shadow: 0 0 0 1px rgba(255, 77, 79, 0.3); +} + +.holiday-calendar.dark .legend-dot.today-dot { + background: #1890ff; + box-shadow: 0 0 0 1px rgba(24, 144, 255, 0.3); +} + +/* Legend badge and tag styles */ +.legend-badge { + width: 16px; + height: 16px; + border-radius: 3px; + color: white; + font-size: 9px; + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); +} + +.legend-badge.working-day-badge { + background: #52c41a; + border: 1px solid #d9f7be; + color: #ffffff; +} + +.holiday-calendar.dark .legend-badge.working-day-badge { + background: #52c41a; + border: 1px solid #237804; + color: #ffffff; +} + +.legend-tag { + display: flex; + align-items: center; + gap: 2px; + padding: 2px 6px; + border-radius: 4px; + font-size: 9px; + font-weight: 500; + border: 1px solid; +} + +.legend-tag-text { + font-size: 8px; + line-height: 1; +} + +.custom-holiday-legend { + background: #f6ffed; + border: 1px solid #b7eb8f; + color: #52c41a; +} + +.official-holiday-legend { + background: #e6f7ff; + border: 1px solid #91d5ff; + color: #1890ff; +} + +.holiday-calendar.dark .custom-holiday-legend { + background: #162312; + border: 1px solid #274916; + color: #95de64; +} + +.holiday-calendar.dark .official-holiday-legend { + background: #111b26; + border: 1px solid #13334c; + color: #69c0ff; +} + +.legend-tag .custom-holiday-icon, +.legend-tag .official-holiday-icon { + font-size: 7px; + filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.1)); } /* Responsive design */ @media (max-width: 768px) { .holiday-calendar .ant-picker-calendar-date { - height: 60px; - padding: 2px 4px; + height: 70px; + padding: 4px 6px; + border-radius: 8px; + } + + .holiday-calendar .ant-picker-calendar-date-value { + font-size: 12px; + margin-bottom: 4px; + } + + .holiday-cell .ant-tag { + font-size: 9px; + padding: 1px 4px; + border-radius: 4px; + } + + .calendar-legend { + flex-direction: column; + gap: 12px; + align-items: flex-start; + padding: 10px 12px; + } + + .working-day-badge { + width: 14px; + height: 14px; + font-size: 8px; + } + + .legend-item { + font-size: 11px; + } + + .holiday-calendar .ant-picker-calendar-header { + padding: 16px 18px; + } + + .holiday-calendar .ant-picker-calendar-date:hover { + transform: scale(1.01); + } +} + +@media (max-width: 480px) { + .holiday-calendar .ant-picker-calendar-date { + height: 65px; + padding: 3px 4px; } .holiday-calendar .ant-picker-calendar-date-value { @@ -274,7 +676,47 @@ } .holiday-cell .ant-tag { - font-size: 9px; - padding: 0 2px; + font-size: 8px; + padding: 1px 3px; + } + + .working-day-badge { + width: 12px; + height: 12px; + font-size: 7px; + } + + .custom-holiday-icon, + .official-holiday-icon { + font-size: 7px; + } + + .calendar-legend { + gap: 8px; + padding: 8px 10px; + } + + .legend-item { + font-size: 10px; + } + + .legend-badge { + width: 14px; + height: 14px; + font-size: 8px; + } + + .legend-tag { + padding: 1px 4px; + font-size: 8px; + } + + .legend-tag-text { + font-size: 7px; + } + + .legend-tag .custom-holiday-icon, + .legend-tag .official-holiday-icon { + font-size: 6px; } } 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/holiday-calendar.tsx index 7ab80bc3..57bdd959 100644 --- a/worklenz-frontend/src/components/admin-center/overview/holiday-calendar/holiday-calendar.tsx +++ b/worklenz-frontend/src/components/admin-center/overview/holiday-calendar/holiday-calendar.tsx @@ -14,8 +14,8 @@ import { Tag, Popconfirm, message, -} from 'antd'; -import { PlusOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons'; +} from '@/shared/antd-imports'; +import { PlusOutlined, DeleteOutlined, EditOutlined } from '@/shared/antd-imports'; import { useTranslation } from 'react-i18next'; import dayjs, { Dayjs } from 'dayjs'; import { holidayApiService } from '@/api/holiday/holiday.api.service'; @@ -28,7 +28,7 @@ import { 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 { fetchHolidays, clearHolidaysCache } from '@/features/admin-center/admin-center.slice'; import logger from '@/utils/errorLogger'; import './holiday-calendar.css'; @@ -38,9 +38,10 @@ const { TextArea } = Input; interface HolidayCalendarProps { themeMode: string; + workingDays?: string[]; } -const HolidayCalendar: React.FC = ({ themeMode }) => { +const HolidayCalendar: React.FC = ({ themeMode, workingDays = [] }) => { const { t } = useTranslation('admin-center/overview'); const dispatch = useAppDispatch(); const { holidays, loadingHolidays, holidaySettings } = useAppSelector( @@ -66,10 +67,32 @@ const HolidayCalendar: React.FC = ({ themeMode }) => { } }; - const fetchHolidaysForDateRange = () => { + const populateHolidaysIfNeeded = async () => { + // Check if we have holiday settings with a country code but no holidays + if (holidaySettings?.country_code && holidays.length === 0) { + try { + console.log('🔄 No holidays found, attempting to populate official holidays...'); + 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); + } + } + }; + + const fetchHolidaysForDateRange = (forceRefresh = false) => { const startOfYear = currentDate.startOf('year'); const endOfYear = currentDate.endOf('year'); + // If forceRefresh is true, clear the cached holidays first + if (forceRefresh) { + dispatch(clearHolidaysCache()); + } + dispatch( fetchHolidays({ from_date: startOfYear.format('YYYY-MM-DD'), @@ -84,6 +107,11 @@ const HolidayCalendar: React.FC = ({ themeMode }) => { fetchHolidaysForDateRange(); }, [currentDate.year()]); + // Check if we need to populate holidays when holiday settings are loaded + useEffect(() => { + populateHolidaysIfNeeded(); + }, [holidaySettings, holidays.length]); + const customHolidays = useMemo(() => { return holidays.filter(holiday => holiday.source === 'custom'); }, [holidays]); @@ -103,7 +131,7 @@ const HolidayCalendar: React.FC = ({ themeMode }) => { message.success(t('holidayCreated')); setModalVisible(false); form.resetFields(); - fetchHolidaysForDateRange(); + fetchHolidaysForDateRange(true); } } catch (error) { logger.error('Error creating holiday', error); @@ -133,7 +161,7 @@ const HolidayCalendar: React.FC = ({ themeMode }) => { setEditModalVisible(false); editForm.resetFields(); setSelectedHoliday(null); - fetchHolidaysForDateRange(); + fetchHolidaysForDateRange(true); } } catch (error) { logger.error('Error updating holiday', error); @@ -146,7 +174,12 @@ const HolidayCalendar: React.FC = ({ themeMode }) => { const res = await holidayApiService.deleteOrganizationHoliday(holidayId); if (res.done) { message.success(t('holidayDeleted')); - fetchHolidaysForDateRange(); + // Close the edit modal and reset form + setEditModalVisible(false); + editForm.resetFields(); + setSelectedHoliday(null); + // Refresh holidays data + fetchHolidaysForDateRange(true); } } catch (error) { logger.error('Error deleting holiday', error); @@ -174,31 +207,55 @@ const HolidayCalendar: React.FC = ({ themeMode }) => { const getHolidayDateCellRender = (date: Dayjs) => { 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 isToday = date.isSame(dayjs(), 'day'); + const isCurrentMonth = date.isSame(currentDate, 'month'); - if (dateHolidays.length > 0) { - return ( -
- {dateHolidays.map((holiday, index) => ( - - {holiday.name} - - ))} -
- ); - } - return null; + return ( +
+ {dateHolidays.length > 0 && ( +
+ {dateHolidays.map((holiday, index) => { + const isOfficial = holiday.source === 'official'; + const isCustom = holiday.source === 'custom'; + return ( + + {isCustom && ( + + )} + {isOfficial && ( + 🏛️ + )} + {holiday.name} + + ); + })} +
+ )} + {isWorkingDay && ( +
+
W
+
+ )} +
+ ); }; const onPanelChange = (value: Dayjs) => { @@ -227,40 +284,83 @@ const HolidayCalendar: React.FC = ({ themeMode }) => { style={{ display: 'flex', justifyContent: 'space-between', - alignItems: 'center', - marginBottom: 16, + alignItems: 'flex-start', + marginBottom: 18, + padding: '4px 0', }} > - - {t('holidayCalendar')} - - - +
+ + {t('holidayCalendar')} + {holidaySettings?.country_code && ( - + {t('officialHolidaysFrom') || 'Official holidays from'}:{' '} - {holidaySettings.country_code} - {holidaySettings.state_code && ` (${holidaySettings.state_code})`} + + {holidaySettings.country_code} + {holidaySettings.state_code && ` (${holidaySettings.state_code})`} + )} - +
+ - +
+ + + {/* Calendar Legend */} +
+
+
W
+ {t('workingDay') || 'Working Day'} +
+
+
+ + Custom +
+ {t('customHoliday') || 'Custom Holiday'} +
+
+
+ 🏛️ + Official +
+ {t('officialHoliday') || 'Official Holiday'} +
+
+
+ {t('today') || 'Today'} +
+
+
{/* Create Holiday Modal */} = ({ themeMode }) => { > {t('cancel')} + {selectedHoliday && selectedHoliday.source === 'custom' && selectedHoliday.is_editable && ( + handleDeleteHoliday(selectedHoliday.id)} + okText={t('yes') || 'Yes'} + cancelText={t('no') || 'No'} + > + + + )}
diff --git a/worklenz-frontend/src/data/sri-lanka-holidays-2025.ts b/worklenz-frontend/src/data/sri-lanka-holidays-2025.ts index 065744d0..cf6edc92 100644 --- a/worklenz-frontend/src/data/sri-lanka-holidays-2025.ts +++ b/worklenz-frontend/src/data/sri-lanka-holidays-2025.ts @@ -20,16 +20,6 @@ export interface SriLankanHolidayData { export const sriLankanHolidays2025: SriLankanHolidayData[] = [ // January - { - name: "New Year's Day", - date: "2025-01-01", - type: "Public", - description: "Celebration of the first day of the Gregorian calendar year", - is_recurring: true, - is_poya: false, - country_code: "LK", - color_code: "#DC143C" - }, { name: "Duruthu Full Moon Poya Day", date: "2025-01-13", diff --git a/worklenz-frontend/src/features/admin-center/admin-center.slice.ts b/worklenz-frontend/src/features/admin-center/admin-center.slice.ts index 30e57ad1..ed51b69e 100644 --- a/worklenz-frontend/src/features/admin-center/admin-center.slice.ts +++ b/worklenz-frontend/src/features/admin-center/admin-center.slice.ts @@ -105,7 +105,6 @@ export const updateHolidaySettings = createAsyncThunk( async (settings: IOrganizationHolidaySettings) => { const { holidayApiService } = await import('@/api/holiday/holiday.api.service'); await holidayApiService.updateOrganizationHolidaySettings(settings); - await adminCenterApiService.updateOrganizationHolidaySettings(settings); return settings; } ); @@ -164,6 +163,10 @@ const adminCenterSlice = createSlice({ ? (state.isUpgradeModalOpen = false) : (state.isUpgradeModalOpen = true); }, + clearHolidaysCache: state => { + state.holidays = []; + state.holidaysDateRange = null; + }, }, extraReducers: builder => { builder.addCase(fetchBillingInfo.pending, (state, action) => { @@ -264,7 +267,7 @@ const adminCenterSlice = createSlice({ }, }); -export const { toggleRedeemCodeDrawer, toggleUpgradeModal } = adminCenterSlice.actions; +export const { toggleRedeemCodeDrawer, toggleUpgradeModal, clearHolidaysCache } = adminCenterSlice.actions; // Selectors for optimized access export const selectHolidaysByDateRange = createSelector( diff --git a/worklenz-frontend/src/pages/admin-center/settings/settings.tsx b/worklenz-frontend/src/pages/admin-center/settings/settings.tsx index 891a9518..c8991b49 100644 --- a/worklenz-frontend/src/pages/admin-center/settings/settings.tsx +++ b/worklenz-frontend/src/pages/admin-center/settings/settings.tsx @@ -12,17 +12,14 @@ import { message, Select, Switch, - Divider, } from '@/shared/antd-imports'; import { PageHeader } from '@ant-design/pro-components'; import { useTranslation } from 'react-i18next'; -import { IOrganization, IOrganizationAdmin } from '@/types/admin-center/admin-center.types'; import logger from '@/utils/errorLogger'; import { scheduleAPIService } from '@/api/schedule/schedule.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 { holidayApiService } from '@/api/holiday/holiday.api.service'; import { useAppDispatch } from '@/hooks/useAppDispatch'; import { useAppSelector } from '@/hooks/useAppSelector'; import { RootState } from '@/app/store'; @@ -38,11 +35,7 @@ const SettingsPage: React.FC = () => { const dispatch = useAppDispatch(); const { organization, - organizationAdmins, - loadingOrganization, - loadingOrganizationAdmins, holidaySettings, - loadingHolidaySettings, countriesWithStates, loadingCountries, } = useAppSelector((state: RootState) => state.adminCenterReducer); @@ -241,7 +234,7 @@ const SettingsPage: React.FC = () => { }} showSearch filterOption={(input, option) => - (option?.children as string)?.toLowerCase().includes(input.toLowerCase()) + (option?.children as unknown as string)?.toLowerCase().includes(input.toLowerCase()) } > {countriesWithStates.map(country => ( @@ -260,7 +253,7 @@ const SettingsPage: React.FC = () => { disabled={!holidayForm.getFieldValue('country_code')} showSearch filterOption={(input, option) => - (option?.children as string)?.toLowerCase().includes(input.toLowerCase()) + (option?.children as unknown as string)?.toLowerCase().includes(input.toLowerCase()) } > {getSelectedCountryStates().map(state => ( @@ -287,7 +280,7 @@ const SettingsPage: React.FC = () => { - + );