refactor(localization): update task-related translations and improve user activity feed

- Added new translation keys for recent tasks and time logged tasks in Albanian, German, English, Spanish, Portuguese, and Chinese localization files.
- Enhanced user activity feed to switch between recent tasks and time logged tasks, improving user experience.
- Updated the date formatting utility to support locale-specific formatting for better internationalization.
- Refactored task activity list and time logged task list components to utilize a table layout for improved readability.
This commit is contained in:
chamikaJ
2025-07-29 10:19:28 +05:30
parent e8ccc2a533
commit 53a28cf489
13 changed files with 347 additions and 387 deletions

View File

@@ -44,7 +44,9 @@
"refresh": "Rifresko", "refresh": "Rifresko",
"recentActivity": "Aktiviteti i Fundit", "recentActivity": "Aktiviteti i Fundit",
"recentTasks": "Detyrat e Fundit", "recentTasks": "Detyrat e Fundit",
"timeLoggedTasks": "Koha e Regjistruar", "recentTasksSegment": "Detyrat e Fundit",
"timeLogged": "Koha e Regjistruar",
"timeLoggedSegment": "Koha e Regjistruar",
"noRecentTasks": "Asnjë detyrë e fundit", "noRecentTasks": "Asnjë detyrë e fundit",
"noTimeLoggedTasks": "Asnjë detyrë me kohë të regjistruar", "noTimeLoggedTasks": "Asnjë detyrë me kohë të regjistruar",
"activityTag": "Aktiviteti", "activityTag": "Aktiviteti",
@@ -53,6 +55,8 @@
"activitySingular": "aktivitet", "activitySingular": "aktivitet",
"activityPlural": "aktivitete", "activityPlural": "aktivitete",
"recentTaskAriaLabel": "Detyrë e fundit:", "recentTaskAriaLabel": "Detyrë e fundit:",
"timeLoggedTaskAriaLabel": "Detyrë me kohë të regjistruar:" "timeLoggedTaskAriaLabel": "Detyrë me kohë të regjistruar:",
"errorLoadingRecentTasks": "Gabim në ngarkimin e detyrave të fundit",
"errorLoadingTimeLoggedTasks": "Gabim në ngarkimin e detyrave me kohë të regjistruar"
} }
} }

View File

@@ -44,7 +44,9 @@
"refresh": "Aktualisieren", "refresh": "Aktualisieren",
"recentActivity": "Aktuelle Aktivitäten", "recentActivity": "Aktuelle Aktivitäten",
"recentTasks": "Aktuelle Aufgaben", "recentTasks": "Aktuelle Aufgaben",
"timeLoggedTasks": "Erfasste Zeit", "recentTasksSegment": "Aktuelle Aufgaben",
"timeLogged": "Erfasste Zeit",
"timeLoggedSegment": "Erfasste Zeit",
"noRecentTasks": "Keine aktuellen Aufgaben", "noRecentTasks": "Keine aktuellen Aufgaben",
"noTimeLoggedTasks": "Keine Aufgaben mit erfasster Zeit", "noTimeLoggedTasks": "Keine Aufgaben mit erfasster Zeit",
"activityTag": "Aktivität", "activityTag": "Aktivität",
@@ -53,6 +55,8 @@
"activitySingular": "Aktivität", "activitySingular": "Aktivität",
"activityPlural": "Aktivitäten", "activityPlural": "Aktivitäten",
"recentTaskAriaLabel": "Aktuelle Aufgabe:", "recentTaskAriaLabel": "Aktuelle Aufgabe:",
"timeLoggedTaskAriaLabel": "Aufgabe mit erfasster Zeit:" "timeLoggedTaskAriaLabel": "Aufgabe mit erfasster Zeit:",
"errorLoadingRecentTasks": "Fehler beim Laden aktueller Aufgaben",
"errorLoadingTimeLoggedTasks": "Fehler beim Laden der Zeiterfassung"
} }
} }

View File

@@ -44,7 +44,9 @@
"refresh": "Refresh", "refresh": "Refresh",
"recentActivity": "Recent Activity", "recentActivity": "Recent Activity",
"recentTasks": "Recent Tasks", "recentTasks": "Recent Tasks",
"timeLoggedTasks": "Time Logged", "recentTasksSegment": "Recent Tasks",
"timeLogged": "Time Logged",
"timeLoggedSegment": "Time Logged",
"noRecentTasks": "No recent tasks", "noRecentTasks": "No recent tasks",
"noTimeLoggedTasks": "No time logged tasks", "noTimeLoggedTasks": "No time logged tasks",
"activityTag": "Activity", "activityTag": "Activity",
@@ -54,12 +56,7 @@
"activityPlural": "activities", "activityPlural": "activities",
"recentTaskAriaLabel": "Recent task:", "recentTaskAriaLabel": "Recent task:",
"timeLoggedTaskAriaLabel": "Time logged task:", "timeLoggedTaskAriaLabel": "Time logged task:",
"Recent Activity": "Recent Activity", "errorLoadingRecentTasks": "Error loading recent tasks",
"Recent Tasks": "Recent Tasks", "errorLoadingTimeLoggedTasks": "Error loading time logged 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"
} }
} }

View File

@@ -43,7 +43,9 @@
"refresh": "Actualizar", "refresh": "Actualizar",
"recentActivity": "Actividad Reciente", "recentActivity": "Actividad Reciente",
"recentTasks": "Tareas Recientes", "recentTasks": "Tareas Recientes",
"timeLoggedTasks": "Tiempo Registrado", "recentTasksSegment": "Tareas Recientes",
"timeLogged": "Tiempo Registrado",
"timeLoggedSegment": "Tiempo Registrado",
"noRecentTasks": "No hay tareas recientes", "noRecentTasks": "No hay tareas recientes",
"noTimeLoggedTasks": "No hay tareas con tiempo registrado", "noTimeLoggedTasks": "No hay tareas con tiempo registrado",
"activityTag": "Actividad", "activityTag": "Actividad",
@@ -52,6 +54,8 @@
"activitySingular": "actividad", "activitySingular": "actividad",
"activityPlural": "actividades", "activityPlural": "actividades",
"recentTaskAriaLabel": "Tarea reciente:", "recentTaskAriaLabel": "Tarea reciente:",
"timeLoggedTaskAriaLabel": "Tarea con tiempo registrado:" "timeLoggedTaskAriaLabel": "Tarea con tiempo registrado:",
"errorLoadingRecentTasks": "Error al cargar tareas recientes",
"errorLoadingTimeLoggedTasks": "Error al cargar tareas con tiempo registrado"
} }
} }

View File

@@ -43,7 +43,9 @@
"refresh": "Atualizar", "refresh": "Atualizar",
"recentActivity": "Atividade Recente", "recentActivity": "Atividade Recente",
"recentTasks": "Tarefas Recentes", "recentTasks": "Tarefas Recentes",
"timeLoggedTasks": "Tempo Registrado", "recentTasksSegment": "Tarefas Recentes",
"timeLogged": "Tempo Registrado",
"timeLoggedSegment": "Tempo Registrado",
"noRecentTasks": "Nenhuma tarefa recente", "noRecentTasks": "Nenhuma tarefa recente",
"noTimeLoggedTasks": "Nenhuma tarefa com tempo registrado", "noTimeLoggedTasks": "Nenhuma tarefa com tempo registrado",
"activityTag": "Atividade", "activityTag": "Atividade",
@@ -52,6 +54,8 @@
"activitySingular": "atividade", "activitySingular": "atividade",
"activityPlural": "atividades", "activityPlural": "atividades",
"recentTaskAriaLabel": "Tarefa recente:", "recentTaskAriaLabel": "Tarefa recente:",
"timeLoggedTaskAriaLabel": "Tarefa com tempo registrado:" "timeLoggedTaskAriaLabel": "Tarefa com tempo registrado:",
"errorLoadingRecentTasks": "Erro ao carregar tarefas recentes",
"errorLoadingTimeLoggedTasks": "Erro ao carregar tarefas com tempo registrado"
} }
} }

View File

@@ -44,7 +44,9 @@
"refresh": "刷新", "refresh": "刷新",
"recentActivity": "最近活动", "recentActivity": "最近活动",
"recentTasks": "最近任务", "recentTasks": "最近任务",
"timeLoggedTasks": "时间记录", "recentTasksSegment": "最近任务",
"timeLogged": "时间记录",
"timeLoggedSegment": "时间记录",
"noRecentTasks": "没有最近任务", "noRecentTasks": "没有最近任务",
"noTimeLoggedTasks": "没有时间记录任务", "noTimeLoggedTasks": "没有时间记录任务",
"activityTag": "活动", "activityTag": "活动",
@@ -53,6 +55,8 @@
"activitySingular": "活动", "activitySingular": "活动",
"activityPlural": "活动", "activityPlural": "活动",
"recentTaskAriaLabel": "最近任务:", "recentTaskAriaLabel": "最近任务:",
"timeLoggedTaskAriaLabel": "时间记录任务:" "timeLoggedTaskAriaLabel": "时间记录任务:",
"errorLoadingRecentTasks": "加载最近任务时出错",
"errorLoadingTimeLoggedTasks": "加载时间记录任务时出错"
} }
} }

View File

@@ -16,7 +16,7 @@ interface UserActivityState {
} }
const initialState: UserActivityState = { const initialState: UserActivityState = {
activeTab: ActivityFeedType.RECENT_TASKS, activeTab: ActivityFeedType.TIME_LOGGED_TASKS,
activities: [], activities: [],
loading: false, loading: false,
error: null, error: null,

View File

@@ -29,7 +29,9 @@ const SIDEBAR_MAX_WIDTH = 400;
// Lazy load heavy components // Lazy load heavy components
const TaskDrawer = React.lazy(() => import('@/components/task-drawer/task-drawer')); const TaskDrawer = React.lazy(() => import('@/components/task-drawer/task-drawer'));
const SurveyPromptModal = React.lazy(() => import('@/components/survey/SurveyPromptModal').then(m => ({ default: m.SurveyPromptModal }))); const SurveyPromptModal = React.lazy(() =>
import('@/components/survey/SurveyPromptModal').then(m => ({ default: m.SurveyPromptModal }))
);
const HomePage = memo(() => { const HomePage = memo(() => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@@ -109,18 +111,18 @@ const HomePage = memo(() => {
<Row gutter={[24, 24]} className="mt-12"> <Row gutter={[24, 24]} className="mt-12">
<Col xs={24} lg={16}> <Col xs={24} lg={16}>
<TasksList /> <Flex vertical gap={24}>
<TasksList />
<TodoList />
</Flex>
</Col> </Col>
<Col xs={24} lg={8}> <Col xs={24} lg={8}>
<Flex vertical gap={24}> <Flex vertical gap={24}>
<UserActivityFeed /> <UserActivityFeed />
<TodoList /> <RecentAndFavouriteProjectList />
<Card title="Recent & Favorite Projects">
<RecentAndFavouriteProjectList />
</Card>
</Flex> </Flex>
</Col> </Col>
</Row> </Row>

View File

@@ -144,7 +144,7 @@ const TodoList = () => {
</Form.Item> </Form.Item>
</Form> </Form>
<div style={{ maxHeight: 420, overflow: 'auto' }}> <div style={{ maxHeight: 300, overflow: 'auto' }}>
{data?.body.length === 0 ? ( {data?.body.length === 0 ? (
<EmptyListPlaceholder <EmptyListPlaceholder
imageSrc="https://s3.us-west-2.amazonaws.com/worklenz.com/assets/empty-box.webp" imageSrc="https://s3.us-west-2.amazonaws.com/worklenz.com/assets/empty-box.webp"

View File

@@ -1,9 +1,9 @@
import React, { useCallback, useMemo } from 'react'; import React, { useCallback } from 'react';
import { List, Typography, Tooltip, Space, Tag, theme } from 'antd'; import { Table, Typography, Tooltip, theme } from 'antd';
import { FileTextOutlined } from '@ant-design/icons'; import { FileTextOutlined } from '@ant-design/icons';
import moment from 'moment';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAppDispatch } from '@/hooks/useAppDispatch'; import { useAppDispatch } from '@/hooks/useAppDispatch';
import { fromNow, formatDate } from '@/utils/dateUtils';
import { import {
setSelectedTaskId, setSelectedTaskId,
setShowTaskDrawer, setShowTaskDrawer,
@@ -22,15 +22,6 @@ const TaskActivityList: React.FC<TaskActivityListProps> = React.memo(({ tasks })
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { token } = theme.useToken(); 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( const handleTaskClick = useCallback(
(taskId: string, projectId: string) => { (taskId: string, projectId: string) => {
dispatch(setSelectedTaskId(taskId)); dispatch(setSelectedTaskId(taskId));
@@ -40,143 +31,63 @@ const TaskActivityList: React.FC<TaskActivityListProps> = React.memo(({ tasks })
[dispatch] [dispatch]
); );
// Enhanced styling with theme support const columns = [
const listItemStyles = useMemo(() => ({ {
padding: '16px 20px', key: 'task',
borderBottom: isDarkMode ? '1px solid #404040' : '1px solid #f0f2f5', render: (record: IUserRecentTask) => (
cursor: 'pointer', <div
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)', style={{
borderRadius: '8px', display: 'flex',
margin: '0 0 2px 0', alignItems: 'flex-start',
background: isDarkMode ? 'transparent' : 'transparent', gap: 12,
position: 'relative' as const, width: '100%',
overflow: 'hidden', cursor: 'pointer',
}), [isDarkMode]); padding: '8px 0'
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) => { onClick={() => handleTaskClick(record.task_id, record.project_id)}
Object.assign(e.currentTarget.style, listItemStyles); aria-label={`${t('tasks.recentTaskAriaLabel')} ${record.task_name}`}
}}
aria-label={`${t('recentTaskAriaLabel')} ${item.task_name}`}
> >
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}> <div style={{
<div style={iconStyles}> marginTop: 2,
<FileTextOutlined /> color: token.colorPrimary,
fontSize: 16
}}>
<FileTextOutlined />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ marginBottom: 4 }}>
<Text strong style={{ fontSize: 14 }}>
{record.task_name}
</Text>
</div> </div>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}> <Text type="secondary" style={{ fontSize: 12 }}>
<Text ellipsis style={taskNameStyles}> {record.project_name}
{item.task_name} </Text>
<Tooltip
title={formatDate(record.last_activity_at, 'MMMM Do YYYY, h:mm:ss a')}
placement="topRight"
>
<Text type="secondary" style={{ fontSize: 12 }}>
{fromNow(record.last_activity_at)}
</Text> </Text>
<Tag style={tagStyles}> </Tooltip>
{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>
</div> </div>
</List.Item> </div>
)} ),
},
];
return (
<Table
className="custom-two-colors-row-table"
dataSource={tasks}
columns={columns}
rowKey="task_id"
showHeader={false}
pagination={false}
size="small"
/> />
); );
}); });

View File

@@ -1,9 +1,9 @@
import React, { useCallback, useMemo } from 'react'; import React, { useCallback } from 'react';
import { List, Typography, Tag, Tooltip, Space, theme } from 'antd'; import { Table, Typography, Tag, Tooltip, Space, theme } from '@/shared/antd-imports';
import { ClockCircleOutlined } from '@ant-design/icons'; import { ClockCircleOutlined } from '@/shared/antd-imports';
import moment from 'moment';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAppDispatch } from '@/hooks/useAppDispatch'; import { useAppDispatch } from '@/hooks/useAppDispatch';
import { fromNow, formatDate } from '@/utils/dateUtils';
import { import {
setSelectedTaskId, setSelectedTaskId,
setShowTaskDrawer, setShowTaskDrawer,
@@ -22,15 +22,6 @@ const TimeLoggedTaskList: React.FC<TimeLoggedTaskListProps> = React.memo(({ task
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { token } = theme.useToken(); 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( const handleTaskClick = useCallback(
(taskId: string, projectId: string) => { (taskId: string, projectId: string) => {
dispatch(setSelectedTaskId(taskId)); dispatch(setSelectedTaskId(taskId));
@@ -40,160 +31,134 @@ const TimeLoggedTaskList: React.FC<TimeLoggedTaskListProps> = React.memo(({ task
[dispatch] [dispatch]
); );
// Enhanced styling with theme support const columns = [
const listItemStyles = useMemo(() => ({ {
padding: '16px 20px', key: 'task',
borderBottom: isDarkMode ? '1px solid #404040' : '1px solid #f0f2f5', render: (record: IUserTimeLoggedTask) => (
cursor: 'pointer', <div
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)', style={{
borderRadius: '8px', display: 'flex',
margin: '0 0 2px 0', alignItems: 'center',
background: isDarkMode ? 'transparent' : 'transparent', gap: 10,
position: 'relative' as const, width: '100%',
overflow: 'hidden', cursor: 'pointer',
}), [isDarkMode]); padding: '8px 0'
}}
const listItemHoverStyles = useMemo(() => ({ onClick={() => handleTaskClick(record.task_id, record.project_id)}
background: isDarkMode aria-label={`${t('tasks.timeLoggedTaskAriaLabel')} ${record.task_name}`}
? 'linear-gradient(135deg, #2a2a2a 0%, #353535 100%)' >
: 'linear-gradient(135deg, #f6ffed 0%, #f0fff4 100%)', {/* Clock Icon */}
borderColor: isDarkMode ? '#505050' : '#b7eb8f', <div style={{
transform: 'translateY(-1px)', color: token.colorSuccess,
boxShadow: isDarkMode fontSize: 14,
? '0 4px 16px rgba(0, 0, 0, 0.3), 0 1px 4px rgba(0, 0, 0, 0.2)' flexShrink: 0
: '0 4px 16px rgba(82, 196, 26, 0.15), 0 1px 4px rgba(0, 0, 0, 0.1)', }}>
}), [isDarkMode]); <ClockCircleOutlined />
</div>
const iconStyles = useMemo(() => ({
color: isDarkMode ? '#73d13d' : '#52c41a', {/* Main Content */}
fontSize: '16px', <div style={{ flex: 1, minWidth: 0 }}>
padding: '8px', {/* Task Name */}
borderRadius: '6px', <div style={{ marginBottom: 2 }}>
background: isDarkMode <Text
? 'linear-gradient(135deg, #1b2918 0%, #273622 100%)' strong
: 'linear-gradient(135deg, #f6ffed 0%, #f0fff4 100%)', style={{
border: isDarkMode ? '1px solid #52c41a20' : '1px solid #52c41a20', fontSize: 13,
display: 'flex', lineHeight: 1.4,
alignItems: 'center', color: token.colorText
justifyContent: 'center', }}
minWidth: '32px', ellipsis={{ tooltip: record.task_name }}
minHeight: '32px', >
}), [isDarkMode]); {record.task_name}
</Text>
const taskNameStyles = useMemo(() => ({ </div>
color: isDarkMode ? '#ffffff' : '#1f2937',
fontSize: '15px', {/* Project Name */}
fontWeight: 600, <Text
lineHeight: '1.4', type="secondary"
}), [isDarkMode]); style={{
fontSize: 11,
const timeLogTagStyles = useMemo(() => ({ lineHeight: 1.2,
background: isDarkMode display: 'block',
? 'linear-gradient(135deg, #365314 0%, #4d7c0f 100%)' marginBottom: 4
: 'linear-gradient(135deg, #f0fff4 0%, #d9f7be 100%)', }}
color: isDarkMode ? '#ffffff' : '#365314', ellipsis={{ tooltip: record.project_name }}
border: isDarkMode ? '1px solid #4d7c0f' : '1px solid #95de64', >
borderRadius: '6px', {record.project_name}
fontSize: '11px', </Text>
fontWeight: 600, </div>
padding: '2px 8px',
textTransform: 'uppercase' as const, {/* Right Side - Time and Status */}
letterSpacing: '0.5px', <div style={{
}), [isDarkMode]); display: 'flex',
flexDirection: 'column',
const timerTagStyles = useMemo(() => ({ alignItems: 'flex-end',
background: isDarkMode gap: 3,
? 'linear-gradient(135deg, #0f766e 0%, #14b8a6 100%)' flexShrink: 0
: 'linear-gradient(135deg, #f0fdfa 0%, #ccfbf1 100%)', }}>
color: isDarkMode ? '#ffffff' : '#0f766e', {/* Time Logged */}
border: isDarkMode ? '1px solid #14b8a6' : '1px solid #5eead4', <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
borderRadius: '6px', <Tag
fontSize: '10px', color="success"
fontWeight: 600, style={{
padding: '1px 6px', margin: 0,
}), [isDarkMode]); fontSize: 11,
padding: '0 6px',
const metaTextStyles = useMemo(() => ({ height: 18,
color: isDarkMode ? '#9ca3af' : '#6b7280', lineHeight: '16px',
fontSize: '13px', borderRadius: 3
fontWeight: 500, }}
}), [isDarkMode]); >
{record.total_time_logged_string}
const timeTextStyles = useMemo(() => ({ </Tag>
color: isDarkMode ? '#8c8c8c' : '#9ca3af', {record.logged_by_timer && (
fontSize: '12px', <Tag
fontWeight: 400, color="processing"
}), [isDarkMode]); style={{
margin: 0,
const timeLoggedStyles = useMemo(() => ({ fontSize: 10,
color: isDarkMode ? '#73d13d' : '#52c41a', padding: '0 4px',
fontSize: '13px', height: 16,
fontWeight: 700, lineHeight: '14px',
background: isDarkMode borderRadius: 2
? 'linear-gradient(135deg, #1b291820 0%, #27362220 100%)' }}
: 'linear-gradient(135deg, #f6ffed 0%, #f0fff4 100%)', >
padding: '4px 8px', {t('tasks.timerTag')}
borderRadius: '6px', </Tag>
border: isDarkMode ? '1px solid #52c41a40' : '1px solid #b7eb8f40', )}
}), [isDarkMode]); </div>
{/* Time Ago */}
<Tooltip
title={formatDate(record.last_logged_at, 'MMMM Do YYYY, h:mm:ss a')}
placement="topRight"
>
<Text
type="secondary"
style={{
fontSize: 10,
lineHeight: 1,
color: token.colorTextTertiary
}}
>
{fromNow(record.last_logged_at)}
</Text>
</Tooltip>
</div>
</div>
),
},
];
return ( return (
<List <Table
className="custom-two-colors-row-table"
dataSource={tasks} dataSource={tasks}
style={{ background: 'transparent' }} columns={columns}
split={false} rowKey="task_id"
renderItem={item => ( showHeader={false}
<List.Item pagination={false}
onClick={() => handleTaskClick(item.task_id, item.project_id)} size="small"
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>
)}
/> />
); );
}); });

View File

@@ -1,6 +1,6 @@
import React, { useMemo, useCallback, useEffect } from 'react'; import React, { useMemo, useCallback, useEffect } from 'react';
import { Card, Segmented, Skeleton, Empty, Typography, Alert } from 'antd'; import { Card, Segmented, Skeleton, Empty, Typography, Alert, Button, Tooltip } from '@/shared/antd-imports';
import { ClockCircleOutlined, UnorderedListOutlined } from '@ant-design/icons'; import { ClockCircleOutlined, UnorderedListOutlined, SyncOutlined } from '@/shared/antd-imports';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAppSelector } from '@/hooks/useAppSelector'; import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch'; import { useAppDispatch } from '@/hooks/useAppDispatch';
@@ -54,11 +54,12 @@ const UserActivityFeed: React.FC = () => {
} }
// If it's an object with a data property (common API pattern) // If it's an object with a data property (common API pattern)
if (recentTasksData && typeof recentTasksData === 'object' && 'data' in recentTasksData) { if (recentTasksData && typeof recentTasksData === 'object' && 'data' in recentTasksData) {
return Array.isArray(recentTasksData.data) ? recentTasksData.data : []; const data = (recentTasksData as any).data;
return Array.isArray(data) ? data : [];
} }
// If it's a different object structure, try to extract tasks // If it's a different object structure, try to extract tasks
if (recentTasksData && typeof recentTasksData === 'object') { if (recentTasksData && typeof recentTasksData === 'object') {
const possibleArrays = Object.values(recentTasksData).filter(Array.isArray); const possibleArrays = Object.values(recentTasksData as any).filter(Array.isArray);
return possibleArrays.length > 0 ? possibleArrays[0] : []; return possibleArrays.length > 0 ? possibleArrays[0] : [];
} }
return []; return [];
@@ -72,11 +73,12 @@ const UserActivityFeed: React.FC = () => {
} }
// If it's an object with a data property (common API pattern) // If it's an object with a data property (common API pattern)
if (timeLoggedTasksData && typeof timeLoggedTasksData === 'object' && 'data' in timeLoggedTasksData) { if (timeLoggedTasksData && typeof timeLoggedTasksData === 'object' && 'data' in timeLoggedTasksData) {
return Array.isArray(timeLoggedTasksData.data) ? timeLoggedTasksData.data : []; const data = (timeLoggedTasksData as any).data;
return Array.isArray(data) ? data : [];
} }
// If it's a different object structure, try to extract tasks // If it's a different object structure, try to extract tasks
if (timeLoggedTasksData && typeof timeLoggedTasksData === 'object') { if (timeLoggedTasksData && typeof timeLoggedTasksData === 'object') {
const possibleArrays = Object.values(timeLoggedTasksData).filter(Array.isArray); const possibleArrays = Object.values(timeLoggedTasksData as any).filter(Array.isArray);
return possibleArrays.length > 0 ? possibleArrays[0] : []; return possibleArrays.length > 0 ? possibleArrays[0] : [];
} }
return []; return [];
@@ -85,21 +87,21 @@ const UserActivityFeed: React.FC = () => {
const segmentOptions = useMemo( const segmentOptions = useMemo(
() => [ () => [
{ {
value: ActivityFeedType.RECENT_TASKS, value: ActivityFeedType.TIME_LOGGED_TASKS,
label: ( label: (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<UnorderedListOutlined /> <ClockCircleOutlined style={{ fontSize: 14 }} />
<span>{t('Recent Tasks')}</span> {t('tasks.timeLoggedSegment')}
</div> </span>
), ),
}, },
{ {
value: ActivityFeedType.TIME_LOGGED_TASKS, value: ActivityFeedType.RECENT_TASKS,
label: ( label: (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<ClockCircleOutlined /> <UnorderedListOutlined style={{ fontSize: 14 }} />
<span>{t('Time Logged Tasks')}</span> {t('tasks.recentTasksSegment')}
</div> </span>
), ),
}, },
], ],
@@ -122,41 +124,77 @@ const UserActivityFeed: React.FC = () => {
} }
}, [activeTab, refetchRecentTasks, refetchTimeLoggedTasks]); }, [activeTab, refetchRecentTasks, refetchTimeLoggedTasks]);
const renderContent = () => { const handleRefresh = useCallback(() => {
if (activeTab === ActivityFeedType.RECENT_TASKS) { if (activeTab === ActivityFeedType.TIME_LOGGED_TASKS) {
if (loadingRecentTasks) { refetchTimeLoggedTasks();
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 { } else {
refetchRecentTasks();
}
}, [activeTab, refetchRecentTasks, refetchTimeLoggedTasks]);
const isLoading = activeTab === ActivityFeedType.TIME_LOGGED_TASKS ? loadingTimeLoggedTasks : loadingRecentTasks;
const currentCount = activeTab === ActivityFeedType.TIME_LOGGED_TASKS ? timeLoggedTasks.length : recentTasks.length;
const renderContent = () => {
if (activeTab === ActivityFeedType.TIME_LOGGED_TASKS) {
if (loadingTimeLoggedTasks) { if (loadingTimeLoggedTasks) {
return <Skeleton active />; return <Skeleton active />;
} }
if (timeLoggedTasksError) { if (timeLoggedTasksError) {
return <Alert message={t('Error Loading Time Logged Tasks')} type="error" showIcon />; return <Alert message={t('tasks.errorLoadingTimeLoggedTasks')} type="error" showIcon />;
} }
if (timeLoggedTasks.length === 0) { if (timeLoggedTasks.length === 0) {
return <Empty description={t('No Time Logged Tasks')} />; return <Empty description={t('tasks.noTimeLoggedTasks')} />;
} }
return <TimeLoggedTaskList tasks={timeLoggedTasks} />; return (
<div style={{ maxHeight: 450, overflow: 'auto' }}>
<TimeLoggedTaskList tasks={timeLoggedTasks} />
</div>
);
} else if (activeTab === ActivityFeedType.RECENT_TASKS) {
if (loadingRecentTasks) {
return <Skeleton active />;
}
if (recentTasksError) {
return <Alert message={t('tasks.errorLoadingRecentTasks')} type="error" showIcon />;
}
if (recentTasks.length === 0) {
return <Empty description={t('tasks.noRecentTasks')} />;
}
return (
<div style={{ maxHeight: 450, overflow: 'auto' }}>
<TaskActivityList tasks={recentTasks} />
</div>
);
} }
return null;
}; };
return ( return (
<Card title={t('Recent Activity')}> <Card
<div style={{ marginBottom: 16 }}> title={
<Segmented <Typography.Title level={5} style={{ marginBlockEnd: 0 }}>
options={segmentOptions} {t('tasks.recentActivity')} ({currentCount})
value={activeTab} </Typography.Title>
onChange={handleTabChange} }
/> extra={
</div> <Tooltip title={t('tasks.refresh')}>
<Button
shape="circle"
icon={<SyncOutlined spin={isLoading} />}
onClick={handleRefresh}
/>
</Tooltip>
}
style={{ width: '100%' }}
>
<Segmented
options={segmentOptions}
value={activeTab}
onChange={handleTabChange}
style={{ marginBottom: 16, width: '100%' }}
block
/>
{renderContent()} {renderContent()}
</Card> </Card>
); );

View File

@@ -1,32 +1,59 @@
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime'; import relativeTime from 'dayjs/plugin/relativeTime';
import localizedFormat from 'dayjs/plugin/localizedFormat';
import 'dayjs/locale/de';
import 'dayjs/locale/es';
import 'dayjs/locale/pt';
import 'dayjs/locale/zh-cn';
import { getLanguageFromLocalStorage } from './language-utils';
// Initialize the relativeTime plugin // Initialize plugins
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
dayjs.extend(localizedFormat);
/** // Map application languages to dayjs locales
* Formats a date to a relative time string (e.g., "2 hours ago", "a day ago") const getLocaleFromLanguage = (language: string): string => {
* This mimics the Angular fromNow pipe functionality const localeMap: Record<string, string> = {
* 'en': 'en',
* @param date - The date to format (string, Date, or dayjs object) 'de': 'de',
* @returns A string representing the relative time 'es': 'es',
*/ 'pt': 'pt',
export const fromNow = (date: string | Date | dayjs.Dayjs): string => { 'alb': 'en', // Albanian not supported by dayjs, fallback to English
if (!date) return ''; 'zh': 'zh-cn'
return dayjs(date).fromNow(); };
return localeMap[language] || 'en';
}; };
/** /**
* Formats a date to a specific format * Formats a date to a relative time string (e.g., "2 hours ago", "a day ago")
* This mimics the Angular fromNow pipe functionality with locale support
*
* @param date - The date to format (string, Date, or dayjs object)
* @param language - Optional language override (defaults to stored language)
* @returns A string representing the relative time
*/
export const fromNow = (date: string | Date | dayjs.Dayjs, language?: string): string => {
if (!date) return '';
const currentLanguage = language || getLanguageFromLocalStorage();
const locale = getLocaleFromLanguage(currentLanguage);
return dayjs(date).locale(locale).fromNow();
};
/**
* Formats a date to a specific format with locale support
* *
* @param date - The date to format (string, Date, or dayjs object) * @param date - The date to format (string, Date, or dayjs object)
* @param format - The format string (default: 'YYYY-MM-DD') * @param format - The format string (default: 'YYYY-MM-DD')
* @param language - Optional language override (defaults to stored language)
* @returns A formatted date string * @returns A formatted date string
*/ */
export const formatDate = ( export const formatDate = (
date: string | Date | dayjs.Dayjs, date: string | Date | dayjs.Dayjs,
format: string = 'YYYY-MM-DD' format: string = 'YYYY-MM-DD',
language?: string
): string => { ): string => {
if (!date) return ''; if (!date) return '';
return dayjs(date).format(format); const currentLanguage = language || getLanguageFromLocalStorage();
const locale = getLocaleFromLanguage(currentLanguage);
return dayjs(date).locale(locale).format(format);
}; };