Compare commits

..

47 Commits

Author SHA1 Message Date
Chamika J
da3728024d feat(gantt): enhance Gantt chart with task creation and phase updates
- Added a task creation popover for quick task entry within the Gantt chart.
- Implemented phase update handling to refresh task and phase data after modifications.
- Enhanced GanttChart and GanttTaskList components to support new task creation and phase management features.
- Updated GanttToolbar to streamline user interactions for task and phase management.
- Improved phase details modal for better editing capabilities and user feedback.
2025-08-06 17:11:45 +05:30
Chamika J
69ec445a8a feat(gantt): enhance Gantt chart interactivity and animations
- Added phase details modal for improved phase management and user interaction.
- Implemented hover effects and animations for task and phase rows in Gantt chart.
- Updated Gantt components to support phase click events and task animations.
- Enhanced CSS for smoother transitions and visual feedback during task interactions.
- Refactored GanttTaskList and GanttChart components to incorporate new animation logic.
2025-08-05 16:44:12 +05:30
Chamika J
ad7eb505b5 feat(gantt): enhance Gantt chart functionality with task progress tracking
- Updated GanttController to include task counts by status (todo, doing, done) and total tasks for each project phase.
- Implemented progress percentage calculations for each phase based on task counts.
- Enhanced ProjectViewGantt component to fetch task priorities and manage task data more effectively.
- Improved GanttChart and GanttTaskList components for better rendering of tasks and phases, including drag-and-drop functionality.
- Refactored GanttTimeline to optimize header generation based on view mode and date range.
- Updated GanttToolbar for improved user interaction with task management features.
2025-08-05 16:02:07 +05:30
Chamika J
d33a7db253 refactor(gantt): restructure Gantt components and update styles
- Deleted obsolete Gantt components including GanttChart, GanttTaskList, GanttTimeline, GanttToolbar, and their associated styles.
- Renamed ProjectViewGantt component for consistency in naming conventions.
- Updated CSS for task list scrollbar to hide the scrollbar and improve visual aesthetics.
- Introduced new styles for consistent row heights, improved hover states, and enhanced button interactions.
- Added functionality for phase management and task animations to enhance user experience.
2025-08-05 12:21:08 +05:30
chamiakJ
9c4293e7a9 feat(gantt): implement Gantt chart functionality with roadmap tasks and project phases
- Added GanttController with endpoints for fetching roadmap tasks and project phases.
- Implemented task date update functionality in the GanttController.
- Created Gantt components including GanttChart, GanttTaskList, GanttTimeline, and GanttToolbar for rendering the Gantt interface.
- Integrated Redux Toolkit Query for API interactions related to Gantt tasks and phases.
- Established context for Gantt state management and utility functions for timeline calculations.
- Enhanced styling for Gantt components and added responsive design features.
- Introduced drag-and-drop functionality for task management within the Gantt chart.
2025-08-04 07:22:56 +05:30
Chamika J
7c42087854 feat(update-notification): implement update notification system for new versions
- Added a service worker message handler to check for updates and notify users.
- Created `UpdateNotification` component to display update prompts with options to reload or dismiss.
- Introduced `UpdateNotificationProvider` to manage update state and notifications globally.
- Implemented `useUpdateChecker` hook for periodic update checks and user notification management.
- Updated localization files to include new strings related to update notifications.
- Enhanced service worker functionality to support hard reloads and update checks.
2025-07-31 16:12:04 +05:30
Chamika J
14c89dec24 chore(tests): remove obsolete SQL test scripts for sort order validation
- Deleted `test_sort_fix.sql` and `test_sort_orders.sql` as they are no longer needed for the current implementation.
- These scripts were previously used to verify sort order constraints and helper functions, but have been deemed unnecessary.
2025-07-31 15:51:43 +05:30
Chamika J
b1bdf0ac11 feat(hubspot): add dark mode support and light color scheme for chat widget
- Removed production check to ensure HubSpot script loads in all environments.
- Implemented dark mode CSS injection when the dark theme is active.
- Added a style block to enforce a light color scheme for the HubSpot chat widget, improving visibility and user experience.
2025-07-31 15:44:04 +05:30
Chamika J
7635676289 feat(trial-user-limits): implement trial member limit checks in project and team controllers
- Added TRIAL_MEMBER_LIMIT constant to enforce a maximum number of trial users in project and team member controllers.
- Implemented logic to check current trial members against the limit during user addition, providing appropriate responses for exceeding limits.
- Updated relevant controllers to utilize the new trial member limit functionality, enhancing subscription management for trial users.
- Enhanced error messaging to guide users on upgrading their subscription for additional members.
2025-07-31 12:56:28 +05:30
chamikaJ
2bd6c19c13 refactor(layouts): simplify MainLayout and enhance styling
- Removed unused imports and performance monitoring hooks from MainLayout.
- Updated layout structure to improve responsiveness and styling, including sticky header and optimized content padding.
- Adjusted home page layout to reduce margin and improve spacing for better visual consistency.
- Enhanced TodoList component with collapsible sections for improved user interaction and task management.
- Streamlined project and schedule pages by removing unnecessary margin adjustments, ensuring a cleaner layout.
2025-07-30 17:20:20 +05:30
chamikaJ
374595261f feat(task-list-v2): enhance sticky column behavior and dark mode support
- Updated DropSpacer and EmptyGroupMessage components to accept an optional isDarkMode prop for improved styling in dark mode.
- Enhanced task rendering in TaskRow to dynamically adjust background colors based on dark mode and drag states.
- Refactored useTaskRowColumns to support sticky column positioning and hover effects, ensuring a consistent user experience across different themes.
- Improved overall visual feedback during task interactions, including drag-and-drop operations.
2025-07-30 16:25:29 +05:30
chamikaJ
b6c056dd1a feat(task-status-dropdown): enhance task status update and group movement handling
- Added logic to optimistically update task status in Redux for immediate feedback.
- Implemented group movement handling when tasks are updated based on their status, ensuring tasks are moved between groups as needed.
- Improved socket event emissions for real-time updates, including parent task handling.
- Refactored group selection logic to streamline target group identification based on status ID and group value.
2025-07-30 15:26:27 +05:30
chamikaJ
81e1872c1f refactor(task-list): simplify drag-and-drop functionality and enhance task rendering
- Removed droppable functionality from TaskGroupHeader and replaced it with a more streamlined approach in TaskListV2Table.
- Introduced DropSpacer component to improve visual feedback during task dragging.
- Updated task rendering logic in TaskRow to enhance user experience with clearer drop indicators.
- Refactored useDragAndDrop hook to manage drop positions more effectively, ensuring tasks can only be reordered within the same group.
- Improved socket event handling for task sorting to ensure accurate updates during drag-and-drop operations.
2025-07-30 15:08:28 +05:30
chamikaJ
5cce3bc613 feat(localization): add timer conflict handling and update translations
- Introduced a new hook `useTaskTimerWithConflictCheck` to manage timer conflicts, prompting users when a timer is already running for a different task.
- Updated localization files for Albanian, German, English, Spanish, Portuguese, and Chinese to include new translation keys related to timer conflict handling and cancellation.
- Refactored components to utilize the new timer hook, enhancing user experience by preventing overlapping timers.
2025-07-30 10:13:08 +05:30
chamikaJ
c53ab511bf Merge branch 'main' of https://github.com/Worklenz/worklenz into release-v2.1.4 2025-07-29 14:19:44 +05:30
Chamika J
7b9a16fd72 Merge pull request #307 from Worklenz/development
refactor: update .gitignore and remove obsolete files
2025-07-29 14:16:55 +05:30
chamikaJ
8830af2cbb refactor: update .gitignore and remove obsolete files
- Added .cursor and .claude directories to .gitignore to prevent tracking of temporary files.
- Deleted obsolete .claude/settings.local.json and .cursor/rules/antd-components.mdc files to clean up the repository and remove unnecessary configurations.
2025-07-29 14:16:52 +05:30
chamikaJ
b915de2b93 feat(reporting): enhance date handling and export functionality
- Improved date range handling in ReportingControllerBaseWithTimezone to support various date formats from the frontend, ensuring robust parsing and timezone conversion.
- Updated SQL queries to use consistent table aliases for clarity.
- Added export functionality for project members and tasks in ProjectReportsDrawer, allowing users to download relevant data.
- Enhanced the Excel export handler in ProjectsReports to streamline project data exports based on the current session's team name.
2025-07-29 13:05:55 +05:30
chamikaJ
29b8c1b2af feat(task-context-menu): add copy link functionality and update translations
- Implemented a new "Copy link to task" feature in the task context menu, allowing users to easily copy task links to the clipboard.
- Added corresponding success and error messages for link copying.
- Updated localization files for Albanian, German, English, Spanish, Portuguese, and Chinese to include new translation keys for the copy link feature.
2025-07-29 12:49:51 +05:30
chamikaJ
c2b231d5cc feat(survey-modal): add survey modal configuration to environment files
- Introduced a new environment variable VITE_ENABLE_SURVEY_MODAL to control the visibility of the survey modal.
- Updated .env.example to include the new variable with a default value of true.
- Modified SurveyPromptModal component to check the environment variable before displaying the modal, enhancing user experience by allowing toggling of the feature.
2025-07-29 11:13:08 +05:30
chamikaJ
53a28cf489 refactor(localization): update task-related translations and improve user activity feed
- Added new translation keys for recent tasks and time logged tasks in Albanian, German, English, Spanish, Portuguese, and Chinese localization files.
- Enhanced user activity feed to switch between recent tasks and time logged tasks, improving user experience.
- Updated the date formatting utility to support locale-specific formatting for better internationalization.
- Refactored task activity list and time logged task list components to utilize a table layout for improved readability.
2025-07-29 10:19:28 +05:30
Chamika J
e8ccc2a533 Merge pull request #306 from Worklenz/feature/task-activities-by-user
Feature/task activities by user
2025-07-29 08:46:13 +05:30
Chamika J
f24c0d8955 Merge branch 'release-v2.1.4' into feature/task-activities-by-user 2025-07-29 08:46:05 +05:30
Chamika J
01a580d992 Merge pull request #304 from Worklenz/fix/reporting-sidebar-style-fix
feat(survey-localization): add survey localization files for multiple…
2025-07-28 16:57:33 +05:30
chamikaJ
c2e670c9a2 feat(survey-localization): add survey localization files for multiple languages
- Introduced new localization JSON files for Albanian, German, English, Spanish, Portuguese, and Chinese to support the survey feature.
- Each file includes translations for modal titles, button texts, and feedback messages to enhance user experience across different languages.
- Updated the SurveyPromptModal component to utilize these localization entries for improved accessibility and user engagement.
2025-07-28 16:57:40 +05:30
Chamika J
25042baf71 Merge pull request #303 from Worklenz/fix/reporting-sidebar-style-fix
feat(account-setup): implement skip functionality and update localiza…
2025-07-28 16:31:53 +05:30
chamikaJ
e8d21ee187 feat(account-setup): implement skip functionality and update localization
- Added a state to manage the skipping process during account setup, enhancing user experience.
- Updated button behavior to reflect the skipping state and provide feedback to users.
- Included new localization entries for the "skipping" status in multiple languages (Albanian, German, English, Spanish, Portuguese, Chinese).
- Refined HubSpot widget styling to ensure better integration with the app's UI.
2025-07-28 16:30:21 +05:30
Chamika J
a8d1446b0d Merge pull request #302 from Worklenz/fix/reporting-sidebar-style-fix
feat(hubspot-integration): refine HubSpot widget styling and add acco…
2025-07-28 16:14:03 +05:30
chamikaJ
2082934cd5 feat(hubspot-integration): refine HubSpot widget styling and add account setup skip functionality
- Enhanced CSS targeting for HubSpot widget elements to prevent interference with the Worklenz app UI.
- Introduced a new function to allow users to bypass team member validation during account setup, improving user experience.
- Updated the button click handler to utilize the new skip functionality for a smoother setup process.
2025-07-28 16:12:12 +05:30
Chamika J
4debcd6aa5 Merge pull request #301 from Worklenz/fix/reporting-sidebar-style-fix
Fix/reporting sidebar style fix
2025-07-28 15:45:34 +05:30
chamikaJ
76adb89caf feat(task-filters): enhance sorting functionality and localization updates
- Added sorting options to task filters, including clear sort, sort ascending, sort descending, and sort by field.
- Updated localization files for multiple languages (Albanian, German, English, Spanish, Portuguese, Chinese) to include new sorting terms.
- Implemented a SortDropdown component for improved user experience in task management.
- Integrated sorting state management in the task management slice for better data handling.
2025-07-28 15:45:12 +05:30
chamikaJ
703a6425fe feat(surveys): add survey tables and initial data for account setup questionnaire
- Created tables for surveys, survey questions, survey responses, and survey answers to support the account setup process.
- Added default account setup survey and corresponding questions to the database.
- Implemented necessary indexes and constraints for data integrity and performance.
2025-07-28 15:17:21 +05:30
Chamika J
e2c9e19b83 Merge pull request #300 from Worklenz/fix/reporting-sidebar-style-fix
refactor(survey-submission): update validation logic and submission d…
2025-07-28 15:08:43 +05:30
chamikaJ
e2a749e0b6 refactor(survey-submission): update validation logic and submission data handling
- Modified the survey submission validator to make both answer_text and answer_json optional, allowing users to submit empty answers.
- Refactored the SurveyPromptModal component to only include answered questions in the submission data, improving data handling and clarity.
2025-07-28 15:07:09 +05:30
Chamika J
2c0b0ac4c5 Merge pull request #299 from Worklenz/fix/reporting-sidebar-style-fix
Fix/reporting sidebar style fix
2025-07-28 14:55:11 +05:30
chamikaJ
dd511b236f refactor(reporting-layout): streamline sidebar and content layout
- Replaced the existing sidebar implementation with a new ReportingSider component that accepts collapse state and toggle function as props.
- Simplified the ReportingCollapsedButton component for better readability and functionality.
- Updated layout styles to enhance responsiveness and maintain consistent margins.
- Removed unused CSS styles related to the sidebar for cleaner code.
2025-07-28 14:54:54 +05:30
chamikaJ
2c860b0cc8 feat(localization): update password-related translations in German and Spanish signup forms
- Translated password labels, guidelines, placeholders, and validation messages to improve user experience in both languages.
- Ensured consistency in terminology and clarity in password requirements for better user understanding.
2025-07-28 14:17:41 +05:30
Chamika J
1e6045c534 Merge pull request #297 from Worklenz/fix/task-time-log-timezone-fix
feat(task-time-logs): enhance time log retrieval and formatting with …
2025-07-28 09:48:08 +05:30
chamikaJ
2a9e12a495 feat(task-time-logs): enhance time log retrieval and formatting with user timezone
- Integrated user timezone handling in the task time logs API service to ensure accurate time representation.
- Added a new utility function to format date/time strings according to the user's profile timezone.
- Updated the TimeLogItem component to utilize the new formatting function for displaying timestamps.
2025-07-28 09:44:59 +05:30
Chamika J
fd2fc793df Merge pull request #295 from Worklenz/chore/added-sign-up-survey
Chore/added sign up survey
2025-07-25 15:23:03 +05:30
Chamika J
f3b7479770 Merge pull request #291 from Worklenz/chore/added-sign-up-survey
Chore/added sign up survey
2025-07-25 13:03:16 +05:30
chamikaJ
61461bb776 feat(user-activity): enhance user activity logs with additional data and improved queries
- Added optional fields for project color, task status, and status color in IUserRecentTask and IUserTimeLoggedTask interfaces.
- Optimized SQL queries to include team filtering and additional data such as project color and task status.
- Updated frontend components to support new data fields and improved styling for better user experience.
- Enhanced dark mode detection and styling in task activity lists.
- Implemented refetching of data on tab change in the user activity feed.
2025-07-14 13:26:28 +05:30
chamikaJ
2a7019c64c refactor(home-page): simplify layout by removing unnecessary Card component
- Removed the Card wrapper around the TasksList for a cleaner layout.
- Cleaned up whitespace in the home-page.tsx file for improved readability.
2025-07-14 12:48:13 +05:30
chamikaJ
5b1cbb0c46 Merge branch 'main' of https://github.com/Worklenz/worklenz into feature/task-activities-by-user 2025-07-14 12:46:18 +05:30
Chamika J
7c04598264 Merge pull request #253 from OminduHirushka/imp/user-activity-feed
user activity feed
2025-07-11 11:27:42 +05:30
Omindu Hirushka
5222d75064 user activity feed - frontend 2025-07-09 07:44:23 +05:30
Omindu Hirushka
2587b8afd9 user activity feed - backend 2025-07-09 07:43:44 +05:30
185 changed files with 11221 additions and 2490 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`

5
.gitignore vendored
View File

@@ -36,6 +36,8 @@ lerna-debug.log*
.vscode/*
!.vscode/extensions.json
.idea/
.cursor/
.claude/
.DS_Store
*.suo
*.ntvs*
@@ -76,4 +78,7 @@ $RECYCLE.BIN/
# TypeScript
*.tsbuildinfo
# Claude
CLAUDE.md

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

@@ -2297,3 +2297,60 @@ ALTER TABLE organization_working_days
ALTER TABLE organization_working_days
ADD CONSTRAINT org_organization_id_fk
FOREIGN KEY (organization_id) REFERENCES organizations;
-- Survey tables for account setup questionnaire
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,
is_active BOOLEAN DEFAULT TRUE NOT NULL,
created_at TIMESTAMP DEFAULT now() NOT NULL,
updated_at TIMESTAMP DEFAULT now() NOT NULL
);
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,
question_type VARCHAR(50) NOT NULL,
is_required BOOLEAN DEFAULT FALSE NOT NULL,
sort_order INTEGER DEFAULT 0 NOT NULL,
options JSONB,
created_at TIMESTAMP DEFAULT now() NOT NULL,
updated_at TIMESTAMP DEFAULT now() NOT NULL
);
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 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,
created_at TIMESTAMP DEFAULT now() NOT NULL,
updated_at TIMESTAMP DEFAULT now() NOT NULL
);
-- Survey table 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);
-- Survey table 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'));
ALTER TABLE survey_responses ADD CONSTRAINT unique_user_survey_response UNIQUE (user_id, survey_id);
ALTER TABLE survey_answers ADD CONSTRAINT unique_response_question_answer UNIQUE (response_id, question_id);

View File

@@ -142,3 +142,25 @@ DROP FUNCTION sys_insert_license_types();
INSERT INTO timezones (name, abbrev, utc_offset)
SELECT name, abbrev, utc_offset
FROM pg_timezone_names;
-- Insert 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;
-- Insert survey questions for account setup survey
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 $$;

View File

@@ -94,4 +94,194 @@ export default class GanttController extends WorklenzControllerBase {
}
return res.status(200).send(new ServerResponse(true, result.rows));
}
@HandleExceptions()
public static async getRoadmapTasks(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const projectId = req.query.project_id;
const q = `
SELECT
t.id,
t.name,
t.start_date,
t.end_date,
t.done,
t.roadmap_sort_order,
t.parent_task_id,
CASE WHEN t.done THEN 100 ELSE 0 END as progress,
ts.name as status_name,
tsc.color_code as status_color,
tp.name as priority_name,
tp.value as priority_value,
tp.color_code as priority_color,
(
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(assignee_info))), '[]'::JSON)
FROM (
SELECT
tm.id as team_member_id,
u.name as assignee_name,
u.avatar_url
FROM tasks_assignees ta
JOIN team_members tm ON ta.team_member_id = tm.id
JOIN users u ON tm.user_id = u.id
WHERE ta.task_id = t.id
) assignee_info
) as assignees,
(
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(phase_info))), '[]'::JSON)
FROM (
SELECT
pp.id as phase_id,
pp.name as phase_name,
pp.color_code as phase_color
FROM task_phase tp
JOIN project_phases pp ON tp.phase_id = pp.id
WHERE tp.task_id = t.id
) phase_info
) as phases,
(
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(dependency_info))), '[]'::JSON)
FROM (
SELECT
td.related_task_id,
td.dependency_type,
rt.name as related_task_name
FROM task_dependencies td
JOIN tasks rt ON td.related_task_id = rt.id
WHERE td.task_id = t.id
) dependency_info
) as dependencies
FROM tasks t
LEFT JOIN task_statuses ts ON t.status_id = ts.id
LEFT JOIN sys_task_status_categories tsc ON ts.category_id = tsc.id
LEFT JOIN task_priorities tp ON t.priority_id = tp.id
WHERE t.project_id = $1
AND t.archived = FALSE
AND t.parent_task_id IS NULL
ORDER BY t.roadmap_sort_order, t.created_at DESC;
`;
const result = await db.query(q, [projectId]);
// Get subtasks for each parent task
for (const task of result.rows) {
const subtasksQuery = `
SELECT
id,
name,
start_date,
end_date,
done,
roadmap_sort_order,
parent_task_id,
CASE WHEN done THEN 100 ELSE 0 END as progress
FROM tasks
WHERE parent_task_id = $1
AND archived = FALSE
ORDER BY roadmap_sort_order, created_at DESC;
`;
const subtasksResult = await db.query(subtasksQuery, [task.id]);
task.subtasks = subtasksResult.rows;
}
return res.status(200).send(new ServerResponse(true, result.rows));
}
@HandleExceptions()
public static async getProjectPhases(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const projectId = req.query.project_id;
const q = `
SELECT
pp.id,
pp.name,
pp.color_code,
pp.start_date,
pp.end_date,
pp.sort_index,
-- Calculate task counts by status category for progress
COALESCE(
(SELECT COUNT(*)
FROM tasks t
JOIN task_phase tp ON t.id = tp.task_id
JOIN task_statuses ts ON t.status_id = ts.id
JOIN sys_task_status_categories stsc ON ts.category_id = stsc.id
WHERE tp.phase_id = pp.id
AND t.archived = FALSE
AND stsc.is_todo = TRUE), 0
) as todo_count,
COALESCE(
(SELECT COUNT(*)
FROM tasks t
JOIN task_phase tp ON t.id = tp.task_id
JOIN task_statuses ts ON t.status_id = ts.id
JOIN sys_task_status_categories stsc ON ts.category_id = stsc.id
WHERE tp.phase_id = pp.id
AND t.archived = FALSE
AND stsc.is_doing = TRUE), 0
) as doing_count,
COALESCE(
(SELECT COUNT(*)
FROM tasks t
JOIN task_phase tp ON t.id = tp.task_id
JOIN task_statuses ts ON t.status_id = ts.id
JOIN sys_task_status_categories stsc ON ts.category_id = stsc.id
WHERE tp.phase_id = pp.id
AND t.archived = FALSE
AND stsc.is_done = TRUE), 0
) as done_count,
COALESCE(
(SELECT COUNT(*)
FROM tasks t
JOIN task_phase tp ON t.id = tp.task_id
WHERE tp.phase_id = pp.id
AND t.archived = FALSE), 0
) as total_count
FROM project_phases pp
WHERE pp.project_id = $1
ORDER BY pp.sort_index, pp.created_at;
`;
const result = await db.query(q, [projectId]);
// Calculate progress percentages for each phase
const phasesWithProgress = result.rows.map(phase => {
const total = parseInt(phase.total_count) || 0;
const todoCount = parseInt(phase.todo_count) || 0;
const doingCount = parseInt(phase.doing_count) || 0;
const doneCount = parseInt(phase.done_count) || 0;
return {
id: phase.id,
name: phase.name,
color_code: phase.color_code,
start_date: phase.start_date,
end_date: phase.end_date,
sort_index: phase.sort_index,
// Calculate progress percentages
todo_progress: total > 0 ? Math.round((todoCount / total) * 100) : 0,
doing_progress: total > 0 ? Math.round((doingCount / total) * 100) : 0,
done_progress: total > 0 ? Math.round((doneCount / total) * 100) : 0,
total_tasks: total
};
});
return res.status(200).send(new ServerResponse(true, phasesWithProgress));
}
@HandleExceptions()
public static async updateTaskDates(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { task_id, start_date, end_date } = req.body;
const q = `
UPDATE tasks
SET start_date = $2, end_date = $3, updated_at = NOW()
WHERE id = $1
RETURNING id, start_date, end_date;
`;
const result = await db.query(q, [task_id, start_date, end_date]);
return res.status(200).send(new ServerResponse(true, result.rows[0]));
}
}

View File

@@ -9,7 +9,7 @@ import {getColor} from "../shared/utils";
import TeamMembersController from "./team-members-controller";
import {checkTeamSubscriptionStatus} from "../shared/paddle-utils";
import {updateUsers} from "../shared/paddle-requests";
import {statusExclude} from "../shared/constants";
import {statusExclude, TRIAL_MEMBER_LIMIT} from "../shared/constants";
import {NotificationsService} from "../services/notifications/notifications.service";
export default class ProjectMembersController extends WorklenzControllerBase {
@@ -118,6 +118,17 @@ export default class ProjectMembersController extends WorklenzControllerBase {
return res.status(200).send(new ServerResponse(false, null, "Maximum number of life time users reached."));
}
/**
* Checks trial user team member limit
*/
if (subscriptionData.subscription_status === "trialing") {
const currentTrialMembers = parseInt(subscriptionData.current_count) || 0;
if (currentTrialMembers + 1 > TRIAL_MEMBER_LIMIT) {
return res.status(200).send(new ServerResponse(false, null, `Trial users cannot exceed ${TRIAL_MEMBER_LIMIT} team members. Please upgrade to add more members.`));
}
}
// if (subscriptionData.status === "trialing") break;
if (!userExists && !subscriptionData.is_credit && !subscriptionData.is_custom && subscriptionData.subscription_status !== "trialing") {
// if (subscriptionData.subscription_status === "active") {

View File

@@ -17,7 +17,7 @@ export default abstract class ReportingControllerBaseWithTimezone extends Workle
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';
return result.rows[0]?.timezone || "UTC";
}
/**
@@ -30,20 +30,43 @@ export default abstract class ReportingControllerBaseWithTimezone extends Workle
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`;
try {
// Handle different date formats that might come from frontend
let startDate, endDate;
// Try to parse the date - it might be a full JS Date string or ISO string
if (dateRange[0].includes("GMT") || dateRange[0].includes("(")) {
// Parse JavaScript Date toString() format
startDate = moment(new Date(dateRange[0]));
endDate = moment(new Date(dateRange[1]));
} else {
// Parse ISO format or other formats
startDate = moment(dateRange[0]);
endDate = moment(dateRange[1]);
}
// Convert to user's timezone and get start/end of day
const start = startDate.tz(userTimezone).startOf("day");
const end = endDate.tz(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 twl.created_at >= '${startUtc}'::TIMESTAMP AND twl.created_at <= '${endUtc}'::TIMESTAMP`;
}
return `AND twl.created_at >= '${startUtc}'::TIMESTAMP AND twl.created_at <= '${endUtc}'::TIMESTAMP`;
} catch (error) {
console.error("Error parsing date range:", error, { dateRange, userTimezone });
// Fallback to current date if parsing fails
const now = moment.tz(userTimezone);
const startUtc = now.clone().startOf("day").utc().format("YYYY-MM-DD HH:mm:ss");
const endUtc = now.clone().endOf("day").utc().format("YYYY-MM-DD HH:mm:ss");
return `AND twl.created_at >= '${startUtc}'::TIMESTAMP AND twl.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
@@ -52,20 +75,20 @@ export default abstract class ReportingControllerBaseWithTimezone extends Workle
switch (key) {
case DATE_RANGES.YESTERDAY:
startDate = now.clone().subtract(1, 'day').startOf('day');
endDate = now.clone().subtract(1, 'day').endOf('day');
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');
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');
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');
startDate = now.clone().subtract(3, "months").startOf("day");
endDate = now.clone().endOf("day");
break;
default:
return "";
@@ -74,7 +97,7 @@ export default abstract class ReportingControllerBaseWithTimezone extends Workle
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 `AND twl.created_at >= '${startUtc}'::TIMESTAMP AND twl.created_at <= '${endUtc}'::TIMESTAMP`;
}
return "";
@@ -87,7 +110,7 @@ export default abstract class ReportingControllerBaseWithTimezone extends Workle
* @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") {
protected static formatDateInTimezone(date: string | Date, userTimezone: string, format = "YYYY-MM-DD HH:mm:ss") {
return moment.tz(date, userTimezone).format(format);
}
@@ -104,12 +127,12 @@ export default abstract class ReportingControllerBaseWithTimezone extends Workle
let workingDays = 0;
const current = start.clone();
while (current.isSameOrBefore(end, 'day')) {
while (current.isSameOrBefore(end, "day")) {
// Monday = 1, Friday = 5
if (current.isoWeekday() >= 1 && current.isoWeekday() <= 5) {
workingDays++;
}
current.add(1, 'day');
current.add(1, "day");
}
return workingDays;

View File

@@ -164,4 +164,38 @@ export default class SurveyController extends WorklenzControllerBase {
return res.status(200).send(new ServerResponse(true, response));
}
@HandleExceptions()
public static async checkAccountSetupSurveyStatus(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const userId = req.user?.id;
if (!userId) {
return res.status(200).send(new ServerResponse(false, null, "User not authenticated"));
}
const q = `
SELECT EXISTS(
SELECT 1
FROM survey_responses sr
INNER JOIN surveys s ON sr.survey_id = s.id
WHERE sr.user_id = $1
AND s.survey_type = 'account_setup'
AND sr.is_completed = true
) as is_completed,
(
SELECT sr.completed_at
FROM survey_responses sr
INNER JOIN surveys s ON sr.survey_id = s.id
WHERE sr.user_id = $1
AND s.survey_type = 'account_setup'
AND sr.is_completed = true
LIMIT 1
) as completed_at;
`;
const result = await db.query(q, [userId]);
const status = result.rows[0] || { is_completed: false, completed_at: null };
return res.status(200).send(new ServerResponse(true, status));
}
}

View File

@@ -13,7 +13,7 @@ import { SocketEvents } from "../socket.io/events";
import WorklenzControllerBase from "./worklenz-controller-base";
import HandleExceptions from "../decorators/handle-exceptions";
import { formatDuration, getColor } from "../shared/utils";
import { statusExclude, TEAM_MEMBER_TREE_MAP_COLOR_ALPHA } from "../shared/constants";
import { statusExclude, TEAM_MEMBER_TREE_MAP_COLOR_ALPHA, TRIAL_MEMBER_LIMIT } from "../shared/constants";
import { checkTeamSubscriptionStatus } from "../shared/paddle-utils";
import { updateUsers } from "../shared/paddle-requests";
import { NotificationsService } from "../services/notifications/notifications.service";
@@ -141,6 +141,17 @@ export default class TeamMembersController extends WorklenzControllerBase {
return res.status(200).send(new ServerResponse(false, null, "Cannot exceed the maximum number of life time users."));
}
/**
* Checks trial user team member limit
*/
if (subscriptionData.subscription_status === "trialing") {
const currentTrialMembers = parseInt(subscriptionData.current_count) || 0;
if (currentTrialMembers + incrementBy > TRIAL_MEMBER_LIMIT) {
return res.status(200).send(new ServerResponse(false, null, `Trial users cannot exceed ${TRIAL_MEMBER_LIMIT} team members. Please upgrade to add more members.`));
}
}
/**
* Checks subscription details and updates the user count if applicable.
* Sends a response if there is an issue with the subscription.
@@ -1081,6 +1092,18 @@ export default class TeamMembersController extends WorklenzControllerBase {
return res.status(200).send(new ServerResponse(false, "Please check your subscription status."));
}
/**
* Checks trial user team member limit
*/
if (subscriptionData.subscription_status === "trialing") {
const currentTrialMembers = parseInt(subscriptionData.current_count) || 0;
const emailsToAdd = req.body.emails?.length || 1;
if (currentTrialMembers + emailsToAdd > TRIAL_MEMBER_LIMIT) {
return res.status(200).send(new ServerResponse(false, null, `Trial users cannot exceed ${TRIAL_MEMBER_LIMIT} team members. Please upgrade to add more members.`));
}
}
// if (subscriptionData.status === "trialing") break;
if (!subscriptionData.is_credit && !subscriptionData.is_custom) {
if (subscriptionData.subscription_status === "active") {

View File

@@ -0,0 +1,117 @@
import moment from "moment";
import { IWorkLenzRequest } from "../interfaces/worklenz-request";
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
import db from "../config/db";
import { ServerResponse } from "../models/server-response";
import WorklenzControllerBase from "./worklenz-controller-base";
import HandleExceptions from "../decorators/handle-exceptions";
import { formatDuration, formatLogText, getColor } from "../shared/utils";
interface IUserRecentTask {
task_id: string;
task_name: string;
project_id: string;
project_name: string;
last_activity_at: string;
activity_count: number;
project_color?: string;
task_status?: string;
status_color?: string;
}
interface IUserTimeLoggedTask {
task_id: string;
task_name: string;
project_id: string;
project_name: string;
total_time_logged: number;
total_time_logged_string: string;
last_logged_at: string;
logged_by_timer: boolean;
project_color?: string;
task_status?: string;
status_color?: string;
log_entries_count?: number;
estimated_time?: number;
}
export default class UserActivityLogsController extends WorklenzControllerBase {
@HandleExceptions()
public static async getRecentTasks(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
if (!req.user) {
return res.status(401).send(new ServerResponse(false, null, "Unauthorized"));
}
const { id: userId, team_id: teamId } = req.user;
const { offset = 0, limit = 10 } = req.query;
// Optimized query with better performance and team filtering
const q = `
SELECT DISTINCT tal.task_id, t.name AS task_name, tal.project_id, p.name AS project_name,
MAX(tal.created_at) AS last_activity_at,
COUNT(DISTINCT tal.id) AS activity_count,
p.color_code AS project_color,
(SELECT name FROM task_statuses WHERE id = t.status_id) AS task_status,
(SELECT color_code
FROM sys_task_status_categories
WHERE id = (SELECT category_id FROM task_statuses WHERE id = t.status_id)) AS status_color
FROM task_activity_logs tal
INNER JOIN tasks t ON tal.task_id = t.id AND t.archived = FALSE
INNER JOIN projects p ON tal.project_id = p.id AND p.team_id = $1
WHERE tal.user_id = $2
AND tal.created_at >= NOW() - INTERVAL '30 days'
GROUP BY tal.task_id, t.name, tal.project_id, p.name, p.color_code, t.status_id
ORDER BY MAX(tal.created_at) DESC
LIMIT $3 OFFSET $4;
`;
const result = await db.query(q, [teamId, userId, limit, offset]);
const tasks: IUserRecentTask[] = result.rows;
return res.status(200).send(new ServerResponse(true, tasks));
}
@HandleExceptions()
public static async getTimeLoggedTasks(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
if (!req.user) {
return res.status(401).send(new ServerResponse(false, null, "Unauthorized"));
}
const { id: userId, team_id: teamId } = req.user;
const { offset = 0, limit = 10 } = req.query;
// Optimized query with better performance, team filtering, and useful additional data
const q = `
SELECT twl.task_id, t.name AS task_name, t.project_id, p.name AS project_name,
SUM(twl.time_spent) AS total_time_logged,
MAX(twl.created_at) AS last_logged_at,
MAX(twl.logged_by_timer::int)::boolean AS logged_by_timer,
p.color_code AS project_color,
(SELECT name FROM task_statuses WHERE id = t.status_id) AS task_status,
(SELECT color_code
FROM sys_task_status_categories
WHERE id = (SELECT category_id FROM task_statuses WHERE id = t.status_id)) AS status_color,
COUNT(DISTINCT twl.id) AS log_entries_count,
(t.total_minutes * 60) AS estimated_time
FROM task_work_log twl
INNER JOIN tasks t ON twl.task_id = t.id AND t.archived = FALSE
INNER JOIN projects p ON t.project_id = p.id AND p.team_id = $1
WHERE twl.user_id = $2
AND twl.created_at >= NOW() - INTERVAL '90 days'
GROUP BY twl.task_id, t.name, t.project_id, p.name, p.color_code, t.status_id, t.total_minutes
HAVING SUM(twl.time_spent) > 0
ORDER BY MAX(twl.created_at) DESC
LIMIT $3 OFFSET $4;
`;
const result = await db.query(q, [teamId, userId, limit, offset]);
const tasks: IUserTimeLoggedTask[] = result.rows.map(task => ({
...task,
total_time_logged_string: formatDuration(moment.duration(task.total_time_logged, "seconds")),
}));
return res.status(200).send(new ServerResponse(true, tasks));
}
}

View File

@@ -27,10 +27,7 @@ export default function surveySubmissionValidator(req: IWorkLenzRequest, res: IW
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`));
}
// answer_text and answer_json are both optional - users can submit empty answers
// Validate answer_text if provided
if (answer.answer_text && typeof answer.answer_text !== 'string') {

View File

@@ -81,5 +81,12 @@
"delete": "Fshi",
"enterStatusName": "Shkruani emrin e statusit",
"selectCategory": "Zgjidh kategorinë",
"close": "Mbyll"
"close": "Mbyll",
"clearSort": "Pastro Renditjen",
"sortAscending": "Rendit në Rritje",
"sortDescending": "Rendit në Zbritje",
"sortByField": "Rendit sipas {{field}}",
"ascendingOrder": "Rritës",
"descendingOrder": "Zbritës",
"currentSort": "Renditja aktuale: {{field}} {{order}}"
}

View File

@@ -81,5 +81,12 @@
"delete": "Löschen",
"enterStatusName": "Statusnamen eingeben",
"selectCategory": "Kategorie auswählen",
"close": "Schließen"
"close": "Schließen",
"clearSort": "Sortierung löschen",
"sortAscending": "Aufsteigend sortieren",
"sortDescending": "Absteigend sortieren",
"sortByField": "Sortieren nach {{field}}",
"ascendingOrder": "Aufsteigend",
"descendingOrder": "Absteigend",
"currentSort": "Aktuelle Sortierung: {{field}} {{order}}"
}

View File

@@ -81,5 +81,12 @@
"delete": "Delete",
"enterStatusName": "Enter status name",
"selectCategory": "Select category",
"close": "Close"
"close": "Close",
"clearSort": "Clear Sort",
"sortAscending": "Sort Ascending",
"sortDescending": "Sort Descending",
"sortByField": "Sort by {{field}}",
"ascendingOrder": "Ascending",
"descendingOrder": "Descending",
"currentSort": "Current sort: {{field}} {{order}}"
}

View File

@@ -77,5 +77,12 @@
"delete": "Eliminar",
"enterStatusName": "Introducir nombre del estado",
"selectCategory": "Seleccionar categoría",
"close": "Cerrar"
"close": "Cerrar",
"clearSort": "Limpiar Ordenamiento",
"sortAscending": "Ordenar Ascendente",
"sortDescending": "Ordenar Descendente",
"sortByField": "Ordenar por {{field}}",
"ascendingOrder": "Ascendente",
"descendingOrder": "Descendente",
"currentSort": "Ordenamiento actual: {{field}} {{order}}"
}

View File

@@ -78,5 +78,12 @@
"delete": "Excluir",
"enterStatusName": "Digite o nome do status",
"selectCategory": "Selecionar categoria",
"close": "Fechar"
"close": "Fechar",
"clearSort": "Limpar Ordenação",
"sortAscending": "Ordenar Crescente",
"sortDescending": "Ordenar Decrescente",
"sortByField": "Ordenar por {{field}}",
"ascendingOrder": "Crescente",
"descendingOrder": "Decrescente",
"currentSort": "Ordenação atual: {{field}} {{order}}"
}

View File

@@ -75,5 +75,12 @@
"delete": "删除",
"enterStatusName": "输入状态名称",
"selectCategory": "选择类别",
"close": "关闭"
"close": "关闭",
"clearSort": "清除排序",
"sortAscending": "升序排列",
"sortDescending": "降序排列",
"sortByField": "按{{field}}排序",
"ascendingOrder": "升序",
"descendingOrder": "降序",
"currentSort": "当前排序:{{field}} {{order}}"
}

View File

@@ -12,4 +12,9 @@ ganttApiRouter.get("/project-phases/:id", safeControllerFunction(GanttController
ganttApiRouter.get("/project-workload", safeControllerFunction(GanttController.getWorkload));
// New roadmap Gantt APIs
ganttApiRouter.get("/roadmap-tasks", safeControllerFunction(GanttController.getRoadmapTasks));
ganttApiRouter.get("/project-phases", safeControllerFunction(GanttController.getProjectPhases));
ganttApiRouter.post("/update-task-dates", safeControllerFunction(GanttController.updateTaskDates));
export default ganttApiRouter;

View File

@@ -1,122 +1,125 @@
import express from "express";
import AccessControlsController from "../../controllers/access-controls-controller";
import AuthController from "../../controllers/auth-controller";
import LogsController from "../../controllers/logs-controller";
import OverviewController from "../../controllers/overview-controller";
import TaskPrioritiesController from "../../controllers/task-priorities-controller";
import attachmentsApiRouter from "./attachments-api-router";
import clientsApiRouter from "./clients-api-router";
import jobTitlesApiRouter from "./job-titles-api-router";
import notificationsApiRouter from "./notifications-api-router";
import personalOverviewApiRouter from "./personal-overview-api-router";
import projectMembersApiRouter from "./project-members-api-router";
import projectsApiRouter from "./projects-api-router";
import settingsApiRouter from "./settings-api-router";
import statusesApiRouter from "./statuses-api-router";
import subTasksApiRouter from "./sub-tasks-api-router";
import taskCommentsApiRouter from "./task-comments-api-router";
import taskWorkLogApiRouter from "./task-work-log-api-router";
import tasksApiRouter from "./tasks-api-router";
import teamMembersApiRouter from "./team-members-api-router";
import teamsApiRouter from "./teams-api-router";
import timezonesApiRouter from "./timezones-api-router";
import todoListApiRouter from "./todo-list-api-router";
import projectStatusesApiRouter from "./project-statuses-api-router";
import labelsApiRouter from "./labels-api-router";
import sharedProjectsApiRouter from "./shared-projects-api-router";
import resourceAllocationApiRouter from "./resource-allocation-api-router";
import taskTemplatesApiRouter from "./task-templates-api-router";
import projectInsightsApiRouter from "./project-insights-api-router";
import passwordValidator from "../../middlewares/validators/password-validator";
import adminCenterApiRouter from "./admin-center-api-router";
import reportingApiRouter from "./reporting-api-router";
import activityLogsApiRouter from "./activity-logs-api-router";
import safeControllerFunction from "../../shared/safe-controller-function";
import projectFoldersApiRouter from "./project-folders-api-router";
import taskPhasesApiRouter from "./task-phases-api-router";
import projectCategoriesApiRouter from "./project-categories-api-router";
import homePageApiRouter from "./home-page-api-router";
import ganttApiRouter from "./gantt-api-router";
import projectCommentsApiRouter from "./project-comments-api-router";
import reportingExportApiRouter from "./reporting-export-api-router";
import projectHealthsApiRouter from "./project-healths-api-router";
import ptTasksApiRouter from "./pt-tasks-api-router";
import projectTemplatesApiRouter from "./project-templates-api";
import ptTaskPhasesApiRouter from "./pt_task-phases-api-router";
import ptStatusesApiRouter from "./pt-statuses-api-router";
import workloadApiRouter from "./gannt-apis/workload-api-router";
import roadmapApiRouter from "./gannt-apis/roadmap-api-router";
import scheduleApiRouter from "./gannt-apis/schedule-api-router";
import scheduleApiV2Router from "./gannt-apis/schedule-api-v2-router";
import projectManagerApiRouter from "./project-managers-api-router";
import surveyApiRouter from "./survey-api-router";
import billingApiRouter from "./billing-api-router";
import taskDependenciesApiRouter from "./task-dependencies-api-router";
import taskRecurringApiRouter from "./task-recurring-api-router";
import customColumnsApiRouter from "./custom-columns-api-router";
const api = express.Router();
api.use("/projects", projectsApiRouter);
api.use("/team-members", teamMembersApiRouter);
api.use("/job-titles", jobTitlesApiRouter);
api.use("/clients", clientsApiRouter);
api.use("/teams", teamsApiRouter);
api.use("/tasks", tasksApiRouter);
api.use("/settings", settingsApiRouter);
api.use("/personal-overview", personalOverviewApiRouter);
api.use("/statuses", statusesApiRouter);
api.use("/todo-list", todoListApiRouter);
api.use("/notifications", notificationsApiRouter);
api.use("/attachments", attachmentsApiRouter);
api.use("/sub-tasks", subTasksApiRouter);
api.use("/project-members", projectMembersApiRouter);
api.use("/task-time-log", taskWorkLogApiRouter);
api.use("/task-comments", taskCommentsApiRouter);
api.use("/timezones", timezonesApiRouter);
api.use("/project-statuses", projectStatusesApiRouter);
api.use("/labels", labelsApiRouter);
api.use("/resource-allocation", resourceAllocationApiRouter);
api.use("/shared/projects", sharedProjectsApiRouter);
api.use("/task-templates", taskTemplatesApiRouter);
api.use("/project-insights", projectInsightsApiRouter);
api.use("/admin-center", adminCenterApiRouter);
api.use("/reporting", reportingApiRouter);
api.use("/activity-logs", activityLogsApiRouter);
api.use("/projects-folders", projectFoldersApiRouter);
api.use("/task-phases", taskPhasesApiRouter);
api.use("/project-categories", projectCategoriesApiRouter);
api.use("/home", homePageApiRouter);
api.use("/gantt", ganttApiRouter);
api.use("/project-comments", projectCommentsApiRouter);
api.use("/reporting-export", reportingExportApiRouter);
api.use("/project-healths", projectHealthsApiRouter);
api.use("/project-templates", projectTemplatesApiRouter);
api.use("/pt-tasks", ptTasksApiRouter);
api.use("/pt-task-phases", ptTaskPhasesApiRouter);
api.use("/pt-statuses", ptStatusesApiRouter);
api.use("/workload-gannt", workloadApiRouter);
api.use("/roadmap-gannt", roadmapApiRouter);
api.use("/schedule-gannt", scheduleApiRouter);
api.use("/schedule-gannt-v2", scheduleApiV2Router);
api.use("/project-managers", projectManagerApiRouter);
api.use("/surveys", surveyApiRouter);
api.get("/overview/:id", safeControllerFunction(OverviewController.getById));
api.get("/task-priorities", safeControllerFunction(TaskPrioritiesController.get));
api.post("/change-password", passwordValidator, safeControllerFunction(AuthController.changePassword));
api.get("/access-controls/roles", safeControllerFunction(AccessControlsController.getRoles));
api.get("/logs/my-dashboard", safeControllerFunction(LogsController.getActivityLog));
api.use("/billing", billingApiRouter);
api.use("/task-dependencies", taskDependenciesApiRouter);
api.use("/task-recurring", taskRecurringApiRouter);
api.use("/custom-columns", customColumnsApiRouter);
import express from "express";
import AccessControlsController from "../../controllers/access-controls-controller";
import AuthController from "../../controllers/auth-controller";
import LogsController from "../../controllers/logs-controller";
import OverviewController from "../../controllers/overview-controller";
import TaskPrioritiesController from "../../controllers/task-priorities-controller";
import attachmentsApiRouter from "./attachments-api-router";
import clientsApiRouter from "./clients-api-router";
import jobTitlesApiRouter from "./job-titles-api-router";
import notificationsApiRouter from "./notifications-api-router";
import personalOverviewApiRouter from "./personal-overview-api-router";
import projectMembersApiRouter from "./project-members-api-router";
import projectsApiRouter from "./projects-api-router";
import settingsApiRouter from "./settings-api-router";
import statusesApiRouter from "./statuses-api-router";
import subTasksApiRouter from "./sub-tasks-api-router";
import taskCommentsApiRouter from "./task-comments-api-router";
import taskWorkLogApiRouter from "./task-work-log-api-router";
import tasksApiRouter from "./tasks-api-router";
import teamMembersApiRouter from "./team-members-api-router";
import teamsApiRouter from "./teams-api-router";
import timezonesApiRouter from "./timezones-api-router";
import todoListApiRouter from "./todo-list-api-router";
import projectStatusesApiRouter from "./project-statuses-api-router";
import labelsApiRouter from "./labels-api-router";
import sharedProjectsApiRouter from "./shared-projects-api-router";
import resourceAllocationApiRouter from "./resource-allocation-api-router";
import taskTemplatesApiRouter from "./task-templates-api-router";
import projectInsightsApiRouter from "./project-insights-api-router";
import passwordValidator from "../../middlewares/validators/password-validator";
import adminCenterApiRouter from "./admin-center-api-router";
import reportingApiRouter from "./reporting-api-router";
import activityLogsApiRouter from "./activity-logs-api-router";
import safeControllerFunction from "../../shared/safe-controller-function";
import projectFoldersApiRouter from "./project-folders-api-router";
import taskPhasesApiRouter from "./task-phases-api-router";
import projectCategoriesApiRouter from "./project-categories-api-router";
import homePageApiRouter from "./home-page-api-router";
import ganttApiRouter from "./gantt-api-router";
import projectCommentsApiRouter from "./project-comments-api-router";
import reportingExportApiRouter from "./reporting-export-api-router";
import projectHealthsApiRouter from "./project-healths-api-router";
import ptTasksApiRouter from "./pt-tasks-api-router";
import projectTemplatesApiRouter from "./project-templates-api";
import ptTaskPhasesApiRouter from "./pt_task-phases-api-router";
import ptStatusesApiRouter from "./pt-statuses-api-router";
import workloadApiRouter from "./gannt-apis/workload-api-router";
import roadmapApiRouter from "./gannt-apis/roadmap-api-router";
import scheduleApiRouter from "./gannt-apis/schedule-api-router";
import scheduleApiV2Router from "./gannt-apis/schedule-api-v2-router";
import projectManagerApiRouter from "./project-managers-api-router";
import surveyApiRouter from "./survey-api-router";
import billingApiRouter from "./billing-api-router";
import taskDependenciesApiRouter from "./task-dependencies-api-router";
import taskRecurringApiRouter from "./task-recurring-api-router";
import customColumnsApiRouter from "./custom-columns-api-router";
import userActivityLogsApiRouter from "./user-activity-logs-api-router";
const api = express.Router();
api.use("/projects", projectsApiRouter);
api.use("/team-members", teamMembersApiRouter);
api.use("/job-titles", jobTitlesApiRouter);
api.use("/clients", clientsApiRouter);
api.use("/teams", teamsApiRouter);
api.use("/tasks", tasksApiRouter);
api.use("/settings", settingsApiRouter);
api.use("/personal-overview", personalOverviewApiRouter);
api.use("/statuses", statusesApiRouter);
api.use("/todo-list", todoListApiRouter);
api.use("/notifications", notificationsApiRouter);
api.use("/attachments", attachmentsApiRouter);
api.use("/sub-tasks", subTasksApiRouter);
api.use("/project-members", projectMembersApiRouter);
api.use("/task-time-log", taskWorkLogApiRouter);
api.use("/task-comments", taskCommentsApiRouter);
api.use("/timezones", timezonesApiRouter);
api.use("/project-statuses", projectStatusesApiRouter);
api.use("/labels", labelsApiRouter);
api.use("/resource-allocation", resourceAllocationApiRouter);
api.use("/shared/projects", sharedProjectsApiRouter);
api.use("/task-templates", taskTemplatesApiRouter);
api.use("/project-insights", projectInsightsApiRouter);
api.use("/admin-center", adminCenterApiRouter);
api.use("/reporting", reportingApiRouter);
api.use("/activity-logs", activityLogsApiRouter);
api.use("/projects-folders", projectFoldersApiRouter);
api.use("/task-phases", taskPhasesApiRouter);
api.use("/project-categories", projectCategoriesApiRouter);
api.use("/home", homePageApiRouter);
api.use("/gantt", ganttApiRouter);
api.use("/project-comments", projectCommentsApiRouter);
api.use("/reporting-export", reportingExportApiRouter);
api.use("/project-healths", projectHealthsApiRouter);
api.use("/project-templates", projectTemplatesApiRouter);
api.use("/pt-tasks", ptTasksApiRouter);
api.use("/pt-task-phases", ptTaskPhasesApiRouter);
api.use("/pt-statuses", ptStatusesApiRouter);
api.use("/workload-gannt", workloadApiRouter);
api.use("/roadmap-gannt", roadmapApiRouter);
api.use("/schedule-gannt", scheduleApiRouter);
api.use("/schedule-gannt-v2", scheduleApiV2Router);
api.use("/project-managers", projectManagerApiRouter);
api.use("/surveys", surveyApiRouter);
api.get("/overview/:id", safeControllerFunction(OverviewController.getById));
api.get("/task-priorities", safeControllerFunction(TaskPrioritiesController.get));
api.post("/change-password", passwordValidator, safeControllerFunction(AuthController.changePassword));
api.get("/access-controls/roles", safeControllerFunction(AccessControlsController.getRoles));
api.get("/logs/my-dashboard", safeControllerFunction(LogsController.getActivityLog));
api.use("/billing", billingApiRouter);
api.use("/task-dependencies", taskDependenciesApiRouter);
api.use("/task-recurring", taskRecurringApiRouter);
api.use("/custom-columns", customColumnsApiRouter);
api.use("/logs", userActivityLogsApiRouter);
export default api;

View File

@@ -8,6 +8,9 @@ const surveyApiRouter = express.Router();
// Get account setup survey with questions
surveyApiRouter.get("/account-setup", safeControllerFunction(SurveyController.getAccountSetupSurvey));
// Check if user has completed account setup survey
surveyApiRouter.get("/account-setup/status", safeControllerFunction(SurveyController.checkAccountSetupSurveyStatus));
// Submit survey response
surveyApiRouter.post("/responses", surveySubmissionValidator, safeControllerFunction(SurveyController.submitSurveyResponse));

View File

@@ -0,0 +1,11 @@
import express from 'express';
import UserActivityLogsController from '../../controllers/user-activity-logs-controller';
import safeControllerFunction from "../../shared/safe-controller-function";
const userActivityLogsApiRouter = express.Router();
userActivityLogsApiRouter.get('/user-recent-tasks', safeControllerFunction(UserActivityLogsController.getRecentTasks));
userActivityLogsApiRouter.get('/user-time-logged-tasks', safeControllerFunction(UserActivityLogsController.getTimeLoggedTasks));
export default userActivityLogsApiRouter;

View File

@@ -160,6 +160,9 @@ export const PASSWORD_POLICY = "Minimum of 8 characters, with upper and lowercas
// paddle status to exclude
export const statusExclude = ["past_due", "paused", "deleted"];
// Trial user team member limit
export const TRIAL_MEMBER_LIMIT = 10;
export const HTML_TAG_REGEXP = /<\/?[^>]+>/gi;
export const UNMAPPED = "Unmapped";

View File

@@ -15,4 +15,8 @@ VITE_RECAPTCHA_SITE_KEY=recaptcha-site-key
VITE_WORKLENZ_SESSION_ID=worklenz-session-id
# Google Login
VITE_ENABLE_GOOGLE_LOGIN=false
VITE_ENABLE_GOOGLE_LOGIN=false
# Survey Modal Configuration
# Set to true to enable the survey modal, false to disable it
VITE_ENABLE_SURVEY_MODAL=false

View File

@@ -0,0 +1,22 @@
VITE_API_URL=http://localhost:3000
VITE_SOCKET_URL=ws://localhost:3000
VITE_APP_TITLE=Worklenz
VITE_APP_ENV=development
# Mixpanel
VITE_MIXPANEL_TOKEN=mixpanel-token
# Recaptcha
VITE_ENABLE_RECAPTCHA=false
VITE_RECAPTCHA_SITE_KEY=recaptcha-site-key
# Session ID
VITE_WORKLENZ_SESSION_ID=worklenz-session-id
# Google Login
VITE_ENABLE_GOOGLE_LOGIN=false
# Survey Modal Configuration
# Set to true to enable the survey modal, false to disable it
VITE_ENABLE_SURVEY_MODAL=false

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,11 @@
"build": "vite build",
"dev-build": "vite build",
"serve": "vite preview",
"format": "prettier --write ."
"format": "prettier --write .",
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"test:ui": "vitest --ui"
},
"dependencies": {
"@ant-design/colors": "^7.1.0",
@@ -77,7 +81,10 @@
"@types/react-dom": "19.0.0",
"@types/react-window": "^1.8.8",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "^3.2.4",
"@vitest/ui": "^3.2.4",
"autoprefixer": "^10.4.21",
"jsdom": "^26.1.0",
"postcss": "^8.5.2",
"prettier-plugin-tailwindcss": "^0.6.13",
"rollup": "^4.40.2",

View File

@@ -15,7 +15,7 @@ class HubSpotManager {
* Load HubSpot script with dark mode support
*/
init() {
if (!this.isProduction) return;
// if (!this.isProduction) return;
const loadHubSpot = () => {
const script = document.createElement('script');
@@ -52,6 +52,7 @@ class HubSpotManager {
existingStyle.remove();
}
// Apply dark mode CSS if dark theme is active
if (isDark) {
this.injectDarkModeCSS();
}
@@ -76,40 +77,27 @@ class HubSpotManager {
style.id = this.styleId;
style.textContent = `
/* HubSpot Chat Widget Dark Mode Override */
/*
Note: We can only style the container backgrounds, not the widget UI inside the iframe.
HubSpot does not currently support external dark mode theming for the chat UI itself.
*/
#hubspot-conversations-inline-parent,
#hubspot-conversations-iframe-container,
.shadow-2xl.widget-align-right.widget-align-bottom,
[data-test-id="chat-widget"],
[class*="VizExCollapsedChat"],
[class*="VizExExpandedChat"],
iframe[src*="hubspot"] {
filter: invert(1) hue-rotate(180deg) !important;
background: transparent !important;
#hubspot-conversations-iframe-container {
background: #141414 !important;
}
/* Target HubSpot widget container backgrounds */
#hubspot-conversations-inline-parent div,
#hubspot-conversations-iframe-container div,
[data-test-id="chat-widget"] div {
background-color: transparent !important;
}
/* Prevent double inversion of images, avatars, and icons */
#hubspot-conversations-iframe-container img,
#hubspot-conversations-iframe-container [style*="background-image"],
#hubspot-conversations-iframe-container svg,
iframe[src*="hubspot"] img,
iframe[src*="hubspot"] svg,
[data-test-id="chat-widget"] img,
[data-test-id="chat-widget"] svg {
filter: invert(1) hue-rotate(180deg) !important;
}
/* Additional targeting for widget launcher and chat bubble */
div[class*="shadow-2xl"],
div[class*="widget-align"],
div[style*="position: fixed"] {
filter: invert(1) hue-rotate(180deg) !important;
/* Ensure Worklenz app elements are not affected by HubSpot styles */
.ant-menu,
.ant-menu *,
[class*="settings"],
[class*="sidebar"],
.worklenz-app *:not([id*="hubspot"]):not([class*="widget"]) {
filter: none !important;
}
`;
document.head.appendChild(style);
@@ -134,4 +122,11 @@ document.addEventListener('DOMContentLoaded', () => {
// Make available globally for potential cleanup
window.HubSpotManager = hubspot;
});
});
// Add this style to ensure the chat widget uses the light color scheme
(function() {
var style = document.createElement('style');
style.innerHTML = '#hubspot-messages-iframe-container { color-scheme: light !important; }';
document.head.appendChild(style);
})();

View File

@@ -41,6 +41,7 @@
"step3InputLabel": "Fto me email",
"addAnother": "Shto një tjetër",
"skipForNow": "Kalo për tani",
"skipping": "Duke kaluar...",
"formTitle": "Krijo detyrën tënde të parë.",
"step3Title": "Fto ekipin tënd për të punuar së bashku",
"maxMembers": " (Mund të ftoni deri në 5 anëtarë)",
@@ -80,6 +81,8 @@
"discoveryQuestion": "Si dëgjove për ne?",
"allSetTitle": "Çdo gjë gati!",
"allSetDescription": "Le të krijojmë projektin tënd të parë dhe të fillojmë me Worklenz",
"surveyCompleteTitle": "Faleminderit!",
"surveyCompleteDescription": "Përgjigjet tuaja na ndihmojnë të përmirësojmë Worklenz për të gjithë",
"aboutYouStepName": "Rreth teje",
"yourNeedsStepName": "Nevojat e tua",
"discoveryStepName": "Zbulimi",

View File

@@ -5,5 +5,13 @@
"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"
"connection-restored": "U lidhët me serverin me sukses",
"cancel": "Anulo",
"update-available": "Worklenz u përditesua!",
"update-description": "Një version i ri i Worklenz është i disponueshëm me karakteristikat dhe përmirësimet më të fundit.",
"update-instruction": "Për eksperiencën më të mirë, ju lutemi rifreskoni faqen për të aplikuar ndryshimet e reja.",
"update-whats-new": "💡 <1>Çfarë ka të re:</1> Përmirësim i performancës, rregullime të gabimeve dhe eksperiencön e përmirësuar e përdoruesit",
"update-now": "Përditeso tani",
"update-later": "Më vonë",
"updating": "Duke u përditesuar..."
}

View File

@@ -0,0 +1,36 @@
{
"title": "Phase Details",
"overview": {
"title": "Overview",
"totalTasks": "Total Tasks",
"completion": "Completion",
"progress": "Progress"
},
"timeline": {
"title": "Timeline",
"startDate": "Start Date",
"endDate": "End Date",
"status": "Status",
"notSet": "Not set",
"statusLabels": {
"upcoming": "Upcoming",
"active": "In Progress",
"overdue": "Overdue",
"notScheduled": "Not Scheduled"
}
},
"taskBreakdown": {
"title": "Task Breakdown",
"completed": "Completed",
"pending": "Pending",
"overdue": "Overdue"
},
"phaseColor": {
"title": "Phase Color",
"description": "Phase identifier color"
},
"tasksInPhase": {
"title": "Tasks in this Phase",
"noTasks": "No tasks in this phase"
}
}

View File

@@ -41,6 +41,22 @@
"list": "Listë",
"calendar": "Kalendar",
"tasks": "Detyrat",
"refresh": "Rifresko"
"refresh": "Rifresko",
"recentActivity": "Aktiviteti i Fundit",
"recentTasks": "Detyrat e Fundit",
"recentTasksSegment": "Detyrat e Fundit",
"timeLogged": "Koha e Regjistruar",
"timeLoggedSegment": "Koha e Regjistruar",
"noRecentTasks": "Asnjë detyrë e fundit",
"noTimeLoggedTasks": "Asnjë detyrë me kohë të regjistruar",
"activityTag": "Aktiviteti",
"timeLogTag": "Regjistrim Kohe",
"timerTag": "Kohëmatës",
"activitySingular": "aktivitet",
"activityPlural": "aktivitete",
"recentTaskAriaLabel": "Detyrë e fundit:",
"timeLoggedTaskAriaLabel": "Detyrë me kohë të regjistruar:",
"errorLoadingRecentTasks": "Gabim në ngarkimin e detyrave të fundit",
"errorLoadingTimeLoggedTasks": "Gabim në ngarkimin e detyrave me kohë të regjistruar"
}
}

View File

@@ -0,0 +1,14 @@
{
"modalTitle": "Ndihmoni të përmirësojmë përvojën tuaj",
"skip": "Kalo për tani",
"previous": "Prapa",
"next": "Tjetra",
"completeSurvey": "Përfundo Anketën",
"submitting": "Duke dërguar përgjigjet tuaja...",
"submitSuccessTitle": "Faleminderit!",
"submitSuccessSubtitle": "Feedback-u juaj na ndihmon të përmirësojmë Worklenz për të gjithë.",
"submitSuccessMessage": "Faleminderit që plotësuat anketën!",
"submitErrorMessage": "Dështoi dërgimi i anketës. Ju lutemi provoni përsëri.",
"submitErrorLog": "Dështoi dërgimi i anketës",
"fetchErrorLog": "Dështoi marrja e anketës"
}

View File

@@ -84,5 +84,12 @@
"close": "Mbyll",
"cannotMoveStatus": "Nuk mund të lëvizet statusi",
"cannotMoveStatusMessage": "Nuk mund të lëvizet ky status sepse do të linte kategorinë '{{categoryName}}' bosh. Çdo kategori duhet të ketë të paktën një status.",
"ok": "OK"
"ok": "OK",
"clearSort": "Pastro Renditjen",
"sortAscending": "Rendit në Rritje",
"sortDescending": "Rendit në Zbritje",
"sortByField": "Rendit sipas {{field}}",
"ascendingOrder": "Rritës",
"descendingOrder": "Zbritës",
"currentSort": "Renditja aktuale: {{field}} {{order}}"
}

View File

@@ -57,6 +57,9 @@
"contextMenu": {
"assignToMe": "Cakto mua",
"copyLink": "Kopjo lidhjen e detyrës",
"linkCopied": "Lidhja u kopjua në clipboard",
"linkCopyFailed": "Dështoi kopjimi i lidhjes",
"moveTo": "Zhvendos në",
"unarchive": "Ç'arkivizo",
"archive": "Arkivizo",
@@ -133,5 +136,11 @@
"dependencies": "Detyra ka varësi",
"recurring": "Detyrë përsëritëse"
}
},
"timer": {
"conflictTitle": "Kronómetr Tashë Në Ecuri",
"conflictMessage": "Ju keni një kronómetr në ecuri për \"{{taskName}}\" në projektin \"{{projectName}}\". Dëshironi ta ndaloni atë kronómetr dhe të filloni një të ri për këtë detyrë?",
"stopAndStart": "Ndalo & Fillo Kronómetr të Ri"
}
}

View File

@@ -45,6 +45,7 @@
"step3InputLabel": "Per E-Mail einladen",
"addAnother": "Weitere hinzufügen",
"skipForNow": "Jetzt überspringen",
"skipping": "Überspringen...",
"formTitle": "Erstellen Sie Ihre erste Aufgabe.",
"step3Title": "Laden Sie Ihr Team zur Zusammenarbeit ein",
"maxMembers": " (Sie können bis zu 5 Mitglieder einladen)",
@@ -90,6 +91,8 @@
"discoveryQuestion": "Wie haben Sie von uns erfahren?",
"allSetTitle": "Sie sind bereit!",
"allSetDescription": "Lassen Sie uns Ihr erstes Projekt erstellen und mit Worklenz beginnen",
"surveyCompleteTitle": "Vielen Dank!",
"surveyCompleteDescription": "Ihr Feedback hilft uns, Worklenz für alle zu verbessern",
"aboutYouStepName": "Über Sie",
"yourNeedsStepName": "Ihre Bedürfnisse",
"discoveryStepName": "Entdeckung",

View File

@@ -7,12 +7,12 @@
"emailLabel": "E-Mail",
"emailPlaceholder": "Ihre E-Mail-Adresse eingeben",
"emailRequired": "Bitte geben Sie Ihre E-Mail-Adresse ein!",
"passwordLabel": "Password",
"passwordGuideline": "Password must be at least 8 characters, include uppercase and lowercase letters, a number, and a special character.",
"passwordPlaceholder": "Enter your password",
"passwordLabel": "Passwort",
"passwordGuideline": "Das Passwort muss mindestens 8 Zeichen lang sein und Groß- und Kleinbuchstaben, eine Zahl und ein Sonderzeichen enthalten.",
"passwordPlaceholder": "Geben Sie Ihr Passwort ein",
"passwordRequired": "Bitte geben Sie Ihr Passwort ein!",
"passwordMinCharacterRequired": "Das Passwort muss mindestens 8 Zeichen lang sein!",
"passwordMaxCharacterRequired": "Password must be at most 32 characters!",
"passwordMaxCharacterRequired": "Das Passwort darf maximal 32 Zeichen lang sein!",
"passwordPatternRequired": "Das Passwort entspricht nicht den Anforderungen!",
"strongPasswordPlaceholder": "Ein stärkeres Passwort eingeben",
"passwordValidationAltText": "Das Passwort muss mindestens 8 Zeichen enthalten, mit Groß- und Kleinbuchstaben, einer Zahl und einem Sonderzeichen.",

View File

@@ -5,5 +5,13 @@
"signup-failed": "Registrierung fehlgeschlagen. Bitte füllen Sie alle erforderlichen Felder aus und versuchen Sie es erneut.",
"reconnecting": "Vom Server getrennt.",
"connection-lost": "Verbindung zum Server fehlgeschlagen. Bitte überprüfen Sie Ihre Internetverbindung.",
"connection-restored": "Erfolgreich mit dem Server verbunden"
"connection-restored": "Erfolgreich mit dem Server verbunden",
"cancel": "Abbrechen",
"update-available": "Worklenz aktualisiert!",
"update-description": "Eine neue Version von Worklenz ist verfügbar mit den neuesten Funktionen und Verbesserungen.",
"update-instruction": "Für die beste Erfahrung laden Sie bitte die Seite neu, um die neuen Änderungen zu übernehmen.",
"update-whats-new": "💡 <1>Was ist neu:</1> Verbesserte Leistung, Fehlerbehebungen und verbesserte Benutzererfahrung",
"update-now": "Jetzt aktualisieren",
"update-later": "Später",
"updating": "Wird aktualisiert..."
}

View File

@@ -0,0 +1,36 @@
{
"title": "Phase Details",
"overview": {
"title": "Overview",
"totalTasks": "Total Tasks",
"completion": "Completion",
"progress": "Progress"
},
"timeline": {
"title": "Timeline",
"startDate": "Start Date",
"endDate": "End Date",
"status": "Status",
"notSet": "Not set",
"statusLabels": {
"upcoming": "Upcoming",
"active": "In Progress",
"overdue": "Overdue",
"notScheduled": "Not Scheduled"
}
},
"taskBreakdown": {
"title": "Task Breakdown",
"completed": "Completed",
"pending": "Pending",
"overdue": "Overdue"
},
"phaseColor": {
"title": "Phase Color",
"description": "Phase identifier color"
},
"tasksInPhase": {
"title": "Tasks in this Phase",
"noTasks": "No tasks in this phase"
}
}

View File

@@ -41,6 +41,22 @@
"list": "Liste",
"calendar": "Kalender",
"tasks": "Aufgaben",
"refresh": "Aktualisieren"
"refresh": "Aktualisieren",
"recentActivity": "Aktuelle Aktivitäten",
"recentTasks": "Aktuelle Aufgaben",
"recentTasksSegment": "Aktuelle Aufgaben",
"timeLogged": "Erfasste Zeit",
"timeLoggedSegment": "Erfasste Zeit",
"noRecentTasks": "Keine aktuellen Aufgaben",
"noTimeLoggedTasks": "Keine Aufgaben mit erfasster Zeit",
"activityTag": "Aktivität",
"timeLogTag": "Zeiterfassung",
"timerTag": "Timer",
"activitySingular": "Aktivität",
"activityPlural": "Aktivitäten",
"recentTaskAriaLabel": "Aktuelle Aufgabe:",
"timeLoggedTaskAriaLabel": "Aufgabe mit erfasster Zeit:",
"errorLoadingRecentTasks": "Fehler beim Laden aktueller Aufgaben",
"errorLoadingTimeLoggedTasks": "Fehler beim Laden der Zeiterfassung"
}
}

View File

@@ -0,0 +1,14 @@
{
"modalTitle": "Helfen Sie uns, Ihre Erfahrung zu verbessern",
"skip": "Für jetzt überspringen",
"previous": "Zurück",
"next": "Weiter",
"completeSurvey": "Umfrage abschließen",
"submitting": "Ihre Antworten werden übermittelt...",
"submitSuccessTitle": "Danke!",
"submitSuccessSubtitle": "Ihr Feedback hilft uns, Worklenz für alle zu verbessern.",
"submitSuccessMessage": "Danke, dass Sie die Umfrage abgeschlossen haben!",
"submitErrorMessage": "Umfrage konnte nicht übermittelt werden. Bitte versuchen Sie es erneut.",
"submitErrorLog": "Umfrageübermittlung fehlgeschlagen",
"fetchErrorLog": "Umfrageabruf fehlgeschlagen"
}

View File

@@ -84,5 +84,12 @@
"close": "Schließen",
"cannotMoveStatus": "Status kann nicht verschoben werden",
"cannotMoveStatusMessage": "Dieser Status kann nicht verschoben werden, da die Kategorie '{{categoryName}}' leer bleiben würde. Jede Kategorie muss mindestens einen Status haben.",
"ok": "OK"
"ok": "OK",
"clearSort": "Sortierung löschen",
"sortAscending": "Aufsteigend sortieren",
"sortDescending": "Absteigend sortieren",
"sortByField": "Sortieren nach {{field}}",
"ascendingOrder": "Aufsteigend",
"descendingOrder": "Absteigend",
"currentSort": "Aktuelle Sortierung: {{field}} {{order}}"
}

View File

@@ -57,6 +57,9 @@
"contextMenu": {
"assignToMe": "Mir zuweisen",
"copyLink": "Link zur Aufgabe kopieren",
"linkCopied": "Link in die Zwischenablage kopiert",
"linkCopyFailed": "Fehler beim Kopieren des Links",
"moveTo": "Verschieben nach",
"unarchive": "Dearchivieren",
"archive": "Archivieren",
@@ -133,5 +136,11 @@
"dependencies": "Aufgabe hat Abhängigkeiten",
"recurring": "Wiederkehrende Aufgabe"
}
},
"timer": {
"conflictTitle": "Timer läuft bereits",
"conflictMessage": "Sie haben einen Timer für \"{{taskName}}\" im Projekt \"{{projectName}}\" laufen. Möchten Sie diesen Timer stoppen und einen neuen für diese Aufgabe starten?",
"stopAndStart": "Stoppen & Neuen Timer starten"
}
}

View File

@@ -44,6 +44,7 @@
"step3InputLabel": "Invite with email",
"addAnother": "Add another",
"skipForNow": "Skip for now",
"skipping": "Skipping...",
"formTitle": "Create your first task.",
"step3Title": "Invite your team to work with",
"maxMembers": " (You can invite up to 5 members)",
@@ -88,6 +89,8 @@
"discoveryQuestion": "How did you hear about us?",
"allSetTitle": "You're all set!",
"allSetDescription": "Let's create your first project and get started with Worklenz",
"surveyCompleteTitle": "Thank you!",
"surveyCompleteDescription": "Your feedback helps us improve Worklenz for everyone",
"aboutYouStepName": "About You",
"yourNeedsStepName": "Your Needs",
"discoveryStepName": "Discovery",

View File

@@ -5,5 +5,13 @@
"signup-failed": "Signup failed. Please ensure all required fields are filled and try again.",
"reconnecting": "Disconnected from server.",
"connection-lost": "Failed to connect to server. Please check your internet connection.",
"connection-restored": "Connected to server successfully"
"connection-restored": "Connected to server successfully",
"cancel": "Cancel",
"update-available": "Worklenz Updated!",
"update-description": "A new version of Worklenz is available with the latest features and improvements.",
"update-instruction": "To get the best experience, please reload the page to apply the new changes.",
"update-whats-new": "💡 <1>What's new:</1> Enhanced performance, bug fixes, and improved user experience",
"update-now": "Update Now",
"update-later": "Later",
"updating": "Updating..."
}

View File

@@ -0,0 +1,42 @@
{
"title": "Phase Details",
"overview": {
"title": "Overview",
"totalTasks": "Total Tasks",
"completion": "Completion",
"progress": "Progress"
},
"timeline": {
"title": "Timeline",
"startDate": "Start Date",
"endDate": "End Date",
"status": "Status",
"notSet": "Not set",
"statusLabels": {
"upcoming": "Upcoming",
"active": "In Progress",
"overdue": "Overdue",
"notScheduled": "Not Scheduled"
}
},
"taskBreakdown": {
"title": "Task Breakdown",
"completed": "Completed",
"pending": "Pending",
"overdue": "Overdue"
},
"phaseColor": {
"title": "Phase Color",
"description": "Phase identifier color"
},
"tasksInPhase": {
"title": "Tasks in this Phase",
"noTasks": "No tasks in this phase",
"priority": "Priority",
"assignees": "Assignees",
"dueDate": "Due Date",
"startDate": "Start Date",
"noAssignees": "Unassigned",
"noDueDate": "No due date"
}
}

View File

@@ -41,6 +41,22 @@
"list": "List",
"calendar": "Calendar",
"tasks": "Tasks",
"refresh": "Refresh"
"refresh": "Refresh",
"recentActivity": "Recent Activity",
"recentTasks": "Recent Tasks",
"recentTasksSegment": "Recent Tasks",
"timeLogged": "Time Logged",
"timeLoggedSegment": "Time Logged",
"noRecentTasks": "No recent tasks",
"noTimeLoggedTasks": "No time logged tasks",
"activityTag": "Activity",
"timeLogTag": "Time Log",
"timerTag": "Timer",
"activitySingular": "activity",
"activityPlural": "activities",
"recentTaskAriaLabel": "Recent task:",
"timeLoggedTaskAriaLabel": "Time logged task:",
"errorLoadingRecentTasks": "Error loading recent tasks",
"errorLoadingTimeLoggedTasks": "Error loading time logged tasks"
}
}

View File

@@ -0,0 +1,14 @@
{
"modalTitle": "Help Us Improve Your Experience",
"skip": "Skip for now",
"previous": "Previous",
"next": "Next",
"completeSurvey": "Complete Survey",
"submitting": "Submitting your responses...",
"submitSuccessTitle": "Thank you!",
"submitSuccessSubtitle": "Your feedback helps us improve Worklenz for everyone.",
"submitSuccessMessage": "Thank you for completing the survey!",
"submitErrorMessage": "Failed to submit survey. Please try again.",
"submitErrorLog": "Failed to submit survey",
"fetchErrorLog": "Failed to fetch survey"
}

View File

@@ -84,5 +84,12 @@
"close": "Close",
"cannotMoveStatus": "Cannot Move Status",
"cannotMoveStatusMessage": "Cannot move this status because it would leave the '{{categoryName}}' category empty. Each category must have at least one status.",
"ok": "OK"
"ok": "OK",
"clearSort": "Clear Sort",
"sortAscending": "Sort Ascending",
"sortDescending": "Sort Descending",
"sortByField": "Sort by {{field}}",
"ascendingOrder": "Ascending",
"descendingOrder": "Descending",
"currentSort": "Current sort: {{field}} {{order}}"
}

View File

@@ -57,6 +57,9 @@
"contextMenu": {
"assignToMe": "Assign to me",
"copyLink": "Copy link to task",
"linkCopied": "Link copied to clipboard",
"linkCopyFailed": "Failed to copy link",
"moveTo": "Move to",
"unarchive": "Unarchive",
"archive": "Archive",
@@ -133,5 +136,11 @@
"dependencies": "Task has dependencies",
"recurring": "Recurring task"
}
},
"timer": {
"conflictTitle": "Timer Already Running",
"conflictMessage": "You have a timer running for \"{{taskName}}\" in project \"{{projectName}}\". Would you like to stop that timer and start a new one for this task?",
"stopAndStart": "Stop & Start New Timer"
}
}

View File

@@ -45,6 +45,7 @@
"step3InputLabel": "Invitar por correo electrónico",
"addAnother": "Agregar otro",
"skipForNow": "Omitir por ahora",
"skipping": "Omitiendo...",
"formTitle": "Crea tu primera tarea.",
"step3Title": "Invita a tu equipo a trabajar",
@@ -91,6 +92,8 @@
"discoveryQuestion": "¿Cómo te enteraste de nosotros?",
"allSetTitle": "¡Ya estás listo!",
"allSetDescription": "Vamos a crear tu primer proyecto y comenzar con Worklenz",
"surveyCompleteTitle": "¡Gracias!",
"surveyCompleteDescription": "Tu retroalimentación nos ayuda a mejorar Worklenz para todos",
"aboutYouStepName": "Sobre ti",
"yourNeedsStepName": "Tus necesidades",
"discoveryStepName": "Descubrimiento",

View File

@@ -7,12 +7,12 @@
"emailLabel": "Correo electrónico",
"emailPlaceholder": "Ingresa tu correo electrónico",
"emailRequired": "¡Por favor ingresa tu correo electrónico!",
"passwordLabel": "Password",
"passwordGuideline": "Password must be at least 8 characters, include uppercase and lowercase letters, a number, and a special character.",
"passwordPlaceholder": "Enter your password",
"passwordLabel": "Contraseña",
"passwordGuideline": "La contraseña debe tener al menos 8 caracteres, incluir letras mayúsculas y minúsculas, un número y un carácter especial.",
"passwordPlaceholder": "Ingresa tu contraseña",
"passwordRequired": "¡Por favor ingresa tu contraseña!",
"passwordMinCharacterRequired": "¡La contraseña debe tener al menos 8 caracteres!",
"passwordMaxCharacterRequired": "Password must be at most 32 characters!",
"passwordMaxCharacterRequired": "¡La contraseña debe tener como máximo 32 caracteres!",
"passwordPatternRequired": "¡La contraseña no cumple con los requisitos!",
"strongPasswordPlaceholder": "Ingresa una contraseña más segura",
"passwordValidationAltText": "La contraseña debe incluir al menos 8 caracteres con letras mayúsculas y minúsculas, un número y un símbolo.",

View File

@@ -5,5 +5,13 @@
"signup-failed": "Error al registrarse. Por favor asegúrate de llenar todos los campos requeridos e intenta nuevamente.",
"reconnecting": "Reconectando al servidor...",
"connection-lost": "Conexión perdida. Intentando reconectarse...",
"connection-restored": "Conexión restaurada. Reconectando al servidor..."
"connection-restored": "Conexión restaurada. Reconectando al servidor...",
"cancel": "Cancelar",
"update-available": "¡Worklenz actualizado!",
"update-description": "Una nueva versión de Worklenz está disponible con las últimas funciones y mejoras.",
"update-instruction": "Para obtener la mejor experiencia, por favor recarga la página para aplicar los nuevos cambios.",
"update-whats-new": "💡 <1>Qué hay de nuevo:</1> Rendimiento mejorado, correcciones de errores y experiencia de usuario mejorada",
"update-now": "Actualizar ahora",
"update-later": "Más tarde",
"updating": "Actualizando..."
}

View File

@@ -0,0 +1,36 @@
{
"title": "Phase Details",
"overview": {
"title": "Overview",
"totalTasks": "Total Tasks",
"completion": "Completion",
"progress": "Progress"
},
"timeline": {
"title": "Timeline",
"startDate": "Start Date",
"endDate": "End Date",
"status": "Status",
"notSet": "Not set",
"statusLabels": {
"upcoming": "Upcoming",
"active": "In Progress",
"overdue": "Overdue",
"notScheduled": "Not Scheduled"
}
},
"taskBreakdown": {
"title": "Task Breakdown",
"completed": "Completed",
"pending": "Pending",
"overdue": "Overdue"
},
"phaseColor": {
"title": "Phase Color",
"description": "Phase identifier color"
},
"tasksInPhase": {
"title": "Tasks in this Phase",
"noTasks": "No tasks in this phase"
}
}

View File

@@ -40,6 +40,22 @@
"list": "Lista",
"calendar": "Calendario",
"tasks": "Tareas",
"refresh": "Actualizar"
"refresh": "Actualizar",
"recentActivity": "Actividad Reciente",
"recentTasks": "Tareas Recientes",
"recentTasksSegment": "Tareas Recientes",
"timeLogged": "Tiempo Registrado",
"timeLoggedSegment": "Tiempo Registrado",
"noRecentTasks": "No hay tareas recientes",
"noTimeLoggedTasks": "No hay tareas con tiempo registrado",
"activityTag": "Actividad",
"timeLogTag": "Registro de Tiempo",
"timerTag": "Temporizador",
"activitySingular": "actividad",
"activityPlural": "actividades",
"recentTaskAriaLabel": "Tarea reciente:",
"timeLoggedTaskAriaLabel": "Tarea con tiempo registrado:",
"errorLoadingRecentTasks": "Error al cargar tareas recientes",
"errorLoadingTimeLoggedTasks": "Error al cargar tareas con tiempo registrado"
}
}

View File

@@ -0,0 +1,14 @@
{
"modalTitle": "Ayúdanos a mejorar tu experiencia",
"skip": "Omitir por ahora",
"previous": "Anterior",
"next": "Siguiente",
"completeSurvey": "Completar encuesta",
"submitting": "Enviando tus respuestas...",
"submitSuccessTitle": "¡Gracias!",
"submitSuccessSubtitle": "Tus comentarios nos ayudan a mejorar Worklenz para todos.",
"submitSuccessMessage": "¡Gracias por completar la encuesta!",
"submitErrorMessage": "No se pudo enviar la encuesta. Por favor, inténtalo de nuevo.",
"submitErrorLog": "Error al enviar la encuesta",
"fetchErrorLog": "Error al obtener la encuesta"
}

View File

@@ -84,5 +84,12 @@
"close": "Cerrar",
"cannotMoveStatus": "No se puede mover el estado",
"cannotMoveStatusMessage": "No se puede mover este estado porque dejaría vacía la categoría '{{categoryName}}'. Cada categoría debe tener al menos un estado.",
"ok": "OK"
"ok": "OK",
"clearSort": "Limpiar Ordenamiento",
"sortAscending": "Ordenar Ascendente",
"sortDescending": "Ordenar Descendente",
"sortByField": "Ordenar por {{field}}",
"ascendingOrder": "Ascendente",
"descendingOrder": "Descendente",
"currentSort": "Ordenamiento actual: {{field}} {{order}}"
}

View File

@@ -57,6 +57,9 @@
"contextMenu": {
"assignToMe": "Asignar a mí",
"copyLink": "Copiar enlace a la tarea",
"linkCopied": "Enlace copiado al portapapeles",
"linkCopyFailed": "Error al copiar el enlace",
"moveTo": "Mover a",
"unarchive": "Desarchivar",
"archive": "Archivar",
@@ -133,5 +136,11 @@
"dependencies": "La tarea tiene dependencias",
"recurring": "Tarea recurrente"
}
},
"timer": {
"conflictTitle": "Temporizador Ya En Ejecución",
"conflictMessage": "Tiene un temporizador ejecutándose para \"{{taskName}}\" en el proyecto \"{{projectName}}\". ¿Le gustaría detener ese temporizador e iniciar uno nuevo para esta tarea?",
"stopAndStart": "Detener e Iniciar Nuevo Temporizador"
}
}

View File

@@ -45,6 +45,7 @@
"step3InputLabel": "Convidar por email",
"addAnother": "Adicionar outro",
"skipForNow": "Pular por enquanto",
"skipping": "Pulando...",
"formTitle": "Crie sua primeira tarefa.",
"step3Title": "Convide sua equipe para trabalhar",
@@ -91,6 +92,8 @@
"discoveryQuestion": "Como você soube sobre nós?",
"allSetTitle": "Você está pronto!",
"allSetDescription": "Vamos criar seu primeiro projeto e começar com o Worklenz",
"surveyCompleteTitle": "Obrigado!",
"surveyCompleteDescription": "Seu feedback nos ajuda a melhorar o Worklenz para todos",
"aboutYouStepName": "Sobre você",
"yourNeedsStepName": "Suas necessidades",
"discoveryStepName": "Descoberta",

View File

@@ -5,5 +5,13 @@
"signup-failed": "Falha no cadastro. Por favor, certifique-se de que todos os campos obrigatórios estão preenchidos e tente novamente.",
"reconnecting": "Reconectando ao servidor...",
"connection-lost": "Conexão perdida. Tentando reconectar...",
"connection-restored": "Conexão restaurada. Reconectando ao servidor..."
"connection-restored": "Conexão restaurada. Reconectando ao servidor...",
"cancel": "Cancelar",
"update-available": "Worklenz atualizado!",
"update-description": "Uma nova versão do Worklenz está disponível com os recursos e melhorias mais recentes.",
"update-instruction": "Para obter a melhor experiência, por favor recarregue a página para aplicar as novas mudanças.",
"update-whats-new": "💡 <1>O que há de novo:</1> Performance aprimorada, correções de bugs e experiência do usuário melhorada",
"update-now": "Atualizar agora",
"update-later": "Mais tarde",
"updating": "Atualizando..."
}

View File

@@ -0,0 +1,36 @@
{
"title": "Phase Details",
"overview": {
"title": "Overview",
"totalTasks": "Total Tasks",
"completion": "Completion",
"progress": "Progress"
},
"timeline": {
"title": "Timeline",
"startDate": "Start Date",
"endDate": "End Date",
"status": "Status",
"notSet": "Not set",
"statusLabels": {
"upcoming": "Upcoming",
"active": "In Progress",
"overdue": "Overdue",
"notScheduled": "Not Scheduled"
}
},
"taskBreakdown": {
"title": "Task Breakdown",
"completed": "Completed",
"pending": "Pending",
"overdue": "Overdue"
},
"phaseColor": {
"title": "Phase Color",
"description": "Phase identifier color"
},
"tasksInPhase": {
"title": "Tasks in this Phase",
"noTasks": "No tasks in this phase"
}
}

View File

@@ -40,6 +40,22 @@
"list": "Lista",
"calendar": "Calendário",
"tasks": "Tarefas",
"refresh": "Atualizar"
"refresh": "Atualizar",
"recentActivity": "Atividade Recente",
"recentTasks": "Tarefas Recentes",
"recentTasksSegment": "Tarefas Recentes",
"timeLogged": "Tempo Registrado",
"timeLoggedSegment": "Tempo Registrado",
"noRecentTasks": "Nenhuma tarefa recente",
"noTimeLoggedTasks": "Nenhuma tarefa com tempo registrado",
"activityTag": "Atividade",
"timeLogTag": "Registro de Tempo",
"timerTag": "Cronômetro",
"activitySingular": "atividade",
"activityPlural": "atividades",
"recentTaskAriaLabel": "Tarefa recente:",
"timeLoggedTaskAriaLabel": "Tarefa com tempo registrado:",
"errorLoadingRecentTasks": "Erro ao carregar tarefas recentes",
"errorLoadingTimeLoggedTasks": "Erro ao carregar tarefas com tempo registrado"
}
}

View File

@@ -0,0 +1,14 @@
{
"modalTitle": "Ajude-nos a melhorar sua experiência",
"skip": "Pular por enquanto",
"previous": "Anterior",
"next": "Próximo",
"completeSurvey": "Concluir Pesquisa",
"submitting": "Enviando suas respostas...",
"submitSuccessTitle": "Obrigado!",
"submitSuccessSubtitle": "Seu feedback nos ajuda a melhorar o Worklenz para todos.",
"submitSuccessMessage": "Obrigado por completar a pesquisa!",
"submitErrorMessage": "Falha ao enviar a pesquisa. Por favor, tente novamente.",
"submitErrorLog": "Falha ao enviar a pesquisa",
"fetchErrorLog": "Falha ao buscar a pesquisa"
}

View File

@@ -84,5 +84,12 @@
"close": "Fechar",
"cannotMoveStatus": "Não é possível mover o status",
"cannotMoveStatusMessage": "Não é possível mover este status porque deixaria a categoria '{{categoryName}}' vazia. Cada categoria deve ter pelo menos um status.",
"ok": "OK"
"ok": "OK",
"clearSort": "Limpar Ordenação",
"sortAscending": "Ordenar Crescente",
"sortDescending": "Ordenar Decrescente",
"sortByField": "Ordenar por {{field}}",
"ascendingOrder": "Crescente",
"descendingOrder": "Decrescente",
"currentSort": "Ordenação atual: {{field}} {{order}}"
}

View File

@@ -57,6 +57,9 @@
"contextMenu": {
"assignToMe": "Atribuir a mim",
"copyLink": "Copiar link da tarefa",
"linkCopied": "Link copiado para a área de transferência",
"linkCopyFailed": "Falha ao copiar o link",
"moveTo": "Mover para",
"unarchive": "Desarquivar",
"archive": "Arquivar",
@@ -133,5 +136,11 @@
"dependencies": "A tarefa tem dependências",
"recurring": "Tarefa recorrente"
}
},
"timer": {
"conflictTitle": "Temporizador Já Em Execução",
"conflictMessage": "Você tem um temporizador executando para \"{{taskName}}\" no projeto \"{{projectName}}\". Gostaria de parar esse temporizador e iniciar um novo para esta tarefa?",
"stopAndStart": "Parar e Iniciar Novo Temporizador"
}
}

View File

@@ -44,6 +44,7 @@
"step3InputLabel": "通过电子邮件邀请",
"addAnother": "添加另一个",
"skipForNow": "暂时跳过",
"skipping": "跳过中...",
"formTitle": "创建您的第一个任务。",
"step3Title": "邀请您的团队一起工作",
"maxMembers": "(您最多可以邀请 5 名成员)",
@@ -89,6 +90,8 @@
"discoveryQuestion": "您是如何听说我们的?",
"allSetTitle": "一切就绪!",
"allSetDescription": "让我们创建您的第一个项目并开始使用 Worklenz 吧",
"surveyCompleteTitle": "谢谢!",
"surveyCompleteDescription": "您的反馈有助于我们为所有人改进 Worklenz",
"aboutYouStepName": "关于您",
"yourNeedsStepName": "您的需求",
"discoveryStepName": "发现",

View File

@@ -5,5 +5,13 @@
"signup-failed": "注册失败。请确保填写所有必填字段并重试。",
"reconnecting": "与服务器断开连接。",
"connection-lost": "无法连接到服务器。请检查您的互联网连接。",
"connection-restored": "成功连接到服务器"
"connection-restored": "成功连接到服务器",
"cancel": "取消",
"update-available": "Worklenz 已更新!",
"update-description": "Worklenz 的新版本已可用,具有最新的功能和改进。",
"update-instruction": "为了获得最佳体验,请刷新页面以应用新更改。",
"update-whats-new": "💡 <1>新增内容:</1>性能增强、错误修复和用户体验改善",
"update-now": "立即更新",
"update-later": "稍后",
"updating": "正在更新..."
}

View File

@@ -0,0 +1,36 @@
{
"title": "Phase Details",
"overview": {
"title": "Overview",
"totalTasks": "Total Tasks",
"completion": "Completion",
"progress": "Progress"
},
"timeline": {
"title": "Timeline",
"startDate": "Start Date",
"endDate": "End Date",
"status": "Status",
"notSet": "Not set",
"statusLabels": {
"upcoming": "Upcoming",
"active": "In Progress",
"overdue": "Overdue",
"notScheduled": "Not Scheduled"
}
},
"taskBreakdown": {
"title": "Task Breakdown",
"completed": "Completed",
"pending": "Pending",
"overdue": "Overdue"
},
"phaseColor": {
"title": "Phase Color",
"description": "Phase identifier color"
},
"tasksInPhase": {
"title": "Tasks in this Phase",
"noTasks": "No tasks in this phase"
}
}

View File

@@ -41,6 +41,22 @@
"list": "列表",
"calendar": "日历",
"tasks": "任务",
"refresh": "刷新"
"refresh": "刷新",
"recentActivity": "最近活动",
"recentTasks": "最近任务",
"recentTasksSegment": "最近任务",
"timeLogged": "时间记录",
"timeLoggedSegment": "时间记录",
"noRecentTasks": "没有最近任务",
"noTimeLoggedTasks": "没有时间记录任务",
"activityTag": "活动",
"timeLogTag": "时间记录",
"timerTag": "计时器",
"activitySingular": "活动",
"activityPlural": "活动",
"recentTaskAriaLabel": "最近任务:",
"timeLoggedTaskAriaLabel": "时间记录任务:",
"errorLoadingRecentTasks": "加载最近任务时出错",
"errorLoadingTimeLoggedTasks": "加载时间记录任务时出错"
}
}

View File

@@ -0,0 +1,14 @@
{
"modalTitle": "帮助我们提升您的体验",
"skip": "暂时跳过",
"previous": "上一步",
"next": "下一步",
"completeSurvey": "完成调查",
"submitting": "正在提交您的回答...",
"submitSuccessTitle": "谢谢!",
"submitSuccessSubtitle": "您的反馈帮助我们改进 Worklenz。",
"submitSuccessMessage": "感谢您完成调查!",
"submitErrorMessage": "提交调查失败。请重试。",
"submitErrorLog": "提交调查失败",
"fetchErrorLog": "获取调查失败"
}

View File

@@ -79,5 +79,12 @@
"close": "关闭",
"cannotMoveStatus": "无法移动状态",
"cannotMoveStatusMessage": "无法移动此状态,因为这会使\"{{categoryName}}\"类别为空。每个类别必须至少有一个状态。",
"ok": "确定"
"ok": "确定",
"clearSort": "清除排序",
"sortAscending": "升序排列",
"sortDescending": "降序排列",
"sortByField": "按{{field}}排序",
"ascendingOrder": "升序",
"descendingOrder": "降序",
"currentSort": "当前排序:{{field}} {{order}}"
}

View File

@@ -50,6 +50,9 @@
"pendingInvitation": "待处理邀请",
"contextMenu": {
"assignToMe": "分配给我",
"copyLink": "复制任务链接",
"linkCopied": "链接已复制到剪贴板",
"linkCopyFailed": "复制链接失败",
"moveTo": "移动到",
"unarchive": "取消归档",
"archive": "归档",
@@ -126,5 +129,11 @@
"dependencies": "任务有依赖项",
"recurring": "重复任务"
}
},
"timer": {
"conflictTitle": "计时器已在运行",
"conflictMessage": "您在项目\"{{projectName}}\"中的\"{{taskName}}\"任务正在运行计时器。您是否要停止该计时器并为此任务启动新的计时器?",
"stopAndStart": "停止并启动新计时器"
}
}

View File

@@ -325,6 +325,12 @@ self.addEventListener('message', event => {
event.ports[0].postMessage({ version: CACHE_VERSION });
break;
case 'CHECK_FOR_UPDATES':
checkForUpdates().then((hasUpdates) => {
event.ports[0].postMessage({ hasUpdates });
});
break;
case 'CLEAR_CACHE':
clearAllCaches().then(() => {
event.ports[0].postMessage({ success: true });
@@ -349,6 +355,44 @@ async function clearAllCaches() {
console.log('Service Worker: All caches cleared');
}
async function checkForUpdates() {
try {
// Check if there's a new service worker available
const registration = await self.registration.update();
const hasNewWorker = registration.installing || registration.waiting;
if (hasNewWorker) {
console.log('Service Worker: New version detected');
return true;
}
// Also check if the main app files have been updated by trying to fetch index.html
// and comparing it with the cached version
try {
const cache = await caches.open(CACHE_NAMES.STATIC);
const cachedResponse = await cache.match('/');
const networkResponse = await fetch('/', { cache: 'no-cache' });
if (cachedResponse && networkResponse.ok) {
const cachedContent = await cachedResponse.text();
const networkContent = await networkResponse.text();
if (cachedContent !== networkContent) {
console.log('Service Worker: App content has changed');
return true;
}
}
} catch (error) {
console.log('Service Worker: Could not check for content updates', error);
}
return false;
} catch (error) {
console.error('Service Worker: Error checking for updates', error);
return false;
}
}
async function handleLogout() {
try {
// Clear all caches

View File

@@ -1,55 +0,0 @@
# SonarQube Configuration for Worklenz Frontend
sonar.projectKey=worklenz-frontend
sonar.projectName=Worklenz Frontend
sonar.projectVersion=1.0.0
# Source code configuration
sonar.sources=src
sonar.tests=src
sonar.test.inclusions=**/*.test.ts,**/*.test.tsx,**/*.spec.ts,**/*.spec.tsx
# Language-specific configurations
sonar.typescript.node=node
sonar.typescript.lcov.reportPaths=coverage/lcov.info
sonar.javascript.lcov.reportPaths=coverage/lcov.info
# Exclusions
sonar.exclusions=**/node_modules/**,\
**/build/**,\
**/dist/**,\
**/public/**,\
**/*.d.ts,\
src/react-app-env.d.ts,\
src/vite-env.d.ts,\
**/*.config.js,\
**/*.config.ts,\
**/*.config.mts,\
scripts/**
# Test exclusions from coverage
sonar.coverage.exclusions=**/*.test.ts,\
**/*.test.tsx,\
**/*.spec.ts,\
**/*.spec.tsx,\
**/*.config.*,\
src/index.tsx,\
src/reportWebVitals.ts,\
src/serviceWorkerRegistration.ts,\
src/setupTests.ts
# Code quality rules
sonar.qualitygate.wait=true
# File encoding
sonar.sourceEncoding=UTF-8
# JavaScript/TypeScript specific settings
sonar.javascript.environments=browser,node,jest
sonar.typescript.tsconfigPath=tsconfig.json
# ESLint configuration (if available)
# sonar.eslint.reportPaths=eslint-report.json
# Additional settings for React projects
sonar.javascript.file.suffixes=.js,.jsx
sonar.typescript.file.suffixes=.ts,.tsx

View File

@@ -6,6 +6,7 @@ import i18next from 'i18next';
// Components
import ThemeWrapper from './features/theme/ThemeWrapper';
import ModuleErrorBoundary from './components/ModuleErrorBoundary';
import { UpdateNotificationProvider } from './components/update-notification';
// Routes
import router from './app/routes';
@@ -202,14 +203,16 @@ const App: React.FC = memo(() => {
return (
<Suspense fallback={<SuspenseFallback />}>
<ThemeWrapper>
<ModuleErrorBoundary>
<RouterProvider
router={router}
future={{
v7_startTransition: true,
}}
/>
</ModuleErrorBoundary>
<UpdateNotificationProvider>
<ModuleErrorBoundary>
<RouterProvider
router={router}
future={{
v7_startTransition: true,
}}
/>
</ModuleErrorBoundary>
</UpdateNotificationProvider>
</ThemeWrapper>
</Suspense>
);

View File

@@ -0,0 +1,46 @@
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
import { API_BASE_URL } from '@/shared/constants';
import { getCsrfToken } from '../api-client';
import { IUserRecentTask, IUserTimeLoggedTask } from '@/types/home/user-activity.types';
import config from '@/config/env';
const rootUrl = '/logs';
export const userActivityApiService = createApi({
reducerPath: 'userActivityApi',
baseQuery: fetchBaseQuery({
baseUrl: `${config.apiUrl}${API_BASE_URL}`,
prepareHeaders: (headers) => {
headers.set('X-CSRF-Token', getCsrfToken() || '');
headers.set('Content-Type', 'application/json');
return headers;
},
credentials: 'include',
}),
tagTypes: ['UserRecentTasks', 'UserTimeLoggedTasks'],
endpoints: (builder) => ({
getUserRecentTasks: builder.query<IUserRecentTask[], { limit?: number; offset?: number }>({
query: ({ limit = 10, offset = 0 }) => ({
url: `${rootUrl}/user-recent-tasks`,
params: { limit, offset },
method: 'GET',
}),
providesTags: ['UserRecentTasks'],
}),
getUserTimeLoggedTasks: builder.query<IUserTimeLoggedTask[], { limit?: number; offset?: number }>({
query: ({ limit = 10, offset = 0 }) => ({
url: `${rootUrl}/user-time-logged-tasks`,
params: { limit, offset },
method: 'GET',
}),
providesTags: ['UserTimeLoggedTasks'],
}),
}),
});
export const {
useGetUserRecentTasksQuery,
useGetUserTimeLoggedTasksQuery,
} = userActivityApiService;

View File

@@ -18,5 +18,10 @@ export const surveyApiService = {
async getUserSurveyResponse(surveyId: string): Promise<IServerResponse<ISurveyResponse>> {
const response = await apiClient.get<IServerResponse<ISurveyResponse>>(`${API_BASE_URL}/surveys/responses/${surveyId}`);
return response.data;
},
async checkAccountSetupSurveyStatus(): Promise<IServerResponse<{ is_completed: boolean; completed_at?: string }>> {
const response = await apiClient.get<IServerResponse<{ is_completed: boolean; completed_at?: string }>>(`${API_BASE_URL}/surveys/account-setup/status`);
return response.data;
}
};

View File

@@ -2,6 +2,7 @@ import { API_BASE_URL } from '@/shared/constants';
import apiClient from '../api-client';
import { IServerResponse } from '@/types/common.types';
import { ITaskLogViewModel } from '@/types/tasks/task-log-view.types';
import { getUserSession } from '@/utils/session-helper';
const rootUrl = `${API_BASE_URL}/task-time-log`;
@@ -17,7 +18,11 @@ export interface IRunningTimer {
export const taskTimeLogsApiService = {
getByTask: async (id: string): Promise<IServerResponse<ITaskLogViewModel[]>> => {
const response = await apiClient.get(`${rootUrl}/task/${id}`);
const session = getUserSession();
const timezone = session?.timezone_name || 'UTC';
const response = await apiClient.get(`${rootUrl}/task/${id}`, {
params: { time_zone_name: timezone }
});
return response.data;
},

View File

@@ -7,6 +7,7 @@ import userReducer from '@features/user/userSlice';
// Home Page
import homePageReducer from '@features/home-page/home-page.slice';
import userActivityReducer from '@features/home-page/user-activity.slice';
// Account Setup
import accountSetupReducer from '@features/account-setup/account-setup.slice';
@@ -82,6 +83,8 @@ import groupingReducer from '@/features/task-management/grouping.slice';
import selectionReducer from '@/features/task-management/selection.slice';
import homePageApiService from '@/api/home-page/home-page.api.service';
import { projectsApi } from '@/api/projects/projects.v1.api.service';
import { userActivityApiService } from '@/api/home-page/user-activity.api.service';
import { ganttApi } from '@/pages/projects/projectView/gantt/services/gantt-api.service';
import projectViewReducer from '@features/project/project-view-slice';
import taskManagementFieldsReducer from '@features/task-management/taskListFields.slice';
@@ -90,7 +93,7 @@ export const store = configureStore({
middleware: getDefaultMiddleware =>
getDefaultMiddleware({
serializableCheck: false,
}).concat(homePageApiService.middleware, projectsApi.middleware),
}).concat(homePageApiService.middleware, projectsApi.middleware, userActivityApiService.middleware, ganttApi.middleware),
reducer: {
// Auth & User
auth: authReducer,
@@ -103,6 +106,10 @@ export const store = configureStore({
homePageReducer: homePageReducer,
[homePageApiService.reducerPath]: homePageApiService.reducer,
[projectsApi.reducerPath]: projectsApi.reducer,
[ganttApi.reducerPath]: ganttApi.reducer,
userActivityReducer: userActivityReducer,
[userActivityApiService.reducerPath]: userActivityApiService.reducer,
// Core UI
themeReducer: themeReducer,
localesReducer: localesReducer,

View File

@@ -3,7 +3,7 @@ import { getJSONFromLocalStorage, saveJSONToLocalStorage } from '../utils/localS
import { Button, ConfigProvider, Tooltip } from '@/shared/antd-imports';
import { PushpinFilled, PushpinOutlined } from '@/shared/antd-imports';
import { colors } from '../styles/colors';
import { navRoutes, NavRoutesType } from '../lib/navbar/navRoutes';
import { navRoutes, NavRoutesType } from '../features/navbar/navRoutes';
// Props type for the component
type PinRouteToNavbarButtonProps = {

View File

@@ -20,6 +20,7 @@ interface Props {
styles: any;
isDarkMode: boolean;
token?: any;
isModal?: boolean; // New prop to indicate if used in modal context
}
interface SurveyPageProps {
@@ -29,6 +30,7 @@ interface SurveyPageProps {
surveyData: IAccountSetupSurveyData;
handleSurveyDataChange: (field: keyof IAccountSetupSurveyData, value: any) => void;
handleUseCaseToggle?: (value: UseCase) => void;
isModal?: boolean;
}
// Page 1: About You
@@ -235,7 +237,7 @@ const YourNeedsPage: React.FC<SurveyPageProps> = ({ styles, token, surveyData, h
};
// Page 3: Discovery
const DiscoveryPage: React.FC<SurveyPageProps> = ({ styles, token, surveyData, handleSurveyDataChange }) => {
const DiscoveryPage: React.FC<SurveyPageProps> = ({ styles, token, surveyData, handleSurveyDataChange, isModal }) => {
const { t } = useTranslation('account-setup');
const howHeardAboutOptions: { value: HowHeardAbout; label: string; icon: string }[] = [
@@ -291,14 +293,18 @@ const DiscoveryPage: React.FC<SurveyPageProps> = ({ styles, token, surveyData, h
<div className="mt-12 p-1.5 rounded-lg text-center" style={{ backgroundColor: token?.colorSuccessBg, borderColor: token?.colorSuccessBorder, border: '1px solid' }}>
<div className="text-4xl mb-3">🎉</div>
<Title level={4} style={{ color: token?.colorText, marginBottom: 8 }}>{t('allSetTitle')}</Title>
<Paragraph style={{ color: token?.colorTextSecondary, marginBottom: 0 }}>{t('allSetDescription')}</Paragraph>
<Title level={4} style={{ color: token?.colorText, marginBottom: 8 }}>
{isModal ? t('surveyCompleteTitle') : t('allSetTitle')}
</Title>
<Paragraph style={{ color: token?.colorTextSecondary, marginBottom: 0 }}>
{isModal ? t('surveyCompleteDescription') : t('allSetDescription')}
</Paragraph>
</div>
</div>
);
};
export const SurveyStep: React.FC<Props> = ({ onEnter, styles, isDarkMode, token }) => {
export const SurveyStep: React.FC<Props> = ({ onEnter, styles, isDarkMode, token, isModal = false }) => {
const { t } = useTranslation('account-setup');
const dispatch = useDispatch();
const { surveyData, surveySubStep } = useSelector((state: RootState) => state.accountSetupReducer);
@@ -339,9 +345,9 @@ export const SurveyStep: React.FC<Props> = ({ onEnter, styles, isDarkMode, token
};
const surveyPages = [
<AboutYouPage key="about-you" styles={styles} isDarkMode={isDarkMode} token={token} surveyData={surveyData} handleSurveyDataChange={handleSurveyDataChange} />,
<YourNeedsPage key="your-needs" styles={styles} isDarkMode={isDarkMode} token={token} surveyData={surveyData} handleSurveyDataChange={handleSurveyDataChange} handleUseCaseToggle={handleUseCaseToggle} />,
<DiscoveryPage key="discovery" styles={styles} isDarkMode={isDarkMode} token={token} surveyData={surveyData} handleSurveyDataChange={handleSurveyDataChange} />
<AboutYouPage key="about-you" styles={styles} isDarkMode={isDarkMode} token={token} surveyData={surveyData} handleSurveyDataChange={handleSurveyDataChange} isModal={isModal} />,
<YourNeedsPage key="your-needs" styles={styles} isDarkMode={isDarkMode} token={token} surveyData={surveyData} handleSurveyDataChange={handleSurveyDataChange} handleUseCaseToggle={handleUseCaseToggle} isModal={isModal} />,
<DiscoveryPage key="discovery" styles={styles} isDarkMode={isDarkMode} token={token} surveyData={surveyData} handleSurveyDataChange={handleSurveyDataChange} isModal={isModal} />
];
React.useEffect(() => {

View File

@@ -1,14 +1,4 @@
import {
AutoComplete,
Button,
Flex,
Form,
message,
Modal,
Select,
Spin,
Typography,
} from '@/shared/antd-imports';
import { AutoComplete, Button, Drawer, Flex, Form, message, Modal, Select, Spin, Typography } from '@/shared/antd-imports';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import {
@@ -21,6 +11,7 @@ import { jobTitlesApiService } from '@/api/settings/job-titles/job-titles.api.se
import { IJobTitle } from '@/types/job.types';
import { teamMembersApiService } from '@/api/team-members/teamMembers.api.service';
import { ITeamMemberCreateRequest } from '@/types/teamMembers/team-member-create-request';
import { LinkOutlined } from '@ant-design/icons';
interface FormValues {
email: string[];

View File

@@ -10,3 +10,6 @@ export { default as LabelsSelector } from './LabelsSelector';
export { default as Progress } from './Progress';
export { default as Tag } from './Tag';
export { default as Tooltip } from './Tooltip';
// Update Notification Components
export * from './update-notification';

View File

@@ -1,112 +0,0 @@
import {
Button,
Card,
Dropdown,
Flex,
MenuProps,
Space,
Typography,
HomeOutlined,
MenuOutlined,
ProjectOutlined,
QuestionCircleOutlined,
ReadOutlined,
} from '@/shared/antd-imports';
import React, { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { colors } from '@/styles/colors';
import { NavLink } from 'react-router-dom';
import InviteButton from '@/components/navbar/invite/InviteButton';
import SwitchTeamButton from '@/components/navbar/switchTeam/SwitchTeamButton';
// custom css
import './MobileMenuButton.css';
const MobileMenuButton = memo(() => {
// localization
const { t } = useTranslation('navbar');
const navLinks = useMemo(
() => [
{
name: 'home',
icon: React.createElement(HomeOutlined),
},
{
name: 'projects',
icon: React.createElement(ProjectOutlined),
},
// {
// name: 'schedule',
// icon: React.createElement(ClockCircleOutlined),
// },
{
name: 'reporting',
icon: React.createElement(ReadOutlined),
},
{
name: 'help',
icon: React.createElement(QuestionCircleOutlined),
},
],
[]
);
const mobileMenu: MenuProps['items'] = useMemo(
() => [
{
key: '1',
label: (
<Card className="mobile-menu-card" bordered={false} style={{ width: 230 }}>
{navLinks.map((navEl, index) => (
<NavLink key={index} to={`/worklenz/${navEl.name}`}>
<Typography.Text strong>
<Space>
{navEl.icon}
{t(navEl.name)}
</Space>
</Typography.Text>
</NavLink>
))}
<Flex
vertical
gap={12}
style={{
width: '90%',
marginInlineStart: 12,
marginBlock: 6,
}}
>
<Button
style={{
backgroundColor: colors.lightBeige,
color: 'black',
}}
>
{t('upgradePlan')}
</Button>
<InviteButton />
<SwitchTeamButton />
</Flex>
</Card>
),
},
],
[navLinks, t]
);
return (
<Dropdown
overlayClassName="mobile-menu-dropdown"
menu={{ items: mobileMenu }}
placement="bottomRight"
trigger={['click']}
>
<Button className="borderless-icon-btn" icon={<MenuOutlined style={{ fontSize: 20 }} />} />
</Dropdown>
);
});
MobileMenuButton.displayName = 'MobileMenuButton';
export default MobileMenuButton;

View File

@@ -1,128 +0,0 @@
# Notification Components Styling Fixes
## Issue Resolved
Fixed missing spacing and borders in notification templates that occurred during performance optimization.
## Root Cause
During the performance optimization, the CSS class references and styling approach were changed, which resulted in:
- Missing borders around notification items
- No spacing between notifications
- Improper padding and margins
## Solutions Applied
### 1. Updated CSS Class Usage
- **Before**: Used generic `ant-notification-notice` classes
- **After**: Implemented proper Tailwind CSS classes with fallback styling
### 2. Tailwind CSS Classes Implementation
#### NotificationItem.tsx
```jsx
// Container classes with proper spacing and borders
const containerClasses = [
'w-auto p-3 mb-3 rounded border border-gray-200 bg-white shadow-sm transition-all duration-300',
'hover:shadow-md hover:bg-gray-50',
notification.url ? 'cursor-pointer' : 'cursor-default',
'dark:border-gray-600 dark:bg-gray-800 dark:hover:bg-gray-700'
].join(' ');
// Updated content structure
<div className="notification-content">
<div className="notification-description">
<Text type="secondary" className="mb-2 flex items-center gap-2">
<BankOutlined /> {notification.team}
</Text>
<div className="mb-2" dangerouslySetInnerHTML={safeMessageHtml} />
{shouldShowProject && (
<div className="mb-2">
<Tag style={tagStyle}>{notification.project}</Tag>
</div>
)}
</div>
<div className="flex items-baseline justify-between mt-2">
{/* Footer content */}
</div>
</div>
```
#### NotificationTemplate.tsx
Applied similar Tailwind classes for consistency:
- `p-3` for padding
- `mb-3` for bottom margin
- `rounded` for border radius
- `border border-gray-200` for borders
- `shadow-sm` for subtle shadows
- `transition-all duration-300` for smooth animations
#### NotificationDrawer.tsx
Updated container classes:
```jsx
<div className="notification-list mt-4 px-2">
{/* Notification items */}
</div>
```
### 3. Responsive Design Support
#### Light Mode
- Background: `bg-white`
- Border: `border-gray-200`
- Hover: `hover:bg-gray-50`
- Shadow: `shadow-sm``hover:shadow-md`
#### Dark Mode
- Background: `dark:bg-gray-800`
- Border: `dark:border-gray-600`
- Hover: `dark:hover:bg-gray-700`
- Maintains proper contrast
### 4. CSS Imports Fixed
- **NotificationItem.tsx**: Updated import from `PushNotificationTemplate.css` to `NotificationItem.css`
- **NotificationTemplate.tsx**: Added proper CSS import for styling
### 5. Spacing Improvements
#### Margins and Padding
- **Container**: `p-3` (12px padding)
- **Bottom margin**: `mb-3` (12px between items)
- **Internal spacing**: `mb-2` (8px between content sections)
- **Text**: `text-xs` for timestamp
#### Layout Classes
- **Flexbox**: `flex items-center gap-2` for inline elements
- **Alignment**: `flex items-baseline justify-between` for footer
- **Cursor**: `cursor-pointer` or `cursor-default` based on interactivity
## Visual Improvements
### Before Fix
- No visible borders
- Items touching each other
- Poor visual hierarchy
- Inconsistent spacing
### After Fix
- ✅ Clear borders around each notification
- ✅ Proper spacing between items
- ✅ Good visual hierarchy
- ✅ Consistent padding and margins
- ✅ Smooth hover effects
- ✅ Dark mode support
- ✅ Responsive design
## Performance Maintained
All performance optimizations (React.memo, useCallback, useMemo) remain intact while fixing the visual issues.
## Build Verification
✅ Production build successful
✅ No styling conflicts
✅ Proper Tailwind CSS compilation
✅ Cross-browser compatibility maintained
## Key Benefits
1. **Consistent Design**: Unified styling across all notification components
2. **Better UX**: Clear visual separation and proper interactive states
3. **Maintainable**: Using Tailwind CSS classes reduces custom CSS
4. **Accessible**: Proper contrast ratios and hover states
5. **Performance**: No impact on optimized component performance

View File

@@ -1,165 +0,0 @@
import React, { memo, useState, useCallback, useMemo } from 'react';
import { IWorklenzNotification } from '@/types/notifications/notifications.types';
import { BankOutlined } from '@/shared/antd-imports';
import { Button, Tag, Typography, theme } from '@/shared/antd-imports';
import DOMPurify from 'dompurify';
import { fromNow } from '@/utils/dateUtils';
import './NotificationItem.css';
const { Text } = Typography;
interface NotificationItemProps {
notification: IWorklenzNotification;
isUnreadNotifications?: boolean;
markNotificationAsRead?: (id: string) => Promise<void>;
goToUrl?: (e: React.MouseEvent, notification: IWorklenzNotification) => Promise<void>;
}
const NotificationItem = memo<NotificationItemProps>(({
notification,
isUnreadNotifications = true,
markNotificationAsRead,
goToUrl,
}) => {
const { token } = theme.useToken();
const [loading, setLoading] = useState(false);
const isDarkMode = useMemo(
() =>
token.colorBgContainer === '#141414' ||
token.colorBgContainer.includes('dark') ||
document.documentElement.getAttribute('data-theme') === 'dark',
[token.colorBgContainer]
);
const handleNotificationClick = useCallback(
async (e: React.MouseEvent) => {
await goToUrl?.(e, notification);
await markNotificationAsRead?.(notification.id);
},
[goToUrl, markNotificationAsRead, notification]
);
const handleMarkAsRead = useCallback(
async (e: React.MouseEvent) => {
e.stopPropagation();
if (!notification.id) return;
setLoading(true);
try {
await markNotificationAsRead?.(notification.id);
} finally {
setLoading(false);
}
},
[markNotificationAsRead, notification.id]
);
const safeMessageHtml = useMemo(
() => ({ __html: DOMPurify.sanitize(notification.message) }),
[notification.message]
);
const tagStyle = useMemo(() => {
if (!notification.color) return {};
const bgColor = `${notification.color}4d`;
if (isDarkMode) {
return {
backgroundColor: bgColor,
color: '#ffffff',
borderColor: 'transparent',
};
}
return {
backgroundColor: bgColor,
borderColor: 'transparent',
};
}, [notification.color, isDarkMode]);
const containerStyle = useMemo(
() => ({
border: notification.color ? `2px solid ${notification.color}4d` : undefined,
}),
[notification.color]
);
const containerClasses = useMemo(
() => [
'w-auto p-3 mb-3 rounded border border-gray-200 bg-white shadow-sm transition-all duration-300',
'hover:shadow-md hover:bg-gray-50',
notification.url ? 'cursor-pointer' : 'cursor-default',
'dark:border-gray-600 dark:bg-gray-800 dark:hover:bg-gray-700'
].join(' '),
[notification.url]
);
const formattedDate = useMemo(
() => (notification.created_at ? fromNow(notification.created_at) : ''),
[notification.created_at]
);
const shouldShowProject = useMemo(
() => Boolean(notification.project),
[notification.project]
);
const shouldShowMarkAsRead = useMemo(
() => Boolean(isUnreadNotifications && markNotificationAsRead),
[isUnreadNotifications, markNotificationAsRead]
);
return (
<div
style={containerStyle}
onClick={handleNotificationClick}
className={containerClasses}
>
<div className="notification-content">
<div className="notification-description">
{/* Team name */}
<div className="mb-2">
<Text type="secondary" className="flex items-center gap-2">
<BankOutlined /> {notification.team}
</Text>
</div>
{/* Message with HTML content */}
<div className="mb-2" dangerouslySetInnerHTML={safeMessageHtml} />
{/* Project tag */}
{shouldShowProject && (
<div className="mb-2">
<Tag style={tagStyle}>{notification.project}</Tag>
</div>
)}
</div>
{/* Footer with mark as read button and timestamp */}
<div className="flex items-baseline justify-between mt-2">
{shouldShowMarkAsRead && (
<Button
loading={loading}
type="link"
size="small"
shape="round"
className="p-0"
onClick={handleMarkAsRead}
>
<u>Mark as read</u>
</Button>
)}
<Text type="secondary" className="text-xs">
{formattedDate}
</Text>
</div>
</div>
</div>
);
});
NotificationItem.displayName = 'NotificationItem';
export default NotificationItem;

View File

@@ -1,152 +0,0 @@
import React, { memo, useCallback, useMemo } from 'react';
import { Button, Typography, Tag } from '@/shared/antd-imports';
import { BankOutlined } from '@/shared/antd-imports';
import { IWorklenzNotification } from '@/types/notifications/notifications.types';
import { useNavigate } from 'react-router-dom';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { toggleDrawer } from '@features/navbar/notificationSlice';
import { teamsApiService } from '@/api/teams/teams.api.service';
import { formatDistanceToNow } from 'date-fns';
import { tagBackground } from '@/utils/colorUtils';
import logger from '@/utils/errorLogger';
import './NotificationItem.css';
interface NotificationTemplateProps {
item: IWorklenzNotification;
isUnreadNotifications: boolean;
markNotificationAsRead: (id: string) => Promise<void>;
loadersMap: Record<string, boolean>;
}
const NotificationTemplate = memo<NotificationTemplateProps>(({
item,
isUnreadNotifications,
markNotificationAsRead,
loadersMap,
}) => {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const goToUrl = useCallback(
async (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
if (!item.url) return;
try {
dispatch(toggleDrawer());
if (item.team_id) {
await teamsApiService.setActiveTeam(item.team_id);
}
navigate(item.url, {
state: item.params || null,
});
} catch (error) {
logger.error('Error navigating to notification URL', error);
}
},
[item.url, item.team_id, item.params, dispatch, navigate]
);
const formattedDate = useMemo(() => {
if (!item.created_at) return '';
try {
return formatDistanceToNow(new Date(item.created_at), { addSuffix: true });
} catch (error) {
logger.error('Error formatting date', error);
return '';
}
}, [item.created_at]);
const handleMarkAsRead = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
markNotificationAsRead(item.id);
},
[markNotificationAsRead, item.id]
);
const containerStyle = useMemo(
() => ({
border: item.color ? `2px solid ${item.color}4d` : undefined,
}),
[item.color]
);
const containerClassName = useMemo(
() => [
'w-auto p-3 mb-3 rounded border border-gray-200 bg-white shadow-sm transition-all duration-300',
'hover:shadow-md hover:bg-gray-50',
item.url ? 'cursor-pointer' : 'cursor-default',
'dark:border-gray-600 dark:bg-gray-800 dark:hover:bg-gray-700'
].join(' '),
[item.url]
);
const messageHtml = useMemo(
() => ({ __html: item.message }),
[item.message]
);
const tagStyle = useMemo(
() => (item.color ? { backgroundColor: tagBackground(item.color) } : {}),
[item.color]
);
const shouldShowProject = useMemo(
() => Boolean(item.project && item.color),
[item.project, item.color]
);
const isLoading = useMemo(
() => Boolean(loadersMap[item.id]),
[loadersMap, item.id]
);
return (
<div
style={containerStyle}
onClick={goToUrl}
className={containerClassName}
>
<div className="notification-content">
<div className="notification-description">
<Typography.Text type="secondary" className="mb-2 flex items-center gap-2">
<BankOutlined /> {item.team}
</Typography.Text>
<div className="mb-2" dangerouslySetInnerHTML={messageHtml} />
{shouldShowProject && (
<div className="mb-2">
<Tag style={tagStyle}>{item.project}</Tag>
</div>
)}
</div>
<div className="flex items-baseline justify-between mt-2">
{isUnreadNotifications && (
<Button
type="link"
shape="round"
size="small"
loading={isLoading}
onClick={handleMarkAsRead}
>
<u>Mark as read</u>
</Button>
)}
<Typography.Text type="secondary" className="text-xs">
{formattedDate}
</Typography.Text>
</div>
</div>
</div>
);
});
NotificationTemplate.displayName = 'NotificationTemplate';
export default NotificationTemplate;

View File

@@ -1,176 +0,0 @@
import React, { memo, useCallback, useMemo } from 'react';
import { notification } from '@/shared/antd-imports';
import { IWorklenzNotification } from '@/types/notifications/notifications.types';
import { teamsApiService } from '@/api/teams/teams.api.service';
import { toQueryString } from '@/utils/toQueryString';
import { BankOutlined } from '@/shared/antd-imports';
import './PushNotificationTemplate.css';
interface PushNotificationTemplateProps {
notification: IWorklenzNotification;
}
const PushNotificationTemplate = memo(({
notification: notificationData,
}: PushNotificationTemplateProps) => {
const handleClick = useCallback(async () => {
if (!notificationData.url) return;
try {
let url = notificationData.url;
if (notificationData.params && Object.keys(notificationData.params).length) {
const q = toQueryString(notificationData.params);
url += q;
}
if (notificationData.team_id) {
await teamsApiService.setActiveTeam(notificationData.team_id);
}
window.location.href = url;
} catch (error) {
console.error('Error handling notification click:', error);
}
}, [notificationData.url, notificationData.params, notificationData.team_id]);
const containerStyle = useMemo(
() => ({
cursor: notificationData.url ? 'pointer' : 'default',
padding: '8px 0',
borderRadius: '8px',
}),
[notificationData.url]
);
const headerStyle = useMemo(
() => ({
display: 'flex',
alignItems: 'center',
marginBottom: '8px',
color: '#262626',
fontSize: '14px',
fontWeight: 500,
}),
[]
);
const iconStyle = useMemo(
() => ({ marginRight: '8px', color: '#1890ff' }),
[]
);
const messageStyle = useMemo(
() => ({
color: '#595959',
fontSize: '13px',
lineHeight: '1.5',
marginTop: '4px',
}),
[]
);
const className = useMemo(
() => `notification-content ${notificationData.url ? 'clickable' : ''}`,
[notificationData.url]
);
const messageHtml = useMemo(
() => ({ __html: notificationData.message }),
[notificationData.message]
);
return (
<div
onClick={handleClick}
className={className}
style={containerStyle}
>
<div style={headerStyle}>
{notificationData.team ? (
<>
<BankOutlined style={iconStyle} />
{notificationData.team}
</>
) : (
'Worklenz'
)}
</div>
<div
style={messageStyle}
dangerouslySetInnerHTML={messageHtml}
/>
</div>
);
});
PushNotificationTemplate.displayName = 'PushNotificationTemplate';
// Notification queue management
class NotificationQueueManager {
private queue: IWorklenzNotification[] = [];
private isProcessing = false;
private readonly maxQueueSize = 10;
private readonly notificationStyle = {
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
padding: '12px 16px',
minWidth: '300px',
maxWidth: '400px',
};
private processQueue = () => {
if (this.isProcessing || this.queue.length === 0) return;
this.isProcessing = true;
const notificationData = this.queue.shift();
if (notificationData) {
notification.info({
message: null,
description: <PushNotificationTemplate notification={notificationData} />,
placement: 'topRight',
duration: 5,
style: this.notificationStyle,
onClose: () => {
this.isProcessing = false;
// Use setTimeout to prevent stack overflow with rapid notifications
setTimeout(() => this.processQueue(), 0);
},
});
} else {
this.isProcessing = false;
}
};
public addNotification = (notificationData: IWorklenzNotification) => {
// Prevent queue overflow
if (this.queue.length >= this.maxQueueSize) {
console.warn('Notification queue is full, dropping oldest notification');
this.queue.shift();
}
this.queue.push(notificationData);
this.processQueue();
};
public clearQueue = () => {
this.queue.length = 0;
this.isProcessing = false;
};
public getQueueLength = () => this.queue.length;
}
const notificationManager = new NotificationQueueManager();
export const showNotification = (notificationData: IWorklenzNotification) => {
notificationManager.addNotification(notificationData);
};
export const clearNotificationQueue = () => {
notificationManager.clearQueue();
};
export const getNotificationQueueLength = () => {
return notificationManager.getQueueLength();
};

View File

@@ -17,7 +17,7 @@ interface InvitationItemProps {
t: TFunction;
}
const InvitationItem = ({ item, isUnreadNotifications, t }: InvitationItemProps) => {
const InvitationItem: React.FC<InvitationItemProps> = ({ item, isUnreadNotifications, t }) => {
const [accepting, setAccepting] = useState(false);
const [joining, setJoining] = useState(false);
const dispatch = useAppDispatch();

View File

@@ -1,4 +1,3 @@
import React, { memo, useCallback, useMemo } from 'react';
import { Drawer, Empty, Segmented, Typography, Spin, Button, Flex } from '@/shared/antd-imports';
import { useEffect, useState } from 'react';
import { useAppSelector } from '@/hooks/useAppSelector';
@@ -8,7 +7,7 @@ import {
fetchNotifications,
setNotificationType,
toggleDrawer,
} from '../../../../features/navbar/notificationSlice';
} from '../../../../../features/navbar/notificationSlice';
import { NOTIFICATION_OPTION_READ, NOTIFICATION_OPTION_UNREAD } from '@/shared/constants';
import { useTranslation } from 'react-i18next';
import { SocketEvents } from '@/shared/socket-events';
@@ -16,13 +15,13 @@ import { IWorklenzNotification } from '@/types/notifications/notifications.types
import { useSocket } from '@/socket/socketContext';
import { ITeamInvitationViewModel } from '@/types/notifications/notifications.types';
import logger from '@/utils/errorLogger';
import NotificationItem from './notification/NotificationItem';
import InvitationItem from '../../InvitationItem';
import NotificationItem from './notification-item';
import InvitationItem from './invitation-item';
import { notificationsApiService } from '@/api/notifications/notifications.api.service';
import { profileSettingsApiService } from '@/api/settings/profile/profile-settings.api.service';
import { INotificationSettings } from '@/types/settings/notifications.types';
import { toQueryString } from '@/utils/toQueryString';
import { showNotification } from './notification/PushNotificationTemplate';
import { showNotification } from './push-notification-template';
import { teamsApiService } from '@/api/teams/teams.api.service';
import { verifyAuthentication } from '@/features/auth/authSlice';
import { getUserSession } from '@/utils/session-helper';
@@ -31,7 +30,7 @@ import { useNavigate } from 'react-router-dom';
import { createAuthService } from '@/services/auth/auth.service';
const HTML_TAG_REGEXP = /<[^>]*>/g;
const NotificationDrawer = memo(() => {
const NotificationDrawer = () => {
const { isDrawerOpen, notificationType, notifications, invitations } = useAppSelector(
state => state.notificationReducer
);
@@ -51,88 +50,72 @@ const NotificationDrawer = memo(() => {
const navigate = useNavigate();
const authService = createAuthService(navigate);
const createPush = useCallback(
(message: string, title: string, teamId: string | null, url?: string) => {
if (Notification.permission === 'granted' && showBrowserPush) {
const img = 'https://worklenz.com/assets/icons/icon-128x128.png';
const notification = new Notification(title, {
body: message.replace(HTML_TAG_REGEXP, ''),
icon: img,
badge: img,
});
const createPush = (message: string, title: string, teamId: string | null, url?: string) => {
if (Notification.permission === 'granted' && showBrowserPush) {
const img = 'https://worklenz.com/assets/icons/icon-128x128.png';
const notification = new Notification(title, {
body: message.replace(HTML_TAG_REGEXP, ''),
icon: img,
badge: img,
});
notification.onclick = async event => {
if (url) {
window.focus();
notification.onclick = async event => {
if (url) {
window.focus();
if (teamId) {
try {
await teamsApiService.setActiveTeam(teamId);
} catch (error) {
logger.error('Error setting active team from notification', error);
}
}
window.location.href = url;
if (teamId) {
await teamsApiService.setActiveTeam(teamId);
}
};
}
},
[showBrowserPush]
);
const handleInvitationsUpdate = useCallback(
(data: ITeamInvitationViewModel[]) => {
dispatch(fetchInvitations());
},
[dispatch]
);
const handleNotificationsUpdate = useCallback(
async (notification: IWorklenzNotification) => {
dispatch(fetchNotifications(notificationType));
dispatch(fetchInvitations());
if (isPushEnabled()) {
const title = notification.team ? `${notification.team} | Worklenz` : 'Worklenz';
let url = notification.url;
if (url && notification.params && Object.keys(notification.params).length) {
const q = toQueryString(notification.params);
url += q;
window.location.href = url;
}
createPush(notification.message, title, notification.team_id, url);
}
// Show notification using the template
showNotification(notification);
},
[dispatch, notificationType, isPushEnabled, createPush]
);
const handleTeamInvitationsUpdate = useCallback(
async (data: ITeamInvitationViewModel) => {
const notification: IWorklenzNotification = {
id: data.id || '',
team: data.team_name || '',
team_id: data.team_id || '',
message: `You have been invited to join ${data.team_name || 'a team'}`,
};
}
};
if (isPushEnabled()) {
createPush(
notification.message,
notification.team || 'Worklenz',
notification.team_id || null
);
const handleInvitationsUpdate = (data: ITeamInvitationViewModel[]) => {
dispatch(fetchInvitations());
};
const handleNotificationsUpdate = async (notification: IWorklenzNotification) => {
dispatch(fetchNotifications(notificationType));
dispatch(fetchInvitations());
if (isPushEnabled()) {
const title = notification.team ? `${notification.team} | Worklenz` : 'Worklenz';
let url = notification.url;
if (url && notification.params && Object.keys(notification.params).length) {
const q = toQueryString(notification.params);
url += q;
}
// Show notification using the template
showNotification(notification);
dispatch(fetchInvitations());
},
[isPushEnabled, createPush, dispatch]
);
createPush(notification.message, title, notification.team_id, url);
}
// Show notification using the template
showNotification(notification);
};
const handleTeamInvitationsUpdate = async (data: ITeamInvitationViewModel) => {
const notification: IWorklenzNotification = {
id: data.id || '',
team: data.team_name || '',
team_id: data.team_id || '',
message: `You have been invited to join ${data.team_name || 'a team'}`,
};
if (isPushEnabled()) {
createPush(
notification.message,
notification.team || 'Worklenz',
notification.team_id || null
);
}
// Show notification using the template
showNotification(notification);
dispatch(fetchInvitations());
};
const askPushPermission = () => {
if ('Notification' in window && 'serviceWorker' in navigator && 'PushManager' in window) {
@@ -152,40 +135,27 @@ const NotificationDrawer = memo(() => {
}
};
const markNotificationAsRead = useCallback(
async (id: string) => {
if (!id) return;
const markNotificationAsRead = async (id: string) => {
if (!id) return;
try {
const res = await notificationsApiService.updateNotification(id);
if (res.done) {
dispatch(fetchNotifications(notificationType));
dispatch(fetchInvitations());
}
} catch (error) {
logger.error('Error marking notification as read', error);
}
},
[dispatch, notificationType]
);
const handleVerifyAuth = useCallback(async () => {
try {
const result = await dispatch(verifyAuthentication()).unwrap();
if (result.authenticated) {
dispatch(setUser(result.user));
authService.setCurrentSession(result.user);
}
} catch (error) {
logger.error('Error verifying authentication', error);
const res = await notificationsApiService.updateNotification(id);
if (res.done) {
dispatch(fetchNotifications(notificationType));
dispatch(fetchInvitations());
}
}, [dispatch, authService]);
const goToUrl = useCallback(
async (event: React.MouseEvent, notification: IWorklenzNotification) => {
event.preventDefault();
event.stopPropagation();
if (!notification.url) return;
};
const handleVerifyAuth = async () => {
const result = await dispatch(verifyAuthentication()).unwrap();
if (result.authenticated) {
dispatch(setUser(result.user));
authService.setCurrentSession(result.user);
}
};
const goToUrl = async (event: React.MouseEvent, notification: IWorklenzNotification) => {
event.preventDefault();
event.stopPropagation();
if (notification.url) {
dispatch(toggleDrawer());
setIsLoading(true);
try {
@@ -199,13 +169,12 @@ const NotificationDrawer = memo(() => {
);
}
} catch (error) {
logger.error('Error navigating to URL:', error);
console.error('Error navigating to URL:', error);
} finally {
setIsLoading(false);
}
},
[dispatch, navigate, handleVerifyAuth]
);
}
};
const fetchNotificationsSettings = async () => {
try {
@@ -221,15 +190,11 @@ const NotificationDrawer = memo(() => {
}
};
const handleMarkAllAsRead = useCallback(async () => {
try {
await notificationsApiService.readAllNotifications();
dispatch(fetchNotifications(notificationType));
dispatch(fetchInvitations());
} catch (error) {
logger.error('Error marking all notifications as read', error);
}
}, [dispatch, notificationType]);
const handleMarkAllAsRead = async () => {
await notificationsApiService.readAllNotifications();
dispatch(fetchNotifications(notificationType));
dispatch(fetchInvitations());
};
useEffect(() => {
socket?.on(SocketEvents.INVITATIONS_UPDATE.toString(), handleInvitationsUpdate);
@@ -277,15 +242,12 @@ const NotificationDrawer = memo(() => {
<Segmented<string>
options={['Unread', 'Read']}
defaultValue={NOTIFICATION_OPTION_UNREAD}
onChange={useCallback(
(value: string) => {
if (value === NOTIFICATION_OPTION_UNREAD)
dispatch(setNotificationType(NOTIFICATION_OPTION_UNREAD));
if (value === NOTIFICATION_OPTION_READ)
dispatch(setNotificationType(NOTIFICATION_OPTION_READ));
},
[dispatch]
)}
onChange={(value: string) => {
if (value === NOTIFICATION_OPTION_UNREAD)
dispatch(setNotificationType(NOTIFICATION_OPTION_UNREAD));
if (value === NOTIFICATION_OPTION_READ)
dispatch(setNotificationType(NOTIFICATION_OPTION_READ));
}}
/>
<Button type="link" onClick={handleMarkAllAsRead}>
@@ -299,7 +261,7 @@ const NotificationDrawer = memo(() => {
</div>
)}
{invitations && invitations.length > 0 && notificationType === NOTIFICATION_OPTION_UNREAD ? (
<div className="notification-list mt-4 px-2">
<div className="notification-list mt-3">
{invitations.map(invitation => (
<InvitationItem
key={invitation.id}
@@ -311,13 +273,13 @@ const NotificationDrawer = memo(() => {
</div>
) : null}
{notifications && notifications.length > 0 ? (
<div className="notification-list mt-4 px-2">
<div className="notification-list mt-3">
{notifications.map(notification => (
<NotificationItem
key={notification.id}
notification={notification}
isUnreadNotifications={notificationType === NOTIFICATION_OPTION_UNREAD}
markNotificationAsRead={markNotificationAsRead}
markNotificationAsRead={id => Promise.resolve(markNotificationAsRead(id))}
goToUrl={goToUrl}
/>
))}
@@ -326,13 +288,16 @@ const NotificationDrawer = memo(() => {
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={t('notificationsDrawer.noNotifications')}
className="flex flex-col items-center mt-8"
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
marginBlockStart: 32,
}}
/>
)}
</Drawer>
);
});
NotificationDrawer.displayName = 'NotificationDrawer';
};
export default NotificationDrawer;

View File

@@ -0,0 +1,127 @@
import { IWorklenzNotification } from '@/types/notifications/notifications.types';
import { BankOutlined } from '@/shared/antd-imports';
import { Button, Tag, Typography, theme } from '@/shared/antd-imports';
import DOMPurify from 'dompurify';
import React, { useState } from 'react';
import { fromNow } from '@/utils/dateUtils';
import './notification-item.css';
const { Text } = Typography;
interface NotificationItemProps {
notification: IWorklenzNotification;
isUnreadNotifications?: boolean;
markNotificationAsRead?: (id: string) => Promise<void>;
goToUrl?: (e: React.MouseEvent, notification: IWorklenzNotification) => Promise<void>;
}
const NotificationItem = ({
notification,
isUnreadNotifications = true,
markNotificationAsRead,
goToUrl,
}: NotificationItemProps) => {
const { token } = theme.useToken();
const [loading, setLoading] = useState(false);
const isDarkMode =
token.colorBgContainer === '#141414' ||
token.colorBgContainer.includes('dark') ||
document.documentElement.getAttribute('data-theme') === 'dark';
const handleNotificationClick = async (e: React.MouseEvent) => {
await goToUrl?.(e, notification);
await markNotificationAsRead?.(notification.id);
};
const handleMarkAsRead = async (e: React.MouseEvent) => {
e.stopPropagation();
if (!notification.id) return;
setLoading(true);
try {
await markNotificationAsRead?.(notification.id);
} finally {
setLoading(false);
}
};
const createSafeHtml = (html: string) => {
return { __html: DOMPurify.sanitize(html) };
};
const getTagBackground = (color?: string) => {
if (!color) return {};
// Create a more transparent version of the color for the background
// This is equivalent to the color + '4d' in the Angular template
const bgColor = `${color}4d`;
// For dark mode, we might need to adjust the text color for better contrast
if (isDarkMode) {
return {
backgroundColor: bgColor,
color: '#ffffff',
borderColor: 'transparent',
};
}
return {
backgroundColor: bgColor,
borderColor: 'transparent',
};
};
return (
<div
style={{
width: 'auto',
border: notification.color ? `2px solid ${notification.color}4d` : undefined,
cursor: notification.url ? 'pointer' : 'default',
}}
onClick={handleNotificationClick}
className="ant-notification-notice worklenz-notification rounded-4"
>
<div className="ant-notification-notice-content">
<div className="ant-notification-notice-description">
{/* Team name */}
<div className="mb-1">
<Text type="secondary">
<BankOutlined /> {notification.team}
</Text>
</div>
{/* Message with HTML content */}
<div className="mb-1" dangerouslySetInnerHTML={createSafeHtml(notification.message)} />
{/* Project tag */}
{notification.project && (
<div>
<Tag style={getTagBackground(notification.color)}>{notification.project}</Tag>
</div>
)}
</div>
{/* Footer with mark as read button and timestamp */}
<div className="d-flex align-items-baseline justify-content-between mt-1">
{isUnreadNotifications && markNotificationAsRead && (
<Button
loading={loading}
type="link"
size="small"
shape="round"
className="p-0"
onClick={e => handleMarkAsRead(e)}
>
<u>Mark as read</u>
</Button>
)}
<Text type="secondary" className="small">
{notification.created_at ? fromNow(notification.created_at) : ''}
</Text>
</div>
</div>
</div>
);
};
export default NotificationItem;

View File

@@ -0,0 +1,95 @@
import { Button, Typography, Tag } from '@/shared/antd-imports';
import { BankOutlined } from '@/shared/antd-imports';
import { IWorklenzNotification } from '@/types/notifications/notifications.types';
import { useNavigate } from 'react-router-dom';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { toggleDrawer } from '../../../../../features/navbar/notificationSlice';
import { teamsApiService } from '@/api/teams/teams.api.service';
import { formatDistanceToNow } from 'date-fns';
import { tagBackground } from '@/utils/colorUtils';
interface NotificationTemplateProps {
item: IWorklenzNotification;
isUnreadNotifications: boolean;
markNotificationAsRead: (id: string) => Promise<void>;
loadersMap: Record<string, boolean>;
}
const NotificationTemplate: React.FC<NotificationTemplateProps> = ({
item,
isUnreadNotifications,
markNotificationAsRead,
loadersMap,
}) => {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const goToUrl = async (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
console.log('goToUrl triggered', { url: item.url, teamId: item.team_id });
if (item.url) {
dispatch(toggleDrawer());
if (item.team_id) {
await teamsApiService.setActiveTeam(item.team_id);
}
navigate(item.url, {
state: item.params || null,
});
}
};
const formatDate = (dateString?: string) => {
if (!dateString) return '';
return formatDistanceToNow(new Date(dateString), { addSuffix: true });
};
const handleMarkAsRead = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
markNotificationAsRead(item.id);
};
return (
<div
style={{ width: 'auto', border: `2px solid ${item.color}4d` }}
onClick={goToUrl}
className={`ant-notification-notice worklenz-notification rounded-4 ${item.url ? 'cursor-pointer' : ''}`}
>
<div className="ant-notification-notice-content">
<div className="ant-notification-notice-description">
<Typography.Text type="secondary" className="mb-1">
<BankOutlined /> {item.team}
</Typography.Text>
<div className="mb-1" dangerouslySetInnerHTML={{ __html: item.message }} />
{item.project && item.color && (
<Tag style={{ backgroundColor: tagBackground(item.color) }}>{item.project}</Tag>
)}
</div>
<div className="d-flex align-items-baseline justify-content-between mt-1">
{isUnreadNotifications && (
<Button
type="link"
shape="round"
size="small"
loading={loadersMap[item.id]}
onClick={handleMarkAsRead}
>
<u>Mark as read</u>
</Button>
)}
<Typography.Text type="secondary" className="small">
{formatDate(item.created_at)}
</Typography.Text>
</div>
</div>
</div>
);
};
export default NotificationTemplate;

View File

@@ -0,0 +1,105 @@
import { notification } from '@/shared/antd-imports';
import { IWorklenzNotification } from '@/types/notifications/notifications.types';
import { teamsApiService } from '@/api/teams/teams.api.service';
import { toQueryString } from '@/utils/toQueryString';
import { BankOutlined } from '@/shared/antd-imports';
import './push-notification-template.css';
const PushNotificationTemplate = ({
notification: notificationData,
}: {
notification: IWorklenzNotification;
}) => {
const handleClick = async () => {
if (notificationData.url) {
let url = notificationData.url;
if (notificationData.params && Object.keys(notificationData.params).length) {
const q = toQueryString(notificationData.params);
url += q;
}
if (notificationData.team_id) {
await teamsApiService.setActiveTeam(notificationData.team_id);
}
window.location.href = url;
}
};
return (
<div
onClick={handleClick}
className={`notification-content ${notificationData.url ? 'clickable' : ''}`}
style={{
cursor: notificationData.url ? 'pointer' : 'default',
padding: '8px 0',
borderRadius: '8px',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
marginBottom: '8px',
color: '#262626',
fontSize: '14px',
fontWeight: 500,
}}
>
{notificationData.team && (
<>
<BankOutlined style={{ marginRight: '8px', color: '#1890ff' }} />
{notificationData.team}
</>
)}
{!notificationData.team && 'Worklenz'}
</div>
<div
style={{
color: '#595959',
fontSize: '13px',
lineHeight: '1.5',
marginTop: '4px',
}}
dangerouslySetInnerHTML={{ __html: notificationData.message }}
/>
</div>
);
};
let notificationQueue: IWorklenzNotification[] = [];
let isProcessing = false;
const processNotificationQueue = () => {
if (isProcessing || notificationQueue.length === 0) return;
isProcessing = true;
const notificationData = notificationQueue.shift();
if (notificationData) {
notification.info({
message: null,
description: <PushNotificationTemplate notification={notificationData} />,
placement: 'topRight',
duration: 5,
style: {
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
padding: '12px 16px',
minWidth: '300px',
maxWidth: '400px',
},
onClose: () => {
isProcessing = false;
processNotificationQueue();
},
});
} else {
isProcessing = false;
}
};
export const showNotification = (notificationData: IWorklenzNotification) => {
notificationQueue.push(notificationData);
processNotificationQueue();
};

View File

@@ -0,0 +1,269 @@
import React, { useState, useEffect, useRef } from 'react';
import { Modal, Button, Result, Spin, Flex } from '@/shared/antd-imports';
import { SurveyStep } from '@/components/account-setup/survey-step';
import { useSurveyStatus } from '@/hooks/useSurveyStatus';
import { useTranslation } from 'react-i18next';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { surveyApiService } from '@/api/survey/survey.api.service';
import { appMessage } from '@/shared/antd-imports';
import { ISurveySubmissionRequest } from '@/types/account-setup/survey.types';
import logger from '@/utils/errorLogger';
import { resetSurveyData, setSurveySubStep } from '@/features/account-setup/account-setup.slice';
interface SurveyPromptModalProps {
forceShow?: boolean;
onClose?: () => void;
}
export const SurveyPromptModal: React.FC<SurveyPromptModalProps> = ({ forceShow = false, onClose }) => {
const { t } = useTranslation('survey');
const dispatch = useAppDispatch();
const [visible, setVisible] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [surveyCompleted, setSurveyCompleted] = useState(false);
const [surveyInfo, setSurveyInfo] = useState<{ id: string; questions: any[] } | null>(null);
const { hasCompletedSurvey, loading, refetch } = useSurveyStatus();
const themeMode = useAppSelector(state => state.themeReducer.mode);
const surveyData = useAppSelector(state => state.accountSetupReducer.surveyData);
const surveySubStep = useAppSelector(state => state.accountSetupReducer.surveySubStep);
const isDarkMode = themeMode === 'dark';
useEffect(() => {
// Check if survey modal is disabled via environment variable
if (import.meta.env.VITE_ENABLE_SURVEY_MODAL !== 'true' && !forceShow) {
return; // Don't show modal if disabled in environment
}
// Check if survey was skipped recently (within 7 days)
const skippedAt = localStorage.getItem('survey_skipped_at');
if (!forceShow && skippedAt) {
const skippedDate = new Date(skippedAt);
const now = new Date();
const diffDays = (now.getTime() - skippedDate.getTime()) / (1000 * 60 * 60 * 24);
if (diffDays < 3) {
return; // Don't show modal if skipped within 7 days
}
}
if (forceShow) {
setVisible(true);
dispatch(resetSurveyData());
dispatch(setSurveySubStep(0));
// Fetch survey info
const fetchSurvey = async () => {
try {
const response = await surveyApiService.getAccountSetupSurvey();
if (response.done && response.body) {
setSurveyInfo({
id: response.body.id,
questions: response.body.questions || []
});
}
} catch (error) {
logger.error(t('survey:fetchErrorLog'), error);
}
};
fetchSurvey();
} else if (!loading && hasCompletedSurvey === false) {
dispatch(resetSurveyData());
dispatch(setSurveySubStep(0));
// Fetch survey info
const fetchSurvey = async () => {
try {
const response = await surveyApiService.getAccountSetupSurvey();
if (response.done && response.body) {
setSurveyInfo({
id: response.body.id,
questions: response.body.questions || []
});
}
} catch (error) {
logger.error(t('survey:fetchErrorLog'), error);
}
};
fetchSurvey();
// Show modal after a 5 second delay to not interrupt user immediately
const timer = setTimeout(() => {
setVisible(true);
}, 5000);
return () => clearTimeout(timer);
}
}, [loading, hasCompletedSurvey, dispatch, forceShow, t]);
const handleComplete = async () => {
try {
setSubmitting(true);
if (!surveyData || !surveyInfo) {
throw new Error('Survey data not found');
}
// Create a map of question keys to IDs
const questionMap = surveyInfo.questions.reduce((acc, q) => {
acc[q.question_key] = q.id;
return acc;
}, {} as Record<string, string>);
// Prepare submission data with actual question IDs - only include answered questions
const answers: any[] = [];
if (surveyData.organization_type && questionMap['organization_type']) {
answers.push({
question_id: questionMap['organization_type'],
answer_text: surveyData.organization_type
});
}
if (surveyData.user_role && questionMap['user_role']) {
answers.push({
question_id: questionMap['user_role'],
answer_text: surveyData.user_role
});
}
if (surveyData.main_use_cases && surveyData.main_use_cases.length > 0 && questionMap['main_use_cases']) {
answers.push({
question_id: questionMap['main_use_cases'],
answer_json: surveyData.main_use_cases
});
}
if (surveyData.previous_tools && questionMap['previous_tools']) {
answers.push({
question_id: questionMap['previous_tools'],
answer_text: surveyData.previous_tools
});
}
if (surveyData.how_heard_about && questionMap['how_heard_about']) {
answers.push({
question_id: questionMap['how_heard_about'],
answer_text: surveyData.how_heard_about
});
}
const submissionData: ISurveySubmissionRequest = {
survey_id: surveyInfo.id,
answers
};
const response = await surveyApiService.submitSurveyResponse(submissionData);
if (response.done) {
setSurveyCompleted(true);
appMessage.success(t('survey:submitSuccessMessage'));
// Wait a moment before closing
setTimeout(() => {
setVisible(false);
refetch(); // Update the survey status
}, 2000);
} else {
throw new Error(response.message || t('survey:submitErrorMessage'));
}
} catch (error) {
logger.error(t('survey:submitErrorLog'), error);
appMessage.error(t('survey:submitErrorMessage'));
} finally {
setSubmitting(false);
}
};
const handleSkip = () => {
setVisible(false);
// Optionally, you can set a flag in localStorage to not show again for some time
localStorage.setItem('survey_skipped_at', new Date().toISOString());
onClose?.();
};
const isCurrentStepValid = () => {
switch (surveySubStep) {
case 0:
return surveyData.organization_type && surveyData.user_role;
case 1:
return surveyData.main_use_cases && surveyData.main_use_cases.length > 0;
case 2:
return surveyData.how_heard_about;
default:
return false;
}
};
const handleNext = () => {
if (surveySubStep < 2) {
dispatch(setSurveySubStep(surveySubStep + 1));
} else {
handleComplete();
}
};
const handlePrevious = () => {
if (surveySubStep > 0) {
dispatch(setSurveySubStep(surveySubStep - 1));
}
};
if (loading) {
return null;
}
return (
<Modal
open={visible}
title={surveyCompleted ? null : t('survey:modalTitle')}
onCancel={handleSkip}
footer={
surveyCompleted ? null : (
<Flex justify="space-between" align="center">
<div>
<Button onClick={handleSkip}>
{t('survey:skip')}
</Button>
</div>
<Flex gap={8}>
{surveySubStep > 0 && (
<Button onClick={handlePrevious}>
{t('survey:previous')}
</Button>
)}
<Button
type="primary"
onClick={handleNext}
disabled={!isCurrentStepValid()}
loading={submitting && surveySubStep === 2}
>
{surveySubStep === 2 ? t('survey:completeSurvey') : t('survey:next')}
</Button>
</Flex>
</Flex>
)
}
width={800}
maskClosable={false}
centered
>
{submitting ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<Spin size="large" />
<p style={{ marginTop: 16 }}>{t('survey:submitting')}</p>
</div>
) : surveyCompleted ? (
<Result
status="success"
title={t('survey:submitSuccessTitle')}
subTitle={t('survey:submitSuccessSubtitle')}
/>
) : (
<div style={{ maxHeight: '70vh', overflowY: 'auto' }}>
<SurveyStep
onEnter={() => {}} // Empty function since we handle navigation via buttons
styles={{}}
isDarkMode={isDarkMode}
isModal={true} // Pass true to indicate modal context
/>
</div>
)}
</Modal>
);
};

View File

@@ -0,0 +1,73 @@
import React, { useState } from 'react';
import { Card, Button, Result, Alert } from '@/shared/antd-imports';
import { CheckCircleOutlined, FormOutlined } from '@/shared/antd-imports';
import { useSurveyStatus } from '@/hooks/useSurveyStatus';
import { SurveyPromptModal } from './SurveyPromptModal';
import { useTranslation } from 'react-i18next';
export const SurveySettingsCard: React.FC = () => {
const { t } = useTranslation('settings');
const [showModal, setShowModal] = useState(false);
const { hasCompletedSurvey, loading } = useSurveyStatus();
if (loading) {
return (
<Card loading={true} />
);
}
return (
<>
<Card
title={
<span>
<FormOutlined style={{ marginRight: 8 }} />
Personalization Survey
</span>
}
extra={
hasCompletedSurvey && (
<Button type="link" onClick={() => setShowModal(true)}>
Update Responses
</Button>
)
}
>
{hasCompletedSurvey ? (
<Result
icon={<CheckCircleOutlined style={{ color: '#52c41a' }} />}
title="Survey Completed"
subTitle="Thank you for completing the personalization survey. Your responses help us improve Worklenz."
extra={
<Button onClick={() => setShowModal(true)}>
Update Your Responses
</Button>
}
/>
) : (
<>
<Alert
message="Help us personalize your experience"
description="Take a quick survey to tell us about your organization and how you use Worklenz."
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
<div style={{ textAlign: 'center' }}>
<Button type="primary" size="large" onClick={() => setShowModal(true)}>
Take Survey Now
</Button>
</div>
</>
)}
</Card>
{showModal && (
<SurveyPromptModal
forceShow={true}
onClose={() => setShowModal(false)}
/>
)}
</>
);
};

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