Merge pull request #253 from OminduHirushka/imp/user-activity-feed

user activity feed
This commit is contained in:
Chamika J
2025-07-11 11:27:42 +05:30
committed by GitHub
17 changed files with 723 additions and 145 deletions

View File

@@ -0,0 +1,93 @@
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;
}
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;
}
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 } = req.user;
const { offset = 0, limit = 10 } = req.query;
const q = `
SELECT tal.id, tal.task_id, t.name AS task_name, tal.project_id, p.name AS project_name,
tal.attribute_type, tal.log_type, tal.old_value, tal.new_value,
tal.prev_string, tal.next_string, tal.created_at AS last_activity_at,
(SELECT COUNT(*) FROM task_activity_logs WHERE task_id = tal.task_id AND user_id = $1) AS activity_count
FROM task_activity_logs tal
JOIN tasks t ON tal.task_id = t.id
JOIN projects p ON tal.project_id = p.id
WHERE tal.user_id = $1
ORDER BY tal.created_at DESC
LIMIT $2 OFFSET $3;
`;
const result = await db.query(q, [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 } = req.user;
const { offset = 0, limit = 10 } = req.query;
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) AS logged_by_timer
FROM task_work_log twl
JOIN tasks t ON twl.task_id = t.id
JOIN projects p ON t.project_id = p.id
WHERE twl.user_id = $1
GROUP BY twl.task_id, t.name, t.project_id, p.name
ORDER BY MAX(twl.created_at) DESC
LIMIT $2 OFFSET $3;
`;
const result = await db.query(q, [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));
}
}

View File

@@ -1,120 +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 billingApiRouter from "./billing-api-router";
import taskDependenciesApiRouter from "./task-dependencies-api-router";
import taskRecurringApiRouter from "./task-recurring-api-router";
import express from "express";
import AccessControlsController from "../../controllers/access-controls-controller";
import AuthController from "../../controllers/auth-controller";
import LogsController from "../../controllers/logs-controller";
import OverviewController from "../../controllers/overview-controller";
import TaskPrioritiesController from "../../controllers/task-priorities-controller";
import attachmentsApiRouter from "./attachments-api-router";
import clientsApiRouter from "./clients-api-router";
import jobTitlesApiRouter from "./job-titles-api-router";
import notificationsApiRouter from "./notifications-api-router";
import personalOverviewApiRouter from "./personal-overview-api-router";
import projectMembersApiRouter from "./project-members-api-router";
import projectsApiRouter from "./projects-api-router";
import settingsApiRouter from "./settings-api-router";
import statusesApiRouter from "./statuses-api-router";
import subTasksApiRouter from "./sub-tasks-api-router";
import taskCommentsApiRouter from "./task-comments-api-router";
import taskWorkLogApiRouter from "./task-work-log-api-router";
import tasksApiRouter from "./tasks-api-router";
import teamMembersApiRouter from "./team-members-api-router";
import teamsApiRouter from "./teams-api-router";
import timezonesApiRouter from "./timezones-api-router";
import todoListApiRouter from "./todo-list-api-router";
import projectStatusesApiRouter from "./project-statuses-api-router";
import labelsApiRouter from "./labels-api-router";
import sharedProjectsApiRouter from "./shared-projects-api-router";
import resourceAllocationApiRouter from "./resource-allocation-api-router";
import taskTemplatesApiRouter from "./task-templates-api-router";
import projectInsightsApiRouter from "./project-insights-api-router";
import passwordValidator from "../../middlewares/validators/password-validator";
import adminCenterApiRouter from "./admin-center-api-router";
import reportingApiRouter from "./reporting-api-router";
import activityLogsApiRouter from "./activity-logs-api-router";
import safeControllerFunction from "../../shared/safe-controller-function";
import projectFoldersApiRouter from "./project-folders-api-router";
import taskPhasesApiRouter from "./task-phases-api-router";
import projectCategoriesApiRouter from "./project-categories-api-router";
import homePageApiRouter from "./home-page-api-router";
import ganttApiRouter from "./gantt-api-router";
import projectCommentsApiRouter from "./project-comments-api-router";
import reportingExportApiRouter from "./reporting-export-api-router";
import projectHealthsApiRouter from "./project-healths-api-router";
import ptTasksApiRouter from "./pt-tasks-api-router";
import projectTemplatesApiRouter from "./project-templates-api";
import ptTaskPhasesApiRouter from "./pt_task-phases-api-router";
import ptStatusesApiRouter from "./pt-statuses-api-router";
import workloadApiRouter from "./gannt-apis/workload-api-router";
import roadmapApiRouter from "./gannt-apis/roadmap-api-router";
import scheduleApiRouter from "./gannt-apis/schedule-api-router";
import scheduleApiV2Router from "./gannt-apis/schedule-api-v2-router";
import projectManagerApiRouter from "./project-managers-api-router";
import billingApiRouter from "./billing-api-router";
import taskDependenciesApiRouter from "./task-dependencies-api-router";
import taskRecurringApiRouter from "./task-recurring-api-router";
import customColumnsApiRouter from "./custom-columns-api-router";
import 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.get("/overview/:id", safeControllerFunction(OverviewController.getById));
api.get("/task-priorities", safeControllerFunction(TaskPrioritiesController.get));
api.post("/change-password", passwordValidator, safeControllerFunction(AuthController.changePassword));
api.get("/access-controls/roles", safeControllerFunction(AccessControlsController.getRoles));
api.get("/logs/my-dashboard", safeControllerFunction(LogsController.getActivityLog));
api.use("/billing", billingApiRouter);
api.use("/task-dependencies", taskDependenciesApiRouter);
api.use("/task-recurring", taskRecurringApiRouter);
const api = express.Router();
api.use("/projects", projectsApiRouter);
api.use("/team-members", teamMembersApiRouter);
api.use("/job-titles", jobTitlesApiRouter);
api.use("/clients", clientsApiRouter);
api.use("/teams", teamsApiRouter);
api.use("/tasks", tasksApiRouter);
api.use("/settings", settingsApiRouter);
api.use("/personal-overview", personalOverviewApiRouter);
api.use("/statuses", statusesApiRouter);
api.use("/todo-list", todoListApiRouter);
api.use("/notifications", notificationsApiRouter);
api.use("/attachments", attachmentsApiRouter);
api.use("/sub-tasks", subTasksApiRouter);
api.use("/project-members", projectMembersApiRouter);
api.use("/task-time-log", taskWorkLogApiRouter);
api.use("/task-comments", taskCommentsApiRouter);
api.use("/timezones", timezonesApiRouter);
api.use("/project-statuses", projectStatusesApiRouter);
api.use("/labels", labelsApiRouter);
api.use("/resource-allocation", resourceAllocationApiRouter);
api.use("/shared/projects", sharedProjectsApiRouter);
api.use("/task-templates", taskTemplatesApiRouter);
api.use("/project-insights", projectInsightsApiRouter);
api.use("/admin-center", adminCenterApiRouter);
api.use("/reporting", reportingApiRouter);
api.use("/activity-logs", activityLogsApiRouter);
api.use("/projects-folders", projectFoldersApiRouter);
api.use("/task-phases", taskPhasesApiRouter);
api.use("/project-categories", projectCategoriesApiRouter);
api.use("/home", homePageApiRouter);
api.use("/gantt", ganttApiRouter);
api.use("/project-comments", projectCommentsApiRouter);
api.use("/reporting-export", reportingExportApiRouter);
api.use("/project-healths", projectHealthsApiRouter);
api.use("/project-templates", projectTemplatesApiRouter);
api.use("/pt-tasks", ptTasksApiRouter);
api.use("/pt-task-phases", ptTaskPhasesApiRouter);
api.use("/pt-statuses", ptStatusesApiRouter);
api.use("/workload-gannt", workloadApiRouter);
api.use("/roadmap-gannt", roadmapApiRouter);
api.use("/schedule-gannt", scheduleApiRouter);
api.use("/schedule-gannt-v2", scheduleApiV2Router);
api.use("/project-managers", projectManagerApiRouter);
api.get("/overview/:id", safeControllerFunction(OverviewController.getById));
api.get("/task-priorities", safeControllerFunction(TaskPrioritiesController.get));
api.post("/change-password", passwordValidator, safeControllerFunction(AuthController.changePassword));
api.get("/access-controls/roles", safeControllerFunction(AccessControlsController.getRoles));
api.get("/logs/my-dashboard", safeControllerFunction(LogsController.getActivityLog));
api.use("/billing", billingApiRouter);
api.use("/task-dependencies", taskDependenciesApiRouter);
api.use("/task-recurring", taskRecurringApiRouter);
api.use("/custom-columns", customColumnsApiRouter);
api.use("/logs", userActivityLogsApiRouter);
export default api;

View File

@@ -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;

View File

@@ -41,6 +41,11 @@
"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"
}
}

View File

@@ -41,6 +41,11 @@
"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"
}
}

View File

@@ -41,6 +41,11 @@
"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"
}
}

View File

@@ -40,6 +40,11 @@
"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"
}
}

View File

@@ -40,6 +40,11 @@
"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"
}
}

View File

@@ -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;

View File

@@ -6,6 +6,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';
@@ -75,12 +76,13 @@ import teamMembersReducer from '@features/team-members/team-members.slice';
import groupByFilterDropdownReducer from '../features/group-by-filter-dropdown/group-by-filter-dropdown-slice';
import homePageApiService from '@/api/home-page/home-page.api.service';
import { projectsApi } from '@/api/projects/projects.v1.api.service';
import { userActivityApiService } from '@/api/home-page/user-activity.api.service';
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,
@@ -93,6 +95,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,

View File

@@ -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;

View File

@@ -2,13 +2,15 @@ import { useEffect } 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';
@@ -20,11 +22,13 @@ import { fetchProjectHealth } from '@/features/projects/lookups/projectHealth/pr
import { fetchProjects } from '@/features/home-page/home-page.slice';
import { createPortal } from 'react-dom';
import React from 'react';
import UserActivityFeed from './user-activity-feed/user-activity-feed';
const DESKTOP_MIN_WIDTH = 1024;
const TASK_LIST_MIN_WIDTH = 500;
const SIDEBAR_MAX_WIDTH = 400;
const TaskDrawer = React.lazy(() => import('@components/task-drawer/task-drawer'));
const HomePage = () => {
const dispatch = useAppDispatch();
const isDesktop = useMediaQuery({ query: `(min-width: ${DESKTOP_MIN_WIDTH}px)` });
@@ -54,25 +58,6 @@ const HomePage = () => {
isOwnerOrAdmin && <CreateProjectButton />
);
const MainContent = () =>
isDesktop ? (
<Flex gap={24} align="flex-start" className="w-full mt-12">
<Flex style={{ minWidth: TASK_LIST_MIN_WIDTH, width: '100%' }}>
<TasksList />
</Flex>
<Flex vertical gap={24} style={{ width: '100%', maxWidth: SIDEBAR_MAX_WIDTH }}>
<TodoList />
<RecentAndFavouriteProjectList />
</Flex>
</Flex>
) : (
<Flex vertical gap={24} className="mt-6">
<TasksList />
<TodoList />
<RecentAndFavouriteProjectList />
</Flex>
);
return (
<div className="my-24 min-h-[90vh]">
<Col className="flex flex-col gap-6">
@@ -80,11 +65,30 @@ const HomePage = () => {
<CreateProjectButtonComponent />
</Col>
<MainContent />
<Row gutter={[24, 24]} className="mt-12">
<Col xs={24} lg={16}>
<Card title="Task List" className="h-full">
<TasksList />
</Card>
</Col>
<Col xs={24} lg={8}>
<Flex vertical gap={24}>
<UserActivityFeed />
<TodoList />
<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>
);
};
export default HomePage;
export default HomePage;

View File

@@ -0,0 +1,81 @@
import React, { useCallback } from 'react';
import { List, Typography, Tooltip, Space, Tag } from 'antd';
import { FileTextOutlined } from '@ant-design/icons';
import moment from 'moment';
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 dispatch = useAppDispatch();
const handleTaskClick = useCallback(
(taskId: string, projectId: string) => {
dispatch(setSelectedTaskId(taskId));
dispatch(setShowTaskDrawer(true));
dispatch(fetchTask({ taskId, projectId }));
},
[dispatch]
);
return (
<List
dataSource={tasks}
renderItem={item => (
<List.Item
onClick={() => handleTaskClick(item.task_id, item.project_id)}
style={{
padding: '12px 0',
borderBottom: '1px solid #f0f0f0',
cursor: 'pointer',
transition: 'all 0.2s ease',
}}
aria-label={`Recent task: ${item.task_name}`}
>
<Space direction="vertical" style={{ width: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<FileTextOutlined style={{ color: '#1890ff' }} />
<Text strong ellipsis style={{ flex: 1 }}>
{item.task_name}
</Text>
<Tag color="geekblue" style={{ marginLeft: 4, fontWeight: 500 }}>
Activity
</Tag>
<Tooltip title={moment(item.last_activity_at).format('MMMM Do YYYY, h:mm:ss a')}>
<Text type="secondary" style={{ fontSize: 12 }}>
{moment(item.last_activity_at).fromNow()}
</Text>
</Tooltip>
</div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Text type="secondary" style={{ fontSize: 12 }}>
{item.project_name}
</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
{item.activity_count} {item.activity_count === 1 ? 'activity' : 'activities'}
</Text>
</div>
</Space>
</List.Item>
)}
/>
);
});
export default TaskActivityList;

View File

@@ -0,0 +1,80 @@
import React, { useCallback } from 'react';
import { List, Typography, Tag, Tooltip, Space } from 'antd';
import { ClockCircleOutlined } from '@ant-design/icons';
import moment from 'moment';
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 dispatch = useAppDispatch();
const handleTaskClick = useCallback(
(taskId: string, projectId: string) => {
dispatch(setSelectedTaskId(taskId));
dispatch(setShowTaskDrawer(true));
dispatch(fetchTask({ taskId, projectId }));
},
[dispatch]
);
return (
<List
dataSource={tasks}
renderItem={item => (
<List.Item
onClick={() => handleTaskClick(item.task_id, item.project_id)}
style={{
padding: '12px 0',
borderBottom: '1px solid #f0f0f0',
cursor: 'pointer',
transition: 'all 0.2s ease',
}}
aria-label={`Time logged task: ${item.task_name}`}
>
<Space direction="vertical" style={{ width: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<ClockCircleOutlined style={{ color: '#52c41a' }} />
<Text strong ellipsis style={{ flex: 1 }}>
{item.task_name}
</Text>
<Tag color="lime" style={{ marginLeft: 4, fontWeight: 500 }}>
Time Log
</Tag>
<Space>
<Text strong style={{ color: '#52c41a', fontSize: 12 }}>
{item.total_time_logged_string}
</Text>
{item.logged_by_timer && (
<Tag color="green">
Timer
</Tag>
)}
<Tooltip title={moment(item.last_logged_at).format('MMMM Do YYYY, h:mm:ss a')}>
<Text type="secondary" style={{ fontSize: 12 }}>
{moment(item.last_logged_at).fromNow()}
</Text>
</Tooltip>
</Space>
</div>
<Text type="secondary" style={{ fontSize: 12 }}>
{item.project_name}
</Text>
</Space>
</List.Item>
)}
/>
);
});
export default TimeLoggedTaskList;

View File

@@ -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);
}

View File

@@ -0,0 +1,126 @@
import React, { useMemo, useCallback } 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,
} = useGetUserRecentTasksQuery(
{ limit: 10 },
{ skip: activeTab !== ActivityFeedType.RECENT_TASKS }
);
const {
data: timeLoggedTasksData,
isLoading: loadingTimeLoggedTasks,
error: timeLoggedTasksError,
} = useGetUserTimeLoggedTasksQuery(
{ limit: 10 },
{ skip: activeTab !== ActivityFeedType.TIME_LOGGED_TASKS }
);
const recentTasks = useMemo(() => {
if (!recentTasksData) return [];
return Array.isArray(recentTasksData) ? recentTasksData : [];
}, [recentTasksData]);
const timeLoggedTasks = useMemo(() => {
if (!timeLoggedTasksData) return [];
return Array.isArray(timeLoggedTasksData) ? timeLoggedTasksData : [];
}, [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]
);
const renderContent = () => {
if (activeTab === ActivityFeedType.RECENT_TASKS) {
if (recentTasksError) {
return <Alert message={t('Error Loading Recent Tasks')} type="error" showIcon />;
}
if (loadingRecentTasks) {
return <Skeleton active />;
}
if (recentTasks.length === 0) {
return <Empty description={t('No Recent Tasks')} />;
}
return <TaskActivityList tasks={recentTasks} />;
} else {
if (timeLoggedTasksError) {
return <Alert message={t('Error Loading Time Logged Tasks')} type="error" showIcon />;
}
if (loadingTimeLoggedTasks) {
return <Skeleton active />;
}
if (timeLoggedTasks.length === 0) {
return <Empty description={t('No Time Logged Tasks')} />;
}
return <TimeLoggedTaskList tasks={timeLoggedTasks} />;
}
};
return (
<Card>
<div style={{ marginBottom: 16 }}>
<Title level={5} style={{ marginBottom: 12 }}>
{t('Recent Activity')}
</Title>
<Segmented
options={segmentOptions}
value={activeTab}
onChange={handleTabChange}
style={{ width: '100%' }}
/>
</div>
{renderContent()}
</Card>
);
};
export default React.memo(UserActivityFeed);

View File

@@ -0,0 +1,24 @@
export interface IUserRecentTask {
task_id: string;
task_name: string;
project_id: string;
project_name: string;
last_activity_at: string;
activity_count: number;
}
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;
}
export enum ActivityFeedType {
RECENT_TASKS = 'recent-tasks',
TIME_LOGGED_TASKS = 'time-logged-tasks'
}