Compare commits
144 Commits
feature/me
...
feature/sh
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccb8e68192 | ||
|
|
4783b5ec10 | ||
|
|
a08d1efc36 | ||
|
|
bedf85d409 | ||
|
|
c84034b436 | ||
|
|
025b2005c7 | ||
|
|
c5bac36c53 | ||
|
|
06488d80ff | ||
|
|
e0a290c18f | ||
|
|
e3e1b2dc14 | ||
|
|
4a3f4ccc08 | ||
|
|
0e2c37aef2 | ||
|
|
6e188899ed | ||
|
|
509fcc8f64 | ||
|
|
49196aac2e | ||
|
|
413d5df95c | ||
|
|
c031a49a29 | ||
|
|
3129d7a48d | ||
|
|
791cbe22df | ||
|
|
ba2ecb2d85 | ||
|
|
59880bfd59 | ||
|
|
9e66a1ce8c | ||
|
|
7d735dacc4 | ||
|
|
6002ab7c50 | ||
|
|
bd77733935 | ||
|
|
915980dcdf | ||
|
|
dcdb651dd1 | ||
|
|
d6686d64be | ||
|
|
1ec9759434 | ||
|
|
13baf36e3c | ||
|
|
e82bb23cd5 | ||
|
|
66b7dc5322 | ||
|
|
0136f6d3cb | ||
|
|
3bfb886de7 | ||
|
|
5ec7a2741c | ||
|
|
e8bf84ef3a | ||
|
|
593e6cfa98 | ||
|
|
e59216af54 | ||
|
|
0f5946134c | ||
|
|
4f082e982b | ||
|
|
71638ce52a | ||
|
|
45d9049d27 | ||
|
|
3f7b969e44 | ||
|
|
b6be411162 | ||
|
|
dc6a62a66a | ||
|
|
cfa0af24ae | ||
|
|
69f5009579 | ||
|
|
24fa837a39 | ||
|
|
5e4d78c6f5 | ||
|
|
035617c8e8 | ||
|
|
6a4bf4d672 | ||
|
|
aeed75ca31 | ||
|
|
4e43780769 | ||
|
|
fef50bdfb1 | ||
|
|
43c6701d3a | ||
|
|
8cdc8b3ad0 | ||
|
|
b6e4ed9883 | ||
|
|
837692e808 | ||
|
|
6ffdbc64d0 | ||
|
|
65af5f659e | ||
|
|
f38a7b4d56 | ||
|
|
b0fb0a2759 | ||
|
|
4bc1b4fa73 | ||
|
|
8d6c43c59c | ||
|
|
1f6bbce0ae | ||
|
|
d1fe23b431 | ||
|
|
935165d751 | ||
|
|
2f0fb92e3e | ||
|
|
a3d5e63635 | ||
|
|
6a2e9afff8 | ||
|
|
a0f36968b3 | ||
|
|
b5288a8da2 | ||
|
|
b94c56f50d | ||
|
|
f1920c17b4 | ||
|
|
7b1c048dbb | ||
|
|
9b48cc7e06 | ||
|
|
549728cdaf | ||
|
|
b8cc9b5b73 | ||
|
|
a87ea46b97 | ||
|
|
5454c22bd1 | ||
|
|
ad9e940987 | ||
|
|
cae5524168 | ||
|
|
07bc5e6030 | ||
|
|
5cb6548889 | ||
|
|
bc652f83af | ||
|
|
010cbe1af8 | ||
|
|
80797e043c | ||
|
|
312c6b5be8 | ||
|
|
ca0c958918 | ||
|
|
7bb93d2aef | ||
|
|
42c4802d19 | ||
|
|
cf0eaad077 | ||
|
|
f22a91b690 | ||
|
|
c33a152015 | ||
|
|
dcb4ff1eb0 | ||
|
|
612de866b7 | ||
|
|
c55e593535 | ||
|
|
da98fe26ab | ||
|
|
b0ed3f67e8 | ||
|
|
85280c33d2 | ||
|
|
f68c72a92a | ||
|
|
1969fbd1dc | ||
|
|
e567d6b345 | ||
|
|
399d8b420a | ||
|
|
21a4131faa | ||
|
|
659ede7fb5 | ||
|
|
22d0fc7049 | ||
|
|
b320a7b260 | ||
|
|
1a5f6d54ed | ||
|
|
e245530a15 | ||
|
|
87bd1b8801 | ||
|
|
a711d48c9c | ||
|
|
096163d9c0 | ||
|
|
c18889a127 | ||
|
|
a879176c24 | ||
|
|
49fc89ae3a | ||
|
|
d7a5f08058 | ||
|
|
533b59504f | ||
|
|
c1e923c703 | ||
|
|
b104cf2d3f | ||
|
|
3ce81272b2 | ||
|
|
f716971654 | ||
|
|
d7ca1d8bd2 | ||
|
|
c3bec74897 | ||
|
|
8704b6a8c8 | ||
|
|
4687478704 | ||
|
|
db1108a48d | ||
|
|
2bdae400ac | ||
|
|
0cb0efe43e | ||
|
|
4386aabeda | ||
|
|
69e7938365 | ||
|
|
f6eaddefa4 | ||
|
|
84c7428fed | ||
|
|
ded0ad693c | ||
|
|
cc8dca7b75 | ||
|
|
7d81b7784b | ||
|
|
afd4cbdf81 | ||
|
|
3dd56f094c | ||
|
|
26b0b5780a | ||
|
|
fbfeaceb9c | ||
|
|
a568ee808f | ||
|
|
69b910f2a4 | ||
|
|
2b3b0ba635 | ||
|
|
6847eec603 |
195
docs/api/task-breakdown-api.md
Normal file
195
docs/api/task-breakdown-api.md
Normal file
@@ -0,0 +1,195 @@
|
||||
# Task Breakdown API
|
||||
|
||||
## Get Task Financial Breakdown
|
||||
|
||||
**Endpoint:** `GET /api/project-finance/task/:id/breakdown`
|
||||
|
||||
**Description:** Retrieves detailed financial breakdown for a single task, including members grouped by job roles with labor hours and costs.
|
||||
|
||||
### Parameters
|
||||
|
||||
- `id` (path parameter): UUID of the task
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"body": {
|
||||
"task": {
|
||||
"id": "uuid",
|
||||
"name": "Task Name",
|
||||
"project_id": "uuid",
|
||||
"billable": true,
|
||||
"estimated_hours": 10.5,
|
||||
"logged_hours": 8.25,
|
||||
"estimated_labor_cost": 525.0,
|
||||
"actual_labor_cost": 412.5,
|
||||
"fixed_cost": 100.0,
|
||||
"total_estimated_cost": 625.0,
|
||||
"total_actual_cost": 512.5
|
||||
},
|
||||
"grouped_members": [
|
||||
{
|
||||
"jobRole": "Frontend Developer",
|
||||
"estimated_hours": 5.25,
|
||||
"logged_hours": 4.0,
|
||||
"estimated_cost": 262.5,
|
||||
"actual_cost": 200.0,
|
||||
"members": [
|
||||
{
|
||||
"team_member_id": "uuid",
|
||||
"name": "John Doe",
|
||||
"avatar_url": "https://...",
|
||||
"hourly_rate": 50.0,
|
||||
"estimated_hours": 5.25,
|
||||
"logged_hours": 4.0,
|
||||
"estimated_cost": 262.5,
|
||||
"actual_cost": 200.0
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"jobRole": "Backend Developer",
|
||||
"estimated_hours": 5.25,
|
||||
"logged_hours": 4.25,
|
||||
"estimated_cost": 262.5,
|
||||
"actual_cost": 212.5,
|
||||
"members": [
|
||||
{
|
||||
"team_member_id": "uuid",
|
||||
"name": "Jane Smith",
|
||||
"avatar_url": "https://...",
|
||||
"hourly_rate": 50.0,
|
||||
"estimated_hours": 5.25,
|
||||
"logged_hours": 4.25,
|
||||
"estimated_cost": 262.5,
|
||||
"actual_cost": 212.5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"members": [
|
||||
{
|
||||
"team_member_id": "uuid",
|
||||
"name": "John Doe",
|
||||
"avatar_url": "https://...",
|
||||
"hourly_rate": 50.0,
|
||||
"job_title_name": "Frontend Developer",
|
||||
"estimated_hours": 5.25,
|
||||
"logged_hours": 4.0,
|
||||
"estimated_cost": 262.5,
|
||||
"actual_cost": 200.0
|
||||
},
|
||||
{
|
||||
"team_member_id": "uuid",
|
||||
"name": "Jane Smith",
|
||||
"avatar_url": "https://...",
|
||||
"hourly_rate": 50.0,
|
||||
"job_title_name": "Backend Developer",
|
||||
"estimated_hours": 5.25,
|
||||
"logged_hours": 4.25,
|
||||
"estimated_cost": 262.5,
|
||||
"actual_cost": 212.5
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
|
||||
- `404 Not Found`: Task not found
|
||||
- `400 Bad Request`: Invalid task ID
|
||||
|
||||
### Usage
|
||||
|
||||
This endpoint is designed to work with the finance drawer component (`@finance-drawer.tsx`) to provide detailed cost breakdown information for individual tasks. The response includes:
|
||||
|
||||
1. **Task Summary**: Overall task financial information
|
||||
2. **Grouped Members**: Members organized by job role with aggregated costs
|
||||
3. **Individual Members**: Detailed breakdown for each team member
|
||||
|
||||
The data structure matches what the finance drawer expects, with members grouped by job roles and individual labor hours and costs calculated based on:
|
||||
- Estimated hours divided equally among assignees
|
||||
- Actual logged time per member
|
||||
- Hourly rates from project rate cards
|
||||
- Fixed costs added to the totals
|
||||
|
||||
### Frontend Usage Example
|
||||
|
||||
```typescript
|
||||
import { projectFinanceApiService } from '@/api/project-finance-ratecard/project-finance.api.service';
|
||||
|
||||
// Fetch task breakdown
|
||||
const fetchTaskBreakdown = async (taskId: string) => {
|
||||
try {
|
||||
const response = await projectFinanceApiService.getTaskBreakdown(taskId);
|
||||
const breakdown = response.body;
|
||||
|
||||
console.log('Task:', breakdown.task);
|
||||
console.log('Grouped Members:', breakdown.grouped_members);
|
||||
console.log('Individual Members:', breakdown.members);
|
||||
|
||||
return breakdown;
|
||||
} catch (error) {
|
||||
console.error('Error fetching task breakdown:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Usage in React component
|
||||
const TaskBreakdownComponent = ({ taskId }: { taskId: string }) => {
|
||||
const [breakdown, setBreakdown] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadBreakdown = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await fetchTaskBreakdown(taskId);
|
||||
setBreakdown(data);
|
||||
} catch (error) {
|
||||
// Handle error
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (taskId) {
|
||||
loadBreakdown();
|
||||
}
|
||||
}, [taskId]);
|
||||
|
||||
if (loading) return <Spin />;
|
||||
if (!breakdown) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3>{breakdown.task.name}</h3>
|
||||
<p>Total Estimated Cost: ${breakdown.task.total_estimated_cost}</p>
|
||||
<p>Total Actual Cost: ${breakdown.task.total_actual_cost}</p>
|
||||
|
||||
{breakdown.grouped_members.map(group => (
|
||||
<div key={group.jobRole}>
|
||||
<h4>{group.jobRole}</h4>
|
||||
<p>Hours: {group.estimated_hours} | Cost: ${group.estimated_cost}</p>
|
||||
{group.members.map(member => (
|
||||
<div key={member.team_member_id}>
|
||||
{member.name}: {member.estimated_hours}h @ ${member.hourly_rate}/h
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Integration
|
||||
|
||||
This API complements the existing finance endpoints:
|
||||
- `GET /api/project-finance/project/:project_id/tasks` - Get all tasks for a project
|
||||
- `PUT /api/project-finance/task/:task_id/fixed-cost` - Update task fixed cost
|
||||
|
||||
The finance drawer component has been updated to automatically use this API when a task is selected, providing real-time financial breakdown data.
|
||||
@@ -16,24 +16,45 @@ 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.
|
||||
|
||||
## Schedule Options
|
||||
You can choose how often your task repeats. Here are the most common options:
|
||||
You can choose how often your task repeats. Here are the available options:
|
||||
|
||||
- **Daily:** The task is created every day.
|
||||
- **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 can pick the date (e.g., the 1st of every month).
|
||||
- **Weekdays:** The task is created every Monday to Friday.
|
||||
- **Custom:** Set your own schedule, such as every 2 days, every 3 weeks, or only on certain days.
|
||||
- **Weekly:** The task is created once a week. You can pick one or more days (e.g., every Monday and Thursday).
|
||||
- **Monthly:** The task is created once a month. You have two options:
|
||||
- **On a specific date:** Choose a date from 1 to 28 (limited to 28 to ensure consistency across all months)
|
||||
- **On a specific day:** Choose a week (first, second, third, fourth, or last) and a day of the week
|
||||
- **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
|
||||
- "Send team update" every Friday (weekly)
|
||||
- "Submit expense report" on the 1st of each month (monthly)
|
||||
- "Submit expense report" on the 15th of each month (monthly, specific date)
|
||||
- "Monthly team meeting" on the first Monday of each month (monthly, specific day)
|
||||
- "Check backups" every day (daily)
|
||||
- "Review project status" every Monday and Thursday (custom)
|
||||
- "Review project status" every Monday and Thursday (weekly, multiple days)
|
||||
- "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
|
||||
- You can edit or stop a recurring task at any time.
|
||||
- Assign team members and labels to recurring tasks for better organization.
|
||||
- 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?
|
||||
If you have questions or need help setting up recurring tasks, contact your workspace admin or support team.
|
||||
@@ -17,6 +17,51 @@ The recurring tasks cron job automates the creation of tasks based on predefined
|
||||
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.
|
||||
|
||||
## 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
|
||||
- **Templates and Schedules:**
|
||||
- Templates are stored in `task_recurring_templates`.
|
||||
@@ -27,6 +72,7 @@ The recurring tasks cron job automates the creation of tasks based on predefined
|
||||
- Assigns team members and labels by calling appropriate functions/controllers.
|
||||
- **State Tracking:**
|
||||
- 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
|
||||
1. **Fetch Templates:** Retrieve all templates and their associated schedules.
|
||||
@@ -41,10 +87,12 @@ The recurring tasks cron job automates the creation of tasks based on predefined
|
||||
- **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 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
|
||||
- Errors are logged using the `log_error` utility.
|
||||
- The job continues processing other templates even if one fails.
|
||||
- Failed task creations are not retried automatically.
|
||||
|
||||
## References
|
||||
- Source: `src/cron_jobs/recurring-tasks.ts`
|
||||
|
||||
@@ -78,4 +78,8 @@ GOOGLE_CAPTCHA_SECRET_KEY=your_captcha_secret_key
|
||||
GOOGLE_CAPTCHA_PASS_SCORE=0.8
|
||||
|
||||
# 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,131 +0,0 @@
|
||||
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", []);
|
||||
};
|
||||
@@ -0,0 +1,228 @@
|
||||
-- Migration: Add recursive task estimation functionality
|
||||
-- This migration adds a function to calculate recursive task estimation including all subtasks
|
||||
-- and modifies the get_task_form_view_model function to include this data
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Function to calculate recursive task estimation (including all subtasks)
|
||||
CREATE OR REPLACE FUNCTION get_task_recursive_estimation(_task_id UUID) RETURNS JSON
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_result JSON;
|
||||
_has_subtasks BOOLEAN;
|
||||
BEGIN
|
||||
-- First check if this task has any subtasks
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM tasks
|
||||
WHERE parent_task_id = _task_id
|
||||
AND archived = false
|
||||
) INTO _has_subtasks;
|
||||
|
||||
-- If task has subtasks, calculate recursive estimation excluding parent's own estimation
|
||||
IF _has_subtasks THEN
|
||||
WITH RECURSIVE task_tree AS (
|
||||
-- Start with direct subtasks only (exclude the parent task itself)
|
||||
SELECT
|
||||
id,
|
||||
parent_task_id,
|
||||
COALESCE(total_minutes, 0) as total_minutes,
|
||||
1 as level -- Start at level 1 (subtasks)
|
||||
FROM tasks
|
||||
WHERE parent_task_id = _task_id
|
||||
AND archived = false
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Recursive case: Get all descendant tasks
|
||||
SELECT
|
||||
t.id,
|
||||
t.parent_task_id,
|
||||
COALESCE(t.total_minutes, 0) as total_minutes,
|
||||
tt.level + 1 as level
|
||||
FROM tasks t
|
||||
INNER JOIN task_tree tt ON t.parent_task_id = tt.id
|
||||
WHERE t.archived = false
|
||||
),
|
||||
task_counts AS (
|
||||
SELECT
|
||||
COUNT(*) as sub_tasks_count,
|
||||
SUM(total_minutes) as subtasks_total_minutes -- Sum all subtask estimations
|
||||
FROM task_tree
|
||||
)
|
||||
SELECT JSON_BUILD_OBJECT(
|
||||
'sub_tasks_count', COALESCE(tc.sub_tasks_count, 0),
|
||||
'own_total_minutes', 0, -- Always 0 for parent tasks
|
||||
'subtasks_total_minutes', COALESCE(tc.subtasks_total_minutes, 0),
|
||||
'recursive_total_minutes', COALESCE(tc.subtasks_total_minutes, 0), -- Only subtasks total
|
||||
'recursive_total_hours', FLOOR(COALESCE(tc.subtasks_total_minutes, 0) / 60),
|
||||
'recursive_remaining_minutes', COALESCE(tc.subtasks_total_minutes, 0) % 60
|
||||
)
|
||||
INTO _result
|
||||
FROM task_counts tc;
|
||||
ELSE
|
||||
-- If task has no subtasks, use its own estimation
|
||||
SELECT JSON_BUILD_OBJECT(
|
||||
'sub_tasks_count', 0,
|
||||
'own_total_minutes', COALESCE(total_minutes, 0),
|
||||
'subtasks_total_minutes', 0,
|
||||
'recursive_total_minutes', COALESCE(total_minutes, 0), -- Use own estimation
|
||||
'recursive_total_hours', FLOOR(COALESCE(total_minutes, 0) / 60),
|
||||
'recursive_remaining_minutes', COALESCE(total_minutes, 0) % 60
|
||||
)
|
||||
INTO _result
|
||||
FROM tasks
|
||||
WHERE id = _task_id;
|
||||
END IF;
|
||||
|
||||
RETURN COALESCE(_result, JSON_BUILD_OBJECT(
|
||||
'sub_tasks_count', 0,
|
||||
'own_total_minutes', 0,
|
||||
'subtasks_total_minutes', 0,
|
||||
'recursive_total_minutes', 0,
|
||||
'recursive_total_hours', 0,
|
||||
'recursive_remaining_minutes', 0
|
||||
));
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Update the get_task_form_view_model function to include recursive estimation
|
||||
CREATE OR REPLACE FUNCTION public.get_task_form_view_model(_user_id UUID, _team_id UUID, _task_id UUID, _project_id UUID) RETURNS JSON
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_task JSON;
|
||||
_priorities JSON;
|
||||
_projects JSON;
|
||||
_statuses JSON;
|
||||
_team_members JSON;
|
||||
_assignees JSON;
|
||||
_phases JSON;
|
||||
BEGIN
|
||||
|
||||
-- Select task info
|
||||
SELECT COALESCE(ROW_TO_JSON(rec), '{}'::JSON)
|
||||
INTO _task
|
||||
FROM (WITH RECURSIVE task_hierarchy AS (
|
||||
-- Base case: Start with the given task
|
||||
SELECT id,
|
||||
parent_task_id,
|
||||
0 AS level
|
||||
FROM tasks
|
||||
WHERE id = _task_id
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Recursive case: Traverse up to parent tasks
|
||||
SELECT t.id,
|
||||
t.parent_task_id,
|
||||
th.level + 1 AS level
|
||||
FROM tasks t
|
||||
INNER JOIN task_hierarchy th ON t.id = th.parent_task_id
|
||||
WHERE th.parent_task_id IS NOT NULL)
|
||||
SELECT id,
|
||||
name,
|
||||
description,
|
||||
start_date,
|
||||
end_date,
|
||||
done,
|
||||
total_minutes,
|
||||
priority_id,
|
||||
project_id,
|
||||
created_at,
|
||||
updated_at,
|
||||
status_id,
|
||||
parent_task_id,
|
||||
sort_order,
|
||||
(SELECT phase_id FROM task_phase WHERE task_id = tasks.id) AS phase_id,
|
||||
CONCAT((SELECT key FROM projects WHERE id = tasks.project_id), '-', task_no) AS task_key,
|
||||
(SELECT start_time
|
||||
FROM task_timers
|
||||
WHERE task_id = tasks.id
|
||||
AND user_id = _user_id) AS timer_start_time,
|
||||
parent_task_id IS NOT NULL AS is_sub_task,
|
||||
(SELECT COUNT('*')
|
||||
FROM tasks
|
||||
WHERE parent_task_id = tasks.id
|
||||
AND archived IS FALSE) AS sub_tasks_count,
|
||||
(SELECT COUNT(*)
|
||||
FROM tasks_with_status_view tt
|
||||
WHERE (tt.parent_task_id = tasks.id OR tt.task_id = tasks.id)
|
||||
AND tt.is_done IS TRUE)
|
||||
AS completed_count,
|
||||
(SELECT COUNT(*) FROM task_attachments WHERE task_id = tasks.id) AS attachments_count,
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(r))), '[]'::JSON)
|
||||
FROM (SELECT task_labels.label_id AS id,
|
||||
(SELECT name FROM team_labels WHERE id = task_labels.label_id),
|
||||
(SELECT color_code FROM team_labels WHERE id = task_labels.label_id)
|
||||
FROM task_labels
|
||||
WHERE task_id = tasks.id
|
||||
ORDER BY name) r) AS labels,
|
||||
(SELECT color_code
|
||||
FROM sys_task_status_categories
|
||||
WHERE id = (SELECT category_id FROM task_statuses WHERE id = tasks.status_id)) AS status_color,
|
||||
(SELECT COUNT(*) FROM tasks WHERE parent_task_id = _task_id) AS sub_tasks_count,
|
||||
(SELECT name FROM users WHERE id = tasks.reporter_id) AS reporter,
|
||||
(SELECT get_task_assignees(tasks.id)) AS assignees,
|
||||
(SELECT id FROM team_members WHERE user_id = _user_id AND team_id = _team_id) AS team_member_id,
|
||||
billable,
|
||||
schedule_id,
|
||||
progress_value,
|
||||
weight,
|
||||
(SELECT MAX(level) FROM task_hierarchy) AS task_level,
|
||||
(SELECT get_task_recursive_estimation(tasks.id)) AS recursive_estimation
|
||||
FROM tasks
|
||||
WHERE id = _task_id) rec;
|
||||
|
||||
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
INTO _priorities
|
||||
FROM (SELECT id, name FROM task_priorities ORDER BY value) rec;
|
||||
|
||||
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
INTO _phases
|
||||
FROM (SELECT id, name FROM project_phases WHERE project_id = _project_id ORDER BY name) rec;
|
||||
|
||||
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
INTO _projects
|
||||
FROM (SELECT id, name
|
||||
FROM projects
|
||||
WHERE team_id = _team_id
|
||||
AND (CASE
|
||||
WHEN (is_owner(_user_id, _team_id) OR is_admin(_user_id, _team_id) IS TRUE) THEN TRUE
|
||||
ELSE is_member_of_project(projects.id, _user_id, _team_id) END)
|
||||
ORDER BY name) rec;
|
||||
|
||||
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
INTO _statuses
|
||||
FROM (SELECT id, name FROM task_statuses WHERE project_id = _project_id) rec;
|
||||
|
||||
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
INTO _team_members
|
||||
FROM (SELECT team_members.id,
|
||||
(SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = team_members.id),
|
||||
(SELECT email FROM team_member_info_view WHERE team_member_info_view.team_member_id = team_members.id),
|
||||
(SELECT avatar_url
|
||||
FROM team_member_info_view
|
||||
WHERE team_member_info_view.team_member_id = team_members.id)
|
||||
FROM team_members
|
||||
LEFT JOIN users u ON team_members.user_id = u.id
|
||||
WHERE team_id = _team_id
|
||||
AND team_members.active IS TRUE) rec;
|
||||
|
||||
SELECT get_task_assignees(_task_id) INTO _assignees;
|
||||
|
||||
RETURN JSON_BUILD_OBJECT(
|
||||
'task', _task,
|
||||
'priorities', _priorities,
|
||||
'projects', _projects,
|
||||
'statuses', _statuses,
|
||||
'team_members', _team_members,
|
||||
'assignees', _assignees,
|
||||
'phases', _phases
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,20 @@
|
||||
-- Migration: Add currency column to projects table
|
||||
-- Date: 2025-01-17
|
||||
-- Description: Adds project-specific currency support to allow different projects to use different currencies
|
||||
|
||||
-- Add currency column to projects table
|
||||
ALTER TABLE projects
|
||||
ADD COLUMN IF NOT EXISTS currency VARCHAR(3) DEFAULT 'USD';
|
||||
|
||||
-- Add comment for documentation
|
||||
COMMENT ON COLUMN projects.currency IS 'Project-specific currency code (e.g., USD, EUR, GBP, JPY, etc.)';
|
||||
|
||||
-- Add constraint to ensure currency codes are uppercase and 3 characters
|
||||
ALTER TABLE projects
|
||||
ADD CONSTRAINT projects_currency_format_check
|
||||
CHECK (currency ~ '^[A-Z]{3}$');
|
||||
|
||||
-- Update existing projects to have a default currency if they don't have one
|
||||
UPDATE projects
|
||||
SET currency = 'USD'
|
||||
WHERE currency IS NULL;
|
||||
@@ -603,7 +603,8 @@ BEGIN
|
||||
schedule_id,
|
||||
progress_value,
|
||||
weight,
|
||||
(SELECT MAX(level) FROM task_hierarchy) AS task_level
|
||||
(SELECT MAX(level) FROM task_hierarchy) AS task_level,
|
||||
(SELECT get_task_recursive_estimation(tasks.id)) AS recursive_estimation
|
||||
FROM tasks
|
||||
WHERE id = _task_id) rec;
|
||||
|
||||
@@ -662,6 +663,89 @@ ADD COLUMN IF NOT EXISTS use_manual_progress BOOLEAN DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS use_weighted_progress BOOLEAN DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS use_time_progress BOOLEAN DEFAULT FALSE;
|
||||
|
||||
-- Function to calculate recursive task estimation (including all subtasks)
|
||||
CREATE OR REPLACE FUNCTION get_task_recursive_estimation(_task_id UUID) RETURNS JSON
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_result JSON;
|
||||
_has_subtasks BOOLEAN;
|
||||
BEGIN
|
||||
-- First check if this task has any subtasks
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM tasks
|
||||
WHERE parent_task_id = _task_id
|
||||
AND archived = false
|
||||
) INTO _has_subtasks;
|
||||
|
||||
-- If task has subtasks, calculate recursive estimation excluding parent's own estimation
|
||||
IF _has_subtasks THEN
|
||||
WITH RECURSIVE task_tree AS (
|
||||
-- Start with direct subtasks only (exclude the parent task itself)
|
||||
SELECT
|
||||
id,
|
||||
parent_task_id,
|
||||
COALESCE(total_minutes, 0) as total_minutes,
|
||||
1 as level -- Start at level 1 (subtasks)
|
||||
FROM tasks
|
||||
WHERE parent_task_id = _task_id
|
||||
AND archived = false
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Recursive case: Get all descendant tasks
|
||||
SELECT
|
||||
t.id,
|
||||
t.parent_task_id,
|
||||
COALESCE(t.total_minutes, 0) as total_minutes,
|
||||
tt.level + 1 as level
|
||||
FROM tasks t
|
||||
INNER JOIN task_tree tt ON t.parent_task_id = tt.id
|
||||
WHERE t.archived = false
|
||||
),
|
||||
task_counts AS (
|
||||
SELECT
|
||||
COUNT(*) as sub_tasks_count,
|
||||
SUM(total_minutes) as subtasks_total_minutes -- Sum all subtask estimations
|
||||
FROM task_tree
|
||||
)
|
||||
SELECT JSON_BUILD_OBJECT(
|
||||
'sub_tasks_count', COALESCE(tc.sub_tasks_count, 0),
|
||||
'own_total_minutes', 0, -- Always 0 for parent tasks
|
||||
'subtasks_total_minutes', COALESCE(tc.subtasks_total_minutes, 0),
|
||||
'recursive_total_minutes', COALESCE(tc.subtasks_total_minutes, 0), -- Only subtasks total
|
||||
'recursive_total_hours', FLOOR(COALESCE(tc.subtasks_total_minutes, 0) / 60),
|
||||
'recursive_remaining_minutes', COALESCE(tc.subtasks_total_minutes, 0) % 60
|
||||
)
|
||||
INTO _result
|
||||
FROM task_counts tc;
|
||||
ELSE
|
||||
-- If task has no subtasks, use its own estimation
|
||||
SELECT JSON_BUILD_OBJECT(
|
||||
'sub_tasks_count', 0,
|
||||
'own_total_minutes', COALESCE(total_minutes, 0),
|
||||
'subtasks_total_minutes', 0,
|
||||
'recursive_total_minutes', COALESCE(total_minutes, 0), -- Use own estimation
|
||||
'recursive_total_hours', FLOOR(COALESCE(total_minutes, 0) / 60),
|
||||
'recursive_remaining_minutes', COALESCE(total_minutes, 0) % 60
|
||||
)
|
||||
INTO _result
|
||||
FROM tasks
|
||||
WHERE id = _task_id;
|
||||
END IF;
|
||||
|
||||
RETURN COALESCE(_result, JSON_BUILD_OBJECT(
|
||||
'sub_tasks_count', 0,
|
||||
'own_total_minutes', 0,
|
||||
'subtasks_total_minutes', 0,
|
||||
'recursive_total_minutes', 0,
|
||||
'recursive_total_hours', 0,
|
||||
'recursive_remaining_minutes', 0
|
||||
));
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Add a trigger to reset manual progress when a task gets a new subtask
|
||||
CREATE OR REPLACE FUNCTION reset_parent_task_manual_progress() RETURNS TRIGGER AS
|
||||
$$
|
||||
@@ -677,6 +761,22 @@ BEGIN
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Add a trigger to reset parent task estimation when it gets subtasks
|
||||
CREATE OR REPLACE FUNCTION reset_parent_task_estimation() RETURNS TRIGGER AS
|
||||
$$
|
||||
BEGIN
|
||||
-- When a task gets a new subtask (parent_task_id is set), reset the parent's total_minutes to 0
|
||||
-- This ensures parent tasks don't have their own estimation when they have subtasks
|
||||
IF NEW.parent_task_id IS NOT NULL THEN
|
||||
UPDATE tasks
|
||||
SET total_minutes = 0
|
||||
WHERE id = NEW.parent_task_id
|
||||
AND total_minutes > 0;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create the trigger on the tasks table
|
||||
DROP TRIGGER IF EXISTS reset_parent_manual_progress_trigger ON tasks;
|
||||
CREATE TRIGGER reset_parent_manual_progress_trigger
|
||||
@@ -684,4 +784,35 @@ AFTER INSERT OR UPDATE OF parent_task_id ON tasks
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION reset_parent_task_manual_progress();
|
||||
|
||||
-- Create the trigger to reset parent task estimation
|
||||
DROP TRIGGER IF EXISTS reset_parent_estimation_trigger ON tasks;
|
||||
CREATE TRIGGER reset_parent_estimation_trigger
|
||||
AFTER INSERT OR UPDATE OF parent_task_id ON tasks
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION reset_parent_task_estimation();
|
||||
|
||||
-- Function to reset all existing parent tasks' estimations to 0
|
||||
CREATE OR REPLACE FUNCTION reset_all_parent_task_estimations() RETURNS INTEGER AS
|
||||
$$
|
||||
DECLARE
|
||||
_updated_count INTEGER;
|
||||
BEGIN
|
||||
-- Update all tasks that have subtasks to have 0 estimation
|
||||
UPDATE tasks
|
||||
SET total_minutes = 0
|
||||
WHERE id IN (
|
||||
SELECT DISTINCT parent_task_id
|
||||
FROM tasks
|
||||
WHERE parent_task_id IS NOT NULL
|
||||
AND archived = false
|
||||
)
|
||||
AND total_minutes > 0
|
||||
AND archived = false;
|
||||
|
||||
GET DIAGNOSTICS _updated_count = ROW_COUNT;
|
||||
|
||||
RETURN _updated_count;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,48 @@
|
||||
-- Dropping existing finance_rate_cards table
|
||||
DROP TABLE IF EXISTS finance_rate_cards;
|
||||
-- Creating table to store rate card details
|
||||
CREATE TABLE finance_rate_cards
|
||||
(
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
team_id UUID NOT NULL REFERENCES teams (id) ON DELETE CASCADE,
|
||||
name VARCHAR NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Dropping existing finance_project_rate_card_roles table
|
||||
DROP TABLE IF EXISTS finance_project_rate_card_roles CASCADE;
|
||||
-- Creating table with single id primary key
|
||||
CREATE TABLE finance_project_rate_card_roles
|
||||
(
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
project_id UUID NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
|
||||
job_title_id UUID NOT NULL REFERENCES job_titles (id) ON DELETE CASCADE,
|
||||
rate DECIMAL(10, 2) NOT NULL CHECK (rate >= 0),
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT unique_project_role UNIQUE (project_id, job_title_id)
|
||||
);
|
||||
|
||||
-- Dropping existing finance_rate_card_roles table
|
||||
DROP TABLE IF EXISTS finance_rate_card_roles;
|
||||
-- Creating table to store role-specific rates for rate cards
|
||||
CREATE TABLE finance_rate_card_roles
|
||||
(
|
||||
rate_card_id UUID NOT NULL REFERENCES finance_rate_cards (id) ON DELETE CASCADE,
|
||||
job_title_id UUID REFERENCES job_titles(id) ON DELETE SET NULL,
|
||||
rate DECIMAL(10, 2) NOT NULL CHECK (rate >= 0),
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Adding project_rate_card_role_id column to project_members
|
||||
ALTER TABLE project_members
|
||||
ADD COLUMN project_rate_card_role_id UUID REFERENCES finance_project_rate_card_roles(id) ON DELETE SET NULL;
|
||||
|
||||
-- Adding rate_card column to projects
|
||||
ALTER TABLE projects
|
||||
ADD COLUMN rate_card UUID REFERENCES finance_rate_cards(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE finance_rate_cards
|
||||
ADD COLUMN currency TEXT NOT NULL DEFAULT 'USD';
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Add fixed_cost column to tasks table for project finance functionality
|
||||
ALTER TABLE tasks
|
||||
ADD COLUMN fixed_cost DECIMAL(10, 2) DEFAULT 0 CHECK (fixed_cost >= 0);
|
||||
|
||||
-- Add comment to explain the column
|
||||
COMMENT ON COLUMN tasks.fixed_cost IS 'Fixed cost for the task in addition to hourly rate calculations';
|
||||
@@ -14,6 +14,9 @@ CREATE TYPE SCHEDULE_TYPE AS ENUM ('daily', 'weekly', 'yearly', 'monthly', 'ever
|
||||
|
||||
CREATE TYPE LANGUAGE_TYPE AS ENUM ('en', 'es', 'pt');
|
||||
|
||||
-- Add progress mode type for tasks progress tracking
|
||||
CREATE TYPE PROGRESS_MODE_TYPE AS ENUM ('manual', 'weighted', 'time', 'default');
|
||||
|
||||
-- START: Users
|
||||
CREATE SEQUENCE IF NOT EXISTS users_user_no_seq START 1;
|
||||
|
||||
@@ -777,9 +780,15 @@ CREATE TABLE IF NOT EXISTS projects (
|
||||
estimated_man_days INTEGER DEFAULT 0,
|
||||
hours_per_day INTEGER DEFAULT 8,
|
||||
health_id UUID,
|
||||
estimated_working_days INTEGER DEFAULT 0
|
||||
estimated_working_days INTEGER DEFAULT 0,
|
||||
use_manual_progress BOOLEAN DEFAULT FALSE,
|
||||
use_weighted_progress BOOLEAN DEFAULT FALSE,
|
||||
use_time_progress BOOLEAN DEFAULT FALSE,
|
||||
currency VARCHAR(3) DEFAULT 'USD'
|
||||
);
|
||||
|
||||
COMMENT ON COLUMN projects.currency IS 'Project-specific currency code (e.g., USD, EUR, GBP, JPY, etc.)';
|
||||
|
||||
ALTER TABLE projects
|
||||
ADD CONSTRAINT projects_pk
|
||||
PRIMARY KEY (id);
|
||||
@@ -1411,9 +1420,16 @@ CREATE TABLE IF NOT EXISTS tasks (
|
||||
sort_order INTEGER DEFAULT 0 NOT NULL,
|
||||
roadmap_sort_order INTEGER DEFAULT 0 NOT NULL,
|
||||
billable BOOLEAN DEFAULT TRUE,
|
||||
schedule_id UUID
|
||||
schedule_id UUID,
|
||||
manual_progress BOOLEAN DEFAULT FALSE,
|
||||
progress_value INTEGER DEFAULT NULL,
|
||||
progress_mode PROGRESS_MODE_TYPE DEFAULT 'default',
|
||||
weight INTEGER DEFAULT NULL,
|
||||
fixed_cost DECIMAL(10, 2) DEFAULT 0 CHECK (fixed_cost >= 0)
|
||||
);
|
||||
|
||||
COMMENT ON COLUMN tasks.fixed_cost IS 'Fixed cost for the task in addition to hourly rate calculations';
|
||||
|
||||
ALTER TABLE tasks
|
||||
ADD CONSTRAINT tasks_pk
|
||||
PRIMARY KEY (id);
|
||||
@@ -2279,3 +2295,37 @@ ALTER TABLE organization_working_days
|
||||
ALTER TABLE organization_working_days
|
||||
ADD CONSTRAINT org_organization_id_fk
|
||||
FOREIGN KEY (organization_id) REFERENCES organizations;
|
||||
|
||||
-- Finance module tables
|
||||
CREATE TABLE IF NOT EXISTS finance_rate_cards (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
team_id UUID NOT NULL REFERENCES teams (id) ON DELETE CASCADE,
|
||||
name VARCHAR NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
currency TEXT NOT NULL DEFAULT 'USD'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS finance_project_rate_card_roles (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
project_id UUID NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
|
||||
job_title_id UUID NOT NULL REFERENCES job_titles (id) ON DELETE CASCADE,
|
||||
rate DECIMAL(10, 2) NOT NULL CHECK (rate >= 0),
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT unique_project_role UNIQUE (project_id, job_title_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS finance_rate_card_roles (
|
||||
rate_card_id UUID NOT NULL REFERENCES finance_rate_cards (id) ON DELETE CASCADE,
|
||||
job_title_id UUID REFERENCES job_titles(id) ON DELETE SET NULL,
|
||||
rate DECIMAL(10, 2) NOT NULL CHECK (rate >= 0),
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
ALTER TABLE project_members
|
||||
ADD COLUMN IF NOT EXISTS project_rate_card_role_id UUID REFERENCES finance_project_rate_card_roles(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE projects
|
||||
ADD COLUMN IF NOT EXISTS rate_card UUID REFERENCES finance_rate_cards(id) ON DELETE SET NULL;
|
||||
|
||||
@@ -3351,15 +3351,15 @@ BEGIN
|
||||
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
FROM (SELECT team_member_id,
|
||||
project_member_id,
|
||||
(SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id),
|
||||
(SELECT email_notifications_enabled
|
||||
COALESCE((SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id), '') as name,
|
||||
COALESCE((SELECT email_notifications_enabled
|
||||
FROM notification_settings
|
||||
WHERE team_id = tm.team_id
|
||||
AND notification_settings.user_id = u.id) AS email_notifications_enabled,
|
||||
u.avatar_url,
|
||||
AND notification_settings.user_id = u.id), false) AS email_notifications_enabled,
|
||||
COALESCE(u.avatar_url, '') as avatar_url,
|
||||
u.id AS user_id,
|
||||
u.email,
|
||||
u.socket_id AS socket_id,
|
||||
COALESCE(u.email, '') as email,
|
||||
COALESCE(u.socket_id, '') as socket_id,
|
||||
tm.team_id AS team_id
|
||||
FROM tasks_assignees
|
||||
INNER JOIN team_members tm ON tm.id = tasks_assignees.team_member_id
|
||||
@@ -4066,14 +4066,14 @@ DECLARE
|
||||
_schedule_id JSON;
|
||||
_task_completed_at TIMESTAMPTZ;
|
||||
BEGIN
|
||||
SELECT name FROM tasks WHERE id = _task_id INTO _task_name;
|
||||
SELECT COALESCE(name, '') FROM tasks WHERE id = _task_id INTO _task_name;
|
||||
|
||||
SELECT name
|
||||
SELECT COALESCE(name, '')
|
||||
FROM task_statuses
|
||||
WHERE id = (SELECT status_id FROM tasks WHERE id = _task_id)
|
||||
INTO _previous_status_name;
|
||||
|
||||
SELECT name FROM task_statuses WHERE id = _status_id INTO _new_status_name;
|
||||
SELECT COALESCE(name, '') FROM task_statuses WHERE id = _status_id INTO _new_status_name;
|
||||
|
||||
IF (_previous_status_name != _new_status_name)
|
||||
THEN
|
||||
@@ -4081,14 +4081,22 @@ BEGIN
|
||||
|
||||
SELECT get_task_complete_info(_task_id, _status_id) INTO _task_info;
|
||||
|
||||
SELECT name FROM users WHERE id = _user_id INTO _updater_name;
|
||||
SELECT COALESCE(name, '') FROM users WHERE id = _user_id INTO _updater_name;
|
||||
|
||||
_message = CONCAT(_updater_name, ' transitioned "', _task_name, '" from ', _previous_status_name, ' ⟶ ',
|
||||
_new_status_name);
|
||||
END IF;
|
||||
|
||||
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)
|
||||
FROM (SELECT is_done, is_doing, is_todo
|
||||
@@ -4097,7 +4105,7 @@ BEGIN
|
||||
INTO _status_category;
|
||||
|
||||
RETURN JSON_BUILD_OBJECT(
|
||||
'message', _message,
|
||||
'message', COALESCE(_message, ''),
|
||||
'project_id', (SELECT project_id FROM tasks WHERE id = _task_id),
|
||||
'parent_done', (CASE
|
||||
WHEN EXISTS(SELECT 1
|
||||
@@ -4105,14 +4113,14 @@ BEGIN
|
||||
WHERE tasks_with_status_view.task_id = _task_id
|
||||
AND is_done IS TRUE) THEN 1
|
||||
ELSE 0 END),
|
||||
'color_code', (_task_info ->> 'color_code')::TEXT,
|
||||
'color_code_dark', (_task_info ->> 'color_code_dark')::TEXT,
|
||||
'total_tasks', (_task_info ->> 'total_tasks')::INT,
|
||||
'total_completed', (_task_info ->> 'total_completed')::INT,
|
||||
'members', (_task_info ->> 'members')::JSON,
|
||||
'color_code', COALESCE((_task_info ->> 'color_code')::TEXT, ''),
|
||||
'color_code_dark', COALESCE((_task_info ->> 'color_code_dark')::TEXT, ''),
|
||||
'total_tasks', COALESCE((_task_info ->> 'total_tasks')::INT, 0),
|
||||
'total_completed', COALESCE((_task_info ->> 'total_completed')::INT, 0),
|
||||
'members', COALESCE((_task_info -> 'members'), '[]'::JSON),
|
||||
'completed_at', _task_completed_at,
|
||||
'status_category', _status_category,
|
||||
'schedule_id', _schedule_id
|
||||
'status_category', COALESCE(_status_category, '{}'::JSON),
|
||||
'schedule_id', COALESCE(_schedule_id, 'null'::JSON)
|
||||
);
|
||||
END
|
||||
$$;
|
||||
@@ -5393,7 +5401,8 @@ BEGIN
|
||||
updated_at = CURRENT_TIMESTAMP,
|
||||
estimated_working_days = (_body ->> 'working_days')::INTEGER,
|
||||
estimated_man_days = (_body ->> 'man_days')::INTEGER,
|
||||
hours_per_day = (_body ->> 'hours_per_day')::INTEGER
|
||||
hours_per_day = (_body ->> 'hours_per_day')::INTEGER,
|
||||
currency = COALESCE(UPPER((_body ->> 'currency')::TEXT), currency)
|
||||
WHERE id = (_body ->> 'id')::UUID
|
||||
AND team_id = _team_id
|
||||
RETURNING id INTO _project_id;
|
||||
@@ -6148,3 +6157,260 @@ BEGIN
|
||||
RETURN v_new_id;
|
||||
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;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE VIEW project_finance_view AS
|
||||
SELECT
|
||||
t.id,
|
||||
t.name,
|
||||
t.total_minutes / 3600.0 as estimated_hours,
|
||||
COALESCE((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id), 0) / 3600.0 as total_time_logged,
|
||||
COALESCE((SELECT SUM(rate * (time_spent / 3600.0))
|
||||
FROM task_work_log twl
|
||||
LEFT JOIN users u ON twl.user_id = u.id
|
||||
LEFT JOIN team_members tm ON u.id = tm.user_id
|
||||
LEFT JOIN project_members pm ON tm.id = pm.team_member_id
|
||||
LEFT JOIN finance_project_rate_card_roles pmr ON pm.project_rate_card_role_id = pmr.id
|
||||
WHERE twl.task_id = t.id), 0) as estimated_cost,
|
||||
0 as fixed_cost, -- Default to 0 since the column doesn't exist
|
||||
COALESCE(t.total_minutes / 3600.0 *
|
||||
(SELECT rate FROM finance_project_rate_card_roles
|
||||
WHERE project_id = t.project_id
|
||||
AND id = (SELECT project_rate_card_role_id FROM project_members WHERE team_member_id = t.reporter_id LIMIT 1)
|
||||
LIMIT 1), 0) as total_budgeted_cost,
|
||||
COALESCE((SELECT SUM(rate * (time_spent / 3600.0))
|
||||
FROM task_work_log twl
|
||||
LEFT JOIN users u ON twl.user_id = u.id
|
||||
LEFT JOIN team_members tm ON u.id = tm.user_id
|
||||
LEFT JOIN project_members pm ON tm.id = pm.team_member_id
|
||||
LEFT JOIN finance_project_rate_card_roles pmr ON pm.project_rate_card_role_id = pmr.id
|
||||
WHERE twl.task_id = t.id), 0) as total_actual_cost,
|
||||
COALESCE((SELECT SUM(rate * (time_spent / 3600.0))
|
||||
FROM task_work_log twl
|
||||
LEFT JOIN users u ON twl.user_id = u.id
|
||||
LEFT JOIN team_members tm ON u.id = tm.user_id
|
||||
LEFT JOIN project_members pm ON tm.id = pm.team_member_id
|
||||
LEFT JOIN finance_project_rate_card_roles pmr ON pm.project_rate_card_role_id = pmr.id
|
||||
WHERE twl.task_id = t.id), 0) -
|
||||
COALESCE(t.total_minutes / 3600.0 *
|
||||
(SELECT rate FROM finance_project_rate_card_roles
|
||||
WHERE project_id = t.project_id
|
||||
AND id = (SELECT project_rate_card_role_id FROM project_members WHERE team_member_id = t.reporter_id LIMIT 1)
|
||||
LIMIT 1), 0) as variance,
|
||||
t.project_id
|
||||
FROM tasks t;
|
||||
|
||||
77
worklenz-backend/fix-task-hierarchy.sql
Normal file
77
worklenz-backend/fix-task-hierarchy.sql
Normal file
@@ -0,0 +1,77 @@
|
||||
-- Fix task hierarchy and reset parent estimations
|
||||
-- This script ensures proper parent-child relationships and resets parent estimations
|
||||
|
||||
-- First, let's see the current task hierarchy
|
||||
SELECT
|
||||
t.id,
|
||||
t.name,
|
||||
t.parent_task_id,
|
||||
t.total_minutes,
|
||||
(SELECT name FROM tasks WHERE id = t.parent_task_id) as parent_name,
|
||||
(SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id AND archived = false) as actual_subtask_count,
|
||||
t.archived
|
||||
FROM tasks t
|
||||
WHERE (t.name LIKE '%sub%' OR t.name LIKE '%test task%')
|
||||
ORDER BY t.name, t.created_at;
|
||||
|
||||
-- Reset all parent task estimations to 0
|
||||
-- This ensures parent tasks don't have their own estimation when they have subtasks
|
||||
UPDATE tasks
|
||||
SET total_minutes = 0
|
||||
WHERE id IN (
|
||||
SELECT DISTINCT parent_task_id
|
||||
FROM tasks
|
||||
WHERE parent_task_id IS NOT NULL
|
||||
AND archived = false
|
||||
)
|
||||
AND archived = false;
|
||||
|
||||
-- Verify the results after the update
|
||||
SELECT
|
||||
t.id,
|
||||
t.name,
|
||||
t.parent_task_id,
|
||||
t.total_minutes as current_estimation,
|
||||
(SELECT name FROM tasks WHERE id = t.parent_task_id) as parent_name,
|
||||
(SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id AND archived = false) as subtask_count,
|
||||
get_task_recursive_estimation(t.id) as recursive_estimation
|
||||
FROM tasks t
|
||||
WHERE (t.name LIKE '%sub%' OR t.name LIKE '%test task%')
|
||||
AND t.archived = false
|
||||
ORDER BY t.name;
|
||||
|
||||
-- Show the hierarchy in tree format
|
||||
WITH RECURSIVE task_hierarchy AS (
|
||||
-- Top level tasks (no parent)
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
parent_task_id,
|
||||
total_minutes,
|
||||
0 as level,
|
||||
name as path
|
||||
FROM tasks
|
||||
WHERE parent_task_id IS NULL
|
||||
AND (name LIKE '%sub%' OR name LIKE '%test task%')
|
||||
AND archived = false
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Child tasks
|
||||
SELECT
|
||||
t.id,
|
||||
t.name,
|
||||
t.parent_task_id,
|
||||
t.total_minutes,
|
||||
th.level + 1,
|
||||
th.path || ' > ' || t.name
|
||||
FROM tasks t
|
||||
INNER JOIN task_hierarchy th ON t.parent_task_id = th.id
|
||||
WHERE t.archived = false
|
||||
)
|
||||
SELECT
|
||||
REPEAT(' ', level) || name as indented_name,
|
||||
total_minutes,
|
||||
get_task_recursive_estimation(id) as recursive_estimation
|
||||
FROM task_hierarchy
|
||||
ORDER BY path;
|
||||
2376
worklenz-backend/package-lock.json
generated
2376
worklenz-backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,16 +11,30 @@
|
||||
"repository": "GITHUB_REPO_HERE",
|
||||
"author": "worklenz.com",
|
||||
"scripts": {
|
||||
"start": "node ./build/bin/www",
|
||||
"tcs": "grunt build:tsc",
|
||||
"build": "grunt build",
|
||||
"watch": "grunt watch",
|
||||
"dev": "grunt dev",
|
||||
"es": "esbuild `find src -type f -name '*.ts'` --platform=node --minify=true --watch=true --target=esnext --format=cjs --tsconfig=tsconfig.prod.json --outdir=dist",
|
||||
"copy": "grunt copy",
|
||||
"test": "jest",
|
||||
"start": "node build/bin/www.js",
|
||||
"dev": "npm run build:dev && npm run watch",
|
||||
"build": "npm run clean && npm run compile && npm run copy && npm run compress",
|
||||
"build:dev": "npm run clean && npm run compile:dev && npm run copy",
|
||||
"build:prod": "npm run clean && npm run compile:prod && npm run copy && npm run minify && npm run compress",
|
||||
"clean": "rimraf build",
|
||||
"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",
|
||||
"tsc": "tsc",
|
||||
"test": "jest --setupFiles dotenv/config",
|
||||
"test:watch": "jest --watch --setupFiles dotenv/config"
|
||||
},
|
||||
"jestSonar": {
|
||||
@@ -28,6 +42,9 @@
|
||||
"reportFile": "test-reporter.xml",
|
||||
"indent": 4
|
||||
},
|
||||
"overrides": {
|
||||
"rimraf": "^6.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.378.0",
|
||||
"@aws-sdk/client-ses": "^3.378.0",
|
||||
@@ -45,6 +62,7 @@
|
||||
"cors": "^2.8.5",
|
||||
"cron": "^2.4.0",
|
||||
"crypto-js": "^4.1.1",
|
||||
"csrf-sync": "^4.2.1",
|
||||
"csurf": "^1.11.0",
|
||||
"debug": "^4.3.4",
|
||||
"dotenv": "^16.3.1",
|
||||
@@ -120,26 +138,22 @@
|
||||
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
||||
"@typescript-eslint/parser": "^5.62.0",
|
||||
"chokidar": "^3.5.3",
|
||||
"concurrently": "^9.1.2",
|
||||
"cpx2": "^8.0.0",
|
||||
"esbuild": "^0.17.19",
|
||||
"esbuild-envfile-plugin": "^1.0.5",
|
||||
"esbuild-node-externals": "^1.8.0",
|
||||
"eslint": "^8.45.0",
|
||||
"eslint-plugin-security": "^1.7.1",
|
||||
"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",
|
||||
"jest": "^28.1.3",
|
||||
"jest-sonar-reporter": "^2.0.0",
|
||||
"ncp": "^2.0.0",
|
||||
"nodeman": "^1.1.2",
|
||||
"rimraf": "^6.0.1",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"terser": "^5.40.0",
|
||||
"ts-jest": "^28.0.8",
|
||||
"ts-node": "^10.9.1",
|
||||
"tslint": "^6.1.3",
|
||||
|
||||
29
worklenz-backend/reset-existing-parent-estimations.sql
Normal file
29
worklenz-backend/reset-existing-parent-estimations.sql
Normal file
@@ -0,0 +1,29 @@
|
||||
-- Reset all existing parent task estimations to 0
|
||||
-- This script updates all tasks that have subtasks to have 0 estimation
|
||||
|
||||
UPDATE tasks
|
||||
SET total_minutes = 0
|
||||
WHERE id IN (
|
||||
SELECT DISTINCT parent_task_id
|
||||
FROM tasks
|
||||
WHERE parent_task_id IS NOT NULL
|
||||
AND archived = false
|
||||
)
|
||||
AND total_minutes > 0
|
||||
AND archived = false;
|
||||
|
||||
-- Show the results
|
||||
SELECT
|
||||
t.id,
|
||||
t.name,
|
||||
t.total_minutes as current_estimation,
|
||||
(SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id AND archived = false) as subtask_count
|
||||
FROM tasks t
|
||||
WHERE id IN (
|
||||
SELECT DISTINCT parent_task_id
|
||||
FROM tasks
|
||||
WHERE parent_task_id IS NOT NULL
|
||||
AND archived = false
|
||||
)
|
||||
AND archived = false
|
||||
ORDER BY t.name;
|
||||
53
worklenz-backend/scripts/compress.js
Normal file
53
worklenz-backend/scripts/compress.js
Normal file
@@ -0,0 +1,53 @@
|
||||
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 compression from "compression";
|
||||
import passport from "passport";
|
||||
import csurf from "csurf";
|
||||
import { csrfSync } from "csrf-sync";
|
||||
import rateLimit from "express-rate-limit";
|
||||
import cors from "cors";
|
||||
import flash from "connect-flash";
|
||||
@@ -112,17 +112,13 @@ function isLoggedIn(req: Request, _res: Response, next: NextFunction) {
|
||||
return req.user ? next() : next(createError(401));
|
||||
}
|
||||
|
||||
// CSRF configuration
|
||||
const csrfProtection = csurf({
|
||||
cookie: {
|
||||
key: "XSRF-TOKEN",
|
||||
path: "/",
|
||||
httpOnly: false,
|
||||
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"]
|
||||
// CSRF configuration using csrf-sync for session-based authentication
|
||||
const {
|
||||
invalidCsrfTokenError,
|
||||
generateToken,
|
||||
csrfSynchronisedProtection,
|
||||
} = csrfSync({
|
||||
getTokenFromRequest: (req: Request) => req.headers["x-csrf-token"] as string || (req.body && req.body["_csrf"])
|
||||
});
|
||||
|
||||
// Apply CSRF selectively (exclude webhooks and public routes)
|
||||
@@ -135,38 +131,25 @@ app.use((req, res, next) => {
|
||||
) {
|
||||
next();
|
||||
} else {
|
||||
csrfProtection(req, res, next);
|
||||
csrfSynchronisedProtection(req, res, next);
|
||||
}
|
||||
});
|
||||
|
||||
// Set CSRF token cookie
|
||||
// Set CSRF token method on request object for compatibility
|
||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||
if (req.csrfToken) {
|
||||
const token = req.csrfToken();
|
||||
res.cookie("XSRF-TOKEN", token, {
|
||||
httpOnly: false,
|
||||
secure: isProduction(),
|
||||
sameSite: isProduction() ? "none" : "lax",
|
||||
domain: isProduction() ? ".worklenz.com" : undefined,
|
||||
path: "/"
|
||||
});
|
||||
// Add csrfToken method to request object for compatibility
|
||||
if (!req.csrfToken && generateToken) {
|
||||
req.csrfToken = (overwrite?: boolean) => generateToken(req, overwrite);
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// CSRF token refresh endpoint
|
||||
app.get("/csrf-token", (req: Request, res: Response) => {
|
||||
if (req.csrfToken) {
|
||||
const token = req.csrfToken();
|
||||
res.cookie("XSRF-TOKEN", token, {
|
||||
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 {
|
||||
try {
|
||||
const token = generateToken(req);
|
||||
res.status(200).json({ done: true, message: "CSRF token refreshed", token });
|
||||
} catch (error) {
|
||||
res.status(500).json({ done: false, message: "Failed to generate CSRF token" });
|
||||
}
|
||||
});
|
||||
@@ -219,7 +202,7 @@ if (isInternalServer()) {
|
||||
|
||||
// CSRF error handler
|
||||
app.use((err: any, req: Request, res: Response, next: NextFunction) => {
|
||||
if (err.code === "EBADCSRFTOKEN") {
|
||||
if (err === invalidCsrfTokenError) {
|
||||
return res.status(403).json({
|
||||
done: false,
|
||||
message: "Invalid CSRF token",
|
||||
|
||||
@@ -31,12 +31,23 @@ export default class AuthController extends WorklenzControllerBase {
|
||||
// Flash messages sent from passport-local-signup.ts and passport-local-login.ts
|
||||
const errors = req.flash()["error"] || [];
|
||||
const messages = req.flash()["success"] || [];
|
||||
|
||||
// If there are multiple messages, we will send one at a time.
|
||||
const auth_error = errors.length > 0 ? errors[0] : null;
|
||||
const message = messages.length > 0 ? messages[0] : null;
|
||||
|
||||
const midTitle = req.query.strategy === "login" ? "Login Failed!" : "Signup Failed!";
|
||||
const title = req.query.strategy ? midTitle : null;
|
||||
// Determine title based on authentication status and strategy
|
||||
let title = 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)
|
||||
req.user.build_v = FileConstants.getRelease();
|
||||
|
||||
1355
worklenz-backend/src/controllers/project-finance-controller.ts
Normal file
1355
worklenz-backend/src/controllers/project-finance-controller.ts
Normal file
File diff suppressed because it is too large
Load Diff
262
worklenz-backend/src/controllers/project-ratecard-controller.ts
Normal file
262
worklenz-backend/src/controllers/project-ratecard-controller.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import db from "../config/db";
|
||||
import { IWorkLenzRequest } from "../interfaces/worklenz-request";
|
||||
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
|
||||
import { ServerResponse } from "../models/server-response";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
|
||||
export default class ProjectRateCardController extends WorklenzControllerBase {
|
||||
|
||||
// Insert a single role for a project
|
||||
@HandleExceptions()
|
||||
public static async createOne(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { project_id, job_title_id, rate } = req.body;
|
||||
if (!project_id || !job_title_id || typeof rate !== "number") {
|
||||
return res.status(400).send(new ServerResponse(false, null, "Invalid input"));
|
||||
}
|
||||
const q = `
|
||||
INSERT INTO finance_project_rate_card_roles (project_id, job_title_id, rate)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (project_id, job_title_id) DO UPDATE SET rate = EXCLUDED.rate
|
||||
RETURNING *,
|
||||
(SELECT name FROM job_titles jt WHERE jt.id = finance_project_rate_card_roles.job_title_id) AS jobtitle;
|
||||
`;
|
||||
const result = await db.query(q, [project_id, job_title_id, rate]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows[0]));
|
||||
}
|
||||
// Insert multiple roles for a project
|
||||
@HandleExceptions()
|
||||
public static async createMany(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { project_id, roles } = req.body;
|
||||
if (!Array.isArray(roles) || !project_id) {
|
||||
return res.status(400).send(new ServerResponse(false, null, "Invalid input"));
|
||||
}
|
||||
const values = roles.map((role: any) => [
|
||||
project_id,
|
||||
role.job_title_id,
|
||||
role.rate
|
||||
]);
|
||||
const q = `
|
||||
INSERT INTO finance_project_rate_card_roles (project_id, job_title_id, rate)
|
||||
VALUES ${values.map((_, i) => `($${i * 3 + 1}, $${i * 3 + 2}, $${i * 3 + 3})`).join(",")}
|
||||
ON CONFLICT (project_id, job_title_id) DO UPDATE SET rate = EXCLUDED.rate
|
||||
RETURNING *,
|
||||
(SELECT name FROM job_titles jt WHERE jt.id = finance_project_rate_card_roles.job_title_id) AS Jobtitle;
|
||||
`;
|
||||
const flatValues = values.flat();
|
||||
const result = await db.query(q, flatValues);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
// Get all roles for a project
|
||||
@HandleExceptions()
|
||||
public static async getByProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { project_id } = req.params;
|
||||
const q = `
|
||||
SELECT
|
||||
fprr.*,
|
||||
jt.name as jobtitle,
|
||||
(
|
||||
SELECT COALESCE(json_agg(pm.id), '[]'::json)
|
||||
FROM project_members pm
|
||||
WHERE pm.project_rate_card_role_id = fprr.id
|
||||
) AS members
|
||||
FROM finance_project_rate_card_roles fprr
|
||||
LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id
|
||||
WHERE fprr.project_id = $1
|
||||
ORDER BY fprr.created_at;
|
||||
`;
|
||||
const result = await db.query(q, [project_id]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
// Get a single role by id
|
||||
@HandleExceptions()
|
||||
public static async getById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { id } = req.params;
|
||||
const q = `
|
||||
SELECT
|
||||
fprr.*,
|
||||
jt.name as jobtitle,
|
||||
(
|
||||
SELECT COALESCE(json_agg(pm.id), '[]'::json)
|
||||
FROM project_members pm
|
||||
WHERE pm.project_rate_card_role_id = fprr.id
|
||||
) AS members
|
||||
FROM finance_project_rate_card_roles fprr
|
||||
LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id
|
||||
WHERE fprr.id = $1;
|
||||
`;
|
||||
const result = await db.query(q, [id]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows[0]));
|
||||
}
|
||||
|
||||
// Update a single role by id
|
||||
@HandleExceptions()
|
||||
public static async updateById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { id } = req.params;
|
||||
const { job_title_id, rate } = req.body;
|
||||
const q = `
|
||||
WITH updated AS (
|
||||
UPDATE finance_project_rate_card_roles
|
||||
SET job_title_id = $1, rate = $2, updated_at = NOW()
|
||||
WHERE id = $3
|
||||
RETURNING *
|
||||
),
|
||||
jobtitles AS (
|
||||
SELECT u.*, jt.name AS jobtitle
|
||||
FROM updated u
|
||||
JOIN job_titles jt ON jt.id = u.job_title_id
|
||||
),
|
||||
members AS (
|
||||
SELECT json_agg(pm.id) AS members, pm.project_rate_card_role_id
|
||||
FROM project_members pm
|
||||
WHERE pm.project_rate_card_role_id IN (SELECT id FROM jobtitles)
|
||||
GROUP BY pm.project_rate_card_role_id
|
||||
)
|
||||
SELECT jt.*, m.members
|
||||
FROM jobtitles jt
|
||||
LEFT JOIN members m ON m.project_rate_card_role_id = jt.id;
|
||||
`;
|
||||
const result = await db.query(q, [job_title_id, rate, id]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows[0]));
|
||||
}
|
||||
|
||||
// update project member rate for a project with members
|
||||
@HandleExceptions()
|
||||
public static async updateProjectMemberByProjectIdAndMemberId(
|
||||
req: IWorkLenzRequest,
|
||||
res: IWorkLenzResponse
|
||||
): Promise<IWorkLenzResponse> {
|
||||
const { project_id, id } = req.params;
|
||||
const { project_rate_card_role_id } = req.body;
|
||||
|
||||
if (!project_id || !id || !project_rate_card_role_id) {
|
||||
return res.status(400).send(new ServerResponse(false, null, "Missing values"));
|
||||
}
|
||||
|
||||
try {
|
||||
// Step 1: Check current role assignment
|
||||
const checkQuery = `
|
||||
SELECT project_rate_card_role_id
|
||||
FROM project_members
|
||||
WHERE id = $1 AND project_id = $2;
|
||||
`;
|
||||
const { rows: checkRows } = await db.query(checkQuery, [id, project_id]);
|
||||
|
||||
const currentRoleId = checkRows[0]?.project_rate_card_role_id;
|
||||
|
||||
if (currentRoleId !== null && currentRoleId !== project_rate_card_role_id) {
|
||||
// Step 2: Fetch members with the requested role
|
||||
const membersQuery = `
|
||||
SELECT COALESCE(json_agg(id), '[]'::json) AS members
|
||||
FROM project_members
|
||||
WHERE project_id = $1 AND project_rate_card_role_id = $2;
|
||||
`;
|
||||
const { rows: memberRows } = await db.query(membersQuery, [project_id, project_rate_card_role_id]);
|
||||
|
||||
return res.status(200).send(
|
||||
new ServerResponse(false, memberRows[0], "Already Assigned !")
|
||||
);
|
||||
}
|
||||
|
||||
// Step 3: Perform the update
|
||||
const updateQuery = `
|
||||
UPDATE project_members
|
||||
SET project_rate_card_role_id = CASE
|
||||
WHEN project_rate_card_role_id = $1 THEN NULL
|
||||
ELSE $1
|
||||
END
|
||||
WHERE id = $2
|
||||
AND project_id = $3
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM finance_project_rate_card_roles
|
||||
WHERE id = $1 AND project_id = $3
|
||||
)
|
||||
RETURNING project_rate_card_role_id;
|
||||
`;
|
||||
const { rows: updateRows } = await db.query(updateQuery, [project_rate_card_role_id, id, project_id]);
|
||||
|
||||
if (updateRows.length === 0) {
|
||||
return res.status(200).send(new ServerResponse(true, [], "Project member not found or invalid project_rate_card_role_id"));
|
||||
}
|
||||
|
||||
const updatedRoleId = updateRows[0].project_rate_card_role_id || project_rate_card_role_id;
|
||||
|
||||
// Step 4: Fetch updated members list
|
||||
const membersQuery = `
|
||||
SELECT COALESCE(json_agg(id), '[]'::json) AS members
|
||||
FROM project_members
|
||||
WHERE project_id = $1 AND project_rate_card_role_id = $2;
|
||||
`;
|
||||
const { rows: finalMembers } = await db.query(membersQuery, [project_id, updatedRoleId]);
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, finalMembers[0]));
|
||||
} catch (error) {
|
||||
return res.status(500).send(new ServerResponse(false, null, "Internal server error"));
|
||||
}
|
||||
}
|
||||
// Update all roles for a project (delete then insert)
|
||||
@HandleExceptions()
|
||||
public static async updateByProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { project_id, roles } = req.body;
|
||||
if (!Array.isArray(roles) || !project_id) {
|
||||
return res.status(400).send(new ServerResponse(false, null, "Invalid input"));
|
||||
}
|
||||
if (roles.length === 0) {
|
||||
// If no roles provided, do nothing and return empty array
|
||||
return res.status(200).send(new ServerResponse(true, []));
|
||||
}
|
||||
// Build upsert query for all roles
|
||||
const values = roles.map((role: any) => [
|
||||
project_id,
|
||||
role.job_title_id,
|
||||
role.rate
|
||||
]);
|
||||
const q = `
|
||||
WITH upserted AS (
|
||||
INSERT INTO finance_project_rate_card_roles (project_id, job_title_id, rate)
|
||||
VALUES ${values.map((_, i) => `($${i * 3 + 1}, $${i * 3 + 2}, $${i * 3 + 3})`).join(",")}
|
||||
ON CONFLICT (project_id, job_title_id)
|
||||
DO UPDATE SET rate = EXCLUDED.rate, updated_at = NOW()
|
||||
RETURNING *
|
||||
),
|
||||
jobtitles AS (
|
||||
SELECT upr.*, jt.name AS jobtitle
|
||||
FROM upserted upr
|
||||
JOIN job_titles jt ON jt.id = upr.job_title_id
|
||||
),
|
||||
members AS (
|
||||
SELECT json_agg(pm.id) AS members, pm.project_rate_card_role_id
|
||||
FROM project_members pm
|
||||
WHERE pm.project_rate_card_role_id IN (SELECT id FROM jobtitles)
|
||||
GROUP BY pm.project_rate_card_role_id
|
||||
)
|
||||
SELECT jt.*, m.members
|
||||
FROM jobtitles jt
|
||||
LEFT JOIN members m ON m.project_rate_card_role_id = jt.id;
|
||||
`;
|
||||
const flatValues = values.flat();
|
||||
const result = await db.query(q, flatValues);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
// Delete a single role by id
|
||||
@HandleExceptions()
|
||||
public static async deleteById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { id } = req.params;
|
||||
const q = `DELETE FROM finance_project_rate_card_roles WHERE id = $1 RETURNING *;`;
|
||||
const result = await db.query(q, [id]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows[0]));
|
||||
}
|
||||
|
||||
// Delete all roles for a project
|
||||
@HandleExceptions()
|
||||
public static async deleteByProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { project_id } = req.params;
|
||||
const q = `DELETE FROM finance_project_rate_card_roles WHERE project_id = $1 RETURNING *;`;
|
||||
const result = await db.query(q, [project_id]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
}
|
||||
@@ -395,6 +395,7 @@ export default class ProjectsController extends WorklenzControllerBase {
|
||||
projects.folder_id,
|
||||
projects.phase_label,
|
||||
projects.category_id,
|
||||
projects.currency,
|
||||
(projects.estimated_man_days) AS man_days,
|
||||
(projects.estimated_working_days) AS working_days,
|
||||
(projects.hours_per_day) AS hours_per_day,
|
||||
|
||||
157
worklenz-backend/src/controllers/ratecard-controller.ts
Normal file
157
worklenz-backend/src/controllers/ratecard-controller.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { IWorkLenzRequest } from "../interfaces/worklenz-request";
|
||||
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
|
||||
import db from "../config/db";
|
||||
import { ServerResponse } from "../models/server-response";
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
|
||||
export default class RateCardController extends WorklenzControllerBase {
|
||||
@HandleExceptions()
|
||||
public static async create(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `
|
||||
INSERT INTO finance_rate_cards (team_id, name)
|
||||
VALUES ($1, $2)
|
||||
RETURNING id, name, team_id, created_at, updated_at;
|
||||
`;
|
||||
const result = await db.query(q, [req.user?.team_id || null, req.body.name]);
|
||||
const [data] = result.rows;
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async get(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { searchQuery, sortField, sortOrder, size, offset } = this.toPaginationOptions(req.query, "name");
|
||||
|
||||
const q = `
|
||||
SELECT ROW_TO_JSON(rec) AS rate_cards
|
||||
FROM (
|
||||
SELECT COUNT(*) AS total,
|
||||
(
|
||||
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON)
|
||||
FROM (
|
||||
SELECT id, name, team_id, currency, created_at, updated_at
|
||||
FROM finance_rate_cards
|
||||
WHERE team_id = $1 ${searchQuery}
|
||||
ORDER BY ${sortField} ${sortOrder}
|
||||
LIMIT $2 OFFSET $3
|
||||
) t
|
||||
) AS data
|
||||
FROM finance_rate_cards
|
||||
WHERE team_id = $1 ${searchQuery}
|
||||
) rec;
|
||||
`;
|
||||
const result = await db.query(q, [req.user?.team_id || null, size, offset]);
|
||||
const [data] = result.rows;
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, data.rate_cards || this.paginatedDatasetDefaultStruct));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
// 1. Fetch the rate card
|
||||
const q = `
|
||||
SELECT id, name, team_id, currency, created_at, updated_at
|
||||
FROM finance_rate_cards
|
||||
WHERE id = $1 AND team_id = $2;
|
||||
`;
|
||||
const result = await db.query(q, [req.params.id, req.user?.team_id || null]);
|
||||
const [data] = result.rows;
|
||||
|
||||
if (!data) {
|
||||
return res.status(404).send(new ServerResponse(false, null, "Rate card not found"));
|
||||
}
|
||||
|
||||
// 2. Fetch job roles with job title names
|
||||
const jobRolesQ = `
|
||||
SELECT
|
||||
rcr.job_title_id,
|
||||
jt.name AS jobTitle,
|
||||
rcr.rate,
|
||||
rcr.rate_card_id
|
||||
FROM finance_rate_card_roles rcr
|
||||
LEFT JOIN job_titles jt ON rcr.job_title_id = jt.id
|
||||
WHERE rcr.rate_card_id = $1
|
||||
`;
|
||||
const jobRolesResult = await db.query(jobRolesQ, [req.params.id]);
|
||||
const jobRolesList = jobRolesResult.rows;
|
||||
|
||||
// 3. Return the rate card with jobRolesList
|
||||
return res.status(200).send(
|
||||
new ServerResponse(true, {
|
||||
...data,
|
||||
jobRolesList,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async update(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
// 1. Update the rate card
|
||||
const updateRateCardQ = `
|
||||
UPDATE finance_rate_cards
|
||||
SET name = $3, currency = $4, updated_at = NOW()
|
||||
WHERE id = $1 AND team_id = $2
|
||||
RETURNING id, name, team_id, currency, created_at, updated_at;
|
||||
`;
|
||||
const result = await db.query(updateRateCardQ, [
|
||||
req.params.id,
|
||||
req.user?.team_id || null,
|
||||
req.body.name,
|
||||
req.body.currency,
|
||||
]);
|
||||
const [rateCardData] = result.rows;
|
||||
|
||||
// 2. Update job roles (delete old, insert new)
|
||||
if (Array.isArray(req.body.jobRolesList)) {
|
||||
// Delete existing roles for this rate card
|
||||
await db.query(
|
||||
`DELETE FROM finance_rate_card_roles WHERE rate_card_id = $1;`,
|
||||
[req.params.id]
|
||||
);
|
||||
|
||||
// Insert new roles
|
||||
for (const role of req.body.jobRolesList) {
|
||||
if (role.job_title_id) {
|
||||
await db.query(
|
||||
`INSERT INTO finance_rate_card_roles (rate_card_id, job_title_id, rate)
|
||||
VALUES ($1, $2, $3);`,
|
||||
[req.params.id, role.job_title_id, role.rate ?? 0]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Get jobRolesList with job title names
|
||||
const jobRolesQ = `
|
||||
SELECT
|
||||
rcr.job_title_id,
|
||||
jt.name AS jobTitle,
|
||||
rcr.rate
|
||||
FROM finance_rate_card_roles rcr
|
||||
LEFT JOIN job_titles jt ON rcr.job_title_id = jt.id
|
||||
WHERE rcr.rate_card_id = $1
|
||||
`;
|
||||
const jobRolesResult = await db.query(jobRolesQ, [req.params.id]);
|
||||
const jobRolesList = jobRolesResult.rows;
|
||||
|
||||
// 4. Return the updated rate card with jobRolesList
|
||||
return res.status(200).send(
|
||||
new ServerResponse(true, {
|
||||
...rateCardData,
|
||||
jobRolesList,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async deleteById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `
|
||||
DELETE FROM finance_rate_cards
|
||||
WHERE id = $1 AND team_id = $2
|
||||
RETURNING id;
|
||||
`;
|
||||
const result = await db.query(q, [req.params.id, req.user?.team_id || null]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows.length > 0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,25 @@ enum IToggleOptions {
|
||||
}
|
||||
|
||||
export default class ReportingAllocationController extends ReportingControllerBase {
|
||||
// Helper method to build billable query with custom table alias
|
||||
private static buildBillableQueryWithAlias(selectedStatuses: { billable: boolean; nonBillable: boolean }, tableAlias: string = 'tasks'): string {
|
||||
const { billable, nonBillable } = selectedStatuses;
|
||||
|
||||
if (billable && nonBillable) {
|
||||
// Both are enabled, no need to filter
|
||||
return "";
|
||||
} else if (billable && !nonBillable) {
|
||||
// Only billable is enabled - show only billable tasks
|
||||
return ` AND ${tableAlias}.billable IS TRUE`;
|
||||
} else if (!billable && nonBillable) {
|
||||
// Only non-billable is enabled - show only non-billable tasks
|
||||
return ` AND ${tableAlias}.billable IS FALSE`;
|
||||
} else {
|
||||
// Neither selected - this shouldn't happen in normal UI flow
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private static async getTimeLoggedByProjects(projects: string[], users: string[], key: string, dateRange: string[], archived = false, user_id = "", billable: { billable: boolean; nonBillable: boolean }): Promise<any> {
|
||||
try {
|
||||
const projectIds = projects.map(p => `'${p}'`).join(",");
|
||||
@@ -77,8 +96,8 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
||||
sps.icon AS status_icon,
|
||||
(SELECT COUNT(*)
|
||||
FROM tasks
|
||||
WHERE CASE WHEN ($1 IS TRUE) THEN project_id IS NOT NULL ELSE archived = FALSE END ${billableQuery}
|
||||
AND project_id = projects.id) AS all_tasks_count,
|
||||
WHERE CASE WHEN ($1 IS TRUE) THEN project_id IS NOT NULL ELSE archived = FALSE END
|
||||
AND project_id = projects.id ${billableQuery}) AS all_tasks_count,
|
||||
(SELECT COUNT(*)
|
||||
FROM tasks
|
||||
WHERE CASE WHEN ($1 IS TRUE) THEN project_id IS NOT NULL ELSE archived = FALSE END
|
||||
@@ -94,10 +113,11 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
||||
SELECT name,
|
||||
(SELECT COALESCE(SUM(time_spent), 0)
|
||||
FROM task_work_log
|
||||
LEFT JOIN tasks ON task_work_log.task_id = tasks.id
|
||||
WHERE user_id = users.id ${billableQuery}
|
||||
LEFT JOIN tasks ON task_work_log.task_id = tasks.id
|
||||
WHERE user_id = users.id
|
||||
AND CASE WHEN ($1 IS TRUE) THEN tasks.project_id IS NOT NULL ELSE tasks.archived = FALSE END
|
||||
AND tasks.project_id = projects.id
|
||||
${billableQuery}
|
||||
${duration}) AS time_logged
|
||||
FROM users
|
||||
WHERE id IN (${userIds})
|
||||
@@ -121,10 +141,11 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
||||
const q = `(SELECT id,
|
||||
(SELECT COALESCE(SUM(time_spent), 0)
|
||||
FROM task_work_log
|
||||
LEFT JOIN tasks ON task_work_log.task_id = tasks.id ${billableQuery}
|
||||
LEFT JOIN tasks ON task_work_log.task_id = tasks.id
|
||||
WHERE user_id = users.id
|
||||
AND CASE WHEN ($1 IS TRUE) THEN tasks.project_id IS NOT NULL ELSE tasks.archived = FALSE END
|
||||
AND tasks.project_id IN (${projectIds})
|
||||
${billableQuery}
|
||||
${duration}) AS time_logged
|
||||
FROM users
|
||||
WHERE id IN (${userIds})
|
||||
@@ -346,6 +367,8 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
||||
const projects = (req.body.projects || []) as string[];
|
||||
const projectIds = projects.map(p => `'${p}'`).join(",");
|
||||
|
||||
const categories = (req.body.categories || []) as string[];
|
||||
const noCategory = req.body.noCategory || false;
|
||||
const billable = req.body.billable;
|
||||
|
||||
if (!teamIds || !projectIds.length)
|
||||
@@ -361,6 +384,33 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
||||
|
||||
const billableQuery = this.buildBillableQuery(billable);
|
||||
|
||||
// Prepare projects filter
|
||||
let projectsFilter = "";
|
||||
if (projectIds.length > 0) {
|
||||
projectsFilter = `AND p.id IN (${projectIds})`;
|
||||
} else {
|
||||
// If no projects are selected, don't show any data
|
||||
projectsFilter = `AND 1=0`; // This will match no rows
|
||||
}
|
||||
|
||||
// Prepare categories filter - updated logic
|
||||
let categoriesFilter = "";
|
||||
if (categories.length > 0 && noCategory) {
|
||||
// Both specific categories and "No Category" are selected
|
||||
const categoryIds = categories.map(id => `'${id}'`).join(",");
|
||||
categoriesFilter = `AND (p.category_id IS NULL OR p.category_id IN (${categoryIds}))`;
|
||||
} else if (categories.length === 0 && noCategory) {
|
||||
// Only "No Category" is selected
|
||||
categoriesFilter = `AND p.category_id IS NULL`;
|
||||
} else if (categories.length > 0 && !noCategory) {
|
||||
// Only specific categories are selected
|
||||
const categoryIds = categories.map(id => `'${id}'`).join(",");
|
||||
categoriesFilter = `AND p.category_id IN (${categoryIds})`;
|
||||
} else {
|
||||
// categories.length === 0 && !noCategory - no categories selected, show nothing
|
||||
categoriesFilter = `AND 1=0`; // This will match no rows
|
||||
}
|
||||
|
||||
const q = `
|
||||
SELECT p.id,
|
||||
p.name,
|
||||
@@ -368,13 +418,15 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
||||
SUM(total_minutes) AS estimated,
|
||||
color_code
|
||||
FROM projects p
|
||||
LEFT JOIN tasks ON tasks.project_id = p.id ${billableQuery}
|
||||
LEFT JOIN tasks ON tasks.project_id = p.id
|
||||
LEFT JOIN task_work_log ON task_work_log.task_id = tasks.id
|
||||
WHERE p.id IN (${projectIds}) ${durationClause} ${archivedClause}
|
||||
WHERE p.id IN (${projectIds}) ${durationClause} ${archivedClause} ${categoriesFilter} ${billableQuery}
|
||||
GROUP BY p.id, p.name
|
||||
ORDER BY logged_time DESC;`;
|
||||
const result = await db.query(q, []);
|
||||
|
||||
const utilization = (req.body.utilization || []) as string[];
|
||||
|
||||
const data = [];
|
||||
|
||||
for (const project of result.rows) {
|
||||
@@ -401,10 +453,12 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
||||
const projects = (req.body.projects || []) as string[];
|
||||
const projectIds = projects.map(p => `'${p}'`).join(",");
|
||||
|
||||
const categories = (req.body.categories || []) as string[];
|
||||
const noCategory = req.body.noCategory || false;
|
||||
const billable = req.body.billable;
|
||||
|
||||
if (!teamIds || !projectIds.length)
|
||||
return res.status(200).send(new ServerResponse(true, { users: [], projects: [] }));
|
||||
if (!teamIds)
|
||||
return res.status(200).send(new ServerResponse(true, { filteredRows: [], totals: { total_time_logs: "0", total_estimated_hours: "0", total_utilization: "0" } }));
|
||||
|
||||
const { duration, date_range } = req.body;
|
||||
|
||||
@@ -416,7 +470,9 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
||||
endDate = moment(date_range[1]);
|
||||
} else if (duration === DATE_RANGES.ALL_TIME) {
|
||||
// Fetch the earliest start_date (or created_at if null) from selected projects
|
||||
const minDateQuery = `SELECT MIN(COALESCE(start_date, created_at)) as min_date FROM projects WHERE id IN (${projectIds})`;
|
||||
const minDateQuery = projectIds.length > 0
|
||||
? `SELECT MIN(COALESCE(start_date, created_at)) as min_date FROM projects WHERE id IN (${projectIds})`
|
||||
: `SELECT MIN(COALESCE(start_date, created_at)) as min_date FROM projects WHERE team_id IN (${teamIds})`;
|
||||
const minDateResult = await db.query(minDateQuery, []);
|
||||
const minDate = minDateResult.rows[0]?.min_date;
|
||||
startDate = minDate ? moment(minDate) : moment('2000-01-01');
|
||||
@@ -490,46 +546,178 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
||||
const orgWorkingHoursQuery = `SELECT working_hours FROM organizations WHERE id = (SELECT t.organization_id FROM teams t WHERE t.id IN (${teamIds}) LIMIT 1)`;
|
||||
const orgWorkingHoursResult = await db.query(orgWorkingHoursQuery, []);
|
||||
const orgWorkingHours = orgWorkingHoursResult.rows[0]?.working_hours || 8;
|
||||
|
||||
// Calculate total working hours with minimum baseline for non-working day scenarios
|
||||
let totalWorkingHours = workingDays * orgWorkingHours;
|
||||
let isNonWorkingPeriod = false;
|
||||
|
||||
// If no working days but there might be logged time, set minimum baseline
|
||||
// This ensures that time logged on non-working days is treated as over-utilization
|
||||
// Business Logic: If someone works on weekends/holidays when workingDays = 0,
|
||||
// we use a minimal baseline (1 hour) so any logged time results in >100% utilization
|
||||
if (totalWorkingHours === 0) {
|
||||
totalWorkingHours = 1; // Minimal baseline to ensure over-utilization
|
||||
isNonWorkingPeriod = true;
|
||||
}
|
||||
|
||||
const durationClause = this.getDateRangeClause(duration || DATE_RANGES.LAST_WEEK, date_range);
|
||||
const archivedClause = archived
|
||||
? ""
|
||||
: `AND p.id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = p.id AND user_id = '${req.user?.id}') `;
|
||||
|
||||
const billableQuery = this.buildBillableQuery(billable);
|
||||
const billableQuery = this.buildBillableQueryWithAlias(billable, 't');
|
||||
const members = (req.body.members || []) as string[];
|
||||
|
||||
// Prepare members filter - updated logic to handle Clear All scenario
|
||||
let membersFilter = "";
|
||||
if (members.length > 0) {
|
||||
const memberIds = members.map(id => `'${id}'`).join(",");
|
||||
membersFilter = `AND tmiv.team_member_id IN (${memberIds})`;
|
||||
} else {
|
||||
// No members selected - show no data (Clear All scenario)
|
||||
membersFilter = `AND 1=0`; // This will match no rows
|
||||
}
|
||||
|
||||
const q = `
|
||||
SELECT tmiv.email, tmiv.name, SUM(time_spent) AS logged_time
|
||||
FROM team_member_info_view tmiv
|
||||
LEFT JOIN task_work_log ON task_work_log.user_id = tmiv.user_id
|
||||
LEFT JOIN tasks ON tasks.id = task_work_log.task_id ${billableQuery}
|
||||
LEFT JOIN projects p ON p.id = tasks.project_id AND p.team_id = tmiv.team_id
|
||||
WHERE p.id IN (${projectIds})
|
||||
${durationClause} ${archivedClause}
|
||||
GROUP BY tmiv.email, tmiv.name
|
||||
ORDER BY logged_time DESC;`;
|
||||
const result = await db.query(q, []);
|
||||
// Prepare projects filter
|
||||
let projectsFilter = "";
|
||||
if (projectIds.length > 0) {
|
||||
projectsFilter = `AND p.id IN (${projectIds})`;
|
||||
} else {
|
||||
// If no projects are selected, don't show any data
|
||||
projectsFilter = `AND 1=0`; // This will match no rows
|
||||
}
|
||||
|
||||
for (const member of result.rows) {
|
||||
member.value = member.logged_time ? parseFloat(moment.duration(member.logged_time, "seconds").asHours().toFixed(2)) : 0;
|
||||
member.color_code = getColor(member.name);
|
||||
member.total_working_hours = totalWorkingHours;
|
||||
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;
|
||||
// Prepare categories filter - updated logic
|
||||
let categoriesFilter = "";
|
||||
if (categories.length > 0 && noCategory) {
|
||||
// Both specific categories and "No Category" are selected
|
||||
const categoryIds = categories.map(id => `'${id}'`).join(",");
|
||||
categoriesFilter = `AND (p.category_id IS NULL OR p.category_id IN (${categoryIds}))`;
|
||||
} else if (categories.length === 0 && noCategory) {
|
||||
// Only "No Category" is selected
|
||||
categoriesFilter = `AND p.category_id IS NULL`;
|
||||
} else if (categories.length > 0 && !noCategory) {
|
||||
// Only specific categories are selected
|
||||
const categoryIds = categories.map(id => `'${id}'`).join(",");
|
||||
categoriesFilter = `AND p.category_id IN (${categoryIds})`;
|
||||
} else {
|
||||
// categories.length === 0 && !noCategory - no categories selected, show nothing
|
||||
categoriesFilter = `AND 1=0`; // This will match no rows
|
||||
}
|
||||
|
||||
// Create custom duration clause for twl table alias
|
||||
let customDurationClause = "";
|
||||
if (date_range && date_range.length === 2) {
|
||||
const start = moment(date_range[0]).format("YYYY-MM-DD");
|
||||
const end = moment(date_range[1]).format("YYYY-MM-DD");
|
||||
if (start === end) {
|
||||
customDurationClause = `AND twl.created_at::DATE = '${start}'::DATE`;
|
||||
} else {
|
||||
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';
|
||||
// Over/under utilized hours: utilized_hours - total_working_hours
|
||||
const overUnder = member.utilized_hours && member.total_working_hours ? (parseFloat(member.utilized_hours) - member.total_working_hours) : 0;
|
||||
member.over_under_utilized_hours = overUnder.toFixed(2);
|
||||
customDurationClause = `AND twl.created_at::DATE >= '${start}'::DATE AND twl.created_at < '${end}'::DATE + INTERVAL '1 day'`;
|
||||
}
|
||||
} else {
|
||||
const key = duration || DATE_RANGES.LAST_WEEK;
|
||||
if (key === DATE_RANGES.YESTERDAY)
|
||||
customDurationClause = "AND twl.created_at >= (CURRENT_DATE - INTERVAL '1 day')::DATE AND twl.created_at < CURRENT_DATE::DATE";
|
||||
else if (key === DATE_RANGES.LAST_WEEK)
|
||||
customDurationClause = "AND twl.created_at >= (CURRENT_DATE - INTERVAL '1 week')::DATE AND twl.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'";
|
||||
else if (key === DATE_RANGES.LAST_MONTH)
|
||||
customDurationClause = "AND twl.created_at >= (CURRENT_DATE - INTERVAL '1 month')::DATE AND twl.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'";
|
||||
else if (key === DATE_RANGES.LAST_QUARTER)
|
||||
customDurationClause = "AND twl.created_at >= (CURRENT_DATE - INTERVAL '3 months')::DATE AND twl.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'";
|
||||
}
|
||||
|
||||
// Modified query to start from team members and calculate filtered time logs
|
||||
const q = `
|
||||
SELECT
|
||||
tmiv.team_member_id,
|
||||
tmiv.email,
|
||||
tmiv.name,
|
||||
COALESCE(
|
||||
(SELECT SUM(twl.time_spent)
|
||||
FROM task_work_log twl
|
||||
LEFT JOIN tasks t ON t.id = twl.task_id
|
||||
LEFT JOIN projects p ON p.id = t.project_id
|
||||
WHERE twl.user_id = tmiv.user_id
|
||||
${customDurationClause}
|
||||
${projectsFilter}
|
||||
${categoriesFilter}
|
||||
${archivedClause}
|
||||
${billableQuery}
|
||||
AND p.team_id = tmiv.team_id
|
||||
), 0
|
||||
) AS logged_time
|
||||
FROM team_member_info_view tmiv
|
||||
WHERE tmiv.team_id IN (${teamIds})
|
||||
AND tmiv.active = TRUE
|
||||
${membersFilter}
|
||||
GROUP BY tmiv.email, tmiv.name, tmiv.team_member_id, tmiv.user_id, tmiv.team_id
|
||||
ORDER BY logged_time DESC;`;
|
||||
|
||||
const result = await db.query(q, []);
|
||||
const utilization = (req.body.utilization || []) as string[];
|
||||
|
||||
// Precompute totalWorkingHours * 3600 for efficiency
|
||||
const totalWorkingSeconds = totalWorkingHours * 3600;
|
||||
|
||||
// calculate utilization state
|
||||
for (let i = 0, len = result.rows.length; i < len; i++) {
|
||||
const member = result.rows[i];
|
||||
const loggedSeconds = member.logged_time ? parseFloat(member.logged_time) : 0;
|
||||
const utilizedHours = loggedSeconds / 3600;
|
||||
|
||||
// For individual members, use the same logic as total calculation
|
||||
let memberWorkingHours = totalWorkingHours;
|
||||
if (isNonWorkingPeriod && loggedSeconds > 0) {
|
||||
// Any time logged during non-working period should be treated as over-utilization
|
||||
memberWorkingHours = Math.min(utilizedHours, 1); // Use actual time or 1 hour, whichever is smaller
|
||||
}
|
||||
|
||||
const utilizationPercent = memberWorkingHours > 0 && loggedSeconds
|
||||
? ((loggedSeconds / (memberWorkingHours * 3600)) * 100)
|
||||
: 0;
|
||||
const overUnder = utilizedHours - memberWorkingHours;
|
||||
|
||||
member.value = utilizedHours ? parseFloat(utilizedHours.toFixed(2)) : 0;
|
||||
member.color_code = getColor(member.name);
|
||||
member.total_working_hours = memberWorkingHours;
|
||||
member.utilization_percent = utilizationPercent.toFixed(2);
|
||||
member.utilized_hours = utilizedHours.toFixed(2);
|
||||
member.over_under_utilized_hours = overUnder.toFixed(2);
|
||||
|
||||
if (utilizationPercent < 90) {
|
||||
member.utilization_state = 'under';
|
||||
} else if (utilizationPercent <= 110) {
|
||||
member.utilization_state = 'optimal';
|
||||
} else {
|
||||
member.utilization_state = 'over';
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
// Apply utilization filter
|
||||
let filteredRows;
|
||||
if (utilization.length > 0) {
|
||||
// Filter to only show selected utilization states
|
||||
filteredRows = result.rows.filter(member => utilization.includes(member.utilization_state));
|
||||
} else {
|
||||
// No utilization states selected - show no data (Clear All scenario)
|
||||
filteredRows = [];
|
||||
}
|
||||
|
||||
// Calculate totals
|
||||
const total_time_logs = filteredRows.reduce((sum, member) => sum + parseFloat(member.logged_time || '0'), 0);
|
||||
const total_estimated_hours = totalWorkingHours * filteredRows.length; // Total for all members
|
||||
const total_utilization = total_time_logs > 0 && total_estimated_hours > 0
|
||||
? ((total_time_logs / (total_estimated_hours * 3600)) * 100).toFixed(1)
|
||||
: '0';
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, {
|
||||
filteredRows,
|
||||
totals: {
|
||||
total_time_logs: ((total_time_logs / 3600).toFixed(2)).toString(),
|
||||
total_estimated_hours: total_estimated_hours.toString(),
|
||||
total_utilization: total_utilization.toString(),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
@@ -612,6 +800,9 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
||||
|
||||
const projects = (req.body.projects || []) as string[];
|
||||
const projectIds = projects.map(p => `'${p}'`).join(",");
|
||||
|
||||
const categories = (req.body.categories || []) as string[];
|
||||
const noCategory = req.body.noCategory || false;
|
||||
const { type, billable } = req.body;
|
||||
|
||||
if (!teamIds || !projectIds.length)
|
||||
@@ -627,6 +818,33 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
||||
|
||||
const billableQuery = this.buildBillableQuery(billable);
|
||||
|
||||
// Prepare projects filter
|
||||
let projectsFilter = "";
|
||||
if (projectIds.length > 0) {
|
||||
projectsFilter = `AND p.id IN (${projectIds})`;
|
||||
} else {
|
||||
// If no projects are selected, don't show any data
|
||||
projectsFilter = `AND 1=0`; // This will match no rows
|
||||
}
|
||||
|
||||
// Prepare categories filter - updated logic
|
||||
let categoriesFilter = "";
|
||||
if (categories.length > 0 && noCategory) {
|
||||
// Both specific categories and "No Category" are selected
|
||||
const categoryIds = categories.map(id => `'${id}'`).join(",");
|
||||
categoriesFilter = `AND (p.category_id IS NULL OR p.category_id IN (${categoryIds}))`;
|
||||
} else if (categories.length === 0 && noCategory) {
|
||||
// Only "No Category" is selected
|
||||
categoriesFilter = `AND p.category_id IS NULL`;
|
||||
} else if (categories.length > 0 && !noCategory) {
|
||||
// Only specific categories are selected
|
||||
const categoryIds = categories.map(id => `'${id}'`).join(",");
|
||||
categoriesFilter = `AND p.category_id IN (${categoryIds})`;
|
||||
} else {
|
||||
// categories.length === 0 && !noCategory - no categories selected, show nothing
|
||||
categoriesFilter = `AND 1=0`; // This will match no rows
|
||||
}
|
||||
|
||||
const q = `
|
||||
SELECT p.id,
|
||||
p.name,
|
||||
@@ -640,9 +858,9 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
||||
WHERE project_id = p.id) AS estimated,
|
||||
color_code
|
||||
FROM projects p
|
||||
LEFT JOIN tasks ON tasks.project_id = p.id ${billableQuery}
|
||||
LEFT JOIN tasks ON tasks.project_id = p.id
|
||||
LEFT JOIN task_work_log ON task_work_log.task_id = tasks.id
|
||||
WHERE p.id IN (${projectIds}) ${durationClause} ${archivedClause}
|
||||
WHERE p.id IN (${projectIds}) ${durationClause} ${archivedClause} ${categoriesFilter} ${billableQuery}
|
||||
GROUP BY p.id, p.name
|
||||
ORDER BY logged_time DESC;`;
|
||||
const result = await db.query(q, []);
|
||||
|
||||
@@ -31,6 +31,7 @@ export default class ReportingMembersController extends ReportingControllerBase
|
||||
const completedDurationClasue = this.completedDurationFilter(key, dateRange);
|
||||
const overdueActivityLogsClause = this.getActivityLogsOverdue(key, dateRange);
|
||||
const activityLogCreationFilter = this.getActivityLogsCreationClause(key, dateRange);
|
||||
const timeLogDateRangeClause = this.getTimeLogDateRangeClause(key, dateRange);
|
||||
|
||||
const q = `SELECT COUNT(DISTINCT email) AS total,
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON)
|
||||
@@ -100,7 +101,25 @@ export default class ReportingMembersController extends ReportingControllerBase
|
||||
FROM tasks t
|
||||
LEFT JOIN tasks_assignees ta ON t.id = ta.task_id
|
||||
WHERE team_member_id = tmiv.team_member_id
|
||||
AND is_doing((SELECT new_value FROM task_activity_logs tl WHERE tl.task_id = t.id AND tl.attribute_type = 'status' ${activityLogCreationFilter} ORDER BY tl.created_at DESC LIMIT 1)::UUID, t.project_id) ${archivedClause}) AS ongoing_by_activity_logs
|
||||
AND is_doing((SELECT new_value FROM task_activity_logs tl WHERE tl.task_id = t.id AND tl.attribute_type = 'status' ${activityLogCreationFilter} ORDER BY tl.created_at DESC LIMIT 1)::UUID, t.project_id) ${archivedClause}) AS ongoing_by_activity_logs,
|
||||
|
||||
(SELECT COALESCE(SUM(twl.time_spent), 0)
|
||||
FROM task_work_log twl
|
||||
LEFT JOIN tasks t ON twl.task_id = t.id
|
||||
WHERE twl.user_id = (SELECT user_id FROM team_members WHERE id = tmiv.team_member_id)
|
||||
AND t.billable IS TRUE
|
||||
AND t.project_id IN (SELECT id FROM projects WHERE team_id = $1)
|
||||
${timeLogDateRangeClause}
|
||||
${includeArchived ? "" : `AND t.project_id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = t.project_id AND archived_projects.user_id = '${userId}')`}) AS billable_time,
|
||||
|
||||
(SELECT COALESCE(SUM(twl.time_spent), 0)
|
||||
FROM task_work_log twl
|
||||
LEFT JOIN tasks t ON twl.task_id = t.id
|
||||
WHERE twl.user_id = (SELECT user_id FROM team_members WHERE id = tmiv.team_member_id)
|
||||
AND t.billable IS FALSE
|
||||
AND t.project_id IN (SELECT id FROM projects WHERE team_id = $1)
|
||||
${timeLogDateRangeClause}
|
||||
${includeArchived ? "" : `AND t.project_id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = t.project_id AND archived_projects.user_id = '${userId}')`}) AS non_billable_time
|
||||
FROM team_member_info_view tmiv
|
||||
WHERE tmiv.team_id = $1 ${teamsClause}
|
||||
AND tmiv.team_member_id IN (SELECT team_member_id
|
||||
@@ -311,6 +330,30 @@ export default class ReportingMembersController extends ReportingControllerBase
|
||||
return "";
|
||||
}
|
||||
|
||||
protected static getTimeLogDateRangeClause(key: string, dateRange: string[]) {
|
||||
if (dateRange.length === 2) {
|
||||
const start = moment(dateRange[0]).format("YYYY-MM-DD");
|
||||
const end = moment(dateRange[1]).format("YYYY-MM-DD");
|
||||
|
||||
if (start === end) {
|
||||
return `AND twl.created_at::DATE = '${start}'::DATE`;
|
||||
}
|
||||
|
||||
return `AND twl.created_at::DATE >= '${start}'::DATE AND twl.created_at < '${end}'::DATE + INTERVAL '1 day'`;
|
||||
}
|
||||
|
||||
if (key === DATE_RANGES.YESTERDAY)
|
||||
return `AND twl.created_at >= (CURRENT_DATE - INTERVAL '1 day')::DATE AND twl.created_at < CURRENT_DATE::DATE`;
|
||||
if (key === DATE_RANGES.LAST_WEEK)
|
||||
return `AND twl.created_at >= (CURRENT_DATE - INTERVAL '1 week')::DATE AND twl.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'`;
|
||||
if (key === DATE_RANGES.LAST_MONTH)
|
||||
return `AND twl.created_at >= (CURRENT_DATE - INTERVAL '1 month')::DATE AND twl.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'`;
|
||||
if (key === DATE_RANGES.LAST_QUARTER)
|
||||
return `AND twl.created_at >= (CURRENT_DATE - INTERVAL '3 months')::DATE AND twl.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'`;
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
private static formatDuration(duration: moment.Duration) {
|
||||
const empty = "0h 0m";
|
||||
let format = "";
|
||||
@@ -423,6 +466,8 @@ export default class ReportingMembersController extends ReportingControllerBase
|
||||
{ header: "Overdue Tasks", key: "overdue_tasks", width: 20 },
|
||||
{ header: "Completed Tasks", key: "completed_tasks", width: 20 },
|
||||
{ header: "Ongoing Tasks", key: "ongoing_tasks", width: 20 },
|
||||
{ header: "Billable Time (seconds)", key: "billable_time", width: 25 },
|
||||
{ header: "Non-Billable Time (seconds)", key: "non_billable_time", width: 25 },
|
||||
{ header: "Done Tasks(%)", key: "done_tasks", width: 20 },
|
||||
{ header: "Doing Tasks(%)", key: "doing_tasks", width: 20 },
|
||||
{ header: "Todo Tasks(%)", key: "todo_tasks", width: 20 }
|
||||
@@ -430,14 +475,14 @@ export default class ReportingMembersController extends ReportingControllerBase
|
||||
|
||||
// set title
|
||||
sheet.getCell("A1").value = `Members from ${teamName}`;
|
||||
sheet.mergeCells("A1:K1");
|
||||
sheet.mergeCells("A1:M1");
|
||||
sheet.getCell("A1").alignment = { horizontal: "center" };
|
||||
sheet.getCell("A1").style.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "D9D9D9" } };
|
||||
sheet.getCell("A1").font = { size: 16 };
|
||||
|
||||
// set export date
|
||||
sheet.getCell("A2").value = `Exported on : ${exportDate}`;
|
||||
sheet.mergeCells("A2:K2");
|
||||
sheet.mergeCells("A2:M2");
|
||||
sheet.getCell("A2").alignment = { horizontal: "center" };
|
||||
sheet.getCell("A2").style.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "F2F2F2" } };
|
||||
sheet.getCell("A2").font = { size: 12 };
|
||||
@@ -447,7 +492,7 @@ export default class ReportingMembersController extends ReportingControllerBase
|
||||
sheet.mergeCells("A3:D3");
|
||||
|
||||
// set table headers
|
||||
sheet.getRow(5).values = ["Member", "Email", "Tasks Assigned", "Overdue Tasks", "Completed Tasks", "Ongoing Tasks", "Done Tasks(%)", "Doing Tasks(%)", "Todo Tasks(%)"];
|
||||
sheet.getRow(5).values = ["Member", "Email", "Tasks Assigned", "Overdue Tasks", "Completed Tasks", "Ongoing Tasks", "Billable Time (seconds)", "Non-Billable Time (seconds)", "Done Tasks(%)", "Doing Tasks(%)", "Todo Tasks(%)"];
|
||||
sheet.getRow(5).font = { bold: true };
|
||||
|
||||
for (const member of result.members) {
|
||||
@@ -458,6 +503,8 @@ export default class ReportingMembersController extends ReportingControllerBase
|
||||
overdue_tasks: member.overdue,
|
||||
completed_tasks: member.completed,
|
||||
ongoing_tasks: member.ongoing,
|
||||
billable_time: member.billable_time || 0,
|
||||
non_billable_time: member.non_billable_time || 0,
|
||||
done_tasks: member.completed,
|
||||
doing_tasks: member.ongoing_by_activity_logs,
|
||||
todo_tasks: member.todo_by_activity_logs
|
||||
|
||||
@@ -28,32 +28,50 @@ export default class TaskWorklogController extends WorklenzControllerBase {
|
||||
if (!id) return [];
|
||||
|
||||
const q = `
|
||||
WITH time_logs AS (
|
||||
--
|
||||
SELECT id,
|
||||
description,
|
||||
time_spent,
|
||||
created_at,
|
||||
user_id,
|
||||
logged_by_timer,
|
||||
(SELECT name FROM users WHERE users.id = task_work_log.user_id) AS user_name,
|
||||
(SELECT email FROM users WHERE users.id = task_work_log.user_id) AS user_email,
|
||||
(SELECT avatar_url FROM users WHERE users.id = task_work_log.user_id) AS avatar_url
|
||||
FROM task_work_log
|
||||
WHERE task_id = $1
|
||||
--
|
||||
WITH RECURSIVE task_hierarchy AS (
|
||||
-- Base case: Start with the given task
|
||||
SELECT id, name, 0 as level
|
||||
FROM tasks
|
||||
WHERE id = $1
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Recursive case: Get all subtasks
|
||||
SELECT t.id, t.name, th.level + 1
|
||||
FROM tasks t
|
||||
INNER JOIN task_hierarchy th ON t.parent_task_id = th.id
|
||||
WHERE t.archived IS FALSE
|
||||
),
|
||||
time_logs AS (
|
||||
SELECT
|
||||
twl.id,
|
||||
twl.description,
|
||||
twl.time_spent,
|
||||
twl.created_at,
|
||||
twl.user_id,
|
||||
twl.logged_by_timer,
|
||||
twl.task_id,
|
||||
th.name AS task_name,
|
||||
(SELECT name FROM users WHERE users.id = twl.user_id) AS user_name,
|
||||
(SELECT email FROM users WHERE users.id = twl.user_id) AS user_email,
|
||||
(SELECT avatar_url FROM users WHERE users.id = twl.user_id) AS avatar_url
|
||||
FROM task_work_log twl
|
||||
INNER JOIN task_hierarchy th ON twl.task_id = th.id
|
||||
)
|
||||
SELECT id,
|
||||
time_spent,
|
||||
description,
|
||||
created_at,
|
||||
user_id,
|
||||
logged_by_timer,
|
||||
created_at AS start_time,
|
||||
(created_at + INTERVAL '1 second' * time_spent) AS end_time,
|
||||
user_name,
|
||||
user_email,
|
||||
avatar_url
|
||||
SELECT
|
||||
id,
|
||||
time_spent,
|
||||
description,
|
||||
created_at,
|
||||
user_id,
|
||||
logged_by_timer,
|
||||
task_id,
|
||||
task_name,
|
||||
created_at AS start_time,
|
||||
(created_at + INTERVAL '1 second' * time_spent) AS end_time,
|
||||
user_name,
|
||||
user_email,
|
||||
avatar_url
|
||||
FROM time_logs
|
||||
ORDER BY created_at DESC;
|
||||
`;
|
||||
@@ -143,6 +161,7 @@ export default class TaskWorklogController extends WorklenzControllerBase {
|
||||
};
|
||||
|
||||
sheet.columns = [
|
||||
{header: "Task Name", key: "task_name", width: 30},
|
||||
{header: "Reporter Name", key: "user_name", width: 25},
|
||||
{header: "Reporter Email", key: "user_email", width: 25},
|
||||
{header: "Start Time", key: "start_time", width: 25},
|
||||
@@ -153,14 +172,15 @@ export default class TaskWorklogController extends WorklenzControllerBase {
|
||||
];
|
||||
|
||||
sheet.getCell("A1").value = metadata.project_name;
|
||||
sheet.mergeCells("A1:G1");
|
||||
sheet.mergeCells("A1:H1");
|
||||
sheet.getCell("A1").alignment = {horizontal: "center"};
|
||||
|
||||
sheet.getCell("A2").value = `${metadata.name} (${exportDate})`;
|
||||
sheet.mergeCells("A2:G2");
|
||||
sheet.mergeCells("A2:H2");
|
||||
sheet.getCell("A2").alignment = {horizontal: "center"};
|
||||
|
||||
sheet.getRow(4).values = [
|
||||
"Task Name",
|
||||
"Reporter Name",
|
||||
"Reporter Email",
|
||||
"Start Time",
|
||||
@@ -176,6 +196,7 @@ export default class TaskWorklogController extends WorklenzControllerBase {
|
||||
for (const item of results) {
|
||||
totalLogged += parseFloat((item.time_spent || 0).toString());
|
||||
const data = {
|
||||
task_name: item.task_name,
|
||||
user_name: item.user_name,
|
||||
user_email: item.user_email,
|
||||
start_time: moment(item.start_time).add(timezone.hours || 0, "hours").add(timezone.minutes || 0, "minutes").format(timeFormat),
|
||||
@@ -210,6 +231,7 @@ export default class TaskWorklogController extends WorklenzControllerBase {
|
||||
};
|
||||
|
||||
sheet.addRow({
|
||||
task_name: "",
|
||||
user_name: "",
|
||||
user_email: "",
|
||||
start_time: "Total",
|
||||
@@ -219,7 +241,7 @@ export default class TaskWorklogController extends WorklenzControllerBase {
|
||||
time_spent: formatDuration(moment.duration(totalLogged, "seconds")),
|
||||
});
|
||||
|
||||
sheet.mergeCells(`A${sheet.rowCount}:F${sheet.rowCount}`);
|
||||
sheet.mergeCells(`A${sheet.rowCount}:G${sheet.rowCount}`);
|
||||
|
||||
sheet.getCell(`A${sheet.rowCount}`).value = "Total";
|
||||
sheet.getCell(`A${sheet.rowCount}`).alignment = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
import {getColor} from "../shared/utils";
|
||||
import {PriorityColorCodes, TASK_PRIORITY_COLOR_ALPHA, TASK_STATUS_COLOR_ALPHA} from "../shared/constants";
|
||||
import { getColor } from "../shared/utils";
|
||||
import { PriorityColorCodes, TASK_PRIORITY_COLOR_ALPHA, TASK_STATUS_COLOR_ALPHA } from "../shared/constants";
|
||||
import moment from "moment/moment";
|
||||
|
||||
export const GroupBy = {
|
||||
@@ -32,23 +32,14 @@ export default class TasksControllerBase extends WorklenzControllerBase {
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
task.progress = task.complete_ratio || 0;
|
||||
|
||||
|
||||
// Important: Parent tasks should not have manual progress
|
||||
// If they somehow do, reset it
|
||||
if (task.manual_progress) {
|
||||
console.log(` WARNING: Parent task ${task.id} had manual_progress set to true, resetting`);
|
||||
task.manual_progress = false;
|
||||
task.progress_value = null;
|
||||
}
|
||||
@@ -58,28 +49,29 @@ export default class TasksControllerBase extends WorklenzControllerBase {
|
||||
// For manually set progress, use that value directly
|
||||
task.progress = 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, calculate based on time
|
||||
}
|
||||
// For tasks with no subtasks and no manual progress
|
||||
else {
|
||||
task.progress = task.total_minutes_spent && task.total_minutes
|
||||
? ~~(task.total_minutes_spent / task.total_minutes * 100)
|
||||
: 0;
|
||||
|
||||
// Only calculate progress based on time if time-based progress is enabled for the project
|
||||
if (task.project_use_time_progress && task.total_minutes_spent && task.total_minutes) {
|
||||
// Cap the progress at 100% to prevent showing more than 100% progress
|
||||
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
|
||||
task.complete_ratio = task.progress;
|
||||
|
||||
console.log(` Calculated time-based progress: progress=${task.progress}, complete_ratio=${task.complete_ratio}`);
|
||||
}
|
||||
|
||||
|
||||
// Ensure numeric values
|
||||
task.progress = parseInt(task.progress) || 0;
|
||||
task.complete_ratio = parseInt(task.complete_ratio) || 0;
|
||||
|
||||
|
||||
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.attachments_count = Number(task.attachments_count) ? +task.attachments_count : 0;
|
||||
@@ -89,7 +81,31 @@ export default class TasksControllerBase extends WorklenzControllerBase {
|
||||
task.is_sub_task = !!task.parent_task_id;
|
||||
|
||||
task.time_spent_string = `${task.time_spent.hours}h ${(task.time_spent.minutes)}m`;
|
||||
task.total_time_string = `${~~(task.total_minutes / 60)}h ${(task.total_minutes % 60)}m`;
|
||||
|
||||
// Use recursive estimation for parent tasks, own estimation for leaf tasks
|
||||
const recursiveEstimation = task.recursive_estimation || {};
|
||||
const hasSubtasks = (task.sub_tasks_count || 0) > 0;
|
||||
|
||||
let displayMinutes;
|
||||
if (hasSubtasks) {
|
||||
// For parent tasks, use recursive estimation (sum of all subtasks)
|
||||
displayMinutes = recursiveEstimation.recursive_total_minutes || 0;
|
||||
} else {
|
||||
// For leaf tasks, use their own estimation
|
||||
displayMinutes = task.total_minutes || 0;
|
||||
}
|
||||
|
||||
// Format time string - show "0h" for zero time instead of "0h 0m"
|
||||
const hours = ~~(displayMinutes / 60);
|
||||
const minutes = displayMinutes % 60;
|
||||
|
||||
if (displayMinutes === 0) {
|
||||
task.total_time_string = "0h";
|
||||
} else if (minutes === 0) {
|
||||
task.total_time_string = `${hours}h`;
|
||||
} else {
|
||||
task.total_time_string = `${hours}h ${minutes}m`;
|
||||
}
|
||||
|
||||
task.name_color = getColor(task.name);
|
||||
task.priority_color = PriorityColorCodes[task.priority_value] || PriorityColorCodes["0"];
|
||||
|
||||
@@ -97,7 +97,6 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
||||
try {
|
||||
const result = await db.query("SELECT get_task_complete_ratio($1) AS info;", [taskId]);
|
||||
const [data] = result.rows;
|
||||
console.log("data", data);
|
||||
if (data && data.info && data.info.ratio !== undefined) {
|
||||
data.info.ratio = +((data.info.ratio || 0).toFixed());
|
||||
return data.info;
|
||||
@@ -259,6 +258,7 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
||||
(SELECT id FROM task_priorities WHERE id = t.priority_id) AS priority,
|
||||
(SELECT value FROM task_priorities WHERE id = t.priority_id) AS priority_value,
|
||||
total_minutes,
|
||||
(SELECT get_task_recursive_estimation(t.id)) AS recursive_estimation,
|
||||
(SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id) AS total_minutes_spent,
|
||||
created_at,
|
||||
updated_at,
|
||||
|
||||
@@ -427,9 +427,24 @@ export default class TasksController extends TasksControllerBase {
|
||||
|
||||
task.names = WorklenzControllerBase.createTagList(task.assignees);
|
||||
|
||||
const totalMinutes = task.total_minutes;
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
// Use recursive estimation if task has subtasks, otherwise use own estimation
|
||||
const recursiveEstimation = task.recursive_estimation || {};
|
||||
// Check both the recursive estimation count and the actual database count
|
||||
const hasSubtasks = (task.sub_tasks_count || 0) > 0;
|
||||
|
||||
let totalMinutes, hours, minutes;
|
||||
|
||||
if (hasSubtasks) {
|
||||
// For parent tasks, use the sum of all subtasks' estimation (excluding parent's own estimation)
|
||||
totalMinutes = recursiveEstimation.recursive_total_minutes || 0;
|
||||
hours = recursiveEstimation.recursive_total_hours || 0;
|
||||
minutes = recursiveEstimation.recursive_remaining_minutes || 0;
|
||||
} else {
|
||||
// For tasks without subtasks, use their own estimation
|
||||
totalMinutes = task.total_minutes || 0;
|
||||
hours = Math.floor(totalMinutes / 60);
|
||||
minutes = totalMinutes % 60;
|
||||
}
|
||||
|
||||
task.total_hours = hours;
|
||||
task.total_minutes = minutes;
|
||||
@@ -608,6 +623,18 @@ export default class TasksController extends TasksControllerBase {
|
||||
return res.status(200).send(new ServerResponse(true, null));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async resetParentTaskEstimations(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `SELECT reset_all_parent_task_estimations() AS updated_count;`;
|
||||
const result = await db.query(q);
|
||||
const [data] = result.rows;
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, {
|
||||
message: `Reset estimation for ${data.updated_count} parent tasks`,
|
||||
updated_count: data.updated_count
|
||||
}));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async bulkAssignMembers(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { tasks, members, project_id } = req.body;
|
||||
|
||||
@@ -7,5 +7,5 @@ export function startCronJobs() {
|
||||
startNotificationsJob();
|
||||
startDailyDigestJob();
|
||||
startProjectDigestJob();
|
||||
// startRecurringTasksJob();
|
||||
if (process.env.ENABLE_RECURRING_JOBS === "true") startRecurringTasksJob();
|
||||
}
|
||||
|
||||
@@ -7,12 +7,90 @@ 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.
|
||||
// const TIME = "0 11 */1 * 1-5";
|
||||
const TIME = "*/2 * * * *"; // runs every 2 minutes - for testing purposes
|
||||
const TIME = process.env.RECURRING_JOBS_INTERVAL || "0 11 */1 * 1-5";
|
||||
const TIME_FORMAT = "YYYY-MM-DD";
|
||||
// const TIME = "0 0 * * *"; // Runs at midnight every day
|
||||
|
||||
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() {
|
||||
try {
|
||||
log("(cron) Recurring tasks job started.");
|
||||
@@ -33,65 +111,44 @@ async function onRecurringTaskJobTick() {
|
||||
? moment(template.last_task_end_date)
|
||||
: moment(template.created_at);
|
||||
|
||||
const futureLimit = moment(template.last_checked_at || template.created_at).add(1, "week");
|
||||
// Calculate future limit based on schedule type
|
||||
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);
|
||||
const endDatesToCreate: moment.Moment[] = [];
|
||||
|
||||
// Find the next future occurrence
|
||||
while (nextEndDate.isSameOrBefore(now)) {
|
||||
// Find all future occurrences within the limit
|
||||
while (nextEndDate.isSameOrBefore(futureLimit)) {
|
||||
if (nextEndDate.isAfter(now)) {
|
||||
endDatesToCreate.push(moment(nextEndDate));
|
||||
}
|
||||
nextEndDate = calculateNextEndDate(template, nextEndDate);
|
||||
}
|
||||
|
||||
// Only create a task if it's within the future limit
|
||||
if (nextEndDate.isSameOrBefore(futureLimit)) {
|
||||
const existingTaskQuery = `
|
||||
SELECT id FROM tasks
|
||||
WHERE schedule_id = $1 AND end_date::DATE = $2::DATE;
|
||||
// Batch create tasks for all future dates
|
||||
if (endDatesToCreate.length > 0) {
|
||||
const createdTasks = await createBatchTasks(template, endDatesToCreate);
|
||||
createdTaskCount += createdTasks.length;
|
||||
|
||||
// Update the last_checked_at in the schedule
|
||||
const updateScheduleQuery = `
|
||||
UPDATE task_recurring_schedules
|
||||
SET last_checked_at = $1::DATE,
|
||||
last_created_task_end_date = $2
|
||||
WHERE id = $3;
|
||||
`;
|
||||
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`);
|
||||
}
|
||||
await db.query(updateScheduleQuery, [
|
||||
moment().format(TIME_FORMAT),
|
||||
endDatesToCreate[endDatesToCreate.length - 1].format(TIME_FORMAT),
|
||||
template.schedule_id
|
||||
]);
|
||||
} else {
|
||||
console.log(`No task created for template ${template.name} - next occurrence is beyond the future limit`);
|
||||
console.log(`No tasks created for template ${template.name} - next occurrence is beyond the future limit`);
|
||||
}
|
||||
|
||||
// Update the last_checked_at in the schedule
|
||||
const updateScheduleQuery = `
|
||||
UPDATE task_recurring_schedules
|
||||
SET last_checked_at = $1::DATE, last_created_task_end_date = $2
|
||||
WHERE id = $3;
|
||||
`;
|
||||
await db.query(updateScheduleQuery, [moment(template.last_checked_at || template.created_at).add(1, "day").format(TIME_FORMAT), nextEndDate.format(TIME_FORMAT), template.schedule_id]);
|
||||
}
|
||||
|
||||
log(`(cron) Recurring tasks job ended with ${createdTaskCount} new tasks created.`);
|
||||
|
||||
@@ -6,11 +6,11 @@ import { isProduction } from "../shared/utils";
|
||||
const pgSession = require("connect-pg-simple")(session);
|
||||
|
||||
export default session({
|
||||
name: process.env.SESSION_NAME,
|
||||
name: process.env.SESSION_NAME || "worklenz.sid",
|
||||
secret: process.env.SESSION_SECRET || "development-secret-key",
|
||||
proxy: false,
|
||||
resave: false,
|
||||
saveUninitialized: true,
|
||||
resave: true,
|
||||
saveUninitialized: false,
|
||||
rolling: true,
|
||||
store: new pgSession({
|
||||
pool: db.pool,
|
||||
@@ -18,10 +18,8 @@ export default session({
|
||||
}),
|
||||
cookie: {
|
||||
path: "/",
|
||||
// secure: isProduction(),
|
||||
// httpOnly: isProduction(),
|
||||
// sameSite: "none",
|
||||
// domain: isProduction() ? ".worklenz.com" : undefined,
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import {NextFunction} from "express";
|
||||
|
||||
import {IWorkLenzRequest} from "../../interfaces/worklenz-request";
|
||||
import {IWorkLenzResponse} from "../../interfaces/worklenz-response";
|
||||
import {ServerResponse} from "../../models/server-response";
|
||||
|
||||
export default function (req: IWorkLenzRequest, res: IWorkLenzResponse, next: NextFunction): IWorkLenzResponse | void {
|
||||
const {name} = req.body;
|
||||
if (!name || name.trim() === "")
|
||||
return res.status(200).send(new ServerResponse(false, null, "Name is required"));
|
||||
|
||||
req.body.name = req.body.name.trim();
|
||||
|
||||
return next();
|
||||
}
|
||||
@@ -30,6 +30,7 @@ export async function deserialize(user: { id: string | null }, done: IDeserializ
|
||||
const excludedSubscriptionTypes = ["TRIAL", "PADDLE"];
|
||||
const q = `SELECT deserialize_user($1) AS user;`;
|
||||
const result = await db.query(q, [id]);
|
||||
|
||||
if (result.rows.length) {
|
||||
const [data] = result.rows;
|
||||
if (data?.user) {
|
||||
|
||||
@@ -3,13 +3,16 @@ import { Strategy as LocalStrategy } from "passport-local";
|
||||
import { log_error } from "../../shared/utils";
|
||||
import db from "../../config/db";
|
||||
import { Request } from "express";
|
||||
import { ERROR_KEY, SUCCESS_KEY } from "./passport-constants";
|
||||
|
||||
async function handleLogin(req: Request, email: string, password: string, done: any) {
|
||||
console.log("Login attempt for:", email);
|
||||
// Clear any existing flash messages
|
||||
(req.session as any).flash = {};
|
||||
|
||||
if (!email || !password) {
|
||||
console.log("Missing credentials");
|
||||
return done(null, false, { message: "Please enter both email and password" });
|
||||
const errorMsg = "Please enter both email and password";
|
||||
req.flash(ERROR_KEY, errorMsg);
|
||||
return done(null, false);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -19,25 +22,28 @@ async function handleLogin(req: Request, email: string, password: string, done:
|
||||
AND google_id IS NULL
|
||||
AND is_deleted IS FALSE;`;
|
||||
const result = await db.query(q, [email]);
|
||||
console.log("User query result count:", result.rowCount);
|
||||
|
||||
const [data] = result.rows;
|
||||
|
||||
if (!data?.password) {
|
||||
console.log("No account found");
|
||||
return done(null, false, { message: "No account found with this email" });
|
||||
const errorMsg = "No account found with this email";
|
||||
req.flash(ERROR_KEY, errorMsg);
|
||||
return done(null, false);
|
||||
}
|
||||
|
||||
const passwordMatch = bcrypt.compareSync(password, data.password);
|
||||
console.log("Password match:", passwordMatch);
|
||||
|
||||
if (passwordMatch && email === data.email) {
|
||||
delete data.password;
|
||||
return done(null, data, {message: "User successfully logged in"});
|
||||
const successMsg = "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) {
|
||||
console.error("Login error:", error);
|
||||
log_error(error, req.body);
|
||||
return done(error);
|
||||
}
|
||||
|
||||
@@ -47,41 +47,55 @@ async function handleSignUp(req: Request, email: string, password: string, done:
|
||||
// team = Invited team_id if req.body.from_invitation is true
|
||||
const {name, team_name, team_member_id, team_id, timezone} = req.body;
|
||||
|
||||
if (!team_name) return done(null, null, req.flash(ERROR_KEY, "Team name is required"));
|
||||
if (!team_name) {
|
||||
req.flash(ERROR_KEY, "Team name is required");
|
||||
return done(null, null, {message: "Team name is required"});
|
||||
}
|
||||
|
||||
const googleAccountFound = await isGoogleAccountFound(email);
|
||||
if (googleAccountFound)
|
||||
return done(null, null, req.flash(ERROR_KEY, `${req.body.email} is already linked with a Google account.`));
|
||||
if (googleAccountFound) {
|
||||
req.flash(ERROR_KEY, `${req.body.email} is already linked with a Google account.`);
|
||||
return done(null, null, {message: `${req.body.email} is already linked with a Google account.`});
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await registerUser(password, team_id, name, team_name, email, timezone, team_member_id);
|
||||
sendWelcomeEmail(email, name);
|
||||
return done(null, user, req.flash(SUCCESS_KEY, "Registration successful. Please check your email for verification."));
|
||||
req.flash(SUCCESS_KEY, "Registration successful. Please check your email for verification.");
|
||||
return done(null, user, {message: "Registration successful. Please check your email for verification."});
|
||||
} catch (error: any) {
|
||||
const message = (error?.message) || "";
|
||||
|
||||
if (message === "ERROR_INVALID_JOINING_EMAIL") {
|
||||
return done(null, null, req.flash(ERROR_KEY, `No invitations found for email ${req.body.email}.`));
|
||||
req.flash(ERROR_KEY, `No invitations found for email ${req.body.email}.`);
|
||||
return done(null, null, {message: `No invitations found for email ${req.body.email}.`});
|
||||
}
|
||||
|
||||
// if error.message is "email already exists" then it should have the email address in the error message after ":".
|
||||
if (message.includes("EMAIL_EXISTS_ERROR") || error.constraint === "users_google_id_uindex") {
|
||||
const [, value] = error.message.split(":");
|
||||
return done(null, null, req.flash(ERROR_KEY, `Worklenz account already exists for email ${value}.`));
|
||||
const errorMsg = `Worklenz account already exists for email ${value}.`;
|
||||
req.flash(ERROR_KEY, errorMsg);
|
||||
return done(null, null, {message: errorMsg});
|
||||
}
|
||||
|
||||
if (message.includes("TEAM_NAME_EXISTS_ERROR")) {
|
||||
const [, value] = error.message.split(":");
|
||||
return done(null, null, req.flash(ERROR_KEY, `Team name "${value}" already exists. Please choose a different team name.`));
|
||||
const errorMsg = `Team name "${value}" already exists. Please choose a different team name.`;
|
||||
req.flash(ERROR_KEY, errorMsg);
|
||||
return done(null, null, {message: errorMsg});
|
||||
}
|
||||
|
||||
// The Team name is already taken.
|
||||
if (error.constraint === "teams_url_uindex" || error.constraint === "teams_name_uindex") {
|
||||
return done(null, null, req.flash(ERROR_KEY, `Team name "${team_name}" is already taken. Please choose a different team name.`));
|
||||
const errorMsg = `Team name "${team_name}" is already taken. Please choose a different team name.`;
|
||||
req.flash(ERROR_KEY, errorMsg);
|
||||
return done(null, null, {message: errorMsg});
|
||||
}
|
||||
|
||||
log_error(error, req.body);
|
||||
return done(null, null, req.flash(ERROR_KEY, DEFAULT_ERROR_MESSAGE));
|
||||
req.flash(ERROR_KEY, DEFAULT_ERROR_MESSAGE);
|
||||
return done(null, null, {message: DEFAULT_ERROR_MESSAGE});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,120 +1,128 @@
|
||||
import express from "express";
|
||||
|
||||
import AccessControlsController from "../../controllers/access-controls-controller";
|
||||
import AuthController from "../../controllers/auth-controller";
|
||||
import LogsController from "../../controllers/logs-controller";
|
||||
import OverviewController from "../../controllers/overview-controller";
|
||||
import TaskPrioritiesController from "../../controllers/task-priorities-controller";
|
||||
|
||||
import attachmentsApiRouter from "./attachments-api-router";
|
||||
import clientsApiRouter from "./clients-api-router";
|
||||
import jobTitlesApiRouter from "./job-titles-api-router";
|
||||
import notificationsApiRouter from "./notifications-api-router";
|
||||
import personalOverviewApiRouter from "./personal-overview-api-router";
|
||||
import projectMembersApiRouter from "./project-members-api-router";
|
||||
import projectsApiRouter from "./projects-api-router";
|
||||
import settingsApiRouter from "./settings-api-router";
|
||||
import statusesApiRouter from "./statuses-api-router";
|
||||
import subTasksApiRouter from "./sub-tasks-api-router";
|
||||
import taskCommentsApiRouter from "./task-comments-api-router";
|
||||
import taskWorkLogApiRouter from "./task-work-log-api-router";
|
||||
import tasksApiRouter from "./tasks-api-router";
|
||||
import teamMembersApiRouter from "./team-members-api-router";
|
||||
import teamsApiRouter from "./teams-api-router";
|
||||
import timezonesApiRouter from "./timezones-api-router";
|
||||
import todoListApiRouter from "./todo-list-api-router";
|
||||
import projectStatusesApiRouter from "./project-statuses-api-router";
|
||||
import labelsApiRouter from "./labels-api-router";
|
||||
import sharedProjectsApiRouter from "./shared-projects-api-router";
|
||||
import resourceAllocationApiRouter from "./resource-allocation-api-router";
|
||||
import taskTemplatesApiRouter from "./task-templates-api-router";
|
||||
import projectInsightsApiRouter from "./project-insights-api-router";
|
||||
import passwordValidator from "../../middlewares/validators/password-validator";
|
||||
import adminCenterApiRouter from "./admin-center-api-router";
|
||||
import reportingApiRouter from "./reporting-api-router";
|
||||
import activityLogsApiRouter from "./activity-logs-api-router";
|
||||
import safeControllerFunction from "../../shared/safe-controller-function";
|
||||
import projectFoldersApiRouter from "./project-folders-api-router";
|
||||
import taskPhasesApiRouter from "./task-phases-api-router";
|
||||
import projectCategoriesApiRouter from "./project-categories-api-router";
|
||||
import homePageApiRouter from "./home-page-api-router";
|
||||
import ganttApiRouter from "./gantt-api-router";
|
||||
import projectCommentsApiRouter from "./project-comments-api-router";
|
||||
import reportingExportApiRouter from "./reporting-export-api-router";
|
||||
import projectHealthsApiRouter from "./project-healths-api-router";
|
||||
import ptTasksApiRouter from "./pt-tasks-api-router";
|
||||
import projectTemplatesApiRouter from "./project-templates-api";
|
||||
import ptTaskPhasesApiRouter from "./pt_task-phases-api-router";
|
||||
import ptStatusesApiRouter from "./pt-statuses-api-router";
|
||||
import workloadApiRouter from "./gannt-apis/workload-api-router";
|
||||
import roadmapApiRouter from "./gannt-apis/roadmap-api-router";
|
||||
import scheduleApiRouter from "./gannt-apis/schedule-api-router";
|
||||
import scheduleApiV2Router from "./gannt-apis/schedule-api-v2-router";
|
||||
import projectManagerApiRouter from "./project-managers-api-router";
|
||||
|
||||
import billingApiRouter from "./billing-api-router";
|
||||
import taskDependenciesApiRouter from "./task-dependencies-api-router";
|
||||
|
||||
import taskRecurringApiRouter from "./task-recurring-api-router";
|
||||
|
||||
import express from "express";
|
||||
|
||||
import AccessControlsController from "../../controllers/access-controls-controller";
|
||||
import AuthController from "../../controllers/auth-controller";
|
||||
import LogsController from "../../controllers/logs-controller";
|
||||
import OverviewController from "../../controllers/overview-controller";
|
||||
import TaskPrioritiesController from "../../controllers/task-priorities-controller";
|
||||
|
||||
import attachmentsApiRouter from "./attachments-api-router";
|
||||
import clientsApiRouter from "./clients-api-router";
|
||||
import jobTitlesApiRouter from "./job-titles-api-router";
|
||||
import notificationsApiRouter from "./notifications-api-router";
|
||||
import personalOverviewApiRouter from "./personal-overview-api-router";
|
||||
import projectMembersApiRouter from "./project-members-api-router";
|
||||
import projectsApiRouter from "./projects-api-router";
|
||||
import settingsApiRouter from "./settings-api-router";
|
||||
import statusesApiRouter from "./statuses-api-router";
|
||||
import subTasksApiRouter from "./sub-tasks-api-router";
|
||||
import taskCommentsApiRouter from "./task-comments-api-router";
|
||||
import taskWorkLogApiRouter from "./task-work-log-api-router";
|
||||
import tasksApiRouter from "./tasks-api-router";
|
||||
import teamMembersApiRouter from "./team-members-api-router";
|
||||
import teamsApiRouter from "./teams-api-router";
|
||||
import timezonesApiRouter from "./timezones-api-router";
|
||||
import todoListApiRouter from "./todo-list-api-router";
|
||||
import projectStatusesApiRouter from "./project-statuses-api-router";
|
||||
import labelsApiRouter from "./labels-api-router";
|
||||
import sharedProjectsApiRouter from "./shared-projects-api-router";
|
||||
import resourceAllocationApiRouter from "./resource-allocation-api-router";
|
||||
import taskTemplatesApiRouter from "./task-templates-api-router";
|
||||
import projectInsightsApiRouter from "./project-insights-api-router";
|
||||
import passwordValidator from "../../middlewares/validators/password-validator";
|
||||
import adminCenterApiRouter from "./admin-center-api-router";
|
||||
import reportingApiRouter from "./reporting-api-router";
|
||||
import activityLogsApiRouter from "./activity-logs-api-router";
|
||||
import safeControllerFunction from "../../shared/safe-controller-function";
|
||||
import projectFoldersApiRouter from "./project-folders-api-router";
|
||||
import taskPhasesApiRouter from "./task-phases-api-router";
|
||||
import projectCategoriesApiRouter from "./project-categories-api-router";
|
||||
import homePageApiRouter from "./home-page-api-router";
|
||||
import ganttApiRouter from "./gantt-api-router";
|
||||
import projectCommentsApiRouter from "./project-comments-api-router";
|
||||
import reportingExportApiRouter from "./reporting-export-api-router";
|
||||
import projectHealthsApiRouter from "./project-healths-api-router";
|
||||
import ptTasksApiRouter from "./pt-tasks-api-router";
|
||||
import projectTemplatesApiRouter from "./project-templates-api";
|
||||
import ptTaskPhasesApiRouter from "./pt_task-phases-api-router";
|
||||
import ptStatusesApiRouter from "./pt-statuses-api-router";
|
||||
import workloadApiRouter from "./gannt-apis/workload-api-router";
|
||||
import roadmapApiRouter from "./gannt-apis/roadmap-api-router";
|
||||
import scheduleApiRouter from "./gannt-apis/schedule-api-router";
|
||||
import scheduleApiV2Router from "./gannt-apis/schedule-api-v2-router";
|
||||
import projectManagerApiRouter from "./project-managers-api-router";
|
||||
|
||||
import billingApiRouter from "./billing-api-router";
|
||||
import taskDependenciesApiRouter from "./task-dependencies-api-router";
|
||||
|
||||
import taskRecurringApiRouter from "./task-recurring-api-router";
|
||||
|
||||
import customColumnsApiRouter from "./custom-columns-api-router";
|
||||
import ratecardApiRouter from "./ratecard-api-router";
|
||||
import projectRatecardApiRouter from "./project-ratecard-api-router";
|
||||
import projectFinanceApiRouter from "./project-finance-api-router";
|
||||
|
||||
const api = express.Router();
|
||||
|
||||
api.use("/projects", projectsApiRouter);
|
||||
api.use("/team-members", teamMembersApiRouter);
|
||||
api.use("/job-titles", jobTitlesApiRouter);
|
||||
api.use("/clients", clientsApiRouter);
|
||||
api.use("/rate-cards", ratecardApiRouter);
|
||||
api.use("/project-rate-cards", projectRatecardApiRouter);
|
||||
api.use("/teams", teamsApiRouter);
|
||||
api.use("/tasks", tasksApiRouter);
|
||||
api.use("/settings", settingsApiRouter);
|
||||
api.use("/personal-overview", personalOverviewApiRouter);
|
||||
api.use("/statuses", statusesApiRouter);
|
||||
api.use("/todo-list", todoListApiRouter);
|
||||
api.use("/notifications", notificationsApiRouter);
|
||||
api.use("/attachments", attachmentsApiRouter);
|
||||
api.use("/sub-tasks", subTasksApiRouter);
|
||||
api.use("/project-members", projectMembersApiRouter);
|
||||
api.use("/task-time-log", taskWorkLogApiRouter);
|
||||
api.use("/task-comments", taskCommentsApiRouter);
|
||||
api.use("/timezones", timezonesApiRouter);
|
||||
api.use("/project-statuses", projectStatusesApiRouter);
|
||||
api.use("/labels", labelsApiRouter);
|
||||
api.use("/resource-allocation", resourceAllocationApiRouter);
|
||||
api.use("/shared/projects", sharedProjectsApiRouter);
|
||||
api.use("/task-templates", taskTemplatesApiRouter);
|
||||
api.use("/project-insights", projectInsightsApiRouter);
|
||||
api.use("/admin-center", adminCenterApiRouter);
|
||||
api.use("/reporting", reportingApiRouter);
|
||||
api.use("/activity-logs", activityLogsApiRouter);
|
||||
api.use("/projects-folders", projectFoldersApiRouter);
|
||||
api.use("/task-phases", taskPhasesApiRouter);
|
||||
api.use("/project-categories", projectCategoriesApiRouter);
|
||||
api.use("/home", homePageApiRouter);
|
||||
api.use("/gantt", ganttApiRouter);
|
||||
api.use("/project-comments", projectCommentsApiRouter);
|
||||
api.use("/reporting-export", reportingExportApiRouter);
|
||||
api.use("/project-healths", projectHealthsApiRouter);
|
||||
api.use("/project-templates", projectTemplatesApiRouter);
|
||||
api.use("/pt-tasks", ptTasksApiRouter);
|
||||
api.use("/pt-task-phases", ptTaskPhasesApiRouter);
|
||||
api.use("/pt-statuses", ptStatusesApiRouter);
|
||||
api.use("/workload-gannt", workloadApiRouter);
|
||||
api.use("/roadmap-gannt", roadmapApiRouter);
|
||||
api.use("/schedule-gannt", scheduleApiRouter);
|
||||
api.use("/schedule-gannt-v2", scheduleApiV2Router);
|
||||
api.use("/project-managers", projectManagerApiRouter);
|
||||
|
||||
api.get("/overview/:id", safeControllerFunction(OverviewController.getById));
|
||||
api.get("/task-priorities", safeControllerFunction(TaskPrioritiesController.get));
|
||||
api.post("/change-password", passwordValidator, safeControllerFunction(AuthController.changePassword));
|
||||
api.get("/access-controls/roles", safeControllerFunction(AccessControlsController.getRoles));
|
||||
api.get("/logs/my-dashboard", safeControllerFunction(LogsController.getActivityLog));
|
||||
|
||||
api.use("/billing", billingApiRouter);
|
||||
api.use("/task-dependencies", taskDependenciesApiRouter);
|
||||
|
||||
api.use("/task-recurring", taskRecurringApiRouter);
|
||||
|
||||
|
||||
const api = express.Router();
|
||||
|
||||
api.use("/projects", projectsApiRouter);
|
||||
api.use("/team-members", teamMembersApiRouter);
|
||||
api.use("/job-titles", jobTitlesApiRouter);
|
||||
api.use("/clients", clientsApiRouter);
|
||||
api.use("/teams", teamsApiRouter);
|
||||
api.use("/tasks", tasksApiRouter);
|
||||
api.use("/settings", settingsApiRouter);
|
||||
api.use("/personal-overview", personalOverviewApiRouter);
|
||||
api.use("/statuses", statusesApiRouter);
|
||||
api.use("/todo-list", todoListApiRouter);
|
||||
api.use("/notifications", notificationsApiRouter);
|
||||
api.use("/attachments", attachmentsApiRouter);
|
||||
api.use("/sub-tasks", subTasksApiRouter);
|
||||
api.use("/project-members", projectMembersApiRouter);
|
||||
api.use("/task-time-log", taskWorkLogApiRouter);
|
||||
api.use("/task-comments", taskCommentsApiRouter);
|
||||
api.use("/timezones", timezonesApiRouter);
|
||||
api.use("/project-statuses", projectStatusesApiRouter);
|
||||
api.use("/labels", labelsApiRouter);
|
||||
api.use("/resource-allocation", resourceAllocationApiRouter);
|
||||
api.use("/shared/projects", sharedProjectsApiRouter);
|
||||
api.use("/task-templates", taskTemplatesApiRouter);
|
||||
api.use("/project-insights", projectInsightsApiRouter);
|
||||
api.use("/admin-center", adminCenterApiRouter);
|
||||
api.use("/reporting", reportingApiRouter);
|
||||
api.use("/activity-logs", activityLogsApiRouter);
|
||||
api.use("/projects-folders", projectFoldersApiRouter);
|
||||
api.use("/task-phases", taskPhasesApiRouter);
|
||||
api.use("/project-categories", projectCategoriesApiRouter);
|
||||
api.use("/home", homePageApiRouter);
|
||||
api.use("/gantt", ganttApiRouter);
|
||||
api.use("/project-comments", projectCommentsApiRouter);
|
||||
api.use("/reporting-export", reportingExportApiRouter);
|
||||
api.use("/project-healths", projectHealthsApiRouter);
|
||||
api.use("/project-templates", projectTemplatesApiRouter);
|
||||
api.use("/pt-tasks", ptTasksApiRouter);
|
||||
api.use("/pt-task-phases", ptTaskPhasesApiRouter);
|
||||
api.use("/pt-statuses", ptStatusesApiRouter);
|
||||
api.use("/workload-gannt", workloadApiRouter);
|
||||
api.use("/roadmap-gannt", roadmapApiRouter);
|
||||
api.use("/schedule-gannt", scheduleApiRouter);
|
||||
api.use("/schedule-gannt-v2", scheduleApiV2Router);
|
||||
api.use("/project-managers", projectManagerApiRouter);
|
||||
|
||||
api.get("/overview/:id", safeControllerFunction(OverviewController.getById));
|
||||
api.get("/task-priorities", safeControllerFunction(TaskPrioritiesController.get));
|
||||
api.post("/change-password", passwordValidator, safeControllerFunction(AuthController.changePassword));
|
||||
api.get("/access-controls/roles", safeControllerFunction(AccessControlsController.getRoles));
|
||||
api.get("/logs/my-dashboard", safeControllerFunction(LogsController.getActivityLog));
|
||||
|
||||
api.use("/billing", billingApiRouter);
|
||||
api.use("/task-dependencies", taskDependenciesApiRouter);
|
||||
|
||||
api.use("/task-recurring", taskRecurringApiRouter);
|
||||
api.use("/custom-columns", customColumnsApiRouter);
|
||||
|
||||
|
||||
api.use("/project-finance", projectFinanceApiRouter);
|
||||
|
||||
export default api;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import express from "express";
|
||||
|
||||
import ProjectfinanceController from "../../controllers/project-finance-controller";
|
||||
import idParamValidator from "../../middlewares/validators/id-param-validator";
|
||||
import safeControllerFunction from "../../shared/safe-controller-function";
|
||||
|
||||
const projectFinanceApiRouter = express.Router();
|
||||
|
||||
projectFinanceApiRouter.get("/project/:project_id/tasks", ProjectfinanceController.getTasks);
|
||||
projectFinanceApiRouter.get("/project/:project_id/tasks/:parent_task_id/subtasks", ProjectfinanceController.getSubTasks);
|
||||
projectFinanceApiRouter.get(
|
||||
"/task/:id/breakdown",
|
||||
idParamValidator,
|
||||
safeControllerFunction(ProjectfinanceController.getTaskBreakdown)
|
||||
);
|
||||
projectFinanceApiRouter.put("/task/:task_id/fixed-cost", ProjectfinanceController.updateTaskFixedCost);
|
||||
projectFinanceApiRouter.put("/project/:project_id/currency", ProjectfinanceController.updateProjectCurrency);
|
||||
projectFinanceApiRouter.get("/project/:project_id/export", ProjectfinanceController.exportFinanceData);
|
||||
|
||||
export default projectFinanceApiRouter;
|
||||
@@ -0,0 +1,69 @@
|
||||
import express from "express";
|
||||
import ProjectRateCardController from "../../controllers/project-ratecard-controller";
|
||||
import idParamValidator from "../../middlewares/validators/id-param-validator";
|
||||
import safeControllerFunction from "../../shared/safe-controller-function";
|
||||
import projectManagerValidator from "../../middlewares/validators/project-manager-validator";
|
||||
|
||||
const projectRatecardApiRouter = express.Router();
|
||||
|
||||
// Insert multiple roles for a project
|
||||
projectRatecardApiRouter.post(
|
||||
"/",
|
||||
projectManagerValidator,
|
||||
safeControllerFunction(ProjectRateCardController.createMany)
|
||||
);
|
||||
// Insert a single role for a project
|
||||
projectRatecardApiRouter.post(
|
||||
"/create-project-rate-card-role",
|
||||
projectManagerValidator,
|
||||
safeControllerFunction(ProjectRateCardController.createOne)
|
||||
);
|
||||
|
||||
// Get all roles for a project
|
||||
projectRatecardApiRouter.get(
|
||||
"/project/:project_id",
|
||||
safeControllerFunction(ProjectRateCardController.getByProjectId)
|
||||
);
|
||||
|
||||
// Get a single role by id
|
||||
projectRatecardApiRouter.get(
|
||||
"/:id",
|
||||
idParamValidator,
|
||||
safeControllerFunction(ProjectRateCardController.getById)
|
||||
);
|
||||
|
||||
// Update a single role by id
|
||||
projectRatecardApiRouter.put(
|
||||
"/:id",
|
||||
idParamValidator,
|
||||
safeControllerFunction(ProjectRateCardController.updateById)
|
||||
);
|
||||
|
||||
// Update all roles for a project (delete then insert)
|
||||
projectRatecardApiRouter.put(
|
||||
"/project/:project_id",
|
||||
safeControllerFunction(ProjectRateCardController.updateByProjectId)
|
||||
);
|
||||
|
||||
// Update project member rate card role
|
||||
projectRatecardApiRouter.put(
|
||||
"/project/:project_id/members/:id/rate-card-role",
|
||||
idParamValidator,
|
||||
projectManagerValidator,
|
||||
safeControllerFunction(ProjectRateCardController.updateProjectMemberByProjectIdAndMemberId)
|
||||
);
|
||||
|
||||
// Delete a single role by id
|
||||
projectRatecardApiRouter.delete(
|
||||
"/:id",
|
||||
idParamValidator,
|
||||
safeControllerFunction(ProjectRateCardController.deleteById)
|
||||
);
|
||||
|
||||
// Delete all roles for a project
|
||||
projectRatecardApiRouter.delete(
|
||||
"/project/:project_id",
|
||||
safeControllerFunction(ProjectRateCardController.deleteByProjectId)
|
||||
);
|
||||
|
||||
export default projectRatecardApiRouter;
|
||||
48
worklenz-backend/src/routes/apis/ratecard-api-router.ts
Normal file
48
worklenz-backend/src/routes/apis/ratecard-api-router.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import express from "express";
|
||||
|
||||
import RateCardController from "../../controllers/ratecard-controller";
|
||||
|
||||
|
||||
import idParamValidator from "../../middlewares/validators/id-param-validator";
|
||||
import teamOwnerOrAdminValidator from "../../middlewares/validators/team-owner-or-admin-validator";
|
||||
import safeControllerFunction from "../../shared/safe-controller-function";
|
||||
import projectManagerValidator from "../../middlewares/validators/project-manager-validator";
|
||||
import ratecardBodyValidator from "../../middlewares/validators/ratecard-body-validator";
|
||||
|
||||
const ratecardApiRouter = express.Router();
|
||||
|
||||
ratecardApiRouter.post(
|
||||
"/",
|
||||
projectManagerValidator,
|
||||
ratecardBodyValidator,
|
||||
safeControllerFunction(RateCardController.create)
|
||||
);
|
||||
|
||||
ratecardApiRouter.get(
|
||||
"/",
|
||||
safeControllerFunction(RateCardController.get)
|
||||
);
|
||||
|
||||
ratecardApiRouter.get(
|
||||
"/:id",
|
||||
teamOwnerOrAdminValidator,
|
||||
idParamValidator,
|
||||
safeControllerFunction(RateCardController.getById)
|
||||
);
|
||||
|
||||
ratecardApiRouter.put(
|
||||
"/:id",
|
||||
teamOwnerOrAdminValidator,
|
||||
ratecardBodyValidator,
|
||||
idParamValidator,
|
||||
safeControllerFunction(RateCardController.update)
|
||||
);
|
||||
|
||||
ratecardApiRouter.delete(
|
||||
"/:id",
|
||||
teamOwnerOrAdminValidator,
|
||||
idParamValidator,
|
||||
safeControllerFunction(RateCardController.deleteById)
|
||||
);
|
||||
|
||||
export default ratecardApiRouter;
|
||||
@@ -69,4 +69,7 @@ tasksApiRouter.put("/labels/:id", idParamValidator, safeControllerFunction(Tasks
|
||||
// Add custom column value update route
|
||||
tasksApiRouter.put("/:taskId/custom-column", TasksControllerV2.updateCustomColumnValue);
|
||||
|
||||
// Add route to reset parent task estimations
|
||||
tasksApiRouter.post("/reset-parent-estimations", safeControllerFunction(TasksController.resetParentTaskEstimations));
|
||||
|
||||
export default tasksApiRouter;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import {Server, Socket} from "socket.io";
|
||||
import { Server, Socket } from "socket.io";
|
||||
import db from "../../config/db";
|
||||
import {getColor, toMinutes} from "../../shared/utils";
|
||||
import {SocketEvents} from "../events";
|
||||
import { getColor, toMinutes } from "../../shared/utils";
|
||||
import { SocketEvents } from "../events";
|
||||
|
||||
import {log_error, notifyProjectUpdates} from "../util";
|
||||
import { log_error, notifyProjectUpdates } from "../util";
|
||||
import TasksControllerV2 from "../../controllers/tasks-controller-v2";
|
||||
import {TASK_STATUS_COLOR_ALPHA, UNMAPPED} from "../../shared/constants";
|
||||
import { TASK_STATUS_COLOR_ALPHA, UNMAPPED } from "../../shared/constants";
|
||||
import moment from "moment";
|
||||
import momentTime from "moment-timezone";
|
||||
import { logEndDateChange, logStartDateChange, logStatusChange } from "../../services/activity-logs/activity-logs.service";
|
||||
@@ -18,8 +18,9 @@ export async function getTaskCompleteInfo(task: any) {
|
||||
const [d2] = result2.rows;
|
||||
|
||||
task.completed_count = d2.res.total_completed || 0;
|
||||
if (task.sub_tasks_count > 0)
|
||||
if (task.sub_tasks_count > 0 && d2.res.total_tasks > 0) {
|
||||
task.sub_tasks_count = d2.res.total_tasks;
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
@@ -97,8 +98,8 @@ export async function on_quick_task(_io: Server, socket: Socket, data?: string)
|
||||
logEndDateChange({
|
||||
task_id: d.task.id,
|
||||
socket,
|
||||
new_value: body.time_zone && d.task.end_date ? momentTime.tz(d.task.end_date, `${body.time_zone}`) : d.task.end_date,
|
||||
old_value: null
|
||||
new_value: body.time_zone && d.task.end_date ? momentTime.tz(d.task.end_date, `${body.time_zone}`) : d.task.end_date,
|
||||
old_value: null
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,31 +2,30 @@
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<title></title>
|
||||
<title>Password Changed | Worklenz</title>
|
||||
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
|
||||
<meta content="width=device-width,initial-scale=1" name="viewport">
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0
|
||||
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;
|
||||
}
|
||||
|
||||
a[x-apple-data-detectors] {
|
||||
color: inherit !important;
|
||||
text-decoration: inherit !important
|
||||
}
|
||||
|
||||
#MessageViewBody a {
|
||||
color: inherit;
|
||||
text-decoration: none
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: inherit
|
||||
.main-container {
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 4px 24px rgba(73, 146, 240, 0.10);
|
||||
margin: 40px auto 0 auto;
|
||||
max-width: 500px;
|
||||
padding: 0 0 20px 0;
|
||||
}
|
||||
|
||||
.padding-30 {
|
||||
@@ -42,33 +41,48 @@
|
||||
mso-hide: all;
|
||||
display: none;
|
||||
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) {
|
||||
.main-container {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
.desktop_hide table.icons-inner {
|
||||
display: inline-block !important
|
||||
display: inline-block !important;
|
||||
}
|
||||
|
||||
.icons-inner {
|
||||
text-align: center
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.icons-inner td {
|
||||
margin: 0 auto
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.row-content {
|
||||
width: 95% !important
|
||||
width: 95% !important;
|
||||
}
|
||||
|
||||
.mobile_hide {
|
||||
display: none
|
||||
display: none;
|
||||
}
|
||||
|
||||
.stack .column {
|
||||
width: 100%;
|
||||
display: block
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mobile_hide {
|
||||
@@ -76,135 +90,145 @@
|
||||
max-height: 0;
|
||||
max-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 0
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.desktop_hide,
|
||||
.desktop_hide table {
|
||||
display: table !important;
|
||||
max-height: none !important
|
||||
max-height: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Mada:wght@500;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
|
||||
<body style="background-color:#fff;margin:0;padding:0;-webkit-text-size-adjust:none;text-size-adjust:none;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0; background-image:none;background-position:top left;background-size:auto;background-repeat:no-repeat"
|
||||
width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-1" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-1 padding-20"
|
||||
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;margin-left: auto;margin-right: auto;padding-top: 20px;
|
||||
<div class="main-container">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0; background-image:none;background-position:top left;background-size:auto;background-repeat:no-repeat"
|
||||
width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-1" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-1 padding-20"
|
||||
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;margin-left: auto;margin-right: auto;padding-top: 20px;
|
||||
padding-bottom: 20px;" width="220">
|
||||
<tr>
|
||||
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
||||
<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>
|
||||
</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:320px;margin-bottom: 40px;"
|
||||
width="320">
|
||||
<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="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"
|
||||
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
||||
<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>
|
||||
</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:320px;margin-bottom: 40px;"
|
||||
width="320">
|
||||
<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="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>
|
||||
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
||||
<div align="center" class="alignment" style="line-height:10px;margin-top: 30px;">
|
||||
<img
|
||||
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;"
|
||||
width="100">
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<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
|
||||
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;">
|
||||
<p style="margin-top: 0px;margin-bottom: 18px;">Hi,</p>
|
||||
<p style="margin:0;margin-bottom:10px">This is a confirmation that your Worklenz
|
||||
account password was changed.</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>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</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="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-password-changed.png"
|
||||
style="display:block;height:auto;border:0;width:100px;max-width:100%;margin-top: 30px;margin-bottom: 10px;"
|
||||
width="100">
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="pad">
|
||||
<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:150%;text-align:center;direction:ltr;letter-spacing:0;mso-line-height-alt:24px">
|
||||
<p style="margin-top: 0px;margin-bottom: 30px;">
|
||||
We wanted to let you know that your Worklenz password was reset.
|
||||
</p>
|
||||
</div>
|
||||
<td class="alignment" style="vertical-align:middle;text-align:center">
|
||||
<!--[if vml]>
|
||||
<table align="left" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="display:inline-block;padding-left:0px;padding-right:0px;mso-table-lspace: 0pt;mso-table-rspace: 0pt;">
|
||||
<![endif]-->
|
||||
<!--[if !vml]><!-->
|
||||
<table cellpadding="0" cellspacing="0" class="icons-inner" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;display:inline-block;margin-right:-4px;padding-left:0;padding-right:0">
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-2" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:475px" width="505">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="column column-1"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;font-weight:400;text-align:left;vertical-align:top;padding-top:5px;padding-bottom:5px;border-top:0;border-right:0;border-bottom:0;border-left:0"
|
||||
width="100%">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="icons_block block-1" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tr>
|
||||
<td class="pad"
|
||||
style="vertical-align:middle;color:#9d9d9d;font-family:inherit;font-size:15px;padding-bottom:5px;padding-top:5px;text-align:center">
|
||||
<table cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tr>
|
||||
<td class="alignment" style="vertical-align:middle;text-align:center">
|
||||
<!--[if vml]>
|
||||
<table align="left" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="display:inline-block;padding-left:0px;padding-right:0px;mso-table-lspace: 0pt;mso-table-rspace: 0pt;">
|
||||
<![endif]-->
|
||||
<!--[if !vml]><!-->
|
||||
<table cellpadding="0" cellspacing="0" class="icons-inner" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;display:inline-block;margin-right:-4px;padding-left:0;padding-right:0">
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table><!-- End -->
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<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>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -2,31 +2,30 @@
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<title></title>
|
||||
<title>Reset Your Password | Worklenz</title>
|
||||
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
|
||||
<meta content="width=device-width,initial-scale=1" name="viewport">
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0
|
||||
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;
|
||||
}
|
||||
|
||||
a[x-apple-data-detectors] {
|
||||
color: inherit !important;
|
||||
text-decoration: inherit !important
|
||||
}
|
||||
|
||||
#MessageViewBody a {
|
||||
color: inherit;
|
||||
text-decoration: none
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: inherit
|
||||
.main-container {
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 4px 24px rgba(73, 146, 240, 0.10);
|
||||
margin: 40px auto 0 auto;
|
||||
max-width: 500px;
|
||||
padding: 0 0 20px 0;
|
||||
}
|
||||
|
||||
.padding-30 {
|
||||
@@ -42,33 +41,68 @@
|
||||
mso-hide: all;
|
||||
display: none;
|
||||
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) {
|
||||
.main-container {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
.desktop_hide table.icons-inner {
|
||||
display: inline-block !important
|
||||
display: inline-block !important;
|
||||
}
|
||||
|
||||
.icons-inner {
|
||||
text-align: center
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.icons-inner td {
|
||||
margin: 0 auto
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.row-content {
|
||||
width: 95% !important
|
||||
width: 95% !important;
|
||||
}
|
||||
|
||||
.mobile_hide {
|
||||
display: none
|
||||
display: none;
|
||||
}
|
||||
|
||||
.stack .column {
|
||||
width: 100%;
|
||||
display: block
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mobile_hide {
|
||||
@@ -76,179 +110,137 @@
|
||||
max-height: 0;
|
||||
max-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 0
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.desktop_hide,
|
||||
.desktop_hide table {
|
||||
display: table !important;
|
||||
max-height: none !important
|
||||
max-height: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Mada:wght@500;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
|
||||
<body style="background-color:#fff;margin:0;padding:0;-webkit-text-size-adjust:none;text-size-adjust:none">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0; background-image:none;background-position:top left;background-size:auto;background-repeat:no-repeat"
|
||||
width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-1" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tbody>
|
||||
<body>
|
||||
<div class="main-container">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation" style="background:transparent;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-1 padding-20" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;margin-left: auto;margin-right: auto;padding-top: 20px;
|
||||
padding-bottom: 20px;" width="220">
|
||||
<tr>
|
||||
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
||||
<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>
|
||||
</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:475px;"
|
||||
width="475">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-1" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td 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">
|
||||
<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">Reset your password on Worklenz</span>
|
||||
</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<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/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>
|
||||
</td>
|
||||
</tr>
|
||||
</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%">
|
||||
<tr>
|
||||
<td class="pad">
|
||||
<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>
|
||||
</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: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 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>
|
||||
</td>
|
||||
</tr>
|
||||
</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%">
|
||||
<tr>
|
||||
<td class="pad">
|
||||
<div align="center" class="alignment">
|
||||
<a href="https://[VAR_HOSTNAME]/auth/verify-reset-email/[VAR_USER_ID]/[VAR_HASH]">
|
||||
<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>
|
||||
<!--[if mso]></center></v:textbox></v:roundrect><![endif]-->
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<tr>
|
||||
<td>
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-1 padding-20" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;margin-left: auto;margin-right: auto;padding-top: 20px; padding-bottom: 20px;" width="220">
|
||||
<tr>
|
||||
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
||||
<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>
|
||||
</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:475px;" 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">
|
||||
<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;">
|
||||
<span class="tinyMce-placeholder">Reset your password</span>
|
||||
</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<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/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>
|
||||
</td>
|
||||
</tr>
|
||||
</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%">
|
||||
<tr>
|
||||
<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">
|
||||
<p style="margin-top: 0px;margin-bottom: 2px;">Hi,</p>
|
||||
</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: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">
|
||||
<p style="margin:0;margin-bottom:10px">We received a request to reset your Worklenz account password.</p>
|
||||
<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>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</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%">
|
||||
<tr>
|
||||
<td class="pad">
|
||||
<div align="center" class="alignment">
|
||||
<a href="https://[VAR_HOSTNAME]/auth/verify-reset-email/[VAR_USER_ID]/[VAR_HASH]" class="modern-btn">
|
||||
Reset my password
|
||||
</a>
|
||||
</div>
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</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">
|
||||
<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 class="column column-1"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;font-weight:400;text-align:left;vertical-align:top;padding-top:5px;padding-bottom:5px;border-top:0;border-right:0;border-bottom:0;border-left:0"
|
||||
width="100%">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="icons_block block-1" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tr>
|
||||
<td class="pad"
|
||||
style="vertical-align:middle;color:#9d9d9d;font-family:inherit;font-size:15px;padding-bottom:5px;padding-top:5px;text-align:center">
|
||||
<table cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0"
|
||||
width="100%">
|
||||
<tr>
|
||||
<td class="alignment" style="vertical-align:middle;text-align:center">
|
||||
<!--[if vml]>
|
||||
<table align="left" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="display:inline-block;padding-left:0px;padding-right:0px;mso-table-lspace: 0pt;mso-table-rspace: 0pt;">
|
||||
<![endif]-->
|
||||
<!--[if !vml]><!-->
|
||||
<table cellpadding="0"
|
||||
cellspacing="0"
|
||||
class="icons-inner" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;display:inline-block;margin-right:-4px;padding-left:0;padding-right:0">
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<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">
|
||||
<table cellpadding="0" cellspacing="0" class="icons-inner" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;display:inline-block;margin-right:-4px;padding-left:0;padding-right:0">
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table><!-- End -->
|
||||
</tbody>
|
||||
</table>
|
||||
<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>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -2,31 +2,30 @@
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<title></title>
|
||||
<title>Join Your Team on Worklenz</title>
|
||||
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
|
||||
<meta content="width=device-width,initial-scale=1" name="viewport">
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0
|
||||
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;
|
||||
}
|
||||
|
||||
a[x-apple-data-detectors] {
|
||||
color: inherit !important;
|
||||
text-decoration: inherit !important
|
||||
}
|
||||
|
||||
#MessageViewBody a {
|
||||
color: inherit;
|
||||
text-decoration: none
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: inherit
|
||||
.main-container {
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 4px 24px rgba(73, 146, 240, 0.10);
|
||||
margin: 40px auto 0 auto;
|
||||
max-width: 500px;
|
||||
padding: 0 0 20px 0;
|
||||
}
|
||||
|
||||
.padding-30 {
|
||||
@@ -42,33 +41,68 @@
|
||||
mso-hide: all;
|
||||
display: none;
|
||||
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) {
|
||||
.main-container {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
.desktop_hide table.icons-inner {
|
||||
display: inline-block !important
|
||||
display: inline-block !important;
|
||||
}
|
||||
|
||||
.icons-inner {
|
||||
text-align: center
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.icons-inner td {
|
||||
margin: 0 auto
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.row-content {
|
||||
width: 95% !important
|
||||
width: 95% !important;
|
||||
}
|
||||
|
||||
.mobile_hide {
|
||||
display: none
|
||||
display: none;
|
||||
}
|
||||
|
||||
.stack .column {
|
||||
width: 100%;
|
||||
display: block
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mobile_hide {
|
||||
@@ -76,181 +110,134 @@
|
||||
max-height: 0;
|
||||
max-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 0
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.desktop_hide,
|
||||
.desktop_hide table {
|
||||
display: table !important;
|
||||
max-height: none !important
|
||||
max-height: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Mada:wght@500;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
|
||||
<body style="background-color:#fff;margin:0;padding:0;-webkit-text-size-adjust:none;text-size-adjust:none">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0; background-image:none;background-position:top left;background-size:auto;background-repeat:no-repeat"
|
||||
width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-1" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tbody>
|
||||
<body>
|
||||
<div class="main-container">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation" style="background:transparent;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-1 padding-20" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;margin-left: auto;margin-right: auto;padding-top: 20px;
|
||||
padding-bottom: 20px;" width="220">
|
||||
<tr>
|
||||
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
||||
<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>
|
||||
</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:475px;"
|
||||
width="475">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-1" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td 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">
|
||||
<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>
|
||||
</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<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/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>
|
||||
</td>
|
||||
</tr>
|
||||
</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%">
|
||||
<tr>
|
||||
<td class="pad">
|
||||
<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>
|
||||
</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: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>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</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%">
|
||||
<tr>
|
||||
<td class="pad">
|
||||
<div align="center" class="alignment">
|
||||
<a href="https://[VAR_HOSTNAME]/auth/login?team=[VAR_TEAM_ID]&user=[VAR_USER_ID]&project=[PROJECT_ID]">
|
||||
<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>
|
||||
<!--[if mso]></center></v:textbox></v:roundrect><![endif]-->
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<tr>
|
||||
<td>
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-1 padding-20" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;margin-left: auto;margin-right: auto;padding-top: 20px; padding-bottom: 20px;" width="220">
|
||||
<tr>
|
||||
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
||||
<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>
|
||||
</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:475px;" 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">
|
||||
<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;">
|
||||
<span class="tinyMce-placeholder">Join your team on Worklenz</span>
|
||||
</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<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/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>
|
||||
</td>
|
||||
</tr>
|
||||
</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%">
|
||||
<tr>
|
||||
<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">
|
||||
<p style="margin-top: 0px;margin-bottom: 2px;">Hi [VAR_USER_NAME],</p>
|
||||
</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: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">
|
||||
<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>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</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%">
|
||||
<tr>
|
||||
<td class="pad">
|
||||
<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">
|
||||
Go to Worklenz
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</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">
|
||||
<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 class="column column-1"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;font-weight:400;text-align:left;vertical-align:top;padding-top:5px;padding-bottom:5px;border-top:0;border-right:0;border-bottom:0;border-left:0"
|
||||
width="100%">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="icons_block block-1" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tr>
|
||||
<td class="pad"
|
||||
style="vertical-align:middle;color:#9d9d9d;font-family:inherit;font-size:15px;padding-bottom:5px;padding-top:5px;text-align:center">
|
||||
<table cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0"
|
||||
width="100%">
|
||||
<tr>
|
||||
<td class="alignment" style="vertical-align:middle;text-align:center">
|
||||
<!--[if vml]>
|
||||
<table align="left" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="display:inline-block;padding-left:0px;padding-right:0px;mso-table-lspace: 0pt;mso-table-rspace: 0pt;">
|
||||
<![endif]-->
|
||||
<!--[if !vml]><!-->
|
||||
<table cellpadding="0"
|
||||
cellspacing="0"
|
||||
class="icons-inner" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;display:inline-block;margin-right:-4px;padding-left:0;padding-right:0">
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<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">
|
||||
<table cellpadding="0" cellspacing="0" class="icons-inner" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;display:inline-block;margin-right:-4px;padding-left:0;padding-right:0">
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table><!-- End -->
|
||||
</tbody>
|
||||
</table>
|
||||
<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>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -2,31 +2,30 @@
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<title></title>
|
||||
<title>Join Your Team on Worklenz</title>
|
||||
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
|
||||
<meta content="width=device-width,initial-scale=1" name="viewport">
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0
|
||||
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;
|
||||
}
|
||||
|
||||
a[x-apple-data-detectors] {
|
||||
color: inherit !important;
|
||||
text-decoration: inherit !important
|
||||
}
|
||||
|
||||
#MessageViewBody a {
|
||||
color: inherit;
|
||||
text-decoration: none
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: inherit
|
||||
.main-container {
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 4px 24px rgba(73, 146, 240, 0.10);
|
||||
margin: 40px auto 0 auto;
|
||||
max-width: 500px;
|
||||
padding: 0 0 20px 0;
|
||||
}
|
||||
|
||||
.padding-30 {
|
||||
@@ -42,33 +41,68 @@
|
||||
mso-hide: all;
|
||||
display: none;
|
||||
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) {
|
||||
.main-container {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
.desktop_hide table.icons-inner {
|
||||
display: inline-block !important
|
||||
display: inline-block !important;
|
||||
}
|
||||
|
||||
.icons-inner {
|
||||
text-align: center
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.icons-inner td {
|
||||
margin: 0 auto
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.row-content {
|
||||
width: 95% !important
|
||||
width: 95% !important;
|
||||
}
|
||||
|
||||
.mobile_hide {
|
||||
display: none
|
||||
display: none;
|
||||
}
|
||||
|
||||
.stack .column {
|
||||
width: 100%;
|
||||
display: block
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mobile_hide {
|
||||
@@ -76,180 +110,174 @@
|
||||
max-height: 0;
|
||||
max-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 0
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.desktop_hide,
|
||||
.desktop_hide table {
|
||||
display: table !important;
|
||||
max-height: none !important
|
||||
max-height: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Mada:wght@500;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
|
||||
<body style="background-color:#fff;margin:0;padding:0;-webkit-text-size-adjust:none;text-size-adjust:none">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0; background-image:none;background-position:top left;background-size:auto;background-repeat:no-repeat"
|
||||
width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-1" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-1 padding-20" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;margin-left: auto;margin-right: auto;padding-top: 20px;
|
||||
padding-bottom: 20px;" width="220">
|
||||
<tr>
|
||||
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
||||
<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>
|
||||
</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:475px;"
|
||||
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">
|
||||
<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>
|
||||
</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<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/email-unregistered-team-member.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="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>
|
||||
<td class="pad">
|
||||
<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>
|
||||
</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: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>Create an account in Worklenz to continue.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</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%">
|
||||
<tr>
|
||||
<td class="pad">
|
||||
<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]">
|
||||
<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>
|
||||
<!--[if mso]></center></v:textbox></v:roundrect><![endif]-->
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<body>
|
||||
<div class="main-container">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation"
|
||||
style="background:transparent;"
|
||||
width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-1" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-1 padding-20" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;margin-left: auto;margin-right: auto;padding-top: 20px;
|
||||
padding-bottom: 20px;" width="220">
|
||||
<tr>
|
||||
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
||||
<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>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-2" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0"
|
||||
width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack"
|
||||
role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:475px" width="505">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="column column-1"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;font-weight:400;text-align:left;vertical-align:top;padding-top:5px;padding-bottom:5px;border-top:0;border-right:0;border-bottom:0;border-left:0"
|
||||
width="100%">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="icons_block block-1" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tr>
|
||||
<td class="pad"
|
||||
style="vertical-align:middle;color:#9d9d9d;font-family:inherit;font-size:15px;padding-bottom:5px;padding-top:5px;text-align:center">
|
||||
<table cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0"
|
||||
width="100%">
|
||||
<tr>
|
||||
<td class="alignment" style="vertical-align:middle;text-align:center">
|
||||
<!--[if vml]>
|
||||
<table align="left" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="display:inline-block;padding-left:0px;padding-right:0px;mso-table-lspace: 0pt;mso-table-rspace: 0pt;">
|
||||
<![endif]-->
|
||||
<!--[if !vml]><!-->
|
||||
<table cellpadding="0"
|
||||
cellspacing="0"
|
||||
class="icons-inner" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;display:inline-block;margin-right:-4px;padding-left:0;padding-right:0">
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table><!-- End -->
|
||||
</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:475px;"
|
||||
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">
|
||||
<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;">
|
||||
<span class="tinyMce-placeholder">Join your team on Worklenz</span>
|
||||
</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<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/email-unregistered-team-member.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="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>
|
||||
<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">
|
||||
<p style="margin-top: 0px;margin-bottom: 2px;">Hi,</p>
|
||||
</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: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">
|
||||
<p style="margin:0;margin-bottom:10px">You have been added to the "[VAR_TEAM_NAME]" team
|
||||
on Worklenz!</p>
|
||||
<p>Create an account in Worklenz to continue.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</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%">
|
||||
<tr>
|
||||
<td class="pad">
|
||||
<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">
|
||||
Join Worklenz
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</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">
|
||||
<table cellpadding="0"
|
||||
cellspacing="0"
|
||||
class="icons-inner" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;display:inline-block;margin-right:-4px;padding-left:0;padding-right:0">
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<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>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -2,31 +2,30 @@
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<title></title>
|
||||
<title>Welcome to Worklenz</title>
|
||||
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
|
||||
<meta content="width=device-width,initial-scale=1" name="viewport">
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0
|
||||
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;
|
||||
}
|
||||
|
||||
a[x-apple-data-detectors] {
|
||||
color: inherit !important;
|
||||
text-decoration: inherit !important
|
||||
}
|
||||
|
||||
#MessageViewBody a {
|
||||
color: inherit;
|
||||
text-decoration: none
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: inherit
|
||||
.main-container {
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 4px 24px rgba(73, 146, 240, 0.10);
|
||||
margin: 40px auto 0 auto;
|
||||
max-width: 500px;
|
||||
padding: 0 0 20px 0;
|
||||
}
|
||||
|
||||
.padding-30 {
|
||||
@@ -42,33 +41,68 @@
|
||||
mso-hide: all;
|
||||
display: none;
|
||||
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) {
|
||||
.main-container {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
.desktop_hide table.icons-inner {
|
||||
display: inline-block !important
|
||||
display: inline-block !important;
|
||||
}
|
||||
|
||||
.icons-inner {
|
||||
text-align: center
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.icons-inner td {
|
||||
margin: 0 auto
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.row-content {
|
||||
width: 95% !important
|
||||
width: 95% !important;
|
||||
}
|
||||
|
||||
.mobile_hide {
|
||||
display: none
|
||||
display: none;
|
||||
}
|
||||
|
||||
.stack .column {
|
||||
width: 100%;
|
||||
display: block
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mobile_hide {
|
||||
@@ -76,179 +110,173 @@
|
||||
max-height: 0;
|
||||
max-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 0
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.desktop_hide,
|
||||
.desktop_hide table {
|
||||
display: table !important;
|
||||
max-height: none !important
|
||||
max-height: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Mada:wght@500;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
|
||||
<body style="background-color:#fff;margin:0;padding:0;-webkit-text-size-adjust:none;text-size-adjust:none">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;background-image:none;background-position:top left;background-size:auto;background-repeat:no-repeat"
|
||||
width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-1" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-1 padding-20" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;margin-left: auto;margin-right: auto;padding-top: 20px;
|
||||
padding-bottom: 20px;" width="220">
|
||||
<tr>
|
||||
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
||||
<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>
|
||||
</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:475px;"
|
||||
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">
|
||||
<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">Let's get started with Worklenz.</span>
|
||||
</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<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/email-signup.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="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>
|
||||
<td class="pad">
|
||||
<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>
|
||||
</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: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"> We're excited to have you on board. </p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</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%">
|
||||
<tr>
|
||||
<td class="pad">
|
||||
<div align="center" class="alignment">
|
||||
<a href="https://[VAR_HOSTNAME]/auth/login">
|
||||
<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>
|
||||
<!--[if mso]></center></v:textbox></v:roundrect><![endif]-->
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<body>
|
||||
<div class="main-container">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;background:transparent;"
|
||||
width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-1" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-1 padding-20" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;margin-left: auto;margin-right: auto;padding-top: 20px;
|
||||
padding-bottom: 20px;" width="220">
|
||||
<tr>
|
||||
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
||||
<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>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-2" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0"
|
||||
width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack"
|
||||
role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:475px" width="505">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="column column-1"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;font-weight:400;text-align:left;vertical-align:top;padding-top:5px;padding-bottom:5px;border-top:0;border-right:0;border-bottom:0;border-left:0"
|
||||
width="100%">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="icons_block block-1" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tr>
|
||||
<td class="pad"
|
||||
style="vertical-align:middle;color:#9d9d9d;font-family:inherit;font-size:15px;padding-bottom:5px;padding-top:5px;text-align:center">
|
||||
<table cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0"
|
||||
width="100%">
|
||||
<tr>
|
||||
<td class="alignment" style="vertical-align:middle;text-align:center">
|
||||
<!--[if vml]>
|
||||
<table align="left" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="display:inline-block;padding-left:0px;padding-right:0px;mso-table-lspace: 0pt;mso-table-rspace: 0pt;">
|
||||
<![endif]-->
|
||||
<!--[if !vml]><!-->
|
||||
<table cellpadding="0"
|
||||
cellspacing="0"
|
||||
class="icons-inner" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;display:inline-block;margin-right:-4px;padding-left:0;padding-right:0">
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table><!-- End -->
|
||||
</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:475px;"
|
||||
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">
|
||||
<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;">
|
||||
<span class="tinyMce-placeholder">Let's get started with Worklenz.</span>
|
||||
</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<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/email-signup.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="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>
|
||||
<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">
|
||||
<p style="margin-top: 0px;margin-bottom: 2px;">Hi [VAR_USER_NAME],</p>
|
||||
</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: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">
|
||||
<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>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</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%">
|
||||
<tr>
|
||||
<td class="pad">
|
||||
<div align="center" class="alignment">
|
||||
<a href="https://[VAR_HOSTNAME]/auth/login" class="modern-btn">
|
||||
Go to Worklenz
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</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">
|
||||
<table cellpadding="0"
|
||||
cellspacing="0"
|
||||
class="icons-inner" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;display:inline-block;margin-right:-4px;padding-left:0;padding-right:0">
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<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>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap"
|
||||
rel="stylesheet" />
|
||||
<!-- SVAR Gantt Icons -->
|
||||
<link rel="stylesheet" href="https://cdn.svar.dev/fonts/wxi/wx-icons.css" />
|
||||
<title>Worklenz</title>
|
||||
|
||||
<!-- Environment configuration -->
|
||||
@@ -48,6 +50,17 @@
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./src/index.tsx"></script>
|
||||
<script type="text/javascript">
|
||||
if (window.location.hostname === 'app.worklenz.com') {
|
||||
var hs = document.createElement('script');
|
||||
hs.type = 'text/javascript';
|
||||
hs.id = 'hs-script-loader';
|
||||
hs.async = true;
|
||||
hs.defer = true;
|
||||
hs.src = '//js.hs-scripts.com/22348300.js';
|
||||
document.body.appendChild(hs);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,4 +1,4 @@
|
||||
module.exports = {
|
||||
export default {
|
||||
setupFilesAfterEnv: ['<rootDir>/src/setupTests.ts'],
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
|
||||
1425
worklenz-frontend/package-lock.json
generated
1425
worklenz-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@
|
||||
"name": "worklenz",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
"prebuild": "node scripts/copy-tinymce.js",
|
||||
@@ -16,8 +17,8 @@
|
||||
"@ant-design/icons": "^5.4.0",
|
||||
"@ant-design/pro-components": "^2.7.19",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/modifiers": "^6.0.1",
|
||||
"@dnd-kit/sortable": "^7.0.2",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@paddle/paddle-js": "^1.3.3",
|
||||
@@ -52,7 +53,8 @@
|
||||
"react-window": "^1.8.11",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"tinymce": "^7.7.2",
|
||||
"web-vitals": "^4.2.4"
|
||||
"web-vitals": "^4.2.4",
|
||||
"wx-react-gantt": "^1.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
@@ -77,6 +79,12 @@
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^3.0.5"
|
||||
},
|
||||
"resolutions": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@dnd-kit/sortable": "^7.0.2",
|
||||
"@dnd-kit/modifiers": "^6.0.1"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
module.exports = {
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
[
|
||||
{
|
||||
"id": "c2669c5f-a019-445b-b703-b941bbefdab7",
|
||||
"type": "low",
|
||||
"name": "Low",
|
||||
"color_code": "#c2e4d0",
|
||||
"color_code_dark": "#46d980",
|
||||
"tasks": [
|
||||
{
|
||||
"id": "4be5ef5c-1234-4247-b159-6d8df2b37d04",
|
||||
"task": "Testing and QA",
|
||||
"isBillable": false,
|
||||
"hours": 180,
|
||||
"cost": 18000,
|
||||
"fixedCost": 2500,
|
||||
"totalBudget": 20000,
|
||||
"totalActual": 21000,
|
||||
"variance": -1000,
|
||||
"members": [
|
||||
{
|
||||
"memberId": "6",
|
||||
"name": "Eve Adams",
|
||||
"jobId": "J006",
|
||||
"jobRole": "QA Engineer",
|
||||
"hourlyRate": 100
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "6be5ef5c-1234-4247-b159-6d8df2b37d06",
|
||||
"task": "Project Documentation",
|
||||
"isBillable": false,
|
||||
"hours": 100,
|
||||
"cost": 10000,
|
||||
"fixedCost": 1000,
|
||||
"totalBudget": 12000,
|
||||
"totalActual": 12500,
|
||||
"variance": -500,
|
||||
"members": [
|
||||
{
|
||||
"memberId": "8",
|
||||
"name": "Grace Lee",
|
||||
"jobId": "J008",
|
||||
"jobRole": "Technical Writer",
|
||||
"hourlyRate": 100
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "d3f9c5f1-b019-445b-b703-b941bbefdab8",
|
||||
"type": "medium",
|
||||
"name": "Medium",
|
||||
"color_code": "#f9e3b1",
|
||||
"color_code_dark": "#ffc227",
|
||||
"tasks": [
|
||||
{
|
||||
"id": "1be5ef5c-1234-4247-b159-6d8df2b37d01",
|
||||
"task": "UI Design",
|
||||
"isBillable": true,
|
||||
"hours": 120,
|
||||
"cost": 12000,
|
||||
"fixedCost": 1500,
|
||||
"totalBudget": 14000,
|
||||
"totalActual": 13500,
|
||||
"variance": 500,
|
||||
"members": [
|
||||
{
|
||||
"memberId": "1",
|
||||
"name": "John Doe",
|
||||
"jobId": "J001",
|
||||
"jobRole": "UI/UX Designer",
|
||||
"hourlyRate": 100
|
||||
},
|
||||
{
|
||||
"memberId": "2",
|
||||
"name": "Jane Smith",
|
||||
"jobId": "J002",
|
||||
"jobRole": "Frontend Developer",
|
||||
"hourlyRate": 120
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "2be5ef5c-1234-4247-b159-6d8df2b37d02",
|
||||
"task": "API Integration",
|
||||
"isBillable": true,
|
||||
"hours": 200,
|
||||
"cost": 20000,
|
||||
"fixedCost": 3000,
|
||||
"totalBudget": 25000,
|
||||
"totalActual": 26000,
|
||||
"variance": -1000,
|
||||
"members": [
|
||||
{
|
||||
"memberId": "3",
|
||||
"name": "Alice Johnson",
|
||||
"jobId": "J003",
|
||||
"jobRole": "Backend Developer",
|
||||
"hourlyRate": 100
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "e3f9c5f1-b019-445b-b703-b941bbefdab9",
|
||||
"type": "high",
|
||||
"name": "High",
|
||||
"color_code": "#f6bfc0",
|
||||
"color_code_dark": "#ff4141",
|
||||
"tasks": [
|
||||
{
|
||||
"id": "5be5ef5c-1234-4247-b159-6d8df2b37d05",
|
||||
"task": "Database Migration",
|
||||
"isBillable": true,
|
||||
"hours": 250,
|
||||
"cost": 37500,
|
||||
"fixedCost": 4000,
|
||||
"totalBudget": 42000,
|
||||
"totalActual": 41000,
|
||||
"variance": 1000,
|
||||
"members": [
|
||||
{
|
||||
"memberId": "7",
|
||||
"name": "Frank Harris",
|
||||
"jobId": "J007",
|
||||
"jobRole": "Database Administrator",
|
||||
"hourlyRate": 150
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "3be5ef5c-1234-4247-b159-6d8df2b37d03",
|
||||
"task": "Performance Optimization",
|
||||
"isBillable": true,
|
||||
"hours": 300,
|
||||
"cost": 45000,
|
||||
"fixedCost": 5000,
|
||||
"totalBudget": 50000,
|
||||
"totalActual": 47000,
|
||||
"variance": 3000,
|
||||
"members": [
|
||||
{
|
||||
"memberId": "4",
|
||||
"name": "Bob Brown",
|
||||
"jobId": "J004",
|
||||
"jobRole": "Performance Engineer",
|
||||
"hourlyRate": 150
|
||||
},
|
||||
{
|
||||
"memberId": "5",
|
||||
"name": "Charlie Davis",
|
||||
"jobId": "J005",
|
||||
"jobRole": "Full Stack Developer",
|
||||
"hourlyRate": 130
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,163 @@
|
||||
[
|
||||
{
|
||||
"id": "c2669c5f-a019-445b-b703-b941bbefdab7",
|
||||
"type": "todo",
|
||||
"name": "To Do",
|
||||
"color_code": "#d8d7d8",
|
||||
"color_code_dark": "#989898",
|
||||
"tasks": [
|
||||
{
|
||||
"id": "1be5ef5c-1234-4247-b159-6d8df2b37d01",
|
||||
"task": "UI Design",
|
||||
"isBillable": true,
|
||||
"hours": 120,
|
||||
"cost": 12000,
|
||||
"fixedCost": 1500,
|
||||
"totalBudget": 14000,
|
||||
"totalActual": 13500,
|
||||
"variance": 500,
|
||||
"members": [
|
||||
{
|
||||
"memberId": "1",
|
||||
"name": "John Doe",
|
||||
"jobId": "J001",
|
||||
"jobRole": "UI/UX Designer",
|
||||
"hourlyRate": 100
|
||||
},
|
||||
{
|
||||
"memberId": "2",
|
||||
"name": "Jane Smith",
|
||||
"jobId": "J002",
|
||||
"jobRole": "Frontend Developer",
|
||||
"hourlyRate": 120
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "2be5ef5c-1234-4247-b159-6d8df2b37d02",
|
||||
"task": "API Integration",
|
||||
"isBillable": true,
|
||||
"hours": 200,
|
||||
"cost": 20000,
|
||||
"fixedCost": 3000,
|
||||
"totalBudget": 25000,
|
||||
"totalActual": 26000,
|
||||
"variance": -1000,
|
||||
"members": [
|
||||
{
|
||||
"memberId": "3",
|
||||
"name": "Alice Johnson",
|
||||
"jobId": "J003",
|
||||
"jobRole": "Backend Developer",
|
||||
"hourlyRate": 100
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "d3f9c5f1-b019-445b-b703-b941bbefdab8",
|
||||
"type": "doing",
|
||||
"name": "In Progress",
|
||||
"color_code": "#c0d5f6",
|
||||
"color_code_dark": "#4190ff",
|
||||
"tasks": [
|
||||
{
|
||||
"id": "3be5ef5c-1234-4247-b159-6d8df2b37d03",
|
||||
"task": "Performance Optimization",
|
||||
"isBillable": true,
|
||||
"hours": 300,
|
||||
"cost": 45000,
|
||||
"fixedCost": 5000,
|
||||
"totalBudget": 50000,
|
||||
"totalActual": 47000,
|
||||
"variance": 3000,
|
||||
"members": [
|
||||
{
|
||||
"memberId": "4",
|
||||
"name": "Bob Brown",
|
||||
"jobId": "J004",
|
||||
"jobRole": "Performance Engineer",
|
||||
"hourlyRate": 150
|
||||
},
|
||||
{
|
||||
"memberId": "5",
|
||||
"name": "Charlie Davis",
|
||||
"jobId": "J005",
|
||||
"jobRole": "Full Stack Developer",
|
||||
"hourlyRate": 130
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "4be5ef5c-1234-4247-b159-6d8df2b37d04",
|
||||
"task": "Testing and QA",
|
||||
"isBillable": false,
|
||||
"hours": 180,
|
||||
"cost": 18000,
|
||||
"fixedCost": 2500,
|
||||
"totalBudget": 20000,
|
||||
"totalActual": 21000,
|
||||
"variance": -1000,
|
||||
"members": [
|
||||
{
|
||||
"memberId": "6",
|
||||
"name": "Eve Adams",
|
||||
"jobId": "J006",
|
||||
"jobRole": "QA Engineer",
|
||||
"hourlyRate": 100
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "e3f9c5f1-b019-445b-b703-b941bbefdab9",
|
||||
"type": "done",
|
||||
"name": "Done",
|
||||
"color_code": "#c2e4d0",
|
||||
"color_code_dark": "#46d980",
|
||||
"tasks": [
|
||||
{
|
||||
"id": "5be5ef5c-1234-4247-b159-6d8df2b37d05",
|
||||
"task": "Database Migration",
|
||||
"isBillable": true,
|
||||
"hours": 250,
|
||||
"cost": 37500,
|
||||
"fixedCost": 4000,
|
||||
"totalBudget": 42000,
|
||||
"totalActual": 41000,
|
||||
"variance": 1000,
|
||||
"members": [
|
||||
{
|
||||
"memberId": "7",
|
||||
"name": "Frank Harris",
|
||||
"jobId": "J007",
|
||||
"jobRole": "Database Administrator",
|
||||
"hourlyRate": 150
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "6be5ef5c-1234-4247-b159-6d8df2b37d06",
|
||||
"task": "Project Documentation",
|
||||
"isBillable": false,
|
||||
"hours": 100,
|
||||
"cost": 10000,
|
||||
"fixedCost": 1000,
|
||||
"totalBudget": 12000,
|
||||
"totalActual": 12500,
|
||||
"variance": -500,
|
||||
"members": [
|
||||
{
|
||||
"memberId": "8",
|
||||
"name": "Grace Lee",
|
||||
"jobId": "J008",
|
||||
"jobRole": "Technical Writer",
|
||||
"hourlyRate": 100
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,51 @@
|
||||
[
|
||||
{
|
||||
"ratecardId": "RC001",
|
||||
"ratecardName": "Rate Card 1",
|
||||
"jobRolesList": [
|
||||
{
|
||||
"jobId": "J001",
|
||||
"jobTitle": "Project Manager",
|
||||
"ratePerHour": 100
|
||||
},
|
||||
{
|
||||
"jobId": "J002",
|
||||
"jobTitle": "Senior Software Engineer",
|
||||
"ratePerHour": 120
|
||||
},
|
||||
{
|
||||
"jobId": "J003",
|
||||
"jobTitle": "Junior Software Engineer",
|
||||
"ratePerHour": 80
|
||||
},
|
||||
{
|
||||
"jobId": "J004",
|
||||
"jobTitle": "UI/UX Designer",
|
||||
"ratePerHour": 50
|
||||
}
|
||||
],
|
||||
"createdDate": "2024-12-01T00:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"ratecardId": "RC002",
|
||||
"ratecardName": "Rate Card 2",
|
||||
"jobRolesList": [
|
||||
{
|
||||
"jobId": "J001",
|
||||
"jobTitle": "Project Manager",
|
||||
"ratePerHour": 80
|
||||
},
|
||||
{
|
||||
"jobId": "J002",
|
||||
"jobTitle": "Senior Software Engineer",
|
||||
"ratePerHour": 100
|
||||
},
|
||||
{
|
||||
"jobId": "J003",
|
||||
"jobTitle": "Junior Software Engineer",
|
||||
"ratePerHour": 60
|
||||
}
|
||||
],
|
||||
"createdDate": "2024-12-15T00:00:00.000Z"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"financeText": "Finance",
|
||||
"ratecardSingularText": "Rate Card",
|
||||
"groupByText": "Group by",
|
||||
"statusText": "Status",
|
||||
"phaseText": "Phase",
|
||||
"priorityText": "Priority",
|
||||
"exportButton": "Export",
|
||||
"currencyText": "Currency",
|
||||
"importButton": "Import",
|
||||
"filterText": "Filter",
|
||||
"billableOnlyText": "Billable Only",
|
||||
"nonBillableOnlyText": "Non-Billable Only",
|
||||
"allTasksText": "All Tasks",
|
||||
|
||||
"taskColumn": "Task",
|
||||
"membersColumn": "Members",
|
||||
"hoursColumn": "Estimated Hours",
|
||||
"totalTimeLoggedColumn": "Total Time Logged",
|
||||
"costColumn": "Actual Cost",
|
||||
"estimatedCostColumn": "Estimated Cost",
|
||||
"fixedCostColumn": "Fixed Cost",
|
||||
"totalBudgetedCostColumn": "Total Budgeted Cost",
|
||||
"totalActualCostColumn": "Total Actual Cost",
|
||||
"varianceColumn": "Variance",
|
||||
"totalText": "Total",
|
||||
"noTasksFound": "No tasks found",
|
||||
|
||||
"addRoleButton": "+ Add Role",
|
||||
"ratecardImportantNotice": "* This rate card is generated based on the company's standard job titles and rates. However, you have the flexibility to modify it according to the project. These changes will not impact the organization's standard job titles and rates.",
|
||||
"saveButton": "Save",
|
||||
|
||||
"jobTitleColumn": "Job Title",
|
||||
"ratePerHourColumn": "Rate per hour",
|
||||
"ratecardPluralText": "Rate Cards",
|
||||
"labourHoursColumn": "Labour Hours",
|
||||
"actions": "Actions",
|
||||
"selectJobTitle": "Select Job Title",
|
||||
"ratecardsPluralText": "Rate Card Templates",
|
||||
"deleteConfirm": "Are you sure ?",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"alreadyImportedRateCardMessage": "A rate card has already been imported. Clear all imported rate cards to add a new one."
|
||||
|
||||
}
|
||||
|
||||
@@ -31,5 +31,10 @@
|
||||
|
||||
"todoText": "To Do",
|
||||
"doingText": "Doing",
|
||||
"doneText": "Done"
|
||||
"doneText": "Done",
|
||||
|
||||
"timeLogsColumn": "Time Logs",
|
||||
"timeLogsColumnTooltip": "Shows the proportion of billable vs non-billable time",
|
||||
"billable": "Billable",
|
||||
"nonBillable": "Non-Billable"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"nameColumn": "Name",
|
||||
"createdColumn": "Created",
|
||||
"noProjectsAvailable": "No projects available",
|
||||
"deleteConfirmationTitle": "Are you sure you want to delete this rate card?",
|
||||
"deleteConfirmationOk": "Yes, delete",
|
||||
"deleteConfirmationCancel": "Cancel",
|
||||
"searchPlaceholder": "Search rate cards by name",
|
||||
"createRatecard": "Create Rate Card",
|
||||
"editTooltip": "Edit rate card",
|
||||
"deleteTooltip": "Delete rate card",
|
||||
"fetchError": "Failed to fetch rate cards",
|
||||
"createError": "Failed to create rate card",
|
||||
"deleteSuccess": "Rate card deleted successfully",
|
||||
"deleteError": "Failed to delete rate card",
|
||||
|
||||
"jobTitleColumn": "Job title",
|
||||
"ratePerHourColumn": "Rate per hour",
|
||||
"saveButton": "Save",
|
||||
"addRoleButton": "Add Role",
|
||||
"createRatecardSuccessMessage": "Rate card created successfully",
|
||||
"createRatecardErrorMessage": "Failed to create rate card",
|
||||
"updateRatecardSuccessMessage": "Rate card updated successfully",
|
||||
"updateRatecardErrorMessage": "Failed to update rate card",
|
||||
"currency": "Currency",
|
||||
"actionsColumn": "Actions",
|
||||
"addAllButton": "Add All",
|
||||
"removeAllButton": "Remove All",
|
||||
"selectJobTitle": "Select job title",
|
||||
"unsavedChangesTitle": "You have unsaved changes",
|
||||
"unsavedChangesMessage": "Do you want to save your changes before leaving?",
|
||||
"unsavedChangesSave": "Save",
|
||||
"unsavedChangesDiscard": "Discard",
|
||||
"ratecardNameRequired": "Rate card name is required",
|
||||
"ratecardNamePlaceholder": "Enter rate card name",
|
||||
"noRatecardsFound": "No rate cards found",
|
||||
"loadingRateCards": "Loading rate cards...",
|
||||
"noJobTitlesAvailable": "No job titles available",
|
||||
"noRolesAdded": "No roles added yet",
|
||||
"createFirstJobTitle": "Create First Job Title",
|
||||
"jobRolesTitle": "Job Roles",
|
||||
"noJobTitlesMessage": "Please create job titles first in the Job Titles settings before adding roles to rate cards.",
|
||||
"createNewJobTitle": "Create New Job Title",
|
||||
"jobTitleNamePlaceholder": "Enter job title name",
|
||||
"jobTitleNameRequired": "Job title name is required",
|
||||
"jobTitleCreatedSuccess": "Job title created successfully",
|
||||
"jobTitleCreateError": "Failed to create job title",
|
||||
"createButton": "Create",
|
||||
"cancelButton": "Cancel"
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
"recurring": "Recurring",
|
||||
"recurringTaskConfiguration": "Recurring task configuration",
|
||||
"repeats": "Repeats",
|
||||
"daily": "Daily",
|
||||
"weekly": "Weekly",
|
||||
"everyXDays": "Every X Days",
|
||||
"everyXWeeks": "Every X Weeks",
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"show-start-date": "Show Start Date",
|
||||
"hours": "Hours",
|
||||
"minutes": "Minutes",
|
||||
"time-estimation-disabled-tooltip": "Time estimation is disabled because this task has {{count}} subtasks. The estimation shown is the sum of all subtasks.",
|
||||
"progressValue": "Progress Value",
|
||||
"progressValueTooltip": "Set the progress percentage (0-100%)",
|
||||
"progressValueRequired": "Please enter a progress value",
|
||||
@@ -79,7 +80,21 @@
|
||||
"addTimeLog": "Add new time log",
|
||||
"totalLogged": "Total Logged",
|
||||
"exportToExcel": "Export to Excel",
|
||||
"noTimeLogsFound": "No time logs found"
|
||||
"noTimeLogsFound": "No time logs found",
|
||||
"timerDisabledTooltip": "Timer is disabled because this task has {{count}} subtasks. Time should be logged on individual subtasks.",
|
||||
"timeLogDisabledTooltip": "Time logging is disabled because this task has {{count}} subtasks. Time should be logged on individual subtasks.",
|
||||
"date": "Date",
|
||||
"startTime": "Start Time",
|
||||
"endTime": "End Time",
|
||||
"workDescription": "Work Description",
|
||||
"requiredFields": "Please fill in all required fields",
|
||||
"dateRequired": "Please select a date",
|
||||
"startTimeRequired": "Please select start time",
|
||||
"endTimeRequired": "Please select end time",
|
||||
"workDescriptionPlaceholder": "Add a description",
|
||||
"cancel": "Cancel",
|
||||
"logTime": "Log time",
|
||||
"updateTime": "Update time"
|
||||
},
|
||||
"taskActivityLogTab": {
|
||||
"title": "Activity Log"
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
"searchByName": "Search by name",
|
||||
"selectAll": "Select All",
|
||||
"clearAll": "Clear All",
|
||||
"teams": "Teams",
|
||||
|
||||
"searchByProject": "Search by project name",
|
||||
@@ -15,6 +16,8 @@
|
||||
|
||||
"billable": "Billable",
|
||||
"nonBillable": "Non Billable",
|
||||
"filterByBillableStatus": "Filter by Billable Status",
|
||||
"allBillableTypes": "All Billable Types",
|
||||
|
||||
"total": "Total",
|
||||
|
||||
@@ -40,5 +43,25 @@
|
||||
"noCategory": "No Category",
|
||||
"noProjects": "No projects found",
|
||||
"noTeams": "No teams found",
|
||||
"noData": "No data found"
|
||||
"noData": "No data found",
|
||||
"members": "Members",
|
||||
"searchByMember": "Search by member",
|
||||
"utilization": "Utilization",
|
||||
|
||||
"totalTimeLogged": "Total Time Logged",
|
||||
"expectedCapacity": "Expected Capacity",
|
||||
"teamUtilization": "Team Utilization",
|
||||
"variance": "Variance",
|
||||
"acrossAllTeamMembers": "Across all team members",
|
||||
"basedOnWorkingSchedule": "Based on working schedule",
|
||||
"optimal": "Optimal",
|
||||
"underUtilized": "Under-utilized",
|
||||
"overUtilized": "Over-utilized",
|
||||
"overCapacity": "Over capacity",
|
||||
"underCapacity": "Under capacity",
|
||||
"considerWorkloadRedistribution": "Consider workload redistribution",
|
||||
"capacityAvailableForNewProjects": "Capacity available for new projects",
|
||||
"targetRange": "Target: 90-110%",
|
||||
"overtimeWork": "Overtime Work",
|
||||
"reviewWorkLifeBalance": "Review work-life balance policies"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"financeText": "Finanzas",
|
||||
"ratecardSingularText": "Tarifa",
|
||||
"groupByText": "Agrupar por",
|
||||
"statusText": "Estado",
|
||||
"phaseText": "Fase",
|
||||
"priorityText": "Prioridad",
|
||||
"exportButton": "Exportar",
|
||||
"currencyText": "Moneda",
|
||||
"importButton": "Importar",
|
||||
"filterText": "Filtro",
|
||||
"billableOnlyText": "Solo Facturable",
|
||||
"nonBillableOnlyText": "Solo No Facturable",
|
||||
"allTasksText": "Todas las Tareas",
|
||||
|
||||
"taskColumn": "Tarea",
|
||||
"membersColumn": "Miembros",
|
||||
"hoursColumn": "Horas Estimadas",
|
||||
"totalTimeLoggedColumn": "Tiempo Total Registrado",
|
||||
"costColumn": "Costo Real",
|
||||
"estimatedCostColumn": "Costo Estimado",
|
||||
"fixedCostColumn": "Costo Fijo",
|
||||
"totalBudgetedCostColumn": "Costo Total Presupuestado",
|
||||
"totalActualCostColumn": "Costo Real Total",
|
||||
"varianceColumn": "Varianza",
|
||||
"totalText": "Total",
|
||||
"noTasksFound": "No se encontraron tareas",
|
||||
|
||||
"addRoleButton": "+ Agregar Rol",
|
||||
"ratecardImportantNotice": "* Esta tarifa se genera en base a los títulos de trabajo y tarifas estándar de la empresa. Sin embargo, tienes la flexibilidad de modificarla según el proyecto. Estos cambios no afectarán los títulos de trabajo y tarifas estándar de la organización.",
|
||||
"saveButton": "Guardar",
|
||||
|
||||
"jobTitleColumn": "Título del Trabajo",
|
||||
"ratePerHourColumn": "Tarifa por hora",
|
||||
"ratecardPluralText": "Tarifas",
|
||||
"labourHoursColumn": "Horas de Trabajo",
|
||||
"actions": "Acciones",
|
||||
"selectJobTitle": "Seleccionar Título del Trabajo",
|
||||
"ratecardsPluralText": "Plantillas de Tarifas",
|
||||
"deleteConfirm": "¿Estás seguro?",
|
||||
"yes": "Sí",
|
||||
"no": "No",
|
||||
"alreadyImportedRateCardMessage": "Ya se ha importado una tarifa. Borra todas las tarifas importadas para agregar una nueva."
|
||||
}
|
||||
|
||||
@@ -31,5 +31,10 @@
|
||||
|
||||
"todoText": "Por Hacer",
|
||||
"doingText": "Haciendo",
|
||||
"doneText": "Hecho"
|
||||
"doneText": "Hecho",
|
||||
|
||||
"timeLogsColumn": "Registros de Tiempo",
|
||||
"timeLogsColumnTooltip": "Muestra la proporción de tiempo facturable vs no facturable",
|
||||
"billable": "Facturable",
|
||||
"nonBillable": "No Facturable"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"nameColumn": "Nombre",
|
||||
"createdColumn": "Creado",
|
||||
"noProjectsAvailable": "No hay proyectos disponibles",
|
||||
"deleteConfirmationTitle": "¿Está seguro de que desea eliminar esta tarjeta de tarifas?",
|
||||
"deleteConfirmationOk": "Sí, eliminar",
|
||||
"deleteConfirmationCancel": "Cancelar",
|
||||
"searchPlaceholder": "Buscar tarjetas de tarifas por nombre",
|
||||
"createRatecard": "Crear Tarjeta de Tarifas",
|
||||
"editTooltip": "Editar tarjeta de tarifas",
|
||||
"deleteTooltip": "Eliminar tarjeta de tarifas",
|
||||
"fetchError": "Error al cargar las tarjetas de tarifas",
|
||||
"createError": "Error al crear la tarjeta de tarifas",
|
||||
"deleteSuccess": "Tarjeta de tarifas eliminada con éxito",
|
||||
"deleteError": "Error al eliminar la tarjeta de tarifas",
|
||||
|
||||
"jobTitleColumn": "Título del trabajo",
|
||||
"ratePerHourColumn": "Tarifa por hora",
|
||||
"saveButton": "Guardar",
|
||||
"addRoleButton": "Añadir Rol",
|
||||
"createRatecardSuccessMessage": "Tarjeta de tarifas creada con éxito",
|
||||
"createRatecardErrorMessage": "Error al crear la tarjeta de tarifas",
|
||||
"updateRatecardSuccessMessage": "Tarjeta de tarifas actualizada con éxito",
|
||||
"updateRatecardErrorMessage": "Error al actualizar la tarjeta de tarifas",
|
||||
"currency": "Moneda",
|
||||
"actionsColumn": "Acciones",
|
||||
"addAllButton": "Añadir Todo",
|
||||
"removeAllButton": "Eliminar Todo",
|
||||
"selectJobTitle": "Seleccionar título del trabajo",
|
||||
"unsavedChangesTitle": "Tiene cambios sin guardar",
|
||||
"unsavedChangesMessage": "¿Desea guardar los cambios antes de salir?",
|
||||
"unsavedChangesSave": "Guardar",
|
||||
"unsavedChangesDiscard": "Descartar",
|
||||
"ratecardNameRequired": "El nombre de la tarjeta de tarifas es obligatorio",
|
||||
"ratecardNamePlaceholder": "Ingrese el nombre de la tarjeta de tarifas",
|
||||
"noRatecardsFound": "No se encontraron tarjetas de tarifas",
|
||||
"loadingRateCards": "Cargando tarjetas de tarifas...",
|
||||
"noJobTitlesAvailable": "No hay títulos de trabajo disponibles",
|
||||
"noRolesAdded": "Aún no se han añadido roles",
|
||||
"createFirstJobTitle": "Crear Primer Título de Trabajo",
|
||||
"jobRolesTitle": "Roles de Trabajo",
|
||||
"noJobTitlesMessage": "Por favor, cree títulos de trabajo primero en la configuración de Títulos de Trabajo antes de añadir roles a las tarjetas de tarifas.",
|
||||
"createNewJobTitle": "Crear Nuevo Título de Trabajo",
|
||||
"jobTitleNamePlaceholder": "Ingrese el nombre del título de trabajo",
|
||||
"jobTitleNameRequired": "El nombre del título de trabajo es obligatorio",
|
||||
"jobTitleCreatedSuccess": "Título de trabajo creado con éxito",
|
||||
"jobTitleCreateError": "Error al crear el título de trabajo",
|
||||
"createButton": "Crear",
|
||||
"cancelButton": "Cancelar"
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
"recurring": "Recurrente",
|
||||
"recurringTaskConfiguration": "Configuración de tarea recurrente",
|
||||
"repeats": "Repeticiones",
|
||||
"daily": "Diario",
|
||||
"weekly": "Semanal",
|
||||
"everyXDays": "Cada X días",
|
||||
"everyXWeeks": "Cada X semanas",
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"show-start-date": "Mostrar fecha de inicio",
|
||||
"hours": "Horas",
|
||||
"minutes": "Minutos",
|
||||
"time-estimation-disabled-tooltip": "La estimación de tiempo está deshabilitada porque esta tarea tiene {{count}} subtareas. La estimación mostrada es la suma de todas las subtareas.",
|
||||
"progressValue": "Valor de Progreso",
|
||||
"progressValueTooltip": "Establecer el porcentaje de progreso (0-100%)",
|
||||
"progressValueRequired": "Por favor, introduce un valor de progreso",
|
||||
@@ -79,7 +80,21 @@
|
||||
"addTimeLog": "Añadir nuevo registro de tiempo",
|
||||
"totalLogged": "Total registrado",
|
||||
"exportToExcel": "Exportar a Excel",
|
||||
"noTimeLogsFound": "No se encontraron registros de tiempo"
|
||||
"noTimeLogsFound": "No se encontraron registros de tiempo",
|
||||
"timerDisabledTooltip": "El temporizador está deshabilitado porque esta tarea tiene {{count}} subtareas. El tiempo debe registrarse en las subtareas individuales.",
|
||||
"timeLogDisabledTooltip": "El registro de tiempo está deshabilitado porque esta tarea tiene {{count}} subtareas. El tiempo debe registrarse en las subtareas individuales.",
|
||||
"date": "Fecha",
|
||||
"startTime": "Hora de inicio",
|
||||
"endTime": "Hora de finalización",
|
||||
"workDescription": "Descripción del trabajo",
|
||||
"requiredFields": "Por favor, complete todos los campos requeridos",
|
||||
"dateRequired": "Por favor, seleccione una fecha",
|
||||
"startTimeRequired": "Por favor, seleccione la hora de inicio",
|
||||
"endTimeRequired": "Por favor, seleccione la hora de finalización",
|
||||
"workDescriptionPlaceholder": "Añadir una descripción",
|
||||
"cancel": "Cancelar",
|
||||
"logTime": "Registrar tiempo",
|
||||
"updateTime": "Actualizar tiempo"
|
||||
},
|
||||
"taskActivityLogTab": {
|
||||
"title": "Registro de actividad"
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
"searchByName": "Buscar por nombre",
|
||||
"selectAll": "Seleccionar Todo",
|
||||
"clearAll": "Limpiar Todo",
|
||||
"teams": "Equipos",
|
||||
|
||||
"searchByProject": "Buscar por nombre de proyecto",
|
||||
@@ -15,6 +16,8 @@
|
||||
|
||||
"billable": "Facturable",
|
||||
"nonBillable": "No Facturable",
|
||||
"filterByBillableStatus": "Filtrar por Estado Facturable",
|
||||
"allBillableTypes": "Todos los Tipos Facturables",
|
||||
|
||||
"total": "Total",
|
||||
|
||||
@@ -40,5 +43,25 @@
|
||||
"noCategory": "No Categoría",
|
||||
"noProjects": "No se encontraron proyectos",
|
||||
"noTeams": "No se encontraron equipos",
|
||||
"noData": "No se encontraron datos"
|
||||
"noData": "No se encontraron datos",
|
||||
"members": "Miembros",
|
||||
"searchByMember": "Buscar por miembro",
|
||||
"utilization": "Utilización",
|
||||
|
||||
"totalTimeLogged": "Tiempo Total Registrado",
|
||||
"expectedCapacity": "Capacidad Esperada",
|
||||
"teamUtilization": "Utilización del Equipo",
|
||||
"variance": "Varianza",
|
||||
"acrossAllTeamMembers": "En todos los miembros del equipo",
|
||||
"basedOnWorkingSchedule": "Basado en horario de trabajo",
|
||||
"optimal": "Óptimo",
|
||||
"underUtilized": "Sub-utilizado",
|
||||
"overUtilized": "Sobre-utilizado",
|
||||
"overCapacity": "Sobre capacidad",
|
||||
"underCapacity": "Bajo capacidad",
|
||||
"considerWorkloadRedistribution": "Considerar redistribución de carga de trabajo",
|
||||
"capacityAvailableForNewProjects": "Capacidad disponible para nuevos proyectos",
|
||||
"targetRange": "Objetivo: 90-110%",
|
||||
"overtimeWork": "Trabajo de Horas Extras",
|
||||
"reviewWorkLifeBalance": "Revisar políticas de equilibrio trabajo-vida"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"financeText": "Finanças",
|
||||
"ratecardSingularText": "Cartão de Taxa",
|
||||
"groupByText": "Agrupar por",
|
||||
"statusText": "Status",
|
||||
"phaseText": "Fase",
|
||||
"priorityText": "Prioridade",
|
||||
"exportButton": "Exportar",
|
||||
"currencyText": "Moeda",
|
||||
"importButton": "Importar",
|
||||
"filterText": "Filtro",
|
||||
"billableOnlyText": "Apenas Faturável",
|
||||
"nonBillableOnlyText": "Apenas Não Faturável",
|
||||
"allTasksText": "Todas as Tarefas",
|
||||
|
||||
"taskColumn": "Tarefa",
|
||||
"membersColumn": "Membros",
|
||||
"hoursColumn": "Horas Estimadas",
|
||||
"totalTimeLoggedColumn": "Tempo Total Registrado",
|
||||
"costColumn": "Custo Real",
|
||||
"estimatedCostColumn": "Custo Estimado",
|
||||
"fixedCostColumn": "Custo Fixo",
|
||||
"totalBudgetedCostColumn": "Custo Total Orçado",
|
||||
"totalActualCostColumn": "Custo Real Total",
|
||||
"varianceColumn": "Variância",
|
||||
"totalText": "Total",
|
||||
"noTasksFound": "Nenhuma tarefa encontrada",
|
||||
|
||||
"addRoleButton": "+ Adicionar Função",
|
||||
"ratecardImportantNotice": "* Este cartão de taxa é gerado com base nos títulos de trabalho e taxas padrão da empresa. No entanto, você tem a flexibilidade de modificá-lo de acordo com o projeto. Essas alterações não afetarão os títulos de trabalho e taxas padrão da organização.",
|
||||
"saveButton": "Salvar",
|
||||
|
||||
"jobTitleColumn": "Título do Trabalho",
|
||||
"ratePerHourColumn": "Taxa por hora",
|
||||
"ratecardPluralText": "Cartões de Taxa",
|
||||
"labourHoursColumn": "Horas de Trabalho",
|
||||
"actions": "Ações",
|
||||
"selectJobTitle": "Selecionar Título do Trabalho",
|
||||
"ratecardsPluralText": "Modelos de Cartão de Taxa",
|
||||
"deleteConfirm": "Tem certeza?",
|
||||
"yes": "Sim",
|
||||
"no": "Não",
|
||||
"alreadyImportedRateCardMessage": "Um cartão de taxa já foi importado. Limpe todos os cartões de taxa importados para adicionar um novo."
|
||||
}
|
||||
|
||||
@@ -31,5 +31,10 @@
|
||||
|
||||
"todoText": "To Do",
|
||||
"doingText": "Doing",
|
||||
"doneText": "Done"
|
||||
"doneText": "Done",
|
||||
|
||||
"timeLogsColumn": "Registros de Tempo",
|
||||
"timeLogsColumnTooltip": "Mostra a proporção de tempo faturável vs não faturável",
|
||||
"billable": "Faturável",
|
||||
"nonBillable": "Não Faturável"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"nameColumn": "Nome",
|
||||
"createdColumn": "Criado",
|
||||
"noProjectsAvailable": "Nenhum projeto disponível",
|
||||
"deleteConfirmationTitle": "Tem certeza que deseja excluir esta tabela de preços?",
|
||||
"deleteConfirmationOk": "Sim, excluir",
|
||||
"deleteConfirmationCancel": "Cancelar",
|
||||
"searchPlaceholder": "Pesquisar tabelas de preços por nome",
|
||||
"createRatecard": "Criar Tabela de Preços",
|
||||
"editTooltip": "Editar tabela de preços",
|
||||
"deleteTooltip": "Excluir tabela de preços",
|
||||
"fetchError": "Falha ao carregar tabelas de preços",
|
||||
"createError": "Falha ao criar tabela de preços",
|
||||
"deleteSuccess": "Tabela de preços excluída com sucesso",
|
||||
"deleteError": "Falha ao excluir tabela de preços",
|
||||
|
||||
"jobTitleColumn": "Cargo",
|
||||
"ratePerHourColumn": "Taxa por hora",
|
||||
"saveButton": "Salvar",
|
||||
"addRoleButton": "Adicionar Cargo",
|
||||
"createRatecardSuccessMessage": "Tabela de preços criada com sucesso",
|
||||
"createRatecardErrorMessage": "Falha ao criar tabela de preços",
|
||||
"updateRatecardSuccessMessage": "Tabela de preços atualizada com sucesso",
|
||||
"updateRatecardErrorMessage": "Falha ao atualizar tabela de preços",
|
||||
"currency": "Moeda",
|
||||
"actionsColumn": "Ações",
|
||||
"addAllButton": "Adicionar Todos",
|
||||
"removeAllButton": "Remover Todos",
|
||||
"selectJobTitle": "Selecionar cargo",
|
||||
"unsavedChangesTitle": "Você tem alterações não salvas",
|
||||
"unsavedChangesMessage": "Deseja salvar suas alterações antes de sair?",
|
||||
"unsavedChangesSave": "Salvar",
|
||||
"unsavedChangesDiscard": "Descartar",
|
||||
"ratecardNameRequired": "O nome da tabela de preços é obrigatório",
|
||||
"ratecardNamePlaceholder": "Digite o nome da tabela de preços",
|
||||
"noRatecardsFound": "Nenhuma tabela de preços encontrada",
|
||||
"loadingRateCards": "Carregando tabelas de preços...",
|
||||
"noJobTitlesAvailable": "Nenhum cargo disponível",
|
||||
"noRolesAdded": "Nenhum cargo adicionado ainda",
|
||||
"createFirstJobTitle": "Criar Primeiro Cargo",
|
||||
"jobRolesTitle": "Cargos",
|
||||
"noJobTitlesMessage": "Por favor, crie cargos primeiro nas configurações de Cargos antes de adicionar funções às tabelas de preços.",
|
||||
"createNewJobTitle": "Criar Novo Cargo",
|
||||
"jobTitleNamePlaceholder": "Digite o nome do cargo",
|
||||
"jobTitleNameRequired": "O nome do cargo é obrigatório",
|
||||
"jobTitleCreatedSuccess": "Cargo criado com sucesso",
|
||||
"jobTitleCreateError": "Falha ao criar cargo",
|
||||
"createButton": "Criar",
|
||||
"cancelButton": "Cancelar"
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
"recurring": "Recorrente",
|
||||
"recurringTaskConfiguration": "Configuração de tarefa recorrente",
|
||||
"repeats": "Repete",
|
||||
"daily": "Diário",
|
||||
"weekly": "Semanal",
|
||||
"everyXDays": "A cada X dias",
|
||||
"everyXWeeks": "A cada X semanas",
|
||||
|
||||
@@ -23,7 +23,8 @@
|
||||
"show-start-date": "Mostrar data de início",
|
||||
"hours": "Horas",
|
||||
"minutes": "Minutos",
|
||||
"progressValue": "Valor de Progresso",
|
||||
"time-estimation-disabled-tooltip": "A estimativa de tempo está desabilitada porque esta tarefa tem {{count}} subtarefas. A estimativa mostrada é a soma de todas as subtarefas.",
|
||||
"progressValue": "Valor do Progresso",
|
||||
"progressValueTooltip": "Definir a porcentagem de progresso (0-100%)",
|
||||
"progressValueRequired": "Por favor, insira um valor de progresso",
|
||||
"progressValueRange": "O progresso deve estar entre 0 e 100",
|
||||
@@ -79,7 +80,21 @@
|
||||
"addTimeLog": "Adicionar novo registro de tempo",
|
||||
"totalLogged": "Total registrado",
|
||||
"exportToExcel": "Exportar para Excel",
|
||||
"noTimeLogsFound": "Nenhum registro de tempo encontrado"
|
||||
"noTimeLogsFound": "Nenhum registro de tempo encontrado",
|
||||
"timerDisabledTooltip": "O cronômetro está desabilitado porque esta tarefa tem {{count}} subtarefas. O tempo deve ser registrado nas subtarefas individuais.",
|
||||
"timeLogDisabledTooltip": "O registro de tempo está desabilitado porque esta tarefa tem {{count}} subtarefas. O tempo deve ser registrado nas subtarefas individuais.",
|
||||
"date": "Data",
|
||||
"startTime": "Hora de início",
|
||||
"endTime": "Hora de término",
|
||||
"workDescription": "Descrição do trabalho",
|
||||
"requiredFields": "Por favor, preencha todos os campos obrigatórios",
|
||||
"dateRequired": "Por favor, selecione uma data",
|
||||
"startTimeRequired": "Por favor, selecione a hora de início",
|
||||
"endTimeRequired": "Por favor, selecione a hora de término",
|
||||
"workDescriptionPlaceholder": "Adicionar uma descrição",
|
||||
"cancel": "Cancelar",
|
||||
"logTime": "Registrar tempo",
|
||||
"updateTime": "Atualizar tempo"
|
||||
},
|
||||
"taskActivityLogTab": {
|
||||
"title": "Registro de atividade"
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
"searchByName": "Pesquisar por nome",
|
||||
"selectAll": "Selecionar Todos",
|
||||
"clearAll": "Limpar Todos",
|
||||
"teams": "Equipes",
|
||||
|
||||
"searchByProject": "Pesquisar por nome do projeto",
|
||||
@@ -15,6 +16,8 @@
|
||||
|
||||
"billable": "Cobrável",
|
||||
"nonBillable": "Não Cobrável",
|
||||
"filterByBillableStatus": "Filtrar por Status de Cobrança",
|
||||
"allBillableTypes": "Todos os Tipos Cobráveis",
|
||||
|
||||
"total": "Total",
|
||||
|
||||
@@ -40,5 +43,25 @@
|
||||
"noCategory": "Nenhuma Categoria",
|
||||
"noProjects": "Nenhum projeto encontrado",
|
||||
"noTeams": "Nenhum time encontrado",
|
||||
"noData": "Nenhum dado encontrado"
|
||||
"noData": "Nenhum dado encontrado",
|
||||
"members": "Membros",
|
||||
"searchByMember": "Pesquisar por membro",
|
||||
"utilization": "Utilização",
|
||||
|
||||
"totalTimeLogged": "Tempo Total Registrado",
|
||||
"expectedCapacity": "Capacidade Esperada",
|
||||
"teamUtilization": "Utilização da Equipe",
|
||||
"variance": "Variância",
|
||||
"acrossAllTeamMembers": "Em todos os membros da equipe",
|
||||
"basedOnWorkingSchedule": "Baseado no horário de trabalho",
|
||||
"optimal": "Ótimo",
|
||||
"underUtilized": "Sub-utilizado",
|
||||
"overUtilized": "Super-utilizado",
|
||||
"overCapacity": "Acima da capacidade",
|
||||
"underCapacity": "Abaixo da capacidade",
|
||||
"considerWorkloadRedistribution": "Considerar redistribuição da carga de trabalho",
|
||||
"capacityAvailableForNewProjects": "Capacidade disponível para novos projetos",
|
||||
"targetRange": "Meta: 90-110%",
|
||||
"overtimeWork": "Trabalho de Horas Extras",
|
||||
"reviewWorkLifeBalance": "Revisar políticas de equilíbrio trabalho-vida"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
// Get __dirname equivalent for ES modules
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Create the directory if it doesn't exist
|
||||
const targetDir = path.join(__dirname, '..', 'public', 'tinymce');
|
||||
|
||||
@@ -6,6 +6,7 @@ import i18next from 'i18next';
|
||||
// Components
|
||||
import ThemeWrapper from './features/theme/ThemeWrapper';
|
||||
import PreferenceSelector from './components/PreferenceSelector';
|
||||
import ResourcePreloader from './components/resource-preloader/resource-preloader';
|
||||
|
||||
// Routes
|
||||
import router from './app/routes';
|
||||
@@ -13,13 +14,14 @@ import router from './app/routes';
|
||||
// Hooks & Utils
|
||||
import { useAppSelector } from './hooks/useAppSelector';
|
||||
import { initMixpanel } from './utils/mixpanelInit';
|
||||
import { initializeCsrfToken } from './api/api-client';
|
||||
|
||||
// Types & Constants
|
||||
import { Language } from './features/i18n/localesSlice';
|
||||
import logger from './utils/errorLogger';
|
||||
import { SuspenseFallback } from './components/suspense-fallback/suspense-fallback';
|
||||
|
||||
const App: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const App: React.FC<{ children?: React.ReactNode }> = ({ children }) => {
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const language = useAppSelector(state => state.localesReducer.lng);
|
||||
|
||||
@@ -35,10 +37,18 @@ const App: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
});
|
||||
}, [language]);
|
||||
|
||||
// Initialize CSRF token on app startup
|
||||
useEffect(() => {
|
||||
initializeCsrfToken().catch(error => {
|
||||
logger.error('Failed to initialize CSRF token:', error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Suspense fallback={<SuspenseFallback />}>
|
||||
<ThemeWrapper>
|
||||
<RouterProvider router={router} future={{ v7_startTransition: true }} />
|
||||
<ResourcePreloader />
|
||||
</ThemeWrapper>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -4,27 +4,36 @@ import alertService from '@/services/alerts/alertService';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import config from '@/config/env';
|
||||
|
||||
export const getCsrfToken = (): string | null => {
|
||||
const match = document.cookie.split('; ').find(cookie => cookie.startsWith('XSRF-TOKEN='));
|
||||
// Store CSRF token in memory (since csrf-sync uses session-based tokens)
|
||||
let csrfToken: string | null = null;
|
||||
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return decodeURIComponent(match.split('=')[1]);
|
||||
export const getCsrfToken = (): string | null => {
|
||||
return csrfToken;
|
||||
};
|
||||
|
||||
// Function to refresh CSRF token if needed
|
||||
// Function to refresh CSRF token from server
|
||||
export const refreshCsrfToken = async (): Promise<string | null> => {
|
||||
try {
|
||||
// Make a GET request to the server to get a fresh CSRF token
|
||||
await axios.get(`${config.apiUrl}/csrf-token`, { withCredentials: true });
|
||||
return getCsrfToken();
|
||||
const response = await axios.get(`${config.apiUrl}/csrf-token`, { withCredentials: true });
|
||||
if (response.data && response.data.token) {
|
||||
csrfToken = response.data.token;
|
||||
return csrfToken;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh CSRF token:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize CSRF token on app load
|
||||
export const initializeCsrfToken = async (): Promise<void> => {
|
||||
if (!csrfToken) {
|
||||
await refreshCsrfToken();
|
||||
}
|
||||
};
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: config.apiUrl,
|
||||
withCredentials: true,
|
||||
@@ -36,12 +45,16 @@ const apiClient = axios.create({
|
||||
|
||||
// Request interceptor
|
||||
apiClient.interceptors.request.use(
|
||||
config => {
|
||||
const token = getCsrfToken();
|
||||
if (token) {
|
||||
config.headers['X-CSRF-Token'] = token;
|
||||
async config => {
|
||||
// Ensure we have a CSRF token before making requests
|
||||
if (!csrfToken) {
|
||||
await refreshCsrfToken();
|
||||
}
|
||||
|
||||
if (csrfToken) {
|
||||
config.headers['X-CSRF-Token'] = csrfToken;
|
||||
} else {
|
||||
console.warn('No CSRF token found');
|
||||
console.warn('No CSRF token available');
|
||||
}
|
||||
return config;
|
||||
},
|
||||
@@ -84,7 +97,7 @@ apiClient.interceptors.response.use(
|
||||
(typeof errorResponse.data === 'object' &&
|
||||
errorResponse.data !== null &&
|
||||
'message' in errorResponse.data &&
|
||||
errorResponse.data.message === 'Invalid CSRF token' ||
|
||||
(errorResponse.data.message === 'invalid csrf token' || errorResponse.data.message === 'Invalid CSRF token') ||
|
||||
(error as any).code === 'EBADCSRFTOKEN')) {
|
||||
alertService.error('Security Error', 'Invalid security token. Refreshing your session...');
|
||||
|
||||
@@ -94,7 +107,7 @@ apiClient.interceptors.response.use(
|
||||
// Update the token in the failed request
|
||||
error.config.headers['X-CSRF-Token'] = newToken;
|
||||
// Retry the original request with the new token
|
||||
return axios(error.config);
|
||||
return apiClient(error.config);
|
||||
} else {
|
||||
// If token refresh failed, redirect to login
|
||||
window.location.href = '/auth/login';
|
||||
|
||||
@@ -5,7 +5,7 @@ import { toQueryString } from '@/utils/toQueryString';
|
||||
import { IHomeTasksModel, IHomeTasksConfig } from '@/types/home/home-page.types';
|
||||
import { IMyTask } from '@/types/home/my-tasks.types';
|
||||
import { IProject } from '@/types/project/project.types';
|
||||
import { getCsrfToken } from '../api-client';
|
||||
import { getCsrfToken, refreshCsrfToken } from '../api-client';
|
||||
import config from '@/config/env';
|
||||
|
||||
const rootUrl = '/home';
|
||||
@@ -14,9 +14,18 @@ const api = createApi({
|
||||
reducerPath: 'homePageApi',
|
||||
baseQuery: fetchBaseQuery({
|
||||
baseUrl: `${config.apiUrl}${API_BASE_URL}`,
|
||||
prepareHeaders: headers => {
|
||||
headers.set('X-CSRF-Token', getCsrfToken() || '');
|
||||
prepareHeaders: async headers => {
|
||||
// Get CSRF token, refresh if needed
|
||||
let token = getCsrfToken();
|
||||
if (!token) {
|
||||
token = await refreshCsrfToken();
|
||||
}
|
||||
|
||||
if (token) {
|
||||
headers.set('X-CSRF-Token', token);
|
||||
}
|
||||
headers.set('Content-Type', 'application/json');
|
||||
return headers;
|
||||
},
|
||||
credentials: 'include',
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import apiClient from '@api/api-client';
|
||||
import { API_BASE_URL } from '@/shared/constants';
|
||||
import { IServerResponse } from '@/types/common.types';
|
||||
import { IJobType, JobRoleType } from '@/types/project/ratecard.types';
|
||||
|
||||
const rootUrl = `${API_BASE_URL}/project-rate-cards`;
|
||||
|
||||
export interface IProjectRateCardRole {
|
||||
id?: string;
|
||||
project_id: string;
|
||||
job_title_id: string;
|
||||
jobtitle?: string;
|
||||
rate: number;
|
||||
data?: object;
|
||||
roles?: IJobType[];
|
||||
}
|
||||
|
||||
export const projectRateCardApiService = {
|
||||
// Insert multiple roles for a project
|
||||
async insertMany(project_id: string, roles: Omit<IProjectRateCardRole, 'id' | 'project_id'>[]): Promise<IServerResponse<IProjectRateCardRole[]>> {
|
||||
const response = await apiClient.post<IServerResponse<IProjectRateCardRole[]>>(rootUrl, { project_id, roles });
|
||||
return response.data;
|
||||
},
|
||||
// Insert a single role for a project
|
||||
async insertOne({ project_id, job_title_id, rate }: { project_id: string; job_title_id: string; rate: number }): Promise<IServerResponse<IProjectRateCardRole>> {
|
||||
const response = await apiClient.post<IServerResponse<IProjectRateCardRole>>(
|
||||
`${rootUrl}/create-project-rate-card-role`,
|
||||
{ project_id, job_title_id, rate }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get all roles for a project
|
||||
async getFromProjectId(project_id: string): Promise<IServerResponse<IProjectRateCardRole[]>> {
|
||||
const response = await apiClient.get<IServerResponse<IProjectRateCardRole[]>>(`${rootUrl}/project/${project_id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get a single role by id
|
||||
async getFromId(id: string): Promise<IServerResponse<IProjectRateCardRole>> {
|
||||
const response = await apiClient.get<IServerResponse<IProjectRateCardRole>>(`${rootUrl}/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update a single role by id
|
||||
async updateFromId(id: string, body: { job_title_id: string; rate: string }): Promise<IServerResponse<IProjectRateCardRole>> {
|
||||
const response = await apiClient.put<IServerResponse<IProjectRateCardRole>>(`${rootUrl}/${id}`, body);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update all roles for a project (delete then insert)
|
||||
async updateFromProjectId(project_id: string, roles: Omit<IProjectRateCardRole, 'id' | 'project_id'>[]): Promise<IServerResponse<IProjectRateCardRole[]>> {
|
||||
const response = await apiClient.put<IServerResponse<IProjectRateCardRole[]>>(`${rootUrl}/project/${project_id}`, { project_id, roles });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update project member rate card role
|
||||
async updateMemberRateCardRole(
|
||||
project_id: string,
|
||||
member_id: string,
|
||||
project_rate_card_role_id: string
|
||||
): Promise<IServerResponse<JobRoleType>> {
|
||||
const response = await apiClient.put<IServerResponse<JobRoleType>>(
|
||||
`${rootUrl}/project/${project_id}/members/${member_id}/rate-card-role`,
|
||||
{ project_rate_card_role_id }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Delete a single role by id
|
||||
async deleteFromId(id: string): Promise<IServerResponse<IProjectRateCardRole>> {
|
||||
const response = await apiClient.delete<IServerResponse<IProjectRateCardRole>>(`${rootUrl}/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Delete all roles for a project
|
||||
async deleteFromProjectId(project_id: string): Promise<IServerResponse<IProjectRateCardRole[]>> {
|
||||
const response = await apiClient.delete<IServerResponse<IProjectRateCardRole[]>>(`${rootUrl}/project/${project_id}`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
import { API_BASE_URL } from "@/shared/constants";
|
||||
import { IServerResponse } from "@/types/common.types";
|
||||
import apiClient from "../api-client";
|
||||
import { IProjectFinanceResponse, ITaskBreakdownResponse, IProjectFinanceTask } from "@/types/project/project-finance.types";
|
||||
|
||||
const rootUrl = `${API_BASE_URL}/project-finance`;
|
||||
|
||||
type BillableFilterType = 'all' | 'billable' | 'non-billable';
|
||||
|
||||
export const projectFinanceApiService = {
|
||||
getProjectTasks: async (
|
||||
projectId: string,
|
||||
groupBy: 'status' | 'priority' | 'phases' = 'status',
|
||||
billableFilter: BillableFilterType = 'billable'
|
||||
): Promise<IServerResponse<IProjectFinanceResponse>> => {
|
||||
const response = await apiClient.get<IServerResponse<IProjectFinanceResponse>>(
|
||||
`${rootUrl}/project/${projectId}/tasks`,
|
||||
{
|
||||
params: {
|
||||
group_by: groupBy,
|
||||
billable_filter: billableFilter
|
||||
}
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getSubTasks: async (
|
||||
projectId: string,
|
||||
parentTaskId: string,
|
||||
billableFilter: BillableFilterType = 'billable'
|
||||
): Promise<IServerResponse<IProjectFinanceTask[]>> => {
|
||||
const response = await apiClient.get<IServerResponse<IProjectFinanceTask[]>>(
|
||||
`${rootUrl}/project/${projectId}/tasks/${parentTaskId}/subtasks`,
|
||||
{
|
||||
params: {
|
||||
billable_filter: billableFilter
|
||||
}
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getTaskBreakdown: async (
|
||||
taskId: string
|
||||
): Promise<IServerResponse<ITaskBreakdownResponse>> => {
|
||||
const response = await apiClient.get<IServerResponse<ITaskBreakdownResponse>>(
|
||||
`${rootUrl}/task/${taskId}/breakdown`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateTaskFixedCost: async (
|
||||
taskId: string,
|
||||
fixedCost: number
|
||||
): Promise<IServerResponse<any>> => {
|
||||
const response = await apiClient.put<IServerResponse<any>>(
|
||||
`${rootUrl}/task/${taskId}/fixed-cost`,
|
||||
{ fixed_cost: fixedCost }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateProjectCurrency: async (
|
||||
projectId: string,
|
||||
currency: string
|
||||
): Promise<IServerResponse<any>> => {
|
||||
const response = await apiClient.put<IServerResponse<any>>(
|
||||
`${rootUrl}/project/${projectId}/currency`,
|
||||
{ currency }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
exportFinanceData: async (
|
||||
projectId: string,
|
||||
groupBy: 'status' | 'priority' | 'phases' = 'status',
|
||||
billableFilter: BillableFilterType = 'billable'
|
||||
): Promise<Blob> => {
|
||||
const response = await apiClient.get(
|
||||
`${rootUrl}/project/${projectId}/export`,
|
||||
{
|
||||
params: {
|
||||
groupBy,
|
||||
billable_filter: billableFilter
|
||||
},
|
||||
responseType: 'blob'
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { IProjectViewModel } from '@/types/project/projectViewModel.types';
|
||||
import { ITeamMemberOverviewGetResponse } from '@/types/project/project-insights.types';
|
||||
import { IProjectMembersViewModel } from '@/types/projectMember.types';
|
||||
import { IProjectManager } from '@/types/project/projectManager.types';
|
||||
import { ITaskPhase } from '@/types/tasks/taskPhase.types';
|
||||
|
||||
const rootUrl = `${API_BASE_URL}/projects`;
|
||||
|
||||
@@ -120,5 +121,14 @@ export const projectsApiService = {
|
||||
const response = await apiClient.get<IServerResponse<IProjectManager[]>>(`${url}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateProjectPhaseLabel: async (projectId: string, phaseLabel: string) => {
|
||||
const q = toQueryString({ id: projectId, current_project_id: projectId });
|
||||
const response = await apiClient.put<IServerResponse<ITaskPhase>>(
|
||||
`${rootUrl}/label/${projectId}${q}`,
|
||||
{ name: phaseLabel }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { IProjectCategory } from '@/types/project/projectCategory.types';
|
||||
import { IProjectsViewModel } from '@/types/project/projectsViewModel.types';
|
||||
import { IServerResponse } from '@/types/common.types';
|
||||
import { IProjectMembersViewModel } from '@/types/projectMember.types';
|
||||
import { getCsrfToken } from '../api-client';
|
||||
import { getCsrfToken, refreshCsrfToken } from '../api-client';
|
||||
import config from '@/config/env';
|
||||
|
||||
const rootUrl = '/projects';
|
||||
@@ -14,9 +14,18 @@ export const projectsApi = createApi({
|
||||
reducerPath: 'projectsApi',
|
||||
baseQuery: fetchBaseQuery({
|
||||
baseUrl: `${config.apiUrl}${API_BASE_URL}`,
|
||||
prepareHeaders: headers => {
|
||||
headers.set('X-CSRF-Token', getCsrfToken() || '');
|
||||
prepareHeaders: async headers => {
|
||||
// Get CSRF token, refresh if needed
|
||||
let token = getCsrfToken();
|
||||
if (!token) {
|
||||
token = await refreshCsrfToken();
|
||||
}
|
||||
|
||||
if (token) {
|
||||
headers.set('X-CSRF-Token', token);
|
||||
}
|
||||
headers.set('Content-Type', 'application/json');
|
||||
return headers;
|
||||
},
|
||||
credentials: 'include',
|
||||
}),
|
||||
|
||||
@@ -3,7 +3,7 @@ import { toQueryString } from '@/utils/toQueryString';
|
||||
import apiClient from '../api-client';
|
||||
import { IServerResponse } from '@/types/common.types';
|
||||
import { IAllocationViewModel } from '@/types/reporting/reporting-allocation.types';
|
||||
import { IProjectLogsBreakdown, IRPTTimeMember, IRPTTimeProject, ITimeLogBreakdownReq } from '@/types/reporting/reporting.types';
|
||||
import { IProjectLogsBreakdown, IRPTTimeMember, IRPTTimeMemberViewModel, IRPTTimeProject, ITimeLogBreakdownReq } from '@/types/reporting/reporting.types';
|
||||
|
||||
const rootUrl = `${API_BASE_URL}/reporting`;
|
||||
|
||||
@@ -25,7 +25,7 @@ export const reportingTimesheetApiService = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getMemberTimeSheets: async (body = {}, archived = false): Promise<IServerResponse<IRPTTimeMember[]>> => {
|
||||
getMemberTimeSheets: async (body = {}, archived = false): Promise<IServerResponse<IRPTTimeMemberViewModel>> => {
|
||||
const q = toQueryString({ archived });
|
||||
const response = await apiClient.post(`${rootUrl}/time-reports/members/${q}`, body);
|
||||
return response.data;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import apiClient from '@api/api-client';
|
||||
import { API_BASE_URL } from '@/shared/constants';
|
||||
import { IServerResponse } from '@/types/common.types';
|
||||
import { IJobTitle, IJobTitlesViewModel } from '@/types/job.types';
|
||||
import { toQueryString } from '@/utils/toQueryString';
|
||||
import { RatecardType, IRatecardViewModel } from '@/types/project/ratecard.types';
|
||||
|
||||
type IRatecard = {
|
||||
id: string;}
|
||||
|
||||
const rootUrl = `${API_BASE_URL}/rate-cards`;
|
||||
|
||||
export const rateCardApiService = {
|
||||
async getRateCards(
|
||||
index: number,
|
||||
size: number,
|
||||
field: string | null,
|
||||
order: string | null,
|
||||
search?: string | null
|
||||
): Promise<IServerResponse<IRatecardViewModel>> {
|
||||
const s = encodeURIComponent(search || '');
|
||||
const queryString = toQueryString({ index, size, field, order, search: s });
|
||||
const response = await apiClient.get<IServerResponse<IRatecardViewModel>>(
|
||||
`${rootUrl}${queryString}`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
async getRateCardById(id: string): Promise<IServerResponse<RatecardType>> {
|
||||
const response = await apiClient.get<IServerResponse<RatecardType>>(`${rootUrl}/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async createRateCard(body: RatecardType): Promise<IServerResponse<RatecardType>> {
|
||||
const response = await apiClient.post<IServerResponse<RatecardType>>(rootUrl, body);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async updateRateCard(id: string, body: RatecardType): Promise<IServerResponse<RatecardType>> {
|
||||
const response = await apiClient.put<IServerResponse<RatecardType>>(`${rootUrl}/${id}`, body);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async deleteRateCard(id: string): Promise<IServerResponse<void>> {
|
||||
const response = await apiClient.delete<IServerResponse<void>>(`${rootUrl}/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
};
|
||||
@@ -5,6 +5,16 @@ import { ITaskLogViewModel } from "@/types/tasks/task-log-view.types";
|
||||
|
||||
const rootUrl = `${API_BASE_URL}/task-time-log`;
|
||||
|
||||
export interface IRunningTimer {
|
||||
task_id: string;
|
||||
start_time: string;
|
||||
task_name: string;
|
||||
project_id: string;
|
||||
project_name: string;
|
||||
parent_task_id?: string;
|
||||
parent_task_name?: string;
|
||||
}
|
||||
|
||||
export const taskTimeLogsApiService = {
|
||||
getByTask: async (id: string) : Promise<IServerResponse<ITaskLogViewModel[]>> => {
|
||||
const response = await apiClient.get(`${rootUrl}/task/${id}`);
|
||||
@@ -26,6 +36,11 @@ export const taskTimeLogsApiService = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getRunningTimers: async (): Promise<IServerResponse<IRunningTimer[]>> => {
|
||||
const response = await apiClient.get(`${rootUrl}/running-timers`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
exportToExcel(taskId: string) {
|
||||
window.location.href = `${import.meta.env.VITE_API_URL}${API_BASE_URL}/task-time-log/export/${taskId}`;
|
||||
},
|
||||
|
||||
@@ -69,9 +69,11 @@ import projectReportsTableColumnsReducer from '../features/reporting/projectRepo
|
||||
import projectReportsReducer from '../features/reporting/projectReports/project-reports-slice';
|
||||
import membersReportsReducer from '../features/reporting/membersReports/membersReportsSlice';
|
||||
import timeReportsOverviewReducer from '@features/reporting/time-reports/time-reports-overview.slice';
|
||||
|
||||
import financeReducer from '../features/finance/finance-slice';
|
||||
import roadmapReducer from '../features/roadmap/roadmap-slice';
|
||||
import teamMembersReducer from '@features/team-members/team-members.slice';
|
||||
import projectFinanceRateCardReducer from '../features/finance/project-finance-slice';
|
||||
import projectFinancesReducer from '../features/projects/finance/project-finance.slice';
|
||||
import groupByFilterDropdownReducer from '../features/group-by-filter-dropdown/group-by-filter-dropdown-slice';
|
||||
import homePageApiService from '@/api/home-page/home-page.api.service';
|
||||
import { projectsApi } from '@/api/projects/projects.v1.api.service';
|
||||
@@ -155,6 +157,9 @@ export const store = configureStore({
|
||||
roadmapReducer: roadmapReducer,
|
||||
groupByFilterDropdownReducer: groupByFilterDropdownReducer,
|
||||
timeReportsOverviewReducer: timeReportsOverviewReducer,
|
||||
financeReducer: financeReducer,
|
||||
projectFinanceRateCard: projectFinanceRateCardReducer,
|
||||
projectFinances: projectFinancesReducer,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ type EmptyListPlaceholderProps = {
|
||||
};
|
||||
|
||||
const EmptyListPlaceholder = ({
|
||||
imageSrc = '/src/assets/images/empty-box.webp',
|
||||
imageSrc = 'https://s3.us-west-2.amazonaws.com/worklenz.com/assets/empty-box.webp',
|
||||
imageHeight = 60,
|
||||
text,
|
||||
}: EmptyListPlaceholderProps) => {
|
||||
|
||||
24
worklenz-frontend/src/components/HubSpot.tsx
Normal file
24
worklenz-frontend/src/components/HubSpot.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const HubSpot = () => {
|
||||
useEffect(() => {
|
||||
const script = document.createElement('script');
|
||||
script.type = 'text/javascript';
|
||||
script.id = 'hs-script-loader';
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.src = '//js.hs-scripts.com/22348300.js';
|
||||
document.body.appendChild(script);
|
||||
|
||||
return () => {
|
||||
const existingScript = document.getElementById('hs-script-loader');
|
||||
if (existingScript) {
|
||||
existingScript.remove();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default HubSpot;
|
||||
@@ -4,19 +4,20 @@ import { InlineMember } from '@/types/teamMembers/inlineMember.types';
|
||||
interface AvatarsProps {
|
||||
members: InlineMember[];
|
||||
maxCount?: number;
|
||||
allowClickThrough?: boolean;
|
||||
}
|
||||
|
||||
const renderAvatar = (member: InlineMember, index: number) => (
|
||||
const renderAvatar = (member: InlineMember, index: number, allowClickThrough: boolean = false) => (
|
||||
<Tooltip
|
||||
key={member.team_member_id || index}
|
||||
title={member.end && member.names ? member.names.join(', ') : member.name}
|
||||
>
|
||||
{member.avatar_url ? (
|
||||
<span onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
||||
<span onClick={allowClickThrough ? undefined : (e: React.MouseEvent) => e.stopPropagation()}>
|
||||
<Avatar src={member.avatar_url} size={28} key={member.team_member_id || index} />
|
||||
</span>
|
||||
) : (
|
||||
<span onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
||||
<span onClick={allowClickThrough ? undefined : (e: React.MouseEvent) => e.stopPropagation()}>
|
||||
<Avatar
|
||||
size={28}
|
||||
key={member.team_member_id || index}
|
||||
@@ -32,12 +33,12 @@ const renderAvatar = (member: InlineMember, index: number) => (
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const Avatars: React.FC<AvatarsProps> = ({ members, maxCount }) => {
|
||||
const Avatars: React.FC<AvatarsProps> = ({ members, maxCount, allowClickThrough = false }) => {
|
||||
const visibleMembers = maxCount ? members.slice(0, maxCount) : members;
|
||||
return (
|
||||
<div onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
||||
<div onClick={allowClickThrough ? undefined : (e: React.MouseEvent) => e.stopPropagation()}>
|
||||
<Avatar.Group>
|
||||
{visibleMembers.map((member, index) => renderAvatar(member, index))}
|
||||
{visibleMembers.map((member, index) => renderAvatar(member, index, allowClickThrough))}
|
||||
</Avatar.Group>
|
||||
</div>
|
||||
);
|
||||
|
||||
61
worklenz-frontend/src/components/conditional-alert.tsx
Normal file
61
worklenz-frontend/src/components/conditional-alert.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Alert } from 'antd';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface ConditionalAlertProps {
|
||||
message?: string;
|
||||
type?: 'success' | 'info' | 'warning' | 'error';
|
||||
showInitially?: boolean;
|
||||
onClose?: () => void;
|
||||
condition?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ConditionalAlert = ({
|
||||
message = '',
|
||||
type = 'info',
|
||||
showInitially = false,
|
||||
onClose,
|
||||
condition,
|
||||
className = ''
|
||||
}: ConditionalAlertProps) => {
|
||||
const [visible, setVisible] = useState(showInitially);
|
||||
|
||||
useEffect(() => {
|
||||
if (condition !== undefined) {
|
||||
setVisible(condition);
|
||||
}
|
||||
}, [condition]);
|
||||
|
||||
const handleClose = () => {
|
||||
setVisible(false);
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
const alertStyles = {
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1000,
|
||||
margin: 0,
|
||||
borderRadius: 0,
|
||||
} as const;
|
||||
|
||||
if (!visible || !message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert
|
||||
message={message}
|
||||
type={type}
|
||||
closable
|
||||
onClose={handleClose}
|
||||
style={alertStyles}
|
||||
showIcon
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConditionalAlert;
|
||||
184
worklenz-frontend/src/components/license-alert.tsx
Normal file
184
worklenz-frontend/src/components/license-alert.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import { Alert, Button, Space } from 'antd';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { CrownOutlined, ClockCircleOutlined } from '@ant-design/icons';
|
||||
import { ILocalSession } from '@/types/auth/local-session.types';
|
||||
import { LICENSE_ALERT_KEY } from '@/shared/constants';
|
||||
import { format, isSameDay, differenceInDays, addDays, isAfter } from 'date-fns';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface LicenseAlertProps {
|
||||
currentSession: ILocalSession;
|
||||
onVisibilityChange?: (visible: boolean) => void;
|
||||
}
|
||||
|
||||
interface AlertConfig {
|
||||
type: 'success' | 'info' | 'warning' | 'error';
|
||||
message: React.ReactNode;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
licenseType: 'trial' | 'expired' | 'expiring';
|
||||
daysRemaining: number;
|
||||
}
|
||||
|
||||
const LicenseAlert = ({ currentSession, onVisibilityChange }: LicenseAlertProps) => {
|
||||
const navigate = useNavigate();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [alertConfig, setAlertConfig] = useState<AlertConfig | null>(null);
|
||||
|
||||
const handleClose = () => {
|
||||
setVisible(false);
|
||||
setLastAlertDate(new Date());
|
||||
};
|
||||
|
||||
const getLastAlertDate = () => {
|
||||
const lastAlertDate = localStorage.getItem(LICENSE_ALERT_KEY);
|
||||
return lastAlertDate ? new Date(lastAlertDate) : null;
|
||||
};
|
||||
|
||||
const setLastAlertDate = (date: Date) => {
|
||||
localStorage.setItem(LICENSE_ALERT_KEY, format(date, 'yyyy-MM-dd'));
|
||||
};
|
||||
|
||||
const handleUpgrade = () => {
|
||||
navigate('/worklenz/admin-center/billing');
|
||||
};
|
||||
|
||||
const handleExtend = () => {
|
||||
navigate('/worklenz/admin-center/billing');
|
||||
};
|
||||
|
||||
const getVisibleAndConfig = (): { visible: boolean; config: AlertConfig | null } => {
|
||||
const lastAlertDate = getLastAlertDate();
|
||||
|
||||
// Check if alert was already shown today
|
||||
if (lastAlertDate && isSameDay(lastAlertDate, new Date())) {
|
||||
return { visible: false, config: null };
|
||||
}
|
||||
|
||||
if (!currentSession.valid_till_date) {
|
||||
return { visible: false, config: null };
|
||||
}
|
||||
|
||||
let validTillDate = new Date(currentSession.valid_till_date);
|
||||
const today = new Date();
|
||||
|
||||
// If validTillDate is after today, add 1 day (matching Angular logic)
|
||||
if (isAfter(validTillDate, today)) {
|
||||
validTillDate = addDays(validTillDate, 1);
|
||||
}
|
||||
|
||||
// Calculate the difference in days between the two dates
|
||||
const daysDifference = differenceInDays(validTillDate, today);
|
||||
|
||||
// Don't show if no valid_till_date or difference is >= 7 days
|
||||
if (daysDifference >= 7) {
|
||||
return { visible: false, config: null };
|
||||
}
|
||||
|
||||
const absDaysDifference = Math.abs(daysDifference);
|
||||
const dayText = `${absDaysDifference} day${absDaysDifference === 1 ? '' : 's'}`;
|
||||
|
||||
let string1 = '';
|
||||
let string2 = dayText;
|
||||
let licenseType: 'trial' | 'expired' | 'expiring' = 'expiring';
|
||||
let alertType: 'success' | 'info' | 'warning' | 'error' = 'warning';
|
||||
|
||||
if (currentSession.subscription_status === 'trialing') {
|
||||
licenseType = 'trial';
|
||||
if (daysDifference < 0) {
|
||||
string1 = 'Your Worklenz trial expired';
|
||||
string2 = string2 + ' ago';
|
||||
alertType = 'error';
|
||||
licenseType = 'expired';
|
||||
} else if (daysDifference !== 0 && daysDifference < 7) {
|
||||
string1 = 'Your Worklenz trial expires in';
|
||||
} else if (daysDifference === 0 && daysDifference < 7) {
|
||||
string1 = 'Your Worklenz trial expires';
|
||||
string2 = 'today';
|
||||
}
|
||||
} else if (currentSession.subscription_status === 'active') {
|
||||
if (daysDifference < 0) {
|
||||
string1 = 'Your Worklenz subscription expired';
|
||||
string2 = string2 + ' ago';
|
||||
alertType = 'error';
|
||||
licenseType = 'expired';
|
||||
} else if (daysDifference !== 0 && daysDifference < 7) {
|
||||
string1 = 'Your Worklenz subscription expires in';
|
||||
} else if (daysDifference === 0 && daysDifference < 7) {
|
||||
string1 = 'Your Worklenz subscription expires';
|
||||
string2 = 'today';
|
||||
}
|
||||
} else {
|
||||
return { visible: false, config: null };
|
||||
}
|
||||
|
||||
const config: AlertConfig = {
|
||||
type: alertType,
|
||||
message: (
|
||||
<>
|
||||
Action required! {string1} <strong>{string2}</strong>
|
||||
</>
|
||||
),
|
||||
description: '',
|
||||
icon: licenseType === 'expired' || licenseType === 'trial' ? <CrownOutlined /> : <ClockCircleOutlined />,
|
||||
licenseType,
|
||||
daysRemaining: absDaysDifference
|
||||
};
|
||||
|
||||
return { visible: true, config };
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const { visible: shouldShow, config } = getVisibleAndConfig();
|
||||
setVisible(shouldShow);
|
||||
setAlertConfig(config);
|
||||
|
||||
// Notify parent about visibility change
|
||||
if (onVisibilityChange) {
|
||||
onVisibilityChange(shouldShow);
|
||||
}
|
||||
}, [currentSession, onVisibilityChange]);
|
||||
|
||||
const alertStyles = {
|
||||
margin: 0,
|
||||
borderRadius: 0,
|
||||
} as const;
|
||||
|
||||
const actionButtons = alertConfig && (
|
||||
<Space>
|
||||
{/* Show button only if user is owner or admin */}
|
||||
{(currentSession.owner || currentSession.is_admin) && (
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
onClick={currentSession.subscription_status === 'trialing' ? handleUpgrade : handleExtend}
|
||||
>
|
||||
{currentSession.subscription_status === 'trialing' ? 'Upgrade now' : 'Go to Billing'}
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
|
||||
if (!visible || !alertConfig) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-license-alert>
|
||||
<Alert
|
||||
message={alertConfig.message}
|
||||
type={alertConfig.type}
|
||||
closable
|
||||
onClose={handleClose}
|
||||
style={{
|
||||
...alertStyles,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
showIcon
|
||||
action={actionButtons}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LicenseAlert;
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { InputRef } from 'antd/es/input';
|
||||
import Dropdown from 'antd/es/dropdown';
|
||||
import Card from 'antd/es/card';
|
||||
import List from 'antd/es/list';
|
||||
import Input from 'antd/es/input';
|
||||
import Checkbox from 'antd/es/checkbox';
|
||||
import Button from 'antd/es/button';
|
||||
import Empty from 'antd/es/empty';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import SingleAvatar from '../common/single-avatar/single-avatar';
|
||||
import { IProjectMemberViewModel } from '@/types/projectMember.types';
|
||||
|
||||
interface RateCardAssigneeSelectorProps {
|
||||
projectId: string;
|
||||
onChange?: (memberId: string) => void;
|
||||
selectedMemberIds?: string[];
|
||||
memberlist?: IProjectMemberViewModel[];
|
||||
}
|
||||
|
||||
const RateCardAssigneeSelector = ({
|
||||
projectId,
|
||||
onChange,
|
||||
selectedMemberIds = [],
|
||||
memberlist = [],
|
||||
assignedMembers = [], // New prop: List of all assigned member IDs across all job titles
|
||||
}: RateCardAssigneeSelectorProps & { assignedMembers: string[] }) => {
|
||||
const membersInputRef = useRef<InputRef>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [members, setMembers] = useState<IProjectMemberViewModel[]>(memberlist);
|
||||
|
||||
useEffect(() => {
|
||||
setMembers(memberlist);
|
||||
}, [memberlist]);
|
||||
|
||||
const filteredMembers = members.filter((member) =>
|
||||
member.name?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const dropdownContent = (
|
||||
<Card styles={{ body: { padding: 8 } }}>
|
||||
<Input
|
||||
ref={membersInputRef}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.currentTarget.value)}
|
||||
placeholder="Search members"
|
||||
/>
|
||||
<List style={{ padding: 0, maxHeight: 200, overflow: 'auto' }}>
|
||||
{filteredMembers.length ? (
|
||||
filteredMembers.map((member) => {
|
||||
const isAssignedToAnotherJobTitle =
|
||||
assignedMembers.includes(member.id || '') &&
|
||||
!selectedMemberIds.includes(member.id || ''); // Check if the member is assigned elsewhere
|
||||
|
||||
return (
|
||||
<List.Item
|
||||
key={member.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
padding: '4px 8px',
|
||||
border: 'none',
|
||||
opacity: member.pending_invitation || isAssignedToAnotherJobTitle ? 0.5 : 1,
|
||||
justifyContent: 'flex-start',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedMemberIds.includes(member.id || '')}
|
||||
disabled={member.pending_invitation || isAssignedToAnotherJobTitle}
|
||||
onChange={() => onChange?.(member.id || '')}
|
||||
/>
|
||||
<SingleAvatar
|
||||
avatarUrl={member.avatar_url}
|
||||
name={member.name}
|
||||
email={member.email}
|
||||
/>
|
||||
<span>{member.name}</span>
|
||||
</List.Item>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<Empty description="No members found" />
|
||||
)}
|
||||
</List>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
overlayClassName="custom-dropdown"
|
||||
trigger={['click']}
|
||||
dropdownRender={() => dropdownContent}
|
||||
onOpenChange={(open) => {
|
||||
if (open) setTimeout(() => membersInputRef.current?.focus(), 0);
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="dashed"
|
||||
shape="circle"
|
||||
size="small"
|
||||
icon={<PlusOutlined style={{ fontSize: 12 }} />}
|
||||
/>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default RateCardAssigneeSelector;
|
||||
@@ -147,6 +147,10 @@ const TimeWiseFilter = () => {
|
||||
format={'MMM DD, YYYY'}
|
||||
onChange={handleDateRangeChange}
|
||||
value={customRange ? [dayjs(customRange[0]), dayjs(customRange[1])] : null}
|
||||
disabledDate={(current) => {
|
||||
// Disable dates after today
|
||||
return current && current > dayjs().endOf('day');
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* ResourcePreloader component to preload critical chunks for better performance
|
||||
* This helps reduce loading times when users navigate to different project view tabs
|
||||
*/
|
||||
const ResourcePreloader = () => {
|
||||
useEffect(() => {
|
||||
// Preload critical project view chunks after initial page load
|
||||
const preloadCriticalChunks = () => {
|
||||
// Only preload in production and if the user is likely to use project views
|
||||
if (import.meta.env.DEV) return;
|
||||
|
||||
// Check if user is on a project-related page or dashboard
|
||||
const currentPath = window.location.pathname;
|
||||
const isProjectRelated = currentPath.includes('/projects') ||
|
||||
currentPath.includes('/worklenz') ||
|
||||
currentPath === '/';
|
||||
|
||||
if (!isProjectRelated) return;
|
||||
|
||||
// Preload the most commonly used project view components
|
||||
const criticalImports = [
|
||||
() => import('@/pages/projects/projectView/taskList/project-view-task-list'),
|
||||
() => import('@/pages/projects/projectView/board/project-view-board'),
|
||||
() => import('@/components/project-task-filters/filter-dropdowns/group-by-filter-dropdown'),
|
||||
() => import('@/components/project-task-filters/filter-dropdowns/search-dropdown'),
|
||||
];
|
||||
|
||||
// Preload with a small delay to not interfere with initial page load
|
||||
setTimeout(() => {
|
||||
criticalImports.forEach(importFn => {
|
||||
importFn().catch(error => {
|
||||
// Silently handle preload failures - they're not critical
|
||||
console.debug('Preload failed:', error);
|
||||
});
|
||||
});
|
||||
}, 2000); // 2 second delay after initial load
|
||||
};
|
||||
|
||||
// Start preloading when component mounts
|
||||
preloadCriticalChunks();
|
||||
}, []);
|
||||
|
||||
// This component doesn't render anything
|
||||
return null;
|
||||
};
|
||||
|
||||
export default ResourcePreloader;
|
||||
@@ -1,11 +1,13 @@
|
||||
import StatusGroupTables from '@/pages/projects/project-view-1/taskList/statusTables/StatusGroupTables';
|
||||
import TaskGroupList from '@/pages/projects/projectView/taskList/groupTables/TaskGroupList';
|
||||
import { TaskType } from '@/types/task.types';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import GroupByFilterDropdown from '@/pages/projects/project-view-1/taskList/taskListFilters/GroupByFilterDropdown';
|
||||
import GroupByFilterDropdown from '@/components/project-task-filters/filter-dropdowns/group-by-filter-dropdown';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ITaskListGroup } from '@/types/tasks/taskList.types';
|
||||
|
||||
const WithStartAndEndDates = () => {
|
||||
const dataSource: TaskType[] = useAppSelector(state => state.taskReducer.tasks);
|
||||
const dataSource: ITaskListGroup[] = useAppSelector(state => state.taskReducer.taskGroups);
|
||||
const groupBy = useAppSelector(state => state.taskReducer.groupBy);
|
||||
const { t } = useTranslation('schedule');
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||
@@ -57,7 +59,7 @@ const WithStartAndEndDates = () => {
|
||||
<GroupByFilterDropdown />
|
||||
</div>
|
||||
<div>
|
||||
<StatusGroupTables datasource={dataSource} />
|
||||
<TaskGroupList taskGroups={dataSource} groupBy={groupBy} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,13 +2,14 @@ import { TaskType } from '@/types/task.types';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import GroupByFilterDropdown from '@/components/project-task-filters/filter-dropdowns/group-by-filter-dropdown';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import StatusGroupTables from '@/pages/projects/project-view-1/taskList/statusTables/StatusGroupTables';
|
||||
import TaskGroupList from '@/pages/projects/projectView/taskList/groupTables/TaskGroupList';
|
||||
import PriorityGroupTables from '@/pages/projects/projectView/taskList/groupTables/priorityTables/PriorityGroupTables';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
import { ITaskListGroup } from '@/types/tasks/taskList.types';
|
||||
|
||||
const WithStartAndEndDates = () => {
|
||||
const dataSource: ITaskListGroup[] = useAppSelector(state => state.taskReducer.taskGroups);
|
||||
const groupBy = useAppSelector(state => state.taskReducer.groupBy);
|
||||
const { t } = useTranslation('schedule');
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||
@@ -60,10 +61,7 @@ const WithStartAndEndDates = () => {
|
||||
<GroupByFilterDropdown />
|
||||
</div>
|
||||
<div>
|
||||
{dataSource.map(group => (
|
||||
<StatusGroupTables key={group.id} group={group} />
|
||||
))}
|
||||
{/* <PriorityGroupTables datasource={dataSource} /> */}
|
||||
<TaskGroupList taskGroups={dataSource} groupBy={groupBy} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,23 +2,41 @@ import { SocketEvents } from '@/shared/socket-events';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { ITaskViewModel } from '@/types/tasks/task.types';
|
||||
import { Flex, Form, FormInstance, InputNumber, Typography } from 'antd';
|
||||
import { Flex, Form, FormInstance, InputNumber, Typography, Tooltip } from 'antd';
|
||||
import { TFunction } from 'i18next';
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface TaskDrawerEstimationProps {
|
||||
t: TFunction;
|
||||
task: ITaskViewModel;
|
||||
form: FormInstance<any>;
|
||||
subTasksEstimation?: { hours: number; minutes: number }; // Sum of subtasks estimation
|
||||
}
|
||||
|
||||
const TaskDrawerEstimation = ({ t, task, form }: TaskDrawerEstimationProps) => {
|
||||
const TaskDrawerEstimation = ({ t, task, form, subTasksEstimation }: TaskDrawerEstimationProps) => {
|
||||
const { socket, connected } = useSocket();
|
||||
const [hours, setHours] = useState(0);
|
||||
const [minutes, setMinutes] = useState(0);
|
||||
|
||||
// Check if task has subtasks
|
||||
const hasSubTasks = (task?.sub_tasks_count || 0) > 0;
|
||||
|
||||
// Use subtasks estimation if available, otherwise use task's own estimation
|
||||
const displayHours = hasSubTasks && subTasksEstimation ? subTasksEstimation.hours : (task?.total_hours || 0);
|
||||
const displayMinutes = hasSubTasks && subTasksEstimation ? subTasksEstimation.minutes : (task?.total_minutes || 0);
|
||||
|
||||
useEffect(() => {
|
||||
// Update form values when subtasks estimation changes
|
||||
if (hasSubTasks && subTasksEstimation) {
|
||||
form.setFieldsValue({
|
||||
hours: subTasksEstimation.hours,
|
||||
minutes: subTasksEstimation.minutes
|
||||
});
|
||||
}
|
||||
}, [subTasksEstimation, hasSubTasks, form]);
|
||||
|
||||
const handleTimeEstimationBlur = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||
if (!connected || !task.id) return;
|
||||
if (!connected || !task.id || hasSubTasks) return;
|
||||
|
||||
// Get current form values instead of using state
|
||||
const currentHours = form.getFieldValue('hours') || 0;
|
||||
@@ -35,48 +53,69 @@ const TaskDrawerEstimation = ({ t, task, form }: TaskDrawerEstimationProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const tooltipTitle = hasSubTasks
|
||||
? t('taskInfoTab.details.time-estimation-disabled-tooltip', {
|
||||
count: task?.sub_tasks_count || 0,
|
||||
defaultValue: `Time estimation is disabled because this task has ${task?.sub_tasks_count || 0} subtasks. The estimation shown is the sum of all subtasks.`
|
||||
})
|
||||
: '';
|
||||
|
||||
return (
|
||||
<Form.Item name="timeEstimation" label={t('taskInfoTab.details.time-estimation')}>
|
||||
<Flex gap={8}>
|
||||
<Form.Item
|
||||
name={'hours'}
|
||||
label={
|
||||
<Typography.Text style={{ color: colors.lightGray, fontSize: 12 }}>
|
||||
{t('taskInfoTab.details.hours')}
|
||||
</Typography.Text>
|
||||
}
|
||||
style={{ marginBottom: 36 }}
|
||||
labelCol={{ style: { paddingBlock: 0 } }}
|
||||
layout="vertical"
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={24}
|
||||
placeholder={t('taskInfoTab.details.hours')}
|
||||
onBlur={handleTimeEstimationBlur}
|
||||
onChange={value => setHours(value || 0)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={'minutes'}
|
||||
label={
|
||||
<Typography.Text style={{ color: colors.lightGray, fontSize: 12 }}>
|
||||
{t('taskInfoTab.details.minutes')}
|
||||
</Typography.Text>
|
||||
}
|
||||
style={{ marginBottom: 36 }}
|
||||
labelCol={{ style: { paddingBlock: 0 } }}
|
||||
layout="vertical"
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={60}
|
||||
placeholder={t('taskInfoTab.details.minutes')}
|
||||
onBlur={handleTimeEstimationBlur}
|
||||
onChange={value => setMinutes(value || 0)}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Flex>
|
||||
<Tooltip title={tooltipTitle} trigger={hasSubTasks ? 'hover' : []}>
|
||||
<Flex gap={8}>
|
||||
<Form.Item
|
||||
name={'hours'}
|
||||
label={
|
||||
<Typography.Text style={{ color: colors.lightGray, fontSize: 12 }}>
|
||||
{t('taskInfoTab.details.hours')}
|
||||
</Typography.Text>
|
||||
}
|
||||
style={{ marginBottom: 36 }}
|
||||
labelCol={{ style: { paddingBlock: 0 } }}
|
||||
layout="vertical"
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={24}
|
||||
placeholder={t('taskInfoTab.details.hours')}
|
||||
onBlur={handleTimeEstimationBlur}
|
||||
onChange={value => !hasSubTasks && setHours(value || 0)}
|
||||
disabled={hasSubTasks}
|
||||
value={displayHours}
|
||||
style={{
|
||||
cursor: hasSubTasks ? 'not-allowed' : 'default',
|
||||
opacity: hasSubTasks ? 0.6 : 1
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={'minutes'}
|
||||
label={
|
||||
<Typography.Text style={{ color: colors.lightGray, fontSize: 12 }}>
|
||||
{t('taskInfoTab.details.minutes')}
|
||||
</Typography.Text>
|
||||
}
|
||||
style={{ marginBottom: 36 }}
|
||||
labelCol={{ style: { paddingBlock: 0 } }}
|
||||
layout="vertical"
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={60}
|
||||
placeholder={t('taskInfoTab.details.minutes')}
|
||||
onBlur={handleTimeEstimationBlur}
|
||||
onChange={value => !hasSubTasks && setMinutes(value || 0)}
|
||||
disabled={hasSubTasks}
|
||||
value={displayMinutes}
|
||||
style={{
|
||||
cursor: hasSubTasks ? 'not-allowed' : 'default',
|
||||
opacity: hasSubTasks ? 0.6 : 1
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Flex>
|
||||
</Tooltip>
|
||||
</Form.Item>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -24,44 +24,46 @@ import { taskRecurringApiService } from '@/api/tasks/task-recurring.api.service'
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { setTaskRecurringSchedule } from '@/features/task-drawer/task-drawer.slice';
|
||||
|
||||
const repeatOptions: IRepeatOption[] = [
|
||||
{ label: 'Daily', value: ITaskRecurring.Daily },
|
||||
{ label: 'Weekly', value: ITaskRecurring.Weekly },
|
||||
{ label: 'Every X Days', value: ITaskRecurring.EveryXDays },
|
||||
{ label: 'Every X Weeks', value: ITaskRecurring.EveryXWeeks },
|
||||
{ label: 'Every X Months', value: ITaskRecurring.EveryXMonths },
|
||||
{ label: 'Monthly', value: ITaskRecurring.Monthly },
|
||||
];
|
||||
|
||||
const daysOfWeek = [
|
||||
{ label: 'Sunday', value: 0, checked: false },
|
||||
{ label: 'Monday', value: 1, checked: false },
|
||||
{ label: 'Tuesday', value: 2, checked: false },
|
||||
{ label: 'Wednesday', value: 3, checked: false },
|
||||
{ label: 'Thursday', value: 4, checked: false },
|
||||
{ label: 'Friday', value: 5, checked: false },
|
||||
{ label: 'Saturday', value: 6, checked: false }
|
||||
];
|
||||
|
||||
const monthlyDateOptions = Array.from({ length: 28 }, (_, i) => i + 1);
|
||||
const weekOptions = [
|
||||
{ label: 'First', value: 1 },
|
||||
{ label: 'Second', value: 2 },
|
||||
{ label: 'Third', value: 3 },
|
||||
{ label: 'Fourth', value: 4 },
|
||||
{ label: 'Last', value: 5 }
|
||||
];
|
||||
const dayOptions = daysOfWeek.map(d => ({ label: d.label, value: d.value }));
|
||||
|
||||
const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
|
||||
const { socket, connected } = useSocket();
|
||||
const dispatch = useAppDispatch();
|
||||
const { t } = useTranslation('task-drawer/task-drawer-recurring-config');
|
||||
|
||||
const repeatOptions: IRepeatOption[] = [
|
||||
{ label: t('daily'), value: ITaskRecurring.Daily },
|
||||
{ label: t('weekly'), value: ITaskRecurring.Weekly },
|
||||
{ label: t('everyXDays'), value: ITaskRecurring.EveryXDays },
|
||||
{ label: t('everyXWeeks'), value: ITaskRecurring.EveryXWeeks },
|
||||
{ label: t('everyXMonths'), value: ITaskRecurring.EveryXMonths },
|
||||
{ label: t('monthly'), value: ITaskRecurring.Monthly },
|
||||
];
|
||||
|
||||
const daysOfWeek = [
|
||||
{ label: t('sun'), value: 0, checked: false },
|
||||
{ label: t('mon'), value: 1, checked: false },
|
||||
{ label: t('tue'), value: 2, checked: false },
|
||||
{ label: t('wed'), value: 3, checked: false },
|
||||
{ label: t('thu'), value: 4, checked: false },
|
||||
{ label: t('fri'), value: 5, checked: false },
|
||||
{ label: t('sat'), value: 6, checked: false }
|
||||
];
|
||||
|
||||
const weekOptions = [
|
||||
{ label: t('first'), value: 1 },
|
||||
{ label: t('second'), value: 2 },
|
||||
{ label: t('third'), value: 3 },
|
||||
{ label: t('fourth'), value: 4 },
|
||||
{ label: t('last'), value: 5 }
|
||||
];
|
||||
|
||||
const dayOptions = daysOfWeek.map(d => ({ label: d.label, value: d.value }));
|
||||
|
||||
const [recurring, setRecurring] = useState(false);
|
||||
const [showConfig, setShowConfig] = useState(false);
|
||||
const [repeatOption, setRepeatOption] = useState<IRepeatOption>({});
|
||||
const [selectedDays, setSelectedDays] = useState([]);
|
||||
const [selectedDays, setSelectedDays] = useState<number[]>([]);
|
||||
const [monthlyOption, setMonthlyOption] = useState('date');
|
||||
const [selectedMonthlyDate, setSelectedMonthlyDate] = useState(1);
|
||||
const [selectedMonthlyWeek, setSelectedMonthlyWeek] = useState(weekOptions[0].value);
|
||||
@@ -106,8 +108,8 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
|
||||
[repeatOption]
|
||||
);
|
||||
|
||||
const handleDayCheckboxChange = (checkedValues: string[]) => {
|
||||
setSelectedDays(checkedValues as unknown as string[]);
|
||||
const handleDayCheckboxChange = (checkedValues: number[]) => {
|
||||
setSelectedDays(checkedValues);
|
||||
};
|
||||
|
||||
const getSelectedDays = () => {
|
||||
@@ -165,7 +167,9 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
|
||||
|
||||
const res = await taskRecurringApiService.updateTaskRecurringData(task.schedule_id, body);
|
||||
if (res.done) {
|
||||
setRecurring(true);
|
||||
setShowConfig(false);
|
||||
configVisibleChange(false);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error("handleSave", e);
|
||||
@@ -220,9 +224,9 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
|
||||
if (!task) return;
|
||||
|
||||
if (task) setRecurring(!!task.schedule_id);
|
||||
if (recurring) void getScheduleData();
|
||||
if (task.schedule_id) void getScheduleData();
|
||||
socket?.on(SocketEvents.TASK_RECURRING_CHANGE.toString(), handleResponse);
|
||||
}, [task]);
|
||||
}, [task?.schedule_id]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -232,11 +236,11 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
|
||||
|
||||
{recurring && (
|
||||
<Popover
|
||||
title="Recurring task configuration"
|
||||
title={t('recurringTaskConfiguration')}
|
||||
content={
|
||||
<Skeleton loading={loadingData} active>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="Repeats">
|
||||
<Form.Item label={t('repeats')}>
|
||||
<Select
|
||||
value={repeatOption.value}
|
||||
onChange={val => {
|
||||
@@ -251,9 +255,12 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
|
||||
</Form.Item>
|
||||
|
||||
{repeatOption.value === ITaskRecurring.Weekly && (
|
||||
<Form.Item label="Select Days of the Week">
|
||||
<Form.Item label={t('selectDaysOfWeek')}>
|
||||
<Checkbox.Group
|
||||
options={daysOfWeek}
|
||||
options={daysOfWeek.map(day => ({
|
||||
label: day.label,
|
||||
value: day.value
|
||||
}))}
|
||||
value={selectedDays}
|
||||
onChange={handleDayCheckboxChange}
|
||||
style={{ width: '100%' }}
|
||||
@@ -271,17 +278,17 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
|
||||
|
||||
{isMonthlySelected && (
|
||||
<>
|
||||
<Form.Item label="Monthly repeat type">
|
||||
<Form.Item label={t('monthlyRepeatType')}>
|
||||
<Radio.Group
|
||||
value={monthlyOption}
|
||||
onChange={e => setMonthlyOption(e.target.value)}
|
||||
>
|
||||
<Radio.Button value="date">On a specific date</Radio.Button>
|
||||
<Radio.Button value="day">On a specific day</Radio.Button>
|
||||
<Radio.Button value="date">{t('onSpecificDate')}</Radio.Button>
|
||||
<Radio.Button value="day">{t('onSpecificDay')}</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
{monthlyOption === 'date' && (
|
||||
<Form.Item label="Date of the month">
|
||||
<Form.Item label={t('dateOfMonth')}>
|
||||
<Select
|
||||
value={selectedMonthlyDate}
|
||||
onChange={setSelectedMonthlyDate}
|
||||
@@ -295,7 +302,7 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
|
||||
)}
|
||||
{monthlyOption === 'day' && (
|
||||
<>
|
||||
<Form.Item label="Week of the month">
|
||||
<Form.Item label={t('weekOfMonth')}>
|
||||
<Select
|
||||
value={selectedMonthlyWeek}
|
||||
onChange={setSelectedMonthlyWeek}
|
||||
@@ -303,7 +310,7 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
|
||||
style={{ width: 150 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="Day of the week">
|
||||
<Form.Item label={t('dayOfWeek')}>
|
||||
<Select
|
||||
value={selectedMonthlyDay}
|
||||
onChange={setSelectedMonthlyDay}
|
||||
@@ -317,7 +324,7 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
|
||||
)}
|
||||
|
||||
{repeatOption.value === ITaskRecurring.EveryXDays && (
|
||||
<Form.Item label="Interval (days)">
|
||||
<Form.Item label={t('intervalDays')}>
|
||||
<InputNumber
|
||||
min={1}
|
||||
value={intervalDays}
|
||||
@@ -326,7 +333,7 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
|
||||
</Form.Item>
|
||||
)}
|
||||
{repeatOption.value === ITaskRecurring.EveryXWeeks && (
|
||||
<Form.Item label="Interval (weeks)">
|
||||
<Form.Item label={t('intervalWeeks')}>
|
||||
<InputNumber
|
||||
min={1}
|
||||
value={intervalWeeks}
|
||||
@@ -335,7 +342,7 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
|
||||
</Form.Item>
|
||||
)}
|
||||
{repeatOption.value === ITaskRecurring.EveryXMonths && (
|
||||
<Form.Item label="Interval (months)">
|
||||
<Form.Item label={t('intervalMonths')}>
|
||||
<InputNumber
|
||||
min={1}
|
||||
value={intervalMonths}
|
||||
@@ -350,7 +357,7 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
|
||||
loading={updatingData}
|
||||
onClick={handleSave}
|
||||
>
|
||||
Save Changes
|
||||
{t('saveChanges')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
@@ -166,9 +166,11 @@ const SubTaskTable = ({ subTasks, loadingSubTasks, refreshSubTasks, t }: SubTask
|
||||
{
|
||||
key: 'name',
|
||||
dataIndex: 'name',
|
||||
title: 'Name',
|
||||
},
|
||||
{
|
||||
key: 'priority',
|
||||
title: 'Priority',
|
||||
render: (record: IProjectTask) => (
|
||||
<Tag
|
||||
color={themeMode === 'dark' ? record.priority_color_dark : record.priority_color}
|
||||
@@ -180,6 +182,7 @@ const SubTaskTable = ({ subTasks, loadingSubTasks, refreshSubTasks, t }: SubTask
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
title: 'Status',
|
||||
render: (record: IProjectTask) => (
|
||||
<Tag
|
||||
color={themeMode === 'dark' ? record.status_color_dark : record.status_color}
|
||||
@@ -191,10 +194,12 @@ const SubTaskTable = ({ subTasks, loadingSubTasks, refreshSubTasks, t }: SubTask
|
||||
},
|
||||
{
|
||||
key: 'assignee',
|
||||
title: 'Assignees',
|
||||
render: (record: ISubTask) => <Avatars members={record.names || []} />,
|
||||
},
|
||||
{
|
||||
key: 'actionBtns',
|
||||
title: 'Actions',
|
||||
width: 80,
|
||||
render: (record: IProjectTask) => (
|
||||
<Flex gap={8} align="center" className="action-buttons">
|
||||
@@ -230,7 +235,6 @@ const SubTaskTable = ({ subTasks, loadingSubTasks, refreshSubTasks, t }: SubTask
|
||||
{subTasks.length > 0 && (
|
||||
<Table
|
||||
className="custom-two-colors-row-table subtask-table"
|
||||
showHeader={false}
|
||||
dataSource={subTasks}
|
||||
columns={columns}
|
||||
rowKey={record => record?.id || nanoid()}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { ITaskFormViewModel, ITaskViewModel } from '@/types/tasks/task.types';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
import { ISubTask } from '@/types/tasks/subTask.types';
|
||||
import { simpleDateFormat } from '@/utils/simpleDateFormat';
|
||||
|
||||
import NotifyMemberSelector from './notify-member-selector';
|
||||
@@ -30,9 +31,11 @@ import TaskDrawerProgress from './details/task-drawer-progress/task-drawer-progr
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import TaskDrawerRecurringConfig from './details/task-drawer-recurring-config/task-drawer-recurring-config';
|
||||
import { InlineMember } from '@/types/teamMembers/inlineMember.types';
|
||||
|
||||
interface TaskDetailsFormProps {
|
||||
taskFormViewModel?: ITaskFormViewModel | null;
|
||||
subTasks?: ISubTask[]; // Array of subtasks to calculate estimation sum
|
||||
}
|
||||
|
||||
// Custom wrapper that enforces stricter rules for displaying progress input
|
||||
@@ -45,37 +48,44 @@ const ConditionalProgressInput = ({ task, form }: ConditionalProgressInputProps)
|
||||
const { project } = useAppSelector(state => state.projectReducer);
|
||||
const hasSubTasks = task?.sub_tasks_count > 0;
|
||||
const isSubTask = !!task?.parent_task_id;
|
||||
|
||||
// Add more aggressive logging and checks
|
||||
logger.debug(`Task ${task.id} status: hasSubTasks=${hasSubTasks}, isSubTask=${isSubTask}, modes: time=${project?.use_time_progress}, manual=${project?.use_manual_progress}, weighted=${project?.use_weighted_progress}`);
|
||||
|
||||
|
||||
// STRICT RULE: Never show progress input for parent tasks with subtasks
|
||||
// This is the most important check and must be done first
|
||||
if (hasSubTasks) {
|
||||
logger.debug(`Task ${task.id} has ${task.sub_tasks_count} subtasks. Hiding progress input.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
// Only for tasks without subtasks, determine which input to show based on project mode
|
||||
if (project?.use_time_progress) {
|
||||
// In time-based mode, show progress input ONLY for tasks without subtasks
|
||||
return <TaskDrawerProgress task={{...task, sub_tasks_count: hasSubTasks ? 1 : 0}} form={form} />;
|
||||
return (
|
||||
<TaskDrawerProgress task={{ ...task, sub_tasks_count: hasSubTasks ? 1 : 0 }} form={form} />
|
||||
);
|
||||
} else if (project?.use_manual_progress) {
|
||||
// In manual mode, show progress input ONLY for tasks without subtasks
|
||||
return <TaskDrawerProgress task={{...task, sub_tasks_count: hasSubTasks ? 1 : 0}} form={form} />;
|
||||
return (
|
||||
<TaskDrawerProgress task={{ ...task, sub_tasks_count: hasSubTasks ? 1 : 0 }} form={form} />
|
||||
);
|
||||
} else if (project?.use_weighted_progress && isSubTask) {
|
||||
// In weighted mode, show weight input for subtasks
|
||||
return <TaskDrawerProgress task={{...task, sub_tasks_count: hasSubTasks ? 1 : 0}} form={form} />;
|
||||
return (
|
||||
<TaskDrawerProgress task={{ ...task, sub_tasks_count: hasSubTasks ? 1 : 0 }} form={form} />
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) => {
|
||||
const TaskDetailsForm = ({ taskFormViewModel = null, subTasks = [] }: TaskDetailsFormProps) => {
|
||||
const { t } = useTranslation('task-drawer/task-drawer');
|
||||
const [form] = Form.useForm();
|
||||
const { project } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
// No need to calculate subtask estimation on frontend anymore
|
||||
// The backend now provides recursive estimation directly in the task data
|
||||
const subTasksEstimation: { hours: number; minutes: number } | undefined = undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskFormViewModel) {
|
||||
form.resetFields();
|
||||
@@ -148,7 +158,13 @@ const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) =>
|
||||
|
||||
<Form.Item name="assignees" label={t('taskInfoTab.details.assignees')}>
|
||||
<Flex gap={4} align="center">
|
||||
<Avatars members={taskFormViewModel?.task?.assignee_names || []} />
|
||||
<Avatars
|
||||
members={
|
||||
taskFormViewModel?.task?.assignee_names ||
|
||||
(taskFormViewModel?.task?.names as unknown as InlineMember[]) ||
|
||||
[]
|
||||
}
|
||||
/>
|
||||
<TaskDrawerAssigneeSelector
|
||||
task={(taskFormViewModel?.task as ITaskViewModel) || null}
|
||||
/>
|
||||
@@ -157,13 +173,10 @@ const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) =>
|
||||
|
||||
<TaskDrawerDueDate task={taskFormViewModel?.task as ITaskViewModel} t={t} form={form} />
|
||||
|
||||
<TaskDrawerEstimation t={t} task={taskFormViewModel?.task as ITaskViewModel} form={form} />
|
||||
<TaskDrawerEstimation t={t} task={taskFormViewModel?.task as ITaskViewModel} form={form} subTasksEstimation={subTasksEstimation} />
|
||||
|
||||
{taskFormViewModel?.task && (
|
||||
<ConditionalProgressInput
|
||||
task={taskFormViewModel?.task as ITaskViewModel}
|
||||
form={form}
|
||||
/>
|
||||
<ConditionalProgressInput task={taskFormViewModel?.task as ITaskViewModel} form={form} />
|
||||
)}
|
||||
|
||||
<Form.Item name="priority" label={t('taskInfoTab.details.priority')}>
|
||||
|
||||
@@ -100,7 +100,7 @@ const TaskDrawerInfoTab = ({ t }: TaskDrawerInfoTabProps) => {
|
||||
{
|
||||
key: 'details',
|
||||
label: <Typography.Text strong>{t('taskInfoTab.details.title')}</Typography.Text>,
|
||||
children: <TaskDetailsForm taskFormViewModel={taskFormViewModel} />,
|
||||
children: <TaskDetailsForm taskFormViewModel={taskFormViewModel} subTasks={subTasks} />,
|
||||
style: panelStyle,
|
||||
className: 'custom-task-drawer-info-collapse',
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user