Compare commits

...

131 Commits

Author SHA1 Message Date
chamikaJ
ccb8e68192 feat(reporting): add billable and non-billable time tracking to member reports
- Implemented SQL logic to calculate billable and non-billable time for team members in the reporting module.
- Enhanced the reporting members table to display new time tracking metrics with appropriate headers and tooltips.
- Created a new TimeLogsCell component to visually represent billable vs non-billable time with percentage breakdowns.
- Updated localization files for English, Spanish, and Portuguese to include new terms related to time tracking.
2025-06-13 09:50:43 +05:30
chamiakJ
4783b5ec10 fix(finance-table): refine budget and variance calculations for improved accuracy
- Updated budget calculations to consistently include fixed costs across all tasks.
- Adjusted variance logic to align with the new budget calculations, ensuring accurate financial reporting.
- Increased save delay for fixed cost input to 5 seconds, allowing users more time to edit values.
- Added text selection on input focus for better user experience.
2025-06-12 16:21:48 +05:30
chamiakJ
a08d1efc36 fix(finance-table): correct budget and variance calculations for leaf tasks
- Updated the calculation of total budget to include fixed costs for leaf tasks, ensuring accurate financial representation.
- Adjusted variance calculations to reflect the new budget logic, preventing discrepancies in financial reporting.
2025-06-12 16:08:19 +05:30
root
bedf85d409 Merge branch 'feature/project-finance' of https://github.com/Worklenz/worklenz into feature/project-finance 2025-06-11 10:55:26 +00:00
chamikaJ
c84034b436 refactor(project-finance): streamline task cost calculations and prevent double counting
- Removed fixed cost from budget calculations, as actual costs are now aggregated from logs and backend data.
- Updated recursive functions in the FinanceTable and project finance slice to ensure accurate totals without double counting.
- Enhanced comments for clarity on the calculation logic for parent and leaf tasks, improving maintainability.
2025-06-11 16:24:54 +05:30
root
025b2005c7 Merge branch 'feature/project-finance' of https://github.com/Worklenz/worklenz into feature/project-finance 2025-06-11 07:00:50 +00:00
chamikaJ
c5bac36c53 feat(project-finance): enhance task cost tracking and UI updates
- Added `actual_cost_from_logs` to task data structure for improved cost tracking.
- Updated SQL queries in ProjectFinanceController to ensure accurate fixed cost updates and task hierarchy recalculations.
- Enhanced the project finance slice to optimize task hierarchy recalculations, ensuring accurate financial data representation.
- Modified FinanceTable component to reflect changes in cost calculations, preventing double counting and improving UI responsiveness.
2025-06-11 12:28:25 +05:30
chamikaJ
06488d80ff feat(project-finance): optimize task cost calculations and enhance UI responsiveness
- Implemented checks in the ProjectFinanceController to prevent fixed cost updates for parent tasks with subtasks, ensuring accurate financial data.
- Enhanced the project finance slice with memoization and optimized recursive calculations for task hierarchies, improving performance and reducing unnecessary API calls.
- Updated the FinanceTable component to reflect these changes, ensuring totals are calculated without double counting and providing immediate UI updates.
- Added a README to document the new optimized finance calculation system and its features.
2025-06-11 10:05:40 +05:30
chamikaJ
e0a290c18f feat(project-finance): enhance fixed cost calculations and parent task updates
- Updated SQL queries in ProjectFinanceController to aggregate fixed costs from current tasks and their descendants, improving financial accuracy.
- Introduced a new async thunk to update task fixed costs with recalculation, ensuring UI responsiveness and accurate parent task totals.
- Implemented recursive functions in the project finance slice to maintain accurate financial data for parent tasks based on subtasks.
- Enhanced the FinanceTable component to support these updates, ensuring totals reflect the latest calculations across task hierarchies.
2025-06-09 17:03:09 +05:30
chamikaJ
e3e1b2dc14 fix(tasks-controller): cap progress calculation at 100% and synchronize complete_ratio
- Updated progress calculation to ensure it does not exceed 100% when time-based progress is enabled.
- Set complete_ratio to match the calculated progress, improving accuracy in task completion representation.
- Simplified comments for clarity regarding progress defaults and calculations.
2025-06-09 13:05:34 +05:30
root
4a3f4ccc08 Merge branch 'feature/project-finance' of https://github.com/Worklenz/worklenz into feature/project-finance 2025-06-09 07:35:27 +00:00
root
0e2c37aef2 Merge branch 'feature/project-finance' of https://github.com/Worklenz/worklenz into feature/project-finance 2025-06-09 07:03:38 +00:00
chamikaJ
6e188899ed feat(task-hierarchy): implement recursive task estimation and reset functionality
- Added SQL scripts to fix task hierarchy and reset parent task estimations to zero, ensuring accurate estimation calculations.
- Introduced a migration for a recursive task estimation function that aggregates estimations from subtasks, enhancing task management.
- Updated controllers to utilize recursive estimations for displaying task data, improving accuracy in task progress representation.
- Implemented a new API route to reset parent task estimations, allowing for better task management and data integrity.
2025-06-09 12:33:23 +05:30
chamikaJ
509fcc8f64 refactor(project-finance): improve task cost calculations and UI hierarchy
- Updated SQL queries in the ProjectFinanceController to exclude parent tasks from descendant cost calculations, ensuring accurate financial data aggregation.
- Refactored the project finance slice to implement recursive task updates for fixed costs, estimated costs, and time logged, enhancing task management efficiency.
- Enhanced the FinanceTable component to visually represent task hierarchy with improved indentation and hover effects, improving user experience and clarity.
- Added responsive styles for nested tasks and adjusted task name styling for better readability across different levels.
2025-06-09 11:24:49 +05:30
chamikaJ
49196aac2e feat(finance-drawer): enhance task summary and member breakdown display
- Updated the FinanceDrawer component to include a detailed task summary section, displaying estimated and logged hours, labor costs, and fixed costs.
- Improved the member breakdown table by adding columns for logged hours and actual costs, enhancing clarity and usability.
- Adjusted the drawer width for better layout and user experience.
2025-06-06 16:18:31 +05:30
root
413d5df95c Merge branch 'feature/project-finance' of https://github.com/Worklenz/worklenz into feature/project-finance 2025-06-06 09:14:09 +00:00
chamikaJ
c031a49a29 refactor(project-finance): enhance financial statistics display and formatting
- Updated the layout of financial statistics in the ProjectViewFinance component for improved responsiveness and visual clarity.
- Adjusted the formatting of variance and budget utilization values to ensure consistent presentation, including prefix and suffix adjustments.
- Refactored the FinanceTable component to display variance values with appropriate signs and formatting.
- Implemented the use of createPortal for rendering the FinanceDrawer, improving modal management.
2025-06-06 14:43:30 +05:30
root
3129d7a48d Merge branch 'feature/project-finance' of https://github.com/Worklenz/worklenz into feature/project-finance 2025-06-06 06:35:07 +00:00
chamikaJ
791cbe22df feat(project-finance): add billable filter functionality to project finance queries
- Introduced a `billable_filter` query parameter to filter tasks based on their billable status (billable, non-billable, or all).
- Updated the project finance controller to construct SQL queries with billable conditions based on the filter.
- Enhanced the frontend components to support billable filtering in project finance views and exports.
- Added corresponding translations for filter options in multiple languages.
- Refactored related API services to accommodate the new filtering logic.
2025-06-06 12:02:53 +05:30
chamikaJ
ba2ecb2d85 Merge branch 'feature/project-finance' of https://github.com/Worklenz/worklenz into feature/project-finance 2025-06-06 09:10:53 +05:30
chamikaJ
59880bfd59 feat(project-view): implement finance tab visibility based on user permissions
- Added permission checks to conditionally display the finance tab in the project view based on user roles.
- Introduced `hasFinanceViewPermission` utility to determine access rights for the finance tab.
- Updated tab management logic to handle redirection and default tab selection when permissions change.
2025-06-06 09:10:50 +05:30
root
9e66a1ce8c Merge branch 'feature/project-finance' of https://github.com/Worklenz/worklenz into feature/project-finance 2025-06-05 05:57:10 +00:00
chamiakJ
7d735dacc4 Merge branch 'feature/recurring-tasks' of https://github.com/Worklenz/worklenz into feature/project-finance 2025-06-05 11:26:27 +05:30
chamikaJ
915980dcdf feat(project-view): implement finance tab visibility based on user permissions
- Added permission checks to conditionally display the finance tab in the project view based on user roles.
- Introduced `hasFinanceViewPermission` utility to determine access rights for the finance tab.
- Updated tab management logic to handle redirection and default tab selection when permissions change.
2025-06-04 11:53:33 +05:30
chamikaJ
dcdb651dd1 feat(project-finance): add action to update project-specific currency
- Introduced a new action `updateProjectFinanceCurrency` in the project finance slice to allow updating the currency for individual projects.
- Updated the ProjectViewFinance component to dispatch the new action when the project currency is changed, ensuring the state reflects the selected currency.
2025-06-04 11:40:23 +05:30
chamikaJ
d6686d64be feat(project-currency): implement project-specific currency support
- Added a currency column to the projects table to allow different projects to use different currencies.
- Updated existing projects to default to 'USD' if no currency is set.
- Enhanced project finance controller to handle currency retrieval and updates.
- Introduced API endpoints for updating project currency with validation.
- Updated frontend components to display and manage project currency effectively.
2025-06-04 11:30:51 +05:30
chamiakJ
1ec9759434 feat(reporting-allocation): update members and utilization filters to handle "Clear All" scenario
- Enhanced members filter logic to return no data when no members are selected.
- Updated utilization filter to return an empty array when no utilization states are selected, improving clarity in reporting results.
2025-06-03 17:45:03 +05:30
chamiakJ
13baf36e3c feat(reporting-allocation): add helper method for billable query with custom alias and enhance logging for debugging
- Introduced a new method to build billable queries with customizable table aliases, improving query flexibility.
- Enhanced logging throughout the reporting allocation process to aid in debugging and provide clearer insights into query generation and utilization state calculations.
2025-06-03 16:23:07 +05:30
chamiakJ
e82bb23cd5 feat(members-time-sheet): enhance API response handling and add noCategory filter 2025-06-03 14:33:22 +05:30
chamiakJ
66b7dc5322 refactor(vite.config): simplify configuration and optimize chunking strategy
- Removed unnecessary dependency optimizations and SSR configurations for a cleaner setup.
- Streamlined the chunking strategy to focus on core dependencies, enhancing loading efficiency.
- Adjusted build settings for improved performance and maintainability.
2025-06-03 11:19:55 +05:30
chamiakJ
0136f6d3cb chore(dependencies): update package versions and resolutions for improved compatibility
- Downgraded @dnd-kit/modifiers and @dnd-kit/sortable to specific versions for better stability.
- Updated various dependencies including @adobe/css-tools, @ant-design/colors, and Babel packages to their latest versions.
- Added resolutions for @dnd-kit packages to ensure consistent versions across the project.
- Removed deprecated react-is dependency from rc-form to streamline package management.
2025-06-03 11:12:06 +05:30
chamiakJ
3bfb886de7 feat(react-integration): add React polyfills and ensure global availability
- Introduced a React polyfill to prevent undefined errors in dependencies by making React globally available in both window and globalThis.
- Updated the App component to allow optional children prop for improved flexibility.
- Created a new dnd-kit-wrapper utility to ensure React is available globally before importing @dnd-kit utilities.
2025-06-03 11:05:37 +05:30
chamiakJ
5ec7a2741c refactor(vite.config): streamline dependency management and enhance SSR configuration
- Removed redundant dependencies from the optimization list and added them to the SSR configuration for better handling.
- Updated chunking strategy to include @dnd-kit packages with React for improved loading order.
- Introduced additional configuration for ES modules to ensure compatibility with global definitions.
2025-06-03 11:00:41 +05:30
chamiakJ
e8bf84ef3a feat(config): optimize dependency management and enhance isomorphic layout effect hook
- Added a comprehensive list of dependencies to optimize for faster development builds in vite.config.ts.
- Improved the useIsomorphicLayoutEffect hook with additional safety checks to ensure React hooks are available in both client and server environments.
2025-06-03 10:55:37 +05:30
chamiakJ
593e6cfa98 refactor(config): migrate configuration files to ES module syntax
- Updated jest.config.js, postcss.config.js, and tailwind.config.js to use ES module export syntax.
- Added "type": "module" to package.json to support ES module imports.
- Refactored copy-tinymce.js to utilize ES module imports and defined __dirname for compatibility.
2025-06-03 10:49:24 +05:30
chamiakJ
e59216af54 feat(reporting-filters): enhance filter components with improved UI and functionality
- Added date disabling functionality to the TimeWiseFilter component to prevent selection of future dates.
- Updated Categories, Members, Projects, Team, and Utilization components to include active filters count and improved button text display.
- Enhanced dropdown menus with theme-aware styles and added clear all and select all functionalities for better user experience.
- Refactored components to utilize memoization for performance optimization and maintainability.
2025-06-03 10:41:20 +05:30
chamiakJ
0f5946134c feat(time-report-filters): enhance billable status filtering and localization updates
- Added "Clear All" and "Filter by Billable Status" options to the billable filter dropdown in the time report page.
- Updated localization files for English, Spanish, and Portuguese to include new keys for the added filter options.
- Improved the billable filter component to handle selection states and provide a better user experience.
- Optimized the fetching of members and utilization data based on the current route to reduce unnecessary data loading.
2025-06-03 09:49:12 +05:30
chamiakJ
4f082e982b feat(gantt-integration): add SVAR Gantt chart component and related features
- Integrated SVAR Gantt chart into the project view for enhanced task visualization.
- Implemented lazy loading for improved performance and reduced initial bundle size.
- Added custom styles for light and dark themes to match Worklenz branding.
- Updated Redux state management to support Gantt-specific data handling.
- Introduced a ResourcePreloader component to preload critical chunks for better navigation performance.
- Enhanced project view to support Gantt as a new project view option.
- Documented Gantt integration details and usage in README.md.
2025-06-03 08:11:43 +05:30
chamiakJ
71638ce52a refactor(task-list): update task list components and remove deprecated files
- Replaced StatusGroupTables with TaskGroupList in multiple components to streamline task grouping functionality.
- Updated imports to reflect new component structure and paths.
- Removed obsolete task list components and styles to clean up the codebase.
- Enhanced task list filters for improved user experience and consistency across the application.
2025-06-02 23:04:03 +05:30
chamikaJ
45d9049d27 feat(reporting-allocation): enhance working hours calculation and localization updates
- Improved the logic for calculating total working hours, introducing a minimal baseline for non-working days to ensure accurate utilization metrics.
- Updated individual member calculations to reflect over-utilization during non-working periods.
- Added new localization keys for "Overtime Work" and "Review work-life balance policies" in English, Spanish, and Portuguese time report JSON files to enhance user experience.
2025-06-02 17:10:37 +05:30
chamikaJ
3f7b969e44 feat(time-report-localization): enhance English, Spanish, and Portuguese translations for time reporting
- Added new localization keys for total time logged, expected capacity, team utilization, variance, and related terms in English, Spanish, and Portuguese JSON files.
- Updated the Total Time Utilization component to utilize new translations and improve UI elements for better user experience.
- Enhanced theme support for card styles and progress indicators based on utilization status.
2025-06-02 16:51:29 +05:30
chamikaJ
b6be411162 fix(database-functions): correct JSON handling for task members in SQL function
- Updated the handling of the 'members' field in the SQL function to ensure proper JSON formatting.
- Replaced the use of type casting with direct JSON handling for improved clarity and performance.
2025-06-02 16:23:34 +05:30
chamikaJ
dc6a62a66a feat(reporting-allocation): enhance query logic and filters for reporting allocations
- Added logging for SQL queries and results to aid in debugging.
- Introduced additional filters for categories and projects in the reporting allocation query.
- Modified the duration clause to handle cases with no project IDs.
- Improved total calculations for time logs and estimated hours based on filtered results.
- Refactored SQL query to optimize performance and clarity in data retrieval.
2025-06-02 16:08:35 +05:30
chamikaJ
035617c8e8 fix(finance-table): correct cost calculations in finance table
- Updated the cost display logic to show actual costs from logs instead of estimated costs.
- Adjusted the total cost calculation to reflect the difference between total actual and fixed costs.
- Enhanced the accumulation of actual costs in the finance table's totals computation.
2025-05-30 16:50:15 +05:30
chamikaJ
6a4bf4d672 feat(finance-permissions): implement permission checks for finance data editing
- Added permission checks for editing finance data, including fixed costs and rate cards.
- Introduced utility functions to determine user permissions based on roles (admin, project manager).
- Updated finance and rate card components to conditionally render UI elements based on user permissions.
- Displayed alerts for users with limited access to inform them of their editing capabilities.
2025-05-30 16:26:16 +05:30
chamikaJ
aeed75ca31 feat(members-time-sheet): enhance tooltip customization and styling for better user experience
- Added basic styling for tooltips based on theme mode (dark/light).
- Customized tooltip title to display member names with an icon.
- Improved label content with color indicators and status text for utilization states.
- Introduced a footer in tooltips to show total logged time for each member.
2025-05-30 15:24:56 +05:30
chamikaJ
4e43780769 Merge branch 'feature/member-time-progress-and-utilization' of https://github.com/Worklenz/worklenz into feature/project-finance 2025-05-30 15:08:22 +05:30
chamikaJ
fef50bdfb1 feat(task-logging): enhance time log functionality with subtask handling and UI improvements
- Implemented recursive task hierarchy in SQL query to support subtasks in time logging.
- Updated time log export to include task names for better clarity.
- Added tooltips to inform users when time logging and timer functionalities are disabled due to subtasks.
- Enhanced UI components in the task drawer to reflect new time log features and improve user experience.
- Introduced responsive design adjustments for better accessibility on mobile devices.
2025-05-30 13:28:47 +05:30
chamikaJ
43c6701d3a feat(finance): enhance project finance view with export functionality and UI improvements
- Implemented export functionality for finance data, allowing users to download project finance reports in Excel format.
- Refactored project finance header to streamline UI components and improve user experience.
- Removed deprecated FinanceTab and GroupByFilterDropdown components, consolidating functionality into the main finance view.
- Updated the project finance view to utilize new components for better organization and interaction.
- Enhanced group selection for finance data, allowing users to filter by status, priority, or phases.
2025-05-30 12:42:43 +05:30
chamikaJ
8cdc8b3ad0 chore(dependencies): update rimraf version and clean up package-lock.json
- Removed deprecated rimraf entries from package-lock.json to streamline dependencies.
- Added an override for rimraf in package.json to ensure compatibility with version 6.0.1.
- Refactored useTaskDragAndDrop and useTaskSocketHandlers hooks to use separate selectors for improved performance.
- Made minor style adjustments in project-view-header and project-view components for consistency.
2025-05-30 12:19:02 +05:30
chamikaJ
b6e4ed9883 Merge branch 'fix/performance-improvements' of https://github.com/Worklenz/worklenz into feature/project-finance 2025-05-30 11:42:48 +05:30
chamikaJ
b0fb0a2759 refactor(auth): remove debug logging from authentication processes
- Eliminated extensive console logging in the auth controller, deserialize, serialize, and passport strategies to streamline code and improve performance.
- Simplified response handling in the auth controller by directly returning the AuthResponse object.
- Updated session middleware to enhance clarity and maintainability by removing unnecessary debug functions and logs.
2025-05-30 09:48:47 +05:30
Chamika J
4bc1b4fa73 Merge pull request #146 from shancds/feature/reporting-time-members-filtter
Feature/reporting time members filtter
2025-05-30 08:41:21 +05:30
shancds
8d6c43c59c fix(reporting): update total utilization calculation precision and remove debug log from member time sheets 2025-05-29 17:57:16 +05:30
shancds
1f6bbce0ae feat(reporting): add total time utilization component and update member time sheets to include totals 2025-05-29 17:50:11 +05:30
chamikaJ
d1fe23b431 feat(database): add progress tracking and finance module tables
- Introduced a new ENUM type for progress modes in tasks to enhance progress tracking capabilities.
- Updated the projects and tasks tables to include new columns for manual and weighted progress tracking.
- Added new finance-related tables for rate cards and project rate card roles to support financial management within projects.
- Enhanced project members table to link with finance project rate card roles, improving data integrity and relationships.
2025-05-29 17:06:19 +05:30
chamikaJ
935165d751 refactor(session): simplify pg_sessions table structure and query logic
- Removed the created_at column from the pg_sessions table definition to streamline session management.
- Updated the recent sessions query to order by expire instead of created_at, enhancing the relevance of retrieved session data.
2025-05-29 17:04:08 +05:30
chamikaJ
2f0fb92e3e feat(session): add session store debugging and pg_sessions table management
- Implemented a test function for the session store to verify database connection and check the existence of the pg_sessions table.
- Added logic to create the pg_sessions table if it does not exist, including defining its structure and constraints.
- Enhanced session store methods with detailed logging for session set and get operations, improving traceability and debugging capabilities.
2025-05-29 16:54:27 +05:30
chamikaJ
a3d5e63635 fix(session): update session middleware configuration
- Changed session middleware settings to resave sessions when uninitialized and prevent saving uninitialized sessions.
- Updated cookie settings to enable httpOnly and set secure to false, enhancing security measures for session management.
2025-05-29 16:48:25 +05:30
chamikaJ
6a2e9afff8 feat(auth): enhance session and user deserialization logging
- Added detailed logging for session checks in the auth controller, including session ID and full session object.
- Implemented user existence verification in the deserialize function, with improved logging for user checks and database query results.
- Enhanced the serialize function to log the serialized user object and completion of the serialization process, improving traceability in authentication workflows.
2025-05-29 16:44:40 +05:30
chamikaJ
a0f36968b3 feat(auth): add debug logging for authentication processes
- Introduced detailed console logging in the auth controller, deserialize, serialize, and passport strategies to aid in debugging authentication flows.
- Enhanced error handling and response messaging during user registration and login processes, providing clearer feedback for success and failure scenarios.
- Updated the signup and login functions to include more informative logs, improving traceability of user actions and system responses.
2025-05-29 16:13:36 +05:30
shancds
b5288a8da2 fix(reporting): correct member data extraction in fetchReportingMembers 2025-05-29 15:49:06 +05:30
shancds
b94c56f50d feat(reporting): enhance utilization tracking and filtering in time reports 2025-05-29 15:38:25 +05:30
shancds
f1920c17b4 feat(members-time-sheet): enhance utilization display with color indicators 2025-05-29 12:09:50 +05:30
shancds
7b1c048dbb feat(time-report): add member search functionality to time report localization 2025-05-29 09:42:08 +05:30
shancds
9b48cc7e06 refactor(reporting): remove console logs from member time sheets and reporting slice 2025-05-29 09:33:15 +05:30
shancds
549728cdaf feat(reporting): implement member selection and filtering in time reports 2025-05-29 08:24:16 +05:30
chamiakJ
b8cc9b5b73 feat(project-finance): add finance data export functionality
- Implemented a new endpoint in the project finance controller to export financial data as an Excel file, allowing users to download project finance details.
- Enhanced the frontend to include an export button that triggers the finance data export, with appropriate loading states and error handling.
- Added functionality to group exported data by status, priority, or phases, improving the usability of the exported reports.
- Updated the project finance API service to handle the export request and return the generated Excel file as a Blob.
2025-05-29 01:17:05 +05:30
chamiakJ
a87ea46b97 feat(project-finance): implement hierarchical task loading and subtasks retrieval
- Enhanced the project finance controller to support hierarchical loading of tasks, allowing for better aggregation of financial data from parent and subtasks.
- Introduced a new endpoint to fetch subtasks along with their financial details, improving the granularity of task management.
- Updated the frontend to handle subtasks, including UI adjustments for displaying subtasks and their associated financial data.
- Added necessary Redux actions and state management for fetching and displaying subtasks in the finance table.
- Improved user experience by providing tooltips and disabling time estimation for tasks with subtasks, ensuring clarity in task management.
2025-05-29 00:59:59 +05:30
chamiakJ
5454c22bd1 feat(project-finance): enhance fixed cost handling and add silent refresh functionality
- Updated the fixed cost calculation logic to rely on backend values, avoiding unnecessary recalculations in the frontend.
- Introduced a new `fetchProjectFinancesSilent` thunk for refreshing project finance data without altering the loading state.
- Implemented debounced and immediate save functions for fixed cost changes in the FinanceTable component, improving user experience and data accuracy.
- Adjusted the UI to reflect changes in fixed cost handling, ensuring accurate display of task variances.
2025-05-28 22:04:51 +05:30
Chamika J
ad9e940987 Merge pull request #145 from shancds/feature/project-finance
Feature/project finance
2025-05-28 13:02:34 +05:30
shancds
cae5524168 feat(ratecard-assignee-selector): add assignedMembers prop to handle member assignment across job titles 2025-05-28 12:38:19 +05:30
chamiakJ
07bc5e6030 feat(project-finance): implement time formatting utilities and update task time handling
- Added utility functions to format time in hours, minutes, and seconds, and to parse time strings back to seconds.
- Updated the project finance controller to use seconds for estimated time and total time logged, improving accuracy in calculations.
- Modified frontend components to reflect changes in time handling, ensuring consistent display of time in both seconds and formatted strings.
- Adjusted Redux slice and types to accommodate new time formats, enhancing data integrity across the application.
2025-05-28 12:28:03 +05:30
shancds
5cb6548889 feat(import-ratecards-drawer): add alert for already imported rate cards and update button logic 2025-05-28 12:12:33 +05:30
Tharindu Kosgahakumbura
bc652f83af Merge branch 'Worklenz:feature/project-finance' into feature/project-finance 2025-05-28 10:51:21 +05:30
shancds
010cbe1af8 feat(ratecard-drawer): enhance drawer close logic to handle unsaved changes and delete empty rate cards 2025-05-28 10:50:35 +05:30
chamiakJ
ca0c958918 fix(project-finance-controller): correct estimated hours calculation to reflect hours instead of minutes 2025-05-27 16:42:51 +05:30
Chamika J
7bb93d2aef Merge pull request #140 from shancds/feature/project-finance
Feature/project finance
2025-05-27 14:50:17 +05:30
Tharindu Kosgahakumbura
42c4802d19 Merge branch 'Worklenz:feature/project-finance' into feature/project-finance 2025-05-27 14:19:36 +05:30
shancds
cf0eaad077 feat(ratecard-table): improve rate handling and focus management in the ratecard table 2025-05-27 14:16:45 +05:30
shancds
f22a91b690 feat(ratecard): enhance ratecard update logic and add unsaved changes alert 2025-05-27 12:42:34 +05:30
Chamika J
c33a152015 Merge pull request #139 from shancds/feature/project-finance
Feature/project finance
2025-05-27 09:07:17 +05:30
Tharindu Kosgahakumbura
dcb4ff1eb0 Merge branch 'Worklenz:feature/project-finance' into feature/project-finance 2025-05-27 08:51:31 +05:30
chamikaJ
612de866b7 feat(finance-table): enhance task display and localization support
- Added a new message for "No tasks found" in English, Spanish, and Portuguese localization files.
- Updated the finance table component to conditionally render a message when no tasks are available, improving user experience.
- Introduced a new CSS file for finance table styles to enhance visual consistency.
- Refactored the finance table rendering logic to handle task presence more effectively.
2025-05-26 17:03:50 +05:30
shancds
c55e593535 feat(project-view-finance): add confirmation messages for delete actions 2025-05-26 16:58:51 +05:30
shancds
da98fe26ab feat(subtask-table): add titles to table columns for better clarity 2025-05-26 16:46:53 +05:30
chamikaJ
b0ed3f67e8 feat(task-breakdown-api): implement task financial breakdown API and related enhancements
- Added a new API endpoint `GET /api/project-finance/task/:id/breakdown` to retrieve detailed financial breakdown for individual tasks, including labor hours and costs grouped by job roles.
- Introduced a new SQL migration to add a `fixed_cost` column to the tasks table for improved financial calculations.
- Updated the project finance controller to handle task breakdown logic, including calculations for estimated and actual costs.
- Enhanced frontend components to integrate the new task breakdown API, providing real-time financial data in the finance drawer.
- Updated localization files to reflect changes in financial terminology across English, Spanish, and Portuguese.
- Implemented Redux state management for selected tasks in the finance drawer.
2025-05-26 16:36:25 +05:30
shancds
85280c33d2 feat(members-time-sheet): enhance tooltip display with utilization status and color coding 2025-05-26 16:25:12 +05:30
shancds
f68c72a92a feat(ratecard-drawer): add confirmation popover for role deletion and improve button styling 2025-05-26 10:26:51 +05:30
shancds
1969fbd1dc feat(ratecard-drawer): enhance rate card deletion logic and button disable condition
feat(ratecard-settings): update useEffect dependencies for fetching rate cards
2025-05-26 10:18:49 +05:30
shancds
e567d6b345 feat(ratecard): add minimum value constraint to rate input fields 2025-05-26 08:59:58 +05:30
Chamika J
399d8b420a Merge pull request #137 from shancds/feature/project-finance
Feature/project finance (project-ratecard-member-add)
2025-05-26 08:33:29 +05:30
shancds
21a4131faa Fix(Ratecard): translate locate update 2025-05-23 17:09:16 +05:30
shancds
659ede7fb5 feat(project-ratecard-member): ratecard member handle backend and frontend and all update fix bug 2025-05-23 13:57:53 +05:30
shancds
22d0fc7049 feat(project-ratecard-member): add members to the project ratecard func (FE) 2025-05-23 09:34:02 +05:30
chamikaJ
b320a7b260 feat(project-finance): enhance project finance view and calculations
- Added a new SQL view `project_finance_view` to aggregate project financial data.
- Updated `project-finance-controller.ts` to fetch and group tasks by status, priority, or phases, including financial calculations for estimated costs, actual costs, and variances.
- Enhanced frontend components to display total time logged, estimated costs, and fixed costs in the finance table.
- Introduced new utility functions for formatting hours and calculating totals.
- Updated localization files to include new financial columns in English, Spanish, and Portuguese.
- Implemented Redux slice for managing project finance state and actions for updating task costs.
2025-05-23 08:32:48 +05:30
shancds
1a5f6d54ed Feat(settings-ratecard-drawer): add role show drop down fix 2025-05-23 06:47:12 +05:30
shancds
e245530a15 Merge branch 'feature/project-finance' of https://github.com/Worklenz/worklenz into feature/project-finance 2025-05-23 06:22:00 +05:30
shancds
87bd1b8801 feat(ratecard): crud rename and ratecard-assignee-selector create 2025-05-22 16:46:58 +05:30
shancds
a711d48c9c feat(ratecard): implement insertOne functionality for single role creation and update API integration 2025-05-22 13:03:08 +05:30
chamikaJ
096163d9c0 Merge branch 'feature/recurring-tasks' of https://github.com/Worklenz/worklenz into feature/project-finance 2025-05-22 09:56:59 +05:30
shancds
a879176c24 feat(ratecard): update currency default to USD and align rate columns to the right 2025-05-22 09:12:40 +05:30
chamikaJ
49fc89ae3a Merge branch 'feature/recurring-tasks' of https://github.com/Worklenz/worklenz into feature/project-finance 2025-05-22 08:11:27 +05:30
chamiakJ
d7a5f08058 feat(project-finance): implement project finance API and frontend integration for task retrieval 2025-05-22 06:24:00 +05:30
Chamika J
533b59504f Merge pull request #136 from shancds/feature/project-finance
Feature/project finance rate card section
2025-05-21 21:43:21 +05:30
shancds
b104cf2d3f feat(ratecard): add RatecardType import for enhanced type support in rate card functionality 2025-05-21 19:01:22 +05:30
shancds
3ce81272b2 feat(ratecard): enhance project rate card functionality with job title retrieval and bulk save feature 2025-05-21 18:59:04 +05:30
shancds
c3bec74897 feat(project-ratecard): implement project rate card management with CRUD operations and integrate into frontend 2025-05-21 17:16:35 +05:30
shancds
db1108a48d feat(ratecard): add 'Add All' and 'Remove All' buttons, enhance role management, and implement drawer close logic 2025-05-21 12:26:01 +05:30
Chamika J
4386aabeda Merge pull request #135 from shancds/finance-module
Finance module
2025-05-21 10:05:47 +05:30
Chamika J
69e7938365 Merge branch 'feature/project-finance' into finance-module 2025-05-21 10:05:17 +05:30
shancds
f6eaddefa4 feat(ratecard): add 'Add All' button to include all job titles and adjust drawer width 2025-05-21 09:55:58 +05:30
shancds
ded0ad693c refactor: remove debug console logs from selection cell components and database migration table column change 2025-05-21 07:56:38 +05:30
shancds
cc8dca7b75 fix(ratecard): update input value handling and change 'Ratecard' to 'Rate Card' in settings 2025-05-20 18:01:00 +05:30
shancds
7d81b7784b feat(ratecard): update job role references and enhance rate card functionality 2025-05-20 17:46:41 +05:30
chamiakJ
c1067d87fe refactor(reporting): update total working hours calculation in allocation controller
- Replaced project-specific hours per day with organization-wide working hours for total working hours calculation.
- Streamlined the SQL query to fetch organization working hours, ensuring accurate reporting based on organizational settings.
2025-05-20 16:49:07 +05:30
chamiakJ
97feef5982 refactor(reporting): improve utilization calculations in allocation controller
- Updated utilization percentage and utilized hours calculations to handle cases where total working hours are zero, providing 'N/A' for utilization percent when applicable.
- Adjusted logic for over/under utilized hours to ensure accurate reporting based on logged time and total working hours.
2025-05-20 16:18:16 +05:30
chamiakJ
76c92b1cc6 refactor(reporting): optimize date handling and organization working days logic
- Simplified date parsing by removing unnecessary start and end of day adjustments.
- Streamlined the fetching of organization working days from the database, consolidating queries for improved performance.
- Updated the calculation of total working hours to utilize project-specific hours per day, enhancing accuracy in reporting.
2025-05-20 15:51:33 +05:30
shancds
afd4cbdf81 refactor(ratecard): remove console log from updateRateCard thunk 2025-05-20 14:32:53 +05:30
shancds
3dd56f094c feat(ratecard): add currency field to rate card queries and update logic 2025-05-20 14:30:58 +05:30
shancds
26b0b5780a feat(ratecard): enhance rate card management with CRUD operations and improved type definitions 2025-05-20 13:38:42 +05:30
chamiakJ
67c62fc69b refactor(schedule): streamline organization working days update query
- Simplified the SQL update query for organization working days by removing unnecessary line breaks and improving readability.
- Adjusted the subquery to directly select organization IDs, enhancing clarity and maintainability.
2025-05-20 11:55:29 +05:30
chamiakJ
14d8f43001 refactor(reporting): clarify date parsing in allocation controller and frontend
- Updated comments to specify date parsing format as 'YYYY-MM-DD'.
- Modified date range handling in the frontend to format dates using date-fns for consistency.
2025-05-20 09:23:22 +05:30
chamiakJ
3b59a8560b refactor(reporting): simplify date parsing and improve logging format
- Updated date parsing to remove UTC conversion, maintaining local date context.
- Enhanced console logging to display dates in 'YYYY-MM-DD' format for clarity.
- Adjusted date range clause to directly use formatted dates for improved query accuracy.
2025-05-20 08:08:34 +05:30
chamiakJ
819252cedd refactor(reporting): update date handling and logging in allocation controller
- Removed UTC conversion for start and end dates to maintain local date context.
- Enhanced console logging to reflect local date values for better debugging.
2025-05-20 08:06:05 +05:30
chamiakJ
1dade05f54 feat(reporting): enhance date range handling in reporting allocation
- Added support for 'LAST_7_DAYS' and 'LAST_30_DAYS' date ranges in the reporting allocation logic.
- Updated date parsing to convert input dates to UTC while preserving the intended local date.
- Included console logs for debugging date values during processing.
2025-05-20 07:59:49 +05:30
chamiakJ
34613e5e0c Merge branch 'fix/performance-improvements' of https://github.com/Worklenz/worklenz into feature/member-time-progress-and-utilization 2025-05-20 07:07:28 +05:30
shancds
fbfeaceb9c feat(ratecard): implement CRUD operations and validation for rate cards 2025-05-19 17:05:18 +05:30
chamikaJ
a8b20680e5 feat: implement organization working days and hours settings
- Added functionality to fetch and update organization working days and hours in the admin center.
- Introduced a form for saving working days and hours, with validation and error handling.
- Updated the reporting allocation logic to utilize organization-specific working hours for accurate calculations.
- Enhanced localization files to support new settings in English, Spanish, and Portuguese.
2025-05-19 16:07:35 +05:30
shancds
2b3b0ba635 feat: Add Ratecard management functionality with localization support 2025-05-18 22:28:46 +05:30
shancds
6847eec603 feat: Implement Ratecard Drawer and Finance Table 2025-05-14 22:20:50 +05:30
201 changed files with 13673 additions and 6675 deletions

View File

@@ -0,0 +1,195 @@
# Task Breakdown API
## Get Task Financial Breakdown
**Endpoint:** `GET /api/project-finance/task/:id/breakdown`
**Description:** Retrieves detailed financial breakdown for a single task, including members grouped by job roles with labor hours and costs.
### Parameters
- `id` (path parameter): UUID of the task
### Response
```json
{
"success": true,
"body": {
"task": {
"id": "uuid",
"name": "Task Name",
"project_id": "uuid",
"billable": true,
"estimated_hours": 10.5,
"logged_hours": 8.25,
"estimated_labor_cost": 525.0,
"actual_labor_cost": 412.5,
"fixed_cost": 100.0,
"total_estimated_cost": 625.0,
"total_actual_cost": 512.5
},
"grouped_members": [
{
"jobRole": "Frontend Developer",
"estimated_hours": 5.25,
"logged_hours": 4.0,
"estimated_cost": 262.5,
"actual_cost": 200.0,
"members": [
{
"team_member_id": "uuid",
"name": "John Doe",
"avatar_url": "https://...",
"hourly_rate": 50.0,
"estimated_hours": 5.25,
"logged_hours": 4.0,
"estimated_cost": 262.5,
"actual_cost": 200.0
}
]
},
{
"jobRole": "Backend Developer",
"estimated_hours": 5.25,
"logged_hours": 4.25,
"estimated_cost": 262.5,
"actual_cost": 212.5,
"members": [
{
"team_member_id": "uuid",
"name": "Jane Smith",
"avatar_url": "https://...",
"hourly_rate": 50.0,
"estimated_hours": 5.25,
"logged_hours": 4.25,
"estimated_cost": 262.5,
"actual_cost": 212.5
}
]
}
],
"members": [
{
"team_member_id": "uuid",
"name": "John Doe",
"avatar_url": "https://...",
"hourly_rate": 50.0,
"job_title_name": "Frontend Developer",
"estimated_hours": 5.25,
"logged_hours": 4.0,
"estimated_cost": 262.5,
"actual_cost": 200.0
},
{
"team_member_id": "uuid",
"name": "Jane Smith",
"avatar_url": "https://...",
"hourly_rate": 50.0,
"job_title_name": "Backend Developer",
"estimated_hours": 5.25,
"logged_hours": 4.25,
"estimated_cost": 262.5,
"actual_cost": 212.5
}
]
}
}
```
### Error Responses
- `404 Not Found`: Task not found
- `400 Bad Request`: Invalid task ID
### Usage
This endpoint is designed to work with the finance drawer component (`@finance-drawer.tsx`) to provide detailed cost breakdown information for individual tasks. The response includes:
1. **Task Summary**: Overall task financial information
2. **Grouped Members**: Members organized by job role with aggregated costs
3. **Individual Members**: Detailed breakdown for each team member
The data structure matches what the finance drawer expects, with members grouped by job roles and individual labor hours and costs calculated based on:
- Estimated hours divided equally among assignees
- Actual logged time per member
- Hourly rates from project rate cards
- Fixed costs added to the totals
### Frontend Usage Example
```typescript
import { projectFinanceApiService } from '@/api/project-finance-ratecard/project-finance.api.service';
// Fetch task breakdown
const fetchTaskBreakdown = async (taskId: string) => {
try {
const response = await projectFinanceApiService.getTaskBreakdown(taskId);
const breakdown = response.body;
console.log('Task:', breakdown.task);
console.log('Grouped Members:', breakdown.grouped_members);
console.log('Individual Members:', breakdown.members);
return breakdown;
} catch (error) {
console.error('Error fetching task breakdown:', error);
throw error;
}
};
// Usage in React component
const TaskBreakdownComponent = ({ taskId }: { taskId: string }) => {
const [breakdown, setBreakdown] = useState(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
const loadBreakdown = async () => {
setLoading(true);
try {
const data = await fetchTaskBreakdown(taskId);
setBreakdown(data);
} catch (error) {
// Handle error
} finally {
setLoading(false);
}
};
if (taskId) {
loadBreakdown();
}
}, [taskId]);
if (loading) return <Spin />;
if (!breakdown) return null;
return (
<div>
<h3>{breakdown.task.name}</h3>
<p>Total Estimated Cost: ${breakdown.task.total_estimated_cost}</p>
<p>Total Actual Cost: ${breakdown.task.total_actual_cost}</p>
{breakdown.grouped_members.map(group => (
<div key={group.jobRole}>
<h4>{group.jobRole}</h4>
<p>Hours: {group.estimated_hours} | Cost: ${group.estimated_cost}</p>
{group.members.map(member => (
<div key={member.team_member_id}>
{member.name}: {member.estimated_hours}h @ ${member.hourly_rate}/h
</div>
))}
</div>
))}
</div>
);
};
```
### Integration
This API complements the existing finance endpoints:
- `GET /api/project-finance/project/:project_id/tasks` - Get all tasks for a project
- `PUT /api/project-finance/task/:task_id/fixed-cost` - Update task fixed cost
The finance drawer component has been updated to automatically use this API when a task is selected, providing real-time financial breakdown data.

View File

@@ -0,0 +1,228 @@
-- Migration: Add recursive task estimation functionality
-- This migration adds a function to calculate recursive task estimation including all subtasks
-- and modifies the get_task_form_view_model function to include this data
BEGIN;
-- Function to calculate recursive task estimation (including all subtasks)
CREATE OR REPLACE FUNCTION get_task_recursive_estimation(_task_id UUID) RETURNS JSON
LANGUAGE plpgsql
AS
$$
DECLARE
_result JSON;
_has_subtasks BOOLEAN;
BEGIN
-- First check if this task has any subtasks
SELECT EXISTS(
SELECT 1 FROM tasks
WHERE parent_task_id = _task_id
AND archived = false
) INTO _has_subtasks;
-- If task has subtasks, calculate recursive estimation excluding parent's own estimation
IF _has_subtasks THEN
WITH RECURSIVE task_tree AS (
-- Start with direct subtasks only (exclude the parent task itself)
SELECT
id,
parent_task_id,
COALESCE(total_minutes, 0) as total_minutes,
1 as level -- Start at level 1 (subtasks)
FROM tasks
WHERE parent_task_id = _task_id
AND archived = false
UNION ALL
-- Recursive case: Get all descendant tasks
SELECT
t.id,
t.parent_task_id,
COALESCE(t.total_minutes, 0) as total_minutes,
tt.level + 1 as level
FROM tasks t
INNER JOIN task_tree tt ON t.parent_task_id = tt.id
WHERE t.archived = false
),
task_counts AS (
SELECT
COUNT(*) as sub_tasks_count,
SUM(total_minutes) as subtasks_total_minutes -- Sum all subtask estimations
FROM task_tree
)
SELECT JSON_BUILD_OBJECT(
'sub_tasks_count', COALESCE(tc.sub_tasks_count, 0),
'own_total_minutes', 0, -- Always 0 for parent tasks
'subtasks_total_minutes', COALESCE(tc.subtasks_total_minutes, 0),
'recursive_total_minutes', COALESCE(tc.subtasks_total_minutes, 0), -- Only subtasks total
'recursive_total_hours', FLOOR(COALESCE(tc.subtasks_total_minutes, 0) / 60),
'recursive_remaining_minutes', COALESCE(tc.subtasks_total_minutes, 0) % 60
)
INTO _result
FROM task_counts tc;
ELSE
-- If task has no subtasks, use its own estimation
SELECT JSON_BUILD_OBJECT(
'sub_tasks_count', 0,
'own_total_minutes', COALESCE(total_minutes, 0),
'subtasks_total_minutes', 0,
'recursive_total_minutes', COALESCE(total_minutes, 0), -- Use own estimation
'recursive_total_hours', FLOOR(COALESCE(total_minutes, 0) / 60),
'recursive_remaining_minutes', COALESCE(total_minutes, 0) % 60
)
INTO _result
FROM tasks
WHERE id = _task_id;
END IF;
RETURN COALESCE(_result, JSON_BUILD_OBJECT(
'sub_tasks_count', 0,
'own_total_minutes', 0,
'subtasks_total_minutes', 0,
'recursive_total_minutes', 0,
'recursive_total_hours', 0,
'recursive_remaining_minutes', 0
));
END;
$$;
-- Update the get_task_form_view_model function to include recursive estimation
CREATE OR REPLACE FUNCTION public.get_task_form_view_model(_user_id UUID, _team_id UUID, _task_id UUID, _project_id UUID) RETURNS JSON
LANGUAGE plpgsql
AS
$$
DECLARE
_task JSON;
_priorities JSON;
_projects JSON;
_statuses JSON;
_team_members JSON;
_assignees JSON;
_phases JSON;
BEGIN
-- Select task info
SELECT COALESCE(ROW_TO_JSON(rec), '{}'::JSON)
INTO _task
FROM (WITH RECURSIVE task_hierarchy AS (
-- Base case: Start with the given task
SELECT id,
parent_task_id,
0 AS level
FROM tasks
WHERE id = _task_id
UNION ALL
-- Recursive case: Traverse up to parent tasks
SELECT t.id,
t.parent_task_id,
th.level + 1 AS level
FROM tasks t
INNER JOIN task_hierarchy th ON t.id = th.parent_task_id
WHERE th.parent_task_id IS NOT NULL)
SELECT id,
name,
description,
start_date,
end_date,
done,
total_minutes,
priority_id,
project_id,
created_at,
updated_at,
status_id,
parent_task_id,
sort_order,
(SELECT phase_id FROM task_phase WHERE task_id = tasks.id) AS phase_id,
CONCAT((SELECT key FROM projects WHERE id = tasks.project_id), '-', task_no) AS task_key,
(SELECT start_time
FROM task_timers
WHERE task_id = tasks.id
AND user_id = _user_id) AS timer_start_time,
parent_task_id IS NOT NULL AS is_sub_task,
(SELECT COUNT('*')
FROM tasks
WHERE parent_task_id = tasks.id
AND archived IS FALSE) AS sub_tasks_count,
(SELECT COUNT(*)
FROM tasks_with_status_view tt
WHERE (tt.parent_task_id = tasks.id OR tt.task_id = tasks.id)
AND tt.is_done IS TRUE)
AS completed_count,
(SELECT COUNT(*) FROM task_attachments WHERE task_id = tasks.id) AS attachments_count,
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(r))), '[]'::JSON)
FROM (SELECT task_labels.label_id AS id,
(SELECT name FROM team_labels WHERE id = task_labels.label_id),
(SELECT color_code FROM team_labels WHERE id = task_labels.label_id)
FROM task_labels
WHERE task_id = tasks.id
ORDER BY name) r) AS labels,
(SELECT color_code
FROM sys_task_status_categories
WHERE id = (SELECT category_id FROM task_statuses WHERE id = tasks.status_id)) AS status_color,
(SELECT COUNT(*) FROM tasks WHERE parent_task_id = _task_id) AS sub_tasks_count,
(SELECT name FROM users WHERE id = tasks.reporter_id) AS reporter,
(SELECT get_task_assignees(tasks.id)) AS assignees,
(SELECT id FROM team_members WHERE user_id = _user_id AND team_id = _team_id) AS team_member_id,
billable,
schedule_id,
progress_value,
weight,
(SELECT MAX(level) FROM task_hierarchy) AS task_level,
(SELECT get_task_recursive_estimation(tasks.id)) AS recursive_estimation
FROM tasks
WHERE id = _task_id) rec;
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
INTO _priorities
FROM (SELECT id, name FROM task_priorities ORDER BY value) rec;
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
INTO _phases
FROM (SELECT id, name FROM project_phases WHERE project_id = _project_id ORDER BY name) rec;
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
INTO _projects
FROM (SELECT id, name
FROM projects
WHERE team_id = _team_id
AND (CASE
WHEN (is_owner(_user_id, _team_id) OR is_admin(_user_id, _team_id) IS TRUE) THEN TRUE
ELSE is_member_of_project(projects.id, _user_id, _team_id) END)
ORDER BY name) rec;
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
INTO _statuses
FROM (SELECT id, name FROM task_statuses WHERE project_id = _project_id) rec;
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
INTO _team_members
FROM (SELECT team_members.id,
(SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = team_members.id),
(SELECT email FROM team_member_info_view WHERE team_member_info_view.team_member_id = team_members.id),
(SELECT avatar_url
FROM team_member_info_view
WHERE team_member_info_view.team_member_id = team_members.id)
FROM team_members
LEFT JOIN users u ON team_members.user_id = u.id
WHERE team_id = _team_id
AND team_members.active IS TRUE) rec;
SELECT get_task_assignees(_task_id) INTO _assignees;
RETURN JSON_BUILD_OBJECT(
'task', _task,
'priorities', _priorities,
'projects', _projects,
'statuses', _statuses,
'team_members', _team_members,
'assignees', _assignees,
'phases', _phases
);
END;
$$;
COMMIT;

View File

@@ -0,0 +1,20 @@
-- Migration: Add currency column to projects table
-- Date: 2025-01-17
-- Description: Adds project-specific currency support to allow different projects to use different currencies
-- Add currency column to projects table
ALTER TABLE projects
ADD COLUMN IF NOT EXISTS currency VARCHAR(3) DEFAULT 'USD';
-- Add comment for documentation
COMMENT ON COLUMN projects.currency IS 'Project-specific currency code (e.g., USD, EUR, GBP, JPY, etc.)';
-- Add constraint to ensure currency codes are uppercase and 3 characters
ALTER TABLE projects
ADD CONSTRAINT projects_currency_format_check
CHECK (currency ~ '^[A-Z]{3}$');
-- Update existing projects to have a default currency if they don't have one
UPDATE projects
SET currency = 'USD'
WHERE currency IS NULL;

View File

@@ -603,7 +603,8 @@ BEGIN
schedule_id,
progress_value,
weight,
(SELECT MAX(level) FROM task_hierarchy) AS task_level
(SELECT MAX(level) FROM task_hierarchy) AS task_level,
(SELECT get_task_recursive_estimation(tasks.id)) AS recursive_estimation
FROM tasks
WHERE id = _task_id) rec;
@@ -662,6 +663,89 @@ ADD COLUMN IF NOT EXISTS use_manual_progress BOOLEAN DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS use_weighted_progress BOOLEAN DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS use_time_progress BOOLEAN DEFAULT FALSE;
-- Function to calculate recursive task estimation (including all subtasks)
CREATE OR REPLACE FUNCTION get_task_recursive_estimation(_task_id UUID) RETURNS JSON
LANGUAGE plpgsql
AS
$$
DECLARE
_result JSON;
_has_subtasks BOOLEAN;
BEGIN
-- First check if this task has any subtasks
SELECT EXISTS(
SELECT 1 FROM tasks
WHERE parent_task_id = _task_id
AND archived = false
) INTO _has_subtasks;
-- If task has subtasks, calculate recursive estimation excluding parent's own estimation
IF _has_subtasks THEN
WITH RECURSIVE task_tree AS (
-- Start with direct subtasks only (exclude the parent task itself)
SELECT
id,
parent_task_id,
COALESCE(total_minutes, 0) as total_minutes,
1 as level -- Start at level 1 (subtasks)
FROM tasks
WHERE parent_task_id = _task_id
AND archived = false
UNION ALL
-- Recursive case: Get all descendant tasks
SELECT
t.id,
t.parent_task_id,
COALESCE(t.total_minutes, 0) as total_minutes,
tt.level + 1 as level
FROM tasks t
INNER JOIN task_tree tt ON t.parent_task_id = tt.id
WHERE t.archived = false
),
task_counts AS (
SELECT
COUNT(*) as sub_tasks_count,
SUM(total_minutes) as subtasks_total_minutes -- Sum all subtask estimations
FROM task_tree
)
SELECT JSON_BUILD_OBJECT(
'sub_tasks_count', COALESCE(tc.sub_tasks_count, 0),
'own_total_minutes', 0, -- Always 0 for parent tasks
'subtasks_total_minutes', COALESCE(tc.subtasks_total_minutes, 0),
'recursive_total_minutes', COALESCE(tc.subtasks_total_minutes, 0), -- Only subtasks total
'recursive_total_hours', FLOOR(COALESCE(tc.subtasks_total_minutes, 0) / 60),
'recursive_remaining_minutes', COALESCE(tc.subtasks_total_minutes, 0) % 60
)
INTO _result
FROM task_counts tc;
ELSE
-- If task has no subtasks, use its own estimation
SELECT JSON_BUILD_OBJECT(
'sub_tasks_count', 0,
'own_total_minutes', COALESCE(total_minutes, 0),
'subtasks_total_minutes', 0,
'recursive_total_minutes', COALESCE(total_minutes, 0), -- Use own estimation
'recursive_total_hours', FLOOR(COALESCE(total_minutes, 0) / 60),
'recursive_remaining_minutes', COALESCE(total_minutes, 0) % 60
)
INTO _result
FROM tasks
WHERE id = _task_id;
END IF;
RETURN COALESCE(_result, JSON_BUILD_OBJECT(
'sub_tasks_count', 0,
'own_total_minutes', 0,
'subtasks_total_minutes', 0,
'recursive_total_minutes', 0,
'recursive_total_hours', 0,
'recursive_remaining_minutes', 0
));
END;
$$;
-- Add a trigger to reset manual progress when a task gets a new subtask
CREATE OR REPLACE FUNCTION reset_parent_task_manual_progress() RETURNS TRIGGER AS
$$
@@ -677,6 +761,22 @@ BEGIN
END;
$$ LANGUAGE plpgsql;
-- Add a trigger to reset parent task estimation when it gets subtasks
CREATE OR REPLACE FUNCTION reset_parent_task_estimation() RETURNS TRIGGER AS
$$
BEGIN
-- When a task gets a new subtask (parent_task_id is set), reset the parent's total_minutes to 0
-- This ensures parent tasks don't have their own estimation when they have subtasks
IF NEW.parent_task_id IS NOT NULL THEN
UPDATE tasks
SET total_minutes = 0
WHERE id = NEW.parent_task_id
AND total_minutes > 0;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Create the trigger on the tasks table
DROP TRIGGER IF EXISTS reset_parent_manual_progress_trigger ON tasks;
CREATE TRIGGER reset_parent_manual_progress_trigger
@@ -684,4 +784,35 @@ AFTER INSERT OR UPDATE OF parent_task_id ON tasks
FOR EACH ROW
EXECUTE FUNCTION reset_parent_task_manual_progress();
-- Create the trigger to reset parent task estimation
DROP TRIGGER IF EXISTS reset_parent_estimation_trigger ON tasks;
CREATE TRIGGER reset_parent_estimation_trigger
AFTER INSERT OR UPDATE OF parent_task_id ON tasks
FOR EACH ROW
EXECUTE FUNCTION reset_parent_task_estimation();
-- Function to reset all existing parent tasks' estimations to 0
CREATE OR REPLACE FUNCTION reset_all_parent_task_estimations() RETURNS INTEGER AS
$$
DECLARE
_updated_count INTEGER;
BEGIN
-- Update all tasks that have subtasks to have 0 estimation
UPDATE tasks
SET total_minutes = 0
WHERE id IN (
SELECT DISTINCT parent_task_id
FROM tasks
WHERE parent_task_id IS NOT NULL
AND archived = false
)
AND total_minutes > 0
AND archived = false;
GET DIAGNOSTICS _updated_count = ROW_COUNT;
RETURN _updated_count;
END;
$$ LANGUAGE plpgsql;
COMMIT;

View File

@@ -0,0 +1,48 @@
-- Dropping existing finance_rate_cards table
DROP TABLE IF EXISTS finance_rate_cards;
-- Creating table to store rate card details
CREATE TABLE finance_rate_cards
(
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
team_id UUID NOT NULL REFERENCES teams (id) ON DELETE CASCADE,
name VARCHAR NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Dropping existing finance_project_rate_card_roles table
DROP TABLE IF EXISTS finance_project_rate_card_roles CASCADE;
-- Creating table with single id primary key
CREATE TABLE finance_project_rate_card_roles
(
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
project_id UUID NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
job_title_id UUID NOT NULL REFERENCES job_titles (id) ON DELETE CASCADE,
rate DECIMAL(10, 2) NOT NULL CHECK (rate >= 0),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT unique_project_role UNIQUE (project_id, job_title_id)
);
-- Dropping existing finance_rate_card_roles table
DROP TABLE IF EXISTS finance_rate_card_roles;
-- Creating table to store role-specific rates for rate cards
CREATE TABLE finance_rate_card_roles
(
rate_card_id UUID NOT NULL REFERENCES finance_rate_cards (id) ON DELETE CASCADE,
job_title_id UUID REFERENCES job_titles(id) ON DELETE SET NULL,
rate DECIMAL(10, 2) NOT NULL CHECK (rate >= 0),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Adding project_rate_card_role_id column to project_members
ALTER TABLE project_members
ADD COLUMN project_rate_card_role_id UUID REFERENCES finance_project_rate_card_roles(id) ON DELETE SET NULL;
-- Adding rate_card column to projects
ALTER TABLE projects
ADD COLUMN rate_card UUID REFERENCES finance_rate_cards(id) ON DELETE SET NULL;
ALTER TABLE finance_rate_cards
ADD COLUMN currency TEXT NOT NULL DEFAULT 'USD';

View File

@@ -0,0 +1,6 @@
-- Add fixed_cost column to tasks table for project finance functionality
ALTER TABLE tasks
ADD COLUMN fixed_cost DECIMAL(10, 2) DEFAULT 0 CHECK (fixed_cost >= 0);
-- Add comment to explain the column
COMMENT ON COLUMN tasks.fixed_cost IS 'Fixed cost for the task in addition to hourly rate calculations';

View File

@@ -118,7 +118,7 @@ BEGIN
SELECT SUM(time_spent)
FROM task_work_log
WHERE task_id = t.id
), 0) as logged_minutes
), 0) / 60.0 as logged_minutes
FROM tasks t
WHERE t.id = _task_id
)

View File

@@ -14,6 +14,9 @@ CREATE TYPE SCHEDULE_TYPE AS ENUM ('daily', 'weekly', 'yearly', 'monthly', 'ever
CREATE TYPE LANGUAGE_TYPE AS ENUM ('en', 'es', 'pt');
-- Add progress mode type for tasks progress tracking
CREATE TYPE PROGRESS_MODE_TYPE AS ENUM ('manual', 'weighted', 'time', 'default');
-- START: Users
CREATE SEQUENCE IF NOT EXISTS users_user_no_seq START 1;
@@ -777,9 +780,15 @@ CREATE TABLE IF NOT EXISTS projects (
estimated_man_days INTEGER DEFAULT 0,
hours_per_day INTEGER DEFAULT 8,
health_id UUID,
estimated_working_days INTEGER DEFAULT 0
estimated_working_days INTEGER DEFAULT 0,
use_manual_progress BOOLEAN DEFAULT FALSE,
use_weighted_progress BOOLEAN DEFAULT FALSE,
use_time_progress BOOLEAN DEFAULT FALSE,
currency VARCHAR(3) DEFAULT 'USD'
);
COMMENT ON COLUMN projects.currency IS 'Project-specific currency code (e.g., USD, EUR, GBP, JPY, etc.)';
ALTER TABLE projects
ADD CONSTRAINT projects_pk
PRIMARY KEY (id);
@@ -1411,9 +1420,16 @@ CREATE TABLE IF NOT EXISTS tasks (
sort_order INTEGER DEFAULT 0 NOT NULL,
roadmap_sort_order INTEGER DEFAULT 0 NOT NULL,
billable BOOLEAN DEFAULT TRUE,
schedule_id UUID
schedule_id UUID,
manual_progress BOOLEAN DEFAULT FALSE,
progress_value INTEGER DEFAULT NULL,
progress_mode PROGRESS_MODE_TYPE DEFAULT 'default',
weight INTEGER DEFAULT NULL,
fixed_cost DECIMAL(10, 2) DEFAULT 0 CHECK (fixed_cost >= 0)
);
COMMENT ON COLUMN tasks.fixed_cost IS 'Fixed cost for the task in addition to hourly rate calculations';
ALTER TABLE tasks
ADD CONSTRAINT tasks_pk
PRIMARY KEY (id);
@@ -2279,3 +2295,37 @@ ALTER TABLE organization_working_days
ALTER TABLE organization_working_days
ADD CONSTRAINT org_organization_id_fk
FOREIGN KEY (organization_id) REFERENCES organizations;
-- Finance module tables
CREATE TABLE IF NOT EXISTS finance_rate_cards (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
team_id UUID NOT NULL REFERENCES teams (id) ON DELETE CASCADE,
name VARCHAR NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
currency TEXT NOT NULL DEFAULT 'USD'
);
CREATE TABLE IF NOT EXISTS finance_project_rate_card_roles (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
project_id UUID NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
job_title_id UUID NOT NULL REFERENCES job_titles (id) ON DELETE CASCADE,
rate DECIMAL(10, 2) NOT NULL CHECK (rate >= 0),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT unique_project_role UNIQUE (project_id, job_title_id)
);
CREATE TABLE IF NOT EXISTS finance_rate_card_roles (
rate_card_id UUID NOT NULL REFERENCES finance_rate_cards (id) ON DELETE CASCADE,
job_title_id UUID REFERENCES job_titles(id) ON DELETE SET NULL,
rate DECIMAL(10, 2) NOT NULL CHECK (rate >= 0),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
ALTER TABLE project_members
ADD COLUMN IF NOT EXISTS project_rate_card_role_id UUID REFERENCES finance_project_rate_card_roles(id) ON DELETE SET NULL;
ALTER TABLE projects
ADD COLUMN IF NOT EXISTS rate_card UUID REFERENCES finance_rate_cards(id) ON DELETE SET NULL;

View File

@@ -4117,7 +4117,7 @@ BEGIN
'color_code_dark', COALESCE((_task_info ->> 'color_code_dark')::TEXT, ''),
'total_tasks', COALESCE((_task_info ->> 'total_tasks')::INT, 0),
'total_completed', COALESCE((_task_info ->> 'total_completed')::INT, 0),
'members', COALESCE((_task_info ->> 'members')::JSON, '[]'::JSON),
'members', COALESCE((_task_info -> 'members'), '[]'::JSON),
'completed_at', _task_completed_at,
'status_category', COALESCE(_status_category, '{}'::JSON),
'schedule_id', COALESCE(_schedule_id, 'null'::JSON)
@@ -5401,7 +5401,8 @@ BEGIN
updated_at = CURRENT_TIMESTAMP,
estimated_working_days = (_body ->> 'working_days')::INTEGER,
estimated_man_days = (_body ->> 'man_days')::INTEGER,
hours_per_day = (_body ->> 'hours_per_day')::INTEGER
hours_per_day = (_body ->> 'hours_per_day')::INTEGER,
currency = COALESCE(UPPER((_body ->> 'currency')::TEXT), currency)
WHERE id = (_body ->> 'id')::UUID
AND team_id = _team_id
RETURNING id INTO _project_id;
@@ -6372,3 +6373,44 @@ BEGIN
);
END;
$$;
CREATE OR REPLACE VIEW project_finance_view AS
SELECT
t.id,
t.name,
t.total_minutes / 3600.0 as estimated_hours,
COALESCE((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id), 0) / 3600.0 as total_time_logged,
COALESCE((SELECT SUM(rate * (time_spent / 3600.0))
FROM task_work_log twl
LEFT JOIN users u ON twl.user_id = u.id
LEFT JOIN team_members tm ON u.id = tm.user_id
LEFT JOIN project_members pm ON tm.id = pm.team_member_id
LEFT JOIN finance_project_rate_card_roles pmr ON pm.project_rate_card_role_id = pmr.id
WHERE twl.task_id = t.id), 0) as estimated_cost,
0 as fixed_cost, -- Default to 0 since the column doesn't exist
COALESCE(t.total_minutes / 3600.0 *
(SELECT rate FROM finance_project_rate_card_roles
WHERE project_id = t.project_id
AND id = (SELECT project_rate_card_role_id FROM project_members WHERE team_member_id = t.reporter_id LIMIT 1)
LIMIT 1), 0) as total_budgeted_cost,
COALESCE((SELECT SUM(rate * (time_spent / 3600.0))
FROM task_work_log twl
LEFT JOIN users u ON twl.user_id = u.id
LEFT JOIN team_members tm ON u.id = tm.user_id
LEFT JOIN project_members pm ON tm.id = pm.team_member_id
LEFT JOIN finance_project_rate_card_roles pmr ON pm.project_rate_card_role_id = pmr.id
WHERE twl.task_id = t.id), 0) as total_actual_cost,
COALESCE((SELECT SUM(rate * (time_spent / 3600.0))
FROM task_work_log twl
LEFT JOIN users u ON twl.user_id = u.id
LEFT JOIN team_members tm ON u.id = tm.user_id
LEFT JOIN project_members pm ON tm.id = pm.team_member_id
LEFT JOIN finance_project_rate_card_roles pmr ON pm.project_rate_card_role_id = pmr.id
WHERE twl.task_id = t.id), 0) -
COALESCE(t.total_minutes / 3600.0 *
(SELECT rate FROM finance_project_rate_card_roles
WHERE project_id = t.project_id
AND id = (SELECT project_rate_card_role_id FROM project_members WHERE team_member_id = t.reporter_id LIMIT 1)
LIMIT 1), 0) as variance,
t.project_id
FROM tasks t;

View File

@@ -0,0 +1,77 @@
-- Fix task hierarchy and reset parent estimations
-- This script ensures proper parent-child relationships and resets parent estimations
-- First, let's see the current task hierarchy
SELECT
t.id,
t.name,
t.parent_task_id,
t.total_minutes,
(SELECT name FROM tasks WHERE id = t.parent_task_id) as parent_name,
(SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id AND archived = false) as actual_subtask_count,
t.archived
FROM tasks t
WHERE (t.name LIKE '%sub%' OR t.name LIKE '%test task%')
ORDER BY t.name, t.created_at;
-- Reset all parent task estimations to 0
-- This ensures parent tasks don't have their own estimation when they have subtasks
UPDATE tasks
SET total_minutes = 0
WHERE id IN (
SELECT DISTINCT parent_task_id
FROM tasks
WHERE parent_task_id IS NOT NULL
AND archived = false
)
AND archived = false;
-- Verify the results after the update
SELECT
t.id,
t.name,
t.parent_task_id,
t.total_minutes as current_estimation,
(SELECT name FROM tasks WHERE id = t.parent_task_id) as parent_name,
(SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id AND archived = false) as subtask_count,
get_task_recursive_estimation(t.id) as recursive_estimation
FROM tasks t
WHERE (t.name LIKE '%sub%' OR t.name LIKE '%test task%')
AND t.archived = false
ORDER BY t.name;
-- Show the hierarchy in tree format
WITH RECURSIVE task_hierarchy AS (
-- Top level tasks (no parent)
SELECT
id,
name,
parent_task_id,
total_minutes,
0 as level,
name as path
FROM tasks
WHERE parent_task_id IS NULL
AND (name LIKE '%sub%' OR name LIKE '%test task%')
AND archived = false
UNION ALL
-- Child tasks
SELECT
t.id,
t.name,
t.parent_task_id,
t.total_minutes,
th.level + 1,
th.path || ' > ' || t.name
FROM tasks t
INNER JOIN task_hierarchy th ON t.parent_task_id = th.id
WHERE t.archived = false
)
SELECT
REPEAT(' ', level) || name as indented_name,
total_minutes,
get_task_recursive_estimation(id) as recursive_estimation
FROM task_hierarchy
ORDER BY path;

View File

@@ -3528,7 +3528,6 @@
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^5.1.2",
@@ -3546,7 +3545,6 @@
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
"integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -3559,7 +3557,6 @@
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
"integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -3572,14 +3569,12 @@
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
"dev": true,
"license": "MIT"
},
"node_modules/@isaacs/cliui/node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"eastasianwidth": "^0.2.0",
@@ -3597,7 +3592,6 @@
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
"integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.0.1"
@@ -3613,7 +3607,6 @@
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.1.0",
@@ -3934,23 +3927,6 @@
"node": ">=8"
}
},
"node_modules/@jest/core/node_modules/rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"dev": true,
"license": "ISC",
"dependencies": {
"glob": "^7.1.3"
},
"bin": {
"rimraf": "bin.js"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@jest/core/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -4485,22 +4461,6 @@
"node": ">=10"
}
},
"node_modules/@mapbox/node-pre-gyp/node_modules/rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"license": "ISC",
"dependencies": {
"glob": "^7.1.3"
},
"bin": {
"rimraf": "bin.js"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@mapbox/node-pre-gyp/node_modules/semver": {
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
@@ -7682,7 +7642,6 @@
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
@@ -8076,7 +8035,6 @@
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"dev": true,
"license": "MIT"
},
"node_modules/ecdsa-sig-formatter": {
@@ -9102,23 +9060,6 @@
"node": "^10.12.0 || >=12.0.0"
}
},
"node_modules/flat-cache/node_modules/rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"dev": true,
"license": "ISC",
"dependencies": {
"glob": "^7.1.3"
},
"bin": {
"rimraf": "bin.js"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/flatted": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz",
@@ -9154,7 +9095,6 @@
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
"dev": true,
"license": "ISC",
"dependencies": {
"cross-spawn": "^7.0.6",
@@ -9171,7 +9111,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=14"
@@ -9300,17 +9239,6 @@
"mkdirp": "bin/cmd.js"
}
},
"node_modules/fstream/node_modules/rimraf": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
"integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
"dependencies": {
"glob": "^7.1.3"
},
"bin": {
"rimraf": "bin.js"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -9943,8 +9871,7 @@
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="
},
"node_modules/istanbul-lib-coverage": {
"version": "3.2.0",
@@ -10085,7 +10012,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz",
"integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/cliui": "^8.0.2"
@@ -12749,7 +12675,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"dev": true,
"license": "BlueOak-1.0.0"
},
"node_modules/pako": {
@@ -12905,7 +12830,6 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"engines": {
"node": ">=8"
}
@@ -12919,7 +12843,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz",
"integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^11.0.0",
@@ -12936,7 +12859,6 @@
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz",
"integrity": "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==",
"dev": true,
"license": "ISC",
"engines": {
"node": "20 || >=22"
@@ -12946,7 +12868,6 @@
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=16 || 14 >=14.17"
@@ -13918,7 +13839,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.0.1.tgz",
"integrity": "sha512-9dkvaxAsk/xNXSJzMgFqqMCuFgt2+KsOFek3TMLfo8NCPfWpBmqwyNn5Y+NX56QUYfCtsyhF3ayiboEoUmJk/A==",
"dev": true,
"license": "ISC",
"dependencies": {
"glob": "^11.0.0",
@@ -13938,7 +13858,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
@@ -13948,7 +13867,6 @@
"version": "11.0.2",
"resolved": "https://registry.npmjs.org/glob/-/glob-11.0.2.tgz",
"integrity": "sha512-YT7U7Vye+t5fZ/QMkBFrTJ7ZQxInIUjwyAjVj84CYXqgBdv30MFUPGnBR6sQaVq6Is15wYJUsnzTuWaGRBhBAQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"foreground-child": "^3.1.0",
@@ -13972,7 +13890,6 @@
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.1.tgz",
"integrity": "sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
@@ -13988,7 +13905,6 @@
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=16 || 14 >=14.17"
@@ -14286,7 +14202,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"dependencies": {
"shebang-regex": "^3.0.0"
},
@@ -14298,7 +14213,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"engines": {
"node": ">=8"
}
@@ -14651,7 +14565,6 @@
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
@@ -14678,7 +14591,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
@@ -14983,22 +14895,6 @@
"node": ">=8.17.0"
}
},
"node_modules/tmp/node_modules/rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"license": "ISC",
"dependencies": {
"glob": "^7.1.3"
},
"bin": {
"rimraf": "bin.js"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/tmpl": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
@@ -15671,7 +15567,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"dependencies": {
"isexe": "^2.0.0"
},
@@ -15761,7 +15656,6 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
@@ -15779,7 +15673,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@@ -15795,7 +15688,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@@ -15808,7 +15700,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/wrap-ansi/node_modules/ansi-styles": {

View File

@@ -42,6 +42,9 @@
"reportFile": "test-reporter.xml",
"indent": 4
},
"overrides": {
"rimraf": "^6.0.1"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.378.0",
"@aws-sdk/client-ses": "^3.378.0",

View File

@@ -0,0 +1,29 @@
-- Reset all existing parent task estimations to 0
-- This script updates all tasks that have subtasks to have 0 estimation
UPDATE tasks
SET total_minutes = 0
WHERE id IN (
SELECT DISTINCT parent_task_id
FROM tasks
WHERE parent_task_id IS NOT NULL
AND archived = false
)
AND total_minutes > 0
AND archived = false;
-- Show the results
SELECT
t.id,
t.name,
t.total_minutes as current_estimation,
(SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id AND archived = false) as subtask_count
FROM tasks t
WHERE id IN (
SELECT DISTINCT parent_task_id
FROM tasks
WHERE parent_task_id IS NOT NULL
AND archived = false
)
AND archived = false
ORDER BY t.name;

View File

@@ -31,6 +31,7 @@ export default class AuthController extends WorklenzControllerBase {
// Flash messages sent from passport-local-signup.ts and passport-local-login.ts
const errors = req.flash()["error"] || [];
const messages = req.flash()["success"] || [];
// If there are multiple messages, we will send one at a time.
const auth_error = errors.length > 0 ? errors[0] : null;
const message = messages.length > 0 ? messages[0] : null;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,262 @@
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 } = req.body;
if (!project_id || !job_title_id || typeof rate !== "number") {
return res.status(400).send(new ServerResponse(false, null, "Invalid input"));
}
const q = `
INSERT INTO finance_project_rate_card_roles (project_id, job_title_id, rate)
VALUES ($1, $2, $3)
ON CONFLICT (project_id, job_title_id) DO UPDATE SET rate = EXCLUDED.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, [project_id, job_title_id, rate]);
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"));
}
const values = roles.map((role: any) => [
project_id,
role.job_title_id,
role.rate
]);
const q = `
INSERT INTO finance_project_rate_card_roles (project_id, job_title_id, rate)
VALUES ${values.map((_, i) => `($${i * 3 + 1}, $${i * 3 + 2}, $${i * 3 + 3})`).join(",")}
ON CONFLICT (project_id, job_title_id) DO UPDATE SET rate = EXCLUDED.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 } = req.body;
const q = `
WITH updated AS (
UPDATE finance_project_rate_card_roles
SET job_title_id = $1, rate = $2, updated_at = NOW()
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, [job_title_id, rate, id]);
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 values = roles.map((role: any) => [
project_id,
role.job_title_id,
role.rate
]);
const q = `
WITH upserted AS (
INSERT INTO finance_project_rate_card_roles (project_id, job_title_id, rate)
VALUES ${values.map((_, i) => `($${i * 3 + 1}, $${i * 3 + 2}, $${i * 3 + 3})`).join(",")}
ON CONFLICT (project_id, job_title_id)
DO UPDATE SET rate = EXCLUDED.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

@@ -395,6 +395,7 @@ export default class ProjectsController extends WorklenzControllerBase {
projects.folder_id,
projects.phase_label,
projects.category_id,
projects.currency,
(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,157 @@
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.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)
VALUES ($1, $2, $3);`,
[req.params.id, role.job_title_id, role.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

@@ -415,15 +415,20 @@ export default class ReportingController extends WorklenzControllerBase {
@HandleExceptions()
public static async getMyTeams(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const selectedTeamId = req.user?.team_id;
if (!selectedTeamId) {
return res.status(400).send(new ServerResponse(false, "No selected team"));
}
const q = `SELECT team_id AS id, name
FROM team_members tm
LEFT JOIN teams ON teams.id = tm.team_id
WHERE tm.user_id = $1
AND tm.team_id = $2
AND role_id IN (SELECT id
FROM roles
WHERE (admin_role IS TRUE OR owner IS TRUE))
ORDER BY name;`;
const result = await db.query(q, [req.user?.id]);
const result = await db.query(q, [req.user?.id, selectedTeamId]);
result.rows.forEach((team: any) => team.selected = true);
return res.status(200).send(new ServerResponse(true, result.rows));
}

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,223 @@ 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
};
// Count working days based on organization settings
let workingDays = 0;
let current = startDate.clone();
while (current.isSameOrBefore(endDate, 'day')) {
const day = current.isoWeekday();
if (day >= 1 && day <= 5) workingDays++;
if (
(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 working_hours 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]?.working_hours || 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, []);
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);
// Prepare members filter - updated logic to handle Clear All scenario
let membersFilter = "";
if (members.length > 0) {
const memberIds = members.map(id => `'${id}'`).join(",");
membersFilter = `AND tmiv.team_member_id IN (${memberIds})`;
} else {
// No members selected - show no data (Clear All scenario)
membersFilter = `AND 1=0`; // This will match no rows
}
return res.status(200).send(new ServerResponse(true, result.rows));
// 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
}
// 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'";
}
// Modified query to start from team members and calculate filtered time logs
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}
${projectsFilter}
${categoriesFilter}
${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 = totalWorkingHours;
if (isNonWorkingPeriod && loggedSeconds > 0) {
// Any time logged during non-working period should be treated as over-utilization
memberWorkingHours = Math.min(utilizedHours, 1); // Use actual time or 1 hour, whichever is smaller
}
const 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 - show no data (Clear All scenario)
filteredRows = [];
}
// Calculate totals
const total_time_logs = filteredRows.reduce((sum, member) => sum + parseFloat(member.logged_time || '0'), 0);
const total_estimated_hours = totalWorkingHours * filteredRows.length; // Total for all members
const 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 +800,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 +818,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 +858,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

@@ -31,6 +31,7 @@ export default class ReportingMembersController extends ReportingControllerBase
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)
@@ -100,7 +101,25 @@ export default class ReportingMembersController extends ReportingControllerBase
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
@@ -311,6 +330,30 @@ export default class ReportingMembersController extends ReportingControllerBase
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 = "";
@@ -423,6 +466,8 @@ export default class ReportingMembersController extends ReportingControllerBase
{ 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 }
@@ -430,14 +475,14 @@ export default class ReportingMembersController extends ReportingControllerBase
// 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 };
@@ -447,7 +492,7 @@ export default class ReportingMembersController extends ReportingControllerBase
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) {
@@ -458,6 +503,8 @@ export default class ReportingMembersController extends ReportingControllerBase
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

@@ -74,14 +74,9 @@ 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]);

View File

@@ -28,27 +28,45 @@ export default class TaskWorklogController extends WorklenzControllerBase {
if (!id) return [];
const q = `
WITH time_logs AS (
--
SELECT id,
description,
time_spent,
created_at,
user_id,
logged_by_timer,
(SELECT name FROM users WHERE users.id = task_work_log.user_id) AS user_name,
(SELECT email FROM users WHERE users.id = task_work_log.user_id) AS user_email,
(SELECT avatar_url FROM users WHERE users.id = task_work_log.user_id) AS avatar_url
FROM task_work_log
WHERE task_id = $1
--
WITH RECURSIVE task_hierarchy AS (
-- Base case: Start with the given task
SELECT id, name, 0 as level
FROM tasks
WHERE id = $1
UNION ALL
-- Recursive case: Get all subtasks
SELECT t.id, t.name, th.level + 1
FROM tasks t
INNER JOIN task_hierarchy th ON t.parent_task_id = th.id
WHERE t.archived IS FALSE
),
time_logs AS (
SELECT
twl.id,
twl.description,
twl.time_spent,
twl.created_at,
twl.user_id,
twl.logged_by_timer,
twl.task_id,
th.name AS task_name,
(SELECT name FROM users WHERE users.id = twl.user_id) AS user_name,
(SELECT email FROM users WHERE users.id = twl.user_id) AS user_email,
(SELECT avatar_url FROM users WHERE users.id = twl.user_id) AS avatar_url
FROM task_work_log twl
INNER JOIN task_hierarchy th ON twl.task_id = th.id
)
SELECT id,
SELECT
id,
time_spent,
description,
created_at,
user_id,
logged_by_timer,
task_id,
task_name,
created_at AS start_time,
(created_at + INTERVAL '1 second' * time_spent) AS end_time,
user_name,
@@ -143,6 +161,7 @@ export default class TaskWorklogController extends WorklenzControllerBase {
};
sheet.columns = [
{header: "Task Name", key: "task_name", width: 30},
{header: "Reporter Name", key: "user_name", width: 25},
{header: "Reporter Email", key: "user_email", width: 25},
{header: "Start Time", key: "start_time", width: 25},
@@ -153,14 +172,15 @@ export default class TaskWorklogController extends WorklenzControllerBase {
];
sheet.getCell("A1").value = metadata.project_name;
sheet.mergeCells("A1:G1");
sheet.mergeCells("A1:H1");
sheet.getCell("A1").alignment = {horizontal: "center"};
sheet.getCell("A2").value = `${metadata.name} (${exportDate})`;
sheet.mergeCells("A2:G2");
sheet.mergeCells("A2:H2");
sheet.getCell("A2").alignment = {horizontal: "center"};
sheet.getRow(4).values = [
"Task Name",
"Reporter Name",
"Reporter Email",
"Start Time",
@@ -176,6 +196,7 @@ export default class TaskWorklogController extends WorklenzControllerBase {
for (const item of results) {
totalLogged += parseFloat((item.time_spent || 0).toString());
const data = {
task_name: item.task_name,
user_name: item.user_name,
user_email: item.user_email,
start_time: moment(item.start_time).add(timezone.hours || 0, "hours").add(timezone.minutes || 0, "minutes").format(timeFormat),
@@ -210,6 +231,7 @@ export default class TaskWorklogController extends WorklenzControllerBase {
};
sheet.addRow({
task_name: "",
user_name: "",
user_email: "",
start_time: "Total",
@@ -219,7 +241,7 @@ export default class TaskWorklogController extends WorklenzControllerBase {
time_spent: formatDuration(moment.duration(totalLogged, "seconds")),
});
sheet.mergeCells(`A${sheet.rowCount}:F${sheet.rowCount}`);
sheet.mergeCells(`A${sheet.rowCount}:G${sheet.rowCount}`);
sheet.getCell(`A${sheet.rowCount}`).value = "Total";
sheet.getCell(`A${sheet.rowCount}`).alignment = {

View File

@@ -50,11 +50,16 @@ export default class TasksControllerBase extends WorklenzControllerBase {
task.progress = parseInt(task.progress_value);
task.complete_ratio = parseInt(task.progress_value);
}
// For tasks with no subtasks and no manual progress, calculate based on time
// For tasks with no subtasks and no manual progress
else {
task.progress = task.total_minutes_spent && task.total_minutes
? ~~(task.total_minutes_spent / task.total_minutes * 100)
: 0;
// Only calculate progress based on time if time-based progress is enabled for the project
if (task.project_use_time_progress && task.total_minutes_spent && task.total_minutes) {
// Cap the progress at 100% to prevent showing more than 100% progress
task.progress = Math.min(~~(task.total_minutes_spent / task.total_minutes * 100), 100);
} else {
// Default to 0% progress when time-based calculation is not enabled
task.progress = 0;
}
// Set complete_ratio to match progress
task.complete_ratio = task.progress;
@@ -76,7 +81,31 @@ export default class TasksControllerBase extends WorklenzControllerBase {
task.is_sub_task = !!task.parent_task_id;
task.time_spent_string = `${task.time_spent.hours}h ${(task.time_spent.minutes)}m`;
task.total_time_string = `${~~(task.total_minutes / 60)}h ${(task.total_minutes % 60)}m`;
// Use recursive estimation for parent tasks, own estimation for leaf tasks
const recursiveEstimation = task.recursive_estimation || {};
const hasSubtasks = (task.sub_tasks_count || 0) > 0;
let displayMinutes;
if (hasSubtasks) {
// For parent tasks, use recursive estimation (sum of all subtasks)
displayMinutes = recursiveEstimation.recursive_total_minutes || 0;
} else {
// For leaf tasks, use their own estimation
displayMinutes = task.total_minutes || 0;
}
// Format time string - show "0h" for zero time instead of "0h 0m"
const hours = ~~(displayMinutes / 60);
const minutes = displayMinutes % 60;
if (displayMinutes === 0) {
task.total_time_string = "0h";
} else if (minutes === 0) {
task.total_time_string = `${hours}h`;
} else {
task.total_time_string = `${hours}h ${minutes}m`;
}
task.name_color = getColor(task.name);
task.priority_color = PriorityColorCodes[task.priority_value] || PriorityColorCodes["0"];

View File

@@ -258,6 +258,7 @@ export default class TasksControllerV2 extends TasksControllerBase {
(SELECT id FROM task_priorities WHERE id = t.priority_id) AS priority,
(SELECT value FROM task_priorities WHERE id = t.priority_id) AS priority_value,
total_minutes,
(SELECT get_task_recursive_estimation(t.id)) AS recursive_estimation,
(SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id) AS total_minutes_spent,
created_at,
updated_at,

View File

@@ -427,9 +427,24 @@ export default class TasksController extends TasksControllerBase {
task.names = WorklenzControllerBase.createTagList(task.assignees);
const totalMinutes = task.total_minutes;
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
// Use recursive estimation if task has subtasks, otherwise use own estimation
const recursiveEstimation = task.recursive_estimation || {};
// Check both the recursive estimation count and the actual database count
const hasSubtasks = (task.sub_tasks_count || 0) > 0;
let totalMinutes, hours, minutes;
if (hasSubtasks) {
// For parent tasks, use the sum of all subtasks' estimation (excluding parent's own estimation)
totalMinutes = recursiveEstimation.recursive_total_minutes || 0;
hours = recursiveEstimation.recursive_total_hours || 0;
minutes = recursiveEstimation.recursive_remaining_minutes || 0;
} else {
// For tasks without subtasks, use their own estimation
totalMinutes = task.total_minutes || 0;
hours = Math.floor(totalMinutes / 60);
minutes = totalMinutes % 60;
}
task.total_hours = hours;
task.total_minutes = minutes;
@@ -608,6 +623,18 @@ export default class TasksController extends TasksControllerBase {
return res.status(200).send(new ServerResponse(true, null));
}
@HandleExceptions()
public static async resetParentTaskEstimations(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const q = `SELECT reset_all_parent_task_estimations() AS updated_count;`;
const result = await db.query(q);
const [data] = result.rows;
return res.status(200).send(new ServerResponse(true, {
message: `Reset estimation for ${data.updated_count} parent tasks`,
updated_count: data.updated_count
}));
}
@HandleExceptions()
public static async bulkAssignMembers(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { tasks, members, project_id } = req.body;

View File

@@ -8,8 +8,8 @@ const pgSession = require("connect-pg-simple")(session);
export default session({
name: process.env.SESSION_NAME || "worklenz.sid",
secret: process.env.SESSION_SECRET || "development-secret-key",
proxy: true,
resave: false,
proxy: false,
resave: true,
saveUninitialized: false,
rolling: true,
store: new pgSession({
@@ -18,9 +18,8 @@ export default session({
}),
cookie: {
path: "/",
secure: isProduction(), // Use secure cookies in production
httpOnly: true,
sameSite: "lax", // Standard setting for same-origin requests
secure: false,
maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days
}
});

View File

@@ -0,0 +1,15 @@
import {NextFunction} from "express";
import {IWorkLenzRequest} from "../../interfaces/worklenz-request";
import {IWorkLenzResponse} from "../../interfaces/worklenz-response";
import {ServerResponse} from "../../models/server-response";
export default function (req: IWorkLenzRequest, res: IWorkLenzResponse, next: NextFunction): IWorkLenzResponse | void {
const {name} = req.body;
if (!name || name.trim() === "")
return res.status(200).send(new ServerResponse(false, null, "Name is required"));
req.body.name = req.body.name.trim();
return next();
}

View File

@@ -30,6 +30,7 @@ export async function deserialize(user: { id: string | null }, done: IDeserializ
const excludedSubscriptionTypes = ["TRIAL", "PADDLE"];
const q = `SELECT deserialize_user($1) AS user;`;
const result = await db.query(q, [id]);
if (result.rows.length) {
const [data] = result.rows;
if (data?.user) {

View File

@@ -44,7 +44,6 @@ async function handleLogin(req: Request, email: string, password: string, done:
req.flash(ERROR_KEY, errorMsg);
return done(null, false);
} catch (error) {
console.error("Login error:", error);
log_error(error, req.body);
return done(error);
}

View File

@@ -47,41 +47,55 @@ async function handleSignUp(req: Request, email: string, password: string, done:
// team = Invited team_id if req.body.from_invitation is true
const {name, team_name, team_member_id, team_id, timezone} = req.body;
if (!team_name) return done(null, null, req.flash(ERROR_KEY, "Team name is required"));
if (!team_name) {
req.flash(ERROR_KEY, "Team name is required");
return done(null, null, {message: "Team name is required"});
}
const googleAccountFound = await isGoogleAccountFound(email);
if (googleAccountFound)
return done(null, null, req.flash(ERROR_KEY, `${req.body.email} is already linked with a Google account.`));
if (googleAccountFound) {
req.flash(ERROR_KEY, `${req.body.email} is already linked with a Google account.`);
return done(null, null, {message: `${req.body.email} is already linked with a Google account.`});
}
try {
const user = await registerUser(password, team_id, name, team_name, email, timezone, team_member_id);
sendWelcomeEmail(email, name);
return done(null, user, req.flash(SUCCESS_KEY, "Registration successful. Please check your email for verification."));
req.flash(SUCCESS_KEY, "Registration successful. Please check your email for verification.");
return done(null, user, {message: "Registration successful. Please check your email for verification."});
} catch (error: any) {
const message = (error?.message) || "";
if (message === "ERROR_INVALID_JOINING_EMAIL") {
return done(null, null, req.flash(ERROR_KEY, `No invitations found for email ${req.body.email}.`));
req.flash(ERROR_KEY, `No invitations found for email ${req.body.email}.`);
return done(null, null, {message: `No invitations found for email ${req.body.email}.`});
}
// if error.message is "email already exists" then it should have the email address in the error message after ":".
if (message.includes("EMAIL_EXISTS_ERROR") || error.constraint === "users_google_id_uindex") {
const [, value] = error.message.split(":");
return done(null, null, req.flash(ERROR_KEY, `Worklenz account already exists for email ${value}.`));
const errorMsg = `Worklenz account already exists for email ${value}.`;
req.flash(ERROR_KEY, errorMsg);
return done(null, null, {message: errorMsg});
}
if (message.includes("TEAM_NAME_EXISTS_ERROR")) {
const [, value] = error.message.split(":");
return done(null, null, req.flash(ERROR_KEY, `Team name "${value}" already exists. Please choose a different team name.`));
const errorMsg = `Team name "${value}" already exists. Please choose a different team name.`;
req.flash(ERROR_KEY, errorMsg);
return done(null, null, {message: errorMsg});
}
// The Team name is already taken.
if (error.constraint === "teams_url_uindex" || error.constraint === "teams_name_uindex") {
return done(null, null, req.flash(ERROR_KEY, `Team name "${team_name}" is already taken. Please choose a different team name.`));
const errorMsg = `Team name "${team_name}" is already taken. Please choose a different team name.`;
req.flash(ERROR_KEY, errorMsg);
return done(null, null, {message: errorMsg});
}
log_error(error, req.body);
return done(null, null, req.flash(ERROR_KEY, DEFAULT_ERROR_MESSAGE));
req.flash(ERROR_KEY, DEFAULT_ERROR_MESSAGE);
return done(null, null, {message: DEFAULT_ERROR_MESSAGE});
}
}

View File

@@ -56,7 +56,11 @@ import billingApiRouter from "./billing-api-router";
import taskDependenciesApiRouter from "./task-dependencies-api-router";
import taskRecurringApiRouter from "./task-recurring-api-router";
import customColumnsApiRouter from "./custom-columns-api-router";
import ratecardApiRouter from "./ratecard-api-router";
import projectRatecardApiRouter from "./project-ratecard-api-router";
import projectFinanceApiRouter from "./project-finance-api-router";
const api = express.Router();
@@ -64,6 +68,8 @@ api.use("/projects", projectsApiRouter);
api.use("/team-members", teamMembersApiRouter);
api.use("/job-titles", jobTitlesApiRouter);
api.use("/clients", clientsApiRouter);
api.use("/rate-cards", ratecardApiRouter);
api.use("/project-rate-cards", projectRatecardApiRouter);
api.use("/teams", teamsApiRouter);
api.use("/tasks", tasksApiRouter);
api.use("/settings", settingsApiRouter);
@@ -117,4 +123,6 @@ api.use("/task-recurring", taskRecurringApiRouter);
api.use("/custom-columns", customColumnsApiRouter);
api.use("/project-finance", projectFinanceApiRouter);
export default api;

View File

@@ -0,0 +1,20 @@
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", ProjectfinanceController.getTasks);
projectFinanceApiRouter.get("/project/:project_id/tasks/:parent_task_id/subtasks", ProjectfinanceController.getSubTasks);
projectFinanceApiRouter.get(
"/task/:id/breakdown",
idParamValidator,
safeControllerFunction(ProjectfinanceController.getTaskBreakdown)
);
projectFinanceApiRouter.put("/task/:task_id/fixed-cost", ProjectfinanceController.updateTaskFixedCost);
projectFinanceApiRouter.put("/project/:project_id/currency", ProjectfinanceController.updateProjectCurrency);
projectFinanceApiRouter.get("/project/:project_id/export", ProjectfinanceController.exportFinanceData);
export default projectFinanceApiRouter;

View File

@@ -0,0 +1,69 @@
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();
// Insert multiple roles for a project
projectRatecardApiRouter.post(
"/",
projectManagerValidator,
safeControllerFunction(ProjectRateCardController.createMany)
);
// Insert a single role for a project
projectRatecardApiRouter.post(
"/create-project-rate-card-role",
projectManagerValidator,
safeControllerFunction(ProjectRateCardController.createOne)
);
// Get all roles for a project
projectRatecardApiRouter.get(
"/project/:project_id",
safeControllerFunction(ProjectRateCardController.getByProjectId)
);
// Get a single role by id
projectRatecardApiRouter.get(
"/:id",
idParamValidator,
safeControllerFunction(ProjectRateCardController.getById)
);
// Update a single role by id
projectRatecardApiRouter.put(
"/:id",
idParamValidator,
safeControllerFunction(ProjectRateCardController.updateById)
);
// Update all roles for a project (delete then insert)
projectRatecardApiRouter.put(
"/project/:project_id",
safeControllerFunction(ProjectRateCardController.updateByProjectId)
);
// Update project member rate card role
projectRatecardApiRouter.put(
"/project/:project_id/members/:id/rate-card-role",
idParamValidator,
projectManagerValidator,
safeControllerFunction(ProjectRateCardController.updateProjectMemberByProjectIdAndMemberId)
);
// Delete a single role by id
projectRatecardApiRouter.delete(
"/:id",
idParamValidator,
safeControllerFunction(ProjectRateCardController.deleteById)
);
// Delete all roles for a project
projectRatecardApiRouter.delete(
"/project/:project_id",
safeControllerFunction(ProjectRateCardController.deleteByProjectId)
);
export default projectRatecardApiRouter;

View File

@@ -0,0 +1,48 @@
import express from "express";
import RateCardController from "../../controllers/ratecard-controller";
import idParamValidator from "../../middlewares/validators/id-param-validator";
import teamOwnerOrAdminValidator from "../../middlewares/validators/team-owner-or-admin-validator";
import safeControllerFunction from "../../shared/safe-controller-function";
import projectManagerValidator from "../../middlewares/validators/project-manager-validator";
import ratecardBodyValidator from "../../middlewares/validators/ratecard-body-validator";
const ratecardApiRouter = express.Router();
ratecardApiRouter.post(
"/",
projectManagerValidator,
ratecardBodyValidator,
safeControllerFunction(RateCardController.create)
);
ratecardApiRouter.get(
"/",
safeControllerFunction(RateCardController.get)
);
ratecardApiRouter.get(
"/:id",
teamOwnerOrAdminValidator,
idParamValidator,
safeControllerFunction(RateCardController.getById)
);
ratecardApiRouter.put(
"/:id",
teamOwnerOrAdminValidator,
ratecardBodyValidator,
idParamValidator,
safeControllerFunction(RateCardController.update)
);
ratecardApiRouter.delete(
"/:id",
teamOwnerOrAdminValidator,
idParamValidator,
safeControllerFunction(RateCardController.deleteById)
);
export default ratecardApiRouter;

View File

@@ -69,4 +69,7 @@ tasksApiRouter.put("/labels/:id", idParamValidator, safeControllerFunction(Tasks
// Add custom column value update route
tasksApiRouter.put("/:taskId/custom-column", TasksControllerV2.updateCustomColumnValue);
// Add route to reset parent task estimations
tasksApiRouter.post("/reset-parent-estimations", safeControllerFunction(TasksController.resetParentTaskEstimations));
export default tasksApiRouter;

View File

@@ -166,7 +166,9 @@ export const UNMAPPED = "Unmapped";
export const DATE_RANGES = {
YESTERDAY: "YESTERDAY",
LAST_7_DAYS: "LAST_7_DAYS",
LAST_WEEK: "LAST_WEEK",
LAST_30_DAYS: "LAST_30_DAYS",
LAST_MONTH: "LAST_MONTH",
LAST_QUARTER: "LAST_QUARTER",
ALL_TIME: "ALL_TIME"

View File

@@ -1,11 +1,11 @@
import {Server, Socket} from "socket.io";
import { Server, Socket } from "socket.io";
import db from "../../config/db";
import {getColor, toMinutes} from "../../shared/utils";
import {SocketEvents} from "../events";
import { getColor, toMinutes } from "../../shared/utils";
import { SocketEvents } from "../events";
import {log_error, notifyProjectUpdates} from "../util";
import { log_error, notifyProjectUpdates } from "../util";
import TasksControllerV2 from "../../controllers/tasks-controller-v2";
import {TASK_STATUS_COLOR_ALPHA, UNMAPPED} from "../../shared/constants";
import { TASK_STATUS_COLOR_ALPHA, UNMAPPED } from "../../shared/constants";
import moment from "moment";
import momentTime from "moment-timezone";
import { logEndDateChange, logStartDateChange, logStatusChange } from "../../services/activity-logs/activity-logs.service";
@@ -18,8 +18,9 @@ export async function getTaskCompleteInfo(task: any) {
const [d2] = result2.rows;
task.completed_count = d2.res.total_completed || 0;
if (task.sub_tasks_count > 0)
if (task.sub_tasks_count > 0 && d2.res.total_tasks > 0) {
task.sub_tasks_count = d2.res.total_tasks;
}
return task;
}

View File

@@ -10,6 +10,8 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap"
rel="stylesheet" />
<!-- SVAR Gantt Icons -->
<link rel="stylesheet" href="https://cdn.svar.dev/fonts/wxi/wx-icons.css" />
<title>Worklenz</title>
<!-- Environment configuration -->

View File

@@ -1,4 +1,4 @@
module.exports = {
export default {
setupFilesAfterEnv: ['<rootDir>/src/setupTests.ts'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@
"name": "worklenz",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "vite",
"prebuild": "node scripts/copy-tinymce.js",
@@ -16,8 +17,8 @@
"@ant-design/icons": "^5.4.0",
"@ant-design/pro-components": "^2.7.19",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/modifiers": "^6.0.1",
"@dnd-kit/sortable": "^7.0.2",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.14.0",
"@paddle/paddle-js": "^1.3.3",
@@ -52,7 +53,8 @@
"react-window": "^1.8.11",
"socket.io-client": "^4.8.1",
"tinymce": "^7.7.2",
"web-vitals": "^4.2.4"
"web-vitals": "^4.2.4",
"wx-react-gantt": "^1.3.1"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.6.3",
@@ -77,6 +79,12 @@
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.0.5"
},
"resolutions": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/utilities": "^3.2.2",
"@dnd-kit/sortable": "^7.0.2",
"@dnd-kit/modifiers": "^6.0.1"
},
"eslintConfig": {
"extends": [
"react-app",

View File

@@ -1,4 +1,4 @@
module.exports = {
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},

View File

@@ -0,0 +1,163 @@
[
{
"id": "c2669c5f-a019-445b-b703-b941bbefdab7",
"type": "low",
"name": "Low",
"color_code": "#c2e4d0",
"color_code_dark": "#46d980",
"tasks": [
{
"id": "4be5ef5c-1234-4247-b159-6d8df2b37d04",
"task": "Testing and QA",
"isBillable": false,
"hours": 180,
"cost": 18000,
"fixedCost": 2500,
"totalBudget": 20000,
"totalActual": 21000,
"variance": -1000,
"members": [
{
"memberId": "6",
"name": "Eve Adams",
"jobId": "J006",
"jobRole": "QA Engineer",
"hourlyRate": 100
}
]
},
{
"id": "6be5ef5c-1234-4247-b159-6d8df2b37d06",
"task": "Project Documentation",
"isBillable": false,
"hours": 100,
"cost": 10000,
"fixedCost": 1000,
"totalBudget": 12000,
"totalActual": 12500,
"variance": -500,
"members": [
{
"memberId": "8",
"name": "Grace Lee",
"jobId": "J008",
"jobRole": "Technical Writer",
"hourlyRate": 100
}
]
}
]
},
{
"id": "d3f9c5f1-b019-445b-b703-b941bbefdab8",
"type": "medium",
"name": "Medium",
"color_code": "#f9e3b1",
"color_code_dark": "#ffc227",
"tasks": [
{
"id": "1be5ef5c-1234-4247-b159-6d8df2b37d01",
"task": "UI Design",
"isBillable": true,
"hours": 120,
"cost": 12000,
"fixedCost": 1500,
"totalBudget": 14000,
"totalActual": 13500,
"variance": 500,
"members": [
{
"memberId": "1",
"name": "John Doe",
"jobId": "J001",
"jobRole": "UI/UX Designer",
"hourlyRate": 100
},
{
"memberId": "2",
"name": "Jane Smith",
"jobId": "J002",
"jobRole": "Frontend Developer",
"hourlyRate": 120
}
]
},
{
"id": "2be5ef5c-1234-4247-b159-6d8df2b37d02",
"task": "API Integration",
"isBillable": true,
"hours": 200,
"cost": 20000,
"fixedCost": 3000,
"totalBudget": 25000,
"totalActual": 26000,
"variance": -1000,
"members": [
{
"memberId": "3",
"name": "Alice Johnson",
"jobId": "J003",
"jobRole": "Backend Developer",
"hourlyRate": 100
}
]
}
]
},
{
"id": "e3f9c5f1-b019-445b-b703-b941bbefdab9",
"type": "high",
"name": "High",
"color_code": "#f6bfc0",
"color_code_dark": "#ff4141",
"tasks": [
{
"id": "5be5ef5c-1234-4247-b159-6d8df2b37d05",
"task": "Database Migration",
"isBillable": true,
"hours": 250,
"cost": 37500,
"fixedCost": 4000,
"totalBudget": 42000,
"totalActual": 41000,
"variance": 1000,
"members": [
{
"memberId": "7",
"name": "Frank Harris",
"jobId": "J007",
"jobRole": "Database Administrator",
"hourlyRate": 150
}
]
},
{
"id": "3be5ef5c-1234-4247-b159-6d8df2b37d03",
"task": "Performance Optimization",
"isBillable": true,
"hours": 300,
"cost": 45000,
"fixedCost": 5000,
"totalBudget": 50000,
"totalActual": 47000,
"variance": 3000,
"members": [
{
"memberId": "4",
"name": "Bob Brown",
"jobId": "J004",
"jobRole": "Performance Engineer",
"hourlyRate": 150
},
{
"memberId": "5",
"name": "Charlie Davis",
"jobId": "J005",
"jobRole": "Full Stack Developer",
"hourlyRate": 130
}
]
}
]
}
]

View File

@@ -0,0 +1,163 @@
[
{
"id": "c2669c5f-a019-445b-b703-b941bbefdab7",
"type": "todo",
"name": "To Do",
"color_code": "#d8d7d8",
"color_code_dark": "#989898",
"tasks": [
{
"id": "1be5ef5c-1234-4247-b159-6d8df2b37d01",
"task": "UI Design",
"isBillable": true,
"hours": 120,
"cost": 12000,
"fixedCost": 1500,
"totalBudget": 14000,
"totalActual": 13500,
"variance": 500,
"members": [
{
"memberId": "1",
"name": "John Doe",
"jobId": "J001",
"jobRole": "UI/UX Designer",
"hourlyRate": 100
},
{
"memberId": "2",
"name": "Jane Smith",
"jobId": "J002",
"jobRole": "Frontend Developer",
"hourlyRate": 120
}
]
},
{
"id": "2be5ef5c-1234-4247-b159-6d8df2b37d02",
"task": "API Integration",
"isBillable": true,
"hours": 200,
"cost": 20000,
"fixedCost": 3000,
"totalBudget": 25000,
"totalActual": 26000,
"variance": -1000,
"members": [
{
"memberId": "3",
"name": "Alice Johnson",
"jobId": "J003",
"jobRole": "Backend Developer",
"hourlyRate": 100
}
]
}
]
},
{
"id": "d3f9c5f1-b019-445b-b703-b941bbefdab8",
"type": "doing",
"name": "In Progress",
"color_code": "#c0d5f6",
"color_code_dark": "#4190ff",
"tasks": [
{
"id": "3be5ef5c-1234-4247-b159-6d8df2b37d03",
"task": "Performance Optimization",
"isBillable": true,
"hours": 300,
"cost": 45000,
"fixedCost": 5000,
"totalBudget": 50000,
"totalActual": 47000,
"variance": 3000,
"members": [
{
"memberId": "4",
"name": "Bob Brown",
"jobId": "J004",
"jobRole": "Performance Engineer",
"hourlyRate": 150
},
{
"memberId": "5",
"name": "Charlie Davis",
"jobId": "J005",
"jobRole": "Full Stack Developer",
"hourlyRate": 130
}
]
},
{
"id": "4be5ef5c-1234-4247-b159-6d8df2b37d04",
"task": "Testing and QA",
"isBillable": false,
"hours": 180,
"cost": 18000,
"fixedCost": 2500,
"totalBudget": 20000,
"totalActual": 21000,
"variance": -1000,
"members": [
{
"memberId": "6",
"name": "Eve Adams",
"jobId": "J006",
"jobRole": "QA Engineer",
"hourlyRate": 100
}
]
}
]
},
{
"id": "e3f9c5f1-b019-445b-b703-b941bbefdab9",
"type": "done",
"name": "Done",
"color_code": "#c2e4d0",
"color_code_dark": "#46d980",
"tasks": [
{
"id": "5be5ef5c-1234-4247-b159-6d8df2b37d05",
"task": "Database Migration",
"isBillable": true,
"hours": 250,
"cost": 37500,
"fixedCost": 4000,
"totalBudget": 42000,
"totalActual": 41000,
"variance": 1000,
"members": [
{
"memberId": "7",
"name": "Frank Harris",
"jobId": "J007",
"jobRole": "Database Administrator",
"hourlyRate": 150
}
]
},
{
"id": "6be5ef5c-1234-4247-b159-6d8df2b37d06",
"task": "Project Documentation",
"isBillable": false,
"hours": 100,
"cost": 10000,
"fixedCost": 1000,
"totalBudget": 12000,
"totalActual": 12500,
"variance": -500,
"members": [
{
"memberId": "8",
"name": "Grace Lee",
"jobId": "J008",
"jobRole": "Technical Writer",
"hourlyRate": 100
}
]
}
]
}
]

View File

@@ -0,0 +1,51 @@
[
{
"ratecardId": "RC001",
"ratecardName": "Rate Card 1",
"jobRolesList": [
{
"jobId": "J001",
"jobTitle": "Project Manager",
"ratePerHour": 100
},
{
"jobId": "J002",
"jobTitle": "Senior Software Engineer",
"ratePerHour": 120
},
{
"jobId": "J003",
"jobTitle": "Junior Software Engineer",
"ratePerHour": 80
},
{
"jobId": "J004",
"jobTitle": "UI/UX Designer",
"ratePerHour": 50
}
],
"createdDate": "2024-12-01T00:00:00.000Z"
},
{
"ratecardId": "RC002",
"ratecardName": "Rate Card 2",
"jobRolesList": [
{
"jobId": "J001",
"jobTitle": "Project Manager",
"ratePerHour": 80
},
{
"jobId": "J002",
"jobTitle": "Senior Software Engineer",
"ratePerHour": 100
},
{
"jobId": "J003",
"jobTitle": "Junior Software Engineer",
"ratePerHour": 60
}
],
"createdDate": "2024-12-15T00:00:00.000Z"
}
]

View File

@@ -4,5 +4,19 @@
"owner": "Organization Owner",
"admins": "Organization Admins",
"contactNumber": "Add Contact Number",
"edit": "Edit"
"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": "Saved successfully!",
"errorSaving": "Error saving settings."
}

View File

@@ -0,0 +1,46 @@
{
"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",
"taskColumn": "Task",
"membersColumn": "Members",
"hoursColumn": "Estimated Hours",
"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",
"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."
}

View File

@@ -31,5 +31,10 @@
"todoText": "To Do",
"doingText": "Doing",
"doneText": "Done"
"doneText": "Done",
"timeLogsColumn": "Time Logs",
"timeLogsColumnTooltip": "Shows the proportion of billable vs non-billable time",
"billable": "Billable",
"nonBillable": "Non-Billable"
}

View File

@@ -0,0 +1,50 @@
{
"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",
"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"
}

View File

@@ -23,6 +23,7 @@
"show-start-date": "Show Start Date",
"hours": "Hours",
"minutes": "Minutes",
"time-estimation-disabled-tooltip": "Time estimation is disabled because this task has {{count}} subtasks. The estimation shown is the sum of all subtasks.",
"progressValue": "Progress Value",
"progressValueTooltip": "Set the progress percentage (0-100%)",
"progressValueRequired": "Please enter a progress value",
@@ -79,7 +80,21 @@
"addTimeLog": "Add new time log",
"totalLogged": "Total Logged",
"exportToExcel": "Export to Excel",
"noTimeLogsFound": "No time logs found"
"noTimeLogsFound": "No time logs found",
"timerDisabledTooltip": "Timer is disabled because this task has {{count}} subtasks. Time should be logged on individual subtasks.",
"timeLogDisabledTooltip": "Time logging is disabled because this task has {{count}} subtasks. Time should be logged on individual subtasks.",
"date": "Date",
"startTime": "Start Time",
"endTime": "End Time",
"workDescription": "Work Description",
"requiredFields": "Please fill in all required fields",
"dateRequired": "Please select a date",
"startTimeRequired": "Please select start time",
"endTimeRequired": "Please select end time",
"workDescriptionPlaceholder": "Add a description",
"cancel": "Cancel",
"logTime": "Log time",
"updateTime": "Update time"
},
"taskActivityLogTab": {
"title": "Activity Log"

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",
"filterByBillableStatus": "Filter by Billable Status",
"allBillableTypes": "All Billable Types",
"total": "Total",
@@ -40,5 +43,25 @@
"noCategory": "No Category",
"noProjects": "No projects found",
"noTeams": "No teams found",
"noData": "No data found"
"noData": "No data found",
"members": "Members",
"searchByMember": "Search by member",
"utilization": "Utilization",
"totalTimeLogged": "Total Time Logged",
"expectedCapacity": "Expected Capacity",
"teamUtilization": "Team Utilization",
"variance": "Variance",
"acrossAllTeamMembers": "Across all team members",
"basedOnWorkingSchedule": "Based on working schedule",
"optimal": "Optimal",
"underUtilized": "Under-utilized",
"overUtilized": "Over-utilized",
"overCapacity": "Over capacity",
"underCapacity": "Under capacity",
"considerWorkloadRedistribution": "Consider workload redistribution",
"capacityAvailableForNewProjects": "Capacity available for new projects",
"targetRange": "Target: 90-110%",
"overtimeWork": "Overtime Work",
"reviewWorkLifeBalance": "Review work-life balance policies"
}

View File

@@ -4,5 +4,19 @@
"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": "¡Guardado exitosamente!",
"errorSaving": "Error al guardar la configuración."
}

View File

@@ -0,0 +1,45 @@
{
"financeText": "Finanzas",
"ratecardSingularText": "Tarifa",
"groupByText": "Agrupar por",
"statusText": "Estado",
"phaseText": "Fase",
"priorityText": "Prioridad",
"exportButton": "Exportar",
"currencyText": "Moneda",
"importButton": "Importar",
"filterText": "Filtro",
"billableOnlyText": "Solo Facturable",
"nonBillableOnlyText": "Solo No Facturable",
"allTasksText": "Todas las Tareas",
"taskColumn": "Tarea",
"membersColumn": "Miembros",
"hoursColumn": "Horas Estimadas",
"totalTimeLoggedColumn": "Tiempo Total Registrado",
"costColumn": "Costo Real",
"estimatedCostColumn": "Costo Estimado",
"fixedCostColumn": "Costo Fijo",
"totalBudgetedCostColumn": "Costo Total Presupuestado",
"totalActualCostColumn": "Costo Real Total",
"varianceColumn": "Varianza",
"totalText": "Total",
"noTasksFound": "No se encontraron tareas",
"addRoleButton": "+ Agregar Rol",
"ratecardImportantNotice": "* Esta tarifa se genera en base a los títulos de trabajo y tarifas estándar de la empresa. Sin embargo, tienes la flexibilidad de modificarla según el proyecto. Estos cambios no afectarán los títulos de trabajo y tarifas estándar de la organización.",
"saveButton": "Guardar",
"jobTitleColumn": "Título del Trabajo",
"ratePerHourColumn": "Tarifa por hora",
"ratecardPluralText": "Tarifas",
"labourHoursColumn": "Horas de Trabajo",
"actions": "Acciones",
"selectJobTitle": "Seleccionar Título del Trabajo",
"ratecardsPluralText": "Plantillas de Tarifas",
"deleteConfirm": "¿Estás seguro?",
"yes": "Sí",
"no": "No",
"alreadyImportedRateCardMessage": "Ya se ha importado una tarifa. Borra todas las tarifas importadas para agregar una nueva."
}

View File

@@ -31,5 +31,10 @@
"todoText": "Por Hacer",
"doingText": "Haciendo",
"doneText": "Hecho"
"doneText": "Hecho",
"timeLogsColumn": "Registros de Tiempo",
"timeLogsColumnTooltip": "Muestra la proporción de tiempo facturable vs no facturable",
"billable": "Facturable",
"nonBillable": "No Facturable"
}

View File

@@ -0,0 +1,50 @@
{
"nameColumn": "Nombre",
"createdColumn": "Creado",
"noProjectsAvailable": "No hay proyectos disponibles",
"deleteConfirmationTitle": "¿Está seguro de que desea eliminar esta tarjeta de tarifas?",
"deleteConfirmationOk": "Sí, eliminar",
"deleteConfirmationCancel": "Cancelar",
"searchPlaceholder": "Buscar tarjetas de tarifas por nombre",
"createRatecard": "Crear Tarjeta de Tarifas",
"editTooltip": "Editar tarjeta de tarifas",
"deleteTooltip": "Eliminar tarjeta de tarifas",
"fetchError": "Error al cargar las tarjetas de tarifas",
"createError": "Error al crear la tarjeta de tarifas",
"deleteSuccess": "Tarjeta de tarifas eliminada con éxito",
"deleteError": "Error al eliminar la tarjeta de tarifas",
"jobTitleColumn": "Título del trabajo",
"ratePerHourColumn": "Tarifa por hora",
"saveButton": "Guardar",
"addRoleButton": "Añadir Rol",
"createRatecardSuccessMessage": "Tarjeta de tarifas creada con éxito",
"createRatecardErrorMessage": "Error al crear la tarjeta de tarifas",
"updateRatecardSuccessMessage": "Tarjeta de tarifas actualizada con éxito",
"updateRatecardErrorMessage": "Error al actualizar la tarjeta de tarifas",
"currency": "Moneda",
"actionsColumn": "Acciones",
"addAllButton": "Añadir 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 tarjeta de tarifas es obligatorio",
"ratecardNamePlaceholder": "Ingrese el nombre de la tarjeta de tarifas",
"noRatecardsFound": "No se encontraron tarjetas de tarifas",
"loadingRateCards": "Cargando tarjetas de tarifas...",
"noJobTitlesAvailable": "No hay títulos de trabajo disponibles",
"noRolesAdded": "Aún no se han añadido roles",
"createFirstJobTitle": "Crear Primer Título de Trabajo",
"jobRolesTitle": "Roles de Trabajo",
"noJobTitlesMessage": "Por favor, cree títulos de trabajo primero en la configuración de Títulos de Trabajo antes de añadir roles a las tarjetas de tarifas.",
"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": "Error al crear el título de trabajo",
"createButton": "Crear",
"cancelButton": "Cancelar"
}

View File

@@ -23,6 +23,7 @@
"show-start-date": "Mostrar fecha de inicio",
"hours": "Horas",
"minutes": "Minutos",
"time-estimation-disabled-tooltip": "La estimación de tiempo está deshabilitada porque esta tarea tiene {{count}} subtareas. La estimación mostrada es la suma de todas las subtareas.",
"progressValue": "Valor de Progreso",
"progressValueTooltip": "Establecer el porcentaje de progreso (0-100%)",
"progressValueRequired": "Por favor, introduce un valor de progreso",
@@ -79,7 +80,21 @@
"addTimeLog": "Añadir nuevo registro de tiempo",
"totalLogged": "Total registrado",
"exportToExcel": "Exportar a Excel",
"noTimeLogsFound": "No se encontraron registros de tiempo"
"noTimeLogsFound": "No se encontraron registros de tiempo",
"timerDisabledTooltip": "El temporizador está deshabilitado porque esta tarea tiene {{count}} subtareas. El tiempo debe registrarse en las subtareas individuales.",
"timeLogDisabledTooltip": "El registro de tiempo está deshabilitado porque esta tarea tiene {{count}} subtareas. El tiempo debe registrarse en las subtareas individuales.",
"date": "Fecha",
"startTime": "Hora de inicio",
"endTime": "Hora de finalización",
"workDescription": "Descripción del trabajo",
"requiredFields": "Por favor, complete todos los campos requeridos",
"dateRequired": "Por favor, seleccione una fecha",
"startTimeRequired": "Por favor, seleccione la hora de inicio",
"endTimeRequired": "Por favor, seleccione la hora de finalización",
"workDescriptionPlaceholder": "Añadir una descripción",
"cancel": "Cancelar",
"logTime": "Registrar tiempo",
"updateTime": "Actualizar tiempo"
},
"taskActivityLogTab": {
"title": "Registro de actividad"

View File

@@ -5,6 +5,7 @@
"searchByName": "Buscar por nombre",
"selectAll": "Seleccionar Todo",
"clearAll": "Limpiar Todo",
"teams": "Equipos",
"searchByProject": "Buscar por nombre de proyecto",
@@ -15,6 +16,8 @@
"billable": "Facturable",
"nonBillable": "No Facturable",
"filterByBillableStatus": "Filtrar por Estado Facturable",
"allBillableTypes": "Todos los Tipos Facturables",
"total": "Total",
@@ -40,5 +43,25 @@
"noCategory": "No Categoría",
"noProjects": "No se encontraron proyectos",
"noTeams": "No se encontraron equipos",
"noData": "No se encontraron datos"
"noData": "No se encontraron datos",
"members": "Miembros",
"searchByMember": "Buscar por miembro",
"utilization": "Utilización",
"totalTimeLogged": "Tiempo Total Registrado",
"expectedCapacity": "Capacidad Esperada",
"teamUtilization": "Utilización del Equipo",
"variance": "Varianza",
"acrossAllTeamMembers": "En todos los miembros del equipo",
"basedOnWorkingSchedule": "Basado en horario de trabajo",
"optimal": "Óptimo",
"underUtilized": "Sub-utilizado",
"overUtilized": "Sobre-utilizado",
"overCapacity": "Sobre capacidad",
"underCapacity": "Bajo capacidad",
"considerWorkloadRedistribution": "Considerar redistribución de carga de trabajo",
"capacityAvailableForNewProjects": "Capacidad disponible para nuevos proyectos",
"targetRange": "Objetivo: 90-110%",
"overtimeWork": "Trabajo de Horas Extras",
"reviewWorkLifeBalance": "Revisar políticas de equilibrio trabajo-vida"
}

View File

@@ -4,5 +4,19 @@
"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": "Salvo com sucesso!",
"errorSaving": "Erro ao salvar as configurações."
}

View File

@@ -0,0 +1,45 @@
{
"financeText": "Finanças",
"ratecardSingularText": "Cartão de Taxa",
"groupByText": "Agrupar por",
"statusText": "Status",
"phaseText": "Fase",
"priorityText": "Prioridade",
"exportButton": "Exportar",
"currencyText": "Moeda",
"importButton": "Importar",
"filterText": "Filtro",
"billableOnlyText": "Apenas Faturável",
"nonBillableOnlyText": "Apenas Não Faturável",
"allTasksText": "Todas as Tarefas",
"taskColumn": "Tarefa",
"membersColumn": "Membros",
"hoursColumn": "Horas Estimadas",
"totalTimeLoggedColumn": "Tempo Total Registrado",
"costColumn": "Custo Real",
"estimatedCostColumn": "Custo Estimado",
"fixedCostColumn": "Custo Fixo",
"totalBudgetedCostColumn": "Custo Total Orçado",
"totalActualCostColumn": "Custo Real Total",
"varianceColumn": "Variância",
"totalText": "Total",
"noTasksFound": "Nenhuma tarefa encontrada",
"addRoleButton": "+ Adicionar Função",
"ratecardImportantNotice": "* Este cartão de taxa é gerado com base nos títulos de trabalho e taxas padrão da empresa. No entanto, você tem a flexibilidade de modificá-lo de acordo com o projeto. Essas alterações não afetarão os títulos de trabalho e taxas padrão da organização.",
"saveButton": "Salvar",
"jobTitleColumn": "Título do Trabalho",
"ratePerHourColumn": "Taxa por hora",
"ratecardPluralText": "Cartões de Taxa",
"labourHoursColumn": "Horas de Trabalho",
"actions": "Ações",
"selectJobTitle": "Selecionar Título do Trabalho",
"ratecardsPluralText": "Modelos de Cartão de Taxa",
"deleteConfirm": "Tem certeza?",
"yes": "Sim",
"no": "Não",
"alreadyImportedRateCardMessage": "Um cartão de taxa já foi importado. Limpe todos os cartões de taxa importados para adicionar um novo."
}

View File

@@ -31,5 +31,10 @@
"todoText": "To Do",
"doingText": "Doing",
"doneText": "Done"
"doneText": "Done",
"timeLogsColumn": "Registros de Tempo",
"timeLogsColumnTooltip": "Mostra a proporção de tempo faturável vs não faturável",
"billable": "Faturável",
"nonBillable": "Não Faturável"
}

View File

@@ -0,0 +1,50 @@
{
"nameColumn": "Nome",
"createdColumn": "Criado",
"noProjectsAvailable": "Nenhum projeto disponível",
"deleteConfirmationTitle": "Tem certeza que deseja excluir esta tabela de preços?",
"deleteConfirmationOk": "Sim, excluir",
"deleteConfirmationCancel": "Cancelar",
"searchPlaceholder": "Pesquisar tabelas de preços por nome",
"createRatecard": "Criar Tabela de Preços",
"editTooltip": "Editar tabela de preços",
"deleteTooltip": "Excluir tabela de preços",
"fetchError": "Falha ao carregar tabelas de preços",
"createError": "Falha ao criar tabela de preços",
"deleteSuccess": "Tabela de preços excluída com sucesso",
"deleteError": "Falha ao excluir tabela de preços",
"jobTitleColumn": "Cargo",
"ratePerHourColumn": "Taxa por hora",
"saveButton": "Salvar",
"addRoleButton": "Adicionar Cargo",
"createRatecardSuccessMessage": "Tabela de preços criada com sucesso",
"createRatecardErrorMessage": "Falha ao criar tabela de preços",
"updateRatecardSuccessMessage": "Tabela de preços atualizada com sucesso",
"updateRatecardErrorMessage": "Falha ao atualizar tabela de preços",
"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 suas alterações antes de sair?",
"unsavedChangesSave": "Salvar",
"unsavedChangesDiscard": "Descartar",
"ratecardNameRequired": "O nome da tabela de preços é obrigatório",
"ratecardNamePlaceholder": "Digite o nome da tabela de preços",
"noRatecardsFound": "Nenhuma tabela de preços encontrada",
"loadingRateCards": "Carregando tabelas de preços...",
"noJobTitlesAvailable": "Nenhum cargo disponível",
"noRolesAdded": "Nenhum cargo adicionado ainda",
"createFirstJobTitle": "Criar Primeiro Cargo",
"jobRolesTitle": "Cargos",
"noJobTitlesMessage": "Por favor, crie cargos primeiro nas configurações de Cargos antes de adicionar funções às tabelas de preços.",
"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"
}

View File

@@ -23,7 +23,8 @@
"show-start-date": "Mostrar data de início",
"hours": "Horas",
"minutes": "Minutos",
"progressValue": "Valor de Progresso",
"time-estimation-disabled-tooltip": "A estimativa de tempo está desabilitada porque esta tarefa tem {{count}} subtarefas. A estimativa mostrada é a soma de todas as subtarefas.",
"progressValue": "Valor do Progresso",
"progressValueTooltip": "Definir a porcentagem de progresso (0-100%)",
"progressValueRequired": "Por favor, insira um valor de progresso",
"progressValueRange": "O progresso deve estar entre 0 e 100",
@@ -79,7 +80,21 @@
"addTimeLog": "Adicionar novo registro de tempo",
"totalLogged": "Total registrado",
"exportToExcel": "Exportar para Excel",
"noTimeLogsFound": "Nenhum registro de tempo encontrado"
"noTimeLogsFound": "Nenhum registro de tempo encontrado",
"timerDisabledTooltip": "O cronômetro está desabilitado porque esta tarefa tem {{count}} subtarefas. O tempo deve ser registrado nas subtarefas individuais.",
"timeLogDisabledTooltip": "O registro de tempo está desabilitado porque esta tarefa tem {{count}} subtarefas. O tempo deve ser registrado nas subtarefas individuais.",
"date": "Data",
"startTime": "Hora de início",
"endTime": "Hora de término",
"workDescription": "Descrição do trabalho",
"requiredFields": "Por favor, preencha todos os campos obrigatórios",
"dateRequired": "Por favor, selecione uma data",
"startTimeRequired": "Por favor, selecione a hora de início",
"endTimeRequired": "Por favor, selecione a hora de término",
"workDescriptionPlaceholder": "Adicionar uma descrição",
"cancel": "Cancelar",
"logTime": "Registrar tempo",
"updateTime": "Atualizar tempo"
},
"taskActivityLogTab": {
"title": "Registro de atividade"

View File

@@ -5,6 +5,7 @@
"searchByName": "Pesquisar por nome",
"selectAll": "Selecionar Todos",
"clearAll": "Limpar Todos",
"teams": "Equipes",
"searchByProject": "Pesquisar por nome do projeto",
@@ -15,6 +16,8 @@
"billable": "Cobrável",
"nonBillable": "Não Cobrável",
"filterByBillableStatus": "Filtrar por Status de Cobrança",
"allBillableTypes": "Todos os Tipos Cobráveis",
"total": "Total",
@@ -40,5 +43,25 @@
"noCategory": "Nenhuma Categoria",
"noProjects": "Nenhum projeto encontrado",
"noTeams": "Nenhum time encontrado",
"noData": "Nenhum dado encontrado"
"noData": "Nenhum dado encontrado",
"members": "Membros",
"searchByMember": "Pesquisar por membro",
"utilization": "Utilização",
"totalTimeLogged": "Tempo Total Registrado",
"expectedCapacity": "Capacidade Esperada",
"teamUtilization": "Utilização da Equipe",
"variance": "Variância",
"acrossAllTeamMembers": "Em todos os membros da equipe",
"basedOnWorkingSchedule": "Baseado no horário de trabalho",
"optimal": "Ótimo",
"underUtilized": "Sub-utilizado",
"overUtilized": "Super-utilizado",
"overCapacity": "Acima da capacidade",
"underCapacity": "Abaixo da capacidade",
"considerWorkloadRedistribution": "Considerar redistribuição da carga de trabalho",
"capacityAvailableForNewProjects": "Capacidade disponível para novos projetos",
"targetRange": "Meta: 90-110%",
"overtimeWork": "Trabalho de Horas Extras",
"reviewWorkLifeBalance": "Revisar políticas de equilíbrio trabalho-vida"
}

View File

@@ -1,5 +1,10 @@
const fs = require('fs');
const path = require('path');
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
// Get __dirname equivalent for ES modules
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Create the directory if it doesn't exist
const targetDir = path.join(__dirname, '..', 'public', 'tinymce');

View File

@@ -6,6 +6,7 @@ import i18next from 'i18next';
// Components
import ThemeWrapper from './features/theme/ThemeWrapper';
import PreferenceSelector from './components/PreferenceSelector';
import ResourcePreloader from './components/resource-preloader/resource-preloader';
// Routes
import router from './app/routes';
@@ -20,7 +21,7 @@ import { Language } from './features/i18n/localesSlice';
import logger from './utils/errorLogger';
import { SuspenseFallback } from './components/suspense-fallback/suspense-fallback';
const App: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const App: React.FC<{ children?: React.ReactNode }> = ({ children }) => {
const themeMode = useAppSelector(state => state.themeReducer.mode);
const language = useAppSelector(state => state.localesReducer.lng);
@@ -47,6 +48,7 @@ const App: React.FC<{ children: React.ReactNode }> = ({ children }) => {
<Suspense fallback={<SuspenseFallback />}>
<ThemeWrapper>
<RouterProvider router={router} future={{ v7_startTransition: true }} />
<ResourcePreloader />
</ThemeWrapper>
</Suspense>
);

View File

@@ -0,0 +1,81 @@
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-rate-cards`;
export interface IProjectRateCardRole {
id?: string;
project_id: string;
job_title_id: string;
jobtitle?: string;
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 }: { project_id: string; job_title_id: string; rate: number }): Promise<IServerResponse<IProjectRateCardRole>> {
const response = await apiClient.post<IServerResponse<IProjectRateCardRole>>(
`${rootUrl}/create-project-rate-card-role`,
{ project_id, job_title_id, 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 }): 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,92 @@
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;
},
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

@@ -7,6 +7,7 @@ import { IProjectViewModel } from '@/types/project/projectViewModel.types';
import { ITeamMemberOverviewGetResponse } from '@/types/project/project-insights.types';
import { IProjectMembersViewModel } from '@/types/projectMember.types';
import { IProjectManager } from '@/types/project/projectManager.types';
import { ITaskPhase } from '@/types/tasks/taskPhase.types';
const rootUrl = `${API_BASE_URL}/projects`;
@@ -120,5 +121,14 @@ export const projectsApiService = {
const response = await apiClient.get<IServerResponse<IProjectManager[]>>(`${url}`);
return response.data;
},
updateProjectPhaseLabel: async (projectId: string, phaseLabel: string) => {
const q = toQueryString({ id: projectId, current_project_id: projectId });
const response = await apiClient.put<IServerResponse<ITaskPhase>>(
`${rootUrl}/label/${projectId}${q}`,
{ name: phaseLabel }
);
return response.data;
},
};

View File

@@ -3,7 +3,7 @@ import { toQueryString } from '@/utils/toQueryString';
import apiClient from '../api-client';
import { IServerResponse } from '@/types/common.types';
import { IAllocationViewModel } from '@/types/reporting/reporting-allocation.types';
import { IProjectLogsBreakdown, IRPTTimeMember, IRPTTimeProject, ITimeLogBreakdownReq } from '@/types/reporting/reporting.types';
import { IProjectLogsBreakdown, IRPTTimeMember, IRPTTimeMemberViewModel, IRPTTimeProject, ITimeLogBreakdownReq } from '@/types/reporting/reporting.types';
const rootUrl = `${API_BASE_URL}/reporting`;
@@ -25,7 +25,7 @@ export const reportingTimesheetApiService = {
return response.data;
},
getMemberTimeSheets: async (body = {}, archived = false): Promise<IServerResponse<IRPTTimeMember[]>> => {
getMemberTimeSheets: async (body = {}, archived = false): Promise<IServerResponse<IRPTTimeMemberViewModel>> => {
const q = toQueryString({ archived });
const response = await apiClient.post(`${rootUrl}/time-reports/members/${q}`, body);
return response.data;

View File

@@ -0,0 +1,48 @@
import apiClient from '@api/api-client';
import { API_BASE_URL } from '@/shared/constants';
import { IServerResponse } from '@/types/common.types';
import { IJobTitle, IJobTitlesViewModel } from '@/types/job.types';
import { toQueryString } from '@/utils/toQueryString';
import { RatecardType, IRatecardViewModel } from '@/types/project/ratecard.types';
type IRatecard = {
id: string;}
const rootUrl = `${API_BASE_URL}/rate-cards`;
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

@@ -69,9 +69,11 @@ import projectReportsTableColumnsReducer from '../features/reporting/projectRepo
import projectReportsReducer from '../features/reporting/projectReports/project-reports-slice';
import membersReportsReducer from '../features/reporting/membersReports/membersReportsSlice';
import timeReportsOverviewReducer from '@features/reporting/time-reports/time-reports-overview.slice';
import financeReducer from '../features/finance/finance-slice';
import roadmapReducer from '../features/roadmap/roadmap-slice';
import teamMembersReducer from '@features/team-members/team-members.slice';
import projectFinanceRateCardReducer from '../features/finance/project-finance-slice';
import projectFinancesReducer from '../features/projects/finance/project-finance.slice';
import groupByFilterDropdownReducer from '../features/group-by-filter-dropdown/group-by-filter-dropdown-slice';
import homePageApiService from '@/api/home-page/home-page.api.service';
import { projectsApi } from '@/api/projects/projects.v1.api.service';
@@ -155,6 +157,9 @@ export const store = configureStore({
roadmapReducer: roadmapReducer,
groupByFilterDropdownReducer: groupByFilterDropdownReducer,
timeReportsOverviewReducer: timeReportsOverviewReducer,
financeReducer: financeReducer,
projectFinanceRateCard: projectFinanceRateCardReducer,
projectFinances: projectFinancesReducer,
},
});

View File

@@ -4,19 +4,20 @@ import { InlineMember } from '@/types/teamMembers/inlineMember.types';
interface AvatarsProps {
members: InlineMember[];
maxCount?: number;
allowClickThrough?: boolean;
}
const renderAvatar = (member: InlineMember, index: number) => (
const renderAvatar = (member: InlineMember, index: number, allowClickThrough: boolean = false) => (
<Tooltip
key={member.team_member_id || index}
title={member.end && member.names ? member.names.join(', ') : member.name}
>
{member.avatar_url ? (
<span onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<span onClick={allowClickThrough ? undefined : (e: React.MouseEvent) => e.stopPropagation()}>
<Avatar src={member.avatar_url} size={28} key={member.team_member_id || index} />
</span>
) : (
<span onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<span onClick={allowClickThrough ? undefined : (e: React.MouseEvent) => e.stopPropagation()}>
<Avatar
size={28}
key={member.team_member_id || index}
@@ -32,12 +33,12 @@ const renderAvatar = (member: InlineMember, index: number) => (
</Tooltip>
);
const Avatars: React.FC<AvatarsProps> = ({ members, maxCount }) => {
const Avatars: React.FC<AvatarsProps> = ({ members, maxCount, allowClickThrough = false }) => {
const visibleMembers = maxCount ? members.slice(0, maxCount) : members;
return (
<div onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<div onClick={allowClickThrough ? undefined : (e: React.MouseEvent) => e.stopPropagation()}>
<Avatar.Group>
{visibleMembers.map((member, index) => renderAvatar(member, index))}
{visibleMembers.map((member, index) => renderAvatar(member, index, allowClickThrough))}
</Avatar.Group>
</div>
);

View File

@@ -0,0 +1,61 @@
import { Alert } from 'antd';
import { useState, useEffect } from 'react';
interface ConditionalAlertProps {
message?: string;
type?: 'success' | 'info' | 'warning' | 'error';
showInitially?: boolean;
onClose?: () => void;
condition?: boolean;
className?: string;
}
const ConditionalAlert = ({
message = '',
type = 'info',
showInitially = false,
onClose,
condition,
className = ''
}: ConditionalAlertProps) => {
const [visible, setVisible] = useState(showInitially);
useEffect(() => {
if (condition !== undefined) {
setVisible(condition);
}
}, [condition]);
const handleClose = () => {
setVisible(false);
onClose?.();
};
const alertStyles = {
position: 'fixed',
top: 0,
left: 0,
right: 0,
zIndex: 1000,
margin: 0,
borderRadius: 0,
} as const;
if (!visible || !message) {
return null;
}
return (
<Alert
message={message}
type={type}
closable
onClose={handleClose}
style={alertStyles}
showIcon
className={className}
/>
);
};
export default ConditionalAlert;

View File

@@ -0,0 +1,184 @@
import { Alert, Button, Space } from 'antd';
import { useState, useEffect } from 'react';
import { CrownOutlined, ClockCircleOutlined } from '@ant-design/icons';
import { ILocalSession } from '@/types/auth/local-session.types';
import { LICENSE_ALERT_KEY } from '@/shared/constants';
import { format, isSameDay, differenceInDays, addDays, isAfter } from 'date-fns';
import { useNavigate } from 'react-router-dom';
interface LicenseAlertProps {
currentSession: ILocalSession;
onVisibilityChange?: (visible: boolean) => void;
}
interface AlertConfig {
type: 'success' | 'info' | 'warning' | 'error';
message: React.ReactNode;
description: string;
icon: React.ReactNode;
licenseType: 'trial' | 'expired' | 'expiring';
daysRemaining: number;
}
const LicenseAlert = ({ currentSession, onVisibilityChange }: LicenseAlertProps) => {
const navigate = useNavigate();
const [visible, setVisible] = useState(false);
const [alertConfig, setAlertConfig] = useState<AlertConfig | null>(null);
const handleClose = () => {
setVisible(false);
setLastAlertDate(new Date());
};
const getLastAlertDate = () => {
const lastAlertDate = localStorage.getItem(LICENSE_ALERT_KEY);
return lastAlertDate ? new Date(lastAlertDate) : null;
};
const setLastAlertDate = (date: Date) => {
localStorage.setItem(LICENSE_ALERT_KEY, format(date, 'yyyy-MM-dd'));
};
const handleUpgrade = () => {
navigate('/worklenz/admin-center/billing');
};
const handleExtend = () => {
navigate('/worklenz/admin-center/billing');
};
const getVisibleAndConfig = (): { visible: boolean; config: AlertConfig | null } => {
const lastAlertDate = getLastAlertDate();
// Check if alert was already shown today
if (lastAlertDate && isSameDay(lastAlertDate, new Date())) {
return { visible: false, config: null };
}
if (!currentSession.valid_till_date) {
return { visible: false, config: null };
}
let validTillDate = new Date(currentSession.valid_till_date);
const today = new Date();
// If validTillDate is after today, add 1 day (matching Angular logic)
if (isAfter(validTillDate, today)) {
validTillDate = addDays(validTillDate, 1);
}
// Calculate the difference in days between the two dates
const daysDifference = differenceInDays(validTillDate, today);
// Don't show if no valid_till_date or difference is >= 7 days
if (daysDifference >= 7) {
return { visible: false, config: null };
}
const absDaysDifference = Math.abs(daysDifference);
const dayText = `${absDaysDifference} day${absDaysDifference === 1 ? '' : 's'}`;
let string1 = '';
let string2 = dayText;
let licenseType: 'trial' | 'expired' | 'expiring' = 'expiring';
let alertType: 'success' | 'info' | 'warning' | 'error' = 'warning';
if (currentSession.subscription_status === 'trialing') {
licenseType = 'trial';
if (daysDifference < 0) {
string1 = 'Your Worklenz trial expired';
string2 = string2 + ' ago';
alertType = 'error';
licenseType = 'expired';
} else if (daysDifference !== 0 && daysDifference < 7) {
string1 = 'Your Worklenz trial expires in';
} else if (daysDifference === 0 && daysDifference < 7) {
string1 = 'Your Worklenz trial expires';
string2 = 'today';
}
} else if (currentSession.subscription_status === 'active') {
if (daysDifference < 0) {
string1 = 'Your Worklenz subscription expired';
string2 = string2 + ' ago';
alertType = 'error';
licenseType = 'expired';
} else if (daysDifference !== 0 && daysDifference < 7) {
string1 = 'Your Worklenz subscription expires in';
} else if (daysDifference === 0 && daysDifference < 7) {
string1 = 'Your Worklenz subscription expires';
string2 = 'today';
}
} else {
return { visible: false, config: null };
}
const config: AlertConfig = {
type: alertType,
message: (
<>
Action required! {string1} <strong>{string2}</strong>
</>
),
description: '',
icon: licenseType === 'expired' || licenseType === 'trial' ? <CrownOutlined /> : <ClockCircleOutlined />,
licenseType,
daysRemaining: absDaysDifference
};
return { visible: true, config };
};
useEffect(() => {
const { visible: shouldShow, config } = getVisibleAndConfig();
setVisible(shouldShow);
setAlertConfig(config);
// Notify parent about visibility change
if (onVisibilityChange) {
onVisibilityChange(shouldShow);
}
}, [currentSession, onVisibilityChange]);
const alertStyles = {
margin: 0,
borderRadius: 0,
} as const;
const actionButtons = alertConfig && (
<Space>
{/* Show button only if user is owner or admin */}
{(currentSession.owner || currentSession.is_admin) && (
<Button
type="primary"
size="small"
onClick={currentSession.subscription_status === 'trialing' ? handleUpgrade : handleExtend}
>
{currentSession.subscription_status === 'trialing' ? 'Upgrade now' : 'Go to Billing'}
</Button>
)}
</Space>
);
if (!visible || !alertConfig) {
return null;
}
return (
<div data-license-alert>
<Alert
message={alertConfig.message}
type={alertConfig.type}
closable
onClose={handleClose}
style={{
...alertStyles,
fontWeight: 500,
}}
showIcon
action={actionButtons}
/>
</div>
);
};
export default LicenseAlert;

View File

@@ -0,0 +1,109 @@
import { useEffect, useRef, useState } from 'react';
import { InputRef } from 'antd/es/input';
import Dropdown from 'antd/es/dropdown';
import Card from 'antd/es/card';
import List from 'antd/es/list';
import Input from 'antd/es/input';
import Checkbox from 'antd/es/checkbox';
import Button from 'antd/es/button';
import Empty from 'antd/es/empty';
import { PlusOutlined } from '@ant-design/icons';
import SingleAvatar from '../common/single-avatar/single-avatar';
import { IProjectMemberViewModel } from '@/types/projectMember.types';
interface RateCardAssigneeSelectorProps {
projectId: string;
onChange?: (memberId: string) => void;
selectedMemberIds?: string[];
memberlist?: IProjectMemberViewModel[];
}
const RateCardAssigneeSelector = ({
projectId,
onChange,
selectedMemberIds = [],
memberlist = [],
assignedMembers = [], // New prop: List of all assigned member IDs across all job titles
}: RateCardAssigneeSelectorProps & { assignedMembers: string[] }) => {
const membersInputRef = useRef<InputRef>(null);
const [searchQuery, setSearchQuery] = useState('');
const [members, setMembers] = useState<IProjectMemberViewModel[]>(memberlist);
useEffect(() => {
setMembers(memberlist);
}, [memberlist]);
const filteredMembers = members.filter((member) =>
member.name?.toLowerCase().includes(searchQuery.toLowerCase())
);
const dropdownContent = (
<Card styles={{ body: { padding: 8 } }}>
<Input
ref={membersInputRef}
value={searchQuery}
onChange={(e) => setSearchQuery(e.currentTarget.value)}
placeholder="Search members"
/>
<List style={{ padding: 0, maxHeight: 200, overflow: 'auto' }}>
{filteredMembers.length ? (
filteredMembers.map((member) => {
const isAssignedToAnotherJobTitle =
assignedMembers.includes(member.id || '') &&
!selectedMemberIds.includes(member.id || ''); // Check if the member is assigned elsewhere
return (
<List.Item
key={member.id}
style={{
display: 'flex',
gap: 8,
alignItems: 'center',
padding: '4px 8px',
border: 'none',
opacity: member.pending_invitation || isAssignedToAnotherJobTitle ? 0.5 : 1,
justifyContent: 'flex-start',
textAlign: 'left',
}}
>
<Checkbox
checked={selectedMemberIds.includes(member.id || '')}
disabled={member.pending_invitation || isAssignedToAnotherJobTitle}
onChange={() => onChange?.(member.id || '')}
/>
<SingleAvatar
avatarUrl={member.avatar_url}
name={member.name}
email={member.email}
/>
<span>{member.name}</span>
</List.Item>
);
})
) : (
<Empty description="No members found" />
)}
</List>
</Card>
);
return (
<Dropdown
overlayClassName="custom-dropdown"
trigger={['click']}
dropdownRender={() => dropdownContent}
onOpenChange={(open) => {
if (open) setTimeout(() => membersInputRef.current?.focus(), 0);
}}
>
<Button
type="dashed"
shape="circle"
size="small"
icon={<PlusOutlined style={{ fontSize: 12 }} />}
/>
</Dropdown>
);
};
export default RateCardAssigneeSelector;

View File

@@ -147,6 +147,10 @@ const TimeWiseFilter = () => {
format={'MMM DD, YYYY'}
onChange={handleDateRangeChange}
value={customRange ? [dayjs(customRange[0]), dayjs(customRange[1])] : null}
disabledDate={(current) => {
// Disable dates after today
return current && current > dayjs().endOf('day');
}}
/>
<Button

View File

@@ -0,0 +1,49 @@
import { useEffect } from 'react';
/**
* ResourcePreloader component to preload critical chunks for better performance
* This helps reduce loading times when users navigate to different project view tabs
*/
const ResourcePreloader = () => {
useEffect(() => {
// Preload critical project view chunks after initial page load
const preloadCriticalChunks = () => {
// Only preload in production and if the user is likely to use project views
if (import.meta.env.DEV) return;
// Check if user is on a project-related page or dashboard
const currentPath = window.location.pathname;
const isProjectRelated = currentPath.includes('/projects') ||
currentPath.includes('/worklenz') ||
currentPath === '/';
if (!isProjectRelated) return;
// Preload the most commonly used project view components
const criticalImports = [
() => import('@/pages/projects/projectView/taskList/project-view-task-list'),
() => import('@/pages/projects/projectView/board/project-view-board'),
() => import('@/components/project-task-filters/filter-dropdowns/group-by-filter-dropdown'),
() => import('@/components/project-task-filters/filter-dropdowns/search-dropdown'),
];
// Preload with a small delay to not interfere with initial page load
setTimeout(() => {
criticalImports.forEach(importFn => {
importFn().catch(error => {
// Silently handle preload failures - they're not critical
console.debug('Preload failed:', error);
});
});
}, 2000); // 2 second delay after initial load
};
// Start preloading when component mounts
preloadCriticalChunks();
}, []);
// This component doesn't render anything
return null;
};
export default ResourcePreloader;

View File

@@ -1,11 +1,13 @@
import StatusGroupTables from '@/pages/projects/project-view-1/taskList/statusTables/StatusGroupTables';
import TaskGroupList from '@/pages/projects/projectView/taskList/groupTables/TaskGroupList';
import { TaskType } from '@/types/task.types';
import { useAppSelector } from '@/hooks/useAppSelector';
import GroupByFilterDropdown from '@/pages/projects/project-view-1/taskList/taskListFilters/GroupByFilterDropdown';
import GroupByFilterDropdown from '@/components/project-task-filters/filter-dropdowns/group-by-filter-dropdown';
import { useTranslation } from 'react-i18next';
import { ITaskListGroup } from '@/types/tasks/taskList.types';
const WithStartAndEndDates = () => {
const dataSource: TaskType[] = useAppSelector(state => state.taskReducer.tasks);
const dataSource: ITaskListGroup[] = useAppSelector(state => state.taskReducer.taskGroups);
const groupBy = useAppSelector(state => state.taskReducer.groupBy);
const { t } = useTranslation('schedule');
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
@@ -57,7 +59,7 @@ const WithStartAndEndDates = () => {
<GroupByFilterDropdown />
</div>
<div>
<StatusGroupTables datasource={dataSource} />
<TaskGroupList taskGroups={dataSource} groupBy={groupBy} />
</div>
</div>
);

View File

@@ -2,13 +2,14 @@ import { TaskType } from '@/types/task.types';
import { useAppSelector } from '@/hooks/useAppSelector';
import GroupByFilterDropdown from '@/components/project-task-filters/filter-dropdowns/group-by-filter-dropdown';
import { useTranslation } from 'react-i18next';
import StatusGroupTables from '@/pages/projects/project-view-1/taskList/statusTables/StatusGroupTables';
import TaskGroupList from '@/pages/projects/projectView/taskList/groupTables/TaskGroupList';
import PriorityGroupTables from '@/pages/projects/projectView/taskList/groupTables/priorityTables/PriorityGroupTables';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import { ITaskListGroup } from '@/types/tasks/taskList.types';
const WithStartAndEndDates = () => {
const dataSource: ITaskListGroup[] = useAppSelector(state => state.taskReducer.taskGroups);
const groupBy = useAppSelector(state => state.taskReducer.groupBy);
const { t } = useTranslation('schedule');
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
@@ -60,10 +61,7 @@ const WithStartAndEndDates = () => {
<GroupByFilterDropdown />
</div>
<div>
{dataSource.map(group => (
<StatusGroupTables key={group.id} group={group} />
))}
{/* <PriorityGroupTables datasource={dataSource} /> */}
<TaskGroupList taskGroups={dataSource} groupBy={groupBy} />
</div>
</div>
);

View File

@@ -2,23 +2,41 @@ import { SocketEvents } from '@/shared/socket-events';
import { useSocket } from '@/socket/socketContext';
import { colors } from '@/styles/colors';
import { ITaskViewModel } from '@/types/tasks/task.types';
import { Flex, Form, FormInstance, InputNumber, Typography } from 'antd';
import { Flex, Form, FormInstance, InputNumber, Typography, Tooltip } from 'antd';
import { TFunction } from 'i18next';
import { useState } from 'react';
import { useState, useEffect } from 'react';
interface TaskDrawerEstimationProps {
t: TFunction;
task: ITaskViewModel;
form: FormInstance<any>;
subTasksEstimation?: { hours: number; minutes: number }; // Sum of subtasks estimation
}
const TaskDrawerEstimation = ({ t, task, form }: TaskDrawerEstimationProps) => {
const TaskDrawerEstimation = ({ t, task, form, subTasksEstimation }: TaskDrawerEstimationProps) => {
const { socket, connected } = useSocket();
const [hours, setHours] = useState(0);
const [minutes, setMinutes] = useState(0);
// Check if task has subtasks
const hasSubTasks = (task?.sub_tasks_count || 0) > 0;
// Use subtasks estimation if available, otherwise use task's own estimation
const displayHours = hasSubTasks && subTasksEstimation ? subTasksEstimation.hours : (task?.total_hours || 0);
const displayMinutes = hasSubTasks && subTasksEstimation ? subTasksEstimation.minutes : (task?.total_minutes || 0);
useEffect(() => {
// Update form values when subtasks estimation changes
if (hasSubTasks && subTasksEstimation) {
form.setFieldsValue({
hours: subTasksEstimation.hours,
minutes: subTasksEstimation.minutes
});
}
}, [subTasksEstimation, hasSubTasks, form]);
const handleTimeEstimationBlur = (e: React.FocusEvent<HTMLInputElement>) => {
if (!connected || !task.id) return;
if (!connected || !task.id || hasSubTasks) return;
// Get current form values instead of using state
const currentHours = form.getFieldValue('hours') || 0;
@@ -35,8 +53,16 @@ const TaskDrawerEstimation = ({ t, task, form }: TaskDrawerEstimationProps) => {
);
};
const tooltipTitle = hasSubTasks
? t('taskInfoTab.details.time-estimation-disabled-tooltip', {
count: task?.sub_tasks_count || 0,
defaultValue: `Time estimation is disabled because this task has ${task?.sub_tasks_count || 0} subtasks. The estimation shown is the sum of all subtasks.`
})
: '';
return (
<Form.Item name="timeEstimation" label={t('taskInfoTab.details.time-estimation')}>
<Tooltip title={tooltipTitle} trigger={hasSubTasks ? 'hover' : []}>
<Flex gap={8}>
<Form.Item
name={'hours'}
@@ -54,7 +80,13 @@ const TaskDrawerEstimation = ({ t, task, form }: TaskDrawerEstimationProps) => {
max={24}
placeholder={t('taskInfoTab.details.hours')}
onBlur={handleTimeEstimationBlur}
onChange={value => setHours(value || 0)}
onChange={value => !hasSubTasks && setHours(value || 0)}
disabled={hasSubTasks}
value={displayHours}
style={{
cursor: hasSubTasks ? 'not-allowed' : 'default',
opacity: hasSubTasks ? 0.6 : 1
}}
/>
</Form.Item>
<Form.Item
@@ -73,10 +105,17 @@ const TaskDrawerEstimation = ({ t, task, form }: TaskDrawerEstimationProps) => {
max={60}
placeholder={t('taskInfoTab.details.minutes')}
onBlur={handleTimeEstimationBlur}
onChange={value => setMinutes(value || 0)}
onChange={value => !hasSubTasks && setMinutes(value || 0)}
disabled={hasSubTasks}
value={displayMinutes}
style={{
cursor: hasSubTasks ? 'not-allowed' : 'default',
opacity: hasSubTasks ? 0.6 : 1
}}
/>
</Form.Item>
</Flex>
</Tooltip>
</Form.Item>
);
};

View File

@@ -166,9 +166,11 @@ const SubTaskTable = ({ subTasks, loadingSubTasks, refreshSubTasks, t }: SubTask
{
key: 'name',
dataIndex: 'name',
title: 'Name',
},
{
key: 'priority',
title: 'Priority',
render: (record: IProjectTask) => (
<Tag
color={themeMode === 'dark' ? record.priority_color_dark : record.priority_color}
@@ -180,6 +182,7 @@ const SubTaskTable = ({ subTasks, loadingSubTasks, refreshSubTasks, t }: SubTask
},
{
key: 'status',
title: 'Status',
render: (record: IProjectTask) => (
<Tag
color={themeMode === 'dark' ? record.status_color_dark : record.status_color}
@@ -191,10 +194,12 @@ const SubTaskTable = ({ subTasks, loadingSubTasks, refreshSubTasks, t }: SubTask
},
{
key: 'assignee',
title: 'Assignees',
render: (record: ISubTask) => <Avatars members={record.names || []} />,
},
{
key: 'actionBtns',
title: 'Actions',
width: 80,
render: (record: IProjectTask) => (
<Flex gap={8} align="center" className="action-buttons">
@@ -230,7 +235,6 @@ const SubTaskTable = ({ subTasks, loadingSubTasks, refreshSubTasks, t }: SubTask
{subTasks.length > 0 && (
<Table
className="custom-two-colors-row-table subtask-table"
showHeader={false}
dataSource={subTasks}
columns={columns}
rowKey={record => record?.id || nanoid()}

View File

@@ -14,6 +14,7 @@ import { useTranslation } from 'react-i18next';
import { colors } from '@/styles/colors';
import { ITaskFormViewModel, ITaskViewModel } from '@/types/tasks/task.types';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import { ISubTask } from '@/types/tasks/subTask.types';
import { simpleDateFormat } from '@/utils/simpleDateFormat';
import NotifyMemberSelector from './notify-member-selector';
@@ -34,6 +35,7 @@ import { InlineMember } from '@/types/teamMembers/inlineMember.types';
interface TaskDetailsFormProps {
taskFormViewModel?: ITaskFormViewModel | null;
subTasks?: ISubTask[]; // Array of subtasks to calculate estimation sum
}
// Custom wrapper that enforces stricter rules for displaying progress input
@@ -75,11 +77,15 @@ const ConditionalProgressInput = ({ task, form }: ConditionalProgressInputProps)
return null;
};
const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) => {
const TaskDetailsForm = ({ taskFormViewModel = null, subTasks = [] }: TaskDetailsFormProps) => {
const { t } = useTranslation('task-drawer/task-drawer');
const [form] = Form.useForm();
const { project } = useAppSelector(state => state.projectReducer);
// No need to calculate subtask estimation on frontend anymore
// The backend now provides recursive estimation directly in the task data
const subTasksEstimation: { hours: number; minutes: number } | undefined = undefined;
useEffect(() => {
if (!taskFormViewModel) {
form.resetFields();
@@ -167,7 +173,7 @@ const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) =>
<TaskDrawerDueDate task={taskFormViewModel?.task as ITaskViewModel} t={t} form={form} />
<TaskDrawerEstimation t={t} task={taskFormViewModel?.task as ITaskViewModel} form={form} />
<TaskDrawerEstimation t={t} task={taskFormViewModel?.task as ITaskViewModel} form={form} subTasksEstimation={subTasksEstimation} />
{taskFormViewModel?.task && (
<ConditionalProgressInput task={taskFormViewModel?.task as ITaskViewModel} form={form} />
@@ -183,9 +189,9 @@ const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) =>
<TaskDrawerBillable task={taskFormViewModel?.task as ITaskViewModel} />
</Form.Item>
<Form.Item name="recurring" label={t('taskInfoTab.details.recurring')}>
{/* <Form.Item name="recurring" label={t('taskInfoTab.details.recurring')}>
<TaskDrawerRecurringConfig task={taskFormViewModel?.task as ITaskViewModel} />
</Form.Item>
</Form.Item> */}
<Form.Item name="notify" label={t('taskInfoTab.details.notify')}>
<NotifyMemberSelector task={taskFormViewModel?.task as ITaskViewModel} t={t} />

View File

@@ -100,7 +100,7 @@ const TaskDrawerInfoTab = ({ t }: TaskDrawerInfoTabProps) => {
{
key: 'details',
label: <Typography.Text strong>{t('taskInfoTab.details.title')}</Typography.Text>,
children: <TaskDetailsForm taskFormViewModel={taskFormViewModel} />,
children: <TaskDetailsForm taskFormViewModel={taskFormViewModel} subTasks={subTasks} />,
style: panelStyle,
className: 'custom-task-drawer-info-collapse',
},

View File

@@ -25,8 +25,6 @@ const TaskDrawerTimeLog = ({ t, refreshTrigger = 0 }: TaskDrawerTimeLogProps) =>
const [totalTimeText, setTotalTimeText] = useState<string>('0m 0s');
const [loading, setLoading] = useState<boolean>(false);
const dispatch = useAppDispatch();
const themeMode = useAppSelector(state => state.themeReducer.mode);
const { selectedTaskId, taskFormViewModel, timeLogEditing } = useAppSelector(
state => state.taskDrawerReducer
);
@@ -36,6 +34,15 @@ const TaskDrawerTimeLog = ({ t, refreshTrigger = 0 }: TaskDrawerTimeLogProps) =>
taskFormViewModel?.task?.timer_start_time || null
);
// Check if task has subtasks
const hasSubTasks = (taskFormViewModel?.task?.sub_tasks_count || 0) > 0;
const timerDisabledTooltip = hasSubTasks
? t('taskTimeLogTab.timerDisabledTooltip', {
count: taskFormViewModel?.task?.sub_tasks_count || 0,
defaultValue: `Timer is disabled because this task has ${taskFormViewModel?.task?.sub_tasks_count || 0} subtasks. Time should be logged on individual subtasks.`
})
: '';
const formatTimeComponents = (hours: number, minutes: number, seconds: number): string => {
const parts = [];
if (hours > 0) parts.push(`${hours}h`);
@@ -131,6 +138,8 @@ const TaskDrawerTimeLog = ({ t, refreshTrigger = 0 }: TaskDrawerTimeLogProps) =>
handleStartTimer={handleStartTimer}
handleStopTimer={handleTimerStop}
timeString={timeString}
disabled={hasSubTasks}
disabledTooltip={timerDisabledTooltip}
/>
<Button size="small" icon={<DownloadOutlined />} onClick={handleExportToExcel}>
{t('taskTimeLogTab.exportToExcel')}

View File

@@ -1,9 +1,11 @@
import React from 'react';
import { Button, DatePicker, Form, Input, TimePicker, Flex } from 'antd';
import { Button, DatePicker, Form, Input, TimePicker, Flex, Tooltip } from 'antd';
import { ClockCircleOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { useTranslation } from 'react-i18next';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { themeWiseColor } from '@/utils/themeWiseColor';
import { useAuthService } from '@/hooks/useAuth';
import { useSocket } from '@/socket/socketContext';
@@ -11,6 +13,7 @@ import { SocketEvents } from '@/shared/socket-events';
import { ITaskAssigneesUpdateResponse } from '@/types/tasks/task-assignee-update-response';
import { ITaskLogViewModel } from '@/types/tasks/task-log-view.types';
import { taskTimeLogsApiService } from '@/api/tasks/task-time-logs.api.service';
import { setRefreshTimestamp } from '@/features/project/project.slice';
interface TimeLogFormProps {
onCancel: () => void;
@@ -25,8 +28,10 @@ const TimeLogForm = ({
initialValues,
mode = 'create'
}: TimeLogFormProps) => {
const { t } = useTranslation('task-drawer/task-drawer');
const currentSession = useAuthService().getCurrentSession();
const { socket, connected } = useSocket();
const dispatch = useAppDispatch();
const [form] = Form.useForm();
const [formValues, setFormValues] = React.useState<{
date: any;
@@ -41,6 +46,9 @@ const TimeLogForm = ({
const themeMode = useAppSelector(state => state.themeReducer.mode);
const { taskFormViewModel } = useAppSelector(state => state.taskDrawerReducer);
// Check if task has subtasks
const hasSubTasks = (taskFormViewModel?.task?.sub_tasks_count || 0) > 0;
React.useEffect(() => {
if (initialValues && mode === 'edit') {
const createdAt = dayjs(initialValues.created_at);
@@ -164,7 +172,9 @@ const TimeLogForm = ({
console.log('Creating new time log:', requestBody);
await taskTimeLogsApiService.create(requestBody);
}
console.log('Received values:', values);
// Trigger refresh of finance data
dispatch(setRefreshTimestamp());
// Call onSubmitSuccess if provided, otherwise just cancel
if (onSubmitSuccess) {
@@ -181,6 +191,23 @@ const TimeLogForm = ({
return formValues.date && formValues.startTime && formValues.endTime;
};
const isSubmitDisabled = () => {
return !isFormValid() || hasSubTasks;
};
const getSubmitTooltip = () => {
if (hasSubTasks) {
return t('taskTimeLogTab.timeLogDisabledTooltip', {
count: taskFormViewModel?.task?.sub_tasks_count || 0,
defaultValue: `Time logging is disabled because this task has ${taskFormViewModel?.task?.sub_tasks_count || 0} subtasks. Time should be logged on individual subtasks.`
});
}
if (!isFormValid()) {
return t('taskTimeLogTab.requiredFields');
}
return '';
};
return (
<Flex
gap={8}
@@ -219,45 +246,51 @@ const TimeLogForm = ({
<Flex gap={8} wrap="wrap" style={{ width: '100%' }}>
<Form.Item
name="date"
label="Date"
rules={[{ required: true, message: 'Please select a date' }]}
label={t('taskTimeLogTab.date')}
rules={[{ required: true, message: t('taskTimeLogTab.dateRequired') }]}
>
<DatePicker disabledDate={current => current && current.toDate() > new Date()} />
</Form.Item>
<Form.Item
name="startTime"
label="Start Time"
rules={[{ required: true, message: 'Please select start time' }]}
label={t('taskTimeLogTab.startTime')}
rules={[{ required: true, message: t('taskTimeLogTab.startTimeRequired') }]}
>
<TimePicker format="HH:mm" />
</Form.Item>
<Form.Item
name="endTime"
label="End Time"
rules={[{ required: true, message: 'Please select end time' }]}
label={t('taskTimeLogTab.endTime')}
rules={[{ required: true, message: t('taskTimeLogTab.endTimeRequired') }]}
>
<TimePicker format="HH:mm" />
</Form.Item>
</Flex>
</Form.Item>
<Form.Item name="description" label="Work Description" style={{ marginBlockEnd: 12 }}>
<Input.TextArea placeholder="Add a description" />
<Form.Item name="description" label={t('taskTimeLogTab.workDescription')} style={{ marginBlockEnd: 12 }}>
<Input.TextArea placeholder={t('taskTimeLogTab.workDescriptionPlaceholder')} />
</Form.Item>
<Form.Item style={{ marginBlockEnd: 0 }}>
<Flex gap={8}>
<Button onClick={onCancel}>Cancel</Button>
<Button onClick={onCancel}>{t('taskTimeLogTab.cancel')}</Button>
<Tooltip title={getSubmitTooltip()} trigger={isSubmitDisabled() ? 'hover' : []}>
<Button
type="primary"
icon={<ClockCircleOutlined />}
disabled={!isFormValid()}
disabled={isSubmitDisabled()}
htmlType="submit"
style={{
opacity: hasSubTasks ? 0.5 : 1,
cursor: hasSubTasks ? 'not-allowed' : 'pointer'
}}
>
{mode === 'edit' ? 'Update time' : 'Log time'}
{mode === 'edit' ? t('taskTimeLogTab.updateTime') : t('taskTimeLogTab.logTime')}
</Button>
</Tooltip>
</Flex>
</Form.Item>
</Form>

View File

@@ -0,0 +1,35 @@
.time-log-item .ant-card {
transition: all 0.2s ease;
}
.time-log-item .ant-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
border-color: #d9d9d9;
}
/* Dark mode hover effects */
[data-theme='dark'] .time-log-item .ant-card:hover {
box-shadow: 0 2px 8px rgba(255, 255, 255, 0.15);
border-color: #434343;
}
.time-log-item .ant-card .ant-card-body {
padding: 12px 16px;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.time-log-item .ant-card {
margin-bottom: 6px;
}
.time-log-item .ant-divider-vertical {
display: none;
}
/* Stack time info vertically on mobile */
.time-log-item .time-tracking-info {
flex-direction: column;
gap: 8px;
}
}

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { Button, Divider, Flex, Popconfirm, Typography, Space } from 'antd';
import { Button, Divider, Flex, Popconfirm, Typography, Space, Tag, Card } from 'antd';
import { ClockCircleOutlined } from '@ant-design/icons';
import { colors } from '@/styles/colors';
import { ITaskLogViewModel } from '@/types/tasks/task-log-view.types';
import SingleAvatar from '@/components/common/single-avatar/single-avatar';
@@ -12,6 +13,7 @@ import { useAppDispatch } from '@/hooks/useAppDispatch';
import { setTimeLogEditing } from '@/features/task-drawer/task-drawer.slice';
import TimeLogForm from './time-log-form';
import { useAuthService } from '@/hooks/useAuth';
import { setRefreshTimestamp } from '@/features/project/project.slice';
type TimeLogItemProps = {
log: ITaskLogViewModel;
@@ -19,20 +21,18 @@ type TimeLogItemProps = {
};
const TimeLogItem = ({ log, onDelete }: TimeLogItemProps) => {
const { user_name, avatar_url, time_spent_text, logged_by_timer, created_at, user_id, description } = log;
const { selectedTaskId } = useAppSelector(state => state.taskDrawerReducer);
const { user_name, avatar_url, time_spent_text, logged_by_timer, created_at, user_id, description, task_name, task_id, start_time, end_time } = log;
const { selectedTaskId, taskFormViewModel } = useAppSelector(state => state.taskDrawerReducer);
const themeMode = useAppSelector(state => state.themeReducer.mode);
const dispatch = useAppDispatch();
const currentSession = useAuthService().getCurrentSession();
const renderLoggedByTimer = () => {
if (!logged_by_timer) return null;
return (
<>
via Timer about{' '}
<Typography.Text strong style={{ fontSize: 15 }}>
{logged_by_timer}
</Typography.Text>
</>
<Tag icon={<ClockCircleOutlined />} color="green" style={{ fontSize: '11px', margin: 0 }}>
Timer
</Tag>
);
};
@@ -42,6 +42,9 @@ const TimeLogItem = ({ log, onDelete }: TimeLogItemProps) => {
if (!logId || !selectedTaskId) return;
const res = await taskTimeLogsApiService.delete(logId, selectedTaskId);
if (res.done) {
// Trigger refresh of finance data
dispatch(setRefreshTimestamp());
if (onDelete) onDelete();
}
};
@@ -60,14 +63,14 @@ const TimeLogItem = ({ log, onDelete }: TimeLogItemProps) => {
return (
<Space size={8}>
<Button type="link" onClick={handleEdit} style={{ padding: '0', height: 'auto', fontSize: '14px' }}>
<Button type="link" onClick={handleEdit} style={{ padding: '0', height: 'auto', fontSize: '12px' }}>
Edit
</Button>
<Popconfirm
title="Are you sure you want to delete this time log?"
onConfirm={() => handleDeleteTimeLog(log.id)}
>
<Button type="link" style={{ padding: '0', height: 'auto', fontSize: '14px' }}>
<Button type="link" style={{ padding: '0', height: 'auto', fontSize: '12px', color: '#ff4d4f' }}>
Delete
</Button>
</Popconfirm>
@@ -75,33 +78,136 @@ const TimeLogItem = ({ log, onDelete }: TimeLogItemProps) => {
);
};
// Check if this time log is from a subtask
const isFromSubtask = task_id && task_id !== selectedTaskId;
const formatTime = (timeString: string | undefined) => {
if (!timeString) return '';
try {
return new Date(timeString).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
hour12: true
});
} catch {
return timeString;
}
};
const formatDate = (timeString: string | undefined) => {
if (!timeString) return '';
try {
return new Date(timeString).toLocaleDateString([], {
month: 'short',
day: 'numeric',
year: 'numeric'
});
} catch {
return timeString;
}
};
const isDarkMode = themeMode === 'dark';
return (
<div className="time-log-item">
<Flex vertical gap={8}>
<Flex align="start" gap={12}>
<Card
size="small"
style={{
marginBottom: 8,
borderRadius: 8,
boxShadow: isDarkMode ? '0 1px 3px rgba(255,255,255,0.1)' : '0 1px 3px rgba(0,0,0,0.1)',
border: isDarkMode ? '1px solid #303030' : '1px solid #f0f0f0',
backgroundColor: isDarkMode ? '#1f1f1f' : '#ffffff'
}}
bodyStyle={{ padding: '12px 16px' }}
>
<Flex vertical gap={12}>
{/* Header with user info and task name */}
<Flex align="center" justify="space-between">
<Flex align="center" gap={12}>
<SingleAvatar avatarUrl={avatar_url} name={user_name} />
<Flex vertical style={{ flex: 1 }}>
<Flex justify="space-between" align="start">
<Flex vertical>
<Typography.Text>
<Typography.Text strong>{user_name}</Typography.Text> logged <Typography.Text strong>{time_spent_text}</Typography.Text> {renderLoggedByTimer()} {calculateTimeGap(created_at || '')}
<Flex vertical gap={2}>
<Flex align="center" gap={8} wrap>
<Typography.Text strong style={{ fontSize: '14px' }}>
{user_name}
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{formatDateTimeWithLocale(created_at || '')}
{task_name && (
<Tag color={isFromSubtask ? "blue" : "default"} style={{ fontSize: '11px', margin: 0 }}>
{task_name}
</Tag>
)}
{renderLoggedByTimer()}
</Flex>
<Typography.Text type="secondary" style={{ fontSize: '12px' }}>
{calculateTimeGap(created_at || '')}
</Typography.Text>
</Flex>
</Flex>
{renderActionButtons()}
</Flex>
{/* Time tracking details */}
<Flex align="center" justify="space-between" style={{
backgroundColor: isDarkMode ? '#262626' : '#fafafa',
padding: '8px 12px',
borderRadius: 6,
border: isDarkMode ? '1px solid #303030' : '1px solid #f0f0f0'
}}>
<Flex align="center" gap={16}>
<Flex vertical gap={2}>
<Typography.Text type="secondary" style={{ fontSize: '11px', lineHeight: 1 }}>
Start Time
</Typography.Text>
<Typography.Text strong style={{ fontSize: '12px', lineHeight: 1 }}>
{formatTime(start_time)}
</Typography.Text>
</Flex>
<Divider type="vertical" style={{ height: '24px', margin: 0 }} />
<Flex vertical gap={2}>
<Typography.Text type="secondary" style={{ fontSize: '11px', lineHeight: 1 }}>
End Time
</Typography.Text>
<Typography.Text strong style={{ fontSize: '12px', lineHeight: 1 }}>
{formatTime(end_time)}
</Typography.Text>
</Flex>
<Divider type="vertical" style={{ height: '24px', margin: 0 }} />
<Flex align="center" gap={6}>
<ClockCircleOutlined style={{ color: '#1890ff', fontSize: '14px' }} />
<Flex vertical gap={2}>
<Typography.Text type="secondary" style={{ fontSize: '11px', lineHeight: 1 }}>
Duration
</Typography.Text>
<Typography.Text strong style={{ fontSize: '12px', lineHeight: 1, color: '#1890ff' }}>
{time_spent_text}
</Typography.Text>
</Flex>
</Flex>
</Flex>
<Typography.Text type="secondary" style={{ fontSize: '11px' }}>
{formatDate(created_at)}
</Typography.Text>
</Flex>
{/* Description */}
{description && (
<Typography.Text style={{ marginTop: 8, display: 'block' }}>
<Flex vertical gap={4}>
<Typography.Text type="secondary" style={{ fontSize: '11px', fontWeight: 500 }}>
Description:
</Typography.Text>
<Typography.Text style={{ fontSize: '13px', lineHeight: 1.4 }}>
{description}
</Typography.Text>
</Flex>
)}
</Flex>
</Flex>
<Divider style={{ margin: '8px 0' }} />
</Flex>
</Card>
</div>
);
};

View File

@@ -1,4 +1,4 @@
import { TabsProps, Tabs, Button } from 'antd';
import { TabsProps, Tabs, Button, Tooltip } from 'antd';
import Drawer from 'antd/es/drawer';
import { InputRef } from 'antd/es/input';
import { useTranslation } from 'react-i18next';
@@ -146,16 +146,40 @@ const TaskDrawer = () => {
/>
);
} else {
return (
<Flex justify="center" style={{ width: '100%', padding: '16px 0 0' }}>
// Check if task has subtasks
const hasSubTasks = (taskFormViewModel?.task?.sub_tasks_count || 0) > 0;
const addTimeLogTooltip = hasSubTasks
? t('taskTimeLogTab.timeLogDisabledTooltip', {
count: taskFormViewModel?.task?.sub_tasks_count || 0,
defaultValue: `Time logging is disabled because this task has ${taskFormViewModel?.task?.sub_tasks_count || 0} subtasks. Time should be logged on individual subtasks.`
})
: '';
const addButton = (
<Button
type="primary"
icon={<PlusOutlined />}
onClick={handleAddTimeLog}
style={{ width: '100%' }}
style={{
width: '100%',
opacity: hasSubTasks ? 0.5 : 1,
cursor: hasSubTasks ? 'not-allowed' : 'pointer'
}}
disabled={hasSubTasks}
>
Add new time log
</Button>
);
return (
<Flex justify="center" style={{ width: '100%', padding: '16px 0 0' }}>
{hasSubTasks ? (
<Tooltip title={addTimeLogTooltip}>
{addButton}
</Tooltip>
) : (
addButton
)}
</Flex>
);
}

View File

@@ -3,7 +3,7 @@ import { Divider, Empty, Flex, Popover, Typography } from 'antd';
import { PlayCircleFilled } from '@ant-design/icons';
import { colors } from '@/styles/colors';
import CustomAvatar from '@components/CustomAvatar';
import { mockTimeLogs } from '@/pages/projects/project-view-1/taskList/taskListTable/taskListTableCells/mockTimeLogs';
import { mockTimeLogs } from '@/shared/mockTimeLogs';
type TaskListTimeTrackerCellProps = {
taskId: string | null;

View File

@@ -7,7 +7,7 @@ import logger from '@/utils/errorLogger';
import { formatDateTimeWithLocale } from '@/utils/format-date-time-with-locale';
import { formatDate } from '@/utils/timeUtils';
import { PlayCircleFilled } from '@ant-design/icons';
import { Flex, Button, Popover, Typography, Divider, Skeleton } from 'antd/es';
import { Flex, Button, Popover, Typography, Divider, Skeleton, Tooltip, Tag } from 'antd/es';
import React from 'react';
import { useState } from 'react';
@@ -17,6 +17,8 @@ interface TaskTimerProps {
handleStopTimer: () => void;
timeString: string;
taskId: string;
disabled?: boolean;
disabledTooltip?: string;
}
const TaskTimer = ({
@@ -25,6 +27,8 @@ const TaskTimer = ({
handleStopTimer,
timeString,
taskId,
disabled = false,
disabledTooltip,
}: TaskTimerProps) => {
const [timeLogs, setTimeLogs] = useState<ITaskLogViewModel[]>([]);
const [loading, setLoading] = useState(false);
@@ -69,32 +73,90 @@ const TaskTimer = ({
};
const timeTrackingLogCard = (
<Flex vertical style={{ width: '100%', maxWidth: 400, maxHeight: 350, overflowY: 'scroll' }}>
<Flex vertical style={{ width: '100%', maxWidth: 450, maxHeight: 350, overflowY: 'scroll' }}>
<Skeleton active loading={loading}>
{timeLogs.map(log => (
{timeLogs.map(log => {
// Check if this time log is from a subtask
const isFromSubtask = log.task_id && log.task_id !== taskId;
const formatTime = (timeString: string | undefined) => {
if (!timeString) return '';
try {
return new Date(timeString).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
hour12: true
});
} catch {
return timeString;
}
};
return (
<React.Fragment key={log.id}>
<Flex gap={12} align="center" wrap="wrap">
<Flex vertical gap={8} style={{ padding: '8px 0' }}>
<Flex gap={12} align="center">
<SingleAvatar avatarUrl={log.avatar_url} name={log.user_name} />
<Flex vertical style={{ flex: 1, minWidth: 0 }}>
<Typography style={{ fontSize: 15, wordBreak: 'break-word' }}>
<Typography.Text strong style={{ fontSize: 15 }}>
{log.user_name}&nbsp;
<Flex align="center" gap={8} wrap>
<Typography.Text strong style={{ fontSize: 14 }}>
{log.user_name}
</Typography.Text>
logged&nbsp;
<Typography.Text strong style={{ fontSize: 15 }}>
{formatTimeSpent(log.time_spent || 0)}
</Typography.Text>{' '}
{renderLoggedByTimer(log)}
{calculateTimeGap(log.created_at || '')}
</Typography>
{log.task_name && (
<Tag color={isFromSubtask ? "blue" : "default"} style={{ fontSize: '10px', margin: 0, padding: '0 4px' }}>
{log.task_name}
</Tag>
)}
{log.logged_by_timer && (
<Tag color="green" style={{ fontSize: '10px', margin: 0 }}>
Timer
</Tag>
)}
</Flex>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{formatDateTimeWithLocale(log.created_at || '')}
{calculateTimeGap(log.created_at || '')}
</Typography.Text>
</Flex>
</Flex>
<Divider style={{ marginBlock: 12 }} />
<Flex align="center" gap={12} style={{
backgroundColor: '#fafafa',
padding: '6px 8px',
borderRadius: 4,
fontSize: '11px'
}}>
<Flex vertical gap={2}>
<Typography.Text type="secondary" style={{ fontSize: '10px' }}>
Start
</Typography.Text>
<Typography.Text strong style={{ fontSize: '11px' }}>
{formatTime(log.start_time)}
</Typography.Text>
</Flex>
<Typography.Text type="secondary" style={{ fontSize: '10px' }}></Typography.Text>
<Flex vertical gap={2}>
<Typography.Text type="secondary" style={{ fontSize: '10px' }}>
End
</Typography.Text>
<Typography.Text strong style={{ fontSize: '11px' }}>
{formatTime(log.end_time)}
</Typography.Text>
</Flex>
<Divider type="vertical" style={{ height: '16px', margin: 0 }} />
<Flex vertical gap={2}>
<Typography.Text type="secondary" style={{ fontSize: '10px' }}>
Duration
</Typography.Text>
<Typography.Text strong style={{ color: '#1890ff', fontSize: '11px' }}>
{formatTimeSpent(log.time_spent || 0)}
</Typography.Text>
</Flex>
</Flex>
</Flex>
<Divider style={{ marginBlock: 8 }} />
</React.Fragment>
))}
);
})}
</Skeleton>
</Flex>
);
@@ -121,17 +183,45 @@ const TaskTimer = ({
}
};
return (
<Flex gap={4} align="center">
{started ? (
<Button type="text" icon={renderStopIcon()} onClick={handleStopTimer} />
const renderTimerButton = () => {
const button = started ? (
<Button
type="text"
icon={renderStopIcon()}
onClick={handleStopTimer}
disabled={disabled}
style={{
opacity: disabled ? 0.5 : 1,
cursor: disabled ? 'not-allowed' : 'pointer'
}}
/>
) : (
<Button
type="text"
icon={<PlayCircleFilled style={{ color: colors.skyBlue, fontSize: 16 }} />}
icon={<PlayCircleFilled style={{ color: disabled ? colors.lightGray : colors.skyBlue, fontSize: 16 }} />}
onClick={handleStartTimer}
disabled={disabled}
style={{
opacity: disabled ? 0.5 : 1,
cursor: disabled ? 'not-allowed' : 'pointer'
}}
/>
)}
);
if (disabled && disabledTooltip) {
return (
<Tooltip title={disabledTooltip}>
{button}
</Tooltip>
);
}
return button;
};
return (
<Flex gap={4} align="center">
{renderTimerButton()}
<Popover
title={
<Typography.Text style={{ fontWeight: 500 }}>

View File

@@ -0,0 +1,290 @@
import React, { useEffect, useState } from 'react';
import { Drawer, Typography, Spin } from 'antd';
import { useTranslation } from 'react-i18next';
import { useAppSelector } from '../../../hooks/useAppSelector';
import { useAppDispatch } from '../../../hooks/useAppDispatch';
import { themeWiseColor } from '../../../utils/themeWiseColor';
import { closeFinanceDrawer } from '../finance-slice';
import { projectFinanceApiService } from '../../../api/project-finance-ratecard/project-finance.api.service';
import { ITaskBreakdownResponse } from '../../../types/project/project-finance.types';
const FinanceDrawer = () => {
const [taskBreakdown, setTaskBreakdown] = useState<ITaskBreakdownResponse | null>(null);
const [loading, setLoading] = useState(false);
// Get task and drawer state from Redux store
const selectedTask = useAppSelector((state) => state.financeReducer.selectedTask);
const isDrawerOpen = useAppSelector((state) => state.financeReducer.isFinanceDrawerOpen);
useEffect(() => {
if (selectedTask?.id && isDrawerOpen) {
fetchTaskBreakdown(selectedTask.id);
} else {
setTaskBreakdown(null);
}
}, [selectedTask, isDrawerOpen]);
const fetchTaskBreakdown = async (taskId: string) => {
try {
setLoading(true);
const response = await projectFinanceApiService.getTaskBreakdown(taskId);
setTaskBreakdown(response.body);
} catch (error) {
console.error('Error fetching task breakdown:', error);
} finally {
setLoading(false);
}
};
// localization
const { t } = useTranslation('project-view-finance');
// get theme data from theme reducer
const themeMode = useAppSelector((state) => state.themeReducer.mode);
const dispatch = useAppDispatch();
// Get project currency from project finances, fallback to finance reducer currency
const projectCurrency = useAppSelector((state) => state.projectFinances.project?.currency);
const fallbackCurrency = useAppSelector((state) => state.financeReducer.currency);
const currency = (projectCurrency || fallbackCurrency || 'USD').toUpperCase();
// function handle drawer close
const handleClose = () => {
setTaskBreakdown(null);
dispatch(closeFinanceDrawer());
};
return (
<Drawer
title={
<Typography.Text style={{ fontWeight: 500, fontSize: 16 }}>
{taskBreakdown?.task?.name || selectedTask?.name || t('noTaskSelected')}
</Typography.Text>
}
open={isDrawerOpen}
onClose={handleClose}
destroyOnHidden={true}
width={640}
>
<div>
{loading ? (
<div style={{ textAlign: 'center', padding: '20px' }}>
<Spin size="large" />
</div>
) : (
<>
{/* Task Summary */}
{taskBreakdown?.task && (
<div style={{ marginBottom: 24, padding: 16, backgroundColor: themeWiseColor('#f9f9f9', '#1a1a1a', themeMode), borderRadius: 8 }}>
<Typography.Text strong style={{ fontSize: 16, display: 'block', marginBottom: 12 }}>
Task Overview
</Typography.Text>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: 16 }}>
<div>
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
Estimated Hours
</Typography.Text>
<Typography.Text strong style={{ fontSize: 16 }}>
{taskBreakdown.task.estimated_hours?.toFixed(2) || '0.00'}
</Typography.Text>
</div>
<div>
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
Total Logged Hours
</Typography.Text>
<Typography.Text strong style={{ fontSize: 16 }}>
{taskBreakdown.task.logged_hours?.toFixed(2) || '0.00'}
</Typography.Text>
</div>
<div>
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
Estimated Labor Cost ({currency})
</Typography.Text>
<Typography.Text strong style={{ fontSize: 16 }}>
{taskBreakdown.task.estimated_labor_cost?.toFixed(2) || '0.00'}
</Typography.Text>
</div>
<div>
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
Actual Labor Cost ({currency})
</Typography.Text>
<Typography.Text strong style={{ fontSize: 16 }}>
{taskBreakdown.task.actual_labor_cost?.toFixed(2) || '0.00'}
</Typography.Text>
</div>
<div>
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
Fixed Cost ({currency})
</Typography.Text>
<Typography.Text strong style={{ fontSize: 16 }}>
{taskBreakdown.task.fixed_cost?.toFixed(2) || '0.00'}
</Typography.Text>
</div>
<div>
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
Total Actual Cost ({currency})
</Typography.Text>
<Typography.Text strong style={{ fontSize: 16 }}>
{taskBreakdown.task.total_actual_cost?.toFixed(2) || '0.00'}
</Typography.Text>
</div>
</div>
</div>
)}
{/* Member Breakdown Table */}
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12 }}>
Member Time Logs & Costs
</Typography.Text>
<table
style={{
width: '100%',
borderCollapse: 'collapse',
marginBottom: '16px',
}}
>
<thead>
<tr
style={{
height: 48,
backgroundColor: themeWiseColor(
'#F5F5F5',
'#1d1d1d',
themeMode
),
}}
>
<th
style={{
textAlign: 'left',
padding: 8,
}}
>
Role / Member
</th>
<th
style={{
textAlign: 'right',
padding: 8,
}}
>
Logged Hours
</th>
<th
style={{
textAlign: 'right',
padding: 8,
}}
>
Hourly Rate ({currency})
</th>
<th
style={{
textAlign: 'right',
padding: 8,
}}
>
Actual Cost ({currency})
</th>
</tr>
</thead>
<tbody>
{taskBreakdown?.grouped_members?.map((group: any) => (
<React.Fragment key={group.jobRole}>
{/* Group Header */}
<tr
style={{
backgroundColor: themeWiseColor(
'#D9D9D9',
'#000',
themeMode
),
height: 56,
}}
className="border-b-[1px] font-semibold"
>
<td style={{ padding: 8, fontWeight: 'bold' }}>{group.jobRole}</td>
<td
style={{
textAlign: 'right',
padding: 8,
fontWeight: 'bold',
}}
>
{group.logged_hours?.toFixed(2) || '0.00'}
</td>
<td
style={{
textAlign: 'right',
padding: 8,
fontWeight: 'bold',
color: '#999',
}}
>
-
</td>
<td
style={{
textAlign: 'right',
padding: 8,
fontWeight: 'bold',
}}
>
{group.actual_cost?.toFixed(2) || '0.00'}
</td>
</tr>
{/* Member Rows */}
{group.members?.map((member: any, index: number) => (
<tr
key={`${group.jobRole}-${index}`}
className="border-b-[1px]"
style={{ height: 56 }}
>
<td
style={{
padding: 8,
paddingLeft: 32,
}}
>
{member.name}
</td>
<td
style={{
textAlign: 'right',
padding: 8,
}}
>
{member.logged_hours?.toFixed(2) || '0.00'}
</td>
<td
style={{
textAlign: 'right',
padding: 8,
}}
>
{member.hourly_rate?.toFixed(2) || '0.00'}
</td>
<td
style={{
textAlign: 'right',
padding: 8,
}}
>
{member.actual_cost?.toFixed(2) || '0.00'}
</td>
</tr>
))}
</React.Fragment>
))}
</tbody>
</table>
</>
)}
</div>
</Drawer>
);
};
export default FinanceDrawer;

View File

@@ -0,0 +1,200 @@
import { rateCardApiService } from '@/api/settings/rate-cards/rate-cards.api.service';
import { RatecardType } from '@/types/project/ratecard.types';
import logger from '@/utils/errorLogger';
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
type financeState = {
isRatecardDrawerOpen: boolean;
isFinanceDrawerOpen: boolean;
isImportRatecardsDrawerOpen: boolean;
currency: string;
isRatecardsLoading?: boolean;
isFinanceDrawerloading?: boolean;
drawerRatecard?: RatecardType | null;
ratecardsList?: RatecardType[] | null;
selectedTask?: any | null;
};
const initialState: financeState = {
isRatecardDrawerOpen: false,
isFinanceDrawerOpen: false,
isImportRatecardsDrawerOpen: false,
currency: 'USD',
isRatecardsLoading: false,
isFinanceDrawerloading: false,
drawerRatecard: null,
ratecardsList: null,
selectedTask: null,
};
interface FetchRateCardsParams {
index: number;
size: number;
field: string | null;
order: string | null;
search: string | null;
}
// Async thunks
export const fetchRateCards = createAsyncThunk(
'ratecards/fetchAll',
async (params: FetchRateCardsParams, { rejectWithValue }) => {
try {
const response = await rateCardApiService.getRateCards(
params.index,
params.size,
params.field,
params.order,
params.search
);
return response.body;
} catch (error) {
logger.error('Fetch RateCards', error);
if (error instanceof Error) {
return rejectWithValue(error.message);
}
return rejectWithValue('Failed to fetch rate cards');
}
}
);
export const fetchRateCardById = createAsyncThunk(
'ratecard/fetchById',
async (id: string, { rejectWithValue }) => {
try {
const response = await rateCardApiService.getRateCardById(id);
return response.body;
} catch (error) {
logger.error('Fetch RateCardById', error);
if (error instanceof Error) {
return rejectWithValue(error.message);
}
return rejectWithValue('Failed to fetch rate card');
}
}
);
export const createRateCard = createAsyncThunk(
'ratecards/create',
async (body: RatecardType, { rejectWithValue }) => {
try {
const response = await rateCardApiService.createRateCard(body);
return response.body;
} catch (error) {
logger.error('Create RateCard', error);
if (error instanceof Error) {
return rejectWithValue(error.message);
}
return rejectWithValue('Failed to create rate card');
}
}
);
export const updateRateCard = createAsyncThunk(
'ratecards/update',
async ({ id, body }: { id: string; body: RatecardType }, { rejectWithValue }) => {
try {
const response = await rateCardApiService.updateRateCard(id, body);
return response.body;
} catch (error) {
logger.error('Update RateCard', error);
if (error instanceof Error) {
return rejectWithValue(error.message);
}
return rejectWithValue('Failed to update rate card');
}
}
);
export const deleteRateCard = createAsyncThunk(
'ratecards/delete',
async (id: string, { rejectWithValue }) => {
try {
await rateCardApiService.deleteRateCard(id);
return id;
} catch (error) {
logger.error('Delete RateCard', error);
if (error instanceof Error) {
return rejectWithValue(error.message);
}
return rejectWithValue('Failed to delete rate card');
}
}
);
const financeSlice = createSlice({
name: 'financeReducer',
initialState,
reducers: {
toggleRatecardDrawer: (state) => {
state.isRatecardDrawerOpen = !state.isRatecardDrawerOpen;
},
toggleFinanceDrawer: (state) => {
state.isFinanceDrawerOpen = !state.isFinanceDrawerOpen;
},
openFinanceDrawer: (state, action: PayloadAction<any>) => {
state.isFinanceDrawerOpen = true;
state.selectedTask = action.payload;
},
closeFinanceDrawer: (state) => {
state.isFinanceDrawerOpen = false;
state.selectedTask = null;
},
setSelectedTask: (state, action: PayloadAction<any>) => {
state.selectedTask = action.payload;
},
toggleImportRatecardsDrawer: (state) => {
state.isImportRatecardsDrawerOpen = !state.isImportRatecardsDrawerOpen;
},
changeCurrency: (state, action: PayloadAction<string>) => {
state.currency = action.payload;
},
ratecardDrawerLoading: (state, action: PayloadAction<boolean>) => {
state.isFinanceDrawerloading = action.payload;
},
clearDrawerRatecard: (state) => {
state.drawerRatecard = null;
},
},
extraReducers: (builder) => {
builder
.addCase(fetchRateCards.pending, (state) => {
state.isRatecardsLoading = true;
})
.addCase(fetchRateCards.fulfilled, (state, action) => {
state.isRatecardsLoading = false;
state.ratecardsList = Array.isArray(action.payload.data)
? action.payload.data
: Array.isArray(action.payload)
? action.payload
: [];
})
.addCase(fetchRateCards.rejected, (state) => {
state.isRatecardsLoading = false;
state.ratecardsList = [];
})
.addCase(fetchRateCardById.pending, (state) => {
state.isFinanceDrawerloading = true;
state.drawerRatecard = null;
})
.addCase(fetchRateCardById.fulfilled, (state, action) => {
state.isFinanceDrawerloading = false;
state.drawerRatecard = action.payload;
})
.addCase(fetchRateCardById.rejected, (state) => {
state.isFinanceDrawerloading = false;
state.drawerRatecard = null;
});
},
});
export const {
toggleRatecardDrawer,
toggleFinanceDrawer,
openFinanceDrawer,
closeFinanceDrawer,
setSelectedTask,
toggleImportRatecardsDrawer,
changeCurrency,
ratecardDrawerLoading,
clearDrawerRatecard,
} = financeSlice.actions;
export default financeSlice.reducer;

View File

@@ -0,0 +1,271 @@
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
import { projectRateCardApiService, IProjectRateCardRole } from '@/api/project-finance-ratecard/project-finance-rate-cards.api.service';
import logger from '@/utils/errorLogger';
import { JobRoleType } from '@/types/project/ratecard.types';
type ProjectFinanceRateCardState = {
isDrawerOpen: boolean;
isLoading: boolean;
rateCardRoles: JobRoleType[] | null;
drawerRole: IProjectRateCardRole | null;
error?: string | null;
};
const initialState: ProjectFinanceRateCardState = {
isDrawerOpen: false,
isLoading: false,
rateCardRoles: null,
drawerRole: null,
error: null,
};
// Async thunks
export const fetchProjectRateCardRoles = createAsyncThunk(
'projectFinance/fetchAll',
async (project_id: string, { rejectWithValue }) => {
try {
const response = await projectRateCardApiService.getFromProjectId(project_id);
return response.body;
} catch (error) {
logger.error('Fetch Project RateCard Roles', error);
if (error instanceof Error) return rejectWithValue(error.message);
return rejectWithValue('Failed to fetch project rate card roles');
}
}
);
export const fetchProjectRateCardRoleById = createAsyncThunk(
'projectFinance/fetchById',
async (id: string, { rejectWithValue }) => {
try {
const response = await projectRateCardApiService.getFromId(id);
return response.body;
} catch (error) {
logger.error('Fetch Project RateCard Role By Id', error);
if (error instanceof Error) return rejectWithValue(error.message);
return rejectWithValue('Failed to fetch project rate card role');
}
}
);
export const insertProjectRateCardRoles = createAsyncThunk(
'projectFinance/insertMany',
async ({ project_id, roles }: { project_id: string; roles: Omit<IProjectRateCardRole, 'id' | 'project_id'>[] }, { rejectWithValue }) => {
try {
const response = await projectRateCardApiService.insertMany(project_id, roles);
return response.body;
} catch (error) {
logger.error('Insert Project RateCard Roles', error);
if (error instanceof Error) return rejectWithValue(error.message);
return rejectWithValue('Failed to insert project rate card roles');
}
}
);
export const insertProjectRateCardRole = createAsyncThunk(
'projectFinance/insertOne',
async (
{ project_id, job_title_id, rate }: { project_id: string; job_title_id: string; rate: number },
{ rejectWithValue }
) => {
try {
const response = await projectRateCardApiService.insertOne({ project_id, job_title_id, rate });
return response.body;
} catch (error) {
logger.error('Insert Project RateCard Role', error);
if (error instanceof Error) return rejectWithValue(error.message);
return rejectWithValue('Failed to insert project rate card role');
}
}
);
export const updateProjectRateCardRoleById = createAsyncThunk(
'projectFinance/updateById',
async ({ id, body }: { id: string; body: { job_title_id: string; rate: string } }, { rejectWithValue }) => {
try {
const response = await projectRateCardApiService.updateFromId(id, body);
return response.body;
} catch (error) {
logger.error('Update Project RateCard Role By Id', error);
if (error instanceof Error) return rejectWithValue(error.message);
return rejectWithValue('Failed to update project rate card role');
}
}
);
export const updateProjectRateCardRolesByProjectId = createAsyncThunk(
'projectFinance/updateByProjectId',
async ({ project_id, roles }: { project_id: string; roles: Omit<IProjectRateCardRole, 'id' | 'project_id'>[] }, { rejectWithValue }) => {
try {
const response = await projectRateCardApiService.updateFromProjectId(project_id, roles);
return response.body;
} catch (error) {
logger.error('Update Project RateCard Roles By ProjectId', error);
if (error instanceof Error) return rejectWithValue(error.message);
return rejectWithValue('Failed to update project rate card roles');
}
}
);
export const deleteProjectRateCardRoleById = createAsyncThunk(
'projectFinance/deleteById',
async (id: string, { rejectWithValue }) => {
try {
const response = await projectRateCardApiService.deleteFromId(id);
return response.body;
} catch (error) {
logger.error('Delete Project RateCard Role By Id', error);
if (error instanceof Error) return rejectWithValue(error.message);
return rejectWithValue('Failed to delete project rate card role');
}
}
);
export const assignMemberToRateCardRole = createAsyncThunk(
'projectFinance/assignMemberToRateCardRole',
async ({ project_id, member_id, project_rate_card_role_id }: { project_id: string; member_id: string; project_rate_card_role_id: string }) => {
const response = await projectRateCardApiService.updateMemberRateCardRole(project_id, member_id, project_rate_card_role_id);
return response.body;
}
);
export const deleteProjectRateCardRolesByProjectId = createAsyncThunk(
'projectFinance/deleteByProjectId',
async (project_id: string, { rejectWithValue }) => {
try {
const response = await projectRateCardApiService.deleteFromProjectId(project_id);
return response.body;
} catch (error) {
logger.error('Delete Project RateCard Roles By ProjectId', error);
if (error instanceof Error) return rejectWithValue(error.message);
return rejectWithValue('Failed to delete project rate card roles');
}
}
);
const projectFinanceSlice = createSlice({
name: 'projectFinanceRateCard',
initialState,
reducers: {
toggleDrawer: (state) => {
state.isDrawerOpen = !state.isDrawerOpen;
},
clearDrawerRole: (state) => {
state.drawerRole = null;
},
clearError: (state) => {
state.error = null;
},
},
extraReducers: (builder) => {
builder
// Fetch all
.addCase(fetchProjectRateCardRoles.pending, (state) => {
state.isLoading = true;
state.error = null;
})
.addCase(fetchProjectRateCardRoles.fulfilled, (state, action) => {
state.isLoading = false;
state.rateCardRoles = action.payload || [];
})
.addCase(fetchProjectRateCardRoles.rejected, (state, action) => {
state.isLoading = false;
state.error = action.payload as string;
state.rateCardRoles = [];
})
// Fetch by id
.addCase(fetchProjectRateCardRoleById.pending, (state) => {
state.isLoading = true;
state.drawerRole = null;
state.error = null;
})
.addCase(fetchProjectRateCardRoleById.fulfilled, (state, action) => {
state.isLoading = false;
state.drawerRole = action.payload || null;
})
.addCase(fetchProjectRateCardRoleById.rejected, (state, action) => {
state.isLoading = false;
state.error = action.payload as string;
state.drawerRole = null;
})
// Insert many
.addCase(insertProjectRateCardRoles.pending, (state) => {
state.isLoading = true;
state.error = null;
})
.addCase(insertProjectRateCardRoles.fulfilled, (state, action) => {
state.isLoading = false;
state.rateCardRoles = action.payload || [];
})
.addCase(insertProjectRateCardRoles.rejected, (state, action) => {
state.isLoading = false;
state.error = action.payload as string;
})
// Update by id
.addCase(updateProjectRateCardRoleById.pending, (state) => {
state.isLoading = true;
state.error = null;
})
.addCase(updateProjectRateCardRoleById.fulfilled, (state, action) => {
state.isLoading = false;
if (state.rateCardRoles && action.payload) {
state.rateCardRoles = state.rateCardRoles.map((role) =>
role.id === action.payload.id ? action.payload : role
);
}
})
.addCase(updateProjectRateCardRoleById.rejected, (state, action) => {
state.isLoading = false;
state.error = action.payload as string;
})
// Update by project id
.addCase(updateProjectRateCardRolesByProjectId.pending, (state) => {
state.isLoading = true;
state.error = null;
})
.addCase(updateProjectRateCardRolesByProjectId.fulfilled, (state, action) => {
state.isLoading = false;
state.rateCardRoles = action.payload || [];
})
.addCase(updateProjectRateCardRolesByProjectId.rejected, (state, action) => {
state.isLoading = false;
state.error = action.payload as string;
})
// Delete by id
.addCase(deleteProjectRateCardRoleById.pending, (state) => {
state.isLoading = true;
state.error = null;
})
.addCase(deleteProjectRateCardRoleById.fulfilled, (state, action) => {
state.isLoading = false;
if (state.rateCardRoles && action.payload) {
state.rateCardRoles = state.rateCardRoles.filter((role) => role.id !== action.payload.id);
}
})
.addCase(deleteProjectRateCardRoleById.rejected, (state, action) => {
state.isLoading = false;
state.error = action.payload as string;
})
// Delete by project id
.addCase(deleteProjectRateCardRolesByProjectId.pending, (state) => {
state.isLoading = true;
state.error = null;
})
.addCase(deleteProjectRateCardRolesByProjectId.fulfilled, (state) => {
state.isLoading = false;
state.rateCardRoles = [];
})
.addCase(deleteProjectRateCardRolesByProjectId.rejected, (state, action) => {
state.isLoading = false;
state.error = action.payload as string;
});
},
});
export const {
toggleDrawer,
clearDrawerRole,
clearError,
} = projectFinanceSlice.actions;
export default projectFinanceSlice.reducer;

View File

@@ -0,0 +1,175 @@
import { Drawer, Typography, Button, Table, Menu, Flex, Spin, Alert } from 'antd';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAppSelector } from '../../../hooks/useAppSelector';
import { useAppDispatch } from '../../../hooks/useAppDispatch';
import { fetchRateCards, toggleImportRatecardsDrawer } from '../finance-slice';
import { fetchRateCardById } from '../finance-slice';
import { insertProjectRateCardRoles } from '../project-finance-slice';
import { useParams } from 'react-router-dom';
const ImportRatecardsDrawer: React.FC = () => {
const dispatch = useAppDispatch();
const { projectId } = useParams();
const { t } = useTranslation('project-view-finance');
const drawerRatecard = useAppSelector(
(state) => state.financeReducer.drawerRatecard
);
const ratecardsList = useAppSelector(
(state) => state.financeReducer.ratecardsList || []
);
const isDrawerOpen = useAppSelector(
(state) => state.financeReducer.isImportRatecardsDrawerOpen
);
// Get project currency from project finances, fallback to finance reducer currency
const projectCurrency = useAppSelector((state) => state.projectFinances.project?.currency);
const fallbackCurrency = useAppSelector((state) => state.financeReducer.currency);
const currency = (projectCurrency || fallbackCurrency || 'USD').toUpperCase();
const rolesRedux = useAppSelector((state) => state.projectFinanceRateCard.rateCardRoles) || [];
// Loading states
const isRatecardsLoading = useAppSelector(
(state) => state.financeReducer.isRatecardsLoading
);
const [selectedRatecardId, setSelectedRatecardId] = useState<string | null>(null);
useEffect(() => {
if (selectedRatecardId) {
dispatch(fetchRateCardById(selectedRatecardId));
}
}, [selectedRatecardId, dispatch]);
useEffect(() => {
if (isDrawerOpen) {
dispatch(fetchRateCards({
index: 1,
size: 1000,
field: 'name',
order: 'asc',
search: '',
}));
}
}, [isDrawerOpen, dispatch]);
useEffect(() => {
if (ratecardsList.length > 0 && !selectedRatecardId) {
setSelectedRatecardId(ratecardsList[0].id || null);
}
}, [ratecardsList, selectedRatecardId]);
const columns = [
{
title: t('jobTitleColumn'),
dataIndex: 'jobtitle',
render: (text: string) => (
<Typography.Text className="group-hover:text-[#1890ff]">
{text}
</Typography.Text>
),
},
{
title: `${t('ratePerHourColumn')} (${currency})`,
dataIndex: 'rate',
render: (text: number) => <Typography.Text>{text}</Typography.Text>,
},
];
return (
<Drawer
title={
<Typography.Text style={{ fontWeight: 500, fontSize: 16 }}>
{t('ratecardsPluralText')}
</Typography.Text>
}
footer={
<div style={{ textAlign: 'right' }}>
{/* Alert message */}
{rolesRedux.length !== 0 ? (
<div style={{ textAlign: 'right' }}>
<Alert
message={t('alreadyImportedRateCardMessage') || 'A rate card has already been imported. Clear all imported rate cards to add a new one.'}
type="warning"
showIcon
style={{ marginBottom: 16 }}
/>
</div>
) : (
<div style={{ textAlign: 'right' }}>
<Button
type="primary"
onClick={() => {
if (!projectId) {
// Handle missing project id (show error, etc.)
return;
}
if (drawerRatecard?.jobRolesList?.length) {
dispatch(
insertProjectRateCardRoles({
project_id: projectId,
roles: drawerRatecard.jobRolesList
.filter((role) => typeof role.rate !== 'undefined')
.map((role) => ({
...role,
rate: Number(role.rate),
})),
})
);
}
dispatch(toggleImportRatecardsDrawer());
}}
>
{t('import')}
</Button>
</div>
)}
</div>
}
open={isDrawerOpen}
onClose={() => dispatch(toggleImportRatecardsDrawer())}
width={1000}
>
<Flex gap={12}>
{/* Sidebar menu with loading */}
<Spin spinning={isRatecardsLoading} style={{ width: '20%' }}>
<Menu
mode="vertical"
style={{ width: '100%' }}
selectedKeys={
selectedRatecardId
? [selectedRatecardId]
: ratecardsList[0]?.id
? [ratecardsList[0].id]
: []
}
onClick={({ key }) => setSelectedRatecardId(key)}
>
{ratecardsList.map((ratecard) => (
<Menu.Item key={ratecard.id}>
{ratecard.name}
</Menu.Item>
))}
</Menu>
</Spin>
{/* Table for job roles with loading */}
<Table
style={{ flex: 1 }}
dataSource={drawerRatecard?.jobRolesList || []}
columns={columns}
rowKey={(record) => record.job_title_id}
onRow={() => ({
className: 'group',
style: { cursor: 'pointer' },
})}
pagination={false}
loading={isRatecardsLoading}
/>
</Flex>
</Drawer>
);
};
export default ImportRatecardsDrawer;

View File

@@ -0,0 +1,530 @@
import { Drawer, Select, Typography, Flex, Button, Input, Table, Tooltip, Alert, Space, message, Popconfirm } from 'antd';
import React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAppSelector } from '../../../hooks/useAppSelector';
import { useAppDispatch } from '../../../hooks/useAppDispatch';
import { deleteRateCard, fetchRateCardById, fetchRateCards, toggleRatecardDrawer, updateRateCard } from '../finance-slice';
import { RatecardType, IJobType } from '@/types/project/ratecard.types';
import { IJobTitlesViewModel } from '@/types/job.types';
import { jobTitlesApiService } from '@/api/settings/job-titles/job-titles.api.service';
import { DeleteOutlined, ExclamationCircleFilled, PlusOutlined } from '@ant-design/icons';
import { colors } from '@/styles/colors';
import CreateJobTitlesDrawer from '@/features/settings/job/CreateJobTitlesDrawer';
import { toggleCreateJobTitleDrawer } from '@/features/settings/job/jobSlice';
import { CURRENCY_OPTIONS, DEFAULT_CURRENCY } from '@/shared/constants/currencies';
interface PaginationType {
current: number;
pageSize: number;
field: string;
order: string;
total: number;
pageSizeOptions: string[];
size: 'small' | 'default';
}
const RatecardDrawer = ({
type,
ratecardId,
onSaved,
}: {
type: 'create' | 'update';
ratecardId: string;
onSaved?: () => void;
}) => {
const [ratecardsList, setRatecardsList] = useState<RatecardType[]>([]);
const [roles, setRoles] = useState<IJobType[]>([]);
const [initialRoles, setInitialRoles] = useState<IJobType[]>([]);
const [initialName, setInitialName] = useState<string>('Untitled Rate Card');
const [initialCurrency, setInitialCurrency] = useState<string>(DEFAULT_CURRENCY);
const [addingRowIndex, setAddingRowIndex] = useState<number | null>(null);
const { t } = useTranslation('settings/ratecard-settings');
const drawerLoading = useAppSelector(state => state.financeReducer.isFinanceDrawerloading);
const drawerRatecard = useAppSelector(state => state.financeReducer.drawerRatecard);
const isDrawerOpen = useAppSelector(
(state) => state.financeReducer.isRatecardDrawerOpen
);
const dispatch = useAppDispatch();
const [isAddingRole, setIsAddingRole] = useState(false);
const [selectedJobTitleId, setSelectedJobTitleId] = useState<string | undefined>(undefined);
const [searchQuery, setSearchQuery] = useState('');
const [currency, setCurrency] = useState(DEFAULT_CURRENCY);
const [name, setName] = useState<string>('Untitled Rate Card');
const [jobTitles, setJobTitles] = useState<IJobTitlesViewModel>({});
const [pagination, setPagination] = useState<PaginationType>({
current: 1,
pageSize: 10000,
field: 'name',
order: 'desc',
total: 0,
pageSizeOptions: ['5', '10', '15', '20', '50', '100'],
size: 'small',
});
const [editingRowIndex, setEditingRowIndex] = useState<number | null>(null);
const [showUnsavedAlert, setShowUnsavedAlert] = useState(false);
const [messageApi, contextHolder] = message.useMessage();
const [isCreatingJobTitle, setIsCreatingJobTitle] = useState(false);
const [newJobTitleName, setNewJobTitleName] = useState('');
// Detect changes
const hasChanges = useMemo(() => {
const rolesChanged = JSON.stringify(roles) !== JSON.stringify(initialRoles);
const nameChanged = name !== initialName;
const currencyChanged = currency !== initialCurrency;
return rolesChanged || nameChanged || currencyChanged;
}, [roles, name, currency, initialRoles, initialName, initialCurrency]);
const getJobTitles = useMemo(() => {
return async () => {
const response = await jobTitlesApiService.getJobTitles(
pagination.current,
pagination.pageSize,
pagination.field,
pagination.order,
searchQuery
);
if (response.done) {
setJobTitles(response.body);
setPagination(prev => ({ ...prev, total: response.body.total || 0 }));
}
};
}, [pagination.current, pagination.pageSize, pagination.field, pagination.order, searchQuery]);
useEffect(() => {
getJobTitles();
}, []);
const selectedRatecard = ratecardsList.find(
(ratecard) => ratecard.id === ratecardId
);
useEffect(() => {
if (type === 'update' && ratecardId) {
dispatch(fetchRateCardById(ratecardId));
}
}, [type, ratecardId, dispatch]);
useEffect(() => {
if (type === 'update' && drawerRatecard) {
setRoles(drawerRatecard.jobRolesList || []);
setInitialRoles(drawerRatecard.jobRolesList || []);
setName(drawerRatecard.name || '');
setInitialName(drawerRatecard.name || '');
setCurrency(drawerRatecard.currency || DEFAULT_CURRENCY);
setInitialCurrency(drawerRatecard.currency || DEFAULT_CURRENCY);
}
}, [drawerRatecard, type]);
const handleAddAllRoles = () => {
if (!jobTitles.data) return;
const existingIds = new Set(roles.map(r => r.job_title_id));
const newRoles = jobTitles.data
.filter(jt => jt.id && !existingIds.has(jt.id))
.map(jt => ({
jobtitle: jt.name,
rate_card_id: ratecardId,
job_title_id: jt.id!,
rate: 0,
}));
const mergedRoles = [...roles, ...newRoles].filter(
(role, idx, arr) =>
arr.findIndex(r => r.job_title_id === role.job_title_id) === idx
);
setRoles(mergedRoles);
};
const handleAddRole = () => {
if (Object.keys(jobTitles).length === 0) {
// Allow inline job title creation
setIsCreatingJobTitle(true);
} else {
// Add a new empty role to the table
const newRole = {
jobtitle: '',
rate_card_id: ratecardId,
job_title_id: '',
rate: 0,
};
setRoles([...roles, newRole]);
setAddingRowIndex(roles.length);
setIsAddingRole(true);
}
};
const handleCreateJobTitle = async () => {
if (!newJobTitleName.trim()) {
messageApi.warning(t('jobTitleNameRequired') || 'Job title name is required');
return;
}
try {
// Create the job title using the API
const response = await jobTitlesApiService.createJobTitle({
name: newJobTitleName.trim()
});
if (response.done) {
// Refresh job titles
await getJobTitles();
// Create a new role with the newly created job title
const newRole = {
jobtitle: newJobTitleName.trim(),
rate_card_id: ratecardId,
job_title_id: response.body.id,
rate: 0,
};
setRoles([...roles, newRole]);
// Reset creation state
setIsCreatingJobTitle(false);
setNewJobTitleName('');
messageApi.success(t('jobTitleCreatedSuccess') || 'Job title created successfully');
} else {
messageApi.error(t('jobTitleCreateError') || 'Failed to create job title');
}
} catch (error) {
console.error('Failed to create job title:', error);
messageApi.error(t('jobTitleCreateError') || 'Failed to create job title');
}
};
const handleCancelJobTitleCreation = () => {
setIsCreatingJobTitle(false);
setNewJobTitleName('');
};
const handleDeleteRole = (index: number) => {
const updatedRoles = [...roles];
updatedRoles.splice(index, 1);
setRoles(updatedRoles);
};
const handleSelectJobTitle = (jobTitleId: string) => {
if (roles.some(role => role.job_title_id === jobTitleId)) {
setIsAddingRole(false);
setSelectedJobTitleId(undefined);
return;
}
const jobTitle = jobTitles.data?.find(jt => jt.id === jobTitleId);
if (jobTitle) {
const newRole = {
jobtitle: jobTitle.name,
rate_card_id: ratecardId,
job_title_id: jobTitleId,
rate: 0,
};
setRoles([...roles, newRole]);
}
setIsAddingRole(false);
setSelectedJobTitleId(undefined);
};
const handleSave = async () => {
if (type === 'update' && ratecardId) {
try {
const filteredRoles = roles.filter(role => role.jobtitle && role.jobtitle.trim() !== '');
await dispatch(updateRateCard({
id: ratecardId,
body: {
name,
currency,
jobRolesList: filteredRoles,
},
}) as any);
await dispatch(fetchRateCards({
index: 1,
size: 10,
field: 'name',
order: 'desc',
search: '',
}) as any);
if (onSaved) onSaved();
dispatch(toggleRatecardDrawer());
// Reset initial states after save
setInitialRoles(filteredRoles);
setInitialName(name);
setInitialCurrency(currency);
setShowUnsavedAlert(false);
} catch (error) {
console.error('Failed to update rate card', error);
} finally {
setRoles([]);
setName('Untitled Rate Card');
setCurrency(DEFAULT_CURRENCY);
setInitialRoles([]);
setInitialName('Untitled Rate Card');
setInitialCurrency(DEFAULT_CURRENCY);
}
}
};
const columns = [
{
title: t('jobTitleColumn'),
dataIndex: 'jobtitle',
render: (text: string, record: any, index: number) => {
if (index === addingRowIndex || index === editingRowIndex) {
return (
<Select
showSearch
autoFocus
placeholder={t('selectJobTitle')}
style={{ minWidth: 150 }}
value={record.job_title_id || undefined}
onChange={value => {
if (roles.some((role, idx) => role.job_title_id === value && idx !== index)) {
return;
}
const updatedRoles = [...roles];
const selectedJob = jobTitles.data?.find(jt => jt.id === value);
updatedRoles[index].job_title_id = value;
updatedRoles[index].jobtitle = selectedJob?.name || '';
setRoles(updatedRoles);
setEditingRowIndex(null);
setAddingRowIndex(null);
}}
onBlur={() => {
if (roles[index].job_title_id === "") {
handleDeleteRole(index);
}
setEditingRowIndex(null);
setAddingRowIndex(null);
}}
filterOption={(input, option) =>
(option?.children as string).toLowerCase().includes(input.toLowerCase())
}
>
{jobTitles.data
?.filter(jt => !roles.some((role, idx) => role.job_title_id === jt.id && idx !== index))
.map(jt => (
<Select.Option key={jt.id} value={jt.id}>
{jt.name}
</Select.Option>
))}
</Select>
);
}
return (
<span
style={{ cursor: 'pointer' }}
>
{record.jobtitle}
</span>
);
},
},
{
title: `${t('ratePerHourColumn')} (${currency})`,
dataIndex: 'rate',
align: 'right',
render: (text: number, record: any, index: number) => (
<Input
type="number"
value={roles[index]?.rate ?? 0}
min={0}
style={{
background: 'transparent',
border: 'none',
boxShadow: 'none',
textAlign: 'right',
padding: 0,
}}
onChange={(e) => {
const updatedRoles = roles.map((role, idx) =>
idx === index ? { ...role, rate: parseInt(e.target.value, 10) || 0 } : role
);
setRoles(updatedRoles);
}}
/>
),
},
{
title: t('actionsColumn') || 'Actions',
dataIndex: 'actions',
render: (_: any, __: any, index: number) => (
<Popconfirm
title={t('deleteConfirmationTitle')}
icon={<ExclamationCircleFilled style={{ color: colors.vibrantOrange }} />}
okText={t('deleteConfirmationOk')}
cancelText={t('deleteConfirmationCancel')}
onConfirm={async () => {
handleDeleteRole(index);
}}
>
<Tooltip title="Delete">
<Button
size="small"
icon={<DeleteOutlined />}
/>
</Tooltip>
</Popconfirm>
),
},
];
const handleDrawerClose = async() => {
if (!name || name.trim() === '') {
messageApi.open({
type: 'warning',
content: t('ratecardNameRequired') || 'Rate card name is required.',
});
return;
} else if (hasChanges) {
setShowUnsavedAlert(true);
}
else if (name === 'Untitled Rate Card' && roles.length === 0){
await dispatch(deleteRateCard(ratecardId));
dispatch(toggleRatecardDrawer());
}
else {
dispatch(toggleRatecardDrawer());
}
};
const handleConfirmSave = async () => {
await handleSave();
setShowUnsavedAlert(false);
};
const handleConfirmDiscard = () => {
dispatch(toggleRatecardDrawer());
setRoles([]);
setName('Untitled Rate Card');
setCurrency(DEFAULT_CURRENCY);
setInitialRoles([]);
setInitialName('Untitled Rate Card');
setInitialCurrency(DEFAULT_CURRENCY);
setShowUnsavedAlert(false);
};
return (
<>
{contextHolder}
<Drawer
loading={drawerLoading}
onClose={handleDrawerClose}
title={
<Flex align="center" justify="space-between">
<Typography.Text style={{ fontWeight: 500, fontSize: 16 }}>
<Input
value={name}
placeholder={t('ratecardNamePlaceholder')}
style={{
fontWeight: 500,
fontSize: 16,
background: 'transparent',
border: 'none',
boxShadow: 'none',
padding: 0,
}}
onChange={e => {
setName(e.target.value);
}}
/>
</Typography.Text>
<Flex gap={8} align="center">
<Typography.Text>{t('currency')}</Typography.Text>
<Select
value={currency}
options={CURRENCY_OPTIONS}
onChange={(value) => setCurrency(value)}
/>
<Button onClick={handleAddAllRoles} type="default">
{t('addAllButton')}
</Button>
</Flex>
</Flex>
}
open={isDrawerOpen}
width={700}
footer={
<Flex justify="end" gap={16} style={{ marginTop: 16 }}>
<Button style={{ marginBottom: 24 }} onClick={handleSave} type="primary" disabled={name === '' || (name === 'Untitled Rate Card' && roles.length === 0)}>
{t('saveButton')}
</Button>
</Flex>
}
>
{showUnsavedAlert && (
<Alert
message={t('unsavedChangesTitle') || 'Unsaved Changes'}
type="warning"
showIcon
closable
onClose={() => setShowUnsavedAlert(false)}
action={
<Space direction="horizontal">
<Button size="small" type="primary" onClick={handleConfirmSave}>
Save
</Button>
<Button size="small" danger onClick={handleConfirmDiscard}>
Discard
</Button>
</Space>
}
style={{ marginBottom: 16 }}
/>
)}
<Flex vertical gap={16}>
<Flex justify="space-between" align="center">
<Typography.Title level={5} style={{ margin: 0 }}>
{t('jobRolesTitle') || 'Job Roles'}
</Typography.Title>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={handleAddRole}
>
{t('addRoleButton')}
</Button>
</Flex>
<Table
dataSource={roles}
columns={columns}
rowKey="job_title_id"
pagination={false}
locale={{
emptyText: isCreatingJobTitle ? (
<Flex vertical align="center" gap={16} style={{ padding: '24px 0' }}>
<Typography.Text strong>
{t('createNewJobTitle') || 'Create New Job Title'}
</Typography.Text>
<Flex gap={8} align="center">
<Input
placeholder={t('jobTitleNamePlaceholder') || 'Enter job title name'}
value={newJobTitleName}
onChange={(e) => setNewJobTitleName(e.target.value)}
onPressEnter={handleCreateJobTitle}
autoFocus
style={{ width: 200 }}
/>
<Button type="primary" onClick={handleCreateJobTitle}>
{t('createButton') || 'Create'}
</Button>
<Button onClick={handleCancelJobTitleCreation}>
{t('cancelButton') || 'Cancel'}
</Button>
</Flex>
</Flex>
) : (
<Flex vertical align="center" gap={16} style={{ padding: '24px 0' }}>
<Typography.Text type="secondary">
{Object.keys(jobTitles).length === 0
? t('noJobTitlesAvailable')
: t('noRolesAdded')}
</Typography.Text>
</Flex>
),
}}
/>
</Flex>
</Drawer>
<CreateJobTitlesDrawer />
</>
);
};
export default RatecardDrawer;

View File

@@ -1,7 +1,7 @@
export type NavRoutesType = {
name: string;
path: string;
adminOnly: boolean;
adminOnly?: boolean;
freePlanFeature?: boolean;
};

View File

@@ -27,7 +27,7 @@ interface TaskListState {
error: string | null;
importTaskTemplateDrawerOpen: boolean;
createTaskTemplateDrawerOpen: boolean;
projectView: 'list' | 'kanban';
projectView: 'list' | 'kanban' | 'gantt';
refreshTimestamp: string | null;
}
@@ -35,9 +35,9 @@ const initialState: TaskListState = {
projectId: null,
project: null,
projectLoading: false,
activeMembers: [],
columns: [],
members: [],
activeMembers: [],
labels: [],
statuses: [],
priorities: [],
@@ -116,6 +116,11 @@ const projectSlice = createSlice({
state.project.phase_label = action.payload;
}
},
updateProjectCurrency: (state, action: PayloadAction<string>) => {
if (state.project) {
state.project.currency = action.payload;
}
},
addTask: (
state,
action: PayloadAction<{ task: IProjectTask; groupId: string; insert?: boolean }>
@@ -173,7 +178,7 @@ const projectSlice = createSlice({
setRefreshTimestamp: (state) => {
state.refreshTimestamp = new Date().getTime().toString();
},
setProjectView: (state, action: PayloadAction<'list' | 'kanban'>) => {
setProjectView: (state, action: PayloadAction<'list' | 'kanban' | 'gantt'>) => {
state.projectView = action.payload;
},
},
@@ -214,7 +219,8 @@ export const {
setCreateTaskTemplateDrawerOpen,
setProjectView,
updatePhaseLabel,
setRefreshTimestamp
setRefreshTimestamp,
updateProjectCurrency
} = projectSlice.actions;
export default projectSlice.reducer;

View File

@@ -0,0 +1,83 @@
# Optimized Finance Calculation System
## Overview
This system provides efficient frontend recalculation of project finance data when fixed costs are updated, eliminating the need for API refetches and ensuring optimal performance even with deeply nested task hierarchies.
## Key Features
### 1. Hierarchical Recalculation
- When a nested subtask's fixed cost is updated, all parent tasks are automatically recalculated
- Parent task totals are aggregated from their subtasks to avoid double counting
- Calculations propagate up the entire task hierarchy efficiently
### 2. Performance Optimizations
- **Memoization**: Task calculations are cached to avoid redundant computations
- **Smart Cache Management**: Cache entries expire automatically and are cleaned up periodically
- **Selective Updates**: Only tasks that have actually changed trigger recalculations
### 3. Frontend-Only Updates
- No API refetches required for fixed cost updates
- Immediate UI responsiveness
- Reduced server load and network traffic
## How It Works
### Task Update Flow
1. User updates fixed cost in UI
2. `updateTaskFixedCostAsync` is dispatched
3. API call updates the backend
4. Redux reducer updates the task and triggers `recalculateTaskHierarchy`
5. All parent tasks are recalculated automatically
6. UI updates immediately with new values
### Calculation Logic
```typescript
// For parent tasks with subtasks
parentTask.fixed_cost = sum(subtask.fixed_cost)
parentTask.total_budget = parentTask.estimated_cost + parentTask.fixed_cost
parentTask.variance = parentTask.total_actual - parentTask.total_budget
// For leaf tasks
task.total_budget = task.estimated_cost + task.fixed_cost
task.variance = task.total_actual - task.total_budget
```
### Memoization Strategy
- Cache key includes all relevant financial fields
- Cache entries expire after 10 minutes
- Cache is cleared when fresh data is loaded from API
- Automatic cleanup prevents memory leaks
## Usage Examples
### Updating Fixed Cost
```typescript
// This will automatically recalculate all parent tasks
dispatch(updateTaskFixedCostAsync({
taskId: 'subtask-123',
groupId: 'group-456',
fixedCost: 1500
}));
```
### Budget Statistics
The budget statistics in the project overview are calculated efficiently:
- Avoids double counting in nested hierarchies
- Uses aggregated values from parent tasks
- Updates automatically when any task changes
## Performance Benefits
1. **Reduced API Calls**: No refetching required for fixed cost updates
2. **Faster UI Updates**: Immediate recalculation and display
3. **Memory Efficient**: Smart caching with automatic cleanup
4. **Scalable**: Handles deeply nested task hierarchies efficiently
## Cache Management
The system includes automatic cache management:
- Cache cleanup every 5 minutes
- Entries expire after 10 minutes
- Manual cache clearing when fresh data is loaded
- Memory-efficient with automatic garbage collection

View File

@@ -0,0 +1,482 @@
import { projectFinanceApiService } from '@/api/project-finance-ratecard/project-finance.api.service';
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
import { IProjectFinanceGroup, IProjectFinanceTask, IProjectRateCard, IProjectFinanceProject } from '@/types/project/project-finance.types';
import { parseTimeToSeconds } from '@/utils/timeUtils';
type FinanceTabType = 'finance' | 'ratecard';
type GroupTypes = 'status' | 'priority' | 'phases';
type BillableFilterType = 'all' | 'billable' | 'non-billable';
interface ProjectFinanceState {
activeTab: FinanceTabType;
activeGroup: GroupTypes;
billableFilter: BillableFilterType;
loading: boolean;
taskGroups: IProjectFinanceGroup[];
projectRateCards: IProjectRateCard[];
project: IProjectFinanceProject | null;
}
// Enhanced utility functions for efficient frontend calculations
const secondsToHours = (seconds: number) => seconds / 3600;
const calculateTaskCosts = (task: IProjectFinanceTask) => {
const hours = secondsToHours(task.estimated_seconds || 0);
const timeLoggedHours = secondsToHours(task.total_time_logged_seconds || 0);
const totalBudget = task.estimated_cost || 0;
// task.total_actual already includes actual_cost_from_logs + fixed_cost from backend
const totalActual = task.total_actual || 0;
const variance = totalActual - totalBudget;
return {
hours,
timeLoggedHours,
totalBudget,
totalActual,
variance
};
};
// Memoization cache for task calculations to improve performance
const taskCalculationCache = new Map<string, {
task: IProjectFinanceTask;
result: IProjectFinanceTask;
timestamp: number;
}>();
// Cache cleanup interval (5 minutes)
const CACHE_CLEANUP_INTERVAL = 5 * 60 * 1000;
const CACHE_MAX_AGE = 10 * 60 * 1000; // 10 minutes
// Periodic cache cleanup
setInterval(() => {
const now = Date.now();
Array.from(taskCalculationCache.entries()).forEach(([key, value]) => {
if (now - value.timestamp > CACHE_MAX_AGE) {
taskCalculationCache.delete(key);
}
});
}, CACHE_CLEANUP_INTERVAL);
// Generate cache key for task
const generateTaskCacheKey = (task: IProjectFinanceTask): string => {
return `${task.id}-${task.estimated_cost}-${task.fixed_cost}-${task.total_actual}-${task.estimated_seconds}-${task.total_time_logged_seconds}`;
};
// Check if task has changed significantly to warrant recalculation
const hasTaskChanged = (oldTask: IProjectFinanceTask, newTask: IProjectFinanceTask): boolean => {
return (
oldTask.estimated_cost !== newTask.estimated_cost ||
oldTask.fixed_cost !== newTask.fixed_cost ||
oldTask.total_actual !== newTask.total_actual ||
oldTask.estimated_seconds !== newTask.estimated_seconds ||
oldTask.total_time_logged_seconds !== newTask.total_time_logged_seconds
);
};
// Optimized recursive calculation for task hierarchy with memoization
const recalculateTaskHierarchy = (tasks: IProjectFinanceTask[]): IProjectFinanceTask[] => {
return tasks.map(task => {
// If task has loaded subtasks, recalculate from subtasks
if (task.sub_tasks && task.sub_tasks.length > 0) {
const updatedSubTasks = recalculateTaskHierarchy(task.sub_tasks);
// Calculate totals from subtasks only (for time and costs from logs)
const subtaskTotals = updatedSubTasks.reduce((acc, subtask) => ({
estimated_cost: acc.estimated_cost + (subtask.estimated_cost || 0),
fixed_cost: acc.fixed_cost + (subtask.fixed_cost || 0),
actual_cost_from_logs: acc.actual_cost_from_logs + (subtask.actual_cost_from_logs || 0),
estimated_seconds: acc.estimated_seconds + (subtask.estimated_seconds || 0),
total_time_logged_seconds: acc.total_time_logged_seconds + (subtask.total_time_logged_seconds || 0)
}), {
estimated_cost: 0,
fixed_cost: 0,
actual_cost_from_logs: 0,
estimated_seconds: 0,
total_time_logged_seconds: 0
});
// For parent tasks with loaded subtasks: use ONLY the subtask totals
// The parent's original values were backend-aggregated, now we use frontend subtask aggregation
const totalFixedCost = subtaskTotals.fixed_cost; // Only subtask fixed costs
const totalEstimatedCost = subtaskTotals.estimated_cost; // Only subtask estimated costs
const totalActualCostFromLogs = subtaskTotals.actual_cost_from_logs; // Only subtask logged costs
const totalActual = totalActualCostFromLogs + totalFixedCost;
// Update parent task with aggregated values
const updatedTask = {
...task,
sub_tasks: updatedSubTasks,
estimated_cost: totalEstimatedCost,
fixed_cost: totalFixedCost,
actual_cost_from_logs: totalActualCostFromLogs,
total_actual: totalActual,
estimated_seconds: subtaskTotals.estimated_seconds,
total_time_logged_seconds: subtaskTotals.total_time_logged_seconds,
total_budget: totalEstimatedCost,
variance: totalActual - totalEstimatedCost
};
return updatedTask;
}
// For parent tasks without loaded subtasks, trust backend-calculated values
if (task.sub_tasks_count > 0 && (!task.sub_tasks || task.sub_tasks.length === 0)) {
// Parent task with unloaded subtasks - backend has already calculated aggregated values
const { totalBudget, totalActual, variance } = calculateTaskCosts(task);
return {
...task,
total_budget: totalBudget,
total_actual: totalActual,
variance: variance
};
}
// For leaf tasks, check cache first
const cacheKey = generateTaskCacheKey(task);
const cached = taskCalculationCache.get(cacheKey);
if (cached && !hasTaskChanged(cached.task, task)) {
return { ...cached.result, ...task }; // Merge with current task to preserve other properties
}
// For leaf tasks, just recalculate their own values
const { totalBudget, totalActual, variance } = calculateTaskCosts(task);
const updatedTask = {
...task,
total_budget: totalBudget,
total_actual: totalActual,
variance: variance
};
// Cache the result only for leaf tasks
taskCalculationCache.set(cacheKey, {
task: { ...task },
result: updatedTask,
timestamp: Date.now()
});
return updatedTask;
});
};
// Optimized function to find and update a specific task, then recalculate hierarchy
const updateTaskAndRecalculateHierarchy = (
tasks: IProjectFinanceTask[],
targetId: string,
updateFn: (task: IProjectFinanceTask) => IProjectFinanceTask
): { updated: boolean; tasks: IProjectFinanceTask[] } => {
let updated = false;
const updatedTasks = tasks.map(task => {
if (task.id === targetId) {
updated = true;
return updateFn(task);
}
// Search in subtasks recursively
if (task.sub_tasks && task.sub_tasks.length > 0) {
const result = updateTaskAndRecalculateHierarchy(task.sub_tasks, targetId, updateFn);
if (result.updated) {
updated = true;
return {
...task,
sub_tasks: result.tasks
};
}
}
return task;
});
// If a task was updated, recalculate the entire hierarchy to ensure parent totals are correct
return {
updated,
tasks: updated ? recalculateTaskHierarchy(updatedTasks) : updatedTasks
};
};
const initialState: ProjectFinanceState = {
activeTab: 'finance',
activeGroup: 'status',
billableFilter: 'billable',
loading: false,
taskGroups: [],
projectRateCards: [],
project: null,
};
export const fetchProjectFinances = createAsyncThunk(
'projectFinances/fetchProjectFinances',
async ({ projectId, groupBy, billableFilter }: { projectId: string; groupBy: GroupTypes; billableFilter?: BillableFilterType }) => {
const response = await projectFinanceApiService.getProjectTasks(projectId, groupBy, billableFilter);
return response.body;
}
);
export const fetchProjectFinancesSilent = createAsyncThunk(
'projectFinances/fetchProjectFinancesSilent',
async ({ projectId, groupBy, billableFilter }: { projectId: string; groupBy: GroupTypes; billableFilter?: BillableFilterType }) => {
const response = await projectFinanceApiService.getProjectTasks(projectId, groupBy, billableFilter);
return response.body;
}
);
export const fetchSubTasks = createAsyncThunk(
'projectFinances/fetchSubTasks',
async ({ projectId, parentTaskId, billableFilter }: { projectId: string; parentTaskId: string; billableFilter?: BillableFilterType }) => {
const response = await projectFinanceApiService.getSubTasks(projectId, parentTaskId, billableFilter);
return { parentTaskId, subTasks: response.body };
}
);
export const updateTaskFixedCostAsync = createAsyncThunk(
'projectFinances/updateTaskFixedCostAsync',
async ({ taskId, groupId, fixedCost }: { taskId: string; groupId: string; fixedCost: number }) => {
await projectFinanceApiService.updateTaskFixedCost(taskId, fixedCost);
return { taskId, groupId, fixedCost };
}
);
// Function to clear calculation cache (useful for testing or when data is refreshed)
const clearCalculationCache = () => {
taskCalculationCache.clear();
};
export const projectFinancesSlice = createSlice({
name: 'projectFinances',
initialState,
reducers: {
setActiveTab: (state, action: PayloadAction<FinanceTabType>) => {
state.activeTab = action.payload;
},
setActiveGroup: (state, action: PayloadAction<GroupTypes>) => {
state.activeGroup = action.payload;
},
setBillableFilter: (state, action: PayloadAction<BillableFilterType>) => {
state.billableFilter = action.payload;
},
updateTaskFixedCost: (state, action: PayloadAction<{ taskId: string; groupId: string; fixedCost: number }>) => {
const { taskId, groupId, fixedCost } = action.payload;
const group = state.taskGroups.find(g => g.group_id === groupId);
if (group) {
const result = updateTaskAndRecalculateHierarchy(
group.tasks,
taskId,
(task) => ({
...task,
fixed_cost: fixedCost
})
);
if (result.updated) {
group.tasks = result.tasks;
}
}
},
updateTaskEstimatedCost: (state, action: PayloadAction<{ taskId: string; groupId: string; estimatedCost: number }>) => {
const { taskId, groupId, estimatedCost } = action.payload;
const group = state.taskGroups.find(g => g.group_id === groupId);
if (group) {
const result = updateTaskAndRecalculateHierarchy(
group.tasks,
taskId,
(task) => ({
...task,
estimated_cost: estimatedCost
})
);
if (result.updated) {
group.tasks = result.tasks;
}
}
},
updateTaskTimeLogged: (state, action: PayloadAction<{ taskId: string; groupId: string; timeLoggedSeconds: number; timeLoggedString: string; totalActual: number }>) => {
const { taskId, groupId, timeLoggedSeconds, timeLoggedString, totalActual } = action.payload;
const group = state.taskGroups.find(g => g.group_id === groupId);
if (group) {
const result = updateTaskAndRecalculateHierarchy(
group.tasks,
taskId,
(task) => ({
...task,
total_time_logged_seconds: timeLoggedSeconds,
total_time_logged: timeLoggedString,
total_actual: totalActual
})
);
if (result.updated) {
group.tasks = result.tasks;
}
}
},
toggleTaskExpansion: (state, action: PayloadAction<{ taskId: string; groupId: string }>) => {
const { taskId, groupId } = action.payload;
const group = state.taskGroups.find(g => g.group_id === groupId);
if (group) {
// Recursive function to find and toggle a task in the hierarchy
const findAndToggleTask = (tasks: IProjectFinanceTask[], targetId: string): boolean => {
for (const task of tasks) {
if (task.id === targetId) {
task.show_sub_tasks = !task.show_sub_tasks;
return true;
}
// Search in subtasks recursively
if (task.sub_tasks && findAndToggleTask(task.sub_tasks, targetId)) {
return true;
}
}
return false;
};
findAndToggleTask(group.tasks, taskId);
}
},
updateProjectFinanceCurrency: (state, action: PayloadAction<string>) => {
if (state.project) {
state.project.currency = action.payload;
}
},
},
extraReducers: (builder) => {
builder
.addCase(fetchProjectFinances.pending, (state) => {
state.loading = true;
})
.addCase(fetchProjectFinances.fulfilled, (state, action) => {
state.loading = false;
// Apply hierarchy recalculation to ensure parent tasks show correct aggregated values
const recalculatedGroups = action.payload.groups.map(group => ({
...group,
tasks: recalculateTaskHierarchy(group.tasks)
}));
state.taskGroups = recalculatedGroups;
state.projectRateCards = action.payload.project_rate_cards;
state.project = action.payload.project;
// Clear cache when fresh data is loaded
clearCalculationCache();
})
.addCase(fetchProjectFinances.rejected, (state) => {
state.loading = false;
})
.addCase(fetchProjectFinancesSilent.fulfilled, (state, action) => {
// Helper function to preserve expansion state and sub_tasks during updates
const preserveExpansionState = (existingTasks: IProjectFinanceTask[], newTasks: IProjectFinanceTask[]): IProjectFinanceTask[] => {
return newTasks.map(newTask => {
const existingTask = existingTasks.find(t => t.id === newTask.id);
if (existingTask) {
// Preserve expansion state and subtasks
const updatedTask = {
...newTask,
show_sub_tasks: existingTask.show_sub_tasks,
sub_tasks: existingTask.sub_tasks ?
preserveExpansionState(existingTask.sub_tasks, newTask.sub_tasks || []) :
newTask.sub_tasks
};
return updatedTask;
}
return newTask;
});
};
// Update groups while preserving expansion state and applying hierarchy recalculation
const updatedTaskGroups = action.payload.groups.map(newGroup => {
const existingGroup = state.taskGroups.find(g => g.group_id === newGroup.group_id);
if (existingGroup) {
const tasksWithExpansion = preserveExpansionState(existingGroup.tasks, newGroup.tasks);
return {
...newGroup,
tasks: recalculateTaskHierarchy(tasksWithExpansion)
};
}
return {
...newGroup,
tasks: recalculateTaskHierarchy(newGroup.tasks)
};
});
// Update data without changing loading state for silent refresh
state.taskGroups = updatedTaskGroups;
state.projectRateCards = action.payload.project_rate_cards;
state.project = action.payload.project;
// Clear cache when data is refreshed from backend
clearCalculationCache();
})
.addCase(updateTaskFixedCostAsync.fulfilled, (state, action) => {
const { taskId, groupId, fixedCost } = action.payload;
const group = state.taskGroups.find(g => g.group_id === groupId);
if (group) {
// Update the specific task's fixed cost and recalculate the entire hierarchy
const result = updateTaskAndRecalculateHierarchy(
group.tasks,
taskId,
(task) => ({
...task,
fixed_cost: fixedCost
})
);
if (result.updated) {
group.tasks = result.tasks;
clearCalculationCache();
}
}
})
.addCase(fetchSubTasks.fulfilled, (state, action) => {
const { parentTaskId, subTasks } = action.payload;
// Recursive function to find and update a task in the hierarchy
const findAndUpdateTask = (tasks: IProjectFinanceTask[], targetId: string): boolean => {
for (const task of tasks) {
if (task.id === targetId) {
// Found the parent task, add subtasks
task.sub_tasks = subTasks.map(subTask => ({
...subTask,
is_sub_task: true,
parent_task_id: targetId
}));
task.show_sub_tasks = true;
return true;
}
// Search in subtasks recursively
if (task.sub_tasks && findAndUpdateTask(task.sub_tasks, targetId)) {
return true;
}
}
return false;
};
// Find the parent task in any group and add the subtasks
for (const group of state.taskGroups) {
if (findAndUpdateTask(group.tasks, parentTaskId)) {
// Recalculate the hierarchy after adding subtasks to ensure parent values are correct
group.tasks = recalculateTaskHierarchy(group.tasks);
break;
}
}
});
},
});
export const {
setActiveTab,
setActiveGroup,
setBillableFilter,
updateTaskFixedCost,
updateTaskEstimatedCost,
updateTaskTimeLogged,
toggleTaskExpansion,
updateProjectFinanceCurrency
} = projectFinancesSlice.actions;
export default projectFinancesSlice.reducer;

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