diff --git a/worklenz-frontend/public/locales/alb/home.json b/worklenz-frontend/public/locales/alb/home.json index 58d26e0b..438ad788 100644 --- a/worklenz-frontend/public/locales/alb/home.json +++ b/worklenz-frontend/public/locales/alb/home.json @@ -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" } } diff --git a/worklenz-frontend/public/locales/de/home.json b/worklenz-frontend/public/locales/de/home.json index cc868952..00da8fcf 100644 --- a/worklenz-frontend/public/locales/de/home.json +++ b/worklenz-frontend/public/locales/de/home.json @@ -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" } } diff --git a/worklenz-frontend/public/locales/en/home.json b/worklenz-frontend/public/locales/en/home.json index ccf40936..4d64626e 100644 --- a/worklenz-frontend/public/locales/en/home.json +++ b/worklenz-frontend/public/locales/en/home.json @@ -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" } } diff --git a/worklenz-frontend/public/locales/es/home.json b/worklenz-frontend/public/locales/es/home.json index cfd238f9..3eb1388c 100644 --- a/worklenz-frontend/public/locales/es/home.json +++ b/worklenz-frontend/public/locales/es/home.json @@ -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" } } diff --git a/worklenz-frontend/public/locales/pt/home.json b/worklenz-frontend/public/locales/pt/home.json index b19ece5f..71fa4b02 100644 --- a/worklenz-frontend/public/locales/pt/home.json +++ b/worklenz-frontend/public/locales/pt/home.json @@ -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" } } diff --git a/worklenz-frontend/src/api/home-page/user-activity.api.service.ts b/worklenz-frontend/src/api/home-page/user-activity.api.service.ts new file mode 100644 index 00000000..f37da22a --- /dev/null +++ b/worklenz-frontend/src/api/home-page/user-activity.api.service.ts @@ -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({ + query: ({ limit = 10, offset = 0 }) => ({ + url: `${rootUrl}/user-recent-tasks`, + params: { limit, offset }, + method: 'GET', + }), + providesTags: ['UserRecentTasks'], + }), + getUserTimeLoggedTasks: builder.query({ + query: ({ limit = 10, offset = 0 }) => ({ + url: `${rootUrl}/user-time-logged-tasks`, + params: { limit, offset }, + method: 'GET', + }), + providesTags: ['UserTimeLoggedTasks'], + }), + }), +}); + +export const { + useGetUserRecentTasksQuery, + useGetUserTimeLoggedTasksQuery, +} = userActivityApiService; + + diff --git a/worklenz-frontend/src/app/store.ts b/worklenz-frontend/src/app/store.ts index 6bf7adcf..ef02da3f 100644 --- a/worklenz-frontend/src/app/store.ts +++ b/worklenz-frontend/src/app/store.ts @@ -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, diff --git a/worklenz-frontend/src/features/home-page/user-activity.slice.ts b/worklenz-frontend/src/features/home-page/user-activity.slice.ts new file mode 100644 index 00000000..bfe207f2 --- /dev/null +++ b/worklenz-frontend/src/features/home-page/user-activity.slice.ts @@ -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) { + state.activeTab = action.payload; + }, + fetchActivitiesStart(state) { + state.loading = true; + state.error = null; + }, + fetchActivitiesSuccess(state, action: PayloadAction) { + state.activities = action.payload; + state.loading = false; + state.error = null; + }, + fetchActivitiesFailure(state, action: PayloadAction) { + state.loading = false; + state.error = action.payload; + }, + clearActivities(state) { + state.activities = []; + }, + }, +}); + +export const { + setActiveTab, + fetchActivitiesStart, + fetchActivitiesSuccess, + fetchActivitiesFailure, + clearActivities, +} = userActivitySlice.actions; + +export default userActivitySlice.reducer; \ No newline at end of file diff --git a/worklenz-frontend/src/pages/home/home-page.tsx b/worklenz-frontend/src/pages/home/home-page.tsx index 820dadc8..e861a38e 100644 --- a/worklenz-frontend/src/pages/home/home-page.tsx +++ b/worklenz-frontend/src/pages/home/home-page.tsx @@ -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 && ); - const MainContent = () => - isDesktop ? ( - - - - - - - - - - ) : ( - - - - - - ); - return (
@@ -80,11 +65,30 @@ const HomePage = () => { - + + + + + + + + + + + + + + + + + + + + {createPortal(, document.body, 'home-task-drawer')} {createPortal( {}} />, document.body, 'project-drawer')}
); }; -export default HomePage; +export default HomePage; \ No newline at end of file diff --git a/worklenz-frontend/src/pages/home/user-activity-feed/task-activity-list.tsx b/worklenz-frontend/src/pages/home/user-activity-feed/task-activity-list.tsx new file mode 100644 index 00000000..9a9d7ed7 --- /dev/null +++ b/worklenz-frontend/src/pages/home/user-activity-feed/task-activity-list.tsx @@ -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 = 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 ( + ( + 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}`} + > + +
+ + + {item.task_name} + + + Activity + + + + {moment(item.last_activity_at).fromNow()} + + +
+
+ + {item.project_name} + + + {item.activity_count} {item.activity_count === 1 ? 'activity' : 'activities'} + +
+
+
+ )} + /> + ); +}); + +export default TaskActivityList; diff --git a/worklenz-frontend/src/pages/home/user-activity-feed/time-logged-task-list.tsx b/worklenz-frontend/src/pages/home/user-activity-feed/time-logged-task-list.tsx new file mode 100644 index 00000000..c4da1825 --- /dev/null +++ b/worklenz-frontend/src/pages/home/user-activity-feed/time-logged-task-list.tsx @@ -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 = 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 ( + ( + 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}`} + > + +
+ + + {item.task_name} + + + Time Log + + + + {item.total_time_logged_string} + + {item.logged_by_timer && ( + + Timer + + )} + + + {moment(item.last_logged_at).fromNow()} + + + +
+ + {item.project_name} + +
+
+ )} + /> + ); +}); + +export default TimeLoggedTaskList; diff --git a/worklenz-frontend/src/pages/home/user-activity-feed/user-activity-feed.css b/worklenz-frontend/src/pages/home/user-activity-feed/user-activity-feed.css new file mode 100644 index 00000000..98faae79 --- /dev/null +++ b/worklenz-frontend/src/pages/home/user-activity-feed/user-activity-feed.css @@ -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); +} diff --git a/worklenz-frontend/src/pages/home/user-activity-feed/user-activity-feed.tsx b/worklenz-frontend/src/pages/home/user-activity-feed/user-activity-feed.tsx new file mode 100644 index 00000000..77e817cc --- /dev/null +++ b/worklenz-frontend/src/pages/home/user-activity-feed/user-activity-feed.tsx @@ -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: ( +
+ + {t('Recent Tasks')} +
+ ), + }, + { + value: ActivityFeedType.TIME_LOGGED_TASKS, + label: ( +
+ + {t('Time Logged Tasks')} +
+ ), + }, + ], + [t] + ); + + const handleTabChange = useCallback( + (value: ActivityFeedType) => { + dispatch(setActiveTab(value)); + }, + [dispatch] + ); + + const renderContent = () => { + if (activeTab === ActivityFeedType.RECENT_TASKS) { + if (recentTasksError) { + return ; + } + if (loadingRecentTasks) { + return ; + } + if (recentTasks.length === 0) { + return ; + } + return ; + } else { + if (timeLoggedTasksError) { + return ; + } + if (loadingTimeLoggedTasks) { + return ; + } + if (timeLoggedTasks.length === 0) { + return ; + } + return ; + } + }; + + return ( + +
+ + {t('Recent Activity')} + + +
+ {renderContent()} +
+ ); +}; + +export default React.memo(UserActivityFeed); diff --git a/worklenz-frontend/src/types/home/user-activity.types.ts b/worklenz-frontend/src/types/home/user-activity.types.ts new file mode 100644 index 00000000..66256193 --- /dev/null +++ b/worklenz-frontend/src/types/home/user-activity.types.ts @@ -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' +}