Compare commits
19 Commits
9043cd4cb2
...
95177b529e
| Author | SHA1 | Date | |
|---|---|---|---|
| 95177b529e | |||
| fd545231d3 | |||
| e1fe030316 | |||
| c11c972c7c | |||
| 7c2d6c465e | |||
| 50d4ff9281 | |||
| 61282e1d1b | |||
| ee8da0b88c | |||
| c4150ab8ac | |||
| 833ac2446f | |||
| 2bc7a37082 | |||
| 9c84048730 | |||
| 651fb16feb | |||
| 1618b5fb57 | |||
| 47691a649f | |||
| b0737ea6ad | |||
| 21a99d9ec1 | |||
| b965dd0a6d | |||
| 43dc693061 |
@@ -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
|
||||
@@ -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
@@ -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/",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -8,4 +8,5 @@ export const SideBarItemHasSubMenu = [
|
||||
"blog",
|
||||
"support",
|
||||
"accessLogs",
|
||||
"taskmanager",
|
||||
];
|
||||
|
||||
+22
-1
@@ -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;
|
||||
|
||||
@@ -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={() => {}}
|
||||
/>
|
||||
|
||||
+4
-3
@@ -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>
|
||||
|
||||
+3
-2
@@ -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={() => {}}
|
||||
/>
|
||||
|
||||
+10
-19
@@ -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>
|
||||
|
||||
+4
-5
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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 }));
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
Reference in New Issue
Block a user