Compare commits

..

19 Commits

Author SHA1 Message Date
hamid zarghami 95177b529e update task phase
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-12 12:29:52 +03:30
hamid zarghami fd545231d3 Fix + button 2026-07-12 12:17:43 +03:30
hamid zarghami e1fe030316 setting date 2026-07-12 12:15:55 +03:30
hamid zarghami c11c972c7c attachment 2026-07-12 12:13:31 +03:30
hamid zarghami 7c2d6c465e change status checklist 2026-07-12 12:00:08 +03:30
hamid zarghami 50d4ff9281 check list 2026-07-12 11:56:46 +03:30
hamid zarghami 61282e1d1b manager users 2026-07-12 11:43:56 +03:30
hamid zarghami ee8da0b88c Remark 2026-07-11 12:22:15 +03:30
hamid zarghami c4150ab8ac delete task and update task 2026-07-11 11:58:49 +03:30
hamid zarghami 833ac2446f task list and create 2026-07-11 11:54:23 +03:30
hamid zarghami 2bc7a37082 taskphase crate and delete 2026-07-11 11:45:18 +03:30
hamid zarghami 9c84048730 get project 2026-07-11 11:40:55 +03:30
hamid zarghami 651fb16feb CREATE AND DELETE column 2026-07-11 11:06:43 +03:30
hamid zarghami 1618b5fb57 update project 2026-07-11 11:01:01 +03:30
hamid zarghami 47691a649f project list 2026-07-11 10:19:39 +03:30
hamid zarghami b0737ea6ad create project 2026-07-09 11:42:41 +03:30
hamid zarghami 21a99d9ec1 edit and delete workspace 2026-07-09 10:17:33 +03:30
hamid zarghami b965dd0a6d list of workspace 2026-07-09 10:12:55 +03:30
hamid zarghami 43dc693061 workspace create 2026-07-09 10:09:47 +03:30
59 changed files with 3337 additions and 387 deletions
+109
View File
@@ -0,0 +1,109 @@
---
name: danak-admin
description: Documents danak-admin React admin panel conventions — page structure, routing, TanStack Query hooks, axios services, sidebar, and Persian i18n. Use when adding pages or features, wiring API calls, updating navigation, or working in this repository.
---
# Danak Admin
Persian (RTL) admin panel: React 19, Vite, TypeScript, React Router 7, TanStack Query, Formik/Yup, Tailwind, iconsax-react.
## Stack
| Layer | Location / tool |
|-------|-----------------|
| Routes (paths) | `src/config/Pages.ts` |
| Route components | `src/router/Main.tsx` |
| Auth routes | `src/router/Auth.tsx` |
| HTTP client | `src/config/axios.ts``import axios from "../../../config/axios"` |
| Shared UI | `src/components/` |
| Layout | `src/shared/` (Header, SideBar, Footer) |
| i18n | `src/langs/fa.json`, namespace `global`, `useTranslation('global')` |
| Env | `VITE_BASE_URL`, `VITE_TOKEN_NAME`, `VITE_REFRESH_TOKEN_NAME` |
## Feature folder layout
Each domain lives under `src/pages/{feature}/`:
```
{feature}/
├── List.tsx # index table view
├── Create.tsx # create form
├── Update.tsx # edit form (uses :id param)
├── hooks/use{Feature}Data.ts
├── service/{Feature}Service.ts
├── types/{Feature}Types.ts
└── components/ # feature-specific UI (Delete, filters, etc.)
```
Mirror an existing feature (e.g. `customer/`, `ads/`) before inventing new patterns.
## Data layer
**Service** — plain async functions, one API call each:
```typescript
import axios from "../../../config/axios";
import { CreateCustomerType } from "../types/CustomerTypes";
export const getCustomers = async (page: number) => {
const { data } = await axios.get(`/users/customers?page=${page}&paginate=1`);
return data;
};
export const createCustomer = async (params: CreateCustomerType) => {
const { data } = await axios.post(`/users/customers`, params);
return data;
};
```
**Hooks** — TanStack Query wrappers in `hooks/use{Feature}Data.ts`:
- Lists: `useQuery({ queryKey: ["customers", page], queryFn: () => api.getCustomers(page) })`
- Detail: add `enabled: !!id`
- Mutations: `useMutation` + `queryClient.invalidateQueries({ queryKey: ["customers"] })`
- Export hooks as `useGetX`, `useCreateX`, `useUpdateX`, `useDeleteX`
**Types**`{Feature}ItemType`, `Create{Feature}Type` in `types/`.
## Page patterns
**List** — header + optional filters + `PageLoading` while pending + `<table>` with `Td` + `Pagination`. Link actions via `Pages.{feature}.*`.
**Create / Update** — Formik + Yup validation, `toast.success(t('success'))` on success, `toast.error(error.response?.data?.error.message[0])` on error (`ErrorType` from `src/helpers/types.ts`). Navigate back to list with `useNavigate()` + `Pages.*`.
**Delete** — small component using `ModalConfrim` + mutation hook; call `refetch()` or rely on query invalidation.
## Adding a feature
Follow the checklist in [add-feature.md](add-feature.md). Minimum steps:
1. Add paths to `src/config/Pages.ts`
2. Create page files under `src/pages/{feature}/`
3. Register `<Route>` entries in `src/router/Main.tsx` (static imports, no lazy loading)
4. Add sidebar entry in `src/shared/SideBar.tsx` if user-facing
5. Add translation keys to `src/langs/fa.json`
Dynamic routes use string concat: `Pages.customer.update + ":id"`.
## Sidebar & submenu
- Main items: `SideBarItem` in `SideBar.tsx` with `link`, `isActive`, `activeName`
- Features with sub-nav: add segment to `SideBarItemHasSubMenu` in `src/config/SideBarSubMenu.ts`, create `{Feature}SubMenu.tsx` in `src/shared/components/`, render it in the `subMenuName === "..."` block at bottom of `SideBar.tsx`
- Submenu items use `SubMenuItem` with `Pages.*` links
- `subMenuName` is derived from first URL segment (`location.pathname.split("/")[1]`)
## Conventions
- Components: `const MyPage: FC = () => { ... }; export default MyPage`
- Styling: Tailwind utility classes; `clx()` from `src/helpers/utils` for conditional classes
- Icons: `iconsax-react` (Outline/Bold variants, `#8C90A3` inactive / `black` active)
- Do not add lazy-loaded routes — project uses direct imports in `Main.tsx`
- Keep API paths relative to `VITE_BASE_URL`; auth token is attached automatically by axios interceptor
- Prefer reusing `Button`, `Input`, `Select`, `Textarea`, `Pagination`, `PageLoading`, `ModalConfrim` from `src/components/`
## Do not
- Commit `.env` or secrets
- Introduce new state libraries (Zustand is only used in `sharedStore` for layout)
- Add English-only UI strings — add keys to `fa.json`
- Change axios/auth/token refresh logic unless explicitly requested
+83
View File
@@ -0,0 +1,83 @@
# Add Feature Checklist
Copy and track when adding a new admin section (e.g. `coupons`):
```
- [ ] Pages.ts paths
- [ ] types/{Feature}Types.ts
- [ ] service/{Feature}Service.ts
- [ ] hooks/use{Feature}Data.ts
- [ ] List.tsx (+ Create/Update if CRUD)
- [ ] components/ (Delete, filters, etc.)
- [ ] Main.tsx routes + imports
- [ ] SideBar.tsx (+ SubMenu if multi-page)
- [ ] fa.json translations
```
## 1. Pages.ts
```typescript
coupons: {
list: "/coupons/list",
create: "/coupons/create",
detail: "/coupons/detail/",
},
```
Use kebab-case URL segments consistent with existing routes.
## 2. Service + hooks + types
Copy structure from `src/pages/customer/` or `src/pages/ads/`. Adjust API paths to match backend.
## 3. Main.tsx
Add import at top, then route(s):
```tsx
import CouponsList from "../pages/coupons/List";
// ...
<Route path={Pages.coupons.list} element={<CouponsList />} />
<Route path={Pages.coupons.create} element={<CreateCoupon />} />
<Route path={Pages.coupons.detail + ":id"} element={<UpdateCoupon />} />
```
## 4. Sidebar (optional)
**Single-page feature** — add `SideBarItem`:
```tsx
<SideBarItem
icon={<TicketDiscount variant={isActive("coupons") ? "Bold" : "Outline"} color={isActive("coupons") ? "black" : "#8C90A3"} size={iconSizeSideBar} />}
title={t("sidebar.coupons")}
isActive={isActive("coupons")}
link={Pages.coupons.list}
activeName="coupons"
/>
```
**Multi-page feature** — also:
1. Add `"coupons"` to `SideBarItemHasSubMenu` in `src/config/SideBarSubMenu.ts`
2. Create `src/shared/components/CouponsSubMenu.tsx` (copy `CustomerSubMenu.tsx`)
3. Import and add branch in `SideBar.tsx`: `subMenuName === "coupons" ? <CouponsSubMenu /> : ...`
4. Point main sidebar link to list path; first URL segment must match `activeName` / submenu key
## 5. Translations
Add keys under logical groups in `src/langs/fa.json`:
```json
"sidebar": { "coupons": "کوپن‌ها" },
"submenu": { "coupon_list": "لیست کوپن‌ها" },
"coupon": { "coupon_list": "لیست کوپن‌ها", "add_coupon": "افزودن کوپن" }
```
Use `t('coupon.add_coupon')` in components.
## 6. Verify
- `npm run build` passes
- List loads, create/update navigate correctly
- Sidebar highlights on correct routes
- Mutation invalidates list query cache
+4 -1
View File
@@ -169,8 +169,11 @@ export const Pages = {
}, },
taskmanager: { taskmanager: {
workspace: "/taskmanager/workspace/", workspace: "/taskmanager/workspace/",
workspaceList: "/workspace/list",
createWorkspace: "/workspace/create", createWorkspace: "/workspace/create",
updateWorkspace: "/workspace/update/",
createProject: "/project/create", createProject: "/project/create",
projectList: "/project/list", updateProject: "/project/update/",
projectList: "/project/list/",
}, },
}; };
+1
View File
@@ -8,4 +8,5 @@ export const SideBarItemHasSubMenu = [
"blog", "blog",
"support", "support",
"accessLogs", "accessLogs",
"taskmanager",
]; ];
+22 -1
View File
@@ -143,7 +143,12 @@
"guide": "راهنمای سرویس", "guide": "راهنمای سرویس",
"access_logs_list": "لیست لاگ‌ها", "access_logs_list": "لیست لاگ‌ها",
"access_logs_stats": "آمار لاگ‌ها", "access_logs_stats": "آمار لاگ‌ها",
"access_logs_errors": "لاگ‌های خطا" "access_logs_errors": "لاگ‌های خطا",
"taskmanager_workspace": "فضای کاری",
"taskmanager_workspace_list": "لیست فضاهای کاری",
"taskmanager_create_workspace": "ساخت فضای کاری جدید",
"taskmanager_project_list": "لیست پروژه‌ها",
"taskmanager_create_project": "افزودن پروژه"
}, },
"guide": { "guide": {
"list_guide": "لیست راهنمای سرویس", "list_guide": "لیست راهنمای سرویس",
@@ -968,15 +973,19 @@
}, },
"cancel": "لغو", "cancel": "لغو",
"taskmanager": { "taskmanager": {
"workspace_list": "لیست فضاهای کاری",
"new_workspace": "ساخت فضای کاری جدید", "new_workspace": "ساخت فضای کاری جدید",
"submit_workspace": "ثبت فضای کاری", "submit_workspace": "ثبت فضای کاری",
"workspace_deleted": "فضای کاری با موفقیت حذف شد",
"workspacename": "نام", "workspacename": "نام",
"workspacetype": "نوع", "workspacetype": "نوع",
"workspace_info": "اطلاعات",
"workspace_description": "توضیحات", "workspace_description": "توضیحات",
"workspace_status": "وضعیت فضای کار", "workspace_status": "وضعیت فضای کار",
"choose_color": "انتخاب رنگ دلخواه", "choose_color": "انتخاب رنگ دلخواه",
"users": "کاربران", "users": "کاربران",
"select_user": "انتخاب کاربر", "select_user": "انتخاب کاربر",
"select_workspace": "انتخاب فضای کاری",
"no_users_added": "هنوز کاربری اضافه نکردی", "no_users_added": "هنوز کاربری اضافه نکردی",
"new_project": "افزودن پروژه", "new_project": "افزودن پروژه",
"submit_project": "ثبت پروژه", "submit_project": "ثبت پروژه",
@@ -991,6 +1000,18 @@
"select_date": "انتخاب تاریخ", "select_date": "انتخاب تاریخ",
"projects": "پروژه‌ها", "projects": "پروژه‌ها",
"delete_project": "پاک کردن", "delete_project": "پاک کردن",
"add_new_column": "اضافه کردن ستون جدید",
"add_column": "اضافه کردن",
"column_name": "نام ستون",
"delete_column": "حذف ستون",
"delete_column_confirm": "آیا از حذف این ستون اطمینان دارید؟",
"column_deleted": "ستون با موفقیت حذف شد",
"add_new_task": "اضافه کردن تسک",
"add_task": "اضافه کردن",
"task_title": "عنوان تسک",
"delete_task": "حذف تسک",
"delete_task_confirm": "آیا از حذف این تسک اطمینان دارید؟",
"task_deleted": "تسک با موفقیت حذف شد",
"task_detail": { "task_detail": {
"labels": "برچسب ها", "labels": "برچسب ها",
"user_management": "مدیریت کاربران", "user_management": "مدیریت کاربران",
@@ -1,12 +1,146 @@
import { AddCircle } from "iconsax-react"; import { Add, AddCircle, CloseCircle } from "iconsax-react";
import { type FC } from "react"; import { useRef, useState, type FC } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "react-toastify";
import ColorsImage from "../../../assets/images/colors.png";
import { ErrorType } from "../../../helpers/types";
import { clx } from "../../../helpers/utils";
import { useCreateTaskPhase } from "../task-phase/hooks/useTaskPhaseData";
import type { TaskPhaseItemType } from "../task-phase/types/TaskPhaseTypes";
import { LABEL_COLOR_OPTIONS } from "./task-detail/labels/constants";
const DEFAULT_COLUMN_COLOR = "#4F46E5";
type Props = {
projectId: string;
nextOrder: number;
onColumnCreated: (phase: TaskPhaseItemType) => void;
};
const AddNewColumn: FC<Props> = ({ projectId, nextOrder, onColumnCreated }) => {
const { t } = useTranslation("global");
const colorInputRef = useRef<HTMLInputElement>(null);
const createTaskPhase = useCreateTaskPhase();
const [isAdding, setIsAdding] = useState(false);
const [name, setName] = useState("");
const [selectedColor, setSelectedColor] = useState(DEFAULT_COLUMN_COLOR);
const resetForm = () => {
setName("");
setSelectedColor(DEFAULT_COLUMN_COLOR);
setIsAdding(false);
};
const handleSubmit = () => {
const trimmedName = name.trim();
if (!trimmedName || !projectId) return;
createTaskPhase.mutate(
{
name: trimmedName,
order: nextOrder,
color: selectedColor,
projectId,
},
{
onSuccess: (data) => {
const phase = (data?.data ?? data) as TaskPhaseItemType;
onColumnCreated(phase);
toast.success(t("success"));
resetForm();
},
onError: (error: ErrorType) => {
toast.error(error.response?.data?.error.message[0]);
},
},
);
};
if (!isAdding) {
return (
<button
type="button"
onClick={() => setIsAdding(true)}
className="bg-[#F0F3F7BF] bg-opacity-75 rounded-full px-4 sm:px-6 py-3 sm:py-4 flex items-center gap-2 shrink-0 self-start cursor-pointer w-[272px] sm:w-[288px] xl:w-[310px] justify-center"
>
<AddCircle size={16} color="black" />
<span className="text-xs text-black">{t("taskmanager.add_new_column")}</span>
</button>
);
}
const AddNewColumn: FC = () => {
return ( return (
<button type="button" className="bg-[#F0F3F7BF] bg-opacity-75 rounded-full px-4 sm:px-6 py-3 sm:py-4 flex items-center gap-2 shrink-0 self-start cursor-pointer w-[272px] sm:w-[288px] xl:w-[310px] justify-center"> <div className="bg-[#F0F3F7] rounded-xl p-3 sm:p-4 xl:p-6 w-[272px] sm:w-[288px] xl:w-[310px] shrink-0 self-start flex flex-col gap-3 sm:gap-4">
<AddCircle size={16} color="black" /> <input
<span className="text-xs text-black">اضافه کردن ستون جدید</span> type="text"
</button> value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("taskmanager.column_name")}
className="w-full bg-white rounded-lg px-3 py-2.5 text-sm outline-none"
autoFocus
/>
<div>
<div className="text-xs mb-3">{t("taskmanager.choose_color")}</div>
<div className="flex items-center gap-2 flex-wrap">
<button
type="button"
onClick={() => colorInputRef.current?.click()}
className={clx(
"size-6 rounded-lg cursor-pointer overflow-hidden",
!LABEL_COLOR_OPTIONS.includes(
selectedColor as (typeof LABEL_COLOR_OPTIONS)[number],
) && "ring-1 ring-[#0047FF] rounded-full",
)}
aria-label={t("taskmanager.choose_color")}
>
<img src={ColorsImage} alt="" className="size-full object-cover" />
</button>
{LABEL_COLOR_OPTIONS.map((color) => (
<button
key={color}
type="button"
onClick={() => setSelectedColor(color)}
className={clx(
"size-6 rounded-lg cursor-pointer transition-transform",
color === "#FFFFFF" && "border border-[#D0D0D0]",
selectedColor === color && "ring-2 ring-[#0047FF] ring-offset-1",
)}
style={{ backgroundColor: color }}
aria-label={color}
/>
))}
<input
ref={colorInputRef}
type="color"
value={selectedColor}
onChange={(e) => setSelectedColor(e.target.value)}
className="sr-only"
/>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleSubmit}
disabled={!name.trim() || createTaskPhase.isPending}
className="flex items-center gap-2 bg-black text-white text-[13px] rounded-full px-4 py-2 cursor-pointer disabled:opacity-50"
>
<span>{t("taskmanager.add_column")}</span>
<Add size={18} color="white" />
</button>
<button
type="button"
onClick={resetForm}
className="cursor-pointer"
aria-label={t("cancel")}
>
<CloseCircle size={22} color="#888888" variant="Bold" />
</button>
</div>
</div>
); );
}; };
+98 -15
View File
@@ -1,10 +1,19 @@
import { Add, AddCircle, CloseCircle, More } from "iconsax-react"; import { Add, AddCircle, CloseCircle } from "iconsax-react";
import { useRef, useState, type FC } from "react"; import { useRef, useState, type FC } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "react-toastify";
import ModalConfrim from "../../../components/ModalConfrim";
import { ErrorType } from "../../../helpers/types";
import { useCreateTask } from "../task/hooks/useTaskData";
import type { TaskItemType } from "../task/types/TaskTypes";
import { useDeleteTaskPhase } from "../task-phase/hooks/useTaskPhaseData";
import type { Column as ColumnType, Task as TaskType } from "../types"; import type { Column as ColumnType, Task as TaskType } from "../types";
import ColumnMenu from "./ColumnMenu";
import Task from "./Task"; import Task from "./Task";
type Props = { type Props = {
column: ColumnType; column: ColumnType;
projectId: string;
tasks: TaskType[]; tasks: TaskType[];
draggedTaskId: string | null; draggedTaskId: string | null;
draggedColumnId: string | null; draggedColumnId: string | null;
@@ -15,10 +24,13 @@ type Props = {
onColumnDragEnd: () => void; onColumnDragEnd: () => void;
onColumnDrop: (overColumnId: string) => void; onColumnDrop: (overColumnId: string) => void;
onTaskClick?: (task: TaskType) => void; onTaskClick?: (task: TaskType) => void;
onColumnDeleted?: (columnId: string) => void;
onTaskCreated?: (task: TaskItemType, taskPhaseId: string) => void;
}; };
const Column: FC<Props> = ({ const Column: FC<Props> = ({
column, column,
projectId,
tasks, tasks,
draggedTaskId, draggedTaskId,
draggedColumnId, draggedColumnId,
@@ -29,8 +41,16 @@ const Column: FC<Props> = ({
onColumnDragEnd, onColumnDragEnd,
onColumnDrop, onColumnDrop,
onTaskClick, onTaskClick,
onColumnDeleted,
onTaskCreated,
}) => { }) => {
const { t } = useTranslation("global");
const deleteTaskPhase = useDeleteTaskPhase();
const createTask = useCreateTask();
const [isAdding, setIsAdding] = useState(false); const [isAdding, setIsAdding] = useState(false);
const [title, setTitle] = useState("");
const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false);
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
const [isColumnDragOver, setIsColumnDragOver] = useState(false); const [isColumnDragOver, setIsColumnDragOver] = useState(false);
const columnRef = useRef<HTMLDivElement>(null); const columnRef = useRef<HTMLDivElement>(null);
@@ -116,6 +136,52 @@ const Column: FC<Props> = ({
onColumnDrop(column.id); onColumnDrop(column.id);
}; };
const resetTaskForm = () => {
setTitle("");
setIsAdding(false);
};
const handleCreateTask = () => {
const trimmedTitle = title.trim();
if (!trimmedTitle || !projectId) return;
createTask.mutate(
{
title: trimmedTitle,
taskPhaseId: column.id,
order: tasks.length,
projectId,
},
{
onSuccess: (data) => {
const task = (data?.data ?? data) as TaskItemType;
onTaskCreated?.(task, column.id);
toast.success(t("success"));
resetTaskForm();
},
onError: (error: ErrorType) => {
toast.error(error.response?.data?.error.message[0]);
},
},
);
};
const handleDeleteColumn = () => {
deleteTaskPhase.mutate(
{ id: column.id, projectId },
{
onSuccess: () => {
onColumnDeleted?.(column.id);
setIsDeleteConfirmOpen(false);
toast.success(t("taskmanager.column_deleted"));
},
onError: (error: ErrorType) => {
toast.error(error.response?.data?.error.message[0]);
},
},
);
};
return ( return (
<div <div
ref={columnRef} ref={columnRef}
@@ -139,16 +205,26 @@ const Column: FC<Props> = ({
} }
}} }}
> >
<div <div className="flex justify-between items-center shrink-0 gap-2">
draggable <div
onDragStart={handleColumnDragStart} draggable
onDragEnd={handleColumnDragEnd} onDragStart={handleColumnDragStart}
className="flex justify-between items-center shrink-0 cursor-grab active:cursor-grabbing" onDragEnd={handleColumnDragEnd}
> className="flex-1 min-w-0 font-bold text-sm truncate cursor-grab active:cursor-grabbing"
<div className="font-bold text-sm truncate">{column.title}</div> >
<More size={20} color="black" className="shrink-0 pointer-events-none" /> {column.title}
</div>
<ColumnMenu onDelete={() => setIsDeleteConfirmOpen(true)} />
</div> </div>
<ModalConfrim
isOpen={isDeleteConfirmOpen}
close={() => setIsDeleteConfirmOpen(false)}
onConfrim={handleDeleteColumn}
isLoading={deleteTaskPhase.isPending}
label={t("taskmanager.delete_column_confirm")}
/>
<div className="mt-3 sm:mt-4 xl:mt-5 flex-1 min-h-0 overflow-y-auto overscroll-y-contain flex flex-col gap-3 sm:gap-4"> <div className="mt-3 sm:mt-4 xl:mt-5 flex-1 min-h-0 overflow-y-auto overscroll-y-contain flex flex-col gap-3 sm:gap-4">
{tasks.map((task, index) => ( {tasks.map((task, index) => (
<div <div
@@ -175,23 +251,30 @@ const Column: FC<Props> = ({
<div className="mt-3 sm:mt-4 xl:mt-5 shrink-0 flex flex-col gap-3"> <div className="mt-3 sm:mt-4 xl:mt-5 shrink-0 flex flex-col gap-3">
<input <input
type="text" type="text"
placeholder="عنوان تسک" value={title}
onChange={(e) => setTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleCreateTask();
}}
placeholder={t("taskmanager.task_title")}
className="w-full bg-white rounded-lg px-3 py-2.5 text-sm outline-none" className="w-full bg-white rounded-lg px-3 py-2.5 text-sm outline-none"
autoFocus autoFocus
/> />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
type="button" type="button"
className="flex items-center gap-2 bg-black text-white text-[13px] rounded-full px-4 py-2 cursor-pointer" onClick={handleCreateTask}
disabled={!title.trim() || createTask.isPending}
className="flex items-center gap-2 bg-black text-white text-[13px] rounded-full px-4 py-2 cursor-pointer disabled:opacity-50"
> >
<span>اضافه کردن</span> <span>{t("taskmanager.add_task")}</span>
<Add size={18} color="white" /> <Add size={18} color="white" />
</button> </button>
<button <button
type="button" type="button"
onClick={() => setIsAdding(false)} onClick={resetTaskForm}
className="cursor-pointer" className="cursor-pointer"
aria-label="انصراف" aria-label={t("cancel")}
> >
<CloseCircle size={22} color="#888888" variant="Bold" /> <CloseCircle size={22} color="#888888" variant="Bold" />
</button> </button>
@@ -204,7 +287,7 @@ const Column: FC<Props> = ({
className="mt-3 sm:mt-4 xl:mt-5 flex gap-2 items-center shrink-0 cursor-pointer" className="mt-3 sm:mt-4 xl:mt-5 flex gap-2 items-center shrink-0 cursor-pointer"
> >
<AddCircle size={18} color="#888888" /> <AddCircle size={18} color="#888888" />
<span className="text-description text-[13px] mt-0.5">اضافه کردن تسک</span> <span className="text-description text-[13px] mt-0.5">{t("taskmanager.add_new_task")}</span>
</button> </button>
)} )}
</div> </div>
@@ -0,0 +1,38 @@
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import { More } from "iconsax-react";
import { FC } from "react";
import { useTranslation } from "react-i18next";
type Props = {
onDelete: () => void;
};
const ColumnMenu: FC<Props> = ({ onDelete }) => {
const { t } = useTranslation("global");
return (
<Popover className="relative">
<PopoverButton
className="flex items-center justify-center size-8 rounded-lg hover:bg-black/5 transition-colors outline-none shrink-0"
onClick={(e) => e.stopPropagation()}
>
<More size={20} color="black" />
</PopoverButton>
<PopoverPanel
anchor="bottom start"
className="z-20 mt-1 min-w-[120px] rounded-xl bg-white shadow-md border border-border py-1 text-sm"
>
<button
type="button"
onClick={onDelete}
className="w-full px-4 py-2.5 text-right text-red-500 hover:bg-red-50 transition-colors"
>
{t("taskmanager.delete_column")}
</button>
</PopoverPanel>
</Popover>
);
};
export default ColumnMenu;
@@ -4,9 +4,10 @@ import { useTranslation } from "react-i18next";
type Props = { type Props = {
value?: string; value?: string;
onChange?: (value: string) => void; onChange?: (value: string) => void;
onBlur?: () => void;
}; };
const TaskDetailDescription: FC<Props> = ({ value = "", onChange }) => { const TaskDetailDescription: FC<Props> = ({ value = "", onChange, onBlur }) => {
const { t } = useTranslation("global"); const { t } = useTranslation("global");
return ( return (
@@ -15,6 +16,7 @@ const TaskDetailDescription: FC<Props> = ({ value = "", onChange }) => {
<textarea <textarea
value={value} value={value}
onChange={(e) => onChange?.(e.target.value)} onChange={(e) => onChange?.(e.target.value)}
onBlur={onBlur}
placeholder={t("taskmanager.task_detail.description_placeholder")} placeholder={t("taskmanager.task_detail.description_placeholder")}
rows={5} rows={5}
className="w-full bg-white/25 rounded-xl px-4 py-3 text-xs outline-none resize-none placeholder:text-description" className="w-full bg-white/25 rounded-xl px-4 py-3 text-xs outline-none resize-none placeholder:text-description"
@@ -1,20 +1,68 @@
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import { ArrowDown2, CloseCircle } from "iconsax-react"; import { ArrowDown2, CloseCircle } from "iconsax-react";
import { type FC } from "react"; import { type FC } from "react";
import TrashWithConfrim from "../../../../components/TrashWithConfrim";
import { clx } from "../../../../helpers/utils";
import type { Column } from "../../types";
type Props = { type Props = {
statusLabel: string; columns: Column[];
selectedPhaseId: string;
onPhaseChange: (phaseId: string) => void;
isChangingPhase?: boolean;
onClose: () => void; onClose: () => void;
onDelete?: () => void;
isDeleting?: boolean;
}; };
const TaskDetailHeader: FC<Props> = ({ statusLabel, onClose }) => { const TaskDetailHeader: FC<Props> = ({
columns,
selectedPhaseId,
onPhaseChange,
isChangingPhase,
onClose,
onDelete,
isDeleting,
}) => {
const selectedColumn = columns.find((column) => column.id === selectedPhaseId);
return ( return (
<div className="flex items-center justify-between border-b border-border pb-4"> <div className="flex items-center justify-between border-b border-border pb-4">
<button type="button" className="flex items-center gap-1.5 bg-white/60 rounded-full px-4 py-2 text-xs font-medium cursor-pointer"> <Popover className="relative">
<span>{statusLabel}</span> <PopoverButton
<ArrowDown2 size={14} color="#292D32" /> disabled={isChangingPhase}
</button> className="flex items-center gap-1.5 bg-white/60 rounded-full px-4 py-2 text-xs font-medium cursor-pointer disabled:opacity-50 outline-none"
>
<span>{selectedColumn?.title ?? ""}</span>
<ArrowDown2 size={14} color="#292D32" />
</PopoverButton>
<PopoverPanel
anchor="bottom start"
className="z-20 mt-1 min-w-[160px] rounded-xl bg-white shadow-md border border-border py-1 text-sm"
>
{columns.map((column) => (
<button
key={column.id}
type="button"
onClick={() => onPhaseChange(column.id)}
className={clx(
"w-full px-4 py-2.5 text-right transition-colors hover:bg-black/5",
column.id === selectedPhaseId && "font-bold bg-black/5",
)}
>
{column.title}
</button>
))}
</PopoverPanel>
</Popover>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{onDelete ? (
<div className="size-8 rounded-full bg-white/60 flex items-center justify-center">
<TrashWithConfrim onDelete={onDelete} isLoading={isDeleting} color="#FF0000" />
</div>
) : null}
<button type="button" onClick={onClose} className="size-8 rounded-full bg-white/60 flex items-center justify-center cursor-pointer" aria-label="بستن"> <button type="button" onClick={onClose} className="size-8 rounded-full bg-white/60 flex items-center justify-center cursor-pointer" aria-label="بستن">
<CloseCircle size={20} color="#292D32" /> <CloseCircle size={20} color="#292D32" />
</button> </button>
@@ -12,9 +12,11 @@ type Props = {
selectedUsers: TaskUser[]; selectedUsers: TaskUser[];
startDate: DateObject | null; startDate: DateObject | null;
endDate: DateObject | null; endDate: DateObject | null;
onAddLabel?: () => void;
onAddUser?: () => void;
}; };
const TaskDetailMetadataPreview: FC<Props> = ({ selectedLabels, selectedUsers, startDate, endDate }) => { const TaskDetailMetadataPreview: FC<Props> = ({ selectedLabels, selectedUsers, startDate, endDate, onAddLabel, onAddUser }) => {
const { t } = useTranslation("global"); const { t } = useTranslation("global");
const dateLabel = formatDateRange(startDate, endDate); const dateLabel = formatDateRange(startDate, endDate);
@@ -34,6 +36,7 @@ const TaskDetailMetadataPreview: FC<Props> = ({ selectedLabels, selectedUsers, s
))} ))}
<button <button
type="button" type="button"
onClick={onAddLabel}
className="size-7 rounded-lg border border-[#D0D0D0] bg-white/60 flex items-center justify-center cursor-pointer shrink-0" className="size-7 rounded-lg border border-[#D0D0D0] bg-white/60 flex items-center justify-center cursor-pointer shrink-0"
aria-label={t("taskmanager.task_detail.new_label")} aria-label={t("taskmanager.task_detail.new_label")}
> >
@@ -62,6 +65,7 @@ const TaskDetailMetadataPreview: FC<Props> = ({ selectedLabels, selectedUsers, s
))} ))}
<button <button
type="button" type="button"
onClick={onAddUser}
className="size-7 rounded-full border border-[#D0D0D0] bg-white/60 flex items-center justify-center cursor-pointer shrink-0" className="size-7 rounded-full border border-[#D0D0D0] bg-white/60 flex items-center justify-center cursor-pointer shrink-0"
aria-label={t("taskmanager.users")} aria-label={t("taskmanager.users")}
> >
@@ -1,6 +1,12 @@
import { type FC, useEffect, useState } from "react"; import { type FC, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "react-toastify";
import DefaulModal from "../../../../components/DefaulModal"; import DefaulModal from "../../../../components/DefaulModal";
import type { Task } from "../../types"; import PageLoading from "../../../../components/PageLoading";
import { ErrorType } from "../../../../helpers/types";
import { useChangeTaskPhase, useDeleteTask, useGetTaskDetail, useUpdateTask } from "../../task/hooks/useTaskData";
import type { TaskDetailType } from "../../task/types/TaskTypes";
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 TaskDetailDescription from "./TaskDetailDescription"; import TaskDetailDescription from "./TaskDetailDescription";
@@ -11,42 +17,170 @@ import type { TaskDetailTab } from "./types";
type Props = { type Props = {
open: boolean; open: boolean;
task: Task | null; task: Task | null;
statusLabel: string; columns: Column[];
projectId: string;
onClose: () => void; onClose: () => void;
onTaskPhaseChanged?: (taskId: string, taskPhaseId: string) => void;
}; };
const TaskDetailModal: FC<Props> = ({ open, task, statusLabel, onClose }) => { const TaskDetailModal: FC<Props> = ({ open, task, columns, projectId, onClose, onTaskPhaseChanged }) => {
const { t } = useTranslation("global");
const [activeTab, setActiveTab] = useState<TaskDetailTab | null>(null); const [activeTab, setActiveTab] = useState<TaskDetailTab | null>(null);
const [title, setTitle] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const initialTitleRef = useRef("");
const initialDescriptionRef = useRef("");
const getTaskDetail = useGetTaskDetail(task?.id ?? "", open && !!task?.id);
const updateTask = useUpdateTask();
const changeTaskPhase = useChangeTaskPhase();
const deleteTask = useDeleteTask();
const rawTaskDetail = getTaskDetail.data?.data?.task ?? getTaskDetail.data?.data;
const taskDetail: TaskDetailType | undefined = rawTaskDetail
? {
...rawTaskDetail,
id: rawTaskDetail.id ?? task?.id ?? "",
labels: rawTaskDetail.labels ?? rawTaskDetail.remarks,
}
: undefined;
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
setActiveTab(null); setActiveTab(null);
setTitle("");
setDescription(""); setDescription("");
initialTitleRef.current = "";
initialDescriptionRef.current = "";
} }
}, [open]); }, [open]);
useEffect(() => {
if (!taskDetail) return;
setTitle(taskDetail.title);
setDescription(taskDetail.description ?? "");
initialTitleRef.current = taskDetail.title;
initialDescriptionRef.current = taskDetail.description ?? "";
}, [taskDetail]);
if (!task) return null;
const selectedPhaseId = taskDetail?.taskPhaseId ?? task.columnId;
const handleTabChange = (tab: TaskDetailTab) => { const handleTabChange = (tab: TaskDetailTab) => {
setActiveTab((prev) => (prev === tab ? null : tab)); setActiveTab((prev) => (prev === tab ? null : tab));
}; };
if (!task) return null; const handleDelete = () => {
if (!task.id) return;
deleteTask.mutate(
{ id: task.id, projectId: taskDetail?.projectId ?? "" },
{
onSuccess: () => {
toast.success(t("taskmanager.task_deleted"));
onClose();
},
onError: (error: ErrorType) => {
toast.error(error.response?.data?.error.message[0]);
},
},
);
};
const saveField = (field: "title" | "description", value: string) => {
const initialValue = field === "title" ? initialTitleRef.current : initialDescriptionRef.current;
if (value === initialValue) return;
updateTask.mutate(
{ id: task.id, params: { [field]: value } },
{
onSuccess: () => {
if (field === "title") {
initialTitleRef.current = value;
} else {
initialDescriptionRef.current = value;
}
toast.success(t("success"));
},
onError: (error: ErrorType) => {
toast.error(error.response?.data?.error.message[0]);
if (field === "title") {
setTitle(initialTitleRef.current);
} else {
setDescription(initialDescriptionRef.current);
}
},
},
);
};
const handlePhaseChange = (taskPhaseId: string) => {
if (taskPhaseId === selectedPhaseId) return;
changeTaskPhase.mutate(
{ id: task.id, params: { taskPhaseId }, projectId: taskDetail?.projectId ?? projectId },
{
onSuccess: () => {
onTaskPhaseChanged?.(task.id, taskPhaseId);
toast.success(t("success"));
},
onError: (error: ErrorType) => {
toast.error(error.response?.data?.error.message[0]);
},
},
);
};
return ( return (
<DefaulModal open={open} close={onClose} width={680}> <DefaulModal open={open} close={onClose} width={680}>
<TaskDetailHeader statusLabel={statusLabel} onClose={onClose} /> {getTaskDetail.isPending ? (
<PageLoading />
) : (
<>
<TaskDetailHeader
columns={columns}
selectedPhaseId={selectedPhaseId}
onPhaseChange={handlePhaseChange}
isChangingPhase={changeTaskPhase.isPending}
onClose={onClose}
onDelete={handleDelete}
isDeleting={deleteTask.isPending}
/>
<h2 className="mt-6 text-sm font-bold">{task.title}</h2> <input
value={title}
onChange={(e) => setTitle(e.target.value)}
onBlur={() => saveField("title", title)}
className="mt-6 w-full text-sm font-bold bg-transparent outline-none"
/>
<div className="mt-4"> <div className="mt-4">
<TaskDetailToolbar activeTab={activeTab} onTabChange={handleTabChange} /> <TaskDetailToolbar
</div> activeTab={activeTab}
onTabChange={handleTabChange}
taskDetail={taskDetail}
taskId={task.id}
/>
</div>
<TaskDetailDescription value={description} onChange={setDescription} /> <TaskDetailDescription
value={description}
onChange={setDescription}
onBlur={() => saveField("description", description)}
/>
<AttachmentList /> <AttachmentList attachments={taskDetail?.attachments} />
<CheckLists /> <CheckLists
taskId={task.id}
checkListItems={taskDetail?.checkListItems}
checkListItemCount={taskDetail?.checkListItemCount}
completedCheckListItemCount={taskDetail?.completedCheckListItemCount}
/>
</>
)}
</DefaulModal> </DefaulModal>
); );
}; };
@@ -1,36 +1,91 @@
import { type FC, useState } from "react"; import { type FC, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "react-toastify";
import { useGetUsers } from "../../../users/hooks/useUserData";
import TaskDetailAttachmentPopover from "./attachment/TaskDetailAttachmentPopover"; import TaskDetailAttachmentPopover from "./attachment/TaskDetailAttachmentPopover";
import { DEFAULT_DATE_RANGE } from "./date/constants";
import TaskDetailDatePopover from "./date/TaskDetailDatePopover"; import TaskDetailDatePopover from "./date/TaskDetailDatePopover";
import { parseApiDate, toApiDate } from "./date/utils";
import type DateObject from "react-date-object"; import type DateObject from "react-date-object";
import { DEFAULT_CHECKLISTS } from "./checklist/constants";
import TaskDetailChecklistPopover from "./checklist/TaskDetailChecklistPopover"; import TaskDetailChecklistPopover from "./checklist/TaskDetailChecklistPopover";
import type { TaskChecklist } from "./checklist/types";
import { DEFAULT_LABELS } from "./labels/constants";
import TaskDetailLabelsPopover from "./labels/TaskDetailLabelsPopover"; import TaskDetailLabelsPopover from "./labels/TaskDetailLabelsPopover";
import type { LabelsView, TaskLabel } from "./labels/types"; import type { LabelsView, TaskLabel } from "./labels/types";
import TaskDetailActionBar from "./TaskDetailActionBar"; import TaskDetailActionBar from "./TaskDetailActionBar";
import TaskDetailMetadataPreview from "./TaskDetailMetadataPreview"; import TaskDetailMetadataPreview from "./TaskDetailMetadataPreview";
import type { TaskDetailTab } from "./types"; import type { TaskDetailTab } from "./types";
import { DEFAULT_USERS } from "./users/constants";
import TaskDetailUsersPopover from "./users/TaskDetailUsersPopover"; import TaskDetailUsersPopover from "./users/TaskDetailUsersPopover";
import type { TaskDetailType } from "../../task/types/TaskTypes";
import { useCreateCheckListItem, useCreateTaskAttachment, useCreateTaskRemark, useUpdateTask } from "../../task/hooks/useTaskData";
import { useSingleUpload } from "../../../service/hooks/useServiceData";
import { ErrorType } from "../../../../helpers/types";
type Props = { type Props = {
activeTab: TaskDetailTab | null; activeTab: TaskDetailTab | null;
onTabChange: (tab: TaskDetailTab) => void; onTabChange: (tab: TaskDetailTab) => void;
taskDetail?: TaskDetailType;
taskId: string;
}; };
const TaskDetailToolbar: FC<Props> = ({ activeTab, onTabChange }) => { const TaskDetailToolbar: FC<Props> = ({ activeTab, onTabChange, taskDetail, taskId }) => {
const { t } = useTranslation("global");
const createTaskRemark = useCreateTaskRemark();
const createCheckListItem = useCreateCheckListItem();
const createTaskAttachment = useCreateTaskAttachment();
const singleUpload = useSingleUpload();
const updateTask = useUpdateTask();
const getUsers = useGetUsers("");
const [labelsView, setLabelsView] = useState<LabelsView>("list"); const [labelsView, setLabelsView] = useState<LabelsView>("list");
const [labels, setLabels] = useState<TaskLabel[]>(DEFAULT_LABELS); const [labels, setLabels] = useState<TaskLabel[]>([]);
const [selectedLabelIds, setSelectedLabelIds] = useState<Set<string>>(new Set(["1"])); const [selectedLabelIds, setSelectedLabelIds] = useState<Set<string>>(new Set());
const [selectedUserIds, setSelectedUserIds] = useState<Set<string>>(new Set(["1"])); const [selectedUserIds, setSelectedUserIds] = useState<Set<string>>(new Set());
const [checklists, setChecklists] = useState<TaskChecklist[]>(DEFAULT_CHECKLISTS); const [startDate, setStartDate] = useState<DateObject | null>(null);
const [startDate, setStartDate] = useState<DateObject | null>(DEFAULT_DATE_RANGE.startDate); const [endDate, setEndDate] = useState<DateObject | null>(null);
const [endDate, setEndDate] = useState<DateObject | null>(DEFAULT_DATE_RANGE.endDate);
useEffect(() => {
if (!taskDetail) return;
const taskLabels = taskDetail.labels ?? taskDetail.remarks ?? [];
if (taskLabels.length) {
setLabels(taskLabels);
setSelectedLabelIds(new Set(taskLabels.map((label) => label.id)));
} else {
setLabels([]);
setSelectedLabelIds(new Set());
}
if (taskDetail.users?.length) {
setSelectedUserIds(new Set(taskDetail.users.map((user) => user.id)));
} else {
setSelectedUserIds(new Set());
}
setStartDate(parseApiDate(taskDetail.startDate));
setEndDate(parseApiDate(taskDetail.endDate));
}, [taskDetail]);
const selectedLabels = labels.filter((label) => selectedLabelIds.has(label.id)); const selectedLabels = labels.filter((label) => selectedLabelIds.has(label.id));
const selectedUsers = DEFAULT_USERS.filter((user) => selectedUserIds.has(user.id)); const users = useMemo(() => {
const apiUsers =
getUsers.data?.data?.users?.map((user) => ({
id: user.id,
name: `${user.firstName} ${user.lastName}`.trim(),
})) ?? [];
const taskUsers =
taskDetail?.users?.map((user) => ({
id: user.id,
name: `${user.firstName} ${user.lastName}`.trim(),
})) ?? [];
const userMap = new Map(apiUsers.map((user) => [user.id, user]));
taskUsers.forEach((user) => {
if (!userMap.has(user.id)) {
userMap.set(user.id, user);
}
});
return Array.from(userMap.values());
}, [getUsers.data, taskDetail?.users]);
const selectedUsers = users.filter((user) => selectedUserIds.has(user.id));
const isLabelsOpen = activeTab === "labels"; const isLabelsOpen = activeTab === "labels";
const isUsersOpen = activeTab === "users"; const isUsersOpen = activeTab === "users";
const isChecklistOpen = activeTab === "checklist"; const isChecklistOpen = activeTab === "checklist";
@@ -44,6 +99,19 @@ const TaskDetailToolbar: FC<Props> = ({ activeTab, onTabChange }) => {
onTabChange(tab); onTabChange(tab);
}; };
const handleAddLabelClick = () => {
setLabelsView("create");
if (activeTab !== "labels") {
onTabChange("labels");
}
};
const handleAddUserClick = () => {
if (activeTab !== "users") {
onTabChange("users");
}
};
const handleLabelToggle = (id: string) => { const handleLabelToggle = (id: string) => {
setSelectedLabelIds((prev) => { setSelectedLabelIds((prev) => {
const next = new Set(prev); const next = new Set(prev);
@@ -57,57 +125,162 @@ const TaskDetailToolbar: FC<Props> = ({ activeTab, onTabChange }) => {
}; };
const handleUserToggle = (id: string) => { const handleUserToggle = (id: string) => {
setSelectedUserIds((prev) => { const effectiveTaskId = taskId || taskDetail?.id;
const next = new Set(prev); if (!effectiveTaskId) return;
if (next.has(id)) {
next.delete(id); const previous = selectedUserIds;
} else { const next = new Set(previous);
next.add(id); if (next.has(id)) {
} next.delete(id);
return next; } else {
}); next.add(id);
}
setSelectedUserIds(next);
updateTask.mutate(
{ id: effectiveTaskId, params: { userIds: Array.from(next) } },
{
onSuccess: () => {
toast.success(t("success"));
},
onError: (error: ErrorType) => {
toast.error(error.response?.data?.error.message[0]);
setSelectedUserIds(previous);
},
},
);
}; };
const handleCreate = (title: string, color: string) => { const handleCreate = (title: string, color: string) => {
const newLabel: TaskLabel = { const effectiveTaskId = taskId || taskDetail?.id;
id: crypto.randomUUID(), if (!effectiveTaskId) return;
title,
color, createTaskRemark.mutate(
}; { title, color, taskId: effectiveTaskId },
setLabels((prev) => [...prev, newLabel]); {
setSelectedLabelIds((prev) => new Set([...prev, newLabel.id])); onSuccess: (response) => {
setLabelsView("list"); const remark =
response.data?.remark ??
(response as { remark?: TaskLabel }).remark ??
(response.data as TaskLabel | undefined);
if (remark?.id) {
const newLabel: TaskLabel = {
id: remark.id,
title: remark.title,
color: remark.color,
};
setLabels((prev) => [...prev, newLabel]);
setSelectedLabelIds((prev) => new Set([...prev, newLabel.id]));
}
setLabelsView("list");
toast.success(t("success"));
},
onError: (error: ErrorType) => {
toast.error(error.response?.data?.error.message[0]);
},
},
);
}; };
const handleChecklistClose = () => { const handleChecklistClose = () => {
if (isChecklistOpen) onTabChange("checklist"); if (isChecklistOpen) onTabChange("checklist");
}; };
const handleChecklistCreate = (title: string, _copyFromId: string) => { const handleChecklistCreate = (title: string) => {
const newChecklist: TaskChecklist = { const effectiveTaskId = taskId || taskDetail?.id;
id: crypto.randomUUID(), if (!effectiveTaskId) return;
title,
}; createCheckListItem.mutate(
setChecklists((prev) => [...prev, newChecklist]); { title, isDone: false, taskId: effectiveTaskId },
handleChecklistClose(); {
onSuccess: () => {
handleChecklistClose();
toast.success(t("success"));
},
onError: (error: ErrorType) => {
toast.error(error.response?.data?.error.message[0]);
},
},
);
}; };
const handleAttachmentClose = () => { const handleAttachmentClose = () => {
if (isAttachmentOpen) onTabChange("attachment"); if (isAttachmentOpen) onTabChange("attachment");
}; };
const handleAttachmentCreate = (_data: { file?: File; title: string; linkId: string }) => { const handleAttachmentCreate = async (data: { file?: File; title: string; linkId: string }) => {
handleAttachmentClose(); const effectiveTaskId = taskId || taskDetail?.id;
if (!effectiveTaskId) return;
let fileUrl = data.linkId.trim();
if (data.file) {
const formData = new FormData();
formData.append("file", data.file);
try {
const uploadResult = await singleUpload.mutateAsync(formData);
fileUrl = uploadResult?.data?.url ?? "";
} catch (error) {
toast.error((error as ErrorType).response?.data?.error.message[0]);
return;
}
}
if (!fileUrl) return;
const title = data.title.trim() || data.file?.name || "";
const type = data.file ? "file" : "link";
createTaskAttachment.mutate(
{ file: fileUrl, title, type, taskId: effectiveTaskId },
{
onSuccess: () => {
handleAttachmentClose();
toast.success(t("success"));
},
onError: (error: ErrorType) => {
toast.error(error.response?.data?.error.message[0]);
},
},
);
}; };
const isAttachmentSubmitting = singleUpload.isPending || createTaskAttachment.isPending;
const handleDateClose = () => { const handleDateClose = () => {
if (isDateOpen) onTabChange("date"); if (isDateOpen) onTabChange("date");
}; };
const handleDateSubmit = (data: { startDate: DateObject | null; endDate: DateObject | null }) => { const handleDateSubmit = (data: { startDate: DateObject | null; endDate: DateObject | null }) => {
const effectiveTaskId = taskId || taskDetail?.id;
if (!effectiveTaskId) return;
const previousStartDate = startDate;
const previousEndDate = endDate;
const params = {
startDate: data.startDate ? toApiDate(data.startDate) : null,
endDate: data.endDate ? toApiDate(data.endDate) : null,
};
setStartDate(data.startDate); setStartDate(data.startDate);
setEndDate(data.endDate); setEndDate(data.endDate);
handleDateClose();
updateTask.mutate(
{ id: effectiveTaskId, params },
{
onSuccess: () => {
handleDateClose();
toast.success(t("success"));
},
onError: (error: ErrorType) => {
toast.error(error.response?.data?.error.message[0]);
setStartDate(previousStartDate);
setEndDate(previousEndDate);
},
},
);
}; };
return ( return (
@@ -124,31 +297,48 @@ const TaskDetailToolbar: FC<Props> = ({ activeTab, onTabChange }) => {
onViewChange={setLabelsView} onViewChange={setLabelsView}
onToggle={handleLabelToggle} onToggle={handleLabelToggle}
onCreate={handleCreate} onCreate={handleCreate}
isSubmitting={createTaskRemark.isPending}
/> />
) : null ) : null
} }
usersPopover={ usersPopover={
isUsersOpen ? ( isUsersOpen ? (
<TaskDetailUsersPopover users={DEFAULT_USERS} selectedIds={selectedUserIds} onToggle={handleUserToggle} /> <TaskDetailUsersPopover
users={users}
selectedIds={selectedUserIds}
onToggle={handleUserToggle}
isLoading={getUsers.isPending}
isSubmitting={updateTask.isPending}
/>
) : null ) : null
} }
checklistPopover={ checklistPopover={
isChecklistOpen ? ( isChecklistOpen ? (
<TaskDetailChecklistPopover <TaskDetailChecklistPopover
checklists={checklists}
onClose={handleChecklistClose} onClose={handleChecklistClose}
onSubmit={handleChecklistCreate} onSubmit={handleChecklistCreate}
isSubmitting={createCheckListItem.isPending}
/> />
) : null ) : null
} }
attachmentPopover={ attachmentPopover={
isAttachmentOpen ? ( isAttachmentOpen ? (
<TaskDetailAttachmentPopover onClose={handleAttachmentClose} onSubmit={handleAttachmentCreate} /> <TaskDetailAttachmentPopover
onClose={handleAttachmentClose}
onSubmit={handleAttachmentCreate}
isSubmitting={isAttachmentSubmitting}
/>
) : null ) : null
} }
datePopover={ datePopover={
isDateOpen ? ( isDateOpen ? (
<TaskDetailDatePopover onClose={handleDateClose} onSubmit={handleDateSubmit} /> <TaskDetailDatePopover
onClose={handleDateClose}
onSubmit={handleDateSubmit}
initialStartDate={startDate}
initialEndDate={endDate}
isSubmitting={updateTask.isPending}
/>
) : null ) : null
} }
/> />
@@ -158,6 +348,8 @@ const TaskDetailToolbar: FC<Props> = ({ activeTab, onTabChange }) => {
selectedUsers={selectedUsers} selectedUsers={selectedUsers}
startDate={startDate} startDate={startDate}
endDate={endDate} endDate={endDate}
onAddLabel={handleAddLabelClick}
onAddUser={handleAddUserClick}
/> />
</div> </div>
); );
@@ -5,7 +5,7 @@ import { timeAgo } from "../../../../../config/func";
type Props = { type Props = {
title: string; title: string;
createdAt: string; createdAt?: string;
type: "file" | "link"; type: "file" | "link";
url?: string; url?: string;
onEdit: () => void; onEdit: () => void;
@@ -21,14 +21,14 @@ const AttachmentItem: FC<Props> = ({ title, createdAt, type, url, onEdit, onDele
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
{type === "link" && url ? ( {url ? (
<a href={url} target="_blank" rel="noopener noreferrer" className="text-xs font-bold text-[#0047FF] underline truncate block"> <a href={url} target="_blank" rel="noopener noreferrer" className="text-xs font-bold text-[#0047FF] underline truncate block">
{title} {title}
</a> </a>
) : ( ) : (
<div className="text-xs font-bold truncate">{title}</div> <div className="text-xs font-bold truncate">{title}</div>
)} )}
<div className="text-[11px] mt-0.5">{timeAgo(createdAt)}</div> {createdAt && <div className="text-[11px] mt-0.5">{timeAgo(createdAt)}</div>}
</div> </div>
</div> </div>
@@ -2,29 +2,27 @@ import { type FC } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import AttachmentItem from "./AttachmentItem"; import AttachmentItem from "./AttachmentItem";
import type { TaskAttachment } from "./types"; import type { TaskAttachment } from "./types";
import type { TaskAttachmentType } from "../../../task/types/TaskTypes";
const AttachmentList: FC = () => { type Props = {
attachments?: TaskAttachmentType[];
};
const AttachmentList: FC<Props> = ({ attachments = [] }) => {
const { t } = useTranslation("global"); const { t } = useTranslation("global");
const attachments: TaskAttachment[] = [ const mappedAttachments: TaskAttachment[] = attachments.map((item) => ({
{ id: item.id,
id: "1", title: item.title,
title: "file.pdf", type: item.type,
fileName: "file.pdf", file: item.file,
createdAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), createdAt: item.createdAt,
}, }));
{
id: "2",
title: "لینک طرح",
url: "https://example.com",
createdAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
},
];
const files = attachments.filter((item) => item.fileName); const files = mappedAttachments.filter((item) => item.type === "file");
const links = attachments.filter((item) => item.url); const links = mappedAttachments.filter((item) => item.type === "link");
if (attachments.length === 0) return null; if (mappedAttachments.length === 0) return null;
return ( return (
<div className="mt-6"> <div className="mt-6">
@@ -38,9 +36,10 @@ const AttachmentList: FC = () => {
{files.map((item) => ( {files.map((item) => (
<AttachmentItem <AttachmentItem
key={item.id} key={item.id}
title={item.fileName ?? item.title} title={item.title}
createdAt={item.createdAt ?? ""} createdAt={item.createdAt}
type="file" type="file"
url={item.file}
onEdit={() => {}} onEdit={() => {}}
onDelete={() => {}} onDelete={() => {}}
/> />
@@ -60,7 +59,7 @@ const AttachmentList: FC = () => {
title={item.title} title={item.title}
createdAt={item.createdAt} createdAt={item.createdAt}
type="link" type="link"
url={item.url} url={item.file}
onEdit={() => {}} onEdit={() => {}}
onDelete={() => {}} onDelete={() => {}}
/> />
@@ -7,9 +7,10 @@ import Select from "../../../../../components/Select";
type Props = { type Props = {
onClose: () => void; onClose: () => void;
onSubmit: (data: { file?: File; title: string; linkId: string }) => void; onSubmit: (data: { file?: File; title: string; linkId: string }) => void;
isSubmitting?: boolean;
}; };
const TaskDetailAttachmentForm: FC<Props> = ({ onClose, onSubmit }) => { const TaskDetailAttachmentForm: FC<Props> = ({ onClose, onSubmit, isSubmitting = false }) => {
const { t } = useTranslation("global"); const { t } = useTranslation("global");
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
@@ -64,10 +65,10 @@ const TaskDetailAttachmentForm: FC<Props> = ({ onClose, onSubmit }) => {
/> />
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button type="button" onClick={onClose} className="h-8 bg-[#E8E4F0] text-[#292D32] rounded-xl text-xs w-[95px]"> <Button type="button" onClick={onClose} disabled={isSubmitting} className="h-8 bg-[#E8E4F0] text-[#292D32] rounded-xl text-xs w-[95px]">
{t("cancel")} {t("cancel")}
</Button> </Button>
<Button type="button" onClick={handleSubmit} disabled={!canSubmit} className="h-8 bg-[#292D32] text-white rounded-xl text-xs disabled:opacity-50 w-[95px]"> <Button type="button" onClick={handleSubmit} disabled={!canSubmit || isSubmitting} isLoading={isSubmitting} className="h-8 bg-[#292D32] text-white rounded-xl text-xs disabled:opacity-50 w-[95px]">
{t("taskmanager.task_detail.insert")} {t("taskmanager.task_detail.insert")}
</Button> </Button>
</div> </div>
@@ -4,10 +4,11 @@ import TaskDetailAttachmentForm from "./TaskDetailAttachmentForm";
type Props = { type Props = {
onClose: () => void; onClose: () => void;
onSubmit: (data: { file?: File; title: string; linkId: string }) => void; onSubmit: (data: { file?: File; title: string; linkId: string }) => void;
isSubmitting?: boolean;
}; };
const TaskDetailAttachmentPopover: FC<Props> = ({ onClose, onSubmit }) => { const TaskDetailAttachmentPopover: FC<Props> = ({ onClose, onSubmit, isSubmitting }) => {
return <TaskDetailAttachmentForm onClose={onClose} onSubmit={onSubmit} />; return <TaskDetailAttachmentForm onClose={onClose} onSubmit={onSubmit} isSubmitting={isSubmitting} />;
}; };
export default TaskDetailAttachmentPopover; export default TaskDetailAttachmentPopover;
@@ -1,8 +1,7 @@
export type TaskAttachment = { export type TaskAttachment = {
id: string; id: string;
title: string; title: string;
createdAt: string; type: "file" | "link";
linkId?: string; file?: string;
fileName?: string; createdAt?: string;
url?: string;
}; };
@@ -1,26 +1,63 @@
import { Edit } from "iconsax-react"; import { Edit } from "iconsax-react";
import { useTranslation } from "react-i18next";
import { toast } from "react-toastify";
import ProgressBar from "../../../../../components/ProgressBar"; import ProgressBar from "../../../../../components/ProgressBar";
import TrashWithConfrim from "../../../../../components/TrashWithConfrim"; import TrashWithConfrim from "../../../../../components/TrashWithConfrim";
import { ErrorType } from "../../../../../helpers/types";
import { useUpdateCheckListItem } from "../../../task/hooks/useTaskData";
import type { TaskChecklistItemType } from "../../../task/types/TaskTypes";
import ChecklistItem from "./ChecklistItem"; import ChecklistItem from "./ChecklistItem";
const CheckLists = () => { type Props = {
const items = [ taskId: string;
{ id: "1", title: "آیتم ۱", checked: true }, checkListItems?: TaskChecklistItemType[];
{ id: "2", title: "آیتم ۱", checked: true }, checkListItemCount?: number;
{ id: "3", title: "آیتم ۱", checked: true }, completedCheckListItemCount?: number;
]; };
const CheckLists = ({
taskId,
checkListItems = [],
checkListItemCount,
completedCheckListItemCount,
}: Props) => {
const { t } = useTranslation("global");
const updateCheckListItem = useUpdateCheckListItem();
const items = checkListItems.map((item) => ({
id: item.id,
title: item.title,
checked: item.isDone,
}));
const totalCount = checkListItemCount ?? items.length;
const doneCount = completedCheckListItemCount ?? items.filter((item) => item.checked).length;
const progress = totalCount > 0 ? Math.round((doneCount / totalCount) * 100) : 0;
const handleToggle = (id: string, checked: boolean) => {
updateCheckListItem.mutate(
{ id, params: { isDone: !checked }, taskId },
{
onError: (error: ErrorType) => {
toast.error(error.response?.data?.error.message[0]);
},
},
);
};
if (checkListItems.length === 0) return null;
return ( return (
<div className="mt-6"> <div className="mt-6">
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<div className="text-sm font-bold mt-px">چک لیست</div> <div className="text-sm font-bold mt-px">{t("taskmanager.task_detail.checklist")}</div>
<Edit size={20} color="#0047FF" /> <Edit size={20} color="#0047FF" />
<TrashWithConfrim onDelete={() => {}} color="#FF0000" /> <TrashWithConfrim onDelete={() => {}} color="#FF0000" />
</div> </div>
<div className="mt-4 flex gap-2 items-center"> <div className="mt-4 flex gap-2 items-center">
<div className="text-xs shrink-0">۲۵٪</div> <div className="text-xs shrink-0">{progress}٪</div>
<ProgressBar value={25} /> <ProgressBar value={progress} />
</div> </div>
<div className="mt-4 flex flex-col gap-2"> <div className="mt-4 flex flex-col gap-2">
@@ -29,7 +66,7 @@ const CheckLists = () => {
key={item.id} key={item.id}
title={item.title} title={item.title}
checked={item.checked} checked={item.checked}
onToggle={() => {}} onToggle={() => handleToggle(item.id, item.checked)}
onEdit={() => {}} onEdit={() => {}}
onDelete={() => {}} onDelete={() => {}}
/> />
@@ -3,26 +3,21 @@ import { type FC, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import Button from "../../../../../components/Button"; import Button from "../../../../../components/Button";
import Input from "../../../../../components/Input"; import Input from "../../../../../components/Input";
import Select from "../../../../../components/Select";
import type { TaskChecklist } from "./types";
type Props = { type Props = {
checklists: TaskChecklist[];
onClose: () => void; onClose: () => void;
onSubmit: (title: string, copyFromId: string) => void; onSubmit: (title: string) => void;
isSubmitting?: boolean;
}; };
const TaskDetailChecklistForm: FC<Props> = ({ checklists, onClose, onSubmit }) => { const TaskDetailChecklistForm: FC<Props> = ({ onClose, onSubmit, isSubmitting = false }) => {
const { t } = useTranslation("global"); const { t } = useTranslation("global");
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [copyFromId, setCopyFromId] = useState("");
const copyFromItems = [{ value: "", label: t("taskmanager.task_detail.none") }, ...checklists.map((checklist) => ({ value: checklist.id, label: checklist.title }))];
const handleSubmit = () => { const handleSubmit = () => {
const trimmed = title.trim(); const trimmed = title.trim();
if (!trimmed) return; if (!trimmed) return;
onSubmit(trimmed, copyFromId); onSubmit(trimmed);
}; };
return ( return (
@@ -36,20 +31,16 @@ const TaskDetailChecklistForm: FC<Props> = ({ checklists, onClose, onSubmit }) =
<Input label={t("taskmanager.task_detail.label_title")} value={title} onChange={(e) => setTitle(e.target.value)} className="h-8 bg-white/63 border-white" labelClassName="text-xs" /> <Input label={t("taskmanager.task_detail.label_title")} value={title} onChange={(e) => setTitle(e.target.value)} className="h-8 bg-white/63 border-white" labelClassName="text-xs" />
<Select
label={t("taskmanager.task_detail.copy_from")}
labelClassName="text-xs"
value={copyFromId}
onChange={(e) => setCopyFromId(e.target.value)}
items={copyFromItems}
className="h-8 bg-white/63 border-white text-xs rounded-xl"
/>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button type="button" onClick={onClose} className="h-8 bg-[#E8E4F0] text-[#292D32] rounded-xl text-xs w-[95px]"> <Button type="button" onClick={onClose} className="h-8 bg-[#E8E4F0] text-[#292D32] rounded-xl text-xs w-[95px]">
{t("cancel")} {t("cancel")}
</Button> </Button>
<Button type="button" onClick={handleSubmit} disabled={!title.trim()} className="h-8 bg-[#292D32] text-white rounded-xl text-xs disabled:opacity-50 w-[95px]"> <Button
type="button"
onClick={handleSubmit}
disabled={!title.trim() || isSubmitting}
className="h-8 bg-[#292D32] text-white rounded-xl text-xs disabled:opacity-50 w-[95px]"
>
{t("save")} {t("save")}
</Button> </Button>
</div> </div>
@@ -1,15 +1,14 @@
import { type FC } from "react"; import { type FC } from "react";
import TaskDetailChecklistForm from "./TaskDetailChecklistForm"; import TaskDetailChecklistForm from "./TaskDetailChecklistForm";
import type { TaskChecklist } from "./types";
type Props = { type Props = {
checklists: TaskChecklist[];
onClose: () => void; onClose: () => void;
onSubmit: (title: string, copyFromId: string) => void; onSubmit: (title: string) => void;
isSubmitting?: boolean;
}; };
const TaskDetailChecklistPopover: FC<Props> = ({ checklists, onClose, onSubmit }) => { const TaskDetailChecklistPopover: FC<Props> = ({ onClose, onSubmit, isSubmitting }) => {
return <TaskDetailChecklistForm checklists={checklists} onClose={onClose} onSubmit={onSubmit} />; return <TaskDetailChecklistForm onClose={onClose} onSubmit={onSubmit} isSubmitting={isSubmitting} />;
}; };
export default TaskDetailChecklistPopover; export default TaskDetailChecklistPopover;
@@ -1,6 +0,0 @@
import type { TaskChecklist } from "./types";
export const DEFAULT_CHECKLISTS: TaskChecklist[] = [
{ id: "1", title: "چک لیست پروژه" },
{ id: "2", title: "چک لیست انتشار" },
];
@@ -1,4 +0,0 @@
export type TaskChecklist = {
id: string;
title: string;
};
@@ -12,14 +12,23 @@ import { formatDateLabel } from "./utils";
type Props = { type Props = {
onClose: () => void; onClose: () => void;
onSubmit: (data: { startDate: DateObject | null; endDate: DateObject | null }) => void; onSubmit: (data: { startDate: DateObject | null; endDate: DateObject | null }) => void;
initialStartDate?: DateObject | null;
initialEndDate?: DateObject | null;
isSubmitting?: boolean;
}; };
const TaskDetailDateForm: FC<Props> = ({ onClose, onSubmit }) => { const TaskDetailDateForm: FC<Props> = ({
onClose,
onSubmit,
initialStartDate = null,
initialEndDate = null,
isSubmitting = false,
}) => {
const { t } = useTranslation("global"); const { t } = useTranslation("global");
const [startDate, setStartDate] = useState<DateObject | null>(null); const [startDate, setStartDate] = useState<DateObject | null>(initialStartDate);
const [endDate, setEndDate] = useState<DateObject | null>(null); const [endDate, setEndDate] = useState<DateObject | null>(initialEndDate);
const [startEnabled, setStartEnabled] = useState(true); const [startEnabled, setStartEnabled] = useState(Boolean(initialStartDate) || !initialEndDate);
const [endEnabled, setEndEnabled] = useState(false); const [endEnabled, setEndEnabled] = useState(Boolean(initialEndDate));
const [activeField, setActiveField] = useState<TaskDateField>("start"); const [activeField, setActiveField] = useState<TaskDateField>("start");
const calendarValue = (() => { const calendarValue = (() => {
@@ -135,10 +144,21 @@ const TaskDetailDateForm: FC<Props> = ({ onClose, onSubmit }) => {
</div> </div>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button type="button" onClick={onClose} className="h-8 bg-[#E8E4F0] text-[#292D32] rounded-xl text-xs w-[95px]"> <Button
type="button"
onClick={onClose}
disabled={isSubmitting}
className="h-8 bg-[#E8E4F0] text-[#292D32] rounded-xl text-xs w-[95px]"
>
{t("cancel")} {t("cancel")}
</Button> </Button>
<Button type="button" onClick={handleSubmit} disabled={!canSubmit} className="h-8 bg-[#292D32] text-white rounded-xl text-xs disabled:opacity-50 w-[95px]"> <Button
type="button"
onClick={handleSubmit}
disabled={!canSubmit || isSubmitting}
isLoading={isSubmitting}
className="h-8 bg-[#292D32] text-white rounded-xl text-xs disabled:opacity-50 w-[95px]"
>
{t("taskmanager.task_detail.insert")} {t("taskmanager.task_detail.insert")}
</Button> </Button>
</div> </div>
@@ -5,10 +5,27 @@ import TaskDetailDateForm from "./TaskDetailDateForm";
type Props = { type Props = {
onClose: () => void; onClose: () => void;
onSubmit: (data: { startDate: DateObject | null; endDate: DateObject | null }) => void; onSubmit: (data: { startDate: DateObject | null; endDate: DateObject | null }) => void;
initialStartDate?: DateObject | null;
initialEndDate?: DateObject | null;
isSubmitting?: boolean;
}; };
const TaskDetailDatePopover: FC<Props> = ({ onClose, onSubmit }) => { const TaskDetailDatePopover: FC<Props> = ({
return <TaskDetailDateForm onClose={onClose} onSubmit={onSubmit} />; onClose,
onSubmit,
initialStartDate,
initialEndDate,
isSubmitting,
}) => {
return (
<TaskDetailDateForm
onClose={onClose}
onSubmit={onSubmit}
initialStartDate={initialStartDate}
initialEndDate={initialEndDate}
isSubmitting={isSubmitting}
/>
);
}; };
export default TaskDetailDatePopover; export default TaskDetailDatePopover;
@@ -1,7 +1,19 @@
import DateObject from "react-date-object"; import DateObject from "react-date-object";
import gregorian from "react-date-object/calendars/gregorian";
import persian from "react-date-object/calendars/persian"; import persian from "react-date-object/calendars/persian";
import gregorian_en from "react-date-object/locales/gregorian_en";
import persian_fa from "react-date-object/locales/persian_fa"; import persian_fa from "react-date-object/locales/persian_fa";
export const parseApiDate = (value?: string | null): DateObject | null => {
if (!value) return null;
const date = new DateObject({ date: value, calendar: gregorian });
return date.isValid ? date : null;
};
export const toApiDate = (date: DateObject): string => {
return date.convert(gregorian, gregorian_en).format("YYYY-MM-DD");
};
export const formatDateLabel = (date: DateObject | null) => { export const formatDateLabel = (date: DateObject | null) => {
if (!date) return ""; if (!date) return "";
return date.convert(persian, persian_fa).format("D MMMM"); return date.convert(persian, persian_fa).format("D MMMM");
@@ -10,6 +10,7 @@ type Props = {
onViewChange: (view: LabelsView) => void; onViewChange: (view: LabelsView) => void;
onToggle: (id: string) => void; onToggle: (id: string) => void;
onCreate: (title: string, color: string) => void; onCreate: (title: string, color: string) => void;
isSubmitting?: boolean;
}; };
const TaskDetailLabelsPopover: FC<Props> = ({ const TaskDetailLabelsPopover: FC<Props> = ({
@@ -19,6 +20,7 @@ const TaskDetailLabelsPopover: FC<Props> = ({
onViewChange, onViewChange,
onToggle, onToggle,
onCreate, onCreate,
isSubmitting = false,
}) => { }) => {
return ( return (
<> <>
@@ -34,6 +36,7 @@ const TaskDetailLabelsPopover: FC<Props> = ({
<TaskDetailNewLabelForm <TaskDetailNewLabelForm
onBack={() => onViewChange("list")} onBack={() => onViewChange("list")}
onSubmit={onCreate} onSubmit={onCreate}
isSubmitting={isSubmitting}
/> />
)} )}
</> </>
@@ -10,9 +10,10 @@ import { LABEL_COLOR_OPTIONS } from "./constants";
type Props = { type Props = {
onBack: () => void; onBack: () => void;
onSubmit: (title: string, color: string) => void; onSubmit: (title: string, color: string) => void;
isSubmitting?: boolean;
}; };
const TaskDetailNewLabelForm: FC<Props> = ({ onBack, onSubmit }) => { const TaskDetailNewLabelForm: FC<Props> = ({ onBack, onSubmit, isSubmitting = false }) => {
const { t } = useTranslation("global"); const { t } = useTranslation("global");
const colorInputRef = useRef<HTMLInputElement>(null); const colorInputRef = useRef<HTMLInputElement>(null);
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
@@ -48,7 +49,8 @@ const TaskDetailNewLabelForm: FC<Props> = ({ onBack, onSubmit }) => {
<Button <Button
type="button" type="button"
onClick={handleSubmit} onClick={handleSubmit}
disabled={!title.trim()} disabled={!title.trim() || isSubmitting}
isLoading={isSubmitting}
className="flex items-center justify-center gap-2 bg-white border border-[#D0D0D0] text-[#292D32] rounded-[6px] h-7 text-xs disabled:opacity-50" className="flex items-center justify-center gap-2 bg-white border border-[#D0D0D0] text-[#292D32] rounded-[6px] h-7 text-xs disabled:opacity-50"
> >
<AddCircle size={14} color="#292D32" /> <AddCircle size={14} color="#292D32" />
@@ -1,15 +1,18 @@
import { type FC, useMemo, useState } from "react"; import { type FC, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import Input from "../../../../../components/Input"; import Input from "../../../../../components/Input";
import TaskDetailLabelCheckbox from "../labels/TaskDetailLabelCheckbox";
import type { TaskUser } from "./types"; import type { TaskUser } from "./types";
type Props = { type Props = {
users: TaskUser[]; users: TaskUser[];
selectedIds: Set<string>; selectedIds: Set<string>;
onToggle: (id: string) => void; onToggle: (id: string) => void;
isLoading?: boolean;
isSubmitting?: boolean;
}; };
const TaskDetailUsersList: FC<Props> = ({ users }) => { const TaskDetailUsersList: FC<Props> = ({ users, selectedIds, onToggle, isLoading = false, isSubmitting = false }) => {
const { t } = useTranslation("global"); const { t } = useTranslation("global");
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
@@ -30,17 +33,31 @@ const TaskDetailUsersList: FC<Props> = ({ users }) => {
<div> <div>
<div className="text-xs font-medium mb-2">{t("taskmanager.task_detail.members")}</div> <div className="text-xs font-medium mb-2">{t("taskmanager.task_detail.members")}</div>
<div className="flex flex-col gap-2"> {isLoading ? (
{filteredUsers.map((user) => { <div className="text-[10px] text-description text-center py-2">{t("loading")}</div>
return ( ) : (
<div key={user.id} className="flex gap-1.5 items-center"> <div className="flex flex-col gap-2 max-h-48 overflow-y-auto">
<div className="size-6 rounded-full bg-gray-400"></div> {filteredUsers.map((user) => {
<div className="text-[10px]">{user.name}</div> const isSelected = selectedIds.has(user.id);
</div>
); return (
})} <div key={user.id} className="flex gap-1.5 items-center">
</div> <TaskDetailLabelCheckbox
checked={isSelected}
onToggle={() => onToggle(user.id)}
ariaLabel={user.name}
/>
<div className="size-6 rounded-full bg-gray-400 shrink-0"></div>
<div className="text-[10px]">{user.name}</div>
</div>
);
})}
{!filteredUsers.length ? <div className="text-[10px] text-description">{t("taskmanager.no_users_added")}</div> : null}
</div>
)}
</div> </div>
{isSubmitting ? <div className="text-[10px] text-description text-center">{t("loading")}</div> : null}
</div> </div>
); );
}; };
@@ -6,10 +6,20 @@ type Props = {
users: TaskUser[]; users: TaskUser[];
selectedIds: Set<string>; selectedIds: Set<string>;
onToggle: (id: string) => void; onToggle: (id: string) => void;
isLoading?: boolean;
isSubmitting?: boolean;
}; };
const TaskDetailUsersPopover: FC<Props> = ({ users, selectedIds, onToggle }) => { const TaskDetailUsersPopover: FC<Props> = ({ users, selectedIds, onToggle, isLoading, isSubmitting }) => {
return <TaskDetailUsersList users={users} selectedIds={selectedIds} onToggle={onToggle} />; return (
<TaskDetailUsersList
users={users}
selectedIds={selectedIds}
onToggle={onToggle}
isLoading={isLoading}
isSubmitting={isSubmitting}
/>
);
}; };
export default TaskDetailUsersPopover; export default TaskDetailUsersPopover;
+142 -8
View File
@@ -1,20 +1,110 @@
import { FC, useState } from "react";
import { TickCircle } from "iconsax-react"; import { TickCircle } from "iconsax-react";
import { useFormik } from "formik";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate, useSearchParams } from "react-router-dom";
import { toast } from "react-toastify";
import * as Yup from "yup";
import moment from "moment-jalaali";
import Button from "../../../components/Button"; import Button from "../../../components/Button";
import DatePickerComponent from "../../../components/DatePicker"; import DatePickerComponent from "../../../components/DatePicker";
import Input from "../../../components/Input"; import Input from "../../../components/Input";
import Textarea from "../../../components/Textarea"; import Textarea from "../../../components/Textarea";
import CreateProjectSidebar from "./components/CreateProjectSidebar"; import { Pages } from "../../../config/Pages";
import { ErrorType } from "../../../helpers/types";
import { useSingleUpload } from "../../service/hooks/useServiceData";
import CreateProjectSidebar, {
BACKGROUND_PRESETS,
SelectedUser,
} from "./components/CreateProjectSidebar";
import { useCreateProject } from "./hooks/useProjectData";
import { CreateProjectType } from "./types/ProjectTypes";
const CreateProject = () => { const CreateProject: FC = () => {
const { t } = useTranslation("global"); const { t } = useTranslation("global");
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const createProject = useCreateProject();
const singleUpload = useSingleUpload();
const [isActive, setIsActive] = useState(true);
const [selectedColor, setSelectedColor] = useState("#A8E6CF");
const [selectedUsers, setSelectedUsers] = useState<SelectedUser[]>([]);
const [selectedBackground, setSelectedBackground] = useState<string>(BACKGROUND_PRESETS[0]);
const [backgroundImage, setBackgroundImage] = useState<File | null>(null);
const [workspaceId, setWorkspaceId] = useState(searchParams.get("workspaceId") ?? "");
const handleCreateProject = async (values: CreateProjectType) => {
createProject.mutate(values, {
onSuccess: () => {
toast.success(t("success"));
navigate(`${Pages.taskmanager.projectList}${workspaceId}`);
},
onError: (error: ErrorType) => {
toast.error(error.response?.data?.error.message[0]);
},
});
};
const formik = useFormik<
Pick<CreateProjectType, "name" | "ownerName" | "description" | "startDate">
>({
initialValues: {
name: "",
ownerName: "",
description: "",
startDate: "",
},
validationSchema: Yup.object({
name: Yup.string().required(t("errors.required")),
ownerName: Yup.string().required(t("errors.required")),
description: Yup.string(),
startDate: Yup.string().required(t("errors.required")),
}),
onSubmit: async (values) => {
if (!workspaceId) {
toast.error(t("taskmanager.select_workspace"));
return;
}
let backgroundPicture = "";
if (backgroundImage) {
const formData = new FormData();
formData.append("file", backgroundImage);
try {
const uploadResult = await singleUpload.mutateAsync(formData);
backgroundPicture = uploadResult?.data?.url ?? "";
} catch (error) {
const uploadError = error as ErrorType;
toast.error(uploadError.response?.data?.error.message[0]);
return;
}
}
handleCreateProject({
...values,
startDate: moment(values.startDate, "jYYYY/jMM/jDD").format("YYYY-MM-DD"),
backgroundPicture,
backgroundColor: selectedColor,
isActive,
workspaceId,
userIds: selectedUsers.map((user) => user.id),
});
},
});
return ( return (
<div className="w-full mt-4"> <div className="w-full mt-4">
<div className="flex w-full justify-between items-center"> <div className="flex w-full justify-between items-center">
<div>{t("taskmanager.new_project")}</div> <div>{t("taskmanager.new_project")}</div>
<div> <div>
<Button className="px-5" isLoading={false}> <Button
className="px-5"
isLoading={createProject.isPending || singleUpload.isPending}
onClick={() => formik.handleSubmit()}
>
<div className="flex gap-2"> <div className="flex gap-2">
<TickCircle className="size-5" color="#fff" /> <TickCircle className="size-5" color="#fff" />
<div>{t("taskmanager.submit_project")}</div> <div>{t("taskmanager.submit_project")}</div>
@@ -29,21 +119,65 @@ const CreateProject = () => {
<div className="text-sm font-bold">{t("taskmanager.project_info")}</div> <div className="text-sm font-bold">{t("taskmanager.project_info")}</div>
<div className="mt-8"> <div className="mt-8">
<Input label={t("taskmanager.project_name")} /> <Input
label={t("taskmanager.project_name")}
{...formik.getFieldProps("name")}
error_text={
formik.touched.name && formik.errors.name ? formik.errors.name : ""
}
/>
</div> </div>
<div className="mt-8 rowTwoInput"> <div className="mt-8 rowTwoInput">
<DatePickerComponent label={t("taskmanager.project_start_date")} placeholder={t("taskmanager.select_date")} onChange={() => undefined} /> <DatePickerComponent
<Input label={t("taskmanager.employer_name")} /> label={t("taskmanager.project_start_date")}
placeholder={t("taskmanager.select_date")}
onChange={(value) => formik.setFieldValue("startDate", value)}
error_text={
formik.touched.startDate && formik.errors.startDate
? formik.errors.startDate
: ""
}
/>
<Input
label={t("taskmanager.employer_name")}
{...formik.getFieldProps("ownerName")}
error_text={
formik.touched.ownerName && formik.errors.ownerName
? formik.errors.ownerName
: ""
}
/>
</div> </div>
<div className="mt-8"> <div className="mt-8">
<Textarea label={t("description")} /> <Textarea
label={t("description")}
{...formik.getFieldProps("description")}
error_text={
formik.touched.description && formik.errors.description
? formik.errors.description
: ""
}
/>
</div> </div>
</div> </div>
</div> </div>
<CreateProjectSidebar /> <CreateProjectSidebar
isActive={isActive}
onIsActiveChange={setIsActive}
selectedColor={selectedColor}
onColorChange={setSelectedColor}
selectedUsers={selectedUsers}
onSelectedUsersChange={setSelectedUsers}
selectedBackground={selectedBackground}
onBackgroundChange={setSelectedBackground}
backgroundImage={backgroundImage}
onBackgroundImageChange={setBackgroundImage}
workspaceId={workspaceId}
onWorkspaceIdChange={setWorkspaceId}
/>
</div> </div>
</div> </div>
); );
+31 -13
View File
@@ -1,29 +1,47 @@
import { useState } from "react"; import { FC, useState } from "react";
import projectsData from "./data/projects.json"; import { useParams } from "react-router-dom";
import PageLoading from "../../../components/PageLoading";
import { useGetWorkspaceDetail } from "../workspace/hooks/useWorkspaceData";
import { WorkspaceDetailType } from "../workspace/types/WorkspaceTypes";
import ProjectGrid from "./components/ProjectGrid"; import ProjectGrid from "./components/ProjectGrid";
import ProjectListHeader from "./components/ProjectListHeader"; import ProjectListHeader from "./components/ProjectListHeader";
import WorkspaceInfoBanner from "./components/WorkspaceInfoBanner"; import WorkspaceInfoBanner from "./components/WorkspaceInfoBanner";
import type { ProjectItem, WorkspaceInfo } from "./types/ProjectTypes"; import { useGetProjectsByWorkspace } from "./hooks/useProjectData";
import type { ProjectItem } from "./types/ProjectTypes";
const List = () => { const List: FC = () => {
const { workspace, projects: initialProjects } = projectsData as { const { workspaceId = "" } = useParams();
workspace: WorkspaceInfo; const getProjects = useGetProjectsByWorkspace(workspaceId);
projects: ProjectItem[]; const getWorkspaceDetail = useGetWorkspaceDetail(workspaceId);
};
const [projects, setProjects] = useState<ProjectItem[]>(initialProjects); const workspace: WorkspaceDetailType | undefined =
getWorkspaceDetail.data?.data?.workspace ?? getWorkspaceDetail.data?.data;
const apiProjects: ProjectItem[] =
getProjects.data?.data?.projects ?? getProjects.data?.data ?? [];
const [deletedIds, setDeletedIds] = useState<string[]>([]);
const projects = apiProjects.filter((project) => !deletedIds.includes(project.id));
const handleDelete = (id: string) => { const handleDelete = (id: string) => {
setProjects((prev) => prev.filter((project) => project.id !== id)); setDeletedIds((prev) => [...prev, id]);
}; };
const isLoading = getProjects.isPending || getWorkspaceDetail.isPending;
return ( return (
<div className="w-full mt-4"> <div className="w-full mt-4">
<ProjectListHeader title={workspace.name} /> <ProjectListHeader title={workspace?.name ?? ""} workspaceId={workspaceId} />
<WorkspaceInfoBanner description={workspace.description} /> {workspace?.description ? (
<WorkspaceInfoBanner description={workspace.description} />
) : null}
<ProjectGrid projects={projects} onDelete={handleDelete} /> {isLoading ? (
<PageLoading />
) : (
<ProjectGrid projects={projects} onDelete={handleDelete} />
)}
</div> </div>
); );
}; };
+242
View File
@@ -0,0 +1,242 @@
import { FC, useEffect, useState } from "react";
import { TickCircle } from "iconsax-react";
import { useFormik } from "formik";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "react-router-dom";
import { toast } from "react-toastify";
import * as Yup from "yup";
import moment from "moment-jalaali";
import Button from "../../../components/Button";
import DatePickerComponent from "../../../components/DatePicker";
import Input from "../../../components/Input";
import PageLoading from "../../../components/PageLoading";
import Textarea from "../../../components/Textarea";
import { Pages } from "../../../config/Pages";
import { ErrorType } from "../../../helpers/types";
import { useSingleUpload } from "../../service/hooks/useServiceData";
import CreateProjectSidebar, {
BACKGROUND_PRESETS,
SelectedUser,
} from "./components/CreateProjectSidebar";
import { useGetProjectDetail, useUpdateProject } from "./hooks/useProjectData";
import { ProjectDetailType, UpdateProjectType } from "./types/ProjectTypes";
const UpdateProject: FC = () => {
const { t } = useTranslation("global");
const { id = "" } = useParams();
const navigate = useNavigate();
const getProjectDetail = useGetProjectDetail(id);
const updateProject = useUpdateProject();
const singleUpload = useSingleUpload();
const [isActive, setIsActive] = useState(true);
const [selectedColor, setSelectedColor] = useState("#A8E6CF");
const [selectedUsers, setSelectedUsers] = useState<SelectedUser[]>([]);
const [selectedBackground, setSelectedBackground] = useState<string>(BACKGROUND_PRESETS[0]);
const [backgroundImage, setBackgroundImage] = useState<File | null>(null);
const [workspaceId, setWorkspaceId] = useState("");
const [existingBackgroundPicture, setExistingBackgroundPicture] = useState("");
const [startDateDefault, setStartDateDefault] = useState("");
const project: ProjectDetailType | undefined =
getProjectDetail.data?.data?.project ?? getProjectDetail.data?.data;
const handleUpdateProject = (values: UpdateProjectType) => {
if (!id) return;
updateProject.mutate(
{ id, params: values },
{
onSuccess: () => {
toast.success(t("success"));
navigate(`${Pages.taskmanager.projectList}${values.workspaceId}`);
},
onError: (error: ErrorType) => {
toast.error(error.response?.data?.error.message[0]);
},
},
);
};
const formik = useFormik<
Pick<UpdateProjectType, "name" | "ownerName" | "description" | "startDate">
>({
initialValues: {
name: "",
ownerName: "",
description: "",
startDate: "",
},
validationSchema: Yup.object({
name: Yup.string().required(t("errors.required")),
ownerName: Yup.string().required(t("errors.required")),
description: Yup.string(),
startDate: Yup.string().required(t("errors.required")),
}),
onSubmit: async (values) => {
if (!workspaceId) {
toast.error(t("taskmanager.select_workspace"));
return;
}
let backgroundPicture = existingBackgroundPicture;
if (backgroundImage) {
const formData = new FormData();
formData.append("file", backgroundImage);
try {
const uploadResult = await singleUpload.mutateAsync(formData);
backgroundPicture = uploadResult?.data?.url ?? "";
} catch (error) {
const uploadError = error as ErrorType;
toast.error(uploadError.response?.data?.error.message[0]);
return;
}
}
handleUpdateProject({
...values,
startDate: moment(values.startDate, "jYYYY/jMM/jDD").format("YYYY-MM-DD"),
backgroundPicture,
backgroundColor: selectedColor,
isActive,
workspaceId,
userIds: selectedUsers.map((user) => user.id),
});
},
});
useEffect(() => {
if (!project) return;
formik.setValues({
name: project.name,
ownerName: project.ownerName,
description: project.description ?? "",
startDate: project.startDate
? moment(project.startDate, "YYYY-MM-DD").format("jYYYY/jMM/jDD")
: "",
});
setIsActive(project.isActive);
setSelectedColor(project.backgroundColor ?? "#A8E6CF");
setWorkspaceId(project.workspaceId);
setExistingBackgroundPicture(project.backgroundPicture ?? "");
setStartDateDefault(
project.startDate
? moment(project.startDate, "YYYY-MM-DD").format("jYYYY/jMM/jDD")
: "",
);
if (project.users?.length) {
setSelectedUsers(
project.users.map((user) => ({
id: user.id,
name: `${user.firstName} ${user.lastName}`,
})),
);
} else if (project.userIds?.length) {
setSelectedUsers(
project.userIds.map((userId) => ({
id: userId,
name: userId,
})),
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [project]);
if (getProjectDetail.isPending) {
return <PageLoading />;
}
return (
<div className="w-full mt-4">
<div className="flex w-full justify-between items-center">
<div>{t("edit")}</div>
<div>
<Button
className="px-5"
isLoading={updateProject.isPending || singleUpload.isPending}
onClick={() => formik.handleSubmit()}
>
<div className="flex gap-2">
<TickCircle className="size-5" color="#fff" />
<div>{t("submit")}</div>
</div>
</Button>
</div>
</div>
<div className="flex gap-6">
<div className="flex-1">
<div className="flex-1 mt-10 bg-white py-8 xl:px-10 px-4 rounded-3xl">
<div className="text-sm font-bold">{t("taskmanager.project_info")}</div>
<div className="mt-8">
<Input
label={t("taskmanager.project_name")}
{...formik.getFieldProps("name")}
error_text={
formik.touched.name && formik.errors.name ? formik.errors.name : ""
}
/>
</div>
<div className="mt-8 rowTwoInput">
<DatePickerComponent
label={t("taskmanager.project_start_date")}
placeholder={t("taskmanager.select_date")}
defaulValue={startDateDefault}
onChange={(value) => formik.setFieldValue("startDate", value)}
error_text={
formik.touched.startDate && formik.errors.startDate
? formik.errors.startDate
: ""
}
/>
<Input
label={t("taskmanager.employer_name")}
{...formik.getFieldProps("ownerName")}
error_text={
formik.touched.ownerName && formik.errors.ownerName
? formik.errors.ownerName
: ""
}
/>
</div>
<div className="mt-8">
<Textarea
label={t("description")}
{...formik.getFieldProps("description")}
error_text={
formik.touched.description && formik.errors.description
? formik.errors.description
: ""
}
/>
</div>
</div>
</div>
<CreateProjectSidebar
isActive={isActive}
onIsActiveChange={setIsActive}
selectedColor={selectedColor}
onColorChange={setSelectedColor}
selectedUsers={selectedUsers}
onSelectedUsersChange={setSelectedUsers}
selectedBackground={selectedBackground}
onBackgroundChange={setSelectedBackground}
backgroundImage={backgroundImage}
onBackgroundImageChange={setBackgroundImage}
workspaceId={workspaceId}
onWorkspaceIdChange={setWorkspaceId}
/>
</div>
</div>
);
};
export default UpdateProject;
@@ -5,14 +5,16 @@ import Select from "../../../../components/Select";
import SwitchComponent from "../../../../components/Switch"; import SwitchComponent from "../../../../components/Switch";
import { useGetUsers } from "../../../users/hooks/useUserData"; import { useGetUsers } from "../../../users/hooks/useUserData";
import { UserItemType } from "../../../users/types/UserTypes"; import { UserItemType } from "../../../users/types/UserTypes";
import { useGetWorkspaces } from "../../workspace/hooks/useWorkspaceData";
import { WorkspaceItemType } from "../../workspace/types/WorkspaceTypes";
import WorkspaceColorPicker from "../../workspace/components/WorkspaceColorPicker"; import WorkspaceColorPicker from "../../workspace/components/WorkspaceColorPicker";
type SelectedUser = { export type SelectedUser = {
id: string; id: string;
name: string; name: string;
}; };
const BACKGROUND_PRESETS = [ export const BACKGROUND_PRESETS = [
"linear-gradient(45deg, #6a11cb, #2575fc)", "linear-gradient(45deg, #6a11cb, #2575fc)",
"linear-gradient(45deg, #ff00cc, #333399)", "linear-gradient(45deg, #ff00cc, #333399)",
"linear-gradient(45deg, #ff0000, #ff8c00)", "linear-gradient(45deg, #ff0000, #ff8c00)",
@@ -21,18 +23,53 @@ const BACKGROUND_PRESETS = [
"linear-gradient(45deg, #2c3e50, #4ca1af)", "linear-gradient(45deg, #2c3e50, #4ca1af)",
] as const; ] as const;
const CreateProjectSidebar: FC = () => { type Props = {
isActive: boolean;
onIsActiveChange: (value: boolean) => void;
selectedColor: string;
onColorChange: (color: string) => void;
selectedUsers: SelectedUser[];
onSelectedUsersChange: (users: SelectedUser[]) => void;
selectedBackground: string;
onBackgroundChange: (background: string) => void;
backgroundImage: File | null;
onBackgroundImageChange: (file: File | null) => void;
workspaceId: string;
onWorkspaceIdChange: (id: string) => void;
workspaceError?: string;
};
const CreateProjectSidebar: FC<Props> = ({
isActive,
onIsActiveChange,
selectedColor,
onColorChange,
selectedUsers,
onSelectedUsersChange,
selectedBackground,
onBackgroundChange,
backgroundImage,
onBackgroundImageChange,
workspaceId,
onWorkspaceIdChange,
workspaceError,
}) => {
const { t } = useTranslation("global"); const { t } = useTranslation("global");
const getUsers = useGetUsers(""); const getUsers = useGetUsers("");
const getWorkspaces = useGetWorkspaces();
const [isActive, setIsActive] = useState(true);
const [selectedColor, setSelectedColor] = useState("#A8E6CF");
const [selectedUsers, setSelectedUsers] = useState<SelectedUser[]>([]);
const [userSelectValue, setUserSelectValue] = useState(""); const [userSelectValue, setUserSelectValue] = useState("");
const [selectedBackground, setSelectedBackground] = useState<string>(BACKGROUND_PRESETS[0]);
const [backgroundImage, setBackgroundImage] = useState<File | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const workspaces: WorkspaceItemType[] =
getWorkspaces.data?.data?.workspaces ??
getWorkspaces.data?.data ??
[];
const workspaceItems = workspaces.map((workspace) => ({
label: workspace.name,
value: workspace.id,
}));
const userItems = const userItems =
getUsers.data?.data?.users getUsers.data?.data?.users
?.filter((user: UserItemType) => !selectedUsers.some((selected) => selected.id === user.id)) ?.filter((user: UserItemType) => !selectedUsers.some((selected) => selected.id === user.id))
@@ -48,19 +85,22 @@ const CreateProjectSidebar: FC = () => {
const user = getUsers.data?.data?.users?.find((item: UserItemType) => item.id === value); const user = getUsers.data?.data?.users?.find((item: UserItemType) => item.id === value);
if (user) { if (user) {
setSelectedUsers((prev) => [...prev, { id: user.id, name: `${user.firstName} ${user.lastName}` }]); onSelectedUsersChange([
...selectedUsers,
{ id: user.id, name: `${user.firstName} ${user.lastName}` },
]);
} }
setUserSelectValue(""); setUserSelectValue("");
}; };
const handleRemoveUser = (id: string) => { const handleRemoveUser = (id: string) => {
setSelectedUsers((prev) => prev.filter((user) => user.id !== id)); onSelectedUsersChange(selectedUsers.filter((user) => user.id !== id));
}; };
const handleBackgroundImageChange = (event: React.ChangeEvent<HTMLInputElement>) => { const handleBackgroundImageChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0] ?? null; const file = event.target.files?.[0] ?? null;
setBackgroundImage(file); onBackgroundImageChange(file);
}; };
return ( return (
@@ -69,10 +109,22 @@ const CreateProjectSidebar: FC = () => {
<div>{t("taskmanager.project_status")}</div> <div>{t("taskmanager.project_status")}</div>
<div className="flex gap-2 text-xs items-center text-description"> <div className="flex gap-2 text-xs items-center text-description">
<div>{isActive ? t("slider.active") : t("slider.inactive")}</div> <div>{isActive ? t("slider.active") : t("slider.inactive")}</div>
<SwitchComponent active={isActive} onChange={setIsActive} /> <SwitchComponent active={isActive} onChange={onIsActiveChange} />
</div> </div>
</div> </div>
<div className="mt-8">
<Select
label={t("taskmanager.workspacename")}
placeholder={t("taskmanager.select_workspace")}
items={workspaceItems}
value={workspaceId}
onChange={(e) => onWorkspaceIdChange(e.target.value)}
className="border"
error_text={workspaceError ?? ""}
/>
</div>
<div className="mt-8"> <div className="mt-8">
<Select label={t("taskmanager.users")} placeholder={t("taskmanager.select_user")} items={userItems} value={userSelectValue} onChange={handleUserSelect} className="border" /> <Select label={t("taskmanager.users")} placeholder={t("taskmanager.select_user")} items={userItems} value={userSelectValue} onChange={handleUserSelect} className="border" />
@@ -102,7 +154,7 @@ const CreateProjectSidebar: FC = () => {
background, background,
borderColor: selectedBackground === background ? "#000" : "#E2E8F0", borderColor: selectedBackground === background ? "#000" : "#E2E8F0",
}} }}
onClick={() => setSelectedBackground(background)} onClick={() => onBackgroundChange(background)}
/> />
))} ))}
</div> </div>
@@ -110,7 +162,7 @@ const CreateProjectSidebar: FC = () => {
<div className="mt-8"> <div className="mt-8">
<div className="text-sm mb-3">{t("taskmanager.choose_color")}</div> <div className="text-sm mb-3">{t("taskmanager.choose_color")}</div>
<WorkspaceColorPicker selectedColor={selectedColor} onChange={setSelectedColor} /> <WorkspaceColorPicker selectedColor={selectedColor} onChange={onColorChange} />
</div> </div>
<div className="mt-8"> <div className="mt-8">
@@ -1,5 +1,5 @@
import { FC } from "react"; import { CSSProperties, FC } from "react";
import { Link } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { Pages } from "../../../../config/Pages"; import { Pages } from "../../../../config/Pages";
import type { ProjectItem } from "../types/ProjectTypes"; import type { ProjectItem } from "../types/ProjectTypes";
import ProjectCardMenu from "./ProjectCardMenu"; import ProjectCardMenu from "./ProjectCardMenu";
@@ -17,21 +17,33 @@ const hexToRgba = (hex: string, alpha: number) => {
return `rgba(${r}, ${g}, ${b}, ${alpha})`; return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}; };
const getCardBackground = (background: string) => { const getCardBackgroundStyle = (project: ProjectItem): CSSProperties => {
const color = /^#[0-9A-Fa-f]{6}$/i.test(background) ? background : (background.match(/#[0-9A-Fa-f]{6}/i)?.[0] ?? "#A8E6CF"); if (project.backgroundPicture) {
return {
backgroundImage: `url(${project.backgroundPicture})`,
backgroundSize: "cover",
backgroundPosition: "center",
};
}
return `linear-gradient(135deg, ${color}, ${hexToRgba(color, 0.5)})`; const color = project.backgroundColor ?? "#A8E6CF";
return {
background: `linear-gradient(135deg, ${color}, ${hexToRgba(color, 0.5)})`,
};
}; };
const ProjectCard: FC<Props> = ({ project, onDelete }) => { const ProjectCard: FC<Props> = ({ project, onDelete }) => {
const navigate = useNavigate();
const handleEdit = () => { const handleEdit = () => {
// TODO: navigate to edit page when available navigate(`${Pages.taskmanager.updateProject}${project.id}`);
}; };
return ( return (
<div className="bg-white rounded-[20px] overflow-hidden shadow-sm border border-border/40 p-3"> <div className="bg-white rounded-[20px] overflow-hidden shadow-sm border border-border/40 p-3">
<Link to={Pages.taskmanager.workspace + project.id}> <Link to={Pages.taskmanager.workspace + project.id}>
<div className="h-[84px] w-full rounded-[16px]" style={{ background: getCardBackground(project.background) }} /> <div className="h-[84px] w-full rounded-[16px]" style={getCardBackgroundStyle(project)} />
</Link> </Link>
<div className="flex items-center justify-between mt-3.5"> <div className="flex items-center justify-between mt-3.5">
@@ -7,11 +7,15 @@ import { Pages } from "../../../../config/Pages";
type Props = { type Props = {
title: string; title: string;
workspaceId?: string;
onSettingsClick?: () => void; onSettingsClick?: () => void;
}; };
const ProjectListHeader: FC<Props> = ({ title, onSettingsClick }) => { const ProjectListHeader: FC<Props> = ({ title, workspaceId, onSettingsClick }) => {
const { t } = useTranslation("global"); const { t } = useTranslation("global");
const createProjectLink = workspaceId
? `${Pages.taskmanager.createProject}?workspaceId=${workspaceId}`
: Pages.taskmanager.createProject;
return ( return (
<div className="flex w-full justify-between items-center"> <div className="flex w-full justify-between items-center">
@@ -23,7 +27,7 @@ const ProjectListHeader: FC<Props> = ({ title, onSettingsClick }) => {
<span>{t("sidebar.setting")}</span> <span>{t("sidebar.setting")}</span>
</Button> </Button>
<Link to={Pages.taskmanager.createProject}> <Link to={createProjectLink}>
<Button onClick={onSettingsClick} className="flex items-center gap-2 whitespace-nowrap px-4"> <Button onClick={onSettingsClick} className="flex items-center gap-2 whitespace-nowrap px-4">
<Add size={18} color="white" /> <Add size={18} color="white" />
<span>{t("taskmanager.new_project")}</span> <span>{t("taskmanager.new_project")}</span>
@@ -0,0 +1,54 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as api from "../service/ProjectService";
import { CreateProjectType, UpdateProjectType } from "../types/ProjectTypes";
export const useGetProjectsByWorkspace = (workspaceId: string) => {
return useQuery({
queryKey: ["projects", workspaceId],
queryFn: () => api.getProjectsByWorkspace(workspaceId),
enabled: !!workspaceId,
});
};
export const useGetProjectDetail = (id: string) => {
return useQuery({
queryKey: ["project-detail", id],
queryFn: () => api.getProjectDetail(id),
enabled: !!id,
});
};
export const useGetProject = (id: string) => {
return useQuery({
queryKey: ["project", id],
queryFn: () => api.getProject(id),
enabled: !!id,
});
};
export const useCreateProject = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (variables: CreateProjectType) => api.createProject(variables),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: ["projects", variables.workspaceId],
});
},
});
};
export const useUpdateProject = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, params }: { id: string; params: UpdateProjectType }) => api.updateProject(id, params),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({
queryKey: ["project-detail", variables.id],
});
},
});
};
@@ -0,0 +1,27 @@
import axios from "../../../../config/axios";
import { CreateProjectType, GetProjectResponse, UpdateProjectType } from "../types/ProjectTypes";
export const getProjectsByWorkspace = async (workspaceId: string) => {
const { data } = await axios.get(`/task-manager/projects/user/workspace/${workspaceId}`);
return data;
};
export const getProjectDetail = async (id: string) => {
const { data } = await axios.get(`/task-manager/projects/${id}`);
return data;
};
export const getProject = async (id: string): Promise<GetProjectResponse> => {
const { data } = await axios.get(`/task-manager/projects/detail/${id}`);
return data;
};
export const createProject = async (params: CreateProjectType) => {
const { data } = await axios.post(`/task-manager/projects`, params);
return data;
};
export const updateProject = async (id: string, params: UpdateProjectType) => {
const { data } = await axios.patch(`/task-manager/projects/${id}`, params);
return data;
};
@@ -1,10 +1,54 @@
import { IResponse } from "../../../../types/response.types";
import { TaskItemType } from "../../task/types/TaskTypes";
export type ProjectItem = { export type ProjectItem = {
id: string; id: string;
name: string; name: string;
background: string; backgroundColor?: string;
backgroundPicture?: string;
}; };
export type WorkspaceInfo = { export type ProjectUserType = {
name: string; id: string;
description: string; firstName: string;
lastName: string;
}; };
export type CreateProjectType = {
name: string;
ownerName: string;
description: string;
startDate: string;
backgroundPicture: string;
backgroundColor: string;
isActive: boolean;
workspaceId: string;
userIds: string[];
};
export type ProjectDetailType = CreateProjectType & {
id: string;
users?: ProjectUserType[];
};
export type UpdateProjectType = CreateProjectType;
export type ProjectTaskType = TaskItemType;
export type ProjectTaskPhaseType = {
id: string;
name: string;
order: number;
color: string;
tasks: ProjectTaskType[];
};
export type ProjectWithPhasesType = {
id: string;
name: string;
taskPhases: ProjectTaskPhaseType[];
};
export interface GetProjectResponse extends IResponse<ProjectWithPhasesType> {
statusCode: number;
}
@@ -0,0 +1,40 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import * as api from "../service/TaskPhaseService";
import { CreateTaskPhaseType, UpdateTaskPhaseType } from "../types/TaskPhaseTypes";
export const useCreateTaskPhase = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (variables: CreateTaskPhaseType) => api.createTaskPhase(variables),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: ["task-phases", variables.projectId],
});
queryClient.invalidateQueries({ queryKey: ["project", variables.projectId] });
},
});
};
export const useUpdateTaskPhase = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, params }: { id: string; params: UpdateTaskPhaseType; projectId: string }) =>
api.updateTaskPhase(id, params),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ["project", variables.projectId] });
},
});
};
export const useDeleteTaskPhase = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id }: { id: string; projectId: string }) => api.deleteTaskPhase(id),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ["project", variables.projectId] });
},
});
};
@@ -0,0 +1,17 @@
import axios from "../../../../config/axios";
import { CreateTaskPhaseType, UpdateTaskPhaseType } from "../types/TaskPhaseTypes";
export const createTaskPhase = async (params: CreateTaskPhaseType) => {
const { data } = await axios.post(`/task-manager/task-phases`, params);
return data;
};
export const updateTaskPhase = async (id: string, params: UpdateTaskPhaseType) => {
const { data } = await axios.patch(`/task-manager/task-phases/${id}`, params);
return data;
};
export const deleteTaskPhase = async (id: string) => {
const { data } = await axios.delete(`/task-manager/task-phases/${id}`);
return data;
};
@@ -0,0 +1,18 @@
export type CreateTaskPhaseType = {
name: string;
order: number;
color: string;
projectId: string;
};
export type UpdateTaskPhaseType = {
order: number;
};
export type TaskPhaseItemType = {
id: string;
name: string;
order: number;
color: string;
projectId: string;
};
@@ -0,0 +1,118 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as api from "../service/TaskService";
import {
ChangeTaskPhaseType,
CreateCheckListItemType,
CreateTaskAttachmentType,
CreateTaskRemarkType,
CreateTaskType,
UpdateCheckListItemType,
UpdateTaskType,
} from "../types/TaskTypes";
export const useGetTaskDetail = (id: string, enabled = true) => {
return useQuery({
queryKey: ["task-detail", id],
queryFn: () => api.getTaskDetail(id),
enabled: !!id && enabled,
});
};
export const useCreateTask = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (variables: CreateTaskType) => api.createTask(variables),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ["project", variables.projectId] });
},
});
};
export const useUpdateTask = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, params }: { id: string; params: UpdateTaskType }) => api.updateTask(id, params),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ["task-detail", variables.id] });
queryClient.invalidateQueries({ queryKey: ["project"] });
},
});
};
export const useChangeTaskPhase = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, params }: { id: string; params: ChangeTaskPhaseType; projectId: string }) =>
api.changeTaskPhase(id, params),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ["task-detail", variables.id] });
queryClient.invalidateQueries({ queryKey: ["project", variables.projectId] });
queryClient.invalidateQueries({ queryKey: ["project"] });
},
});
};
export const useDeleteTask = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id }: { id: string; projectId: string }) => api.deleteTask(id),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ["task-detail", variables.id] });
queryClient.invalidateQueries({ queryKey: ["project", variables.projectId] });
queryClient.invalidateQueries({ queryKey: ["project"] });
},
});
};
export const useCreateTaskRemark = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (variables: CreateTaskRemarkType) => api.createTaskRemark(variables),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ["task-detail", variables.taskId] });
queryClient.invalidateQueries({ queryKey: ["project"] });
},
});
};
export const useCreateCheckListItem = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (variables: CreateCheckListItemType) => api.createCheckListItem(variables),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ["task-detail", variables.taskId] });
queryClient.invalidateQueries({ queryKey: ["project"] });
},
});
};
export const useUpdateCheckListItem = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, params }: { id: string; params: UpdateCheckListItemType; taskId: string }) =>
api.updateCheckListItem(id, params),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ["task-detail", variables.taskId] });
queryClient.invalidateQueries({ queryKey: ["project"] });
},
});
};
export const useCreateTaskAttachment = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (variables: CreateTaskAttachmentType) => api.createTaskAttachment(variables),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ["task-detail", variables.taskId] });
queryClient.invalidateQueries({ queryKey: ["project"] });
},
});
};
@@ -0,0 +1,62 @@
import axios from "../../../../config/axios";
import {
ChangeTaskPhaseType,
CreateCheckListItemResponse,
CreateCheckListItemType,
CreateTaskAttachmentType,
CreateTaskRemarkResponse,
CreateTaskRemarkType,
CreateTaskType,
GetTaskDetailResponse,
UpdateCheckListItemResponse,
UpdateCheckListItemType,
UpdateTaskType,
} from "../types/TaskTypes";
export const createTask = async (params: CreateTaskType) => {
const { data } = await axios.post(`/task-manager/tasks`, params);
return data;
};
export const getTaskDetail = async (id: string): Promise<GetTaskDetailResponse> => {
const { data } = await axios.get(`/task-manager/tasks/detail/${id}`);
return data;
};
export const updateTask = async (id: string, params: UpdateTaskType) => {
const { data } = await axios.patch(`/task-manager/tasks/${id}`, params);
return data;
};
export const changeTaskPhase = async (id: string, params: ChangeTaskPhaseType) => {
const { data } = await axios.patch(`/task-manager/tasks/${id}/change-phase`, params);
return data;
};
export const deleteTask = async (id: string) => {
const { data } = await axios.delete(`/task-manager/tasks/${id}`);
return data;
};
export const createTaskRemark = async (params: CreateTaskRemarkType): Promise<CreateTaskRemarkResponse> => {
const { data } = await axios.post(`/task-manager/remarks`, params);
return data;
};
export const createCheckListItem = async (params: CreateCheckListItemType): Promise<CreateCheckListItemResponse> => {
const { data } = await axios.post(`/task-manager/check-list-items`, params);
return data;
};
export const updateCheckListItem = async (
id: string,
params: UpdateCheckListItemType,
): Promise<UpdateCheckListItemResponse> => {
const { data } = await axios.patch(`/task-manager/check-list-items/${id}`, params);
return data;
};
export const createTaskAttachment = async (params: CreateTaskAttachmentType) => {
const { data } = await axios.post(`/task-manager/attachments`, params);
return data;
};
@@ -0,0 +1,123 @@
import { IResponse } from "../../../../types/response.types";
export type CreateTaskType = {
title: string;
taskPhaseId: string;
order: number;
projectId: string;
};
export type TaskItemType = {
id: string;
title: string;
taskPhaseId: string;
order: number;
tag?: string;
dateRange?: string;
attachments?: number;
checklistDone?: number;
checklistTotal?: number;
};
export type TaskLabelType = {
id: string;
title: string;
color: string;
};
export type CreateTaskRemarkType = {
title: string;
color: string;
taskId: string;
};
export interface CreateTaskRemarkResponse extends IResponse<{ remark: TaskLabelType }> {
statusCode: number;
}
export type TaskUserType = {
id: string;
firstName: string;
lastName: string;
};
export type TaskChecklistItemType = {
id: string;
title: string;
isDone: boolean;
};
export type CreateCheckListItemType = {
title: string;
isDone: boolean;
taskId: string;
};
export interface CreateCheckListItemResponse extends IResponse<{ checkListItem: TaskChecklistItemType }> {
statusCode: number;
}
export type UpdateCheckListItemType = {
isDone: boolean;
};
export interface UpdateCheckListItemResponse extends IResponse<{ checkListItem: TaskChecklistItemType }> {
statusCode: number;
}
export type TaskChecklistType = {
id: string;
title: string;
items: TaskChecklistItemType[];
};
export type TaskAttachmentType = {
id: string;
title: string;
type: "file" | "link";
file?: string;
createdAt?: string;
};
export type CreateTaskAttachmentType = {
file: string;
title: string;
type: "file" | "link";
taskId: string;
};
export type TaskDetailType = {
id: string;
createdAt: string;
updatedAt: string;
title: string;
description?: string | null;
color?: string | null;
taskPhaseId: string;
order?: number;
projectId?: string;
startDate?: string | null;
endDate?: string | null;
labels?: TaskLabelType[];
remarks?: TaskLabelType[];
users?: TaskUserType[];
checkListItems?: TaskChecklistItemType[];
checkListItemCount?: number;
completedCheckListItemCount?: number;
attachments?: TaskAttachmentType[];
};
export type ChangeTaskPhaseType = {
taskPhaseId: string;
};
export type UpdateTaskType = Partial<
Pick<TaskDetailType, "title" | "description" | "taskPhaseId" | "order" | "startDate" | "endDate">
> & {
color?: string;
userIds?: string[];
};
export interface GetTaskDetailResponse extends IResponse<TaskDetailType> {
statusCode: number;
}
@@ -0,0 +1,14 @@
import type { Task } from "../../types";
import type { TaskItemType } from "../types/TaskTypes";
export const mapTaskItemToTask = (task: TaskItemType, taskPhaseId: string, fallbackOrder: number): Task => ({
id: task.id,
columnId: task.taskPhaseId ?? taskPhaseId,
order: task.order ?? fallbackOrder,
title: task.title,
tag: task.tag ?? "",
dateRange: task.dateRange ?? "",
attachments: task.attachments ?? 0,
checklistDone: task.checklistDone ?? 0,
checklistTotal: task.checklistTotal ?? 0,
});
+2
View File
@@ -1,6 +1,8 @@
export type Column = { export type Column = {
id: string; id: string;
title: string; title: string;
color?: string;
order?: number;
}; };
export type Task = { export type Task = {
@@ -15,5 +15,5 @@ export const reorderColumns = (
const result = [...columns]; const result = [...columns];
const [removed] = result.splice(oldIndex, 1); const [removed] = result.splice(oldIndex, 1);
result.splice(newIndex, 0, removed); result.splice(newIndex, 0, removed);
return result; return result.map((column, index) => ({ ...column, order: index + 1 }));
}; };
+146 -17
View File
@@ -1,27 +1,63 @@
import { useMemo, useState, type FC } from "react"; import { useEffect, useMemo, useState, type FC } from "react";
import { useParams } from "react-router-dom";
import { toast } from "react-toastify";
import PageLoading from "../../components/PageLoading";
import { ErrorType } from "../../helpers/types";
import AddNewColumn from "./components/AddNewColumn"; import AddNewColumn from "./components/AddNewColumn";
import Column from "./components/Column"; import Column from "./components/Column";
import HeaderWorkspace from "./components/HeaderWorkspace"; import HeaderWorkspace from "./components/HeaderWorkspace";
import TaskDetailModal from "./components/task-detail/TaskDetailModal"; import TaskDetailModal from "./components/task-detail/TaskDetailModal";
import tasksData from "./data/tasks.json"; import { useGetProject } from "./project/hooks/useProjectData";
import type { Task, WorkspaceData } from "./types"; import { useUpdateTaskPhase } from "./task-phase/hooks/useTaskPhaseData";
import { useChangeTaskPhase } from "./task/hooks/useTaskData";
import { mapTaskItemToTask } from "./task/utils/mapTaskItemToTask";
import type { TaskItemType } from "./task/types/TaskTypes";
import type { TaskPhaseItemType } from "./task-phase/types/TaskPhaseTypes";
import type { Column as ColumnType, Task } from "./types";
import { reorderColumns } from "./utils/reorderColumns"; import { reorderColumns } from "./utils/reorderColumns";
import { reorderTasks } from "./utils/reorderTasks"; import { reorderTasks } from "./utils/reorderTasks";
const Workspace: FC = () => { const Workspace: FC = () => {
const [columns, setColumns] = useState( const { slug: projectId = "" } = useParams<{ slug: string }>();
(tasksData as WorkspaceData).columns, const { data: project, isPending } = useGetProject(projectId);
); const changeTaskPhase = useChangeTaskPhase();
const [tasks, setTasks] = useState<Task[]>(tasksData.tasks as Task[]); const updateTaskPhase = useUpdateTaskPhase();
const [columns, setColumns] = useState<ColumnType[]>([]);
const [tasks, setTasks] = useState<Task[]>([]);
const [draggedTaskId, setDraggedTaskId] = useState<string | null>(null); const [draggedTaskId, setDraggedTaskId] = useState<string | null>(null);
const [draggedColumnId, setDraggedColumnId] = useState<string | null>(null); const [draggedColumnId, setDraggedColumnId] = useState<string | null>(null);
const [selectedTask, setSelectedTask] = useState<Task | null>(null); const [selectedTask, setSelectedTask] = useState<Task | null>(null);
useEffect(() => {
if (!project?.data?.taskPhases) return;
setColumns(
[...project.data.taskPhases]
.sort((a, b) => a.order - b.order)
.map((phase) => ({
id: phase.id,
title: phase.name,
color: phase.color,
order: phase.order,
})),
);
}, [project?.data?.taskPhases]);
useEffect(() => {
if (!project?.data?.taskPhases) return;
const apiTasks = project.data.taskPhases.flatMap((phase) =>
(phase.tasks ?? []).map((task, index) =>
mapTaskItemToTask(task as TaskItemType, phase.id, index),
),
);
setTasks(apiTasks);
}, [project?.data?.taskPhases]);
const tasksByColumn = useMemo(() => { const tasksByColumn = useMemo(() => {
return columns.reduce<Record<string, Task[]>>((acc, column) => { return columns.reduce<Record<string, Task[]>>((acc, column) => {
acc[column.id] = tasks acc[column.id] = tasks.filter((task) => task.columnId === column.id).sort((a, b) => a.order - b.order);
.filter((task) => task.columnId === column.id)
.sort((a, b) => a.order - b.order);
return acc; return acc;
}, {}); }, {});
}, [columns, tasks]); }, [columns, tasks]);
@@ -36,8 +72,48 @@ const Workspace: FC = () => {
const handleDrop = (columnId: string, index: number) => { const handleDrop = (columnId: string, index: number) => {
if (!draggedTaskId) return; if (!draggedTaskId) return;
setTasks((prev) => reorderTasks(prev, draggedTaskId, columnId, index));
const draggedTask = tasks.find((task) => task.id === draggedTaskId);
if (!draggedTask) return;
const previousTasks = tasks;
const columnChanged = draggedTask.columnId !== columnId;
const nextTasks = reorderTasks(tasks, draggedTaskId, columnId, index);
const movedTask = nextTasks.find((task) => task.id === draggedTaskId) ?? null;
setTasks(nextTasks);
setDraggedTaskId(null); setDraggedTaskId(null);
if (selectedTask?.id === draggedTaskId && movedTask) {
setSelectedTask(movedTask);
}
if (!columnChanged) return;
changeTaskPhase.mutate(
{ id: draggedTaskId, params: { taskPhaseId: columnId }, projectId },
{
onError: (error: ErrorType) => {
setTasks(previousTasks);
if (selectedTask?.id === draggedTaskId) {
setSelectedTask(draggedTask);
}
toast.error(error.response?.data?.error.message[0]);
},
},
);
};
const handleTaskPhaseChanged = (taskId: string, taskPhaseId: string) => {
setTasks((prev) => {
const task = prev.find((item) => item.id === taskId);
if (!task || task.columnId === taskPhaseId) return prev;
const destinationIndex = prev.filter((item) => item.columnId === taskPhaseId).length;
return reorderTasks(prev, taskId, taskPhaseId, destinationIndex);
});
setSelectedTask((prev) => (prev?.id === taskId ? { ...prev, columnId: taskPhaseId } : prev));
}; };
const handleColumnDragStart = (columnId: string) => { const handleColumnDragStart = (columnId: string) => {
@@ -50,13 +126,57 @@ const Workspace: FC = () => {
const handleColumnDrop = (overColumnId: string) => { const handleColumnDrop = (overColumnId: string) => {
if (!draggedColumnId || draggedColumnId === overColumnId) return; if (!draggedColumnId || draggedColumnId === overColumnId) return;
setColumns((prev) => reorderColumns(prev, draggedColumnId, overColumnId));
const previousColumns = columns;
const nextColumns = reorderColumns(columns, draggedColumnId, overColumnId);
const movedColumn = nextColumns.find((column) => column.id === draggedColumnId);
setColumns(nextColumns);
setDraggedColumnId(null); setDraggedColumnId(null);
if (!movedColumn?.order) return;
updateTaskPhase.mutate(
{ id: draggedColumnId, params: { order: movedColumn.order }, projectId },
{
onError: (error: ErrorType) => {
setColumns(previousColumns);
toast.error(error.response?.data?.error.message[0]);
},
},
);
}; };
const selectedTaskColumn = selectedTask const nextColumnOrder = columns.length
? columns.find((column) => column.id === selectedTask.columnId) ? Math.max(...columns.map((column) => column.order ?? 0)) + 1
: null; : 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),
),
);
};
const handleColumnDeleted = (columnId: string) => {
setColumns((prev) => prev.filter((column) => column.id !== columnId));
setTasks((prev) => prev.filter((task) => task.columnId !== columnId));
if (selectedTask?.columnId === columnId) {
setSelectedTask(null);
}
};
const handleTaskCreated = (task: TaskItemType, taskPhaseId: string) => {
setTasks((prev) => [
...prev,
mapTaskItemToTask(task, taskPhaseId, prev.filter((item) => item.columnId === taskPhaseId).length),
]);
};
if (isPending) {
return <PageLoading />;
}
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">
@@ -67,6 +187,7 @@ const Workspace: FC = () => {
<Column <Column
key={column.id} key={column.id}
column={column} column={column}
projectId={projectId}
tasks={tasksByColumn[column.id] ?? []} tasks={tasksByColumn[column.id] ?? []}
draggedTaskId={draggedTaskId} draggedTaskId={draggedTaskId}
draggedColumnId={draggedColumnId} draggedColumnId={draggedColumnId}
@@ -77,18 +198,26 @@ const Workspace: FC = () => {
onColumnDragEnd={handleColumnDragEnd} onColumnDragEnd={handleColumnDragEnd}
onColumnDrop={handleColumnDrop} onColumnDrop={handleColumnDrop}
onTaskClick={setSelectedTask} onTaskClick={setSelectedTask}
onColumnDeleted={handleColumnDeleted}
onTaskCreated={handleTaskCreated}
/> />
))} ))}
<AddNewColumn /> <AddNewColumn
projectId={projectId}
nextOrder={nextColumnOrder}
onColumnCreated={handleColumnCreated}
/>
</div> </div>
</div> </div>
<TaskDetailModal <TaskDetailModal
open={selectedTask !== null} open={selectedTask !== null}
task={selectedTask} task={selectedTask}
statusLabel={selectedTaskColumn?.title ?? ""} columns={columns}
projectId={projectId}
onClose={() => setSelectedTask(null)} onClose={() => setSelectedTask(null)}
onTaskPhaseChanged={handleTaskPhaseChanged}
/> />
</div> </div>
); );
+85 -12
View File
@@ -1,20 +1,71 @@
import { FC, useState } from "react";
import { TickCircle } from "iconsax-react"; import { TickCircle } from "iconsax-react";
import { useFormik } from "formik";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
import * as Yup from "yup";
import Button from "../../../components/Button"; import Button from "../../../components/Button";
import Input from "../../../components/Input"; import Input from "../../../components/Input";
import Select from "../../../components/Select";
import Textarea from "../../../components/Textarea"; import Textarea from "../../../components/Textarea";
import CreateWorkspaceSidebar from "./components/CreateWorkspaceSidebar"; import { Pages } from "../../../config/Pages";
import { ErrorType } from "../../../helpers/types";
import { useCreateWorkspace } from "./hooks/useWorkspaceData";
import { CreateWorkspaceType } from "./types/WorkspaceTypes";
import CreateWorkspaceSidebar, {
SelectedUser,
} from "./components/CreateWorkspaceSidebar";
const CreateWorkspace = () => { const CreateWorkspace: FC = () => {
const { t } = useTranslation("global"); const { t } = useTranslation("global");
const navigate = useNavigate();
const createWorkspace = useCreateWorkspace();
const [isActive, setIsActive] = useState(true);
const [selectedColor, setSelectedColor] = useState("#A8E6CF");
const [selectedUsers, setSelectedUsers] = useState<SelectedUser[]>([]);
const handleCreateWorkspace = (values: CreateWorkspaceType) => {
createWorkspace.mutate(values, {
onSuccess: () => {
toast.success(t("success"));
navigate(Pages.taskmanager.workspaceList);
},
onError: (error: ErrorType) => {
toast.error(error.response?.data?.error.message[0]);
},
});
};
const formik = useFormik<Pick<CreateWorkspaceType, "name" | "description">>({
initialValues: {
name: "",
description: "",
},
validationSchema: Yup.object({
name: Yup.string().required(t("errors.required")),
description: Yup.string(),
}),
onSubmit: (values) => {
handleCreateWorkspace({
...values,
color: selectedColor,
isActive,
userIds: selectedUsers.map((user) => user.id),
});
},
});
return ( return (
<div className="w-full mt-4"> <div className="w-full mt-4">
<div className="flex w-full justify-between items-center"> <div className="flex w-full justify-between items-center">
<div>{t("taskmanager.new_workspace")}</div> <div>{t("taskmanager.new_workspace")}</div>
<div> <div>
<Button className="px-5" isLoading={false}> <Button
className="px-5"
isLoading={createWorkspace.isPending}
onClick={() => formik.handleSubmit()}
>
<div className="flex gap-2"> <div className="flex gap-2">
<TickCircle className="size-5" color="#fff" /> <TickCircle className="size-5" color="#fff" />
<div>{t("taskmanager.submit_workspace")}</div> <div>{t("taskmanager.submit_workspace")}</div>
@@ -26,20 +77,42 @@ const CreateWorkspace = () => {
<div className="flex gap-6"> <div className="flex gap-6">
<div className="flex-1"> <div className="flex-1">
<div className="flex-1 mt-10 bg-white py-8 xl:px-10 px-4 rounded-3xl"> <div className="flex-1 mt-10 bg-white py-8 xl:px-10 px-4 rounded-3xl">
<div className="text-sm font-bold">اطلاعات</div> <div className="text-sm font-bold">{t("taskmanager.workspace_info")}</div>
<div className="mt-8 rowTwoInput">
<Input label={t("taskmanager.workspacename")} />
<Select label={t("taskmanager.workspacetype")} items={[]} />
</div>
<div className="mt-8"> <div className="mt-8">
<Textarea label={t("taskmanager.workspace_description")} /> <Input
label={t("taskmanager.workspacename")}
{...formik.getFieldProps("name")}
error_text={
formik.touched.name && formik.errors.name
? formik.errors.name
: ""
}
/>
</div>
<div className="mt-8">
<Textarea
label={t("taskmanager.workspace_description")}
{...formik.getFieldProps("description")}
error_text={
formik.touched.description && formik.errors.description
? formik.errors.description
: ""
}
/>
</div> </div>
</div> </div>
</div> </div>
<CreateWorkspaceSidebar /> <CreateWorkspaceSidebar
isActive={isActive}
onIsActiveChange={setIsActive}
selectedColor={selectedColor}
onColorChange={setSelectedColor}
selectedUsers={selectedUsers}
onSelectedUsersChange={setSelectedUsers}
/>
</div> </div>
</div> </div>
); );
+118
View File
@@ -0,0 +1,118 @@
import { Add, Edit } from "iconsax-react";
import { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { toast } from "react-toastify";
import Button from "../../../components/Button";
import PageLoading from "../../../components/PageLoading";
import Td from "../../../components/Td";
import TrashWithConfrim from "../../../components/TrashWithConfrim";
import { Pages } from "../../../config/Pages";
import { ErrorType } from "../../../helpers/types";
import { useDeleteWorkspace, useGetWorkspaces } from "./hooks/useWorkspaceData";
import { WorkspaceItemType } from "./types/WorkspaceTypes";
const WorkspaceList: FC = () => {
const { t } = useTranslation("global");
const getWorkspaces = useGetWorkspaces();
const deleteWorkspace = useDeleteWorkspace();
const workspaces: WorkspaceItemType[] = getWorkspaces.data?.data?.workspaces ?? getWorkspaces.data?.data ?? [];
const handleDelete = (id: string) => {
deleteWorkspace.mutate(id, {
onSuccess: () => {
toast.success(t("taskmanager.workspace_deleted"));
},
onError: (error: ErrorType) => {
toast.error(error.response?.data?.error.message[0]);
},
});
};
return (
<div className="mt-4">
<div className="flex w-full justify-between items-center">
<div>{t("taskmanager.workspace_list")}</div>
<Link to={Pages.taskmanager.createWorkspace}>
<Button className="px-5">
<div className="flex gap-2">
<Add
className="size-5"
color="#fff"
/>
<div>{t("taskmanager.new_workspace")}</div>
</div>
</Button>
</Link>
</div>
{getWorkspaces.isPending ? (
<PageLoading />
) : (
<div className="relative overflow-x-auto rounded-3xl mt-9 w-full">
<table className="w-full text-sm">
<thead className="thead">
<tr>
<Td text={t("taskmanager.workspacename")} />
<Td text={t("taskmanager.workspace_description")} />
<Td text={t("taskmanager.workspace_status")} />
<Td text={t("taskmanager.projects")} />
<Td text="" />
</tr>
</thead>
<tbody>
{workspaces.map((item) => (
<tr
key={item.id}
className="tr"
>
<Td text="">
<div className="flex items-center gap-3">
<div
className="size-4 rounded-full shrink-0"
style={{ backgroundColor: item.color }}
/>
<Link
to={Pages.taskmanager.workspace + item.id}
className="text-[#0047FF] hover:opacity-70 transition-opacity"
>
{item.name}
</Link>
</div>
</Td>
<Td text={item.description ?? ""} />
<Td text={item.isActive ? t("slider.active") : t("slider.inactive")} />
<Td text="">
<Link to={Pages.taskmanager.projectList + item.id}>
<Button
label="مدیریت پروژه ها"
className="bg-blue-400 w-fit px-3"
/>
</Link>
</Td>
<Td text="">
<div className="flex items-center gap-2">
<Link to={Pages.taskmanager.updateWorkspace + item.id}>
<Edit
size={20}
color="#8C90A3"
/>
</Link>
<TrashWithConfrim
onDelete={() => handleDelete(item.id)}
isLoading={deleteWorkspace.isPending}
/>
</div>
</Td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
};
export default WorkspaceList;
+171
View File
@@ -0,0 +1,171 @@
import { FC, useEffect, useState } from "react";
import { TickCircle } from "iconsax-react";
import { useFormik } from "formik";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "react-router-dom";
import { toast } from "react-toastify";
import * as Yup from "yup";
import Button from "../../../components/Button";
import Input from "../../../components/Input";
import PageLoading from "../../../components/PageLoading";
import Textarea from "../../../components/Textarea";
import { Pages } from "../../../config/Pages";
import { ErrorType } from "../../../helpers/types";
import {
useGetWorkspaceDetail,
useUpdateWorkspace,
} from "./hooks/useWorkspaceData";
import { UpdateWorkspaceType, WorkspaceDetailType } from "./types/WorkspaceTypes";
import CreateWorkspaceSidebar, {
SelectedUser,
} from "./components/CreateWorkspaceSidebar";
const UpdateWorkspace: FC = () => {
const { t } = useTranslation("global");
const { id } = useParams();
const navigate = useNavigate();
const getWorkspaceDetail = useGetWorkspaceDetail(id || "");
const updateWorkspace = useUpdateWorkspace();
const [isActive, setIsActive] = useState(true);
const [selectedColor, setSelectedColor] = useState("#A8E6CF");
const [selectedUsers, setSelectedUsers] = useState<SelectedUser[]>([]);
const workspace: WorkspaceDetailType | undefined =
getWorkspaceDetail.data?.data?.workspace ??
getWorkspaceDetail.data?.data;
const handleUpdateWorkspace = (values: UpdateWorkspaceType) => {
if (!id) return;
updateWorkspace.mutate(
{
id,
params: values,
},
{
onSuccess: () => {
toast.success(t("success"));
navigate(Pages.taskmanager.workspaceList);
},
onError: (error: ErrorType) => {
toast.error(error.response?.data?.error.message[0]);
},
},
);
};
const formik = useFormik<Pick<UpdateWorkspaceType, "name" | "description">>({
initialValues: {
name: "",
description: "",
},
validationSchema: Yup.object({
name: Yup.string().required(t("errors.required")),
description: Yup.string(),
}),
onSubmit: (values) => {
handleUpdateWorkspace({
...values,
color: selectedColor,
isActive,
userIds: selectedUsers.map((user) => user.id),
});
},
});
useEffect(() => {
if (!workspace) return;
formik.setValues({
name: workspace.name,
description: workspace.description ?? "",
});
setIsActive(workspace.isActive);
setSelectedColor(workspace.color);
if (workspace.users?.length) {
setSelectedUsers(
workspace.users.map((user) => ({
id: user.id,
name: `${user.firstName} ${user.lastName}`,
})),
);
} else if (workspace.userIds?.length) {
setSelectedUsers(
workspace.userIds.map((userId) => ({
id: userId,
name: userId,
})),
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workspace]);
if (getWorkspaceDetail.isPending) {
return <PageLoading />;
}
return (
<div className="w-full mt-4">
<div className="flex w-full justify-between items-center">
<div>{t("edit")}</div>
<div>
<Button
className="px-5"
isLoading={updateWorkspace.isPending}
onClick={() => formik.handleSubmit()}
>
<div className="flex gap-2">
<TickCircle className="size-5" color="#fff" />
<div>{t("submit")}</div>
</div>
</Button>
</div>
</div>
<div className="flex gap-6">
<div className="flex-1">
<div className="flex-1 mt-10 bg-white py-8 xl:px-10 px-4 rounded-3xl">
<div className="text-sm font-bold">{t("taskmanager.workspace_info")}</div>
<div className="mt-8">
<Input
label={t("taskmanager.workspacename")}
{...formik.getFieldProps("name")}
error_text={
formik.touched.name && formik.errors.name
? formik.errors.name
: ""
}
/>
</div>
<div className="mt-8">
<Textarea
label={t("taskmanager.workspace_description")}
{...formik.getFieldProps("description")}
error_text={
formik.touched.description && formik.errors.description
? formik.errors.description
: ""
}
/>
</div>
</div>
</div>
<CreateWorkspaceSidebar
isActive={isActive}
onIsActiveChange={setIsActive}
selectedColor={selectedColor}
onColorChange={setSelectedColor}
selectedUsers={selectedUsers}
onSelectedUsersChange={setSelectedUsers}
/>
</div>
</div>
);
};
export default UpdateWorkspace;
@@ -7,18 +7,30 @@ import { useGetUsers } from "../../../users/hooks/useUserData";
import { UserItemType } from "../../../users/types/UserTypes"; import { UserItemType } from "../../../users/types/UserTypes";
import WorkspaceColorPicker from "./WorkspaceColorPicker"; import WorkspaceColorPicker from "./WorkspaceColorPicker";
type SelectedUser = { export type SelectedUser = {
id: string; id: string;
name: string; name: string;
}; };
const CreateWorkspaceSidebar: FC = () => { type Props = {
isActive: boolean;
onIsActiveChange: (value: boolean) => void;
selectedColor: string;
onColorChange: (color: string) => void;
selectedUsers: SelectedUser[];
onSelectedUsersChange: (users: SelectedUser[]) => void;
};
const CreateWorkspaceSidebar: FC<Props> = ({
isActive,
onIsActiveChange,
selectedColor,
onColorChange,
selectedUsers,
onSelectedUsersChange,
}) => {
const { t } = useTranslation("global"); const { t } = useTranslation("global");
const getUsers = useGetUsers(""); const getUsers = useGetUsers("");
const [isActive, setIsActive] = useState(true);
const [selectedColor, setSelectedColor] = useState("#A8E6CF");
const [selectedUsers, setSelectedUsers] = useState<SelectedUser[]>([]);
const [userSelectValue, setUserSelectValue] = useState(""); const [userSelectValue, setUserSelectValue] = useState("");
const userItems = const userItems =
@@ -41,8 +53,8 @@ const CreateWorkspaceSidebar: FC = () => {
); );
if (user) { if (user) {
setSelectedUsers((prev) => [ onSelectedUsersChange([
...prev, ...selectedUsers,
{ id: user.id, name: `${user.firstName} ${user.lastName}` }, { id: user.id, name: `${user.firstName} ${user.lastName}` },
]); ]);
} }
@@ -51,7 +63,7 @@ const CreateWorkspaceSidebar: FC = () => {
}; };
const handleRemoveUser = (id: string) => { const handleRemoveUser = (id: string) => {
setSelectedUsers((prev) => prev.filter((user) => user.id !== id)); onSelectedUsersChange(selectedUsers.filter((user) => user.id !== id));
}; };
return ( return (
@@ -60,7 +72,7 @@ const CreateWorkspaceSidebar: FC = () => {
<div>{t("taskmanager.workspace_status")}</div> <div>{t("taskmanager.workspace_status")}</div>
<div className="flex gap-2 text-xs items-center text-description"> <div className="flex gap-2 text-xs items-center text-description">
<div>{isActive ? t("slider.active") : t("slider.inactive")}</div> <div>{isActive ? t("slider.active") : t("slider.inactive")}</div>
<SwitchComponent active={isActive} onChange={setIsActive} /> <SwitchComponent active={isActive} onChange={onIsActiveChange} />
</div> </div>
</div> </div>
@@ -69,7 +81,7 @@ const CreateWorkspaceSidebar: FC = () => {
<div className="mt-3"> <div className="mt-3">
<WorkspaceColorPicker <WorkspaceColorPicker
selectedColor={selectedColor} selectedColor={selectedColor}
onChange={setSelectedColor} onChange={onColorChange}
/> />
</div> </div>
</div> </div>
@@ -0,0 +1,63 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as api from "../service/WorkspaceService";
import {
CreateWorkspaceType,
UpdateWorkspaceType,
} from "../types/WorkspaceTypes";
export const useGetWorkspaces = () => {
return useQuery({
queryKey: ["workspaces"],
queryFn: api.getWorkspacesByUser,
});
};
export const useGetWorkspaceDetail = (id: string) => {
return useQuery({
queryKey: ["workspace-detail", id],
queryFn: () => api.getWorkspaceDetail(id),
enabled: !!id,
});
};
export const useCreateWorkspace = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (variables: CreateWorkspaceType) => api.createWorkspace(variables),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["workspaces"],
});
},
});
};
export const useUpdateWorkspace = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
id,
params,
}: {
id: string;
params: UpdateWorkspaceType;
}) => api.updateWorkspace(id, params),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["workspaces"] });
queryClient.invalidateQueries({ queryKey: ["workspace-detail"] });
},
});
};
export const useDeleteWorkspace = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => api.deleteWorkspace(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["workspaces"] });
},
});
};
@@ -0,0 +1,30 @@
import axios from "../../../../config/axios";
import {
CreateWorkspaceType,
UpdateWorkspaceType,
} from "../types/WorkspaceTypes";
export const getWorkspacesByUser = async () => {
const { data } = await axios.get(`/task-manager/workspaces/byuser`);
return data;
};
export const getWorkspaceDetail = async (id: string) => {
const { data } = await axios.get(`/task-manager/workspaces/${id}`);
return data;
};
export const createWorkspace = async (params: CreateWorkspaceType) => {
const { data } = await axios.post(`/task-manager/workspaces`, params);
return data;
};
export const updateWorkspace = async (id: string, params: UpdateWorkspaceType) => {
const { data } = await axios.patch(`/task-manager/workspaces/${id}`, params);
return data;
};
export const deleteWorkspace = async (id: string) => {
const { data } = await axios.delete(`/task-manager/workspaces/${id}`);
return data;
};
@@ -0,0 +1,28 @@
export type CreateWorkspaceType = {
name: string;
description: string;
color: string;
isActive: boolean;
userIds: string[];
};
export type WorkspaceItemType = {
id: string;
name: string;
description?: string;
color: string;
isActive: boolean;
};
export type WorkspaceUserType = {
id: string;
firstName: string;
lastName: string;
};
export type WorkspaceDetailType = WorkspaceItemType & {
userIds?: string[];
users?: WorkspaceUserType[];
};
export type UpdateWorkspaceType = CreateWorkspaceType;
+392 -100
View File
@@ -80,8 +80,11 @@ import PlanUsers from "../pages/support/PlanUsers";
import UpdateSupport from "../pages/support/Update"; import UpdateSupport from "../pages/support/Update";
import CreateProject from "../pages/taskmanager/project/Create.tsx"; import CreateProject from "../pages/taskmanager/project/Create.tsx";
import ProjectList from "../pages/taskmanager/project/List.tsx"; import ProjectList from "../pages/taskmanager/project/List.tsx";
import UpdateProject from "../pages/taskmanager/project/Update.tsx";
import Workspace from "../pages/taskmanager/workspace.tsx"; import Workspace from "../pages/taskmanager/workspace.tsx";
import CreateWorkspace from "../pages/taskmanager/workspace/Create.tsx"; import CreateWorkspace from "../pages/taskmanager/workspace/Create.tsx";
import WorkspaceList from "../pages/taskmanager/workspace/List.tsx";
import UpdateWorkspace from "../pages/taskmanager/workspace/Update.tsx";
import TicketCategory from "../pages/ticket/Category"; import TicketCategory from "../pages/ticket/Category";
import CreateTicket from "../pages/ticket/CreateTicket"; import CreateTicket from "../pages/ticket/CreateTicket";
import TicketDetail from "../pages/ticket/Detail"; import TicketDetail from "../pages/ticket/Detail";
@@ -111,115 +114,404 @@ const MainRouter: FC = () => {
{!isWorkspace && <SideBar />} {!isWorkspace && <SideBar />}
<Header /> <Header />
<div className={clx("flex-1 mt-[68px] xl:mt-[81px]", !isWorkspace && "xl:ms-[269px]", !isWorkspace && hasSubMenu && "xl:ms-[305px]")}> <div className={clx("flex-1 mt-[68px] xl:mt-[81px]", !isWorkspace && "xl:ms-[269px]", !isWorkspace && hasSubMenu && "xl:ms-[305px]")}>
<div <div className={clx("flex flex-col", isWorkspace ? "h-[calc(100dvh-68px)] xl:h-[calc(100dvh-97px)] overflow-hidden" : "overflow-auto h-[calc(100vh-113px)]")}>
className={clx(
"flex flex-col",
isWorkspace ? "h-[calc(100dvh-68px)] xl:h-[calc(100dvh-97px)] overflow-hidden" : "overflow-auto h-[calc(100vh-113px)]",
)}
>
<div className={clx("flex-1 min-h-0", isWorkspace && "overflow-hidden")}> <div className={clx("flex-1 min-h-0", isWorkspace && "overflow-hidden")}>
<Routes> <Routes>
<Route path={Pages.dashboard} element={<Home />} /> <Route
<Route path={Pages.services.mine} element={<MyServices />} /> path={Pages.dashboard}
<Route path={Pages.services.other} element={<OtherServices />} /> element={<Home />}
<Route path={Pages.services.detail + ":id"} element={<DetailService />} /> />
<Route path={Pages.services.add} element={<AddService />} /> <Route
<Route path={Pages.services.list} element={<ListService />} /> path={Pages.services.mine}
<Route path={Pages.services.update + ":id"} element={<UpdateService />} /> element={<MyServices />}
<Route path={Pages.services.category} element={<Category />} /> />
<Route path={Pages.services.plan + ":id"} element={<Plans />} /> <Route
<Route path={Pages.services.review} element={<Reviews />} /> path={Pages.services.other}
<Route path={Pages.ticket.list} element={<TicketList />} /> element={<OtherServices />}
<Route path={Pages.ticket.create} element={<CreateTicket />} /> />
<Route path={Pages.ticket.category} element={<TicketCategory />} /> <Route
<Route path={Pages.ticket.detail + ":id"} element={<TicketDetail />} /> path={Pages.services.detail + ":id"}
<Route path={Pages.ads.list} element={<AdsList />} /> element={<DetailService />}
<Route path={Pages.ads.create} element={<CreateAds />} /> />
<Route path={Pages.ads.update + ":id"} element={<UpdateAds />} /> <Route
<Route path={Pages.discount.list} element={<DiscountsList />} /> path={Pages.services.add}
<Route path={Pages.discount.create} element={<CreateDiscount />} /> element={<AddService />}
<Route path={Pages.discount.detail + ":id"} element={<UpdateDiscount />} /> />
<Route path={Pages.blog.list} element={<BlogList />} /> <Route
<Route path={Pages.blog.create} element={<CreateBlog />} /> path={Pages.services.list}
<Route path={Pages.blog.category} element={<BlogCategory />} /> element={<ListService />}
<Route path={Pages.blog.detail + ":id"} element={<UpdateBlog />} /> />
<Route path={Pages.transactions} element={<TransactionList />} /> <Route
<Route path={Pages.receipts.index} element={<ReceiptsList />} /> path={Pages.services.update + ":id"}
<Route path={Pages.receipts.detail + ":id"} element={<ReceiptsDetail />} /> element={<UpdateService />}
<Route path={Pages.receipts.create} element={<CreateReceipt />} /> />
<Route path={Pages.announcement.list} element={<AnnouncementtList />} /> <Route
<Route path={Pages.announcement.detail + ":id"} element={<Update />} /> path={Pages.services.category}
<Route path={Pages.announcement.create} element={<Create />} /> element={<Category />}
<Route path={Pages.criticisms.list} element={<CriticismsList />} /> />
<Route path={Pages.criticisms.detail + ":id"} element={<CriticismsDetail />} /> <Route
<Route path={Pages.learning.list} element={<LearningList />} /> path={Pages.services.plan + ":id"}
<Route path={Pages.learning.create} element={<CreateLearning />} /> element={<Plans />}
<Route path={Pages.learning.category} element={<LearningCategory />} /> />
<Route path={Pages.setting} element={<Setting />} /> <Route
<Route path={Pages.wallet} element={<Wallet />} /> path={Pages.services.review}
<Route path={Pages.profile} element={<Profile />} /> element={<Reviews />}
<Route path={Pages.customer.list} element={<CustomerList />} /> />
<Route path={Pages.customer.create} element={<CreateCustomer />} /> <Route
<Route path={Pages.customer.update + ":id"} element={<UpdateCustomer />} /> path={Pages.ticket.list}
<Route path={Pages.messages.list} element={<MessagesList />} /> element={<TicketList />}
<Route path={Pages.messages.detail + ":id"} element={<MessageDetail />} /> />
<Route path={Pages.users.list} element={<UsersList />} /> <Route
<Route path={Pages.users.create} element={<CreateUser />} /> path={Pages.ticket.create}
<Route path={Pages.users.detail + ":id"} element={<UpdateUser />} /> element={<CreateTicket />}
<Route path={Pages.users.roleList} element={<RolesList />} /> />
<Route path={Pages.users.roleCreate} element={<RoleCreate />} /> <Route
<Route path={Pages.users.roleUpdate + ":id"} element={<RoleUpdate />} /> path={Pages.ticket.category}
<Route path={Pages.users.groupAdd} element={<GroupCreate />} /> element={<TicketCategory />}
<Route path={Pages.users.groupList} element={<GroupList />} /> />
<Route path={Pages.cardBank.create} element={<CreateBankCard />} /> <Route
<Route path={Pages.cardBank.list} element={<CardBankList />} /> path={Pages.ticket.detail + ":id"}
<Route path={Pages.cardBank.detail + ":id"} element={<EditBankCard />} /> element={<TicketDetail />}
<Route path={Pages.payment.list} element={<PaymentList />} /> />
<Route path={Pages.sliders.create} element={<CreateSlider />} /> <Route
<Route path={Pages.sliders.list} element={<SliderList />} /> path={Pages.ads.list}
<Route path={Pages.sliders.detail + ":id"} element={<UpdateSlider />} /> element={<AdsList />}
<Route path={Pages.blog.comments} element={<Comments />} /> />
<Route path={Pages.support.create} element={<CreatePlan />} /> <Route
<Route path={Pages.support.list} element={<SupportList />} /> path={Pages.ads.create}
<Route path={Pages.support.detail + ":id"} element={<UpdateSupport />} /> element={<CreateAds />}
<Route path={Pages.support.planUsers + ":id"} element={<PlanUsers />} /> />
<Route path={Pages.services.guide + ":id"} element={<List />} /> <Route
<Route path={Pages.services.guide + ":id/create"} element={<CreateGuide />} /> path={Pages.ads.update + ":id"}
<Route path={Pages.services.guide + ":id/:guideId"} element={<UpdateGuide />} /> element={<UpdateAds />}
<Route path={Pages.feedback.list} element={<ListFeedback />} /> />
<Route path={Pages.accessLogs.list} element={<AccessLogsList />} /> <Route
<Route path={Pages.accessLogs.stats} element={<AccessLogsStats />} /> path={Pages.discount.list}
<Route path={Pages.accessLogs.errors} element={<ErrorLogs />} /> element={<DiscountsList />}
<Route path={Pages.accessLogs.permissions} element={<PermissionLogs />} /> />
<Route
path={Pages.discount.create}
element={<CreateDiscount />}
/>
<Route
path={Pages.discount.detail + ":id"}
element={<UpdateDiscount />}
/>
<Route
path={Pages.blog.list}
element={<BlogList />}
/>
<Route
path={Pages.blog.create}
element={<CreateBlog />}
/>
<Route
path={Pages.blog.category}
element={<BlogCategory />}
/>
<Route
path={Pages.blog.detail + ":id"}
element={<UpdateBlog />}
/>
<Route
path={Pages.transactions}
element={<TransactionList />}
/>
<Route
path={Pages.receipts.index}
element={<ReceiptsList />}
/>
<Route
path={Pages.receipts.detail + ":id"}
element={<ReceiptsDetail />}
/>
<Route
path={Pages.receipts.create}
element={<CreateReceipt />}
/>
<Route
path={Pages.announcement.list}
element={<AnnouncementtList />}
/>
<Route
path={Pages.announcement.detail + ":id"}
element={<Update />}
/>
<Route
path={Pages.announcement.create}
element={<Create />}
/>
<Route
path={Pages.criticisms.list}
element={<CriticismsList />}
/>
<Route
path={Pages.criticisms.detail + ":id"}
element={<CriticismsDetail />}
/>
<Route
path={Pages.learning.list}
element={<LearningList />}
/>
<Route
path={Pages.learning.create}
element={<CreateLearning />}
/>
<Route
path={Pages.learning.category}
element={<LearningCategory />}
/>
<Route
path={Pages.setting}
element={<Setting />}
/>
<Route
path={Pages.wallet}
element={<Wallet />}
/>
<Route
path={Pages.profile}
element={<Profile />}
/>
<Route
path={Pages.customer.list}
element={<CustomerList />}
/>
<Route
path={Pages.customer.create}
element={<CreateCustomer />}
/>
<Route
path={Pages.customer.update + ":id"}
element={<UpdateCustomer />}
/>
<Route
path={Pages.messages.list}
element={<MessagesList />}
/>
<Route
path={Pages.messages.detail + ":id"}
element={<MessageDetail />}
/>
<Route
path={Pages.users.list}
element={<UsersList />}
/>
<Route
path={Pages.users.create}
element={<CreateUser />}
/>
<Route
path={Pages.users.detail + ":id"}
element={<UpdateUser />}
/>
<Route
path={Pages.users.roleList}
element={<RolesList />}
/>
<Route
path={Pages.users.roleCreate}
element={<RoleCreate />}
/>
<Route
path={Pages.users.roleUpdate + ":id"}
element={<RoleUpdate />}
/>
<Route
path={Pages.users.groupAdd}
element={<GroupCreate />}
/>
<Route
path={Pages.users.groupList}
element={<GroupList />}
/>
<Route
path={Pages.cardBank.create}
element={<CreateBankCard />}
/>
<Route
path={Pages.cardBank.list}
element={<CardBankList />}
/>
<Route
path={Pages.cardBank.detail + ":id"}
element={<EditBankCard />}
/>
<Route
path={Pages.payment.list}
element={<PaymentList />}
/>
<Route
path={Pages.sliders.create}
element={<CreateSlider />}
/>
<Route
path={Pages.sliders.list}
element={<SliderList />}
/>
<Route
path={Pages.sliders.detail + ":id"}
element={<UpdateSlider />}
/>
<Route
path={Pages.blog.comments}
element={<Comments />}
/>
<Route
path={Pages.support.create}
element={<CreatePlan />}
/>
<Route
path={Pages.support.list}
element={<SupportList />}
/>
<Route
path={Pages.support.detail + ":id"}
element={<UpdateSupport />}
/>
<Route
path={Pages.support.planUsers + ":id"}
element={<PlanUsers />}
/>
<Route
path={Pages.services.guide + ":id"}
element={<List />}
/>
<Route
path={Pages.services.guide + ":id/create"}
element={<CreateGuide />}
/>
<Route
path={Pages.services.guide + ":id/:guideId"}
element={<UpdateGuide />}
/>
<Route
path={Pages.feedback.list}
element={<ListFeedback />}
/>
<Route
path={Pages.accessLogs.list}
element={<AccessLogsList />}
/>
<Route
path={Pages.accessLogs.stats}
element={<AccessLogsStats />}
/>
<Route
path={Pages.accessLogs.errors}
element={<ErrorLogs />}
/>
<Route
path={Pages.accessLogs.permissions}
element={<PermissionLogs />}
/>
<Route path={Pages.dmenu.icons.list} element={<IconsList />} /> <Route
<Route path={Pages.dmenu.icons.groupList} element={<GroupIconList />} /> path={Pages.dmenu.icons.list}
<Route path={Pages.dmenu.warnings.list} element={<WarningsList />} /> element={<IconsList />}
<Route path={Pages.dmenu.restaurants.list} element={<RestaurantsList />} /> />
<Route path={Pages.dmenu.restaurants.smsCountList} element={<SmsCountList />} /> <Route
<Route path={Pages.dmenu.restaurants.update + ":id"} element={<UpdateRestaurant />} /> path={Pages.dmenu.icons.groupList}
element={<GroupIconList />}
/>
<Route
path={Pages.dmenu.warnings.list}
element={<WarningsList />}
/>
<Route
path={Pages.dmenu.restaurants.list}
element={<RestaurantsList />}
/>
<Route
path={Pages.dmenu.restaurants.smsCountList}
element={<SmsCountList />}
/>
<Route
path={Pages.dmenu.restaurants.update + ":id"}
element={<UpdateRestaurant />}
/>
<Route path={Pages.dkala.icons.list} element={<DkalaIconsList />} /> <Route
<Route path={Pages.dkala.icons.groupList} element={<DkalaGroupIconList />} /> path={Pages.dkala.icons.list}
<Route path={Pages.dkala.warnings.list} element={<DkalaWarningsList />} /> element={<DkalaIconsList />}
<Route path={Pages.dkala.shops.list} element={<ShopsList />} /> />
<Route path={Pages.dkala.shops.smsCountList} element={<DkalaSmsCountList />} /> <Route
<Route path={Pages.dkala.shops.update + ":id"} element={<UpdateShop />} /> path={Pages.dkala.icons.groupList}
element={<DkalaGroupIconList />}
/>
<Route
path={Pages.dkala.warnings.list}
element={<DkalaWarningsList />}
/>
<Route
path={Pages.dkala.shops.list}
element={<ShopsList />}
/>
<Route
path={Pages.dkala.shops.smsCountList}
element={<DkalaSmsCountList />}
/>
<Route
path={Pages.dkala.shops.update + ":id"}
element={<UpdateShop />}
/>
<Route path={Pages.dpage.stickers.list} element={<DpageStickersList />} /> <Route
path={Pages.dpage.stickers.list}
element={<DpageStickersList />}
/>
<Route path={Pages.subscriptions.list} element={<SubscriptionsList />} /> <Route
path={Pages.subscriptions.list}
element={<SubscriptionsList />}
/>
<Route path={Pages.dmail.list} element={<Domains />} /> <Route
path={Pages.dmail.list}
element={<Domains />}
/>
<Route path={Pages.reseller.list} element={<ResellerList />} /> <Route
<Route path={Pages.reseller.create} element={<CreateReseller />} /> path={Pages.reseller.list}
<Route path={Pages.reseller.withdraw} element={<Withdraw />} /> element={<ResellerList />}
/>
<Route
path={Pages.reseller.create}
element={<CreateReseller />}
/>
<Route
path={Pages.reseller.withdraw}
element={<Withdraw />}
/>
<Route path={Pages.taskmanager.workspace + ":slug"} element={<Workspace />} /> <Route
<Route path={Pages.taskmanager.createWorkspace} element={<CreateWorkspace />} /> path={Pages.taskmanager.workspace + ":slug"}
<Route path={Pages.taskmanager.createProject} element={<CreateProject />} /> element={<Workspace />}
<Route path={Pages.taskmanager.projectList} element={<ProjectList />} /> />
<Route
path={Pages.taskmanager.workspaceList}
element={<WorkspaceList />}
/>
<Route
path={Pages.taskmanager.createWorkspace}
element={<CreateWorkspace />}
/>
<Route
path={Pages.taskmanager.updateWorkspace + ":id"}
element={<UpdateWorkspace />}
/>
<Route
path={Pages.taskmanager.createProject}
element={<CreateProject />}
/>
<Route
path={Pages.taskmanager.updateProject + ":id"}
element={<UpdateProject />}
/>
<Route
path={Pages.taskmanager.projectList + ":workspaceId"}
element={<ProjectList />}
/>
</Routes> </Routes>
</div> </div>
{!isWorkspace && <div className="h-20 shrink-0 xl:hidden" />} {!isWorkspace && <div className="h-20 shrink-0 xl:hidden" />}
+51 -6
View File
@@ -1,5 +1,6 @@
import { import {
Activity, Activity,
Add,
ArrowDown2, ArrowDown2,
BagTick, BagTick,
Building, Building,
@@ -51,6 +52,7 @@ import LearningSubMenu from "./components/LearningSubMenu";
import ReceiptSubMenu from "./components/ReceiptSubMenu"; import ReceiptSubMenu from "./components/ReceiptSubMenu";
import ServicesSubMenu from "./components/ServicesSubMenu"; import ServicesSubMenu from "./components/ServicesSubMenu";
import SupportSubMenu from "./components/SupportSubMenu"; import SupportSubMenu from "./components/SupportSubMenu";
import TaskManagerSubMenu from "./components/TaskManagerSubMenu";
import TicketSubMenu from "./components/TicketSubMenu"; import TicketSubMenu from "./components/TicketSubMenu";
import UsersSubMenu from "./components/UsersSubMenu"; import UsersSubMenu from "./components/UsersSubMenu";
import SideBarItem from "./SideBarItem"; import SideBarItem from "./SideBarItem";
@@ -69,10 +71,20 @@ const SideBar: FC = () => {
useEffect(() => { useEffect(() => {
const split = location.pathname.split("/"); const split = location.pathname.split("/");
const isTaskManagerRoute =
location.pathname.startsWith(Pages.taskmanager.workspace) ||
location.pathname === Pages.taskmanager.workspaceList ||
location.pathname.startsWith(Pages.taskmanager.updateWorkspace) ||
location.pathname === Pages.taskmanager.createWorkspace ||
location.pathname === Pages.taskmanager.createProject ||
location.pathname === Pages.taskmanager.projectList;
if (SideBarItemHasSubMenu.includes(split[1])) { if (SideBarItemHasSubMenu.includes(split[1])) {
setSubMenuName(split[1]); setSubMenuName(split[1]);
setSubtMenu(true); setSubtMenu(true);
} else if (isTaskManagerRoute) {
setSubMenuName("taskmanager");
setSubtMenu(true);
} else { } else {
setSubMenuName(""); setSubMenuName("");
setSubtMenu(false); setSubtMenu(false);
@@ -459,18 +471,49 @@ const SideBar: FC = () => {
</div> </div>
</div> </div>
} }
<div className={clx("text-xs text-[#8C90A3]")}> <div className={clx("text-xs text-[#8C90A3]", !openMenu.includes("taskmanager") && "hidden")}>
<SideBarItem <SideBarItem
icon={ icon={
<Task <Task
variant={isActive("taskmanager") ? "Bold" : "Outline"} variant={location.pathname === Pages.taskmanager.workspaceList ? "Bold" : "Outline"}
color={isActive("taskmanager") ? "black" : "#8C90A3"} color={location.pathname === Pages.taskmanager.workspaceList ? "black" : "#8C90A3"}
size={iconSizeSideBar} size={iconSizeSideBar}
/> />
} }
title={t("sidebar.taskmanager")} title={t("submenu.taskmanager_workspace_list")}
isActive={isActive("taskmanager")} isActive={location.pathname === Pages.taskmanager.workspaceList}
link={Pages.taskmanager.workspace + "dpage"} link={Pages.taskmanager.workspaceList}
name="taskmanager"
activeName=""
/>
<SideBarItem
icon={
<Add
variant={location.pathname === Pages.taskmanager.createWorkspace ? "Bold" : "Outline"}
color={location.pathname === Pages.taskmanager.createWorkspace ? "black" : "#8C90A3"}
size={iconSizeSideBar}
/>
}
title={t("submenu.taskmanager_create_workspace")}
isActive={location.pathname === Pages.taskmanager.createWorkspace}
link={Pages.taskmanager.createWorkspace}
name="taskmanager"
activeName=""
/>
<SideBarItem
icon={
<Add
variant={location.pathname === Pages.taskmanager.createProject ? "Bold" : "Outline"}
color={location.pathname === Pages.taskmanager.createProject ? "black" : "#8C90A3"}
size={iconSizeSideBar}
/>
}
title={t("submenu.taskmanager_create_project")}
isActive={location.pathname === Pages.taskmanager.createProject}
link={Pages.taskmanager.createProject}
name="taskmanager"
activeName="" activeName=""
/> />
</div> </div>
@@ -948,6 +991,8 @@ const SideBar: FC = () => {
<SupportSubMenu /> <SupportSubMenu />
) : subMenuName === "accessLogs" ? ( ) : subMenuName === "accessLogs" ? (
<AccessLogsSubMenu /> <AccessLogsSubMenu />
) : subMenuName === "taskmanager" ? (
<TaskManagerSubMenu />
) : null} ) : null}
</div> </div>
)} )}
@@ -0,0 +1,58 @@
import { Task } from "iconsax-react";
import { FC } from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import { Pages } from "../../config/Pages";
import SubMenuItem from "./SubMenuItem";
const TaskManagerSubMenu: FC = () => {
const { t } = useTranslation("global");
const location = useLocation();
const isWorkspaceListActive = location.pathname === Pages.taskmanager.workspaceList;
const isKanbanActive =
location.pathname.startsWith(Pages.taskmanager.workspace) &&
!isWorkspaceListActive;
const isCreateWorkspaceActive = location.pathname === Pages.taskmanager.createWorkspace;
const isProjectListActive = location.pathname === Pages.taskmanager.projectList;
const isCreateProjectActive = location.pathname === Pages.taskmanager.createProject;
return (
<div className="py-12">
<div className="flex gap-3 items-center border-b pb-10 px-6">
<Task size={24} color="#000" variant="Bold" />
<div className="text-xs">{t("sidebar.taskmanager")}</div>
</div>
<div className="flex flex-col mt-10 gap-4">
<SubMenuItem
title={t("submenu.taskmanager_workspace_list")}
isActive={isWorkspaceListActive}
link={Pages.taskmanager.workspaceList}
/>
<SubMenuItem
title={t("submenu.taskmanager_workspace")}
isActive={isKanbanActive}
link={Pages.taskmanager.workspace + "dpage"}
/>
<SubMenuItem
title={t("submenu.taskmanager_create_workspace")}
isActive={isCreateWorkspaceActive}
link={Pages.taskmanager.createWorkspace}
/>
<SubMenuItem
title={t("submenu.taskmanager_project_list")}
isActive={isProjectListActive}
link={Pages.taskmanager.projectList}
/>
<SubMenuItem
title={t("submenu.taskmanager_create_project")}
isActive={isCreateProjectActive}
link={Pages.taskmanager.createProject}
/>
</div>
</div>
);
};
export default TaskManagerSubMenu;