feat(task-logging): enhance time log functionality with subtask handling and UI improvements

- Implemented recursive task hierarchy in SQL query to support subtasks in time logging.
- Updated time log export to include task names for better clarity.
- Added tooltips to inform users when time logging and timer functionalities are disabled due to subtasks.
- Enhanced UI components in the task drawer to reflect new time log features and improve user experience.
- Introduced responsive design adjustments for better accessibility on mobile devices.
This commit is contained in:
chamikaJ
2025-05-30 13:28:47 +05:30
parent 43c6701d3a
commit fef50bdfb1
12 changed files with 490 additions and 124 deletions

View File

@@ -28,32 +28,50 @@ export default class TaskWorklogController extends WorklenzControllerBase {
if (!id) return [];
const q = `
WITH time_logs AS (
--
SELECT id,
description,
time_spent,
created_at,
user_id,
logged_by_timer,
(SELECT name FROM users WHERE users.id = task_work_log.user_id) AS user_name,
(SELECT email FROM users WHERE users.id = task_work_log.user_id) AS user_email,
(SELECT avatar_url FROM users WHERE users.id = task_work_log.user_id) AS avatar_url
FROM task_work_log
WHERE task_id = $1
--
WITH RECURSIVE task_hierarchy AS (
-- Base case: Start with the given task
SELECT id, name, 0 as level
FROM tasks
WHERE id = $1
UNION ALL
-- Recursive case: Get all subtasks
SELECT t.id, t.name, th.level + 1
FROM tasks t
INNER JOIN task_hierarchy th ON t.parent_task_id = th.id
WHERE t.archived IS FALSE
),
time_logs AS (
SELECT
twl.id,
twl.description,
twl.time_spent,
twl.created_at,
twl.user_id,
twl.logged_by_timer,
twl.task_id,
th.name AS task_name,
(SELECT name FROM users WHERE users.id = twl.user_id) AS user_name,
(SELECT email FROM users WHERE users.id = twl.user_id) AS user_email,
(SELECT avatar_url FROM users WHERE users.id = twl.user_id) AS avatar_url
FROM task_work_log twl
INNER JOIN task_hierarchy th ON twl.task_id = th.id
)
SELECT id,
time_spent,
description,
created_at,
user_id,
logged_by_timer,
created_at AS start_time,
(created_at + INTERVAL '1 second' * time_spent) AS end_time,
user_name,
user_email,
avatar_url
SELECT
id,
time_spent,
description,
created_at,
user_id,
logged_by_timer,
task_id,
task_name,
created_at AS start_time,
(created_at + INTERVAL '1 second' * time_spent) AS end_time,
user_name,
user_email,
avatar_url
FROM time_logs
ORDER BY created_at DESC;
`;
@@ -143,6 +161,7 @@ export default class TaskWorklogController extends WorklenzControllerBase {
};
sheet.columns = [
{header: "Task Name", key: "task_name", width: 30},
{header: "Reporter Name", key: "user_name", width: 25},
{header: "Reporter Email", key: "user_email", width: 25},
{header: "Start Time", key: "start_time", width: 25},
@@ -153,14 +172,15 @@ export default class TaskWorklogController extends WorklenzControllerBase {
];
sheet.getCell("A1").value = metadata.project_name;
sheet.mergeCells("A1:G1");
sheet.mergeCells("A1:H1");
sheet.getCell("A1").alignment = {horizontal: "center"};
sheet.getCell("A2").value = `${metadata.name} (${exportDate})`;
sheet.mergeCells("A2:G2");
sheet.mergeCells("A2:H2");
sheet.getCell("A2").alignment = {horizontal: "center"};
sheet.getRow(4).values = [
"Task Name",
"Reporter Name",
"Reporter Email",
"Start Time",
@@ -176,6 +196,7 @@ export default class TaskWorklogController extends WorklenzControllerBase {
for (const item of results) {
totalLogged += parseFloat((item.time_spent || 0).toString());
const data = {
task_name: item.task_name,
user_name: item.user_name,
user_email: item.user_email,
start_time: moment(item.start_time).add(timezone.hours || 0, "hours").add(timezone.minutes || 0, "minutes").format(timeFormat),
@@ -210,6 +231,7 @@ export default class TaskWorklogController extends WorklenzControllerBase {
};
sheet.addRow({
task_name: "",
user_name: "",
user_email: "",
start_time: "Total",
@@ -219,7 +241,7 @@ export default class TaskWorklogController extends WorklenzControllerBase {
time_spent: formatDuration(moment.duration(totalLogged, "seconds")),
});
sheet.mergeCells(`A${sheet.rowCount}:F${sheet.rowCount}`);
sheet.mergeCells(`A${sheet.rowCount}:G${sheet.rowCount}`);
sheet.getCell(`A${sheet.rowCount}`).value = "Total";
sheet.getCell(`A${sheet.rowCount}`).alignment = {

View File

@@ -80,7 +80,21 @@
"addTimeLog": "Add new time log",
"totalLogged": "Total Logged",
"exportToExcel": "Export to Excel",
"noTimeLogsFound": "No time logs found"
"noTimeLogsFound": "No time logs found",
"timerDisabledTooltip": "Timer is disabled because this task has {{count}} subtasks. Time should be logged on individual subtasks.",
"timeLogDisabledTooltip": "Time logging is disabled because this task has {{count}} subtasks. Time should be logged on individual subtasks.",
"date": "Date",
"startTime": "Start Time",
"endTime": "End Time",
"workDescription": "Work Description",
"requiredFields": "Please fill in all required fields",
"dateRequired": "Please select a date",
"startTimeRequired": "Please select start time",
"endTimeRequired": "Please select end time",
"workDescriptionPlaceholder": "Add a description",
"cancel": "Cancel",
"logTime": "Log time",
"updateTime": "Update time"
},
"taskActivityLogTab": {
"title": "Activity Log"

View File

@@ -80,7 +80,21 @@
"addTimeLog": "Añadir nuevo registro de tiempo",
"totalLogged": "Total registrado",
"exportToExcel": "Exportar a Excel",
"noTimeLogsFound": "No se encontraron registros de tiempo"
"noTimeLogsFound": "No se encontraron registros de tiempo",
"timerDisabledTooltip": "El temporizador está deshabilitado porque esta tarea tiene {{count}} subtareas. El tiempo debe registrarse en las subtareas individuales.",
"timeLogDisabledTooltip": "El registro de tiempo está deshabilitado porque esta tarea tiene {{count}} subtareas. El tiempo debe registrarse en las subtareas individuales.",
"date": "Fecha",
"startTime": "Hora de inicio",
"endTime": "Hora de finalización",
"workDescription": "Descripción del trabajo",
"requiredFields": "Por favor, complete todos los campos requeridos",
"dateRequired": "Por favor, seleccione una fecha",
"startTimeRequired": "Por favor, seleccione la hora de inicio",
"endTimeRequired": "Por favor, seleccione la hora de finalización",
"workDescriptionPlaceholder": "Añadir una descripción",
"cancel": "Cancelar",
"logTime": "Registrar tiempo",
"updateTime": "Actualizar tiempo"
},
"taskActivityLogTab": {
"title": "Registro de actividad"

View File

@@ -80,7 +80,21 @@
"addTimeLog": "Adicionar novo registro de tempo",
"totalLogged": "Total registrado",
"exportToExcel": "Exportar para Excel",
"noTimeLogsFound": "Nenhum registro de tempo encontrado"
"noTimeLogsFound": "Nenhum registro de tempo encontrado",
"timerDisabledTooltip": "O cronômetro está desabilitado porque esta tarefa tem {{count}} subtarefas. O tempo deve ser registrado nas subtarefas individuais.",
"timeLogDisabledTooltip": "O registro de tempo está desabilitado porque esta tarefa tem {{count}} subtarefas. O tempo deve ser registrado nas subtarefas individuais.",
"date": "Data",
"startTime": "Hora de início",
"endTime": "Hora de término",
"workDescription": "Descrição do trabalho",
"requiredFields": "Por favor, preencha todos os campos obrigatórios",
"dateRequired": "Por favor, selecione uma data",
"startTimeRequired": "Por favor, selecione a hora de início",
"endTimeRequired": "Por favor, selecione a hora de término",
"workDescriptionPlaceholder": "Adicionar uma descrição",
"cancel": "Cancelar",
"logTime": "Registrar tempo",
"updateTime": "Atualizar tempo"
},
"taskActivityLogTab": {
"title": "Registro de atividade"

View File

@@ -25,8 +25,6 @@ const TaskDrawerTimeLog = ({ t, refreshTrigger = 0 }: TaskDrawerTimeLogProps) =>
const [totalTimeText, setTotalTimeText] = useState<string>('0m 0s');
const [loading, setLoading] = useState<boolean>(false);
const dispatch = useAppDispatch();
const themeMode = useAppSelector(state => state.themeReducer.mode);
const { selectedTaskId, taskFormViewModel, timeLogEditing } = useAppSelector(
state => state.taskDrawerReducer
);
@@ -36,6 +34,15 @@ const TaskDrawerTimeLog = ({ t, refreshTrigger = 0 }: TaskDrawerTimeLogProps) =>
taskFormViewModel?.task?.timer_start_time || null
);
// Check if task has subtasks
const hasSubTasks = (taskFormViewModel?.task?.sub_tasks_count || 0) > 0;
const timerDisabledTooltip = hasSubTasks
? t('taskTimeLogTab.timerDisabledTooltip', {
count: taskFormViewModel?.task?.sub_tasks_count || 0,
defaultValue: `Timer is disabled because this task has ${taskFormViewModel?.task?.sub_tasks_count || 0} subtasks. Time should be logged on individual subtasks.`
})
: '';
const formatTimeComponents = (hours: number, minutes: number, seconds: number): string => {
const parts = [];
if (hours > 0) parts.push(`${hours}h`);
@@ -131,6 +138,8 @@ const TaskDrawerTimeLog = ({ t, refreshTrigger = 0 }: TaskDrawerTimeLogProps) =>
handleStartTimer={handleStartTimer}
handleStopTimer={handleTimerStop}
timeString={timeString}
disabled={hasSubTasks}
disabledTooltip={timerDisabledTooltip}
/>
<Button size="small" icon={<DownloadOutlined />} onClick={handleExportToExcel}>
{t('taskTimeLogTab.exportToExcel')}

View File

@@ -1,7 +1,8 @@
import React from 'react';
import { Button, DatePicker, Form, Input, TimePicker, Flex } from 'antd';
import { Button, DatePicker, Form, Input, TimePicker, Flex, Tooltip } from 'antd';
import { ClockCircleOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { useTranslation } from 'react-i18next';
import { useAppSelector } from '@/hooks/useAppSelector';
import { themeWiseColor } from '@/utils/themeWiseColor';
@@ -25,6 +26,7 @@ const TimeLogForm = ({
initialValues,
mode = 'create'
}: TimeLogFormProps) => {
const { t } = useTranslation('task-drawer/task-drawer');
const currentSession = useAuthService().getCurrentSession();
const { socket, connected } = useSocket();
const [form] = Form.useForm();
@@ -41,6 +43,9 @@ const TimeLogForm = ({
const themeMode = useAppSelector(state => state.themeReducer.mode);
const { taskFormViewModel } = useAppSelector(state => state.taskDrawerReducer);
// Check if task has subtasks
const hasSubTasks = (taskFormViewModel?.task?.sub_tasks_count || 0) > 0;
React.useEffect(() => {
if (initialValues && mode === 'edit') {
const createdAt = dayjs(initialValues.created_at);
@@ -164,7 +169,6 @@ const TimeLogForm = ({
console.log('Creating new time log:', requestBody);
await taskTimeLogsApiService.create(requestBody);
}
console.log('Received values:', values);
// Call onSubmitSuccess if provided, otherwise just cancel
if (onSubmitSuccess) {
@@ -181,6 +185,23 @@ const TimeLogForm = ({
return formValues.date && formValues.startTime && formValues.endTime;
};
const isSubmitDisabled = () => {
return !isFormValid() || hasSubTasks;
};
const getSubmitTooltip = () => {
if (hasSubTasks) {
return t('taskTimeLogTab.timeLogDisabledTooltip', {
count: taskFormViewModel?.task?.sub_tasks_count || 0,
defaultValue: `Time logging is disabled because this task has ${taskFormViewModel?.task?.sub_tasks_count || 0} subtasks. Time should be logged on individual subtasks.`
});
}
if (!isFormValid()) {
return t('taskTimeLogTab.requiredFields');
}
return '';
};
return (
<Flex
gap={8}
@@ -219,45 +240,51 @@ const TimeLogForm = ({
<Flex gap={8} wrap="wrap" style={{ width: '100%' }}>
<Form.Item
name="date"
label="Date"
rules={[{ required: true, message: 'Please select a date' }]}
label={t('taskTimeLogTab.date')}
rules={[{ required: true, message: t('taskTimeLogTab.dateRequired') }]}
>
<DatePicker disabledDate={current => current && current.toDate() > new Date()} />
</Form.Item>
<Form.Item
name="startTime"
label="Start Time"
rules={[{ required: true, message: 'Please select start time' }]}
label={t('taskTimeLogTab.startTime')}
rules={[{ required: true, message: t('taskTimeLogTab.startTimeRequired') }]}
>
<TimePicker format="HH:mm" />
</Form.Item>
<Form.Item
name="endTime"
label="End Time"
rules={[{ required: true, message: 'Please select end time' }]}
label={t('taskTimeLogTab.endTime')}
rules={[{ required: true, message: t('taskTimeLogTab.endTimeRequired') }]}
>
<TimePicker format="HH:mm" />
</Form.Item>
</Flex>
</Form.Item>
<Form.Item name="description" label="Work Description" style={{ marginBlockEnd: 12 }}>
<Input.TextArea placeholder="Add a description" />
<Form.Item name="description" label={t('taskTimeLogTab.workDescription')} style={{ marginBlockEnd: 12 }}>
<Input.TextArea placeholder={t('taskTimeLogTab.workDescriptionPlaceholder')} />
</Form.Item>
<Form.Item style={{ marginBlockEnd: 0 }}>
<Flex gap={8}>
<Button onClick={onCancel}>Cancel</Button>
<Button
type="primary"
icon={<ClockCircleOutlined />}
disabled={!isFormValid()}
htmlType="submit"
>
{mode === 'edit' ? 'Update time' : 'Log time'}
</Button>
<Button onClick={onCancel}>{t('taskTimeLogTab.cancel')}</Button>
<Tooltip title={getSubmitTooltip()} trigger={isSubmitDisabled() ? 'hover' : []}>
<Button
type="primary"
icon={<ClockCircleOutlined />}
disabled={isSubmitDisabled()}
htmlType="submit"
style={{
opacity: hasSubTasks ? 0.5 : 1,
cursor: hasSubTasks ? 'not-allowed' : 'pointer'
}}
>
{mode === 'edit' ? t('taskTimeLogTab.updateTime') : t('taskTimeLogTab.logTime')}
</Button>
</Tooltip>
</Flex>
</Form.Item>
</Form>

View File

@@ -0,0 +1,35 @@
.time-log-item .ant-card {
transition: all 0.2s ease;
}
.time-log-item .ant-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
border-color: #d9d9d9;
}
/* Dark mode hover effects */
[data-theme='dark'] .time-log-item .ant-card:hover {
box-shadow: 0 2px 8px rgba(255, 255, 255, 0.15);
border-color: #434343;
}
.time-log-item .ant-card .ant-card-body {
padding: 12px 16px;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.time-log-item .ant-card {
margin-bottom: 6px;
}
.time-log-item .ant-divider-vertical {
display: none;
}
/* Stack time info vertically on mobile */
.time-log-item .time-tracking-info {
flex-direction: column;
gap: 8px;
}
}

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { Button, Divider, Flex, Popconfirm, Typography, Space } from 'antd';
import { Button, Divider, Flex, Popconfirm, Typography, Space, Tag, Card } from 'antd';
import { ClockCircleOutlined } from '@ant-design/icons';
import { colors } from '@/styles/colors';
import { ITaskLogViewModel } from '@/types/tasks/task-log-view.types';
import SingleAvatar from '@/components/common/single-avatar/single-avatar';
@@ -19,20 +20,18 @@ type TimeLogItemProps = {
};
const TimeLogItem = ({ log, onDelete }: TimeLogItemProps) => {
const { user_name, avatar_url, time_spent_text, logged_by_timer, created_at, user_id, description } = log;
const { selectedTaskId } = useAppSelector(state => state.taskDrawerReducer);
const { user_name, avatar_url, time_spent_text, logged_by_timer, created_at, user_id, description, task_name, task_id, start_time, end_time } = log;
const { selectedTaskId, taskFormViewModel } = useAppSelector(state => state.taskDrawerReducer);
const themeMode = useAppSelector(state => state.themeReducer.mode);
const dispatch = useAppDispatch();
const currentSession = useAuthService().getCurrentSession();
const renderLoggedByTimer = () => {
if (!logged_by_timer) return null;
return (
<>
via Timer about{' '}
<Typography.Text strong style={{ fontSize: 15 }}>
{logged_by_timer}
</Typography.Text>
</>
<Tag icon={<ClockCircleOutlined />} color="green" style={{ fontSize: '11px', margin: 0 }}>
Timer
</Tag>
);
};
@@ -60,14 +59,14 @@ const TimeLogItem = ({ log, onDelete }: TimeLogItemProps) => {
return (
<Space size={8}>
<Button type="link" onClick={handleEdit} style={{ padding: '0', height: 'auto', fontSize: '14px' }}>
<Button type="link" onClick={handleEdit} style={{ padding: '0', height: 'auto', fontSize: '12px' }}>
Edit
</Button>
<Popconfirm
title="Are you sure you want to delete this time log?"
onConfirm={() => handleDeleteTimeLog(log.id)}
>
<Button type="link" style={{ padding: '0', height: 'auto', fontSize: '14px' }}>
<Button type="link" style={{ padding: '0', height: 'auto', fontSize: '12px', color: '#ff4d4f' }}>
Delete
</Button>
</Popconfirm>
@@ -75,33 +74,136 @@ const TimeLogItem = ({ log, onDelete }: TimeLogItemProps) => {
);
};
// Check if this time log is from a subtask
const isFromSubtask = task_id && task_id !== selectedTaskId;
const formatTime = (timeString: string | undefined) => {
if (!timeString) return '';
try {
return new Date(timeString).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
hour12: true
});
} catch {
return timeString;
}
};
const formatDate = (timeString: string | undefined) => {
if (!timeString) return '';
try {
return new Date(timeString).toLocaleDateString([], {
month: 'short',
day: 'numeric',
year: 'numeric'
});
} catch {
return timeString;
}
};
const isDarkMode = themeMode === 'dark';
return (
<div className="time-log-item">
<Flex vertical gap={8}>
<Flex align="start" gap={12}>
<SingleAvatar avatarUrl={avatar_url} name={user_name} />
<Flex vertical style={{ flex: 1 }}>
<Flex justify="space-between" align="start">
<Flex vertical>
<Typography.Text>
<Typography.Text strong>{user_name}</Typography.Text> logged <Typography.Text strong>{time_spent_text}</Typography.Text> {renderLoggedByTimer()} {calculateTimeGap(created_at || '')}
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{formatDateTimeWithLocale(created_at || '')}
<Card
size="small"
style={{
marginBottom: 8,
borderRadius: 8,
boxShadow: isDarkMode ? '0 1px 3px rgba(255,255,255,0.1)' : '0 1px 3px rgba(0,0,0,0.1)',
border: isDarkMode ? '1px solid #303030' : '1px solid #f0f0f0',
backgroundColor: isDarkMode ? '#1f1f1f' : '#ffffff'
}}
bodyStyle={{ padding: '12px 16px' }}
>
<Flex vertical gap={12}>
{/* Header with user info and task name */}
<Flex align="center" justify="space-between">
<Flex align="center" gap={12}>
<SingleAvatar avatarUrl={avatar_url} name={user_name} />
<Flex vertical gap={2}>
<Flex align="center" gap={8} wrap>
<Typography.Text strong style={{ fontSize: '14px' }}>
{user_name}
</Typography.Text>
{task_name && (
<Tag color={isFromSubtask ? "blue" : "default"} style={{ fontSize: '11px', margin: 0 }}>
{task_name}
</Tag>
)}
{renderLoggedByTimer()}
</Flex>
<Typography.Text type="secondary" style={{ fontSize: '12px' }}>
{calculateTimeGap(created_at || '')}
</Typography.Text>
</Flex>
{renderActionButtons()}
</Flex>
{renderActionButtons()}
</Flex>
{/* Time tracking details */}
<Flex align="center" justify="space-between" style={{
backgroundColor: isDarkMode ? '#262626' : '#fafafa',
padding: '8px 12px',
borderRadius: 6,
border: isDarkMode ? '1px solid #303030' : '1px solid #f0f0f0'
}}>
<Flex align="center" gap={16}>
<Flex vertical gap={2}>
<Typography.Text type="secondary" style={{ fontSize: '11px', lineHeight: 1 }}>
Start Time
</Typography.Text>
<Typography.Text strong style={{ fontSize: '12px', lineHeight: 1 }}>
{formatTime(start_time)}
</Typography.Text>
</Flex>
<Divider type="vertical" style={{ height: '24px', margin: 0 }} />
<Flex vertical gap={2}>
<Typography.Text type="secondary" style={{ fontSize: '11px', lineHeight: 1 }}>
End Time
</Typography.Text>
<Typography.Text strong style={{ fontSize: '12px', lineHeight: 1 }}>
{formatTime(end_time)}
</Typography.Text>
</Flex>
<Divider type="vertical" style={{ height: '24px', margin: 0 }} />
<Flex align="center" gap={6}>
<ClockCircleOutlined style={{ color: '#1890ff', fontSize: '14px' }} />
<Flex vertical gap={2}>
<Typography.Text type="secondary" style={{ fontSize: '11px', lineHeight: 1 }}>
Duration
</Typography.Text>
<Typography.Text strong style={{ fontSize: '12px', lineHeight: 1, color: '#1890ff' }}>
{time_spent_text}
</Typography.Text>
</Flex>
</Flex>
</Flex>
{description && (
<Typography.Text style={{ marginTop: 8, display: 'block' }}>
<Typography.Text type="secondary" style={{ fontSize: '11px' }}>
{formatDate(created_at)}
</Typography.Text>
</Flex>
{/* Description */}
{description && (
<Flex vertical gap={4}>
<Typography.Text type="secondary" style={{ fontSize: '11px', fontWeight: 500 }}>
Description:
</Typography.Text>
<Typography.Text style={{ fontSize: '13px', lineHeight: 1.4 }}>
{description}
</Typography.Text>
)}
</Flex>
</Flex>
)}
</Flex>
<Divider style={{ margin: '8px 0' }} />
</Flex>
</Card>
</div>
);
};

View File

@@ -1,4 +1,4 @@
import { TabsProps, Tabs, Button } from 'antd';
import { TabsProps, Tabs, Button, Tooltip } from 'antd';
import Drawer from 'antd/es/drawer';
import { InputRef } from 'antd/es/input';
import { useTranslation } from 'react-i18next';
@@ -146,16 +146,40 @@ const TaskDrawer = () => {
/>
);
} else {
// Check if task has subtasks
const hasSubTasks = (taskFormViewModel?.task?.sub_tasks_count || 0) > 0;
const addTimeLogTooltip = hasSubTasks
? t('taskTimeLogTab.timeLogDisabledTooltip', {
count: taskFormViewModel?.task?.sub_tasks_count || 0,
defaultValue: `Time logging is disabled because this task has ${taskFormViewModel?.task?.sub_tasks_count || 0} subtasks. Time should be logged on individual subtasks.`
})
: '';
const addButton = (
<Button
type="primary"
icon={<PlusOutlined />}
onClick={handleAddTimeLog}
style={{
width: '100%',
opacity: hasSubTasks ? 0.5 : 1,
cursor: hasSubTasks ? 'not-allowed' : 'pointer'
}}
disabled={hasSubTasks}
>
Add new time log
</Button>
);
return (
<Flex justify="center" style={{ width: '100%', padding: '16px 0 0' }}>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={handleAddTimeLog}
style={{ width: '100%' }}
>
Add new time log
</Button>
{hasSubTasks ? (
<Tooltip title={addTimeLogTooltip}>
{addButton}
</Tooltip>
) : (
addButton
)}
</Flex>
);
}

View File

@@ -7,7 +7,7 @@ import logger from '@/utils/errorLogger';
import { formatDateTimeWithLocale } from '@/utils/format-date-time-with-locale';
import { formatDate } from '@/utils/timeUtils';
import { PlayCircleFilled } from '@ant-design/icons';
import { Flex, Button, Popover, Typography, Divider, Skeleton } from 'antd/es';
import { Flex, Button, Popover, Typography, Divider, Skeleton, Tooltip, Tag } from 'antd/es';
import React from 'react';
import { useState } from 'react';
@@ -17,6 +17,8 @@ interface TaskTimerProps {
handleStopTimer: () => void;
timeString: string;
taskId: string;
disabled?: boolean;
disabledTooltip?: string;
}
const TaskTimer = ({
@@ -25,6 +27,8 @@ const TaskTimer = ({
handleStopTimer,
timeString,
taskId,
disabled = false,
disabledTooltip,
}: TaskTimerProps) => {
const [timeLogs, setTimeLogs] = useState<ITaskLogViewModel[]>([]);
const [loading, setLoading] = useState(false);
@@ -69,32 +73,90 @@ const TaskTimer = ({
};
const timeTrackingLogCard = (
<Flex vertical style={{ width: '100%', maxWidth: 400, maxHeight: 350, overflowY: 'scroll' }}>
<Flex vertical style={{ width: '100%', maxWidth: 450, maxHeight: 350, overflowY: 'scroll' }}>
<Skeleton active loading={loading}>
{timeLogs.map(log => (
<React.Fragment key={log.id}>
<Flex gap={12} align="center" wrap="wrap">
<SingleAvatar avatarUrl={log.avatar_url} name={log.user_name} />
<Flex vertical style={{ flex: 1, minWidth: 0 }}>
<Typography style={{ fontSize: 15, wordBreak: 'break-word' }}>
<Typography.Text strong style={{ fontSize: 15 }}>
{log.user_name}&nbsp;
</Typography.Text>
logged&nbsp;
<Typography.Text strong style={{ fontSize: 15 }}>
{formatTimeSpent(log.time_spent || 0)}
</Typography.Text>{' '}
{renderLoggedByTimer(log)}
{calculateTimeGap(log.created_at || '')}
</Typography>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{formatDateTimeWithLocale(log.created_at || '')}
</Typography.Text>
{timeLogs.map(log => {
// Check if this time log is from a subtask
const isFromSubtask = log.task_id && log.task_id !== taskId;
const formatTime = (timeString: string | undefined) => {
if (!timeString) return '';
try {
return new Date(timeString).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
hour12: true
});
} catch {
return timeString;
}
};
return (
<React.Fragment key={log.id}>
<Flex vertical gap={8} style={{ padding: '8px 0' }}>
<Flex gap={12} align="center">
<SingleAvatar avatarUrl={log.avatar_url} name={log.user_name} />
<Flex vertical style={{ flex: 1, minWidth: 0 }}>
<Flex align="center" gap={8} wrap>
<Typography.Text strong style={{ fontSize: 14 }}>
{log.user_name}
</Typography.Text>
{log.task_name && (
<Tag color={isFromSubtask ? "blue" : "default"} style={{ fontSize: '10px', margin: 0, padding: '0 4px' }}>
{log.task_name}
</Tag>
)}
{log.logged_by_timer && (
<Tag color="green" style={{ fontSize: '10px', margin: 0 }}>
Timer
</Tag>
)}
</Flex>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{calculateTimeGap(log.created_at || '')}
</Typography.Text>
</Flex>
</Flex>
<Flex align="center" gap={12} style={{
backgroundColor: '#fafafa',
padding: '6px 8px',
borderRadius: 4,
fontSize: '11px'
}}>
<Flex vertical gap={2}>
<Typography.Text type="secondary" style={{ fontSize: '10px' }}>
Start
</Typography.Text>
<Typography.Text strong style={{ fontSize: '11px' }}>
{formatTime(log.start_time)}
</Typography.Text>
</Flex>
<Typography.Text type="secondary" style={{ fontSize: '10px' }}></Typography.Text>
<Flex vertical gap={2}>
<Typography.Text type="secondary" style={{ fontSize: '10px' }}>
End
</Typography.Text>
<Typography.Text strong style={{ fontSize: '11px' }}>
{formatTime(log.end_time)}
</Typography.Text>
</Flex>
<Divider type="vertical" style={{ height: '16px', margin: 0 }} />
<Flex vertical gap={2}>
<Typography.Text type="secondary" style={{ fontSize: '10px' }}>
Duration
</Typography.Text>
<Typography.Text strong style={{ color: '#1890ff', fontSize: '11px' }}>
{formatTimeSpent(log.time_spent || 0)}
</Typography.Text>
</Flex>
</Flex>
</Flex>
</Flex>
<Divider style={{ marginBlock: 12 }} />
</React.Fragment>
))}
<Divider style={{ marginBlock: 8 }} />
</React.Fragment>
);
})}
</Skeleton>
</Flex>
);
@@ -121,17 +183,45 @@ const TaskTimer = ({
}
};
const renderTimerButton = () => {
const button = started ? (
<Button
type="text"
icon={renderStopIcon()}
onClick={handleStopTimer}
disabled={disabled}
style={{
opacity: disabled ? 0.5 : 1,
cursor: disabled ? 'not-allowed' : 'pointer'
}}
/>
) : (
<Button
type="text"
icon={<PlayCircleFilled style={{ color: disabled ? colors.lightGray : colors.skyBlue, fontSize: 16 }} />}
onClick={handleStartTimer}
disabled={disabled}
style={{
opacity: disabled ? 0.5 : 1,
cursor: disabled ? 'not-allowed' : 'pointer'
}}
/>
);
if (disabled && disabledTooltip) {
return (
<Tooltip title={disabledTooltip}>
{button}
</Tooltip>
);
}
return button;
};
return (
<Flex gap={4} align="center">
{started ? (
<Button type="text" icon={renderStopIcon()} onClick={handleStopTimer} />
) : (
<Button
type="text"
icon={<PlayCircleFilled style={{ color: colors.skyBlue, fontSize: 16 }} />}
onClick={handleStartTimer}
/>
)}
{renderTimerButton()}
<Popover
title={
<Typography.Text style={{ fontWeight: 500 }}>

View File

@@ -1,17 +1,28 @@
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import TaskTimer from '@/components/taskListCommon/task-timer/task-timer';
import { useTaskTimer } from '@/hooks/useTaskTimer';
import { useTranslation } from 'react-i18next';
type TaskListTimeTrackerCellProps = {
task: IProjectTask;
};
const TaskListTimeTrackerCell = ({ task }: TaskListTimeTrackerCellProps) => {
const { t } = useTranslation('task-drawer/task-drawer');
const { started, timeString, handleStartTimer, handleStopTimer } = useTaskTimer(
task.id || '',
task.timer_start_time || null
);
// Check if task has subtasks
const hasSubTasks = (task.sub_tasks_count || 0) > 0;
const timerDisabledTooltip = hasSubTasks
? t('taskTimeLogTab.timerDisabledTooltip', {
count: task.sub_tasks_count || 0,
defaultValue: `Timer is disabled because this task has ${task.sub_tasks_count || 0} subtasks. Time should be logged on individual subtasks.`
})
: '';
return (
<TaskTimer
taskId={task.id || ''}
@@ -19,6 +30,8 @@ const TaskListTimeTrackerCell = ({ task }: TaskListTimeTrackerCellProps) => {
handleStartTimer={handleStartTimer}
handleStopTimer={handleStopTimer}
timeString={timeString}
disabled={hasSubTasks}
disabledTooltip={timerDisabledTooltip}
/>
);
};

View File

@@ -16,4 +16,6 @@ export interface ITaskLogViewModel {
time_spent?: number;
avatar_color?: string;
user_id?: string;
task_id?: string;
task_name?: string;
}