Merge pull request #306 from Worklenz/feature/task-activities-by-user
Feature/task activities by user
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
import moment from "moment";
|
||||
import { IWorkLenzRequest } from "../interfaces/worklenz-request";
|
||||
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
|
||||
|
||||
import db from "../config/db";
|
||||
|
||||
import { ServerResponse } from "../models/server-response";
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
import { formatDuration, formatLogText, getColor } from "../shared/utils";
|
||||
|
||||
interface IUserRecentTask {
|
||||
task_id: string;
|
||||
task_name: string;
|
||||
project_id: string;
|
||||
project_name: string;
|
||||
last_activity_at: string;
|
||||
activity_count: number;
|
||||
project_color?: string;
|
||||
task_status?: string;
|
||||
status_color?: string;
|
||||
}
|
||||
|
||||
interface IUserTimeLoggedTask {
|
||||
task_id: string;
|
||||
task_name: string;
|
||||
project_id: string;
|
||||
project_name: string;
|
||||
total_time_logged: number;
|
||||
total_time_logged_string: string;
|
||||
last_logged_at: string;
|
||||
logged_by_timer: boolean;
|
||||
project_color?: string;
|
||||
task_status?: string;
|
||||
status_color?: string;
|
||||
log_entries_count?: number;
|
||||
estimated_time?: number;
|
||||
}
|
||||
|
||||
export default class UserActivityLogsController extends WorklenzControllerBase {
|
||||
@HandleExceptions()
|
||||
public static async getRecentTasks(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
if (!req.user) {
|
||||
return res.status(401).send(new ServerResponse(false, null, "Unauthorized"));
|
||||
}
|
||||
|
||||
const { id: userId, team_id: teamId } = req.user;
|
||||
const { offset = 0, limit = 10 } = req.query;
|
||||
|
||||
// Optimized query with better performance and team filtering
|
||||
const q = `
|
||||
SELECT DISTINCT tal.task_id, t.name AS task_name, tal.project_id, p.name AS project_name,
|
||||
MAX(tal.created_at) AS last_activity_at,
|
||||
COUNT(DISTINCT tal.id) AS activity_count,
|
||||
p.color_code AS project_color,
|
||||
(SELECT name FROM task_statuses WHERE id = t.status_id) AS task_status,
|
||||
(SELECT color_code
|
||||
FROM sys_task_status_categories
|
||||
WHERE id = (SELECT category_id FROM task_statuses WHERE id = t.status_id)) AS status_color
|
||||
FROM task_activity_logs tal
|
||||
INNER JOIN tasks t ON tal.task_id = t.id AND t.archived = FALSE
|
||||
INNER JOIN projects p ON tal.project_id = p.id AND p.team_id = $1
|
||||
WHERE tal.user_id = $2
|
||||
AND tal.created_at >= NOW() - INTERVAL '30 days'
|
||||
GROUP BY tal.task_id, t.name, tal.project_id, p.name, p.color_code, t.status_id
|
||||
ORDER BY MAX(tal.created_at) DESC
|
||||
LIMIT $3 OFFSET $4;
|
||||
`;
|
||||
|
||||
const result = await db.query(q, [teamId, userId, limit, offset]);
|
||||
const tasks: IUserRecentTask[] = result.rows;
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, tasks));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getTimeLoggedTasks(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
if (!req.user) {
|
||||
return res.status(401).send(new ServerResponse(false, null, "Unauthorized"));
|
||||
}
|
||||
|
||||
const { id: userId, team_id: teamId } = req.user;
|
||||
const { offset = 0, limit = 10 } = req.query;
|
||||
|
||||
// Optimized query with better performance, team filtering, and useful additional data
|
||||
const q = `
|
||||
SELECT twl.task_id, t.name AS task_name, t.project_id, p.name AS project_name,
|
||||
SUM(twl.time_spent) AS total_time_logged,
|
||||
MAX(twl.created_at) AS last_logged_at,
|
||||
MAX(twl.logged_by_timer::int)::boolean AS logged_by_timer,
|
||||
p.color_code AS project_color,
|
||||
(SELECT name FROM task_statuses WHERE id = t.status_id) AS task_status,
|
||||
(SELECT color_code
|
||||
FROM sys_task_status_categories
|
||||
WHERE id = (SELECT category_id FROM task_statuses WHERE id = t.status_id)) AS status_color,
|
||||
COUNT(DISTINCT twl.id) AS log_entries_count,
|
||||
(t.total_minutes * 60) AS estimated_time
|
||||
FROM task_work_log twl
|
||||
INNER JOIN tasks t ON twl.task_id = t.id AND t.archived = FALSE
|
||||
INNER JOIN projects p ON t.project_id = p.id AND p.team_id = $1
|
||||
WHERE twl.user_id = $2
|
||||
AND twl.created_at >= NOW() - INTERVAL '90 days'
|
||||
GROUP BY twl.task_id, t.name, t.project_id, p.name, p.color_code, t.status_id, t.total_minutes
|
||||
HAVING SUM(twl.time_spent) > 0
|
||||
ORDER BY MAX(twl.created_at) DESC
|
||||
LIMIT $3 OFFSET $4;
|
||||
`;
|
||||
|
||||
const result = await db.query(q, [teamId, userId, limit, offset]);
|
||||
const tasks: IUserTimeLoggedTask[] = result.rows.map(task => ({
|
||||
...task,
|
||||
total_time_logged_string: formatDuration(moment.duration(task.total_time_logged, "seconds")),
|
||||
}));
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, tasks));
|
||||
}
|
||||
}
|
||||
@@ -1,122 +1,125 @@
|
||||
import express from "express";
|
||||
|
||||
import AccessControlsController from "../../controllers/access-controls-controller";
|
||||
import AuthController from "../../controllers/auth-controller";
|
||||
import LogsController from "../../controllers/logs-controller";
|
||||
import OverviewController from "../../controllers/overview-controller";
|
||||
import TaskPrioritiesController from "../../controllers/task-priorities-controller";
|
||||
|
||||
import attachmentsApiRouter from "./attachments-api-router";
|
||||
import clientsApiRouter from "./clients-api-router";
|
||||
import jobTitlesApiRouter from "./job-titles-api-router";
|
||||
import notificationsApiRouter from "./notifications-api-router";
|
||||
import personalOverviewApiRouter from "./personal-overview-api-router";
|
||||
import projectMembersApiRouter from "./project-members-api-router";
|
||||
import projectsApiRouter from "./projects-api-router";
|
||||
import settingsApiRouter from "./settings-api-router";
|
||||
import statusesApiRouter from "./statuses-api-router";
|
||||
import subTasksApiRouter from "./sub-tasks-api-router";
|
||||
import taskCommentsApiRouter from "./task-comments-api-router";
|
||||
import taskWorkLogApiRouter from "./task-work-log-api-router";
|
||||
import tasksApiRouter from "./tasks-api-router";
|
||||
import teamMembersApiRouter from "./team-members-api-router";
|
||||
import teamsApiRouter from "./teams-api-router";
|
||||
import timezonesApiRouter from "./timezones-api-router";
|
||||
import todoListApiRouter from "./todo-list-api-router";
|
||||
import projectStatusesApiRouter from "./project-statuses-api-router";
|
||||
import labelsApiRouter from "./labels-api-router";
|
||||
import sharedProjectsApiRouter from "./shared-projects-api-router";
|
||||
import resourceAllocationApiRouter from "./resource-allocation-api-router";
|
||||
import taskTemplatesApiRouter from "./task-templates-api-router";
|
||||
import projectInsightsApiRouter from "./project-insights-api-router";
|
||||
import passwordValidator from "../../middlewares/validators/password-validator";
|
||||
import adminCenterApiRouter from "./admin-center-api-router";
|
||||
import reportingApiRouter from "./reporting-api-router";
|
||||
import activityLogsApiRouter from "./activity-logs-api-router";
|
||||
import safeControllerFunction from "../../shared/safe-controller-function";
|
||||
import projectFoldersApiRouter from "./project-folders-api-router";
|
||||
import taskPhasesApiRouter from "./task-phases-api-router";
|
||||
import projectCategoriesApiRouter from "./project-categories-api-router";
|
||||
import homePageApiRouter from "./home-page-api-router";
|
||||
import ganttApiRouter from "./gantt-api-router";
|
||||
import projectCommentsApiRouter from "./project-comments-api-router";
|
||||
import reportingExportApiRouter from "./reporting-export-api-router";
|
||||
import projectHealthsApiRouter from "./project-healths-api-router";
|
||||
import ptTasksApiRouter from "./pt-tasks-api-router";
|
||||
import projectTemplatesApiRouter from "./project-templates-api";
|
||||
import ptTaskPhasesApiRouter from "./pt_task-phases-api-router";
|
||||
import ptStatusesApiRouter from "./pt-statuses-api-router";
|
||||
import workloadApiRouter from "./gannt-apis/workload-api-router";
|
||||
import roadmapApiRouter from "./gannt-apis/roadmap-api-router";
|
||||
import scheduleApiRouter from "./gannt-apis/schedule-api-router";
|
||||
import scheduleApiV2Router from "./gannt-apis/schedule-api-v2-router";
|
||||
import projectManagerApiRouter from "./project-managers-api-router";
|
||||
import surveyApiRouter from "./survey-api-router";
|
||||
|
||||
import billingApiRouter from "./billing-api-router";
|
||||
import taskDependenciesApiRouter from "./task-dependencies-api-router";
|
||||
|
||||
import taskRecurringApiRouter from "./task-recurring-api-router";
|
||||
|
||||
import customColumnsApiRouter from "./custom-columns-api-router";
|
||||
|
||||
const api = express.Router();
|
||||
|
||||
api.use("/projects", projectsApiRouter);
|
||||
api.use("/team-members", teamMembersApiRouter);
|
||||
api.use("/job-titles", jobTitlesApiRouter);
|
||||
api.use("/clients", clientsApiRouter);
|
||||
api.use("/teams", teamsApiRouter);
|
||||
api.use("/tasks", tasksApiRouter);
|
||||
api.use("/settings", settingsApiRouter);
|
||||
api.use("/personal-overview", personalOverviewApiRouter);
|
||||
api.use("/statuses", statusesApiRouter);
|
||||
api.use("/todo-list", todoListApiRouter);
|
||||
api.use("/notifications", notificationsApiRouter);
|
||||
api.use("/attachments", attachmentsApiRouter);
|
||||
api.use("/sub-tasks", subTasksApiRouter);
|
||||
api.use("/project-members", projectMembersApiRouter);
|
||||
api.use("/task-time-log", taskWorkLogApiRouter);
|
||||
api.use("/task-comments", taskCommentsApiRouter);
|
||||
api.use("/timezones", timezonesApiRouter);
|
||||
api.use("/project-statuses", projectStatusesApiRouter);
|
||||
api.use("/labels", labelsApiRouter);
|
||||
api.use("/resource-allocation", resourceAllocationApiRouter);
|
||||
api.use("/shared/projects", sharedProjectsApiRouter);
|
||||
api.use("/task-templates", taskTemplatesApiRouter);
|
||||
api.use("/project-insights", projectInsightsApiRouter);
|
||||
api.use("/admin-center", adminCenterApiRouter);
|
||||
api.use("/reporting", reportingApiRouter);
|
||||
api.use("/activity-logs", activityLogsApiRouter);
|
||||
api.use("/projects-folders", projectFoldersApiRouter);
|
||||
api.use("/task-phases", taskPhasesApiRouter);
|
||||
api.use("/project-categories", projectCategoriesApiRouter);
|
||||
api.use("/home", homePageApiRouter);
|
||||
api.use("/gantt", ganttApiRouter);
|
||||
api.use("/project-comments", projectCommentsApiRouter);
|
||||
api.use("/reporting-export", reportingExportApiRouter);
|
||||
api.use("/project-healths", projectHealthsApiRouter);
|
||||
api.use("/project-templates", projectTemplatesApiRouter);
|
||||
api.use("/pt-tasks", ptTasksApiRouter);
|
||||
api.use("/pt-task-phases", ptTaskPhasesApiRouter);
|
||||
api.use("/pt-statuses", ptStatusesApiRouter);
|
||||
api.use("/workload-gannt", workloadApiRouter);
|
||||
api.use("/roadmap-gannt", roadmapApiRouter);
|
||||
api.use("/schedule-gannt", scheduleApiRouter);
|
||||
api.use("/schedule-gannt-v2", scheduleApiV2Router);
|
||||
api.use("/project-managers", projectManagerApiRouter);
|
||||
api.use("/surveys", surveyApiRouter);
|
||||
|
||||
api.get("/overview/:id", safeControllerFunction(OverviewController.getById));
|
||||
api.get("/task-priorities", safeControllerFunction(TaskPrioritiesController.get));
|
||||
api.post("/change-password", passwordValidator, safeControllerFunction(AuthController.changePassword));
|
||||
api.get("/access-controls/roles", safeControllerFunction(AccessControlsController.getRoles));
|
||||
api.get("/logs/my-dashboard", safeControllerFunction(LogsController.getActivityLog));
|
||||
|
||||
api.use("/billing", billingApiRouter);
|
||||
api.use("/task-dependencies", taskDependenciesApiRouter);
|
||||
|
||||
api.use("/task-recurring", taskRecurringApiRouter);
|
||||
|
||||
api.use("/custom-columns", customColumnsApiRouter);
|
||||
|
||||
import express from "express";
|
||||
|
||||
import AccessControlsController from "../../controllers/access-controls-controller";
|
||||
import AuthController from "../../controllers/auth-controller";
|
||||
import LogsController from "../../controllers/logs-controller";
|
||||
import OverviewController from "../../controllers/overview-controller";
|
||||
import TaskPrioritiesController from "../../controllers/task-priorities-controller";
|
||||
|
||||
import attachmentsApiRouter from "./attachments-api-router";
|
||||
import clientsApiRouter from "./clients-api-router";
|
||||
import jobTitlesApiRouter from "./job-titles-api-router";
|
||||
import notificationsApiRouter from "./notifications-api-router";
|
||||
import personalOverviewApiRouter from "./personal-overview-api-router";
|
||||
import projectMembersApiRouter from "./project-members-api-router";
|
||||
import projectsApiRouter from "./projects-api-router";
|
||||
import settingsApiRouter from "./settings-api-router";
|
||||
import statusesApiRouter from "./statuses-api-router";
|
||||
import subTasksApiRouter from "./sub-tasks-api-router";
|
||||
import taskCommentsApiRouter from "./task-comments-api-router";
|
||||
import taskWorkLogApiRouter from "./task-work-log-api-router";
|
||||
import tasksApiRouter from "./tasks-api-router";
|
||||
import teamMembersApiRouter from "./team-members-api-router";
|
||||
import teamsApiRouter from "./teams-api-router";
|
||||
import timezonesApiRouter from "./timezones-api-router";
|
||||
import todoListApiRouter from "./todo-list-api-router";
|
||||
import projectStatusesApiRouter from "./project-statuses-api-router";
|
||||
import labelsApiRouter from "./labels-api-router";
|
||||
import sharedProjectsApiRouter from "./shared-projects-api-router";
|
||||
import resourceAllocationApiRouter from "./resource-allocation-api-router";
|
||||
import taskTemplatesApiRouter from "./task-templates-api-router";
|
||||
import projectInsightsApiRouter from "./project-insights-api-router";
|
||||
import passwordValidator from "../../middlewares/validators/password-validator";
|
||||
import adminCenterApiRouter from "./admin-center-api-router";
|
||||
import reportingApiRouter from "./reporting-api-router";
|
||||
import activityLogsApiRouter from "./activity-logs-api-router";
|
||||
import safeControllerFunction from "../../shared/safe-controller-function";
|
||||
import projectFoldersApiRouter from "./project-folders-api-router";
|
||||
import taskPhasesApiRouter from "./task-phases-api-router";
|
||||
import projectCategoriesApiRouter from "./project-categories-api-router";
|
||||
import homePageApiRouter from "./home-page-api-router";
|
||||
import ganttApiRouter from "./gantt-api-router";
|
||||
import projectCommentsApiRouter from "./project-comments-api-router";
|
||||
import reportingExportApiRouter from "./reporting-export-api-router";
|
||||
import projectHealthsApiRouter from "./project-healths-api-router";
|
||||
import ptTasksApiRouter from "./pt-tasks-api-router";
|
||||
import projectTemplatesApiRouter from "./project-templates-api";
|
||||
import ptTaskPhasesApiRouter from "./pt_task-phases-api-router";
|
||||
import ptStatusesApiRouter from "./pt-statuses-api-router";
|
||||
import workloadApiRouter from "./gannt-apis/workload-api-router";
|
||||
import roadmapApiRouter from "./gannt-apis/roadmap-api-router";
|
||||
import scheduleApiRouter from "./gannt-apis/schedule-api-router";
|
||||
import scheduleApiV2Router from "./gannt-apis/schedule-api-v2-router";
|
||||
import projectManagerApiRouter from "./project-managers-api-router";
|
||||
import surveyApiRouter from "./survey-api-router";
|
||||
|
||||
import billingApiRouter from "./billing-api-router";
|
||||
import taskDependenciesApiRouter from "./task-dependencies-api-router";
|
||||
|
||||
import taskRecurringApiRouter from "./task-recurring-api-router";
|
||||
|
||||
import customColumnsApiRouter from "./custom-columns-api-router";
|
||||
import userActivityLogsApiRouter from "./user-activity-logs-api-router";
|
||||
|
||||
const api = express.Router();
|
||||
|
||||
api.use("/projects", projectsApiRouter);
|
||||
api.use("/team-members", teamMembersApiRouter);
|
||||
api.use("/job-titles", jobTitlesApiRouter);
|
||||
api.use("/clients", clientsApiRouter);
|
||||
api.use("/teams", teamsApiRouter);
|
||||
api.use("/tasks", tasksApiRouter);
|
||||
api.use("/settings", settingsApiRouter);
|
||||
api.use("/personal-overview", personalOverviewApiRouter);
|
||||
api.use("/statuses", statusesApiRouter);
|
||||
api.use("/todo-list", todoListApiRouter);
|
||||
api.use("/notifications", notificationsApiRouter);
|
||||
api.use("/attachments", attachmentsApiRouter);
|
||||
api.use("/sub-tasks", subTasksApiRouter);
|
||||
api.use("/project-members", projectMembersApiRouter);
|
||||
api.use("/task-time-log", taskWorkLogApiRouter);
|
||||
api.use("/task-comments", taskCommentsApiRouter);
|
||||
api.use("/timezones", timezonesApiRouter);
|
||||
api.use("/project-statuses", projectStatusesApiRouter);
|
||||
api.use("/labels", labelsApiRouter);
|
||||
api.use("/resource-allocation", resourceAllocationApiRouter);
|
||||
api.use("/shared/projects", sharedProjectsApiRouter);
|
||||
api.use("/task-templates", taskTemplatesApiRouter);
|
||||
api.use("/project-insights", projectInsightsApiRouter);
|
||||
api.use("/admin-center", adminCenterApiRouter);
|
||||
api.use("/reporting", reportingApiRouter);
|
||||
api.use("/activity-logs", activityLogsApiRouter);
|
||||
api.use("/projects-folders", projectFoldersApiRouter);
|
||||
api.use("/task-phases", taskPhasesApiRouter);
|
||||
api.use("/project-categories", projectCategoriesApiRouter);
|
||||
api.use("/home", homePageApiRouter);
|
||||
api.use("/gantt", ganttApiRouter);
|
||||
api.use("/project-comments", projectCommentsApiRouter);
|
||||
api.use("/reporting-export", reportingExportApiRouter);
|
||||
api.use("/project-healths", projectHealthsApiRouter);
|
||||
api.use("/project-templates", projectTemplatesApiRouter);
|
||||
api.use("/pt-tasks", ptTasksApiRouter);
|
||||
api.use("/pt-task-phases", ptTaskPhasesApiRouter);
|
||||
api.use("/pt-statuses", ptStatusesApiRouter);
|
||||
api.use("/workload-gannt", workloadApiRouter);
|
||||
api.use("/roadmap-gannt", roadmapApiRouter);
|
||||
api.use("/schedule-gannt", scheduleApiRouter);
|
||||
api.use("/schedule-gannt-v2", scheduleApiV2Router);
|
||||
api.use("/project-managers", projectManagerApiRouter);
|
||||
api.use("/surveys", surveyApiRouter);
|
||||
|
||||
api.get("/overview/:id", safeControllerFunction(OverviewController.getById));
|
||||
api.get("/task-priorities", safeControllerFunction(TaskPrioritiesController.get));
|
||||
api.post("/change-password", passwordValidator, safeControllerFunction(AuthController.changePassword));
|
||||
api.get("/access-controls/roles", safeControllerFunction(AccessControlsController.getRoles));
|
||||
api.get("/logs/my-dashboard", safeControllerFunction(LogsController.getActivityLog));
|
||||
|
||||
api.use("/billing", billingApiRouter);
|
||||
api.use("/task-dependencies", taskDependenciesApiRouter);
|
||||
|
||||
api.use("/task-recurring", taskRecurringApiRouter);
|
||||
|
||||
api.use("/custom-columns", customColumnsApiRouter);
|
||||
|
||||
api.use("/logs", userActivityLogsApiRouter);
|
||||
export default api;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import express from 'express';
|
||||
|
||||
import UserActivityLogsController from '../../controllers/user-activity-logs-controller';
|
||||
import safeControllerFunction from "../../shared/safe-controller-function";
|
||||
|
||||
const userActivityLogsApiRouter = express.Router();
|
||||
|
||||
userActivityLogsApiRouter.get('/user-recent-tasks', safeControllerFunction(UserActivityLogsController.getRecentTasks));
|
||||
userActivityLogsApiRouter.get('/user-time-logged-tasks', safeControllerFunction(UserActivityLogsController.getTimeLoggedTasks));
|
||||
|
||||
export default userActivityLogsApiRouter;
|
||||
@@ -41,6 +41,18 @@
|
||||
"list": "Listë",
|
||||
"calendar": "Kalendar",
|
||||
"tasks": "Detyrat",
|
||||
"refresh": "Rifresko"
|
||||
"refresh": "Rifresko",
|
||||
"recentActivity": "Aktiviteti i Fundit",
|
||||
"recentTasks": "Detyrat e Fundit",
|
||||
"timeLoggedTasks": "Koha e Regjistruar",
|
||||
"noRecentTasks": "Asnjë detyrë e fundit",
|
||||
"noTimeLoggedTasks": "Asnjë detyrë me kohë të regjistruar",
|
||||
"activityTag": "Aktiviteti",
|
||||
"timeLogTag": "Regjistrim Kohe",
|
||||
"timerTag": "Kohëmatës",
|
||||
"activitySingular": "aktivitet",
|
||||
"activityPlural": "aktivitete",
|
||||
"recentTaskAriaLabel": "Detyrë e fundit:",
|
||||
"timeLoggedTaskAriaLabel": "Detyrë me kohë të regjistruar:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,18 @@
|
||||
"list": "Liste",
|
||||
"calendar": "Kalender",
|
||||
"tasks": "Aufgaben",
|
||||
"refresh": "Aktualisieren"
|
||||
"refresh": "Aktualisieren",
|
||||
"recentActivity": "Aktuelle Aktivitäten",
|
||||
"recentTasks": "Aktuelle Aufgaben",
|
||||
"timeLoggedTasks": "Erfasste Zeit",
|
||||
"noRecentTasks": "Keine aktuellen Aufgaben",
|
||||
"noTimeLoggedTasks": "Keine Aufgaben mit erfasster Zeit",
|
||||
"activityTag": "Aktivität",
|
||||
"timeLogTag": "Zeiterfassung",
|
||||
"timerTag": "Timer",
|
||||
"activitySingular": "Aktivität",
|
||||
"activityPlural": "Aktivitäten",
|
||||
"recentTaskAriaLabel": "Aktuelle Aufgabe:",
|
||||
"timeLoggedTaskAriaLabel": "Aufgabe mit erfasster Zeit:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,25 @@
|
||||
"list": "List",
|
||||
"calendar": "Calendar",
|
||||
"tasks": "Tasks",
|
||||
"refresh": "Refresh"
|
||||
"refresh": "Refresh",
|
||||
"recentActivity": "Recent Activity",
|
||||
"recentTasks": "Recent Tasks",
|
||||
"timeLoggedTasks": "Time Logged",
|
||||
"noRecentTasks": "No recent tasks",
|
||||
"noTimeLoggedTasks": "No time logged tasks",
|
||||
"activityTag": "Activity",
|
||||
"timeLogTag": "Time Log",
|
||||
"timerTag": "Timer",
|
||||
"activitySingular": "activity",
|
||||
"activityPlural": "activities",
|
||||
"recentTaskAriaLabel": "Recent task:",
|
||||
"timeLoggedTaskAriaLabel": "Time logged task:",
|
||||
"Recent Activity": "Recent Activity",
|
||||
"Recent Tasks": "Recent Tasks",
|
||||
"Time Logged Tasks": "Time Logged Tasks",
|
||||
"Error Loading Recent Tasks": "Error loading recent tasks",
|
||||
"Error Loading Time Logged Tasks": "Error loading time logged tasks",
|
||||
"No Recent Tasks": "No recent tasks",
|
||||
"No Time Logged Tasks": "No time logged tasks"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,18 @@
|
||||
"list": "Lista",
|
||||
"calendar": "Calendario",
|
||||
"tasks": "Tareas",
|
||||
"refresh": "Actualizar"
|
||||
"refresh": "Actualizar",
|
||||
"recentActivity": "Actividad Reciente",
|
||||
"recentTasks": "Tareas Recientes",
|
||||
"timeLoggedTasks": "Tiempo Registrado",
|
||||
"noRecentTasks": "No hay tareas recientes",
|
||||
"noTimeLoggedTasks": "No hay tareas con tiempo registrado",
|
||||
"activityTag": "Actividad",
|
||||
"timeLogTag": "Registro de Tiempo",
|
||||
"timerTag": "Temporizador",
|
||||
"activitySingular": "actividad",
|
||||
"activityPlural": "actividades",
|
||||
"recentTaskAriaLabel": "Tarea reciente:",
|
||||
"timeLoggedTaskAriaLabel": "Tarea con tiempo registrado:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,18 @@
|
||||
"list": "Lista",
|
||||
"calendar": "Calendário",
|
||||
"tasks": "Tarefas",
|
||||
"refresh": "Atualizar"
|
||||
"refresh": "Atualizar",
|
||||
"recentActivity": "Atividade Recente",
|
||||
"recentTasks": "Tarefas Recentes",
|
||||
"timeLoggedTasks": "Tempo Registrado",
|
||||
"noRecentTasks": "Nenhuma tarefa recente",
|
||||
"noTimeLoggedTasks": "Nenhuma tarefa com tempo registrado",
|
||||
"activityTag": "Atividade",
|
||||
"timeLogTag": "Registro de Tempo",
|
||||
"timerTag": "Cronômetro",
|
||||
"activitySingular": "atividade",
|
||||
"activityPlural": "atividades",
|
||||
"recentTaskAriaLabel": "Tarefa recente:",
|
||||
"timeLoggedTaskAriaLabel": "Tarefa com tempo registrado:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,18 @@
|
||||
"list": "列表",
|
||||
"calendar": "日历",
|
||||
"tasks": "任务",
|
||||
"refresh": "刷新"
|
||||
"refresh": "刷新",
|
||||
"recentActivity": "最近活动",
|
||||
"recentTasks": "最近任务",
|
||||
"timeLoggedTasks": "时间记录",
|
||||
"noRecentTasks": "没有最近任务",
|
||||
"noTimeLoggedTasks": "没有时间记录任务",
|
||||
"activityTag": "活动",
|
||||
"timeLogTag": "时间记录",
|
||||
"timerTag": "计时器",
|
||||
"activitySingular": "活动",
|
||||
"activityPlural": "活动",
|
||||
"recentTaskAriaLabel": "最近任务:",
|
||||
"timeLoggedTaskAriaLabel": "时间记录任务:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
|
||||
import { API_BASE_URL } from '@/shared/constants';
|
||||
import { getCsrfToken } from '../api-client';
|
||||
import { IUserRecentTask, IUserTimeLoggedTask } from '@/types/home/user-activity.types';
|
||||
import config from '@/config/env';
|
||||
|
||||
const rootUrl = '/logs';
|
||||
|
||||
export const userActivityApiService = createApi({
|
||||
reducerPath: 'userActivityApi',
|
||||
baseQuery: fetchBaseQuery({
|
||||
baseUrl: `${config.apiUrl}${API_BASE_URL}`,
|
||||
prepareHeaders: (headers) => {
|
||||
headers.set('X-CSRF-Token', getCsrfToken() || '');
|
||||
headers.set('Content-Type', 'application/json');
|
||||
return headers;
|
||||
},
|
||||
credentials: 'include',
|
||||
}),
|
||||
tagTypes: ['UserRecentTasks', 'UserTimeLoggedTasks'],
|
||||
endpoints: (builder) => ({
|
||||
getUserRecentTasks: builder.query<IUserRecentTask[], { limit?: number; offset?: number }>({
|
||||
query: ({ limit = 10, offset = 0 }) => ({
|
||||
url: `${rootUrl}/user-recent-tasks`,
|
||||
params: { limit, offset },
|
||||
method: 'GET',
|
||||
}),
|
||||
providesTags: ['UserRecentTasks'],
|
||||
}),
|
||||
getUserTimeLoggedTasks: builder.query<IUserTimeLoggedTask[], { limit?: number; offset?: number }>({
|
||||
query: ({ limit = 10, offset = 0 }) => ({
|
||||
url: `${rootUrl}/user-time-logged-tasks`,
|
||||
params: { limit, offset },
|
||||
method: 'GET',
|
||||
}),
|
||||
providesTags: ['UserTimeLoggedTasks'],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const {
|
||||
useGetUserRecentTasksQuery,
|
||||
useGetUserTimeLoggedTasksQuery,
|
||||
} = userActivityApiService;
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import userReducer from '@features/user/userSlice';
|
||||
|
||||
// Home Page
|
||||
import homePageReducer from '@features/home-page/home-page.slice';
|
||||
import userActivityReducer from '@features/home-page/user-activity.slice';
|
||||
|
||||
// Account Setup
|
||||
import accountSetupReducer from '@features/account-setup/account-setup.slice';
|
||||
@@ -82,6 +83,7 @@ import groupingReducer from '@/features/task-management/grouping.slice';
|
||||
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 projectViewReducer from '@features/project/project-view-slice';
|
||||
import taskManagementFieldsReducer from '@features/task-management/taskListFields.slice';
|
||||
@@ -90,7 +92,7 @@ export const store = configureStore({
|
||||
middleware: getDefaultMiddleware =>
|
||||
getDefaultMiddleware({
|
||||
serializableCheck: false,
|
||||
}).concat(homePageApiService.middleware, projectsApi.middleware),
|
||||
}).concat(homePageApiService.middleware, projectsApi.middleware, userActivityApiService.middleware),
|
||||
reducer: {
|
||||
// Auth & User
|
||||
auth: authReducer,
|
||||
@@ -103,6 +105,9 @@ export const store = configureStore({
|
||||
homePageReducer: homePageReducer,
|
||||
[homePageApiService.reducerPath]: homePageApiService.reducer,
|
||||
[projectsApi.reducerPath]: projectsApi.reducer,
|
||||
userActivityReducer: userActivityReducer,
|
||||
[userActivityApiService.reducerPath]: userActivityApiService.reducer,
|
||||
|
||||
// Core UI
|
||||
themeReducer: themeReducer,
|
||||
localesReducer: localesReducer,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { ActivityFeedType } from '@/types/home/user-activity.types';
|
||||
|
||||
interface ActivityItem {
|
||||
id: string;
|
||||
type: string;
|
||||
description: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
interface UserActivityState {
|
||||
activeTab: ActivityFeedType;
|
||||
activities: ActivityItem[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const initialState: UserActivityState = {
|
||||
activeTab: ActivityFeedType.RECENT_TASKS,
|
||||
activities: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const userActivitySlice = createSlice({
|
||||
name: 'userActivity',
|
||||
initialState,
|
||||
reducers: {
|
||||
setActiveTab(state, action: PayloadAction<ActivityFeedType>) {
|
||||
state.activeTab = action.payload;
|
||||
},
|
||||
fetchActivitiesStart(state) {
|
||||
state.loading = true;
|
||||
state.error = null;
|
||||
},
|
||||
fetchActivitiesSuccess(state, action: PayloadAction<ActivityItem[]>) {
|
||||
state.activities = action.payload;
|
||||
state.loading = false;
|
||||
state.error = null;
|
||||
},
|
||||
fetchActivitiesFailure(state, action: PayloadAction<string>) {
|
||||
state.loading = false;
|
||||
state.error = action.payload;
|
||||
},
|
||||
clearActivities(state) {
|
||||
state.activities = [];
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
setActiveTab,
|
||||
fetchActivitiesStart,
|
||||
fetchActivitiesSuccess,
|
||||
fetchActivitiesFailure,
|
||||
clearActivities,
|
||||
} = userActivitySlice.actions;
|
||||
|
||||
export default userActivitySlice.reducer;
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useEffect, memo, useMemo, useCallback } from 'react';
|
||||
import React, { useEffect, memo, useMemo, useCallback } from 'react';
|
||||
import { useMediaQuery } from 'react-responsive';
|
||||
import Col from 'antd/es/col';
|
||||
import Flex from 'antd/es/flex';
|
||||
import Row from 'antd/es/row';
|
||||
import Card from 'antd/es/card';
|
||||
|
||||
import GreetingWithTime from './greeting-with-time';
|
||||
import TasksList from '@/pages/home/task-list/tasks-list';
|
||||
import TodoList from '@/pages/home/todo-list/todo-list';
|
||||
import ProjectDrawer from '@/components/projects/project-drawer/project-drawer';
|
||||
import CreateProjectButton from '@/components/projects/project-create-button/project-create-button';
|
||||
import RecentAndFavouriteProjectList from '@/pages/home/recent-and-favourite-project-list/recent-and-favourite-project-list';
|
||||
import TodoList from './todo-list/todo-list';
|
||||
|
||||
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
@@ -19,7 +21,7 @@ import { fetchProjectCategories } from '@/features/projects/lookups/projectCateg
|
||||
import { fetchProjectHealth } from '@/features/projects/lookups/projectHealth/projectHealthSlice';
|
||||
import { fetchProjects } from '@/features/home-page/home-page.slice';
|
||||
import { createPortal } from 'react-dom';
|
||||
import React, { Suspense } from 'react';
|
||||
import UserActivityFeed from './user-activity-feed/user-activity-feed';
|
||||
|
||||
const DESKTOP_MIN_WIDTH = 1024;
|
||||
const TASK_LIST_MIN_WIDTH = 500;
|
||||
@@ -45,7 +47,7 @@ const HomePage = memo(() => {
|
||||
console.warn('Failed to preload TaskDrawer:', error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
preloadTaskDrawer();
|
||||
}, []);
|
||||
|
||||
@@ -98,26 +100,6 @@ const HomePage = memo(() => {
|
||||
);
|
||||
}, [isDesktop, isOwnerOrAdmin]);
|
||||
|
||||
const MainContent = useMemo(() => {
|
||||
return isDesktop ? (
|
||||
<Flex gap={24} align="flex-start" className="w-full mt-12">
|
||||
<Flex style={desktopFlexStyle}>
|
||||
<TasksList />
|
||||
</Flex>
|
||||
<Flex vertical gap={24} style={sidebarFlexStyle}>
|
||||
<TodoList />
|
||||
<RecentAndFavouriteProjectList />
|
||||
</Flex>
|
||||
</Flex>
|
||||
) : (
|
||||
<Flex vertical gap={24} className="mt-6">
|
||||
<TasksList />
|
||||
<TodoList />
|
||||
<RecentAndFavouriteProjectList />
|
||||
</Flex>
|
||||
);
|
||||
}, [isDesktop, desktopFlexStyle, sidebarFlexStyle]);
|
||||
|
||||
return (
|
||||
<div className="my-24 min-h-[90vh]">
|
||||
<Col className="flex flex-col gap-6">
|
||||
@@ -125,29 +107,26 @@ const HomePage = memo(() => {
|
||||
{CreateProjectButtonComponent}
|
||||
</Col>
|
||||
|
||||
{MainContent}
|
||||
<Row gutter={[24, 24]} className="mt-12">
|
||||
<Col xs={24} lg={16}>
|
||||
<TasksList />
|
||||
</Col>
|
||||
|
||||
{/* Use Suspense for lazy-loaded components with error boundary */}
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
{createPortal(
|
||||
<React.Suspense fallback={null}>
|
||||
<TaskDrawer />
|
||||
</React.Suspense>,
|
||||
document.body,
|
||||
'home-task-drawer'
|
||||
)}
|
||||
</Suspense>
|
||||
<Col xs={24} lg={8}>
|
||||
<Flex vertical gap={24}>
|
||||
<UserActivityFeed />
|
||||
|
||||
{createPortal(
|
||||
<ProjectDrawer onClose={handleProjectDrawerClose} />,
|
||||
document.body,
|
||||
'project-drawer'
|
||||
)}
|
||||
<TodoList />
|
||||
|
||||
{/* Survey Modal - only shown to users who haven't completed it */}
|
||||
<Suspense fallback={null}>
|
||||
<SurveyPromptModal />
|
||||
</Suspense>
|
||||
<Card title="Recent & Favorite Projects">
|
||||
<RecentAndFavouriteProjectList />
|
||||
</Card>
|
||||
</Flex>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{createPortal(<TaskDrawer />, document.body, 'home-task-drawer')}
|
||||
{createPortal(<ProjectDrawer onClose={() => {}} />, document.body, 'project-drawer')}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { List, Typography, Tooltip, Space, Tag, theme } from 'antd';
|
||||
import { FileTextOutlined } from '@ant-design/icons';
|
||||
import moment from 'moment';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import {
|
||||
setSelectedTaskId,
|
||||
setShowTaskDrawer,
|
||||
fetchTask,
|
||||
} from '@/features/task-drawer/task-drawer.slice';
|
||||
import { IUserRecentTask } from '@/types/home/user-activity.types';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface TaskActivityListProps {
|
||||
tasks: IUserRecentTask[];
|
||||
}
|
||||
|
||||
const TaskActivityList: React.FC<TaskActivityListProps> = React.memo(({ tasks }) => {
|
||||
const { t } = useTranslation('home');
|
||||
const dispatch = useAppDispatch();
|
||||
const { token } = theme.useToken();
|
||||
|
||||
// Enhanced dark mode detection
|
||||
const isDarkMode = useMemo(() => {
|
||||
return token.colorBgContainer === '#1f1f1f' ||
|
||||
token.colorBgBase === '#141414' ||
|
||||
token.colorBgElevated === '#1f1f1f' ||
|
||||
document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||
document.body.classList.contains('dark');
|
||||
}, [token]);
|
||||
|
||||
const handleTaskClick = useCallback(
|
||||
(taskId: string, projectId: string) => {
|
||||
dispatch(setSelectedTaskId(taskId));
|
||||
dispatch(setShowTaskDrawer(true));
|
||||
dispatch(fetchTask({ taskId, projectId }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
// Enhanced styling with theme support
|
||||
const listItemStyles = useMemo(() => ({
|
||||
padding: '16px 20px',
|
||||
borderBottom: isDarkMode ? '1px solid #404040' : '1px solid #f0f2f5',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
borderRadius: '8px',
|
||||
margin: '0 0 2px 0',
|
||||
background: isDarkMode ? 'transparent' : 'transparent',
|
||||
position: 'relative' as const,
|
||||
overflow: 'hidden',
|
||||
}), [isDarkMode]);
|
||||
|
||||
const listItemHoverStyles = useMemo(() => ({
|
||||
background: isDarkMode
|
||||
? 'linear-gradient(135deg, #2a2a2a 0%, #353535 100%)'
|
||||
: 'linear-gradient(135deg, #f8f9ff 0%, #f0f4ff 100%)',
|
||||
borderColor: isDarkMode ? '#505050' : '#d1d9e6',
|
||||
transform: 'translateY(-1px)',
|
||||
boxShadow: isDarkMode
|
||||
? '0 4px 16px rgba(0, 0, 0, 0.3), 0 1px 4px rgba(0, 0, 0, 0.2)'
|
||||
: '0 4px 16px rgba(24, 144, 255, 0.15), 0 1px 4px rgba(0, 0, 0, 0.1)',
|
||||
}), [isDarkMode]);
|
||||
|
||||
const iconStyles = useMemo(() => ({
|
||||
color: isDarkMode ? '#40a9ff' : '#1890ff',
|
||||
fontSize: '16px',
|
||||
padding: '8px',
|
||||
borderRadius: '6px',
|
||||
background: isDarkMode
|
||||
? 'linear-gradient(135deg, #1a2332 0%, #2a3441 100%)'
|
||||
: 'linear-gradient(135deg, #e6f7ff 0%, #f0f8ff 100%)',
|
||||
border: isDarkMode ? '1px solid #40a9ff20' : '1px solid #1890ff20',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: '32px',
|
||||
minHeight: '32px',
|
||||
}), [isDarkMode]);
|
||||
|
||||
const taskNameStyles = useMemo(() => ({
|
||||
color: isDarkMode ? '#ffffff' : '#1f2937',
|
||||
fontSize: '15px',
|
||||
fontWeight: 600,
|
||||
lineHeight: '1.4',
|
||||
}), [isDarkMode]);
|
||||
|
||||
const tagStyles = useMemo(() => ({
|
||||
background: isDarkMode
|
||||
? 'linear-gradient(135deg, #1e40af 0%, #3b82f6 100%)'
|
||||
: 'linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%)',
|
||||
color: isDarkMode ? '#ffffff' : '#1e40af',
|
||||
border: isDarkMode ? '1px solid #3b82f6' : '1px solid #93c5fd',
|
||||
borderRadius: '6px',
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
padding: '2px 8px',
|
||||
textTransform: 'uppercase' as const,
|
||||
letterSpacing: '0.5px',
|
||||
}), [isDarkMode]);
|
||||
|
||||
const metaTextStyles = useMemo(() => ({
|
||||
color: isDarkMode ? '#9ca3af' : '#6b7280',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
}), [isDarkMode]);
|
||||
|
||||
const timeTextStyles = useMemo(() => ({
|
||||
color: isDarkMode ? '#8c8c8c' : '#9ca3af',
|
||||
fontSize: '12px',
|
||||
fontWeight: 400,
|
||||
}), [isDarkMode]);
|
||||
|
||||
const activityCountStyles = useMemo(() => ({
|
||||
color: isDarkMode ? '#10b981' : '#059669',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
background: isDarkMode
|
||||
? 'linear-gradient(135deg, #064e3b20 0%, #065f4620 100%)'
|
||||
: 'linear-gradient(135deg, #ecfdf5 0%, #d1fae5 100%)',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
border: isDarkMode ? '1px solid #065f4640' : '1px solid #a7f3d040',
|
||||
}), [isDarkMode]);
|
||||
|
||||
return (
|
||||
<List
|
||||
dataSource={tasks}
|
||||
style={{ background: 'transparent' }}
|
||||
split={false}
|
||||
renderItem={item => (
|
||||
<List.Item
|
||||
onClick={() => handleTaskClick(item.task_id, item.project_id)}
|
||||
style={listItemStyles}
|
||||
onMouseEnter={(e) => {
|
||||
Object.assign(e.currentTarget.style, listItemHoverStyles);
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
Object.assign(e.currentTarget.style, listItemStyles);
|
||||
}}
|
||||
aria-label={`${t('recentTaskAriaLabel')} ${item.task_name}`}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<div style={iconStyles}>
|
||||
<FileTextOutlined />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
|
||||
<Text ellipsis style={taskNameStyles}>
|
||||
{item.task_name}
|
||||
</Text>
|
||||
<Tag style={tagStyles}>
|
||||
{t('activityTag')}
|
||||
</Tag>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Text style={metaTextStyles}>
|
||||
{item.project_name}
|
||||
</Text>
|
||||
<Space size={16}>
|
||||
<span style={activityCountStyles}>
|
||||
{item.activity_count} {item.activity_count === 1 ? t('activitySingular') : t('activityPlural')}
|
||||
</span>
|
||||
<Tooltip
|
||||
title={moment(item.last_activity_at).format('MMMM Do YYYY, h:mm:ss a')}
|
||||
placement="topRight"
|
||||
>
|
||||
<Text style={timeTextStyles}>
|
||||
{moment(item.last_activity_at).fromNow()}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default TaskActivityList;
|
||||
@@ -0,0 +1,201 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { List, Typography, Tag, Tooltip, Space, theme } from 'antd';
|
||||
import { ClockCircleOutlined } from '@ant-design/icons';
|
||||
import moment from 'moment';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import {
|
||||
setSelectedTaskId,
|
||||
setShowTaskDrawer,
|
||||
fetchTask,
|
||||
} from '@/features/task-drawer/task-drawer.slice';
|
||||
import { IUserTimeLoggedTask } from '@/types/home/user-activity.types';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface TimeLoggedTaskListProps {
|
||||
tasks: IUserTimeLoggedTask[];
|
||||
}
|
||||
|
||||
const TimeLoggedTaskList: React.FC<TimeLoggedTaskListProps> = React.memo(({ tasks }) => {
|
||||
const { t } = useTranslation('home');
|
||||
const dispatch = useAppDispatch();
|
||||
const { token } = theme.useToken();
|
||||
|
||||
// Enhanced dark mode detection
|
||||
const isDarkMode = useMemo(() => {
|
||||
return token.colorBgContainer === '#1f1f1f' ||
|
||||
token.colorBgBase === '#141414' ||
|
||||
token.colorBgElevated === '#1f1f1f' ||
|
||||
document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||
document.body.classList.contains('dark');
|
||||
}, [token]);
|
||||
|
||||
const handleTaskClick = useCallback(
|
||||
(taskId: string, projectId: string) => {
|
||||
dispatch(setSelectedTaskId(taskId));
|
||||
dispatch(setShowTaskDrawer(true));
|
||||
dispatch(fetchTask({ taskId, projectId }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
// Enhanced styling with theme support
|
||||
const listItemStyles = useMemo(() => ({
|
||||
padding: '16px 20px',
|
||||
borderBottom: isDarkMode ? '1px solid #404040' : '1px solid #f0f2f5',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
borderRadius: '8px',
|
||||
margin: '0 0 2px 0',
|
||||
background: isDarkMode ? 'transparent' : 'transparent',
|
||||
position: 'relative' as const,
|
||||
overflow: 'hidden',
|
||||
}), [isDarkMode]);
|
||||
|
||||
const listItemHoverStyles = useMemo(() => ({
|
||||
background: isDarkMode
|
||||
? 'linear-gradient(135deg, #2a2a2a 0%, #353535 100%)'
|
||||
: 'linear-gradient(135deg, #f6ffed 0%, #f0fff4 100%)',
|
||||
borderColor: isDarkMode ? '#505050' : '#b7eb8f',
|
||||
transform: 'translateY(-1px)',
|
||||
boxShadow: isDarkMode
|
||||
? '0 4px 16px rgba(0, 0, 0, 0.3), 0 1px 4px rgba(0, 0, 0, 0.2)'
|
||||
: '0 4px 16px rgba(82, 196, 26, 0.15), 0 1px 4px rgba(0, 0, 0, 0.1)',
|
||||
}), [isDarkMode]);
|
||||
|
||||
const iconStyles = useMemo(() => ({
|
||||
color: isDarkMode ? '#73d13d' : '#52c41a',
|
||||
fontSize: '16px',
|
||||
padding: '8px',
|
||||
borderRadius: '6px',
|
||||
background: isDarkMode
|
||||
? 'linear-gradient(135deg, #1b2918 0%, #273622 100%)'
|
||||
: 'linear-gradient(135deg, #f6ffed 0%, #f0fff4 100%)',
|
||||
border: isDarkMode ? '1px solid #52c41a20' : '1px solid #52c41a20',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: '32px',
|
||||
minHeight: '32px',
|
||||
}), [isDarkMode]);
|
||||
|
||||
const taskNameStyles = useMemo(() => ({
|
||||
color: isDarkMode ? '#ffffff' : '#1f2937',
|
||||
fontSize: '15px',
|
||||
fontWeight: 600,
|
||||
lineHeight: '1.4',
|
||||
}), [isDarkMode]);
|
||||
|
||||
const timeLogTagStyles = useMemo(() => ({
|
||||
background: isDarkMode
|
||||
? 'linear-gradient(135deg, #365314 0%, #4d7c0f 100%)'
|
||||
: 'linear-gradient(135deg, #f0fff4 0%, #d9f7be 100%)',
|
||||
color: isDarkMode ? '#ffffff' : '#365314',
|
||||
border: isDarkMode ? '1px solid #4d7c0f' : '1px solid #95de64',
|
||||
borderRadius: '6px',
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
padding: '2px 8px',
|
||||
textTransform: 'uppercase' as const,
|
||||
letterSpacing: '0.5px',
|
||||
}), [isDarkMode]);
|
||||
|
||||
const timerTagStyles = useMemo(() => ({
|
||||
background: isDarkMode
|
||||
? 'linear-gradient(135deg, #0f766e 0%, #14b8a6 100%)'
|
||||
: 'linear-gradient(135deg, #f0fdfa 0%, #ccfbf1 100%)',
|
||||
color: isDarkMode ? '#ffffff' : '#0f766e',
|
||||
border: isDarkMode ? '1px solid #14b8a6' : '1px solid #5eead4',
|
||||
borderRadius: '6px',
|
||||
fontSize: '10px',
|
||||
fontWeight: 600,
|
||||
padding: '1px 6px',
|
||||
}), [isDarkMode]);
|
||||
|
||||
const metaTextStyles = useMemo(() => ({
|
||||
color: isDarkMode ? '#9ca3af' : '#6b7280',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
}), [isDarkMode]);
|
||||
|
||||
const timeTextStyles = useMemo(() => ({
|
||||
color: isDarkMode ? '#8c8c8c' : '#9ca3af',
|
||||
fontSize: '12px',
|
||||
fontWeight: 400,
|
||||
}), [isDarkMode]);
|
||||
|
||||
const timeLoggedStyles = useMemo(() => ({
|
||||
color: isDarkMode ? '#73d13d' : '#52c41a',
|
||||
fontSize: '13px',
|
||||
fontWeight: 700,
|
||||
background: isDarkMode
|
||||
? 'linear-gradient(135deg, #1b291820 0%, #27362220 100%)'
|
||||
: 'linear-gradient(135deg, #f6ffed 0%, #f0fff4 100%)',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '6px',
|
||||
border: isDarkMode ? '1px solid #52c41a40' : '1px solid #b7eb8f40',
|
||||
}), [isDarkMode]);
|
||||
|
||||
return (
|
||||
<List
|
||||
dataSource={tasks}
|
||||
style={{ background: 'transparent' }}
|
||||
split={false}
|
||||
renderItem={item => (
|
||||
<List.Item
|
||||
onClick={() => handleTaskClick(item.task_id, item.project_id)}
|
||||
style={listItemStyles}
|
||||
onMouseEnter={(e) => {
|
||||
Object.assign(e.currentTarget.style, listItemHoverStyles);
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
Object.assign(e.currentTarget.style, listItemStyles);
|
||||
}}
|
||||
aria-label={`${t('timeLoggedTaskAriaLabel')} ${item.task_name}`}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<div style={iconStyles}>
|
||||
<ClockCircleOutlined />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
|
||||
<Text ellipsis style={taskNameStyles}>
|
||||
{item.task_name}
|
||||
</Text>
|
||||
<Tag style={timeLogTagStyles}>
|
||||
{t('timeLogTag')}
|
||||
</Tag>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Text style={metaTextStyles}>
|
||||
{item.project_name}
|
||||
</Text>
|
||||
<Space size={12} align="center">
|
||||
<span style={timeLoggedStyles}>
|
||||
{item.total_time_logged_string}
|
||||
</span>
|
||||
{item.logged_by_timer && (
|
||||
<Tag style={timerTagStyles}>
|
||||
{t('timerTag')}
|
||||
</Tag>
|
||||
)}
|
||||
<Tooltip
|
||||
title={moment(item.last_logged_at).format('MMMM Do YYYY, h:mm:ss a')}
|
||||
placement="topRight"
|
||||
>
|
||||
<Text style={timeTextStyles}>
|
||||
{moment(item.last_logged_at).fromNow()}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default TimeLoggedTaskList;
|
||||
@@ -0,0 +1,19 @@
|
||||
.activity-feed-item:hover {
|
||||
background-color: var(--activity-hover, #fafafa);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.activity-feed-item:active {
|
||||
transform: translateY(0);
|
||||
background-color: var(--activity-active, #f0f0f0);
|
||||
}
|
||||
|
||||
/* Dark theme support */
|
||||
[data-theme="dark"] .activity-feed-item:hover {
|
||||
background-color: var(--activity-hover, #262626);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .activity-feed-item:active {
|
||||
background-color: var(--activity-active, #1f1f1f);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import React, { useMemo, useCallback, useEffect } from 'react';
|
||||
import { Card, Segmented, Skeleton, Empty, Typography, Alert } from 'antd';
|
||||
import { ClockCircleOutlined, UnorderedListOutlined } from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { ActivityFeedType } from '@/types/home/user-activity.types';
|
||||
import { setActiveTab } from '@/features/home-page/user-activity.slice';
|
||||
import {
|
||||
useGetUserRecentTasksQuery,
|
||||
useGetUserTimeLoggedTasksQuery,
|
||||
} from '@/api/home-page/user-activity.api.service';
|
||||
import TaskActivityList from './task-activity-list';
|
||||
import TimeLoggedTaskList from './time-logged-task-list';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
const UserActivityFeed: React.FC = () => {
|
||||
const { t } = useTranslation('home');
|
||||
const dispatch = useAppDispatch();
|
||||
const { activeTab } = useAppSelector(state => state.userActivityReducer);
|
||||
|
||||
const {
|
||||
data: recentTasksData,
|
||||
isLoading: loadingRecentTasks,
|
||||
error: recentTasksError,
|
||||
refetch: refetchRecentTasks,
|
||||
} = useGetUserRecentTasksQuery(
|
||||
{ limit: 10 },
|
||||
{
|
||||
skip: false,
|
||||
refetchOnMountOrArgChange: true
|
||||
}
|
||||
);
|
||||
|
||||
const {
|
||||
data: timeLoggedTasksData,
|
||||
isLoading: loadingTimeLoggedTasks,
|
||||
error: timeLoggedTasksError,
|
||||
refetch: refetchTimeLoggedTasks,
|
||||
} = useGetUserTimeLoggedTasksQuery(
|
||||
{ limit: 10 },
|
||||
{
|
||||
skip: false,
|
||||
refetchOnMountOrArgChange: true
|
||||
}
|
||||
);
|
||||
|
||||
const recentTasks = useMemo(() => {
|
||||
if (!recentTasksData) return [];
|
||||
// Handle both array and object responses from the API
|
||||
if (Array.isArray(recentTasksData)) {
|
||||
return recentTasksData;
|
||||
}
|
||||
// If it's an object with a data property (common API pattern)
|
||||
if (recentTasksData && typeof recentTasksData === 'object' && 'data' in recentTasksData) {
|
||||
return Array.isArray(recentTasksData.data) ? recentTasksData.data : [];
|
||||
}
|
||||
// If it's a different object structure, try to extract tasks
|
||||
if (recentTasksData && typeof recentTasksData === 'object') {
|
||||
const possibleArrays = Object.values(recentTasksData).filter(Array.isArray);
|
||||
return possibleArrays.length > 0 ? possibleArrays[0] : [];
|
||||
}
|
||||
return [];
|
||||
}, [recentTasksData]);
|
||||
|
||||
const timeLoggedTasks = useMemo(() => {
|
||||
if (!timeLoggedTasksData) return [];
|
||||
// Handle both array and object responses from the API
|
||||
if (Array.isArray(timeLoggedTasksData)) {
|
||||
return timeLoggedTasksData;
|
||||
}
|
||||
// If it's an object with a data property (common API pattern)
|
||||
if (timeLoggedTasksData && typeof timeLoggedTasksData === 'object' && 'data' in timeLoggedTasksData) {
|
||||
return Array.isArray(timeLoggedTasksData.data) ? timeLoggedTasksData.data : [];
|
||||
}
|
||||
// If it's a different object structure, try to extract tasks
|
||||
if (timeLoggedTasksData && typeof timeLoggedTasksData === 'object') {
|
||||
const possibleArrays = Object.values(timeLoggedTasksData).filter(Array.isArray);
|
||||
return possibleArrays.length > 0 ? possibleArrays[0] : [];
|
||||
}
|
||||
return [];
|
||||
}, [timeLoggedTasksData]);
|
||||
|
||||
const segmentOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
value: ActivityFeedType.RECENT_TASKS,
|
||||
label: (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<UnorderedListOutlined />
|
||||
<span>{t('Recent Tasks')}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: ActivityFeedType.TIME_LOGGED_TASKS,
|
||||
label: (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<ClockCircleOutlined />
|
||||
<span>{t('Time Logged Tasks')}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const handleTabChange = useCallback(
|
||||
(value: ActivityFeedType) => {
|
||||
dispatch(setActiveTab(value));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
// Refetch data when the active tab changes
|
||||
useEffect(() => {
|
||||
if (activeTab === ActivityFeedType.RECENT_TASKS) {
|
||||
refetchRecentTasks();
|
||||
} else if (activeTab === ActivityFeedType.TIME_LOGGED_TASKS) {
|
||||
refetchTimeLoggedTasks();
|
||||
}
|
||||
}, [activeTab, refetchRecentTasks, refetchTimeLoggedTasks]);
|
||||
|
||||
const renderContent = () => {
|
||||
if (activeTab === ActivityFeedType.RECENT_TASKS) {
|
||||
if (loadingRecentTasks) {
|
||||
return <Skeleton active />;
|
||||
}
|
||||
if (recentTasksError) {
|
||||
return <Alert message={t('Error Loading Recent Tasks')} type="error" showIcon />;
|
||||
}
|
||||
if (recentTasks.length === 0) {
|
||||
return <Empty description={t('No Recent Tasks')} />;
|
||||
}
|
||||
return <TaskActivityList tasks={recentTasks} />;
|
||||
} else {
|
||||
if (loadingTimeLoggedTasks) {
|
||||
return <Skeleton active />;
|
||||
}
|
||||
if (timeLoggedTasksError) {
|
||||
return <Alert message={t('Error Loading Time Logged Tasks')} type="error" showIcon />;
|
||||
}
|
||||
if (timeLoggedTasks.length === 0) {
|
||||
return <Empty description={t('No Time Logged Tasks')} />;
|
||||
}
|
||||
return <TimeLoggedTaskList tasks={timeLoggedTasks} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card title={t('Recent Activity')}>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Segmented
|
||||
options={segmentOptions}
|
||||
value={activeTab}
|
||||
onChange={handleTabChange}
|
||||
/>
|
||||
</div>
|
||||
{renderContent()}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(UserActivityFeed);
|
||||
32
worklenz-frontend/src/types/home/user-activity.types.ts
Normal file
32
worklenz-frontend/src/types/home/user-activity.types.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export interface IUserRecentTask {
|
||||
task_id: string;
|
||||
task_name: string;
|
||||
project_id: string;
|
||||
project_name: string;
|
||||
last_activity_at: string;
|
||||
activity_count: number;
|
||||
project_color?: string;
|
||||
task_status?: string;
|
||||
status_color?: string;
|
||||
}
|
||||
|
||||
export interface IUserTimeLoggedTask {
|
||||
task_id: string;
|
||||
task_name: string;
|
||||
project_id: string;
|
||||
project_name: string;
|
||||
total_time_logged: number;
|
||||
total_time_logged_string: string;
|
||||
last_logged_at: string;
|
||||
logged_by_timer: boolean;
|
||||
project_color?: string;
|
||||
task_status?: string;
|
||||
status_color?: string;
|
||||
log_entries_count?: number;
|
||||
estimated_time?: number;
|
||||
}
|
||||
|
||||
export enum ActivityFeedType {
|
||||
RECENT_TASKS = 'recent-tasks',
|
||||
TIME_LOGGED_TASKS = 'time-logged-tasks'
|
||||
}
|
||||
Reference in New Issue
Block a user