Compare commits
10 Commits
v2.1.0
...
feature/me
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1067d87fe | ||
|
|
97feef5982 | ||
|
|
76c92b1cc6 | ||
|
|
67c62fc69b | ||
|
|
14d8f43001 | ||
|
|
3b59a8560b | ||
|
|
819252cedd | ||
|
|
1dade05f54 | ||
|
|
34613e5e0c | ||
|
|
a8b20680e5 |
32
README.md
32
README.md
@@ -1,6 +1,6 @@
|
|||||||
<h1 align="center">
|
<h1 align="center">
|
||||||
<a href="https://worklenz.com" target="_blank" rel="noopener noreferrer">
|
<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>
|
</a>
|
||||||
<br>
|
<br>
|
||||||
Worklenz
|
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).
|
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
|
## Screenshots
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -336,7 +315,6 @@ docker-compose up -d
|
|||||||
docker-compose down
|
docker-compose down
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
## MinIO Integration
|
## 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.
|
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
|
- Frontend: http://localhost:5000
|
||||||
- Backend API: http://localhost:3000 (or https://localhost:3000 with SSL)
|
- 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
|
### Remote Server Deployment
|
||||||
|
|
||||||
When deploying to a remote server:
|
When deploying to a remote server:
|
||||||
@@ -454,10 +428,6 @@ When deploying to a remote server:
|
|||||||
- Frontend: http://your-server-hostname:5000
|
- Frontend: http://your-server-hostname:5000
|
||||||
- Backend API: http://your-server-hostname:3000
|
- 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
|
### Environment Configuration
|
||||||
|
|
||||||
The Docker setup uses environment variables to configure the services:
|
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:
|
ports:
|
||||||
- "5000:5000"
|
- "5000:5000"
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
backend:
|
||||||
restart: unless-stopped
|
condition: service_started
|
||||||
env_file:
|
env_file:
|
||||||
- ./worklenz-frontend/.env.production
|
- ./worklenz-frontend/.env.production
|
||||||
networks:
|
networks:
|
||||||
@@ -26,7 +26,6 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
minio:
|
minio:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
restart: unless-stopped
|
|
||||||
env_file:
|
env_file:
|
||||||
- ./worklenz-backend/.env
|
- ./worklenz-backend/.env
|
||||||
networks:
|
networks:
|
||||||
@@ -38,7 +37,6 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "9000:9000"
|
- "9000:9000"
|
||||||
- "9001:9001"
|
- "9001:9001"
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
environment:
|
||||||
MINIO_ROOT_USER: ${S3_ACCESS_KEY_ID:-minioadmin}
|
MINIO_ROOT_USER: ${S3_ACCESS_KEY_ID:-minioadmin}
|
||||||
MINIO_ROOT_PASSWORD: ${S3_SECRET_ACCESS_KEY:-minioadmin}
|
MINIO_ROOT_PASSWORD: ${S3_SECRET_ACCESS_KEY:-minioadmin}
|
||||||
@@ -54,14 +52,13 @@ services:
|
|||||||
container_name: worklenz_createbuckets
|
container_name: worklenz_createbuckets
|
||||||
depends_on:
|
depends_on:
|
||||||
- minio
|
- minio
|
||||||
restart: on-failure
|
|
||||||
entrypoint: >
|
entrypoint: >
|
||||||
/bin/sh -c '
|
/bin/sh -c '
|
||||||
echo "Waiting for MinIO to start...";
|
echo "Waiting for MinIO to start...";
|
||||||
sleep 15;
|
sleep 15;
|
||||||
for i in 1 2 3 4 5; do
|
for i in 1 2 3 4 5; do
|
||||||
echo "Attempt $i to connect to MinIO...";
|
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!";
|
echo "Successfully connected to MinIO!";
|
||||||
/usr/bin/mc mb --ignore-existing myminio/worklenz-bucket;
|
/usr/bin/mc mb --ignore-existing myminio/worklenz-bucket;
|
||||||
/usr/bin/mc policy set public myminio/worklenz-bucket;
|
/usr/bin/mc policy set public myminio/worklenz-bucket;
|
||||||
@@ -83,79 +80,32 @@ services:
|
|||||||
POSTGRES_DB: ${DB_NAME:-worklenz_db}
|
POSTGRES_DB: ${DB_NAME:-worklenz_db}
|
||||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-password}
|
POSTGRES_PASSWORD: ${DB_PASSWORD:-password}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test: [ "CMD-SHELL", "pg_isready -d ${DB_NAME:-worklenz_db} -U ${DB_USER:-postgres}" ]
|
||||||
[
|
|
||||||
"CMD-SHELL",
|
|
||||||
"pg_isready -d ${DB_NAME:-worklenz_db} -U ${DB_USER:-postgres}",
|
|
||||||
]
|
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
restart: unless-stopped
|
|
||||||
networks:
|
networks:
|
||||||
- worklenz
|
- worklenz
|
||||||
volumes:
|
volumes:
|
||||||
- worklenz_postgres_data:/var/lib/postgresql/data
|
- worklenz_postgres_data:/var/lib/postgresql/data
|
||||||
- type: bind
|
- type: bind
|
||||||
source: ./worklenz-backend/database/sql
|
source: ./worklenz-backend/database
|
||||||
target: /docker-entrypoint-initdb.d/sql
|
target: /docker-entrypoint-initdb.d
|
||||||
consistency: cached
|
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: >
|
command: >
|
||||||
bash -c '
|
bash -c ' if command -v apt-get >/dev/null 2>&1; then
|
||||||
if command -v apt-get >/dev/null 2>&1; then
|
|
||||||
apt-get update && apt-get install -y dos2unix
|
apt-get update && apt-get install -y dos2unix
|
||||||
elif command -v apk >/dev/null 2>&1; then
|
elif command -v apk >/dev/null 2>&1; then
|
||||||
apk add --no-cache dos2unix
|
apk add --no-cache dos2unix
|
||||||
fi
|
fi && find /docker-entrypoint-initdb.d -type f -name "*.sh" -exec sh -c '\''
|
||||||
|
dos2unix "{}" 2>/dev/null || true
|
||||||
find /docker-entrypoint-initdb.d -type f -name "*.sh" -exec sh -c '"'"'
|
chmod +x "{}"
|
||||||
for f; do
|
'\'' \; && exec docker-entrypoint.sh postgres '
|
||||||
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
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
worklenz_postgres_data:
|
worklenz_postgres_data:
|
||||||
worklenz_minio_data:
|
worklenz_minio_data:
|
||||||
pgdata:
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
worklenz:
|
worklenz:
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -16,45 +16,24 @@ Recurring tasks are tasks that repeat automatically on a schedule you choose. Th
|
|||||||
5. Save the task. It will now be created automatically based on your chosen schedule.
|
5. Save the task. It will now be created automatically based on your chosen schedule.
|
||||||
|
|
||||||
## Schedule Options
|
## Schedule Options
|
||||||
You can choose how often your task repeats. Here are the available options:
|
You can choose how often your task repeats. Here are the most common options:
|
||||||
|
|
||||||
- **Daily:** The task is created every day.
|
- **Daily:** The task is created every day.
|
||||||
- **Weekly:** The task is created once a week. You can pick one or more days (e.g., every Monday and Thursday).
|
- **Weekly:** The task is created once a week. You can pick the day (e.g., every Monday).
|
||||||
- **Monthly:** The task is created once a month. You have two options:
|
- **Monthly:** The task is created once a month. You can pick the date (e.g., the 1st of every month).
|
||||||
- **On a specific date:** Choose a date from 1 to 28 (limited to 28 to ensure consistency across all months)
|
- **Weekdays:** The task is created every Monday to Friday.
|
||||||
- **On a specific day:** Choose a week (first, second, third, fourth, or last) and a day of the week
|
- **Custom:** Set your own schedule, such as every 2 days, every 3 weeks, or only on certain days.
|
||||||
- **Every X Days:** The task is created every specified number of days (e.g., every 3 days)
|
|
||||||
- **Every X Weeks:** The task is created every specified number of weeks (e.g., every 2 weeks)
|
|
||||||
- **Every X Months:** The task is created every specified number of months (e.g., every 3 months)
|
|
||||||
|
|
||||||
### Examples
|
### Examples
|
||||||
- "Send team update" every Friday (weekly)
|
- "Send team update" every Friday (weekly)
|
||||||
- "Submit expense report" on the 15th of each month (monthly, specific date)
|
- "Submit expense report" on the 1st of each month (monthly)
|
||||||
- "Monthly team meeting" on the first Monday of each month (monthly, specific day)
|
|
||||||
- "Check backups" every day (daily)
|
- "Check backups" every day (daily)
|
||||||
- "Review project status" every Monday and Thursday (weekly, multiple days)
|
- "Review project status" every Monday and Thursday (custom)
|
||||||
- "Quarterly report" every 3 months (every X months)
|
|
||||||
|
|
||||||
## Future Task Creation
|
|
||||||
The system automatically creates tasks up to a certain point in the future to ensure timely scheduling:
|
|
||||||
|
|
||||||
- **Daily Tasks:** Created up to 7 days in advance
|
|
||||||
- **Weekly Tasks:** Created up to 2 weeks in advance
|
|
||||||
- **Monthly Tasks:** Created up to 2 months in advance
|
|
||||||
- **Every X Days/Weeks/Months:** Created up to 2 intervals in advance
|
|
||||||
|
|
||||||
This ensures that:
|
|
||||||
- You always have upcoming tasks visible in your schedule
|
|
||||||
- Tasks are created at appropriate intervals
|
|
||||||
- The system maintains a reasonable number of future tasks
|
|
||||||
|
|
||||||
## Tips
|
## Tips
|
||||||
- You can edit or stop a recurring task at any time.
|
- You can edit or stop a recurring task at any time.
|
||||||
- Assign team members and labels to recurring tasks for better organization.
|
- Assign team members and labels to recurring tasks for better organization.
|
||||||
- Check your task list regularly to see newly created recurring tasks.
|
- Check your task list regularly to see newly created recurring tasks.
|
||||||
- For monthly tasks, dates are limited to 1-28 to ensure the task occurs on the same date every month.
|
|
||||||
- Tasks are created automatically within the future limit window - you don't need to manually create them.
|
|
||||||
- If you need to see tasks further in the future, they will be created automatically as the current tasks are completed.
|
|
||||||
|
|
||||||
## Need Help?
|
## Need Help?
|
||||||
If you have questions or need help setting up recurring tasks, contact your workspace admin or support team.
|
If you have questions or need help setting up recurring tasks, contact your workspace admin or support team.
|
||||||
@@ -17,51 +17,6 @@ The recurring tasks cron job automates the creation of tasks based on predefined
|
|||||||
3. Checks if a task for the next occurrence already exists.
|
3. Checks if a task for the next occurrence already exists.
|
||||||
4. Creates a new task if it does not exist and the next occurrence is within the allowed future window.
|
4. Creates a new task if it does not exist and the next occurrence is within the allowed future window.
|
||||||
|
|
||||||
## Future Limit Logic
|
|
||||||
The system implements different future limits based on the schedule type to maintain an appropriate number of future tasks:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const FUTURE_LIMITS = {
|
|
||||||
daily: moment.duration(7, 'days'),
|
|
||||||
weekly: moment.duration(2, 'weeks'),
|
|
||||||
monthly: moment.duration(2, 'months'),
|
|
||||||
every_x_days: (interval: number) => moment.duration(interval * 2, 'days'),
|
|
||||||
every_x_weeks: (interval: number) => moment.duration(interval * 2, 'weeks'),
|
|
||||||
every_x_months: (interval: number) => moment.duration(interval * 2, 'months')
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Implementation Details
|
|
||||||
- **Base Calculation:**
|
|
||||||
```typescript
|
|
||||||
const futureLimit = moment(template.last_checked_at || template.created_at)
|
|
||||||
.add(getFutureLimit(schedule.schedule_type, schedule.interval), 'days');
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Task Creation Rules:**
|
|
||||||
1. Only create tasks if the next occurrence is before the future limit
|
|
||||||
2. Skip creation if a task already exists for that date
|
|
||||||
3. Update `last_checked_at` after processing
|
|
||||||
|
|
||||||
- **Benefits:**
|
|
||||||
- Prevents excessive task creation
|
|
||||||
- Maintains system performance
|
|
||||||
- Ensures timely task visibility
|
|
||||||
- Allows for schedule modifications
|
|
||||||
|
|
||||||
## Date Handling
|
|
||||||
- **Monthly Tasks:**
|
|
||||||
- Dates are limited to 1-28 to ensure consistency across all months
|
|
||||||
- This prevents issues with months having different numbers of days
|
|
||||||
- No special handling needed for February or months with 30/31 days
|
|
||||||
- **Weekly Tasks:**
|
|
||||||
- Supports multiple days of the week (0-6, where 0 is Sunday)
|
|
||||||
- Tasks are created for each selected day
|
|
||||||
- **Interval-based Tasks:**
|
|
||||||
- Every X days/weeks/months from the last task's end date
|
|
||||||
- Minimum interval is 1 day/week/month
|
|
||||||
- No maximum limit, but tasks are only created up to the future limit
|
|
||||||
|
|
||||||
## Database Interactions
|
## Database Interactions
|
||||||
- **Templates and Schedules:**
|
- **Templates and Schedules:**
|
||||||
- Templates are stored in `task_recurring_templates`.
|
- Templates are stored in `task_recurring_templates`.
|
||||||
@@ -72,7 +27,6 @@ const FUTURE_LIMITS = {
|
|||||||
- Assigns team members and labels by calling appropriate functions/controllers.
|
- Assigns team members and labels by calling appropriate functions/controllers.
|
||||||
- **State Tracking:**
|
- **State Tracking:**
|
||||||
- Updates `last_checked_at` and `last_created_task_end_date` in the schedule after processing.
|
- Updates `last_checked_at` and `last_created_task_end_date` in the schedule after processing.
|
||||||
- Maintains future limits based on schedule type.
|
|
||||||
|
|
||||||
## Task Creation Process
|
## Task Creation Process
|
||||||
1. **Fetch Templates:** Retrieve all templates and their associated schedules.
|
1. **Fetch Templates:** Retrieve all templates and their associated schedules.
|
||||||
@@ -87,12 +41,10 @@ const FUTURE_LIMITS = {
|
|||||||
- **Cron Expression:** Modify the `TIME` constant in the code to change the schedule.
|
- **Cron Expression:** Modify the `TIME` constant in the code to change the schedule.
|
||||||
- **Task Template Structure:** Extend the template or schedule interfaces to support additional fields.
|
- **Task Template Structure:** Extend the template or schedule interfaces to support additional fields.
|
||||||
- **Task Creation Logic:** Customize the task creation process or add new assignment/labeling logic as needed.
|
- **Task Creation Logic:** Customize the task creation process or add new assignment/labeling logic as needed.
|
||||||
- **Future Window:** Adjust the future limits by modifying the `FUTURE_LIMITS` configuration.
|
|
||||||
|
|
||||||
## Error Handling
|
## Error Handling
|
||||||
- Errors are logged using the `log_error` utility.
|
- Errors are logged using the `log_error` utility.
|
||||||
- The job continues processing other templates even if one fails.
|
- The job continues processing other templates even if one fails.
|
||||||
- Failed task creations are not retried automatically.
|
|
||||||
|
|
||||||
## References
|
## References
|
||||||
- Source: `src/cron_jobs/recurring-tasks.ts`
|
- Source: `src/cron_jobs/recurring-tasks.ts`
|
||||||
|
|||||||
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
|
NODE_ENV=production
|
||||||
PORT=3000
|
PORT=3000
|
||||||
SESSION_NAME=worklenz.sid
|
SESSION_NAME=worklenz.sid
|
||||||
SESSION_SECRET=$(openssl rand -base64 48)
|
SESSION_SECRET=change_me_in_production
|
||||||
COOKIE_SECRET=$(openssl rand -base64 48)
|
COOKIE_SECRET=change_me_in_production
|
||||||
|
|
||||||
# CORS
|
# CORS
|
||||||
SOCKET_IO_CORS=${FRONTEND_URL}
|
SOCKET_IO_CORS=${FRONTEND_URL}
|
||||||
@@ -123,7 +123,7 @@ SLACK_WEBHOOK=
|
|||||||
COMMIT_BUILD_IMMEDIATELY=true
|
COMMIT_BUILD_IMMEDIATELY=true
|
||||||
|
|
||||||
# JWT Secret
|
# JWT Secret
|
||||||
JWT_SECRET=$(openssl rand -base64 48)
|
JWT_SECRET=change_me_in_production
|
||||||
EOL
|
EOL
|
||||||
|
|
||||||
echo "Environment configuration updated for ${HOSTNAME} with" $([ "$USE_SSL" = "true" ] && echo "HTTPS/WSS" || echo "HTTP/WS")
|
echo "Environment configuration updated for ${HOSTNAME} with" $([ "$USE_SSL" = "true" ] && echo "HTTPS/WSS" || echo "HTTP/WS")
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
node_modules
|
node_modules
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
|
build
|
||||||
.scannerwork
|
.scannerwork
|
||||||
coverage
|
coverage
|
||||||
.dockerignore
|
|
||||||
.git
|
|
||||||
*.md
|
|
||||||
tests
|
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,3 @@ GOOGLE_CAPTCHA_PASS_SCORE=0.8
|
|||||||
|
|
||||||
# Email Cronjobs
|
# Email Cronjobs
|
||||||
ENABLE_EMAIL_CRONJOBS=true
|
ENABLE_EMAIL_CRONJOBS=true
|
||||||
|
|
||||||
# RECURRING_JOBS
|
|
||||||
ENABLE_RECURRING_JOBS=true
|
|
||||||
RECURRING_JOBS_INTERVAL="0 11 */1 * 1-5"
|
|
||||||
@@ -1,39 +1,26 @@
|
|||||||
# --- Stage 1: Build ---
|
# Use the official Node.js 20 image as a base
|
||||||
FROM node:20-slim AS builder
|
FROM node:20
|
||||||
|
|
||||||
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/*
|
|
||||||
|
|
||||||
|
# Create and set the working directory
|
||||||
WORKDIR /usr/src/app
|
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 ./
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install app dependencies
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy the rest of the application code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# Run the build script to compile TypeScript to JavaScript
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
RUN echo "$RELEASE_VERSION" > release
|
# Expose the port the app runs on
|
||||||
|
|
||||||
# --- 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 3000
|
EXPOSE 3000
|
||||||
CMD ["node", "build/bin/www"]
|
|
||||||
|
|
||||||
|
# Start the application
|
||||||
|
CMD ["npm", "start"]
|
||||||
131
worklenz-backend/Gruntfile.js
Normal file
131
worklenz-backend/Gruntfile.js
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
module.exports = function (grunt) {
|
||||||
|
|
||||||
|
// Project configuration.
|
||||||
|
grunt.initConfig({
|
||||||
|
pkg: grunt.file.readJSON("package.json"),
|
||||||
|
clean: {
|
||||||
|
dist: "build"
|
||||||
|
},
|
||||||
|
compress: require("./grunt/grunt-compress"),
|
||||||
|
copy: {
|
||||||
|
main: {
|
||||||
|
files: [
|
||||||
|
{expand: true, cwd: "src", src: ["public/**"], dest: "build"},
|
||||||
|
{expand: true, cwd: "src", src: ["views/**"], dest: "build"},
|
||||||
|
{expand: true, cwd: "landing-page-assets", src: ["**"], dest: "build/public/assets"},
|
||||||
|
{expand: true, cwd: "src", src: ["shared/sample-data.json"], dest: "build", filter: "isFile"},
|
||||||
|
{expand: true, cwd: "src", src: ["shared/templates/**"], dest: "build", filter: "isFile"},
|
||||||
|
{expand: true, cwd: "src", src: ["shared/postgresql-error-codes.json"], dest: "build", filter: "isFile"},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
packages: {
|
||||||
|
files: [
|
||||||
|
{expand: true, cwd: "", src: [".env"], dest: "build", filter: "isFile"},
|
||||||
|
{expand: true, cwd: "", src: [".gitignore"], dest: "build", filter: "isFile"},
|
||||||
|
{expand: true, cwd: "", src: ["release"], dest: "build", filter: "isFile"},
|
||||||
|
{expand: true, cwd: "", src: ["jest.config.js"], dest: "build", filter: "isFile"},
|
||||||
|
{expand: true, cwd: "", src: ["package.json"], dest: "build", filter: "isFile"},
|
||||||
|
{expand: true, cwd: "", src: ["package-lock.json"], dest: "build", filter: "isFile"},
|
||||||
|
{expand: true, cwd: "", src: ["common_modules/**"], dest: "build"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sync: {
|
||||||
|
main: {
|
||||||
|
files: [
|
||||||
|
{cwd: "src", src: ["views/**", "public/**"], dest: "build/"}, // makes all src relative to cwd
|
||||||
|
],
|
||||||
|
verbose: true,
|
||||||
|
failOnError: true,
|
||||||
|
compareUsing: "md5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
uglify: {
|
||||||
|
all: {
|
||||||
|
files: [{
|
||||||
|
expand: true,
|
||||||
|
cwd: "build",
|
||||||
|
src: "**/*.js",
|
||||||
|
dest: "build"
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
controllers: {
|
||||||
|
files: [{
|
||||||
|
expand: true,
|
||||||
|
cwd: "build",
|
||||||
|
src: "controllers/*.js",
|
||||||
|
dest: "build"
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
routes: {
|
||||||
|
files: [{
|
||||||
|
expand: true,
|
||||||
|
cwd: "build",
|
||||||
|
src: "routes/**/*.js",
|
||||||
|
dest: "build"
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
assets: {
|
||||||
|
files: [{
|
||||||
|
expand: true,
|
||||||
|
cwd: "build",
|
||||||
|
src: "public/assets/**/*.js",
|
||||||
|
dest: "build"
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
shell: {
|
||||||
|
tsc: {
|
||||||
|
command: "tsc --build tsconfig.prod.json"
|
||||||
|
},
|
||||||
|
esbuild: {
|
||||||
|
// command: "esbuild `find src -type f -name '*.ts'` --platform=node --minify=false --target=esnext --format=cjs --tsconfig=tsconfig.prod.json --outdir=build"
|
||||||
|
command: "node esbuild && node cli/esbuild-patch"
|
||||||
|
},
|
||||||
|
tsc_dev: {
|
||||||
|
command: "tsc --build tsconfig.json"
|
||||||
|
},
|
||||||
|
swagger: {
|
||||||
|
command: "node ./cli/swagger"
|
||||||
|
},
|
||||||
|
inline_queries: {
|
||||||
|
command: "node ./cli/inline-queries"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
scripts: {
|
||||||
|
files: ["src/**/*.ts"],
|
||||||
|
tasks: ["shell:tsc_dev"],
|
||||||
|
options: {
|
||||||
|
debounceDelay: 250,
|
||||||
|
spawn: false,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
other: {
|
||||||
|
files: ["src/**/*.pug", "landing-page-assets/**"],
|
||||||
|
tasks: ["sync"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
grunt.registerTask("clean", ["clean"]);
|
||||||
|
grunt.registerTask("copy", ["copy:main"]);
|
||||||
|
grunt.registerTask("swagger", ["shell:swagger"]);
|
||||||
|
grunt.registerTask("build:tsc", ["shell:tsc"]);
|
||||||
|
grunt.registerTask("build", ["clean", "shell:tsc", "copy:main", "compress"]);
|
||||||
|
grunt.registerTask("build:es", ["clean", "shell:esbuild", "copy:main", "uglify:assets", "compress"]);
|
||||||
|
grunt.registerTask("build:strict", ["clean", "shell:tsc", "copy:packages", "uglify:all", "copy:main", "compress"]);
|
||||||
|
grunt.registerTask("dev", ["clean", "copy:main", "shell:tsc_dev", "shell:inline_queries", "watch"]);
|
||||||
|
|
||||||
|
// Load the plugin that provides the "uglify" task.
|
||||||
|
grunt.loadNpmTasks("grunt-contrib-watch");
|
||||||
|
grunt.loadNpmTasks("grunt-contrib-clean");
|
||||||
|
grunt.loadNpmTasks("grunt-contrib-copy");
|
||||||
|
grunt.loadNpmTasks("grunt-contrib-uglify");
|
||||||
|
grunt.loadNpmTasks("grunt-contrib-compress");
|
||||||
|
grunt.loadNpmTasks("grunt-shell");
|
||||||
|
grunt.loadNpmTasks("grunt-sync");
|
||||||
|
|
||||||
|
// Default task(s).
|
||||||
|
grunt.registerTask("default", []);
|
||||||
|
};
|
||||||
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."
|
|
||||||
@@ -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);
|
|
||||||
@@ -145,7 +145,7 @@ BEGIN
|
|||||||
SET progress_value = NULL,
|
SET progress_value = NULL,
|
||||||
progress_mode = NULL
|
progress_mode = NULL
|
||||||
WHERE project_id = _project_id
|
WHERE project_id = _project_id
|
||||||
AND progress_mode::text::progress_mode_type = _old_mode;
|
AND progress_mode = _old_mode;
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
RETURN NEW;
|
RETURN NEW;
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ BEGIN
|
|||||||
SELECT SUM(time_spent)
|
SELECT SUM(time_spent)
|
||||||
FROM task_work_log
|
FROM task_work_log
|
||||||
WHERE task_id = t.id
|
WHERE task_id = t.id
|
||||||
), 0) as logged_minutes
|
), 0) / 60.0 as logged_minutes
|
||||||
FROM tasks t
|
FROM tasks t
|
||||||
WHERE t.id = _task_id
|
WHERE t.id = _task_id
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ 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 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');
|
||||||
|
|
||||||
-- START: Users
|
-- START: Users
|
||||||
CREATE SEQUENCE IF NOT EXISTS users_user_no_seq START 1;
|
CREATE SEQUENCE IF NOT EXISTS users_user_no_seq START 1;
|
||||||
|
|||||||
@@ -32,37 +32,3 @@ SELECT u.avatar_url,
|
|||||||
FROM team_members
|
FROM team_members
|
||||||
LEFT JOIN users u ON team_members.user_id = u.id;
|
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;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
|
|||||||
@@ -3351,15 +3351,15 @@ BEGIN
|
|||||||
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||||
FROM (SELECT team_member_id,
|
FROM (SELECT team_member_id,
|
||||||
project_member_id,
|
project_member_id,
|
||||||
COALESCE((SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id), '') as name,
|
(SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id),
|
||||||
COALESCE((SELECT email_notifications_enabled
|
(SELECT email_notifications_enabled
|
||||||
FROM notification_settings
|
FROM notification_settings
|
||||||
WHERE team_id = tm.team_id
|
WHERE team_id = tm.team_id
|
||||||
AND notification_settings.user_id = u.id), false) AS email_notifications_enabled,
|
AND notification_settings.user_id = u.id) AS email_notifications_enabled,
|
||||||
COALESCE(u.avatar_url, '') as avatar_url,
|
u.avatar_url,
|
||||||
u.id AS user_id,
|
u.id AS user_id,
|
||||||
COALESCE(u.email, '') as email,
|
u.email,
|
||||||
COALESCE(u.socket_id, '') as socket_id,
|
u.socket_id AS socket_id,
|
||||||
tm.team_id AS team_id
|
tm.team_id AS team_id
|
||||||
FROM tasks_assignees
|
FROM tasks_assignees
|
||||||
INNER JOIN team_members tm ON tm.id = tasks_assignees.team_member_id
|
INNER JOIN team_members tm ON tm.id = tasks_assignees.team_member_id
|
||||||
@@ -4066,14 +4066,14 @@ DECLARE
|
|||||||
_schedule_id JSON;
|
_schedule_id JSON;
|
||||||
_task_completed_at TIMESTAMPTZ;
|
_task_completed_at TIMESTAMPTZ;
|
||||||
BEGIN
|
BEGIN
|
||||||
SELECT COALESCE(name, '') FROM tasks WHERE id = _task_id INTO _task_name;
|
SELECT name FROM tasks WHERE id = _task_id INTO _task_name;
|
||||||
|
|
||||||
SELECT COALESCE(name, '')
|
SELECT name
|
||||||
FROM task_statuses
|
FROM task_statuses
|
||||||
WHERE id = (SELECT status_id FROM tasks WHERE id = _task_id)
|
WHERE id = (SELECT status_id FROM tasks WHERE id = _task_id)
|
||||||
INTO _previous_status_name;
|
INTO _previous_status_name;
|
||||||
|
|
||||||
SELECT COALESCE(name, '') FROM task_statuses WHERE id = _status_id INTO _new_status_name;
|
SELECT name FROM task_statuses WHERE id = _status_id INTO _new_status_name;
|
||||||
|
|
||||||
IF (_previous_status_name != _new_status_name)
|
IF (_previous_status_name != _new_status_name)
|
||||||
THEN
|
THEN
|
||||||
@@ -4081,22 +4081,14 @@ BEGIN
|
|||||||
|
|
||||||
SELECT get_task_complete_info(_task_id, _status_id) INTO _task_info;
|
SELECT get_task_complete_info(_task_id, _status_id) INTO _task_info;
|
||||||
|
|
||||||
SELECT COALESCE(name, '') FROM users WHERE id = _user_id INTO _updater_name;
|
SELECT name FROM users WHERE id = _user_id INTO _updater_name;
|
||||||
|
|
||||||
_message = CONCAT(_updater_name, ' transitioned "', _task_name, '" from ', _previous_status_name, ' ⟶ ',
|
_message = CONCAT(_updater_name, ' transitioned "', _task_name, '" from ', _previous_status_name, ' ⟶ ',
|
||||||
_new_status_name);
|
_new_status_name);
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
SELECT completed_at FROM tasks WHERE id = _task_id INTO _task_completed_at;
|
SELECT completed_at FROM tasks WHERE id = _task_id INTO _task_completed_at;
|
||||||
|
SELECT schedule_id FROM tasks WHERE id = _task_id INTO _schedule_id;
|
||||||
-- Handle schedule_id properly for recurring tasks
|
|
||||||
SELECT CASE
|
|
||||||
WHEN schedule_id IS NULL THEN 'null'::json
|
|
||||||
ELSE json_build_object('id', schedule_id)
|
|
||||||
END
|
|
||||||
FROM tasks
|
|
||||||
WHERE id = _task_id
|
|
||||||
INTO _schedule_id;
|
|
||||||
|
|
||||||
SELECT COALESCE(ROW_TO_JSON(r), '{}'::JSON)
|
SELECT COALESCE(ROW_TO_JSON(r), '{}'::JSON)
|
||||||
FROM (SELECT is_done, is_doing, is_todo
|
FROM (SELECT is_done, is_doing, is_todo
|
||||||
@@ -4105,7 +4097,7 @@ BEGIN
|
|||||||
INTO _status_category;
|
INTO _status_category;
|
||||||
|
|
||||||
RETURN JSON_BUILD_OBJECT(
|
RETURN JSON_BUILD_OBJECT(
|
||||||
'message', COALESCE(_message, ''),
|
'message', _message,
|
||||||
'project_id', (SELECT project_id FROM tasks WHERE id = _task_id),
|
'project_id', (SELECT project_id FROM tasks WHERE id = _task_id),
|
||||||
'parent_done', (CASE
|
'parent_done', (CASE
|
||||||
WHEN EXISTS(SELECT 1
|
WHEN EXISTS(SELECT 1
|
||||||
@@ -4113,14 +4105,14 @@ BEGIN
|
|||||||
WHERE tasks_with_status_view.task_id = _task_id
|
WHERE tasks_with_status_view.task_id = _task_id
|
||||||
AND is_done IS TRUE) THEN 1
|
AND is_done IS TRUE) THEN 1
|
||||||
ELSE 0 END),
|
ELSE 0 END),
|
||||||
'color_code', COALESCE((_task_info ->> 'color_code')::TEXT, ''),
|
'color_code', (_task_info ->> 'color_code')::TEXT,
|
||||||
'color_code_dark', COALESCE((_task_info ->> 'color_code_dark')::TEXT, ''),
|
'color_code_dark', (_task_info ->> 'color_code_dark')::TEXT,
|
||||||
'total_tasks', COALESCE((_task_info ->> 'total_tasks')::INT, 0),
|
'total_tasks', (_task_info ->> 'total_tasks')::INT,
|
||||||
'total_completed', COALESCE((_task_info ->> 'total_completed')::INT, 0),
|
'total_completed', (_task_info ->> 'total_completed')::INT,
|
||||||
'members', COALESCE((_task_info ->> 'members')::JSON, '[]'::JSON),
|
'members', (_task_info ->> 'members')::JSON,
|
||||||
'completed_at', _task_completed_at,
|
'completed_at', _task_completed_at,
|
||||||
'status_category', COALESCE(_status_category, '{}'::JSON),
|
'status_category', _status_category,
|
||||||
'schedule_id', COALESCE(_schedule_id, 'null'::JSON)
|
'schedule_id', _schedule_id
|
||||||
);
|
);
|
||||||
END
|
END
|
||||||
$$;
|
$$;
|
||||||
@@ -4325,7 +4317,6 @@ DECLARE
|
|||||||
_from_group UUID;
|
_from_group UUID;
|
||||||
_to_group UUID;
|
_to_group UUID;
|
||||||
_group_by TEXT;
|
_group_by TEXT;
|
||||||
_batch_size INT := 100; -- PERFORMANCE OPTIMIZATION: Batch size for large updates
|
|
||||||
BEGIN
|
BEGIN
|
||||||
_project_id = (_body ->> 'project_id')::UUID;
|
_project_id = (_body ->> 'project_id')::UUID;
|
||||||
_task_id = (_body ->> 'task_id')::UUID;
|
_task_id = (_body ->> 'task_id')::UUID;
|
||||||
@@ -4338,26 +4329,16 @@ BEGIN
|
|||||||
|
|
||||||
_group_by = (_body ->> 'group_by')::TEXT;
|
_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)
|
IF (_from_group <> _to_group OR (_from_group <> _to_group) IS NULL)
|
||||||
THEN
|
THEN
|
||||||
-- PERFORMANCE OPTIMIZATION: Batch update group changes
|
|
||||||
IF (_group_by = 'status')
|
IF (_group_by = 'status')
|
||||||
THEN
|
THEN
|
||||||
UPDATE tasks
|
UPDATE tasks SET status_id = _to_group WHERE id = _task_id AND status_id = _from_group;
|
||||||
SET status_id = _to_group
|
|
||||||
WHERE id = _task_id
|
|
||||||
AND status_id = _from_group
|
|
||||||
AND project_id = _project_id;
|
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
IF (_group_by = 'priority')
|
IF (_group_by = 'priority')
|
||||||
THEN
|
THEN
|
||||||
UPDATE tasks
|
UPDATE tasks SET priority_id = _to_group WHERE id = _task_id AND priority_id = _from_group;
|
||||||
SET priority_id = _to_group
|
|
||||||
WHERE id = _task_id
|
|
||||||
AND priority_id = _from_group
|
|
||||||
AND project_id = _project_id;
|
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
IF (_group_by = 'phase')
|
IF (_group_by = 'phase')
|
||||||
@@ -4376,15 +4357,14 @@ BEGIN
|
|||||||
END IF;
|
END IF;
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
-- PERFORMANCE OPTIMIZATION: Optimized sort order handling
|
|
||||||
IF ((_body ->> 'to_last_index')::BOOLEAN IS TRUE AND _from_index < _to_index)
|
IF ((_body ->> 'to_last_index')::BOOLEAN IS TRUE AND _from_index < _to_index)
|
||||||
THEN
|
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
|
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;
|
END IF;
|
||||||
ELSE
|
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 IF;
|
||||||
END
|
END
|
||||||
$$;
|
$$;
|
||||||
@@ -6168,337 +6148,3 @@ BEGIN
|
|||||||
RETURN v_new_id;
|
RETURN v_new_id;
|
||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION transfer_team_ownership(_team_id UUID, _new_owner_id UUID) RETURNS json
|
|
||||||
LANGUAGE plpgsql
|
|
||||||
AS
|
|
||||||
$$
|
|
||||||
DECLARE
|
|
||||||
_old_owner_id UUID;
|
|
||||||
_owner_role_id UUID;
|
|
||||||
_admin_role_id UUID;
|
|
||||||
_old_org_id UUID;
|
|
||||||
_new_org_id UUID;
|
|
||||||
_has_license BOOLEAN;
|
|
||||||
_old_owner_role_id UUID;
|
|
||||||
_new_owner_role_id UUID;
|
|
||||||
_has_active_coupon BOOLEAN;
|
|
||||||
_other_teams_count INTEGER;
|
|
||||||
_new_owner_org_id UUID;
|
|
||||||
_license_type_id UUID;
|
|
||||||
_has_valid_license BOOLEAN;
|
|
||||||
BEGIN
|
|
||||||
-- Get the current owner's ID and organization
|
|
||||||
SELECT t.user_id, t.organization_id
|
|
||||||
INTO _old_owner_id, _old_org_id
|
|
||||||
FROM teams t
|
|
||||||
WHERE t.id = _team_id;
|
|
||||||
|
|
||||||
IF _old_owner_id IS NULL THEN
|
|
||||||
RAISE EXCEPTION 'Team not found';
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- Get the new owner's organization
|
|
||||||
SELECT organization_id INTO _new_owner_org_id
|
|
||||||
FROM organizations
|
|
||||||
WHERE user_id = _new_owner_id;
|
|
||||||
|
|
||||||
-- Get the old organization
|
|
||||||
SELECT id INTO _old_org_id
|
|
||||||
FROM organizations
|
|
||||||
WHERE id = _old_org_id;
|
|
||||||
|
|
||||||
IF _old_org_id IS NULL THEN
|
|
||||||
RAISE EXCEPTION 'Organization not found';
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- Check if new owner has any valid license type
|
|
||||||
SELECT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM (
|
|
||||||
-- Check regular subscriptions
|
|
||||||
SELECT lus.user_id, lus.status, lus.active
|
|
||||||
FROM licensing_user_subscriptions lus
|
|
||||||
WHERE lus.user_id = _new_owner_id
|
|
||||||
AND lus.active = TRUE
|
|
||||||
AND lus.status IN ('active', 'trialing')
|
|
||||||
|
|
||||||
UNION ALL
|
|
||||||
|
|
||||||
-- Check custom subscriptions
|
|
||||||
SELECT lcs.user_id, lcs.subscription_status as status, TRUE as active
|
|
||||||
FROM licensing_custom_subs lcs
|
|
||||||
WHERE lcs.user_id = _new_owner_id
|
|
||||||
AND lcs.end_date > CURRENT_DATE
|
|
||||||
|
|
||||||
UNION ALL
|
|
||||||
|
|
||||||
-- Check trial status in organizations
|
|
||||||
SELECT o.user_id, o.subscription_status as status, TRUE as active
|
|
||||||
FROM organizations o
|
|
||||||
WHERE o.user_id = _new_owner_id
|
|
||||||
AND o.trial_in_progress = TRUE
|
|
||||||
AND o.trial_expire_date > CURRENT_DATE
|
|
||||||
) valid_licenses
|
|
||||||
) INTO _has_valid_license;
|
|
||||||
|
|
||||||
IF NOT _has_valid_license THEN
|
|
||||||
RAISE EXCEPTION 'New owner does not have a valid license (subscription, custom subscription, or trial)';
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- Check if new owner has any active coupon codes
|
|
||||||
SELECT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM licensing_coupon_codes lcc
|
|
||||||
WHERE lcc.redeemed_by = _new_owner_id
|
|
||||||
AND lcc.is_redeemed = TRUE
|
|
||||||
AND lcc.is_refunded = FALSE
|
|
||||||
) INTO _has_active_coupon;
|
|
||||||
|
|
||||||
IF _has_active_coupon THEN
|
|
||||||
RAISE EXCEPTION 'New owner has active coupon codes that need to be handled before transfer';
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- Count other teams in the organization for information purposes
|
|
||||||
SELECT COUNT(*) INTO _other_teams_count
|
|
||||||
FROM teams
|
|
||||||
WHERE organization_id = _old_org_id
|
|
||||||
AND id != _team_id;
|
|
||||||
|
|
||||||
-- If new owner has their own organization, move the team to their organization
|
|
||||||
IF _new_owner_org_id IS NOT NULL THEN
|
|
||||||
-- Update the team to use the new owner's organization
|
|
||||||
UPDATE teams
|
|
||||||
SET user_id = _new_owner_id,
|
|
||||||
organization_id = _new_owner_org_id
|
|
||||||
WHERE id = _team_id;
|
|
||||||
|
|
||||||
-- Create notification about organization change
|
|
||||||
PERFORM create_notification(
|
|
||||||
_old_owner_id,
|
|
||||||
_team_id,
|
|
||||||
NULL,
|
|
||||||
NULL,
|
|
||||||
CONCAT('Team <b>', (SELECT name FROM teams WHERE id = _team_id), '</b> has been moved to a different organization')
|
|
||||||
);
|
|
||||||
|
|
||||||
PERFORM create_notification(
|
|
||||||
_new_owner_id,
|
|
||||||
_team_id,
|
|
||||||
NULL,
|
|
||||||
NULL,
|
|
||||||
CONCAT('Team <b>', (SELECT name FROM teams WHERE id = _team_id), '</b> has been moved to your organization')
|
|
||||||
);
|
|
||||||
ELSE
|
|
||||||
-- If new owner doesn't have an organization, transfer the old organization to them
|
|
||||||
UPDATE organizations
|
|
||||||
SET user_id = _new_owner_id
|
|
||||||
WHERE id = _old_org_id;
|
|
||||||
|
|
||||||
-- Update the team to use the same organization
|
|
||||||
UPDATE teams
|
|
||||||
SET user_id = _new_owner_id,
|
|
||||||
organization_id = _old_org_id
|
|
||||||
WHERE id = _team_id;
|
|
||||||
|
|
||||||
-- Notify both users about organization ownership transfer
|
|
||||||
PERFORM create_notification(
|
|
||||||
_old_owner_id,
|
|
||||||
NULL,
|
|
||||||
NULL,
|
|
||||||
NULL,
|
|
||||||
CONCAT('You are no longer the owner of organization <b>', (SELECT organization_name FROM organizations WHERE id = _old_org_id), '</b>')
|
|
||||||
);
|
|
||||||
|
|
||||||
PERFORM create_notification(
|
|
||||||
_new_owner_id,
|
|
||||||
NULL,
|
|
||||||
NULL,
|
|
||||||
NULL,
|
|
||||||
CONCAT('You are now the owner of organization <b>', (SELECT organization_name FROM organizations WHERE id = _old_org_id), '</b>')
|
|
||||||
);
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- Get the owner and admin role IDs
|
|
||||||
SELECT id INTO _owner_role_id FROM roles WHERE team_id = _team_id AND owner = TRUE;
|
|
||||||
SELECT id INTO _admin_role_id FROM roles WHERE team_id = _team_id AND admin_role = TRUE;
|
|
||||||
|
|
||||||
-- Get current role IDs for both users
|
|
||||||
SELECT role_id INTO _old_owner_role_id
|
|
||||||
FROM team_members
|
|
||||||
WHERE team_id = _team_id AND user_id = _old_owner_id;
|
|
||||||
|
|
||||||
SELECT role_id INTO _new_owner_role_id
|
|
||||||
FROM team_members
|
|
||||||
WHERE team_id = _team_id AND user_id = _new_owner_id;
|
|
||||||
|
|
||||||
-- Update the old owner's role to admin if they want to stay in the team
|
|
||||||
IF _old_owner_role_id IS NOT NULL THEN
|
|
||||||
UPDATE team_members
|
|
||||||
SET role_id = _admin_role_id
|
|
||||||
WHERE team_id = _team_id AND user_id = _old_owner_id;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- Update the new owner's role to owner
|
|
||||||
IF _new_owner_role_id IS NOT NULL THEN
|
|
||||||
UPDATE team_members
|
|
||||||
SET role_id = _owner_role_id
|
|
||||||
WHERE team_id = _team_id AND user_id = _new_owner_id;
|
|
||||||
ELSE
|
|
||||||
-- If new owner is not a team member yet, add them
|
|
||||||
INSERT INTO team_members (user_id, team_id, role_id)
|
|
||||||
VALUES (_new_owner_id, _team_id, _owner_role_id);
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- Create notification for both users about team ownership
|
|
||||||
PERFORM create_notification(
|
|
||||||
_old_owner_id,
|
|
||||||
_team_id,
|
|
||||||
NULL,
|
|
||||||
NULL,
|
|
||||||
CONCAT('You are no longer the owner of team <b>', (SELECT name FROM teams WHERE id = _team_id), '</b>')
|
|
||||||
);
|
|
||||||
|
|
||||||
PERFORM create_notification(
|
|
||||||
_new_owner_id,
|
|
||||||
_team_id,
|
|
||||||
NULL,
|
|
||||||
NULL,
|
|
||||||
CONCAT('You are now the owner of team <b>', (SELECT name FROM teams WHERE id = _team_id), '</b>')
|
|
||||||
);
|
|
||||||
|
|
||||||
RETURN json_build_object(
|
|
||||||
'success', TRUE,
|
|
||||||
'old_owner_id', _old_owner_id,
|
|
||||||
'new_owner_id', _new_owner_id,
|
|
||||||
'team_id', _team_id,
|
|
||||||
'old_org_id', _old_org_id,
|
|
||||||
'new_org_id', COALESCE(_new_owner_org_id, _old_org_id),
|
|
||||||
'old_role_id', _old_owner_role_id,
|
|
||||||
'new_role_id', _new_owner_role_id,
|
|
||||||
'has_valid_license', _has_valid_license,
|
|
||||||
'has_active_coupon', _has_active_coupon,
|
|
||||||
'other_teams_count', _other_teams_count,
|
|
||||||
'org_ownership_transferred', _new_owner_org_id IS NULL,
|
|
||||||
'team_moved_to_new_org', _new_owner_org_id IS NOT NULL
|
|
||||||
);
|
|
||||||
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
|
|
||||||
$$;
|
|
||||||
|
|||||||
11364
worklenz-backend/package-lock.json
generated
11364
worklenz-backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,30 +11,16 @@
|
|||||||
"repository": "GITHUB_REPO_HERE",
|
"repository": "GITHUB_REPO_HERE",
|
||||||
"author": "worklenz.com",
|
"author": "worklenz.com",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "jest",
|
"start": "node ./build/bin/www",
|
||||||
"start": "node build/bin/www.js",
|
"tcs": "grunt build:tsc",
|
||||||
"dev": "npm run build:dev && npm run watch",
|
"build": "grunt build",
|
||||||
"build": "npm run clean && npm run compile && npm run copy && npm run compress",
|
"watch": "grunt watch",
|
||||||
"build:dev": "npm run clean && npm run compile:dev && npm run copy",
|
"dev": "grunt dev",
|
||||||
"build:prod": "npm run clean && npm run compile:prod && npm run copy && npm run minify && npm run compress",
|
"es": "esbuild `find src -type f -name '*.ts'` --platform=node --minify=true --watch=true --target=esnext --format=cjs --tsconfig=tsconfig.prod.json --outdir=dist",
|
||||||
"clean": "rimraf build",
|
"copy": "grunt copy",
|
||||||
"compile": "tsc --build tsconfig.prod.json",
|
|
||||||
"compile:dev": "tsc --build tsconfig.json",
|
|
||||||
"compile:prod": "tsc --build tsconfig.prod.json",
|
|
||||||
"copy": "npm run copy:assets && npm run copy:views && npm run copy:config && npm run copy:shared",
|
|
||||||
"copy:assets": "npx cpx2 \"src/public/**\" build/public",
|
|
||||||
"copy:views": "npx cpx2 \"src/views/**\" build/views",
|
|
||||||
"copy:config": "npx cpx2 \".env\" build && npx cpx2 \"package.json\" build",
|
|
||||||
"copy:shared": "npx cpx2 \"src/shared/postgresql-error-codes.json\" build/shared && npx cpx2 \"src/shared/sample-data.json\" build/shared && npx cpx2 \"src/shared/templates/**\" build/shared/templates",
|
|
||||||
"watch": "concurrently \"npm run watch:ts\" \"npm run watch:assets\"",
|
|
||||||
"watch:ts": "tsc --build tsconfig.json --watch",
|
|
||||||
"watch:assets": "npx cpx2 \"src/{public,views}/**\" build --watch",
|
|
||||||
"minify": "terser build/**/*.js --compress --mangle --output-dir build",
|
|
||||||
"compress": "node scripts/compress.js",
|
|
||||||
"swagger": "node ./cli/swagger",
|
|
||||||
"inline-queries": "node ./cli/inline-queries",
|
|
||||||
"sonar": "sonar-scanner -Dproject.settings=sonar-project-dev.properties",
|
"sonar": "sonar-scanner -Dproject.settings=sonar-project-dev.properties",
|
||||||
"tsc": "tsc",
|
"tsc": "tsc",
|
||||||
|
"test": "jest --setupFiles dotenv/config",
|
||||||
"test:watch": "jest --watch --setupFiles dotenv/config"
|
"test:watch": "jest --watch --setupFiles dotenv/config"
|
||||||
},
|
},
|
||||||
"jestSonar": {
|
"jestSonar": {
|
||||||
@@ -59,7 +45,6 @@
|
|||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"cron": "^2.4.0",
|
"cron": "^2.4.0",
|
||||||
"crypto-js": "^4.1.1",
|
"crypto-js": "^4.1.1",
|
||||||
"csrf-sync": "^4.2.1",
|
|
||||||
"csurf": "^1.11.0",
|
"csurf": "^1.11.0",
|
||||||
"debug": "^4.3.4",
|
"debug": "^4.3.4",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
@@ -68,7 +53,6 @@
|
|||||||
"express-rate-limit": "^6.8.0",
|
"express-rate-limit": "^6.8.0",
|
||||||
"express-session": "^1.17.3",
|
"express-session": "^1.17.3",
|
||||||
"express-validator": "^6.15.0",
|
"express-validator": "^6.15.0",
|
||||||
"grunt-cli": "^1.5.0",
|
|
||||||
"helmet": "^6.2.0",
|
"helmet": "^6.2.0",
|
||||||
"hpp": "^0.2.3",
|
"hpp": "^0.2.3",
|
||||||
"http-errors": "^2.0.0",
|
"http-errors": "^2.0.0",
|
||||||
@@ -86,6 +70,7 @@
|
|||||||
"passport-local": "^1.0.0",
|
"passport-local": "^1.0.0",
|
||||||
"path": "^0.12.7",
|
"path": "^0.12.7",
|
||||||
"pg": "^8.14.1",
|
"pg": "^8.14.1",
|
||||||
|
"pg-native": "^3.3.0",
|
||||||
"pug": "^3.0.2",
|
"pug": "^3.0.2",
|
||||||
"redis": "^4.6.7",
|
"redis": "^4.6.7",
|
||||||
"sanitize-html": "^2.11.0",
|
"sanitize-html": "^2.11.0",
|
||||||
@@ -93,10 +78,8 @@
|
|||||||
"sharp": "^0.32.6",
|
"sharp": "^0.32.6",
|
||||||
"slugify": "^1.6.6",
|
"slugify": "^1.6.6",
|
||||||
"socket.io": "^4.7.1",
|
"socket.io": "^4.7.1",
|
||||||
"tinymce": "^7.8.0",
|
|
||||||
"uglify-js": "^3.17.4",
|
"uglify-js": "^3.17.4",
|
||||||
"winston": "^3.10.0",
|
"winston": "^3.10.0",
|
||||||
"worklenz-backend": "file:",
|
|
||||||
"xss-filters": "^1.2.7"
|
"xss-filters": "^1.2.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -104,17 +87,15 @@
|
|||||||
"@babel/preset-typescript": "^7.22.5",
|
"@babel/preset-typescript": "^7.22.5",
|
||||||
"@types/bcrypt": "^5.0.0",
|
"@types/bcrypt": "^5.0.0",
|
||||||
"@types/bluebird": "^3.5.38",
|
"@types/bluebird": "^3.5.38",
|
||||||
"@types/body-parser": "^1.19.2",
|
|
||||||
"@types/compression": "^1.7.2",
|
"@types/compression": "^1.7.2",
|
||||||
"@types/connect-flash": "^0.0.37",
|
"@types/connect-flash": "^0.0.37",
|
||||||
"@types/cookie-parser": "^1.4.3",
|
"@types/cookie-parser": "^1.4.3",
|
||||||
"@types/cron": "^2.0.1",
|
"@types/cron": "^2.0.1",
|
||||||
"@types/crypto-js": "^4.2.2",
|
"@types/crypto-js": "^4.2.2",
|
||||||
"@types/csurf": "^1.11.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": "^1.0.2",
|
||||||
"@types/express-brute-redis": "^0.0.4",
|
"@types/express-brute-redis": "^0.0.4",
|
||||||
"@types/express-serve-static-core": "^4.17.34",
|
|
||||||
"@types/express-session": "^1.17.7",
|
"@types/express-session": "^1.17.7",
|
||||||
"@types/fs-extra": "^9.0.13",
|
"@types/fs-extra": "^9.0.13",
|
||||||
"@types/hpp": "^0.2.2",
|
"@types/hpp": "^0.2.2",
|
||||||
@@ -139,22 +120,26 @@
|
|||||||
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
||||||
"@typescript-eslint/parser": "^5.62.0",
|
"@typescript-eslint/parser": "^5.62.0",
|
||||||
"chokidar": "^3.5.3",
|
"chokidar": "^3.5.3",
|
||||||
"concurrently": "^9.1.2",
|
|
||||||
"cpx2": "^8.0.0",
|
|
||||||
"esbuild": "^0.17.19",
|
"esbuild": "^0.17.19",
|
||||||
"esbuild-envfile-plugin": "^1.0.5",
|
"esbuild-envfile-plugin": "^1.0.5",
|
||||||
"esbuild-node-externals": "^1.8.0",
|
"esbuild-node-externals": "^1.8.0",
|
||||||
"eslint": "^8.45.0",
|
"eslint": "^8.45.0",
|
||||||
"eslint-plugin-security": "^1.7.1",
|
"eslint-plugin-security": "^1.7.1",
|
||||||
"fs-extra": "^10.1.0",
|
"fs-extra": "^10.1.0",
|
||||||
|
"grunt": "^1.6.1",
|
||||||
|
"grunt-contrib-clean": "^2.0.1",
|
||||||
|
"grunt-contrib-compress": "^2.0.0",
|
||||||
|
"grunt-contrib-copy": "^1.0.0",
|
||||||
|
"grunt-contrib-uglify": "^5.2.2",
|
||||||
|
"grunt-contrib-watch": "^1.1.0",
|
||||||
|
"grunt-shell": "^4.0.0",
|
||||||
|
"grunt-sync": "^0.8.2",
|
||||||
"highcharts": "^11.1.0",
|
"highcharts": "^11.1.0",
|
||||||
"jest": "^28.1.3",
|
"jest": "^28.1.3",
|
||||||
"jest-sonar-reporter": "^2.0.0",
|
"jest-sonar-reporter": "^2.0.0",
|
||||||
"ncp": "^2.0.0",
|
"ncp": "^2.0.0",
|
||||||
"nodeman": "^1.1.2",
|
"nodeman": "^1.1.2",
|
||||||
"rimraf": "^6.0.1",
|
|
||||||
"swagger-jsdoc": "^6.2.8",
|
"swagger-jsdoc": "^6.2.8",
|
||||||
"terser": "^5.40.0",
|
|
||||||
"ts-jest": "^28.0.8",
|
"ts-jest": "^28.0.8",
|
||||||
"ts-node": "^10.9.1",
|
"ts-node": "^10.9.1",
|
||||||
"tslint": "^6.1.3",
|
"tslint": "^6.1.3",
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const { createGzip } = require('zlib');
|
|
||||||
const { pipeline } = require('stream');
|
|
||||||
|
|
||||||
async function compressFile(inputPath, outputPath) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const gzip = createGzip();
|
|
||||||
const source = fs.createReadStream(inputPath);
|
|
||||||
const destination = fs.createWriteStream(outputPath);
|
|
||||||
|
|
||||||
pipeline(source, gzip, destination, (err) => {
|
|
||||||
if (err) {
|
|
||||||
reject(err);
|
|
||||||
} else {
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function compressDirectory(dir) {
|
|
||||||
const files = fs.readdirSync(dir, { withFileTypes: true });
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
const fullPath = path.join(dir, file.name);
|
|
||||||
|
|
||||||
if (file.isDirectory()) {
|
|
||||||
await compressDirectory(fullPath);
|
|
||||||
} else if (file.name.endsWith('.js') || file.name.endsWith('.css')) {
|
|
||||||
const gzPath = fullPath + '.gz';
|
|
||||||
await compressFile(fullPath, gzPath);
|
|
||||||
console.log(`Compressed: ${fullPath} -> ${gzPath}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
try {
|
|
||||||
const buildDir = path.join(__dirname, '../build');
|
|
||||||
if (fs.existsSync(buildDir)) {
|
|
||||||
await compressDirectory(buildDir);
|
|
||||||
console.log('Compression complete!');
|
|
||||||
} else {
|
|
||||||
console.log('Build directory not found. Run build first.');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Compression failed:', error);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
@@ -6,7 +6,7 @@ import logger from "morgan";
|
|||||||
import helmet from "helmet";
|
import helmet from "helmet";
|
||||||
import compression from "compression";
|
import compression from "compression";
|
||||||
import passport from "passport";
|
import passport from "passport";
|
||||||
import { csrfSync } from "csrf-sync";
|
import csurf from "csurf";
|
||||||
import rateLimit from "express-rate-limit";
|
import rateLimit from "express-rate-limit";
|
||||||
import cors from "cors";
|
import cors from "cors";
|
||||||
import flash from "connect-flash";
|
import flash from "connect-flash";
|
||||||
@@ -112,13 +112,17 @@ function isLoggedIn(req: Request, _res: Response, next: NextFunction) {
|
|||||||
return req.user ? next() : next(createError(401));
|
return req.user ? next() : next(createError(401));
|
||||||
}
|
}
|
||||||
|
|
||||||
// CSRF configuration using csrf-sync for session-based authentication
|
// CSRF configuration
|
||||||
const {
|
const csrfProtection = csurf({
|
||||||
invalidCsrfTokenError,
|
cookie: {
|
||||||
generateToken,
|
key: "XSRF-TOKEN",
|
||||||
csrfSynchronisedProtection,
|
path: "/",
|
||||||
} = csrfSync({
|
httpOnly: false,
|
||||||
getTokenFromRequest: (req: Request) => req.headers["x-csrf-token"] as string || (req.body && req.body["_csrf"])
|
secure: isProduction(), // Only secure in production
|
||||||
|
sameSite: isProduction() ? "none" : "lax", // Different settings for dev vs prod
|
||||||
|
domain: isProduction() ? ".worklenz.com" : undefined // Only set domain in production
|
||||||
|
},
|
||||||
|
ignoreMethods: ["HEAD", "OPTIONS"]
|
||||||
});
|
});
|
||||||
|
|
||||||
// Apply CSRF selectively (exclude webhooks and public routes)
|
// Apply CSRF selectively (exclude webhooks and public routes)
|
||||||
@@ -131,25 +135,38 @@ app.use((req, res, next) => {
|
|||||||
) {
|
) {
|
||||||
next();
|
next();
|
||||||
} else {
|
} else {
|
||||||
csrfSynchronisedProtection(req, res, next);
|
csrfProtection(req, res, next);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set CSRF token method on request object for compatibility
|
// Set CSRF token cookie
|
||||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||||
// Add csrfToken method to request object for compatibility
|
if (req.csrfToken) {
|
||||||
if (!req.csrfToken && generateToken) {
|
const token = req.csrfToken();
|
||||||
req.csrfToken = (overwrite?: boolean) => generateToken(req, overwrite);
|
res.cookie("XSRF-TOKEN", token, {
|
||||||
|
httpOnly: false,
|
||||||
|
secure: isProduction(),
|
||||||
|
sameSite: isProduction() ? "none" : "lax",
|
||||||
|
domain: isProduction() ? ".worklenz.com" : undefined,
|
||||||
|
path: "/"
|
||||||
|
});
|
||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
// CSRF token refresh endpoint
|
// CSRF token refresh endpoint
|
||||||
app.get("/csrf-token", (req: Request, res: Response) => {
|
app.get("/csrf-token", (req: Request, res: Response) => {
|
||||||
try {
|
if (req.csrfToken) {
|
||||||
const token = generateToken(req);
|
const token = req.csrfToken();
|
||||||
res.status(200).json({ done: true, message: "CSRF token refreshed", token });
|
res.cookie("XSRF-TOKEN", token, {
|
||||||
} catch (error) {
|
httpOnly: false,
|
||||||
|
secure: isProduction(),
|
||||||
|
sameSite: isProduction() ? "none" : "lax",
|
||||||
|
domain: isProduction() ? ".worklenz.com" : undefined,
|
||||||
|
path: "/"
|
||||||
|
});
|
||||||
|
res.status(200).json({ done: true, message: "CSRF token refreshed" });
|
||||||
|
} else {
|
||||||
res.status(500).json({ done: false, message: "Failed to generate CSRF token" });
|
res.status(500).json({ done: false, message: "Failed to generate CSRF token" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -202,7 +219,7 @@ if (isInternalServer()) {
|
|||||||
|
|
||||||
// CSRF error handler
|
// CSRF error handler
|
||||||
app.use((err: any, req: Request, res: Response, next: NextFunction) => {
|
app.use((err: any, req: Request, res: Response, next: NextFunction) => {
|
||||||
if (err === invalidCsrfTokenError) {
|
if (err.code === "EBADCSRFTOKEN") {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
done: false,
|
done: false,
|
||||||
message: "Invalid CSRF token",
|
message: "Invalid CSRF token",
|
||||||
|
|||||||
@@ -35,18 +35,8 @@ export default class AuthController extends WorklenzControllerBase {
|
|||||||
const auth_error = errors.length > 0 ? errors[0] : null;
|
const auth_error = errors.length > 0 ? errors[0] : null;
|
||||||
const message = messages.length > 0 ? messages[0] : null;
|
const message = messages.length > 0 ? messages[0] : null;
|
||||||
|
|
||||||
// Determine title based on authentication status and strategy
|
const midTitle = req.query.strategy === "login" ? "Login Failed!" : "Signup Failed!";
|
||||||
let title = null;
|
const title = req.query.strategy ? midTitle : null;
|
||||||
if (req.query.strategy) {
|
|
||||||
if (auth_error) {
|
|
||||||
// Show failure title only when there's an actual error
|
|
||||||
title = req.query.strategy === "login" ? "Login Failed!" : "Signup Failed!";
|
|
||||||
} else if (req.isAuthenticated() && message) {
|
|
||||||
// Show success title when authenticated and there's a success message
|
|
||||||
title = req.query.strategy === "login" ? "Login Successful!" : "Signup Successful!";
|
|
||||||
}
|
|
||||||
// If no error and not authenticated, don't show any title (this might be a redirect without completion)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.user)
|
if (req.user)
|
||||||
req.user.build_v = FileConstants.getRelease();
|
req.user.build_v = FileConstants.getRelease();
|
||||||
|
|||||||
@@ -756,186 +756,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: [] }));
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -415,15 +415,20 @@ export default class ReportingController extends WorklenzControllerBase {
|
|||||||
|
|
||||||
@HandleExceptions()
|
@HandleExceptions()
|
||||||
public static async getMyTeams(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
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
|
const q = `SELECT team_id AS id, name
|
||||||
FROM team_members tm
|
FROM team_members tm
|
||||||
LEFT JOIN teams ON teams.id = tm.team_id
|
LEFT JOIN teams ON teams.id = tm.team_id
|
||||||
WHERE tm.user_id = $1
|
WHERE tm.user_id = $1
|
||||||
|
AND tm.team_id = $2
|
||||||
AND role_id IN (SELECT id
|
AND role_id IN (SELECT id
|
||||||
FROM roles
|
FROM roles
|
||||||
WHERE (admin_role IS TRUE OR owner IS TRUE))
|
WHERE (admin_role IS TRUE OR owner IS TRUE))
|
||||||
ORDER BY name;`;
|
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);
|
result.rows.forEach((team: any) => team.selected = true);
|
||||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -445,27 +445,52 @@ 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 workingDays = 0;
|
||||||
let current = startDate.clone();
|
let current = startDate.clone();
|
||||||
while (current.isSameOrBefore(endDate, 'day')) {
|
while (current.isSameOrBefore(endDate, 'day')) {
|
||||||
const day = current.isoWeekday();
|
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');
|
current.add(1, 'day');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get hours_per_day for all selected projects
|
// Get organization working hours
|
||||||
const projectHoursQuery = `SELECT id, hours_per_day FROM projects WHERE id IN (${projectIds})`;
|
const orgWorkingHoursQuery = `SELECT working_hours FROM organizations WHERE id = (SELECT t.organization_id FROM teams t WHERE t.id IN (${teamIds}) LIMIT 1)`;
|
||||||
const projectHoursResult = await db.query(projectHoursQuery, []);
|
const orgWorkingHoursResult = await db.query(orgWorkingHoursQuery, []);
|
||||||
const projectHoursMap: Record<string, number> = {};
|
const orgWorkingHours = orgWorkingHoursResult.rows[0]?.working_hours || 8;
|
||||||
for (const row of projectHoursResult.rows) {
|
let totalWorkingHours = workingDays * orgWorkingHours;
|
||||||
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];
|
|
||||||
}
|
|
||||||
|
|
||||||
const durationClause = this.getDateRangeClause(duration || DATE_RANGES.LAST_WEEK, date_range);
|
const durationClause = this.getDateRangeClause(duration || DATE_RANGES.LAST_WEEK, date_range);
|
||||||
const archivedClause = archived
|
const archivedClause = archived
|
||||||
@@ -490,12 +515,19 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
|||||||
member.value = member.logged_time ? parseFloat(moment.duration(member.logged_time, "seconds").asHours().toFixed(2)) : 0;
|
member.value = member.logged_time ? parseFloat(moment.duration(member.logged_time, "seconds").asHours().toFixed(2)) : 0;
|
||||||
member.color_code = getColor(member.name);
|
member.color_code = getColor(member.name);
|
||||||
member.total_working_hours = totalWorkingHours;
|
member.total_working_hours = totalWorkingHours;
|
||||||
member.utilization_percent = (totalWorkingHours > 0 && member.logged_time) ? ((parseFloat(member.logged_time) / (totalWorkingHours * 3600)) * 100).toFixed(2) : '0.00';
|
if (totalWorkingHours === 0) {
|
||||||
|
member.utilization_percent = member.logged_time && parseFloat(member.logged_time) > 0 ? 'N/A' : '0.00';
|
||||||
|
member.utilized_hours = member.logged_time ? (parseFloat(member.logged_time) / 3600).toFixed(2) : '0.00';
|
||||||
|
// Over/under utilized hours: all logged time is over-utilized
|
||||||
|
member.over_under_utilized_hours = member.utilized_hours;
|
||||||
|
} else {
|
||||||
|
member.utilization_percent = (member.logged_time && totalWorkingHours > 0) ? ((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';
|
member.utilized_hours = member.logged_time ? (parseFloat(member.logged_time) / 3600).toFixed(2) : '0.00';
|
||||||
// Over/under utilized hours: utilized_hours - total_working_hours
|
// 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;
|
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);
|
member.over_under_utilized_hours = overUnder.toFixed(2);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,14 +74,9 @@ export default class ScheduleControllerV2 extends WorklenzControllerBase {
|
|||||||
.map(day => `${day.toLowerCase()} = ${workingDays.includes(day)}`)
|
.map(day => `${day.toLowerCase()} = ${workingDays.includes(day)}`)
|
||||||
.join(", ");
|
.join(", ");
|
||||||
|
|
||||||
const updateQuery = `
|
const updateQuery = `UPDATE public.organization_working_days
|
||||||
UPDATE public.organization_working_days
|
|
||||||
SET ${setClause}, updated_at = CURRENT_TIMESTAMP
|
SET ${setClause}, updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE organization_id IN (
|
WHERE organization_id IN (SELECT id FROM organizations WHERE user_id = $1);`;
|
||||||
SELECT organization_id FROM organizations
|
|
||||||
WHERE user_id = $1
|
|
||||||
);
|
|
||||||
`;
|
|
||||||
|
|
||||||
await db.query(updateQuery, [req.user?.owner_id]);
|
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));
|
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()
|
@HandleExceptions()
|
||||||
public static async updateStatusOrder(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
public static async updateStatusOrder(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||||
const q = `SELECT update_status_order($1);`;
|
const q = `SELECT update_status_order($1);`;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||||
import { getColor } from "../shared/utils";
|
import {getColor} from "../shared/utils";
|
||||||
import { PriorityColorCodes, TASK_PRIORITY_COLOR_ALPHA, TASK_STATUS_COLOR_ALPHA } from "../shared/constants";
|
import {PriorityColorCodes, TASK_PRIORITY_COLOR_ALPHA, TASK_STATUS_COLOR_ALPHA} from "../shared/constants";
|
||||||
import moment from "moment/moment";
|
import moment from "moment/moment";
|
||||||
|
|
||||||
export const GroupBy = {
|
export const GroupBy = {
|
||||||
@@ -32,14 +32,23 @@ export default class TasksControllerBase extends WorklenzControllerBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static updateTaskViewModel(task: any) {
|
public static updateTaskViewModel(task: any) {
|
||||||
|
console.log(`Processing task ${task.id} (${task.name})`);
|
||||||
|
console.log(` manual_progress: ${task.manual_progress}, progress_value: ${task.progress_value}`);
|
||||||
|
console.log(` project_use_manual_progress: ${task.project_use_manual_progress}, project_use_weighted_progress: ${task.project_use_weighted_progress}`);
|
||||||
|
console.log(` has subtasks: ${task.sub_tasks_count > 0}`);
|
||||||
|
|
||||||
// For parent tasks (with subtasks), always use calculated progress from subtasks
|
// For parent tasks (with subtasks), always use calculated progress from subtasks
|
||||||
if (task.sub_tasks_count > 0) {
|
if (task.sub_tasks_count > 0) {
|
||||||
|
// For parent tasks without manual progress, calculate from subtasks (already done via db function)
|
||||||
|
console.log(` Parent task with subtasks: complete_ratio=${task.complete_ratio}`);
|
||||||
|
|
||||||
// Ensure progress matches complete_ratio for consistency
|
// Ensure progress matches complete_ratio for consistency
|
||||||
task.progress = task.complete_ratio || 0;
|
task.progress = task.complete_ratio || 0;
|
||||||
|
|
||||||
// Important: Parent tasks should not have manual progress
|
// Important: Parent tasks should not have manual progress
|
||||||
// If they somehow do, reset it
|
// If they somehow do, reset it
|
||||||
if (task.manual_progress) {
|
if (task.manual_progress) {
|
||||||
|
console.log(` WARNING: Parent task ${task.id} had manual_progress set to true, resetting`);
|
||||||
task.manual_progress = false;
|
task.manual_progress = false;
|
||||||
task.progress_value = null;
|
task.progress_value = null;
|
||||||
}
|
}
|
||||||
@@ -49,20 +58,19 @@ export default class TasksControllerBase extends WorklenzControllerBase {
|
|||||||
// For manually set progress, use that value directly
|
// For manually set progress, use that value directly
|
||||||
task.progress = parseInt(task.progress_value);
|
task.progress = parseInt(task.progress_value);
|
||||||
task.complete_ratio = parseInt(task.progress_value);
|
task.complete_ratio = parseInt(task.progress_value);
|
||||||
|
|
||||||
|
console.log(` Using manual progress: progress=${task.progress}, complete_ratio=${task.complete_ratio}`);
|
||||||
}
|
}
|
||||||
// For tasks with no subtasks and no manual progress
|
// For tasks with no subtasks and no manual progress, calculate based on time
|
||||||
else {
|
else {
|
||||||
// Only calculate progress based on time if time-based progress is enabled for the project
|
task.progress = task.total_minutes_spent && task.total_minutes
|
||||||
if (task.project_use_time_progress && task.total_minutes_spent && task.total_minutes) {
|
? ~~(task.total_minutes_spent / task.total_minutes * 100)
|
||||||
// Cap the progress at 100% to prevent showing more than 100% progress
|
: 0;
|
||||||
task.progress = Math.min(~~(task.total_minutes_spent / task.total_minutes * 100), 100);
|
|
||||||
} else {
|
|
||||||
// Default to 0% progress when time-based calculation is not enabled
|
|
||||||
task.progress = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set complete_ratio to match progress
|
// Set complete_ratio to match progress
|
||||||
task.complete_ratio = task.progress;
|
task.complete_ratio = task.progress;
|
||||||
|
|
||||||
|
console.log(` Calculated time-based progress: progress=${task.progress}, complete_ratio=${task.complete_ratio}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure numeric values
|
// Ensure numeric values
|
||||||
@@ -71,7 +79,7 @@ export default class TasksControllerBase extends WorklenzControllerBase {
|
|||||||
|
|
||||||
task.overdue = task.total_minutes < task.total_minutes_spent;
|
task.overdue = task.total_minutes < task.total_minutes_spent;
|
||||||
|
|
||||||
task.time_spent = { hours: ~~(task.total_minutes_spent / 60), minutes: task.total_minutes_spent % 60 };
|
task.time_spent = {hours: ~~(task.total_minutes_spent / 60), minutes: task.total_minutes_spent % 60};
|
||||||
|
|
||||||
task.comments_count = Number(task.comments_count) ? +task.comments_count : 0;
|
task.comments_count = Number(task.comments_count) ? +task.comments_count : 0;
|
||||||
task.attachments_count = Number(task.attachments_count) ? +task.attachments_count : 0;
|
task.attachments_count = Number(task.attachments_count) ? +task.attachments_count : 0;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -7,5 +7,5 @@ export function startCronJobs() {
|
|||||||
startNotificationsJob();
|
startNotificationsJob();
|
||||||
startDailyDigestJob();
|
startDailyDigestJob();
|
||||||
startProjectDigestJob();
|
startProjectDigestJob();
|
||||||
if (process.env.ENABLE_RECURRING_JOBS === "true") startRecurringTasksJob();
|
// startRecurringTasksJob();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,90 +7,12 @@ import TasksController from "../controllers/tasks-controller";
|
|||||||
|
|
||||||
// At 11:00+00 (4.30pm+530) on every day-of-month if it's on every day-of-week from Monday through Friday.
|
// At 11:00+00 (4.30pm+530) on every day-of-month if it's on every day-of-week from Monday through Friday.
|
||||||
// const TIME = "0 11 */1 * 1-5";
|
// const TIME = "0 11 */1 * 1-5";
|
||||||
const TIME = process.env.RECURRING_JOBS_INTERVAL || "0 11 */1 * 1-5";
|
const TIME = "*/2 * * * *"; // runs every 2 minutes - for testing purposes
|
||||||
const TIME_FORMAT = "YYYY-MM-DD";
|
const TIME_FORMAT = "YYYY-MM-DD";
|
||||||
// const TIME = "0 0 * * *"; // Runs at midnight every day
|
// const TIME = "0 0 * * *"; // Runs at midnight every day
|
||||||
|
|
||||||
const log = (value: any) => console.log("recurring-task-cron-job:", value);
|
const log = (value: any) => console.log("recurring-task-cron-job:", value);
|
||||||
|
|
||||||
// Define future limits for different schedule types
|
|
||||||
// More conservative limits to prevent task list clutter
|
|
||||||
const FUTURE_LIMITS = {
|
|
||||||
daily: moment.duration(3, "days"),
|
|
||||||
weekly: moment.duration(1, "week"),
|
|
||||||
monthly: moment.duration(1, "month"),
|
|
||||||
every_x_days: (interval: number) => moment.duration(interval, "days"),
|
|
||||||
every_x_weeks: (interval: number) => moment.duration(interval, "weeks"),
|
|
||||||
every_x_months: (interval: number) => moment.duration(interval, "months")
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to get the future limit based on schedule type
|
|
||||||
function getFutureLimit(scheduleType: string, interval?: number): moment.Duration {
|
|
||||||
switch (scheduleType) {
|
|
||||||
case "daily":
|
|
||||||
return FUTURE_LIMITS.daily;
|
|
||||||
case "weekly":
|
|
||||||
return FUTURE_LIMITS.weekly;
|
|
||||||
case "monthly":
|
|
||||||
return FUTURE_LIMITS.monthly;
|
|
||||||
case "every_x_days":
|
|
||||||
return FUTURE_LIMITS.every_x_days(interval || 1);
|
|
||||||
case "every_x_weeks":
|
|
||||||
return FUTURE_LIMITS.every_x_weeks(interval || 1);
|
|
||||||
case "every_x_months":
|
|
||||||
return FUTURE_LIMITS.every_x_months(interval || 1);
|
|
||||||
default:
|
|
||||||
return moment.duration(3, "days"); // Default to 3 days
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to batch create tasks
|
|
||||||
async function createBatchTasks(template: ITaskTemplate & IRecurringSchedule, endDates: moment.Moment[]) {
|
|
||||||
const createdTasks = [];
|
|
||||||
|
|
||||||
for (const nextEndDate of endDates) {
|
|
||||||
const existingTaskQuery = `
|
|
||||||
SELECT id FROM tasks
|
|
||||||
WHERE schedule_id = $1 AND end_date::DATE = $2::DATE;
|
|
||||||
`;
|
|
||||||
const existingTaskResult = await db.query(existingTaskQuery, [template.schedule_id, nextEndDate.format(TIME_FORMAT)]);
|
|
||||||
|
|
||||||
if (existingTaskResult.rows.length === 0) {
|
|
||||||
const createTaskQuery = `SELECT create_quick_task($1::json) as task;`;
|
|
||||||
const taskData = {
|
|
||||||
name: template.name,
|
|
||||||
priority_id: template.priority_id,
|
|
||||||
project_id: template.project_id,
|
|
||||||
reporter_id: template.reporter_id,
|
|
||||||
status_id: template.status_id || null,
|
|
||||||
end_date: nextEndDate.format(TIME_FORMAT),
|
|
||||||
schedule_id: template.schedule_id
|
|
||||||
};
|
|
||||||
const createTaskResult = await db.query(createTaskQuery, [JSON.stringify(taskData)]);
|
|
||||||
const createdTask = createTaskResult.rows[0].task;
|
|
||||||
|
|
||||||
if (createdTask) {
|
|
||||||
createdTasks.push(createdTask);
|
|
||||||
|
|
||||||
for (const assignee of template.assignees) {
|
|
||||||
await TasksController.createTaskBulkAssignees(assignee.team_member_id, template.project_id, createdTask.id, assignee.assigned_by);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const label of template.labels) {
|
|
||||||
const q = `SELECT add_or_remove_task_label($1, $2) AS labels;`;
|
|
||||||
await db.query(q, [createdTask.id, label.label_id]);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Created task for template ${template.name} with end date ${nextEndDate.format(TIME_FORMAT)}`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log(`Skipped creating task for template ${template.name} with end date ${nextEndDate.format(TIME_FORMAT)} - task already exists`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return createdTasks;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onRecurringTaskJobTick() {
|
async function onRecurringTaskJobTick() {
|
||||||
try {
|
try {
|
||||||
log("(cron) Recurring tasks job started.");
|
log("(cron) Recurring tasks job started.");
|
||||||
@@ -111,44 +33,65 @@ async function onRecurringTaskJobTick() {
|
|||||||
? moment(template.last_task_end_date)
|
? moment(template.last_task_end_date)
|
||||||
: moment(template.created_at);
|
: moment(template.created_at);
|
||||||
|
|
||||||
// Calculate future limit based on schedule type
|
const futureLimit = moment(template.last_checked_at || template.created_at).add(1, "week");
|
||||||
const futureLimit = moment(template.last_checked_at || template.created_at)
|
|
||||||
.add(getFutureLimit(
|
|
||||||
template.schedule_type,
|
|
||||||
template.interval_days || template.interval_weeks || template.interval_months || 1
|
|
||||||
));
|
|
||||||
|
|
||||||
let nextEndDate = calculateNextEndDate(template, lastTaskEndDate);
|
let nextEndDate = calculateNextEndDate(template, lastTaskEndDate);
|
||||||
const endDatesToCreate: moment.Moment[] = [];
|
|
||||||
|
|
||||||
// Find all future occurrences within the limit
|
// Find the next future occurrence
|
||||||
while (nextEndDate.isSameOrBefore(futureLimit)) {
|
while (nextEndDate.isSameOrBefore(now)) {
|
||||||
if (nextEndDate.isAfter(now)) {
|
|
||||||
endDatesToCreate.push(moment(nextEndDate));
|
|
||||||
}
|
|
||||||
nextEndDate = calculateNextEndDate(template, nextEndDate);
|
nextEndDate = calculateNextEndDate(template, nextEndDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Batch create tasks for all future dates
|
// Only create a task if it's within the future limit
|
||||||
if (endDatesToCreate.length > 0) {
|
if (nextEndDate.isSameOrBefore(futureLimit)) {
|
||||||
const createdTasks = await createBatchTasks(template, endDatesToCreate);
|
const existingTaskQuery = `
|
||||||
createdTaskCount += createdTasks.length;
|
SELECT id FROM tasks
|
||||||
|
WHERE schedule_id = $1 AND end_date::DATE = $2::DATE;
|
||||||
|
`;
|
||||||
|
const existingTaskResult = await db.query(existingTaskQuery, [template.schedule_id, nextEndDate.format(TIME_FORMAT)]);
|
||||||
|
|
||||||
|
if (existingTaskResult.rows.length === 0) {
|
||||||
|
const createTaskQuery = `SELECT create_quick_task($1::json) as task;`;
|
||||||
|
const taskData = {
|
||||||
|
name: template.name,
|
||||||
|
priority_id: template.priority_id,
|
||||||
|
project_id: template.project_id,
|
||||||
|
reporter_id: template.reporter_id,
|
||||||
|
status_id: template.status_id || null,
|
||||||
|
end_date: nextEndDate.format(TIME_FORMAT),
|
||||||
|
schedule_id: template.schedule_id
|
||||||
|
};
|
||||||
|
const createTaskResult = await db.query(createTaskQuery, [JSON.stringify(taskData)]);
|
||||||
|
const createdTask = createTaskResult.rows[0].task;
|
||||||
|
|
||||||
|
if (createdTask) {
|
||||||
|
createdTaskCount++;
|
||||||
|
|
||||||
|
for (const assignee of template.assignees) {
|
||||||
|
await TasksController.createTaskBulkAssignees(assignee.team_member_id, template.project_id, createdTask.id, assignee.assigned_by);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const label of template.labels) {
|
||||||
|
const q = `SELECT add_or_remove_task_label($1, $2) AS labels;`;
|
||||||
|
await db.query(q, [createdTask.id, label.label_id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Created task for template ${template.name} with end date ${nextEndDate.format(TIME_FORMAT)}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`Skipped creating task for template ${template.name} with end date ${nextEndDate.format(TIME_FORMAT)} - task already exists`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`No task created for template ${template.name} - next occurrence is beyond the future limit`);
|
||||||
|
}
|
||||||
|
|
||||||
// Update the last_checked_at in the schedule
|
// Update the last_checked_at in the schedule
|
||||||
const updateScheduleQuery = `
|
const updateScheduleQuery = `
|
||||||
UPDATE task_recurring_schedules
|
UPDATE task_recurring_schedules
|
||||||
SET last_checked_at = $1::DATE,
|
SET last_checked_at = $1::DATE, last_created_task_end_date = $2
|
||||||
last_created_task_end_date = $2
|
|
||||||
WHERE id = $3;
|
WHERE id = $3;
|
||||||
`;
|
`;
|
||||||
await db.query(updateScheduleQuery, [
|
await db.query(updateScheduleQuery, [moment(template.last_checked_at || template.created_at).add(1, "day").format(TIME_FORMAT), nextEndDate.format(TIME_FORMAT), template.schedule_id]);
|
||||||
moment().format(TIME_FORMAT),
|
|
||||||
endDatesToCreate[endDatesToCreate.length - 1].format(TIME_FORMAT),
|
|
||||||
template.schedule_id
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
console.log(`No tasks created for template ${template.name} - next occurrence is beyond the future limit`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log(`(cron) Recurring tasks job ended with ${createdTaskCount} new tasks created.`);
|
log(`(cron) Recurring tasks job ended with ${createdTaskCount} new tasks created.`);
|
||||||
|
|||||||
@@ -3,16 +3,13 @@ import { Strategy as LocalStrategy } from "passport-local";
|
|||||||
import { log_error } from "../../shared/utils";
|
import { log_error } from "../../shared/utils";
|
||||||
import db from "../../config/db";
|
import db from "../../config/db";
|
||||||
import { Request } from "express";
|
import { Request } from "express";
|
||||||
import { ERROR_KEY, SUCCESS_KEY } from "./passport-constants";
|
|
||||||
|
|
||||||
async function handleLogin(req: Request, email: string, password: string, done: any) {
|
async function handleLogin(req: Request, email: string, password: string, done: any) {
|
||||||
// Clear any existing flash messages
|
console.log("Login attempt for:", email);
|
||||||
(req.session as any).flash = {};
|
|
||||||
|
|
||||||
if (!email || !password) {
|
if (!email || !password) {
|
||||||
const errorMsg = "Please enter both email and password";
|
console.log("Missing credentials");
|
||||||
req.flash(ERROR_KEY, errorMsg);
|
return done(null, false, { message: "Please enter both email and password" });
|
||||||
return done(null, false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -22,27 +19,23 @@ async function handleLogin(req: Request, email: string, password: string, done:
|
|||||||
AND google_id IS NULL
|
AND google_id IS NULL
|
||||||
AND is_deleted IS FALSE;`;
|
AND is_deleted IS FALSE;`;
|
||||||
const result = await db.query(q, [email]);
|
const result = await db.query(q, [email]);
|
||||||
|
console.log("User query result count:", result.rowCount);
|
||||||
|
|
||||||
const [data] = result.rows;
|
const [data] = result.rows;
|
||||||
|
|
||||||
if (!data?.password) {
|
if (!data?.password) {
|
||||||
const errorMsg = "No account found with this email";
|
console.log("No account found");
|
||||||
req.flash(ERROR_KEY, errorMsg);
|
return done(null, false, { message: "No account found with this email" });
|
||||||
return done(null, false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const passwordMatch = bcrypt.compareSync(password, data.password);
|
const passwordMatch = bcrypt.compareSync(password, data.password);
|
||||||
|
console.log("Password match:", passwordMatch);
|
||||||
|
|
||||||
if (passwordMatch && email === data.email) {
|
if (passwordMatch && email === data.email) {
|
||||||
delete data.password;
|
delete data.password;
|
||||||
const successMsg = "User successfully logged in";
|
return done(null, data, {message: "User successfully logged in"});
|
||||||
req.flash(SUCCESS_KEY, successMsg);
|
|
||||||
return done(null, data);
|
|
||||||
}
|
}
|
||||||
|
return done(null, false, { message: "Incorrect email or password" });
|
||||||
const errorMsg = "Incorrect email or password";
|
|
||||||
req.flash(ERROR_KEY, errorMsg);
|
|
||||||
return done(null, false);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Login error:", error);
|
console.error("Login error:", error);
|
||||||
log_error(error, req.body);
|
log_error(error, req.body);
|
||||||
|
|||||||
@@ -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/",
|
"homepage": "https://www.tiny.cloud/",
|
||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://github.com/tinymce/tinymce/issues"
|
"url": "https://github.com/tinymce/tinymce/issues"
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"tinymce": "file:"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -18,7 +18,6 @@ projectsApiRouter.get("/update-exist-sort-order", safeControllerFunction(Project
|
|||||||
|
|
||||||
projectsApiRouter.post("/", teamOwnerOrAdminValidator, projectsBodyValidator, safeControllerFunction(ProjectsController.create));
|
projectsApiRouter.post("/", teamOwnerOrAdminValidator, projectsBodyValidator, safeControllerFunction(ProjectsController.create));
|
||||||
projectsApiRouter.get("/", safeControllerFunction(ProjectsController.get));
|
projectsApiRouter.get("/", safeControllerFunction(ProjectsController.get));
|
||||||
projectsApiRouter.get("/grouped", safeControllerFunction(ProjectsController.getGrouped));
|
|
||||||
projectsApiRouter.get("/my-task-projects", safeControllerFunction(ProjectsController.getMyProjectsToTasks));
|
projectsApiRouter.get("/my-task-projects", safeControllerFunction(ProjectsController.getMyProjectsToTasks));
|
||||||
projectsApiRouter.get("/my-projects", safeControllerFunction(ProjectsController.getMyProjects));
|
projectsApiRouter.get("/my-projects", safeControllerFunction(ProjectsController.getMyProjects));
|
||||||
projectsApiRouter.get("/all", safeControllerFunction(ProjectsController.getAllProjects));
|
projectsApiRouter.get("/all", safeControllerFunction(ProjectsController.getAllProjects));
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ statusesApiRouter.put("/order", statusOrderValidator, safeControllerFunction(Tas
|
|||||||
statusesApiRouter.get("/categories", safeControllerFunction(TaskStatusesController.getCategories));
|
statusesApiRouter.get("/categories", safeControllerFunction(TaskStatusesController.getCategories));
|
||||||
statusesApiRouter.get("/:id", idParamValidator, safeControllerFunction(TaskStatusesController.getById));
|
statusesApiRouter.get("/:id", idParamValidator, safeControllerFunction(TaskStatusesController.getById));
|
||||||
statusesApiRouter.put("/name/:id", projectManagerValidator, idParamValidator, taskStatusBodyValidator, safeControllerFunction(TaskStatusesController.updateName));
|
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.put("/:id", projectManagerValidator, idParamValidator, taskStatusBodyValidator, safeControllerFunction(TaskStatusesController.update));
|
||||||
statusesApiRouter.delete("/:id", projectManagerValidator, idParamValidator, statusDeleteValidator, safeControllerFunction(TaskStatusesController.deleteById));
|
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.put("/list/columns/:id", idParamValidator, safeControllerFunction(TaskListColumnsController.toggleColumn));
|
||||||
|
|
||||||
tasksApiRouter.get("/list/v2/:id", idParamValidator, safeControllerFunction(getList));
|
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.get("/assignees/:id", idParamValidator, safeControllerFunction(TasksController.getProjectTaskAssignees));
|
||||||
|
|
||||||
tasksApiRouter.put("/bulk/status", mapTasksToBulkUpdate, bulkTasksStatusValidator, safeControllerFunction(TasksController.bulkChangeStatus));
|
tasksApiRouter.put("/bulk/status", mapTasksToBulkUpdate, bulkTasksStatusValidator, safeControllerFunction(TasksController.bulkChangeStatus));
|
||||||
|
|||||||
@@ -166,7 +166,9 @@ export const UNMAPPED = "Unmapped";
|
|||||||
|
|
||||||
export const DATE_RANGES = {
|
export const DATE_RANGES = {
|
||||||
YESTERDAY: "YESTERDAY",
|
YESTERDAY: "YESTERDAY",
|
||||||
|
LAST_7_DAYS: "LAST_7_DAYS",
|
||||||
LAST_WEEK: "LAST_WEEK",
|
LAST_WEEK: "LAST_WEEK",
|
||||||
|
LAST_30_DAYS: "LAST_30_DAYS",
|
||||||
LAST_MONTH: "LAST_MONTH",
|
LAST_MONTH: "LAST_MONTH",
|
||||||
LAST_QUARTER: "LAST_QUARTER",
|
LAST_QUARTER: "LAST_QUARTER",
|
||||||
ALL_TIME: "ALL_TIME"
|
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 isSubscribe = data.mode == 0;
|
||||||
const q = isSubscribe
|
const q = isSubscribe
|
||||||
? `INSERT INTO project_subscribers (user_id, project_id, team_member_id)
|
? `INSERT INTO project_subscribers (user_id, project_id, team_member_id)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3);`
|
||||||
ON CONFLICT (user_id, project_id, team_member_id) DO NOTHING;`
|
|
||||||
: `DELETE
|
: `DELETE
|
||||||
FROM project_subscribers
|
FROM project_subscribers
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
@@ -28,7 +27,7 @@ export async function on_project_subscriber_change(_io: Server, socket: Socket,
|
|||||||
AND team_member_id = $3;`;
|
AND team_member_id = $3;`;
|
||||||
await db.query(q, [data.user_id, data.project_id, data.team_member_id]);
|
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);
|
socket.emit(SocketEvents.PROJECT_SUBSCRIBERS_CHANGE.toString(), subscribers);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -56,8 +56,6 @@ export async function on_quick_task(_io: Server, socket: Socket, data?: string)
|
|||||||
const q = `SELECT create_quick_task($1) AS task;`;
|
const q = `SELECT create_quick_task($1) AS task;`;
|
||||||
const body = JSON.parse(data as string);
|
const body = JSON.parse(data as string);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
body.name = (body.name || "").trim();
|
body.name = (body.name || "").trim();
|
||||||
body.priority_id = body.priority_id?.trim() || null;
|
body.priority_id = body.priority_id?.trim() || null;
|
||||||
body.status_id = body.status_id?.trim() || null;
|
body.status_id = body.status_id?.trim() || null;
|
||||||
@@ -113,12 +111,10 @@ export async function on_quick_task(_io: Server, socket: Socket, data?: string)
|
|||||||
|
|
||||||
notifyProjectUpdates(socket, d.task.id);
|
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) {
|
} catch (error) {
|
||||||
log_error(error);
|
log_error(error);
|
||||||
socket.emit(SocketEvents.QUICK_TASK.toString(), null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
socket.emit(SocketEvents.QUICK_TASK.toString(), null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,23 +88,6 @@ export async function on_task_status_change(_io: Server, socket: Socket, data?:
|
|||||||
}, 100);
|
}, 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);
|
const info = await TasksControllerV2.getTaskCompleteRatio(body.parent_task || body.task_id);
|
||||||
|
|||||||
@@ -2,30 +2,31 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<title>Password Changed | Worklenz</title>
|
<title></title>
|
||||||
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
|
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
|
||||||
<meta content="width=device-width,initial-scale=1" name="viewport">
|
<meta content="width=device-width,initial-scale=1" name="viewport">
|
||||||
<style>
|
<style>
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0
|
||||||
background: linear-gradient(135deg, #f5f8ff 0%, #eaf0fb 100%);
|
|
||||||
-webkit-text-size-adjust: none;
|
|
||||||
text-size-adjust: none;
|
|
||||||
font-family: 'Mada', Arial, sans-serif;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-container {
|
a[x-apple-data-detectors] {
|
||||||
background: #fff;
|
color: inherit !important;
|
||||||
border-radius: 18px;
|
text-decoration: inherit !important
|
||||||
box-shadow: 0 4px 24px rgba(73, 146, 240, 0.10);
|
}
|
||||||
margin: 40px auto 0 auto;
|
|
||||||
max-width: 500px;
|
#MessageViewBody a {
|
||||||
padding: 0 0 20px 0;
|
color: inherit;
|
||||||
|
text-decoration: none
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
line-height: inherit
|
||||||
}
|
}
|
||||||
|
|
||||||
.padding-30 {
|
.padding-30 {
|
||||||
@@ -41,48 +42,33 @@
|
|||||||
mso-hide: all;
|
mso-hide: all;
|
||||||
display: none;
|
display: none;
|
||||||
max-height: 0;
|
max-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden
|
||||||
}
|
|
||||||
|
|
||||||
.footer {
|
|
||||||
text-align: center;
|
|
||||||
color: #b0b8c9;
|
|
||||||
font-size: 13px;
|
|
||||||
margin-top: 30px;
|
|
||||||
padding-bottom: 18px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 525px) {
|
@media (max-width: 525px) {
|
||||||
.main-container {
|
|
||||||
margin: 0;
|
|
||||||
border-radius: 0;
|
|
||||||
box-shadow: none;
|
|
||||||
max-width: 100% !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.desktop_hide table.icons-inner {
|
.desktop_hide table.icons-inner {
|
||||||
display: inline-block !important;
|
display: inline-block !important
|
||||||
}
|
}
|
||||||
|
|
||||||
.icons-inner {
|
.icons-inner {
|
||||||
text-align: center;
|
text-align: center
|
||||||
}
|
}
|
||||||
|
|
||||||
.icons-inner td {
|
.icons-inner td {
|
||||||
margin: 0 auto;
|
margin: 0 auto
|
||||||
}
|
}
|
||||||
|
|
||||||
.row-content {
|
.row-content {
|
||||||
width: 95% !important;
|
width: 95% !important
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile_hide {
|
.mobile_hide {
|
||||||
display: none;
|
display: none
|
||||||
}
|
}
|
||||||
|
|
||||||
.stack .column {
|
.stack .column {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: block;
|
display: block
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile_hide {
|
.mobile_hide {
|
||||||
@@ -90,21 +76,19 @@
|
|||||||
max-height: 0;
|
max-height: 0;
|
||||||
max-width: 0;
|
max-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-size: 0;
|
font-size: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
.desktop_hide,
|
.desktop_hide,
|
||||||
.desktop_hide table {
|
.desktop_hide table {
|
||||||
display: table !important;
|
display: table !important;
|
||||||
max-height: none !important;
|
max-height: none !important
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Mada:wght@500;700&display=swap" rel="stylesheet">
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body style="background-color:#fff;margin:0;padding:0;-webkit-text-size-adjust:none;text-size-adjust:none;">
|
<body style="background-color:#fff;margin:0;padding:0;-webkit-text-size-adjust:none;text-size-adjust:none;">
|
||||||
<div class="main-container">
|
|
||||||
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation"
|
<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"
|
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%">
|
width="100%">
|
||||||
@@ -116,6 +100,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
|
|
||||||
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-1 padding-20"
|
<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;
|
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;margin-left: auto;margin-right: auto;padding-top: 20px;
|
||||||
padding-bottom: 20px;" width="220">
|
padding-bottom: 20px;" width="220">
|
||||||
@@ -142,35 +127,28 @@
|
|||||||
<table border="0" cellpadding="5" cellspacing="0" class="paragraph_block block-4 padding-30"
|
<table border="0" cellpadding="5" cellspacing="0" class="paragraph_block block-4 padding-30"
|
||||||
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word"
|
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word"
|
||||||
width="100%">
|
width="100%">
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
||||||
<div align="center" class="alignment" style="line-height:10px;margin-top: 30px;">
|
<div align="center" class="alignment" style="line-height:10px"><img
|
||||||
<img
|
|
||||||
src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-password-changed.png"
|
src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-password-changed.png"
|
||||||
style="display:block;height:auto;border:0;width:100px;max-width:100%;margin-bottom: 10px;"
|
style="display:block;height:auto;border:0;width:100px;max-width:100%;margin-top: 30px;margin-bottom: 10px;"
|
||||||
width="100">
|
width="100">
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="pad">
|
<td class="pad">
|
||||||
<h1
|
|
||||||
style="margin:0;color:rgb(66, 66, 66);font-size:28px;line-height:120%;text-align:center;direction:ltr;font-weight:700;letter-spacing:0.5px;margin-top:10px;margin-bottom:0;padding-top: 10px;padding-bottom: 10px;font-family: 'Mada', Arial, sans-serif;">
|
|
||||||
Password Changed Successfully
|
|
||||||
</h1>
|
|
||||||
<div
|
<div
|
||||||
style="color:#505771;font-size:19px;font-family: 'Mada', Arial, sans-serif;font-weight:500;line-height:150%;text-align:center;direction:ltr;letter-spacing:0.1px;mso-line-height-alt:24px;margin-top: 18px;">
|
style="color:#505771;font-size:19px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;font-weight:500;line-height:150%;text-align:center;direction:ltr;letter-spacing:0;mso-line-height-alt:24px">
|
||||||
<p style="margin-top: 0px;margin-bottom: 18px;">Hi,</p>
|
<p style="margin-top: 0px;margin-bottom: 30px;">
|
||||||
<p style="margin:0;margin-bottom:10px">This is a confirmation that your Worklenz
|
We wanted to let you know that your Worklenz password was reset.
|
||||||
account password was changed.</p>
|
</p>
|
||||||
<p style="margin:0;margin-bottom:10px">If you did not make this change, please <a
|
|
||||||
href="mailto:support@worklenz.com"
|
|
||||||
style="color:#4992f0;text-decoration:none;">contact our support team</a>
|
|
||||||
immediately.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -223,12 +201,10 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div class="footer">
|
</td>
|
||||||
If you have any questions, contact us at <a href="mailto:support@worklenz.com"
|
</tr>
|
||||||
style="color:#4992f0;text-decoration:none;">support@worklenz.com</a>.<br>
|
</tbody>
|
||||||
© 2025 Worklenz. All rights reserved.
|
</table><!-- End -->
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -2,35 +2,31 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<title>Worklenz 2.1.0 Release</title>
|
<title></title>
|
||||||
<meta name="subject" content="Worklenz 2.1.0 Release" />
|
|
||||||
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
|
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
|
||||||
<meta content="width=device-width,initial-scale=1" name="viewport">
|
<meta content="width=device-width,initial-scale=1" name="viewport">
|
||||||
<style>
|
<style>
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0
|
||||||
background: #f6f8fa;
|
|
||||||
font-family: 'Mada', 'Segoe UI', Arial, sans-serif;
|
|
||||||
color: #222;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
a[x-apple-data-detectors] {
|
a[x-apple-data-detectors] {
|
||||||
color: inherit !important;
|
color: inherit !important;
|
||||||
text-decoration: inherit !important;
|
text-decoration: inherit !important
|
||||||
}
|
}
|
||||||
|
|
||||||
#MessageViewBody a {
|
#MessageViewBody a {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
line-height: 1.6;
|
line-height: inherit
|
||||||
}
|
}
|
||||||
|
|
||||||
.padding-30 {
|
.padding-30 {
|
||||||
@@ -41,201 +37,272 @@
|
|||||||
padding: 0px 20px;
|
padding: 0px 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.desktop_hide,
|
||||||
background: #fff;
|
.desktop_hide table {
|
||||||
border-radius: 16px;
|
mso-hide: all;
|
||||||
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 {
|
|
||||||
display: none;
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<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" width="100%" style="background: #f6f8fa;">
|
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation"
|
||||||
<tr>
|
style="mso-table-lspace:0;mso-table-rspace:0;background-image:none;background-position:top left;background-size:auto;background-repeat:no-repeat"
|
||||||
<td align="center">
|
width="100%">
|
||||||
<table border="0" cellpadding="0" cellspacing="0" width="720" style="max-width: 98vw;">
|
<tbody>
|
||||||
<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>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<div class="card">
|
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-1" role="presentation"
|
||||||
<h3>🚀 New Tasks List & Kanban Board</h3>
|
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||||
<ul class="feature-list">
|
<tbody>
|
||||||
<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>
|
<tr>
|
||||||
<td style="padding: 32px 0 0 0;">
|
<td>
|
||||||
<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;">
|
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-1 padding-20"
|
||||||
Click <a href="{{unsubscribe}}" target="_blank" style="color: #1890ff;">here</a> to unsubscribe and
|
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;margin-left: auto;margin-right: auto;padding-top: 20px;
|
||||||
manage your email preferences.
|
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>
|
</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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
</tbody>
|
||||||
</table>
|
</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>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -2,30 +2,31 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<title>Reset Your Password | Worklenz</title>
|
<title></title>
|
||||||
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
|
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
|
||||||
<meta content="width=device-width,initial-scale=1" name="viewport">
|
<meta content="width=device-width,initial-scale=1" name="viewport">
|
||||||
<style>
|
<style>
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0
|
||||||
background: linear-gradient(135deg, #f5f8ff 0%, #eaf0fb 100%);
|
|
||||||
-webkit-text-size-adjust: none;
|
|
||||||
text-size-adjust: none;
|
|
||||||
font-family: 'Mada', Arial, sans-serif;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-container {
|
a[x-apple-data-detectors] {
|
||||||
background: #fff;
|
color: inherit !important;
|
||||||
border-radius: 18px;
|
text-decoration: inherit !important
|
||||||
box-shadow: 0 4px 24px rgba(73, 146, 240, 0.10);
|
}
|
||||||
margin: 40px auto 0 auto;
|
|
||||||
max-width: 500px;
|
#MessageViewBody a {
|
||||||
padding: 0 0 20px 0;
|
color: inherit;
|
||||||
|
text-decoration: none
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
line-height: inherit
|
||||||
}
|
}
|
||||||
|
|
||||||
.padding-30 {
|
.padding-30 {
|
||||||
@@ -41,68 +42,33 @@
|
|||||||
mso-hide: all;
|
mso-hide: all;
|
||||||
display: none;
|
display: none;
|
||||||
max-height: 0;
|
max-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden
|
||||||
}
|
|
||||||
|
|
||||||
.modern-btn {
|
|
||||||
text-decoration: none;
|
|
||||||
display: inline-block;
|
|
||||||
color: #fff;
|
|
||||||
background: linear-gradient(90deg, #54bf6b 0%, #4992f0d9 100%);
|
|
||||||
border-radius: 6px;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 10px 32px;
|
|
||||||
font-size: 17px;
|
|
||||||
box-shadow: 0 2px 8px rgba(73, 146, 240, 0.15);
|
|
||||||
transition: background 0.2s, box-shadow 0.2s;
|
|
||||||
margin-top: 10px;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modern-btn:hover {
|
|
||||||
background: linear-gradient(90deg, #4992f0d9 0%, #54bf6b 100%);
|
|
||||||
box-shadow: 0 4px 16px rgba(73, 146, 240, 0.18);
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer {
|
|
||||||
text-align: center;
|
|
||||||
color: #b0b8c9;
|
|
||||||
font-size: 13px;
|
|
||||||
margin-top: 30px;
|
|
||||||
padding-bottom: 18px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 525px) {
|
@media (max-width: 525px) {
|
||||||
.main-container {
|
|
||||||
margin: 0;
|
|
||||||
border-radius: 0;
|
|
||||||
box-shadow: none;
|
|
||||||
max-width: 100% !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.desktop_hide table.icons-inner {
|
.desktop_hide table.icons-inner {
|
||||||
display: inline-block !important;
|
display: inline-block !important
|
||||||
}
|
}
|
||||||
|
|
||||||
.icons-inner {
|
.icons-inner {
|
||||||
text-align: center;
|
text-align: center
|
||||||
}
|
}
|
||||||
|
|
||||||
.icons-inner td {
|
.icons-inner td {
|
||||||
margin: 0 auto;
|
margin: 0 auto
|
||||||
}
|
}
|
||||||
|
|
||||||
.row-content {
|
.row-content {
|
||||||
width: 95% !important;
|
width: 95% !important
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile_hide {
|
.mobile_hide {
|
||||||
display: none;
|
display: none
|
||||||
}
|
}
|
||||||
|
|
||||||
.stack .column {
|
.stack .column {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: block;
|
display: block
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile_hide {
|
.mobile_hide {
|
||||||
@@ -110,113 +76,160 @@
|
|||||||
max-height: 0;
|
max-height: 0;
|
||||||
max-width: 0;
|
max-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-size: 0;
|
font-size: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
.desktop_hide,
|
.desktop_hide,
|
||||||
.desktop_hide table {
|
.desktop_hide table {
|
||||||
display: table !important;
|
display: table !important;
|
||||||
max-height: none !important;
|
max-height: none !important
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Mada:wght@500;700&display=swap" rel="stylesheet">
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body style="background-color:#fff;margin:0;padding:0;-webkit-text-size-adjust:none;text-size-adjust:none">
|
||||||
<div class="main-container">
|
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation"
|
||||||
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation" style="background:transparent;" width="100%">
|
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>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<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%">
|
<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>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<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="220">
|
|
||||||
|
<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="220">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
||||||
<div align="left" class="alignment" style="line-height:10px">
|
<div align="left" class="alignment" style="line-height:10px">
|
||||||
<a href="www.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;height:auto;border:0;max-width:100%;margin-top: 10px;margin-bottom: 5px;"></a>
|
<a href="www.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;height:auto;border:0;max-width:100%;margin-top: 10px;margin-bottom: 5px;"></a>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</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:475px;" width="475">
|
<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="475">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<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%">
|
<td class="column column-1"
|
||||||
<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%">
|
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>
|
<tr>
|
||||||
<td class="pad">
|
<td class="pad">
|
||||||
<h1 style="margin:0;color:rgb(66, 66, 66);font-size:28px;line-height:120%;text-align:center;direction:ltr;font-weight:700;letter-spacing:0.5px;margin-top:30px;margin-bottom:0;padding-top: 20px;padding-bottom: 20px;font-family: 'Mada', Arial, sans-serif;">
|
<h1
|
||||||
<span class="tinyMce-placeholder">Reset your password</span>
|
style="margin:0;color:rgb(66, 66, 66);font-size:26px;line-height:120%;text-align:center;direction:ltr;font-weight:700;letter-spacing:normal;margin-top:30px;margin-bottom:0;padding-top: 20px;padding-bottom: 20px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;">
|
||||||
|
<span class="tinyMce-placeholder">Reset your password on Worklenz</span>
|
||||||
</h1>
|
</h1>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
<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/email-new-team.png" style="display:block;height:auto;border:0;width:180px;max-width:100%;margin-top: 30px;margin-bottom: 10px;" width="180">
|
<div align="center" class="alignment" style="line-height:10px"><img
|
||||||
|
src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-new-team.png"
|
||||||
|
style="display:block;height:auto;border:0;width:180px;max-width:100%;margin-top: 30px;margin-bottom: 10px;"
|
||||||
|
width="180">
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
<table border="0" cellpadding="5" cellspacing="0" class="paragraph_block block-4 padding-30" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word" width="100%">
|
<table border="0" cellpadding="5" cellspacing="0" class="paragraph_block block-4 padding-30"
|
||||||
|
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word"
|
||||||
|
width="100%">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="pad">
|
<td class="pad">
|
||||||
<div style="color:#505771;font-size:20px;font-family: 'Mada', Arial, sans-serif;font-weight:500;line-height:130%;text-align:center;direction:ltr;letter-spacing:0.1px;mso-line-height-alt:24px">
|
<div
|
||||||
|
style="color:#505771;font-size:19px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;font-weight:500;line-height:120%;text-align:center;direction:ltr;letter-spacing:0;mso-line-height-alt:24px">
|
||||||
<p style="margin-top: 0px;margin-bottom: 2px;">Hi,</p>
|
<p style="margin-top: 0px;margin-bottom: 2px;">Hi,</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</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%">
|
<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>
|
<tr>
|
||||||
<td class="pad">
|
<td class="pad">
|
||||||
<div style="color:rgb(143,152,164);font-size:16px;font-family: 'Mada', Arial, sans-serif;font-weight:400;line-height:145%;text-align:center;direction:ltr;letter-spacing:0.1px;mso-line-height-alt:18px">
|
<div
|
||||||
<p style="margin:0;margin-bottom:10px">We received a request to reset your Worklenz account password.</p>
|
style="color:rgb(143,152,164);font-size:16px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;font-weight:400;line-height:135%;text-align:center;direction:ltr;letter-spacing:0;mso-line-height-alt:18px">
|
||||||
<p style="margin:0;margin-bottom:10px">Click the button below to set a new password. If you did not request this, you can safely ignore this email.</p>
|
<p style="margin:0;margin-bottom:10px">You have requested to reset your password
|
||||||
|
</p>
|
||||||
|
<p style="margin:0;margin-bottom:10px">To reset your password, click the following link and follow the instructions.</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
<table border="0" cellpadding="10" cellspacing="0" class="button_block block-6" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;margin-top: 10px;margin-bottom: 30px;" width="100%">
|
<table border="0" cellpadding="10" cellspacing="0" class="button_block block-6" role="presentation"
|
||||||
|
style="mso-table-lspace:0;mso-table-rspace:0;margin-top: 10px;margin-bottom: 30px;"
|
||||||
|
width="100%">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="pad">
|
<td class="pad">
|
||||||
<div align="center" class="alignment">
|
<div align="center" class="alignment">
|
||||||
<a href="https://[VAR_HOSTNAME]/auth/verify-reset-email/[VAR_USER_ID]/[VAR_HASH]" class="modern-btn">
|
<a href="https://[VAR_HOSTNAME]/auth/verify-reset-email/[VAR_USER_ID]/[VAR_HASH]">
|
||||||
Reset my password
|
<div
|
||||||
|
style="text-decoration:none;display:inline-block;color:#fff;background: #54bf6b;border-radius:3px;width:auto;font-weight:400;padding-top:6px;padding-bottom:7px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;text-align:center;mso-border-alt:none;word-break:keep-all">
|
||||||
|
<span
|
||||||
|
style="padding-left:25px;padding-right:25px;font-size:16px;display:inline-block;letter-spacing:normal;"><span
|
||||||
|
dir="ltr" style="word-break: break-word; line-height: 28px;">Reset my password</span></span>
|
||||||
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
<!--[if mso]></center></v:textbox></v:roundrect><![endif]-->
|
||||||
<div style="color:#b0b8c9;font-size:14px;text-align:center;margin-top:10px;">
|
|
||||||
<p style="margin:0;">For your security, this link will expire in 1 hour.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-2" role="presentation"
|
||||||
</tr>
|
style="mso-table-lspace:0;mso-table-rspace:0"
|
||||||
</tbody>
|
width="100%">
|
||||||
</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>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<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">
|
<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>
|
<tbody>
|
||||||
<tr>
|
<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%">
|
<td class="column column-1"
|
||||||
<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%">
|
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>
|
<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">
|
<td class="pad"
|
||||||
<table cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
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>
|
<tr>
|
||||||
<td class="alignment" style="vertical-align:middle;text-align:center">
|
<td class="alignment" style="vertical-align:middle;text-align:center">
|
||||||
<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">
|
<!--[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>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -235,12 +248,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table><!-- End -->
|
||||||
<div class="footer">
|
|
||||||
If you have any questions, contact us at <a href="mailto:support@worklenz.com" style="color:#4992f0;text-decoration:none;">support@worklenz.com</a>.<br>
|
|
||||||
© 2025 Worklenz. All rights reserved.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -2,30 +2,31 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<title>Join Your Team on Worklenz</title>
|
<title></title>
|
||||||
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
|
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
|
||||||
<meta content="width=device-width,initial-scale=1" name="viewport">
|
<meta content="width=device-width,initial-scale=1" name="viewport">
|
||||||
<style>
|
<style>
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0
|
||||||
background: linear-gradient(135deg, #f5f8ff 0%, #eaf0fb 100%);
|
|
||||||
-webkit-text-size-adjust: none;
|
|
||||||
text-size-adjust: none;
|
|
||||||
font-family: 'Mada', Arial, sans-serif;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-container {
|
a[x-apple-data-detectors] {
|
||||||
background: #fff;
|
color: inherit !important;
|
||||||
border-radius: 18px;
|
text-decoration: inherit !important
|
||||||
box-shadow: 0 4px 24px rgba(73, 146, 240, 0.10);
|
}
|
||||||
margin: 40px auto 0 auto;
|
|
||||||
max-width: 500px;
|
#MessageViewBody a {
|
||||||
padding: 0 0 20px 0;
|
color: inherit;
|
||||||
|
text-decoration: none
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
line-height: inherit
|
||||||
}
|
}
|
||||||
|
|
||||||
.padding-30 {
|
.padding-30 {
|
||||||
@@ -41,68 +42,33 @@
|
|||||||
mso-hide: all;
|
mso-hide: all;
|
||||||
display: none;
|
display: none;
|
||||||
max-height: 0;
|
max-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden
|
||||||
}
|
|
||||||
|
|
||||||
.modern-btn {
|
|
||||||
text-decoration: none;
|
|
||||||
display: inline-block;
|
|
||||||
color: #fff;
|
|
||||||
background: linear-gradient(90deg, #54bf6b 0%, #4992f0d9 100%);
|
|
||||||
border-radius: 6px;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 10px 32px;
|
|
||||||
font-size: 17px;
|
|
||||||
box-shadow: 0 2px 8px rgba(73, 146, 240, 0.15);
|
|
||||||
transition: background 0.2s, box-shadow 0.2s;
|
|
||||||
margin-top: 10px;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modern-btn:hover {
|
|
||||||
background: linear-gradient(90deg, #4992f0d9 0%, #54bf6b 100%);
|
|
||||||
box-shadow: 0 4px 16px rgba(73, 146, 240, 0.18);
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer {
|
|
||||||
text-align: center;
|
|
||||||
color: #b0b8c9;
|
|
||||||
font-size: 13px;
|
|
||||||
margin-top: 30px;
|
|
||||||
padding-bottom: 18px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 525px) {
|
@media (max-width: 525px) {
|
||||||
.main-container {
|
|
||||||
margin: 0;
|
|
||||||
border-radius: 0;
|
|
||||||
box-shadow: none;
|
|
||||||
max-width: 100% !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.desktop_hide table.icons-inner {
|
.desktop_hide table.icons-inner {
|
||||||
display: inline-block !important;
|
display: inline-block !important
|
||||||
}
|
}
|
||||||
|
|
||||||
.icons-inner {
|
.icons-inner {
|
||||||
text-align: center;
|
text-align: center
|
||||||
}
|
}
|
||||||
|
|
||||||
.icons-inner td {
|
.icons-inner td {
|
||||||
margin: 0 auto;
|
margin: 0 auto
|
||||||
}
|
}
|
||||||
|
|
||||||
.row-content {
|
.row-content {
|
||||||
width: 95% !important;
|
width: 95% !important
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile_hide {
|
.mobile_hide {
|
||||||
display: none;
|
display: none
|
||||||
}
|
}
|
||||||
|
|
||||||
.stack .column {
|
.stack .column {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: block;
|
display: block
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile_hide {
|
.mobile_hide {
|
||||||
@@ -110,110 +76,162 @@
|
|||||||
max-height: 0;
|
max-height: 0;
|
||||||
max-width: 0;
|
max-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-size: 0;
|
font-size: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
.desktop_hide,
|
.desktop_hide,
|
||||||
.desktop_hide table {
|
.desktop_hide table {
|
||||||
display: table !important;
|
display: table !important;
|
||||||
max-height: none !important;
|
max-height: none !important
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Mada:wght@500;700&display=swap" rel="stylesheet">
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body style="background-color:#fff;margin:0;padding:0;-webkit-text-size-adjust:none;text-size-adjust:none">
|
||||||
<div class="main-container">
|
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation"
|
||||||
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation" style="background:transparent;" width="100%">
|
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>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<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%">
|
<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>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<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="220">
|
|
||||||
|
<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="220">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
||||||
<div align="left" class="alignment" style="line-height:10px">
|
<div align="left" class="alignment" style="line-height:10px">
|
||||||
<a href="www.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;height:auto;border:0;max-width:100%;margin-top: 10px;margin-bottom: 5px;"></a>
|
<a href="www.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;height:auto;border:0;max-width:100%;margin-top: 10px;margin-bottom: 5px;"></a>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</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:475px;" width="475">
|
<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="475">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<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%">
|
<td class="column column-1"
|
||||||
<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%">
|
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>
|
<tr>
|
||||||
<td class="pad">
|
<td class="pad">
|
||||||
<h1 style="margin:0;color:rgb(66, 66, 66);font-size:28px;line-height:120%;text-align:center;direction:ltr;font-weight:700;letter-spacing:0.5px;margin-top:30px;margin-bottom:0;padding-top: 20px;padding-bottom: 20px;font-family: 'Mada', Arial, sans-serif;">
|
<h1
|
||||||
|
style="margin:0;color:rgb(66, 66, 66);font-size:26px;line-height:120%;text-align:center;direction:ltr;font-weight:700;letter-spacing:normal;margin-top:30px;margin-bottom:0;padding-top: 20px;padding-bottom: 20px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;">
|
||||||
<span class="tinyMce-placeholder">Join your team on Worklenz</span>
|
<span class="tinyMce-placeholder">Join your team on Worklenz</span>
|
||||||
</h1>
|
</h1>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
<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/email-new-team.png" style="display:block;height:auto;border:0;width:180px;max-width:100%;margin-top: 30px;margin-bottom: 10px;" width="180">
|
<div align="center" class="alignment" style="line-height:10px"><img
|
||||||
|
src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-new-team.png"
|
||||||
|
style="display:block;height:auto;border:0;width:180px;max-width:100%;margin-top: 30px;margin-bottom: 10px;"
|
||||||
|
width="180">
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
<table border="0" cellpadding="5" cellspacing="0" class="paragraph_block block-4 padding-30" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word" width="100%">
|
<table border="0" cellpadding="5" cellspacing="0" class="paragraph_block block-4 padding-30"
|
||||||
|
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word"
|
||||||
|
width="100%">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="pad">
|
<td class="pad">
|
||||||
<div style="color:#505771;font-size:20px;font-family: 'Mada', Arial, sans-serif;font-weight:500;line-height:130%;text-align:center;direction:ltr;letter-spacing:0.1px;mso-line-height-alt:24px">
|
<div
|
||||||
|
style="color:#505771;font-size:19px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;font-weight:500;line-height:120%;text-align:center;direction:ltr;letter-spacing:0;mso-line-height-alt:24px">
|
||||||
<p style="margin-top: 0px;margin-bottom: 2px;">Hi [VAR_USER_NAME],</p>
|
<p style="margin-top: 0px;margin-bottom: 2px;">Hi [VAR_USER_NAME],</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</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%">
|
<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>
|
<tr>
|
||||||
<td class="pad">
|
<td class="pad">
|
||||||
<div style="color:rgb(143,152,164);font-size:16px;font-family: 'Mada', Arial, sans-serif;font-weight:400;line-height:145%;text-align:center;direction:ltr;letter-spacing:0.1px;mso-line-height-alt:18px">
|
<div
|
||||||
<p style="margin:0;margin-bottom:10px">You have been added to the "[VAR_TEAM_NAME]" team on Worklenz!</p>
|
style="color:rgb(143,152,164);font-size:16px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;font-weight:400;line-height:135%;text-align:center;direction:ltr;letter-spacing:0;mso-line-height-alt:18px">
|
||||||
|
<p style="margin:0;margin-bottom:10px">You have been added to the "[VAR_TEAM_NAME]" team
|
||||||
|
on Worklenz!
|
||||||
|
</p>
|
||||||
<p>Sign in to your Worklenz account to continue.</p>
|
<p>Sign in to your Worklenz account to continue.</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
<table border="0" cellpadding="10" cellspacing="0" class="button_block block-6" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;margin-top: 10px;margin-bottom: 30px;" width="100%">
|
<table border="0" cellpadding="10" cellspacing="0" class="button_block block-6" role="presentation"
|
||||||
|
style="mso-table-lspace:0;mso-table-rspace:0;margin-top: 10px;margin-bottom: 30px;"
|
||||||
|
width="100%">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="pad">
|
<td class="pad">
|
||||||
<div align="center" class="alignment">
|
<div align="center" class="alignment">
|
||||||
<a href="https://[VAR_HOSTNAME]/auth/login?team=[VAR_TEAM_ID]&user=[VAR_USER_ID]&project=[PROJECT_ID]" class="modern-btn">
|
<a href="https://[VAR_HOSTNAME]/auth/login?team=[VAR_TEAM_ID]&user=[VAR_USER_ID]&project=[PROJECT_ID]">
|
||||||
Go to Worklenz
|
<div
|
||||||
|
style="text-decoration:none;display:inline-block;color:#fff;background: #54bf6b;border-radius:3px;width:auto;font-weight:400;padding-top:6px;padding-bottom:7px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;text-align:center;mso-border-alt:none;word-break:keep-all">
|
||||||
|
<span
|
||||||
|
style="padding-left:25px;padding-right:25px;font-size:16px;display:inline-block;letter-spacing:normal;"><span
|
||||||
|
dir="ltr" style="word-break: break-word; line-height: 28px;">Go to
|
||||||
|
Worklenz</span></span>
|
||||||
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
<!--[if mso]></center></v:textbox></v:roundrect><![endif]-->
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-2" role="presentation"
|
||||||
</tr>
|
style="mso-table-lspace:0;mso-table-rspace:0"
|
||||||
</tbody>
|
width="100%">
|
||||||
</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>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<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">
|
<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>
|
<tbody>
|
||||||
<tr>
|
<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%">
|
<td class="column column-1"
|
||||||
<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%">
|
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>
|
<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">
|
<td class="pad"
|
||||||
<table cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
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>
|
<tr>
|
||||||
<td class="alignment" style="vertical-align:middle;text-align:center">
|
<td class="alignment" style="vertical-align:middle;text-align:center">
|
||||||
<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">
|
<!--[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>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -232,12 +250,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table><!-- End -->
|
||||||
<div class="footer">
|
|
||||||
If you have any questions, contact us at <a href="mailto:support@worklenz.com" style="color:#4992f0;text-decoration:none;">support@worklenz.com</a>.<br>
|
|
||||||
© 2025 Worklenz. All rights reserved.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -2,30 +2,31 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<title>Join Your Team on Worklenz</title>
|
<title></title>
|
||||||
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
|
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
|
||||||
<meta content="width=device-width,initial-scale=1" name="viewport">
|
<meta content="width=device-width,initial-scale=1" name="viewport">
|
||||||
<style>
|
<style>
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0
|
||||||
background: linear-gradient(135deg, #f5f8ff 0%, #eaf0fb 100%);
|
|
||||||
-webkit-text-size-adjust: none;
|
|
||||||
text-size-adjust: none;
|
|
||||||
font-family: 'Mada', Arial, sans-serif;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-container {
|
a[x-apple-data-detectors] {
|
||||||
background: #fff;
|
color: inherit !important;
|
||||||
border-radius: 18px;
|
text-decoration: inherit !important
|
||||||
box-shadow: 0 4px 24px rgba(73, 146, 240, 0.10);
|
}
|
||||||
margin: 40px auto 0 auto;
|
|
||||||
max-width: 500px;
|
#MessageViewBody a {
|
||||||
padding: 0 0 20px 0;
|
color: inherit;
|
||||||
|
text-decoration: none
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
line-height: inherit
|
||||||
}
|
}
|
||||||
|
|
||||||
.padding-30 {
|
.padding-30 {
|
||||||
@@ -41,68 +42,33 @@
|
|||||||
mso-hide: all;
|
mso-hide: all;
|
||||||
display: none;
|
display: none;
|
||||||
max-height: 0;
|
max-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden
|
||||||
}
|
|
||||||
|
|
||||||
.modern-btn {
|
|
||||||
text-decoration: none;
|
|
||||||
display: inline-block;
|
|
||||||
color: #fff;
|
|
||||||
background: linear-gradient(90deg, #6249f0 0%, #4992f0d9 100%);
|
|
||||||
border-radius: 6px;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 10px 32px;
|
|
||||||
font-size: 17px;
|
|
||||||
box-shadow: 0 2px 8px rgba(73, 146, 240, 0.15);
|
|
||||||
transition: background 0.2s, box-shadow 0.2s;
|
|
||||||
margin-top: 10px;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modern-btn:hover {
|
|
||||||
background: linear-gradient(90deg, #4992f0d9 0%, #6249f0 100%);
|
|
||||||
box-shadow: 0 4px 16px rgba(73, 146, 240, 0.18);
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer {
|
|
||||||
text-align: center;
|
|
||||||
color: #b0b8c9;
|
|
||||||
font-size: 13px;
|
|
||||||
margin-top: 30px;
|
|
||||||
padding-bottom: 18px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 525px) {
|
@media (max-width: 525px) {
|
||||||
.main-container {
|
|
||||||
margin: 0;
|
|
||||||
border-radius: 0;
|
|
||||||
box-shadow: none;
|
|
||||||
max-width: 100% !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.desktop_hide table.icons-inner {
|
.desktop_hide table.icons-inner {
|
||||||
display: inline-block !important;
|
display: inline-block !important
|
||||||
}
|
}
|
||||||
|
|
||||||
.icons-inner {
|
.icons-inner {
|
||||||
text-align: center;
|
text-align: center
|
||||||
}
|
}
|
||||||
|
|
||||||
.icons-inner td {
|
.icons-inner td {
|
||||||
margin: 0 auto;
|
margin: 0 auto
|
||||||
}
|
}
|
||||||
|
|
||||||
.row-content {
|
.row-content {
|
||||||
width: 95% !important;
|
width: 95% !important
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile_hide {
|
.mobile_hide {
|
||||||
display: none;
|
display: none
|
||||||
}
|
}
|
||||||
|
|
||||||
.stack .column {
|
.stack .column {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: block;
|
display: block
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile_hide {
|
.mobile_hide {
|
||||||
@@ -110,23 +76,21 @@
|
|||||||
max-height: 0;
|
max-height: 0;
|
||||||
max-width: 0;
|
max-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-size: 0;
|
font-size: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
.desktop_hide,
|
.desktop_hide,
|
||||||
.desktop_hide table {
|
.desktop_hide table {
|
||||||
display: table !important;
|
display: table !important;
|
||||||
max-height: none !important;
|
max-height: none !important
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Mada:wght@500;700&display=swap" rel="stylesheet">
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body style="background-color:#fff;margin:0;padding:0;-webkit-text-size-adjust:none;text-size-adjust:none">
|
||||||
<div class="main-container">
|
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation"
|
||||||
<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"
|
||||||
style="background:transparent;"
|
|
||||||
width="100%">
|
width="100%">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -136,6 +100,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
|
|
||||||
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-1 padding-20" role="presentation"
|
<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;
|
style="mso-table-lspace:0;mso-table-rspace:0;margin-left: auto;margin-right: auto;padding-top: 20px;
|
||||||
padding-bottom: 20px;" width="220">
|
padding-bottom: 20px;" width="220">
|
||||||
@@ -164,7 +129,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td class="pad">
|
<td class="pad">
|
||||||
<h1
|
<h1
|
||||||
style="margin:0;color:rgb(66, 66, 66);font-size:28px;line-height:120%;text-align:center;direction:ltr;font-weight:700;letter-spacing:0.5px;margin-top:30px;margin-bottom:0;padding-top: 20px;padding-bottom: 20px;font-family: 'Mada', Arial, sans-serif;">
|
style="margin:0;color:rgb(66, 66, 66);font-size:26px;line-height:120%;text-align:center;direction:ltr;font-weight:700;letter-spacing:normal;margin-top:30px;margin-bottom:0;padding-top: 20px;padding-bottom: 20px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;">
|
||||||
<span class="tinyMce-placeholder">Join your team on Worklenz</span>
|
<span class="tinyMce-placeholder">Join your team on Worklenz</span>
|
||||||
</h1>
|
</h1>
|
||||||
</td>
|
</td>
|
||||||
@@ -185,7 +150,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td class="pad">
|
<td class="pad">
|
||||||
<div
|
<div
|
||||||
style="color:#505771;font-size:20px;font-family: 'Mada', Arial, sans-serif;font-weight:500;line-height:130%;text-align:center;direction:ltr;letter-spacing:0.1px;mso-line-height-alt:24px">
|
style="color:#505771;font-size:19px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;font-weight:500;line-height:120%;text-align:center;direction:ltr;letter-spacing:0;mso-line-height-alt:24px">
|
||||||
<p style="margin-top: 0px;margin-bottom: 2px;">Hi,</p>
|
<p style="margin-top: 0px;margin-bottom: 2px;">Hi,</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -197,7 +162,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td class="pad">
|
<td class="pad">
|
||||||
<div
|
<div
|
||||||
style="color:rgb(143,152,164);font-size:16px;font-family: 'Mada', Arial, sans-serif;font-weight:400;line-height:145%;text-align:center;direction:ltr;letter-spacing:0.1px;mso-line-height-alt:18px">
|
style="color:rgb(143,152,164);font-size:16px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;font-weight:400;line-height:135%;text-align:center;direction:ltr;letter-spacing:0;mso-line-height-alt:18px">
|
||||||
<p style="margin:0;margin-bottom:10px">You have been added to the "[VAR_TEAM_NAME]" team
|
<p style="margin:0;margin-bottom:10px">You have been added to the "[VAR_TEAM_NAME]" team
|
||||||
on Worklenz!</p>
|
on Worklenz!</p>
|
||||||
<p>Create an account in Worklenz to continue.</p>
|
<p>Create an account in Worklenz to continue.</p>
|
||||||
@@ -211,17 +176,24 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td class="pad">
|
<td class="pad">
|
||||||
<div align="center" class="alignment">
|
<div align="center" class="alignment">
|
||||||
<a href="https://[VAR_HOSTNAME]/auth/signup?email=[VAR_EMAIL]&user=[VAR_USER_ID]&team=[VAR_TEAM_ID]&name=[VAR_USER_NAME]&project=[PROJECT_ID]" class="modern-btn">
|
<a href="https://[VAR_HOSTNAME]/auth/signup?email=[VAR_EMAIL]&user=[VAR_USER_ID]&team=[VAR_TEAM_ID]&name=[VAR_USER_NAME]&project=[PROJECT_ID]">
|
||||||
Join Worklenz
|
<div
|
||||||
|
style="text-decoration:none;display:inline-block;color:#fff;background: #6249f0;border-radius:3px;width:auto;font-weight:400;padding-top:6px;padding-bottom:7px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;text-align:center;mso-border-alt:none;word-break:keep-all">
|
||||||
|
<span
|
||||||
|
style="padding-left:25px;padding-right:25px;font-size:16px;display:inline-block;letter-spacing:normal;"><span
|
||||||
|
dir="ltr" style="word-break: break-word; line-height: 28px;">Join
|
||||||
|
Worklenz</span></span>
|
||||||
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
<!--[if mso]></center></v:textbox></v:roundrect><![endif]-->
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</table>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -250,6 +222,11 @@
|
|||||||
width="100%">
|
width="100%">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="alignment" style="vertical-align:middle;text-align:center">
|
<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"
|
<table cellpadding="0"
|
||||||
cellspacing="0"
|
cellspacing="0"
|
||||||
class="icons-inner" role="presentation"
|
class="icons-inner" role="presentation"
|
||||||
@@ -272,12 +249,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table><!-- End -->
|
||||||
<div class="footer">
|
|
||||||
If you have any questions, contact us at <a href="mailto:support@worklenz.com" style="color:#4992f0;text-decoration:none;">support@worklenz.com</a>.<br>
|
|
||||||
© 2025 Worklenz. All rights reserved.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -2,30 +2,31 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<title>Welcome to Worklenz</title>
|
<title></title>
|
||||||
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
|
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
|
||||||
<meta content="width=device-width,initial-scale=1" name="viewport">
|
<meta content="width=device-width,initial-scale=1" name="viewport">
|
||||||
<style>
|
<style>
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0
|
||||||
background: linear-gradient(135deg, #f5f8ff 0%, #eaf0fb 100%);
|
|
||||||
-webkit-text-size-adjust: none;
|
|
||||||
text-size-adjust: none;
|
|
||||||
font-family: 'Mada', Arial, sans-serif;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-container {
|
a[x-apple-data-detectors] {
|
||||||
background: #fff;
|
color: inherit !important;
|
||||||
border-radius: 18px;
|
text-decoration: inherit !important
|
||||||
box-shadow: 0 4px 24px rgba(73, 146, 240, 0.10);
|
}
|
||||||
margin: 40px auto 0 auto;
|
|
||||||
max-width: 500px;
|
#MessageViewBody a {
|
||||||
padding: 0 0 20px 0;
|
color: inherit;
|
||||||
|
text-decoration: none
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
line-height: inherit
|
||||||
}
|
}
|
||||||
|
|
||||||
.padding-30 {
|
.padding-30 {
|
||||||
@@ -41,68 +42,33 @@
|
|||||||
mso-hide: all;
|
mso-hide: all;
|
||||||
display: none;
|
display: none;
|
||||||
max-height: 0;
|
max-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden
|
||||||
}
|
|
||||||
|
|
||||||
.modern-btn {
|
|
||||||
text-decoration: none;
|
|
||||||
display: inline-block;
|
|
||||||
color: #fff;
|
|
||||||
background: linear-gradient(90deg, #4992f0d9 0%, #3b6fd6 100%);
|
|
||||||
border-radius: 6px;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 10px 32px;
|
|
||||||
font-size: 17px;
|
|
||||||
box-shadow: 0 2px 8px rgba(73, 146, 240, 0.15);
|
|
||||||
transition: background 0.2s, box-shadow 0.2s;
|
|
||||||
margin-top: 10px;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modern-btn:hover {
|
|
||||||
background: linear-gradient(90deg, #3b6fd6 0%, #4992f0d9 100%);
|
|
||||||
box-shadow: 0 4px 16px rgba(73, 146, 240, 0.18);
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer {
|
|
||||||
text-align: center;
|
|
||||||
color: #b0b8c9;
|
|
||||||
font-size: 13px;
|
|
||||||
margin-top: 30px;
|
|
||||||
padding-bottom: 18px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 525px) {
|
@media (max-width: 525px) {
|
||||||
.main-container {
|
|
||||||
margin: 0;
|
|
||||||
border-radius: 0;
|
|
||||||
box-shadow: none;
|
|
||||||
max-width: 100% !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.desktop_hide table.icons-inner {
|
.desktop_hide table.icons-inner {
|
||||||
display: inline-block !important;
|
display: inline-block !important
|
||||||
}
|
}
|
||||||
|
|
||||||
.icons-inner {
|
.icons-inner {
|
||||||
text-align: center;
|
text-align: center
|
||||||
}
|
}
|
||||||
|
|
||||||
.icons-inner td {
|
.icons-inner td {
|
||||||
margin: 0 auto;
|
margin: 0 auto
|
||||||
}
|
}
|
||||||
|
|
||||||
.row-content {
|
.row-content {
|
||||||
width: 95% !important;
|
width: 95% !important
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile_hide {
|
.mobile_hide {
|
||||||
display: none;
|
display: none
|
||||||
}
|
}
|
||||||
|
|
||||||
.stack .column {
|
.stack .column {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: block;
|
display: block
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile_hide {
|
.mobile_hide {
|
||||||
@@ -110,23 +76,21 @@
|
|||||||
max-height: 0;
|
max-height: 0;
|
||||||
max-width: 0;
|
max-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-size: 0;
|
font-size: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
.desktop_hide,
|
.desktop_hide,
|
||||||
.desktop_hide table {
|
.desktop_hide table {
|
||||||
display: table !important;
|
display: table !important;
|
||||||
max-height: none !important;
|
max-height: none !important
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Mada:wght@500;700&display=swap" rel="stylesheet">
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body style="background-color:#fff;margin:0;padding:0;-webkit-text-size-adjust:none;text-size-adjust:none">
|
||||||
<div class="main-container">
|
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation"
|
||||||
<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"
|
||||||
style="mso-table-lspace:0;mso-table-rspace:0;background:transparent;"
|
|
||||||
width="100%">
|
width="100%">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -136,6 +100,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
|
|
||||||
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-1 padding-20" role="presentation"
|
<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;
|
style="mso-table-lspace:0;mso-table-rspace:0;margin-left: auto;margin-right: auto;padding-top: 20px;
|
||||||
padding-bottom: 20px;" width="220">
|
padding-bottom: 20px;" width="220">
|
||||||
@@ -164,7 +129,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td class="pad">
|
<td class="pad">
|
||||||
<h1
|
<h1
|
||||||
style="margin:0;color:rgb(66, 66, 66);font-size:28px;line-height:120%;text-align:center;direction:ltr;font-weight:700;letter-spacing:0.5px;margin-top:30px;margin-bottom:0;padding-top: 20px;padding-bottom: 20px;font-family: 'Mada', Arial, sans-serif;">
|
style="margin:0;color:rgb(66, 66, 66);font-size:26px;line-height:120%;text-align:center;direction:ltr;font-weight:700;letter-spacing:normal;margin-top:30px;margin-bottom:0;padding-top: 20px;padding-bottom: 20px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;">
|
||||||
<span class="tinyMce-placeholder">Let's get started with Worklenz.</span>
|
<span class="tinyMce-placeholder">Let's get started with Worklenz.</span>
|
||||||
</h1>
|
</h1>
|
||||||
</td>
|
</td>
|
||||||
@@ -185,7 +150,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td class="pad">
|
<td class="pad">
|
||||||
<div
|
<div
|
||||||
style="color:#505771;font-size:20px;font-family: 'Mada', Arial, sans-serif;font-weight:500;line-height:130%;text-align:center;direction:ltr;letter-spacing:0.1px;mso-line-height-alt:24px">
|
style="color:#505771;font-size:19px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;font-weight:500;line-height:120%;text-align:center;direction:ltr;letter-spacing:0;mso-line-height-alt:24px">
|
||||||
<p style="margin-top: 0px;margin-bottom: 2px;">Hi [VAR_USER_NAME],</p>
|
<p style="margin-top: 0px;margin-bottom: 2px;">Hi [VAR_USER_NAME],</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -197,7 +162,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td class="pad">
|
<td class="pad">
|
||||||
<div
|
<div
|
||||||
style="color:rgb(143,152,164);font-size:16px;font-family: 'Mada', Arial, sans-serif;font-weight:400;line-height:145%;text-align:center;direction:ltr;letter-spacing:0.1px;mso-line-height-alt:18px">
|
style="color:rgb(143,152,164);font-size:16px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;font-weight:400;line-height:135%;text-align:center;direction:ltr;letter-spacing:0;mso-line-height-alt:18px">
|
||||||
<p style="margin:0;margin-bottom:10px">Thanks for joining Worklenz!</p>
|
<p style="margin:0;margin-bottom:10px">Thanks for joining Worklenz!</p>
|
||||||
<p style="margin:0"> We're excited to have you on board. </p>
|
<p style="margin:0"> We're excited to have you on board. </p>
|
||||||
</div>
|
</div>
|
||||||
@@ -210,17 +175,24 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td class="pad">
|
<td class="pad">
|
||||||
<div align="center" class="alignment">
|
<div align="center" class="alignment">
|
||||||
<a href="https://[VAR_HOSTNAME]/auth/login" class="modern-btn">
|
<a href="https://[VAR_HOSTNAME]/auth/login">
|
||||||
Go to Worklenz
|
<div
|
||||||
|
style="text-decoration:none;display:inline-block;color:#fff;background:#4992f0d9;border-radius:3px;width:auto;font-weight:400;padding-top:6px;padding-bottom:7px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;text-align:center;mso-border-alt:none;word-break:keep-all">
|
||||||
|
<span
|
||||||
|
style="padding-left:25px;padding-right:25px;font-size:16px;display:inline-block;letter-spacing:normal;"><span
|
||||||
|
dir="ltr" style="word-break: break-word; line-height: 28px;">Go to
|
||||||
|
Worklenz</span></span>
|
||||||
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
<!--[if mso]></center></v:textbox></v:roundrect><![endif]-->
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</table>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -249,6 +221,11 @@
|
|||||||
width="100%">
|
width="100%">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="alignment" style="vertical-align:middle;text-align:center">
|
<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"
|
<table cellpadding="0"
|
||||||
cellspacing="0"
|
cellspacing="0"
|
||||||
class="icons-inner" role="presentation"
|
class="icons-inner" role="presentation"
|
||||||
@@ -271,12 +248,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table><!-- End -->
|
||||||
<div class="footer">
|
|
||||||
If you have any questions, contact us at <a href="mailto:support@worklenz.com" style="color:#4992f0;text-decoration:none;">support@worklenz.com</a>.<br>
|
|
||||||
© 2025 Worklenz. All rights reserved.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
</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 && \
|
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
|
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
|
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/).
|
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
|
## Table of Contents
|
||||||
|
|
||||||
- [Getting Started](#getting-started)
|
- [Getting Started](#getting-started)
|
||||||
- [Available Scripts](#available-scripts)
|
- [Available Scripts](#available-scripts)
|
||||||
- [Project Structure](#project-structure)
|
- [Project Structure](#project-structure)
|
||||||
- [Contributing](#contributing)
|
- [Contributing](#contributing)
|
||||||
- [Learn More](#learn-more)
|
- [Learn More](#learn-more)
|
||||||
- [License](#license)
|
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
@@ -95,7 +93,3 @@ To learn more about the technologies used in this project:
|
|||||||
- [TypeScript Documentation](https://www.typescriptlang.org/docs/)
|
- [TypeScript Documentation](https://www.typescriptlang.org/docs/)
|
||||||
- [Ant Design Documentation](https://ant.design/docs/react/introduce)
|
- [Ant Design Documentation](https://ant.design/docs/react/introduce)
|
||||||
- [Vite Documentation](https://vitejs.dev/guide/)
|
- [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,53 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
|
||||||
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<link rel="icon" href="./favicon.ico" />
|
<link rel="icon" href="./favicon.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="theme-color" content="#2b2b2b" />
|
<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.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link rel="dns-prefetch" href="https://www.googletagmanager.com" />
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap"
|
||||||
<link rel="dns-prefetch" href="https://js.hs-scripts.com" />
|
rel="stylesheet" />
|
||||||
|
|
||||||
<!-- 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>
|
<title>Worklenz</title>
|
||||||
|
|
||||||
<!-- Environment configuration -->
|
<!-- Environment configuration -->
|
||||||
<script src="/env-config.js"></script>
|
<script src="/env-config.js"></script>
|
||||||
|
<!-- Unregister service worker -->
|
||||||
<!-- Optimized Google Analytics with reduced blocking -->
|
<script src="/unregister-sw.js"></script>
|
||||||
<script>
|
<!-- Microsoft Clarity -->
|
||||||
// Function to initialize Google Analytics asynchronously
|
<script type="text/javascript">
|
||||||
function initGoogleAnalytics() {
|
if (window.location.hostname === 'app.worklenz.com') {
|
||||||
// Use requestIdleCallback to defer analytics loading
|
(function (c, l, a, r, i, t, y) {
|
||||||
const loadAnalytics = () => {
|
c[a] = c[a] || function () { (c[a].q = c[a].q || []).push(arguments) };
|
||||||
// Determine which tracking ID to use based on the environment
|
t = l.createElement(r); t.async = 1; t.src = "https://www.clarity.ms/tag/dx77073klh";
|
||||||
const isProduction = window.location.hostname === 'app.worklenz.com';
|
y = l.getElementsByTagName(r)[0]; y.parentNode.insertBefore(t, y);
|
||||||
|
})(window, document, "clarity", "script", "dx77073klh");
|
||||||
const trackingId = isProduction ? 'G-7KSRKQ1397' : 'G-3LM2HGWEXG'; // Open source tracking ID
|
|
||||||
|
|
||||||
// Load the Google Analytics script
|
|
||||||
const script = document.createElement('script');
|
|
||||||
script.async = true;
|
|
||||||
script.src = `https://www.googletagmanager.com/gtag/js?id=${trackingId}`;
|
|
||||||
document.head.appendChild(script);
|
|
||||||
|
|
||||||
// Initialize Google Analytics
|
|
||||||
window.dataLayer = window.dataLayer || [];
|
|
||||||
function gtag() {
|
|
||||||
dataLayer.push(arguments);
|
|
||||||
}
|
}
|
||||||
gtag('js', new Date());
|
|
||||||
gtag('config', trackingId);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Use requestIdleCallback if available, otherwise setTimeout
|
|
||||||
if ('requestIdleCallback' in window) {
|
|
||||||
requestIdleCallback(loadAnalytics, { timeout: 2000 });
|
|
||||||
} else {
|
|
||||||
setTimeout(loadAnalytics, 1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize analytics after a delay to not block initial render
|
|
||||||
initGoogleAnalytics();
|
|
||||||
|
|
||||||
// Function to show privacy notice
|
|
||||||
function showPrivacyNotice() {
|
|
||||||
const notice = document.createElement('div');
|
|
||||||
notice.style.cssText = `
|
|
||||||
position: fixed;
|
|
||||||
bottom: 16px;
|
|
||||||
right: 16px;
|
|
||||||
background: #222;
|
|
||||||
color: #f5f5f5;
|
|
||||||
padding: 12px 16px 10px 16px;
|
|
||||||
border-radius: 7px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.18);
|
|
||||||
z-index: 1000;
|
|
||||||
max-width: 320px;
|
|
||||||
font-family: Inter, sans-serif;
|
|
||||||
border: 1px solid #333;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
`;
|
|
||||||
notice.innerHTML = `
|
|
||||||
<div style="margin-bottom: 6px; font-weight: 600; color: #fff; font-size: 1rem;">Analytics Notice</div>
|
|
||||||
<div style="margin-bottom: 8px; color: #f5f5f5;">This app uses Google Analytics for anonymous usage stats. No personal data is tracked.</div>
|
|
||||||
<button id="analytics-notice-btn" style="padding: 5px 14px; background: #1890ff; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 0.95rem;">Got it</button>
|
|
||||||
`;
|
|
||||||
document.body.appendChild(notice);
|
|
||||||
// Add event listener to button
|
|
||||||
const btn = notice.querySelector('#analytics-notice-btn');
|
|
||||||
btn.addEventListener('click', function (e) {
|
|
||||||
e.preventDefault();
|
|
||||||
localStorage.setItem('privacyNoticeShown', 'true');
|
|
||||||
notice.remove();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for DOM to be ready
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
|
||||||
// Check if we should show the notice
|
|
||||||
const isProduction =
|
|
||||||
window.location.hostname === 'worklenz.com' ||
|
|
||||||
window.location.hostname === 'app.worklenz.com';
|
|
||||||
const noticeShown = localStorage.getItem('privacyNoticeShown') === 'true';
|
|
||||||
|
|
||||||
// Show notice if not in production and not shown before
|
|
||||||
if (!isProduction && !noticeShown) {
|
|
||||||
showPrivacyNotice();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
</head>
|
<!-- 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);
|
||||||
|
|
||||||
<body>
|
gaScript.onload = function() {
|
||||||
|
window.dataLayer = window.dataLayer || [];
|
||||||
|
function gtag(){dataLayer.push(arguments);}
|
||||||
|
gtag('js', new Date());
|
||||||
|
gtag('config', 'G-7KSRKQ1397');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="./src/index.tsx"></script>
|
<script type="module" src="./src/index.tsx"></script>
|
||||||
<script type="text/javascript">
|
</body>
|
||||||
// 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>
|
||||||
2115
worklenz-frontend/package-lock.json
generated
2115
worklenz-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -3,8 +3,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "vite dev",
|
"start": "vite",
|
||||||
"dev": "vite dev",
|
|
||||||
"prebuild": "node scripts/copy-tinymce.js",
|
"prebuild": "node scripts/copy-tinymce.js",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"dev-build": "vite build",
|
"dev-build": "vite build",
|
||||||
@@ -14,25 +13,22 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/colors": "^7.1.0",
|
"@ant-design/colors": "^7.1.0",
|
||||||
"@ant-design/compatible": "^5.1.4",
|
"@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",
|
"@ant-design/pro-components": "^2.7.19",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/modifiers": "^9.0.0",
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@heroicons/react": "^2.2.0",
|
|
||||||
"@paddle/paddle-js": "^1.3.3",
|
"@paddle/paddle-js": "^1.3.3",
|
||||||
"@reduxjs/toolkit": "^2.2.7",
|
"@reduxjs/toolkit": "^2.2.7",
|
||||||
"@tailwindcss/forms": "^0.5.10",
|
|
||||||
"@tanstack/react-table": "^8.20.6",
|
"@tanstack/react-table": "^8.20.6",
|
||||||
"@tanstack/react-virtual": "^3.11.2",
|
"@tanstack/react-virtual": "^3.11.2",
|
||||||
"@tinymce/tinymce-react": "^5.1.1",
|
"@tinymce/tinymce-react": "^5.1.1",
|
||||||
"antd": "^5.26.2",
|
"antd": "^5.24.9",
|
||||||
"axios": "^1.9.0",
|
"axios": "^1.9.0",
|
||||||
"chart.js": "^4.4.7",
|
"chart.js": "^4.4.7",
|
||||||
"chartjs-plugin-datalabels": "^2.2.0",
|
"chartjs-plugin-datalabels": "^2.2.0",
|
||||||
"cors": "^2.8.5",
|
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"dompurify": "^3.2.5",
|
"dompurify": "^3.2.5",
|
||||||
"gantt-task-react": "^0.3.9",
|
"gantt-task-react": "^0.3.9",
|
||||||
@@ -42,7 +38,6 @@
|
|||||||
"i18next-http-backend": "^2.7.3",
|
"i18next-http-backend": "^2.7.3",
|
||||||
"jspdf": "^3.0.0",
|
"jspdf": "^3.0.0",
|
||||||
"mixpanel-browser": "^2.56.0",
|
"mixpanel-browser": "^2.56.0",
|
||||||
"nanoid": "^5.1.5",
|
|
||||||
"primereact": "^10.8.4",
|
"primereact": "^10.8.4",
|
||||||
"re-resizable": "^6.10.3",
|
"re-resizable": "^6.10.3",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
@@ -54,13 +49,10 @@
|
|||||||
"react-responsive": "^10.0.0",
|
"react-responsive": "^10.0.0",
|
||||||
"react-router-dom": "^6.28.1",
|
"react-router-dom": "^6.28.1",
|
||||||
"react-timer-hook": "^3.0.8",
|
"react-timer-hook": "^3.0.8",
|
||||||
"react-virtuoso": "^4.13.0",
|
|
||||||
"react-window": "^1.8.11",
|
"react-window": "^1.8.11",
|
||||||
"react-window-infinite-loader": "^1.0.10",
|
|
||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.1",
|
||||||
"tinymce": "^7.7.2",
|
"tinymce": "^7.7.2",
|
||||||
"web-vitals": "^4.2.4",
|
"web-vitals": "^4.2.4"
|
||||||
"worklenz": "file:"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
@@ -74,12 +66,10 @@
|
|||||||
"@types/node": "^20.8.4",
|
"@types/node": "^20.8.4",
|
||||||
"@types/react": "19.0.0",
|
"@types/react": "19.0.0",
|
||||||
"@types/react-dom": "19.0.0",
|
"@types/react-dom": "19.0.0",
|
||||||
"@types/react-window": "^1.8.8",
|
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.20",
|
||||||
"postcss": "^8.5.2",
|
"postcss": "^8.5.2",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.13",
|
"prettier-plugin-tailwindcss": "^0.6.8",
|
||||||
"rollup": "^4.40.2",
|
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"terser": "^5.39.0",
|
"terser": "^5.39.0",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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."
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
{
|
|
||||||
"overview": {
|
|
||||||
"title": "Përmbledhje",
|
|
||||||
"statusOverview": "Përmbledhje Statusi",
|
|
||||||
"priorityOverview": "Përmbledhje Prioriteti",
|
|
||||||
"lastUpdatedTasks": "Detyrat e Përditësuara Së Fundi"
|
|
||||||
},
|
|
||||||
"members": {
|
|
||||||
"title": "Anëtarët",
|
|
||||||
"tooltip": "Anëtarët",
|
|
||||||
"tasksByMembers": "Detyrat sipas anëtarëve",
|
|
||||||
"tasksByMembersTooltip": "Detyrat sipas anëtarëve",
|
|
||||||
"name": "Emri",
|
|
||||||
"taskCount": "Numri i Detyrave",
|
|
||||||
"contribution": "Kontributi",
|
|
||||||
"completed": "Të Përfunduara",
|
|
||||||
"incomplete": "Të Papërfunduara",
|
|
||||||
"overdue": "Të Vonuara",
|
|
||||||
"progress": "Progresi"
|
|
||||||
},
|
|
||||||
"tasks": {
|
|
||||||
"overdueTasks": "Detyrat e Vonuara",
|
|
||||||
"overLoggedTasks": "Detyrat me regjistrim të tepërt",
|
|
||||||
"tasksCompletedEarly": "Detyrat e përfunduara para afatit",
|
|
||||||
"tasksCompletedLate": "Detyrat e përfunduara pas afatit",
|
|
||||||
"overLoggedTasksTooltip": "Detyrat me kohë të regjistruar mbi kohën e vlerësuar",
|
|
||||||
"overdueTasksTooltip": "Detyrat që kanë kaluar afatin e tyre"
|
|
||||||
},
|
|
||||||
"common": {
|
|
||||||
"seeAll": "Shiko të gjitha",
|
|
||||||
"totalLoggedHours": "Orët totale të regjistruara",
|
|
||||||
"totalEstimation": "Vlerësimi total",
|
|
||||||
"completedTasks": "Detyrat e përfunduara",
|
|
||||||
"incompleteTasks": "Detyrat e papërfunduara",
|
|
||||||
"overdueTasks": "Detyrat e vonuara",
|
|
||||||
"overdueTasksTooltip": "Detyrat që kanë kaluar afatin e tyre",
|
|
||||||
"totalLoggedHoursTooltip": "Vlerësimi dhe koha e regjistruar për detyrat.",
|
|
||||||
"includeArchivedTasks": "Përfshi Detyrat e Arkivuara",
|
|
||||||
"export": "Eksporto"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"nameColumn": "Emri",
|
|
||||||
"jobTitleColumn": "Titulli i Punës",
|
|
||||||
"emailColumn": "Email",
|
|
||||||
"tasksColumn": "Detyrat",
|
|
||||||
"taskProgressColumn": "Progresi i Detyrave",
|
|
||||||
"accessColumn": "Qasja",
|
|
||||||
"fileIconAlt": "Ikona e skedarit",
|
|
||||||
"deleteConfirmationTitle": "Jeni i sigurt?",
|
|
||||||
"deleteConfirmationOk": "Po",
|
|
||||||
"deleteConfirmationCancel": "Anulo",
|
|
||||||
"refreshButtonTooltip": "Rifresko anëtarët",
|
|
||||||
"deleteButtonTooltip": "Hiq nga projekti",
|
|
||||||
"memberCount": "Anëtar",
|
|
||||||
"membersCountPlural": "Anëtarë",
|
|
||||||
"emptyText": "Nuk ka bashkëngjitje në projekt."
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"inputPlaceholder": "Shto një koment..",
|
|
||||||
"addButton": "Shto",
|
|
||||||
"cancelButton": "Anulo",
|
|
||||||
"deleteButton": "Fshi"
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"taskList": "Lista e Detyrave",
|
|
||||||
"board": "Tabela Kanban",
|
|
||||||
"insights": "Analiza",
|
|
||||||
"files": "Skedarë",
|
|
||||||
"members": "Anëtarë",
|
|
||||||
"updates": "Përditësime",
|
|
||||||
"projectView": "Pamja e Projektit",
|
|
||||||
"loading": "Duke ngarkuar projektin...",
|
|
||||||
"error": "Gabim në ngarkimin e projektit",
|
|
||||||
"pinnedTab": "E fiksuar si tab i parazgjedhur",
|
|
||||||
"pinTab": "Fikso si tab i parazgjedhur",
|
|
||||||
"unpinTab": "Hiqe fiksimin e tab-it të parazgjedhur"
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"importTaskTemplate": "Importo Shabllon Detyrash",
|
|
||||||
"templateName": "Emri i Shabllonit",
|
|
||||||
"templateDescription": "Përshkrimi i Shabllonit",
|
|
||||||
"selectedTasks": "Detyrat e Përzgjedhura",
|
|
||||||
"tasks": "Detyrat",
|
|
||||||
"templates": "Shabllonet",
|
|
||||||
"remove": "Hiq",
|
|
||||||
"cancel": "Anulo",
|
|
||||||
"import": "Importo"
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"title": "Anëtarët e Projektit",
|
|
||||||
"searchLabel": "Shtoni anëtarë duke shkruar emrin ose email-in e tyre",
|
|
||||||
"searchPlaceholder": "Shkruani emrin ose email-in",
|
|
||||||
"inviteAsAMember": "Fto si anëtar",
|
|
||||||
"inviteNewMemberByEmail": "Fto anëtar të ri me email"
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
{
|
|
||||||
"importTasks": "Importo detyra",
|
|
||||||
"importTask": "Importo detyrë",
|
|
||||||
"createTask": "Krijo detyrë",
|
|
||||||
"settings": "Cilësimet",
|
|
||||||
"subscribe": "Abonohu",
|
|
||||||
"unsubscribe": "Çabonohu",
|
|
||||||
"deleteProject": "Fshi projektin",
|
|
||||||
"startDate": "Data e fillimit",
|
|
||||||
"endDate": "Data e mbarimit",
|
|
||||||
"projectSettings": "Cilësimet e projektit",
|
|
||||||
"projectSummary": "Përmbledhja e projektit",
|
|
||||||
"receiveProjectSummary": "Merrni një përmbledhje të projektit çdo mbrëmje.",
|
|
||||||
"refreshProject": "Rifresko projektin",
|
|
||||||
"saveAsTemplate": "Ruaj si model",
|
|
||||||
"invite": "Fto",
|
|
||||||
"subscribeTooltip": "Abonohu tek njoftimet e projektit",
|
|
||||||
"unsubscribeTooltip": "Çabonohu nga njoftimet e projektit",
|
|
||||||
"refreshTooltip": "Rifresko të dhënat e projektit",
|
|
||||||
"settingsTooltip": "Hap cilësimet e projektit",
|
|
||||||
"saveAsTemplateTooltip": "Ruaj këtë projekt si model",
|
|
||||||
"inviteTooltip": "Fto anëtarë të ekipit në këtë projekt",
|
|
||||||
"createTaskTooltip": "Krijo një detyrë të re",
|
|
||||||
"importTaskTooltip": "Importo detyrë nga modeli",
|
|
||||||
"navigateBackTooltip": "Kthehu tek lista e projekteve",
|
|
||||||
"projectStatusTooltip": "Statusi i projektit",
|
|
||||||
"projectDatesInfo": "Informacion për kohëzgjatjen e projektit",
|
|
||||||
"projectCategoryTooltip": "Kategoria e projektit"
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
{
|
|
||||||
"title": "Ruaj si Shabllon",
|
|
||||||
"templateName": "Emri i Shabllonit",
|
|
||||||
"includes": "Çfarë duhet të përfshihet në shabllon nga projekti?",
|
|
||||||
"includesOptions": {
|
|
||||||
"statuses": "Statuset",
|
|
||||||
"phases": "Fazat",
|
|
||||||
"labels": "Etiketat"
|
|
||||||
},
|
|
||||||
"taskIncludes": "Çfarë duhet të përfshihet në shabllon nga detyrat?",
|
|
||||||
"taskIncludesOptions": {
|
|
||||||
"statuses": "Statuset",
|
|
||||||
"phases": "Fazat",
|
|
||||||
"labels": "Etiketat",
|
|
||||||
"name": "Emri",
|
|
||||||
"priority": "Prioriteti",
|
|
||||||
"status": "Statusi",
|
|
||||||
"phase": "Faza",
|
|
||||||
"label": "Etiketa",
|
|
||||||
"timeEstimate": "Vlerësimi i Kohës",
|
|
||||||
"description": "Përshkrimi",
|
|
||||||
"subTasks": "Nëndetyrat"
|
|
||||||
},
|
|
||||||
"cancel": "Anulo",
|
|
||||||
"save": "Ruaj",
|
|
||||||
"templateNamePlaceholder": "Shkruani emrin e shabllonit"
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
{
|
|
||||||
"exportButton": "Eksporto",
|
|
||||||
"timeLogsButton": "Regjistrimet e Kohës",
|
|
||||||
"activityLogsButton": "Regjistrimet e Aktivitetit",
|
|
||||||
"tasksButton": "Detyrat",
|
|
||||||
"searchByNameInputPlaceholder": "Kërko sipas emrit",
|
|
||||||
|
|
||||||
"overviewTab": "Përmbledhje",
|
|
||||||
"timeLogsTab": "Regjistrimet e Kohës",
|
|
||||||
"activityLogsTab": "Regjistrimet e Aktivitetit",
|
|
||||||
"tasksTab": "Detyrat",
|
|
||||||
|
|
||||||
"projectsText": "Projektet",
|
|
||||||
"totalTasksText": "Detyrat Gjithsej",
|
|
||||||
"assignedTasksText": "Detyrat e Caktuara",
|
|
||||||
"completedTasksText": "Detyrat e Përfunduara",
|
|
||||||
"ongoingTasksText": "Detyrat në Vazhdim",
|
|
||||||
"overdueTasksText": "Detyrat e Vonuara",
|
|
||||||
"loggedHoursText": "Orët e Regjistruara",
|
|
||||||
|
|
||||||
"tasksText": "Detyrat",
|
|
||||||
"allText": "Të Gjitha",
|
|
||||||
|
|
||||||
"tasksByProjectsText": "Detyrat Sipas Projekteve",
|
|
||||||
"tasksByStatusText": "Detyrat Sipas Statusit",
|
|
||||||
"tasksByPriorityText": "Detyrat Sipas Prioritetit",
|
|
||||||
|
|
||||||
"todoText": "Për Të Bërë",
|
|
||||||
"doingText": "Duke bërë",
|
|
||||||
"doneText": "E Përfunduar",
|
|
||||||
"lowText": "I Ulët",
|
|
||||||
"mediumText": "I Mesëm",
|
|
||||||
"highText": "I Lartë",
|
|
||||||
|
|
||||||
"billableButton": "Fakturueshme",
|
|
||||||
"billableText": "Fakturueshme",
|
|
||||||
"nonBillableText": "Jo Fakturueshme",
|
|
||||||
|
|
||||||
"timeLogsEmptyPlaceholder": "Asnjë regjistrim kohe për të shfaqur",
|
|
||||||
"loggedText": "Regjistruar",
|
|
||||||
"forText": "për",
|
|
||||||
"inText": "në",
|
|
||||||
"updatedText": "Përditësuar",
|
|
||||||
"fromText": "Nga",
|
|
||||||
"toText": "në",
|
|
||||||
"withinText": "brenda",
|
|
||||||
|
|
||||||
"activityLogsEmptyPlaceholder": "Asnjë regjistrim aktiviteti për të shfaqur",
|
|
||||||
|
|
||||||
"filterByText": "Filtro sipas:",
|
|
||||||
"selectProjectPlaceholder": "Zgjidh Projektin",
|
|
||||||
|
|
||||||
"taskColumn": "Detyra",
|
|
||||||
"nameColumn": "Emri",
|
|
||||||
"projectColumn": "Projekti",
|
|
||||||
"statusColumn": "Statusi",
|
|
||||||
"priorityColumn": "Prioriteti",
|
|
||||||
"dueDateColumn": "Afati",
|
|
||||||
"completedDateColumn": "Data e Përfundimit",
|
|
||||||
"estimatedTimeColumn": "Koha e Vlerësuar",
|
|
||||||
"loggedTimeColumn": "Koha e Regjistruar",
|
|
||||||
"overloggedTimeColumn": "Koha e Tepërt",
|
|
||||||
"daysLeftColumn": "Ditë të Mbetura/Vonuar",
|
|
||||||
"startDateColumn": "Data e Fillimit",
|
|
||||||
"endDateColumn": "Data e Përfundimit",
|
|
||||||
"actualTimeColumn": "Koha Aktuale",
|
|
||||||
"projectHealthColumn": "Gjendja e Projektit",
|
|
||||||
"categoryColumn": "Kategoria",
|
|
||||||
"projectManagerColumn": "Menaxheri i Projektit",
|
|
||||||
|
|
||||||
"tasksStatsOverviewDrawerTitle": "Detyrat e ",
|
|
||||||
"projectsStatsOverviewDrawerTitle": "Projektet e ",
|
|
||||||
|
|
||||||
"cancelledText": "Anuluar",
|
|
||||||
"blockedText": "E Bllokuar",
|
|
||||||
"onHoldText": "Në Pritje",
|
|
||||||
"proposedText": "E Propozuar",
|
|
||||||
"inPlanningText": "Në Planifikim",
|
|
||||||
"inProgressText": "Në Progres",
|
|
||||||
"completedText": "E Përfunduar",
|
|
||||||
"continuousText": "E Vazhdueshme",
|
|
||||||
|
|
||||||
"daysLeftText": "ditë të mbetura",
|
|
||||||
"daysOverdueText": "ditë vonuar",
|
|
||||||
|
|
||||||
"notSetText": "Pa Caktuar",
|
|
||||||
"needsAttentionText": "Kërkon Vëmendje",
|
|
||||||
"atRiskText": "Në Rrezik",
|
|
||||||
"goodText": "Në Rregull"
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
{
|
|
||||||
"yesterdayText": "Dje",
|
|
||||||
"lastSevenDaysText": "7 Ditët e Fundit",
|
|
||||||
"lastWeekText": "Javën e Kaluar",
|
|
||||||
"lastThirtyDaysText": "30 Ditët e Fundit",
|
|
||||||
"lastMonthText": "Muajin e Kaluar",
|
|
||||||
"lastThreeMonthsText": "3 Muajt e Fundit",
|
|
||||||
"allTimeText": "Të Gjitha",
|
|
||||||
"customRangeText": "Interval i Përshtatur",
|
|
||||||
"startDateInputPlaceholder": "Data e fillimit",
|
|
||||||
"EndDateInputPlaceholder": "Data e përfundimit",
|
|
||||||
"filterButton": "Filtro",
|
|
||||||
|
|
||||||
"membersTitle": "Anëtarët",
|
|
||||||
"includeArchivedButton": "Përfshij Projektet e Arkivuara",
|
|
||||||
"exportButton": "Eksporto",
|
|
||||||
"excelButton": "Excel",
|
|
||||||
"searchByNameInputPlaceholder": "Kërko sipas emrit",
|
|
||||||
|
|
||||||
"memberColumn": "Anëtari",
|
|
||||||
"tasksProgressColumn": "Progresi i Detyrave",
|
|
||||||
"tasksAssignedColumn": "Detyrat e Caktuara",
|
|
||||||
"completedTasksColumn": "Detyrat e Përfunduara",
|
|
||||||
"overdueTasksColumn": "Detyrat e Vonuara",
|
|
||||||
"ongoingTasksColumn": "Detyrat në Vazhdim",
|
|
||||||
|
|
||||||
"tasksAssignedColumnTooltip": "Detyrat e caktuara në intervalin e zgjedhur",
|
|
||||||
"overdueTasksColumnTooltip": "Detyrat e vonuara deri në fund të intervalit të zgjedhur",
|
|
||||||
"completedTasksColumnTooltip": "Detyrat e përfunduara në intervalin e zgjedhur",
|
|
||||||
"ongoingTasksColumnTooltip": "Detyrat e filluara por jo të përfunduara ende",
|
|
||||||
|
|
||||||
"todoText": "Për Të Bërë",
|
|
||||||
"doingText": "Duke bërë",
|
|
||||||
"doneText": "E Përfunduar"
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
{
|
|
||||||
"exportButton": "Eksporto",
|
|
||||||
"projectsButton": "Projektet",
|
|
||||||
"membersButton": "Anëtarët",
|
|
||||||
"searchByNameInputPlaceholder": "Kërko sipas emrit",
|
|
||||||
|
|
||||||
"overviewTab": "Përmbledhje",
|
|
||||||
"projectsTab": "Projektet",
|
|
||||||
"membersTab": "Anëtarët",
|
|
||||||
|
|
||||||
"projectsByStatusText": "Projektet Sipas Statusit",
|
|
||||||
"projectsByCategoryText": "Projektet Sipas Kategorisë",
|
|
||||||
"projectsByHealthText": "Projektet Sipas Gjendjes",
|
|
||||||
|
|
||||||
"projectsText": "Projektet",
|
|
||||||
"allText": "Të Gjitha",
|
|
||||||
|
|
||||||
"cancelledText": "Anuluar",
|
|
||||||
"blockedText": "E Bllokuar",
|
|
||||||
"onHoldText": "Në Pritje",
|
|
||||||
"proposedText": "E Propozuar",
|
|
||||||
"inPlanningText": "Në Planifikim",
|
|
||||||
"inProgressText": "Në Progres",
|
|
||||||
"completedText": "E Përfunduar",
|
|
||||||
"continuousText": "E Vazhdueshme",
|
|
||||||
|
|
||||||
"notSetText": "Pa Caktuar",
|
|
||||||
"needsAttentionText": "Kërkon Vëmendje",
|
|
||||||
"atRiskText": "Në Rrezik",
|
|
||||||
"goodText": "Në Rregull",
|
|
||||||
|
|
||||||
"nameColumn": "Emri",
|
|
||||||
"emailColumn": "Email",
|
|
||||||
"projectsColumn": "Projektet",
|
|
||||||
"tasksColumn": "Detyrat",
|
|
||||||
"overdueTasksColumn": "Detyrat e Vonuara",
|
|
||||||
"completedTasksColumn": "Detyrat e Përfunduara",
|
|
||||||
"ongoingTasksColumn": "Detyrat në Vazhdim"
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
{
|
|
||||||
"overviewTitle": "Përmbledhje",
|
|
||||||
"includeArchivedButton": "Përfshij Projektet e Arkivuara",
|
|
||||||
|
|
||||||
"teamCount": "Ekip",
|
|
||||||
"teamCountPlural": "Ekipe",
|
|
||||||
"projectCount": "Projekt",
|
|
||||||
"projectCountPlural": "Projekte",
|
|
||||||
"memberCount": "Anëtar",
|
|
||||||
"memberCountPlural": "Anëtarë",
|
|
||||||
"activeProjectCount": "Projekt Aktiv",
|
|
||||||
"activeProjectCountPlural": "Projekte Aktive",
|
|
||||||
"overdueProjectCount": "Projekt i Vonuar",
|
|
||||||
"overdueProjectCountPlural": "Projekte të Vonuara",
|
|
||||||
"unassignedMemberCount": "Anëtar i Pacaktuar",
|
|
||||||
"unassignedMemberCountPlural": "Anëtarë të Pacaktuar",
|
|
||||||
"memberWithOverdueTaskCount": "Anëtar me Detyrë të Vonuar",
|
|
||||||
"memberWithOverdueTaskCountPlural": "Anëtarë me Detyra të Vonuara",
|
|
||||||
|
|
||||||
"teamsText": "Ekipet",
|
|
||||||
|
|
||||||
"nameColumn": "Emri",
|
|
||||||
"projectsColumn": "Projektet",
|
|
||||||
"membersColumn": "Anëtarët"
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
{
|
|
||||||
"exportButton": "Eksporto",
|
|
||||||
"membersButton": "Anëtarët",
|
|
||||||
"tasksButton": "Detyrat",
|
|
||||||
"searchByNameInputPlaceholder": "Kërko sipas emrit",
|
|
||||||
|
|
||||||
"overviewTab": "Përmbledhje",
|
|
||||||
"membersTab": "Anëtarët",
|
|
||||||
"tasksTab": "Detyrat",
|
|
||||||
|
|
||||||
"completedTasksText": "Detyrat e Përfunduara",
|
|
||||||
"incompleteTasksText": "Detyrat e Papërfunduara",
|
|
||||||
"overdueTasksText": "Detyrat e Vonuara",
|
|
||||||
"allocatedHoursText": "Orët e Alokuara",
|
|
||||||
"loggedHoursText": "Orët e Regjistruara",
|
|
||||||
|
|
||||||
"tasksText": "Detyrat",
|
|
||||||
"allText": "Të Gjitha",
|
|
||||||
|
|
||||||
"tasksByStatusText": "Detyrat Sipas Statusit",
|
|
||||||
"tasksByPriorityText": "Detyrat Sipas Prioritetit",
|
|
||||||
"tasksByDueDateText": "Detyrat Sipas Afatit",
|
|
||||||
|
|
||||||
"todoText": "Për Të Bërë",
|
|
||||||
"doingText": "Duke bërë",
|
|
||||||
"doneText": "E Përfunduar",
|
|
||||||
"lowText": "I Ulët",
|
|
||||||
"mediumText": "I Mesëm",
|
|
||||||
"highText": "I Lartë",
|
|
||||||
"completedText": "E Përfunduar",
|
|
||||||
"upcomingText": "Në Ardhje",
|
|
||||||
"overdueText": "E Vonuar",
|
|
||||||
"noDueDateText": "Pa Afat",
|
|
||||||
|
|
||||||
"nameColumn": "Emri",
|
|
||||||
"tasksCountColumn": "Numri i Detyrave",
|
|
||||||
"completedTasksColumn": "Detyrat e Përfunduara",
|
|
||||||
"incompleteTasksColumn": "Detyrat e Papërfunduara",
|
|
||||||
"overdueTasksColumn": "Detyrat e Vonuara",
|
|
||||||
"contributionColumn": "Kontributi",
|
|
||||||
"progressColumn": "Progresi",
|
|
||||||
"loggedTimeColumn": "Koha e Regjistruar",
|
|
||||||
"taskColumn": "Detyra",
|
|
||||||
"projectColumn": "Projekti",
|
|
||||||
"statusColumn": "Statusi",
|
|
||||||
"priorityColumn": "Prioriteti",
|
|
||||||
"phaseColumn": "Faza",
|
|
||||||
"dueDateColumn": "Afati",
|
|
||||||
"completedDateColumn": "Data e Përfundimit",
|
|
||||||
"estimatedTimeColumn": "Koha e Vlerësuar",
|
|
||||||
"overloggedTimeColumn": "Koha e Tepërt",
|
|
||||||
"completedOnColumn": "Përfunduar Më",
|
|
||||||
"daysOverdueColumn": "Ditë vonim",
|
|
||||||
|
|
||||||
"groupByText": "Grupo Sipas:",
|
|
||||||
"statusText": "Statusi",
|
|
||||||
"priorityText": "Prioriteti",
|
|
||||||
"phaseText": "Faza"
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
{
|
|
||||||
"searchByNamePlaceholder": "Kërko sipas emrit",
|
|
||||||
"searchByCategoryPlaceholder": "Kërko sipas kategorisë",
|
|
||||||
|
|
||||||
"statusText": "Statusi",
|
|
||||||
"healthText": "Gjendja",
|
|
||||||
"categoryText": "Kategoria",
|
|
||||||
"projectManagerText": "Menaxheri i Projektit",
|
|
||||||
"showFieldsText": "Shfaq fushat",
|
|
||||||
|
|
||||||
"cancelledText": "Anuluar",
|
|
||||||
"blockedText": "E bllokuar",
|
|
||||||
"onHoldText": "Në pritje",
|
|
||||||
"proposedText": "E propozuar",
|
|
||||||
"inPlanningText": "Në planifikim",
|
|
||||||
"inProgressText": "Në progres",
|
|
||||||
"completedText": "E përfunduar",
|
|
||||||
"continuousText": "E vazhdueshme",
|
|
||||||
|
|
||||||
"notSetText": "Pa caktuar",
|
|
||||||
"needsAttentionText": "Kërkon vëmendje",
|
|
||||||
"atRiskText": "Në rrezik",
|
|
||||||
"goodText": "Në rregull",
|
|
||||||
|
|
||||||
"nameText": "Projekti",
|
|
||||||
"estimatedVsActualText": "Vlerësuar vs Aktual",
|
|
||||||
"tasksProgressText": "Progresi i detyrave",
|
|
||||||
"lastActivityText": "Aktiviteti i fundit",
|
|
||||||
"datesText": "Datat e Fillimit/Përfundimit",
|
|
||||||
"daysLeftText": "Ditë të mbetura/vonuar",
|
|
||||||
"projectHealthText": "Gjendja e projektit",
|
|
||||||
"projectUpdateText": "Përditësimi i projektit",
|
|
||||||
"clientText": "Klienti",
|
|
||||||
"teamText": "Ekipi"
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
{
|
|
||||||
"projectCount": "Projekt",
|
|
||||||
"projectCountPlural": "Projekte",
|
|
||||||
"includeArchivedButton": "Përfshij Projektet e Arkivuara",
|
|
||||||
"exportButton": "Eksporto",
|
|
||||||
"excelButton": "Excel",
|
|
||||||
|
|
||||||
"projectColumn": "Projekti",
|
|
||||||
"estimatedVsActualColumn": "Vlerësuar vs Aktual",
|
|
||||||
"tasksProgressColumn": "Progresi i Detyrave",
|
|
||||||
"lastActivityColumn": "Aktiviteti i Fundit",
|
|
||||||
"statusColumn": "Statusi",
|
|
||||||
"datesColumn": "Data e Fillimit/Përfundimit",
|
|
||||||
"daysLeftColumn": "Ditë të Mbetura/Vonuar",
|
|
||||||
"projectHealthColumn": "Gjendja e Projektit",
|
|
||||||
"categoryColumn": "Kategoria",
|
|
||||||
"projectUpdateColumn": "Përditësimi i Projektit",
|
|
||||||
"clientColumn": "Klienti",
|
|
||||||
"teamColumn": "Ekipi",
|
|
||||||
"projectManagerColumn": "Menaxheri i Projektit",
|
|
||||||
|
|
||||||
"openButton": "Hap",
|
|
||||||
|
|
||||||
"estimatedText": "Vlerësuar",
|
|
||||||
"actualText": "Aktual",
|
|
||||||
|
|
||||||
"todoText": "Për të Bërë",
|
|
||||||
"doingText": "duke bërë",
|
|
||||||
"doneText": "E Përfunduar",
|
|
||||||
|
|
||||||
"cancelledText": "Anuluar",
|
|
||||||
"blockedText": "E Bllokuar",
|
|
||||||
"onHoldText": "Në Pritje",
|
|
||||||
"proposedText": "E Propozuar",
|
|
||||||
"inPlanningText": "Në Planifikim",
|
|
||||||
"inProgressText": "Në Progres",
|
|
||||||
"completedText": "E Përfunduar",
|
|
||||||
"continuousText": "E Vazhdueshme",
|
|
||||||
|
|
||||||
"daysLeftText": "ditë të mbetura",
|
|
||||||
"dayLeftText": "ditë e mbetur",
|
|
||||||
"daysOverdueText": "ditë vonuar",
|
|
||||||
|
|
||||||
"notSetText": "Pa Caktuar",
|
|
||||||
"needsAttentionText": "Kërkon Vëmendje",
|
|
||||||
"atRiskText": "Në Rrezik",
|
|
||||||
"goodText": "Në Rregull",
|
|
||||||
|
|
||||||
"setCategoryText": "Cakto Kategorinë",
|
|
||||||
"searchByNameInputPlaceholder": "Kërko sipas emrit",
|
|
||||||
"todayText": "Sot"
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"overview": "Përmbledhje",
|
|
||||||
"projects": "Projektet",
|
|
||||||
"members": "Anëtarët",
|
|
||||||
"timeReports": "Raportet e Kohës",
|
|
||||||
"estimateVsActual": "Vlerësimi vs Aktual",
|
|
||||||
"currentOrganizationTooltip": "Organizata aktuale"
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user