Compare commits
47 Commits
chore/adde
...
feature/cu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da3728024d | ||
|
|
69ec445a8a | ||
|
|
ad7eb505b5 | ||
|
|
d33a7db253 | ||
|
|
9c4293e7a9 | ||
|
|
7c42087854 | ||
|
|
14c89dec24 | ||
|
|
b1bdf0ac11 | ||
|
|
7635676289 | ||
|
|
2bd6c19c13 | ||
|
|
374595261f | ||
|
|
b6c056dd1a | ||
|
|
81e1872c1f | ||
|
|
5cce3bc613 | ||
|
|
c53ab511bf | ||
|
|
7b9a16fd72 | ||
|
|
8830af2cbb | ||
|
|
b915de2b93 | ||
|
|
29b8c1b2af | ||
|
|
c2b231d5cc | ||
|
|
53a28cf489 | ||
|
|
e8ccc2a533 | ||
|
|
f24c0d8955 | ||
|
|
01a580d992 | ||
|
|
c2e670c9a2 | ||
|
|
25042baf71 | ||
|
|
e8d21ee187 | ||
|
|
a8d1446b0d | ||
|
|
2082934cd5 | ||
|
|
4debcd6aa5 | ||
|
|
76adb89caf | ||
|
|
703a6425fe | ||
|
|
e2c9e19b83 | ||
|
|
e2a749e0b6 | ||
|
|
2c0b0ac4c5 | ||
|
|
dd511b236f | ||
|
|
2c860b0cc8 | ||
|
|
1e6045c534 | ||
|
|
2a9e12a495 | ||
|
|
fd2fc793df | ||
|
|
f3b7479770 | ||
|
|
61461bb776 | ||
|
|
2a7019c64c | ||
|
|
5b1cbb0c46 | ||
|
|
7c04598264 | ||
|
|
5222d75064 | ||
|
|
2587b8afd9 |
@@ -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`
|
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -36,6 +36,8 @@ lerna-debug.log*
|
|||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
.idea/
|
.idea/
|
||||||
|
.cursor/
|
||||||
|
.claude/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.suo
|
*.suo
|
||||||
*.ntvs*
|
*.ntvs*
|
||||||
@@ -76,4 +78,7 @@ $RECYCLE.BIN/
|
|||||||
# TypeScript
|
# TypeScript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Claude
|
||||||
|
CLAUDE.md
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
-- Test script to verify the sort order constraint fix
|
|
||||||
|
|
||||||
-- Test the helper function
|
|
||||||
SELECT get_sort_column_name('status'); -- Should return 'status_sort_order'
|
|
||||||
SELECT get_sort_column_name('priority'); -- Should return 'priority_sort_order'
|
|
||||||
SELECT get_sort_column_name('phase'); -- Should return 'phase_sort_order'
|
|
||||||
SELECT get_sort_column_name('members'); -- Should return 'member_sort_order'
|
|
||||||
SELECT get_sort_column_name('unknown'); -- Should return 'status_sort_order' (default)
|
|
||||||
|
|
||||||
-- Test bulk update function (example - would need real project_id and task_ids)
|
|
||||||
/*
|
|
||||||
SELECT update_task_sort_orders_bulk(
|
|
||||||
'[
|
|
||||||
{"task_id": "example-uuid", "sort_order": 1, "status_id": "status-uuid"},
|
|
||||||
{"task_id": "example-uuid-2", "sort_order": 2, "status_id": "status-uuid"}
|
|
||||||
]'::json,
|
|
||||||
'status'
|
|
||||||
);
|
|
||||||
*/
|
|
||||||
|
|
||||||
-- Verify that sort_order constraint still exists and works
|
|
||||||
SELECT
|
|
||||||
tc.constraint_name,
|
|
||||||
tc.table_name,
|
|
||||||
kcu.column_name
|
|
||||||
FROM information_schema.table_constraints tc
|
|
||||||
JOIN information_schema.key_column_usage kcu
|
|
||||||
ON tc.constraint_name = kcu.constraint_name
|
|
||||||
WHERE tc.constraint_name = 'tasks_sort_order_unique';
|
|
||||||
|
|
||||||
-- Check that new sort order columns don't have unique constraints (which is correct)
|
|
||||||
SELECT
|
|
||||||
tc.constraint_name,
|
|
||||||
tc.table_name,
|
|
||||||
kcu.column_name
|
|
||||||
FROM information_schema.table_constraints tc
|
|
||||||
JOIN information_schema.key_column_usage kcu
|
|
||||||
ON tc.constraint_name = kcu.constraint_name
|
|
||||||
WHERE kcu.table_name = 'tasks'
|
|
||||||
AND kcu.column_name IN ('status_sort_order', 'priority_sort_order', 'phase_sort_order', 'member_sort_order')
|
|
||||||
AND tc.constraint_type = 'UNIQUE';
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
-- Test script to validate the separate sort order implementation
|
|
||||||
|
|
||||||
-- Check if new columns exist
|
|
||||||
SELECT column_name, data_type, is_nullable, column_default
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_name = 'tasks'
|
|
||||||
AND column_name IN ('status_sort_order', 'priority_sort_order', 'phase_sort_order', 'member_sort_order')
|
|
||||||
ORDER BY column_name;
|
|
||||||
|
|
||||||
-- Check if helper function exists
|
|
||||||
SELECT routine_name, routine_type
|
|
||||||
FROM information_schema.routines
|
|
||||||
WHERE routine_name IN ('get_sort_column_name', 'update_task_sort_orders_bulk', 'handle_task_list_sort_order_change');
|
|
||||||
|
|
||||||
-- Sample test data to verify different sort orders work
|
|
||||||
-- (This would be run after the migrations)
|
|
||||||
/*
|
|
||||||
-- Test: Tasks should have different orders for different groupings
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
sort_order,
|
|
||||||
status_sort_order,
|
|
||||||
priority_sort_order,
|
|
||||||
phase_sort_order,
|
|
||||||
member_sort_order
|
|
||||||
FROM tasks
|
|
||||||
WHERE project_id = '<test-project-id>'
|
|
||||||
ORDER BY status_sort_order;
|
|
||||||
*/
|
|
||||||
@@ -2297,3 +2297,60 @@ ALTER TABLE organization_working_days
|
|||||||
ALTER TABLE organization_working_days
|
ALTER TABLE organization_working_days
|
||||||
ADD CONSTRAINT org_organization_id_fk
|
ADD CONSTRAINT org_organization_id_fk
|
||||||
FOREIGN KEY (organization_id) REFERENCES organizations;
|
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)
|
INSERT INTO timezones (name, abbrev, utc_offset)
|
||||||
SELECT name, abbrev, utc_offset
|
SELECT name, abbrev, utc_offset
|
||||||
FROM pg_timezone_names;
|
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 $$;
|
||||||
|
|||||||
@@ -94,4 +94,194 @@ export default class GanttController extends WorklenzControllerBase {
|
|||||||
}
|
}
|
||||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HandleExceptions()
|
||||||
|
public static async getRoadmapTasks(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||||
|
const projectId = req.query.project_id;
|
||||||
|
|
||||||
|
const q = `
|
||||||
|
SELECT
|
||||||
|
t.id,
|
||||||
|
t.name,
|
||||||
|
t.start_date,
|
||||||
|
t.end_date,
|
||||||
|
t.done,
|
||||||
|
t.roadmap_sort_order,
|
||||||
|
t.parent_task_id,
|
||||||
|
CASE WHEN t.done THEN 100 ELSE 0 END as progress,
|
||||||
|
ts.name as status_name,
|
||||||
|
tsc.color_code as status_color,
|
||||||
|
tp.name as priority_name,
|
||||||
|
tp.value as priority_value,
|
||||||
|
tp.color_code as priority_color,
|
||||||
|
(
|
||||||
|
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(assignee_info))), '[]'::JSON)
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
tm.id as team_member_id,
|
||||||
|
u.name as assignee_name,
|
||||||
|
u.avatar_url
|
||||||
|
FROM tasks_assignees ta
|
||||||
|
JOIN team_members tm ON ta.team_member_id = tm.id
|
||||||
|
JOIN users u ON tm.user_id = u.id
|
||||||
|
WHERE ta.task_id = t.id
|
||||||
|
) assignee_info
|
||||||
|
) as assignees,
|
||||||
|
(
|
||||||
|
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(phase_info))), '[]'::JSON)
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
pp.id as phase_id,
|
||||||
|
pp.name as phase_name,
|
||||||
|
pp.color_code as phase_color
|
||||||
|
FROM task_phase tp
|
||||||
|
JOIN project_phases pp ON tp.phase_id = pp.id
|
||||||
|
WHERE tp.task_id = t.id
|
||||||
|
) phase_info
|
||||||
|
) as phases,
|
||||||
|
(
|
||||||
|
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(dependency_info))), '[]'::JSON)
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
td.related_task_id,
|
||||||
|
td.dependency_type,
|
||||||
|
rt.name as related_task_name
|
||||||
|
FROM task_dependencies td
|
||||||
|
JOIN tasks rt ON td.related_task_id = rt.id
|
||||||
|
WHERE td.task_id = t.id
|
||||||
|
) dependency_info
|
||||||
|
) as dependencies
|
||||||
|
FROM tasks t
|
||||||
|
LEFT JOIN task_statuses ts ON t.status_id = ts.id
|
||||||
|
LEFT JOIN sys_task_status_categories tsc ON ts.category_id = tsc.id
|
||||||
|
LEFT JOIN task_priorities tp ON t.priority_id = tp.id
|
||||||
|
WHERE t.project_id = $1
|
||||||
|
AND t.archived = FALSE
|
||||||
|
AND t.parent_task_id IS NULL
|
||||||
|
ORDER BY t.roadmap_sort_order, t.created_at DESC;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(q, [projectId]);
|
||||||
|
|
||||||
|
// Get subtasks for each parent task
|
||||||
|
for (const task of result.rows) {
|
||||||
|
const subtasksQuery = `
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
start_date,
|
||||||
|
end_date,
|
||||||
|
done,
|
||||||
|
roadmap_sort_order,
|
||||||
|
parent_task_id,
|
||||||
|
CASE WHEN done THEN 100 ELSE 0 END as progress
|
||||||
|
FROM tasks
|
||||||
|
WHERE parent_task_id = $1
|
||||||
|
AND archived = FALSE
|
||||||
|
ORDER BY roadmap_sort_order, created_at DESC;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const subtasksResult = await db.query(subtasksQuery, [task.id]);
|
||||||
|
task.subtasks = subtasksResult.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||||
|
}
|
||||||
|
|
||||||
|
@HandleExceptions()
|
||||||
|
public static async getProjectPhases(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||||
|
const projectId = req.query.project_id;
|
||||||
|
|
||||||
|
const q = `
|
||||||
|
SELECT
|
||||||
|
pp.id,
|
||||||
|
pp.name,
|
||||||
|
pp.color_code,
|
||||||
|
pp.start_date,
|
||||||
|
pp.end_date,
|
||||||
|
pp.sort_index,
|
||||||
|
-- Calculate task counts by status category for progress
|
||||||
|
COALESCE(
|
||||||
|
(SELECT COUNT(*)
|
||||||
|
FROM tasks t
|
||||||
|
JOIN task_phase tp ON t.id = tp.task_id
|
||||||
|
JOIN task_statuses ts ON t.status_id = ts.id
|
||||||
|
JOIN sys_task_status_categories stsc ON ts.category_id = stsc.id
|
||||||
|
WHERE tp.phase_id = pp.id
|
||||||
|
AND t.archived = FALSE
|
||||||
|
AND stsc.is_todo = TRUE), 0
|
||||||
|
) as todo_count,
|
||||||
|
COALESCE(
|
||||||
|
(SELECT COUNT(*)
|
||||||
|
FROM tasks t
|
||||||
|
JOIN task_phase tp ON t.id = tp.task_id
|
||||||
|
JOIN task_statuses ts ON t.status_id = ts.id
|
||||||
|
JOIN sys_task_status_categories stsc ON ts.category_id = stsc.id
|
||||||
|
WHERE tp.phase_id = pp.id
|
||||||
|
AND t.archived = FALSE
|
||||||
|
AND stsc.is_doing = TRUE), 0
|
||||||
|
) as doing_count,
|
||||||
|
COALESCE(
|
||||||
|
(SELECT COUNT(*)
|
||||||
|
FROM tasks t
|
||||||
|
JOIN task_phase tp ON t.id = tp.task_id
|
||||||
|
JOIN task_statuses ts ON t.status_id = ts.id
|
||||||
|
JOIN sys_task_status_categories stsc ON ts.category_id = stsc.id
|
||||||
|
WHERE tp.phase_id = pp.id
|
||||||
|
AND t.archived = FALSE
|
||||||
|
AND stsc.is_done = TRUE), 0
|
||||||
|
) as done_count,
|
||||||
|
COALESCE(
|
||||||
|
(SELECT COUNT(*)
|
||||||
|
FROM tasks t
|
||||||
|
JOIN task_phase tp ON t.id = tp.task_id
|
||||||
|
WHERE tp.phase_id = pp.id
|
||||||
|
AND t.archived = FALSE), 0
|
||||||
|
) as total_count
|
||||||
|
FROM project_phases pp
|
||||||
|
WHERE pp.project_id = $1
|
||||||
|
ORDER BY pp.sort_index, pp.created_at;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(q, [projectId]);
|
||||||
|
|
||||||
|
// Calculate progress percentages for each phase
|
||||||
|
const phasesWithProgress = result.rows.map(phase => {
|
||||||
|
const total = parseInt(phase.total_count) || 0;
|
||||||
|
const todoCount = parseInt(phase.todo_count) || 0;
|
||||||
|
const doingCount = parseInt(phase.doing_count) || 0;
|
||||||
|
const doneCount = parseInt(phase.done_count) || 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: phase.id,
|
||||||
|
name: phase.name,
|
||||||
|
color_code: phase.color_code,
|
||||||
|
start_date: phase.start_date,
|
||||||
|
end_date: phase.end_date,
|
||||||
|
sort_index: phase.sort_index,
|
||||||
|
// Calculate progress percentages
|
||||||
|
todo_progress: total > 0 ? Math.round((todoCount / total) * 100) : 0,
|
||||||
|
doing_progress: total > 0 ? Math.round((doingCount / total) * 100) : 0,
|
||||||
|
done_progress: total > 0 ? Math.round((doneCount / total) * 100) : 0,
|
||||||
|
total_tasks: total
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).send(new ServerResponse(true, phasesWithProgress));
|
||||||
|
}
|
||||||
|
|
||||||
|
@HandleExceptions()
|
||||||
|
public static async updateTaskDates(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||||
|
const { task_id, start_date, end_date } = req.body;
|
||||||
|
|
||||||
|
const q = `
|
||||||
|
UPDATE tasks
|
||||||
|
SET start_date = $2, end_date = $3, updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id, start_date, end_date;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(q, [task_id, start_date, end_date]);
|
||||||
|
return res.status(200).send(new ServerResponse(true, result.rows[0]));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {getColor} from "../shared/utils";
|
|||||||
import TeamMembersController from "./team-members-controller";
|
import TeamMembersController from "./team-members-controller";
|
||||||
import {checkTeamSubscriptionStatus} from "../shared/paddle-utils";
|
import {checkTeamSubscriptionStatus} from "../shared/paddle-utils";
|
||||||
import {updateUsers} from "../shared/paddle-requests";
|
import {updateUsers} from "../shared/paddle-requests";
|
||||||
import {statusExclude} from "../shared/constants";
|
import {statusExclude, TRIAL_MEMBER_LIMIT} from "../shared/constants";
|
||||||
import {NotificationsService} from "../services/notifications/notifications.service";
|
import {NotificationsService} from "../services/notifications/notifications.service";
|
||||||
|
|
||||||
export default class ProjectMembersController extends WorklenzControllerBase {
|
export default class ProjectMembersController extends WorklenzControllerBase {
|
||||||
@@ -118,6 +118,17 @@ export default class ProjectMembersController extends WorklenzControllerBase {
|
|||||||
return res.status(200).send(new ServerResponse(false, null, "Maximum number of life time users reached."));
|
return res.status(200).send(new ServerResponse(false, null, "Maximum number of life time users reached."));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks trial user team member limit
|
||||||
|
*/
|
||||||
|
if (subscriptionData.subscription_status === "trialing") {
|
||||||
|
const currentTrialMembers = parseInt(subscriptionData.current_count) || 0;
|
||||||
|
|
||||||
|
if (currentTrialMembers + 1 > TRIAL_MEMBER_LIMIT) {
|
||||||
|
return res.status(200).send(new ServerResponse(false, null, `Trial users cannot exceed ${TRIAL_MEMBER_LIMIT} team members. Please upgrade to add more members.`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// if (subscriptionData.status === "trialing") break;
|
// if (subscriptionData.status === "trialing") break;
|
||||||
if (!userExists && !subscriptionData.is_credit && !subscriptionData.is_custom && subscriptionData.subscription_status !== "trialing") {
|
if (!userExists && !subscriptionData.is_credit && !subscriptionData.is_custom && subscriptionData.subscription_status !== "trialing") {
|
||||||
// if (subscriptionData.subscription_status === "active") {
|
// if (subscriptionData.subscription_status === "active") {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export default abstract class ReportingControllerBaseWithTimezone extends Workle
|
|||||||
JOIN timezones tz ON u.timezone_id = tz.id
|
JOIN timezones tz ON u.timezone_id = tz.id
|
||||||
WHERE u.id = $1`;
|
WHERE u.id = $1`;
|
||||||
const result = await db.query(q, [userId]);
|
const result = await db.query(q, [userId]);
|
||||||
return result.rows[0]?.timezone || 'UTC';
|
return result.rows[0]?.timezone || "UTC";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,20 +30,43 @@ export default abstract class ReportingControllerBaseWithTimezone extends Workle
|
|||||||
protected static getDateRangeClauseWithTimezone(key: string, dateRange: string[], userTimezone: string) {
|
protected static getDateRangeClauseWithTimezone(key: string, dateRange: string[], userTimezone: string) {
|
||||||
// For custom date ranges
|
// For custom date ranges
|
||||||
if (dateRange.length === 2) {
|
if (dateRange.length === 2) {
|
||||||
// Convert dates to user's timezone start/end of day
|
try {
|
||||||
const start = moment.tz(dateRange[0], userTimezone).startOf('day');
|
// Handle different date formats that might come from frontend
|
||||||
const end = moment.tz(dateRange[1], userTimezone).endOf('day');
|
let startDate, endDate;
|
||||||
|
|
||||||
// Convert to UTC for database comparison
|
// Try to parse the date - it might be a full JS Date string or ISO string
|
||||||
const startUtc = start.utc().format("YYYY-MM-DD HH:mm:ss");
|
if (dateRange[0].includes("GMT") || dateRange[0].includes("(")) {
|
||||||
const endUtc = end.utc().format("YYYY-MM-DD HH:mm:ss");
|
// Parse JavaScript Date toString() format
|
||||||
|
startDate = moment(new Date(dateRange[0]));
|
||||||
if (start.isSame(end, 'day')) {
|
endDate = moment(new Date(dateRange[1]));
|
||||||
// Single day selection
|
} else {
|
||||||
return `AND task_work_log.created_at >= '${startUtc}'::TIMESTAMP AND task_work_log.created_at <= '${endUtc}'::TIMESTAMP`;
|
// Parse ISO format or other formats
|
||||||
|
startDate = moment(dateRange[0]);
|
||||||
|
endDate = moment(dateRange[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to user's timezone and get start/end of day
|
||||||
|
const start = startDate.tz(userTimezone).startOf("day");
|
||||||
|
const end = endDate.tz(userTimezone).endOf("day");
|
||||||
|
|
||||||
|
// Convert to UTC for database comparison
|
||||||
|
const startUtc = start.utc().format("YYYY-MM-DD HH:mm:ss");
|
||||||
|
const endUtc = end.utc().format("YYYY-MM-DD HH:mm:ss");
|
||||||
|
|
||||||
|
if (start.isSame(end, "day")) {
|
||||||
|
// Single day selection
|
||||||
|
return `AND twl.created_at >= '${startUtc}'::TIMESTAMP AND twl.created_at <= '${endUtc}'::TIMESTAMP`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `AND twl.created_at >= '${startUtc}'::TIMESTAMP AND twl.created_at <= '${endUtc}'::TIMESTAMP`;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error parsing date range:", error, { dateRange, userTimezone });
|
||||||
|
// Fallback to current date if parsing fails
|
||||||
|
const now = moment.tz(userTimezone);
|
||||||
|
const startUtc = now.clone().startOf("day").utc().format("YYYY-MM-DD HH:mm:ss");
|
||||||
|
const endUtc = now.clone().endOf("day").utc().format("YYYY-MM-DD HH:mm:ss");
|
||||||
|
return `AND twl.created_at >= '${startUtc}'::TIMESTAMP AND twl.created_at <= '${endUtc}'::TIMESTAMP`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `AND task_work_log.created_at >= '${startUtc}'::TIMESTAMP AND task_work_log.created_at <= '${endUtc}'::TIMESTAMP`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// For predefined ranges, calculate based on user's timezone
|
// For predefined ranges, calculate based on user's timezone
|
||||||
@@ -52,20 +75,20 @@ export default abstract class ReportingControllerBaseWithTimezone extends Workle
|
|||||||
|
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case DATE_RANGES.YESTERDAY:
|
case DATE_RANGES.YESTERDAY:
|
||||||
startDate = now.clone().subtract(1, 'day').startOf('day');
|
startDate = now.clone().subtract(1, "day").startOf("day");
|
||||||
endDate = now.clone().subtract(1, 'day').endOf('day');
|
endDate = now.clone().subtract(1, "day").endOf("day");
|
||||||
break;
|
break;
|
||||||
case DATE_RANGES.LAST_WEEK:
|
case DATE_RANGES.LAST_WEEK:
|
||||||
startDate = now.clone().subtract(1, 'week').startOf('week');
|
startDate = now.clone().subtract(1, "week").startOf("week");
|
||||||
endDate = now.clone().subtract(1, 'week').endOf('week');
|
endDate = now.clone().subtract(1, "week").endOf("week");
|
||||||
break;
|
break;
|
||||||
case DATE_RANGES.LAST_MONTH:
|
case DATE_RANGES.LAST_MONTH:
|
||||||
startDate = now.clone().subtract(1, 'month').startOf('month');
|
startDate = now.clone().subtract(1, "month").startOf("month");
|
||||||
endDate = now.clone().subtract(1, 'month').endOf('month');
|
endDate = now.clone().subtract(1, "month").endOf("month");
|
||||||
break;
|
break;
|
||||||
case DATE_RANGES.LAST_QUARTER:
|
case DATE_RANGES.LAST_QUARTER:
|
||||||
startDate = now.clone().subtract(3, 'months').startOf('day');
|
startDate = now.clone().subtract(3, "months").startOf("day");
|
||||||
endDate = now.clone().endOf('day');
|
endDate = now.clone().endOf("day");
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
return "";
|
return "";
|
||||||
@@ -74,7 +97,7 @@ export default abstract class ReportingControllerBaseWithTimezone extends Workle
|
|||||||
if (startDate && endDate) {
|
if (startDate && endDate) {
|
||||||
const startUtc = startDate.utc().format("YYYY-MM-DD HH:mm:ss");
|
const startUtc = startDate.utc().format("YYYY-MM-DD HH:mm:ss");
|
||||||
const endUtc = endDate.utc().format("YYYY-MM-DD HH:mm:ss");
|
const endUtc = endDate.utc().format("YYYY-MM-DD HH:mm:ss");
|
||||||
return `AND task_work_log.created_at >= '${startUtc}'::TIMESTAMP AND task_work_log.created_at <= '${endUtc}'::TIMESTAMP`;
|
return `AND twl.created_at >= '${startUtc}'::TIMESTAMP AND twl.created_at <= '${endUtc}'::TIMESTAMP`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return "";
|
return "";
|
||||||
@@ -87,7 +110,7 @@ export default abstract class ReportingControllerBaseWithTimezone extends Workle
|
|||||||
* @param format - Moment format string
|
* @param format - Moment format string
|
||||||
* @returns Formatted date string
|
* @returns Formatted date string
|
||||||
*/
|
*/
|
||||||
protected static formatDateInTimezone(date: string | Date, userTimezone: string, format: string = "YYYY-MM-DD HH:mm:ss") {
|
protected static formatDateInTimezone(date: string | Date, userTimezone: string, format = "YYYY-MM-DD HH:mm:ss") {
|
||||||
return moment.tz(date, userTimezone).format(format);
|
return moment.tz(date, userTimezone).format(format);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,12 +127,12 @@ export default abstract class ReportingControllerBaseWithTimezone extends Workle
|
|||||||
let workingDays = 0;
|
let workingDays = 0;
|
||||||
|
|
||||||
const current = start.clone();
|
const current = start.clone();
|
||||||
while (current.isSameOrBefore(end, 'day')) {
|
while (current.isSameOrBefore(end, "day")) {
|
||||||
// Monday = 1, Friday = 5
|
// Monday = 1, Friday = 5
|
||||||
if (current.isoWeekday() >= 1 && current.isoWeekday() <= 5) {
|
if (current.isoWeekday() >= 1 && current.isoWeekday() <= 5) {
|
||||||
workingDays++;
|
workingDays++;
|
||||||
}
|
}
|
||||||
current.add(1, 'day');
|
current.add(1, "day");
|
||||||
}
|
}
|
||||||
|
|
||||||
return workingDays;
|
return workingDays;
|
||||||
|
|||||||
@@ -164,4 +164,38 @@ export default class SurveyController extends WorklenzControllerBase {
|
|||||||
|
|
||||||
return res.status(200).send(new ServerResponse(true, response));
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -13,7 +13,7 @@ import { SocketEvents } from "../socket.io/events";
|
|||||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||||
import HandleExceptions from "../decorators/handle-exceptions";
|
import HandleExceptions from "../decorators/handle-exceptions";
|
||||||
import { formatDuration, getColor } from "../shared/utils";
|
import { formatDuration, getColor } from "../shared/utils";
|
||||||
import { statusExclude, TEAM_MEMBER_TREE_MAP_COLOR_ALPHA } from "../shared/constants";
|
import { statusExclude, TEAM_MEMBER_TREE_MAP_COLOR_ALPHA, TRIAL_MEMBER_LIMIT } from "../shared/constants";
|
||||||
import { checkTeamSubscriptionStatus } from "../shared/paddle-utils";
|
import { checkTeamSubscriptionStatus } from "../shared/paddle-utils";
|
||||||
import { updateUsers } from "../shared/paddle-requests";
|
import { updateUsers } from "../shared/paddle-requests";
|
||||||
import { NotificationsService } from "../services/notifications/notifications.service";
|
import { NotificationsService } from "../services/notifications/notifications.service";
|
||||||
@@ -141,6 +141,17 @@ export default class TeamMembersController extends WorklenzControllerBase {
|
|||||||
return res.status(200).send(new ServerResponse(false, null, "Cannot exceed the maximum number of life time users."));
|
return res.status(200).send(new ServerResponse(false, null, "Cannot exceed the maximum number of life time users."));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks trial user team member limit
|
||||||
|
*/
|
||||||
|
if (subscriptionData.subscription_status === "trialing") {
|
||||||
|
const currentTrialMembers = parseInt(subscriptionData.current_count) || 0;
|
||||||
|
|
||||||
|
if (currentTrialMembers + incrementBy > TRIAL_MEMBER_LIMIT) {
|
||||||
|
return res.status(200).send(new ServerResponse(false, null, `Trial users cannot exceed ${TRIAL_MEMBER_LIMIT} team members. Please upgrade to add more members.`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks subscription details and updates the user count if applicable.
|
* Checks subscription details and updates the user count if applicable.
|
||||||
* Sends a response if there is an issue with the subscription.
|
* Sends a response if there is an issue with the subscription.
|
||||||
@@ -1081,6 +1092,18 @@ export default class TeamMembersController extends WorklenzControllerBase {
|
|||||||
return res.status(200).send(new ServerResponse(false, "Please check your subscription status."));
|
return res.status(200).send(new ServerResponse(false, "Please check your subscription status."));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks trial user team member limit
|
||||||
|
*/
|
||||||
|
if (subscriptionData.subscription_status === "trialing") {
|
||||||
|
const currentTrialMembers = parseInt(subscriptionData.current_count) || 0;
|
||||||
|
const emailsToAdd = req.body.emails?.length || 1;
|
||||||
|
|
||||||
|
if (currentTrialMembers + emailsToAdd > TRIAL_MEMBER_LIMIT) {
|
||||||
|
return res.status(200).send(new ServerResponse(false, null, `Trial users cannot exceed ${TRIAL_MEMBER_LIMIT} team members. Please upgrade to add more members.`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// if (subscriptionData.status === "trialing") break;
|
// if (subscriptionData.status === "trialing") break;
|
||||||
if (!subscriptionData.is_credit && !subscriptionData.is_custom) {
|
if (!subscriptionData.is_credit && !subscriptionData.is_custom) {
|
||||||
if (subscriptionData.subscription_status === "active") {
|
if (subscriptionData.subscription_status === "active") {
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import moment from "moment";
|
||||||
|
import { IWorkLenzRequest } from "../interfaces/worklenz-request";
|
||||||
|
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
|
||||||
|
|
||||||
|
import db from "../config/db";
|
||||||
|
|
||||||
|
import { ServerResponse } from "../models/server-response";
|
||||||
|
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||||
|
import HandleExceptions from "../decorators/handle-exceptions";
|
||||||
|
import { formatDuration, formatLogText, getColor } from "../shared/utils";
|
||||||
|
|
||||||
|
interface IUserRecentTask {
|
||||||
|
task_id: string;
|
||||||
|
task_name: string;
|
||||||
|
project_id: string;
|
||||||
|
project_name: string;
|
||||||
|
last_activity_at: string;
|
||||||
|
activity_count: number;
|
||||||
|
project_color?: string;
|
||||||
|
task_status?: string;
|
||||||
|
status_color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IUserTimeLoggedTask {
|
||||||
|
task_id: string;
|
||||||
|
task_name: string;
|
||||||
|
project_id: string;
|
||||||
|
project_name: string;
|
||||||
|
total_time_logged: number;
|
||||||
|
total_time_logged_string: string;
|
||||||
|
last_logged_at: string;
|
||||||
|
logged_by_timer: boolean;
|
||||||
|
project_color?: string;
|
||||||
|
task_status?: string;
|
||||||
|
status_color?: string;
|
||||||
|
log_entries_count?: number;
|
||||||
|
estimated_time?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class UserActivityLogsController extends WorklenzControllerBase {
|
||||||
|
@HandleExceptions()
|
||||||
|
public static async getRecentTasks(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||||
|
if (!req.user) {
|
||||||
|
return res.status(401).send(new ServerResponse(false, null, "Unauthorized"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id: userId, team_id: teamId } = req.user;
|
||||||
|
const { offset = 0, limit = 10 } = req.query;
|
||||||
|
|
||||||
|
// Optimized query with better performance and team filtering
|
||||||
|
const q = `
|
||||||
|
SELECT DISTINCT tal.task_id, t.name AS task_name, tal.project_id, p.name AS project_name,
|
||||||
|
MAX(tal.created_at) AS last_activity_at,
|
||||||
|
COUNT(DISTINCT tal.id) AS activity_count,
|
||||||
|
p.color_code AS project_color,
|
||||||
|
(SELECT name FROM task_statuses WHERE id = t.status_id) AS task_status,
|
||||||
|
(SELECT color_code
|
||||||
|
FROM sys_task_status_categories
|
||||||
|
WHERE id = (SELECT category_id FROM task_statuses WHERE id = t.status_id)) AS status_color
|
||||||
|
FROM task_activity_logs tal
|
||||||
|
INNER JOIN tasks t ON tal.task_id = t.id AND t.archived = FALSE
|
||||||
|
INNER JOIN projects p ON tal.project_id = p.id AND p.team_id = $1
|
||||||
|
WHERE tal.user_id = $2
|
||||||
|
AND tal.created_at >= NOW() - INTERVAL '30 days'
|
||||||
|
GROUP BY tal.task_id, t.name, tal.project_id, p.name, p.color_code, t.status_id
|
||||||
|
ORDER BY MAX(tal.created_at) DESC
|
||||||
|
LIMIT $3 OFFSET $4;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(q, [teamId, userId, limit, offset]);
|
||||||
|
const tasks: IUserRecentTask[] = result.rows;
|
||||||
|
|
||||||
|
return res.status(200).send(new ServerResponse(true, tasks));
|
||||||
|
}
|
||||||
|
|
||||||
|
@HandleExceptions()
|
||||||
|
public static async getTimeLoggedTasks(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||||
|
if (!req.user) {
|
||||||
|
return res.status(401).send(new ServerResponse(false, null, "Unauthorized"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id: userId, team_id: teamId } = req.user;
|
||||||
|
const { offset = 0, limit = 10 } = req.query;
|
||||||
|
|
||||||
|
// Optimized query with better performance, team filtering, and useful additional data
|
||||||
|
const q = `
|
||||||
|
SELECT twl.task_id, t.name AS task_name, t.project_id, p.name AS project_name,
|
||||||
|
SUM(twl.time_spent) AS total_time_logged,
|
||||||
|
MAX(twl.created_at) AS last_logged_at,
|
||||||
|
MAX(twl.logged_by_timer::int)::boolean AS logged_by_timer,
|
||||||
|
p.color_code AS project_color,
|
||||||
|
(SELECT name FROM task_statuses WHERE id = t.status_id) AS task_status,
|
||||||
|
(SELECT color_code
|
||||||
|
FROM sys_task_status_categories
|
||||||
|
WHERE id = (SELECT category_id FROM task_statuses WHERE id = t.status_id)) AS status_color,
|
||||||
|
COUNT(DISTINCT twl.id) AS log_entries_count,
|
||||||
|
(t.total_minutes * 60) AS estimated_time
|
||||||
|
FROM task_work_log twl
|
||||||
|
INNER JOIN tasks t ON twl.task_id = t.id AND t.archived = FALSE
|
||||||
|
INNER JOIN projects p ON t.project_id = p.id AND p.team_id = $1
|
||||||
|
WHERE twl.user_id = $2
|
||||||
|
AND twl.created_at >= NOW() - INTERVAL '90 days'
|
||||||
|
GROUP BY twl.task_id, t.name, t.project_id, p.name, p.color_code, t.status_id, t.total_minutes
|
||||||
|
HAVING SUM(twl.time_spent) > 0
|
||||||
|
ORDER BY MAX(twl.created_at) DESC
|
||||||
|
LIMIT $3 OFFSET $4;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(q, [teamId, userId, limit, offset]);
|
||||||
|
const tasks: IUserTimeLoggedTask[] = result.rows.map(task => ({
|
||||||
|
...task,
|
||||||
|
total_time_logged_string: formatDuration(moment.duration(task.total_time_logged, "seconds")),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return res.status(200).send(new ServerResponse(true, tasks));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`));
|
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
|
// answer_text and answer_json are both optional - users can submit empty answers
|
||||||
if (!answer.answer_text && !answer.answer_json) {
|
|
||||||
return res.status(200).send(new ServerResponse(false, null, `Answer ${i + 1}: Either answer_text or answer_json is required`));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate answer_text if provided
|
// Validate answer_text if provided
|
||||||
if (answer.answer_text && typeof answer.answer_text !== 'string') {
|
if (answer.answer_text && typeof answer.answer_text !== 'string') {
|
||||||
|
|||||||
@@ -81,5 +81,12 @@
|
|||||||
"delete": "Fshi",
|
"delete": "Fshi",
|
||||||
"enterStatusName": "Shkruani emrin e statusit",
|
"enterStatusName": "Shkruani emrin e statusit",
|
||||||
"selectCategory": "Zgjidh kategorinë",
|
"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",
|
"delete": "Löschen",
|
||||||
"enterStatusName": "Statusnamen eingeben",
|
"enterStatusName": "Statusnamen eingeben",
|
||||||
"selectCategory": "Kategorie auswählen",
|
"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",
|
"delete": "Delete",
|
||||||
"enterStatusName": "Enter status name",
|
"enterStatusName": "Enter status name",
|
||||||
"selectCategory": "Select category",
|
"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",
|
"delete": "Eliminar",
|
||||||
"enterStatusName": "Introducir nombre del estado",
|
"enterStatusName": "Introducir nombre del estado",
|
||||||
"selectCategory": "Seleccionar categoría",
|
"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",
|
"delete": "Excluir",
|
||||||
"enterStatusName": "Digite o nome do status",
|
"enterStatusName": "Digite o nome do status",
|
||||||
"selectCategory": "Selecionar categoria",
|
"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": "删除",
|
"delete": "删除",
|
||||||
"enterStatusName": "输入状态名称",
|
"enterStatusName": "输入状态名称",
|
||||||
"selectCategory": "选择类别",
|
"selectCategory": "选择类别",
|
||||||
"close": "关闭"
|
"close": "关闭",
|
||||||
|
"clearSort": "清除排序",
|
||||||
|
"sortAscending": "升序排列",
|
||||||
|
"sortDescending": "降序排列",
|
||||||
|
"sortByField": "按{{field}}排序",
|
||||||
|
"ascendingOrder": "升序",
|
||||||
|
"descendingOrder": "降序",
|
||||||
|
"currentSort": "当前排序:{{field}} {{order}}"
|
||||||
}
|
}
|
||||||
@@ -12,4 +12,9 @@ ganttApiRouter.get("/project-phases/:id", safeControllerFunction(GanttController
|
|||||||
|
|
||||||
ganttApiRouter.get("/project-workload", safeControllerFunction(GanttController.getWorkload));
|
ganttApiRouter.get("/project-workload", safeControllerFunction(GanttController.getWorkload));
|
||||||
|
|
||||||
|
// New roadmap Gantt APIs
|
||||||
|
ganttApiRouter.get("/roadmap-tasks", safeControllerFunction(GanttController.getRoadmapTasks));
|
||||||
|
ganttApiRouter.get("/project-phases", safeControllerFunction(GanttController.getProjectPhases));
|
||||||
|
ganttApiRouter.post("/update-task-dates", safeControllerFunction(GanttController.updateTaskDates));
|
||||||
|
|
||||||
export default ganttApiRouter;
|
export default ganttApiRouter;
|
||||||
@@ -1,122 +1,125 @@
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
|
|
||||||
import AccessControlsController from "../../controllers/access-controls-controller";
|
import AccessControlsController from "../../controllers/access-controls-controller";
|
||||||
import AuthController from "../../controllers/auth-controller";
|
import AuthController from "../../controllers/auth-controller";
|
||||||
import LogsController from "../../controllers/logs-controller";
|
import LogsController from "../../controllers/logs-controller";
|
||||||
import OverviewController from "../../controllers/overview-controller";
|
import OverviewController from "../../controllers/overview-controller";
|
||||||
import TaskPrioritiesController from "../../controllers/task-priorities-controller";
|
import TaskPrioritiesController from "../../controllers/task-priorities-controller";
|
||||||
|
|
||||||
import attachmentsApiRouter from "./attachments-api-router";
|
import attachmentsApiRouter from "./attachments-api-router";
|
||||||
import clientsApiRouter from "./clients-api-router";
|
import clientsApiRouter from "./clients-api-router";
|
||||||
import jobTitlesApiRouter from "./job-titles-api-router";
|
import jobTitlesApiRouter from "./job-titles-api-router";
|
||||||
import notificationsApiRouter from "./notifications-api-router";
|
import notificationsApiRouter from "./notifications-api-router";
|
||||||
import personalOverviewApiRouter from "./personal-overview-api-router";
|
import personalOverviewApiRouter from "./personal-overview-api-router";
|
||||||
import projectMembersApiRouter from "./project-members-api-router";
|
import projectMembersApiRouter from "./project-members-api-router";
|
||||||
import projectsApiRouter from "./projects-api-router";
|
import projectsApiRouter from "./projects-api-router";
|
||||||
import settingsApiRouter from "./settings-api-router";
|
import settingsApiRouter from "./settings-api-router";
|
||||||
import statusesApiRouter from "./statuses-api-router";
|
import statusesApiRouter from "./statuses-api-router";
|
||||||
import subTasksApiRouter from "./sub-tasks-api-router";
|
import subTasksApiRouter from "./sub-tasks-api-router";
|
||||||
import taskCommentsApiRouter from "./task-comments-api-router";
|
import taskCommentsApiRouter from "./task-comments-api-router";
|
||||||
import taskWorkLogApiRouter from "./task-work-log-api-router";
|
import taskWorkLogApiRouter from "./task-work-log-api-router";
|
||||||
import tasksApiRouter from "./tasks-api-router";
|
import tasksApiRouter from "./tasks-api-router";
|
||||||
import teamMembersApiRouter from "./team-members-api-router";
|
import teamMembersApiRouter from "./team-members-api-router";
|
||||||
import teamsApiRouter from "./teams-api-router";
|
import teamsApiRouter from "./teams-api-router";
|
||||||
import timezonesApiRouter from "./timezones-api-router";
|
import timezonesApiRouter from "./timezones-api-router";
|
||||||
import todoListApiRouter from "./todo-list-api-router";
|
import todoListApiRouter from "./todo-list-api-router";
|
||||||
import projectStatusesApiRouter from "./project-statuses-api-router";
|
import projectStatusesApiRouter from "./project-statuses-api-router";
|
||||||
import labelsApiRouter from "./labels-api-router";
|
import labelsApiRouter from "./labels-api-router";
|
||||||
import sharedProjectsApiRouter from "./shared-projects-api-router";
|
import sharedProjectsApiRouter from "./shared-projects-api-router";
|
||||||
import resourceAllocationApiRouter from "./resource-allocation-api-router";
|
import resourceAllocationApiRouter from "./resource-allocation-api-router";
|
||||||
import taskTemplatesApiRouter from "./task-templates-api-router";
|
import taskTemplatesApiRouter from "./task-templates-api-router";
|
||||||
import projectInsightsApiRouter from "./project-insights-api-router";
|
import projectInsightsApiRouter from "./project-insights-api-router";
|
||||||
import passwordValidator from "../../middlewares/validators/password-validator";
|
import passwordValidator from "../../middlewares/validators/password-validator";
|
||||||
import adminCenterApiRouter from "./admin-center-api-router";
|
import adminCenterApiRouter from "./admin-center-api-router";
|
||||||
import reportingApiRouter from "./reporting-api-router";
|
import reportingApiRouter from "./reporting-api-router";
|
||||||
import activityLogsApiRouter from "./activity-logs-api-router";
|
import activityLogsApiRouter from "./activity-logs-api-router";
|
||||||
import safeControllerFunction from "../../shared/safe-controller-function";
|
import safeControllerFunction from "../../shared/safe-controller-function";
|
||||||
import projectFoldersApiRouter from "./project-folders-api-router";
|
import projectFoldersApiRouter from "./project-folders-api-router";
|
||||||
import taskPhasesApiRouter from "./task-phases-api-router";
|
import taskPhasesApiRouter from "./task-phases-api-router";
|
||||||
import projectCategoriesApiRouter from "./project-categories-api-router";
|
import projectCategoriesApiRouter from "./project-categories-api-router";
|
||||||
import homePageApiRouter from "./home-page-api-router";
|
import homePageApiRouter from "./home-page-api-router";
|
||||||
import ganttApiRouter from "./gantt-api-router";
|
import ganttApiRouter from "./gantt-api-router";
|
||||||
import projectCommentsApiRouter from "./project-comments-api-router";
|
import projectCommentsApiRouter from "./project-comments-api-router";
|
||||||
import reportingExportApiRouter from "./reporting-export-api-router";
|
import reportingExportApiRouter from "./reporting-export-api-router";
|
||||||
import projectHealthsApiRouter from "./project-healths-api-router";
|
import projectHealthsApiRouter from "./project-healths-api-router";
|
||||||
import ptTasksApiRouter from "./pt-tasks-api-router";
|
import ptTasksApiRouter from "./pt-tasks-api-router";
|
||||||
import projectTemplatesApiRouter from "./project-templates-api";
|
import projectTemplatesApiRouter from "./project-templates-api";
|
||||||
import ptTaskPhasesApiRouter from "./pt_task-phases-api-router";
|
import ptTaskPhasesApiRouter from "./pt_task-phases-api-router";
|
||||||
import ptStatusesApiRouter from "./pt-statuses-api-router";
|
import ptStatusesApiRouter from "./pt-statuses-api-router";
|
||||||
import workloadApiRouter from "./gannt-apis/workload-api-router";
|
import workloadApiRouter from "./gannt-apis/workload-api-router";
|
||||||
import roadmapApiRouter from "./gannt-apis/roadmap-api-router";
|
import roadmapApiRouter from "./gannt-apis/roadmap-api-router";
|
||||||
import scheduleApiRouter from "./gannt-apis/schedule-api-router";
|
import scheduleApiRouter from "./gannt-apis/schedule-api-router";
|
||||||
import scheduleApiV2Router from "./gannt-apis/schedule-api-v2-router";
|
import scheduleApiV2Router from "./gannt-apis/schedule-api-v2-router";
|
||||||
import projectManagerApiRouter from "./project-managers-api-router";
|
import projectManagerApiRouter from "./project-managers-api-router";
|
||||||
import surveyApiRouter from "./survey-api-router";
|
import surveyApiRouter from "./survey-api-router";
|
||||||
|
|
||||||
import billingApiRouter from "./billing-api-router";
|
import billingApiRouter from "./billing-api-router";
|
||||||
import taskDependenciesApiRouter from "./task-dependencies-api-router";
|
import taskDependenciesApiRouter from "./task-dependencies-api-router";
|
||||||
|
|
||||||
import taskRecurringApiRouter from "./task-recurring-api-router";
|
import taskRecurringApiRouter from "./task-recurring-api-router";
|
||||||
|
|
||||||
import customColumnsApiRouter from "./custom-columns-api-router";
|
import customColumnsApiRouter from "./custom-columns-api-router";
|
||||||
|
import userActivityLogsApiRouter from "./user-activity-logs-api-router";
|
||||||
const api = express.Router();
|
|
||||||
|
const api = express.Router();
|
||||||
api.use("/projects", projectsApiRouter);
|
|
||||||
api.use("/team-members", teamMembersApiRouter);
|
api.use("/projects", projectsApiRouter);
|
||||||
api.use("/job-titles", jobTitlesApiRouter);
|
api.use("/team-members", teamMembersApiRouter);
|
||||||
api.use("/clients", clientsApiRouter);
|
api.use("/job-titles", jobTitlesApiRouter);
|
||||||
api.use("/teams", teamsApiRouter);
|
api.use("/clients", clientsApiRouter);
|
||||||
api.use("/tasks", tasksApiRouter);
|
api.use("/teams", teamsApiRouter);
|
||||||
api.use("/settings", settingsApiRouter);
|
api.use("/tasks", tasksApiRouter);
|
||||||
api.use("/personal-overview", personalOverviewApiRouter);
|
api.use("/settings", settingsApiRouter);
|
||||||
api.use("/statuses", statusesApiRouter);
|
api.use("/personal-overview", personalOverviewApiRouter);
|
||||||
api.use("/todo-list", todoListApiRouter);
|
api.use("/statuses", statusesApiRouter);
|
||||||
api.use("/notifications", notificationsApiRouter);
|
api.use("/todo-list", todoListApiRouter);
|
||||||
api.use("/attachments", attachmentsApiRouter);
|
api.use("/notifications", notificationsApiRouter);
|
||||||
api.use("/sub-tasks", subTasksApiRouter);
|
api.use("/attachments", attachmentsApiRouter);
|
||||||
api.use("/project-members", projectMembersApiRouter);
|
api.use("/sub-tasks", subTasksApiRouter);
|
||||||
api.use("/task-time-log", taskWorkLogApiRouter);
|
api.use("/project-members", projectMembersApiRouter);
|
||||||
api.use("/task-comments", taskCommentsApiRouter);
|
api.use("/task-time-log", taskWorkLogApiRouter);
|
||||||
api.use("/timezones", timezonesApiRouter);
|
api.use("/task-comments", taskCommentsApiRouter);
|
||||||
api.use("/project-statuses", projectStatusesApiRouter);
|
api.use("/timezones", timezonesApiRouter);
|
||||||
api.use("/labels", labelsApiRouter);
|
api.use("/project-statuses", projectStatusesApiRouter);
|
||||||
api.use("/resource-allocation", resourceAllocationApiRouter);
|
api.use("/labels", labelsApiRouter);
|
||||||
api.use("/shared/projects", sharedProjectsApiRouter);
|
api.use("/resource-allocation", resourceAllocationApiRouter);
|
||||||
api.use("/task-templates", taskTemplatesApiRouter);
|
api.use("/shared/projects", sharedProjectsApiRouter);
|
||||||
api.use("/project-insights", projectInsightsApiRouter);
|
api.use("/task-templates", taskTemplatesApiRouter);
|
||||||
api.use("/admin-center", adminCenterApiRouter);
|
api.use("/project-insights", projectInsightsApiRouter);
|
||||||
api.use("/reporting", reportingApiRouter);
|
api.use("/admin-center", adminCenterApiRouter);
|
||||||
api.use("/activity-logs", activityLogsApiRouter);
|
api.use("/reporting", reportingApiRouter);
|
||||||
api.use("/projects-folders", projectFoldersApiRouter);
|
api.use("/activity-logs", activityLogsApiRouter);
|
||||||
api.use("/task-phases", taskPhasesApiRouter);
|
api.use("/projects-folders", projectFoldersApiRouter);
|
||||||
api.use("/project-categories", projectCategoriesApiRouter);
|
api.use("/task-phases", taskPhasesApiRouter);
|
||||||
api.use("/home", homePageApiRouter);
|
api.use("/project-categories", projectCategoriesApiRouter);
|
||||||
api.use("/gantt", ganttApiRouter);
|
api.use("/home", homePageApiRouter);
|
||||||
api.use("/project-comments", projectCommentsApiRouter);
|
api.use("/gantt", ganttApiRouter);
|
||||||
api.use("/reporting-export", reportingExportApiRouter);
|
api.use("/project-comments", projectCommentsApiRouter);
|
||||||
api.use("/project-healths", projectHealthsApiRouter);
|
api.use("/reporting-export", reportingExportApiRouter);
|
||||||
api.use("/project-templates", projectTemplatesApiRouter);
|
api.use("/project-healths", projectHealthsApiRouter);
|
||||||
api.use("/pt-tasks", ptTasksApiRouter);
|
api.use("/project-templates", projectTemplatesApiRouter);
|
||||||
api.use("/pt-task-phases", ptTaskPhasesApiRouter);
|
api.use("/pt-tasks", ptTasksApiRouter);
|
||||||
api.use("/pt-statuses", ptStatusesApiRouter);
|
api.use("/pt-task-phases", ptTaskPhasesApiRouter);
|
||||||
api.use("/workload-gannt", workloadApiRouter);
|
api.use("/pt-statuses", ptStatusesApiRouter);
|
||||||
api.use("/roadmap-gannt", roadmapApiRouter);
|
api.use("/workload-gannt", workloadApiRouter);
|
||||||
api.use("/schedule-gannt", scheduleApiRouter);
|
api.use("/roadmap-gannt", roadmapApiRouter);
|
||||||
api.use("/schedule-gannt-v2", scheduleApiV2Router);
|
api.use("/schedule-gannt", scheduleApiRouter);
|
||||||
api.use("/project-managers", projectManagerApiRouter);
|
api.use("/schedule-gannt-v2", scheduleApiV2Router);
|
||||||
api.use("/surveys", surveyApiRouter);
|
api.use("/project-managers", projectManagerApiRouter);
|
||||||
|
api.use("/surveys", surveyApiRouter);
|
||||||
api.get("/overview/:id", safeControllerFunction(OverviewController.getById));
|
|
||||||
api.get("/task-priorities", safeControllerFunction(TaskPrioritiesController.get));
|
api.get("/overview/:id", safeControllerFunction(OverviewController.getById));
|
||||||
api.post("/change-password", passwordValidator, safeControllerFunction(AuthController.changePassword));
|
api.get("/task-priorities", safeControllerFunction(TaskPrioritiesController.get));
|
||||||
api.get("/access-controls/roles", safeControllerFunction(AccessControlsController.getRoles));
|
api.post("/change-password", passwordValidator, safeControllerFunction(AuthController.changePassword));
|
||||||
api.get("/logs/my-dashboard", safeControllerFunction(LogsController.getActivityLog));
|
api.get("/access-controls/roles", safeControllerFunction(AccessControlsController.getRoles));
|
||||||
|
api.get("/logs/my-dashboard", safeControllerFunction(LogsController.getActivityLog));
|
||||||
api.use("/billing", billingApiRouter);
|
|
||||||
api.use("/task-dependencies", taskDependenciesApiRouter);
|
api.use("/billing", billingApiRouter);
|
||||||
|
api.use("/task-dependencies", taskDependenciesApiRouter);
|
||||||
api.use("/task-recurring", taskRecurringApiRouter);
|
|
||||||
|
api.use("/task-recurring", taskRecurringApiRouter);
|
||||||
api.use("/custom-columns", customColumnsApiRouter);
|
|
||||||
|
api.use("/custom-columns", customColumnsApiRouter);
|
||||||
|
|
||||||
|
api.use("/logs", userActivityLogsApiRouter);
|
||||||
|
export default api;
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ const surveyApiRouter = express.Router();
|
|||||||
// Get account setup survey with questions
|
// Get account setup survey with questions
|
||||||
surveyApiRouter.get("/account-setup", safeControllerFunction(SurveyController.getAccountSetupSurvey));
|
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
|
// Submit survey response
|
||||||
surveyApiRouter.post("/responses", surveySubmissionValidator, safeControllerFunction(SurveyController.submitSurveyResponse));
|
surveyApiRouter.post("/responses", surveySubmissionValidator, safeControllerFunction(SurveyController.submitSurveyResponse));
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import express from 'express';
|
||||||
|
|
||||||
|
import UserActivityLogsController from '../../controllers/user-activity-logs-controller';
|
||||||
|
import safeControllerFunction from "../../shared/safe-controller-function";
|
||||||
|
|
||||||
|
const userActivityLogsApiRouter = express.Router();
|
||||||
|
|
||||||
|
userActivityLogsApiRouter.get('/user-recent-tasks', safeControllerFunction(UserActivityLogsController.getRecentTasks));
|
||||||
|
userActivityLogsApiRouter.get('/user-time-logged-tasks', safeControllerFunction(UserActivityLogsController.getTimeLoggedTasks));
|
||||||
|
|
||||||
|
export default userActivityLogsApiRouter;
|
||||||
@@ -160,6 +160,9 @@ export const PASSWORD_POLICY = "Minimum of 8 characters, with upper and lowercas
|
|||||||
// paddle status to exclude
|
// paddle status to exclude
|
||||||
export const statusExclude = ["past_due", "paused", "deleted"];
|
export const statusExclude = ["past_due", "paused", "deleted"];
|
||||||
|
|
||||||
|
// Trial user team member limit
|
||||||
|
export const TRIAL_MEMBER_LIMIT = 10;
|
||||||
|
|
||||||
export const HTML_TAG_REGEXP = /<\/?[^>]+>/gi;
|
export const HTML_TAG_REGEXP = /<\/?[^>]+>/gi;
|
||||||
|
|
||||||
export const UNMAPPED = "Unmapped";
|
export const UNMAPPED = "Unmapped";
|
||||||
|
|||||||
@@ -15,4 +15,8 @@ VITE_RECAPTCHA_SITE_KEY=recaptcha-site-key
|
|||||||
VITE_WORKLENZ_SESSION_ID=worklenz-session-id
|
VITE_WORKLENZ_SESSION_ID=worklenz-session-id
|
||||||
|
|
||||||
# Google Login
|
# Google Login
|
||||||
VITE_ENABLE_GOOGLE_LOGIN=false
|
VITE_ENABLE_GOOGLE_LOGIN=false
|
||||||
|
|
||||||
|
# Survey Modal Configuration
|
||||||
|
# Set to true to enable the survey modal, false to disable it
|
||||||
|
VITE_ENABLE_SURVEY_MODAL=false
|
||||||
22
worklenz-frontend/.env.example
Normal file
22
worklenz-frontend/.env.example
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
VITE_API_URL=http://localhost:3000
|
||||||
|
VITE_SOCKET_URL=ws://localhost:3000
|
||||||
|
|
||||||
|
VITE_APP_TITLE=Worklenz
|
||||||
|
VITE_APP_ENV=development
|
||||||
|
|
||||||
|
# Mixpanel
|
||||||
|
VITE_MIXPANEL_TOKEN=mixpanel-token
|
||||||
|
|
||||||
|
# Recaptcha
|
||||||
|
VITE_ENABLE_RECAPTCHA=false
|
||||||
|
VITE_RECAPTCHA_SITE_KEY=recaptcha-site-key
|
||||||
|
|
||||||
|
# Session ID
|
||||||
|
VITE_WORKLENZ_SESSION_ID=worklenz-session-id
|
||||||
|
|
||||||
|
# Google Login
|
||||||
|
VITE_ENABLE_GOOGLE_LOGIN=false
|
||||||
|
|
||||||
|
# Survey Modal Configuration
|
||||||
|
# Set to true to enable the survey modal, false to disable it
|
||||||
|
VITE_ENABLE_SURVEY_MODAL=false
|
||||||
558
worklenz-frontend/package-lock.json
generated
558
worklenz-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,11 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"dev-build": "vite build",
|
"dev-build": "vite build",
|
||||||
"serve": "vite preview",
|
"serve": "vite preview",
|
||||||
"format": "prettier --write ."
|
"format": "prettier --write .",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:run": "vitest run",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
|
"test:ui": "vitest --ui"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/colors": "^7.1.0",
|
"@ant-design/colors": "^7.1.0",
|
||||||
@@ -77,7 +81,10 @@
|
|||||||
"@types/react-dom": "19.0.0",
|
"@types/react-dom": "19.0.0",
|
||||||
"@types/react-window": "^1.8.8",
|
"@types/react-window": "^1.8.8",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"@vitest/coverage-v8": "^3.2.4",
|
||||||
|
"@vitest/ui": "^3.2.4",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
|
"jsdom": "^26.1.0",
|
||||||
"postcss": "^8.5.2",
|
"postcss": "^8.5.2",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.13",
|
"prettier-plugin-tailwindcss": "^0.6.13",
|
||||||
"rollup": "^4.40.2",
|
"rollup": "^4.40.2",
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class HubSpotManager {
|
|||||||
* Load HubSpot script with dark mode support
|
* Load HubSpot script with dark mode support
|
||||||
*/
|
*/
|
||||||
init() {
|
init() {
|
||||||
if (!this.isProduction) return;
|
// if (!this.isProduction) return;
|
||||||
|
|
||||||
const loadHubSpot = () => {
|
const loadHubSpot = () => {
|
||||||
const script = document.createElement('script');
|
const script = document.createElement('script');
|
||||||
@@ -52,6 +52,7 @@ class HubSpotManager {
|
|||||||
existingStyle.remove();
|
existingStyle.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply dark mode CSS if dark theme is active
|
||||||
if (isDark) {
|
if (isDark) {
|
||||||
this.injectDarkModeCSS();
|
this.injectDarkModeCSS();
|
||||||
}
|
}
|
||||||
@@ -76,40 +77,27 @@ class HubSpotManager {
|
|||||||
style.id = this.styleId;
|
style.id = this.styleId;
|
||||||
style.textContent = `
|
style.textContent = `
|
||||||
/* HubSpot Chat Widget Dark Mode Override */
|
/* 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-inline-parent,
|
||||||
#hubspot-conversations-iframe-container,
|
#hubspot-conversations-iframe-container {
|
||||||
.shadow-2xl.widget-align-right.widget-align-bottom,
|
background: #141414 !important;
|
||||||
[data-test-id="chat-widget"],
|
|
||||||
[class*="VizExCollapsedChat"],
|
|
||||||
[class*="VizExExpandedChat"],
|
|
||||||
iframe[src*="hubspot"] {
|
|
||||||
filter: invert(1) hue-rotate(180deg) !important;
|
|
||||||
background: transparent !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Target HubSpot widget container backgrounds */
|
/* Target HubSpot widget container backgrounds */
|
||||||
#hubspot-conversations-inline-parent div,
|
#hubspot-conversations-inline-parent div,
|
||||||
#hubspot-conversations-iframe-container div,
|
#hubspot-conversations-iframe-container div,
|
||||||
[data-test-id="chat-widget"] div {
|
[data-test-id="chat-widget"] div {
|
||||||
background-color: transparent !important;
|
background-color: transparent !important;
|
||||||
}
|
}
|
||||||
|
/* Ensure Worklenz app elements are not affected by HubSpot styles */
|
||||||
/* Prevent double inversion of images, avatars, and icons */
|
.ant-menu,
|
||||||
#hubspot-conversations-iframe-container img,
|
.ant-menu *,
|
||||||
#hubspot-conversations-iframe-container [style*="background-image"],
|
[class*="settings"],
|
||||||
#hubspot-conversations-iframe-container svg,
|
[class*="sidebar"],
|
||||||
iframe[src*="hubspot"] img,
|
.worklenz-app *:not([id*="hubspot"]):not([class*="widget"]) {
|
||||||
iframe[src*="hubspot"] svg,
|
filter: none !important;
|
||||||
[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;
|
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
document.head.appendChild(style);
|
document.head.appendChild(style);
|
||||||
@@ -134,4 +122,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
// Make available globally for potential cleanup
|
// Make available globally for potential cleanup
|
||||||
window.HubSpotManager = hubspot;
|
window.HubSpotManager = hubspot;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Add this style to ensure the chat widget uses the light color scheme
|
||||||
|
(function() {
|
||||||
|
var style = document.createElement('style');
|
||||||
|
style.innerHTML = '#hubspot-messages-iframe-container { color-scheme: light !important; }';
|
||||||
|
document.head.appendChild(style);
|
||||||
|
})();
|
||||||
@@ -41,6 +41,7 @@
|
|||||||
"step3InputLabel": "Fto me email",
|
"step3InputLabel": "Fto me email",
|
||||||
"addAnother": "Shto një tjetër",
|
"addAnother": "Shto një tjetër",
|
||||||
"skipForNow": "Kalo për tani",
|
"skipForNow": "Kalo për tani",
|
||||||
|
"skipping": "Duke kaluar...",
|
||||||
"formTitle": "Krijo detyrën tënde të parë.",
|
"formTitle": "Krijo detyrën tënde të parë.",
|
||||||
"step3Title": "Fto ekipin tënd për të punuar së bashku",
|
"step3Title": "Fto ekipin tënd për të punuar së bashku",
|
||||||
"maxMembers": " (Mund të ftoni deri në 5 anëtarë)",
|
"maxMembers": " (Mund të ftoni deri në 5 anëtarë)",
|
||||||
@@ -80,6 +81,8 @@
|
|||||||
"discoveryQuestion": "Si dëgjove për ne?",
|
"discoveryQuestion": "Si dëgjove për ne?",
|
||||||
"allSetTitle": "Çdo gjë gati!",
|
"allSetTitle": "Çdo gjë gati!",
|
||||||
"allSetDescription": "Le të krijojmë projektin tënd të parë dhe të fillojmë me Worklenz",
|
"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",
|
"aboutYouStepName": "Rreth teje",
|
||||||
"yourNeedsStepName": "Nevojat e tua",
|
"yourNeedsStepName": "Nevojat e tua",
|
||||||
"discoveryStepName": "Zbulimi",
|
"discoveryStepName": "Zbulimi",
|
||||||
|
|||||||
@@ -5,5 +5,13 @@
|
|||||||
"signup-failed": "Regjistrimi dështoi. Ju lutemi sigurohuni që të gjitha fushat e nevojshme janë plotësuar dhe provoni përsëri.",
|
"signup-failed": "Regjistrimi dështoi. Ju lutemi sigurohuni që të gjitha fushat e nevojshme janë plotësuar dhe provoni përsëri.",
|
||||||
"reconnecting": "Jeni shkëputur nga serveri.",
|
"reconnecting": "Jeni shkëputur nga serveri.",
|
||||||
"connection-lost": "Lidhja me serverin dështoi. Ju lutemi kontrolloni lidhjen tuaj me internet.",
|
"connection-lost": "Lidhja me serverin dështoi. Ju lutemi kontrolloni lidhjen tuaj me internet.",
|
||||||
"connection-restored": "U lidhët me serverin me sukses"
|
"connection-restored": "U lidhët me serverin me sukses",
|
||||||
|
"cancel": "Anulo",
|
||||||
|
"update-available": "Worklenz u përditesua!",
|
||||||
|
"update-description": "Një version i ri i Worklenz është i disponueshëm me karakteristikat dhe përmirësimet më të fundit.",
|
||||||
|
"update-instruction": "Për eksperiencën më të mirë, ju lutemi rifreskoni faqen për të aplikuar ndryshimet e reja.",
|
||||||
|
"update-whats-new": "💡 <1>Çfarë ka të re:</1> Përmirësim i performancës, rregullime të gabimeve dhe eksperiencön e përmirësuar e përdoruesit",
|
||||||
|
"update-now": "Përditeso tani",
|
||||||
|
"update-later": "Më vonë",
|
||||||
|
"updating": "Duke u përditesuar..."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"title": "Phase Details",
|
||||||
|
"overview": {
|
||||||
|
"title": "Overview",
|
||||||
|
"totalTasks": "Total Tasks",
|
||||||
|
"completion": "Completion",
|
||||||
|
"progress": "Progress"
|
||||||
|
},
|
||||||
|
"timeline": {
|
||||||
|
"title": "Timeline",
|
||||||
|
"startDate": "Start Date",
|
||||||
|
"endDate": "End Date",
|
||||||
|
"status": "Status",
|
||||||
|
"notSet": "Not set",
|
||||||
|
"statusLabels": {
|
||||||
|
"upcoming": "Upcoming",
|
||||||
|
"active": "In Progress",
|
||||||
|
"overdue": "Overdue",
|
||||||
|
"notScheduled": "Not Scheduled"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"taskBreakdown": {
|
||||||
|
"title": "Task Breakdown",
|
||||||
|
"completed": "Completed",
|
||||||
|
"pending": "Pending",
|
||||||
|
"overdue": "Overdue"
|
||||||
|
},
|
||||||
|
"phaseColor": {
|
||||||
|
"title": "Phase Color",
|
||||||
|
"description": "Phase identifier color"
|
||||||
|
},
|
||||||
|
"tasksInPhase": {
|
||||||
|
"title": "Tasks in this Phase",
|
||||||
|
"noTasks": "No tasks in this phase"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,22 @@
|
|||||||
"list": "Listë",
|
"list": "Listë",
|
||||||
"calendar": "Kalendar",
|
"calendar": "Kalendar",
|
||||||
"tasks": "Detyrat",
|
"tasks": "Detyrat",
|
||||||
"refresh": "Rifresko"
|
"refresh": "Rifresko",
|
||||||
|
"recentActivity": "Aktiviteti i Fundit",
|
||||||
|
"recentTasks": "Detyrat e Fundit",
|
||||||
|
"recentTasksSegment": "Detyrat e Fundit",
|
||||||
|
"timeLogged": "Koha e Regjistruar",
|
||||||
|
"timeLoggedSegment": "Koha e Regjistruar",
|
||||||
|
"noRecentTasks": "Asnjë detyrë e fundit",
|
||||||
|
"noTimeLoggedTasks": "Asnjë detyrë me kohë të regjistruar",
|
||||||
|
"activityTag": "Aktiviteti",
|
||||||
|
"timeLogTag": "Regjistrim Kohe",
|
||||||
|
"timerTag": "Kohëmatës",
|
||||||
|
"activitySingular": "aktivitet",
|
||||||
|
"activityPlural": "aktivitete",
|
||||||
|
"recentTaskAriaLabel": "Detyrë e fundit:",
|
||||||
|
"timeLoggedTaskAriaLabel": "Detyrë me kohë të regjistruar:",
|
||||||
|
"errorLoadingRecentTasks": "Gabim në ngarkimin e detyrave të fundit",
|
||||||
|
"errorLoadingTimeLoggedTasks": "Gabim në ngarkimin e detyrave me kohë të regjistruar"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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",
|
"close": "Mbyll",
|
||||||
"cannotMoveStatus": "Nuk mund të lëvizet statusi",
|
"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.",
|
"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}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,9 @@
|
|||||||
|
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"assignToMe": "Cakto mua",
|
"assignToMe": "Cakto mua",
|
||||||
|
"copyLink": "Kopjo lidhjen e detyrës",
|
||||||
|
"linkCopied": "Lidhja u kopjua në clipboard",
|
||||||
|
"linkCopyFailed": "Dështoi kopjimi i lidhjes",
|
||||||
"moveTo": "Zhvendos në",
|
"moveTo": "Zhvendos në",
|
||||||
"unarchive": "Ç'arkivizo",
|
"unarchive": "Ç'arkivizo",
|
||||||
"archive": "Arkivizo",
|
"archive": "Arkivizo",
|
||||||
@@ -133,5 +136,11 @@
|
|||||||
"dependencies": "Detyra ka varësi",
|
"dependencies": "Detyra ka varësi",
|
||||||
"recurring": "Detyrë përsëritëse"
|
"recurring": "Detyrë përsëritëse"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"timer": {
|
||||||
|
"conflictTitle": "Kronómetr Tashë Në Ecuri",
|
||||||
|
"conflictMessage": "Ju keni një kronómetr në ecuri për \"{{taskName}}\" në projektin \"{{projectName}}\". Dëshironi ta ndaloni atë kronómetr dhe të filloni një të ri për këtë detyrë?",
|
||||||
|
"stopAndStart": "Ndalo & Fillo Kronómetr të Ri"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
"step3InputLabel": "Per E-Mail einladen",
|
"step3InputLabel": "Per E-Mail einladen",
|
||||||
"addAnother": "Weitere hinzufügen",
|
"addAnother": "Weitere hinzufügen",
|
||||||
"skipForNow": "Jetzt überspringen",
|
"skipForNow": "Jetzt überspringen",
|
||||||
|
"skipping": "Überspringen...",
|
||||||
"formTitle": "Erstellen Sie Ihre erste Aufgabe.",
|
"formTitle": "Erstellen Sie Ihre erste Aufgabe.",
|
||||||
"step3Title": "Laden Sie Ihr Team zur Zusammenarbeit ein",
|
"step3Title": "Laden Sie Ihr Team zur Zusammenarbeit ein",
|
||||||
"maxMembers": " (Sie können bis zu 5 Mitglieder einladen)",
|
"maxMembers": " (Sie können bis zu 5 Mitglieder einladen)",
|
||||||
@@ -90,6 +91,8 @@
|
|||||||
"discoveryQuestion": "Wie haben Sie von uns erfahren?",
|
"discoveryQuestion": "Wie haben Sie von uns erfahren?",
|
||||||
"allSetTitle": "Sie sind bereit!",
|
"allSetTitle": "Sie sind bereit!",
|
||||||
"allSetDescription": "Lassen Sie uns Ihr erstes Projekt erstellen und mit Worklenz beginnen",
|
"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",
|
"aboutYouStepName": "Über Sie",
|
||||||
"yourNeedsStepName": "Ihre Bedürfnisse",
|
"yourNeedsStepName": "Ihre Bedürfnisse",
|
||||||
"discoveryStepName": "Entdeckung",
|
"discoveryStepName": "Entdeckung",
|
||||||
|
|||||||
@@ -7,12 +7,12 @@
|
|||||||
"emailLabel": "E-Mail",
|
"emailLabel": "E-Mail",
|
||||||
"emailPlaceholder": "Ihre E-Mail-Adresse eingeben",
|
"emailPlaceholder": "Ihre E-Mail-Adresse eingeben",
|
||||||
"emailRequired": "Bitte geben Sie Ihre E-Mail-Adresse ein!",
|
"emailRequired": "Bitte geben Sie Ihre E-Mail-Adresse ein!",
|
||||||
"passwordLabel": "Password",
|
"passwordLabel": "Passwort",
|
||||||
"passwordGuideline": "Password must be at least 8 characters, include uppercase and lowercase letters, a number, and a special character.",
|
"passwordGuideline": "Das Passwort muss mindestens 8 Zeichen lang sein und Groß- und Kleinbuchstaben, eine Zahl und ein Sonderzeichen enthalten.",
|
||||||
"passwordPlaceholder": "Enter your password",
|
"passwordPlaceholder": "Geben Sie Ihr Passwort ein",
|
||||||
"passwordRequired": "Bitte geben Sie Ihr Passwort ein!",
|
"passwordRequired": "Bitte geben Sie Ihr Passwort ein!",
|
||||||
"passwordMinCharacterRequired": "Das Passwort muss mindestens 8 Zeichen lang sein!",
|
"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!",
|
"passwordPatternRequired": "Das Passwort entspricht nicht den Anforderungen!",
|
||||||
"strongPasswordPlaceholder": "Ein stärkeres Passwort eingeben",
|
"strongPasswordPlaceholder": "Ein stärkeres Passwort eingeben",
|
||||||
"passwordValidationAltText": "Das Passwort muss mindestens 8 Zeichen enthalten, mit Groß- und Kleinbuchstaben, einer Zahl und einem Sonderzeichen.",
|
"passwordValidationAltText": "Das Passwort muss mindestens 8 Zeichen enthalten, mit Groß- und Kleinbuchstaben, einer Zahl und einem Sonderzeichen.",
|
||||||
|
|||||||
@@ -5,5 +5,13 @@
|
|||||||
"signup-failed": "Registrierung fehlgeschlagen. Bitte füllen Sie alle erforderlichen Felder aus und versuchen Sie es erneut.",
|
"signup-failed": "Registrierung fehlgeschlagen. Bitte füllen Sie alle erforderlichen Felder aus und versuchen Sie es erneut.",
|
||||||
"reconnecting": "Vom Server getrennt.",
|
"reconnecting": "Vom Server getrennt.",
|
||||||
"connection-lost": "Verbindung zum Server fehlgeschlagen. Bitte überprüfen Sie Ihre Internetverbindung.",
|
"connection-lost": "Verbindung zum Server fehlgeschlagen. Bitte überprüfen Sie Ihre Internetverbindung.",
|
||||||
"connection-restored": "Erfolgreich mit dem Server verbunden"
|
"connection-restored": "Erfolgreich mit dem Server verbunden",
|
||||||
|
"cancel": "Abbrechen",
|
||||||
|
"update-available": "Worklenz aktualisiert!",
|
||||||
|
"update-description": "Eine neue Version von Worklenz ist verfügbar mit den neuesten Funktionen und Verbesserungen.",
|
||||||
|
"update-instruction": "Für die beste Erfahrung laden Sie bitte die Seite neu, um die neuen Änderungen zu übernehmen.",
|
||||||
|
"update-whats-new": "💡 <1>Was ist neu:</1> Verbesserte Leistung, Fehlerbehebungen und verbesserte Benutzererfahrung",
|
||||||
|
"update-now": "Jetzt aktualisieren",
|
||||||
|
"update-later": "Später",
|
||||||
|
"updating": "Wird aktualisiert..."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"title": "Phase Details",
|
||||||
|
"overview": {
|
||||||
|
"title": "Overview",
|
||||||
|
"totalTasks": "Total Tasks",
|
||||||
|
"completion": "Completion",
|
||||||
|
"progress": "Progress"
|
||||||
|
},
|
||||||
|
"timeline": {
|
||||||
|
"title": "Timeline",
|
||||||
|
"startDate": "Start Date",
|
||||||
|
"endDate": "End Date",
|
||||||
|
"status": "Status",
|
||||||
|
"notSet": "Not set",
|
||||||
|
"statusLabels": {
|
||||||
|
"upcoming": "Upcoming",
|
||||||
|
"active": "In Progress",
|
||||||
|
"overdue": "Overdue",
|
||||||
|
"notScheduled": "Not Scheduled"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"taskBreakdown": {
|
||||||
|
"title": "Task Breakdown",
|
||||||
|
"completed": "Completed",
|
||||||
|
"pending": "Pending",
|
||||||
|
"overdue": "Overdue"
|
||||||
|
},
|
||||||
|
"phaseColor": {
|
||||||
|
"title": "Phase Color",
|
||||||
|
"description": "Phase identifier color"
|
||||||
|
},
|
||||||
|
"tasksInPhase": {
|
||||||
|
"title": "Tasks in this Phase",
|
||||||
|
"noTasks": "No tasks in this phase"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,22 @@
|
|||||||
"list": "Liste",
|
"list": "Liste",
|
||||||
"calendar": "Kalender",
|
"calendar": "Kalender",
|
||||||
"tasks": "Aufgaben",
|
"tasks": "Aufgaben",
|
||||||
"refresh": "Aktualisieren"
|
"refresh": "Aktualisieren",
|
||||||
|
"recentActivity": "Aktuelle Aktivitäten",
|
||||||
|
"recentTasks": "Aktuelle Aufgaben",
|
||||||
|
"recentTasksSegment": "Aktuelle Aufgaben",
|
||||||
|
"timeLogged": "Erfasste Zeit",
|
||||||
|
"timeLoggedSegment": "Erfasste Zeit",
|
||||||
|
"noRecentTasks": "Keine aktuellen Aufgaben",
|
||||||
|
"noTimeLoggedTasks": "Keine Aufgaben mit erfasster Zeit",
|
||||||
|
"activityTag": "Aktivität",
|
||||||
|
"timeLogTag": "Zeiterfassung",
|
||||||
|
"timerTag": "Timer",
|
||||||
|
"activitySingular": "Aktivität",
|
||||||
|
"activityPlural": "Aktivitäten",
|
||||||
|
"recentTaskAriaLabel": "Aktuelle Aufgabe:",
|
||||||
|
"timeLoggedTaskAriaLabel": "Aufgabe mit erfasster Zeit:",
|
||||||
|
"errorLoadingRecentTasks": "Fehler beim Laden aktueller Aufgaben",
|
||||||
|
"errorLoadingTimeLoggedTasks": "Fehler beim Laden der Zeiterfassung"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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",
|
"close": "Schließen",
|
||||||
"cannotMoveStatus": "Status kann nicht verschoben werden",
|
"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.",
|
"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}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,9 @@
|
|||||||
|
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"assignToMe": "Mir zuweisen",
|
"assignToMe": "Mir zuweisen",
|
||||||
|
"copyLink": "Link zur Aufgabe kopieren",
|
||||||
|
"linkCopied": "Link in die Zwischenablage kopiert",
|
||||||
|
"linkCopyFailed": "Fehler beim Kopieren des Links",
|
||||||
"moveTo": "Verschieben nach",
|
"moveTo": "Verschieben nach",
|
||||||
"unarchive": "Dearchivieren",
|
"unarchive": "Dearchivieren",
|
||||||
"archive": "Archivieren",
|
"archive": "Archivieren",
|
||||||
@@ -133,5 +136,11 @@
|
|||||||
"dependencies": "Aufgabe hat Abhängigkeiten",
|
"dependencies": "Aufgabe hat Abhängigkeiten",
|
||||||
"recurring": "Wiederkehrende Aufgabe"
|
"recurring": "Wiederkehrende Aufgabe"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"timer": {
|
||||||
|
"conflictTitle": "Timer läuft bereits",
|
||||||
|
"conflictMessage": "Sie haben einen Timer für \"{{taskName}}\" im Projekt \"{{projectName}}\" laufen. Möchten Sie diesen Timer stoppen und einen neuen für diese Aufgabe starten?",
|
||||||
|
"stopAndStart": "Stoppen & Neuen Timer starten"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
"step3InputLabel": "Invite with email",
|
"step3InputLabel": "Invite with email",
|
||||||
"addAnother": "Add another",
|
"addAnother": "Add another",
|
||||||
"skipForNow": "Skip for now",
|
"skipForNow": "Skip for now",
|
||||||
|
"skipping": "Skipping...",
|
||||||
"formTitle": "Create your first task.",
|
"formTitle": "Create your first task.",
|
||||||
"step3Title": "Invite your team to work with",
|
"step3Title": "Invite your team to work with",
|
||||||
"maxMembers": " (You can invite up to 5 members)",
|
"maxMembers": " (You can invite up to 5 members)",
|
||||||
@@ -88,6 +89,8 @@
|
|||||||
"discoveryQuestion": "How did you hear about us?",
|
"discoveryQuestion": "How did you hear about us?",
|
||||||
"allSetTitle": "You're all set!",
|
"allSetTitle": "You're all set!",
|
||||||
"allSetDescription": "Let's create your first project and get started with Worklenz",
|
"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",
|
"aboutYouStepName": "About You",
|
||||||
"yourNeedsStepName": "Your Needs",
|
"yourNeedsStepName": "Your Needs",
|
||||||
"discoveryStepName": "Discovery",
|
"discoveryStepName": "Discovery",
|
||||||
|
|||||||
@@ -5,5 +5,13 @@
|
|||||||
"signup-failed": "Signup failed. Please ensure all required fields are filled and try again.",
|
"signup-failed": "Signup failed. Please ensure all required fields are filled and try again.",
|
||||||
"reconnecting": "Disconnected from server.",
|
"reconnecting": "Disconnected from server.",
|
||||||
"connection-lost": "Failed to connect to server. Please check your internet connection.",
|
"connection-lost": "Failed to connect to server. Please check your internet connection.",
|
||||||
"connection-restored": "Connected to server successfully"
|
"connection-restored": "Connected to server successfully",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"update-available": "Worklenz Updated!",
|
||||||
|
"update-description": "A new version of Worklenz is available with the latest features and improvements.",
|
||||||
|
"update-instruction": "To get the best experience, please reload the page to apply the new changes.",
|
||||||
|
"update-whats-new": "💡 <1>What's new:</1> Enhanced performance, bug fixes, and improved user experience",
|
||||||
|
"update-now": "Update Now",
|
||||||
|
"update-later": "Later",
|
||||||
|
"updating": "Updating..."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"title": "Phase Details",
|
||||||
|
"overview": {
|
||||||
|
"title": "Overview",
|
||||||
|
"totalTasks": "Total Tasks",
|
||||||
|
"completion": "Completion",
|
||||||
|
"progress": "Progress"
|
||||||
|
},
|
||||||
|
"timeline": {
|
||||||
|
"title": "Timeline",
|
||||||
|
"startDate": "Start Date",
|
||||||
|
"endDate": "End Date",
|
||||||
|
"status": "Status",
|
||||||
|
"notSet": "Not set",
|
||||||
|
"statusLabels": {
|
||||||
|
"upcoming": "Upcoming",
|
||||||
|
"active": "In Progress",
|
||||||
|
"overdue": "Overdue",
|
||||||
|
"notScheduled": "Not Scheduled"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"taskBreakdown": {
|
||||||
|
"title": "Task Breakdown",
|
||||||
|
"completed": "Completed",
|
||||||
|
"pending": "Pending",
|
||||||
|
"overdue": "Overdue"
|
||||||
|
},
|
||||||
|
"phaseColor": {
|
||||||
|
"title": "Phase Color",
|
||||||
|
"description": "Phase identifier color"
|
||||||
|
},
|
||||||
|
"tasksInPhase": {
|
||||||
|
"title": "Tasks in this Phase",
|
||||||
|
"noTasks": "No tasks in this phase",
|
||||||
|
"priority": "Priority",
|
||||||
|
"assignees": "Assignees",
|
||||||
|
"dueDate": "Due Date",
|
||||||
|
"startDate": "Start Date",
|
||||||
|
"noAssignees": "Unassigned",
|
||||||
|
"noDueDate": "No due date"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,22 @@
|
|||||||
"list": "List",
|
"list": "List",
|
||||||
"calendar": "Calendar",
|
"calendar": "Calendar",
|
||||||
"tasks": "Tasks",
|
"tasks": "Tasks",
|
||||||
"refresh": "Refresh"
|
"refresh": "Refresh",
|
||||||
|
"recentActivity": "Recent Activity",
|
||||||
|
"recentTasks": "Recent Tasks",
|
||||||
|
"recentTasksSegment": "Recent Tasks",
|
||||||
|
"timeLogged": "Time Logged",
|
||||||
|
"timeLoggedSegment": "Time Logged",
|
||||||
|
"noRecentTasks": "No recent tasks",
|
||||||
|
"noTimeLoggedTasks": "No time logged tasks",
|
||||||
|
"activityTag": "Activity",
|
||||||
|
"timeLogTag": "Time Log",
|
||||||
|
"timerTag": "Timer",
|
||||||
|
"activitySingular": "activity",
|
||||||
|
"activityPlural": "activities",
|
||||||
|
"recentTaskAriaLabel": "Recent task:",
|
||||||
|
"timeLoggedTaskAriaLabel": "Time logged task:",
|
||||||
|
"errorLoadingRecentTasks": "Error loading recent tasks",
|
||||||
|
"errorLoadingTimeLoggedTasks": "Error loading time logged tasks"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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",
|
"close": "Close",
|
||||||
"cannotMoveStatus": "Cannot Move Status",
|
"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.",
|
"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}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,9 @@
|
|||||||
|
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"assignToMe": "Assign to me",
|
"assignToMe": "Assign to me",
|
||||||
|
"copyLink": "Copy link to task",
|
||||||
|
"linkCopied": "Link copied to clipboard",
|
||||||
|
"linkCopyFailed": "Failed to copy link",
|
||||||
"moveTo": "Move to",
|
"moveTo": "Move to",
|
||||||
"unarchive": "Unarchive",
|
"unarchive": "Unarchive",
|
||||||
"archive": "Archive",
|
"archive": "Archive",
|
||||||
@@ -133,5 +136,11 @@
|
|||||||
"dependencies": "Task has dependencies",
|
"dependencies": "Task has dependencies",
|
||||||
"recurring": "Recurring task"
|
"recurring": "Recurring task"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"timer": {
|
||||||
|
"conflictTitle": "Timer Already Running",
|
||||||
|
"conflictMessage": "You have a timer running for \"{{taskName}}\" in project \"{{projectName}}\". Would you like to stop that timer and start a new one for this task?",
|
||||||
|
"stopAndStart": "Stop & Start New Timer"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
"step3InputLabel": "Invitar por correo electrónico",
|
"step3InputLabel": "Invitar por correo electrónico",
|
||||||
"addAnother": "Agregar otro",
|
"addAnother": "Agregar otro",
|
||||||
"skipForNow": "Omitir por ahora",
|
"skipForNow": "Omitir por ahora",
|
||||||
|
"skipping": "Omitiendo...",
|
||||||
"formTitle": "Crea tu primera tarea.",
|
"formTitle": "Crea tu primera tarea.",
|
||||||
"step3Title": "Invita a tu equipo a trabajar",
|
"step3Title": "Invita a tu equipo a trabajar",
|
||||||
|
|
||||||
@@ -91,6 +92,8 @@
|
|||||||
"discoveryQuestion": "¿Cómo te enteraste de nosotros?",
|
"discoveryQuestion": "¿Cómo te enteraste de nosotros?",
|
||||||
"allSetTitle": "¡Ya estás listo!",
|
"allSetTitle": "¡Ya estás listo!",
|
||||||
"allSetDescription": "Vamos a crear tu primer proyecto y comenzar con Worklenz",
|
"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",
|
"aboutYouStepName": "Sobre ti",
|
||||||
"yourNeedsStepName": "Tus necesidades",
|
"yourNeedsStepName": "Tus necesidades",
|
||||||
"discoveryStepName": "Descubrimiento",
|
"discoveryStepName": "Descubrimiento",
|
||||||
|
|||||||
@@ -7,12 +7,12 @@
|
|||||||
"emailLabel": "Correo electrónico",
|
"emailLabel": "Correo electrónico",
|
||||||
"emailPlaceholder": "Ingresa tu correo electrónico",
|
"emailPlaceholder": "Ingresa tu correo electrónico",
|
||||||
"emailRequired": "¡Por favor ingresa tu correo electrónico!",
|
"emailRequired": "¡Por favor ingresa tu correo electrónico!",
|
||||||
"passwordLabel": "Password",
|
"passwordLabel": "Contraseña",
|
||||||
"passwordGuideline": "Password must be at least 8 characters, include uppercase and lowercase letters, a number, and a special character.",
|
"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": "Enter your password",
|
"passwordPlaceholder": "Ingresa tu contraseña",
|
||||||
"passwordRequired": "¡Por favor ingresa tu contraseña!",
|
"passwordRequired": "¡Por favor ingresa tu contraseña!",
|
||||||
"passwordMinCharacterRequired": "¡La contraseña debe tener al menos 8 caracteres!",
|
"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!",
|
"passwordPatternRequired": "¡La contraseña no cumple con los requisitos!",
|
||||||
"strongPasswordPlaceholder": "Ingresa una contraseña más segura",
|
"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.",
|
"passwordValidationAltText": "La contraseña debe incluir al menos 8 caracteres con letras mayúsculas y minúsculas, un número y un símbolo.",
|
||||||
|
|||||||
@@ -5,5 +5,13 @@
|
|||||||
"signup-failed": "Error al registrarse. Por favor asegúrate de llenar todos los campos requeridos e intenta nuevamente.",
|
"signup-failed": "Error al registrarse. Por favor asegúrate de llenar todos los campos requeridos e intenta nuevamente.",
|
||||||
"reconnecting": "Reconectando al servidor...",
|
"reconnecting": "Reconectando al servidor...",
|
||||||
"connection-lost": "Conexión perdida. Intentando reconectarse...",
|
"connection-lost": "Conexión perdida. Intentando reconectarse...",
|
||||||
"connection-restored": "Conexión restaurada. Reconectando al servidor..."
|
"connection-restored": "Conexión restaurada. Reconectando al servidor...",
|
||||||
|
"cancel": "Cancelar",
|
||||||
|
"update-available": "¡Worklenz actualizado!",
|
||||||
|
"update-description": "Una nueva versión de Worklenz está disponible con las últimas funciones y mejoras.",
|
||||||
|
"update-instruction": "Para obtener la mejor experiencia, por favor recarga la página para aplicar los nuevos cambios.",
|
||||||
|
"update-whats-new": "💡 <1>Qué hay de nuevo:</1> Rendimiento mejorado, correcciones de errores y experiencia de usuario mejorada",
|
||||||
|
"update-now": "Actualizar ahora",
|
||||||
|
"update-later": "Más tarde",
|
||||||
|
"updating": "Actualizando..."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"title": "Phase Details",
|
||||||
|
"overview": {
|
||||||
|
"title": "Overview",
|
||||||
|
"totalTasks": "Total Tasks",
|
||||||
|
"completion": "Completion",
|
||||||
|
"progress": "Progress"
|
||||||
|
},
|
||||||
|
"timeline": {
|
||||||
|
"title": "Timeline",
|
||||||
|
"startDate": "Start Date",
|
||||||
|
"endDate": "End Date",
|
||||||
|
"status": "Status",
|
||||||
|
"notSet": "Not set",
|
||||||
|
"statusLabels": {
|
||||||
|
"upcoming": "Upcoming",
|
||||||
|
"active": "In Progress",
|
||||||
|
"overdue": "Overdue",
|
||||||
|
"notScheduled": "Not Scheduled"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"taskBreakdown": {
|
||||||
|
"title": "Task Breakdown",
|
||||||
|
"completed": "Completed",
|
||||||
|
"pending": "Pending",
|
||||||
|
"overdue": "Overdue"
|
||||||
|
},
|
||||||
|
"phaseColor": {
|
||||||
|
"title": "Phase Color",
|
||||||
|
"description": "Phase identifier color"
|
||||||
|
},
|
||||||
|
"tasksInPhase": {
|
||||||
|
"title": "Tasks in this Phase",
|
||||||
|
"noTasks": "No tasks in this phase"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,22 @@
|
|||||||
"list": "Lista",
|
"list": "Lista",
|
||||||
"calendar": "Calendario",
|
"calendar": "Calendario",
|
||||||
"tasks": "Tareas",
|
"tasks": "Tareas",
|
||||||
"refresh": "Actualizar"
|
"refresh": "Actualizar",
|
||||||
|
"recentActivity": "Actividad Reciente",
|
||||||
|
"recentTasks": "Tareas Recientes",
|
||||||
|
"recentTasksSegment": "Tareas Recientes",
|
||||||
|
"timeLogged": "Tiempo Registrado",
|
||||||
|
"timeLoggedSegment": "Tiempo Registrado",
|
||||||
|
"noRecentTasks": "No hay tareas recientes",
|
||||||
|
"noTimeLoggedTasks": "No hay tareas con tiempo registrado",
|
||||||
|
"activityTag": "Actividad",
|
||||||
|
"timeLogTag": "Registro de Tiempo",
|
||||||
|
"timerTag": "Temporizador",
|
||||||
|
"activitySingular": "actividad",
|
||||||
|
"activityPlural": "actividades",
|
||||||
|
"recentTaskAriaLabel": "Tarea reciente:",
|
||||||
|
"timeLoggedTaskAriaLabel": "Tarea con tiempo registrado:",
|
||||||
|
"errorLoadingRecentTasks": "Error al cargar tareas recientes",
|
||||||
|
"errorLoadingTimeLoggedTasks": "Error al cargar tareas con tiempo registrado"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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",
|
"close": "Cerrar",
|
||||||
"cannotMoveStatus": "No se puede mover el estado",
|
"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.",
|
"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}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,9 @@
|
|||||||
|
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"assignToMe": "Asignar a mí",
|
"assignToMe": "Asignar a mí",
|
||||||
|
"copyLink": "Copiar enlace a la tarea",
|
||||||
|
"linkCopied": "Enlace copiado al portapapeles",
|
||||||
|
"linkCopyFailed": "Error al copiar el enlace",
|
||||||
"moveTo": "Mover a",
|
"moveTo": "Mover a",
|
||||||
"unarchive": "Desarchivar",
|
"unarchive": "Desarchivar",
|
||||||
"archive": "Archivar",
|
"archive": "Archivar",
|
||||||
@@ -133,5 +136,11 @@
|
|||||||
"dependencies": "La tarea tiene dependencias",
|
"dependencies": "La tarea tiene dependencias",
|
||||||
"recurring": "Tarea recurrente"
|
"recurring": "Tarea recurrente"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"timer": {
|
||||||
|
"conflictTitle": "Temporizador Ya En Ejecución",
|
||||||
|
"conflictMessage": "Tiene un temporizador ejecutándose para \"{{taskName}}\" en el proyecto \"{{projectName}}\". ¿Le gustaría detener ese temporizador e iniciar uno nuevo para esta tarea?",
|
||||||
|
"stopAndStart": "Detener e Iniciar Nuevo Temporizador"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
"step3InputLabel": "Convidar por email",
|
"step3InputLabel": "Convidar por email",
|
||||||
"addAnother": "Adicionar outro",
|
"addAnother": "Adicionar outro",
|
||||||
"skipForNow": "Pular por enquanto",
|
"skipForNow": "Pular por enquanto",
|
||||||
|
"skipping": "Pulando...",
|
||||||
"formTitle": "Crie sua primeira tarefa.",
|
"formTitle": "Crie sua primeira tarefa.",
|
||||||
"step3Title": "Convide sua equipe para trabalhar",
|
"step3Title": "Convide sua equipe para trabalhar",
|
||||||
|
|
||||||
@@ -91,6 +92,8 @@
|
|||||||
"discoveryQuestion": "Como você soube sobre nós?",
|
"discoveryQuestion": "Como você soube sobre nós?",
|
||||||
"allSetTitle": "Você está pronto!",
|
"allSetTitle": "Você está pronto!",
|
||||||
"allSetDescription": "Vamos criar seu primeiro projeto e começar com o Worklenz",
|
"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ê",
|
"aboutYouStepName": "Sobre você",
|
||||||
"yourNeedsStepName": "Suas necessidades",
|
"yourNeedsStepName": "Suas necessidades",
|
||||||
"discoveryStepName": "Descoberta",
|
"discoveryStepName": "Descoberta",
|
||||||
|
|||||||
@@ -5,5 +5,13 @@
|
|||||||
"signup-failed": "Falha no cadastro. Por favor, certifique-se de que todos os campos obrigatórios estão preenchidos e tente novamente.",
|
"signup-failed": "Falha no cadastro. Por favor, certifique-se de que todos os campos obrigatórios estão preenchidos e tente novamente.",
|
||||||
"reconnecting": "Reconectando ao servidor...",
|
"reconnecting": "Reconectando ao servidor...",
|
||||||
"connection-lost": "Conexão perdida. Tentando reconectar...",
|
"connection-lost": "Conexão perdida. Tentando reconectar...",
|
||||||
"connection-restored": "Conexão restaurada. Reconectando ao servidor..."
|
"connection-restored": "Conexão restaurada. Reconectando ao servidor...",
|
||||||
|
"cancel": "Cancelar",
|
||||||
|
"update-available": "Worklenz atualizado!",
|
||||||
|
"update-description": "Uma nova versão do Worklenz está disponível com os recursos e melhorias mais recentes.",
|
||||||
|
"update-instruction": "Para obter a melhor experiência, por favor recarregue a página para aplicar as novas mudanças.",
|
||||||
|
"update-whats-new": "💡 <1>O que há de novo:</1> Performance aprimorada, correções de bugs e experiência do usuário melhorada",
|
||||||
|
"update-now": "Atualizar agora",
|
||||||
|
"update-later": "Mais tarde",
|
||||||
|
"updating": "Atualizando..."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"title": "Phase Details",
|
||||||
|
"overview": {
|
||||||
|
"title": "Overview",
|
||||||
|
"totalTasks": "Total Tasks",
|
||||||
|
"completion": "Completion",
|
||||||
|
"progress": "Progress"
|
||||||
|
},
|
||||||
|
"timeline": {
|
||||||
|
"title": "Timeline",
|
||||||
|
"startDate": "Start Date",
|
||||||
|
"endDate": "End Date",
|
||||||
|
"status": "Status",
|
||||||
|
"notSet": "Not set",
|
||||||
|
"statusLabels": {
|
||||||
|
"upcoming": "Upcoming",
|
||||||
|
"active": "In Progress",
|
||||||
|
"overdue": "Overdue",
|
||||||
|
"notScheduled": "Not Scheduled"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"taskBreakdown": {
|
||||||
|
"title": "Task Breakdown",
|
||||||
|
"completed": "Completed",
|
||||||
|
"pending": "Pending",
|
||||||
|
"overdue": "Overdue"
|
||||||
|
},
|
||||||
|
"phaseColor": {
|
||||||
|
"title": "Phase Color",
|
||||||
|
"description": "Phase identifier color"
|
||||||
|
},
|
||||||
|
"tasksInPhase": {
|
||||||
|
"title": "Tasks in this Phase",
|
||||||
|
"noTasks": "No tasks in this phase"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,22 @@
|
|||||||
"list": "Lista",
|
"list": "Lista",
|
||||||
"calendar": "Calendário",
|
"calendar": "Calendário",
|
||||||
"tasks": "Tarefas",
|
"tasks": "Tarefas",
|
||||||
"refresh": "Atualizar"
|
"refresh": "Atualizar",
|
||||||
|
"recentActivity": "Atividade Recente",
|
||||||
|
"recentTasks": "Tarefas Recentes",
|
||||||
|
"recentTasksSegment": "Tarefas Recentes",
|
||||||
|
"timeLogged": "Tempo Registrado",
|
||||||
|
"timeLoggedSegment": "Tempo Registrado",
|
||||||
|
"noRecentTasks": "Nenhuma tarefa recente",
|
||||||
|
"noTimeLoggedTasks": "Nenhuma tarefa com tempo registrado",
|
||||||
|
"activityTag": "Atividade",
|
||||||
|
"timeLogTag": "Registro de Tempo",
|
||||||
|
"timerTag": "Cronômetro",
|
||||||
|
"activitySingular": "atividade",
|
||||||
|
"activityPlural": "atividades",
|
||||||
|
"recentTaskAriaLabel": "Tarefa recente:",
|
||||||
|
"timeLoggedTaskAriaLabel": "Tarefa com tempo registrado:",
|
||||||
|
"errorLoadingRecentTasks": "Erro ao carregar tarefas recentes",
|
||||||
|
"errorLoadingTimeLoggedTasks": "Erro ao carregar tarefas com tempo registrado"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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",
|
"close": "Fechar",
|
||||||
"cannotMoveStatus": "Não é possível mover o status",
|
"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.",
|
"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}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,9 @@
|
|||||||
|
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"assignToMe": "Atribuir a mim",
|
"assignToMe": "Atribuir a mim",
|
||||||
|
"copyLink": "Copiar link da tarefa",
|
||||||
|
"linkCopied": "Link copiado para a área de transferência",
|
||||||
|
"linkCopyFailed": "Falha ao copiar o link",
|
||||||
"moveTo": "Mover para",
|
"moveTo": "Mover para",
|
||||||
"unarchive": "Desarquivar",
|
"unarchive": "Desarquivar",
|
||||||
"archive": "Arquivar",
|
"archive": "Arquivar",
|
||||||
@@ -133,5 +136,11 @@
|
|||||||
"dependencies": "A tarefa tem dependências",
|
"dependencies": "A tarefa tem dependências",
|
||||||
"recurring": "Tarefa recorrente"
|
"recurring": "Tarefa recorrente"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"timer": {
|
||||||
|
"conflictTitle": "Temporizador Já Em Execução",
|
||||||
|
"conflictMessage": "Você tem um temporizador executando para \"{{taskName}}\" no projeto \"{{projectName}}\". Gostaria de parar esse temporizador e iniciar um novo para esta tarefa?",
|
||||||
|
"stopAndStart": "Parar e Iniciar Novo Temporizador"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
"step3InputLabel": "通过电子邮件邀请",
|
"step3InputLabel": "通过电子邮件邀请",
|
||||||
"addAnother": "添加另一个",
|
"addAnother": "添加另一个",
|
||||||
"skipForNow": "暂时跳过",
|
"skipForNow": "暂时跳过",
|
||||||
|
"skipping": "跳过中...",
|
||||||
"formTitle": "创建您的第一个任务。",
|
"formTitle": "创建您的第一个任务。",
|
||||||
"step3Title": "邀请您的团队一起工作",
|
"step3Title": "邀请您的团队一起工作",
|
||||||
"maxMembers": "(您最多可以邀请 5 名成员)",
|
"maxMembers": "(您最多可以邀请 5 名成员)",
|
||||||
@@ -89,6 +90,8 @@
|
|||||||
"discoveryQuestion": "您是如何听说我们的?",
|
"discoveryQuestion": "您是如何听说我们的?",
|
||||||
"allSetTitle": "一切就绪!",
|
"allSetTitle": "一切就绪!",
|
||||||
"allSetDescription": "让我们创建您的第一个项目并开始使用 Worklenz 吧",
|
"allSetDescription": "让我们创建您的第一个项目并开始使用 Worklenz 吧",
|
||||||
|
"surveyCompleteTitle": "谢谢!",
|
||||||
|
"surveyCompleteDescription": "您的反馈有助于我们为所有人改进 Worklenz",
|
||||||
"aboutYouStepName": "关于您",
|
"aboutYouStepName": "关于您",
|
||||||
"yourNeedsStepName": "您的需求",
|
"yourNeedsStepName": "您的需求",
|
||||||
"discoveryStepName": "发现",
|
"discoveryStepName": "发现",
|
||||||
|
|||||||
@@ -5,5 +5,13 @@
|
|||||||
"signup-failed": "注册失败。请确保填写所有必填字段并重试。",
|
"signup-failed": "注册失败。请确保填写所有必填字段并重试。",
|
||||||
"reconnecting": "与服务器断开连接。",
|
"reconnecting": "与服务器断开连接。",
|
||||||
"connection-lost": "无法连接到服务器。请检查您的互联网连接。",
|
"connection-lost": "无法连接到服务器。请检查您的互联网连接。",
|
||||||
"connection-restored": "成功连接到服务器"
|
"connection-restored": "成功连接到服务器",
|
||||||
|
"cancel": "取消",
|
||||||
|
"update-available": "Worklenz 已更新!",
|
||||||
|
"update-description": "Worklenz 的新版本已可用,具有最新的功能和改进。",
|
||||||
|
"update-instruction": "为了获得最佳体验,请刷新页面以应用新更改。",
|
||||||
|
"update-whats-new": "💡 <1>新增内容:</1>性能增强、错误修复和用户体验改善",
|
||||||
|
"update-now": "立即更新",
|
||||||
|
"update-later": "稍后",
|
||||||
|
"updating": "正在更新..."
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"title": "Phase Details",
|
||||||
|
"overview": {
|
||||||
|
"title": "Overview",
|
||||||
|
"totalTasks": "Total Tasks",
|
||||||
|
"completion": "Completion",
|
||||||
|
"progress": "Progress"
|
||||||
|
},
|
||||||
|
"timeline": {
|
||||||
|
"title": "Timeline",
|
||||||
|
"startDate": "Start Date",
|
||||||
|
"endDate": "End Date",
|
||||||
|
"status": "Status",
|
||||||
|
"notSet": "Not set",
|
||||||
|
"statusLabels": {
|
||||||
|
"upcoming": "Upcoming",
|
||||||
|
"active": "In Progress",
|
||||||
|
"overdue": "Overdue",
|
||||||
|
"notScheduled": "Not Scheduled"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"taskBreakdown": {
|
||||||
|
"title": "Task Breakdown",
|
||||||
|
"completed": "Completed",
|
||||||
|
"pending": "Pending",
|
||||||
|
"overdue": "Overdue"
|
||||||
|
},
|
||||||
|
"phaseColor": {
|
||||||
|
"title": "Phase Color",
|
||||||
|
"description": "Phase identifier color"
|
||||||
|
},
|
||||||
|
"tasksInPhase": {
|
||||||
|
"title": "Tasks in this Phase",
|
||||||
|
"noTasks": "No tasks in this phase"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,22 @@
|
|||||||
"list": "列表",
|
"list": "列表",
|
||||||
"calendar": "日历",
|
"calendar": "日历",
|
||||||
"tasks": "任务",
|
"tasks": "任务",
|
||||||
"refresh": "刷新"
|
"refresh": "刷新",
|
||||||
|
"recentActivity": "最近活动",
|
||||||
|
"recentTasks": "最近任务",
|
||||||
|
"recentTasksSegment": "最近任务",
|
||||||
|
"timeLogged": "时间记录",
|
||||||
|
"timeLoggedSegment": "时间记录",
|
||||||
|
"noRecentTasks": "没有最近任务",
|
||||||
|
"noTimeLoggedTasks": "没有时间记录任务",
|
||||||
|
"activityTag": "活动",
|
||||||
|
"timeLogTag": "时间记录",
|
||||||
|
"timerTag": "计时器",
|
||||||
|
"activitySingular": "活动",
|
||||||
|
"activityPlural": "活动",
|
||||||
|
"recentTaskAriaLabel": "最近任务:",
|
||||||
|
"timeLoggedTaskAriaLabel": "时间记录任务:",
|
||||||
|
"errorLoadingRecentTasks": "加载最近任务时出错",
|
||||||
|
"errorLoadingTimeLoggedTasks": "加载时间记录任务时出错"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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": "关闭",
|
"close": "关闭",
|
||||||
"cannotMoveStatus": "无法移动状态",
|
"cannotMoveStatus": "无法移动状态",
|
||||||
"cannotMoveStatusMessage": "无法移动此状态,因为这会使\"{{categoryName}}\"类别为空。每个类别必须至少有一个状态。",
|
"cannotMoveStatusMessage": "无法移动此状态,因为这会使\"{{categoryName}}\"类别为空。每个类别必须至少有一个状态。",
|
||||||
"ok": "确定"
|
"ok": "确定",
|
||||||
|
"clearSort": "清除排序",
|
||||||
|
"sortAscending": "升序排列",
|
||||||
|
"sortDescending": "降序排列",
|
||||||
|
"sortByField": "按{{field}}排序",
|
||||||
|
"ascendingOrder": "升序",
|
||||||
|
"descendingOrder": "降序",
|
||||||
|
"currentSort": "当前排序:{{field}} {{order}}"
|
||||||
}
|
}
|
||||||
@@ -50,6 +50,9 @@
|
|||||||
"pendingInvitation": "待处理邀请",
|
"pendingInvitation": "待处理邀请",
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"assignToMe": "分配给我",
|
"assignToMe": "分配给我",
|
||||||
|
"copyLink": "复制任务链接",
|
||||||
|
"linkCopied": "链接已复制到剪贴板",
|
||||||
|
"linkCopyFailed": "复制链接失败",
|
||||||
"moveTo": "移动到",
|
"moveTo": "移动到",
|
||||||
"unarchive": "取消归档",
|
"unarchive": "取消归档",
|
||||||
"archive": "归档",
|
"archive": "归档",
|
||||||
@@ -126,5 +129,11 @@
|
|||||||
"dependencies": "任务有依赖项",
|
"dependencies": "任务有依赖项",
|
||||||
"recurring": "重复任务"
|
"recurring": "重复任务"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"timer": {
|
||||||
|
"conflictTitle": "计时器已在运行",
|
||||||
|
"conflictMessage": "您在项目\"{{projectName}}\"中的\"{{taskName}}\"任务正在运行计时器。您是否要停止该计时器并为此任务启动新的计时器?",
|
||||||
|
"stopAndStart": "停止并启动新计时器"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -325,6 +325,12 @@ self.addEventListener('message', event => {
|
|||||||
event.ports[0].postMessage({ version: CACHE_VERSION });
|
event.ports[0].postMessage({ version: CACHE_VERSION });
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'CHECK_FOR_UPDATES':
|
||||||
|
checkForUpdates().then((hasUpdates) => {
|
||||||
|
event.ports[0].postMessage({ hasUpdates });
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
case 'CLEAR_CACHE':
|
case 'CLEAR_CACHE':
|
||||||
clearAllCaches().then(() => {
|
clearAllCaches().then(() => {
|
||||||
event.ports[0].postMessage({ success: true });
|
event.ports[0].postMessage({ success: true });
|
||||||
@@ -349,6 +355,44 @@ async function clearAllCaches() {
|
|||||||
console.log('Service Worker: All caches cleared');
|
console.log('Service Worker: All caches cleared');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function checkForUpdates() {
|
||||||
|
try {
|
||||||
|
// Check if there's a new service worker available
|
||||||
|
const registration = await self.registration.update();
|
||||||
|
const hasNewWorker = registration.installing || registration.waiting;
|
||||||
|
|
||||||
|
if (hasNewWorker) {
|
||||||
|
console.log('Service Worker: New version detected');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check if the main app files have been updated by trying to fetch index.html
|
||||||
|
// and comparing it with the cached version
|
||||||
|
try {
|
||||||
|
const cache = await caches.open(CACHE_NAMES.STATIC);
|
||||||
|
const cachedResponse = await cache.match('/');
|
||||||
|
const networkResponse = await fetch('/', { cache: 'no-cache' });
|
||||||
|
|
||||||
|
if (cachedResponse && networkResponse.ok) {
|
||||||
|
const cachedContent = await cachedResponse.text();
|
||||||
|
const networkContent = await networkResponse.text();
|
||||||
|
|
||||||
|
if (cachedContent !== networkContent) {
|
||||||
|
console.log('Service Worker: App content has changed');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Service Worker: Could not check for content updates', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Service Worker: Error checking for updates', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
try {
|
try {
|
||||||
// Clear all caches
|
// Clear all caches
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import i18next from 'i18next';
|
|||||||
// Components
|
// Components
|
||||||
import ThemeWrapper from './features/theme/ThemeWrapper';
|
import ThemeWrapper from './features/theme/ThemeWrapper';
|
||||||
import ModuleErrorBoundary from './components/ModuleErrorBoundary';
|
import ModuleErrorBoundary from './components/ModuleErrorBoundary';
|
||||||
|
import { UpdateNotificationProvider } from './components/update-notification';
|
||||||
|
|
||||||
// Routes
|
// Routes
|
||||||
import router from './app/routes';
|
import router from './app/routes';
|
||||||
@@ -202,14 +203,16 @@ const App: React.FC = memo(() => {
|
|||||||
return (
|
return (
|
||||||
<Suspense fallback={<SuspenseFallback />}>
|
<Suspense fallback={<SuspenseFallback />}>
|
||||||
<ThemeWrapper>
|
<ThemeWrapper>
|
||||||
<ModuleErrorBoundary>
|
<UpdateNotificationProvider>
|
||||||
<RouterProvider
|
<ModuleErrorBoundary>
|
||||||
router={router}
|
<RouterProvider
|
||||||
future={{
|
router={router}
|
||||||
v7_startTransition: true,
|
future={{
|
||||||
}}
|
v7_startTransition: true,
|
||||||
/>
|
}}
|
||||||
</ModuleErrorBoundary>
|
/>
|
||||||
|
</ModuleErrorBoundary>
|
||||||
|
</UpdateNotificationProvider>
|
||||||
</ThemeWrapper>
|
</ThemeWrapper>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
|
||||||
|
import { API_BASE_URL } from '@/shared/constants';
|
||||||
|
import { getCsrfToken } from '../api-client';
|
||||||
|
import { IUserRecentTask, IUserTimeLoggedTask } from '@/types/home/user-activity.types';
|
||||||
|
import config from '@/config/env';
|
||||||
|
|
||||||
|
const rootUrl = '/logs';
|
||||||
|
|
||||||
|
export const userActivityApiService = createApi({
|
||||||
|
reducerPath: 'userActivityApi',
|
||||||
|
baseQuery: fetchBaseQuery({
|
||||||
|
baseUrl: `${config.apiUrl}${API_BASE_URL}`,
|
||||||
|
prepareHeaders: (headers) => {
|
||||||
|
headers.set('X-CSRF-Token', getCsrfToken() || '');
|
||||||
|
headers.set('Content-Type', 'application/json');
|
||||||
|
return headers;
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
}),
|
||||||
|
tagTypes: ['UserRecentTasks', 'UserTimeLoggedTasks'],
|
||||||
|
endpoints: (builder) => ({
|
||||||
|
getUserRecentTasks: builder.query<IUserRecentTask[], { limit?: number; offset?: number }>({
|
||||||
|
query: ({ limit = 10, offset = 0 }) => ({
|
||||||
|
url: `${rootUrl}/user-recent-tasks`,
|
||||||
|
params: { limit, offset },
|
||||||
|
method: 'GET',
|
||||||
|
}),
|
||||||
|
providesTags: ['UserRecentTasks'],
|
||||||
|
}),
|
||||||
|
getUserTimeLoggedTasks: builder.query<IUserTimeLoggedTask[], { limit?: number; offset?: number }>({
|
||||||
|
query: ({ limit = 10, offset = 0 }) => ({
|
||||||
|
url: `${rootUrl}/user-time-logged-tasks`,
|
||||||
|
params: { limit, offset },
|
||||||
|
method: 'GET',
|
||||||
|
}),
|
||||||
|
providesTags: ['UserTimeLoggedTasks'],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const {
|
||||||
|
useGetUserRecentTasksQuery,
|
||||||
|
useGetUserTimeLoggedTasksQuery,
|
||||||
|
} = userActivityApiService;
|
||||||
|
|
||||||
|
|
||||||
@@ -18,5 +18,10 @@ export const surveyApiService = {
|
|||||||
async getUserSurveyResponse(surveyId: string): Promise<IServerResponse<ISurveyResponse>> {
|
async getUserSurveyResponse(surveyId: string): Promise<IServerResponse<ISurveyResponse>> {
|
||||||
const response = await apiClient.get<IServerResponse<ISurveyResponse>>(`${API_BASE_URL}/surveys/responses/${surveyId}`);
|
const response = await apiClient.get<IServerResponse<ISurveyResponse>>(`${API_BASE_URL}/surveys/responses/${surveyId}`);
|
||||||
return response.data;
|
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 apiClient from '../api-client';
|
||||||
import { IServerResponse } from '@/types/common.types';
|
import { IServerResponse } from '@/types/common.types';
|
||||||
import { ITaskLogViewModel } from '@/types/tasks/task-log-view.types';
|
import { ITaskLogViewModel } from '@/types/tasks/task-log-view.types';
|
||||||
|
import { getUserSession } from '@/utils/session-helper';
|
||||||
|
|
||||||
const rootUrl = `${API_BASE_URL}/task-time-log`;
|
const rootUrl = `${API_BASE_URL}/task-time-log`;
|
||||||
|
|
||||||
@@ -17,7 +18,11 @@ export interface IRunningTimer {
|
|||||||
|
|
||||||
export const taskTimeLogsApiService = {
|
export const taskTimeLogsApiService = {
|
||||||
getByTask: async (id: string): Promise<IServerResponse<ITaskLogViewModel[]>> => {
|
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;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import userReducer from '@features/user/userSlice';
|
|||||||
|
|
||||||
// Home Page
|
// Home Page
|
||||||
import homePageReducer from '@features/home-page/home-page.slice';
|
import homePageReducer from '@features/home-page/home-page.slice';
|
||||||
|
import userActivityReducer from '@features/home-page/user-activity.slice';
|
||||||
|
|
||||||
// Account Setup
|
// Account Setup
|
||||||
import accountSetupReducer from '@features/account-setup/account-setup.slice';
|
import accountSetupReducer from '@features/account-setup/account-setup.slice';
|
||||||
@@ -82,6 +83,8 @@ import groupingReducer from '@/features/task-management/grouping.slice';
|
|||||||
import selectionReducer from '@/features/task-management/selection.slice';
|
import selectionReducer from '@/features/task-management/selection.slice';
|
||||||
import homePageApiService from '@/api/home-page/home-page.api.service';
|
import homePageApiService from '@/api/home-page/home-page.api.service';
|
||||||
import { projectsApi } from '@/api/projects/projects.v1.api.service';
|
import { projectsApi } from '@/api/projects/projects.v1.api.service';
|
||||||
|
import { userActivityApiService } from '@/api/home-page/user-activity.api.service';
|
||||||
|
import { ganttApi } from '@/pages/projects/projectView/gantt/services/gantt-api.service';
|
||||||
|
|
||||||
import projectViewReducer from '@features/project/project-view-slice';
|
import projectViewReducer from '@features/project/project-view-slice';
|
||||||
import taskManagementFieldsReducer from '@features/task-management/taskListFields.slice';
|
import taskManagementFieldsReducer from '@features/task-management/taskListFields.slice';
|
||||||
@@ -90,7 +93,7 @@ export const store = configureStore({
|
|||||||
middleware: getDefaultMiddleware =>
|
middleware: getDefaultMiddleware =>
|
||||||
getDefaultMiddleware({
|
getDefaultMiddleware({
|
||||||
serializableCheck: false,
|
serializableCheck: false,
|
||||||
}).concat(homePageApiService.middleware, projectsApi.middleware),
|
}).concat(homePageApiService.middleware, projectsApi.middleware, userActivityApiService.middleware, ganttApi.middleware),
|
||||||
reducer: {
|
reducer: {
|
||||||
// Auth & User
|
// Auth & User
|
||||||
auth: authReducer,
|
auth: authReducer,
|
||||||
@@ -103,6 +106,10 @@ export const store = configureStore({
|
|||||||
homePageReducer: homePageReducer,
|
homePageReducer: homePageReducer,
|
||||||
[homePageApiService.reducerPath]: homePageApiService.reducer,
|
[homePageApiService.reducerPath]: homePageApiService.reducer,
|
||||||
[projectsApi.reducerPath]: projectsApi.reducer,
|
[projectsApi.reducerPath]: projectsApi.reducer,
|
||||||
|
[ganttApi.reducerPath]: ganttApi.reducer,
|
||||||
|
userActivityReducer: userActivityReducer,
|
||||||
|
[userActivityApiService.reducerPath]: userActivityApiService.reducer,
|
||||||
|
|
||||||
// Core UI
|
// Core UI
|
||||||
themeReducer: themeReducer,
|
themeReducer: themeReducer,
|
||||||
localesReducer: localesReducer,
|
localesReducer: localesReducer,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ interface Props {
|
|||||||
styles: any;
|
styles: any;
|
||||||
isDarkMode: boolean;
|
isDarkMode: boolean;
|
||||||
token?: any;
|
token?: any;
|
||||||
|
isModal?: boolean; // New prop to indicate if used in modal context
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SurveyPageProps {
|
interface SurveyPageProps {
|
||||||
@@ -29,6 +30,7 @@ interface SurveyPageProps {
|
|||||||
surveyData: IAccountSetupSurveyData;
|
surveyData: IAccountSetupSurveyData;
|
||||||
handleSurveyDataChange: (field: keyof IAccountSetupSurveyData, value: any) => void;
|
handleSurveyDataChange: (field: keyof IAccountSetupSurveyData, value: any) => void;
|
||||||
handleUseCaseToggle?: (value: UseCase) => void;
|
handleUseCaseToggle?: (value: UseCase) => void;
|
||||||
|
isModal?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Page 1: About You
|
// Page 1: About You
|
||||||
@@ -235,7 +237,7 @@ const YourNeedsPage: React.FC<SurveyPageProps> = ({ styles, token, surveyData, h
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Page 3: Discovery
|
// 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 { t } = useTranslation('account-setup');
|
||||||
|
|
||||||
const howHeardAboutOptions: { value: HowHeardAbout; label: string; icon: string }[] = [
|
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="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>
|
<div className="text-4xl mb-3">🎉</div>
|
||||||
<Title level={4} style={{ color: token?.colorText, marginBottom: 8 }}>{t('allSetTitle')}</Title>
|
<Title level={4} style={{ color: token?.colorText, marginBottom: 8 }}>
|
||||||
<Paragraph style={{ color: token?.colorTextSecondary, marginBottom: 0 }}>{t('allSetDescription')}</Paragraph>
|
{isModal ? t('surveyCompleteTitle') : t('allSetTitle')}
|
||||||
|
</Title>
|
||||||
|
<Paragraph style={{ color: token?.colorTextSecondary, marginBottom: 0 }}>
|
||||||
|
{isModal ? t('surveyCompleteDescription') : t('allSetDescription')}
|
||||||
|
</Paragraph>
|
||||||
</div>
|
</div>
|
||||||
</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 { t } = useTranslation('account-setup');
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const { surveyData, surveySubStep } = useSelector((state: RootState) => state.accountSetupReducer);
|
const { surveyData, surveySubStep } = useSelector((state: RootState) => state.accountSetupReducer);
|
||||||
@@ -339,9 +345,9 @@ export const SurveyStep: React.FC<Props> = ({ onEnter, styles, isDarkMode, token
|
|||||||
};
|
};
|
||||||
|
|
||||||
const surveyPages = [
|
const surveyPages = [
|
||||||
<AboutYouPage key="about-you" 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} />,
|
<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} />
|
<DiscoveryPage key="discovery" styles={styles} isDarkMode={isDarkMode} token={token} surveyData={surveyData} handleSurveyDataChange={handleSurveyDataChange} isModal={isModal} />
|
||||||
];
|
];
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|||||||
@@ -10,3 +10,6 @@ export { default as LabelsSelector } from './LabelsSelector';
|
|||||||
export { default as Progress } from './Progress';
|
export { default as Progress } from './Progress';
|
||||||
export { default as Tag } from './Tag';
|
export { default as Tag } from './Tag';
|
||||||
export { default as Tooltip } from './Tooltip';
|
export { default as Tooltip } from './Tooltip';
|
||||||
|
|
||||||
|
// Update Notification Components
|
||||||
|
export * from './update-notification';
|
||||||
|
|||||||
269
worklenz-frontend/src/components/survey/SurveyPromptModal.tsx
Normal file
269
worklenz-frontend/src/components/survey/SurveyPromptModal.tsx
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { Modal, Button, Result, Spin, Flex } from '@/shared/antd-imports';
|
||||||
|
import { SurveyStep } from '@/components/account-setup/survey-step';
|
||||||
|
import { useSurveyStatus } from '@/hooks/useSurveyStatus';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
|
import { surveyApiService } from '@/api/survey/survey.api.service';
|
||||||
|
import { appMessage } from '@/shared/antd-imports';
|
||||||
|
import { ISurveySubmissionRequest } from '@/types/account-setup/survey.types';
|
||||||
|
import logger from '@/utils/errorLogger';
|
||||||
|
import { resetSurveyData, setSurveySubStep } from '@/features/account-setup/account-setup.slice';
|
||||||
|
|
||||||
|
interface SurveyPromptModalProps {
|
||||||
|
forceShow?: boolean;
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SurveyPromptModal: React.FC<SurveyPromptModalProps> = ({ forceShow = false, onClose }) => {
|
||||||
|
const { t } = useTranslation('survey');
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [surveyCompleted, setSurveyCompleted] = useState(false);
|
||||||
|
const [surveyInfo, setSurveyInfo] = useState<{ id: string; questions: any[] } | null>(null);
|
||||||
|
const { hasCompletedSurvey, loading, refetch } = useSurveyStatus();
|
||||||
|
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||||
|
const surveyData = useAppSelector(state => state.accountSetupReducer.surveyData);
|
||||||
|
const surveySubStep = useAppSelector(state => state.accountSetupReducer.surveySubStep);
|
||||||
|
const isDarkMode = themeMode === 'dark';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check if survey modal is disabled via environment variable
|
||||||
|
if (import.meta.env.VITE_ENABLE_SURVEY_MODAL !== 'true' && !forceShow) {
|
||||||
|
return; // Don't show modal if disabled in environment
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if survey was skipped recently (within 7 days)
|
||||||
|
const skippedAt = localStorage.getItem('survey_skipped_at');
|
||||||
|
if (!forceShow && skippedAt) {
|
||||||
|
const skippedDate = new Date(skippedAt);
|
||||||
|
const now = new Date();
|
||||||
|
const diffDays = (now.getTime() - skippedDate.getTime()) / (1000 * 60 * 60 * 24);
|
||||||
|
if (diffDays < 3) {
|
||||||
|
return; // Don't show modal if skipped within 7 days
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (forceShow) {
|
||||||
|
setVisible(true);
|
||||||
|
dispatch(resetSurveyData());
|
||||||
|
dispatch(setSurveySubStep(0));
|
||||||
|
// Fetch survey info
|
||||||
|
const fetchSurvey = async () => {
|
||||||
|
try {
|
||||||
|
const response = await surveyApiService.getAccountSetupSurvey();
|
||||||
|
if (response.done && response.body) {
|
||||||
|
setSurveyInfo({
|
||||||
|
id: response.body.id,
|
||||||
|
questions: response.body.questions || []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(t('survey:fetchErrorLog'), error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchSurvey();
|
||||||
|
} else if (!loading && hasCompletedSurvey === false) {
|
||||||
|
dispatch(resetSurveyData());
|
||||||
|
dispatch(setSurveySubStep(0));
|
||||||
|
// Fetch survey info
|
||||||
|
const fetchSurvey = async () => {
|
||||||
|
try {
|
||||||
|
const response = await surveyApiService.getAccountSetupSurvey();
|
||||||
|
if (response.done && response.body) {
|
||||||
|
setSurveyInfo({
|
||||||
|
id: response.body.id,
|
||||||
|
questions: response.body.questions || []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(t('survey:fetchErrorLog'), error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchSurvey();
|
||||||
|
// Show modal after a 5 second delay to not interrupt user immediately
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setVisible(true);
|
||||||
|
}, 5000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [loading, hasCompletedSurvey, dispatch, forceShow, t]);
|
||||||
|
|
||||||
|
const handleComplete = async () => {
|
||||||
|
try {
|
||||||
|
setSubmitting(true);
|
||||||
|
|
||||||
|
if (!surveyData || !surveyInfo) {
|
||||||
|
throw new Error('Survey data not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a map of question keys to IDs
|
||||||
|
const questionMap = surveyInfo.questions.reduce((acc, q) => {
|
||||||
|
acc[q.question_key] = q.id;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, string>);
|
||||||
|
|
||||||
|
// Prepare submission data with actual question IDs - only include answered questions
|
||||||
|
const answers: any[] = [];
|
||||||
|
|
||||||
|
if (surveyData.organization_type && questionMap['organization_type']) {
|
||||||
|
answers.push({
|
||||||
|
question_id: questionMap['organization_type'],
|
||||||
|
answer_text: surveyData.organization_type
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (surveyData.user_role && questionMap['user_role']) {
|
||||||
|
answers.push({
|
||||||
|
question_id: questionMap['user_role'],
|
||||||
|
answer_text: surveyData.user_role
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (surveyData.main_use_cases && surveyData.main_use_cases.length > 0 && questionMap['main_use_cases']) {
|
||||||
|
answers.push({
|
||||||
|
question_id: questionMap['main_use_cases'],
|
||||||
|
answer_json: surveyData.main_use_cases
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (surveyData.previous_tools && questionMap['previous_tools']) {
|
||||||
|
answers.push({
|
||||||
|
question_id: questionMap['previous_tools'],
|
||||||
|
answer_text: surveyData.previous_tools
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (surveyData.how_heard_about && questionMap['how_heard_about']) {
|
||||||
|
answers.push({
|
||||||
|
question_id: questionMap['how_heard_about'],
|
||||||
|
answer_text: surveyData.how_heard_about
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const submissionData: ISurveySubmissionRequest = {
|
||||||
|
survey_id: surveyInfo.id,
|
||||||
|
answers
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await surveyApiService.submitSurveyResponse(submissionData);
|
||||||
|
|
||||||
|
if (response.done) {
|
||||||
|
setSurveyCompleted(true);
|
||||||
|
appMessage.success(t('survey:submitSuccessMessage'));
|
||||||
|
|
||||||
|
// Wait a moment before closing
|
||||||
|
setTimeout(() => {
|
||||||
|
setVisible(false);
|
||||||
|
refetch(); // Update the survey status
|
||||||
|
}, 2000);
|
||||||
|
} else {
|
||||||
|
throw new Error(response.message || t('survey:submitErrorMessage'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(t('survey:submitErrorLog'), error);
|
||||||
|
appMessage.error(t('survey:submitErrorMessage'));
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSkip = () => {
|
||||||
|
setVisible(false);
|
||||||
|
// Optionally, you can set a flag in localStorage to not show again for some time
|
||||||
|
localStorage.setItem('survey_skipped_at', new Date().toISOString());
|
||||||
|
onClose?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
const isCurrentStepValid = () => {
|
||||||
|
switch (surveySubStep) {
|
||||||
|
case 0:
|
||||||
|
return surveyData.organization_type && surveyData.user_role;
|
||||||
|
case 1:
|
||||||
|
return surveyData.main_use_cases && surveyData.main_use_cases.length > 0;
|
||||||
|
case 2:
|
||||||
|
return surveyData.how_heard_about;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
if (surveySubStep < 2) {
|
||||||
|
dispatch(setSurveySubStep(surveySubStep + 1));
|
||||||
|
} else {
|
||||||
|
handleComplete();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrevious = () => {
|
||||||
|
if (surveySubStep > 0) {
|
||||||
|
dispatch(setSurveySubStep(surveySubStep - 1));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={visible}
|
||||||
|
title={surveyCompleted ? null : t('survey:modalTitle')}
|
||||||
|
onCancel={handleSkip}
|
||||||
|
footer={
|
||||||
|
surveyCompleted ? null : (
|
||||||
|
<Flex justify="space-between" align="center">
|
||||||
|
<div>
|
||||||
|
<Button onClick={handleSkip}>
|
||||||
|
{t('survey:skip')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Flex gap={8}>
|
||||||
|
{surveySubStep > 0 && (
|
||||||
|
<Button onClick={handlePrevious}>
|
||||||
|
{t('survey:previous')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={handleNext}
|
||||||
|
disabled={!isCurrentStepValid()}
|
||||||
|
loading={submitting && surveySubStep === 2}
|
||||||
|
>
|
||||||
|
{surveySubStep === 2 ? t('survey:completeSurvey') : t('survey:next')}
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
width={800}
|
||||||
|
maskClosable={false}
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
{submitting ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||||
|
<Spin size="large" />
|
||||||
|
<p style={{ marginTop: 16 }}>{t('survey:submitting')}</p>
|
||||||
|
</div>
|
||||||
|
) : surveyCompleted ? (
|
||||||
|
<Result
|
||||||
|
status="success"
|
||||||
|
title={t('survey:submitSuccessTitle')}
|
||||||
|
subTitle={t('survey:submitSuccessSubtitle')}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={{ maxHeight: '70vh', overflowY: 'auto' }}>
|
||||||
|
<SurveyStep
|
||||||
|
onEnter={() => {}} // Empty function since we handle navigation via buttons
|
||||||
|
styles={{}}
|
||||||
|
isDarkMode={isDarkMode}
|
||||||
|
isModal={true} // Pass true to indicate modal context
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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';
|
||||||
@@ -12,7 +12,7 @@ import TimeLogList from './time-log-list';
|
|||||||
import { taskTimeLogsApiService } from '@/api/tasks/task-time-logs.api.service';
|
import { taskTimeLogsApiService } from '@/api/tasks/task-time-logs.api.service';
|
||||||
import { ITaskLogViewModel } from '@/types/tasks/task-log-view.types';
|
import { ITaskLogViewModel } from '@/types/tasks/task-log-view.types';
|
||||||
import TaskTimer from '@/components/taskListCommon/task-timer/task-timer';
|
import TaskTimer from '@/components/taskListCommon/task-timer/task-timer';
|
||||||
import { useTaskTimer } from '@/hooks/useTaskTimer';
|
import { useTaskTimerWithConflictCheck } from '@/hooks/useTaskTimerWithConflictCheck';
|
||||||
import logger from '@/utils/errorLogger';
|
import logger from '@/utils/errorLogger';
|
||||||
|
|
||||||
interface TaskDrawerTimeLogProps {
|
interface TaskDrawerTimeLogProps {
|
||||||
@@ -31,7 +31,7 @@ const TaskDrawerTimeLog = ({ t, refreshTrigger = 0 }: TaskDrawerTimeLogProps) =>
|
|||||||
state => state.taskDrawerReducer
|
state => state.taskDrawerReducer
|
||||||
);
|
);
|
||||||
|
|
||||||
const { started, timeString, handleStartTimer, handleStopTimer } = useTaskTimer(
|
const { started, timeString, handleStartTimer, handleStopTimer } = useTaskTimerWithConflictCheck(
|
||||||
selectedTaskId || '',
|
selectedTaskId || '',
|
||||||
taskFormViewModel?.task?.timer_start_time || null
|
taskFormViewModel?.task?.timer_start_time || null
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Button, Divider, Flex, Popconfirm, Typography, Space } from '@/shared/a
|
|||||||
import { colors } from '@/styles/colors';
|
import { colors } from '@/styles/colors';
|
||||||
import { ITaskLogViewModel } from '@/types/tasks/task-log-view.types';
|
import { ITaskLogViewModel } from '@/types/tasks/task-log-view.types';
|
||||||
import SingleAvatar from '@/components/common/single-avatar/single-avatar';
|
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 { calculateTimeGap } from '@/utils/calculate-time-gap';
|
||||||
import './time-log-item.css';
|
import './time-log-item.css';
|
||||||
import { taskTimeLogsApiService } from '@/api/tasks/task-time-logs.api.service';
|
import { taskTimeLogsApiService } from '@/api/tasks/task-time-logs.api.service';
|
||||||
@@ -101,7 +101,7 @@ const TimeLogItem = ({ log, onDelete }: TimeLogItemProps) => {
|
|||||||
{renderLoggedByTimer()} {calculateTimeGap(created_at || '')}
|
{renderLoggedByTimer()} {calculateTimeGap(created_at || '')}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
{formatDateTimeWithLocale(created_at || '')}
|
{formatDateTimeWithUserTimezone(created_at || '', currentSession?.timezone_name)}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
{renderActionButtons()}
|
{renderActionButtons()}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import React, { useMemo, useCallback, useState } from 'react';
|
import React, { useMemo, useCallback, useState } from 'react';
|
||||||
import { useDroppable } from '@dnd-kit/core';
|
|
||||||
// @ts-ignore: Heroicons module types
|
// @ts-ignore: Heroicons module types
|
||||||
import {
|
import {
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
@@ -382,24 +381,12 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({
|
|||||||
t,
|
t,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Make the group header droppable
|
|
||||||
const { isOver, setNodeRef } = useDroppable({
|
|
||||||
id: group.id,
|
|
||||||
data: {
|
|
||||||
type: 'group',
|
|
||||||
group,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex items-center">
|
<div className="relative flex items-center">
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
className="inline-flex w-max items-center px-1 cursor-pointer hover:opacity-80 transition-opacity duration-200 ease-in-out border-t border-b border-gray-200 dark:border-gray-700 rounded-t-md pr-2"
|
||||||
className={`inline-flex w-max items-center px-1 cursor-pointer hover:opacity-80 transition-opacity duration-200 ease-in-out border-t border-b border-gray-200 dark:border-gray-700 rounded-t-md pr-2 ${
|
|
||||||
isOver ? 'ring-2 ring-blue-400 ring-opacity-50' : ''
|
|
||||||
}`}
|
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: isOver ? `${headerBackgroundColor}dd` : headerBackgroundColor,
|
backgroundColor: headerBackgroundColor,
|
||||||
color: headerTextColor,
|
color: headerTextColor,
|
||||||
position: 'sticky',
|
position: 'sticky',
|
||||||
top: 0,
|
top: 0,
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ import {
|
|||||||
KeyboardSensor,
|
KeyboardSensor,
|
||||||
TouchSensor,
|
TouchSensor,
|
||||||
closestCenter,
|
closestCenter,
|
||||||
useDroppable,
|
Modifier,
|
||||||
} from '@dnd-kit/core';
|
} from '@dnd-kit/core';
|
||||||
|
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
|
||||||
import {
|
import {
|
||||||
SortableContext,
|
SortableContext,
|
||||||
verticalListSortingStrategy,
|
verticalListSortingStrategy,
|
||||||
@@ -67,106 +68,132 @@ import TaskListSkeleton from './components/TaskListSkeleton';
|
|||||||
import ConvertToSubtaskDrawer from '@/components/task-list-common/convert-to-subtask-drawer/convert-to-subtask-drawer';
|
import ConvertToSubtaskDrawer from '@/components/task-list-common/convert-to-subtask-drawer/convert-to-subtask-drawer';
|
||||||
import EmptyListPlaceholder from '@/components/EmptyListPlaceholder';
|
import EmptyListPlaceholder from '@/components/EmptyListPlaceholder';
|
||||||
|
|
||||||
// Empty Group Drop Zone Component
|
// Drop Spacer Component - creates space between tasks when dragging
|
||||||
const EmptyGroupDropZone: React.FC<{
|
const DropSpacer: React.FC<{ isVisible: boolean; visibleColumns: any[]; isDarkMode?: boolean }> = ({
|
||||||
groupId: string;
|
isVisible,
|
||||||
visibleColumns: any[];
|
visibleColumns,
|
||||||
t: (key: string) => string;
|
isDarkMode = false
|
||||||
}> = ({ groupId, visibleColumns, t }) => {
|
}) => {
|
||||||
const { setNodeRef, isOver, active } = useDroppable({
|
|
||||||
id: `empty-group-${groupId}`,
|
|
||||||
data: {
|
|
||||||
type: 'group',
|
|
||||||
groupId: groupId,
|
|
||||||
isEmpty: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={setNodeRef}
|
|
||||||
className={`relative w-full transition-colors duration-200 ${
|
|
||||||
isOver && active ? 'bg-blue-50 dark:bg-blue-900/20' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center min-w-max px-1 border-t border-b border-gray-200 dark:border-gray-700" style={{ height: '40px' }}>
|
|
||||||
{visibleColumns.map((column, index) => {
|
|
||||||
const emptyColumnStyle = {
|
|
||||||
width: column.width,
|
|
||||||
flexShrink: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Show text in the title column
|
|
||||||
if (column.id === 'title') {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={`empty-${column.id}`}
|
|
||||||
className="flex items-center pl-1"
|
|
||||||
style={emptyColumnStyle}
|
|
||||||
>
|
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
No tasks in this group
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={`empty-${column.id}`}
|
|
||||||
className="border-r border-gray-200 dark:border-gray-700"
|
|
||||||
style={emptyColumnStyle}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{isOver && active && (
|
|
||||||
<div className="absolute inset-0 border-2 border-dashed border-blue-400 dark:border-blue-500 rounded-md pointer-events-none" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Placeholder Drop Indicator Component
|
|
||||||
const PlaceholderDropIndicator: React.FC<{
|
|
||||||
isVisible: boolean;
|
|
||||||
visibleColumns: any[];
|
|
||||||
}> = ({ isVisible, visibleColumns }) => {
|
|
||||||
if (!isVisible) return null;
|
if (!isVisible) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex items-center min-w-max px-1 border-2 border-dashed border-blue-400 dark:border-blue-500 bg-blue-50 dark:bg-blue-900/20 rounded-md mx-1 my-1 transition-all duration-200 ease-in-out"
|
className="flex items-center min-w-max px-1 border-2 border-dashed border-blue-400 dark:border-blue-500 bg-blue-50 dark:bg-blue-900/20 transition-all duration-200 ease-in-out"
|
||||||
style={{ minWidth: 'max-content', height: '40px' }}
|
style={{
|
||||||
|
height: isVisible ? '40px' : '0px',
|
||||||
|
opacity: isVisible ? 1 : 0,
|
||||||
|
marginTop: isVisible ? '2px' : '0px',
|
||||||
|
marginBottom: isVisible ? '2px' : '0px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{visibleColumns.map((column, index) => {
|
{visibleColumns.map((column, index) => {
|
||||||
|
// Calculate left position for sticky columns
|
||||||
|
let leftPosition = 0;
|
||||||
|
if (column.isSticky) {
|
||||||
|
for (let i = 0; i < index; i++) {
|
||||||
|
const prevColumn = visibleColumns[i];
|
||||||
|
if (prevColumn.isSticky) {
|
||||||
|
leftPosition += parseInt(prevColumn.width.replace('px', ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const columnStyle = {
|
const columnStyle = {
|
||||||
width: column.width,
|
width: column.width,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
|
...(column.isSticky && {
|
||||||
|
position: 'sticky' as const,
|
||||||
|
left: leftPosition,
|
||||||
|
zIndex: 10,
|
||||||
|
backgroundColor: 'inherit', // Inherit from parent spacer
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (column.id === 'title') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`spacer-${column.id}`}
|
||||||
|
className="flex items-center pl-1 border-r border-blue-300 dark:border-blue-600"
|
||||||
|
style={columnStyle}
|
||||||
|
>
|
||||||
|
<span className="text-xs text-blue-600 dark:text-blue-400 font-medium">
|
||||||
|
Drop here
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`placeholder-${column.id}`}
|
key={`spacer-${column.id}`}
|
||||||
className="flex items-center justify-center h-full"
|
className="border-r border-blue-300 dark:border-blue-600"
|
||||||
style={columnStyle}
|
style={columnStyle}
|
||||||
>
|
/>
|
||||||
{/* Show "Drop task here" message in the title column */}
|
|
||||||
{column.id === 'title' && (
|
|
||||||
<div className="text-xs text-blue-600 dark:text-blue-400 font-medium opacity-75">
|
|
||||||
Drop task here
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* Show subtle placeholder content in other columns */}
|
|
||||||
{column.id !== 'title' && column.id !== 'dragHandle' && (
|
|
||||||
<div className="w-full h-4 mx-1 bg-white dark:bg-gray-700 rounded opacity-50" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Empty Group Message Component
|
||||||
|
const EmptyGroupMessage: React.FC<{ visibleColumns: any[]; isDarkMode?: boolean }> = ({
|
||||||
|
visibleColumns,
|
||||||
|
isDarkMode = false
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center min-w-max px-1 border-b border-gray-200 dark:border-gray-700" style={{ height: '40px' }}>
|
||||||
|
{visibleColumns.map((column, index) => {
|
||||||
|
// Calculate left position for sticky columns
|
||||||
|
let leftPosition = 0;
|
||||||
|
if (column.isSticky) {
|
||||||
|
for (let i = 0; i < index; i++) {
|
||||||
|
const prevColumn = visibleColumns[i];
|
||||||
|
if (prevColumn.isSticky) {
|
||||||
|
leftPosition += parseInt(prevColumn.width.replace('px', ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyColumnStyle = {
|
||||||
|
width: column.width,
|
||||||
|
flexShrink: 0,
|
||||||
|
...(column.isSticky && {
|
||||||
|
position: 'sticky' as const,
|
||||||
|
left: leftPosition,
|
||||||
|
zIndex: 10,
|
||||||
|
backgroundColor: 'inherit', // Inherit from parent container
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show text in the title column
|
||||||
|
if (column.id === 'title') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`empty-${column.id}`}
|
||||||
|
className="flex items-center pl-1 border-r border-gray-200 dark:border-gray-700"
|
||||||
|
style={emptyColumnStyle}
|
||||||
|
>
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400 italic">
|
||||||
|
No tasks in this group
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`empty-${column.id}`}
|
||||||
|
className="border-r border-gray-200 dark:border-gray-700"
|
||||||
|
style={emptyColumnStyle}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
// Hooks and utilities
|
// Hooks and utilities
|
||||||
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
|
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
|
||||||
import { useSocket } from '@/socket/socketContext';
|
import { useSocket } from '@/socket/socketContext';
|
||||||
@@ -229,7 +256,7 @@ const TaskListV2Section: React.FC = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Custom hooks
|
// Custom hooks
|
||||||
const { activeId, overId, handleDragStart, handleDragOver, handleDragEnd } = useDragAndDrop(
|
const { activeId, overId, dropPosition, handleDragStart, handleDragOver, handleDragEnd } = useDragAndDrop(
|
||||||
allTasks,
|
allTasks,
|
||||||
groups
|
groups
|
||||||
);
|
);
|
||||||
@@ -564,16 +591,12 @@ const TaskListV2Section: React.FC = () => {
|
|||||||
projectId={urlProjectId || ''}
|
projectId={urlProjectId || ''}
|
||||||
/>
|
/>
|
||||||
{isGroupEmpty && !isGroupCollapsed && (
|
{isGroupEmpty && !isGroupCollapsed && (
|
||||||
<EmptyGroupDropZone
|
<EmptyGroupMessage visibleColumns={visibleColumns} isDarkMode={isDarkMode} />
|
||||||
groupId={group.id}
|
|
||||||
visibleColumns={visibleColumns}
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[virtuosoGroups, collapsedGroups, handleGroupCollapse, visibleColumns, t]
|
[virtuosoGroups, collapsedGroups, handleGroupCollapse, visibleColumns, t, isDarkMode]
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderTask = useCallback(
|
const renderTask = useCallback(
|
||||||
@@ -614,19 +637,40 @@ const TaskListV2Section: React.FC = () => {
|
|||||||
const renderColumnHeaders = useCallback(
|
const renderColumnHeaders = useCallback(
|
||||||
() => (
|
() => (
|
||||||
<div
|
<div
|
||||||
className="bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700"
|
className="border-b border-gray-200 dark:border-gray-700"
|
||||||
style={{ width: '100%', minWidth: 'max-content' }}
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
minWidth: 'max-content',
|
||||||
|
backgroundColor: isDarkMode ? '#141414' : '#f9fafb'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="flex items-center px-1 py-3 w-full"
|
className="flex items-center px-1 py-3 w-full"
|
||||||
style={{ minWidth: 'max-content', height: '44px' }}
|
style={{ minWidth: 'max-content', height: '44px' }}
|
||||||
>
|
>
|
||||||
{visibleColumns.map((column, index) => {
|
{visibleColumns.map((column, index) => {
|
||||||
|
// Calculate left position for sticky columns
|
||||||
|
let leftPosition = 0;
|
||||||
|
if (column.isSticky) {
|
||||||
|
for (let i = 0; i < index; i++) {
|
||||||
|
const prevColumn = visibleColumns[i];
|
||||||
|
if (prevColumn.isSticky) {
|
||||||
|
leftPosition += parseInt(prevColumn.width.replace('px', ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const columnStyle: ColumnStyle = {
|
const columnStyle: ColumnStyle = {
|
||||||
width: column.width,
|
width: column.width,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
...((column as any).minWidth && { minWidth: (column as any).minWidth }),
|
...((column as any).minWidth && { minWidth: (column as any).minWidth }),
|
||||||
...((column as any).maxWidth && { maxWidth: (column as any).maxWidth }),
|
...((column as any).maxWidth && { maxWidth: (column as any).maxWidth }),
|
||||||
|
...(column.isSticky && {
|
||||||
|
position: 'sticky' as const,
|
||||||
|
left: leftPosition,
|
||||||
|
zIndex: 15,
|
||||||
|
backgroundColor: isDarkMode ? '#141414' : '#f9fafb', // custom dark header : bg-gray-50
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -791,13 +835,25 @@ const TaskListV2Section: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DndContext
|
<>
|
||||||
sensors={sensors}
|
{/* CSS for sticky column hover effects */}
|
||||||
collisionDetection={closestCenter}
|
<style>
|
||||||
onDragStart={handleDragStart}
|
{`
|
||||||
onDragOver={handleDragOver}
|
.hover\\:bg-gray-50:hover .sticky-column-hover,
|
||||||
onDragEnd={handleDragEnd}
|
.dark .hover\\:bg-gray-800:hover .sticky-column-hover {
|
||||||
>
|
background-color: var(--hover-bg) !important;
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
modifiers={[restrictToVerticalAxis]}
|
||||||
|
>
|
||||||
<div className="flex flex-col bg-white dark:bg-gray-900 h-full overflow-hidden">
|
<div className="flex flex-col bg-white dark:bg-gray-900 h-full overflow-hidden">
|
||||||
{/* Table Container */}
|
{/* Table Container */}
|
||||||
<div
|
<div
|
||||||
@@ -850,41 +906,21 @@ const TaskListV2Section: React.FC = () => {
|
|||||||
|
|
||||||
// Check if this is the first actual task in the group (not AddTaskRow)
|
// Check if this is the first actual task in the group (not AddTaskRow)
|
||||||
const isFirstTaskInGroup = taskIndex === 0 && !('isAddTaskRow' in task);
|
const isFirstTaskInGroup = taskIndex === 0 && !('isAddTaskRow' in task);
|
||||||
|
|
||||||
// Check if we should show drop indicators
|
// Check if we should show drop spacer
|
||||||
const isTaskBeingDraggedOver = overId === task.id;
|
const isOverThisTask = activeId && overId === task.id && !('isAddTaskRow' in task);
|
||||||
const isGroupBeingDraggedOver = overId === group.id;
|
const showDropSpacerBefore = isOverThisTask && dropPosition === 'before';
|
||||||
const isFirstTaskInGroupBeingDraggedOver = isFirstTaskInGroup && isTaskBeingDraggedOver;
|
const showDropSpacerAfter = isOverThisTask && dropPosition === 'after';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={task.id || `add-task-${group.id}-${taskIndex}`}>
|
<div key={task.id || `add-task-${group.id}-${taskIndex}`}>
|
||||||
{/* Placeholder drop indicator before first task in group */}
|
{showDropSpacerBefore && <DropSpacer isVisible={true} visibleColumns={visibleColumns} isDarkMode={isDarkMode} />}
|
||||||
{isFirstTaskInGroupBeingDraggedOver && (
|
|
||||||
<PlaceholderDropIndicator isVisible={true} visibleColumns={visibleColumns} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Placeholder drop indicator between tasks */}
|
|
||||||
{isTaskBeingDraggedOver && !isFirstTaskInGroup && (
|
|
||||||
<PlaceholderDropIndicator isVisible={true} visibleColumns={visibleColumns} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{renderTask(globalTaskIndex, isFirstTaskInGroup)}
|
{renderTask(globalTaskIndex, isFirstTaskInGroup)}
|
||||||
|
{showDropSpacerAfter && <DropSpacer isVisible={true} visibleColumns={visibleColumns} isDarkMode={isDarkMode} />}
|
||||||
{/* Placeholder drop indicator at end of group when dragging over group */}
|
|
||||||
{isGroupBeingDraggedOver && taskIndex === group.tasks.length - 1 && (
|
|
||||||
<PlaceholderDropIndicator isVisible={true} visibleColumns={visibleColumns} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
) : (
|
) : null
|
||||||
// Handle empty groups with placeholder drop indicator
|
|
||||||
overId === group.id && (
|
|
||||||
<div style={{ minWidth: 'max-content' }}>
|
|
||||||
<PlaceholderDropIndicator isVisible={true} visibleColumns={visibleColumns} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -894,15 +930,15 @@ const TaskListV2Section: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Drag Overlay */}
|
{/* Drag Overlay */}
|
||||||
<DragOverlay dropAnimation={{ duration: 200, easing: 'ease-in-out' }}>
|
<DragOverlay dropAnimation={{ duration: 200, easing: 'cubic-bezier(0.18, 0.67, 0.6, 1.22)' }}>
|
||||||
{activeId ? (
|
{activeId ? (
|
||||||
<div
|
<div
|
||||||
className="bg-white dark:bg-gray-800 shadow-lg rounded-md border border-blue-400 dark:border-blue-500 opacity-90"
|
className="bg-white dark:bg-gray-800 shadow-2xl rounded-lg border-2 border-blue-500 dark:border-blue-400 opacity-95"
|
||||||
style={{ width: visibleColumns.find(col => col.id === 'title')?.width || '300px' }}
|
style={{ width: visibleColumns.find(col => col.id === 'title')?.width || '300px' }}
|
||||||
>
|
>
|
||||||
<div className="px-3 py-2">
|
<div className="px-4 py-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-3">
|
||||||
<HolderOutlined className="text-gray-400 dark:text-gray-500 text-xs" />
|
<HolderOutlined className="text-blue-500 dark:text-blue-400 text-sm" />
|
||||||
<div className="text-sm font-medium text-gray-900 dark:text-white truncate flex-1">
|
<div className="text-sm font-medium text-gray-900 dark:text-white truncate flex-1">
|
||||||
{allTasks.find(task => task.id === activeId)?.name ||
|
{allTasks.find(task => task.id === activeId)?.name ||
|
||||||
allTasks.find(task => task.id === activeId)?.title ||
|
allTasks.find(task => task.id === activeId)?.title ||
|
||||||
@@ -954,6 +990,7 @@ const TaskListV2Section: React.FC = () => {
|
|||||||
{createPortal(<ConvertToSubtaskDrawer />, document.body, 'convert-to-subtask-drawer')}
|
{createPortal(<ConvertToSubtaskDrawer />, document.body, 'convert-to-subtask-drawer')}
|
||||||
</div>
|
</div>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ const TaskRow: React.FC<TaskRowProps> = memo(({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Drag and drop functionality - only enable for parent tasks
|
// Drag and drop functionality - only enable for parent tasks
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging, isOver } = useSortable({
|
||||||
id: task.id,
|
id: task.id,
|
||||||
data: {
|
data: {
|
||||||
type: 'task',
|
type: 'task',
|
||||||
@@ -116,24 +116,41 @@ const TaskRow: React.FC<TaskRowProps> = memo(({
|
|||||||
const style = useMemo(() => ({
|
const style = useMemo(() => ({
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
transition,
|
transition,
|
||||||
opacity: isDragging ? 0 : 1, // Completely hide the original task while dragging
|
opacity: isDragging ? 0.3 : 1, // Make original task slightly transparent while dragging
|
||||||
}), [transform, transition, isDragging]);
|
}), [transform, transition, isDragging]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={{ ...style, height: '40px' }}
|
style={{ ...style, height: '40px' }}
|
||||||
className={`flex items-center min-w-max px-1 border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 ${
|
className={`flex items-center min-w-max px-1 border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors ${
|
||||||
isFirstInGroup ? 'border-t border-gray-200 dark:border-gray-700' : ''
|
isFirstInGroup ? 'border-t border-gray-200 dark:border-gray-700' : ''
|
||||||
} ${
|
} ${
|
||||||
isDragging ? 'shadow-lg border border-blue-300' : ''
|
isDragging ? 'opacity-50' : ''
|
||||||
|
} ${
|
||||||
|
isOver && !isDragging ? 'bg-blue-50 dark:bg-blue-900/20' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{visibleColumns.map((column, index) => (
|
{visibleColumns.map((column, index) => {
|
||||||
<React.Fragment key={column.id}>
|
// Calculate background state for sticky columns - custom dark mode colors
|
||||||
{renderColumn(column.id, column.width, column.isSticky, index)}
|
const rowBackgrounds = {
|
||||||
</React.Fragment>
|
normal: isDarkMode ? '#1e1e1e' : '#ffffff', // custom dark : bg-white
|
||||||
))}
|
hover: isDarkMode ? '#1f2937' : '#f9fafb', // slightly lighter dark : bg-gray-50
|
||||||
|
dragOver: isDarkMode ? '#1e3a8a33' : '#dbeafe', // bg-blue-900/20 : bg-blue-50
|
||||||
|
};
|
||||||
|
|
||||||
|
let currentBg = rowBackgrounds.normal;
|
||||||
|
if (isOver && !isDragging) {
|
||||||
|
currentBg = rowBackgrounds.dragOver;
|
||||||
|
}
|
||||||
|
// Note: hover state is handled by CSS, so we'll use a CSS custom property
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={column.id}>
|
||||||
|
{renderColumn(column.id, column.width, column.isSticky, index, currentBg, rowBackgrounds)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import TaskTimer from '@/components/taskListCommon/task-timer/task-timer';
|
import TaskTimer from '@/components/taskListCommon/task-timer/task-timer';
|
||||||
import { useTaskTimer } from '@/hooks/useTaskTimer';
|
import { useTaskTimerWithConflictCheck } from '@/hooks/useTaskTimerWithConflictCheck';
|
||||||
|
|
||||||
interface TaskTimeTrackingProps {
|
interface TaskTimeTrackingProps {
|
||||||
taskId: string;
|
taskId: string;
|
||||||
@@ -8,7 +8,7 @@ interface TaskTimeTrackingProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TaskTimeTracking: React.FC<TaskTimeTrackingProps> = React.memo(({ taskId, isDarkMode }) => {
|
const TaskTimeTracking: React.FC<TaskTimeTrackingProps> = React.memo(({ taskId, isDarkMode }) => {
|
||||||
const { started, timeString, handleStartTimer, handleStopTimer } = useTaskTimer(
|
const { started, timeString, handleStartTimer, handleStopTimer } = useTaskTimerWithConflictCheck(
|
||||||
taskId,
|
taskId,
|
||||||
null // The hook will get the timer start time from Redux
|
null // The hook will get the timer start time from Redux
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ import {
|
|||||||
RetweetOutlined,
|
RetweetOutlined,
|
||||||
UserAddOutlined,
|
UserAddOutlined,
|
||||||
LoadingOutlined,
|
LoadingOutlined,
|
||||||
|
CopyOutlined,
|
||||||
|
message,
|
||||||
} from '@/shared/antd-imports';
|
} from '@/shared/antd-imports';
|
||||||
|
|
||||||
interface TaskContextMenuProps {
|
interface TaskContextMenuProps {
|
||||||
@@ -325,6 +327,21 @@ const TaskContextMenu: React.FC<TaskContextMenuProps> = ({
|
|||||||
}
|
}
|
||||||
}, [task?.id, projectId, dispatch, onClose]);
|
}, [task?.id, projectId, dispatch, onClose]);
|
||||||
|
|
||||||
|
const handleCopyLink = useCallback(async () => {
|
||||||
|
if (!projectId || !task.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const taskLink = `${window.location.origin}/worklenz/projects/${projectId}?tab=tasks-list&pinned_tab=tasks-list&task=${task.id}`;
|
||||||
|
await navigator.clipboard.writeText(taskLink);
|
||||||
|
message.success(t('contextMenu.linkCopied'));
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error copying link:', error);
|
||||||
|
message.error(t('contextMenu.linkCopyFailed'));
|
||||||
|
} finally {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}, [projectId, task.id, onClose, t]);
|
||||||
|
|
||||||
const menuItems = useMemo(() => {
|
const menuItems = useMemo(() => {
|
||||||
const items = [
|
const items = [
|
||||||
{
|
{
|
||||||
@@ -344,6 +361,18 @@ const TaskContextMenu: React.FC<TaskContextMenuProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'copyLink',
|
||||||
|
label: (
|
||||||
|
<button
|
||||||
|
onClick={handleCopyLink}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 w-full text-left"
|
||||||
|
>
|
||||||
|
<CopyOutlined className="text-gray-500 dark:text-gray-400" />
|
||||||
|
<span>{t('contextMenu.copyLink')}</span>
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Add Move To submenu if there are options
|
// Add Move To submenu if there are options
|
||||||
@@ -500,6 +529,7 @@ const TaskContextMenu: React.FC<TaskContextMenuProps> = ({
|
|||||||
handleArchive,
|
handleArchive,
|
||||||
handleDelete,
|
handleDelete,
|
||||||
handleConvertToTask,
|
handleConvertToTask,
|
||||||
|
handleCopyLink,
|
||||||
getMoveToOptions,
|
getMoveToOptions,
|
||||||
dispatch,
|
dispatch,
|
||||||
t,
|
t,
|
||||||
|
|||||||
@@ -364,7 +364,7 @@ interface ReporterColumnProps {
|
|||||||
export const ReporterColumn: React.FC<ReporterColumnProps> = memo(({ width, reporter }) => (
|
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 }}>
|
<div className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700" style={{ width }}>
|
||||||
{reporter ? (
|
{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>
|
<span className="text-sm text-gray-400 dark:text-gray-500 whitespace-nowrap">-</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -19,10 +19,11 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
|
|||||||
const currentSession = useAuthService().getCurrentSession();
|
const currentSession = useAuthService().getCurrentSession();
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
const [overId, setOverId] = useState<string | null>(null);
|
const [overId, setOverId] = useState<string | null>(null);
|
||||||
|
const [dropPosition, setDropPosition] = useState<'before' | 'after' | null>(null);
|
||||||
|
|
||||||
// Helper function to emit socket event for persistence
|
// Helper function to emit socket event for persistence (within-group only)
|
||||||
const emitTaskSortChange = useCallback(
|
const emitTaskSortChange = useCallback(
|
||||||
(taskId: string, sourceGroup: TaskGroup, targetGroup: TaskGroup, insertIndex: number) => {
|
(taskId: string, group: TaskGroup, insertIndex: number) => {
|
||||||
if (!socket || !connected || !projectId) {
|
if (!socket || !connected || !projectId) {
|
||||||
logger.warning('Socket not connected or missing project ID');
|
logger.warning('Socket not connected or missing project ID');
|
||||||
return;
|
return;
|
||||||
@@ -40,54 +41,30 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
|
|||||||
// Use new bulk update approach - recalculate ALL task orders to prevent duplicates
|
// Use new bulk update approach - recalculate ALL task orders to prevent duplicates
|
||||||
const taskUpdates: any[] = [];
|
const taskUpdates: any[] = [];
|
||||||
|
|
||||||
// Create a copy of all groups and perform the move operation
|
// Create a copy of all groups
|
||||||
const updatedGroups = groups.map(group => ({
|
const updatedGroups = groups.map(g => ({
|
||||||
...group,
|
...g,
|
||||||
taskIds: [...group.taskIds]
|
taskIds: [...g.taskIds]
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Find the source and target groups in our copy
|
// Find the group in our copy
|
||||||
const sourceGroupCopy = updatedGroups.find(g => g.id === sourceGroup.id)!;
|
const groupCopy = updatedGroups.find(g => g.id === group.id)!;
|
||||||
const targetGroupCopy = updatedGroups.find(g => g.id === targetGroup.id)!;
|
|
||||||
|
|
||||||
if (sourceGroup.id === targetGroup.id) {
|
// Reorder within the group
|
||||||
// Same group - reorder within the group
|
const sourceIndex = groupCopy.taskIds.indexOf(taskId);
|
||||||
const sourceIndex = sourceGroupCopy.taskIds.indexOf(taskId);
|
// Remove task from old position
|
||||||
// Remove task from old position
|
groupCopy.taskIds.splice(sourceIndex, 1);
|
||||||
sourceGroupCopy.taskIds.splice(sourceIndex, 1);
|
// Insert at new position
|
||||||
// Insert at new position
|
groupCopy.taskIds.splice(insertIndex, 0, taskId);
|
||||||
sourceGroupCopy.taskIds.splice(insertIndex, 0, taskId);
|
|
||||||
} else {
|
|
||||||
// Different groups - move task between groups
|
|
||||||
// Remove from source group
|
|
||||||
const sourceIndex = sourceGroupCopy.taskIds.indexOf(taskId);
|
|
||||||
sourceGroupCopy.taskIds.splice(sourceIndex, 1);
|
|
||||||
|
|
||||||
// Add to target group
|
|
||||||
targetGroupCopy.taskIds.splice(insertIndex, 0, taskId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now assign sequential sort orders to ALL tasks across ALL groups
|
// Now assign sequential sort orders to ALL tasks across ALL groups
|
||||||
let currentSortOrder = 0;
|
let currentSortOrder = 0;
|
||||||
updatedGroups.forEach(group => {
|
updatedGroups.forEach(grp => {
|
||||||
group.taskIds.forEach(id => {
|
grp.taskIds.forEach(id => {
|
||||||
const update: any = {
|
taskUpdates.push({
|
||||||
task_id: id,
|
task_id: id,
|
||||||
sort_order: currentSortOrder
|
sort_order: currentSortOrder
|
||||||
};
|
});
|
||||||
|
|
||||||
// Add group-specific fields for the moved task if it changed groups
|
|
||||||
if (id === taskId && sourceGroup.id !== targetGroup.id) {
|
|
||||||
if (currentGrouping === 'status') {
|
|
||||||
update.status_id = targetGroup.id;
|
|
||||||
} else if (currentGrouping === 'priority') {
|
|
||||||
update.priority_id = targetGroup.id;
|
|
||||||
} else if (currentGrouping === 'phase') {
|
|
||||||
update.phase_id = targetGroup.id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
taskUpdates.push(update);
|
|
||||||
currentSortOrder++;
|
currentSortOrder++;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -96,8 +73,8 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
|
|||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
group_by: currentGrouping || 'status',
|
group_by: currentGrouping || 'status',
|
||||||
task_updates: taskUpdates,
|
task_updates: taskUpdates,
|
||||||
from_group: sourceGroup.id,
|
from_group: group.id,
|
||||||
to_group: targetGroup.id,
|
to_group: group.id,
|
||||||
task: {
|
task: {
|
||||||
id: task.id,
|
id: task.id,
|
||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
@@ -108,32 +85,6 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), socketData);
|
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), socketData);
|
||||||
|
|
||||||
// Also emit the specific grouping field change event for the moved task
|
|
||||||
if (sourceGroup.id !== targetGroup.id) {
|
|
||||||
if (currentGrouping === 'phase') {
|
|
||||||
// Emit phase change event
|
|
||||||
socket.emit(SocketEvents.TASK_PHASE_CHANGE.toString(), {
|
|
||||||
task_id: taskId,
|
|
||||||
phase_id: targetGroup.id,
|
|
||||||
parent_task: task.parent_task_id || null,
|
|
||||||
});
|
|
||||||
} else if (currentGrouping === 'priority') {
|
|
||||||
// Emit priority change event
|
|
||||||
socket.emit(SocketEvents.TASK_PRIORITY_CHANGE.toString(), JSON.stringify({
|
|
||||||
task_id: taskId,
|
|
||||||
priority_id: targetGroup.id,
|
|
||||||
team_id: teamId,
|
|
||||||
}));
|
|
||||||
} else if (currentGrouping === 'status') {
|
|
||||||
// Emit status change event
|
|
||||||
socket.emit(SocketEvents.TASK_STATUS_CHANGE.toString(), JSON.stringify({
|
|
||||||
task_id: taskId,
|
|
||||||
status_id: targetGroup.id,
|
|
||||||
team_id: teamId,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[socket, connected, projectId, allTasks, groups, currentGrouping, currentSession]
|
[socket, connected, projectId, allTasks, groups, currentGrouping, currentSession]
|
||||||
);
|
);
|
||||||
@@ -148,32 +99,38 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
|
|||||||
|
|
||||||
if (!over) {
|
if (!over) {
|
||||||
setOverId(null);
|
setOverId(null);
|
||||||
|
setDropPosition(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeId = active.id;
|
const activeTask = allTasks.find(task => task.id === active.id);
|
||||||
const overId = over.id;
|
const overTask = allTasks.find(task => task.id === over.id);
|
||||||
|
|
||||||
// Set the overId for drop indicators
|
if (activeTask && overTask) {
|
||||||
setOverId(overId as string);
|
const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id));
|
||||||
|
const overGroup = groups.find(group => group.taskIds.includes(overTask.id));
|
||||||
// Find the active task and the item being dragged over
|
|
||||||
const activeTask = allTasks.find(task => task.id === activeId);
|
// Only set overId if both tasks are in the same group
|
||||||
if (!activeTask) return;
|
if (activeGroup && overGroup && activeGroup.id === overGroup.id) {
|
||||||
|
setOverId(over.id as string);
|
||||||
// Check if we're dragging over a task or a group
|
|
||||||
const overTask = allTasks.find(task => task.id === overId);
|
// Calculate drop position based on task indices
|
||||||
const overGroup = groups.find(group => group.id === overId);
|
const activeIndex = activeGroup.taskIds.indexOf(activeTask.id);
|
||||||
|
const overIndex = activeGroup.taskIds.indexOf(overTask.id);
|
||||||
// Find the groups
|
|
||||||
const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id));
|
if (activeIndex < overIndex) {
|
||||||
let targetGroup = overGroup;
|
setDropPosition('after');
|
||||||
|
} else {
|
||||||
if (overTask) {
|
setDropPosition('before');
|
||||||
targetGroup = groups.find(group => group.taskIds.includes(overTask.id));
|
}
|
||||||
|
} else {
|
||||||
|
setOverId(null);
|
||||||
|
setDropPosition(null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setOverId(null);
|
||||||
|
setDropPosition(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!activeGroup || !targetGroup) return;
|
|
||||||
},
|
},
|
||||||
[allTasks, groups]
|
[allTasks, groups]
|
||||||
);
|
);
|
||||||
@@ -183,6 +140,7 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
|
|||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
setActiveId(null);
|
setActiveId(null);
|
||||||
setOverId(null);
|
setOverId(null);
|
||||||
|
setDropPosition(null);
|
||||||
|
|
||||||
if (!over || active.id === over.id) {
|
if (!over || active.id === over.id) {
|
||||||
return;
|
return;
|
||||||
@@ -198,86 +156,50 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the groups
|
// Find the active task's group
|
||||||
const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id));
|
const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id));
|
||||||
if (!activeGroup) {
|
if (!activeGroup) {
|
||||||
logger.error('Could not find active group for task:', activeId);
|
logger.error('Could not find active group for task:', activeId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we're dropping on a task, group, or empty group
|
// Only allow dropping on tasks in the same group
|
||||||
const overTask = allTasks.find(task => task.id === overId);
|
const overTask = allTasks.find(task => task.id === overId);
|
||||||
const overGroup = groups.find(group => group.id === overId);
|
if (!overTask) {
|
||||||
|
return;
|
||||||
// Check if dropping on empty group drop zone
|
}
|
||||||
const isEmptyGroupDrop = typeof overId === 'string' && overId.startsWith('empty-group-');
|
|
||||||
const emptyGroupId = isEmptyGroupDrop ? overId.replace('empty-group-', '') : null;
|
const overGroup = groups.find(group => group.taskIds.includes(overTask.id));
|
||||||
const emptyGroup = emptyGroupId ? groups.find(group => group.id === emptyGroupId) : null;
|
if (!overGroup || overGroup.id !== activeGroup.id) {
|
||||||
|
|
||||||
let targetGroup = overGroup || emptyGroup;
|
|
||||||
let insertIndex = 0;
|
|
||||||
|
|
||||||
if (overTask) {
|
|
||||||
// Dropping on a task
|
|
||||||
targetGroup = groups.find(group => group.taskIds.includes(overTask.id));
|
|
||||||
if (targetGroup) {
|
|
||||||
insertIndex = targetGroup.taskIds.indexOf(overTask.id);
|
|
||||||
}
|
|
||||||
} else if (overGroup) {
|
|
||||||
// Dropping on a group (at the end)
|
|
||||||
targetGroup = overGroup;
|
|
||||||
insertIndex = targetGroup.taskIds.length;
|
|
||||||
} else if (emptyGroup) {
|
|
||||||
// Dropping on an empty group
|
|
||||||
targetGroup = emptyGroup;
|
|
||||||
insertIndex = 0; // First position in empty group
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!targetGroup) {
|
|
||||||
logger.error('Could not find target group');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isCrossGroup = activeGroup.id !== targetGroup.id;
|
|
||||||
const activeIndex = activeGroup.taskIds.indexOf(activeTask.id);
|
const activeIndex = activeGroup.taskIds.indexOf(activeTask.id);
|
||||||
|
const overIndex = activeGroup.taskIds.indexOf(overTask.id);
|
||||||
|
|
||||||
if (isCrossGroup) {
|
if (activeIndex !== overIndex) {
|
||||||
// Moving task between groups
|
// Reorder task within same group
|
||||||
console.log('Moving task between groups:', {
|
|
||||||
task: activeTask.name || activeTask.title,
|
|
||||||
from: activeGroup.title,
|
|
||||||
to: targetGroup.title,
|
|
||||||
newPosition: insertIndex,
|
|
||||||
});
|
|
||||||
|
|
||||||
// reorderTasksInGroup handles both same-group and cross-group moves
|
|
||||||
// No need for separate moveTaskBetweenGroups call
|
|
||||||
dispatch(
|
dispatch(
|
||||||
reorderTasksInGroup({
|
reorderTasksInGroup({
|
||||||
sourceTaskId: activeId as string,
|
sourceTaskId: activeId as string,
|
||||||
destinationTaskId: over.id as string,
|
destinationTaskId: overId as string,
|
||||||
sourceGroupId: activeGroup.id,
|
sourceGroupId: activeGroup.id,
|
||||||
destinationGroupId: targetGroup.id,
|
destinationGroupId: activeGroup.id,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Emit socket event for persistence
|
// Calculate the final index after reordering for socket emission
|
||||||
emitTaskSortChange(activeId as string, activeGroup, targetGroup, insertIndex);
|
let finalIndex = overIndex;
|
||||||
} else {
|
if (activeIndex < overIndex) {
|
||||||
if (activeIndex !== insertIndex) {
|
// When dragging down, the task ends up just after the destination
|
||||||
// Reorder task within same group at drop position
|
finalIndex = overIndex;
|
||||||
dispatch(
|
} else {
|
||||||
reorderTasksInGroup({
|
// When dragging up, the task ends up at the destination position
|
||||||
sourceTaskId: activeId as string,
|
finalIndex = overIndex;
|
||||||
destinationTaskId: over.id as string,
|
|
||||||
sourceGroupId: activeGroup.id,
|
|
||||||
destinationGroupId: activeGroup.id,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Emit socket event for persistence
|
|
||||||
emitTaskSortChange(activeId as string, activeGroup, targetGroup, insertIndex);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Emit socket event for persistence
|
||||||
|
emitTaskSortChange(activeId as string, activeGroup, finalIndex);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[allTasks, groups, dispatch, emitTaskSortChange]
|
[allTasks, groups, dispatch, emitTaskSortChange]
|
||||||
@@ -286,6 +208,7 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
|
|||||||
return {
|
return {
|
||||||
activeId,
|
activeId,
|
||||||
overId,
|
overId,
|
||||||
|
dropPosition,
|
||||||
handleDragStart,
|
handleDragStart,
|
||||||
handleDragOver,
|
handleDragOver,
|
||||||
handleDragEnd,
|
handleDragEnd,
|
||||||
|
|||||||
@@ -90,17 +90,39 @@ export const useTaskRowColumns = ({
|
|||||||
depth = 0,
|
depth = 0,
|
||||||
}: UseTaskRowColumnsProps) => {
|
}: UseTaskRowColumnsProps) => {
|
||||||
|
|
||||||
const renderColumn = useCallback((columnId: string, width: string, isSticky?: boolean, index?: number) => {
|
const renderColumn = useCallback((columnId: string, width: string, isSticky?: boolean, index?: number, currentBg?: string, rowBackgrounds?: any) => {
|
||||||
switch (columnId) {
|
// Calculate left position for sticky columns
|
||||||
case 'dragHandle':
|
let leftPosition = 0;
|
||||||
return (
|
if (isSticky && typeof index === 'number') {
|
||||||
<DragHandleColumn
|
for (let i = 0; i < index; i++) {
|
||||||
width={width}
|
const prevColumn = visibleColumns[i];
|
||||||
isSubtask={isSubtask}
|
if (prevColumn.isSticky) {
|
||||||
attributes={attributes}
|
leftPosition += parseInt(prevColumn.width.replace('px', ''));
|
||||||
listeners={listeners}
|
}
|
||||||
/>
|
}
|
||||||
);
|
}
|
||||||
|
|
||||||
|
// Create wrapper style for sticky positioning
|
||||||
|
const wrapperStyle = isSticky ? {
|
||||||
|
position: 'sticky' as const,
|
||||||
|
left: leftPosition,
|
||||||
|
zIndex: 5, // Lower than header but above regular content
|
||||||
|
backgroundColor: currentBg || (isDarkMode ? '#1e1e1e' : '#ffffff'), // Use dynamic background or fallback
|
||||||
|
overflow: 'hidden', // Prevent content from spilling over
|
||||||
|
width: width, // Ensure the wrapper respects column width
|
||||||
|
} : {};
|
||||||
|
|
||||||
|
const renderColumnContent = () => {
|
||||||
|
switch (columnId) {
|
||||||
|
case 'dragHandle':
|
||||||
|
return (
|
||||||
|
<DragHandleColumn
|
||||||
|
width={width}
|
||||||
|
isSubtask={isSubtask}
|
||||||
|
attributes={attributes}
|
||||||
|
listeners={listeners}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
case 'checkbox':
|
case 'checkbox':
|
||||||
return (
|
return (
|
||||||
@@ -294,7 +316,27 @@ export const useTaskRowColumns = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Wrap content with sticky positioning if needed
|
||||||
|
const content = renderColumnContent();
|
||||||
|
if (isSticky) {
|
||||||
|
const hoverBg = rowBackgrounds?.hover || (isDarkMode ? '#2a2a2a' : '#f9fafb');
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...wrapperStyle,
|
||||||
|
'--hover-bg': hoverBg,
|
||||||
|
} as React.CSSProperties}
|
||||||
|
className="border-r border-gray-200 dark:border-gray-700 overflow-hidden sticky-column-hover hover:bg-[var(--hover-bg)]"
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
}, [
|
}, [
|
||||||
task,
|
task,
|
||||||
projectId,
|
projectId,
|
||||||
@@ -319,6 +361,7 @@ export const useTaskRowColumns = ({
|
|||||||
handleTaskNameEdit,
|
handleTaskNameEdit,
|
||||||
attributes,
|
attributes,
|
||||||
listeners,
|
listeners,
|
||||||
|
depth,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return { renderColumn };
|
return { renderColumn };
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
EyeOutlined,
|
EyeOutlined,
|
||||||
InboxOutlined,
|
InboxOutlined,
|
||||||
CheckOutlined,
|
CheckOutlined,
|
||||||
|
SortAscendingOutlined,
|
||||||
|
SortDescendingOutlined,
|
||||||
} from '@/shared/antd-imports';
|
} from '@/shared/antd-imports';
|
||||||
import { RootState } from '@/app/store';
|
import { RootState } from '@/app/store';
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
@@ -30,6 +32,12 @@ import {
|
|||||||
setArchived as setTaskManagementArchived,
|
setArchived as setTaskManagementArchived,
|
||||||
toggleArchived as toggleTaskManagementArchived,
|
toggleArchived as toggleTaskManagementArchived,
|
||||||
selectArchived,
|
selectArchived,
|
||||||
|
setSort,
|
||||||
|
setSortField,
|
||||||
|
setSortOrder,
|
||||||
|
selectSort,
|
||||||
|
selectSortField,
|
||||||
|
selectSortOrder,
|
||||||
} from '@/features/task-management/task-management.slice';
|
} from '@/features/task-management/task-management.slice';
|
||||||
import {
|
import {
|
||||||
setCurrentGrouping,
|
setCurrentGrouping,
|
||||||
@@ -44,11 +52,13 @@ import {
|
|||||||
setLabels,
|
setLabels,
|
||||||
setSearch,
|
setSearch,
|
||||||
setPriorities,
|
setPriorities,
|
||||||
|
setFields,
|
||||||
} from '@/features/tasks/tasks.slice';
|
} from '@/features/tasks/tasks.slice';
|
||||||
import { getTeamMembers } from '@/features/team-members/team-members.slice';
|
import { getTeamMembers } from '@/features/team-members/team-members.slice';
|
||||||
import { ITaskPriority } from '@/types/tasks/taskPriority.types';
|
import { ITaskPriority } from '@/types/tasks/taskPriority.types';
|
||||||
import { ITaskListColumn } from '@/types/tasks/taskList.types';
|
import { ITaskListColumn } from '@/types/tasks/taskList.types';
|
||||||
import { IGroupBy } from '@/features/tasks/tasks.slice';
|
import { IGroupBy } from '@/features/tasks/tasks.slice';
|
||||||
|
import { ITaskListSortableColumn } from '@/types/tasks/taskListFilters.types';
|
||||||
// --- Enhanced Kanban imports ---
|
// --- Enhanced Kanban imports ---
|
||||||
import {
|
import {
|
||||||
setGroupBy as setKanbanGroupBy,
|
setGroupBy as setKanbanGroupBy,
|
||||||
@@ -84,6 +94,12 @@ const FILTER_DEBOUNCE_DELAY = 300; // ms
|
|||||||
const SEARCH_DEBOUNCE_DELAY = 500; // ms
|
const SEARCH_DEBOUNCE_DELAY = 500; // ms
|
||||||
const MAX_FILTER_OPTIONS = 100;
|
const MAX_FILTER_OPTIONS = 100;
|
||||||
|
|
||||||
|
// Sort order enum
|
||||||
|
enum SORT_ORDER {
|
||||||
|
ASCEND = 'ascend',
|
||||||
|
DESCEND = 'descend',
|
||||||
|
}
|
||||||
|
|
||||||
// Limit options to prevent UI lag
|
// Limit options to prevent UI lag
|
||||||
|
|
||||||
// Optimized selectors with proper transformation logic
|
// 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 LOCAL_STORAGE_KEY = 'worklenz.taskManagement.fields';
|
||||||
|
|
||||||
const FieldsDropdown: React.FC<{ themeClasses: any; isDarkMode: boolean }> = ({
|
const FieldsDropdown: React.FC<{ themeClasses: any; isDarkMode: boolean }> = ({
|
||||||
@@ -1050,14 +1252,20 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
|
|||||||
};
|
};
|
||||||
}, [dispatch, projectView]);
|
}, [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
|
// Calculate active filters count - memoized to prevent unnecessary recalculations
|
||||||
const calculatedActiveFiltersCount = useMemo(() => {
|
const calculatedActiveFiltersCount = useMemo(() => {
|
||||||
const count = filterSections.reduce(
|
const count = filterSections.reduce(
|
||||||
(acc, section) => (section.id === 'groupBy' ? acc : acc + section.selectedValues.length),
|
(acc, section) => (section.id === 'groupBy' ? acc : acc + section.selectedValues.length),
|
||||||
0
|
0
|
||||||
);
|
);
|
||||||
return count + (searchValue ? 1 : 0);
|
const sortFieldsCount = position === 'list' ? sortFields.length : 0;
|
||||||
}, [filterSections, searchValue]);
|
const taskManagementSortCount = position === 'list' && taskManagementSortField ? 1 : 0;
|
||||||
|
return count + (searchValue ? 1 : 0) + sortFieldsCount + taskManagementSortCount;
|
||||||
|
}, [filterSections, searchValue, sortFields, taskManagementSortField, position]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeFiltersCount !== calculatedActiveFiltersCount) {
|
if (activeFiltersCount !== calculatedActiveFiltersCount) {
|
||||||
@@ -1231,6 +1439,12 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
|
|||||||
// Clear priority filters
|
// Clear priority filters
|
||||||
dispatch(setPriorities([]));
|
dispatch(setPriorities([]));
|
||||||
|
|
||||||
|
// Clear sort fields
|
||||||
|
dispatch(setFields([]));
|
||||||
|
|
||||||
|
// Clear sort from task-management slice
|
||||||
|
dispatch(setSort({ field: '', order: 'ASC' }));
|
||||||
|
|
||||||
// Clear archived state based on position
|
// Clear archived state based on position
|
||||||
if (position === 'list') {
|
if (position === 'list') {
|
||||||
dispatch(setTaskManagementArchived(false));
|
dispatch(setTaskManagementArchived(false));
|
||||||
@@ -1276,9 +1490,9 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
|
|||||||
<div
|
<div
|
||||||
className={`${themeClasses.containerBg} border ${themeClasses.containerBorder} rounded-md p-1.5 shadow-sm ${className}`}
|
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 */}
|
{/* 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 */}
|
{/* Search */}
|
||||||
<SearchFilter
|
<SearchFilter
|
||||||
value={searchValue}
|
value={searchValue}
|
||||||
@@ -1287,6 +1501,11 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
|
|||||||
themeClasses={themeClasses}
|
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 */}
|
{/* Filter Dropdowns - Only render when data is loaded */}
|
||||||
{isDataLoaded ? (
|
{isDataLoaded ? (
|
||||||
filterSectionsData.map(section => (
|
filterSectionsData.map(section => (
|
||||||
@@ -1316,7 +1535,7 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Section - Additional Controls */}
|
{/* 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 */}
|
{/* Active Filters Indicator */}
|
||||||
{activeFiltersCount > 0 && (
|
{activeFiltersCount > 0 && (
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { Task } from '@/types/task-management.types';
|
|||||||
import {
|
import {
|
||||||
updateTask,
|
updateTask,
|
||||||
selectCurrentGroupingV3,
|
selectCurrentGroupingV3,
|
||||||
|
selectGroups,
|
||||||
|
moveTaskBetweenGroups,
|
||||||
} from '@/features/task-management/task-management.slice';
|
} from '@/features/task-management/task-management.slice';
|
||||||
|
|
||||||
interface TaskStatusDropdownProps {
|
interface TaskStatusDropdownProps {
|
||||||
@@ -30,6 +32,7 @@ const TaskStatusDropdown: React.FC<TaskStatusDropdownProps> = ({
|
|||||||
|
|
||||||
const statusList = useAppSelector(state => state.taskStatusReducer.status);
|
const statusList = useAppSelector(state => state.taskStatusReducer.status);
|
||||||
const currentGroupingV3 = useAppSelector(selectCurrentGroupingV3);
|
const currentGroupingV3 = useAppSelector(selectCurrentGroupingV3);
|
||||||
|
const groups = useAppSelector(selectGroups);
|
||||||
|
|
||||||
// Find current status details
|
// Find current status details
|
||||||
const currentStatus = useMemo(() => {
|
const currentStatus = useMemo(() => {
|
||||||
@@ -44,21 +47,53 @@ const TaskStatusDropdown: React.FC<TaskStatusDropdownProps> = ({
|
|||||||
(statusId: string, statusName: string) => {
|
(statusId: string, statusName: string) => {
|
||||||
if (!task.id || !statusId || !connected) return;
|
if (!task.id || !statusId || !connected) return;
|
||||||
|
|
||||||
console.log('🎯 Status change initiated:', { taskId: task.id, statusId, statusName });
|
// Optimistic update: immediately update the task status in Redux for instant feedback
|
||||||
|
const updatedTask = {
|
||||||
|
...task,
|
||||||
|
status: statusId,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
dispatch(updateTask(updatedTask));
|
||||||
|
|
||||||
|
// Handle group movement if grouping by status
|
||||||
|
if (currentGroupingV3 === 'status' && groups && groups.length > 0) {
|
||||||
|
// Find current group containing the task
|
||||||
|
const currentGroup = groups.find(group => group.taskIds.includes(task.id));
|
||||||
|
|
||||||
|
// Find target group based on the new status ID
|
||||||
|
let targetGroup = groups.find(group => group.id === statusId);
|
||||||
|
|
||||||
|
// If not found by status ID, try matching with group value
|
||||||
|
if (!targetGroup) {
|
||||||
|
targetGroup = groups.find(group => group.groupValue === statusId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentGroup && targetGroup && currentGroup.id !== targetGroup.id) {
|
||||||
|
// Move task between groups immediately for instant feedback
|
||||||
|
dispatch(
|
||||||
|
moveTaskBetweenGroups({
|
||||||
|
taskId: task.id,
|
||||||
|
sourceGroupId: currentGroup.id,
|
||||||
|
targetGroupId: targetGroup.id,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit socket event for server-side update and real-time sync
|
||||||
socket?.emit(
|
socket?.emit(
|
||||||
SocketEvents.TASK_STATUS_CHANGE.toString(),
|
SocketEvents.TASK_STATUS_CHANGE.toString(),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
task_id: task.id,
|
task_id: task.id,
|
||||||
status_id: statusId,
|
status_id: statusId,
|
||||||
parent_task: null, // Assuming top-level tasks for now
|
parent_task: task.parent_task_id || null,
|
||||||
team_id: projectId, // Using projectId as teamId
|
team_id: projectId,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id);
|
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
},
|
},
|
||||||
[task.id, connected, socket, projectId]
|
[task, connected, socket, projectId, dispatch, currentGroupingV3, groups]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Calculate dropdown position and handle outside clicks
|
// Calculate dropdown position and handle outside clicks
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
// Update Notification Component
|
||||||
|
// Shows a notification when new build is available and provides update options
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Modal, Button, Space, Typography } from '@/shared/antd-imports';
|
||||||
|
import { ReloadOutlined, CloseOutlined, DownloadOutlined } from '@/shared/antd-imports';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useServiceWorker } from '../../utils/serviceWorkerRegistration';
|
||||||
|
|
||||||
|
const { Text, Title } = Typography;
|
||||||
|
|
||||||
|
interface UpdateNotificationProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onUpdate: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UpdateNotification: React.FC<UpdateNotificationProps> = ({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
onUpdate
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation('common');
|
||||||
|
const [isUpdating, setIsUpdating] = React.useState(false);
|
||||||
|
const { hardReload } = useServiceWorker();
|
||||||
|
|
||||||
|
const handleUpdate = async () => {
|
||||||
|
setIsUpdating(true);
|
||||||
|
try {
|
||||||
|
if (hardReload) {
|
||||||
|
await hardReload();
|
||||||
|
} else {
|
||||||
|
// Fallback to regular reload
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
onUpdate();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during update:', error);
|
||||||
|
// Fallback to regular reload
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLater = () => {
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<DownloadOutlined style={{ color: '#1890ff' }} />
|
||||||
|
<Title level={4} style={{ margin: 0, color: '#1890ff' }}>
|
||||||
|
{t('update-available')}
|
||||||
|
</Title>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
open={visible}
|
||||||
|
onCancel={handleLater}
|
||||||
|
footer={null}
|
||||||
|
centered
|
||||||
|
closable={false}
|
||||||
|
maskClosable={false}
|
||||||
|
width={460}
|
||||||
|
styles={{
|
||||||
|
body: { padding: '20px 24px' }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: '20px' }}>
|
||||||
|
<Text style={{ fontSize: '16px', lineHeight: '1.6' }}>
|
||||||
|
{t('update-description')}
|
||||||
|
</Text>
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<Text style={{ fontSize: '14px', color: '#8c8c8c' }}>
|
||||||
|
{t('update-instruction')}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
background: '#f6ffed',
|
||||||
|
border: '1px solid #b7eb8f',
|
||||||
|
borderRadius: '6px',
|
||||||
|
padding: '12px',
|
||||||
|
marginBottom: '20px'
|
||||||
|
}}>
|
||||||
|
<Text style={{ fontSize: '13px', color: '#389e0d' }}>
|
||||||
|
{t('update-whats-new', {
|
||||||
|
interpolation: { escapeValue: false }
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Space
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
justifyContent: 'flex-end'
|
||||||
|
}}
|
||||||
|
size="middle"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
icon={<CloseOutlined />}
|
||||||
|
onClick={handleLater}
|
||||||
|
disabled={isUpdating}
|
||||||
|
>
|
||||||
|
{t('update-later')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
loading={isUpdating}
|
||||||
|
onClick={handleUpdate}
|
||||||
|
>
|
||||||
|
{isUpdating ? t('updating') : t('update-now')}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UpdateNotification;
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// Update Notification Provider
|
||||||
|
// Provides global update notification management
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useUpdateChecker } from '../../hooks/useUpdateChecker';
|
||||||
|
import UpdateNotification from './UpdateNotification';
|
||||||
|
|
||||||
|
interface UpdateNotificationProviderProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
checkInterval?: number;
|
||||||
|
enableAutoCheck?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UpdateNotificationProvider: React.FC<UpdateNotificationProviderProps> = ({
|
||||||
|
children,
|
||||||
|
checkInterval = 5 * 60 * 1000, // 5 minutes
|
||||||
|
enableAutoCheck = true
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
showUpdateNotification,
|
||||||
|
setShowUpdateNotification,
|
||||||
|
dismissUpdate
|
||||||
|
} = useUpdateChecker({
|
||||||
|
checkInterval,
|
||||||
|
enableAutoCheck,
|
||||||
|
showNotificationOnUpdate: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
dismissUpdate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdate = () => {
|
||||||
|
// The hardReload function in UpdateNotification will handle the actual update
|
||||||
|
setShowUpdateNotification(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{children}
|
||||||
|
<UpdateNotification
|
||||||
|
visible={showUpdateNotification}
|
||||||
|
onClose={handleClose}
|
||||||
|
onUpdate={handleUpdate}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UpdateNotificationProvider;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as UpdateNotification } from './UpdateNotification';
|
||||||
|
export { default as UpdateNotificationProvider } from './UpdateNotificationProvider';
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user