Compare commits
131 Commits
v2.1.0
...
feature/sh
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccb8e68192 | ||
|
|
4783b5ec10 | ||
|
|
a08d1efc36 | ||
|
|
bedf85d409 | ||
|
|
c84034b436 | ||
|
|
025b2005c7 | ||
|
|
c5bac36c53 | ||
|
|
06488d80ff | ||
|
|
e0a290c18f | ||
|
|
e3e1b2dc14 | ||
|
|
4a3f4ccc08 | ||
|
|
0e2c37aef2 | ||
|
|
6e188899ed | ||
|
|
509fcc8f64 | ||
|
|
49196aac2e | ||
|
|
413d5df95c | ||
|
|
c031a49a29 | ||
|
|
3129d7a48d | ||
|
|
791cbe22df | ||
|
|
ba2ecb2d85 | ||
|
|
59880bfd59 | ||
|
|
9e66a1ce8c | ||
|
|
7d735dacc4 | ||
|
|
915980dcdf | ||
|
|
dcdb651dd1 | ||
|
|
d6686d64be | ||
|
|
1ec9759434 | ||
|
|
13baf36e3c | ||
|
|
e82bb23cd5 | ||
|
|
66b7dc5322 | ||
|
|
0136f6d3cb | ||
|
|
3bfb886de7 | ||
|
|
5ec7a2741c | ||
|
|
e8bf84ef3a | ||
|
|
593e6cfa98 | ||
|
|
e59216af54 | ||
|
|
0f5946134c | ||
|
|
4f082e982b | ||
|
|
71638ce52a | ||
|
|
45d9049d27 | ||
|
|
3f7b969e44 | ||
|
|
b6be411162 | ||
|
|
dc6a62a66a | ||
|
|
035617c8e8 | ||
|
|
6a4bf4d672 | ||
|
|
aeed75ca31 | ||
|
|
4e43780769 | ||
|
|
fef50bdfb1 | ||
|
|
43c6701d3a | ||
|
|
8cdc8b3ad0 | ||
|
|
b6e4ed9883 | ||
|
|
b0fb0a2759 | ||
|
|
4bc1b4fa73 | ||
|
|
8d6c43c59c | ||
|
|
1f6bbce0ae | ||
|
|
d1fe23b431 | ||
|
|
935165d751 | ||
|
|
2f0fb92e3e | ||
|
|
a3d5e63635 | ||
|
|
6a2e9afff8 | ||
|
|
a0f36968b3 | ||
|
|
b5288a8da2 | ||
|
|
b94c56f50d | ||
|
|
f1920c17b4 | ||
|
|
7b1c048dbb | ||
|
|
9b48cc7e06 | ||
|
|
549728cdaf | ||
|
|
b8cc9b5b73 | ||
|
|
a87ea46b97 | ||
|
|
5454c22bd1 | ||
|
|
ad9e940987 | ||
|
|
cae5524168 | ||
|
|
07bc5e6030 | ||
|
|
5cb6548889 | ||
|
|
bc652f83af | ||
|
|
010cbe1af8 | ||
|
|
ca0c958918 | ||
|
|
7bb93d2aef | ||
|
|
42c4802d19 | ||
|
|
cf0eaad077 | ||
|
|
f22a91b690 | ||
|
|
c33a152015 | ||
|
|
dcb4ff1eb0 | ||
|
|
612de866b7 | ||
|
|
c55e593535 | ||
|
|
da98fe26ab | ||
|
|
b0ed3f67e8 | ||
|
|
85280c33d2 | ||
|
|
f68c72a92a | ||
|
|
1969fbd1dc | ||
|
|
e567d6b345 | ||
|
|
399d8b420a | ||
|
|
21a4131faa | ||
|
|
659ede7fb5 | ||
|
|
22d0fc7049 | ||
|
|
b320a7b260 | ||
|
|
1a5f6d54ed | ||
|
|
e245530a15 | ||
|
|
87bd1b8801 | ||
|
|
a711d48c9c | ||
|
|
096163d9c0 | ||
|
|
a879176c24 | ||
|
|
49fc89ae3a | ||
|
|
d7a5f08058 | ||
|
|
533b59504f | ||
|
|
b104cf2d3f | ||
|
|
3ce81272b2 | ||
|
|
c3bec74897 | ||
|
|
db1108a48d | ||
|
|
4386aabeda | ||
|
|
69e7938365 | ||
|
|
f6eaddefa4 | ||
|
|
ded0ad693c | ||
|
|
cc8dca7b75 | ||
|
|
7d81b7784b | ||
|
|
c1067d87fe | ||
|
|
97feef5982 | ||
|
|
76c92b1cc6 | ||
|
|
afd4cbdf81 | ||
|
|
3dd56f094c | ||
|
|
26b0b5780a | ||
|
|
67c62fc69b | ||
|
|
14d8f43001 | ||
|
|
3b59a8560b | ||
|
|
819252cedd | ||
|
|
1dade05f54 | ||
|
|
34613e5e0c | ||
|
|
fbfeaceb9c | ||
|
|
a8b20680e5 | ||
|
|
2b3b0ba635 | ||
|
|
6847eec603 |
32
README.md
32
README.md
@@ -1,6 +1,6 @@
|
||||
<h1 align="center">
|
||||
<a href="https://worklenz.com" target="_blank" rel="noopener noreferrer">
|
||||
<img src="https://s3.us-west-2.amazonaws.com/worklenz.com/assets/icon-144x144.png" alt="Worklenz Logo" width="75">
|
||||
<img src="https://app.worklenz.com/assets/icons/icon-144x144.png" alt="Worklenz Logo" width="75">
|
||||
</a>
|
||||
<br>
|
||||
Worklenz
|
||||
@@ -192,27 +192,6 @@ Email [info@worklenz.com](mailto:info@worklenz.com) to disclose any security vul
|
||||
|
||||
This project is licensed under the [MIT License](LICENSE).
|
||||
|
||||
## Analytics
|
||||
|
||||
Worklenz uses Google Analytics to understand how the application is being used. This helps us improve the application and make better decisions about future development.
|
||||
|
||||
### What We Track
|
||||
- Anonymous usage statistics
|
||||
- Page views and navigation patterns
|
||||
- Feature usage
|
||||
- Browser and device information
|
||||
|
||||
### Privacy
|
||||
- Analytics is opt-in only
|
||||
- No personal information is collected
|
||||
- Users can opt-out at any time
|
||||
- Data is stored according to Google's privacy policy
|
||||
|
||||
### How to Opt-Out
|
||||
If you've previously opted in and want to opt-out:
|
||||
1. Clear your browser's local storage for the Worklenz domain
|
||||
2. Or click the "Decline" button in the analytics notice if it appears
|
||||
|
||||
## Screenshots
|
||||
|
||||
<p align="center">
|
||||
@@ -336,7 +315,6 @@ docker-compose up -d
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
|
||||
## MinIO Integration
|
||||
|
||||
The project uses MinIO as an S3-compatible object storage service, which provides an open-source alternative to AWS S3 for development and production.
|
||||
@@ -425,10 +403,6 @@ This script generates properly configured environment files for both development
|
||||
- Frontend: http://localhost:5000
|
||||
- Backend API: http://localhost:3000 (or https://localhost:3000 with SSL)
|
||||
|
||||
4. Video Guide
|
||||
|
||||
For a visual walkthrough of the local Docker deployment process, check out our [step-by-step video guide](https://www.youtube.com/watch?v=AfwAKxJbqLg).
|
||||
|
||||
### Remote Server Deployment
|
||||
|
||||
When deploying to a remote server:
|
||||
@@ -454,10 +428,6 @@ When deploying to a remote server:
|
||||
- Frontend: http://your-server-hostname:5000
|
||||
- Backend API: http://your-server-hostname:3000
|
||||
|
||||
4. Video Guide
|
||||
|
||||
For a complete walkthrough of deploying Worklenz to a remote server, check out our [deployment video guide](https://www.youtube.com/watch?v=CAZGu2iOXQs&t=10s).
|
||||
|
||||
### Environment Configuration
|
||||
|
||||
The Docker setup uses environment variables to configure the services:
|
||||
|
||||
16
backup.sh
16
backup.sh
@@ -1,16 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -eu
|
||||
|
||||
# Adjust these as needed:
|
||||
CONTAINER=worklenz_db
|
||||
DB_NAME=worklenz_db
|
||||
DB_USER=postgres
|
||||
BACKUP_DIR=./pg_backups
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
timestamp=$(date +%Y-%m-%d_%H-%M-%S)
|
||||
outfile="${BACKUP_DIR}/${DB_NAME}_${timestamp}.sql"
|
||||
echo "Creating backup $outfile ..."
|
||||
|
||||
docker exec -t "$CONTAINER" pg_dump -U "$DB_USER" -d "$DB_NAME" > "$outfile"
|
||||
echo "Backup saved to $outfile"
|
||||
@@ -7,8 +7,8 @@ services:
|
||||
ports:
|
||||
- "5000:5000"
|
||||
depends_on:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
backend:
|
||||
condition: service_started
|
||||
env_file:
|
||||
- ./worklenz-frontend/.env.production
|
||||
networks:
|
||||
@@ -26,7 +26,6 @@ services:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
condition: service_started
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- ./worklenz-backend/.env
|
||||
networks:
|
||||
@@ -38,7 +37,6 @@ services:
|
||||
ports:
|
||||
- "9000:9000"
|
||||
- "9001:9001"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${S3_ACCESS_KEY_ID:-minioadmin}
|
||||
MINIO_ROOT_PASSWORD: ${S3_SECRET_ACCESS_KEY:-minioadmin}
|
||||
@@ -54,14 +52,13 @@ services:
|
||||
container_name: worklenz_createbuckets
|
||||
depends_on:
|
||||
- minio
|
||||
restart: on-failure
|
||||
entrypoint: >
|
||||
/bin/sh -c '
|
||||
echo "Waiting for MinIO to start...";
|
||||
sleep 15;
|
||||
for i in 1 2 3 4 5; do
|
||||
echo "Attempt $i to connect to MinIO...";
|
||||
if /usr/bin/mc alias set myminio http://minio:9000 minioadmin minioadmin; then
|
||||
if /usr/bin/mc config host add myminio http://minio:9000 minioadmin minioadmin; then
|
||||
echo "Successfully connected to MinIO!";
|
||||
/usr/bin/mc mb --ignore-existing myminio/worklenz-bucket;
|
||||
/usr/bin/mc policy set public myminio/worklenz-bucket;
|
||||
@@ -83,79 +80,32 @@ services:
|
||||
POSTGRES_DB: ${DB_NAME:-worklenz_db}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-password}
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"pg_isready -d ${DB_NAME:-worklenz_db} -U ${DB_USER:-postgres}",
|
||||
]
|
||||
test: [ "CMD-SHELL", "pg_isready -d ${DB_NAME:-worklenz_db} -U ${DB_USER:-postgres}" ]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- worklenz
|
||||
volumes:
|
||||
- worklenz_postgres_data:/var/lib/postgresql/data
|
||||
- type: bind
|
||||
source: ./worklenz-backend/database/sql
|
||||
target: /docker-entrypoint-initdb.d/sql
|
||||
source: ./worklenz-backend/database
|
||||
target: /docker-entrypoint-initdb.d
|
||||
consistency: cached
|
||||
- type: bind
|
||||
source: ./worklenz-backend/database/migrations
|
||||
target: /docker-entrypoint-initdb.d/migrations
|
||||
consistency: cached
|
||||
- type: bind
|
||||
source: ./worklenz-backend/database/00_init.sh
|
||||
target: /docker-entrypoint-initdb.d/00_init.sh
|
||||
consistency: cached
|
||||
- type: bind
|
||||
source: ./pg_backups
|
||||
target: /docker-entrypoint-initdb.d/pg_backups
|
||||
command: >
|
||||
bash -c '
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
apt-get update && apt-get install -y dos2unix
|
||||
elif command -v apk >/dev/null 2>&1; then
|
||||
apk add --no-cache dos2unix
|
||||
fi
|
||||
|
||||
find /docker-entrypoint-initdb.d -type f -name "*.sh" -exec sh -c '"'"'
|
||||
for f; do
|
||||
dos2unix "$f" 2>/dev/null || true
|
||||
chmod +x "$f"
|
||||
done
|
||||
'"'"' sh {} +
|
||||
|
||||
exec docker-entrypoint.sh postgres
|
||||
'
|
||||
db-backup:
|
||||
image: postgres:15
|
||||
container_name: worklenz_db_backup
|
||||
environment:
|
||||
POSTGRES_USER: ${DB_USER:-postgres}
|
||||
POSTGRES_DB: ${DB_NAME:-worklenz_db}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-password}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./pg_backups:/pg_backups #host dir for backups files
|
||||
#setup bassh loop to backup data evey 24h
|
||||
command: >
|
||||
bash -c 'while true; do
|
||||
sleep 86400;
|
||||
PGPASSWORD=$$POSTGRES_PASSWORD pg_dump -h worklenz_db -U $$POSTGRES_USER -d $$POSTGRES_DB \
|
||||
> /pg_backups/worklenz_db_$$(date +%Y-%m-%d_%H-%M-%S).sql;
|
||||
find /pg_backups -type f -name "*.sql" -mtime +30 -delete;
|
||||
done'
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- worklenz
|
||||
bash -c ' if command -v apt-get >/dev/null 2>&1; then
|
||||
apt-get update && apt-get install -y dos2unix
|
||||
elif command -v apk >/dev/null 2>&1; then
|
||||
apk add --no-cache dos2unix
|
||||
fi && find /docker-entrypoint-initdb.d -type f -name "*.sh" -exec sh -c '\''
|
||||
dos2unix "{}" 2>/dev/null || true
|
||||
chmod +x "{}"
|
||||
'\'' \; && exec docker-entrypoint.sh postgres '
|
||||
|
||||
volumes:
|
||||
worklenz_postgres_data:
|
||||
worklenz_minio_data:
|
||||
pgdata:
|
||||
|
||||
|
||||
networks:
|
||||
worklenz:
|
||||
|
||||
195
docs/api/task-breakdown-api.md
Normal file
195
docs/api/task-breakdown-api.md
Normal file
@@ -0,0 +1,195 @@
|
||||
# Task Breakdown API
|
||||
|
||||
## Get Task Financial Breakdown
|
||||
|
||||
**Endpoint:** `GET /api/project-finance/task/:id/breakdown`
|
||||
|
||||
**Description:** Retrieves detailed financial breakdown for a single task, including members grouped by job roles with labor hours and costs.
|
||||
|
||||
### Parameters
|
||||
|
||||
- `id` (path parameter): UUID of the task
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"body": {
|
||||
"task": {
|
||||
"id": "uuid",
|
||||
"name": "Task Name",
|
||||
"project_id": "uuid",
|
||||
"billable": true,
|
||||
"estimated_hours": 10.5,
|
||||
"logged_hours": 8.25,
|
||||
"estimated_labor_cost": 525.0,
|
||||
"actual_labor_cost": 412.5,
|
||||
"fixed_cost": 100.0,
|
||||
"total_estimated_cost": 625.0,
|
||||
"total_actual_cost": 512.5
|
||||
},
|
||||
"grouped_members": [
|
||||
{
|
||||
"jobRole": "Frontend Developer",
|
||||
"estimated_hours": 5.25,
|
||||
"logged_hours": 4.0,
|
||||
"estimated_cost": 262.5,
|
||||
"actual_cost": 200.0,
|
||||
"members": [
|
||||
{
|
||||
"team_member_id": "uuid",
|
||||
"name": "John Doe",
|
||||
"avatar_url": "https://...",
|
||||
"hourly_rate": 50.0,
|
||||
"estimated_hours": 5.25,
|
||||
"logged_hours": 4.0,
|
||||
"estimated_cost": 262.5,
|
||||
"actual_cost": 200.0
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"jobRole": "Backend Developer",
|
||||
"estimated_hours": 5.25,
|
||||
"logged_hours": 4.25,
|
||||
"estimated_cost": 262.5,
|
||||
"actual_cost": 212.5,
|
||||
"members": [
|
||||
{
|
||||
"team_member_id": "uuid",
|
||||
"name": "Jane Smith",
|
||||
"avatar_url": "https://...",
|
||||
"hourly_rate": 50.0,
|
||||
"estimated_hours": 5.25,
|
||||
"logged_hours": 4.25,
|
||||
"estimated_cost": 262.5,
|
||||
"actual_cost": 212.5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"members": [
|
||||
{
|
||||
"team_member_id": "uuid",
|
||||
"name": "John Doe",
|
||||
"avatar_url": "https://...",
|
||||
"hourly_rate": 50.0,
|
||||
"job_title_name": "Frontend Developer",
|
||||
"estimated_hours": 5.25,
|
||||
"logged_hours": 4.0,
|
||||
"estimated_cost": 262.5,
|
||||
"actual_cost": 200.0
|
||||
},
|
||||
{
|
||||
"team_member_id": "uuid",
|
||||
"name": "Jane Smith",
|
||||
"avatar_url": "https://...",
|
||||
"hourly_rate": 50.0,
|
||||
"job_title_name": "Backend Developer",
|
||||
"estimated_hours": 5.25,
|
||||
"logged_hours": 4.25,
|
||||
"estimated_cost": 262.5,
|
||||
"actual_cost": 212.5
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
|
||||
- `404 Not Found`: Task not found
|
||||
- `400 Bad Request`: Invalid task ID
|
||||
|
||||
### Usage
|
||||
|
||||
This endpoint is designed to work with the finance drawer component (`@finance-drawer.tsx`) to provide detailed cost breakdown information for individual tasks. The response includes:
|
||||
|
||||
1. **Task Summary**: Overall task financial information
|
||||
2. **Grouped Members**: Members organized by job role with aggregated costs
|
||||
3. **Individual Members**: Detailed breakdown for each team member
|
||||
|
||||
The data structure matches what the finance drawer expects, with members grouped by job roles and individual labor hours and costs calculated based on:
|
||||
- Estimated hours divided equally among assignees
|
||||
- Actual logged time per member
|
||||
- Hourly rates from project rate cards
|
||||
- Fixed costs added to the totals
|
||||
|
||||
### Frontend Usage Example
|
||||
|
||||
```typescript
|
||||
import { projectFinanceApiService } from '@/api/project-finance-ratecard/project-finance.api.service';
|
||||
|
||||
// Fetch task breakdown
|
||||
const fetchTaskBreakdown = async (taskId: string) => {
|
||||
try {
|
||||
const response = await projectFinanceApiService.getTaskBreakdown(taskId);
|
||||
const breakdown = response.body;
|
||||
|
||||
console.log('Task:', breakdown.task);
|
||||
console.log('Grouped Members:', breakdown.grouped_members);
|
||||
console.log('Individual Members:', breakdown.members);
|
||||
|
||||
return breakdown;
|
||||
} catch (error) {
|
||||
console.error('Error fetching task breakdown:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Usage in React component
|
||||
const TaskBreakdownComponent = ({ taskId }: { taskId: string }) => {
|
||||
const [breakdown, setBreakdown] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadBreakdown = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await fetchTaskBreakdown(taskId);
|
||||
setBreakdown(data);
|
||||
} catch (error) {
|
||||
// Handle error
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (taskId) {
|
||||
loadBreakdown();
|
||||
}
|
||||
}, [taskId]);
|
||||
|
||||
if (loading) return <Spin />;
|
||||
if (!breakdown) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3>{breakdown.task.name}</h3>
|
||||
<p>Total Estimated Cost: ${breakdown.task.total_estimated_cost}</p>
|
||||
<p>Total Actual Cost: ${breakdown.task.total_actual_cost}</p>
|
||||
|
||||
{breakdown.grouped_members.map(group => (
|
||||
<div key={group.jobRole}>
|
||||
<h4>{group.jobRole}</h4>
|
||||
<p>Hours: {group.estimated_hours} | Cost: ${group.estimated_cost}</p>
|
||||
{group.members.map(member => (
|
||||
<div key={member.team_member_id}>
|
||||
{member.name}: {member.estimated_hours}h @ ${member.hourly_rate}/h
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Integration
|
||||
|
||||
This API complements the existing finance endpoints:
|
||||
- `GET /api/project-finance/project/:project_id/tasks` - Get all tasks for a project
|
||||
- `PUT /api/project-finance/task/:task_id/fixed-cost` - Update task fixed cost
|
||||
|
||||
The finance drawer component has been updated to automatically use this API when a task is selected, providing real-time financial breakdown data.
|
||||
@@ -1,429 +0,0 @@
|
||||
# Enhanced Task Management: Technical Guide
|
||||
|
||||
## Overview
|
||||
The Enhanced Task Management system is a comprehensive React-based interface built on top of WorkLenz's existing task infrastructure. It provides a modern, grouped view with drag-and-drop functionality, bulk operations, and responsive design.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Component Structure
|
||||
```
|
||||
src/components/task-management/
|
||||
├── TaskListBoard.tsx # Main container with DnD context
|
||||
├── TaskGroup.tsx # Individual group with collapse/expand
|
||||
├── TaskRow.tsx # Task display with rich metadata
|
||||
├── GroupingSelector.tsx # Grouping method switcher
|
||||
└── BulkActionBar.tsx # Bulk operations toolbar
|
||||
```
|
||||
|
||||
### Integration Points
|
||||
The system integrates with existing WorkLenz infrastructure:
|
||||
|
||||
- **Redux Store:** Uses `tasks.slice.ts` for state management
|
||||
- **Types:** Leverages existing TypeScript interfaces
|
||||
- **API Services:** Works with existing task API endpoints
|
||||
- **WebSocket:** Supports real-time updates via existing socket system
|
||||
|
||||
## Core Components
|
||||
|
||||
### TaskListBoard.tsx
|
||||
Main orchestrator component that provides:
|
||||
|
||||
- **DnD Context:** @dnd-kit drag-and-drop functionality
|
||||
- **State Management:** Redux integration for task data
|
||||
- **Event Handling:** Drag events and bulk operations
|
||||
- **Layout Structure:** Header controls and group container
|
||||
|
||||
#### Key Props
|
||||
```typescript
|
||||
interface TaskListBoardProps {
|
||||
projectId: string; // Required: Project identifier
|
||||
className?: string; // Optional: Additional CSS classes
|
||||
}
|
||||
```
|
||||
|
||||
#### Redux Selectors Used
|
||||
```typescript
|
||||
const {
|
||||
taskGroups, // ITaskListGroup[] - Grouped task data
|
||||
loadingGroups, // boolean - Loading state
|
||||
error, // string | null - Error state
|
||||
groupBy, // IGroupBy - Current grouping method
|
||||
search, // string | null - Search filter
|
||||
archived, // boolean - Show archived tasks
|
||||
} = useSelector((state: RootState) => state.taskReducer);
|
||||
```
|
||||
|
||||
### TaskGroup.tsx
|
||||
Renders individual task groups with:
|
||||
|
||||
- **Collapsible Headers:** Expand/collapse functionality
|
||||
- **Progress Indicators:** Visual completion progress
|
||||
- **Drop Zones:** Accept dropped tasks from other groups
|
||||
- **Group Statistics:** Task counts and completion rates
|
||||
|
||||
#### Key Props
|
||||
```typescript
|
||||
interface TaskGroupProps {
|
||||
group: ITaskListGroup; // Group data with tasks
|
||||
projectId: string; // Project context
|
||||
currentGrouping: IGroupBy; // Current grouping mode
|
||||
selectedTaskIds: string[]; // Selected task IDs
|
||||
onAddTask?: (groupId: string) => void;
|
||||
onToggleCollapse?: (groupId: string) => void;
|
||||
}
|
||||
```
|
||||
|
||||
### TaskRow.tsx
|
||||
Individual task display featuring:
|
||||
|
||||
- **Rich Metadata:** Progress, assignees, labels, due dates
|
||||
- **Drag Handles:** Sortable within and between groups
|
||||
- **Selection:** Multi-select with checkboxes
|
||||
- **Subtask Support:** Expandable hierarchy display
|
||||
|
||||
#### Key Props
|
||||
```typescript
|
||||
interface TaskRowProps {
|
||||
task: IProjectTask; // Task data
|
||||
projectId: string; // Project context
|
||||
groupId: string; // Parent group ID
|
||||
currentGrouping: IGroupBy; // Current grouping mode
|
||||
isSelected: boolean; // Selection state
|
||||
isDragOverlay?: boolean; // Drag overlay rendering
|
||||
index?: number; // Position in group
|
||||
onSelect?: (taskId: string, selected: boolean) => void;
|
||||
onToggleSubtasks?: (taskId: string) => void;
|
||||
}
|
||||
```
|
||||
|
||||
## State Management
|
||||
|
||||
### Redux Integration
|
||||
The system uses existing WorkLenz Redux patterns:
|
||||
|
||||
```typescript
|
||||
// Primary slice used
|
||||
import {
|
||||
fetchTaskGroups, // Async thunk for loading data
|
||||
reorderTasks, // Update task order/group
|
||||
setGroup, // Change grouping method
|
||||
updateTaskStatus, // Update individual task status
|
||||
updateTaskPriority, // Update individual task priority
|
||||
// ... other existing actions
|
||||
} from '@/features/tasks/tasks.slice';
|
||||
```
|
||||
|
||||
### Data Flow
|
||||
1. **Component Mount:** `TaskListBoard` dispatches `fetchTaskGroups(projectId)`
|
||||
2. **Group Changes:** `setGroup(newGroupBy)` triggers data reorganization
|
||||
3. **Drag Operations:** `reorderTasks()` updates task positions and properties
|
||||
4. **Real-time Updates:** WebSocket events update Redux state automatically
|
||||
|
||||
## Drag and Drop Implementation
|
||||
|
||||
### DnD Kit Integration
|
||||
Uses @dnd-kit for modern, accessible drag-and-drop:
|
||||
|
||||
```typescript
|
||||
// Sensors for different input methods
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 8 }
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
### Drag Event Handling
|
||||
```typescript
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
// Determine source and target
|
||||
const sourceGroup = findTaskGroup(active.id);
|
||||
const targetGroup = findTargetGroup(over?.id);
|
||||
|
||||
// Update task arrays and dispatch changes
|
||||
dispatch(reorderTasks({
|
||||
activeGroupId: sourceGroup.id,
|
||||
overGroupId: targetGroup.id,
|
||||
fromIndex: sourceIndex,
|
||||
toIndex: targetIndex,
|
||||
task: movedTask,
|
||||
updatedSourceTasks,
|
||||
updatedTargetTasks,
|
||||
}));
|
||||
};
|
||||
```
|
||||
|
||||
### Smart Property Updates
|
||||
When tasks are moved between groups, properties update automatically:
|
||||
|
||||
- **Status Grouping:** Moving to "Done" group sets task status to "done"
|
||||
- **Priority Grouping:** Moving to "High" group sets task priority to "high"
|
||||
- **Phase Grouping:** Moving to "Testing" group sets task phase to "testing"
|
||||
|
||||
## Bulk Operations
|
||||
|
||||
### Selection State Management
|
||||
```typescript
|
||||
// Local state for task selection
|
||||
const [selectedTaskIds, setSelectedTaskIds] = useState<string[]>([]);
|
||||
|
||||
// Selection handlers
|
||||
const handleTaskSelect = (taskId: string, selected: boolean) => {
|
||||
if (selected) {
|
||||
setSelectedTaskIds(prev => [...prev, taskId]);
|
||||
} else {
|
||||
setSelectedTaskIds(prev => prev.filter(id => id !== taskId));
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Context-Aware Actions
|
||||
Bulk actions adapt to current grouping:
|
||||
|
||||
```typescript
|
||||
// Only show status changes when not grouped by status
|
||||
{currentGrouping !== 'status' && (
|
||||
<Dropdown overlay={statusMenu}>
|
||||
<Button>Change Status</Button>
|
||||
</Dropdown>
|
||||
)}
|
||||
```
|
||||
|
||||
## Performance Optimizations
|
||||
|
||||
### Memoized Selectors
|
||||
```typescript
|
||||
// Expensive group calculations are memoized
|
||||
const taskGroups = useMemo(() => {
|
||||
return createGroupsFromTasks(tasks, currentGrouping);
|
||||
}, [tasks, currentGrouping]);
|
||||
```
|
||||
|
||||
### Virtual Scrolling Ready
|
||||
For large datasets, the system is prepared for react-window integration:
|
||||
|
||||
```typescript
|
||||
// Large group detection
|
||||
const shouldVirtualize = group.tasks.length > 100;
|
||||
|
||||
return shouldVirtualize ? (
|
||||
<VirtualizedTaskList tasks={group.tasks} />
|
||||
) : (
|
||||
<StandardTaskList tasks={group.tasks} />
|
||||
);
|
||||
```
|
||||
|
||||
### Optimistic Updates
|
||||
UI updates immediately while API calls process in background:
|
||||
|
||||
```typescript
|
||||
// Immediate UI update
|
||||
dispatch(updateTaskStatusOptimistically(taskId, newStatus));
|
||||
|
||||
// API call with rollback on error
|
||||
try {
|
||||
await updateTaskStatus(taskId, newStatus);
|
||||
} catch (error) {
|
||||
dispatch(rollbackTaskStatusUpdate(taskId));
|
||||
}
|
||||
```
|
||||
|
||||
## Responsive Design
|
||||
|
||||
### Breakpoint Strategy
|
||||
```css
|
||||
/* Mobile-first responsive design */
|
||||
.task-row {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.task-row {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.task-row {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Progressive Enhancement
|
||||
- **Mobile:** Essential information only
|
||||
- **Tablet:** Additional metadata visible
|
||||
- **Desktop:** Full feature set with optimal layout
|
||||
|
||||
## Accessibility
|
||||
|
||||
### ARIA Implementation
|
||||
```typescript
|
||||
// Proper ARIA labels for screen readers
|
||||
<div
|
||||
role="button"
|
||||
aria-label={`Move task ${task.name}`}
|
||||
tabIndex={0}
|
||||
{...dragHandleProps}
|
||||
>
|
||||
<DragOutlined />
|
||||
</div>
|
||||
```
|
||||
|
||||
### Keyboard Navigation
|
||||
- **Tab:** Navigate between elements
|
||||
- **Space:** Select/deselect tasks
|
||||
- **Enter:** Activate buttons
|
||||
- **Arrows:** Navigate sortable lists with keyboard sensor
|
||||
|
||||
### Focus Management
|
||||
```typescript
|
||||
// Maintain focus during dynamic updates
|
||||
useEffect(() => {
|
||||
if (shouldFocusTask) {
|
||||
taskRef.current?.focus();
|
||||
}
|
||||
}, [taskGroups]);
|
||||
```
|
||||
|
||||
## WebSocket Integration
|
||||
|
||||
### Real-time Updates
|
||||
The system subscribes to existing WorkLenz WebSocket events:
|
||||
|
||||
```typescript
|
||||
// Socket event handlers (existing WorkLenz patterns)
|
||||
socket.on('TASK_STATUS_CHANGED', (data) => {
|
||||
dispatch(updateTaskStatus(data));
|
||||
});
|
||||
|
||||
socket.on('TASK_PROGRESS_UPDATED', (data) => {
|
||||
dispatch(updateTaskProgress(data));
|
||||
});
|
||||
```
|
||||
|
||||
### Live Collaboration
|
||||
- Multiple users can work simultaneously
|
||||
- Changes appear in real-time
|
||||
- Conflict resolution through server-side validation
|
||||
|
||||
## API Integration
|
||||
|
||||
### Existing Endpoints Used
|
||||
```typescript
|
||||
// Uses existing WorkLenz API services
|
||||
import { tasksApiService } from '@/api/tasks/tasks.api.service';
|
||||
|
||||
// Task data fetching
|
||||
tasksApiService.getTaskList(config);
|
||||
|
||||
// Task updates
|
||||
tasksApiService.updateTask(taskId, changes);
|
||||
|
||||
// Bulk operations
|
||||
tasksApiService.bulkUpdateTasks(taskIds, changes);
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
```typescript
|
||||
try {
|
||||
await dispatch(fetchTaskGroups(projectId));
|
||||
} catch (error) {
|
||||
// Display user-friendly error message
|
||||
message.error('Failed to load tasks. Please try again.');
|
||||
logger.error('Task loading error:', error);
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Component Testing
|
||||
```typescript
|
||||
// Example test structure
|
||||
describe('TaskListBoard', () => {
|
||||
it('should render task groups correctly', () => {
|
||||
const mockTasks = generateMockTasks(10);
|
||||
render(<TaskListBoard projectId="test-project" />);
|
||||
|
||||
expect(screen.getByText('Tasks (10)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle drag and drop operations', async () => {
|
||||
// Test drag and drop functionality
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Integration Testing
|
||||
- Redux state management
|
||||
- API service integration
|
||||
- WebSocket event handling
|
||||
- Drag and drop operations
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
### Code Organization
|
||||
- Follow existing WorkLenz patterns
|
||||
- Use TypeScript strictly
|
||||
- Implement proper error boundaries
|
||||
- Maintain accessibility standards
|
||||
|
||||
### Performance Considerations
|
||||
- Memoize expensive calculations
|
||||
- Implement virtual scrolling for large datasets
|
||||
- Debounce user input operations
|
||||
- Optimize re-render cycles
|
||||
|
||||
### Styling Standards
|
||||
- Use existing Ant Design components
|
||||
- Follow WorkLenz design system
|
||||
- Implement responsive breakpoints
|
||||
- Maintain dark mode compatibility
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Planned Features
|
||||
- Custom column integration
|
||||
- Advanced filtering capabilities
|
||||
- Kanban board view
|
||||
- Enhanced time tracking
|
||||
- Task templates
|
||||
|
||||
### Extension Points
|
||||
The system is designed for easy extension:
|
||||
|
||||
```typescript
|
||||
// Plugin architecture ready
|
||||
interface TaskViewPlugin {
|
||||
name: string;
|
||||
component: React.ComponentType;
|
||||
supportedGroupings: IGroupBy[];
|
||||
}
|
||||
|
||||
const plugins: TaskViewPlugin[] = [
|
||||
{ name: 'kanban', component: KanbanView, supportedGroupings: ['status'] },
|
||||
{ name: 'timeline', component: TimelineView, supportedGroupings: ['phase'] },
|
||||
];
|
||||
```
|
||||
|
||||
## Deployment Considerations
|
||||
|
||||
### Bundle Size
|
||||
- Tree-shake unused dependencies
|
||||
- Code-split large components
|
||||
- Optimize asset loading
|
||||
|
||||
### Browser Compatibility
|
||||
- Modern browsers (ES2020+)
|
||||
- Graceful degradation for older browsers
|
||||
- Progressive enhancement approach
|
||||
|
||||
### Performance Monitoring
|
||||
- Track component render times
|
||||
- Monitor API response times
|
||||
- Measure user interaction latency
|
||||
@@ -1,275 +0,0 @@
|
||||
# Enhanced Task Management: User Guide
|
||||
|
||||
## What Is Enhanced Task Management?
|
||||
The Enhanced Task Management system provides a modern, grouped view of your tasks with advanced features like drag-and-drop, bulk operations, and dynamic grouping. This system builds on WorkLenz's existing task infrastructure while offering improved productivity and organization tools.
|
||||
|
||||
## Why Use Enhanced Task Management?
|
||||
- **Better Organization:** Group tasks by Status, Priority, or Phase for clearer project overview
|
||||
- **Increased Productivity:** Bulk operations let you update multiple tasks at once
|
||||
- **Intuitive Interface:** Drag-and-drop functionality makes task management feel natural
|
||||
- **Rich Task Display:** See progress, assignees, labels, and due dates at a glance
|
||||
- **Responsive Design:** Works seamlessly on desktop, tablet, and mobile devices
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Accessing Enhanced Task Management
|
||||
1. Navigate to your project workspace
|
||||
2. Look for the enhanced task view option in your project interface
|
||||
3. The system will display your tasks grouped by the current grouping method (default: Status)
|
||||
|
||||
### Understanding the Interface
|
||||
The enhanced task management interface consists of several key areas:
|
||||
|
||||
- **Header Controls:** Task count, grouping selector, and action buttons
|
||||
- **Task Groups:** Collapsible sections containing related tasks
|
||||
- **Individual Tasks:** Rich task cards with metadata and actions
|
||||
- **Bulk Action Bar:** Appears when multiple tasks are selected (blue bar)
|
||||
|
||||
## Task Grouping
|
||||
|
||||
### Available Grouping Options
|
||||
You can organize your tasks using three different grouping methods:
|
||||
|
||||
#### 1. Status Grouping (Default)
|
||||
Groups tasks by their current status:
|
||||
- **To Do:** Tasks not yet started
|
||||
- **Doing:** Tasks currently in progress
|
||||
- **Done:** Completed tasks
|
||||
|
||||
#### 2. Priority Grouping
|
||||
Groups tasks by their priority level:
|
||||
- **Critical:** Highest priority, urgent tasks
|
||||
- **High:** Important tasks requiring attention
|
||||
- **Medium:** Standard priority tasks
|
||||
- **Low:** Tasks that can be addressed later
|
||||
|
||||
#### 3. Phase Grouping
|
||||
Groups tasks by project phases:
|
||||
- **Planning:** Tasks in the planning stage
|
||||
- **Development:** Implementation and development tasks
|
||||
- **Testing:** Quality assurance and testing tasks
|
||||
- **Deployment:** Release and deployment tasks
|
||||
|
||||
### Switching Between Groupings
|
||||
1. Locate the "Group by" dropdown in the header controls
|
||||
2. Select your preferred grouping method (Status, Priority, or Phase)
|
||||
3. Tasks will automatically reorganize into the new groups
|
||||
4. Your grouping preference is saved for future sessions
|
||||
|
||||
### Group Features
|
||||
Each task group includes:
|
||||
- **Color-coded headers** with visual indicators
|
||||
- **Task count badges** showing the number of tasks in each group
|
||||
- **Progress indicators** showing completion percentage
|
||||
- **Collapse/expand functionality** to hide or show group contents
|
||||
- **Add task buttons** to quickly create tasks in specific groups
|
||||
|
||||
## Drag and Drop
|
||||
|
||||
### Moving Tasks Within Groups
|
||||
1. Hover over a task to reveal the drag handle (⋮⋮ icon)
|
||||
2. Click and hold the drag handle
|
||||
3. Drag the task to your desired position within the same group
|
||||
4. Release to drop the task in its new position
|
||||
|
||||
### Moving Tasks Between Groups
|
||||
1. Click and hold the drag handle on any task
|
||||
2. Drag the task over a different group
|
||||
3. The target group will highlight to show it can accept the task
|
||||
4. Release to drop the task into the new group
|
||||
5. The task's properties (status, priority, or phase) will automatically update
|
||||
|
||||
### Drag and Drop Benefits
|
||||
- **Instant Updates:** Task properties change automatically when moved between groups
|
||||
- **Visual Feedback:** Clear indicators show where tasks can be dropped
|
||||
- **Keyboard Accessible:** Alternative keyboard controls for accessibility
|
||||
- **Mobile Friendly:** Touch-friendly drag operations on mobile devices
|
||||
|
||||
## Multi-Select and Bulk Operations
|
||||
|
||||
### Selecting Tasks
|
||||
You can select multiple tasks using several methods:
|
||||
|
||||
#### Individual Selection
|
||||
- Click the checkbox next to any task to select it
|
||||
- Click again to deselect
|
||||
|
||||
#### Range Selection
|
||||
- Select the first task in your desired range
|
||||
- Hold Shift and click the last task in the range
|
||||
- All tasks between the first and last will be selected
|
||||
|
||||
#### Multiple Selection
|
||||
- Hold Ctrl (or Cmd on Mac) while clicking tasks
|
||||
- This allows you to select non-consecutive tasks
|
||||
|
||||
### Bulk Actions
|
||||
When you have tasks selected, a blue bulk action bar appears with these options:
|
||||
|
||||
#### Change Status (when not grouped by Status)
|
||||
- Update the status of all selected tasks at once
|
||||
- Choose from available status options in your project
|
||||
|
||||
#### Set Priority (when not grouped by Priority)
|
||||
- Assign the same priority level to all selected tasks
|
||||
- Options include Critical, High, Medium, and Low
|
||||
|
||||
#### More Actions
|
||||
Additional bulk operations include:
|
||||
- **Assign to Member:** Add team members to multiple tasks
|
||||
- **Add Labels:** Apply labels to selected tasks
|
||||
- **Archive Tasks:** Move multiple tasks to archive
|
||||
|
||||
#### Delete Tasks
|
||||
- Permanently remove multiple tasks at once
|
||||
- Confirmation dialog prevents accidental deletions
|
||||
|
||||
### Bulk Action Tips
|
||||
- The bulk action bar only shows relevant options based on your current grouping
|
||||
- You can clear your selection at any time using the "Clear" button
|
||||
- Bulk operations provide immediate feedback and can be undone if needed
|
||||
|
||||
## Task Display Features
|
||||
|
||||
### Rich Task Information
|
||||
Each task displays comprehensive information:
|
||||
|
||||
#### Basic Information
|
||||
- **Task Key:** Unique identifier (e.g., PROJ-123)
|
||||
- **Task Name:** Clear, descriptive title
|
||||
- **Description:** Additional details when available
|
||||
|
||||
#### Visual Indicators
|
||||
- **Progress Bar:** Shows completion percentage (0-100%)
|
||||
- **Priority Indicator:** Color-coded dot showing task importance
|
||||
- **Status Color:** Left border color indicates current status
|
||||
|
||||
#### Team and Collaboration
|
||||
- **Assignee Avatars:** Profile pictures of assigned team members (up to 3 visible)
|
||||
- **Labels:** Color-coded tags for categorization
|
||||
- **Comment Count:** Number of comments and discussions
|
||||
- **Attachment Count:** Number of files attached to the task
|
||||
|
||||
#### Timing Information
|
||||
- **Due Dates:** When tasks are scheduled to complete
|
||||
- Red text: Overdue tasks
|
||||
- Orange text: Due today or within 3 days
|
||||
- Gray text: Future due dates
|
||||
- **Time Tracking:** Estimated vs. logged time when available
|
||||
|
||||
### Subtask Support
|
||||
Tasks with subtasks include additional features:
|
||||
|
||||
#### Expanding Subtasks
|
||||
- Click the "+X" button next to task names to expand subtasks
|
||||
- Subtasks appear indented below the parent task
|
||||
- Click "−X" to collapse subtasks
|
||||
|
||||
#### Subtask Progress
|
||||
- Parent task progress reflects completion of all subtasks
|
||||
- Individual subtask progress is visible when expanded
|
||||
- Subtask counts show total number of child tasks
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Real-time Updates
|
||||
- Changes made by team members appear instantly
|
||||
- Live collaboration with multiple users
|
||||
- WebSocket connections ensure data synchronization
|
||||
|
||||
### Search and Filtering
|
||||
- Use existing project search and filter capabilities
|
||||
- Enhanced task management respects current filter settings
|
||||
- Search results maintain grouping organization
|
||||
|
||||
### Responsive Design
|
||||
The interface adapts to different screen sizes:
|
||||
|
||||
#### Desktop (Large Screens)
|
||||
- Full feature set with all metadata visible
|
||||
- Optimal drag-and-drop experience
|
||||
- Multi-column layouts where appropriate
|
||||
|
||||
#### Tablet (Medium Screens)
|
||||
- Condensed but functional interface
|
||||
- Touch-friendly interactions
|
||||
- Simplified metadata display
|
||||
|
||||
#### Mobile (Small Screens)
|
||||
- Stacked layout for easy navigation
|
||||
- Large touch targets for selections
|
||||
- Essential information prioritized
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Organizing Your Tasks
|
||||
1. **Choose the Right Grouping:** Select the grouping method that best fits your workflow
|
||||
2. **Use Labels Consistently:** Apply meaningful labels for better categorization
|
||||
3. **Keep Groups Balanced:** Avoid having too many tasks in a single group
|
||||
4. **Regular Maintenance:** Review and update task organization periodically
|
||||
|
||||
### Collaboration Tips
|
||||
1. **Clear Task Names:** Use descriptive titles that everyone understands
|
||||
2. **Proper Assignment:** Assign tasks to appropriate team members
|
||||
3. **Progress Updates:** Keep progress percentages current for accurate project tracking
|
||||
4. **Use Comments:** Communicate about tasks using the comment system
|
||||
|
||||
### Productivity Techniques
|
||||
1. **Batch Similar Operations:** Use bulk actions for efficiency
|
||||
2. **Prioritize Effectively:** Use priority grouping during planning phases
|
||||
3. **Track Progress:** Monitor completion rates using group progress indicators
|
||||
4. **Plan Ahead:** Use due dates and time estimates for better scheduling
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
### Navigation
|
||||
- **Tab:** Move focus between elements
|
||||
- **Enter:** Activate focused button or link
|
||||
- **Esc:** Close open dialogs or clear selections
|
||||
|
||||
### Selection
|
||||
- **Space:** Select/deselect focused task
|
||||
- **Shift + Click:** Range selection
|
||||
- **Ctrl + Click:** Multi-selection (Cmd + Click on Mac)
|
||||
|
||||
### Actions
|
||||
- **Delete:** Remove selected tasks (with confirmation)
|
||||
- **Ctrl + A:** Select all visible tasks (Cmd + A on Mac)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### Tasks Not Moving Between Groups
|
||||
- Ensure you have edit permissions for the tasks
|
||||
- Check that you're dragging from the drag handle (⋮⋮ icon)
|
||||
- Verify the target group allows the task type
|
||||
|
||||
#### Bulk Actions Not Working
|
||||
- Confirm tasks are actually selected (checkboxes checked)
|
||||
- Ensure you have appropriate permissions
|
||||
- Check that the action is available for your current grouping
|
||||
|
||||
#### Missing Task Information
|
||||
- Some metadata may be hidden on smaller screens
|
||||
- Try expanding to full screen or using desktop view
|
||||
- Check that task has the required information (assignees, labels, etc.)
|
||||
|
||||
### Performance Tips
|
||||
- For projects with hundreds of tasks, consider using filters to reduce visible tasks
|
||||
- Collapse groups you're not actively working with
|
||||
- Clear selections when not performing bulk operations
|
||||
|
||||
## Getting Help
|
||||
- Contact your workspace administrator for permission-related issues
|
||||
- Check the main WorkLenz documentation for general task management help
|
||||
- Report bugs or feature requests through your organization's support channels
|
||||
|
||||
## What's New
|
||||
This enhanced task management system builds on WorkLenz's solid foundation while adding:
|
||||
- Modern drag-and-drop interfaces
|
||||
- Flexible grouping options
|
||||
- Powerful bulk operation capabilities
|
||||
- Rich visual task displays
|
||||
- Mobile-responsive design
|
||||
- Improved accessibility features
|
||||
6
package-lock.json
generated
6
package-lock.json
generated
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "worklenz",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
@@ -73,8 +73,8 @@ cat > worklenz-backend/.env << EOL
|
||||
NODE_ENV=production
|
||||
PORT=3000
|
||||
SESSION_NAME=worklenz.sid
|
||||
SESSION_SECRET=$(openssl rand -base64 48)
|
||||
COOKIE_SECRET=$(openssl rand -base64 48)
|
||||
SESSION_SECRET=change_me_in_production
|
||||
COOKIE_SECRET=change_me_in_production
|
||||
|
||||
# CORS
|
||||
SOCKET_IO_CORS=${FRONTEND_URL}
|
||||
@@ -123,7 +123,7 @@ SLACK_WEBHOOK=
|
||||
COMMIT_BUILD_IMMEDIATELY=true
|
||||
|
||||
# JWT Secret
|
||||
JWT_SECRET=$(openssl rand -base64 48)
|
||||
JWT_SECRET=change_me_in_production
|
||||
EOL
|
||||
|
||||
echo "Environment configuration updated for ${HOSTNAME} with" $([ "$USE_SSL" = "true" ] && echo "HTTPS/WSS" || echo "HTTP/WS")
|
||||
@@ -138,4 +138,4 @@ echo "Frontend URL: ${FRONTEND_URL}"
|
||||
echo "API URL: ${HTTP_PREFIX}${HOSTNAME}:3000"
|
||||
echo "Socket URL: ${WS_PREFIX}${HOSTNAME}:3000"
|
||||
echo "MinIO Dashboard URL: ${MINIO_DASHBOARD_URL}"
|
||||
echo "CORS is configured to allow requests from: ${FRONTEND_URL}"
|
||||
echo "CORS is configured to allow requests from: ${FRONTEND_URL}"
|
||||
@@ -1,9 +1,5 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
build
|
||||
.scannerwork
|
||||
coverage
|
||||
.dockerignore
|
||||
.git
|
||||
*.md
|
||||
tests
|
||||
|
||||
|
||||
@@ -1,39 +1,26 @@
|
||||
# --- Stage 1: Build ---
|
||||
FROM node:20-slim AS builder
|
||||
|
||||
ARG RELEASE_VERSION
|
||||
RUN apt-get update && apt-get install -y \
|
||||
python3 \
|
||||
make \
|
||||
g++ \
|
||||
curl \
|
||||
postgresql-server-dev-all \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# Use the official Node.js 20 image as a base
|
||||
FROM node:20
|
||||
|
||||
# Create and set the working directory
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
# Install global dependencies
|
||||
RUN npm install -g ts-node typescript grunt grunt-cli
|
||||
|
||||
# Copy package.json and package-lock.json (if available)
|
||||
COPY package*.json ./
|
||||
|
||||
# Install app dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy the rest of the application code
|
||||
COPY . .
|
||||
|
||||
# Run the build script to compile TypeScript to JavaScript
|
||||
RUN npm run build
|
||||
|
||||
RUN echo "$RELEASE_VERSION" > release
|
||||
|
||||
# --- Stage 2: Production Image ---
|
||||
FROM node:20-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y libpq5 && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /usr/src/app/package*.json ./
|
||||
COPY --from=builder /usr/src/app/build ./build
|
||||
COPY --from=builder /usr/src/app/node_modules ./node_modules
|
||||
COPY --from=builder /usr/src/app/release ./release
|
||||
COPY --from=builder /usr/src/app/worklenz-email-templates ./worklenz-email-templates
|
||||
|
||||
|
||||
# Expose the port the app runs on
|
||||
EXPOSE 3000
|
||||
CMD ["node", "build/bin/www"]
|
||||
|
||||
# Start the application
|
||||
CMD ["npm", "start"]
|
||||
55
worklenz-backend/database/00-init-db.sh
Normal file
55
worklenz-backend/database/00-init-db.sh
Normal file
@@ -0,0 +1,55 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# This script controls the order of SQL file execution during database initialization
|
||||
echo "Starting database initialization..."
|
||||
|
||||
# Check if we have SQL files in expected locations
|
||||
if [ -f "/docker-entrypoint-initdb.d/sql/0_extensions.sql" ]; then
|
||||
SQL_DIR="/docker-entrypoint-initdb.d/sql"
|
||||
echo "Using SQL files from sql/ subdirectory"
|
||||
elif [ -f "/docker-entrypoint-initdb.d/0_extensions.sql" ]; then
|
||||
# First time setup - move files to subdirectory
|
||||
echo "Moving SQL files to sql/ subdirectory..."
|
||||
mkdir -p /docker-entrypoint-initdb.d/sql
|
||||
|
||||
# Move all SQL files (except this script) to the subdirectory
|
||||
for f in /docker-entrypoint-initdb.d/*.sql; do
|
||||
if [ -f "$f" ]; then
|
||||
cp "$f" /docker-entrypoint-initdb.d/sql/
|
||||
echo "Copied $f to sql/ subdirectory"
|
||||
fi
|
||||
done
|
||||
|
||||
SQL_DIR="/docker-entrypoint-initdb.d/sql"
|
||||
else
|
||||
echo "SQL files not found in expected locations!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Execute SQL files in the correct order
|
||||
echo "Executing 0_extensions.sql..."
|
||||
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -f "$SQL_DIR/0_extensions.sql"
|
||||
|
||||
echo "Executing 1_tables.sql..."
|
||||
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -f "$SQL_DIR/1_tables.sql"
|
||||
|
||||
echo "Executing indexes.sql..."
|
||||
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -f "$SQL_DIR/indexes.sql"
|
||||
|
||||
echo "Executing 4_functions.sql..."
|
||||
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -f "$SQL_DIR/4_functions.sql"
|
||||
|
||||
echo "Executing triggers.sql..."
|
||||
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -f "$SQL_DIR/triggers.sql"
|
||||
|
||||
echo "Executing 3_views.sql..."
|
||||
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -f "$SQL_DIR/3_views.sql"
|
||||
|
||||
echo "Executing 2_dml.sql..."
|
||||
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -f "$SQL_DIR/2_dml.sql"
|
||||
|
||||
echo "Executing 5_database_user.sql..."
|
||||
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -f "$SQL_DIR/5_database_user.sql"
|
||||
|
||||
echo "Database initialization completed successfully"
|
||||
@@ -1,88 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "Starting database initialization..."
|
||||
|
||||
SQL_DIR="/docker-entrypoint-initdb.d/sql"
|
||||
MIGRATIONS_DIR="/docker-entrypoint-initdb.d/migrations"
|
||||
BACKUP_DIR="/docker-entrypoint-initdb.d/pg_backups"
|
||||
|
||||
# --------------------------------------------
|
||||
# 🗄️ STEP 1: Attempt to restore latest backup
|
||||
# --------------------------------------------
|
||||
|
||||
if [ -d "$BACKUP_DIR" ]; then
|
||||
LATEST_BACKUP=$(ls -t "$BACKUP_DIR"/*.sql 2>/dev/null | head -n 1)
|
||||
else
|
||||
LATEST_BACKUP=""
|
||||
fi
|
||||
|
||||
if [ -f "$LATEST_BACKUP" ]; then
|
||||
echo "🗄️ Found latest backup: $LATEST_BACKUP"
|
||||
echo "⏳ Restoring from backup..."
|
||||
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" < "$LATEST_BACKUP"
|
||||
echo "✅ Backup restoration complete. Skipping schema and migrations."
|
||||
exit 0
|
||||
else
|
||||
echo "ℹ️ No valid backup found. Proceeding with base schema and migrations."
|
||||
fi
|
||||
|
||||
# --------------------------------------------
|
||||
# 🏗️ STEP 2: Continue with base schema setup
|
||||
# --------------------------------------------
|
||||
|
||||
# Create migrations table if it doesn't exist
|
||||
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version TEXT PRIMARY KEY,
|
||||
applied_at TIMESTAMP DEFAULT now()
|
||||
);
|
||||
"
|
||||
|
||||
# List of base schema files to execute in order
|
||||
BASE_SQL_FILES=(
|
||||
"0_extensions.sql"
|
||||
"1_tables.sql"
|
||||
"indexes.sql"
|
||||
"4_functions.sql"
|
||||
"triggers.sql"
|
||||
"3_views.sql"
|
||||
"2_dml.sql"
|
||||
"5_database_user.sql"
|
||||
)
|
||||
|
||||
echo "Running base schema SQL files in order..."
|
||||
|
||||
for file in "${BASE_SQL_FILES[@]}"; do
|
||||
full_path="$SQL_DIR/$file"
|
||||
if [ -f "$full_path" ]; then
|
||||
echo "Executing $file..."
|
||||
psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" -f "$full_path"
|
||||
else
|
||||
echo "WARNING: $file not found, skipping."
|
||||
fi
|
||||
done
|
||||
|
||||
echo "✅ Base schema SQL execution complete."
|
||||
|
||||
# --------------------------------------------
|
||||
# 🚀 STEP 3: Apply SQL migrations
|
||||
# --------------------------------------------
|
||||
|
||||
if [ -d "$MIGRATIONS_DIR" ] && compgen -G "$MIGRATIONS_DIR/*.sql" > /dev/null; then
|
||||
echo "Applying migrations..."
|
||||
for f in "$MIGRATIONS_DIR"/*.sql; do
|
||||
version=$(basename "$f")
|
||||
if ! psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAc "SELECT 1 FROM schema_migrations WHERE version = '$version'" | grep -q 1; then
|
||||
echo "Applying migration: $version"
|
||||
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -f "$f"
|
||||
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "INSERT INTO schema_migrations (version) VALUES ('$version');"
|
||||
else
|
||||
echo "Skipping already applied migration: $version"
|
||||
fi
|
||||
done
|
||||
else
|
||||
echo "No migration files found or directory is empty, skipping migrations."
|
||||
fi
|
||||
|
||||
echo "🎉 Database initialization completed successfully."
|
||||
@@ -0,0 +1,228 @@
|
||||
-- Migration: Add recursive task estimation functionality
|
||||
-- This migration adds a function to calculate recursive task estimation including all subtasks
|
||||
-- and modifies the get_task_form_view_model function to include this data
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Function to calculate recursive task estimation (including all subtasks)
|
||||
CREATE OR REPLACE FUNCTION get_task_recursive_estimation(_task_id UUID) RETURNS JSON
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_result JSON;
|
||||
_has_subtasks BOOLEAN;
|
||||
BEGIN
|
||||
-- First check if this task has any subtasks
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM tasks
|
||||
WHERE parent_task_id = _task_id
|
||||
AND archived = false
|
||||
) INTO _has_subtasks;
|
||||
|
||||
-- If task has subtasks, calculate recursive estimation excluding parent's own estimation
|
||||
IF _has_subtasks THEN
|
||||
WITH RECURSIVE task_tree AS (
|
||||
-- Start with direct subtasks only (exclude the parent task itself)
|
||||
SELECT
|
||||
id,
|
||||
parent_task_id,
|
||||
COALESCE(total_minutes, 0) as total_minutes,
|
||||
1 as level -- Start at level 1 (subtasks)
|
||||
FROM tasks
|
||||
WHERE parent_task_id = _task_id
|
||||
AND archived = false
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Recursive case: Get all descendant tasks
|
||||
SELECT
|
||||
t.id,
|
||||
t.parent_task_id,
|
||||
COALESCE(t.total_minutes, 0) as total_minutes,
|
||||
tt.level + 1 as level
|
||||
FROM tasks t
|
||||
INNER JOIN task_tree tt ON t.parent_task_id = tt.id
|
||||
WHERE t.archived = false
|
||||
),
|
||||
task_counts AS (
|
||||
SELECT
|
||||
COUNT(*) as sub_tasks_count,
|
||||
SUM(total_minutes) as subtasks_total_minutes -- Sum all subtask estimations
|
||||
FROM task_tree
|
||||
)
|
||||
SELECT JSON_BUILD_OBJECT(
|
||||
'sub_tasks_count', COALESCE(tc.sub_tasks_count, 0),
|
||||
'own_total_minutes', 0, -- Always 0 for parent tasks
|
||||
'subtasks_total_minutes', COALESCE(tc.subtasks_total_minutes, 0),
|
||||
'recursive_total_minutes', COALESCE(tc.subtasks_total_minutes, 0), -- Only subtasks total
|
||||
'recursive_total_hours', FLOOR(COALESCE(tc.subtasks_total_minutes, 0) / 60),
|
||||
'recursive_remaining_minutes', COALESCE(tc.subtasks_total_minutes, 0) % 60
|
||||
)
|
||||
INTO _result
|
||||
FROM task_counts tc;
|
||||
ELSE
|
||||
-- If task has no subtasks, use its own estimation
|
||||
SELECT JSON_BUILD_OBJECT(
|
||||
'sub_tasks_count', 0,
|
||||
'own_total_minutes', COALESCE(total_minutes, 0),
|
||||
'subtasks_total_minutes', 0,
|
||||
'recursive_total_minutes', COALESCE(total_minutes, 0), -- Use own estimation
|
||||
'recursive_total_hours', FLOOR(COALESCE(total_minutes, 0) / 60),
|
||||
'recursive_remaining_minutes', COALESCE(total_minutes, 0) % 60
|
||||
)
|
||||
INTO _result
|
||||
FROM tasks
|
||||
WHERE id = _task_id;
|
||||
END IF;
|
||||
|
||||
RETURN COALESCE(_result, JSON_BUILD_OBJECT(
|
||||
'sub_tasks_count', 0,
|
||||
'own_total_minutes', 0,
|
||||
'subtasks_total_minutes', 0,
|
||||
'recursive_total_minutes', 0,
|
||||
'recursive_total_hours', 0,
|
||||
'recursive_remaining_minutes', 0
|
||||
));
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Update the get_task_form_view_model function to include recursive estimation
|
||||
CREATE OR REPLACE FUNCTION public.get_task_form_view_model(_user_id UUID, _team_id UUID, _task_id UUID, _project_id UUID) RETURNS JSON
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_task JSON;
|
||||
_priorities JSON;
|
||||
_projects JSON;
|
||||
_statuses JSON;
|
||||
_team_members JSON;
|
||||
_assignees JSON;
|
||||
_phases JSON;
|
||||
BEGIN
|
||||
|
||||
-- Select task info
|
||||
SELECT COALESCE(ROW_TO_JSON(rec), '{}'::JSON)
|
||||
INTO _task
|
||||
FROM (WITH RECURSIVE task_hierarchy AS (
|
||||
-- Base case: Start with the given task
|
||||
SELECT id,
|
||||
parent_task_id,
|
||||
0 AS level
|
||||
FROM tasks
|
||||
WHERE id = _task_id
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Recursive case: Traverse up to parent tasks
|
||||
SELECT t.id,
|
||||
t.parent_task_id,
|
||||
th.level + 1 AS level
|
||||
FROM tasks t
|
||||
INNER JOIN task_hierarchy th ON t.id = th.parent_task_id
|
||||
WHERE th.parent_task_id IS NOT NULL)
|
||||
SELECT id,
|
||||
name,
|
||||
description,
|
||||
start_date,
|
||||
end_date,
|
||||
done,
|
||||
total_minutes,
|
||||
priority_id,
|
||||
project_id,
|
||||
created_at,
|
||||
updated_at,
|
||||
status_id,
|
||||
parent_task_id,
|
||||
sort_order,
|
||||
(SELECT phase_id FROM task_phase WHERE task_id = tasks.id) AS phase_id,
|
||||
CONCAT((SELECT key FROM projects WHERE id = tasks.project_id), '-', task_no) AS task_key,
|
||||
(SELECT start_time
|
||||
FROM task_timers
|
||||
WHERE task_id = tasks.id
|
||||
AND user_id = _user_id) AS timer_start_time,
|
||||
parent_task_id IS NOT NULL AS is_sub_task,
|
||||
(SELECT COUNT('*')
|
||||
FROM tasks
|
||||
WHERE parent_task_id = tasks.id
|
||||
AND archived IS FALSE) AS sub_tasks_count,
|
||||
(SELECT COUNT(*)
|
||||
FROM tasks_with_status_view tt
|
||||
WHERE (tt.parent_task_id = tasks.id OR tt.task_id = tasks.id)
|
||||
AND tt.is_done IS TRUE)
|
||||
AS completed_count,
|
||||
(SELECT COUNT(*) FROM task_attachments WHERE task_id = tasks.id) AS attachments_count,
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(r))), '[]'::JSON)
|
||||
FROM (SELECT task_labels.label_id AS id,
|
||||
(SELECT name FROM team_labels WHERE id = task_labels.label_id),
|
||||
(SELECT color_code FROM team_labels WHERE id = task_labels.label_id)
|
||||
FROM task_labels
|
||||
WHERE task_id = tasks.id
|
||||
ORDER BY name) r) AS labels,
|
||||
(SELECT color_code
|
||||
FROM sys_task_status_categories
|
||||
WHERE id = (SELECT category_id FROM task_statuses WHERE id = tasks.status_id)) AS status_color,
|
||||
(SELECT COUNT(*) FROM tasks WHERE parent_task_id = _task_id) AS sub_tasks_count,
|
||||
(SELECT name FROM users WHERE id = tasks.reporter_id) AS reporter,
|
||||
(SELECT get_task_assignees(tasks.id)) AS assignees,
|
||||
(SELECT id FROM team_members WHERE user_id = _user_id AND team_id = _team_id) AS team_member_id,
|
||||
billable,
|
||||
schedule_id,
|
||||
progress_value,
|
||||
weight,
|
||||
(SELECT MAX(level) FROM task_hierarchy) AS task_level,
|
||||
(SELECT get_task_recursive_estimation(tasks.id)) AS recursive_estimation
|
||||
FROM tasks
|
||||
WHERE id = _task_id) rec;
|
||||
|
||||
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
INTO _priorities
|
||||
FROM (SELECT id, name FROM task_priorities ORDER BY value) rec;
|
||||
|
||||
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
INTO _phases
|
||||
FROM (SELECT id, name FROM project_phases WHERE project_id = _project_id ORDER BY name) rec;
|
||||
|
||||
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
INTO _projects
|
||||
FROM (SELECT id, name
|
||||
FROM projects
|
||||
WHERE team_id = _team_id
|
||||
AND (CASE
|
||||
WHEN (is_owner(_user_id, _team_id) OR is_admin(_user_id, _team_id) IS TRUE) THEN TRUE
|
||||
ELSE is_member_of_project(projects.id, _user_id, _team_id) END)
|
||||
ORDER BY name) rec;
|
||||
|
||||
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
INTO _statuses
|
||||
FROM (SELECT id, name FROM task_statuses WHERE project_id = _project_id) rec;
|
||||
|
||||
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
INTO _team_members
|
||||
FROM (SELECT team_members.id,
|
||||
(SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = team_members.id),
|
||||
(SELECT email FROM team_member_info_view WHERE team_member_info_view.team_member_id = team_members.id),
|
||||
(SELECT avatar_url
|
||||
FROM team_member_info_view
|
||||
WHERE team_member_info_view.team_member_id = team_members.id)
|
||||
FROM team_members
|
||||
LEFT JOIN users u ON team_members.user_id = u.id
|
||||
WHERE team_id = _team_id
|
||||
AND team_members.active IS TRUE) rec;
|
||||
|
||||
SELECT get_task_assignees(_task_id) INTO _assignees;
|
||||
|
||||
RETURN JSON_BUILD_OBJECT(
|
||||
'task', _task,
|
||||
'priorities', _priorities,
|
||||
'projects', _projects,
|
||||
'statuses', _statuses,
|
||||
'team_members', _team_members,
|
||||
'assignees', _assignees,
|
||||
'phases', _phases
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
@@ -1,135 +0,0 @@
|
||||
-- Performance indexes for optimized tasks queries
|
||||
-- Migration: 20250115000000-performance-indexes.sql
|
||||
|
||||
-- Composite index for main task filtering
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_project_archived_parent
|
||||
ON tasks(project_id, archived, parent_task_id)
|
||||
WHERE archived = FALSE;
|
||||
|
||||
-- Index for status joins
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_status_project
|
||||
ON tasks(status_id, project_id)
|
||||
WHERE archived = FALSE;
|
||||
|
||||
-- Index for assignees lookup
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_assignees_task_member
|
||||
ON tasks_assignees(task_id, team_member_id);
|
||||
|
||||
-- Index for phase lookup
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_phase_task_phase
|
||||
ON task_phase(task_id, phase_id);
|
||||
|
||||
-- Index for subtask counting
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_parent_archived
|
||||
ON tasks(parent_task_id, archived)
|
||||
WHERE parent_task_id IS NOT NULL AND archived = FALSE;
|
||||
|
||||
-- Index for labels
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_labels_task_label
|
||||
ON task_labels(task_id, label_id);
|
||||
|
||||
-- Index for comments count
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_comments_task
|
||||
ON task_comments(task_id);
|
||||
|
||||
-- Index for attachments count
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_attachments_task
|
||||
ON task_attachments(task_id);
|
||||
|
||||
-- Index for work log aggregation
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_work_log_task
|
||||
ON task_work_log(task_id);
|
||||
|
||||
-- Index for subscribers check
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_subscribers_task
|
||||
ON task_subscribers(task_id);
|
||||
|
||||
-- Index for dependencies check
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_dependencies_task
|
||||
ON task_dependencies(task_id);
|
||||
|
||||
-- Index for timers lookup
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_timers_task_user
|
||||
ON task_timers(task_id, user_id);
|
||||
|
||||
-- Index for custom columns
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_cc_column_values_task
|
||||
ON cc_column_values(task_id);
|
||||
|
||||
-- Index for team member info view optimization
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_team_members_team_user
|
||||
ON team_members(team_id, user_id)
|
||||
WHERE active = TRUE;
|
||||
|
||||
-- Index for notification settings
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_notification_settings_user_team
|
||||
ON notification_settings(user_id, team_id);
|
||||
|
||||
-- Index for task status categories
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_statuses_category
|
||||
ON task_statuses(category_id, project_id);
|
||||
|
||||
-- Index for project phases
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_project_phases_project_sort
|
||||
ON project_phases(project_id, sort_index);
|
||||
|
||||
-- Index for task priorities
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_priorities_value
|
||||
ON task_priorities(value);
|
||||
|
||||
-- Index for team labels
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_team_labels_team
|
||||
ON team_labels(team_id);
|
||||
|
||||
-- NEW INDEXES FOR PERFORMANCE OPTIMIZATION --
|
||||
|
||||
-- Composite index for task main query optimization (covers most WHERE conditions)
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_performance_main
|
||||
ON tasks(project_id, archived, parent_task_id, status_id, priority_id)
|
||||
WHERE archived = FALSE;
|
||||
|
||||
-- Index for sorting by sort_order with project filter
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_project_sort_order
|
||||
ON tasks(project_id, sort_order)
|
||||
WHERE archived = FALSE;
|
||||
|
||||
-- Index for email_invitations to optimize team_member_info_view
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_email_invitations_team_member
|
||||
ON email_invitations(team_member_id);
|
||||
|
||||
-- Covering index for task status with category information
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_statuses_covering
|
||||
ON task_statuses(id, category_id, project_id);
|
||||
|
||||
-- Index for task aggregation queries (parent task progress calculation)
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_parent_status_archived
|
||||
ON tasks(parent_task_id, status_id, archived)
|
||||
WHERE archived = FALSE;
|
||||
|
||||
-- Index for project team member filtering
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_team_members_project_lookup
|
||||
ON team_members(team_id, active, user_id)
|
||||
WHERE active = TRUE;
|
||||
|
||||
-- Covering index for tasks with frequently accessed columns
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_covering_main
|
||||
ON tasks(id, project_id, archived, parent_task_id, status_id, priority_id, sort_order, name)
|
||||
WHERE archived = FALSE;
|
||||
|
||||
-- Index for task search functionality
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_name_search
|
||||
ON tasks USING gin(to_tsvector('english', name))
|
||||
WHERE archived = FALSE;
|
||||
|
||||
-- Index for date-based filtering (if used)
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_dates
|
||||
ON tasks(project_id, start_date, end_date)
|
||||
WHERE archived = FALSE;
|
||||
|
||||
-- Index for task timers with user filtering
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_timers_user_task
|
||||
ON task_timers(user_id, task_id);
|
||||
|
||||
-- Index for sys_task_status_categories lookups
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_sys_task_status_categories_covering
|
||||
ON sys_task_status_categories(id, color_code, color_code_dark, is_done, is_doing, is_todo);
|
||||
@@ -0,0 +1,20 @@
|
||||
-- Migration: Add currency column to projects table
|
||||
-- Date: 2025-01-17
|
||||
-- Description: Adds project-specific currency support to allow different projects to use different currencies
|
||||
|
||||
-- Add currency column to projects table
|
||||
ALTER TABLE projects
|
||||
ADD COLUMN IF NOT EXISTS currency VARCHAR(3) DEFAULT 'USD';
|
||||
|
||||
-- Add comment for documentation
|
||||
COMMENT ON COLUMN projects.currency IS 'Project-specific currency code (e.g., USD, EUR, GBP, JPY, etc.)';
|
||||
|
||||
-- Add constraint to ensure currency codes are uppercase and 3 characters
|
||||
ALTER TABLE projects
|
||||
ADD CONSTRAINT projects_currency_format_check
|
||||
CHECK (currency ~ '^[A-Z]{3}$');
|
||||
|
||||
-- Update existing projects to have a default currency if they don't have one
|
||||
UPDATE projects
|
||||
SET currency = 'USD'
|
||||
WHERE currency IS NULL;
|
||||
@@ -603,7 +603,8 @@ BEGIN
|
||||
schedule_id,
|
||||
progress_value,
|
||||
weight,
|
||||
(SELECT MAX(level) FROM task_hierarchy) AS task_level
|
||||
(SELECT MAX(level) FROM task_hierarchy) AS task_level,
|
||||
(SELECT get_task_recursive_estimation(tasks.id)) AS recursive_estimation
|
||||
FROM tasks
|
||||
WHERE id = _task_id) rec;
|
||||
|
||||
@@ -662,6 +663,89 @@ ADD COLUMN IF NOT EXISTS use_manual_progress BOOLEAN DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS use_weighted_progress BOOLEAN DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS use_time_progress BOOLEAN DEFAULT FALSE;
|
||||
|
||||
-- Function to calculate recursive task estimation (including all subtasks)
|
||||
CREATE OR REPLACE FUNCTION get_task_recursive_estimation(_task_id UUID) RETURNS JSON
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_result JSON;
|
||||
_has_subtasks BOOLEAN;
|
||||
BEGIN
|
||||
-- First check if this task has any subtasks
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM tasks
|
||||
WHERE parent_task_id = _task_id
|
||||
AND archived = false
|
||||
) INTO _has_subtasks;
|
||||
|
||||
-- If task has subtasks, calculate recursive estimation excluding parent's own estimation
|
||||
IF _has_subtasks THEN
|
||||
WITH RECURSIVE task_tree AS (
|
||||
-- Start with direct subtasks only (exclude the parent task itself)
|
||||
SELECT
|
||||
id,
|
||||
parent_task_id,
|
||||
COALESCE(total_minutes, 0) as total_minutes,
|
||||
1 as level -- Start at level 1 (subtasks)
|
||||
FROM tasks
|
||||
WHERE parent_task_id = _task_id
|
||||
AND archived = false
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Recursive case: Get all descendant tasks
|
||||
SELECT
|
||||
t.id,
|
||||
t.parent_task_id,
|
||||
COALESCE(t.total_minutes, 0) as total_minutes,
|
||||
tt.level + 1 as level
|
||||
FROM tasks t
|
||||
INNER JOIN task_tree tt ON t.parent_task_id = tt.id
|
||||
WHERE t.archived = false
|
||||
),
|
||||
task_counts AS (
|
||||
SELECT
|
||||
COUNT(*) as sub_tasks_count,
|
||||
SUM(total_minutes) as subtasks_total_minutes -- Sum all subtask estimations
|
||||
FROM task_tree
|
||||
)
|
||||
SELECT JSON_BUILD_OBJECT(
|
||||
'sub_tasks_count', COALESCE(tc.sub_tasks_count, 0),
|
||||
'own_total_minutes', 0, -- Always 0 for parent tasks
|
||||
'subtasks_total_minutes', COALESCE(tc.subtasks_total_minutes, 0),
|
||||
'recursive_total_minutes', COALESCE(tc.subtasks_total_minutes, 0), -- Only subtasks total
|
||||
'recursive_total_hours', FLOOR(COALESCE(tc.subtasks_total_minutes, 0) / 60),
|
||||
'recursive_remaining_minutes', COALESCE(tc.subtasks_total_minutes, 0) % 60
|
||||
)
|
||||
INTO _result
|
||||
FROM task_counts tc;
|
||||
ELSE
|
||||
-- If task has no subtasks, use its own estimation
|
||||
SELECT JSON_BUILD_OBJECT(
|
||||
'sub_tasks_count', 0,
|
||||
'own_total_minutes', COALESCE(total_minutes, 0),
|
||||
'subtasks_total_minutes', 0,
|
||||
'recursive_total_minutes', COALESCE(total_minutes, 0), -- Use own estimation
|
||||
'recursive_total_hours', FLOOR(COALESCE(total_minutes, 0) / 60),
|
||||
'recursive_remaining_minutes', COALESCE(total_minutes, 0) % 60
|
||||
)
|
||||
INTO _result
|
||||
FROM tasks
|
||||
WHERE id = _task_id;
|
||||
END IF;
|
||||
|
||||
RETURN COALESCE(_result, JSON_BUILD_OBJECT(
|
||||
'sub_tasks_count', 0,
|
||||
'own_total_minutes', 0,
|
||||
'subtasks_total_minutes', 0,
|
||||
'recursive_total_minutes', 0,
|
||||
'recursive_total_hours', 0,
|
||||
'recursive_remaining_minutes', 0
|
||||
));
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Add a trigger to reset manual progress when a task gets a new subtask
|
||||
CREATE OR REPLACE FUNCTION reset_parent_task_manual_progress() RETURNS TRIGGER AS
|
||||
$$
|
||||
@@ -677,6 +761,22 @@ BEGIN
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Add a trigger to reset parent task estimation when it gets subtasks
|
||||
CREATE OR REPLACE FUNCTION reset_parent_task_estimation() RETURNS TRIGGER AS
|
||||
$$
|
||||
BEGIN
|
||||
-- When a task gets a new subtask (parent_task_id is set), reset the parent's total_minutes to 0
|
||||
-- This ensures parent tasks don't have their own estimation when they have subtasks
|
||||
IF NEW.parent_task_id IS NOT NULL THEN
|
||||
UPDATE tasks
|
||||
SET total_minutes = 0
|
||||
WHERE id = NEW.parent_task_id
|
||||
AND total_minutes > 0;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create the trigger on the tasks table
|
||||
DROP TRIGGER IF EXISTS reset_parent_manual_progress_trigger ON tasks;
|
||||
CREATE TRIGGER reset_parent_manual_progress_trigger
|
||||
@@ -684,4 +784,35 @@ AFTER INSERT OR UPDATE OF parent_task_id ON tasks
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION reset_parent_task_manual_progress();
|
||||
|
||||
-- Create the trigger to reset parent task estimation
|
||||
DROP TRIGGER IF EXISTS reset_parent_estimation_trigger ON tasks;
|
||||
CREATE TRIGGER reset_parent_estimation_trigger
|
||||
AFTER INSERT OR UPDATE OF parent_task_id ON tasks
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION reset_parent_task_estimation();
|
||||
|
||||
-- Function to reset all existing parent tasks' estimations to 0
|
||||
CREATE OR REPLACE FUNCTION reset_all_parent_task_estimations() RETURNS INTEGER AS
|
||||
$$
|
||||
DECLARE
|
||||
_updated_count INTEGER;
|
||||
BEGIN
|
||||
-- Update all tasks that have subtasks to have 0 estimation
|
||||
UPDATE tasks
|
||||
SET total_minutes = 0
|
||||
WHERE id IN (
|
||||
SELECT DISTINCT parent_task_id
|
||||
FROM tasks
|
||||
WHERE parent_task_id IS NOT NULL
|
||||
AND archived = false
|
||||
)
|
||||
AND total_minutes > 0
|
||||
AND archived = false;
|
||||
|
||||
GET DIAGNOSTICS _updated_count = ROW_COUNT;
|
||||
|
||||
RETURN _updated_count;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
COMMIT;
|
||||
@@ -145,7 +145,7 @@ BEGIN
|
||||
SET progress_value = NULL,
|
||||
progress_mode = NULL
|
||||
WHERE project_id = _project_id
|
||||
AND progress_mode::text::progress_mode_type = _old_mode;
|
||||
AND progress_mode = _old_mode;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
-- Dropping existing finance_rate_cards table
|
||||
DROP TABLE IF EXISTS finance_rate_cards;
|
||||
-- Creating table to store rate card details
|
||||
CREATE TABLE finance_rate_cards
|
||||
(
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
team_id UUID NOT NULL REFERENCES teams (id) ON DELETE CASCADE,
|
||||
name VARCHAR NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Dropping existing finance_project_rate_card_roles table
|
||||
DROP TABLE IF EXISTS finance_project_rate_card_roles CASCADE;
|
||||
-- Creating table with single id primary key
|
||||
CREATE TABLE finance_project_rate_card_roles
|
||||
(
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
project_id UUID NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
|
||||
job_title_id UUID NOT NULL REFERENCES job_titles (id) ON DELETE CASCADE,
|
||||
rate DECIMAL(10, 2) NOT NULL CHECK (rate >= 0),
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT unique_project_role UNIQUE (project_id, job_title_id)
|
||||
);
|
||||
|
||||
-- Dropping existing finance_rate_card_roles table
|
||||
DROP TABLE IF EXISTS finance_rate_card_roles;
|
||||
-- Creating table to store role-specific rates for rate cards
|
||||
CREATE TABLE finance_rate_card_roles
|
||||
(
|
||||
rate_card_id UUID NOT NULL REFERENCES finance_rate_cards (id) ON DELETE CASCADE,
|
||||
job_title_id UUID REFERENCES job_titles(id) ON DELETE SET NULL,
|
||||
rate DECIMAL(10, 2) NOT NULL CHECK (rate >= 0),
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Adding project_rate_card_role_id column to project_members
|
||||
ALTER TABLE project_members
|
||||
ADD COLUMN project_rate_card_role_id UUID REFERENCES finance_project_rate_card_roles(id) ON DELETE SET NULL;
|
||||
|
||||
-- Adding rate_card column to projects
|
||||
ALTER TABLE projects
|
||||
ADD COLUMN rate_card UUID REFERENCES finance_rate_cards(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE finance_rate_cards
|
||||
ADD COLUMN currency TEXT NOT NULL DEFAULT 'USD';
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Add fixed_cost column to tasks table for project finance functionality
|
||||
ALTER TABLE tasks
|
||||
ADD COLUMN fixed_cost DECIMAL(10, 2) DEFAULT 0 CHECK (fixed_cost >= 0);
|
||||
|
||||
-- Add comment to explain the column
|
||||
COMMENT ON COLUMN tasks.fixed_cost IS 'Fixed cost for the task in addition to hourly rate calculations';
|
||||
@@ -118,7 +118,7 @@ BEGIN
|
||||
SELECT SUM(time_spent)
|
||||
FROM task_work_log
|
||||
WHERE task_id = t.id
|
||||
), 0) as logged_minutes
|
||||
), 0) / 60.0 as logged_minutes
|
||||
FROM tasks t
|
||||
WHERE t.id = _task_id
|
||||
)
|
||||
|
||||
@@ -12,7 +12,10 @@ CREATE TYPE DEPENDENCY_TYPE AS ENUM ('blocked_by');
|
||||
|
||||
CREATE TYPE SCHEDULE_TYPE AS ENUM ('daily', 'weekly', 'yearly', 'monthly', 'every_x_days', 'every_x_weeks', 'every_x_months');
|
||||
|
||||
CREATE TYPE LANGUAGE_TYPE AS ENUM ('en', 'es', 'pt', 'alb', 'de', 'zh_cn');
|
||||
CREATE TYPE LANGUAGE_TYPE AS ENUM ('en', 'es', 'pt');
|
||||
|
||||
-- Add progress mode type for tasks progress tracking
|
||||
CREATE TYPE PROGRESS_MODE_TYPE AS ENUM ('manual', 'weighted', 'time', 'default');
|
||||
|
||||
-- START: Users
|
||||
CREATE SEQUENCE IF NOT EXISTS users_user_no_seq START 1;
|
||||
@@ -777,9 +780,15 @@ CREATE TABLE IF NOT EXISTS projects (
|
||||
estimated_man_days INTEGER DEFAULT 0,
|
||||
hours_per_day INTEGER DEFAULT 8,
|
||||
health_id UUID,
|
||||
estimated_working_days INTEGER DEFAULT 0
|
||||
estimated_working_days INTEGER DEFAULT 0,
|
||||
use_manual_progress BOOLEAN DEFAULT FALSE,
|
||||
use_weighted_progress BOOLEAN DEFAULT FALSE,
|
||||
use_time_progress BOOLEAN DEFAULT FALSE,
|
||||
currency VARCHAR(3) DEFAULT 'USD'
|
||||
);
|
||||
|
||||
COMMENT ON COLUMN projects.currency IS 'Project-specific currency code (e.g., USD, EUR, GBP, JPY, etc.)';
|
||||
|
||||
ALTER TABLE projects
|
||||
ADD CONSTRAINT projects_pk
|
||||
PRIMARY KEY (id);
|
||||
@@ -1411,9 +1420,16 @@ CREATE TABLE IF NOT EXISTS tasks (
|
||||
sort_order INTEGER DEFAULT 0 NOT NULL,
|
||||
roadmap_sort_order INTEGER DEFAULT 0 NOT NULL,
|
||||
billable BOOLEAN DEFAULT TRUE,
|
||||
schedule_id UUID
|
||||
schedule_id UUID,
|
||||
manual_progress BOOLEAN DEFAULT FALSE,
|
||||
progress_value INTEGER DEFAULT NULL,
|
||||
progress_mode PROGRESS_MODE_TYPE DEFAULT 'default',
|
||||
weight INTEGER DEFAULT NULL,
|
||||
fixed_cost DECIMAL(10, 2) DEFAULT 0 CHECK (fixed_cost >= 0)
|
||||
);
|
||||
|
||||
COMMENT ON COLUMN tasks.fixed_cost IS 'Fixed cost for the task in addition to hourly rate calculations';
|
||||
|
||||
ALTER TABLE tasks
|
||||
ADD CONSTRAINT tasks_pk
|
||||
PRIMARY KEY (id);
|
||||
@@ -2279,3 +2295,37 @@ ALTER TABLE organization_working_days
|
||||
ALTER TABLE organization_working_days
|
||||
ADD CONSTRAINT org_organization_id_fk
|
||||
FOREIGN KEY (organization_id) REFERENCES organizations;
|
||||
|
||||
-- Finance module tables
|
||||
CREATE TABLE IF NOT EXISTS finance_rate_cards (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
team_id UUID NOT NULL REFERENCES teams (id) ON DELETE CASCADE,
|
||||
name VARCHAR NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
currency TEXT NOT NULL DEFAULT 'USD'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS finance_project_rate_card_roles (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
project_id UUID NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
|
||||
job_title_id UUID NOT NULL REFERENCES job_titles (id) ON DELETE CASCADE,
|
||||
rate DECIMAL(10, 2) NOT NULL CHECK (rate >= 0),
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT unique_project_role UNIQUE (project_id, job_title_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS finance_rate_card_roles (
|
||||
rate_card_id UUID NOT NULL REFERENCES finance_rate_cards (id) ON DELETE CASCADE,
|
||||
job_title_id UUID REFERENCES job_titles(id) ON DELETE SET NULL,
|
||||
rate DECIMAL(10, 2) NOT NULL CHECK (rate >= 0),
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
ALTER TABLE project_members
|
||||
ADD COLUMN IF NOT EXISTS project_rate_card_role_id UUID REFERENCES finance_project_rate_card_roles(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE projects
|
||||
ADD COLUMN IF NOT EXISTS rate_card UUID REFERENCES finance_rate_cards(id) ON DELETE SET NULL;
|
||||
|
||||
@@ -32,37 +32,3 @@ SELECT u.avatar_url,
|
||||
FROM team_members
|
||||
LEFT JOIN users u ON team_members.user_id = u.id;
|
||||
|
||||
-- PERFORMANCE OPTIMIZATION: Create materialized view for team member info
|
||||
-- This pre-calculates the expensive joins and subqueries from team_member_info_view
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS team_member_info_mv AS
|
||||
SELECT
|
||||
u.avatar_url,
|
||||
COALESCE(u.email, ei.email) AS email,
|
||||
COALESCE(u.name, ei.name) AS name,
|
||||
u.id AS user_id,
|
||||
tm.id AS team_member_id,
|
||||
tm.team_id,
|
||||
tm.active,
|
||||
u.socket_id
|
||||
FROM team_members tm
|
||||
LEFT JOIN users u ON tm.user_id = u.id
|
||||
LEFT JOIN email_invitations ei ON ei.team_member_id = tm.id
|
||||
WHERE tm.active = TRUE;
|
||||
|
||||
-- Create unique index on the materialized view for fast lookups
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_team_member_info_mv_team_member_id
|
||||
ON team_member_info_mv(team_member_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_team_member_info_mv_team_user
|
||||
ON team_member_info_mv(team_id, user_id);
|
||||
|
||||
-- Function to refresh the materialized view
|
||||
CREATE OR REPLACE FUNCTION refresh_team_member_info_mv()
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
REFRESH MATERIALIZED VIEW CONCURRENTLY team_member_info_mv;
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
@@ -4117,7 +4117,7 @@ BEGIN
|
||||
'color_code_dark', COALESCE((_task_info ->> 'color_code_dark')::TEXT, ''),
|
||||
'total_tasks', COALESCE((_task_info ->> 'total_tasks')::INT, 0),
|
||||
'total_completed', COALESCE((_task_info ->> 'total_completed')::INT, 0),
|
||||
'members', COALESCE((_task_info ->> 'members')::JSON, '[]'::JSON),
|
||||
'members', COALESCE((_task_info -> 'members'), '[]'::JSON),
|
||||
'completed_at', _task_completed_at,
|
||||
'status_category', COALESCE(_status_category, '{}'::JSON),
|
||||
'schedule_id', COALESCE(_schedule_id, 'null'::JSON)
|
||||
@@ -4325,7 +4325,6 @@ DECLARE
|
||||
_from_group UUID;
|
||||
_to_group UUID;
|
||||
_group_by TEXT;
|
||||
_batch_size INT := 100; -- PERFORMANCE OPTIMIZATION: Batch size for large updates
|
||||
BEGIN
|
||||
_project_id = (_body ->> 'project_id')::UUID;
|
||||
_task_id = (_body ->> 'task_id')::UUID;
|
||||
@@ -4338,26 +4337,16 @@ BEGIN
|
||||
|
||||
_group_by = (_body ->> 'group_by')::TEXT;
|
||||
|
||||
-- PERFORMANCE OPTIMIZATION: Use CTE for better query planning
|
||||
IF (_from_group <> _to_group OR (_from_group <> _to_group) IS NULL)
|
||||
THEN
|
||||
-- PERFORMANCE OPTIMIZATION: Batch update group changes
|
||||
IF (_group_by = 'status')
|
||||
THEN
|
||||
UPDATE tasks
|
||||
SET status_id = _to_group
|
||||
WHERE id = _task_id
|
||||
AND status_id = _from_group
|
||||
AND project_id = _project_id;
|
||||
UPDATE tasks SET status_id = _to_group WHERE id = _task_id AND status_id = _from_group;
|
||||
END IF;
|
||||
|
||||
IF (_group_by = 'priority')
|
||||
THEN
|
||||
UPDATE tasks
|
||||
SET priority_id = _to_group
|
||||
WHERE id = _task_id
|
||||
AND priority_id = _from_group
|
||||
AND project_id = _project_id;
|
||||
UPDATE tasks SET priority_id = _to_group WHERE id = _task_id AND priority_id = _from_group;
|
||||
END IF;
|
||||
|
||||
IF (_group_by = 'phase')
|
||||
@@ -4376,15 +4365,14 @@ BEGIN
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- PERFORMANCE OPTIMIZATION: Optimized sort order handling
|
||||
IF ((_body ->> 'to_last_index')::BOOLEAN IS TRUE AND _from_index < _to_index)
|
||||
THEN
|
||||
PERFORM handle_task_list_sort_inside_group_optimized(_from_index, _to_index, _task_id, _project_id, _batch_size);
|
||||
PERFORM handle_task_list_sort_inside_group(_from_index, _to_index, _task_id, _project_id);
|
||||
ELSE
|
||||
PERFORM handle_task_list_sort_between_groups_optimized(_from_index, _to_index, _task_id, _project_id, _batch_size);
|
||||
PERFORM handle_task_list_sort_between_groups(_from_index, _to_index, _task_id, _project_id);
|
||||
END IF;
|
||||
ELSE
|
||||
PERFORM handle_task_list_sort_inside_group_optimized(_from_index, _to_index, _task_id, _project_id, _batch_size);
|
||||
PERFORM handle_task_list_sort_inside_group(_from_index, _to_index, _task_id, _project_id);
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
@@ -5413,7 +5401,8 @@ BEGIN
|
||||
updated_at = CURRENT_TIMESTAMP,
|
||||
estimated_working_days = (_body ->> 'working_days')::INTEGER,
|
||||
estimated_man_days = (_body ->> 'man_days')::INTEGER,
|
||||
hours_per_day = (_body ->> 'hours_per_day')::INTEGER
|
||||
hours_per_day = (_body ->> 'hours_per_day')::INTEGER,
|
||||
currency = COALESCE(UPPER((_body ->> 'currency')::TEXT), currency)
|
||||
WHERE id = (_body ->> 'id')::UUID
|
||||
AND team_id = _team_id
|
||||
RETURNING id INTO _project_id;
|
||||
@@ -6385,120 +6374,43 @@ BEGIN
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- PERFORMANCE OPTIMIZATION: Optimized version with batching for large datasets
|
||||
CREATE OR REPLACE FUNCTION handle_task_list_sort_between_groups_optimized(_from_index integer, _to_index integer, _task_id uuid, _project_id uuid, _batch_size integer DEFAULT 100) RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_offset INT := 0;
|
||||
_affected_rows INT;
|
||||
BEGIN
|
||||
-- PERFORMANCE OPTIMIZATION: Use CTE for better query planning
|
||||
IF (_to_index = -1)
|
||||
THEN
|
||||
_to_index = COALESCE((SELECT MAX(sort_order) + 1 FROM tasks WHERE project_id = _project_id), 0);
|
||||
END IF;
|
||||
|
||||
-- PERFORMANCE OPTIMIZATION: Batch updates for large datasets
|
||||
IF _to_index > _from_index
|
||||
THEN
|
||||
LOOP
|
||||
WITH batch_update AS (
|
||||
UPDATE tasks
|
||||
SET sort_order = sort_order - 1
|
||||
WHERE project_id = _project_id
|
||||
AND sort_order > _from_index
|
||||
AND sort_order < _to_index
|
||||
AND sort_order > _offset
|
||||
AND sort_order <= _offset + _batch_size
|
||||
RETURNING 1
|
||||
)
|
||||
SELECT COUNT(*) INTO _affected_rows FROM batch_update;
|
||||
|
||||
EXIT WHEN _affected_rows = 0;
|
||||
_offset := _offset + _batch_size;
|
||||
END LOOP;
|
||||
|
||||
UPDATE tasks SET sort_order = _to_index - 1 WHERE id = _task_id AND project_id = _project_id;
|
||||
END IF;
|
||||
|
||||
IF _to_index < _from_index
|
||||
THEN
|
||||
_offset := 0;
|
||||
LOOP
|
||||
WITH batch_update AS (
|
||||
UPDATE tasks
|
||||
SET sort_order = sort_order + 1
|
||||
WHERE project_id = _project_id
|
||||
AND sort_order > _to_index
|
||||
AND sort_order < _from_index
|
||||
AND sort_order > _offset
|
||||
AND sort_order <= _offset + _batch_size
|
||||
RETURNING 1
|
||||
)
|
||||
SELECT COUNT(*) INTO _affected_rows FROM batch_update;
|
||||
|
||||
EXIT WHEN _affected_rows = 0;
|
||||
_offset := _offset + _batch_size;
|
||||
END LOOP;
|
||||
|
||||
UPDATE tasks SET sort_order = _to_index + 1 WHERE id = _task_id AND project_id = _project_id;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- PERFORMANCE OPTIMIZATION: Optimized version with batching for large datasets
|
||||
CREATE OR REPLACE FUNCTION handle_task_list_sort_inside_group_optimized(_from_index integer, _to_index integer, _task_id uuid, _project_id uuid, _batch_size integer DEFAULT 100) RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_offset INT := 0;
|
||||
_affected_rows INT;
|
||||
BEGIN
|
||||
-- PERFORMANCE OPTIMIZATION: Batch updates for large datasets
|
||||
IF _to_index > _from_index
|
||||
THEN
|
||||
LOOP
|
||||
WITH batch_update AS (
|
||||
UPDATE tasks
|
||||
SET sort_order = sort_order - 1
|
||||
WHERE project_id = _project_id
|
||||
AND sort_order > _from_index
|
||||
AND sort_order <= _to_index
|
||||
AND sort_order > _offset
|
||||
AND sort_order <= _offset + _batch_size
|
||||
RETURNING 1
|
||||
)
|
||||
SELECT COUNT(*) INTO _affected_rows FROM batch_update;
|
||||
|
||||
EXIT WHEN _affected_rows = 0;
|
||||
_offset := _offset + _batch_size;
|
||||
END LOOP;
|
||||
END IF;
|
||||
|
||||
IF _to_index < _from_index
|
||||
THEN
|
||||
_offset := 0;
|
||||
LOOP
|
||||
WITH batch_update AS (
|
||||
UPDATE tasks
|
||||
SET sort_order = sort_order + 1
|
||||
WHERE project_id = _project_id
|
||||
AND sort_order >= _to_index
|
||||
AND sort_order < _from_index
|
||||
AND sort_order > _offset
|
||||
AND sort_order <= _offset + _batch_size
|
||||
RETURNING 1
|
||||
)
|
||||
SELECT COUNT(*) INTO _affected_rows FROM batch_update;
|
||||
|
||||
EXIT WHEN _affected_rows = 0;
|
||||
_offset := _offset + _batch_size;
|
||||
END LOOP;
|
||||
END IF;
|
||||
|
||||
UPDATE tasks SET sort_order = _to_index WHERE id = _task_id AND project_id = _project_id;
|
||||
END
|
||||
$$;
|
||||
CREATE OR REPLACE VIEW project_finance_view AS
|
||||
SELECT
|
||||
t.id,
|
||||
t.name,
|
||||
t.total_minutes / 3600.0 as estimated_hours,
|
||||
COALESCE((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id), 0) / 3600.0 as total_time_logged,
|
||||
COALESCE((SELECT SUM(rate * (time_spent / 3600.0))
|
||||
FROM task_work_log twl
|
||||
LEFT JOIN users u ON twl.user_id = u.id
|
||||
LEFT JOIN team_members tm ON u.id = tm.user_id
|
||||
LEFT JOIN project_members pm ON tm.id = pm.team_member_id
|
||||
LEFT JOIN finance_project_rate_card_roles pmr ON pm.project_rate_card_role_id = pmr.id
|
||||
WHERE twl.task_id = t.id), 0) as estimated_cost,
|
||||
0 as fixed_cost, -- Default to 0 since the column doesn't exist
|
||||
COALESCE(t.total_minutes / 3600.0 *
|
||||
(SELECT rate FROM finance_project_rate_card_roles
|
||||
WHERE project_id = t.project_id
|
||||
AND id = (SELECT project_rate_card_role_id FROM project_members WHERE team_member_id = t.reporter_id LIMIT 1)
|
||||
LIMIT 1), 0) as total_budgeted_cost,
|
||||
COALESCE((SELECT SUM(rate * (time_spent / 3600.0))
|
||||
FROM task_work_log twl
|
||||
LEFT JOIN users u ON twl.user_id = u.id
|
||||
LEFT JOIN team_members tm ON u.id = tm.user_id
|
||||
LEFT JOIN project_members pm ON tm.id = pm.team_member_id
|
||||
LEFT JOIN finance_project_rate_card_roles pmr ON pm.project_rate_card_role_id = pmr.id
|
||||
WHERE twl.task_id = t.id), 0) as total_actual_cost,
|
||||
COALESCE((SELECT SUM(rate * (time_spent / 3600.0))
|
||||
FROM task_work_log twl
|
||||
LEFT JOIN users u ON twl.user_id = u.id
|
||||
LEFT JOIN team_members tm ON u.id = tm.user_id
|
||||
LEFT JOIN project_members pm ON tm.id = pm.team_member_id
|
||||
LEFT JOIN finance_project_rate_card_roles pmr ON pm.project_rate_card_role_id = pmr.id
|
||||
WHERE twl.task_id = t.id), 0) -
|
||||
COALESCE(t.total_minutes / 3600.0 *
|
||||
(SELECT rate FROM finance_project_rate_card_roles
|
||||
WHERE project_id = t.project_id
|
||||
AND id = (SELECT project_rate_card_role_id FROM project_members WHERE team_member_id = t.reporter_id LIMIT 1)
|
||||
LIMIT 1), 0) as variance,
|
||||
t.project_id
|
||||
FROM tasks t;
|
||||
|
||||
77
worklenz-backend/fix-task-hierarchy.sql
Normal file
77
worklenz-backend/fix-task-hierarchy.sql
Normal file
@@ -0,0 +1,77 @@
|
||||
-- Fix task hierarchy and reset parent estimations
|
||||
-- This script ensures proper parent-child relationships and resets parent estimations
|
||||
|
||||
-- First, let's see the current task hierarchy
|
||||
SELECT
|
||||
t.id,
|
||||
t.name,
|
||||
t.parent_task_id,
|
||||
t.total_minutes,
|
||||
(SELECT name FROM tasks WHERE id = t.parent_task_id) as parent_name,
|
||||
(SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id AND archived = false) as actual_subtask_count,
|
||||
t.archived
|
||||
FROM tasks t
|
||||
WHERE (t.name LIKE '%sub%' OR t.name LIKE '%test task%')
|
||||
ORDER BY t.name, t.created_at;
|
||||
|
||||
-- Reset all parent task estimations to 0
|
||||
-- This ensures parent tasks don't have their own estimation when they have subtasks
|
||||
UPDATE tasks
|
||||
SET total_minutes = 0
|
||||
WHERE id IN (
|
||||
SELECT DISTINCT parent_task_id
|
||||
FROM tasks
|
||||
WHERE parent_task_id IS NOT NULL
|
||||
AND archived = false
|
||||
)
|
||||
AND archived = false;
|
||||
|
||||
-- Verify the results after the update
|
||||
SELECT
|
||||
t.id,
|
||||
t.name,
|
||||
t.parent_task_id,
|
||||
t.total_minutes as current_estimation,
|
||||
(SELECT name FROM tasks WHERE id = t.parent_task_id) as parent_name,
|
||||
(SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id AND archived = false) as subtask_count,
|
||||
get_task_recursive_estimation(t.id) as recursive_estimation
|
||||
FROM tasks t
|
||||
WHERE (t.name LIKE '%sub%' OR t.name LIKE '%test task%')
|
||||
AND t.archived = false
|
||||
ORDER BY t.name;
|
||||
|
||||
-- Show the hierarchy in tree format
|
||||
WITH RECURSIVE task_hierarchy AS (
|
||||
-- Top level tasks (no parent)
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
parent_task_id,
|
||||
total_minutes,
|
||||
0 as level,
|
||||
name as path
|
||||
FROM tasks
|
||||
WHERE parent_task_id IS NULL
|
||||
AND (name LIKE '%sub%' OR name LIKE '%test task%')
|
||||
AND archived = false
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Child tasks
|
||||
SELECT
|
||||
t.id,
|
||||
t.name,
|
||||
t.parent_task_id,
|
||||
t.total_minutes,
|
||||
th.level + 1,
|
||||
th.path || ' > ' || t.name
|
||||
FROM tasks t
|
||||
INNER JOIN task_hierarchy th ON t.parent_task_id = th.id
|
||||
WHERE t.archived = false
|
||||
)
|
||||
SELECT
|
||||
REPEAT(' ', level) || name as indented_name,
|
||||
total_minutes,
|
||||
get_task_recursive_estimation(id) as recursive_estimation
|
||||
FROM task_hierarchy
|
||||
ORDER BY path;
|
||||
10520
worklenz-backend/package-lock.json
generated
10520
worklenz-backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -42,6 +42,9 @@
|
||||
"reportFile": "test-reporter.xml",
|
||||
"indent": 4
|
||||
},
|
||||
"overrides": {
|
||||
"rimraf": "^6.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.378.0",
|
||||
"@aws-sdk/client-ses": "^3.378.0",
|
||||
@@ -68,7 +71,6 @@
|
||||
"express-rate-limit": "^6.8.0",
|
||||
"express-session": "^1.17.3",
|
||||
"express-validator": "^6.15.0",
|
||||
"grunt-cli": "^1.5.0",
|
||||
"helmet": "^6.2.0",
|
||||
"hpp": "^0.2.3",
|
||||
"http-errors": "^2.0.0",
|
||||
@@ -86,6 +88,7 @@
|
||||
"passport-local": "^1.0.0",
|
||||
"path": "^0.12.7",
|
||||
"pg": "^8.14.1",
|
||||
"pg-native": "^3.3.0",
|
||||
"pug": "^3.0.2",
|
||||
"redis": "^4.6.7",
|
||||
"sanitize-html": "^2.11.0",
|
||||
@@ -93,10 +96,8 @@
|
||||
"sharp": "^0.32.6",
|
||||
"slugify": "^1.6.6",
|
||||
"socket.io": "^4.7.1",
|
||||
"tinymce": "^7.8.0",
|
||||
"uglify-js": "^3.17.4",
|
||||
"winston": "^3.10.0",
|
||||
"worklenz-backend": "file:",
|
||||
"xss-filters": "^1.2.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -104,17 +105,15 @@
|
||||
"@babel/preset-typescript": "^7.22.5",
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
"@types/bluebird": "^3.5.38",
|
||||
"@types/body-parser": "^1.19.2",
|
||||
"@types/compression": "^1.7.2",
|
||||
"@types/connect-flash": "^0.0.37",
|
||||
"@types/cookie-parser": "^1.4.3",
|
||||
"@types/cron": "^2.0.1",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/csurf": "^1.11.2",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/express-brute": "^1.0.2",
|
||||
"@types/express-brute-redis": "^0.0.4",
|
||||
"@types/express-serve-static-core": "^4.17.34",
|
||||
"@types/express-session": "^1.17.7",
|
||||
"@types/fs-extra": "^9.0.13",
|
||||
"@types/hpp": "^0.2.2",
|
||||
|
||||
29
worklenz-backend/reset-existing-parent-estimations.sql
Normal file
29
worklenz-backend/reset-existing-parent-estimations.sql
Normal file
@@ -0,0 +1,29 @@
|
||||
-- Reset all existing parent task estimations to 0
|
||||
-- This script updates all tasks that have subtasks to have 0 estimation
|
||||
|
||||
UPDATE tasks
|
||||
SET total_minutes = 0
|
||||
WHERE id IN (
|
||||
SELECT DISTINCT parent_task_id
|
||||
FROM tasks
|
||||
WHERE parent_task_id IS NOT NULL
|
||||
AND archived = false
|
||||
)
|
||||
AND total_minutes > 0
|
||||
AND archived = false;
|
||||
|
||||
-- Show the results
|
||||
SELECT
|
||||
t.id,
|
||||
t.name,
|
||||
t.total_minutes as current_estimation,
|
||||
(SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id AND archived = false) as subtask_count
|
||||
FROM tasks t
|
||||
WHERE id IN (
|
||||
SELECT DISTINCT parent_task_id
|
||||
FROM tasks
|
||||
WHERE parent_task_id IS NOT NULL
|
||||
AND archived = false
|
||||
)
|
||||
AND archived = false
|
||||
ORDER BY t.name;
|
||||
@@ -31,6 +31,7 @@ export default class AuthController extends WorklenzControllerBase {
|
||||
// Flash messages sent from passport-local-signup.ts and passport-local-login.ts
|
||||
const errors = req.flash()["error"] || [];
|
||||
const messages = req.flash()["success"] || [];
|
||||
|
||||
// If there are multiple messages, we will send one at a time.
|
||||
const auth_error = errors.length > 0 ? errors[0] : null;
|
||||
const message = messages.length > 0 ? messages[0] : null;
|
||||
|
||||
1355
worklenz-backend/src/controllers/project-finance-controller.ts
Normal file
1355
worklenz-backend/src/controllers/project-finance-controller.ts
Normal file
File diff suppressed because it is too large
Load Diff
262
worklenz-backend/src/controllers/project-ratecard-controller.ts
Normal file
262
worklenz-backend/src/controllers/project-ratecard-controller.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import db from "../config/db";
|
||||
import { IWorkLenzRequest } from "../interfaces/worklenz-request";
|
||||
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
|
||||
import { ServerResponse } from "../models/server-response";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
|
||||
export default class ProjectRateCardController extends WorklenzControllerBase {
|
||||
|
||||
// Insert a single role for a project
|
||||
@HandleExceptions()
|
||||
public static async createOne(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { project_id, job_title_id, rate } = req.body;
|
||||
if (!project_id || !job_title_id || typeof rate !== "number") {
|
||||
return res.status(400).send(new ServerResponse(false, null, "Invalid input"));
|
||||
}
|
||||
const q = `
|
||||
INSERT INTO finance_project_rate_card_roles (project_id, job_title_id, rate)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (project_id, job_title_id) DO UPDATE SET rate = EXCLUDED.rate
|
||||
RETURNING *,
|
||||
(SELECT name FROM job_titles jt WHERE jt.id = finance_project_rate_card_roles.job_title_id) AS jobtitle;
|
||||
`;
|
||||
const result = await db.query(q, [project_id, job_title_id, rate]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows[0]));
|
||||
}
|
||||
// Insert multiple roles for a project
|
||||
@HandleExceptions()
|
||||
public static async createMany(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { project_id, roles } = req.body;
|
||||
if (!Array.isArray(roles) || !project_id) {
|
||||
return res.status(400).send(new ServerResponse(false, null, "Invalid input"));
|
||||
}
|
||||
const values = roles.map((role: any) => [
|
||||
project_id,
|
||||
role.job_title_id,
|
||||
role.rate
|
||||
]);
|
||||
const q = `
|
||||
INSERT INTO finance_project_rate_card_roles (project_id, job_title_id, rate)
|
||||
VALUES ${values.map((_, i) => `($${i * 3 + 1}, $${i * 3 + 2}, $${i * 3 + 3})`).join(",")}
|
||||
ON CONFLICT (project_id, job_title_id) DO UPDATE SET rate = EXCLUDED.rate
|
||||
RETURNING *,
|
||||
(SELECT name FROM job_titles jt WHERE jt.id = finance_project_rate_card_roles.job_title_id) AS Jobtitle;
|
||||
`;
|
||||
const flatValues = values.flat();
|
||||
const result = await db.query(q, flatValues);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
// Get all roles for a project
|
||||
@HandleExceptions()
|
||||
public static async getByProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { project_id } = req.params;
|
||||
const q = `
|
||||
SELECT
|
||||
fprr.*,
|
||||
jt.name as jobtitle,
|
||||
(
|
||||
SELECT COALESCE(json_agg(pm.id), '[]'::json)
|
||||
FROM project_members pm
|
||||
WHERE pm.project_rate_card_role_id = fprr.id
|
||||
) AS members
|
||||
FROM finance_project_rate_card_roles fprr
|
||||
LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id
|
||||
WHERE fprr.project_id = $1
|
||||
ORDER BY fprr.created_at;
|
||||
`;
|
||||
const result = await db.query(q, [project_id]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
// Get a single role by id
|
||||
@HandleExceptions()
|
||||
public static async getById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { id } = req.params;
|
||||
const q = `
|
||||
SELECT
|
||||
fprr.*,
|
||||
jt.name as jobtitle,
|
||||
(
|
||||
SELECT COALESCE(json_agg(pm.id), '[]'::json)
|
||||
FROM project_members pm
|
||||
WHERE pm.project_rate_card_role_id = fprr.id
|
||||
) AS members
|
||||
FROM finance_project_rate_card_roles fprr
|
||||
LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id
|
||||
WHERE fprr.id = $1;
|
||||
`;
|
||||
const result = await db.query(q, [id]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows[0]));
|
||||
}
|
||||
|
||||
// Update a single role by id
|
||||
@HandleExceptions()
|
||||
public static async updateById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { id } = req.params;
|
||||
const { job_title_id, rate } = req.body;
|
||||
const q = `
|
||||
WITH updated AS (
|
||||
UPDATE finance_project_rate_card_roles
|
||||
SET job_title_id = $1, rate = $2, updated_at = NOW()
|
||||
WHERE id = $3
|
||||
RETURNING *
|
||||
),
|
||||
jobtitles AS (
|
||||
SELECT u.*, jt.name AS jobtitle
|
||||
FROM updated u
|
||||
JOIN job_titles jt ON jt.id = u.job_title_id
|
||||
),
|
||||
members AS (
|
||||
SELECT json_agg(pm.id) AS members, pm.project_rate_card_role_id
|
||||
FROM project_members pm
|
||||
WHERE pm.project_rate_card_role_id IN (SELECT id FROM jobtitles)
|
||||
GROUP BY pm.project_rate_card_role_id
|
||||
)
|
||||
SELECT jt.*, m.members
|
||||
FROM jobtitles jt
|
||||
LEFT JOIN members m ON m.project_rate_card_role_id = jt.id;
|
||||
`;
|
||||
const result = await db.query(q, [job_title_id, rate, id]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows[0]));
|
||||
}
|
||||
|
||||
// update project member rate for a project with members
|
||||
@HandleExceptions()
|
||||
public static async updateProjectMemberByProjectIdAndMemberId(
|
||||
req: IWorkLenzRequest,
|
||||
res: IWorkLenzResponse
|
||||
): Promise<IWorkLenzResponse> {
|
||||
const { project_id, id } = req.params;
|
||||
const { project_rate_card_role_id } = req.body;
|
||||
|
||||
if (!project_id || !id || !project_rate_card_role_id) {
|
||||
return res.status(400).send(new ServerResponse(false, null, "Missing values"));
|
||||
}
|
||||
|
||||
try {
|
||||
// Step 1: Check current role assignment
|
||||
const checkQuery = `
|
||||
SELECT project_rate_card_role_id
|
||||
FROM project_members
|
||||
WHERE id = $1 AND project_id = $2;
|
||||
`;
|
||||
const { rows: checkRows } = await db.query(checkQuery, [id, project_id]);
|
||||
|
||||
const currentRoleId = checkRows[0]?.project_rate_card_role_id;
|
||||
|
||||
if (currentRoleId !== null && currentRoleId !== project_rate_card_role_id) {
|
||||
// Step 2: Fetch members with the requested role
|
||||
const membersQuery = `
|
||||
SELECT COALESCE(json_agg(id), '[]'::json) AS members
|
||||
FROM project_members
|
||||
WHERE project_id = $1 AND project_rate_card_role_id = $2;
|
||||
`;
|
||||
const { rows: memberRows } = await db.query(membersQuery, [project_id, project_rate_card_role_id]);
|
||||
|
||||
return res.status(200).send(
|
||||
new ServerResponse(false, memberRows[0], "Already Assigned !")
|
||||
);
|
||||
}
|
||||
|
||||
// Step 3: Perform the update
|
||||
const updateQuery = `
|
||||
UPDATE project_members
|
||||
SET project_rate_card_role_id = CASE
|
||||
WHEN project_rate_card_role_id = $1 THEN NULL
|
||||
ELSE $1
|
||||
END
|
||||
WHERE id = $2
|
||||
AND project_id = $3
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM finance_project_rate_card_roles
|
||||
WHERE id = $1 AND project_id = $3
|
||||
)
|
||||
RETURNING project_rate_card_role_id;
|
||||
`;
|
||||
const { rows: updateRows } = await db.query(updateQuery, [project_rate_card_role_id, id, project_id]);
|
||||
|
||||
if (updateRows.length === 0) {
|
||||
return res.status(200).send(new ServerResponse(true, [], "Project member not found or invalid project_rate_card_role_id"));
|
||||
}
|
||||
|
||||
const updatedRoleId = updateRows[0].project_rate_card_role_id || project_rate_card_role_id;
|
||||
|
||||
// Step 4: Fetch updated members list
|
||||
const membersQuery = `
|
||||
SELECT COALESCE(json_agg(id), '[]'::json) AS members
|
||||
FROM project_members
|
||||
WHERE project_id = $1 AND project_rate_card_role_id = $2;
|
||||
`;
|
||||
const { rows: finalMembers } = await db.query(membersQuery, [project_id, updatedRoleId]);
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, finalMembers[0]));
|
||||
} catch (error) {
|
||||
return res.status(500).send(new ServerResponse(false, null, "Internal server error"));
|
||||
}
|
||||
}
|
||||
// Update all roles for a project (delete then insert)
|
||||
@HandleExceptions()
|
||||
public static async updateByProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { project_id, roles } = req.body;
|
||||
if (!Array.isArray(roles) || !project_id) {
|
||||
return res.status(400).send(new ServerResponse(false, null, "Invalid input"));
|
||||
}
|
||||
if (roles.length === 0) {
|
||||
// If no roles provided, do nothing and return empty array
|
||||
return res.status(200).send(new ServerResponse(true, []));
|
||||
}
|
||||
// Build upsert query for all roles
|
||||
const values = roles.map((role: any) => [
|
||||
project_id,
|
||||
role.job_title_id,
|
||||
role.rate
|
||||
]);
|
||||
const q = `
|
||||
WITH upserted AS (
|
||||
INSERT INTO finance_project_rate_card_roles (project_id, job_title_id, rate)
|
||||
VALUES ${values.map((_, i) => `($${i * 3 + 1}, $${i * 3 + 2}, $${i * 3 + 3})`).join(",")}
|
||||
ON CONFLICT (project_id, job_title_id)
|
||||
DO UPDATE SET rate = EXCLUDED.rate, updated_at = NOW()
|
||||
RETURNING *
|
||||
),
|
||||
jobtitles AS (
|
||||
SELECT upr.*, jt.name AS jobtitle
|
||||
FROM upserted upr
|
||||
JOIN job_titles jt ON jt.id = upr.job_title_id
|
||||
),
|
||||
members AS (
|
||||
SELECT json_agg(pm.id) AS members, pm.project_rate_card_role_id
|
||||
FROM project_members pm
|
||||
WHERE pm.project_rate_card_role_id IN (SELECT id FROM jobtitles)
|
||||
GROUP BY pm.project_rate_card_role_id
|
||||
)
|
||||
SELECT jt.*, m.members
|
||||
FROM jobtitles jt
|
||||
LEFT JOIN members m ON m.project_rate_card_role_id = jt.id;
|
||||
`;
|
||||
const flatValues = values.flat();
|
||||
const result = await db.query(q, flatValues);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
// Delete a single role by id
|
||||
@HandleExceptions()
|
||||
public static async deleteById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { id } = req.params;
|
||||
const q = `DELETE FROM finance_project_rate_card_roles WHERE id = $1 RETURNING *;`;
|
||||
const result = await db.query(q, [id]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows[0]));
|
||||
}
|
||||
|
||||
// Delete all roles for a project
|
||||
@HandleExceptions()
|
||||
public static async deleteByProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { project_id } = req.params;
|
||||
const q = `DELETE FROM finance_project_rate_card_roles WHERE project_id = $1 RETURNING *;`;
|
||||
const result = await db.query(q, [project_id]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
}
|
||||
@@ -395,6 +395,7 @@ export default class ProjectsController extends WorklenzControllerBase {
|
||||
projects.folder_id,
|
||||
projects.phase_label,
|
||||
projects.category_id,
|
||||
projects.currency,
|
||||
(projects.estimated_man_days) AS man_days,
|
||||
(projects.estimated_working_days) AS working_days,
|
||||
(projects.hours_per_day) AS hours_per_day,
|
||||
@@ -756,186 +757,4 @@ export default class ProjectsController extends WorklenzControllerBase {
|
||||
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getGrouped(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
// Use qualified field name for projects to avoid ambiguity
|
||||
const {searchQuery, sortField, sortOrder, size, offset} = this.toPaginationOptions(req.query, ["projects.name"]);
|
||||
const groupBy = req.query.groupBy as string || "category";
|
||||
|
||||
const filterByMember = !req.user?.owner && !req.user?.is_admin ?
|
||||
` AND is_member_of_project(projects.id, '${req.user?.id}', $1) ` : "";
|
||||
|
||||
const isFavorites = req.query.filter === "1" ? ` AND EXISTS(SELECT user_id FROM favorite_projects WHERE user_id = '${req.user?.id}' AND project_id = projects.id)` : "";
|
||||
const isArchived = req.query.filter === "2"
|
||||
? ` AND EXISTS(SELECT user_id FROM archived_projects WHERE user_id = '${req.user?.id}' AND project_id = projects.id)`
|
||||
: ` AND NOT EXISTS(SELECT user_id FROM archived_projects WHERE user_id = '${req.user?.id}' AND project_id = projects.id)`;
|
||||
const categories = this.getFilterByCategoryWhereClosure(req.query.categories as string);
|
||||
const statuses = this.getFilterByStatusWhereClosure(req.query.statuses as string);
|
||||
|
||||
// Determine grouping field and join based on groupBy parameter
|
||||
let groupField = "";
|
||||
let groupName = "";
|
||||
let groupColor = "";
|
||||
let groupJoin = "";
|
||||
let groupByFields = "";
|
||||
let groupOrderBy = "";
|
||||
|
||||
switch (groupBy) {
|
||||
case "client":
|
||||
groupField = "COALESCE(projects.client_id::text, 'no-client')";
|
||||
groupName = "COALESCE(clients.name, 'No Client')";
|
||||
groupColor = "'#688'";
|
||||
groupJoin = "LEFT JOIN clients ON projects.client_id = clients.id";
|
||||
groupByFields = "projects.client_id, clients.name";
|
||||
groupOrderBy = "COALESCE(clients.name, 'No Client')";
|
||||
break;
|
||||
case "status":
|
||||
groupField = "COALESCE(projects.status_id::text, 'no-status')";
|
||||
groupName = "COALESCE(sys_project_statuses.name, 'No Status')";
|
||||
groupColor = "COALESCE(sys_project_statuses.color_code, '#888')";
|
||||
groupJoin = "LEFT JOIN sys_project_statuses ON projects.status_id = sys_project_statuses.id";
|
||||
groupByFields = "projects.status_id, sys_project_statuses.name, sys_project_statuses.color_code";
|
||||
groupOrderBy = "COALESCE(sys_project_statuses.name, 'No Status')";
|
||||
break;
|
||||
case "category":
|
||||
default:
|
||||
groupField = "COALESCE(projects.category_id::text, 'uncategorized')";
|
||||
groupName = "COALESCE(project_categories.name, 'Uncategorized')";
|
||||
groupColor = "COALESCE(project_categories.color_code, '#888')";
|
||||
groupJoin = "LEFT JOIN project_categories ON projects.category_id = project_categories.id";
|
||||
groupByFields = "projects.category_id, project_categories.name, project_categories.color_code";
|
||||
groupOrderBy = "COALESCE(project_categories.name, 'Uncategorized')";
|
||||
}
|
||||
|
||||
// Ensure sortField is properly qualified for the inner project query
|
||||
let qualifiedSortField = sortField;
|
||||
if (Array.isArray(sortField)) {
|
||||
qualifiedSortField = sortField[0]; // Take the first field if it's an array
|
||||
}
|
||||
// Replace "projects." with "p2." for the inner query
|
||||
const innerSortField = qualifiedSortField.replace("projects.", "p2.");
|
||||
|
||||
const q = `
|
||||
SELECT ROW_TO_JSON(rec) AS groups
|
||||
FROM (
|
||||
SELECT COUNT(DISTINCT ${groupField}) AS total_groups,
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(group_data))), '[]'::JSON)
|
||||
FROM (
|
||||
SELECT ${groupField} AS group_key,
|
||||
${groupName} AS group_name,
|
||||
${groupColor} AS group_color,
|
||||
COUNT(*) AS project_count,
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(project_data))), '[]'::JSON)
|
||||
FROM (
|
||||
SELECT p2.id,
|
||||
p2.name,
|
||||
(SELECT sys_project_statuses.name FROM sys_project_statuses WHERE sys_project_statuses.id = p2.status_id) AS status,
|
||||
(SELECT sys_project_statuses.color_code FROM sys_project_statuses WHERE sys_project_statuses.id = p2.status_id) AS status_color,
|
||||
(SELECT sys_project_statuses.icon FROM sys_project_statuses WHERE sys_project_statuses.id = p2.status_id) AS status_icon,
|
||||
EXISTS(SELECT user_id
|
||||
FROM favorite_projects
|
||||
WHERE user_id = '${req.user?.id}'
|
||||
AND project_id = p2.id) AS favorite,
|
||||
EXISTS(SELECT user_id
|
||||
FROM archived_projects
|
||||
WHERE user_id = '${req.user?.id}'
|
||||
AND project_id = p2.id) AS archived,
|
||||
p2.color_code,
|
||||
p2.start_date,
|
||||
p2.end_date,
|
||||
p2.category_id,
|
||||
(SELECT COUNT(*)
|
||||
FROM tasks
|
||||
WHERE archived IS FALSE
|
||||
AND project_id = p2.id) AS all_tasks_count,
|
||||
(SELECT COUNT(*)
|
||||
FROM tasks
|
||||
WHERE archived IS FALSE
|
||||
AND project_id = p2.id
|
||||
AND status_id IN (SELECT task_statuses.id
|
||||
FROM task_statuses
|
||||
WHERE task_statuses.project_id = p2.id
|
||||
AND task_statuses.category_id IN
|
||||
(SELECT sys_task_status_categories.id FROM sys_task_status_categories WHERE sys_task_status_categories.is_done IS TRUE))) AS completed_tasks_count,
|
||||
(SELECT COUNT(*)
|
||||
FROM project_members
|
||||
WHERE project_members.project_id = p2.id) AS members_count,
|
||||
(SELECT get_project_members(p2.id)) AS names,
|
||||
(SELECT clients.name FROM clients WHERE clients.id = p2.client_id) AS client_name,
|
||||
(SELECT users.name FROM users WHERE users.id = p2.owner_id) AS project_owner,
|
||||
(SELECT project_categories.name FROM project_categories WHERE project_categories.id = p2.category_id) AS category_name,
|
||||
(SELECT project_categories.color_code
|
||||
FROM project_categories
|
||||
WHERE project_categories.id = p2.category_id) AS category_color,
|
||||
((SELECT project_members.team_member_id as team_member_id
|
||||
FROM project_members
|
||||
WHERE project_members.project_id = p2.id
|
||||
AND project_members.project_access_level_id = (SELECT project_access_levels.id FROM project_access_levels WHERE project_access_levels.key = 'PROJECT_MANAGER'))) AS project_manager_team_member_id,
|
||||
(SELECT project_members.default_view
|
||||
FROM project_members
|
||||
WHERE project_members.project_id = p2.id
|
||||
AND project_members.team_member_id = '${req.user?.team_member_id}') AS team_member_default_view,
|
||||
(SELECT CASE
|
||||
WHEN ((SELECT MAX(tasks.updated_at)
|
||||
FROM tasks
|
||||
WHERE tasks.archived IS FALSE
|
||||
AND tasks.project_id = p2.id) >
|
||||
p2.updated_at)
|
||||
THEN (SELECT MAX(tasks.updated_at)
|
||||
FROM tasks
|
||||
WHERE tasks.archived IS FALSE
|
||||
AND tasks.project_id = p2.id)
|
||||
ELSE p2.updated_at END) AS updated_at
|
||||
FROM projects p2
|
||||
${groupJoin.replace("projects.", "p2.")}
|
||||
WHERE p2.team_id = $1
|
||||
AND ${groupField.replace("projects.", "p2.")} = ${groupField}
|
||||
${categories.replace("projects.", "p2.")}
|
||||
${statuses.replace("projects.", "p2.")}
|
||||
${isArchived.replace("projects.", "p2.")}
|
||||
${isFavorites.replace("projects.", "p2.")}
|
||||
${filterByMember.replace("projects.", "p2.")}
|
||||
${searchQuery.replace("projects.", "p2.")}
|
||||
ORDER BY ${innerSortField} ${sortOrder}
|
||||
) project_data
|
||||
) AS projects
|
||||
FROM projects
|
||||
${groupJoin}
|
||||
WHERE projects.team_id = $1 ${categories} ${statuses} ${isArchived} ${isFavorites} ${filterByMember} ${searchQuery}
|
||||
GROUP BY ${groupByFields}
|
||||
ORDER BY ${groupOrderBy}
|
||||
LIMIT $2 OFFSET $3
|
||||
) group_data
|
||||
) AS data
|
||||
FROM projects
|
||||
${groupJoin}
|
||||
WHERE projects.team_id = $1 ${categories} ${statuses} ${isArchived} ${isFavorites} ${filterByMember} ${searchQuery}
|
||||
) rec;
|
||||
`;
|
||||
|
||||
const result = await db.query(q, [req.user?.team_id || null, size, offset]);
|
||||
const [data] = result.rows;
|
||||
|
||||
// Process the grouped data
|
||||
for (const group of data?.groups.data || []) {
|
||||
for (const project of group.projects || []) {
|
||||
project.progress = project.all_tasks_count > 0
|
||||
? ((project.completed_tasks_count / project.all_tasks_count) * 100).toFixed(0) : 0;
|
||||
|
||||
project.updated_at_string = moment(project.updated_at).fromNow();
|
||||
|
||||
project.names = this.createTagList(project?.names);
|
||||
project.names.map((a: any) => a.color_code = getColor(a.name));
|
||||
|
||||
if (project.project_manager_team_member_id) {
|
||||
project.project_manager = {
|
||||
id: project.project_manager_team_member_id
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, data?.groups || { total_groups: 0, data: [] }));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
157
worklenz-backend/src/controllers/ratecard-controller.ts
Normal file
157
worklenz-backend/src/controllers/ratecard-controller.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { IWorkLenzRequest } from "../interfaces/worklenz-request";
|
||||
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
|
||||
import db from "../config/db";
|
||||
import { ServerResponse } from "../models/server-response";
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
|
||||
export default class RateCardController extends WorklenzControllerBase {
|
||||
@HandleExceptions()
|
||||
public static async create(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `
|
||||
INSERT INTO finance_rate_cards (team_id, name)
|
||||
VALUES ($1, $2)
|
||||
RETURNING id, name, team_id, created_at, updated_at;
|
||||
`;
|
||||
const result = await db.query(q, [req.user?.team_id || null, req.body.name]);
|
||||
const [data] = result.rows;
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async get(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { searchQuery, sortField, sortOrder, size, offset } = this.toPaginationOptions(req.query, "name");
|
||||
|
||||
const q = `
|
||||
SELECT ROW_TO_JSON(rec) AS rate_cards
|
||||
FROM (
|
||||
SELECT COUNT(*) AS total,
|
||||
(
|
||||
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON)
|
||||
FROM (
|
||||
SELECT id, name, team_id, currency, created_at, updated_at
|
||||
FROM finance_rate_cards
|
||||
WHERE team_id = $1 ${searchQuery}
|
||||
ORDER BY ${sortField} ${sortOrder}
|
||||
LIMIT $2 OFFSET $3
|
||||
) t
|
||||
) AS data
|
||||
FROM finance_rate_cards
|
||||
WHERE team_id = $1 ${searchQuery}
|
||||
) rec;
|
||||
`;
|
||||
const result = await db.query(q, [req.user?.team_id || null, size, offset]);
|
||||
const [data] = result.rows;
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, data.rate_cards || this.paginatedDatasetDefaultStruct));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
// 1. Fetch the rate card
|
||||
const q = `
|
||||
SELECT id, name, team_id, currency, created_at, updated_at
|
||||
FROM finance_rate_cards
|
||||
WHERE id = $1 AND team_id = $2;
|
||||
`;
|
||||
const result = await db.query(q, [req.params.id, req.user?.team_id || null]);
|
||||
const [data] = result.rows;
|
||||
|
||||
if (!data) {
|
||||
return res.status(404).send(new ServerResponse(false, null, "Rate card not found"));
|
||||
}
|
||||
|
||||
// 2. Fetch job roles with job title names
|
||||
const jobRolesQ = `
|
||||
SELECT
|
||||
rcr.job_title_id,
|
||||
jt.name AS jobTitle,
|
||||
rcr.rate,
|
||||
rcr.rate_card_id
|
||||
FROM finance_rate_card_roles rcr
|
||||
LEFT JOIN job_titles jt ON rcr.job_title_id = jt.id
|
||||
WHERE rcr.rate_card_id = $1
|
||||
`;
|
||||
const jobRolesResult = await db.query(jobRolesQ, [req.params.id]);
|
||||
const jobRolesList = jobRolesResult.rows;
|
||||
|
||||
// 3. Return the rate card with jobRolesList
|
||||
return res.status(200).send(
|
||||
new ServerResponse(true, {
|
||||
...data,
|
||||
jobRolesList,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async update(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
// 1. Update the rate card
|
||||
const updateRateCardQ = `
|
||||
UPDATE finance_rate_cards
|
||||
SET name = $3, currency = $4, updated_at = NOW()
|
||||
WHERE id = $1 AND team_id = $2
|
||||
RETURNING id, name, team_id, currency, created_at, updated_at;
|
||||
`;
|
||||
const result = await db.query(updateRateCardQ, [
|
||||
req.params.id,
|
||||
req.user?.team_id || null,
|
||||
req.body.name,
|
||||
req.body.currency,
|
||||
]);
|
||||
const [rateCardData] = result.rows;
|
||||
|
||||
// 2. Update job roles (delete old, insert new)
|
||||
if (Array.isArray(req.body.jobRolesList)) {
|
||||
// Delete existing roles for this rate card
|
||||
await db.query(
|
||||
`DELETE FROM finance_rate_card_roles WHERE rate_card_id = $1;`,
|
||||
[req.params.id]
|
||||
);
|
||||
|
||||
// Insert new roles
|
||||
for (const role of req.body.jobRolesList) {
|
||||
if (role.job_title_id) {
|
||||
await db.query(
|
||||
`INSERT INTO finance_rate_card_roles (rate_card_id, job_title_id, rate)
|
||||
VALUES ($1, $2, $3);`,
|
||||
[req.params.id, role.job_title_id, role.rate ?? 0]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Get jobRolesList with job title names
|
||||
const jobRolesQ = `
|
||||
SELECT
|
||||
rcr.job_title_id,
|
||||
jt.name AS jobTitle,
|
||||
rcr.rate
|
||||
FROM finance_rate_card_roles rcr
|
||||
LEFT JOIN job_titles jt ON rcr.job_title_id = jt.id
|
||||
WHERE rcr.rate_card_id = $1
|
||||
`;
|
||||
const jobRolesResult = await db.query(jobRolesQ, [req.params.id]);
|
||||
const jobRolesList = jobRolesResult.rows;
|
||||
|
||||
// 4. Return the updated rate card with jobRolesList
|
||||
return res.status(200).send(
|
||||
new ServerResponse(true, {
|
||||
...rateCardData,
|
||||
jobRolesList,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async deleteById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `
|
||||
DELETE FROM finance_rate_cards
|
||||
WHERE id = $1 AND team_id = $2
|
||||
RETURNING id;
|
||||
`;
|
||||
const result = await db.query(q, [req.params.id, req.user?.team_id || null]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows.length > 0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,15 +415,20 @@ export default class ReportingController extends WorklenzControllerBase {
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getMyTeams(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const selectedTeamId = req.user?.team_id;
|
||||
if (!selectedTeamId) {
|
||||
return res.status(400).send(new ServerResponse(false, "No selected team"));
|
||||
}
|
||||
const q = `SELECT team_id AS id, name
|
||||
FROM team_members tm
|
||||
LEFT JOIN teams ON teams.id = tm.team_id
|
||||
WHERE tm.user_id = $1
|
||||
AND tm.team_id = $2
|
||||
AND role_id IN (SELECT id
|
||||
FROM roles
|
||||
WHERE (admin_role IS TRUE OR owner IS TRUE))
|
||||
ORDER BY name;`;
|
||||
const result = await db.query(q, [req.user?.id]);
|
||||
const result = await db.query(q, [req.user?.id, selectedTeamId]);
|
||||
result.rows.forEach((team: any) => team.selected = true);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@@ -15,6 +15,25 @@ enum IToggleOptions {
|
||||
}
|
||||
|
||||
export default class ReportingAllocationController extends ReportingControllerBase {
|
||||
// Helper method to build billable query with custom table alias
|
||||
private static buildBillableQueryWithAlias(selectedStatuses: { billable: boolean; nonBillable: boolean }, tableAlias: string = 'tasks'): string {
|
||||
const { billable, nonBillable } = selectedStatuses;
|
||||
|
||||
if (billable && nonBillable) {
|
||||
// Both are enabled, no need to filter
|
||||
return "";
|
||||
} else if (billable && !nonBillable) {
|
||||
// Only billable is enabled - show only billable tasks
|
||||
return ` AND ${tableAlias}.billable IS TRUE`;
|
||||
} else if (!billable && nonBillable) {
|
||||
// Only non-billable is enabled - show only non-billable tasks
|
||||
return ` AND ${tableAlias}.billable IS FALSE`;
|
||||
} else {
|
||||
// Neither selected - this shouldn't happen in normal UI flow
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private static async getTimeLoggedByProjects(projects: string[], users: string[], key: string, dateRange: string[], archived = false, user_id = "", billable: { billable: boolean; nonBillable: boolean }): Promise<any> {
|
||||
try {
|
||||
const projectIds = projects.map(p => `'${p}'`).join(",");
|
||||
@@ -77,8 +96,8 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
||||
sps.icon AS status_icon,
|
||||
(SELECT COUNT(*)
|
||||
FROM tasks
|
||||
WHERE CASE WHEN ($1 IS TRUE) THEN project_id IS NOT NULL ELSE archived = FALSE END ${billableQuery}
|
||||
AND project_id = projects.id) AS all_tasks_count,
|
||||
WHERE CASE WHEN ($1 IS TRUE) THEN project_id IS NOT NULL ELSE archived = FALSE END
|
||||
AND project_id = projects.id ${billableQuery}) AS all_tasks_count,
|
||||
(SELECT COUNT(*)
|
||||
FROM tasks
|
||||
WHERE CASE WHEN ($1 IS TRUE) THEN project_id IS NOT NULL ELSE archived = FALSE END
|
||||
@@ -94,10 +113,11 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
||||
SELECT name,
|
||||
(SELECT COALESCE(SUM(time_spent), 0)
|
||||
FROM task_work_log
|
||||
LEFT JOIN tasks ON task_work_log.task_id = tasks.id
|
||||
WHERE user_id = users.id ${billableQuery}
|
||||
LEFT JOIN tasks ON task_work_log.task_id = tasks.id
|
||||
WHERE user_id = users.id
|
||||
AND CASE WHEN ($1 IS TRUE) THEN tasks.project_id IS NOT NULL ELSE tasks.archived = FALSE END
|
||||
AND tasks.project_id = projects.id
|
||||
${billableQuery}
|
||||
${duration}) AS time_logged
|
||||
FROM users
|
||||
WHERE id IN (${userIds})
|
||||
@@ -121,10 +141,11 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
||||
const q = `(SELECT id,
|
||||
(SELECT COALESCE(SUM(time_spent), 0)
|
||||
FROM task_work_log
|
||||
LEFT JOIN tasks ON task_work_log.task_id = tasks.id ${billableQuery}
|
||||
LEFT JOIN tasks ON task_work_log.task_id = tasks.id
|
||||
WHERE user_id = users.id
|
||||
AND CASE WHEN ($1 IS TRUE) THEN tasks.project_id IS NOT NULL ELSE tasks.archived = FALSE END
|
||||
AND tasks.project_id IN (${projectIds})
|
||||
${billableQuery}
|
||||
${duration}) AS time_logged
|
||||
FROM users
|
||||
WHERE id IN (${userIds})
|
||||
@@ -346,6 +367,8 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
||||
const projects = (req.body.projects || []) as string[];
|
||||
const projectIds = projects.map(p => `'${p}'`).join(",");
|
||||
|
||||
const categories = (req.body.categories || []) as string[];
|
||||
const noCategory = req.body.noCategory || false;
|
||||
const billable = req.body.billable;
|
||||
|
||||
if (!teamIds || !projectIds.length)
|
||||
@@ -361,6 +384,33 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
||||
|
||||
const billableQuery = this.buildBillableQuery(billable);
|
||||
|
||||
// Prepare projects filter
|
||||
let projectsFilter = "";
|
||||
if (projectIds.length > 0) {
|
||||
projectsFilter = `AND p.id IN (${projectIds})`;
|
||||
} else {
|
||||
// If no projects are selected, don't show any data
|
||||
projectsFilter = `AND 1=0`; // This will match no rows
|
||||
}
|
||||
|
||||
// Prepare categories filter - updated logic
|
||||
let categoriesFilter = "";
|
||||
if (categories.length > 0 && noCategory) {
|
||||
// Both specific categories and "No Category" are selected
|
||||
const categoryIds = categories.map(id => `'${id}'`).join(",");
|
||||
categoriesFilter = `AND (p.category_id IS NULL OR p.category_id IN (${categoryIds}))`;
|
||||
} else if (categories.length === 0 && noCategory) {
|
||||
// Only "No Category" is selected
|
||||
categoriesFilter = `AND p.category_id IS NULL`;
|
||||
} else if (categories.length > 0 && !noCategory) {
|
||||
// Only specific categories are selected
|
||||
const categoryIds = categories.map(id => `'${id}'`).join(",");
|
||||
categoriesFilter = `AND p.category_id IN (${categoryIds})`;
|
||||
} else {
|
||||
// categories.length === 0 && !noCategory - no categories selected, show nothing
|
||||
categoriesFilter = `AND 1=0`; // This will match no rows
|
||||
}
|
||||
|
||||
const q = `
|
||||
SELECT p.id,
|
||||
p.name,
|
||||
@@ -368,13 +418,15 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
||||
SUM(total_minutes) AS estimated,
|
||||
color_code
|
||||
FROM projects p
|
||||
LEFT JOIN tasks ON tasks.project_id = p.id ${billableQuery}
|
||||
LEFT JOIN tasks ON tasks.project_id = p.id
|
||||
LEFT JOIN task_work_log ON task_work_log.task_id = tasks.id
|
||||
WHERE p.id IN (${projectIds}) ${durationClause} ${archivedClause}
|
||||
WHERE p.id IN (${projectIds}) ${durationClause} ${archivedClause} ${categoriesFilter} ${billableQuery}
|
||||
GROUP BY p.id, p.name
|
||||
ORDER BY logged_time DESC;`;
|
||||
const result = await db.query(q, []);
|
||||
|
||||
const utilization = (req.body.utilization || []) as string[];
|
||||
|
||||
const data = [];
|
||||
|
||||
for (const project of result.rows) {
|
||||
@@ -401,10 +453,12 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
||||
const projects = (req.body.projects || []) as string[];
|
||||
const projectIds = projects.map(p => `'${p}'`).join(",");
|
||||
|
||||
const categories = (req.body.categories || []) as string[];
|
||||
const noCategory = req.body.noCategory || false;
|
||||
const billable = req.body.billable;
|
||||
|
||||
if (!teamIds || !projectIds.length)
|
||||
return res.status(200).send(new ServerResponse(true, { users: [], projects: [] }));
|
||||
if (!teamIds)
|
||||
return res.status(200).send(new ServerResponse(true, { filteredRows: [], totals: { total_time_logs: "0", total_estimated_hours: "0", total_utilization: "0" } }));
|
||||
|
||||
const { duration, date_range } = req.body;
|
||||
|
||||
@@ -416,7 +470,9 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
||||
endDate = moment(date_range[1]);
|
||||
} else if (duration === DATE_RANGES.ALL_TIME) {
|
||||
// Fetch the earliest start_date (or created_at if null) from selected projects
|
||||
const minDateQuery = `SELECT MIN(COALESCE(start_date, created_at)) as min_date FROM projects WHERE id IN (${projectIds})`;
|
||||
const minDateQuery = projectIds.length > 0
|
||||
? `SELECT MIN(COALESCE(start_date, created_at)) as min_date FROM projects WHERE id IN (${projectIds})`
|
||||
: `SELECT MIN(COALESCE(start_date, created_at)) as min_date FROM projects WHERE team_id IN (${teamIds})`;
|
||||
const minDateResult = await db.query(minDateQuery, []);
|
||||
const minDate = minDateResult.rows[0]?.min_date;
|
||||
startDate = minDate ? moment(minDate) : moment('2000-01-01');
|
||||
@@ -445,59 +501,223 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
||||
}
|
||||
}
|
||||
|
||||
// Count only weekdays (Mon-Fri) in the period
|
||||
// Get organization working days
|
||||
const orgWorkingDaysQuery = `
|
||||
SELECT monday, tuesday, wednesday, thursday, friday, saturday, sunday
|
||||
FROM organization_working_days
|
||||
WHERE organization_id IN (
|
||||
SELECT t.organization_id
|
||||
FROM teams t
|
||||
WHERE t.id IN (${teamIds})
|
||||
LIMIT 1
|
||||
);
|
||||
`;
|
||||
const orgWorkingDaysResult = await db.query(orgWorkingDaysQuery, []);
|
||||
const workingDaysConfig = orgWorkingDaysResult.rows[0] || {
|
||||
monday: true,
|
||||
tuesday: true,
|
||||
wednesday: true,
|
||||
thursday: true,
|
||||
friday: true,
|
||||
saturday: false,
|
||||
sunday: false
|
||||
};
|
||||
|
||||
// Count working days based on organization settings
|
||||
let workingDays = 0;
|
||||
let current = startDate.clone();
|
||||
while (current.isSameOrBefore(endDate, 'day')) {
|
||||
const day = current.isoWeekday();
|
||||
if (day >= 1 && day <= 5) workingDays++;
|
||||
if (
|
||||
(day === 1 && workingDaysConfig.monday) ||
|
||||
(day === 2 && workingDaysConfig.tuesday) ||
|
||||
(day === 3 && workingDaysConfig.wednesday) ||
|
||||
(day === 4 && workingDaysConfig.thursday) ||
|
||||
(day === 5 && workingDaysConfig.friday) ||
|
||||
(day === 6 && workingDaysConfig.saturday) ||
|
||||
(day === 7 && workingDaysConfig.sunday)
|
||||
) {
|
||||
workingDays++;
|
||||
}
|
||||
current.add(1, 'day');
|
||||
}
|
||||
|
||||
// Get hours_per_day for all selected projects
|
||||
const projectHoursQuery = `SELECT id, hours_per_day FROM projects WHERE id IN (${projectIds})`;
|
||||
const projectHoursResult = await db.query(projectHoursQuery, []);
|
||||
const projectHoursMap: Record<string, number> = {};
|
||||
for (const row of projectHoursResult.rows) {
|
||||
projectHoursMap[row.id] = row.hours_per_day || 8;
|
||||
}
|
||||
// Sum total working hours for all selected projects
|
||||
let totalWorkingHours = 0;
|
||||
for (const pid of Object.keys(projectHoursMap)) {
|
||||
totalWorkingHours += workingDays * projectHoursMap[pid];
|
||||
// Get organization working hours
|
||||
const orgWorkingHoursQuery = `SELECT working_hours FROM organizations WHERE id = (SELECT t.organization_id FROM teams t WHERE t.id IN (${teamIds}) LIMIT 1)`;
|
||||
const orgWorkingHoursResult = await db.query(orgWorkingHoursQuery, []);
|
||||
const orgWorkingHours = orgWorkingHoursResult.rows[0]?.working_hours || 8;
|
||||
|
||||
// Calculate total working hours with minimum baseline for non-working day scenarios
|
||||
let totalWorkingHours = workingDays * orgWorkingHours;
|
||||
let isNonWorkingPeriod = false;
|
||||
|
||||
// If no working days but there might be logged time, set minimum baseline
|
||||
// This ensures that time logged on non-working days is treated as over-utilization
|
||||
// Business Logic: If someone works on weekends/holidays when workingDays = 0,
|
||||
// we use a minimal baseline (1 hour) so any logged time results in >100% utilization
|
||||
if (totalWorkingHours === 0) {
|
||||
totalWorkingHours = 1; // Minimal baseline to ensure over-utilization
|
||||
isNonWorkingPeriod = true;
|
||||
}
|
||||
|
||||
const durationClause = this.getDateRangeClause(duration || DATE_RANGES.LAST_WEEK, date_range);
|
||||
const archivedClause = archived
|
||||
? ""
|
||||
: `AND p.id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = p.id AND user_id = '${req.user?.id}') `;
|
||||
|
||||
const billableQuery = this.buildBillableQuery(billable);
|
||||
|
||||
const q = `
|
||||
SELECT tmiv.email, tmiv.name, SUM(time_spent) AS logged_time
|
||||
FROM team_member_info_view tmiv
|
||||
LEFT JOIN task_work_log ON task_work_log.user_id = tmiv.user_id
|
||||
LEFT JOIN tasks ON tasks.id = task_work_log.task_id ${billableQuery}
|
||||
LEFT JOIN projects p ON p.id = tasks.project_id AND p.team_id = tmiv.team_id
|
||||
WHERE p.id IN (${projectIds})
|
||||
${durationClause} ${archivedClause}
|
||||
GROUP BY tmiv.email, tmiv.name
|
||||
ORDER BY logged_time DESC;`;
|
||||
const result = await db.query(q, []);
|
||||
|
||||
for (const member of result.rows) {
|
||||
member.value = member.logged_time ? parseFloat(moment.duration(member.logged_time, "seconds").asHours().toFixed(2)) : 0;
|
||||
member.color_code = getColor(member.name);
|
||||
member.total_working_hours = totalWorkingHours;
|
||||
member.utilization_percent = (totalWorkingHours > 0 && member.logged_time) ? ((parseFloat(member.logged_time) / (totalWorkingHours * 3600)) * 100).toFixed(2) : '0.00';
|
||||
member.utilized_hours = member.logged_time ? (parseFloat(member.logged_time) / 3600).toFixed(2) : '0.00';
|
||||
// Over/under utilized hours: utilized_hours - total_working_hours
|
||||
const overUnder = member.utilized_hours && member.total_working_hours ? (parseFloat(member.utilized_hours) - member.total_working_hours) : 0;
|
||||
member.over_under_utilized_hours = overUnder.toFixed(2);
|
||||
const billableQuery = this.buildBillableQueryWithAlias(billable, 't');
|
||||
const members = (req.body.members || []) as string[];
|
||||
|
||||
// Prepare members filter - updated logic to handle Clear All scenario
|
||||
let membersFilter = "";
|
||||
if (members.length > 0) {
|
||||
const memberIds = members.map(id => `'${id}'`).join(",");
|
||||
membersFilter = `AND tmiv.team_member_id IN (${memberIds})`;
|
||||
} else {
|
||||
// No members selected - show no data (Clear All scenario)
|
||||
membersFilter = `AND 1=0`; // This will match no rows
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
// Prepare projects filter
|
||||
let projectsFilter = "";
|
||||
if (projectIds.length > 0) {
|
||||
projectsFilter = `AND p.id IN (${projectIds})`;
|
||||
} else {
|
||||
// If no projects are selected, don't show any data
|
||||
projectsFilter = `AND 1=0`; // This will match no rows
|
||||
}
|
||||
|
||||
// Prepare categories filter - updated logic
|
||||
let categoriesFilter = "";
|
||||
if (categories.length > 0 && noCategory) {
|
||||
// Both specific categories and "No Category" are selected
|
||||
const categoryIds = categories.map(id => `'${id}'`).join(",");
|
||||
categoriesFilter = `AND (p.category_id IS NULL OR p.category_id IN (${categoryIds}))`;
|
||||
} else if (categories.length === 0 && noCategory) {
|
||||
// Only "No Category" is selected
|
||||
categoriesFilter = `AND p.category_id IS NULL`;
|
||||
} else if (categories.length > 0 && !noCategory) {
|
||||
// Only specific categories are selected
|
||||
const categoryIds = categories.map(id => `'${id}'`).join(",");
|
||||
categoriesFilter = `AND p.category_id IN (${categoryIds})`;
|
||||
} else {
|
||||
// categories.length === 0 && !noCategory - no categories selected, show nothing
|
||||
categoriesFilter = `AND 1=0`; // This will match no rows
|
||||
}
|
||||
|
||||
// Create custom duration clause for twl table alias
|
||||
let customDurationClause = "";
|
||||
if (date_range && date_range.length === 2) {
|
||||
const start = moment(date_range[0]).format("YYYY-MM-DD");
|
||||
const end = moment(date_range[1]).format("YYYY-MM-DD");
|
||||
if (start === end) {
|
||||
customDurationClause = `AND twl.created_at::DATE = '${start}'::DATE`;
|
||||
} else {
|
||||
customDurationClause = `AND twl.created_at::DATE >= '${start}'::DATE AND twl.created_at < '${end}'::DATE + INTERVAL '1 day'`;
|
||||
}
|
||||
} else {
|
||||
const key = duration || DATE_RANGES.LAST_WEEK;
|
||||
if (key === DATE_RANGES.YESTERDAY)
|
||||
customDurationClause = "AND twl.created_at >= (CURRENT_DATE - INTERVAL '1 day')::DATE AND twl.created_at < CURRENT_DATE::DATE";
|
||||
else if (key === DATE_RANGES.LAST_WEEK)
|
||||
customDurationClause = "AND twl.created_at >= (CURRENT_DATE - INTERVAL '1 week')::DATE AND twl.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'";
|
||||
else if (key === DATE_RANGES.LAST_MONTH)
|
||||
customDurationClause = "AND twl.created_at >= (CURRENT_DATE - INTERVAL '1 month')::DATE AND twl.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'";
|
||||
else if (key === DATE_RANGES.LAST_QUARTER)
|
||||
customDurationClause = "AND twl.created_at >= (CURRENT_DATE - INTERVAL '3 months')::DATE AND twl.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'";
|
||||
}
|
||||
|
||||
// Modified query to start from team members and calculate filtered time logs
|
||||
const q = `
|
||||
SELECT
|
||||
tmiv.team_member_id,
|
||||
tmiv.email,
|
||||
tmiv.name,
|
||||
COALESCE(
|
||||
(SELECT SUM(twl.time_spent)
|
||||
FROM task_work_log twl
|
||||
LEFT JOIN tasks t ON t.id = twl.task_id
|
||||
LEFT JOIN projects p ON p.id = t.project_id
|
||||
WHERE twl.user_id = tmiv.user_id
|
||||
${customDurationClause}
|
||||
${projectsFilter}
|
||||
${categoriesFilter}
|
||||
${archivedClause}
|
||||
${billableQuery}
|
||||
AND p.team_id = tmiv.team_id
|
||||
), 0
|
||||
) AS logged_time
|
||||
FROM team_member_info_view tmiv
|
||||
WHERE tmiv.team_id IN (${teamIds})
|
||||
AND tmiv.active = TRUE
|
||||
${membersFilter}
|
||||
GROUP BY tmiv.email, tmiv.name, tmiv.team_member_id, tmiv.user_id, tmiv.team_id
|
||||
ORDER BY logged_time DESC;`;
|
||||
|
||||
const result = await db.query(q, []);
|
||||
const utilization = (req.body.utilization || []) as string[];
|
||||
|
||||
// Precompute totalWorkingHours * 3600 for efficiency
|
||||
const totalWorkingSeconds = totalWorkingHours * 3600;
|
||||
|
||||
// calculate utilization state
|
||||
for (let i = 0, len = result.rows.length; i < len; i++) {
|
||||
const member = result.rows[i];
|
||||
const loggedSeconds = member.logged_time ? parseFloat(member.logged_time) : 0;
|
||||
const utilizedHours = loggedSeconds / 3600;
|
||||
|
||||
// For individual members, use the same logic as total calculation
|
||||
let memberWorkingHours = totalWorkingHours;
|
||||
if (isNonWorkingPeriod && loggedSeconds > 0) {
|
||||
// Any time logged during non-working period should be treated as over-utilization
|
||||
memberWorkingHours = Math.min(utilizedHours, 1); // Use actual time or 1 hour, whichever is smaller
|
||||
}
|
||||
|
||||
const utilizationPercent = memberWorkingHours > 0 && loggedSeconds
|
||||
? ((loggedSeconds / (memberWorkingHours * 3600)) * 100)
|
||||
: 0;
|
||||
const overUnder = utilizedHours - memberWorkingHours;
|
||||
|
||||
member.value = utilizedHours ? parseFloat(utilizedHours.toFixed(2)) : 0;
|
||||
member.color_code = getColor(member.name);
|
||||
member.total_working_hours = memberWorkingHours;
|
||||
member.utilization_percent = utilizationPercent.toFixed(2);
|
||||
member.utilized_hours = utilizedHours.toFixed(2);
|
||||
member.over_under_utilized_hours = overUnder.toFixed(2);
|
||||
|
||||
if (utilizationPercent < 90) {
|
||||
member.utilization_state = 'under';
|
||||
} else if (utilizationPercent <= 110) {
|
||||
member.utilization_state = 'optimal';
|
||||
} else {
|
||||
member.utilization_state = 'over';
|
||||
}
|
||||
}
|
||||
|
||||
// Apply utilization filter
|
||||
let filteredRows;
|
||||
if (utilization.length > 0) {
|
||||
// Filter to only show selected utilization states
|
||||
filteredRows = result.rows.filter(member => utilization.includes(member.utilization_state));
|
||||
} else {
|
||||
// No utilization states selected - show no data (Clear All scenario)
|
||||
filteredRows = [];
|
||||
}
|
||||
|
||||
// Calculate totals
|
||||
const total_time_logs = filteredRows.reduce((sum, member) => sum + parseFloat(member.logged_time || '0'), 0);
|
||||
const total_estimated_hours = totalWorkingHours * filteredRows.length; // Total for all members
|
||||
const total_utilization = total_time_logs > 0 && total_estimated_hours > 0
|
||||
? ((total_time_logs / (total_estimated_hours * 3600)) * 100).toFixed(1)
|
||||
: '0';
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, {
|
||||
filteredRows,
|
||||
totals: {
|
||||
total_time_logs: ((total_time_logs / 3600).toFixed(2)).toString(),
|
||||
total_estimated_hours: total_estimated_hours.toString(),
|
||||
total_utilization: total_utilization.toString(),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
@@ -580,6 +800,9 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
||||
|
||||
const projects = (req.body.projects || []) as string[];
|
||||
const projectIds = projects.map(p => `'${p}'`).join(",");
|
||||
|
||||
const categories = (req.body.categories || []) as string[];
|
||||
const noCategory = req.body.noCategory || false;
|
||||
const { type, billable } = req.body;
|
||||
|
||||
if (!teamIds || !projectIds.length)
|
||||
@@ -595,6 +818,33 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
||||
|
||||
const billableQuery = this.buildBillableQuery(billable);
|
||||
|
||||
// Prepare projects filter
|
||||
let projectsFilter = "";
|
||||
if (projectIds.length > 0) {
|
||||
projectsFilter = `AND p.id IN (${projectIds})`;
|
||||
} else {
|
||||
// If no projects are selected, don't show any data
|
||||
projectsFilter = `AND 1=0`; // This will match no rows
|
||||
}
|
||||
|
||||
// Prepare categories filter - updated logic
|
||||
let categoriesFilter = "";
|
||||
if (categories.length > 0 && noCategory) {
|
||||
// Both specific categories and "No Category" are selected
|
||||
const categoryIds = categories.map(id => `'${id}'`).join(",");
|
||||
categoriesFilter = `AND (p.category_id IS NULL OR p.category_id IN (${categoryIds}))`;
|
||||
} else if (categories.length === 0 && noCategory) {
|
||||
// Only "No Category" is selected
|
||||
categoriesFilter = `AND p.category_id IS NULL`;
|
||||
} else if (categories.length > 0 && !noCategory) {
|
||||
// Only specific categories are selected
|
||||
const categoryIds = categories.map(id => `'${id}'`).join(",");
|
||||
categoriesFilter = `AND p.category_id IN (${categoryIds})`;
|
||||
} else {
|
||||
// categories.length === 0 && !noCategory - no categories selected, show nothing
|
||||
categoriesFilter = `AND 1=0`; // This will match no rows
|
||||
}
|
||||
|
||||
const q = `
|
||||
SELECT p.id,
|
||||
p.name,
|
||||
@@ -608,9 +858,9 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
||||
WHERE project_id = p.id) AS estimated,
|
||||
color_code
|
||||
FROM projects p
|
||||
LEFT JOIN tasks ON tasks.project_id = p.id ${billableQuery}
|
||||
LEFT JOIN tasks ON tasks.project_id = p.id
|
||||
LEFT JOIN task_work_log ON task_work_log.task_id = tasks.id
|
||||
WHERE p.id IN (${projectIds}) ${durationClause} ${archivedClause}
|
||||
WHERE p.id IN (${projectIds}) ${durationClause} ${archivedClause} ${categoriesFilter} ${billableQuery}
|
||||
GROUP BY p.id, p.name
|
||||
ORDER BY logged_time DESC;`;
|
||||
const result = await db.query(q, []);
|
||||
|
||||
@@ -31,6 +31,7 @@ export default class ReportingMembersController extends ReportingControllerBase
|
||||
const completedDurationClasue = this.completedDurationFilter(key, dateRange);
|
||||
const overdueActivityLogsClause = this.getActivityLogsOverdue(key, dateRange);
|
||||
const activityLogCreationFilter = this.getActivityLogsCreationClause(key, dateRange);
|
||||
const timeLogDateRangeClause = this.getTimeLogDateRangeClause(key, dateRange);
|
||||
|
||||
const q = `SELECT COUNT(DISTINCT email) AS total,
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON)
|
||||
@@ -100,7 +101,25 @@ export default class ReportingMembersController extends ReportingControllerBase
|
||||
FROM tasks t
|
||||
LEFT JOIN tasks_assignees ta ON t.id = ta.task_id
|
||||
WHERE team_member_id = tmiv.team_member_id
|
||||
AND is_doing((SELECT new_value FROM task_activity_logs tl WHERE tl.task_id = t.id AND tl.attribute_type = 'status' ${activityLogCreationFilter} ORDER BY tl.created_at DESC LIMIT 1)::UUID, t.project_id) ${archivedClause}) AS ongoing_by_activity_logs
|
||||
AND is_doing((SELECT new_value FROM task_activity_logs tl WHERE tl.task_id = t.id AND tl.attribute_type = 'status' ${activityLogCreationFilter} ORDER BY tl.created_at DESC LIMIT 1)::UUID, t.project_id) ${archivedClause}) AS ongoing_by_activity_logs,
|
||||
|
||||
(SELECT COALESCE(SUM(twl.time_spent), 0)
|
||||
FROM task_work_log twl
|
||||
LEFT JOIN tasks t ON twl.task_id = t.id
|
||||
WHERE twl.user_id = (SELECT user_id FROM team_members WHERE id = tmiv.team_member_id)
|
||||
AND t.billable IS TRUE
|
||||
AND t.project_id IN (SELECT id FROM projects WHERE team_id = $1)
|
||||
${timeLogDateRangeClause}
|
||||
${includeArchived ? "" : `AND t.project_id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = t.project_id AND archived_projects.user_id = '${userId}')`}) AS billable_time,
|
||||
|
||||
(SELECT COALESCE(SUM(twl.time_spent), 0)
|
||||
FROM task_work_log twl
|
||||
LEFT JOIN tasks t ON twl.task_id = t.id
|
||||
WHERE twl.user_id = (SELECT user_id FROM team_members WHERE id = tmiv.team_member_id)
|
||||
AND t.billable IS FALSE
|
||||
AND t.project_id IN (SELECT id FROM projects WHERE team_id = $1)
|
||||
${timeLogDateRangeClause}
|
||||
${includeArchived ? "" : `AND t.project_id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = t.project_id AND archived_projects.user_id = '${userId}')`}) AS non_billable_time
|
||||
FROM team_member_info_view tmiv
|
||||
WHERE tmiv.team_id = $1 ${teamsClause}
|
||||
AND tmiv.team_member_id IN (SELECT team_member_id
|
||||
@@ -311,6 +330,30 @@ export default class ReportingMembersController extends ReportingControllerBase
|
||||
return "";
|
||||
}
|
||||
|
||||
protected static getTimeLogDateRangeClause(key: string, dateRange: string[]) {
|
||||
if (dateRange.length === 2) {
|
||||
const start = moment(dateRange[0]).format("YYYY-MM-DD");
|
||||
const end = moment(dateRange[1]).format("YYYY-MM-DD");
|
||||
|
||||
if (start === end) {
|
||||
return `AND twl.created_at::DATE = '${start}'::DATE`;
|
||||
}
|
||||
|
||||
return `AND twl.created_at::DATE >= '${start}'::DATE AND twl.created_at < '${end}'::DATE + INTERVAL '1 day'`;
|
||||
}
|
||||
|
||||
if (key === DATE_RANGES.YESTERDAY)
|
||||
return `AND twl.created_at >= (CURRENT_DATE - INTERVAL '1 day')::DATE AND twl.created_at < CURRENT_DATE::DATE`;
|
||||
if (key === DATE_RANGES.LAST_WEEK)
|
||||
return `AND twl.created_at >= (CURRENT_DATE - INTERVAL '1 week')::DATE AND twl.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'`;
|
||||
if (key === DATE_RANGES.LAST_MONTH)
|
||||
return `AND twl.created_at >= (CURRENT_DATE - INTERVAL '1 month')::DATE AND twl.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'`;
|
||||
if (key === DATE_RANGES.LAST_QUARTER)
|
||||
return `AND twl.created_at >= (CURRENT_DATE - INTERVAL '3 months')::DATE AND twl.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'`;
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
private static formatDuration(duration: moment.Duration) {
|
||||
const empty = "0h 0m";
|
||||
let format = "";
|
||||
@@ -423,6 +466,8 @@ export default class ReportingMembersController extends ReportingControllerBase
|
||||
{ header: "Overdue Tasks", key: "overdue_tasks", width: 20 },
|
||||
{ header: "Completed Tasks", key: "completed_tasks", width: 20 },
|
||||
{ header: "Ongoing Tasks", key: "ongoing_tasks", width: 20 },
|
||||
{ header: "Billable Time (seconds)", key: "billable_time", width: 25 },
|
||||
{ header: "Non-Billable Time (seconds)", key: "non_billable_time", width: 25 },
|
||||
{ header: "Done Tasks(%)", key: "done_tasks", width: 20 },
|
||||
{ header: "Doing Tasks(%)", key: "doing_tasks", width: 20 },
|
||||
{ header: "Todo Tasks(%)", key: "todo_tasks", width: 20 }
|
||||
@@ -430,14 +475,14 @@ export default class ReportingMembersController extends ReportingControllerBase
|
||||
|
||||
// set title
|
||||
sheet.getCell("A1").value = `Members from ${teamName}`;
|
||||
sheet.mergeCells("A1:K1");
|
||||
sheet.mergeCells("A1:M1");
|
||||
sheet.getCell("A1").alignment = { horizontal: "center" };
|
||||
sheet.getCell("A1").style.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "D9D9D9" } };
|
||||
sheet.getCell("A1").font = { size: 16 };
|
||||
|
||||
// set export date
|
||||
sheet.getCell("A2").value = `Exported on : ${exportDate}`;
|
||||
sheet.mergeCells("A2:K2");
|
||||
sheet.mergeCells("A2:M2");
|
||||
sheet.getCell("A2").alignment = { horizontal: "center" };
|
||||
sheet.getCell("A2").style.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "F2F2F2" } };
|
||||
sheet.getCell("A2").font = { size: 12 };
|
||||
@@ -447,7 +492,7 @@ export default class ReportingMembersController extends ReportingControllerBase
|
||||
sheet.mergeCells("A3:D3");
|
||||
|
||||
// set table headers
|
||||
sheet.getRow(5).values = ["Member", "Email", "Tasks Assigned", "Overdue Tasks", "Completed Tasks", "Ongoing Tasks", "Done Tasks(%)", "Doing Tasks(%)", "Todo Tasks(%)"];
|
||||
sheet.getRow(5).values = ["Member", "Email", "Tasks Assigned", "Overdue Tasks", "Completed Tasks", "Ongoing Tasks", "Billable Time (seconds)", "Non-Billable Time (seconds)", "Done Tasks(%)", "Doing Tasks(%)", "Todo Tasks(%)"];
|
||||
sheet.getRow(5).font = { bold: true };
|
||||
|
||||
for (const member of result.members) {
|
||||
@@ -458,6 +503,8 @@ export default class ReportingMembersController extends ReportingControllerBase
|
||||
overdue_tasks: member.overdue,
|
||||
completed_tasks: member.completed,
|
||||
ongoing_tasks: member.ongoing,
|
||||
billable_time: member.billable_time || 0,
|
||||
non_billable_time: member.non_billable_time || 0,
|
||||
done_tasks: member.completed,
|
||||
doing_tasks: member.ongoing_by_activity_logs,
|
||||
todo_tasks: member.todo_by_activity_logs
|
||||
|
||||
@@ -74,14 +74,9 @@ export default class ScheduleControllerV2 extends WorklenzControllerBase {
|
||||
.map(day => `${day.toLowerCase()} = ${workingDays.includes(day)}`)
|
||||
.join(", ");
|
||||
|
||||
const updateQuery = `
|
||||
UPDATE public.organization_working_days
|
||||
const updateQuery = `UPDATE public.organization_working_days
|
||||
SET ${setClause}, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE organization_id IN (
|
||||
SELECT organization_id FROM organizations
|
||||
WHERE user_id = $1
|
||||
);
|
||||
`;
|
||||
WHERE organization_id IN (SELECT id FROM organizations WHERE user_id = $1);`;
|
||||
|
||||
await db.query(updateQuery, [req.user?.owner_id]);
|
||||
|
||||
|
||||
@@ -134,25 +134,6 @@ export default class TaskStatusesController extends WorklenzControllerBase {
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async updateCategory(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const hasMoreCategories = await TaskStatusesController.hasMoreCategories(req.params.id, req.query.current_project_id as string);
|
||||
|
||||
if (!hasMoreCategories)
|
||||
return res.status(200).send(new ServerResponse(false, null, existsErrorMessage).withTitle("Status category update failed!"));
|
||||
|
||||
const q = `
|
||||
UPDATE task_statuses
|
||||
SET category_id = $2
|
||||
WHERE id = $1
|
||||
AND project_id = $3
|
||||
RETURNING (SELECT color_code FROM sys_task_status_categories WHERE id = task_statuses.category_id), (SELECT color_code_dark FROM sys_task_status_categories WHERE id = task_statuses.category_id);
|
||||
`;
|
||||
const result = await db.query(q, [req.params.id, req.body.category_id, req.query.current_project_id]);
|
||||
const [data] = result.rows;
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async updateStatusOrder(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `SELECT update_status_order($1);`;
|
||||
|
||||
@@ -28,32 +28,50 @@ export default class TaskWorklogController extends WorklenzControllerBase {
|
||||
if (!id) return [];
|
||||
|
||||
const q = `
|
||||
WITH time_logs AS (
|
||||
--
|
||||
SELECT id,
|
||||
description,
|
||||
time_spent,
|
||||
created_at,
|
||||
user_id,
|
||||
logged_by_timer,
|
||||
(SELECT name FROM users WHERE users.id = task_work_log.user_id) AS user_name,
|
||||
(SELECT email FROM users WHERE users.id = task_work_log.user_id) AS user_email,
|
||||
(SELECT avatar_url FROM users WHERE users.id = task_work_log.user_id) AS avatar_url
|
||||
FROM task_work_log
|
||||
WHERE task_id = $1
|
||||
--
|
||||
WITH RECURSIVE task_hierarchy AS (
|
||||
-- Base case: Start with the given task
|
||||
SELECT id, name, 0 as level
|
||||
FROM tasks
|
||||
WHERE id = $1
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Recursive case: Get all subtasks
|
||||
SELECT t.id, t.name, th.level + 1
|
||||
FROM tasks t
|
||||
INNER JOIN task_hierarchy th ON t.parent_task_id = th.id
|
||||
WHERE t.archived IS FALSE
|
||||
),
|
||||
time_logs AS (
|
||||
SELECT
|
||||
twl.id,
|
||||
twl.description,
|
||||
twl.time_spent,
|
||||
twl.created_at,
|
||||
twl.user_id,
|
||||
twl.logged_by_timer,
|
||||
twl.task_id,
|
||||
th.name AS task_name,
|
||||
(SELECT name FROM users WHERE users.id = twl.user_id) AS user_name,
|
||||
(SELECT email FROM users WHERE users.id = twl.user_id) AS user_email,
|
||||
(SELECT avatar_url FROM users WHERE users.id = twl.user_id) AS avatar_url
|
||||
FROM task_work_log twl
|
||||
INNER JOIN task_hierarchy th ON twl.task_id = th.id
|
||||
)
|
||||
SELECT id,
|
||||
time_spent,
|
||||
description,
|
||||
created_at,
|
||||
user_id,
|
||||
logged_by_timer,
|
||||
created_at AS start_time,
|
||||
(created_at + INTERVAL '1 second' * time_spent) AS end_time,
|
||||
user_name,
|
||||
user_email,
|
||||
avatar_url
|
||||
SELECT
|
||||
id,
|
||||
time_spent,
|
||||
description,
|
||||
created_at,
|
||||
user_id,
|
||||
logged_by_timer,
|
||||
task_id,
|
||||
task_name,
|
||||
created_at AS start_time,
|
||||
(created_at + INTERVAL '1 second' * time_spent) AS end_time,
|
||||
user_name,
|
||||
user_email,
|
||||
avatar_url
|
||||
FROM time_logs
|
||||
ORDER BY created_at DESC;
|
||||
`;
|
||||
@@ -143,6 +161,7 @@ export default class TaskWorklogController extends WorklenzControllerBase {
|
||||
};
|
||||
|
||||
sheet.columns = [
|
||||
{header: "Task Name", key: "task_name", width: 30},
|
||||
{header: "Reporter Name", key: "user_name", width: 25},
|
||||
{header: "Reporter Email", key: "user_email", width: 25},
|
||||
{header: "Start Time", key: "start_time", width: 25},
|
||||
@@ -153,14 +172,15 @@ export default class TaskWorklogController extends WorklenzControllerBase {
|
||||
];
|
||||
|
||||
sheet.getCell("A1").value = metadata.project_name;
|
||||
sheet.mergeCells("A1:G1");
|
||||
sheet.mergeCells("A1:H1");
|
||||
sheet.getCell("A1").alignment = {horizontal: "center"};
|
||||
|
||||
sheet.getCell("A2").value = `${metadata.name} (${exportDate})`;
|
||||
sheet.mergeCells("A2:G2");
|
||||
sheet.mergeCells("A2:H2");
|
||||
sheet.getCell("A2").alignment = {horizontal: "center"};
|
||||
|
||||
sheet.getRow(4).values = [
|
||||
"Task Name",
|
||||
"Reporter Name",
|
||||
"Reporter Email",
|
||||
"Start Time",
|
||||
@@ -176,6 +196,7 @@ export default class TaskWorklogController extends WorklenzControllerBase {
|
||||
for (const item of results) {
|
||||
totalLogged += parseFloat((item.time_spent || 0).toString());
|
||||
const data = {
|
||||
task_name: item.task_name,
|
||||
user_name: item.user_name,
|
||||
user_email: item.user_email,
|
||||
start_time: moment(item.start_time).add(timezone.hours || 0, "hours").add(timezone.minutes || 0, "minutes").format(timeFormat),
|
||||
@@ -210,6 +231,7 @@ export default class TaskWorklogController extends WorklenzControllerBase {
|
||||
};
|
||||
|
||||
sheet.addRow({
|
||||
task_name: "",
|
||||
user_name: "",
|
||||
user_email: "",
|
||||
start_time: "Total",
|
||||
@@ -219,7 +241,7 @@ export default class TaskWorklogController extends WorklenzControllerBase {
|
||||
time_spent: formatDuration(moment.duration(totalLogged, "seconds")),
|
||||
});
|
||||
|
||||
sheet.mergeCells(`A${sheet.rowCount}:F${sheet.rowCount}`);
|
||||
sheet.mergeCells(`A${sheet.rowCount}:G${sheet.rowCount}`);
|
||||
|
||||
sheet.getCell(`A${sheet.rowCount}`).value = "Total";
|
||||
sheet.getCell(`A${sheet.rowCount}`).alignment = {
|
||||
|
||||
@@ -81,7 +81,31 @@ export default class TasksControllerBase extends WorklenzControllerBase {
|
||||
task.is_sub_task = !!task.parent_task_id;
|
||||
|
||||
task.time_spent_string = `${task.time_spent.hours}h ${(task.time_spent.minutes)}m`;
|
||||
task.total_time_string = `${~~(task.total_minutes / 60)}h ${(task.total_minutes % 60)}m`;
|
||||
|
||||
// Use recursive estimation for parent tasks, own estimation for leaf tasks
|
||||
const recursiveEstimation = task.recursive_estimation || {};
|
||||
const hasSubtasks = (task.sub_tasks_count || 0) > 0;
|
||||
|
||||
let displayMinutes;
|
||||
if (hasSubtasks) {
|
||||
// For parent tasks, use recursive estimation (sum of all subtasks)
|
||||
displayMinutes = recursiveEstimation.recursive_total_minutes || 0;
|
||||
} else {
|
||||
// For leaf tasks, use their own estimation
|
||||
displayMinutes = task.total_minutes || 0;
|
||||
}
|
||||
|
||||
// Format time string - show "0h" for zero time instead of "0h 0m"
|
||||
const hours = ~~(displayMinutes / 60);
|
||||
const minutes = displayMinutes % 60;
|
||||
|
||||
if (displayMinutes === 0) {
|
||||
task.total_time_string = "0h";
|
||||
} else if (minutes === 0) {
|
||||
task.total_time_string = `${hours}h`;
|
||||
} else {
|
||||
task.total_time_string = `${hours}h ${minutes}m`;
|
||||
}
|
||||
|
||||
task.name_color = getColor(task.name);
|
||||
task.priority_color = PriorityColorCodes[task.priority_value] || PriorityColorCodes["0"];
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -427,9 +427,24 @@ export default class TasksController extends TasksControllerBase {
|
||||
|
||||
task.names = WorklenzControllerBase.createTagList(task.assignees);
|
||||
|
||||
const totalMinutes = task.total_minutes;
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
// Use recursive estimation if task has subtasks, otherwise use own estimation
|
||||
const recursiveEstimation = task.recursive_estimation || {};
|
||||
// Check both the recursive estimation count and the actual database count
|
||||
const hasSubtasks = (task.sub_tasks_count || 0) > 0;
|
||||
|
||||
let totalMinutes, hours, minutes;
|
||||
|
||||
if (hasSubtasks) {
|
||||
// For parent tasks, use the sum of all subtasks' estimation (excluding parent's own estimation)
|
||||
totalMinutes = recursiveEstimation.recursive_total_minutes || 0;
|
||||
hours = recursiveEstimation.recursive_total_hours || 0;
|
||||
minutes = recursiveEstimation.recursive_remaining_minutes || 0;
|
||||
} else {
|
||||
// For tasks without subtasks, use their own estimation
|
||||
totalMinutes = task.total_minutes || 0;
|
||||
hours = Math.floor(totalMinutes / 60);
|
||||
minutes = totalMinutes % 60;
|
||||
}
|
||||
|
||||
task.total_hours = hours;
|
||||
task.total_minutes = minutes;
|
||||
@@ -608,6 +623,18 @@ export default class TasksController extends TasksControllerBase {
|
||||
return res.status(200).send(new ServerResponse(true, null));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async resetParentTaskEstimations(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `SELECT reset_all_parent_task_estimations() AS updated_count;`;
|
||||
const result = await db.query(q);
|
||||
const [data] = result.rows;
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, {
|
||||
message: `Reset estimation for ${data.updated_count} parent tasks`,
|
||||
updated_count: data.updated_count
|
||||
}));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async bulkAssignMembers(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { tasks, members, project_id } = req.body;
|
||||
|
||||
@@ -6,11 +6,11 @@ import { isProduction } from "../shared/utils";
|
||||
const pgSession = require("connect-pg-simple")(session);
|
||||
|
||||
export default session({
|
||||
name: process.env.SESSION_NAME,
|
||||
name: process.env.SESSION_NAME || "worklenz.sid",
|
||||
secret: process.env.SESSION_SECRET || "development-secret-key",
|
||||
proxy: false,
|
||||
resave: false,
|
||||
saveUninitialized: true,
|
||||
resave: true,
|
||||
saveUninitialized: false,
|
||||
rolling: true,
|
||||
store: new pgSession({
|
||||
pool: db.pool,
|
||||
@@ -18,10 +18,8 @@ export default session({
|
||||
}),
|
||||
cookie: {
|
||||
path: "/",
|
||||
// secure: isProduction(),
|
||||
// httpOnly: isProduction(),
|
||||
// sameSite: "none",
|
||||
// domain: isProduction() ? ".worklenz.com" : undefined,
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import {NextFunction} from "express";
|
||||
|
||||
import {IWorkLenzRequest} from "../../interfaces/worklenz-request";
|
||||
import {IWorkLenzResponse} from "../../interfaces/worklenz-response";
|
||||
import {ServerResponse} from "../../models/server-response";
|
||||
|
||||
export default function (req: IWorkLenzRequest, res: IWorkLenzResponse, next: NextFunction): IWorkLenzResponse | void {
|
||||
const {name} = req.body;
|
||||
if (!name || name.trim() === "")
|
||||
return res.status(200).send(new ServerResponse(false, null, "Name is required"));
|
||||
|
||||
req.body.name = req.body.name.trim();
|
||||
|
||||
return next();
|
||||
}
|
||||
@@ -30,6 +30,7 @@ export async function deserialize(user: { id: string | null }, done: IDeserializ
|
||||
const excludedSubscriptionTypes = ["TRIAL", "PADDLE"];
|
||||
const q = `SELECT deserialize_user($1) AS user;`;
|
||||
const result = await db.query(q, [id]);
|
||||
|
||||
if (result.rows.length) {
|
||||
const [data] = result.rows;
|
||||
if (data?.user) {
|
||||
|
||||
@@ -44,7 +44,6 @@ async function handleLogin(req: Request, email: string, password: string, done:
|
||||
req.flash(ERROR_KEY, errorMsg);
|
||||
return done(null, false);
|
||||
} catch (error) {
|
||||
console.error("Login error:", error);
|
||||
log_error(error, req.body);
|
||||
return done(error);
|
||||
}
|
||||
|
||||
@@ -47,41 +47,55 @@ async function handleSignUp(req: Request, email: string, password: string, done:
|
||||
// team = Invited team_id if req.body.from_invitation is true
|
||||
const {name, team_name, team_member_id, team_id, timezone} = req.body;
|
||||
|
||||
if (!team_name) return done(null, null, req.flash(ERROR_KEY, "Team name is required"));
|
||||
if (!team_name) {
|
||||
req.flash(ERROR_KEY, "Team name is required");
|
||||
return done(null, null, {message: "Team name is required"});
|
||||
}
|
||||
|
||||
const googleAccountFound = await isGoogleAccountFound(email);
|
||||
if (googleAccountFound)
|
||||
return done(null, null, req.flash(ERROR_KEY, `${req.body.email} is already linked with a Google account.`));
|
||||
if (googleAccountFound) {
|
||||
req.flash(ERROR_KEY, `${req.body.email} is already linked with a Google account.`);
|
||||
return done(null, null, {message: `${req.body.email} is already linked with a Google account.`});
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await registerUser(password, team_id, name, team_name, email, timezone, team_member_id);
|
||||
sendWelcomeEmail(email, name);
|
||||
return done(null, user, req.flash(SUCCESS_KEY, "Registration successful. Please check your email for verification."));
|
||||
req.flash(SUCCESS_KEY, "Registration successful. Please check your email for verification.");
|
||||
return done(null, user, {message: "Registration successful. Please check your email for verification."});
|
||||
} catch (error: any) {
|
||||
const message = (error?.message) || "";
|
||||
|
||||
if (message === "ERROR_INVALID_JOINING_EMAIL") {
|
||||
return done(null, null, req.flash(ERROR_KEY, `No invitations found for email ${req.body.email}.`));
|
||||
req.flash(ERROR_KEY, `No invitations found for email ${req.body.email}.`);
|
||||
return done(null, null, {message: `No invitations found for email ${req.body.email}.`});
|
||||
}
|
||||
|
||||
// if error.message is "email already exists" then it should have the email address in the error message after ":".
|
||||
if (message.includes("EMAIL_EXISTS_ERROR") || error.constraint === "users_google_id_uindex") {
|
||||
const [, value] = error.message.split(":");
|
||||
return done(null, null, req.flash(ERROR_KEY, `Worklenz account already exists for email ${value}.`));
|
||||
const errorMsg = `Worklenz account already exists for email ${value}.`;
|
||||
req.flash(ERROR_KEY, errorMsg);
|
||||
return done(null, null, {message: errorMsg});
|
||||
}
|
||||
|
||||
if (message.includes("TEAM_NAME_EXISTS_ERROR")) {
|
||||
const [, value] = error.message.split(":");
|
||||
return done(null, null, req.flash(ERROR_KEY, `Team name "${value}" already exists. Please choose a different team name.`));
|
||||
const errorMsg = `Team name "${value}" already exists. Please choose a different team name.`;
|
||||
req.flash(ERROR_KEY, errorMsg);
|
||||
return done(null, null, {message: errorMsg});
|
||||
}
|
||||
|
||||
// The Team name is already taken.
|
||||
if (error.constraint === "teams_url_uindex" || error.constraint === "teams_name_uindex") {
|
||||
return done(null, null, req.flash(ERROR_KEY, `Team name "${team_name}" is already taken. Please choose a different team name.`));
|
||||
const errorMsg = `Team name "${team_name}" is already taken. Please choose a different team name.`;
|
||||
req.flash(ERROR_KEY, errorMsg);
|
||||
return done(null, null, {message: errorMsg});
|
||||
}
|
||||
|
||||
log_error(error, req.body);
|
||||
return done(null, null, req.flash(ERROR_KEY, DEFAULT_ERROR_MESSAGE));
|
||||
req.flash(ERROR_KEY, DEFAULT_ERROR_MESSAGE);
|
||||
return done(null, null, {message: DEFAULT_ERROR_MESSAGE});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"name": "tinymce",
|
||||
"version": "6.8.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "tinymce",
|
||||
"version": "6.8.4",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinymce": "file:"
|
||||
}
|
||||
},
|
||||
"node_modules/tinymce": {
|
||||
"resolved": "",
|
||||
"link": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,8 +28,5 @@
|
||||
"homepage": "https://www.tiny.cloud/",
|
||||
"bugs": {
|
||||
"url": "https://github.com/tinymce/tinymce/issues"
|
||||
},
|
||||
"dependencies": {
|
||||
"tinymce": "file:"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,120 +1,128 @@
|
||||
import express from "express";
|
||||
|
||||
import AccessControlsController from "../../controllers/access-controls-controller";
|
||||
import AuthController from "../../controllers/auth-controller";
|
||||
import LogsController from "../../controllers/logs-controller";
|
||||
import OverviewController from "../../controllers/overview-controller";
|
||||
import TaskPrioritiesController from "../../controllers/task-priorities-controller";
|
||||
|
||||
import attachmentsApiRouter from "./attachments-api-router";
|
||||
import clientsApiRouter from "./clients-api-router";
|
||||
import jobTitlesApiRouter from "./job-titles-api-router";
|
||||
import notificationsApiRouter from "./notifications-api-router";
|
||||
import personalOverviewApiRouter from "./personal-overview-api-router";
|
||||
import projectMembersApiRouter from "./project-members-api-router";
|
||||
import projectsApiRouter from "./projects-api-router";
|
||||
import settingsApiRouter from "./settings-api-router";
|
||||
import statusesApiRouter from "./statuses-api-router";
|
||||
import subTasksApiRouter from "./sub-tasks-api-router";
|
||||
import taskCommentsApiRouter from "./task-comments-api-router";
|
||||
import taskWorkLogApiRouter from "./task-work-log-api-router";
|
||||
import tasksApiRouter from "./tasks-api-router";
|
||||
import teamMembersApiRouter from "./team-members-api-router";
|
||||
import teamsApiRouter from "./teams-api-router";
|
||||
import timezonesApiRouter from "./timezones-api-router";
|
||||
import todoListApiRouter from "./todo-list-api-router";
|
||||
import projectStatusesApiRouter from "./project-statuses-api-router";
|
||||
import labelsApiRouter from "./labels-api-router";
|
||||
import sharedProjectsApiRouter from "./shared-projects-api-router";
|
||||
import resourceAllocationApiRouter from "./resource-allocation-api-router";
|
||||
import taskTemplatesApiRouter from "./task-templates-api-router";
|
||||
import projectInsightsApiRouter from "./project-insights-api-router";
|
||||
import passwordValidator from "../../middlewares/validators/password-validator";
|
||||
import adminCenterApiRouter from "./admin-center-api-router";
|
||||
import reportingApiRouter from "./reporting-api-router";
|
||||
import activityLogsApiRouter from "./activity-logs-api-router";
|
||||
import safeControllerFunction from "../../shared/safe-controller-function";
|
||||
import projectFoldersApiRouter from "./project-folders-api-router";
|
||||
import taskPhasesApiRouter from "./task-phases-api-router";
|
||||
import projectCategoriesApiRouter from "./project-categories-api-router";
|
||||
import homePageApiRouter from "./home-page-api-router";
|
||||
import ganttApiRouter from "./gantt-api-router";
|
||||
import projectCommentsApiRouter from "./project-comments-api-router";
|
||||
import reportingExportApiRouter from "./reporting-export-api-router";
|
||||
import projectHealthsApiRouter from "./project-healths-api-router";
|
||||
import ptTasksApiRouter from "./pt-tasks-api-router";
|
||||
import projectTemplatesApiRouter from "./project-templates-api";
|
||||
import ptTaskPhasesApiRouter from "./pt_task-phases-api-router";
|
||||
import ptStatusesApiRouter from "./pt-statuses-api-router";
|
||||
import workloadApiRouter from "./gannt-apis/workload-api-router";
|
||||
import roadmapApiRouter from "./gannt-apis/roadmap-api-router";
|
||||
import scheduleApiRouter from "./gannt-apis/schedule-api-router";
|
||||
import scheduleApiV2Router from "./gannt-apis/schedule-api-v2-router";
|
||||
import projectManagerApiRouter from "./project-managers-api-router";
|
||||
|
||||
import billingApiRouter from "./billing-api-router";
|
||||
import taskDependenciesApiRouter from "./task-dependencies-api-router";
|
||||
|
||||
import taskRecurringApiRouter from "./task-recurring-api-router";
|
||||
|
||||
import express from "express";
|
||||
|
||||
import AccessControlsController from "../../controllers/access-controls-controller";
|
||||
import AuthController from "../../controllers/auth-controller";
|
||||
import LogsController from "../../controllers/logs-controller";
|
||||
import OverviewController from "../../controllers/overview-controller";
|
||||
import TaskPrioritiesController from "../../controllers/task-priorities-controller";
|
||||
|
||||
import attachmentsApiRouter from "./attachments-api-router";
|
||||
import clientsApiRouter from "./clients-api-router";
|
||||
import jobTitlesApiRouter from "./job-titles-api-router";
|
||||
import notificationsApiRouter from "./notifications-api-router";
|
||||
import personalOverviewApiRouter from "./personal-overview-api-router";
|
||||
import projectMembersApiRouter from "./project-members-api-router";
|
||||
import projectsApiRouter from "./projects-api-router";
|
||||
import settingsApiRouter from "./settings-api-router";
|
||||
import statusesApiRouter from "./statuses-api-router";
|
||||
import subTasksApiRouter from "./sub-tasks-api-router";
|
||||
import taskCommentsApiRouter from "./task-comments-api-router";
|
||||
import taskWorkLogApiRouter from "./task-work-log-api-router";
|
||||
import tasksApiRouter from "./tasks-api-router";
|
||||
import teamMembersApiRouter from "./team-members-api-router";
|
||||
import teamsApiRouter from "./teams-api-router";
|
||||
import timezonesApiRouter from "./timezones-api-router";
|
||||
import todoListApiRouter from "./todo-list-api-router";
|
||||
import projectStatusesApiRouter from "./project-statuses-api-router";
|
||||
import labelsApiRouter from "./labels-api-router";
|
||||
import sharedProjectsApiRouter from "./shared-projects-api-router";
|
||||
import resourceAllocationApiRouter from "./resource-allocation-api-router";
|
||||
import taskTemplatesApiRouter from "./task-templates-api-router";
|
||||
import projectInsightsApiRouter from "./project-insights-api-router";
|
||||
import passwordValidator from "../../middlewares/validators/password-validator";
|
||||
import adminCenterApiRouter from "./admin-center-api-router";
|
||||
import reportingApiRouter from "./reporting-api-router";
|
||||
import activityLogsApiRouter from "./activity-logs-api-router";
|
||||
import safeControllerFunction from "../../shared/safe-controller-function";
|
||||
import projectFoldersApiRouter from "./project-folders-api-router";
|
||||
import taskPhasesApiRouter from "./task-phases-api-router";
|
||||
import projectCategoriesApiRouter from "./project-categories-api-router";
|
||||
import homePageApiRouter from "./home-page-api-router";
|
||||
import ganttApiRouter from "./gantt-api-router";
|
||||
import projectCommentsApiRouter from "./project-comments-api-router";
|
||||
import reportingExportApiRouter from "./reporting-export-api-router";
|
||||
import projectHealthsApiRouter from "./project-healths-api-router";
|
||||
import ptTasksApiRouter from "./pt-tasks-api-router";
|
||||
import projectTemplatesApiRouter from "./project-templates-api";
|
||||
import ptTaskPhasesApiRouter from "./pt_task-phases-api-router";
|
||||
import ptStatusesApiRouter from "./pt-statuses-api-router";
|
||||
import workloadApiRouter from "./gannt-apis/workload-api-router";
|
||||
import roadmapApiRouter from "./gannt-apis/roadmap-api-router";
|
||||
import scheduleApiRouter from "./gannt-apis/schedule-api-router";
|
||||
import scheduleApiV2Router from "./gannt-apis/schedule-api-v2-router";
|
||||
import projectManagerApiRouter from "./project-managers-api-router";
|
||||
|
||||
import 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 ratecardApiRouter from "./ratecard-api-router";
|
||||
import projectRatecardApiRouter from "./project-ratecard-api-router";
|
||||
import projectFinanceApiRouter from "./project-finance-api-router";
|
||||
|
||||
const api = express.Router();
|
||||
|
||||
api.use("/projects", projectsApiRouter);
|
||||
api.use("/team-members", teamMembersApiRouter);
|
||||
api.use("/job-titles", jobTitlesApiRouter);
|
||||
api.use("/clients", clientsApiRouter);
|
||||
api.use("/rate-cards", ratecardApiRouter);
|
||||
api.use("/project-rate-cards", projectRatecardApiRouter);
|
||||
api.use("/teams", teamsApiRouter);
|
||||
api.use("/tasks", tasksApiRouter);
|
||||
api.use("/settings", settingsApiRouter);
|
||||
api.use("/personal-overview", personalOverviewApiRouter);
|
||||
api.use("/statuses", statusesApiRouter);
|
||||
api.use("/todo-list", todoListApiRouter);
|
||||
api.use("/notifications", notificationsApiRouter);
|
||||
api.use("/attachments", attachmentsApiRouter);
|
||||
api.use("/sub-tasks", subTasksApiRouter);
|
||||
api.use("/project-members", projectMembersApiRouter);
|
||||
api.use("/task-time-log", taskWorkLogApiRouter);
|
||||
api.use("/task-comments", taskCommentsApiRouter);
|
||||
api.use("/timezones", timezonesApiRouter);
|
||||
api.use("/project-statuses", projectStatusesApiRouter);
|
||||
api.use("/labels", labelsApiRouter);
|
||||
api.use("/resource-allocation", resourceAllocationApiRouter);
|
||||
api.use("/shared/projects", sharedProjectsApiRouter);
|
||||
api.use("/task-templates", taskTemplatesApiRouter);
|
||||
api.use("/project-insights", projectInsightsApiRouter);
|
||||
api.use("/admin-center", adminCenterApiRouter);
|
||||
api.use("/reporting", reportingApiRouter);
|
||||
api.use("/activity-logs", activityLogsApiRouter);
|
||||
api.use("/projects-folders", projectFoldersApiRouter);
|
||||
api.use("/task-phases", taskPhasesApiRouter);
|
||||
api.use("/project-categories", projectCategoriesApiRouter);
|
||||
api.use("/home", homePageApiRouter);
|
||||
api.use("/gantt", ganttApiRouter);
|
||||
api.use("/project-comments", projectCommentsApiRouter);
|
||||
api.use("/reporting-export", reportingExportApiRouter);
|
||||
api.use("/project-healths", projectHealthsApiRouter);
|
||||
api.use("/project-templates", projectTemplatesApiRouter);
|
||||
api.use("/pt-tasks", ptTasksApiRouter);
|
||||
api.use("/pt-task-phases", ptTaskPhasesApiRouter);
|
||||
api.use("/pt-statuses", ptStatusesApiRouter);
|
||||
api.use("/workload-gannt", workloadApiRouter);
|
||||
api.use("/roadmap-gannt", roadmapApiRouter);
|
||||
api.use("/schedule-gannt", scheduleApiRouter);
|
||||
api.use("/schedule-gannt-v2", scheduleApiV2Router);
|
||||
api.use("/project-managers", projectManagerApiRouter);
|
||||
|
||||
api.get("/overview/:id", safeControllerFunction(OverviewController.getById));
|
||||
api.get("/task-priorities", safeControllerFunction(TaskPrioritiesController.get));
|
||||
api.post("/change-password", passwordValidator, safeControllerFunction(AuthController.changePassword));
|
||||
api.get("/access-controls/roles", safeControllerFunction(AccessControlsController.getRoles));
|
||||
api.get("/logs/my-dashboard", safeControllerFunction(LogsController.getActivityLog));
|
||||
|
||||
api.use("/billing", billingApiRouter);
|
||||
api.use("/task-dependencies", taskDependenciesApiRouter);
|
||||
|
||||
api.use("/task-recurring", taskRecurringApiRouter);
|
||||
|
||||
|
||||
const api = express.Router();
|
||||
|
||||
api.use("/projects", projectsApiRouter);
|
||||
api.use("/team-members", teamMembersApiRouter);
|
||||
api.use("/job-titles", jobTitlesApiRouter);
|
||||
api.use("/clients", clientsApiRouter);
|
||||
api.use("/teams", teamsApiRouter);
|
||||
api.use("/tasks", tasksApiRouter);
|
||||
api.use("/settings", settingsApiRouter);
|
||||
api.use("/personal-overview", personalOverviewApiRouter);
|
||||
api.use("/statuses", statusesApiRouter);
|
||||
api.use("/todo-list", todoListApiRouter);
|
||||
api.use("/notifications", notificationsApiRouter);
|
||||
api.use("/attachments", attachmentsApiRouter);
|
||||
api.use("/sub-tasks", subTasksApiRouter);
|
||||
api.use("/project-members", projectMembersApiRouter);
|
||||
api.use("/task-time-log", taskWorkLogApiRouter);
|
||||
api.use("/task-comments", taskCommentsApiRouter);
|
||||
api.use("/timezones", timezonesApiRouter);
|
||||
api.use("/project-statuses", projectStatusesApiRouter);
|
||||
api.use("/labels", labelsApiRouter);
|
||||
api.use("/resource-allocation", resourceAllocationApiRouter);
|
||||
api.use("/shared/projects", sharedProjectsApiRouter);
|
||||
api.use("/task-templates", taskTemplatesApiRouter);
|
||||
api.use("/project-insights", projectInsightsApiRouter);
|
||||
api.use("/admin-center", adminCenterApiRouter);
|
||||
api.use("/reporting", reportingApiRouter);
|
||||
api.use("/activity-logs", activityLogsApiRouter);
|
||||
api.use("/projects-folders", projectFoldersApiRouter);
|
||||
api.use("/task-phases", taskPhasesApiRouter);
|
||||
api.use("/project-categories", projectCategoriesApiRouter);
|
||||
api.use("/home", homePageApiRouter);
|
||||
api.use("/gantt", ganttApiRouter);
|
||||
api.use("/project-comments", projectCommentsApiRouter);
|
||||
api.use("/reporting-export", reportingExportApiRouter);
|
||||
api.use("/project-healths", projectHealthsApiRouter);
|
||||
api.use("/project-templates", projectTemplatesApiRouter);
|
||||
api.use("/pt-tasks", ptTasksApiRouter);
|
||||
api.use("/pt-task-phases", ptTaskPhasesApiRouter);
|
||||
api.use("/pt-statuses", ptStatusesApiRouter);
|
||||
api.use("/workload-gannt", workloadApiRouter);
|
||||
api.use("/roadmap-gannt", roadmapApiRouter);
|
||||
api.use("/schedule-gannt", scheduleApiRouter);
|
||||
api.use("/schedule-gannt-v2", scheduleApiV2Router);
|
||||
api.use("/project-managers", projectManagerApiRouter);
|
||||
|
||||
api.get("/overview/:id", safeControllerFunction(OverviewController.getById));
|
||||
api.get("/task-priorities", safeControllerFunction(TaskPrioritiesController.get));
|
||||
api.post("/change-password", passwordValidator, safeControllerFunction(AuthController.changePassword));
|
||||
api.get("/access-controls/roles", safeControllerFunction(AccessControlsController.getRoles));
|
||||
api.get("/logs/my-dashboard", safeControllerFunction(LogsController.getActivityLog));
|
||||
|
||||
api.use("/billing", billingApiRouter);
|
||||
api.use("/task-dependencies", taskDependenciesApiRouter);
|
||||
|
||||
api.use("/task-recurring", taskRecurringApiRouter);
|
||||
api.use("/custom-columns", customColumnsApiRouter);
|
||||
|
||||
|
||||
api.use("/project-finance", projectFinanceApiRouter);
|
||||
|
||||
export default api;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import express from "express";
|
||||
|
||||
import ProjectfinanceController from "../../controllers/project-finance-controller";
|
||||
import idParamValidator from "../../middlewares/validators/id-param-validator";
|
||||
import safeControllerFunction from "../../shared/safe-controller-function";
|
||||
|
||||
const projectFinanceApiRouter = express.Router();
|
||||
|
||||
projectFinanceApiRouter.get("/project/:project_id/tasks", ProjectfinanceController.getTasks);
|
||||
projectFinanceApiRouter.get("/project/:project_id/tasks/:parent_task_id/subtasks", ProjectfinanceController.getSubTasks);
|
||||
projectFinanceApiRouter.get(
|
||||
"/task/:id/breakdown",
|
||||
idParamValidator,
|
||||
safeControllerFunction(ProjectfinanceController.getTaskBreakdown)
|
||||
);
|
||||
projectFinanceApiRouter.put("/task/:task_id/fixed-cost", ProjectfinanceController.updateTaskFixedCost);
|
||||
projectFinanceApiRouter.put("/project/:project_id/currency", ProjectfinanceController.updateProjectCurrency);
|
||||
projectFinanceApiRouter.get("/project/:project_id/export", ProjectfinanceController.exportFinanceData);
|
||||
|
||||
export default projectFinanceApiRouter;
|
||||
@@ -0,0 +1,69 @@
|
||||
import express from "express";
|
||||
import ProjectRateCardController from "../../controllers/project-ratecard-controller";
|
||||
import idParamValidator from "../../middlewares/validators/id-param-validator";
|
||||
import safeControllerFunction from "../../shared/safe-controller-function";
|
||||
import projectManagerValidator from "../../middlewares/validators/project-manager-validator";
|
||||
|
||||
const projectRatecardApiRouter = express.Router();
|
||||
|
||||
// Insert multiple roles for a project
|
||||
projectRatecardApiRouter.post(
|
||||
"/",
|
||||
projectManagerValidator,
|
||||
safeControllerFunction(ProjectRateCardController.createMany)
|
||||
);
|
||||
// Insert a single role for a project
|
||||
projectRatecardApiRouter.post(
|
||||
"/create-project-rate-card-role",
|
||||
projectManagerValidator,
|
||||
safeControllerFunction(ProjectRateCardController.createOne)
|
||||
);
|
||||
|
||||
// Get all roles for a project
|
||||
projectRatecardApiRouter.get(
|
||||
"/project/:project_id",
|
||||
safeControllerFunction(ProjectRateCardController.getByProjectId)
|
||||
);
|
||||
|
||||
// Get a single role by id
|
||||
projectRatecardApiRouter.get(
|
||||
"/:id",
|
||||
idParamValidator,
|
||||
safeControllerFunction(ProjectRateCardController.getById)
|
||||
);
|
||||
|
||||
// Update a single role by id
|
||||
projectRatecardApiRouter.put(
|
||||
"/:id",
|
||||
idParamValidator,
|
||||
safeControllerFunction(ProjectRateCardController.updateById)
|
||||
);
|
||||
|
||||
// Update all roles for a project (delete then insert)
|
||||
projectRatecardApiRouter.put(
|
||||
"/project/:project_id",
|
||||
safeControllerFunction(ProjectRateCardController.updateByProjectId)
|
||||
);
|
||||
|
||||
// Update project member rate card role
|
||||
projectRatecardApiRouter.put(
|
||||
"/project/:project_id/members/:id/rate-card-role",
|
||||
idParamValidator,
|
||||
projectManagerValidator,
|
||||
safeControllerFunction(ProjectRateCardController.updateProjectMemberByProjectIdAndMemberId)
|
||||
);
|
||||
|
||||
// Delete a single role by id
|
||||
projectRatecardApiRouter.delete(
|
||||
"/:id",
|
||||
idParamValidator,
|
||||
safeControllerFunction(ProjectRateCardController.deleteById)
|
||||
);
|
||||
|
||||
// Delete all roles for a project
|
||||
projectRatecardApiRouter.delete(
|
||||
"/project/:project_id",
|
||||
safeControllerFunction(ProjectRateCardController.deleteByProjectId)
|
||||
);
|
||||
|
||||
export default projectRatecardApiRouter;
|
||||
@@ -18,7 +18,6 @@ projectsApiRouter.get("/update-exist-sort-order", safeControllerFunction(Project
|
||||
|
||||
projectsApiRouter.post("/", teamOwnerOrAdminValidator, projectsBodyValidator, safeControllerFunction(ProjectsController.create));
|
||||
projectsApiRouter.get("/", safeControllerFunction(ProjectsController.get));
|
||||
projectsApiRouter.get("/grouped", safeControllerFunction(ProjectsController.getGrouped));
|
||||
projectsApiRouter.get("/my-task-projects", safeControllerFunction(ProjectsController.getMyProjectsToTasks));
|
||||
projectsApiRouter.get("/my-projects", safeControllerFunction(ProjectsController.getMyProjects));
|
||||
projectsApiRouter.get("/all", safeControllerFunction(ProjectsController.getAllProjects));
|
||||
|
||||
48
worklenz-backend/src/routes/apis/ratecard-api-router.ts
Normal file
48
worklenz-backend/src/routes/apis/ratecard-api-router.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import express from "express";
|
||||
|
||||
import RateCardController from "../../controllers/ratecard-controller";
|
||||
|
||||
|
||||
import idParamValidator from "../../middlewares/validators/id-param-validator";
|
||||
import teamOwnerOrAdminValidator from "../../middlewares/validators/team-owner-or-admin-validator";
|
||||
import safeControllerFunction from "../../shared/safe-controller-function";
|
||||
import projectManagerValidator from "../../middlewares/validators/project-manager-validator";
|
||||
import ratecardBodyValidator from "../../middlewares/validators/ratecard-body-validator";
|
||||
|
||||
const ratecardApiRouter = express.Router();
|
||||
|
||||
ratecardApiRouter.post(
|
||||
"/",
|
||||
projectManagerValidator,
|
||||
ratecardBodyValidator,
|
||||
safeControllerFunction(RateCardController.create)
|
||||
);
|
||||
|
||||
ratecardApiRouter.get(
|
||||
"/",
|
||||
safeControllerFunction(RateCardController.get)
|
||||
);
|
||||
|
||||
ratecardApiRouter.get(
|
||||
"/:id",
|
||||
teamOwnerOrAdminValidator,
|
||||
idParamValidator,
|
||||
safeControllerFunction(RateCardController.getById)
|
||||
);
|
||||
|
||||
ratecardApiRouter.put(
|
||||
"/:id",
|
||||
teamOwnerOrAdminValidator,
|
||||
ratecardBodyValidator,
|
||||
idParamValidator,
|
||||
safeControllerFunction(RateCardController.update)
|
||||
);
|
||||
|
||||
ratecardApiRouter.delete(
|
||||
"/:id",
|
||||
teamOwnerOrAdminValidator,
|
||||
idParamValidator,
|
||||
safeControllerFunction(RateCardController.deleteById)
|
||||
);
|
||||
|
||||
export default ratecardApiRouter;
|
||||
@@ -18,7 +18,6 @@ statusesApiRouter.put("/order", statusOrderValidator, safeControllerFunction(Tas
|
||||
statusesApiRouter.get("/categories", safeControllerFunction(TaskStatusesController.getCategories));
|
||||
statusesApiRouter.get("/:id", idParamValidator, safeControllerFunction(TaskStatusesController.getById));
|
||||
statusesApiRouter.put("/name/:id", projectManagerValidator, idParamValidator, taskStatusBodyValidator, safeControllerFunction(TaskStatusesController.updateName));
|
||||
statusesApiRouter.put("/category/:id", projectManagerValidator, idParamValidator, safeControllerFunction(TaskStatusesController.updateCategory));
|
||||
statusesApiRouter.put("/:id", projectManagerValidator, idParamValidator, taskStatusBodyValidator, safeControllerFunction(TaskStatusesController.update));
|
||||
statusesApiRouter.delete("/:id", projectManagerValidator, idParamValidator, statusDeleteValidator, safeControllerFunction(TaskStatusesController.deleteById));
|
||||
|
||||
|
||||
@@ -42,9 +42,6 @@ tasksApiRouter.get("/list/columns/:id", idParamValidator, safeControllerFunction
|
||||
tasksApiRouter.put("/list/columns/:id", idParamValidator, safeControllerFunction(TaskListColumnsController.toggleColumn));
|
||||
|
||||
tasksApiRouter.get("/list/v2/:id", idParamValidator, safeControllerFunction(getList));
|
||||
tasksApiRouter.get("/list/v3/:id", idParamValidator, safeControllerFunction(TasksControllerV2.getTasksV3));
|
||||
tasksApiRouter.post("/refresh-progress/:id", idParamValidator, safeControllerFunction(TasksControllerV2.refreshTaskProgress));
|
||||
tasksApiRouter.get("/progress-status/:id", idParamValidator, safeControllerFunction(TasksControllerV2.getTaskProgressStatus));
|
||||
tasksApiRouter.get("/assignees/:id", idParamValidator, safeControllerFunction(TasksController.getProjectTaskAssignees));
|
||||
|
||||
tasksApiRouter.put("/bulk/status", mapTasksToBulkUpdate, bulkTasksStatusValidator, safeControllerFunction(TasksController.bulkChangeStatus));
|
||||
@@ -72,4 +69,7 @@ tasksApiRouter.put("/labels/:id", idParamValidator, safeControllerFunction(Tasks
|
||||
// Add custom column value update route
|
||||
tasksApiRouter.put("/:taskId/custom-column", TasksControllerV2.updateCustomColumnValue);
|
||||
|
||||
// Add route to reset parent task estimations
|
||||
tasksApiRouter.post("/reset-parent-estimations", safeControllerFunction(TasksController.resetParentTaskEstimations));
|
||||
|
||||
export default tasksApiRouter;
|
||||
|
||||
@@ -166,7 +166,9 @@ export const UNMAPPED = "Unmapped";
|
||||
|
||||
export const DATE_RANGES = {
|
||||
YESTERDAY: "YESTERDAY",
|
||||
LAST_7_DAYS: "LAST_7_DAYS",
|
||||
LAST_WEEK: "LAST_WEEK",
|
||||
LAST_30_DAYS: "LAST_30_DAYS",
|
||||
LAST_MONTH: "LAST_MONTH",
|
||||
LAST_QUARTER: "LAST_QUARTER",
|
||||
ALL_TIME: "ALL_TIME"
|
||||
|
||||
@@ -19,8 +19,7 @@ export async function on_project_subscriber_change(_io: Server, socket: Socket,
|
||||
const isSubscribe = data.mode == 0;
|
||||
const q = isSubscribe
|
||||
? `INSERT INTO project_subscribers (user_id, project_id, team_member_id)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (user_id, project_id, team_member_id) DO NOTHING;`
|
||||
VALUES ($1, $2, $3);`
|
||||
: `DELETE
|
||||
FROM project_subscribers
|
||||
WHERE user_id = $1
|
||||
@@ -28,7 +27,7 @@ export async function on_project_subscriber_change(_io: Server, socket: Socket,
|
||||
AND team_member_id = $3;`;
|
||||
await db.query(q, [data.user_id, data.project_id, data.team_member_id]);
|
||||
|
||||
const subscribers = await TasksControllerV2.getProjectSubscribers(data.project_id);
|
||||
const subscribers = await TasksControllerV2.getTaskSubscribers(data.project_id);
|
||||
socket.emit(SocketEvents.PROJECT_SUBSCRIBERS_CHANGE.toString(), subscribers);
|
||||
|
||||
return;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import {Server, Socket} from "socket.io";
|
||||
import { Server, Socket } from "socket.io";
|
||||
import db from "../../config/db";
|
||||
import {getColor, toMinutes} from "../../shared/utils";
|
||||
import {SocketEvents} from "../events";
|
||||
import { getColor, toMinutes } from "../../shared/utils";
|
||||
import { SocketEvents } from "../events";
|
||||
|
||||
import {log_error, notifyProjectUpdates} from "../util";
|
||||
import { log_error, notifyProjectUpdates } from "../util";
|
||||
import TasksControllerV2 from "../../controllers/tasks-controller-v2";
|
||||
import {TASK_STATUS_COLOR_ALPHA, UNMAPPED} from "../../shared/constants";
|
||||
import { TASK_STATUS_COLOR_ALPHA, UNMAPPED } from "../../shared/constants";
|
||||
import moment from "moment";
|
||||
import momentTime from "moment-timezone";
|
||||
import { logEndDateChange, logStartDateChange, logStatusChange } from "../../services/activity-logs/activity-logs.service";
|
||||
@@ -18,8 +18,9 @@ export async function getTaskCompleteInfo(task: any) {
|
||||
const [d2] = result2.rows;
|
||||
|
||||
task.completed_count = d2.res.total_completed || 0;
|
||||
if (task.sub_tasks_count > 0)
|
||||
if (task.sub_tasks_count > 0 && d2.res.total_tasks > 0) {
|
||||
task.sub_tasks_count = d2.res.total_tasks;
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
@@ -56,8 +57,6 @@ export async function on_quick_task(_io: Server, socket: Socket, data?: string)
|
||||
const q = `SELECT create_quick_task($1) AS task;`;
|
||||
const body = JSON.parse(data as string);
|
||||
|
||||
|
||||
|
||||
body.name = (body.name || "").trim();
|
||||
body.priority_id = body.priority_id?.trim() || null;
|
||||
body.status_id = body.status_id?.trim() || null;
|
||||
@@ -99,8 +98,8 @@ export async function on_quick_task(_io: Server, socket: Socket, data?: string)
|
||||
logEndDateChange({
|
||||
task_id: d.task.id,
|
||||
socket,
|
||||
new_value: body.time_zone && d.task.end_date ? momentTime.tz(d.task.end_date, `${body.time_zone}`) : d.task.end_date,
|
||||
old_value: null
|
||||
new_value: body.time_zone && d.task.end_date ? momentTime.tz(d.task.end_date, `${body.time_zone}`) : d.task.end_date,
|
||||
old_value: null
|
||||
});
|
||||
}
|
||||
|
||||
@@ -113,12 +112,10 @@ export async function on_quick_task(_io: Server, socket: Socket, data?: string)
|
||||
|
||||
notifyProjectUpdates(socket, d.task.id);
|
||||
}
|
||||
} else {
|
||||
// Empty task name, emit null to indicate no task was created
|
||||
socket.emit(SocketEvents.QUICK_TASK.toString(), null);
|
||||
}
|
||||
} catch (error) {
|
||||
log_error(error);
|
||||
socket.emit(SocketEvents.QUICK_TASK.toString(), null);
|
||||
}
|
||||
|
||||
socket.emit(SocketEvents.QUICK_TASK.toString(), null);
|
||||
}
|
||||
|
||||
@@ -138,4 +138,4 @@ export async function on_task_sort_order_change(_io: Server, socket: Socket, dat
|
||||
}
|
||||
|
||||
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), []);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,10 +58,10 @@ export async function on_task_status_change(_io: Server, socket: Socket, data?:
|
||||
FROM tasks
|
||||
WHERE id = $1
|
||||
`, [body.task_id]);
|
||||
|
||||
|
||||
const currentProgress = progressResult.rows[0]?.progress_value;
|
||||
const isManualProgress = progressResult.rows[0]?.manual_progress;
|
||||
|
||||
|
||||
// Only update if not already 100%
|
||||
if (currentProgress !== 100) {
|
||||
// Update progress to 100%
|
||||
@@ -70,9 +70,9 @@ export async function on_task_status_change(_io: Server, socket: Socket, data?:
|
||||
SET progress_value = 100, manual_progress = TRUE
|
||||
WHERE id = $1
|
||||
`, [body.task_id]);
|
||||
|
||||
|
||||
log(`Task ${body.task_id} moved to done status - progress automatically set to 100%`, null);
|
||||
|
||||
|
||||
// Log the progress change to activity logs
|
||||
await logProgressChange({
|
||||
task_id: body.task_id,
|
||||
@@ -80,7 +80,7 @@ export async function on_task_status_change(_io: Server, socket: Socket, data?:
|
||||
new_value: "100",
|
||||
socket
|
||||
});
|
||||
|
||||
|
||||
// If this is a subtask, update parent task progress
|
||||
if (body.parent_task) {
|
||||
setTimeout(() => {
|
||||
@@ -88,23 +88,6 @@ export async function on_task_status_change(_io: Server, socket: Socket, data?:
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Task is moving from "done" to "todo" or "doing" - reset manual_progress to FALSE
|
||||
// so progress can be recalculated based on subtasks
|
||||
await db.query(`
|
||||
UPDATE tasks
|
||||
SET manual_progress = FALSE
|
||||
WHERE id = $1
|
||||
`, [body.task_id]);
|
||||
|
||||
log(`Task ${body.task_id} moved from done status - manual_progress reset to FALSE`, null);
|
||||
|
||||
// If this is a subtask, update parent task progress
|
||||
if (body.parent_task) {
|
||||
setTimeout(() => {
|
||||
socket.emit(SocketEvents.GET_TASK_PROGRESS.toString(), body.parent_task);
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
const info = await TasksControllerV2.getTaskCompleteRatio(body.parent_task || body.task_id);
|
||||
|
||||
@@ -2,35 +2,31 @@
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<title>Worklenz 2.1.0 Release</title>
|
||||
<meta name="subject" content="Worklenz 2.1.0 Release" />
|
||||
<title></title>
|
||||
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
|
||||
<meta content="width=device-width,initial-scale=1" name="viewport">
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
box-sizing: border-box
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #f6f8fa;
|
||||
font-family: 'Mada', 'Segoe UI', Arial, sans-serif;
|
||||
color: #222;
|
||||
padding: 0
|
||||
}
|
||||
|
||||
a[x-apple-data-detectors] {
|
||||
color: inherit !important;
|
||||
text-decoration: inherit !important;
|
||||
text-decoration: inherit !important
|
||||
}
|
||||
|
||||
#MessageViewBody a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
text-decoration: none
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: 1.6;
|
||||
line-height: inherit
|
||||
}
|
||||
|
||||
.padding-30 {
|
||||
@@ -41,201 +37,272 @@
|
||||
padding: 0px 20px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 2px 12px rgba(24, 144, 255, 0.08);
|
||||
margin-bottom: 32px;
|
||||
padding: 32px 32px 24px 32px;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
color: #1890ff;
|
||||
margin-top: 0;
|
||||
margin-bottom: 12px;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.card img {
|
||||
border-radius: 10px;
|
||||
margin: 18px 0 0 0;
|
||||
box-shadow: 0 1px 8px rgba(24, 144, 255, 0.07);
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.feature-list {
|
||||
padding-left: 18px;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.feature-list li {
|
||||
margin-bottom: 6px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.lang-badge {
|
||||
display: inline-block;
|
||||
background: #e6f7ff;
|
||||
color: #1890ff;
|
||||
border-radius: 8px;
|
||||
padding: 3px 10px;
|
||||
font-size: 14px;
|
||||
margin-right: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.main-btn {
|
||||
background: #1890ff;
|
||||
border: none;
|
||||
outline: none;
|
||||
padding: 14px 28px;
|
||||
font-size: 18px;
|
||||
text-decoration: none;
|
||||
color: white;
|
||||
border-radius: 23px;
|
||||
margin: 32px auto 0 auto;
|
||||
font-family: 'Mada', sans-serif;
|
||||
display: inline-block;
|
||||
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.13);
|
||||
transition: background 0.2s, color 0.2s, border 0.2s;
|
||||
border: 2px solid #1890ff;
|
||||
}
|
||||
|
||||
.main-btn:hover {
|
||||
background: #40a9ff;
|
||||
color: #fff;
|
||||
border-color: #40a9ff;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.card {
|
||||
padding: 18px 8px 16px 8px;
|
||||
}
|
||||
|
||||
.main-btn {
|
||||
width: 90%;
|
||||
font-size: 16px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background: #181a1b;
|
||||
color: #e6e6e6;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #23272a;
|
||||
box-shadow: 0 2px 12px rgba(24, 144, 255, 0.13);
|
||||
}
|
||||
|
||||
.main-btn {
|
||||
background: #1890ff;
|
||||
color: #fff;
|
||||
border: 2px solid #1890ff;
|
||||
}
|
||||
|
||||
.main-btn:hover {
|
||||
background: #40a9ff;
|
||||
color: #fff;
|
||||
border-color: #40a9ff;
|
||||
}
|
||||
|
||||
.logo-light {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.logo-dark {
|
||||
display: block !important;
|
||||
}
|
||||
}
|
||||
|
||||
.logo-light {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.logo-dark {
|
||||
.desktop_hide,
|
||||
.desktop_hide table {
|
||||
mso-hide: all;
|
||||
display: none;
|
||||
max-height: 0;
|
||||
overflow: hidden
|
||||
}
|
||||
|
||||
@media (max-width: 525px) {
|
||||
.desktop_hide table.icons-inner {
|
||||
display: inline-block !important
|
||||
}
|
||||
|
||||
.icons-inner {
|
||||
text-align: center
|
||||
}
|
||||
|
||||
.icons-inner td {
|
||||
margin: 0 auto
|
||||
}
|
||||
|
||||
.row-content {
|
||||
width: 95% !important
|
||||
}
|
||||
|
||||
.mobile_hide {
|
||||
display: none
|
||||
}
|
||||
|
||||
.stack .column {
|
||||
width: 100%;
|
||||
display: block
|
||||
}
|
||||
|
||||
.mobile_hide {
|
||||
min-height: 0;
|
||||
max-height: 0;
|
||||
max-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 0
|
||||
}
|
||||
|
||||
.desktop_hide,
|
||||
.desktop_hide table {
|
||||
display: table !important;
|
||||
max-height: none !important
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="background: #f6f8fa;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="720" style="max-width: 98vw;">
|
||||
<tr>
|
||||
<td align="center" style="padding: 32px 0 18px 0;">
|
||||
<a href="https://worklenz.com" target="_blank" style="display: inline-block;">
|
||||
<img class="logo-light"
|
||||
src="https://s3.us-west-2.amazonaws.com/worklenz.com/assets/worklenz-light-mode.png"
|
||||
alt="Worklenz Light Logo" style="width: 170px; margin-bottom: 0; display: block;" />
|
||||
<img class="logo-dark"
|
||||
src="https://s3.us-west-2.amazonaws.com/worklenz.com/assets/worklenz-dark-mode.png"
|
||||
alt="Worklenz Dark Logo" style="width: 170px; margin-bottom: 0; display: none;" />
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<div class="card">
|
||||
<h3>🚀 New Tasks List & Kanban Board</h3>
|
||||
<ul class="feature-list">
|
||||
<li>Performance optimized for faster loading</li>
|
||||
<li>Redesigned UI for clarity and speed</li>
|
||||
<li>Advanced filters for easier task management</li>
|
||||
</ul>
|
||||
<img src="https://s3.us-west-2.amazonaws.com/worklenz.com/gifs/WL20250708/task-list-v2.gif"
|
||||
alt="New Task List">
|
||||
<img src="https://s3.us-west-2.amazonaws.com/worklenz.com/gifs/WL20250708/kanban-v2.gif"
|
||||
alt="New Kanban Board">
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>📁 Group View in Projects List</h3>
|
||||
<ul class="feature-list">
|
||||
<li>Toggle between list and group view</li>
|
||||
<li>Group projects by client or category</li>
|
||||
<li>Improved navigation and organization</li>
|
||||
</ul>
|
||||
<img src="https://s3.us-west-2.amazonaws.com/worklenz.com/gifs/WL20250708/project-list-group-view.gif"
|
||||
alt="Project List Group View">
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>🌐 New Language Support</h3>
|
||||
<span class="lang-badge">Deutsch (DE)</span>
|
||||
<span class="lang-badge">Shqip (ALB)</span>
|
||||
<p style="margin-top: 10px;">Worklenz is now available in German and Albanian!</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>🛠️ Bug Fixes & UI Improvements</h3>
|
||||
<ul class="feature-list">
|
||||
<li>General bug fixes</li>
|
||||
<li>UI/UX enhancements for a smoother experience</li>
|
||||
<li>Performance improvements across the platform</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div style="text-align: center;">
|
||||
<a href="https://app.worklenz.com/auth" target="_blank" class="main-btn">See what's new</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 32px 0 0 0;">
|
||||
<hr style="border: none; border-top: 1px solid #e6e6e6; margin: 32px 0 16px 0;">
|
||||
<p style="font-family:sans-serif;text-decoration:none; text-align: center; color: #888; font-size: 15px;">
|
||||
Click <a href="{{unsubscribe}}" target="_blank" style="color: #1890ff;">here</a> to unsubscribe and
|
||||
manage your email preferences.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<body style="background-color:#fff;margin:0;padding:0;-webkit-text-size-adjust:none;text-size-adjust:none">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;background-image:none;background-position:top left;background-size:auto;background-repeat:no-repeat"
|
||||
width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-1" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-1 padding-20"
|
||||
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;margin-left: auto;margin-right: auto;padding-top: 20px;
|
||||
padding-bottom: 20px;" width="300">
|
||||
<tr>
|
||||
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
||||
<div align="left" class="alignment" style="line-height:10px">
|
||||
<a href="https://worklenz.com" style="outline:none;width: 170px;" tabindex="-1"
|
||||
target="_blank"><img
|
||||
src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-logo.png"
|
||||
style="display:block;max-width: 300px;height:auto;border:0;max-width:100%;margin-top: 10px;margin-bottom: 0px;"></a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack"
|
||||
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:720px;"
|
||||
width="475">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="column column-1"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;font-weight:400;text-align:left;vertical-align:top;padding-top:5px;padding-bottom:5px;border-top:0;border-right:0;border-bottom:0;border-left:0"
|
||||
width="100%">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-3"
|
||||
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
|
||||
<tr>
|
||||
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
||||
<div align="center" class="alignment" style="line-height:10px"><img
|
||||
src="https://worklenz.s3.amazonaws.com/email-templates-assets/under-maintenance.png"
|
||||
style="display:block;height:auto;border:0;width:180px;max-width:100%;/* margin-top: 30px; */margin-bottom: 10px;"
|
||||
width="180">
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table border="0" cellpadding="8" cellspacing="0" class="paragraph_block block-5 padding-30"
|
||||
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word"
|
||||
width="100%">
|
||||
<tr>
|
||||
<td class="pad">
|
||||
<div
|
||||
style="color:#505771;font-size:16px;font-family: 'Mada', sans-serif;font-weight:400;line-height:135%;direction:ltr;letter-spacing:0;border-radius: 12px;margin-bottom: 12px;">
|
||||
<h3 style="margin-bottom: 0;">Project Roadmap Redesign</h3>
|
||||
<p>
|
||||
|
||||
Experience a comprehensive visual representation of task progression within your projects.
|
||||
The sequential arrangement unfolds seamlessly in a user-friendly timeline format, allowing
|
||||
for effortless understanding and efficient project management.
|
||||
</p>
|
||||
<img src="https://worklenz.s3.amazonaws.com/gifs/WL20231114/roadmap.gif"
|
||||
style="width: 100%;margin: auto;" alt="Revamped Reporting">
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table border="0" cellpadding="8" cellspacing="0" class="paragraph_block block-5 padding-30"
|
||||
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word"
|
||||
width="100%">
|
||||
<tr>
|
||||
<td class="pad">
|
||||
<div
|
||||
style="color:#505771;font-size:16px;font-family: 'Mada', sans-serif;font-weight:400;line-height:135%;direction:ltr;letter-spacing:0;border-radius: 12px;margin-bottom: 12px;">
|
||||
<h3 style="margin-bottom: 0;">Project Workload Redesign</h3>
|
||||
<p>
|
||||
Gain insights into the optimized allocation and utilization of resources within your project.
|
||||
</p>
|
||||
<img src="https://worklenz.s3.amazonaws.com/gifs/WL20231114/workload-1.gif"
|
||||
style="width: 100%;margin: auto;" alt="Revamped Reporting">
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table border="0" cellpadding="8" cellspacing="0" class="paragraph_block block-5 padding-30"
|
||||
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word"
|
||||
width="100%">
|
||||
<tr>
|
||||
<td class="pad">
|
||||
<div
|
||||
style="color:#505771;font-size:16px;font-family: 'Mada', sans-serif;font-weight:400;line-height:135%;direction:ltr;letter-spacing:0;border-radius: 12px;margin-bottom: 12px;">
|
||||
<h3 style="margin-bottom: 0;">Create new tasks from the roadmap itself</h3>
|
||||
<p>
|
||||
Effortlessly generate and modify tasks directly from the roadmap interface with a simple
|
||||
click-and-drag functionality.
|
||||
<br>Seamlessly adjust the task's date range according to your
|
||||
preferences, providing a user-friendly and intuitive experience for efficient task management.
|
||||
</p>
|
||||
<img src="https://worklenz.s3.amazonaws.com/gifs/WL20231114/roadmap-2.gif"
|
||||
style="width: 100%;margin: auto;" alt="Revamped Reporting">
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table border="0" cellpadding="8" cellspacing="0" class="paragraph_block block-5 padding-30"
|
||||
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word"
|
||||
width="100%">
|
||||
<tr>
|
||||
<td class="pad">
|
||||
<div
|
||||
style="color:#505771;font-size:16px;font-family: 'Mada', sans-serif;font-weight:400;line-height:135%;direction:ltr;letter-spacing:0;border-radius: 12px;margin-bottom: 12px;">
|
||||
<h3 style="margin-bottom: 0;">Deactivate Team Members</h3>
|
||||
<p>
|
||||
Effortlessly manage your team by deactivating members without losing their valuable work.
|
||||
<br>
|
||||
<br>
|
||||
Navigate to the "Settings" section and access "Team Members" to conveniently deactivate
|
||||
team members while preserving the work they have contributed.
|
||||
</p>
|
||||
<img src="https://worklenz.s3.amazonaws.com/gifs/WL20231114/workload-1.gif"
|
||||
style="width: 100%;margin: auto;" alt="Revamped Reporting">
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table border="0" cellpadding="8" cellspacing="0" class="paragraph_block block-5 padding-30"
|
||||
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word"
|
||||
width="100%">
|
||||
<tr>
|
||||
<td class="pad">
|
||||
<div
|
||||
style="color:#505771;font-size:16px;font-family: 'Mada', sans-serif;font-weight:400;line-height:135%;direction:ltr;letter-spacing:0;border-radius: 12px;margin-bottom: 12px;">
|
||||
<h3 style="margin-bottom: 0;">Reporting Enhancements</h3>
|
||||
<p>
|
||||
This release also includes several other miscellaneous bug fixes and performance
|
||||
enhancements to further improve your experience.
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div style="text-align: center;">
|
||||
<a href="https://worklenz.com/worklenz" target="_blank"
|
||||
style="background: #1890ff;border: none;outline: none;padding: 12px 16px;font-size: 18px;text-decoration: none;color: white;border-radius: 23px;margin: auto;font-family: 'Mada', sans-serif;">See
|
||||
what's new</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-2" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack"
|
||||
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:475px"
|
||||
width="505">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="column column-1"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;font-weight:400;text-align:left;vertical-align:top;padding-top:5px;padding-bottom:5px;border-top:0;border-right:0;border-bottom:0;border-left:0"
|
||||
width="100%">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="icons_block block-1"
|
||||
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tr>
|
||||
<td class="pad"
|
||||
style="vertical-align:middle;color:#9d9d9d;font-family:inherit;font-size:15px;padding-bottom:5px;padding-top:5px;text-align:center">
|
||||
<table cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tr>
|
||||
<td class="alignment" style="vertical-align:middle;text-align:center">
|
||||
<!--[if vml]>
|
||||
<table align="left" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="display:inline-block;padding-left:0px;padding-right:0px;mso-table-lspace: 0pt;mso-table-rspace: 0pt;">
|
||||
<![endif]-->
|
||||
<!--[if !vml]><!-->
|
||||
<table cellpadding="0" cellspacing="0" class="icons-inner" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;display:inline-block;margin-right:-4px;padding-left:0;padding-right:0">
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table><!-- End -->
|
||||
<hr>
|
||||
<p style="font-family:sans-serif;text-decoration:none; text-align: center;">
|
||||
Click <a href="{{{unsubscribe}}}" target="_blank">here</a> to unsubscribe and manage your email preferences.
|
||||
</p>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.git
|
||||
.gitignore
|
||||
.dockerignore
|
||||
README.md
|
||||
@@ -12,7 +12,7 @@ COPY . .
|
||||
RUN echo "window.VITE_API_URL='${VITE_API_URL:-http://backend:3000}';" > ./public/env-config.js && \
|
||||
echo "window.VITE_SOCKET_URL='${VITE_SOCKET_URL:-ws://backend:3000}';" >> ./public/env-config.js
|
||||
|
||||
RUN NODE_OPTIONS="--max-old-space-size=4096" npm run build
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine AS production
|
||||
|
||||
|
||||
@@ -3,13 +3,11 @@
|
||||
Worklenz is a project management application built with React, TypeScript, and Ant Design. The project is bundled using [Vite](https://vitejs.dev/).
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Getting Started](#getting-started)
|
||||
- [Available Scripts](#available-scripts)
|
||||
- [Project Structure](#project-structure)
|
||||
- [Contributing](#contributing)
|
||||
- [Learn More](#learn-more)
|
||||
- [License](#license)
|
||||
|
||||
## Getting Started
|
||||
|
||||
@@ -95,7 +93,3 @@ To learn more about the technologies used in this project:
|
||||
- [TypeScript Documentation](https://www.typescriptlang.org/docs/)
|
||||
- [Ant Design Documentation](https://ant.design/docs/react/introduce)
|
||||
- [Vite Documentation](https://vitejs.dev/guide/)
|
||||
|
||||
## License
|
||||
|
||||
Worklenz is open source and released under the [GNU Affero General Public License Version 3 (AGPLv3)](LICENSE).
|
||||
|
||||
@@ -1,151 +1,66 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="./favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#2b2b2b" />
|
||||
|
||||
<!-- Resource hints for better loading performance -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link rel="dns-prefetch" href="https://www.googletagmanager.com" />
|
||||
<link rel="dns-prefetch" href="https://js.hs-scripts.com" />
|
||||
|
||||
<!-- Preload critical resources -->
|
||||
<link rel="preload" href="/locales/en/common.json" as="fetch" type="application/json" crossorigin />
|
||||
<link rel="preload" href="/locales/en/auth/login.json" as="fetch" type="application/json" crossorigin />
|
||||
<link rel="preload" href="/locales/en/navbar.json" as="fetch" type="application/json" crossorigin />
|
||||
|
||||
<!-- Optimized font loading with font-display: swap -->
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap"
|
||||
rel="stylesheet"
|
||||
media="print"
|
||||
onload="this.media='all'"
|
||||
/>
|
||||
<noscript>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</noscript>
|
||||
|
||||
<title>Worklenz</title>
|
||||
|
||||
<!-- Environment configuration -->
|
||||
<script src="/env-config.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
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="./favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#2b2b2b" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap"
|
||||
rel="stylesheet" />
|
||||
<!-- SVAR Gantt Icons -->
|
||||
<link rel="stylesheet" href="https://cdn.svar.dev/fonts/wxi/wx-icons.css" />
|
||||
<title>Worklenz</title>
|
||||
|
||||
// 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);
|
||||
<!-- Environment configuration -->
|
||||
<script src="/env-config.js"></script>
|
||||
<!-- Unregister service worker -->
|
||||
<script src="/unregister-sw.js"></script>
|
||||
<!-- Microsoft Clarity -->
|
||||
<script type="text/javascript">
|
||||
if (window.location.hostname === 'app.worklenz.com') {
|
||||
(function (c, l, a, r, i, t, y) {
|
||||
c[a] = c[a] || function () { (c[a].q = c[a].q || []).push(arguments) };
|
||||
t = l.createElement(r); t.async = 1; t.src = "https://www.clarity.ms/tag/dx77073klh";
|
||||
y = l.getElementsByTagName(r)[0]; y.parentNode.insertBefore(t, y);
|
||||
})(window, document, "clarity", "script", "dx77073klh");
|
||||
}
|
||||
</script>
|
||||
<!-- Google Analytics (only on production) -->
|
||||
<script type="text/javascript">
|
||||
if (window.location.hostname === 'app.worklenz.com') {
|
||||
var gaScript = document.createElement('script');
|
||||
gaScript.async = true;
|
||||
gaScript.src = 'https://www.googletagmanager.com/gtag/js?id=G-7KSRKQ1397';
|
||||
document.head.appendChild(gaScript);
|
||||
|
||||
gaScript.onload = function() {
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
gtag('config', 'G-7KSRKQ1397');
|
||||
};
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
|
||||
// Initialize Google Analytics
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag() {
|
||||
dataLayer.push(arguments);
|
||||
}
|
||||
gtag('js', new Date());
|
||||
gtag('config', trackingId);
|
||||
};
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./src/index.tsx"></script>
|
||||
<script type="text/javascript">
|
||||
if (window.location.hostname === 'app.worklenz.com') {
|
||||
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);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
// 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>
|
||||
<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>
|
||||
</html>
|
||||
@@ -1,4 +1,4 @@
|
||||
module.exports = {
|
||||
export default {
|
||||
setupFilesAfterEnv: ['<rootDir>/src/setupTests.ts'],
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
|
||||
1862
worklenz-frontend/package-lock.json
generated
1862
worklenz-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -2,9 +2,9 @@
|
||||
"name": "worklenz",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "vite dev",
|
||||
"dev": "vite dev",
|
||||
"start": "vite",
|
||||
"prebuild": "node scripts/copy-tinymce.js",
|
||||
"build": "vite build",
|
||||
"dev-build": "vite build",
|
||||
@@ -14,25 +14,22 @@
|
||||
"dependencies": {
|
||||
"@ant-design/colors": "^7.1.0",
|
||||
"@ant-design/compatible": "^5.1.4",
|
||||
"@ant-design/icons": "^4.7.0",
|
||||
"@ant-design/icons": "^5.4.0",
|
||||
"@ant-design/pro-components": "^2.7.19",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/modifiers": "^6.0.1",
|
||||
"@dnd-kit/sortable": "^7.0.2",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@paddle/paddle-js": "^1.3.3",
|
||||
"@reduxjs/toolkit": "^2.2.7",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tanstack/react-table": "^8.20.6",
|
||||
"@tanstack/react-virtual": "^3.11.2",
|
||||
"@tinymce/tinymce-react": "^5.1.1",
|
||||
"antd": "^5.26.2",
|
||||
"antd": "^5.24.9",
|
||||
"axios": "^1.9.0",
|
||||
"chart.js": "^4.4.7",
|
||||
"chartjs-plugin-datalabels": "^2.2.0",
|
||||
"cors": "^2.8.5",
|
||||
"date-fns": "^4.1.0",
|
||||
"dompurify": "^3.2.5",
|
||||
"gantt-task-react": "^0.3.9",
|
||||
@@ -42,7 +39,6 @@
|
||||
"i18next-http-backend": "^2.7.3",
|
||||
"jspdf": "^3.0.0",
|
||||
"mixpanel-browser": "^2.56.0",
|
||||
"nanoid": "^5.1.5",
|
||||
"primereact": "^10.8.4",
|
||||
"re-resizable": "^6.10.3",
|
||||
"react": "^18.3.1",
|
||||
@@ -54,13 +50,11 @@
|
||||
"react-responsive": "^10.0.0",
|
||||
"react-router-dom": "^6.28.1",
|
||||
"react-timer-hook": "^3.0.8",
|
||||
"react-virtuoso": "^4.13.0",
|
||||
"react-window": "^1.8.11",
|
||||
"react-window-infinite-loader": "^1.0.10",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"tinymce": "^7.7.2",
|
||||
"web-vitals": "^4.2.4",
|
||||
"worklenz": "file:"
|
||||
"wx-react-gantt": "^1.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
@@ -74,12 +68,10 @@
|
||||
"@types/node": "^20.8.4",
|
||||
"@types/react": "19.0.0",
|
||||
"@types/react-dom": "19.0.0",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.5.2",
|
||||
"prettier-plugin-tailwindcss": "^0.6.13",
|
||||
"rollup": "^4.40.2",
|
||||
"prettier-plugin-tailwindcss": "^0.6.8",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"terser": "^5.39.0",
|
||||
"typescript": "^5.7.3",
|
||||
@@ -87,6 +79,12 @@
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^3.0.5"
|
||||
},
|
||||
"resolutions": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@dnd-kit/sortable": "^7.0.2",
|
||||
"@dnd-kit/modifiers": "^6.0.1"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
module.exports = {
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
|
||||
@@ -14,4 +14,4 @@
|
||||
/* Maintain hover state */
|
||||
.table-body-row:hover .sticky-column {
|
||||
background-color: var(--background-hover);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
// Development placeholder for env-config.js
|
||||
// In production, this file is dynamically generated with actual environment values
|
||||
// For development, we let the application fall back to import.meta.env variables
|
||||
|
||||
// Set undefined values so the application falls back to build-time env vars
|
||||
window.VITE_API_URL = undefined;
|
||||
window.VITE_SOCKET_URL = undefined;
|
||||
@@ -0,0 +1,163 @@
|
||||
[
|
||||
{
|
||||
"id": "c2669c5f-a019-445b-b703-b941bbefdab7",
|
||||
"type": "low",
|
||||
"name": "Low",
|
||||
"color_code": "#c2e4d0",
|
||||
"color_code_dark": "#46d980",
|
||||
"tasks": [
|
||||
{
|
||||
"id": "4be5ef5c-1234-4247-b159-6d8df2b37d04",
|
||||
"task": "Testing and QA",
|
||||
"isBillable": false,
|
||||
"hours": 180,
|
||||
"cost": 18000,
|
||||
"fixedCost": 2500,
|
||||
"totalBudget": 20000,
|
||||
"totalActual": 21000,
|
||||
"variance": -1000,
|
||||
"members": [
|
||||
{
|
||||
"memberId": "6",
|
||||
"name": "Eve Adams",
|
||||
"jobId": "J006",
|
||||
"jobRole": "QA Engineer",
|
||||
"hourlyRate": 100
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "6be5ef5c-1234-4247-b159-6d8df2b37d06",
|
||||
"task": "Project Documentation",
|
||||
"isBillable": false,
|
||||
"hours": 100,
|
||||
"cost": 10000,
|
||||
"fixedCost": 1000,
|
||||
"totalBudget": 12000,
|
||||
"totalActual": 12500,
|
||||
"variance": -500,
|
||||
"members": [
|
||||
{
|
||||
"memberId": "8",
|
||||
"name": "Grace Lee",
|
||||
"jobId": "J008",
|
||||
"jobRole": "Technical Writer",
|
||||
"hourlyRate": 100
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "d3f9c5f1-b019-445b-b703-b941bbefdab8",
|
||||
"type": "medium",
|
||||
"name": "Medium",
|
||||
"color_code": "#f9e3b1",
|
||||
"color_code_dark": "#ffc227",
|
||||
"tasks": [
|
||||
{
|
||||
"id": "1be5ef5c-1234-4247-b159-6d8df2b37d01",
|
||||
"task": "UI Design",
|
||||
"isBillable": true,
|
||||
"hours": 120,
|
||||
"cost": 12000,
|
||||
"fixedCost": 1500,
|
||||
"totalBudget": 14000,
|
||||
"totalActual": 13500,
|
||||
"variance": 500,
|
||||
"members": [
|
||||
{
|
||||
"memberId": "1",
|
||||
"name": "John Doe",
|
||||
"jobId": "J001",
|
||||
"jobRole": "UI/UX Designer",
|
||||
"hourlyRate": 100
|
||||
},
|
||||
{
|
||||
"memberId": "2",
|
||||
"name": "Jane Smith",
|
||||
"jobId": "J002",
|
||||
"jobRole": "Frontend Developer",
|
||||
"hourlyRate": 120
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "2be5ef5c-1234-4247-b159-6d8df2b37d02",
|
||||
"task": "API Integration",
|
||||
"isBillable": true,
|
||||
"hours": 200,
|
||||
"cost": 20000,
|
||||
"fixedCost": 3000,
|
||||
"totalBudget": 25000,
|
||||
"totalActual": 26000,
|
||||
"variance": -1000,
|
||||
"members": [
|
||||
{
|
||||
"memberId": "3",
|
||||
"name": "Alice Johnson",
|
||||
"jobId": "J003",
|
||||
"jobRole": "Backend Developer",
|
||||
"hourlyRate": 100
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "e3f9c5f1-b019-445b-b703-b941bbefdab9",
|
||||
"type": "high",
|
||||
"name": "High",
|
||||
"color_code": "#f6bfc0",
|
||||
"color_code_dark": "#ff4141",
|
||||
"tasks": [
|
||||
{
|
||||
"id": "5be5ef5c-1234-4247-b159-6d8df2b37d05",
|
||||
"task": "Database Migration",
|
||||
"isBillable": true,
|
||||
"hours": 250,
|
||||
"cost": 37500,
|
||||
"fixedCost": 4000,
|
||||
"totalBudget": 42000,
|
||||
"totalActual": 41000,
|
||||
"variance": 1000,
|
||||
"members": [
|
||||
{
|
||||
"memberId": "7",
|
||||
"name": "Frank Harris",
|
||||
"jobId": "J007",
|
||||
"jobRole": "Database Administrator",
|
||||
"hourlyRate": 150
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "3be5ef5c-1234-4247-b159-6d8df2b37d03",
|
||||
"task": "Performance Optimization",
|
||||
"isBillable": true,
|
||||
"hours": 300,
|
||||
"cost": 45000,
|
||||
"fixedCost": 5000,
|
||||
"totalBudget": 50000,
|
||||
"totalActual": 47000,
|
||||
"variance": 3000,
|
||||
"members": [
|
||||
{
|
||||
"memberId": "4",
|
||||
"name": "Bob Brown",
|
||||
"jobId": "J004",
|
||||
"jobRole": "Performance Engineer",
|
||||
"hourlyRate": 150
|
||||
},
|
||||
{
|
||||
"memberId": "5",
|
||||
"name": "Charlie Davis",
|
||||
"jobId": "J005",
|
||||
"jobRole": "Full Stack Developer",
|
||||
"hourlyRate": 130
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,163 @@
|
||||
[
|
||||
{
|
||||
"id": "c2669c5f-a019-445b-b703-b941bbefdab7",
|
||||
"type": "todo",
|
||||
"name": "To Do",
|
||||
"color_code": "#d8d7d8",
|
||||
"color_code_dark": "#989898",
|
||||
"tasks": [
|
||||
{
|
||||
"id": "1be5ef5c-1234-4247-b159-6d8df2b37d01",
|
||||
"task": "UI Design",
|
||||
"isBillable": true,
|
||||
"hours": 120,
|
||||
"cost": 12000,
|
||||
"fixedCost": 1500,
|
||||
"totalBudget": 14000,
|
||||
"totalActual": 13500,
|
||||
"variance": 500,
|
||||
"members": [
|
||||
{
|
||||
"memberId": "1",
|
||||
"name": "John Doe",
|
||||
"jobId": "J001",
|
||||
"jobRole": "UI/UX Designer",
|
||||
"hourlyRate": 100
|
||||
},
|
||||
{
|
||||
"memberId": "2",
|
||||
"name": "Jane Smith",
|
||||
"jobId": "J002",
|
||||
"jobRole": "Frontend Developer",
|
||||
"hourlyRate": 120
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "2be5ef5c-1234-4247-b159-6d8df2b37d02",
|
||||
"task": "API Integration",
|
||||
"isBillable": true,
|
||||
"hours": 200,
|
||||
"cost": 20000,
|
||||
"fixedCost": 3000,
|
||||
"totalBudget": 25000,
|
||||
"totalActual": 26000,
|
||||
"variance": -1000,
|
||||
"members": [
|
||||
{
|
||||
"memberId": "3",
|
||||
"name": "Alice Johnson",
|
||||
"jobId": "J003",
|
||||
"jobRole": "Backend Developer",
|
||||
"hourlyRate": 100
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "d3f9c5f1-b019-445b-b703-b941bbefdab8",
|
||||
"type": "doing",
|
||||
"name": "In Progress",
|
||||
"color_code": "#c0d5f6",
|
||||
"color_code_dark": "#4190ff",
|
||||
"tasks": [
|
||||
{
|
||||
"id": "3be5ef5c-1234-4247-b159-6d8df2b37d03",
|
||||
"task": "Performance Optimization",
|
||||
"isBillable": true,
|
||||
"hours": 300,
|
||||
"cost": 45000,
|
||||
"fixedCost": 5000,
|
||||
"totalBudget": 50000,
|
||||
"totalActual": 47000,
|
||||
"variance": 3000,
|
||||
"members": [
|
||||
{
|
||||
"memberId": "4",
|
||||
"name": "Bob Brown",
|
||||
"jobId": "J004",
|
||||
"jobRole": "Performance Engineer",
|
||||
"hourlyRate": 150
|
||||
},
|
||||
{
|
||||
"memberId": "5",
|
||||
"name": "Charlie Davis",
|
||||
"jobId": "J005",
|
||||
"jobRole": "Full Stack Developer",
|
||||
"hourlyRate": 130
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "4be5ef5c-1234-4247-b159-6d8df2b37d04",
|
||||
"task": "Testing and QA",
|
||||
"isBillable": false,
|
||||
"hours": 180,
|
||||
"cost": 18000,
|
||||
"fixedCost": 2500,
|
||||
"totalBudget": 20000,
|
||||
"totalActual": 21000,
|
||||
"variance": -1000,
|
||||
"members": [
|
||||
{
|
||||
"memberId": "6",
|
||||
"name": "Eve Adams",
|
||||
"jobId": "J006",
|
||||
"jobRole": "QA Engineer",
|
||||
"hourlyRate": 100
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "e3f9c5f1-b019-445b-b703-b941bbefdab9",
|
||||
"type": "done",
|
||||
"name": "Done",
|
||||
"color_code": "#c2e4d0",
|
||||
"color_code_dark": "#46d980",
|
||||
"tasks": [
|
||||
{
|
||||
"id": "5be5ef5c-1234-4247-b159-6d8df2b37d05",
|
||||
"task": "Database Migration",
|
||||
"isBillable": true,
|
||||
"hours": 250,
|
||||
"cost": 37500,
|
||||
"fixedCost": 4000,
|
||||
"totalBudget": 42000,
|
||||
"totalActual": 41000,
|
||||
"variance": 1000,
|
||||
"members": [
|
||||
{
|
||||
"memberId": "7",
|
||||
"name": "Frank Harris",
|
||||
"jobId": "J007",
|
||||
"jobRole": "Database Administrator",
|
||||
"hourlyRate": 150
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "6be5ef5c-1234-4247-b159-6d8df2b37d06",
|
||||
"task": "Project Documentation",
|
||||
"isBillable": false,
|
||||
"hours": 100,
|
||||
"cost": 10000,
|
||||
"fixedCost": 1000,
|
||||
"totalBudget": 12000,
|
||||
"totalActual": 12500,
|
||||
"variance": -500,
|
||||
"members": [
|
||||
{
|
||||
"memberId": "8",
|
||||
"name": "Grace Lee",
|
||||
"jobId": "J008",
|
||||
"jobRole": "Technical Writer",
|
||||
"hourlyRate": 100
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,51 @@
|
||||
[
|
||||
{
|
||||
"ratecardId": "RC001",
|
||||
"ratecardName": "Rate Card 1",
|
||||
"jobRolesList": [
|
||||
{
|
||||
"jobId": "J001",
|
||||
"jobTitle": "Project Manager",
|
||||
"ratePerHour": 100
|
||||
},
|
||||
{
|
||||
"jobId": "J002",
|
||||
"jobTitle": "Senior Software Engineer",
|
||||
"ratePerHour": 120
|
||||
},
|
||||
{
|
||||
"jobId": "J003",
|
||||
"jobTitle": "Junior Software Engineer",
|
||||
"ratePerHour": 80
|
||||
},
|
||||
{
|
||||
"jobId": "J004",
|
||||
"jobTitle": "UI/UX Designer",
|
||||
"ratePerHour": 50
|
||||
}
|
||||
],
|
||||
"createdDate": "2024-12-01T00:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"ratecardId": "RC002",
|
||||
"ratecardName": "Rate Card 2",
|
||||
"jobRolesList": [
|
||||
{
|
||||
"jobId": "J001",
|
||||
"jobTitle": "Project Manager",
|
||||
"ratePerHour": 80
|
||||
},
|
||||
{
|
||||
"jobId": "J002",
|
||||
"jobTitle": "Senior Software Engineer",
|
||||
"ratePerHour": 100
|
||||
},
|
||||
{
|
||||
"jobId": "J003",
|
||||
"jobTitle": "Junior Software Engineer",
|
||||
"ratePerHour": 60
|
||||
}
|
||||
],
|
||||
"createdDate": "2024-12-15T00:00:00.000Z"
|
||||
}
|
||||
]
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"doesNotExistText": "Na vjen keq, faqja që kërkoni nuk ekziston.",
|
||||
"backHomeButton": "Kthehu në Faqen Kryesore"
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"continue": "Vazhdo",
|
||||
|
||||
"setupYourAccount": "Konfiguro Llogarinë Tënde në Worklenz.",
|
||||
"organizationStepTitle": "Emërtoni Organizatën Tuaj",
|
||||
"organizationStepLabel": "Zgjidhni një emër për llogarinë tuaj në Worklenz.",
|
||||
|
||||
"projectStepTitle": "Krijoni projektin tuaj të parë",
|
||||
"projectStepLabel": "Në cilin projekt po punoni aktualisht?",
|
||||
"projectStepPlaceholder": "p.sh. Plani i Marketingut",
|
||||
|
||||
"tasksStepTitle": "Krijoni detyrat tuaja të para",
|
||||
"tasksStepLabel": "Shkruani disa detyra që do të kryeni në",
|
||||
"tasksStepAddAnother": "Shto një tjetër",
|
||||
|
||||
"emailPlaceholder": "Adresa email",
|
||||
"invalidEmail": "Ju lutemi vendosni një adresë email të vlefshme",
|
||||
"or": "ose",
|
||||
"templateButton": "Importo nga shablloni",
|
||||
"goBack": "Kthehu Mbrapa",
|
||||
"cancel": "Anulo",
|
||||
"create": "Krijo",
|
||||
"templateDrawerTitle": "Zgjidh nga shabllonet",
|
||||
"step3InputLabel": "Fto me email",
|
||||
"addAnother": "Shto një tjetër",
|
||||
"skipForNow": "Kalo tani për tani",
|
||||
"formTitle": "Krijoni detyrën tuaj të parë.",
|
||||
"step3Title": "Fto ekipin tënd të punojë me",
|
||||
"maxMembers": " (Mund të ftoni deri në 5 anëtarë)",
|
||||
"maxTasks": " (Mund të krijoni deri në 5 detyra)"
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
{
|
||||
"title": "Faturimet",
|
||||
"currentBill": "Fatura Aktuale",
|
||||
"configuration": "Konfigurimi",
|
||||
"currentPlanDetails": "Detajet e Planit Aktual",
|
||||
"upgradePlan": "Përmirëso Planin",
|
||||
"cardBodyText01": "Provë falas",
|
||||
"cardBodyText02": "(Plani juaj i provës skadon në 1 muaj 19 ditë)",
|
||||
"redeemCode": "Kodi i Zbritjes",
|
||||
"accountStorage": "Depozita e Llogarisë",
|
||||
"used": "Përdorur:",
|
||||
"remaining": "E mbetur:",
|
||||
"charges": "Tarifat",
|
||||
"tooltip": "Tarifat për ciklin aktual të faturimit",
|
||||
"description": "Përshkrimi",
|
||||
"billingPeriod": "Periudha e Faturimit",
|
||||
"billStatus": "Statusi i Faturës",
|
||||
"perUserValue": "Vlera për Përdorues",
|
||||
"users": "Përdoruesit",
|
||||
|
||||
"amount": "Shuma",
|
||||
"invoices": "Faturat",
|
||||
"transactionId": "ID e Transaksionit",
|
||||
"transactionDate": "Data e Transaksionit",
|
||||
"paymentMethod": "Metoda e Pagesës",
|
||||
"status": "Statusi",
|
||||
"ltdUsers": "Mund të shtoni deri në {{ltd_users}} përdorues.",
|
||||
|
||||
"totalSeats": "Vende totale",
|
||||
"availableSeats": "Vende të disponueshme",
|
||||
"addMoreSeats": "Shto më shumë vende",
|
||||
|
||||
"drawerTitle": "Kodi i Zbritjes",
|
||||
"label": "Kodi i Zbritjes",
|
||||
"drawerPlaceholder": "Vendosni kodin tuaj të zbritjes",
|
||||
"redeemSubmit": "Paraqit",
|
||||
|
||||
"modalTitle": "Zgjidhni planin më të mirë për ekipin tuaj",
|
||||
"seatLabel": "Numri i vendeve",
|
||||
"freePlan": "Plan Falas",
|
||||
"startup": "Startup",
|
||||
"business": "Biznes",
|
||||
"tag": "Më i Popullarizuar",
|
||||
"enterprise": "Ndërmarrje",
|
||||
|
||||
"freeSubtitle": "falas përgjithmonë",
|
||||
"freeUsers": "Më e mira për përdorim personal",
|
||||
"freeText01": "100MB depozitë",
|
||||
"freeText02": "3 projekte",
|
||||
"freeText03": "5 anëtarë të ekipit",
|
||||
|
||||
"startupSubtitle": "ÇMIM I RASTËSISHËM / muaj",
|
||||
"startupUsers": "Deri në 15 përdorues",
|
||||
"startupText01": "25GB depozitë",
|
||||
"startupText02": "Projekte të pakufizuara aktive",
|
||||
"startupText03": "Orar",
|
||||
"startupText04": "Raportim",
|
||||
"startupText05": "Abonohu në projekte",
|
||||
|
||||
"businessSubtitle": "përdorues / muaj",
|
||||
"businessUsers": "16 - 200 përdorues",
|
||||
|
||||
"enterpriseUsers": "200 - 500+ përdorues",
|
||||
|
||||
"footerTitle": "Ju lutemi na jepni një numër kontakti që mund të përdorim për t'ju kontaktuar.",
|
||||
"footerLabel": "Numri i Kontaktit",
|
||||
"footerButton": "Na kontaktoni",
|
||||
|
||||
"redeemCodePlaceHolder": "Vendosni kodin tuaj të zbritjes",
|
||||
"submit": "Paraqit",
|
||||
|
||||
"trialPlan": "Provë Falas",
|
||||
"trialExpireDate": "E vlefshme deri më {{trial_expire_date}}",
|
||||
"trialExpired": "Provat tuaja falas skaduan {{trial_expire_string}}",
|
||||
"trialInProgress": "Provat tuaja falas skadojnë {{trial_expire_string}}",
|
||||
|
||||
"required": "Kjo fushë është e detyrueshme",
|
||||
"invalidCode": "Kod i pavlefshëm",
|
||||
|
||||
"selectPlan": "Zgjidhni planin më të mirë për ekipin tuaj",
|
||||
"changeSubscriptionPlan": "Ndryshoni planin tuaj të abonimit",
|
||||
"noOfSeats": "Numri i vendeve",
|
||||
"annualPlan": "Pro - Vjetor",
|
||||
"monthlyPlan": "Pro - Mujor",
|
||||
"freeForever": "Falas Përgjithmonë",
|
||||
"bestForPersonalUse": "Më e mira për përdorim personal",
|
||||
"storage": "Depozitë",
|
||||
"projects": "Projekte",
|
||||
"teamMembers": "Anëtarët e Ekipit",
|
||||
"unlimitedTeamMembers": "Anëtarë të pakufizuar të ekipit",
|
||||
"unlimitedActiveProjects": "Projekte të pakufizuara aktive",
|
||||
"schedule": "Orar",
|
||||
"reporting": "Raportim",
|
||||
"subscribeToProjects": "Abonohu në projekte",
|
||||
"billedAnnually": "Faturuar çdo vit",
|
||||
"billedMonthly": "Faturuar çdo muaj",
|
||||
|
||||
"pausePlan": "Pauzë Planin",
|
||||
"resumePlan": "Rifillo Planin",
|
||||
"changePlan": "Ndrysho Planin",
|
||||
"cancelPlan": "Anulo Planin",
|
||||
|
||||
"perMonthPerUser": "për përdorues/muaj",
|
||||
"viewInvoice": "Shiko Faturën",
|
||||
"switchToFreePlan": "Kalo në Planin Falas",
|
||||
|
||||
"expirestoday": "sot",
|
||||
"expirestomorrow": "nesër",
|
||||
"expiredDaysAgo": "{{days}} ditë më parë",
|
||||
|
||||
"continueWith": "Vazhdo me {{plan}}",
|
||||
"changeToPlan": "Ndrysho në {{plan}}"
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"overview": "Përmbledhje",
|
||||
"name": "Emri i Organizatës",
|
||||
"owner": "Pronari i Organizatës",
|
||||
"admins": "Administruesit e Organizatës",
|
||||
"contactNumber": "Shto Numrin e Kontaktit",
|
||||
"edit": "Redakto"
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"membersCount": "Numri i Anëtarëve",
|
||||
"createdAt": "Krijuar më",
|
||||
"projectName": "Emri i Projektit",
|
||||
"teamName": "Emri i Ekipit",
|
||||
"refreshProjects": "Rifresko Projektet",
|
||||
"searchPlaceholder": "Kërkoni sipas emrit të projektit",
|
||||
"deleteProject": "Jeni i sigurt që dëshironi të fshini këtë projekt?",
|
||||
"confirm": "Konfirmo",
|
||||
"cancel": "Anulo",
|
||||
"delete": "Fshi Projektin"
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"overview": "Përmbledhje",
|
||||
"users": "Përdoruesit",
|
||||
"teams": "Ekipet",
|
||||
"billing": "Faturimi",
|
||||
"projects": "Projektet",
|
||||
"adminCenter": "Qendra Administrative"
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"title": "Ekipet",
|
||||
"subtitle": "ekipet",
|
||||
"tooltip": "Rifresko ekipet",
|
||||
"placeholder": "Kërko sipas emrit",
|
||||
"addTeam": "Shto Ekip",
|
||||
"team": "Ekipi",
|
||||
"membersCount": "Numri i Anëtarëve",
|
||||
"members": "Anëtarët",
|
||||
"drawerTitle": "Krijo Ekip të Ri",
|
||||
"label": "Emri i Ekipit",
|
||||
"drawerPlaceholder": "Emri",
|
||||
"create": "Krijo",
|
||||
"delete": "Fshi",
|
||||
"settings": "Cilësimet",
|
||||
"popTitle": "Jeni i sigurt?",
|
||||
"message": "Ju lutemi shkruani një Emër",
|
||||
"teamSettings": "Cilësimet e Ekipit",
|
||||
"teamName": "Emri i Ekipit",
|
||||
"teamDescription": "Përshkrimi i Ekipit",
|
||||
"teamMembers": "Anëtarët e Ekipit",
|
||||
"teamMembersCount": "Numri i Anëtarëve të Ekipit",
|
||||
"teamMembersPlaceholder": "Kërko sipas emrit",
|
||||
"addMember": "Shto Anëtar",
|
||||
"add": "Shto",
|
||||
"update": "Përditëso",
|
||||
"teamNamePlaceholder": "Emri i ekipit",
|
||||
"user": "Përdoruesi",
|
||||
"role": "Roli",
|
||||
"owner": "Pronari",
|
||||
"admin": "Administruesi",
|
||||
"member": "Anëtari"
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"title": "Përdoruesit",
|
||||
"subTitle": "përdoruesit",
|
||||
"placeholder": "Kërko sipas emrit",
|
||||
"user": "Përdoruesi",
|
||||
"email": "Email",
|
||||
"lastActivity": "Aktiviteti i Fundit",
|
||||
"refresh": "Rifresko përdoruesit"
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"name": "Emri",
|
||||
"client": "Klienti",
|
||||
"category": "Kategoria",
|
||||
"status": "Statusi",
|
||||
"tasksProgress": "Progresi i Detyrave",
|
||||
"updated_at": "Përditësuar Së Fundi",
|
||||
"members": "Anëtarët",
|
||||
"setting": "Cilësimet",
|
||||
"projects": "Projektet",
|
||||
"refreshProjects": "Rifresko projektet",
|
||||
"all": "Të Gjitha",
|
||||
"favorites": "Të Preferuarat",
|
||||
"archived": "Të Arkivuara",
|
||||
"placeholder": "Kërko sipas emrit",
|
||||
"archive": "Arkivo",
|
||||
"unarchive": "Ç'arkivo",
|
||||
"archiveConfirm": "Jeni i sigurt që doni ta arkivoni këtë projekt?",
|
||||
"unarchiveConfirm": "Jeni i sigurt që doni ta çarkivoni këtë projekt?",
|
||||
"clickToFilter": "Klikoni për të filtruar sipas",
|
||||
"noProjects": "Nuk u gjetën projekte",
|
||||
"addToFavourites": "Shto në të preferuarat"
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"loggingOut": "Po dilni...",
|
||||
"authenticating": "Po autentikoheni...",
|
||||
"gettingThingsReady": "Po përgatiten gjërat për ju..."
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"headerDescription": "Rivendosni fjalëkalimin tuaj",
|
||||
"emailLabel": "Email",
|
||||
"emailPlaceholder": "Vendosni email-in tuaj",
|
||||
"emailRequired": "Ju lutemi vendosni Email-in tuaj!",
|
||||
"resetPasswordButton": "Rivendos Fjalëkalimin",
|
||||
"returnToLoginButton": "Kthehu te Hyrja",
|
||||
"passwordResetSuccessMessage": "Një lidhje për rivendosjen e fjalëkalimit është dërguar në email-in tuaj.",
|
||||
"orText": "OSE",
|
||||
"successTitle": "U dërguan udhëzimet për rivendosje!",
|
||||
"successMessage": "Informacioni për rivendosje është dërguar në email-in tuaj. Ju lutemi kontrolloni email-in."
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"headerDescription": "Hyni në llogarinë tuaj",
|
||||
"emailLabel": "Email",
|
||||
"emailPlaceholder": "Vendosni email-in tuaj",
|
||||
"emailRequired": "Ju lutemi vendosni Email-in tuaj!",
|
||||
"passwordLabel": "Fjalëkalimi",
|
||||
"passwordPlaceholder": "Vendosni fjalëkalimin",
|
||||
"passwordRequired": "Ju lutemi vendosni Fjalëkalimin!",
|
||||
"rememberMe": "Më mbaj mend",
|
||||
"loginButton": "Hyr",
|
||||
"signupButton": "Regjistrohu",
|
||||
"forgotPasswordButton": "Keni harruar fjalëkalimin?",
|
||||
"signInWithGoogleButton": "Hyr me Google",
|
||||
"dontHaveAccountText": "Nuk keni llogari?",
|
||||
"orText": "OSE",
|
||||
"successMessage": "Jeni futur me sukses!",
|
||||
"loginError": "Hyrja dështoi",
|
||||
"googleLoginError": "Hyrja përmes Google dështoi",
|
||||
"validationMessages": {
|
||||
"email": "Ju lutemi vendosni një adresë email të vlefshme",
|
||||
"password": "Fjalëkalimi duhet të jetë së paku 8 karaktere"
|
||||
},
|
||||
"errorMessages": {
|
||||
"loginErrorTitle": "Hyrja dështoi",
|
||||
"loginErrorMessage": "Ju lutemi kontrolloni email-in dhe fjalëkalimin dhe provoni përsëri"
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"headerDescription": "Regjistrohuni për të filluar",
|
||||
"nameLabel": "Emri i Plotë",
|
||||
"namePlaceholder": "Shkruani emrin tuaj të plotë",
|
||||
"nameRequired": "Ju lutemi shkruani emrin tuaj të plotë!",
|
||||
"nameMinCharacterRequired": "Emri duhet të jetë së paku 4 karaktere!",
|
||||
"emailLabel": "Email",
|
||||
"emailPlaceholder": "Shkruani email-in tuaj",
|
||||
"emailRequired": "Ju lutemi shkruani Email-in tuaj!",
|
||||
"passwordLabel": "Fjalëkalimi",
|
||||
"passwordPlaceholder": "Krijoni një fjalëkalim",
|
||||
"passwordRequired": "Ju lutemi krijoni një Fjalëkalim!",
|
||||
"passwordMinCharacterRequired": "Fjalëkalimi duhet të jetë së paku 8 karaktere!",
|
||||
"passwordPatternRequired": "Fjalëkalimi nuk plotëson kërkesat!",
|
||||
"strongPasswordPlaceholder": "Vendosni një fjalëkalim më të fortë",
|
||||
"passwordValidationAltText": "Fjalëkalimi duhet të përmbajë së paku 8 karaktere me shkronja të mëdha dhe të vogla, një numër dhe një simbol.",
|
||||
"signupSuccessMessage": "Jeni regjistruar me sukses!",
|
||||
"privacyPolicyLink": "Politika e Privatësisë",
|
||||
"termsOfUseLink": "Kushtet e Përdorimit",
|
||||
"bySigningUpText": "Duke u regjistruar, ju pranoni",
|
||||
"andText": "dhe",
|
||||
"signupButton": "Regjistrohu",
|
||||
"signInWithGoogleButton": "Hyr me Google",
|
||||
"alreadyHaveAccountText": "Keni tashmë një llogari?",
|
||||
"loginButton": "Hyr",
|
||||
"orText": "OSE",
|
||||
"reCAPTCHAVerificationError": "Gabim në Verifikimin e reCAPTCHA",
|
||||
"reCAPTCHAVerificationErrorMessage": "Nuk mundëm të verifikojmë reCAPTCHA-n tuaj. Ju lutemi provoni përsëri."
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"title": "Verifikoni Email-in për Rivendosje",
|
||||
"description": "Vendosni fjalëkalimin tuaj të ri",
|
||||
"placeholder": "Vendosni fjalëkalimin tuaj të ri",
|
||||
"confirmPasswordPlaceholder": "Konfirmoni fjalëkalimin e ri",
|
||||
"passwordHint": "Të paktën 8 karaktere, me shkronja të mëdha dhe të vogla, një numër dhe një simbol.",
|
||||
"resetPasswordButton": "Rivendos fjalëkalimin",
|
||||
"orText": "Ose",
|
||||
"resendResetEmail": "Dërgo përsëri email-in e rivendosjes",
|
||||
"passwordRequired": "Ju lutemi vendosni fjalëkalimin e ri",
|
||||
"returnToLoginButton": "Kthehu te Hyrja",
|
||||
"confirmPasswordRequired": "Ju lutemi konfirmoni fjalëkalimin e ri",
|
||||
"passwordMismatch": "Fjalëkalimet nuk përputhen"
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"login-success": "Hyrja u krye me sukses!",
|
||||
"login-failed": "Hyrja dështoi. Ju lutemi kontrolloni kredencialet dhe provoni përsëri.",
|
||||
"signup-success": "Regjistrimi u krye me sukses! Mirë se erdhët.",
|
||||
"signup-failed": "Regjistrimi dështoi. Ju lutemi sigurohuni që të gjitha fushat e nevojshme janë plotësuar dhe provoni përsëri.",
|
||||
"reconnecting": "Jeni shkëputur nga serveri.",
|
||||
"connection-lost": "Lidhja me serverin dështoi. Ju lutemi kontrolloni lidhjen tuaj me internet.",
|
||||
"connection-restored": "U lidhët me serverin me sukses"
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"formTitle": "Krijoni projektin tuaj të parë",
|
||||
"inputLabel": "Në cilin projekt po punoni aktualisht?",
|
||||
"or": "ose",
|
||||
"templateButton": "Importo nga shablloni",
|
||||
"createFromTemplate": "Krijo nga shablloni",
|
||||
"goBack": "Kthehu Mbrapa",
|
||||
"continue": "Vazhdo",
|
||||
"cancel": "Anulo",
|
||||
"create": "Krijo",
|
||||
"templateDrawerTitle": "Zgjidh nga shabllonet",
|
||||
"createProject": "Krijo Projekt"
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"formTitle": "Krijo detyrën tënde të parë.",
|
||||
"inputLabel": "Shkruaj disa detyra që do të kryesh në",
|
||||
"addAnother": "Shto një tjetër",
|
||||
"goBack": "Kthehu mbrapa",
|
||||
"continue": "Vazhdo"
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
{
|
||||
"todoList": {
|
||||
"title": "Lista e Detyrave",
|
||||
"refreshTasks": "Rifresko detyrat",
|
||||
"addTask": "+ Shto Detyrë",
|
||||
"noTasks": "Asnjë detyrë",
|
||||
"pressEnter": "Shtyp",
|
||||
"toCreate": "për të krijuar.",
|
||||
"markAsDone": "Shëno si të përfunduar"
|
||||
},
|
||||
"projects": {
|
||||
"title": "Projektet",
|
||||
"refreshProjects": "Rifresko projektet",
|
||||
"noRecentProjects": "Aktualisht nuk jeni caktuar në asnjë projekt.",
|
||||
"noFavouriteProjects": "Asnjë projekt i shënuar si i preferuar.",
|
||||
"recent": "Të Fundit",
|
||||
"favourites": "Të Preferuarat"
|
||||
},
|
||||
"tasks": {
|
||||
"assignedToMe": "Më janë caktuar",
|
||||
"assignedByMe": "I kam caktuar",
|
||||
"all": "Të Gjitha",
|
||||
"today": "Sot",
|
||||
"upcoming": "Ardhj",
|
||||
"overdue": "Të vonuara",
|
||||
"noDueDate": "Pa afat",
|
||||
"noTasks": "Asnjë detyrë për të shfaqur.",
|
||||
"addTask": "+ Shto detyrë",
|
||||
"name": "Emri",
|
||||
"project": "Projekti",
|
||||
"status": "Statusi",
|
||||
"dueDate": "Afati",
|
||||
"dueDatePlaceholder": "Cakto Afatin",
|
||||
"tomorrow": "Nesër",
|
||||
"nextWeek": "Javën e Ardhshme",
|
||||
"nextMonth": "Muajin e Ardhshëm",
|
||||
"projectRequired": "Ju lutemi zgjidhni një projekt",
|
||||
"pressTabToSelectDueDateAndProject": "Shtyp Tab për të zgjedhur afatin dhe projektin",
|
||||
"dueOn": "Detyrat me afat më",
|
||||
"taskRequired": "Ju lutemi shtoni një detyrë",
|
||||
"list": "Listë",
|
||||
"calendar": "Kalendar",
|
||||
"tasks": "Detyrat",
|
||||
"refresh": "Rifresko"
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"formTitle": "Fto ekipin tënd të punojë me",
|
||||
"inputLabel": "Fto me email",
|
||||
"addAnother": "Shto një tjetër",
|
||||
"goBack": "Kthehu mbrapa",
|
||||
"continue": "Vazhdo",
|
||||
"skipForNow": "Anashkalo tani për tani"
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"rename": "Riemërto",
|
||||
"delete": "Fshi",
|
||||
"addTask": "Shto Detyrë",
|
||||
"addSectionButton": "Shto Seksion",
|
||||
"changeCategory": "Ndrysho kategorinë",
|
||||
|
||||
"deleteTooltip": "Fshi",
|
||||
"deleteConfirmationTitle": "Jeni i sigurt?",
|
||||
"deleteConfirmationOk": "Po",
|
||||
"deleteConfirmationCancel": "Anulo",
|
||||
|
||||
"dueDate": "Data e përfundimit",
|
||||
"cancel": "Anulo",
|
||||
|
||||
"today": "Sot",
|
||||
"tomorrow": "Nesër",
|
||||
"assignToMe": "Cakto mua",
|
||||
"archive": "Arkivo",
|
||||
|
||||
"newTaskNamePlaceholder": "Shkruaj emrin e detyrës",
|
||||
"newSubtaskNamePlaceholder": "Shkruaj emrin e nëndetyrës",
|
||||
"untitledSection": "Seksion pa titull",
|
||||
"unmapped": "Pa hartë",
|
||||
"clickToChangeDate": "Klikoni për të ndryshuar datën",
|
||||
"noDueDate": "Pa datë përfundimi",
|
||||
"save": "Ruaj",
|
||||
"clear": "Pastro",
|
||||
"nextWeek": "Javën e ardhshme"
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"title": "Prova juaj e Worklenz ka skaduar!",
|
||||
"subtitle": "Ju lutemi përmirësoni tani.",
|
||||
"button": "Përmirëso tani",
|
||||
"checking": "Po kontrollohet statusi i abonimit..."
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"logoAlt": "Logoja e Worklenz",
|
||||
"home": "Kryefaqja",
|
||||
"projects": "Projektet",
|
||||
"schedule": "Orari",
|
||||
"reporting": "Raportimi",
|
||||
"clients": "Klientët",
|
||||
"teams": "Ekipet",
|
||||
"labels": "Etiketa",
|
||||
"jobTitles": "Tituj Pune",
|
||||
"upgradePlan": "Përmirëso Abonimin",
|
||||
"upgradePlanTooltip": "Përmirëso abonimin",
|
||||
"invite": "Fto",
|
||||
"inviteTooltip": "Fto anëtarë të ekipit të bashkohen",
|
||||
"switchTeamTooltip": "Ndrysho ekipin",
|
||||
"help": "Ndihmë",
|
||||
"notificationTooltip": "Shiko njoftimet",
|
||||
"profileTooltip": "Shiko profilin",
|
||||
"adminCenter": "Qendra Administrative",
|
||||
"settings": "Cilësimet",
|
||||
"logOut": "Dil",
|
||||
"notificationsDrawer": {
|
||||
"read": "Lexuara e njoftimet ",
|
||||
"unread": "Njoftimet e palexuara",
|
||||
"markAsRead": "Shëno si të lexuara",
|
||||
"readAndJoin": "Lexo & Bashkohu",
|
||||
"accept": "Prano",
|
||||
"acceptAndJoin": "Prano & Bashkohu",
|
||||
"noNotifications": "Asnjë njoftim"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"nameYourOrganization": "Emërtoni organizatën tuaj.",
|
||||
"worklenzAccountTitle": "Zgjidhni një emër për llogarinë tuaj në Worklenz.",
|
||||
"continue": "Vazhdo"
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"configurePhases": "Konfiguro Fazat",
|
||||
"phaseLabel": "Etiketa e Fazës",
|
||||
"enterPhaseName": "Vendosni një emër për etiketën e fazës",
|
||||
"addOption": "Shto Opsion",
|
||||
"phaseOptions": "Opsionet e Fazës:"
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
{
|
||||
"createProject": "Krijo Projekt",
|
||||
"editProject": "Modifiko Projektin",
|
||||
"enterCategoryName": "Vendosni emër për kategorinë",
|
||||
"hitEnterToCreate": "Shtyp Enter për të krijuar!",
|
||||
"enterNotes": "Shënime",
|
||||
"youCanManageClientsUnderSettings": "Mund të menaxhoni klientët nën Cilësimet",
|
||||
"addCategory": "Shto kategori projektit",
|
||||
"newCategory": "Kategori e Re",
|
||||
"notes": "Shënime",
|
||||
"startDate": "Data e Fillimit",
|
||||
"endDate": "Data e Përfundimit",
|
||||
"estimateWorkingDays": "Vlerëso ditët e punës",
|
||||
"estimateManDays": "Vlerëso ditët e punëtorëve",
|
||||
"hoursPerDay": "Orë në ditë",
|
||||
"create": "Krijo",
|
||||
"update": "Përditëso",
|
||||
"delete": "Fshi",
|
||||
"typeToSearchClients": "Shkruani për të kërkuar klientë",
|
||||
"projectColor": "Ngjyra e Projektit",
|
||||
"pleaseEnterAName": "Ju lutemi vendosni një emër",
|
||||
"enterProjectName": "Vendosni emrin e projektit",
|
||||
"name": "Emri",
|
||||
"status": "Statusi",
|
||||
"health": "Gjendja",
|
||||
"category": "Kategoria",
|
||||
"projectManager": "Menaxheri i Projektit",
|
||||
"client": "Klienti",
|
||||
"deleteConfirmation": "Jeni i sigurt që doni të fshini?",
|
||||
"deleteConfirmationDescription": "Kjo do të fshijë të gjitha të dhënat e lidhura dhe nuk mund të zhbëhet.",
|
||||
"yes": "Po",
|
||||
"no": "Jo",
|
||||
"createdAt": "Krijuar më",
|
||||
"updatedAt": "Përditësuar më",
|
||||
"by": "nga",
|
||||
"add": "Shto",
|
||||
"asClient": "si klient",
|
||||
"createClient": "Krijo klient",
|
||||
"searchInputPlaceholder": "Kërko sipas emrit ose emailit",
|
||||
"hoursPerDayValidationMessage": "Orët në ditë duhet të jenë një numër midis 1 dhe 24",
|
||||
"noPermission": "Nuk ka leje"
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"nameColumn": "Emri",
|
||||
"attachedTaskColumn": "Detyra e Bashkangjitur",
|
||||
"sizeColumn": "Madhësia",
|
||||
"uploadedByColumn": "Ngarkuar Nga",
|
||||
"uploadedAtColumn": "Ngarkuar Më",
|
||||
"fileIconAlt": "Ikona e skedarit",
|
||||
"titleDescriptionText": "Të gjitha bashkëngjitjet e detyrave në këtë projekt do të shfahen këtu.",
|
||||
"deleteConfirmationTitle": "Jeni i sigurt?",
|
||||
"deleteConfirmationOk": "Po",
|
||||
"deleteConfirmationCancel": "Anulo",
|
||||
"segmentedTooltip": "Së shpejti! Kaloni midis pamjes listë dhe pamjes miniaturash.",
|
||||
"emptyText": "Nuk ka bashkëngjitje në projekt."
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user