Compare commits
22 Commits
chore/adde
...
release-v2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bde793d44 | ||
|
|
8a829c659f | ||
|
|
8d17490f7e | ||
|
|
8830af2cbb | ||
|
|
01a580d992 | ||
|
|
c2e670c9a2 | ||
|
|
25042baf71 | ||
|
|
e8d21ee187 | ||
|
|
a8d1446b0d | ||
|
|
2082934cd5 | ||
|
|
4debcd6aa5 | ||
|
|
76adb89caf | ||
|
|
703a6425fe | ||
|
|
e2c9e19b83 | ||
|
|
e2a749e0b6 | ||
|
|
2c0b0ac4c5 | ||
|
|
dd511b236f | ||
|
|
2c860b0cc8 | ||
|
|
1e6045c534 | ||
|
|
2a9e12a495 | ||
|
|
fd2fc793df | ||
|
|
f3b7479770 |
@@ -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": []
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -36,6 +36,8 @@ lerna-debug.log*
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea/
|
||||
.cursor/
|
||||
.claude/
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 $$;
|
||||
|
||||
@@ -25,9 +25,10 @@ export default abstract class ReportingControllerBaseWithTimezone extends Workle
|
||||
* @param key - Date range key (e.g., YESTERDAY, LAST_WEEK)
|
||||
* @param dateRange - Array of date strings
|
||||
* @param userTimezone - User's timezone (e.g., 'America/New_York')
|
||||
* @param tableAlias - Table alias to use (e.g., 'twl', 'task_work_log')
|
||||
* @returns SQL clause for date filtering
|
||||
*/
|
||||
protected static getDateRangeClauseWithTimezone(key: string, dateRange: string[], userTimezone: string) {
|
||||
protected static getDateRangeClauseWithTimezone(key: string, dateRange: string[], userTimezone: string, tableAlias: string = 'task_work_log') {
|
||||
// For custom date ranges
|
||||
if (dateRange.length === 2) {
|
||||
// Convert dates to user's timezone start/end of day
|
||||
@@ -40,10 +41,10 @@ export default abstract class ReportingControllerBaseWithTimezone extends Workle
|
||||
|
||||
if (start.isSame(end, 'day')) {
|
||||
// Single day selection
|
||||
return `AND task_work_log.created_at >= '${startUtc}'::TIMESTAMP AND task_work_log.created_at <= '${endUtc}'::TIMESTAMP`;
|
||||
return `AND ${tableAlias}.created_at >= '${startUtc}'::TIMESTAMP AND ${tableAlias}.created_at <= '${endUtc}'::TIMESTAMP`;
|
||||
}
|
||||
|
||||
return `AND task_work_log.created_at >= '${startUtc}'::TIMESTAMP AND task_work_log.created_at <= '${endUtc}'::TIMESTAMP`;
|
||||
return `AND ${tableAlias}.created_at >= '${startUtc}'::TIMESTAMP AND ${tableAlias}.created_at <= '${endUtc}'::TIMESTAMP`;
|
||||
}
|
||||
|
||||
// For predefined ranges, calculate based on user's timezone
|
||||
@@ -74,7 +75,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 ${tableAlias}.created_at >= '${startUtc}'::TIMESTAMP AND ${tableAlias}.created_at <= '${endUtc}'::TIMESTAMP`;
|
||||
}
|
||||
|
||||
return "";
|
||||
|
||||
@@ -324,8 +324,8 @@ export default class ReportingMembersController extends ReportingControllerBaseW
|
||||
(SELECT color_code FROM project_phases WHERE id = (SELECT phase_id FROM task_phase WHERE task_id = t.id)) AS phase_color,
|
||||
|
||||
(total_minutes * 60) AS total_minutes,
|
||||
(SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id AND ta.team_member_id = $1) AS time_logged,
|
||||
((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id AND ta.team_member_id = $1) - (total_minutes * 60)) AS overlogged_time`;
|
||||
(SELECT SUM(time_spent) FROM task_work_log twl WHERE twl.task_id = t.id AND twl.user_id = (SELECT user_id FROM team_members WHERE id = $1)) AS time_logged,
|
||||
((SELECT SUM(time_spent) FROM task_work_log twl WHERE twl.task_id = t.id AND twl.user_id = (SELECT user_id FROM team_members WHERE id = $1)) - (total_minutes * 60)) AS overlogged_time`;
|
||||
}
|
||||
|
||||
protected static getActivityLogsOverdue(key: string, dateRange: string[]) {
|
||||
@@ -548,7 +548,7 @@ export default class ReportingMembersController extends ReportingControllerBaseW
|
||||
|
||||
// Get user timezone for proper date filtering
|
||||
const userTimezone = await this.getUserTimezone(req.user?.id as string);
|
||||
const durationClause = this.getDateRangeClauseWithTimezone(duration as string || DATE_RANGES.LAST_WEEK, dateRange, userTimezone);
|
||||
const durationClause = this.getDateRangeClauseWithTimezone(duration as string || DATE_RANGES.LAST_WEEK, dateRange, userTimezone, 'twl');
|
||||
const minMaxDateClause = this.getMinMaxDates(duration as string || DATE_RANGES.LAST_WEEK, dateRange, "task_work_log");
|
||||
const memberName = (req.query.member_name as string)?.trim() || null;
|
||||
|
||||
@@ -1101,7 +1101,7 @@ export default class ReportingMembersController extends ReportingControllerBaseW
|
||||
|
||||
// Get user timezone for proper date filtering
|
||||
const userTimezone = await this.getUserTimezone(req.user?.id as string);
|
||||
const durationClause = this.getDateRangeClauseWithTimezone(duration || DATE_RANGES.LAST_WEEK, date_range, userTimezone);
|
||||
const durationClause = this.getDateRangeClauseWithTimezone(duration || DATE_RANGES.LAST_WEEK, date_range, userTimezone, 'twl');
|
||||
const minMaxDateClause = this.getMinMaxDates(duration || DATE_RANGES.LAST_WEEK, date_range, "task_work_log");
|
||||
|
||||
const billableQuery = this.buildBillableQuery(billable);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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') {
|
||||
|
||||
@@ -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}}"
|
||||
}
|
||||
|
||||
@@ -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}}"
|
||||
}
|
||||
|
||||
@@ -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}}"
|
||||
}
|
||||
|
||||
@@ -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}}"
|
||||
}
|
||||
|
||||
@@ -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}}"
|
||||
}
|
||||
|
||||
@@ -75,5 +75,12 @@
|
||||
"delete": "删除",
|
||||
"enterStatusName": "输入状态名称",
|
||||
"selectCategory": "选择类别",
|
||||
"close": "关闭"
|
||||
"close": "关闭",
|
||||
"clearSort": "清除排序",
|
||||
"sortAscending": "升序排列",
|
||||
"sortDescending": "降序排列",
|
||||
"sortByField": "按{{field}}排序",
|
||||
"ascendingOrder": "升序",
|
||||
"descendingOrder": "降序",
|
||||
"currentSort": "当前排序:{{field}} {{order}}"
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ export async function on_quick_assign_or_remove(_io: Server, socket: Socket, dat
|
||||
assign_type: type
|
||||
});
|
||||
|
||||
if (userId !== assignment.user_id) {
|
||||
if (assignment && userId !== assignment.user_id) {
|
||||
NotificationsService.createTaskUpdate(
|
||||
type,
|
||||
userId as string,
|
||||
@@ -109,6 +109,11 @@ export async function assignMemberIfNot(taskId: string, userId: string, teamId:
|
||||
const result = await db.query(q, [taskId, userId, teamId]);
|
||||
const [data] = result.rows;
|
||||
|
||||
if (!data) {
|
||||
log_error(new Error(`No team member found for userId: ${userId}, teamId: ${teamId}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const body = {
|
||||
team_member_id: data.team_member_id,
|
||||
project_id: data.project_id,
|
||||
|
||||
@@ -76,40 +76,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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
14
worklenz-frontend/public/locales/alb/survey.json
Normal file
14
worklenz-frontend/public/locales/alb/survey.json
Normal 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"
|
||||
}
|
||||
@@ -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}}"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
14
worklenz-frontend/public/locales/de/survey.json
Normal file
14
worklenz-frontend/public/locales/de/survey.json
Normal 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"
|
||||
}
|
||||
@@ -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}}"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
14
worklenz-frontend/public/locales/en/survey.json
Normal file
14
worklenz-frontend/public/locales/en/survey.json
Normal 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"
|
||||
}
|
||||
@@ -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}}"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
14
worklenz-frontend/public/locales/es/survey.json
Normal file
14
worklenz-frontend/public/locales/es/survey.json
Normal 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"
|
||||
}
|
||||
@@ -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}}"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
14
worklenz-frontend/public/locales/pt/survey.json
Normal file
14
worklenz-frontend/public/locales/pt/survey.json
Normal 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"
|
||||
}
|
||||
@@ -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}}"
|
||||
}
|
||||
|
||||
@@ -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": "发现",
|
||||
|
||||
14
worklenz-frontend/public/locales/zh/survey.json
Normal file
14
worklenz-frontend/public/locales/zh/survey.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"modalTitle": "帮助我们提升您的体验",
|
||||
"skip": "暂时跳过",
|
||||
"previous": "上一步",
|
||||
"next": "下一步",
|
||||
"completeSurvey": "完成调查",
|
||||
"submitting": "正在提交您的回答...",
|
||||
"submitSuccessTitle": "谢谢!",
|
||||
"submitSuccessSubtitle": "您的反馈帮助我们改进 Worklenz。",
|
||||
"submitSuccessMessage": "感谢您完成调查!",
|
||||
"submitErrorMessage": "提交调查失败。请重试。",
|
||||
"submitErrorLog": "提交调查失败",
|
||||
"fetchErrorLog": "获取调查失败"
|
||||
}
|
||||
@@ -79,5 +79,12 @@
|
||||
"close": "关闭",
|
||||
"cannotMoveStatus": "无法移动状态",
|
||||
"cannotMoveStatusMessage": "无法移动此状态,因为这会使\"{{categoryName}}\"类别为空。每个类别必须至少有一个状态。",
|
||||
"ok": "确定"
|
||||
"ok": "确定",
|
||||
"clearSort": "清除排序",
|
||||
"sortAscending": "升序排列",
|
||||
"sortDescending": "降序排列",
|
||||
"sortByField": "按{{field}}排序",
|
||||
"ascendingOrder": "升序",
|
||||
"descendingOrder": "降序",
|
||||
"currentSort": "当前排序:{{field}} {{order}}"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
};
|
||||
264
worklenz-frontend/src/components/survey/SurveyPromptModal.tsx
Normal file
264
worklenz-frontend/src/components/survey/SurveyPromptModal.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
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 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>
|
||||
);
|
||||
};
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
2
worklenz-frontend/src/components/survey/index.ts
Normal file
2
worklenz-frontend/src/components/survey/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { SurveyPromptModal } from './SurveyPromptModal';
|
||||
export { SurveySettingsCard } from './SurveySettingsCard';
|
||||
@@ -3,7 +3,7 @@ import { Button, Divider, Flex, Popconfirm, Typography, Space } from '@/shared/a
|
||||
import { colors } from '@/styles/colors';
|
||||
import { ITaskLogViewModel } from '@/types/tasks/task-log-view.types';
|
||||
import SingleAvatar from '@/components/common/single-avatar/single-avatar';
|
||||
import { formatDateTimeWithLocale } from '@/utils/format-date-time-with-locale';
|
||||
import { formatDateTimeWithUserTimezone } from '@/utils/format-date-time-with-user-timezone';
|
||||
import { calculateTimeGap } from '@/utils/calculate-time-gap';
|
||||
import './time-log-item.css';
|
||||
import { taskTimeLogsApiService } from '@/api/tasks/task-time-logs.api.service';
|
||||
@@ -101,7 +101,7 @@ const TimeLogItem = ({ log, onDelete }: TimeLogItemProps) => {
|
||||
{renderLoggedByTimer()} {calculateTimeGap(created_at || '')}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{formatDateTimeWithLocale(created_at || '')}
|
||||
{formatDateTimeWithUserTimezone(created_at || '', currentSession?.timezone_name)}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
{renderActionButtons()}
|
||||
|
||||
@@ -364,7 +364,7 @@ interface ReporterColumnProps {
|
||||
export const ReporterColumn: React.FC<ReporterColumnProps> = memo(({ width, reporter }) => (
|
||||
<div className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700" style={{ width }}>
|
||||
{reporter ? (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{reporter}</span>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 truncate" title={reporter}>{reporter}</span>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400 dark:text-gray-500 whitespace-nowrap">-</span>
|
||||
)}
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
EyeOutlined,
|
||||
InboxOutlined,
|
||||
CheckOutlined,
|
||||
SortAscendingOutlined,
|
||||
SortDescendingOutlined,
|
||||
} from '@/shared/antd-imports';
|
||||
import { RootState } from '@/app/store';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
@@ -30,6 +32,12 @@ import {
|
||||
setArchived as setTaskManagementArchived,
|
||||
toggleArchived as toggleTaskManagementArchived,
|
||||
selectArchived,
|
||||
setSort,
|
||||
setSortField,
|
||||
setSortOrder,
|
||||
selectSort,
|
||||
selectSortField,
|
||||
selectSortOrder,
|
||||
} from '@/features/task-management/task-management.slice';
|
||||
import {
|
||||
setCurrentGrouping,
|
||||
@@ -44,11 +52,13 @@ import {
|
||||
setLabels,
|
||||
setSearch,
|
||||
setPriorities,
|
||||
setFields,
|
||||
} from '@/features/tasks/tasks.slice';
|
||||
import { getTeamMembers } from '@/features/team-members/team-members.slice';
|
||||
import { ITaskPriority } from '@/types/tasks/taskPriority.types';
|
||||
import { ITaskListColumn } from '@/types/tasks/taskList.types';
|
||||
import { IGroupBy } from '@/features/tasks/tasks.slice';
|
||||
import { ITaskListSortableColumn } from '@/types/tasks/taskListFilters.types';
|
||||
// --- Enhanced Kanban imports ---
|
||||
import {
|
||||
setGroupBy as setKanbanGroupBy,
|
||||
@@ -84,6 +94,12 @@ const FILTER_DEBOUNCE_DELAY = 300; // ms
|
||||
const SEARCH_DEBOUNCE_DELAY = 500; // ms
|
||||
const MAX_FILTER_OPTIONS = 100;
|
||||
|
||||
// Sort order enum
|
||||
enum SORT_ORDER {
|
||||
ASCEND = 'ascend',
|
||||
DESCEND = 'descend',
|
||||
}
|
||||
|
||||
// Limit options to prevent UI lag
|
||||
|
||||
// Optimized selectors with proper transformation logic
|
||||
@@ -740,6 +756,192 @@ const SearchFilter: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
// Sort Dropdown Component - Simplified version using task-management slice
|
||||
const SortDropdown: React.FC<{ themeClasses: any; isDarkMode: boolean }> = ({
|
||||
themeClasses,
|
||||
isDarkMode,
|
||||
}) => {
|
||||
const { t } = useTranslation('task-list-filters');
|
||||
const dispatch = useAppDispatch();
|
||||
const { projectId } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
// Get current sort state from task-management slice
|
||||
const currentSortField = useAppSelector(selectSortField);
|
||||
const currentSortOrder = useAppSelector(selectSortOrder);
|
||||
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close dropdown on outside click
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, [open]);
|
||||
|
||||
const sortFieldsList = [
|
||||
{ label: t('taskText'), key: 'name' },
|
||||
{ label: t('statusText'), key: 'status' },
|
||||
{ label: t('priorityText'), key: 'priority' },
|
||||
{ label: t('startDateText'), key: 'start_date' },
|
||||
{ label: t('endDateText'), key: 'end_date' },
|
||||
{ label: t('completedDateText'), key: 'completed_at' },
|
||||
{ label: t('createdDateText'), key: 'created_at' },
|
||||
{ label: t('lastUpdatedText'), key: 'updated_at' },
|
||||
];
|
||||
|
||||
const handleSortFieldChange = (fieldKey: string) => {
|
||||
// If clicking the same field, toggle order, otherwise set new field with ASC
|
||||
if (currentSortField === fieldKey) {
|
||||
const newOrder = currentSortOrder === 'ASC' ? 'DESC' : 'ASC';
|
||||
dispatch(setSort({ field: fieldKey, order: newOrder }));
|
||||
} else {
|
||||
dispatch(setSort({ field: fieldKey, order: 'ASC' }));
|
||||
}
|
||||
|
||||
// Fetch updated tasks
|
||||
if (projectId) {
|
||||
dispatch(fetchTasksV3(projectId));
|
||||
}
|
||||
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const clearSort = () => {
|
||||
dispatch(setSort({ field: '', order: 'ASC' }));
|
||||
if (projectId) {
|
||||
dispatch(fetchTasksV3(projectId));
|
||||
}
|
||||
};
|
||||
|
||||
const isActive = currentSortField !== '';
|
||||
const currentFieldLabel = sortFieldsList.find(f => f.key === currentSortField)?.label;
|
||||
const orderText = currentSortOrder === 'ASC' ? t('ascendingOrder') : t('descendingOrder');
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
{/* Trigger Button - matching FilterDropdown style */}
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
title={
|
||||
isActive
|
||||
? t('currentSort', { field: currentFieldLabel, order: orderText })
|
||||
: t('sortText')
|
||||
}
|
||||
className={`
|
||||
inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-md
|
||||
border transition-all duration-200 ease-in-out
|
||||
${
|
||||
isActive
|
||||
? isDarkMode
|
||||
? 'bg-gray-600 text-white border-gray-500'
|
||||
: 'bg-gray-200 text-gray-800 border-gray-300 font-semibold'
|
||||
: `${themeClasses.buttonBg} ${themeClasses.buttonBorder} ${themeClasses.buttonText}`
|
||||
}
|
||||
hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2
|
||||
${isDarkMode ? 'focus:ring-offset-gray-900' : 'focus:ring-offset-white'}
|
||||
`}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
{currentSortOrder === 'ASC' ? (
|
||||
<SortAscendingOutlined className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<SortDescendingOutlined className="w-3.5 h-3.5" />
|
||||
)}
|
||||
<span className="hidden sm:inline">{t('sortText')}</span>
|
||||
{isActive && currentFieldLabel && (
|
||||
<span className={`text-xs ${isDarkMode ? 'text-gray-300' : 'text-gray-600'} max-w-16 truncate hidden md:inline`}>
|
||||
{currentFieldLabel}
|
||||
</span>
|
||||
)}
|
||||
<DownOutlined
|
||||
className={`w-3.5 h-3.5 transition-transform duration-200 ${open ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Dropdown Panel - matching FilterDropdown style */}
|
||||
{open && (
|
||||
<div
|
||||
className={`absolute top-full left-0 z-50 mt-1 w-64 ${themeClasses.dropdownBg} rounded-md shadow-sm border ${themeClasses.dropdownBorder}`}
|
||||
>
|
||||
{/* Clear Sort Option */}
|
||||
{isActive && (
|
||||
<div className={`p-2 border-b ${themeClasses.dividerBorder}`}>
|
||||
<button
|
||||
onClick={clearSort}
|
||||
className={`w-full text-left px-2 py-1.5 text-xs rounded transition-colors duration-150 ${themeClasses.optionText} ${themeClasses.optionHover}`}
|
||||
>
|
||||
{t('clearSort')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Options List */}
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
<div className="p-0.5">
|
||||
{sortFieldsList.map(sortField => {
|
||||
const isSelected = currentSortField === sortField.key;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={sortField.key}
|
||||
onClick={() => handleSortFieldChange(sortField.key)}
|
||||
className={`
|
||||
w-full flex items-center justify-between gap-2 px-2 py-1.5 text-xs rounded
|
||||
transition-colors duration-150 text-left
|
||||
${
|
||||
isSelected
|
||||
? isDarkMode
|
||||
? 'bg-gray-600 text-white'
|
||||
: 'bg-gray-200 text-gray-800 font-semibold'
|
||||
: `${themeClasses.optionText} ${themeClasses.optionHover}`
|
||||
}
|
||||
`}
|
||||
title={
|
||||
isSelected
|
||||
? t('currentSort', {
|
||||
field: sortField.label,
|
||||
order: orderText
|
||||
}) + ` - ${t('sortDescending')}`
|
||||
: t('sortByField', { field: sortField.label }) + ` - ${t('sortAscending')}`
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate">{sortField.label}</span>
|
||||
{isSelected && (
|
||||
<span className={`text-xs ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>
|
||||
({orderText})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{isSelected ? (
|
||||
currentSortOrder === 'ASC' ? (
|
||||
<SortAscendingOutlined className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<SortDescendingOutlined className="w-3.5 h-3.5" />
|
||||
)
|
||||
) : (
|
||||
<SortAscendingOutlined className="w-3.5 h-3.5 opacity-50" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const LOCAL_STORAGE_KEY = 'worklenz.taskManagement.fields';
|
||||
|
||||
const FieldsDropdown: React.FC<{ themeClasses: any; isDarkMode: boolean }> = ({
|
||||
@@ -1050,14 +1252,20 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
|
||||
};
|
||||
}, [dispatch, projectView]);
|
||||
|
||||
// Get sort fields for active count calculation
|
||||
const sortFields = useAppSelector(state => state.taskReducer.fields);
|
||||
const taskManagementSortField = useAppSelector(selectSortField);
|
||||
|
||||
// Calculate active filters count - memoized to prevent unnecessary recalculations
|
||||
const calculatedActiveFiltersCount = useMemo(() => {
|
||||
const count = filterSections.reduce(
|
||||
(acc, section) => (section.id === 'groupBy' ? acc : acc + section.selectedValues.length),
|
||||
0
|
||||
);
|
||||
return count + (searchValue ? 1 : 0);
|
||||
}, [filterSections, searchValue]);
|
||||
const sortFieldsCount = position === 'list' ? sortFields.length : 0;
|
||||
const taskManagementSortCount = position === 'list' && taskManagementSortField ? 1 : 0;
|
||||
return count + (searchValue ? 1 : 0) + sortFieldsCount + taskManagementSortCount;
|
||||
}, [filterSections, searchValue, sortFields, taskManagementSortField, position]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeFiltersCount !== calculatedActiveFiltersCount) {
|
||||
@@ -1231,6 +1439,12 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
|
||||
// Clear priority filters
|
||||
dispatch(setPriorities([]));
|
||||
|
||||
// Clear sort fields
|
||||
dispatch(setFields([]));
|
||||
|
||||
// Clear sort from task-management slice
|
||||
dispatch(setSort({ field: '', order: 'ASC' }));
|
||||
|
||||
// Clear archived state based on position
|
||||
if (position === 'list') {
|
||||
dispatch(setTaskManagementArchived(false));
|
||||
@@ -1276,9 +1490,9 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
|
||||
<div
|
||||
className={`${themeClasses.containerBg} border ${themeClasses.containerBorder} rounded-md p-1.5 shadow-sm ${className}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 min-h-[36px]">
|
||||
{/* Left Section - Main Filters */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2 flex-1 min-w-0">
|
||||
{/* Search */}
|
||||
<SearchFilter
|
||||
value={searchValue}
|
||||
@@ -1287,6 +1501,11 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
|
||||
themeClasses={themeClasses}
|
||||
/>
|
||||
|
||||
{/* Sort Filter Button (for list view) - appears after search */}
|
||||
{position === 'list' && (
|
||||
<SortDropdown themeClasses={themeClasses} isDarkMode={isDarkMode} />
|
||||
)}
|
||||
|
||||
{/* Filter Dropdowns - Only render when data is loaded */}
|
||||
{isDataLoaded ? (
|
||||
filterSectionsData.map(section => (
|
||||
@@ -1316,7 +1535,7 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
|
||||
</div>
|
||||
|
||||
{/* Right Section - Additional Controls */}
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<div className="flex flex-wrap items-center gap-2 ml-auto min-w-0 shrink-0">
|
||||
{/* Active Filters Indicator */}
|
||||
{activeFiltersCount > 0 && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
|
||||
@@ -61,6 +61,10 @@ const accountSetupSlice = createSlice({
|
||||
setSurveySubStep: (state, action: PayloadAction<number>) => {
|
||||
state.surveySubStep = action.payload;
|
||||
},
|
||||
resetSurveyData: (state) => {
|
||||
state.surveyData = {};
|
||||
state.surveySubStep = 0;
|
||||
},
|
||||
resetAccountSetup: () => initialState,
|
||||
},
|
||||
});
|
||||
@@ -74,6 +78,7 @@ export const {
|
||||
setCurrentStep,
|
||||
setSurveyData,
|
||||
setSurveySubStep,
|
||||
resetSurveyData,
|
||||
resetAccountSetup,
|
||||
} = accountSetupSlice.actions;
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { QuestionCircleOutlined } from '@/shared/antd-imports';
|
||||
import { Button, Tooltip } from '@/shared/antd-imports';
|
||||
import React, { memo } from 'react';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import './HelpButton.css';
|
||||
|
||||
const HelpButton = memo(() => {
|
||||
const HelpButton = () => {
|
||||
// localization
|
||||
const { t } = useTranslation('navbar');
|
||||
|
||||
@@ -18,8 +18,6 @@ const HelpButton = memo(() => {
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
|
||||
HelpButton.displayName = 'HelpButton';
|
||||
};
|
||||
|
||||
export default HelpButton;
|
||||
@@ -1,12 +1,12 @@
|
||||
import { UsergroupAddOutlined } from '@/shared/antd-imports';
|
||||
import { Button, Tooltip } from '@/shared/antd-imports';
|
||||
import React, { memo, useCallback } from 'react';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { colors } from '../../../styles/colors';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { toggleInviteMemberDrawer } from '../../../features/settings/member/memberSlice';
|
||||
import { toggleInviteMemberDrawer } from '../../settings/member/memberSlice';
|
||||
|
||||
const InviteButton = memo(() => {
|
||||
const InviteButton = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// localization
|
||||
@@ -21,14 +21,12 @@ const InviteButton = memo(() => {
|
||||
color: colors.skyBlue,
|
||||
borderColor: colors.skyBlue,
|
||||
}}
|
||||
onClick={useCallback(() => dispatch(toggleInviteMemberDrawer()), [dispatch])}
|
||||
onClick={() => dispatch(toggleInviteMemberDrawer())}
|
||||
>
|
||||
{t('invite')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
|
||||
InviteButton.displayName = 'InviteButton';
|
||||
};
|
||||
|
||||
export default InviteButton;
|
||||
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
ClockCircleOutlined,
|
||||
HomeOutlined,
|
||||
MenuOutlined,
|
||||
ProjectOutlined,
|
||||
QuestionCircleOutlined,
|
||||
ReadOutlined,
|
||||
} from '@/shared/antd-imports';
|
||||
import { Button, Card, Dropdown, Flex, MenuProps, Space, Typography } from '@/shared/antd-imports';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { colors } from '../../../styles/colors';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import InviteButton from '../invite/InviteButton';
|
||||
import SwitchTeamButton from '../switchTeam/SwitchTeamButton';
|
||||
// custom css
|
||||
import './mobileMenu.css';
|
||||
|
||||
const MobileMenuButton = () => {
|
||||
// localization
|
||||
const { t } = useTranslation('navbar');
|
||||
|
||||
const navLinks = [
|
||||
{
|
||||
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'] = [
|
||||
{
|
||||
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>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
overlayClassName="mobile-menu-dropdown"
|
||||
menu={{ items: mobileMenu }}
|
||||
placement="bottomRight"
|
||||
trigger={['click']}
|
||||
>
|
||||
<Button className="borderless-icon-btn" icon={<MenuOutlined style={{ fontSize: 20 }} />} />
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileMenuButton;
|
||||
@@ -1,14 +1,14 @@
|
||||
import { memo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import logo from '@/assets/images/worklenz-light-mode.png';
|
||||
import logoDark from '@/assets/images/worklenz-dark-mode.png';
|
||||
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { RootState } from '@/app/store';
|
||||
|
||||
const NavbarLogo = memo(() => {
|
||||
const NavbarLogo = () => {
|
||||
const { t } = useTranslation('navbar');
|
||||
const themeMode = useSelector((state: RootState) => state.themeReducer.mode);
|
||||
|
||||
@@ -23,8 +23,6 @@ const NavbarLogo = memo(() => {
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
|
||||
NavbarLogo.displayName = 'NavbarLogo';
|
||||
};
|
||||
|
||||
export default NavbarLogo;
|
||||
@@ -1,60 +1,55 @@
|
||||
import React, { useEffect, useState, useMemo, memo } from 'react';
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Col, ConfigProvider, Flex, Menu } from '@/shared/antd-imports';
|
||||
import { Col, ConfigProvider, Flex, Menu, MenuProps, Alert } from '@/shared/antd-imports';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import InviteTeamMembers from '../common/invite-team-members/invite-team-members';
|
||||
import InviteTeamMembers from '../../components/common/invite-team-members/invite-team-members';
|
||||
import InviteButton from './invite/InviteButton';
|
||||
import MobileMenuButton from './mobileMenu/MobileMenuButton';
|
||||
import NavbarLogo from './NavbarLogo';
|
||||
import NotificationButton from './NotificationButton';
|
||||
import ProfileButton from './user-profile/ProfileButton';
|
||||
import NavbarLogo from './navbar-logo';
|
||||
import NotificationButton from '../../components/navbar/notifications/notifications-drawer/notification/notification-button';
|
||||
import ProfileButton from './user-profile/profile-button';
|
||||
import SwitchTeamButton from './switchTeam/SwitchTeamButton';
|
||||
import UpgradePlanButton from './upgradePlan/UpgradePlanButton';
|
||||
import NotificationDrawer from './notifications/notifications-drawer/notfication-drawer';
|
||||
import NotificationDrawer from '../../components/navbar/notifications/notifications-drawer/notification/notfication-drawer';
|
||||
|
||||
import { useResponsive } from '@/hooks/useResponsive';
|
||||
import { getJSONFromLocalStorage } from '@/utils/localStorageFunctions';
|
||||
import { navRoutes, NavRoutesType } from '@/lib/navbar/navRoutes';
|
||||
import { navRoutes, NavRoutesType } from './navRoutes';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { authApiService } from '@/api/auth/auth.api.service';
|
||||
import { ISUBSCRIPTION_TYPE } from '@/shared/constants';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import TimerButton from './timers/timer-button';
|
||||
import HelpButton from './help/HelpButton';
|
||||
|
||||
const Navbar = memo(() => {
|
||||
const Navbar = () => {
|
||||
const [current, setCurrent] = useState<string>('home');
|
||||
const authService = useAuthService();
|
||||
const currentSession = authService.getCurrentSession();
|
||||
const currentSession = useAuthService().getCurrentSession();
|
||||
const [daysUntilExpiry, setDaysUntilExpiry] = useState<number | null>(null);
|
||||
|
||||
const location = useLocation();
|
||||
const { isDesktop, isMobile, isTablet } = useResponsive();
|
||||
const { t } = useTranslation('navbar');
|
||||
const authService = useAuthService();
|
||||
const [navRoutesList, setNavRoutesList] = useState<NavRoutesType[]>(navRoutes);
|
||||
const [isOwnerOrAdmin, setIsOwnerOrAdmin] = useState<boolean>(authService.isOwnerOrAdmin());
|
||||
const showUpgradeTypes = useMemo(() => [ISUBSCRIPTION_TYPE.TRIAL], []);
|
||||
const showUpgradeTypes = [ISUBSCRIPTION_TYPE.TRIAL];
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
authApiService
|
||||
.verify()
|
||||
.then(authorizeResponse => {
|
||||
if (mounted && authorizeResponse.authenticated) {
|
||||
if (authorizeResponse.authenticated) {
|
||||
authService.setCurrentSession(authorizeResponse.user);
|
||||
setIsOwnerOrAdmin(!!(authorizeResponse.user.is_admin || authorizeResponse.user.owner));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
if (mounted) {
|
||||
logger.error('Error during authorization', error);
|
||||
}
|
||||
logger.error('Error during authorization', error);
|
||||
});
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [authService]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const storedNavRoutesList: NavRoutesType[] = getJSONFromLocalStorage('navRoutes') || navRoutes;
|
||||
@@ -188,8 +183,6 @@ const Navbar = memo(() => {
|
||||
{createPortal(<NotificationDrawer />, document.body, 'notification-drawer')}
|
||||
</Col>
|
||||
);
|
||||
});
|
||||
|
||||
Navbar.displayName = 'Navbar';
|
||||
};
|
||||
|
||||
export default Navbar;
|
||||
@@ -61,7 +61,7 @@ const notificationSlice = createSlice({
|
||||
initialState,
|
||||
reducers: {
|
||||
toggleDrawer: state => {
|
||||
state.isDrawerOpen = !state.isDrawerOpen;
|
||||
state.isDrawerOpen ? (state.isDrawerOpen = false) : (state.isDrawerOpen = true);
|
||||
},
|
||||
setNotificationType: (state, action) => {
|
||||
state.notificationType = action.payload;
|
||||
@@ -76,12 +76,12 @@ const notificationSlice = createSlice({
|
||||
state.invitations = action.payload;
|
||||
state.invitationsCount = action.payload.length;
|
||||
|
||||
state._dataset = state._dataset.concat(
|
||||
state.invitations.map(invitation => ({
|
||||
state.invitations.map(invitation => {
|
||||
state._dataset.push({
|
||||
type: 'invitation',
|
||||
data: invitation,
|
||||
}))
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
builder.addCase(fetchInvitations.rejected, state => {
|
||||
state.loading = false;
|
||||
@@ -94,12 +94,12 @@ const notificationSlice = createSlice({
|
||||
state.notifications = action.payload;
|
||||
state.notificationsCount = action.payload.length;
|
||||
|
||||
state._dataset = state._dataset.concat(
|
||||
state.notifications.map(notification => ({
|
||||
state.notifications.map(notification => {
|
||||
state._dataset.push({
|
||||
type: 'notification',
|
||||
data: notification,
|
||||
}))
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
builder.addCase(fetchUnreadCount.pending, state => {
|
||||
state.unreadNotificationsCount = 0;
|
||||
|
||||
@@ -1,20 +1,33 @@
|
||||
// Ant Design Icons
|
||||
import { BankOutlined, CaretDownFilled, CheckCircleFilled } from '@/shared/antd-imports';
|
||||
|
||||
// Ant Design Components
|
||||
import { Card, Divider, Dropdown, Flex, Tooltip, Typography } from '@/shared/antd-imports';
|
||||
|
||||
// Redux Hooks
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
|
||||
// Redux Actions
|
||||
import { fetchTeams, setActiveTeam } from '@/features/teams/teamSlice';
|
||||
import { verifyAuthentication } from '@/features/auth/authSlice';
|
||||
import { setUser } from '@/features/user/userSlice';
|
||||
|
||||
// Hooks & Services
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { createAuthService } from '@/services/auth/auth.service';
|
||||
import CustomAvatar from '@/components/CustomAvatar';
|
||||
import { colors } from '@/styles/colors';
|
||||
import './SwitchTeamButton.css';
|
||||
import { useEffect, memo, useCallback, useMemo } from 'react';
|
||||
|
||||
const SwitchTeamButton = memo(() => {
|
||||
// Components
|
||||
import CustomAvatar from '@/components/CustomAvatar';
|
||||
|
||||
// Styles
|
||||
import { colors } from '@/styles/colors';
|
||||
import './switchTeam.css';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const SwitchTeamButton = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const navigate = useNavigate();
|
||||
const authService = createAuthService(navigate);
|
||||
@@ -30,39 +43,32 @@ const SwitchTeamButton = memo(() => {
|
||||
dispatch(fetchTeams());
|
||||
}, [dispatch]);
|
||||
|
||||
const isActiveTeam = useCallback(
|
||||
(teamId: string): boolean => {
|
||||
if (!teamId || !session?.team_id) return false;
|
||||
return teamId === session.team_id;
|
||||
},
|
||||
[session?.team_id]
|
||||
);
|
||||
const isActiveTeam = (teamId: string): boolean => {
|
||||
if (!teamId || !session?.team_id) return false;
|
||||
return teamId === session.team_id;
|
||||
};
|
||||
|
||||
const handleVerifyAuth = useCallback(async () => {
|
||||
const handleVerifyAuth = async () => {
|
||||
const result = await dispatch(verifyAuthentication()).unwrap();
|
||||
if (result.authenticated) {
|
||||
dispatch(setUser(result.user));
|
||||
authService.setCurrentSession(result.user);
|
||||
}
|
||||
}, [dispatch, authService]);
|
||||
};
|
||||
|
||||
const handleTeamSelect = useCallback(
|
||||
async (id: string) => {
|
||||
if (!id) return;
|
||||
const handleTeamSelect = async (id: string) => {
|
||||
if (!id) return;
|
||||
|
||||
await dispatch(setActiveTeam(id));
|
||||
await handleVerifyAuth();
|
||||
window.location.reload();
|
||||
},
|
||||
[dispatch, handleVerifyAuth]
|
||||
);
|
||||
await dispatch(setActiveTeam(id));
|
||||
await handleVerifyAuth();
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const renderTeamCard = useCallback(
|
||||
(team: any, index: number) => (
|
||||
const renderTeamCard = (team: any, index: number) => (
|
||||
<Card
|
||||
className="switch-team-card"
|
||||
onClick={() => handleTeamSelect(team.id)}
|
||||
variant='borderless'
|
||||
bordered={false}
|
||||
style={{ width: 230 }}
|
||||
>
|
||||
<Flex vertical>
|
||||
@@ -86,19 +92,14 @@ const SwitchTeamButton = memo(() => {
|
||||
{index < teamsList.length - 1 && <Divider style={{ margin: 0 }} />}
|
||||
</Flex>
|
||||
</Card>
|
||||
),
|
||||
[handleTeamSelect, isActiveTeam, teamsList.length]
|
||||
);
|
||||
|
||||
const dropdownItems = useMemo(
|
||||
() =>
|
||||
teamsList?.map((team, index) => ({
|
||||
key: team.id || '',
|
||||
label: renderTeamCard(team, index),
|
||||
type: 'item' as const,
|
||||
})) || [],
|
||||
[teamsList, renderTeamCard]
|
||||
);
|
||||
const dropdownItems =
|
||||
teamsList?.map((team, index) => ({
|
||||
key: team.id || '',
|
||||
label: renderTeamCard(team, index),
|
||||
type: 'item' as const,
|
||||
})) || [];
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
@@ -131,8 +132,6 @@ const SwitchTeamButton = memo(() => {
|
||||
</Tooltip>
|
||||
</Dropdown>
|
||||
);
|
||||
});
|
||||
|
||||
SwitchTeamButton.displayName = 'SwitchTeamButton';
|
||||
};
|
||||
|
||||
export default SwitchTeamButton;
|
||||
@@ -1,16 +1,6 @@
|
||||
import { ClockCircleOutlined, StopOutlined } from '@/shared/antd-imports';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Dropdown,
|
||||
List,
|
||||
Tooltip,
|
||||
Typography,
|
||||
Space,
|
||||
Divider,
|
||||
theme,
|
||||
} from '@/shared/antd-imports';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Badge, Button, Dropdown, List, Tooltip, Typography, Space, Divider, theme } from '@/shared/antd-imports';
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { taskTimeLogsApiService, IRunningTimer } from '@/api/tasks/task-time-logs.api.service';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Button, Tooltip } from '@/shared/antd-imports';
|
||||
import React, { memo, useCallback } from 'react';
|
||||
import React from 'react';
|
||||
import { colors } from '../../../styles/colors';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
|
||||
const UpgradePlanButton = memo(() => {
|
||||
const UpgradePlanButton = () => {
|
||||
// localization
|
||||
const { t } = useTranslation('navbar');
|
||||
const navigate = useNavigate();
|
||||
@@ -22,14 +22,12 @@ const UpgradePlanButton = memo(() => {
|
||||
}}
|
||||
size="small"
|
||||
type="text"
|
||||
onClick={useCallback(() => navigate('/worklenz/admin-center/billing'), [navigate])}
|
||||
onClick={() => navigate('/worklenz/admin-center/billing')}
|
||||
>
|
||||
{t('upgradePlan')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
|
||||
UpgradePlanButton.displayName = 'UpgradePlanButton';
|
||||
};
|
||||
|
||||
export default UpgradePlanButton;
|
||||
@@ -9,17 +9,17 @@ import { RootState } from '@/app/store';
|
||||
|
||||
import { getRole } from '@/utils/session-helper';
|
||||
|
||||
import './ProfileDropdown.css';
|
||||
import './ProfileButton.css';
|
||||
import './profile-dropdown.css';
|
||||
import './profile-button.css';
|
||||
import SingleAvatar from '@/components/common/single-avatar/single-avatar';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface ProfileButtonProps {
|
||||
isOwnerOrAdmin: boolean;
|
||||
}
|
||||
|
||||
const ProfileButton = memo(({ isOwnerOrAdmin }: ProfileButtonProps) => {
|
||||
const ProfileButton = ({ isOwnerOrAdmin }: ProfileButtonProps) => {
|
||||
const { t } = useTranslation('navbar');
|
||||
const authService = useAuthService();
|
||||
const currentSession = useAppSelector((state: RootState) => state.userReducer);
|
||||
@@ -27,15 +27,11 @@ const ProfileButton = memo(({ isOwnerOrAdmin }: ProfileButtonProps) => {
|
||||
const role = getRole();
|
||||
const themeMode = useAppSelector((state: RootState) => state.themeReducer.mode);
|
||||
|
||||
const getLinkStyle = useMemo(
|
||||
() => ({
|
||||
color: themeMode === 'dark' ? '#ffffffd9' : '#181818',
|
||||
}),
|
||||
[themeMode]
|
||||
);
|
||||
const getLinkStyle = () => ({
|
||||
color: themeMode === 'dark' ? '#ffffffd9' : '#181818',
|
||||
});
|
||||
|
||||
const profile: MenuProps['items'] = useMemo(
|
||||
() => [
|
||||
const profile: MenuProps['items'] = [
|
||||
{
|
||||
key: '1',
|
||||
label: (
|
||||
@@ -85,22 +81,20 @@ const ProfileButton = memo(({ isOwnerOrAdmin }: ProfileButtonProps) => {
|
||||
style={{ width: 230 }}
|
||||
>
|
||||
{isOwnerOrAdmin && (
|
||||
<Link to="/worklenz/admin-center/overview" style={getLinkStyle}>
|
||||
<Link to="/worklenz/admin-center/overview" style={getLinkStyle()}>
|
||||
{t('adminCenter')}
|
||||
</Link>
|
||||
)}
|
||||
<Link to="/worklenz/settings/profile" style={getLinkStyle}>
|
||||
<Link to="/worklenz/settings/profile" style={getLinkStyle()}>
|
||||
{t('settings')}
|
||||
</Link>
|
||||
<Link to="/auth/logging-out" style={getLinkStyle}>
|
||||
<Link to="/auth/logging-out" style={getLinkStyle()}>
|
||||
{t('logOut')}
|
||||
</Link>
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
],
|
||||
[currentSession, role, themeMode, getLinkStyle, isOwnerOrAdmin, t]
|
||||
);
|
||||
];
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
@@ -129,8 +123,6 @@ const ProfileButton = memo(({ isOwnerOrAdmin }: ProfileButtonProps) => {
|
||||
</Tooltip>
|
||||
</Dropdown>
|
||||
);
|
||||
});
|
||||
|
||||
ProfileButton.displayName = 'ProfileButton';
|
||||
};
|
||||
|
||||
export default ProfileButton;
|
||||
@@ -64,6 +64,9 @@ const initialState: TaskManagementState = {
|
||||
loadingColumns: false,
|
||||
columns: [],
|
||||
customColumns: [],
|
||||
// Add sort-related state
|
||||
sortField: '',
|
||||
sortOrder: 'ASC',
|
||||
};
|
||||
|
||||
// Async thunk to fetch tasks from API
|
||||
@@ -233,12 +236,16 @@ export const fetchTasksV3 = createAsyncThunk(
|
||||
// Get archived state from task management slice
|
||||
const archivedState = state.taskManagement.archived;
|
||||
|
||||
// Get sort state from task management slice
|
||||
const sortField = state.taskManagement.sortField;
|
||||
const sortOrder = state.taskManagement.sortOrder;
|
||||
|
||||
const config: ITaskListConfigV2 = {
|
||||
id: projectId,
|
||||
archived: archivedState,
|
||||
group: currentGrouping || '',
|
||||
field: '',
|
||||
order: '',
|
||||
field: sortField,
|
||||
order: sortOrder,
|
||||
search: searchValue,
|
||||
statuses: '',
|
||||
members: selectedAssignees,
|
||||
@@ -737,6 +744,16 @@ const taskManagementSlice = createSlice({
|
||||
toggleArchived: (state) => {
|
||||
state.archived = !state.archived;
|
||||
},
|
||||
setSortField: (state, action: PayloadAction<string>) => {
|
||||
state.sortField = action.payload;
|
||||
},
|
||||
setSortOrder: (state, action: PayloadAction<'ASC' | 'DESC'>) => {
|
||||
state.sortOrder = action.payload;
|
||||
},
|
||||
setSort: (state, action: PayloadAction<{ field: string; order: 'ASC' | 'DESC' }>) => {
|
||||
state.sortField = action.payload.field;
|
||||
state.sortOrder = action.payload.order;
|
||||
},
|
||||
resetTaskManagement: state => {
|
||||
state.loading = false;
|
||||
state.error = null;
|
||||
@@ -745,6 +762,8 @@ const taskManagementSlice = createSlice({
|
||||
state.selectedPriorities = [];
|
||||
state.search = '';
|
||||
state.archived = false;
|
||||
state.sortField = '';
|
||||
state.sortOrder = 'ASC';
|
||||
state.ids = [];
|
||||
state.entities = {};
|
||||
},
|
||||
@@ -1129,6 +1148,9 @@ export const {
|
||||
setSearch,
|
||||
setArchived,
|
||||
toggleArchived,
|
||||
setSortField,
|
||||
setSortOrder,
|
||||
setSort,
|
||||
resetTaskManagement,
|
||||
toggleTaskExpansion,
|
||||
addSubtaskToParent,
|
||||
@@ -1160,6 +1182,9 @@ export const selectLoading = (state: RootState) => state.taskManagement.loading;
|
||||
export const selectError = (state: RootState) => state.taskManagement.error;
|
||||
export const selectSelectedPriorities = (state: RootState) => state.taskManagement.selectedPriorities;
|
||||
export const selectSearch = (state: RootState) => state.taskManagement.search;
|
||||
export const selectSortField = (state: RootState) => state.taskManagement.sortField;
|
||||
export const selectSortOrder = (state: RootState) => state.taskManagement.sortOrder;
|
||||
export const selectSort = (state: RootState) => ({ field: state.taskManagement.sortField, order: state.taskManagement.sortOrder });
|
||||
export const selectSubtaskLoading = (state: RootState, taskId: string) => state.taskManagement.loadingSubtasks[taskId] || false;
|
||||
|
||||
// Memoized selectors to prevent unnecessary re-renders
|
||||
|
||||
49
worklenz-frontend/src/hooks/useSurveyStatus.ts
Normal file
49
worklenz-frontend/src/hooks/useSurveyStatus.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { surveyApiService } from '@/api/survey/survey.api.service';
|
||||
import logger from '@/utils/errorLogger';
|
||||
|
||||
export interface UseSurveyStatusResult {
|
||||
hasCompletedSurvey: boolean | null;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useSurveyStatus = (): UseSurveyStatusResult => {
|
||||
const [hasCompletedSurvey, setHasCompletedSurvey] = useState<boolean | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const checkSurveyStatus = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await surveyApiService.checkAccountSetupSurveyStatus();
|
||||
|
||||
if (response.done) {
|
||||
setHasCompletedSurvey(response.body.is_completed);
|
||||
} else {
|
||||
setHasCompletedSurvey(false);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to check survey status', err);
|
||||
setError(err as Error);
|
||||
// Assume not completed if there's an error
|
||||
setHasCompletedSurvey(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
checkSurveyStatus();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
hasCompletedSurvey,
|
||||
loading,
|
||||
error,
|
||||
refetch: checkSurveyStatus
|
||||
};
|
||||
};
|
||||
@@ -3,9 +3,9 @@ import { Outlet } from 'react-router-dom';
|
||||
import { memo, useMemo, useEffect, useRef } from 'react';
|
||||
import { useMediaQuery } from 'react-responsive';
|
||||
|
||||
import Navbar from '@/components/navbar/Navbar';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { colors } from '@/styles/colors';
|
||||
import Navbar from '../features/navbar/navbar';
|
||||
import { useAppSelector } from '../hooks/useAppSelector';
|
||||
import { colors } from '../styles/colors';
|
||||
|
||||
import { useRenderPerformance } from '@/utils/performance';
|
||||
import { DynamicCSSLoader, LayoutStabilizer } from '@/utils/css-optimizations';
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Col, ConfigProvider, Layout } from '@/shared/antd-imports';
|
||||
import { useEffect, useState } from 'react';
|
||||
import Navbar from '@/components/navbar/Navbar';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||
import ReportingSider from '@/pages/reporting/sidebar/reporting-sider';
|
||||
import Navbar from '../features/navbar/navbar';
|
||||
import { useAppSelector } from '../hooks/useAppSelector';
|
||||
import { colors } from '../styles/colors';
|
||||
import { themeWiseColor } from '../utils/themeWiseColor';
|
||||
import ReportingSider from '../pages/reporting/sidebar/reporting-sider';
|
||||
import { Outlet, useNavigate } from 'react-router-dom';
|
||||
import ReportingCollapsedButton from '@/pages/reporting/sidebar/reporting-collapsed-button';
|
||||
import ReportingCollapsedButton from '../pages/reporting/sidebar/reporting-collapsed-button';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { reportingApiService } from '@/api/reporting/reporting.api.service';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
|
||||
@@ -80,6 +80,7 @@ const AccountSetup: React.FC = () => {
|
||||
const themeMode = useSelector((state: RootState) => state.themeReducer.mode);
|
||||
|
||||
const [surveyId, setSurveyId] = React.useState<string | null>(null);
|
||||
const [isSkipping, setIsSkipping] = React.useState(false);
|
||||
|
||||
const isDarkMode = themeMode === 'dark';
|
||||
// Helper to extract organization name from email or fallback to user name
|
||||
@@ -214,6 +215,18 @@ const AccountSetup: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkipMembers = async () => {
|
||||
try {
|
||||
setIsSkipping(true);
|
||||
// Bypass all validation and complete setup without team members
|
||||
await completeAccountSetup(true);
|
||||
} catch (error) {
|
||||
logger.error('Failed to skip members and complete setup', error);
|
||||
} finally {
|
||||
setIsSkipping(false);
|
||||
}
|
||||
};
|
||||
|
||||
const completeAccountSetupWithTemplate = async () => {
|
||||
try {
|
||||
await saveSurveyData(); // Save survey data first
|
||||
@@ -592,9 +605,11 @@ const AccountSetup: React.FC = () => {
|
||||
type="link"
|
||||
className="p-0 font-medium"
|
||||
style={{ color: token.colorTextTertiary }}
|
||||
onClick={() => completeAccountSetup(true)}
|
||||
onClick={handleSkipMembers}
|
||||
loading={isSkipping}
|
||||
disabled={isSkipping}
|
||||
>
|
||||
{t('skipForNow')}
|
||||
{isSkipping ? t('skipping') : t('skipForNow')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -27,6 +27,7 @@ const SIDEBAR_MAX_WIDTH = 400;
|
||||
|
||||
// Lazy load heavy components
|
||||
const TaskDrawer = React.lazy(() => import('@/components/task-drawer/task-drawer'));
|
||||
const SurveyPromptModal = React.lazy(() => import('@/components/survey/SurveyPromptModal').then(m => ({ default: m.SurveyPromptModal })));
|
||||
|
||||
const HomePage = memo(() => {
|
||||
const dispatch = useAppDispatch();
|
||||
@@ -142,6 +143,11 @@ const HomePage = memo(() => {
|
||||
document.body,
|
||||
'project-drawer'
|
||||
)}
|
||||
|
||||
{/* Survey Modal - only shown to users who haven't completed it */}
|
||||
<Suspense fallback={null}>
|
||||
<SurveyPromptModal />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -114,6 +114,9 @@ export interface TaskManagementState {
|
||||
loadingColumns: boolean;
|
||||
columns: ITaskListColumn[];
|
||||
customColumns: ITaskListColumn[];
|
||||
// Add sort-related state
|
||||
sortField: string;
|
||||
sortOrder: 'ASC' | 'DESC';
|
||||
}
|
||||
|
||||
export interface TaskGroupsState {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { format } from 'date-fns';
|
||||
import { enUS, es, pt } from 'date-fns/locale';
|
||||
import { getLanguageFromLocalStorage } from './language-utils';
|
||||
|
||||
/**
|
||||
* Formats a date/time string using the user's profile timezone
|
||||
* This ensures consistency between time logs display and reporting filters
|
||||
*
|
||||
* @param dateString - The date string to format (typically in UTC)
|
||||
* @param userTimezone - The user's timezone from their profile (e.g., 'America/New_York')
|
||||
* @returns Formatted date string in user's timezone
|
||||
*/
|
||||
export const formatDateTimeWithUserTimezone = (
|
||||
dateString: string,
|
||||
userTimezone?: string | null
|
||||
): string => {
|
||||
if (!dateString) return '';
|
||||
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
|
||||
// If timezone is provided, use it for formatting
|
||||
if (userTimezone && userTimezone !== 'UTC') {
|
||||
// Use the browser's toLocaleString with timezone option
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
timeZone: userTimezone,
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: true
|
||||
};
|
||||
|
||||
// Get the appropriate locale
|
||||
const localeString = getLanguageFromLocalStorage();
|
||||
const localeMap = {
|
||||
'en': 'en-US',
|
||||
'es': 'es-ES',
|
||||
'pt': 'pt-PT'
|
||||
};
|
||||
const locale = localeMap[localeString as keyof typeof localeMap] || 'en-US';
|
||||
|
||||
return date.toLocaleString(locale, options);
|
||||
}
|
||||
|
||||
// Fallback to date-fns formatting for UTC or when no timezone
|
||||
const localeString = getLanguageFromLocalStorage();
|
||||
const locale = localeString === 'en' ? enUS : localeString === 'es' ? es : pt;
|
||||
return format(date, 'MMM d, yyyy, h:mm:ss a', { locale });
|
||||
} catch (error) {
|
||||
console.error('Error formatting date with user timezone:', error);
|
||||
// Fallback to original date string if formatting fails
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a date is "yesterday" in the user's timezone
|
||||
* This is used to ensure consistency with reporting filters
|
||||
*
|
||||
* @param dateString - The date string to check
|
||||
* @param userTimezone - The user's timezone from their profile
|
||||
* @returns true if the date is yesterday in user's timezone
|
||||
*/
|
||||
export const isYesterdayInUserTimezone = (dateString: string, userTimezone?: string | null): boolean => {
|
||||
if (!dateString || !userTimezone) return false;
|
||||
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
|
||||
// Get current date in user's timezone
|
||||
const nowInTimezone = new Date().toLocaleString('en-US', { timeZone: userTimezone });
|
||||
const now = new Date(nowInTimezone);
|
||||
|
||||
// Get yesterday in user's timezone
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
// Convert the input date to user's timezone
|
||||
const dateInTimezone = new Date(date.toLocaleString('en-US', { timeZone: userTimezone }));
|
||||
|
||||
// Compare dates (ignoring time)
|
||||
return (
|
||||
dateInTimezone.getFullYear() === yesterday.getFullYear() &&
|
||||
dateInTimezone.getMonth() === yesterday.getMonth() &&
|
||||
dateInTimezone.getDate() === yesterday.getDate()
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error checking if date is yesterday:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user