Compare commits

...

26 Commits

Author SHA1 Message Date
Chamika J
11a6224fb3 fix(holiday-api): add error handling and fallback for countries with states API
- Implemented try-catch block in `getCountriesWithStates` to handle API errors gracefully.
- Added logging for errors when fetching countries, with a fallback to static data if the API call fails.
- Updated the `SettingsPage` to utilize the selected country code state for improved state selection logic.
2025-08-01 17:17:57 +05:30
Chamika J
8f407b45a9 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.
2025-08-01 16:54:58 +05:30
Chamika J
1a64115063 Merge branch 'release-v2.1.4' of https://github.com/Worklenz/worklenz into feature/team-utilization 2025-08-01 09:31:38 +05:30
Chamika J
903a9475b1 feat(sri-lankan-holidays): add comprehensive holiday management for Sri Lanka
- Introduced a new SQL migration file to populate Sri Lankan holidays from verified sources for the years 2020-2050.
- Implemented holiday data fetching and population logic in AdminCenterController and HolidayController to automatically sync holidays for Sri Lanka.
- Added a holiday data provider service to centralize holiday data retrieval and storage.
- Created a script for updating Sri Lankan holidays, including generating SQL for new years and validating holiday data.
- Developed documentation outlining the annual update process for Sri Lankan holidays, ensuring accurate data management.
- Included a JSON file containing detailed holiday information and metadata for Sri Lankan public holidays.
2025-07-31 10:43:49 +05:30
chamiakJ
13984fcfd4 Merge branch 'release-v2.1.4' of https://github.com/Worklenz/worklenz into feature/team-utilization 2025-07-31 06:57:50 +05:30
chamikaJ
f085f87107 feat(timer): enhance timer conflict resolution and state management
- Updated `useTaskTimerWithConflictCheck` to immediately update Redux state when stopping a conflicting timer, ensuring synchronization between task management and time tracking slices.
- Adjusted the timer start logic to trigger immediately after state updates, improving user experience during timer transitions.
- Added necessary imports for Redux dispatch and state management to support the new functionality.
2025-07-30 13:27:41 +05:30
chamikaJ
d9700a9b2c refactor(assignee-selector, kanban-group): clean up code formatting and improve modal confirmation handling
- Removed unnecessary line breaks and improved code readability in AssigneeSelector and KanbanGroup components.
- Refactored modal confirmation logic in KanbanGroup to streamline delete operations.
- Enhanced the dropdown and member list rendering in AssigneeSelector for better performance and user experience.
2025-07-30 13:09:43 +05:30
chamikaJ
9da6dced01 Merge branch 'release-v2.1.4' of https://github.com/Worklenz/worklenz into feature/team-utilization 2025-07-30 12:56:56 +05:30
chamikaJ
9dfc1fa375 feat(admin-center): implement admin center settings retrieval and enhance holiday population logic
- Added a new API endpoint in AdminCenterController to fetch admin center settings, including organization details.
- Updated the admin center API router to include the new settings route.
- Enhanced the holiday controller to check for recent holiday population before attempting to repopulate, preventing duplicate entries.
- Improved the holiday calendar component to manage holiday population attempts and display loading states.
- Updated localization files to support new messages related to calculation methods and holiday settings.
2025-07-30 11:35:12 +05:30
chamiakJ
069ae6ccb1 feat(projects-controller): add currency and budget fields to project details 2025-07-28 18:31:30 +05:30
chamikaJ
f81d0f9594 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.
2025-07-28 13:07:15 +05:30
chamikaJ
c18b289e4f feat(reporting): enhance reporting allocation and members controllers
- Added a helper method to build billable queries with custom table aliases in the ReportingAllocationController.
- Updated the logic for filtering projects and categories in the ReportingAllocationController to improve data retrieval based on user selections.
- Refactored the query structure in both ReportingAllocationController and ReportingMembersController to streamline data processing and enhance performance.
- Improved handling of utilization calculations and added new filters for team members in the ReportingMembersController.
- Enhanced Excel export functionality for member reports, ensuring accurate data representation and improved formatting.
2025-07-28 10:31:08 +05:30
Chamika J
b762bb5b18 Merge pull request #298 from Worklenz/feature/holiday-calendar-integration
feat(localization): update and enhance localization files for multipl…
2025-07-28 10:01:33 +05:30
Chamika J
7c7f955bb5 Merge branch 'feature/team-utilization' into feature/holiday-calendar-integration 2025-07-28 10:01:24 +05:30
chamiakJ
e0f268e4a1 fix(holiday-controller): specify error type in catch block for better type safety 2025-07-28 07:41:29 +05:30
chamiakJ
d39bddc22f feat(project-view): add user permission-based tab filtering
- Implemented a new function to filter project view tabs based on user finance permissions, enhancing the user experience by conditionally displaying the finance tab.
- Updated the project view component to utilize the new filtering function, ensuring that only authorized users can access finance-related features.
- Refactored URL parameter handling to accommodate the filtered tab items, improving state management and user navigation.
2025-07-28 07:36:25 +05:30
chamiakJ
591d348ae5 feat(localization): update and enhance localization files for multiple languages
- Updated localization files for various languages, including English, German, Spanish, Portuguese, and Chinese, to ensure consistency and accuracy across the application.
- Added new keys and updated existing ones to support recent UI changes and features, particularly in project views, task lists, and admin center settings.
- Enhanced the structure of localization files to improve maintainability and facilitate future updates.
- Implemented performance optimizations in the frontend components to better handle localization data.
2025-07-28 07:19:55 +05:30
chamiakJ
fc88c14b94 Merge branch 'feature/add-calender-and-holidays' of https://github.com/Worklenz/worklenz into feature/team-utilization 2025-07-26 12:31:35 +05:30
chamikaJ
22d78222d3 feat(localization): update tab labels to include finance
- Added the "finance" tab label to the project-view constants, ensuring it is available for localization.
- Updated the tab label retrieval function and the tab label update function to support the new finance key, enhancing the user interface for finance-related features.
2025-07-24 16:05:23 +05:30
chamikaJ
3a6af8bd07 feat(localization): add finance key to project-view localization files
- Added a new "finance" key to project-view localization files for multiple languages, enhancing support for finance-related UI elements.
- Ensured consistency across all language files by updating the structure and maintaining formatting.
2025-07-24 16:05:11 +05:30
chamikaJ
4ffc3465e3 feat(finance): implement project finance and rate card management features
- Added new controllers for managing project finance and rate cards, including CRUD operations for rate card roles and project finance tasks.
- Introduced API routes for project finance and rate card functionalities, enhancing the backend structure.
- Developed frontend components for displaying and managing project finance data, including a finance drawer and rate card settings.
- Enhanced localization files to support new UI elements and ensure consistency across multiple languages.
- Implemented utility functions for handling man-days and financial calculations, improving overall functionality.
2025-07-24 15:23:34 +05:30
chamikaJ
4b54f2cc17 feat(admin-center): implement organization calculation method settings
- Added functionality to update the organization's calculation method (hourly or man-days) in the Admin Center.
- Created a new component for managing the calculation method, including UI elements for selection and saving changes.
- Updated API service to handle the new endpoint for updating the calculation method.
- Enhanced localization files to support new keys related to the calculation method settings.
- Introduced a settings page to manage organization working days and hours alongside the calculation method.
2025-07-24 12:53:46 +05:30
chamikaJ
67a75685a9 feat(reporting): add total time utilization component and enhance localization
- Introduced a new TotalTimeUtilization component to display total time logged, expected capacity, and team utilization metrics.
- Updated existing time-report localization files to include new keys for total time logged, expected capacity, and utilization states across multiple languages.
- Refactored MembersTimeReports to integrate the new TotalTimeUtilization component, improving the reporting interface.
- Enhanced the overall structure and organization of the reporting components for better maintainability.
2025-07-24 11:41:04 +05:30
chamikaJ
20ce0c9687 feat(reporting): enhance time reports page with new filters and components
- Added new components for filtering by billable status, categories, projects, members, and teams in the time reports overview.
- Implemented a new header component to manage the layout and functionality of the time reports page.
- Refactored existing components to improve organization and maintainability, including the removal of deprecated files.
- Updated localization files to support new UI elements and ensure consistency across languages.
- Adjusted the language selector to reflect the correct language codes for Chinese.
2025-07-24 11:40:39 +05:30
chamikaJ
f73c151da2 feat(holiday-system): add holiday population functionality and API integration
- Implemented a new API endpoint to populate country holidays in the database.
- Enhanced the HolidayController to handle holiday population logic for multiple countries.
- Updated the holiday API router to include the new populate endpoint.
- Added a service method in the frontend to trigger holiday population.
- Integrated a button in the admin center overview for easy access to populate holidays.
- Improved error handling and user feedback during the holiday population process.
2025-07-17 11:27:29 +05:30
chamiakJ
5214368354 feat(holiday-system): implement comprehensive holiday management features
- Added holiday types and organization holidays management with CRUD operations.
- Introduced country holidays import functionality using the date-holidays npm package.
- Created database migrations for holiday types and organization holidays tables.
- Developed a holiday calendar component for visual representation and management of holidays.
- Enhanced API routes for holiday-related operations and integrated them into the admin center.
- Updated frontend localization for holiday management features.
- Implemented scripts for populating holidays in the database for 200+ countries.
2025-07-16 07:59:27 +05:30
404 changed files with 28024 additions and 6773 deletions

View File

@@ -0,0 +1,85 @@
-- Create holiday types table
CREATE TABLE IF NOT EXISTS holiday_types (
id UUID DEFAULT uuid_generate_v4() NOT NULL,
name TEXT NOT NULL,
description TEXT,
color_code WL_HEX_COLOR 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 holiday_types
ADD CONSTRAINT holiday_types_pk
PRIMARY KEY (id);
-- Insert default holiday types
INSERT INTO holiday_types (name, description, color_code) VALUES
('Public Holiday', 'Official public holidays', '#f37070'),
('Company Holiday', 'Company-specific holidays', '#70a6f3'),
('Personal Holiday', 'Personal or optional holidays', '#75c997'),
('Religious Holiday', 'Religious observances', '#fbc84c')
ON CONFLICT DO NOTHING;
-- Create organization holidays table
CREATE TABLE IF NOT EXISTS organization_holidays (
id UUID DEFAULT uuid_generate_v4() NOT NULL,
organization_id UUID NOT NULL,
holiday_type_id UUID NOT NULL,
name TEXT NOT NULL,
description TEXT,
date DATE NOT NULL,
is_recurring BOOLEAN DEFAULT FALSE 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_holidays
ADD CONSTRAINT organization_holidays_pk
PRIMARY KEY (id);
ALTER TABLE organization_holidays
ADD CONSTRAINT organization_holidays_organization_id_fk
FOREIGN KEY (organization_id) REFERENCES organizations
ON DELETE CASCADE;
ALTER TABLE organization_holidays
ADD CONSTRAINT organization_holidays_holiday_type_id_fk
FOREIGN KEY (holiday_type_id) REFERENCES holiday_types
ON DELETE RESTRICT;
-- Add unique constraint to prevent duplicate holidays on the same date for an organization
ALTER TABLE organization_holidays
ADD CONSTRAINT organization_holidays_organization_date_unique
UNIQUE (organization_id, date);
-- Create country holidays table for predefined holidays
CREATE TABLE IF NOT EXISTS country_holidays (
id UUID DEFAULT uuid_generate_v4() NOT NULL,
country_code CHAR(2) 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 country_holidays
ADD CONSTRAINT country_holidays_pk
PRIMARY KEY (id);
ALTER TABLE country_holidays
ADD CONSTRAINT country_holidays_country_code_fk
FOREIGN KEY (country_code) REFERENCES countries(code)
ON DELETE CASCADE;
-- Add unique constraint to prevent duplicate holidays for the same country, name, and date
ALTER TABLE country_holidays
ADD CONSTRAINT country_holidays_country_name_date_unique
UNIQUE (country_code, name, date);
-- Create indexes for better performance
CREATE INDEX IF NOT EXISTS idx_organization_holidays_organization_id ON organization_holidays(organization_id);
CREATE INDEX IF NOT EXISTS idx_organization_holidays_date ON organization_holidays(date);
CREATE INDEX IF NOT EXISTS idx_country_holidays_country_code ON country_holidays(country_code);
CREATE INDEX IF NOT EXISTS idx_country_holidays_date ON country_holidays(date);

View File

@@ -0,0 +1,60 @@
-- ================================================================
-- Sri Lankan Holidays Migration
-- ================================================================
-- This migration populates Sri Lankan holidays from verified sources
--
-- SOURCES & VERIFICATION:
-- - 2025 data: Verified from official government sources
-- - Fixed holidays: Independence Day, May Day, Christmas (all years)
-- - Variable holidays: Added only when officially verified
--
-- MAINTENANCE:
-- - Use scripts/update-sri-lankan-holidays.js for updates
-- - See docs/sri-lankan-holiday-update-process.md for process
-- ================================================================
-- Insert fixed holidays for multiple years (these never change dates)
DO $$
DECLARE
current_year INT;
BEGIN
FOR current_year IN 2020..2050 LOOP
INSERT INTO country_holidays (country_code, name, description, date, is_recurring)
VALUES
('LK', 'Independence Day', 'Commemorates the independence of Sri Lanka from British rule in 1948',
make_date(current_year, 2, 4), true),
('LK', 'May Day', 'International Workers'' Day',
make_date(current_year, 5, 1), true),
('LK', 'Christmas Day', 'Christian celebration of the birth of Jesus Christ',
make_date(current_year, 12, 25), true)
ON CONFLICT (country_code, name, date) DO NOTHING;
END LOOP;
END $$;
-- Insert specific holidays for years 2025-2028 (from our JSON data)
-- 2025 holidays
INSERT INTO country_holidays (country_code, name, description, date, is_recurring)
VALUES
('LK', 'Duruthu Full Moon Poya Day', 'Commemorates the first visit of Buddha to Sri Lanka', '2025-01-13', false),
('LK', 'Navam Full Moon Poya Day', 'Commemorates the appointment of Sariputta and Moggallana as Buddha''s chief disciples', '2025-02-12', false),
('LK', 'Medin Full Moon Poya Day', 'Commemorates Buddha''s first visit to his father''s palace after enlightenment', '2025-03-14', false),
('LK', 'Eid al-Fitr', 'Festival marking the end of Ramadan', '2025-03-31', false),
('LK', 'Bak Full Moon Poya Day', 'Commemorates Buddha''s second visit to Sri Lanka', '2025-04-12', false),
('LK', 'Good Friday', 'Christian commemoration of the crucifixion of Jesus Christ', '2025-04-18', false),
('LK', 'Vesak Full Moon Poya Day', 'Most sacred day for Buddhists - commemorates birth, enlightenment and passing of Buddha', '2025-05-12', false),
('LK', 'Day after Vesak Full Moon Poya Day', 'Additional day for Vesak celebrations', '2025-05-13', false),
('LK', 'Eid al-Adha', 'Islamic festival of sacrifice', '2025-06-07', false),
('LK', 'Poson Full Moon Poya Day', 'Commemorates the introduction of Buddhism to Sri Lanka by Arahat Mahinda', '2025-06-11', false),
('LK', 'Esala Full Moon Poya Day', 'Commemorates Buddha''s first sermon and the arrival of the Sacred Tooth Relic', '2025-07-10', false),
('LK', 'Nikini Full Moon Poya Day', 'Commemorates the first Buddhist council', '2025-08-09', false),
('LK', 'Binara Full Moon Poya Day', 'Commemorates Buddha''s visit to heaven to preach to his mother', '2025-09-07', false),
('LK', 'Vap Full Moon Poya Day', 'Marks the end of Buddhist Lent and Buddha''s return from heaven', '2025-10-07', false),
('LK', 'Deepavali', 'Hindu Festival of Lights', '2025-10-20', false),
('LK', 'Il Full Moon Poya Day', 'Commemorates Buddha''s ordination of sixty disciples', '2025-11-05', false),
('LK', 'Unduvap Full Moon Poya Day', 'Commemorates the arrival of Sanghamitta Theri with the Sacred Bo sapling', '2025-12-04', false)
ON CONFLICT (country_code, name, date) DO NOTHING;
-- NOTE: Data for 2026+ should be added only after verification from official sources
-- Use the holiday management script to generate templates for new years:
-- node update-sri-lankan-holidays.js --poya-template YYYY

View File

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

View File

@@ -0,0 +1,352 @@
# 🌍 Holiday Calendar System
The Worklenz Holiday Calendar System provides comprehensive holiday management for organizations operating globally.
## 📋 Features
- **200+ Countries Supported** - Comprehensive holiday data for countries worldwide
- **Multiple Holiday Types** - Public, Company, Personal, and Religious holidays
- **Import Country Holidays** - Bulk import official holidays from any supported country
- **Manual Holiday Management** - Add, edit, and delete custom holidays
- **Recurring Holidays** - Support for annual recurring holidays
- **Visual Calendar** - Interactive calendar with color-coded holiday display
- **Dark/Light Mode** - Full theme support
## 🚀 Quick Start
### 1. Database Setup
Run the migration to create the holiday tables:
```bash
# Run the migration
psql -d your_database -f database/migrations/20250130000000-add-holiday-calendar.sql
```
### 2. Populate Country Holidays
Use the npm package to populate holidays for 200+ countries:
```bash
# Run the holiday population script
./scripts/run-holiday-population.sh
```
This will populate holidays for years 2020-2030 for all supported countries.
### 3. Access the Holiday Calendar
Navigate to **Admin Center → Overview** to access the holiday calendar.
## 🌐 Supported Countries
The system includes **200+ countries** across all continents:
### North America
- United States 🇺🇸
- Canada 🇨🇦
- Mexico 🇲🇽
### Europe
- United Kingdom 🇬🇧
- Germany 🇩🇪
- France 🇫🇷
- Italy 🇮🇹
- Spain 🇪🇸
- Netherlands 🇳🇱
- Belgium 🇧🇪
- Switzerland 🇨🇭
- Austria 🇦🇹
- Sweden 🇸🇪
- Norway 🇳🇴
- Denmark 🇩🇰
- Finland 🇫🇮
- Poland 🇵🇱
- Czech Republic 🇨🇿
- Hungary 🇭🇺
- Romania 🇷🇴
- Bulgaria 🇧🇬
- Croatia 🇭🇷
- Slovenia 🇸🇮
- Slovakia 🇸🇰
- Lithuania 🇱🇹
- Latvia 🇱🇻
- Estonia 🇪🇪
- Ireland 🇮🇪
- Portugal 🇵🇹
- Greece 🇬🇷
- Cyprus 🇨🇾
- Malta 🇲🇹
- Luxembourg 🇱🇺
- Iceland 🇮🇸
### Asia
- China 🇨🇳
- Japan 🇯🇵
- South Korea 🇰🇷
- India 🇮🇳
- Pakistan 🇵🇰
- Bangladesh 🇧🇩
- Sri Lanka 🇱🇰
- Nepal 🇳🇵
- Thailand 🇹🇭
- Vietnam 🇻🇳
- Malaysia 🇲🇾
- Singapore 🇸🇬
- Indonesia 🇮🇩
- Philippines 🇵🇭
- Myanmar 🇲🇲
- Cambodia 🇰🇭
- Laos 🇱🇦
- Brunei 🇧🇳
- Timor-Leste 🇹🇱
- Mongolia 🇲🇳
- Kazakhstan 🇰🇿
- Uzbekistan 🇺🇿
- Kyrgyzstan 🇰🇬
- Tajikistan 🇹🇯
- Turkmenistan 🇹🇲
- Afghanistan 🇦🇫
- Iran 🇮🇷
- Iraq 🇮🇶
- Saudi Arabia 🇸🇦
- UAE 🇦🇪
- Qatar 🇶🇦
- Kuwait 🇰🇼
- Bahrain 🇧🇭
- Oman 🇴🇲
- Yemen 🇾🇪
- Jordan 🇯🇴
- Lebanon 🇱🇧
- Syria 🇸🇾
- Israel 🇮🇱
- Palestine 🇵🇸
- Turkey 🇹🇷
- Georgia 🇬🇪
- Armenia 🇦🇲
- Azerbaijan 🇦🇿
### Oceania
- Australia 🇦🇺
- New Zealand 🇳🇿
- Fiji 🇫🇯
- Papua New Guinea 🇵🇬
- Solomon Islands 🇸🇧
- Vanuatu 🇻🇺
- New Caledonia 🇳🇨
- French Polynesia 🇵🇫
- Tonga 🇹🇴
- Samoa 🇼🇸
- Kiribati 🇰🇮
- Tuvalu 🇹🇻
- Nauru 🇳🇷
- Palau 🇵🇼
- Marshall Islands 🇲🇭
- Micronesia 🇫🇲
### Africa
- South Africa 🇿🇦
- Egypt 🇪🇬
- Nigeria 🇳🇬
- Kenya 🇰🇪
- Ethiopia 🇪🇹
- Tanzania 🇹🇿
- Uganda 🇺🇬
- Ghana 🇬🇭
- Ivory Coast 🇨🇮
- Senegal 🇸🇳
- Mali 🇲🇱
- Burkina Faso 🇧🇫
- Niger 🇳🇪
- Chad 🇹🇩
- Cameroon 🇨🇲
- Central African Republic 🇨🇫
- Republic of the Congo 🇨🇬
- Democratic Republic of the Congo 🇨🇩
- Gabon 🇬🇦
- Equatorial Guinea 🇬🇶
- São Tomé and Príncipe 🇸🇹
- Angola 🇦🇴
- Zambia 🇿🇲
- Zimbabwe 🇿🇼
- Botswana 🇧🇼
- Namibia 🇳🇦
- Lesotho 🇱🇸
- Eswatini 🇸🇿
- Madagascar 🇲🇬
- Mauritius 🇲🇺
- Seychelles 🇸🇨
- Comoros 🇰🇲
- Djibouti 🇩🇯
- Somalia 🇸🇴
- Eritrea 🇪🇷
- Sudan 🇸🇩
- South Sudan 🇸🇸
- Libya 🇱🇾
- Tunisia 🇹🇳
- Algeria 🇩🇿
- Morocco 🇲🇦
- Western Sahara 🇪🇭
- Mauritania 🇲🇷
- Gambia 🇬🇲
- Guinea-Bissau 🇬🇼
- Guinea 🇬🇳
- Sierra Leone 🇸🇱
- Liberia 🇱🇷
- Togo 🇹🇬
- Benin 🇧🇯
### South America
- Brazil 🇧🇷
- Argentina 🇦🇷
- Chile 🇨🇱
- Colombia 🇨🇴
- Peru 🇵🇪
- Venezuela 🇻🇪
- Ecuador 🇪🇨
- Bolivia 🇧🇴
- Paraguay 🇵🇾
- Uruguay 🇺🇾
- Guyana 🇬🇾
- Suriname 🇸🇷
- Falkland Islands 🇫🇰
- French Guiana 🇬🇫
### Central America & Caribbean
- Mexico 🇲🇽
- Guatemala 🇬🇹
- Belize 🇧🇿
- El Salvador 🇸🇻
- Honduras 🇭🇳
- Nicaragua 🇳🇮
- Costa Rica 🇨🇷
- Panama 🇵🇦
- Cuba 🇨🇺
- Jamaica 🇯🇲
- Haiti 🇭🇹
- Dominican Republic 🇩🇴
- Puerto Rico 🇵🇷
- Trinidad and Tobago 🇹🇹
- Barbados 🇧🇧
- Grenada 🇬🇩
- Saint Lucia 🇱🇨
- Saint Vincent and the Grenadines 🇻🇨
- Antigua and Barbuda 🇦🇬
- Saint Kitts and Nevis 🇰🇳
- Dominica 🇩🇲
- Bahamas 🇧🇸
- Turks and Caicos Islands 🇹🇨
- Cayman Islands 🇰🇾
- Bermuda 🇧🇲
- Anguilla 🇦🇮
- British Virgin Islands 🇻🇬
- U.S. Virgin Islands 🇻🇮
- Aruba 🇦🇼
- Curaçao 🇨🇼
- Sint Maarten 🇸🇽
- Saint Martin 🇲🇫
- Saint Barthélemy 🇧🇱
- Guadeloupe 🇬🇵
- Martinique 🇲🇶
## 🔧 API Endpoints
### Holiday Types
```http
GET /api/holidays/types
```
### Organization Holidays
```http
GET /api/holidays/organization?year=2024
POST /api/holidays/organization
PUT /api/holidays/organization/:id
DELETE /api/holidays/organization/:id
```
### Country Holidays
```http
GET /api/holidays/countries
GET /api/holidays/countries/:country_code?year=2024
POST /api/holidays/import
```
### Calendar View
```http
GET /api/holidays/calendar?year=2024&month=1
```
## 📊 Holiday Types
The system supports four types of holidays:
1. **Public Holiday** - Official government holidays (Red)
2. **Company Holiday** - Organization-specific holidays (Blue)
3. **Personal Holiday** - Personal or optional holidays (Green)
4. **Religious Holiday** - Religious observances (Yellow)
## 🎯 Usage Examples
### Import US Holidays
```javascript
const result = await holidayApiService.importCountryHolidays({
country_code: 'US',
year: 2024
});
```
### Add Custom Holiday
```javascript
const holiday = await holidayApiService.createOrganizationHoliday({
name: 'Company Retreat',
description: 'Annual team building event',
date: '2024-06-15',
holiday_type_id: 'company-holiday-id',
is_recurring: true
});
```
### Get Calendar View
```javascript
const calendar = await holidayApiService.getHolidayCalendar(2024, 1);
```
## 🔄 Data Sources
The holiday data is sourced from the `date-holidays` npm package, which provides:
- **Official government holidays** for 200+ countries
- **Religious holidays** (Christian, Islamic, Jewish, Hindu, Buddhist)
- **Cultural and traditional holidays**
- **Historical and commemorative days**
## 🛠️ Maintenance
### Adding New Countries
1. Add the country to the `countries` table
2. Update the `populate-holidays.js` script
3. Run the population script
### Updating Holiday Data
```bash
# Re-run the holiday population script
./scripts/run-holiday-population.sh
```
## 📝 Notes
- Holidays are stored for years 2020-2030 by default
- The system prevents duplicate holidays on the same date
- Imported holidays are automatically classified as "Public Holiday" type
- All holidays support recurring annual patterns
- The calendar view combines organization and country holidays
## 🎉 Benefits
- **Global Compliance** - Ensure compliance with local holiday regulations
- **Resource Planning** - Better project scheduling and resource allocation
- **Team Coordination** - Improved team communication and planning
- **Cost Management** - Accurate billing and time tracking
- **Cultural Awareness** - Respect for diverse cultural and religious practices

View File

@@ -26,6 +26,7 @@
"crypto-js": "^4.1.1",
"csrf-sync": "^4.2.1",
"csurf": "^1.11.0",
"date-holidays": "^3.24.4",
"debug": "^4.3.4",
"dotenv": "^16.3.1",
"exceljs": "^4.3.0",
@@ -33,7 +34,6 @@
"express-rate-limit": "^6.8.0",
"express-session": "^1.17.3",
"express-validator": "^6.15.0",
"grunt-cli": "^1.5.0",
"helmet": "^6.2.0",
"hpp": "^0.2.3",
"http-errors": "^2.0.0",
@@ -126,7 +126,7 @@
"typescript": "^4.9.5"
},
"engines": {
"node": ">=16.13.0",
"node": ">=20.0.0",
"npm": ">=8.11.0",
"yarn": "WARNING: Please use npm package manager instead of yarn"
}
@@ -6452,33 +6452,14 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0"
},
"node_modules/array-each": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/array-each/-/array-each-1.0.1.tgz",
"integrity": "sha512-zHjL5SZa68hkKHBFBK6DJCTtr9sfTCPCaph/L7tMSLcTFgy+zX7E+6q5UArbtOtMBCtxdICpfTCspRse+ywyXA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/array-slice": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/array-slice/-/array-slice-1.1.0.tgz",
"integrity": "sha512-B1qMD3RBP7O8o0H2KbrXDyB0IccejMF15+87Lvlor12ONPRHP6gTjXMNkt/d3ZuOGbAe66hFmaCfECI24Ufp6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/array-union": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
@@ -6501,6 +6482,15 @@
"integrity": "sha512-5oJg84os6NMQNl27T9LnZkvvqzvAnHu03ShCnoj6bsJwS7L8AO4lf+C/XjK/nvzEqQB744moC6V128RucQd1jA==",
"license": "MIT"
},
"node_modules/astronomia": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/astronomia/-/astronomia-4.1.1.tgz",
"integrity": "sha512-TcJD9lUC5eAo0/Ji7rnQauX/yQbi0yZWM+JsNr77W3OA5fsrgvuFgubLMFwfw4VlZ29cu9dG/yfJbfvuTSftjg==",
"license": "MIT",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/async": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
@@ -6951,6 +6941,7 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
@@ -7097,6 +7088,18 @@
"node": ">= 0.8"
}
},
"node_modules/caldate": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/caldate/-/caldate-2.0.5.tgz",
"integrity": "sha512-JndhrUuDuE975KUhFqJaVR1OQkCHZqpOrJur/CFXEIEhWhBMjxO85cRSK8q4FW+B+yyPq6GYua2u4KvNzTcq0w==",
"license": "ISC",
"dependencies": {
"moment-timezone": "^0.5.43"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@@ -7939,6 +7942,73 @@
"node": ">=0.6"
}
},
"node_modules/date-bengali-revised": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/date-bengali-revised/-/date-bengali-revised-2.0.2.tgz",
"integrity": "sha512-q9iDru4+TSA9k4zfm0CFHJj6nBsxP7AYgWC/qodK/i7oOIlj5K2z5IcQDtESfs/Qwqt/xJYaP86tkazd/vRptg==",
"license": "MIT",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/date-chinese": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/date-chinese/-/date-chinese-2.1.4.tgz",
"integrity": "sha512-WY+6+Qw92ZGWFvGtStmNQHEYpNa87b8IAQ5T8VKt4wqrn24lBXyyBnWI5jAIyy7h/KVwJZ06bD8l/b7yss82Ww==",
"license": "MIT",
"dependencies": {
"astronomia": "^4.1.0"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/date-easter": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/date-easter/-/date-easter-1.0.3.tgz",
"integrity": "sha512-aOViyIgpM4W0OWUiLqivznwTtuMlD/rdUWhc5IatYnplhPiWrLv75cnifaKYhmQwUBLAMWLNG4/9mlLIbXoGBQ==",
"license": "MIT",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/date-holidays": {
"version": "3.24.4",
"resolved": "https://registry.npmjs.org/date-holidays/-/date-holidays-3.24.4.tgz",
"integrity": "sha512-IZsFU6KJvmomA+bzk1uvDJ8P0/9nEOGZ8YMPQGpipNDUY+pL219AmnwWypYrz36nyWYJ2/fSkGNHaWOfFwpiAg==",
"license": "(ISC AND CC-BY-3.0)",
"dependencies": {
"date-holidays-parser": "^3.4.7",
"js-yaml": "^4.1.0",
"lodash": "^4.17.21",
"prepin": "^1.0.3"
},
"bin": {
"holidays2json": "scripts/holidays2json.cjs"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/date-holidays-parser": {
"version": "3.4.7",
"resolved": "https://registry.npmjs.org/date-holidays-parser/-/date-holidays-parser-3.4.7.tgz",
"integrity": "sha512-h09ZEtM6u5cYM6m1bX+1Ny9f+nLO9KVZUKNPEnH7lhbXYTfqZogaGTnhONswGeIJFF91UImIftS3CdM9HLW5oQ==",
"license": "ISC",
"dependencies": {
"astronomia": "^4.1.1",
"caldate": "^2.0.5",
"date-bengali-revised": "^2.0.2",
"date-chinese": "^2.1.4",
"date-easter": "^1.0.3",
"deepmerge": "^4.3.1",
"jalaali-js": "^1.2.7",
"moment-timezone": "^0.5.47"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/dayjs": {
"version": "1.11.13",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz",
@@ -8056,15 +8126,6 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/detect-file": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/detect-file/-/detect-file-1.0.0.tgz",
"integrity": "sha512-DtCOLG98P007x7wiiOmfI0fi3eIKyWiLTGJ2MDnVi/E04lWGbf+JzrRHMm0rgIIZJGtHpKpbVgLWHrv8xXpc3Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/detect-libc": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
@@ -8924,18 +8985,6 @@
"node": ">=6"
}
},
"node_modules/expand-tilde": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz",
"integrity": "sha512-A5EmesHW6rfnZ9ysHQjPdJRni0SRar0tjtG5MNtm9n5TUvsYU8oozprtRD4AqHxcZWWlVuAmQo2nWKfN9oyjTw==",
"license": "MIT",
"dependencies": {
"homedir-polyfill": "^1.0.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/expect": {
"version": "28.1.3",
"resolved": "https://registry.npmjs.org/expect/-/expect-28.1.3.tgz",
@@ -9088,12 +9137,6 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/fast-csv": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz",
@@ -9222,6 +9265,7 @@
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
@@ -9287,46 +9331,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/findup-sync": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-4.0.0.tgz",
"integrity": "sha512-6jvvn/12IC4quLBL1KNokxC7wWTvYncaVUYSoxWw7YykPLuRrnv4qdHcSOywOI5RpkOVGeQRtWM8/q+G6W6qfQ==",
"license": "MIT",
"dependencies": {
"detect-file": "^1.0.0",
"is-glob": "^4.0.0",
"micromatch": "^4.0.2",
"resolve-dir": "^1.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/fined": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/fined/-/fined-1.2.0.tgz",
"integrity": "sha512-ZYDqPLGxDkDhDZBjZBb+oD1+j0rA4E0pXY50eplAAOPg2N/gUBSSk5IM1/QhPfyVo19lJ+CvXpqfvk+b2p/8Ng==",
"license": "MIT",
"dependencies": {
"expand-tilde": "^2.0.2",
"is-plain-object": "^2.0.3",
"object.defaults": "^1.1.0",
"object.pick": "^1.2.0",
"parse-filepath": "^1.0.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/flagged-respawn": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/flagged-respawn/-/flagged-respawn-1.0.1.tgz",
"integrity": "sha512-lNaHNVymajmk0OJMBn8fVUAU1BtDeKIqKoVhk4xAALB57aALg6b4W0MfJ/cUE0g9YBXy5XhSlPIpYIJ7HaY/3Q==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/flat-cache": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz",
@@ -9427,27 +9431,6 @@
}
}
},
"node_modules/for-in": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz",
"integrity": "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/for-own": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/for-own/-/for-own-1.0.0.tgz",
"integrity": "sha512-0OABksIGrxKK8K4kynWkQ7y1zounQxP+CWnyclVwj81KW3vlLlGUx57DKGcP/LH216GzqnstnPocF16Nxs0Ycg==",
"license": "MIT",
"dependencies": {
"for-in": "^1.0.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/foreground-child": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
@@ -9845,48 +9828,6 @@
"node": ">= 0.10"
}
},
"node_modules/global-modules": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/global-modules/-/global-modules-1.0.0.tgz",
"integrity": "sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg==",
"license": "MIT",
"dependencies": {
"global-prefix": "^1.0.1",
"is-windows": "^1.0.1",
"resolve-dir": "^1.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/global-prefix": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-1.0.2.tgz",
"integrity": "sha512-5lsx1NUDHtSjfg0eHlmYvZKv8/nVqX4ckFbM+FrGcQ+04KWcWFo9P5MxPZYSzUvyzmdTbI7Eix8Q4IbELDqzKg==",
"license": "MIT",
"dependencies": {
"expand-tilde": "^2.0.2",
"homedir-polyfill": "^1.0.1",
"ini": "^1.3.4",
"is-windows": "^1.0.1",
"which": "^1.2.14"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/global-prefix/node_modules/which": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
"integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==",
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"which": "bin/which"
}
},
"node_modules/globals": {
"version": "11.12.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
@@ -9943,34 +9884,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/grunt-cli": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/grunt-cli/-/grunt-cli-1.5.0.tgz",
"integrity": "sha512-rILKAFoU0dzlf22SUfDtq2R1fosChXXlJM5j7wI6uoW8gwmXDXzbUvirlKZSYCdXl3LXFbR+8xyS+WFo+b6vlA==",
"license": "MIT",
"dependencies": {
"grunt-known-options": "~2.0.0",
"interpret": "~1.1.0",
"liftup": "~3.0.1",
"nopt": "~5.0.0",
"v8flags": "^4.0.1"
},
"bin": {
"grunt": "bin/grunt"
},
"engines": {
"node": ">=10"
}
},
"node_modules/grunt-known-options": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/grunt-known-options/-/grunt-known-options-2.0.0.tgz",
"integrity": "sha512-GD7cTz0I4SAede1/+pAbmJRG44zFLPipVtdL9o3vqx9IEyb7b4/Y3s7r6ofI3CchR5GvYJ+8buCSioDv5dQLiA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -10042,18 +9955,6 @@
"dev": true,
"license": "https://www.highcharts.com/license"
},
"node_modules/homedir-polyfill": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz",
"integrity": "sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==",
"license": "MIT",
"dependencies": {
"parse-passwd": "^1.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/hpp": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/hpp/-/hpp-0.2.3.tgz",
@@ -10263,12 +10164,6 @@
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/interpret": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/interpret/-/interpret-1.1.0.tgz",
"integrity": "sha512-CLM8SNMDu7C5psFCn6Wg/tgpj/bKAg7hc2gWqcuR9OD5Ft9PhBpIu8PLicPeis+xDd6YX2ncI8MCA64I9tftIA==",
"license": "MIT"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -10278,19 +10173,6 @@
"node": ">= 0.10"
}
},
"node_modules/is-absolute": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-absolute/-/is-absolute-1.0.0.tgz",
"integrity": "sha512-dOWoqflvcydARa360Gvv18DZ/gRuHKi2NU/wU5X1ZFzdYfH29nkiNZsF3mp4OJ3H4yo9Mx8A/uAGNzpzPN3yBA==",
"license": "MIT",
"dependencies": {
"is-relative": "^1.0.0",
"is-windows": "^1.0.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-arrayish": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
@@ -10352,6 +10234,7 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -10380,6 +10263,7 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
@@ -10392,6 +10276,7 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
@@ -10407,18 +10292,6 @@
"node": ">=8"
}
},
"node_modules/is-plain-object": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz",
"integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==",
"license": "MIT",
"dependencies": {
"isobject": "^3.0.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-promise": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz",
@@ -10443,18 +10316,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-relative": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-relative/-/is-relative-1.0.0.tgz",
"integrity": "sha512-Kw/ReK0iqwKeu0MITLFuj0jbPAmEiOsIwyIXvvbfa6QfmN9pkD1M+8pdk7Rl/dTKbH34/XBFMbgD4iMJhLQbGA==",
"license": "MIT",
"dependencies": {
"is-unc-path": "^1.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-stream": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
@@ -10467,27 +10328,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-unc-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-unc-path/-/is-unc-path-1.0.0.tgz",
"integrity": "sha512-mrGpVd0fs7WWLfVsStvgF6iEJnbjDFZh9/emhRDcGWTduTfNHd9CHeUwH3gYIjdbwo4On6hunkztwOaAw0yllQ==",
"license": "MIT",
"dependencies": {
"unc-path-regex": "^0.1.2"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-windows": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz",
"integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
@@ -10498,17 +10338,9 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/isobject": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz",
"integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
@@ -10638,6 +10470,12 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/jalaali-js": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/jalaali-js/-/jalaali-js-1.2.8.tgz",
"integrity": "sha512-Jl/EwY84JwjW2wsWqeU4pNd22VNQ7EkjI36bDuLw31wH98WQW4fPjD0+mG7cdCK+Y8D6s9R3zLiQ3LaKu6bD8A==",
"license": "MIT"
},
"node_modules/javascript-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/javascript-stringify/-/javascript-stringify-2.1.0.tgz",
@@ -11324,7 +11162,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
@@ -11526,15 +11363,6 @@
"json-buffer": "3.0.1"
}
},
"node_modules/kind-of": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/kleur": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
@@ -11626,25 +11454,6 @@
"immediate": "~3.0.5"
}
},
"node_modules/liftup": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/liftup/-/liftup-3.0.1.tgz",
"integrity": "sha512-yRHaiQDizWSzoXk3APcA71eOI/UuhEkNN9DiW2Tt44mhYzX4joFoCZlxsSOF7RyeLlfqzFLQI1ngFq3ggMPhOw==",
"license": "MIT",
"dependencies": {
"extend": "^3.0.2",
"findup-sync": "^4.0.0",
"fined": "^1.2.0",
"flagged-respawn": "^1.0.1",
"is-plain-object": "^2.0.4",
"object.map": "^1.0.1",
"rechoir": "^0.7.0",
"resolve": "^1.19.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@@ -11883,18 +11692,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/make-iterator": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/make-iterator/-/make-iterator-1.0.1.tgz",
"integrity": "sha512-pxiuXh0iVEq7VM7KMIhs5gxsfxCux2URptUQaXo4iZZJxBAzTPOLE2BumO5dbfVYq/hBJFBR/a1mFDmOx5AGmw==",
"license": "MIT",
"dependencies": {
"kind-of": "^6.0.2"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/makeerror": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz",
@@ -11905,15 +11702,6 @@
"tmpl": "1.0.5"
}
},
"node_modules/map-cache": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz",
"integrity": "sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -11971,6 +11759,7 @@
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT",
"dependencies": {
"braces": "^3.0.3",
@@ -12418,46 +12207,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/object.defaults": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/object.defaults/-/object.defaults-1.1.0.tgz",
"integrity": "sha512-c/K0mw/F11k4dEUBMW8naXUuBuhxRCfG7W+yFy8EcijU/rSmazOUd1XAEEe6bC0OuXY4HUKjTJv7xbxIMqdxrA==",
"license": "MIT",
"dependencies": {
"array-each": "^1.0.1",
"array-slice": "^1.0.0",
"for-own": "^1.0.0",
"isobject": "^3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object.map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/object.map/-/object.map-1.0.1.tgz",
"integrity": "sha512-3+mAJu2PLfnSVGHwIWubpOFLscJANBKuB/6A4CxBstc4aqwQY0FWcsppuy4jU5GSB95yES5JHSI+33AWuS4k6w==",
"license": "MIT",
"dependencies": {
"for-own": "^1.0.0",
"make-iterator": "^1.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object.pick": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz",
"integrity": "sha512-tqa/UMy/CCoYmj+H5qc07qvSL9dqcs/WZENZ1JbtWBlATP+iVOe778gE6MSijnyCnORzDuX6hU+LA4SZ09YjFQ==",
"license": "MIT",
"dependencies": {
"isobject": "^3.0.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -12620,20 +12369,6 @@
"node": ">=6"
}
},
"node_modules/parse-filepath": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/parse-filepath/-/parse-filepath-1.0.2.tgz",
"integrity": "sha512-FwdRXKCohSVeXqwtYonZTXtbGJKrn+HNyWDYVcp5yuJlesTwNH4rsmRZ+GrKAPJ5bLpRxESMeS+Rl0VCHRvB2Q==",
"license": "MIT",
"dependencies": {
"is-absolute": "^1.0.0",
"map-cache": "^0.2.0",
"path-root": "^0.1.1"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/parse-json": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
@@ -12653,15 +12388,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/parse-passwd": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/parse-passwd/-/parse-passwd-1.0.0.tgz",
"integrity": "sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/parse-srcset": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
@@ -12800,27 +12526,6 @@
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"license": "MIT"
},
"node_modules/path-root": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/path-root/-/path-root-0.1.1.tgz",
"integrity": "sha512-QLcPegTHF11axjfojBIoDygmS2E3Lf+8+jI6wOVmNVenrKSo3mFdSGiIgdSHenczw3wPtlVMQaFVwGmM7BJdtg==",
"license": "MIT",
"dependencies": {
"path-root-regex": "^0.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/path-root-regex": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/path-root-regex/-/path-root-regex-0.1.2.tgz",
"integrity": "sha512-4GlJ6rZDhQZFE0DPVKh0e9jmZ5egZfxTkp7bcRDuPlJXbAwhxcl2dINPUAsjLdejqaLsCeg8axcLjIbvBjN4pQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/path-scurry": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz",
@@ -12968,6 +12673,7 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
@@ -13176,6 +12882,15 @@
"node": ">= 0.8.0"
}
},
"node_modules/prepin": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/prepin/-/prepin-1.0.3.tgz",
"integrity": "sha512-0XL2hreherEEvUy0fiaGEfN/ioXFV+JpImqIzQjxk6iBg4jQ2ARKqvC4+BmRD8w/pnpD+lbxvh0Ub+z7yBEjvA==",
"license": "Unlicense",
"bin": {
"prepin": "bin/prepin.js"
}
},
"node_modules/pretty-format": {
"version": "28.1.3",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-28.1.3.tgz",
@@ -13563,18 +13278,6 @@
"node": ">=8.10.0"
}
},
"node_modules/rechoir": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.7.1.tgz",
"integrity": "sha512-/njmZ8s1wVeR6pjTZ+0nCnv8SpZNRMT2D1RLOJQESlYFDBvwpTA4KWJpZ+sBJ4+vhjILRcK7JIFdGCdxEAAitg==",
"license": "MIT",
"dependencies": {
"resolve": "^1.9.0"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/redis": {
"version": "4.7.1",
"resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz",
@@ -13726,19 +13429,6 @@
"node": ">=8"
}
},
"node_modules/resolve-dir": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/resolve-dir/-/resolve-dir-1.0.1.tgz",
"integrity": "sha512-R7uiTjECzvOsWSfdM0QKFNBVFcK27aHOUwdvK53BcW8zqnGdYp0Fbj82cy54+2A4P2tFM22J5kRfe1R+lM/1yg==",
"license": "MIT",
"dependencies": {
"expand-tilde": "^2.0.0",
"global-modules": "^1.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -14974,6 +14664,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
@@ -15494,15 +15185,6 @@
"integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==",
"license": "MIT"
},
"node_modules/unc-path-regex": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/unc-path-regex/-/unc-path-regex-0.1.2.tgz",
"integrity": "sha512-eXL4nmJT7oCpkZsHZUOJo8hcX3GbsiDOa0Qu9F646fi8dT3XuSVopVqAcEiVzSKKH7UoDti23wNX3qGFxcW5Qg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
@@ -15732,15 +15414,6 @@
"node": ">=10.12.0"
}
},
"node_modules/v8flags": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/v8flags/-/v8flags-4.0.1.tgz",
"integrity": "sha512-fcRLaS4H/hrZk9hYwbdRM35D0U8IYMfEClhXxCivOojl+yTRAZH3Zy2sSy6qVCiGbV9YAtPssP6jaChqC9vPCg==",
"license": "MIT",
"engines": {
"node": ">= 10.13.0"
}
},
"node_modules/validator": {
"version": "13.15.15",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.15.tgz",

View File

@@ -61,6 +61,7 @@
"crypto-js": "^4.1.1",
"csrf-sync": "^4.2.1",
"csurf": "^1.11.0",
"date-holidays": "^3.24.4",
"debug": "^4.3.4",
"dotenv": "^16.3.1",
"exceljs": "^4.3.0",

View File

@@ -0,0 +1,265 @@
const Holidays = require("date-holidays");
const { Pool } = require("pg");
const config = require("../build/config/db-config").default;
// Database connection
const pool = new Pool(config);
// Countries to populate with holidays
const countries = [
{ code: "US", name: "United States" },
{ code: "GB", name: "United Kingdom" },
{ code: "CA", name: "Canada" },
{ code: "AU", name: "Australia" },
{ code: "DE", name: "Germany" },
{ code: "FR", name: "France" },
{ code: "IT", name: "Italy" },
{ code: "ES", name: "Spain" },
{ code: "NL", name: "Netherlands" },
{ code: "BE", name: "Belgium" },
{ code: "CH", name: "Switzerland" },
{ code: "AT", name: "Austria" },
{ code: "SE", name: "Sweden" },
{ code: "NO", name: "Norway" },
{ code: "DK", name: "Denmark" },
{ code: "FI", name: "Finland" },
{ code: "PL", name: "Poland" },
{ code: "CZ", name: "Czech Republic" },
{ code: "HU", name: "Hungary" },
{ code: "RO", name: "Romania" },
{ code: "BG", name: "Bulgaria" },
{ code: "HR", name: "Croatia" },
{ code: "SI", name: "Slovenia" },
{ code: "SK", name: "Slovakia" },
{ code: "LT", name: "Lithuania" },
{ code: "LV", name: "Latvia" },
{ code: "EE", name: "Estonia" },
{ code: "IE", name: "Ireland" },
{ code: "PT", name: "Portugal" },
{ code: "GR", name: "Greece" },
{ code: "CY", name: "Cyprus" },
{ code: "MT", name: "Malta" },
{ code: "LU", name: "Luxembourg" },
{ code: "IS", name: "Iceland" },
{ code: "CN", name: "China" },
{ code: "JP", name: "Japan" },
{ code: "KR", name: "South Korea" },
{ code: "IN", name: "India" },
{ code: "PK", name: "Pakistan" },
{ code: "BD", name: "Bangladesh" },
{ code: "LK", name: "Sri Lanka" },
{ code: "NP", name: "Nepal" },
{ code: "TH", name: "Thailand" },
{ code: "VN", name: "Vietnam" },
{ code: "MY", name: "Malaysia" },
{ code: "SG", name: "Singapore" },
{ code: "ID", name: "Indonesia" },
{ code: "PH", name: "Philippines" },
{ code: "MM", name: "Myanmar" },
{ code: "KH", name: "Cambodia" },
{ code: "LA", name: "Laos" },
{ code: "BN", name: "Brunei" },
{ code: "TL", name: "Timor-Leste" },
{ code: "MN", name: "Mongolia" },
{ code: "KZ", name: "Kazakhstan" },
{ code: "UZ", name: "Uzbekistan" },
{ code: "KG", name: "Kyrgyzstan" },
{ code: "TJ", name: "Tajikistan" },
{ code: "TM", name: "Turkmenistan" },
{ code: "AF", name: "Afghanistan" },
{ code: "IR", name: "Iran" },
{ code: "IQ", name: "Iraq" },
{ code: "SA", name: "Saudi Arabia" },
{ code: "AE", name: "United Arab Emirates" },
{ code: "QA", name: "Qatar" },
{ code: "KW", name: "Kuwait" },
{ code: "BH", name: "Bahrain" },
{ code: "OM", name: "Oman" },
{ code: "YE", name: "Yemen" },
{ code: "JO", name: "Jordan" },
{ code: "LB", name: "Lebanon" },
{ code: "SY", name: "Syria" },
{ code: "IL", name: "Israel" },
{ code: "PS", name: "Palestine" },
{ code: "TR", name: "Turkey" },
{ code: "GE", name: "Georgia" },
{ code: "AM", name: "Armenia" },
{ code: "AZ", name: "Azerbaijan" },
{ code: "NZ", name: "New Zealand" },
{ code: "FJ", name: "Fiji" },
{ code: "PG", name: "Papua New Guinea" },
{ code: "SB", name: "Solomon Islands" },
{ code: "VU", name: "Vanuatu" },
{ code: "NC", name: "New Caledonia" },
{ code: "PF", name: "French Polynesia" },
{ code: "TO", name: "Tonga" },
{ code: "WS", name: "Samoa" },
{ code: "KI", name: "Kiribati" },
{ code: "TV", name: "Tuvalu" },
{ code: "NR", name: "Nauru" },
{ code: "PW", name: "Palau" },
{ code: "MH", name: "Marshall Islands" },
{ code: "FM", name: "Micronesia" },
{ code: "ZA", name: "South Africa" },
{ code: "EG", name: "Egypt" },
{ code: "NG", name: "Nigeria" },
{ code: "KE", name: "Kenya" },
{ code: "ET", name: "Ethiopia" },
{ code: "TZ", name: "Tanzania" },
{ code: "UG", name: "Uganda" },
{ code: "GH", name: "Ghana" },
{ code: "CI", name: "Ivory Coast" },
{ code: "SN", name: "Senegal" },
{ code: "ML", name: "Mali" },
{ code: "BF", name: "Burkina Faso" },
{ code: "NE", name: "Niger" },
{ code: "TD", name: "Chad" },
{ code: "CM", name: "Cameroon" },
{ code: "CF", name: "Central African Republic" },
{ code: "CG", name: "Republic of the Congo" },
{ code: "CD", name: "Democratic Republic of the Congo" },
{ code: "GA", name: "Gabon" },
{ code: "GQ", name: "Equatorial Guinea" },
{ code: "ST", name: "São Tomé and Príncipe" },
{ code: "AO", name: "Angola" },
{ code: "ZM", name: "Zambia" },
{ code: "ZW", name: "Zimbabwe" },
{ code: "BW", name: "Botswana" },
{ code: "NA", name: "Namibia" },
{ code: "LS", name: "Lesotho" },
{ code: "SZ", name: "Eswatini" },
{ code: "MG", name: "Madagascar" },
{ code: "MU", name: "Mauritius" },
{ code: "SC", name: "Seychelles" },
{ code: "KM", name: "Comoros" },
{ code: "DJ", name: "Djibouti" },
{ code: "SO", name: "Somalia" },
{ code: "ER", name: "Eritrea" },
{ code: "SD", name: "Sudan" },
{ code: "SS", name: "South Sudan" },
{ code: "LY", name: "Libya" },
{ code: "TN", name: "Tunisia" },
{ code: "DZ", name: "Algeria" },
{ code: "MA", name: "Morocco" },
{ code: "EH", name: "Western Sahara" },
{ code: "MR", name: "Mauritania" },
{ code: "GM", name: "Gambia" },
{ code: "GW", name: "Guinea-Bissau" },
{ code: "GN", name: "Guinea" },
{ code: "SL", name: "Sierra Leone" },
{ code: "LR", name: "Liberia" },
{ code: "TG", name: "Togo" },
{ code: "BJ", name: "Benin" },
{ code: "BR", name: "Brazil" },
{ code: "AR", name: "Argentina" },
{ code: "CL", name: "Chile" },
{ code: "CO", name: "Colombia" },
{ code: "PE", name: "Peru" },
{ code: "VE", name: "Venezuela" },
{ code: "EC", name: "Ecuador" },
{ code: "BO", name: "Bolivia" },
{ code: "PY", name: "Paraguay" },
{ code: "UY", name: "Uruguay" },
{ code: "GY", name: "Guyana" },
{ code: "SR", name: "Suriname" },
{ code: "FK", name: "Falkland Islands" },
{ code: "GF", name: "French Guiana" },
{ code: "MX", name: "Mexico" },
{ code: "GT", name: "Guatemala" },
{ code: "BZ", name: "Belize" },
{ code: "SV", name: "El Salvador" },
{ code: "HN", name: "Honduras" },
{ code: "NI", name: "Nicaragua" },
{ code: "CR", name: "Costa Rica" },
{ code: "PA", name: "Panama" },
{ code: "CU", name: "Cuba" },
{ code: "JM", name: "Jamaica" },
{ code: "HT", name: "Haiti" },
{ code: "DO", name: "Dominican Republic" },
{ code: "PR", name: "Puerto Rico" },
{ code: "TT", name: "Trinidad and Tobago" },
{ code: "BB", name: "Barbados" },
{ code: "GD", name: "Grenada" },
{ code: "LC", name: "Saint Lucia" },
{ code: "VC", name: "Saint Vincent and the Grenadines" },
{ code: "AG", name: "Antigua and Barbuda" },
{ code: "KN", name: "Saint Kitts and Nevis" },
{ code: "DM", name: "Dominica" },
{ code: "BS", name: "Bahamas" },
{ code: "TC", name: "Turks and Caicos Islands" },
{ code: "KY", name: "Cayman Islands" },
{ code: "BM", name: "Bermuda" },
{ code: "AI", name: "Anguilla" },
{ code: "VG", name: "British Virgin Islands" },
{ code: "VI", name: "U.S. Virgin Islands" },
{ code: "AW", name: "Aruba" },
{ code: "CW", name: "Curaçao" },
{ code: "SX", name: "Sint Maarten" },
{ code: "MF", name: "Saint Martin" },
{ code: "BL", name: "Saint Barthélemy" },
{ code: "GP", name: "Guadeloupe" },
{ code: "MQ", name: "Martinique" }
];
async function populateHolidays() {
const client = await pool.connect();
try {
console.log("Starting holiday population...");
for (const country of countries) {
console.log(`Processing ${country.name} (${country.code})...`);
try {
const hd = new Holidays(country.code);
// Get holidays for multiple years (2020-2030)
for (let year = 2020; year <= 2030; year++) {
const holidays = hd.getHolidays(year);
for (const holiday of holidays) {
// Skip if holiday is not a date object
if (!holiday.date || typeof holiday.date !== "object") {
continue;
}
const dateStr = holiday.date.toISOString().split("T")[0];
const name = holiday.name || "Unknown Holiday";
const description = holiday.type || "Public Holiday";
// Insert holiday into database
const query = `
INSERT INTO country_holidays (country_code, name, description, date, is_recurring)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (country_code, name, date) DO NOTHING
`;
await client.query(query, [
country.code,
name,
description,
dateStr,
true // Most holidays are recurring
]);
}
}
console.log(`✓ Completed ${country.name}`);
} catch (error) {
console.log(`✗ Error processing ${country.name}: ${error.message}`);
}
}
console.log("Holiday population completed!");
} catch (error) {
console.error("Database error:", error);
} finally {
client.release();
await pool.end();
}
}
// Run the script
populateHolidays().catch(console.error);

View File

@@ -0,0 +1,25 @@
#!/bin/bash
echo "🌍 Starting Holiday Population Script..."
echo "This will populate the database with holidays for 200+ countries using the date-holidays npm package."
echo ""
# Check if Node.js is installed
if ! command -v node &> /dev/null; then
echo "❌ Node.js is not installed. Please install Node.js first."
exit 1
fi
# Check if the script exists
if [ ! -f "scripts/populate-holidays.js" ]; then
echo "❌ Holiday population script not found."
exit 1
fi
# Run the holiday population script
echo "🚀 Running holiday population script..."
node scripts/populate-holidays.js
echo ""
echo "✅ Holiday population completed!"
echo "You can now use the holiday import feature in the admin center."

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,416 @@
import { IWorkLenzRequest } from "../interfaces/worklenz-request";
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
import db from "../config/db";
import { ServerResponse } from "../models/server-response";
import WorklenzControllerBase from "./worklenz-controller-base";
import HandleExceptions from "../decorators/handle-exceptions";
import {
ICreateHolidayRequest,
IUpdateHolidayRequest,
IImportCountryHolidaysRequest,
} from "../interfaces/holiday.interface";
export default class HolidayController extends WorklenzControllerBase {
@HandleExceptions()
public static async getHolidayTypes(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const q = `SELECT id, name, description, color_code, created_at, updated_at
FROM holiday_types
ORDER BY name;`;
const result = await db.query(q);
return res.status(200).send(new ServerResponse(true, result.rows));
}
@HandleExceptions()
public static async getOrganizationHolidays(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { year } = req.query;
const yearFilter = year ? `AND EXTRACT(YEAR FROM date) = $2` : "";
const params = year ? [req.user?.owner_id, year] : [req.user?.owner_id];
const q = `SELECT oh.id, oh.organization_id, oh.holiday_type_id, oh.name, oh.description,
oh.date, oh.is_recurring, oh.created_at, oh.updated_at,
ht.name as holiday_type_name, ht.color_code
FROM organization_holidays oh
JOIN holiday_types ht ON oh.holiday_type_id = ht.id
WHERE oh.organization_id = (
SELECT id FROM organizations WHERE user_id = $1
) ${yearFilter}
ORDER BY oh.date;`;
const result = await db.query(q, params);
return res.status(200).send(new ServerResponse(true, result.rows));
}
@HandleExceptions()
public static async createOrganizationHoliday(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { name, description, date, holiday_type_id, is_recurring = false }: ICreateHolidayRequest = req.body;
const q = `INSERT INTO organization_holidays (organization_id, holiday_type_id, name, description, date, is_recurring)
VALUES (
(SELECT id FROM organizations WHERE user_id = $1),
$2, $3, $4, $5, $6
)
RETURNING id;`;
const result = await db.query(q, [req.user?.owner_id, holiday_type_id, name, description, date, is_recurring]);
return res.status(201).send(new ServerResponse(true, result.rows[0]));
}
@HandleExceptions()
public static async updateOrganizationHoliday(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { id } = req.params;
const { name, description, date, holiday_type_id, is_recurring }: IUpdateHolidayRequest = req.body;
const updateFields = [];
const values = [req.user?.owner_id, id];
let paramIndex = 3;
if (name !== undefined) {
updateFields.push(`name = $${paramIndex++}`);
values.push(name);
}
if (description !== undefined) {
updateFields.push(`description = $${paramIndex++}`);
values.push(description);
}
if (date !== undefined) {
updateFields.push(`date = $${paramIndex++}`);
values.push(date);
}
if (holiday_type_id !== undefined) {
updateFields.push(`holiday_type_id = $${paramIndex++}`);
values.push(holiday_type_id);
}
if (is_recurring !== undefined) {
updateFields.push(`is_recurring = $${paramIndex++}`);
values.push(is_recurring.toString());
}
if (updateFields.length === 0) {
return res.status(400).send(new ServerResponse(false, "No fields to update"));
}
const q = `UPDATE organization_holidays
SET ${updateFields.join(", ")}, updated_at = CURRENT_TIMESTAMP
WHERE id = $2 AND organization_id = (
SELECT id FROM organizations WHERE user_id = $1
)
RETURNING id;`;
const result = await db.query(q, values);
if (result.rows.length === 0) {
return res.status(404).send(new ServerResponse(false, "Holiday not found"));
}
return res.status(200).send(new ServerResponse(true, result.rows[0]));
}
@HandleExceptions()
public static async deleteOrganizationHoliday(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { id } = req.params;
const q = `DELETE FROM organization_holidays
WHERE id = $2 AND organization_id = (
SELECT id FROM organizations WHERE user_id = $1
)
RETURNING id;`;
const result = await db.query(q, [req.user?.owner_id, id]);
if (result.rows.length === 0) {
return res.status(404).send(new ServerResponse(false, "Holiday not found"));
}
return res.status(200).send(new ServerResponse(true, { message: "Holiday deleted successfully" }));
}
@HandleExceptions()
public static async getCountryHolidays(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { country_code, year } = req.query;
if (!country_code) {
return res.status(400).send(new ServerResponse(false, "Country code is required"));
}
const yearFilter = year ? `AND EXTRACT(YEAR FROM date) = $2` : "";
const params = year ? [country_code, year] : [country_code];
const q = `SELECT id, country_code, name, description, date, is_recurring, created_at, updated_at
FROM country_holidays
WHERE country_code = $1 ${yearFilter}
ORDER BY date;`;
const result = await db.query(q, params);
return res.status(200).send(new ServerResponse(true, result.rows));
}
@HandleExceptions()
public static async getAvailableCountries(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const q = `SELECT DISTINCT c.code, c.name
FROM countries c
JOIN country_holidays ch ON c.code = ch.country_code
ORDER BY c.name;`;
const result = await db.query(q);
return res.status(200).send(new ServerResponse(true, result.rows));
}
@HandleExceptions()
public static async importCountryHolidays(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { country_code, year }: IImportCountryHolidaysRequest = req.body;
if (!country_code) {
return res.status(400).send(new ServerResponse(false, "Country code is required"));
}
// Get organization ID
const orgQ = `SELECT id FROM organizations WHERE user_id = $1`;
const orgResult = await db.query(orgQ, [req.user?.owner_id]);
const organizationId = orgResult.rows[0]?.id;
if (!organizationId) {
return res.status(404).send(new ServerResponse(false, "Organization not found"));
}
// Get default holiday type (Public Holiday)
const typeQ = `SELECT id FROM holiday_types WHERE name = 'Public Holiday' LIMIT 1`;
const typeResult = await db.query(typeQ);
const holidayTypeId = typeResult.rows[0]?.id;
if (!holidayTypeId) {
return res.status(404).send(new ServerResponse(false, "Default holiday type not found"));
}
// Get country holidays for the specified year
const yearFilter = year ? `AND EXTRACT(YEAR FROM date) = $2` : "";
const params = year ? [country_code, year] : [country_code];
const holidaysQ = `SELECT name, description, date, is_recurring
FROM country_holidays
WHERE country_code = $1 ${yearFilter}`;
const holidaysResult = await db.query(holidaysQ, params);
if (holidaysResult.rows.length === 0) {
return res.status(404).send(new ServerResponse(false, "No holidays found for this country and year"));
}
// Import holidays to organization
const importQ = `INSERT INTO organization_holidays (organization_id, holiday_type_id, name, description, date, is_recurring)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (organization_id, date) DO NOTHING`;
let importedCount = 0;
for (const holiday of holidaysResult.rows) {
try {
await db.query(importQ, [
organizationId,
holidayTypeId,
holiday.name,
holiday.description,
holiday.date,
holiday.is_recurring
]);
importedCount++;
} catch (error) {
// Skip duplicates
continue;
}
}
return res.status(200).send(new ServerResponse(true, {
message: `Successfully imported ${importedCount} holidays`,
imported_count: importedCount
}));
}
@HandleExceptions()
public static async getHolidayCalendar(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { year, month } = req.query;
if (!year || !month) {
return res.status(400).send(new ServerResponse(false, "Year and month are required"));
}
const q = `SELECT oh.id, oh.name, oh.description, oh.date, oh.is_recurring,
ht.name as holiday_type_name, ht.color_code,
'organization' as source
FROM organization_holidays oh
JOIN holiday_types ht ON oh.holiday_type_id = ht.id
WHERE oh.organization_id = (
SELECT id FROM organizations WHERE user_id = $1
)
AND EXTRACT(YEAR FROM oh.date) = $2
AND EXTRACT(MONTH FROM oh.date) = $3
UNION ALL
SELECT ch.id, ch.name, ch.description, ch.date, ch.is_recurring,
'Public Holiday' as holiday_type_name, '#f37070' as color_code,
'country' as source
FROM country_holidays ch
JOIN organizations o ON ch.country_code = (
SELECT c.code FROM countries c WHERE c.id = o.country
)
WHERE o.user_id = $1
AND EXTRACT(YEAR FROM ch.date) = $2
AND EXTRACT(MONTH FROM ch.date) = $3
ORDER BY date;`;
const result = await db.query(q, [req.user?.owner_id, year, month]);
return res.status(200).send(new ServerResponse(true, result.rows));
}
@HandleExceptions()
public static async populateCountryHolidays(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
// Check if this organization has recently populated holidays (within last hour)
const recentPopulationCheck = `
SELECT COUNT(*) as count
FROM organization_holidays
WHERE organization_id = (SELECT id FROM organizations WHERE user_id = $1)
AND created_at > NOW() - INTERVAL '1 hour'
`;
const recentResult = await db.query(recentPopulationCheck, [req.user?.owner_id]);
const recentCount = parseInt(recentResult.rows[0]?.count || '0');
// If there are recent holidays added, skip population
if (recentCount > 10) {
return res.status(200).send(new ServerResponse(true, {
success: true,
message: "Holidays were recently populated, skipping to avoid duplicates",
total_populated: 0,
recently_populated: true
}));
}
const Holidays = require("date-holidays");
const countries = [
{ code: "US", name: "United States" },
{ code: "GB", name: "United Kingdom" },
{ code: "CA", name: "Canada" },
{ code: "AU", name: "Australia" },
{ code: "DE", name: "Germany" },
{ code: "FR", name: "France" },
{ code: "IT", name: "Italy" },
{ code: "ES", name: "Spain" },
{ code: "NL", name: "Netherlands" },
{ code: "BE", name: "Belgium" },
{ code: "CH", name: "Switzerland" },
{ code: "AT", name: "Austria" },
{ code: "SE", name: "Sweden" },
{ code: "NO", name: "Norway" },
{ code: "DK", name: "Denmark" },
{ code: "FI", name: "Finland" },
{ code: "PL", name: "Poland" },
{ code: "CZ", name: "Czech Republic" },
{ code: "HU", name: "Hungary" },
{ code: "RO", name: "Romania" },
{ code: "BG", name: "Bulgaria" },
{ code: "HR", name: "Croatia" },
{ code: "SI", name: "Slovenia" },
{ code: "SK", name: "Slovakia" },
{ code: "LT", name: "Lithuania" },
{ code: "LV", name: "Latvia" },
{ code: "EE", name: "Estonia" },
{ code: "IE", name: "Ireland" },
{ code: "PT", name: "Portugal" },
{ code: "GR", name: "Greece" },
{ code: "CY", name: "Cyprus" },
{ code: "MT", name: "Malta" },
{ code: "LU", name: "Luxembourg" },
{ code: "IS", name: "Iceland" },
{ code: "CN", name: "China" },
{ code: "JP", name: "Japan" },
{ code: "KR", name: "South Korea" },
{ code: "IN", name: "India" },
{ code: "BR", name: "Brazil" },
{ code: "AR", name: "Argentina" },
{ code: "MX", name: "Mexico" },
{ code: "ZA", name: "South Africa" },
{ code: "NZ", name: "New Zealand" },
{ code: "LK", name: "Sri Lanka" }
];
let totalPopulated = 0;
const errors = [];
for (const country of countries) {
try {
// Special handling for Sri Lanka
if (country.code === 'LK') {
// Import the holiday data provider
const { HolidayDataProvider } = require("../services/holiday-data-provider");
for (let year = 2020; year <= 2050; year++) {
const sriLankanHolidays = await HolidayDataProvider.getSriLankanHolidays(year);
for (const holiday of sriLankanHolidays) {
const query = `
INSERT INTO country_holidays (country_code, name, description, date, is_recurring)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (country_code, name, date) DO NOTHING
`;
await db.query(query, [
'LK',
holiday.name,
holiday.description,
holiday.date,
holiday.is_recurring
]);
totalPopulated++;
}
}
} else {
// Use date-holidays for other countries
const hd = new Holidays(country.code);
for (let year = 2020; year <= 2050; year++) {
const holidays = hd.getHolidays(year);
for (const holiday of holidays) {
if (!holiday.date || typeof holiday.date !== "object") {
continue;
}
const dateStr = holiday.date.toISOString().split("T")[0];
const name = holiday.name || "Unknown Holiday";
const description = holiday.type || "Public Holiday";
const query = `
INSERT INTO country_holidays (country_code, name, description, date, is_recurring)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (country_code, name, date) DO NOTHING
`;
await db.query(query, [
country.code,
name,
description,
dateStr,
true
]);
totalPopulated++;
}
}
}
} catch (error: any) {
errors.push(`${country.name}: ${error?.message || "Unknown error"}`);
}
}
const response = {
success: true,
message: `Successfully populated ${totalPopulated} holidays`,
total_populated: totalPopulated,
errors: errors.length > 0 ? errors : undefined
};
return res.status(200).send(new ServerResponse(true, response));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,292 @@
import db from "../config/db";
import { IWorkLenzRequest } from "../interfaces/worklenz-request";
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
import { ServerResponse } from "../models/server-response";
import HandleExceptions from "../decorators/handle-exceptions";
import WorklenzControllerBase from "./worklenz-controller-base";
export default class ProjectRateCardController extends WorklenzControllerBase {
// Insert a single role for a project
@HandleExceptions()
public static async createOne(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { project_id, job_title_id, rate, man_day_rate } = req.body;
if (!project_id || !job_title_id || typeof rate !== "number") {
return res.status(400).send(new ServerResponse(false, null, "Invalid input"));
}
// Handle both rate and man_day_rate fields
const columns = ["project_id", "job_title_id", "rate"];
const values = [project_id, job_title_id, rate];
if (typeof man_day_rate !== "undefined") {
columns.push("man_day_rate");
values.push(man_day_rate);
}
const q = `
INSERT INTO finance_project_rate_card_roles (${columns.join(", ")})
VALUES (${values.map((_, i) => `$${i + 1}`).join(", ")})
ON CONFLICT (project_id, job_title_id) DO UPDATE SET
rate = EXCLUDED.rate${typeof man_day_rate !== "undefined" ? ", man_day_rate = EXCLUDED.man_day_rate" : ""}
RETURNING *,
(SELECT name FROM job_titles jt WHERE jt.id = finance_project_rate_card_roles.job_title_id) AS jobtitle;
`;
const result = await db.query(q, values);
return res.status(200).send(new ServerResponse(true, result.rows[0]));
}
// Insert multiple roles for a project
@HandleExceptions()
public static async createMany(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { project_id, roles } = req.body;
if (!Array.isArray(roles) || !project_id) {
return res.status(400).send(new ServerResponse(false, null, "Invalid input"));
}
// Handle both rate and man_day_rate fields for each role
const columns = ["project_id", "job_title_id", "rate", "man_day_rate"];
const values = roles.map((role: any) => [
project_id,
role.job_title_id,
typeof role.rate !== "undefined" ? role.rate : 0,
typeof role.man_day_rate !== "undefined" ? role.man_day_rate : 0
]);
const q = `
INSERT INTO finance_project_rate_card_roles (${columns.join(", ")})
VALUES ${values.map((_, i) => `($${i * 4 + 1}, $${i * 4 + 2}, $${i * 4 + 3}, $${i * 4 + 4})`).join(",")}
ON CONFLICT (project_id, job_title_id) DO UPDATE SET
rate = EXCLUDED.rate,
man_day_rate = EXCLUDED.man_day_rate
RETURNING *,
(SELECT name FROM job_titles jt WHERE jt.id = finance_project_rate_card_roles.job_title_id) AS jobtitle;
`;
const flatValues = values.flat();
const result = await db.query(q, flatValues);
return res.status(200).send(new ServerResponse(true, result.rows));
}
// Get all roles for a project
@HandleExceptions()
public static async getByProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { project_id } = req.params;
const q = `
SELECT
fprr.*,
jt.name as jobtitle,
(
SELECT COALESCE(json_agg(pm.id), '[]'::json)
FROM project_members pm
WHERE pm.project_rate_card_role_id = fprr.id
) AS members
FROM finance_project_rate_card_roles fprr
LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id
WHERE fprr.project_id = $1
ORDER BY fprr.created_at;
`;
const result = await db.query(q, [project_id]);
return res.status(200).send(new ServerResponse(true, result.rows));
}
// Get a single role by id
@HandleExceptions()
public static async getById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { id } = req.params;
const q = `
SELECT
fprr.*,
jt.name as jobtitle,
(
SELECT COALESCE(json_agg(pm.id), '[]'::json)
FROM project_members pm
WHERE pm.project_rate_card_role_id = fprr.id
) AS members
FROM finance_project_rate_card_roles fprr
LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id
WHERE fprr.id = $1;
`;
const result = await db.query(q, [id]);
return res.status(200).send(new ServerResponse(true, result.rows[0]));
}
// Update a single role by id
@HandleExceptions()
public static async updateById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { id } = req.params;
const { job_title_id, rate, man_day_rate } = req.body;
let setClause = "job_title_id = $1, updated_at = NOW()";
const values = [job_title_id];
if (typeof man_day_rate !== "undefined") {
setClause += ", man_day_rate = $2";
values.push(man_day_rate);
} else {
setClause += ", rate = $2";
values.push(rate);
}
values.push(id);
const q = `
WITH updated AS (
UPDATE finance_project_rate_card_roles
SET ${setClause}
WHERE id = $3
RETURNING *
),
jobtitles AS (
SELECT u.*, jt.name AS jobtitle
FROM updated u
JOIN job_titles jt ON jt.id = u.job_title_id
),
members AS (
SELECT json_agg(pm.id) AS members, pm.project_rate_card_role_id
FROM project_members pm
WHERE pm.project_rate_card_role_id IN (SELECT id FROM jobtitles)
GROUP BY pm.project_rate_card_role_id
)
SELECT jt.*, m.members
FROM jobtitles jt
LEFT JOIN members m ON m.project_rate_card_role_id = jt.id;
`;
const result = await db.query(q, values);
return res.status(200).send(new ServerResponse(true, result.rows[0]));
}
// update project member rate for a project with members
@HandleExceptions()
public static async updateProjectMemberByProjectIdAndMemberId(
req: IWorkLenzRequest,
res: IWorkLenzResponse
): Promise<IWorkLenzResponse> {
const { project_id, id } = req.params;
const { project_rate_card_role_id } = req.body;
if (!project_id || !id || !project_rate_card_role_id) {
return res.status(400).send(new ServerResponse(false, null, "Missing values"));
}
try {
// Step 1: Check current role assignment
const checkQuery = `
SELECT project_rate_card_role_id
FROM project_members
WHERE id = $1 AND project_id = $2;
`;
const { rows: checkRows } = await db.query(checkQuery, [id, project_id]);
const currentRoleId = checkRows[0]?.project_rate_card_role_id;
if (currentRoleId !== null && currentRoleId !== project_rate_card_role_id) {
// Step 2: Fetch members with the requested role
const membersQuery = `
SELECT COALESCE(json_agg(id), '[]'::json) AS members
FROM project_members
WHERE project_id = $1 AND project_rate_card_role_id = $2;
`;
const { rows: memberRows } = await db.query(membersQuery, [project_id, project_rate_card_role_id]);
return res.status(200).send(
new ServerResponse(false, memberRows[0], "Already Assigned !")
);
}
// Step 3: Perform the update
const updateQuery = `
UPDATE project_members
SET project_rate_card_role_id = CASE
WHEN project_rate_card_role_id = $1 THEN NULL
ELSE $1
END
WHERE id = $2
AND project_id = $3
AND EXISTS (
SELECT 1
FROM finance_project_rate_card_roles
WHERE id = $1 AND project_id = $3
)
RETURNING project_rate_card_role_id;
`;
const { rows: updateRows } = await db.query(updateQuery, [project_rate_card_role_id, id, project_id]);
if (updateRows.length === 0) {
return res.status(200).send(new ServerResponse(true, [], "Project member not found or invalid project_rate_card_role_id"));
}
const updatedRoleId = updateRows[0].project_rate_card_role_id || project_rate_card_role_id;
// Step 4: Fetch updated members list
const membersQuery = `
SELECT COALESCE(json_agg(id), '[]'::json) AS members
FROM project_members
WHERE project_id = $1 AND project_rate_card_role_id = $2;
`;
const { rows: finalMembers } = await db.query(membersQuery, [project_id, updatedRoleId]);
return res.status(200).send(new ServerResponse(true, finalMembers[0]));
} catch (error) {
return res.status(500).send(new ServerResponse(false, null, "Internal server error"));
}
}
// Update all roles for a project (delete then insert)
@HandleExceptions()
public static async updateByProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { project_id, roles } = req.body;
if (!Array.isArray(roles) || !project_id) {
return res.status(400).send(new ServerResponse(false, null, "Invalid input"));
}
if (roles.length === 0) {
// If no roles provided, do nothing and return empty array
return res.status(200).send(new ServerResponse(true, []));
}
// Build upsert query for all roles
const columns = ["project_id", "job_title_id", "rate", "man_day_rate"];
const values = roles.map((role: any) => [
project_id,
role.job_title_id,
typeof role.rate !== "undefined" ? role.rate : null,
typeof role.man_day_rate !== "undefined" ? role.man_day_rate : null
]);
const q = `
WITH upserted AS (
INSERT INTO finance_project_rate_card_roles (${columns.join(", ")})
VALUES ${values.map((_, i) => `($${i * 4 + 1}, $${i * 4 + 2}, $${i * 4 + 3}, $${i * 4 + 4})`).join(",")}
ON CONFLICT (project_id, job_title_id)
DO UPDATE SET rate = EXCLUDED.rate, man_day_rate = EXCLUDED.man_day_rate, updated_at = NOW()
RETURNING *
),
jobtitles AS (
SELECT upr.*, jt.name AS jobtitle
FROM upserted upr
JOIN job_titles jt ON jt.id = upr.job_title_id
),
members AS (
SELECT json_agg(pm.id) AS members, pm.project_rate_card_role_id
FROM project_members pm
WHERE pm.project_rate_card_role_id IN (SELECT id FROM jobtitles)
GROUP BY pm.project_rate_card_role_id
)
SELECT jt.*, m.members
FROM jobtitles jt
LEFT JOIN members m ON m.project_rate_card_role_id = jt.id;
`;
const flatValues = values.flat();
const result = await db.query(q, flatValues);
return res.status(200).send(new ServerResponse(true, result.rows));
}
// Delete a single role by id
@HandleExceptions()
public static async deleteById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { id } = req.params;
const q = `DELETE FROM finance_project_rate_card_roles WHERE id = $1 RETURNING *;`;
const result = await db.query(q, [id]);
return res.status(200).send(new ServerResponse(true, result.rows[0]));
}
// Delete all roles for a project
@HandleExceptions()
public static async deleteByProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { project_id } = req.params;
const q = `DELETE FROM finance_project_rate_card_roles WHERE project_id = $1 RETURNING *;`;
const result = await db.query(q, [project_id]);
return res.status(200).send(new ServerResponse(true, result.rows));
}
}

View File

@@ -388,6 +388,8 @@ export default class ProjectsController extends WorklenzControllerBase {
projects.folder_id,
projects.phase_label,
projects.category_id,
projects.currency,
projects.budget,
(projects.estimated_man_days) AS man_days,
(projects.estimated_working_days) AS working_days,
(projects.hours_per_day) AS hours_per_day,

View File

@@ -0,0 +1,198 @@
import { IWorkLenzRequest } from "../interfaces/worklenz-request";
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
import db from "../config/db";
import { ServerResponse } from "../models/server-response";
import WorklenzControllerBase from "./worklenz-controller-base";
import HandleExceptions from "../decorators/handle-exceptions";
export default class RateCardController extends WorklenzControllerBase {
@HandleExceptions()
public static async create(
req: IWorkLenzRequest,
res: IWorkLenzResponse
): Promise<IWorkLenzResponse> {
const q = `
INSERT INTO finance_rate_cards (team_id, name)
VALUES ($1, $2)
RETURNING id, name, team_id, created_at, updated_at;
`;
const result = await db.query(q, [
req.user?.team_id || null,
req.body.name,
]);
const [data] = result.rows;
return res.status(200).send(new ServerResponse(true, data));
}
@HandleExceptions()
public static async get(
req: IWorkLenzRequest,
res: IWorkLenzResponse
): Promise<IWorkLenzResponse> {
const { searchQuery, sortField, sortOrder, size, offset } =
this.toPaginationOptions(req.query, "name");
const q = `
SELECT ROW_TO_JSON(rec) AS rate_cards
FROM (
SELECT COUNT(*) AS total,
(
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON)
FROM (
SELECT id, name, team_id, currency, created_at, updated_at
FROM finance_rate_cards
WHERE team_id = $1 ${searchQuery}
ORDER BY ${sortField} ${sortOrder}
LIMIT $2 OFFSET $3
) t
) AS data
FROM finance_rate_cards
WHERE team_id = $1 ${searchQuery}
) rec;
`;
const result = await db.query(q, [req.user?.team_id || null, size, offset]);
const [data] = result.rows;
return res
.status(200)
.send(
new ServerResponse(
true,
data.rate_cards || this.paginatedDatasetDefaultStruct
)
);
}
@HandleExceptions()
public static async getById(
req: IWorkLenzRequest,
res: IWorkLenzResponse
): Promise<IWorkLenzResponse> {
// 1. Fetch the rate card
const q = `
SELECT id, name, team_id, currency, created_at, updated_at
FROM finance_rate_cards
WHERE id = $1 AND team_id = $2;
`;
const result = await db.query(q, [
req.params.id,
req.user?.team_id || null,
]);
const [data] = result.rows;
if (!data) {
return res
.status(404)
.send(new ServerResponse(false, null, "Rate card not found"));
}
// 2. Fetch job roles with job title names
const jobRolesQ = `
SELECT
rcr.job_title_id,
jt.name AS jobTitle,
rcr.rate,
rcr.man_day_rate,
rcr.rate_card_id
FROM finance_rate_card_roles rcr
LEFT JOIN job_titles jt ON rcr.job_title_id = jt.id
WHERE rcr.rate_card_id = $1
`;
const jobRolesResult = await db.query(jobRolesQ, [req.params.id]);
const jobRolesList = jobRolesResult.rows;
// 3. Return the rate card with jobRolesList
return res.status(200).send(
new ServerResponse(true, {
...data,
jobRolesList,
})
);
}
@HandleExceptions()
public static async update(
req: IWorkLenzRequest,
res: IWorkLenzResponse
): Promise<IWorkLenzResponse> {
// 1. Update the rate card
const updateRateCardQ = `
UPDATE finance_rate_cards
SET name = $3, currency = $4, updated_at = NOW()
WHERE id = $1 AND team_id = $2
RETURNING id, name, team_id, currency, created_at, updated_at;
`;
const result = await db.query(updateRateCardQ, [
req.params.id,
req.user?.team_id || null,
req.body.name,
req.body.currency,
]);
const [rateCardData] = result.rows;
// 2. Update job roles (delete old, insert new)
if (Array.isArray(req.body.jobRolesList)) {
// Delete existing roles for this rate card
await db.query(
`DELETE FROM finance_rate_card_roles WHERE rate_card_id = $1;`,
[req.params.id]
);
// Insert new roles
for (const role of req.body.jobRolesList) {
if (role.job_title_id) {
await db.query(
`INSERT INTO finance_rate_card_roles (rate_card_id, job_title_id, rate, man_day_rate)
VALUES ($1, $2, $3, $4);`,
[
req.params.id,
role.job_title_id,
role.rate ?? 0,
role.man_day_rate ?? 0,
]
);
}
}
}
// 3. Get jobRolesList with job title names
const jobRolesQ = `
SELECT
rcr.job_title_id,
jt.name AS jobTitle,
rcr.rate
FROM finance_rate_card_roles rcr
LEFT JOIN job_titles jt ON rcr.job_title_id = jt.id
WHERE rcr.rate_card_id = $1
`;
const jobRolesResult = await db.query(jobRolesQ, [req.params.id]);
const jobRolesList = jobRolesResult.rows;
// 4. Return the updated rate card with jobRolesList
return res.status(200).send(
new ServerResponse(true, {
...rateCardData,
jobRolesList,
})
);
}
@HandleExceptions()
public static async deleteById(
req: IWorkLenzRequest,
res: IWorkLenzResponse
): Promise<IWorkLenzResponse> {
const q = `
DELETE FROM finance_rate_cards
WHERE id = $1 AND team_id = $2
RETURNING id;
`;
const result = await db.query(q, [
req.params.id,
req.user?.team_id || null,
]);
return res
.status(200)
.send(new ServerResponse(true, result.rows.length > 0));
}
}

View File

@@ -15,6 +15,25 @@ enum IToggleOptions {
}
export default class ReportingAllocationController extends ReportingControllerBase {
// Helper method to build billable query with custom table alias
private static buildBillableQueryWithAlias(selectedStatuses: { billable: boolean; nonBillable: boolean }, tableAlias: string = 'tasks'): string {
const { billable, nonBillable } = selectedStatuses;
if (billable && nonBillable) {
// Both are enabled, no need to filter
return "";
} else if (billable && !nonBillable) {
// Only billable is enabled - show only billable tasks
return ` AND ${tableAlias}.billable IS TRUE`;
} else if (!billable && nonBillable) {
// Only non-billable is enabled - show only non-billable tasks
return ` AND ${tableAlias}.billable IS FALSE`;
} else {
// Neither selected - this shouldn't happen in normal UI flow
return "";
}
}
private static async getTimeLoggedByProjects(projects: string[], users: string[], key: string, dateRange: string[], archived = false, user_id = "", billable: { billable: boolean; nonBillable: boolean }): Promise<any> {
try {
const projectIds = projects.map(p => `'${p}'`).join(",");
@@ -77,8 +96,8 @@ export default class ReportingAllocationController extends ReportingControllerBa
sps.icon AS status_icon,
(SELECT COUNT(*)
FROM tasks
WHERE CASE WHEN ($1 IS TRUE) THEN project_id IS NOT NULL ELSE archived = FALSE END ${billableQuery}
AND project_id = projects.id) AS all_tasks_count,
WHERE CASE WHEN ($1 IS TRUE) THEN project_id IS NOT NULL ELSE archived = FALSE END
AND project_id = projects.id ${billableQuery}) AS all_tasks_count,
(SELECT COUNT(*)
FROM tasks
WHERE CASE WHEN ($1 IS TRUE) THEN project_id IS NOT NULL ELSE archived = FALSE END
@@ -95,9 +114,10 @@ export default class ReportingAllocationController extends ReportingControllerBa
(SELECT COALESCE(SUM(time_spent), 0)
FROM task_work_log
LEFT JOIN tasks ON task_work_log.task_id = tasks.id
WHERE user_id = users.id ${billableQuery}
WHERE user_id = users.id
AND CASE WHEN ($1 IS TRUE) THEN tasks.project_id IS NOT NULL ELSE tasks.archived = FALSE END
AND tasks.project_id = projects.id
${billableQuery}
${duration}) AS time_logged
FROM users
WHERE id IN (${userIds})
@@ -121,10 +141,11 @@ export default class ReportingAllocationController extends ReportingControllerBa
const q = `(SELECT id,
(SELECT COALESCE(SUM(time_spent), 0)
FROM task_work_log
LEFT JOIN tasks ON task_work_log.task_id = tasks.id ${billableQuery}
LEFT JOIN tasks ON task_work_log.task_id = tasks.id
WHERE user_id = users.id
AND CASE WHEN ($1 IS TRUE) THEN tasks.project_id IS NOT NULL ELSE tasks.archived = FALSE END
AND tasks.project_id IN (${projectIds})
${billableQuery}
${duration}) AS time_logged
FROM users
WHERE id IN (${userIds})
@@ -346,6 +367,8 @@ export default class ReportingAllocationController extends ReportingControllerBa
const projects = (req.body.projects || []) as string[];
const projectIds = projects.map(p => `'${p}'`).join(",");
const categories = (req.body.categories || []) as string[];
const noCategory = req.body.noCategory || false;
const billable = req.body.billable;
if (!teamIds || !projectIds.length)
@@ -361,6 +384,33 @@ export default class ReportingAllocationController extends ReportingControllerBa
const billableQuery = this.buildBillableQuery(billable);
// Prepare projects filter
let projectsFilter = "";
if (projectIds.length > 0) {
projectsFilter = `AND p.id IN (${projectIds})`;
} else {
// If no projects are selected, don't show any data
projectsFilter = `AND 1=0`; // This will match no rows
}
// Prepare categories filter - updated logic
let categoriesFilter = "";
if (categories.length > 0 && noCategory) {
// Both specific categories and "No Category" are selected
const categoryIds = categories.map(id => `'${id}'`).join(",");
categoriesFilter = `AND (p.category_id IS NULL OR p.category_id IN (${categoryIds}))`;
} else if (categories.length === 0 && noCategory) {
// Only "No Category" is selected
categoriesFilter = `AND p.category_id IS NULL`;
} else if (categories.length > 0 && !noCategory) {
// Only specific categories are selected
const categoryIds = categories.map(id => `'${id}'`).join(",");
categoriesFilter = `AND p.category_id IN (${categoryIds})`;
} else {
// categories.length === 0 && !noCategory - no categories selected, show nothing
categoriesFilter = `AND 1=0`; // This will match no rows
}
const q = `
SELECT p.id,
p.name,
@@ -368,13 +418,15 @@ export default class ReportingAllocationController extends ReportingControllerBa
SUM(total_minutes) AS estimated,
color_code
FROM projects p
LEFT JOIN tasks ON tasks.project_id = p.id ${billableQuery}
LEFT JOIN tasks ON tasks.project_id = p.id
LEFT JOIN task_work_log ON task_work_log.task_id = tasks.id
WHERE p.id IN (${projectIds}) ${durationClause} ${archivedClause}
WHERE p.id IN (${projectIds}) ${durationClause} ${archivedClause} ${categoriesFilter} ${billableQuery}
GROUP BY p.id, p.name
ORDER BY logged_time DESC;`;
const result = await db.query(q, []);
const utilization = (req.body.utilization || []) as string[];
const data = [];
for (const project of result.rows) {
@@ -401,10 +453,12 @@ export default class ReportingAllocationController extends ReportingControllerBa
const projects = (req.body.projects || []) as string[];
const projectIds = projects.map(p => `'${p}'`).join(",");
const categories = (req.body.categories || []) as string[];
const noCategory = req.body.noCategory || false;
const billable = req.body.billable;
if (!teamIds || !projectIds.length)
return res.status(200).send(new ServerResponse(true, { users: [], projects: [] }));
if (!teamIds)
return res.status(200).send(new ServerResponse(true, { filteredRows: [], totals: { total_time_logs: "0", total_estimated_hours: "0", total_utilization: "0" } }));
const { duration, date_range } = req.body;
@@ -416,7 +470,9 @@ export default class ReportingAllocationController extends ReportingControllerBa
endDate = moment(date_range[1]);
} else if (duration === DATE_RANGES.ALL_TIME) {
// Fetch the earliest start_date (or created_at if null) from selected projects
const minDateQuery = `SELECT MIN(COALESCE(start_date, created_at)) as min_date FROM projects WHERE id IN (${projectIds})`;
const minDateQuery = projectIds.length > 0
? `SELECT MIN(COALESCE(start_date, created_at)) as min_date FROM projects WHERE id IN (${projectIds})`
: `SELECT MIN(COALESCE(start_date, created_at)) as min_date FROM projects WHERE team_id IN (${teamIds})`;
const minDateResult = await db.query(minDateQuery, []);
const minDate = minDateResult.rows[0]?.min_date;
startDate = minDate ? moment(minDate) : moment('2000-01-01');
@@ -445,59 +501,368 @@ export default class ReportingAllocationController extends ReportingControllerBa
}
}
// Count only weekdays (Mon-Fri) in the period
// Get organization working days
const orgWorkingDaysQuery = `
SELECT monday, tuesday, wednesday, thursday, friday, saturday, sunday
FROM organization_working_days
WHERE organization_id IN (
SELECT t.organization_id
FROM teams t
WHERE t.id IN (${teamIds})
LIMIT 1
);
`;
const orgWorkingDaysResult = await db.query(orgWorkingDaysQuery, []);
const workingDaysConfig = orgWorkingDaysResult.rows[0] || {
monday: true,
tuesday: true,
wednesday: true,
thursday: true,
friday: true,
saturday: false,
sunday: false
};
// Get organization ID for holiday queries
const orgIdQuery = `SELECT t.organization_id FROM teams t WHERE t.id IN (${teamIds}) LIMIT 1`;
const orgIdResult = await db.query(orgIdQuery, []);
const organizationId = orgIdResult.rows[0]?.organization_id;
// Fetch organization holidays within the date range
const orgHolidaysQuery = `
SELECT date
FROM organization_holidays
WHERE organization_id = $1
AND date >= $2::date
AND date <= $3::date
`;
const orgHolidaysResult = await db.query(orgHolidaysQuery, [
organizationId,
startDate.format('YYYY-MM-DD'),
endDate.format('YYYY-MM-DD')
]);
// Fetch country/state holidays if auto-sync is enabled
let countryStateHolidays: any[] = [];
const holidaySettingsQuery = `
SELECT country_code, state_code, auto_sync_holidays
FROM organization_holiday_settings
WHERE organization_id = $1
`;
const holidaySettingsResult = await db.query(holidaySettingsQuery, [organizationId]);
const holidaySettings = holidaySettingsResult.rows[0];
if (holidaySettings?.auto_sync_holidays && holidaySettings.country_code) {
// Fetch country holidays
const countryHolidaysQuery = `
SELECT date
FROM country_holidays
WHERE country_code = $1
AND (
(is_recurring = false AND date >= $2::date AND date <= $3::date) OR
(is_recurring = true AND
EXTRACT(MONTH FROM date) || '-' || EXTRACT(DAY FROM date) IN (
SELECT EXTRACT(MONTH FROM d::date) || '-' || EXTRACT(DAY FROM d::date)
FROM generate_series($2::date, $3::date, '1 day'::interval) d
)
)
)
`;
const countryHolidaysResult = await db.query(countryHolidaysQuery, [
holidaySettings.country_code,
startDate.format('YYYY-MM-DD'),
endDate.format('YYYY-MM-DD')
]);
countryStateHolidays = countryStateHolidays.concat(countryHolidaysResult.rows);
// Fetch state holidays if state_code is set
if (holidaySettings.state_code) {
const stateHolidaysQuery = `
SELECT date
FROM state_holidays
WHERE country_code = $1 AND state_code = $2
AND (
(is_recurring = false AND date >= $3::date AND date <= $4::date) OR
(is_recurring = true AND
EXTRACT(MONTH FROM date) || '-' || EXTRACT(DAY FROM date) IN (
SELECT EXTRACT(MONTH FROM d::date) || '-' || EXTRACT(DAY FROM d::date)
FROM generate_series($3::date, $4::date, '1 day'::interval) d
)
)
)
`;
const stateHolidaysResult = await db.query(stateHolidaysQuery, [
holidaySettings.country_code,
holidaySettings.state_code,
startDate.format('YYYY-MM-DD'),
endDate.format('YYYY-MM-DD')
]);
countryStateHolidays = countryStateHolidays.concat(stateHolidaysResult.rows);
}
}
// Create a Set of holiday dates for efficient lookup
const holidayDates = new Set<string>();
// Add organization holidays
orgHolidaysResult.rows.forEach(row => {
holidayDates.add(moment(row.date).format('YYYY-MM-DD'));
});
// Add country/state holidays (handling recurring holidays)
countryStateHolidays.forEach(row => {
const holidayDate = moment(row.date);
if (row.is_recurring) {
// For recurring holidays, check each year in the date range
let checkDate = startDate.clone().month(holidayDate.month()).date(holidayDate.date());
if (checkDate.isBefore(startDate)) {
checkDate.add(1, 'year');
}
while (checkDate.isSameOrBefore(endDate)) {
if (checkDate.isSameOrAfter(startDate)) {
holidayDates.add(checkDate.format('YYYY-MM-DD'));
}
checkDate.add(1, 'year');
}
} else {
holidayDates.add(holidayDate.format('YYYY-MM-DD'));
}
});
// Count working days based on organization settings, excluding holidays
let workingDays = 0;
let current = startDate.clone();
while (current.isSameOrBefore(endDate, 'day')) {
const day = current.isoWeekday();
if (day >= 1 && day <= 5) workingDays++;
const currentDateStr = current.format('YYYY-MM-DD');
// Check if it's a working day AND not a holiday
if (
!holidayDates.has(currentDateStr) && (
(day === 1 && workingDaysConfig.monday) ||
(day === 2 && workingDaysConfig.tuesday) ||
(day === 3 && workingDaysConfig.wednesday) ||
(day === 4 && workingDaysConfig.thursday) ||
(day === 5 && workingDaysConfig.friday) ||
(day === 6 && workingDaysConfig.saturday) ||
(day === 7 && workingDaysConfig.sunday)
)
) {
workingDays++;
}
current.add(1, 'day');
}
// Get hours_per_day for all selected projects
const projectHoursQuery = `SELECT id, hours_per_day FROM projects WHERE id IN (${projectIds})`;
const projectHoursResult = await db.query(projectHoursQuery, []);
const projectHoursMap: Record<string, number> = {};
for (const row of projectHoursResult.rows) {
projectHoursMap[row.id] = row.hours_per_day || 8;
}
// Sum total working hours for all selected projects
let totalWorkingHours = 0;
for (const pid of Object.keys(projectHoursMap)) {
totalWorkingHours += workingDays * projectHoursMap[pid];
// Get organization working hours
const orgWorkingHoursQuery = `SELECT hours_per_day FROM organizations WHERE id = (SELECT t.organization_id FROM teams t WHERE t.id IN (${teamIds}) LIMIT 1)`;
const orgWorkingHoursResult = await db.query(orgWorkingHoursQuery, []);
const orgWorkingHours = orgWorkingHoursResult.rows[0]?.hours_per_day || 8;
// Calculate total working hours with minimum baseline for non-working day scenarios
let totalWorkingHours = workingDays * orgWorkingHours;
let isNonWorkingPeriod = false;
// If no working days but there might be logged time, set minimum baseline
// This ensures that time logged on non-working days is treated as over-utilization
// Business Logic: If someone works on weekends/holidays when workingDays = 0,
// we use a minimal baseline (1 hour) so any logged time results in >100% utilization
if (totalWorkingHours === 0) {
totalWorkingHours = 1; // Minimal baseline to ensure over-utilization
isNonWorkingPeriod = true;
}
const durationClause = this.getDateRangeClause(duration || DATE_RANGES.LAST_WEEK, date_range);
const archivedClause = archived
? ""
: `AND p.id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = p.id AND user_id = '${req.user?.id}') `;
const billableQuery = this.buildBillableQuery(billable);
const billableQuery = this.buildBillableQueryWithAlias(billable, 't');
const members = (req.body.members || []) as string[];
const q = `
SELECT tmiv.email, tmiv.name, SUM(time_spent) AS logged_time
FROM team_member_info_view tmiv
LEFT JOIN task_work_log ON task_work_log.user_id = tmiv.user_id
LEFT JOIN tasks ON tasks.id = task_work_log.task_id ${billableQuery}
LEFT JOIN projects p ON p.id = tasks.project_id AND p.team_id = tmiv.team_id
WHERE p.id IN (${projectIds})
${durationClause} ${archivedClause}
GROUP BY tmiv.email, tmiv.name
ORDER BY logged_time DESC;`;
const result = await db.query(q, []);
// Prepare members filter
let membersFilter = "";
if (members.length > 0) {
const memberIds = members.map(id => `'${id}'`).join(",");
membersFilter = `AND tmiv.team_member_id IN (${memberIds})`;
} else {
// If no members are selected, we should not show any data
// This is different from other filters where no selection means "show all"
// For members, no selection should mean "show none" to respect the UI filter state
membersFilter = `AND 1=0`; // This will match no rows
}
// Note: Members filter works differently - when no members are selected, show nothing
for (const member of result.rows) {
member.value = member.logged_time ? parseFloat(moment.duration(member.logged_time, "seconds").asHours().toFixed(2)) : 0;
member.color_code = getColor(member.name);
member.total_working_hours = totalWorkingHours;
member.utilization_percent = (totalWorkingHours > 0 && member.logged_time) ? ((parseFloat(member.logged_time) / (totalWorkingHours * 3600)) * 100).toFixed(2) : '0.00';
member.utilized_hours = member.logged_time ? (parseFloat(member.logged_time) / 3600).toFixed(2) : '0.00';
// Over/under utilized hours: utilized_hours - total_working_hours
const overUnder = member.utilized_hours && member.total_working_hours ? (parseFloat(member.utilized_hours) - member.total_working_hours) : 0;
member.over_under_utilized_hours = overUnder.toFixed(2);
// Create custom duration clause for twl table alias
let customDurationClause = "";
if (date_range && date_range.length === 2) {
const start = moment(date_range[0]).format("YYYY-MM-DD");
const end = moment(date_range[1]).format("YYYY-MM-DD");
if (start === end) {
customDurationClause = `AND twl.created_at::DATE = '${start}'::DATE`;
} else {
customDurationClause = `AND twl.created_at::DATE >= '${start}'::DATE AND twl.created_at < '${end}'::DATE + INTERVAL '1 day'`;
}
} else {
const key = duration || DATE_RANGES.LAST_WEEK;
if (key === DATE_RANGES.YESTERDAY)
customDurationClause = "AND twl.created_at >= (CURRENT_DATE - INTERVAL '1 day')::DATE AND twl.created_at < CURRENT_DATE::DATE";
else if (key === DATE_RANGES.LAST_WEEK)
customDurationClause = "AND twl.created_at >= (CURRENT_DATE - INTERVAL '1 week')::DATE AND twl.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'";
else if (key === DATE_RANGES.LAST_MONTH)
customDurationClause = "AND twl.created_at >= (CURRENT_DATE - INTERVAL '1 month')::DATE AND twl.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'";
else if (key === DATE_RANGES.LAST_QUARTER)
customDurationClause = "AND twl.created_at >= (CURRENT_DATE - INTERVAL '3 months')::DATE AND twl.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'";
}
return res.status(200).send(new ServerResponse(true, result.rows));
// Prepare conditional filters for the subquery - only apply if selections are made
let conditionalProjectsFilter = "";
let conditionalCategoriesFilter = "";
// Only apply project filter if projects are actually selected
if (projectIds.length > 0) {
conditionalProjectsFilter = `AND p.id IN (${projectIds})`;
}
// Only apply category filter if categories are selected or noCategory is true
if (categories.length > 0 && noCategory) {
const categoryIds = categories.map(id => `'${id}'`).join(",");
conditionalCategoriesFilter = `AND (p.category_id IS NULL OR p.category_id IN (${categoryIds}))`;
} else if (categories.length === 0 && noCategory) {
conditionalCategoriesFilter = `AND p.category_id IS NULL`;
} else if (categories.length > 0 && !noCategory) {
const categoryIds = categories.map(id => `'${id}'`).join(",");
conditionalCategoriesFilter = `AND p.category_id IN (${categoryIds})`;
}
// If no categories and no noCategory, don't filter by category (show all)
// Check if all filters are unchecked (Clear All scenario) - return no data to avoid overwhelming UI
const hasProjectFilter = projectIds.length > 0;
const hasCategoryFilter = categories.length > 0 || noCategory;
const hasMemberFilter = members.length > 0;
// Note: We'll check utilization filter after the query since it's applied post-processing
if (!hasProjectFilter && !hasCategoryFilter && !hasMemberFilter) {
// Still need to check utilization filter, but we'll do a quick check
const utilization = (req.body.utilization || []) as string[];
const hasUtilizationFilter = utilization.length > 0;
if (!hasUtilizationFilter) {
return res.status(200).send(new ServerResponse(true, { filteredRows: [], totals: { total_time_logs: "0", total_estimated_hours: "0", total_utilization: "0" } }));
}
}
// Modified query to start from team members and calculate filtered time logs
// This query ensures ALL active team members are included, even if they have no logged time
const q = `
SELECT
tmiv.team_member_id,
tmiv.email,
tmiv.name,
COALESCE(
(SELECT SUM(twl.time_spent)
FROM task_work_log twl
LEFT JOIN tasks t ON t.id = twl.task_id
LEFT JOIN projects p ON p.id = t.project_id
WHERE twl.user_id = tmiv.user_id
${customDurationClause}
${conditionalProjectsFilter}
${conditionalCategoriesFilter}
${archivedClause}
${billableQuery}
AND p.team_id = tmiv.team_id
), 0
) AS logged_time
FROM team_member_info_view tmiv
WHERE tmiv.team_id IN (${teamIds})
AND tmiv.active = TRUE
${membersFilter}
GROUP BY tmiv.email, tmiv.name, tmiv.team_member_id, tmiv.user_id, tmiv.team_id
ORDER BY logged_time DESC;`;
const result = await db.query(q, []);
const utilization = (req.body.utilization || []) as string[];
// Precompute totalWorkingHours * 3600 for efficiency
const totalWorkingSeconds = totalWorkingHours * 3600;
// calculate utilization state
for (let i = 0, len = result.rows.length; i < len; i++) {
const member = result.rows[i];
const loggedSeconds = member.logged_time ? parseFloat(member.logged_time) : 0;
const utilizedHours = loggedSeconds / 3600;
// For individual members, use the same logic as total calculation
let memberWorkingHours;
let utilizationPercent;
if (isNonWorkingPeriod) {
// Non-working period: each member's expected working hours is 0
memberWorkingHours = 0;
// Any time logged during non-working period is overtime
utilizationPercent = loggedSeconds > 0 ? 100 : 0; // Show 100+ as numeric 100 for consistency
} else {
// Normal working period
memberWorkingHours = totalWorkingHours;
utilizationPercent = memberWorkingHours > 0 && loggedSeconds
? ((loggedSeconds / (memberWorkingHours * 3600)) * 100)
: 0;
}
const overUnder = utilizedHours - memberWorkingHours;
member.value = utilizedHours ? parseFloat(utilizedHours.toFixed(2)) : 0;
member.color_code = getColor(member.name);
member.total_working_hours = memberWorkingHours;
member.utilization_percent = utilizationPercent.toFixed(2);
member.utilized_hours = utilizedHours.toFixed(2);
member.over_under_utilized_hours = overUnder.toFixed(2);
if (utilizationPercent < 90) {
member.utilization_state = 'under';
} else if (utilizationPercent <= 110) {
member.utilization_state = 'optimal';
} else {
member.utilization_state = 'over';
}
}
// Apply utilization filter
let filteredRows;
if (utilization.length > 0) {
// Filter to only show selected utilization states
filteredRows = result.rows.filter(member => utilization.includes(member.utilization_state));
} else {
// No utilization states selected
// If we reached here, it means at least one other filter was applied
// so we show all members (don't filter by utilization)
filteredRows = result.rows;
}
// Calculate totals
const total_time_logs = filteredRows.reduce((sum, member) => sum + parseFloat(member.logged_time || '0'), 0);
let total_estimated_hours;
let total_utilization;
if (isNonWorkingPeriod) {
// Non-working period: expected capacity is 0
total_estimated_hours = 0;
// Special handling for utilization on non-working days
total_utilization = total_time_logs > 0 ? "100+" : "0";
} else {
// Normal working period calculation
total_estimated_hours = totalWorkingHours * filteredRows.length;
total_utilization = total_time_logs > 0 && total_estimated_hours > 0
? ((total_time_logs / (total_estimated_hours * 3600)) * 100).toFixed(1)
: '0';
}
return res.status(200).send(new ServerResponse(true, {
filteredRows,
totals: {
total_time_logs: ((total_time_logs / 3600).toFixed(2)).toString(),
total_estimated_hours: total_estimated_hours.toString(),
total_utilization: total_utilization.toString(),
},
}));
}
@HandleExceptions()
@@ -580,6 +945,9 @@ export default class ReportingAllocationController extends ReportingControllerBa
const projects = (req.body.projects || []) as string[];
const projectIds = projects.map(p => `'${p}'`).join(",");
const categories = (req.body.categories || []) as string[];
const noCategory = req.body.noCategory || false;
const { type, billable } = req.body;
if (!teamIds || !projectIds.length)
@@ -595,6 +963,33 @@ export default class ReportingAllocationController extends ReportingControllerBa
const billableQuery = this.buildBillableQuery(billable);
// Prepare projects filter
let projectsFilter = "";
if (projectIds.length > 0) {
projectsFilter = `AND p.id IN (${projectIds})`;
} else {
// If no projects are selected, don't show any data
projectsFilter = `AND 1=0`; // This will match no rows
}
// Prepare categories filter - updated logic
let categoriesFilter = "";
if (categories.length > 0 && noCategory) {
// Both specific categories and "No Category" are selected
const categoryIds = categories.map(id => `'${id}'`).join(",");
categoriesFilter = `AND (p.category_id IS NULL OR p.category_id IN (${categoryIds}))`;
} else if (categories.length === 0 && noCategory) {
// Only "No Category" is selected
categoriesFilter = `AND p.category_id IS NULL`;
} else if (categories.length > 0 && !noCategory) {
// Only specific categories are selected
const categoryIds = categories.map(id => `'${id}'`).join(",");
categoriesFilter = `AND p.category_id IN (${categoryIds})`;
} else {
// categories.length === 0 && !noCategory - no categories selected, show nothing
categoriesFilter = `AND 1=0`; // This will match no rows
}
const q = `
SELECT p.id,
p.name,
@@ -608,9 +1003,9 @@ export default class ReportingAllocationController extends ReportingControllerBa
WHERE project_id = p.id) AS estimated,
color_code
FROM projects p
LEFT JOIN tasks ON tasks.project_id = p.id ${billableQuery}
LEFT JOIN tasks ON tasks.project_id = p.id
LEFT JOIN task_work_log ON task_work_log.task_id = tasks.id
WHERE p.id IN (${projectIds}) ${durationClause} ${archivedClause}
WHERE p.id IN (${projectIds}) ${durationClause} ${archivedClause} ${categoriesFilter} ${billableQuery}
GROUP BY p.id, p.name
ORDER BY logged_time DESC;`;
const result = await db.query(q, []);

View File

@@ -90,6 +90,7 @@ export default class ReportingMembersController extends ReportingControllerBaseW
const completedDurationClasue = this.completedDurationFilter(key, dateRange);
const overdueActivityLogsClause = this.getActivityLogsOverdue(key, dateRange);
const activityLogCreationFilter = this.getActivityLogsCreationClause(key, dateRange);
const timeLogDateRangeClause = this.getTimeLogDateRangeClause(key, dateRange);
const q = `SELECT COUNT(DISTINCT email) AS total,
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON)
@@ -159,12 +160,27 @@ export default class ReportingMembersController extends ReportingControllerBaseW
FROM tasks t
LEFT JOIN tasks_assignees ta ON t.id = ta.task_id
WHERE team_member_id = tmiv.team_member_id
AND is_doing((SELECT new_value FROM task_activity_logs tl WHERE tl.task_id = t.id AND tl.attribute_type = 'status' ${activityLogCreationFilter} ORDER BY tl.created_at DESC LIMIT 1)::UUID, t.project_id) ${archivedClause}) AS ongoing_by_activity_logs
AND is_doing((SELECT new_value FROM task_activity_logs tl WHERE tl.task_id = t.id AND tl.attribute_type = 'status' ${activityLogCreationFilter} ORDER BY tl.created_at DESC LIMIT 1)::UUID, t.project_id) ${archivedClause}) AS ongoing_by_activity_logs,
(SELECT COALESCE(SUM(twl.time_spent), 0)
FROM task_work_log twl
LEFT JOIN tasks t ON twl.task_id = t.id
WHERE twl.user_id = (SELECT user_id FROM team_members WHERE id = tmiv.team_member_id)
AND t.billable IS TRUE
AND t.project_id IN (SELECT id FROM projects WHERE team_id = $1)
${timeLogDateRangeClause}
${includeArchived ? "" : `AND t.project_id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = t.project_id AND archived_projects.user_id = '${userId}')`}) AS billable_time,
(SELECT COALESCE(SUM(twl.time_spent), 0)
FROM task_work_log twl
LEFT JOIN tasks t ON twl.task_id = t.id
WHERE twl.user_id = (SELECT user_id FROM team_members WHERE id = tmiv.team_member_id)
AND t.billable IS FALSE
AND t.project_id IN (SELECT id FROM projects WHERE team_id = $1)
${timeLogDateRangeClause}
${includeArchived ? "" : `AND t.project_id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = t.project_id AND archived_projects.user_id = '${userId}')`}) AS non_billable_time
FROM team_member_info_view tmiv
WHERE tmiv.team_id = $1 ${teamsClause}
AND tmiv.team_member_id IN (SELECT team_member_id
FROM project_members
WHERE project_id IN (SELECT id FROM projects WHERE projects.team_id = tmiv.team_id))
${searchQuery}
GROUP BY email, name, avatar_url, team_member_id, tmiv.team_id
ORDER BY last_user_activity DESC NULLS LAST
@@ -172,9 +188,6 @@ export default class ReportingMembersController extends ReportingControllerBaseW
${pagingClause}) t) AS members
FROM team_member_info_view tmiv
WHERE tmiv.team_id = $1 ${teamsClause}
AND tmiv.team_member_id IN (SELECT team_member_id
FROM project_members
WHERE project_id IN (SELECT id FROM projects WHERE projects.team_id = tmiv.team_id))
${searchQuery}`;
const result = await db.query(q, [teamId]);
const [data] = result.rows;
@@ -370,6 +383,30 @@ export default class ReportingMembersController extends ReportingControllerBaseW
return "";
}
protected static getTimeLogDateRangeClause(key: string, dateRange: string[]) {
if (dateRange.length === 2) {
const start = moment(dateRange[0]).format("YYYY-MM-DD");
const end = moment(dateRange[1]).format("YYYY-MM-DD");
if (start === end) {
return `AND twl.created_at::DATE = '${start}'::DATE`;
}
return `AND twl.created_at::DATE >= '${start}'::DATE AND twl.created_at < '${end}'::DATE + INTERVAL '1 day'`;
}
if (key === DATE_RANGES.YESTERDAY)
return `AND twl.created_at >= (CURRENT_DATE - INTERVAL '1 day')::DATE AND twl.created_at < CURRENT_DATE::DATE`;
if (key === DATE_RANGES.LAST_WEEK)
return `AND twl.created_at >= (CURRENT_DATE - INTERVAL '1 week')::DATE AND twl.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'`;
if (key === DATE_RANGES.LAST_MONTH)
return `AND twl.created_at >= (CURRENT_DATE - INTERVAL '1 month')::DATE AND twl.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'`;
if (key === DATE_RANGES.LAST_QUARTER)
return `AND twl.created_at >= (CURRENT_DATE - INTERVAL '3 months')::DATE AND twl.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'`;
return "";
}
private static formatDuration(duration: moment.Duration) {
const empty = "0h 0m";
let format = "";
@@ -482,6 +519,8 @@ export default class ReportingMembersController extends ReportingControllerBaseW
{ header: "Overdue Tasks", key: "overdue_tasks", width: 20 },
{ header: "Completed Tasks", key: "completed_tasks", width: 20 },
{ header: "Ongoing Tasks", key: "ongoing_tasks", width: 20 },
{ header: "Billable Time (seconds)", key: "billable_time", width: 25 },
{ header: "Non-Billable Time (seconds)", key: "non_billable_time", width: 25 },
{ header: "Done Tasks(%)", key: "done_tasks", width: 20 },
{ header: "Doing Tasks(%)", key: "doing_tasks", width: 20 },
{ header: "Todo Tasks(%)", key: "todo_tasks", width: 20 }
@@ -489,14 +528,14 @@ export default class ReportingMembersController extends ReportingControllerBaseW
// set title
sheet.getCell("A1").value = `Members from ${teamName}`;
sheet.mergeCells("A1:K1");
sheet.mergeCells("A1:M1");
sheet.getCell("A1").alignment = { horizontal: "center" };
sheet.getCell("A1").style.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "D9D9D9" } };
sheet.getCell("A1").font = { size: 16 };
// set export date
sheet.getCell("A2").value = `Exported on : ${exportDate}`;
sheet.mergeCells("A2:K2");
sheet.mergeCells("A2:M2");
sheet.getCell("A2").alignment = { horizontal: "center" };
sheet.getCell("A2").style.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "F2F2F2" } };
sheet.getCell("A2").font = { size: 12 };
@@ -506,7 +545,7 @@ export default class ReportingMembersController extends ReportingControllerBaseW
sheet.mergeCells("A3:D3");
// set table headers
sheet.getRow(5).values = ["Member", "Email", "Tasks Assigned", "Overdue Tasks", "Completed Tasks", "Ongoing Tasks", "Done Tasks(%)", "Doing Tasks(%)", "Todo Tasks(%)"];
sheet.getRow(5).values = ["Member", "Email", "Tasks Assigned", "Overdue Tasks", "Completed Tasks", "Ongoing Tasks", "Billable Time (seconds)", "Non-Billable Time (seconds)", "Done Tasks(%)", "Doing Tasks(%)", "Todo Tasks(%)"];
sheet.getRow(5).font = { bold: true };
for (const member of result.members) {
@@ -517,6 +556,8 @@ export default class ReportingMembersController extends ReportingControllerBaseW
overdue_tasks: member.overdue,
completed_tasks: member.completed,
ongoing_tasks: member.ongoing,
billable_time: member.billable_time || 0,
non_billable_time: member.non_billable_time || 0,
done_tasks: member.completed,
doing_tasks: member.ongoing_by_activity_logs,
todo_tasks: member.todo_by_activity_logs

View File

@@ -53,13 +53,13 @@ export default class ScheduleControllerV2 extends WorklenzControllerBase {
const [workingDays] = workingDaysResults.rows;
// get organization working hours
const getDataHoursq = `SELECT working_hours FROM organizations WHERE user_id = $1 GROUP BY id LIMIT 1;`;
const getDataHoursq = `SELECT hours_per_day FROM organizations WHERE user_id = $1 GROUP BY id LIMIT 1;`;
const workingHoursResults = await db.query(getDataHoursq, [req.user?.owner_id]);
const [workingHours] = workingHoursResults.rows;
return res.status(200).send(new ServerResponse(true, { workingDays: workingDays?.working_days, workingHours: workingHours?.working_hours }));
return res.status(200).send(new ServerResponse(true, { workingDays: workingDays?.working_days, workingHours: workingHours?.hours_per_day }));
}
@HandleExceptions()
@@ -74,18 +74,13 @@ export default class ScheduleControllerV2 extends WorklenzControllerBase {
.map(day => `${day.toLowerCase()} = ${workingDays.includes(day)}`)
.join(", ");
const updateQuery = `
UPDATE public.organization_working_days
const updateQuery = `UPDATE public.organization_working_days
SET ${setClause}, updated_at = CURRENT_TIMESTAMP
WHERE organization_id IN (
SELECT organization_id FROM organizations
WHERE user_id = $1
);
`;
WHERE organization_id IN (SELECT id FROM organizations WHERE user_id = $1);`;
await db.query(updateQuery, [req.user?.owner_id]);
const getDataHoursq = `UPDATE organizations SET working_hours = $1 WHERE user_id = $2;`;
const getDataHoursq = `UPDATE organizations SET hours_per_day = $1 WHERE user_id = $2;`;
await db.query(getDataHoursq, [workingHours, req.user?.owner_id]);

View File

@@ -0,0 +1,219 @@
{
"_metadata": {
"description": "Sri Lankan Public Holidays Data",
"last_updated": "2025-01-31",
"sources": {
"2025": "Based on official government sources and existing verified data",
"note": "All dates should be verified against official sources before use"
},
"official_sources": [
"Central Bank of Sri Lanka - Holiday Circulars",
"Department of Meteorology - Astrological calculations",
"Ministry of Public Administration - Official gazette",
"Buddhist and Pali University - Poya day calculations",
"All Ceylon Jamiyyatul Ulama - Islamic calendar",
"Hindu Cultural Centre - Hindu calendar"
],
"verification_process": "Each year should be verified against current official publications before adding to production systems"
},
"2025": [
{
"name": "Duruthu Full Moon Poya Day",
"date": "2025-01-13",
"type": "Poya",
"description": "Commemorates the first visit of Buddha to Sri Lanka",
"is_recurring": false
},
{
"name": "Navam Full Moon Poya Day",
"date": "2025-02-12",
"type": "Poya",
"description": "Commemorates the appointment of Sariputta and Moggallana as Buddha's chief disciples",
"is_recurring": false
},
{
"name": "Independence Day",
"date": "2025-02-04",
"type": "Public",
"description": "Commemorates the independence of Sri Lanka from British rule in 1948",
"is_recurring": true
},
{
"name": "Medin Full Moon Poya Day",
"date": "2025-03-14",
"type": "Poya",
"description": "Commemorates Buddha's first visit to his father's palace after enlightenment",
"is_recurring": false
},
{
"name": "Eid al-Fitr",
"date": "2025-03-31",
"type": "Public",
"description": "Festival marking the end of Ramadan",
"is_recurring": false
},
{
"name": "Bak Full Moon Poya Day",
"date": "2025-04-12",
"type": "Poya",
"description": "Commemorates Buddha's second visit to Sri Lanka",
"is_recurring": false
},
{
"name": "Sinhala and Tamil New Year Day",
"date": "2025-04-13",
"type": "Public",
"description": "Traditional New Year celebrated by Sinhalese and Tamil communities",
"is_recurring": true
},
{
"name": "Day after Sinhala and Tamil New Year",
"date": "2025-04-14",
"type": "Public",
"description": "Second day of traditional New Year celebrations",
"is_recurring": true
},
{
"name": "Good Friday",
"date": "2025-04-18",
"type": "Public",
"description": "Christian commemoration of the crucifixion of Jesus Christ",
"is_recurring": false
},
{
"name": "May Day",
"date": "2025-05-01",
"type": "Public",
"description": "International Workers' Day",
"is_recurring": true
},
{
"name": "Vesak Full Moon Poya Day",
"date": "2025-05-12",
"type": "Poya",
"description": "Most sacred day for Buddhists - commemorates birth, enlightenment and passing of Buddha",
"is_recurring": false
},
{
"name": "Day after Vesak Full Moon Poya Day",
"date": "2025-05-13",
"type": "Public",
"description": "Additional day for Vesak celebrations",
"is_recurring": false
},
{
"name": "Eid al-Adha",
"date": "2025-06-07",
"type": "Public",
"description": "Islamic festival of sacrifice",
"is_recurring": false
},
{
"name": "Poson Full Moon Poya Day",
"date": "2025-06-11",
"type": "Poya",
"description": "Commemorates the introduction of Buddhism to Sri Lanka by Arahat Mahinda",
"is_recurring": false
},
{
"name": "Esala Full Moon Poya Day",
"date": "2025-07-10",
"type": "Poya",
"description": "Commemorates Buddha's first sermon and the arrival of the Sacred Tooth Relic",
"is_recurring": false
},
{
"name": "Nikini Full Moon Poya Day",
"date": "2025-08-09",
"type": "Poya",
"description": "Commemorates the first Buddhist council",
"is_recurring": false
},
{
"name": "Binara Full Moon Poya Day",
"date": "2025-09-07",
"type": "Poya",
"description": "Commemorates Buddha's visit to heaven to preach to his mother",
"is_recurring": false
},
{
"name": "Vap Full Moon Poya Day",
"date": "2025-10-07",
"type": "Poya",
"description": "Marks the end of Buddhist Lent and Buddha's return from heaven",
"is_recurring": false
},
{
"name": "Deepavali",
"date": "2025-10-20",
"type": "Public",
"description": "Hindu Festival of Lights",
"is_recurring": false
},
{
"name": "Il Full Moon Poya Day",
"date": "2025-11-05",
"type": "Poya",
"description": "Commemorates Buddha's ordination of sixty disciples",
"is_recurring": false
},
{
"name": "Unduvap Full Moon Poya Day",
"date": "2025-12-04",
"type": "Poya",
"description": "Commemorates the arrival of Sanghamitta Theri with the Sacred Bo sapling",
"is_recurring": false
},
{
"name": "Christmas Day",
"date": "2025-12-25",
"type": "Public",
"description": "Christian celebration of the birth of Jesus Christ",
"is_recurring": true
}
],
"fixed_holidays": [
{
"name": "Independence Day",
"month": 2,
"day": 4,
"type": "Public",
"description": "Commemorates the independence of Sri Lanka from British rule in 1948"
},
{
"name": "May Day",
"month": 5,
"day": 1,
"type": "Public",
"description": "International Workers' Day"
},
{
"name": "Christmas Day",
"month": 12,
"day": 25,
"type": "Public",
"description": "Christian celebration of the birth of Jesus Christ"
}
],
"variable_holidays_info": {
"sinhala_tamil_new_year": {
"description": "Sinhala and Tamil New Year dates vary based on astrological calculations. Common patterns:",
"common_dates": [
{ "pattern": "April 12-13", "years": "Some years" },
{ "pattern": "April 13-14", "years": "Most common" },
{ "pattern": "April 14-15", "years": "Occasional" }
],
"note": "These dates should be verified annually from official sources like the Department of Meteorology or astrological authorities"
},
"poya_days": {
"description": "Full moon Poya days follow the lunar calendar and change each year",
"note": "Dates should be obtained from Buddhist calendar or astronomical calculations"
},
"religious_holidays": {
"eid_fitr": "Based on Islamic lunar calendar - varies each year",
"eid_adha": "Based on Islamic lunar calendar - varies each year",
"good_friday": "Based on Easter calculation - varies each year",
"deepavali": "Based on Hindu lunar calendar - varies each year"
}
}
}

View File

@@ -0,0 +1,170 @@
# Sri Lankan Holiday Annual Update Process
## Overview
This document outlines the process for annually updating Sri Lankan holiday data to ensure accurate utilization calculations.
## Data Sources & Verification
### Official Government Sources
1. **Central Bank of Sri Lanka**
- Holiday circulars (usually published in December for the next year)
- Website: [cbsl.gov.lk](https://www.cbsl.gov.lk)
2. **Department of Meteorology**
- Astrological calculations for Sinhala & Tamil New Year
- Website: [meteo.gov.lk](http://www.meteo.gov.lk)
3. **Ministry of Public Administration**
- Official gazette notifications
- Public holiday declarations
### Religious Authorities
1. **Buddhist Calendar**
- Buddhist and Pali University of Sri Lanka
- Major temples (Malwatte, Asgiriya)
2. **Islamic Calendar**
- All Ceylon Jamiyyatul Ulama (ACJU)
- Colombo Grand Mosque
3. **Hindu Calendar**
- Hindu Cultural Centre
- Tamil cultural organizations
## Annual Update Workflow
### 1. Preparation (October - November)
```bash
# Check current data status
node update-sri-lankan-holidays.js --list
node update-sri-lankan-holidays.js --validate
```
### 2. Research Phase (November - December)
For the upcoming year (e.g., 2026):
1. **Fixed Holidays** ✅ Already handled
- Independence Day (Feb 4)
- May Day (May 1)
- Christmas Day (Dec 25)
2. **Variable Holidays** ⚠️ Require verification
- **Sinhala & Tamil New Year**: Check Department of Meteorology
- **Poya Days**: Check Buddhist calendar/temples
- **Good Friday**: Calculate from Easter
- **Eid al-Fitr & Eid al-Adha**: Check Islamic calendar
- **Deepavali**: Check Hindu calendar
### 3. Data Collection Template
```bash
# Generate template for the new year
node update-sri-lankan-holidays.js --poya-template 2026
```
This will output a template like:
```json
{
"name": "Duruthu Full Moon Poya Day",
"date": "2026-??-??",
"type": "Poya",
"description": "Commemorates the first visit of Buddha to Sri Lanka",
"is_recurring": false
}
```
### 4. Research Checklist
#### Sinhala & Tamil New Year
- [ ] Check Department of Meteorology announcements
- [ ] Verify with astrological authorities
- [ ] Confirm if dates are April 12-13, 13-14, or 14-15
#### Poya Days (12 per year)
- [ ] Get Buddhist calendar for the year
- [ ] Verify with temples or Buddhist authorities
- [ ] Double-check lunar calendar calculations
#### Religious Holidays
- [ ] **Good Friday**: Calculate based on Easter
- [ ] **Eid al-Fitr**: Check Islamic calendar/ACJU
- [ ] **Eid al-Adha**: Check Islamic calendar/ACJU
- [ ] **Deepavali**: Check Hindu calendar/cultural centers
### 5. Data Entry
1. Edit `src/data/sri-lankan-holidays.json`
2. Add new year section with verified dates
3. Update metadata with sources used
### 6. Validation & Testing
```bash
# Validate the new data
node update-sri-lankan-holidays.js --validate
# Generate SQL for database
node update-sri-lankan-holidays.js --generate-sql 2026
```
### 7. Database Update
1. Create new migration file with the generated SQL
2. Test in development environment
3. Deploy to production
### 8. Documentation
- Update metadata in JSON file
- Document sources used
- Note any special circumstances or date changes
## Emergency Updates
If holidays are announced late or changed:
1. **Quick JSON Update**:
```bash
# Edit the JSON file directly
# Add the new/changed holiday
```
2. **Database Hotfix**:
```sql
INSERT INTO country_holidays (country_code, name, description, date, is_recurring)
VALUES ('LK', 'Emergency Holiday', 'Description', 'YYYY-MM-DD', false)
ON CONFLICT (country_code, name, date) DO NOTHING;
```
3. **Notify Users**: Consider adding a notification system for holiday changes
## Quality Assurance
### Pre-Release Checklist
- [ ] All 12 Poya days included for the year
- [ ] Sinhala & Tamil New Year dates verified
- [ ] Religious holidays cross-checked with multiple sources
- [ ] No duplicate dates
- [ ] JSON format validation passes
- [ ] Database migration tested
### Post-Release Monitoring
- [ ] Monitor utilization calculations for anomalies
- [ ] Check user feedback for missed holidays
- [ ] Verify against actual government announcements
## Automation Opportunities
Future improvements could include:
1. **API Integration**: Connect to reliable holiday APIs
2. **Web Scraping**: Automated monitoring of official websites
3. **Notification System**: Alert when new holidays are announced
4. **Validation Service**: Cross-check against multiple sources
## Contact Information
For questions about the holiday update process:
- Technical issues: Development team
- Holiday verification: Sri Lankan team members
- Religious holidays: Local community contacts
## Version History
- **v1.0** (2025-01-31): Initial process documentation
- **2025 Data**: Verified and included
- **2026+ Data**: Pending official source verification

View File

@@ -0,0 +1,54 @@
export interface IHolidayType {
id: string;
name: string;
description?: string;
color_code: string;
created_at: string;
updated_at: string;
}
export interface IOrganizationHoliday {
id: string;
organization_id: string;
holiday_type_id: string;
name: string;
description?: string;
date: string;
is_recurring: boolean;
created_at: string;
updated_at: string;
holiday_type?: IHolidayType;
}
export interface ICountryHoliday {
id: string;
country_code: string;
name: string;
description?: string;
date: string;
is_recurring: boolean;
created_at: string;
updated_at: string;
}
export interface ICreateHolidayRequest {
name: string;
description?: string;
date: string;
holiday_type_id: string;
is_recurring?: boolean;
}
export interface IUpdateHolidayRequest {
id: string;
name?: string;
description?: string;
date?: string;
holiday_type_id?: string;
is_recurring?: boolean;
}
export interface IImportCountryHolidaysRequest {
country_code: string;
year?: number;
}

View File

@@ -15,5 +15,15 @@
"assignToMe": "分配给我",
"archive": "归档",
"newTaskNamePlaceholder": "写一个任务名称",
"newSubtaskNamePlaceholder": "写一个子任务名称"
"newSubtaskNamePlaceholder": "写一个子任务名称",
"untitledSection": "无标题部分",
"unmapped": "未映射",
"clickToChangeDate": "点击更改日期",
"noDueDate": "无截止日期",
"save": "保存",
"clear": "清除",
"nextWeek": "下周",
"noSubtasks": "无子任务",
"showSubtasks": "显示子任务",
"hideSubtasks": "隐藏子任务"
}

View File

@@ -18,6 +18,10 @@
"changeCategory": "更改类别",
"clickToEditGroupName": "点击编辑组名称",
"enterGroupName": "输入组名称",
"todo": "待办",
"inProgress": "进行中",
"done": "已完成",
"defaultTaskName": "无标题任务",
"indicators": {
"tooltips": {

View File

@@ -29,5 +29,37 @@
"noCategory": "无类别",
"noProjects": "未找到项目",
"noTeams": "未找到团队",
"noData": "未找到数据"
"noData": "未找到数据",
"groupBy": "分组方式",
"groupByCategory": "类别",
"groupByTeam": "团队",
"groupByStatus": "状态",
"groupByNone": "无",
"clearSearch": "清除搜索",
"selectedProjects": "已选项目",
"projectsSelected": "个项目已选择",
"showSelected": "仅显示已选择",
"expandAll": "全部展开",
"collapseAll": "全部折叠",
"ungrouped": "未分组",
"clearAll": "清除全部",
"filterByBillableStatus": "按计费状态筛选",
"searchByMember": "按成员搜索",
"members": "成员",
"utilization": "利用率",
"totalTimeLogged": "总记录时间",
"acrossAllTeamMembers": "跨所有团队成员",
"expectedCapacity": "预期容量",
"basedOnWorkingSchedule": "基于工作时间表",
"teamUtilization": "团队利用率",
"targetRange": "目标范围",
"variance": "差异",
"overCapacity": "超出容量",
"underCapacity": "容量不足",
"considerWorkloadRedistribution": "考虑工作负载重新分配",
"capacityAvailableForNewProjects": "可用于新项目的容量",
"optimal": "最佳",
"underUtilized": "利用率不足",
"overUtilized": "过度利用"
}

View File

@@ -8,11 +8,18 @@ import teamOwnerOrAdminValidator from "../../middlewares/validators/team-owner-o
const adminCenterApiRouter = express.Router();
// overview
adminCenterApiRouter.get("/settings", teamOwnerOrAdminValidator, safeControllerFunction(AdminCenterController.getAdminCenterSettings));
adminCenterApiRouter.get("/organization", teamOwnerOrAdminValidator, safeControllerFunction(AdminCenterController.getOrganizationDetails));
adminCenterApiRouter.get("/organization/admins", teamOwnerOrAdminValidator, safeControllerFunction(AdminCenterController.getOrganizationAdmins));
adminCenterApiRouter.put("/organization", teamOwnerOrAdminValidator, organizationSettingsValidator, safeControllerFunction(AdminCenterController.updateOrganizationName));
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));

View File

@@ -0,0 +1,29 @@
import express from "express";
import HolidayController from "../../controllers/holiday-controller";
import safeControllerFunction from "../../shared/safe-controller-function";
import teamOwnerOrAdminValidator from "../../middlewares/validators/team-owner-or-admin-validator";
import idParamValidator from "../../middlewares/validators/id-param-validator";
const holidayApiRouter = express.Router();
// Holiday types
holidayApiRouter.get("/types", safeControllerFunction(HolidayController.getHolidayTypes));
// Organization holidays
holidayApiRouter.get("/organization", teamOwnerOrAdminValidator, safeControllerFunction(HolidayController.getOrganizationHolidays));
holidayApiRouter.post("/organization", teamOwnerOrAdminValidator, safeControllerFunction(HolidayController.createOrganizationHoliday));
holidayApiRouter.put("/organization/:id", teamOwnerOrAdminValidator, idParamValidator, safeControllerFunction(HolidayController.updateOrganizationHoliday));
holidayApiRouter.delete("/organization/:id", teamOwnerOrAdminValidator, idParamValidator, safeControllerFunction(HolidayController.deleteOrganizationHoliday));
// Country holidays
holidayApiRouter.get("/countries", safeControllerFunction(HolidayController.getAvailableCountries));
holidayApiRouter.get("/countries/:country_code", safeControllerFunction(HolidayController.getCountryHolidays));
holidayApiRouter.post("/import", teamOwnerOrAdminValidator, safeControllerFunction(HolidayController.importCountryHolidays));
// Calendar view
holidayApiRouter.get("/calendar", teamOwnerOrAdminValidator, safeControllerFunction(HolidayController.getHolidayCalendar));
// Populate holidays
holidayApiRouter.post("/populate", teamOwnerOrAdminValidator, safeControllerFunction(HolidayController.populateCountryHolidays));
export default holidayApiRouter;

View File

@@ -59,6 +59,10 @@ import taskDependenciesApiRouter from "./task-dependencies-api-router";
import taskRecurringApiRouter from "./task-recurring-api-router";
import customColumnsApiRouter from "./custom-columns-api-router";
import projectFinanceApiRouter from "./project-finance-api-router";
import projectRatecardApiRouter from "./project-ratecard-api-router";
import ratecardApiRouter from "./ratecard-api-router";
import holidayApiRouter from "./holiday-api-router";
import userActivityLogsApiRouter from "./user-activity-logs-api-router";
const api = express.Router();
@@ -121,5 +125,13 @@ api.use("/task-recurring", taskRecurringApiRouter);
api.use("/custom-columns", customColumnsApiRouter);
api.use("/project-finance", projectFinanceApiRouter);
api.use("/project-ratecard", projectRatecardApiRouter);
api.use("/ratecard", ratecardApiRouter);
api.use("/holidays", holidayApiRouter);
api.use("/logs", userActivityLogsApiRouter);
export default api;

View File

@@ -0,0 +1,50 @@
import express from "express";
import ProjectfinanceController from "../../controllers/project-finance-controller";
import idParamValidator from "../../middlewares/validators/id-param-validator";
import safeControllerFunction from "../../shared/safe-controller-function";
const projectFinanceApiRouter = express.Router();
projectFinanceApiRouter.get(
"/project/:project_id/tasks",
safeControllerFunction(ProjectfinanceController.getTasks)
);
projectFinanceApiRouter.get(
"/project/:project_id/tasks/:parent_task_id/subtasks",
safeControllerFunction(ProjectfinanceController.getSubTasks)
);
projectFinanceApiRouter.get(
"/task/:id/breakdown",
idParamValidator,
safeControllerFunction(ProjectfinanceController.getTaskBreakdown)
);
projectFinanceApiRouter.put(
"/task/:task_id/fixed-cost",
safeControllerFunction(ProjectfinanceController.updateTaskFixedCost)
);
projectFinanceApiRouter.put(
"/project/:project_id/currency",
safeControllerFunction(ProjectfinanceController.updateProjectCurrency)
);
projectFinanceApiRouter.put(
"/project/:project_id/budget",
safeControllerFunction(ProjectfinanceController.updateProjectBudget)
);
projectFinanceApiRouter.put(
"/project/:project_id/calculation-method",
safeControllerFunction(
ProjectfinanceController.updateProjectCalculationMethod
)
);
projectFinanceApiRouter.put(
"/rate-card-role/:rate_card_role_id/man-day-rate",
safeControllerFunction(ProjectfinanceController.updateRateCardManDayRate)
);
projectFinanceApiRouter.get(
"/project/:project_id/export",
safeControllerFunction(ProjectfinanceController.exportFinanceData)
);
export default projectFinanceApiRouter;

View File

@@ -0,0 +1,19 @@
import express from "express";
import ProjectRateCardController from "../../controllers/project-ratecard-controller";
import idParamValidator from "../../middlewares/validators/id-param-validator";
import safeControllerFunction from "../../shared/safe-controller-function";
import projectManagerValidator from "../../middlewares/validators/project-manager-validator";
const projectRatecardApiRouter = express.Router();
projectRatecardApiRouter.post("/", projectManagerValidator, safeControllerFunction(ProjectRateCardController.createMany));
projectRatecardApiRouter.post("/create-project-rate-card-role",projectManagerValidator,safeControllerFunction(ProjectRateCardController.createOne));
projectRatecardApiRouter.get("/project/:project_id",safeControllerFunction(ProjectRateCardController.getByProjectId));
projectRatecardApiRouter.get("/:id",idParamValidator,safeControllerFunction(ProjectRateCardController.getById));
projectRatecardApiRouter.put("/:id",idParamValidator,safeControllerFunction(ProjectRateCardController.updateById));
projectRatecardApiRouter.put("/project/:project_id",safeControllerFunction(ProjectRateCardController.updateByProjectId));
projectRatecardApiRouter.put("/project/:project_id/members/:id/rate-card-role",idParamValidator,projectManagerValidator,safeControllerFunction( ProjectRateCardController.updateProjectMemberByProjectIdAndMemberId));
projectRatecardApiRouter.delete("/:id",idParamValidator,safeControllerFunction(ProjectRateCardController.deleteById));
projectRatecardApiRouter.delete("/project/:project_id",safeControllerFunction(ProjectRateCardController.deleteByProjectId));
export default projectRatecardApiRouter;

View File

@@ -0,0 +1,13 @@
import express from "express";
import RatecardController from "../../controllers/ratecard-controller";
const ratecardApiRouter = express.Router();
ratecardApiRouter.post("/", RatecardController.create);
ratecardApiRouter.get("/", RatecardController.get);
ratecardApiRouter.get("/:id", RatecardController.getById);
ratecardApiRouter.put("/:id", RatecardController.update);
ratecardApiRouter.delete("/:id", RatecardController.deleteById);
export default ratecardApiRouter;

View File

@@ -0,0 +1,346 @@
/**
* Script to update Sri Lankan holidays JSON file
*
* This script can be used to:
* 1. Add holidays for new years
* 2. Update existing holiday data
* 3. Generate SQL migration files
*
* Usage:
* node update-sri-lankan-holidays.js --year 2029 --add-poya-days
* node update-sri-lankan-holidays.js --generate-sql --year 2029
*/
const fs = require("fs");
const path = require("path");
class SriLankanHolidayUpdater {
constructor() {
this.filePath = path.join(__dirname, "..", "data", "sri-lankan-holidays.json");
this.holidayData = this.loadHolidayData();
}
loadHolidayData() {
try {
const content = fs.readFileSync(this.filePath, "utf8");
return JSON.parse(content);
} catch (error) {
console.error("Error loading holiday data:", error);
return { fixed_holidays: [] };
}
}
saveHolidayData() {
try {
fs.writeFileSync(this.filePath, JSON.stringify(this.holidayData, null, 2));
console.log("Holiday data saved successfully");
} catch (error) {
console.error("Error saving holiday data:", error);
}
}
// Generate fixed holidays for a year
generateFixedHolidays(year) {
return this.holidayData.fixed_holidays.map(holiday => ({
name: holiday.name,
date: `${year}-${String(holiday.month).padStart(2, "0")}-${String(holiday.day).padStart(2, "0")}`,
type: holiday.type,
description: holiday.description,
is_recurring: true
}));
}
// Add a new year with basic holidays
addYear(year) {
if (this.holidayData[year.toString()]) {
console.log(`Year ${year} already exists`);
return;
}
const fixedHolidays = this.generateFixedHolidays(year);
this.holidayData[year.toString()] = fixedHolidays;
console.log(`Added basic holidays for year ${year}`);
console.log("Note: You need to manually add Poya days, Good Friday, Eid, and Deepavali dates");
}
// Generate SQL for a specific year
generateSQL(year) {
const yearData = this.holidayData[year.toString()];
if (!yearData) {
console.log(`No data found for year ${year}`);
return;
}
const values = yearData.map(holiday => {
return `('LK', '${holiday.name.replace(/'/g, "''")}', '${holiday.description.replace(/'/g, "''")}', '${holiday.date}', ${holiday.is_recurring})`;
}).join(",\n ");
const sql = `-- ${year} Sri Lankan holidays
INSERT INTO country_holidays (country_code, name, description, date, is_recurring)
VALUES
${values}
ON CONFLICT (country_code, name, date) DO NOTHING;`;
console.log(sql);
return sql;
}
// List all available years
listYears() {
const years = Object.keys(this.holidayData)
.filter(key => key !== "fixed_holidays" && key !== "_metadata" && key !== "variable_holidays_info")
.sort();
console.log("📅 Available years:", years.join(", "));
console.log("");
years.forEach(year => {
const count = this.holidayData[year].length;
const source = this.holidayData._metadata?.sources?.[year] || "Unknown source";
console.log(` ${year}: ${count} holidays - ${source}`);
});
console.log("");
console.log("⚠️ IMPORTANT: Only 2025 data has been verified from official sources.");
console.log(" Future years should be verified before production use.");
console.log("");
console.log("📖 See docs/sri-lankan-holiday-update-process.md for verification process");
}
// Validate holiday data
validate() {
const issues = [];
Object.keys(this.holidayData).forEach(year => {
if (year === "fixed_holidays") return;
const holidays = this.holidayData[year];
holidays.forEach((holiday, index) => {
// Check required fields
if (!holiday.name) issues.push(`${year}[${index}]: Missing name`);
if (!holiday.date) issues.push(`${year}[${index}]: Missing date`);
if (!holiday.description) issues.push(`${year}[${index}]: Missing description`);
// Check date format
if (holiday.date && !/^\d{4}-\d{2}-\d{2}$/.test(holiday.date)) {
issues.push(`${year}[${index}]: Invalid date format: ${holiday.date}`);
}
// Check if date matches the year
if (holiday.date && !holiday.date.startsWith(year)) {
issues.push(`${year}[${index}]: Date ${holiday.date} doesn't match year ${year}`);
}
});
});
if (issues.length === 0) {
console.log("✅ All holiday data is valid");
} else {
console.log("❌ Found issues:");
issues.forEach(issue => console.log(` ${issue}`));
}
return issues.length === 0;
}
// Template for adding Poya days (user needs to provide actual dates)
getPoyaDayTemplate(year) {
const poyaDays = [
{ name: "Duruthu", description: "Commemorates the first visit of Buddha to Sri Lanka" },
{ name: "Navam", description: "Commemorates the appointment of Sariputta and Moggallana as Buddha's chief disciples" },
{ name: "Medin", description: "Commemorates Buddha's first visit to his father's palace after enlightenment" },
{ name: "Bak", description: "Commemorates Buddha's second visit to Sri Lanka" },
{ name: "Vesak", description: "Most sacred day for Buddhists - commemorates birth, enlightenment and passing of Buddha" },
{ name: "Poson", description: "Commemorates the introduction of Buddhism to Sri Lanka by Arahat Mahinda" },
{ name: "Esala", description: "Commemorates Buddha's first sermon and the arrival of the Sacred Tooth Relic" },
{ name: "Nikini", description: "Commemorates the first Buddhist council" },
{ name: "Binara", description: "Commemorates Buddha's visit to heaven to preach to his mother" },
{ name: "Vap", description: "Marks the end of Buddhist Lent and Buddha's return from heaven" },
{ name: "Il", description: "Commemorates Buddha's ordination of sixty disciples" },
{ name: "Unduvap", description: "Commemorates the arrival of Sanghamitta Theri with the Sacred Bo sapling" }
];
console.log(`\n=== TEMPLATE FOR ${year} SRI LANKAN HOLIDAYS ===\n`);
console.log(`// Fixed holidays (same every year)`);
console.log(`{
"name": "Independence Day",
"date": "${year}-02-04",
"type": "Public",
"description": "Commemorates the independence of Sri Lanka from British rule in 1948",
"is_recurring": true
},
{
"name": "May Day",
"date": "${year}-05-01",
"type": "Public",
"description": "International Workers' Day",
"is_recurring": true
},
{
"name": "Christmas Day",
"date": "${year}-12-25",
"type": "Public",
"description": "Christian celebration of the birth of Jesus Christ",
"is_recurring": true
},`);
console.log(`\n// Variable holidays (need to verify dates)`);
console.log(`{
"name": "Sinhala and Tamil New Year Day",
"date": "${year}-04-??", // Usually April 13, but can be 12 or 14
"type": "Public",
"description": "Traditional New Year celebrated by Sinhalese and Tamil communities",
"is_recurring": false
},
{
"name": "Day after Sinhala and Tamil New Year",
"date": "${year}-04-??", // Day after New Year Day
"type": "Public",
"description": "Second day of traditional New Year celebrations",
"is_recurring": false
},`);
console.log(`\n// Poya Days (lunar calendar - need to find actual dates):`);
poyaDays.forEach((poya, index) => {
console.log(`{
"name": "${poya.name} Full Moon Poya Day",
"date": "${year}-??-??",
"type": "Poya",
"description": "${poya.description}",
"is_recurring": false
},`);
});
console.log(`\n// Religious holidays (need to verify dates)`);
console.log(`{
"name": "Good Friday",
"date": "${year}-??-??", // Based on Easter calculation
"type": "Public",
"description": "Christian commemoration of the crucifixion of Jesus Christ",
"is_recurring": false
},
{
"name": "Eid al-Fitr",
"date": "${year}-??-??", // Islamic lunar calendar
"type": "Public",
"description": "Festival marking the end of Ramadan",
"is_recurring": false
},
{
"name": "Eid al-Adha",
"date": "${year}-??-??", // Islamic lunar calendar
"type": "Public",
"description": "Islamic festival of sacrifice",
"is_recurring": false
},
{
"name": "Deepavali",
"date": "${year}-??-??", // Hindu lunar calendar
"type": "Public",
"description": "Hindu Festival of Lights",
"is_recurring": false
}`);
console.log(`\n=== NOTES ===`);
console.log(`1. Sinhala & Tamil New Year: Check official gazette or Department of Meteorology`);
console.log(`2. Poya Days: Check Buddhist calendar or astronomical calculations`);
console.log(`3. Good Friday: Calculate based on Easter (Western calendar)`);
console.log(`4. Islamic holidays: Check Islamic calendar or local mosque announcements`);
console.log(`5. Deepavali: Check Hindu calendar or Tamil cultural organizations`);
console.log(`\nReliable sources:`);
console.log(`- Sri Lanka Department of Meteorology`);
console.log(`- Central Bank of Sri Lanka holiday circulars`);
console.log(`- Ministry of Public Administration gazette notifications`);
}
// Show information about variable holidays
showVariableHolidayInfo() {
console.log(`\n=== SRI LANKAN VARIABLE HOLIDAYS INFO ===\n`);
console.log(`🗓️ SINHALA & TAMIL NEW YEAR:`);
console.log(` • Usually April 13-14, but can vary to April 12-13 or April 14-15`);
console.log(` • Based on astrological calculations`);
console.log(` • Check: Department of Meteorology or official gazette\n`);
console.log(`🌕 POYA DAYS (12 per year):`);
console.log(` • Follow Buddhist lunar calendar`);
console.log(` • Dates change every year`);
console.log(` • Usually fall on full moon days\n`);
console.log(`🕊️ GOOD FRIDAY:`);
console.log(` • Based on Easter calculation (Western Christianity)`);
console.log(` • First Sunday after first full moon after March 21\n`);
console.log(`☪️ ISLAMIC HOLIDAYS (Eid al-Fitr, Eid al-Adha):`);
console.log(` • Follow Islamic lunar calendar (Hijri)`);
console.log(` • Dates shift ~11 days earlier each year`);
console.log(` • Depend on moon sighting\n`);
console.log(`🪔 DEEPAVALI:`);
console.log(` • Hindu Festival of Lights`);
console.log(` • Based on Hindu lunar calendar`);
console.log(` • Usually October/November\n`);
console.log(`📋 RECOMMENDED WORKFLOW:`);
console.log(` 1. Use --add-year to create basic structure`);
console.log(` 2. Research accurate dates from official sources`);
console.log(` 3. Manually edit the JSON file with correct dates`);
console.log(` 4. Use --validate to check the data`);
console.log(` 5. Use --generate-sql to create migration`);
}
}
// CLI interface
if (require.main === module) {
const updater = new SriLankanHolidayUpdater();
const args = process.argv.slice(2);
if (args.includes("--list")) {
updater.listYears();
} else if (args.includes("--validate")) {
updater.validate();
} else if (args.includes("--add-year")) {
const yearIndex = args.indexOf("--add-year") + 1;
const year = parseInt(args[yearIndex]);
if (year) {
updater.addYear(year);
updater.saveHolidayData();
} else {
console.log("Please provide a year: --add-year 2029");
}
} else if (args.includes("--generate-sql")) {
const yearIndex = args.indexOf("--generate-sql") + 1;
const year = parseInt(args[yearIndex]);
if (year) {
updater.generateSQL(year);
} else {
console.log("Please provide a year: --generate-sql 2029");
}
} else if (args.includes("--poya-template")) {
const yearIndex = args.indexOf("--poya-template") + 1;
const year = parseInt(args[yearIndex]);
if (year) {
updater.getPoyaDayTemplate(year);
} else {
console.log("Please provide a year: --poya-template 2029");
}
} else if (args.includes("--holiday-info")) {
updater.showVariableHolidayInfo();
} else {
console.log(`
Sri Lankan Holiday Updater
Usage:
node update-sri-lankan-holidays.js --list # List all years
node update-sri-lankan-holidays.js --validate # Validate data
node update-sri-lankan-holidays.js --holiday-info # Show variable holiday info
node update-sri-lankan-holidays.js --add-year 2029 # Add basic holidays for year
node update-sri-lankan-holidays.js --generate-sql 2029 # Generate SQL for year
node update-sri-lankan-holidays.js --poya-template 2029 # Show complete template for year
`);
}
}
module.exports = SriLankanHolidayUpdater;

View File

@@ -0,0 +1,225 @@
import moment from "moment";
import db from "../config/db";
import * as fs from "fs";
import * as path from "path";
interface HolidayData {
name: string;
date: string;
description: string;
is_recurring: boolean;
}
export class HolidayDataProvider {
/**
* Fetch Sri Lankan holidays from external API or database
* This provides a centralized way to get accurate holiday data
*/
public static async getSriLankanHolidays(year: number): Promise<HolidayData[]> {
try {
// First, check if we have data in the database for this year
const dbHolidays = await this.getHolidaysFromDatabase("LK", year);
if (dbHolidays.length > 0) {
return dbHolidays;
}
// Load holidays from JSON file
const holidaysFromFile = this.getHolidaysFromFile(year);
if (holidaysFromFile.length > 0) {
// Store in database for future use
await this.storeHolidaysInDatabase("LK", holidaysFromFile);
return holidaysFromFile;
}
// If specific year not found, generate from fixed holidays + fallback
return this.generateHolidaysFromFixed(year);
} catch (error) {
console.error("Error fetching Sri Lankan holidays:", error);
// Fallback to basic holidays
return this.getBasicSriLankanHolidays(year);
}
}
private static async getHolidaysFromDatabase(countryCode: string, year: number): Promise<HolidayData[]> {
const query = `
SELECT name, date, description, is_recurring
FROM country_holidays
WHERE country_code = $1
AND EXTRACT(YEAR FROM date) = $2
ORDER BY date
`;
const result = await db.query(query, [countryCode, year]);
return result.rows.map(row => ({
name: row.name,
date: moment(row.date).format("YYYY-MM-DD"),
description: row.description,
is_recurring: row.is_recurring
}));
}
private static async storeHolidaysInDatabase(countryCode: string, holidays: HolidayData[]): Promise<void> {
for (const holiday of holidays) {
const query = `
INSERT INTO country_holidays (country_code, name, description, date, is_recurring)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (country_code, name, date) DO NOTHING
`;
await db.query(query, [
countryCode,
holiday.name,
holiday.description,
holiday.date,
holiday.is_recurring
]);
}
}
private static getHolidaysFromFile(year: number): HolidayData[] {
try {
const filePath = path.join(__dirname, "..", "data", "sri-lankan-holidays.json");
const fileContent = fs.readFileSync(filePath, "utf8");
const holidayData = JSON.parse(fileContent);
// Check if we have data for the specific year
if (holidayData[year.toString()]) {
return holidayData[year.toString()].map((holiday: any) => ({
name: holiday.name,
date: holiday.date,
description: holiday.description,
is_recurring: holiday.is_recurring
}));
}
return [];
} catch (error) {
console.error("Error reading holidays from file:", error);
return [];
}
}
private static generateHolidaysFromFixed(year: number): HolidayData[] {
try {
const filePath = path.join(__dirname, "..", "data", "sri-lankan-holidays.json");
const fileContent = fs.readFileSync(filePath, "utf8");
const holidayData = JSON.parse(fileContent);
// Generate holidays from fixed_holidays for the given year
if (holidayData.fixed_holidays) {
const fixedHolidays = holidayData.fixed_holidays.map((holiday: any) => ({
name: holiday.name,
date: `${year}-${String(holiday.month).padStart(2, "0")}-${String(holiday.day).padStart(2, "0")}`,
description: holiday.description,
is_recurring: true
}));
// Log warning about incomplete data
console.warn(`⚠️ Using only fixed holidays for Sri Lankan year ${year}. Poya days and religious holidays not included.`);
console.warn(` To add complete data, see: docs/sri-lankan-holiday-update-process.md`);
return fixedHolidays;
}
return this.getBasicSriLankanHolidays(year);
} catch (error) {
console.error("Error generating holidays from fixed data:", error);
return this.getBasicSriLankanHolidays(year);
}
}
private static getSriLankan2025Holidays(): HolidayData[] {
// Import the 2025 data we already have
return [
// Poya Days
{ name: "Duruthu Full Moon Poya Day", date: "2025-01-13", description: "Commemorates the first visit of Buddha to Sri Lanka", is_recurring: false },
{ name: "Navam Full Moon Poya Day", date: "2025-02-12", description: "Commemorates the appointment of Sariputta and Moggallana as Buddha's chief disciples", is_recurring: false },
{ name: "Medin Full Moon Poya Day", date: "2025-03-14", description: "Commemorates Buddha's first visit to his father's palace after enlightenment", is_recurring: false },
{ name: "Bak Full Moon Poya Day", date: "2025-04-12", description: "Commemorates Buddha's second visit to Sri Lanka", is_recurring: false },
{ name: "Vesak Full Moon Poya Day", date: "2025-05-12", description: "Most sacred day for Buddhists", is_recurring: false },
{ name: "Poson Full Moon Poya Day", date: "2025-06-11", description: "Commemorates the introduction of Buddhism to Sri Lanka", is_recurring: false },
{ name: "Esala Full Moon Poya Day", date: "2025-07-10", description: "Commemorates Buddha's first sermon", is_recurring: false },
{ name: "Nikini Full Moon Poya Day", date: "2025-08-09", description: "Commemorates the first Buddhist council", is_recurring: false },
{ name: "Binara Full Moon Poya Day", date: "2025-09-07", description: "Commemorates Buddha's visit to heaven", is_recurring: false },
{ name: "Vap Full Moon Poya Day", date: "2025-10-07", description: "Marks the end of Buddhist Lent", is_recurring: false },
{ name: "Il Full Moon Poya Day", date: "2025-11-05", description: "Commemorates Buddha's ordination of sixty disciples", is_recurring: false },
{ name: "Unduvap Full Moon Poya Day", date: "2025-12-04", description: "Commemorates the arrival of Sanghamitta Theri", is_recurring: false },
// Fixed holidays
{ name: "Independence Day", date: "2025-02-04", description: "Sri Lankan Independence Day", is_recurring: true },
{ name: "Sinhala and Tamil New Year Day", date: "2025-04-13", description: "Traditional New Year", is_recurring: true },
{ name: "Day after Sinhala and Tamil New Year", date: "2025-04-14", description: "New Year celebrations", is_recurring: true },
{ name: "May Day", date: "2025-05-01", description: "International Workers' Day", is_recurring: true },
{ name: "Christmas Day", date: "2025-12-25", description: "Christmas", is_recurring: true },
// Variable holidays
{ name: "Good Friday", date: "2025-04-18", description: "Christian holiday", is_recurring: false },
{ name: "Day after Vesak Full Moon Poya Day", date: "2025-05-13", description: "Vesak celebrations", is_recurring: false },
{ name: "Eid al-Fitr", date: "2025-03-31", description: "End of Ramadan", is_recurring: false },
{ name: "Deepavali", date: "2025-10-20", description: "Hindu Festival of Lights", is_recurring: false }
];
}
private static generateApproximateHolidays(year: number): HolidayData[] {
// This is a fallback method that generates approximate dates
// In production, you should use accurate astronomical calculations or external data
const holidays: HolidayData[] = [];
// Fixed holidays
holidays.push(
{ name: "Independence Day", date: `${year}-02-04`, description: "Sri Lankan Independence Day", is_recurring: true },
{ name: "Sinhala and Tamil New Year Day", date: `${year}-04-13`, description: "Traditional New Year", is_recurring: true },
{ name: "Day after Sinhala and Tamil New Year", date: `${year}-04-14`, description: "New Year celebrations", is_recurring: true },
{ name: "May Day", date: `${year}-05-01`, description: "International Workers' Day", is_recurring: true },
{ name: "Christmas Day", date: `${year}-12-25`, description: "Christmas", is_recurring: true }
);
// Note: For Poya days and other religious holidays, you would need
// astronomical calculations or reliable external data sources
return holidays;
}
private static getBasicSriLankanHolidays(year: number): HolidayData[] {
// Return only the fixed holidays that don't change
return [
{ name: "Independence Day", date: `${year}-02-04`, description: "Sri Lankan Independence Day", is_recurring: true },
{ name: "Sinhala and Tamil New Year Day", date: `${year}-04-13`, description: "Traditional New Year", is_recurring: true },
{ name: "Day after Sinhala and Tamil New Year", date: `${year}-04-14`, description: "New Year celebrations", is_recurring: true },
{ name: "May Day", date: `${year}-05-01`, description: "International Workers' Day", is_recurring: true },
{ name: "Christmas Day", date: `${year}-12-25`, description: "Christmas", is_recurring: true }
];
}
/**
* Update organization holidays for a specific year
* This can be called periodically to ensure holiday data is up to date
*/
public static async updateOrganizationHolidays(organizationId: string, countryCode: string, year: number): Promise<void> {
if (countryCode !== "LK") return;
const holidays = await this.getSriLankanHolidays(year);
// Get default holiday type
const typeQuery = `SELECT id FROM holiday_types WHERE name = 'Public Holiday' LIMIT 1`;
const typeResult = await db.query(typeQuery);
const holidayTypeId = typeResult.rows[0]?.id;
if (!holidayTypeId) return;
// Insert holidays into organization_holidays
for (const holiday of holidays) {
const query = `
INSERT INTO organization_holidays (organization_id, holiday_type_id, name, description, date, is_recurring)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (organization_id, date) DO NOTHING
`;
await db.query(query, [
organizationId,
holidayTypeId,
holiday.name,
holiday.description,
holiday.date,
holiday.is_recurring
]);
}
}
}

View File

@@ -0,0 +1,221 @@
import moment from "moment";
interface SriLankanHoliday {
name: string;
date: string;
type: "Public" | "Bank" | "Mercantile" | "Poya";
description: string;
is_recurring: boolean;
is_poya: boolean;
country_code: string;
color_code: string;
}
export class SriLankanHolidayService {
private static readonly COUNTRY_CODE = "LK";
// Fixed recurring holidays (same date every year)
private static readonly FIXED_HOLIDAYS = [
{
name: "Independence Day",
month: 2,
day: 4,
type: "Public" as const,
description: "Commemorates the independence of Sri Lanka from British rule in 1948",
color_code: "#DC143C"
},
{
name: "Sinhala and Tamil New Year Day",
month: 4,
day: 13,
type: "Public" as const,
description: "Traditional New Year celebrated by Sinhalese and Tamil communities",
color_code: "#DC143C"
},
{
name: "Day after Sinhala and Tamil New Year",
month: 4,
day: 14,
type: "Public" as const,
description: "Second day of traditional New Year celebrations",
color_code: "#DC143C"
},
{
name: "May Day",
month: 5,
day: 1,
type: "Public" as const,
description: "International Workers' Day",
color_code: "#DC143C"
},
{
name: "Christmas Day",
month: 12,
day: 25,
type: "Public" as const,
description: "Christian celebration of the birth of Jesus Christ",
color_code: "#DC143C"
}
];
// Poya days names (in order of Buddhist months)
private static readonly POYA_NAMES = [
{ name: "Duruthu", description: "Commemorates the first visit of Buddha to Sri Lanka" },
{ name: "Navam", description: "Commemorates the appointment of Sariputta and Moggallana as Buddha's chief disciples" },
{ name: "Medin", description: "Commemorates Buddha's first visit to his father's palace after enlightenment" },
{ name: "Bak", description: "Commemorates Buddha's second visit to Sri Lanka" },
{ name: "Vesak", description: "Most sacred day for Buddhists - commemorates birth, enlightenment and passing of Buddha" },
{ name: "Poson", description: "Commemorates the introduction of Buddhism to Sri Lanka by Arahat Mahinda" },
{ name: "Esala", description: "Commemorates Buddha's first sermon and the arrival of the Sacred Tooth Relic" },
{ name: "Nikini", description: "Commemorates the first Buddhist council" },
{ name: "Binara", description: "Commemorates Buddha's visit to heaven to preach to his mother" },
{ name: "Vap", description: "Marks the end of Buddhist Lent and Buddha's return from heaven" },
{ name: "Il", description: "Commemorates Buddha's ordination of sixty disciples" },
{ name: "Unduvap", description: "Commemorates the arrival of Sanghamitta Theri with the Sacred Bo sapling" }
];
/**
* Calculate Poya days for a given year
* Note: This is a simplified calculation. For production use, consider using
* astronomical calculations or an API that provides accurate lunar calendar dates
*/
private static calculatePoyaDays(year: number): SriLankanHoliday[] {
const poyaDays: SriLankanHoliday[] = [];
// This is a simplified approach - in reality, you would need astronomical calculations
// or use a service that provides accurate Buddhist lunar calendar dates
// For now, we'll use approximate dates based on lunar month cycles
// Starting from a known Vesak date (May full moon)
// and calculating other Poya days based on lunar month intervals
const baseVesakDate = this.getVesakDate(year);
for (let i = 0; i < 12; i++) {
const monthsFromVesak = i - 4; // Vesak is the 5th month
const poyaDate = moment(baseVesakDate).add(monthsFromVesak * 29.53, "days"); // Lunar month average
// Adjust to the nearest full moon date (would need proper calculation in production)
const poyaInfo = this.POYA_NAMES[i];
poyaDays.push({
name: `${poyaInfo.name} Full Moon Poya Day`,
date: poyaDate.format("YYYY-MM-DD"),
type: "Poya",
description: poyaInfo.description,
is_recurring: false,
is_poya: true,
country_code: this.COUNTRY_CODE,
color_code: "#8B4513"
});
}
return poyaDays;
}
/**
* Get approximate Vesak date for a year
* Vesak typically falls on the full moon in May
*/
private static getVesakDate(year: number): Date {
// This is a simplified calculation
// In production, use astronomical calculations or a reliable API
const may1 = new Date(year, 4, 1); // May 1st
const fullMoonDay = 15; // Approximate - would need proper lunar calculation
return new Date(year, 4, fullMoonDay);
}
/**
* Get Easter date for a year (Western/Gregorian calendar)
* Using Computus algorithm
*/
private static getEasterDate(year: number): Date {
const a = year % 19;
const b = Math.floor(year / 100);
const c = year % 100;
const d = Math.floor(b / 4);
const e = b % 4;
const f = Math.floor((b + 8) / 25);
const g = Math.floor((b - f + 1) / 3);
const h = (19 * a + b - d - g + 15) % 30;
const i = Math.floor(c / 4);
const k = c % 4;
const l = (32 + 2 * e + 2 * i - h - k) % 7;
const m = Math.floor((a + 11 * h + 22 * l) / 451);
const month = Math.floor((h + l - 7 * m + 114) / 31);
const day = ((h + l - 7 * m + 114) % 31) + 1;
return new Date(year, month - 1, day);
}
/**
* Get all Sri Lankan holidays for a given year
*/
public static getHolidaysForYear(year: number): SriLankanHoliday[] {
const holidays: SriLankanHoliday[] = [];
// Add fixed holidays
for (const holiday of this.FIXED_HOLIDAYS) {
holidays.push({
...holiday,
date: `${year}-${String(holiday.month).padStart(2, "0")}-${String(holiday.day).padStart(2, "0")}`,
is_recurring: true,
is_poya: false,
country_code: this.COUNTRY_CODE
});
}
// Add Poya days
const poyaDays = this.calculatePoyaDays(year);
holidays.push(...poyaDays);
// Add Good Friday (2 days before Easter)
const easter = this.getEasterDate(year);
const goodFriday = moment(easter).subtract(2, "days");
holidays.push({
name: "Good Friday",
date: goodFriday.format("YYYY-MM-DD"),
type: "Public",
description: "Christian commemoration of the crucifixion of Jesus Christ",
is_recurring: false,
is_poya: false,
country_code: this.COUNTRY_CODE,
color_code: "#DC143C"
});
// Add day after Vesak
const vesakDay = poyaDays.find(p => p.name.includes("Vesak"));
if (vesakDay) {
const dayAfterVesak = moment(vesakDay.date).add(1, "day");
holidays.push({
name: "Day after Vesak Full Moon Poya Day",
date: dayAfterVesak.format("YYYY-MM-DD"),
type: "Public",
description: "Additional day for Vesak celebrations",
is_recurring: false,
is_poya: false,
country_code: this.COUNTRY_CODE,
color_code: "#DC143C"
});
}
// Note: Eid and Deepavali dates would need to be calculated based on
// Islamic and Hindu calendars respectively, or fetched from an external source
return holidays.sort((a, b) => a.date.localeCompare(b.date));
}
/**
* Generate SQL insert statements for holidays
*/
public static generateSQL(year: number, tableName = "country_holidays"): string {
const holidays = this.getHolidaysForYear(year);
const values = holidays.map(holiday => {
return `('${this.COUNTRY_CODE}', '${holiday.name.replace(/'/g, "''")}', '${holiday.description.replace(/'/g, "''")}', '${holiday.date}', ${holiday.is_recurring})`;
}).join(",\n ");
return `INSERT INTO ${tableName} (country_code, name, description, date, is_recurring)
VALUES
${values}
ON CONFLICT (country_code, name, date) DO NOTHING;`;
}
}

View File

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

View File

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

View File

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

View File

@@ -4,5 +4,69 @@
"owner": "Pronari i Organizatës",
"admins": "Administruesit e Organizatës",
"contactNumber": "Shto Numrin e Kontaktit",
"edit": "Redakto"
"edit": "Redakto",
"organizationWorkingDaysAndHours": "Ditët dhe Orët e Punës së Organizatës",
"workingDays": "Ditët e Punës",
"workingHours": "Orët e Punës",
"monday": "E Hënë",
"tuesday": "E Martë",
"wednesday": "E Mërkurë",
"thursday": "E Enjte",
"friday": "E Premte",
"saturday": "E Shtunë",
"sunday": "E Dielë",
"hours": "orë",
"saveButton": "Ruaj",
"saved": "Cilësimet u ruajtën me sukses",
"errorSaving": "Gabim gjatë ruajtjes së cilësimeve",
"organizationCalculationMethod": "Metoda e Llogaritjes së Organizatës",
"calculationMethod": "Metoda e Llogaritjes",
"hourlyRates": "Normat Orërore",
"manDays": "Ditët e Njeriut",
"saveChanges": "Ruaj Ndryshimet",
"hourlyCalculationDescription": "Të gjitha kostot e projektit do të llogariten duke përdorur orët e vlerësuara × normat orërore",
"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",
"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"
}

View File

@@ -0,0 +1,33 @@
{
"settings": "Cilësimet",
"organizationWorkingDaysAndHours": "Ditët dhe Orët e Punës së Organizatës",
"workingDays": "Ditët e Punës",
"workingHours": "Orët e Punës",
"hours": "orë",
"monday": "E Hënë",
"tuesday": "E Martë",
"wednesday": "E Mërkurë",
"thursday": "E Enjte",
"friday": "E Premte",
"saturday": "E Shtunë",
"sunday": "E Dielë",
"saveButton": "Ruaj",
"saved": "Cilësimet u ruajtën me sukses",
"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"
}

View File

@@ -4,5 +4,6 @@
"teams": "Ekipet",
"billing": "Faturimi",
"projects": "Projektet",
"settings": "Cilësimet",
"adminCenter": "Qendra Administrative"
}

View File

@@ -41,7 +41,6 @@
"noSubtasks": "Pa nëndetyra",
"showSubtasks": "Shfaq nëndetyrat",
"hideSubtasks": "Fshih nëndetyrat",
"errorLoadingTasks": "Gabim gjatë ngarkimit të detyrave",
"noTasksFound": "Nuk u gjetën detyra",
"loadingFilters": "Duke ngarkuar filtra...",

View File

@@ -38,5 +38,13 @@
"createClient": "Krijo klient",
"searchInputPlaceholder": "Kërko sipas emrit ose emailit",
"hoursPerDayValidationMessage": "Orët në ditë duhet të jenë një numër midis 1 dhe 24",
"noPermission": "Nuk ka leje"
"workingDaysValidationMessage": "Ditët e punës duhet të jenë një numër pozitiv",
"manDaysValidationMessage": "Ditët e punëtorëve duhet të jenë një numër pozitiv",
"noPermission": "Nuk ka leje",
"progressSettings": "Cilësimet e Progresit",
"manualProgress": "Progresi Manual",
"manualProgressTooltip": "Lejo përditësimet manuale të progresit për detyrat pa nëndetyra",
"weightedProgress": "Progresi i Ponderuar",
"weightedProgressTooltip": "Llogarit progresin bazuar në peshat e nëndetyrave",
"timeProgress": "Progresi i Bazuar në Kohë"
}

View File

@@ -0,0 +1,114 @@
{
"financeText": "Finance",
"ratecardSingularText": "Rate Card",
"groupByText": "Group by",
"statusText": "Status",
"phaseText": "Phase",
"priorityText": "Priority",
"exportButton": "Export",
"currencyText": "Currency",
"importButton": "Import",
"filterText": "Filter",
"billableOnlyText": "Billable Only",
"nonBillableOnlyText": "Non-Billable Only",
"allTasksText": "All Tasks",
"projectBudgetOverviewText": "Project Budget Overview",
"taskColumn": "Task",
"membersColumn": "Members",
"hoursColumn": "Estimated Hours",
"manDaysColumn": "Estimated Man Days",
"actualManDaysColumn": "Actual Man Days",
"effortVarianceColumn": "Effort Variance",
"totalTimeLoggedColumn": "Total Time Logged",
"costColumn": "Actual Cost",
"estimatedCostColumn": "Estimated Cost",
"fixedCostColumn": "Fixed Cost",
"totalBudgetedCostColumn": "Total Budgeted Cost",
"totalActualCostColumn": "Total Actual Cost",
"varianceColumn": "Variance",
"totalText": "Total",
"noTasksFound": "No tasks found",
"addRoleButton": "+ Add Role",
"ratecardImportantNotice": "* This rate card is generated based on the company's standard job titles and rates. However, you have the flexibility to modify it according to the project. These changes will not impact the organization's standard job titles and rates.",
"saveButton": "Save",
"jobTitleColumn": "Job Title",
"ratePerHourColumn": "Rate per hour",
"ratePerManDayColumn": "Tarifa për ditë-njeri",
"calculationMethodText": "Calculation Method",
"hourlyRatesText": "Hourly Rates",
"manDaysText": "Man Days",
"hoursPerDayText": "Hours per Day",
"ratecardPluralText": "Rate Cards",
"labourHoursColumn": "Labour Hours",
"actions": "Actions",
"selectJobTitle": "Select Job Title",
"ratecardsPluralText": "Rate Card Templates",
"deleteConfirm": "Are you sure ?",
"yes": "Yes",
"no": "No",
"alreadyImportedRateCardMessage": "A rate card has already been imported. Clear all imported rate cards to add a new one.",
"budgetOverviewTooltips": {
"manualBudget": "Manual project budget amount set by project manager",
"totalActualCost": "Total actual cost including fixed costs",
"variance": "Difference between manual budget and actual cost",
"utilization": "Percentage of manual budget utilized",
"estimatedHours": "Total estimated hours from all tasks",
"fixedCosts": "Total fixed costs from all tasks",
"timeBasedCost": "Actual cost from time tracking (excluding fixed costs)",
"remainingBudget": "Remaining budget amount"
},
"budgetModal": {
"title": "Edit Project Budget",
"description": "Set a manual budget for this project. This budget will be used for all financial calculations and should include both time-based costs and fixed costs.",
"placeholder": "Enter budget amount",
"saveButton": "Save",
"cancelButton": "Cancel"
},
"budgetStatistics": {
"manualBudget": "Manual Budget",
"totalActualCost": "Total Actual Cost",
"variance": "Variance",
"budgetUtilization": "Budget Utilization",
"estimatedHours": "Estimated Hours",
"fixedCosts": "Fixed Costs",
"timeBasedCost": "Time-based Cost",
"remainingBudget": "Remaining Budget",
"noManualBudgetSet": "(No Manual Budget Set)"
},
"budgetSettingsDrawer": {
"title": "Project Budget Settings",
"budgetConfiguration": "Budget Configuration",
"projectBudget": "Project Budget",
"projectBudgetTooltip": "Total budget allocated for this project",
"currency": "Currency",
"costCalculationMethod": "Cost Calculation Method",
"calculationMethod": "Calculation Method",
"workingHoursPerDay": "Working Hours per Day",
"workingHoursPerDayTooltip": "Number of working hours in a day for man-day calculations",
"hourlyCalculationInfo": "Costs will be calculated using estimated hours × hourly rates",
"manDaysCalculationInfo": "Costs will be calculated using estimated man days × daily rates",
"importantNotes": "Important Notes",
"calculationMethodChangeNote": "• Changing the calculation method will affect how costs are calculated for all tasks in this project",
"immediateEffectNote": "• Changes take effect immediately and will recalculate all project totals",
"projectWideNote": "• Budget settings apply to the entire project and all its tasks",
"cancel": "Cancel",
"saveChanges": "Save Changes",
"budgetSettingsUpdated": "Budget settings updated successfully",
"budgetSettingsUpdateFailed": "Failed to update budget settings"
},
"columnTooltips": {
"hours": "Total estimated hours for all tasks. Calculated from task time estimates.",
"manDays": "Total estimated man days for all tasks. Based on {{hoursPerDay}} hours per working day.",
"actualManDays": "Actual man days spent based on time logged. Calculated as: Total Time Logged ÷ {{hoursPerDay}} hours per day.",
"effortVariance": "Difference between estimated and actual man days. Positive values indicate over-estimation, negative values indicate under-estimation.",
"totalTimeLogged": "Total time actually logged by team members across all tasks.",
"estimatedCostHourly": "Estimated cost calculated as: Estimated Hours × Hourly Rates for assigned team members.",
"estimatedCostManDays": "Estimated cost calculated as: Estimated Man Days × Daily Rates for assigned team members.",
"actualCost": "Actual cost based on time logged. Calculated as: Time Logged × Hourly Rates for team members.",
"fixedCost": "Fixed costs that don't depend on time spent. Added manually per task.",
"totalBudgetHourly": "Total budgeted cost including estimated cost (Hours × Hourly Rates) + Fixed Costs.",
"totalBudgetManDays": "Total budgeted cost including estimated cost (Man Days × Daily Rates) + Fixed Costs.",
"totalActual": "Total actual cost including time-based cost + Fixed Costs.",
"variance": "Cost variance: Total Budgeted Costs - Total Actual Cost. Positive values indicate under-budget, negative values indicate over-budget."
}
}

View File

@@ -10,5 +10,6 @@
"error": "Gabim në ngarkimin e projektit",
"pinnedTab": "E fiksuar si tab i parazgjedhur",
"pinTab": "Fikso si tab i parazgjedhur",
"unpinTab": "Hiqe fiksimin e tab-it të parazgjedhur"
"unpinTab": "Hiqe fiksimin e tab-it të parazgjedhur",
"finance": "Finance"
}

View File

@@ -0,0 +1,55 @@
{
"nameColumn": "Emri",
"createdColumn": "Krijuar",
"noProjectsAvailable": "Nuk ka projekte të disponueshme",
"deleteConfirmationTitle": "Jeni i sigurt që doni të fshini këtë rate card?",
"deleteConfirmationOk": "Po, fshij",
"deleteConfirmationCancel": "Anulo",
"searchPlaceholder": "Kërko rate cards sipas emrit",
"createRatecard": "Krijo Rate Card",
"editTooltip": "Redakto rate card",
"deleteTooltip": "Fshi rate card",
"fetchError": "Dështoi të merret rate card",
"createError": "Dështoi të krijohet rate card",
"deleteSuccess": "Rate card u fshi me sukses",
"deleteError": "Dështoi të fshihet rate card",
"jobTitleColumn": "Titulli i punës",
"ratePerHourColumn": "Tarifa për orë",
"ratePerDayColumn": "Tarifa për ditë",
"ratePerManDayColumn": "Tarifa për ditë-njeri",
"saveButton": "Ruaj",
"addRoleButton": "Shto rol",
"createRatecardSuccessMessage": "Rate card u krijua me sukses",
"createRatecardErrorMessage": "Dështoi të krijohet rate card",
"updateRatecardSuccessMessage": "Rate card u përditësua me sukses",
"updateRatecardErrorMessage": "Dështoi të përditësohet rate card",
"currency": "Monedha",
"actionsColumn": "Veprime",
"addAllButton": "Shto të gjitha",
"removeAllButton": "Hiq të gjitha",
"selectJobTitle": "Zgjidh titullin e punës",
"unsavedChangesTitle": "Keni ndryshime të paruajtura",
"unsavedChangesMessage": "Dëshironi të ruani ndryshimet para se të largoheni?",
"unsavedChangesSave": "Ruaj",
"unsavedChangesDiscard": "Hidh poshtë",
"ratecardNameRequired": "Emri i rate card është i detyrueshëm",
"ratecardNamePlaceholder": "Shkruani emrin e rate card",
"noRatecardsFound": "Nuk u gjetën rate cards",
"loadingRateCards": "Duke ngarkuar rate cards...",
"noJobTitlesAvailable": "Nuk ka tituj pune të disponueshëm",
"noRolesAdded": "Ende nuk janë shtuar role",
"createFirstJobTitle": "Krijo titullin e parë të punës",
"jobRolesTitle": "Rolet e punës",
"noJobTitlesMessage": "Ju lutemi krijoni tituj pune së pari në cilësimet përpara se të shtoni role në rate cards.",
"createNewJobTitle": "Krijo titull të ri pune",
"jobTitleNamePlaceholder": "Shkruani emrin e titullit të punës",
"jobTitleNameRequired": "Emri i titullit të punës është i detyrueshëm",
"jobTitleCreatedSuccess": "Titulli i punës u krijua me sukses",
"jobTitleCreateError": "Dështoi të krijohet titulli i punës",
"createButton": "Krijo",
"cancelButton": "Anulo",
"discardButton": "Hidh poshtë",
"manDaysCalculationMessage": "Organizata po përdor llogaritjen e ditëve-njeri ({{hours}}h/ditë). Tarifat më sipër përfaqësojnë tarifa ditore.",
"hourlyCalculationMessage": "Organizata po përdor llogaritjen orore. Tarifat më sipër përfaqësojnë tarifa orore."
}

View File

@@ -0,0 +1,34 @@
{
"recurring": "Përsëritës",
"recurringTaskConfiguration": "Konfigurimi i detyrës përsëritëse",
"repeats": "Përsëritet",
"daily": "Ditore",
"weekly": "Javore",
"everyXDays": "Çdo X ditë",
"everyXWeeks": "Çdo X javë",
"everyXMonths": "Çdo X muaj",
"monthly": "Mujore",
"selectDaysOfWeek": "Zgjidh ditët e javës",
"mon": "Hën",
"tue": "Mar",
"wed": "Mër",
"thu": "Enj",
"fri": "Pre",
"sat": "Sht",
"sun": "Die",
"monthlyRepeatType": "Lloji i përsëritjes mujore",
"onSpecificDate": "Në një datë specifike",
"onSpecificDay": "Në një ditë specifike",
"dateOfMonth": "Data e muajit",
"weekOfMonth": "Java e muajit",
"dayOfWeek": "Dita e javës",
"first": "E para",
"second": "E dyta",
"third": "E treta",
"fourth": "E katërta",
"last": "E fundit",
"intervalDays": "Intervali (ditë)",
"intervalWeeks": "Intervali (javë)",
"intervalMonths": "Intervali (muaj)",
"saveChanges": "Ruaj ndryshimet"
}

View File

@@ -5,6 +5,7 @@
"searchByName": "Kërko sipas emrit",
"selectAll": "Zgjidh të Gjitha",
"clearAll": "Pastro të Gjitha",
"teams": "Ekipet",
"searchByProject": "Kërko sipas emrit të projektit",
@@ -15,6 +16,8 @@
"billable": "Fakturueshme",
"nonBillable": "Jo Fakturueshme",
"allBillableTypes": "Të Gjitha Llojet e Fakturueshme",
"filterByBillableStatus": "Filtro sipas statusit të fakturueshmërisë",
"total": "Total",
@@ -28,6 +31,9 @@
"membersTimeSheet": "Fletë Kohore e Anëtarëve",
"member": "Anëtar",
"members": "Anëtarët",
"searchByMember": "Kërko sipas anëtarit",
"utilization": "Përdorimi",
"estimatedVsActual": "Vlerësuar vs Aktual",
"workingDays": "Ditë Pune",
@@ -40,5 +46,32 @@
"noCategory": "Pa Kategori",
"noProjects": "Nuk u gjetën projekte",
"noTeams": "Nuk u gjetën ekipe",
"noData": "Nuk u gjetën të dhëna"
"noData": "Nuk u gjetën të dhëna",
"groupBy": "Gruppo sipas",
"groupByCategory": "Kategori",
"groupByTeam": "Ekip",
"groupByStatus": "Status",
"groupByNone": "Asnjë",
"clearSearch": "Pastro kërkimin",
"selectedProjects": "Projektet e Zgjedhura",
"projectsSelected": "projekte të zgjedhura",
"showSelected": "Shfaq Vetëm të Zgjedhurat",
"expandAll": "Zgjero të Gjitha",
"collapseAll": "Mbyll të Gjitha",
"ungrouped": "Pa Grupuar",
"totalTimeLogged": "Koha Totale e Regjistruar",
"acrossAllTeamMembers": "Në të gjithë anëtarët e ekipit",
"expectedCapacity": "Kapaciteti i Pritur",
"basedOnWorkingSchedule": "Bazuar në orarin e punës",
"teamUtilization": "Përdorimi i Ekipit",
"targetRange": "Gama e Objektivit",
"variance": "Varianca",
"overCapacity": "Mbi Kapacitetin",
"underCapacity": "Nën Kapacitetin",
"considerWorkloadRedistribution": "Konsidero rishpërndarjen e ngarkesës së punës",
"capacityAvailableForNewProjects": "Kapaciteti i disponueshëm për projekte të reja",
"optimal": "Optimal",
"underUtilized": "I Përdorur Pak",
"overUtilized": "I Përdorur Shumë"
}

View File

@@ -4,5 +4,69 @@
"owner": "Organisationsinhaber",
"admins": "Organisationsadministratoren",
"contactNumber": "Kontaktnummer hinzufügen",
"edit": "Bearbeiten"
"edit": "Bearbeiten",
"organizationWorkingDaysAndHours": "Arbeitstage und -stunden der Organisation",
"workingDays": "Arbeitstage",
"workingHours": "Arbeitsstunden",
"monday": "Montag",
"tuesday": "Dienstag",
"wednesday": "Mittwoch",
"thursday": "Donnerstag",
"friday": "Freitag",
"saturday": "Samstag",
"sunday": "Sonntag",
"hours": "Stunden",
"saveButton": "Speichern",
"saved": "Einstellungen erfolgreich gespeichert",
"errorSaving": "Fehler beim Speichern der Einstellungen",
"organizationCalculationMethod": "Organisations-Berechnungsmethode",
"calculationMethod": "Berechnungsmethode",
"hourlyRates": "Stundensätze",
"manDays": "Mann-Tage",
"saveChanges": "Änderungen speichern",
"hourlyCalculationDescription": "Alle Projektkosten werden anhand geschätzter Stunden × Stundensätze berechnet",
"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",
"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"
}

View File

@@ -0,0 +1,33 @@
{
"settings": "Einstellungen",
"organizationWorkingDaysAndHours": "Arbeitstage und -stunden der Organisation",
"workingDays": "Arbeitstage",
"workingHours": "Arbeitsstunden",
"hours": "Stunden",
"monday": "Montag",
"tuesday": "Dienstag",
"wednesday": "Mittwoch",
"thursday": "Donnerstag",
"friday": "Freitag",
"saturday": "Samstag",
"sunday": "Sonntag",
"saveButton": "Speichern",
"saved": "Einstellungen erfolgreich gespeichert",
"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"
}

View File

@@ -4,5 +4,6 @@
"teams": "Teams",
"billing": "Abrechnung",
"projects": "Projekte",
"settings": "Einstellungen",
"adminCenter": "Admin-Center"
}

View File

@@ -38,5 +38,13 @@
"createClient": "Kunde erstellen",
"searchInputPlaceholder": "Nach Name oder E-Mail suchen",
"hoursPerDayValidationMessage": "Stunden pro Tag müssen zwischen 1 und 24",
"noPermission": "Keine Berechtigung"
"workingDaysValidationMessage": "Arbeitstage müssen eine positive Zahl sein",
"manDaysValidationMessage": "Personentage müssen eine positive Zahl sein",
"noPermission": "Keine Berechtigung",
"progressSettings": "Fortschrittseinstellungen",
"manualProgress": "Manueller Fortschritt",
"manualProgressTooltip": "Manuelle Fortschrittsaktualisierungen für Aufgaben ohne Unteraufgaben erlauben",
"weightedProgress": "Gewichteter Fortschritt",
"weightedProgressTooltip": "Fortschritt basierend auf Unteraufgaben-Gewichten berechnen",
"timeProgress": "Zeitbasierter Fortschritt"
}

View File

@@ -0,0 +1,114 @@
{
"financeText": "Finance",
"ratecardSingularText": "Rate Card",
"groupByText": "Group by",
"statusText": "Status",
"phaseText": "Phase",
"priorityText": "Priority",
"exportButton": "Export",
"currencyText": "Currency",
"importButton": "Import",
"filterText": "Filter",
"billableOnlyText": "Billable Only",
"nonBillableOnlyText": "Non-Billable Only",
"allTasksText": "All Tasks",
"projectBudgetOverviewText": "Project Budget Overview",
"taskColumn": "Task",
"membersColumn": "Members",
"hoursColumn": "Estimated Hours",
"manDaysColumn": "Estimated Man Days",
"actualManDaysColumn": "Actual Man Days",
"effortVarianceColumn": "Effort Variance",
"totalTimeLoggedColumn": "Total Time Logged",
"costColumn": "Actual Cost",
"estimatedCostColumn": "Estimated Cost",
"fixedCostColumn": "Fixed Cost",
"totalBudgetedCostColumn": "Total Budgeted Cost",
"totalActualCostColumn": "Total Actual Cost",
"varianceColumn": "Variance",
"totalText": "Total",
"noTasksFound": "No tasks found",
"addRoleButton": "+ Add Role",
"ratecardImportantNotice": "* This rate card is generated based on the company's standard job titles and rates. However, you have the flexibility to modify it according to the project. These changes will not impact the organization's standard job titles and rates.",
"saveButton": "Save",
"jobTitleColumn": "Job Title",
"ratePerHourColumn": "Rate per hour",
"ratePerManDayColumn": "Satz pro Manntag",
"calculationMethodText": "Calculation Method",
"hourlyRatesText": "Hourly Rates",
"manDaysText": "Man Days",
"hoursPerDayText": "Hours per Day",
"ratecardPluralText": "Rate Cards",
"labourHoursColumn": "Labour Hours",
"actions": "Actions",
"selectJobTitle": "Select Job Title",
"ratecardsPluralText": "Rate Card Templates",
"deleteConfirm": "Are you sure ?",
"yes": "Yes",
"no": "No",
"alreadyImportedRateCardMessage": "A rate card has already been imported. Clear all imported rate cards to add a new one.",
"budgetOverviewTooltips": {
"manualBudget": "Manual project budget amount set by project manager",
"totalActualCost": "Total actual cost including fixed costs",
"variance": "Difference between manual budget and actual cost",
"utilization": "Percentage of manual budget utilized",
"estimatedHours": "Total estimated hours from all tasks",
"fixedCosts": "Total fixed costs from all tasks",
"timeBasedCost": "Actual cost from time tracking (excluding fixed costs)",
"remainingBudget": "Remaining budget amount"
},
"budgetModal": {
"title": "Edit Project Budget",
"description": "Set a manual budget for this project. This budget will be used for all financial calculations and should include both time-based costs and fixed costs.",
"placeholder": "Enter budget amount",
"saveButton": "Save",
"cancelButton": "Cancel"
},
"budgetStatistics": {
"manualBudget": "Manual Budget",
"totalActualCost": "Total Actual Cost",
"variance": "Variance",
"budgetUtilization": "Budget Utilization",
"estimatedHours": "Estimated Hours",
"fixedCosts": "Fixed Costs",
"timeBasedCost": "Time-based Cost",
"remainingBudget": "Remaining Budget",
"noManualBudgetSet": "(No Manual Budget Set)"
},
"budgetSettingsDrawer": {
"title": "Project Budget Settings",
"budgetConfiguration": "Budget Configuration",
"projectBudget": "Project Budget",
"projectBudgetTooltip": "Total budget allocated for this project",
"currency": "Currency",
"costCalculationMethod": "Cost Calculation Method",
"calculationMethod": "Calculation Method",
"workingHoursPerDay": "Working Hours per Day",
"workingHoursPerDayTooltip": "Number of working hours in a day for man-day calculations",
"hourlyCalculationInfo": "Costs will be calculated using estimated hours × hourly rates",
"manDaysCalculationInfo": "Costs will be calculated using estimated man days × daily rates",
"importantNotes": "Important Notes",
"calculationMethodChangeNote": "• Changing the calculation method will affect how costs are calculated for all tasks in this project",
"immediateEffectNote": "• Changes take effect immediately and will recalculate all project totals",
"projectWideNote": "• Budget settings apply to the entire project and all its tasks",
"cancel": "Cancel",
"saveChanges": "Save Changes",
"budgetSettingsUpdated": "Budget settings updated successfully",
"budgetSettingsUpdateFailed": "Failed to update budget settings"
},
"columnTooltips": {
"hours": "Total estimated hours for all tasks. Calculated from task time estimates.",
"manDays": "Total estimated man days for all tasks. Based on {{hoursPerDay}} hours per working day.",
"actualManDays": "Actual man days spent based on time logged. Calculated as: Total Time Logged ÷ {{hoursPerDay}} hours per day.",
"effortVariance": "Difference between estimated and actual man days. Positive values indicate over-estimation, negative values indicate under-estimation.",
"totalTimeLogged": "Total time actually logged by team members across all tasks.",
"estimatedCostHourly": "Estimated cost calculated as: Estimated Hours × Hourly Rates for assigned team members.",
"estimatedCostManDays": "Estimated cost calculated as: Estimated Man Days × Daily Rates for assigned team members.",
"actualCost": "Actual cost based on time logged. Calculated as: Time Logged × Hourly Rates for team members.",
"fixedCost": "Fixed costs that don't depend on time spent. Added manually per task.",
"totalBudgetHourly": "Total budgeted cost including estimated cost (Hours × Hourly Rates) + Fixed Costs.",
"totalBudgetManDays": "Total budgeted cost including estimated cost (Man Days × Daily Rates) + Fixed Costs.",
"totalActual": "Total actual cost including time-based cost + Fixed Costs.",
"variance": "Cost variance: Total Budgeted Costs - Total Actual Cost. Positive values indicate under-budget, negative values indicate over-budget."
}
}

View File

@@ -10,5 +10,6 @@
"error": "Fehler beim Laden des Projekts",
"pinnedTab": "Als Standard-Registerkarte festgesetzt",
"pinTab": "Als Standard-Registerkarte festsetzen",
"unpinTab": "Standard-Registerkarte lösen"
"unpinTab": "Standard-Registerkarte lösen",
"finance": "Finance"
}

View File

@@ -0,0 +1,55 @@
{
"nameColumn": "Name",
"createdColumn": "Erstellt",
"noProjectsAvailable": "Keine Projekte verfügbar",
"deleteConfirmationTitle": "Sind Sie sicher, dass Sie diese Rate Card löschen möchten?",
"deleteConfirmationOk": "Ja, löschen",
"deleteConfirmationCancel": "Abbrechen",
"searchPlaceholder": "Rate Cards nach Name suchen",
"createRatecard": "Rate Card erstellen",
"editTooltip": "Rate Card bearbeiten",
"deleteTooltip": "Rate Card löschen",
"fetchError": "Rate Cards konnten nicht abgerufen werden",
"createError": "Rate Card konnte nicht erstellt werden",
"deleteSuccess": "Rate Card erfolgreich gelöscht",
"deleteError": "Rate Card konnte nicht gelöscht werden",
"jobTitleColumn": "Berufsbezeichnung",
"ratePerHourColumn": "Stundensatz",
"ratePerDayColumn": "Tagessatz",
"ratePerManDayColumn": "Satz pro Manntag",
"saveButton": "Speichern",
"addRoleButton": "Rolle hinzufügen",
"createRatecardSuccessMessage": "Rate Card erfolgreich erstellt",
"createRatecardErrorMessage": "Rate Card konnte nicht erstellt werden",
"updateRatecardSuccessMessage": "Rate Card erfolgreich aktualisiert",
"updateRatecardErrorMessage": "Rate Card konnte nicht aktualisiert werden",
"currency": "Währung",
"actionsColumn": "Aktionen",
"addAllButton": "Alle hinzufügen",
"removeAllButton": "Alle entfernen",
"selectJobTitle": "Berufsbezeichnung auswählen",
"unsavedChangesTitle": "Sie haben ungespeicherte Änderungen",
"unsavedChangesMessage": "Möchten Sie Ihre Änderungen vor dem Verlassen speichern?",
"unsavedChangesSave": "Speichern",
"unsavedChangesDiscard": "Verwerfen",
"ratecardNameRequired": "Rate Card Name ist erforderlich",
"ratecardNamePlaceholder": "Rate Card Name eingeben",
"noRatecardsFound": "Keine Rate Cards gefunden",
"loadingRateCards": "Rate Cards werden geladen...",
"noJobTitlesAvailable": "Keine Berufsbezeichnungen verfügbar",
"noRolesAdded": "Noch keine Rollen hinzugefügt",
"createFirstJobTitle": "Erste Berufsbezeichnung erstellen",
"jobRolesTitle": "Job-Rollen",
"noJobTitlesMessage": "Bitte erstellen Sie zuerst Berufsbezeichnungen in den Einstellungen, bevor Sie Rollen zu Rate Cards hinzufügen.",
"createNewJobTitle": "Neue Berufsbezeichnung erstellen",
"jobTitleNamePlaceholder": "Name der Berufsbezeichnung eingeben",
"jobTitleNameRequired": "Name der Berufsbezeichnung ist erforderlich",
"jobTitleCreatedSuccess": "Berufsbezeichnung erfolgreich erstellt",
"jobTitleCreateError": "Berufsbezeichnung konnte nicht erstellt werden",
"createButton": "Erstellen",
"cancelButton": "Abbrechen",
"discardButton": "Verwerfen",
"manDaysCalculationMessage": "Organisation verwendet Manntage-Berechnung ({{hours}}h/Tag). Die obigen Sätze stellen Tagessätze dar.",
"hourlyCalculationMessage": "Organisation verwendet Stunden-Berechnung. Die obigen Sätze stellen Stundensätze dar."
}

View File

@@ -0,0 +1,34 @@
{
"recurring": "Wiederkehrend",
"recurringTaskConfiguration": "Wiederkehrende Aufgabenkonfiguration",
"repeats": "Wiederholt sich",
"daily": "Täglich",
"weekly": "Wöchentlich",
"everyXDays": "Alle X Tage",
"everyXWeeks": "Alle X Wochen",
"everyXMonths": "Alle X Monate",
"monthly": "Monatlich",
"selectDaysOfWeek": "Wochentage auswählen",
"mon": "Mo",
"tue": "Di",
"wed": "Mi",
"thu": "Do",
"fri": "Fr",
"sat": "Sa",
"sun": "So",
"monthlyRepeatType": "Monatlicher Wiederholungstyp",
"onSpecificDate": "An einem bestimmten Datum",
"onSpecificDay": "An einem bestimmten Tag",
"dateOfMonth": "Datum des Monats",
"weekOfMonth": "Woche des Monats",
"dayOfWeek": "Wochentag",
"first": "Erste",
"second": "Zweite",
"third": "Dritte",
"fourth": "Vierte",
"last": "Letzte",
"intervalDays": "Intervall (Tage)",
"intervalWeeks": "Intervall (Wochen)",
"intervalMonths": "Intervall (Monate)",
"saveChanges": "Änderungen speichern"
}

View File

@@ -5,6 +5,7 @@
"searchByName": "Nach Namen suchen",
"selectAll": "Alle auswählen",
"clearAll": "Alle löschen",
"teams": "Teams",
"searchByProject": "Nach Projektnamen suchen",
@@ -15,6 +16,8 @@
"billable": "Abrechenbar",
"nonBillable": "Nicht abrechenbar",
"allBillableTypes": "Alle Abrechnungsarten",
"filterByBillableStatus": "Nach abrechenbarem Status filtern",
"total": "Gesamt",
@@ -28,6 +31,9 @@
"membersTimeSheet": "Mitglieder-Zeiterfassung",
"member": "Mitglied",
"members": "Mitglieder",
"searchByMember": "Nach Mitglied suchen",
"utilization": "Auslastung",
"estimatedVsActual": "Geschätzt vs. Tatsächlich",
"workingDays": "Arbeitstage",
@@ -40,5 +46,32 @@
"noCategory": "Keine Kategorie",
"noProjects": "Keine Projekte gefunden",
"noTeams": "Keine Teams gefunden",
"noData": "Keine Daten gefunden"
"noData": "Keine Daten gefunden",
"groupBy": "Gruppieren nach",
"groupByCategory": "Kategorie",
"groupByTeam": "Team",
"groupByStatus": "Status",
"groupByNone": "Keine",
"clearSearch": "Suche löschen",
"selectedProjects": "Ausgewählte Projekte",
"projectsSelected": "Projekte ausgewählt",
"showSelected": "Nur Ausgewählte anzeigen",
"expandAll": "Alle erweitern",
"collapseAll": "Alle einklappen",
"ungrouped": "Nicht gruppiert",
"totalTimeLogged": "Gesamte erfasste Zeit",
"acrossAllTeamMembers": "Über alle Teammitglieder",
"expectedCapacity": "Erwartete Kapazität",
"basedOnWorkingSchedule": "Basierend auf Arbeitsplan",
"teamUtilization": "Team-Auslastung",
"targetRange": "Zielbereich",
"variance": "Abweichung",
"overCapacity": "Überkapazität",
"underCapacity": "Unterkapazität",
"considerWorkloadRedistribution": "Arbeitslast-Umverteilung erwägen",
"capacityAvailableForNewProjects": "Kapazität für neue Projekte verfügbar",
"optimal": "Optimal",
"underUtilized": "Unterausgelastet",
"overUtilized": "Überausgelastet"
}

View File

@@ -5,6 +5,70 @@
"admins": "Organization Admins",
"contactNumber": "Add Contact Number",
"edit": "Edit",
"organizationWorkingDaysAndHours": "Organization Working Days & Hours",
"workingDays": "Working Days",
"workingHours": "Working Hours",
"monday": "Monday",
"tuesday": "Tuesday",
"wednesday": "Wednesday",
"thursday": "Thursday",
"friday": "Friday",
"saturday": "Saturday",
"sunday": "Sunday",
"hours": "hours",
"saveButton": "Save",
"saved": "Settings saved successfully",
"errorSaving": "Error saving settings",
"organizationCalculationMethod": "Organization Calculation Method",
"calculationMethod": "Calculation Method",
"hourlyRates": "Hourly Rates",
"manDays": "Man Days",
"saveChanges": "Save Changes",
"hourlyCalculationDescription": "All project costs will be calculated using estimated hours × hourly rates",
"manDaysCalculationDescription": "All project costs will be calculated using estimated man days × daily rates",
"calculationMethodTooltip": "This setting applies to all projects in your organization",
"calculationMethodUpdated": "Organization calculation method updated successfully",
"calculationMethodUpdateError": "Failed to update calculation method",
"holidayCalendar": "Holiday Calendar",
"addHoliday": "Add Holiday",
"editHoliday": "Edit Holiday",
"holidayName": "Holiday Name",
"holidayNameRequired": "Please enter holiday name",
"description": "Description",
"date": "Date",
"dateRequired": "Please select a date",
"holidayType": "Holiday Type",
"holidayTypeRequired": "Please select a holiday type",
"recurring": "Recurring",
"save": "Save",
"update": "Update",
"cancel": "Cancel",
"holidayCreated": "Holiday created successfully",
"holidayUpdated": "Holiday updated successfully",
"holidayDeleted": "Holiday deleted successfully",
"errorCreatingHoliday": "Error creating holiday",
"errorUpdatingHoliday": "Error updating holiday",
"errorDeletingHoliday": "Error deleting holiday",
"importCountryHolidays": "Import Country Holidays",
"country": "Country",
"countryRequired": "Please select a country",
"selectCountry": "Select a country",
"year": "Year",
"import": "Import",
"holidaysImported": "Successfully imported {{count}} 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",
"emailAddress": "Email Address",
"enterOrganizationName": "Enter organization name",
"ownerSuffix": " (Owner)"

View File

@@ -0,0 +1,27 @@
{
"settings": "Settings",
"organizationWorkingDaysAndHours": "Organization Working Days & Hours",
"workingDays": "Working Days",
"workingHours": "Working Hours",
"hours": "hours",
"monday": "Monday",
"tuesday": "Tuesday",
"wednesday": "Wednesday",
"thursday": "Thursday",
"friday": "Friday",
"saturday": "Saturday",
"sunday": "Sunday",
"saveButton": "Save",
"saved": "Settings saved successfully",
"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"
}

View File

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

View File

@@ -0,0 +1,122 @@
{
"financeText": "Finance",
"ratecardSingularText": "Rate Card",
"groupByText": "Group by",
"statusText": "Status",
"phaseText": "Phase",
"priorityText": "Priority",
"exportButton": "Export",
"currencyText": "Currency",
"importButton": "Import",
"filterText": "Filter",
"billableOnlyText": "Billable Only",
"nonBillableOnlyText": "Non-Billable Only",
"allTasksText": "All Tasks",
"projectBudgetOverviewText": "Project Budget Overview",
"taskColumn": "Task",
"membersColumn": "Members",
"hoursColumn": "Estimated Hours",
"manDaysColumn": "Estimated Man Days",
"actualManDaysColumn": "Actual Man Days",
"effortVarianceColumn": "Effort Variance",
"totalTimeLoggedColumn": "Total Time Logged",
"costColumn": "Actual Cost",
"estimatedCostColumn": "Estimated Cost",
"fixedCostColumn": "Fixed Cost",
"totalBudgetedCostColumn": "Total Budgeted Cost",
"totalActualCostColumn": "Total Actual Cost",
"varianceColumn": "Variance",
"totalText": "Total",
"noTasksFound": "No tasks found",
"addRoleButton": "+ Add Role",
"ratecardImportantNotice": "* This rate card is generated based on the company's standard job titles and rates. However, you have the flexibility to modify it according to the project. These changes will not impact the organization's standard job titles and rates.",
"saveButton": "Save",
"jobTitleColumn": "Job Title",
"ratePerHourColumn": "Rate per hour",
"ratePerManDayColumn": "Rate per man day",
"calculationMethodText": "Calculation Method",
"hourlyRatesText": "Hourly Rates",
"manDaysText": "Man Days",
"hoursPerDayText": "Hours per Day",
"ratecardPluralText": "Rate Cards",
"labourHoursColumn": "Labour Hours",
"actions": "Actions",
"selectJobTitle": "Select Job Title",
"ratecardsPluralText": "Rate Card Templates",
"deleteConfirm": "Are you sure ?",
"yes": "Yes",
"no": "No",
"alreadyImportedRateCardMessage": "A rate card has already been imported. Clear all imported rate cards to add a new one.",
"budgetOverviewTooltips": {
"manualBudget": "Manual project budget amount set by project manager",
"totalActualCost": "Total actual cost including fixed costs",
"variance": "Difference between manual budget and actual cost",
"utilization": "Percentage of manual budget utilized",
"estimatedHours": "Total estimated hours from all tasks",
"fixedCosts": "Total fixed costs from all tasks",
"timeBasedCost": "Actual cost from time tracking (excluding fixed costs)",
"remainingBudget": "Remaining budget amount"
},
"budgetModal": {
"title": "Edit Project Budget",
"description": "Set a manual budget for this project. This budget will be used for all financial calculations and should include both time-based costs and fixed costs.",
"placeholder": "Enter budget amount",
"saveButton": "Save",
"cancelButton": "Cancel"
},
"budgetStatistics": {
"manualBudget": "Manual Budget",
"totalActualCost": "Total Actual Cost",
"variance": "Variance",
"budgetUtilization": "Budget Utilization",
"estimatedHours": "Estimated Hours",
"fixedCosts": "Fixed Costs",
"timeBasedCost": "Time-based Cost",
"remainingBudget": "Remaining Budget",
"noManualBudgetSet": "(No Manual Budget Set)"
},
"budgetSettingsDrawer": {
"title": "Project Budget Settings",
"budgetConfiguration": "Budget Configuration",
"projectBudget": "Project Budget",
"projectBudgetTooltip": "Total budget allocated for this project",
"currency": "Currency",
"costCalculationMethod": "Cost Calculation Method",
"calculationMethod": "Calculation Method",
"workingHoursPerDay": "Working Hours per Day",
"workingHoursPerDayTooltip": "Number of working hours in a day for man-day calculations",
"hourlyCalculationInfo": "Costs will be calculated using estimated hours × hourly rates",
"manDaysCalculationInfo": "Costs will be calculated using estimated man days × daily rates",
"importantNotes": "Important Notes",
"calculationMethodChangeNote": "• Changing the calculation method will affect how costs are calculated for all tasks in this project",
"immediateEffectNote": "• Changes take effect immediately and will recalculate all project totals",
"projectWideNote": "• Budget settings apply to the entire project and all its tasks",
"cancel": "Cancel",
"saveChanges": "Save Changes",
"budgetSettingsUpdated": "Budget settings updated successfully",
"budgetSettingsUpdateFailed": "Failed to update budget settings"
},
"columnTooltips": {
"hours": "Total estimated hours for all tasks. Calculated from task time estimates.",
"manDays": "Total estimated man days for all tasks. Based on {{hoursPerDay}} hours per working day.",
"actualManDays": "Actual man days spent based on time logged. Calculated as: Total Time Logged ÷ {{hoursPerDay}} hours per day.",
"effortVariance": "Difference between estimated and actual man days. Positive values indicate over-estimation, negative values indicate under-estimation.",
"totalTimeLogged": "Total time actually logged by team members across all tasks.",
"estimatedCostHourly": "Estimated cost calculated as: Estimated Hours × Hourly Rates for assigned team members.",
"estimatedCostManDays": "Estimated cost calculated as: Estimated Man Days × Daily Rates for assigned team members.",
"actualCost": "Actual cost based on time logged. Calculated as: Time Logged × Hourly Rates for team members.",
"fixedCost": "Fixed costs that don't depend on time spent. Added manually per task.",
"totalBudgetHourly": "Total budgeted cost including estimated cost (Hours × Hourly Rates) + Fixed Costs.",
"totalBudgetManDays": "Total budgeted cost including estimated cost (Man Days × Daily Rates) + Fixed Costs.",
"totalActual": "Total actual cost including time-based cost + Fixed Costs.",
"variance": "Cost variance: Total Budgeted Costs - Total Actual Cost. Positive values indicate under-budget, negative values indicate over-budget."
}
}

View File

@@ -10,5 +10,6 @@
"error": "Error loading project",
"pinnedTab": "Pinned as default tab",
"pinTab": "Pin as default tab",
"unpinTab": "Unpin default tab"
"unpinTab": "Unpin default tab",
"finance": "Finance"
}

View File

@@ -0,0 +1,55 @@
{
"nameColumn": "Name",
"createdColumn": "Created",
"noProjectsAvailable": "No projects available",
"deleteConfirmationTitle": "Are you sure you want to delete this rate card?",
"deleteConfirmationOk": "Yes, delete",
"deleteConfirmationCancel": "Cancel",
"searchPlaceholder": "Search rate cards by name",
"createRatecard": "Create Rate Card",
"editTooltip": "Edit rate card",
"deleteTooltip": "Delete rate card",
"fetchError": "Failed to fetch rate cards",
"createError": "Failed to create rate card",
"deleteSuccess": "Rate card deleted successfully",
"deleteError": "Failed to delete rate card",
"jobTitleColumn": "Job title",
"ratePerHourColumn": "Rate per hour",
"ratePerDayColumn": "Rate per day",
"ratePerManDayColumn": "Rate per man day",
"saveButton": "Save",
"addRoleButton": "Add Role",
"createRatecardSuccessMessage": "Rate card created successfully",
"createRatecardErrorMessage": "Failed to create rate card",
"updateRatecardSuccessMessage": "Rate card updated successfully",
"updateRatecardErrorMessage": "Failed to update rate card",
"currency": "Currency",
"actionsColumn": "Actions",
"addAllButton": "Add All",
"removeAllButton": "Remove All",
"selectJobTitle": "Select job title",
"unsavedChangesTitle": "You have unsaved changes",
"unsavedChangesMessage": "Do you want to save your changes before leaving?",
"unsavedChangesSave": "Save",
"unsavedChangesDiscard": "Discard",
"ratecardNameRequired": "Rate card name is required",
"ratecardNamePlaceholder": "Enter rate card name",
"noRatecardsFound": "No rate cards found",
"loadingRateCards": "Loading rate cards...",
"noJobTitlesAvailable": "No job titles available",
"noRolesAdded": "No roles added yet",
"createFirstJobTitle": "Create First Job Title",
"jobRolesTitle": "Job Roles",
"noJobTitlesMessage": "Please create job titles first in the Job Titles settings before adding roles to rate cards.",
"createNewJobTitle": "Create New Job Title",
"jobTitleNamePlaceholder": "Enter job title name",
"jobTitleNameRequired": "Job title name is required",
"jobTitleCreatedSuccess": "Job title created successfully",
"jobTitleCreateError": "Failed to create job title",
"createButton": "Create",
"cancelButton": "Cancel",
"discardButton": "Discard",
"manDaysCalculationMessage": "Organization is using man days calculation ({{hours}}h/day). Rates above represent daily rates.",
"hourlyCalculationMessage": "Organization is using hourly calculation. Rates above represent hourly rates."
}

View File

@@ -5,6 +5,7 @@
"searchByName": "Search by name",
"selectAll": "Select All",
"clearAll": "Clear All",
"teams": "Teams",
"searchByProject": "Search by project name",
@@ -15,6 +16,8 @@
"billable": "Billable",
"nonBillable": "Non Billable",
"allBillableTypes": "All Billable Types",
"filterByBillableStatus": "Filter by billable status",
"total": "Total",
@@ -28,6 +31,9 @@
"membersTimeSheet": "Members Time Sheet",
"member": "Member",
"members": "Members",
"searchByMember": "Search by member",
"utilization": "Utilization",
"estimatedVsActual": "Estimated vs Actual",
"workingDays": "Working Days",
@@ -53,5 +59,20 @@
"showSelected": "Show Selected Only",
"expandAll": "Expand All",
"collapseAll": "Collapse All",
"ungrouped": "Ungrouped"
"ungrouped": "Ungrouped",
"totalTimeLogged": "Total Time Logged",
"acrossAllTeamMembers": "Across all team members",
"expectedCapacity": "Expected Capacity",
"basedOnWorkingSchedule": "Based on working schedule",
"teamUtilization": "Team Utilization",
"targetRange": "Target Range",
"variance": "Variance",
"overCapacity": "Over Capacity",
"underCapacity": "Under Capacity",
"considerWorkloadRedistribution": "Consider workload redistribution",
"capacityAvailableForNewProjects": "Capacity available for new projects",
"optimal": "Optimal",
"underUtilized": "Under Utilized",
"overUtilized": "Over Utilized"
}

View File

@@ -4,5 +4,69 @@
"owner": "Propietario de la Organización",
"admins": "Administradores de la Organización",
"contactNumber": "Agregar Número de Contacto",
"edit": "Editar"
"edit": "Editar",
"organizationWorkingDaysAndHours": "Días y Horas Laborales de la Organización",
"workingDays": "Días Laborales",
"workingHours": "Horas Laborales",
"monday": "Lunes",
"tuesday": "Martes",
"wednesday": "Miércoles",
"thursday": "Jueves",
"friday": "Viernes",
"saturday": "Sábado",
"sunday": "Domingo",
"hours": "horas",
"saveButton": "Guardar",
"saved": "Configuración guardada exitosamente",
"errorSaving": "Error al guardar la configuración",
"organizationCalculationMethod": "Método de Cálculo de la Organización",
"calculationMethod": "Método de Cálculo",
"hourlyRates": "Tarifas por Hora",
"manDays": "Días Hombre",
"saveChanges": "Guardar Cambios",
"hourlyCalculationDescription": "Todos los costos del proyecto se calcularán usando horas estimadas × tarifas por hora",
"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",
"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"
}

View File

@@ -0,0 +1,33 @@
{
"settings": "Configuración",
"organizationWorkingDaysAndHours": "Días y Horas Laborales de la Organización",
"workingDays": "Días Laborales",
"workingHours": "Horas Laborales",
"hours": "horas",
"monday": "Lunes",
"tuesday": "Martes",
"wednesday": "Miércoles",
"thursday": "Jueves",
"friday": "Viernes",
"saturday": "Sábado",
"sunday": "Domingo",
"saveButton": "Guardar",
"saved": "Configuración guardada exitosamente",
"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"
}

View File

@@ -4,5 +4,6 @@
"teams": "Equipos",
"billing": "Facturación",
"projects": "Proyectos",
"settings": "Configuración",
"adminCenter": "Centro de Administración"
}

View File

@@ -0,0 +1,114 @@
{
"financeText": "Finance",
"ratecardSingularText": "Rate Card",
"groupByText": "Group by",
"statusText": "Status",
"phaseText": "Phase",
"priorityText": "Priority",
"exportButton": "Export",
"currencyText": "Currency",
"importButton": "Import",
"filterText": "Filter",
"billableOnlyText": "Billable Only",
"nonBillableOnlyText": "Non-Billable Only",
"allTasksText": "All Tasks",
"projectBudgetOverviewText": "Project Budget Overview",
"taskColumn": "Task",
"membersColumn": "Members",
"hoursColumn": "Estimated Hours",
"manDaysColumn": "Estimated Man Days",
"actualManDaysColumn": "Actual Man Days",
"effortVarianceColumn": "Effort Variance",
"totalTimeLoggedColumn": "Total Time Logged",
"costColumn": "Actual Cost",
"estimatedCostColumn": "Estimated Cost",
"fixedCostColumn": "Fixed Cost",
"totalBudgetedCostColumn": "Total Budgeted Cost",
"totalActualCostColumn": "Total Actual Cost",
"varianceColumn": "Variance",
"totalText": "Total",
"noTasksFound": "No tasks found",
"addRoleButton": "+ Add Role",
"ratecardImportantNotice": "* This rate card is generated based on the company's standard job titles and rates. However, you have the flexibility to modify it according to the project. These changes will not impact the organization's standard job titles and rates.",
"saveButton": "Save",
"jobTitleColumn": "Job Title",
"ratePerHourColumn": "Rate per hour",
"ratePerManDayColumn": "Tarifa por día-hombre",
"calculationMethodText": "Calculation Method",
"hourlyRatesText": "Hourly Rates",
"manDaysText": "Man Days",
"hoursPerDayText": "Hours per Day",
"ratecardPluralText": "Rate Cards",
"labourHoursColumn": "Labour Hours",
"actions": "Actions",
"selectJobTitle": "Select Job Title",
"ratecardsPluralText": "Rate Card Templates",
"deleteConfirm": "Are you sure ?",
"yes": "Yes",
"no": "No",
"alreadyImportedRateCardMessage": "A rate card has already been imported. Clear all imported rate cards to add a new one.",
"budgetOverviewTooltips": {
"manualBudget": "Manual project budget amount set by project manager",
"totalActualCost": "Total actual cost including fixed costs",
"variance": "Difference between manual budget and actual cost",
"utilization": "Percentage of manual budget utilized",
"estimatedHours": "Total estimated hours from all tasks",
"fixedCosts": "Total fixed costs from all tasks",
"timeBasedCost": "Actual cost from time tracking (excluding fixed costs)",
"remainingBudget": "Remaining budget amount"
},
"budgetModal": {
"title": "Edit Project Budget",
"description": "Set a manual budget for this project. This budget will be used for all financial calculations and should include both time-based costs and fixed costs.",
"placeholder": "Enter budget amount",
"saveButton": "Save",
"cancelButton": "Cancel"
},
"budgetStatistics": {
"manualBudget": "Manual Budget",
"totalActualCost": "Total Actual Cost",
"variance": "Variance",
"budgetUtilization": "Budget Utilization",
"estimatedHours": "Estimated Hours",
"fixedCosts": "Fixed Costs",
"timeBasedCost": "Time-based Cost",
"remainingBudget": "Remaining Budget",
"noManualBudgetSet": "(No Manual Budget Set)"
},
"budgetSettingsDrawer": {
"title": "Project Budget Settings",
"budgetConfiguration": "Budget Configuration",
"projectBudget": "Project Budget",
"projectBudgetTooltip": "Total budget allocated for this project",
"currency": "Currency",
"costCalculationMethod": "Cost Calculation Method",
"calculationMethod": "Calculation Method",
"workingHoursPerDay": "Working Hours per Day",
"workingHoursPerDayTooltip": "Number of working hours in a day for man-day calculations",
"hourlyCalculationInfo": "Costs will be calculated using estimated hours × hourly rates",
"manDaysCalculationInfo": "Costs will be calculated using estimated man days × daily rates",
"importantNotes": "Important Notes",
"calculationMethodChangeNote": "• Changing the calculation method will affect how costs are calculated for all tasks in this project",
"immediateEffectNote": "• Changes take effect immediately and will recalculate all project totals",
"projectWideNote": "• Budget settings apply to the entire project and all its tasks",
"cancel": "Cancel",
"saveChanges": "Save Changes",
"budgetSettingsUpdated": "Budget settings updated successfully",
"budgetSettingsUpdateFailed": "Failed to update budget settings"
},
"columnTooltips": {
"hours": "Total estimated hours for all tasks. Calculated from task time estimates.",
"manDays": "Total estimated man days for all tasks. Based on {{hoursPerDay}} hours per working day.",
"actualManDays": "Actual man days spent based on time logged. Calculated as: Total Time Logged ÷ {{hoursPerDay}} hours per day.",
"effortVariance": "Difference between estimated and actual man days. Positive values indicate over-estimation, negative values indicate under-estimation.",
"totalTimeLogged": "Total time actually logged by team members across all tasks.",
"estimatedCostHourly": "Estimated cost calculated as: Estimated Hours × Hourly Rates for assigned team members.",
"estimatedCostManDays": "Estimated cost calculated as: Estimated Man Days × Daily Rates for assigned team members.",
"actualCost": "Actual cost based on time logged. Calculated as: Time Logged × Hourly Rates for team members.",
"fixedCost": "Fixed costs that don't depend on time spent. Added manually per task.",
"totalBudgetHourly": "Total budgeted cost including estimated cost (Hours × Hourly Rates) + Fixed Costs.",
"totalBudgetManDays": "Total budgeted cost including estimated cost (Man Days × Daily Rates) + Fixed Costs.",
"totalActual": "Total actual cost including time-based cost + Fixed Costs.",
"variance": "Cost variance: Total Budgeted Costs - Total Actual Cost. Positive values indicate under-budget, negative values indicate over-budget."
}
}

View File

@@ -10,5 +10,6 @@
"error": "Error al cargar el proyecto",
"pinnedTab": "Fijado como pestaña predeterminada",
"pinTab": "Fijar como pestaña predeterminada",
"unpinTab": "Desfijar pestaña predeterminada"
"unpinTab": "Desfijar pestaña predeterminada",
"finance": "Finance"
}

View File

@@ -0,0 +1,55 @@
{
"nameColumn": "Nombre",
"createdColumn": "Creado",
"noProjectsAvailable": "No hay proyectos disponibles",
"deleteConfirmationTitle": "¿Está seguro de que desea eliminar esta rate card?",
"deleteConfirmationOk": "Sí, eliminar",
"deleteConfirmationCancel": "Cancelar",
"searchPlaceholder": "Buscar rate cards por nombre",
"createRatecard": "Crear Rate Card",
"editTooltip": "Editar rate card",
"deleteTooltip": "Eliminar rate card",
"fetchError": "No se pudieron obtener las rate cards",
"createError": "No se pudo crear la rate card",
"deleteSuccess": "Rate card eliminada con éxito",
"deleteError": "No se pudo eliminar la rate card",
"jobTitleColumn": "Título del trabajo",
"ratePerHourColumn": "Tarifa por hora",
"ratePerDayColumn": "Tarifa por día",
"ratePerManDayColumn": "Tarifa por día-hombre",
"saveButton": "Guardar",
"addRoleButton": "Agregar rol",
"createRatecardSuccessMessage": "Rate card creada con éxito",
"createRatecardErrorMessage": "No se pudo crear la rate card",
"updateRatecardSuccessMessage": "Rate card actualizada con éxito",
"updateRatecardErrorMessage": "No se pudo actualizar la rate card",
"currency": "Moneda",
"actionsColumn": "Acciones",
"addAllButton": "Agregar todo",
"removeAllButton": "Eliminar todo",
"selectJobTitle": "Seleccionar título del trabajo",
"unsavedChangesTitle": "Tiene cambios sin guardar",
"unsavedChangesMessage": "¿Desea guardar los cambios antes de salir?",
"unsavedChangesSave": "Guardar",
"unsavedChangesDiscard": "Descartar",
"ratecardNameRequired": "El nombre de la rate card es obligatorio",
"ratecardNamePlaceholder": "Ingrese el nombre de la rate card",
"noRatecardsFound": "No se encontraron rate cards",
"loadingRateCards": "Cargando rate cards...",
"noJobTitlesAvailable": "No hay títulos de trabajo disponibles",
"noRolesAdded": "Aún no se han agregado roles",
"createFirstJobTitle": "Crear primer título de trabajo",
"jobRolesTitle": "Roles de trabajo",
"noJobTitlesMessage": "Por favor, cree primero títulos de trabajo en la configuración antes de agregar roles a las rate cards.",
"createNewJobTitle": "Crear nuevo título de trabajo",
"jobTitleNamePlaceholder": "Ingrese el nombre del título de trabajo",
"jobTitleNameRequired": "El nombre del título de trabajo es obligatorio",
"jobTitleCreatedSuccess": "Título de trabajo creado con éxito",
"jobTitleCreateError": "No se pudo crear el título de trabajo",
"createButton": "Crear",
"cancelButton": "Cancelar",
"discardButton": "Descartar",
"manDaysCalculationMessage": "La organización utiliza cálculo por días-hombre ({{hours}}h/día). Las tarifas anteriores representan tarifas diarias.",
"hourlyCalculationMessage": "La organización utiliza cálculo por horas. Las tarifas anteriores representan tarifas por hora."
}

View File

@@ -5,6 +5,7 @@
"searchByName": "Buscar por nombre",
"selectAll": "Seleccionar Todo",
"clearAll": "Limpiar Todo",
"teams": "Equipos",
"searchByProject": "Buscar por nombre del proyecto",
@@ -15,6 +16,8 @@
"billable": "Facturable",
"nonBillable": "No Facturable",
"allBillableTypes": "Todos los Tipos Facturables",
"filterByBillableStatus": "Filtrar por estado facturable",
"total": "Total",
@@ -28,6 +31,9 @@
"membersTimeSheet": "Hoja de Tiempo de Miembros",
"member": "Miembro",
"members": "Miembros",
"searchByMember": "Buscar por miembro",
"utilization": "Utilización",
"estimatedVsActual": "Estimado vs Real",
"workingDays": "Días Laborables",
@@ -53,5 +59,20 @@
"showSelected": "Mostrar Solo Seleccionados",
"expandAll": "Expandir Todo",
"collapseAll": "Contraer Todo",
"ungrouped": "Sin Agrupar"
"ungrouped": "Sin Agrupar",
"totalTimeLogged": "Tiempo Total Registrado",
"acrossAllTeamMembers": "En todos los miembros del equipo",
"expectedCapacity": "Capacidad Esperada",
"basedOnWorkingSchedule": "Basado en el horario de trabajo",
"teamUtilization": "Utilización del Equipo",
"targetRange": "Rango Objetivo",
"variance": "Varianza",
"overCapacity": "Sobre Capacidad",
"underCapacity": "Bajo Capacidad",
"considerWorkloadRedistribution": "Considerar redistribución de carga de trabajo",
"capacityAvailableForNewProjects": "Capacidad disponible para nuevos proyectos",
"optimal": "Óptimo",
"underUtilized": "Subutilizado",
"overUtilized": "Sobreutilizado"
}

View File

@@ -4,5 +4,69 @@
"owner": "Proprietário da Organização",
"admins": "Administradores da Organização",
"contactNumber": "Adicione o Número de Contato",
"edit": "Editar"
"edit": "Editar",
"organizationWorkingDaysAndHours": "Dias e Horas de Trabalho da Organização",
"workingDays": "Dias de Trabalho",
"workingHours": "Horas de Trabalho",
"monday": "Segunda-feira",
"tuesday": "Terça-feira",
"wednesday": "Quarta-feira",
"thursday": "Quinta-feira",
"friday": "Sexta-feira",
"saturday": "Sábado",
"sunday": "Domingo",
"hours": "horas",
"saveButton": "Salvar",
"saved": "Configurações salvas com sucesso",
"errorSaving": "Erro ao salvar configurações",
"organizationCalculationMethod": "Método de Cálculo da Organização",
"calculationMethod": "Método de Cálculo",
"hourlyRates": "Taxas por Hora",
"manDays": "Dias Homem",
"saveChanges": "Salvar Alterações",
"hourlyCalculationDescription": "Todos os custos do projeto serão calculados usando horas estimadas × taxas por hora",
"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",
"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"
}

View File

@@ -0,0 +1,33 @@
{
"settings": "Configurações",
"organizationWorkingDaysAndHours": "Dias e Horas de Trabalho da Organização",
"workingDays": "Dias de Trabalho",
"workingHours": "Horas de Trabalho",
"hours": "horas",
"monday": "Segunda-feira",
"tuesday": "Terça-feira",
"wednesday": "Quarta-feira",
"thursday": "Quinta-feira",
"friday": "Sexta-feira",
"saturday": "Sábado",
"sunday": "Domingo",
"saveButton": "Salvar",
"saved": "Configurações salvas com sucesso",
"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"
}

View File

@@ -4,5 +4,6 @@
"teams": "Equipes",
"billing": "Faturamento",
"projects": "Projetos",
"settings": "Configurações",
"adminCenter": "Central Administrativa"
}

View File

@@ -0,0 +1,114 @@
{
"financeText": "Finanças",
"ratecardSingularText": "Tabela de Preços",
"groupByText": "Agrupar por",
"statusText": "Status",
"phaseText": "Fase",
"priorityText": "Prioridade",
"exportButton": "Exportar",
"currencyText": "Moeda",
"importButton": "Importar",
"filterText": "Filtrar",
"billableOnlyText": "Apenas Faturável",
"nonBillableOnlyText": "Apenas Não Faturável",
"allTasksText": "Todas as Tarefas",
"projectBudgetOverviewText": "Visão Geral do Orçamento do Projeto",
"taskColumn": "Tarefa",
"membersColumn": "Membros",
"hoursColumn": "Horas Estimadas",
"manDaysColumn": "Dias-Homem Estimados",
"actualManDaysColumn": "Dias-Homem Reais",
"effortVarianceColumn": "Variação do Esforço",
"totalTimeLoggedColumn": "Tempo Total Registrado",
"costColumn": "Custo Real",
"estimatedCostColumn": "Custo Estimado",
"fixedCostColumn": "Custo Fixo",
"totalBudgetedCostColumn": "Custo Total Orçado",
"totalActualCostColumn": "Custo Total Real",
"varianceColumn": "Variação",
"totalText": "Total",
"noTasksFound": "Nenhuma tarefa encontrada",
"addRoleButton": "+ Adicionar Função",
"ratecardImportantNotice": "* Esta tabela de preços é gerada com base nos cargos padrão e taxas da empresa. No entanto, você tem a flexibilidade de modificá-la de acordo com o projeto. Essas alterações não afetarão os cargos padrão e taxas da organização.",
"saveButton": "Salvar",
"jobTitleColumn": "Cargo",
"ratePerHourColumn": "Taxa por hora",
"ratePerManDayColumn": "Taxa por dia-homem",
"calculationMethodText": "Método de Cálculo",
"hourlyRatesText": "Taxas por Hora",
"manDaysText": "Dias-Homem",
"hoursPerDayText": "Horas por Dia",
"ratecardPluralText": "Tabelas de Preços",
"labourHoursColumn": "Horas de Trabalho",
"actions": "Ações",
"selectJobTitle": "Selecionar Cargo",
"ratecardsPluralText": "Modelos de Tabela de Preços",
"deleteConfirm": "Tem certeza?",
"yes": "Sim",
"no": "Não",
"alreadyImportedRateCardMessage": "Uma tabela de preços já foi importada. Limpe todas as tabelas de preços importadas para adicionar uma nova.",
"budgetOverviewTooltips": {
"manualBudget": "Valor do orçamento manual do projeto definido pelo gerente do projeto",
"totalActualCost": "Custo total real incluindo custos fixos",
"variance": "Diferença entre orçamento manual e custo real",
"utilization": "Porcentagem do orçamento manual utilizado",
"estimatedHours": "Total de horas estimadas de todas as tarefas",
"fixedCosts": "Total de custos fixos de todas as tarefas",
"timeBasedCost": "Custo real do rastreamento de tempo (excluindo custos fixos)",
"remainingBudget": "Valor do orçamento restante"
},
"budgetModal": {
"title": "Editar Orçamento do Projeto",
"description": "Defina um orçamento manual para este projeto. Este orçamento será usado para todos os cálculos financeiros e deve incluir tanto custos baseados em tempo quanto custos fixos.",
"placeholder": "Digite o valor do orçamento",
"saveButton": "Salvar",
"cancelButton": "Cancelar"
},
"budgetStatistics": {
"manualBudget": "Orçamento Manual",
"totalActualCost": "Custo Total Real",
"variance": "Variação",
"budgetUtilization": "Utilização do Orçamento",
"estimatedHours": "Horas Estimadas",
"fixedCosts": "Custos Fixos",
"timeBasedCost": "Custo Baseado em Tempo",
"remainingBudget": "Orçamento Restante",
"noManualBudgetSet": "(Nenhum Orçamento Manual Definido)"
},
"budgetSettingsDrawer": {
"title": "Configurações de Orçamento do Projeto",
"budgetConfiguration": "Configuração do Orçamento",
"projectBudget": "Orçamento do Projeto",
"projectBudgetTooltip": "Orçamento total alocado para este projeto",
"currency": "Moeda",
"costCalculationMethod": "Método de Cálculo de Custo",
"calculationMethod": "Método de Cálculo",
"workingHoursPerDay": "Horas de Trabalho por Dia",
"workingHoursPerDayTooltip": "Número de horas de trabalho em um dia para cálculos de dia-homem",
"hourlyCalculationInfo": "Os custos serão calculados usando horas estimadas × taxas por hora",
"manDaysCalculationInfo": "Os custos serão calculados usando dias-homem estimados × taxas diárias",
"importantNotes": "Notas Importantes",
"calculationMethodChangeNote": "• Alterar o método de cálculo afetará como os custos são calculados para todas as tarefas neste projeto",
"immediateEffectNote": "• As alterações entram em vigor imediatamente e recalcularão todos os totais do projeto",
"projectWideNote": "• As configurações de orçamento se aplicam a todo o projeto e todas as suas tarefas",
"cancel": "Cancelar",
"saveChanges": "Salvar Alterações",
"budgetSettingsUpdated": "Configurações de orçamento atualizadas com sucesso",
"budgetSettingsUpdateFailed": "Falha ao atualizar configurações de orçamento"
},
"columnTooltips": {
"hours": "Total de horas estimadas para todas as tarefas. Calculado a partir das estimativas de tempo das tarefas.",
"manDays": "Total de dias-homem estimados para todas as tarefas. Baseado em {{hoursPerDay}} horas por dia de trabalho.",
"actualManDays": "Dias-homem reais gastos com base no tempo registrado. Calculado como: Tempo Total Registrado ÷ {{hoursPerDay}} horas por dia.",
"effortVariance": "Diferença entre dias-homem estimados e reais. Valores positivos indicam superestimação, valores negativos indicam subestimação.",
"totalTimeLogged": "Tempo total realmente registrado pelos membros da equipe em todas as tarefas.",
"estimatedCostHourly": "Custo estimado calculado como: Horas Estimadas × Taxas por Hora para membros da equipe designados.",
"estimatedCostManDays": "Custo estimado calculado como: Dias-Homem Estimados × Taxas Diárias para membros da equipe designados.",
"actualCost": "Custo real baseado no tempo registrado. Calculado como: Tempo Registrado × Taxas por Hora para membros da equipe.",
"fixedCost": "Custos fixos que não dependem do tempo gasto. Adicionados manualmente por tarefa.",
"totalBudgetHourly": "Custo total orçado incluindo custo estimado (Horas × Taxas por Hora) + Custos Fixos.",
"totalBudgetManDays": "Custo total orçado incluindo custo estimado (Dias-Homem × Taxas Diárias) + Custos Fixos.",
"totalActual": "Custo total real incluindo custo baseado em tempo + Custos Fixos.",
"variance": "Variação de custo: Custos Totais Orçados - Custo Total Real. Valores positivos indicam abaixo do orçamento, valores negativos indicam acima do orçamento."
}
}

View File

@@ -10,5 +10,6 @@
"error": "Erro ao carregar projeto",
"pinnedTab": "Fixada como aba padrão",
"pinTab": "Fixar como aba padrão",
"unpinTab": "Desfixar aba padrão"
"unpinTab": "Desfixar aba padrão",
"finance": "Finance"
}

View File

@@ -0,0 +1,55 @@
{
"nameColumn": "Nome",
"createdColumn": "Criado",
"noProjectsAvailable": "Nenhum projeto disponível",
"deleteConfirmationTitle": "Tem certeza de que deseja excluir este rate card?",
"deleteConfirmationOk": "Sim, excluir",
"deleteConfirmationCancel": "Cancelar",
"searchPlaceholder": "Pesquisar rate cards por nome",
"createRatecard": "Criar Rate Card",
"editTooltip": "Editar rate card",
"deleteTooltip": "Excluir rate card",
"fetchError": "Falha ao buscar rate cards",
"createError": "Falha ao criar rate card",
"deleteSuccess": "Rate card excluído com sucesso",
"deleteError": "Falha ao excluir rate card",
"jobTitleColumn": "Cargo",
"ratePerHourColumn": "Taxa por hora",
"ratePerDayColumn": "Taxa por dia",
"ratePerManDayColumn": "Taxa por dia-homem",
"saveButton": "Salvar",
"addRoleButton": "Adicionar função",
"createRatecardSuccessMessage": "Rate card criado com sucesso",
"createRatecardErrorMessage": "Falha ao criar rate card",
"updateRatecardSuccessMessage": "Rate card atualizado com sucesso",
"updateRatecardErrorMessage": "Falha ao atualizar rate card",
"currency": "Moeda",
"actionsColumn": "Ações",
"addAllButton": "Adicionar todos",
"removeAllButton": "Remover todos",
"selectJobTitle": "Selecionar cargo",
"unsavedChangesTitle": "Você tem alterações não salvas",
"unsavedChangesMessage": "Deseja salvar as alterações antes de sair?",
"unsavedChangesSave": "Salvar",
"unsavedChangesDiscard": "Descartar",
"ratecardNameRequired": "O nome do rate card é obrigatório",
"ratecardNamePlaceholder": "Digite o nome do rate card",
"noRatecardsFound": "Nenhum rate card encontrado",
"loadingRateCards": "Carregando rate cards...",
"noJobTitlesAvailable": "Nenhum cargo disponível",
"noRolesAdded": "Nenhuma função adicionada ainda",
"createFirstJobTitle": "Criar primeiro cargo",
"jobRolesTitle": "Funções de trabalho",
"noJobTitlesMessage": "Por favor, crie cargos primeiro nas configurações antes de adicionar funções aos rate cards.",
"createNewJobTitle": "Criar novo cargo",
"jobTitleNamePlaceholder": "Digite o nome do cargo",
"jobTitleNameRequired": "O nome do cargo é obrigatório",
"jobTitleCreatedSuccess": "Cargo criado com sucesso",
"jobTitleCreateError": "Falha ao criar cargo",
"createButton": "Criar",
"cancelButton": "Cancelar",
"discardButton": "Descartar",
"manDaysCalculationMessage": "A organização está usando cálculo por dias-homem ({{hours}}h/dia). As taxas acima representam taxas diárias.",
"hourlyCalculationMessage": "A organização está usando cálculo por horas. As taxas acima representam taxas horárias."
}

View File

@@ -5,6 +5,7 @@
"searchByName": "Pesquisar por nome",
"selectAll": "Selecionar Tudo",
"clearAll": "Limpar Tudo",
"teams": "Equipes",
"searchByProject": "Pesquisar por nome do projeto",
@@ -15,6 +16,8 @@
"billable": "Faturável",
"nonBillable": "Não Faturável",
"allBillableTypes": "Todos os Tipos Faturáveis",
"filterByBillableStatus": "Filtrar por status faturável",
"total": "Total",
@@ -28,6 +31,9 @@
"membersTimeSheet": "Folha de Tempo de Membros",
"member": "Membro",
"members": "Membros",
"searchByMember": "Pesquisar por membro",
"utilization": "Utilização",
"estimatedVsActual": "Estimado vs Real",
"workingDays": "Dias Úteis",
@@ -53,5 +59,20 @@
"showSelected": "Mostrar Apenas Selecionados",
"expandAll": "Expandir Tudo",
"collapseAll": "Recolher Tudo",
"ungrouped": "Não Agrupado"
"ungrouped": "Não Agrupado",
"totalTimeLogged": "Tempo Total Registrado",
"acrossAllTeamMembers": "Em todos os membros da equipe",
"expectedCapacity": "Capacidade Esperada",
"basedOnWorkingSchedule": "Baseado no cronograma de trabalho",
"teamUtilization": "Utilização da Equipe",
"targetRange": "Faixa Alvo",
"variance": "Variância",
"overCapacity": "Sobre Capacidade",
"underCapacity": "Abaixo da Capacidade",
"considerWorkloadRedistribution": "Considerar redistribuição de carga de trabalho",
"capacityAvailableForNewProjects": "Capacidade disponível para novos projetos",
"optimal": "Ótimo",
"underUtilized": "Subutilizado",
"overUtilized": "Sobreutilizado"
}

View File

@@ -47,7 +47,7 @@
"skipping": "跳过中...",
"formTitle": "创建您的第一个任务。",
"step3Title": "邀请您的团队一起工作",
"maxMembers": "(您最多可以邀请 5 名成员)",
"maxMembers": "(您最多可以邀请5名成员)",
"maxTasks": "(您最多可以创建 5 个任务)",
"membersStepTitle": "邀请您的团队",
@@ -83,7 +83,6 @@
"useCaseOther": "其他",
"selectedText": "已选择",
"previousToolsQuestion": "您之前用过哪些工具?(可选)",
"previousToolsPlaceholder": "例如Asana、Trello、Jira、Monday.com 等",
"discoveryTitle": "最后一个问题……",
"discoveryDescription": "帮助我们了解您是如何发现 Worklenz 的",
@@ -120,7 +119,6 @@
"templateStartup": "初创启动",
"templateStartupDesc": "MVP 开发、融资、增长",
"tasksStepTitle": "添加您的第一个任务",
"tasksStepDescription": "将 \"{{projectName}}\" 拆分为可执行任务以开始",
"taskPlaceholder": "任务 {{index}} - 例如:需要做什么?",
"addAnotherTask": "添加另一个任务 ({{current}}/{{max}})",

View File

@@ -4,5 +4,69 @@
"owner": "组织所有者",
"admins": "组织管理员",
"contactNumber": "添加联系电话",
"edit": "编辑"
"edit": "编辑",
"organizationWorkingDaysAndHours": "组织工作日和工作时间",
"workingDays": "工作日",
"workingHours": "工作时间",
"monday": "星期一",
"tuesday": "星期二",
"wednesday": "星期三",
"thursday": "星期四",
"friday": "星期五",
"saturday": "星期六",
"sunday": "星期日",
"hours": "小时",
"saveButton": "保存",
"saved": "设置保存成功",
"errorSaving": "保存设置时出错",
"organizationCalculationMethod": "组织计算方法",
"calculationMethod": "计算方法",
"hourlyRates": "小时费率",
"manDays": "人天",
"saveChanges": "保存更改",
"hourlyCalculationDescription": "所有项目成本将使用估算小时数 × 小时费率计算",
"manDaysCalculationDescription": "所有项目成本将使用估算人天数 × 日费率计算",
"calculationMethodTooltip": "此设置适用于您组织中的所有项目",
"calculationMethodUpdated": "组织计算方法更新成功",
"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": "否"
}

View File

@@ -0,0 +1,33 @@
{
"settings": "设置",
"organizationWorkingDaysAndHours": "组织工作日和工作时间",
"workingDays": "工作日",
"workingHours": "工作时间",
"hours": "小时",
"monday": "星期一",
"tuesday": "星期二",
"wednesday": "星期三",
"thursday": "星期四",
"friday": "星期五",
"saturday": "星期六",
"sunday": "星期日",
"saveButton": "保存",
"saved": "设置保存成功",
"errorSaving": "保存设置时出错",
"holidaySettings": "假期设置",
"country": "国家",
"countryRequired": "请选择一个国家",
"selectCountry": "选择国家",
"state": "州/省",
"selectState": "选择州/省(可选)",
"autoSyncHolidays": "自动同步官方假期",
"saveHolidaySettings": "保存假期设置",
"holidaySettingsSaved": "假期设置保存成功",
"errorSavingHolidaySettings": "保存假期设置时出错",
"addCustomHoliday": "添加自定义假期",
"officialHolidaysFrom": "官方假期来自",
"workingDay": "工作日",
"holiday": "假期",
"today": "今天",
"cannotEditOfficialHoliday": "无法编辑官方假期"
}

View File

@@ -4,5 +4,6 @@
"teams": "团队",
"billing": "账单",
"projects": "项目",
"settings": "设置",
"adminCenter": "管理中心"
}

View File

@@ -34,7 +34,6 @@
"noSubtasks": "无子任务",
"showSubtasks": "显示子任务",
"hideSubtasks": "隐藏子任务",
"errorLoadingTasks": "加载任务时出错",
"noTasksFound": "未找到任务",
"loadingFilters": "正在加载过滤器...",

View File

@@ -38,5 +38,13 @@
"createClient": "创建客户",
"searchInputPlaceholder": "按名称或电子邮件搜索",
"hoursPerDayValidationMessage": "每天小时数必须是1到24之间的数字",
"noPermission": "无权限"
"workingDaysValidationMessage": "工作日必须是正数",
"manDaysValidationMessage": "人天必须是正数",
"noPermission": "无权限",
"progressSettings": "进度设置",
"manualProgress": "手动进度",
"manualProgressTooltip": "允许对没有子任务的任务进行手动进度更新",
"weightedProgress": "加权进度",
"weightedProgressTooltip": "基于子任务权重计算进度",
"timeProgress": "基于时间的进度"
}

View File

@@ -0,0 +1,114 @@
{
"financeText": "财务",
"ratecardSingularText": "费率卡",
"groupByText": "分组方式",
"statusText": "状态",
"phaseText": "阶段",
"priorityText": "优先级",
"exportButton": "导出",
"currencyText": "货币",
"importButton": "导入",
"filterText": "筛选",
"billableOnlyText": "仅可计费",
"nonBillableOnlyText": "仅不可计费",
"allTasksText": "所有任务",
"projectBudgetOverviewText": "项目预算概览",
"taskColumn": "任务",
"membersColumn": "成员",
"hoursColumn": "预估工时",
"manDaysColumn": "预估人天",
"actualManDaysColumn": "实际人天",
"effortVarianceColumn": "工作量偏差",
"totalTimeLoggedColumn": "总记录时间",
"costColumn": "实际成本",
"estimatedCostColumn": "预估成本",
"fixedCostColumn": "固定成本",
"totalBudgetedCostColumn": "总预算成本",
"totalActualCostColumn": "总实际成本",
"varianceColumn": "偏差",
"totalText": "总计",
"noTasksFound": "未找到任务",
"addRoleButton": "+ 添加角色",
"ratecardImportantNotice": "* 此费率卡基于公司标准职位和费率生成。但是,您可以根据项目灵活修改。这些更改不会影响组织的标准职位和费率。",
"saveButton": "保存",
"jobTitleColumn": "职位",
"ratePerHourColumn": "每小时费率",
"ratePerManDayColumn": "每人每日费率",
"calculationMethodText": "计算方法",
"hourlyRatesText": "小时费率",
"manDaysText": "人天",
"hoursPerDayText": "每日工时",
"ratecardPluralText": "费率卡",
"labourHoursColumn": "工时",
"actions": "操作",
"selectJobTitle": "选择职位",
"ratecardsPluralText": "费率卡模板",
"deleteConfirm": "您确定吗?",
"yes": "是",
"no": "否",
"alreadyImportedRateCardMessage": "已导入费率卡。清除所有已导入的费率卡以添加新的费率卡。",
"budgetOverviewTooltips": {
"manualBudget": "项目经理设置的手动项目预算金额",
"totalActualCost": "包括固定成本在内的总实际成本",
"variance": "手动预算与实际成本之间的差异",
"utilization": "手动预算使用百分比",
"estimatedHours": "所有任务的总预估工时",
"fixedCosts": "所有任务的总固定成本",
"timeBasedCost": "基于时间跟踪的实际成本(不包括固定成本)",
"remainingBudget": "剩余预算金额"
},
"budgetModal": {
"title": "编辑项目预算",
"description": "为此项目设置手动预算。此预算将用于所有财务计算,应包括基于时间的成本和固定成本。",
"placeholder": "输入预算金额",
"saveButton": "保存",
"cancelButton": "取消"
},
"budgetStatistics": {
"manualBudget": "手动预算",
"totalActualCost": "总实际成本",
"variance": "偏差",
"budgetUtilization": "预算使用率",
"estimatedHours": "预估工时",
"fixedCosts": "固定成本",
"timeBasedCost": "基于时间的成本",
"remainingBudget": "剩余预算",
"noManualBudgetSet": "(未设置手动预算)"
},
"budgetSettingsDrawer": {
"title": "项目预算设置",
"budgetConfiguration": "预算配置",
"projectBudget": "项目预算",
"projectBudgetTooltip": "为此项目分配的总预算",
"currency": "货币",
"costCalculationMethod": "成本计算方法",
"calculationMethod": "计算方法",
"workingHoursPerDay": "每日工作工时",
"workingHoursPerDayTooltip": "人天计算中每日的工作工时数",
"hourlyCalculationInfo": "成本将使用预估工时 × 小时费率计算",
"manDaysCalculationInfo": "成本将使用预估人天 × 日费率计算",
"importantNotes": "重要说明",
"calculationMethodChangeNote": "• 更改计算方法将影响此项目中所有任务的成本计算方式",
"immediateEffectNote": "• 更改立即生效并将重新计算所有项目总计",
"projectWideNote": "• 预算设置适用于整个项目及其所有任务",
"cancel": "取消",
"saveChanges": "保存更改",
"budgetSettingsUpdated": "预算设置更新成功",
"budgetSettingsUpdateFailed": "更新预算设置失败"
},
"columnTooltips": {
"hours": "所有任务的总预估工时。根据任务时间估算计算。",
"manDays": "所有任务的总预估人天。基于每个工作日 {{hoursPerDay}} 小时。",
"actualManDays": "基于记录时间花费的实际人天。计算方式:总记录时间 ÷ {{hoursPerDay}} 小时/天。",
"effortVariance": "预估人天与实际人天之间的差异。正值表示过度估算,负值表示估算不足。",
"totalTimeLogged": "团队成员在所有任务中实际记录的总时间。",
"estimatedCostHourly": "预估成本计算方式:预估工时 × 分配团队成员的小时费率。",
"estimatedCostManDays": "预估成本计算方式:预估人天 × 分配团队成员的日费率。",
"actualCost": "基于记录时间的实际成本。计算方式:记录时间 × 团队成员的小时费率。",
"fixedCost": "不依赖时间花费的固定成本。每个任务手动添加。",
"totalBudgetHourly": "总预算成本包括预估成本(工时 × 小时费率)+ 固定成本。",
"totalBudgetManDays": "总预算成本包括预估成本(人天 × 日费率)+ 固定成本。",
"totalActual": "总实际成本包括基于时间的成本 + 固定成本。",
"variance": "成本偏差:总预算成本 - 总实际成本。正值表示预算内,负值表示超预算。"
}
}

View File

@@ -10,5 +10,6 @@
"error": "加载项目时出错",
"pinnedTab": "已固定为默认标签页",
"pinTab": "固定为默认标签页",
"unpinTab": "取消固定默认标签页"
"unpinTab": "取消固定默认标签页",
"finance": "财务"
}

View File

@@ -1,11 +1,11 @@
{
"importTaskTemplate": "导入任务模板",
"templateName": "模板名称",
"templateDescription": "模板描述",
"selectedTasks": "已选任务",
"tasks": "任务",
"templates": "模板",
"remove": "移除",
"cancel": "取消",
"import": "导入"
"importTaskTemplate": "导入任务模板",
"templateName": "模板名称",
"templateDescription": "模板描述",
"selectedTasks": "已选任务",
"tasks": "任务",
"templates": "模板",
"remove": "移除",
"cancel": "取消",
"import": "导入"
}

View File

@@ -0,0 +1,55 @@
{
"nameColumn": "名称",
"createdColumn": "创建时间",
"noProjectsAvailable": "没有可用的项目",
"deleteConfirmationTitle": "您确定要删除此费率卡吗?",
"deleteConfirmationOk": "是,删除",
"deleteConfirmationCancel": "取消",
"searchPlaceholder": "按名称搜索费率卡",
"createRatecard": "创建费率卡",
"editTooltip": "编辑费率卡",
"deleteTooltip": "删除费率卡",
"fetchError": "获取费率卡失败",
"createError": "创建费率卡失败",
"deleteSuccess": "费率卡删除成功",
"deleteError": "删除费率卡失败",
"jobTitleColumn": "职位名称",
"ratePerHourColumn": "每小时费率",
"ratePerDayColumn": "每日费率",
"ratePerManDayColumn": "每人每日费率",
"saveButton": "保存",
"addRoleButton": "添加角色",
"createRatecardSuccessMessage": "费率卡创建成功",
"createRatecardErrorMessage": "创建费率卡失败",
"updateRatecardSuccessMessage": "费率卡更新成功",
"updateRatecardErrorMessage": "更新费率卡失败",
"currency": "货币",
"actionsColumn": "操作",
"addAllButton": "全部添加",
"removeAllButton": "全部移除",
"selectJobTitle": "选择职位名称",
"unsavedChangesTitle": "您有未保存的更改",
"unsavedChangesMessage": "您想在离开前保存更改吗?",
"unsavedChangesSave": "保存",
"unsavedChangesDiscard": "放弃",
"ratecardNameRequired": "费率卡名称为必填项",
"ratecardNamePlaceholder": "输入费率卡名称",
"noRatecardsFound": "未找到费率卡",
"loadingRateCards": "正在加载费率卡...",
"noJobTitlesAvailable": "没有可用的职位名称",
"noRolesAdded": "尚未添加角色",
"createFirstJobTitle": "创建第一个职位名称",
"jobRolesTitle": "职位角色",
"noJobTitlesMessage": "请先在职位名称设置中创建职位名称,然后再向费率卡添加角色。",
"createNewJobTitle": "创建新职位名称",
"jobTitleNamePlaceholder": "输入职位名称",
"jobTitleNameRequired": "职位名称为必填项",
"jobTitleCreatedSuccess": "职位名称创建成功",
"jobTitleCreateError": "职位名称创建失败",
"createButton": "创建",
"cancelButton": "取消",
"discardButton": "放弃",
"manDaysCalculationMessage": "组织正在使用人日计算({{hours}}小时/天)。上述费率代表日费率。",
"hourlyCalculationMessage": "组织正在使用小时计算。上述费率代表小时费率。"
}

View File

@@ -0,0 +1,34 @@
{
"recurring": "重复",
"recurringTaskConfiguration": "重复任务配置",
"repeats": "重复",
"daily": "每日",
"weekly": "每周",
"everyXDays": "每X天",
"everyXWeeks": "每X周",
"everyXMonths": "每X月",
"monthly": "每月",
"selectDaysOfWeek": "选择星期几",
"mon": "周一",
"tue": "周二",
"wed": "周三",
"thu": "周四",
"fri": "周五",
"sat": "周六",
"sun": "周日",
"monthlyRepeatType": "每月重复类型",
"onSpecificDate": "在特定日期",
"onSpecificDay": "在特定星期几",
"dateOfMonth": "月份日期",
"weekOfMonth": "月份周数",
"dayOfWeek": "星期几",
"first": "第一",
"second": "第二",
"third": "第三",
"fourth": "第四",
"last": "最后",
"intervalDays": "间隔(天)",
"intervalWeeks": "间隔(周)",
"intervalMonths": "间隔(月)",
"saveChanges": "保存更改"
}

View File

@@ -1,24 +1,24 @@
{
"taskSelected": "任务已选择",
"tasksSelected": "任务已选择",
"changeStatus": "更改状态/优先级/阶段",
"changeLabel": "更改标签",
"assignToMe": "分配给我",
"changeAssignees": "更改受托人",
"archive": "归档",
"unarchive": "取消归档",
"delete": "删除",
"moreOptions": "更多选项",
"deselectAll": "取消全选",
"status": "状态",
"priority": "优先级",
"phase": "阶段",
"member": "成员",
"createTaskTemplate": "创建任务模板",
"apply": "应用",
"createLabel": "+ 创建标签",
"hitEnterToCreate": "按回车键创建",
"pendingInvitation": "待处理邀请",
"noMatchingLabels": "没有匹配的标签",
"noLabels": "没有标签"
"taskSelected": "任务已选择",
"tasksSelected": "任务已选择",
"changeStatus": "更改状态/优先级/阶段",
"changeLabel": "更改标签",
"assignToMe": "分配给我",
"changeAssignees": "更改受托人",
"archive": "归档",
"unarchive": "取消归档",
"delete": "删除",
"moreOptions": "更多选项",
"deselectAll": "取消全选",
"status": "状态",
"priority": "优先级",
"phase": "阶段",
"member": "成员",
"createTaskTemplate": "创建任务模板",
"apply": "应用",
"createLabel": "+ 创建标签",
"hitEnterToCreate": "按回车键创建",
"pendingInvitation": "待处理邀请",
"noMatchingLabels": "没有匹配的标签",
"noLabels": "没有标签"
}

View File

@@ -4,6 +4,7 @@
"timeSheet": "时间表",
"searchByName": "按名称搜索",
"selectAll": "全选",
"clearAll": "清除全部",
"teams": "团队",
"searchByProject": "按项目名称搜索",
"projects": "项目",
@@ -11,6 +12,8 @@
"categories": "类别",
"billable": "可计费",
"nonBillable": "不可计费",
"allBillableTypes": "所有计费类型",
"filterByBillableStatus": "按计费状态筛选",
"total": "总计",
"projectsTimeSheet": "项目时间表",
"loggedTime": "已记录时间(小时)",
@@ -19,6 +22,9 @@
"for": "为",
"membersTimeSheet": "成员时间表",
"member": "成员",
"members": "成员",
"searchByMember": "按成员搜索",
"utilization": "利用率",
"estimatedVsActual": "预计用时 vs 实际用时",
"workingDays": "工作日",
"manDays": "人天",
@@ -29,5 +35,32 @@
"noCategory": "无类别",
"noProjects": "未找到项目",
"noTeams": "未找到团队",
"noData": "未找到数据"
"noData": "未找到数据",
"groupBy": "分组方式",
"groupByCategory": "类别",
"groupByTeam": "团队",
"groupByStatus": "状态",
"groupByNone": "无",
"clearSearch": "清除搜索",
"selectedProjects": "已选项目",
"projectsSelected": "个项目已选择",
"showSelected": "仅显示已选择",
"expandAll": "全部展开",
"collapseAll": "全部折叠",
"ungrouped": "未分组",
"totalTimeLogged": "总记录时间",
"acrossAllTeamMembers": "跨所有团队成员",
"expectedCapacity": "预期容量",
"basedOnWorkingSchedule": "基于工作时间表",
"teamUtilization": "团队利用率",
"targetRange": "目标范围",
"variance": "差异",
"overCapacity": "超出容量",
"underCapacity": "容量不足",
"considerWorkloadRedistribution": "考虑工作负载重新分配",
"capacityAvailableForNewProjects": "可用于新项目的容量",
"optimal": "最佳",
"underUtilized": "利用率不足",
"overUtilized": "过度利用"
}

View File

@@ -1,5 +1,5 @@
{
"title": "未授权!",
"subtitle": "您无权访问此页面",
"button": "返回首页"
"title": "未授权!",
"subtitle": "您无权访问此页面",
"button": "返回首页"
}

View File

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

View File

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

View File

@@ -19,6 +19,7 @@ import {
IFreePlanSettings,
IBillingAccountStorage,
} from '@/types/admin-center/admin-center.types';
import { IOrganizationHolidaySettings } from '@/types/holiday/holiday.types';
import { IClient } from '@/types/client.types';
import { toQueryString } from '@/utils/toQueryString';
@@ -280,4 +281,31 @@ export const adminCenterApiService = {
);
return response.data;
},
async updateOrganizationCalculationMethod(
calculationMethod: 'hourly' | 'man_days'
): Promise<IServerResponse<any>> {
const response = await apiClient.put<IServerResponse<any>>(
`${rootUrl}/organization/calculation-method`,
{
calculation_method: calculationMethod,
}
);
return response.data;
},
async updateOrganizationHolidaySettings(
settings: IOrganizationHolidaySettings
): Promise<IServerResponse<any>> {
const response = await apiClient.put<IServerResponse<any>>(
`${rootUrl}/organization/holiday-settings`,
settings
);
return response.data;
},
async getAdminCenterSettings(): Promise<IServerResponse<IOrganization>> {
const response = await apiClient.get<IServerResponse<IOrganization>>(`${rootUrl}/settings`);
return response.data;
},
};

View File

@@ -0,0 +1,845 @@
import { API_BASE_URL } from '@/shared/constants';
import apiClient from '../api-client';
import { IServerResponse } from '@/types/common.types';
import {
IHolidayType,
IOrganizationHoliday,
ICountryHoliday,
IAvailableCountry,
ICreateHolidayRequest,
IUpdateHolidayRequest,
IImportCountryHolidaysRequest,
IHolidayCalendarEvent,
IOrganizationHolidaySettings,
ICountryWithStates,
ICombinedHolidaysRequest,
IHolidayDateRange,
} from '@/types/holiday/holiday.types';
import logger from '@/utils/errorLogger';
import { error } from 'console';
const rootUrl = `${API_BASE_URL}/holidays`;
export const holidayApiService = {
// Holiday types
getHolidayTypes: async (): Promise<IServerResponse<IHolidayType[]>> => {
const response = await apiClient.get<IServerResponse<IHolidayType[]>>(
`${rootUrl}/types`
);
return response.data;
},
// Organization holidays
getOrganizationHolidays: async (
year?: number
): Promise<IServerResponse<IOrganizationHoliday[]>> => {
const params = year ? { year } : {};
const response = await apiClient.get<IServerResponse<IOrganizationHoliday[]>>(
`${rootUrl}/organization`,
{ params }
);
return response.data;
},
// Holiday CRUD operations
createOrganizationHoliday: async (data: ICreateHolidayRequest): Promise<IServerResponse<any>> => {
const response = await apiClient.post<IServerResponse<any>>(
`${rootUrl}/organization`,
data
);
return response.data;
},
updateOrganizationHoliday: async (
id: string,
data: IUpdateHolidayRequest
): Promise<IServerResponse<any>> => {
const response = await apiClient.put<IServerResponse<any>>(
`${rootUrl}/organization/${id}`,
data
);
return response.data;
},
deleteOrganizationHoliday: async (id: string): Promise<IServerResponse<any>> => {
const response = await apiClient.delete<IServerResponse<any>>(
`${rootUrl}/organization/${id}`
);
return response.data;
},
// Country holidays - PLACEHOLDER with all date-holidays supported countries
getAvailableCountries: async (): Promise<IServerResponse<IAvailableCountry[]>> => {
// Return all countries supported by date-holidays library (simplified list without states)
const availableCountries = [
{ code: 'AD', name: 'Andorra' },
{ code: 'AE', name: 'United Arab Emirates' },
{ code: 'AG', name: 'Antigua & Barbuda' },
{ code: 'AI', name: 'Anguilla' },
{ code: 'AL', name: 'Albania' },
{ code: 'AM', name: 'Armenia' },
{ code: 'AO', name: 'Angola' },
{ code: 'AR', name: 'Argentina' },
{ code: 'AT', name: 'Austria' },
{ code: 'AU', name: 'Australia' },
{ code: 'AW', name: 'Aruba' },
{ code: 'AZ', name: 'Azerbaijan' },
{ code: 'BA', name: 'Bosnia and Herzegovina' },
{ code: 'BB', name: 'Barbados' },
{ code: 'BD', name: 'Bangladesh' },
{ code: 'BE', name: 'Belgium' },
{ code: 'BF', name: 'Burkina Faso' },
{ code: 'BG', name: 'Bulgaria' },
{ code: 'BH', name: 'Bahrain' },
{ code: 'BI', name: 'Burundi' },
{ code: 'BJ', name: 'Benin' },
{ code: 'BM', name: 'Bermuda' },
{ code: 'BN', name: 'Brunei' },
{ code: 'BO', name: 'Bolivia' },
{ code: 'BR', name: 'Brazil' },
{ code: 'BS', name: 'Bahamas' },
{ code: 'BW', name: 'Botswana' },
{ code: 'BY', name: 'Belarus' },
{ code: 'BZ', name: 'Belize' },
{ code: 'CA', name: 'Canada' },
{ code: 'CH', name: 'Switzerland' },
{ code: 'CK', name: 'Cook Islands' },
{ code: 'CL', name: 'Chile' },
{ code: 'CM', name: 'Cameroon' },
{ code: 'CN', name: 'China' },
{ code: 'CO', name: 'Colombia' },
{ code: 'CR', name: 'Costa Rica' },
{ code: 'CU', name: 'Cuba' },
{ code: 'CY', name: 'Cyprus' },
{ code: 'CZ', name: 'Czech Republic' },
{ code: 'DE', name: 'Germany' },
{ code: 'DK', name: 'Denmark' },
{ code: 'DO', name: 'Dominican Republic' },
{ code: 'EC', name: 'Ecuador' },
{ code: 'EE', name: 'Estonia' },
{ code: 'ES', name: 'Spain' },
{ code: 'ET', name: 'Ethiopia' },
{ code: 'FI', name: 'Finland' },
{ code: 'FR', name: 'France' },
{ code: 'GB', name: 'United Kingdom' },
{ code: 'GE', name: 'Georgia' },
{ code: 'GR', name: 'Greece' },
{ code: 'GT', name: 'Guatemala' },
{ code: 'HK', name: 'Hong Kong' },
{ code: 'HN', name: 'Honduras' },
{ code: 'HR', name: 'Croatia' },
{ code: 'HU', name: 'Hungary' },
{ code: 'ID', name: 'Indonesia' },
{ code: 'IE', name: 'Ireland' },
{ code: 'IL', name: 'Israel' },
{ code: 'IN', name: 'India' },
{ code: 'IR', name: 'Iran' },
{ code: 'IS', name: 'Iceland' },
{ code: 'IT', name: 'Italy' },
{ code: 'JM', name: 'Jamaica' },
{ code: 'JP', name: 'Japan' },
{ code: 'KE', name: 'Kenya' },
{ code: 'KR', name: 'South Korea' },
{ code: 'LI', name: 'Liechtenstein' },
{ code: 'LT', name: 'Lithuania' },
{ code: 'LU', name: 'Luxembourg' },
{ code: 'LV', name: 'Latvia' },
{ code: 'MA', name: 'Morocco' },
{ code: 'MC', name: 'Monaco' },
{ code: 'MD', name: 'Moldova' },
{ code: 'MK', name: 'North Macedonia' },
{ code: 'MT', name: 'Malta' },
{ code: 'MX', name: 'Mexico' },
{ code: 'MY', name: 'Malaysia' },
{ code: 'NI', name: 'Nicaragua' },
{ code: 'NL', name: 'Netherlands' },
{ code: 'NO', name: 'Norway' },
{ code: 'NZ', name: 'New Zealand' },
{ code: 'PA', name: 'Panama' },
{ code: 'PE', name: 'Peru' },
{ code: 'PH', name: 'Philippines' },
{ code: 'PL', name: 'Poland' },
{ code: 'PR', name: 'Puerto Rico' },
{ code: 'PT', name: 'Portugal' },
{ code: 'RO', name: 'Romania' },
{ code: 'RS', name: 'Serbia' },
{ code: 'RU', name: 'Russia' },
{ code: 'SA', name: 'Saudi Arabia' },
{ code: 'SE', name: 'Sweden' },
{ code: 'SG', name: 'Singapore' },
{ code: 'SI', name: 'Slovenia' },
{ code: 'SK', name: 'Slovakia' },
{ code: 'LK', name: 'Sri Lanka' },
{ code: 'TH', name: 'Thailand' },
{ code: 'TR', name: 'Turkey' },
{ code: 'UA', name: 'Ukraine' },
{ code: 'US', name: 'United States' },
{ code: 'UY', name: 'Uruguay' },
{ code: 'VE', name: 'Venezuela' },
{ code: 'VN', name: 'Vietnam' },
{ code: 'ZA', name: 'South Africa' }
];
return {
done: true,
body: availableCountries,
} as IServerResponse<IAvailableCountry[]>;
},
getCountryHolidays: async (
countryCode: string,
year?: number
): Promise<IServerResponse<ICountryHoliday[]>> => {
const params: any = { country_code: countryCode };
if (year) {
params.year = year;
}
const response = await apiClient.get<IServerResponse<ICountryHoliday[]>>(
`${rootUrl}/countries/${countryCode}`,
{ params }
);
return response.data;
},
importCountryHolidays: async (
data: IImportCountryHolidaysRequest
): Promise<IServerResponse<any>> => {
// Return success for now
return {
done: true,
body: {},
} as IServerResponse<any>;
},
// Calendar view - PLACEHOLDER until backend implements
getHolidayCalendar: async (
year: number,
month: number
): Promise<IServerResponse<IHolidayCalendarEvent[]>> => {
// Return empty array for now
return {
done: true,
body: [],
} as IServerResponse<IHolidayCalendarEvent[]>;
},
// Organization holiday settings
getOrganizationHolidaySettings: async (): Promise<
IServerResponse<IOrganizationHolidaySettings>
> => {
const response = await apiClient.get<IServerResponse<IOrganizationHolidaySettings>>(
`${API_BASE_URL}/admin-center/organization/holiday-settings`
);
return response.data;
},
updateOrganizationHolidaySettings: async (
data: IOrganizationHolidaySettings
): Promise<IServerResponse<any>> => {
const response = await apiClient.put<IServerResponse<any>>(
`${API_BASE_URL}/admin-center/organization/holiday-settings`,
data
);
return response.data;
},
// Countries with states
getCountriesWithStates: async (): Promise<IServerResponse<ICountryWithStates[]>> => {
try {
const response = await apiClient.get<IServerResponse<ICountryWithStates[]>>(
`${API_BASE_URL}/admin-center/countries-with-states`
);
return response.data;
} catch (error) {
logger.error('Error fetching countries with states from API, falling back to static data', error);
// Fallback to static data if API fails
const supportedCountries = [
{ code: 'AD', name: 'Andorra' },
{ code: 'AE', name: 'United Arab Emirates' },
{ code: 'AG', name: 'Antigua & Barbuda' },
{ code: 'AI', name: 'Anguilla' },
{ code: 'AL', name: 'Albania' },
{ code: 'AM', name: 'Armenia' },
{ code: 'AO', name: 'Angola' },
{ code: 'AR', name: 'Argentina' },
{
code: 'AT',
name: 'Austria',
states: [
{ code: '1', name: 'Burgenland' },
{ code: '2', name: 'Kärnten' },
{ code: '3', name: 'Niederösterreich' },
{ code: '4', name: 'Oberösterreich' },
{ code: '5', name: 'Salzburg' },
{ code: '6', name: 'Steiermark' },
{ code: '7', name: 'Tirol' },
{ code: '8', name: 'Vorarlberg' },
{ code: '9', name: 'Wien' }
]
},
{
code: 'AU',
name: 'Australia',
states: [
{ code: 'act', name: 'Australian Capital Territory' },
{ code: 'nsw', name: 'New South Wales' },
{ code: 'nt', name: 'Northern Territory' },
{ code: 'qld', name: 'Queensland' },
{ code: 'sa', name: 'South Australia' },
{ code: 'tas', name: 'Tasmania' },
{ code: 'vic', name: 'Victoria' },
{ code: 'wa', name: 'Western Australia' }
]
},
{ code: 'AW', name: 'Aruba' },
{ code: 'AZ', name: 'Azerbaijan' },
{ code: 'BA', name: 'Bosnia and Herzegovina' },
{ code: 'BB', name: 'Barbados' },
{ code: 'BD', name: 'Bangladesh' },
{ code: 'BE', name: 'Belgium' },
{ code: 'BF', name: 'Burkina Faso' },
{ code: 'BG', name: 'Bulgaria' },
{ code: 'BH', name: 'Bahrain' },
{ code: 'BI', name: 'Burundi' },
{ code: 'BJ', name: 'Benin' },
{ code: 'BM', name: 'Bermuda' },
{ code: 'BN', name: 'Brunei' },
{ code: 'BO', name: 'Bolivia' },
{
code: 'BR',
name: 'Brazil',
states: [
{ code: 'ac', name: 'Acre' },
{ code: 'al', name: 'Alagoas' },
{ code: 'ap', name: 'Amapá' },
{ code: 'am', name: 'Amazonas' },
{ code: 'ba', name: 'Bahia' },
{ code: 'ce', name: 'Ceará' },
{ code: 'df', name: 'Distrito Federal' },
{ code: 'es', name: 'Espírito Santo' },
{ code: 'go', name: 'Goiás' },
{ code: 'ma', name: 'Maranhão' },
{ code: 'mt', name: 'Mato Grosso' },
{ code: 'ms', name: 'Mato Grosso do Sul' },
{ code: 'mg', name: 'Minas Gerais' },
{ code: 'pa', name: 'Pará' },
{ code: 'pb', name: 'Paraíba' },
{ code: 'pr', name: 'Paraná' },
{ code: 'pe', name: 'Pernambuco' },
{ code: 'pi', name: 'Piauí' },
{ code: 'rj', name: 'Rio de Janeiro' },
{ code: 'rn', name: 'Rio Grande do Norte' },
{ code: 'rs', name: 'Rio Grande do Sul' },
{ code: 'ro', name: 'Rondônia' },
{ code: 'rr', name: 'Roraima' },
{ code: 'sc', name: 'Santa Catarina' },
{ code: 'sp', name: 'São Paulo' },
{ code: 'se', name: 'Sergipe' },
{ code: 'to', name: 'Tocantins' }
]
},
{ code: 'BS', name: 'Bahamas' },
{ code: 'BW', name: 'Botswana' },
{ code: 'BY', name: 'Belarus' },
{ code: 'BZ', name: 'Belize' },
{
code: 'CA',
name: 'Canada',
states: [
{ code: 'ab', name: 'Alberta' },
{ code: 'bc', name: 'British Columbia' },
{ code: 'mb', name: 'Manitoba' },
{ code: 'nb', name: 'New Brunswick' },
{ code: 'nl', name: 'Newfoundland and Labrador' },
{ code: 'ns', name: 'Nova Scotia' },
{ code: 'nt', name: 'Northwest Territories' },
{ code: 'nu', name: 'Nunavut' },
{ code: 'on', name: 'Ontario' },
{ code: 'pe', name: 'Prince Edward Island' },
{ code: 'qc', name: 'Quebec' },
{ code: 'sk', name: 'Saskatchewan' },
{ code: 'yt', name: 'Yukon' }
]
},
{
code: 'CH',
name: 'Switzerland',
states: [
{ code: 'ag', name: 'Aargau' },
{ code: 'ai', name: 'Appenzell Innerrhoden' },
{ code: 'ar', name: 'Appenzell Ausserrhoden' },
{ code: 'be', name: 'Bern' },
{ code: 'bl', name: 'Basel-Landschaft' },
{ code: 'bs', name: 'Basel-Stadt' },
{ code: 'fr', name: 'Fribourg' },
{ code: 'ge', name: 'Geneva' },
{ code: 'gl', name: 'Glarus' },
{ code: 'gr', name: 'Graubünden' },
{ code: 'ju', name: 'Jura' },
{ code: 'lu', name: 'Lucerne' },
{ code: 'ne', name: 'Neuchâtel' },
{ code: 'nw', name: 'Nidwalden' },
{ code: 'ow', name: 'Obwalden' },
{ code: 'sg', name: 'St. Gallen' },
{ code: 'sh', name: 'Schaffhausen' },
{ code: 'so', name: 'Solothurn' },
{ code: 'sz', name: 'Schwyz' },
{ code: 'tg', name: 'Thurgau' },
{ code: 'ti', name: 'Ticino' },
{ code: 'ur', name: 'Uri' },
{ code: 'vd', name: 'Vaud' },
{ code: 'vs', name: 'Valais' },
{ code: 'zg', name: 'Zug' },
{ code: 'zh', name: 'Zurich' }
]
},
{ code: 'CK', name: 'Cook Islands' },
{ code: 'CL', name: 'Chile' },
{ code: 'CM', name: 'Cameroon' },
{ code: 'CN', name: 'China' },
{ code: 'CO', name: 'Colombia' },
{ code: 'CR', name: 'Costa Rica' },
{ code: 'CU', name: 'Cuba' },
{ code: 'CY', name: 'Cyprus' },
{ code: 'CZ', name: 'Czech Republic' },
{
code: 'DE',
name: 'Germany',
states: [
{ code: 'bw', name: 'Baden-Württemberg' },
{ code: 'by', name: 'Bayern' },
{ code: 'be', name: 'Berlin' },
{ code: 'bb', name: 'Brandenburg' },
{ code: 'hb', name: 'Bremen' },
{ code: 'hh', name: 'Hamburg' },
{ code: 'he', name: 'Hessen' },
{ code: 'mv', name: 'Mecklenburg-Vorpommern' },
{ code: 'ni', name: 'Niedersachsen' },
{ code: 'nw', name: 'Nordrhein-Westfalen' },
{ code: 'rp', name: 'Rheinland-Pfalz' },
{ code: 'sl', name: 'Saarland' },
{ code: 'sn', name: 'Sachsen' },
{ code: 'st', name: 'Sachsen-Anhalt' },
{ code: 'sh', name: 'Schleswig-Holstein' },
{ code: 'th', name: 'Thüringen' }
]
},
{ code: 'DK', name: 'Denmark' },
{ code: 'DO', name: 'Dominican Republic' },
{ code: 'EC', name: 'Ecuador' },
{ code: 'EE', name: 'Estonia' },
{ code: 'ES', name: 'Spain' },
{ code: 'ET', name: 'Ethiopia' },
{ code: 'FI', name: 'Finland' },
{ code: 'FR', name: 'France' },
{
code: 'GB',
name: 'United Kingdom',
states: [
{ code: 'eng', name: 'England' },
{ code: 'nir', name: 'Northern Ireland' },
{ code: 'sct', name: 'Scotland' },
{ code: 'wls', name: 'Wales' }
]
},
{ code: 'GE', name: 'Georgia' },
{ code: 'GR', name: 'Greece' },
{ code: 'GT', name: 'Guatemala' },
{ code: 'HK', name: 'Hong Kong' },
{ code: 'HN', name: 'Honduras' },
{ code: 'HR', name: 'Croatia' },
{ code: 'HU', name: 'Hungary' },
{ code: 'ID', name: 'Indonesia' },
{ code: 'IE', name: 'Ireland' },
{ code: 'IL', name: 'Israel' },
{
code: 'IN',
name: 'India',
states: [
{ code: 'an', name: 'Andaman and Nicobar Islands' },
{ code: 'ap', name: 'Andhra Pradesh' },
{ code: 'ar', name: 'Arunachal Pradesh' },
{ code: 'as', name: 'Assam' },
{ code: 'br', name: 'Bihar' },
{ code: 'ch', name: 'Chandigarh' },
{ code: 'ct', name: 'Chhattisgarh' },
{ code: 'dd', name: 'Daman and Diu' },
{ code: 'dl', name: 'Delhi' },
{ code: 'ga', name: 'Goa' },
{ code: 'gj', name: 'Gujarat' },
{ code: 'hr', name: 'Haryana' },
{ code: 'hp', name: 'Himachal Pradesh' },
{ code: 'jk', name: 'Jammu and Kashmir' },
{ code: 'jh', name: 'Jharkhand' },
{ code: 'ka', name: 'Karnataka' },
{ code: 'kl', name: 'Kerala' },
{ code: 'ld', name: 'Lakshadweep' },
{ code: 'mp', name: 'Madhya Pradesh' },
{ code: 'mh', name: 'Maharashtra' },
{ code: 'mn', name: 'Manipur' },
{ code: 'ml', name: 'Meghalaya' },
{ code: 'mz', name: 'Mizoram' },
{ code: 'nl', name: 'Nagaland' },
{ code: 'or', name: 'Odisha' },
{ code: 'py', name: 'Puducherry' },
{ code: 'pb', name: 'Punjab' },
{ code: 'rj', name: 'Rajasthan' },
{ code: 'sk', name: 'Sikkim' },
{ code: 'tn', name: 'Tamil Nadu' },
{ code: 'tg', name: 'Telangana' },
{ code: 'tr', name: 'Tripura' },
{ code: 'up', name: 'Uttar Pradesh' },
{ code: 'ut', name: 'Uttarakhand' },
{ code: 'wb', name: 'West Bengal' }
]
},
{ code: 'IR', name: 'Iran' },
{ code: 'IS', name: 'Iceland' },
{
code: 'IT',
name: 'Italy',
states: [
{ code: '65', name: 'Abruzzo' },
{ code: '77', name: 'Basilicata' },
{ code: '78', name: 'Calabria' },
{ code: '72', name: 'Campania' },
{ code: '45', name: 'Emilia-Romagna' },
{ code: '36', name: 'Friuli-Venezia Giulia' },
{ code: '62', name: 'Lazio' },
{ code: '42', name: 'Liguria' },
{ code: '25', name: 'Lombardia' },
{ code: '57', name: 'Marche' },
{ code: '67', name: 'Molise' },
{ code: '21', name: 'Piemonte' },
{ code: '75', name: 'Puglia' },
{ code: '88', name: 'Sardegna' },
{ code: '82', name: 'Sicilia' },
{ code: '52', name: 'Toscana' },
{ code: '32', name: 'Trentino-Alto Adige' },
{ code: '55', name: 'Umbria' },
{ code: '23', name: "Valle d'Aosta" },
{ code: '34', name: 'Veneto' }
]
},
{ code: 'JM', name: 'Jamaica' },
{ code: 'JP', name: 'Japan' },
{ code: 'KE', name: 'Kenya' },
{ code: 'KR', name: 'South Korea' },
{ code: 'LI', name: 'Liechtenstein' },
{ code: 'LT', name: 'Lithuania' },
{ code: 'LU', name: 'Luxembourg' },
{ code: 'LV', name: 'Latvia' },
{ code: 'MA', name: 'Morocco' },
{ code: 'MC', name: 'Monaco' },
{ code: 'MD', name: 'Moldova' },
{ code: 'MK', name: 'North Macedonia' },
{ code: 'MT', name: 'Malta' },
{
code: 'MX',
name: 'Mexico',
states: [
{ code: 'ag', name: 'Aguascalientes' },
{ code: 'bc', name: 'Baja California' },
{ code: 'bs', name: 'Baja California Sur' },
{ code: 'cm', name: 'Campeche' },
{ code: 'cs', name: 'Chiapas' },
{ code: 'ch', name: 'Chihuahua' },
{ code: 'co', name: 'Coahuila' },
{ code: 'cl', name: 'Colima' },
{ code: 'df', name: 'Mexico City' },
{ code: 'dg', name: 'Durango' },
{ code: 'gt', name: 'Guanajuato' },
{ code: 'gr', name: 'Guerrero' },
{ code: 'hg', name: 'Hidalgo' },
{ code: 'jc', name: 'Jalisco' },
{ code: 'mc', name: 'State of Mexico' },
{ code: 'mn', name: 'Michoacán' },
{ code: 'ms', name: 'Morelos' },
{ code: 'nt', name: 'Nayarit' },
{ code: 'nl', name: 'Nuevo León' },
{ code: 'oa', name: 'Oaxaca' },
{ code: 'pu', name: 'Puebla' },
{ code: 'qe', name: 'Querétaro' },
{ code: 'qr', name: 'Quintana Roo' },
{ code: 'sl', name: 'San Luis Potosí' },
{ code: 'si', name: 'Sinaloa' },
{ code: 'so', name: 'Sonora' },
{ code: 'tb', name: 'Tabasco' },
{ code: 'tm', name: 'Tamaulipas' },
{ code: 'tl', name: 'Tlaxcala' },
{ code: 've', name: 'Veracruz' },
{ code: 'yu', name: 'Yucatán' },
{ code: 'za', name: 'Zacatecas' }
]
},
{ code: 'MY', name: 'Malaysia' },
{ code: 'NI', name: 'Nicaragua' },
{
code: 'NL',
name: 'Netherlands',
states: [
{ code: 'dr', name: 'Drenthe' },
{ code: 'fl', name: 'Flevoland' },
{ code: 'fr', name: 'Friesland' },
{ code: 'gd', name: 'Gelderland' },
{ code: 'gr', name: 'Groningen' },
{ code: 'lb', name: 'Limburg' },
{ code: 'nb', name: 'North Brabant' },
{ code: 'nh', name: 'North Holland' },
{ code: 'ov', name: 'Overijssel' },
{ code: 'ut', name: 'Utrecht' },
{ code: 'ze', name: 'Zeeland' },
{ code: 'zh', name: 'South Holland' }
]
},
{ code: 'NO', name: 'Norway' },
{ code: 'NZ', name: 'New Zealand' },
{ code: 'PA', name: 'Panama' },
{ code: 'PE', name: 'Peru' },
{ code: 'PH', name: 'Philippines' },
{ code: 'PL', name: 'Poland' },
{ code: 'PR', name: 'Puerto Rico' },
{ code: 'PT', name: 'Portugal' },
{ code: 'RO', name: 'Romania' },
{ code: 'RS', name: 'Serbia' },
{ code: 'RU', name: 'Russia' },
{ code: 'SA', name: 'Saudi Arabia' },
{ code: 'SE', name: 'Sweden' },
{ code: 'SG', name: 'Singapore' },
{ code: 'SI', name: 'Slovenia' },
{ code: 'SK', name: 'Slovakia' },
{
code: 'LK',
name: 'Sri Lanka',
states: [
{ code: 'central', name: 'Central Province' },
{ code: 'eastern', name: 'Eastern Province' },
{ code: 'north-central', name: 'North Central Province' },
{ code: 'northern', name: 'Northern Province' },
{ code: 'north-western', name: 'North Western Province' },
{ code: 'sabaragamuwa', name: 'Sabaragamuwa Province' },
{ code: 'southern', name: 'Southern Province' },
{ code: 'uva', name: 'Uva Province' },
{ code: 'western', name: 'Western Province' }
]
},
{ code: 'TH', name: 'Thailand' },
{ code: 'TR', name: 'Turkey' },
{ code: 'UA', name: 'Ukraine' },
{
code: 'US',
name: 'United States',
states: [
{ code: 'al', name: 'Alabama' },
{ code: 'ak', name: 'Alaska' },
{ code: 'az', name: 'Arizona' },
{ code: 'ar', name: 'Arkansas' },
{ code: 'ca', name: 'California' },
{ code: 'co', name: 'Colorado' },
{ code: 'ct', name: 'Connecticut' },
{ code: 'de', name: 'Delaware' },
{ code: 'dc', name: 'District of Columbia' },
{ code: 'fl', name: 'Florida' },
{ code: 'ga', name: 'Georgia' },
{ code: 'hi', name: 'Hawaii' },
{ code: 'id', name: 'Idaho' },
{ code: 'il', name: 'Illinois' },
{ code: 'in', name: 'Indiana' },
{ code: 'ia', name: 'Iowa' },
{ code: 'ks', name: 'Kansas' },
{ code: 'ky', name: 'Kentucky' },
{ code: 'la', name: 'Louisiana' },
{ code: 'me', name: 'Maine' },
{ code: 'md', name: 'Maryland' },
{ code: 'ma', name: 'Massachusetts' },
{ code: 'mi', name: 'Michigan' },
{ code: 'mn', name: 'Minnesota' },
{ code: 'ms', name: 'Mississippi' },
{ code: 'mo', name: 'Missouri' },
{ code: 'mt', name: 'Montana' },
{ code: 'ne', name: 'Nebraska' },
{ code: 'nv', name: 'Nevada' },
{ code: 'nh', name: 'New Hampshire' },
{ code: 'nj', name: 'New Jersey' },
{ code: 'nm', name: 'New Mexico' },
{ code: 'ny', name: 'New York' },
{ code: 'nc', name: 'North Carolina' },
{ code: 'nd', name: 'North Dakota' },
{ code: 'oh', name: 'Ohio' },
{ code: 'ok', name: 'Oklahoma' },
{ code: 'or', name: 'Oregon' },
{ code: 'pa', name: 'Pennsylvania' },
{ code: 'ri', name: 'Rhode Island' },
{ code: 'sc', name: 'South Carolina' },
{ code: 'sd', name: 'South Dakota' },
{ code: 'tn', name: 'Tennessee' },
{ code: 'tx', name: 'Texas' },
{ code: 'ut', name: 'Utah' },
{ code: 'vt', name: 'Vermont' },
{ code: 'va', name: 'Virginia' },
{ code: 'wa', name: 'Washington' },
{ code: 'wv', name: 'West Virginia' },
{ code: 'wi', name: 'Wisconsin' },
{ code: 'wy', name: 'Wyoming' }
]
},
{ code: 'UY', name: 'Uruguay' },
{ code: 'VE', name: 'Venezuela' },
{ code: 'VN', name: 'Vietnam' },
{ code: 'ZA', name: 'South Africa' }
];
return {
done: true,
body: supportedCountries,
} as IServerResponse<ICountryWithStates[]>;
}
},
// Combined holidays (official + custom) - Database-driven approach
getCombinedHolidays: async (
params: ICombinedHolidaysRequest & { country_code?: string }
): Promise<IServerResponse<IHolidayCalendarEvent[]>> => {
try {
console.log('🔍 getCombinedHolidays called with params:', params);
const year = new Date(params.from_date).getFullYear();
let allHolidays: IHolidayCalendarEvent[] = [];
// 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}`);
// 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)
const customRes = await holidayApiService.getOrganizationHolidays(year);
if (customRes.done && customRes.body) {
const customHolidays = customRes.body
.filter((h: any) => h.date >= params.from_date && h.date <= params.to_date)
.map((h: any) => ({
id: h.id,
name: h.name,
description: h.description,
date: h.date,
is_recurring: h.is_recurring,
holiday_type_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,
is_editable: h.is_editable !== false, // Default to true unless explicitly false
}));
// 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));
allHolidays.push(...uniqueCustomHolidays);
}
return {
done: true,
body: allHolidays,
} as IServerResponse<IHolidayCalendarEvent[]>;
} catch (error) {
logger.error('Error fetching combined holidays:', error);
return {
done: false,
body: [],
} as IServerResponse<IHolidayCalendarEvent[]>;
}
},
// Working days calculation - PLACEHOLDER until backend implements
getWorkingDaysCount: async (
params: IHolidayDateRange
): Promise<
IServerResponse<{ working_days: number; total_days: number; holidays_count: number }>
> => {
// Simple calculation without holidays for now
const start = new Date(params.from_date);
const end = new Date(params.to_date);
let workingDays = 0;
let totalDays = 0;
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
totalDays++;
const day = d.getDay();
if (day !== 0 && day !== 6) {
// Not Sunday or Saturday
workingDays++;
}
}
return {
done: true,
body: {
working_days: workingDays,
total_days: totalDays,
holidays_count: 0,
},
} as IServerResponse<{ working_days: number; total_days: number; holidays_count: number }>;
},
// Populate holidays - Populate the database with official holidays for various countries
populateCountryHolidays: async (): Promise<IServerResponse<any>> => {
const response = await apiClient.post<IServerResponse<any>>(`${rootUrl}/populate`);
return response.data;
},
};

View File

@@ -0,0 +1,311 @@
import { IServerResponse } from '@/types/common.types';
import { IHolidayCalendarEvent } from '@/types/holiday/holiday.types';
import dayjs from 'dayjs';
export interface ISriLankanHoliday {
date: string;
name: string;
type: 'Public' | 'Bank' | 'Mercantile' | 'Poya';
description?: string;
is_poya?: boolean;
is_editable?: boolean;
}
export interface ISriLankanHolidayResponse {
holidays: ISriLankanHoliday[];
total: number;
year: number;
month?: number;
}
export interface ISriLankanCheckHolidayResponse {
is_holiday: boolean;
holiday?: ISriLankanHoliday;
date: string;
}
// Sri Lankan Holiday API Configuration
const SRI_LANKA_API_BASE_URL = 'https://srilanka-holidays.vercel.app/api/v1';
/**
* Sri Lankan Holiday API Service
* Uses the dedicated srilanka-holidays API for accurate Sri Lankan holiday data
* Source: https://github.com/Dilshan-H/srilanka-holidays
*/
export const sriLankanHolidayApiService = {
/**
* Get Sri Lankan holidays for a specific year
*/
getHolidays: async (params: {
year: number;
month?: number;
type?: 'Public' | 'Bank' | 'Mercantile' | 'Poya';
}): Promise<IServerResponse<ISriLankanHoliday[]>> => {
try {
const queryParams = new URLSearchParams({
year: params.year.toString(),
format: 'json',
});
if (params.month) {
queryParams.append('month', params.month.toString());
}
if (params.type) {
queryParams.append('type', params.type);
}
// For now, return mock data as placeholder until API key is configured
const mockSriLankanHolidays: ISriLankanHoliday[] = [
{
date: `${params.year}-01-01`,
name: "New Year's Day",
type: 'Public',
description: 'Celebration of the first day of the Gregorian calendar year',
},
{
date: `${params.year}-02-04`,
name: 'Independence Day',
type: 'Public',
description: 'Commemorates the independence of Sri Lanka from British rule in 1948',
},
{
date: `${params.year}-02-13`,
name: 'Navam Full Moon Poya Day',
type: 'Poya',
description: 'Buddhist festival celebrating the full moon',
is_poya: true,
},
{
date: `${params.year}-03-15`,
name: 'Medin Full Moon Poya Day',
type: 'Poya',
description: 'Buddhist festival celebrating the full moon',
is_poya: true,
},
{
date: `${params.year}-04-13`,
name: 'Sinhala and Tamil New Year Day',
type: 'Public',
description: 'Traditional New Year celebrated by Sinhalese and Tamil communities',
},
{
date: `${params.year}-04-14`,
name: 'Day after Sinhala and Tamil New Year Day',
type: 'Public',
description: 'Second day of traditional New Year celebrations',
},
{
date: `${params.year}-05-01`,
name: 'May Day',
type: 'Public',
description: 'International Workers Day',
},
{
date: `${params.year}-05-12`,
name: 'Vesak Full Moon Poya Day',
type: 'Poya',
description: 'Celebrates the birth, enlightenment and passing away of Buddha',
is_poya: true,
},
{
date: `${params.year}-05-13`,
name: 'Day after Vesak Full Moon Poya Day',
type: 'Public',
description: 'Additional day for Vesak celebrations',
},
{
date: `${params.year}-06-11`,
name: 'Poson Full Moon Poya Day',
type: 'Poya',
description: 'Commemorates the introduction of Buddhism to Sri Lanka',
is_poya: true,
},
{
date: `${params.year}-08-09`,
name: 'Nikini Full Moon Poya Day',
type: 'Poya',
description: 'Buddhist festival celebrating the full moon',
is_poya: true,
},
{
date: `${params.year}-09-07`,
name: 'Binara Full Moon Poya Day',
type: 'Poya',
description: 'Buddhist festival celebrating the full moon',
is_poya: true,
},
{
date: `${params.year}-10-07`,
name: 'Vap Full Moon Poya Day',
type: 'Poya',
description: 'Buddhist festival celebrating the full moon',
is_poya: true,
},
{
date: `${params.year}-11-05`,
name: 'Il Full Moon Poya Day',
type: 'Poya',
description: 'Buddhist festival celebrating the full moon',
is_poya: true,
},
{
date: `${params.year}-12-05`,
name: 'Unduvap Full Moon Poya Day',
type: 'Poya',
description: 'Buddhist festival celebrating the full moon',
is_poya: true,
},
{
date: `${params.year}-12-25`,
name: 'Christmas Day',
type: 'Public',
description: 'Christian celebration of the birth of Jesus Christ',
},
];
// Filter by month if specified
let filteredHolidays = mockSriLankanHolidays;
if (params.month) {
filteredHolidays = mockSriLankanHolidays.filter(holiday => {
const holidayMonth = dayjs(holiday.date).month() + 1; // dayjs months are 0-indexed
return holidayMonth === params.month;
});
}
// Filter by type if specified
if (params.type) {
filteredHolidays = filteredHolidays.filter(holiday => holiday.type === params.type);
}
return {
done: true,
body: filteredHolidays,
} as IServerResponse<ISriLankanHoliday[]>;
// TODO: Uncomment when API key is configured
// const response = await fetch(`${SRI_LANKA_API_BASE_URL}/holidays?${queryParams}`, {
// headers: {
// 'X-API-Key': process.env.SRI_LANKA_API_KEY || '',
// 'Content-Type': 'application/json',
// },
// });
// if (!response.ok) {
// throw new Error(`Sri Lankan Holiday API error: ${response.status}`);
// }
// const data: ISriLankanHolidayResponse = await response.json();
// return {
// done: true,
// body: data.holidays,
// } as IServerResponse<ISriLankanHoliday[]>;
} catch (error) {
console.error('Error fetching Sri Lankan holidays:', error);
return {
done: false,
body: [],
} as IServerResponse<ISriLankanHoliday[]>;
}
},
/**
* Check if a specific date is a holiday in Sri Lanka
*/
checkHoliday: async (params: {
year: number;
month: number;
day: number;
}): Promise<IServerResponse<ISriLankanCheckHolidayResponse>> => {
try {
// For now, use mock implementation
const allHolidays = await sriLankanHolidayApiService.getHolidays({ year: params.year });
if (!allHolidays.done || !allHolidays.body) {
return {
done: false,
body: {
is_holiday: false,
date: `${params.year}-${params.month.toString().padStart(2, '0')}-${params.day.toString().padStart(2, '0')}`,
},
} as IServerResponse<ISriLankanCheckHolidayResponse>;
}
const checkDate = `${params.year}-${params.month.toString().padStart(2, '0')}-${params.day.toString().padStart(2, '0')}`;
const holiday = allHolidays.body.find(h => h.date === checkDate);
return {
done: true,
body: {
is_holiday: !!holiday,
holiday: holiday,
date: checkDate,
},
} as IServerResponse<ISriLankanCheckHolidayResponse>;
// TODO: Uncomment when API key is configured
// const queryParams = new URLSearchParams({
// year: params.year.toString(),
// month: params.month.toString(),
// day: params.day.toString(),
// });
// const response = await fetch(`${SRI_LANKA_API_BASE_URL}/check_holiday?${queryParams}`, {
// headers: {
// 'X-API-Key': process.env.SRI_LANKA_API_KEY || '',
// 'Content-Type': 'application/json',
// },
// });
// if (!response.ok) {
// throw new Error(`Sri Lankan Holiday API error: ${response.status}`);
// }
// const data: ISriLankanCheckHolidayResponse = await response.json();
// return {
// done: true,
// body: data,
// } as IServerResponse<ISriLankanCheckHolidayResponse>;
} catch (error) {
console.error('Error checking Sri Lankan holiday:', error);
return {
done: false,
body: {
is_holiday: false,
date: `${params.year}-${params.month.toString().padStart(2, '0')}-${params.day.toString().padStart(2, '0')}`,
},
} as IServerResponse<ISriLankanCheckHolidayResponse>;
}
},
/**
* Convert Sri Lankan holiday to calendar event format
*/
convertToCalendarEvent: (holiday: ISriLankanHoliday): IHolidayCalendarEvent => {
// Color coding for different holiday types
const getColorCode = (type: string, isPoya?: boolean): string => {
if (isPoya) return '#8B4513'; // Brown for Poya days
switch (type) {
case 'Public': return '#DC143C'; // Crimson for public holidays
case 'Bank': return '#4682B4'; // Steel blue for bank holidays
case 'Mercantile': return '#32CD32'; // Lime green for mercantile holidays
case 'Poya': return '#8B4513'; // Brown for Poya days
default: return '#f37070'; // Default red
}
};
return {
id: `lk-${holiday.date}-${holiday.name.replace(/\s+/g, '-').toLowerCase()}`,
name: holiday.name,
description: holiday.description || holiday.name,
date: holiday.date,
is_recurring: holiday.is_poya || false, // Poya days recur monthly
holiday_type_name: holiday.type,
color_code: getColorCode(holiday.type, holiday.is_poya),
source: 'official' as const,
is_editable: holiday.is_editable || false,
};
},
};

View File

@@ -0,0 +1,116 @@
import apiClient from '@api/api-client';
import { API_BASE_URL } from '@/shared/constants';
import { IServerResponse } from '@/types/common.types';
import { IJobType, JobRoleType } from '@/types/project/ratecard.types';
const rootUrl = `${API_BASE_URL}/project-ratecard`;
export interface IProjectRateCardRole {
id?: string;
project_id: string;
job_title_id: string;
jobtitle?: string;
rate: number;
man_day_rate?: number;
data?: object;
roles?: IJobType[];
}
export const projectRateCardApiService = {
// Insert multiple roles for a project
async insertMany(
project_id: string,
roles: Omit<IProjectRateCardRole, 'id' | 'project_id'>[]
): Promise<IServerResponse<IProjectRateCardRole[]>> {
const response = await apiClient.post<IServerResponse<IProjectRateCardRole[]>>(rootUrl, {
project_id,
roles,
});
return response.data;
},
// Insert a single role for a project
async insertOne({
project_id,
job_title_id,
rate,
man_day_rate,
}: {
project_id: string;
job_title_id: string;
rate: number;
man_day_rate?: number;
}): Promise<IServerResponse<IProjectRateCardRole>> {
const response = await apiClient.post<IServerResponse<IProjectRateCardRole>>(
`${rootUrl}/create-project-rate-card-role`,
{ project_id, job_title_id, rate, man_day_rate }
);
return response.data;
},
// Get all roles for a project
async getFromProjectId(project_id: string): Promise<IServerResponse<IProjectRateCardRole[]>> {
const response = await apiClient.get<IServerResponse<IProjectRateCardRole[]>>(
`${rootUrl}/project/${project_id}`
);
return response.data;
},
// Get a single role by id
async getFromId(id: string): Promise<IServerResponse<IProjectRateCardRole>> {
const response = await apiClient.get<IServerResponse<IProjectRateCardRole>>(`${rootUrl}/${id}`);
return response.data;
},
// Update a single role by id
async updateFromId(
id: string,
body: { job_title_id: string; rate?: string; man_day_rate?: string }
): Promise<IServerResponse<IProjectRateCardRole>> {
const response = await apiClient.put<IServerResponse<IProjectRateCardRole>>(
`${rootUrl}/${id}`,
body
);
return response.data;
},
// Update all roles for a project (delete then insert)
async updateFromProjectId(
project_id: string,
roles: Omit<IProjectRateCardRole, 'id' | 'project_id'>[]
): Promise<IServerResponse<IProjectRateCardRole[]>> {
const response = await apiClient.put<IServerResponse<IProjectRateCardRole[]>>(
`${rootUrl}/project/${project_id}`,
{ project_id, roles }
);
return response.data;
},
// Update project member rate card role
async updateMemberRateCardRole(
project_id: string,
member_id: string,
project_rate_card_role_id: string
): Promise<IServerResponse<JobRoleType>> {
const response = await apiClient.put<IServerResponse<JobRoleType>>(
`${rootUrl}/project/${project_id}/members/${member_id}/rate-card-role`,
{ project_rate_card_role_id }
);
return response.data;
},
// Delete a single role by id
async deleteFromId(id: string): Promise<IServerResponse<IProjectRateCardRole>> {
const response = await apiClient.delete<IServerResponse<IProjectRateCardRole>>(
`${rootUrl}/${id}`
);
return response.data;
},
// Delete all roles for a project
async deleteFromProjectId(project_id: string): Promise<IServerResponse<IProjectRateCardRole[]>> {
const response = await apiClient.delete<IServerResponse<IProjectRateCardRole[]>>(
`${rootUrl}/project/${project_id}`
);
return response.data;
},
};

View File

@@ -0,0 +1,133 @@
import { API_BASE_URL } from '@/shared/constants';
import { IServerResponse } from '@/types/common.types';
import apiClient from '../api-client';
import {
IProjectFinanceResponse,
ITaskBreakdownResponse,
IProjectFinanceTask,
} from '@/types/project/project-finance.types';
const rootUrl = `${API_BASE_URL}/project-finance`;
type BillableFilterType = 'all' | 'billable' | 'non-billable';
export const projectFinanceApiService = {
getProjectTasks: async (
projectId: string,
groupBy: 'status' | 'priority' | 'phases' = 'status',
billableFilter: BillableFilterType = 'billable'
): Promise<IServerResponse<IProjectFinanceResponse>> => {
const response = await apiClient.get<IServerResponse<IProjectFinanceResponse>>(
`${rootUrl}/project/${projectId}/tasks`,
{
params: {
group_by: groupBy,
billable_filter: billableFilter,
},
}
);
return response.data;
},
getSubTasks: async (
projectId: string,
parentTaskId: string,
billableFilter: BillableFilterType = 'billable'
): Promise<IServerResponse<IProjectFinanceTask[]>> => {
const response = await apiClient.get<IServerResponse<IProjectFinanceTask[]>>(
`${rootUrl}/project/${projectId}/tasks/${parentTaskId}/subtasks`,
{
params: {
billable_filter: billableFilter,
},
}
);
return response.data;
},
getTaskBreakdown: async (taskId: string): Promise<IServerResponse<ITaskBreakdownResponse>> => {
const response = await apiClient.get<IServerResponse<ITaskBreakdownResponse>>(
`${rootUrl}/task/${taskId}/breakdown`
);
return response.data;
},
updateTaskFixedCost: async (taskId: string, fixedCost: number): Promise<IServerResponse<any>> => {
const response = await apiClient.put<IServerResponse<any>>(
`${rootUrl}/task/${taskId}/fixed-cost`,
{ fixed_cost: fixedCost }
);
return response.data;
},
updateProjectCurrency: async (
projectId: string,
currency: string
): Promise<IServerResponse<any>> => {
const response = await apiClient.put<IServerResponse<any>>(
`${rootUrl}/project/${projectId}/currency`,
{ currency }
);
return response.data;
},
updateProjectBudget: async (projectId: string, budget: number): Promise<IServerResponse<any>> => {
const response = await apiClient.put<IServerResponse<any>>(
`${rootUrl}/project/${projectId}/budget`,
{ budget }
);
return response.data;
},
updateProjectCalculationMethod: async (
projectId: string,
calculationMethod: 'hourly' | 'man_days',
hoursPerDay?: number
): Promise<IServerResponse<any>> => {
const response = await apiClient.put<IServerResponse<any>>(
`${rootUrl}/project/${projectId}/calculation-method`,
{
calculation_method: calculationMethod,
hours_per_day: hoursPerDay,
}
);
return response.data;
},
updateTaskEstimatedManDays: async (
taskId: string,
estimatedManDays: number
): Promise<IServerResponse<any>> => {
const response = await apiClient.put<IServerResponse<any>>(
`${rootUrl}/task/${taskId}/estimated-man-days`,
{ estimated_man_days: estimatedManDays }
);
return response.data;
},
updateRateCardManDayRate: async (
rateCardRoleId: string,
manDayRate: number
): Promise<IServerResponse<any>> => {
const response = await apiClient.put<IServerResponse<any>>(
`${rootUrl}/rate-card-role/${rateCardRoleId}/man-day-rate`,
{ man_day_rate: manDayRate }
);
return response.data;
},
exportFinanceData: async (
projectId: string,
groupBy: 'status' | 'priority' | 'phases' = 'status',
billableFilter: BillableFilterType = 'billable'
): Promise<Blob> => {
const response = await apiClient.get(`${rootUrl}/project/${projectId}/export`, {
params: {
groupBy,
billable_filter: billableFilter,
},
responseType: 'blob',
});
return response.data;
},
};

View File

@@ -0,0 +1,47 @@
import apiClient from '@api/api-client';
import { API_BASE_URL } from '@/shared/constants';
import { IServerResponse } from '@/types/common.types';
import { toQueryString } from '@/utils/toQueryString';
import { RatecardType, IRatecardViewModel } from '@/types/project/ratecard.types';
type IRatecard = {
id: string;
};
const rootUrl = `${API_BASE_URL}/ratecard`;
export const rateCardApiService = {
async getRateCards(
index: number,
size: number,
field: string | null,
order: string | null,
search?: string | null
): Promise<IServerResponse<IRatecardViewModel>> {
const s = encodeURIComponent(search || '');
const queryString = toQueryString({ index, size, field, order, search: s });
const response = await apiClient.get<IServerResponse<IRatecardViewModel>>(
`${rootUrl}${queryString}`
);
return response.data;
},
async getRateCardById(id: string): Promise<IServerResponse<RatecardType>> {
const response = await apiClient.get<IServerResponse<RatecardType>>(`${rootUrl}/${id}`);
return response.data;
},
async createRateCard(body: RatecardType): Promise<IServerResponse<RatecardType>> {
const response = await apiClient.post<IServerResponse<RatecardType>>(rootUrl, body);
return response.data;
},
async updateRateCard(id: string, body: RatecardType): Promise<IServerResponse<RatecardType>> {
const response = await apiClient.put<IServerResponse<RatecardType>>(`${rootUrl}/${id}`, body);
return response.data;
},
async deleteRateCard(id: string): Promise<IServerResponse<void>> {
const response = await apiClient.delete<IServerResponse<void>>(`${rootUrl}/${id}`);
return response.data;
},
};

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

View File

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

View File

@@ -88,6 +88,10 @@ import { userActivityApiService } from '@/api/home-page/user-activity.api.servic
import projectViewReducer from '@features/project/project-view-slice';
import taskManagementFieldsReducer from '@features/task-management/taskListFields.slice';
import projectFinanceRateCardReducer from '@/features/finance/project-finance-slice';
import projectFinancesReducer from '@/features/projects/finance/project-finance.slice';
import financeReducer from '@/features/projects/finance/finance-slice';
export const store = configureStore({
middleware: getDefaultMiddleware =>
getDefaultMiddleware({
@@ -179,6 +183,11 @@ export const store = configureStore({
grouping: groupingReducer,
taskManagementSelection: selectionReducer,
taskManagementFields: taskManagementFieldsReducer,
// Finance
projectFinanceRateCardReducer: projectFinanceRateCardReducer,
projectFinancesReducer: projectFinancesReducer,
financeReducer: financeReducer,
},
});

Some files were not shown because too many files have changed in this diff Show More