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: {
workspace: "/taskmanager/workspace/",
workspaceList: "/workspace/list",
createWorkspace: "/workspace/create",
updateWorkspace: "/workspace/update/",
createProject: "/project/create",
projectList: "/project/list",
updateProject: "/project/update/",
projectList: "/project/list/",
},
};
+1
View File
@@ -8,4 +8,5 @@ export const SideBarItemHasSubMenu = [
"blog",
"support",
"accessLogs",
"taskmanager",
];
+22 -1
View File
@@ -143,7 +143,12 @@
"guide": "راهنمای سرویس",
"access_logs_list": "لیست لاگ‌ها",
"access_logs_stats": "آمار لاگ‌ها",
"access_logs_errors": "لاگ‌های خطا"
"access_logs_errors": "لاگ‌های خطا",
"taskmanager_workspace": "فضای کاری",
"taskmanager_workspace_list": "لیست فضاهای کاری",
"taskmanager_create_workspace": "ساخت فضای کاری جدید",
"taskmanager_project_list": "لیست پروژه‌ها",
"taskmanager_create_project": "افزودن پروژه"
},
"guide": {
"list_guide": "لیست راهنمای سرویس",
@@ -968,15 +973,19 @@
},
"cancel": "لغو",
"taskmanager": {
"workspace_list": "لیست فضاهای کاری",
"new_workspace": "ساخت فضای کاری جدید",
"submit_workspace": "ثبت فضای کاری",
"workspace_deleted": "فضای کاری با موفقیت حذف شد",
"workspacename": "نام",
"workspacetype": "نوع",
"workspace_info": "اطلاعات",
"workspace_description": "توضیحات",
"workspace_status": "وضعیت فضای کار",
"choose_color": "انتخاب رنگ دلخواه",
"users": "کاربران",
"select_user": "انتخاب کاربر",
"select_workspace": "انتخاب فضای کاری",
"no_users_added": "هنوز کاربری اضافه نکردی",
"new_project": "افزودن پروژه",
"submit_project": "ثبت پروژه",
@@ -991,6 +1000,18 @@
"select_date": "انتخاب تاریخ",
"projects": "پروژه‌ها",
"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": {
"labels": "برچسب ها",
"user_management": "مدیریت کاربران",
@@ -1,13 +1,147 @@
import { AddCircle } from "iconsax-react";
import { type FC } from "react";
import { Add, AddCircle, CloseCircle } from "iconsax-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 AddNewColumn: FC = () => {
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" 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">
<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">اضافه کردن ستون جدید</span>
<span className="text-xs text-black">{t("taskmanager.add_new_column")}</span>
</button>
);
}
return (
<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">
<input
type="text"
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>
);
};
export default AddNewColumn;
+93 -10
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 { 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 ColumnMenu from "./ColumnMenu";
import Task from "./Task";
type Props = {
column: ColumnType;
projectId: string;
tasks: TaskType[];
draggedTaskId: string | null;
draggedColumnId: string | null;
@@ -15,10 +24,13 @@ type Props = {
onColumnDragEnd: () => void;
onColumnDrop: (overColumnId: string) => void;
onTaskClick?: (task: TaskType) => void;
onColumnDeleted?: (columnId: string) => void;
onTaskCreated?: (task: TaskItemType, taskPhaseId: string) => void;
};
const Column: FC<Props> = ({
column,
projectId,
tasks,
draggedTaskId,
draggedColumnId,
@@ -29,8 +41,16 @@ const Column: FC<Props> = ({
onColumnDragEnd,
onColumnDrop,
onTaskClick,
onColumnDeleted,
onTaskCreated,
}) => {
const { t } = useTranslation("global");
const deleteTaskPhase = useDeleteTaskPhase();
const createTask = useCreateTask();
const [isAdding, setIsAdding] = useState(false);
const [title, setTitle] = useState("");
const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
const [isColumnDragOver, setIsColumnDragOver] = useState(false);
const columnRef = useRef<HTMLDivElement>(null);
@@ -116,6 +136,52 @@ const Column: FC<Props> = ({
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 (
<div
ref={columnRef}
@@ -139,15 +205,25 @@ const Column: FC<Props> = ({
}
}}
>
<div className="flex justify-between items-center shrink-0 gap-2">
<div
draggable
onDragStart={handleColumnDragStart}
onDragEnd={handleColumnDragEnd}
className="flex justify-between items-center shrink-0 cursor-grab active:cursor-grabbing"
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>
<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">
{tasks.map((task, index) => (
@@ -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">
<input
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"
autoFocus
/>
<div className="flex items-center gap-2">
<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" />
</button>
<button
type="button"
onClick={() => setIsAdding(false)}
onClick={resetTaskForm}
className="cursor-pointer"
aria-label="انصراف"
aria-label={t("cancel")}
>
<CloseCircle size={22} color="#888888" variant="Bold" />
</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"
>
<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>
)}
</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 = {
value?: string;
onChange?: (value: string) => void;
onBlur?: () => void;
};
const TaskDetailDescription: FC<Props> = ({ value = "", onChange }) => {
const TaskDetailDescription: FC<Props> = ({ value = "", onChange, onBlur }) => {
const { t } = useTranslation("global");
return (
@@ -15,6 +16,7 @@ const TaskDetailDescription: FC<Props> = ({ value = "", onChange }) => {
<textarea
value={value}
onChange={(e) => onChange?.(e.target.value)}
onBlur={onBlur}
placeholder={t("taskmanager.task_detail.description_placeholder")}
rows={5}
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 { type FC } from "react";
import TrashWithConfrim from "../../../../components/TrashWithConfrim";
import { clx } from "../../../../helpers/utils";
import type { Column } from "../../types";
type Props = {
statusLabel: string;
columns: Column[];
selectedPhaseId: string;
onPhaseChange: (phaseId: string) => void;
isChangingPhase?: boolean;
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 (
<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">
<span>{statusLabel}</span>
<Popover className="relative">
<PopoverButton
disabled={isChangingPhase}
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">
{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="بستن">
<CloseCircle size={20} color="#292D32" />
</button>
@@ -12,9 +12,11 @@ type Props = {
selectedUsers: TaskUser[];
startDate: 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 dateLabel = formatDateRange(startDate, endDate);
@@ -34,6 +36,7 @@ const TaskDetailMetadataPreview: FC<Props> = ({ selectedLabels, selectedUsers, s
))}
<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"
aria-label={t("taskmanager.task_detail.new_label")}
>
@@ -62,6 +65,7 @@ const TaskDetailMetadataPreview: FC<Props> = ({ selectedLabels, selectedUsers, s
))}
<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"
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 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 CheckLists from "./checklist/List";
import TaskDetailDescription from "./TaskDetailDescription";
@@ -11,42 +17,170 @@ import type { TaskDetailTab } from "./types";
type Props = {
open: boolean;
task: Task | null;
statusLabel: string;
columns: Column[];
projectId: string;
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 [title, setTitle] = 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(() => {
if (!open) {
setActiveTab(null);
setTitle("");
setDescription("");
initialTitleRef.current = "";
initialDescriptionRef.current = "";
}
}, [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) => {
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 (
<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">
<TaskDetailToolbar activeTab={activeTab} onTabChange={handleTabChange} />
<TaskDetailToolbar
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>
);
};
@@ -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 { DEFAULT_DATE_RANGE } from "./date/constants";
import TaskDetailDatePopover from "./date/TaskDetailDatePopover";
import { parseApiDate, toApiDate } from "./date/utils";
import type DateObject from "react-date-object";
import { DEFAULT_CHECKLISTS } from "./checklist/constants";
import TaskDetailChecklistPopover from "./checklist/TaskDetailChecklistPopover";
import type { TaskChecklist } from "./checklist/types";
import { DEFAULT_LABELS } from "./labels/constants";
import TaskDetailLabelsPopover from "./labels/TaskDetailLabelsPopover";
import type { LabelsView, TaskLabel } from "./labels/types";
import TaskDetailActionBar from "./TaskDetailActionBar";
import TaskDetailMetadataPreview from "./TaskDetailMetadataPreview";
import type { TaskDetailTab } from "./types";
import { DEFAULT_USERS } from "./users/constants";
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 = {
activeTab: TaskDetailTab | null;
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 [labels, setLabels] = useState<TaskLabel[]>(DEFAULT_LABELS);
const [selectedLabelIds, setSelectedLabelIds] = useState<Set<string>>(new Set(["1"]));
const [selectedUserIds, setSelectedUserIds] = useState<Set<string>>(new Set(["1"]));
const [checklists, setChecklists] = useState<TaskChecklist[]>(DEFAULT_CHECKLISTS);
const [startDate, setStartDate] = useState<DateObject | null>(DEFAULT_DATE_RANGE.startDate);
const [endDate, setEndDate] = useState<DateObject | null>(DEFAULT_DATE_RANGE.endDate);
const [labels, setLabels] = useState<TaskLabel[]>([]);
const [selectedLabelIds, setSelectedLabelIds] = useState<Set<string>>(new Set());
const [selectedUserIds, setSelectedUserIds] = useState<Set<string>>(new Set());
const [startDate, setStartDate] = useState<DateObject | null>(null);
const [endDate, setEndDate] = useState<DateObject | null>(null);
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 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 isUsersOpen = activeTab === "users";
const isChecklistOpen = activeTab === "checklist";
@@ -44,6 +99,19 @@ const TaskDetailToolbar: FC<Props> = ({ activeTab, onTabChange }) => {
onTabChange(tab);
};
const handleAddLabelClick = () => {
setLabelsView("create");
if (activeTab !== "labels") {
onTabChange("labels");
}
};
const handleAddUserClick = () => {
if (activeTab !== "users") {
onTabChange("users");
}
};
const handleLabelToggle = (id: string) => {
setSelectedLabelIds((prev) => {
const next = new Set(prev);
@@ -57,57 +125,162 @@ const TaskDetailToolbar: FC<Props> = ({ activeTab, onTabChange }) => {
};
const handleUserToggle = (id: string) => {
setSelectedUserIds((prev) => {
const next = new Set(prev);
const effectiveTaskId = taskId || taskDetail?.id;
if (!effectiveTaskId) return;
const previous = selectedUserIds;
const next = new Set(previous);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
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 effectiveTaskId = taskId || taskDetail?.id;
if (!effectiveTaskId) return;
createTaskRemark.mutate(
{ title, color, taskId: effectiveTaskId },
{
onSuccess: (response) => {
const remark =
response.data?.remark ??
(response as { remark?: TaskLabel }).remark ??
(response.data as TaskLabel | undefined);
if (remark?.id) {
const newLabel: TaskLabel = {
id: crypto.randomUUID(),
title,
color,
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 = () => {
if (isChecklistOpen) onTabChange("checklist");
};
const handleChecklistCreate = (title: string, _copyFromId: string) => {
const newChecklist: TaskChecklist = {
id: crypto.randomUUID(),
title,
};
setChecklists((prev) => [...prev, newChecklist]);
const handleChecklistCreate = (title: string) => {
const effectiveTaskId = taskId || taskDetail?.id;
if (!effectiveTaskId) return;
createCheckListItem.mutate(
{ title, isDone: false, taskId: effectiveTaskId },
{
onSuccess: () => {
handleChecklistClose();
toast.success(t("success"));
},
onError: (error: ErrorType) => {
toast.error(error.response?.data?.error.message[0]);
},
},
);
};
const handleAttachmentClose = () => {
if (isAttachmentOpen) onTabChange("attachment");
};
const handleAttachmentCreate = (_data: { file?: File; title: string; linkId: string }) => {
const handleAttachmentCreate = async (data: { file?: File; title: string; linkId: string }) => {
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 = () => {
if (isDateOpen) onTabChange("date");
};
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);
setEndDate(data.endDate);
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 (
@@ -124,31 +297,48 @@ const TaskDetailToolbar: FC<Props> = ({ activeTab, onTabChange }) => {
onViewChange={setLabelsView}
onToggle={handleLabelToggle}
onCreate={handleCreate}
isSubmitting={createTaskRemark.isPending}
/>
) : null
}
usersPopover={
isUsersOpen ? (
<TaskDetailUsersPopover users={DEFAULT_USERS} selectedIds={selectedUserIds} onToggle={handleUserToggle} />
<TaskDetailUsersPopover
users={users}
selectedIds={selectedUserIds}
onToggle={handleUserToggle}
isLoading={getUsers.isPending}
isSubmitting={updateTask.isPending}
/>
) : null
}
checklistPopover={
isChecklistOpen ? (
<TaskDetailChecklistPopover
checklists={checklists}
onClose={handleChecklistClose}
onSubmit={handleChecklistCreate}
isSubmitting={createCheckListItem.isPending}
/>
) : null
}
attachmentPopover={
isAttachmentOpen ? (
<TaskDetailAttachmentPopover onClose={handleAttachmentClose} onSubmit={handleAttachmentCreate} />
<TaskDetailAttachmentPopover
onClose={handleAttachmentClose}
onSubmit={handleAttachmentCreate}
isSubmitting={isAttachmentSubmitting}
/>
) : null
}
datePopover={
isDateOpen ? (
<TaskDetailDatePopover onClose={handleDateClose} onSubmit={handleDateSubmit} />
<TaskDetailDatePopover
onClose={handleDateClose}
onSubmit={handleDateSubmit}
initialStartDate={startDate}
initialEndDate={endDate}
isSubmitting={updateTask.isPending}
/>
) : null
}
/>
@@ -158,6 +348,8 @@ const TaskDetailToolbar: FC<Props> = ({ activeTab, onTabChange }) => {
selectedUsers={selectedUsers}
startDate={startDate}
endDate={endDate}
onAddLabel={handleAddLabelClick}
onAddUser={handleAddUserClick}
/>
</div>
);
@@ -5,7 +5,7 @@ import { timeAgo } from "../../../../../config/func";
type Props = {
title: string;
createdAt: string;
createdAt?: string;
type: "file" | "link";
url?: string;
onEdit: () => void;
@@ -21,14 +21,14 @@ const AttachmentItem: FC<Props> = ({ title, createdAt, type, url, onEdit, onDele
</div>
<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">
{title}
</a>
) : (
<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>
@@ -2,29 +2,27 @@ import { type FC } from "react";
import { useTranslation } from "react-i18next";
import AttachmentItem from "./AttachmentItem";
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 attachments: TaskAttachment[] = [
{
id: "1",
title: "file.pdf",
fileName: "file.pdf",
createdAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
},
{
id: "2",
title: "لینک طرح",
url: "https://example.com",
createdAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
},
];
const mappedAttachments: TaskAttachment[] = attachments.map((item) => ({
id: item.id,
title: item.title,
type: item.type,
file: item.file,
createdAt: item.createdAt,
}));
const files = attachments.filter((item) => item.fileName);
const links = attachments.filter((item) => item.url);
const files = mappedAttachments.filter((item) => item.type === "file");
const links = mappedAttachments.filter((item) => item.type === "link");
if (attachments.length === 0) return null;
if (mappedAttachments.length === 0) return null;
return (
<div className="mt-6">
@@ -38,9 +36,10 @@ const AttachmentList: FC = () => {
{files.map((item) => (
<AttachmentItem
key={item.id}
title={item.fileName ?? item.title}
createdAt={item.createdAt ?? ""}
title={item.title}
createdAt={item.createdAt}
type="file"
url={item.file}
onEdit={() => {}}
onDelete={() => {}}
/>
@@ -60,7 +59,7 @@ const AttachmentList: FC = () => {
title={item.title}
createdAt={item.createdAt}
type="link"
url={item.url}
url={item.file}
onEdit={() => {}}
onDelete={() => {}}
/>
@@ -7,9 +7,10 @@ import Select from "../../../../../components/Select";
type Props = {
onClose: () => 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 fileInputRef = useRef<HTMLInputElement>(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">
<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")}
</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")}
</Button>
</div>
@@ -4,10 +4,11 @@ import TaskDetailAttachmentForm from "./TaskDetailAttachmentForm";
type Props = {
onClose: () => void;
onSubmit: (data: { file?: File; title: string; linkId: string }) => void;
isSubmitting?: boolean;
};
const TaskDetailAttachmentPopover: FC<Props> = ({ onClose, onSubmit }) => {
return <TaskDetailAttachmentForm onClose={onClose} onSubmit={onSubmit} />;
const TaskDetailAttachmentPopover: FC<Props> = ({ onClose, onSubmit, isSubmitting }) => {
return <TaskDetailAttachmentForm onClose={onClose} onSubmit={onSubmit} isSubmitting={isSubmitting} />;
};
export default TaskDetailAttachmentPopover;
@@ -1,8 +1,7 @@
export type TaskAttachment = {
id: string;
title: string;
createdAt: string;
linkId?: string;
fileName?: string;
url?: string;
type: "file" | "link";
file?: string;
createdAt?: string;
};
@@ -1,26 +1,63 @@
import { Edit } from "iconsax-react";
import { useTranslation } from "react-i18next";
import { toast } from "react-toastify";
import ProgressBar from "../../../../../components/ProgressBar";
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";
const CheckLists = () => {
const items = [
{ id: "1", title: "آیتم ۱", checked: true },
{ id: "2", title: "آیتم ۱", checked: true },
{ id: "3", title: "آیتم ۱", checked: true },
];
type Props = {
taskId: string;
checkListItems?: TaskChecklistItemType[];
checkListItemCount?: number;
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 (
<div className="mt-6">
<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" />
<TrashWithConfrim onDelete={() => {}} color="#FF0000" />
</div>
<div className="mt-4 flex gap-2 items-center">
<div className="text-xs shrink-0">۲۵٪</div>
<ProgressBar value={25} />
<div className="text-xs shrink-0">{progress}٪</div>
<ProgressBar value={progress} />
</div>
<div className="mt-4 flex flex-col gap-2">
@@ -29,7 +66,7 @@ const CheckLists = () => {
key={item.id}
title={item.title}
checked={item.checked}
onToggle={() => {}}
onToggle={() => handleToggle(item.id, item.checked)}
onEdit={() => {}}
onDelete={() => {}}
/>
@@ -3,26 +3,21 @@ import { type FC, useState } from "react";
import { useTranslation } from "react-i18next";
import Button from "../../../../../components/Button";
import Input from "../../../../../components/Input";
import Select from "../../../../../components/Select";
import type { TaskChecklist } from "./types";
type Props = {
checklists: TaskChecklist[];
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 [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 trimmed = title.trim();
if (!trimmed) return;
onSubmit(trimmed, copyFromId);
onSubmit(trimmed);
};
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" />
<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">
<Button type="button" onClick={onClose} className="h-8 bg-[#E8E4F0] text-[#292D32] rounded-xl text-xs w-[95px]">
{t("cancel")}
</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")}
</Button>
</div>
@@ -1,15 +1,14 @@
import { type FC } from "react";
import TaskDetailChecklistForm from "./TaskDetailChecklistForm";
import type { TaskChecklist } from "./types";
type Props = {
checklists: TaskChecklist[];
onClose: () => void;
onSubmit: (title: string, copyFromId: string) => void;
onSubmit: (title: string) => void;
isSubmitting?: boolean;
};
const TaskDetailChecklistPopover: FC<Props> = ({ checklists, onClose, onSubmit }) => {
return <TaskDetailChecklistForm checklists={checklists} onClose={onClose} onSubmit={onSubmit} />;
const TaskDetailChecklistPopover: FC<Props> = ({ onClose, onSubmit, isSubmitting }) => {
return <TaskDetailChecklistForm onClose={onClose} onSubmit={onSubmit} isSubmitting={isSubmitting} />;
};
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 = {
onClose: () => 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 [startDate, setStartDate] = useState<DateObject | null>(null);
const [endDate, setEndDate] = useState<DateObject | null>(null);
const [startEnabled, setStartEnabled] = useState(true);
const [endEnabled, setEndEnabled] = useState(false);
const [startDate, setStartDate] = useState<DateObject | null>(initialStartDate);
const [endDate, setEndDate] = useState<DateObject | null>(initialEndDate);
const [startEnabled, setStartEnabled] = useState(Boolean(initialStartDate) || !initialEndDate);
const [endEnabled, setEndEnabled] = useState(Boolean(initialEndDate));
const [activeField, setActiveField] = useState<TaskDateField>("start");
const calendarValue = (() => {
@@ -135,10 +144,21 @@ const TaskDetailDateForm: FC<Props> = ({ onClose, onSubmit }) => {
</div>
<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")}
</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")}
</Button>
</div>
@@ -5,10 +5,27 @@ import TaskDetailDateForm from "./TaskDetailDateForm";
type Props = {
onClose: () => void;
onSubmit: (data: { startDate: DateObject | null; endDate: DateObject | null }) => void;
initialStartDate?: DateObject | null;
initialEndDate?: DateObject | null;
isSubmitting?: boolean;
};
const TaskDetailDatePopover: FC<Props> = ({ onClose, onSubmit }) => {
return <TaskDetailDateForm onClose={onClose} onSubmit={onSubmit} />;
const TaskDetailDatePopover: FC<Props> = ({
onClose,
onSubmit,
initialStartDate,
initialEndDate,
isSubmitting,
}) => {
return (
<TaskDetailDateForm
onClose={onClose}
onSubmit={onSubmit}
initialStartDate={initialStartDate}
initialEndDate={initialEndDate}
isSubmitting={isSubmitting}
/>
);
};
export default TaskDetailDatePopover;
@@ -1,7 +1,19 @@
import DateObject from "react-date-object";
import gregorian from "react-date-object/calendars/gregorian";
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";
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) => {
if (!date) return "";
return date.convert(persian, persian_fa).format("D MMMM");
@@ -10,6 +10,7 @@ type Props = {
onViewChange: (view: LabelsView) => void;
onToggle: (id: string) => void;
onCreate: (title: string, color: string) => void;
isSubmitting?: boolean;
};
const TaskDetailLabelsPopover: FC<Props> = ({
@@ -19,6 +20,7 @@ const TaskDetailLabelsPopover: FC<Props> = ({
onViewChange,
onToggle,
onCreate,
isSubmitting = false,
}) => {
return (
<>
@@ -34,6 +36,7 @@ const TaskDetailLabelsPopover: FC<Props> = ({
<TaskDetailNewLabelForm
onBack={() => onViewChange("list")}
onSubmit={onCreate}
isSubmitting={isSubmitting}
/>
)}
</>
@@ -10,9 +10,10 @@ import { LABEL_COLOR_OPTIONS } from "./constants";
type Props = {
onBack: () => 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 colorInputRef = useRef<HTMLInputElement>(null);
const [title, setTitle] = useState("");
@@ -48,7 +49,8 @@ const TaskDetailNewLabelForm: FC<Props> = ({ onBack, onSubmit }) => {
<Button
type="button"
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"
>
<AddCircle size={14} color="#292D32" />
@@ -1,15 +1,18 @@
import { type FC, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import Input from "../../../../../components/Input";
import TaskDetailLabelCheckbox from "../labels/TaskDetailLabelCheckbox";
import type { TaskUser } from "./types";
type Props = {
users: TaskUser[];
selectedIds: Set<string>;
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 [search, setSearch] = useState("");
@@ -30,17 +33,31 @@ const TaskDetailUsersList: FC<Props> = ({ users }) => {
<div>
<div className="text-xs font-medium mb-2">{t("taskmanager.task_detail.members")}</div>
<div className="flex flex-col gap-2">
{isLoading ? (
<div className="text-[10px] text-description text-center py-2">{t("loading")}</div>
) : (
<div className="flex flex-col gap-2 max-h-48 overflow-y-auto">
{filteredUsers.map((user) => {
const isSelected = selectedIds.has(user.id);
return (
<div key={user.id} className="flex gap-1.5 items-center">
<div className="size-6 rounded-full bg-gray-400"></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>
{isSubmitting ? <div className="text-[10px] text-description text-center">{t("loading")}</div> : null}
</div>
);
};
@@ -6,10 +6,20 @@ type Props = {
users: TaskUser[];
selectedIds: Set<string>;
onToggle: (id: string) => void;
isLoading?: boolean;
isSubmitting?: boolean;
};
const TaskDetailUsersPopover: FC<Props> = ({ users, selectedIds, onToggle }) => {
return <TaskDetailUsersList users={users} selectedIds={selectedIds} onToggle={onToggle} />;
const TaskDetailUsersPopover: FC<Props> = ({ users, selectedIds, onToggle, isLoading, isSubmitting }) => {
return (
<TaskDetailUsersList
users={users}
selectedIds={selectedIds}
onToggle={onToggle}
isLoading={isLoading}
isSubmitting={isSubmitting}
/>
);
};
export default TaskDetailUsersPopover;
+142 -8
View File
@@ -1,20 +1,110 @@
import { FC, useState } from "react";
import { TickCircle } from "iconsax-react";
import { useFormik } from "formik";
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 DatePickerComponent from "../../../components/DatePicker";
import Input from "../../../components/Input";
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 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 (
<div className="w-full mt-4">
<div className="flex w-full justify-between items-center">
<div>{t("taskmanager.new_project")}</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">
<TickCircle className="size-5" color="#fff" />
<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="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 className="mt-8 rowTwoInput">
<DatePickerComponent label={t("taskmanager.project_start_date")} placeholder={t("taskmanager.select_date")} onChange={() => undefined} />
<Input label={t("taskmanager.employer_name")} />
<DatePickerComponent
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 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>
<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>
);
+29 -11
View File
@@ -1,29 +1,47 @@
import { useState } from "react";
import projectsData from "./data/projects.json";
import { FC, useState } from "react";
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 ProjectListHeader from "./components/ProjectListHeader";
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 { workspace, projects: initialProjects } = projectsData as {
workspace: WorkspaceInfo;
projects: ProjectItem[];
};
const List: FC = () => {
const { workspaceId = "" } = useParams();
const getProjects = useGetProjectsByWorkspace(workspaceId);
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) => {
setProjects((prev) => prev.filter((project) => project.id !== id));
setDeletedIds((prev) => [...prev, id]);
};
const isLoading = getProjects.isPending || getWorkspaceDetail.isPending;
return (
<div className="w-full mt-4">
<ProjectListHeader title={workspace.name} />
<ProjectListHeader title={workspace?.name ?? ""} workspaceId={workspaceId} />
{workspace?.description ? (
<WorkspaceInfoBanner description={workspace.description} />
) : null}
{isLoading ? (
<PageLoading />
) : (
<ProjectGrid projects={projects} onDelete={handleDelete} />
)}
</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 { useGetUsers } from "../../../users/hooks/useUserData";
import { UserItemType } from "../../../users/types/UserTypes";
import { useGetWorkspaces } from "../../workspace/hooks/useWorkspaceData";
import { WorkspaceItemType } from "../../workspace/types/WorkspaceTypes";
import WorkspaceColorPicker from "../../workspace/components/WorkspaceColorPicker";
type SelectedUser = {
export type SelectedUser = {
id: string;
name: string;
};
const BACKGROUND_PRESETS = [
export const BACKGROUND_PRESETS = [
"linear-gradient(45deg, #6a11cb, #2575fc)",
"linear-gradient(45deg, #ff00cc, #333399)",
"linear-gradient(45deg, #ff0000, #ff8c00)",
@@ -21,18 +23,53 @@ const BACKGROUND_PRESETS = [
"linear-gradient(45deg, #2c3e50, #4ca1af)",
] 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 getUsers = useGetUsers("");
const [isActive, setIsActive] = useState(true);
const [selectedColor, setSelectedColor] = useState("#A8E6CF");
const [selectedUsers, setSelectedUsers] = useState<SelectedUser[]>([]);
const getWorkspaces = useGetWorkspaces();
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 workspaces: WorkspaceItemType[] =
getWorkspaces.data?.data?.workspaces ??
getWorkspaces.data?.data ??
[];
const workspaceItems = workspaces.map((workspace) => ({
label: workspace.name,
value: workspace.id,
}));
const userItems =
getUsers.data?.data?.users
?.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);
if (user) {
setSelectedUsers((prev) => [...prev, { id: user.id, name: `${user.firstName} ${user.lastName}` }]);
onSelectedUsersChange([
...selectedUsers,
{ id: user.id, name: `${user.firstName} ${user.lastName}` },
]);
}
setUserSelectValue("");
};
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 file = event.target.files?.[0] ?? null;
setBackgroundImage(file);
onBackgroundImageChange(file);
};
return (
@@ -69,10 +109,22 @@ const CreateProjectSidebar: FC = () => {
<div>{t("taskmanager.project_status")}</div>
<div className="flex gap-2 text-xs items-center text-description">
<div>{isActive ? t("slider.active") : t("slider.inactive")}</div>
<SwitchComponent active={isActive} onChange={setIsActive} />
<SwitchComponent active={isActive} onChange={onIsActiveChange} />
</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">
<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,
borderColor: selectedBackground === background ? "#000" : "#E2E8F0",
}}
onClick={() => setSelectedBackground(background)}
onClick={() => onBackgroundChange(background)}
/>
))}
</div>
@@ -110,7 +162,7 @@ const CreateProjectSidebar: FC = () => {
<div className="mt-8">
<div className="text-sm mb-3">{t("taskmanager.choose_color")}</div>
<WorkspaceColorPicker selectedColor={selectedColor} onChange={setSelectedColor} />
<WorkspaceColorPicker selectedColor={selectedColor} onChange={onColorChange} />
</div>
<div className="mt-8">
@@ -1,5 +1,5 @@
import { FC } from "react";
import { Link } from "react-router-dom";
import { CSSProperties, FC } from "react";
import { Link, useNavigate } from "react-router-dom";
import { Pages } from "../../../../config/Pages";
import type { ProjectItem } from "../types/ProjectTypes";
import ProjectCardMenu from "./ProjectCardMenu";
@@ -17,21 +17,33 @@ const hexToRgba = (hex: string, alpha: number) => {
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
};
const getCardBackground = (background: string) => {
const color = /^#[0-9A-Fa-f]{6}$/i.test(background) ? background : (background.match(/#[0-9A-Fa-f]{6}/i)?.[0] ?? "#A8E6CF");
const getCardBackgroundStyle = (project: ProjectItem): CSSProperties => {
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 navigate = useNavigate();
const handleEdit = () => {
// TODO: navigate to edit page when available
navigate(`${Pages.taskmanager.updateProject}${project.id}`);
};
return (
<div className="bg-white rounded-[20px] overflow-hidden shadow-sm border border-border/40 p-3">
<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>
<div className="flex items-center justify-between mt-3.5">
@@ -7,11 +7,15 @@ import { Pages } from "../../../../config/Pages";
type Props = {
title: string;
workspaceId?: string;
onSettingsClick?: () => void;
};
const ProjectListHeader: FC<Props> = ({ title, onSettingsClick }) => {
const ProjectListHeader: FC<Props> = ({ title, workspaceId, onSettingsClick }) => {
const { t } = useTranslation("global");
const createProjectLink = workspaceId
? `${Pages.taskmanager.createProject}?workspaceId=${workspaceId}`
: Pages.taskmanager.createProject;
return (
<div className="flex w-full justify-between items-center">
@@ -23,7 +27,7 @@ const ProjectListHeader: FC<Props> = ({ title, onSettingsClick }) => {
<span>{t("sidebar.setting")}</span>
</Button>
<Link to={Pages.taskmanager.createProject}>
<Link to={createProjectLink}>
<Button onClick={onSettingsClick} className="flex items-center gap-2 whitespace-nowrap px-4">
<Add size={18} color="white" />
<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 = {
id: string;
name: string;
background: string;
backgroundColor?: string;
backgroundPicture?: string;
};
export type WorkspaceInfo = {
name: string;
description: string;
export type ProjectUserType = {
id: 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 = {
id: string;
title: string;
color?: string;
order?: number;
};
export type Task = {
@@ -15,5 +15,5 @@ export const reorderColumns = (
const result = [...columns];
const [removed] = result.splice(oldIndex, 1);
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 Column from "./components/Column";
import HeaderWorkspace from "./components/HeaderWorkspace";
import TaskDetailModal from "./components/task-detail/TaskDetailModal";
import tasksData from "./data/tasks.json";
import type { Task, WorkspaceData } from "./types";
import { useGetProject } from "./project/hooks/useProjectData";
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 { reorderTasks } from "./utils/reorderTasks";
const Workspace: FC = () => {
const [columns, setColumns] = useState(
(tasksData as WorkspaceData).columns,
);
const [tasks, setTasks] = useState<Task[]>(tasksData.tasks as Task[]);
const { slug: projectId = "" } = useParams<{ slug: string }>();
const { data: project, isPending } = useGetProject(projectId);
const changeTaskPhase = useChangeTaskPhase();
const updateTaskPhase = useUpdateTaskPhase();
const [columns, setColumns] = useState<ColumnType[]>([]);
const [tasks, setTasks] = useState<Task[]>([]);
const [draggedTaskId, setDraggedTaskId] = useState<string | null>(null);
const [draggedColumnId, setDraggedColumnId] = useState<string | 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(() => {
return columns.reduce<Record<string, Task[]>>((acc, column) => {
acc[column.id] = tasks
.filter((task) => task.columnId === column.id)
.sort((a, b) => a.order - b.order);
acc[column.id] = tasks.filter((task) => task.columnId === column.id).sort((a, b) => a.order - b.order);
return acc;
}, {});
}, [columns, tasks]);
@@ -36,8 +72,48 @@ const Workspace: FC = () => {
const handleDrop = (columnId: string, index: number) => {
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);
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) => {
@@ -50,13 +126,57 @@ const Workspace: FC = () => {
const handleColumnDrop = (overColumnId: string) => {
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);
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
? columns.find((column) => column.id === selectedTask.columnId)
: null;
const nextColumnOrder = columns.length
? Math.max(...columns.map((column) => column.order ?? 0)) + 1
: 1;
const handleColumnCreated = (phase: TaskPhaseItemType) => {
setColumns((prev) =>
[...prev, { id: phase.id, title: phase.name, color: phase.color, order: phase.order }].sort(
(a, b) => (a.order ?? 0) - (b.order ?? 0),
),
);
};
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 (
<div className="bg-[#01347A] h-full rounded-none xl:rounded-[32px] overflow-hidden flex flex-col">
@@ -67,6 +187,7 @@ const Workspace: FC = () => {
<Column
key={column.id}
column={column}
projectId={projectId}
tasks={tasksByColumn[column.id] ?? []}
draggedTaskId={draggedTaskId}
draggedColumnId={draggedColumnId}
@@ -77,18 +198,26 @@ const Workspace: FC = () => {
onColumnDragEnd={handleColumnDragEnd}
onColumnDrop={handleColumnDrop}
onTaskClick={setSelectedTask}
onColumnDeleted={handleColumnDeleted}
onTaskCreated={handleTaskCreated}
/>
))}
<AddNewColumn />
<AddNewColumn
projectId={projectId}
nextOrder={nextColumnOrder}
onColumnCreated={handleColumnCreated}
/>
</div>
</div>
<TaskDetailModal
open={selectedTask !== null}
task={selectedTask}
statusLabel={selectedTaskColumn?.title ?? ""}
columns={columns}
projectId={projectId}
onClose={() => setSelectedTask(null)}
onTaskPhaseChanged={handleTaskPhaseChanged}
/>
</div>
);
+85 -12
View File
@@ -1,20 +1,71 @@
import { FC, useState } from "react";
import { TickCircle } from "iconsax-react";
import { useFormik } from "formik";
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 Input from "../../../components/Input";
import Select from "../../../components/Select";
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 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 (
<div className="w-full mt-4">
<div className="flex w-full justify-between items-center">
<div>{t("taskmanager.new_workspace")}</div>
<div>
<Button className="px-5" isLoading={false}>
<Button
className="px-5"
isLoading={createWorkspace.isPending}
onClick={() => formik.handleSubmit()}
>
<div className="flex gap-2">
<TickCircle className="size-5" color="#fff" />
<div>{t("taskmanager.submit_workspace")}</div>
@@ -26,20 +77,42 @@ const CreateWorkspace = () => {
<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">اطلاعات</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">
<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>
<CreateWorkspaceSidebar />
<CreateWorkspaceSidebar
isActive={isActive}
onIsActiveChange={setIsActive}
selectedColor={selectedColor}
onColorChange={setSelectedColor}
selectedUsers={selectedUsers}
onSelectedUsersChange={setSelectedUsers}
/>
</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 WorkspaceColorPicker from "./WorkspaceColorPicker";
type SelectedUser = {
export type SelectedUser = {
id: 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 getUsers = useGetUsers("");
const [isActive, setIsActive] = useState(true);
const [selectedColor, setSelectedColor] = useState("#A8E6CF");
const [selectedUsers, setSelectedUsers] = useState<SelectedUser[]>([]);
const [userSelectValue, setUserSelectValue] = useState("");
const userItems =
@@ -41,8 +53,8 @@ const CreateWorkspaceSidebar: FC = () => {
);
if (user) {
setSelectedUsers((prev) => [
...prev,
onSelectedUsersChange([
...selectedUsers,
{ id: user.id, name: `${user.firstName} ${user.lastName}` },
]);
}
@@ -51,7 +63,7 @@ const CreateWorkspaceSidebar: FC = () => {
};
const handleRemoveUser = (id: string) => {
setSelectedUsers((prev) => prev.filter((user) => user.id !== id));
onSelectedUsersChange(selectedUsers.filter((user) => user.id !== id));
};
return (
@@ -60,7 +72,7 @@ const CreateWorkspaceSidebar: FC = () => {
<div>{t("taskmanager.workspace_status")}</div>
<div className="flex gap-2 text-xs items-center text-description">
<div>{isActive ? t("slider.active") : t("slider.inactive")}</div>
<SwitchComponent active={isActive} onChange={setIsActive} />
<SwitchComponent active={isActive} onChange={onIsActiveChange} />
</div>
</div>
@@ -69,7 +81,7 @@ const CreateWorkspaceSidebar: FC = () => {
<div className="mt-3">
<WorkspaceColorPicker
selectedColor={selectedColor}
onChange={setSelectedColor}
onChange={onColorChange}
/>
</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 CreateProject from "../pages/taskmanager/project/Create.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 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 CreateTicket from "../pages/ticket/CreateTicket";
import TicketDetail from "../pages/ticket/Detail";
@@ -111,115 +114,404 @@ const MainRouter: FC = () => {
{!isWorkspace && <SideBar />}
<Header />
<div className={clx("flex-1 mt-[68px] xl:mt-[81px]", !isWorkspace && "xl:ms-[269px]", !isWorkspace && hasSubMenu && "xl:ms-[305px]")}>
<div
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 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")}>
<Routes>
<Route path={Pages.dashboard} element={<Home />} />
<Route path={Pages.services.mine} element={<MyServices />} />
<Route path={Pages.services.other} element={<OtherServices />} />
<Route path={Pages.services.detail + ":id"} element={<DetailService />} />
<Route path={Pages.services.add} element={<AddService />} />
<Route path={Pages.services.list} element={<ListService />} />
<Route path={Pages.services.update + ":id"} element={<UpdateService />} />
<Route path={Pages.services.category} element={<Category />} />
<Route path={Pages.services.plan + ":id"} element={<Plans />} />
<Route path={Pages.services.review} element={<Reviews />} />
<Route path={Pages.ticket.list} element={<TicketList />} />
<Route path={Pages.ticket.create} element={<CreateTicket />} />
<Route path={Pages.ticket.category} element={<TicketCategory />} />
<Route path={Pages.ticket.detail + ":id"} element={<TicketDetail />} />
<Route path={Pages.ads.list} element={<AdsList />} />
<Route path={Pages.ads.create} element={<CreateAds />} />
<Route path={Pages.ads.update + ":id"} element={<UpdateAds />} />
<Route path={Pages.discount.list} element={<DiscountsList />} />
<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.dashboard}
element={<Home />}
/>
<Route
path={Pages.services.mine}
element={<MyServices />}
/>
<Route
path={Pages.services.other}
element={<OtherServices />}
/>
<Route
path={Pages.services.detail + ":id"}
element={<DetailService />}
/>
<Route
path={Pages.services.add}
element={<AddService />}
/>
<Route
path={Pages.services.list}
element={<ListService />}
/>
<Route
path={Pages.services.update + ":id"}
element={<UpdateService />}
/>
<Route
path={Pages.services.category}
element={<Category />}
/>
<Route
path={Pages.services.plan + ":id"}
element={<Plans />}
/>
<Route
path={Pages.services.review}
element={<Reviews />}
/>
<Route
path={Pages.ticket.list}
element={<TicketList />}
/>
<Route
path={Pages.ticket.create}
element={<CreateTicket />}
/>
<Route
path={Pages.ticket.category}
element={<TicketCategory />}
/>
<Route
path={Pages.ticket.detail + ":id"}
element={<TicketDetail />}
/>
<Route
path={Pages.ads.list}
element={<AdsList />}
/>
<Route
path={Pages.ads.create}
element={<CreateAds />}
/>
<Route
path={Pages.ads.update + ":id"}
element={<UpdateAds />}
/>
<Route
path={Pages.discount.list}
element={<DiscountsList />}
/>
<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 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.dmenu.icons.list}
element={<IconsList />}
/>
<Route
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 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.dkala.icons.list}
element={<DkalaIconsList />}
/>
<Route
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 path={Pages.reseller.create} element={<CreateReseller />} />
<Route path={Pages.reseller.withdraw} element={<Withdraw />} />
<Route
path={Pages.reseller.list}
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 path={Pages.taskmanager.createWorkspace} element={<CreateWorkspace />} />
<Route path={Pages.taskmanager.createProject} element={<CreateProject />} />
<Route path={Pages.taskmanager.projectList} element={<ProjectList />} />
<Route
path={Pages.taskmanager.workspace + ":slug"}
element={<Workspace />}
/>
<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>
</div>
{!isWorkspace && <div className="h-20 shrink-0 xl:hidden" />}
+51 -6
View File
@@ -1,5 +1,6 @@
import {
Activity,
Add,
ArrowDown2,
BagTick,
Building,
@@ -51,6 +52,7 @@ import LearningSubMenu from "./components/LearningSubMenu";
import ReceiptSubMenu from "./components/ReceiptSubMenu";
import ServicesSubMenu from "./components/ServicesSubMenu";
import SupportSubMenu from "./components/SupportSubMenu";
import TaskManagerSubMenu from "./components/TaskManagerSubMenu";
import TicketSubMenu from "./components/TicketSubMenu";
import UsersSubMenu from "./components/UsersSubMenu";
import SideBarItem from "./SideBarItem";
@@ -69,10 +71,20 @@ const SideBar: FC = () => {
useEffect(() => {
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])) {
setSubMenuName(split[1]);
setSubtMenu(true);
} else if (isTaskManagerRoute) {
setSubMenuName("taskmanager");
setSubtMenu(true);
} else {
setSubMenuName("");
setSubtMenu(false);
@@ -459,18 +471,49 @@ const SideBar: FC = () => {
</div>
</div>
}
<div className={clx("text-xs text-[#8C90A3]")}>
<div className={clx("text-xs text-[#8C90A3]", !openMenu.includes("taskmanager") && "hidden")}>
<SideBarItem
icon={
<Task
variant={isActive("taskmanager") ? "Bold" : "Outline"}
color={isActive("taskmanager") ? "black" : "#8C90A3"}
variant={location.pathname === Pages.taskmanager.workspaceList ? "Bold" : "Outline"}
color={location.pathname === Pages.taskmanager.workspaceList ? "black" : "#8C90A3"}
size={iconSizeSideBar}
/>
}
title={t("sidebar.taskmanager")}
isActive={isActive("taskmanager")}
link={Pages.taskmanager.workspace + "dpage"}
title={t("submenu.taskmanager_workspace_list")}
isActive={location.pathname === Pages.taskmanager.workspaceList}
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=""
/>
</div>
@@ -948,6 +991,8 @@ const SideBar: FC = () => {
<SupportSubMenu />
) : subMenuName === "accessLogs" ? (
<AccessLogsSubMenu />
) : subMenuName === "taskmanager" ? (
<TaskManagerSubMenu />
) : null}
</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;