user activity feed - frontend
This commit is contained in:
@@ -41,6 +41,11 @@
|
|||||||
"list": "Listë",
|
"list": "Listë",
|
||||||
"calendar": "Kalendar",
|
"calendar": "Kalendar",
|
||||||
"tasks": "Detyrat",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,11 @@
|
|||||||
"list": "Liste",
|
"list": "Liste",
|
||||||
"calendar": "Kalender",
|
"calendar": "Kalender",
|
||||||
"tasks": "Aufgaben",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,11 @@
|
|||||||
"list": "List",
|
"list": "List",
|
||||||
"calendar": "Calendar",
|
"calendar": "Calendar",
|
||||||
"tasks": "Tasks",
|
"tasks": "Tasks",
|
||||||
"refresh": "Refresh"
|
"refresh": "Refresh",
|
||||||
|
"recentActivity": "Recent Activity",
|
||||||
|
"recentTasks": "Recent Tasks",
|
||||||
|
"timeLoggedTasks": "Time Logged",
|
||||||
|
"noRecentTasks": "No recent tasks",
|
||||||
|
"noTimeLoggedTasks": "No time logged tasks"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,11 @@
|
|||||||
"list": "Lista",
|
"list": "Lista",
|
||||||
"calendar": "Calendario",
|
"calendar": "Calendario",
|
||||||
"tasks": "Tareas",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,11 @@
|
|||||||
"list": "Lista",
|
"list": "Lista",
|
||||||
"calendar": "Calendário",
|
"calendar": "Calendário",
|
||||||
"tasks": "Tarefas",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|
||||||
@@ -6,6 +6,7 @@ import userReducer from '@features/user/userSlice';
|
|||||||
|
|
||||||
// Home Page
|
// Home Page
|
||||||
import homePageReducer from '@features/home-page/home-page.slice';
|
import homePageReducer from '@features/home-page/home-page.slice';
|
||||||
|
import userActivityReducer from '@features/home-page/user-activity.slice';
|
||||||
|
|
||||||
// Account Setup
|
// Account Setup
|
||||||
import accountSetupReducer from '@features/account-setup/account-setup.slice';
|
import accountSetupReducer from '@features/account-setup/account-setup.slice';
|
||||||
@@ -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 groupByFilterDropdownReducer from '../features/group-by-filter-dropdown/group-by-filter-dropdown-slice';
|
||||||
import homePageApiService from '@/api/home-page/home-page.api.service';
|
import homePageApiService from '@/api/home-page/home-page.api.service';
|
||||||
import { projectsApi } from '@/api/projects/projects.v1.api.service';
|
import { projectsApi } from '@/api/projects/projects.v1.api.service';
|
||||||
|
import { userActivityApiService } from '@/api/home-page/user-activity.api.service';
|
||||||
|
|
||||||
export const store = configureStore({
|
export const store = configureStore({
|
||||||
middleware: getDefaultMiddleware =>
|
middleware: getDefaultMiddleware =>
|
||||||
getDefaultMiddleware({
|
getDefaultMiddleware({
|
||||||
serializableCheck: false,
|
serializableCheck: false,
|
||||||
}).concat(homePageApiService.middleware, projectsApi.middleware),
|
}).concat(homePageApiService.middleware, projectsApi.middleware, userActivityApiService.middleware),
|
||||||
reducer: {
|
reducer: {
|
||||||
// Auth & User
|
// Auth & User
|
||||||
auth: authReducer,
|
auth: authReducer,
|
||||||
@@ -93,6 +95,9 @@ export const store = configureStore({
|
|||||||
homePageReducer: homePageReducer,
|
homePageReducer: homePageReducer,
|
||||||
[homePageApiService.reducerPath]: homePageApiService.reducer,
|
[homePageApiService.reducerPath]: homePageApiService.reducer,
|
||||||
[projectsApi.reducerPath]: projectsApi.reducer,
|
[projectsApi.reducerPath]: projectsApi.reducer,
|
||||||
|
userActivityReducer: userActivityReducer,
|
||||||
|
[userActivityApiService.reducerPath]: userActivityApiService.reducer,
|
||||||
|
|
||||||
// Core UI
|
// Core UI
|
||||||
themeReducer: themeReducer,
|
themeReducer: themeReducer,
|
||||||
localesReducer: localesReducer,
|
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;
|
||||||
@@ -2,13 +2,15 @@ import { useEffect } from 'react';
|
|||||||
import { useMediaQuery } from 'react-responsive';
|
import { useMediaQuery } from 'react-responsive';
|
||||||
import Col from 'antd/es/col';
|
import Col from 'antd/es/col';
|
||||||
import Flex from 'antd/es/flex';
|
import Flex from 'antd/es/flex';
|
||||||
|
import Row from 'antd/es/row';
|
||||||
|
import Card from 'antd/es/card';
|
||||||
|
|
||||||
import GreetingWithTime from './greeting-with-time';
|
import GreetingWithTime from './greeting-with-time';
|
||||||
import TasksList from '@/pages/home/task-list/tasks-list';
|
import TasksList from '@/pages/home/task-list/tasks-list';
|
||||||
import TodoList from '@/pages/home/todo-list/todo-list';
|
|
||||||
import ProjectDrawer from '@/components/projects/project-drawer/project-drawer';
|
import ProjectDrawer from '@/components/projects/project-drawer/project-drawer';
|
||||||
import CreateProjectButton from '@/components/projects/project-create-button/project-create-button';
|
import CreateProjectButton from '@/components/projects/project-create-button/project-create-button';
|
||||||
import RecentAndFavouriteProjectList from '@/pages/home/recent-and-favourite-project-list/recent-and-favourite-project-list';
|
import RecentAndFavouriteProjectList from '@/pages/home/recent-and-favourite-project-list/recent-and-favourite-project-list';
|
||||||
|
import TodoList from './todo-list/todo-list';
|
||||||
|
|
||||||
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
@@ -20,11 +22,13 @@ import { fetchProjectHealth } from '@/features/projects/lookups/projectHealth/pr
|
|||||||
import { fetchProjects } from '@/features/home-page/home-page.slice';
|
import { fetchProjects } from '@/features/home-page/home-page.slice';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import UserActivityFeed from './user-activity-feed/user-activity-feed';
|
||||||
|
|
||||||
const DESKTOP_MIN_WIDTH = 1024;
|
const DESKTOP_MIN_WIDTH = 1024;
|
||||||
const TASK_LIST_MIN_WIDTH = 500;
|
const TASK_LIST_MIN_WIDTH = 500;
|
||||||
const SIDEBAR_MAX_WIDTH = 400;
|
const SIDEBAR_MAX_WIDTH = 400;
|
||||||
const TaskDrawer = React.lazy(() => import('@components/task-drawer/task-drawer'));
|
const TaskDrawer = React.lazy(() => import('@components/task-drawer/task-drawer'));
|
||||||
|
|
||||||
const HomePage = () => {
|
const HomePage = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const isDesktop = useMediaQuery({ query: `(min-width: ${DESKTOP_MIN_WIDTH}px)` });
|
const isDesktop = useMediaQuery({ query: `(min-width: ${DESKTOP_MIN_WIDTH}px)` });
|
||||||
@@ -54,25 +58,6 @@ const HomePage = () => {
|
|||||||
isOwnerOrAdmin && <CreateProjectButton />
|
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 (
|
return (
|
||||||
<div className="my-24 min-h-[90vh]">
|
<div className="my-24 min-h-[90vh]">
|
||||||
<Col className="flex flex-col gap-6">
|
<Col className="flex flex-col gap-6">
|
||||||
@@ -80,11 +65,30 @@ const HomePage = () => {
|
|||||||
<CreateProjectButtonComponent />
|
<CreateProjectButtonComponent />
|
||||||
</Col>
|
</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(<TaskDrawer />, document.body, 'home-task-drawer')}
|
||||||
{createPortal(<ProjectDrawer onClose={() => {}} />, document.body, 'project-drawer')}
|
{createPortal(<ProjectDrawer onClose={() => {}} />, document.body, 'project-drawer')}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default HomePage;
|
export default HomePage;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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,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);
|
||||||
24
worklenz-frontend/src/types/home/user-activity.types.ts
Normal file
24
worklenz-frontend/src/types/home/user-activity.types.ts
Normal 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'
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user