Compare commits
5 Commits
feature/cu
...
release-v2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
210a9a7aba | ||
|
|
6e37208f62 | ||
|
|
136dac17fb | ||
|
|
884cb9c462 | ||
|
|
d1bd36e0a4 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -78,7 +78,4 @@ $RECYCLE.BIN/
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
|
||||
# Claude
|
||||
CLAUDE.md
|
||||
|
||||
|
||||
|
||||
@@ -94,194 +94,4 @@ export default class GanttController extends WorklenzControllerBase {
|
||||
}
|
||||
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]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import db from "../config/db";
|
||||
import {ServerResponse} from "../models/server-response";
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
import {TASK_PRIORITY_COLOR_ALPHA, WorklenzColorCodes} from "../shared/constants";
|
||||
import {TASK_PRIORITY_COLOR_ALPHA, WorklenzColorCodes, WorklenzColorShades} from "../shared/constants";
|
||||
|
||||
export default class LabelsController extends WorklenzControllerBase {
|
||||
@HandleExceptions()
|
||||
@@ -73,13 +73,44 @@ export default class LabelsController extends WorklenzControllerBase {
|
||||
WHERE id = $1
|
||||
AND team_id = $2;`;
|
||||
|
||||
if (!WorklenzColorCodes.includes(req.body.color))
|
||||
if (!Object.values(WorklenzColorShades).flat().includes(req.body.color))
|
||||
return res.status(400).send(new ServerResponse(false, null));
|
||||
|
||||
const result = await db.query(q, [req.params.id, req.user?.team_id, req.body.color]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async updateLabel(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const updates = [];
|
||||
const values = [req.params.id, req.user?.team_id];
|
||||
let paramIndex = 3;
|
||||
|
||||
if (req.body.name) {
|
||||
updates.push(`name = $${paramIndex++}`);
|
||||
values.push(req.body.name);
|
||||
}
|
||||
|
||||
if (req.body.color) {
|
||||
if (!Object.values(WorklenzColorShades).flat().includes(req.body.color))
|
||||
return res.status(400).send(new ServerResponse(false, null));
|
||||
updates.push(`color_code = $${paramIndex++}`);
|
||||
values.push(req.body.color);
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return res.status(400).send(new ServerResponse(false, "No valid fields to update"));
|
||||
}
|
||||
|
||||
const q = `UPDATE team_labels
|
||||
SET ${updates.join(', ')}
|
||||
WHERE id = $1
|
||||
AND team_id = $2;`;
|
||||
|
||||
const result = await db.query(q, values);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async deleteById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `DELETE
|
||||
|
||||
@@ -8,7 +8,7 @@ import {PASSWORD_POLICY} from "../../shared/constants";
|
||||
function isStrongPassword(password: string) {
|
||||
if (!isProduction()) return true;
|
||||
const strength = PasswordStrengthChecker.validate(password);
|
||||
return strength.value >= 2 && strength.length < 32;
|
||||
return strength.value >= 2 && strength.length <= 32;
|
||||
}
|
||||
|
||||
export default function (req: Request, res: Response, next: NextFunction) {
|
||||
|
||||
@@ -12,9 +12,4 @@ ganttApiRouter.get("/project-phases/:id", safeControllerFunction(GanttController
|
||||
|
||||
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;
|
||||
@@ -11,6 +11,7 @@ labelsApiRouter.get("/", safeControllerFunction(LabelsController.get));
|
||||
labelsApiRouter.get("/tasks/:id", idParamValidator, safeControllerFunction(LabelsController.getByTask));
|
||||
labelsApiRouter.get("/project/:id", idParamValidator, safeControllerFunction(LabelsController.getByProject));
|
||||
labelsApiRouter.put("/tasks/:id", idParamValidator, teamOwnerOrAdminValidator, safeControllerFunction(LabelsController.updateColor));
|
||||
labelsApiRouter.put("/team/:id", idParamValidator, teamOwnerOrAdminValidator, safeControllerFunction(LabelsController.updateLabel));
|
||||
labelsApiRouter.delete("/team/:id", idParamValidator, teamOwnerOrAdminValidator, safeControllerFunction(LabelsController.deleteById));
|
||||
|
||||
export default labelsApiRouter;
|
||||
|
||||
@@ -6,7 +6,7 @@ export const DEFAULT_ERROR_MESSAGE = "Unknown error has occurred.";
|
||||
export const SessionsStatus = {
|
||||
IDLE: "IDLE",
|
||||
STARTED: "STARTED",
|
||||
ENDED: "ENDED"
|
||||
ENDED: "ENDED",
|
||||
};
|
||||
|
||||
export const LOG_DESCRIPTIONS = {
|
||||
@@ -18,6 +18,33 @@ export const LOG_DESCRIPTIONS = {
|
||||
PROJECT_MEMBER_REMOVED: "was removed from the project by",
|
||||
};
|
||||
|
||||
export const WorklenzColorShades = {
|
||||
"#154c9b": ["#0D2A50", "#112E54", "#153258", "#19365C", "#1D3A60", "#213E64", "#254268", "#29466C", "#2D4A70", "#314E74"],
|
||||
"#3b7ad4": ["#224884", "#26528A", "#2A5C90", "#2E6696", "#32709C", "#367AA2", "#3A84A8", "#3E8EAE", "#4298B4", "#46A2BA"],
|
||||
"#70a6f3": ["#3D5D8A", "#46679E", "#5071B2", "#597BC6", "#6385DA", "#6C8FEE", "#7699F2", "#7FA3F6", "#89ADFA", "#92B7FE"],
|
||||
"#7781ca": ["#42486F", "#4C5283", "#565C97", "#6066AB", "#6A70BF", "#747AD3", "#7E84E7", "#888EFB", "#9298FF", "#9CA2FF"],
|
||||
"#9877ca": ["#542D70", "#6E3A8A", "#8847A4", "#A254BE", "#BC61D8", "#D66EF2", "#E07BFC", "#EA88FF", "#F495FF", "#FEA2FF"],
|
||||
"#c178c9": ["#6A2E6F", "#843B89", "#9E48A3", "#B855BD", "#D262D7", "#EC6FF1", "#F67CFB", "#FF89FF", "#FF96FF", "#FFA3FF"],
|
||||
"#ee87c5": ["#832C6A", "#9D3984", "#B7469E", "#D153B8", "#EB60D2", "#FF6DEC", "#FF7AF6", "#FF87FF", "#FF94FF", "#FFA1FF"],
|
||||
"#ca7881": ["#6F2C3E", "#893958", "#A34672", "#BD538C", "#D760A6", "#F16DC0", "#FB7ADA", "#FF87F4", "#FF94FF", "#FFA1FF"],
|
||||
"#75c9c0": ["#3F6B66", "#497E7A", "#53918E", "#5DA4A2", "#67B7B6", "#71CBCA", "#7BDEDE", "#85F2F2", "#8FFFFF", "#99FFFF"],
|
||||
"#75c997": ["#3F6B54", "#497E6A", "#53917F", "#5DA495", "#67B7AA", "#71CBBF", "#7BDED4", "#85F2E9", "#8FFFFF", "#99FFFF"],
|
||||
"#80ca79": ["#456F3E", "#5A804D", "#6F935C", "#84A66B", "#99B97A", "#AECC89", "#C3DF98", "#D8F2A7", "#EDFFB6", "#FFFFC5"],
|
||||
"#aacb78": ["#5F6F3E", "#7A804D", "#94935C", "#AFA66B", "#CAB97A", "#E5CC89", "#FFDF98", "#FFF2A7", "#FFFFB6", "#FFFFC5"],
|
||||
"#cbbc78": ["#6F5D3E", "#8A704D", "#A4835C", "#BF966B", "#DAA97A", "#F5BC89", "#FFCF98", "#FFE2A7", "#FFF5B6", "#FFFFC5"],
|
||||
"#cb9878": ["#704D3E", "#8B604D", "#A6735C", "#C1866B", "#DC997A", "#F7AC89", "#FFBF98", "#FFD2A7", "#FFE5B6", "#FFF8C5"],
|
||||
"#bb774c": ["#653D27", "#80502C", "#9B6331", "#B67636", "#D1893B", "#EC9C40", "#FFAF45", "#FFC24A", "#FFD54F", "#FFE854"],
|
||||
"#905b39": ["#4D2F1A", "#623C23", "#774A2C", "#8C5735", "#A1643E", "#B67147", "#CB7E50", "#E08B59", "#F59862", "#FFA56B"],
|
||||
"#903737": ["#4D1A1A", "#622323", "#772C2C", "#8C3535", "#A13E3E", "#B64747", "#CB5050", "#E05959", "#F56262", "#FF6B6B"],
|
||||
"#bf4949": ["#661212", "#801B1B", "#992424", "#B32D2D", "#CC3636", "#E63F3F", "#FF4848", "#FF5151", "#FF5A5A", "#FF6363"],
|
||||
"#f37070": ["#853A3A", "#A04D4D", "#BA6060", "#D47373", "#EF8686", "#FF9999", "#FFA3A3", "#FFACAC", "#FFB6B6", "#FFBFBF"],
|
||||
"#ff9c3c": ["#8F5614", "#AA6F1F", "#C48829", "#DFA233", "#F9BB3D", "#FFC04E", "#FFC75F", "#FFCE70", "#FFD581", "#FFDB92"],
|
||||
"#fbc84c": ["#8F6D14", "#AA862F", "#C4A029", "#DFB933", "#F9D23D", "#FFD74E", "#FFDC5F", "#FFE170", "#FFE681", "#FFEB92"],
|
||||
"#cbc8a1": ["#6F6D58", "#8A886F", "#A4A286", "#BFBC9D", "#DAD6B4", "#F5F0CB", "#FFFEDE", "#FFFFF2", "#FFFFCD", "#FFFFCD"],
|
||||
"#a9a9a9": ["#5D5D5D", "#757575", "#8D8D8D", "#A5A5A5", "#BDBDBD", "#D5D5D5", "#EDEDED", "#F5F5F5", "#FFFFFF", "#FFFFFF"],
|
||||
"#767676": ["#404040", "#4D4D4D", "#5A5A5A", "#676767", "#747474", "#818181", "#8E8E8E", "#9B9B9B", "#A8A8A8", "#B5B5B5"]
|
||||
} as const;
|
||||
|
||||
export const WorklenzColorCodes = [
|
||||
"#154c9b",
|
||||
"#3b7ad4",
|
||||
@@ -46,33 +73,33 @@ export const WorklenzColorCodes = [
|
||||
];
|
||||
|
||||
export const AvatarNamesMap: { [x: string]: string } = {
|
||||
"A": "#154c9b",
|
||||
"B": "#3b7ad4",
|
||||
"C": "#70a6f3",
|
||||
"D": "#7781ca",
|
||||
"E": "#9877ca",
|
||||
"F": "#c178c9",
|
||||
"G": "#ee87c5",
|
||||
"H": "#ca7881",
|
||||
"I": "#75c9c0",
|
||||
"J": "#75c997",
|
||||
"K": "#80ca79",
|
||||
"L": "#aacb78",
|
||||
"M": "#cbbc78",
|
||||
"N": "#cb9878",
|
||||
"O": "#bb774c",
|
||||
"P": "#905b39",
|
||||
"Q": "#903737",
|
||||
"R": "#bf4949",
|
||||
"S": "#f37070",
|
||||
"T": "#ff9c3c",
|
||||
"U": "#fbc84c",
|
||||
"V": "#cbc8a1",
|
||||
"W": "#a9a9a9",
|
||||
"X": "#767676",
|
||||
"Y": "#cb9878",
|
||||
"Z": "#903737",
|
||||
"+": "#9e9e9e"
|
||||
A: "#154c9b",
|
||||
B: "#3b7ad4",
|
||||
C: "#70a6f3",
|
||||
D: "#7781ca",
|
||||
E: "#9877ca",
|
||||
F: "#c178c9",
|
||||
G: "#ee87c5",
|
||||
H: "#ca7881",
|
||||
I: "#75c9c0",
|
||||
J: "#75c997",
|
||||
K: "#80ca79",
|
||||
L: "#aacb78",
|
||||
M: "#cbbc78",
|
||||
N: "#cb9878",
|
||||
O: "#bb774c",
|
||||
P: "#905b39",
|
||||
Q: "#903737",
|
||||
R: "#bf4949",
|
||||
S: "#f37070",
|
||||
T: "#ff9c3c",
|
||||
U: "#fbc84c",
|
||||
V: "#cbc8a1",
|
||||
W: "#a9a9a9",
|
||||
X: "#767676",
|
||||
Y: "#cb9878",
|
||||
Z: "#903737",
|
||||
"+": "#9e9e9e",
|
||||
};
|
||||
|
||||
export const NumbersColorMap: { [x: string]: string } = {
|
||||
@@ -85,19 +112,19 @@ export const NumbersColorMap: { [x: string]: string } = {
|
||||
"6": "#ee87c5",
|
||||
"7": "#ca7881",
|
||||
"8": "#75c9c0",
|
||||
"9": "#75c997"
|
||||
"9": "#75c997",
|
||||
};
|
||||
|
||||
export const PriorityColorCodes: { [x: number]: string; } = {
|
||||
export const PriorityColorCodes: { [x: number]: string } = {
|
||||
0: "#2E8B57",
|
||||
1: "#DAA520",
|
||||
2: "#CD5C5C"
|
||||
2: "#CD5C5C",
|
||||
};
|
||||
|
||||
export const PriorityColorCodesDark: { [x: number]: string; } = {
|
||||
export const PriorityColorCodesDark: { [x: number]: string } = {
|
||||
0: "#3CB371",
|
||||
1: "#B8860B",
|
||||
2: "#F08080"
|
||||
2: "#F08080",
|
||||
};
|
||||
|
||||
export const TASK_STATUS_TODO_COLOR = "#a9a9a9";
|
||||
@@ -113,7 +140,6 @@ export const TASK_DUE_UPCOMING_COLOR = "#70a6f3";
|
||||
export const TASK_DUE_OVERDUE_COLOR = "#f37070";
|
||||
export const TASK_DUE_NO_DUE_COLOR = "#a9a9a9";
|
||||
|
||||
|
||||
export const DEFAULT_PAGE_SIZE = 20;
|
||||
|
||||
// S3 Credentials
|
||||
@@ -125,7 +151,8 @@ export const S3_SECRET_ACCESS_KEY = process.env.S3_SECRET_ACCESS_KEY || "";
|
||||
|
||||
// Azure Blob Storage Credentials
|
||||
export const STORAGE_PROVIDER = process.env.STORAGE_PROVIDER || "s3";
|
||||
export const AZURE_STORAGE_ACCOUNT_NAME = process.env.AZURE_STORAGE_ACCOUNT_NAME;
|
||||
export const AZURE_STORAGE_ACCOUNT_NAME =
|
||||
process.env.AZURE_STORAGE_ACCOUNT_NAME;
|
||||
export const AZURE_STORAGE_CONTAINER = process.env.AZURE_STORAGE_CONTAINER;
|
||||
export const AZURE_STORAGE_ACCOUNT_KEY = process.env.AZURE_STORAGE_ACCOUNT_KEY;
|
||||
export const AZURE_STORAGE_URL = process.env.AZURE_STORAGE_URL;
|
||||
@@ -136,7 +163,7 @@ export function getStorageUrl() {
|
||||
console.warn("AZURE_STORAGE_URL is not defined, falling back to S3_URL");
|
||||
return S3_URL;
|
||||
}
|
||||
|
||||
|
||||
// Return just the base Azure Blob Storage URL
|
||||
// AZURE_STORAGE_URL should be in the format: https://storageaccountname.blob.core.windows.net
|
||||
return `${AZURE_STORAGE_URL}/${AZURE_STORAGE_CONTAINER}`;
|
||||
@@ -150,12 +177,16 @@ export const TEAM_MEMBER_TREE_MAP_COLOR_ALPHA = "40";
|
||||
|
||||
// LICENSING SERVER URLS
|
||||
export const LOCAL_URL = "http://localhost:3001";
|
||||
export const UAT_SERVER_URL = process.env.UAT_SERVER_URL || "https://your-uat-server-url";
|
||||
export const DEV_SERVER_URL = process.env.DEV_SERVER_URL || "https://your-dev-server-url";
|
||||
export const PRODUCTION_SERVER_URL = process.env.PRODUCTION_SERVER_URL || "https://your-production-server-url";
|
||||
export const UAT_SERVER_URL =
|
||||
process.env.UAT_SERVER_URL || "https://your-uat-server-url";
|
||||
export const DEV_SERVER_URL =
|
||||
process.env.DEV_SERVER_URL || "https://your-dev-server-url";
|
||||
export const PRODUCTION_SERVER_URL =
|
||||
process.env.PRODUCTION_SERVER_URL || "https://your-production-server-url";
|
||||
|
||||
// *Sync with the client
|
||||
export const PASSWORD_POLICY = "Minimum of 8 characters, with upper and lowercase and a number and a symbol.";
|
||||
export const PASSWORD_POLICY =
|
||||
"Minimum of 8 characters, with upper and lowercase and a number and a symbol.";
|
||||
|
||||
// paddle status to exclude
|
||||
export const statusExclude = ["past_due", "paused", "deleted"];
|
||||
@@ -172,5 +203,5 @@ export const DATE_RANGES = {
|
||||
LAST_WEEK: "LAST_WEEK",
|
||||
LAST_MONTH: "LAST_MONTH",
|
||||
LAST_QUARTER: "LAST_QUARTER",
|
||||
ALL_TIME: "ALL_TIME"
|
||||
ALL_TIME: "ALL_TIME",
|
||||
};
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -7,5 +7,9 @@
|
||||
"searchPlaceholder": "Kërko sipas emrit",
|
||||
"emptyText": "Etiketat mund të krijohen gjatë përditësimit ose krijimit të detyrave.",
|
||||
"pinTooltip": "Klikoni për ta fiksuar në menynë kryesore",
|
||||
"colorChangeTooltip": "Klikoni për të ndryshuar ngjyrën"
|
||||
"colorChangeTooltip": "Klikoni për të ndryshuar ngjyrën",
|
||||
"pageTitle": "Menaxho Etiketat",
|
||||
"deleteConfirmTitle": "Jeni i sigurt që dëshironi ta fshini këtë?",
|
||||
"deleteButton": "Fshi",
|
||||
"cancelButton": "Anulo"
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -7,5 +7,9 @@
|
||||
"searchPlaceholder": "Nach Name suchen",
|
||||
"emptyText": "Labels können beim Aktualisieren oder Erstellen von Aufgaben erstellt werden.",
|
||||
"pinTooltip": "Zum Anheften an das Hauptmenü klicken",
|
||||
"colorChangeTooltip": "Zum Ändern der Farbe klicken"
|
||||
"colorChangeTooltip": "Zum Ändern der Farbe klicken",
|
||||
"pageTitle": "Labels verwalten",
|
||||
"deleteConfirmTitle": "Sind Sie sicher, dass Sie dies löschen möchten?",
|
||||
"deleteButton": "Löschen",
|
||||
"cancelButton": "Abbrechen"
|
||||
}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -7,5 +7,9 @@
|
||||
"searchPlaceholder": "Search by name",
|
||||
"emptyText": "Labels can be created while updating or creating tasks.",
|
||||
"pinTooltip": "Click to pin this into the main menu",
|
||||
"colorChangeTooltip": "Click to change color"
|
||||
"colorChangeTooltip": "Click to change color",
|
||||
"pageTitle": "Manage Labels",
|
||||
"deleteConfirmTitle": "Are you sure you want to delete this?",
|
||||
"deleteButton": "Delete",
|
||||
"cancelButton": "Cancel"
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -7,5 +7,9 @@
|
||||
"searchPlaceholder": "Buscar por nombre",
|
||||
"emptyText": "Las etiquetas se pueden crear al actualizar o crear tareas.",
|
||||
"pinTooltip": "Haz clic para fijar esto en el menú principal",
|
||||
"colorChangeTooltip": "Haz clic para cambiar el color"
|
||||
"colorChangeTooltip": "Haz clic para cambiar el color",
|
||||
"pageTitle": "Administrar Etiquetas",
|
||||
"deleteConfirmTitle": "¿Estás seguro de que quieres eliminar esto?",
|
||||
"deleteButton": "Eliminar",
|
||||
"cancelButton": "Cancelar"
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -7,5 +7,9 @@
|
||||
"searchPlaceholder": "Pesquisar por nome",
|
||||
"emptyText": "Os rótulos podem ser criados ao atualizar ou criar tarefas.",
|
||||
"pinTooltip": "Clique para fixar isso no menu principal",
|
||||
"colorChangeTooltip": "Clique para mudar a cor"
|
||||
"colorChangeTooltip": "Clique para mudar a cor",
|
||||
"pageTitle": "Gerenciar Rótulos",
|
||||
"deleteConfirmTitle": "Tem certeza de que deseja excluir isto?",
|
||||
"deleteButton": "Excluir",
|
||||
"cancelButton": "Cancelar"
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -7,5 +7,9 @@
|
||||
"searchPlaceholder": "按名称搜索",
|
||||
"emptyText": "标签可以在更新或创建任务时创建。",
|
||||
"pinTooltip": "点击将其固定到主菜单",
|
||||
"colorChangeTooltip": "点击更改颜色"
|
||||
"colorChangeTooltip": "点击更改颜色",
|
||||
"pageTitle": "管理标签",
|
||||
"deleteConfirmTitle": "您确定要删除这个吗?",
|
||||
"deleteButton": "删除",
|
||||
"cancelButton": "取消"
|
||||
}
|
||||
@@ -27,12 +27,17 @@ export const labelsApiService = {
|
||||
|
||||
updateColor: async (labelId: string, color: string): Promise<IServerResponse<ITaskLabel>> => {
|
||||
const response = await apiClient.put<IServerResponse<ITaskLabel>>(
|
||||
`${rootUrl}/tasks/${labelId}/color`,
|
||||
`${rootUrl}/tasks/${labelId}`,
|
||||
{ color }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateLabel: async (labelId: string, data: { name?: string; color?: string }): Promise<IServerResponse<ITaskLabel>> => {
|
||||
const response = await apiClient.put<IServerResponse<ITaskLabel>>(`${rootUrl}/team/${labelId}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
deleteById: async (labelId: string): Promise<IServerResponse<void>> => {
|
||||
const response = await apiClient.delete<IServerResponse<void>>(`${rootUrl}/team/${labelId}`);
|
||||
return response.data;
|
||||
|
||||
@@ -11,9 +11,7 @@ import { SuspenseFallback } from '@/components/suspense-fallback/suspense-fallba
|
||||
const HomePage = lazy(() => import('@/pages/home/home-page'));
|
||||
const ProjectList = lazy(() => import('@/pages/projects/project-list'));
|
||||
const Schedule = lazy(() => import('@/pages/schedule/schedule'));
|
||||
const ProjectTemplateEditView = lazy(
|
||||
() => import('@/pages/settings/project-templates/projectTemplateEditView/ProjectTemplateEditView')
|
||||
);
|
||||
|
||||
const LicenseExpired = lazy(() => import('@/pages/license-expired/license-expired'));
|
||||
const ProjectView = lazy(() => import('@/pages/projects/projectView/project-view'));
|
||||
const Unauthorized = lazy(() => import('@/pages/unauthorized/unauthorized'));
|
||||
@@ -91,14 +89,6 @@ const mainRoutes: RouteObject[] = [
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: `settings/project-templates/edit/:templateId/:templateName`,
|
||||
element: (
|
||||
<Suspense fallback={<SuspenseFallback />}>
|
||||
<ProjectTemplateEditView />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'unauthorized',
|
||||
element: (
|
||||
|
||||
@@ -84,7 +84,6 @@ import selectionReducer from '@/features/task-management/selection.slice';
|
||||
import homePageApiService from '@/api/home-page/home-page.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 taskManagementFieldsReducer from '@features/task-management/taskListFields.slice';
|
||||
@@ -93,7 +92,7 @@ export const store = configureStore({
|
||||
middleware: getDefaultMiddleware =>
|
||||
getDefaultMiddleware({
|
||||
serializableCheck: false,
|
||||
}).concat(homePageApiService.middleware, projectsApi.middleware, userActivityApiService.middleware, ganttApi.middleware),
|
||||
}).concat(homePageApiService.middleware, projectsApi.middleware, userActivityApiService.middleware),
|
||||
reducer: {
|
||||
// Auth & User
|
||||
auth: authReducer,
|
||||
@@ -106,7 +105,6 @@ export const store = configureStore({
|
||||
homePageReducer: homePageReducer,
|
||||
[homePageApiService.reducerPath]: homePageApiService.reducer,
|
||||
[projectsApi.reducerPath]: projectsApi.reducer,
|
||||
[ganttApi.reducerPath]: ganttApi.reducer,
|
||||
userActivityReducer: userActivityReducer,
|
||||
[userActivityApiService.reducerPath]: userActivityApiService.reducer,
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@ import { Tag, Typography } from '@/shared/antd-imports';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { ITaskLabel } from '@/types/tasks/taskLabel.types';
|
||||
import { ALPHA_CHANNEL } from '@/shared/constants';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
|
||||
const CustomColorLabel = ({ label }: { label: ITaskLabel | null }) => {
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
return (
|
||||
<Tag
|
||||
key={label?.id}
|
||||
@@ -17,7 +19,7 @@ const CustomColorLabel = ({ label }: { label: ITaskLabel | null }) => {
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
<Typography.Text style={{ fontSize: 11, color: colors.darkGray }}>
|
||||
<Typography.Text style={{ fontSize: 11, color: themeMode === 'dark' ? 'rgba(255, 255, 255, 0.85)' : colors.darkGray }}>
|
||||
{label?.name}
|
||||
</Typography.Text>
|
||||
</Tag>
|
||||
|
||||
@@ -4,40 +4,25 @@ import { Outlet } from 'react-router-dom';
|
||||
import { useMediaQuery } from 'react-responsive';
|
||||
import AdminCenterSidebar from '@/pages/admin-center/sidebar/sidebar';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
|
||||
const AdminCenterLayout: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const isTablet = useMediaQuery({ query: '(min-width:768px)' });
|
||||
const isMarginAvailable = useMediaQuery({ query: '(min-width: 1000px)' });
|
||||
const { t } = useTranslation('admin-center/sidebar');
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginBlock: 96,
|
||||
minHeight: '90vh',
|
||||
marginLeft: `${isMarginAvailable ? '5%' : ''}`,
|
||||
marginRight: `${isMarginAvailable ? '5%' : ''}`,
|
||||
}}
|
||||
>
|
||||
<div className="my-6">
|
||||
<Typography.Title level={4}>{t('adminCenter')}</Typography.Title>
|
||||
|
||||
{isTablet ? (
|
||||
<Flex
|
||||
gap={24}
|
||||
align="flex-start"
|
||||
style={{
|
||||
width: '100%',
|
||||
marginBlockStart: 24,
|
||||
}}
|
||||
className="w-full mt-6"
|
||||
>
|
||||
<Flex style={{ width: '100%', maxWidth: 240 }}>
|
||||
<Flex className="w-full max-w-60">
|
||||
<AdminCenterSidebar />
|
||||
</Flex>
|
||||
<Flex style={{ width: '100%' }}>
|
||||
<Flex className="w-full">
|
||||
<Outlet />
|
||||
</Flex>
|
||||
</Flex>
|
||||
@@ -45,9 +30,7 @@ const AdminCenterLayout: React.FC = () => {
|
||||
<Flex
|
||||
vertical
|
||||
gap={24}
|
||||
style={{
|
||||
marginBlockStart: 24,
|
||||
}}
|
||||
className="mt-6"
|
||||
>
|
||||
<AdminCenterSidebar />
|
||||
<Outlet />
|
||||
|
||||
@@ -1,35 +1,25 @@
|
||||
import { Flex, Typography } from '@/shared/antd-imports';
|
||||
import SettingsSidebar from '../pages/settings/sidebar/settings-sidebar';
|
||||
import { Outlet, useNavigate } from 'react-router-dom';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { useMediaQuery } from 'react-responsive';
|
||||
import { useEffect } from 'react';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
|
||||
const SettingsLayout = () => {
|
||||
const isTablet = useMediaQuery({ query: '(min-width: 768px)' });
|
||||
const { getCurrentSession } = useAuthService();
|
||||
const currentSession = getCurrentSession();
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div style={{ marginBlock: 96, minHeight: '90vh' }}>
|
||||
<div className="my-6 min-h-[90vh]">
|
||||
<Typography.Title level={4}>Settings</Typography.Title>
|
||||
|
||||
{isTablet ? (
|
||||
<Flex
|
||||
gap={24}
|
||||
align="flex-start"
|
||||
style={{
|
||||
width: '100%',
|
||||
marginBlockStart: 24,
|
||||
}}
|
||||
className="w-full mt-6"
|
||||
>
|
||||
<Flex style={{ width: '100%', maxWidth: 240 }}>
|
||||
<Flex className="w-full max-w-60">
|
||||
<SettingsSidebar />
|
||||
</Flex>
|
||||
<Flex style={{ width: '100%' }}>
|
||||
<Flex className="w-full">
|
||||
<Outlet />
|
||||
</Flex>
|
||||
</Flex>
|
||||
@@ -37,9 +27,7 @@ const SettingsLayout = () => {
|
||||
<Flex
|
||||
vertical
|
||||
gap={24}
|
||||
style={{
|
||||
marginBlockStart: 24,
|
||||
}}
|
||||
className="mt-6"
|
||||
>
|
||||
<SettingsSidebar />
|
||||
<Outlet />
|
||||
|
||||
@@ -19,9 +19,6 @@ const ProjectViewMembers = React.lazy(
|
||||
const ProjectViewUpdates = React.lazy(
|
||||
() => 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 TabItems = {
|
||||
@@ -46,7 +43,6 @@ const getTabLabel = (key: string): string => {
|
||||
files: 'Files',
|
||||
members: 'Members',
|
||||
updates: 'Updates',
|
||||
gantt: 'Gantt Chart',
|
||||
};
|
||||
return fallbacks[key] || key;
|
||||
}
|
||||
@@ -121,16 +117,6 @@ export const tabItems: TabItems[] = [
|
||||
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
|
||||
@@ -156,9 +142,6 @@ export const updateTabLabels = () => {
|
||||
case 'updates':
|
||||
item.label = getTabLabel('updates');
|
||||
break;
|
||||
case 'gantt':
|
||||
item.label = getTabLabel('gantt');
|
||||
break;
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -18,7 +18,7 @@ const ProfileSettings = lazy(() => import('../../pages/settings/profile/profile-
|
||||
const NotificationsSettings = lazy(() => import('../../pages/settings/notifications/notifications-settings'));
|
||||
const ClientsSettings = lazy(() => import('../../pages/settings/clients/clients-settings'));
|
||||
const JobTitlesSettings = lazy(() => import('@/pages/settings/job-titles/job-titles-settings'));
|
||||
const LabelsSettings = lazy(() => import('../../pages/settings/labels/labels-settings'));
|
||||
const LabelsSettings = lazy(() => import('../../pages/settings/labels/LabelsSettings'));
|
||||
const CategoriesSettings = lazy(() => import('../../pages/settings/categories/categories-settings'));
|
||||
const ProjectTemplatesSettings = lazy(() => import('@/pages/settings/project-templates/project-templates-settings'));
|
||||
const TaskTemplatesSettings = lazy(() => import('@/pages/settings/task-templates/task-templates-settings'));
|
||||
|
||||
@@ -1,361 +0,0 @@
|
||||
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;
|
||||
@@ -1,819 +0,0 @@
|
||||
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);
|
||||
@@ -1,953 +0,0 @@
|
||||
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);
|
||||
@@ -1,249 +0,0 @@
|
||||
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);
|
||||
@@ -1,89 +0,0 @@
|
||||
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;
|
||||
@@ -1,630 +0,0 @@
|
||||
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;
|
||||
@@ -1,18 +0,0 @@
|
||||
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;
|
||||
}
|
||||
};
|
||||
@@ -1,19 +0,0 @@
|
||||
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;
|
||||
};
|
||||
@@ -1,270 +0,0 @@
|
||||
/* 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;
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -1,339 +0,0 @@
|
||||
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,
|
||||
}));
|
||||
};
|
||||
@@ -1,63 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,338 +0,0 @@
|
||||
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);
|
||||
},
|
||||
};
|
||||
@@ -8,22 +8,28 @@ import {
|
||||
TableProps,
|
||||
Tooltip,
|
||||
Typography,
|
||||
DeleteOutlined,
|
||||
ExclamationCircleFilled,
|
||||
SearchOutlined,
|
||||
EditOutlined,
|
||||
} from '@/shared/antd-imports';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import PinRouteToNavbarButton from '../../../components/PinRouteToNavbarButton';
|
||||
import PinRouteToNavbarButton from '@/components/PinRouteToNavbarButton';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { DeleteOutlined, ExclamationCircleFilled, SearchOutlined } from '@/shared/antd-imports';
|
||||
import { ITaskLabel } from '@/types/label.type';
|
||||
import { labelsApiService } from '@/api/taskAttributes/labels/labels.api.service';
|
||||
import CustomColorLabel from '@components/task-list-common/labelsSelector/custom-color-label';
|
||||
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import LabelsDrawer from './labels-drawer';
|
||||
|
||||
const LabelsSettings = () => {
|
||||
const { t } = useTranslation('settings/labels');
|
||||
useDocumentTitle('Manage Labels');
|
||||
useDocumentTitle(t('pageTitle', 'Manage Labels'));
|
||||
|
||||
const [selectedLabelId, setSelectedLabelId] = useState<string | null>(null);
|
||||
const [showDrawer, setShowDrawer] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [labels, setLabels] = useState<ITaskLabel[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -64,32 +70,62 @@ const LabelsSettings = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditClick = (id: string) => {
|
||||
setSelectedLabelId(id);
|
||||
setShowDrawer(true);
|
||||
};
|
||||
|
||||
const handleDrawerClose = () => {
|
||||
setSelectedLabelId(null);
|
||||
setShowDrawer(false);
|
||||
getLabels();
|
||||
};
|
||||
|
||||
|
||||
// table columns
|
||||
const columns: TableProps['columns'] = [
|
||||
{
|
||||
key: 'label',
|
||||
title: t('labelColumn'),
|
||||
title: t('labelColumn', 'Label'),
|
||||
onCell: record => ({
|
||||
onClick: () => handleEditClick(record.id!),
|
||||
}),
|
||||
render: (record: ITaskLabel) => <CustomColorLabel label={record} />,
|
||||
},
|
||||
{
|
||||
key: 'associatedTask',
|
||||
title: t('associatedTaskColumn'),
|
||||
title: t('associatedTaskColumn', 'Associated Task Count'),
|
||||
render: (record: ITaskLabel) => <Typography.Text>{record.usage}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
key: 'actionBtns',
|
||||
width: 60,
|
||||
width: 100,
|
||||
render: (record: ITaskLabel) => (
|
||||
<div className="action-button opacity-0 transition-opacity duration-200">
|
||||
<Popconfirm
|
||||
title="Are you sure you want to delete this?"
|
||||
icon={<ExclamationCircleFilled style={{ color: '#ff9800' }} />}
|
||||
okText="Delete"
|
||||
cancelText="Cancel"
|
||||
onConfirm={() => deleteLabel(record.id!)}
|
||||
>
|
||||
<Button shape="default" icon={<DeleteOutlined />} size="small" />
|
||||
</Popconfirm>
|
||||
<Flex gap={4}>
|
||||
<Tooltip title={t('editTooltip', 'Edit')}>
|
||||
<Button
|
||||
shape="default"
|
||||
icon={<EditOutlined />}
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEditClick(record.id!);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Popconfirm
|
||||
title={t('deleteConfirmTitle', 'Are you sure you want to delete this?')}
|
||||
icon={<ExclamationCircleFilled style={{ color: '#ff9800' }} />}
|
||||
okText={t('deleteButton', 'Delete')}
|
||||
cancelText={t('cancelButton', 'Cancel')}
|
||||
onConfirm={() => deleteLabel(record.id!)}
|
||||
>
|
||||
<Tooltip title={t('deleteTooltip', 'Delete')}>
|
||||
<Button shape="default" icon={<DeleteOutlined />} size="small" />
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
</Flex>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -104,12 +140,12 @@ const LabelsSettings = () => {
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
placeholder={t('searchPlaceholder')}
|
||||
placeholder={t('searchPlaceholder', 'Search by name')}
|
||||
style={{ maxWidth: 232 }}
|
||||
suffix={<SearchOutlined />}
|
||||
/>
|
||||
|
||||
<Tooltip title={t('pinTooltip')} trigger={'hover'}>
|
||||
<Tooltip title={t('pinTooltip', 'Click to pin this into the main menu')} trigger={'hover'}>
|
||||
{/* this button pin this route to navbar */}
|
||||
<PinRouteToNavbarButton name="labels" path="/worklenz/settings/labels" />
|
||||
</Tooltip>
|
||||
@@ -119,13 +155,17 @@ const LabelsSettings = () => {
|
||||
>
|
||||
<Table
|
||||
locale={{
|
||||
emptyText: <Typography.Text>{t('emptyText')}</Typography.Text>,
|
||||
emptyText: <Typography.Text>{t('emptyText', 'Labels can be created while updating or creating tasks.')}</Typography.Text>,
|
||||
}}
|
||||
loading={loading}
|
||||
className="custom-two-colors-row-table"
|
||||
dataSource={filteredData}
|
||||
columns={columns}
|
||||
rowKey={record => record.id!}
|
||||
onRow={(record) => ({
|
||||
style: { cursor: 'pointer' },
|
||||
onClick: () => handleEditClick(record.id!),
|
||||
})}
|
||||
pagination={{
|
||||
showSizeChanger: true,
|
||||
defaultPageSize: 20,
|
||||
@@ -133,6 +173,12 @@ const LabelsSettings = () => {
|
||||
size: 'small',
|
||||
}}
|
||||
/>
|
||||
|
||||
<LabelsDrawer
|
||||
drawerOpen={showDrawer}
|
||||
labelId={selectedLabelId}
|
||||
drawerClosed={handleDrawerClose}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
228
worklenz-frontend/src/pages/settings/labels/labels-drawer.tsx
Normal file
228
worklenz-frontend/src/pages/settings/labels/labels-drawer.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import { Button, Drawer, Form, Input, message, Typography, Flex, Dropdown } from '@/shared/antd-imports';
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { theme } from 'antd';
|
||||
import { labelsApiService } from '@/api/taskAttributes/labels/labels.api.service';
|
||||
|
||||
const WorklenzColorShades = {
|
||||
"#154c9b": ["#0D2A50", "#112E54", "#153258", "#19365C", "#1D3A60", "#213E64", "#254268", "#29466C", "#2D4A70", "#314E74"],
|
||||
"#3b7ad4": ["#224884", "#26528A", "#2A5C90", "#2E6696", "#32709C", "#367AA2", "#3A84A8", "#3E8EAE", "#4298B4", "#46A2BA"],
|
||||
"#70a6f3": ["#3D5D8A", "#46679E", "#5071B2", "#597BC6", "#6385DA", "#6C8FEE", "#7699F2", "#7FA3F6", "#89ADFA", "#92B7FE"],
|
||||
"#7781ca": ["#42486F", "#4C5283", "#565C97", "#6066AB", "#6A70BF", "#747AD3", "#7E84E7", "#888EFB", "#9298FF", "#9CA2FF"],
|
||||
"#9877ca": ["#542D70", "#6E3A8A", "#8847A4", "#A254BE", "#BC61D8", "#D66EF2", "#E07BFC", "#EA88FF", "#F495FF", "#FEA2FF"],
|
||||
"#c178c9": ["#6A2E6F", "#843B89", "#9E48A3", "#B855BD", "#D262D7", "#EC6FF1", "#F67CFB", "#FF89FF", "#FF96FF", "#FFA3FF"],
|
||||
"#ee87c5": ["#832C6A", "#9D3984", "#B7469E", "#D153B8", "#EB60D2", "#FF6DEC", "#FF7AF6", "#FF87FF", "#FF94FF", "#FFA1FF"],
|
||||
"#ca7881": ["#6F2C3E", "#893958", "#A34672", "#BD538C", "#D760A6", "#F16DC0", "#FB7ADA", "#FF87F4", "#FF94FF", "#FFA1FF"],
|
||||
"#75c9c0": ["#3F6B66", "#497E7A", "#53918E", "#5DA4A2", "#67B7B6", "#71CBCA", "#7BDEDE", "#85F2F2", "#8FFFFF", "#99FFFF"],
|
||||
"#75c997": ["#3F6B54", "#497E6A", "#53917F", "#5DA495", "#67B7AA", "#71CBBF", "#7BDED4", "#85F2E9", "#8FFFFF", "#99FFFF"],
|
||||
"#80ca79": ["#456F3E", "#5A804D", "#6F935C", "#84A66B", "#99B97A", "#AECC89", "#C3DF98", "#D8F2A7", "#EDFFB6", "#FFFFC5"],
|
||||
"#aacb78": ["#5F6F3E", "#7A804D", "#94935C", "#AFA66B", "#CAB97A", "#E5CC89", "#FFDF98", "#FFF2A7", "#FFFFB6", "#FFFFC5"],
|
||||
"#cbbc78": ["#6F5D3E", "#8A704D", "#A4835C", "#BF966B", "#DAA97A", "#F5BC89", "#FFCF98", "#FFE2A7", "#FFF5B6", "#FFFFC5"],
|
||||
"#cb9878": ["#704D3E", "#8B604D", "#A6735C", "#C1866B", "#DC997A", "#F7AC89", "#FFBF98", "#FFD2A7", "#FFE5B6", "#FFF8C5"],
|
||||
"#bb774c": ["#653D27", "#80502C", "#9B6331", "#B67636", "#D1893B", "#EC9C40", "#FFAF45", "#FFC24A", "#FFD54F", "#FFE854"],
|
||||
"#905b39": ["#4D2F1A", "#623C23", "#774A2C", "#8C5735", "#A1643E", "#B67147", "#CB7E50", "#E08B59", "#F59862", "#FFA56B"],
|
||||
"#903737": ["#4D1A1A", "#622323", "#772C2C", "#8C3535", "#A13E3E", "#B64747", "#CB5050", "#E05959", "#F56262", "#FF6B6B"],
|
||||
"#bf4949": ["#661212", "#801B1B", "#992424", "#B32D2D", "#CC3636", "#E63F3F", "#FF4848", "#FF5151", "#FF5A5A", "#FF6363"],
|
||||
"#f37070": ["#853A3A", "#A04D4D", "#BA6060", "#D47373", "#EF8686", "#FF9999", "#FFA3A3", "#FFACAC", "#FFB6B6", "#FFBFBF"],
|
||||
"#ff9c3c": ["#8F5614", "#AA6F1F", "#C48829", "#DFA233", "#F9BB3D", "#FFC04E", "#FFC75F", "#FFCE70", "#FFD581", "#FFDB92"],
|
||||
"#fbc84c": ["#8F6D14", "#AA862F", "#C4A029", "#DFB933", "#F9D23D", "#FFD74E", "#FFDC5F", "#FFE170", "#FFE681", "#FFEB92"],
|
||||
"#cbc8a1": ["#6F6D58", "#8A886F", "#A4A286", "#BFBC9D", "#DAD6B4", "#F5F0CB", "#FFFEDE", "#FFFFF2", "#FFFFCD", "#FFFFCD"],
|
||||
"#a9a9a9": ["#5D5D5D", "#757575", "#8D8D8D", "#A5A5A5", "#BDBDBD", "#D5D5D5", "#EDEDED", "#F5F5F5", "#FFFFFF", "#FFFFFF"],
|
||||
"#767676": ["#404040", "#4D4D4D", "#5A5A5A", "#676767", "#747474", "#818181", "#8E8E8E", "#9B9B9B", "#A8A8A8", "#B5B5B5"]
|
||||
} as const;
|
||||
|
||||
// Flatten the color shades into a single array for the color picker
|
||||
const WorklenzColorCodes = Object.values(WorklenzColorShades).flat();
|
||||
|
||||
type LabelsDrawerProps = {
|
||||
drawerOpen: boolean;
|
||||
labelId: string | null;
|
||||
drawerClosed: () => void;
|
||||
};
|
||||
|
||||
const LabelsDrawer = ({
|
||||
drawerOpen = false,
|
||||
labelId = null,
|
||||
drawerClosed,
|
||||
}: LabelsDrawerProps) => {
|
||||
const { t } = useTranslation('settings/labels');
|
||||
const { token } = theme.useToken();
|
||||
const [form] = Form.useForm();
|
||||
|
||||
useEffect(() => {
|
||||
if (labelId) {
|
||||
getLabelById(labelId);
|
||||
} else {
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ color_code: Object.keys(WorklenzColorShades)[0] }); // Set default color
|
||||
}
|
||||
}, [labelId, form]);
|
||||
|
||||
const getLabelById = async (id: string) => {
|
||||
try {
|
||||
const response = await labelsApiService.getLabels();
|
||||
if (response.done) {
|
||||
const label = response.body.find((l: any) => l.id === id);
|
||||
if (label) {
|
||||
form.setFieldsValue({
|
||||
name: label.name,
|
||||
color_code: label.color_code
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(t('fetchLabelErrorMessage', 'Failed to fetch label'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (values: { name: string; color_code: string }) => {
|
||||
try {
|
||||
if (labelId) {
|
||||
const response = await labelsApiService.updateLabel(labelId, {
|
||||
name: values.name,
|
||||
color: values.color_code,
|
||||
});
|
||||
if (response.done) {
|
||||
message.success(t('updateLabelSuccessMessage', 'Label updated successfully'));
|
||||
drawerClosed();
|
||||
}
|
||||
} else {
|
||||
// For creating new labels, we'd need a create API endpoint
|
||||
message.info(t('createNotSupported', 'Creating new labels is done through tasks'));
|
||||
drawerClosed();
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(labelId ? t('updateLabelErrorMessage', 'Failed to update label') : t('createLabelErrorMessage', 'Failed to create label'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
form.resetFields();
|
||||
drawerClosed();
|
||||
};
|
||||
|
||||
const ColorPicker = ({ value, onChange }: { value?: string; onChange?: (color: string) => void }) => (
|
||||
<Dropdown
|
||||
dropdownRender={() => (
|
||||
<div style={{
|
||||
padding: 16,
|
||||
backgroundColor: token.colorBgElevated,
|
||||
borderRadius: token.borderRadius,
|
||||
boxShadow: token.boxShadowSecondary,
|
||||
border: `1px solid ${token.colorBorder}`,
|
||||
width: 400,
|
||||
maxHeight: 500,
|
||||
overflowY: 'auto'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(10, 1fr)',
|
||||
gap: 6,
|
||||
justifyItems: 'center'
|
||||
}}>
|
||||
{WorklenzColorCodes.map((color) => (
|
||||
<div
|
||||
key={color}
|
||||
style={{
|
||||
width: 18,
|
||||
height: 18,
|
||||
backgroundColor: color,
|
||||
borderRadius: 2,
|
||||
border: value === color ? `2px solid ${token.colorPrimary}` : `1px solid ${token.colorBorder}`,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
flexShrink: 0
|
||||
}}
|
||||
onClick={() => onChange?.(color)}
|
||||
onMouseEnter={(e) => {
|
||||
if (value !== color) {
|
||||
e.currentTarget.style.transform = 'scale(1.2)';
|
||||
e.currentTarget.style.boxShadow = token.boxShadow;
|
||||
e.currentTarget.style.zIndex = '10';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
e.currentTarget.style.zIndex = '1';
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
trigger={['click']}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
backgroundColor: value || Object.keys(WorklenzColorShades)[0],
|
||||
borderRadius: 4,
|
||||
border: `1px solid ${token.colorBorder}`,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.boxShadow = token.boxShadow;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
/>
|
||||
</Dropdown>
|
||||
);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={
|
||||
<Typography.Text style={{ fontWeight: 500, fontSize: 16 }}>
|
||||
{labelId ? t('updateLabelDrawerTitle', 'Edit Label') : t('createLabelDrawerTitle', 'Create Label')}
|
||||
</Typography.Text>
|
||||
}
|
||||
open={drawerOpen}
|
||||
onClose={handleClose}
|
||||
destroyOnClose
|
||||
width={400}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleFormSubmit}>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label={t('nameLabel', 'Name')}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: t('nameRequiredMessage', 'Please enter a label name'),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input placeholder={t('namePlaceholder', 'Enter label name')} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="color_code"
|
||||
label={t('colorLabel', 'Color')}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: t('colorRequiredMessage', 'Please select a color'),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<ColorPicker />
|
||||
</Form.Item>
|
||||
|
||||
<Flex justify="end" gap={8}>
|
||||
<Button onClick={handleClose}>
|
||||
{t('cancelButton', 'Cancel')}
|
||||
</Button>
|
||||
<Button type="primary" htmlType="submit">
|
||||
{labelId ? t('updateButton', 'Update') : t('createButton', 'Create')}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Form>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default LabelsDrawer;
|
||||
@@ -313,3 +313,293 @@ export const durations: IRPTDuration[] = [
|
||||
dates: '',
|
||||
},
|
||||
];
|
||||
|
||||
export const WorklenzColorCodes = [
|
||||
// Row 1: Slate/Gray spectrum
|
||||
'#0f172a',
|
||||
'#1e293b',
|
||||
'#334155',
|
||||
'#475569',
|
||||
'#64748b',
|
||||
'#94a3b8',
|
||||
'#cbd5e1',
|
||||
'#e2e8f0',
|
||||
'#f1f5f9',
|
||||
'#f8fafc',
|
||||
'#ffffff',
|
||||
'#000000',
|
||||
'#1a1a1a',
|
||||
'#2d2d30',
|
||||
'#3e3e42',
|
||||
'#525252',
|
||||
|
||||
// Row 2: Blue spectrum - dark to light
|
||||
'#0c4a6e',
|
||||
'#075985',
|
||||
'#0369a1',
|
||||
'#0284c7',
|
||||
'#0ea5e9',
|
||||
'#38bdf8',
|
||||
'#7dd3fc',
|
||||
'#bae6fd',
|
||||
'#e0f2fe',
|
||||
'#f0f9ff',
|
||||
'#1e3a8a',
|
||||
'#1d4ed8',
|
||||
'#2563eb',
|
||||
'#3b82f6',
|
||||
'#60a5fa',
|
||||
'#93c5fd',
|
||||
|
||||
// Row 3: Indigo/Violet spectrum
|
||||
'#312e81',
|
||||
'#3730a3',
|
||||
'#4338ca',
|
||||
'#4f46e5',
|
||||
'#6366f1',
|
||||
'#818cf8',
|
||||
'#a5b4fc',
|
||||
'#c7d2fe',
|
||||
'#e0e7ff',
|
||||
'#eef2ff',
|
||||
'#581c87',
|
||||
'#6b21a8',
|
||||
'#7c3aed',
|
||||
'#8b5cf6',
|
||||
'#a78bfa',
|
||||
'#c4b5fd',
|
||||
|
||||
// Row 4: Purple/Fuchsia spectrum
|
||||
'#701a75',
|
||||
'#86198f',
|
||||
'#a21caf',
|
||||
'#c026d3',
|
||||
'#d946ef',
|
||||
'#e879f9',
|
||||
'#f0abfc',
|
||||
'#f3e8ff',
|
||||
'#faf5ff',
|
||||
'#fdf4ff',
|
||||
'#831843',
|
||||
'#be185d',
|
||||
'#e11d48',
|
||||
'#f43f5e',
|
||||
'#fb7185',
|
||||
'#fda4af',
|
||||
|
||||
// Row 5: Pink/Rose spectrum
|
||||
'#9f1239',
|
||||
'#be123c',
|
||||
'#e11d48',
|
||||
'#f43f5e',
|
||||
'#fb7185',
|
||||
'#fda4af',
|
||||
'#fecdd3',
|
||||
'#fed7d7',
|
||||
'#fef2f2',
|
||||
'#fff1f2',
|
||||
'#450a0a',
|
||||
'#7f1d1d',
|
||||
'#991b1b',
|
||||
'#dc2626',
|
||||
'#ef4444',
|
||||
'#f87171',
|
||||
|
||||
// Row 6: Red spectrum
|
||||
'#7f1d1d',
|
||||
'#991b1b',
|
||||
'#dc2626',
|
||||
'#ef4444',
|
||||
'#f87171',
|
||||
'#fca5a5',
|
||||
'#fecaca',
|
||||
'#fef2f2',
|
||||
'#fffbeb',
|
||||
'#fefce8',
|
||||
'#92400e',
|
||||
'#a16207',
|
||||
'#ca8a04',
|
||||
'#eab308',
|
||||
'#facc15',
|
||||
'#fef08a',
|
||||
|
||||
// Row 7: Orange spectrum
|
||||
'#9a3412',
|
||||
'#c2410c',
|
||||
'#ea580c',
|
||||
'#f97316',
|
||||
'#fb923c',
|
||||
'#fdba74',
|
||||
'#fed7aa',
|
||||
'#ffedd5',
|
||||
'#fff7ed',
|
||||
'#fffbeb',
|
||||
'#78350f',
|
||||
'#92400e',
|
||||
'#c2410c',
|
||||
'#ea580c',
|
||||
'#f97316',
|
||||
'#fb923c',
|
||||
|
||||
// Row 8: Amber/Yellow spectrum
|
||||
'#451a03',
|
||||
'#78350f',
|
||||
'#92400e',
|
||||
'#a16207',
|
||||
'#ca8a04',
|
||||
'#eab308',
|
||||
'#facc15',
|
||||
'#fef08a',
|
||||
'#fefce8',
|
||||
'#fffbeb',
|
||||
'#365314',
|
||||
'#4d7c0f',
|
||||
'#65a30d',
|
||||
'#84cc16',
|
||||
'#a3e635',
|
||||
'#bef264',
|
||||
|
||||
// Row 9: Lime/Green spectrum
|
||||
'#1a2e05',
|
||||
'#365314',
|
||||
'#4d7c0f',
|
||||
'#65a30d',
|
||||
'#84cc16',
|
||||
'#a3e635',
|
||||
'#bef264',
|
||||
'#d9f99d',
|
||||
'#ecfccb',
|
||||
'#f7fee7',
|
||||
'#14532d',
|
||||
'#166534',
|
||||
'#15803d',
|
||||
'#16a34a',
|
||||
'#22c55e',
|
||||
'#4ade80',
|
||||
|
||||
// Row 10: Emerald spectrum
|
||||
'#064e3b',
|
||||
'#065f46',
|
||||
'#047857',
|
||||
'#059669',
|
||||
'#10b981',
|
||||
'#34d399',
|
||||
'#6ee7b7',
|
||||
'#a7f3d0',
|
||||
'#d1fae5',
|
||||
'#ecfdf5',
|
||||
'#0f766e',
|
||||
'#0d9488',
|
||||
'#14b8a6',
|
||||
'#2dd4bf',
|
||||
'#5eead4',
|
||||
'#99f6e4',
|
||||
|
||||
// Row 11: Teal/Cyan spectrum
|
||||
'#134e4a',
|
||||
'#155e75',
|
||||
'#0891b2',
|
||||
'#0e7490',
|
||||
'#0284c7',
|
||||
'#0ea5e9',
|
||||
'#22d3ee',
|
||||
'#67e8f9',
|
||||
'#a5f3fc',
|
||||
'#cffafe',
|
||||
'#164e63',
|
||||
'#0c4a6e',
|
||||
'#075985',
|
||||
'#0369a1',
|
||||
'#0284c7',
|
||||
'#0ea5e9',
|
||||
|
||||
// Row 12: Sky spectrum
|
||||
'#0c4a6e',
|
||||
'#075985',
|
||||
'#0369a1',
|
||||
'#0284c7',
|
||||
'#0ea5e9',
|
||||
'#38bdf8',
|
||||
'#7dd3fc',
|
||||
'#bae6fd',
|
||||
'#e0f2fe',
|
||||
'#f0f9ff',
|
||||
'#1e40af',
|
||||
'#1d4ed8',
|
||||
'#2563eb',
|
||||
'#3b82f6',
|
||||
'#60a5fa',
|
||||
'#93c5fd',
|
||||
|
||||
// Row 13: Warm grays and browns
|
||||
'#292524',
|
||||
'#44403c',
|
||||
'#57534e',
|
||||
'#78716c',
|
||||
'#a8a29e',
|
||||
'#d6d3d1',
|
||||
'#e7e5e4',
|
||||
'#f5f5f4',
|
||||
'#fafaf9',
|
||||
'#ffffff',
|
||||
'#7c2d12',
|
||||
'#9a3412',
|
||||
'#c2410c',
|
||||
'#ea580c',
|
||||
'#f97316',
|
||||
'#fb923c',
|
||||
|
||||
// Row 14: Cool grays
|
||||
'#111827',
|
||||
'#1f2937',
|
||||
'#374151',
|
||||
'#4b5563',
|
||||
'#6b7280',
|
||||
'#9ca3af',
|
||||
'#d1d5db',
|
||||
'#e5e7eb',
|
||||
'#f3f4f6',
|
||||
'#f9fafb',
|
||||
'#030712',
|
||||
'#0c0a09',
|
||||
'#1c1917',
|
||||
'#292524',
|
||||
'#44403c',
|
||||
'#57534e',
|
||||
|
||||
// Row 15: Neutral spectrum
|
||||
'#171717',
|
||||
'#262626',
|
||||
'#404040',
|
||||
'#525252',
|
||||
'#737373',
|
||||
'#a3a3a3',
|
||||
'#d4d4d4',
|
||||
'#e5e5e5',
|
||||
'#f5f5f5',
|
||||
'#fafafa',
|
||||
'#09090b',
|
||||
'#18181b',
|
||||
'#27272a',
|
||||
'#3f3f46',
|
||||
'#52525b',
|
||||
'#71717a',
|
||||
|
||||
// Row 16: Extended colors
|
||||
'#a1a1aa',
|
||||
'#d4d4d8',
|
||||
'#e4e4e7',
|
||||
'#f4f4f5',
|
||||
'#fafafa',
|
||||
'#27272a',
|
||||
'#3f3f46',
|
||||
'#52525b',
|
||||
'#71717a',
|
||||
'#a1a1aa',
|
||||
'#d4d4d8',
|
||||
'#e4e4e7',
|
||||
'#f4f4f5',
|
||||
'#fafafa',
|
||||
'#ffffff',
|
||||
'#000000',
|
||||
];
|
||||
|
||||
@@ -188,16 +188,16 @@ export interface AdvancedGanttProps {
|
||||
// Data
|
||||
tasks: GanttTask[];
|
||||
columns?: ColumnConfig[];
|
||||
|
||||
|
||||
// Configuration
|
||||
timelineConfig?: Partial<TimelineConfig>;
|
||||
virtualScrollConfig?: Partial<VirtualScrollConfig>;
|
||||
zoomLevels?: ZoomLevel[];
|
||||
|
||||
|
||||
// Initial State
|
||||
initialViewState?: Partial<GanttViewState>;
|
||||
initialSelection?: string[];
|
||||
|
||||
|
||||
// Event Handlers
|
||||
onTaskUpdate?: (taskId: string, updates: Partial<GanttTask>) => void;
|
||||
onTaskCreate?: (task: Omit<GanttTask, 'id'>) => void;
|
||||
@@ -209,13 +209,13 @@ export interface AdvancedGanttProps {
|
||||
onColumnResize?: ColumnResizeHandler;
|
||||
onDependencyCreate?: (fromTaskId: string, toTaskId: string) => void;
|
||||
onDependencyDelete?: (fromTaskId: string, toTaskId: string) => void;
|
||||
|
||||
|
||||
// UI Customization
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
theme?: 'light' | 'dark' | 'auto';
|
||||
locale?: string;
|
||||
|
||||
|
||||
// Feature Flags
|
||||
enableDragDrop?: boolean;
|
||||
enableResize?: boolean;
|
||||
@@ -225,7 +225,7 @@ export interface AdvancedGanttProps {
|
||||
enableTooltips?: boolean;
|
||||
enableExport?: boolean;
|
||||
enablePrint?: boolean;
|
||||
|
||||
|
||||
// Performance Options
|
||||
enableVirtualScrolling?: boolean;
|
||||
enableDebouncing?: boolean;
|
||||
@@ -258,14 +258,7 @@ export interface ExportOptions {
|
||||
// Filter and Search
|
||||
export interface FilterConfig {
|
||||
field: string;
|
||||
operator:
|
||||
| 'equals'
|
||||
| 'contains'
|
||||
| 'startsWith'
|
||||
| 'endsWith'
|
||||
| 'greaterThan'
|
||||
| 'lessThan'
|
||||
| 'between';
|
||||
operator: 'equals' | 'contains' | 'startsWith' | 'endsWith' | 'greaterThan' | 'lessThan' | 'between';
|
||||
value: any;
|
||||
logic?: 'and' | 'or';
|
||||
}
|
||||
@@ -311,4 +304,4 @@ export interface KeyboardShortcut {
|
||||
action: string;
|
||||
description: string;
|
||||
handler: (event: KeyboardEvent) => void;
|
||||
}
|
||||
}
|
||||
@@ -2,37 +2,37 @@ import { useMemo, useCallback, useRef, useEffect } from 'react';
|
||||
import { GanttTask, PerformanceMetrics } from '../types/advanced-gantt.types';
|
||||
|
||||
// Debounce utility for drag operations
|
||||
export function useDebounce<T extends (...args: any[]) => any>(callback: T, delay: number): T {
|
||||
export function useDebounce<T extends (...args: any[]) => any>(
|
||||
callback: T,
|
||||
delay: number
|
||||
): T {
|
||||
const timeoutRef = useRef<NodeJS.Timeout>();
|
||||
|
||||
return useCallback(
|
||||
(...args: Parameters<T>) => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
callback(...args);
|
||||
}, delay);
|
||||
},
|
||||
[callback, delay]
|
||||
) as T;
|
||||
|
||||
return useCallback((...args: Parameters<T>) => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
callback(...args);
|
||||
}, delay);
|
||||
}, [callback, delay]) as T;
|
||||
}
|
||||
|
||||
// Throttle utility for scroll events
|
||||
export function useThrottle<T extends (...args: any[]) => any>(callback: T, delay: number): T {
|
||||
export function useThrottle<T extends (...args: any[]) => any>(
|
||||
callback: T,
|
||||
delay: number
|
||||
): T {
|
||||
const lastCall = useRef<number>(0);
|
||||
|
||||
return useCallback(
|
||||
(...args: Parameters<T>) => {
|
||||
const now = Date.now();
|
||||
if (now - lastCall.current >= delay) {
|
||||
lastCall.current = now;
|
||||
callback(...args);
|
||||
}
|
||||
},
|
||||
[callback, delay]
|
||||
) as T;
|
||||
|
||||
return useCallback((...args: Parameters<T>) => {
|
||||
const now = Date.now();
|
||||
if (now - lastCall.current >= delay) {
|
||||
lastCall.current = now;
|
||||
callback(...args);
|
||||
}
|
||||
}, [callback, delay]) as T;
|
||||
}
|
||||
|
||||
// Memoized task calculations
|
||||
@@ -41,23 +41,23 @@ export const useTaskCalculations = (tasks: GanttTask[]) => {
|
||||
const taskMap = new Map<string, GanttTask>();
|
||||
const parentChildMap = new Map<string, string[]>();
|
||||
const dependencyMap = new Map<string, string[]>();
|
||||
|
||||
|
||||
// Build maps for efficient lookups
|
||||
tasks.forEach(task => {
|
||||
taskMap.set(task.id, task);
|
||||
|
||||
|
||||
if (task.parent) {
|
||||
if (!parentChildMap.has(task.parent)) {
|
||||
parentChildMap.set(task.parent, []);
|
||||
}
|
||||
parentChildMap.get(task.parent)!.push(task.id);
|
||||
}
|
||||
|
||||
|
||||
if (task.dependencies) {
|
||||
dependencyMap.set(task.id, task.dependencies);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return {
|
||||
taskMap,
|
||||
parentChildMap,
|
||||
@@ -95,7 +95,7 @@ export const useVirtualScrolling = (
|
||||
);
|
||||
const visibleItems = tasks.slice(startIndex, endIndex + 1);
|
||||
const offsetY = startIndex * itemHeight;
|
||||
|
||||
|
||||
return {
|
||||
startIndex,
|
||||
endIndex,
|
||||
@@ -124,31 +124,29 @@ export const useTimelineVirtualScrolling = (
|
||||
overscan: number = 10
|
||||
): TimelineVirtualData => {
|
||||
return useMemo(() => {
|
||||
const totalDays = Math.ceil(
|
||||
(projectEndDate.getTime() - projectStartDate.getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
const totalDays = Math.ceil((projectEndDate.getTime() - projectStartDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||
const totalWidth = totalDays * dayWidth;
|
||||
|
||||
|
||||
const startDayIndex = Math.max(0, Math.floor(scrollLeft / dayWidth) - overscan);
|
||||
const endDayIndex = Math.min(
|
||||
totalDays - 1,
|
||||
Math.ceil((scrollLeft + containerWidth) / dayWidth) + overscan
|
||||
);
|
||||
|
||||
|
||||
const visibleDays: Date[] = [];
|
||||
for (let i = startDayIndex; i <= endDayIndex; i++) {
|
||||
const date = new Date(projectStartDate);
|
||||
date.setDate(date.getDate() + i);
|
||||
visibleDays.push(date);
|
||||
}
|
||||
|
||||
|
||||
const offsetX = startDayIndex * dayWidth;
|
||||
const startDate = new Date(projectStartDate);
|
||||
startDate.setDate(startDate.getDate() + startDayIndex);
|
||||
|
||||
|
||||
const endDate = new Date(projectStartDate);
|
||||
endDate.setDate(endDate.getDate() + endDayIndex);
|
||||
|
||||
|
||||
return {
|
||||
startDate,
|
||||
endDate,
|
||||
@@ -171,25 +169,25 @@ export const usePerformanceMonitoring = (): {
|
||||
taskCount: 0,
|
||||
visibleTaskCount: 0,
|
||||
});
|
||||
|
||||
|
||||
const measurementsRef = useRef<Map<string, number>>(new Map());
|
||||
|
||||
|
||||
const startMeasure = useCallback((name: string) => {
|
||||
measurementsRef.current.set(name, performance.now());
|
||||
}, []);
|
||||
|
||||
|
||||
const endMeasure = useCallback((name: string) => {
|
||||
const startTime = measurementsRef.current.get(name);
|
||||
if (startTime) {
|
||||
const duration = performance.now() - startTime;
|
||||
measurementsRef.current.delete(name);
|
||||
|
||||
|
||||
if (name === 'render') {
|
||||
metricsRef.current.renderTime = duration;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
const recordMetric = useCallback((name: string, value: number) => {
|
||||
switch (name) {
|
||||
case 'taskCount':
|
||||
@@ -206,7 +204,7 @@ export const usePerformanceMonitoring = (): {
|
||||
break;
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
return {
|
||||
metrics: metricsRef.current,
|
||||
startMeasure,
|
||||
@@ -222,26 +220,26 @@ export const useIntersectionObserver = (
|
||||
) => {
|
||||
const targetRef = useRef<HTMLElement>(null);
|
||||
const observerRef = useRef<IntersectionObserver>();
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!targetRef.current) return;
|
||||
|
||||
|
||||
observerRef.current = new IntersectionObserver(callback, {
|
||||
root: null,
|
||||
rootMargin: '100px',
|
||||
threshold: 0.1,
|
||||
...options,
|
||||
});
|
||||
|
||||
|
||||
observerRef.current.observe(targetRef.current);
|
||||
|
||||
|
||||
return () => {
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect();
|
||||
}
|
||||
};
|
||||
}, [callback, options]);
|
||||
|
||||
|
||||
return targetRef;
|
||||
};
|
||||
|
||||
@@ -249,47 +247,47 @@ export const useIntersectionObserver = (
|
||||
export const useDateCalculations = () => {
|
||||
return useMemo(() => {
|
||||
const cache = new Map<string, number>();
|
||||
|
||||
|
||||
const getDaysBetween = (start: Date, end: Date): number => {
|
||||
const key = `${start.getTime()}-${end.getTime()}`;
|
||||
if (cache.has(key)) {
|
||||
return cache.get(key)!;
|
||||
}
|
||||
|
||||
|
||||
const days = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
|
||||
cache.set(key, days);
|
||||
return days;
|
||||
};
|
||||
|
||||
|
||||
const addDays = (date: Date, days: number): Date => {
|
||||
const result = new Date(date);
|
||||
result.setDate(result.getDate() + days);
|
||||
return result;
|
||||
};
|
||||
|
||||
|
||||
const isWeekend = (date: Date): boolean => {
|
||||
const day = date.getDay();
|
||||
return day === 0 || day === 6; // Sunday or Saturday
|
||||
};
|
||||
|
||||
|
||||
const isWorkingDay = (date: Date, workingDays: number[]): boolean => {
|
||||
return workingDays.includes(date.getDay());
|
||||
};
|
||||
|
||||
|
||||
const getWorkingDaysBetween = (start: Date, end: Date, workingDays: number[]): number => {
|
||||
let count = 0;
|
||||
const current = new Date(start);
|
||||
|
||||
|
||||
while (current <= end) {
|
||||
if (isWorkingDay(current, workingDays)) {
|
||||
count++;
|
||||
}
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
|
||||
|
||||
return count;
|
||||
};
|
||||
|
||||
|
||||
return {
|
||||
getDaysBetween,
|
||||
addDays,
|
||||
@@ -302,25 +300,25 @@ export const useDateCalculations = () => {
|
||||
};
|
||||
|
||||
// Task position calculations
|
||||
export const useTaskPositions = (tasks: GanttTask[], timelineStart: Date, dayWidth: number) => {
|
||||
export const useTaskPositions = (
|
||||
tasks: GanttTask[],
|
||||
timelineStart: Date,
|
||||
dayWidth: number
|
||||
) => {
|
||||
return useMemo(() => {
|
||||
const positions = new Map<string, { x: number; width: number; y: number }>();
|
||||
|
||||
|
||||
tasks.forEach((task, index) => {
|
||||
const startDays = Math.floor(
|
||||
(task.startDate.getTime() - timelineStart.getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
const endDays = Math.floor(
|
||||
(task.endDate.getTime() - timelineStart.getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
|
||||
const startDays = Math.floor((task.startDate.getTime() - timelineStart.getTime()) / (1000 * 60 * 60 * 24));
|
||||
const endDays = Math.floor((task.endDate.getTime() - timelineStart.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
positions.set(task.id, {
|
||||
x: startDays * dayWidth,
|
||||
width: Math.max(1, (endDays - startDays) * dayWidth),
|
||||
y: index * 40, // Assuming 40px row height
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
return positions;
|
||||
}, [tasks, timelineStart, dayWidth]);
|
||||
};
|
||||
@@ -328,57 +326,57 @@ export const useTaskPositions = (tasks: GanttTask[], timelineStart: Date, dayWid
|
||||
// Memory management utilities
|
||||
export const useMemoryManagement = () => {
|
||||
const cleanupFunctions = useRef<Array<() => void>>([]);
|
||||
|
||||
|
||||
const addCleanup = useCallback((cleanup: () => void) => {
|
||||
cleanupFunctions.current.push(cleanup);
|
||||
}, []);
|
||||
|
||||
|
||||
const runCleanup = useCallback(() => {
|
||||
cleanupFunctions.current.forEach(cleanup => cleanup());
|
||||
cleanupFunctions.current = [];
|
||||
}, []);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
return runCleanup;
|
||||
}, [runCleanup]);
|
||||
|
||||
|
||||
return { addCleanup, runCleanup };
|
||||
};
|
||||
|
||||
// Batch update utility for multiple task changes
|
||||
export const useBatchUpdates = <T>(updateFunction: (updates: T[]) => void, delay: number = 100) => {
|
||||
export const useBatchUpdates = <T>(
|
||||
updateFunction: (updates: T[]) => void,
|
||||
delay: number = 100
|
||||
) => {
|
||||
const batchRef = useRef<T[]>([]);
|
||||
const timeoutRef = useRef<NodeJS.Timeout>();
|
||||
|
||||
const addUpdate = useCallback(
|
||||
(update: T) => {
|
||||
batchRef.current.push(update);
|
||||
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
|
||||
const addUpdate = useCallback((update: T) => {
|
||||
batchRef.current.push(update);
|
||||
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
if (batchRef.current.length > 0) {
|
||||
updateFunction([...batchRef.current]);
|
||||
batchRef.current = [];
|
||||
}
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
if (batchRef.current.length > 0) {
|
||||
updateFunction([...batchRef.current]);
|
||||
batchRef.current = [];
|
||||
}
|
||||
}, delay);
|
||||
},
|
||||
[updateFunction, delay]
|
||||
);
|
||||
|
||||
}, delay);
|
||||
}, [updateFunction, delay]);
|
||||
|
||||
const flushUpdates = useCallback(() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
|
||||
if (batchRef.current.length > 0) {
|
||||
updateFunction([...batchRef.current]);
|
||||
batchRef.current = [];
|
||||
}
|
||||
}, [updateFunction]);
|
||||
|
||||
|
||||
return { addUpdate, flushUpdates };
|
||||
};
|
||||
|
||||
@@ -387,24 +385,24 @@ export const useFPSMonitoring = () => {
|
||||
const fpsRef = useRef<number>(0);
|
||||
const frameCountRef = useRef<number>(0);
|
||||
const lastTimeRef = useRef<number>(performance.now());
|
||||
|
||||
|
||||
const measureFPS = useCallback(() => {
|
||||
frameCountRef.current++;
|
||||
const now = performance.now();
|
||||
|
||||
|
||||
if (now - lastTimeRef.current >= 1000) {
|
||||
fpsRef.current = Math.round((frameCountRef.current * 1000) / (now - lastTimeRef.current));
|
||||
frameCountRef.current = 0;
|
||||
lastTimeRef.current = now;
|
||||
}
|
||||
|
||||
|
||||
requestAnimationFrame(measureFPS);
|
||||
}, []);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const rafId = requestAnimationFrame(measureFPS);
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
}, [measureFPS]);
|
||||
|
||||
|
||||
return fpsRef.current;
|
||||
};
|
||||
};
|
||||
@@ -19,12 +19,6 @@ module.exports = {
|
||||
'"Noto Color Emoji"',
|
||||
],
|
||||
},
|
||||
colors: {
|
||||
gray: {
|
||||
750: '#2d3748',
|
||||
850: '#1a202c',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
|
||||
Reference in New Issue
Block a user