From 443d97f898b0ba7e584d90d184ea887284c345b6 Mon Sep 17 00:00:00 2001 From: hamid zarghami Date: Wed, 15 Jul 2026 10:24:05 +0330 Subject: [PATCH] track time --- .env | 2 +- src/langs/fa.json | 8 +- .../task-detail/TaskDetailActionBar.tsx | 12 ++- .../task-detail/TaskDetailModal.tsx | 5 + .../task-detail/TaskDetailToolbar.tsx | 3 + .../components/task-detail/time/List.tsx | 31 ++++++ .../time/TaskDetailTimePopover.tsx | 97 +++++++++++++++++++ .../task-detail/time/TimeListItem.tsx | 52 ++++++++++ .../components/task-detail/time/utils.ts | 33 +++++++ .../components/task-detail/types.ts | 2 +- .../taskmanager/task/hooks/useTaskTimeData.ts | 34 +++++++ .../task/service/TaskTimeService.ts | 17 ++++ .../taskmanager/task/types/TaskTimeTypes.ts | 24 +++++ src/pages/taskmanager/workspace.tsx | 75 +++++--------- 14 files changed, 339 insertions(+), 56 deletions(-) create mode 100644 src/pages/taskmanager/components/task-detail/time/List.tsx create mode 100644 src/pages/taskmanager/components/task-detail/time/TaskDetailTimePopover.tsx create mode 100644 src/pages/taskmanager/components/task-detail/time/TimeListItem.tsx create mode 100644 src/pages/taskmanager/components/task-detail/time/utils.ts create mode 100644 src/pages/taskmanager/task/hooks/useTaskTimeData.ts create mode 100644 src/pages/taskmanager/task/service/TaskTimeService.ts create mode 100644 src/pages/taskmanager/task/types/TaskTimeTypes.ts diff --git a/.env b/.env index 394f193..7c2fdf8 100644 --- a/.env +++ b/.env @@ -2,4 +2,4 @@ VITE_TOKEN_NAME = 'admin_token' VITE_REFRESH_TOKEN_NAME = 'admin_refresh_token' VITE_BASE_URL = 'https://api.danakcorp.com' -VITE_BASE_URL = 'http://192.168.99.131:3500' \ No newline at end of file +#VITE_BASE_URL = 'http://192.168.99.131:3500' \ No newline at end of file diff --git a/src/langs/fa.json b/src/langs/fa.json index aba2c6b..27d152c 100644 --- a/src/langs/fa.json +++ b/src/langs/fa.json @@ -1034,7 +1034,13 @@ "or": "یا", "insert": "درج", "start_date": "تاریخ شروع", - "end_date": "تاریخ پایان" + "end_date": "تاریخ پایان", + "time_tracking": "ثبت زمان", + "start_tracking": "شروع", + "stop_tracking": "پایان", + "time_history": "سابقه", + "no_time_entries": "هنوز زمانی ثبت نشده", + "tracking_active": "در حال اجرا" } } } diff --git a/src/pages/taskmanager/components/task-detail/TaskDetailActionBar.tsx b/src/pages/taskmanager/components/task-detail/TaskDetailActionBar.tsx index 7859a8e..6f3d96f 100644 --- a/src/pages/taskmanager/components/task-detail/TaskDetailActionBar.tsx +++ b/src/pages/taskmanager/components/task-detail/TaskDetailActionBar.tsx @@ -1,4 +1,4 @@ -import { Calendar, Paperclip2, Profile2User, Tag, TickSquare, type Icon } from "iconsax-react"; +import { Calendar, Paperclip2, Profile2User, Tag, TickSquare, Timer1, type Icon } from "iconsax-react"; import { type FC, type ReactNode, useRef } from "react"; import { useTranslation } from "react-i18next"; import TaskDetailPopoverPanel from "./TaskDetailPopoverPanel"; @@ -16,6 +16,7 @@ const TABS: TabItem[] = [ { id: "checklist", icon: TickSquare, labelKey: "taskmanager.task_detail.checklist" }, { id: "attachment", icon: Paperclip2, labelKey: "taskmanager.task_detail.attachment" }, { id: "date", icon: Calendar, labelKey: "taskmanager.task_detail.date_settings" }, + { id: "time", icon: Timer1, labelKey: "taskmanager.task_detail.time_tracking" }, ]; type Props = { @@ -26,13 +27,14 @@ type Props = { checklistPopover?: ReactNode; attachmentPopover?: ReactNode; datePopover?: ReactNode; + timePopover?: ReactNode; }; -const POPOVER_TABS = new Set(["labels", "users", "checklist", "attachment", "date"]); +const POPOVER_TABS = new Set(["labels", "users", "checklist", "attachment", "date", "time"]); -type PopoverTab = "labels" | "users" | "checklist" | "attachment" | "date"; +type PopoverTab = "labels" | "users" | "checklist" | "attachment" | "date" | "time"; -const TaskDetailActionBar: FC = ({ activeTab, onTabChange, labelsPopover, usersPopover, checklistPopover, attachmentPopover, datePopover }) => { +const TaskDetailActionBar: FC = ({ activeTab, onTabChange, labelsPopover, usersPopover, checklistPopover, attachmentPopover, datePopover, timePopover }) => { const { t } = useTranslation("global"); const anchorRefs = useRef>({ labels: { current: null }, @@ -40,6 +42,7 @@ const TaskDetailActionBar: FC = ({ activeTab, onTabChange, labelsPopover, checklist: { current: null }, attachment: { current: null }, date: { current: null }, + time: { current: null }, }); const popoverByTab: Record = { @@ -48,6 +51,7 @@ const TaskDetailActionBar: FC = ({ activeTab, onTabChange, labelsPopover, checklist: checklistPopover, attachment: attachmentPopover, date: datePopover, + time: timePopover, }; return ( diff --git a/src/pages/taskmanager/components/task-detail/TaskDetailModal.tsx b/src/pages/taskmanager/components/task-detail/TaskDetailModal.tsx index 6bc745b..40058d3 100644 --- a/src/pages/taskmanager/components/task-detail/TaskDetailModal.tsx +++ b/src/pages/taskmanager/components/task-detail/TaskDetailModal.tsx @@ -5,10 +5,12 @@ import DefaulModal from "../../../../components/DefaulModal"; import PageLoading from "../../../../components/PageLoading"; import { ErrorType } from "../../../../helpers/types"; import { useChangeTaskPhase, useDeleteTask, useGetTaskDetail, useUpdateTask } from "../../task/hooks/useTaskData"; +import { useGetTaskTimes } from "../../task/hooks/useTaskTimeData"; import type { TaskDetailType } from "../../task/types/TaskTypes"; import type { Column, Task } from "../../types"; import AttachmentList from "./attachment/List"; import CheckLists from "./checklist/List"; +import TimeList from "./time/List"; import TaskDetailCoverColorBar from "./cover/TaskDetailCoverColorBar"; import TaskDetailDescription from "./TaskDetailDescription"; import TaskDetailHeader from "./TaskDetailHeader"; @@ -34,6 +36,7 @@ const TaskDetailModal: FC = ({ open, task, columns, projectId, onClose, o const initialDescriptionRef = useRef(""); const getTaskDetail = useGetTaskDetail(task?.id ?? "", open && !!task?.id); + const getTaskTimes = useGetTaskTimes(task?.id ?? "", open && !!task?.id); const updateTask = useUpdateTask(); const changeTaskPhase = useChangeTaskPhase(); const deleteTask = useDeleteTask(); @@ -212,6 +215,8 @@ const TaskDetailModal: FC = ({ open, task, columns, projectId, onClose, o checkListItemCount={taskDetail?.checkListItemCount} completedCheckListItemCount={taskDetail?.completedCheckListItemCount} /> + + )} diff --git a/src/pages/taskmanager/components/task-detail/TaskDetailToolbar.tsx b/src/pages/taskmanager/components/task-detail/TaskDetailToolbar.tsx index 2669f98..7efb61f 100644 --- a/src/pages/taskmanager/components/task-detail/TaskDetailToolbar.tsx +++ b/src/pages/taskmanager/components/task-detail/TaskDetailToolbar.tsx @@ -13,6 +13,7 @@ import TaskDetailActionBar from "./TaskDetailActionBar"; import TaskDetailMetadataPreview from "./TaskDetailMetadataPreview"; import type { TaskDetailTab } from "./types"; import TaskDetailUsersPopover from "./users/TaskDetailUsersPopover"; +import TaskDetailTimePopover from "./time/TaskDetailTimePopover"; import type { TaskUser } from "./users/types"; import type { UserItemType } from "../../../users/types/UserTypes"; import type { TaskDetailType } from "../../task/types/TaskTypes"; @@ -93,6 +94,7 @@ const TaskDetailToolbar: FC = ({ activeTab, onTabChange, taskDetail, task const isChecklistOpen = activeTab === "checklist"; const isAttachmentOpen = activeTab === "attachment"; const isDateOpen = activeTab === "date"; + const isTimeOpen = activeTab === "time"; const handleTabChange = (tab: TaskDetailTab) => { if (tab === "labels" && activeTab !== "labels") { @@ -340,6 +342,7 @@ const TaskDetailToolbar: FC = ({ activeTab, onTabChange, taskDetail, task /> ) : null } + timePopover={isTimeOpen ? : null} /> = ({ times = [] }) => { + const { t } = useTranslation("global"); + const sortedTimes = useMemo(() => sortTaskTimes(times), [times]); + const activeEntry = useMemo(() => getActiveTimeEntry(times), [times]); + + if (times.length === 0) return null; + + return ( +
+
{t("taskmanager.task_detail.time_tracking")}
+ +
+ {sortedTimes.map((entry) => ( + + ))} +
+
+ ); +}; + +export default TimeList; diff --git a/src/pages/taskmanager/components/task-detail/time/TaskDetailTimePopover.tsx b/src/pages/taskmanager/components/task-detail/time/TaskDetailTimePopover.tsx new file mode 100644 index 0000000..fc80a5f --- /dev/null +++ b/src/pages/taskmanager/components/task-detail/time/TaskDetailTimePopover.tsx @@ -0,0 +1,97 @@ +import { type FC, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "react-toastify"; +import { ErrorType } from "../../../../../helpers/types"; +import { useEndTimeTracking, useGetTaskTimes, useStartTimeTracking } from "../../../task/hooks/useTaskTimeData"; +import { formatElapsedTime, getActiveTimeEntry, getElapsedSeconds } from "./utils"; + +type Props = { + taskId: string; +}; + +const TaskDetailTimePopover: FC = ({ taskId }) => { + const { t } = useTranslation("global"); + const getTaskTimes = useGetTaskTimes(taskId); + const startTimeTracking = useStartTimeTracking(); + const endTimeTracking = useEndTimeTracking(); + + const times = useMemo(() => getTaskTimes.data?.data ?? [], [getTaskTimes.data]); + const activeEntry = useMemo(() => getActiveTimeEntry(times), [times]); + const isTracking = !!activeEntry; + const isSubmitting = startTimeTracking.isPending || endTimeTracking.isPending; + + const [elapsedSeconds, setElapsedSeconds] = useState(() => + activeEntry ? getElapsedSeconds(activeEntry.startedAt) : 0, + ); + + useEffect(() => { + if (!activeEntry) { + setElapsedSeconds(0); + return; + } + + const updateElapsed = () => { + setElapsedSeconds(getElapsedSeconds(activeEntry.startedAt)); + }; + + updateElapsed(); + const intervalId = window.setInterval(updateElapsed, 1000); + + return () => { + window.clearInterval(intervalId); + }; + }, [activeEntry?.id, activeEntry?.startedAt]); + + const handleStart = () => { + startTimeTracking.mutate(taskId, { + onSuccess: () => { + toast.success(t("success")); + }, + onError: (error: ErrorType) => { + toast.error(error.response?.data?.error.message[0]); + }, + }); + }; + + const handleStop = () => { + endTimeTracking.mutate(taskId, { + onSuccess: () => { + toast.success(t("success")); + }, + onError: (error: ErrorType) => { + toast.error(error.response?.data?.error.message[0]); + }, + }); + }; + + return ( +
+ {isTracking ? ( +
+ + {formatElapsedTime(elapsedSeconds)} + + +
+ ) : ( + + )} +
+ ); +}; + +export default TaskDetailTimePopover; diff --git a/src/pages/taskmanager/components/task-detail/time/TimeListItem.tsx b/src/pages/taskmanager/components/task-detail/time/TimeListItem.tsx new file mode 100644 index 0000000..cce8732 --- /dev/null +++ b/src/pages/taskmanager/components/task-detail/time/TimeListItem.tsx @@ -0,0 +1,52 @@ +import moment from "moment-jalaali"; +import { type FC, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import type { TaskTimeEntryType } from "../../../task/types/TaskTimeTypes"; +import { formatElapsedTime, getEntryDurationSeconds } from "./utils"; + +type Props = { + entry: TaskTimeEntryType; + isActive: boolean; +}; + +const TimeListItem: FC = ({ entry, isActive }) => { + const { t } = useTranslation("global"); + const [durationSeconds, setDurationSeconds] = useState(() => getEntryDurationSeconds(entry)); + + useEffect(() => { + if (!isActive) { + setDurationSeconds(getEntryDurationSeconds(entry)); + return; + } + + const updateDuration = () => { + setDurationSeconds(getEntryDurationSeconds(entry)); + }; + + updateDuration(); + const intervalId = window.setInterval(updateDuration, 1000); + + return () => { + window.clearInterval(intervalId); + }; + }, [entry.startedAt, entry.endedAt, entry.duration, isActive]); + + const startLabel = moment(entry.startedAt).format("jYYYY/jMM/jDD HH:mm"); + const endLabel = entry.endedAt ? moment(entry.endedAt).format("jYYYY/jMM/jDD HH:mm") : null; + + return ( +
+
+
+ {startLabel} + {endLabel ? ` - ${endLabel}` : ` (${t("taskmanager.task_detail.tracking_active")})`} +
+
+ {formatElapsedTime(durationSeconds)} +
+
+
+ ); +}; + +export default TimeListItem; diff --git a/src/pages/taskmanager/components/task-detail/time/utils.ts b/src/pages/taskmanager/components/task-detail/time/utils.ts new file mode 100644 index 0000000..3e7c975 --- /dev/null +++ b/src/pages/taskmanager/components/task-detail/time/utils.ts @@ -0,0 +1,33 @@ +export const formatElapsedTime = (totalSeconds: number): string => { + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + return [hours, minutes, seconds].map((value) => String(value).padStart(2, "0")).join(":"); +}; + +export const getElapsedSeconds = (startedAt: string, endedAt?: string | null): number => { + const start = new Date(startedAt).getTime(); + if (Number.isNaN(start)) return 0; + + const end = endedAt ? new Date(endedAt).getTime() : Date.now(); + if (Number.isNaN(end)) return 0; + + return Math.max(0, Math.floor((end - start) / 1000)); +}; + +export const getActiveTimeEntry = (times: T[]): T | null => { + return times.find((entry) => entry.startedAt && !entry.endedAt) ?? null; +}; + +export const getEntryDurationSeconds = (entry: { startedAt: string; endedAt?: string | null; duration?: number | null }): number => { + if (entry.endedAt) { + return entry.duration ?? getElapsedSeconds(entry.startedAt, entry.endedAt); + } + + return getElapsedSeconds(entry.startedAt); +}; + +export const sortTaskTimes = (times: T[]): T[] => { + return [...times].sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()); +}; diff --git a/src/pages/taskmanager/components/task-detail/types.ts b/src/pages/taskmanager/components/task-detail/types.ts index 2595d13..90a6b32 100644 --- a/src/pages/taskmanager/components/task-detail/types.ts +++ b/src/pages/taskmanager/components/task-detail/types.ts @@ -1 +1 @@ -export type TaskDetailTab = "labels" | "users" | "checklist" | "attachment" | "date"; +export type TaskDetailTab = "labels" | "users" | "checklist" | "attachment" | "date" | "time"; diff --git a/src/pages/taskmanager/task/hooks/useTaskTimeData.ts b/src/pages/taskmanager/task/hooks/useTaskTimeData.ts new file mode 100644 index 0000000..b94f595 --- /dev/null +++ b/src/pages/taskmanager/task/hooks/useTaskTimeData.ts @@ -0,0 +1,34 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import * as api from "../service/TaskTimeService"; + +export const useGetTaskTimes = (taskId: string, enabled = true) => { + return useQuery({ + queryKey: ["task-times", taskId], + queryFn: () => api.getTaskTimes(taskId), + enabled: !!taskId && enabled, + }); +}; + +export const useStartTimeTracking = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (taskId: string) => api.startTimeTracking(taskId), + onSuccess: (_data, taskId) => { + queryClient.invalidateQueries({ queryKey: ["task-detail", taskId] }); + queryClient.invalidateQueries({ queryKey: ["task-times", taskId] }); + }, + }); +}; + +export const useEndTimeTracking = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (taskId: string) => api.endTimeTracking(taskId), + onSuccess: (_data, taskId) => { + queryClient.invalidateQueries({ queryKey: ["task-detail", taskId] }); + queryClient.invalidateQueries({ queryKey: ["task-times", taskId] }); + }, + }); +}; diff --git a/src/pages/taskmanager/task/service/TaskTimeService.ts b/src/pages/taskmanager/task/service/TaskTimeService.ts new file mode 100644 index 0000000..ac7749c --- /dev/null +++ b/src/pages/taskmanager/task/service/TaskTimeService.ts @@ -0,0 +1,17 @@ +import axios from "../../../../config/axios"; +import { EndTaskTimeResponse, GetTaskTimesResponse, StartTaskTimeResponse } from "../types/TaskTimeTypes"; + +export const getTaskTimes = async (taskId: string): Promise => { + const { data } = await axios.get(`/task-manager/times/task/${taskId}`); + return data; +}; + +export const startTimeTracking = async (taskId: string): Promise => { + const { data } = await axios.post(`/task-manager/times/start/${taskId}`); + return data; +}; + +export const endTimeTracking = async (taskId: string): Promise => { + const { data } = await axios.post(`/task-manager/times/end/${taskId}`); + return data; +}; diff --git a/src/pages/taskmanager/task/types/TaskTimeTypes.ts b/src/pages/taskmanager/task/types/TaskTimeTypes.ts new file mode 100644 index 0000000..55a898c --- /dev/null +++ b/src/pages/taskmanager/task/types/TaskTimeTypes.ts @@ -0,0 +1,24 @@ +import { IResponse } from "../../../../types/response.types"; + +export type TaskTimeEntryType = { + id: string; + taskId: string; + startedAt: string; + endedAt?: string | null; + duration?: number | null; + adminId?: string; + createdAt?: string; + updatedAt?: string; +}; + +export interface StartTaskTimeResponse extends IResponse { + statusCode: number; +} + +export interface EndTaskTimeResponse extends IResponse { + statusCode: number; +} + +export interface GetTaskTimesResponse extends IResponse { + statusCode: number; +} diff --git a/src/pages/taskmanager/workspace.tsx b/src/pages/taskmanager/workspace.tsx index 66b4e7c..afad71f 100644 --- a/src/pages/taskmanager/workspace.tsx +++ b/src/pages/taskmanager/workspace.tsx @@ -10,18 +10,13 @@ import HeaderWorkspace from "./components/HeaderWorkspace"; import TaskDetailModal from "./components/task-detail/TaskDetailModal"; import { useGetProject } from "./project/hooks/useProjectData"; import { useUpdateTaskPhase } from "./task-phase/hooks/useTaskPhaseData"; -import { useChangeTaskPhase, useChangeTaskPriority } from "./task/hooks/useTaskData"; import type { TaskPhaseItemType } from "./task-phase/types/TaskPhaseTypes"; +import { useChangeTaskPhase, useChangeTaskPriority } from "./task/hooks/useTaskData"; import type { Column as ColumnType, Task } from "./types"; import { getPriorityDestinationTaskId } from "./utils/getPriorityDestinationTaskId"; import { reorderColumns } from "./utils/reorderColumns"; import { reorderTaskItems } from "./utils/reorderTaskItems"; -import { - getTaskPhaseItems, - insertTaskPhaseItem, - type TasksByTaskPhaseInfiniteData, - updateTaskPhaseItems, -} from "./utils/taskPhaseQueryCache"; +import { getTaskPhaseItems, insertTaskPhaseItem, updateTaskPhaseItems, type TasksByTaskPhaseInfiniteData } from "./utils/taskPhaseQueryCache"; const Workspace: FC = () => { const queryClient = useQueryClient(); @@ -68,18 +63,13 @@ const Workspace: FC = () => { setDraggedTask(null); if (!columnChanged) { - const previousData = queryClient.getQueryData([ - "tasks-by-task-phase", - columnId, - ]); + const previousData = queryClient.getQueryData(["tasks-by-task-phase", columnId]); const currentItems = getTaskPhaseItems(previousData); const destTaskId = getPriorityDestinationTaskId(currentItems, activeTask.id, index); if (!destTaskId) return; - queryClient.setQueryData(["tasks-by-task-phase", columnId], (current) => - updateTaskPhaseItems(current, (items) => reorderTaskItems(items, activeTask.id, index)), - ); + queryClient.setQueryData(["tasks-by-task-phase", columnId], (current) => updateTaskPhaseItems(current, (items) => reorderTaskItems(items, activeTask.id, index))); changeTaskPriority.mutate( { srcId: activeTask.id, destId: destTaskId, taskPhaseId: columnId, projectId }, @@ -95,41 +85,34 @@ const Workspace: FC = () => { return; } - const previousSource = queryClient.getQueryData([ - "tasks-by-task-phase", - sourceColumnId, - ]); - const previousDest = queryClient.getQueryData([ - "tasks-by-task-phase", - columnId, - ]); + const previousSource = queryClient.getQueryData(["tasks-by-task-phase", sourceColumnId]); + const previousDest = queryClient.getQueryData(["tasks-by-task-phase", columnId]); queryClient.setQueryData(["tasks-by-task-phase", sourceColumnId], (current) => updateTaskPhaseItems(current, (items) => items.filter((task) => task.id !== activeTask.id)), ); queryClient.setQueryData(["tasks-by-task-phase", columnId], (current) => - insertTaskPhaseItem(current, { - id: activeTask.id, - title: activeTask.title, - taskPhaseId: columnId, - order: index, - priority: index, - tag: activeTask.tag, - dateRange: activeTask.dateRange, - color: activeTask.color, - attachments: activeTask.attachments, - checklistDone: activeTask.checklistDone, - checklistTotal: activeTask.checklistTotal, - }, index), + insertTaskPhaseItem( + current, + { + id: activeTask.id, + title: activeTask.title, + taskPhaseId: columnId, + order: index, + priority: index, + tag: activeTask.tag, + dateRange: activeTask.dateRange, + color: activeTask.color, + attachments: activeTask.attachments, + checklistDone: activeTask.checklistDone, + checklistTotal: activeTask.checklistTotal, + }, + index, + ), ); - const destTaskId = getPriorityDestinationTaskId( - getTaskPhaseItems(previousDest), - activeTask.id, - index, - false, - ); + const destTaskId = getPriorityDestinationTaskId(getTaskPhaseItems(previousDest), activeTask.id, index, false); if (selectedTask?.id === activeTask.id) { setSelectedTask({ ...activeTask, columnId }); @@ -214,16 +197,10 @@ const Workspace: FC = () => { ); }; - const nextColumnOrder = columns.length - ? Math.max(...columns.map((column) => column.order ?? 0)) + 1 - : 1; + const nextColumnOrder = columns.length ? Math.max(...columns.map((column) => column.order ?? 0)) + 1 : 1; const handleColumnCreated = (phase: TaskPhaseItemType) => { - setColumns((prev) => - [...prev, { id: phase.id, title: phase.name, color: phase.color, order: phase.order }].sort( - (a, b) => (a.order ?? 0) - (b.order ?? 0), - ), - ); + setColumns((prev) => [...prev, { id: phase.id, title: phase.name, color: phase.color, order: phase.order }].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))); }; const handleColumnDeleted = (columnId: string) => {