Compare commits
28 Commits
release-v2
...
feature/cu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da3728024d | ||
|
|
69ec445a8a | ||
|
|
ad7eb505b5 | ||
|
|
d33a7db253 | ||
|
|
9c4293e7a9 | ||
|
|
7c42087854 | ||
|
|
14c89dec24 | ||
|
|
b1bdf0ac11 | ||
|
|
7635676289 | ||
|
|
2bd6c19c13 | ||
|
|
374595261f | ||
|
|
b6c056dd1a | ||
|
|
81e1872c1f | ||
|
|
5cce3bc613 | ||
|
|
c53ab511bf | ||
|
|
7b9a16fd72 | ||
|
|
b915de2b93 | ||
|
|
29b8c1b2af | ||
|
|
c2b231d5cc | ||
|
|
53a28cf489 | ||
|
|
e8ccc2a533 | ||
|
|
f24c0d8955 | ||
|
|
61461bb776 | ||
|
|
2a7019c64c | ||
|
|
5b1cbb0c46 | ||
|
|
7c04598264 | ||
|
|
5222d75064 | ||
|
|
2587b8afd9 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -78,4 +78,7 @@ $RECYCLE.BIN/
|
|||||||
# TypeScript
|
# TypeScript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Claude
|
||||||
|
CLAUDE.md
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
-- Test script to verify the sort order constraint fix
|
|
||||||
|
|
||||||
-- Test the helper function
|
|
||||||
SELECT get_sort_column_name('status'); -- Should return 'status_sort_order'
|
|
||||||
SELECT get_sort_column_name('priority'); -- Should return 'priority_sort_order'
|
|
||||||
SELECT get_sort_column_name('phase'); -- Should return 'phase_sort_order'
|
|
||||||
SELECT get_sort_column_name('members'); -- Should return 'member_sort_order'
|
|
||||||
SELECT get_sort_column_name('unknown'); -- Should return 'status_sort_order' (default)
|
|
||||||
|
|
||||||
-- Test bulk update function (example - would need real project_id and task_ids)
|
|
||||||
/*
|
|
||||||
SELECT update_task_sort_orders_bulk(
|
|
||||||
'[
|
|
||||||
{"task_id": "example-uuid", "sort_order": 1, "status_id": "status-uuid"},
|
|
||||||
{"task_id": "example-uuid-2", "sort_order": 2, "status_id": "status-uuid"}
|
|
||||||
]'::json,
|
|
||||||
'status'
|
|
||||||
);
|
|
||||||
*/
|
|
||||||
|
|
||||||
-- Verify that sort_order constraint still exists and works
|
|
||||||
SELECT
|
|
||||||
tc.constraint_name,
|
|
||||||
tc.table_name,
|
|
||||||
kcu.column_name
|
|
||||||
FROM information_schema.table_constraints tc
|
|
||||||
JOIN information_schema.key_column_usage kcu
|
|
||||||
ON tc.constraint_name = kcu.constraint_name
|
|
||||||
WHERE tc.constraint_name = 'tasks_sort_order_unique';
|
|
||||||
|
|
||||||
-- Check that new sort order columns don't have unique constraints (which is correct)
|
|
||||||
SELECT
|
|
||||||
tc.constraint_name,
|
|
||||||
tc.table_name,
|
|
||||||
kcu.column_name
|
|
||||||
FROM information_schema.table_constraints tc
|
|
||||||
JOIN information_schema.key_column_usage kcu
|
|
||||||
ON tc.constraint_name = kcu.constraint_name
|
|
||||||
WHERE kcu.table_name = 'tasks'
|
|
||||||
AND kcu.column_name IN ('status_sort_order', 'priority_sort_order', 'phase_sort_order', 'member_sort_order')
|
|
||||||
AND tc.constraint_type = 'UNIQUE';
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
-- Test script to validate the separate sort order implementation
|
|
||||||
|
|
||||||
-- Check if new columns exist
|
|
||||||
SELECT column_name, data_type, is_nullable, column_default
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_name = 'tasks'
|
|
||||||
AND column_name IN ('status_sort_order', 'priority_sort_order', 'phase_sort_order', 'member_sort_order')
|
|
||||||
ORDER BY column_name;
|
|
||||||
|
|
||||||
-- Check if helper function exists
|
|
||||||
SELECT routine_name, routine_type
|
|
||||||
FROM information_schema.routines
|
|
||||||
WHERE routine_name IN ('get_sort_column_name', 'update_task_sort_orders_bulk', 'handle_task_list_sort_order_change');
|
|
||||||
|
|
||||||
-- Sample test data to verify different sort orders work
|
|
||||||
-- (This would be run after the migrations)
|
|
||||||
/*
|
|
||||||
-- Test: Tasks should have different orders for different groupings
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
sort_order,
|
|
||||||
status_sort_order,
|
|
||||||
priority_sort_order,
|
|
||||||
phase_sort_order,
|
|
||||||
member_sort_order
|
|
||||||
FROM tasks
|
|
||||||
WHERE project_id = '<test-project-id>'
|
|
||||||
ORDER BY status_sort_order;
|
|
||||||
*/
|
|
||||||
@@ -94,4 +94,194 @@ export default class GanttController extends WorklenzControllerBase {
|
|||||||
}
|
}
|
||||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HandleExceptions()
|
||||||
|
public static async getRoadmapTasks(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||||
|
const projectId = req.query.project_id;
|
||||||
|
|
||||||
|
const q = `
|
||||||
|
SELECT
|
||||||
|
t.id,
|
||||||
|
t.name,
|
||||||
|
t.start_date,
|
||||||
|
t.end_date,
|
||||||
|
t.done,
|
||||||
|
t.roadmap_sort_order,
|
||||||
|
t.parent_task_id,
|
||||||
|
CASE WHEN t.done THEN 100 ELSE 0 END as progress,
|
||||||
|
ts.name as status_name,
|
||||||
|
tsc.color_code as status_color,
|
||||||
|
tp.name as priority_name,
|
||||||
|
tp.value as priority_value,
|
||||||
|
tp.color_code as priority_color,
|
||||||
|
(
|
||||||
|
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(assignee_info))), '[]'::JSON)
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
tm.id as team_member_id,
|
||||||
|
u.name as assignee_name,
|
||||||
|
u.avatar_url
|
||||||
|
FROM tasks_assignees ta
|
||||||
|
JOIN team_members tm ON ta.team_member_id = tm.id
|
||||||
|
JOIN users u ON tm.user_id = u.id
|
||||||
|
WHERE ta.task_id = t.id
|
||||||
|
) assignee_info
|
||||||
|
) as assignees,
|
||||||
|
(
|
||||||
|
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(phase_info))), '[]'::JSON)
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
pp.id as phase_id,
|
||||||
|
pp.name as phase_name,
|
||||||
|
pp.color_code as phase_color
|
||||||
|
FROM task_phase tp
|
||||||
|
JOIN project_phases pp ON tp.phase_id = pp.id
|
||||||
|
WHERE tp.task_id = t.id
|
||||||
|
) phase_info
|
||||||
|
) as phases,
|
||||||
|
(
|
||||||
|
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(dependency_info))), '[]'::JSON)
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
td.related_task_id,
|
||||||
|
td.dependency_type,
|
||||||
|
rt.name as related_task_name
|
||||||
|
FROM task_dependencies td
|
||||||
|
JOIN tasks rt ON td.related_task_id = rt.id
|
||||||
|
WHERE td.task_id = t.id
|
||||||
|
) dependency_info
|
||||||
|
) as dependencies
|
||||||
|
FROM tasks t
|
||||||
|
LEFT JOIN task_statuses ts ON t.status_id = ts.id
|
||||||
|
LEFT JOIN sys_task_status_categories tsc ON ts.category_id = tsc.id
|
||||||
|
LEFT JOIN task_priorities tp ON t.priority_id = tp.id
|
||||||
|
WHERE t.project_id = $1
|
||||||
|
AND t.archived = FALSE
|
||||||
|
AND t.parent_task_id IS NULL
|
||||||
|
ORDER BY t.roadmap_sort_order, t.created_at DESC;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(q, [projectId]);
|
||||||
|
|
||||||
|
// Get subtasks for each parent task
|
||||||
|
for (const task of result.rows) {
|
||||||
|
const subtasksQuery = `
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
start_date,
|
||||||
|
end_date,
|
||||||
|
done,
|
||||||
|
roadmap_sort_order,
|
||||||
|
parent_task_id,
|
||||||
|
CASE WHEN done THEN 100 ELSE 0 END as progress
|
||||||
|
FROM tasks
|
||||||
|
WHERE parent_task_id = $1
|
||||||
|
AND archived = FALSE
|
||||||
|
ORDER BY roadmap_sort_order, created_at DESC;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const subtasksResult = await db.query(subtasksQuery, [task.id]);
|
||||||
|
task.subtasks = subtasksResult.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||||
|
}
|
||||||
|
|
||||||
|
@HandleExceptions()
|
||||||
|
public static async getProjectPhases(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||||
|
const projectId = req.query.project_id;
|
||||||
|
|
||||||
|
const q = `
|
||||||
|
SELECT
|
||||||
|
pp.id,
|
||||||
|
pp.name,
|
||||||
|
pp.color_code,
|
||||||
|
pp.start_date,
|
||||||
|
pp.end_date,
|
||||||
|
pp.sort_index,
|
||||||
|
-- Calculate task counts by status category for progress
|
||||||
|
COALESCE(
|
||||||
|
(SELECT COUNT(*)
|
||||||
|
FROM tasks t
|
||||||
|
JOIN task_phase tp ON t.id = tp.task_id
|
||||||
|
JOIN task_statuses ts ON t.status_id = ts.id
|
||||||
|
JOIN sys_task_status_categories stsc ON ts.category_id = stsc.id
|
||||||
|
WHERE tp.phase_id = pp.id
|
||||||
|
AND t.archived = FALSE
|
||||||
|
AND stsc.is_todo = TRUE), 0
|
||||||
|
) as todo_count,
|
||||||
|
COALESCE(
|
||||||
|
(SELECT COUNT(*)
|
||||||
|
FROM tasks t
|
||||||
|
JOIN task_phase tp ON t.id = tp.task_id
|
||||||
|
JOIN task_statuses ts ON t.status_id = ts.id
|
||||||
|
JOIN sys_task_status_categories stsc ON ts.category_id = stsc.id
|
||||||
|
WHERE tp.phase_id = pp.id
|
||||||
|
AND t.archived = FALSE
|
||||||
|
AND stsc.is_doing = TRUE), 0
|
||||||
|
) as doing_count,
|
||||||
|
COALESCE(
|
||||||
|
(SELECT COUNT(*)
|
||||||
|
FROM tasks t
|
||||||
|
JOIN task_phase tp ON t.id = tp.task_id
|
||||||
|
JOIN task_statuses ts ON t.status_id = ts.id
|
||||||
|
JOIN sys_task_status_categories stsc ON ts.category_id = stsc.id
|
||||||
|
WHERE tp.phase_id = pp.id
|
||||||
|
AND t.archived = FALSE
|
||||||
|
AND stsc.is_done = TRUE), 0
|
||||||
|
) as done_count,
|
||||||
|
COALESCE(
|
||||||
|
(SELECT COUNT(*)
|
||||||
|
FROM tasks t
|
||||||
|
JOIN task_phase tp ON t.id = tp.task_id
|
||||||
|
WHERE tp.phase_id = pp.id
|
||||||
|
AND t.archived = FALSE), 0
|
||||||
|
) as total_count
|
||||||
|
FROM project_phases pp
|
||||||
|
WHERE pp.project_id = $1
|
||||||
|
ORDER BY pp.sort_index, pp.created_at;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(q, [projectId]);
|
||||||
|
|
||||||
|
// Calculate progress percentages for each phase
|
||||||
|
const phasesWithProgress = result.rows.map(phase => {
|
||||||
|
const total = parseInt(phase.total_count) || 0;
|
||||||
|
const todoCount = parseInt(phase.todo_count) || 0;
|
||||||
|
const doingCount = parseInt(phase.doing_count) || 0;
|
||||||
|
const doneCount = parseInt(phase.done_count) || 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: phase.id,
|
||||||
|
name: phase.name,
|
||||||
|
color_code: phase.color_code,
|
||||||
|
start_date: phase.start_date,
|
||||||
|
end_date: phase.end_date,
|
||||||
|
sort_index: phase.sort_index,
|
||||||
|
// Calculate progress percentages
|
||||||
|
todo_progress: total > 0 ? Math.round((todoCount / total) * 100) : 0,
|
||||||
|
doing_progress: total > 0 ? Math.round((doingCount / total) * 100) : 0,
|
||||||
|
done_progress: total > 0 ? Math.round((doneCount / total) * 100) : 0,
|
||||||
|
total_tasks: total
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).send(new ServerResponse(true, phasesWithProgress));
|
||||||
|
}
|
||||||
|
|
||||||
|
@HandleExceptions()
|
||||||
|
public static async updateTaskDates(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||||
|
const { task_id, start_date, end_date } = req.body;
|
||||||
|
|
||||||
|
const q = `
|
||||||
|
UPDATE tasks
|
||||||
|
SET start_date = $2, end_date = $3, updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id, start_date, end_date;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(q, [task_id, start_date, end_date]);
|
||||||
|
return res.status(200).send(new ServerResponse(true, result.rows[0]));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {getColor} from "../shared/utils";
|
|||||||
import TeamMembersController from "./team-members-controller";
|
import TeamMembersController from "./team-members-controller";
|
||||||
import {checkTeamSubscriptionStatus} from "../shared/paddle-utils";
|
import {checkTeamSubscriptionStatus} from "../shared/paddle-utils";
|
||||||
import {updateUsers} from "../shared/paddle-requests";
|
import {updateUsers} from "../shared/paddle-requests";
|
||||||
import {statusExclude} from "../shared/constants";
|
import {statusExclude, TRIAL_MEMBER_LIMIT} from "../shared/constants";
|
||||||
import {NotificationsService} from "../services/notifications/notifications.service";
|
import {NotificationsService} from "../services/notifications/notifications.service";
|
||||||
|
|
||||||
export default class ProjectMembersController extends WorklenzControllerBase {
|
export default class ProjectMembersController extends WorklenzControllerBase {
|
||||||
@@ -118,6 +118,17 @@ export default class ProjectMembersController extends WorklenzControllerBase {
|
|||||||
return res.status(200).send(new ServerResponse(false, null, "Maximum number of life time users reached."));
|
return res.status(200).send(new ServerResponse(false, null, "Maximum number of life time users reached."));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks trial user team member limit
|
||||||
|
*/
|
||||||
|
if (subscriptionData.subscription_status === "trialing") {
|
||||||
|
const currentTrialMembers = parseInt(subscriptionData.current_count) || 0;
|
||||||
|
|
||||||
|
if (currentTrialMembers + 1 > TRIAL_MEMBER_LIMIT) {
|
||||||
|
return res.status(200).send(new ServerResponse(false, null, `Trial users cannot exceed ${TRIAL_MEMBER_LIMIT} team members. Please upgrade to add more members.`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// if (subscriptionData.status === "trialing") break;
|
// if (subscriptionData.status === "trialing") break;
|
||||||
if (!userExists && !subscriptionData.is_credit && !subscriptionData.is_custom && subscriptionData.subscription_status !== "trialing") {
|
if (!userExists && !subscriptionData.is_credit && !subscriptionData.is_custom && subscriptionData.subscription_status !== "trialing") {
|
||||||
// if (subscriptionData.subscription_status === "active") {
|
// if (subscriptionData.subscription_status === "active") {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export default abstract class ReportingControllerBaseWithTimezone extends Workle
|
|||||||
JOIN timezones tz ON u.timezone_id = tz.id
|
JOIN timezones tz ON u.timezone_id = tz.id
|
||||||
WHERE u.id = $1`;
|
WHERE u.id = $1`;
|
||||||
const result = await db.query(q, [userId]);
|
const result = await db.query(q, [userId]);
|
||||||
return result.rows[0]?.timezone || 'UTC';
|
return result.rows[0]?.timezone || "UTC";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,26 +25,48 @@ export default abstract class ReportingControllerBaseWithTimezone extends Workle
|
|||||||
* @param key - Date range key (e.g., YESTERDAY, LAST_WEEK)
|
* @param key - Date range key (e.g., YESTERDAY, LAST_WEEK)
|
||||||
* @param dateRange - Array of date strings
|
* @param dateRange - Array of date strings
|
||||||
* @param userTimezone - User's timezone (e.g., 'America/New_York')
|
* @param userTimezone - User's timezone (e.g., 'America/New_York')
|
||||||
* @param tableAlias - Table alias to use (e.g., 'twl', 'task_work_log')
|
|
||||||
* @returns SQL clause for date filtering
|
* @returns SQL clause for date filtering
|
||||||
*/
|
*/
|
||||||
protected static getDateRangeClauseWithTimezone(key: string, dateRange: string[], userTimezone: string, tableAlias: string = 'task_work_log') {
|
protected static getDateRangeClauseWithTimezone(key: string, dateRange: string[], userTimezone: string) {
|
||||||
// For custom date ranges
|
// For custom date ranges
|
||||||
if (dateRange.length === 2) {
|
if (dateRange.length === 2) {
|
||||||
// Convert dates to user's timezone start/end of day
|
try {
|
||||||
const start = moment.tz(dateRange[0], userTimezone).startOf('day');
|
// Handle different date formats that might come from frontend
|
||||||
const end = moment.tz(dateRange[1], userTimezone).endOf('day');
|
let startDate, endDate;
|
||||||
|
|
||||||
// Convert to UTC for database comparison
|
// Try to parse the date - it might be a full JS Date string or ISO string
|
||||||
const startUtc = start.utc().format("YYYY-MM-DD HH:mm:ss");
|
if (dateRange[0].includes("GMT") || dateRange[0].includes("(")) {
|
||||||
const endUtc = end.utc().format("YYYY-MM-DD HH:mm:ss");
|
// Parse JavaScript Date toString() format
|
||||||
|
startDate = moment(new Date(dateRange[0]));
|
||||||
|
endDate = moment(new Date(dateRange[1]));
|
||||||
|
} else {
|
||||||
|
// Parse ISO format or other formats
|
||||||
|
startDate = moment(dateRange[0]);
|
||||||
|
endDate = moment(dateRange[1]);
|
||||||
|
}
|
||||||
|
|
||||||
if (start.isSame(end, 'day')) {
|
// Convert to user's timezone and get start/end of day
|
||||||
// Single day selection
|
const start = startDate.tz(userTimezone).startOf("day");
|
||||||
return `AND ${tableAlias}.created_at >= '${startUtc}'::TIMESTAMP AND ${tableAlias}.created_at <= '${endUtc}'::TIMESTAMP`;
|
const end = endDate.tz(userTimezone).endOf("day");
|
||||||
|
|
||||||
|
// Convert to UTC for database comparison
|
||||||
|
const startUtc = start.utc().format("YYYY-MM-DD HH:mm:ss");
|
||||||
|
const endUtc = end.utc().format("YYYY-MM-DD HH:mm:ss");
|
||||||
|
|
||||||
|
if (start.isSame(end, "day")) {
|
||||||
|
// Single day selection
|
||||||
|
return `AND twl.created_at >= '${startUtc}'::TIMESTAMP AND twl.created_at <= '${endUtc}'::TIMESTAMP`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `AND twl.created_at >= '${startUtc}'::TIMESTAMP AND twl.created_at <= '${endUtc}'::TIMESTAMP`;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error parsing date range:", error, { dateRange, userTimezone });
|
||||||
|
// Fallback to current date if parsing fails
|
||||||
|
const now = moment.tz(userTimezone);
|
||||||
|
const startUtc = now.clone().startOf("day").utc().format("YYYY-MM-DD HH:mm:ss");
|
||||||
|
const endUtc = now.clone().endOf("day").utc().format("YYYY-MM-DD HH:mm:ss");
|
||||||
|
return `AND twl.created_at >= '${startUtc}'::TIMESTAMP AND twl.created_at <= '${endUtc}'::TIMESTAMP`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `AND ${tableAlias}.created_at >= '${startUtc}'::TIMESTAMP AND ${tableAlias}.created_at <= '${endUtc}'::TIMESTAMP`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// For predefined ranges, calculate based on user's timezone
|
// For predefined ranges, calculate based on user's timezone
|
||||||
@@ -53,20 +75,20 @@ export default abstract class ReportingControllerBaseWithTimezone extends Workle
|
|||||||
|
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case DATE_RANGES.YESTERDAY:
|
case DATE_RANGES.YESTERDAY:
|
||||||
startDate = now.clone().subtract(1, 'day').startOf('day');
|
startDate = now.clone().subtract(1, "day").startOf("day");
|
||||||
endDate = now.clone().subtract(1, 'day').endOf('day');
|
endDate = now.clone().subtract(1, "day").endOf("day");
|
||||||
break;
|
break;
|
||||||
case DATE_RANGES.LAST_WEEK:
|
case DATE_RANGES.LAST_WEEK:
|
||||||
startDate = now.clone().subtract(1, 'week').startOf('week');
|
startDate = now.clone().subtract(1, "week").startOf("week");
|
||||||
endDate = now.clone().subtract(1, 'week').endOf('week');
|
endDate = now.clone().subtract(1, "week").endOf("week");
|
||||||
break;
|
break;
|
||||||
case DATE_RANGES.LAST_MONTH:
|
case DATE_RANGES.LAST_MONTH:
|
||||||
startDate = now.clone().subtract(1, 'month').startOf('month');
|
startDate = now.clone().subtract(1, "month").startOf("month");
|
||||||
endDate = now.clone().subtract(1, 'month').endOf('month');
|
endDate = now.clone().subtract(1, "month").endOf("month");
|
||||||
break;
|
break;
|
||||||
case DATE_RANGES.LAST_QUARTER:
|
case DATE_RANGES.LAST_QUARTER:
|
||||||
startDate = now.clone().subtract(3, 'months').startOf('day');
|
startDate = now.clone().subtract(3, "months").startOf("day");
|
||||||
endDate = now.clone().endOf('day');
|
endDate = now.clone().endOf("day");
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
return "";
|
return "";
|
||||||
@@ -75,7 +97,7 @@ export default abstract class ReportingControllerBaseWithTimezone extends Workle
|
|||||||
if (startDate && endDate) {
|
if (startDate && endDate) {
|
||||||
const startUtc = startDate.utc().format("YYYY-MM-DD HH:mm:ss");
|
const startUtc = startDate.utc().format("YYYY-MM-DD HH:mm:ss");
|
||||||
const endUtc = endDate.utc().format("YYYY-MM-DD HH:mm:ss");
|
const endUtc = endDate.utc().format("YYYY-MM-DD HH:mm:ss");
|
||||||
return `AND ${tableAlias}.created_at >= '${startUtc}'::TIMESTAMP AND ${tableAlias}.created_at <= '${endUtc}'::TIMESTAMP`;
|
return `AND twl.created_at >= '${startUtc}'::TIMESTAMP AND twl.created_at <= '${endUtc}'::TIMESTAMP`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return "";
|
return "";
|
||||||
@@ -88,7 +110,7 @@ export default abstract class ReportingControllerBaseWithTimezone extends Workle
|
|||||||
* @param format - Moment format string
|
* @param format - Moment format string
|
||||||
* @returns Formatted date string
|
* @returns Formatted date string
|
||||||
*/
|
*/
|
||||||
protected static formatDateInTimezone(date: string | Date, userTimezone: string, format: string = "YYYY-MM-DD HH:mm:ss") {
|
protected static formatDateInTimezone(date: string | Date, userTimezone: string, format = "YYYY-MM-DD HH:mm:ss") {
|
||||||
return moment.tz(date, userTimezone).format(format);
|
return moment.tz(date, userTimezone).format(format);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,12 +127,12 @@ export default abstract class ReportingControllerBaseWithTimezone extends Workle
|
|||||||
let workingDays = 0;
|
let workingDays = 0;
|
||||||
|
|
||||||
const current = start.clone();
|
const current = start.clone();
|
||||||
while (current.isSameOrBefore(end, 'day')) {
|
while (current.isSameOrBefore(end, "day")) {
|
||||||
// Monday = 1, Friday = 5
|
// Monday = 1, Friday = 5
|
||||||
if (current.isoWeekday() >= 1 && current.isoWeekday() <= 5) {
|
if (current.isoWeekday() >= 1 && current.isoWeekday() <= 5) {
|
||||||
workingDays++;
|
workingDays++;
|
||||||
}
|
}
|
||||||
current.add(1, 'day');
|
current.add(1, "day");
|
||||||
}
|
}
|
||||||
|
|
||||||
return workingDays;
|
return workingDays;
|
||||||
|
|||||||
@@ -324,8 +324,8 @@ export default class ReportingMembersController extends ReportingControllerBaseW
|
|||||||
(SELECT color_code FROM project_phases WHERE id = (SELECT phase_id FROM task_phase WHERE task_id = t.id)) AS phase_color,
|
(SELECT color_code FROM project_phases WHERE id = (SELECT phase_id FROM task_phase WHERE task_id = t.id)) AS phase_color,
|
||||||
|
|
||||||
(total_minutes * 60) AS total_minutes,
|
(total_minutes * 60) AS total_minutes,
|
||||||
(SELECT SUM(time_spent) FROM task_work_log twl WHERE twl.task_id = t.id AND twl.user_id = (SELECT user_id FROM team_members WHERE id = $1)) AS time_logged,
|
(SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id AND ta.team_member_id = $1) AS time_logged,
|
||||||
((SELECT SUM(time_spent) FROM task_work_log twl WHERE twl.task_id = t.id AND twl.user_id = (SELECT user_id FROM team_members WHERE id = $1)) - (total_minutes * 60)) AS overlogged_time`;
|
((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id AND ta.team_member_id = $1) - (total_minutes * 60)) AS overlogged_time`;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected static getActivityLogsOverdue(key: string, dateRange: string[]) {
|
protected static getActivityLogsOverdue(key: string, dateRange: string[]) {
|
||||||
@@ -548,7 +548,7 @@ export default class ReportingMembersController extends ReportingControllerBaseW
|
|||||||
|
|
||||||
// Get user timezone for proper date filtering
|
// Get user timezone for proper date filtering
|
||||||
const userTimezone = await this.getUserTimezone(req.user?.id as string);
|
const userTimezone = await this.getUserTimezone(req.user?.id as string);
|
||||||
const durationClause = this.getDateRangeClauseWithTimezone(duration as string || DATE_RANGES.LAST_WEEK, dateRange, userTimezone, 'twl');
|
const durationClause = this.getDateRangeClauseWithTimezone(duration as string || DATE_RANGES.LAST_WEEK, dateRange, userTimezone);
|
||||||
const minMaxDateClause = this.getMinMaxDates(duration as string || DATE_RANGES.LAST_WEEK, dateRange, "task_work_log");
|
const minMaxDateClause = this.getMinMaxDates(duration as string || DATE_RANGES.LAST_WEEK, dateRange, "task_work_log");
|
||||||
const memberName = (req.query.member_name as string)?.trim() || null;
|
const memberName = (req.query.member_name as string)?.trim() || null;
|
||||||
|
|
||||||
@@ -1101,7 +1101,7 @@ export default class ReportingMembersController extends ReportingControllerBaseW
|
|||||||
|
|
||||||
// Get user timezone for proper date filtering
|
// Get user timezone for proper date filtering
|
||||||
const userTimezone = await this.getUserTimezone(req.user?.id as string);
|
const userTimezone = await this.getUserTimezone(req.user?.id as string);
|
||||||
const durationClause = this.getDateRangeClauseWithTimezone(duration || DATE_RANGES.LAST_WEEK, date_range, userTimezone, 'twl');
|
const durationClause = this.getDateRangeClauseWithTimezone(duration || DATE_RANGES.LAST_WEEK, date_range, userTimezone);
|
||||||
const minMaxDateClause = this.getMinMaxDates(duration || DATE_RANGES.LAST_WEEK, date_range, "task_work_log");
|
const minMaxDateClause = this.getMinMaxDates(duration || DATE_RANGES.LAST_WEEK, date_range, "task_work_log");
|
||||||
|
|
||||||
const billableQuery = this.buildBillableQuery(billable);
|
const billableQuery = this.buildBillableQuery(billable);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { SocketEvents } from "../socket.io/events";
|
|||||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||||
import HandleExceptions from "../decorators/handle-exceptions";
|
import HandleExceptions from "../decorators/handle-exceptions";
|
||||||
import { formatDuration, getColor } from "../shared/utils";
|
import { formatDuration, getColor } from "../shared/utils";
|
||||||
import { statusExclude, TEAM_MEMBER_TREE_MAP_COLOR_ALPHA } from "../shared/constants";
|
import { statusExclude, TEAM_MEMBER_TREE_MAP_COLOR_ALPHA, TRIAL_MEMBER_LIMIT } from "../shared/constants";
|
||||||
import { checkTeamSubscriptionStatus } from "../shared/paddle-utils";
|
import { checkTeamSubscriptionStatus } from "../shared/paddle-utils";
|
||||||
import { updateUsers } from "../shared/paddle-requests";
|
import { updateUsers } from "../shared/paddle-requests";
|
||||||
import { NotificationsService } from "../services/notifications/notifications.service";
|
import { NotificationsService } from "../services/notifications/notifications.service";
|
||||||
@@ -141,6 +141,17 @@ export default class TeamMembersController extends WorklenzControllerBase {
|
|||||||
return res.status(200).send(new ServerResponse(false, null, "Cannot exceed the maximum number of life time users."));
|
return res.status(200).send(new ServerResponse(false, null, "Cannot exceed the maximum number of life time users."));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks trial user team member limit
|
||||||
|
*/
|
||||||
|
if (subscriptionData.subscription_status === "trialing") {
|
||||||
|
const currentTrialMembers = parseInt(subscriptionData.current_count) || 0;
|
||||||
|
|
||||||
|
if (currentTrialMembers + incrementBy > TRIAL_MEMBER_LIMIT) {
|
||||||
|
return res.status(200).send(new ServerResponse(false, null, `Trial users cannot exceed ${TRIAL_MEMBER_LIMIT} team members. Please upgrade to add more members.`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks subscription details and updates the user count if applicable.
|
* Checks subscription details and updates the user count if applicable.
|
||||||
* Sends a response if there is an issue with the subscription.
|
* Sends a response if there is an issue with the subscription.
|
||||||
@@ -1081,6 +1092,18 @@ export default class TeamMembersController extends WorklenzControllerBase {
|
|||||||
return res.status(200).send(new ServerResponse(false, "Please check your subscription status."));
|
return res.status(200).send(new ServerResponse(false, "Please check your subscription status."));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks trial user team member limit
|
||||||
|
*/
|
||||||
|
if (subscriptionData.subscription_status === "trialing") {
|
||||||
|
const currentTrialMembers = parseInt(subscriptionData.current_count) || 0;
|
||||||
|
const emailsToAdd = req.body.emails?.length || 1;
|
||||||
|
|
||||||
|
if (currentTrialMembers + emailsToAdd > TRIAL_MEMBER_LIMIT) {
|
||||||
|
return res.status(200).send(new ServerResponse(false, null, `Trial users cannot exceed ${TRIAL_MEMBER_LIMIT} team members. Please upgrade to add more members.`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// if (subscriptionData.status === "trialing") break;
|
// if (subscriptionData.status === "trialing") break;
|
||||||
if (!subscriptionData.is_credit && !subscriptionData.is_custom) {
|
if (!subscriptionData.is_credit && !subscriptionData.is_custom) {
|
||||||
if (subscriptionData.subscription_status === "active") {
|
if (subscriptionData.subscription_status === "active") {
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import moment from "moment";
|
||||||
|
import { IWorkLenzRequest } from "../interfaces/worklenz-request";
|
||||||
|
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
|
||||||
|
|
||||||
|
import db from "../config/db";
|
||||||
|
|
||||||
|
import { ServerResponse } from "../models/server-response";
|
||||||
|
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||||
|
import HandleExceptions from "../decorators/handle-exceptions";
|
||||||
|
import { formatDuration, formatLogText, getColor } from "../shared/utils";
|
||||||
|
|
||||||
|
interface IUserRecentTask {
|
||||||
|
task_id: string;
|
||||||
|
task_name: string;
|
||||||
|
project_id: string;
|
||||||
|
project_name: string;
|
||||||
|
last_activity_at: string;
|
||||||
|
activity_count: number;
|
||||||
|
project_color?: string;
|
||||||
|
task_status?: string;
|
||||||
|
status_color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IUserTimeLoggedTask {
|
||||||
|
task_id: string;
|
||||||
|
task_name: string;
|
||||||
|
project_id: string;
|
||||||
|
project_name: string;
|
||||||
|
total_time_logged: number;
|
||||||
|
total_time_logged_string: string;
|
||||||
|
last_logged_at: string;
|
||||||
|
logged_by_timer: boolean;
|
||||||
|
project_color?: string;
|
||||||
|
task_status?: string;
|
||||||
|
status_color?: string;
|
||||||
|
log_entries_count?: number;
|
||||||
|
estimated_time?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class UserActivityLogsController extends WorklenzControllerBase {
|
||||||
|
@HandleExceptions()
|
||||||
|
public static async getRecentTasks(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||||
|
if (!req.user) {
|
||||||
|
return res.status(401).send(new ServerResponse(false, null, "Unauthorized"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id: userId, team_id: teamId } = req.user;
|
||||||
|
const { offset = 0, limit = 10 } = req.query;
|
||||||
|
|
||||||
|
// Optimized query with better performance and team filtering
|
||||||
|
const q = `
|
||||||
|
SELECT DISTINCT tal.task_id, t.name AS task_name, tal.project_id, p.name AS project_name,
|
||||||
|
MAX(tal.created_at) AS last_activity_at,
|
||||||
|
COUNT(DISTINCT tal.id) AS activity_count,
|
||||||
|
p.color_code AS project_color,
|
||||||
|
(SELECT name FROM task_statuses WHERE id = t.status_id) AS task_status,
|
||||||
|
(SELECT color_code
|
||||||
|
FROM sys_task_status_categories
|
||||||
|
WHERE id = (SELECT category_id FROM task_statuses WHERE id = t.status_id)) AS status_color
|
||||||
|
FROM task_activity_logs tal
|
||||||
|
INNER JOIN tasks t ON tal.task_id = t.id AND t.archived = FALSE
|
||||||
|
INNER JOIN projects p ON tal.project_id = p.id AND p.team_id = $1
|
||||||
|
WHERE tal.user_id = $2
|
||||||
|
AND tal.created_at >= NOW() - INTERVAL '30 days'
|
||||||
|
GROUP BY tal.task_id, t.name, tal.project_id, p.name, p.color_code, t.status_id
|
||||||
|
ORDER BY MAX(tal.created_at) DESC
|
||||||
|
LIMIT $3 OFFSET $4;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(q, [teamId, userId, limit, offset]);
|
||||||
|
const tasks: IUserRecentTask[] = result.rows;
|
||||||
|
|
||||||
|
return res.status(200).send(new ServerResponse(true, tasks));
|
||||||
|
}
|
||||||
|
|
||||||
|
@HandleExceptions()
|
||||||
|
public static async getTimeLoggedTasks(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||||
|
if (!req.user) {
|
||||||
|
return res.status(401).send(new ServerResponse(false, null, "Unauthorized"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id: userId, team_id: teamId } = req.user;
|
||||||
|
const { offset = 0, limit = 10 } = req.query;
|
||||||
|
|
||||||
|
// Optimized query with better performance, team filtering, and useful additional data
|
||||||
|
const q = `
|
||||||
|
SELECT twl.task_id, t.name AS task_name, t.project_id, p.name AS project_name,
|
||||||
|
SUM(twl.time_spent) AS total_time_logged,
|
||||||
|
MAX(twl.created_at) AS last_logged_at,
|
||||||
|
MAX(twl.logged_by_timer::int)::boolean AS logged_by_timer,
|
||||||
|
p.color_code AS project_color,
|
||||||
|
(SELECT name FROM task_statuses WHERE id = t.status_id) AS task_status,
|
||||||
|
(SELECT color_code
|
||||||
|
FROM sys_task_status_categories
|
||||||
|
WHERE id = (SELECT category_id FROM task_statuses WHERE id = t.status_id)) AS status_color,
|
||||||
|
COUNT(DISTINCT twl.id) AS log_entries_count,
|
||||||
|
(t.total_minutes * 60) AS estimated_time
|
||||||
|
FROM task_work_log twl
|
||||||
|
INNER JOIN tasks t ON twl.task_id = t.id AND t.archived = FALSE
|
||||||
|
INNER JOIN projects p ON t.project_id = p.id AND p.team_id = $1
|
||||||
|
WHERE twl.user_id = $2
|
||||||
|
AND twl.created_at >= NOW() - INTERVAL '90 days'
|
||||||
|
GROUP BY twl.task_id, t.name, t.project_id, p.name, p.color_code, t.status_id, t.total_minutes
|
||||||
|
HAVING SUM(twl.time_spent) > 0
|
||||||
|
ORDER BY MAX(twl.created_at) DESC
|
||||||
|
LIMIT $3 OFFSET $4;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(q, [teamId, userId, limit, offset]);
|
||||||
|
const tasks: IUserTimeLoggedTask[] = result.rows.map(task => ({
|
||||||
|
...task,
|
||||||
|
total_time_logged_string: formatDuration(moment.duration(task.total_time_logged, "seconds")),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return res.status(200).send(new ServerResponse(true, tasks));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,4 +12,9 @@ ganttApiRouter.get("/project-phases/:id", safeControllerFunction(GanttController
|
|||||||
|
|
||||||
ganttApiRouter.get("/project-workload", safeControllerFunction(GanttController.getWorkload));
|
ganttApiRouter.get("/project-workload", safeControllerFunction(GanttController.getWorkload));
|
||||||
|
|
||||||
|
// New roadmap Gantt APIs
|
||||||
|
ganttApiRouter.get("/roadmap-tasks", safeControllerFunction(GanttController.getRoadmapTasks));
|
||||||
|
ganttApiRouter.get("/project-phases", safeControllerFunction(GanttController.getProjectPhases));
|
||||||
|
ganttApiRouter.post("/update-task-dates", safeControllerFunction(GanttController.updateTaskDates));
|
||||||
|
|
||||||
export default ganttApiRouter;
|
export default ganttApiRouter;
|
||||||
@@ -57,7 +57,9 @@ import billingApiRouter from "./billing-api-router";
|
|||||||
import taskDependenciesApiRouter from "./task-dependencies-api-router";
|
import taskDependenciesApiRouter from "./task-dependencies-api-router";
|
||||||
|
|
||||||
import taskRecurringApiRouter from "./task-recurring-api-router";
|
import taskRecurringApiRouter from "./task-recurring-api-router";
|
||||||
|
|
||||||
|
import customColumnsApiRouter from "./custom-columns-api-router";
|
||||||
|
import userActivityLogsApiRouter from "./user-activity-logs-api-router";
|
||||||
|
|
||||||
const api = express.Router();
|
const api = express.Router();
|
||||||
|
|
||||||
@@ -119,4 +121,5 @@ api.use("/task-recurring", taskRecurringApiRouter);
|
|||||||
|
|
||||||
api.use("/custom-columns", customColumnsApiRouter);
|
api.use("/custom-columns", customColumnsApiRouter);
|
||||||
|
|
||||||
|
api.use("/logs", userActivityLogsApiRouter);
|
||||||
export default api;
|
export default api;
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import express from 'express';
|
||||||
|
|
||||||
|
import UserActivityLogsController from '../../controllers/user-activity-logs-controller';
|
||||||
|
import safeControllerFunction from "../../shared/safe-controller-function";
|
||||||
|
|
||||||
|
const userActivityLogsApiRouter = express.Router();
|
||||||
|
|
||||||
|
userActivityLogsApiRouter.get('/user-recent-tasks', safeControllerFunction(UserActivityLogsController.getRecentTasks));
|
||||||
|
userActivityLogsApiRouter.get('/user-time-logged-tasks', safeControllerFunction(UserActivityLogsController.getTimeLoggedTasks));
|
||||||
|
|
||||||
|
export default userActivityLogsApiRouter;
|
||||||
@@ -160,6 +160,9 @@ export const PASSWORD_POLICY = "Minimum of 8 characters, with upper and lowercas
|
|||||||
// paddle status to exclude
|
// paddle status to exclude
|
||||||
export const statusExclude = ["past_due", "paused", "deleted"];
|
export const statusExclude = ["past_due", "paused", "deleted"];
|
||||||
|
|
||||||
|
// Trial user team member limit
|
||||||
|
export const TRIAL_MEMBER_LIMIT = 10;
|
||||||
|
|
||||||
export const HTML_TAG_REGEXP = /<\/?[^>]+>/gi;
|
export const HTML_TAG_REGEXP = /<\/?[^>]+>/gi;
|
||||||
|
|
||||||
export const UNMAPPED = "Unmapped";
|
export const UNMAPPED = "Unmapped";
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export async function on_quick_assign_or_remove(_io: Server, socket: Socket, dat
|
|||||||
assign_type: type
|
assign_type: type
|
||||||
});
|
});
|
||||||
|
|
||||||
if (assignment && userId !== assignment.user_id) {
|
if (userId !== assignment.user_id) {
|
||||||
NotificationsService.createTaskUpdate(
|
NotificationsService.createTaskUpdate(
|
||||||
type,
|
type,
|
||||||
userId as string,
|
userId as string,
|
||||||
@@ -109,11 +109,6 @@ export async function assignMemberIfNot(taskId: string, userId: string, teamId:
|
|||||||
const result = await db.query(q, [taskId, userId, teamId]);
|
const result = await db.query(q, [taskId, userId, teamId]);
|
||||||
const [data] = result.rows;
|
const [data] = result.rows;
|
||||||
|
|
||||||
if (!data) {
|
|
||||||
log_error(new Error(`No team member found for userId: ${userId}, teamId: ${teamId}`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = {
|
const body = {
|
||||||
team_member_id: data.team_member_id,
|
team_member_id: data.team_member_id,
|
||||||
project_id: data.project_id,
|
project_id: data.project_id,
|
||||||
|
|||||||
@@ -16,3 +16,7 @@ VITE_WORKLENZ_SESSION_ID=worklenz-session-id
|
|||||||
|
|
||||||
# Google Login
|
# Google Login
|
||||||
VITE_ENABLE_GOOGLE_LOGIN=false
|
VITE_ENABLE_GOOGLE_LOGIN=false
|
||||||
|
|
||||||
|
# Survey Modal Configuration
|
||||||
|
# Set to true to enable the survey modal, false to disable it
|
||||||
|
VITE_ENABLE_SURVEY_MODAL=false
|
||||||
22
worklenz-frontend/.env.example
Normal file
22
worklenz-frontend/.env.example
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
VITE_API_URL=http://localhost:3000
|
||||||
|
VITE_SOCKET_URL=ws://localhost:3000
|
||||||
|
|
||||||
|
VITE_APP_TITLE=Worklenz
|
||||||
|
VITE_APP_ENV=development
|
||||||
|
|
||||||
|
# Mixpanel
|
||||||
|
VITE_MIXPANEL_TOKEN=mixpanel-token
|
||||||
|
|
||||||
|
# Recaptcha
|
||||||
|
VITE_ENABLE_RECAPTCHA=false
|
||||||
|
VITE_RECAPTCHA_SITE_KEY=recaptcha-site-key
|
||||||
|
|
||||||
|
# Session ID
|
||||||
|
VITE_WORKLENZ_SESSION_ID=worklenz-session-id
|
||||||
|
|
||||||
|
# Google Login
|
||||||
|
VITE_ENABLE_GOOGLE_LOGIN=false
|
||||||
|
|
||||||
|
# Survey Modal Configuration
|
||||||
|
# Set to true to enable the survey modal, false to disable it
|
||||||
|
VITE_ENABLE_SURVEY_MODAL=false
|
||||||
558
worklenz-frontend/package-lock.json
generated
558
worklenz-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,11 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"dev-build": "vite build",
|
"dev-build": "vite build",
|
||||||
"serve": "vite preview",
|
"serve": "vite preview",
|
||||||
"format": "prettier --write ."
|
"format": "prettier --write .",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:run": "vitest run",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
|
"test:ui": "vitest --ui"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/colors": "^7.1.0",
|
"@ant-design/colors": "^7.1.0",
|
||||||
@@ -77,7 +81,10 @@
|
|||||||
"@types/react-dom": "19.0.0",
|
"@types/react-dom": "19.0.0",
|
||||||
"@types/react-window": "^1.8.8",
|
"@types/react-window": "^1.8.8",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"@vitest/coverage-v8": "^3.2.4",
|
||||||
|
"@vitest/ui": "^3.2.4",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
|
"jsdom": "^26.1.0",
|
||||||
"postcss": "^8.5.2",
|
"postcss": "^8.5.2",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.13",
|
"prettier-plugin-tailwindcss": "^0.6.13",
|
||||||
"rollup": "^4.40.2",
|
"rollup": "^4.40.2",
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class HubSpotManager {
|
|||||||
* Load HubSpot script with dark mode support
|
* Load HubSpot script with dark mode support
|
||||||
*/
|
*/
|
||||||
init() {
|
init() {
|
||||||
if (!this.isProduction) return;
|
// if (!this.isProduction) return;
|
||||||
|
|
||||||
const loadHubSpot = () => {
|
const loadHubSpot = () => {
|
||||||
const script = document.createElement('script');
|
const script = document.createElement('script');
|
||||||
@@ -52,6 +52,7 @@ class HubSpotManager {
|
|||||||
existingStyle.remove();
|
existingStyle.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply dark mode CSS if dark theme is active
|
||||||
if (isDark) {
|
if (isDark) {
|
||||||
this.injectDarkModeCSS();
|
this.injectDarkModeCSS();
|
||||||
}
|
}
|
||||||
@@ -122,3 +123,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
// Make available globally for potential cleanup
|
// Make available globally for potential cleanup
|
||||||
window.HubSpotManager = hubspot;
|
window.HubSpotManager = hubspot;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Add this style to ensure the chat widget uses the light color scheme
|
||||||
|
(function() {
|
||||||
|
var style = document.createElement('style');
|
||||||
|
style.innerHTML = '#hubspot-messages-iframe-container { color-scheme: light !important; }';
|
||||||
|
document.head.appendChild(style);
|
||||||
|
})();
|
||||||
@@ -5,5 +5,13 @@
|
|||||||
"signup-failed": "Regjistrimi dështoi. Ju lutemi sigurohuni që të gjitha fushat e nevojshme janë plotësuar dhe provoni përsëri.",
|
"signup-failed": "Regjistrimi dështoi. Ju lutemi sigurohuni që të gjitha fushat e nevojshme janë plotësuar dhe provoni përsëri.",
|
||||||
"reconnecting": "Jeni shkëputur nga serveri.",
|
"reconnecting": "Jeni shkëputur nga serveri.",
|
||||||
"connection-lost": "Lidhja me serverin dështoi. Ju lutemi kontrolloni lidhjen tuaj me internet.",
|
"connection-lost": "Lidhja me serverin dështoi. Ju lutemi kontrolloni lidhjen tuaj me internet.",
|
||||||
"connection-restored": "U lidhët me serverin me sukses"
|
"connection-restored": "U lidhët me serverin me sukses",
|
||||||
|
"cancel": "Anulo",
|
||||||
|
"update-available": "Worklenz u përditesua!",
|
||||||
|
"update-description": "Një version i ri i Worklenz është i disponueshëm me karakteristikat dhe përmirësimet më të fundit.",
|
||||||
|
"update-instruction": "Për eksperiencën më të mirë, ju lutemi rifreskoni faqen për të aplikuar ndryshimet e reja.",
|
||||||
|
"update-whats-new": "💡 <1>Çfarë ka të re:</1> Përmirësim i performancës, rregullime të gabimeve dhe eksperiencön e përmirësuar e përdoruesit",
|
||||||
|
"update-now": "Përditeso tani",
|
||||||
|
"update-later": "Më vonë",
|
||||||
|
"updating": "Duke u përditesuar..."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"title": "Phase Details",
|
||||||
|
"overview": {
|
||||||
|
"title": "Overview",
|
||||||
|
"totalTasks": "Total Tasks",
|
||||||
|
"completion": "Completion",
|
||||||
|
"progress": "Progress"
|
||||||
|
},
|
||||||
|
"timeline": {
|
||||||
|
"title": "Timeline",
|
||||||
|
"startDate": "Start Date",
|
||||||
|
"endDate": "End Date",
|
||||||
|
"status": "Status",
|
||||||
|
"notSet": "Not set",
|
||||||
|
"statusLabels": {
|
||||||
|
"upcoming": "Upcoming",
|
||||||
|
"active": "In Progress",
|
||||||
|
"overdue": "Overdue",
|
||||||
|
"notScheduled": "Not Scheduled"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"taskBreakdown": {
|
||||||
|
"title": "Task Breakdown",
|
||||||
|
"completed": "Completed",
|
||||||
|
"pending": "Pending",
|
||||||
|
"overdue": "Overdue"
|
||||||
|
},
|
||||||
|
"phaseColor": {
|
||||||
|
"title": "Phase Color",
|
||||||
|
"description": "Phase identifier color"
|
||||||
|
},
|
||||||
|
"tasksInPhase": {
|
||||||
|
"title": "Tasks in this Phase",
|
||||||
|
"noTasks": "No tasks in this phase"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,22 @@
|
|||||||
"list": "Listë",
|
"list": "Listë",
|
||||||
"calendar": "Kalendar",
|
"calendar": "Kalendar",
|
||||||
"tasks": "Detyrat",
|
"tasks": "Detyrat",
|
||||||
"refresh": "Rifresko"
|
"refresh": "Rifresko",
|
||||||
|
"recentActivity": "Aktiviteti i Fundit",
|
||||||
|
"recentTasks": "Detyrat e Fundit",
|
||||||
|
"recentTasksSegment": "Detyrat e Fundit",
|
||||||
|
"timeLogged": "Koha e Regjistruar",
|
||||||
|
"timeLoggedSegment": "Koha e Regjistruar",
|
||||||
|
"noRecentTasks": "Asnjë detyrë e fundit",
|
||||||
|
"noTimeLoggedTasks": "Asnjë detyrë me kohë të regjistruar",
|
||||||
|
"activityTag": "Aktiviteti",
|
||||||
|
"timeLogTag": "Regjistrim Kohe",
|
||||||
|
"timerTag": "Kohëmatës",
|
||||||
|
"activitySingular": "aktivitet",
|
||||||
|
"activityPlural": "aktivitete",
|
||||||
|
"recentTaskAriaLabel": "Detyrë e fundit:",
|
||||||
|
"timeLoggedTaskAriaLabel": "Detyrë me kohë të regjistruar:",
|
||||||
|
"errorLoadingRecentTasks": "Gabim në ngarkimin e detyrave të fundit",
|
||||||
|
"errorLoadingTimeLoggedTasks": "Gabim në ngarkimin e detyrave me kohë të regjistruar"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,9 @@
|
|||||||
|
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"assignToMe": "Cakto mua",
|
"assignToMe": "Cakto mua",
|
||||||
|
"copyLink": "Kopjo lidhjen e detyrës",
|
||||||
|
"linkCopied": "Lidhja u kopjua në clipboard",
|
||||||
|
"linkCopyFailed": "Dështoi kopjimi i lidhjes",
|
||||||
"moveTo": "Zhvendos në",
|
"moveTo": "Zhvendos në",
|
||||||
"unarchive": "Ç'arkivizo",
|
"unarchive": "Ç'arkivizo",
|
||||||
"archive": "Arkivizo",
|
"archive": "Arkivizo",
|
||||||
@@ -133,5 +136,11 @@
|
|||||||
"dependencies": "Detyra ka varësi",
|
"dependencies": "Detyra ka varësi",
|
||||||
"recurring": "Detyrë përsëritëse"
|
"recurring": "Detyrë përsëritëse"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"timer": {
|
||||||
|
"conflictTitle": "Kronómetr Tashë Në Ecuri",
|
||||||
|
"conflictMessage": "Ju keni një kronómetr në ecuri për \"{{taskName}}\" në projektin \"{{projectName}}\". Dëshironi ta ndaloni atë kronómetr dhe të filloni një të ri për këtë detyrë?",
|
||||||
|
"stopAndStart": "Ndalo & Fillo Kronómetr të Ri"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,5 +5,13 @@
|
|||||||
"signup-failed": "Registrierung fehlgeschlagen. Bitte füllen Sie alle erforderlichen Felder aus und versuchen Sie es erneut.",
|
"signup-failed": "Registrierung fehlgeschlagen. Bitte füllen Sie alle erforderlichen Felder aus und versuchen Sie es erneut.",
|
||||||
"reconnecting": "Vom Server getrennt.",
|
"reconnecting": "Vom Server getrennt.",
|
||||||
"connection-lost": "Verbindung zum Server fehlgeschlagen. Bitte überprüfen Sie Ihre Internetverbindung.",
|
"connection-lost": "Verbindung zum Server fehlgeschlagen. Bitte überprüfen Sie Ihre Internetverbindung.",
|
||||||
"connection-restored": "Erfolgreich mit dem Server verbunden"
|
"connection-restored": "Erfolgreich mit dem Server verbunden",
|
||||||
|
"cancel": "Abbrechen",
|
||||||
|
"update-available": "Worklenz aktualisiert!",
|
||||||
|
"update-description": "Eine neue Version von Worklenz ist verfügbar mit den neuesten Funktionen und Verbesserungen.",
|
||||||
|
"update-instruction": "Für die beste Erfahrung laden Sie bitte die Seite neu, um die neuen Änderungen zu übernehmen.",
|
||||||
|
"update-whats-new": "💡 <1>Was ist neu:</1> Verbesserte Leistung, Fehlerbehebungen und verbesserte Benutzererfahrung",
|
||||||
|
"update-now": "Jetzt aktualisieren",
|
||||||
|
"update-later": "Später",
|
||||||
|
"updating": "Wird aktualisiert..."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"title": "Phase Details",
|
||||||
|
"overview": {
|
||||||
|
"title": "Overview",
|
||||||
|
"totalTasks": "Total Tasks",
|
||||||
|
"completion": "Completion",
|
||||||
|
"progress": "Progress"
|
||||||
|
},
|
||||||
|
"timeline": {
|
||||||
|
"title": "Timeline",
|
||||||
|
"startDate": "Start Date",
|
||||||
|
"endDate": "End Date",
|
||||||
|
"status": "Status",
|
||||||
|
"notSet": "Not set",
|
||||||
|
"statusLabels": {
|
||||||
|
"upcoming": "Upcoming",
|
||||||
|
"active": "In Progress",
|
||||||
|
"overdue": "Overdue",
|
||||||
|
"notScheduled": "Not Scheduled"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"taskBreakdown": {
|
||||||
|
"title": "Task Breakdown",
|
||||||
|
"completed": "Completed",
|
||||||
|
"pending": "Pending",
|
||||||
|
"overdue": "Overdue"
|
||||||
|
},
|
||||||
|
"phaseColor": {
|
||||||
|
"title": "Phase Color",
|
||||||
|
"description": "Phase identifier color"
|
||||||
|
},
|
||||||
|
"tasksInPhase": {
|
||||||
|
"title": "Tasks in this Phase",
|
||||||
|
"noTasks": "No tasks in this phase"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,22 @@
|
|||||||
"list": "Liste",
|
"list": "Liste",
|
||||||
"calendar": "Kalender",
|
"calendar": "Kalender",
|
||||||
"tasks": "Aufgaben",
|
"tasks": "Aufgaben",
|
||||||
"refresh": "Aktualisieren"
|
"refresh": "Aktualisieren",
|
||||||
|
"recentActivity": "Aktuelle Aktivitäten",
|
||||||
|
"recentTasks": "Aktuelle Aufgaben",
|
||||||
|
"recentTasksSegment": "Aktuelle Aufgaben",
|
||||||
|
"timeLogged": "Erfasste Zeit",
|
||||||
|
"timeLoggedSegment": "Erfasste Zeit",
|
||||||
|
"noRecentTasks": "Keine aktuellen Aufgaben",
|
||||||
|
"noTimeLoggedTasks": "Keine Aufgaben mit erfasster Zeit",
|
||||||
|
"activityTag": "Aktivität",
|
||||||
|
"timeLogTag": "Zeiterfassung",
|
||||||
|
"timerTag": "Timer",
|
||||||
|
"activitySingular": "Aktivität",
|
||||||
|
"activityPlural": "Aktivitäten",
|
||||||
|
"recentTaskAriaLabel": "Aktuelle Aufgabe:",
|
||||||
|
"timeLoggedTaskAriaLabel": "Aufgabe mit erfasster Zeit:",
|
||||||
|
"errorLoadingRecentTasks": "Fehler beim Laden aktueller Aufgaben",
|
||||||
|
"errorLoadingTimeLoggedTasks": "Fehler beim Laden der Zeiterfassung"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,9 @@
|
|||||||
|
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"assignToMe": "Mir zuweisen",
|
"assignToMe": "Mir zuweisen",
|
||||||
|
"copyLink": "Link zur Aufgabe kopieren",
|
||||||
|
"linkCopied": "Link in die Zwischenablage kopiert",
|
||||||
|
"linkCopyFailed": "Fehler beim Kopieren des Links",
|
||||||
"moveTo": "Verschieben nach",
|
"moveTo": "Verschieben nach",
|
||||||
"unarchive": "Dearchivieren",
|
"unarchive": "Dearchivieren",
|
||||||
"archive": "Archivieren",
|
"archive": "Archivieren",
|
||||||
@@ -133,5 +136,11 @@
|
|||||||
"dependencies": "Aufgabe hat Abhängigkeiten",
|
"dependencies": "Aufgabe hat Abhängigkeiten",
|
||||||
"recurring": "Wiederkehrende Aufgabe"
|
"recurring": "Wiederkehrende Aufgabe"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"timer": {
|
||||||
|
"conflictTitle": "Timer läuft bereits",
|
||||||
|
"conflictMessage": "Sie haben einen Timer für \"{{taskName}}\" im Projekt \"{{projectName}}\" laufen. Möchten Sie diesen Timer stoppen und einen neuen für diese Aufgabe starten?",
|
||||||
|
"stopAndStart": "Stoppen & Neuen Timer starten"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,5 +5,13 @@
|
|||||||
"signup-failed": "Signup failed. Please ensure all required fields are filled and try again.",
|
"signup-failed": "Signup failed. Please ensure all required fields are filled and try again.",
|
||||||
"reconnecting": "Disconnected from server.",
|
"reconnecting": "Disconnected from server.",
|
||||||
"connection-lost": "Failed to connect to server. Please check your internet connection.",
|
"connection-lost": "Failed to connect to server. Please check your internet connection.",
|
||||||
"connection-restored": "Connected to server successfully"
|
"connection-restored": "Connected to server successfully",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"update-available": "Worklenz Updated!",
|
||||||
|
"update-description": "A new version of Worklenz is available with the latest features and improvements.",
|
||||||
|
"update-instruction": "To get the best experience, please reload the page to apply the new changes.",
|
||||||
|
"update-whats-new": "💡 <1>What's new:</1> Enhanced performance, bug fixes, and improved user experience",
|
||||||
|
"update-now": "Update Now",
|
||||||
|
"update-later": "Later",
|
||||||
|
"updating": "Updating..."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"title": "Phase Details",
|
||||||
|
"overview": {
|
||||||
|
"title": "Overview",
|
||||||
|
"totalTasks": "Total Tasks",
|
||||||
|
"completion": "Completion",
|
||||||
|
"progress": "Progress"
|
||||||
|
},
|
||||||
|
"timeline": {
|
||||||
|
"title": "Timeline",
|
||||||
|
"startDate": "Start Date",
|
||||||
|
"endDate": "End Date",
|
||||||
|
"status": "Status",
|
||||||
|
"notSet": "Not set",
|
||||||
|
"statusLabels": {
|
||||||
|
"upcoming": "Upcoming",
|
||||||
|
"active": "In Progress",
|
||||||
|
"overdue": "Overdue",
|
||||||
|
"notScheduled": "Not Scheduled"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"taskBreakdown": {
|
||||||
|
"title": "Task Breakdown",
|
||||||
|
"completed": "Completed",
|
||||||
|
"pending": "Pending",
|
||||||
|
"overdue": "Overdue"
|
||||||
|
},
|
||||||
|
"phaseColor": {
|
||||||
|
"title": "Phase Color",
|
||||||
|
"description": "Phase identifier color"
|
||||||
|
},
|
||||||
|
"tasksInPhase": {
|
||||||
|
"title": "Tasks in this Phase",
|
||||||
|
"noTasks": "No tasks in this phase",
|
||||||
|
"priority": "Priority",
|
||||||
|
"assignees": "Assignees",
|
||||||
|
"dueDate": "Due Date",
|
||||||
|
"startDate": "Start Date",
|
||||||
|
"noAssignees": "Unassigned",
|
||||||
|
"noDueDate": "No due date"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,22 @@
|
|||||||
"list": "List",
|
"list": "List",
|
||||||
"calendar": "Calendar",
|
"calendar": "Calendar",
|
||||||
"tasks": "Tasks",
|
"tasks": "Tasks",
|
||||||
"refresh": "Refresh"
|
"refresh": "Refresh",
|
||||||
|
"recentActivity": "Recent Activity",
|
||||||
|
"recentTasks": "Recent Tasks",
|
||||||
|
"recentTasksSegment": "Recent Tasks",
|
||||||
|
"timeLogged": "Time Logged",
|
||||||
|
"timeLoggedSegment": "Time Logged",
|
||||||
|
"noRecentTasks": "No recent tasks",
|
||||||
|
"noTimeLoggedTasks": "No time logged tasks",
|
||||||
|
"activityTag": "Activity",
|
||||||
|
"timeLogTag": "Time Log",
|
||||||
|
"timerTag": "Timer",
|
||||||
|
"activitySingular": "activity",
|
||||||
|
"activityPlural": "activities",
|
||||||
|
"recentTaskAriaLabel": "Recent task:",
|
||||||
|
"timeLoggedTaskAriaLabel": "Time logged task:",
|
||||||
|
"errorLoadingRecentTasks": "Error loading recent tasks",
|
||||||
|
"errorLoadingTimeLoggedTasks": "Error loading time logged tasks"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,9 @@
|
|||||||
|
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"assignToMe": "Assign to me",
|
"assignToMe": "Assign to me",
|
||||||
|
"copyLink": "Copy link to task",
|
||||||
|
"linkCopied": "Link copied to clipboard",
|
||||||
|
"linkCopyFailed": "Failed to copy link",
|
||||||
"moveTo": "Move to",
|
"moveTo": "Move to",
|
||||||
"unarchive": "Unarchive",
|
"unarchive": "Unarchive",
|
||||||
"archive": "Archive",
|
"archive": "Archive",
|
||||||
@@ -133,5 +136,11 @@
|
|||||||
"dependencies": "Task has dependencies",
|
"dependencies": "Task has dependencies",
|
||||||
"recurring": "Recurring task"
|
"recurring": "Recurring task"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"timer": {
|
||||||
|
"conflictTitle": "Timer Already Running",
|
||||||
|
"conflictMessage": "You have a timer running for \"{{taskName}}\" in project \"{{projectName}}\". Would you like to stop that timer and start a new one for this task?",
|
||||||
|
"stopAndStart": "Stop & Start New Timer"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,5 +5,13 @@
|
|||||||
"signup-failed": "Error al registrarse. Por favor asegúrate de llenar todos los campos requeridos e intenta nuevamente.",
|
"signup-failed": "Error al registrarse. Por favor asegúrate de llenar todos los campos requeridos e intenta nuevamente.",
|
||||||
"reconnecting": "Reconectando al servidor...",
|
"reconnecting": "Reconectando al servidor...",
|
||||||
"connection-lost": "Conexión perdida. Intentando reconectarse...",
|
"connection-lost": "Conexión perdida. Intentando reconectarse...",
|
||||||
"connection-restored": "Conexión restaurada. Reconectando al servidor..."
|
"connection-restored": "Conexión restaurada. Reconectando al servidor...",
|
||||||
|
"cancel": "Cancelar",
|
||||||
|
"update-available": "¡Worklenz actualizado!",
|
||||||
|
"update-description": "Una nueva versión de Worklenz está disponible con las últimas funciones y mejoras.",
|
||||||
|
"update-instruction": "Para obtener la mejor experiencia, por favor recarga la página para aplicar los nuevos cambios.",
|
||||||
|
"update-whats-new": "💡 <1>Qué hay de nuevo:</1> Rendimiento mejorado, correcciones de errores y experiencia de usuario mejorada",
|
||||||
|
"update-now": "Actualizar ahora",
|
||||||
|
"update-later": "Más tarde",
|
||||||
|
"updating": "Actualizando..."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"title": "Phase Details",
|
||||||
|
"overview": {
|
||||||
|
"title": "Overview",
|
||||||
|
"totalTasks": "Total Tasks",
|
||||||
|
"completion": "Completion",
|
||||||
|
"progress": "Progress"
|
||||||
|
},
|
||||||
|
"timeline": {
|
||||||
|
"title": "Timeline",
|
||||||
|
"startDate": "Start Date",
|
||||||
|
"endDate": "End Date",
|
||||||
|
"status": "Status",
|
||||||
|
"notSet": "Not set",
|
||||||
|
"statusLabels": {
|
||||||
|
"upcoming": "Upcoming",
|
||||||
|
"active": "In Progress",
|
||||||
|
"overdue": "Overdue",
|
||||||
|
"notScheduled": "Not Scheduled"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"taskBreakdown": {
|
||||||
|
"title": "Task Breakdown",
|
||||||
|
"completed": "Completed",
|
||||||
|
"pending": "Pending",
|
||||||
|
"overdue": "Overdue"
|
||||||
|
},
|
||||||
|
"phaseColor": {
|
||||||
|
"title": "Phase Color",
|
||||||
|
"description": "Phase identifier color"
|
||||||
|
},
|
||||||
|
"tasksInPhase": {
|
||||||
|
"title": "Tasks in this Phase",
|
||||||
|
"noTasks": "No tasks in this phase"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,22 @@
|
|||||||
"list": "Lista",
|
"list": "Lista",
|
||||||
"calendar": "Calendario",
|
"calendar": "Calendario",
|
||||||
"tasks": "Tareas",
|
"tasks": "Tareas",
|
||||||
"refresh": "Actualizar"
|
"refresh": "Actualizar",
|
||||||
|
"recentActivity": "Actividad Reciente",
|
||||||
|
"recentTasks": "Tareas Recientes",
|
||||||
|
"recentTasksSegment": "Tareas Recientes",
|
||||||
|
"timeLogged": "Tiempo Registrado",
|
||||||
|
"timeLoggedSegment": "Tiempo Registrado",
|
||||||
|
"noRecentTasks": "No hay tareas recientes",
|
||||||
|
"noTimeLoggedTasks": "No hay tareas con tiempo registrado",
|
||||||
|
"activityTag": "Actividad",
|
||||||
|
"timeLogTag": "Registro de Tiempo",
|
||||||
|
"timerTag": "Temporizador",
|
||||||
|
"activitySingular": "actividad",
|
||||||
|
"activityPlural": "actividades",
|
||||||
|
"recentTaskAriaLabel": "Tarea reciente:",
|
||||||
|
"timeLoggedTaskAriaLabel": "Tarea con tiempo registrado:",
|
||||||
|
"errorLoadingRecentTasks": "Error al cargar tareas recientes",
|
||||||
|
"errorLoadingTimeLoggedTasks": "Error al cargar tareas con tiempo registrado"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,9 @@
|
|||||||
|
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"assignToMe": "Asignar a mí",
|
"assignToMe": "Asignar a mí",
|
||||||
|
"copyLink": "Copiar enlace a la tarea",
|
||||||
|
"linkCopied": "Enlace copiado al portapapeles",
|
||||||
|
"linkCopyFailed": "Error al copiar el enlace",
|
||||||
"moveTo": "Mover a",
|
"moveTo": "Mover a",
|
||||||
"unarchive": "Desarchivar",
|
"unarchive": "Desarchivar",
|
||||||
"archive": "Archivar",
|
"archive": "Archivar",
|
||||||
@@ -133,5 +136,11 @@
|
|||||||
"dependencies": "La tarea tiene dependencias",
|
"dependencies": "La tarea tiene dependencias",
|
||||||
"recurring": "Tarea recurrente"
|
"recurring": "Tarea recurrente"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"timer": {
|
||||||
|
"conflictTitle": "Temporizador Ya En Ejecución",
|
||||||
|
"conflictMessage": "Tiene un temporizador ejecutándose para \"{{taskName}}\" en el proyecto \"{{projectName}}\". ¿Le gustaría detener ese temporizador e iniciar uno nuevo para esta tarea?",
|
||||||
|
"stopAndStart": "Detener e Iniciar Nuevo Temporizador"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,5 +5,13 @@
|
|||||||
"signup-failed": "Falha no cadastro. Por favor, certifique-se de que todos os campos obrigatórios estão preenchidos e tente novamente.",
|
"signup-failed": "Falha no cadastro. Por favor, certifique-se de que todos os campos obrigatórios estão preenchidos e tente novamente.",
|
||||||
"reconnecting": "Reconectando ao servidor...",
|
"reconnecting": "Reconectando ao servidor...",
|
||||||
"connection-lost": "Conexão perdida. Tentando reconectar...",
|
"connection-lost": "Conexão perdida. Tentando reconectar...",
|
||||||
"connection-restored": "Conexão restaurada. Reconectando ao servidor..."
|
"connection-restored": "Conexão restaurada. Reconectando ao servidor...",
|
||||||
|
"cancel": "Cancelar",
|
||||||
|
"update-available": "Worklenz atualizado!",
|
||||||
|
"update-description": "Uma nova versão do Worklenz está disponível com os recursos e melhorias mais recentes.",
|
||||||
|
"update-instruction": "Para obter a melhor experiência, por favor recarregue a página para aplicar as novas mudanças.",
|
||||||
|
"update-whats-new": "💡 <1>O que há de novo:</1> Performance aprimorada, correções de bugs e experiência do usuário melhorada",
|
||||||
|
"update-now": "Atualizar agora",
|
||||||
|
"update-later": "Mais tarde",
|
||||||
|
"updating": "Atualizando..."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"title": "Phase Details",
|
||||||
|
"overview": {
|
||||||
|
"title": "Overview",
|
||||||
|
"totalTasks": "Total Tasks",
|
||||||
|
"completion": "Completion",
|
||||||
|
"progress": "Progress"
|
||||||
|
},
|
||||||
|
"timeline": {
|
||||||
|
"title": "Timeline",
|
||||||
|
"startDate": "Start Date",
|
||||||
|
"endDate": "End Date",
|
||||||
|
"status": "Status",
|
||||||
|
"notSet": "Not set",
|
||||||
|
"statusLabels": {
|
||||||
|
"upcoming": "Upcoming",
|
||||||
|
"active": "In Progress",
|
||||||
|
"overdue": "Overdue",
|
||||||
|
"notScheduled": "Not Scheduled"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"taskBreakdown": {
|
||||||
|
"title": "Task Breakdown",
|
||||||
|
"completed": "Completed",
|
||||||
|
"pending": "Pending",
|
||||||
|
"overdue": "Overdue"
|
||||||
|
},
|
||||||
|
"phaseColor": {
|
||||||
|
"title": "Phase Color",
|
||||||
|
"description": "Phase identifier color"
|
||||||
|
},
|
||||||
|
"tasksInPhase": {
|
||||||
|
"title": "Tasks in this Phase",
|
||||||
|
"noTasks": "No tasks in this phase"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,22 @@
|
|||||||
"list": "Lista",
|
"list": "Lista",
|
||||||
"calendar": "Calendário",
|
"calendar": "Calendário",
|
||||||
"tasks": "Tarefas",
|
"tasks": "Tarefas",
|
||||||
"refresh": "Atualizar"
|
"refresh": "Atualizar",
|
||||||
|
"recentActivity": "Atividade Recente",
|
||||||
|
"recentTasks": "Tarefas Recentes",
|
||||||
|
"recentTasksSegment": "Tarefas Recentes",
|
||||||
|
"timeLogged": "Tempo Registrado",
|
||||||
|
"timeLoggedSegment": "Tempo Registrado",
|
||||||
|
"noRecentTasks": "Nenhuma tarefa recente",
|
||||||
|
"noTimeLoggedTasks": "Nenhuma tarefa com tempo registrado",
|
||||||
|
"activityTag": "Atividade",
|
||||||
|
"timeLogTag": "Registro de Tempo",
|
||||||
|
"timerTag": "Cronômetro",
|
||||||
|
"activitySingular": "atividade",
|
||||||
|
"activityPlural": "atividades",
|
||||||
|
"recentTaskAriaLabel": "Tarefa recente:",
|
||||||
|
"timeLoggedTaskAriaLabel": "Tarefa com tempo registrado:",
|
||||||
|
"errorLoadingRecentTasks": "Erro ao carregar tarefas recentes",
|
||||||
|
"errorLoadingTimeLoggedTasks": "Erro ao carregar tarefas com tempo registrado"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,9 @@
|
|||||||
|
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"assignToMe": "Atribuir a mim",
|
"assignToMe": "Atribuir a mim",
|
||||||
|
"copyLink": "Copiar link da tarefa",
|
||||||
|
"linkCopied": "Link copiado para a área de transferência",
|
||||||
|
"linkCopyFailed": "Falha ao copiar o link",
|
||||||
"moveTo": "Mover para",
|
"moveTo": "Mover para",
|
||||||
"unarchive": "Desarquivar",
|
"unarchive": "Desarquivar",
|
||||||
"archive": "Arquivar",
|
"archive": "Arquivar",
|
||||||
@@ -133,5 +136,11 @@
|
|||||||
"dependencies": "A tarefa tem dependências",
|
"dependencies": "A tarefa tem dependências",
|
||||||
"recurring": "Tarefa recorrente"
|
"recurring": "Tarefa recorrente"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"timer": {
|
||||||
|
"conflictTitle": "Temporizador Já Em Execução",
|
||||||
|
"conflictMessage": "Você tem um temporizador executando para \"{{taskName}}\" no projeto \"{{projectName}}\". Gostaria de parar esse temporizador e iniciar um novo para esta tarefa?",
|
||||||
|
"stopAndStart": "Parar e Iniciar Novo Temporizador"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,5 +5,13 @@
|
|||||||
"signup-failed": "注册失败。请确保填写所有必填字段并重试。",
|
"signup-failed": "注册失败。请确保填写所有必填字段并重试。",
|
||||||
"reconnecting": "与服务器断开连接。",
|
"reconnecting": "与服务器断开连接。",
|
||||||
"connection-lost": "无法连接到服务器。请检查您的互联网连接。",
|
"connection-lost": "无法连接到服务器。请检查您的互联网连接。",
|
||||||
"connection-restored": "成功连接到服务器"
|
"connection-restored": "成功连接到服务器",
|
||||||
|
"cancel": "取消",
|
||||||
|
"update-available": "Worklenz 已更新!",
|
||||||
|
"update-description": "Worklenz 的新版本已可用,具有最新的功能和改进。",
|
||||||
|
"update-instruction": "为了获得最佳体验,请刷新页面以应用新更改。",
|
||||||
|
"update-whats-new": "💡 <1>新增内容:</1>性能增强、错误修复和用户体验改善",
|
||||||
|
"update-now": "立即更新",
|
||||||
|
"update-later": "稍后",
|
||||||
|
"updating": "正在更新..."
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"title": "Phase Details",
|
||||||
|
"overview": {
|
||||||
|
"title": "Overview",
|
||||||
|
"totalTasks": "Total Tasks",
|
||||||
|
"completion": "Completion",
|
||||||
|
"progress": "Progress"
|
||||||
|
},
|
||||||
|
"timeline": {
|
||||||
|
"title": "Timeline",
|
||||||
|
"startDate": "Start Date",
|
||||||
|
"endDate": "End Date",
|
||||||
|
"status": "Status",
|
||||||
|
"notSet": "Not set",
|
||||||
|
"statusLabels": {
|
||||||
|
"upcoming": "Upcoming",
|
||||||
|
"active": "In Progress",
|
||||||
|
"overdue": "Overdue",
|
||||||
|
"notScheduled": "Not Scheduled"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"taskBreakdown": {
|
||||||
|
"title": "Task Breakdown",
|
||||||
|
"completed": "Completed",
|
||||||
|
"pending": "Pending",
|
||||||
|
"overdue": "Overdue"
|
||||||
|
},
|
||||||
|
"phaseColor": {
|
||||||
|
"title": "Phase Color",
|
||||||
|
"description": "Phase identifier color"
|
||||||
|
},
|
||||||
|
"tasksInPhase": {
|
||||||
|
"title": "Tasks in this Phase",
|
||||||
|
"noTasks": "No tasks in this phase"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,22 @@
|
|||||||
"list": "列表",
|
"list": "列表",
|
||||||
"calendar": "日历",
|
"calendar": "日历",
|
||||||
"tasks": "任务",
|
"tasks": "任务",
|
||||||
"refresh": "刷新"
|
"refresh": "刷新",
|
||||||
|
"recentActivity": "最近活动",
|
||||||
|
"recentTasks": "最近任务",
|
||||||
|
"recentTasksSegment": "最近任务",
|
||||||
|
"timeLogged": "时间记录",
|
||||||
|
"timeLoggedSegment": "时间记录",
|
||||||
|
"noRecentTasks": "没有最近任务",
|
||||||
|
"noTimeLoggedTasks": "没有时间记录任务",
|
||||||
|
"activityTag": "活动",
|
||||||
|
"timeLogTag": "时间记录",
|
||||||
|
"timerTag": "计时器",
|
||||||
|
"activitySingular": "活动",
|
||||||
|
"activityPlural": "活动",
|
||||||
|
"recentTaskAriaLabel": "最近任务:",
|
||||||
|
"timeLoggedTaskAriaLabel": "时间记录任务:",
|
||||||
|
"errorLoadingRecentTasks": "加载最近任务时出错",
|
||||||
|
"errorLoadingTimeLoggedTasks": "加载时间记录任务时出错"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -50,6 +50,9 @@
|
|||||||
"pendingInvitation": "待处理邀请",
|
"pendingInvitation": "待处理邀请",
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"assignToMe": "分配给我",
|
"assignToMe": "分配给我",
|
||||||
|
"copyLink": "复制任务链接",
|
||||||
|
"linkCopied": "链接已复制到剪贴板",
|
||||||
|
"linkCopyFailed": "复制链接失败",
|
||||||
"moveTo": "移动到",
|
"moveTo": "移动到",
|
||||||
"unarchive": "取消归档",
|
"unarchive": "取消归档",
|
||||||
"archive": "归档",
|
"archive": "归档",
|
||||||
@@ -126,5 +129,11 @@
|
|||||||
"dependencies": "任务有依赖项",
|
"dependencies": "任务有依赖项",
|
||||||
"recurring": "重复任务"
|
"recurring": "重复任务"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"timer": {
|
||||||
|
"conflictTitle": "计时器已在运行",
|
||||||
|
"conflictMessage": "您在项目\"{{projectName}}\"中的\"{{taskName}}\"任务正在运行计时器。您是否要停止该计时器并为此任务启动新的计时器?",
|
||||||
|
"stopAndStart": "停止并启动新计时器"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -325,6 +325,12 @@ self.addEventListener('message', event => {
|
|||||||
event.ports[0].postMessage({ version: CACHE_VERSION });
|
event.ports[0].postMessage({ version: CACHE_VERSION });
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'CHECK_FOR_UPDATES':
|
||||||
|
checkForUpdates().then((hasUpdates) => {
|
||||||
|
event.ports[0].postMessage({ hasUpdates });
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
case 'CLEAR_CACHE':
|
case 'CLEAR_CACHE':
|
||||||
clearAllCaches().then(() => {
|
clearAllCaches().then(() => {
|
||||||
event.ports[0].postMessage({ success: true });
|
event.ports[0].postMessage({ success: true });
|
||||||
@@ -349,6 +355,44 @@ async function clearAllCaches() {
|
|||||||
console.log('Service Worker: All caches cleared');
|
console.log('Service Worker: All caches cleared');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function checkForUpdates() {
|
||||||
|
try {
|
||||||
|
// Check if there's a new service worker available
|
||||||
|
const registration = await self.registration.update();
|
||||||
|
const hasNewWorker = registration.installing || registration.waiting;
|
||||||
|
|
||||||
|
if (hasNewWorker) {
|
||||||
|
console.log('Service Worker: New version detected');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check if the main app files have been updated by trying to fetch index.html
|
||||||
|
// and comparing it with the cached version
|
||||||
|
try {
|
||||||
|
const cache = await caches.open(CACHE_NAMES.STATIC);
|
||||||
|
const cachedResponse = await cache.match('/');
|
||||||
|
const networkResponse = await fetch('/', { cache: 'no-cache' });
|
||||||
|
|
||||||
|
if (cachedResponse && networkResponse.ok) {
|
||||||
|
const cachedContent = await cachedResponse.text();
|
||||||
|
const networkContent = await networkResponse.text();
|
||||||
|
|
||||||
|
if (cachedContent !== networkContent) {
|
||||||
|
console.log('Service Worker: App content has changed');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Service Worker: Could not check for content updates', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Service Worker: Error checking for updates', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
try {
|
try {
|
||||||
// Clear all caches
|
// Clear all caches
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import i18next from 'i18next';
|
|||||||
// Components
|
// Components
|
||||||
import ThemeWrapper from './features/theme/ThemeWrapper';
|
import ThemeWrapper from './features/theme/ThemeWrapper';
|
||||||
import ModuleErrorBoundary from './components/ModuleErrorBoundary';
|
import ModuleErrorBoundary from './components/ModuleErrorBoundary';
|
||||||
|
import { UpdateNotificationProvider } from './components/update-notification';
|
||||||
|
|
||||||
// Routes
|
// Routes
|
||||||
import router from './app/routes';
|
import router from './app/routes';
|
||||||
@@ -202,14 +203,16 @@ const App: React.FC = memo(() => {
|
|||||||
return (
|
return (
|
||||||
<Suspense fallback={<SuspenseFallback />}>
|
<Suspense fallback={<SuspenseFallback />}>
|
||||||
<ThemeWrapper>
|
<ThemeWrapper>
|
||||||
<ModuleErrorBoundary>
|
<UpdateNotificationProvider>
|
||||||
<RouterProvider
|
<ModuleErrorBoundary>
|
||||||
router={router}
|
<RouterProvider
|
||||||
future={{
|
router={router}
|
||||||
v7_startTransition: true,
|
future={{
|
||||||
}}
|
v7_startTransition: true,
|
||||||
/>
|
}}
|
||||||
</ModuleErrorBoundary>
|
/>
|
||||||
|
</ModuleErrorBoundary>
|
||||||
|
</UpdateNotificationProvider>
|
||||||
</ThemeWrapper>
|
</ThemeWrapper>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
|
||||||
|
import { API_BASE_URL } from '@/shared/constants';
|
||||||
|
import { getCsrfToken } from '../api-client';
|
||||||
|
import { IUserRecentTask, IUserTimeLoggedTask } from '@/types/home/user-activity.types';
|
||||||
|
import config from '@/config/env';
|
||||||
|
|
||||||
|
const rootUrl = '/logs';
|
||||||
|
|
||||||
|
export const userActivityApiService = createApi({
|
||||||
|
reducerPath: 'userActivityApi',
|
||||||
|
baseQuery: fetchBaseQuery({
|
||||||
|
baseUrl: `${config.apiUrl}${API_BASE_URL}`,
|
||||||
|
prepareHeaders: (headers) => {
|
||||||
|
headers.set('X-CSRF-Token', getCsrfToken() || '');
|
||||||
|
headers.set('Content-Type', 'application/json');
|
||||||
|
return headers;
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
}),
|
||||||
|
tagTypes: ['UserRecentTasks', 'UserTimeLoggedTasks'],
|
||||||
|
endpoints: (builder) => ({
|
||||||
|
getUserRecentTasks: builder.query<IUserRecentTask[], { limit?: number; offset?: number }>({
|
||||||
|
query: ({ limit = 10, offset = 0 }) => ({
|
||||||
|
url: `${rootUrl}/user-recent-tasks`,
|
||||||
|
params: { limit, offset },
|
||||||
|
method: 'GET',
|
||||||
|
}),
|
||||||
|
providesTags: ['UserRecentTasks'],
|
||||||
|
}),
|
||||||
|
getUserTimeLoggedTasks: builder.query<IUserTimeLoggedTask[], { limit?: number; offset?: number }>({
|
||||||
|
query: ({ limit = 10, offset = 0 }) => ({
|
||||||
|
url: `${rootUrl}/user-time-logged-tasks`,
|
||||||
|
params: { limit, offset },
|
||||||
|
method: 'GET',
|
||||||
|
}),
|
||||||
|
providesTags: ['UserTimeLoggedTasks'],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const {
|
||||||
|
useGetUserRecentTasksQuery,
|
||||||
|
useGetUserTimeLoggedTasksQuery,
|
||||||
|
} = userActivityApiService;
|
||||||
|
|
||||||
|
|
||||||
@@ -7,6 +7,7 @@ import userReducer from '@features/user/userSlice';
|
|||||||
|
|
||||||
// Home Page
|
// Home Page
|
||||||
import homePageReducer from '@features/home-page/home-page.slice';
|
import homePageReducer from '@features/home-page/home-page.slice';
|
||||||
|
import userActivityReducer from '@features/home-page/user-activity.slice';
|
||||||
|
|
||||||
// Account Setup
|
// Account Setup
|
||||||
import accountSetupReducer from '@features/account-setup/account-setup.slice';
|
import accountSetupReducer from '@features/account-setup/account-setup.slice';
|
||||||
@@ -82,6 +83,8 @@ import groupingReducer from '@/features/task-management/grouping.slice';
|
|||||||
import selectionReducer from '@/features/task-management/selection.slice';
|
import selectionReducer from '@/features/task-management/selection.slice';
|
||||||
import homePageApiService from '@/api/home-page/home-page.api.service';
|
import homePageApiService from '@/api/home-page/home-page.api.service';
|
||||||
import { projectsApi } from '@/api/projects/projects.v1.api.service';
|
import { projectsApi } from '@/api/projects/projects.v1.api.service';
|
||||||
|
import { userActivityApiService } from '@/api/home-page/user-activity.api.service';
|
||||||
|
import { ganttApi } from '@/pages/projects/projectView/gantt/services/gantt-api.service';
|
||||||
|
|
||||||
import projectViewReducer from '@features/project/project-view-slice';
|
import projectViewReducer from '@features/project/project-view-slice';
|
||||||
import taskManagementFieldsReducer from '@features/task-management/taskListFields.slice';
|
import taskManagementFieldsReducer from '@features/task-management/taskListFields.slice';
|
||||||
@@ -90,7 +93,7 @@ export const store = configureStore({
|
|||||||
middleware: getDefaultMiddleware =>
|
middleware: getDefaultMiddleware =>
|
||||||
getDefaultMiddleware({
|
getDefaultMiddleware({
|
||||||
serializableCheck: false,
|
serializableCheck: false,
|
||||||
}).concat(homePageApiService.middleware, projectsApi.middleware),
|
}).concat(homePageApiService.middleware, projectsApi.middleware, userActivityApiService.middleware, ganttApi.middleware),
|
||||||
reducer: {
|
reducer: {
|
||||||
// Auth & User
|
// Auth & User
|
||||||
auth: authReducer,
|
auth: authReducer,
|
||||||
@@ -103,6 +106,10 @@ export const store = configureStore({
|
|||||||
homePageReducer: homePageReducer,
|
homePageReducer: homePageReducer,
|
||||||
[homePageApiService.reducerPath]: homePageApiService.reducer,
|
[homePageApiService.reducerPath]: homePageApiService.reducer,
|
||||||
[projectsApi.reducerPath]: projectsApi.reducer,
|
[projectsApi.reducerPath]: projectsApi.reducer,
|
||||||
|
[ganttApi.reducerPath]: ganttApi.reducer,
|
||||||
|
userActivityReducer: userActivityReducer,
|
||||||
|
[userActivityApiService.reducerPath]: userActivityApiService.reducer,
|
||||||
|
|
||||||
// Core UI
|
// Core UI
|
||||||
themeReducer: themeReducer,
|
themeReducer: themeReducer,
|
||||||
localesReducer: localesReducer,
|
localesReducer: localesReducer,
|
||||||
|
|||||||
@@ -10,3 +10,6 @@ export { default as LabelsSelector } from './LabelsSelector';
|
|||||||
export { default as Progress } from './Progress';
|
export { default as Progress } from './Progress';
|
||||||
export { default as Tag } from './Tag';
|
export { default as Tag } from './Tag';
|
||||||
export { default as Tooltip } from './Tooltip';
|
export { default as Tooltip } from './Tooltip';
|
||||||
|
|
||||||
|
// Update Notification Components
|
||||||
|
export * from './update-notification';
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ export const SurveyPromptModal: React.FC<SurveyPromptModalProps> = ({ forceShow
|
|||||||
const isDarkMode = themeMode === 'dark';
|
const isDarkMode = themeMode === 'dark';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Check if survey modal is disabled via environment variable
|
||||||
|
if (import.meta.env.VITE_ENABLE_SURVEY_MODAL !== 'true' && !forceShow) {
|
||||||
|
return; // Don't show modal if disabled in environment
|
||||||
|
}
|
||||||
|
|
||||||
// Check if survey was skipped recently (within 7 days)
|
// Check if survey was skipped recently (within 7 days)
|
||||||
const skippedAt = localStorage.getItem('survey_skipped_at');
|
const skippedAt = localStorage.getItem('survey_skipped_at');
|
||||||
if (!forceShow && skippedAt) {
|
if (!forceShow && skippedAt) {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import TimeLogList from './time-log-list';
|
|||||||
import { taskTimeLogsApiService } from '@/api/tasks/task-time-logs.api.service';
|
import { taskTimeLogsApiService } from '@/api/tasks/task-time-logs.api.service';
|
||||||
import { ITaskLogViewModel } from '@/types/tasks/task-log-view.types';
|
import { ITaskLogViewModel } from '@/types/tasks/task-log-view.types';
|
||||||
import TaskTimer from '@/components/taskListCommon/task-timer/task-timer';
|
import TaskTimer from '@/components/taskListCommon/task-timer/task-timer';
|
||||||
import { useTaskTimer } from '@/hooks/useTaskTimer';
|
import { useTaskTimerWithConflictCheck } from '@/hooks/useTaskTimerWithConflictCheck';
|
||||||
import logger from '@/utils/errorLogger';
|
import logger from '@/utils/errorLogger';
|
||||||
|
|
||||||
interface TaskDrawerTimeLogProps {
|
interface TaskDrawerTimeLogProps {
|
||||||
@@ -31,7 +31,7 @@ const TaskDrawerTimeLog = ({ t, refreshTrigger = 0 }: TaskDrawerTimeLogProps) =>
|
|||||||
state => state.taskDrawerReducer
|
state => state.taskDrawerReducer
|
||||||
);
|
);
|
||||||
|
|
||||||
const { started, timeString, handleStartTimer, handleStopTimer } = useTaskTimer(
|
const { started, timeString, handleStartTimer, handleStopTimer } = useTaskTimerWithConflictCheck(
|
||||||
selectedTaskId || '',
|
selectedTaskId || '',
|
||||||
taskFormViewModel?.task?.timer_start_time || null
|
taskFormViewModel?.task?.timer_start_time || null
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import React, { useMemo, useCallback, useState } from 'react';
|
import React, { useMemo, useCallback, useState } from 'react';
|
||||||
import { useDroppable } from '@dnd-kit/core';
|
|
||||||
// @ts-ignore: Heroicons module types
|
// @ts-ignore: Heroicons module types
|
||||||
import {
|
import {
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
@@ -382,24 +381,12 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({
|
|||||||
t,
|
t,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Make the group header droppable
|
|
||||||
const { isOver, setNodeRef } = useDroppable({
|
|
||||||
id: group.id,
|
|
||||||
data: {
|
|
||||||
type: 'group',
|
|
||||||
group,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex items-center">
|
<div className="relative flex items-center">
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
className="inline-flex w-max items-center px-1 cursor-pointer hover:opacity-80 transition-opacity duration-200 ease-in-out border-t border-b border-gray-200 dark:border-gray-700 rounded-t-md pr-2"
|
||||||
className={`inline-flex w-max items-center px-1 cursor-pointer hover:opacity-80 transition-opacity duration-200 ease-in-out border-t border-b border-gray-200 dark:border-gray-700 rounded-t-md pr-2 ${
|
|
||||||
isOver ? 'ring-2 ring-blue-400 ring-opacity-50' : ''
|
|
||||||
}`}
|
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: isOver ? `${headerBackgroundColor}dd` : headerBackgroundColor,
|
backgroundColor: headerBackgroundColor,
|
||||||
color: headerTextColor,
|
color: headerTextColor,
|
||||||
position: 'sticky',
|
position: 'sticky',
|
||||||
top: 0,
|
top: 0,
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ import {
|
|||||||
KeyboardSensor,
|
KeyboardSensor,
|
||||||
TouchSensor,
|
TouchSensor,
|
||||||
closestCenter,
|
closestCenter,
|
||||||
useDroppable,
|
Modifier,
|
||||||
} from '@dnd-kit/core';
|
} from '@dnd-kit/core';
|
||||||
|
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
|
||||||
import {
|
import {
|
||||||
SortableContext,
|
SortableContext,
|
||||||
verticalListSortingStrategy,
|
verticalListSortingStrategy,
|
||||||
@@ -67,106 +68,132 @@ import TaskListSkeleton from './components/TaskListSkeleton';
|
|||||||
import ConvertToSubtaskDrawer from '@/components/task-list-common/convert-to-subtask-drawer/convert-to-subtask-drawer';
|
import ConvertToSubtaskDrawer from '@/components/task-list-common/convert-to-subtask-drawer/convert-to-subtask-drawer';
|
||||||
import EmptyListPlaceholder from '@/components/EmptyListPlaceholder';
|
import EmptyListPlaceholder from '@/components/EmptyListPlaceholder';
|
||||||
|
|
||||||
// Empty Group Drop Zone Component
|
// Drop Spacer Component - creates space between tasks when dragging
|
||||||
const EmptyGroupDropZone: React.FC<{
|
const DropSpacer: React.FC<{ isVisible: boolean; visibleColumns: any[]; isDarkMode?: boolean }> = ({
|
||||||
groupId: string;
|
isVisible,
|
||||||
visibleColumns: any[];
|
visibleColumns,
|
||||||
t: (key: string) => string;
|
isDarkMode = false
|
||||||
}> = ({ groupId, visibleColumns, t }) => {
|
}) => {
|
||||||
const { setNodeRef, isOver, active } = useDroppable({
|
|
||||||
id: `empty-group-${groupId}`,
|
|
||||||
data: {
|
|
||||||
type: 'group',
|
|
||||||
groupId: groupId,
|
|
||||||
isEmpty: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={setNodeRef}
|
|
||||||
className={`relative w-full transition-colors duration-200 ${
|
|
||||||
isOver && active ? 'bg-blue-50 dark:bg-blue-900/20' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center min-w-max px-1 border-t border-b border-gray-200 dark:border-gray-700" style={{ height: '40px' }}>
|
|
||||||
{visibleColumns.map((column, index) => {
|
|
||||||
const emptyColumnStyle = {
|
|
||||||
width: column.width,
|
|
||||||
flexShrink: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Show text in the title column
|
|
||||||
if (column.id === 'title') {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={`empty-${column.id}`}
|
|
||||||
className="flex items-center pl-1"
|
|
||||||
style={emptyColumnStyle}
|
|
||||||
>
|
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
No tasks in this group
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={`empty-${column.id}`}
|
|
||||||
className="border-r border-gray-200 dark:border-gray-700"
|
|
||||||
style={emptyColumnStyle}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{isOver && active && (
|
|
||||||
<div className="absolute inset-0 border-2 border-dashed border-blue-400 dark:border-blue-500 rounded-md pointer-events-none" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Placeholder Drop Indicator Component
|
|
||||||
const PlaceholderDropIndicator: React.FC<{
|
|
||||||
isVisible: boolean;
|
|
||||||
visibleColumns: any[];
|
|
||||||
}> = ({ isVisible, visibleColumns }) => {
|
|
||||||
if (!isVisible) return null;
|
if (!isVisible) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex items-center min-w-max px-1 border-2 border-dashed border-blue-400 dark:border-blue-500 bg-blue-50 dark:bg-blue-900/20 rounded-md mx-1 my-1 transition-all duration-200 ease-in-out"
|
className="flex items-center min-w-max px-1 border-2 border-dashed border-blue-400 dark:border-blue-500 bg-blue-50 dark:bg-blue-900/20 transition-all duration-200 ease-in-out"
|
||||||
style={{ minWidth: 'max-content', height: '40px' }}
|
style={{
|
||||||
|
height: isVisible ? '40px' : '0px',
|
||||||
|
opacity: isVisible ? 1 : 0,
|
||||||
|
marginTop: isVisible ? '2px' : '0px',
|
||||||
|
marginBottom: isVisible ? '2px' : '0px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{visibleColumns.map((column, index) => {
|
{visibleColumns.map((column, index) => {
|
||||||
|
// Calculate left position for sticky columns
|
||||||
|
let leftPosition = 0;
|
||||||
|
if (column.isSticky) {
|
||||||
|
for (let i = 0; i < index; i++) {
|
||||||
|
const prevColumn = visibleColumns[i];
|
||||||
|
if (prevColumn.isSticky) {
|
||||||
|
leftPosition += parseInt(prevColumn.width.replace('px', ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const columnStyle = {
|
const columnStyle = {
|
||||||
width: column.width,
|
width: column.width,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
|
...(column.isSticky && {
|
||||||
|
position: 'sticky' as const,
|
||||||
|
left: leftPosition,
|
||||||
|
zIndex: 10,
|
||||||
|
backgroundColor: 'inherit', // Inherit from parent spacer
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (column.id === 'title') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`spacer-${column.id}`}
|
||||||
|
className="flex items-center pl-1 border-r border-blue-300 dark:border-blue-600"
|
||||||
|
style={columnStyle}
|
||||||
|
>
|
||||||
|
<span className="text-xs text-blue-600 dark:text-blue-400 font-medium">
|
||||||
|
Drop here
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`placeholder-${column.id}`}
|
key={`spacer-${column.id}`}
|
||||||
className="flex items-center justify-center h-full"
|
className="border-r border-blue-300 dark:border-blue-600"
|
||||||
style={columnStyle}
|
style={columnStyle}
|
||||||
>
|
/>
|
||||||
{/* Show "Drop task here" message in the title column */}
|
|
||||||
{column.id === 'title' && (
|
|
||||||
<div className="text-xs text-blue-600 dark:text-blue-400 font-medium opacity-75">
|
|
||||||
Drop task here
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* Show subtle placeholder content in other columns */}
|
|
||||||
{column.id !== 'title' && column.id !== 'dragHandle' && (
|
|
||||||
<div className="w-full h-4 mx-1 bg-white dark:bg-gray-700 rounded opacity-50" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Empty Group Message Component
|
||||||
|
const EmptyGroupMessage: React.FC<{ visibleColumns: any[]; isDarkMode?: boolean }> = ({
|
||||||
|
visibleColumns,
|
||||||
|
isDarkMode = false
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center min-w-max px-1 border-b border-gray-200 dark:border-gray-700" style={{ height: '40px' }}>
|
||||||
|
{visibleColumns.map((column, index) => {
|
||||||
|
// Calculate left position for sticky columns
|
||||||
|
let leftPosition = 0;
|
||||||
|
if (column.isSticky) {
|
||||||
|
for (let i = 0; i < index; i++) {
|
||||||
|
const prevColumn = visibleColumns[i];
|
||||||
|
if (prevColumn.isSticky) {
|
||||||
|
leftPosition += parseInt(prevColumn.width.replace('px', ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyColumnStyle = {
|
||||||
|
width: column.width,
|
||||||
|
flexShrink: 0,
|
||||||
|
...(column.isSticky && {
|
||||||
|
position: 'sticky' as const,
|
||||||
|
left: leftPosition,
|
||||||
|
zIndex: 10,
|
||||||
|
backgroundColor: 'inherit', // Inherit from parent container
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show text in the title column
|
||||||
|
if (column.id === 'title') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`empty-${column.id}`}
|
||||||
|
className="flex items-center pl-1 border-r border-gray-200 dark:border-gray-700"
|
||||||
|
style={emptyColumnStyle}
|
||||||
|
>
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400 italic">
|
||||||
|
No tasks in this group
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`empty-${column.id}`}
|
||||||
|
className="border-r border-gray-200 dark:border-gray-700"
|
||||||
|
style={emptyColumnStyle}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
// Hooks and utilities
|
// Hooks and utilities
|
||||||
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
|
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
|
||||||
import { useSocket } from '@/socket/socketContext';
|
import { useSocket } from '@/socket/socketContext';
|
||||||
@@ -229,7 +256,7 @@ const TaskListV2Section: React.FC = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Custom hooks
|
// Custom hooks
|
||||||
const { activeId, overId, handleDragStart, handleDragOver, handleDragEnd } = useDragAndDrop(
|
const { activeId, overId, dropPosition, handleDragStart, handleDragOver, handleDragEnd } = useDragAndDrop(
|
||||||
allTasks,
|
allTasks,
|
||||||
groups
|
groups
|
||||||
);
|
);
|
||||||
@@ -564,16 +591,12 @@ const TaskListV2Section: React.FC = () => {
|
|||||||
projectId={urlProjectId || ''}
|
projectId={urlProjectId || ''}
|
||||||
/>
|
/>
|
||||||
{isGroupEmpty && !isGroupCollapsed && (
|
{isGroupEmpty && !isGroupCollapsed && (
|
||||||
<EmptyGroupDropZone
|
<EmptyGroupMessage visibleColumns={visibleColumns} isDarkMode={isDarkMode} />
|
||||||
groupId={group.id}
|
|
||||||
visibleColumns={visibleColumns}
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[virtuosoGroups, collapsedGroups, handleGroupCollapse, visibleColumns, t]
|
[virtuosoGroups, collapsedGroups, handleGroupCollapse, visibleColumns, t, isDarkMode]
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderTask = useCallback(
|
const renderTask = useCallback(
|
||||||
@@ -614,19 +637,40 @@ const TaskListV2Section: React.FC = () => {
|
|||||||
const renderColumnHeaders = useCallback(
|
const renderColumnHeaders = useCallback(
|
||||||
() => (
|
() => (
|
||||||
<div
|
<div
|
||||||
className="bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700"
|
className="border-b border-gray-200 dark:border-gray-700"
|
||||||
style={{ width: '100%', minWidth: 'max-content' }}
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
minWidth: 'max-content',
|
||||||
|
backgroundColor: isDarkMode ? '#141414' : '#f9fafb'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="flex items-center px-1 py-3 w-full"
|
className="flex items-center px-1 py-3 w-full"
|
||||||
style={{ minWidth: 'max-content', height: '44px' }}
|
style={{ minWidth: 'max-content', height: '44px' }}
|
||||||
>
|
>
|
||||||
{visibleColumns.map((column, index) => {
|
{visibleColumns.map((column, index) => {
|
||||||
|
// Calculate left position for sticky columns
|
||||||
|
let leftPosition = 0;
|
||||||
|
if (column.isSticky) {
|
||||||
|
for (let i = 0; i < index; i++) {
|
||||||
|
const prevColumn = visibleColumns[i];
|
||||||
|
if (prevColumn.isSticky) {
|
||||||
|
leftPosition += parseInt(prevColumn.width.replace('px', ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const columnStyle: ColumnStyle = {
|
const columnStyle: ColumnStyle = {
|
||||||
width: column.width,
|
width: column.width,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
...((column as any).minWidth && { minWidth: (column as any).minWidth }),
|
...((column as any).minWidth && { minWidth: (column as any).minWidth }),
|
||||||
...((column as any).maxWidth && { maxWidth: (column as any).maxWidth }),
|
...((column as any).maxWidth && { maxWidth: (column as any).maxWidth }),
|
||||||
|
...(column.isSticky && {
|
||||||
|
position: 'sticky' as const,
|
||||||
|
left: leftPosition,
|
||||||
|
zIndex: 15,
|
||||||
|
backgroundColor: isDarkMode ? '#141414' : '#f9fafb', // custom dark header : bg-gray-50
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -791,13 +835,25 @@ const TaskListV2Section: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DndContext
|
<>
|
||||||
sensors={sensors}
|
{/* CSS for sticky column hover effects */}
|
||||||
collisionDetection={closestCenter}
|
<style>
|
||||||
onDragStart={handleDragStart}
|
{`
|
||||||
onDragOver={handleDragOver}
|
.hover\\:bg-gray-50:hover .sticky-column-hover,
|
||||||
onDragEnd={handleDragEnd}
|
.dark .hover\\:bg-gray-800:hover .sticky-column-hover {
|
||||||
>
|
background-color: var(--hover-bg) !important;
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
modifiers={[restrictToVerticalAxis]}
|
||||||
|
>
|
||||||
<div className="flex flex-col bg-white dark:bg-gray-900 h-full overflow-hidden">
|
<div className="flex flex-col bg-white dark:bg-gray-900 h-full overflow-hidden">
|
||||||
{/* Table Container */}
|
{/* Table Container */}
|
||||||
<div
|
<div
|
||||||
@@ -851,40 +907,20 @@ const TaskListV2Section: React.FC = () => {
|
|||||||
// Check if this is the first actual task in the group (not AddTaskRow)
|
// Check if this is the first actual task in the group (not AddTaskRow)
|
||||||
const isFirstTaskInGroup = taskIndex === 0 && !('isAddTaskRow' in task);
|
const isFirstTaskInGroup = taskIndex === 0 && !('isAddTaskRow' in task);
|
||||||
|
|
||||||
// Check if we should show drop indicators
|
// Check if we should show drop spacer
|
||||||
const isTaskBeingDraggedOver = overId === task.id;
|
const isOverThisTask = activeId && overId === task.id && !('isAddTaskRow' in task);
|
||||||
const isGroupBeingDraggedOver = overId === group.id;
|
const showDropSpacerBefore = isOverThisTask && dropPosition === 'before';
|
||||||
const isFirstTaskInGroupBeingDraggedOver = isFirstTaskInGroup && isTaskBeingDraggedOver;
|
const showDropSpacerAfter = isOverThisTask && dropPosition === 'after';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={task.id || `add-task-${group.id}-${taskIndex}`}>
|
<div key={task.id || `add-task-${group.id}-${taskIndex}`}>
|
||||||
{/* Placeholder drop indicator before first task in group */}
|
{showDropSpacerBefore && <DropSpacer isVisible={true} visibleColumns={visibleColumns} isDarkMode={isDarkMode} />}
|
||||||
{isFirstTaskInGroupBeingDraggedOver && (
|
|
||||||
<PlaceholderDropIndicator isVisible={true} visibleColumns={visibleColumns} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Placeholder drop indicator between tasks */}
|
|
||||||
{isTaskBeingDraggedOver && !isFirstTaskInGroup && (
|
|
||||||
<PlaceholderDropIndicator isVisible={true} visibleColumns={visibleColumns} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{renderTask(globalTaskIndex, isFirstTaskInGroup)}
|
{renderTask(globalTaskIndex, isFirstTaskInGroup)}
|
||||||
|
{showDropSpacerAfter && <DropSpacer isVisible={true} visibleColumns={visibleColumns} isDarkMode={isDarkMode} />}
|
||||||
{/* Placeholder drop indicator at end of group when dragging over group */}
|
|
||||||
{isGroupBeingDraggedOver && taskIndex === group.tasks.length - 1 && (
|
|
||||||
<PlaceholderDropIndicator isVisible={true} visibleColumns={visibleColumns} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
) : (
|
) : null
|
||||||
// Handle empty groups with placeholder drop indicator
|
|
||||||
overId === group.id && (
|
|
||||||
<div style={{ minWidth: 'max-content' }}>
|
|
||||||
<PlaceholderDropIndicator isVisible={true} visibleColumns={visibleColumns} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -894,15 +930,15 @@ const TaskListV2Section: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Drag Overlay */}
|
{/* Drag Overlay */}
|
||||||
<DragOverlay dropAnimation={{ duration: 200, easing: 'ease-in-out' }}>
|
<DragOverlay dropAnimation={{ duration: 200, easing: 'cubic-bezier(0.18, 0.67, 0.6, 1.22)' }}>
|
||||||
{activeId ? (
|
{activeId ? (
|
||||||
<div
|
<div
|
||||||
className="bg-white dark:bg-gray-800 shadow-lg rounded-md border border-blue-400 dark:border-blue-500 opacity-90"
|
className="bg-white dark:bg-gray-800 shadow-2xl rounded-lg border-2 border-blue-500 dark:border-blue-400 opacity-95"
|
||||||
style={{ width: visibleColumns.find(col => col.id === 'title')?.width || '300px' }}
|
style={{ width: visibleColumns.find(col => col.id === 'title')?.width || '300px' }}
|
||||||
>
|
>
|
||||||
<div className="px-3 py-2">
|
<div className="px-4 py-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-3">
|
||||||
<HolderOutlined className="text-gray-400 dark:text-gray-500 text-xs" />
|
<HolderOutlined className="text-blue-500 dark:text-blue-400 text-sm" />
|
||||||
<div className="text-sm font-medium text-gray-900 dark:text-white truncate flex-1">
|
<div className="text-sm font-medium text-gray-900 dark:text-white truncate flex-1">
|
||||||
{allTasks.find(task => task.id === activeId)?.name ||
|
{allTasks.find(task => task.id === activeId)?.name ||
|
||||||
allTasks.find(task => task.id === activeId)?.title ||
|
allTasks.find(task => task.id === activeId)?.title ||
|
||||||
@@ -954,6 +990,7 @@ const TaskListV2Section: React.FC = () => {
|
|||||||
{createPortal(<ConvertToSubtaskDrawer />, document.body, 'convert-to-subtask-drawer')}
|
{createPortal(<ConvertToSubtaskDrawer />, document.body, 'convert-to-subtask-drawer')}
|
||||||
</div>
|
</div>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ const TaskRow: React.FC<TaskRowProps> = memo(({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Drag and drop functionality - only enable for parent tasks
|
// Drag and drop functionality - only enable for parent tasks
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging, isOver } = useSortable({
|
||||||
id: task.id,
|
id: task.id,
|
||||||
data: {
|
data: {
|
||||||
type: 'task',
|
type: 'task',
|
||||||
@@ -116,24 +116,41 @@ const TaskRow: React.FC<TaskRowProps> = memo(({
|
|||||||
const style = useMemo(() => ({
|
const style = useMemo(() => ({
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
transition,
|
transition,
|
||||||
opacity: isDragging ? 0 : 1, // Completely hide the original task while dragging
|
opacity: isDragging ? 0.3 : 1, // Make original task slightly transparent while dragging
|
||||||
}), [transform, transition, isDragging]);
|
}), [transform, transition, isDragging]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={{ ...style, height: '40px' }}
|
style={{ ...style, height: '40px' }}
|
||||||
className={`flex items-center min-w-max px-1 border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 ${
|
className={`flex items-center min-w-max px-1 border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors ${
|
||||||
isFirstInGroup ? 'border-t border-gray-200 dark:border-gray-700' : ''
|
isFirstInGroup ? 'border-t border-gray-200 dark:border-gray-700' : ''
|
||||||
} ${
|
} ${
|
||||||
isDragging ? 'shadow-lg border border-blue-300' : ''
|
isDragging ? 'opacity-50' : ''
|
||||||
|
} ${
|
||||||
|
isOver && !isDragging ? 'bg-blue-50 dark:bg-blue-900/20' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{visibleColumns.map((column, index) => (
|
{visibleColumns.map((column, index) => {
|
||||||
<React.Fragment key={column.id}>
|
// Calculate background state for sticky columns - custom dark mode colors
|
||||||
{renderColumn(column.id, column.width, column.isSticky, index)}
|
const rowBackgrounds = {
|
||||||
</React.Fragment>
|
normal: isDarkMode ? '#1e1e1e' : '#ffffff', // custom dark : bg-white
|
||||||
))}
|
hover: isDarkMode ? '#1f2937' : '#f9fafb', // slightly lighter dark : bg-gray-50
|
||||||
|
dragOver: isDarkMode ? '#1e3a8a33' : '#dbeafe', // bg-blue-900/20 : bg-blue-50
|
||||||
|
};
|
||||||
|
|
||||||
|
let currentBg = rowBackgrounds.normal;
|
||||||
|
if (isOver && !isDragging) {
|
||||||
|
currentBg = rowBackgrounds.dragOver;
|
||||||
|
}
|
||||||
|
// Note: hover state is handled by CSS, so we'll use a CSS custom property
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={column.id}>
|
||||||
|
{renderColumn(column.id, column.width, column.isSticky, index, currentBg, rowBackgrounds)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import TaskTimer from '@/components/taskListCommon/task-timer/task-timer';
|
import TaskTimer from '@/components/taskListCommon/task-timer/task-timer';
|
||||||
import { useTaskTimer } from '@/hooks/useTaskTimer';
|
import { useTaskTimerWithConflictCheck } from '@/hooks/useTaskTimerWithConflictCheck';
|
||||||
|
|
||||||
interface TaskTimeTrackingProps {
|
interface TaskTimeTrackingProps {
|
||||||
taskId: string;
|
taskId: string;
|
||||||
@@ -8,7 +8,7 @@ interface TaskTimeTrackingProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TaskTimeTracking: React.FC<TaskTimeTrackingProps> = React.memo(({ taskId, isDarkMode }) => {
|
const TaskTimeTracking: React.FC<TaskTimeTrackingProps> = React.memo(({ taskId, isDarkMode }) => {
|
||||||
const { started, timeString, handleStartTimer, handleStopTimer } = useTaskTimer(
|
const { started, timeString, handleStartTimer, handleStopTimer } = useTaskTimerWithConflictCheck(
|
||||||
taskId,
|
taskId,
|
||||||
null // The hook will get the timer start time from Redux
|
null // The hook will get the timer start time from Redux
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ import {
|
|||||||
RetweetOutlined,
|
RetweetOutlined,
|
||||||
UserAddOutlined,
|
UserAddOutlined,
|
||||||
LoadingOutlined,
|
LoadingOutlined,
|
||||||
|
CopyOutlined,
|
||||||
|
message,
|
||||||
} from '@/shared/antd-imports';
|
} from '@/shared/antd-imports';
|
||||||
|
|
||||||
interface TaskContextMenuProps {
|
interface TaskContextMenuProps {
|
||||||
@@ -325,6 +327,21 @@ const TaskContextMenu: React.FC<TaskContextMenuProps> = ({
|
|||||||
}
|
}
|
||||||
}, [task?.id, projectId, dispatch, onClose]);
|
}, [task?.id, projectId, dispatch, onClose]);
|
||||||
|
|
||||||
|
const handleCopyLink = useCallback(async () => {
|
||||||
|
if (!projectId || !task.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const taskLink = `${window.location.origin}/worklenz/projects/${projectId}?tab=tasks-list&pinned_tab=tasks-list&task=${task.id}`;
|
||||||
|
await navigator.clipboard.writeText(taskLink);
|
||||||
|
message.success(t('contextMenu.linkCopied'));
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error copying link:', error);
|
||||||
|
message.error(t('contextMenu.linkCopyFailed'));
|
||||||
|
} finally {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}, [projectId, task.id, onClose, t]);
|
||||||
|
|
||||||
const menuItems = useMemo(() => {
|
const menuItems = useMemo(() => {
|
||||||
const items = [
|
const items = [
|
||||||
{
|
{
|
||||||
@@ -344,6 +361,18 @@ const TaskContextMenu: React.FC<TaskContextMenuProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'copyLink',
|
||||||
|
label: (
|
||||||
|
<button
|
||||||
|
onClick={handleCopyLink}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 w-full text-left"
|
||||||
|
>
|
||||||
|
<CopyOutlined className="text-gray-500 dark:text-gray-400" />
|
||||||
|
<span>{t('contextMenu.copyLink')}</span>
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Add Move To submenu if there are options
|
// Add Move To submenu if there are options
|
||||||
@@ -500,6 +529,7 @@ const TaskContextMenu: React.FC<TaskContextMenuProps> = ({
|
|||||||
handleArchive,
|
handleArchive,
|
||||||
handleDelete,
|
handleDelete,
|
||||||
handleConvertToTask,
|
handleConvertToTask,
|
||||||
|
handleCopyLink,
|
||||||
getMoveToOptions,
|
getMoveToOptions,
|
||||||
dispatch,
|
dispatch,
|
||||||
t,
|
t,
|
||||||
|
|||||||
@@ -19,10 +19,11 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
|
|||||||
const currentSession = useAuthService().getCurrentSession();
|
const currentSession = useAuthService().getCurrentSession();
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
const [overId, setOverId] = useState<string | null>(null);
|
const [overId, setOverId] = useState<string | null>(null);
|
||||||
|
const [dropPosition, setDropPosition] = useState<'before' | 'after' | null>(null);
|
||||||
|
|
||||||
// Helper function to emit socket event for persistence
|
// Helper function to emit socket event for persistence (within-group only)
|
||||||
const emitTaskSortChange = useCallback(
|
const emitTaskSortChange = useCallback(
|
||||||
(taskId: string, sourceGroup: TaskGroup, targetGroup: TaskGroup, insertIndex: number) => {
|
(taskId: string, group: TaskGroup, insertIndex: number) => {
|
||||||
if (!socket || !connected || !projectId) {
|
if (!socket || !connected || !projectId) {
|
||||||
logger.warning('Socket not connected or missing project ID');
|
logger.warning('Socket not connected or missing project ID');
|
||||||
return;
|
return;
|
||||||
@@ -40,54 +41,30 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
|
|||||||
// Use new bulk update approach - recalculate ALL task orders to prevent duplicates
|
// Use new bulk update approach - recalculate ALL task orders to prevent duplicates
|
||||||
const taskUpdates: any[] = [];
|
const taskUpdates: any[] = [];
|
||||||
|
|
||||||
// Create a copy of all groups and perform the move operation
|
// Create a copy of all groups
|
||||||
const updatedGroups = groups.map(group => ({
|
const updatedGroups = groups.map(g => ({
|
||||||
...group,
|
...g,
|
||||||
taskIds: [...group.taskIds]
|
taskIds: [...g.taskIds]
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Find the source and target groups in our copy
|
// Find the group in our copy
|
||||||
const sourceGroupCopy = updatedGroups.find(g => g.id === sourceGroup.id)!;
|
const groupCopy = updatedGroups.find(g => g.id === group.id)!;
|
||||||
const targetGroupCopy = updatedGroups.find(g => g.id === targetGroup.id)!;
|
|
||||||
|
|
||||||
if (sourceGroup.id === targetGroup.id) {
|
// Reorder within the group
|
||||||
// Same group - reorder within the group
|
const sourceIndex = groupCopy.taskIds.indexOf(taskId);
|
||||||
const sourceIndex = sourceGroupCopy.taskIds.indexOf(taskId);
|
// Remove task from old position
|
||||||
// Remove task from old position
|
groupCopy.taskIds.splice(sourceIndex, 1);
|
||||||
sourceGroupCopy.taskIds.splice(sourceIndex, 1);
|
// Insert at new position
|
||||||
// Insert at new position
|
groupCopy.taskIds.splice(insertIndex, 0, taskId);
|
||||||
sourceGroupCopy.taskIds.splice(insertIndex, 0, taskId);
|
|
||||||
} else {
|
|
||||||
// Different groups - move task between groups
|
|
||||||
// Remove from source group
|
|
||||||
const sourceIndex = sourceGroupCopy.taskIds.indexOf(taskId);
|
|
||||||
sourceGroupCopy.taskIds.splice(sourceIndex, 1);
|
|
||||||
|
|
||||||
// Add to target group
|
|
||||||
targetGroupCopy.taskIds.splice(insertIndex, 0, taskId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now assign sequential sort orders to ALL tasks across ALL groups
|
// Now assign sequential sort orders to ALL tasks across ALL groups
|
||||||
let currentSortOrder = 0;
|
let currentSortOrder = 0;
|
||||||
updatedGroups.forEach(group => {
|
updatedGroups.forEach(grp => {
|
||||||
group.taskIds.forEach(id => {
|
grp.taskIds.forEach(id => {
|
||||||
const update: any = {
|
taskUpdates.push({
|
||||||
task_id: id,
|
task_id: id,
|
||||||
sort_order: currentSortOrder
|
sort_order: currentSortOrder
|
||||||
};
|
});
|
||||||
|
|
||||||
// Add group-specific fields for the moved task if it changed groups
|
|
||||||
if (id === taskId && sourceGroup.id !== targetGroup.id) {
|
|
||||||
if (currentGrouping === 'status') {
|
|
||||||
update.status_id = targetGroup.id;
|
|
||||||
} else if (currentGrouping === 'priority') {
|
|
||||||
update.priority_id = targetGroup.id;
|
|
||||||
} else if (currentGrouping === 'phase') {
|
|
||||||
update.phase_id = targetGroup.id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
taskUpdates.push(update);
|
|
||||||
currentSortOrder++;
|
currentSortOrder++;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -96,8 +73,8 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
|
|||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
group_by: currentGrouping || 'status',
|
group_by: currentGrouping || 'status',
|
||||||
task_updates: taskUpdates,
|
task_updates: taskUpdates,
|
||||||
from_group: sourceGroup.id,
|
from_group: group.id,
|
||||||
to_group: targetGroup.id,
|
to_group: group.id,
|
||||||
task: {
|
task: {
|
||||||
id: task.id,
|
id: task.id,
|
||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
@@ -108,32 +85,6 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), socketData);
|
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), socketData);
|
||||||
|
|
||||||
// Also emit the specific grouping field change event for the moved task
|
|
||||||
if (sourceGroup.id !== targetGroup.id) {
|
|
||||||
if (currentGrouping === 'phase') {
|
|
||||||
// Emit phase change event
|
|
||||||
socket.emit(SocketEvents.TASK_PHASE_CHANGE.toString(), {
|
|
||||||
task_id: taskId,
|
|
||||||
phase_id: targetGroup.id,
|
|
||||||
parent_task: task.parent_task_id || null,
|
|
||||||
});
|
|
||||||
} else if (currentGrouping === 'priority') {
|
|
||||||
// Emit priority change event
|
|
||||||
socket.emit(SocketEvents.TASK_PRIORITY_CHANGE.toString(), JSON.stringify({
|
|
||||||
task_id: taskId,
|
|
||||||
priority_id: targetGroup.id,
|
|
||||||
team_id: teamId,
|
|
||||||
}));
|
|
||||||
} else if (currentGrouping === 'status') {
|
|
||||||
// Emit status change event
|
|
||||||
socket.emit(SocketEvents.TASK_STATUS_CHANGE.toString(), JSON.stringify({
|
|
||||||
task_id: taskId,
|
|
||||||
status_id: targetGroup.id,
|
|
||||||
team_id: teamId,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[socket, connected, projectId, allTasks, groups, currentGrouping, currentSession]
|
[socket, connected, projectId, allTasks, groups, currentGrouping, currentSession]
|
||||||
);
|
);
|
||||||
@@ -148,32 +99,38 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
|
|||||||
|
|
||||||
if (!over) {
|
if (!over) {
|
||||||
setOverId(null);
|
setOverId(null);
|
||||||
|
setDropPosition(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeId = active.id;
|
const activeTask = allTasks.find(task => task.id === active.id);
|
||||||
const overId = over.id;
|
const overTask = allTasks.find(task => task.id === over.id);
|
||||||
|
|
||||||
// Set the overId for drop indicators
|
if (activeTask && overTask) {
|
||||||
setOverId(overId as string);
|
const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id));
|
||||||
|
const overGroup = groups.find(group => group.taskIds.includes(overTask.id));
|
||||||
|
|
||||||
// Find the active task and the item being dragged over
|
// Only set overId if both tasks are in the same group
|
||||||
const activeTask = allTasks.find(task => task.id === activeId);
|
if (activeGroup && overGroup && activeGroup.id === overGroup.id) {
|
||||||
if (!activeTask) return;
|
setOverId(over.id as string);
|
||||||
|
|
||||||
// Check if we're dragging over a task or a group
|
// Calculate drop position based on task indices
|
||||||
const overTask = allTasks.find(task => task.id === overId);
|
const activeIndex = activeGroup.taskIds.indexOf(activeTask.id);
|
||||||
const overGroup = groups.find(group => group.id === overId);
|
const overIndex = activeGroup.taskIds.indexOf(overTask.id);
|
||||||
|
|
||||||
// Find the groups
|
if (activeIndex < overIndex) {
|
||||||
const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id));
|
setDropPosition('after');
|
||||||
let targetGroup = overGroup;
|
} else {
|
||||||
|
setDropPosition('before');
|
||||||
if (overTask) {
|
}
|
||||||
targetGroup = groups.find(group => group.taskIds.includes(overTask.id));
|
} else {
|
||||||
|
setOverId(null);
|
||||||
|
setDropPosition(null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setOverId(null);
|
||||||
|
setDropPosition(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!activeGroup || !targetGroup) return;
|
|
||||||
},
|
},
|
||||||
[allTasks, groups]
|
[allTasks, groups]
|
||||||
);
|
);
|
||||||
@@ -183,6 +140,7 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
|
|||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
setActiveId(null);
|
setActiveId(null);
|
||||||
setOverId(null);
|
setOverId(null);
|
||||||
|
setDropPosition(null);
|
||||||
|
|
||||||
if (!over || active.id === over.id) {
|
if (!over || active.id === over.id) {
|
||||||
return;
|
return;
|
||||||
@@ -198,86 +156,50 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the groups
|
// Find the active task's group
|
||||||
const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id));
|
const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id));
|
||||||
if (!activeGroup) {
|
if (!activeGroup) {
|
||||||
logger.error('Could not find active group for task:', activeId);
|
logger.error('Could not find active group for task:', activeId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we're dropping on a task, group, or empty group
|
// Only allow dropping on tasks in the same group
|
||||||
const overTask = allTasks.find(task => task.id === overId);
|
const overTask = allTasks.find(task => task.id === overId);
|
||||||
const overGroup = groups.find(group => group.id === overId);
|
if (!overTask) {
|
||||||
|
return;
|
||||||
// Check if dropping on empty group drop zone
|
}
|
||||||
const isEmptyGroupDrop = typeof overId === 'string' && overId.startsWith('empty-group-');
|
|
||||||
const emptyGroupId = isEmptyGroupDrop ? overId.replace('empty-group-', '') : null;
|
const overGroup = groups.find(group => group.taskIds.includes(overTask.id));
|
||||||
const emptyGroup = emptyGroupId ? groups.find(group => group.id === emptyGroupId) : null;
|
if (!overGroup || overGroup.id !== activeGroup.id) {
|
||||||
|
|
||||||
let targetGroup = overGroup || emptyGroup;
|
|
||||||
let insertIndex = 0;
|
|
||||||
|
|
||||||
if (overTask) {
|
|
||||||
// Dropping on a task
|
|
||||||
targetGroup = groups.find(group => group.taskIds.includes(overTask.id));
|
|
||||||
if (targetGroup) {
|
|
||||||
insertIndex = targetGroup.taskIds.indexOf(overTask.id);
|
|
||||||
}
|
|
||||||
} else if (overGroup) {
|
|
||||||
// Dropping on a group (at the end)
|
|
||||||
targetGroup = overGroup;
|
|
||||||
insertIndex = targetGroup.taskIds.length;
|
|
||||||
} else if (emptyGroup) {
|
|
||||||
// Dropping on an empty group
|
|
||||||
targetGroup = emptyGroup;
|
|
||||||
insertIndex = 0; // First position in empty group
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!targetGroup) {
|
|
||||||
logger.error('Could not find target group');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isCrossGroup = activeGroup.id !== targetGroup.id;
|
|
||||||
const activeIndex = activeGroup.taskIds.indexOf(activeTask.id);
|
const activeIndex = activeGroup.taskIds.indexOf(activeTask.id);
|
||||||
|
const overIndex = activeGroup.taskIds.indexOf(overTask.id);
|
||||||
|
|
||||||
if (isCrossGroup) {
|
if (activeIndex !== overIndex) {
|
||||||
// Moving task between groups
|
// Reorder task within same group
|
||||||
console.log('Moving task between groups:', {
|
|
||||||
task: activeTask.name || activeTask.title,
|
|
||||||
from: activeGroup.title,
|
|
||||||
to: targetGroup.title,
|
|
||||||
newPosition: insertIndex,
|
|
||||||
});
|
|
||||||
|
|
||||||
// reorderTasksInGroup handles both same-group and cross-group moves
|
|
||||||
// No need for separate moveTaskBetweenGroups call
|
|
||||||
dispatch(
|
dispatch(
|
||||||
reorderTasksInGroup({
|
reorderTasksInGroup({
|
||||||
sourceTaskId: activeId as string,
|
sourceTaskId: activeId as string,
|
||||||
destinationTaskId: over.id as string,
|
destinationTaskId: overId as string,
|
||||||
sourceGroupId: activeGroup.id,
|
sourceGroupId: activeGroup.id,
|
||||||
destinationGroupId: targetGroup.id,
|
destinationGroupId: activeGroup.id,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Emit socket event for persistence
|
// Calculate the final index after reordering for socket emission
|
||||||
emitTaskSortChange(activeId as string, activeGroup, targetGroup, insertIndex);
|
let finalIndex = overIndex;
|
||||||
} else {
|
if (activeIndex < overIndex) {
|
||||||
if (activeIndex !== insertIndex) {
|
// When dragging down, the task ends up just after the destination
|
||||||
// Reorder task within same group at drop position
|
finalIndex = overIndex;
|
||||||
dispatch(
|
} else {
|
||||||
reorderTasksInGroup({
|
// When dragging up, the task ends up at the destination position
|
||||||
sourceTaskId: activeId as string,
|
finalIndex = overIndex;
|
||||||
destinationTaskId: over.id as string,
|
|
||||||
sourceGroupId: activeGroup.id,
|
|
||||||
destinationGroupId: activeGroup.id,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Emit socket event for persistence
|
|
||||||
emitTaskSortChange(activeId as string, activeGroup, targetGroup, insertIndex);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Emit socket event for persistence
|
||||||
|
emitTaskSortChange(activeId as string, activeGroup, finalIndex);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[allTasks, groups, dispatch, emitTaskSortChange]
|
[allTasks, groups, dispatch, emitTaskSortChange]
|
||||||
@@ -286,6 +208,7 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
|
|||||||
return {
|
return {
|
||||||
activeId,
|
activeId,
|
||||||
overId,
|
overId,
|
||||||
|
dropPosition,
|
||||||
handleDragStart,
|
handleDragStart,
|
||||||
handleDragOver,
|
handleDragOver,
|
||||||
handleDragEnd,
|
handleDragEnd,
|
||||||
|
|||||||
@@ -90,17 +90,39 @@ export const useTaskRowColumns = ({
|
|||||||
depth = 0,
|
depth = 0,
|
||||||
}: UseTaskRowColumnsProps) => {
|
}: UseTaskRowColumnsProps) => {
|
||||||
|
|
||||||
const renderColumn = useCallback((columnId: string, width: string, isSticky?: boolean, index?: number) => {
|
const renderColumn = useCallback((columnId: string, width: string, isSticky?: boolean, index?: number, currentBg?: string, rowBackgrounds?: any) => {
|
||||||
switch (columnId) {
|
// Calculate left position for sticky columns
|
||||||
case 'dragHandle':
|
let leftPosition = 0;
|
||||||
return (
|
if (isSticky && typeof index === 'number') {
|
||||||
<DragHandleColumn
|
for (let i = 0; i < index; i++) {
|
||||||
width={width}
|
const prevColumn = visibleColumns[i];
|
||||||
isSubtask={isSubtask}
|
if (prevColumn.isSticky) {
|
||||||
attributes={attributes}
|
leftPosition += parseInt(prevColumn.width.replace('px', ''));
|
||||||
listeners={listeners}
|
}
|
||||||
/>
|
}
|
||||||
);
|
}
|
||||||
|
|
||||||
|
// Create wrapper style for sticky positioning
|
||||||
|
const wrapperStyle = isSticky ? {
|
||||||
|
position: 'sticky' as const,
|
||||||
|
left: leftPosition,
|
||||||
|
zIndex: 5, // Lower than header but above regular content
|
||||||
|
backgroundColor: currentBg || (isDarkMode ? '#1e1e1e' : '#ffffff'), // Use dynamic background or fallback
|
||||||
|
overflow: 'hidden', // Prevent content from spilling over
|
||||||
|
width: width, // Ensure the wrapper respects column width
|
||||||
|
} : {};
|
||||||
|
|
||||||
|
const renderColumnContent = () => {
|
||||||
|
switch (columnId) {
|
||||||
|
case 'dragHandle':
|
||||||
|
return (
|
||||||
|
<DragHandleColumn
|
||||||
|
width={width}
|
||||||
|
isSubtask={isSubtask}
|
||||||
|
attributes={attributes}
|
||||||
|
listeners={listeners}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
case 'checkbox':
|
case 'checkbox':
|
||||||
return (
|
return (
|
||||||
@@ -294,7 +316,27 @@ export const useTaskRowColumns = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Wrap content with sticky positioning if needed
|
||||||
|
const content = renderColumnContent();
|
||||||
|
if (isSticky) {
|
||||||
|
const hoverBg = rowBackgrounds?.hover || (isDarkMode ? '#2a2a2a' : '#f9fafb');
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...wrapperStyle,
|
||||||
|
'--hover-bg': hoverBg,
|
||||||
|
} as React.CSSProperties}
|
||||||
|
className="border-r border-gray-200 dark:border-gray-700 overflow-hidden sticky-column-hover hover:bg-[var(--hover-bg)]"
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
}, [
|
}, [
|
||||||
task,
|
task,
|
||||||
projectId,
|
projectId,
|
||||||
@@ -319,6 +361,7 @@ export const useTaskRowColumns = ({
|
|||||||
handleTaskNameEdit,
|
handleTaskNameEdit,
|
||||||
attributes,
|
attributes,
|
||||||
listeners,
|
listeners,
|
||||||
|
depth,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return { renderColumn };
|
return { renderColumn };
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { Task } from '@/types/task-management.types';
|
|||||||
import {
|
import {
|
||||||
updateTask,
|
updateTask,
|
||||||
selectCurrentGroupingV3,
|
selectCurrentGroupingV3,
|
||||||
|
selectGroups,
|
||||||
|
moveTaskBetweenGroups,
|
||||||
} from '@/features/task-management/task-management.slice';
|
} from '@/features/task-management/task-management.slice';
|
||||||
|
|
||||||
interface TaskStatusDropdownProps {
|
interface TaskStatusDropdownProps {
|
||||||
@@ -30,6 +32,7 @@ const TaskStatusDropdown: React.FC<TaskStatusDropdownProps> = ({
|
|||||||
|
|
||||||
const statusList = useAppSelector(state => state.taskStatusReducer.status);
|
const statusList = useAppSelector(state => state.taskStatusReducer.status);
|
||||||
const currentGroupingV3 = useAppSelector(selectCurrentGroupingV3);
|
const currentGroupingV3 = useAppSelector(selectCurrentGroupingV3);
|
||||||
|
const groups = useAppSelector(selectGroups);
|
||||||
|
|
||||||
// Find current status details
|
// Find current status details
|
||||||
const currentStatus = useMemo(() => {
|
const currentStatus = useMemo(() => {
|
||||||
@@ -44,21 +47,53 @@ const TaskStatusDropdown: React.FC<TaskStatusDropdownProps> = ({
|
|||||||
(statusId: string, statusName: string) => {
|
(statusId: string, statusName: string) => {
|
||||||
if (!task.id || !statusId || !connected) return;
|
if (!task.id || !statusId || !connected) return;
|
||||||
|
|
||||||
console.log('🎯 Status change initiated:', { taskId: task.id, statusId, statusName });
|
// Optimistic update: immediately update the task status in Redux for instant feedback
|
||||||
|
const updatedTask = {
|
||||||
|
...task,
|
||||||
|
status: statusId,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
dispatch(updateTask(updatedTask));
|
||||||
|
|
||||||
|
// Handle group movement if grouping by status
|
||||||
|
if (currentGroupingV3 === 'status' && groups && groups.length > 0) {
|
||||||
|
// Find current group containing the task
|
||||||
|
const currentGroup = groups.find(group => group.taskIds.includes(task.id));
|
||||||
|
|
||||||
|
// Find target group based on the new status ID
|
||||||
|
let targetGroup = groups.find(group => group.id === statusId);
|
||||||
|
|
||||||
|
// If not found by status ID, try matching with group value
|
||||||
|
if (!targetGroup) {
|
||||||
|
targetGroup = groups.find(group => group.groupValue === statusId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentGroup && targetGroup && currentGroup.id !== targetGroup.id) {
|
||||||
|
// Move task between groups immediately for instant feedback
|
||||||
|
dispatch(
|
||||||
|
moveTaskBetweenGroups({
|
||||||
|
taskId: task.id,
|
||||||
|
sourceGroupId: currentGroup.id,
|
||||||
|
targetGroupId: targetGroup.id,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit socket event for server-side update and real-time sync
|
||||||
socket?.emit(
|
socket?.emit(
|
||||||
SocketEvents.TASK_STATUS_CHANGE.toString(),
|
SocketEvents.TASK_STATUS_CHANGE.toString(),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
task_id: task.id,
|
task_id: task.id,
|
||||||
status_id: statusId,
|
status_id: statusId,
|
||||||
parent_task: null, // Assuming top-level tasks for now
|
parent_task: task.parent_task_id || null,
|
||||||
team_id: projectId, // Using projectId as teamId
|
team_id: projectId,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id);
|
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
},
|
},
|
||||||
[task.id, connected, socket, projectId]
|
[task, connected, socket, projectId, dispatch, currentGroupingV3, groups]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Calculate dropdown position and handle outside clicks
|
// Calculate dropdown position and handle outside clicks
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
// Update Notification Component
|
||||||
|
// Shows a notification when new build is available and provides update options
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Modal, Button, Space, Typography } from '@/shared/antd-imports';
|
||||||
|
import { ReloadOutlined, CloseOutlined, DownloadOutlined } from '@/shared/antd-imports';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useServiceWorker } from '../../utils/serviceWorkerRegistration';
|
||||||
|
|
||||||
|
const { Text, Title } = Typography;
|
||||||
|
|
||||||
|
interface UpdateNotificationProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onUpdate: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UpdateNotification: React.FC<UpdateNotificationProps> = ({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
onUpdate
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation('common');
|
||||||
|
const [isUpdating, setIsUpdating] = React.useState(false);
|
||||||
|
const { hardReload } = useServiceWorker();
|
||||||
|
|
||||||
|
const handleUpdate = async () => {
|
||||||
|
setIsUpdating(true);
|
||||||
|
try {
|
||||||
|
if (hardReload) {
|
||||||
|
await hardReload();
|
||||||
|
} else {
|
||||||
|
// Fallback to regular reload
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
onUpdate();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during update:', error);
|
||||||
|
// Fallback to regular reload
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLater = () => {
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<DownloadOutlined style={{ color: '#1890ff' }} />
|
||||||
|
<Title level={4} style={{ margin: 0, color: '#1890ff' }}>
|
||||||
|
{t('update-available')}
|
||||||
|
</Title>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
open={visible}
|
||||||
|
onCancel={handleLater}
|
||||||
|
footer={null}
|
||||||
|
centered
|
||||||
|
closable={false}
|
||||||
|
maskClosable={false}
|
||||||
|
width={460}
|
||||||
|
styles={{
|
||||||
|
body: { padding: '20px 24px' }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: '20px' }}>
|
||||||
|
<Text style={{ fontSize: '16px', lineHeight: '1.6' }}>
|
||||||
|
{t('update-description')}
|
||||||
|
</Text>
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<Text style={{ fontSize: '14px', color: '#8c8c8c' }}>
|
||||||
|
{t('update-instruction')}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
background: '#f6ffed',
|
||||||
|
border: '1px solid #b7eb8f',
|
||||||
|
borderRadius: '6px',
|
||||||
|
padding: '12px',
|
||||||
|
marginBottom: '20px'
|
||||||
|
}}>
|
||||||
|
<Text style={{ fontSize: '13px', color: '#389e0d' }}>
|
||||||
|
{t('update-whats-new', {
|
||||||
|
interpolation: { escapeValue: false }
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Space
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
justifyContent: 'flex-end'
|
||||||
|
}}
|
||||||
|
size="middle"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
icon={<CloseOutlined />}
|
||||||
|
onClick={handleLater}
|
||||||
|
disabled={isUpdating}
|
||||||
|
>
|
||||||
|
{t('update-later')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
loading={isUpdating}
|
||||||
|
onClick={handleUpdate}
|
||||||
|
>
|
||||||
|
{isUpdating ? t('updating') : t('update-now')}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UpdateNotification;
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// Update Notification Provider
|
||||||
|
// Provides global update notification management
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useUpdateChecker } from '../../hooks/useUpdateChecker';
|
||||||
|
import UpdateNotification from './UpdateNotification';
|
||||||
|
|
||||||
|
interface UpdateNotificationProviderProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
checkInterval?: number;
|
||||||
|
enableAutoCheck?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UpdateNotificationProvider: React.FC<UpdateNotificationProviderProps> = ({
|
||||||
|
children,
|
||||||
|
checkInterval = 5 * 60 * 1000, // 5 minutes
|
||||||
|
enableAutoCheck = true
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
showUpdateNotification,
|
||||||
|
setShowUpdateNotification,
|
||||||
|
dismissUpdate
|
||||||
|
} = useUpdateChecker({
|
||||||
|
checkInterval,
|
||||||
|
enableAutoCheck,
|
||||||
|
showNotificationOnUpdate: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
dismissUpdate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdate = () => {
|
||||||
|
// The hardReload function in UpdateNotification will handle the actual update
|
||||||
|
setShowUpdateNotification(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{children}
|
||||||
|
<UpdateNotification
|
||||||
|
visible={showUpdateNotification}
|
||||||
|
onClose={handleClose}
|
||||||
|
onUpdate={handleUpdate}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UpdateNotificationProvider;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as UpdateNotification } from './UpdateNotification';
|
||||||
|
export { default as UpdateNotificationProvider } from './UpdateNotificationProvider';
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
import { ActivityFeedType } from '@/types/home/user-activity.types';
|
||||||
|
|
||||||
|
interface ActivityItem {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
description: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserActivityState {
|
||||||
|
activeTab: ActivityFeedType;
|
||||||
|
activities: ActivityItem[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: UserActivityState = {
|
||||||
|
activeTab: ActivityFeedType.TIME_LOGGED_TASKS,
|
||||||
|
activities: [],
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const userActivitySlice = createSlice({
|
||||||
|
name: 'userActivity',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
setActiveTab(state, action: PayloadAction<ActivityFeedType>) {
|
||||||
|
state.activeTab = action.payload;
|
||||||
|
},
|
||||||
|
fetchActivitiesStart(state) {
|
||||||
|
state.loading = true;
|
||||||
|
state.error = null;
|
||||||
|
},
|
||||||
|
fetchActivitiesSuccess(state, action: PayloadAction<ActivityItem[]>) {
|
||||||
|
state.activities = action.payload;
|
||||||
|
state.loading = false;
|
||||||
|
state.error = null;
|
||||||
|
},
|
||||||
|
fetchActivitiesFailure(state, action: PayloadAction<string>) {
|
||||||
|
state.loading = false;
|
||||||
|
state.error = action.payload;
|
||||||
|
},
|
||||||
|
clearActivities(state) {
|
||||||
|
state.activities = [];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const {
|
||||||
|
setActiveTab,
|
||||||
|
fetchActivitiesStart,
|
||||||
|
fetchActivitiesSuccess,
|
||||||
|
fetchActivitiesFailure,
|
||||||
|
clearActivities,
|
||||||
|
} = userActivitySlice.actions;
|
||||||
|
|
||||||
|
export default userActivitySlice.reducer;
|
||||||
@@ -153,8 +153,8 @@ const Navbar = () => {
|
|||||||
<Flex align="center">
|
<Flex align="center">
|
||||||
<SwitchTeamButton />
|
<SwitchTeamButton />
|
||||||
<NotificationButton />
|
<NotificationButton />
|
||||||
{/* <TimerButton /> */}
|
<TimerButton />
|
||||||
<HelpButton />
|
{/* <HelpButton /> */}
|
||||||
<ProfileButton isOwnerOrAdmin={isOwnerOrAdmin} />
|
<ProfileButton isOwnerOrAdmin={isOwnerOrAdmin} />
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useAppDispatch } from '@/hooks/useAppDispatch';
|
|||||||
import { useSocket } from '@/socket/socketContext';
|
import { useSocket } from '@/socket/socketContext';
|
||||||
import { SocketEvents } from '@/shared/socket-events';
|
import { SocketEvents } from '@/shared/socket-events';
|
||||||
import { updateTaskTimeTracking } from '@/features/tasks/tasks.slice';
|
import { updateTaskTimeTracking } from '@/features/tasks/tasks.slice';
|
||||||
import moment from 'moment';
|
import { format, differenceInSeconds, isValid, parseISO } from 'date-fns';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
const { useToken } = theme;
|
const { useToken } = theme;
|
||||||
@@ -60,17 +60,17 @@ const TimerButton = () => {
|
|||||||
try {
|
try {
|
||||||
if (!timer || !timer.task_id || !timer.start_time) return;
|
if (!timer || !timer.task_id || !timer.start_time) return;
|
||||||
|
|
||||||
const startTime = moment(timer.start_time);
|
const startTime = parseISO(timer.start_time);
|
||||||
if (!startTime.isValid()) {
|
if (!isValid(startTime)) {
|
||||||
logError(`Invalid start time for timer ${timer.task_id}: ${timer.start_time}`);
|
logError(`Invalid start time for timer ${timer.task_id}: ${timer.start_time}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = moment();
|
const now = new Date();
|
||||||
const duration = moment.duration(now.diff(startTime));
|
const totalSeconds = differenceInSeconds(now, startTime);
|
||||||
const hours = Math.floor(duration.asHours());
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
const minutes = duration.minutes();
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
const seconds = duration.seconds();
|
const seconds = totalSeconds % 60;
|
||||||
newTimes[timer.task_id] =
|
newTimes[timer.task_id] =
|
||||||
`${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
`${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -86,12 +86,7 @@ const TimerButton = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchRunningTimers();
|
fetchRunningTimers();
|
||||||
|
|
||||||
// Set up polling to refresh timers every 30 seconds
|
// Removed periodic polling - rely on socket events for real-time updates
|
||||||
const pollInterval = setInterval(() => {
|
|
||||||
fetchRunningTimers();
|
|
||||||
}, 30000);
|
|
||||||
|
|
||||||
return () => clearInterval(pollInterval);
|
|
||||||
}, [fetchRunningTimers]);
|
}, [fetchRunningTimers]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -273,7 +268,7 @@ const TimerButton = () => {
|
|||||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||||
Started:{' '}
|
Started:{' '}
|
||||||
{timer.start_time
|
{timer.start_time
|
||||||
? moment(timer.start_time).format('HH:mm')
|
? format(parseISO(timer.start_time), 'HH:mm')
|
||||||
: '--:--'}
|
: '--:--'}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Drawer, Typography, Flex, Button, Dropdown } from '@/shared/antd-imports';
|
import { Drawer, Typography, Flex, Button, Dropdown } from '@/shared/antd-imports';
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useCallback } from 'react';
|
||||||
import { useAppSelector } from '../../../../hooks/useAppSelector';
|
import { useAppSelector } from '../../../../hooks/useAppSelector';
|
||||||
import { useAppDispatch } from '../../../../hooks/useAppDispatch';
|
import { useAppDispatch } from '../../../../hooks/useAppDispatch';
|
||||||
import { setSelectedProject, toggleProjectReportsDrawer } from '../project-reports-slice';
|
import { setSelectedProject, toggleProjectReportsDrawer } from '../project-reports-slice';
|
||||||
@@ -8,6 +8,8 @@ import ProjectReportsDrawerTabs from './ProjectReportsDrawerTabs';
|
|||||||
import { colors } from '../../../../styles/colors';
|
import { colors } from '../../../../styles/colors';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { IRPTProject } from '@/types/reporting/reporting.types';
|
import { IRPTProject } from '@/types/reporting/reporting.types';
|
||||||
|
import { useAuthService } from '../../../../hooks/useAuth';
|
||||||
|
import { reportingExportApiService } from '@/api/reporting/reporting-export.api.service';
|
||||||
|
|
||||||
type ProjectReportsDrawerProps = {
|
type ProjectReportsDrawerProps = {
|
||||||
selectedProject: IRPTProject | null;
|
selectedProject: IRPTProject | null;
|
||||||
@@ -17,6 +19,8 @@ const ProjectReportsDrawer = ({ selectedProject }: ProjectReportsDrawerProps) =>
|
|||||||
const { t } = useTranslation('reporting-projects-drawer');
|
const { t } = useTranslation('reporting-projects-drawer');
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
const currentSession = useAuthService().getCurrentSession();
|
||||||
|
const [exporting, setExporting] = useState<boolean>(false);
|
||||||
|
|
||||||
// get drawer open state and project list from the reducer
|
// get drawer open state and project list from the reducer
|
||||||
const isDrawerOpen = useAppSelector(
|
const isDrawerOpen = useAppSelector(
|
||||||
@@ -35,6 +39,54 @@ const ProjectReportsDrawer = ({ selectedProject }: ProjectReportsDrawerProps) =>
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Export handlers
|
||||||
|
const handleExportMembers = useCallback(() => {
|
||||||
|
if (!selectedProject?.id) return;
|
||||||
|
try {
|
||||||
|
setExporting(true);
|
||||||
|
const teamName = currentSession?.team_name || 'Team';
|
||||||
|
reportingExportApiService.exportProjectMembers(
|
||||||
|
selectedProject.id,
|
||||||
|
selectedProject.name,
|
||||||
|
teamName
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error exporting project members:', error);
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
}, [selectedProject, currentSession?.team_name]);
|
||||||
|
|
||||||
|
const handleExportTasks = useCallback(() => {
|
||||||
|
if (!selectedProject?.id) return;
|
||||||
|
try {
|
||||||
|
setExporting(true);
|
||||||
|
const teamName = currentSession?.team_name || 'Team';
|
||||||
|
reportingExportApiService.exportProjectTasks(
|
||||||
|
selectedProject.id,
|
||||||
|
selectedProject.name,
|
||||||
|
teamName
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error exporting project tasks:', error);
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
}, [selectedProject, currentSession?.team_name]);
|
||||||
|
|
||||||
|
const handleExportClick = useCallback((key: string) => {
|
||||||
|
switch (key) {
|
||||||
|
case '1':
|
||||||
|
handleExportMembers();
|
||||||
|
break;
|
||||||
|
case '2':
|
||||||
|
handleExportTasks();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}, [handleExportMembers, handleExportTasks]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer
|
<Drawer
|
||||||
open={isDrawerOpen}
|
open={isDrawerOpen}
|
||||||
@@ -56,9 +108,15 @@ const ProjectReportsDrawer = ({ selectedProject }: ProjectReportsDrawerProps) =>
|
|||||||
{ key: '1', label: t('membersButton') },
|
{ key: '1', label: t('membersButton') },
|
||||||
{ key: '2', label: t('tasksButton') },
|
{ key: '2', label: t('tasksButton') },
|
||||||
],
|
],
|
||||||
|
onClick: ({ key }) => handleExportClick(key),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Button type="primary" icon={<DownOutlined />} iconPosition="end">
|
<Button
|
||||||
|
type="primary"
|
||||||
|
loading={exporting}
|
||||||
|
icon={<DownOutlined />}
|
||||||
|
iconPosition="end"
|
||||||
|
>
|
||||||
{t('exportButton')}
|
{t('exportButton')}
|
||||||
</Button>
|
</Button>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
|
|||||||
@@ -680,8 +680,23 @@ const taskManagementSlice = createSlice({
|
|||||||
const group = state.groups.find(g => g.id === sourceGroupId);
|
const group = state.groups.find(g => g.id === sourceGroupId);
|
||||||
if (group) {
|
if (group) {
|
||||||
const newTasks = Array.from(group.taskIds);
|
const newTasks = Array.from(group.taskIds);
|
||||||
const [removed] = newTasks.splice(newTasks.indexOf(sourceTaskId), 1);
|
const sourceIndex = newTasks.indexOf(sourceTaskId);
|
||||||
newTasks.splice(newTasks.indexOf(destinationTaskId), 0, removed);
|
const destinationIndex = newTasks.indexOf(destinationTaskId);
|
||||||
|
|
||||||
|
// Remove the task from its current position
|
||||||
|
const [removed] = newTasks.splice(sourceIndex, 1);
|
||||||
|
|
||||||
|
// Calculate the insertion index
|
||||||
|
let insertIndex = destinationIndex;
|
||||||
|
if (sourceIndex < destinationIndex) {
|
||||||
|
// When dragging down, we need to insert after the destination
|
||||||
|
insertIndex = destinationIndex;
|
||||||
|
} else {
|
||||||
|
// When dragging up, we insert before the destination
|
||||||
|
insertIndex = destinationIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
newTasks.splice(insertIndex, 0, removed);
|
||||||
group.taskIds = newTasks;
|
group.taskIds = newTasks;
|
||||||
|
|
||||||
// Update order for affected tasks using the appropriate sort field
|
// Update order for affected tasks using the appropriate sort field
|
||||||
|
|||||||
@@ -244,44 +244,18 @@ export const useTaskSocketHandlers = () => {
|
|||||||
// Find current group containing the task
|
// Find current group containing the task
|
||||||
const currentGroup = groups.find(group => group.taskIds.includes(response.id));
|
const currentGroup = groups.find(group => group.taskIds.includes(response.id));
|
||||||
|
|
||||||
// Find target group based on new status value with multiple matching strategies
|
// Find target group based on the actual status ID from response
|
||||||
let targetGroup = groups.find(group => group.groupValue === newStatusValue);
|
let targetGroup = groups.find(group => group.id === response.status_id);
|
||||||
|
|
||||||
// If not found, try case-insensitive matching
|
// If not found by status ID, try matching with group value
|
||||||
if (!targetGroup) {
|
if (!targetGroup) {
|
||||||
targetGroup = groups.find(group =>
|
targetGroup = groups.find(group => group.groupValue === response.status_id);
|
||||||
group.groupValue?.toLowerCase() === newStatusValue.toLowerCase()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If still not found, try matching with title
|
// If still not found, try matching by status name (fallback)
|
||||||
if (!targetGroup) {
|
if (!targetGroup && response.status) {
|
||||||
targetGroup = groups.find(group =>
|
targetGroup = groups.find(group =>
|
||||||
group.title?.toLowerCase() === newStatusValue.toLowerCase()
|
group.title?.toLowerCase() === response.status.toLowerCase()
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If still not found, try matching common status patterns
|
|
||||||
if (!targetGroup && newStatusValue === 'todo') {
|
|
||||||
targetGroup = groups.find(group =>
|
|
||||||
group.title?.toLowerCase().includes('todo') ||
|
|
||||||
group.title?.toLowerCase().includes('to do') ||
|
|
||||||
group.title?.toLowerCase().includes('pending') ||
|
|
||||||
group.groupValue?.toLowerCase().includes('todo')
|
|
||||||
);
|
|
||||||
} else if (!targetGroup && newStatusValue === 'doing') {
|
|
||||||
targetGroup = groups.find(group =>
|
|
||||||
group.title?.toLowerCase().includes('doing') ||
|
|
||||||
group.title?.toLowerCase().includes('progress') ||
|
|
||||||
group.title?.toLowerCase().includes('active') ||
|
|
||||||
group.groupValue?.toLowerCase().includes('doing')
|
|
||||||
);
|
|
||||||
} else if (!targetGroup && newStatusValue === 'done') {
|
|
||||||
targetGroup = groups.find(group =>
|
|
||||||
group.title?.toLowerCase().includes('done') ||
|
|
||||||
group.title?.toLowerCase().includes('complete') ||
|
|
||||||
group.title?.toLowerCase().includes('finish') ||
|
|
||||||
group.groupValue?.toLowerCase().includes('done')
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,14 +269,11 @@ export const useTaskSocketHandlers = () => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
} else if (!targetGroup) {
|
} else if (!targetGroup) {
|
||||||
console.log('❌ Target group not found for status:', newStatusValue);
|
// Fallback: refetch tasks to ensure consistency
|
||||||
} else if (!currentGroup) {
|
if (projectId) {
|
||||||
console.log('❌ Current group not found for task:', response.id);
|
dispatch(fetchTasksV3(projectId));
|
||||||
} else {
|
}
|
||||||
console.log('🔧 No group movement needed - task already in correct group');
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
console.log('🔧 Not grouped by status, skipping group movement');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
94
worklenz-frontend/src/hooks/useTaskTimerWithConflictCheck.ts
Normal file
94
worklenz-frontend/src/hooks/useTaskTimerWithConflictCheck.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Modal } from 'antd';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useSocket } from '@/socket/socketContext';
|
||||||
|
import { SocketEvents } from '@/shared/socket-events';
|
||||||
|
import { taskTimeLogsApiService } from '@/api/tasks/task-time-logs.api.service';
|
||||||
|
import { useTaskTimer } from './useTaskTimer';
|
||||||
|
|
||||||
|
interface ConflictingTimer {
|
||||||
|
task_id: string;
|
||||||
|
task_name: string;
|
||||||
|
project_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTaskTimerWithConflictCheck = (taskId: string, timerStartTime: string | null) => {
|
||||||
|
const { socket } = useSocket();
|
||||||
|
const { t: tTable } = useTranslation('task-list-table');
|
||||||
|
const { t: tCommon } = useTranslation('common');
|
||||||
|
|
||||||
|
// Ensure timerStartTime is a number or null, as required by useTaskTimer
|
||||||
|
const parsedTimerStartTime = typeof timerStartTime === 'string' && timerStartTime !== null
|
||||||
|
? Number(timerStartTime)
|
||||||
|
: timerStartTime;
|
||||||
|
|
||||||
|
const originalHook = useTaskTimer(taskId, parsedTimerStartTime as number | null);
|
||||||
|
const [isCheckingConflict, setIsCheckingConflict] = useState(false);
|
||||||
|
|
||||||
|
const checkForConflictingTimers = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await taskTimeLogsApiService.getRunningTimers();
|
||||||
|
const runningTimers = response.body || [];
|
||||||
|
|
||||||
|
// Find conflicting timer (running timer for a different task)
|
||||||
|
const conflictingTimer = runningTimers.find((timer: ConflictingTimer) =>
|
||||||
|
timer.task_id !== taskId
|
||||||
|
);
|
||||||
|
|
||||||
|
return conflictingTimer;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking for conflicting timers:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [taskId]);
|
||||||
|
|
||||||
|
const handleStartTimerWithConflictCheck = useCallback(async () => {
|
||||||
|
if (isCheckingConflict) return;
|
||||||
|
|
||||||
|
setIsCheckingConflict(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const conflictingTimer = await checkForConflictingTimers();
|
||||||
|
|
||||||
|
if (conflictingTimer) {
|
||||||
|
Modal.confirm({
|
||||||
|
title: tTable('timer.conflictTitle'),
|
||||||
|
content: tTable('timer.conflictMessage', {
|
||||||
|
taskName: conflictingTimer.task_name,
|
||||||
|
projectName: conflictingTimer.project_name
|
||||||
|
}),
|
||||||
|
okText: tTable('timer.stopAndStart'),
|
||||||
|
cancelText: tCommon('cancel'),
|
||||||
|
onOk: () => {
|
||||||
|
// Stop the conflicting timer
|
||||||
|
if (socket) {
|
||||||
|
socket.emit(SocketEvents.TASK_TIMER_STOP.toString(), JSON.stringify({
|
||||||
|
task_id: conflictingTimer.task_id
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the new timer after a short delay
|
||||||
|
setTimeout(() => {
|
||||||
|
originalHook.handleStartTimer();
|
||||||
|
}, 100);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// No conflict, start timer directly
|
||||||
|
originalHook.handleStartTimer();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error handling timer start with conflict check:', error);
|
||||||
|
// Fallback to original behavior
|
||||||
|
originalHook.handleStartTimer();
|
||||||
|
} finally {
|
||||||
|
setIsCheckingConflict(false);
|
||||||
|
}
|
||||||
|
}, [isCheckingConflict, checkForConflictingTimers, tTable, tCommon, socket, originalHook]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...originalHook,
|
||||||
|
handleStartTimer: handleStartTimerWithConflictCheck,
|
||||||
|
isCheckingConflict,
|
||||||
|
};
|
||||||
|
};
|
||||||
141
worklenz-frontend/src/hooks/useUpdateChecker.ts
Normal file
141
worklenz-frontend/src/hooks/useUpdateChecker.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
// Update Checker Hook
|
||||||
|
// Periodically checks for app updates and manages update notifications
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useServiceWorker } from '../utils/serviceWorkerRegistration';
|
||||||
|
|
||||||
|
interface UseUpdateCheckerOptions {
|
||||||
|
checkInterval?: number; // Check interval in milliseconds (default: 5 minutes)
|
||||||
|
enableAutoCheck?: boolean; // Enable automatic checking (default: true)
|
||||||
|
showNotificationOnUpdate?: boolean; // Show notification when update is found (default: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseUpdateCheckerReturn {
|
||||||
|
hasUpdate: boolean;
|
||||||
|
isChecking: boolean;
|
||||||
|
lastChecked: Date | null;
|
||||||
|
checkForUpdates: () => Promise<void>;
|
||||||
|
dismissUpdate: () => void;
|
||||||
|
showUpdateNotification: boolean;
|
||||||
|
setShowUpdateNotification: (show: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateChecker(options: UseUpdateCheckerOptions = {}): UseUpdateCheckerReturn {
|
||||||
|
const {
|
||||||
|
checkInterval = 5 * 60 * 1000, // 5 minutes
|
||||||
|
enableAutoCheck = true,
|
||||||
|
showNotificationOnUpdate = true
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const { checkForUpdates: serviceWorkerCheckUpdates, swManager } = useServiceWorker();
|
||||||
|
|
||||||
|
const [hasUpdate, setHasUpdate] = React.useState(false);
|
||||||
|
const [isChecking, setIsChecking] = React.useState(false);
|
||||||
|
const [lastChecked, setLastChecked] = React.useState<Date | null>(null);
|
||||||
|
const [showUpdateNotification, setShowUpdateNotification] = React.useState(false);
|
||||||
|
const [updateDismissed, setUpdateDismissed] = React.useState(false);
|
||||||
|
|
||||||
|
// Check for updates function
|
||||||
|
const checkForUpdates = React.useCallback(async () => {
|
||||||
|
if (!serviceWorkerCheckUpdates || isChecking) return;
|
||||||
|
|
||||||
|
setIsChecking(true);
|
||||||
|
try {
|
||||||
|
const hasUpdates = await serviceWorkerCheckUpdates();
|
||||||
|
setHasUpdate(hasUpdates);
|
||||||
|
setLastChecked(new Date());
|
||||||
|
|
||||||
|
// Show notification if update found and user hasn't dismissed it
|
||||||
|
if (hasUpdates && showNotificationOnUpdate && !updateDismissed) {
|
||||||
|
setShowUpdateNotification(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Update check completed:', { hasUpdates });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking for updates:', error);
|
||||||
|
} finally {
|
||||||
|
setIsChecking(false);
|
||||||
|
}
|
||||||
|
}, [serviceWorkerCheckUpdates, isChecking, showNotificationOnUpdate, updateDismissed]);
|
||||||
|
|
||||||
|
// Dismiss update notification
|
||||||
|
const dismissUpdate = React.useCallback(() => {
|
||||||
|
setUpdateDismissed(true);
|
||||||
|
setShowUpdateNotification(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Set up automatic checking interval
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!enableAutoCheck || !swManager) return;
|
||||||
|
|
||||||
|
// Initial check after a short delay
|
||||||
|
const initialTimeout = setTimeout(() => {
|
||||||
|
checkForUpdates();
|
||||||
|
}, 10000); // 10 seconds after component mount
|
||||||
|
|
||||||
|
// Set up interval for periodic checks
|
||||||
|
const intervalId = setInterval(() => {
|
||||||
|
checkForUpdates();
|
||||||
|
}, checkInterval);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(initialTimeout);
|
||||||
|
clearInterval(intervalId);
|
||||||
|
};
|
||||||
|
}, [enableAutoCheck, swManager, checkInterval, checkForUpdates]);
|
||||||
|
|
||||||
|
// Listen for visibility change to check for updates when user returns to tab
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!enableAutoCheck) return;
|
||||||
|
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (!document.hidden && swManager) {
|
||||||
|
// Check for updates when user returns to the tab
|
||||||
|
setTimeout(() => {
|
||||||
|
checkForUpdates();
|
||||||
|
}, 2000); // 2 second delay
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
};
|
||||||
|
}, [enableAutoCheck, swManager, checkForUpdates]);
|
||||||
|
|
||||||
|
// Listen for focus events to check for updates
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!enableAutoCheck) return;
|
||||||
|
|
||||||
|
const handleFocus = () => {
|
||||||
|
if (swManager && !isChecking) {
|
||||||
|
// Check for updates when window regains focus
|
||||||
|
setTimeout(() => {
|
||||||
|
checkForUpdates();
|
||||||
|
}, 1000); // 1 second delay
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('focus', handleFocus);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('focus', handleFocus);
|
||||||
|
};
|
||||||
|
}, [enableAutoCheck, swManager, isChecking, checkForUpdates]);
|
||||||
|
|
||||||
|
// Reset dismissed state when new update is found
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (hasUpdate && updateDismissed) {
|
||||||
|
setUpdateDismissed(false);
|
||||||
|
}
|
||||||
|
}, [hasUpdate, updateDismissed]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasUpdate,
|
||||||
|
isChecking,
|
||||||
|
lastChecked,
|
||||||
|
checkForUpdates,
|
||||||
|
dismissUpdate,
|
||||||
|
showUpdateNotification,
|
||||||
|
setShowUpdateNotification
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,62 +1,18 @@
|
|||||||
import { Col, ConfigProvider, Layout } from '@/shared/antd-imports';
|
import { ConfigProvider, Layout } from '@/shared/antd-imports';
|
||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet, useLocation } from 'react-router-dom';
|
||||||
import { memo, useMemo, useEffect, useRef } from 'react';
|
import { memo, useMemo } from 'react';
|
||||||
import { useMediaQuery } from 'react-responsive';
|
|
||||||
|
|
||||||
import Navbar from '../features/navbar/navbar';
|
import Navbar from '../features/navbar/navbar';
|
||||||
import { useAppSelector } from '../hooks/useAppSelector';
|
import { useAppSelector } from '../hooks/useAppSelector';
|
||||||
import { colors } from '../styles/colors';
|
import { colors } from '../styles/colors';
|
||||||
|
|
||||||
import { useRenderPerformance } from '@/utils/performance';
|
|
||||||
import { DynamicCSSLoader, LayoutStabilizer } from '@/utils/css-optimizations';
|
|
||||||
|
|
||||||
const MainLayout = memo(() => {
|
const MainLayout = memo(() => {
|
||||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||||
const isDesktop = useMediaQuery({ query: '(min-width: 1024px)' });
|
const location = useLocation();
|
||||||
const layoutRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// Performance monitoring in development
|
const isProjectView = location.pathname.includes('/projects/') &&
|
||||||
useRenderPerformance('MainLayout');
|
!location.pathname.endsWith('/projects');
|
||||||
|
|
||||||
// Apply layout optimizations
|
|
||||||
useEffect(() => {
|
|
||||||
if (layoutRef.current) {
|
|
||||||
// Prevent layout shifts in main content area
|
|
||||||
LayoutStabilizer.applyContainment(layoutRef.current, 'layout');
|
|
||||||
|
|
||||||
// Load non-critical CSS dynamically
|
|
||||||
DynamicCSSLoader.loadCSS('/styles/non-critical.css', {
|
|
||||||
priority: 'low',
|
|
||||||
media: 'all'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Memoize styles to prevent object recreation on every render
|
|
||||||
const headerStyles = useMemo(
|
|
||||||
() => ({
|
|
||||||
zIndex: 999,
|
|
||||||
position: 'fixed' as const,
|
|
||||||
width: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
padding: 0,
|
|
||||||
borderBottom: themeMode === 'dark' ? '1px solid #303030' : 'none',
|
|
||||||
}),
|
|
||||||
[themeMode]
|
|
||||||
);
|
|
||||||
|
|
||||||
const contentStyles = useMemo(
|
|
||||||
() => ({
|
|
||||||
paddingInline: isDesktop ? 64 : 24,
|
|
||||||
overflowX: 'hidden' as const,
|
|
||||||
}),
|
|
||||||
[isDesktop]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Memoize theme configuration
|
|
||||||
const themeConfig = useMemo(
|
const themeConfig = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
components: {
|
components: {
|
||||||
@@ -69,23 +25,19 @@ const MainLayout = memo(() => {
|
|||||||
[themeMode]
|
[themeMode]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Memoize header className
|
|
||||||
const headerClassName = useMemo(
|
|
||||||
() => `shadow-md ${themeMode === 'dark' ? '' : 'shadow-[#18181811]'}`,
|
|
||||||
[themeMode]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfigProvider theme={themeConfig}>
|
<ConfigProvider theme={themeConfig}>
|
||||||
<Layout ref={layoutRef} style={{ minHeight: '100vh' }} className="prevent-layout-shift">
|
<Layout className="min-h-screen">
|
||||||
<Layout.Header className={`${headerClassName} gpu-accelerated`} style={headerStyles}>
|
<Layout.Header
|
||||||
|
className={`sticky top-0 z-[999] flex items-center p-0 shadow-md ${
|
||||||
|
themeMode === 'dark' ? 'border-b border-[#303030]' : 'shadow-[#18181811]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<Navbar />
|
<Navbar />
|
||||||
</Layout.Header>
|
</Layout.Header>
|
||||||
|
|
||||||
<Layout.Content className="layout-contained">
|
<Layout.Content className={`px-4 sm:px-8 lg:px-12 xl:px-16 ${!isProjectView ? 'overflow-x-hidden max-w-[1400px]' : ''} mx-auto w-full`}>
|
||||||
<Col xxl={{ span: 18, offset: 3, flex: '100%' }} style={contentStyles} className="task-content-container">
|
<Outlet />
|
||||||
<Outlet />
|
|
||||||
</Col>
|
|
||||||
</Layout.Content>
|
</Layout.Content>
|
||||||
</Layout>
|
</Layout>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ const ProjectViewMembers = React.lazy(
|
|||||||
const ProjectViewUpdates = React.lazy(
|
const ProjectViewUpdates = React.lazy(
|
||||||
() => import('@/pages/projects/project-view-1/updates/project-view-updates')
|
() => import('@/pages/projects/project-view-1/updates/project-view-updates')
|
||||||
);
|
);
|
||||||
|
const ProjectViewGantt = React.lazy(
|
||||||
|
() => import('@/pages/projects/projectView/gantt/ProjectViewGantt')
|
||||||
|
);
|
||||||
|
|
||||||
// type of a tab items
|
// type of a tab items
|
||||||
type TabItems = {
|
type TabItems = {
|
||||||
@@ -43,6 +46,7 @@ const getTabLabel = (key: string): string => {
|
|||||||
files: 'Files',
|
files: 'Files',
|
||||||
members: 'Members',
|
members: 'Members',
|
||||||
updates: 'Updates',
|
updates: 'Updates',
|
||||||
|
gantt: 'Gantt Chart',
|
||||||
};
|
};
|
||||||
return fallbacks[key] || key;
|
return fallbacks[key] || key;
|
||||||
}
|
}
|
||||||
@@ -117,6 +121,16 @@ export const tabItems: TabItems[] = [
|
|||||||
React.createElement(ProjectViewUpdates)
|
React.createElement(ProjectViewUpdates)
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
index: 6,
|
||||||
|
key: 'gantt',
|
||||||
|
label: getTabLabel('gantt'),
|
||||||
|
element: React.createElement(
|
||||||
|
Suspense,
|
||||||
|
{ fallback: React.createElement(InlineSuspenseFallback) },
|
||||||
|
React.createElement(ProjectViewGantt)
|
||||||
|
),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Function to update tab labels when language changes
|
// Function to update tab labels when language changes
|
||||||
@@ -142,6 +156,9 @@ export const updateTabLabels = () => {
|
|||||||
case 'updates':
|
case 'updates':
|
||||||
item.label = getTabLabel('updates');
|
item.label = getTabLabel('updates');
|
||||||
break;
|
break;
|
||||||
|
case 'gantt':
|
||||||
|
item.label = getTabLabel('gantt');
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import { Provider } from 'react-redux';
|
||||||
|
import { configureStore } from '@reduxjs/toolkit';
|
||||||
|
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { I18nextProvider } from 'react-i18next';
|
||||||
|
import i18n from 'i18next';
|
||||||
|
|
||||||
|
import AuthenticatingPage from '../AuthenticatingPage';
|
||||||
|
import { verifyAuthentication } from '@/features/auth/authSlice';
|
||||||
|
import { setUser } from '@/features/user/userSlice';
|
||||||
|
import { setSession } from '@/utils/session-helper';
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
vi.mock('@/features/auth/authSlice', () => ({
|
||||||
|
verifyAuthentication: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/features/user/userSlice', () => ({
|
||||||
|
setUser: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/utils/session-helper', () => ({
|
||||||
|
setSession: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/utils/errorLogger', () => ({
|
||||||
|
default: {
|
||||||
|
error: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/shared/constants', () => ({
|
||||||
|
WORKLENZ_REDIRECT_PROJ_KEY: 'worklenz_redirect_proj',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock navigation
|
||||||
|
const mockNavigate = vi.fn();
|
||||||
|
vi.mock('react-router-dom', async () => {
|
||||||
|
const actual = await vi.importActual('react-router-dom');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useNavigate: () => mockNavigate,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock dispatch
|
||||||
|
const mockDispatch = vi.fn();
|
||||||
|
vi.mock('@/hooks/useAppDispatch', () => ({
|
||||||
|
useAppDispatch: () => mockDispatch,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Setup i18n for testing
|
||||||
|
i18n.init({
|
||||||
|
lng: 'en',
|
||||||
|
resources: {
|
||||||
|
en: {
|
||||||
|
'auth/auth-common': {
|
||||||
|
authenticating: 'Authenticating...',
|
||||||
|
gettingThingsReady: 'Getting things ready for you...',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create test store
|
||||||
|
const createTestStore = () => {
|
||||||
|
return configureStore({
|
||||||
|
reducer: {
|
||||||
|
auth: (state = {}) => state,
|
||||||
|
user: (state = {}) => state,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderWithProviders = (component: React.ReactElement) => {
|
||||||
|
const store = createTestStore();
|
||||||
|
return render(
|
||||||
|
<Provider store={store}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<I18nextProvider i18n={i18n}>
|
||||||
|
{component}
|
||||||
|
</I18nextProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('AuthenticatingPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
localStorage.clear();
|
||||||
|
// Mock window.location
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
value: { href: '' },
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders loading state correctly', () => {
|
||||||
|
renderWithProviders(<AuthenticatingPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Authenticating...')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Getting things ready for you...')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('generic', { busy: true })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects to login when authentication fails', async () => {
|
||||||
|
mockDispatch.mockReturnValue({
|
||||||
|
unwrap: vi.fn().mockResolvedValue({ authenticated: false }),
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(<AuthenticatingPage />);
|
||||||
|
|
||||||
|
// Run all pending timers
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
expect(mockNavigate).toHaveBeenCalledWith('/auth/login');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects to setup when user setup is not completed', async () => {
|
||||||
|
const mockUser = {
|
||||||
|
id: '1',
|
||||||
|
email: 'test@example.com',
|
||||||
|
setup_completed: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockDispatch.mockReturnValue({
|
||||||
|
unwrap: vi.fn().mockResolvedValue({
|
||||||
|
authenticated: true,
|
||||||
|
user: mockUser,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(<AuthenticatingPage />);
|
||||||
|
|
||||||
|
// Run all pending timers
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
expect(setSession).toHaveBeenCalledWith(mockUser);
|
||||||
|
expect(setUser).toHaveBeenCalledWith(mockUser);
|
||||||
|
expect(mockNavigate).toHaveBeenCalledWith('/worklenz/setup');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects to home after successful authentication', async () => {
|
||||||
|
const mockUser = {
|
||||||
|
id: '1',
|
||||||
|
email: 'test@example.com',
|
||||||
|
setup_completed: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockDispatch.mockReturnValue({
|
||||||
|
unwrap: vi.fn().mockResolvedValue({
|
||||||
|
authenticated: true,
|
||||||
|
user: mockUser,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(<AuthenticatingPage />);
|
||||||
|
|
||||||
|
// Run all pending timers
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
expect(setSession).toHaveBeenCalledWith(mockUser);
|
||||||
|
expect(setUser).toHaveBeenCalledWith(mockUser);
|
||||||
|
expect(mockNavigate).toHaveBeenCalledWith('/worklenz/home');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects to project when redirect key is present in localStorage', async () => {
|
||||||
|
const projectId = 'test-project-123';
|
||||||
|
localStorage.setItem('worklenz_redirect_proj', projectId);
|
||||||
|
|
||||||
|
const mockUser = {
|
||||||
|
id: '1',
|
||||||
|
email: 'test@example.com',
|
||||||
|
setup_completed: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockDispatch.mockReturnValue({
|
||||||
|
unwrap: vi.fn().mockResolvedValue({
|
||||||
|
authenticated: true,
|
||||||
|
user: mockUser,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock window.location with a proper setter
|
||||||
|
let hrefValue = '';
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
value: {
|
||||||
|
get href() {
|
||||||
|
return hrefValue;
|
||||||
|
},
|
||||||
|
set href(value) {
|
||||||
|
hrefValue = value;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(<AuthenticatingPage />);
|
||||||
|
|
||||||
|
// Run all pending timers
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
expect(setSession).toHaveBeenCalledWith(mockUser);
|
||||||
|
expect(setUser).toHaveBeenCalledWith(mockUser);
|
||||||
|
expect(hrefValue).toBe(`/worklenz/projects/${projectId}?tab=tasks-list`);
|
||||||
|
expect(localStorage.getItem('worklenz_redirect_proj')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles authentication errors and redirects to login', async () => {
|
||||||
|
mockDispatch.mockReturnValue({
|
||||||
|
unwrap: vi.fn().mockRejectedValue(new Error('Authentication failed')),
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(<AuthenticatingPage />);
|
||||||
|
|
||||||
|
// Run all pending timers
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
expect(mockNavigate).toHaveBeenCalledWith('/auth/login');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import { Provider } from 'react-redux';
|
||||||
|
import { configureStore } from '@reduxjs/toolkit';
|
||||||
|
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { I18nextProvider } from 'react-i18next';
|
||||||
|
import i18n from 'i18next';
|
||||||
|
|
||||||
|
import ForgotPasswordPage from '../ForgotPasswordPage';
|
||||||
|
import { resetPassword, verifyAuthentication } from '@/features/auth/authSlice';
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
vi.mock('@/features/auth/authSlice', () => ({
|
||||||
|
resetPassword: vi.fn(),
|
||||||
|
verifyAuthentication: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/features/user/userSlice', () => ({
|
||||||
|
setUser: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/utils/session-helper', () => ({
|
||||||
|
setSession: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/utils/errorLogger', () => ({
|
||||||
|
default: {
|
||||||
|
error: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/hooks/useMixpanelTracking', () => ({
|
||||||
|
useMixpanelTracking: () => ({
|
||||||
|
trackMixpanelEvent: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/hooks/useDoumentTItle', () => ({
|
||||||
|
useDocumentTitle: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('react-responsive', () => ({
|
||||||
|
useMediaQuery: () => false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock navigation
|
||||||
|
const mockNavigate = vi.fn();
|
||||||
|
vi.mock('react-router-dom', async () => {
|
||||||
|
const actual = await vi.importActual('react-router-dom');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useNavigate: () => mockNavigate,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock dispatch
|
||||||
|
const mockDispatch = vi.fn();
|
||||||
|
vi.mock('@/hooks/useAppDispatch', () => ({
|
||||||
|
useAppDispatch: () => mockDispatch,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Setup i18n for testing
|
||||||
|
i18n.init({
|
||||||
|
lng: 'en',
|
||||||
|
resources: {
|
||||||
|
en: {
|
||||||
|
'auth/forgot-password': {
|
||||||
|
headerDescription: 'Enter your email to reset your password',
|
||||||
|
emailRequired: 'Please input your email!',
|
||||||
|
emailPlaceholder: 'Enter your email',
|
||||||
|
resetPasswordButton: 'Reset Password',
|
||||||
|
returnToLoginButton: 'Return to Login',
|
||||||
|
orText: 'or',
|
||||||
|
successTitle: 'Password Reset Email Sent',
|
||||||
|
successMessage: 'Please check your email for instructions to reset your password.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create test store
|
||||||
|
const createTestStore = () => {
|
||||||
|
return configureStore({
|
||||||
|
reducer: {
|
||||||
|
auth: (state = {}) => state,
|
||||||
|
user: (state = {}) => state,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderWithProviders = (component: React.ReactElement) => {
|
||||||
|
const store = createTestStore();
|
||||||
|
return render(
|
||||||
|
<Provider store={store}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<I18nextProvider i18n={i18n}>
|
||||||
|
{component}
|
||||||
|
</I18nextProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('ForgotPasswordPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
// Mock URL search params
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
value: { search: '' },
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders forgot password form correctly', () => {
|
||||||
|
renderWithProviders(<ForgotPasswordPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Enter your email to reset your password')).toBeInTheDocument();
|
||||||
|
expect(screen.getByPlaceholderText('Enter your email')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Reset Password' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Return to Login' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('or')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates required email field', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<ForgotPasswordPage />);
|
||||||
|
|
||||||
|
const submitButton = screen.getByRole('button', { name: 'Reset Password' });
|
||||||
|
await user.click(submitButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Please input your email!')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates email format', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<ForgotPasswordPage />);
|
||||||
|
|
||||||
|
const emailInput = screen.getByPlaceholderText('Enter your email');
|
||||||
|
await user.type(emailInput, 'invalid-email');
|
||||||
|
|
||||||
|
const submitButton = screen.getByRole('button', { name: 'Reset Password' });
|
||||||
|
await user.click(submitButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Please input your email!')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('submits form with valid email', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
mockDispatch.mockReturnValue({
|
||||||
|
unwrap: vi.fn().mockResolvedValue({ done: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(<ForgotPasswordPage />);
|
||||||
|
|
||||||
|
const emailInput = screen.getByPlaceholderText('Enter your email');
|
||||||
|
await user.type(emailInput, 'test@example.com');
|
||||||
|
|
||||||
|
const submitButton = screen.getByRole('button', { name: 'Reset Password' });
|
||||||
|
await user.click(submitButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(resetPassword).toHaveBeenCalledWith('test@example.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows success message after successful submission', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
mockDispatch.mockReturnValue({
|
||||||
|
unwrap: vi.fn().mockResolvedValue({ done: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(<ForgotPasswordPage />);
|
||||||
|
|
||||||
|
const emailInput = screen.getByPlaceholderText('Enter your email');
|
||||||
|
await user.type(emailInput, 'test@example.com');
|
||||||
|
|
||||||
|
const submitButton = screen.getByRole('button', { name: 'Reset Password' });
|
||||||
|
await user.click(submitButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Password Reset Email Sent')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Please check your email for instructions to reset your password.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows loading state during submission', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
mockDispatch.mockReturnValue({
|
||||||
|
unwrap: vi.fn().mockImplementation(() => new Promise(() => {})), // Never resolves
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(<ForgotPasswordPage />);
|
||||||
|
|
||||||
|
const emailInput = screen.getByPlaceholderText('Enter your email');
|
||||||
|
await user.type(emailInput, 'test@example.com');
|
||||||
|
|
||||||
|
const submitButton = screen.getByRole('button', { name: 'Reset Password' });
|
||||||
|
await user.click(submitButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('img', { name: /loading/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles submission errors gracefully', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
mockDispatch.mockReturnValue({
|
||||||
|
unwrap: vi.fn().mockRejectedValue(new Error('Reset failed')),
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(<ForgotPasswordPage />);
|
||||||
|
|
||||||
|
const emailInput = screen.getByPlaceholderText('Enter your email');
|
||||||
|
await user.type(emailInput, 'test@example.com');
|
||||||
|
|
||||||
|
const submitButton = screen.getByRole('button', { name: 'Reset Password' });
|
||||||
|
await user.click(submitButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// Should not show success message
|
||||||
|
expect(screen.queryByText('Password Reset Email Sent')).not.toBeInTheDocument();
|
||||||
|
// Should still show the form
|
||||||
|
expect(screen.getByPlaceholderText('Enter your email')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigates to login page when return button is clicked', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<ForgotPasswordPage />);
|
||||||
|
|
||||||
|
const returnButton = screen.getByRole('button', { name: 'Return to Login' });
|
||||||
|
await user.click(returnButton);
|
||||||
|
|
||||||
|
expect(returnButton.closest('a')).toHaveAttribute('href', '/auth/login');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles team parameter from URL', () => {
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
value: { search: '?team=test-team-id' },
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(<ForgotPasswordPage />);
|
||||||
|
|
||||||
|
// Component should render normally even with team parameter
|
||||||
|
expect(screen.getByText('Enter your email to reset your password')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects authenticated users to home', async () => {
|
||||||
|
mockDispatch.mockReturnValue({
|
||||||
|
unwrap: vi.fn().mockResolvedValue({
|
||||||
|
authenticated: true,
|
||||||
|
user: { id: '1', email: 'test@example.com' },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(<ForgotPasswordPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockNavigate).toHaveBeenCalledWith('/worklenz/home');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not submit with empty email after trimming', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
mockDispatch.mockReturnValue({
|
||||||
|
unwrap: vi.fn().mockResolvedValue({ done: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(<ForgotPasswordPage />);
|
||||||
|
|
||||||
|
const emailInput = screen.getByPlaceholderText('Enter your email');
|
||||||
|
await user.type(emailInput, ' '); // Only whitespace
|
||||||
|
|
||||||
|
const submitButton = screen.getByRole('button', { name: 'Reset Password' });
|
||||||
|
await user.click(submitButton);
|
||||||
|
|
||||||
|
// Should not call resetPassword with empty string
|
||||||
|
expect(resetPassword).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { I18nextProvider } from 'react-i18next';
|
||||||
|
import i18n from 'i18next';
|
||||||
|
|
||||||
|
import LoggingOutPage from '../LoggingOutPage';
|
||||||
|
import { authApiService } from '@/api/auth/auth.api.service';
|
||||||
|
import CacheCleanup from '@/utils/cache-cleanup';
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
const mockAuthService = {
|
||||||
|
signOut: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('@/hooks/useAuth', () => ({
|
||||||
|
useAuthService: () => mockAuthService,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/api/auth/auth.api.service', () => ({
|
||||||
|
authApiService: {
|
||||||
|
logout: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/utils/cache-cleanup', () => ({
|
||||||
|
default: {
|
||||||
|
clearAllCaches: vi.fn(),
|
||||||
|
forceReload: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('react-responsive', () => ({
|
||||||
|
useMediaQuery: () => false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock navigation
|
||||||
|
const mockNavigate = vi.fn();
|
||||||
|
vi.mock('react-router-dom', async () => {
|
||||||
|
const actual = await vi.importActual('react-router-dom');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useNavigate: () => mockNavigate,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Setup i18n for testing
|
||||||
|
i18n.init({
|
||||||
|
lng: 'en',
|
||||||
|
resources: {
|
||||||
|
en: {
|
||||||
|
'auth/auth-common': {
|
||||||
|
loggingOut: 'Logging Out...',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderWithProviders = (component: React.ReactElement) => {
|
||||||
|
return render(
|
||||||
|
<BrowserRouter>
|
||||||
|
<I18nextProvider i18n={i18n}>
|
||||||
|
{component}
|
||||||
|
</I18nextProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('LoggingOutPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
// Mock console.error to avoid noise in tests
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
|
vi.mock('@/hooks/useAuth', () => ({
|
||||||
|
useAuthService: () => mockAuthService,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders loading state correctly', () => {
|
||||||
|
renderWithProviders(<LoggingOutPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Logging Out...')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('img', { name: /loading/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('performs complete logout sequence successfully', async () => {
|
||||||
|
mockAuthService.signOut.mockResolvedValue(undefined);
|
||||||
|
(authApiService.logout as any).mockResolvedValue(undefined);
|
||||||
|
(CacheCleanup.clearAllCaches as any).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
renderWithProviders(<LoggingOutPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockAuthService.signOut).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(authApiService.logout).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(CacheCleanup.clearAllCaches).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fast-forward time to trigger the setTimeout
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(CacheCleanup.forceReload).toHaveBeenCalledWith('/auth/login');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles auth service signOut failure', async () => {
|
||||||
|
mockAuthService.signOut.mockRejectedValue(new Error('SignOut failed'));
|
||||||
|
(authApiService.logout as any).mockResolvedValue(undefined);
|
||||||
|
(CacheCleanup.clearAllCaches as any).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
renderWithProviders(<LoggingOutPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockAuthService.signOut).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(console.error).toHaveBeenCalledWith('Logout error:', expect.any(Error));
|
||||||
|
expect(CacheCleanup.forceReload).toHaveBeenCalledWith('/auth/login');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles backend logout failure', async () => {
|
||||||
|
mockAuthService.signOut.mockResolvedValue(undefined);
|
||||||
|
(authApiService.logout as any).mockRejectedValue(new Error('Backend logout failed'));
|
||||||
|
(CacheCleanup.clearAllCaches as any).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
renderWithProviders(<LoggingOutPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockAuthService.signOut).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(authApiService.logout).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(console.error).toHaveBeenCalledWith('Logout error:', expect.any(Error));
|
||||||
|
expect(CacheCleanup.forceReload).toHaveBeenCalledWith('/auth/login');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles cache cleanup failure', async () => {
|
||||||
|
mockAuthService.signOut.mockResolvedValue(undefined);
|
||||||
|
(authApiService.logout as any).mockResolvedValue(undefined);
|
||||||
|
(CacheCleanup.clearAllCaches as any).mockRejectedValue(new Error('Cache cleanup failed'));
|
||||||
|
|
||||||
|
renderWithProviders(<LoggingOutPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockAuthService.signOut).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(authApiService.logout).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(CacheCleanup.clearAllCaches).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(console.error).toHaveBeenCalledWith('Logout error:', expect.any(Error));
|
||||||
|
expect(CacheCleanup.forceReload).toHaveBeenCalledWith('/auth/login');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('triggers logout sequence immediately on mount', () => {
|
||||||
|
renderWithProviders(<LoggingOutPage />);
|
||||||
|
|
||||||
|
expect(mockAuthService.signOut).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows consistent loading UI throughout logout process', async () => {
|
||||||
|
mockAuthService.signOut.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)));
|
||||||
|
(authApiService.logout as any).mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)));
|
||||||
|
(CacheCleanup.clearAllCaches as any).mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)));
|
||||||
|
|
||||||
|
renderWithProviders(<LoggingOutPage />);
|
||||||
|
|
||||||
|
// Should show loading state immediately
|
||||||
|
expect(screen.getByText('Logging Out...')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('img', { name: /loading/i })).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Should continue showing loading state during the process
|
||||||
|
vi.advanceTimersByTime(50);
|
||||||
|
|
||||||
|
expect(screen.getByText('Logging Out...')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('img', { name: /loading/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls forceReload with correct path after timeout', async () => {
|
||||||
|
mockAuthService.signOut.mockResolvedValue(undefined);
|
||||||
|
(authApiService.logout as any).mockResolvedValue(undefined);
|
||||||
|
(CacheCleanup.clearAllCaches as any).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
renderWithProviders(<LoggingOutPage />);
|
||||||
|
|
||||||
|
// Wait for all async operations to complete
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(CacheCleanup.clearAllCaches).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fast-forward exactly 1000ms
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(CacheCleanup.forceReload).toHaveBeenCalledWith('/auth/login');
|
||||||
|
expect(CacheCleanup.forceReload).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles complete failure of all logout steps', async () => {
|
||||||
|
mockAuthService.signOut.mockRejectedValue(new Error('SignOut failed'));
|
||||||
|
(authApiService.logout as any).mockRejectedValue(new Error('Backend logout failed'));
|
||||||
|
(CacheCleanup.clearAllCaches as any).mockRejectedValue(new Error('Cache cleanup failed'));
|
||||||
|
|
||||||
|
renderWithProviders(<LoggingOutPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(console.error).toHaveBeenCalledWith('Logout error:', expect.any(Error));
|
||||||
|
expect(CacheCleanup.forceReload).toHaveBeenCalledWith('/auth/login');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
317
worklenz-frontend/src/pages/auth/__tests__/LoginPage.test.tsx
Normal file
317
worklenz-frontend/src/pages/auth/__tests__/LoginPage.test.tsx
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import { Provider } from 'react-redux';
|
||||||
|
import { configureStore } from '@reduxjs/toolkit';
|
||||||
|
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { I18nextProvider } from 'react-i18next';
|
||||||
|
import i18n from 'i18next';
|
||||||
|
|
||||||
|
import LoginPage from '../LoginPage';
|
||||||
|
import { login, verifyAuthentication } from '@/features/auth/authSlice';
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
vi.mock('@/features/auth/authSlice', () => ({
|
||||||
|
login: vi.fn(),
|
||||||
|
verifyAuthentication: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/features/user/userSlice', () => ({
|
||||||
|
setUser: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/utils/session-helper', () => ({
|
||||||
|
setSession: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/utils/errorLogger', () => ({
|
||||||
|
default: {
|
||||||
|
error: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/hooks/useMixpanelTracking', () => ({
|
||||||
|
useMixpanelTracking: () => ({
|
||||||
|
trackMixpanelEvent: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/hooks/useDoumentTItle', () => ({
|
||||||
|
useDocumentTitle: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/hooks/useAuth', () => ({
|
||||||
|
useAuthService: () => ({
|
||||||
|
getCurrentSession: () => null,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/services/alerts/alertService', () => ({
|
||||||
|
default: {
|
||||||
|
error: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('react-responsive', () => ({
|
||||||
|
useMediaQuery: () => false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock navigation
|
||||||
|
const mockNavigate = vi.fn();
|
||||||
|
vi.mock('react-router-dom', async () => {
|
||||||
|
const actual = await vi.importActual('react-router-dom');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useNavigate: () => mockNavigate,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock dispatch
|
||||||
|
const mockDispatch = vi.fn();
|
||||||
|
vi.mock('@/hooks/useAppDispatch', () => ({
|
||||||
|
useAppDispatch: () => mockDispatch,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Setup i18n for testing
|
||||||
|
i18n.init({
|
||||||
|
lng: 'en',
|
||||||
|
resources: {
|
||||||
|
en: {
|
||||||
|
'auth/login': {
|
||||||
|
headerDescription: 'Sign in to your account',
|
||||||
|
emailRequired: 'Please input your email!',
|
||||||
|
passwordRequired: 'Please input your password!',
|
||||||
|
emailPlaceholder: 'Email',
|
||||||
|
passwordPlaceholder: 'Password',
|
||||||
|
loginButton: 'Sign In',
|
||||||
|
rememberMe: 'Remember me',
|
||||||
|
forgotPasswordButton: 'Forgot password?',
|
||||||
|
signInWithGoogleButton: 'Sign in with Google',
|
||||||
|
orText: 'or',
|
||||||
|
dontHaveAccountText: "Don't have an account?",
|
||||||
|
signupButton: 'Sign up',
|
||||||
|
successMessage: 'Login successful!',
|
||||||
|
'validationMessages.email': 'Please enter a valid email!',
|
||||||
|
'validationMessages.password': 'Password must be at least 8 characters!',
|
||||||
|
'errorMessages.loginErrorTitle': 'Login Failed',
|
||||||
|
'errorMessages.loginErrorMessage': 'Invalid email or password',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create test store
|
||||||
|
const createTestStore = (initialState: any = {}) => {
|
||||||
|
return configureStore({
|
||||||
|
reducer: {
|
||||||
|
auth: (state = { isLoading: false, ...initialState.auth }) => state,
|
||||||
|
user: (state = {}) => state,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderWithProviders = (component: React.ReactElement, initialState: any = {}) => {
|
||||||
|
const store = createTestStore(initialState);
|
||||||
|
return render(
|
||||||
|
<Provider store={store}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<I18nextProvider i18n={i18n}>
|
||||||
|
{component}
|
||||||
|
</I18nextProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('LoginPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
// Mock environment variables
|
||||||
|
vi.stubEnv('VITE_ENABLE_GOOGLE_LOGIN', 'true');
|
||||||
|
vi.stubEnv('VITE_API_URL', 'http://localhost:3000');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders login form correctly', () => {
|
||||||
|
renderWithProviders(<LoginPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Sign in to your account')).toBeInTheDocument();
|
||||||
|
expect(screen.getByPlaceholderText('Email')).toBeInTheDocument();
|
||||||
|
expect(screen.getByPlaceholderText('Password')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Sign In' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Remember me')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Forgot password?')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows Google login button when enabled', () => {
|
||||||
|
renderWithProviders(<LoginPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Sign in with Google')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates required fields', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<LoginPage />);
|
||||||
|
|
||||||
|
const submitButton = screen.getByRole('button', { name: 'Sign In' });
|
||||||
|
await user.click(submitButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Please input your email!')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Please input your password!')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates email format', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<LoginPage />);
|
||||||
|
|
||||||
|
const emailInput = screen.getByPlaceholderText('Email');
|
||||||
|
await user.type(emailInput, 'invalid-email');
|
||||||
|
|
||||||
|
const submitButton = screen.getByRole('button', { name: 'Sign In' });
|
||||||
|
await user.click(submitButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Please enter a valid email!')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates password minimum length', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<LoginPage />);
|
||||||
|
|
||||||
|
const emailInput = screen.getByPlaceholderText('Email');
|
||||||
|
const passwordInput = screen.getByPlaceholderText('Password');
|
||||||
|
|
||||||
|
await user.type(emailInput, 'test@example.com');
|
||||||
|
await user.type(passwordInput, '123');
|
||||||
|
|
||||||
|
const submitButton = screen.getByRole('button', { name: 'Sign In' });
|
||||||
|
await user.click(submitButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Password must be at least 8 characters!')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('submits form with valid credentials', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
mockDispatch.mockReturnValue({
|
||||||
|
unwrap: vi.fn().mockResolvedValue({
|
||||||
|
authenticated: true,
|
||||||
|
user: { id: '1', email: 'test@example.com' },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(<LoginPage />);
|
||||||
|
|
||||||
|
const emailInput = screen.getByPlaceholderText('Email');
|
||||||
|
const passwordInput = screen.getByPlaceholderText('Password');
|
||||||
|
|
||||||
|
await user.type(emailInput, 'test@example.com');
|
||||||
|
await user.type(passwordInput, 'password123');
|
||||||
|
|
||||||
|
const submitButton = screen.getByRole('button', { name: 'Sign In' });
|
||||||
|
await user.click(submitButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(login).toHaveBeenCalledWith({
|
||||||
|
email: 'test@example.com',
|
||||||
|
password: 'password123',
|
||||||
|
remember: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows loading state during login', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<LoginPage />, { auth: { isLoading: true } });
|
||||||
|
|
||||||
|
const submitButton = screen.getByRole('button', { name: 'Sign In' });
|
||||||
|
expect(submitButton).toBeDisabled();
|
||||||
|
expect(screen.getByRole('img', { name: /loading/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles Google login click', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<LoginPage />);
|
||||||
|
|
||||||
|
// Mock window.location
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
value: { href: '' },
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const googleButton = screen.getByText('Sign in with Google');
|
||||||
|
await user.click(googleButton);
|
||||||
|
|
||||||
|
expect(window.location.href).toBe('http://localhost:3000/secure/google');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigates to signup page', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<LoginPage />);
|
||||||
|
|
||||||
|
const signupLink = screen.getByText('Sign up');
|
||||||
|
await user.click(signupLink);
|
||||||
|
|
||||||
|
// Link navigation is handled by React Router, so we just check the element exists
|
||||||
|
expect(signupLink.closest('a')).toHaveAttribute('href', '/auth/signup');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigates to forgot password page', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<LoginPage />);
|
||||||
|
|
||||||
|
const forgotPasswordLink = screen.getByText('Forgot password?');
|
||||||
|
await user.click(forgotPasswordLink);
|
||||||
|
|
||||||
|
expect(forgotPasswordLink.closest('a')).toHaveAttribute('href', '/auth/forgot-password');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles remember me checkbox', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<LoginPage />);
|
||||||
|
|
||||||
|
const rememberMeCheckbox = screen.getByRole('checkbox', { name: 'Remember me' });
|
||||||
|
expect(rememberMeCheckbox).toBeChecked(); // Default is true
|
||||||
|
|
||||||
|
await user.click(rememberMeCheckbox);
|
||||||
|
expect(rememberMeCheckbox).not.toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects already authenticated users', async () => {
|
||||||
|
mockDispatch.mockReturnValue({
|
||||||
|
unwrap: vi.fn().mockResolvedValue({
|
||||||
|
authenticated: true,
|
||||||
|
user: { id: '1', email: 'test@example.com', setup_completed: true },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(<LoginPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockNavigate).toHaveBeenCalledWith('/worklenz/home');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects to setup for users with incomplete setup', async () => {
|
||||||
|
const mockCurrentSession = {
|
||||||
|
id: '1',
|
||||||
|
email: 'test@example.com',
|
||||||
|
setup_completed: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('@/hooks/useAuth', () => ({
|
||||||
|
useAuthService: () => ({
|
||||||
|
getCurrentSession: () => mockCurrentSession,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
renderWithProviders(<LoginPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockNavigate).toHaveBeenCalledWith('/worklenz/setup');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
359
worklenz-frontend/src/pages/auth/__tests__/SignupPage.test.tsx
Normal file
359
worklenz-frontend/src/pages/auth/__tests__/SignupPage.test.tsx
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import { Provider } from 'react-redux';
|
||||||
|
import { configureStore } from '@reduxjs/toolkit';
|
||||||
|
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { I18nextProvider } from 'react-i18next';
|
||||||
|
import i18n from 'i18next';
|
||||||
|
|
||||||
|
import SignupPage from '../SignupPage';
|
||||||
|
import { signUp } from '@/features/auth/authSlice';
|
||||||
|
import { authApiService } from '@/api/auth/auth.api.service';
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
vi.mock('@/features/auth/authSlice', () => ({
|
||||||
|
signUp: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/api/auth/auth.api.service', () => ({
|
||||||
|
authApiService: {
|
||||||
|
signUpCheck: vi.fn(),
|
||||||
|
verifyRecaptchaToken: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/hooks/useMixpanelTracking', () => ({
|
||||||
|
useMixpanelTracking: () => ({
|
||||||
|
trackMixpanelEvent: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/hooks/useDoumentTItle', () => ({
|
||||||
|
useDocumentTitle: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/utils/errorLogger', () => ({
|
||||||
|
default: {
|
||||||
|
error: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/services/alerts/alertService', () => ({
|
||||||
|
default: {
|
||||||
|
error: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('react-responsive', () => ({
|
||||||
|
useMediaQuery: () => false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock navigation
|
||||||
|
const mockNavigate = vi.fn();
|
||||||
|
vi.mock('react-router-dom', async () => {
|
||||||
|
const actual = await vi.importActual('react-router-dom');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useNavigate: () => mockNavigate,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock dispatch
|
||||||
|
const mockDispatch = vi.fn();
|
||||||
|
vi.mock('@/hooks/useAppDispatch', () => ({
|
||||||
|
useAppDispatch: () => mockDispatch,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Setup i18n for testing
|
||||||
|
i18n.init({
|
||||||
|
lng: 'en',
|
||||||
|
resources: {
|
||||||
|
en: {
|
||||||
|
'auth/signup': {
|
||||||
|
headerDescription: 'Sign up to get started',
|
||||||
|
nameLabel: 'Full Name',
|
||||||
|
emailLabel: 'Email',
|
||||||
|
passwordLabel: 'Password',
|
||||||
|
nameRequired: 'Please input your name!',
|
||||||
|
emailRequired: 'Please input your email!',
|
||||||
|
passwordRequired: 'Please input your password!',
|
||||||
|
nameMinCharacterRequired: 'Name must be at least 4 characters!',
|
||||||
|
passwordMinCharacterRequired: 'Password must be at least 8 characters!',
|
||||||
|
passwordMaxCharacterRequired: 'Password must be no more than 32 characters!',
|
||||||
|
passwordPatternRequired: 'Password must contain uppercase, lowercase, number and special character!',
|
||||||
|
namePlaceholder: 'Enter your full name',
|
||||||
|
emailPlaceholder: 'Enter your email',
|
||||||
|
strongPasswordPlaceholder: 'Enter a strong password',
|
||||||
|
passwordGuideline: 'Password must be at least 8 characters, include uppercase and lowercase letters, a number, and a special character.',
|
||||||
|
signupButton: 'Sign Up',
|
||||||
|
signInWithGoogleButton: 'Sign up with Google',
|
||||||
|
orText: 'or',
|
||||||
|
alreadyHaveAccountText: 'Already have an account?',
|
||||||
|
loginButton: 'Log in',
|
||||||
|
bySigningUpText: 'By signing up, you agree to our',
|
||||||
|
privacyPolicyLink: 'Privacy Policy',
|
||||||
|
andText: 'and',
|
||||||
|
termsOfUseLink: 'Terms of Use',
|
||||||
|
reCAPTCHAVerificationError: 'reCAPTCHA Verification Failed',
|
||||||
|
reCAPTCHAVerificationErrorMessage: 'Please try again',
|
||||||
|
'passwordChecklist.minLength': 'At least 8 characters',
|
||||||
|
'passwordChecklist.uppercase': 'One uppercase letter',
|
||||||
|
'passwordChecklist.lowercase': 'One lowercase letter',
|
||||||
|
'passwordChecklist.number': 'One number',
|
||||||
|
'passwordChecklist.special': 'One special character',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create test store
|
||||||
|
const createTestStore = () => {
|
||||||
|
return configureStore({
|
||||||
|
reducer: {
|
||||||
|
themeReducer: (state = { mode: 'light' }) => state,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderWithProviders = (component: React.ReactElement) => {
|
||||||
|
const store = createTestStore();
|
||||||
|
return render(
|
||||||
|
<Provider store={store}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<I18nextProvider i18n={i18n}>
|
||||||
|
{component}
|
||||||
|
</I18nextProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('SignupPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
localStorage.clear();
|
||||||
|
// Mock environment variables
|
||||||
|
vi.stubEnv('VITE_ENABLE_GOOGLE_LOGIN', 'true');
|
||||||
|
vi.stubEnv('VITE_ENABLE_RECAPTCHA', 'false');
|
||||||
|
vi.stubEnv('VITE_API_URL', 'http://localhost:3000');
|
||||||
|
|
||||||
|
// Mock URL search params
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
value: { search: '' },
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders signup form correctly', () => {
|
||||||
|
renderWithProviders(<SignupPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Sign up to get started')).toBeInTheDocument();
|
||||||
|
expect(screen.getByPlaceholderText('Enter your full name')).toBeInTheDocument();
|
||||||
|
expect(screen.getByPlaceholderText('Enter your email')).toBeInTheDocument();
|
||||||
|
expect(screen.getByPlaceholderText('Enter a strong password')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Sign Up' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows Google signup button when enabled', () => {
|
||||||
|
renderWithProviders(<SignupPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Sign up with Google')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates required fields', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<SignupPage />);
|
||||||
|
|
||||||
|
const submitButton = screen.getByRole('button', { name: 'Sign Up' });
|
||||||
|
await user.click(submitButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Please input your name!')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Please input your email!')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Please input your password!')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates name minimum length', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<SignupPage />);
|
||||||
|
|
||||||
|
const nameInput = screen.getByPlaceholderText('Enter your full name');
|
||||||
|
await user.type(nameInput, 'Jo');
|
||||||
|
|
||||||
|
const submitButton = screen.getByRole('button', { name: 'Sign Up' });
|
||||||
|
await user.click(submitButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Name must be at least 4 characters!')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates email format', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<SignupPage />);
|
||||||
|
|
||||||
|
const emailInput = screen.getByPlaceholderText('Enter your email');
|
||||||
|
await user.type(emailInput, 'invalid-email');
|
||||||
|
|
||||||
|
const submitButton = screen.getByRole('button', { name: 'Sign Up' });
|
||||||
|
await user.click(submitButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Please input your email!')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates password requirements', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<SignupPage />);
|
||||||
|
|
||||||
|
const passwordInput = screen.getByPlaceholderText('Enter a strong password');
|
||||||
|
await user.type(passwordInput, 'weak');
|
||||||
|
|
||||||
|
const submitButton = screen.getByRole('button', { name: 'Sign Up' });
|
||||||
|
await user.click(submitButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Password must be at least 8 characters!')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows password checklist when password field is focused', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<SignupPage />);
|
||||||
|
|
||||||
|
const passwordInput = screen.getByPlaceholderText('Enter a strong password');
|
||||||
|
await user.click(passwordInput);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('At least 8 characters')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('One uppercase letter')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('One lowercase letter')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('One number')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('One special character')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates password checklist based on input', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<SignupPage />);
|
||||||
|
|
||||||
|
const passwordInput = screen.getByPlaceholderText('Enter a strong password');
|
||||||
|
await user.click(passwordInput);
|
||||||
|
await user.type(passwordInput, 'Password123!');
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// All checklist items should be visible and some should be checked
|
||||||
|
expect(screen.getByText('At least 8 characters')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('One uppercase letter')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('One lowercase letter')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('One number')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('One special character')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('submits form with valid data', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
(authApiService.signUpCheck as any).mockResolvedValue({ done: true });
|
||||||
|
|
||||||
|
renderWithProviders(<SignupPage />);
|
||||||
|
|
||||||
|
const nameInput = screen.getByPlaceholderText('Enter your full name');
|
||||||
|
const emailInput = screen.getByPlaceholderText('Enter your email');
|
||||||
|
const passwordInput = screen.getByPlaceholderText('Enter a strong password');
|
||||||
|
|
||||||
|
await user.type(nameInput, 'John Doe');
|
||||||
|
await user.type(emailInput, 'john@example.com');
|
||||||
|
await user.type(passwordInput, 'Password123!');
|
||||||
|
|
||||||
|
const submitButton = screen.getByRole('button', { name: 'Sign Up' });
|
||||||
|
await user.click(submitButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(authApiService.signUpCheck).toHaveBeenCalledWith({
|
||||||
|
name: 'John Doe',
|
||||||
|
email: 'john@example.com',
|
||||||
|
password: 'Password123!',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles Google signup click', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<SignupPage />);
|
||||||
|
|
||||||
|
// Mock window.location
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
value: { href: '' },
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const googleButton = screen.getByText('Sign up with Google');
|
||||||
|
await user.click(googleButton);
|
||||||
|
|
||||||
|
expect(window.location.href).toBe('http://localhost:3000/secure/google?');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pre-fills form from URL parameters', () => {
|
||||||
|
// Mock URLSearchParams
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
value: { search: '?email=test@example.com&name=Test User' },
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(<SignupPage />);
|
||||||
|
|
||||||
|
const nameInput = screen.getByPlaceholderText('Enter your full name') as HTMLInputElement;
|
||||||
|
const emailInput = screen.getByPlaceholderText('Enter your email') as HTMLInputElement;
|
||||||
|
|
||||||
|
expect(nameInput.value).toBe('Test User');
|
||||||
|
expect(emailInput.value).toBe('test@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows terms of use and privacy policy links', () => {
|
||||||
|
renderWithProviders(<SignupPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Privacy Policy')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Terms of Use')).toBeInTheDocument();
|
||||||
|
|
||||||
|
const privacyLink = screen.getByText('Privacy Policy').closest('a');
|
||||||
|
const termsLink = screen.getByText('Terms of Use').closest('a');
|
||||||
|
|
||||||
|
expect(privacyLink).toHaveAttribute('href', 'https://worklenz.com/privacy/');
|
||||||
|
expect(termsLink).toHaveAttribute('href', 'https://worklenz.com/terms/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigates to login page', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<SignupPage />);
|
||||||
|
|
||||||
|
const loginLink = screen.getByText('Log in');
|
||||||
|
await user.click(loginLink);
|
||||||
|
|
||||||
|
expect(loginLink.closest('a')).toHaveAttribute('href', '/auth/login');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows loading state during signup', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
(authApiService.signUpCheck as any).mockResolvedValue({ done: true });
|
||||||
|
|
||||||
|
renderWithProviders(<SignupPage />);
|
||||||
|
|
||||||
|
const nameInput = screen.getByPlaceholderText('Enter your full name');
|
||||||
|
const emailInput = screen.getByPlaceholderText('Enter your email');
|
||||||
|
const passwordInput = screen.getByPlaceholderText('Enter a strong password');
|
||||||
|
|
||||||
|
await user.type(nameInput, 'John Doe');
|
||||||
|
await user.type(emailInput, 'john@example.com');
|
||||||
|
await user.type(passwordInput, 'Password123!');
|
||||||
|
|
||||||
|
const submitButton = screen.getByRole('button', { name: 'Sign Up' });
|
||||||
|
await user.click(submitButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('img', { name: /loading/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { BrowserRouter, MemoryRouter } from 'react-router-dom';
|
||||||
|
import { Provider } from 'react-redux';
|
||||||
|
import { configureStore } from '@reduxjs/toolkit';
|
||||||
|
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { I18nextProvider } from 'react-i18next';
|
||||||
|
import i18n from 'i18next';
|
||||||
|
|
||||||
|
import VerifyResetEmailPage from '../VerifyResetEmailPage';
|
||||||
|
import { updatePassword } from '@/features/auth/authSlice';
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
vi.mock('@/features/auth/authSlice', () => ({
|
||||||
|
updatePassword: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/utils/errorLogger', () => ({
|
||||||
|
default: {
|
||||||
|
error: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/hooks/useMixpanelTracking', () => ({
|
||||||
|
useMixpanelTracking: () => ({
|
||||||
|
trackMixpanelEvent: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/hooks/useDoumentTItle', () => ({
|
||||||
|
useDocumentTitle: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('react-responsive', () => ({
|
||||||
|
useMediaQuery: () => false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock navigation
|
||||||
|
const mockNavigate = vi.fn();
|
||||||
|
vi.mock('react-router-dom', async () => {
|
||||||
|
const actual = await vi.importActual('react-router-dom');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useNavigate: () => mockNavigate,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock dispatch
|
||||||
|
const mockDispatch = vi.fn();
|
||||||
|
vi.mock('@/hooks/useAppDispatch', () => ({
|
||||||
|
useAppDispatch: () => mockDispatch,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Setup i18n for testing
|
||||||
|
i18n.init({
|
||||||
|
lng: 'en',
|
||||||
|
resources: {
|
||||||
|
en: {
|
||||||
|
'auth/verify-reset-email': {
|
||||||
|
description: 'Enter your new password',
|
||||||
|
passwordRequired: 'Please input your password!',
|
||||||
|
confirmPasswordRequired: 'Please confirm your password!',
|
||||||
|
placeholder: 'Enter new password',
|
||||||
|
confirmPasswordPlaceholder: 'Confirm new password',
|
||||||
|
resetPasswordButton: 'Reset Password',
|
||||||
|
resendResetEmail: 'Resend Reset Email',
|
||||||
|
orText: 'or',
|
||||||
|
passwordMismatch: 'The two passwords do not match!',
|
||||||
|
successTitle: 'Password Reset Successful',
|
||||||
|
successMessage: 'Your password has been reset successfully.',
|
||||||
|
'passwordChecklist.minLength': 'At least 8 characters',
|
||||||
|
'passwordChecklist.uppercase': 'One uppercase letter',
|
||||||
|
'passwordChecklist.lowercase': 'One lowercase letter',
|
||||||
|
'passwordChecklist.number': 'One number',
|
||||||
|
'passwordChecklist.special': 'One special character',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create test store
|
||||||
|
const createTestStore = () => {
|
||||||
|
return configureStore({
|
||||||
|
reducer: {
|
||||||
|
themeReducer: (state = { mode: 'light' }) => state,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderWithProviders = (component: React.ReactElement, route = '/verify-reset/test-hash/test-user') => {
|
||||||
|
const store = createTestStore();
|
||||||
|
return render(
|
||||||
|
<Provider store={store}>
|
||||||
|
<MemoryRouter initialEntries={[route]}>
|
||||||
|
<I18nextProvider i18n={i18n}>
|
||||||
|
{component}
|
||||||
|
</I18nextProvider>
|
||||||
|
</MemoryRouter>
|
||||||
|
</Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('VerifyResetEmailPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders password reset form correctly', () => {
|
||||||
|
renderWithProviders(<VerifyResetEmailPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Enter your new password')).toBeInTheDocument();
|
||||||
|
expect(screen.getByPlaceholderText('Enter new password')).toBeInTheDocument();
|
||||||
|
expect(screen.getByPlaceholderText('Confirm new password')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Reset Password' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Resend Reset Email' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows password checklist immediately', () => {
|
||||||
|
renderWithProviders(<VerifyResetEmailPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText('At least 8 characters')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('One uppercase letter')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('One lowercase letter')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('One number')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('One special character')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates required password fields', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<VerifyResetEmailPage />);
|
||||||
|
|
||||||
|
const submitButton = screen.getByRole('button', { name: 'Reset Password' });
|
||||||
|
await user.click(submitButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Please input your password!')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Please confirm your password!')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates password confirmation match', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<VerifyResetEmailPage />);
|
||||||
|
|
||||||
|
const passwordInput = screen.getByPlaceholderText('Enter new password');
|
||||||
|
const confirmPasswordInput = screen.getByPlaceholderText('Confirm new password');
|
||||||
|
|
||||||
|
await user.type(passwordInput, 'Password123!');
|
||||||
|
await user.type(confirmPasswordInput, 'DifferentPassword123!');
|
||||||
|
|
||||||
|
const submitButton = screen.getByRole('button', { name: 'Reset Password' });
|
||||||
|
await user.click(submitButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('The two passwords do not match!')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates password checklist based on input', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<VerifyResetEmailPage />);
|
||||||
|
|
||||||
|
const passwordInput = screen.getByPlaceholderText('Enter new password');
|
||||||
|
await user.type(passwordInput, 'Password123!');
|
||||||
|
|
||||||
|
// All checklist items should be visible (this component shows them by default)
|
||||||
|
expect(screen.getByText('At least 8 characters')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('One uppercase letter')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('One lowercase letter')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('One number')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('One special character')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('submits form with valid matching passwords', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
mockDispatch.mockReturnValue({
|
||||||
|
unwrap: vi.fn().mockResolvedValue({ done: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(<VerifyResetEmailPage />);
|
||||||
|
|
||||||
|
const passwordInput = screen.getByPlaceholderText('Enter new password');
|
||||||
|
const confirmPasswordInput = screen.getByPlaceholderText('Confirm new password');
|
||||||
|
|
||||||
|
await user.type(passwordInput, 'Password123!');
|
||||||
|
await user.type(confirmPasswordInput, 'Password123!');
|
||||||
|
|
||||||
|
const submitButton = screen.getByRole('button', { name: 'Reset Password' });
|
||||||
|
await user.click(submitButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(updatePassword).toHaveBeenCalledWith({
|
||||||
|
hash: 'test-hash',
|
||||||
|
user: 'test-user',
|
||||||
|
password: 'Password123!',
|
||||||
|
confirmPassword: 'Password123!',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows success message after successful password reset', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
mockDispatch.mockReturnValue({
|
||||||
|
unwrap: vi.fn().mockResolvedValue({ done: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(<VerifyResetEmailPage />);
|
||||||
|
|
||||||
|
const passwordInput = screen.getByPlaceholderText('Enter new password');
|
||||||
|
const confirmPasswordInput = screen.getByPlaceholderText('Confirm new password');
|
||||||
|
|
||||||
|
await user.type(passwordInput, 'Password123!');
|
||||||
|
await user.type(confirmPasswordInput, 'Password123!');
|
||||||
|
|
||||||
|
const submitButton = screen.getByRole('button', { name: 'Reset Password' });
|
||||||
|
await user.click(submitButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Password Reset Successful')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Your password has been reset successfully.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockNavigate).toHaveBeenCalledWith('/auth/login');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows loading state during submission', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
mockDispatch.mockReturnValue({
|
||||||
|
unwrap: vi.fn().mockImplementation(() => new Promise(() => {})), // Never resolves
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(<VerifyResetEmailPage />);
|
||||||
|
|
||||||
|
const passwordInput = screen.getByPlaceholderText('Enter new password');
|
||||||
|
const confirmPasswordInput = screen.getByPlaceholderText('Confirm new password');
|
||||||
|
|
||||||
|
await user.type(passwordInput, 'Password123!');
|
||||||
|
await user.type(confirmPasswordInput, 'Password123!');
|
||||||
|
|
||||||
|
const submitButton = screen.getByRole('button', { name: 'Reset Password' });
|
||||||
|
await user.click(submitButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('img', { name: /loading/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles submission errors gracefully', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
mockDispatch.mockReturnValue({
|
||||||
|
unwrap: vi.fn().mockRejectedValue(new Error('Reset failed')),
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(<VerifyResetEmailPage />);
|
||||||
|
|
||||||
|
const passwordInput = screen.getByPlaceholderText('Enter new password');
|
||||||
|
const confirmPasswordInput = screen.getByPlaceholderText('Confirm new password');
|
||||||
|
|
||||||
|
await user.type(passwordInput, 'Password123!');
|
||||||
|
await user.type(confirmPasswordInput, 'Password123!');
|
||||||
|
|
||||||
|
const submitButton = screen.getByRole('button', { name: 'Reset Password' });
|
||||||
|
await user.click(submitButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// Should not show success message
|
||||||
|
expect(screen.queryByText('Password Reset Successful')).not.toBeInTheDocument();
|
||||||
|
// Should still show the form
|
||||||
|
expect(screen.getByPlaceholderText('Enter new password')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigates to forgot password page when resend button is clicked', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<VerifyResetEmailPage />);
|
||||||
|
|
||||||
|
const resendButton = screen.getByRole('button', { name: 'Resend Reset Email' });
|
||||||
|
await user.click(resendButton);
|
||||||
|
|
||||||
|
expect(resendButton.closest('a')).toHaveAttribute('href', '/auth/forgot-password');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prevents pasting in confirm password field', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<VerifyResetEmailPage />);
|
||||||
|
|
||||||
|
const confirmPasswordInput = screen.getByPlaceholderText('Confirm new password');
|
||||||
|
|
||||||
|
await user.click(confirmPasswordInput);
|
||||||
|
|
||||||
|
// Try to paste - should be prevented
|
||||||
|
const pasteEvent = new ClipboardEvent('paste', {
|
||||||
|
clipboardData: new DataTransfer(),
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent(confirmPasswordInput, pasteEvent);
|
||||||
|
|
||||||
|
// The preventDefault should be called (we can't easily test this directly,
|
||||||
|
// but we can ensure the input behavior remains consistent)
|
||||||
|
expect(confirmPasswordInput).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not submit with empty passwords after trimming', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
mockDispatch.mockReturnValue({
|
||||||
|
unwrap: vi.fn().mockResolvedValue({ done: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(<VerifyResetEmailPage />);
|
||||||
|
|
||||||
|
const passwordInput = screen.getByPlaceholderText('Enter new password');
|
||||||
|
const confirmPasswordInput = screen.getByPlaceholderText('Confirm new password');
|
||||||
|
|
||||||
|
await user.type(passwordInput, ' '); // Only whitespace
|
||||||
|
await user.type(confirmPasswordInput, ' '); // Only whitespace
|
||||||
|
|
||||||
|
const submitButton = screen.getByRole('button', { name: 'Reset Password' });
|
||||||
|
await user.click(submitButton);
|
||||||
|
|
||||||
|
// Should not call updatePassword with empty strings
|
||||||
|
expect(updatePassword).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts hash and user from URL params', () => {
|
||||||
|
renderWithProviders(<VerifyResetEmailPage />, '/verify-reset/my-hash/my-user');
|
||||||
|
|
||||||
|
// Component should render normally, indicating it received the params
|
||||||
|
expect(screen.getByText('Enter your new password')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
import { useEffect, memo, useMemo, useCallback } from 'react';
|
import React, { useEffect, memo, useMemo, useCallback } from 'react';
|
||||||
import { useMediaQuery } from 'react-responsive';
|
import { useMediaQuery } from 'react-responsive';
|
||||||
import Col from 'antd/es/col';
|
import Col from 'antd/es/col';
|
||||||
import Flex from 'antd/es/flex';
|
import Flex from 'antd/es/flex';
|
||||||
|
import Row from 'antd/es/row';
|
||||||
|
import Card from 'antd/es/card';
|
||||||
|
|
||||||
import GreetingWithTime from './greeting-with-time';
|
import GreetingWithTime from './greeting-with-time';
|
||||||
import TasksList from '@/pages/home/task-list/tasks-list';
|
import TasksList from '@/pages/home/task-list/tasks-list';
|
||||||
import TodoList from '@/pages/home/todo-list/todo-list';
|
|
||||||
import ProjectDrawer from '@/components/projects/project-drawer/project-drawer';
|
import ProjectDrawer from '@/components/projects/project-drawer/project-drawer';
|
||||||
import CreateProjectButton from '@/components/projects/project-create-button/project-create-button';
|
import CreateProjectButton from '@/components/projects/project-create-button/project-create-button';
|
||||||
import RecentAndFavouriteProjectList from '@/pages/home/recent-and-favourite-project-list/recent-and-favourite-project-list';
|
import RecentAndFavouriteProjectList from '@/pages/home/recent-and-favourite-project-list/recent-and-favourite-project-list';
|
||||||
|
import TodoList from './todo-list/todo-list';
|
||||||
|
|
||||||
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
@@ -19,7 +21,7 @@ import { fetchProjectCategories } from '@/features/projects/lookups/projectCateg
|
|||||||
import { fetchProjectHealth } from '@/features/projects/lookups/projectHealth/projectHealthSlice';
|
import { fetchProjectHealth } from '@/features/projects/lookups/projectHealth/projectHealthSlice';
|
||||||
import { fetchProjects } from '@/features/home-page/home-page.slice';
|
import { fetchProjects } from '@/features/home-page/home-page.slice';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import React, { Suspense } from 'react';
|
import UserActivityFeed from './user-activity-feed/user-activity-feed';
|
||||||
|
|
||||||
const DESKTOP_MIN_WIDTH = 1024;
|
const DESKTOP_MIN_WIDTH = 1024;
|
||||||
const TASK_LIST_MIN_WIDTH = 500;
|
const TASK_LIST_MIN_WIDTH = 500;
|
||||||
@@ -27,7 +29,9 @@ const SIDEBAR_MAX_WIDTH = 400;
|
|||||||
|
|
||||||
// Lazy load heavy components
|
// Lazy load heavy components
|
||||||
const TaskDrawer = React.lazy(() => import('@/components/task-drawer/task-drawer'));
|
const TaskDrawer = React.lazy(() => import('@/components/task-drawer/task-drawer'));
|
||||||
const SurveyPromptModal = React.lazy(() => import('@/components/survey/SurveyPromptModal').then(m => ({ default: m.SurveyPromptModal })));
|
const SurveyPromptModal = React.lazy(() =>
|
||||||
|
import('@/components/survey/SurveyPromptModal').then(m => ({ default: m.SurveyPromptModal }))
|
||||||
|
);
|
||||||
|
|
||||||
const HomePage = memo(() => {
|
const HomePage = memo(() => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
@@ -98,56 +102,33 @@ const HomePage = memo(() => {
|
|||||||
);
|
);
|
||||||
}, [isDesktop, isOwnerOrAdmin]);
|
}, [isDesktop, isOwnerOrAdmin]);
|
||||||
|
|
||||||
const MainContent = useMemo(() => {
|
|
||||||
return isDesktop ? (
|
|
||||||
<Flex gap={24} align="flex-start" className="w-full mt-12">
|
|
||||||
<Flex style={desktopFlexStyle}>
|
|
||||||
<TasksList />
|
|
||||||
</Flex>
|
|
||||||
<Flex vertical gap={24} style={sidebarFlexStyle}>
|
|
||||||
<TodoList />
|
|
||||||
<RecentAndFavouriteProjectList />
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
) : (
|
|
||||||
<Flex vertical gap={24} className="mt-6">
|
|
||||||
<TasksList />
|
|
||||||
<TodoList />
|
|
||||||
<RecentAndFavouriteProjectList />
|
|
||||||
</Flex>
|
|
||||||
);
|
|
||||||
}, [isDesktop, desktopFlexStyle, sidebarFlexStyle]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="my-24 min-h-[90vh]">
|
<div className="my-8 min-h-[90vh]">
|
||||||
<Col className="flex flex-col gap-6">
|
<Col className="flex flex-col gap-6">
|
||||||
<GreetingWithTime />
|
<GreetingWithTime />
|
||||||
{CreateProjectButtonComponent}
|
{CreateProjectButtonComponent}
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
{MainContent}
|
<Row gutter={[24, 24]} className="mt-12">
|
||||||
|
<Col xs={24} lg={16}>
|
||||||
|
<Flex vertical gap={24}>
|
||||||
|
<TasksList />
|
||||||
|
</Flex>
|
||||||
|
</Col>
|
||||||
|
|
||||||
{/* Use Suspense for lazy-loaded components with error boundary */}
|
<Col xs={24} lg={8}>
|
||||||
<Suspense fallback={<div>Loading...</div>}>
|
<Flex vertical gap={24}>
|
||||||
{createPortal(
|
<TodoList />
|
||||||
<React.Suspense fallback={null}>
|
|
||||||
<TaskDrawer />
|
|
||||||
</React.Suspense>,
|
|
||||||
document.body,
|
|
||||||
'home-task-drawer'
|
|
||||||
)}
|
|
||||||
</Suspense>
|
|
||||||
|
|
||||||
{createPortal(
|
<UserActivityFeed />
|
||||||
<ProjectDrawer onClose={handleProjectDrawerClose} />,
|
|
||||||
document.body,
|
|
||||||
'project-drawer'
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Survey Modal - only shown to users who haven't completed it */}
|
<RecentAndFavouriteProjectList />
|
||||||
<Suspense fallback={null}>
|
</Flex>
|
||||||
<SurveyPromptModal />
|
</Col>
|
||||||
</Suspense>
|
</Row>
|
||||||
|
|
||||||
|
{createPortal(<TaskDrawer />, document.body, 'home-task-drawer')}
|
||||||
|
{createPortal(<ProjectDrawer onClose={() => {}} />, document.body, 'project-drawer')}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { CheckCircleOutlined, SyncOutlined } from '@/shared/antd-imports';
|
import { CheckCircleOutlined, SyncOutlined, DownOutlined, RightOutlined } from '@/shared/antd-imports';
|
||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import Form from 'antd/es/form';
|
import Form from 'antd/es/form';
|
||||||
import Input, { InputRef } from 'antd/es/input';
|
import Input, { InputRef } from 'antd/es/input';
|
||||||
import Flex from 'antd/es/flex';
|
import Flex from 'antd/es/flex';
|
||||||
import Card from 'antd/es/card';
|
import Card from 'antd/es/card';
|
||||||
|
import Collapse from 'antd/es/collapse';
|
||||||
import ConfigProvider from 'antd/es/config-provider';
|
import ConfigProvider from 'antd/es/config-provider';
|
||||||
import Table, { TableProps } from 'antd/es/table';
|
import Table, { TableProps } from 'antd/es/table';
|
||||||
import Tooltip from 'antd/es/tooltip';
|
import Tooltip from 'antd/es/tooltip';
|
||||||
@@ -23,6 +24,7 @@ import { useCreatePersonalTaskMutation } from '@/api/home-page/home-page.api.ser
|
|||||||
|
|
||||||
const TodoList = () => {
|
const TodoList = () => {
|
||||||
const [isAlertShowing, setIsAlertShowing] = useState(false);
|
const [isAlertShowing, setIsAlertShowing] = useState(false);
|
||||||
|
const [isCollapsed, setIsCollapsed] = useState(true);
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const { t } = useTranslation('home');
|
const { t } = useTranslation('home');
|
||||||
|
|
||||||
@@ -97,73 +99,109 @@ const TodoList = () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card style={{ width: '100%' }} bodyStyle={{ padding: 0 }}>
|
||||||
title={
|
<style>{`
|
||||||
<Typography.Title level={5} style={{ marginBlockEnd: 0 }}>
|
.todo-collapse .ant-collapse-header {
|
||||||
{t('home:todoList.title')} ({data?.body.length})
|
display: flex !important;
|
||||||
</Typography.Title>
|
align-items: center !important;
|
||||||
}
|
padding: 12px 16px !important;
|
||||||
extra={
|
}
|
||||||
<Tooltip title={t('home:todoList.refreshTasks')}>
|
.todo-collapse .ant-collapse-expand-icon {
|
||||||
<Button shape="circle" icon={<SyncOutlined spin={isFetching} />} onClick={refetch} />
|
margin-right: 8px !important;
|
||||||
</Tooltip>
|
display: flex !important;
|
||||||
}
|
align-items: center !important;
|
||||||
style={{ width: '100%' }}
|
}
|
||||||
>
|
`}</style>
|
||||||
<div>
|
<Collapse
|
||||||
<Form form={form} onFinish={handleTodoSubmit}>
|
defaultActiveKey={[]}
|
||||||
<Form.Item name="name">
|
ghost
|
||||||
<Flex vertical>
|
size="small"
|
||||||
<Input
|
className="todo-collapse"
|
||||||
ref={todoInputRef}
|
expandIcon={({ isActive }) =>
|
||||||
placeholder={t('home:todoList.addTask')}
|
isActive ? <DownOutlined /> : <RightOutlined />
|
||||||
onChange={e => {
|
}
|
||||||
const inputValue = e.currentTarget.value;
|
onChange={(keys) => {
|
||||||
|
setIsCollapsed(keys.length === 0);
|
||||||
|
}}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: '1',
|
||||||
|
label: (
|
||||||
|
<Flex style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
|
||||||
|
<Typography.Title level={5} style={{ margin: 0 }}>
|
||||||
|
{t('home:todoList.title')} ({data?.body.length})
|
||||||
|
</Typography.Title>
|
||||||
|
<Tooltip title={t('home:todoList.refreshTasks')}>
|
||||||
|
<Button
|
||||||
|
shape="circle"
|
||||||
|
icon={<SyncOutlined spin={isFetching} />}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
refetch();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Flex>
|
||||||
|
),
|
||||||
|
children: (
|
||||||
|
<div style={{ padding: '0 16px 16px 16px' }}>
|
||||||
|
<Form form={form} onFinish={handleTodoSubmit}>
|
||||||
|
<Form.Item name="name">
|
||||||
|
<Flex vertical>
|
||||||
|
<Input
|
||||||
|
ref={todoInputRef}
|
||||||
|
placeholder={t('home:todoList.addTask')}
|
||||||
|
onChange={e => {
|
||||||
|
const inputValue = e.currentTarget.value;
|
||||||
|
|
||||||
if (inputValue.length >= 1) setIsAlertShowing(true);
|
if (inputValue.length >= 1) setIsAlertShowing(true);
|
||||||
else if (inputValue === '') setIsAlertShowing(false);
|
else if (inputValue === '') setIsAlertShowing(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{isAlertShowing && (
|
{isAlertShowing && (
|
||||||
<Alert
|
<Alert
|
||||||
message={
|
message={
|
||||||
<Typography.Text style={{ fontSize: 11 }}>
|
<Typography.Text style={{ fontSize: 11 }}>
|
||||||
{t('home:todoList.pressEnter')} <strong>Enter</strong>{' '}
|
{t('home:todoList.pressEnter')} <strong>Enter</strong>{' '}
|
||||||
{t('home:todoList.toCreate')}
|
{t('home:todoList.toCreate')}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
}
|
}
|
||||||
type="info"
|
type="info"
|
||||||
style={{
|
style={{
|
||||||
width: 'fit-content',
|
width: 'fit-content',
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
padding: '0 6px',
|
padding: '0 6px',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
<div style={{ maxHeight: 420, overflow: 'auto' }}>
|
<div style={{ maxHeight: 300, overflow: 'auto' }}>
|
||||||
{data?.body.length === 0 ? (
|
{data?.body.length === 0 ? (
|
||||||
<EmptyListPlaceholder
|
<EmptyListPlaceholder
|
||||||
imageSrc="https://s3.us-west-2.amazonaws.com/worklenz.com/assets/empty-box.webp"
|
imageSrc="https://s3.us-west-2.amazonaws.com/worklenz.com/assets/empty-box.webp"
|
||||||
text={t('home:todoList.noTasks')}
|
text={t('home:todoList.noTasks')}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Table
|
<Table
|
||||||
className="custom-two-colors-row-table"
|
className="custom-two-colors-row-table"
|
||||||
rowKey={record => record.id || ''}
|
rowKey={record => record.id || ''}
|
||||||
dataSource={data?.body}
|
dataSource={data?.body}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
showHeader={false}
|
showHeader={false}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
size="small"
|
size="small"
|
||||||
loading={isFetching}
|
loading={isFetching}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import React, { useCallback } from 'react';
|
||||||
|
import { Table, Typography, Tooltip, theme } from 'antd';
|
||||||
|
import { FileTextOutlined } from '@ant-design/icons';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
|
import { fromNow, formatDate } from '@/utils/dateUtils';
|
||||||
|
import {
|
||||||
|
setSelectedTaskId,
|
||||||
|
setShowTaskDrawer,
|
||||||
|
fetchTask,
|
||||||
|
} from '@/features/task-drawer/task-drawer.slice';
|
||||||
|
import { IUserRecentTask } from '@/types/home/user-activity.types';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
interface TaskActivityListProps {
|
||||||
|
tasks: IUserRecentTask[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const TaskActivityList: React.FC<TaskActivityListProps> = React.memo(({ tasks }) => {
|
||||||
|
const { t } = useTranslation('home');
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
|
||||||
|
const handleTaskClick = useCallback(
|
||||||
|
(taskId: string, projectId: string) => {
|
||||||
|
dispatch(setSelectedTaskId(taskId));
|
||||||
|
dispatch(setShowTaskDrawer(true));
|
||||||
|
dispatch(fetchTask({ taskId, projectId }));
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
key: 'task',
|
||||||
|
render: (record: IUserRecentTask) => (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 12,
|
||||||
|
width: '100%',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '8px 0'
|
||||||
|
}}
|
||||||
|
onClick={() => handleTaskClick(record.task_id, record.project_id)}
|
||||||
|
aria-label={`${t('tasks.recentTaskAriaLabel')} ${record.task_name}`}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
marginTop: 2,
|
||||||
|
color: token.colorPrimary,
|
||||||
|
fontSize: 16
|
||||||
|
}}>
|
||||||
|
<FileTextOutlined />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ marginBottom: 4 }}>
|
||||||
|
<Text strong style={{ fontSize: 14 }}>
|
||||||
|
{record.task_name}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{record.project_name}
|
||||||
|
</Text>
|
||||||
|
<Tooltip
|
||||||
|
title={formatDate(record.last_activity_at, 'MMMM Do YYYY, h:mm:ss a')}
|
||||||
|
placement="topRight"
|
||||||
|
>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{fromNow(record.last_activity_at)}
|
||||||
|
</Text>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table
|
||||||
|
className="custom-two-colors-row-table"
|
||||||
|
dataSource={tasks}
|
||||||
|
columns={columns}
|
||||||
|
rowKey="task_id"
|
||||||
|
showHeader={false}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default TaskActivityList;
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import React, { useCallback } from 'react';
|
||||||
|
import { Table, Typography, Tag, Tooltip, Space, theme } from '@/shared/antd-imports';
|
||||||
|
import { ClockCircleOutlined } from '@/shared/antd-imports';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
|
import { fromNow, formatDate } from '@/utils/dateUtils';
|
||||||
|
import {
|
||||||
|
setSelectedTaskId,
|
||||||
|
setShowTaskDrawer,
|
||||||
|
fetchTask,
|
||||||
|
} from '@/features/task-drawer/task-drawer.slice';
|
||||||
|
import { IUserTimeLoggedTask } from '@/types/home/user-activity.types';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
interface TimeLoggedTaskListProps {
|
||||||
|
tasks: IUserTimeLoggedTask[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const TimeLoggedTaskList: React.FC<TimeLoggedTaskListProps> = React.memo(({ tasks }) => {
|
||||||
|
const { t } = useTranslation('home');
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
|
||||||
|
const handleTaskClick = useCallback(
|
||||||
|
(taskId: string, projectId: string) => {
|
||||||
|
dispatch(setSelectedTaskId(taskId));
|
||||||
|
dispatch(setShowTaskDrawer(true));
|
||||||
|
dispatch(fetchTask({ taskId, projectId }));
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
key: 'task',
|
||||||
|
render: (record: IUserTimeLoggedTask) => (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
width: '100%',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '8px 0'
|
||||||
|
}}
|
||||||
|
onClick={() => handleTaskClick(record.task_id, record.project_id)}
|
||||||
|
aria-label={`${t('tasks.timeLoggedTaskAriaLabel')} ${record.task_name}`}
|
||||||
|
>
|
||||||
|
{/* Clock Icon */}
|
||||||
|
<div style={{
|
||||||
|
color: token.colorSuccess,
|
||||||
|
fontSize: 14,
|
||||||
|
flexShrink: 0
|
||||||
|
}}>
|
||||||
|
<ClockCircleOutlined />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
{/* Task Name */}
|
||||||
|
<div style={{ marginBottom: 2 }}>
|
||||||
|
<Text
|
||||||
|
strong
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
color: token.colorText
|
||||||
|
}}
|
||||||
|
ellipsis={{ tooltip: record.task_name }}
|
||||||
|
>
|
||||||
|
{record.task_name}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Project Name */}
|
||||||
|
<Text
|
||||||
|
type="secondary"
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
lineHeight: 1.2,
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: 4
|
||||||
|
}}
|
||||||
|
ellipsis={{ tooltip: record.project_name }}
|
||||||
|
>
|
||||||
|
{record.project_name}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Side - Time and Status */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
gap: 3,
|
||||||
|
flexShrink: 0
|
||||||
|
}}>
|
||||||
|
{/* Time Logged */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<Tag
|
||||||
|
color="success"
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: 11,
|
||||||
|
padding: '0 6px',
|
||||||
|
height: 18,
|
||||||
|
lineHeight: '16px',
|
||||||
|
borderRadius: 3
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{record.total_time_logged_string}
|
||||||
|
</Tag>
|
||||||
|
{record.logged_by_timer && (
|
||||||
|
<Tag
|
||||||
|
color="processing"
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: 10,
|
||||||
|
padding: '0 4px',
|
||||||
|
height: 16,
|
||||||
|
lineHeight: '14px',
|
||||||
|
borderRadius: 2
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('tasks.timerTag')}
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time Ago */}
|
||||||
|
<Tooltip
|
||||||
|
title={formatDate(record.last_logged_at, 'MMMM Do YYYY, h:mm:ss a')}
|
||||||
|
placement="topRight"
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
type="secondary"
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
lineHeight: 1,
|
||||||
|
color: token.colorTextTertiary
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{fromNow(record.last_logged_at)}
|
||||||
|
</Text>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table
|
||||||
|
className="custom-two-colors-row-table"
|
||||||
|
dataSource={tasks}
|
||||||
|
columns={columns}
|
||||||
|
rowKey="task_id"
|
||||||
|
showHeader={false}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default TimeLoggedTaskList;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
.activity-feed-item:hover {
|
||||||
|
background-color: var(--activity-hover, #fafafa);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-feed-item:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
background-color: var(--activity-active, #f0f0f0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark theme support */
|
||||||
|
[data-theme="dark"] .activity-feed-item:hover {
|
||||||
|
background-color: var(--activity-hover, #262626);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .activity-feed-item:active {
|
||||||
|
background-color: var(--activity-active, #1f1f1f);
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import React, { useMemo, useCallback, useEffect } from 'react';
|
||||||
|
import { Card, Segmented, Skeleton, Empty, Typography, Alert, Button, Tooltip } from '@/shared/antd-imports';
|
||||||
|
import { ClockCircleOutlined, UnorderedListOutlined, SyncOutlined } from '@/shared/antd-imports';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
|
import { ActivityFeedType } from '@/types/home/user-activity.types';
|
||||||
|
import { setActiveTab } from '@/features/home-page/user-activity.slice';
|
||||||
|
import {
|
||||||
|
useGetUserRecentTasksQuery,
|
||||||
|
useGetUserTimeLoggedTasksQuery,
|
||||||
|
} from '@/api/home-page/user-activity.api.service';
|
||||||
|
import TaskActivityList from './task-activity-list';
|
||||||
|
import TimeLoggedTaskList from './time-logged-task-list';
|
||||||
|
|
||||||
|
const { Title } = Typography;
|
||||||
|
|
||||||
|
const UserActivityFeed: React.FC = () => {
|
||||||
|
const { t } = useTranslation('home');
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { activeTab } = useAppSelector(state => state.userActivityReducer);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: recentTasksData,
|
||||||
|
isLoading: loadingRecentTasks,
|
||||||
|
error: recentTasksError,
|
||||||
|
refetch: refetchRecentTasks,
|
||||||
|
} = useGetUserRecentTasksQuery(
|
||||||
|
{ limit: 10 },
|
||||||
|
{
|
||||||
|
skip: false,
|
||||||
|
refetchOnMountOrArgChange: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: timeLoggedTasksData,
|
||||||
|
isLoading: loadingTimeLoggedTasks,
|
||||||
|
error: timeLoggedTasksError,
|
||||||
|
refetch: refetchTimeLoggedTasks,
|
||||||
|
} = useGetUserTimeLoggedTasksQuery(
|
||||||
|
{ limit: 10 },
|
||||||
|
{
|
||||||
|
skip: false,
|
||||||
|
refetchOnMountOrArgChange: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const recentTasks = useMemo(() => {
|
||||||
|
if (!recentTasksData) return [];
|
||||||
|
// Handle both array and object responses from the API
|
||||||
|
if (Array.isArray(recentTasksData)) {
|
||||||
|
return recentTasksData;
|
||||||
|
}
|
||||||
|
// If it's an object with a data property (common API pattern)
|
||||||
|
if (recentTasksData && typeof recentTasksData === 'object' && 'data' in recentTasksData) {
|
||||||
|
const data = (recentTasksData as any).data;
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
}
|
||||||
|
// If it's a different object structure, try to extract tasks
|
||||||
|
if (recentTasksData && typeof recentTasksData === 'object') {
|
||||||
|
const possibleArrays = Object.values(recentTasksData as any).filter(Array.isArray);
|
||||||
|
return possibleArrays.length > 0 ? possibleArrays[0] : [];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}, [recentTasksData]);
|
||||||
|
|
||||||
|
const timeLoggedTasks = useMemo(() => {
|
||||||
|
if (!timeLoggedTasksData) return [];
|
||||||
|
// Handle both array and object responses from the API
|
||||||
|
if (Array.isArray(timeLoggedTasksData)) {
|
||||||
|
return timeLoggedTasksData;
|
||||||
|
}
|
||||||
|
// If it's an object with a data property (common API pattern)
|
||||||
|
if (timeLoggedTasksData && typeof timeLoggedTasksData === 'object' && 'data' in timeLoggedTasksData) {
|
||||||
|
const data = (timeLoggedTasksData as any).data;
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
}
|
||||||
|
// If it's a different object structure, try to extract tasks
|
||||||
|
if (timeLoggedTasksData && typeof timeLoggedTasksData === 'object') {
|
||||||
|
const possibleArrays = Object.values(timeLoggedTasksData as any).filter(Array.isArray);
|
||||||
|
return possibleArrays.length > 0 ? possibleArrays[0] : [];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}, [timeLoggedTasksData]);
|
||||||
|
|
||||||
|
const segmentOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
value: ActivityFeedType.TIME_LOGGED_TASKS,
|
||||||
|
label: (
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<ClockCircleOutlined style={{ fontSize: 14 }} />
|
||||||
|
{t('tasks.timeLoggedSegment')}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: ActivityFeedType.RECENT_TASKS,
|
||||||
|
label: (
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<UnorderedListOutlined style={{ fontSize: 14 }} />
|
||||||
|
{t('tasks.recentTasksSegment')}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTabChange = useCallback(
|
||||||
|
(value: ActivityFeedType) => {
|
||||||
|
dispatch(setActiveTab(value));
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Refetch data when the active tab changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === ActivityFeedType.RECENT_TASKS) {
|
||||||
|
refetchRecentTasks();
|
||||||
|
} else if (activeTab === ActivityFeedType.TIME_LOGGED_TASKS) {
|
||||||
|
refetchTimeLoggedTasks();
|
||||||
|
}
|
||||||
|
}, [activeTab, refetchRecentTasks, refetchTimeLoggedTasks]);
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(() => {
|
||||||
|
if (activeTab === ActivityFeedType.TIME_LOGGED_TASKS) {
|
||||||
|
refetchTimeLoggedTasks();
|
||||||
|
} else {
|
||||||
|
refetchRecentTasks();
|
||||||
|
}
|
||||||
|
}, [activeTab, refetchRecentTasks, refetchTimeLoggedTasks]);
|
||||||
|
|
||||||
|
const isLoading = activeTab === ActivityFeedType.TIME_LOGGED_TASKS ? loadingTimeLoggedTasks : loadingRecentTasks;
|
||||||
|
const currentCount = activeTab === ActivityFeedType.TIME_LOGGED_TASKS ? timeLoggedTasks.length : recentTasks.length;
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
if (activeTab === ActivityFeedType.TIME_LOGGED_TASKS) {
|
||||||
|
if (loadingTimeLoggedTasks) {
|
||||||
|
return <Skeleton active />;
|
||||||
|
}
|
||||||
|
if (timeLoggedTasksError) {
|
||||||
|
return <Alert message={t('tasks.errorLoadingTimeLoggedTasks')} type="error" showIcon />;
|
||||||
|
}
|
||||||
|
if (timeLoggedTasks.length === 0) {
|
||||||
|
return <Empty description={t('tasks.noTimeLoggedTasks')} />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div style={{ maxHeight: 450, overflow: 'auto' }}>
|
||||||
|
<TimeLoggedTaskList tasks={timeLoggedTasks} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (activeTab === ActivityFeedType.RECENT_TASKS) {
|
||||||
|
if (loadingRecentTasks) {
|
||||||
|
return <Skeleton active />;
|
||||||
|
}
|
||||||
|
if (recentTasksError) {
|
||||||
|
return <Alert message={t('tasks.errorLoadingRecentTasks')} type="error" showIcon />;
|
||||||
|
}
|
||||||
|
if (recentTasks.length === 0) {
|
||||||
|
return <Empty description={t('tasks.noRecentTasks')} />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div style={{ maxHeight: 450, overflow: 'auto' }}>
|
||||||
|
<TaskActivityList tasks={recentTasks} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<Typography.Title level={5} style={{ marginBlockEnd: 0 }}>
|
||||||
|
{t('tasks.recentActivity')} ({currentCount})
|
||||||
|
</Typography.Title>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<Tooltip title={t('tasks.refresh')}>
|
||||||
|
<Button
|
||||||
|
shape="circle"
|
||||||
|
icon={<SyncOutlined spin={isLoading} />}
|
||||||
|
onClick={handleRefresh}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
>
|
||||||
|
<Segmented
|
||||||
|
options={segmentOptions}
|
||||||
|
value={activeTab}
|
||||||
|
onChange={handleTabChange}
|
||||||
|
style={{ marginBottom: 16, width: '100%' }}
|
||||||
|
block
|
||||||
|
/>
|
||||||
|
{renderContent()}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(UserActivityFeed);
|
||||||
@@ -802,7 +802,7 @@ const ProjectList: React.FC = () => {
|
|||||||
}, [loadingProjects, isFetchingProjects, viewMode, groupedProjects.loading, isLoading]);
|
}, [loadingProjects, isFetchingProjects, viewMode, groupedProjects.loading, isLoading]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ marginBlock: 65, minHeight: '90vh' }}>
|
<div style={{ minHeight: '90vh' }}>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
className="site-page-header"
|
className="site-page-header"
|
||||||
title={`${projectCount} ${t('projects')}`}
|
title={`${projectCount} ${t('projects')}`}
|
||||||
|
|||||||
@@ -0,0 +1,361 @@
|
|||||||
|
import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react';
|
||||||
|
import { Spin, message } from '@/shared/antd-imports';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import GanttTimeline from './components/gantt-timeline/GanttTimeline';
|
||||||
|
import GanttTaskList from './components/gantt-task-list/GanttTaskList';
|
||||||
|
import GanttChart from './components/gantt-chart/GanttChart';
|
||||||
|
import GanttToolbar from './components/gantt-toolbar/GanttToolbar';
|
||||||
|
import ManagePhaseModal from '@components/task-management/ManagePhaseModal';
|
||||||
|
import PhaseDetailsModal from './components/phase-details-modal/PhaseDetailsModal';
|
||||||
|
import { GanttProvider } from './context/gantt-context';
|
||||||
|
import { GanttViewMode } from './types/gantt-types';
|
||||||
|
import {
|
||||||
|
useGetRoadmapTasksQuery,
|
||||||
|
useGetProjectPhasesQuery,
|
||||||
|
transformToGanttTasks,
|
||||||
|
transformToGanttPhases,
|
||||||
|
} from './services/gantt-api.service';
|
||||||
|
import { TimelineUtils } from './utils/timeline-calculator';
|
||||||
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
|
import {
|
||||||
|
setShowTaskDrawer,
|
||||||
|
setSelectedTaskId,
|
||||||
|
setTaskFormViewModel,
|
||||||
|
fetchTask,
|
||||||
|
} from '@features/task-drawer/task-drawer.slice';
|
||||||
|
import { fetchPriorities } from '@/features/taskAttributes/taskPrioritySlice';
|
||||||
|
import { DEFAULT_TASK_NAME } from '@/shared/constants';
|
||||||
|
import './gantt-styles.css';
|
||||||
|
|
||||||
|
const ProjectViewGantt: React.FC = React.memo(() => {
|
||||||
|
const { projectId } = useParams<{ projectId: string }>();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const [viewMode, setViewMode] = useState<GanttViewMode>('month');
|
||||||
|
const [showPhaseModal, setShowPhaseModal] = useState(false);
|
||||||
|
const [showPhaseDetailsModal, setShowPhaseDetailsModal] = useState(false);
|
||||||
|
const [selectedPhase, setSelectedPhase] = useState<any>(null);
|
||||||
|
const [expandedTasks, setExpandedTasks] = useState<Set<string>>(new Set());
|
||||||
|
const [animatingTasks, setAnimatingTasks] = useState<Set<string>>(new Set());
|
||||||
|
const [prevExpandedTasks, setPrevExpandedTasks] = useState<Set<string>>(new Set());
|
||||||
|
const timelineRef = useRef<HTMLDivElement>(null);
|
||||||
|
const chartRef = useRef<HTMLDivElement>(null);
|
||||||
|
const taskListRef = useRef<HTMLDivElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// RTK Query hooks
|
||||||
|
const {
|
||||||
|
data: tasksResponse,
|
||||||
|
error: tasksError,
|
||||||
|
isLoading: tasksLoading,
|
||||||
|
refetch: refetchTasks,
|
||||||
|
} = useGetRoadmapTasksQuery({ projectId: projectId || '' }, { skip: !projectId });
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: phasesResponse,
|
||||||
|
error: phasesError,
|
||||||
|
isLoading: phasesLoading,
|
||||||
|
refetch: refetchPhases,
|
||||||
|
} = useGetProjectPhasesQuery({ projectId: projectId || '' }, { skip: !projectId });
|
||||||
|
|
||||||
|
// Transform API data to component format
|
||||||
|
const tasks = useMemo(() => {
|
||||||
|
if (tasksResponse?.body && phasesResponse?.body) {
|
||||||
|
const transformedTasks = transformToGanttTasks(tasksResponse.body, phasesResponse.body);
|
||||||
|
const result: any[] = [];
|
||||||
|
|
||||||
|
transformedTasks.forEach(task => {
|
||||||
|
// Always show phase milestones
|
||||||
|
if (task.type === 'milestone' || task.is_milestone) {
|
||||||
|
result.push(task);
|
||||||
|
|
||||||
|
// If this phase is expanded, show its children tasks
|
||||||
|
const phaseId =
|
||||||
|
task.id === 'phase-unmapped'
|
||||||
|
? 'unmapped'
|
||||||
|
: task.phase_id || task.id.replace('phase-', '');
|
||||||
|
const isExpanded = expandedTasks.has(phaseId);
|
||||||
|
|
||||||
|
if (isExpanded && task.children) {
|
||||||
|
task.children.forEach((child: any) => {
|
||||||
|
result.push({
|
||||||
|
...child,
|
||||||
|
phase_id: task.phase_id, // Ensure child has correct phase_id
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}, [tasksResponse, phasesResponse, expandedTasks]);
|
||||||
|
|
||||||
|
const phases = useMemo(() => {
|
||||||
|
if (phasesResponse?.body) {
|
||||||
|
return transformToGanttPhases(phasesResponse.body);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}, [phasesResponse]);
|
||||||
|
|
||||||
|
// Calculate date range based on tasks
|
||||||
|
const dateRange = useMemo(() => {
|
||||||
|
if (tasks.length > 0) {
|
||||||
|
return TimelineUtils.getSmartDateRange(tasks, viewMode);
|
||||||
|
}
|
||||||
|
return { start: new Date(), end: new Date() };
|
||||||
|
}, [tasks, viewMode]);
|
||||||
|
|
||||||
|
const loading = tasksLoading || phasesLoading;
|
||||||
|
|
||||||
|
// Load priorities for task drawer functionality
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(fetchPriorities());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
// Track expansion changes for animations
|
||||||
|
useEffect(() => {
|
||||||
|
const currentExpanded = expandedTasks;
|
||||||
|
const previousExpanded = prevExpandedTasks;
|
||||||
|
|
||||||
|
// Find newly expanded or collapsed phases
|
||||||
|
const newlyExpanded = new Set([...currentExpanded].filter(id => !previousExpanded.has(id)));
|
||||||
|
const newlyCollapsed = new Set([...previousExpanded].filter(id => !currentExpanded.has(id)));
|
||||||
|
|
||||||
|
if (newlyExpanded.size > 0 || newlyCollapsed.size > 0) {
|
||||||
|
// Set animation state for newly changed phases
|
||||||
|
setAnimatingTasks(new Set([...newlyExpanded, ...newlyCollapsed]));
|
||||||
|
|
||||||
|
// Clear animation state after animation completes
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
setAnimatingTasks(new Set());
|
||||||
|
}, 400); // Match CSS animation duration
|
||||||
|
|
||||||
|
setPrevExpandedTasks(new Set(currentExpanded));
|
||||||
|
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}, [expandedTasks, prevExpandedTasks]);
|
||||||
|
|
||||||
|
const handleViewModeChange = useCallback((mode: GanttViewMode) => {
|
||||||
|
setViewMode(mode);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleChartScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||||
|
const target = e.target as HTMLDivElement;
|
||||||
|
|
||||||
|
// Sync horizontal scroll with timeline
|
||||||
|
if (timelineRef.current) {
|
||||||
|
timelineRef.current.scrollLeft = target.scrollLeft;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync vertical scroll with task list
|
||||||
|
if (taskListRef.current) {
|
||||||
|
taskListRef.current.scrollTop = target.scrollTop;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleTaskListScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||||
|
const target = e.target as HTMLDivElement;
|
||||||
|
|
||||||
|
// Sync vertical scroll with chart
|
||||||
|
if (chartRef.current) {
|
||||||
|
chartRef.current.scrollTop = target.scrollTop;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(() => {
|
||||||
|
refetchTasks();
|
||||||
|
refetchPhases();
|
||||||
|
}, [refetchTasks, refetchPhases]);
|
||||||
|
|
||||||
|
const handleCreatePhase = useCallback(() => {
|
||||||
|
setShowPhaseModal(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCreateTask = useCallback(
|
||||||
|
(phaseId?: string) => {
|
||||||
|
// Create a new task using the task drawer
|
||||||
|
const newTaskViewModel = {
|
||||||
|
id: null,
|
||||||
|
name: DEFAULT_TASK_NAME,
|
||||||
|
project_id: projectId,
|
||||||
|
phase_id: phaseId || null,
|
||||||
|
// Add other default properties as needed
|
||||||
|
};
|
||||||
|
|
||||||
|
dispatch(setSelectedTaskId(null));
|
||||||
|
dispatch(setTaskFormViewModel(newTaskViewModel));
|
||||||
|
dispatch(setShowTaskDrawer(true));
|
||||||
|
},
|
||||||
|
[dispatch, projectId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTaskClick = useCallback(
|
||||||
|
(taskId: string) => {
|
||||||
|
// Open existing task in the task drawer
|
||||||
|
dispatch(setSelectedTaskId(taskId));
|
||||||
|
dispatch(setTaskFormViewModel(null)); // Clear form view model for existing task
|
||||||
|
dispatch(setShowTaskDrawer(true));
|
||||||
|
|
||||||
|
// Fetch the complete task data including priorities
|
||||||
|
if (projectId) {
|
||||||
|
dispatch(fetchTask({ taskId, projectId }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[dispatch, projectId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClosePhaseModal = useCallback(() => {
|
||||||
|
setShowPhaseModal(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePhaseClick = useCallback((phase: any) => {
|
||||||
|
setSelectedPhase(phase);
|
||||||
|
setShowPhaseDetailsModal(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClosePhaseDetailsModal = useCallback(() => {
|
||||||
|
setShowPhaseDetailsModal(false);
|
||||||
|
setSelectedPhase(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePhaseUpdate = useCallback(
|
||||||
|
(updatedPhase: any) => {
|
||||||
|
// Refresh the data after phase update
|
||||||
|
refetchTasks();
|
||||||
|
refetchPhases();
|
||||||
|
},
|
||||||
|
[refetchTasks, refetchPhases]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePhaseReorder = useCallback((oldIndex: number, newIndex: number) => {
|
||||||
|
// TODO: Implement phase reordering API call
|
||||||
|
console.log('Reorder phases:', { oldIndex, newIndex });
|
||||||
|
message.info('Phase reordering will be implemented with the backend API');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCreateQuickTask = useCallback(
|
||||||
|
(taskName: string, phaseId?: string, startDate?: Date) => {
|
||||||
|
// For now, just refresh the Gantt data after task creation
|
||||||
|
// The actual task creation will happen through existing mechanisms
|
||||||
|
// and the refresh will show the new task
|
||||||
|
console.log('Task created:', { taskName, phaseId, startDate });
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
message.success(`Task "${taskName}" created successfully`);
|
||||||
|
|
||||||
|
// Refresh the Gantt data to show the new task
|
||||||
|
refetchTasks();
|
||||||
|
refetchPhases();
|
||||||
|
},
|
||||||
|
[refetchTasks, refetchPhases]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle errors
|
||||||
|
if (tasksError || phasesError) {
|
||||||
|
message.error('Failed to load Gantt chart data');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center h-full w-full">
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GanttProvider
|
||||||
|
value={{
|
||||||
|
tasks,
|
||||||
|
phases,
|
||||||
|
viewMode,
|
||||||
|
projectId: projectId || '',
|
||||||
|
dateRange,
|
||||||
|
onRefresh: handleRefresh,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex flex-col h-full w-full bg-gray-50 dark:bg-gray-900"
|
||||||
|
style={{ height: 'calc(100vh - 64px)' }}
|
||||||
|
>
|
||||||
|
<GanttToolbar
|
||||||
|
viewMode={viewMode}
|
||||||
|
onViewModeChange={handleViewModeChange}
|
||||||
|
dateRange={dateRange}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-1 overflow-hidden border border-gray-200 dark:border-gray-700 rounded-md bg-white dark:bg-gray-800">
|
||||||
|
<div className="relative flex w-full h-full">
|
||||||
|
{/* Fixed Task List - positioned absolutely to avoid scrollbar interference */}
|
||||||
|
<div className="absolute left-0 top-0 bottom-0 z-20 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700">
|
||||||
|
<GanttTaskList
|
||||||
|
tasks={tasks}
|
||||||
|
projectId={projectId || ''}
|
||||||
|
viewMode={viewMode}
|
||||||
|
onTaskClick={handleTaskClick}
|
||||||
|
onPhaseClick={handlePhaseClick}
|
||||||
|
onCreateTask={handleCreateTask}
|
||||||
|
onCreateQuickTask={handleCreateQuickTask}
|
||||||
|
onCreatePhase={handleCreatePhase}
|
||||||
|
onPhaseReorder={handlePhaseReorder}
|
||||||
|
ref={taskListRef}
|
||||||
|
onScroll={handleTaskListScroll}
|
||||||
|
expandedTasks={expandedTasks}
|
||||||
|
onExpandedTasksChange={setExpandedTasks}
|
||||||
|
animatingTasks={animatingTasks}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable Timeline and Chart - with left margin for task list */}
|
||||||
|
<div
|
||||||
|
className="flex-1 flex flex-col overflow-hidden gantt-timeline-container"
|
||||||
|
style={{ marginLeft: '444px' }}
|
||||||
|
ref={containerRef}
|
||||||
|
>
|
||||||
|
<GanttTimeline
|
||||||
|
viewMode={viewMode}
|
||||||
|
ref={timelineRef}
|
||||||
|
containerRef={containerRef}
|
||||||
|
dateRange={dateRange}
|
||||||
|
/>
|
||||||
|
<GanttChart
|
||||||
|
tasks={tasks}
|
||||||
|
viewMode={viewMode}
|
||||||
|
ref={chartRef}
|
||||||
|
onScroll={handleChartScroll}
|
||||||
|
onPhaseClick={handlePhaseClick}
|
||||||
|
containerRef={containerRef}
|
||||||
|
dateRange={dateRange}
|
||||||
|
phases={phases}
|
||||||
|
expandedTasks={expandedTasks}
|
||||||
|
animatingTasks={animatingTasks}
|
||||||
|
onCreateQuickTask={handleCreateQuickTask}
|
||||||
|
projectId={projectId || ''}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Phase Management Modal */}
|
||||||
|
<ManagePhaseModal
|
||||||
|
open={showPhaseModal}
|
||||||
|
onClose={handleClosePhaseModal}
|
||||||
|
projectId={projectId}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Phase Details Modal */}
|
||||||
|
<PhaseDetailsModal
|
||||||
|
open={showPhaseDetailsModal}
|
||||||
|
onClose={handleClosePhaseDetailsModal}
|
||||||
|
phase={selectedPhase}
|
||||||
|
onPhaseUpdate={handlePhaseUpdate}
|
||||||
|
/>
|
||||||
|
</GanttProvider>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
ProjectViewGantt.displayName = 'ProjectViewGantt';
|
||||||
|
|
||||||
|
export default ProjectViewGantt;
|
||||||
@@ -0,0 +1,819 @@
|
|||||||
|
import React, { memo, useMemo, forwardRef, RefObject, useState, useCallback } from 'react';
|
||||||
|
import ReactDOM from 'react-dom';
|
||||||
|
import { Input } from 'antd';
|
||||||
|
import { GanttTask, GanttViewMode, GanttPhase } from '../../types/gantt-types';
|
||||||
|
import { useGanttDimensions } from '../../hooks/useGanttDimensions';
|
||||||
|
|
||||||
|
// Utility function to add alpha channel to hex color
|
||||||
|
const addAlphaToHex = (hex: string, alpha: number): string => {
|
||||||
|
// Remove # if present
|
||||||
|
const cleanHex = hex.replace('#', '');
|
||||||
|
|
||||||
|
// Convert hex to RGB
|
||||||
|
const r = parseInt(cleanHex.substring(0, 2), 16);
|
||||||
|
const g = parseInt(cleanHex.substring(2, 4), 16);
|
||||||
|
const b = parseInt(cleanHex.substring(4, 6), 16);
|
||||||
|
|
||||||
|
// Return rgba string
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface GanttChartProps {
|
||||||
|
tasks: GanttTask[];
|
||||||
|
viewMode: GanttViewMode;
|
||||||
|
onScroll?: (e: React.UIEvent<HTMLDivElement>) => void;
|
||||||
|
onPhaseClick?: (phase: GanttTask) => void;
|
||||||
|
containerRef: RefObject<HTMLDivElement | null>;
|
||||||
|
dateRange?: { start: Date; end: Date };
|
||||||
|
phases?: GanttPhase[];
|
||||||
|
expandedTasks?: Set<string>;
|
||||||
|
animatingTasks?: Set<string>;
|
||||||
|
onCreateQuickTask?: (taskName: string, phaseId?: string, startDate?: Date) => void;
|
||||||
|
projectId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GridColumnProps {
|
||||||
|
index: number;
|
||||||
|
columnWidth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GridColumn: React.FC<GridColumnProps> = memo(({ index, columnWidth }) => (
|
||||||
|
<div
|
||||||
|
className={`border-r border-gray-100 dark:border-gray-700 flex-shrink-0 h-full ${
|
||||||
|
index % 2 === 1 ? 'bg-gray-50 dark:bg-gray-850' : ''
|
||||||
|
}`}
|
||||||
|
style={{ width: `${columnWidth}px` }}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
|
||||||
|
GridColumn.displayName = 'GridColumn';
|
||||||
|
|
||||||
|
interface TaskBarRowProps {
|
||||||
|
task: GanttTask;
|
||||||
|
viewMode: GanttViewMode;
|
||||||
|
columnWidth: number;
|
||||||
|
columnsCount: number;
|
||||||
|
dateRange?: { start: Date; end: Date };
|
||||||
|
animationClass?: string;
|
||||||
|
onPhaseClick?: (phase: GanttTask) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TaskBarRow: React.FC<TaskBarRowProps> = memo(
|
||||||
|
({ task, viewMode, columnWidth, columnsCount, dateRange, animationClass = '', onPhaseClick }) => {
|
||||||
|
const renderMilestone = () => {
|
||||||
|
if (!task.start_date || !dateRange) return null;
|
||||||
|
|
||||||
|
// Calculate position for milestone diamond based on view mode
|
||||||
|
const totalTimeSpan = dateRange.end.getTime() - dateRange.start.getTime();
|
||||||
|
const timeFromStart = task.start_date.getTime() - dateRange.start.getTime();
|
||||||
|
const left = Math.max(0, (timeFromStart / totalTimeSpan) * (columnsCount * columnWidth));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute top-1/2 transform -translate-y-1/2 -translate-x-1/2 w-4 h-4 rotate-45 z-10 shadow-sm"
|
||||||
|
style={{
|
||||||
|
left: `${left}px`,
|
||||||
|
backgroundColor: task.color || '#3b82f6',
|
||||||
|
}}
|
||||||
|
title={`${task.name} - ${task.start_date.toLocaleDateString()}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderTaskBar = () => {
|
||||||
|
if (!task.start_date || !task.end_date || !dateRange) return null;
|
||||||
|
|
||||||
|
// Calculate position and width for task bar based on time ratios
|
||||||
|
const totalTimeSpan = dateRange.end.getTime() - dateRange.start.getTime();
|
||||||
|
const timeFromStart = task.start_date.getTime() - dateRange.start.getTime();
|
||||||
|
const taskDuration = task.end_date.getTime() - task.start_date.getTime();
|
||||||
|
|
||||||
|
const totalWidth = columnsCount * columnWidth;
|
||||||
|
const left = Math.max(0, (timeFromStart / totalTimeSpan) * totalWidth);
|
||||||
|
const width = Math.max(10, (taskDuration / totalTimeSpan) * totalWidth);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute top-1/2 transform -translate-y-1/2 h-6 rounded flex items-center px-2 text-xs text-white font-medium shadow-sm"
|
||||||
|
style={{
|
||||||
|
left: `${left}px`,
|
||||||
|
width: `${width}px`,
|
||||||
|
backgroundColor: task.color || '#6b7280',
|
||||||
|
}}
|
||||||
|
title={`${task.name} - ${task.start_date.toLocaleDateString()} to ${task.end_date.toLocaleDateString()}`}
|
||||||
|
>
|
||||||
|
<div className="truncate">{task.name}</div>
|
||||||
|
{task.progress > 0 && (
|
||||||
|
<div
|
||||||
|
className="absolute top-0 left-0 h-full bg-black bg-opacity-20 rounded"
|
||||||
|
style={{ width: `${task.progress}%` }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isPhase = task.type === 'milestone' || task.is_milestone;
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (isPhase && onPhaseClick) {
|
||||||
|
onPhaseClick(task);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${isPhase ? 'min-h-[4.5rem]' : 'h-9'} relative border-b border-gray-100 dark:border-gray-700 transition-colors ${
|
||||||
|
!isPhase ? 'hover:bg-gray-50 dark:hover:bg-gray-750' : onPhaseClick ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-750' : ''
|
||||||
|
} ${animationClass}`}
|
||||||
|
onClick={isPhase && onPhaseClick ? handleClick : undefined}
|
||||||
|
style={{
|
||||||
|
...(isPhase && task.color ? { backgroundColor: addAlphaToHex(task.color, 0.15) } : {}),
|
||||||
|
// Set lower z-index when no phase click handler so parent can receive clicks
|
||||||
|
...(isPhase && !onPhaseClick ? { position: 'relative', zIndex: 1 } : {}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isPhase ? renderMilestone() : renderTaskBar()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
TaskBarRow.displayName = 'TaskBarRow';
|
||||||
|
|
||||||
|
// Task Creation Popover Component
|
||||||
|
const TaskCreationPopover: React.FC<{
|
||||||
|
taskPopover: {
|
||||||
|
taskName: string;
|
||||||
|
date: Date;
|
||||||
|
phaseId: string | null;
|
||||||
|
position: { x: number; y: number };
|
||||||
|
visible: boolean;
|
||||||
|
};
|
||||||
|
onTaskNameChange: (name: string) => void;
|
||||||
|
onCreateTask: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}> = ({ taskPopover, onTaskNameChange, onCreateTask, onCancel }) => {
|
||||||
|
if (!taskPopover.visible) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ReactDOM.createPortal(
|
||||||
|
<>
|
||||||
|
{/* Click outside overlay to close popover */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[9999] bg-black/5"
|
||||||
|
onClick={onCancel}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Popover */}
|
||||||
|
<div
|
||||||
|
className="fixed z-[10000]"
|
||||||
|
style={{
|
||||||
|
left: `${taskPopover.position.x - 100}px`,
|
||||||
|
top: `${taskPopover.position.y - 30}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg p-3 min-w-[250px]">
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 mb-2">
|
||||||
|
Add task for {taskPopover.date.toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
value={taskPopover.taskName}
|
||||||
|
onChange={(e) => onTaskNameChange(e.target.value)}
|
||||||
|
onPressEnter={onCreateTask}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Enter task name..."
|
||||||
|
autoFocus
|
||||||
|
size="small"
|
||||||
|
className="mb-2"
|
||||||
|
/>
|
||||||
|
<div className="text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
Press Enter to create • Esc to cancel
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const GanttChart = forwardRef<HTMLDivElement, GanttChartProps>(
|
||||||
|
({ tasks, viewMode, onScroll, onPhaseClick, containerRef, dateRange, phases, expandedTasks, animatingTasks, onCreateQuickTask, projectId }, ref) => {
|
||||||
|
|
||||||
|
// State for popover task creation
|
||||||
|
const [taskPopover, setTaskPopover] = useState<{
|
||||||
|
taskName: string;
|
||||||
|
date: Date;
|
||||||
|
phaseId: string | null;
|
||||||
|
position: { x: number; y: number };
|
||||||
|
visible: boolean;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const columnsCount = useMemo(() => {
|
||||||
|
if (!dateRange) {
|
||||||
|
// Default counts if no date range
|
||||||
|
switch (viewMode) {
|
||||||
|
case 'day':
|
||||||
|
return 30;
|
||||||
|
case 'week':
|
||||||
|
return 12;
|
||||||
|
case 'month':
|
||||||
|
return 12;
|
||||||
|
case 'quarter':
|
||||||
|
return 8;
|
||||||
|
case 'year':
|
||||||
|
return 5;
|
||||||
|
default:
|
||||||
|
return 12;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { start, end } = dateRange;
|
||||||
|
const diffTime = Math.abs(end.getTime() - start.getTime());
|
||||||
|
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
let baseColumnsCount = 0;
|
||||||
|
|
||||||
|
switch (viewMode) {
|
||||||
|
case 'day':
|
||||||
|
baseColumnsCount = diffDays;
|
||||||
|
break;
|
||||||
|
case 'week':
|
||||||
|
baseColumnsCount = Math.ceil(diffDays / 7);
|
||||||
|
break;
|
||||||
|
case 'month':
|
||||||
|
const startYear = start.getFullYear();
|
||||||
|
const startMonth = start.getMonth();
|
||||||
|
const endYear = end.getFullYear();
|
||||||
|
const endMonth = end.getMonth();
|
||||||
|
baseColumnsCount = (endYear - startYear) * 12 + (endMonth - startMonth) + 1;
|
||||||
|
break;
|
||||||
|
case 'quarter':
|
||||||
|
const qStartYear = start.getFullYear();
|
||||||
|
const qStartQuarter = Math.ceil((start.getMonth() + 1) / 3);
|
||||||
|
const qEndYear = end.getFullYear();
|
||||||
|
const qEndQuarter = Math.ceil((end.getMonth() + 1) / 3);
|
||||||
|
baseColumnsCount = (qEndYear - qStartYear) * 4 + (qEndQuarter - qStartQuarter) + 1;
|
||||||
|
break;
|
||||||
|
case 'year':
|
||||||
|
baseColumnsCount = end.getFullYear() - start.getFullYear() + 1;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
baseColumnsCount = 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseColumnsCount;
|
||||||
|
}, [viewMode, dateRange]);
|
||||||
|
|
||||||
|
// Calculate exact date from mouse position within timeline columns
|
||||||
|
const calculateDateFromPosition = useCallback((x: number, columnWidth: number): Date => {
|
||||||
|
if (!dateRange) return new Date();
|
||||||
|
|
||||||
|
// Calculate which column was clicked and position within that column
|
||||||
|
const columnIndex = Math.floor(x / columnWidth);
|
||||||
|
const positionWithinColumn = (x % columnWidth) / columnWidth; // 0 to 1
|
||||||
|
|
||||||
|
const { start, end } = dateRange;
|
||||||
|
let targetDate = new Date(start);
|
||||||
|
|
||||||
|
// Handle virtual columns beyond the actual date range
|
||||||
|
const actualColumnsInRange = columnsCount;
|
||||||
|
const isVirtualColumn = columnIndex >= actualColumnsInRange;
|
||||||
|
|
||||||
|
// If it's a virtual column, extend the date by calculating based on the end date
|
||||||
|
if (isVirtualColumn) {
|
||||||
|
const virtualColumnIndex = columnIndex - actualColumnsInRange;
|
||||||
|
targetDate = new Date(end);
|
||||||
|
|
||||||
|
switch (viewMode) {
|
||||||
|
case 'day':
|
||||||
|
targetDate.setDate(targetDate.getDate() + virtualColumnIndex + 1);
|
||||||
|
targetDate.setHours(Math.min(Math.floor(positionWithinColumn * 24), 23), 0, 0, 0);
|
||||||
|
break;
|
||||||
|
case 'week':
|
||||||
|
targetDate.setDate(targetDate.getDate() + (virtualColumnIndex + 1) * 7);
|
||||||
|
const dayWithinVirtualWeek = Math.min(Math.floor(positionWithinColumn * 7), 6);
|
||||||
|
targetDate.setDate(targetDate.getDate() + dayWithinVirtualWeek);
|
||||||
|
targetDate.setHours(0, 0, 0, 0);
|
||||||
|
break;
|
||||||
|
case 'month':
|
||||||
|
targetDate.setMonth(targetDate.getMonth() + virtualColumnIndex + 1);
|
||||||
|
const daysInVirtualMonth = new Date(targetDate.getFullYear(), targetDate.getMonth() + 1, 0).getDate();
|
||||||
|
const dayWithinVirtualMonth = Math.max(1, Math.min(Math.ceil(positionWithinColumn * daysInVirtualMonth), daysInVirtualMonth));
|
||||||
|
targetDate.setDate(dayWithinVirtualMonth);
|
||||||
|
targetDate.setHours(0, 0, 0, 0);
|
||||||
|
break;
|
||||||
|
case 'quarter':
|
||||||
|
const quartersToAdd = virtualColumnIndex + 1;
|
||||||
|
targetDate.setMonth(targetDate.getMonth() + (quartersToAdd * 3));
|
||||||
|
const quarterStartMonth = Math.floor(targetDate.getMonth() / 3) * 3;
|
||||||
|
targetDate.setMonth(quarterStartMonth, 1);
|
||||||
|
const quarterEndDate = new Date(targetDate.getFullYear(), quarterStartMonth + 3, 0);
|
||||||
|
const daysInVirtualQuarter = Math.floor((quarterEndDate.getTime() - targetDate.getTime()) / (1000 * 60 * 60 * 24)) + 1;
|
||||||
|
const dayWithinVirtualQuarter = Math.min(Math.floor(positionWithinColumn * daysInVirtualQuarter), daysInVirtualQuarter - 1);
|
||||||
|
targetDate.setDate(targetDate.getDate() + dayWithinVirtualQuarter);
|
||||||
|
targetDate.setHours(0, 0, 0, 0);
|
||||||
|
break;
|
||||||
|
case 'year':
|
||||||
|
targetDate.setFullYear(targetDate.getFullYear() + virtualColumnIndex + 1);
|
||||||
|
const isLeapYear = (targetDate.getFullYear() % 4 === 0 && targetDate.getFullYear() % 100 !== 0) || (targetDate.getFullYear() % 400 === 0);
|
||||||
|
const daysInVirtualYear = isLeapYear ? 366 : 365;
|
||||||
|
const dayWithinVirtualYear = Math.min(Math.floor(positionWithinColumn * daysInVirtualYear), daysInVirtualYear - 1);
|
||||||
|
targetDate = new Date(targetDate.getFullYear(), 0, 1 + dayWithinVirtualYear);
|
||||||
|
targetDate.setHours(0, 0, 0, 0);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
targetDate.setDate(targetDate.getDate() + virtualColumnIndex + 1);
|
||||||
|
targetDate.setHours(0, 0, 0, 0);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return targetDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (viewMode) {
|
||||||
|
case 'day':
|
||||||
|
// Timeline shows individual days - each column is one day
|
||||||
|
const dayStart = new Date(start);
|
||||||
|
const dayDates: Date[] = [];
|
||||||
|
const tempDayDate = new Date(dayStart);
|
||||||
|
while (tempDayDate <= end && dayDates.length <= columnIndex) {
|
||||||
|
dayDates.push(new Date(tempDayDate));
|
||||||
|
tempDayDate.setDate(tempDayDate.getDate() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dayDates[columnIndex]) {
|
||||||
|
targetDate = new Date(dayDates[columnIndex]);
|
||||||
|
// For day view, add hours based on position within column (0-23 hours)
|
||||||
|
const hour = Math.min(Math.floor(positionWithinColumn * 24), 23);
|
||||||
|
targetDate.setHours(hour, 0, 0, 0);
|
||||||
|
} else if (dayDates.length > 0) {
|
||||||
|
// Fallback to last available day if index is out of bounds
|
||||||
|
targetDate = new Date(dayDates[dayDates.length - 1]);
|
||||||
|
targetDate.setHours(23, 59, 59, 999);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'week':
|
||||||
|
// Timeline shows weeks - calculate specific day within the week
|
||||||
|
const weekStart = new Date(start);
|
||||||
|
weekStart.setDate(weekStart.getDate() - weekStart.getDay()); // Start of week (Sunday)
|
||||||
|
|
||||||
|
const weekDates: Date[] = [];
|
||||||
|
const tempWeekDate = new Date(weekStart);
|
||||||
|
while (tempWeekDate <= end && weekDates.length <= columnIndex) {
|
||||||
|
weekDates.push(new Date(tempWeekDate));
|
||||||
|
tempWeekDate.setDate(tempWeekDate.getDate() + 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (weekDates[columnIndex]) {
|
||||||
|
targetDate = new Date(weekDates[columnIndex]);
|
||||||
|
// Add days within the week (0-6 days from Sunday)
|
||||||
|
const dayWithinWeek = Math.min(Math.floor(positionWithinColumn * 7), 6);
|
||||||
|
targetDate.setDate(targetDate.getDate() + dayWithinWeek);
|
||||||
|
targetDate.setHours(0, 0, 0, 0);
|
||||||
|
} else if (weekDates.length > 0) {
|
||||||
|
// Fallback to last available week if index is out of bounds
|
||||||
|
targetDate = new Date(weekDates[weekDates.length - 1]);
|
||||||
|
targetDate.setDate(targetDate.getDate() + 6); // End of week
|
||||||
|
targetDate.setHours(23, 59, 59, 999);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'month':
|
||||||
|
// Timeline shows months - calculate specific day within the month
|
||||||
|
const startYear = start.getFullYear();
|
||||||
|
const startMonth = start.getMonth();
|
||||||
|
const endYear = end.getFullYear();
|
||||||
|
const endMonth = end.getMonth();
|
||||||
|
|
||||||
|
const monthDates: Date[] = [];
|
||||||
|
let currentYear = startYear;
|
||||||
|
let currentMonth = startMonth;
|
||||||
|
|
||||||
|
while ((currentYear < endYear || (currentYear === endYear && currentMonth <= endMonth))
|
||||||
|
&& monthDates.length <= columnIndex) {
|
||||||
|
monthDates.push(new Date(currentYear, currentMonth, 1));
|
||||||
|
currentMonth++;
|
||||||
|
if (currentMonth > 11) {
|
||||||
|
currentMonth = 0;
|
||||||
|
currentYear++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (monthDates[columnIndex]) {
|
||||||
|
targetDate = new Date(monthDates[columnIndex]);
|
||||||
|
// Calculate days in this month
|
||||||
|
const daysInMonth = new Date(targetDate.getFullYear(), targetDate.getMonth() + 1, 0).getDate();
|
||||||
|
// Add days within the month (1-daysInMonth)
|
||||||
|
const dayWithinMonth = Math.max(1, Math.min(Math.ceil(positionWithinColumn * daysInMonth), daysInMonth));
|
||||||
|
targetDate.setDate(dayWithinMonth);
|
||||||
|
targetDate.setHours(0, 0, 0, 0);
|
||||||
|
} else if (monthDates.length > 0) {
|
||||||
|
// Fallback to last available month if index is out of bounds
|
||||||
|
targetDate = new Date(monthDates[monthDates.length - 1]);
|
||||||
|
const daysInMonth = new Date(targetDate.getFullYear(), targetDate.getMonth() + 1, 0).getDate();
|
||||||
|
targetDate.setDate(daysInMonth);
|
||||||
|
targetDate.setHours(23, 59, 59, 999);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'quarter':
|
||||||
|
// Timeline shows quarters - calculate specific month and day within quarter
|
||||||
|
const qStartYear = start.getFullYear();
|
||||||
|
const qStartQuarter = Math.ceil((start.getMonth() + 1) / 3);
|
||||||
|
const qEndYear = end.getFullYear();
|
||||||
|
const qEndQuarter = Math.ceil((end.getMonth() + 1) / 3);
|
||||||
|
|
||||||
|
const quarterDates: Date[] = [];
|
||||||
|
let qYear = qStartYear;
|
||||||
|
let qQuarter = qStartQuarter;
|
||||||
|
|
||||||
|
while ((qYear < qEndYear || (qYear === qEndYear && qQuarter <= qEndQuarter))
|
||||||
|
&& quarterDates.length <= columnIndex) {
|
||||||
|
const quarterStartMonth = (qQuarter - 1) * 3;
|
||||||
|
quarterDates.push(new Date(qYear, quarterStartMonth, 1));
|
||||||
|
|
||||||
|
qQuarter++;
|
||||||
|
if (qQuarter > 4) {
|
||||||
|
qQuarter = 1;
|
||||||
|
qYear++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quarterDates[columnIndex]) {
|
||||||
|
targetDate = new Date(quarterDates[columnIndex]);
|
||||||
|
// Calculate exact days in this quarter
|
||||||
|
const quarterStartMonth = targetDate.getMonth();
|
||||||
|
const quarterEndMonth = Math.min(quarterStartMonth + 2, 11);
|
||||||
|
const quarterEndDate = new Date(targetDate.getFullYear(), quarterEndMonth + 1, 0);
|
||||||
|
const daysInQuarter = Math.floor((quarterEndDate.getTime() - targetDate.getTime()) / (1000 * 60 * 60 * 24)) + 1;
|
||||||
|
|
||||||
|
const dayWithinQuarter = Math.min(Math.floor(positionWithinColumn * daysInQuarter), daysInQuarter - 1);
|
||||||
|
targetDate.setDate(targetDate.getDate() + dayWithinQuarter);
|
||||||
|
targetDate.setHours(0, 0, 0, 0);
|
||||||
|
} else if (quarterDates.length > 0) {
|
||||||
|
// Fallback to last available quarter if index is out of bounds
|
||||||
|
targetDate = new Date(quarterDates[quarterDates.length - 1]);
|
||||||
|
const quarterStartMonth = targetDate.getMonth();
|
||||||
|
const quarterEndMonth = Math.min(quarterStartMonth + 2, 11);
|
||||||
|
targetDate.setMonth(quarterEndMonth);
|
||||||
|
const daysInMonth = new Date(targetDate.getFullYear(), quarterEndMonth + 1, 0).getDate();
|
||||||
|
targetDate.setDate(daysInMonth);
|
||||||
|
targetDate.setHours(23, 59, 59, 999);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'year':
|
||||||
|
// Timeline shows years - calculate specific month and day within year
|
||||||
|
const yearStart = start.getFullYear();
|
||||||
|
const yearEnd = end.getFullYear();
|
||||||
|
|
||||||
|
const yearDates: Date[] = [];
|
||||||
|
for (let year = yearStart; year <= yearEnd && yearDates.length <= columnIndex; year++) {
|
||||||
|
yearDates.push(new Date(year, 0, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (yearDates[columnIndex]) {
|
||||||
|
targetDate = new Date(yearDates[columnIndex]);
|
||||||
|
// Calculate exact days in this year
|
||||||
|
const isLeapYear = (targetDate.getFullYear() % 4 === 0 && targetDate.getFullYear() % 100 !== 0) || (targetDate.getFullYear() % 400 === 0);
|
||||||
|
const daysInYear = isLeapYear ? 366 : 365;
|
||||||
|
const dayWithinYear = Math.min(Math.floor(positionWithinColumn * daysInYear), daysInYear - 1);
|
||||||
|
|
||||||
|
// Add days carefully to avoid month overflow
|
||||||
|
const tempDate = new Date(targetDate.getFullYear(), 0, 1 + dayWithinYear);
|
||||||
|
targetDate = tempDate;
|
||||||
|
targetDate.setHours(0, 0, 0, 0);
|
||||||
|
} else if (yearDates.length > 0) {
|
||||||
|
// Fallback to last available year if index is out of bounds
|
||||||
|
targetDate = new Date(yearDates[yearDates.length - 1]);
|
||||||
|
targetDate.setMonth(11, 31); // December 31st
|
||||||
|
targetDate.setHours(23, 59, 59, 999);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Default to day precision
|
||||||
|
targetDate = new Date(start);
|
||||||
|
targetDate.setDate(start.getDate() + columnIndex);
|
||||||
|
targetDate.setHours(0, 0, 0, 0);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final safety check - ensure we have a valid date
|
||||||
|
if (isNaN(targetDate.getTime())) {
|
||||||
|
console.warn('Invalid date calculated, falling back to start date');
|
||||||
|
targetDate = new Date(start);
|
||||||
|
targetDate.setHours(0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure date is within the dateRange bounds
|
||||||
|
if (targetDate < start) {
|
||||||
|
targetDate = new Date(start);
|
||||||
|
targetDate.setHours(0, 0, 0, 0);
|
||||||
|
} else if (targetDate > end) {
|
||||||
|
targetDate = new Date(end);
|
||||||
|
targetDate.setHours(23, 59, 59, 999);
|
||||||
|
}
|
||||||
|
|
||||||
|
return targetDate;
|
||||||
|
}, [dateRange, viewMode, columnsCount]);
|
||||||
|
|
||||||
|
// First get basic dimensions to access containerWidth
|
||||||
|
const basicDimensions = useGanttDimensions(
|
||||||
|
viewMode,
|
||||||
|
containerRef,
|
||||||
|
columnsCount
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate effective columns count that ensures container coverage
|
||||||
|
const effectiveColumnsCount = useMemo(() => {
|
||||||
|
if (!basicDimensions.containerWidth || basicDimensions.containerWidth === 0) {
|
||||||
|
return columnsCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import the column width calculation
|
||||||
|
const getBaseColumnWidth = (mode: GanttViewMode): number => {
|
||||||
|
switch (mode) {
|
||||||
|
case 'day':
|
||||||
|
return 40;
|
||||||
|
case 'week':
|
||||||
|
return 60;
|
||||||
|
case 'month':
|
||||||
|
return 80;
|
||||||
|
case 'quarter':
|
||||||
|
return 120;
|
||||||
|
case 'year':
|
||||||
|
return 160;
|
||||||
|
default:
|
||||||
|
return 80;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseColumnWidth = getBaseColumnWidth(viewMode);
|
||||||
|
const minColumnsNeeded = Math.ceil(basicDimensions.containerWidth / baseColumnWidth);
|
||||||
|
|
||||||
|
// For views that should stretch (month, quarter, year), ensure we have enough columns
|
||||||
|
// but don't add too many extra columns for day/week views
|
||||||
|
const shouldEnsureMinimum = viewMode !== 'day' && viewMode !== 'week';
|
||||||
|
|
||||||
|
if (shouldEnsureMinimum) {
|
||||||
|
return Math.max(columnsCount, minColumnsNeeded);
|
||||||
|
} else {
|
||||||
|
// For day/week views, we want scrolling, so just use calculated columns
|
||||||
|
// But ensure we have at least enough to fill a reasonable portion
|
||||||
|
return Math.max(columnsCount, Math.min(minColumnsNeeded, columnsCount * 2));
|
||||||
|
}
|
||||||
|
}, [columnsCount, basicDimensions.containerWidth, viewMode]);
|
||||||
|
|
||||||
|
// Get final dimensions with effective column count
|
||||||
|
const { actualColumnWidth, totalWidth, shouldScroll, containerWidth } = useGanttDimensions(
|
||||||
|
viewMode,
|
||||||
|
containerRef,
|
||||||
|
effectiveColumnsCount
|
||||||
|
);
|
||||||
|
|
||||||
|
const gridColumns = useMemo(
|
||||||
|
() => Array.from({ length: effectiveColumnsCount }).map((_, index) => index),
|
||||||
|
[effectiveColumnsCount]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Flatten tasks to match the same hierarchy as task list
|
||||||
|
// This should be synchronized with the task list component's expand/collapse state
|
||||||
|
const flattenedTasks = useMemo(() => {
|
||||||
|
const result: Array<GanttTask | { id: string; isEmptyRow: boolean; isAddPhaseRow?: boolean }> = [];
|
||||||
|
const processedIds = new Set<string>(); // Track processed task IDs to prevent duplicates
|
||||||
|
|
||||||
|
const processTask = (task: GanttTask, level: number = 0) => {
|
||||||
|
const isPhase = task.type === 'milestone' || task.is_milestone;
|
||||||
|
const phaseId = isPhase
|
||||||
|
? task.id === 'phase-unmapped'
|
||||||
|
? 'unmapped'
|
||||||
|
: task.phase_id || task.id.replace('phase-', '')
|
||||||
|
: task.id;
|
||||||
|
const isExpanded = expandedTasks ? expandedTasks.has(phaseId) : task.expanded !== false;
|
||||||
|
|
||||||
|
// Avoid processing the same task multiple times
|
||||||
|
if (processedIds.has(task.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
processedIds.add(task.id);
|
||||||
|
|
||||||
|
// Set the correct level for nested tasks
|
||||||
|
const taskWithLevel = { ...task, level };
|
||||||
|
result.push(taskWithLevel);
|
||||||
|
|
||||||
|
if (isPhase && isExpanded) {
|
||||||
|
// Add children if they exist
|
||||||
|
if (task.children && task.children.length > 0) {
|
||||||
|
task.children.forEach(child => processTask(child, level + 1));
|
||||||
|
}
|
||||||
|
// Add an empty row for the "Add Task" button at the end (only if not already processed)
|
||||||
|
const addTaskId = `add-task-${task.id}-timeline`;
|
||||||
|
if (!processedIds.has(addTaskId)) {
|
||||||
|
processedIds.add(addTaskId);
|
||||||
|
result.push({ id: addTaskId, isEmptyRow: true });
|
||||||
|
}
|
||||||
|
} else if (!isPhase && task.children && expandedTasks && expandedTasks.has(task.id)) {
|
||||||
|
task.children.forEach(child => processTask(child, level + 1));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
tasks.forEach(task => processTask(task, 0));
|
||||||
|
|
||||||
|
// Add the "Add Phase" row at the end
|
||||||
|
result.push({ id: 'add-phase-timeline', isEmptyRow: true, isAddPhaseRow: true });
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [tasks, expandedTasks]);
|
||||||
|
|
||||||
|
// Use flattenedTasks directly since we're using popover instead of inline rows
|
||||||
|
const finalTasks = flattenedTasks;
|
||||||
|
|
||||||
|
// Handle timeline click - defined after flattenedTasks
|
||||||
|
const handleTimelineClick = useCallback((e: React.MouseEvent, rowIndex: number) => {
|
||||||
|
if (!dateRange || !onCreateQuickTask) return;
|
||||||
|
|
||||||
|
// Get the click position relative to the timeline
|
||||||
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
|
||||||
|
// Calculate which date was clicked based on column position
|
||||||
|
const clickedDate = calculateDateFromPosition(x, actualColumnWidth);
|
||||||
|
|
||||||
|
// Find which phase this row belongs to
|
||||||
|
const task = flattenedTasks[rowIndex];
|
||||||
|
let phaseId: string | null = null;
|
||||||
|
|
||||||
|
if (task && 'phase_id' in task) {
|
||||||
|
phaseId = task.phase_id || null;
|
||||||
|
} else {
|
||||||
|
// Find the nearest phase above this row
|
||||||
|
for (let i = rowIndex - 1; i >= 0; i--) {
|
||||||
|
const prevTask = flattenedTasks[i];
|
||||||
|
if (prevTask && 'is_milestone' in prevTask && prevTask.is_milestone) {
|
||||||
|
phaseId = prevTask.phase_id || prevTask.id.replace('phase-', '');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the click position relative to the viewport for popover positioning
|
||||||
|
const clickX = e.clientX;
|
||||||
|
const clickY = e.clientY;
|
||||||
|
|
||||||
|
const newPopoverState = {
|
||||||
|
taskName: '',
|
||||||
|
date: clickedDate,
|
||||||
|
phaseId,
|
||||||
|
position: { x: clickX, y: clickY },
|
||||||
|
visible: true,
|
||||||
|
};
|
||||||
|
setTaskPopover(newPopoverState);
|
||||||
|
}, [dateRange, onCreateQuickTask, flattenedTasks, calculateDateFromPosition, actualColumnWidth]);
|
||||||
|
|
||||||
|
// Handle task creation
|
||||||
|
const handleCreateTask = useCallback(() => {
|
||||||
|
if (taskPopover && onCreateQuickTask && taskPopover.taskName.trim()) {
|
||||||
|
onCreateQuickTask(taskPopover.taskName.trim(), taskPopover.phaseId || undefined, taskPopover.date);
|
||||||
|
setTaskPopover(null);
|
||||||
|
}
|
||||||
|
}, [taskPopover, onCreateQuickTask]);
|
||||||
|
|
||||||
|
// Handle cancel
|
||||||
|
const handleCancel = useCallback(() => {
|
||||||
|
setTaskPopover(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={`flex-1 relative bg-white dark:bg-gray-800 overflow-y-auto ${
|
||||||
|
shouldScroll ? 'overflow-x-auto' : 'overflow-x-hidden'
|
||||||
|
} gantt-chart-scroll`}
|
||||||
|
onScroll={onScroll}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative"
|
||||||
|
style={{
|
||||||
|
width: `${totalWidth}px`,
|
||||||
|
minHeight: '100%',
|
||||||
|
minWidth: shouldScroll ? 'auto' : '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute top-0 left-0 bottom-0 flex pointer-events-none"
|
||||||
|
style={{ width: `${totalWidth}px` }}
|
||||||
|
>
|
||||||
|
{/* Grid columns for timeline */}
|
||||||
|
{gridColumns.map(index => (
|
||||||
|
<GridColumn key={`grid-col-${index}`} index={index} columnWidth={actualColumnWidth} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="relative z-10">
|
||||||
|
{finalTasks.map((item, index) => {
|
||||||
|
if ('isEmptyRow' in item && item.isEmptyRow) {
|
||||||
|
// Check if this is the Add Phase row
|
||||||
|
if ('isAddPhaseRow' in item && item.isAddPhaseRow) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="min-h-[4.5rem] border-b border-gray-100 dark:border-gray-700 bg-blue-50 dark:bg-blue-900/20"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular add-task row - determine animation classes
|
||||||
|
const addTaskPhaseId = item.id.replace('add-task-', '').replace('-timeline', '');
|
||||||
|
const shouldAnimate = animatingTasks ? animatingTasks.has(addTaskPhaseId) : false;
|
||||||
|
const staggerIndex = Math.min((index - 1) % 5, 4);
|
||||||
|
const animationClass = shouldAnimate
|
||||||
|
? `gantt-task-slide-in gantt-task-stagger-${staggerIndex + 1}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// Render empty row for add-task
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className={`h-9 border-b border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 ${animationClass}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = item as GanttTask;
|
||||||
|
const isPhase = task.type === 'milestone' || task.is_milestone;
|
||||||
|
|
||||||
|
// Determine if this task should have animation classes
|
||||||
|
let parentPhaseId = '';
|
||||||
|
if (isPhase) {
|
||||||
|
parentPhaseId = task.id === 'phase-unmapped' ? 'unmapped' : task.phase_id || task.id.replace('phase-', '');
|
||||||
|
} else {
|
||||||
|
parentPhaseId = task.phase_id || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldAnimate = !isPhase && animatingTasks ? animatingTasks.has(parentPhaseId) : false;
|
||||||
|
const staggerIndex = Math.min((index - 1) % 5, 4);
|
||||||
|
const animationClass = shouldAnimate
|
||||||
|
? `gantt-task-slide-in gantt-task-stagger-${staggerIndex + 1}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className={`relative cursor-pointer hover:bg-blue-50/30 dark:hover:bg-blue-900/10 transition-colors ${animationClass}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
handleTimelineClick(e, index);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
height: isPhase ? '4.5rem' : '2.25rem',
|
||||||
|
zIndex: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ position: 'absolute', inset: 0, zIndex: 1 }}>
|
||||||
|
<TaskBarRow
|
||||||
|
task={task}
|
||||||
|
viewMode={viewMode}
|
||||||
|
columnWidth={actualColumnWidth}
|
||||||
|
columnsCount={columnsCount}
|
||||||
|
dateRange={dateRange}
|
||||||
|
animationClass=""
|
||||||
|
onPhaseClick={undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{finalTasks.length === 0 && (
|
||||||
|
<div className="flex items-center justify-center h-64 text-gray-400 dark:text-gray-500">
|
||||||
|
No tasks to display
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{/* Task Creation Popover */}
|
||||||
|
{taskPopover && taskPopover.visible && (
|
||||||
|
<TaskCreationPopover
|
||||||
|
taskPopover={taskPopover}
|
||||||
|
onTaskNameChange={(name) => setTaskPopover(prev => prev ? { ...prev, taskName: name } : null)}
|
||||||
|
onCreateTask={handleCreateTask}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
GanttChart.displayName = 'GanttChart';
|
||||||
|
|
||||||
|
export default memo(GanttChart);
|
||||||
@@ -0,0 +1,953 @@
|
|||||||
|
import React, { memo, useCallback, useState, forwardRef, useRef, useEffect, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
RightOutlined,
|
||||||
|
DownOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
HolderOutlined,
|
||||||
|
CalendarOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { Button, Tooltip, Input, DatePicker, Space, message } from 'antd';
|
||||||
|
import dayjs, { Dayjs } from 'dayjs';
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
DragEndEvent,
|
||||||
|
DragOverEvent,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
import { GanttTask, GanttViewMode } from '../../types/gantt-types';
|
||||||
|
import { useSocket } from '../../../../../../socket/socketContext';
|
||||||
|
import { SocketEvents } from '../../../../../../shared/socket-events';
|
||||||
|
import { useAppDispatch } from '../../../../../../hooks/useAppDispatch';
|
||||||
|
import { addTask } from '../../../../../../features/task-management/task-management.slice';
|
||||||
|
import { useAuthService } from '../../../../../../hooks/useAuth';
|
||||||
|
import { useUpdatePhaseMutation } from '../../services/gantt-api.service';
|
||||||
|
|
||||||
|
// Utility function to add alpha channel to hex color
|
||||||
|
const addAlphaToHex = (hex: string, alpha: number): string => {
|
||||||
|
// Remove # if present
|
||||||
|
const cleanHex = hex.replace('#', '');
|
||||||
|
|
||||||
|
// Convert hex to RGB
|
||||||
|
const r = parseInt(cleanHex.substring(0, 2), 16);
|
||||||
|
const g = parseInt(cleanHex.substring(2, 4), 16);
|
||||||
|
const b = parseInt(cleanHex.substring(4, 6), 16);
|
||||||
|
|
||||||
|
// Return rgba string
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface GanttTaskListProps {
|
||||||
|
tasks: GanttTask[];
|
||||||
|
projectId: string;
|
||||||
|
viewMode: GanttViewMode;
|
||||||
|
onTaskToggle?: (taskId: string) => void;
|
||||||
|
onTaskClick?: (taskId: string) => void;
|
||||||
|
onPhaseClick?: (phase: GanttTask) => void;
|
||||||
|
onCreateTask?: (phaseId?: string) => void;
|
||||||
|
onCreateQuickTask?: (taskName: string, phaseId?: string) => void;
|
||||||
|
onCreatePhase?: () => void;
|
||||||
|
onPhaseReorder?: (oldIndex: number, newIndex: number) => void;
|
||||||
|
onScroll?: (e: React.UIEvent<HTMLDivElement>) => void;
|
||||||
|
expandedTasks?: Set<string>;
|
||||||
|
onExpandedTasksChange?: (expanded: Set<string>) => void;
|
||||||
|
animatingTasks?: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TaskRowProps {
|
||||||
|
task: GanttTask;
|
||||||
|
index: number;
|
||||||
|
projectId: string;
|
||||||
|
onToggle?: (taskId: string) => void;
|
||||||
|
onTaskClick?: (taskId: string) => void;
|
||||||
|
onPhaseClick?: (phase: GanttTask) => void;
|
||||||
|
expandedTasks: Set<string>;
|
||||||
|
onCreateTask?: (phaseId?: string) => void;
|
||||||
|
onCreateQuickTask?: (taskName: string, phaseId?: string) => void;
|
||||||
|
isDraggable?: boolean;
|
||||||
|
activeId?: string | null;
|
||||||
|
overId?: string | null;
|
||||||
|
animationClass?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SortableTaskRowProps extends TaskRowProps {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sortable wrapper for phase milestones
|
||||||
|
const SortableTaskRow: React.FC<SortableTaskRowProps> = memo(props => {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
|
id: props.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.7 : 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={setNodeRef} style={style}>
|
||||||
|
<TaskRow
|
||||||
|
{...props}
|
||||||
|
isDraggable={true}
|
||||||
|
dragAttributes={attributes}
|
||||||
|
dragListeners={listeners}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
SortableTaskRow.displayName = 'SortableTaskRow';
|
||||||
|
|
||||||
|
const TaskRow: React.FC<TaskRowProps & { dragAttributes?: any; dragListeners?: any }> = memo(
|
||||||
|
({
|
||||||
|
task,
|
||||||
|
projectId,
|
||||||
|
onToggle,
|
||||||
|
onTaskClick,
|
||||||
|
onPhaseClick,
|
||||||
|
expandedTasks,
|
||||||
|
onCreateTask,
|
||||||
|
onCreateQuickTask,
|
||||||
|
isDraggable = false,
|
||||||
|
activeId,
|
||||||
|
overId,
|
||||||
|
dragAttributes,
|
||||||
|
dragListeners,
|
||||||
|
animationClass = '',
|
||||||
|
}) => {
|
||||||
|
const [showInlineInput, setShowInlineInput] = useState(false);
|
||||||
|
const [taskName, setTaskName] = useState('');
|
||||||
|
const [showDatePickers, setShowDatePickers] = useState(false);
|
||||||
|
const datePickerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { socket, connected } = useSocket();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const [updatePhase] = useUpdatePhaseMutation();
|
||||||
|
const formatDateRange = useCallback(() => {
|
||||||
|
if (!task.start_date || !task.end_date) {
|
||||||
|
return <span className="text-gray-400 dark:text-gray-500">Not scheduled</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = new Date(task.start_date).toLocaleDateString();
|
||||||
|
const end = new Date(task.end_date).toLocaleDateString();
|
||||||
|
return `${start} - ${end}`;
|
||||||
|
}, [task.start_date, task.end_date]);
|
||||||
|
|
||||||
|
const isPhase = task.type === 'milestone' || task.is_milestone;
|
||||||
|
const hasChildren = task.children && task.children.length > 0;
|
||||||
|
// For phases, use phase_id for expansion state, for tasks use task.id
|
||||||
|
const phaseId = isPhase
|
||||||
|
? task.id === 'phase-unmapped'
|
||||||
|
? 'unmapped'
|
||||||
|
: task.phase_id || task.id.replace('phase-', '')
|
||||||
|
: task.id;
|
||||||
|
const isExpanded = expandedTasks.has(phaseId);
|
||||||
|
const indentLevel = (task.level || 0) * 20;
|
||||||
|
|
||||||
|
const handleToggle = useCallback(() => {
|
||||||
|
// For phases, always allow toggle (regardless of having children)
|
||||||
|
// Use the standard onToggle handler which will call handleTaskToggle in GanttTaskList
|
||||||
|
if (isPhase && onToggle) {
|
||||||
|
onToggle(phaseId);
|
||||||
|
} else if (hasChildren && onToggle) {
|
||||||
|
onToggle(task.id);
|
||||||
|
}
|
||||||
|
}, [isPhase, hasChildren, onToggle, task.id, phaseId]);
|
||||||
|
|
||||||
|
const getTaskIcon = () => {
|
||||||
|
// No icon for phases
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getExpandIcon = () => {
|
||||||
|
// All phases should be expandable (with or without children)
|
||||||
|
if (isPhase) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleToggle}
|
||||||
|
className={`w-4 h-4 flex items-center justify-center rounded gantt-expand-icon ${
|
||||||
|
isExpanded ? 'expanded' : ''
|
||||||
|
} hover:bg-black/10`}
|
||||||
|
style={task.color ? { color: task.color } : {}}
|
||||||
|
>
|
||||||
|
<RightOutlined className="text-xs transition-transform duration-200" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className="w-4 h-4" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateTask = () => {
|
||||||
|
if (onCreateTask) {
|
||||||
|
// For phase milestones, pass the phase ID
|
||||||
|
const phaseId = task.type === 'milestone' && task.phase_id ? task.phase_id : undefined;
|
||||||
|
onCreateTask(phaseId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle inline task creation
|
||||||
|
const handleQuickTaskCreation = useCallback(
|
||||||
|
(taskName: string) => {
|
||||||
|
if (!connected || !socket || !projectId) return;
|
||||||
|
|
||||||
|
const currentSession = JSON.parse(localStorage.getItem('session') || '{}');
|
||||||
|
const phaseId = task.type === 'milestone' && task.phase_id ? task.phase_id : undefined;
|
||||||
|
|
||||||
|
const requestBody = {
|
||||||
|
project_id: projectId,
|
||||||
|
name: taskName.trim(),
|
||||||
|
reporter_id: currentSession.id,
|
||||||
|
team_id: currentSession.team_id,
|
||||||
|
phase_id: phaseId,
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(requestBody));
|
||||||
|
|
||||||
|
// Handle the response and update UI
|
||||||
|
socket.once(SocketEvents.QUICK_TASK.toString(), (response: any) => {
|
||||||
|
if (response) {
|
||||||
|
// The task will be automatically added to the task management slice
|
||||||
|
// via global socket handlers, but we need to refresh the Gantt data
|
||||||
|
onCreateQuickTask?.(taskName, phaseId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset input state
|
||||||
|
setTaskName('');
|
||||||
|
setShowInlineInput(false);
|
||||||
|
},
|
||||||
|
[connected, socket, projectId, task.type, task.phase_id, onCreateQuickTask]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleKeyPress = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' && taskName.trim()) {
|
||||||
|
handleQuickTaskCreation(taskName);
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setTaskName('');
|
||||||
|
setShowInlineInput(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[taskName, handleQuickTaskCreation]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleShowInlineInput = useCallback(() => {
|
||||||
|
setShowInlineInput(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePhaseDateUpdate = useCallback(
|
||||||
|
async (startDate: Date, endDate: Date) => {
|
||||||
|
if (!projectId || !task.phase_id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updatePhase({
|
||||||
|
project_id: projectId,
|
||||||
|
phase_id: task.phase_id,
|
||||||
|
start_date: startDate.toISOString(),
|
||||||
|
end_date: endDate.toISOString(),
|
||||||
|
}).unwrap();
|
||||||
|
|
||||||
|
message.success('Phase dates updated successfully');
|
||||||
|
setShowDatePickers(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update phase dates:', error);
|
||||||
|
message.error('Failed to update phase dates');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[projectId, task.phase_id, updatePhase]
|
||||||
|
);
|
||||||
|
|
||||||
|
const isEmpty = isPhase && (!task.children || task.children.length === 0);
|
||||||
|
|
||||||
|
// Calculate phase completion percentage
|
||||||
|
const phaseCompletion = useMemo(() => {
|
||||||
|
if (!isPhase || !task.children || task.children.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const totalTasks = task.children.length;
|
||||||
|
const completedTasks = task.children.filter(child => child.progress === 100).length;
|
||||||
|
return Math.round((completedTasks / totalTasks) * 100);
|
||||||
|
}, [isPhase, task.children]);
|
||||||
|
|
||||||
|
const handleTaskClick = useCallback(() => {
|
||||||
|
if (!isPhase && onTaskClick) {
|
||||||
|
onTaskClick(task.id);
|
||||||
|
}
|
||||||
|
}, [isPhase, onTaskClick, task.id]);
|
||||||
|
|
||||||
|
const handlePhaseClick = useCallback(() => {
|
||||||
|
if (isPhase && onPhaseClick) {
|
||||||
|
onPhaseClick(task);
|
||||||
|
}
|
||||||
|
}, [isPhase, onPhaseClick, task]);
|
||||||
|
|
||||||
|
// Handle click outside to close date picker
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (datePickerRef.current && !datePickerRef.current.contains(event.target as Node)) {
|
||||||
|
setShowDatePickers(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (showDatePickers) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [showDatePickers]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={`group flex ${isPhase ? 'min-h-[4.5rem] gantt-phase-row' : 'h-9 gantt-task-row'} border-b border-gray-100 dark:border-gray-700 transition-colors ${
|
||||||
|
!isPhase
|
||||||
|
? 'bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-750 cursor-pointer'
|
||||||
|
: ''
|
||||||
|
} ${isDraggable && !isPhase ? 'cursor-grab active:cursor-grabbing' : ''} ${
|
||||||
|
activeId === task.id ? 'opacity-50' : ''
|
||||||
|
} ${overId === task.id && overId !== activeId ? 'ring-2 ring-blue-500 ring-inset' : ''} ${animationClass}`}
|
||||||
|
style={
|
||||||
|
isPhase && task.color
|
||||||
|
? {
|
||||||
|
backgroundColor: addAlphaToHex(task.color, 0.15),
|
||||||
|
color: task.color,
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
}
|
||||||
|
onClick={!isPhase ? handleTaskClick : undefined}
|
||||||
|
{...(!isPhase && isDraggable ? dragAttributes : {})}
|
||||||
|
{...(!isPhase && isDraggable ? dragListeners : {})}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`w-full px-2 py-2 text-sm ${isPhase ? '' : 'text-gray-800 dark:text-gray-200'} flex items-center justify-between`}
|
||||||
|
style={{
|
||||||
|
paddingLeft: `${8 + indentLevel + (isPhase && task.id === 'phase-unmapped' ? 28 : 0)}px`,
|
||||||
|
color: isPhase && task.color ? task.color : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 truncate flex-1">
|
||||||
|
{/* Drag handle for phases */}
|
||||||
|
{isPhase && isDraggable && (
|
||||||
|
<button
|
||||||
|
{...dragAttributes}
|
||||||
|
{...dragListeners}
|
||||||
|
className="opacity-50 hover:opacity-100 cursor-grab active:cursor-grabbing p-1 rounded hover:bg-black/10"
|
||||||
|
style={{ color: task.color }}
|
||||||
|
title="Drag to reorder phase"
|
||||||
|
>
|
||||||
|
<HolderOutlined className="text-xs" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{getExpandIcon()}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 ml-1 truncate flex-1">
|
||||||
|
{getTaskIcon()}
|
||||||
|
<div className="flex flex-col flex-1">
|
||||||
|
<span
|
||||||
|
className={`truncate ${task.type === 'milestone' ? 'font-semibold cursor-pointer hover:opacity-80' : ''}`}
|
||||||
|
onClick={isPhase ? handlePhaseClick : undefined}
|
||||||
|
>
|
||||||
|
{task.name}
|
||||||
|
</span>
|
||||||
|
{isPhase && (
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-xs" style={{ color: task.color, opacity: 0.8 }}>
|
||||||
|
{task.children?.length || 0} tasks
|
||||||
|
</span>
|
||||||
|
{!showDatePickers && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDatePickers(true)}
|
||||||
|
className="text-xs flex items-center gap-1 transition-colors"
|
||||||
|
style={{ color: task.color, opacity: 0.7 }}
|
||||||
|
>
|
||||||
|
<CalendarOutlined className="text-[10px]" />
|
||||||
|
{task.start_date && task.end_date ? (
|
||||||
|
<>
|
||||||
|
{dayjs(task.start_date).format('MMM D')} -{' '}
|
||||||
|
{dayjs(task.end_date).format('MMM D, YYYY')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Set dates'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{showDatePickers && isPhase && (
|
||||||
|
<div ref={datePickerRef} className="flex items-center gap-1 mt-2 -ml-1">
|
||||||
|
<DatePicker.RangePicker
|
||||||
|
size="small"
|
||||||
|
value={[
|
||||||
|
task.start_date ? dayjs(task.start_date) : null,
|
||||||
|
task.end_date ? dayjs(task.end_date) : null,
|
||||||
|
]}
|
||||||
|
onChange={dates => {
|
||||||
|
if (dates && dates[0] && dates[1]) {
|
||||||
|
handlePhaseDateUpdate(dates[0].toDate(), dates[1].toDate());
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onOpenChange={open => {
|
||||||
|
if (!open) {
|
||||||
|
setShowDatePickers(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
style={{ width: 180 }}
|
||||||
|
format="MMM D, YYYY"
|
||||||
|
placeholder={['Start date', 'End date']}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Phase completion percentage on the right side */}
|
||||||
|
{isPhase && task.children && task.children.length > 0 && (
|
||||||
|
<div className="flex-shrink-0 mr-2">
|
||||||
|
<span className="text-xs font-medium" style={{ color: task.color, opacity: 0.9 }}>
|
||||||
|
{phaseCompletion}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
TaskRow.displayName = 'TaskRow';
|
||||||
|
|
||||||
|
// Add Task Row Component
|
||||||
|
interface AddTaskRowProps {
|
||||||
|
task: GanttTask;
|
||||||
|
projectId: string;
|
||||||
|
onCreateQuickTask?: (taskName: string, phaseId?: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AddTaskRow: React.FC<AddTaskRowProps> = memo(({ task, projectId, onCreateQuickTask }) => {
|
||||||
|
const [showInlineInput, setShowInlineInput] = useState(false);
|
||||||
|
const [taskName, setTaskName] = useState('');
|
||||||
|
const { socket, connected } = useSocket();
|
||||||
|
const authService = useAuthService();
|
||||||
|
|
||||||
|
// Handle inline task creation
|
||||||
|
const handleQuickTaskCreation = useCallback(
|
||||||
|
(taskName: string) => {
|
||||||
|
if (!connected || !socket || !projectId) return;
|
||||||
|
|
||||||
|
const currentSession = authService.getCurrentSession();
|
||||||
|
if (!currentSession) {
|
||||||
|
console.error('No current session found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the correct phase ID
|
||||||
|
let phaseId: string | null | undefined = task.parent_phase_id;
|
||||||
|
if (phaseId === 'unmapped') {
|
||||||
|
phaseId = null; // Unmapped tasks have no phase
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestBody = {
|
||||||
|
project_id: projectId,
|
||||||
|
name: taskName.trim(),
|
||||||
|
reporter_id: currentSession.id,
|
||||||
|
team_id: currentSession.team_id,
|
||||||
|
phase_id: phaseId,
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(requestBody));
|
||||||
|
|
||||||
|
// Handle the response and update UI
|
||||||
|
socket.once(SocketEvents.QUICK_TASK.toString(), (response: any) => {
|
||||||
|
if (response) {
|
||||||
|
// Immediately refresh the Gantt data to show the new task
|
||||||
|
onCreateQuickTask?.(taskName, phaseId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset input state
|
||||||
|
setTaskName('');
|
||||||
|
setShowInlineInput(false);
|
||||||
|
},
|
||||||
|
[connected, socket, projectId, task.parent_phase_id, onCreateQuickTask, authService]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleKeyPress = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' && taskName.trim()) {
|
||||||
|
handleQuickTaskCreation(taskName);
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setTaskName('');
|
||||||
|
setShowInlineInput(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[taskName, handleQuickTaskCreation]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleShowInlineInput = useCallback(() => {
|
||||||
|
setShowInlineInput(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="gantt-add-task-inline flex h-9 border-b border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
|
||||||
|
<div
|
||||||
|
className="w-full px-2 py-2 text-sm flex items-center"
|
||||||
|
style={{ paddingLeft: `${8 + 40}px` }} // Extra indent for child
|
||||||
|
>
|
||||||
|
{showInlineInput ? (
|
||||||
|
<Input
|
||||||
|
size="small"
|
||||||
|
placeholder="Enter task name..."
|
||||||
|
value={taskName}
|
||||||
|
onChange={e => setTaskName(e.target.value)}
|
||||||
|
onKeyDown={handleKeyPress}
|
||||||
|
onBlur={() => {
|
||||||
|
if (!taskName.trim()) {
|
||||||
|
setShowInlineInput(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
className="text-xs dark:bg-gray-700 dark:border-gray-600 dark:text-gray-100"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={handleShowInlineInput}
|
||||||
|
className="text-xs text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 gantt-add-task-btn"
|
||||||
|
>
|
||||||
|
Add Task
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
AddTaskRow.displayName = 'AddTaskRow';
|
||||||
|
|
||||||
|
// Add Phase Row Component
|
||||||
|
interface AddPhaseRowProps {
|
||||||
|
projectId: string;
|
||||||
|
onCreatePhase?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AddPhaseRow: React.FC<AddPhaseRowProps> = memo(({ projectId, onCreatePhase }) => {
|
||||||
|
return (
|
||||||
|
<div className="gantt-add-phase-row flex min-h-[4.5rem] border-b border-gray-100 dark:border-gray-700 bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors cursor-pointer">
|
||||||
|
<div
|
||||||
|
className="w-full px-2 py-2 text-sm flex items-center"
|
||||||
|
style={{ paddingLeft: `8px` }}
|
||||||
|
onClick={onCreatePhase}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-4 h-4 flex items-center justify-center rounded bg-blue-500 text-white">
|
||||||
|
<PlusOutlined className="text-xs" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-semibold text-blue-600 dark:text-blue-400">
|
||||||
|
Add New Phase
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-blue-500 dark:text-blue-300 opacity-80">
|
||||||
|
Click to create a new project phase
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
AddPhaseRow.displayName = 'AddPhaseRow';
|
||||||
|
|
||||||
|
const GanttTaskList = forwardRef<HTMLDivElement, GanttTaskListProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
tasks,
|
||||||
|
projectId,
|
||||||
|
viewMode,
|
||||||
|
onTaskToggle,
|
||||||
|
onTaskClick,
|
||||||
|
onPhaseClick,
|
||||||
|
onCreateTask,
|
||||||
|
onCreateQuickTask,
|
||||||
|
onCreatePhase,
|
||||||
|
onPhaseReorder,
|
||||||
|
onScroll,
|
||||||
|
expandedTasks: expandedTasksProp,
|
||||||
|
onExpandedTasksChange,
|
||||||
|
animatingTasks: animatingTasksProp,
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const [localExpandedTasks, setLocalExpandedTasks] = useState<Set<string>>(
|
||||||
|
() => new Set(tasks.filter(t => t.expanded).map(t => t.id))
|
||||||
|
);
|
||||||
|
|
||||||
|
const expandedTasks = expandedTasksProp || localExpandedTasks;
|
||||||
|
const animatingTasks = animatingTasksProp || new Set();
|
||||||
|
|
||||||
|
// Drag and drop state
|
||||||
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
|
const [overId, setOverId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Socket and auth
|
||||||
|
const { socket, connected } = useSocket();
|
||||||
|
const currentSession = useAuthService().getCurrentSession();
|
||||||
|
|
||||||
|
// DnD sensors
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
distance: 8,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
const handleTaskToggle = useCallback(
|
||||||
|
(taskId: string) => {
|
||||||
|
const updateExpanded = (prev: Set<string>) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (newSet.has(taskId)) {
|
||||||
|
newSet.delete(taskId);
|
||||||
|
} else {
|
||||||
|
newSet.add(taskId);
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (onExpandedTasksChange) {
|
||||||
|
onExpandedTasksChange(updateExpanded(expandedTasks));
|
||||||
|
} else {
|
||||||
|
setLocalExpandedTasks(updateExpanded);
|
||||||
|
}
|
||||||
|
|
||||||
|
onTaskToggle?.(taskId);
|
||||||
|
},
|
||||||
|
[expandedTasks, onExpandedTasksChange, onTaskToggle]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Flatten tasks based on expand/collapse state
|
||||||
|
const flattenTasks = useCallback(
|
||||||
|
(taskList: GanttTask[]): GanttTask[] => {
|
||||||
|
const result: GanttTask[] = [];
|
||||||
|
const processedIds = new Set<string>(); // Track processed task IDs to prevent duplicates
|
||||||
|
|
||||||
|
const processTask = (task: GanttTask, level: number = 0) => {
|
||||||
|
const isPhase = task.type === 'milestone' || task.is_milestone;
|
||||||
|
const phaseId = isPhase
|
||||||
|
? task.id === 'phase-unmapped'
|
||||||
|
? 'unmapped'
|
||||||
|
: task.phase_id || task.id.replace('phase-', '')
|
||||||
|
: task.id;
|
||||||
|
const isExpanded = expandedTasks.has(phaseId);
|
||||||
|
|
||||||
|
// Avoid processing the same task multiple times
|
||||||
|
if (processedIds.has(task.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
processedIds.add(task.id);
|
||||||
|
|
||||||
|
// Set the correct level for nested tasks
|
||||||
|
const taskWithLevel = { ...task, level };
|
||||||
|
result.push(taskWithLevel);
|
||||||
|
|
||||||
|
if (isPhase && isExpanded) {
|
||||||
|
// Add children if they exist
|
||||||
|
if (task.children && task.children.length > 0) {
|
||||||
|
task.children.forEach(child => processTask(child, level + 1));
|
||||||
|
}
|
||||||
|
// Add a special "add task" row at the end (only if not already processed)
|
||||||
|
const addTaskId = `add-task-${task.id}`;
|
||||||
|
if (!processedIds.has(addTaskId)) {
|
||||||
|
processedIds.add(addTaskId);
|
||||||
|
result.push({
|
||||||
|
id: addTaskId,
|
||||||
|
name: 'Add Task',
|
||||||
|
type: 'add-task-button' as any,
|
||||||
|
phase_id: task.phase_id,
|
||||||
|
parent_phase_id: phaseId,
|
||||||
|
level: level + 1,
|
||||||
|
start_date: null,
|
||||||
|
end_date: null,
|
||||||
|
progress: 0,
|
||||||
|
} as GanttTask);
|
||||||
|
}
|
||||||
|
} else if (!isPhase && task.children && expandedTasks.has(task.id)) {
|
||||||
|
task.children.forEach(child => processTask(child, level + 1));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
taskList.forEach(task => processTask(task, 0));
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
[expandedTasks]
|
||||||
|
);
|
||||||
|
|
||||||
|
const visibleTasks = flattenTasks(tasks);
|
||||||
|
|
||||||
|
// Emit task sort change via socket for moving tasks between phases
|
||||||
|
const emitTaskPhaseChange = useCallback(
|
||||||
|
(taskId: string, fromPhaseId: string | null, toPhaseId: string | null, sortOrder: number) => {
|
||||||
|
if (!socket || !connected || !projectId) return;
|
||||||
|
|
||||||
|
const task = visibleTasks.find(t => t.id === taskId);
|
||||||
|
if (!task || task.type === 'milestone' || task.is_milestone) return;
|
||||||
|
|
||||||
|
const teamId = currentSession?.team_id || '';
|
||||||
|
|
||||||
|
const socketData = {
|
||||||
|
project_id: projectId,
|
||||||
|
group_by: 'phase',
|
||||||
|
task_updates: [
|
||||||
|
{
|
||||||
|
task_id: taskId,
|
||||||
|
sort_order: sortOrder,
|
||||||
|
phase_id: toPhaseId,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
from_group: fromPhaseId || 'unmapped',
|
||||||
|
to_group: toPhaseId || 'unmapped',
|
||||||
|
task: {
|
||||||
|
id: task.id,
|
||||||
|
project_id: projectId,
|
||||||
|
status: '',
|
||||||
|
priority: '',
|
||||||
|
},
|
||||||
|
team_id: teamId,
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), socketData);
|
||||||
|
},
|
||||||
|
[socket, connected, projectId, visibleTasks, currentSession]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragStart = useCallback((event: any) => {
|
||||||
|
setActiveId(event.active.id as string);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((event: DragOverEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
|
||||||
|
if (!over) {
|
||||||
|
setOverId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setOverId(over.id as string);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(
|
||||||
|
(event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
setActiveId(null);
|
||||||
|
setOverId(null);
|
||||||
|
|
||||||
|
if (!over || active.id === over.id) return;
|
||||||
|
|
||||||
|
const activeTask = visibleTasks.find(t => t.id === active.id);
|
||||||
|
const overTask = visibleTasks.find(t => t.id === over.id);
|
||||||
|
|
||||||
|
// Handle phase reordering (existing functionality)
|
||||||
|
if (
|
||||||
|
activeTask &&
|
||||||
|
(activeTask.type === 'milestone' || activeTask.is_milestone) &&
|
||||||
|
onPhaseReorder
|
||||||
|
) {
|
||||||
|
const phases = tasks.filter(task => task.type === 'milestone' || task.is_milestone);
|
||||||
|
const oldIndex = phases.findIndex(phase => phase.id === active.id);
|
||||||
|
const newIndex = phases.findIndex(phase => phase.id === over.id);
|
||||||
|
|
||||||
|
if (oldIndex !== -1 && newIndex !== -1) {
|
||||||
|
onPhaseReorder(oldIndex, newIndex);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle task moving between phases
|
||||||
|
if (activeTask && !(activeTask.type === 'milestone' || activeTask.is_milestone)) {
|
||||||
|
let targetPhaseId: string | null = null;
|
||||||
|
|
||||||
|
// If dropped on a phase, move to that phase
|
||||||
|
if (overTask && (overTask.type === 'milestone' || overTask.is_milestone)) {
|
||||||
|
targetPhaseId = overTask.phase_id || overTask.id.replace('phase-', '');
|
||||||
|
if (overTask.id === 'phase-unmapped') {
|
||||||
|
targetPhaseId = null;
|
||||||
|
}
|
||||||
|
} else if (overTask) {
|
||||||
|
// If dropped on another task, move to that task's phase
|
||||||
|
targetPhaseId = overTask.phase_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find current phase
|
||||||
|
const currentPhaseId = activeTask.phase_id;
|
||||||
|
|
||||||
|
// Only emit if phase actually changed
|
||||||
|
if (currentPhaseId !== targetPhaseId) {
|
||||||
|
emitTaskPhaseChange(activeTask.id, currentPhaseId, targetPhaseId, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[tasks, visibleTasks, onPhaseReorder, emitTaskPhaseChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Separate phases and tasks for drag and drop (exclude unmapped phase)
|
||||||
|
const phases = visibleTasks.filter(
|
||||||
|
task => (task.type === 'milestone' || task.is_milestone) && task.id !== 'phase-unmapped'
|
||||||
|
);
|
||||||
|
const regularTasks = visibleTasks.filter(
|
||||||
|
task => !(task.type === 'milestone' || task.is_milestone)
|
||||||
|
);
|
||||||
|
|
||||||
|
// All draggable items (phases + tasks)
|
||||||
|
const allDraggableItems = [...phases.map(p => p.id), ...regularTasks.map(t => t.id)];
|
||||||
|
const phasesSet = new Set(phases.map(p => p.id));
|
||||||
|
|
||||||
|
// Determine if the timeline has dual headers
|
||||||
|
const hasDualHeaders = ['month', 'week', 'day'].includes(viewMode);
|
||||||
|
const headerHeight = hasDualHeaders ? 'h-20' : 'h-10';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-[444px] min-w-[444px] max-w-[444px] h-full flex flex-col bg-gray-50 dark:bg-gray-900 gantt-task-list-container">
|
||||||
|
<div
|
||||||
|
className={`flex ${headerHeight} border-b border-gray-200 dark:border-gray-700 bg-gray-100 dark:bg-gray-800 font-medium text-sm flex-shrink-0 items-center`}
|
||||||
|
>
|
||||||
|
<div className="w-full px-4 text-gray-700 dark:text-gray-300">Task Name</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 gantt-task-list-scroll relative" ref={ref} onScroll={onScroll}>
|
||||||
|
{visibleTasks.length === 0 && (
|
||||||
|
<div className="p-8 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
No tasks available
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<SortableContext items={allDraggableItems} strategy={verticalListSortingStrategy}>
|
||||||
|
{visibleTasks.map((task, index) => {
|
||||||
|
const isPhase = task.type === 'milestone' || task.is_milestone;
|
||||||
|
const isUnmappedPhase = task.id === 'phase-unmapped';
|
||||||
|
const isAddTaskButton = task.type === 'add-task-button';
|
||||||
|
|
||||||
|
// Determine if this task should have animation classes
|
||||||
|
let parentPhaseId = '';
|
||||||
|
if (isPhase) {
|
||||||
|
parentPhaseId = task.id === 'phase-unmapped' ? 'unmapped' : task.phase_id || task.id.replace('phase-', '');
|
||||||
|
} else if (isAddTaskButton) {
|
||||||
|
parentPhaseId = task.parent_phase_id || '';
|
||||||
|
} else {
|
||||||
|
parentPhaseId = task.phase_id || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldAnimate = !isPhase && animatingTasks.has(parentPhaseId);
|
||||||
|
const staggerIndex = Math.min((index - 1) % 5, 4); // Subtract 1 to account for phase row, limit stagger to 5 levels
|
||||||
|
|
||||||
|
if (isAddTaskButton) {
|
||||||
|
const animationClass = shouldAnimate
|
||||||
|
? `gantt-task-slide-in gantt-task-stagger-${staggerIndex + 1}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={task.id} className={animationClass}>
|
||||||
|
<AddTaskRow
|
||||||
|
task={task}
|
||||||
|
projectId={projectId}
|
||||||
|
onCreateQuickTask={onCreateQuickTask}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (isPhase && !isUnmappedPhase) {
|
||||||
|
return (
|
||||||
|
<SortableTaskRow
|
||||||
|
key={task.id}
|
||||||
|
id={task.id}
|
||||||
|
task={task}
|
||||||
|
index={index}
|
||||||
|
projectId={projectId}
|
||||||
|
onToggle={handleTaskToggle}
|
||||||
|
onTaskClick={onTaskClick}
|
||||||
|
onPhaseClick={onPhaseClick}
|
||||||
|
expandedTasks={expandedTasks}
|
||||||
|
onCreateTask={onCreateTask}
|
||||||
|
onCreateQuickTask={onCreateQuickTask}
|
||||||
|
activeId={activeId}
|
||||||
|
overId={overId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (isUnmappedPhase) {
|
||||||
|
return (
|
||||||
|
<TaskRow
|
||||||
|
key={task.id}
|
||||||
|
task={task}
|
||||||
|
index={index}
|
||||||
|
projectId={projectId}
|
||||||
|
onToggle={handleTaskToggle}
|
||||||
|
onTaskClick={onTaskClick}
|
||||||
|
onPhaseClick={onPhaseClick}
|
||||||
|
expandedTasks={expandedTasks}
|
||||||
|
onCreateTask={onCreateTask}
|
||||||
|
onCreateQuickTask={onCreateQuickTask}
|
||||||
|
isDraggable={false}
|
||||||
|
activeId={activeId}
|
||||||
|
overId={overId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Regular tasks - make them draggable too with animation
|
||||||
|
const animationClass = shouldAnimate
|
||||||
|
? `gantt-task-slide-in gantt-task-stagger-${staggerIndex + 1}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SortableTaskRow
|
||||||
|
key={task.id}
|
||||||
|
id={task.id}
|
||||||
|
task={task}
|
||||||
|
index={index}
|
||||||
|
projectId={projectId}
|
||||||
|
onToggle={handleTaskToggle}
|
||||||
|
onTaskClick={onTaskClick}
|
||||||
|
onPhaseClick={onPhaseClick}
|
||||||
|
expandedTasks={expandedTasks}
|
||||||
|
onCreateTask={onCreateTask}
|
||||||
|
onCreateQuickTask={onCreateQuickTask}
|
||||||
|
activeId={activeId}
|
||||||
|
overId={overId}
|
||||||
|
animationClass={animationClass}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
|
||||||
|
{/* Add Phase Row - always at the bottom */}
|
||||||
|
<AddPhaseRow
|
||||||
|
projectId={projectId}
|
||||||
|
onCreatePhase={onCreatePhase}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
GanttTaskList.displayName = 'GanttTaskList';
|
||||||
|
|
||||||
|
export default memo(GanttTaskList);
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import React, { memo, useMemo, forwardRef, RefObject } from 'react';
|
||||||
|
import { GanttViewMode } from '../../types/gantt-types';
|
||||||
|
import { useGanttDimensions } from '../../hooks/useGanttDimensions';
|
||||||
|
import { TimelineUtils } from '../../utils/timeline-calculator';
|
||||||
|
|
||||||
|
interface GanttTimelineProps {
|
||||||
|
viewMode: GanttViewMode;
|
||||||
|
containerRef: RefObject<HTMLDivElement | null>;
|
||||||
|
dateRange?: { start: Date; end: Date };
|
||||||
|
}
|
||||||
|
|
||||||
|
const GanttTimeline = forwardRef<HTMLDivElement, GanttTimelineProps>(
|
||||||
|
({ viewMode, containerRef, dateRange }, ref) => {
|
||||||
|
const { topHeaders, bottomHeaders } = useMemo(() => {
|
||||||
|
if (!dateRange) {
|
||||||
|
return { topHeaders: [], bottomHeaders: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { start, end } = dateRange;
|
||||||
|
const topHeaders: Array<{ label: string; key: string; span: number }> = [];
|
||||||
|
const bottomHeaders: Array<{ label: string; key: string }> = [];
|
||||||
|
|
||||||
|
switch (viewMode) {
|
||||||
|
case 'month':
|
||||||
|
// Top: Years, Bottom: Months
|
||||||
|
const startYear = start.getFullYear();
|
||||||
|
const startMonth = start.getMonth();
|
||||||
|
const endYear = end.getFullYear();
|
||||||
|
const endMonth = end.getMonth();
|
||||||
|
|
||||||
|
// Generate bottom headers (months)
|
||||||
|
let currentYear = startYear;
|
||||||
|
let currentMonth = startMonth;
|
||||||
|
|
||||||
|
while (currentYear < endYear || (currentYear === endYear && currentMonth <= endMonth)) {
|
||||||
|
const date = new Date(currentYear, currentMonth, 1);
|
||||||
|
bottomHeaders.push({
|
||||||
|
label: date.toLocaleDateString('en-US', { month: 'short' }),
|
||||||
|
key: `month-${currentYear}-${currentMonth}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
currentMonth++;
|
||||||
|
if (currentMonth > 11) {
|
||||||
|
currentMonth = 0;
|
||||||
|
currentYear++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate top headers (years)
|
||||||
|
for (let year = startYear; year <= endYear; year++) {
|
||||||
|
const monthsInYear = bottomHeaders.filter(h => h.key.includes(`-${year}-`)).length;
|
||||||
|
if (monthsInYear > 0) {
|
||||||
|
topHeaders.push({
|
||||||
|
label: `${year}`,
|
||||||
|
key: `year-${year}`,
|
||||||
|
span: monthsInYear,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'week':
|
||||||
|
// Top: Months, Bottom: Weeks
|
||||||
|
const weekStart = new Date(start);
|
||||||
|
const weekEnd = new Date(end);
|
||||||
|
weekStart.setDate(weekStart.getDate() - weekStart.getDay());
|
||||||
|
|
||||||
|
const weekDates: Date[] = [];
|
||||||
|
const tempDate = new Date(weekStart);
|
||||||
|
while (tempDate <= weekEnd) {
|
||||||
|
weekDates.push(new Date(tempDate));
|
||||||
|
tempDate.setDate(tempDate.getDate() + 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate bottom headers (weeks)
|
||||||
|
weekDates.forEach(date => {
|
||||||
|
const weekNum = TimelineUtils.getWeekNumber(date);
|
||||||
|
bottomHeaders.push({
|
||||||
|
label: `W${weekNum}`,
|
||||||
|
key: `week-${date.getFullYear()}-${weekNum}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate top headers (months)
|
||||||
|
const monthGroups = new Map<string, number>();
|
||||||
|
weekDates.forEach(date => {
|
||||||
|
const monthKey = `${date.getFullYear()}-${date.getMonth()}`;
|
||||||
|
monthGroups.set(monthKey, (monthGroups.get(monthKey) || 0) + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
monthGroups.forEach((count, monthKey) => {
|
||||||
|
const [year, month] = monthKey.split('-').map(Number);
|
||||||
|
const date = new Date(year, month, 1);
|
||||||
|
topHeaders.push({
|
||||||
|
label: date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' }),
|
||||||
|
key: `month-${monthKey}`,
|
||||||
|
span: count,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'day':
|
||||||
|
// Top: Months, Bottom: Days
|
||||||
|
const dayStart = new Date(start);
|
||||||
|
const dayEnd = new Date(end);
|
||||||
|
|
||||||
|
const dayDates: Date[] = [];
|
||||||
|
const tempDayDate = new Date(dayStart);
|
||||||
|
while (tempDayDate <= dayEnd) {
|
||||||
|
dayDates.push(new Date(tempDayDate));
|
||||||
|
tempDayDate.setDate(tempDayDate.getDate() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate bottom headers (days)
|
||||||
|
dayDates.forEach(date => {
|
||||||
|
bottomHeaders.push({
|
||||||
|
label: date.getDate().toString(),
|
||||||
|
key: `day-${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate top headers (months)
|
||||||
|
const dayMonthGroups = new Map<string, number>();
|
||||||
|
dayDates.forEach(date => {
|
||||||
|
const monthKey = `${date.getFullYear()}-${date.getMonth()}`;
|
||||||
|
dayMonthGroups.set(monthKey, (dayMonthGroups.get(monthKey) || 0) + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
dayMonthGroups.forEach((count, monthKey) => {
|
||||||
|
const [year, month] = monthKey.split('-').map(Number);
|
||||||
|
const date = new Date(year, month, 1);
|
||||||
|
topHeaders.push({
|
||||||
|
label: date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' }),
|
||||||
|
key: `month-${monthKey}`,
|
||||||
|
span: count,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Fallback to single row for other view modes
|
||||||
|
const result = [];
|
||||||
|
switch (viewMode) {
|
||||||
|
case 'quarter':
|
||||||
|
const qStartYear = start.getFullYear();
|
||||||
|
const qStartQuarter = Math.ceil((start.getMonth() + 1) / 3);
|
||||||
|
const qEndYear = end.getFullYear();
|
||||||
|
const qEndQuarter = Math.ceil((end.getMonth() + 1) / 3);
|
||||||
|
|
||||||
|
let qYear = qStartYear;
|
||||||
|
let qQuarter = qStartQuarter;
|
||||||
|
|
||||||
|
while (qYear < qEndYear || (qYear === qEndYear && qQuarter <= qEndQuarter)) {
|
||||||
|
result.push({
|
||||||
|
label: `Q${qQuarter} ${qYear}`,
|
||||||
|
key: `quarter-${qYear}-${qQuarter}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
qQuarter++;
|
||||||
|
if (qQuarter > 4) {
|
||||||
|
qQuarter = 1;
|
||||||
|
qYear++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'year':
|
||||||
|
const yearStart = start.getFullYear();
|
||||||
|
const yearEnd = end.getFullYear();
|
||||||
|
|
||||||
|
for (let year = yearStart; year <= yearEnd; year++) {
|
||||||
|
result.push({
|
||||||
|
label: `${year}`,
|
||||||
|
key: `year-${year}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.forEach(item => {
|
||||||
|
bottomHeaders.push(item);
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { topHeaders, bottomHeaders };
|
||||||
|
}, [viewMode, dateRange]);
|
||||||
|
|
||||||
|
const { actualColumnWidth, totalWidth, shouldScroll } = useGanttDimensions(
|
||||||
|
viewMode,
|
||||||
|
containerRef,
|
||||||
|
bottomHeaders.length
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasTopHeaders = topHeaders.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={`${hasTopHeaders ? 'h-20' : 'h-10'} flex-shrink-0 bg-gray-100 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 overflow-y-hidden ${
|
||||||
|
shouldScroll ? 'overflow-x-auto' : 'overflow-x-hidden'
|
||||||
|
} scrollbar-hide flex flex-col`}
|
||||||
|
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||||
|
>
|
||||||
|
{hasTopHeaders && (
|
||||||
|
<div
|
||||||
|
className="flex h-10 border-b border-gray-200 dark:border-gray-700"
|
||||||
|
style={{ width: `${totalWidth}px`, minWidth: shouldScroll ? 'auto' : '100%' }}
|
||||||
|
>
|
||||||
|
{topHeaders.map(header => (
|
||||||
|
<div
|
||||||
|
key={header.key}
|
||||||
|
className="py-2.5 text-center border-r border-gray-200 dark:border-gray-700 text-sm font-semibold text-gray-800 dark:text-gray-200 flex-shrink-0 px-2 whitespace-nowrap bg-gray-50 dark:bg-gray-750"
|
||||||
|
style={{ width: `${actualColumnWidth * header.span}px` }}
|
||||||
|
title={header.label}
|
||||||
|
>
|
||||||
|
{header.label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="flex h-10"
|
||||||
|
style={{ width: `${totalWidth}px`, minWidth: shouldScroll ? 'auto' : '100%' }}
|
||||||
|
>
|
||||||
|
{bottomHeaders.map(header => (
|
||||||
|
<div
|
||||||
|
key={header.key}
|
||||||
|
className={`py-2.5 text-center border-r border-gray-200 dark:border-gray-700 text-sm font-medium text-gray-700 dark:text-gray-300 flex-shrink-0 ${
|
||||||
|
viewMode === 'day' ? 'px-1 text-xs' : 'px-2'
|
||||||
|
} ${
|
||||||
|
viewMode === 'day' && actualColumnWidth < 50
|
||||||
|
? 'whitespace-nowrap overflow-hidden text-ellipsis'
|
||||||
|
: 'whitespace-nowrap'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${actualColumnWidth}px` }}
|
||||||
|
title={header.label}
|
||||||
|
>
|
||||||
|
{header.label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
GanttTimeline.displayName = 'GanttTimeline';
|
||||||
|
|
||||||
|
export default memo(GanttTimeline);
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import React, { memo } from 'react';
|
||||||
|
import { Select, Button, Space } from 'antd';
|
||||||
|
import {
|
||||||
|
ZoomInOutlined,
|
||||||
|
ZoomOutOutlined,
|
||||||
|
FullscreenOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { GanttViewMode } from '../../types/gantt-types';
|
||||||
|
|
||||||
|
const { Option } = Select;
|
||||||
|
|
||||||
|
interface GanttToolbarProps {
|
||||||
|
viewMode: GanttViewMode;
|
||||||
|
onViewModeChange: (mode: GanttViewMode) => void;
|
||||||
|
dateRange?: { start: Date; end: Date };
|
||||||
|
}
|
||||||
|
|
||||||
|
const GanttToolbar: React.FC<GanttToolbarProps> = memo(
|
||||||
|
({ viewMode, onViewModeChange, dateRange }) => {
|
||||||
|
// Define zoom levels in order from most detailed to least detailed
|
||||||
|
const zoomLevels: GanttViewMode[] = ['day', 'week', 'month', 'quarter', 'year'];
|
||||||
|
const currentZoomIndex = zoomLevels.indexOf(viewMode);
|
||||||
|
|
||||||
|
const handleZoomIn = () => {
|
||||||
|
// Zoom in means more detail (lower index)
|
||||||
|
if (currentZoomIndex > 0) {
|
||||||
|
onViewModeChange(zoomLevels[currentZoomIndex - 1]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleZoomOut = () => {
|
||||||
|
// Zoom out means less detail (higher index)
|
||||||
|
if (currentZoomIndex < zoomLevels.length - 1) {
|
||||||
|
onViewModeChange(zoomLevels[currentZoomIndex + 1]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFullscreen = () => {
|
||||||
|
// Toggle fullscreen mode
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
document.documentElement.requestFullscreen().catch(err => {
|
||||||
|
console.warn('Failed to enter fullscreen:', err);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
document.exitFullscreen().catch(err => {
|
||||||
|
console.warn('Failed to exit fullscreen:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="p-4 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
|
||||||
|
<Space>
|
||||||
|
<Select value={viewMode} onChange={onViewModeChange} className="w-32">
|
||||||
|
<Option value="day">Day</Option>
|
||||||
|
<Option value="week">Week</Option>
|
||||||
|
<Option value="month">Month</Option>
|
||||||
|
<Option value="quarter">Quarter</Option>
|
||||||
|
<Option value="year">Year</Option>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
icon={<ZoomInOutlined />}
|
||||||
|
title="Zoom In"
|
||||||
|
onClick={handleZoomIn}
|
||||||
|
disabled={currentZoomIndex === 0}
|
||||||
|
className="hover:text-blue-600 dark:hover:text-blue-400 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon={<ZoomOutOutlined />}
|
||||||
|
title="Zoom Out"
|
||||||
|
onClick={handleZoomOut}
|
||||||
|
disabled={currentZoomIndex === zoomLevels.length - 1}
|
||||||
|
className="hover:text-blue-600 dark:hover:text-blue-400 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon={<FullscreenOutlined />}
|
||||||
|
title="Toggle Fullscreen"
|
||||||
|
onClick={handleFullscreen}
|
||||||
|
className="hover:text-blue-600 dark:hover:text-blue-400"
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
GanttToolbar.displayName = 'GanttToolbar';
|
||||||
|
|
||||||
|
export default GanttToolbar;
|
||||||
@@ -0,0 +1,630 @@
|
|||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import { Modal, Typography, Divider, Progress, Tag, Row, Col, Card, Statistic, theme, Tooltip, Input, DatePicker, ColorPicker, message } from 'antd';
|
||||||
|
import { CalendarOutlined, CheckCircleOutlined, ClockCircleOutlined, BgColorsOutlined, MinusOutlined, PauseOutlined, DoubleRightOutlined, UserOutlined } from '@ant-design/icons';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import AvatarGroup from '@/components/AvatarGroup';
|
||||||
|
import { GanttTask } from '../../types/gantt-types';
|
||||||
|
import { useUpdatePhaseMutation } from '../../services/gantt-api.service';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
interface PhaseDetailsModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
phase: GanttTask | null;
|
||||||
|
onPhaseUpdate?: (phase: Partial<GanttTask>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PhaseDetailsModal: React.FC<PhaseDetailsModalProps> = ({ open, onClose, phase, onPhaseUpdate }) => {
|
||||||
|
const { projectId } = useParams<{ projectId: string }>();
|
||||||
|
const { t } = useTranslation('gantt/phase-details-modal');
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
|
||||||
|
// API mutation hook
|
||||||
|
const [updatePhase, { isLoading: isUpdating }] = useUpdatePhaseMutation();
|
||||||
|
|
||||||
|
// Inline editing state
|
||||||
|
const [editingField, setEditingField] = useState<string | null>(null);
|
||||||
|
const [editedValues, setEditedValues] = useState<Partial<GanttTask>>({});
|
||||||
|
|
||||||
|
// Calculate phase statistics
|
||||||
|
const phaseStats = useMemo(() => {
|
||||||
|
if (!phase || !phase.children) {
|
||||||
|
return {
|
||||||
|
totalTasks: 0,
|
||||||
|
completedTasks: 0,
|
||||||
|
pendingTasks: 0,
|
||||||
|
overdueTasks: 0,
|
||||||
|
completionPercentage: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalTasks = phase.children.length;
|
||||||
|
const completedTasks = phase.children.filter(task => task.progress === 100).length;
|
||||||
|
const pendingTasks = totalTasks - completedTasks;
|
||||||
|
|
||||||
|
// Calculate overdue tasks (tasks with end_date in the past and progress < 100)
|
||||||
|
const now = new Date();
|
||||||
|
const overdueTasks = phase.children.filter(task =>
|
||||||
|
task.end_date &&
|
||||||
|
new Date(task.end_date) < now &&
|
||||||
|
task.progress < 100
|
||||||
|
).length;
|
||||||
|
|
||||||
|
const completionPercentage = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalTasks,
|
||||||
|
completedTasks,
|
||||||
|
pendingTasks,
|
||||||
|
overdueTasks,
|
||||||
|
completionPercentage,
|
||||||
|
};
|
||||||
|
}, [phase]);
|
||||||
|
|
||||||
|
const formatDate = (date: Date | null) => {
|
||||||
|
if (!date) return t('timeline.notSet');
|
||||||
|
return dayjs(date).format('MMM DD, YYYY');
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDateStatus = () => {
|
||||||
|
if (!phase?.start_date || !phase?.end_date) return 'not-set';
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const startDate = new Date(phase.start_date);
|
||||||
|
const endDate = new Date(phase.end_date);
|
||||||
|
|
||||||
|
if (now < startDate) return 'upcoming';
|
||||||
|
if (now > endDate) return 'overdue';
|
||||||
|
return 'active';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDateStatusColor = () => {
|
||||||
|
const status = getDateStatus();
|
||||||
|
switch (status) {
|
||||||
|
case 'upcoming': return '#1890ff';
|
||||||
|
case 'active': return '#52c41a';
|
||||||
|
case 'overdue': return '#ff4d4f';
|
||||||
|
default: return '#8c8c8c';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDateStatusText = () => {
|
||||||
|
const status = getDateStatus();
|
||||||
|
switch (status) {
|
||||||
|
case 'upcoming': return t('timeline.statusLabels.upcoming');
|
||||||
|
case 'active': return t('timeline.statusLabels.active');
|
||||||
|
case 'overdue': return t('timeline.statusLabels.overdue');
|
||||||
|
default: return t('timeline.statusLabels.notScheduled');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTaskStatus = (task: GanttTask) => {
|
||||||
|
if (task.progress === 100) return 'completed';
|
||||||
|
if (task.end_date && new Date(task.end_date) < new Date() && task.progress < 100) return 'overdue';
|
||||||
|
if (task.start_date && new Date(task.start_date) > new Date()) return 'upcoming';
|
||||||
|
return 'in-progress';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTaskStatusText = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed': return 'Completed';
|
||||||
|
case 'overdue': return 'Overdue';
|
||||||
|
case 'upcoming': return 'Upcoming';
|
||||||
|
case 'in-progress': return 'In Progress';
|
||||||
|
default: return 'Not Started';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTaskStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed': return token.colorSuccess;
|
||||||
|
case 'overdue': return token.colorError;
|
||||||
|
case 'upcoming': return token.colorPrimary;
|
||||||
|
case 'in-progress': return token.colorWarning;
|
||||||
|
default: return token.colorTextTertiary;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPriorityIcon = (priority: string) => {
|
||||||
|
const priorityLower = priority?.toLowerCase();
|
||||||
|
switch (priorityLower) {
|
||||||
|
case 'low':
|
||||||
|
return <MinusOutlined className="w-3 h-3" />;
|
||||||
|
case 'medium':
|
||||||
|
return <PauseOutlined className="w-3 h-3" style={{ transform: 'rotate(90deg)' }} />;
|
||||||
|
case 'high':
|
||||||
|
return <DoubleRightOutlined className="w-3 h-3" style={{ transform: 'rotate(90deg)' }} />;
|
||||||
|
default:
|
||||||
|
return <MinusOutlined className="w-3 h-3" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPriorityColor = (priority: string) => {
|
||||||
|
const priorityLower = priority?.toLowerCase();
|
||||||
|
switch (priorityLower) {
|
||||||
|
case 'low': return '#52c41a';
|
||||||
|
case 'medium': return '#faad14';
|
||||||
|
case 'high': return '#ff4d4f';
|
||||||
|
default: return token.colorTextTertiary;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const convertAssigneesToMembers = (assignees: string[] | undefined) => {
|
||||||
|
if (!assignees || assignees.length === 0) return [];
|
||||||
|
|
||||||
|
return assignees.map((assignee, index) => ({
|
||||||
|
id: `assignee-${index}`,
|
||||||
|
name: assignee,
|
||||||
|
color_code: token.colorPrimary,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFieldSave = async (field: string, value: any) => {
|
||||||
|
if (!phase || !projectId) {
|
||||||
|
message.error('Phase or project information is missing');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the actual phase_id from the phase object
|
||||||
|
const phaseId = phase.phase_id || (phase.id.startsWith('phase-') ? phase.id.replace('phase-', '') : phase.id);
|
||||||
|
|
||||||
|
if (!phaseId || phaseId === 'unmapped') {
|
||||||
|
message.error('Cannot edit unmapped phase');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Prepare API request based on field
|
||||||
|
const updateData: any = {
|
||||||
|
phase_id: phaseId,
|
||||||
|
project_id: projectId,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Map the field to API format
|
||||||
|
if (field === 'name') {
|
||||||
|
updateData.name = value;
|
||||||
|
} else if (field === 'color') {
|
||||||
|
updateData.color_code = value;
|
||||||
|
} else if (field === 'start_date') {
|
||||||
|
updateData.start_date = value ? new Date(value).toISOString() : null;
|
||||||
|
} else if (field === 'end_date') {
|
||||||
|
updateData.end_date = value ? new Date(value).toISOString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call the API
|
||||||
|
await updatePhase(updateData).unwrap();
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
message.success(`Phase ${field.replace('_', ' ')} updated successfully`);
|
||||||
|
|
||||||
|
// Call the parent handler to refresh data
|
||||||
|
if (onPhaseUpdate) {
|
||||||
|
onPhaseUpdate({
|
||||||
|
id: phase.id,
|
||||||
|
[field]: value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear editing state
|
||||||
|
setEditingField(null);
|
||||||
|
setEditedValues({});
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to update phase:', error);
|
||||||
|
message.error(error?.data?.message || `Failed to update phase ${field.replace('_', ' ')}`);
|
||||||
|
|
||||||
|
// Don't clear editing state on error so user can try again
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFieldCancel = () => {
|
||||||
|
setEditingField(null);
|
||||||
|
setEditedValues({});
|
||||||
|
};
|
||||||
|
|
||||||
|
const startEditing = (field: string, currentValue: any) => {
|
||||||
|
setEditingField(field);
|
||||||
|
setEditedValues({ [field]: currentValue });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!phase) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<ColorPicker
|
||||||
|
value={phase.color || token.colorPrimary}
|
||||||
|
onChange={(color) => handleFieldSave('color', color.toHexString())}
|
||||||
|
size="small"
|
||||||
|
showText={false}
|
||||||
|
trigger="click"
|
||||||
|
/>
|
||||||
|
{editingField === 'name' ? (
|
||||||
|
<Input
|
||||||
|
value={editedValues.name || phase.name}
|
||||||
|
onChange={(e) => setEditedValues(prev => ({ ...prev, name: e.target.value }))}
|
||||||
|
onPressEnter={() => handleFieldSave('name', editedValues.name)}
|
||||||
|
onBlur={() => handleFieldSave('name', editedValues.name)}
|
||||||
|
onKeyDown={(e) => e.key === 'Escape' && handleFieldCancel()}
|
||||||
|
className="font-semibold text-lg"
|
||||||
|
style={{ border: 'none', padding: 0, background: 'transparent' }}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Title
|
||||||
|
level={4}
|
||||||
|
className="!mb-0 cursor-pointer hover:opacity-70"
|
||||||
|
style={{ color: token.colorText }}
|
||||||
|
onClick={() => startEditing('name', phase.name)}
|
||||||
|
title="Click to edit"
|
||||||
|
>
|
||||||
|
{phase.name}
|
||||||
|
</Title>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
open={open}
|
||||||
|
onCancel={onClose}
|
||||||
|
footer={null}
|
||||||
|
width={1000}
|
||||||
|
centered
|
||||||
|
className="phase-details-modal"
|
||||||
|
confirmLoading={isUpdating}
|
||||||
|
>
|
||||||
|
<div className="flex gap-6">
|
||||||
|
{/* Left Side - Phase Overview and Stats */}
|
||||||
|
<div className="flex-1 space-y-6">
|
||||||
|
{/* Phase Overview */}
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
className="shadow-sm"
|
||||||
|
style={{ backgroundColor: token.colorBgContainer, borderColor: token.colorBorder }}
|
||||||
|
>
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Statistic
|
||||||
|
title={t('overview.totalTasks')}
|
||||||
|
value={phaseStats.totalTasks}
|
||||||
|
prefix={<ClockCircleOutlined style={{ color: token.colorPrimary }} />}
|
||||||
|
valueStyle={{ color: token.colorText }}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Statistic
|
||||||
|
title={t('overview.completion')}
|
||||||
|
value={phaseStats.completionPercentage}
|
||||||
|
suffix="%"
|
||||||
|
prefix={<CheckCircleOutlined style={{ color: token.colorSuccess }} />}
|
||||||
|
valueStyle={{ color: token.colorText }}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Divider className="my-4" style={{ borderColor: token.colorBorder }} />
|
||||||
|
<Progress
|
||||||
|
percent={phaseStats.completionPercentage}
|
||||||
|
strokeColor={{
|
||||||
|
'0%': phase.color || token.colorPrimary,
|
||||||
|
'100%': phase.color || token.colorPrimary,
|
||||||
|
}}
|
||||||
|
trailColor={token.colorBgLayout}
|
||||||
|
className="mb-2"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Date Information */}
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
title={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CalendarOutlined style={{ color: token.colorPrimary }} />
|
||||||
|
<Text strong style={{ color: token.colorText }}>{t('timeline.title')}</Text>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
className="shadow-sm"
|
||||||
|
style={{ backgroundColor: token.colorBgContainer, borderColor: token.colorBorder }}
|
||||||
|
>
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
<Col span={8}>
|
||||||
|
<Text type="secondary">{t('timeline.startDate')}</Text>
|
||||||
|
<br />
|
||||||
|
{editingField === 'start_date' ? (
|
||||||
|
<DatePicker
|
||||||
|
value={editedValues.start_date ? dayjs(editedValues.start_date) : (phase.start_date ? dayjs(phase.start_date) : null)}
|
||||||
|
onChange={(date) => {
|
||||||
|
const newDate = date?.toDate() || null;
|
||||||
|
setEditedValues(prev => ({ ...prev, start_date: newDate }));
|
||||||
|
handleFieldSave('start_date', newDate);
|
||||||
|
}}
|
||||||
|
size="small"
|
||||||
|
className="w-full"
|
||||||
|
placeholder="Select start date"
|
||||||
|
autoFocus
|
||||||
|
open={true}
|
||||||
|
onOpenChange={(open) => !open && handleFieldCancel()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text
|
||||||
|
strong
|
||||||
|
className="cursor-pointer hover:opacity-70"
|
||||||
|
style={{ color: token.colorText }}
|
||||||
|
onClick={() => startEditing('start_date', phase.start_date)}
|
||||||
|
title="Click to edit"
|
||||||
|
>
|
||||||
|
{formatDate(phase.start_date)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Text type="secondary">{t('timeline.endDate')}</Text>
|
||||||
|
<br />
|
||||||
|
{editingField === 'end_date' ? (
|
||||||
|
<DatePicker
|
||||||
|
value={editedValues.end_date ? dayjs(editedValues.end_date) : (phase.end_date ? dayjs(phase.end_date) : null)}
|
||||||
|
onChange={(date) => {
|
||||||
|
const newDate = date?.toDate() || null;
|
||||||
|
setEditedValues(prev => ({ ...prev, end_date: newDate }));
|
||||||
|
handleFieldSave('end_date', newDate);
|
||||||
|
}}
|
||||||
|
size="small"
|
||||||
|
className="w-full"
|
||||||
|
placeholder="Select end date"
|
||||||
|
autoFocus
|
||||||
|
open={true}
|
||||||
|
onOpenChange={(open) => !open && handleFieldCancel()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text
|
||||||
|
strong
|
||||||
|
className="cursor-pointer hover:opacity-70"
|
||||||
|
style={{ color: token.colorText }}
|
||||||
|
onClick={() => startEditing('end_date', phase.end_date)}
|
||||||
|
title="Click to edit"
|
||||||
|
>
|
||||||
|
{formatDate(phase.end_date)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Text type="secondary">{t('timeline.status')}</Text>
|
||||||
|
<br />
|
||||||
|
<Tag color={getDateStatusColor()}>{getDateStatusText()}</Tag>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Task Breakdown */}
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
title={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CheckCircleOutlined style={{ color: token.colorSuccess }} />
|
||||||
|
<Text strong style={{ color: token.colorText }}>{t('taskBreakdown.title')}</Text>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
className="shadow-sm"
|
||||||
|
style={{ backgroundColor: token.colorBgContainer, borderColor: token.colorBorder }}
|
||||||
|
>
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
<Col span={8}>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold text-green-500 dark:text-green-400">
|
||||||
|
{phaseStats.completedTasks}
|
||||||
|
</div>
|
||||||
|
<Text type="secondary">{t('taskBreakdown.completed')}</Text>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold text-yellow-500 dark:text-yellow-400">
|
||||||
|
{phaseStats.pendingTasks}
|
||||||
|
</div>
|
||||||
|
<Text type="secondary">{t('taskBreakdown.pending')}</Text>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold text-red-500 dark:text-red-400">
|
||||||
|
{phaseStats.overdueTasks}
|
||||||
|
</div>
|
||||||
|
<Text type="secondary">{t('taskBreakdown.overdue')}</Text>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Color Information */}
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
title={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BgColorsOutlined style={{ color: token.colorPrimary }} />
|
||||||
|
<Text strong style={{ color: token.colorText }}>{t('phaseColor.title')}</Text>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
className="shadow-sm"
|
||||||
|
style={{ backgroundColor: token.colorBgContainer, borderColor: token.colorBorder }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className="w-10 h-10 rounded-lg border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: phase.color || token.colorPrimary,
|
||||||
|
borderColor: token.colorBorder,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Text strong style={{ color: token.colorText }}>{phase.color || token.colorPrimary}</Text>
|
||||||
|
<br />
|
||||||
|
<Text type="secondary">{t('phaseColor.description')}</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Side - Task List */}
|
||||||
|
<div className="flex-1 flex flex-col">
|
||||||
|
{phase.children && phase.children.length > 0 ? (
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
title={
|
||||||
|
<Text strong style={{ color: token.colorText }}>{t('tasksInPhase.title')}</Text>
|
||||||
|
}
|
||||||
|
className="shadow-sm flex-1 flex flex-col"
|
||||||
|
style={{ backgroundColor: token.colorBgContainer, borderColor: token.colorBorder }}
|
||||||
|
bodyStyle={{ flex: 1, display: 'flex', flexDirection: 'column', padding: '16px' }}
|
||||||
|
>
|
||||||
|
<div className="space-y-3 flex-1 overflow-y-auto">
|
||||||
|
{phase.children.map((task) => {
|
||||||
|
const taskStatus = getTaskStatus(task);
|
||||||
|
const taskStatusColor = getTaskStatusColor(taskStatus);
|
||||||
|
|
||||||
|
const assigneeMembers = convertAssigneesToMembers(task.assignees);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={task.id}
|
||||||
|
className={`p-3 rounded-md border transition-colors hover:shadow-sm ${
|
||||||
|
task.progress === 100
|
||||||
|
? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800'
|
||||||
|
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: task.progress === 100
|
||||||
|
? undefined
|
||||||
|
: token.colorBgContainer,
|
||||||
|
borderColor: task.progress === 100
|
||||||
|
? undefined
|
||||||
|
: token.colorBorder
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Main row with task info */}
|
||||||
|
<div className="flex items-center justify-between gap-3 mb-2">
|
||||||
|
{/* Left side: Status icon, task name, and priority */}
|
||||||
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
{task.progress === 100 ? (
|
||||||
|
<CheckCircleOutlined
|
||||||
|
className="flex-shrink-0"
|
||||||
|
style={{ color: token.colorSuccess, fontSize: '14px' }}
|
||||||
|
/>
|
||||||
|
) : taskStatus === 'overdue' ? (
|
||||||
|
<ClockCircleOutlined
|
||||||
|
className="flex-shrink-0"
|
||||||
|
style={{ color: token.colorError, fontSize: '14px' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ClockCircleOutlined
|
||||||
|
className="flex-shrink-0"
|
||||||
|
style={{ color: token.colorWarning, fontSize: '14px' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text
|
||||||
|
strong
|
||||||
|
className="text-sm truncate flex-1"
|
||||||
|
style={{ color: token.colorText }}
|
||||||
|
title={task.name}
|
||||||
|
>
|
||||||
|
{task.name}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Priority Icon */}
|
||||||
|
{task.priority && (
|
||||||
|
<Tooltip title={`Priority: ${task.priority}`}>
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center w-5 h-5 rounded flex-shrink-0"
|
||||||
|
style={{
|
||||||
|
backgroundColor: getPriorityColor(task.priority),
|
||||||
|
color: 'white'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getPriorityIcon(task.priority)}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side: Status tag */}
|
||||||
|
<Tag
|
||||||
|
color={taskStatusColor}
|
||||||
|
className="text-xs font-medium flex-shrink-0"
|
||||||
|
>
|
||||||
|
{getTaskStatusText(taskStatus)}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom row with assignees, progress, and due date */}
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
{/* Assignees */}
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
{assigneeMembers.length > 0 ? (
|
||||||
|
<AvatarGroup
|
||||||
|
members={assigneeMembers}
|
||||||
|
maxCount={3}
|
||||||
|
size={20}
|
||||||
|
isDarkMode={token.mode === 'dark'}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-1 text-gray-400">
|
||||||
|
<UserOutlined className="text-xs" />
|
||||||
|
<Text type="secondary" className="text-xs">
|
||||||
|
Unassigned
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Due Date */}
|
||||||
|
<div className="flex items-center justify-end flex-1">
|
||||||
|
{task.end_date ? (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<CalendarOutlined
|
||||||
|
className="text-xs"
|
||||||
|
style={{
|
||||||
|
color: taskStatus === 'overdue' ? token.colorError : token.colorTextTertiary
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
type="secondary"
|
||||||
|
className={`text-xs ${taskStatus === 'overdue' ? 'text-red-500 dark:text-red-400' : ''}`}
|
||||||
|
>
|
||||||
|
{dayjs(task.end_date).format('MMM DD')}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Text type="secondary" className="text-xs italic">
|
||||||
|
No due date
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
className="shadow-sm flex-1 flex items-center justify-center"
|
||||||
|
style={{ backgroundColor: token.colorBgContainer, borderColor: token.colorBorder }}
|
||||||
|
bodyStyle={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||||
|
>
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<ClockCircleOutlined className="text-4xl mb-3" style={{ color: token.colorTextTertiary }} />
|
||||||
|
<Text type="secondary" className="text-lg">
|
||||||
|
{t('tasksInPhase.noTasks')}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PhaseDetailsModal;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export const GANTT_COLUMN_WIDTH = 80; // Base column width in pixels
|
||||||
|
|
||||||
|
export const getColumnWidth = (viewMode: string): number => {
|
||||||
|
switch (viewMode) {
|
||||||
|
case 'day':
|
||||||
|
return 40;
|
||||||
|
case 'week':
|
||||||
|
return 60;
|
||||||
|
case 'month':
|
||||||
|
return 80;
|
||||||
|
case 'quarter':
|
||||||
|
return 120;
|
||||||
|
case 'year':
|
||||||
|
return 160;
|
||||||
|
default:
|
||||||
|
return 80;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import React, { createContext, useContext } from 'react';
|
||||||
|
import { GanttContextType } from '../types/gantt-types';
|
||||||
|
|
||||||
|
const GanttContext = createContext<GanttContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const GanttProvider: React.FC<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
value: GanttContextType;
|
||||||
|
}> = ({ children, value }) => {
|
||||||
|
return <GanttContext.Provider value={value}>{children}</GanttContext.Provider>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useGanttContext = () => {
|
||||||
|
const context = useContext(GanttContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useGanttContext must be used within a GanttProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||||
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide scrollbar for IE, Edge and Firefox */
|
||||||
|
.scrollbar-hide {
|
||||||
|
-ms-overflow-style: none; /* IE and Edge */
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gantt task list specific styles */
|
||||||
|
.gantt-task-list-container {
|
||||||
|
/* Ensure the task list stays fixed and doesn't interfere with timeline scrolling */
|
||||||
|
position: relative;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gantt-timeline-container {
|
||||||
|
/* Ensure timeline scrolling doesn't affect task list positioning */
|
||||||
|
position: relative;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar for task list - hide scrollbar */
|
||||||
|
.gantt-task-list-scroll {
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
scrollbar-width: none; /* Firefox - hide scrollbar */
|
||||||
|
-ms-overflow-style: none; /* IE and Edge - hide scrollbar */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Webkit scrollbar styling - hide scrollbar */
|
||||||
|
.gantt-task-list-scroll::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gantt chart scrollbar - show both vertical and horizontal */
|
||||||
|
.gantt-chart-scroll::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gantt-chart-scroll::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gantt-chart-scroll::-webkit-scrollbar-thumb {
|
||||||
|
background-color: #cbd5e0;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gantt-chart-scroll::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: #a0aec0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Firefox - hide horizontal scrollbar */
|
||||||
|
.gantt-chart-scroll {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #cbd5e0 transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode chart scrollbar */
|
||||||
|
.dark .gantt-chart-scroll::-webkit-scrollbar-thumb {
|
||||||
|
background-color: #4a5568;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .gantt-chart-scroll::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: #718096;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .gantt-chart-scroll {
|
||||||
|
scrollbar-color: #4a5568 transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure consistent row heights and alignment */
|
||||||
|
.gantt-task-list-scroll,
|
||||||
|
.gantt-chart-scroll {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
/* Ensure same scrolling behavior */
|
||||||
|
scroll-behavior: auto;
|
||||||
|
/* Prevent sub-pixel rendering issues */
|
||||||
|
transform: translateZ(0);
|
||||||
|
will-change: scroll-position;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure all rows have exact same box model */
|
||||||
|
.gantt-task-list-scroll > div > div > div,
|
||||||
|
.gantt-chart-scroll > div > div > div {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Force consistent border rendering */
|
||||||
|
.gantt-task-list-scroll .border-b,
|
||||||
|
.gantt-chart-scroll .border-b {
|
||||||
|
border-bottom-width: 1px;
|
||||||
|
border-bottom-style: solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Improve visual hierarchy for phase rows */
|
||||||
|
.gantt-phase-row {
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gantt-phase-row:hover {
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gantt-phase-row::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 4px;
|
||||||
|
background-color: currentColor;
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: opacity 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gantt-phase-row:hover::before {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Better hover states */
|
||||||
|
.gantt-task-row:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .gantt-task-row:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Improved button styles */
|
||||||
|
.gantt-add-task-btn {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gantt-add-task-btn:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Phase expansion transitions */
|
||||||
|
.gantt-phase-children {
|
||||||
|
overflow: hidden;
|
||||||
|
transition:
|
||||||
|
max-height 0.4s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
opacity 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gantt-phase-children.collapsed {
|
||||||
|
max-height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gantt-phase-children.expanded {
|
||||||
|
max-height: 2000px; /* Adjust based on expected max children */
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Individual task transitions */
|
||||||
|
.gantt-task-row,
|
||||||
|
.gantt-add-task-inline {
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Staggered animation for multiple tasks */
|
||||||
|
.gantt-task-stagger-1 { animation-delay: 0.05s; }
|
||||||
|
.gantt-task-stagger-2 { animation-delay: 0.1s; }
|
||||||
|
.gantt-task-stagger-3 { animation-delay: 0.15s; }
|
||||||
|
.gantt-task-stagger-4 { animation-delay: 0.2s; }
|
||||||
|
.gantt-task-stagger-5 { animation-delay: 0.25s; }
|
||||||
|
|
||||||
|
/* Expand/collapse icon transitions */
|
||||||
|
.gantt-expand-icon {
|
||||||
|
transition: transform 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gantt-expand-icon.expanded {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Task row slide-in animation */
|
||||||
|
.gantt-task-slide-in {
|
||||||
|
animation: slideIn 0.3s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gantt-task-slide-out {
|
||||||
|
animation: slideOut 0.2s ease-in forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
max-height: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
max-height: 36px; /* Height of a task row */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideOut {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
max-height: 36px;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
max-height: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add task button specific styles */
|
||||||
|
.gantt-add-task-inline {
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
animation: fadeIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-5px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Timeline task bar transitions */
|
||||||
|
.gantt-chart-scroll .gantt-task-slide-in {
|
||||||
|
animation: slideInTimeline 0.3s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInTimeline {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px) scale(0.95);
|
||||||
|
max-height: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
max-height: 36px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enhanced timeline task bar styling */
|
||||||
|
.gantt-chart-scroll .relative {
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure timeline task bars have smooth hover transitions */
|
||||||
|
.gantt-chart-scroll .hover\\:bg-gray-50:hover {
|
||||||
|
transition: background-color 0.15s ease-in-out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { useState, useEffect, useCallback, RefObject } from 'react';
|
||||||
|
import { GanttViewMode } from '../types/gantt-types';
|
||||||
|
import { getColumnWidth } from '../constants/gantt-constants';
|
||||||
|
|
||||||
|
export const useGanttDimensions = (
|
||||||
|
viewMode: GanttViewMode,
|
||||||
|
containerRef: RefObject<HTMLDivElement>,
|
||||||
|
columnsCount: number
|
||||||
|
) => {
|
||||||
|
const [containerWidth, setContainerWidth] = useState(0);
|
||||||
|
|
||||||
|
const updateContainerWidth = useCallback(() => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
setContainerWidth(containerRef.current.offsetWidth);
|
||||||
|
}
|
||||||
|
}, [containerRef]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateContainerWidth();
|
||||||
|
window.addEventListener('resize', updateContainerWidth);
|
||||||
|
return () => window.removeEventListener('resize', updateContainerWidth);
|
||||||
|
}, [updateContainerWidth]);
|
||||||
|
|
||||||
|
const baseColumnWidth = getColumnWidth(viewMode);
|
||||||
|
const minTotalWidth = columnsCount * baseColumnWidth;
|
||||||
|
|
||||||
|
// For day/week views with many columns, always use base width to enable scrolling
|
||||||
|
// For month/quarter/year views, stretch to fill container if wider
|
||||||
|
const shouldStretch = viewMode !== 'day' && viewMode !== 'week';
|
||||||
|
|
||||||
|
const actualColumnWidth =
|
||||||
|
shouldStretch && containerWidth > minTotalWidth
|
||||||
|
? containerWidth / columnsCount
|
||||||
|
: baseColumnWidth;
|
||||||
|
|
||||||
|
const totalWidth = columnsCount * actualColumnWidth;
|
||||||
|
|
||||||
|
return {
|
||||||
|
containerWidth,
|
||||||
|
actualColumnWidth,
|
||||||
|
totalWidth,
|
||||||
|
columnsCount,
|
||||||
|
shouldScroll: totalWidth > containerWidth,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
|
||||||
|
import { API_BASE_URL } from '@/shared/constants';
|
||||||
|
import { IServerResponse } from '@/types/common.types';
|
||||||
|
import { getCsrfToken, refreshCsrfToken } from '@/api/api-client';
|
||||||
|
import config from '@/config/env';
|
||||||
|
import { GanttTask, GanttPhase } from '../types/gantt-types';
|
||||||
|
|
||||||
|
const rootUrl = '/gantt';
|
||||||
|
|
||||||
|
export interface RoadmapTasksResponse {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
start_date: string | null;
|
||||||
|
end_date: string | null;
|
||||||
|
done: boolean;
|
||||||
|
progress: number;
|
||||||
|
roadmap_sort_order: number;
|
||||||
|
parent_task_id: string | null;
|
||||||
|
status_name: string;
|
||||||
|
status_color: string;
|
||||||
|
priority_name: string;
|
||||||
|
priority_value: number;
|
||||||
|
priority_color: string;
|
||||||
|
phases: Array<{
|
||||||
|
phase_id: string;
|
||||||
|
phase_name: string;
|
||||||
|
phase_color: string;
|
||||||
|
}>;
|
||||||
|
assignees: Array<{
|
||||||
|
team_member_id: string;
|
||||||
|
assignee_name: string;
|
||||||
|
avatar_url?: string;
|
||||||
|
}>;
|
||||||
|
dependencies: Array<{
|
||||||
|
related_task_id: string;
|
||||||
|
dependency_type: string;
|
||||||
|
related_task_name: string;
|
||||||
|
}>;
|
||||||
|
subtasks: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
start_date: string | null;
|
||||||
|
end_date: string | null;
|
||||||
|
done: boolean;
|
||||||
|
progress: number;
|
||||||
|
roadmap_sort_order: number;
|
||||||
|
parent_task_id: string;
|
||||||
|
phase_id?: string | null; // Keep this for subtasks compatibility
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectPhaseResponse {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color_code: string;
|
||||||
|
start_date: string | null;
|
||||||
|
end_date: string | null;
|
||||||
|
sort_index: number;
|
||||||
|
todo_progress: number;
|
||||||
|
doing_progress: number;
|
||||||
|
done_progress: number;
|
||||||
|
total_tasks: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateTaskDatesRequest {
|
||||||
|
task_id: string;
|
||||||
|
start_date: string;
|
||||||
|
end_date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatePhaseRequest {
|
||||||
|
project_id: string;
|
||||||
|
name: string;
|
||||||
|
color_code?: string;
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTaskRequest {
|
||||||
|
project_id: string;
|
||||||
|
name: string;
|
||||||
|
phase_id?: string;
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
|
priority_id?: string;
|
||||||
|
status_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdatePhaseRequest {
|
||||||
|
phase_id: string;
|
||||||
|
project_id: string;
|
||||||
|
name?: string;
|
||||||
|
color_code?: string;
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ganttApi = createApi({
|
||||||
|
reducerPath: 'ganttApi',
|
||||||
|
baseQuery: fetchBaseQuery({
|
||||||
|
baseUrl: `${config.apiUrl}${API_BASE_URL}`,
|
||||||
|
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',
|
||||||
|
}),
|
||||||
|
tagTypes: ['GanttTasks', 'GanttPhases'],
|
||||||
|
endpoints: builder => ({
|
||||||
|
getRoadmapTasks: builder.query<IServerResponse<RoadmapTasksResponse[]>, { projectId: string }>({
|
||||||
|
query: ({ projectId }) => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
project_id: projectId,
|
||||||
|
});
|
||||||
|
return `${rootUrl}/roadmap-tasks?${params.toString()}`;
|
||||||
|
},
|
||||||
|
providesTags: (result, error, { projectId }) => [
|
||||||
|
{ type: 'GanttTasks', id: projectId },
|
||||||
|
{ type: 'GanttTasks', id: 'LIST' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
|
||||||
|
getProjectPhases: builder.query<IServerResponse<ProjectPhaseResponse[]>, { projectId: string }>(
|
||||||
|
{
|
||||||
|
query: ({ projectId }) => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
project_id: projectId,
|
||||||
|
});
|
||||||
|
return `${rootUrl}/project-phases?${params.toString()}`;
|
||||||
|
},
|
||||||
|
providesTags: (result, error, { projectId }) => [
|
||||||
|
{ type: 'GanttPhases', id: projectId },
|
||||||
|
{ type: 'GanttPhases', id: 'LIST' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
|
||||||
|
updateTaskDates: builder.mutation<IServerResponse<any>, UpdateTaskDatesRequest>({
|
||||||
|
query: body => ({
|
||||||
|
url: `${rootUrl}/update-task-dates`,
|
||||||
|
method: 'POST',
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
invalidatesTags: (result, error, { task_id }) => [{ type: 'GanttTasks', id: 'LIST' }],
|
||||||
|
}),
|
||||||
|
|
||||||
|
createPhase: builder.mutation<IServerResponse<ProjectPhaseResponse>, CreatePhaseRequest>({
|
||||||
|
query: body => ({
|
||||||
|
url: `${rootUrl}/create-phase`,
|
||||||
|
method: 'POST',
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
invalidatesTags: (result, error, { project_id }) => [
|
||||||
|
{ type: 'GanttPhases', id: project_id },
|
||||||
|
{ type: 'GanttPhases', id: 'LIST' },
|
||||||
|
{ type: 'GanttTasks', id: project_id },
|
||||||
|
{ type: 'GanttTasks', id: 'LIST' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
|
||||||
|
createTask: builder.mutation<IServerResponse<RoadmapTasksResponse>, CreateTaskRequest>({
|
||||||
|
query: body => ({
|
||||||
|
url: `${rootUrl}/create-task`,
|
||||||
|
method: 'POST',
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
invalidatesTags: (result, error, { project_id }) => [
|
||||||
|
{ type: 'GanttTasks', id: project_id },
|
||||||
|
{ type: 'GanttTasks', id: 'LIST' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
|
||||||
|
updatePhase: builder.mutation<IServerResponse<ProjectPhaseResponse>, UpdatePhaseRequest>({
|
||||||
|
query: body => ({
|
||||||
|
url: `${rootUrl}/update-phase`,
|
||||||
|
method: 'PUT',
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
invalidatesTags: (result, error, { project_id }) => [
|
||||||
|
{ type: 'GanttPhases', id: project_id },
|
||||||
|
{ type: 'GanttPhases', id: 'LIST' },
|
||||||
|
{ type: 'GanttTasks', id: project_id },
|
||||||
|
{ type: 'GanttTasks', id: 'LIST' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const {
|
||||||
|
useGetRoadmapTasksQuery,
|
||||||
|
useGetProjectPhasesQuery,
|
||||||
|
useUpdateTaskDatesMutation,
|
||||||
|
useCreatePhaseMutation,
|
||||||
|
useCreateTaskMutation,
|
||||||
|
useUpdatePhaseMutation,
|
||||||
|
} = ganttApi;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform API response to Gantt task format with phases as milestones
|
||||||
|
*/
|
||||||
|
export const transformToGanttTasks = (
|
||||||
|
apiTasks: RoadmapTasksResponse[],
|
||||||
|
apiPhases: ProjectPhaseResponse[]
|
||||||
|
): GanttTask[] => {
|
||||||
|
// Group tasks by phase
|
||||||
|
const tasksByPhase = new Map<string, RoadmapTasksResponse[]>();
|
||||||
|
const unassignedTasks: RoadmapTasksResponse[] = [];
|
||||||
|
|
||||||
|
apiTasks.forEach(task => {
|
||||||
|
// Tasks now have phases array instead of direct phase_id
|
||||||
|
const taskPhaseId = task.phases.length > 0 ? task.phases[0].phase_id : null;
|
||||||
|
|
||||||
|
if (taskPhaseId) {
|
||||||
|
if (!tasksByPhase.has(taskPhaseId)) {
|
||||||
|
tasksByPhase.set(taskPhaseId, []);
|
||||||
|
}
|
||||||
|
tasksByPhase.get(taskPhaseId)!.push(task);
|
||||||
|
} else {
|
||||||
|
unassignedTasks.push(task);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result: GanttTask[] = [];
|
||||||
|
|
||||||
|
// Create phase milestones with their tasks (sorted by phase order)
|
||||||
|
[...apiPhases]
|
||||||
|
.sort((a, b) => a.sort_index - b.sort_index)
|
||||||
|
.forEach(phase => {
|
||||||
|
const phaseTasks = tasksByPhase.get(phase.id) || [];
|
||||||
|
|
||||||
|
// Create phase milestone
|
||||||
|
const phaseMilestone: GanttTask = {
|
||||||
|
id: `phase-${phase.id}`,
|
||||||
|
name: phase.name,
|
||||||
|
start_date: phase.start_date ? new Date(phase.start_date) : null,
|
||||||
|
end_date: phase.end_date ? new Date(phase.end_date) : null,
|
||||||
|
progress: 0,
|
||||||
|
level: 0,
|
||||||
|
expanded: true,
|
||||||
|
color: phase.color_code,
|
||||||
|
type: 'milestone',
|
||||||
|
is_milestone: true,
|
||||||
|
phase_id: phase.id,
|
||||||
|
// Pass through phase progress data from backend
|
||||||
|
todo_progress: phase.todo_progress,
|
||||||
|
doing_progress: phase.doing_progress,
|
||||||
|
done_progress: phase.done_progress,
|
||||||
|
total_tasks: phase.total_tasks,
|
||||||
|
children: phaseTasks.map(task => transformTask(task, 1)),
|
||||||
|
};
|
||||||
|
|
||||||
|
result.push(phaseMilestone);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Always create unmapped phase at the bottom (even if empty)
|
||||||
|
const unmappedPhase: GanttTask = {
|
||||||
|
id: 'phase-unmapped',
|
||||||
|
name: 'Unmapped',
|
||||||
|
start_date: null,
|
||||||
|
end_date: null,
|
||||||
|
progress: 0,
|
||||||
|
level: 0,
|
||||||
|
expanded: true,
|
||||||
|
color: '#9CA3AF', // Gray color for unmapped phase
|
||||||
|
type: 'milestone',
|
||||||
|
is_milestone: true,
|
||||||
|
phase_id: null,
|
||||||
|
children: unassignedTasks.map(task => transformTask(task, 1)),
|
||||||
|
};
|
||||||
|
|
||||||
|
result.push(unmappedPhase);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to transform individual task
|
||||||
|
*/
|
||||||
|
const transformTask = (task: RoadmapTasksResponse, level: number = 0): GanttTask => {
|
||||||
|
const taskPhaseId = task.phases.length > 0 ? task.phases[0].phase_id : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: task.id,
|
||||||
|
name: task.name,
|
||||||
|
start_date: task.start_date ? new Date(task.start_date) : null,
|
||||||
|
end_date: task.end_date ? new Date(task.end_date) : null,
|
||||||
|
progress: task.progress,
|
||||||
|
dependencies: task.dependencies.map(dep => dep.related_task_id),
|
||||||
|
dependencyType: (task.dependencies[0]?.dependency_type as any) || 'blocked_by',
|
||||||
|
parent_id: task.parent_task_id,
|
||||||
|
children: task.subtasks.map(subtask => ({
|
||||||
|
id: subtask.id,
|
||||||
|
name: subtask.name,
|
||||||
|
start_date: subtask.start_date ? new Date(subtask.start_date) : null,
|
||||||
|
end_date: subtask.end_date ? new Date(subtask.end_date) : null,
|
||||||
|
progress: subtask.progress,
|
||||||
|
parent_id: subtask.parent_task_id,
|
||||||
|
level: level + 1,
|
||||||
|
type: 'task',
|
||||||
|
phase_id: subtask.phase_id, // Subtasks might still use direct phase_id
|
||||||
|
})),
|
||||||
|
level,
|
||||||
|
expanded: true,
|
||||||
|
color: task.status_color || task.priority_color,
|
||||||
|
assignees: task.assignees.map(a => a.assignee_name),
|
||||||
|
priority: task.priority_name,
|
||||||
|
status: task.status_name,
|
||||||
|
phase_id: taskPhaseId,
|
||||||
|
is_milestone: false,
|
||||||
|
type: 'task',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform API response to Gantt phases format
|
||||||
|
*/
|
||||||
|
export const transformToGanttPhases = (apiPhases: ProjectPhaseResponse[]): GanttPhase[] => {
|
||||||
|
return apiPhases.map(phase => ({
|
||||||
|
id: phase.id,
|
||||||
|
name: phase.name,
|
||||||
|
color_code: phase.color_code,
|
||||||
|
start_date: phase.start_date ? new Date(phase.start_date) : null,
|
||||||
|
end_date: phase.end_date ? new Date(phase.end_date) : null,
|
||||||
|
sort_index: phase.sort_index,
|
||||||
|
todo_progress: phase.todo_progress,
|
||||||
|
doing_progress: phase.doing_progress,
|
||||||
|
done_progress: phase.done_progress,
|
||||||
|
total_tasks: phase.total_tasks,
|
||||||
|
}));
|
||||||
|
};
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
export type GanttViewMode = 'day' | 'week' | 'month' | 'quarter' | 'year';
|
||||||
|
|
||||||
|
export type DependencyType =
|
||||||
|
| 'blocked_by'
|
||||||
|
| 'finish_to_start'
|
||||||
|
| 'start_to_start'
|
||||||
|
| 'finish_to_finish'
|
||||||
|
| 'start_to_finish';
|
||||||
|
|
||||||
|
export interface GanttTask {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
start_date: Date | null;
|
||||||
|
end_date: Date | null;
|
||||||
|
progress: number;
|
||||||
|
dependencies?: string[];
|
||||||
|
dependencyType?: DependencyType;
|
||||||
|
parent_id?: string;
|
||||||
|
children?: GanttTask[];
|
||||||
|
level?: number;
|
||||||
|
expanded?: boolean;
|
||||||
|
color?: string;
|
||||||
|
assignees?: string[];
|
||||||
|
priority?: string;
|
||||||
|
status?: string;
|
||||||
|
phase_id?: string;
|
||||||
|
is_milestone?: boolean;
|
||||||
|
type?: 'task' | 'milestone' | 'phase' | 'add-task-button';
|
||||||
|
// Add task row specific properties
|
||||||
|
parent_phase_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GanttPhase {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color_code: string;
|
||||||
|
start_date: Date | null;
|
||||||
|
end_date: Date | null;
|
||||||
|
sort_index: number;
|
||||||
|
tasks?: GanttTask[];
|
||||||
|
expanded?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GanttMilestone extends Omit<GanttTask, 'type'> {
|
||||||
|
type: 'milestone';
|
||||||
|
phase_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GanttDependency {
|
||||||
|
id: string;
|
||||||
|
task_id: string;
|
||||||
|
related_task_id: string;
|
||||||
|
dependency_type: DependencyType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GanttContextType {
|
||||||
|
tasks: GanttTask[];
|
||||||
|
phases: GanttPhase[];
|
||||||
|
viewMode: GanttViewMode;
|
||||||
|
projectId: string;
|
||||||
|
dateRange: { start: Date; end: Date };
|
||||||
|
onRefresh: () => void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
import { GanttViewMode, GanttTask } from '../types/gantt-types';
|
||||||
|
|
||||||
|
export interface TimelinePosition {
|
||||||
|
left: number;
|
||||||
|
width: number;
|
||||||
|
isValid: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimelineBounds {
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
totalDays: number;
|
||||||
|
pixelsPerDay: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TimelineCalculator {
|
||||||
|
private viewMode: GanttViewMode;
|
||||||
|
private columnWidth: number;
|
||||||
|
private timelineBounds: TimelineBounds;
|
||||||
|
|
||||||
|
constructor(viewMode: GanttViewMode, columnWidth: number, startDate: Date, endDate: Date) {
|
||||||
|
this.viewMode = viewMode;
|
||||||
|
this.columnWidth = columnWidth;
|
||||||
|
this.timelineBounds = this.calculateTimelineBounds(startDate, endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate timeline bounds and pixels per day
|
||||||
|
*/
|
||||||
|
private calculateTimelineBounds(startDate: Date, endDate: Date): TimelineBounds {
|
||||||
|
const totalDays = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
const columnsCount = this.getColumnsCount();
|
||||||
|
const totalWidth = columnsCount * this.columnWidth;
|
||||||
|
const pixelsPerDay = totalWidth / totalDays;
|
||||||
|
|
||||||
|
return {
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
totalDays,
|
||||||
|
pixelsPerDay,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get number of columns based on view mode
|
||||||
|
*/
|
||||||
|
private getColumnsCount(): number {
|
||||||
|
switch (this.viewMode) {
|
||||||
|
case 'day':
|
||||||
|
return 30;
|
||||||
|
case 'week':
|
||||||
|
return 12;
|
||||||
|
case 'month':
|
||||||
|
return 12;
|
||||||
|
case 'quarter':
|
||||||
|
return 8;
|
||||||
|
case 'year':
|
||||||
|
return 5;
|
||||||
|
default:
|
||||||
|
return 12;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate task bar position and width
|
||||||
|
*/
|
||||||
|
calculateTaskPosition(task: GanttTask): TimelinePosition {
|
||||||
|
if (!task.start_date || !task.end_date) {
|
||||||
|
return { left: 0, width: 0, isValid: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskStart = new Date(task.start_date);
|
||||||
|
const taskEnd = new Date(task.end_date);
|
||||||
|
|
||||||
|
// Ensure task dates are within timeline bounds
|
||||||
|
const clampedStart = new Date(
|
||||||
|
Math.max(taskStart.getTime(), this.timelineBounds.startDate.getTime())
|
||||||
|
);
|
||||||
|
const clampedEnd = new Date(Math.min(taskEnd.getTime(), this.timelineBounds.endDate.getTime()));
|
||||||
|
|
||||||
|
// Calculate days from timeline start
|
||||||
|
const daysFromStart = Math.floor(
|
||||||
|
(clampedStart.getTime() - this.timelineBounds.startDate.getTime()) / (1000 * 60 * 60 * 24)
|
||||||
|
);
|
||||||
|
const taskDuration = Math.ceil(
|
||||||
|
(clampedEnd.getTime() - clampedStart.getTime()) / (1000 * 60 * 60 * 24)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate pixel positions
|
||||||
|
const left = daysFromStart * this.timelineBounds.pixelsPerDay;
|
||||||
|
const width = Math.max(taskDuration * this.timelineBounds.pixelsPerDay, 10); // Minimum 10px width
|
||||||
|
|
||||||
|
return {
|
||||||
|
left: Math.max(0, left),
|
||||||
|
width,
|
||||||
|
isValid: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate milestone position (single point in time)
|
||||||
|
*/
|
||||||
|
calculateMilestonePosition(date: Date): { left: number; isValid: boolean } {
|
||||||
|
if (!date) {
|
||||||
|
return { left: 0, isValid: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const milestoneDate = new Date(date);
|
||||||
|
|
||||||
|
// Check if milestone is within timeline bounds
|
||||||
|
if (
|
||||||
|
milestoneDate < this.timelineBounds.startDate ||
|
||||||
|
milestoneDate > this.timelineBounds.endDate
|
||||||
|
) {
|
||||||
|
return { left: 0, isValid: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const daysFromStart = Math.floor(
|
||||||
|
(milestoneDate.getTime() - this.timelineBounds.startDate.getTime()) / (1000 * 60 * 60 * 24)
|
||||||
|
);
|
||||||
|
const left = daysFromStart * this.timelineBounds.pixelsPerDay;
|
||||||
|
|
||||||
|
return {
|
||||||
|
left: Math.max(0, left),
|
||||||
|
isValid: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate dependency line coordinates
|
||||||
|
*/
|
||||||
|
calculateDependencyLine(
|
||||||
|
fromTask: GanttTask,
|
||||||
|
toTask: GanttTask,
|
||||||
|
rowHeight: number = 36
|
||||||
|
): {
|
||||||
|
x1: number;
|
||||||
|
y1: number;
|
||||||
|
x2: number;
|
||||||
|
y2: number;
|
||||||
|
isValid: boolean;
|
||||||
|
} {
|
||||||
|
const fromPosition = this.calculateTaskPosition(fromTask);
|
||||||
|
const toPosition = this.calculateTaskPosition(toTask);
|
||||||
|
|
||||||
|
if (!fromPosition.isValid || !toPosition.isValid) {
|
||||||
|
return { x1: 0, y1: 0, x2: 0, y2: 0, isValid: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assume tasks are in different rows - would need actual row indices in real implementation
|
||||||
|
const fromY = 0; // Would be calculated based on task index * rowHeight
|
||||||
|
const toY = rowHeight; // Would be calculated based on task index * rowHeight
|
||||||
|
|
||||||
|
return {
|
||||||
|
x1: fromPosition.left + fromPosition.width, // End of source task
|
||||||
|
y1: fromY + rowHeight / 2,
|
||||||
|
x2: toPosition.left, // Start of target task
|
||||||
|
y2: toY + rowHeight / 2,
|
||||||
|
isValid: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert pixel position back to date
|
||||||
|
*/
|
||||||
|
pixelToDate(pixelPosition: number): Date {
|
||||||
|
const daysFromStart = pixelPosition / this.timelineBounds.pixelsPerDay;
|
||||||
|
const targetDate = new Date(this.timelineBounds.startDate);
|
||||||
|
targetDate.setDate(targetDate.getDate() + daysFromStart);
|
||||||
|
return targetDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get today line position
|
||||||
|
*/
|
||||||
|
getTodayLinePosition(): { left: number; isVisible: boolean } {
|
||||||
|
const today = new Date();
|
||||||
|
const position = this.calculateMilestonePosition(today);
|
||||||
|
|
||||||
|
return {
|
||||||
|
left: position.left,
|
||||||
|
isVisible: position.isValid,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate weekend/holiday shading areas
|
||||||
|
*/
|
||||||
|
getWeekendAreas(): Array<{ left: number; width: number }> {
|
||||||
|
const weekendAreas: Array<{ left: number; width: number }> = [];
|
||||||
|
const current = new Date(this.timelineBounds.startDate);
|
||||||
|
|
||||||
|
while (current <= this.timelineBounds.endDate) {
|
||||||
|
// Saturday (6) and Sunday (0)
|
||||||
|
if (current.getDay() === 0 || current.getDay() === 6) {
|
||||||
|
const position = this.calculateMilestonePosition(current);
|
||||||
|
if (position.isValid) {
|
||||||
|
weekendAreas.push({
|
||||||
|
left: position.left,
|
||||||
|
width: this.timelineBounds.pixelsPerDay,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current.setDate(current.getDate() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return weekendAreas;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get timeline bounds for external use
|
||||||
|
*/
|
||||||
|
getTimelineBounds(): TimelineBounds {
|
||||||
|
return { ...this.timelineBounds };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update calculator with new parameters
|
||||||
|
*/
|
||||||
|
updateParameters(
|
||||||
|
viewMode: GanttViewMode,
|
||||||
|
columnWidth: number,
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date
|
||||||
|
): void {
|
||||||
|
this.viewMode = viewMode;
|
||||||
|
this.columnWidth = columnWidth;
|
||||||
|
this.timelineBounds = this.calculateTimelineBounds(startDate, endDate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility functions for timeline calculations
|
||||||
|
*/
|
||||||
|
export const TimelineUtils = {
|
||||||
|
/**
|
||||||
|
* Get smart timeline date range based on project tasks
|
||||||
|
*/
|
||||||
|
getSmartDateRange(tasks: GanttTask[], viewMode: GanttViewMode): { start: Date; end: Date } {
|
||||||
|
if (!tasks.length) {
|
||||||
|
// Default to current year
|
||||||
|
const start = new Date();
|
||||||
|
start.setMonth(0, 1); // January 1st
|
||||||
|
const end = new Date();
|
||||||
|
end.setMonth(11, 31); // December 31st
|
||||||
|
return { start, end };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find earliest start date and latest end date
|
||||||
|
let earliestStart: Date | null = null;
|
||||||
|
let latestEnd: Date | null = null;
|
||||||
|
|
||||||
|
tasks.forEach(task => {
|
||||||
|
if (task.start_date) {
|
||||||
|
if (!earliestStart || task.start_date < earliestStart) {
|
||||||
|
earliestStart = task.start_date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (task.end_date) {
|
||||||
|
if (!latestEnd || task.end_date > latestEnd) {
|
||||||
|
latestEnd = task.end_date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check subtasks too
|
||||||
|
if (task.children) {
|
||||||
|
task.children.forEach(subtask => {
|
||||||
|
if (subtask.start_date && (!earliestStart || subtask.start_date < earliestStart)) {
|
||||||
|
earliestStart = subtask.start_date;
|
||||||
|
}
|
||||||
|
if (subtask.end_date && (!latestEnd || subtask.end_date > latestEnd)) {
|
||||||
|
latestEnd = subtask.end_date;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add padding based on view mode
|
||||||
|
const start = earliestStart || new Date();
|
||||||
|
const end = latestEnd || new Date();
|
||||||
|
|
||||||
|
switch (viewMode) {
|
||||||
|
case 'day':
|
||||||
|
start.setDate(start.getDate() - 7); // 1 week before
|
||||||
|
end.setDate(end.getDate() + 7); // 1 week after
|
||||||
|
break;
|
||||||
|
case 'week':
|
||||||
|
start.setDate(start.getDate() - 14); // 2 weeks before
|
||||||
|
end.setDate(end.getDate() + 14); // 2 weeks after
|
||||||
|
break;
|
||||||
|
case 'month':
|
||||||
|
start.setMonth(start.getMonth() - 1); // 1 month before
|
||||||
|
end.setMonth(end.getMonth() + 1); // 1 month after
|
||||||
|
break;
|
||||||
|
case 'quarter':
|
||||||
|
start.setMonth(start.getMonth() - 3); // 1 quarter before
|
||||||
|
end.setMonth(end.getMonth() + 3); // 1 quarter after
|
||||||
|
break;
|
||||||
|
case 'year':
|
||||||
|
start.setFullYear(start.getFullYear() - 1); // 1 year before
|
||||||
|
end.setFullYear(end.getFullYear() + 1); // 1 year after
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { start, end };
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date based on view mode
|
||||||
|
*/
|
||||||
|
formatDateForViewMode(date: Date, viewMode: GanttViewMode): string {
|
||||||
|
switch (viewMode) {
|
||||||
|
case 'day':
|
||||||
|
return date.toLocaleDateString('en-US', { day: '2-digit', month: 'short' });
|
||||||
|
case 'week':
|
||||||
|
return `Week ${this.getWeekNumber(date)}`;
|
||||||
|
case 'month':
|
||||||
|
return date.toLocaleDateString('en-US', { month: 'short' });
|
||||||
|
case 'quarter':
|
||||||
|
return `Q${Math.ceil((date.getMonth() + 1) / 3)} ${date.getFullYear()}`;
|
||||||
|
case 'year':
|
||||||
|
return date.getFullYear().toString();
|
||||||
|
default:
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get week number of the year
|
||||||
|
*/
|
||||||
|
getWeekNumber(date: Date): number {
|
||||||
|
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
||||||
|
const dayNum = d.getUTCDay() || 7;
|
||||||
|
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
|
||||||
|
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||||||
|
return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -360,14 +360,14 @@ const ProjectView = React.memo(() => {
|
|||||||
// Show loading state while project is being fetched or translations are loading
|
// Show loading state while project is being fetched or translations are loading
|
||||||
if (projectLoading || !isInitialized || !translationsReady) {
|
if (projectLoading || !isInitialized || !translationsReady) {
|
||||||
return (
|
return (
|
||||||
<div style={{ marginBlockStart: 70, marginBlockEnd: 12, minHeight: '80vh' }}>
|
<div style={{ marginBlockEnd: 12, minHeight: '80vh' }}>
|
||||||
<SuspenseFallback />
|
<SuspenseFallback />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ marginBlockStart: 70, marginBlockEnd: 12, minHeight: '80vh' }}>
|
<div style={{ marginBlockEnd: 12, minHeight: '80vh' }}>
|
||||||
<ProjectViewHeader />
|
<ProjectViewHeader />
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
|
|||||||
@@ -28,9 +28,8 @@ const ProjectsReports = () => {
|
|||||||
|
|
||||||
// Memoize the Excel export handler to prevent recreation on every render
|
// Memoize the Excel export handler to prevent recreation on every render
|
||||||
const handleExcelExport = useCallback(() => {
|
const handleExcelExport = useCallback(() => {
|
||||||
if (currentSession?.team_name) {
|
const teamName = currentSession?.team_name || 'Team';
|
||||||
reportingExportApiService.exportProjects(currentSession.team_name);
|
reportingExportApiService.exportProjects(teamName);
|
||||||
}
|
|
||||||
}, [currentSession?.team_name]);
|
}, [currentSession?.team_name]);
|
||||||
|
|
||||||
// Memoize the archived checkbox handler to prevent recreation on every render
|
// Memoize the archived checkbox handler to prevent recreation on every render
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ const Schedule: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ marginBlockStart: 65, minHeight: '90vh' }}>
|
<div style={{ minHeight: '90vh' }}>
|
||||||
<Flex align="center" justify="space-between">
|
<Flex align="center" justify="space-between">
|
||||||
<Flex
|
<Flex
|
||||||
gap={16}
|
gap={16}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user