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
1586 changed files with 27732 additions and 96564 deletions

View File

@@ -1,15 +0,0 @@
{
"permissions": {
"allow": [
"Bash(find:*)",
"Bash(npm run build:*)",
"Bash(npm run type-check:*)",
"Bash(npm run:*)",
"Bash(move:*)",
"Bash(mv:*)",
"Bash(grep:*)",
"Bash(rm:*)"
],
"deny": []
}
}

View File

@@ -1,237 +0,0 @@
---
alwaysApply: true
---
# Ant Design Import Rules for Worklenz
## 🚨 CRITICAL: Always Use Centralized Imports
**NEVER import Ant Design components directly from 'antd' or '@ant-design/icons'**
### ✅ Correct Import Pattern
```typescript
import { Button, Input, Select, EditOutlined, PlusOutlined } from '@antd-imports';
// or
import { Button, Input, Select, EditOutlined, PlusOutlined } from '@/shared/antd-imports';
```
### ❌ Forbidden Import Patterns
```typescript
// NEVER do this:
import { Button, Input, Select } from 'antd';
import { EditOutlined, PlusOutlined } from '@ant-design/icons';
```
## Why This Rule Exists
### Benefits of Centralized Imports:
- **Better Tree-Shaking**: Optimized bundle size through centralized management
- **Consistent React Context**: Proper context sharing across components
- **Type Safety**: Centralized TypeScript definitions
- **Maintainability**: Single source of truth for all Ant Design imports
- **Performance**: Reduced bundle size and improved loading times
## What's Available in `@antd-imports`
### Core Components
- **Layout**: Layout, Row, Col, Flex, Divider, Space
- **Navigation**: Menu, Tabs, Breadcrumb, Pagination
- **Data Entry**: Input, Select, DatePicker, TimePicker, Form, Checkbox, InputNumber
- **Data Display**: Table, List, Card, Tag, Avatar, Badge, Progress, Statistic
- **Feedback**: Modal, Drawer, Alert, Message, Notification, Spin, Skeleton, Result
- **Other**: Button, Typography, Tooltip, Popconfirm, Dropdown, ConfigProvider
### Icons
Common icons including: EditOutlined, DeleteOutlined, PlusOutlined, MoreOutlined, CheckOutlined, CloseOutlined, CalendarOutlined, UserOutlined, TeamOutlined, and many more.
### Utilities
- **appMessage**: Centralized message utility
- **appNotification**: Centralized notification utility
- **antdConfig**: Default Ant Design configuration
- **taskManagementAntdConfig**: Task-specific configuration
## Implementation Guidelines
### When Creating New Components:
1. **Always** import from `@/shared/antd-imports`
2. Use `appMessage` and `appNotification` for user feedback
3. Apply `antdConfig` for consistent styling
4. Use `taskManagementAntdConfig` for task-related components
### When Refactoring Existing Code:
1. Replace direct 'antd' imports with `@/shared/antd-imports`
2. Replace direct '@ant-design/icons' imports with `@/shared/antd-imports`
3. Update any custom message/notification calls to use the utilities
### File Location
The centralized import file is located at: `worklenz-frontend/src/shared/antd-imports.ts`
## Examples
### Component Creation
```typescript
import React from 'react';
import { Button, Input, Modal, EditOutlined, appMessage } from '@antd-imports';
const MyComponent = () => {
const handleClick = () => {
appMessage.success('Operation completed!');
};
return (
<Button icon={<EditOutlined />} onClick={handleClick}>
Edit Item
</Button>
);
};
```
### Form Implementation
```typescript
import { Form, Input, Select, Button, DatePicker } from '@antd-imports';
const MyForm = () => {
return (
<Form layout="vertical">
<Form.Item label="Name" name="name">
<Input />
</Form.Item>
<Form.Item label="Type" name="type">
<Select options={options} />
</Form.Item>
<Form.Item label="Date" name="date">
<DatePicker />
</Form.Item>
</Form>
);
};
```
## Enforcement
This rule is **MANDATORY** and applies to:
- All new component development
- All code refactoring
- All bug fixes
- All feature implementations
**Violations will result in code review rejection.**
### File Path:
The centralized file is located at: `worklenz-frontend/src/shared/antd-imports.ts`
# Ant Design Import Rules for Worklenz
## 🚨 CRITICAL: Always Use Centralized Imports
**NEVER import Ant Design components directly from 'antd' or '@ant-design/icons'**
### ✅ Correct Import Pattern
```typescript
import { Button, Input, Select, EditOutlined, PlusOutlined } from '@antd-imports';
// or
import { Button, Input, Select, EditOutlined, PlusOutlined } from '@/shared/antd-imports';
```
### ❌ Forbidden Import Patterns
```typescript
// NEVER do this:
import { Button, Input, Select } from 'antd';
import { EditOutlined, PlusOutlined } from '@ant-design/icons';
```
## Why This Rule Exists
### Benefits of Centralized Imports:
- **Better Tree-Shaking**: Optimized bundle size through centralized management
- **Consistent React Context**: Proper context sharing across components
- **Type Safety**: Centralized TypeScript definitions
- **Maintainability**: Single source of truth for all Ant Design imports
- **Performance**: Reduced bundle size and improved loading times
## What's Available in `@antd-imports`
### Core Components
- **Layout**: Layout, Row, Col, Flex, Divider, Space
- **Navigation**: Menu, Tabs, Breadcrumb, Pagination
- **Data Entry**: Input, Select, DatePicker, TimePicker, Form, Checkbox, InputNumber
- **Data Display**: Table, List, Card, Tag, Avatar, Badge, Progress, Statistic
- **Feedback**: Modal, Drawer, Alert, Message, Notification, Spin, Skeleton, Result
- **Other**: Button, Typography, Tooltip, Popconfirm, Dropdown, ConfigProvider
### Icons
Common icons including: EditOutlined, DeleteOutlined, PlusOutlined, MoreOutlined, CheckOutlined, CloseOutlined, CalendarOutlined, UserOutlined, TeamOutlined, and many more.
### Utilities
- **appMessage**: Centralized message utility
- **appNotification**: Centralized notification utility
- **antdConfig**: Default Ant Design configuration
- **taskManagementAntdConfig**: Task-specific configuration
## Implementation Guidelines
### When Creating New Components:
1. **Always** import from `@antd-imports` or `@/shared/antd-imports`
2. Use `appMessage` and `appNotification` for user feedback
3. Apply `antdConfig` for consistent styling
4. Use `taskManagementAntdConfig` for task-related components
### When Refactoring Existing Code:
1. Replace direct 'antd' imports with `@antd-imports`
2. Replace direct '@ant-design/icons' imports with `@antd-imports`
3. Update any custom message/notification calls to use the utilities
### File Location
The centralized import file is located at: `worklenz-frontend/src/shared/antd-imports.ts`
## Examples
### Component Creation
```typescript
import React from 'react';
import { Button, Input, Modal, EditOutlined, appMessage } from '@antd-imports';
const MyComponent = () => {
const handleClick = () => {
appMessage.success('Operation completed!');
};
return (
<Button icon={<EditOutlined />} onClick={handleClick}>
Edit Item
</Button>
);
};
```
### Form Implementation
```typescript
import { Form, Input, Select, Button, DatePicker } from '@antd-imports';
const MyForm = () => {
return (
<Form layout="vertical">
<Form.Item label="Name" name="name">
<Input />
</Form.Item>
<Form.Item label="Type" name="type">
<Select options={options} />
</Form.Item>
<Form.Item label="Date" name="date">
<DatePicker />
</Form.Item>
</Form>
);
};
```
## Enforcement
This rule is **MANDATORY** and applies to:
- All new component development
- All code refactoring
- All bug fixes
- All feature implementations
**Violations will result in code review rejection.**
### File Path:
The centralized file is located at: `worklenz-frontend/src/shared/antd-imports.ts`

416
README.md
View File

@@ -1,29 +1,11 @@
<h1 align="center">
<a href="https://worklenz.com" target="_blank" rel="noopener noreferrer">
<img src="https://s3.us-west-2.amazonaws.com/worklenz.com/assets/icon-144x144.png" alt="Worklenz Logo" width="75">
<img src="https://app.worklenz.com/assets/icons/icon-144x144.png" alt="Worklenz Logo" width="75">
</a>
<br>
Worklenz
</h1>
<p align="center">
<a href="https://github.com/Worklenz/worklenz/blob/main/LICENSE">
<img src="https://img.shields.io/badge/license-AGPL--3.0-blue.svg" alt="License">
</a>
<a href="https://github.com/Worklenz/worklenz/releases">
<img src="https://img.shields.io/github/v/release/Worklenz/worklenz" alt="Release">
</a>
<a href="https://github.com/Worklenz/worklenz/stargazers">
<img src="https://img.shields.io/github/stars/Worklenz/worklenz" alt="Stars">
</a>
<a href="https://github.com/Worklenz/worklenz/network/members">
<img src="https://img.shields.io/github/forks/Worklenz/worklenz" alt="Forks">
</a>
<a href="https://github.com/Worklenz/worklenz/issues">
<img src="https://img.shields.io/github/issues/Worklenz/worklenz" alt="Issues">
</a>
</p>
<p align="center">
<a href="https://worklenz.com/task-management/">Task Management</a> |
<a href="https://worklenz.com/time-tracking/">Time Tracking</a> |
@@ -45,24 +27,6 @@
Worklenz is a project management tool designed to help organizations improve their efficiency. It provides a
comprehensive solution for managing projects, tasks, and collaboration within teams.
## Table of Contents
- [Features](#features)
- [Tech Stack](#tech-stack)
- [Getting Started](#getting-started)
- [Quick Start (Docker)](#-quick-start-docker---recommended)
- [Manual Installation](#-manual-installation-for-development)
- [Deployment](#deployment)
- [Local Development](#local-development-with-docker)
- [Remote Server Deployment](#remote-server-deployment)
- [Configuration](#configuration)
- [MinIO Integration](#minio-integration)
- [Security](#security)
- [Analytics](#analytics)
- [Screenshots](#screenshots)
- [Contributing](#contributing)
- [License](#license)
## Features
- **Project Planning**: Create and organize projects, assign tasks to team members.
@@ -86,80 +50,42 @@ This repository contains the frontend and backend code for Worklenz.
## Getting Started
Choose your preferred setup method below. Docker is recommended for quick setup and testing.
These instructions will help you set up and run the Worklenz project on your local machine for development and testing purposes.
### 🚀 Quick Start (Docker - Recommended)
### Prerequisites
The fastest way to get Worklenz running locally with all dependencies included.
**Prerequisites:**
- Docker and Docker Compose installed on your system
- Git
**Steps:**
1. Clone the repository:
```bash
git clone https://github.com/Worklenz/worklenz.git
cd worklenz
```
2. Start the Docker containers:
```bash
docker-compose up -d
```
3. Access the application:
- **Frontend**: http://localhost:5000
- **Backend API**: http://localhost:3000
- **MinIO Console**: http://localhost:9001 (login: minioadmin/minioadmin)
4. To stop the services:
```bash
docker-compose down
```
**Alternative startup methods:**
- **Windows**: Run `start.bat`
- **Linux/macOS**: Run `./start.sh`
**Video Guide**: For a visual walkthrough of the local Docker deployment process, check out our [step-by-step video guide](https://www.youtube.com/watch?v=AfwAKxJbqLg).
### 🛠️ Manual Installation (For Development)
For developers who want to run the services individually or customize the setup.
**Prerequisites:**
- Node.js (version 18 or higher)
- PostgreSQL (version 15 or higher)
- PostgreSQL database
- An S3-compatible storage service (like MinIO) or Azure Blob Storage
**Steps:**
### Option 1: Manual Installation
1. Clone the repository:
1. Clone the repository
```bash
git clone https://github.com/Worklenz/worklenz.git
cd worklenz
```
2. Set up environment variables:
```bash
cp worklenz-backend/.env.template worklenz-backend/.env
# Update the environment variables with your configuration
```
2. Set up environment variables
- Copy the example environment files
```bash
cp .env.example .env
cp worklenz-backend/.env.example worklenz-backend/.env
```
- Update the environment variables with your configuration
3. Install dependencies:
3. Install dependencies
```bash
# Backend dependencies
# Install backend dependencies
cd worklenz-backend
npm install
# Frontend dependencies
# Install frontend dependencies
cd ../worklenz-frontend
npm install
```
4. Set up the database:
4. Set up the database
```bash
# Create a PostgreSQL database named worklenz_db
cd worklenz-backend
@@ -175,47 +101,49 @@ psql -U your_username -d worklenz_db -f database/sql/2_dml.sql
psql -U your_username -d worklenz_db -f database/sql/5_database_user.sql
```
5. Start the development servers:
5. Start the development servers
```bash
# Terminal 1: Start the backend
# In one terminal, start the backend
cd worklenz-backend
npm run dev
# Terminal 2: Start the frontend
# In another terminal, start the frontend
cd worklenz-frontend
npm run dev
```
6. Access the application at http://localhost:5000
## Deployment
### Option 2: Docker Setup
For local development, follow the [Quick Start (Docker)](#-quick-start-docker---recommended) section above.
The project includes a fully configured Docker setup with:
- Frontend React application
- Backend server
- PostgreSQL database
- MinIO for S3-compatible storage
### Remote Server Deployment
1. Clone the repository:
```bash
git clone https://github.com/Worklenz/worklenz.git
cd worklenz
```
When deploying to a remote server:
2. Start the Docker containers (choose one option):
1. Set up the environment files with your server's hostname:
```bash
# For HTTP/WS
./update-docker-env.sh your-server-hostname
**Using Docker Compose directly**
```bash
docker-compose up -d
```
# For HTTPS/WSS
./update-docker-env.sh your-server-hostname true
```
3. The application will be available at:
- Frontend: http://localhost:5000
- Backend API: http://localhost:3000
- MinIO Console: http://localhost:9001 (login with minioadmin/minioadmin)
2. Pull and run the latest Docker images:
```bash
docker-compose pull
docker-compose up -d
```
3. Access the application through your server's hostname:
- Frontend: http://your-server-hostname:5000
- Backend API: http://your-server-hostname:3000
4. **Video Guide**: For a complete walkthrough of deploying Worklenz to a remote server, check out our [deployment video guide](https://www.youtube.com/watch?v=CAZGu2iOXQs&t=10s).
4. To stop the services:
```bash
docker-compose down
```
## Configuration
@@ -230,46 +158,16 @@ Worklenz requires several environment variables to be configured for proper oper
Please refer to the `.env.example` files for a full list of required variables.
The Docker setup uses environment variables to configure the services:
- **Frontend:**
- `VITE_API_URL`: URL of the backend API (default: http://backend:3000 for container networking)
- `VITE_SOCKET_URL`: WebSocket URL for real-time communication (default: ws://backend:3000)
- **Backend:**
- Database connection parameters
- Storage configuration
- Other backend settings
For custom configuration, edit the `.env` file or the `update-docker-env.sh` script.
## MinIO Integration
### MinIO Integration
The project uses MinIO as an S3-compatible object storage service, which provides an open-source alternative to AWS S3 for development and production.
### Working with MinIO
MinIO provides an S3-compatible API, so any code that works with S3 will work with MinIO by simply changing the endpoint URL. The backend has been configured to use MinIO by default, with no additional configuration required.
- **MinIO Console**: http://localhost:9001
- Username: minioadmin
- Password: minioadmin
- **Default Bucket**: worklenz-bucket (created automatically when the containers start)
### Backend Storage Configuration
The backend is pre-configured to use MinIO with the following settings:
```javascript
// S3 credentials with MinIO defaults
export const REGION = process.env.AWS_REGION || "us-east-1";
export const BUCKET = process.env.AWS_BUCKET || "worklenz-bucket";
export const S3_URL = process.env.S3_URL || "http://minio:9000/worklenz-bucket";
export const S3_ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID || "minioadmin";
export const S3_SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY || "minioadmin";
```
### Security Considerations
For production deployments:
@@ -280,32 +178,19 @@ For production deployments:
4. Enable HTTPS for all public endpoints
5. Review and update dependencies regularly
## Contributing
We welcome contributions from the community! If you'd like to contribute, please follow our [contributing guidelines](CONTRIBUTING.md).
## Security
If you believe you have found a security vulnerability in Worklenz, we encourage you to responsibly disclose this and not open a public issue. We will investigate all legitimate reports.
Email [info@worklenz.com](mailto:info@worklenz.com) to disclose any security vulnerabilities.
## Analytics
## License
Worklenz uses Google Analytics to understand how the application is being used. This helps us improve the application and make better decisions about future development.
### What We Track
- Anonymous usage statistics
- Page views and navigation patterns
- Feature usage
- Browser and device information
### Privacy
- Analytics is opt-in only
- No personal information is collected
- Users can opt-out at any time
- Data is stored according to Google's privacy policy
### How to Opt-Out
If you've previously opted in and want to opt-out:
1. Clear your browser's local storage for the Worklenz domain
2. Or click the "Decline" button in the analytics notice if it appears
This project is licensed under the [MIT License](LICENSE).
## Screenshots
@@ -355,13 +240,206 @@ If you've previously opted in and want to opt-out:
</a>
</p>
## Contributing
### Contributing
We welcome contributions from the community! If you'd like to contribute, please follow our [contributing guidelines](CONTRIBUTING.md).
We welcome contributions from the community! If you'd like to contribute, please follow
our [contributing guidelines](CONTRIBUTING.md).
## License
### License
Worklenz is open source and released under the [GNU Affero General Public License Version 3 (AGPLv3)](LICENSE).
By contributing to Worklenz, you agree that your contributions will be licensed under its AGPL.
# Worklenz React
This repository contains the React version of Worklenz with a Docker setup for easy development and deployment.
## Getting Started with Docker
The project includes a fully configured Docker setup with:
- Frontend React application
- Backend server
- PostgreSQL database
- MinIO for S3-compatible storage
### Prerequisites
- Docker and Docker Compose installed on your system
- Git
### Quick Start
1. Clone the repository:
```bash
git clone https://github.com/Worklenz/worklenz.git
cd worklenz
```
2. Start the Docker containers (choose one option):
**Option 1: Using the provided scripts (easiest)**
- On Windows:
```
start.bat
```
- On Linux/macOS:
```bash
./start.sh
```
**Option 2: Using Docker Compose directly**
```bash
docker-compose up -d
```
3. The application will be available at:
- Frontend: http://localhost:5000
- Backend API: http://localhost:3000
- MinIO Console: http://localhost:9001 (login with minioadmin/minioadmin)
4. To stop the services (choose one option):
**Option 1: Using the provided scripts**
- On Windows:
```
stop.bat
```
- On Linux/macOS:
```bash
./stop.sh
```
**Option 2: Using Docker Compose directly**
```bash
docker-compose down
```
## MinIO Integration
The project uses MinIO as an S3-compatible object storage service, which provides an open-source alternative to AWS S3 for development and production.
### Working with MinIO
MinIO provides an S3-compatible API, so any code that works with S3 will work with MinIO by simply changing the endpoint URL. The backend has been configured to use MinIO by default, with no additional configuration required.
- **MinIO Console**: http://localhost:9001
- Username: minioadmin
- Password: minioadmin
- **Default Bucket**: worklenz-bucket (created automatically when the containers start)
### Backend Storage Configuration
The backend is pre-configured to use MinIO with the following settings:
```javascript
// S3 credentials with MinIO defaults
export const REGION = process.env.AWS_REGION || "us-east-1";
export const BUCKET = process.env.AWS_BUCKET || "worklenz-bucket";
export const S3_URL = process.env.S3_URL || "http://minio:9000/worklenz-bucket";
export const S3_ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID || "minioadmin";
export const S3_SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY || "minioadmin";
```
The S3 client is initialized with special MinIO configuration:
```javascript
const s3Client = new S3Client({
region: REGION,
credentials: {
accessKeyId: S3_ACCESS_KEY_ID || "",
secretAccessKey: S3_SECRET_ACCESS_KEY || "",
},
endpoint: getEndpointFromUrl(), // Extracts endpoint from S3_URL
forcePathStyle: true, // Required for MinIO
});
```
### Environment Configuration
The project uses the following environment file structure:
- **Frontend**:
- `worklenz-frontend/.env.development` - Development environment variables
- `worklenz-frontend/.env.production` - Production build variables
- **Backend**:
- `worklenz-backend/.env` - Backend environment variables
### Setting Up Environment Files
The Docker environment script will create or overwrite all environment files:
```bash
# For HTTP/WS
./update-docker-env.sh your-hostname
# For HTTPS/WSS
./update-docker-env.sh your-hostname true
```
This script generates properly configured environment files for both development and production environments.
## Docker Deployment
### Local Development with Docker
1. Set up the environment files:
```bash
# For HTTP/WS
./update-docker-env.sh
# For HTTPS/WSS
./update-docker-env.sh localhost true
```
2. Run the application using Docker Compose:
```bash
docker-compose up -d
```
3. Access the application:
- Frontend: http://localhost:5000
- Backend API: http://localhost:3000 (or https://localhost:3000 with SSL)
### Remote Server Deployment
When deploying to a remote server:
1. Set up the environment files with your server's hostname:
```bash
# For HTTP/WS
./update-docker-env.sh your-server-hostname
# For HTTPS/WSS
./update-docker-env.sh your-server-hostname true
```
This ensures that the frontend correctly connects to the backend API.
2. Pull and run the latest Docker images:
```bash
docker-compose pull
docker-compose up -d
```
3. Access the application through your server's hostname:
- Frontend: http://your-server-hostname:5000
- Backend API: http://your-server-hostname:3000
### Environment Configuration
The Docker setup uses environment variables to configure the services:
- Frontend:
- `VITE_API_URL`: URL of the backend API (default: http://backend:3000 for container networking)
- `VITE_SOCKET_URL`: WebSocket URL for real-time communication (default: ws://backend:3000)
- Backend:
- Database connection parameters
- Storage configuration
- Other backend settings
For custom configuration, edit the `.env` file or the `update-docker-env.sh` script.

View File

@@ -4,7 +4,7 @@ Getting started with development is a breeze! Follow these steps and you'll be c
## Requirements
- Node.js version v20 or newer - [Node.js](https://nodejs.org/en/download/)
- Node.js version v16 or newer - [Node.js](https://nodejs.org/en/download/)
- PostgreSQL version v15 or newer - [PostgreSQL](https://www.postgresql.org/download/)
- S3-compatible storage (like MinIO) for file storage
@@ -38,7 +38,7 @@ Getting started with development is a breeze! Follow these steps and you'll be c
npm start
```
4. Navigate to [http://localhost:5173](http://localhost:5173) (development server)
4. Navigate to [http://localhost:5173](http://localhost:5173)
### Backend installation
@@ -126,7 +126,7 @@ For an easier setup, you can use Docker and Docker Compose:
```
3. Access the application:
- Frontend: http://localhost:5000 (Docker production build)
- Frontend: http://localhost:5000
- Backend API: http://localhost:3000
- MinIO Console: http://localhost:9001 (login with minioadmin/minioadmin)

View File

@@ -1,16 +0,0 @@
#!/bin/bash
set -eu
# Adjust these as needed:
CONTAINER=worklenz_db
DB_NAME=worklenz_db
DB_USER=postgres
BACKUP_DIR=./pg_backups
mkdir -p "$BACKUP_DIR"
timestamp=$(date +%Y-%m-%d_%H-%M-%S)
outfile="${BACKUP_DIR}/${DB_NAME}_${timestamp}.sql"
echo "Creating backup $outfile ..."
docker exec -t "$CONTAINER" pg_dump -U "$DB_USER" -d "$DB_NAME" > "$outfile"
echo "Backup saved to $outfile"

View File

@@ -7,8 +7,8 @@ services:
ports:
- "5000:5000"
depends_on:
- backend
restart: unless-stopped
backend:
condition: service_started
env_file:
- ./worklenz-frontend/.env.production
networks:
@@ -26,7 +26,6 @@ services:
condition: service_healthy
minio:
condition: service_started
restart: unless-stopped
env_file:
- ./worklenz-backend/.env
networks:
@@ -38,7 +37,6 @@ services:
ports:
- "9000:9000"
- "9001:9001"
restart: unless-stopped
environment:
MINIO_ROOT_USER: ${S3_ACCESS_KEY_ID:-minioadmin}
MINIO_ROOT_PASSWORD: ${S3_SECRET_ACCESS_KEY:-minioadmin}
@@ -54,14 +52,13 @@ services:
container_name: worklenz_createbuckets
depends_on:
- minio
restart: on-failure
entrypoint: >
/bin/sh -c '
echo "Waiting for MinIO to start...";
sleep 15;
for i in 1 2 3 4 5; do
echo "Attempt $i to connect to MinIO...";
if /usr/bin/mc alias set myminio http://minio:9000 minioadmin minioadmin; then
if /usr/bin/mc config host add myminio http://minio:9000 minioadmin minioadmin; then
echo "Successfully connected to MinIO!";
/usr/bin/mc mb --ignore-existing myminio/worklenz-bucket;
/usr/bin/mc policy set public myminio/worklenz-bucket;
@@ -83,79 +80,32 @@ services:
POSTGRES_DB: ${DB_NAME:-worklenz_db}
POSTGRES_PASSWORD: ${DB_PASSWORD:-password}
healthcheck:
test:
[
"CMD-SHELL",
"pg_isready -d ${DB_NAME:-worklenz_db} -U ${DB_USER:-postgres}",
]
test: [ "CMD-SHELL", "pg_isready -d ${DB_NAME:-worklenz_db} -U ${DB_USER:-postgres}" ]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
networks:
- worklenz
volumes:
- worklenz_postgres_data:/var/lib/postgresql/data
- type: bind
source: ./worklenz-backend/database/sql
target: /docker-entrypoint-initdb.d/sql
source: ./worklenz-backend/database
target: /docker-entrypoint-initdb.d
consistency: cached
- type: bind
source: ./worklenz-backend/database/migrations
target: /docker-entrypoint-initdb.d/migrations
consistency: cached
- type: bind
source: ./worklenz-backend/database/00_init.sh
target: /docker-entrypoint-initdb.d/00_init.sh
consistency: cached
- type: bind
source: ./pg_backups
target: /docker-entrypoint-initdb.d/pg_backups
command: >
bash -c '
if command -v apt-get >/dev/null 2>&1; then
bash -c ' if command -v apt-get >/dev/null 2>&1; then
apt-get update && apt-get install -y dos2unix
elif command -v apk >/dev/null 2>&1; then
apk add --no-cache dos2unix
fi
find /docker-entrypoint-initdb.d -type f -name "*.sh" -exec sh -c '"'"'
for f; do
dos2unix "$f" 2>/dev/null || true
chmod +x "$f"
done
'"'"' sh {} +
exec docker-entrypoint.sh postgres
'
db-backup:
image: postgres:15
container_name: worklenz_db_backup
environment:
POSTGRES_USER: ${DB_USER:-postgres}
POSTGRES_DB: ${DB_NAME:-worklenz_db}
POSTGRES_PASSWORD: ${DB_PASSWORD:-password}
depends_on:
db:
condition: service_healthy
volumes:
- ./pg_backups:/pg_backups #host dir for backups files
#setup bassh loop to backup data evey 24h
command: >
bash -c 'while true; do
sleep 86400;
PGPASSWORD=$$POSTGRES_PASSWORD pg_dump -h worklenz_db -U $$POSTGRES_USER -d $$POSTGRES_DB \
> /pg_backups/worklenz_db_$$(date +%Y-%m-%d_%H-%M-%S).sql;
find /pg_backups -type f -name "*.sql" -mtime +30 -delete;
done'
restart: unless-stopped
networks:
- worklenz
fi && find /docker-entrypoint-initdb.d -type f -name "*.sh" -exec sh -c '\''
dos2unix "{}" 2>/dev/null || true
chmod +x "{}"
'\'' \; && exec docker-entrypoint.sh postgres '
volumes:
worklenz_postgres_data:
worklenz_minio_data:
pgdata:
networks:
worklenz:

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

@@ -1,429 +0,0 @@
# Enhanced Task Management: Technical Guide
## Overview
The Enhanced Task Management system is a comprehensive React-based interface built on top of WorkLenz's existing task infrastructure. It provides a modern, grouped view with drag-and-drop functionality, bulk operations, and responsive design.
## Architecture
### Component Structure
```
src/components/task-management/
├── TaskListBoard.tsx # Main container with DnD context
├── TaskGroup.tsx # Individual group with collapse/expand
├── TaskRow.tsx # Task display with rich metadata
├── GroupingSelector.tsx # Grouping method switcher
└── BulkActionBar.tsx # Bulk operations toolbar
```
### Integration Points
The system integrates with existing WorkLenz infrastructure:
- **Redux Store:** Uses `tasks.slice.ts` for state management
- **Types:** Leverages existing TypeScript interfaces
- **API Services:** Works with existing task API endpoints
- **WebSocket:** Supports real-time updates via existing socket system
## Core Components
### TaskListBoard.tsx
Main orchestrator component that provides:
- **DnD Context:** @dnd-kit drag-and-drop functionality
- **State Management:** Redux integration for task data
- **Event Handling:** Drag events and bulk operations
- **Layout Structure:** Header controls and group container
#### Key Props
```typescript
interface TaskListBoardProps {
projectId: string; // Required: Project identifier
className?: string; // Optional: Additional CSS classes
}
```
#### Redux Selectors Used
```typescript
const {
taskGroups, // ITaskListGroup[] - Grouped task data
loadingGroups, // boolean - Loading state
error, // string | null - Error state
groupBy, // IGroupBy - Current grouping method
search, // string | null - Search filter
archived, // boolean - Show archived tasks
} = useSelector((state: RootState) => state.taskReducer);
```
### TaskGroup.tsx
Renders individual task groups with:
- **Collapsible Headers:** Expand/collapse functionality
- **Progress Indicators:** Visual completion progress
- **Drop Zones:** Accept dropped tasks from other groups
- **Group Statistics:** Task counts and completion rates
#### Key Props
```typescript
interface TaskGroupProps {
group: ITaskListGroup; // Group data with tasks
projectId: string; // Project context
currentGrouping: IGroupBy; // Current grouping mode
selectedTaskIds: string[]; // Selected task IDs
onAddTask?: (groupId: string) => void;
onToggleCollapse?: (groupId: string) => void;
}
```
### TaskRow.tsx
Individual task display featuring:
- **Rich Metadata:** Progress, assignees, labels, due dates
- **Drag Handles:** Sortable within and between groups
- **Selection:** Multi-select with checkboxes
- **Subtask Support:** Expandable hierarchy display
#### Key Props
```typescript
interface TaskRowProps {
task: IProjectTask; // Task data
projectId: string; // Project context
groupId: string; // Parent group ID
currentGrouping: IGroupBy; // Current grouping mode
isSelected: boolean; // Selection state
isDragOverlay?: boolean; // Drag overlay rendering
index?: number; // Position in group
onSelect?: (taskId: string, selected: boolean) => void;
onToggleSubtasks?: (taskId: string) => void;
}
```
## State Management
### Redux Integration
The system uses existing WorkLenz Redux patterns:
```typescript
// Primary slice used
import {
fetchTaskGroups, // Async thunk for loading data
reorderTasks, // Update task order/group
setGroup, // Change grouping method
updateTaskStatus, // Update individual task status
updateTaskPriority, // Update individual task priority
// ... other existing actions
} from '@/features/tasks/tasks.slice';
```
### Data Flow
1. **Component Mount:** `TaskListBoard` dispatches `fetchTaskGroups(projectId)`
2. **Group Changes:** `setGroup(newGroupBy)` triggers data reorganization
3. **Drag Operations:** `reorderTasks()` updates task positions and properties
4. **Real-time Updates:** WebSocket events update Redux state automatically
## Drag and Drop Implementation
### DnD Kit Integration
Uses @dnd-kit for modern, accessible drag-and-drop:
```typescript
// Sensors for different input methods
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 }
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates
})
);
```
### Drag Event Handling
```typescript
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
// Determine source and target
const sourceGroup = findTaskGroup(active.id);
const targetGroup = findTargetGroup(over?.id);
// Update task arrays and dispatch changes
dispatch(reorderTasks({
activeGroupId: sourceGroup.id,
overGroupId: targetGroup.id,
fromIndex: sourceIndex,
toIndex: targetIndex,
task: movedTask,
updatedSourceTasks,
updatedTargetTasks,
}));
};
```
### Smart Property Updates
When tasks are moved between groups, properties update automatically:
- **Status Grouping:** Moving to "Done" group sets task status to "done"
- **Priority Grouping:** Moving to "High" group sets task priority to "high"
- **Phase Grouping:** Moving to "Testing" group sets task phase to "testing"
## Bulk Operations
### Selection State Management
```typescript
// Local state for task selection
const [selectedTaskIds, setSelectedTaskIds] = useState<string[]>([]);
// Selection handlers
const handleTaskSelect = (taskId: string, selected: boolean) => {
if (selected) {
setSelectedTaskIds(prev => [...prev, taskId]);
} else {
setSelectedTaskIds(prev => prev.filter(id => id !== taskId));
}
};
```
### Context-Aware Actions
Bulk actions adapt to current grouping:
```typescript
// Only show status changes when not grouped by status
{currentGrouping !== 'status' && (
<Dropdown overlay={statusMenu}>
<Button>Change Status</Button>
</Dropdown>
)}
```
## Performance Optimizations
### Memoized Selectors
```typescript
// Expensive group calculations are memoized
const taskGroups = useMemo(() => {
return createGroupsFromTasks(tasks, currentGrouping);
}, [tasks, currentGrouping]);
```
### Virtual Scrolling Ready
For large datasets, the system is prepared for react-window integration:
```typescript
// Large group detection
const shouldVirtualize = group.tasks.length > 100;
return shouldVirtualize ? (
<VirtualizedTaskList tasks={group.tasks} />
) : (
<StandardTaskList tasks={group.tasks} />
);
```
### Optimistic Updates
UI updates immediately while API calls process in background:
```typescript
// Immediate UI update
dispatch(updateTaskStatusOptimistically(taskId, newStatus));
// API call with rollback on error
try {
await updateTaskStatus(taskId, newStatus);
} catch (error) {
dispatch(rollbackTaskStatusUpdate(taskId));
}
```
## Responsive Design
### Breakpoint Strategy
```css
/* Mobile-first responsive design */
.task-row {
padding: 12px;
}
@media (min-width: 768px) {
.task-row {
padding: 16px;
}
}
@media (min-width: 1024px) {
.task-row {
padding: 20px;
}
}
```
### Progressive Enhancement
- **Mobile:** Essential information only
- **Tablet:** Additional metadata visible
- **Desktop:** Full feature set with optimal layout
## Accessibility
### ARIA Implementation
```typescript
// Proper ARIA labels for screen readers
<div
role="button"
aria-label={`Move task ${task.name}`}
tabIndex={0}
{...dragHandleProps}
>
<DragOutlined />
</div>
```
### Keyboard Navigation
- **Tab:** Navigate between elements
- **Space:** Select/deselect tasks
- **Enter:** Activate buttons
- **Arrows:** Navigate sortable lists with keyboard sensor
### Focus Management
```typescript
// Maintain focus during dynamic updates
useEffect(() => {
if (shouldFocusTask) {
taskRef.current?.focus();
}
}, [taskGroups]);
```
## WebSocket Integration
### Real-time Updates
The system subscribes to existing WorkLenz WebSocket events:
```typescript
// Socket event handlers (existing WorkLenz patterns)
socket.on('TASK_STATUS_CHANGED', (data) => {
dispatch(updateTaskStatus(data));
});
socket.on('TASK_PROGRESS_UPDATED', (data) => {
dispatch(updateTaskProgress(data));
});
```
### Live Collaboration
- Multiple users can work simultaneously
- Changes appear in real-time
- Conflict resolution through server-side validation
## API Integration
### Existing Endpoints Used
```typescript
// Uses existing WorkLenz API services
import { tasksApiService } from '@/api/tasks/tasks.api.service';
// Task data fetching
tasksApiService.getTaskList(config);
// Task updates
tasksApiService.updateTask(taskId, changes);
// Bulk operations
tasksApiService.bulkUpdateTasks(taskIds, changes);
```
### Error Handling
```typescript
try {
await dispatch(fetchTaskGroups(projectId));
} catch (error) {
// Display user-friendly error message
message.error('Failed to load tasks. Please try again.');
logger.error('Task loading error:', error);
}
```
## Testing Strategy
### Component Testing
```typescript
// Example test structure
describe('TaskListBoard', () => {
it('should render task groups correctly', () => {
const mockTasks = generateMockTasks(10);
render(<TaskListBoard projectId="test-project" />);
expect(screen.getByText('Tasks (10)')).toBeInTheDocument();
});
it('should handle drag and drop operations', async () => {
// Test drag and drop functionality
});
});
```
### Integration Testing
- Redux state management
- API service integration
- WebSocket event handling
- Drag and drop operations
## Development Guidelines
### Code Organization
- Follow existing WorkLenz patterns
- Use TypeScript strictly
- Implement proper error boundaries
- Maintain accessibility standards
### Performance Considerations
- Memoize expensive calculations
- Implement virtual scrolling for large datasets
- Debounce user input operations
- Optimize re-render cycles
### Styling Standards
- Use existing Ant Design components
- Follow WorkLenz design system
- Implement responsive breakpoints
- Maintain dark mode compatibility
## Future Enhancements
### Planned Features
- Custom column integration
- Advanced filtering capabilities
- Kanban board view
- Enhanced time tracking
- Task templates
### Extension Points
The system is designed for easy extension:
```typescript
// Plugin architecture ready
interface TaskViewPlugin {
name: string;
component: React.ComponentType;
supportedGroupings: IGroupBy[];
}
const plugins: TaskViewPlugin[] = [
{ name: 'kanban', component: KanbanView, supportedGroupings: ['status'] },
{ name: 'timeline', component: TimelineView, supportedGroupings: ['phase'] },
];
```
## Deployment Considerations
### Bundle Size
- Tree-shake unused dependencies
- Code-split large components
- Optimize asset loading
### Browser Compatibility
- Modern browsers (ES2020+)
- Graceful degradation for older browsers
- Progressive enhancement approach
### Performance Monitoring
- Track component render times
- Monitor API response times
- Measure user interaction latency

View File

@@ -1,275 +0,0 @@
# Enhanced Task Management: User Guide
## What Is Enhanced Task Management?
The Enhanced Task Management system provides a modern, grouped view of your tasks with advanced features like drag-and-drop, bulk operations, and dynamic grouping. This system builds on WorkLenz's existing task infrastructure while offering improved productivity and organization tools.
## Why Use Enhanced Task Management?
- **Better Organization:** Group tasks by Status, Priority, or Phase for clearer project overview
- **Increased Productivity:** Bulk operations let you update multiple tasks at once
- **Intuitive Interface:** Drag-and-drop functionality makes task management feel natural
- **Rich Task Display:** See progress, assignees, labels, and due dates at a glance
- **Responsive Design:** Works seamlessly on desktop, tablet, and mobile devices
## Getting Started
### Accessing Enhanced Task Management
1. Navigate to your project workspace
2. Look for the enhanced task view option in your project interface
3. The system will display your tasks grouped by the current grouping method (default: Status)
### Understanding the Interface
The enhanced task management interface consists of several key areas:
- **Header Controls:** Task count, grouping selector, and action buttons
- **Task Groups:** Collapsible sections containing related tasks
- **Individual Tasks:** Rich task cards with metadata and actions
- **Bulk Action Bar:** Appears when multiple tasks are selected (blue bar)
## Task Grouping
### Available Grouping Options
You can organize your tasks using three different grouping methods:
#### 1. Status Grouping (Default)
Groups tasks by their current status:
- **To Do:** Tasks not yet started
- **Doing:** Tasks currently in progress
- **Done:** Completed tasks
#### 2. Priority Grouping
Groups tasks by their priority level:
- **Critical:** Highest priority, urgent tasks
- **High:** Important tasks requiring attention
- **Medium:** Standard priority tasks
- **Low:** Tasks that can be addressed later
#### 3. Phase Grouping
Groups tasks by project phases:
- **Planning:** Tasks in the planning stage
- **Development:** Implementation and development tasks
- **Testing:** Quality assurance and testing tasks
- **Deployment:** Release and deployment tasks
### Switching Between Groupings
1. Locate the "Group by" dropdown in the header controls
2. Select your preferred grouping method (Status, Priority, or Phase)
3. Tasks will automatically reorganize into the new groups
4. Your grouping preference is saved for future sessions
### Group Features
Each task group includes:
- **Color-coded headers** with visual indicators
- **Task count badges** showing the number of tasks in each group
- **Progress indicators** showing completion percentage
- **Collapse/expand functionality** to hide or show group contents
- **Add task buttons** to quickly create tasks in specific groups
## Drag and Drop
### Moving Tasks Within Groups
1. Hover over a task to reveal the drag handle (⋮⋮ icon)
2. Click and hold the drag handle
3. Drag the task to your desired position within the same group
4. Release to drop the task in its new position
### Moving Tasks Between Groups
1. Click and hold the drag handle on any task
2. Drag the task over a different group
3. The target group will highlight to show it can accept the task
4. Release to drop the task into the new group
5. The task's properties (status, priority, or phase) will automatically update
### Drag and Drop Benefits
- **Instant Updates:** Task properties change automatically when moved between groups
- **Visual Feedback:** Clear indicators show where tasks can be dropped
- **Keyboard Accessible:** Alternative keyboard controls for accessibility
- **Mobile Friendly:** Touch-friendly drag operations on mobile devices
## Multi-Select and Bulk Operations
### Selecting Tasks
You can select multiple tasks using several methods:
#### Individual Selection
- Click the checkbox next to any task to select it
- Click again to deselect
#### Range Selection
- Select the first task in your desired range
- Hold Shift and click the last task in the range
- All tasks between the first and last will be selected
#### Multiple Selection
- Hold Ctrl (or Cmd on Mac) while clicking tasks
- This allows you to select non-consecutive tasks
### Bulk Actions
When you have tasks selected, a blue bulk action bar appears with these options:
#### Change Status (when not grouped by Status)
- Update the status of all selected tasks at once
- Choose from available status options in your project
#### Set Priority (when not grouped by Priority)
- Assign the same priority level to all selected tasks
- Options include Critical, High, Medium, and Low
#### More Actions
Additional bulk operations include:
- **Assign to Member:** Add team members to multiple tasks
- **Add Labels:** Apply labels to selected tasks
- **Archive Tasks:** Move multiple tasks to archive
#### Delete Tasks
- Permanently remove multiple tasks at once
- Confirmation dialog prevents accidental deletions
### Bulk Action Tips
- The bulk action bar only shows relevant options based on your current grouping
- You can clear your selection at any time using the "Clear" button
- Bulk operations provide immediate feedback and can be undone if needed
## Task Display Features
### Rich Task Information
Each task displays comprehensive information:
#### Basic Information
- **Task Key:** Unique identifier (e.g., PROJ-123)
- **Task Name:** Clear, descriptive title
- **Description:** Additional details when available
#### Visual Indicators
- **Progress Bar:** Shows completion percentage (0-100%)
- **Priority Indicator:** Color-coded dot showing task importance
- **Status Color:** Left border color indicates current status
#### Team and Collaboration
- **Assignee Avatars:** Profile pictures of assigned team members (up to 3 visible)
- **Labels:** Color-coded tags for categorization
- **Comment Count:** Number of comments and discussions
- **Attachment Count:** Number of files attached to the task
#### Timing Information
- **Due Dates:** When tasks are scheduled to complete
- Red text: Overdue tasks
- Orange text: Due today or within 3 days
- Gray text: Future due dates
- **Time Tracking:** Estimated vs. logged time when available
### Subtask Support
Tasks with subtasks include additional features:
#### Expanding Subtasks
- Click the "+X" button next to task names to expand subtasks
- Subtasks appear indented below the parent task
- Click "X" to collapse subtasks
#### Subtask Progress
- Parent task progress reflects completion of all subtasks
- Individual subtask progress is visible when expanded
- Subtask counts show total number of child tasks
## Advanced Features
### Real-time Updates
- Changes made by team members appear instantly
- Live collaboration with multiple users
- WebSocket connections ensure data synchronization
### Search and Filtering
- Use existing project search and filter capabilities
- Enhanced task management respects current filter settings
- Search results maintain grouping organization
### Responsive Design
The interface adapts to different screen sizes:
#### Desktop (Large Screens)
- Full feature set with all metadata visible
- Optimal drag-and-drop experience
- Multi-column layouts where appropriate
#### Tablet (Medium Screens)
- Condensed but functional interface
- Touch-friendly interactions
- Simplified metadata display
#### Mobile (Small Screens)
- Stacked layout for easy navigation
- Large touch targets for selections
- Essential information prioritized
## Best Practices
### Organizing Your Tasks
1. **Choose the Right Grouping:** Select the grouping method that best fits your workflow
2. **Use Labels Consistently:** Apply meaningful labels for better categorization
3. **Keep Groups Balanced:** Avoid having too many tasks in a single group
4. **Regular Maintenance:** Review and update task organization periodically
### Collaboration Tips
1. **Clear Task Names:** Use descriptive titles that everyone understands
2. **Proper Assignment:** Assign tasks to appropriate team members
3. **Progress Updates:** Keep progress percentages current for accurate project tracking
4. **Use Comments:** Communicate about tasks using the comment system
### Productivity Techniques
1. **Batch Similar Operations:** Use bulk actions for efficiency
2. **Prioritize Effectively:** Use priority grouping during planning phases
3. **Track Progress:** Monitor completion rates using group progress indicators
4. **Plan Ahead:** Use due dates and time estimates for better scheduling
## Keyboard Shortcuts
### Navigation
- **Tab:** Move focus between elements
- **Enter:** Activate focused button or link
- **Esc:** Close open dialogs or clear selections
### Selection
- **Space:** Select/deselect focused task
- **Shift + Click:** Range selection
- **Ctrl + Click:** Multi-selection (Cmd + Click on Mac)
### Actions
- **Delete:** Remove selected tasks (with confirmation)
- **Ctrl + A:** Select all visible tasks (Cmd + A on Mac)
## Troubleshooting
### Common Issues
#### Tasks Not Moving Between Groups
- Ensure you have edit permissions for the tasks
- Check that you're dragging from the drag handle (⋮⋮ icon)
- Verify the target group allows the task type
#### Bulk Actions Not Working
- Confirm tasks are actually selected (checkboxes checked)
- Ensure you have appropriate permissions
- Check that the action is available for your current grouping
#### Missing Task Information
- Some metadata may be hidden on smaller screens
- Try expanding to full screen or using desktop view
- Check that task has the required information (assignees, labels, etc.)
### Performance Tips
- For projects with hundreds of tasks, consider using filters to reduce visible tasks
- Collapse groups you're not actively working with
- Clear selections when not performing bulk operations
## Getting Help
- Contact your workspace administrator for permission-related issues
- Check the main WorkLenz documentation for general task management help
- Report bugs or feature requests through your organization's support channels
## What's New
This enhanced task management system builds on WorkLenz's solid foundation while adding:
- Modern drag-and-drop interfaces
- Flexible grouping options
- Powerful bulk operation capabilities
- Rich visual task displays
- Mobile-responsive design
- Improved accessibility features

6
package-lock.json generated
View File

@@ -1,6 +0,0 @@
{
"name": "worklenz",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View File

@@ -1,41 +0,0 @@
-- Test script to verify the sort order constraint fix
-- Test the helper function
SELECT get_sort_column_name('status'); -- Should return 'status_sort_order'
SELECT get_sort_column_name('priority'); -- Should return 'priority_sort_order'
SELECT get_sort_column_name('phase'); -- Should return 'phase_sort_order'
SELECT get_sort_column_name('members'); -- Should return 'member_sort_order'
SELECT get_sort_column_name('unknown'); -- Should return 'status_sort_order' (default)
-- Test bulk update function (example - would need real project_id and task_ids)
/*
SELECT update_task_sort_orders_bulk(
'[
{"task_id": "example-uuid", "sort_order": 1, "status_id": "status-uuid"},
{"task_id": "example-uuid-2", "sort_order": 2, "status_id": "status-uuid"}
]'::json,
'status'
);
*/
-- Verify that sort_order constraint still exists and works
SELECT
tc.constraint_name,
tc.table_name,
kcu.column_name
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
WHERE tc.constraint_name = 'tasks_sort_order_unique';
-- Check that new sort order columns don't have unique constraints (which is correct)
SELECT
tc.constraint_name,
tc.table_name,
kcu.column_name
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
WHERE kcu.table_name = 'tasks'
AND kcu.column_name IN ('status_sort_order', 'priority_sort_order', 'phase_sort_order', 'member_sort_order')
AND tc.constraint_type = 'UNIQUE';

View File

@@ -1,30 +0,0 @@
-- Test script to validate the separate sort order implementation
-- Check if new columns exist
SELECT column_name, data_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_name = 'tasks'
AND column_name IN ('status_sort_order', 'priority_sort_order', 'phase_sort_order', 'member_sort_order')
ORDER BY column_name;
-- Check if helper function exists
SELECT routine_name, routine_type
FROM information_schema.routines
WHERE routine_name IN ('get_sort_column_name', 'update_task_sort_orders_bulk', 'handle_task_list_sort_order_change');
-- Sample test data to verify different sort orders work
-- (This would be run after the migrations)
/*
-- Test: Tasks should have different orders for different groupings
SELECT
id,
name,
sort_order,
status_sort_order,
priority_sort_order,
phase_sort_order,
member_sort_order
FROM tasks
WHERE project_id = '<test-project-id>'
ORDER BY status_sort_order;
*/

View File

@@ -73,8 +73,8 @@ cat > worklenz-backend/.env << EOL
NODE_ENV=production
PORT=3000
SESSION_NAME=worklenz.sid
SESSION_SECRET=$(openssl rand -base64 48)
COOKIE_SECRET=$(openssl rand -base64 48)
SESSION_SECRET=change_me_in_production
COOKIE_SECRET=change_me_in_production
# CORS
SOCKET_IO_CORS=${FRONTEND_URL}
@@ -123,7 +123,7 @@ SLACK_WEBHOOK=
COMMIT_BUILD_IMMEDIATELY=true
# JWT Secret
JWT_SECRET=$(openssl rand -base64 48)
JWT_SECRET=change_me_in_production
EOL
echo "Environment configuration updated for ${HOSTNAME} with" $([ "$USE_SSL" = "true" ] && echo "HTTPS/WSS" || echo "HTTP/WS")

View File

@@ -1,9 +1,5 @@
node_modules
npm-debug.log
build
.scannerwork
coverage
.dockerignore
.git
*.md
tests

View File

@@ -20,6 +20,9 @@ coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components

View File

@@ -1,39 +1,26 @@
# --- Stage 1: Build ---
FROM node:20-slim AS builder
ARG RELEASE_VERSION
RUN apt-get update && apt-get install -y \
python3 \
make \
g++ \
curl \
postgresql-server-dev-all \
&& rm -rf /var/lib/apt/lists/*
# Use the official Node.js 20 image as a base
FROM node:20
# Create and set the working directory
WORKDIR /usr/src/app
# Install global dependencies
RUN npm install -g ts-node typescript grunt grunt-cli
# Copy package.json and package-lock.json (if available)
COPY package*.json ./
# Install app dependencies
RUN npm ci
# Copy the rest of the application code
COPY . .
# Run the build script to compile TypeScript to JavaScript
RUN npm run build
RUN echo "$RELEASE_VERSION" > release
# --- Stage 2: Production Image ---
FROM node:20-slim
RUN apt-get update && apt-get install -y libpq5 && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /usr/src/app/package*.json ./
COPY --from=builder /usr/src/app/build ./build
COPY --from=builder /usr/src/app/node_modules ./node_modules
COPY --from=builder /usr/src/app/release ./release
COPY --from=builder /usr/src/app/worklenz-email-templates ./worklenz-email-templates
# Expose the port the app runs on
EXPOSE 3000
CMD ["node", "build/bin/www"]
# Start the application
CMD ["npm", "start"]

View File

@@ -0,0 +1,55 @@
#!/bin/bash
set -e
# This script controls the order of SQL file execution during database initialization
echo "Starting database initialization..."
# Check if we have SQL files in expected locations
if [ -f "/docker-entrypoint-initdb.d/sql/0_extensions.sql" ]; then
SQL_DIR="/docker-entrypoint-initdb.d/sql"
echo "Using SQL files from sql/ subdirectory"
elif [ -f "/docker-entrypoint-initdb.d/0_extensions.sql" ]; then
# First time setup - move files to subdirectory
echo "Moving SQL files to sql/ subdirectory..."
mkdir -p /docker-entrypoint-initdb.d/sql
# Move all SQL files (except this script) to the subdirectory
for f in /docker-entrypoint-initdb.d/*.sql; do
if [ -f "$f" ]; then
cp "$f" /docker-entrypoint-initdb.d/sql/
echo "Copied $f to sql/ subdirectory"
fi
done
SQL_DIR="/docker-entrypoint-initdb.d/sql"
else
echo "SQL files not found in expected locations!"
exit 1
fi
# Execute SQL files in the correct order
echo "Executing 0_extensions.sql..."
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -f "$SQL_DIR/0_extensions.sql"
echo "Executing 1_tables.sql..."
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -f "$SQL_DIR/1_tables.sql"
echo "Executing indexes.sql..."
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -f "$SQL_DIR/indexes.sql"
echo "Executing 4_functions.sql..."
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -f "$SQL_DIR/4_functions.sql"
echo "Executing triggers.sql..."
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -f "$SQL_DIR/triggers.sql"
echo "Executing 3_views.sql..."
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -f "$SQL_DIR/3_views.sql"
echo "Executing 2_dml.sql..."
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -f "$SQL_DIR/2_dml.sql"
echo "Executing 5_database_user.sql..."
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -f "$SQL_DIR/5_database_user.sql"
echo "Database initialization completed successfully"

View File

@@ -1,88 +0,0 @@
#!/bin/bash
set -e
echo "Starting database initialization..."
SQL_DIR="/docker-entrypoint-initdb.d/sql"
MIGRATIONS_DIR="/docker-entrypoint-initdb.d/migrations"
BACKUP_DIR="/docker-entrypoint-initdb.d/pg_backups"
# --------------------------------------------
# 🗄️ STEP 1: Attempt to restore latest backup
# --------------------------------------------
if [ -d "$BACKUP_DIR" ]; then
LATEST_BACKUP=$(ls -t "$BACKUP_DIR"/*.sql 2>/dev/null | head -n 1)
else
LATEST_BACKUP=""
fi
if [ -f "$LATEST_BACKUP" ]; then
echo "🗄️ Found latest backup: $LATEST_BACKUP"
echo "⏳ Restoring from backup..."
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" < "$LATEST_BACKUP"
echo "✅ Backup restoration complete. Skipping schema and migrations."
exit 0
else
echo " No valid backup found. Proceeding with base schema and migrations."
fi
# --------------------------------------------
# 🏗️ STEP 2: Continue with base schema setup
# --------------------------------------------
# Create migrations table if it doesn't exist
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "
CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at TIMESTAMP DEFAULT now()
);
"
# List of base schema files to execute in order
BASE_SQL_FILES=(
"0_extensions.sql"
"1_tables.sql"
"indexes.sql"
"4_functions.sql"
"triggers.sql"
"3_views.sql"
"2_dml.sql"
"5_database_user.sql"
)
echo "Running base schema SQL files in order..."
for file in "${BASE_SQL_FILES[@]}"; do
full_path="$SQL_DIR/$file"
if [ -f "$full_path" ]; then
echo "Executing $file..."
psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" -f "$full_path"
else
echo "WARNING: $file not found, skipping."
fi
done
echo "✅ Base schema SQL execution complete."
# --------------------------------------------
# 🚀 STEP 3: Apply SQL migrations
# --------------------------------------------
if [ -d "$MIGRATIONS_DIR" ] && compgen -G "$MIGRATIONS_DIR/*.sql" > /dev/null; then
echo "Applying migrations..."
for f in "$MIGRATIONS_DIR"/*.sql; do
version=$(basename "$f")
if ! psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAc "SELECT 1 FROM schema_migrations WHERE version = '$version'" | grep -q 1; then
echo "Applying migration: $version"
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -f "$f"
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "INSERT INTO schema_migrations (version) VALUES ('$version');"
else
echo "Skipping already applied migration: $version"
fi
done
else
echo "No migration files found or directory is empty, skipping migrations."
fi
echo "🎉 Database initialization completed successfully."

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

@@ -1,135 +0,0 @@
-- Performance indexes for optimized tasks queries
-- Migration: 20250115000000-performance-indexes.sql
-- Composite index for main task filtering
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_project_archived_parent
ON tasks(project_id, archived, parent_task_id)
WHERE archived = FALSE;
-- Index for status joins
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_status_project
ON tasks(status_id, project_id)
WHERE archived = FALSE;
-- Index for assignees lookup
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_assignees_task_member
ON tasks_assignees(task_id, team_member_id);
-- Index for phase lookup
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_phase_task_phase
ON task_phase(task_id, phase_id);
-- Index for subtask counting
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_parent_archived
ON tasks(parent_task_id, archived)
WHERE parent_task_id IS NOT NULL AND archived = FALSE;
-- Index for labels
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_labels_task_label
ON task_labels(task_id, label_id);
-- Index for comments count
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_comments_task
ON task_comments(task_id);
-- Index for attachments count
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_attachments_task
ON task_attachments(task_id);
-- Index for work log aggregation
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_work_log_task
ON task_work_log(task_id);
-- Index for subscribers check
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_subscribers_task
ON task_subscribers(task_id);
-- Index for dependencies check
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_dependencies_task
ON task_dependencies(task_id);
-- Index for timers lookup
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_timers_task_user
ON task_timers(task_id, user_id);
-- Index for custom columns
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_cc_column_values_task
ON cc_column_values(task_id);
-- Index for team member info view optimization
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_team_members_team_user
ON team_members(team_id, user_id)
WHERE active = TRUE;
-- Index for notification settings
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_notification_settings_user_team
ON notification_settings(user_id, team_id);
-- Index for task status categories
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_statuses_category
ON task_statuses(category_id, project_id);
-- Index for project phases
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_project_phases_project_sort
ON project_phases(project_id, sort_index);
-- Index for task priorities
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_priorities_value
ON task_priorities(value);
-- Index for team labels
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_team_labels_team
ON team_labels(team_id);
-- NEW INDEXES FOR PERFORMANCE OPTIMIZATION --
-- Composite index for task main query optimization (covers most WHERE conditions)
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_performance_main
ON tasks(project_id, archived, parent_task_id, status_id, priority_id)
WHERE archived = FALSE;
-- Index for sorting by sort_order with project filter
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_project_sort_order
ON tasks(project_id, sort_order)
WHERE archived = FALSE;
-- Index for email_invitations to optimize team_member_info_view
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_email_invitations_team_member
ON email_invitations(team_member_id);
-- Covering index for task status with category information
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_statuses_covering
ON task_statuses(id, category_id, project_id);
-- Index for task aggregation queries (parent task progress calculation)
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_parent_status_archived
ON tasks(parent_task_id, status_id, archived)
WHERE archived = FALSE;
-- Index for project team member filtering
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_team_members_project_lookup
ON team_members(team_id, active, user_id)
WHERE active = TRUE;
-- Covering index for tasks with frequently accessed columns
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_covering_main
ON tasks(id, project_id, archived, parent_task_id, status_id, priority_id, sort_order, name)
WHERE archived = FALSE;
-- Index for task search functionality
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_name_search
ON tasks USING gin(to_tsvector('english', name))
WHERE archived = FALSE;
-- Index for date-based filtering (if used)
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_dates
ON tasks(project_id, start_date, end_date)
WHERE archived = FALSE;
-- Index for task timers with user filtering
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_timers_user_task
ON task_timers(user_id, task_id);
-- Index for sys_task_status_categories lookups
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_sys_task_status_categories_covering
ON sys_task_status_categories(id, color_code, color_code_dark, is_done, is_doing, is_todo);

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

@@ -1,143 +0,0 @@
-- Fix window function error in task sort optimized functions
-- Error: window functions are not allowed in UPDATE
-- Replace the optimized sort functions to avoid CTE usage in UPDATE statements
CREATE OR REPLACE FUNCTION handle_task_list_sort_between_groups_optimized(_from_index integer, _to_index integer, _task_id uuid, _project_id uuid, _batch_size integer DEFAULT 100) RETURNS void
LANGUAGE plpgsql
AS
$$
DECLARE
_offset INT := 0;
_affected_rows INT;
BEGIN
-- PERFORMANCE OPTIMIZATION: Use direct updates without CTE in UPDATE
IF (_to_index = -1)
THEN
_to_index = COALESCE((SELECT MAX(sort_order) + 1 FROM tasks WHERE project_id = _project_id), 0);
END IF;
-- PERFORMANCE OPTIMIZATION: Batch updates for large datasets
IF _to_index > _from_index
THEN
LOOP
UPDATE tasks
SET sort_order = sort_order - 1
WHERE project_id = _project_id
AND sort_order > _from_index
AND sort_order < _to_index
AND sort_order > _offset
AND sort_order <= _offset + _batch_size;
GET DIAGNOSTICS _affected_rows = ROW_COUNT;
EXIT WHEN _affected_rows = 0;
_offset := _offset + _batch_size;
END LOOP;
UPDATE tasks SET sort_order = _to_index - 1 WHERE id = _task_id AND project_id = _project_id;
END IF;
IF _to_index < _from_index
THEN
_offset := 0;
LOOP
UPDATE tasks
SET sort_order = sort_order + 1
WHERE project_id = _project_id
AND sort_order > _to_index
AND sort_order < _from_index
AND sort_order > _offset
AND sort_order <= _offset + _batch_size;
GET DIAGNOSTICS _affected_rows = ROW_COUNT;
EXIT WHEN _affected_rows = 0;
_offset := _offset + _batch_size;
END LOOP;
UPDATE tasks SET sort_order = _to_index + 1 WHERE id = _task_id AND project_id = _project_id;
END IF;
END
$$;
-- Replace the second optimized sort function
CREATE OR REPLACE FUNCTION handle_task_list_sort_inside_group_optimized(_from_index integer, _to_index integer, _task_id uuid, _project_id uuid, _batch_size integer DEFAULT 100) RETURNS void
LANGUAGE plpgsql
AS
$$
DECLARE
_offset INT := 0;
_affected_rows INT;
BEGIN
-- PERFORMANCE OPTIMIZATION: Batch updates for large datasets without CTE in UPDATE
IF _to_index > _from_index
THEN
LOOP
UPDATE tasks
SET sort_order = sort_order - 1
WHERE project_id = _project_id
AND sort_order > _from_index
AND sort_order <= _to_index
AND sort_order > _offset
AND sort_order <= _offset + _batch_size;
GET DIAGNOSTICS _affected_rows = ROW_COUNT;
EXIT WHEN _affected_rows = 0;
_offset := _offset + _batch_size;
END LOOP;
END IF;
IF _to_index < _from_index
THEN
_offset := 0;
LOOP
UPDATE tasks
SET sort_order = sort_order + 1
WHERE project_id = _project_id
AND sort_order >= _to_index
AND sort_order < _from_index
AND sort_order > _offset
AND sort_order <= _offset + _batch_size;
GET DIAGNOSTICS _affected_rows = ROW_COUNT;
EXIT WHEN _affected_rows = 0;
_offset := _offset + _batch_size;
END LOOP;
END IF;
UPDATE tasks SET sort_order = _to_index WHERE id = _task_id AND project_id = _project_id;
END
$$;
-- Add simple bulk update function as alternative
CREATE OR REPLACE FUNCTION update_task_sort_orders_bulk(_updates json) RETURNS void
LANGUAGE plpgsql
AS
$$
DECLARE
_update_record RECORD;
BEGIN
-- Simple approach: update each task's sort_order from the provided array
FOR _update_record IN
SELECT
(item->>'task_id')::uuid as task_id,
(item->>'sort_order')::int as sort_order,
(item->>'status_id')::uuid as status_id,
(item->>'priority_id')::uuid as priority_id,
(item->>'phase_id')::uuid as phase_id
FROM json_array_elements(_updates) as item
LOOP
UPDATE tasks
SET
sort_order = _update_record.sort_order,
status_id = COALESCE(_update_record.status_id, status_id),
priority_id = COALESCE(_update_record.priority_id, priority_id)
WHERE id = _update_record.task_id;
-- Handle phase updates separately since it's in a different table
IF _update_record.phase_id IS NOT NULL THEN
INSERT INTO task_phase (task_id, phase_id)
VALUES (_update_record.task_id, _update_record.phase_id)
ON CONFLICT (task_id) DO UPDATE SET phase_id = _update_record.phase_id;
END IF;
END LOOP;
END
$$;

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

@@ -145,7 +145,7 @@ BEGIN
SET progress_value = NULL,
progress_mode = NULL
WHERE project_id = _project_id
AND progress_mode::text::progress_mode_type = _old_mode;
AND progress_mode = _old_mode;
END IF;
RETURN NEW;

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

@@ -1,300 +0,0 @@
-- Fix Duplicate Sort Orders Script
-- This script detects and fixes duplicate sort order values that break task ordering
-- 1. DETECTION QUERIES - Run these first to see the scope of the problem
-- Check for duplicates in main sort_order column
SELECT
project_id,
sort_order,
COUNT(*) as duplicate_count,
STRING_AGG(id::text, ', ') as task_ids
FROM tasks
WHERE project_id IS NOT NULL
GROUP BY project_id, sort_order
HAVING COUNT(*) > 1
ORDER BY project_id, sort_order;
-- Check for duplicates in status_sort_order
SELECT
project_id,
status_sort_order,
COUNT(*) as duplicate_count,
STRING_AGG(id::text, ', ') as task_ids
FROM tasks
WHERE project_id IS NOT NULL
GROUP BY project_id, status_sort_order
HAVING COUNT(*) > 1
ORDER BY project_id, status_sort_order;
-- Check for duplicates in priority_sort_order
SELECT
project_id,
priority_sort_order,
COUNT(*) as duplicate_count,
STRING_AGG(id::text, ', ') as task_ids
FROM tasks
WHERE project_id IS NOT NULL
GROUP BY project_id, priority_sort_order
HAVING COUNT(*) > 1
ORDER BY project_id, priority_sort_order;
-- Check for duplicates in phase_sort_order
SELECT
project_id,
phase_sort_order,
COUNT(*) as duplicate_count,
STRING_AGG(id::text, ', ') as task_ids
FROM tasks
WHERE project_id IS NOT NULL
GROUP BY project_id, phase_sort_order
HAVING COUNT(*) > 1
ORDER BY project_id, phase_sort_order;
-- Note: member_sort_order removed - no longer used
-- 2. CLEANUP FUNCTIONS
-- Fix duplicates in main sort_order column
CREATE OR REPLACE FUNCTION fix_sort_order_duplicates() RETURNS void
LANGUAGE plpgsql
AS
$$
DECLARE
_project RECORD;
_task RECORD;
_counter INTEGER;
BEGIN
-- For each project, reassign sort_order values to ensure uniqueness
FOR _project IN
SELECT DISTINCT project_id
FROM tasks
WHERE project_id IS NOT NULL
LOOP
_counter := 0;
-- Reassign sort_order values sequentially for this project
FOR _task IN
SELECT id
FROM tasks
WHERE project_id = _project.project_id
ORDER BY sort_order, created_at
LOOP
UPDATE tasks
SET sort_order = _counter
WHERE id = _task.id;
_counter := _counter + 1;
END LOOP;
END LOOP;
RAISE NOTICE 'Fixed sort_order duplicates for all projects';
END
$$;
-- Fix duplicates in status_sort_order column
CREATE OR REPLACE FUNCTION fix_status_sort_order_duplicates() RETURNS void
LANGUAGE plpgsql
AS
$$
DECLARE
_project RECORD;
_task RECORD;
_counter INTEGER;
BEGIN
FOR _project IN
SELECT DISTINCT project_id
FROM tasks
WHERE project_id IS NOT NULL
LOOP
_counter := 0;
FOR _task IN
SELECT id
FROM tasks
WHERE project_id = _project.project_id
ORDER BY status_sort_order, created_at
LOOP
UPDATE tasks
SET status_sort_order = _counter
WHERE id = _task.id;
_counter := _counter + 1;
END LOOP;
END LOOP;
RAISE NOTICE 'Fixed status_sort_order duplicates for all projects';
END
$$;
-- Fix duplicates in priority_sort_order column
CREATE OR REPLACE FUNCTION fix_priority_sort_order_duplicates() RETURNS void
LANGUAGE plpgsql
AS
$$
DECLARE
_project RECORD;
_task RECORD;
_counter INTEGER;
BEGIN
FOR _project IN
SELECT DISTINCT project_id
FROM tasks
WHERE project_id IS NOT NULL
LOOP
_counter := 0;
FOR _task IN
SELECT id
FROM tasks
WHERE project_id = _project.project_id
ORDER BY priority_sort_order, created_at
LOOP
UPDATE tasks
SET priority_sort_order = _counter
WHERE id = _task.id;
_counter := _counter + 1;
END LOOP;
END LOOP;
RAISE NOTICE 'Fixed priority_sort_order duplicates for all projects';
END
$$;
-- Fix duplicates in phase_sort_order column
CREATE OR REPLACE FUNCTION fix_phase_sort_order_duplicates() RETURNS void
LANGUAGE plpgsql
AS
$$
DECLARE
_project RECORD;
_task RECORD;
_counter INTEGER;
BEGIN
FOR _project IN
SELECT DISTINCT project_id
FROM tasks
WHERE project_id IS NOT NULL
LOOP
_counter := 0;
FOR _task IN
SELECT id
FROM tasks
WHERE project_id = _project.project_id
ORDER BY phase_sort_order, created_at
LOOP
UPDATE tasks
SET phase_sort_order = _counter
WHERE id = _task.id;
_counter := _counter + 1;
END LOOP;
END LOOP;
RAISE NOTICE 'Fixed phase_sort_order duplicates for all projects';
END
$$;
-- Note: fix_member_sort_order_duplicates() removed - no longer needed
-- Master function to fix all sort order duplicates
CREATE OR REPLACE FUNCTION fix_all_duplicate_sort_orders() RETURNS void
LANGUAGE plpgsql
AS
$$
BEGIN
RAISE NOTICE 'Starting sort order cleanup for all columns...';
PERFORM fix_sort_order_duplicates();
PERFORM fix_status_sort_order_duplicates();
PERFORM fix_priority_sort_order_duplicates();
PERFORM fix_phase_sort_order_duplicates();
RAISE NOTICE 'Completed sort order cleanup for all columns';
END
$$;
-- 3. VERIFICATION FUNCTION
-- Verify that duplicates have been fixed
CREATE OR REPLACE FUNCTION verify_sort_order_integrity() RETURNS TABLE(
column_name text,
project_id uuid,
duplicate_count bigint,
status text
)
LANGUAGE plpgsql
AS
$$
BEGIN
-- Check sort_order duplicates
RETURN QUERY
SELECT
'sort_order'::text as column_name,
t.project_id,
COUNT(*) as duplicate_count,
CASE WHEN COUNT(*) > 1 THEN 'DUPLICATES FOUND' ELSE 'OK' END as status
FROM tasks t
WHERE t.project_id IS NOT NULL
GROUP BY t.project_id, t.sort_order
HAVING COUNT(*) > 1;
-- Check status_sort_order duplicates
RETURN QUERY
SELECT
'status_sort_order'::text as column_name,
t.project_id,
COUNT(*) as duplicate_count,
CASE WHEN COUNT(*) > 1 THEN 'DUPLICATES FOUND' ELSE 'OK' END as status
FROM tasks t
WHERE t.project_id IS NOT NULL
GROUP BY t.project_id, t.status_sort_order
HAVING COUNT(*) > 1;
-- Check priority_sort_order duplicates
RETURN QUERY
SELECT
'priority_sort_order'::text as column_name,
t.project_id,
COUNT(*) as duplicate_count,
CASE WHEN COUNT(*) > 1 THEN 'DUPLICATES FOUND' ELSE 'OK' END as status
FROM tasks t
WHERE t.project_id IS NOT NULL
GROUP BY t.project_id, t.priority_sort_order
HAVING COUNT(*) > 1;
-- Check phase_sort_order duplicates
RETURN QUERY
SELECT
'phase_sort_order'::text as column_name,
t.project_id,
COUNT(*) as duplicate_count,
CASE WHEN COUNT(*) > 1 THEN 'DUPLICATES FOUND' ELSE 'OK' END as status
FROM tasks t
WHERE t.project_id IS NOT NULL
GROUP BY t.project_id, t.phase_sort_order
HAVING COUNT(*) > 1;
-- Note: member_sort_order verification removed - column no longer used
END
$$;
-- 4. USAGE INSTRUCTIONS
/*
USAGE:
1. First, run the detection queries to see which projects have duplicates
2. Then run this to fix all duplicates:
SELECT fix_all_duplicate_sort_orders();
3. Finally, verify the fix worked:
SELECT * FROM verify_sort_order_integrity();
If verification returns no rows, all duplicates have been fixed successfully.
WARNING: This will reassign sort order values based on current order + creation time.
Make sure to backup your database before running these functions.
*/

View File

@@ -1,37 +0,0 @@
-- Migration: Add separate sort order columns for different grouping types
-- This allows users to maintain different task orders when switching between grouping views
-- Add new sort order columns
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS status_sort_order INTEGER DEFAULT 0;
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS priority_sort_order INTEGER DEFAULT 0;
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS phase_sort_order INTEGER DEFAULT 0;
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS member_sort_order INTEGER DEFAULT 0;
-- Initialize new columns with current sort_order values
UPDATE tasks SET
status_sort_order = sort_order,
priority_sort_order = sort_order,
phase_sort_order = sort_order,
member_sort_order = sort_order
WHERE status_sort_order = 0
OR priority_sort_order = 0
OR phase_sort_order = 0
OR member_sort_order = 0;
-- Add constraints to ensure non-negative values
ALTER TABLE tasks ADD CONSTRAINT tasks_status_sort_order_check CHECK (status_sort_order >= 0);
ALTER TABLE tasks ADD CONSTRAINT tasks_priority_sort_order_check CHECK (priority_sort_order >= 0);
ALTER TABLE tasks ADD CONSTRAINT tasks_phase_sort_order_check CHECK (phase_sort_order >= 0);
ALTER TABLE tasks ADD CONSTRAINT tasks_member_sort_order_check CHECK (member_sort_order >= 0);
-- Add indexes for performance (since these will be used for ordering)
CREATE INDEX IF NOT EXISTS idx_tasks_status_sort_order ON tasks(project_id, status_sort_order);
CREATE INDEX IF NOT EXISTS idx_tasks_priority_sort_order ON tasks(project_id, priority_sort_order);
CREATE INDEX IF NOT EXISTS idx_tasks_phase_sort_order ON tasks(project_id, phase_sort_order);
CREATE INDEX IF NOT EXISTS idx_tasks_member_sort_order ON tasks(project_id, member_sort_order);
-- Update comments for documentation
COMMENT ON COLUMN tasks.status_sort_order IS 'Sort order when grouped by status';
COMMENT ON COLUMN tasks.priority_sort_order IS 'Sort order when grouped by priority';
COMMENT ON COLUMN tasks.phase_sort_order IS 'Sort order when grouped by phase';
COMMENT ON COLUMN tasks.member_sort_order IS 'Sort order when grouped by members/assignees';

View File

@@ -1,172 +0,0 @@
-- Migration: Update database functions to handle grouping-specific sort orders
-- Function to get the appropriate sort column name based on grouping type
CREATE OR REPLACE FUNCTION get_sort_column_name(_group_by TEXT) RETURNS TEXT
LANGUAGE plpgsql
AS
$$
BEGIN
CASE _group_by
WHEN 'status' THEN RETURN 'status_sort_order';
WHEN 'priority' THEN RETURN 'priority_sort_order';
WHEN 'phase' THEN RETURN 'phase_sort_order';
WHEN 'members' THEN RETURN 'member_sort_order';
ELSE RETURN 'sort_order'; -- fallback to general sort_order
END CASE;
END;
$$;
-- Updated bulk sort order function to handle different sort columns
CREATE OR REPLACE FUNCTION update_task_sort_orders_bulk(_updates json, _group_by text DEFAULT 'status') RETURNS void
LANGUAGE plpgsql
AS
$$
DECLARE
_update_record RECORD;
_sort_column TEXT;
_sql TEXT;
BEGIN
-- Get the appropriate sort column based on grouping
_sort_column := get_sort_column_name(_group_by);
-- Simple approach: update each task's sort_order from the provided array
FOR _update_record IN
SELECT
(item->>'task_id')::uuid as task_id,
(item->>'sort_order')::int as sort_order,
(item->>'status_id')::uuid as status_id,
(item->>'priority_id')::uuid as priority_id,
(item->>'phase_id')::uuid as phase_id
FROM json_array_elements(_updates) as item
LOOP
-- Update the appropriate sort column and other fields using dynamic SQL
-- Only update sort_order if we're using the default sorting
IF _sort_column = 'sort_order' THEN
UPDATE tasks SET
sort_order = _update_record.sort_order,
status_id = COALESCE(_update_record.status_id, status_id),
priority_id = COALESCE(_update_record.priority_id, priority_id)
WHERE id = _update_record.task_id;
ELSE
-- Update only the grouping-specific sort column, not the main sort_order
_sql := 'UPDATE tasks SET ' || _sort_column || ' = $1, ' ||
'status_id = COALESCE($2, status_id), ' ||
'priority_id = COALESCE($3, priority_id) ' ||
'WHERE id = $4';
EXECUTE _sql USING
_update_record.sort_order,
_update_record.status_id,
_update_record.priority_id,
_update_record.task_id;
END IF;
-- Handle phase updates separately since it's in a different table
IF _update_record.phase_id IS NOT NULL THEN
INSERT INTO task_phase (task_id, phase_id)
VALUES (_update_record.task_id, _update_record.phase_id)
ON CONFLICT (task_id) DO UPDATE SET phase_id = _update_record.phase_id;
END IF;
END LOOP;
END;
$$;
-- Updated main sort order change handler
CREATE OR REPLACE FUNCTION handle_task_list_sort_order_change(_body json) RETURNS void
LANGUAGE plpgsql
AS
$$
DECLARE
_from_index INT;
_to_index INT;
_task_id UUID;
_project_id UUID;
_from_group UUID;
_to_group UUID;
_group_by TEXT;
_batch_size INT := 100;
_sort_column TEXT;
_sql TEXT;
BEGIN
_project_id = (_body ->> 'project_id')::UUID;
_task_id = (_body ->> 'task_id')::UUID;
_from_index = (_body ->> 'from_index')::INT;
_to_index = (_body ->> 'to_index')::INT;
_from_group = (_body ->> 'from_group')::UUID;
_to_group = (_body ->> 'to_group')::UUID;
_group_by = (_body ->> 'group_by')::TEXT;
-- Get the appropriate sort column
_sort_column := get_sort_column_name(_group_by);
-- Handle group changes
IF (_from_group <> _to_group OR (_from_group <> _to_group) IS NULL) THEN
IF (_group_by = 'status') THEN
UPDATE tasks
SET status_id = _to_group
WHERE id = _task_id
AND status_id = _from_group
AND project_id = _project_id;
END IF;
IF (_group_by = 'priority') THEN
UPDATE tasks
SET priority_id = _to_group
WHERE id = _task_id
AND priority_id = _from_group
AND project_id = _project_id;
END IF;
IF (_group_by = 'phase') THEN
IF (is_null_or_empty(_to_group) IS FALSE) THEN
INSERT INTO task_phase (task_id, phase_id)
VALUES (_task_id, _to_group)
ON CONFLICT (task_id) DO UPDATE SET phase_id = _to_group;
ELSE
DELETE FROM task_phase WHERE task_id = _task_id;
END IF;
END IF;
END IF;
-- Handle sort order changes using dynamic SQL
IF (_from_index <> _to_index) THEN
-- For the main sort_order column, we need to be careful about unique constraints
IF _sort_column = 'sort_order' THEN
-- Use a transaction-safe approach for the main sort_order column
IF (_to_index > _from_index) THEN
-- Moving down: decrease sort_order for items between old and new position
UPDATE tasks SET sort_order = sort_order - 1
WHERE project_id = _project_id
AND sort_order > _from_index
AND sort_order <= _to_index;
ELSE
-- Moving up: increase sort_order for items between new and old position
UPDATE tasks SET sort_order = sort_order + 1
WHERE project_id = _project_id
AND sort_order >= _to_index
AND sort_order < _from_index;
END IF;
-- Set the new sort_order for the moved task
UPDATE tasks SET sort_order = _to_index WHERE id = _task_id;
ELSE
-- For grouping-specific columns, use dynamic SQL since there's no unique constraint
IF (_to_index > _from_index) THEN
-- Moving down: decrease sort_order for items between old and new position
_sql := 'UPDATE tasks SET ' || _sort_column || ' = ' || _sort_column || ' - 1 ' ||
'WHERE project_id = $1 AND ' || _sort_column || ' > $2 AND ' || _sort_column || ' <= $3';
EXECUTE _sql USING _project_id, _from_index, _to_index;
ELSE
-- Moving up: increase sort_order for items between new and old position
_sql := 'UPDATE tasks SET ' || _sort_column || ' = ' || _sort_column || ' + 1 ' ||
'WHERE project_id = $1 AND ' || _sort_column || ' >= $2 AND ' || _sort_column || ' < $3';
EXECUTE _sql USING _project_id, _to_index, _from_index;
END IF;
-- Set the new sort_order for the moved task
_sql := 'UPDATE tasks SET ' || _sort_column || ' = $1 WHERE id = $2';
EXECUTE _sql USING _to_index, _task_id;
END IF;
END IF;
END;
$$;

View File

@@ -1,179 +0,0 @@
-- Migration: Fix sort order constraint violations
-- First, let's ensure all existing tasks have unique sort_order values within each project
-- This is a one-time fix to ensure data consistency
DO $$
DECLARE
_project RECORD;
_task RECORD;
_counter INTEGER;
BEGIN
-- For each project, reassign sort_order values to ensure uniqueness
FOR _project IN
SELECT DISTINCT project_id
FROM tasks
WHERE project_id IS NOT NULL
LOOP
_counter := 0;
-- Reassign sort_order values sequentially for this project
FOR _task IN
SELECT id
FROM tasks
WHERE project_id = _project.project_id
ORDER BY sort_order, created_at
LOOP
UPDATE tasks
SET sort_order = _counter
WHERE id = _task.id;
_counter := _counter + 1;
END LOOP;
END LOOP;
END
$$;
-- Now create a better version of our functions that properly handles the constraints
-- Updated bulk sort order function that avoids sort_order conflicts
CREATE OR REPLACE FUNCTION update_task_sort_orders_bulk(_updates json, _group_by text DEFAULT 'status') RETURNS void
LANGUAGE plpgsql
AS
$$
DECLARE
_update_record RECORD;
_sort_column TEXT;
_sql TEXT;
BEGIN
-- Get the appropriate sort column based on grouping
_sort_column := get_sort_column_name(_group_by);
-- Process each update record
FOR _update_record IN
SELECT
(item->>'task_id')::uuid as task_id,
(item->>'sort_order')::int as sort_order,
(item->>'status_id')::uuid as status_id,
(item->>'priority_id')::uuid as priority_id,
(item->>'phase_id')::uuid as phase_id
FROM json_array_elements(_updates) as item
LOOP
-- Update the grouping-specific sort column and other fields
_sql := 'UPDATE tasks SET ' || _sort_column || ' = $1, ' ||
'status_id = COALESCE($2, status_id), ' ||
'priority_id = COALESCE($3, priority_id), ' ||
'updated_at = CURRENT_TIMESTAMP ' ||
'WHERE id = $4';
EXECUTE _sql USING
_update_record.sort_order,
_update_record.status_id,
_update_record.priority_id,
_update_record.task_id;
-- Handle phase updates separately since it's in a different table
IF _update_record.phase_id IS NOT NULL THEN
INSERT INTO task_phase (task_id, phase_id)
VALUES (_update_record.task_id, _update_record.phase_id)
ON CONFLICT (task_id) DO UPDATE SET phase_id = _update_record.phase_id;
END IF;
END LOOP;
END;
$$;
-- Also update the helper function to be more explicit
CREATE OR REPLACE FUNCTION get_sort_column_name(_group_by TEXT) RETURNS TEXT
LANGUAGE plpgsql
AS
$$
BEGIN
CASE _group_by
WHEN 'status' THEN RETURN 'status_sort_order';
WHEN 'priority' THEN RETURN 'priority_sort_order';
WHEN 'phase' THEN RETURN 'phase_sort_order';
WHEN 'members' THEN RETURN 'member_sort_order';
-- For backward compatibility, still support general sort_order but be explicit
WHEN 'general' THEN RETURN 'sort_order';
ELSE RETURN 'status_sort_order'; -- Default to status sorting
END CASE;
END;
$$;
-- Updated main sort order change handler that avoids conflicts
CREATE OR REPLACE FUNCTION handle_task_list_sort_order_change(_body json) RETURNS void
LANGUAGE plpgsql
AS
$$
DECLARE
_from_index INT;
_to_index INT;
_task_id UUID;
_project_id UUID;
_from_group UUID;
_to_group UUID;
_group_by TEXT;
_sort_column TEXT;
_sql TEXT;
BEGIN
_project_id = (_body ->> 'project_id')::UUID;
_task_id = (_body ->> 'task_id')::UUID;
_from_index = (_body ->> 'from_index')::INT;
_to_index = (_body ->> 'to_index')::INT;
_from_group = (_body ->> 'from_group')::UUID;
_to_group = (_body ->> 'to_group')::UUID;
_group_by = (_body ->> 'group_by')::TEXT;
-- Get the appropriate sort column
_sort_column := get_sort_column_name(_group_by);
-- Handle group changes first
IF (_from_group <> _to_group OR (_from_group <> _to_group) IS NULL) THEN
IF (_group_by = 'status') THEN
UPDATE tasks
SET status_id = _to_group, updated_at = CURRENT_TIMESTAMP
WHERE id = _task_id
AND project_id = _project_id;
END IF;
IF (_group_by = 'priority') THEN
UPDATE tasks
SET priority_id = _to_group, updated_at = CURRENT_TIMESTAMP
WHERE id = _task_id
AND project_id = _project_id;
END IF;
IF (_group_by = 'phase') THEN
IF (is_null_or_empty(_to_group) IS FALSE) THEN
INSERT INTO task_phase (task_id, phase_id)
VALUES (_task_id, _to_group)
ON CONFLICT (task_id) DO UPDATE SET phase_id = _to_group;
ELSE
DELETE FROM task_phase WHERE task_id = _task_id;
END IF;
END IF;
END IF;
-- Handle sort order changes for the grouping-specific column only
IF (_from_index <> _to_index) THEN
-- Update the grouping-specific sort order (no unique constraint issues)
IF (_to_index > _from_index) THEN
-- Moving down: decrease sort order for items between old and new position
_sql := 'UPDATE tasks SET ' || _sort_column || ' = ' || _sort_column || ' - 1, ' ||
'updated_at = CURRENT_TIMESTAMP ' ||
'WHERE project_id = $1 AND ' || _sort_column || ' > $2 AND ' || _sort_column || ' <= $3';
EXECUTE _sql USING _project_id, _from_index, _to_index;
ELSE
-- Moving up: increase sort order for items between new and old position
_sql := 'UPDATE tasks SET ' || _sort_column || ' = ' || _sort_column || ' + 1, ' ||
'updated_at = CURRENT_TIMESTAMP ' ||
'WHERE project_id = $1 AND ' || _sort_column || ' >= $2 AND ' || _sort_column || ' < $3';
EXECUTE _sql USING _project_id, _to_index, _from_index;
END IF;
-- Set the new sort order for the moved task
_sql := 'UPDATE tasks SET ' || _sort_column || ' = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2';
EXECUTE _sql USING _to_index, _task_id;
END IF;
END;
$$;

View File

@@ -1,93 +0,0 @@
-- Migration: Add survey tables for account setup questionnaire
-- Date: 2025-07-24
-- Description: Creates tables to store survey questions and user responses for account setup flow
BEGIN;
-- Create surveys table to define different types of surveys
CREATE TABLE IF NOT EXISTS surveys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
description TEXT,
survey_type VARCHAR(50) DEFAULT 'account_setup' NOT NULL, -- 'account_setup', 'onboarding', 'feedback'
is_active BOOLEAN DEFAULT TRUE NOT NULL,
created_at TIMESTAMP DEFAULT now() NOT NULL,
updated_at TIMESTAMP DEFAULT now() NOT NULL
);
-- Create survey_questions table to store individual questions
CREATE TABLE IF NOT EXISTS survey_questions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
survey_id UUID REFERENCES surveys(id) ON DELETE CASCADE NOT NULL,
question_key VARCHAR(100) NOT NULL, -- Used for localization keys
question_type VARCHAR(50) NOT NULL, -- 'single_choice', 'multiple_choice', 'text'
is_required BOOLEAN DEFAULT FALSE NOT NULL,
sort_order INTEGER DEFAULT 0 NOT NULL,
options JSONB, -- For choice questions, store options as JSON array
created_at TIMESTAMP DEFAULT now() NOT NULL,
updated_at TIMESTAMP DEFAULT now() NOT NULL
);
-- Create survey_responses table to track user responses to surveys
CREATE TABLE IF NOT EXISTS survey_responses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
survey_id UUID REFERENCES surveys(id) ON DELETE CASCADE NOT NULL,
user_id UUID REFERENCES users(id) ON DELETE CASCADE NOT NULL,
is_completed BOOLEAN DEFAULT FALSE NOT NULL,
started_at TIMESTAMP DEFAULT now() NOT NULL,
completed_at TIMESTAMP,
created_at TIMESTAMP DEFAULT now() NOT NULL,
updated_at TIMESTAMP DEFAULT now() NOT NULL
);
-- Create survey_answers table to store individual question answers
CREATE TABLE IF NOT EXISTS survey_answers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
response_id UUID REFERENCES survey_responses(id) ON DELETE CASCADE NOT NULL,
question_id UUID REFERENCES survey_questions(id) ON DELETE CASCADE NOT NULL,
answer_text TEXT,
answer_json JSONB, -- For multiple choice answers stored as array
created_at TIMESTAMP DEFAULT now() NOT NULL,
updated_at TIMESTAMP DEFAULT now() NOT NULL
);
-- Add performance indexes
CREATE INDEX IF NOT EXISTS idx_surveys_type_active ON surveys(survey_type, is_active);
CREATE INDEX IF NOT EXISTS idx_survey_questions_survey_order ON survey_questions(survey_id, sort_order);
CREATE INDEX IF NOT EXISTS idx_survey_responses_user_survey ON survey_responses(user_id, survey_id);
CREATE INDEX IF NOT EXISTS idx_survey_responses_completed ON survey_responses(survey_id, is_completed);
CREATE INDEX IF NOT EXISTS idx_survey_answers_response ON survey_answers(response_id);
-- Add constraints
ALTER TABLE survey_questions ADD CONSTRAINT survey_questions_sort_order_check CHECK (sort_order >= 0);
ALTER TABLE survey_questions ADD CONSTRAINT survey_questions_type_check CHECK (question_type IN ('single_choice', 'multiple_choice', 'text'));
-- Add unique constraint to prevent duplicate responses per user per survey
ALTER TABLE survey_responses ADD CONSTRAINT unique_user_survey_response UNIQUE (user_id, survey_id);
-- Add unique constraint to prevent duplicate answers per question per response
ALTER TABLE survey_answers ADD CONSTRAINT unique_response_question_answer UNIQUE (response_id, question_id);
-- Insert the default account setup survey
INSERT INTO surveys (name, description, survey_type, is_active) VALUES
('Account Setup Survey', 'Initial questionnaire during account setup to understand user needs', 'account_setup', true)
ON CONFLICT DO NOTHING;
-- Get the survey ID for inserting questions
DO $$
DECLARE
survey_uuid UUID;
BEGIN
SELECT id INTO survey_uuid FROM surveys WHERE survey_type = 'account_setup' AND name = 'Account Setup Survey' LIMIT 1;
-- Insert survey questions
INSERT INTO survey_questions (survey_id, question_key, question_type, is_required, sort_order, options) VALUES
(survey_uuid, 'organization_type', 'single_choice', true, 1, '["freelancer", "startup", "small_medium_business", "agency", "enterprise", "other"]'),
(survey_uuid, 'user_role', 'single_choice', true, 2, '["founder_ceo", "project_manager", "software_developer", "designer", "operations", "other"]'),
(survey_uuid, 'main_use_cases', 'multiple_choice', true, 3, '["task_management", "team_collaboration", "resource_planning", "client_communication", "time_tracking", "other"]'),
(survey_uuid, 'previous_tools', 'text', false, 4, null),
(survey_uuid, 'how_heard_about', 'single_choice', false, 5, '["google_search", "twitter", "linkedin", "friend_colleague", "blog_article", "other"]')
ON CONFLICT DO NOTHING;
END $$;
COMMIT;

View File

@@ -1,72 +0,0 @@
# Node-pg-migrate Migrations
This directory contains database migrations managed by node-pg-migrate.
## Migration Commands
- `npm run migrate:create -- migration-name` - Create a new migration file
- `npm run migrate:up` - Run all pending migrations
- `npm run migrate:down` - Rollback the last migration
- `npm run migrate:redo` - Rollback and re-run the last migration
## Migration File Format
Migrations are JavaScript files with timestamp prefixes (e.g., `20250115000000_performance-indexes.js`).
Each migration file exports two functions:
- `exports.up` - Contains the forward migration logic
- `exports.down` - Contains the rollback logic
## Best Practices
1. **Always use IF EXISTS/IF NOT EXISTS checks** to make migrations idempotent
2. **Test migrations locally** before deploying to production
3. **Include rollback logic** in the `down` function for all changes
4. **Use descriptive names** for migration files
5. **Keep migrations focused** - one logical change per migration
## Example Migration
```javascript
exports.up = pgm => {
// Create table with IF NOT EXISTS
pgm.createTable('users', {
id: 'id',
name: { type: 'varchar(100)', notNull: true },
created_at: {
type: 'timestamp',
notNull: true,
default: pgm.func('current_timestamp')
}
}, { ifNotExists: true });
// Add index with IF NOT EXISTS
pgm.createIndex('users', 'name', {
name: 'idx_users_name',
ifNotExists: true
});
};
exports.down = pgm => {
// Drop in reverse order
pgm.dropIndex('users', 'name', {
name: 'idx_users_name',
ifExists: true
});
pgm.dropTable('users', { ifExists: true });
};
```
## Migration History
The `pgmigrations` table tracks which migrations have been run. Do not modify this table manually.
## Converting from SQL Migrations
When converting SQL migrations to node-pg-migrate format:
1. Wrap SQL statements in `pgm.sql()` calls
2. Use node-pg-migrate helper methods where possible (createTable, addColumns, etc.)
3. Always include `IF EXISTS/IF NOT EXISTS` checks
4. Ensure proper rollback logic in the `down` function

View File

@@ -12,7 +12,10 @@ CREATE TYPE DEPENDENCY_TYPE AS ENUM ('blocked_by');
CREATE TYPE SCHEDULE_TYPE AS ENUM ('daily', 'weekly', 'yearly', 'monthly', 'every_x_days', 'every_x_weeks', 'every_x_months');
CREATE TYPE LANGUAGE_TYPE AS ENUM ('en', 'es', 'pt', 'alb', 'de', 'zh_cn');
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);
@@ -1410,13 +1419,17 @@ CREATE TABLE IF NOT EXISTS tasks (
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
sort_order INTEGER DEFAULT 0 NOT NULL,
roadmap_sort_order INTEGER DEFAULT 0 NOT NULL,
status_sort_order INTEGER DEFAULT 0 NOT NULL,
priority_sort_order INTEGER DEFAULT 0 NOT NULL,
phase_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);
@@ -1502,21 +1515,6 @@ ALTER TABLE tasks
ADD CONSTRAINT tasks_total_minutes_check
CHECK ((total_minutes >= (0)::NUMERIC) AND (total_minutes <= (999999)::NUMERIC));
-- Add constraints for new sort order columns
ALTER TABLE tasks ADD CONSTRAINT tasks_status_sort_order_check CHECK (status_sort_order >= 0);
ALTER TABLE tasks ADD CONSTRAINT tasks_priority_sort_order_check CHECK (priority_sort_order >= 0);
ALTER TABLE tasks ADD CONSTRAINT tasks_phase_sort_order_check CHECK (phase_sort_order >= 0);
-- Add indexes for performance on new sort order columns
CREATE INDEX IF NOT EXISTS idx_tasks_status_sort_order ON tasks(project_id, status_sort_order);
CREATE INDEX IF NOT EXISTS idx_tasks_priority_sort_order ON tasks(project_id, priority_sort_order);
CREATE INDEX IF NOT EXISTS idx_tasks_phase_sort_order ON tasks(project_id, phase_sort_order);
-- Add comments for documentation
COMMENT ON COLUMN tasks.status_sort_order IS 'Sort order when grouped by status';
COMMENT ON COLUMN tasks.priority_sort_order IS 'Sort order when grouped by priority';
COMMENT ON COLUMN tasks.phase_sort_order IS 'Sort order when grouped by phase';
CREATE TABLE IF NOT EXISTS tasks_assignees (
task_id UUID NOT NULL,
project_member_id UUID NOT NULL,
@@ -2297,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

@@ -32,37 +32,3 @@ SELECT u.avatar_url,
FROM team_members
LEFT JOIN users u ON team_members.user_id = u.id;
-- PERFORMANCE OPTIMIZATION: Create materialized view for team member info
-- This pre-calculates the expensive joins and subqueries from team_member_info_view
CREATE MATERIALIZED VIEW IF NOT EXISTS team_member_info_mv AS
SELECT
u.avatar_url,
COALESCE(u.email, ei.email) AS email,
COALESCE(u.name, ei.name) AS name,
u.id AS user_id,
tm.id AS team_member_id,
tm.team_id,
tm.active,
u.socket_id
FROM team_members tm
LEFT JOIN users u ON tm.user_id = u.id
LEFT JOIN email_invitations ei ON ei.team_member_id = tm.id
WHERE tm.active = TRUE;
-- Create unique index on the materialized view for fast lookups
CREATE UNIQUE INDEX IF NOT EXISTS idx_team_member_info_mv_team_member_id
ON team_member_info_mv(team_member_id);
CREATE INDEX IF NOT EXISTS idx_team_member_info_mv_team_user
ON team_member_info_mv(team_id, user_id);
-- Function to refresh the materialized view
CREATE OR REPLACE FUNCTION refresh_team_member_info_mv()
RETURNS void
LANGUAGE plpgsql
AS $$
BEGIN
REFRESH MATERIALIZED VIEW CONCURRENTLY team_member_info_mv;
END;
$$;

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)
@@ -4313,24 +4313,6 @@ BEGIN
END
$$;
-- Helper function to get the appropriate sort column name based on grouping type
CREATE OR REPLACE FUNCTION get_sort_column_name(_group_by TEXT) RETURNS TEXT
LANGUAGE plpgsql
AS
$$
BEGIN
CASE _group_by
WHEN 'status' THEN RETURN 'status_sort_order';
WHEN 'priority' THEN RETURN 'priority_sort_order';
WHEN 'phase' THEN RETURN 'phase_sort_order';
WHEN 'members' THEN RETURN 'member_sort_order';
-- For backward compatibility, still support general sort_order but be explicit
WHEN 'general' THEN RETURN 'sort_order';
ELSE RETURN 'status_sort_order'; -- Default to status sorting
END CASE;
END;
$$;
CREATE OR REPLACE FUNCTION handle_task_list_sort_order_change(_body json) RETURNS void
LANGUAGE plpgsql
AS
@@ -4343,67 +4325,54 @@ DECLARE
_from_group UUID;
_to_group UUID;
_group_by TEXT;
_sort_column TEXT;
_sql TEXT;
BEGIN
_project_id = (_body ->> 'project_id')::UUID;
_task_id = (_body ->> 'task_id')::UUID;
_from_index = (_body ->> 'from_index')::INT;
_to_index = (_body ->> 'to_index')::INT;
_from_index = (_body ->> 'from_index')::INT; -- from sort_order
_to_index = (_body ->> 'to_index')::INT; -- to sort_order
_from_group = (_body ->> 'from_group')::UUID;
_to_group = (_body ->> 'to_group')::UUID;
_group_by = (_body ->> 'group_by')::TEXT;
-- Get the appropriate sort column
_sort_column := get_sort_column_name(_group_by);
-- Handle group changes first
IF (_from_group <> _to_group OR (_from_group <> _to_group) IS NULL) THEN
IF (_group_by = 'status') THEN
UPDATE tasks
SET status_id = _to_group, updated_at = CURRENT_TIMESTAMP
WHERE id = _task_id
AND project_id = _project_id;
IF (_from_group <> _to_group OR (_from_group <> _to_group) IS NULL)
THEN
IF (_group_by = 'status')
THEN
UPDATE tasks SET status_id = _to_group WHERE id = _task_id AND status_id = _from_group;
END IF;
IF (_group_by = 'priority') THEN
UPDATE tasks
SET priority_id = _to_group, updated_at = CURRENT_TIMESTAMP
WHERE id = _task_id
AND project_id = _project_id;
IF (_group_by = 'priority')
THEN
UPDATE tasks SET priority_id = _to_group WHERE id = _task_id AND priority_id = _from_group;
END IF;
IF (_group_by = 'phase') THEN
IF (is_null_or_empty(_to_group) IS FALSE) THEN
IF (_group_by = 'phase')
THEN
IF (is_null_or_empty(_to_group) IS FALSE)
THEN
INSERT INTO task_phase (task_id, phase_id)
VALUES (_task_id, _to_group)
ON CONFLICT (task_id) DO UPDATE SET phase_id = _to_group;
ELSE
DELETE FROM task_phase WHERE task_id = _task_id;
END IF;
IF (is_null_or_empty(_to_group) IS TRUE)
THEN
DELETE
FROM task_phase
WHERE task_id = _task_id;
END IF;
END IF;
-- Handle sort order changes for the grouping-specific column only
IF (_from_index <> _to_index) THEN
-- Update the grouping-specific sort order (no unique constraint issues)
IF (_to_index > _from_index) THEN
-- Moving down: decrease sort order for items between old and new position
_sql := 'UPDATE tasks SET ' || _sort_column || ' = ' || _sort_column || ' - 1, ' ||
'updated_at = CURRENT_TIMESTAMP ' ||
'WHERE project_id = $1 AND ' || _sort_column || ' > $2 AND ' || _sort_column || ' <= $3';
EXECUTE _sql USING _project_id, _from_index, _to_index;
IF ((_body ->> 'to_last_index')::BOOLEAN IS TRUE AND _from_index < _to_index)
THEN
PERFORM handle_task_list_sort_inside_group(_from_index, _to_index, _task_id, _project_id);
ELSE
-- Moving up: increase sort order for items between new and old position
_sql := 'UPDATE tasks SET ' || _sort_column || ' = ' || _sort_column || ' + 1, ' ||
'updated_at = CURRENT_TIMESTAMP ' ||
'WHERE project_id = $1 AND ' || _sort_column || ' >= $2 AND ' || _sort_column || ' < $3';
EXECUTE _sql USING _project_id, _to_index, _from_index;
PERFORM handle_task_list_sort_between_groups(_from_index, _to_index, _task_id, _project_id);
END IF;
-- Set the new sort order for the moved task
_sql := 'UPDATE tasks SET ' || _sort_column || ' = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2';
EXECUTE _sql USING _to_index, _task_id;
ELSE
PERFORM handle_task_list_sort_inside_group(_from_index, _to_index, _task_id, _project_id);
END IF;
END
$$;
@@ -4608,31 +4577,31 @@ BEGIN
INSERT INTO project_task_list_cols (project_id, name, key, index, pinned)
VALUES (_project_id, 'Progress', 'PROGRESS', 3, TRUE);
INSERT INTO project_task_list_cols (project_id, name, key, index, pinned)
VALUES (_project_id, 'Status', 'STATUS', 4, TRUE);
VALUES (_project_id, 'Members', 'ASSIGNEES', 4, TRUE);
INSERT INTO project_task_list_cols (project_id, name, key, index, pinned)
VALUES (_project_id, 'Members', 'ASSIGNEES', 5, TRUE);
VALUES (_project_id, 'Labels', 'LABELS', 5, TRUE);
INSERT INTO project_task_list_cols (project_id, name, key, index, pinned)
VALUES (_project_id, 'Labels', 'LABELS', 6, TRUE);
VALUES (_project_id, 'Status', 'STATUS', 6, TRUE);
INSERT INTO project_task_list_cols (project_id, name, key, index, pinned)
VALUES (_project_id, 'Phase', 'PHASE', 7, TRUE);
VALUES (_project_id, 'Priority', 'PRIORITY', 7, TRUE);
INSERT INTO project_task_list_cols (project_id, name, key, index, pinned)
VALUES (_project_id, 'Priority', 'PRIORITY', 8, TRUE);
VALUES (_project_id, 'Time Tracking', 'TIME_TRACKING', 8, TRUE);
INSERT INTO project_task_list_cols (project_id, name, key, index, pinned)
VALUES (_project_id, 'Time Tracking', 'TIME_TRACKING', 9, TRUE);
VALUES (_project_id, 'Estimation', 'ESTIMATION', 9, FALSE);
INSERT INTO project_task_list_cols (project_id, name, key, index, pinned)
VALUES (_project_id, 'Estimation', 'ESTIMATION', 10, FALSE);
VALUES (_project_id, 'Start Date', 'START_DATE', 10, FALSE);
INSERT INTO project_task_list_cols (project_id, name, key, index, pinned)
VALUES (_project_id, 'Start Date', 'START_DATE', 11, FALSE);
VALUES (_project_id, 'Due Date', 'DUE_DATE', 11, TRUE);
INSERT INTO project_task_list_cols (project_id, name, key, index, pinned)
VALUES (_project_id, 'Due Date', 'DUE_DATE', 12, TRUE);
VALUES (_project_id, 'Completed Date', 'COMPLETED_DATE', 12, FALSE);
INSERT INTO project_task_list_cols (project_id, name, key, index, pinned)
VALUES (_project_id, 'Completed Date', 'COMPLETED_DATE', 13, FALSE);
VALUES (_project_id, 'Created Date', 'CREATED_DATE', 13, FALSE);
INSERT INTO project_task_list_cols (project_id, name, key, index, pinned)
VALUES (_project_id, 'Created Date', 'CREATED_DATE', 14, FALSE);
VALUES (_project_id, 'Last Updated', 'LAST_UPDATED', 14, FALSE);
INSERT INTO project_task_list_cols (project_id, name, key, index, pinned)
VALUES (_project_id, 'Last Updated', 'LAST_UPDATED', 15, FALSE);
VALUES (_project_id, 'Reporter', 'REPORTER', 15, FALSE);
INSERT INTO project_task_list_cols (project_id, name, key, index, pinned)
VALUES (_project_id, 'Reporter', 'REPORTER', 16, FALSE);
VALUES (_project_id, 'Phase', 'PHASE', 16, FALSE);
END
$$;
@@ -5432,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;
@@ -5516,15 +5486,8 @@ $$
DECLARE
_iterator NUMERIC := 0;
_status_id TEXT;
_project_id UUID;
_base_sort_order NUMERIC;
BEGIN
-- Get the project_id from the first status to ensure we update all statuses in the same project
SELECT project_id INTO _project_id
FROM task_statuses
WHERE id = (SELECT TRIM(BOTH '"' FROM JSON_ARRAY_ELEMENTS(_status_ids)::TEXT) LIMIT 1)::UUID;
-- Update the sort_order for statuses in the provided order
FOR _status_id IN SELECT * FROM JSON_ARRAY_ELEMENTS((_status_ids)::JSON)
LOOP
UPDATE task_statuses
@@ -5533,29 +5496,6 @@ BEGIN
_iterator := _iterator + 1;
END LOOP;
-- Get the base sort order for remaining statuses (simple count approach)
SELECT COUNT(*) INTO _base_sort_order
FROM task_statuses ts2
WHERE ts2.project_id = _project_id
AND ts2.id = ANY(SELECT (TRIM(BOTH '"' FROM JSON_ARRAY_ELEMENTS(_status_ids)::TEXT))::UUID);
-- Update remaining statuses with simple sequential numbering
-- Reset iterator to start from base_sort_order
_iterator := _base_sort_order;
-- Use a cursor approach to avoid window functions
FOR _status_id IN
SELECT id::TEXT FROM task_statuses
WHERE project_id = _project_id
AND id NOT IN (SELECT (TRIM(BOTH '"' FROM JSON_ARRAY_ELEMENTS(_status_ids)::TEXT))::UUID)
ORDER BY sort_order
LOOP
UPDATE task_statuses
SET sort_order = _iterator
WHERE id = _status_id::UUID;
_iterator := _iterator + 1;
END LOOP;
RETURN;
END
$$;
@@ -6434,217 +6374,43 @@ BEGIN
END;
$$;
-- PERFORMANCE OPTIMIZATION: Optimized version with batching for large datasets
CREATE OR REPLACE FUNCTION handle_task_list_sort_between_groups_optimized(_from_index integer, _to_index integer, _task_id uuid, _project_id uuid, _batch_size integer DEFAULT 100) RETURNS void
LANGUAGE plpgsql
AS
$$
DECLARE
_offset INT := 0;
_affected_rows INT;
BEGIN
-- PERFORMANCE OPTIMIZATION: Use direct updates without CTE in UPDATE
IF (_to_index = -1)
THEN
_to_index = COALESCE((SELECT MAX(sort_order) + 1 FROM tasks WHERE project_id = _project_id), 0);
END IF;
-- PERFORMANCE OPTIMIZATION: Batch updates for large datasets
IF _to_index > _from_index
THEN
LOOP
UPDATE tasks
SET sort_order = sort_order - 1
WHERE project_id = _project_id
AND sort_order > _from_index
AND sort_order < _to_index
AND sort_order > _offset
AND sort_order <= _offset + _batch_size;
GET DIAGNOSTICS _affected_rows = ROW_COUNT;
EXIT WHEN _affected_rows = 0;
_offset := _offset + _batch_size;
END LOOP;
UPDATE tasks SET sort_order = _to_index - 1 WHERE id = _task_id AND project_id = _project_id;
END IF;
IF _to_index < _from_index
THEN
_offset := 0;
LOOP
UPDATE tasks
SET sort_order = sort_order + 1
WHERE project_id = _project_id
AND sort_order > _to_index
AND sort_order < _from_index
AND sort_order > _offset
AND sort_order <= _offset + _batch_size;
GET DIAGNOSTICS _affected_rows = ROW_COUNT;
EXIT WHEN _affected_rows = 0;
_offset := _offset + _batch_size;
END LOOP;
UPDATE tasks SET sort_order = _to_index + 1 WHERE id = _task_id AND project_id = _project_id;
END IF;
END
$$;
-- PERFORMANCE OPTIMIZATION: Optimized version with batching for large datasets
CREATE OR REPLACE FUNCTION handle_task_list_sort_inside_group_optimized(_from_index integer, _to_index integer, _task_id uuid, _project_id uuid, _batch_size integer DEFAULT 100) RETURNS void
LANGUAGE plpgsql
AS
$$
DECLARE
_offset INT := 0;
_affected_rows INT;
BEGIN
-- PERFORMANCE OPTIMIZATION: Batch updates for large datasets without CTE in UPDATE
IF _to_index > _from_index
THEN
LOOP
UPDATE tasks
SET sort_order = sort_order - 1
WHERE project_id = _project_id
AND sort_order > _from_index
AND sort_order <= _to_index
AND sort_order > _offset
AND sort_order <= _offset + _batch_size;
GET DIAGNOSTICS _affected_rows = ROW_COUNT;
EXIT WHEN _affected_rows = 0;
_offset := _offset + _batch_size;
END LOOP;
END IF;
IF _to_index < _from_index
THEN
_offset := 0;
LOOP
UPDATE tasks
SET sort_order = sort_order + 1
WHERE project_id = _project_id
AND sort_order >= _to_index
AND sort_order < _from_index
AND sort_order > _offset
AND sort_order <= _offset + _batch_size;
GET DIAGNOSTICS _affected_rows = ROW_COUNT;
EXIT WHEN _affected_rows = 0;
_offset := _offset + _batch_size;
END LOOP;
END IF;
UPDATE tasks SET sort_order = _to_index WHERE id = _task_id AND project_id = _project_id;
END
$$;
-- Updated bulk sort order function that avoids sort_order conflicts
CREATE OR REPLACE FUNCTION update_task_sort_orders_bulk(_updates json, _group_by text DEFAULT 'status') RETURNS void
LANGUAGE plpgsql
AS
$$
DECLARE
_update_record RECORD;
_sort_column TEXT;
_sql TEXT;
BEGIN
-- Get the appropriate sort column based on grouping
_sort_column := get_sort_column_name(_group_by);
-- Process each update record
FOR _update_record IN
SELECT
(item->>'task_id')::uuid as task_id,
(item->>'sort_order')::int as sort_order,
(item->>'status_id')::uuid as status_id,
(item->>'priority_id')::uuid as priority_id,
(item->>'phase_id')::uuid as phase_id
FROM json_array_elements(_updates) as item
LOOP
-- Update the grouping-specific sort column and other fields
_sql := 'UPDATE tasks SET ' || _sort_column || ' = $1, ' ||
'status_id = COALESCE($2, status_id), ' ||
'priority_id = COALESCE($3, priority_id), ' ||
'updated_at = CURRENT_TIMESTAMP ' ||
'WHERE id = $4';
EXECUTE _sql USING
_update_record.sort_order,
_update_record.status_id,
_update_record.priority_id,
_update_record.task_id;
-- Handle phase updates separately since it's in a different table
IF _update_record.phase_id IS NOT NULL THEN
INSERT INTO task_phase (task_id, phase_id)
VALUES (_update_record.task_id, _update_record.phase_id)
ON CONFLICT (task_id) DO UPDATE SET phase_id = _update_record.phase_id;
END IF;
END LOOP;
END
$$;
-- Function to get the appropriate sort column name based on grouping type
CREATE OR REPLACE FUNCTION get_sort_column_name(_group_by TEXT) RETURNS TEXT
LANGUAGE plpgsql
AS
$$
BEGIN
CASE _group_by
WHEN 'status' THEN RETURN 'status_sort_order';
WHEN 'priority' THEN RETURN 'priority_sort_order';
WHEN 'phase' THEN RETURN 'phase_sort_order';
-- For backward compatibility, still support general sort_order but be explicit
WHEN 'general' THEN RETURN 'sort_order';
ELSE RETURN 'status_sort_order'; -- Default to status sorting
END CASE;
END;
$$;
-- Updated bulk sort order function to handle different sort columns
CREATE OR REPLACE FUNCTION update_task_sort_orders_bulk(_updates json, _group_by text DEFAULT 'status') RETURNS void
LANGUAGE plpgsql
AS
$$
DECLARE
_update_record RECORD;
_sort_column TEXT;
_sql TEXT;
BEGIN
-- Get the appropriate sort column based on grouping
_sort_column := get_sort_column_name(_group_by);
-- Process each update record
FOR _update_record IN
SELECT
(item->>'task_id')::uuid as task_id,
(item->>'sort_order')::int as sort_order,
(item->>'status_id')::uuid as status_id,
(item->>'priority_id')::uuid as priority_id,
(item->>'phase_id')::uuid as phase_id
FROM json_array_elements(_updates) as item
LOOP
-- Update the grouping-specific sort column and other fields
_sql := 'UPDATE tasks SET ' || _sort_column || ' = $1, ' ||
'status_id = COALESCE($2, status_id), ' ||
'priority_id = COALESCE($3, priority_id), ' ||
'updated_at = CURRENT_TIMESTAMP ' ||
'WHERE id = $4';
EXECUTE _sql USING
_update_record.sort_order,
_update_record.status_id,
_update_record.priority_id,
_update_record.task_id;
-- Handle phase updates separately since it's in a different table
IF _update_record.phase_id IS NOT NULL THEN
INSERT INTO task_phase (task_id, phase_id)
VALUES (_update_record.task_id, _update_record.phase_id)
ON CONFLICT (task_id) DO UPDATE SET phase_id = _update_record.phase_id;
END IF;
END LOOP;
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

@@ -132,139 +132,3 @@ CREATE INDEX IF NOT EXISTS projects_team_id_index
CREATE INDEX IF NOT EXISTS projects_team_id_name_index
ON projects (team_id, name);
-- Performance indexes for optimized tasks queries
-- From migration: 20250115000000-performance-indexes.sql
-- Composite index for main task filtering
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_project_archived_parent
ON tasks(project_id, archived, parent_task_id)
WHERE archived = FALSE;
-- Index for status joins
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_status_project
ON tasks(status_id, project_id)
WHERE archived = FALSE;
-- Index for assignees lookup
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_assignees_task_member
ON tasks_assignees(task_id, team_member_id);
-- Index for phase lookup
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_phase_task_phase
ON task_phase(task_id, phase_id);
-- Index for subtask counting
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_parent_archived
ON tasks(parent_task_id, archived)
WHERE parent_task_id IS NOT NULL AND archived = FALSE;
-- Index for labels
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_labels_task_label
ON task_labels(task_id, label_id);
-- Index for comments count
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_comments_task
ON task_comments(task_id);
-- Index for attachments count
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_attachments_task
ON task_attachments(task_id);
-- Index for work log aggregation
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_work_log_task
ON task_work_log(task_id);
-- Index for subscribers check
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_subscribers_task
ON task_subscribers(task_id);
-- Index for dependencies check
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_dependencies_task
ON task_dependencies(task_id);
-- Index for timers lookup
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_timers_task_user
ON task_timers(task_id, user_id);
-- Index for custom columns
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_cc_column_values_task
ON cc_column_values(task_id);
-- Index for team member info view optimization
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_team_members_team_user
ON team_members(team_id, user_id)
WHERE active = TRUE;
-- Index for notification settings
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_notification_settings_user_team
ON notification_settings(user_id, team_id);
-- Index for task status categories
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_statuses_category
ON task_statuses(category_id, project_id);
-- Index for project phases
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_project_phases_project_sort
ON project_phases(project_id, sort_index);
-- Index for task priorities
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_priorities_value
ON task_priorities(value);
-- Index for team labels
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_team_labels_team
ON team_labels(team_id);
-- Advanced performance indexes for task optimization
-- Composite index for task main query optimization (covers most WHERE conditions)
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_performance_main
ON tasks(project_id, archived, parent_task_id, status_id, priority_id)
WHERE archived = FALSE;
-- Index for sorting by sort_order with project filter
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_project_sort_order
ON tasks(project_id, sort_order)
WHERE archived = FALSE;
-- Index for email_invitations to optimize team_member_info_view
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_email_invitations_team_member
ON email_invitations(team_member_id);
-- Covering index for task status with category information
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_statuses_covering
ON task_statuses(id, category_id, project_id);
-- Index for task aggregation queries (parent task progress calculation)
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_parent_status_archived
ON tasks(parent_task_id, status_id, archived)
WHERE archived = FALSE;
-- Index for project team member filtering
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_team_members_project_lookup
ON team_members(team_id, active, user_id)
WHERE active = TRUE;
-- Covering index for tasks with frequently accessed columns
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_covering_main
ON tasks(id, project_id, archived, parent_task_id, status_id, priority_id, sort_order, name)
WHERE archived = FALSE;
-- Index for task search functionality
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_name_search
ON tasks USING gin(to_tsvector('english', name))
WHERE archived = FALSE;
-- Index for date-based filtering (if used)
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_dates
ON tasks(project_id, start_date, end_date)
WHERE archived = FALSE;
-- Index for task timers with user filtering
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_timers_user_task
ON task_timers(user_id, task_id);
-- Index for sys_task_status_categories lookups
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_sys_task_status_categories_covering
ON sys_task_status_categories(id, color_code, color_code_dark, is_done, is_doing, is_todo);

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

@@ -0,0 +1,28 @@
module.exports = {
brotli_js: {
options: {
mode: "brotli",
brotli: {
mode: 1
}
},
expand: true,
cwd: "build/public",
src: ["**/*.js"],
dest: "build/public",
extDot: "last",
ext: ".js.br"
},
gzip_js: {
options: {
mode: "gzip"
},
files: [{
expand: true,
cwd: "build/public",
src: ["**/*.js"],
dest: "build/public",
ext: ".js.gz"
}]
}
};

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@
"private": true,
"engines": {
"npm": ">=8.11.0",
"node": ">=20.0.0",
"node": ">=16.13.0",
"yarn": "WARNING: Please use npm package manager instead of yarn"
},
"main": "build/bin/www",
@@ -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",
@@ -85,6 +88,7 @@
"passport-local": "^1.0.0",
"path": "^0.12.7",
"pg": "^8.14.1",
"pg-native": "^3.3.0",
"pug": "^3.0.2",
"redis": "^4.6.7",
"sanitize-html": "^2.11.0",
@@ -92,10 +96,8 @@
"sharp": "^0.32.6",
"slugify": "^1.6.6",
"socket.io": "^4.7.1",
"tinymce": "^7.8.0",
"uglify-js": "^3.17.4",
"winston": "^3.10.0",
"worklenz-backend": "file:",
"xss-filters": "^1.2.7"
},
"devDependencies": {
@@ -103,17 +105,15 @@
"@babel/preset-typescript": "^7.22.5",
"@types/bcrypt": "^5.0.0",
"@types/bluebird": "^3.5.38",
"@types/body-parser": "^1.19.2",
"@types/compression": "^1.7.2",
"@types/connect-flash": "^0.0.37",
"@types/cookie-parser": "^1.4.3",
"@types/cron": "^2.0.1",
"@types/crypto-js": "^4.2.2",
"@types/csurf": "^1.11.2",
"@types/express": "^4.17.21",
"@types/express": "^4.17.17",
"@types/express-brute": "^1.0.2",
"@types/express-brute-redis": "^0.0.4",
"@types/express-serve-static-core": "^4.17.34",
"@types/express-session": "^1.17.7",
"@types/fs-extra": "^9.0.13",
"@types/hpp": "^0.2.2",

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;

View File

@@ -137,10 +137,6 @@ export default class HomePageController extends WorklenzControllerBase {
WHERE category_id NOT IN (SELECT id
FROM sys_task_status_categories
WHERE is_done IS FALSE))
AND NOT EXISTS(SELECT project_id
FROM archived_projects
WHERE project_id = p.id
AND user_id = $2)
${groupByClosure}
ORDER BY t.end_date ASC`;
@@ -162,13 +158,9 @@ export default class HomePageController extends WorklenzControllerBase {
WHERE category_id NOT IN (SELECT id
FROM sys_task_status_categories
WHERE is_done IS FALSE))
AND NOT EXISTS(SELECT project_id
FROM archived_projects
WHERE project_id = p.id
AND user_id = $3)
${groupByClosure}`;
const result = await db.query(q, [teamId, userId, userId]);
const result = await db.query(q, [teamId, userId]);
const [row] = result.rows;
return row;
}

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

@@ -317,58 +317,65 @@ export default class ProjectsController extends WorklenzControllerBase {
@HandleExceptions()
public static async getMembersByProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const {sortField, sortOrder, size, offset} = this.toPaginationOptions(req.query, "name");
const search = (req.query.search || "").toString().trim();
let searchFilter = "";
const params = [req.params.id, req.user?.team_id ?? null, size, offset];
if (search) {
searchFilter = `
AND (
(SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id) ILIKE '%' || $5 || '%'
OR (SELECT email FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id) ILIKE '%' || $5 || '%'
)
`;
params.push(search);
}
const q = `
WITH filtered_members AS (
SELECT project_members.id,
SELECT ROW_TO_JSON(rec) AS members
FROM (SELECT COUNT(*) AS total,
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON)
FROM (SELECT project_members.id,
team_member_id,
(SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id) AS name,
(SELECT email FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id) AS email,
(SELECT name
FROM team_member_info_view
WHERE team_member_info_view.team_member_id = tm.id),
(SELECT email
FROM team_member_info_view
WHERE team_member_info_view.team_member_id = tm.id) AS email,
u.avatar_url,
(SELECT COUNT(*) FROM tasks WHERE archived IS FALSE AND project_id = project_members.project_id AND id IN (SELECT task_id FROM tasks_assignees WHERE tasks_assignees.project_member_id = project_members.id)) AS all_tasks_count,
(SELECT COUNT(*) FROM tasks WHERE archived IS FALSE AND project_id = project_members.project_id AND id IN (SELECT task_id FROM tasks_assignees WHERE tasks_assignees.project_member_id = project_members.id) AND status_id IN (SELECT id FROM task_statuses WHERE category_id = (SELECT id FROM sys_task_status_categories WHERE is_done IS TRUE))) AS completed_tasks_count,
EXISTS(SELECT email FROM email_invitations WHERE team_member_id = project_members.team_member_id AND email_invitations.team_id = $2) AS pending_invitation,
(SELECT project_access_levels.name FROM project_access_levels WHERE project_access_levels.id = project_members.project_access_level_id) AS access,
(SELECT COUNT(*)
FROM tasks
WHERE archived IS FALSE
AND project_id = project_members.project_id
AND id IN (SELECT task_id
FROM tasks_assignees
WHERE tasks_assignees.project_member_id = project_members.id)) AS all_tasks_count,
(SELECT COUNT(*)
FROM tasks
WHERE archived IS FALSE
AND project_id = project_members.project_id
AND id IN (SELECT task_id
FROM tasks_assignees
WHERE tasks_assignees.project_member_id = project_members.id)
AND status_id IN (SELECT id
FROM task_statuses
WHERE category_id = (SELECT id
FROM sys_task_status_categories
WHERE is_done IS TRUE))) AS completed_tasks_count,
EXISTS(SELECT email
FROM email_invitations
WHERE team_member_id = project_members.team_member_id
AND email_invitations.team_id = $2) AS pending_invitation,
(SELECT project_access_levels.name
FROM project_access_levels
WHERE project_access_levels.id = project_members.project_access_level_id) AS access,
(SELECT name FROM job_titles WHERE id = tm.job_title_id) AS job_title
FROM project_members
INNER JOIN team_members tm ON project_members.team_member_id = tm.id
LEFT JOIN users u ON tm.user_id = u.id
WHERE project_id = $1
${search ? searchFilter : ""}
)
SELECT
(SELECT COUNT(*) FROM filtered_members) AS total,
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON)
FROM (
SELECT * FROM filtered_members
ORDER BY ${sortField} ${sortOrder}
LIMIT $3 OFFSET $4
) t
) AS data
LIMIT $3 OFFSET $4) t) AS data
FROM project_members
WHERE project_id = $1) rec;
`;
const result = await db.query(q, params);
const result = await db.query(q, [req.params.id, req.user?.team_id ?? null, size, offset]);
const [data] = result.rows;
for (const member of data?.data || []) {
for (const member of data?.members.data || []) {
member.progress = member.all_tasks_count > 0
? ((member.completed_tasks_count / member.all_tasks_count) * 100).toFixed(0) : 0;
}
return res.status(200).send(new ServerResponse(true, data || this.paginatedDatasetDefaultStruct));
return res.status(200).send(new ServerResponse(true, data?.members || this.paginatedDatasetDefaultStruct));
}
@HandleExceptions()
@@ -388,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,
@@ -749,186 +757,4 @@ export default class ProjectsController extends WorklenzControllerBase {
}
@HandleExceptions()
public static async getGrouped(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
// Use qualified field name for projects to avoid ambiguity
const {searchQuery, sortField, sortOrder, size, offset} = this.toPaginationOptions(req.query, ["projects.name"]);
const groupBy = req.query.groupBy as string || "category";
const filterByMember = !req.user?.owner && !req.user?.is_admin ?
` AND is_member_of_project(projects.id, '${req.user?.id}', $1) ` : "";
const isFavorites = req.query.filter === "1" ? ` AND EXISTS(SELECT user_id FROM favorite_projects WHERE user_id = '${req.user?.id}' AND project_id = projects.id)` : "";
const isArchived = req.query.filter === "2"
? ` AND EXISTS(SELECT user_id FROM archived_projects WHERE user_id = '${req.user?.id}' AND project_id = projects.id)`
: ` AND NOT EXISTS(SELECT user_id FROM archived_projects WHERE user_id = '${req.user?.id}' AND project_id = projects.id)`;
const categories = this.getFilterByCategoryWhereClosure(req.query.categories as string);
const statuses = this.getFilterByStatusWhereClosure(req.query.statuses as string);
// Determine grouping field and join based on groupBy parameter
let groupField = "";
let groupName = "";
let groupColor = "";
let groupJoin = "";
let groupByFields = "";
let groupOrderBy = "";
switch (groupBy) {
case "client":
groupField = "COALESCE(projects.client_id::text, 'no-client')";
groupName = "COALESCE(clients.name, 'No Client')";
groupColor = "'#688'";
groupJoin = "LEFT JOIN clients ON projects.client_id = clients.id";
groupByFields = "projects.client_id, clients.name";
groupOrderBy = "COALESCE(clients.name, 'No Client')";
break;
case "status":
groupField = "COALESCE(projects.status_id::text, 'no-status')";
groupName = "COALESCE(sys_project_statuses.name, 'No Status')";
groupColor = "COALESCE(sys_project_statuses.color_code, '#888')";
groupJoin = "LEFT JOIN sys_project_statuses ON projects.status_id = sys_project_statuses.id";
groupByFields = "projects.status_id, sys_project_statuses.name, sys_project_statuses.color_code";
groupOrderBy = "COALESCE(sys_project_statuses.name, 'No Status')";
break;
case "category":
default:
groupField = "COALESCE(projects.category_id::text, 'uncategorized')";
groupName = "COALESCE(project_categories.name, 'Uncategorized')";
groupColor = "COALESCE(project_categories.color_code, '#888')";
groupJoin = "LEFT JOIN project_categories ON projects.category_id = project_categories.id";
groupByFields = "projects.category_id, project_categories.name, project_categories.color_code";
groupOrderBy = "COALESCE(project_categories.name, 'Uncategorized')";
}
// Ensure sortField is properly qualified for the inner project query
let qualifiedSortField = sortField;
if (Array.isArray(sortField)) {
qualifiedSortField = sortField[0]; // Take the first field if it's an array
}
// Replace "projects." with "p2." for the inner query
const innerSortField = qualifiedSortField.replace("projects.", "p2.");
const q = `
SELECT ROW_TO_JSON(rec) AS groups
FROM (
SELECT COUNT(DISTINCT ${groupField}) AS total_groups,
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(group_data))), '[]'::JSON)
FROM (
SELECT ${groupField} AS group_key,
${groupName} AS group_name,
${groupColor} AS group_color,
COUNT(*) AS project_count,
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(project_data))), '[]'::JSON)
FROM (
SELECT p2.id,
p2.name,
(SELECT sys_project_statuses.name FROM sys_project_statuses WHERE sys_project_statuses.id = p2.status_id) AS status,
(SELECT sys_project_statuses.color_code FROM sys_project_statuses WHERE sys_project_statuses.id = p2.status_id) AS status_color,
(SELECT sys_project_statuses.icon FROM sys_project_statuses WHERE sys_project_statuses.id = p2.status_id) AS status_icon,
EXISTS(SELECT user_id
FROM favorite_projects
WHERE user_id = '${req.user?.id}'
AND project_id = p2.id) AS favorite,
EXISTS(SELECT user_id
FROM archived_projects
WHERE user_id = '${req.user?.id}'
AND project_id = p2.id) AS archived,
p2.color_code,
p2.start_date,
p2.end_date,
p2.category_id,
(SELECT COUNT(*)
FROM tasks
WHERE archived IS FALSE
AND project_id = p2.id) AS all_tasks_count,
(SELECT COUNT(*)
FROM tasks
WHERE archived IS FALSE
AND project_id = p2.id
AND status_id IN (SELECT task_statuses.id
FROM task_statuses
WHERE task_statuses.project_id = p2.id
AND task_statuses.category_id IN
(SELECT sys_task_status_categories.id FROM sys_task_status_categories WHERE sys_task_status_categories.is_done IS TRUE))) AS completed_tasks_count,
(SELECT COUNT(*)
FROM project_members
WHERE project_members.project_id = p2.id) AS members_count,
(SELECT get_project_members(p2.id)) AS names,
(SELECT clients.name FROM clients WHERE clients.id = p2.client_id) AS client_name,
(SELECT users.name FROM users WHERE users.id = p2.owner_id) AS project_owner,
(SELECT project_categories.name FROM project_categories WHERE project_categories.id = p2.category_id) AS category_name,
(SELECT project_categories.color_code
FROM project_categories
WHERE project_categories.id = p2.category_id) AS category_color,
((SELECT project_members.team_member_id as team_member_id
FROM project_members
WHERE project_members.project_id = p2.id
AND project_members.project_access_level_id = (SELECT project_access_levels.id FROM project_access_levels WHERE project_access_levels.key = 'PROJECT_MANAGER'))) AS project_manager_team_member_id,
(SELECT project_members.default_view
FROM project_members
WHERE project_members.project_id = p2.id
AND project_members.team_member_id = '${req.user?.team_member_id}') AS team_member_default_view,
(SELECT CASE
WHEN ((SELECT MAX(tasks.updated_at)
FROM tasks
WHERE tasks.archived IS FALSE
AND tasks.project_id = p2.id) >
p2.updated_at)
THEN (SELECT MAX(tasks.updated_at)
FROM tasks
WHERE tasks.archived IS FALSE
AND tasks.project_id = p2.id)
ELSE p2.updated_at END) AS updated_at
FROM projects p2
${groupJoin.replace("projects.", "p2.")}
WHERE p2.team_id = $1
AND ${groupField.replace("projects.", "p2.")} = ${groupField}
${categories.replace("projects.", "p2.")}
${statuses.replace("projects.", "p2.")}
${isArchived.replace("projects.", "p2.")}
${isFavorites.replace("projects.", "p2.")}
${filterByMember.replace("projects.", "p2.")}
${searchQuery.replace("projects.", "p2.")}
ORDER BY ${innerSortField} ${sortOrder}
) project_data
) AS projects
FROM projects
${groupJoin}
WHERE projects.team_id = $1 ${categories} ${statuses} ${isArchived} ${isFavorites} ${filterByMember} ${searchQuery}
GROUP BY ${groupByFields}
ORDER BY ${groupOrderBy}
LIMIT $2 OFFSET $3
) group_data
) AS data
FROM projects
${groupJoin}
WHERE projects.team_id = $1 ${categories} ${statuses} ${isArchived} ${isFavorites} ${filterByMember} ${searchQuery}
) rec;
`;
const result = await db.query(q, [req.user?.team_id || null, size, offset]);
const [data] = result.rows;
// Process the grouped data
for (const group of data?.groups.data || []) {
for (const project of group.projects || []) {
project.progress = project.all_tasks_count > 0
? ((project.completed_tasks_count / project.all_tasks_count) * 100).toFixed(0) : 0;
project.updated_at_string = moment(project.updated_at).fromNow();
project.names = this.createTagList(project?.names);
project.names.map((a: any) => a.color_code = getColor(a.name));
if (project.project_manager_team_member_id) {
project.project_manager = {
id: project.project_manager_team_member_id
};
}
}
}
return res.status(200).send(new ServerResponse(true, data?.groups || { total_groups: 0, data: [] }));
}
}

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

@@ -1,179 +0,0 @@
// Example of updated getMemberTimeSheets method with timezone support
// This shows the key changes needed to handle timezones properly
import moment from "moment-timezone";
import db from "../../config/db";
import { IWorkLenzRequest } from "../../interfaces/worklenz-request";
import { IWorkLenzResponse } from "../../interfaces/worklenz-response";
import { ServerResponse } from "../../models/server-response";
import { DATE_RANGES } from "../../shared/constants";
export async function getMemberTimeSheets(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const archived = req.query.archived === "true";
const teams = (req.body.teams || []) as string[];
const teamIds = teams.map(id => `'${id}'`).join(",");
const projects = (req.body.projects || []) as string[];
const projectIds = projects.map(p => `'${p}'`).join(",");
const {billable} = req.body;
// Get user timezone from request or database
const userTimezone = req.body.timezone || await getUserTimezone(req.user?.id || "");
if (!teamIds || !projectIds.length)
return res.status(200).send(new ServerResponse(true, { users: [], projects: [] }));
const { duration, date_range } = req.body;
// Calculate date range with timezone support
let startDate: moment.Moment;
let endDate: moment.Moment;
if (date_range && date_range.length === 2) {
// Convert user's local dates to their timezone's start/end of day
startDate = moment.tz(date_range[0], userTimezone).startOf("day");
endDate = moment.tz(date_range[1], userTimezone).endOf("day");
} else if (duration === DATE_RANGES.ALL_TIME) {
const minDateQuery = `SELECT MIN(COALESCE(start_date, created_at)) as min_date FROM projects WHERE id IN (${projectIds})`;
const minDateResult = await db.query(minDateQuery, []);
const minDate = minDateResult.rows[0]?.min_date;
startDate = minDate ? moment.tz(minDate, userTimezone) : moment.tz("2000-01-01", userTimezone);
endDate = moment.tz(userTimezone);
} else {
// Calculate ranges based on user's timezone
const now = moment.tz(userTimezone);
switch (duration) {
case DATE_RANGES.YESTERDAY:
startDate = now.clone().subtract(1, "day").startOf("day");
endDate = now.clone().subtract(1, "day").endOf("day");
break;
case DATE_RANGES.LAST_WEEK:
startDate = now.clone().subtract(1, "week").startOf("isoWeek");
endDate = now.clone().subtract(1, "week").endOf("isoWeek");
break;
case DATE_RANGES.LAST_MONTH:
startDate = now.clone().subtract(1, "month").startOf("month");
endDate = now.clone().subtract(1, "month").endOf("month");
break;
case DATE_RANGES.LAST_QUARTER:
startDate = now.clone().subtract(3, "months").startOf("day");
endDate = now.clone().endOf("day");
break;
default:
startDate = now.clone().startOf("day");
endDate = now.clone().endOf("day");
}
}
// Convert to UTC for database queries
const startUtc = startDate.utc().format("YYYY-MM-DD HH:mm:ss");
const endUtc = endDate.utc().format("YYYY-MM-DD HH:mm:ss");
// Calculate working days in user's timezone
const totalDays = endDate.diff(startDate, "days") + 1;
let workingDays = 0;
const current = startDate.clone();
while (current.isSameOrBefore(endDate, "day")) {
if (current.isoWeekday() >= 1 && current.isoWeekday() <= 5) {
workingDays++;
}
current.add(1, "day");
}
// Updated SQL query with proper timezone handling
const billableQuery = buildBillableQuery(billable);
const archivedClause = archived ? "" : `AND projects.id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = projects.id AND user_id = '${req.user?.id}')`;
const q = `
WITH project_hours AS (
SELECT
id,
COALESCE(hours_per_day, 8) as hours_per_day
FROM projects
WHERE id IN (${projectIds})
),
total_working_hours AS (
SELECT
SUM(hours_per_day) * ${workingDays} as total_hours
FROM project_hours
)
SELECT
u.id,
u.email,
tm.name,
tm.color_code,
COALESCE(SUM(twl.time_spent), 0) as logged_time,
COALESCE(SUM(twl.time_spent), 0) / 3600.0 as value,
(SELECT total_hours FROM total_working_hours) as total_working_hours,
CASE
WHEN (SELECT total_hours FROM total_working_hours) > 0
THEN ROUND((COALESCE(SUM(twl.time_spent), 0) / 3600.0) / (SELECT total_hours FROM total_working_hours) * 100, 2)
ELSE 0
END as utilization_percent,
ROUND(COALESCE(SUM(twl.time_spent), 0) / 3600.0, 2) as utilized_hours,
ROUND(COALESCE(SUM(twl.time_spent), 0) / 3600.0 - (SELECT total_hours FROM total_working_hours), 2) as over_under_utilized_hours,
'${userTimezone}' as user_timezone,
'${startDate.format("YYYY-MM-DD")}' as report_start_date,
'${endDate.format("YYYY-MM-DD")}' as report_end_date
FROM team_members tm
LEFT JOIN users u ON tm.user_id = u.id
LEFT JOIN task_work_log twl ON twl.user_id = u.id
LEFT JOIN tasks t ON twl.task_id = t.id ${billableQuery}
LEFT JOIN projects p ON t.project_id = p.id
WHERE tm.team_id IN (${teamIds})
AND (
twl.id IS NULL
OR (
p.id IN (${projectIds})
AND twl.created_at >= '${startUtc}'::TIMESTAMP
AND twl.created_at <= '${endUtc}'::TIMESTAMP
${archivedClause}
)
)
GROUP BY u.id, u.email, tm.name, tm.color_code
ORDER BY logged_time DESC`;
const result = await db.query(q, []);
// Add timezone context to response
const response = {
data: result.rows,
timezone_info: {
user_timezone: userTimezone,
report_period: {
start: startDate.format("YYYY-MM-DD HH:mm:ss z"),
end: endDate.format("YYYY-MM-DD HH:mm:ss z"),
working_days: workingDays,
total_days: totalDays
}
}
};
return res.status(200).send(new ServerResponse(true, response));
}
async function getUserTimezone(userId: string): Promise<string> {
const q = `SELECT tz.name as timezone
FROM users u
JOIN timezones tz ON u.timezone_id = tz.id
WHERE u.id = $1`;
const result = await db.query(q, [userId]);
return result.rows[0]?.timezone || "UTC";
}
function buildBillableQuery(billable: { billable: boolean; nonBillable: boolean }): string {
if (!billable) return "";
const { billable: isBillable, nonBillable } = billable;
if (isBillable && nonBillable) {
return "";
} else if (isBillable) {
return " AND tasks.billable IS TRUE";
} else if (nonBillable) {
return " AND tasks.billable IS FALSE";
}
return "";
}

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

@@ -1,117 +0,0 @@
import WorklenzControllerBase from "../worklenz-controller-base";
import { IWorkLenzRequest } from "../../interfaces/worklenz-request";
import db from "../../config/db";
import moment from "moment-timezone";
import { DATE_RANGES } from "../../shared/constants";
export default abstract class ReportingControllerBaseWithTimezone extends WorklenzControllerBase {
/**
* Get the user's timezone from the database or request
* @param userId - The user ID
* @returns The user's timezone or 'UTC' as default
*/
protected static async getUserTimezone(userId: string): Promise<string> {
const q = `SELECT tz.name as timezone
FROM users u
JOIN timezones tz ON u.timezone_id = tz.id
WHERE u.id = $1`;
const result = await db.query(q, [userId]);
return result.rows[0]?.timezone || 'UTC';
}
/**
* Generate date range clause with timezone support
* @param key - Date range key (e.g., YESTERDAY, LAST_WEEK)
* @param dateRange - Array of date strings
* @param userTimezone - User's timezone (e.g., 'America/New_York')
* @returns SQL clause for date filtering
*/
protected static getDateRangeClauseWithTimezone(key: string, dateRange: string[], userTimezone: string) {
// For custom date ranges
if (dateRange.length === 2) {
// Convert dates to user's timezone start/end of day
const start = moment.tz(dateRange[0], userTimezone).startOf('day');
const end = moment.tz(dateRange[1], userTimezone).endOf('day');
// Convert to UTC for database comparison
const startUtc = start.utc().format("YYYY-MM-DD HH:mm:ss");
const endUtc = end.utc().format("YYYY-MM-DD HH:mm:ss");
if (start.isSame(end, 'day')) {
// Single day selection
return `AND task_work_log.created_at >= '${startUtc}'::TIMESTAMP AND task_work_log.created_at <= '${endUtc}'::TIMESTAMP`;
}
return `AND task_work_log.created_at >= '${startUtc}'::TIMESTAMP AND task_work_log.created_at <= '${endUtc}'::TIMESTAMP`;
}
// For predefined ranges, calculate based on user's timezone
const now = moment.tz(userTimezone);
let startDate, endDate;
switch (key) {
case DATE_RANGES.YESTERDAY:
startDate = now.clone().subtract(1, 'day').startOf('day');
endDate = now.clone().subtract(1, 'day').endOf('day');
break;
case DATE_RANGES.LAST_WEEK:
startDate = now.clone().subtract(1, 'week').startOf('week');
endDate = now.clone().subtract(1, 'week').endOf('week');
break;
case DATE_RANGES.LAST_MONTH:
startDate = now.clone().subtract(1, 'month').startOf('month');
endDate = now.clone().subtract(1, 'month').endOf('month');
break;
case DATE_RANGES.LAST_QUARTER:
startDate = now.clone().subtract(3, 'months').startOf('day');
endDate = now.clone().endOf('day');
break;
default:
return "";
}
if (startDate && endDate) {
const startUtc = startDate.utc().format("YYYY-MM-DD HH:mm:ss");
const endUtc = endDate.utc().format("YYYY-MM-DD HH:mm:ss");
return `AND task_work_log.created_at >= '${startUtc}'::TIMESTAMP AND task_work_log.created_at <= '${endUtc}'::TIMESTAMP`;
}
return "";
}
/**
* Format dates for display in user's timezone
* @param date - Date to format
* @param userTimezone - User's timezone
* @param format - Moment format string
* @returns Formatted date string
*/
protected static formatDateInTimezone(date: string | Date, userTimezone: string, format: string = "YYYY-MM-DD HH:mm:ss") {
return moment.tz(date, userTimezone).format(format);
}
/**
* Get working days count between two dates in user's timezone
* @param startDate - Start date
* @param endDate - End date
* @param userTimezone - User's timezone
* @returns Number of working days
*/
protected static getWorkingDaysInTimezone(startDate: string, endDate: string, userTimezone: string): number {
const start = moment.tz(startDate, userTimezone);
const end = moment.tz(endDate, userTimezone);
let workingDays = 0;
const current = start.clone();
while (current.isSameOrBefore(end, 'day')) {
// Monday = 1, Friday = 5
if (current.isoWeekday() >= 1 && current.isoWeekday() <= 5) {
workingDays++;
}
current.add(1, 'day');
}
return workingDays;
}
}

View File

@@ -6,69 +6,10 @@ import { IWorkLenzResponse } from "../../interfaces/worklenz-response";
import { ServerResponse } from "../../models/server-response";
import { DATE_RANGES, TASK_PRIORITY_COLOR_ALPHA } from "../../shared/constants";
import { formatDuration, getColor, int } from "../../shared/utils";
import ReportingControllerBaseWithTimezone from "./reporting-controller-base-with-timezone";
import ReportingControllerBase from "./reporting-controller-base";
import Excel from "exceljs";
export default class ReportingMembersController extends ReportingControllerBaseWithTimezone {
protected static getPercentage(n: number, total: number) {
return +(n ? (n / total) * 100 : 0).toFixed();
}
protected static getCurrentTeamId(req: IWorkLenzRequest): string | null {
return req.user?.team_id ?? null;
}
public static convertMinutesToHoursAndMinutes(totalMinutes: number) {
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return `${hours}h ${minutes}m`;
}
public static convertSecondsToHoursAndMinutes(seconds: number) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return `${hours}h ${minutes}m`;
}
protected static formatEndDate(endDate: string) {
const end = moment(endDate).format("YYYY-MM-DD");
const fEndDate = moment(end);
return fEndDate;
}
protected static formatCurrentDate() {
const current = moment().format("YYYY-MM-DD");
const fCurrentDate = moment(current);
return fCurrentDate;
}
protected static getDaysLeft(endDate: string): number | null {
if (!endDate) return null;
const fCurrentDate = this.formatCurrentDate();
const fEndDate = this.formatEndDate(endDate);
return fEndDate.diff(fCurrentDate, "days");
}
protected static isOverdue(endDate: string): boolean {
if (!endDate) return false;
const fCurrentDate = this.formatCurrentDate();
const fEndDate = this.formatEndDate(endDate);
return fEndDate.isBefore(fCurrentDate);
}
protected static isToday(endDate: string): boolean {
if (!endDate) return false;
const fCurrentDate = this.formatCurrentDate();
const fEndDate = this.formatEndDate(endDate);
return fEndDate.isSame(fCurrentDate);
}
export default class ReportingMembersController extends ReportingControllerBase {
private static async getMembers(
teamId: string, searchQuery = "",
@@ -90,6 +31,7 @@ export default class ReportingMembersController extends ReportingControllerBaseW
const completedDurationClasue = this.completedDurationFilter(key, dateRange);
const overdueActivityLogsClause = this.getActivityLogsOverdue(key, dateRange);
const activityLogCreationFilter = this.getActivityLogsCreationClause(key, dateRange);
const timeLogDateRangeClause = this.getTimeLogDateRangeClause(key, dateRange);
const q = `SELECT COUNT(DISTINCT email) AS total,
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON)
@@ -159,7 +101,25 @@ export default class ReportingMembersController extends ReportingControllerBaseW
FROM tasks t
LEFT JOIN tasks_assignees ta ON t.id = ta.task_id
WHERE team_member_id = tmiv.team_member_id
AND is_doing((SELECT new_value FROM task_activity_logs tl WHERE tl.task_id = t.id AND tl.attribute_type = 'status' ${activityLogCreationFilter} ORDER BY tl.created_at DESC LIMIT 1)::UUID, t.project_id) ${archivedClause}) AS ongoing_by_activity_logs
AND is_doing((SELECT new_value FROM task_activity_logs tl WHERE tl.task_id = t.id AND tl.attribute_type = 'status' ${activityLogCreationFilter} ORDER BY tl.created_at DESC LIMIT 1)::UUID, t.project_id) ${archivedClause}) AS ongoing_by_activity_logs,
(SELECT COALESCE(SUM(twl.time_spent), 0)
FROM task_work_log twl
LEFT JOIN tasks t ON twl.task_id = t.id
WHERE twl.user_id = (SELECT user_id FROM team_members WHERE id = tmiv.team_member_id)
AND t.billable IS TRUE
AND t.project_id IN (SELECT id FROM projects WHERE team_id = $1)
${timeLogDateRangeClause}
${includeArchived ? "" : `AND t.project_id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = t.project_id AND archived_projects.user_id = '${userId}')`}) AS billable_time,
(SELECT COALESCE(SUM(twl.time_spent), 0)
FROM task_work_log twl
LEFT JOIN tasks t ON twl.task_id = t.id
WHERE twl.user_id = (SELECT user_id FROM team_members WHERE id = tmiv.team_member_id)
AND t.billable IS FALSE
AND t.project_id IN (SELECT id FROM projects WHERE team_id = $1)
${timeLogDateRangeClause}
${includeArchived ? "" : `AND t.project_id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = t.project_id AND archived_projects.user_id = '${userId}')`}) AS non_billable_time
FROM team_member_info_view tmiv
WHERE tmiv.team_id = $1 ${teamsClause}
AND tmiv.team_member_id IN (SELECT team_member_id
@@ -370,6 +330,30 @@ export default class ReportingMembersController extends ReportingControllerBaseW
return "";
}
protected static getTimeLogDateRangeClause(key: string, dateRange: string[]) {
if (dateRange.length === 2) {
const start = moment(dateRange[0]).format("YYYY-MM-DD");
const end = moment(dateRange[1]).format("YYYY-MM-DD");
if (start === end) {
return `AND twl.created_at::DATE = '${start}'::DATE`;
}
return `AND twl.created_at::DATE >= '${start}'::DATE AND twl.created_at < '${end}'::DATE + INTERVAL '1 day'`;
}
if (key === DATE_RANGES.YESTERDAY)
return `AND twl.created_at >= (CURRENT_DATE - INTERVAL '1 day')::DATE AND twl.created_at < CURRENT_DATE::DATE`;
if (key === DATE_RANGES.LAST_WEEK)
return `AND twl.created_at >= (CURRENT_DATE - INTERVAL '1 week')::DATE AND twl.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'`;
if (key === DATE_RANGES.LAST_MONTH)
return `AND twl.created_at >= (CURRENT_DATE - INTERVAL '1 month')::DATE AND twl.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'`;
if (key === DATE_RANGES.LAST_QUARTER)
return `AND twl.created_at >= (CURRENT_DATE - INTERVAL '3 months')::DATE AND twl.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'`;
return "";
}
private static formatDuration(duration: moment.Duration) {
const empty = "0h 0m";
let format = "";
@@ -482,6 +466,8 @@ export default class ReportingMembersController extends ReportingControllerBaseW
{ header: "Overdue Tasks", key: "overdue_tasks", width: 20 },
{ header: "Completed Tasks", key: "completed_tasks", width: 20 },
{ header: "Ongoing Tasks", key: "ongoing_tasks", width: 20 },
{ header: "Billable Time (seconds)", key: "billable_time", width: 25 },
{ header: "Non-Billable Time (seconds)", key: "non_billable_time", width: 25 },
{ header: "Done Tasks(%)", key: "done_tasks", width: 20 },
{ header: "Doing Tasks(%)", key: "doing_tasks", width: 20 },
{ header: "Todo Tasks(%)", key: "todo_tasks", width: 20 }
@@ -489,14 +475,14 @@ export default class ReportingMembersController extends ReportingControllerBaseW
// set title
sheet.getCell("A1").value = `Members from ${teamName}`;
sheet.mergeCells("A1:K1");
sheet.mergeCells("A1:M1");
sheet.getCell("A1").alignment = { horizontal: "center" };
sheet.getCell("A1").style.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "D9D9D9" } };
sheet.getCell("A1").font = { size: 16 };
// set export date
sheet.getCell("A2").value = `Exported on : ${exportDate}`;
sheet.mergeCells("A2:K2");
sheet.mergeCells("A2:M2");
sheet.getCell("A2").alignment = { horizontal: "center" };
sheet.getCell("A2").style.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "F2F2F2" } };
sheet.getCell("A2").font = { size: 12 };
@@ -506,7 +492,7 @@ export default class ReportingMembersController extends ReportingControllerBaseW
sheet.mergeCells("A3:D3");
// set table headers
sheet.getRow(5).values = ["Member", "Email", "Tasks Assigned", "Overdue Tasks", "Completed Tasks", "Ongoing Tasks", "Done Tasks(%)", "Doing Tasks(%)", "Todo Tasks(%)"];
sheet.getRow(5).values = ["Member", "Email", "Tasks Assigned", "Overdue Tasks", "Completed Tasks", "Ongoing Tasks", "Billable Time (seconds)", "Non-Billable Time (seconds)", "Done Tasks(%)", "Doing Tasks(%)", "Todo Tasks(%)"];
sheet.getRow(5).font = { bold: true };
for (const member of result.members) {
@@ -517,6 +503,8 @@ export default class ReportingMembersController extends ReportingControllerBaseW
overdue_tasks: member.overdue,
completed_tasks: member.completed,
ongoing_tasks: member.ongoing,
billable_time: member.billable_time || 0,
non_billable_time: member.non_billable_time || 0,
done_tasks: member.completed,
doing_tasks: member.ongoing_by_activity_logs,
todo_tasks: member.todo_by_activity_logs
@@ -546,9 +534,7 @@ export default class ReportingMembersController extends ReportingControllerBaseW
dateRange = date_range.split(",");
}
// Get user timezone for proper date filtering
const userTimezone = await this.getUserTimezone(req.user?.id as string);
const durationClause = this.getDateRangeClauseWithTimezone(duration as string || DATE_RANGES.LAST_WEEK, dateRange, userTimezone);
const durationClause = ReportingMembersController.getDateRangeClauseMembers(duration as string || DATE_RANGES.LAST_WEEK, dateRange, "twl");
const minMaxDateClause = this.getMinMaxDates(duration as string || DATE_RANGES.LAST_WEEK, dateRange, "task_work_log");
const memberName = (req.query.member_name as string)?.trim() || null;
@@ -1099,9 +1085,7 @@ export default class ReportingMembersController extends ReportingControllerBaseW
public static async getMemberTimelogs(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { team_member_id, team_id, duration, date_range, archived, billable } = req.body;
// Get user timezone for proper date filtering
const userTimezone = await this.getUserTimezone(req.user?.id as string);
const durationClause = this.getDateRangeClauseWithTimezone(duration || DATE_RANGES.LAST_WEEK, date_range, userTimezone);
const durationClause = ReportingMembersController.getDateRangeClauseMembers(duration || DATE_RANGES.LAST_WEEK, date_range, "twl");
const minMaxDateClause = this.getMinMaxDates(duration || DATE_RANGES.LAST_WEEK, date_range, "task_work_log");
const billableQuery = this.buildBillableQuery(billable);
@@ -1293,8 +1277,8 @@ public static async getSingleMemberProjects(req: IWorkLenzRequest, res: IWorkLen
row.actual_time = int(row.actual_time);
row.estimated_time_string = this.convertMinutesToHoursAndMinutes(int(row.estimated_time));
row.actual_time_string = this.convertSecondsToHoursAndMinutes(int(row.actual_time));
row.days_left = this.getDaysLeft(row.end_date);
row.is_overdue = this.isOverdue(row.end_date);
row.days_left = ReportingControllerBase.getDaysLeft(row.end_date);
row.is_overdue = ReportingControllerBase.isOverdue(row.end_date);
if (row.days_left && row.is_overdue) {
row.days_left = row.days_left.toString().replace(/-/g, "");
}

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

@@ -1,167 +0,0 @@
import { IWorkLenzRequest } from "../interfaces/worklenz-request";
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
import { ServerResponse } from "../models/server-response";
import WorklenzControllerBase from "./worklenz-controller-base";
import HandleExceptions from "../decorators/handle-exceptions";
import { ISurveySubmissionRequest } from "../interfaces/survey";
import db from "../config/db";
export default class SurveyController extends WorklenzControllerBase {
@HandleExceptions()
public static async getAccountSetupSurvey(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const q = `
SELECT
s.id,
s.name,
s.description,
s.survey_type,
s.is_active,
COALESCE(
json_agg(
json_build_object(
'id', sq.id,
'survey_id', sq.survey_id,
'question_key', sq.question_key,
'question_type', sq.question_type,
'is_required', sq.is_required,
'sort_order', sq.sort_order,
'options', sq.options
) ORDER BY sq.sort_order
) FILTER (WHERE sq.id IS NOT NULL),
'[]'
) AS questions
FROM surveys s
LEFT JOIN survey_questions sq ON s.id = sq.survey_id
WHERE s.survey_type = 'account_setup' AND s.is_active = true
GROUP BY s.id, s.name, s.description, s.survey_type, s.is_active
LIMIT 1;
`;
const result = await db.query(q);
const [survey] = result.rows;
if (!survey) {
return res.status(200).send(new ServerResponse(false, null, "Account setup survey not found"));
}
return res.status(200).send(new ServerResponse(true, survey));
}
@HandleExceptions()
public static async submitSurveyResponse(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const userId = req.user?.id;
const body = req.body as ISurveySubmissionRequest;
if (!userId) {
return res.status(200).send(new ServerResponse(false, null, "User not authenticated"));
}
if (!body.survey_id || !body.answers || !Array.isArray(body.answers)) {
return res.status(200).send(new ServerResponse(false, null, "Invalid survey submission data"));
}
// Check if user has already submitted a response for this survey
const existingResponseQuery = `
SELECT id FROM survey_responses
WHERE user_id = $1 AND survey_id = $2;
`;
const existingResult = await db.query(existingResponseQuery, [userId, body.survey_id]);
let responseId: string;
if (existingResult.rows.length > 0) {
// Update existing response
responseId = existingResult.rows[0].id;
const updateResponseQuery = `
UPDATE survey_responses
SET is_completed = true, completed_at = NOW(), updated_at = NOW()
WHERE id = $1;
`;
await db.query(updateResponseQuery, [responseId]);
// Delete existing answers
const deleteAnswersQuery = `DELETE FROM survey_answers WHERE response_id = $1;`;
await db.query(deleteAnswersQuery, [responseId]);
} else {
// Create new response
const createResponseQuery = `
INSERT INTO survey_responses (survey_id, user_id, is_completed, completed_at)
VALUES ($1, $2, true, NOW())
RETURNING id;
`;
const responseResult = await db.query(createResponseQuery, [body.survey_id, userId]);
responseId = responseResult.rows[0].id;
}
// Insert new answers
if (body.answers.length > 0) {
const answerValues: string[] = [];
const params: any[] = [];
body.answers.forEach((answer, index) => {
const baseIndex = index * 4;
answerValues.push(`($${baseIndex + 1}, $${baseIndex + 2}, $${baseIndex + 3}, $${baseIndex + 4})`);
params.push(
responseId,
answer.question_id,
answer.answer_text || null,
answer.answer_json ? JSON.stringify(answer.answer_json) : null
);
});
const insertAnswersQuery = `
INSERT INTO survey_answers (response_id, question_id, answer_text, answer_json)
VALUES ${answerValues.join(', ')};
`;
await db.query(insertAnswersQuery, params);
}
return res.status(200).send(new ServerResponse(true, { response_id: responseId }));
}
@HandleExceptions()
public static async getUserSurveyResponse(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const userId = req.user?.id;
const surveyId = req.params.survey_id;
if (!userId) {
return res.status(200).send(new ServerResponse(false, null, "User not authenticated"));
}
const q = `
SELECT
sr.id,
sr.survey_id,
sr.user_id,
sr.is_completed,
sr.started_at,
sr.completed_at,
COALESCE(
json_agg(
json_build_object(
'question_id', sa.question_id,
'answer_text', sa.answer_text,
'answer_json', sa.answer_json
)
) FILTER (WHERE sa.id IS NOT NULL),
'[]'
) AS answers
FROM survey_responses sr
LEFT JOIN survey_answers sa ON sr.id = sa.response_id
WHERE sr.user_id = $1 AND sr.survey_id = $2
GROUP BY sr.id, sr.survey_id, sr.user_id, sr.is_completed, sr.started_at, sr.completed_at;
`;
const result = await db.query(q, [userId, surveyId]);
const [response] = result.rows;
if (!response) {
return res.status(200).send(new ServerResponse(false, null, "Survey response not found"));
}
return res.status(200).send(new ServerResponse(true, response));
}
}

View File

@@ -16,23 +16,19 @@ export default class TaskPhasesController extends WorklenzControllerBase {
if (!req.query.id)
return res.status(400).send(new ServerResponse(false, null, "Invalid request"));
// Use custom name if provided, otherwise use default naming pattern
const phaseName = req.body.name?.trim() ||
`Untitled Phase (${(await db.query("SELECT COUNT(*) FROM project_phases WHERE project_id = $1", [req.query.id])).rows[0].count + 1})`;
const q = `
INSERT INTO project_phases (name, color_code, project_id, sort_index)
VALUES (
CONCAT('Untitled Phase (', (SELECT COUNT(*) FROM project_phases WHERE project_id = $2) + 1, ')'),
$1,
$2,
$3,
(SELECT COUNT(*) FROM project_phases WHERE project_id = $3) + 1)
(SELECT COUNT(*) FROM project_phases WHERE project_id = $2) + 1)
RETURNING id, name, color_code, sort_index;
`;
req.body.color_code = this.DEFAULT_PHASE_COLOR;
const result = await db.query(q, [phaseName, req.body.color_code, req.query.id]);
const result = await db.query(q, [req.body.color_code, req.query.id]);
const [data] = result.rows;
data.color_code = getColor(data.name) + TASK_STATUS_COLOR_ALPHA;

View File

@@ -134,25 +134,6 @@ export default class TaskStatusesController extends WorklenzControllerBase {
return res.status(200).send(new ServerResponse(true, data));
}
@HandleExceptions()
public static async updateCategory(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const hasMoreCategories = await TaskStatusesController.hasMoreCategories(req.params.id, req.query.current_project_id as string);
if (!hasMoreCategories)
return res.status(200).send(new ServerResponse(false, null, existsErrorMessage).withTitle("Status category update failed!"));
const q = `
UPDATE task_statuses
SET category_id = $2
WHERE id = $1
AND project_id = $3
RETURNING (SELECT color_code FROM sys_task_status_categories WHERE id = task_statuses.category_id), (SELECT color_code_dark FROM sys_task_status_categories WHERE id = task_statuses.category_id);
`;
const result = await db.query(q, [req.params.id, req.body.category_id, req.query.current_project_id]);
const [data] = result.rows;
return res.status(200).send(new ServerResponse(true, data));
}
@HandleExceptions()
public static async updateStatusOrder(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const q = `SELECT update_status_order($1);`;

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

@@ -16,7 +16,6 @@ export interface ITaskGroup {
start_date?: string;
end_date?: string;
color_code: string;
color_code_dark: string;
category_id: string | null;
old_category_id?: string;
todo_progress?: number;
@@ -82,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"];

File diff suppressed because it is too large Load Diff

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

@@ -34,25 +34,30 @@ export default abstract class WorklenzControllerBase {
const offset = queryParams.search ? 0 : (index - 1) * size;
const paging = queryParams.paging || "true";
// let s = "";
// if (typeof searchField === "string") {
// s = `${searchField} || ' ' || id::TEXT`;
// } else if (Array.isArray(searchField)) {
// s = searchField.join(" || ' ' || ");
// }
// const search = (queryParams.search as string || "").trim();
// const searchQuery = search ? `AND TO_TSVECTOR(${s}) @@ TO_TSQUERY('${toTsQuery(search)}')` : "";
const search = (queryParams.search as string || "").trim();
let s = "";
if (typeof searchField === "string") {
s = ` ${searchField} ILIKE '%${search}%'`;
} else if (Array.isArray(searchField)) {
s = searchField.map(index => ` ${index} ILIKE '%${search}%'`).join(" OR ");
}
let searchQuery = "";
if (search) {
// Properly escape single quotes to prevent SQL syntax errors
const escapedSearch = search.replace(/'/g, "''");
let s = "";
if (typeof searchField === "string") {
s = ` ${searchField} ILIKE '%${escapedSearch}%'`;
} else if (Array.isArray(searchField)) {
s = searchField.map(field => ` ${field} ILIKE '%${escapedSearch}%'`).join(" OR ");
}
if (s) {
searchQuery = isMemberFilter ? ` (${s}) AND ` : ` AND (${s}) `;
}
}
// Sort
const sortField = /null|undefined/.test(queryParams.field as string) ? searchField : queryParams.field;

View File

@@ -1,37 +0,0 @@
export interface ISurveyQuestion {
id: string;
survey_id: string;
question_key: string;
question_type: 'single_choice' | 'multiple_choice' | 'text';
is_required: boolean;
sort_order: number;
options?: string[];
}
export interface ISurvey {
id: string;
name: string;
description?: string;
survey_type: 'account_setup' | 'onboarding' | 'feedback';
is_active: boolean;
questions?: ISurveyQuestion[];
}
export interface ISurveyAnswer {
question_id: string;
answer_text?: string;
answer_json?: string[];
}
export interface ISurveyResponse {
id?: string;
survey_id: string;
user_id?: string;
is_completed: boolean;
answers: ISurveyAnswer[];
}
export interface ISurveySubmissionRequest {
survey_id: string;
answers: ISurveyAnswer[];
}

View File

@@ -6,11 +6,11 @@ import { isProduction } from "../shared/utils";
const pgSession = require("connect-pg-simple")(session);
export default session({
name: process.env.SESSION_NAME,
name: process.env.SESSION_NAME || "worklenz.sid",
secret: process.env.SESSION_SECRET || "development-secret-key",
proxy: false,
resave: false,
saveUninitialized: true,
resave: true,
saveUninitialized: false,
rolling: true,
store: new pgSession({
pool: db.pool,
@@ -18,10 +18,8 @@ export default session({
}),
cookie: {
path: "/",
// secure: isProduction(),
// httpOnly: isProduction(),
// sameSite: "none",
// domain: isProduction() ? ".worklenz.com" : undefined,
httpOnly: true,
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

@@ -1,56 +0,0 @@
import { NextFunction } from "express";
import { IWorkLenzRequest } from "../../interfaces/worklenz-request";
import { IWorkLenzResponse } from "../../interfaces/worklenz-response";
import { ServerResponse } from "../../models/server-response";
import { ISurveySubmissionRequest } from "../../interfaces/survey";
export default function surveySubmissionValidator(req: IWorkLenzRequest, res: IWorkLenzResponse, next: NextFunction): IWorkLenzResponse | void {
const body = req.body as ISurveySubmissionRequest;
if (!body) {
return res.status(200).send(new ServerResponse(false, null, "Request body is required"));
}
if (!body.survey_id || typeof body.survey_id !== 'string') {
return res.status(200).send(new ServerResponse(false, null, "Survey ID is required and must be a string"));
}
if (!body.answers || !Array.isArray(body.answers)) {
return res.status(200).send(new ServerResponse(false, null, "Answers are required and must be an array"));
}
// Validate each answer
for (let i = 0; i < body.answers.length; i++) {
const answer = body.answers[i];
if (!answer.question_id || typeof answer.question_id !== 'string') {
return res.status(200).send(new ServerResponse(false, null, `Answer ${i + 1}: Question ID is required and must be a string`));
}
// At least one of answer_text or answer_json should be provided
if (!answer.answer_text && !answer.answer_json) {
return res.status(200).send(new ServerResponse(false, null, `Answer ${i + 1}: Either answer_text or answer_json is required`));
}
// Validate answer_text if provided
if (answer.answer_text && typeof answer.answer_text !== 'string') {
return res.status(200).send(new ServerResponse(false, null, `Answer ${i + 1}: answer_text must be a string`));
}
// Validate answer_json if provided
if (answer.answer_json && !Array.isArray(answer.answer_json)) {
return res.status(200).send(new ServerResponse(false, null, `Answer ${i + 1}: answer_json must be an array`));
}
// Validate answer_json items are strings
if (answer.answer_json) {
for (let j = 0; j < answer.answer_json.length; j++) {
if (typeof answer.answer_json[j] !== 'string') {
return res.status(200).send(new ServerResponse(false, null, `Answer ${i + 1}: answer_json items must be strings`));
}
}
}
}
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

@@ -1,4 +0,0 @@
{
"doesNotExistText": "Na vjen keq, faqja që kërkoni nuk ekziston.",
"backHomeButton": "Kthehu në Faqen Kryesore"
}

View File

@@ -1,31 +0,0 @@
{
"continue": "Vazhdo",
"setupYourAccount": "Konfiguro Llogarinë Tënde në Worklenz.",
"organizationStepTitle": "Emërtoni Organizatën Tuaj",
"organizationStepLabel": "Zgjidhni një emër për llogarinë tuaj në Worklenz.",
"projectStepTitle": "Krijoni projektin tuaj të parë",
"projectStepLabel": "Në cilin projekt po punoni aktualisht?",
"projectStepPlaceholder": "p.sh. Plani i Marketingut",
"tasksStepTitle": "Krijoni detyrat tuaja të para",
"tasksStepLabel": "Shkruani disa detyra që do të kryeni në",
"tasksStepAddAnother": "Shto një tjetër",
"emailPlaceholder": "Adresa email",
"invalidEmail": "Ju lutemi vendosni një adresë email të vlefshme",
"or": "ose",
"templateButton": "Importo nga shablloni",
"goBack": "Kthehu Mbrapa",
"cancel": "Anulo",
"create": "Krijo",
"templateDrawerTitle": "Zgjidh nga shabllonet",
"step3InputLabel": "Fto me email",
"addAnother": "Shto një tjetër",
"skipForNow": "Kalo tani për tani",
"formTitle": "Krijoni detyrën tuaj të parë.",
"step3Title": "Fto ekipin tënd të punojë me",
"maxMembers": " (Mund të ftoni deri në 5 anëtarë)",
"maxTasks": " (Mund të krijoni deri në 5 detyra)"
}

View File

@@ -1,113 +0,0 @@
{
"title": "Faturimet",
"currentBill": "Fatura Aktuale",
"configuration": "Konfigurimi",
"currentPlanDetails": "Detajet e Planit Aktual",
"upgradePlan": "Përmirëso Planin",
"cardBodyText01": "Provë falas",
"cardBodyText02": "(Plani juaj i provës skadon në 1 muaj 19 ditë)",
"redeemCode": "Kodi i Zbritjes",
"accountStorage": "Depozita e Llogarisë",
"used": "Përdorur:",
"remaining": "E mbetur:",
"charges": "Tarifat",
"tooltip": "Tarifat për ciklin aktual të faturimit",
"description": "Përshkrimi",
"billingPeriod": "Periudha e Faturimit",
"billStatus": "Statusi i Faturës",
"perUserValue": "Vlera për Përdorues",
"users": "Përdoruesit",
"amount": "Shuma",
"invoices": "Faturat",
"transactionId": "ID e Transaksionit",
"transactionDate": "Data e Transaksionit",
"paymentMethod": "Metoda e Pagesës",
"status": "Statusi",
"ltdUsers": "Mund të shtoni deri në {{ltd_users}} përdorues.",
"totalSeats": "Vende totale",
"availableSeats": "Vende të disponueshme",
"addMoreSeats": "Shto më shumë vende",
"drawerTitle": "Kodi i Zbritjes",
"label": "Kodi i Zbritjes",
"drawerPlaceholder": "Vendosni kodin tuaj të zbritjes",
"redeemSubmit": "Paraqit",
"modalTitle": "Zgjidhni planin më të mirë për ekipin tuaj",
"seatLabel": "Numri i vendeve",
"freePlan": "Plan Falas",
"startup": "Startup",
"business": "Biznes",
"tag": "Më i Popullarizuar",
"enterprise": "Ndërmarrje",
"freeSubtitle": "falas përgjithmonë",
"freeUsers": "Më e mira për përdorim personal",
"freeText01": "100MB depozitë",
"freeText02": "3 projekte",
"freeText03": "5 anëtarë të ekipit",
"startupSubtitle": "ÇMIM I RASTËSISHËM / muaj",
"startupUsers": "Deri në 15 përdorues",
"startupText01": "25GB depozitë",
"startupText02": "Projekte të pakufizuara aktive",
"startupText03": "Orar",
"startupText04": "Raportim",
"startupText05": "Abonohu në projekte",
"businessSubtitle": "përdorues / muaj",
"businessUsers": "16 - 200 përdorues",
"enterpriseUsers": "200 - 500+ përdorues",
"footerTitle": "Ju lutemi na jepni një numër kontakti që mund të përdorim për t'ju kontaktuar.",
"footerLabel": "Numri i Kontaktit",
"footerButton": "Na kontaktoni",
"redeemCodePlaceHolder": "Vendosni kodin tuaj të zbritjes",
"submit": "Paraqit",
"trialPlan": "Provë Falas",
"trialExpireDate": "E vlefshme deri më {{trial_expire_date}}",
"trialExpired": "Provat tuaja falas skaduan {{trial_expire_string}}",
"trialInProgress": "Provat tuaja falas skadojnë {{trial_expire_string}}",
"required": "Kjo fushë është e detyrueshme",
"invalidCode": "Kod i pavlefshëm",
"selectPlan": "Zgjidhni planin më të mirë për ekipin tuaj",
"changeSubscriptionPlan": "Ndryshoni planin tuaj të abonimit",
"noOfSeats": "Numri i vendeve",
"annualPlan": "Pro - Vjetor",
"monthlyPlan": "Pro - Mujor",
"freeForever": "Falas Përgjithmonë",
"bestForPersonalUse": "Më e mira për përdorim personal",
"storage": "Depozitë",
"projects": "Projekte",
"teamMembers": "Anëtarët e Ekipit",
"unlimitedTeamMembers": "Anëtarë të pakufizuar të ekipit",
"unlimitedActiveProjects": "Projekte të pakufizuara aktive",
"schedule": "Orar",
"reporting": "Raportim",
"subscribeToProjects": "Abonohu në projekte",
"billedAnnually": "Faturuar çdo vit",
"billedMonthly": "Faturuar çdo muaj",
"pausePlan": "Pauzë Planin",
"resumePlan": "Rifillo Planin",
"changePlan": "Ndrysho Planin",
"cancelPlan": "Anulo Planin",
"perMonthPerUser": "për përdorues/muaj",
"viewInvoice": "Shiko Faturën",
"switchToFreePlan": "Kalo në Planin Falas",
"expirestoday": "sot",
"expirestomorrow": "nesër",
"expiredDaysAgo": "{{days}} ditë më parë",
"continueWith": "Vazhdo me {{plan}}",
"changeToPlan": "Ndrysho në {{plan}}"
}

View File

@@ -1,8 +0,0 @@
{
"overview": "Përmbledhje",
"name": "Emri i Organizatës",
"owner": "Pronari i Organizatës",
"admins": "Administruesit e Organizatës",
"contactNumber": "Shto Numrin e Kontaktit",
"edit": "Redakto"
}

View File

@@ -1,12 +0,0 @@
{
"membersCount": "Numri i Anëtarëve",
"createdAt": "Krijuar më",
"projectName": "Emri i Projektit",
"teamName": "Emri i Ekipit",
"refreshProjects": "Rifresko Projektet",
"searchPlaceholder": "Kërkoni sipas emrit të projektit",
"deleteProject": "Jeni i sigurt që dëshironi të fshini këtë projekt?",
"confirm": "Konfirmo",
"cancel": "Anulo",
"delete": "Fshi Projektin"
}

View File

@@ -1,8 +0,0 @@
{
"overview": "Përmbledhje",
"users": "Përdoruesit",
"teams": "Ekipet",
"billing": "Faturimi",
"projects": "Projektet",
"adminCenter": "Qendra Administrative"
}

View File

@@ -1,33 +0,0 @@
{
"title": "Ekipet",
"subtitle": "ekipet",
"tooltip": "Rifresko ekipet",
"placeholder": "Kërko sipas emrit",
"addTeam": "Shto Ekip",
"team": "Ekipi",
"membersCount": "Numri i Anëtarëve",
"members": "Anëtarët",
"drawerTitle": "Krijo Ekip të Ri",
"label": "Emri i Ekipit",
"drawerPlaceholder": "Emri",
"create": "Krijo",
"delete": "Fshi",
"settings": "Cilësimet",
"popTitle": "Jeni i sigurt?",
"message": "Ju lutemi shkruani një Emër",
"teamSettings": "Cilësimet e Ekipit",
"teamName": "Emri i Ekipit",
"teamDescription": "Përshkrimi i Ekipit",
"teamMembers": "Anëtarët e Ekipit",
"teamMembersCount": "Numri i Anëtarëve të Ekipit",
"teamMembersPlaceholder": "Kërko sipas emrit",
"addMember": "Shto Anëtar",
"add": "Shto",
"update": "Përditëso",
"teamNamePlaceholder": "Emri i ekipit",
"user": "Përdoruesi",
"role": "Roli",
"owner": "Pronari",
"admin": "Administruesi",
"member": "Anëtari"
}

View File

@@ -1,9 +0,0 @@
{
"title": "Përdoruesit",
"subTitle": "përdoruesit",
"placeholder": "Kërko sipas emrit",
"user": "Përdoruesi",
"email": "Email",
"lastActivity": "Aktiviteti i Fundit",
"refresh": "Rifresko përdoruesit"
}

View File

@@ -1,34 +0,0 @@
{
"name": "Emri",
"client": "Klienti",
"category": "Kategoria",
"status": "Statusi",
"tasksProgress": "Përparimi i Detyrave",
"updated_at": "E Përditësuar së Fundi",
"members": "Anëtarët",
"setting": "Cilësimet",
"projects": "Projektet",
"refreshProjects": "Rifresko projektet",
"all": "Të gjitha",
"favorites": "Të preferuarit",
"archived": "E arkivuar",
"placeholder": "Kërko sipas emrit",
"archive": "Arkivo",
"unarchive": "Çarkivo",
"archiveConfirm": "Jeni i sigurt që dëshironi të arkivoni këtë projekt?",
"unarchiveConfirm": "Jeni i sigurt që dëshironi të çarkivoni këtë projekt?",
"yes": "Po",
"no": "Jo",
"clickToFilter": "Kliko për të filtruar sipas",
"noProjects": "Nuk u gjetën projekte",
"addToFavourites": "Shto te të preferuarit",
"list": "Lista",
"group": "Grupi",
"listView": "Pamja e Listës",
"groupView": "Pamja e Grupit",
"groupBy": {
"category": "Kategoria",
"client": "Klienti"
},
"noPermission": "Nuk keni leje për të kryer këtë veprim"
}

View File

@@ -1,5 +0,0 @@
{
"loggingOut": "Po dilni...",
"authenticating": "Po autentikoheni...",
"gettingThingsReady": "Po përgatiten gjërat për ju..."
}

View File

@@ -1,12 +0,0 @@
{
"headerDescription": "Rivendosni fjalëkalimin tuaj",
"emailLabel": "Email",
"emailPlaceholder": "Vendosni email-in tuaj",
"emailRequired": "Ju lutemi vendosni Email-in tuaj!",
"resetPasswordButton": "Rivendos Fjalëkalimin",
"returnToLoginButton": "Kthehu te Hyrja",
"passwordResetSuccessMessage": "Një lidhje për rivendosjen e fjalëkalimit është dërguar në email-in tuaj.",
"orText": "OSE",
"successTitle": "U dërguan udhëzimet për rivendosje!",
"successMessage": "Informacioni për rivendosje është dërguar në email-in tuaj. Ju lutemi kontrolloni email-in."
}

View File

@@ -1,27 +0,0 @@
{
"headerDescription": "Hyni në llogarinë tuaj",
"emailLabel": "Email",
"emailPlaceholder": "Vendosni email-in tuaj",
"emailRequired": "Ju lutemi vendosni Email-in tuaj!",
"passwordLabel": "Fjalëkalimi",
"passwordPlaceholder": "Vendosni fjalëkalimin",
"passwordRequired": "Ju lutemi vendosni Fjalëkalimin!",
"rememberMe": "Më mbaj mend",
"loginButton": "Hyr",
"signupButton": "Regjistrohu",
"forgotPasswordButton": "Keni harruar fjalëkalimin?",
"signInWithGoogleButton": "Hyr me Google",
"dontHaveAccountText": "Nuk keni llogari?",
"orText": "OSE",
"successMessage": "Jeni futur me sukses!",
"loginError": "Hyrja dështoi",
"googleLoginError": "Hyrja përmes Google dështoi",
"validationMessages": {
"email": "Ju lutemi vendosni një adresë email të vlefshme",
"password": "Fjalëkalimi duhet të jetë së paku 8 karaktere"
},
"errorMessages": {
"loginErrorTitle": "Hyrja dështoi",
"loginErrorMessage": "Ju lutemi kontrolloni email-in dhe fjalëkalimin dhe provoni përsëri"
}
}

View File

@@ -1,29 +0,0 @@
{
"headerDescription": "Regjistrohuni për të filluar",
"nameLabel": "Emri i Plotë",
"namePlaceholder": "Shkruani emrin tuaj të plotë",
"nameRequired": "Ju lutemi shkruani emrin tuaj të plotë!",
"nameMinCharacterRequired": "Emri duhet të jetë së paku 4 karaktere!",
"emailLabel": "Email",
"emailPlaceholder": "Shkruani email-in tuaj",
"emailRequired": "Ju lutemi shkruani Email-in tuaj!",
"passwordLabel": "Fjalëkalimi",
"passwordPlaceholder": "Krijoni një fjalëkalim",
"passwordRequired": "Ju lutemi krijoni një Fjalëkalim!",
"passwordMinCharacterRequired": "Fjalëkalimi duhet të jetë së paku 8 karaktere!",
"passwordPatternRequired": "Fjalëkalimi nuk plotëson kërkesat!",
"strongPasswordPlaceholder": "Vendosni një fjalëkalim më të fortë",
"passwordValidationAltText": "Fjalëkalimi duhet të përmbajë së paku 8 karaktere me shkronja të mëdha dhe të vogla, një numër dhe një simbol.",
"signupSuccessMessage": "Jeni regjistruar me sukses!",
"privacyPolicyLink": "Politika e Privatësisë",
"termsOfUseLink": "Kushtet e Përdorimit",
"bySigningUpText": "Duke u regjistruar, ju pranoni",
"andText": "dhe",
"signupButton": "Regjistrohu",
"signInWithGoogleButton": "Hyr me Google",
"alreadyHaveAccountText": "Keni tashmë një llogari?",
"loginButton": "Hyr",
"orText": "OSE",
"reCAPTCHAVerificationError": "Gabim në Verifikimin e reCAPTCHA",
"reCAPTCHAVerificationErrorMessage": "Nuk mundëm të verifikojmë reCAPTCHA-n tuaj. Ju lutemi provoni përsëri."
}

View File

@@ -1,14 +0,0 @@
{
"title": "Verifikoni Email-in për Rivendosje",
"description": "Vendosni fjalëkalimin tuaj të ri",
"placeholder": "Vendosni fjalëkalimin tuaj të ri",
"confirmPasswordPlaceholder": "Konfirmoni fjalëkalimin e ri",
"passwordHint": "Të paktën 8 karaktere, me shkronja të mëdha dhe të vogla, një numër dhe një simbol.",
"resetPasswordButton": "Rivendos fjalëkalimin",
"orText": "Ose",
"resendResetEmail": "Dërgo përsëri email-in e rivendosjes",
"passwordRequired": "Ju lutemi vendosni fjalëkalimin e ri",
"returnToLoginButton": "Kthehu te Hyrja",
"confirmPasswordRequired": "Ju lutemi konfirmoni fjalëkalimin e ri",
"passwordMismatch": "Fjalëkalimet nuk përputhen"
}

View File

@@ -1,9 +0,0 @@
{
"login-success": "Hyrja u krye me sukses!",
"login-failed": "Hyrja dështoi. Ju lutemi kontrolloni kredencialet dhe provoni përsëri.",
"signup-success": "Regjistrimi u krye me sukses! Mirë se erdhët.",
"signup-failed": "Regjistrimi dështoi. Ju lutemi sigurohuni që të gjitha fushat e nevojshme janë plotësuar dhe provoni përsëri.",
"reconnecting": "Jeni shkëputur nga serveri.",
"connection-lost": "Lidhja me serverin dështoi. Ju lutemi kontrolloni lidhjen tuaj me internet.",
"connection-restored": "U lidhët me serverin me sukses"
}

View File

@@ -1,13 +0,0 @@
{
"formTitle": "Krijoni projektin tuaj të parë",
"inputLabel": "Në cilin projekt po punoni aktualisht?",
"or": "ose",
"templateButton": "Importo nga shablloni",
"createFromTemplate": "Krijo nga shablloni",
"goBack": "Kthehu Mbrapa",
"continue": "Vazhdo",
"cancel": "Anulo",
"create": "Krijo",
"templateDrawerTitle": "Zgjidh nga shabllonet",
"createProject": "Krijo Projekt"
}

View File

@@ -1,7 +0,0 @@
{
"formTitle": "Krijo detyrën tënde të parë.",
"inputLabel": "Shkruaj disa detyra që do të kryesh në",
"addAnother": "Shto një tjetër",
"goBack": "Kthehu mbrapa",
"continue": "Vazhdo"
}

View File

@@ -1,46 +0,0 @@
{
"todoList": {
"title": "Lista e Detyrave",
"refreshTasks": "Rifresko detyrat",
"addTask": "+ Shto Detyrë",
"noTasks": "Asnjë detyrë",
"pressEnter": "Shtyp",
"toCreate": "për të krijuar.",
"markAsDone": "Shëno si të përfunduar"
},
"projects": {
"title": "Projektet",
"refreshProjects": "Rifresko projektet",
"noRecentProjects": "Aktualisht nuk jeni caktuar në asnjë projekt.",
"noFavouriteProjects": "Asnjë projekt i shënuar si i preferuar.",
"recent": "Të Fundit",
"favourites": "Të Preferuarat"
},
"tasks": {
"assignedToMe": "Më janë caktuar",
"assignedByMe": "I kam caktuar",
"all": "Të Gjitha",
"today": "Sot",
"upcoming": "Ardhj",
"overdue": "Të vonuara",
"noDueDate": "Pa afat",
"noTasks": "Asnjë detyrë për të shfaqur.",
"addTask": "+ Shto detyrë",
"name": "Emri",
"project": "Projekti",
"status": "Statusi",
"dueDate": "Afati",
"dueDatePlaceholder": "Cakto Afatin",
"tomorrow": "Nesër",
"nextWeek": "Javën e Ardhshme",
"nextMonth": "Muajin e Ardhshëm",
"projectRequired": "Ju lutemi zgjidhni një projekt",
"pressTabToSelectDueDateAndProject": "Shtyp Tab për të zgjedhur afatin dhe projektin",
"dueOn": "Detyrat me afat më",
"taskRequired": "Ju lutemi shtoni një detyrë",
"list": "Listë",
"calendar": "Kalendar",
"tasks": "Detyrat",
"refresh": "Rifresko"
}
}

View File

@@ -1,8 +0,0 @@
{
"formTitle": "Fto ekipin tënd të punojë me",
"inputLabel": "Fto me email",
"addAnother": "Shto një tjetër",
"goBack": "Kthehu mbrapa",
"continue": "Vazhdo",
"skipForNow": "Anashkalo tani për tani"
}

View File

@@ -1,30 +0,0 @@
{
"rename": "Riemërto",
"delete": "Fshi",
"addTask": "Shto Detyrë",
"addSectionButton": "Shto Seksion",
"changeCategory": "Ndrysho kategorinë",
"deleteTooltip": "Fshi",
"deleteConfirmationTitle": "Jeni i sigurt?",
"deleteConfirmationOk": "Po",
"deleteConfirmationCancel": "Anulo",
"dueDate": "Data e përfundimit",
"cancel": "Anulo",
"today": "Sot",
"tomorrow": "Nesër",
"assignToMe": "Cakto mua",
"archive": "Arkivo",
"newTaskNamePlaceholder": "Shkruaj emrin e detyrës",
"newSubtaskNamePlaceholder": "Shkruaj emrin e nëndetyrës",
"untitledSection": "Seksion pa titull",
"unmapped": "Pa hartë",
"clickToChangeDate": "Klikoni për të ndryshuar datën",
"noDueDate": "Pa datë përfundimi",
"save": "Ruaj",
"clear": "Pastro",
"nextWeek": "Javën e ardhshme"
}

View File

@@ -1,6 +0,0 @@
{
"title": "Prova juaj e Worklenz ka skaduar!",
"subtitle": "Ju lutemi përmirësoni tani.",
"button": "Përmirëso tani",
"checking": "Po kontrollohet statusi i abonimit..."
}

View File

@@ -1,31 +0,0 @@
{
"logoAlt": "Logoja e Worklenz",
"home": "Kryefaqja",
"projects": "Projektet",
"schedule": "Orari",
"reporting": "Raportimi",
"clients": "Klientët",
"teams": "Ekipet",
"labels": "Etiketa",
"jobTitles": "Tituj Pune",
"upgradePlan": "Përmirëso Abonimin",
"upgradePlanTooltip": "Përmirëso abonimin",
"invite": "Fto",
"inviteTooltip": "Fto anëtarë të ekipit të bashkohen",
"switchTeamTooltip": "Ndrysho ekipin",
"help": "Ndihmë",
"notificationTooltip": "Shiko njoftimet",
"profileTooltip": "Shiko profilin",
"adminCenter": "Qendra Administrative",
"settings": "Cilësimet",
"logOut": "Dil",
"notificationsDrawer": {
"read": "Lexuara e njoftimet ",
"unread": "Njoftimet e palexuara",
"markAsRead": "Shëno si të lexuara",
"readAndJoin": "Lexo & Bashkohu",
"accept": "Prano",
"acceptAndJoin": "Prano & Bashkohu",
"noNotifications": "Asnjë njoftim"
}
}

View File

@@ -1,5 +0,0 @@
{
"nameYourOrganization": "Emërtoni organizatën tuaj.",
"worklenzAccountTitle": "Zgjidhni një emër për llogarinë tuaj në Worklenz.",
"continue": "Vazhdo"
}

View File

@@ -1,19 +0,0 @@
{
"configurePhases": "Konfiguro Fazat",
"phaseLabel": "Etiketa e Fazës",
"enterPhaseName": "Vendosni një emër për etiketën e fazës",
"addOption": "Shto Opsion",
"phaseOptions": "Opsionet e Fazës:",
"dragToReorderPhases": "Zvarrit fazat për t'i rirenditur. Çdo fazë mund të ketë një ngjyrë të ndryshme.",
"enterNewPhaseName": "Shkruani emrin e fazës së re...",
"addPhase": "Shto Fazë",
"noPhasesFound": "Nuk u gjetën faza. Krijoni fazën tuaj të parë më sipër.",
"deletePhase": "Fshi Fazën",
"deletePhaseConfirm": "Jeni të sigurt që doni të fshini këtë fazë? Ky veprim nuk mund të zhbëhet.",
"rename": "Riemëro",
"delete": "Fshi",
"enterPhaseName": "Shkruani emrin e fazës",
"selectColor": "Zgjidh ngjyrën",
"managePhases": "Menaxho Fazat",
"close": "Mbyll"
}

View File

@@ -1,42 +0,0 @@
{
"createProject": "Krijo Projekt",
"editProject": "Modifiko Projektin",
"enterCategoryName": "Vendosni emër për kategorinë",
"hitEnterToCreate": "Shtyp Enter për të krijuar!",
"enterNotes": "Shënime",
"youCanManageClientsUnderSettings": "Mund të menaxhoni klientët nën Cilësimet",
"addCategory": "Shto kategori projektit",
"newCategory": "Kategori e Re",
"notes": "Shënime",
"startDate": "Data e Fillimit",
"endDate": "Data e Përfundimit",
"estimateWorkingDays": "Vlerëso ditët e punës",
"estimateManDays": "Vlerëso ditët e punëtorëve",
"hoursPerDay": "Orë në ditë",
"create": "Krijo",
"update": "Përditëso",
"delete": "Fshi",
"typeToSearchClients": "Shkruani për të kërkuar klientë",
"projectColor": "Ngjyra e Projektit",
"pleaseEnterAName": "Ju lutemi vendosni një emër",
"enterProjectName": "Vendosni emrin e projektit",
"name": "Emri",
"status": "Statusi",
"health": "Gjendja",
"category": "Kategoria",
"projectManager": "Menaxheri i Projektit",
"client": "Klienti",
"deleteConfirmation": "Jeni i sigurt që doni të fshini?",
"deleteConfirmationDescription": "Kjo do të fshijë të gjitha të dhënat e lidhura dhe nuk mund të zhbëhet.",
"yes": "Po",
"no": "Jo",
"createdAt": "Krijuar më",
"updatedAt": "Përditësuar më",
"by": "nga",
"add": "Shto",
"asClient": "si klient",
"createClient": "Krijo klient",
"searchInputPlaceholder": "Kërko sipas emrit ose emailit",
"hoursPerDayValidationMessage": "Orët në ditë duhet të jenë një numër midis 1 dhe 24",
"noPermission": "Nuk ka leje"
}

View File

@@ -1,14 +0,0 @@
{
"nameColumn": "Emri",
"attachedTaskColumn": "Detyra e Bashkangjitur",
"sizeColumn": "Madhësia",
"uploadedByColumn": "Ngarkuar Nga",
"uploadedAtColumn": "Ngarkuar Më",
"fileIconAlt": "Ikona e skedarit",
"titleDescriptionText": "Të gjitha bashkëngjitjet e detyrave në këtë projekt do të shfahen këtu.",
"deleteConfirmationTitle": "Jeni i sigurt?",
"deleteConfirmationOk": "Po",
"deleteConfirmationCancel": "Anulo",
"segmentedTooltip": "Së shpejti! Kaloni midis pamjes listë dhe pamjes miniaturash.",
"emptyText": "Nuk ka bashkëngjitje në projekt."
}

View File

@@ -1,41 +0,0 @@
{
"overview": {
"title": "Përmbledhje",
"statusOverview": "Përmbledhje Statusi",
"priorityOverview": "Përmbledhje Prioriteti",
"lastUpdatedTasks": "Detyrat e Përditësuara Së Fundi"
},
"members": {
"title": "Anëtarët",
"tooltip": "Anëtarët",
"tasksByMembers": "Detyrat sipas anëtarëve",
"tasksByMembersTooltip": "Detyrat sipas anëtarëve",
"name": "Emri",
"taskCount": "Numri i Detyrave",
"contribution": "Kontributi",
"completed": "Të Përfunduara",
"incomplete": "Të Papërfunduara",
"overdue": "Të Vonuara",
"progress": "Progresi"
},
"tasks": {
"overdueTasks": "Detyrat e Vonuara",
"overLoggedTasks": "Detyrat me regjistrim të tepërt",
"tasksCompletedEarly": "Detyrat e përfunduara para afatit",
"tasksCompletedLate": "Detyrat e përfunduara pas afatit",
"overLoggedTasksTooltip": "Detyrat me kohë të regjistruar mbi kohën e vlerësuar",
"overdueTasksTooltip": "Detyrat që kanë kaluar afatin e tyre"
},
"common": {
"seeAll": "Shiko të gjitha",
"totalLoggedHours": "Orët totale të regjistruara",
"totalEstimation": "Vlerësimi total",
"completedTasks": "Detyrat e përfunduara",
"incompleteTasks": "Detyrat e papërfunduara",
"overdueTasks": "Detyrat e vonuara",
"overdueTasksTooltip": "Detyrat që kanë kaluar afatin e tyre",
"totalLoggedHoursTooltip": "Vlerësimi dhe koha e regjistruar për detyrat.",
"includeArchivedTasks": "Përfshi Detyrat e Arkivuara",
"export": "Eksporto"
}
}

View File

@@ -1,17 +0,0 @@
{
"nameColumn": "Emri",
"jobTitleColumn": "Titulli i Punës",
"emailColumn": "Email",
"tasksColumn": "Detyrat",
"taskProgressColumn": "Progresi i Detyrave",
"accessColumn": "Qasja",
"fileIconAlt": "Ikona e skedarit",
"deleteConfirmationTitle": "Jeni i sigurt?",
"deleteConfirmationOk": "Po",
"deleteConfirmationCancel": "Anulo",
"refreshButtonTooltip": "Rifresko anëtarët",
"deleteButtonTooltip": "Hiq nga projekti",
"memberCount": "Anëtar",
"membersCountPlural": "Anëtarë",
"emptyText": "Nuk ka bashkëngjitje në projekt."
}

View File

@@ -1,6 +0,0 @@
{
"inputPlaceholder": "Shto një koment..",
"addButton": "Shto",
"cancelButton": "Anulo",
"deleteButton": "Fshi"
}

View File

@@ -1,14 +0,0 @@
{
"taskList": "Lista e Detyrave",
"board": "Tabela Kanban",
"insights": "Analiza",
"files": "Skedarë",
"members": "Anëtarë",
"updates": "Përditësime",
"projectView": "Pamja e Projektit",
"loading": "Duke ngarkuar projektin...",
"error": "Gabim në ngarkimin e projektit",
"pinnedTab": "E fiksuar si tab i parazgjedhur",
"pinTab": "Fikso si tab i parazgjedhur",
"unpinTab": "Hiqe fiksimin e tab-it të parazgjedhur"
}

View File

@@ -1,11 +0,0 @@
{
"importTaskTemplate": "Importo Shabllon Detyrash",
"templateName": "Emri i Shabllonit",
"templateDescription": "Përshkrimi i Shabllonit",
"selectedTasks": "Detyrat e Përzgjedhura",
"tasks": "Detyrat",
"templates": "Shabllonet",
"remove": "Hiq",
"cancel": "Anulo",
"import": "Importo"
}

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