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:
@@ -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 = {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
</Typography.Text>
|
||||
logged
|
||||
<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 }}>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,4 +16,6 @@ export interface ITaskLogViewModel {
|
||||
time_spent?: number;
|
||||
avatar_color?: string;
|
||||
user_id?: string;
|
||||
task_id?: string;
|
||||
task_name?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user