Compare commits

..

2 Commits

Author SHA1 Message Date
chamikaJ
7aeaaa1fee docs(job-queue-dependencies): add documentation for job queue setup and configuration
- Created a new document detailing the dependencies required for the job queue implementation using Bull and IORedis.
- Included installation instructions, Redis setup, environment variable configuration, and usage examples for the RecurringTasksService.
- Highlighted the benefits of using job queues over cron jobs and provided monitoring options for job queues.
2025-07-20 19:16:24 +05:30
chamikaJ
474f1afe66 feat(recurring-tasks): implement recurring tasks service with timezone support and notifications
- Added a new service for managing recurring tasks, allowing configuration of task schedules with timezone support.
- Introduced job queues for processing recurring tasks and handling task creation in bulk.
- Implemented notification system to alert users about newly created recurring tasks, including email and in-app notifications.
- Enhanced database schema with new tables for notifications and audit logs to track recurring task operations.
- Updated frontend components to support timezone selection and manage excluded dates for recurring tasks.
- Refactored existing code to integrate new features and improve overall task management experience.
2025-07-20 19:16:03 +05:30
597 changed files with 6017 additions and 14472 deletions

View File

@@ -4,11 +4,7 @@
"Bash(find:*)",
"Bash(npm run build:*)",
"Bash(npm run type-check:*)",
"Bash(npm run:*)",
"Bash(move:*)",
"Bash(mv:*)",
"Bash(grep:*)",
"Bash(rm:*)"
"Bash(npm run:*)"
],
"deny": []
}

View File

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

View File

@@ -0,0 +1,111 @@
# Job Queue Dependencies
To use the job queue implementation for recurring tasks, add these dependencies to your package.json:
```json
{
"dependencies": {
"bull": "^4.12.2",
"ioredis": "^5.3.2"
},
"devDependencies": {
"@types/bull": "^4.10.0"
}
}
```
## Installation
```bash
npm install bull ioredis
npm install --save-dev @types/bull
```
## Redis Setup
1. Install Redis on your system:
- **Ubuntu/Debian**: `sudo apt install redis-server`
- **macOS**: `brew install redis`
- **Windows**: Use WSL or Redis for Windows
- **Docker**: `docker run -d -p 6379:6379 redis:alpine`
2. Configure Redis connection in your environment variables:
```env
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=your_password # Optional
REDIS_DB=0
```
## Configuration
Add these environment variables to control the recurring tasks behavior:
```env
# Service configuration
RECURRING_TASKS_ENABLED=true
RECURRING_TASKS_MODE=queue # or 'cron'
# Queue configuration
RECURRING_TASKS_MAX_CONCURRENCY=5
RECURRING_TASKS_RETRY_ATTEMPTS=3
RECURRING_TASKS_RETRY_DELAY=2000
# Notifications
RECURRING_TASKS_NOTIFICATIONS_ENABLED=true
RECURRING_TASKS_EMAIL_NOTIFICATIONS=true
RECURRING_TASKS_PUSH_NOTIFICATIONS=true
RECURRING_TASKS_IN_APP_NOTIFICATIONS=true
# Audit logging
RECURRING_TASKS_AUDIT_LOG_ENABLED=true
RECURRING_TASKS_AUDIT_RETENTION_DAYS=90
```
## Usage
In your main application file, start the service:
```typescript
import { RecurringTasksService } from './src/services/recurring-tasks-service';
// Start the service
await RecurringTasksService.start();
// Get status
const status = await RecurringTasksService.getStatus();
console.log('Recurring tasks status:', status);
// Health check
const health = await RecurringTasksService.healthCheck();
console.log('Health check:', health);
```
## Benefits of Job Queue vs Cron
### Job Queue (Bull/BullMQ) Benefits:
- **Better scalability**: Can run multiple workers
- **Retry logic**: Built-in retry with exponential backoff
- **Monitoring**: Redis-based job monitoring and UI
- **Priority queues**: Handle urgent tasks first
- **Rate limiting**: Control processing rate
- **Persistence**: Jobs survive server restarts
### Cron Job Benefits:
- **Simplicity**: No external dependencies
- **Lower resource usage**: No Redis required
- **Predictable timing**: Runs exactly on schedule
- **Easier debugging**: Simpler execution model
## Monitoring
You can monitor the job queues using:
- **Bull Dashboard**: Web UI for monitoring jobs
- **Redis CLI**: Direct Redis monitoring
- **Application logs**: Built-in audit logging
- **Health checks**: Built-in health check endpoint
Install Bull Dashboard for monitoring:
```bash
npm install -g bull-board
```

382
package-lock.json generated
View File

@@ -2,5 +2,385 @@
"name": "worklenz",
"lockfileVersion": 3,
"requires": true,
"packages": {}
"packages": {
"": {
"dependencies": {
"bull": "^4.16.5",
"ioredis": "^5.6.1"
},
"devDependencies": {
"@types/bull": "^3.15.9"
}
},
"node_modules/@ioredis/commands": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz",
"integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==",
"license": "MIT"
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
"integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz",
"integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz",
"integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz",
"integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz",
"integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz",
"integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@types/bull": {
"version": "3.15.9",
"resolved": "https://registry.npmjs.org/@types/bull/-/bull-3.15.9.tgz",
"integrity": "sha512-MPUcyPPQauAmynoO3ezHAmCOhbB0pWmYyijr/5ctaCqhbKWsjW0YCod38ZcLzUBprosfZ9dPqfYIcfdKjk7RNQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/ioredis": "*",
"@types/redis": "^2.8.0"
}
},
"node_modules/@types/ioredis": {
"version": "4.28.10",
"resolved": "https://registry.npmjs.org/@types/ioredis/-/ioredis-4.28.10.tgz",
"integrity": "sha512-69LyhUgrXdgcNDv7ogs1qXZomnfOEnSmrmMFqKgt1XMJxmoOSG/u3wYy13yACIfKuMJ8IhKgHafDO3sx19zVQQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/node": {
"version": "24.0.15",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.15.tgz",
"integrity": "sha512-oaeTSbCef7U/z7rDeJA138xpG3NuKc64/rZ2qmUFkFJmnMsAPaluIifqyWd8hSSMxyP9oie3dLAqYPblag9KgA==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.8.0"
}
},
"node_modules/@types/redis": {
"version": "2.8.32",
"resolved": "https://registry.npmjs.org/@types/redis/-/redis-2.8.32.tgz",
"integrity": "sha512-7jkMKxcGq9p242exlbsVzuJb57KqHRhNl4dHoQu2Y5v9bCAbtIXXH0R3HleSQW4CTOqpHIYUW3t6tpUj4BVQ+w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/bull": {
"version": "4.16.5",
"resolved": "https://registry.npmjs.org/bull/-/bull-4.16.5.tgz",
"integrity": "sha512-lDsx2BzkKe7gkCYiT5Acj02DpTwDznl/VNN7Psn7M3USPG7Vs/BaClZJJTAG+ufAR9++N1/NiUTdaFBWDIl5TQ==",
"license": "MIT",
"dependencies": {
"cron-parser": "^4.9.0",
"get-port": "^5.1.1",
"ioredis": "^5.3.2",
"lodash": "^4.17.21",
"msgpackr": "^1.11.2",
"semver": "^7.5.2",
"uuid": "^8.3.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/cron-parser": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz",
"integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==",
"license": "MIT",
"dependencies": {
"luxon": "^3.2.1"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/debug": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10"
}
},
"node_modules/detect-libc": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
"integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==",
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/get-port": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz",
"integrity": "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==",
"license": "MIT",
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ioredis": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.6.1.tgz",
"integrity": "sha512-UxC0Yv1Y4WRJiGQxQkP0hfdL0/5/6YvdfOOClRgJ0qppSarkhneSa6UvkMkms0AkdGimSH3Ikqm+6mkMmX7vGA==",
"license": "MIT",
"dependencies": {
"@ioredis/commands": "^1.1.1",
"cluster-key-slot": "^1.1.0",
"debug": "^4.3.4",
"denque": "^2.1.0",
"lodash.defaults": "^4.2.0",
"lodash.isarguments": "^3.1.0",
"redis-errors": "^1.2.0",
"redis-parser": "^3.0.0",
"standard-as-callback": "^2.1.0"
},
"engines": {
"node": ">=12.22.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/ioredis"
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
"node_modules/lodash.defaults": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
"license": "MIT"
},
"node_modules/lodash.isarguments": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
"license": "MIT"
},
"node_modules/luxon": {
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.1.tgz",
"integrity": "sha512-RkRWjA926cTvz5rAb1BqyWkKbbjzCGchDUIKMCUvNi17j6f6j8uHGDV82Aqcqtzd+icoYpELmG3ksgGiFNNcNg==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/msgpackr": {
"version": "1.11.5",
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.5.tgz",
"integrity": "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==",
"license": "MIT",
"optionalDependencies": {
"msgpackr-extract": "^3.0.2"
}
},
"node_modules/msgpackr-extract": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz",
"integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"node-gyp-build-optional-packages": "5.2.2"
},
"bin": {
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
},
"optionalDependencies": {
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3",
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3"
}
},
"node_modules/node-gyp-build-optional-packages": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
"license": "MIT",
"optional": true,
"dependencies": {
"detect-libc": "^2.0.1"
},
"bin": {
"node-gyp-build-optional-packages": "bin.js",
"node-gyp-build-optional-packages-optional": "optional.js",
"node-gyp-build-optional-packages-test": "build-test.js"
}
},
"node_modules/redis-errors": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/redis-parser": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
"license": "MIT",
"dependencies": {
"redis-errors": "^1.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/semver": {
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/standard-as-callback": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
"license": "MIT"
},
"node_modules/undici-types": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz",
"integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==",
"dev": true,
"license": "MIT"
},
"node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
}
}
}

9
package.json Normal file
View File

@@ -0,0 +1,9 @@
{
"dependencies": {
"bull": "^4.16.5",
"ioredis": "^5.6.1"
},
"devDependencies": {
"@types/bull": "^3.15.9"
}
}

View File

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

View File

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

View File

@@ -0,0 +1,185 @@
-- Function to create multiple recurring tasks in bulk
CREATE OR REPLACE FUNCTION create_bulk_recurring_tasks(
p_tasks JSONB
)
RETURNS TABLE (
task_id UUID,
task_name TEXT,
created BOOLEAN,
error_message TEXT
) AS $$
DECLARE
v_task JSONB;
v_task_id UUID;
v_existing_id UUID;
v_error_message TEXT;
BEGIN
-- Create a temporary table to store results
CREATE TEMP TABLE IF NOT EXISTS bulk_task_results (
task_id UUID,
task_name TEXT,
created BOOLEAN,
error_message TEXT
) ON COMMIT DROP;
-- Iterate through each task in the array
FOR v_task IN SELECT * FROM jsonb_array_elements(p_tasks)
LOOP
BEGIN
-- Check if task already exists for this schedule and date
SELECT id INTO v_existing_id
FROM tasks
WHERE schedule_id = (v_task->>'schedule_id')::UUID
AND end_date::DATE = (v_task->>'end_date')::DATE
LIMIT 1;
IF v_existing_id IS NOT NULL THEN
-- Task already exists
INSERT INTO bulk_task_results (task_id, task_name, created, error_message)
VALUES (v_existing_id, v_task->>'name', FALSE, 'Task already exists for this date');
ELSE
-- Create the task using existing function
SELECT (create_quick_task(v_task::TEXT)::JSONB)->>'id' INTO v_task_id;
IF v_task_id IS NOT NULL THEN
INSERT INTO bulk_task_results (task_id, task_name, created, error_message)
VALUES (v_task_id::UUID, v_task->>'name', TRUE, NULL);
ELSE
INSERT INTO bulk_task_results (task_id, task_name, created, error_message)
VALUES (NULL, v_task->>'name', FALSE, 'Failed to create task');
END IF;
END IF;
EXCEPTION WHEN OTHERS THEN
-- Capture any errors
v_error_message := SQLERRM;
INSERT INTO bulk_task_results (task_id, task_name, created, error_message)
VALUES (NULL, v_task->>'name', FALSE, v_error_message);
END;
END LOOP;
-- Return all results
RETURN QUERY SELECT * FROM bulk_task_results;
END;
$$ LANGUAGE plpgsql;
-- Function to bulk assign team members to tasks
CREATE OR REPLACE FUNCTION bulk_assign_team_members(
p_assignments JSONB
)
RETURNS TABLE (
task_id UUID,
team_member_id UUID,
assigned BOOLEAN,
error_message TEXT
) AS $$
DECLARE
v_assignment JSONB;
v_result RECORD;
BEGIN
CREATE TEMP TABLE IF NOT EXISTS bulk_assignment_results (
task_id UUID,
team_member_id UUID,
assigned BOOLEAN,
error_message TEXT
) ON COMMIT DROP;
FOR v_assignment IN SELECT * FROM jsonb_array_elements(p_assignments)
LOOP
BEGIN
-- Check if assignment already exists
IF EXISTS (
SELECT 1 FROM tasks_assignees
WHERE task_id = (v_assignment->>'task_id')::UUID
AND team_member_id = (v_assignment->>'team_member_id')::UUID
) THEN
INSERT INTO bulk_assignment_results
VALUES (
(v_assignment->>'task_id')::UUID,
(v_assignment->>'team_member_id')::UUID,
FALSE,
'Assignment already exists'
);
ELSE
-- Create the assignment
INSERT INTO tasks_assignees (task_id, team_member_id, assigned_by)
VALUES (
(v_assignment->>'task_id')::UUID,
(v_assignment->>'team_member_id')::UUID,
(v_assignment->>'assigned_by')::UUID
);
INSERT INTO bulk_assignment_results
VALUES (
(v_assignment->>'task_id')::UUID,
(v_assignment->>'team_member_id')::UUID,
TRUE,
NULL
);
END IF;
EXCEPTION WHEN OTHERS THEN
INSERT INTO bulk_assignment_results
VALUES (
(v_assignment->>'task_id')::UUID,
(v_assignment->>'team_member_id')::UUID,
FALSE,
SQLERRM
);
END;
END LOOP;
RETURN QUERY SELECT * FROM bulk_assignment_results;
END;
$$ LANGUAGE plpgsql;
-- Function to bulk assign labels to tasks
CREATE OR REPLACE FUNCTION bulk_assign_labels(
p_label_assignments JSONB
)
RETURNS TABLE (
task_id UUID,
label_id UUID,
assigned BOOLEAN,
error_message TEXT
) AS $$
DECLARE
v_assignment JSONB;
v_labels JSONB;
BEGIN
CREATE TEMP TABLE IF NOT EXISTS bulk_label_results (
task_id UUID,
label_id UUID,
assigned BOOLEAN,
error_message TEXT
) ON COMMIT DROP;
FOR v_assignment IN SELECT * FROM jsonb_array_elements(p_label_assignments)
LOOP
BEGIN
-- Use existing function to add label
SELECT add_or_remove_task_label(
(v_assignment->>'task_id')::UUID,
(v_assignment->>'label_id')::UUID
) INTO v_labels;
INSERT INTO bulk_label_results
VALUES (
(v_assignment->>'task_id')::UUID,
(v_assignment->>'label_id')::UUID,
TRUE,
NULL
);
EXCEPTION WHEN OTHERS THEN
INSERT INTO bulk_label_results
VALUES (
(v_assignment->>'task_id')::UUID,
(v_assignment->>'label_id')::UUID,
FALSE,
SQLERRM
);
END;
END LOOP;
RETURN QUERY SELECT * FROM bulk_label_results;
END;
$$ LANGUAGE plpgsql;

View File

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

View File

@@ -0,0 +1,40 @@
-- Create notifications table if it doesn't exist
CREATE TABLE IF NOT EXISTS notifications (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
message TEXT NOT NULL,
data JSONB,
read BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
read_at TIMESTAMP WITH TIME ZONE
);
-- Create user_push_tokens table if it doesn't exist (for future push notifications)
CREATE TABLE IF NOT EXISTS user_push_tokens (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
push_token TEXT NOT NULL,
device_type VARCHAR(20),
active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, push_token)
);
-- Add notification preferences to users table if they don't exist
ALTER TABLE users
ADD COLUMN IF NOT EXISTS email_notifications BOOLEAN DEFAULT TRUE,
ADD COLUMN IF NOT EXISTS push_notifications BOOLEAN DEFAULT TRUE,
ADD COLUMN IF NOT EXISTS in_app_notifications BOOLEAN DEFAULT TRUE;
-- Create indexes for better performance
CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id);
CREATE INDEX IF NOT EXISTS idx_notifications_created_at ON notifications(created_at);
CREATE INDEX IF NOT EXISTS idx_notifications_unread ON notifications(user_id, read) WHERE read = FALSE;
CREATE INDEX IF NOT EXISTS idx_user_push_tokens_user_id ON user_push_tokens(user_id);
-- Comments
COMMENT ON TABLE notifications IS 'In-app notifications for users';
COMMENT ON TABLE user_push_tokens IS 'Push notification tokens for mobile devices';
COMMENT ON COLUMN notifications.data IS 'Additional notification data in JSON format';
COMMENT ON COLUMN user_push_tokens.device_type IS 'Device type: ios, android, web';

View File

@@ -0,0 +1,94 @@
-- Create audit log table for recurring task operations
CREATE TABLE IF NOT EXISTS recurring_tasks_audit_log (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
operation_type VARCHAR(50) NOT NULL,
template_id UUID,
schedule_id UUID,
task_id UUID,
template_name TEXT,
success BOOLEAN DEFAULT TRUE,
error_message TEXT,
details JSONB,
created_tasks_count INTEGER DEFAULT 0,
failed_tasks_count INTEGER DEFAULT 0,
execution_time_ms INTEGER,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
created_by UUID REFERENCES users(id)
);
-- Create indexes for better query performance
CREATE INDEX idx_recurring_tasks_audit_log_template_id ON recurring_tasks_audit_log(template_id);
CREATE INDEX idx_recurring_tasks_audit_log_schedule_id ON recurring_tasks_audit_log(schedule_id);
CREATE INDEX idx_recurring_tasks_audit_log_created_at ON recurring_tasks_audit_log(created_at);
CREATE INDEX idx_recurring_tasks_audit_log_operation_type ON recurring_tasks_audit_log(operation_type);
-- Add comments
COMMENT ON TABLE recurring_tasks_audit_log IS 'Audit log for all recurring task operations';
COMMENT ON COLUMN recurring_tasks_audit_log.operation_type IS 'Type of operation: cron_job_run, manual_trigger, schedule_created, schedule_updated, schedule_deleted, etc.';
COMMENT ON COLUMN recurring_tasks_audit_log.details IS 'Additional details about the operation in JSON format';
-- Create a function to log recurring task operations
CREATE OR REPLACE FUNCTION log_recurring_task_operation(
p_operation_type VARCHAR(50),
p_template_id UUID DEFAULT NULL,
p_schedule_id UUID DEFAULT NULL,
p_task_id UUID DEFAULT NULL,
p_template_name TEXT DEFAULT NULL,
p_success BOOLEAN DEFAULT TRUE,
p_error_message TEXT DEFAULT NULL,
p_details JSONB DEFAULT NULL,
p_created_tasks_count INTEGER DEFAULT 0,
p_failed_tasks_count INTEGER DEFAULT 0,
p_execution_time_ms INTEGER DEFAULT NULL,
p_created_by UUID DEFAULT NULL
)
RETURNS UUID AS $$
DECLARE
v_log_id UUID;
BEGIN
INSERT INTO recurring_tasks_audit_log (
operation_type,
template_id,
schedule_id,
task_id,
template_name,
success,
error_message,
details,
created_tasks_count,
failed_tasks_count,
execution_time_ms,
created_by
) VALUES (
p_operation_type,
p_template_id,
p_schedule_id,
p_task_id,
p_template_name,
p_success,
p_error_message,
p_details,
p_created_tasks_count,
p_failed_tasks_count,
p_execution_time_ms,
p_created_by
) RETURNING id INTO v_log_id;
RETURN v_log_id;
END;
$$ LANGUAGE plpgsql;
-- Create a view for recent audit logs
CREATE OR REPLACE VIEW v_recent_recurring_tasks_audit AS
SELECT
l.*,
u.name as created_by_name,
t.name as current_template_name,
s.schedule_type,
s.timezone
FROM recurring_tasks_audit_log l
LEFT JOIN users u ON l.created_by = u.id
LEFT JOIN task_recurring_templates t ON l.template_id = t.id
LEFT JOIN task_recurring_schedules s ON l.schedule_id = s.id
WHERE l.created_at >= CURRENT_TIMESTAMP - INTERVAL '30 days'
ORDER BY l.created_at DESC;

View File

@@ -0,0 +1,44 @@
-- Add timezone support to recurring tasks
-- Add timezone column to task_recurring_schedules
ALTER TABLE task_recurring_schedules
ADD COLUMN IF NOT EXISTS timezone VARCHAR(50) DEFAULT 'UTC';
-- Add timezone column to task_recurring_templates
ALTER TABLE task_recurring_templates
ADD COLUMN IF NOT EXISTS reporter_timezone VARCHAR(50);
-- Add date_of_month column if not exists (for monthly schedules)
ALTER TABLE task_recurring_schedules
ADD COLUMN IF NOT EXISTS date_of_month INTEGER;
-- Add last_checked_at and last_created_task_end_date columns for tracking
ALTER TABLE task_recurring_schedules
ADD COLUMN IF NOT EXISTS last_checked_at TIMESTAMP WITH TIME ZONE,
ADD COLUMN IF NOT EXISTS last_created_task_end_date TIMESTAMP WITH TIME ZONE;
-- Add end_date and excluded_dates columns for schedule control
ALTER TABLE task_recurring_schedules
ADD COLUMN IF NOT EXISTS end_date DATE,
ADD COLUMN IF NOT EXISTS excluded_dates TEXT[];
-- Create index on timezone for better query performance
CREATE INDEX IF NOT EXISTS idx_task_recurring_schedules_timezone
ON task_recurring_schedules(timezone);
-- Update existing records to use user's timezone if available
UPDATE task_recurring_schedules trs
SET timezone = COALESCE(
(SELECT u.timezone
FROM task_recurring_templates trt
JOIN tasks t ON trt.task_id = t.id
JOIN users u ON t.reporter_id = u.id
WHERE trt.schedule_id = trs.id
LIMIT 1),
'UTC'
)
WHERE trs.timezone IS NULL OR trs.timezone = 'UTC';
-- Add comment to explain timezone field
COMMENT ON COLUMN task_recurring_schedules.timezone IS 'IANA timezone identifier for schedule calculations';
COMMENT ON COLUMN task_recurring_templates.reporter_timezone IS 'Original reporter timezone for reference';

View File

@@ -0,0 +1,57 @@
export interface RecurringTasksConfig {
enabled: boolean;
mode: 'cron' | 'queue';
cronInterval: string;
redisConfig: {
host: string;
port: number;
password?: string;
db: number;
};
queueOptions: {
maxConcurrency: number;
retryAttempts: number;
retryDelay: number;
};
notifications: {
enabled: boolean;
email: boolean;
push: boolean;
inApp: boolean;
};
auditLog: {
enabled: boolean;
retentionDays: number;
};
}
export const recurringTasksConfig: RecurringTasksConfig = {
enabled: process.env.RECURRING_TASKS_ENABLED !== 'false',
mode: (process.env.RECURRING_TASKS_MODE as 'cron' | 'queue') || 'cron',
cronInterval: process.env.RECURRING_JOBS_INTERVAL || '0 * * * *',
redisConfig: {
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379'),
password: process.env.REDIS_PASSWORD,
db: parseInt(process.env.REDIS_DB || '0'),
},
queueOptions: {
maxConcurrency: parseInt(process.env.RECURRING_TASKS_MAX_CONCURRENCY || '5'),
retryAttempts: parseInt(process.env.RECURRING_TASKS_RETRY_ATTEMPTS || '3'),
retryDelay: parseInt(process.env.RECURRING_TASKS_RETRY_DELAY || '2000'),
},
notifications: {
enabled: process.env.RECURRING_TASKS_NOTIFICATIONS_ENABLED !== 'false',
email: process.env.RECURRING_TASKS_EMAIL_NOTIFICATIONS !== 'false',
push: process.env.RECURRING_TASKS_PUSH_NOTIFICATIONS !== 'false',
inApp: process.env.RECURRING_TASKS_IN_APP_NOTIFICATIONS !== 'false',
},
auditLog: {
enabled: process.env.RECURRING_TASKS_AUDIT_LOG_ENABLED !== 'false',
retentionDays: parseInt(process.env.RECURRING_TASKS_AUDIT_RETENTION_DAYS || '90'),
},
};

View File

@@ -71,7 +71,7 @@ export default class ProjectsController extends WorklenzControllerBase {
return res.status(200).send(new ServerResponse(false, [], `Sorry, the free plan cannot have more than ${projectsLimit} projects.`));
}
}
const q = `SELECT create_project($1) AS project`;
req.body.team_id = req.user?.team_id || null;
@@ -317,58 +317,65 @@ export default class ProjectsController extends WorklenzControllerBase {
@HandleExceptions()
public static async getMembersByProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const {sortField, sortOrder, size, offset} = this.toPaginationOptions(req.query, "name");
const search = (req.query.search || "").toString().trim();
let searchFilter = "";
const params = [req.params.id, req.user?.team_id ?? null, size, offset];
if (search) {
searchFilter = `
AND (
(SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id) ILIKE '%' || $5 || '%'
OR (SELECT email FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id) ILIKE '%' || $5 || '%'
)
`;
params.push(search);
}
const q = `
WITH filtered_members AS (
SELECT project_members.id,
team_member_id,
(SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id) AS name,
(SELECT email FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id) AS email,
u.avatar_url,
(SELECT COUNT(*) FROM tasks WHERE archived IS FALSE AND project_id = project_members.project_id AND id IN (SELECT task_id FROM tasks_assignees WHERE tasks_assignees.project_member_id = project_members.id)) AS all_tasks_count,
(SELECT COUNT(*) FROM tasks WHERE archived IS FALSE AND project_id = project_members.project_id AND id IN (SELECT task_id FROM tasks_assignees WHERE tasks_assignees.project_member_id = project_members.id) AND status_id IN (SELECT id FROM task_statuses WHERE category_id = (SELECT id FROM sys_task_status_categories WHERE is_done IS TRUE))) AS completed_tasks_count,
EXISTS(SELECT email FROM email_invitations WHERE team_member_id = project_members.team_member_id AND email_invitations.team_id = $2) AS pending_invitation,
(SELECT project_access_levels.name FROM project_access_levels WHERE project_access_levels.id = project_members.project_access_level_id) AS access,
(SELECT name FROM job_titles WHERE id = tm.job_title_id) AS job_title
FROM project_members
INNER JOIN team_members tm ON project_members.team_member_id = tm.id
LEFT JOIN users u ON tm.user_id = u.id
WHERE project_id = $1
${search ? searchFilter : ""}
)
SELECT
(SELECT COUNT(*) FROM filtered_members) AS total,
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON)
FROM (
SELECT * FROM filtered_members
ORDER BY ${sortField} ${sortOrder}
LIMIT $3 OFFSET $4
) t
) AS data
SELECT ROW_TO_JSON(rec) AS members
FROM (SELECT COUNT(*) AS total,
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON)
FROM (SELECT project_members.id,
team_member_id,
(SELECT name
FROM team_member_info_view
WHERE team_member_info_view.team_member_id = tm.id),
(SELECT email
FROM team_member_info_view
WHERE team_member_info_view.team_member_id = tm.id) AS email,
u.avatar_url,
(SELECT COUNT(*)
FROM tasks
WHERE archived IS FALSE
AND project_id = project_members.project_id
AND id IN (SELECT task_id
FROM tasks_assignees
WHERE tasks_assignees.project_member_id = project_members.id)) AS all_tasks_count,
(SELECT COUNT(*)
FROM tasks
WHERE archived IS FALSE
AND project_id = project_members.project_id
AND id IN (SELECT task_id
FROM tasks_assignees
WHERE tasks_assignees.project_member_id = project_members.id)
AND status_id IN (SELECT id
FROM task_statuses
WHERE category_id = (SELECT id
FROM sys_task_status_categories
WHERE is_done IS TRUE))) AS completed_tasks_count,
EXISTS(SELECT email
FROM email_invitations
WHERE team_member_id = project_members.team_member_id
AND email_invitations.team_id = $2) AS pending_invitation,
(SELECT project_access_levels.name
FROM project_access_levels
WHERE project_access_levels.id = project_members.project_access_level_id) AS access,
(SELECT name FROM job_titles WHERE id = tm.job_title_id) AS job_title
FROM project_members
INNER JOIN team_members tm ON project_members.team_member_id = tm.id
LEFT JOIN users u ON tm.user_id = u.id
WHERE project_id = $1
ORDER BY ${sortField} ${sortOrder}
LIMIT $3 OFFSET $4) t) AS data
FROM project_members
WHERE project_id = $1) rec;
`;
const result = await db.query(q, params);
const result = await db.query(q, [req.params.id, req.user?.team_id ?? null, size, offset]);
const [data] = result.rows;
for (const member of data?.data || []) {
for (const member of data?.members.data || []) {
member.progress = member.all_tasks_count > 0
? ((member.completed_tasks_count / member.all_tasks_count) * 100).toFixed(0) : 0;
}
return res.status(200).send(new ServerResponse(true, data || this.paginatedDatasetDefaultStruct));
return res.status(200).send(new ServerResponse(true, data?.members || this.paginatedDatasetDefaultStruct));
}
@HandleExceptions()
@@ -772,7 +779,7 @@ export default class ProjectsController extends WorklenzControllerBase {
let groupJoin = "";
let groupByFields = "";
let groupOrderBy = "";
switch (groupBy) {
case "client":
groupField = "COALESCE(projects.client_id::text, 'no-client')";
@@ -881,13 +888,13 @@ export default class ProjectsController extends WorklenzControllerBase {
ELSE p2.updated_at END) AS updated_at
FROM projects p2
${groupJoin.replace("projects.", "p2.")}
WHERE p2.team_id = $1
WHERE p2.team_id = $1
AND ${groupField.replace("projects.", "p2.")} = ${groupField}
${categories.replace("projects.", "p2.")}
${statuses.replace("projects.", "p2.")}
${isArchived.replace("projects.", "p2.")}
${isFavorites.replace("projects.", "p2.")}
${filterByMember.replace("projects.", "p2.")}
${categories.replace("projects.", "p2.")}
${statuses.replace("projects.", "p2.")}
${isArchived.replace("projects.", "p2.")}
${isFavorites.replace("projects.", "p2.")}
${filterByMember.replace("projects.", "p2.")}
${searchQuery.replace("projects.", "p2.")}
ORDER BY ${innerSortField} ${sortOrder}
) project_data

View File

@@ -0,0 +1,48 @@
import { IWorkLenzRequest } from "../interfaces/worklenz-request";
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
import { ServerResponse } from "../models/server-response";
import WorklenzControllerBase from "./worklenz-controller-base";
import HandleExceptions from "../decorators/handle-exceptions";
import { RecurringTasksPermissions } from "../utils/recurring-tasks-permissions";
import { RecurringTasksAuditLogger } from "../utils/recurring-tasks-audit-logger";
export default class RecurringTasksAdminController extends WorklenzControllerBase {
/**
* Get templates with permission issues
*/
@HandleExceptions()
public static async getPermissionIssues(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const issues = await RecurringTasksPermissions.getTemplatesWithPermissionIssues();
return res.status(200).send(new ServerResponse(true, issues));
}
/**
* Get audit log summary
*/
@HandleExceptions()
public static async getAuditSummary(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { days = 7 } = req.query;
const summary = await RecurringTasksAuditLogger.getAuditSummary(Number(days));
return res.status(200).send(new ServerResponse(true, summary));
}
/**
* Get recent errors from audit log
*/
@HandleExceptions()
public static async getRecentErrors(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { limit = 10 } = req.query;
const errors = await RecurringTasksAuditLogger.getRecentErrors(Number(limit));
return res.status(200).send(new ServerResponse(true, errors));
}
/**
* Validate a specific template
*/
@HandleExceptions()
public static async validateTemplate(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { templateId } = req.params;
const result = await RecurringTasksPermissions.validateTemplatePermissions(templateId);
return res.status(200).send(new ServerResponse(true, result));
}
}

View File

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

View File

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

View File

@@ -6,69 +6,10 @@ import { IWorkLenzResponse } from "../../interfaces/worklenz-response";
import { ServerResponse } from "../../models/server-response";
import { DATE_RANGES, TASK_PRIORITY_COLOR_ALPHA } from "../../shared/constants";
import { formatDuration, getColor, int } from "../../shared/utils";
import ReportingControllerBaseWithTimezone from "./reporting-controller-base-with-timezone";
import ReportingControllerBase from "./reporting-controller-base";
import Excel from "exceljs";
export default class ReportingMembersController extends ReportingControllerBaseWithTimezone {
protected static getPercentage(n: number, total: number) {
return +(n ? (n / total) * 100 : 0).toFixed();
}
protected static getCurrentTeamId(req: IWorkLenzRequest): string | null {
return req.user?.team_id ?? null;
}
public static convertMinutesToHoursAndMinutes(totalMinutes: number) {
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return `${hours}h ${minutes}m`;
}
public static convertSecondsToHoursAndMinutes(seconds: number) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return `${hours}h ${minutes}m`;
}
protected static formatEndDate(endDate: string) {
const end = moment(endDate).format("YYYY-MM-DD");
const fEndDate = moment(end);
return fEndDate;
}
protected static formatCurrentDate() {
const current = moment().format("YYYY-MM-DD");
const fCurrentDate = moment(current);
return fCurrentDate;
}
protected static getDaysLeft(endDate: string): number | null {
if (!endDate) return null;
const fCurrentDate = this.formatCurrentDate();
const fEndDate = this.formatEndDate(endDate);
return fEndDate.diff(fCurrentDate, "days");
}
protected static isOverdue(endDate: string): boolean {
if (!endDate) return false;
const fCurrentDate = this.formatCurrentDate();
const fEndDate = this.formatEndDate(endDate);
return fEndDate.isBefore(fCurrentDate);
}
protected static isToday(endDate: string): boolean {
if (!endDate) return false;
const fCurrentDate = this.formatCurrentDate();
const fEndDate = this.formatEndDate(endDate);
return fEndDate.isSame(fCurrentDate);
}
export default class ReportingMembersController extends ReportingControllerBase {
private static async getMembers(
teamId: string, searchQuery = "",
@@ -546,9 +487,7 @@ export default class ReportingMembersController extends ReportingControllerBaseW
dateRange = date_range.split(",");
}
// Get user timezone for proper date filtering
const userTimezone = await this.getUserTimezone(req.user?.id as string);
const durationClause = this.getDateRangeClauseWithTimezone(duration as string || DATE_RANGES.LAST_WEEK, dateRange, userTimezone);
const durationClause = ReportingMembersController.getDateRangeClauseMembers(duration as string || DATE_RANGES.LAST_WEEK, dateRange, "twl");
const minMaxDateClause = this.getMinMaxDates(duration as string || DATE_RANGES.LAST_WEEK, dateRange, "task_work_log");
const memberName = (req.query.member_name as string)?.trim() || null;
@@ -1099,9 +1038,7 @@ export default class ReportingMembersController extends ReportingControllerBaseW
public static async getMemberTimelogs(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { team_member_id, team_id, duration, date_range, archived, billable } = req.body;
// Get user timezone for proper date filtering
const userTimezone = await this.getUserTimezone(req.user?.id as string);
const durationClause = this.getDateRangeClauseWithTimezone(duration || DATE_RANGES.LAST_WEEK, date_range, userTimezone);
const durationClause = ReportingMembersController.getDateRangeClauseMembers(duration || DATE_RANGES.LAST_WEEK, date_range, "twl");
const minMaxDateClause = this.getMinMaxDates(duration || DATE_RANGES.LAST_WEEK, date_range, "task_work_log");
const billableQuery = this.buildBillableQuery(billable);
@@ -1293,8 +1230,8 @@ public static async getSingleMemberProjects(req: IWorkLenzRequest, res: IWorkLen
row.actual_time = int(row.actual_time);
row.estimated_time_string = this.convertMinutesToHoursAndMinutes(int(row.estimated_time));
row.actual_time_string = this.convertSecondsToHoursAndMinutes(int(row.actual_time));
row.days_left = this.getDaysLeft(row.end_date);
row.is_overdue = this.isOverdue(row.end_date);
row.days_left = ReportingControllerBase.getDaysLeft(row.end_date);
row.is_overdue = ReportingControllerBase.isOverdue(row.end_date);
if (row.days_left && row.is_overdue) {
row.days_left = row.days_left.toString().replace(/-/g, "");
}

View File

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

View File

@@ -6,6 +6,7 @@ import { IWorkLenzRequest } from "../interfaces/worklenz-request";
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
import { ServerResponse } from "../models/server-response";
import { calculateNextEndDate, log_error } from "../shared/utils";
import { RecurringTasksAuditLogger, RecurringTaskOperationType } from "../utils/recurring-tasks-audit-logger";
export default class TaskRecurringController extends WorklenzControllerBase {
@HandleExceptions()
@@ -34,7 +35,7 @@ export default class TaskRecurringController extends WorklenzControllerBase {
}
@HandleExceptions()
public static async createTaskSchedule(taskId: string) {
public static async createTaskSchedule(taskId: string, userId?: string) {
const q = `INSERT INTO task_recurring_schedules (schedule_type) VALUES ('daily') RETURNING id, schedule_type;`;
const result = await db.query(q, []);
const [data] = result.rows;
@@ -44,6 +45,15 @@ export default class TaskRecurringController extends WorklenzControllerBase {
await TaskRecurringController.insertTaskRecurringTemplate(taskId, data.id);
// Log schedule creation
await RecurringTasksAuditLogger.logScheduleChange(
RecurringTaskOperationType.SCHEDULE_CREATED,
data.id,
taskId,
userId,
{ schedule_type: data.schedule_type }
);
return data;
}
@@ -56,9 +66,9 @@ export default class TaskRecurringController extends WorklenzControllerBase {
@HandleExceptions()
public static async updateSchedule(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { id } = req.params;
const { schedule_type, days_of_week, day_of_month, week_of_month, interval_days, interval_weeks, interval_months, date_of_month } = req.body;
const { schedule_type, days_of_week, day_of_month, week_of_month, interval_days, interval_weeks, interval_months, date_of_month, timezone, end_date, excluded_dates } = req.body;
const deleteQ = `UPDATE task_recurring_schedules
const updateQ = `UPDATE task_recurring_schedules
SET schedule_type = $1,
days_of_week = $2,
date_of_month = $3,
@@ -66,9 +76,27 @@ export default class TaskRecurringController extends WorklenzControllerBase {
week_of_month = $5,
interval_days = $6,
interval_weeks = $7,
interval_months = $8
WHERE id = $9;`;
await db.query(deleteQ, [schedule_type, days_of_week, date_of_month, day_of_month, week_of_month, interval_days, interval_weeks, interval_months, id]);
interval_months = $8,
timezone = COALESCE($9, timezone, 'UTC'),
end_date = $10,
excluded_dates = $11
WHERE id = $12;`;
await db.query(updateQ, [schedule_type, days_of_week, date_of_month, day_of_month, week_of_month, interval_days, interval_weeks, interval_months, timezone, end_date, excluded_dates, id]);
// Log schedule update
await RecurringTasksAuditLogger.logScheduleChange(
RecurringTaskOperationType.SCHEDULE_UPDATED,
id,
undefined,
req.user?.id,
{
schedule_type,
timezone,
end_date,
excluded_dates_count: excluded_dates?.length || 0
}
);
return res.status(200).send(new ServerResponse(true, null));
}

View File

@@ -16,7 +16,6 @@ export interface ITaskGroup {
start_date?: string;
end_date?: string;
color_code: string;
color_code_dark: string;
category_id: string | null;
old_category_id?: string;
todo_progress?: number;

File diff suppressed because it is too large Load Diff

View File

@@ -2,12 +2,16 @@ import { CronJob } from "cron";
import { calculateNextEndDate, log_error } from "../shared/utils";
import db from "../config/db";
import { IRecurringSchedule, ITaskTemplate } from "../interfaces/recurring-tasks";
import moment from "moment";
import moment from "moment-timezone";
import TasksController from "../controllers/tasks-controller";
import { TimezoneUtils } from "../utils/timezone-utils";
import { RetryUtils } from "../utils/retry-utils";
import { RecurringTasksAuditLogger, RecurringTaskOperationType } from "../utils/recurring-tasks-audit-logger";
import { RecurringTasksPermissions } from "../utils/recurring-tasks-permissions";
import { RecurringTasksNotifications } from "../utils/recurring-tasks-notifications";
// At 11:00+00 (4.30pm+530) on every day-of-month if it's on every day-of-week from Monday through Friday.
// const TIME = "0 11 */1 * 1-5";
const TIME = process.env.RECURRING_JOBS_INTERVAL || "0 11 */1 * 1-5";
// Run every hour to process tasks in different timezones
const TIME = process.env.RECURRING_JOBS_INTERVAL || "0 * * * *";
const TIME_FORMAT = "YYYY-MM-DD";
// const TIME = "0 0 * * *"; // Runs at midnight every day
@@ -44,8 +48,129 @@ function getFutureLimit(scheduleType: string, interval?: number): moment.Duratio
}
}
// Helper function to batch create tasks
// Helper function to batch create tasks using bulk operations
async function createBatchTasks(template: ITaskTemplate & IRecurringSchedule, endDates: moment.Moment[]) {
if (endDates.length === 0) return [];
try {
// Prepare bulk task data
const tasksData = endDates.map(endDate => ({
name: template.name,
priority_id: template.priority_id,
project_id: template.project_id,
reporter_id: template.reporter_id,
status_id: template.status_id || null,
end_date: endDate.format(TIME_FORMAT),
schedule_id: template.schedule_id
}));
// Create all tasks in bulk with retry logic
const createTasksResult = await RetryUtils.withDatabaseRetry(async () => {
const createTasksQuery = `SELECT * FROM create_bulk_recurring_tasks($1::JSONB);`;
return await db.query(createTasksQuery, [JSON.stringify(tasksData)]);
}, `create_bulk_recurring_tasks for template ${template.name}`);
const createdTasks = createTasksResult.rows.filter(row => row.created);
const failedTasks = createTasksResult.rows.filter(row => !row.created);
// Log results
if (createdTasks.length > 0) {
console.log(`Created ${createdTasks.length} tasks for template ${template.name}`);
}
if (failedTasks.length > 0) {
failedTasks.forEach(task => {
console.log(`Failed to create task for template ${template.name}: ${task.error_message}`);
});
}
// Only process assignments for successfully created tasks
if (createdTasks.length > 0 && (template.assignees?.length > 0 || template.labels?.length > 0)) {
// Validate assignee permissions
let validAssignees = template.assignees || [];
if (validAssignees.length > 0) {
const invalidAssignees = await RecurringTasksPermissions.validateAssigneePermissions(
validAssignees,
template.project_id
);
if (invalidAssignees.length > 0) {
console.log(`Warning: ${invalidAssignees.length} assignees do not have permissions for project ${template.project_id}`);
// Filter out invalid assignees
validAssignees = validAssignees.filter(
a => !invalidAssignees.includes(a.team_member_id)
);
}
}
// Prepare bulk assignments
const assignments = [];
const labelAssignments = [];
for (const task of createdTasks) {
// Prepare team member assignments with validated assignees
if (validAssignees.length > 0) {
for (const assignee of validAssignees) {
assignments.push({
task_id: task.task_id,
team_member_id: assignee.team_member_id,
assigned_by: assignee.assigned_by
});
}
}
// Prepare label assignments
if (template.labels?.length > 0) {
for (const label of template.labels) {
labelAssignments.push({
task_id: task.task_id,
label_id: label.label_id
});
}
}
}
// Bulk assign team members with retry logic
if (assignments.length > 0) {
await RetryUtils.withDatabaseRetry(async () => {
const assignQuery = `SELECT * FROM bulk_assign_team_members($1::JSONB);`;
return await db.query(assignQuery, [JSON.stringify(assignments)]);
}, `bulk_assign_team_members for template ${template.name}`);
}
// Bulk assign labels with retry logic
if (labelAssignments.length > 0) {
await RetryUtils.withDatabaseRetry(async () => {
const labelQuery = `SELECT * FROM bulk_assign_labels($1::JSONB);`;
return await db.query(labelQuery, [JSON.stringify(labelAssignments)]);
}, `bulk_assign_labels for template ${template.name}`);
}
// Send notifications for created tasks
if (createdTasks.length > 0) {
const taskData = createdTasks.map(task => ({ id: task.task_id, name: task.task_name }));
const assigneeIds = template.assignees?.map(a => a.team_member_id) || [];
await RecurringTasksNotifications.notifyRecurringTasksCreated(
template.name,
template.project_id,
taskData,
assigneeIds,
template.reporter_id
);
}
}
return createdTasks.map(task => ({ id: task.task_id, name: task.task_name }));
} catch (error) {
log_error("Error in bulk task creation:", error);
// Fallback to sequential creation if bulk operation fails
console.log("Falling back to sequential task creation");
return createBatchTasksSequential(template, endDates);
}
}
// Fallback function for sequential task creation
async function createBatchTasksSequential(template: ITaskTemplate & IRecurringSchedule, endDates: moment.Moment[]) {
const createdTasks = [];
for (const nextEndDate of endDates) {
@@ -92,69 +217,162 @@ async function createBatchTasks(template: ITaskTemplate & IRecurringSchedule, en
}
async function onRecurringTaskJobTick() {
const errors: any[] = [];
try {
log("(cron) Recurring tasks job started.");
RecurringTasksAuditLogger.startTimer();
const templatesQuery = `
SELECT t.*, s.*, (SELECT MAX(end_date) FROM tasks WHERE schedule_id = s.id) as last_task_end_date
FROM task_recurring_templates t
JOIN task_recurring_schedules s ON t.schedule_id = s.id;
`;
const templatesResult = await db.query(templatesQuery);
const templates = templatesResult.rows as (ITaskTemplate & IRecurringSchedule)[];
// Get all active timezones where it's currently the scheduled hour
const activeTimezones = TimezoneUtils.getActiveTimezones();
log(`Processing recurring tasks for ${activeTimezones.length} timezones`);
// Fetch templates with retry logic
const templatesResult = await RetryUtils.withDatabaseRetry(async () => {
const templatesQuery = `
SELECT t.*, s.*,
(SELECT MAX(end_date) FROM tasks WHERE schedule_id = s.id) as last_task_end_date,
u.timezone as user_timezone
FROM task_recurring_templates t
JOIN task_recurring_schedules s ON t.schedule_id = s.id
LEFT JOIN tasks orig_task ON t.task_id = orig_task.id
LEFT JOIN users u ON orig_task.reporter_id = u.id
WHERE s.end_date IS NULL OR s.end_date >= CURRENT_DATE;
`;
return await db.query(templatesQuery);
}, "fetch_recurring_templates");
const templates = templatesResult.rows as (ITaskTemplate & IRecurringSchedule & { user_timezone?: string })[];
const now = moment();
let createdTaskCount = 0;
for (const template of templates) {
// Check template permissions before processing
const permissionCheck = await RecurringTasksPermissions.validateTemplatePermissions(template.task_id);
if (!permissionCheck.hasPermission) {
console.log(`Skipping template ${template.name}: ${permissionCheck.reason}`);
// Log permission issue
await RecurringTasksAuditLogger.log({
operationType: RecurringTaskOperationType.TASKS_CREATION_FAILED,
templateId: template.task_id,
scheduleId: template.schedule_id,
templateName: template.name,
success: false,
errorMessage: `Permission denied: ${permissionCheck.reason}`,
details: { permissionCheck }
});
continue;
}
// Use template timezone or user timezone or default to UTC
const timezone = template.timezone || TimezoneUtils.getUserTimezone(template.user_timezone);
// Check if this template should run in the current hour for its timezone
if (!activeTimezones.includes(timezone) && timezone !== 'UTC') {
continue;
}
const now = TimezoneUtils.nowInTimezone(timezone);
const lastTaskEndDate = template.last_task_end_date
? moment(template.last_task_end_date)
: moment(template.created_at);
? moment.tz(template.last_task_end_date, timezone)
: moment.tz(template.created_at, timezone);
// Calculate future limit based on schedule type
const futureLimit = moment(template.last_checked_at || template.created_at)
const futureLimit = moment.tz(template.last_checked_at || template.created_at, timezone)
.add(getFutureLimit(
template.schedule_type,
template.interval_days || template.interval_weeks || template.interval_months || 1
));
let nextEndDate = calculateNextEndDate(template, lastTaskEndDate);
let nextEndDate = TimezoneUtils.calculateNextEndDateWithTimezone(template, lastTaskEndDate, timezone);
const endDatesToCreate: moment.Moment[] = [];
// Find all future occurrences within the limit
while (nextEndDate.isSameOrBefore(futureLimit)) {
if (nextEndDate.isAfter(now)) {
endDatesToCreate.push(moment(nextEndDate));
// Check if date is not in excluded dates
if (!template.excluded_dates || !template.excluded_dates.includes(nextEndDate.format(TIME_FORMAT))) {
endDatesToCreate.push(moment(nextEndDate));
}
}
nextEndDate = calculateNextEndDate(template, nextEndDate);
nextEndDate = TimezoneUtils.calculateNextEndDateWithTimezone(template, nextEndDate, timezone);
}
// Batch create tasks for all future dates
if (endDatesToCreate.length > 0) {
const createdTasks = await createBatchTasks(template, endDatesToCreate);
createdTaskCount += createdTasks.length;
try {
const createdTasks = await createBatchTasks(template, endDatesToCreate);
createdTaskCount += createdTasks.length;
// Update the last_checked_at in the schedule
const updateScheduleQuery = `
UPDATE task_recurring_schedules
SET last_checked_at = $1::DATE,
last_created_task_end_date = $2
WHERE id = $3;
`;
await db.query(updateScheduleQuery, [
moment().format(TIME_FORMAT),
endDatesToCreate[endDatesToCreate.length - 1].format(TIME_FORMAT),
template.schedule_id
]);
// Log successful template processing
await RecurringTasksAuditLogger.logTemplateProcessing(
template.task_id,
template.name,
template.schedule_id,
createdTasks.length,
endDatesToCreate.length - createdTasks.length,
{
timezone,
endDates: endDatesToCreate.map(d => d.format(TIME_FORMAT))
}
);
// Update the last_checked_at in the schedule with retry logic
await RetryUtils.withDatabaseRetry(async () => {
const updateScheduleQuery = `
UPDATE task_recurring_schedules
SET last_checked_at = $1,
last_created_task_end_date = $2
WHERE id = $3;
`;
return await db.query(updateScheduleQuery, [
now.toDate(),
endDatesToCreate[endDatesToCreate.length - 1].toDate(),
template.schedule_id
]);
}, `update_schedule for template ${template.name}`);
} catch (error) {
errors.push({ template: template.name, error });
// Log failed template processing
await RecurringTasksAuditLogger.logTemplateProcessing(
template.task_id,
template.name,
template.schedule_id,
0,
endDatesToCreate.length,
{
timezone,
error: error.message || error.toString()
}
);
}
} else {
console.log(`No tasks created for template ${template.name} - next occurrence is beyond the future limit`);
console.log(`No tasks created for template ${template.name} (${timezone}) - next occurrence is beyond the future limit or excluded`);
}
}
log(`(cron) Recurring tasks job ended with ${createdTaskCount} new tasks created.`);
// Log cron job completion
await RecurringTasksAuditLogger.logCronJobRun(
templates.length,
createdTaskCount,
errors
);
} catch (error) {
log_error(error);
log("(cron) Recurring task job ended with errors.");
// Log cron job failure
await RecurringTasksAuditLogger.log({
operationType: RecurringTaskOperationType.CRON_JOB_ERROR,
success: false,
errorMessage: error.message || error.toString(),
details: { error: error.stack || error }
});
}
}

View File

@@ -12,6 +12,9 @@ export interface IRecurringSchedule {
last_checked_at: Date | null;
last_task_end_date: Date | null;
created_at: Date;
timezone?: string;
end_date?: Date | null;
excluded_dates?: string[] | null;
}
interface ITaskTemplateAssignee {

View File

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

View File

@@ -0,0 +1,322 @@
import Bull from 'bull';
import { TimezoneUtils } from '../utils/timezone-utils';
import { RetryUtils } from '../utils/retry-utils';
import { RecurringTasksAuditLogger, RecurringTaskOperationType } from '../utils/recurring-tasks-audit-logger';
import { RecurringTasksPermissions } from '../utils/recurring-tasks-permissions';
import { RecurringTasksNotifications } from '../utils/recurring-tasks-notifications';
import { calculateNextEndDate, log_error } from '../shared/utils';
import { IRecurringSchedule, ITaskTemplate } from '../interfaces/recurring-tasks';
import moment from 'moment-timezone';
import db from '../config/db';
// Configure Redis connection
const redisConfig = {
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379'),
password: process.env.REDIS_PASSWORD,
db: parseInt(process.env.REDIS_DB || '0'),
};
// Create job queues
export const recurringTasksQueue = new Bull('recurring-tasks', {
redis: redisConfig,
defaultJobOptions: {
removeOnComplete: 100, // Keep last 100 completed jobs
removeOnFail: 50, // Keep last 50 failed jobs
attempts: 3,
backoff: {
type: 'exponential',
delay: 2000,
},
},
});
export const taskCreationQueue = new Bull('task-creation', {
redis: redisConfig,
defaultJobOptions: {
removeOnComplete: 200,
removeOnFail: 100,
attempts: 5,
backoff: {
type: 'exponential',
delay: 1000,
},
},
});
// Job data interfaces
interface RecurringTaskJobData {
templateId: string;
scheduleId: string;
timezone: string;
}
interface TaskCreationJobData {
template: ITaskTemplate & IRecurringSchedule;
endDates: string[];
timezone: string;
}
// Job processors
recurringTasksQueue.process('process-template', async (job) => {
const { templateId, scheduleId, timezone }: RecurringTaskJobData = job.data;
try {
RecurringTasksAuditLogger.startTimer();
// Fetch template data
const templateQuery = `
SELECT t.*, s.*,
(SELECT MAX(end_date) FROM tasks WHERE schedule_id = s.id) as last_task_end_date,
u.timezone as user_timezone
FROM task_recurring_templates t
JOIN task_recurring_schedules s ON t.schedule_id = s.id
LEFT JOIN tasks orig_task ON t.task_id = orig_task.id
LEFT JOIN users u ON orig_task.reporter_id = u.id
WHERE t.id = $1 AND s.id = $2
`;
const result = await RetryUtils.withDatabaseRetry(async () => {
return await db.query(templateQuery, [templateId, scheduleId]);
}, 'fetch_template_for_job');
if (result.rows.length === 0) {
throw new Error(`Template ${templateId} not found`);
}
const template = result.rows[0] as ITaskTemplate & IRecurringSchedule & { user_timezone?: string };
// Check permissions
const permissionCheck = await RecurringTasksPermissions.validateTemplatePermissions(template.task_id);
if (!permissionCheck.hasPermission) {
await RecurringTasksAuditLogger.log({
operationType: RecurringTaskOperationType.TASKS_CREATION_FAILED,
templateId: template.task_id,
scheduleId: template.schedule_id,
templateName: template.name,
success: false,
errorMessage: `Permission denied: ${permissionCheck.reason}`,
details: { permissionCheck, processedBy: 'job_queue' }
});
return;
}
// Calculate dates to create
const now = TimezoneUtils.nowInTimezone(timezone);
const lastTaskEndDate = template.last_task_end_date
? moment.tz(template.last_task_end_date, timezone)
: moment.tz(template.created_at, timezone);
const futureLimit = moment.tz(template.last_checked_at || template.created_at, timezone)
.add(getFutureLimit(
template.schedule_type,
template.interval_days || template.interval_weeks || template.interval_months || 1
));
let nextEndDate = TimezoneUtils.calculateNextEndDateWithTimezone(template, lastTaskEndDate, timezone);
const endDatesToCreate: string[] = [];
while (nextEndDate.isSameOrBefore(futureLimit)) {
if (nextEndDate.isAfter(now)) {
if (!template.excluded_dates || !template.excluded_dates.includes(nextEndDate.format('YYYY-MM-DD'))) {
endDatesToCreate.push(nextEndDate.format('YYYY-MM-DD'));
}
}
nextEndDate = TimezoneUtils.calculateNextEndDateWithTimezone(template, nextEndDate, timezone);
}
if (endDatesToCreate.length > 0) {
// Add task creation job
await taskCreationQueue.add('create-tasks', {
template,
endDates: endDatesToCreate,
timezone
}, {
priority: 10, // Higher priority for task creation
});
}
// Update schedule
await RetryUtils.withDatabaseRetry(async () => {
const updateQuery = `
UPDATE task_recurring_schedules
SET last_checked_at = $1
WHERE id = $2;
`;
return await db.query(updateQuery, [now.toDate(), scheduleId]);
}, `update_schedule_for_template_${templateId}`);
} catch (error) {
log_error('Error processing recurring task template:', error);
throw error;
}
});
taskCreationQueue.process('create-tasks', async (job) => {
const { template, endDates, timezone }: TaskCreationJobData = job.data;
try {
// Create tasks using the bulk function from the cron job
const tasksData = endDates.map(endDate => ({
name: template.name,
priority_id: template.priority_id,
project_id: template.project_id,
reporter_id: template.reporter_id,
status_id: template.status_id || null,
end_date: endDate,
schedule_id: template.schedule_id
}));
const createTasksResult = await RetryUtils.withDatabaseRetry(async () => {
const createTasksQuery = `SELECT * FROM create_bulk_recurring_tasks($1::JSONB);`;
return await db.query(createTasksQuery, [JSON.stringify(tasksData)]);
}, `create_bulk_tasks_queue_${template.name}`);
const createdTasks = createTasksResult.rows.filter(row => row.created);
const failedTasks = createTasksResult.rows.filter(row => !row.created);
// Handle assignments and labels (similar to cron job implementation)
if (createdTasks.length > 0 && (template.assignees?.length > 0 || template.labels?.length > 0)) {
// ... (assignment logic from cron job)
}
// Send notifications
if (createdTasks.length > 0) {
const taskData = createdTasks.map(task => ({ id: task.task_id, name: task.task_name }));
const assigneeIds = template.assignees?.map(a => a.team_member_id) || [];
await RecurringTasksNotifications.notifyRecurringTasksCreated(
template.name,
template.project_id,
taskData,
assigneeIds,
template.reporter_id
);
}
// Log results
await RecurringTasksAuditLogger.logTemplateProcessing(
template.task_id,
template.name,
template.schedule_id,
createdTasks.length,
failedTasks.length,
{
timezone,
endDates,
processedBy: 'job_queue'
}
);
return {
created: createdTasks.length,
failed: failedTasks.length
};
} catch (error) {
log_error('Error creating tasks in queue:', error);
throw error;
}
});
// Helper function (copied from cron job)
function getFutureLimit(scheduleType: string, interval?: number): moment.Duration {
const FUTURE_LIMITS = {
daily: moment.duration(3, "days"),
weekly: moment.duration(1, "week"),
monthly: moment.duration(1, "month"),
every_x_days: (interval: number) => moment.duration(interval, "days"),
every_x_weeks: (interval: number) => moment.duration(interval, "weeks"),
every_x_months: (interval: number) => moment.duration(interval, "months")
};
switch (scheduleType) {
case "daily":
return FUTURE_LIMITS.daily;
case "weekly":
return FUTURE_LIMITS.weekly;
case "monthly":
return FUTURE_LIMITS.monthly;
case "every_x_days":
return FUTURE_LIMITS.every_x_days(interval || 1);
case "every_x_weeks":
return FUTURE_LIMITS.every_x_weeks(interval || 1);
case "every_x_months":
return FUTURE_LIMITS.every_x_months(interval || 1);
default:
return moment.duration(3, "days");
}
}
// Job schedulers
export class RecurringTasksJobScheduler {
/**
* Schedule recurring task processing for all templates
*/
static async scheduleRecurringTasks(): Promise<void> {
try {
// Get all active templates
const templatesQuery = `
SELECT t.id as template_id, s.id as schedule_id,
COALESCE(s.timezone, u.timezone, 'UTC') as timezone
FROM task_recurring_templates t
JOIN task_recurring_schedules s ON t.schedule_id = s.id
LEFT JOIN tasks orig_task ON t.task_id = orig_task.id
LEFT JOIN users u ON orig_task.reporter_id = u.id
WHERE s.end_date IS NULL OR s.end_date >= CURRENT_DATE
`;
const result = await db.query(templatesQuery);
// Schedule a job for each template
for (const template of result.rows) {
await recurringTasksQueue.add('process-template', {
templateId: template.template_id,
scheduleId: template.schedule_id,
timezone: template.timezone
}, {
delay: Math.random() * 60000, // Random delay up to 1 minute to spread load
});
}
} catch (error) {
log_error('Error scheduling recurring tasks:', error);
}
}
/**
* Start the job queue system
*/
static async start(): Promise<void> {
console.log('Starting recurring tasks job queue...');
// Schedule recurring task processing every hour
await recurringTasksQueue.add('schedule-all', {}, {
repeat: { cron: '0 * * * *' }, // Every hour
removeOnComplete: 1,
removeOnFail: 1,
});
// Process the schedule-all job
recurringTasksQueue.process('schedule-all', async () => {
await this.scheduleRecurringTasks();
});
console.log('Recurring tasks job queue started');
}
/**
* Get queue statistics
*/
static async getStats(): Promise<any> {
const [recurringStats, creationStats] = await Promise.all([
recurringTasksQueue.getJobCounts(),
taskCreationQueue.getJobCounts()
]);
return {
recurringTasks: recurringStats,
taskCreation: creationStats
};
}
}

View File

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

View File

@@ -1,7 +1,7 @@
{
"continue": "Continue",
"setupYourAccount": "Setup Your Account.",
"setupYourAccount": "Setup Your Worklenz Account.",
"organizationStepTitle": "Name Your Organization",
"organizationStepLabel": "Pick a name for your Worklenz account.",

View File

@@ -51,14 +51,13 @@ import roadmapApiRouter from "./gannt-apis/roadmap-api-router";
import scheduleApiRouter from "./gannt-apis/schedule-api-router";
import scheduleApiV2Router from "./gannt-apis/schedule-api-v2-router";
import projectManagerApiRouter from "./project-managers-api-router";
import surveyApiRouter from "./survey-api-router";
import billingApiRouter from "./billing-api-router";
import taskDependenciesApiRouter from "./task-dependencies-api-router";
import taskRecurringApiRouter from "./task-recurring-api-router";
import customColumnsApiRouter from "./custom-columns-api-router";
import customColumnsApiRouter from "./custom-columns-api-router";
const api = express.Router();
@@ -104,7 +103,6 @@ api.use("/roadmap-gannt", roadmapApiRouter);
api.use("/roadmap-gannt", roadmapApiRouter);
api.use("/schedule-gannt", scheduleApiRouter);
api.use("/schedule-gannt-v2", scheduleApiV2Router);
api.use("/project-managers", projectManagerApiRouter);
api.use("/project-managers", projectManagerApiRouter);
api.get("/overview/:id", safeControllerFunction(OverviewController.getById));
@@ -117,6 +115,6 @@ api.use("/task-dependencies", taskDependenciesApiRouter);
api.use("/task-dependencies", taskDependenciesApiRouter);
api.use("/task-recurring", taskRecurringApiRouter);
api.use("/custom-columns", customColumnsApiRouter);
api.use("/custom-columns", customColumnsApiRouter);

View File

@@ -1,17 +0,0 @@
import express from "express";
import SurveyController from "../../controllers/survey-controller";
import surveySubmissionValidator from "../../middlewares/validators/survey-submission-validator";
import safeControllerFunction from "../../shared/safe-controller-function";
const surveyApiRouter = express.Router();
// Get account setup survey with questions
surveyApiRouter.get("/account-setup", safeControllerFunction(SurveyController.getAccountSetupSurvey));
// Submit survey response
surveyApiRouter.post("/responses", surveySubmissionValidator, safeControllerFunction(SurveyController.submitSurveyResponse));
// Get user's survey response for a specific survey
surveyApiRouter.get("/responses/:survey_id", safeControllerFunction(SurveyController.getUserSurveyResponse));
export default surveyApiRouter;

View File

@@ -0,0 +1,162 @@
import { recurringTasksConfig } from '../config/recurring-tasks-config';
import { startRecurringTasksJob } from '../cron_jobs/recurring-tasks';
import { RecurringTasksJobScheduler } from '../jobs/recurring-tasks-queue';
import { log_error } from '../shared/utils';
export class RecurringTasksService {
private static isStarted = false;
/**
* Start the recurring tasks service based on configuration
*/
static async start(): Promise<void> {
if (this.isStarted) {
console.log('Recurring tasks service already started');
return;
}
if (!recurringTasksConfig.enabled) {
console.log('Recurring tasks service disabled');
return;
}
try {
console.log(`Starting recurring tasks service in ${recurringTasksConfig.mode} mode...`);
switch (recurringTasksConfig.mode) {
case 'cron':
startRecurringTasksJob();
break;
case 'queue':
await RecurringTasksJobScheduler.start();
break;
default:
throw new Error(`Unknown recurring tasks mode: ${recurringTasksConfig.mode}`);
}
this.isStarted = true;
console.log(`Recurring tasks service started successfully in ${recurringTasksConfig.mode} mode`);
} catch (error) {
log_error('Failed to start recurring tasks service:', error);
throw error;
}
}
/**
* Stop the recurring tasks service
*/
static async stop(): Promise<void> {
if (!this.isStarted) {
return;
}
try {
console.log('Stopping recurring tasks service...');
if (recurringTasksConfig.mode === 'queue') {
// Close queue connections
const { recurringTasksQueue, taskCreationQueue } = await import('../jobs/recurring-tasks-queue');
await recurringTasksQueue.close();
await taskCreationQueue.close();
}
this.isStarted = false;
console.log('Recurring tasks service stopped');
} catch (error) {
log_error('Error stopping recurring tasks service:', error);
}
}
/**
* Get service status and statistics
*/
static async getStatus(): Promise<any> {
const status = {
enabled: recurringTasksConfig.enabled,
mode: recurringTasksConfig.mode,
started: this.isStarted,
config: recurringTasksConfig
};
if (this.isStarted && recurringTasksConfig.mode === 'queue') {
try {
const stats = await RecurringTasksJobScheduler.getStats();
return { ...status, queueStats: stats };
} catch (error) {
return { ...status, queueStatsError: error.message };
}
}
return status;
}
/**
* Manually trigger recurring tasks processing
*/
static async triggerManual(): Promise<void> {
if (!this.isStarted) {
throw new Error('Recurring tasks service is not started');
}
try {
if (recurringTasksConfig.mode === 'queue') {
await RecurringTasksJobScheduler.scheduleRecurringTasks();
} else {
// For cron mode, we can't manually trigger easily
// Could implement a manual trigger function in the cron job file
throw new Error('Manual trigger not supported in cron mode');
}
} catch (error) {
log_error('Error manually triggering recurring tasks:', error);
throw error;
}
}
/**
* Health check for the service
*/
static async healthCheck(): Promise<{ healthy: boolean; message: string; details?: any }> {
try {
if (!recurringTasksConfig.enabled) {
return {
healthy: true,
message: 'Recurring tasks service is disabled'
};
}
if (!this.isStarted) {
return {
healthy: false,
message: 'Recurring tasks service is not started'
};
}
if (recurringTasksConfig.mode === 'queue') {
const stats = await RecurringTasksJobScheduler.getStats();
const hasFailures = stats.recurringTasks.failed > 0 || stats.taskCreation.failed > 0;
return {
healthy: !hasFailures,
message: hasFailures ? 'Some jobs are failing' : 'All systems operational',
details: stats
};
}
return {
healthy: true,
message: `Running in ${recurringTasksConfig.mode} mode`
};
} catch (error) {
return {
healthy: false,
message: 'Health check failed',
details: { error: error.message }
};
}
}
}

View File

@@ -89,24 +89,24 @@ export const NumbersColorMap: { [x: string]: string } = {
};
export const PriorityColorCodes: { [x: number]: string; } = {
0: "#2E8B57",
1: "#DAA520",
2: "#CD5C5C"
0: "#75c997",
1: "#fbc84c",
2: "#f37070"
};
export const PriorityColorCodesDark: { [x: number]: string; } = {
0: "#3CB371",
1: "#B8860B",
2: "#F08080"
0: "#46D980",
1: "#FFC227",
2: "#FF4141"
};
export const TASK_STATUS_TODO_COLOR = "#a9a9a9";
export const TASK_STATUS_DOING_COLOR = "#70a6f3";
export const TASK_STATUS_DONE_COLOR = "#75c997";
export const TASK_PRIORITY_LOW_COLOR = "#2E8B57";
export const TASK_PRIORITY_MEDIUM_COLOR = "#DAA520";
export const TASK_PRIORITY_HIGH_COLOR = "#CD5C5C";
export const TASK_PRIORITY_LOW_COLOR = "#75c997";
export const TASK_PRIORITY_MEDIUM_COLOR = "#fbc84c";
export const TASK_PRIORITY_HIGH_COLOR = "#f37070";
export const TASK_DUE_COMPLETED_COLOR = "#75c997";
export const TASK_DUE_UPCOMING_COLOR = "#70a6f3";

View File

@@ -0,0 +1,189 @@
import db from "../config/db";
import { log_error } from "../shared/utils";
export enum RecurringTaskOperationType {
CRON_JOB_RUN = "cron_job_run",
CRON_JOB_ERROR = "cron_job_error",
TEMPLATE_CREATED = "template_created",
TEMPLATE_UPDATED = "template_updated",
TEMPLATE_DELETED = "template_deleted",
SCHEDULE_CREATED = "schedule_created",
SCHEDULE_UPDATED = "schedule_updated",
SCHEDULE_DELETED = "schedule_deleted",
TASKS_CREATED = "tasks_created",
TASKS_CREATION_FAILED = "tasks_creation_failed",
MANUAL_TRIGGER = "manual_trigger",
BULK_OPERATION = "bulk_operation"
}
export interface AuditLogEntry {
operationType: RecurringTaskOperationType;
templateId?: string;
scheduleId?: string;
taskId?: string;
templateName?: string;
success?: boolean;
errorMessage?: string;
details?: any;
createdTasksCount?: number;
failedTasksCount?: number;
executionTimeMs?: number;
createdBy?: string;
}
export class RecurringTasksAuditLogger {
private static startTime: number;
/**
* Start timing an operation
*/
static startTimer(): void {
this.startTime = Date.now();
}
/**
* Get elapsed time since timer started
*/
static getElapsedTime(): number {
return this.startTime ? Date.now() - this.startTime : 0;
}
/**
* Log a recurring task operation
*/
static async log(entry: AuditLogEntry): Promise<void> {
try {
const query = `SELECT log_recurring_task_operation($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12);`;
await db.query(query, [
entry.operationType,
entry.templateId || null,
entry.scheduleId || null,
entry.taskId || null,
entry.templateName || null,
entry.success !== false, // Default to true
entry.errorMessage || null,
entry.details ? JSON.stringify(entry.details) : null,
entry.createdTasksCount || 0,
entry.failedTasksCount || 0,
entry.executionTimeMs || this.getElapsedTime(),
entry.createdBy || null
]);
} catch (error) {
// Don't let audit logging failures break the main flow
log_error("Failed to log recurring task audit entry:", error);
}
}
/**
* Log cron job execution
*/
static async logCronJobRun(
totalTemplates: number,
createdTasksCount: number,
errors: any[] = []
): Promise<void> {
await this.log({
operationType: RecurringTaskOperationType.CRON_JOB_RUN,
success: errors.length === 0,
errorMessage: errors.length > 0 ? `${errors.length} errors occurred` : undefined,
details: {
totalTemplates,
errors: errors.map(e => e.message || e.toString())
},
createdTasksCount,
executionTimeMs: this.getElapsedTime()
});
}
/**
* Log template processing
*/
static async logTemplateProcessing(
templateId: string,
templateName: string,
scheduleId: string,
createdCount: number,
failedCount: number,
details?: any
): Promise<void> {
await this.log({
operationType: RecurringTaskOperationType.TASKS_CREATED,
templateId,
scheduleId,
templateName,
success: failedCount === 0,
createdTasksCount: createdCount,
failedTasksCount: failedCount,
details
});
}
/**
* Log schedule changes
*/
static async logScheduleChange(
operationType: RecurringTaskOperationType,
scheduleId: string,
templateId?: string,
userId?: string,
details?: any
): Promise<void> {
await this.log({
operationType,
scheduleId,
templateId,
createdBy: userId,
details
});
}
/**
* Get audit log summary
*/
static async getAuditSummary(days: number = 7): Promise<any> {
try {
const query = `
SELECT
operation_type,
COUNT(*) as count,
SUM(CASE WHEN success THEN 1 ELSE 0 END) as success_count,
SUM(CASE WHEN NOT success THEN 1 ELSE 0 END) as failure_count,
SUM(created_tasks_count) as total_tasks_created,
SUM(failed_tasks_count) as total_tasks_failed,
AVG(execution_time_ms) as avg_execution_time_ms
FROM recurring_tasks_audit_log
WHERE created_at >= CURRENT_TIMESTAMP - INTERVAL '${days} days'
GROUP BY operation_type
ORDER BY count DESC;
`;
const result = await db.query(query);
return result.rows;
} catch (error) {
log_error("Failed to get audit summary:", error);
return [];
}
}
/**
* Get recent errors
*/
static async getRecentErrors(limit: number = 10): Promise<any[]> {
try {
const query = `
SELECT *
FROM v_recent_recurring_tasks_audit
WHERE NOT success
ORDER BY created_at DESC
LIMIT $1;
`;
const result = await db.query(query, [limit]);
return result.rows;
} catch (error) {
log_error("Failed to get recent errors:", error);
return [];
}
}
}

View File

@@ -0,0 +1,260 @@
import db from "../config/db";
import { log_error } from "../shared/utils";
export interface NotificationData {
userId: string;
projectId: string;
taskId: string;
taskName: string;
templateName: string;
scheduleId: string;
createdBy?: string;
}
export class RecurringTasksNotifications {
/**
* Send notification to user about a new recurring task
*/
static async notifyTaskCreated(data: NotificationData): Promise<void> {
try {
// Create notification in the database
const notificationQuery = `
INSERT INTO notifications (
user_id,
message,
data,
created_at
) VALUES ($1, $2, $3, NOW())
`;
const message = `New recurring task "${data.taskName}" has been created from template "${data.templateName}"`;
const notificationData = {
type: 'recurring_task_created',
task_id: data.taskId,
project_id: data.projectId,
schedule_id: data.scheduleId,
task_name: data.taskName,
template_name: data.templateName
};
await db.query(notificationQuery, [
data.userId,
message,
JSON.stringify(notificationData)
]);
} catch (error) {
log_error("Failed to create notification:", error);
}
}
/**
* Send notifications to all assignees of created tasks
*/
static async notifyAssignees(
taskIds: string[],
templateName: string,
projectId: string
): Promise<void> {
if (taskIds.length === 0) return;
try {
// Get all assignees for the created tasks
const assigneesQuery = `
SELECT DISTINCT ta.team_member_id, t.id as task_id, t.name as task_name
FROM tasks_assignees ta
JOIN tasks t ON ta.task_id = t.id
WHERE t.id = ANY($1)
`;
const result = await db.query(assigneesQuery, [taskIds]);
// Send notification to each assignee
for (const assignee of result.rows) {
await this.notifyTaskCreated({
userId: assignee.team_member_id,
projectId,
taskId: assignee.task_id,
taskName: assignee.task_name,
templateName,
scheduleId: '' // Not needed for assignee notifications
});
}
} catch (error) {
log_error("Failed to notify assignees:", error);
}
}
/**
* Send email notifications (if email system is configured)
*/
static async sendEmailNotifications(
userIds: string[],
subject: string,
message: string
): Promise<void> {
try {
// Get user email addresses
const usersQuery = `
SELECT id, email, name, email_notifications
FROM users
WHERE id = ANY($1) AND email_notifications = true AND email IS NOT NULL
`;
const result = await db.query(usersQuery, [userIds]);
// TODO: Integrate with your email service (SendGrid, AWS SES, etc.)
// For now, just log the email notifications that would be sent
for (const user of result.rows) {
console.log(`Email notification would be sent to ${user.email}: ${subject}`);
// Example: await emailService.send({
// to: user.email,
// subject,
// html: message
// });
}
} catch (error) {
log_error("Failed to send email notifications:", error);
}
}
/**
* Send push notifications (if push notification system is configured)
*/
static async sendPushNotifications(
userIds: string[],
title: string,
body: string,
data?: any
): Promise<void> {
try {
// Get user push tokens
const tokensQuery = `
SELECT user_id, push_token
FROM user_push_tokens
WHERE user_id = ANY($1) AND push_token IS NOT NULL
`;
const result = await db.query(tokensQuery, [userIds]);
// TODO: Integrate with your push notification service (FCM, APNs, etc.)
// For now, just log the push notifications that would be sent
for (const token of result.rows) {
console.log(`Push notification would be sent to ${token.push_token}: ${title}`);
// Example: await pushService.send({
// token: token.push_token,
// title,
// body,
// data
// });
}
} catch (error) {
log_error("Failed to send push notifications:", error);
}
}
/**
* Get notification preferences for users
*/
static async getNotificationPreferences(userIds: string[]): Promise<any[]> {
try {
const query = `
SELECT
id,
email_notifications,
push_notifications,
in_app_notifications
FROM users
WHERE id = ANY($1)
`;
const result = await db.query(query, [userIds]);
return result.rows;
} catch (error) {
log_error("Failed to get notification preferences:", error);
return [];
}
}
/**
* Comprehensive notification for recurring task creation
*/
static async notifyRecurringTasksCreated(
templateName: string,
projectId: string,
createdTasks: Array<{ id: string; name: string }>,
assignees: string[] = [],
reporterId?: string
): Promise<void> {
try {
const taskIds = createdTasks.map(t => t.id);
const allUserIds = [...new Set([...assignees, reporterId].filter(Boolean))];
if (allUserIds.length === 0) return;
// Get notification preferences
const preferences = await this.getNotificationPreferences(allUserIds);
// Send in-app notifications
const inAppUsers = preferences.filter(p => p.in_app_notifications !== false);
for (const user of inAppUsers) {
for (const task of createdTasks) {
await this.notifyTaskCreated({
userId: user.id,
projectId,
taskId: task.id,
taskName: task.name,
templateName,
scheduleId: '',
createdBy: 'system'
});
}
}
// Send email notifications
const emailUsers = preferences
.filter(p => p.email_notifications === true)
.map(p => p.id);
if (emailUsers.length > 0) {
const subject = `New Recurring Tasks Created: ${templateName}`;
const message = `
<h3>Recurring Tasks Created</h3>
<p>${createdTasks.length} new tasks have been created from template "${templateName}":</p>
<ul>
${createdTasks.map(t => `<li>${t.name}</li>`).join('')}
</ul>
`;
await this.sendEmailNotifications(emailUsers, subject, message);
}
// Send push notifications
const pushUsers = preferences
.filter(p => p.push_notifications !== false)
.map(p => p.id);
if (pushUsers.length > 0) {
await this.sendPushNotifications(
pushUsers,
'New Recurring Tasks',
`${createdTasks.length} tasks created from ${templateName}`,
{
type: 'recurring_tasks_created',
project_id: projectId,
task_count: createdTasks.length
}
);
}
} catch (error) {
log_error("Failed to send comprehensive notifications:", error);
}
}
}

View File

@@ -0,0 +1,187 @@
import db from "../config/db";
import { log_error } from "../shared/utils";
export interface PermissionCheckResult {
hasPermission: boolean;
reason?: string;
projectRole?: string;
}
export class RecurringTasksPermissions {
/**
* Check if a user has permission to create tasks in a project
*/
static async canCreateTasksInProject(
userId: string,
projectId: string
): Promise<PermissionCheckResult> {
try {
// Check if user is a member of the project
const memberQuery = `
SELECT pm.role_id, pr.name as role_name, pr.permissions
FROM project_members pm
JOIN project_member_roles pr ON pm.role_id = pr.id
WHERE pm.user_id = $1 AND pm.project_id = $2
LIMIT 1;
`;
const result = await db.query(memberQuery, [userId, projectId]);
if (result.rows.length === 0) {
return {
hasPermission: false,
reason: "User is not a member of the project"
};
}
const member = result.rows[0];
// Check if role has task creation permission
if (member.permissions && member.permissions.create_tasks === false) {
return {
hasPermission: false,
reason: "User role does not have permission to create tasks",
projectRole: member.role_name
};
}
return {
hasPermission: true,
projectRole: member.role_name
};
} catch (error) {
log_error("Error checking project permissions:", error);
return {
hasPermission: false,
reason: "Error checking permissions"
};
}
}
/**
* Check if a template has valid permissions
*/
static async validateTemplatePermissions(templateId: string): Promise<PermissionCheckResult> {
try {
const query = `
SELECT
t.reporter_id,
t.project_id,
p.is_active as project_active,
p.archived as project_archived,
u.is_active as user_active
FROM task_recurring_templates trt
JOIN tasks t ON trt.task_id = t.id
JOIN projects p ON t.project_id = p.id
JOIN users u ON t.reporter_id = u.id
WHERE trt.id = $1
LIMIT 1;
`;
const result = await db.query(query, [templateId]);
if (result.rows.length === 0) {
return {
hasPermission: false,
reason: "Template not found"
};
}
const template = result.rows[0];
// Check if project is active
if (!template.project_active || template.project_archived) {
return {
hasPermission: false,
reason: "Project is not active or archived"
};
}
// Check if reporter is still active
if (!template.user_active) {
return {
hasPermission: false,
reason: "Original task reporter is no longer active"
};
}
// Check if reporter still has permissions in the project
const permissionCheck = await this.canCreateTasksInProject(
template.reporter_id,
template.project_id
);
return permissionCheck;
} catch (error) {
log_error("Error validating template permissions:", error);
return {
hasPermission: false,
reason: "Error validating template permissions"
};
}
}
/**
* Get all templates with permission issues
*/
static async getTemplatesWithPermissionIssues(): Promise<any[]> {
try {
const query = `
SELECT
trt.id as template_id,
trt.name as template_name,
t.reporter_id,
u.name as reporter_name,
t.project_id,
p.name as project_name,
CASE
WHEN NOT p.is_active THEN 'Project inactive'
WHEN p.archived THEN 'Project archived'
WHEN NOT u.is_active THEN 'User inactive'
WHEN NOT EXISTS (
SELECT 1 FROM project_members
WHERE user_id = t.reporter_id AND project_id = t.project_id
) THEN 'User not in project'
ELSE NULL
END as issue
FROM task_recurring_templates trt
JOIN tasks t ON trt.task_id = t.id
JOIN projects p ON t.project_id = p.id
JOIN users u ON t.reporter_id = u.id
WHERE
NOT p.is_active
OR p.archived
OR NOT u.is_active
OR NOT EXISTS (
SELECT 1 FROM project_members
WHERE user_id = t.reporter_id AND project_id = t.project_id
);
`;
const result = await db.query(query);
return result.rows;
} catch (error) {
log_error("Error getting templates with permission issues:", error);
return [];
}
}
/**
* Validate all assignees have permissions
*/
static async validateAssigneePermissions(
assignees: Array<{ team_member_id: string }>,
projectId: string
): Promise<string[]> {
const invalidAssignees: string[] = [];
for (const assignee of assignees) {
const check = await this.canCreateTasksInProject(assignee.team_member_id, projectId);
if (!check.hasPermission) {
invalidAssignees.push(assignee.team_member_id);
}
}
return invalidAssignees;
}
}

View File

@@ -0,0 +1,134 @@
import { log_error } from "../shared/utils";
export interface RetryOptions {
maxRetries: number;
delayMs: number;
backoffFactor?: number;
onRetry?: (error: any, attempt: number) => void;
}
export class RetryUtils {
/**
* Execute a function with retry logic
*/
static async withRetry<T>(
fn: () => Promise<T>,
options: RetryOptions
): Promise<T> {
const { maxRetries, delayMs, backoffFactor = 1.5, onRetry } = options;
let lastError: any;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error;
if (attempt === maxRetries) {
throw error;
}
const delay = delayMs * Math.pow(backoffFactor, attempt - 1);
if (onRetry) {
onRetry(error, attempt);
}
log_error(`Attempt ${attempt} failed. Retrying in ${delay}ms...`, error);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw lastError;
}
/**
* Execute database operations with retry logic
*/
static async withDatabaseRetry<T>(
operation: () => Promise<T>,
operationName: string
): Promise<T> {
return this.withRetry(operation, {
maxRetries: 3,
delayMs: 1000,
backoffFactor: 2,
onRetry: (error, attempt) => {
log_error(`Database operation '${operationName}' failed on attempt ${attempt}:`, error);
}
});
}
/**
* Check if an error is retryable
*/
static isRetryableError(error: any): boolean {
// PostgreSQL error codes that are retryable
const retryableErrorCodes = [
'40001', // serialization_failure
'40P01', // deadlock_detected
'55P03', // lock_not_available
'57P01', // admin_shutdown
'57P02', // crash_shutdown
'57P03', // cannot_connect_now
'58000', // system_error
'58030', // io_error
'53000', // insufficient_resources
'53100', // disk_full
'53200', // out_of_memory
'53300', // too_many_connections
'53400', // configuration_limit_exceeded
];
if (error.code && retryableErrorCodes.includes(error.code)) {
return true;
}
// Network-related errors
if (error.message && (
error.message.includes('ECONNRESET') ||
error.message.includes('ETIMEDOUT') ||
error.message.includes('ECONNREFUSED')
)) {
return true;
}
return false;
}
/**
* Execute with conditional retry based on error type
*/
static async withConditionalRetry<T>(
fn: () => Promise<T>,
options: RetryOptions
): Promise<T> {
const { maxRetries, delayMs, backoffFactor = 1.5, onRetry } = options;
let lastError: any;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error;
if (!this.isRetryableError(error) || attempt === maxRetries) {
throw error;
}
const delay = delayMs * Math.pow(backoffFactor, attempt - 1);
if (onRetry) {
onRetry(error, attempt);
}
log_error(`Retryable error on attempt ${attempt}. Retrying in ${delay}ms...`, error);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw lastError;
}
}

View File

@@ -0,0 +1,156 @@
import moment from "moment-timezone";
import { IRecurringSchedule } from "../interfaces/recurring-tasks";
export class TimezoneUtils {
/**
* Convert a date from one timezone to another
*/
static convertTimezone(date: moment.Moment | Date | string, fromTz: string, toTz: string): moment.Moment {
return moment.tz(date, fromTz).tz(toTz);
}
/**
* Get the current time in a specific timezone
*/
static nowInTimezone(timezone: string): moment.Moment {
return moment.tz(timezone);
}
/**
* Check if a recurring task should run based on timezone
*/
static shouldRunInTimezone(schedule: IRecurringSchedule, timezone: string): boolean {
const now = this.nowInTimezone(timezone);
const scheduleTime = moment.tz(schedule.created_at, timezone);
// Check if it's the right time of day (within a 1-hour window)
const hourDiff = Math.abs(now.hour() - scheduleTime.hour());
return hourDiff < 1;
}
/**
* Calculate next end date considering timezone
*/
static calculateNextEndDateWithTimezone(
schedule: IRecurringSchedule,
lastDate: moment.Moment | Date | string,
timezone: string
): moment.Moment {
const lastMoment = moment.tz(lastDate, timezone);
switch (schedule.schedule_type) {
case "daily":
return lastMoment.clone().add(1, "day");
case "weekly":
if (schedule.days_of_week && schedule.days_of_week.length > 0) {
// Find next occurrence based on selected days
let nextDate = lastMoment.clone();
let daysChecked = 0;
do {
nextDate.add(1, "day");
daysChecked++;
if (schedule.days_of_week.includes(nextDate.day())) {
return nextDate;
}
} while (daysChecked < 7);
// If no valid day found, return next week's first selected day
const sortedDays = [...schedule.days_of_week].sort((a, b) => a - b);
nextDate = lastMoment.clone().add(1, "week").day(sortedDays[0]);
return nextDate;
}
return lastMoment.clone().add(1, "week");
case "monthly":
if (schedule.date_of_month) {
// Specific date of month
let nextDate = lastMoment.clone().add(1, "month").date(schedule.date_of_month);
// Handle months with fewer days
if (nextDate.date() !== schedule.date_of_month) {
nextDate = nextDate.endOf("month");
}
return nextDate;
} else if (schedule.week_of_month && schedule.day_of_month !== undefined) {
// Nth occurrence of a day in month
const nextMonth = lastMoment.clone().add(1, "month").startOf("month");
const targetDay = schedule.day_of_month;
const targetWeek = schedule.week_of_month;
// Find first occurrence of the target day
let firstOccurrence = nextMonth.clone();
while (firstOccurrence.day() !== targetDay) {
firstOccurrence.add(1, "day");
}
// Calculate nth occurrence
if (targetWeek === 5) {
// Last occurrence
let lastOccurrence = firstOccurrence.clone();
let temp = firstOccurrence.clone().add(7, "days");
while (temp.month() === nextMonth.month()) {
lastOccurrence = temp.clone();
temp.add(7, "days");
}
return lastOccurrence;
} else {
// Specific week number
return firstOccurrence.add((targetWeek - 1) * 7, "days");
}
}
return lastMoment.clone().add(1, "month");
case "every_x_days":
return lastMoment.clone().add(schedule.interval_days || 1, "days");
case "every_x_weeks":
return lastMoment.clone().add(schedule.interval_weeks || 1, "weeks");
case "every_x_months":
return lastMoment.clone().add(schedule.interval_months || 1, "months");
default:
return lastMoment.clone().add(1, "day");
}
}
/**
* Get all timezones that should be processed in the current hour
*/
static getActiveTimezones(): string[] {
const activeTimezones: string[] = [];
const allTimezones = moment.tz.names();
for (const tz of allTimezones) {
const tzTime = moment.tz(tz);
// Check if it's 11:00 AM in this timezone (matching the cron schedule)
if (tzTime.hour() === 11) {
activeTimezones.push(tz);
}
}
return activeTimezones;
}
/**
* Validate timezone string
*/
static isValidTimezone(timezone: string): boolean {
return moment.tz.zone(timezone) !== null;
}
/**
* Get user's timezone or default to UTC
*/
static getUserTimezone(userTimezone?: string): string {
if (userTimezone && this.isValidTimezone(userTimezone)) {
return userTimezone;
}
return "UTC";
}
}

View File

@@ -57,15 +57,116 @@
<!-- Environment configuration -->
<script src="/env-config.js"></script>
<!-- Analytics Module -->
<script src="/js/analytics.js"></script>
<!-- Optimized Google Analytics with reduced blocking -->
<script>
// Function to initialize Google Analytics asynchronously
function initGoogleAnalytics() {
// Use requestIdleCallback to defer analytics loading
const loadAnalytics = () => {
// Determine which tracking ID to use based on the environment
const isProduction = window.location.hostname === 'app.worklenz.com';
const trackingId = isProduction ? 'G-7KSRKQ1397' : 'G-3LM2HGWEXG'; // Open source tracking ID
// Load the Google Analytics script
const script = document.createElement('script');
script.async = true;
script.src = `https://www.googletagmanager.com/gtag/js?id=${trackingId}`;
document.head.appendChild(script);
// Initialize Google Analytics
window.dataLayer = window.dataLayer || [];
function gtag() {
dataLayer.push(arguments);
}
gtag('js', new Date());
gtag('config', trackingId);
};
// Use requestIdleCallback if available, otherwise setTimeout
if ('requestIdleCallback' in window) {
requestIdleCallback(loadAnalytics, { timeout: 2000 });
} else {
setTimeout(loadAnalytics, 1000);
}
}
// Initialize analytics after a delay to not block initial render
initGoogleAnalytics();
// Function to show privacy notice
function showPrivacyNotice() {
const notice = document.createElement('div');
notice.style.cssText = `
position: fixed;
bottom: 16px;
right: 16px;
background: #222;
color: #f5f5f5;
padding: 12px 16px 10px 16px;
border-radius: 7px;
box-shadow: 0 2px 8px rgba(0,0,0,0.18);
z-index: 1000;
max-width: 320px;
font-family: Inter, sans-serif;
border: 1px solid #333;
font-size: 0.95rem;
`;
notice.innerHTML = `
<div style="margin-bottom: 6px; font-weight: 600; color: #fff; font-size: 1rem;">Analytics Notice</div>
<div style="margin-bottom: 8px; color: #f5f5f5;">This app uses Google Analytics for anonymous usage stats. No personal data is tracked.</div>
<button id="analytics-notice-btn" style="padding: 5px 14px; background: #1890ff; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 0.95rem;">Got it</button>
`;
document.body.appendChild(notice);
// Add event listener to button
const btn = notice.querySelector('#analytics-notice-btn');
btn.addEventListener('click', function (e) {
e.preventDefault();
localStorage.setItem('privacyNoticeShown', 'true');
notice.remove();
});
}
// Wait for DOM to be ready
document.addEventListener('DOMContentLoaded', function () {
// Check if we should show the notice
const isProduction =
window.location.hostname === 'worklenz.com' ||
window.location.hostname === 'app.worklenz.com';
const noticeShown = localStorage.getItem('privacyNoticeShown') === 'true';
// Show notice if not in production and not shown before
if (!isProduction && !noticeShown) {
showPrivacyNotice();
}
});
</script>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="./src/index.tsx"></script>
<!-- HubSpot Integration Module -->
<script src="/js/hubspot.js"></script>
<script type="text/javascript">
// Load HubSpot script asynchronously and only for production
if (window.location.hostname === 'app.worklenz.com') {
// Use requestIdleCallback to defer HubSpot loading
const loadHubSpot = () => {
var hs = document.createElement('script');
hs.type = 'text/javascript';
hs.id = 'hs-script-loader';
hs.async = true;
hs.defer = true;
hs.src = '//js.hs-scripts.com/22348300.js';
document.body.appendChild(hs);
};
if ('requestIdleCallback' in window) {
requestIdleCallback(loadHubSpot, { timeout: 3000 });
} else {
setTimeout(loadHubSpot, 2000);
}
}
</script>
</body>
</html>

View File

@@ -1,97 +0,0 @@
/**
* Google Analytics initialization module
* Handles analytics loading and privacy notices
*/
class AnalyticsManager {
constructor() {
this.isProduction = window.location.hostname === 'app.worklenz.com';
this.trackingId = this.isProduction ? 'G-7KSRKQ1397' : 'G-3LM2HGWEXG';
}
/**
* Initialize Google Analytics asynchronously
*/
init() {
const loadAnalytics = () => {
// Load the Google Analytics script
const script = document.createElement('script');
script.async = true;
script.src = `https://www.googletagmanager.com/gtag/js?id=${this.trackingId}`;
document.head.appendChild(script);
// Initialize Google Analytics
window.dataLayer = window.dataLayer || [];
function gtag() {
dataLayer.push(arguments);
}
gtag('js', new Date());
gtag('config', this.trackingId);
};
// Use requestIdleCallback if available, otherwise setTimeout
if ('requestIdleCallback' in window) {
requestIdleCallback(loadAnalytics, { timeout: 2000 });
} else {
setTimeout(loadAnalytics, 1000);
}
}
/**
* Show privacy notice for non-production environments
*/
showPrivacyNotice() {
const notice = document.createElement('div');
notice.style.cssText = `
position: fixed;
bottom: 16px;
right: 16px;
background: #222;
color: #f5f5f5;
padding: 12px 16px 10px 16px;
border-radius: 7px;
box-shadow: 0 2px 8px rgba(0,0,0,0.18);
z-index: 1000;
max-width: 320px;
font-family: Inter, sans-serif;
border: 1px solid #333;
font-size: 0.95rem;
`;
notice.innerHTML = `
<div style="margin-bottom: 6px; font-weight: 600; color: #fff; font-size: 1rem;">Analytics Notice</div>
<div style="margin-bottom: 8px; color: #f5f5f5;">This app uses Google Analytics for anonymous usage stats. No personal data is tracked.</div>
<button id="analytics-notice-btn" style="padding: 5px 14px; background: #1890ff; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 0.95rem;">Got it</button>
`;
document.body.appendChild(notice);
// Add event listener to button
const btn = notice.querySelector('#analytics-notice-btn');
btn.addEventListener('click', (e) => {
e.preventDefault();
localStorage.setItem('privacyNoticeShown', 'true');
notice.remove();
});
}
/**
* Check if privacy notice should be shown
*/
checkPrivacyNotice() {
const isProduction =
window.location.hostname === 'worklenz.com' ||
window.location.hostname === 'app.worklenz.com';
const noticeShown = localStorage.getItem('privacyNoticeShown') === 'true';
// Show notice if not in production and not shown before
if (!isProduction && !noticeShown) {
this.showPrivacyNotice();
}
}
}
// Initialize analytics when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
const analytics = new AnalyticsManager();
analytics.init();
analytics.checkPrivacyNotice();
});

View File

@@ -1,137 +0,0 @@
/**
* HubSpot Chat Widget integration module
* Handles widget loading and dark mode theming
*/
class HubSpotManager {
constructor() {
this.isProduction = window.location.hostname === 'app.worklenz.com';
this.scriptId = 'hs-script-loader';
this.scriptSrc = '//js.hs-scripts.com/22348300.js';
this.styleId = 'hubspot-dark-mode-override';
}
/**
* Load HubSpot script with dark mode support
*/
init() {
if (!this.isProduction) return;
const loadHubSpot = () => {
const script = document.createElement('script');
script.type = 'text/javascript';
script.id = this.scriptId;
script.async = true;
script.defer = true;
script.src = this.scriptSrc;
// Configure dark mode after script loads
script.onload = () => this.setupDarkModeSupport();
document.body.appendChild(script);
};
// Use requestIdleCallback for better performance
if ('requestIdleCallback' in window) {
requestIdleCallback(loadHubSpot, { timeout: 3000 });
} else {
setTimeout(loadHubSpot, 2000);
}
}
/**
* Setup dark mode theme switching for HubSpot widget
*/
setupDarkModeSupport() {
const applyTheme = () => {
const isDark = document.documentElement.classList.contains('dark');
// Remove existing theme styles
const existingStyle = document.getElementById(this.styleId);
if (existingStyle) {
existingStyle.remove();
}
if (isDark) {
this.injectDarkModeCSS();
}
};
// Apply initial theme after delay to ensure widget is loaded
setTimeout(applyTheme, 1000);
// Watch for theme changes
const observer = new MutationObserver(applyTheme);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class']
});
}
/**
* Inject CSS for dark mode styling
*/
injectDarkModeCSS() {
const style = document.createElement('style');
style.id = this.styleId;
style.textContent = `
/* HubSpot Chat Widget Dark Mode Override */
#hubspot-conversations-inline-parent,
#hubspot-conversations-iframe-container,
.shadow-2xl.widget-align-right.widget-align-bottom,
[data-test-id="chat-widget"],
[class*="VizExCollapsedChat"],
[class*="VizExExpandedChat"],
iframe[src*="hubspot"] {
filter: invert(1) hue-rotate(180deg) !important;
background: transparent !important;
}
/* Target HubSpot widget container backgrounds */
#hubspot-conversations-inline-parent div,
#hubspot-conversations-iframe-container div,
[data-test-id="chat-widget"] div {
background-color: transparent !important;
}
/* Prevent double inversion of images, avatars, and icons */
#hubspot-conversations-iframe-container img,
#hubspot-conversations-iframe-container [style*="background-image"],
#hubspot-conversations-iframe-container svg,
iframe[src*="hubspot"] img,
iframe[src*="hubspot"] svg,
[data-test-id="chat-widget"] img,
[data-test-id="chat-widget"] svg {
filter: invert(1) hue-rotate(180deg) !important;
}
/* Additional targeting for widget launcher and chat bubble */
div[class*="shadow-2xl"],
div[class*="widget-align"],
div[style*="position: fixed"] {
filter: invert(1) hue-rotate(180deg) !important;
}
`;
document.head.appendChild(style);
}
/**
* Remove HubSpot widget and associated styles
*/
cleanup() {
const script = document.getElementById(this.scriptId);
const style = document.getElementById(this.styleId);
if (script) script.remove();
if (style) style.remove();
}
}
// Initialize HubSpot integration
document.addEventListener('DOMContentLoaded', () => {
const hubspot = new HubSpotManager();
hubspot.init();
// Make available globally for potential cleanup
window.HubSpotManager = hubspot;
});

View File

@@ -1,191 +1,31 @@
{
"continue": "Vazhdo",
"setupYourAccount": "Konfiguro llogarinë tënde.",
"organizationStepTitle": "Emërto organizatën tënde",
"organizationStepLabel": "Zgjidh një emër për llogarinë tënde në Worklenz.",
"organizationStepWelcome": "Konfiguro llogarinë tënde në Worklenz.",
"organizationStepDescription": "Le të fillojmë duke konfiguruar organizatën tënde. Kjo do të jetë hapësira kryesore e punës për ekipin tënd.",
"organizationStepTooltip": "Ky emër do të shfaqet në hapësirën tënde të punës dhe mund të ndryshohet më vonë në cilësime.",
"organizationStepNeedIdeas": "Keni nevojë për ide?",
"organizationStepUseDetected": "Përdorimi i zbuluar:",
"organizationStepCharacters": "karaktere",
"organizationStepGoodLength": "Gjatësi e mirë",
"organizationStepTooShort": "Shumë i shkurtër",
"organizationStepNamingTips": "Këshilla për emërtimin",
"organizationStepTip1": "Mbaje të thjeshtë dhe të lehtë për t'u mbajtur mend",
"organizationStepTip2": "Përfaqëso industrinë ose vlerat e tua",
"organizationStepTip3": "Mendo për rritjen në të ardhmen",
"organizationStepTip4": "Bëje unik dhe të përshtatshëm për markë",
"organizationStepSuggestionsTitle": "Sugjerime për emra",
"organizationStepCategory1": "Kompani Teknologjie",
"organizationStepCategory2": "Agjenci Kreative",
"organizationStepCategory3": "Konsulencë",
"organizationStepCategory4": "Startupe",
"organizationStepSuggestionsNote": "Këto janë vetëm shembuj për të të ndihmuar të fillosh. Zgjidh diçka që përfaqëson organizatën tënde.",
"organizationStepPrivacyNote": "Emri i organizatës tënde është privat dhe i dukshëm vetëm për anëtarët e ekipit.",
"projectStepTitle": "Krijo projektin tënd të parë",
"projectStepLabel": "Në cilin projekt po punon tani?",
"setupYourAccount": "Konfiguro Llogarinë Tënde në Worklenz.",
"organizationStepTitle": "Emërtoni Organizatën Tuaj",
"organizationStepLabel": "Zgjidhni një emër për llogarinë tuaj në Worklenz.",
"projectStepTitle": "Krijoni projektin tuaj të parë",
"projectStepLabel": "Në cilin projekt po punoni aktualisht?",
"projectStepPlaceholder": "p.sh. Plani i Marketingut",
"tasksStepTitle": "Krijo detyrat e tua të para",
"tasksStepLabel": "Shkruaj disa detyra që do të kryesh në",
"tasksStepTitle": "Krijoni detyrat tuaja të para",
"tasksStepLabel": "Shkruani disa detyra që do të kryeni në",
"tasksStepAddAnother": "Shto një tjetër",
"emailPlaceholder": "Adresa e emailit",
"invalidEmail": "Ju lutem vendosni një adresë emaili të vlefshme",
"emailPlaceholder": "Adresa email",
"invalidEmail": "Ju lutemi vendosni një adresë email të vlefshme",
"or": "ose",
"templateButton": "Importo nga shablloni",
"goBack": "Kthehu mbrapa",
"goBack": "Kthehu Mbrapa",
"cancel": "Anulo",
"create": "Krijo",
"templateDrawerTitle": "Zgjidh nga shabllonet",
"step3InputLabel": "Fto me email",
"addAnother": "Shto një tjetër",
"skipForNow": "Kalo për tani",
"formTitle": "Krijo detyrën tënde të parë.",
"step3Title": "Fto ekipin tënd për të punuar së bashku",
"skipForNow": "Kalo tani për tani",
"formTitle": "Krijoni detyrën tuaj të parë.",
"step3Title": "Fto ekipin tënd të punojë me",
"maxMembers": " (Mund të ftoni deri në 5 anëtarë)",
"maxTasks": " (Mund të krijoni deri në 5 detyra)",
"membersStepTitle": "Fto ekipin tënd",
"membersStepDescription": "Shto anëtarë ekipi në \"{{organizationName}}\" dhe filloni bashkëpunimin",
"memberPlaceholder": "Anëtari i ekipit {{index}} - Shkruani adresën e emailit",
"validEmailAddress": "Adresë emaili e vlefshme",
"addAnotherTeamMember": "Shto një anëtar tjetër të ekipit ({{current}}/{{max}})",
"canInviteLater": "Gjithmonë mund të ftoni anëtarë të ekipit më vonë",
"skipStepDescription": "Nuk i keni adresat e emailit gati? Asnjë problem! Mund ta kaloni këtë hap dhe të ftoni anëtarë nga paneli i projektit më vonë.",
"orgCategoryTech": "Kompani Teknologjie",
"orgCategoryCreative": "Agjenci Kreative",
"orgCategoryConsulting": "Konsulencë",
"orgCategoryStartups": "Startupe",
"namingTip1": "Mbaje të thjeshtë dhe të lehtë për t'u mbajtur mend",
"namingTip2": "Përfaqëso industrinë ose vlerat e tua",
"namingTip3": "Mendo për rritjen në të ardhmen",
"namingTip4": "Bëje unik dhe të përshtatshëm për markë",
"aboutYouTitle": "Na trego për veten tënde",
"aboutYouDescription": "Na ndihmo të personalizojmë përvojën tënde",
"orgTypeQuestion": "Cila përshkruan më mirë organizatën tënde?",
"userRoleQuestion": "Cili është roli yt?",
"yourNeedsTitle": "Cilat janë nevojat e tua kryesore?",
"yourNeedsDescription": "Zgjidh të gjitha që aplikohen për të na ndihmuar të konfigurojmë hapësirën tënde të punës",
"yourNeedsQuestion": "Si do ta përdorësh kryesisht Worklenz?",
"useCaseTaskOrg": "Organizo dhe ndiq detyrat",
"useCaseTeamCollab": "Puno së bashku pa pengesa",
"useCaseResourceMgmt": "Menaxho kohën dhe burimet",
"useCaseClientComm": "Qëndro i lidhur me klientët",
"useCaseTimeTrack": "Monitoro orët e projektit",
"useCaseOther": "Diçka tjetër",
"selectedText": "zgjedhur",
"previousToolsQuestion": "Çfarë mjetesh ke përdorur më parë? (Opsionale)",
"discoveryTitle": "Edhe një gjë e fundit...",
"discoveryDescription": "Na ndihmo të kuptojmë si e zbulove Worklenz",
"discoveryQuestion": "Si dëgjove për ne?",
"allSetTitle": "Çdo gjë gati!",
"allSetDescription": "Le të krijojmë projektin tënd të parë dhe të fillojmë me Worklenz",
"aboutYouStepName": "Rreth teje",
"yourNeedsStepName": "Nevojat e tua",
"discoveryStepName": "Zbulimi",
"stepProgress": "Hapi {step} nga 3: {title}",
"projectStepHeader": "Le të krijojmë projektin tënd të parë",
"projectStepSubheader": "Fillo nga e para ose përdor një shabllon për të filluar më shpejt",
"startFromScratch": "Fillo nga e para",
"templateSelected": "Shablloni i zgjedhur më poshtë",
"quickSuggestions": "Sugjerime të shpejta:",
"orText": "OSE",
"startWithTemplate": "Fillo me një shabllon",
"clearToSelectTemplate": "Pastro emrin e projektit më sipër për të zgjedhur një shabllon",
"templateHeadStart": "Fillo më shpejt me struktura të gatshme projekti",
"browseAllTemplates": "Shfleto të gjitha shabllonet",
"templatesAvailable": "15+ shabllone të specializuara sipas industrisë në dispozicion",
"chooseTemplate": "Zgjidh një shabllon që i përshtatet llojit të projektit tënd",
"createProject": "Krijo projekt",
"templateSoftwareDev": "Zhvillim Softueri",
"templateSoftwareDesc": "Sprint-e agile, ndjekje gabimesh, lëshime",
"templateMarketing": "Fushatë Marketingu",
"templateMarketingDesc": "Planifikim fushate, kalendar përmbajtjesh",
"templateConstruction": "Projekt Ndërtimi",
"templateConstructionDesc": "Faza, leje, kontraktorë",
"templateStartup": "Lansim Startup-i",
"templateStartupDesc": "Zhvillim MVP, financim, rritje",
"tasksStepDescription": "Ndaji \"{{projectName}}\" në detyra të veprueshme për të filluar",
"taskPlaceholder": "Detyra {{index}} - p.sh., Çfarë duhet bërë?",
"addAnotherTask": "Shto një detyrë tjetër ({{current}}/{{max}})",
"surveyStepTitle": "Na trego për veten tënde",
"surveyStepLabel": "Na ndihmo të personalizojmë përvojën tënde në Worklenz duke iu përgjigjur disa pyetjeve.",
"organizationType": "Cila përshkruan më mirë organizatën tënde?",
"organizationTypeFreelancer": "Freelancer",
"organizationTypeStartup": "Startup",
"organizationTypeSmallMediumBusiness": "Biznes i Vogël ose i Mesëm",
"organizationTypeAgency": "Agjenci",
"organizationTypeEnterprise": "Ndërmarrje",
"organizationTypeOther": "Tjetër",
"userRole": "Cili është roli yt?",
"userRoleFounderCeo": "Themelues / CEO",
"userRoleProjectManager": "Menaxher Projekti",
"userRoleSoftwareDeveloper": "Zhvillues Softueri",
"userRoleDesigner": "Dizajner",
"userRoleOperations": "Operacionet",
"userRoleOther": "Tjetër",
"mainUseCases": "Për çfarë do ta përdorësh kryesisht Worklenz?",
"mainUseCasesTaskManagement": "Menaxhim detyrash",
"mainUseCasesTeamCollaboration": "Bashkëpunim ekipi",
"mainUseCasesResourcePlanning": "Planifikim burimesh",
"mainUseCasesClientCommunication": "Komunikim & raportim me klientët",
"mainUseCasesTimeTracking": "Ndjekje kohe",
"mainUseCasesOther": "Tjetër",
"previousTools": "Çfarë mjetesh ke përdorur para Worklenz?",
"previousToolsPlaceholder": "p.sh. Trello, Asana, Monday.com",
"howHeardAbout": "Si dëgjove për Worklenz?",
"howHeardAboutGoogleSearch": "Kërkim në Google",
"howHeardAboutTwitter": "Twitter",
"howHeardAboutLinkedin": "LinkedIn",
"howHeardAboutFriendColleague": "Një mik ose koleg",
"howHeardAboutBlogArticle": "Një blog ose artikull",
"howHeardAboutOther": "Tjetër",
"aboutYouStepTitle": "Na trego për veten",
"aboutYouStepDescription": "Na ndihmo të personalizojmë përvojën tënde",
"yourNeedsStepTitle": "Cilat janë nevojat e tua kryesore?",
"yourNeedsStepDescription": "Zgjidh të gjitha që aplikohen për të na ndihmuar të konfigurojmë hapësirën tënde të punës",
"selected": "zgjedhur",
"previousToolsLabel": "Çfarë mjetesh ke përdorur më parë? (Opsionale)",
"roleSuggestions": {
"designer": "UI/UX, Grafikë, Kreativ",
"developer": "Frontend, Backend, Full-stack",
"projectManager": "Planifikim, Koordinim",
"marketing": "Përmbajtje, Media Sociale, Rritje",
"sales": "Zhvillim Biznesi, Marrëdhënie me Klientë",
"operations": "Administratë, HR, Financa"
},
"languages": {
"en": "Anglisht",
"es": "Spanjisht",
"pt": "Portugalisht",
"de": "Gjermanisht",
"alb": "Shqip",
"zh": "Kinezçe"
},
"orgSuggestions": {
"tech": ["TechCorp", "DevStudio", "CodeCraft", "PixelForge"],
"creative": ["Creative Hub", "Design Studio", "Brand Works", "Visual Arts"],
"consulting": ["Strategy Group", "Business Solutions", "Expert Advisors", "Growth Partners"],
"startup": ["Innovation Labs", "Future Works", "Venture Co", "Next Gen"]
},
"projectSuggestions": {
"freelancer": ["Projekti i Klientit", "Përditësim Portfolio", "Markë Personale"],
"startup": ["Zhvillim MVP", "Lansim Produkti", "Kërkim Tregu"],
"agency": ["Fushatë Klienti", "Strategji Markë", "Ridizajnim Website"],
"enterprise": ["Migrim Sistemi", "Optimizim Procesesh", "Trajnim Ekipi"]
},
"useCaseDescriptions": {
"taskManagement": "Organizoj dhe ndjek detyrat",
"teamCollaboration": "Punojmë së bashku pa probleme",
"resourcePlanning": "Menaxhoj kohën dhe burimet",
"clientCommunication": "Qëndroj i lidhur me klientët",
"timeTracking": "Monitoroj orët e projektit",
"other": "Diçka tjetër"
}
"maxTasks": " (Mund të krijoni deri në 5 detyra)"
}

View File

@@ -7,13 +7,11 @@
"emailLabel": "Email",
"emailPlaceholder": "Shkruani email-in tuaj",
"emailRequired": "Ju lutemi shkruani Email-in tuaj!",
"passwordLabel": "Password",
"passwordGuideline": "Password must be at least 8 characters, include uppercase and lowercase letters, a number, and a special character.",
"passwordPlaceholder": "Enter your password",
"passwordLabel": "Fjalëkalimi",
"passwordPlaceholder": "Krijoni një fjalëkalim",
"passwordRequired": "Ju lutemi krijoni një Fjalëkalim!",
"passwordMinCharacterRequired": "Fjalëkalimi duhet të jetë së paku 8 karaktere!",
"passwordMaxCharacterRequired": "Password must be at most 32 characters!",
"passwordPatternRequired": "Fjalëkalimi nuk i plotëson kërkesat!",
"passwordPatternRequired": "Fjalëkalimi nuk plotëson kërkesat!",
"strongPasswordPlaceholder": "Vendosni një fjalëkalim më të fortë",
"passwordValidationAltText": "Fjalëkalimi duhet të përmbajë së paku 8 karaktere me shkronja të mëdha dhe të vogla, një numër dhe një simbol.",
"signupSuccessMessage": "Jeni regjistruar me sukses!",

View File

@@ -10,17 +10,6 @@
"deleteConfirmationOk": "Po",
"deleteConfirmationCancel": "Anulo",
"deleteTaskTitle": "Fshi Detyrën",
"deleteTaskContent": "Jeni i sigurt që doni të fshini këtë detyrë? Kjo veprim nuk mund të zhbëhet.",
"deleteTaskConfirm": "Fshi",
"deleteTaskCancel": "Anulo",
"deleteStatusTitle": "Fshi Statusin",
"deleteStatusContent": "Jeni i sigurt që doni të fshini këtë status? Kjo veprim nuk mund të zhbëhet.",
"deletePhaseTitle": "Fshi Fazen",
"deletePhaseContent": "Jeni i sigurt që doni të fshini këtë fazë? Kjo veprim nuk mund të zhbëhet.",
"dueDate": "Data e përfundimit",
"cancel": "Anulo",
@@ -37,17 +26,5 @@
"noDueDate": "Pa datë përfundimi",
"save": "Ruaj",
"clear": "Pastro",
"nextWeek": "Javën e ardhshme",
"noSubtasks": "Pa nëndetyra",
"showSubtasks": "Shfaq nëndetyrat",
"hideSubtasks": "Fshih nëndetyrat",
"errorLoadingTasks": "Gabim gjatë ngarkimit të detyrave",
"noTasksFound": "Nuk u gjetën detyra",
"loadingFilters": "Duke ngarkuar filtra...",
"failedToUpdateColumnOrder": "Dështoi përditësimi i rendit të kolonave",
"failedToUpdatePhaseOrder": "Dështoi përditësimi i rendit të fazave",
"pleaseTryAgain": "Ju lutemi provoni përsëri",
"taskNotCompleted": "Detyra nuk është përfunduar",
"completeTaskDependencies": "Ju lutemi përfundoni varësitë e detyrës para se të vazhdoni"
"nextWeek": "Javën e ardhshme"
}

View File

@@ -13,6 +13,5 @@
"deleteButtonTooltip": "Hiq nga projekti",
"memberCount": "Anëtar",
"membersCountPlural": "Anëtarë",
"emptyText": "Nuk ka bashkëngjitje në projekt.",
"searchPlaceholder": "Kërko anëtarë"
"emptyText": "Nuk ka bashkëngjitje në projekt."
}

View File

@@ -3,9 +3,5 @@
"searchLabel": "Shtoni anëtarë duke shkruar emrin ose email-in e tyre",
"searchPlaceholder": "Shkruani emrin ose email-in",
"inviteAsAMember": "Fto si anëtar",
"inviteNewMemberByEmail": "Fto anëtar të ri me email",
"members": "Anëtarë",
"copyProjectLink": "Kopjo lidhjen e projektit",
"inviteMember": "Fto anëtar",
"alsoInviteToProject": "Fto edhe në projekt"
"inviteNewMemberByEmail": "Fto anëtar të ri me email"
}

View File

@@ -28,7 +28,7 @@
"jobTitleLabel": "Titulli i Punës",
"jobTitlePlaceholder": "Zgjidh ose kërko titull pune (Opsionale)",
"memberAccessLabel": "Niveli i Qasjes",
"addToTeamButton": "Dërgo ftesën",
"addToTeamButton": "Shto Anëtar në Ekip",
"updateButton": "Ruaj Ndryshimet",
"resendInvitationButton": "Dërgo Përsëri Email-in e Ftesës",
"invitationSentSuccessMessage": "Ftesa për ekip u dërgua me sukses!",
@@ -43,6 +43,5 @@
"updatedText": "Përditësuar",
"noResultFound": "Shkruani një adresë email dhe shtypni Enter...",
"jobTitlesFetchError": "Dështoi marrja e titujve të punës",
"invitationResent": "Ftesa u dërgua sërish me sukses!",
"copyTeamLink": "Kopjo lidhjen e ekipit"
"invitationResent": "Ftesa u dërgua sërish me sukses!"
}

View File

@@ -3,28 +3,7 @@
"setupYourAccount": "Richten Sie Ihr Worklenz-Konto ein.",
"organizationStepTitle": "Organisation benennen",
"organizationStepWelcome": "Willkommen bei Worklenz!",
"organizationStepDescription": "Beginnen wir mit der Einrichtung Ihrer Organisation. Dies wird der Hauptarbeitsplatz für Ihr Team.",
"organizationStepLabel": "Organisationsname",
"organizationStepPlaceholder": "z.B. Acme Corporation",
"organizationStepTooltip": "Dieser Name wird in Ihrem Arbeitsbereich angezeigt und kann später in den Einstellungen geändert werden.",
"organizationStepNeedIdeas": "Brauchen Sie Ideen?",
"organizationStepUseDetected": "Erkannt verwenden:",
"organizationStepCharacters": "Zeichen",
"organizationStepGoodLength": "Gute Länge",
"organizationStepTooShort": "Zu kurz",
"organizationStepNamingTips": "Namensgebungstipps",
"organizationStepTip1": "Halten Sie es einfach und einprägsam",
"organizationStepTip2": "Spiegeln Sie Ihre Branche oder Werte wider",
"organizationStepTip3": "Denken Sie an zukünftiges Wachstum",
"organizationStepTip4": "Machen Sie es einzigartig und markenfähig",
"organizationStepSuggestionsTitle": "Namensvorschläge",
"organizationStepCategory1": "Tech-Unternehmen",
"organizationStepCategory2": "Kreativagenturen",
"organizationStepCategory3": "Beratung",
"organizationStepCategory4": "Startups",
"organizationStepSuggestionsNote": "Dies sind nur Beispiele für den Einstieg. Wählen Sie etwas, das Ihre Organisation repräsentiert.",
"organizationStepPrivacyNote": "Ihr Organisationsname ist privat und nur für Ihre Teammitglieder sichtbar.",
"organizationStepLabel": "Wählen Sie einen Namen für Ihr Worklenz-Konto.",
"projectStepTitle": "Erstellen Sie Ihr erstes Projekt",
"projectStepLabel": "An welchem Projekt arbeiten Sie gerade?",
@@ -48,164 +27,5 @@
"formTitle": "Erstellen Sie Ihre erste Aufgabe.",
"step3Title": "Laden Sie Ihr Team zur Zusammenarbeit ein",
"maxMembers": " (Sie können bis zu 5 Mitglieder einladen)",
"maxTasks": " (Sie können bis zu 5 Aufgaben erstellen)",
"membersStepTitle": "Laden Sie Ihr Team ein",
"membersStepDescription": "Teammitglieder zu \"{{organizationName}}\" hinzufügen und mit der Zusammenarbeit beginnen",
"memberPlaceholder": "Teammitglied {{index}} - E-Mail-Adresse eingeben",
"validEmailAddress": "Gültige E-Mail-Adresse",
"addAnotherTeamMember": "Weiteres Teammitglied hinzufügen ({{current}}/{{max}})",
"canInviteLater": "Sie können Teammitglieder jederzeit später einladen",
"skipStepDescription": "Haben Sie keine E-Mail-Adressen bereit? Kein Problem! Sie können diesen Schritt überspringen und Teammitglieder später über Ihr Projekt-Dashboard einladen.",
"orgCategoryTech": "Technologieunternehmen",
"orgCategoryCreative": "Kreativagenturen",
"orgCategoryConsulting": "Beratung",
"orgCategoryStartups": "Startups",
"namingTip1": "Halten Sie es einfach und einprägsam",
"namingTip2": "Spiegeln Sie Ihre Branche oder Werte wider",
"namingTip3": "Denken Sie an zukünftiges Wachstum",
"namingTip4": "Machen Sie es einzigartig und markenfähig",
"aboutYouTitle": "Erzählen Sie uns von sich",
"aboutYouDescription": "Helfen Sie uns, Ihre Erfahrung zu personalisieren",
"orgTypeQuestion": "Was beschreibt Ihre Organisation am besten?",
"userRoleQuestion": "Was ist Ihre Rolle?",
"yourNeedsTitle": "Was sind Ihre Hauptbedürfnisse?",
"yourNeedsDescription": "Wählen Sie alle zutreffenden aus, um uns bei der Einrichtung Ihres Arbeitsbereichs zu helfen",
"yourNeedsQuestion": "Wie werden Sie Worklenz hauptsächlich nutzen?",
"useCaseTaskOrg": "Aufgaben organisieren und verfolgen",
"useCaseTeamCollab": "Nahtlos zusammenarbeiten",
"useCaseResourceMgmt": "Zeit und Ressourcen verwalten",
"useCaseClientComm": "Mit Kunden in Verbindung bleiben",
"useCaseTimeTrack": "Projektstunden überwachen",
"useCaseOther": "Etwas anderes",
"selectedText": "ausgewählt",
"previousToolsQuestion": "Welche Tools haben Sie zuvor verwendet? (Optional)",
"previousToolsPlaceholder": "z.B. Asana, Trello, Jira, Monday.com, etc.",
"discoveryTitle": "Eine letzte Sache...",
"discoveryDescription": "Helfen Sie uns zu verstehen, wie Sie Worklenz entdeckt haben",
"discoveryQuestion": "Wie haben Sie von uns erfahren?",
"allSetTitle": "Sie sind bereit!",
"allSetDescription": "Lassen Sie uns Ihr erstes Projekt erstellen und mit Worklenz beginnen",
"aboutYouStepName": "Über Sie",
"yourNeedsStepName": "Ihre Bedürfnisse",
"discoveryStepName": "Entdeckung",
"stepProgress": "Schritt {step} von 3: {title}",
"projectStepHeader": "Lassen Sie uns Ihr erstes Projekt erstellen",
"projectStepSubheader": "Von Grund auf beginnen oder eine Vorlage verwenden, um schneller voranzukommen",
"startFromScratch": "Von Grund auf beginnen",
"templateSelected": "Vorlage unten ausgewählt",
"quickSuggestions": "Schnelle Vorschläge:",
"orText": "ODER",
"startWithTemplate": "Mit einer Vorlage beginnen",
"clearToSelectTemplate": "Projektname oben löschen, um eine Vorlage auszuwählen",
"templateHeadStart": "Verschaffen Sie sich einen Vorsprung mit vorgefertigten Projektstrukturen",
"browseAllTemplates": "Alle Vorlagen durchsuchen",
"templatesAvailable": "15+ branchenspezifische Vorlagen verfügbar",
"chooseTemplate": "Wählen Sie eine Vorlage, die zu Ihrem Projekttyp passt",
"createProject": "Projekt erstellen",
"templateSoftwareDev": "Softwareentwicklung",
"templateSoftwareDesc": "Agile Sprints, Fehlerverfolgung, Releases",
"templateMarketing": "Marketing-Kampagne",
"templateMarketingDesc": "Kampagnenplanung, Content-Kalender",
"templateConstruction": "Bauprojekt",
"templateConstructionDesc": "Phasen, Genehmigungen, Auftragnehmer",
"templateStartup": "Startup-Launch",
"templateStartupDesc": "MVP-Entwicklung, Finanzierung, Wachstum",
"tasksStepTitle": "Fügen Sie Ihre ersten Aufgaben hinzu",
"tasksStepDescription": "Unterteilen Sie \"{{projectName}}\" in umsetzbare Aufgaben, um zu beginnen",
"taskPlaceholder": "Aufgabe {{index}} - z.B., Was muss getan werden?",
"addAnotherTask": "Weitere Aufgabe hinzufügen ({{current}}/{{max}})",
"surveyStepTitle": "Erzählen Sie uns von sich",
"surveyStepLabel": "Helfen Sie uns, Ihre Worklenz-Erfahrung zu personalisieren, indem Sie ein paar Fragen beantworten.",
"organizationType": "Was beschreibt Ihre Organisation am besten?",
"organizationTypeFreelancer": "Freelancer",
"organizationTypeStartup": "Startup",
"organizationTypeSmallMediumBusiness": "Kleines oder mittleres Unternehmen",
"organizationTypeAgency": "Agentur",
"organizationTypeEnterprise": "Unternehmen",
"organizationTypeOther": "Andere",
"userRole": "Was ist Ihre Rolle?",
"userRoleFounderCeo": "Gründer / CEO",
"userRoleProjectManager": "Projektmanager",
"userRoleSoftwareDeveloper": "Software-Entwickler",
"userRoleDesigner": "Designer",
"userRoleOperations": "Betrieb",
"userRoleOther": "Andere",
"mainUseCases": "Wofür werden Sie Worklenz hauptsächlich verwenden?",
"mainUseCasesTaskManagement": "Aufgabenverwaltung",
"mainUseCasesTeamCollaboration": "Teamzusammenarbeit",
"mainUseCasesResourcePlanning": "Ressourcenplanung",
"mainUseCasesClientCommunication": "Kundenkommunikation & Berichterstattung",
"mainUseCasesTimeTracking": "Zeiterfassung",
"mainUseCasesOther": "Andere",
"previousTools": "Welche Tools haben Sie vor Worklenz verwendet?",
"previousToolsPlaceholder": "z.B. Trello, Asana, Monday.com",
"howHeardAbout": "Wie haben Sie von Worklenz erfahren?",
"howHeardAboutGoogleSearch": "Google-Suche",
"howHeardAboutTwitter": "Twitter",
"howHeardAboutLinkedin": "LinkedIn",
"howHeardAboutFriendColleague": "Ein Freund oder Kollege",
"howHeardAboutBlogArticle": "Ein Blog oder Artikel",
"howHeardAboutOther": "Andere",
"aboutYouStepTitle": "Erzählen Sie uns von sich",
"aboutYouStepDescription": "Helfen Sie uns, Ihre Erfahrung zu personalisieren",
"yourNeedsStepTitle": "Was sind Ihre Hauptbedürfnisse?",
"yourNeedsStepDescription": "Wählen Sie alle zutreffenden aus, um uns bei der Einrichtung Ihres Arbeitsbereichs zu helfen",
"selected": "ausgewählt",
"previousToolsLabel": "Welche Tools haben Sie zuvor verwendet? (Optional)",
"roleSuggestions": {
"designer": "UI/UX, Grafiken, Kreativ",
"developer": "Frontend, Backend, Full-stack",
"projectManager": "Planung, Koordination",
"marketing": "Inhalt, Social Media, Wachstum",
"sales": "Geschäftsentwicklung, Kundenbeziehungen",
"operations": "Admin, HR, Finanzen"
},
"languages": {
"en": "English",
"es": "Español",
"pt": "Português",
"de": "Deutsch",
"alb": "Shqip",
"zh": "简体中文"
},
"orgSuggestions": {
"tech": ["TechCorp", "DevStudio", "CodeCraft", "PixelForge"],
"creative": ["Creative Hub", "Design Studio", "Brand Works", "Visual Arts"],
"consulting": ["Strategy Group", "Business Solutions", "Expert Advisors", "Growth Partners"],
"startup": ["Innovation Labs", "Future Works", "Venture Co", "Next Gen"]
},
"projectSuggestions": {
"freelancer": ["Kundenprojekt", "Portfolio-Update", "Persönliche Marke"],
"startup": ["MVP-Entwicklung", "Produktlaunch", "Marktforschung"],
"agency": ["Kundenkampagne", "Markenstrategie", "Website-Redesign"],
"enterprise": ["Systemumstellung", "Prozessoptimierung", "Teamschulung"]
},
"useCaseDescriptions": {
"taskManagement": "Aufgaben organisieren und verfolgen",
"teamCollaboration": "Nahtlos zusammenarbeiten",
"resourcePlanning": "Zeit und Ressourcen verwalten",
"clientCommunication": "Mit Kunden in Verbindung bleiben",
"timeTracking": "Projektstunden überwachen",
"other": "Etwas anderes"
}
"maxTasks": " (Sie können bis zu 5 Aufgaben erstellen)"
}

View File

@@ -1,26 +0,0 @@
{
"billingDetails": "Abrechnungsdetails",
"name": "Name",
"namePlaceholder": "Name",
"emailAddress": "E-Mail-Adresse",
"emailPlaceholder": "E-Mail-Adresse",
"contactNumber": "Telefonnummer",
"phoneNumberPlaceholder": "Telefonnummer",
"phoneValidationError": "Telefonnummer muss genau 10 Ziffern haben",
"companyDetails": "Firmendetails",
"companyName": "Firmenname",
"companyNamePlaceholder": "Firmenname",
"addressLine01": "Adresszeile 01",
"addressLine01Placeholder": "Adresszeile 01",
"addressLine02": "Adresszeile 02",
"addressLine02Placeholder": "Adresszeile 02",
"country": "Land",
"countryPlaceholder": "Land",
"city": "Stadt",
"cityPlaceholder": "Stadt",
"state": "Bundesland",
"statePlaceholder": "Bundesland",
"postalCode": "Postleitzahl",
"postalCodePlaceholder": "Postleitzahl",
"save": "Speichern"
}

View File

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

View File

@@ -10,17 +10,6 @@
"deleteConfirmationOk": "Ja",
"deleteConfirmationCancel": "Abbrechen",
"deleteTaskTitle": "Aufgabe löschen",
"deleteTaskContent": "Sind Sie sicher, dass Sie diese Aufgabe löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
"deleteTaskConfirm": "Löschen",
"deleteTaskCancel": "Abbrechen",
"deleteStatusTitle": "Status löschen",
"deleteStatusContent": "Sind Sie sicher, dass Sie diesen Status löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
"deletePhaseTitle": "Phase löschen",
"deletePhaseContent": "Sind Sie sicher, dass Sie diese Phase löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
"dueDate": "Fälligkeitsdatum",
"cancel": "Abbrechen",
@@ -37,17 +26,5 @@
"noDueDate": "Kein Fälligkeitsdatum",
"save": "Speichern",
"clear": "Löschen",
"nextWeek": "Nächste Woche",
"noSubtasks": "Keine Unteraufgaben",
"showSubtasks": "Unteraufgaben anzeigen",
"hideSubtasks": "Unteraufgaben ausblenden",
"errorLoadingTasks": "Fehler beim Laden der Aufgaben",
"noTasksFound": "Keine Aufgaben gefunden",
"loadingFilters": "Filter werden geladen...",
"failedToUpdateColumnOrder": "Fehler beim Aktualisieren der Spaltenreihenfolge",
"failedToUpdatePhaseOrder": "Fehler beim Aktualisieren der Phasenreihenfolge",
"pleaseTryAgain": "Bitte versuchen Sie es erneut",
"taskNotCompleted": "Aufgabe ist nicht abgeschlossen",
"completeTaskDependencies": "Bitte schließen Sie die Aufgabenabhängigkeiten ab, bevor Sie fortfahren"
"nextWeek": "Nächste Woche"
}

View File

@@ -13,6 +13,5 @@
"deleteButtonTooltip": "Aus Projekt entfernen",
"memberCount": "Mitglied",
"membersCountPlural": "Mitglieder",
"emptyText": "Es gibt keine Anhänge in diesem Projekt.",
"searchPlaceholder": "Mitglieder suchen"
"emptyText": "Es gibt keine Anhänge in diesem Projekt."
}

View File

@@ -3,9 +3,5 @@
"searchLabel": "Mitglieder hinzufügen durch Eingabe von Name oder E-Mail",
"searchPlaceholder": "Name oder E-Mail eingeben",
"inviteAsAMember": "Als Mitglied einladen",
"inviteNewMemberByEmail": "Neues Mitglied per E-Mail einladen",
"members": "Mitglieder",
"copyProjectLink": "Projektlink kopieren",
"inviteMember": "Mitglied einladen",
"alsoInviteToProject": "Auch zum Projekt einladen"
"inviteNewMemberByEmail": "Neues Mitglied per E-Mail einladen"
}

View File

@@ -28,7 +28,7 @@
"jobTitleLabel": "Jobtitel",
"jobTitlePlaceholder": "Jobtitel auswählen oder suchen (optional)",
"memberAccessLabel": "Zugriffslevel",
"addToTeamButton": "Einladung senden",
"addToTeamButton": "Mitglied zum Team hinzufügen",
"updateButton": "Änderungen speichern",
"resendInvitationButton": "Einladungs-E-Mail erneut senden",
"invitationSentSuccessMessage": "Team-Einladung erfolgreich versendet!",
@@ -43,6 +43,5 @@
"updatedText": "Aktualisiert",
"noResultFound": "Geben Sie eine E-Mail-Adresse ein und drücken Sie Enter...",
"jobTitlesFetchError": "Fehler beim Abrufen der Jobtitel",
"invitationResent": "Einladung erfolgreich erneut gesendet!",
"copyTeamLink": "Team-Link kopieren"
"invitationResent": "Einladung erfolgreich erneut gesendet!"
}

View File

@@ -1,35 +1,15 @@
{
"continue": "Continue",
"setupYourAccount": "Setup Your Account.",
"setupYourAccount": "Setup Your Worklenz Account.",
"organizationStepTitle": "Name Your Organization",
"organizationStepWelcome": "Welcome to Worklenz!",
"organizationStepDescription": "Let's start by setting up your organization. This will be the main workspace for your team.",
"organizationStepLabel": "Organization name",
"organizationStepPlaceholder": "e.g. Acme Corporation",
"organizationStepTooltip": "This name will appear in your workspace and can be changed later in settings.",
"organizationStepNeedIdeas": "Need ideas?",
"organizationStepUseDetected": "Use detected:",
"organizationStepCharacters": "characters",
"organizationStepGoodLength": "Good length",
"organizationStepTooShort": "Too short",
"organizationStepNamingTips": "Naming Tips",
"organizationStepTip1": "Keep it simple and memorable",
"organizationStepTip2": "Reflect your industry or values",
"organizationStepTip3": "Think about future growth",
"organizationStepTip4": "Make it unique and brandable",
"organizationStepSuggestionsTitle": "Name Suggestions",
"organizationStepCategory1": "Tech Companies",
"organizationStepCategory2": "Creative Agencies",
"organizationStepCategory3": "Consulting",
"organizationStepCategory4": "Startups",
"organizationStepSuggestionsNote": "These are just examples to get you started. Choose something that represents your organization.",
"organizationStepPrivacyNote": "Your organization name is private and only visible to your team members.",
"organizationStepLabel": "Pick a name for your Worklenz account.",
"projectStepTitle": "Create your first project",
"projectStepLabel": "What project are you working on right now?",
"projectStepPlaceholder": "e.g. Marketing Plan",
"tasksStepTitle": "Create your first tasks",
"tasksStepLabel": "Type a few tasks that you are going to do in",
"tasksStepAddAnother": "Add another",
@@ -47,163 +27,5 @@
"formTitle": "Create your first task.",
"step3Title": "Invite your team to work with",
"maxMembers": " (You can invite up to 5 members)",
"maxTasks": " (You can create up to 5 tasks)",
"membersStepTitle": "Invite your team",
"membersStepDescription": "Add team members to \"{{organizationName}}\" and start collaborating",
"memberPlaceholder": "Team member {{index}} - Enter email address",
"validEmailAddress": "Valid email address",
"addAnotherTeamMember": "Add another team member ({{current}}/{{max}})",
"canInviteLater": "You can always invite team members later",
"skipStepDescription": "Don't have email addresses ready? No problem! You can skip this step and invite team members from your project dashboard later.",
"orgCategoryTech": "Tech Companies",
"orgCategoryCreative": "Creative Agencies",
"orgCategoryConsulting": "Consulting",
"orgCategoryStartups": "Startups",
"namingTip1": "Keep it simple and memorable",
"namingTip2": "Reflect your industry or values",
"namingTip3": "Think about future growth",
"namingTip4": "Make it unique and brandable",
"aboutYouTitle": "Tell us about yourself",
"aboutYouDescription": "Help us personalize your experience",
"orgTypeQuestion": "What best describes your organization?",
"userRoleQuestion": "What's your role?",
"yourNeedsTitle": "What are your main needs?",
"yourNeedsDescription": "Select all that apply to help us set up your workspace",
"yourNeedsQuestion": "How will you primarily use Worklenz?",
"useCaseTaskOrg": "Organize and track tasks",
"useCaseTeamCollab": "Work together seamlessly",
"useCaseResourceMgmt": "Manage time and resources",
"useCaseClientComm": "Stay connected with clients",
"useCaseTimeTrack": "Monitor project hours",
"useCaseOther": "Something else",
"selectedText": "selected",
"previousToolsQuestion": "What tools have you used before? (Optional)",
"discoveryTitle": "One last thing...",
"discoveryDescription": "Help us understand how you discovered Worklenz",
"discoveryQuestion": "How did you hear about us?",
"allSetTitle": "You're all set!",
"allSetDescription": "Let's create your first project and get started with Worklenz",
"aboutYouStepName": "About You",
"yourNeedsStepName": "Your Needs",
"discoveryStepName": "Discovery",
"stepProgress": "Step {step} of 3: {title}",
"projectStepHeader": "Let's create your first project",
"projectStepSubheader": "Start from scratch or use a template to get going faster",
"startFromScratch": "Start from scratch",
"templateSelected": "Template selected below",
"quickSuggestions": "Quick suggestions:",
"orText": "OR",
"startWithTemplate": "Start with a template",
"clearToSelectTemplate": "Clear project name above to select a template",
"templateHeadStart": "Get a head start with pre-built project structures",
"browseAllTemplates": "Browse All Templates",
"templatesAvailable": "15+ industry-specific templates available",
"chooseTemplate": "Choose a template that matches your project type",
"createProject": "Create Project",
"templateSoftwareDev": "Software Development",
"templateSoftwareDesc": "Agile sprints, bug tracking, releases",
"templateMarketing": "Marketing Campaign",
"templateMarketingDesc": "Campaign planning, content calendar",
"templateConstruction": "Construction Project",
"templateConstructionDesc": "Phases, permits, contractors",
"templateStartup": "Startup Launch",
"templateStartupDesc": "MVP development, funding, growth",
"tasksStepTitle": "Add your first tasks",
"tasksStepDescription": "Break down \"{{projectName}}\" into actionable tasks to get started",
"taskPlaceholder": "Task {{index}} - e.g., What needs to be done?",
"addAnotherTask": "Add another task ({{current}}/{{max}})",
"surveyStepTitle": "Tell us about yourself",
"surveyStepLabel": "Help us personalize your Worklenz experience by answering a few questions.",
"organizationType": "What best describes your organization?",
"organizationTypeFreelancer": "Freelancer",
"organizationTypeStartup": "Startup",
"organizationTypeSmallMediumBusiness": "Small or Medium Business",
"organizationTypeAgency": "Agency",
"organizationTypeEnterprise": "Enterprise",
"organizationTypeOther": "Other",
"userRole": "What is your role?",
"userRoleFounderCeo": "Founder / CEO",
"userRoleProjectManager": "Project Manager",
"userRoleSoftwareDeveloper": "Software Developer",
"userRoleDesigner": "Designer",
"userRoleOperations": "Operations",
"userRoleOther": "Other",
"mainUseCases": "What will you mainly use Worklenz for?",
"mainUseCasesTaskManagement": "Task management",
"mainUseCasesTeamCollaboration": "Team collaboration",
"mainUseCasesResourcePlanning": "Resource planning",
"mainUseCasesClientCommunication": "Client communication & reporting",
"mainUseCasesTimeTracking": "Time tracking",
"mainUseCasesOther": "Other",
"previousTools": "What tool(s) were you using before Worklenz?",
"previousToolsPlaceholder": "e.g. Trello, Asana, Monday.com",
"howHeardAbout": "How did you hear about Worklenz?",
"howHeardAboutGoogleSearch": "Google Search",
"howHeardAboutTwitter": "Twitter",
"howHeardAboutLinkedin": "LinkedIn",
"howHeardAboutFriendColleague": "A friend or colleague",
"howHeardAboutBlogArticle": "A blog or article",
"howHeardAboutOther": "Other",
"aboutYouStepTitle": "Tell us about yourself",
"aboutYouStepDescription": "Help us personalize your experience",
"yourNeedsStepTitle": "What are your main needs?",
"yourNeedsStepDescription": "Select all that apply to help us set up your workspace",
"selected": "selected",
"previousToolsLabel": "What tools have you used before? (Optional)",
"roleSuggestions": {
"designer": "UI/UX, Graphics, Creative",
"developer": "Frontend, Backend, Full-stack",
"projectManager": "Planning, Coordination",
"marketing": "Content, Social Media, Growth",
"sales": "Business Development, Client Relations",
"operations": "Admin, HR, Finance"
},
"languages": {
"en": "English",
"es": "Español",
"pt": "Português",
"de": "Deutsch",
"alb": "Shqip",
"zh": "简体中文"
},
"orgSuggestions": {
"tech": ["TechCorp", "DevStudio", "CodeCraft", "PixelForge"],
"creative": ["Creative Hub", "Design Studio", "Brand Works", "Visual Arts"],
"consulting": ["Strategy Group", "Business Solutions", "Expert Advisors", "Growth Partners"],
"startup": ["Innovation Labs", "Future Works", "Venture Co", "Next Gen"]
},
"projectSuggestions": {
"freelancer": ["Client Project", "Portfolio Update", "Personal Brand"],
"startup": ["MVP Development", "Product Launch", "Market Research"],
"agency": ["Client Campaign", "Brand Strategy", "Website Redesign"],
"enterprise": ["System Migration", "Process Optimization", "Team Training"]
},
"useCaseDescriptions": {
"taskManagement": "Organize and track tasks",
"teamCollaboration": "Work together seamlessly",
"resourcePlanning": "Manage time and resources",
"clientCommunication": "Stay connected with clients",
"timeTracking": "Monitor project hours",
"other": "Something else"
}
"maxTasks": " (You can create up to 5 tasks)"
}

View File

@@ -1,26 +0,0 @@
{
"billingDetails": "Billing Details",
"name": "Name",
"namePlaceholder": "Name",
"emailAddress": "Email Address",
"emailPlaceholder": "Email Address",
"contactNumber": "Contact Number",
"phoneNumberPlaceholder": "Phone Number",
"phoneValidationError": "Phone number must be exactly 10 digits",
"companyDetails": "Company Details",
"companyName": "Company Name",
"companyNamePlaceholder": "Company Name",
"addressLine01": "Address Line 01",
"addressLine01Placeholder": "Address Line 01",
"addressLine02": "Address Line 02",
"addressLine02Placeholder": "Address Line 02",
"country": "Country",
"countryPlaceholder": "Country",
"city": "City",
"cityPlaceholder": "City",
"state": "State",
"statePlaceholder": "State",
"postalCode": "Postal Code",
"postalCodePlaceholder": "Postal Code",
"save": "Save"
}

View File

@@ -117,26 +117,5 @@
"currentSeatsText": "You currently have {{seats}} seats available.",
"selectSeatsText": "Please select the number of additional seats to purchase.",
"purchase": "Purchase",
"contactSales": "Contact sales",
"submitSuccess": "Code redeemed successfully!",
"submitSuccessDescription": "Your account has been updated with the new credits.",
"percentUsed": "% Used",
"sizeUnits": {
"bytes": "Bytes",
"kb": "KB",
"mb": "MB",
"gb": "GB",
"tb": "TB"
},
"seatPerMonth": "seat / month",
"totalPrice": "Total $",
"tryForFree": "Try for free",
"subscriptionUpdateSuccess": "Subscription updated successfully!",
"paymentProcessorError": "Failed to load payment processor",
"seatsLabel": "Seats:",
"requiredField": "*",
"purchaseSeatsTextSingle": "To continue, you'll need to purchase an additional seat.",
"singleUserNote": "You currently have 1 seat available.",
"selectSeatsTextSingle": "Please select the number of additional seats to purchase.",
"phoneNumberPattern": "07xxxxxxxx"
"contactSales": "Contact sales"
}

View File

@@ -4,8 +4,5 @@
"owner": "Organization Owner",
"admins": "Organization Admins",
"contactNumber": "Add Contact Number",
"edit": "Edit",
"emailAddress": "Email Address",
"enterOrganizationName": "Enter organization name",
"ownerSuffix": " (Owner)"
"edit": "Edit"
}

View File

@@ -5,6 +5,5 @@
"user": "User",
"email": "Email",
"lastActivity": "Last Activity",
"refresh": "Refresh users",
"name": "Name"
"refresh": "Refresh users"
}

View File

@@ -8,11 +8,9 @@
"emailPlaceholder": "Enter your email",
"emailRequired": "Please enter your Email!",
"passwordLabel": "Password",
"passwordGuideline": "Password must be at least 8 characters, include uppercase and lowercase letters, a number, and a special character.",
"passwordPlaceholder": "Enter your password",
"passwordRequired": "Please enter your Password!",
"passwordMinCharacterRequired": "Password must be at least 8 characters!",
"passwordMaxCharacterRequired": "Password must be at most 32 characters!",
"passwordPatternRequired": "Password does not meet the requirements!",
"strongPasswordPlaceholder": "Enter a stronger password",
"passwordValidationAltText": "Password must include at least 8 characters with upper and lower case letters, a number, and a symbol.",

View File

@@ -10,17 +10,6 @@
"deleteConfirmationOk": "Yes",
"deleteConfirmationCancel": "Cancel",
"deleteTaskTitle": "Delete Task",
"deleteTaskContent": "Are you sure you want to delete this task? This action cannot be undone.",
"deleteTaskConfirm": "Delete",
"deleteTaskCancel": "Cancel",
"deleteStatusTitle": "Delete Status",
"deleteStatusContent": "Are you sure you want to delete this status? This action cannot be undone.",
"deletePhaseTitle": "Delete Phase",
"deletePhaseContent": "Are you sure you want to delete this phase? This action cannot be undone.",
"dueDate": "Due date",
"cancel": "Cancel",
@@ -40,14 +29,5 @@
"nextWeek": "Next week",
"noSubtasks": "No subtasks",
"showSubtasks": "Show subtasks",
"hideSubtasks": "Hide subtasks",
"errorLoadingTasks": "Error loading tasks",
"noTasksFound": "No tasks found",
"loadingFilters": "Loading filters...",
"failedToUpdateColumnOrder": "Failed to update column order",
"failedToUpdatePhaseOrder": "Failed to update phase order",
"pleaseTryAgain": "Please try again",
"taskNotCompleted": "Task is not completed",
"completeTaskDependencies": "Please complete the task dependencies before proceeding"
"hideSubtasks": "Hide subtasks"
}

View File

@@ -13,6 +13,5 @@
"deleteButtonTooltip": "Remove from project",
"memberCount": "Member",
"membersCountPlural": "Members",
"emptyText": "There are no attachments in the project.",
"searchPlaceholder": "Search members"
"emptyText": "There are no attachments in the project."
}

View File

@@ -1,11 +1,7 @@
{
"title": "Share Project",
"title": "Project Members",
"searchLabel": "Add members by adding their name or email",
"searchPlaceholder": "Type name or email",
"inviteAsAMember": "Invite as a member",
"inviteNewMemberByEmail": "Invite new member by email",
"members": "Members",
"copyProjectLink": "Copy project link",
"inviteMember": "Invite Member",
"alsoInviteToProject": "Also invite to project"
"inviteNewMemberByEmail": "Invite new member by email"
}

View File

@@ -19,7 +19,7 @@
"cancelText": "No, cancel",
"deactivatedText": "(Currently deactivated)",
"pendingInvitationText": "(Invitation pending)",
"addMemberDrawerTitle": "Invite Team Members",
"addMemberDrawerTitle": "Add New Team Member",
"updateMemberDrawerTitle": "Update Team Member",
"addMemberEmailHint": "Members will be added to the team regardless of invitation acceptance status",
"memberEmailLabel": "Email(s)",
@@ -28,7 +28,7 @@
"jobTitleLabel": "Job Title",
"jobTitlePlaceholder": "Select or search job title (Optional)",
"memberAccessLabel": "Access Level",
"addToTeamButton": "Send Invitation",
"addToTeamButton": "Add Member to Team",
"updateButton": "Save Changes",
"resendInvitationButton": "Resend Invitation Email",
"invitationSentSuccessMessage": "Team invitation sent successfully!",
@@ -43,6 +43,5 @@
"updatedText": "Updated",
"noResultFound": "Type an email address and hit enter...",
"jobTitlesFetchError": "Failed to fetch job titles",
"invitationResent": "Invitation resent successfully!",
"copyTeamLink": "Copy team link"
"invitationResent": "Invitation resent successfully!"
}

View File

@@ -3,28 +3,7 @@
"setupYourAccount": "Configura tu cuenta.",
"organizationStepTitle": "Nombra tu organización",
"organizationStepWelcome": "¡Bienvenido a Worklenz!",
"organizationStepDescription": "Comencemos configurando tu organización. Este será el espacio de trabajo principal para tu equipo.",
"organizationStepLabel": "Nombre de la organización",
"organizationStepPlaceholder": "ej. Corporación Acme",
"organizationStepTooltip": "Este nombre aparecerá en tu espacio de trabajo y se puede cambiar más tarde en la configuración.",
"organizationStepNeedIdeas": "¿Necesitas ideas?",
"organizationStepUseDetected": "Usar detectado:",
"organizationStepCharacters": "caracteres",
"organizationStepGoodLength": "Buena longitud",
"organizationStepTooShort": "Demasiado corto",
"organizationStepNamingTips": "Consejos para nombrar",
"organizationStepTip1": "Manténlo simple y memorable",
"organizationStepTip2": "Refleja tu industria o valores",
"organizationStepTip3": "Piensa en el crecimiento futuro",
"organizationStepTip4": "Hazlo único y reconocible",
"organizationStepSuggestionsTitle": "Sugerencias de nombres",
"organizationStepCategory1": "Empresas tecnológicas",
"organizationStepCategory2": "Agencias creativas",
"organizationStepCategory3": "Consultoría",
"organizationStepCategory4": "Startups",
"organizationStepSuggestionsNote": "Estos son solo ejemplos para empezar. Elige algo que represente a tu organización.",
"organizationStepPrivacyNote": "El nombre de tu organización es privado y solo visible para los miembros de tu equipo.",
"organizationStepLabel": "Elige un nombre para tu cuenta de Worklenz.",
"projectStepTitle": "Crea tu primer proyecto",
"projectStepLabel": "¿En qué proyecto estás trabajando ahora?",
@@ -49,164 +28,5 @@
"step3Title": "Invita a tu equipo a trabajar",
"maxMembers": " (Puedes invitar hasta 5 miembros)",
"maxTasks": " (Puedes crear hasta 5 tareas)",
"membersStepTitle": "Invita a tu equipo",
"membersStepDescription": "Añade miembros del equipo a \"{{organizationName}}\" y comienza a colaborar",
"memberPlaceholder": "Miembro del equipo {{index}} - Ingresa dirección de correo",
"validEmailAddress": "Dirección de correo válida",
"addAnotherTeamMember": "Añadir otro miembro del equipo ({{current}}/{{max}})",
"canInviteLater": "Siempre puedes invitar miembros del equipo más tarde",
"skipStepDescription": "¿No tienes direcciones de correo listas? ¡No hay problema! Puedes omitir este paso e invitar miembros del equipo desde tu panel de proyecto más tarde.",
"orgCategoryTech": "Empresas Tecnológicas",
"orgCategoryCreative": "Agencias Creativas",
"orgCategoryConsulting": "Consultoría",
"orgCategoryStartups": "Startups",
"namingTip1": "Manténlo simple y memorable",
"namingTip2": "Refleja tu industria o valores",
"namingTip3": "Piensa en el crecimiento futuro",
"namingTip4": "Hazlo único y reconocible",
"aboutYouTitle": "Cuéntanos sobre ti",
"aboutYouDescription": "Ayúdanos a personalizar tu experiencia",
"orgTypeQuestion": "¿Qué describe mejor tu organización?",
"userRoleQuestion": "¿Cuál es tu rol?",
"yourNeedsTitle": "¿Cuáles son tus principales necesidades?",
"yourNeedsDescription": "Selecciona todas las que apliquen para ayudarnos a configurar tu espacio de trabajo",
"yourNeedsQuestion": "¿Cómo usarás principalmente Worklenz?",
"useCaseTaskOrg": "Organizar y hacer seguimiento de tareas",
"useCaseTeamCollab": "Trabajar juntos sin problemas",
"useCaseResourceMgmt": "Gestionar tiempo y recursos",
"useCaseClientComm": "Mantenerse conectado con clientes",
"useCaseTimeTrack": "Monitorear horas de proyecto",
"useCaseOther": "Algo más",
"selectedText": "seleccionado",
"previousToolsQuestion": "¿Qué herramientas has usado antes? (Opcional)",
"previousToolsPlaceholder": "ej., Asana, Trello, Jira, Monday.com, etc.",
"discoveryTitle": "Una última cosa...",
"discoveryDescription": "Ayúdanos a entender cómo descubriste Worklenz",
"discoveryQuestion": "¿Cómo te enteraste de nosotros?",
"allSetTitle": "¡Ya estás listo!",
"allSetDescription": "Vamos a crear tu primer proyecto y comenzar con Worklenz",
"aboutYouStepName": "Sobre ti",
"yourNeedsStepName": "Tus necesidades",
"discoveryStepName": "Descubrimiento",
"stepProgress": "Paso {step} de 3: {title}",
"projectStepHeader": "Vamos a crear tu primer proyecto",
"projectStepSubheader": "Empieza desde cero o usa una plantilla para ir más rápido",
"startFromScratch": "Empezar desde cero",
"templateSelected": "Plantilla seleccionada abajo",
"quickSuggestions": "Sugerencias rápidas:",
"orText": "O",
"startWithTemplate": "Comenzar con una plantilla",
"clearToSelectTemplate": "Borra el nombre del proyecto arriba para seleccionar una plantilla",
"templateHeadStart": "Obtén una ventaja inicial con estructuras de proyecto pre-construidas",
"browseAllTemplates": "Explorar todas las plantillas",
"templatesAvailable": "15+ plantillas específicas de industria disponibles",
"chooseTemplate": "Elige una plantilla que coincida con tu tipo de proyecto",
"createProject": "Crear proyecto",
"templateSoftwareDev": "Desarrollo de Software",
"templateSoftwareDesc": "Sprints ágiles, seguimiento de errores, lanzamientos",
"templateMarketing": "Campaña de Marketing",
"templateMarketingDesc": "Planificación de campaña, calendario de contenido",
"templateConstruction": "Proyecto de Construcción",
"templateConstructionDesc": "Fases, permisos, contratistas",
"templateStartup": "Lanzamiento de Startup",
"templateStartupDesc": "Desarrollo MVP, financiación, crecimiento",
"tasksStepTitle": "Añade tus primeras tareas",
"tasksStepDescription": "Desglosa \"{{projectName}}\" en tareas accionables para comenzar",
"taskPlaceholder": "Tarea {{index}} - ej., ¿Qué necesita hacerse?",
"addAnotherTask": "Añadir otra tarea ({{current}}/{{max}})",
"surveyStepTitle": "Cuéntanos sobre ti",
"surveyStepLabel": "Ayúdanos a personalizar tu experiencia de Worklenz respondiendo algunas preguntas.",
"organizationType": "¿Qué describe mejor tu organización?",
"organizationTypeFreelancer": "Freelancer",
"organizationTypeStartup": "Startup",
"organizationTypeSmallMediumBusiness": "Pequeña o Mediana Empresa",
"organizationTypeAgency": "Agencia",
"organizationTypeEnterprise": "Empresa",
"organizationTypeOther": "Otro",
"userRole": "¿Cuál es tu rol?",
"userRoleFounderCeo": "Fundador / CEO",
"userRoleProjectManager": "Gerente de Proyecto",
"userRoleSoftwareDeveloper": "Desarrollador de Software",
"userRoleDesigner": "Diseñador",
"userRoleOperations": "Operaciones",
"userRoleOther": "Otro",
"mainUseCases": "¿Para qué usarás principalmente Worklenz?",
"mainUseCasesTaskManagement": "Gestión de tareas",
"mainUseCasesTeamCollaboration": "Colaboración de equipo",
"mainUseCasesResourcePlanning": "Planificación de recursos",
"mainUseCasesClientCommunication": "Comunicación con clientes e informes",
"mainUseCasesTimeTracking": "Seguimiento de tiempo",
"mainUseCasesOther": "Otro",
"previousTools": "¿Qué herramienta(s) usabas antes de Worklenz?",
"previousToolsPlaceholder": "ej. Trello, Asana, Monday.com",
"howHeardAbout": "¿Cómo conociste Worklenz?",
"howHeardAboutGoogleSearch": "Búsqueda de Google",
"howHeardAboutTwitter": "Twitter",
"howHeardAboutLinkedin": "LinkedIn",
"howHeardAboutFriendColleague": "Un amigo o colega",
"howHeardAboutBlogArticle": "Un blog o artículo",
"howHeardAboutOther": "Otro",
"aboutYouStepTitle": "Cuéntanos sobre ti",
"aboutYouStepDescription": "Ayúdanos a personalizar tu experiencia",
"yourNeedsStepTitle": "¿Cuáles son tus principales necesidades?",
"yourNeedsStepDescription": "Selecciona todas las que apliquen para ayudarnos a configurar tu espacio de trabajo",
"selected": "seleccionado",
"previousToolsLabel": "¿Qué herramientas has usado antes? (Opcional)",
"roleSuggestions": {
"designer": "UI/UX, Gráficos, Creativo",
"developer": "Frontend, Backend, Full-stack",
"projectManager": "Planificación, Coordinación",
"marketing": "Contenido, Redes Sociales, Crecimiento",
"sales": "Desarrollo de Negocios, Relaciones con Clientes",
"operations": "Administración, RRHH, Finanzas"
},
"languages": {
"en": "English",
"es": "Español",
"pt": "Português",
"de": "Deutsch",
"alb": "Shqip",
"zh": "简体中文"
},
"orgSuggestions": {
"tech": ["TechCorp", "DevStudio", "CodeCraft", "PixelForge"],
"creative": ["Creative Hub", "Design Studio", "Brand Works", "Visual Arts"],
"consulting": ["Strategy Group", "Business Solutions", "Expert Advisors", "Growth Partners"],
"startup": ["Innovation Labs", "Future Works", "Venture Co", "Next Gen"]
},
"projectSuggestions": {
"freelancer": ["Proyecto Cliente", "Actualización Portfolio", "Marca Personal"],
"startup": ["Desarrollo MVP", "Lanzamiento Producto", "Investigación Mercado"],
"agency": ["Campaña Cliente", "Estrategia Marca", "Rediseño Website"],
"enterprise": ["Migración Sistema", "Optimización Procesos", "Capacitación Equipo"]
},
"useCaseDescriptions": {
"taskManagement": "Organizar y rastrear tareas",
"teamCollaboration": "Trabajar juntos sin problemas",
"resourcePlanning": "Gestionar tiempo y recursos",
"clientCommunication": "Mantenerse conectado con clientes",
"timeTracking": "Monitorear horas de proyecto",
"other": "Algo más"
}
"maxTasks": " (Puedes crear hasta 5 tareas)"
}

View File

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

View File

@@ -10,17 +10,6 @@
"deleteConfirmationOk": "Sí",
"deleteConfirmationCancel": "Cancelar",
"deleteTaskTitle": "Eliminar tarea",
"deleteTaskContent": "¿Estás seguro de que deseas eliminar esta tarea? Esta acción no se puede deshacer.",
"deleteTaskConfirm": "Eliminar",
"deleteTaskCancel": "Cancelar",
"deleteStatusTitle": "Eliminar estado",
"deleteStatusContent": "¿Estás seguro de que deseas eliminar este estado? Esta acción no se puede deshacer.",
"deletePhaseTitle": "Eliminar fase",
"deletePhaseContent": "¿Estás seguro de que deseas eliminar esta fase? Esta acción no se puede deshacer.",
"dueDate": "Fecha de vencimiento",
"cancel": "Cancelar",
@@ -37,17 +26,5 @@
"noDueDate": "Sin fecha de vencimiento",
"save": "Guardar",
"clear": "Limpiar",
"nextWeek": "Próxima semana",
"noSubtasks": "Sin subtareas",
"showSubtasks": "Mostrar subtareas",
"hideSubtasks": "Ocultar subtareas",
"errorLoadingTasks": "Error al cargar tareas",
"noTasksFound": "No se encontraron tareas",
"loadingFilters": "Cargando filtros...",
"failedToUpdateColumnOrder": "Error al actualizar el orden de las columnas",
"failedToUpdatePhaseOrder": "Error al actualizar el orden de las fases",
"pleaseTryAgain": "Por favor, inténtalo de nuevo",
"taskNotCompleted": "La tarea no está completada",
"completeTaskDependencies": "Por favor, completa las dependencias de la tarea antes de continuar"
"nextWeek": "Próxima semana"
}

View File

@@ -13,6 +13,5 @@
"deleteButtonTooltip": "Eliminar del proyecto",
"memberCount": "Miembro",
"membersCountPlural": "Miembros",
"emptyText": "No hay archivos adjuntos en el proyecto.",
"searchPlaceholder": "Buscar miembros"
"emptyText": "No hay archivos adjuntos en el proyecto."
}

View File

@@ -3,9 +3,5 @@
"searchLabel": "Agregar miembros ingresando su nombre o correo electrónico",
"searchPlaceholder": "Escriba nombre o correo electrónico",
"inviteAsAMember": "Invitar como miembro",
"inviteNewMemberByEmail": "Invitar nuevo miembro por correo electrónico",
"members": "Miembros",
"copyProjectLink": "Copiar enlace del proyecto",
"inviteMember": "Invitar miembro",
"alsoInviteToProject": "También invitar al proyecto"
"inviteNewMemberByEmail": "Invitar nuevo miembro por correo electrónico"
}

View File

@@ -28,7 +28,7 @@
"jobTitleLabel": "Cargo",
"jobTitlePlaceholder": "Seleccione o busque cargo (Opcional)",
"memberAccessLabel": "Nivel de acceso",
"addToTeamButton": "Enviar invitación",
"addToTeamButton": "Agregar miembro al equipo",
"updateButton": "Guardar cambios",
"resendInvitationButton": "Reenviar correo de invitación",
"invitationSentSuccessMessage": "¡Invitación al equipo enviada exitosamente!",
@@ -43,6 +43,5 @@
"updatedText": "Actualizado",
"noResultFound": "Escriba una dirección de correo electrónico y presione enter...",
"jobTitlesFetchError": "Error al obtener los cargos",
"invitationResent": "¡Invitación reenviada exitosamente!",
"copyTeamLink": "Copiar enlace del equipo"
"invitationResent": "¡Invitación reenviada exitosamente!"
}

View File

@@ -3,28 +3,7 @@
"setupYourAccount": "Configure sua conta.",
"organizationStepTitle": "Nomeie sua organização",
"organizationStepWelcome": "Bem-vindo ao Worklenz!",
"organizationStepDescription": "Vamos começar configurando sua organização. Este será o espaço de trabalho principal para sua equipe.",
"organizationStepLabel": "Nome da organização",
"organizationStepPlaceholder": "ex. Corporação Acme",
"organizationStepTooltip": "Este nome aparecerá em seu espaço de trabalho e pode ser alterado posteriormente nas configurações.",
"organizationStepNeedIdeas": "Precisa de ideias?",
"organizationStepUseDetected": "Usar detectado:",
"organizationStepCharacters": "caracteres",
"organizationStepGoodLength": "Bom comprimento",
"organizationStepTooShort": "Muito curto",
"organizationStepNamingTips": "Dicas de nomenclatura",
"organizationStepTip1": "Mantenha simples e memorável",
"organizationStepTip2": "Reflita sua indústria ou valores",
"organizationStepTip3": "Pense no crescimento futuro",
"organizationStepTip4": "Torne único e marcante",
"organizationStepSuggestionsTitle": "Sugestões de nomes",
"organizationStepCategory1": "Empresas de tecnologia",
"organizationStepCategory2": "Agências criativas",
"organizationStepCategory3": "Consultoria",
"organizationStepCategory4": "Startups",
"organizationStepSuggestionsNote": "Estes são apenas exemplos para começar. Escolha algo que represente sua organização.",
"organizationStepPrivacyNote": "O nome da sua organização é privado e visível apenas para os membros da sua equipe.",
"organizationStepLabel": "Escolha um nome para sua conta Worklenz.",
"projectStepTitle": "Crie seu primeiro projeto",
"projectStepLabel": "Em qual projeto você está trabalhando agora?",
@@ -49,164 +28,5 @@
"step3Title": "Convide sua equipe para trabalhar",
"maxMembers": " (Você pode convidar até 5 membros)",
"maxTasks": " (Você pode criar até 5 tarefas)",
"membersStepTitle": "Convide sua equipe",
"membersStepDescription": "Adicione membros da equipe ao \"{{organizationName}}\" e comece a colaborar",
"memberPlaceholder": "Membro da equipe {{index}} - Digite o endereço de email",
"validEmailAddress": "Endereço de email válido",
"addAnotherTeamMember": "Adicionar outro membro da equipe ({{current}}/{{max}})",
"canInviteLater": "Você sempre pode convidar membros da equipe mais tarde",
"skipStepDescription": "Não tem endereços de email prontos? Sem problema! Você pode pular esta etapa e convidar membros da equipe do seu painel de projeto mais tarde.",
"orgCategoryTech": "Empresas de Tecnologia",
"orgCategoryCreative": "Agências Criativas",
"orgCategoryConsulting": "Consultoria",
"orgCategoryStartups": "Startups",
"namingTip1": "Mantenha simples e memorável",
"namingTip2": "Reflita sua indústria ou valores",
"namingTip3": "Pense no crescimento futuro",
"namingTip4": "Torne único e marcante",
"aboutYouTitle": "Conte-nos sobre você",
"aboutYouDescription": "Ajude-nos a personalizar sua experiência",
"orgTypeQuestion": "O que melhor descreve sua organização?",
"userRoleQuestion": "Qual é seu papel?",
"yourNeedsTitle": "Quais são suas principais necessidades?",
"yourNeedsDescription": "Selecione todas que se aplicam para nos ajudar a configurar seu espaço de trabalho",
"yourNeedsQuestion": "Como você usará principalmente o Worklenz?",
"useCaseTaskOrg": "Organizar e acompanhar tarefas",
"useCaseTeamCollab": "Trabalhar juntos perfeitamente",
"useCaseResourceMgmt": "Gerenciar tempo e recursos",
"useCaseClientComm": "Manter-se conectado com clientes",
"useCaseTimeTrack": "Monitorar horas do projeto",
"useCaseOther": "Algo mais",
"selectedText": "selecionado",
"previousToolsQuestion": "Que ferramentas você usou antes? (Opcional)",
"previousToolsPlaceholder": "ex., Asana, Trello, Jira, Monday.com, etc.",
"discoveryTitle": "Uma última coisa...",
"discoveryDescription": "Ajude-nos a entender como você descobriu o Worklenz",
"discoveryQuestion": "Como você soube sobre nós?",
"allSetTitle": "Você está pronto!",
"allSetDescription": "Vamos criar seu primeiro projeto e começar com o Worklenz",
"aboutYouStepName": "Sobre você",
"yourNeedsStepName": "Suas necessidades",
"discoveryStepName": "Descoberta",
"stepProgress": "Passo {step} de 3: {title}",
"projectStepHeader": "Vamos criar seu primeiro projeto",
"projectStepSubheader": "Comece do zero ou use um modelo para ir mais rápido",
"startFromScratch": "Começar do zero",
"templateSelected": "Modelo selecionado abaixo",
"quickSuggestions": "Sugestões rápidas:",
"orText": "OU",
"startWithTemplate": "Começar com um modelo",
"clearToSelectTemplate": "Limpe o nome do projeto acima para selecionar um modelo",
"templateHeadStart": "Obtenha uma vantagem inicial com estruturas de projeto pré-construídas",
"browseAllTemplates": "Navegar por todos os modelos",
"templatesAvailable": "15+ modelos específicos da indústria disponíveis",
"chooseTemplate": "Escolha um modelo que corresponda ao seu tipo de projeto",
"createProject": "Criar projeto",
"templateSoftwareDev": "Desenvolvimento de Software",
"templateSoftwareDesc": "Sprints ágeis, rastreamento de bugs, lançamentos",
"templateMarketing": "Campanha de Marketing",
"templateMarketingDesc": "Planejamento de campanha, calendário de conteúdo",
"templateConstruction": "Projeto de Construção",
"templateConstructionDesc": "Fases, licenças, empreiteiros",
"templateStartup": "Lançamento de Startup",
"templateStartupDesc": "Desenvolvimento MVP, financiamento, crescimento",
"tasksStepTitle": "Adicione suas primeiras tarefas",
"tasksStepDescription": "Divida \"{{projectName}}\" em tarefas acionáveis para começar",
"taskPlaceholder": "Tarefa {{index}} - ex., O que precisa ser feito?",
"addAnotherTask": "Adicionar outra tarefa ({{current}}/{{max}})",
"surveyStepTitle": "Conte-nos sobre você",
"surveyStepLabel": "Ajude-nos a personalizar sua experiência no Worklenz respondendo algumas perguntas.",
"organizationType": "O que melhor descreve sua organização?",
"organizationTypeFreelancer": "Freelancer",
"organizationTypeStartup": "Startup",
"organizationTypeSmallMediumBusiness": "Pequena ou Média Empresa",
"organizationTypeAgency": "Agência",
"organizationTypeEnterprise": "Empresa",
"organizationTypeOther": "Outro",
"userRole": "Qual é o seu papel?",
"userRoleFounderCeo": "Fundador / CEO",
"userRoleProjectManager": "Gerente de Projeto",
"userRoleSoftwareDeveloper": "Desenvolvedor de Software",
"userRoleDesigner": "Designer",
"userRoleOperations": "Operações",
"userRoleOther": "Outro",
"mainUseCases": "Para que você usará principalmente o Worklenz?",
"mainUseCasesTaskManagement": "Gerenciamento de tarefas",
"mainUseCasesTeamCollaboration": "Colaboração em equipe",
"mainUseCasesResourcePlanning": "Planejamento de recursos",
"mainUseCasesClientCommunication": "Comunicação com clientes e relatórios",
"mainUseCasesTimeTracking": "Controle de tempo",
"mainUseCasesOther": "Outro",
"previousTools": "Que ferramenta(s) você usava antes do Worklenz?",
"previousToolsPlaceholder": "ex. Trello, Asana, Monday.com",
"howHeardAbout": "Como você soube do Worklenz?",
"howHeardAboutGoogleSearch": "Busca no Google",
"howHeardAboutTwitter": "Twitter",
"howHeardAboutLinkedin": "LinkedIn",
"howHeardAboutFriendColleague": "Um amigo ou colega",
"howHeardAboutBlogArticle": "Um blog ou artigo",
"howHeardAboutOther": "Outro",
"aboutYouStepTitle": "Conte-nos sobre você",
"aboutYouStepDescription": "Ajude-nos a personalizar sua experiência",
"yourNeedsStepTitle": "Quais são suas principais necessidades?",
"yourNeedsStepDescription": "Selecione todas que se aplicam para nos ajudar a configurar seu espaço de trabalho",
"selected": "selecionado",
"previousToolsLabel": "Que ferramentas você usou antes? (Opcional)",
"roleSuggestions": {
"designer": "UI/UX, Gráficos, Criativo",
"developer": "Frontend, Backend, Full-stack",
"projectManager": "Planejamento, Coordenação",
"marketing": "Conteúdo, Mídias Sociais, Crescimento",
"sales": "Desenvolvimento de Negócios, Relacionamento com Clientes",
"operations": "Administração, RH, Finanças"
},
"languages": {
"en": "English",
"es": "Español",
"pt": "Português",
"de": "Deutsch",
"alb": "Shqip",
"zh": "简体中文"
},
"orgSuggestions": {
"tech": ["TechCorp", "DevStudio", "CodeCraft", "PixelForge"],
"creative": ["Creative Hub", "Design Studio", "Brand Works", "Visual Arts"],
"consulting": ["Strategy Group", "Business Solutions", "Expert Advisors", "Growth Partners"],
"startup": ["Innovation Labs", "Future Works", "Venture Co", "Next Gen"]
},
"projectSuggestions": {
"freelancer": ["Projeto Cliente", "Atualização Portfolio", "Marca Pessoal"],
"startup": ["Desenvolvimento MVP", "Lançamento Produto", "Pesquisa Mercado"],
"agency": ["Campanha Cliente", "Estratégia Marca", "Redesign Website"],
"enterprise": ["Migração Sistema", "Otimização Processos", "Treinamento Equipe"]
},
"useCaseDescriptions": {
"taskManagement": "Organizar e rastrear tarefas",
"teamCollaboration": "Trabalhar juntos perfeitamente",
"resourcePlanning": "Gerenciar tempo e recursos",
"clientCommunication": "Manter-se conectado com clientes",
"timeTracking": "Monitorar horas do projeto",
"other": "Algo mais"
}
"maxTasks": " (Você pode criar até 5 tarefas)"
}

View File

@@ -7,13 +7,11 @@
"emailLabel": "Email",
"emailPlaceholder": "Insira seu email",
"emailRequired": "Por favor, insira seu Email!",
"passwordLabel": "Password",
"passwordGuideline": "Password must be at least 8 characters, include uppercase and lowercase letters, a number, and a special character.",
"passwordPlaceholder": "Enter your password",
"passwordLabel": "Senha",
"passwordPlaceholder": "Insira sua senha",
"passwordRequired": "Por favor, insira sua Senha!",
"passwordMinCharacterRequired": "Senha deve ter pelo menos 8 caracteres!",
"passwordMaxCharacterRequired": "Password must be at most 32 characters!",
"passwordPatternRequired": "A senha não atende aos requisitos!",
"passwordPatternRequired": "Senha não atende aos requisitos!",
"strongPasswordPlaceholder": "Insira uma senha mais forte",
"passwordValidationAltText": "Senha deve incluir pelo menos 8 caracteres com letras maiúsculas e minúsculas, um número e um símbolo.",
"signupSuccessMessage": "Você se inscreveu com sucesso!",

View File

@@ -10,17 +10,6 @@
"deleteConfirmationOk": "Sim",
"deleteConfirmationCancel": "Cancelar",
"deleteTaskTitle": "Excluir Tarefa",
"deleteTaskContent": "Tem certeza de que deseja excluir esta tarefa? Esta ação não pode ser desfeita.",
"deleteTaskConfirm": "Excluir",
"deleteTaskCancel": "Cancelar",
"deleteStatusTitle": "Excluir Status",
"deleteStatusContent": "Tem certeza de que deseja excluir este status? Esta ação não pode ser desfeita.",
"deletePhaseTitle": "Excluir Fase",
"deletePhaseContent": "Tem certeza de que deseja excluir esta fase? Esta ação não pode ser desfeita.",
"dueDate": "Data de vencimento",
"cancel": "Cancelar",
@@ -37,17 +26,5 @@
"noDueDate": "Sem data de vencimento",
"save": "Salvar",
"clear": "Limpar",
"nextWeek": "Próxima semana",
"noSubtasks": "Sem subtarefas",
"showSubtasks": "Mostrar subtarefas",
"hideSubtasks": "Ocultar subtarefas",
"errorLoadingTasks": "Erro ao carregar tarefas",
"noTasksFound": "Nenhuma tarefa encontrada",
"loadingFilters": "Carregando filtros...",
"failedToUpdateColumnOrder": "Falha ao atualizar a ordem das colunas",
"failedToUpdatePhaseOrder": "Falha ao atualizar a ordem das fases",
"pleaseTryAgain": "Por favor, tente novamente",
"taskNotCompleted": "Tarefa não está concluída",
"completeTaskDependencies": "Por favor, complete as dependências da tarefa antes de prosseguir"
"nextWeek": "Próxima semana"
}

View File

@@ -13,6 +13,5 @@
"deleteButtonTooltip": "Remover do projeto",
"memberCount": "Membro",
"membersCountPlural": "Membros",
"emptyText": "Não há anexos no projeto.",
"searchPlaceholder": "Pesquisar membros"
"emptyText": "Não há anexos no projeto."
}

View File

@@ -3,9 +3,5 @@
"searchLabel": "Adicionar membros inserindo nome ou e-mail",
"searchPlaceholder": "Digite nome ou e-mail",
"inviteAsAMember": "Convidar como membro",
"inviteNewMemberByEmail": "Convidar novo membro por e-mail",
"members": "Membros",
"copyProjectLink": "Copiar link do projeto",
"inviteMember": "Convidar membro",
"alsoInviteToProject": "Convidar também para o projeto"
"inviteNewMemberByEmail": "Convidar novo membro por e-mail"
}

View File

@@ -28,7 +28,7 @@
"jobTitleLabel": "Título do Emprego",
"jobTitlePlaceholder": "Selecione ou pesquise o título do emprego (Opcional)",
"memberAccessLabel": "Nível de Acesso",
"addToTeamButton": "Enviar convite",
"addToTeamButton": "Adicionar Membro à Equipe",
"updateButton": "Salvar Alterações",
"resendInvitationButton": "Redirecionar Email de Convite",
"invitationSentSuccessMessage": "Convite para a equipe enviado com sucesso!",
@@ -43,6 +43,5 @@
"updatedText": "Atualizado",
"noResultFound": "Digite um endereço de email e pressione enter...",
"jobTitlesFetchError": "Falha ao buscar cargos",
"invitationResent": "Convite reenviado com sucesso!",
"copyTeamLink": "Copiar link da equipe"
"invitationResent": "Convite reenviado com sucesso!"
}

View File

@@ -1,38 +1,14 @@
{
"continue": "继续",
"setupYourAccount": "设置您的 Worklenz 账户。",
"setupYourAccount": "设置您的Worklenz账户。",
"organizationStepTitle": "命名您的组织",
"organizationStepWelcome": "欢迎使用 Worklenz",
"organizationStepDescription": "让我们从设置您的组织开始。这将是您团队的主要工作空间。",
"organizationStepLabel": "组织名称",
"organizationStepPlaceholder": "例如Acme 公司",
"organizationStepTooltip": "此名称将显示在您的工作区,并可在设置中更改。",
"organizationStepNeedIdeas": "需要灵感?",
"organizationStepUseDetected": "检测到使用:",
"organizationStepCharacters": "字符",
"organizationStepGoodLength": "长度合适",
"organizationStepTooShort": "太短",
"organizationStepNamingTips": "命名建议",
"organizationStepTip1": "保持简单且易记",
"organizationStepTip2": "体现您的行业或价值观",
"organizationStepTip3": "考虑未来发展",
"organizationStepTip4": "使其独特且有品牌感",
"organizationStepSuggestionsTitle": "名称建议",
"organizationStepCategory1": "科技公司",
"organizationStepCategory2": "创意机构",
"organizationStepCategory3": "咨询公司",
"organizationStepCategory4": "初创企业",
"organizationStepSuggestionsNote": "这些只是帮助您入门的示例。请选择能代表您组织的名称。",
"organizationStepPrivacyNote": "您的组织名称是私有的,仅团队成员可见。",
"organizationStepLabel": "为您的Worklenz账户选择一个名称。",
"projectStepTitle": "创建您的第一个项目",
"projectStepLabel": "您现在正在做什么项目?",
"projectStepPlaceholder": "例如:营销计划",
"tasksStepTitle": "创建您的第一个任务",
"tasksStepLabel": "输入您将在其中完成的几个任务",
"tasksStepAddAnother": "添加另一个",
"emailPlaceholder": "电子邮件地址",
"invalidEmail": "请输入有效的电子邮件地址",
"or": "或",
@@ -46,165 +22,6 @@
"skipForNow": "暂时跳过",
"formTitle": "创建您的第一个任务。",
"step3Title": "邀请您的团队一起工作",
"maxMembers": "(您最多可以邀请 5 名成员)",
"maxTasks": "(您最多可以创建 5 个任务)",
"membersStepTitle": "邀请您的团队",
"membersStepDescription": "将团队成员添加到 \"{{organizationName}}\" 并开始协作",
"memberPlaceholder": "团队成员 {{index}} - 输入电子邮件地址",
"validEmailAddress": "有效的电子邮件地址",
"addAnotherTeamMember": "添加另一个团队成员 ({{current}}/{{max}})",
"canInviteLater": "您可以稍后邀请团队成员",
"skipStepDescription": "没有准备好电子邮件地址?没关系!您可以跳过此步骤,稍后从项目面板邀请团队成员。",
"orgCategoryTech": "科技公司",
"orgCategoryCreative": "创意机构",
"orgCategoryConsulting": "咨询公司",
"orgCategoryStartups": "初创企业",
"namingTip1": "保持简单且易记",
"namingTip2": "体现您的行业或价值观",
"namingTip3": "考虑未来发展",
"namingTip4": "使其独特且有品牌感",
"aboutYouTitle": "告诉我们关于您的信息",
"aboutYouDescription": "帮助我们个性化您的体验",
"orgTypeQuestion": "哪项最能描述您的组织?",
"userRoleQuestion": "您的角色是什么?",
"yourNeedsTitle": "您的主要需求是什么?",
"yourNeedsDescription": "请选择所有适用项,帮助我们设置您的工作区",
"yourNeedsQuestion": "您主要如何使用 Worklenz",
"useCaseTaskOrg": "组织和跟踪任务",
"useCaseTeamCollab": "团队协作",
"useCaseResourceMgmt": "管理时间和资源",
"useCaseClientComm": "与客户保持联系",
"useCaseTimeTrack": "监控项目工时",
"useCaseOther": "其他",
"selectedText": "已选择",
"previousToolsQuestion": "您之前用过哪些工具?(可选)",
"previousToolsPlaceholder": "例如Asana、Trello、Jira、Monday.com 等",
"discoveryTitle": "最后一个问题……",
"discoveryDescription": "帮助我们了解您是如何发现 Worklenz 的",
"discoveryQuestion": "您是如何听说我们的?",
"allSetTitle": "一切就绪!",
"allSetDescription": "让我们创建您的第一个项目并开始使用 Worklenz 吧",
"aboutYouStepName": "关于您",
"yourNeedsStepName": "您的需求",
"discoveryStepName": "发现",
"stepProgress": "第 {step} 步,共 3 步:{title}",
"projectStepHeader": "让我们创建您的第一个项目",
"projectStepSubheader": "从头开始或使用模板更快上手",
"startFromScratch": "从头开始",
"templateSelected": "已选择模板如下",
"quickSuggestions": "快速建议:",
"orText": "或",
"startWithTemplate": "从模板开始",
"clearToSelectTemplate": "请先清空上方项目名称以选择模板",
"templateHeadStart": "使用预设项目结构快速开始",
"browseAllTemplates": "浏览所有模板",
"templatesAvailable": "15+ 行业专用模板可用",
"chooseTemplate": "选择与您的项目类型匹配的模板",
"createProject": "创建项目",
"templateSoftwareDev": "软件开发",
"templateSoftwareDesc": "敏捷冲刺、缺陷跟踪、版本发布",
"templateMarketing": "市场营销活动",
"templateMarketingDesc": "活动策划、内容日历",
"templateConstruction": "建设项目",
"templateConstructionDesc": "阶段、许可、承包商",
"templateStartup": "初创启动",
"templateStartupDesc": "MVP 开发、融资、增长",
"tasksStepTitle": "添加您的第一个任务",
"tasksStepDescription": "将 \"{{projectName}}\" 拆分为可执行任务以开始",
"taskPlaceholder": "任务 {{index}} - 例如:需要做什么?",
"addAnotherTask": "添加另一个任务 ({{current}}/{{max}})",
"surveyStepTitle": "告诉我们关于您的信息",
"surveyStepLabel": "通过回答几个问题帮助我们个性化您的 Worklenz 体验。",
"organizationType": "哪项最能描述您的组织?",
"organizationTypeFreelancer": "自由职业者",
"organizationTypeStartup": "初创公司",
"organizationTypeSmallMediumBusiness": "中小企业",
"organizationTypeAgency": "代理机构",
"organizationTypeEnterprise": "企业",
"organizationTypeOther": "其他",
"userRole": "您的角色是什么?",
"userRoleFounderCeo": "创始人 / CEO",
"userRoleProjectManager": "项目经理",
"userRoleSoftwareDeveloper": "软件开发者",
"userRoleDesigner": "设计师",
"userRoleOperations": "运营",
"userRoleOther": "其他",
"mainUseCases": "您主要将 Worklenz 用于什么?",
"mainUseCasesTaskManagement": "任务管理",
"mainUseCasesTeamCollaboration": "团队协作",
"mainUseCasesResourcePlanning": "资源规划",
"mainUseCasesClientCommunication": "客户沟通与报告",
"mainUseCasesTimeTracking": "时间跟踪",
"mainUseCasesOther": "其他",
"previousTools": "在使用 Worklenz 之前您用过哪些工具?",
"previousToolsPlaceholder": "例如Trello、Asana、Monday.com",
"howHeardAbout": "您是如何了解 Worklenz 的?",
"howHeardAboutGoogleSearch": "Google 搜索",
"howHeardAboutTwitter": "Twitter",
"howHeardAboutLinkedin": "LinkedIn",
"howHeardAboutFriendColleague": "朋友或同事",
"howHeardAboutBlogArticle": "博客或文章",
"howHeardAboutOther": "其他",
"aboutYouStepTitle": "告诉我们关于您的信息",
"aboutYouStepDescription": "帮助我们个性化您的体验",
"yourNeedsStepTitle": "您的主要需求是什么?",
"yourNeedsStepDescription": "选择所有适用的选项,帮助我们设置您的工作空间",
"selected": "已选择",
"previousToolsLabel": "您之前使用过哪些工具?(可选)",
"roleSuggestions": {
"designer": "UI/UX、图形、创意",
"developer": "前端、后端、全栈",
"projectManager": "规划、协调",
"marketing": "内容、社交媒体、增长",
"sales": "业务发展、客户关系",
"operations": "行政、人力资源、财务"
},
"languages": {
"en": "English",
"es": "Español",
"pt": "Português",
"de": "Deutsch",
"alb": "Shqip",
"zh": "简体中文"
},
"orgSuggestions": {
"tech": ["TechCorp", "DevStudio", "CodeCraft", "PixelForge"],
"creative": ["Creative Hub", "Design Studio", "Brand Works", "Visual Arts"],
"consulting": ["Strategy Group", "Business Solutions", "Expert Advisors", "Growth Partners"],
"startup": ["Innovation Labs", "Future Works", "Venture Co", "Next Gen"]
},
"projectSuggestions": {
"freelancer": ["客户项目", "作品集更新", "个人品牌"],
"startup": ["MVP开发", "产品发布", "市场调研"],
"agency": ["客户活动", "品牌策略", "网站重设计"],
"enterprise": ["系统迁移", "流程优化", "团队培训"]
},
"useCaseDescriptions": {
"taskManagement": "组织和跟踪任务",
"teamCollaboration": "无缝协作",
"resourcePlanning": "管理时间和资源",
"clientCommunication": "与客户保持联系",
"timeTracking": "监控项目时间",
"other": "其他"
}
}
"maxMembers": "(您最多可以邀请5名成员)",
"maxTasks": "(您最多可以创建5个任务)"
}

View File

@@ -7,12 +7,10 @@
"emailLabel": "电子邮件",
"emailPlaceholder": "输入您的电子邮件",
"emailRequired": "请输入您的电子邮件!",
"passwordLabel": "Password",
"passwordGuideline": "Password must be at least 8 characters, include uppercase and lowercase letters, a number, and a special character.",
"passwordPlaceholder": "Enter your password",
"passwordLabel": "密码",
"passwordPlaceholder": "输入您的密码",
"passwordRequired": "请输入您的密码!",
"passwordMinCharacterRequired": "密码必须至少包含8个字符",
"passwordMaxCharacterRequired": "Password must be at most 32 characters!",
"passwordPatternRequired": "密码不符合要求!",
"strongPasswordPlaceholder": "输入更强的密码",
"passwordValidationAltText": "密码必须至少包含8个字符包括大小写字母、一个数字和一个符号。",

View File

@@ -15,32 +15,5 @@
"assignToMe": "分配给我",
"archive": "归档",
"newTaskNamePlaceholder": "写一个任务名称",
"newSubtaskNamePlaceholder": "写一个子任务名称",
"deleteTaskTitle": "删除任务",
"deleteTaskContent": "您确定要删除此任务吗?此操作无法撤销。",
"deleteTaskConfirm": "删除",
"deleteTaskCancel": "取消",
"deleteStatusTitle": "删除状态",
"deleteStatusContent": "您确定要删除此状态吗?此操作无法撤销。",
"deletePhaseTitle": "删除阶段",
"deletePhaseContent": "您确定要删除此阶段吗?此操作无法撤销。",
"untitledSection": "未命名部分",
"unmapped": "未映射",
"clickToChangeDate": "点击更改日期",
"noDueDate": "无截止日期",
"save": "保存",
"clear": "清除",
"nextWeek": "下周",
"noSubtasks": "无子任务",
"showSubtasks": "显示子任务",
"hideSubtasks": "隐藏子任务",
"errorLoadingTasks": "加载任务时出错",
"noTasksFound": "未找到任务",
"loadingFilters": "正在加载过滤器...",
"failedToUpdateColumnOrder": "更新列顺序失败",
"failedToUpdatePhaseOrder": "更新阶段顺序失败",
"pleaseTryAgain": "请重试",
"taskNotCompleted": "任务未完成",
"completeTaskDependencies": "请先完成任务依赖项,然后再继续"
"newSubtaskNamePlaceholder": "写一个子任务名称"
}

View File

@@ -3,9 +3,5 @@
"searchLabel": "通过添加名称或电子邮件添加成员",
"searchPlaceholder": "输入名称或电子邮件",
"inviteAsAMember": "邀请为成员",
"inviteNewMemberByEmail": "通过电子邮件邀请新成员",
"members": "成员",
"copyProjectLink": "复制项目链接",
"inviteMember": "邀请成员",
"alsoInviteToProject": "也邀请到项目"
"inviteNewMemberByEmail": "通过电子邮件邀请新成员"
}

View File

@@ -28,7 +28,7 @@
"jobTitleLabel": "职位",
"jobTitlePlaceholder": "选择或搜索职位(可选)",
"memberAccessLabel": "访问级别",
"addToTeamButton": "发送邀请",
"addToTeamButton": "将成员添加到团队",
"updateButton": "保存更改",
"resendInvitationButton": "重新发送邀请邮件",
"invitationSentSuccessMessage": "团队邀请已成功发送!",
@@ -43,6 +43,5 @@
"updatedText": "已更新",
"noResultFound": "输入电子邮件地址并按回车键...",
"jobTitlesFetchError": "获取职位失败",
"invitationResent": "邀请重新发送成功!",
"copyTeamLink": "复制团队链接"
"invitationResent": "邀请重新发送成功!"
}

View File

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

View File

@@ -1,92 +0,0 @@
import { API_BASE_URL } from '@/shared/constants';
import { toQueryString } from '@/utils/toQueryString';
import apiClient from '../api-client';
import { IServerResponse } from '@/types/common.types';
import { IAllocationViewModel } from '@/types/reporting/reporting-allocation.types';
import {
IProjectLogsBreakdown,
IRPTTimeMember,
IRPTTimeProject,
ITimeLogBreakdownReq,
} from '@/types/reporting/reporting.types';
const rootUrl = `${API_BASE_URL}/reporting`;
// Helper function to get user's timezone
const getUserTimezone = () => {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
};
export const reportingTimesheetApiService = {
getTimeSheetData: async (
body = {},
archived = false
): Promise<IServerResponse<IAllocationViewModel>> => {
const q = toQueryString({ archived });
const bodyWithTimezone = {
...body,
timezone: getUserTimezone()
};
const response = await apiClient.post(`${rootUrl}/allocation/${q}`, bodyWithTimezone);
return response.data;
},
getAllocationProjects: async (body = {}) => {
const bodyWithTimezone = {
...body,
timezone: getUserTimezone()
};
const response = await apiClient.post(`${rootUrl}/allocation/allocation-projects`, bodyWithTimezone);
return response.data;
},
getProjectTimeSheets: async (
body = {},
archived = false
): Promise<IServerResponse<IRPTTimeProject[]>> => {
const q = toQueryString({ archived });
const bodyWithTimezone = {
...body,
timezone: getUserTimezone()
};
const response = await apiClient.post(`${rootUrl}/time-reports/projects/${q}`, bodyWithTimezone);
return response.data;
},
getMemberTimeSheets: async (
body = {},
archived = false
): Promise<IServerResponse<IRPTTimeMember[]>> => {
const q = toQueryString({ archived });
const bodyWithTimezone = {
...body,
timezone: getUserTimezone()
};
const response = await apiClient.post(`${rootUrl}/time-reports/members/${q}`, bodyWithTimezone);
return response.data;
},
getProjectTimeLogs: async (
body: ITimeLogBreakdownReq
): Promise<IServerResponse<IProjectLogsBreakdown[]>> => {
const bodyWithTimezone = {
...body,
timezone: getUserTimezone()
};
const response = await apiClient.post(`${rootUrl}/project-timelogs`, bodyWithTimezone);
return response.data;
},
getProjectEstimatedVsActual: async (
body = {},
archived = false
): Promise<IServerResponse<IRPTTimeProject[]>> => {
const q = toQueryString({ archived });
const bodyWithTimezone = {
...body,
timezone: getUserTimezone()
};
const response = await apiClient.post(`${rootUrl}/time-reports/estimated-vs-actual${q}`, bodyWithTimezone);
return response.data;
},
};

View File

@@ -1,22 +0,0 @@
import { IServerResponse } from '@/types/common.types';
import { ISurvey, ISurveySubmissionRequest, ISurveyResponse } from '@/types/account-setup/survey.types';
import apiClient from '../api-client';
const API_BASE_URL = '/api/v1';
export const surveyApiService = {
async getAccountSetupSurvey(): Promise<IServerResponse<ISurvey>> {
const response = await apiClient.get<IServerResponse<ISurvey>>(`${API_BASE_URL}/surveys/account-setup`);
return response.data;
},
async submitSurveyResponse(data: ISurveySubmissionRequest): Promise<IServerResponse<{ response_id: string }>> {
const response = await apiClient.post<IServerResponse<{ response_id: string }>>(`${API_BASE_URL}/surveys/responses`, data);
return response.data;
},
async getUserSurveyResponse(surveyId: string): Promise<IServerResponse<ISurveyResponse>> {
const response = await apiClient.get<IServerResponse<ISurveyResponse>>(`${API_BASE_URL}/surveys/responses/${surveyId}`);
return response.data;
}
};

View File

@@ -4,12 +4,12 @@ import { Navigate } from 'react-router-dom';
import { SuspenseFallback } from '@/components/suspense-fallback/suspense-fallback';
// Lazy load auth page components for better code splitting
const LoginPage = lazy(() => import('@/pages/auth/LoginPage'));
const SignupPage = lazy(() => import('@/pages/auth/SignupPage'));
const ForgotPasswordPage = lazy(() => import('@/pages/auth/ForgotPasswordPage'));
const LoggingOutPage = lazy(() => import('@/pages/auth/LoggingOutPage'));
const AuthenticatingPage = lazy(() => import('@/pages/auth/AuthenticatingPage'));
const VerifyResetEmailPage = lazy(() => import('@/pages/auth/VerifyResetEmailPage'));
const LoginPage = lazy(() => import('@/pages/auth/login-page'));
const SignupPage = lazy(() => import('@/pages/auth/signup-page'));
const ForgotPasswordPage = lazy(() => import('@/pages/auth/forgot-password-page'));
const LoggingOutPage = lazy(() => import('@/pages/auth/logging-out'));
const AuthenticatingPage = lazy(() => import('@/pages/auth/authenticating'));
const VerifyResetEmailPage = lazy(() => import('@/pages/auth/verify-reset-email'));
const authRoutes = [
{

View File

@@ -17,7 +17,6 @@ const ProjectTemplateEditView = lazy(
const LicenseExpired = lazy(() => import('@/pages/license-expired/license-expired'));
const ProjectView = lazy(() => import('@/pages/projects/projectView/project-view'));
const Unauthorized = lazy(() => import('@/pages/unauthorized/unauthorized'));
const GanttDemoPage = lazy(() => import('@/pages/GanttDemoPage'));
// Define AdminGuard component with defensive programming
const AdminGuard = ({ children }: { children: React.ReactNode }) => {
@@ -107,14 +106,6 @@ const mainRoutes: RouteObject[] = [
</Suspense>
),
},
{
path: 'gantt-demo',
element: (
<Suspense fallback={<SuspenseFallback />}>
<GanttDemoPage />
</Suspense>
),
},
...settingsRoutes,
...adminCenterRoutes,
],

View File

@@ -1,7 +1,7 @@
import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { useSelector } from 'react-redux';
import { PlusOutlined, UserAddOutlined } from '@/shared/antd-imports';
import { PlusOutlined, UserAddOutlined } from '@ant-design/icons';
import { RootState } from '@/app/store';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import { ITeamMembersViewModel } from '@/types/teamMembers/teamMembersViewModel.types';
@@ -11,10 +11,8 @@ import { useAuthService } from '@/hooks/useAuth';
import { Avatar, Button, Checkbox } from '@/components';
import { sortTeamMembers } from '@/utils/sort-team-members';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { setIsFromAssigner, toggleProjectMemberDrawer } from '@/features/projects/singleProject/members/projectMembersSlice';
import { toggleProjectMemberDrawer } from '@/features/projects/singleProject/members/projectMembersSlice';
import { updateEnhancedKanbanTaskAssignees } from '@/features/enhanced-kanban/enhanced-kanban.slice';
import useIsProjectManager from '@/hooks/useIsProjectManager';
import { useAuthStatus } from '@/hooks/useAuthStatus';
interface AssigneeSelectorProps {
task: IProjectTask;
@@ -23,9 +21,9 @@ interface AssigneeSelectorProps {
kanbanMode?: boolean;
}
const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
task,
groupId = null,
const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
task,
groupId = null,
isDarkMode = false,
kanbanMode = false
}) => {
@@ -44,8 +42,6 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
const currentSession = useAuthService().getCurrentSession();
const { socket } = useSocket();
const dispatch = useAppDispatch();
const { isAdmin } = useAuthStatus();
const isProjectManager = useIsProjectManager();
const filteredMembers = useMemo(() => {
return teamMembers?.data?.filter(member =>
@@ -68,7 +64,7 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node) &&
buttonRef.current && !buttonRef.current.contains(event.target as Node)) {
buttonRef.current && !buttonRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
@@ -78,10 +74,10 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
// Check if the button is still visible in the viewport
if (buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect();
const isVisible = rect.top >= 0 && rect.left >= 0 &&
rect.bottom <= window.innerHeight &&
rect.right <= window.innerWidth;
const isVisible = rect.top >= 0 && rect.left >= 0 &&
rect.bottom <= window.innerHeight &&
rect.right <= window.innerWidth;
if (isVisible) {
updateDropdownPosition();
} else {
@@ -102,7 +98,7 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
document.addEventListener('mousedown', handleClickOutside);
window.addEventListener('scroll', handleScroll, true);
window.addEventListener('resize', handleResize);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
window.removeEventListener('scroll', handleScroll, true);
@@ -117,10 +113,10 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
const handleDropdownToggle = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!isOpen) {
updateDropdownPosition();
// Prepare team members data when opening
const assignees = task?.assignees?.map(assignee => assignee.team_member_id);
const membersData = (members?.data || []).map(member => ({
@@ -129,7 +125,7 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
}));
const sortedMembers = sortTeamMembers(membersData);
setTeamMembers({ data: sortedMembers });
setIsOpen(true);
// Focus search input after opening
setTimeout(() => {
@@ -164,8 +160,8 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
// Update local team members state for dropdown UI
setTeamMembers(prev => ({
...prev,
data: (prev.data || []).map(member =>
member.id === memberId
data: (prev.data || []).map(member =>
member.id === memberId
? { ...member, selected: checked }
: member
)
@@ -202,15 +198,14 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
const checkMemberSelected = (memberId: string) => {
if (!memberId) return false;
// Use optimistic assignees if available, otherwise fall back to task assignees
const assignees = optimisticAssignees.length > 0
? optimisticAssignees
const assignees = optimisticAssignees.length > 0
? optimisticAssignees
: task?.assignees?.map(assignee => assignee.team_member_id) || [];
return assignees.includes(memberId);
};
const handleInviteProjectMemberDrawer = () => {
setIsOpen(false); // Close the assignee dropdown first
dispatch(setIsFromAssigner(true));
dispatch(toggleProjectMemberDrawer()); // Then open the invite drawer
};
@@ -222,12 +217,12 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
className={`
w-5 h-5 rounded-full border border-dashed flex items-center justify-center
transition-colors duration-200
${isOpen
? isDarkMode
? 'border-blue-500 bg-blue-900/20 text-blue-400'
${isOpen
? isDarkMode
? 'border-blue-500 bg-blue-900/20 text-blue-400'
: 'border-blue-500 bg-blue-50 text-blue-600'
: isDarkMode
? 'border-gray-600 hover:border-gray-500 hover:bg-gray-800 text-gray-400'
: isDarkMode
? 'border-gray-600 hover:border-gray-500 hover:bg-gray-800 text-gray-400'
: 'border-gray-300 hover:border-gray-400 hover:bg-gray-100 text-gray-600'
}
`}
@@ -241,8 +236,8 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
onClick={e => e.stopPropagation()}
className={`
fixed z-[99999] w-72 rounded-md shadow-lg border
${isDarkMode
? 'bg-gray-800 border-gray-600'
${isDarkMode
? 'bg-gray-800 border-gray-600'
: 'bg-white border-gray-200'
}
`}
@@ -278,10 +273,10 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
key={member.id}
className={`
flex items-center gap-2 p-2 cursor-pointer transition-colors
${member.pending_invitation
? 'opacity-50 cursor-not-allowed'
: isDarkMode
? 'hover:bg-gray-700'
${member.pending_invitation
? 'opacity-50 cursor-not-allowed'
: isDarkMode
? 'hover:bg-gray-700'
: 'hover:bg-gray-50'
}
`}
@@ -306,21 +301,23 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
/>
</span>
{pendingChanges.has(member.id || '') && (
<div className={`absolute inset-0 flex items-center justify-center ${isDarkMode ? 'bg-gray-800/50' : 'bg-white/50'
}`}>
<div className={`w-3 h-3 border border-t-transparent rounded-full animate-spin ${isDarkMode ? 'border-blue-400' : 'border-blue-600'
}`} />
<div className={`absolute inset-0 flex items-center justify-center ${
isDarkMode ? 'bg-gray-800/50' : 'bg-white/50'
}`}>
<div className={`w-3 h-3 border border-t-transparent rounded-full animate-spin ${
isDarkMode ? 'border-blue-400' : 'border-blue-600'
}`} />
</div>
)}
</div>
<Avatar
src={member.avatar_url}
name={member.name || ''}
size={24}
isDarkMode={isDarkMode}
/>
<div className="flex-1 min-w-0">
<div className={`text-xs font-medium truncate ${isDarkMode ? 'text-gray-100' : 'text-gray-900'}`}>
{member.name}
@@ -342,26 +339,22 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
</div>
{/* Footer */}
{(isAdmin || isProjectManager) && (
<div className={`p-2 border-t ${isDarkMode ? 'border-gray-600' : 'border-gray-200'}`}>
<button
className={`
w-full flex items-center justify-center gap-1 px-2 py-1 text-xs rounded
transition-colors
${isDarkMode
? 'text-blue-400 hover:bg-gray-700'
: 'text-blue-600 hover:bg-blue-50'
}
`}
onClick={handleInviteProjectMemberDrawer}
>
<UserAddOutlined />
Invite member
</button>
</div>
)}
<div className={`p-2 border-t ${isDarkMode ? 'border-gray-600' : 'border-gray-200'}`}>
<button
className={`
w-full flex items-center justify-center gap-1 px-2 py-1 text-xs rounded
transition-colors
${isDarkMode
? 'text-blue-400 hover:bg-gray-700'
: 'text-blue-600 hover:bg-blue-50'
}
`}
onClick={handleInviteProjectMemberDrawer}
>
<UserAddOutlined />
Invite member
</button>
</div>
</div>,
document.body
)}

View File

@@ -1,4 +1,4 @@
import { Flex, Typography } from '@/shared/antd-imports';
import { Flex, Typography } from 'antd';
import logo from '@/assets/images/worklenz-light-mode.png';
import logoDark from '@/assets/images/worklenz-dark-mode.png';
import { useAppSelector } from '@/hooks/useAppSelector';

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Tooltip } from '@/shared/antd-imports';
import { Tooltip } from 'antd';
import { Label } from '@/types/task-management.types';
import { ITaskLabel } from '@/types/tasks/taskLabel.types';

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Tooltip } from '@/shared/antd-imports';
import { Tooltip } from 'antd';
import { NumbersColorMap } from '@/shared/constants';
interface CustomNumberLabelProps {

View File

@@ -1,5 +1,5 @@
import { SearchOutlined } from '@/shared/antd-imports';
import { Input } from '@/shared/antd-imports';
import { SearchOutlined } from '@ant-design/icons';
import { Input } from 'antd';
type CustomSearchbarProps = {
placeholderText: string;

View File

@@ -1,6 +1,6 @@
import { Flex, Tooltip, Typography } from '@/shared/antd-imports';
import { Flex, Tooltip, Typography } from 'antd';
import { colors } from '../styles/colors';
import { ExclamationCircleOutlined } from '@/shared/antd-imports';
import { ExclamationCircleOutlined } from '@ant-design/icons';
// this custom table title used when the typography font weigh 500 needed
const CustomTableTitle = ({ title, tooltip }: { title: string; tooltip?: string | null }) => {

View File

@@ -1,24 +1,17 @@
import { Empty, Typography } from '@/shared/antd-imports';
import { Empty, Typography } from 'antd';
import React from 'react';
import { useTranslation } from 'react-i18next';
type EmptyListPlaceholderProps = {
imageSrc?: string;
imageHeight?: number;
text?: string;
textKey?: string;
i18nNs?: string;
text: string;
};
const EmptyListPlaceholder = ({
imageSrc = 'https://s3.us-west-2.amazonaws.com/worklenz.com/assets/empty-box.webp',
imageHeight = 60,
text,
textKey,
i18nNs = 'task-list-table',
}: EmptyListPlaceholderProps) => {
const { t } = useTranslation(i18nNs);
const description = textKey ? t(textKey) : text;
return (
<Empty
image={imageSrc}
@@ -29,7 +22,7 @@ const EmptyListPlaceholder = ({
alignItems: 'center',
marginBlockStart: 24,
}}
description={<Typography.Text type="secondary">{description}</Typography.Text>}
description={<Typography.Text type="secondary">{text}</Typography.Text>}
/>
);
};

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Button, Result } from '@/shared/antd-imports';
import { Button, Result } from 'antd';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import logger from '@/utils/errorLogger';

View File

@@ -1,7 +1,7 @@
import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { useSelector } from 'react-redux';
import { PlusOutlined, TagOutlined } from '@/shared/antd-imports';
import { PlusOutlined, TagOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import { RootState } from '@/app/store';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';

View File

@@ -1,5 +1,5 @@
import React, { Component, ErrorInfo, ReactNode } from 'react';
import { Button, Result } from '@/shared/antd-imports';
import { Button, Result } from 'antd';
import CacheCleanup from '@/utils/cache-cleanup';
interface Props {

View File

@@ -1,9 +1,9 @@
import React, { useState } from 'react';
import { getJSONFromLocalStorage, saveJSONToLocalStorage } from '../utils/localStorageFunctions';
import { Button, ConfigProvider, Tooltip } from '@/shared/antd-imports';
import { PushpinFilled, PushpinOutlined } from '@/shared/antd-imports';
import { Button, ConfigProvider, Tooltip } from 'antd';
import { PushpinFilled, PushpinOutlined } from '@ant-design/icons';
import { colors } from '../styles/colors';
import { navRoutes, NavRoutesType } from '../lib/navbar/navRoutes';
import { navRoutes, NavRoutesType } from '../features/navbar/navRoutes';
// Props type for the component
type PinRouteToNavbarButtonProps = {

View File

@@ -1,5 +1,5 @@
import { FloatButton, Space, Tooltip } from '@/shared/antd-imports';
import { FormatPainterOutlined } from '@/shared/antd-imports';
import { FloatButton, Space, Tooltip } from 'antd';
import { FormatPainterOutlined } from '@ant-design/icons';
// import LanguageSelector from '../features/i18n/language-selector';
// import ThemeSelector from '../features/theme/ThemeSelector';

View File

@@ -0,0 +1,19 @@
@media (max-width: 1000px) {
.step-content,
.step-form,
.create-first-task-form,
.setup-action-buttons,
.invite-members-form {
width: 400px !important;
}
}
@media (max-width: 500px) {
.step-content,
.step-form,
.create-first-task-form,
.setup-action-buttons,
.invite-members-form {
width: 200px !important;
}
}

View File

@@ -1,15 +1,16 @@
import React, { useEffect, useRef, useState } from 'react';
import { Form, Input, Button, Typography, Card, Avatar, Tag, Alert, Space, Dropdown, MenuProps } from '@/shared/antd-imports';
import { CloseCircleOutlined, MailOutlined, PlusOutlined, UserOutlined, CheckCircleOutlined, ExclamationCircleOutlined, GlobalOutlined } from '@/shared/antd-imports';
import React, { useEffect, useRef } from 'react';
import { Form, Input, Button, List, Alert, message, InputRef } from 'antd';
import { CloseCircleOutlined, MailOutlined, PlusOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import { setTeamMembers } from '@/features/account-setup/account-setup.slice';
import { Typography } from 'antd';
import { setTeamMembers, setTasks } from '@/features/account-setup/account-setup.slice';
import { useDispatch, useSelector } from 'react-redux';
import { RootState } from '@/app/store';
import { validateEmail } from '@/utils/validateEmail';
import { sanitizeInput } from '@/utils/sanitizeInput';
import { setLanguage } from '@/features/i18n/localesSlice';
import { Rule } from 'antd/es/form';
const { Title, Paragraph, Text } = Typography;
const { Title } = Typography;
interface Email {
id: number;
@@ -19,233 +20,163 @@ interface Email {
interface MembersStepProps {
isDarkMode: boolean;
styles: any;
token?: any;
}
const getEmailSuggestions = (orgName?: string) => {
if (!orgName) return [];
const cleanOrgName = orgName.toLowerCase().replace(/[^a-z0-9]/g, '');
return [`info@${cleanOrgName}.com`, `team@${cleanOrgName}.com`, `hello@${cleanOrgName}.com`, `contact@${cleanOrgName}.com`];
};
const getRoleSuggestions = (t: any) => [
{ role: 'Designer', icon: '🎨', description: t('roleSuggestions.designer') },
{ role: 'Developer', icon: '💻', description: t('roleSuggestions.developer') },
{ role: 'Project Manager', icon: '📊', description: t('roleSuggestions.projectManager') },
{ role: 'Marketing', icon: '📢', description: t('roleSuggestions.marketing') },
{ role: 'Sales', icon: '💼', description: t('roleSuggestions.sales') },
{ role: 'Operations', icon: '⚙️', description: t('roleSuggestions.operations') }
];
const MembersStep: React.FC<MembersStepProps> = ({ isDarkMode, styles, token }) => {
const { t, i18n } = useTranslation('account-setup');
const MembersStep: React.FC<MembersStepProps> = ({ isDarkMode, styles }) => {
const { t } = useTranslation('account-setup');
const { teamMembers, organizationName } = useSelector(
(state: RootState) => state.accountSetupReducer
);
const { language } = useSelector((state: RootState) => state.localesReducer);
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
const inputRefs = useRef<(InputRef | null)[]>([]);
const dispatch = useDispatch();
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
const [showSuggestions, setShowSuggestions] = useState(false);
const [validatedEmails, setValidatedEmails] = useState<Set<number>>(new Set());
const emailSuggestions = getEmailSuggestions(organizationName);
const [form] = Form.useForm();
const addEmail = () => {
if (teamMembers.length >= 5) return;
if (teamMembers.length == 5) return;
const newId = teamMembers.length > 0 ? Math.max(...teamMembers.map(t => t.id)) + 1 : 0;
dispatch(setTeamMembers([...teamMembers, { id: newId, value: '' }]));
setTimeout(() => inputRefs.current[teamMembers.length]?.focus(), 100);
setTimeout(() => {
inputRefs.current[newId]?.focus();
}, 0);
};
const removeEmail = (id: number) => {
if (teamMembers.length > 1) dispatch(setTeamMembers(teamMembers.filter(teamMember => teamMember.id !== id)));
if (teamMembers.length > 1) {
dispatch(setTeamMembers(teamMembers.filter(teamMember => teamMember.id !== id)));
}
};
const updateEmail = (id: number, value: string) => {
const sanitizedValue = sanitizeInput(value);
dispatch(setTeamMembers(teamMembers.map(teamMember => teamMember.id === id ? { ...teamMember, value: sanitizedValue } : teamMember)));
dispatch(
setTeamMembers(
teamMembers.map(teamMember =>
teamMember.id === id ? { ...teamMember, value: sanitizedValue } : teamMember
)
)
);
};
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>, index: number) => {
if (e.key === 'Enter') {
const input = e.currentTarget as HTMLInputElement;
if (input.value.trim() && validateEmail(input.value.trim())) {
e.preventDefault();
if (index === teamMembers.length - 1 && teamMembers.length < 5) addEmail();
else if (index < teamMembers.length - 1) inputRefs.current[index + 1]?.focus();
}
}
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
const input = e.currentTarget as HTMLInputElement;
if (!input.value.trim()) return;
e.preventDefault();
addEmail();
};
const handleSuggestionClick = (suggestion: string) => {
const emptyEmailIndex = teamMembers.findIndex(member => !member.value.trim());
if (emptyEmailIndex !== -1) {
updateEmail(teamMembers[emptyEmailIndex].id, suggestion);
} else if (teamMembers.length < 5) {
const newId = teamMembers.length > 0 ? Math.max(...teamMembers.map(t => t.id)) + 1 : 0;
dispatch(setTeamMembers([...teamMembers, { id: newId, value: suggestion }]));
}
setShowSuggestions(false);
// Function to set ref that doesn't return anything (void)
const setInputRef = (index: number) => (el: InputRef | null) => {
inputRefs.current[index] = el;
};
useEffect(() => {
setTimeout(() => inputRefs.current[0]?.focus(), 200);
setTimeout(() => {
inputRefs.current[teamMembers.length - 1]?.focus();
// Set initial form values
const initialValues: Record<string, string> = {};
teamMembers.forEach(teamMember => {
initialValues[`email-${teamMember.id}`] = teamMember.value;
});
form.setFieldsValue(initialValues);
}, 200);
}, []);
const getEmailStatus = (email: string, memberId: number) => {
if (!email.trim()) return 'empty';
if (!validatedEmails.has(memberId)) return 'empty';
return validateEmail(email) ? 'valid' : 'invalid';
const formRules = {
email: [
{
validator: async (_: any, value: string) => {
if (!value) return;
if (!validateEmail(value)) {
throw new Error(t('invalidEmail'));
}
},
},
],
};
const handleBlur = (memberId: number, email: string) => {
setFocusedIndex(null);
if (email.trim()) setValidatedEmails(prev => new Set(prev).add(memberId));
};
const languages = [
{ key: 'en', label: t('languages.en'), flag: '🇺🇸' },
{ key: 'es', label: t('languages.es'), flag: '🇪🇸' },
{ key: 'pt', label: t('languages.pt'), flag: '🇵🇹' },
{ key: 'de', label: t('languages.de'), flag: '🇩🇪' },
{ key: 'alb', label: t('languages.alb'), flag: '🇦🇱' },
{ key: 'zh', label: t('languages.zh'), flag: '🇨🇳' }
];
const handleLanguageChange = (languageKey: string) => {
dispatch(setLanguage(languageKey));
i18n.changeLanguage(languageKey);
};
const currentLanguage = languages.find(lang => lang.key === language) || languages[0];
const languageMenuItems: MenuProps['items'] = languages.map(lang => ({ key: lang.key, label: <div className="flex items-center space-x-2"><span>{lang.flag}</span><span>{lang.label}</span></div>, onClick: () => handleLanguageChange(lang.key) }));
return (
<div className="w-full members-step">
{/* Header */}
<div className="text-center mb-8">
<Title level={3} className="mb-2" style={{ color: token?.colorText }}>
{t('membersStepTitle')}
<Form
form={form}
className="invite-members-form"
style={{
minHeight: '300px',
width: '600px',
paddingBottom: '1rem',
marginBottom: '3rem',
marginTop: '3rem',
display: 'flex',
flexDirection: 'column',
}}
>
<Form.Item>
<Title level={2} style={{ marginBottom: '1rem' }}>
{t('step3Title')} "<mark>{organizationName}</mark>".
</Title>
<Paragraph className="text-base" style={{ color: token?.colorTextSecondary }}>
{t('membersStepDescription', { organizationName })}
</Paragraph>
</div>
{/* Team Members List */}
<div className="mb-6">
<div className="space-y-3">
{teamMembers.map((teamMember, index) => {
const emailStatus = getEmailStatus(teamMember.value, teamMember.id);
return (
<div
key={teamMember.id}
className={`flex items-center space-x-3 p-3 rounded-lg border transition-all duration-200 ${
focusedIndex === index ? 'border-2' : ''
}`}
style={{
borderColor: focusedIndex === index ? token?.colorPrimary :
emailStatus === 'invalid' ? token?.colorError : token?.colorBorder,
backgroundColor: token?.colorBgContainer
}}
>
<Avatar
size={32}
style={{
backgroundColor: emailStatus === 'valid' ? token?.colorSuccess :
emailStatus === 'invalid' ? token?.colorError : token?.colorBorderSecondary,
color: '#fff'
}}
icon={
emailStatus === 'valid' ? <CheckCircleOutlined /> :
emailStatus === 'invalid' ? <ExclamationCircleOutlined /> :
<UserOutlined />
}
/>
<div className="flex-1">
</Form.Item>
<Form.Item
layout="vertical"
rules={[{ required: true }]}
label={
<span className="font-medium">
{t('step3InputLabel')}&nbsp; <MailOutlined /> {t('maxMembers')}
</span>
}
>
<List
dataSource={teamMembers}
bordered={false}
itemLayout="vertical"
renderItem={(teamMember, index) => (
<List.Item key={teamMember.id}>
<div className="invite-members-form" style={{ display: 'flex', width: '600px' }}>
<Form.Item
rules={formRules.email as Rule[]}
className="w-full"
validateTrigger={['onChange', 'onBlur']}
name={`email-${teamMember.id}`}
>
<Input
placeholder={t('memberPlaceholder', { index: index + 1 })}
placeholder={t('emailPlaceholder')}
value={teamMember.value}
onChange={e => updateEmail(teamMember.id, e.target.value)}
onKeyPress={e => handleKeyPress(e, index)}
onFocus={() => setFocusedIndex(index)}
onBlur={() => handleBlur(teamMember.id, teamMember.value)}
ref={el => inputRefs.current[index] = el}
className="border-0 shadow-none"
style={{
backgroundColor: 'transparent',
color: token?.colorText
}}
prefix={<MailOutlined style={{ color: token?.colorTextTertiary }} />}
status={emailStatus === 'invalid' ? 'error' : undefined}
suffix={
emailStatus === 'valid' ? (
<CheckCircleOutlined style={{ color: token?.colorSuccess }} />
) : emailStatus === 'invalid' ? (
<ExclamationCircleOutlined style={{ color: token?.colorError }} />
) : null
}
onPressEnter={handleKeyPress}
ref={setInputRef(index)}
status={teamMember.value && !validateEmail(teamMember.value) ? 'error' : ''}
id={`member-${index}`}
/>
{emailStatus === 'invalid' && (
<Text type="danger" className="text-xs mt-1 block">
{t('invalidEmail')}
</Text>
)}
{emailStatus === 'valid' && (
<Text type="success" className="text-xs mt-1 block">
{t('validEmailAddress')}
</Text>
)}
</div>
{teamMembers.length > 1 && (
<Button
type="text"
size="small"
icon={<CloseCircleOutlined />}
onClick={() => removeEmail(teamMember.id)}
style={{ color: token?.colorTextTertiary }}
/>
)}
</Form.Item>
<Button
className="custom-close-button"
style={{ marginLeft: '48px' }}
type="text"
icon={<CloseCircleOutlined />}
disabled={teamMembers.length === 1}
onClick={() => removeEmail(teamMember.id)}
/>
</div>
);
})}
</div>
{/* Add Member Button */}
{teamMembers.length < 5 && (
<Button
type="dashed"
icon={<PlusOutlined />}
onClick={addEmail}
className="w-full mt-4 h-12 text-base"
style={{
borderColor: token?.colorBorder,
color: token?.colorTextSecondary
}}
>
{t('addAnotherTeamMember', { current: teamMembers.length, max: 5 })}
</Button>
)}
</div>
{/* Skip Option */}
<div className="mb-6">
<Alert
message={t('canInviteLater')}
description={t('skipStepDescription')}
type="info"
showIcon
style={{
backgroundColor: token?.colorInfoBg,
borderColor: token?.colorInfoBorder
}}
</List.Item>
)}
/>
</div>
</div>
<Button
type="dashed"
icon={<PlusOutlined />}
onClick={addEmail}
style={{ marginTop: '16px' }}
disabled={teamMembers.length == 5}
>
{t('tasksStepAddAnother')}
</Button>
<div
style={{
marginTop: '24px',
display: 'flex',
justifyContent: 'space-between',
}}
></div>
</Form.Item>
</Form>
);
};
export default MembersStep;
export default MembersStep;

View File

@@ -1,40 +1,31 @@
import React, { useEffect, useRef, useState } from 'react';
import { Form, Input, InputRef, Typography, Card, Tooltip } from '@/shared/antd-imports';
import React, { useEffect, useRef } from 'react';
import { Form, Input, InputRef, Typography } from 'antd';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { setOrganizationName } from '@/features/account-setup/account-setup.slice';
import { RootState } from '@/app/store';
import { sanitizeInput } from '@/utils/sanitizeInput';
import './admin-center-common.css';
const { Title, Paragraph, Text } = Typography;
const { Title } = Typography;
interface Props {
onEnter: () => void;
styles: any;
organizationNamePlaceholder: string;
organizationNameInitialValue?: string;
isDarkMode: boolean;
token?: any;
}
export const OrganizationStep: React.FC<Props> = ({
onEnter,
styles,
organizationNamePlaceholder,
organizationNameInitialValue,
isDarkMode,
token,
}) => {
const { t } = useTranslation('account-setup');
const dispatch = useDispatch();
const { organizationName } = useSelector((state: RootState) => state.accountSetupReducer);
const inputRef = useRef<InputRef>(null);
// Autofill organization name if not already set
useEffect(() => {
if (!organizationName && organizationNameInitialValue) {
dispatch(setOrganizationName(organizationNameInitialValue));
}
setTimeout(() => inputRef.current?.focus(), 300);
}, []);
@@ -49,85 +40,25 @@ export const OrganizationStep: React.FC<Props> = ({
};
return (
<div className="w-full organization-step">
{/* Header */}
<div className="text-center mb-8">
<Title level={3} className="mb-2" style={{ color: token?.colorText }}>
{t('organizationStepWelcome')}
<Form className="step-form" style={styles.form}>
<Form.Item>
<Title level={2} style={{ marginBottom: '1rem' }}>
{t('organizationStepTitle')}
</Title>
<Paragraph className="text-base" style={{ color: token?.colorTextSecondary }}>
{t('organizationStepDescription')}
</Paragraph>
</div>
{/* Main Form Card */}
<div className="mb-6">
<Card
className="border-2 hover:shadow-md transition-all duration-200"
style={{
borderColor: token?.colorPrimary,
backgroundColor: token?.colorBgContainer
}}
>
<Form.Item
className="mb-4"
label={
<div className="flex items-center space-x-2">
<span className="font-medium text-base" style={{ color: token?.colorText }}>
{t('organizationStepLabel')}
</span>
<Tooltip title={t('organizationStepTooltip')}>
<span
className="text-sm cursor-help"
style={{ color: token?.colorTextTertiary }}
>
</span>
</Tooltip>
</div>
}
>
<Input
placeholder={organizationNamePlaceholder || t('organizationStepPlaceholder')}
value={organizationName}
onChange={handleOrgNameChange}
onPressEnter={onPressEnter}
ref={inputRef}
className="text-base"
/>
</Form.Item>
{/* Character Count and Validation */}
<div className="flex justify-between items-center text-sm">
<Text type="secondary">
{organizationName.length}/50 {t('organizationStepCharacters')}
</Text>
{organizationName.length > 0 && (
<div className="flex items-center space-x-1">
{organizationName.length >= 2 ? (
<span style={{ color: token?.colorSuccess }}> {t('organizationStepGoodLength')}</span>
) : (
<span style={{ color: token?.colorWarning }}> {t('organizationStepTooShort')}</span>
)}
</div>
)}
</div>
</Card>
</div>
{/* Footer Note */}
<div
className="text-center p-4 rounded-lg"
style={{
backgroundColor: token?.colorInfoBg,
borderColor: token?.colorInfoBorder,
border: '1px solid'
}}
</Form.Item>
<Form.Item
layout="vertical"
rules={[{ required: true }]}
label={<span style={styles.label}>{t('organizationStepLabel')}</span>}
>
<Text type="secondary" className="text-sm">
🔒 {t('organizationStepPrivacyNote')}
</Text>
</div>
</div>
<Input
placeholder={organizationNamePlaceholder}
value={organizationName}
onChange={handleOrgNameChange}
onPressEnter={onPressEnter}
ref={inputRef}
/>
</Form.Item>
</Form>
);
};
};

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