Compare commits

...

4 Commits

Author SHA1 Message Date
hamid zarghami d5d161c7f9 fix base url
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-15 11:00:01 +03:30
hamid zarghami 1413744b6f Create task in tocker 2026-07-15 10:48:27 +03:30
hamid zarghami 443d97f898 track time 2026-07-15 10:24:05 +03:30
hamid zarghami a4b263cfd7 workspace name 2026-07-15 10:00:33 +03:30
17 changed files with 576 additions and 60 deletions
+1 -1
View File
@@ -2,4 +2,4 @@ VITE_TOKEN_NAME = 'admin_token'
VITE_REFRESH_TOKEN_NAME = 'admin_refresh_token' VITE_REFRESH_TOKEN_NAME = 'admin_refresh_token'
VITE_BASE_URL = 'https://api.danakcorp.com' VITE_BASE_URL = 'https://api.danakcorp.com'
#VITE_BASE_URL = 'http://10.85.127.88:3500' #VITE_BASE_URL = 'http://192.168.1.107:3500'
+17 -2
View File
@@ -349,7 +349,16 @@
"assign_to": "کارشناس پاسخگویی", "assign_to": "کارشناس پاسخگویی",
"user_plan": "پلن کاربر", "user_plan": "پلن کاربر",
"refer_ticket_success": "تیکت با موفقیت ارجاع شد", "refer_ticket_success": "تیکت با موفقیت ارجاع شد",
"refer_ticket_error": "تیکت ارجاع نشد" "refer_ticket_error": "تیکت ارجاع نشد",
"create_task": "ساخت تسک",
"create_task_modal_title": "ساخت تسک از تیکت",
"create_task_success": "تسک با موفقیت ساخته شد",
"select_project": "انتخاب پروژه",
"select_column": "انتخاب ستون",
"column": "ستون",
"select_workspace_required": "فضای کاری را انتخاب کنید",
"select_project_required": "پروژه را انتخاب کنید",
"select_column_required": "ستون را انتخاب کنید"
}, },
"active": "فعال", "active": "فعال",
"inactive": "غیرفعال", "inactive": "غیرفعال",
@@ -1034,7 +1043,13 @@
"or": "یا", "or": "یا",
"insert": "درج", "insert": "درج",
"start_date": "تاریخ شروع", "start_date": "تاریخ شروع",
"end_date": "تاریخ پایان" "end_date": "تاریخ پایان",
"time_tracking": "ثبت زمان",
"start_tracking": "شروع",
"stop_tracking": "پایان",
"time_history": "سابقه",
"no_time_entries": "هنوز زمانی ثبت نشده",
"tracking_active": "در حال اجرا"
} }
} }
} }
@@ -1,10 +1,15 @@
import { InfoCircle, Setting2, UserAdd } from "iconsax-react"; import { InfoCircle, Setting2, UserAdd } from "iconsax-react";
import type { FC } from "react";
const HeaderWorkspace = () => { type HeaderWorkspaceProps = {
projectName: string;
};
const HeaderWorkspace: FC<HeaderWorkspaceProps> = ({ projectName }) => {
return ( return (
<div className="flex justify-between items-center bg-primary h-14 sm:h-16 xl:h-[102px] px-3 sm:px-6 xl:px-8 shrink-0 gap-3"> <div className="flex justify-between items-center bg-primary h-14 sm:h-16 xl:h-[102px] px-3 sm:px-6 xl:px-8 shrink-0 gap-3">
<div className="flex gap-2 sm:gap-4 items-center min-w-0"> <div className="flex gap-2 sm:gap-4 items-center min-w-0">
<div className="text-white text-base sm:text-lg xl:text-xl truncate">طراحی Dpage</div> <div className="text-white text-base sm:text-lg xl:text-xl truncate">{projectName}</div>
<InfoCircle size={18} color="white" /> <InfoCircle size={18} color="white" />
</div> </div>
@@ -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 { type FC, type ReactNode, useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import TaskDetailPopoverPanel from "./TaskDetailPopoverPanel"; import TaskDetailPopoverPanel from "./TaskDetailPopoverPanel";
@@ -16,6 +16,7 @@ const TABS: TabItem[] = [
{ id: "checklist", icon: TickSquare, labelKey: "taskmanager.task_detail.checklist" }, { id: "checklist", icon: TickSquare, labelKey: "taskmanager.task_detail.checklist" },
{ id: "attachment", icon: Paperclip2, labelKey: "taskmanager.task_detail.attachment" }, { id: "attachment", icon: Paperclip2, labelKey: "taskmanager.task_detail.attachment" },
{ id: "date", icon: Calendar, labelKey: "taskmanager.task_detail.date_settings" }, { id: "date", icon: Calendar, labelKey: "taskmanager.task_detail.date_settings" },
{ id: "time", icon: Timer1, labelKey: "taskmanager.task_detail.time_tracking" },
]; ];
type Props = { type Props = {
@@ -26,13 +27,14 @@ type Props = {
checklistPopover?: ReactNode; checklistPopover?: ReactNode;
attachmentPopover?: ReactNode; attachmentPopover?: ReactNode;
datePopover?: ReactNode; datePopover?: ReactNode;
timePopover?: ReactNode;
}; };
const POPOVER_TABS = new Set<TaskDetailTab>(["labels", "users", "checklist", "attachment", "date"]); const POPOVER_TABS = new Set<TaskDetailTab>(["labels", "users", "checklist", "attachment", "date", "time"]);
type PopoverTab = "labels" | "users" | "checklist" | "attachment" | "date"; type PopoverTab = "labels" | "users" | "checklist" | "attachment" | "date" | "time";
const TaskDetailActionBar: FC<Props> = ({ activeTab, onTabChange, labelsPopover, usersPopover, checklistPopover, attachmentPopover, datePopover }) => { const TaskDetailActionBar: FC<Props> = ({ activeTab, onTabChange, labelsPopover, usersPopover, checklistPopover, attachmentPopover, datePopover, timePopover }) => {
const { t } = useTranslation("global"); const { t } = useTranslation("global");
const anchorRefs = useRef<Record<PopoverTab, { current: HTMLElement | null }>>({ const anchorRefs = useRef<Record<PopoverTab, { current: HTMLElement | null }>>({
labels: { current: null }, labels: { current: null },
@@ -40,6 +42,7 @@ const TaskDetailActionBar: FC<Props> = ({ activeTab, onTabChange, labelsPopover,
checklist: { current: null }, checklist: { current: null },
attachment: { current: null }, attachment: { current: null },
date: { current: null }, date: { current: null },
time: { current: null },
}); });
const popoverByTab: Record<PopoverTab, ReactNode> = { const popoverByTab: Record<PopoverTab, ReactNode> = {
@@ -48,6 +51,7 @@ const TaskDetailActionBar: FC<Props> = ({ activeTab, onTabChange, labelsPopover,
checklist: checklistPopover, checklist: checklistPopover,
attachment: attachmentPopover, attachment: attachmentPopover,
date: datePopover, date: datePopover,
time: timePopover,
}; };
return ( return (
@@ -5,10 +5,12 @@ import DefaulModal from "../../../../components/DefaulModal";
import PageLoading from "../../../../components/PageLoading"; import PageLoading from "../../../../components/PageLoading";
import { ErrorType } from "../../../../helpers/types"; import { ErrorType } from "../../../../helpers/types";
import { useChangeTaskPhase, useDeleteTask, useGetTaskDetail, useUpdateTask } from "../../task/hooks/useTaskData"; import { useChangeTaskPhase, useDeleteTask, useGetTaskDetail, useUpdateTask } from "../../task/hooks/useTaskData";
import { useGetTaskTimes } from "../../task/hooks/useTaskTimeData";
import type { TaskDetailType } from "../../task/types/TaskTypes"; import type { TaskDetailType } from "../../task/types/TaskTypes";
import type { Column, Task } from "../../types"; import type { Column, Task } from "../../types";
import AttachmentList from "./attachment/List"; import AttachmentList from "./attachment/List";
import CheckLists from "./checklist/List"; import CheckLists from "./checklist/List";
import TimeList from "./time/List";
import TaskDetailCoverColorBar from "./cover/TaskDetailCoverColorBar"; import TaskDetailCoverColorBar from "./cover/TaskDetailCoverColorBar";
import TaskDetailDescription from "./TaskDetailDescription"; import TaskDetailDescription from "./TaskDetailDescription";
import TaskDetailHeader from "./TaskDetailHeader"; import TaskDetailHeader from "./TaskDetailHeader";
@@ -34,6 +36,7 @@ const TaskDetailModal: FC<Props> = ({ open, task, columns, projectId, onClose, o
const initialDescriptionRef = useRef(""); const initialDescriptionRef = useRef("");
const getTaskDetail = useGetTaskDetail(task?.id ?? "", open && !!task?.id); const getTaskDetail = useGetTaskDetail(task?.id ?? "", open && !!task?.id);
const getTaskTimes = useGetTaskTimes(task?.id ?? "", open && !!task?.id);
const updateTask = useUpdateTask(); const updateTask = useUpdateTask();
const changeTaskPhase = useChangeTaskPhase(); const changeTaskPhase = useChangeTaskPhase();
const deleteTask = useDeleteTask(); const deleteTask = useDeleteTask();
@@ -212,6 +215,8 @@ const TaskDetailModal: FC<Props> = ({ open, task, columns, projectId, onClose, o
checkListItemCount={taskDetail?.checkListItemCount} checkListItemCount={taskDetail?.checkListItemCount}
completedCheckListItemCount={taskDetail?.completedCheckListItemCount} completedCheckListItemCount={taskDetail?.completedCheckListItemCount}
/> />
<TimeList times={getTaskTimes.data?.data} />
</> </>
)} )}
</DefaulModal> </DefaulModal>
@@ -13,6 +13,7 @@ import TaskDetailActionBar from "./TaskDetailActionBar";
import TaskDetailMetadataPreview from "./TaskDetailMetadataPreview"; import TaskDetailMetadataPreview from "./TaskDetailMetadataPreview";
import type { TaskDetailTab } from "./types"; import type { TaskDetailTab } from "./types";
import TaskDetailUsersPopover from "./users/TaskDetailUsersPopover"; import TaskDetailUsersPopover from "./users/TaskDetailUsersPopover";
import TaskDetailTimePopover from "./time/TaskDetailTimePopover";
import type { TaskUser } from "./users/types"; import type { TaskUser } from "./users/types";
import type { UserItemType } from "../../../users/types/UserTypes"; import type { UserItemType } from "../../../users/types/UserTypes";
import type { TaskDetailType } from "../../task/types/TaskTypes"; import type { TaskDetailType } from "../../task/types/TaskTypes";
@@ -93,6 +94,7 @@ const TaskDetailToolbar: FC<Props> = ({ activeTab, onTabChange, taskDetail, task
const isChecklistOpen = activeTab === "checklist"; const isChecklistOpen = activeTab === "checklist";
const isAttachmentOpen = activeTab === "attachment"; const isAttachmentOpen = activeTab === "attachment";
const isDateOpen = activeTab === "date"; const isDateOpen = activeTab === "date";
const isTimeOpen = activeTab === "time";
const handleTabChange = (tab: TaskDetailTab) => { const handleTabChange = (tab: TaskDetailTab) => {
if (tab === "labels" && activeTab !== "labels") { if (tab === "labels" && activeTab !== "labels") {
@@ -340,6 +342,7 @@ const TaskDetailToolbar: FC<Props> = ({ activeTab, onTabChange, taskDetail, task
/> />
) : null ) : null
} }
timePopover={isTimeOpen ? <TaskDetailTimePopover taskId={taskId} /> : null}
/> />
<TaskDetailMetadataPreview <TaskDetailMetadataPreview
@@ -0,0 +1,31 @@
import { type FC, useMemo } from "react";
import { useTranslation } from "react-i18next";
import type { TaskTimeEntryType } from "../../../task/types/TaskTimeTypes";
import TimeListItem from "./TimeListItem";
import { getActiveTimeEntry, sortTaskTimes } from "./utils";
type Props = {
times?: TaskTimeEntryType[];
};
const TimeList: FC<Props> = ({ times = [] }) => {
const { t } = useTranslation("global");
const sortedTimes = useMemo(() => sortTaskTimes(times), [times]);
const activeEntry = useMemo(() => getActiveTimeEntry(times), [times]);
if (times.length === 0) return null;
return (
<div className="mt-6">
<div className="text-sm font-bold">{t("taskmanager.task_detail.time_tracking")}</div>
<div className="mt-4 flex flex-col gap-2">
{sortedTimes.map((entry) => (
<TimeListItem key={entry.id} entry={entry} isActive={activeEntry?.id === entry.id} />
))}
</div>
</div>
);
};
export default TimeList;
@@ -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<Props> = ({ 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 (
<div className="flex min-h-9 w-full items-center">
{isTracking ? (
<div className="flex w-full items-center justify-between gap-3">
<span className="font-mono text-sm font-bold tracking-wider text-[#01347A]">
{formatElapsedTime(elapsedSeconds)}
</span>
<button
type="button"
onClick={handleStop}
disabled={isSubmitting}
className="shrink-0 rounded-lg bg-[#FF4D4F] px-3 py-1.5 text-[11px] font-medium text-white disabled:opacity-60"
>
{t("taskmanager.task_detail.stop_tracking")}
</button>
</div>
) : (
<button
type="button"
onClick={handleStart}
disabled={isSubmitting}
className="w-full rounded-lg bg-primary px-3 py-2 text-[11px] font-medium text-white disabled:opacity-60"
>
{t("taskmanager.task_detail.start_tracking")}
</button>
)}
</div>
);
};
export default TaskDetailTimePopover;
@@ -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<Props> = ({ 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 (
<div className="flex gap-1 h-[27px] items-center">
<div className="group flex justify-between flex-1 h-full hover:bg-white hover:bg-opacity-10 items-center px-2 relative">
<div className="text-xs truncate">
{startLabel}
{endLabel ? ` - ${endLabel}` : ` (${t("taskmanager.task_detail.tracking_active")})`}
</div>
<div className="font-mono text-xs font-bold text-[#01347A] shrink-0 mr-2">
{formatElapsedTime(durationSeconds)}
</div>
</div>
</div>
);
};
export default TimeListItem;
@@ -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 = <T extends { startedAt: string; endedAt?: string | null }>(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 = <T extends { startedAt: string }>(times: T[]): T[] => {
return [...times].sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime());
};
@@ -1 +1 @@
export type TaskDetailTab = "labels" | "users" | "checklist" | "attachment" | "date"; export type TaskDetailTab = "labels" | "users" | "checklist" | "attachment" | "date" | "time";
@@ -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] });
},
});
};
@@ -0,0 +1,17 @@
import axios from "../../../../config/axios";
import { EndTaskTimeResponse, GetTaskTimesResponse, StartTaskTimeResponse } from "../types/TaskTimeTypes";
export const getTaskTimes = async (taskId: string): Promise<GetTaskTimesResponse> => {
const { data } = await axios.get(`/task-manager/times/task/${taskId}`);
return data;
};
export const startTimeTracking = async (taskId: string): Promise<StartTaskTimeResponse> => {
const { data } = await axios.post(`/task-manager/times/start/${taskId}`);
return data;
};
export const endTimeTracking = async (taskId: string): Promise<EndTaskTimeResponse> => {
const { data } = await axios.post(`/task-manager/times/end/${taskId}`);
return data;
};
@@ -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<TaskTimeEntryType> {
statusCode: number;
}
export interface EndTaskTimeResponse extends IResponse<TaskTimeEntryType> {
statusCode: number;
}
export interface GetTaskTimesResponse extends IResponse<TaskTimeEntryType[]> {
statusCode: number;
}
+16 -39
View File
@@ -10,18 +10,13 @@ import HeaderWorkspace from "./components/HeaderWorkspace";
import TaskDetailModal from "./components/task-detail/TaskDetailModal"; import TaskDetailModal from "./components/task-detail/TaskDetailModal";
import { useGetProject } from "./project/hooks/useProjectData"; import { useGetProject } from "./project/hooks/useProjectData";
import { useUpdateTaskPhase } from "./task-phase/hooks/useTaskPhaseData"; import { useUpdateTaskPhase } from "./task-phase/hooks/useTaskPhaseData";
import { useChangeTaskPhase, useChangeTaskPriority } from "./task/hooks/useTaskData";
import type { TaskPhaseItemType } from "./task-phase/types/TaskPhaseTypes"; import type { TaskPhaseItemType } from "./task-phase/types/TaskPhaseTypes";
import { useChangeTaskPhase, useChangeTaskPriority } from "./task/hooks/useTaskData";
import type { Column as ColumnType, Task } from "./types"; import type { Column as ColumnType, Task } from "./types";
import { getPriorityDestinationTaskId } from "./utils/getPriorityDestinationTaskId"; import { getPriorityDestinationTaskId } from "./utils/getPriorityDestinationTaskId";
import { reorderColumns } from "./utils/reorderColumns"; import { reorderColumns } from "./utils/reorderColumns";
import { reorderTaskItems } from "./utils/reorderTaskItems"; import { reorderTaskItems } from "./utils/reorderTaskItems";
import { import { getTaskPhaseItems, insertTaskPhaseItem, updateTaskPhaseItems, type TasksByTaskPhaseInfiniteData } from "./utils/taskPhaseQueryCache";
getTaskPhaseItems,
insertTaskPhaseItem,
type TasksByTaskPhaseInfiniteData,
updateTaskPhaseItems,
} from "./utils/taskPhaseQueryCache";
const Workspace: FC = () => { const Workspace: FC = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -68,18 +63,13 @@ const Workspace: FC = () => {
setDraggedTask(null); setDraggedTask(null);
if (!columnChanged) { if (!columnChanged) {
const previousData = queryClient.getQueryData<TasksByTaskPhaseInfiniteData>([ const previousData = queryClient.getQueryData<TasksByTaskPhaseInfiniteData>(["tasks-by-task-phase", columnId]);
"tasks-by-task-phase",
columnId,
]);
const currentItems = getTaskPhaseItems(previousData); const currentItems = getTaskPhaseItems(previousData);
const destTaskId = getPriorityDestinationTaskId(currentItems, activeTask.id, index); const destTaskId = getPriorityDestinationTaskId(currentItems, activeTask.id, index);
if (!destTaskId) return; if (!destTaskId) return;
queryClient.setQueryData<TasksByTaskPhaseInfiniteData>(["tasks-by-task-phase", columnId], (current) => queryClient.setQueryData<TasksByTaskPhaseInfiniteData>(["tasks-by-task-phase", columnId], (current) => updateTaskPhaseItems(current, (items) => reorderTaskItems(items, activeTask.id, index)));
updateTaskPhaseItems(current, (items) => reorderTaskItems(items, activeTask.id, index)),
);
changeTaskPriority.mutate( changeTaskPriority.mutate(
{ srcId: activeTask.id, destId: destTaskId, taskPhaseId: columnId, projectId }, { srcId: activeTask.id, destId: destTaskId, taskPhaseId: columnId, projectId },
@@ -95,21 +85,17 @@ const Workspace: FC = () => {
return; return;
} }
const previousSource = queryClient.getQueryData<TasksByTaskPhaseInfiniteData>([ const previousSource = queryClient.getQueryData<TasksByTaskPhaseInfiniteData>(["tasks-by-task-phase", sourceColumnId]);
"tasks-by-task-phase", const previousDest = queryClient.getQueryData<TasksByTaskPhaseInfiniteData>(["tasks-by-task-phase", columnId]);
sourceColumnId,
]);
const previousDest = queryClient.getQueryData<TasksByTaskPhaseInfiniteData>([
"tasks-by-task-phase",
columnId,
]);
queryClient.setQueryData<TasksByTaskPhaseInfiniteData>(["tasks-by-task-phase", sourceColumnId], (current) => queryClient.setQueryData<TasksByTaskPhaseInfiniteData>(["tasks-by-task-phase", sourceColumnId], (current) =>
updateTaskPhaseItems(current, (items) => items.filter((task) => task.id !== activeTask.id)), updateTaskPhaseItems(current, (items) => items.filter((task) => task.id !== activeTask.id)),
); );
queryClient.setQueryData<TasksByTaskPhaseInfiniteData>(["tasks-by-task-phase", columnId], (current) => queryClient.setQueryData<TasksByTaskPhaseInfiniteData>(["tasks-by-task-phase", columnId], (current) =>
insertTaskPhaseItem(current, { insertTaskPhaseItem(
current,
{
id: activeTask.id, id: activeTask.id,
title: activeTask.title, title: activeTask.title,
taskPhaseId: columnId, taskPhaseId: columnId,
@@ -121,15 +107,12 @@ const Workspace: FC = () => {
attachments: activeTask.attachments, attachments: activeTask.attachments,
checklistDone: activeTask.checklistDone, checklistDone: activeTask.checklistDone,
checklistTotal: activeTask.checklistTotal, checklistTotal: activeTask.checklistTotal,
}, index), },
index,
),
); );
const destTaskId = getPriorityDestinationTaskId( const destTaskId = getPriorityDestinationTaskId(getTaskPhaseItems(previousDest), activeTask.id, index, false);
getTaskPhaseItems(previousDest),
activeTask.id,
index,
false,
);
if (selectedTask?.id === activeTask.id) { if (selectedTask?.id === activeTask.id) {
setSelectedTask({ ...activeTask, columnId }); setSelectedTask({ ...activeTask, columnId });
@@ -214,16 +197,10 @@ const Workspace: FC = () => {
); );
}; };
const nextColumnOrder = columns.length const nextColumnOrder = columns.length ? Math.max(...columns.map((column) => column.order ?? 0)) + 1 : 1;
? Math.max(...columns.map((column) => column.order ?? 0)) + 1
: 1;
const handleColumnCreated = (phase: TaskPhaseItemType) => { const handleColumnCreated = (phase: TaskPhaseItemType) => {
setColumns((prev) => setColumns((prev) => [...prev, { id: phase.id, title: phase.name, color: phase.color, order: phase.order }].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)));
[...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) => { const handleColumnDeleted = (columnId: string) => {
@@ -240,7 +217,7 @@ const Workspace: FC = () => {
return ( return (
<div className="bg-[#01347A] h-full rounded-none xl:rounded-[32px] overflow-hidden flex flex-col"> <div className="bg-[#01347A] h-full rounded-none xl:rounded-[32px] overflow-hidden flex flex-col">
<HeaderWorkspace /> <HeaderWorkspace projectName={project?.data?.name ?? ""} />
<div className="flex-1 min-h-0 overflow-x-auto overflow-y-hidden overscroll-x-contain"> <div className="flex-1 min-h-0 overflow-x-auto overflow-y-hidden overscroll-x-contain">
<div className="flex gap-3 sm:gap-4 xl:gap-7 px-3 sm:px-4 xl:px-8 pt-4 xl:pt-6 pb-4 xl:pb-6 h-full items-start w-max min-w-full"> <div className="flex gap-3 sm:gap-4 xl:gap-7 px-3 sm:px-4 xl:px-8 pt-4 xl:pt-6 pb-4 xl:pb-6 h-full items-start w-max min-w-full">
{columns.map((column) => ( {columns.map((column) => (
+8
View File
@@ -18,6 +18,7 @@ import { clx } from '../../helpers/utils'
import { Pages } from '../../config/Pages' import { Pages } from '../../config/Pages'
import { useMultiUpload } from '../service/hooks/useServiceData' import { useMultiUpload } from '../service/hooks/useServiceData'
import ReferTicket from './components/ReferTicket' import ReferTicket from './components/ReferTicket'
import CreateTaskFromTicket from './components/CreateTaskFromTicket'
const TicketDetail: FC = () => { const TicketDetail: FC = () => {
@@ -310,6 +311,13 @@ const TicketDetail: FC = () => {
</div> </div>
</div> </div>
{
getMessages.data?.data?.ticket?.status !== 'CLOSED' &&
<CreateTaskFromTicket
ticketSubject={getMessages.data?.data?.ticket?.subject ?? ''}
/>
}
{ {
<ReferTicket refetch={getMessages.refetch} /> <ReferTicket refetch={getMessages.refetch} />
} }
@@ -0,0 +1,211 @@
import { FC, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import DefaulModal from '../../../components/DefaulModal'
import Select from '../../../components/Select'
import Input from '../../../components/Input'
import Button from '../../../components/Button'
import PageLoading from '../../../components/PageLoading'
import { toast } from 'react-toastify'
import { ErrorType } from '../../../helpers/types'
import { useGetWorkspaces } from '../../taskmanager/workspace/hooks/useWorkspaceData'
import { WorkspaceItemType } from '../../taskmanager/workspace/types/WorkspaceTypes'
import { useGetProjectsByWorkspace, useGetProject } from '../../taskmanager/project/hooks/useProjectData'
import { ProjectItem, ProjectTaskPhaseType } from '../../taskmanager/project/types/ProjectTypes'
import { useCreateTask } from '../../taskmanager/task/hooks/useTaskData'
type Props = {
ticketSubject: string
}
const CreateTaskFromTicket: FC<Props> = ({ ticketSubject }) => {
const { t } = useTranslation('global')
const [open, setOpen] = useState(false)
const [workspaceId, setWorkspaceId] = useState('')
const [projectId, setProjectId] = useState('')
const [taskPhaseId, setTaskPhaseId] = useState('')
const [title, setTitle] = useState('')
const getWorkspaces = useGetWorkspaces()
const getProjects = useGetProjectsByWorkspace(workspaceId)
const getProject = useGetProject(projectId)
const createTask = useCreateTask()
const workspaces: WorkspaceItemType[] =
getWorkspaces.data?.data?.workspaces ??
getWorkspaces.data?.data ??
[]
const projects: ProjectItem[] =
getProjects.data?.data?.projects ??
getProjects.data?.data ??
[]
const taskPhases: ProjectTaskPhaseType[] = useMemo(() => {
const phases = getProject.data?.data?.taskPhases ?? getProject.data?.taskPhases ?? []
return [...phases].sort((a, b) => a.order - b.order)
}, [getProject.data])
const workspaceItems = workspaces.map((workspace) => ({
label: workspace.name,
value: workspace.id,
}))
const projectItems = projects.map((project) => ({
label: project.name,
value: project.id,
}))
const columnItems = taskPhases.map((phase) => ({
label: phase.name,
value: phase.id,
}))
const selectedPhase = taskPhases.find((phase) => phase.id === taskPhaseId)
const resetForm = () => {
setWorkspaceId('')
setProjectId('')
setTaskPhaseId('')
setTitle('')
}
const handleClose = () => {
setOpen(false)
resetForm()
}
useEffect(() => {
if (open) {
setTitle(ticketSubject)
}
}, [open, ticketSubject])
const handleCreateTask = () => {
const trimmedTitle = title.trim()
if (!workspaceId) {
toast.error(t('ticket.select_workspace_required'))
return
}
if (!projectId) {
toast.error(t('ticket.select_project_required'))
return
}
if (!taskPhaseId) {
toast.error(t('ticket.select_column_required'))
return
}
if (!trimmedTitle) {
toast.error(t('errors.required'))
return
}
createTask.mutate(
{
title: trimmedTitle,
taskPhaseId,
order: selectedPhase?.tasks?.length ?? 0,
projectId,
},
{
onSuccess: () => {
toast.success(t('ticket.create_task_success'))
handleClose()
},
onError: (error: ErrorType) => {
toast.error(error.response?.data?.error?.message[0])
},
},
)
}
const isLoadingOptions = getWorkspaces.isPending || getProjects.isPending || getProject.isPending
return (
<div className='bg-white p-6 rounded-3xl mt-8'>
<div className='text-sm'>
{t('ticket.create_task')}
</div>
<div className='flex justify-end mt-4'>
<Button
label={t('ticket.create_task')}
onClick={() => setOpen(true)}
className='w-fit px-7'
/>
</div>
<DefaulModal
open={open}
close={handleClose}
isHeader
title_header={t('ticket.create_task_modal_title')}
>
{
isLoadingOptions && !workspaceItems.length ?
<div className='py-8'>
<PageLoading />
</div>
:
<div className='mt-4 flex flex-col gap-6'>
<Select
label={t('taskmanager.workspacename')}
placeholder={t('taskmanager.select_workspace')}
items={workspaceItems}
value={workspaceId}
onChange={(e) => {
setWorkspaceId(e.target.value)
setProjectId('')
setTaskPhaseId('')
}}
className='border'
/>
<Select
label={t('taskmanager.project_name')}
placeholder={t('ticket.select_project')}
items={projectItems}
value={projectId}
onChange={(e) => {
setProjectId(e.target.value)
setTaskPhaseId('')
}}
className='border'
disabled={!workspaceId || getProjects.isPending}
/>
<Select
label={t('ticket.column')}
placeholder={t('ticket.select_column')}
items={columnItems}
value={taskPhaseId}
onChange={(e) => setTaskPhaseId(e.target.value)}
className='border'
disabled={!projectId || getProject.isPending}
/>
<Input
label={t('taskmanager.task_title')}
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<div className='flex justify-end'>
<Button
label={t('ticket.create_task')}
onClick={handleCreateTask}
isLoading={createTask.isPending}
className='w-fit px-7'
/>
</div>
</div>
}
</DefaulModal>
</div>
)
}
export default CreateTaskFromTicket