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: {
|
taskmanager: {
|
||||||
workspace: "/taskmanager/workspace/",
|
workspace: "/taskmanager/workspace/",
|
||||||
|
workspaceList: "/workspace/list",
|
||||||
createWorkspace: "/workspace/create",
|
createWorkspace: "/workspace/create",
|
||||||
|
updateWorkspace: "/workspace/update/",
|
||||||
createProject: "/project/create",
|
createProject: "/project/create",
|
||||||
projectList: "/project/list",
|
updateProject: "/project/update/",
|
||||||
|
projectList: "/project/list/",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ export const SideBarItemHasSubMenu = [
|
|||||||
"blog",
|
"blog",
|
||||||
"support",
|
"support",
|
||||||
"accessLogs",
|
"accessLogs",
|
||||||
|
"taskmanager",
|
||||||
];
|
];
|
||||||
|
|||||||
+22
-1
@@ -143,7 +143,12 @@
|
|||||||
"guide": "راهنمای سرویس",
|
"guide": "راهنمای سرویس",
|
||||||
"access_logs_list": "لیست لاگها",
|
"access_logs_list": "لیست لاگها",
|
||||||
"access_logs_stats": "آمار لاگها",
|
"access_logs_stats": "آمار لاگها",
|
||||||
"access_logs_errors": "لاگهای خطا"
|
"access_logs_errors": "لاگهای خطا",
|
||||||
|
"taskmanager_workspace": "فضای کاری",
|
||||||
|
"taskmanager_workspace_list": "لیست فضاهای کاری",
|
||||||
|
"taskmanager_create_workspace": "ساخت فضای کاری جدید",
|
||||||
|
"taskmanager_project_list": "لیست پروژهها",
|
||||||
|
"taskmanager_create_project": "افزودن پروژه"
|
||||||
},
|
},
|
||||||
"guide": {
|
"guide": {
|
||||||
"list_guide": "لیست راهنمای سرویس",
|
"list_guide": "لیست راهنمای سرویس",
|
||||||
@@ -968,15 +973,19 @@
|
|||||||
},
|
},
|
||||||
"cancel": "لغو",
|
"cancel": "لغو",
|
||||||
"taskmanager": {
|
"taskmanager": {
|
||||||
|
"workspace_list": "لیست فضاهای کاری",
|
||||||
"new_workspace": "ساخت فضای کاری جدید",
|
"new_workspace": "ساخت فضای کاری جدید",
|
||||||
"submit_workspace": "ثبت فضای کاری",
|
"submit_workspace": "ثبت فضای کاری",
|
||||||
|
"workspace_deleted": "فضای کاری با موفقیت حذف شد",
|
||||||
"workspacename": "نام",
|
"workspacename": "نام",
|
||||||
"workspacetype": "نوع",
|
"workspacetype": "نوع",
|
||||||
|
"workspace_info": "اطلاعات",
|
||||||
"workspace_description": "توضیحات",
|
"workspace_description": "توضیحات",
|
||||||
"workspace_status": "وضعیت فضای کار",
|
"workspace_status": "وضعیت فضای کار",
|
||||||
"choose_color": "انتخاب رنگ دلخواه",
|
"choose_color": "انتخاب رنگ دلخواه",
|
||||||
"users": "کاربران",
|
"users": "کاربران",
|
||||||
"select_user": "انتخاب کاربر",
|
"select_user": "انتخاب کاربر",
|
||||||
|
"select_workspace": "انتخاب فضای کاری",
|
||||||
"no_users_added": "هنوز کاربری اضافه نکردی",
|
"no_users_added": "هنوز کاربری اضافه نکردی",
|
||||||
"new_project": "افزودن پروژه",
|
"new_project": "افزودن پروژه",
|
||||||
"submit_project": "ثبت پروژه",
|
"submit_project": "ثبت پروژه",
|
||||||
@@ -991,6 +1000,18 @@
|
|||||||
"select_date": "انتخاب تاریخ",
|
"select_date": "انتخاب تاریخ",
|
||||||
"projects": "پروژهها",
|
"projects": "پروژهها",
|
||||||
"delete_project": "پاک کردن",
|
"delete_project": "پاک کردن",
|
||||||
|
"add_new_column": "اضافه کردن ستون جدید",
|
||||||
|
"add_column": "اضافه کردن",
|
||||||
|
"column_name": "نام ستون",
|
||||||
|
"delete_column": "حذف ستون",
|
||||||
|
"delete_column_confirm": "آیا از حذف این ستون اطمینان دارید؟",
|
||||||
|
"column_deleted": "ستون با موفقیت حذف شد",
|
||||||
|
"add_new_task": "اضافه کردن تسک",
|
||||||
|
"add_task": "اضافه کردن",
|
||||||
|
"task_title": "عنوان تسک",
|
||||||
|
"delete_task": "حذف تسک",
|
||||||
|
"delete_task_confirm": "آیا از حذف این تسک اطمینان دارید؟",
|
||||||
|
"task_deleted": "تسک با موفقیت حذف شد",
|
||||||
"task_detail": {
|
"task_detail": {
|
||||||
"labels": "برچسب ها",
|
"labels": "برچسب ها",
|
||||||
"user_management": "مدیریت کاربران",
|
"user_management": "مدیریت کاربران",
|
||||||
|
|||||||
@@ -1,13 +1,147 @@
|
|||||||
import { AddCircle } from "iconsax-react";
|
import { Add, AddCircle, CloseCircle } from "iconsax-react";
|
||||||
import { type FC } from "react";
|
import { useRef, useState, type FC } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import ColorsImage from "../../../assets/images/colors.png";
|
||||||
|
import { ErrorType } from "../../../helpers/types";
|
||||||
|
import { clx } from "../../../helpers/utils";
|
||||||
|
import { useCreateTaskPhase } from "../task-phase/hooks/useTaskPhaseData";
|
||||||
|
import type { TaskPhaseItemType } from "../task-phase/types/TaskPhaseTypes";
|
||||||
|
import { LABEL_COLOR_OPTIONS } from "./task-detail/labels/constants";
|
||||||
|
|
||||||
const 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 (
|
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" />
|
<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>
|
</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;
|
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 { useRef, useState, type FC } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import ModalConfrim from "../../../components/ModalConfrim";
|
||||||
|
import { ErrorType } from "../../../helpers/types";
|
||||||
|
import { useCreateTask } from "../task/hooks/useTaskData";
|
||||||
|
import type { TaskItemType } from "../task/types/TaskTypes";
|
||||||
|
import { useDeleteTaskPhase } from "../task-phase/hooks/useTaskPhaseData";
|
||||||
import type { Column as ColumnType, Task as TaskType } from "../types";
|
import type { Column as ColumnType, Task as TaskType } from "../types";
|
||||||
|
import ColumnMenu from "./ColumnMenu";
|
||||||
import Task from "./Task";
|
import Task from "./Task";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
column: ColumnType;
|
column: ColumnType;
|
||||||
|
projectId: string;
|
||||||
tasks: TaskType[];
|
tasks: TaskType[];
|
||||||
draggedTaskId: string | null;
|
draggedTaskId: string | null;
|
||||||
draggedColumnId: string | null;
|
draggedColumnId: string | null;
|
||||||
@@ -15,10 +24,13 @@ type Props = {
|
|||||||
onColumnDragEnd: () => void;
|
onColumnDragEnd: () => void;
|
||||||
onColumnDrop: (overColumnId: string) => void;
|
onColumnDrop: (overColumnId: string) => void;
|
||||||
onTaskClick?: (task: TaskType) => void;
|
onTaskClick?: (task: TaskType) => void;
|
||||||
|
onColumnDeleted?: (columnId: string) => void;
|
||||||
|
onTaskCreated?: (task: TaskItemType, taskPhaseId: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Column: FC<Props> = ({
|
const Column: FC<Props> = ({
|
||||||
column,
|
column,
|
||||||
|
projectId,
|
||||||
tasks,
|
tasks,
|
||||||
draggedTaskId,
|
draggedTaskId,
|
||||||
draggedColumnId,
|
draggedColumnId,
|
||||||
@@ -29,8 +41,16 @@ const Column: FC<Props> = ({
|
|||||||
onColumnDragEnd,
|
onColumnDragEnd,
|
||||||
onColumnDrop,
|
onColumnDrop,
|
||||||
onTaskClick,
|
onTaskClick,
|
||||||
|
onColumnDeleted,
|
||||||
|
onTaskCreated,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation("global");
|
||||||
|
const deleteTaskPhase = useDeleteTaskPhase();
|
||||||
|
const createTask = useCreateTask();
|
||||||
|
|
||||||
const [isAdding, setIsAdding] = useState(false);
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false);
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
const [isColumnDragOver, setIsColumnDragOver] = useState(false);
|
const [isColumnDragOver, setIsColumnDragOver] = useState(false);
|
||||||
const columnRef = useRef<HTMLDivElement>(null);
|
const columnRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -116,6 +136,52 @@ const Column: FC<Props> = ({
|
|||||||
onColumnDrop(column.id);
|
onColumnDrop(column.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resetTaskForm = () => {
|
||||||
|
setTitle("");
|
||||||
|
setIsAdding(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateTask = () => {
|
||||||
|
const trimmedTitle = title.trim();
|
||||||
|
if (!trimmedTitle || !projectId) return;
|
||||||
|
|
||||||
|
createTask.mutate(
|
||||||
|
{
|
||||||
|
title: trimmedTitle,
|
||||||
|
taskPhaseId: column.id,
|
||||||
|
order: tasks.length,
|
||||||
|
projectId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: (data) => {
|
||||||
|
const task = (data?.data ?? data) as TaskItemType;
|
||||||
|
onTaskCreated?.(task, column.id);
|
||||||
|
toast.success(t("success"));
|
||||||
|
resetTaskForm();
|
||||||
|
},
|
||||||
|
onError: (error: ErrorType) => {
|
||||||
|
toast.error(error.response?.data?.error.message[0]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteColumn = () => {
|
||||||
|
deleteTaskPhase.mutate(
|
||||||
|
{ id: column.id, projectId },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
onColumnDeleted?.(column.id);
|
||||||
|
setIsDeleteConfirmOpen(false);
|
||||||
|
toast.success(t("taskmanager.column_deleted"));
|
||||||
|
},
|
||||||
|
onError: (error: ErrorType) => {
|
||||||
|
toast.error(error.response?.data?.error.message[0]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={columnRef}
|
ref={columnRef}
|
||||||
@@ -139,15 +205,25 @@ const Column: FC<Props> = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<div className="flex justify-between items-center shrink-0 gap-2">
|
||||||
<div
|
<div
|
||||||
draggable
|
draggable
|
||||||
onDragStart={handleColumnDragStart}
|
onDragStart={handleColumnDragStart}
|
||||||
onDragEnd={handleColumnDragEnd}
|
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>
|
{column.title}
|
||||||
<More size={20} color="black" className="shrink-0 pointer-events-none" />
|
|
||||||
</div>
|
</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">
|
<div className="mt-3 sm:mt-4 xl:mt-5 flex-1 min-h-0 overflow-y-auto overscroll-y-contain flex flex-col gap-3 sm:gap-4">
|
||||||
{tasks.map((task, index) => (
|
{tasks.map((task, index) => (
|
||||||
@@ -175,23 +251,30 @@ const Column: FC<Props> = ({
|
|||||||
<div className="mt-3 sm:mt-4 xl:mt-5 shrink-0 flex flex-col gap-3">
|
<div className="mt-3 sm:mt-4 xl:mt-5 shrink-0 flex flex-col gap-3">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="عنوان تسک"
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") handleCreateTask();
|
||||||
|
}}
|
||||||
|
placeholder={t("taskmanager.task_title")}
|
||||||
className="w-full bg-white rounded-lg px-3 py-2.5 text-sm outline-none"
|
className="w-full bg-white rounded-lg px-3 py-2.5 text-sm outline-none"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center gap-2 bg-black text-white text-[13px] rounded-full px-4 py-2 cursor-pointer"
|
onClick={handleCreateTask}
|
||||||
|
disabled={!title.trim() || createTask.isPending}
|
||||||
|
className="flex items-center gap-2 bg-black text-white text-[13px] rounded-full px-4 py-2 cursor-pointer disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<span>اضافه کردن</span>
|
<span>{t("taskmanager.add_task")}</span>
|
||||||
<Add size={18} color="white" />
|
<Add size={18} color="white" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsAdding(false)}
|
onClick={resetTaskForm}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
aria-label="انصراف"
|
aria-label={t("cancel")}
|
||||||
>
|
>
|
||||||
<CloseCircle size={22} color="#888888" variant="Bold" />
|
<CloseCircle size={22} color="#888888" variant="Bold" />
|
||||||
</button>
|
</button>
|
||||||
@@ -204,7 +287,7 @@ const Column: FC<Props> = ({
|
|||||||
className="mt-3 sm:mt-4 xl:mt-5 flex gap-2 items-center shrink-0 cursor-pointer"
|
className="mt-3 sm:mt-4 xl:mt-5 flex gap-2 items-center shrink-0 cursor-pointer"
|
||||||
>
|
>
|
||||||
<AddCircle size={18} color="#888888" />
|
<AddCircle size={18} color="#888888" />
|
||||||
<span className="text-description text-[13px] mt-0.5">اضافه کردن تسک</span>
|
<span className="text-description text-[13px] mt-0.5">{t("taskmanager.add_new_task")}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
|
||||||
|
import { More } from "iconsax-react";
|
||||||
|
import { FC } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onDelete: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ColumnMenu: FC<Props> = ({ onDelete }) => {
|
||||||
|
const { t } = useTranslation("global");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover className="relative">
|
||||||
|
<PopoverButton
|
||||||
|
className="flex items-center justify-center size-8 rounded-lg hover:bg-black/5 transition-colors outline-none shrink-0"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<More size={20} color="black" />
|
||||||
|
</PopoverButton>
|
||||||
|
|
||||||
|
<PopoverPanel
|
||||||
|
anchor="bottom start"
|
||||||
|
className="z-20 mt-1 min-w-[120px] rounded-xl bg-white shadow-md border border-border py-1 text-sm"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDelete}
|
||||||
|
className="w-full px-4 py-2.5 text-right text-red-500 hover:bg-red-50 transition-colors"
|
||||||
|
>
|
||||||
|
{t("taskmanager.delete_column")}
|
||||||
|
</button>
|
||||||
|
</PopoverPanel>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ColumnMenu;
|
||||||
@@ -4,9 +4,10 @@ import { useTranslation } from "react-i18next";
|
|||||||
type Props = {
|
type Props = {
|
||||||
value?: string;
|
value?: string;
|
||||||
onChange?: (value: string) => void;
|
onChange?: (value: string) => void;
|
||||||
|
onBlur?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const TaskDetailDescription: FC<Props> = ({ value = "", onChange }) => {
|
const TaskDetailDescription: FC<Props> = ({ value = "", onChange, onBlur }) => {
|
||||||
const { t } = useTranslation("global");
|
const { t } = useTranslation("global");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -15,6 +16,7 @@ const TaskDetailDescription: FC<Props> = ({ value = "", onChange }) => {
|
|||||||
<textarea
|
<textarea
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange?.(e.target.value)}
|
onChange={(e) => onChange?.(e.target.value)}
|
||||||
|
onBlur={onBlur}
|
||||||
placeholder={t("taskmanager.task_detail.description_placeholder")}
|
placeholder={t("taskmanager.task_detail.description_placeholder")}
|
||||||
rows={5}
|
rows={5}
|
||||||
className="w-full bg-white/25 rounded-xl px-4 py-3 text-xs outline-none resize-none placeholder:text-description"
|
className="w-full bg-white/25 rounded-xl px-4 py-3 text-xs outline-none resize-none placeholder:text-description"
|
||||||
|
|||||||
@@ -1,20 +1,68 @@
|
|||||||
|
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
|
||||||
import { ArrowDown2, CloseCircle } from "iconsax-react";
|
import { ArrowDown2, CloseCircle } from "iconsax-react";
|
||||||
import { type FC } from "react";
|
import { type FC } from "react";
|
||||||
|
import TrashWithConfrim from "../../../../components/TrashWithConfrim";
|
||||||
|
import { clx } from "../../../../helpers/utils";
|
||||||
|
import type { Column } from "../../types";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
statusLabel: string;
|
columns: Column[];
|
||||||
|
selectedPhaseId: string;
|
||||||
|
onPhaseChange: (phaseId: string) => void;
|
||||||
|
isChangingPhase?: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
onDelete?: () => void;
|
||||||
|
isDeleting?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const TaskDetailHeader: FC<Props> = ({ statusLabel, onClose }) => {
|
const TaskDetailHeader: FC<Props> = ({
|
||||||
|
columns,
|
||||||
|
selectedPhaseId,
|
||||||
|
onPhaseChange,
|
||||||
|
isChangingPhase,
|
||||||
|
onClose,
|
||||||
|
onDelete,
|
||||||
|
isDeleting,
|
||||||
|
}) => {
|
||||||
|
const selectedColumn = columns.find((column) => column.id === selectedPhaseId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between border-b border-border pb-4">
|
<div className="flex items-center justify-between border-b border-border pb-4">
|
||||||
<button type="button" className="flex items-center gap-1.5 bg-white/60 rounded-full px-4 py-2 text-xs font-medium cursor-pointer">
|
<Popover className="relative">
|
||||||
<span>{statusLabel}</span>
|
<PopoverButton
|
||||||
|
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" />
|
<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>
|
</button>
|
||||||
|
))}
|
||||||
|
</PopoverPanel>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{onDelete ? (
|
||||||
|
<div className="size-8 rounded-full bg-white/60 flex items-center justify-center">
|
||||||
|
<TrashWithConfrim onDelete={onDelete} isLoading={isDeleting} color="#FF0000" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<button type="button" onClick={onClose} className="size-8 rounded-full bg-white/60 flex items-center justify-center cursor-pointer" aria-label="بستن">
|
<button type="button" onClick={onClose} className="size-8 rounded-full bg-white/60 flex items-center justify-center cursor-pointer" aria-label="بستن">
|
||||||
<CloseCircle size={20} color="#292D32" />
|
<CloseCircle size={20} color="#292D32" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ type Props = {
|
|||||||
selectedUsers: TaskUser[];
|
selectedUsers: TaskUser[];
|
||||||
startDate: DateObject | null;
|
startDate: DateObject | null;
|
||||||
endDate: DateObject | null;
|
endDate: DateObject | null;
|
||||||
|
onAddLabel?: () => void;
|
||||||
|
onAddUser?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const TaskDetailMetadataPreview: FC<Props> = ({ selectedLabels, selectedUsers, startDate, endDate }) => {
|
const TaskDetailMetadataPreview: FC<Props> = ({ selectedLabels, selectedUsers, startDate, endDate, onAddLabel, onAddUser }) => {
|
||||||
const { t } = useTranslation("global");
|
const { t } = useTranslation("global");
|
||||||
const dateLabel = formatDateRange(startDate, endDate);
|
const dateLabel = formatDateRange(startDate, endDate);
|
||||||
|
|
||||||
@@ -34,6 +36,7 @@ const TaskDetailMetadataPreview: FC<Props> = ({ selectedLabels, selectedUsers, s
|
|||||||
))}
|
))}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
onClick={onAddLabel}
|
||||||
className="size-7 rounded-lg border border-[#D0D0D0] bg-white/60 flex items-center justify-center cursor-pointer shrink-0"
|
className="size-7 rounded-lg border border-[#D0D0D0] bg-white/60 flex items-center justify-center cursor-pointer shrink-0"
|
||||||
aria-label={t("taskmanager.task_detail.new_label")}
|
aria-label={t("taskmanager.task_detail.new_label")}
|
||||||
>
|
>
|
||||||
@@ -62,6 +65,7 @@ const TaskDetailMetadataPreview: FC<Props> = ({ selectedLabels, selectedUsers, s
|
|||||||
))}
|
))}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
onClick={onAddUser}
|
||||||
className="size-7 rounded-full border border-[#D0D0D0] bg-white/60 flex items-center justify-center cursor-pointer shrink-0"
|
className="size-7 rounded-full border border-[#D0D0D0] bg-white/60 flex items-center justify-center cursor-pointer shrink-0"
|
||||||
aria-label={t("taskmanager.users")}
|
aria-label={t("taskmanager.users")}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { type FC, useEffect, useState } from "react";
|
import { type FC, useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
import DefaulModal from "../../../../components/DefaulModal";
|
import DefaulModal from "../../../../components/DefaulModal";
|
||||||
import type { Task } from "../../types";
|
import PageLoading from "../../../../components/PageLoading";
|
||||||
|
import { ErrorType } from "../../../../helpers/types";
|
||||||
|
import { useChangeTaskPhase, useDeleteTask, useGetTaskDetail, useUpdateTask } from "../../task/hooks/useTaskData";
|
||||||
|
import type { TaskDetailType } from "../../task/types/TaskTypes";
|
||||||
|
import type { Column, Task } from "../../types";
|
||||||
import AttachmentList from "./attachment/List";
|
import AttachmentList from "./attachment/List";
|
||||||
import CheckLists from "./checklist/List";
|
import CheckLists from "./checklist/List";
|
||||||
import TaskDetailDescription from "./TaskDetailDescription";
|
import TaskDetailDescription from "./TaskDetailDescription";
|
||||||
@@ -11,42 +17,170 @@ import type { TaskDetailTab } from "./types";
|
|||||||
type Props = {
|
type Props = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
task: Task | null;
|
task: Task | null;
|
||||||
statusLabel: string;
|
columns: Column[];
|
||||||
|
projectId: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
onTaskPhaseChanged?: (taskId: string, taskPhaseId: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const TaskDetailModal: FC<Props> = ({ open, task, statusLabel, onClose }) => {
|
const TaskDetailModal: FC<Props> = ({ open, task, columns, projectId, onClose, onTaskPhaseChanged }) => {
|
||||||
|
const { t } = useTranslation("global");
|
||||||
const [activeTab, setActiveTab] = useState<TaskDetailTab | null>(null);
|
const [activeTab, setActiveTab] = useState<TaskDetailTab | null>(null);
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
|
const initialTitleRef = useRef("");
|
||||||
|
const initialDescriptionRef = useRef("");
|
||||||
|
|
||||||
|
const getTaskDetail = useGetTaskDetail(task?.id ?? "", open && !!task?.id);
|
||||||
|
const updateTask = useUpdateTask();
|
||||||
|
const changeTaskPhase = useChangeTaskPhase();
|
||||||
|
const deleteTask = useDeleteTask();
|
||||||
|
|
||||||
|
const rawTaskDetail = getTaskDetail.data?.data?.task ?? getTaskDetail.data?.data;
|
||||||
|
const taskDetail: TaskDetailType | undefined = rawTaskDetail
|
||||||
|
? {
|
||||||
|
...rawTaskDetail,
|
||||||
|
id: rawTaskDetail.id ?? task?.id ?? "",
|
||||||
|
labels: rawTaskDetail.labels ?? rawTaskDetail.remarks,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setActiveTab(null);
|
setActiveTab(null);
|
||||||
|
setTitle("");
|
||||||
setDescription("");
|
setDescription("");
|
||||||
|
initialTitleRef.current = "";
|
||||||
|
initialDescriptionRef.current = "";
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!taskDetail) return;
|
||||||
|
|
||||||
|
setTitle(taskDetail.title);
|
||||||
|
setDescription(taskDetail.description ?? "");
|
||||||
|
initialTitleRef.current = taskDetail.title;
|
||||||
|
initialDescriptionRef.current = taskDetail.description ?? "";
|
||||||
|
}, [taskDetail]);
|
||||||
|
|
||||||
|
if (!task) return null;
|
||||||
|
|
||||||
|
const selectedPhaseId = taskDetail?.taskPhaseId ?? task.columnId;
|
||||||
|
|
||||||
const handleTabChange = (tab: TaskDetailTab) => {
|
const handleTabChange = (tab: TaskDetailTab) => {
|
||||||
setActiveTab((prev) => (prev === tab ? null : tab));
|
setActiveTab((prev) => (prev === tab ? null : tab));
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!task) return null;
|
const handleDelete = () => {
|
||||||
|
if (!task.id) return;
|
||||||
|
|
||||||
|
deleteTask.mutate(
|
||||||
|
{ id: task.id, projectId: taskDetail?.projectId ?? "" },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(t("taskmanager.task_deleted"));
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (error: ErrorType) => {
|
||||||
|
toast.error(error.response?.data?.error.message[0]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveField = (field: "title" | "description", value: string) => {
|
||||||
|
const initialValue = field === "title" ? initialTitleRef.current : initialDescriptionRef.current;
|
||||||
|
if (value === initialValue) return;
|
||||||
|
|
||||||
|
updateTask.mutate(
|
||||||
|
{ id: task.id, params: { [field]: value } },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
if (field === "title") {
|
||||||
|
initialTitleRef.current = value;
|
||||||
|
} else {
|
||||||
|
initialDescriptionRef.current = value;
|
||||||
|
}
|
||||||
|
toast.success(t("success"));
|
||||||
|
},
|
||||||
|
onError: (error: ErrorType) => {
|
||||||
|
toast.error(error.response?.data?.error.message[0]);
|
||||||
|
if (field === "title") {
|
||||||
|
setTitle(initialTitleRef.current);
|
||||||
|
} else {
|
||||||
|
setDescription(initialDescriptionRef.current);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePhaseChange = (taskPhaseId: string) => {
|
||||||
|
if (taskPhaseId === selectedPhaseId) return;
|
||||||
|
|
||||||
|
changeTaskPhase.mutate(
|
||||||
|
{ id: task.id, params: { taskPhaseId }, projectId: taskDetail?.projectId ?? projectId },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
onTaskPhaseChanged?.(task.id, taskPhaseId);
|
||||||
|
toast.success(t("success"));
|
||||||
|
},
|
||||||
|
onError: (error: ErrorType) => {
|
||||||
|
toast.error(error.response?.data?.error.message[0]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DefaulModal open={open} close={onClose} width={680}>
|
<DefaulModal open={open} close={onClose} width={680}>
|
||||||
<TaskDetailHeader statusLabel={statusLabel} onClose={onClose} />
|
{getTaskDetail.isPending ? (
|
||||||
|
<PageLoading />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<TaskDetailHeader
|
||||||
|
columns={columns}
|
||||||
|
selectedPhaseId={selectedPhaseId}
|
||||||
|
onPhaseChange={handlePhaseChange}
|
||||||
|
isChangingPhase={changeTaskPhase.isPending}
|
||||||
|
onClose={onClose}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
isDeleting={deleteTask.isPending}
|
||||||
|
/>
|
||||||
|
|
||||||
<h2 className="mt-6 text-sm font-bold">{task.title}</h2>
|
<input
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
onBlur={() => saveField("title", title)}
|
||||||
|
className="mt-6 w-full text-sm font-bold bg-transparent outline-none"
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<TaskDetailToolbar activeTab={activeTab} onTabChange={handleTabChange} />
|
<TaskDetailToolbar
|
||||||
|
activeTab={activeTab}
|
||||||
|
onTabChange={handleTabChange}
|
||||||
|
taskDetail={taskDetail}
|
||||||
|
taskId={task.id}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TaskDetailDescription value={description} onChange={setDescription} />
|
<TaskDetailDescription
|
||||||
|
value={description}
|
||||||
|
onChange={setDescription}
|
||||||
|
onBlur={() => saveField("description", description)}
|
||||||
|
/>
|
||||||
|
|
||||||
<AttachmentList />
|
<AttachmentList attachments={taskDetail?.attachments} />
|
||||||
|
|
||||||
<CheckLists />
|
<CheckLists
|
||||||
|
taskId={task.id}
|
||||||
|
checkListItems={taskDetail?.checkListItems}
|
||||||
|
checkListItemCount={taskDetail?.checkListItemCount}
|
||||||
|
completedCheckListItemCount={taskDetail?.completedCheckListItemCount}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</DefaulModal>
|
</DefaulModal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,36 +1,91 @@
|
|||||||
import { type FC, useState } from "react";
|
import { type FC, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import { useGetUsers } from "../../../users/hooks/useUserData";
|
||||||
import TaskDetailAttachmentPopover from "./attachment/TaskDetailAttachmentPopover";
|
import TaskDetailAttachmentPopover from "./attachment/TaskDetailAttachmentPopover";
|
||||||
import { DEFAULT_DATE_RANGE } from "./date/constants";
|
|
||||||
import TaskDetailDatePopover from "./date/TaskDetailDatePopover";
|
import TaskDetailDatePopover from "./date/TaskDetailDatePopover";
|
||||||
|
import { parseApiDate, toApiDate } from "./date/utils";
|
||||||
import type DateObject from "react-date-object";
|
import type DateObject from "react-date-object";
|
||||||
import { DEFAULT_CHECKLISTS } from "./checklist/constants";
|
|
||||||
import TaskDetailChecklistPopover from "./checklist/TaskDetailChecklistPopover";
|
import TaskDetailChecklistPopover from "./checklist/TaskDetailChecklistPopover";
|
||||||
import type { TaskChecklist } from "./checklist/types";
|
|
||||||
import { DEFAULT_LABELS } from "./labels/constants";
|
|
||||||
import TaskDetailLabelsPopover from "./labels/TaskDetailLabelsPopover";
|
import TaskDetailLabelsPopover from "./labels/TaskDetailLabelsPopover";
|
||||||
import type { LabelsView, TaskLabel } from "./labels/types";
|
import type { LabelsView, TaskLabel } from "./labels/types";
|
||||||
import TaskDetailActionBar from "./TaskDetailActionBar";
|
import TaskDetailActionBar from "./TaskDetailActionBar";
|
||||||
import TaskDetailMetadataPreview from "./TaskDetailMetadataPreview";
|
import TaskDetailMetadataPreview from "./TaskDetailMetadataPreview";
|
||||||
import type { TaskDetailTab } from "./types";
|
import type { TaskDetailTab } from "./types";
|
||||||
import { DEFAULT_USERS } from "./users/constants";
|
|
||||||
import TaskDetailUsersPopover from "./users/TaskDetailUsersPopover";
|
import TaskDetailUsersPopover from "./users/TaskDetailUsersPopover";
|
||||||
|
import type { TaskDetailType } from "../../task/types/TaskTypes";
|
||||||
|
import { useCreateCheckListItem, useCreateTaskAttachment, useCreateTaskRemark, useUpdateTask } from "../../task/hooks/useTaskData";
|
||||||
|
import { useSingleUpload } from "../../../service/hooks/useServiceData";
|
||||||
|
import { ErrorType } from "../../../../helpers/types";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
activeTab: TaskDetailTab | null;
|
activeTab: TaskDetailTab | null;
|
||||||
onTabChange: (tab: TaskDetailTab) => void;
|
onTabChange: (tab: TaskDetailTab) => void;
|
||||||
|
taskDetail?: TaskDetailType;
|
||||||
|
taskId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const TaskDetailToolbar: FC<Props> = ({ activeTab, onTabChange }) => {
|
const TaskDetailToolbar: FC<Props> = ({ activeTab, onTabChange, taskDetail, taskId }) => {
|
||||||
|
const { t } = useTranslation("global");
|
||||||
|
const createTaskRemark = useCreateTaskRemark();
|
||||||
|
const createCheckListItem = useCreateCheckListItem();
|
||||||
|
const createTaskAttachment = useCreateTaskAttachment();
|
||||||
|
const singleUpload = useSingleUpload();
|
||||||
|
const updateTask = useUpdateTask();
|
||||||
|
const getUsers = useGetUsers("");
|
||||||
const [labelsView, setLabelsView] = useState<LabelsView>("list");
|
const [labelsView, setLabelsView] = useState<LabelsView>("list");
|
||||||
const [labels, setLabels] = useState<TaskLabel[]>(DEFAULT_LABELS);
|
const [labels, setLabels] = useState<TaskLabel[]>([]);
|
||||||
const [selectedLabelIds, setSelectedLabelIds] = useState<Set<string>>(new Set(["1"]));
|
const [selectedLabelIds, setSelectedLabelIds] = useState<Set<string>>(new Set());
|
||||||
const [selectedUserIds, setSelectedUserIds] = useState<Set<string>>(new Set(["1"]));
|
const [selectedUserIds, setSelectedUserIds] = useState<Set<string>>(new Set());
|
||||||
const [checklists, setChecklists] = useState<TaskChecklist[]>(DEFAULT_CHECKLISTS);
|
const [startDate, setStartDate] = useState<DateObject | null>(null);
|
||||||
const [startDate, setStartDate] = useState<DateObject | null>(DEFAULT_DATE_RANGE.startDate);
|
const [endDate, setEndDate] = useState<DateObject | null>(null);
|
||||||
const [endDate, setEndDate] = useState<DateObject | null>(DEFAULT_DATE_RANGE.endDate);
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!taskDetail) return;
|
||||||
|
|
||||||
|
const taskLabels = taskDetail.labels ?? taskDetail.remarks ?? [];
|
||||||
|
|
||||||
|
if (taskLabels.length) {
|
||||||
|
setLabels(taskLabels);
|
||||||
|
setSelectedLabelIds(new Set(taskLabels.map((label) => label.id)));
|
||||||
|
} else {
|
||||||
|
setLabels([]);
|
||||||
|
setSelectedLabelIds(new Set());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (taskDetail.users?.length) {
|
||||||
|
setSelectedUserIds(new Set(taskDetail.users.map((user) => user.id)));
|
||||||
|
} else {
|
||||||
|
setSelectedUserIds(new Set());
|
||||||
|
}
|
||||||
|
|
||||||
|
setStartDate(parseApiDate(taskDetail.startDate));
|
||||||
|
setEndDate(parseApiDate(taskDetail.endDate));
|
||||||
|
}, [taskDetail]);
|
||||||
|
|
||||||
const selectedLabels = labels.filter((label) => selectedLabelIds.has(label.id));
|
const selectedLabels = labels.filter((label) => selectedLabelIds.has(label.id));
|
||||||
const selectedUsers = DEFAULT_USERS.filter((user) => selectedUserIds.has(user.id));
|
const users = useMemo(() => {
|
||||||
|
const apiUsers =
|
||||||
|
getUsers.data?.data?.users?.map((user) => ({
|
||||||
|
id: user.id,
|
||||||
|
name: `${user.firstName} ${user.lastName}`.trim(),
|
||||||
|
})) ?? [];
|
||||||
|
const taskUsers =
|
||||||
|
taskDetail?.users?.map((user) => ({
|
||||||
|
id: user.id,
|
||||||
|
name: `${user.firstName} ${user.lastName}`.trim(),
|
||||||
|
})) ?? [];
|
||||||
|
const userMap = new Map(apiUsers.map((user) => [user.id, user]));
|
||||||
|
|
||||||
|
taskUsers.forEach((user) => {
|
||||||
|
if (!userMap.has(user.id)) {
|
||||||
|
userMap.set(user.id, user);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(userMap.values());
|
||||||
|
}, [getUsers.data, taskDetail?.users]);
|
||||||
|
const selectedUsers = users.filter((user) => selectedUserIds.has(user.id));
|
||||||
const isLabelsOpen = activeTab === "labels";
|
const isLabelsOpen = activeTab === "labels";
|
||||||
const isUsersOpen = activeTab === "users";
|
const isUsersOpen = activeTab === "users";
|
||||||
const isChecklistOpen = activeTab === "checklist";
|
const isChecklistOpen = activeTab === "checklist";
|
||||||
@@ -44,6 +99,19 @@ const TaskDetailToolbar: FC<Props> = ({ activeTab, onTabChange }) => {
|
|||||||
onTabChange(tab);
|
onTabChange(tab);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAddLabelClick = () => {
|
||||||
|
setLabelsView("create");
|
||||||
|
if (activeTab !== "labels") {
|
||||||
|
onTabChange("labels");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddUserClick = () => {
|
||||||
|
if (activeTab !== "users") {
|
||||||
|
onTabChange("users");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleLabelToggle = (id: string) => {
|
const handleLabelToggle = (id: string) => {
|
||||||
setSelectedLabelIds((prev) => {
|
setSelectedLabelIds((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
@@ -57,57 +125,162 @@ const TaskDetailToolbar: FC<Props> = ({ activeTab, onTabChange }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleUserToggle = (id: string) => {
|
const handleUserToggle = (id: string) => {
|
||||||
setSelectedUserIds((prev) => {
|
const effectiveTaskId = taskId || taskDetail?.id;
|
||||||
const next = new Set(prev);
|
if (!effectiveTaskId) return;
|
||||||
|
|
||||||
|
const previous = selectedUserIds;
|
||||||
|
const next = new Set(previous);
|
||||||
if (next.has(id)) {
|
if (next.has(id)) {
|
||||||
next.delete(id);
|
next.delete(id);
|
||||||
} else {
|
} else {
|
||||||
next.add(id);
|
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 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 = {
|
const newLabel: TaskLabel = {
|
||||||
id: crypto.randomUUID(),
|
id: remark.id,
|
||||||
title,
|
title: remark.title,
|
||||||
color,
|
color: remark.color,
|
||||||
};
|
};
|
||||||
setLabels((prev) => [...prev, newLabel]);
|
setLabels((prev) => [...prev, newLabel]);
|
||||||
setSelectedLabelIds((prev) => new Set([...prev, newLabel.id]));
|
setSelectedLabelIds((prev) => new Set([...prev, newLabel.id]));
|
||||||
|
}
|
||||||
setLabelsView("list");
|
setLabelsView("list");
|
||||||
|
toast.success(t("success"));
|
||||||
|
},
|
||||||
|
onError: (error: ErrorType) => {
|
||||||
|
toast.error(error.response?.data?.error.message[0]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChecklistClose = () => {
|
const handleChecklistClose = () => {
|
||||||
if (isChecklistOpen) onTabChange("checklist");
|
if (isChecklistOpen) onTabChange("checklist");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChecklistCreate = (title: string, _copyFromId: string) => {
|
const handleChecklistCreate = (title: string) => {
|
||||||
const newChecklist: TaskChecklist = {
|
const effectiveTaskId = taskId || taskDetail?.id;
|
||||||
id: crypto.randomUUID(),
|
if (!effectiveTaskId) return;
|
||||||
title,
|
|
||||||
};
|
createCheckListItem.mutate(
|
||||||
setChecklists((prev) => [...prev, newChecklist]);
|
{ title, isDone: false, taskId: effectiveTaskId },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
handleChecklistClose();
|
handleChecklistClose();
|
||||||
|
toast.success(t("success"));
|
||||||
|
},
|
||||||
|
onError: (error: ErrorType) => {
|
||||||
|
toast.error(error.response?.data?.error.message[0]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAttachmentClose = () => {
|
const handleAttachmentClose = () => {
|
||||||
if (isAttachmentOpen) onTabChange("attachment");
|
if (isAttachmentOpen) onTabChange("attachment");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAttachmentCreate = (_data: { file?: File; title: string; linkId: string }) => {
|
const handleAttachmentCreate = async (data: { file?: File; title: string; linkId: string }) => {
|
||||||
|
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();
|
handleAttachmentClose();
|
||||||
|
toast.success(t("success"));
|
||||||
|
},
|
||||||
|
onError: (error: ErrorType) => {
|
||||||
|
toast.error(error.response?.data?.error.message[0]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isAttachmentSubmitting = singleUpload.isPending || createTaskAttachment.isPending;
|
||||||
|
|
||||||
const handleDateClose = () => {
|
const handleDateClose = () => {
|
||||||
if (isDateOpen) onTabChange("date");
|
if (isDateOpen) onTabChange("date");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDateSubmit = (data: { startDate: DateObject | null; endDate: DateObject | null }) => {
|
const handleDateSubmit = (data: { startDate: DateObject | null; endDate: DateObject | null }) => {
|
||||||
|
const effectiveTaskId = taskId || taskDetail?.id;
|
||||||
|
if (!effectiveTaskId) return;
|
||||||
|
|
||||||
|
const previousStartDate = startDate;
|
||||||
|
const previousEndDate = endDate;
|
||||||
|
const params = {
|
||||||
|
startDate: data.startDate ? toApiDate(data.startDate) : null,
|
||||||
|
endDate: data.endDate ? toApiDate(data.endDate) : null,
|
||||||
|
};
|
||||||
|
|
||||||
setStartDate(data.startDate);
|
setStartDate(data.startDate);
|
||||||
setEndDate(data.endDate);
|
setEndDate(data.endDate);
|
||||||
|
|
||||||
|
updateTask.mutate(
|
||||||
|
{ id: effectiveTaskId, params },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
handleDateClose();
|
handleDateClose();
|
||||||
|
toast.success(t("success"));
|
||||||
|
},
|
||||||
|
onError: (error: ErrorType) => {
|
||||||
|
toast.error(error.response?.data?.error.message[0]);
|
||||||
|
setStartDate(previousStartDate);
|
||||||
|
setEndDate(previousEndDate);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -124,31 +297,48 @@ const TaskDetailToolbar: FC<Props> = ({ activeTab, onTabChange }) => {
|
|||||||
onViewChange={setLabelsView}
|
onViewChange={setLabelsView}
|
||||||
onToggle={handleLabelToggle}
|
onToggle={handleLabelToggle}
|
||||||
onCreate={handleCreate}
|
onCreate={handleCreate}
|
||||||
|
isSubmitting={createTaskRemark.isPending}
|
||||||
/>
|
/>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
usersPopover={
|
usersPopover={
|
||||||
isUsersOpen ? (
|
isUsersOpen ? (
|
||||||
<TaskDetailUsersPopover users={DEFAULT_USERS} selectedIds={selectedUserIds} onToggle={handleUserToggle} />
|
<TaskDetailUsersPopover
|
||||||
|
users={users}
|
||||||
|
selectedIds={selectedUserIds}
|
||||||
|
onToggle={handleUserToggle}
|
||||||
|
isLoading={getUsers.isPending}
|
||||||
|
isSubmitting={updateTask.isPending}
|
||||||
|
/>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
checklistPopover={
|
checklistPopover={
|
||||||
isChecklistOpen ? (
|
isChecklistOpen ? (
|
||||||
<TaskDetailChecklistPopover
|
<TaskDetailChecklistPopover
|
||||||
checklists={checklists}
|
|
||||||
onClose={handleChecklistClose}
|
onClose={handleChecklistClose}
|
||||||
onSubmit={handleChecklistCreate}
|
onSubmit={handleChecklistCreate}
|
||||||
|
isSubmitting={createCheckListItem.isPending}
|
||||||
/>
|
/>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
attachmentPopover={
|
attachmentPopover={
|
||||||
isAttachmentOpen ? (
|
isAttachmentOpen ? (
|
||||||
<TaskDetailAttachmentPopover onClose={handleAttachmentClose} onSubmit={handleAttachmentCreate} />
|
<TaskDetailAttachmentPopover
|
||||||
|
onClose={handleAttachmentClose}
|
||||||
|
onSubmit={handleAttachmentCreate}
|
||||||
|
isSubmitting={isAttachmentSubmitting}
|
||||||
|
/>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
datePopover={
|
datePopover={
|
||||||
isDateOpen ? (
|
isDateOpen ? (
|
||||||
<TaskDetailDatePopover onClose={handleDateClose} onSubmit={handleDateSubmit} />
|
<TaskDetailDatePopover
|
||||||
|
onClose={handleDateClose}
|
||||||
|
onSubmit={handleDateSubmit}
|
||||||
|
initialStartDate={startDate}
|
||||||
|
initialEndDate={endDate}
|
||||||
|
isSubmitting={updateTask.isPending}
|
||||||
|
/>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -158,6 +348,8 @@ const TaskDetailToolbar: FC<Props> = ({ activeTab, onTabChange }) => {
|
|||||||
selectedUsers={selectedUsers}
|
selectedUsers={selectedUsers}
|
||||||
startDate={startDate}
|
startDate={startDate}
|
||||||
endDate={endDate}
|
endDate={endDate}
|
||||||
|
onAddLabel={handleAddLabelClick}
|
||||||
|
onAddUser={handleAddUserClick}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { timeAgo } from "../../../../../config/func";
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
title: string;
|
title: string;
|
||||||
createdAt: string;
|
createdAt?: string;
|
||||||
type: "file" | "link";
|
type: "file" | "link";
|
||||||
url?: string;
|
url?: string;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
@@ -21,14 +21,14 @@ const AttachmentItem: FC<Props> = ({ title, createdAt, type, url, onEdit, onDele
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
{type === "link" && url ? (
|
{url ? (
|
||||||
<a href={url} target="_blank" rel="noopener noreferrer" className="text-xs font-bold text-[#0047FF] underline truncate block">
|
<a href={url} target="_blank" rel="noopener noreferrer" className="text-xs font-bold text-[#0047FF] underline truncate block">
|
||||||
{title}
|
{title}
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-xs font-bold truncate">{title}</div>
|
<div className="text-xs font-bold truncate">{title}</div>
|
||||||
)}
|
)}
|
||||||
<div className="text-[11px] mt-0.5">{timeAgo(createdAt)}</div>
|
{createdAt && <div className="text-[11px] mt-0.5">{timeAgo(createdAt)}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,29 +2,27 @@ import { type FC } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import AttachmentItem from "./AttachmentItem";
|
import AttachmentItem from "./AttachmentItem";
|
||||||
import type { TaskAttachment } from "./types";
|
import type { TaskAttachment } from "./types";
|
||||||
|
import type { TaskAttachmentType } from "../../../task/types/TaskTypes";
|
||||||
|
|
||||||
const AttachmentList: FC = () => {
|
type Props = {
|
||||||
|
attachments?: TaskAttachmentType[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const AttachmentList: FC<Props> = ({ attachments = [] }) => {
|
||||||
const { t } = useTranslation("global");
|
const { t } = useTranslation("global");
|
||||||
|
|
||||||
const attachments: TaskAttachment[] = [
|
const mappedAttachments: TaskAttachment[] = attachments.map((item) => ({
|
||||||
{
|
id: item.id,
|
||||||
id: "1",
|
title: item.title,
|
||||||
title: "file.pdf",
|
type: item.type,
|
||||||
fileName: "file.pdf",
|
file: item.file,
|
||||||
createdAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
|
createdAt: item.createdAt,
|
||||||
},
|
}));
|
||||||
{
|
|
||||||
id: "2",
|
|
||||||
title: "لینک طرح",
|
|
||||||
url: "https://example.com",
|
|
||||||
createdAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const files = attachments.filter((item) => item.fileName);
|
const files = mappedAttachments.filter((item) => item.type === "file");
|
||||||
const links = attachments.filter((item) => item.url);
|
const links = mappedAttachments.filter((item) => item.type === "link");
|
||||||
|
|
||||||
if (attachments.length === 0) return null;
|
if (mappedAttachments.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
@@ -38,9 +36,10 @@ const AttachmentList: FC = () => {
|
|||||||
{files.map((item) => (
|
{files.map((item) => (
|
||||||
<AttachmentItem
|
<AttachmentItem
|
||||||
key={item.id}
|
key={item.id}
|
||||||
title={item.fileName ?? item.title}
|
title={item.title}
|
||||||
createdAt={item.createdAt ?? ""}
|
createdAt={item.createdAt}
|
||||||
type="file"
|
type="file"
|
||||||
|
url={item.file}
|
||||||
onEdit={() => {}}
|
onEdit={() => {}}
|
||||||
onDelete={() => {}}
|
onDelete={() => {}}
|
||||||
/>
|
/>
|
||||||
@@ -60,7 +59,7 @@ const AttachmentList: FC = () => {
|
|||||||
title={item.title}
|
title={item.title}
|
||||||
createdAt={item.createdAt}
|
createdAt={item.createdAt}
|
||||||
type="link"
|
type="link"
|
||||||
url={item.url}
|
url={item.file}
|
||||||
onEdit={() => {}}
|
onEdit={() => {}}
|
||||||
onDelete={() => {}}
|
onDelete={() => {}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+4
-3
@@ -7,9 +7,10 @@ import Select from "../../../../../components/Select";
|
|||||||
type Props = {
|
type Props = {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSubmit: (data: { file?: File; title: string; linkId: string }) => void;
|
onSubmit: (data: { file?: File; title: string; linkId: string }) => void;
|
||||||
|
isSubmitting?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const TaskDetailAttachmentForm: FC<Props> = ({ onClose, onSubmit }) => {
|
const TaskDetailAttachmentForm: FC<Props> = ({ onClose, onSubmit, isSubmitting = false }) => {
|
||||||
const { t } = useTranslation("global");
|
const { t } = useTranslation("global");
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
@@ -64,10 +65,10 @@ const TaskDetailAttachmentForm: FC<Props> = ({ onClose, onSubmit }) => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Button type="button" onClick={onClose} className="h-8 bg-[#E8E4F0] text-[#292D32] rounded-xl text-xs w-[95px]">
|
<Button type="button" onClick={onClose} disabled={isSubmitting} className="h-8 bg-[#E8E4F0] text-[#292D32] rounded-xl text-xs w-[95px]">
|
||||||
{t("cancel")}
|
{t("cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" onClick={handleSubmit} disabled={!canSubmit} className="h-8 bg-[#292D32] text-white rounded-xl text-xs disabled:opacity-50 w-[95px]">
|
<Button type="button" onClick={handleSubmit} disabled={!canSubmit || isSubmitting} isLoading={isSubmitting} className="h-8 bg-[#292D32] text-white rounded-xl text-xs disabled:opacity-50 w-[95px]">
|
||||||
{t("taskmanager.task_detail.insert")}
|
{t("taskmanager.task_detail.insert")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+3
-2
@@ -4,10 +4,11 @@ import TaskDetailAttachmentForm from "./TaskDetailAttachmentForm";
|
|||||||
type Props = {
|
type Props = {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSubmit: (data: { file?: File; title: string; linkId: string }) => void;
|
onSubmit: (data: { file?: File; title: string; linkId: string }) => void;
|
||||||
|
isSubmitting?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const TaskDetailAttachmentPopover: FC<Props> = ({ onClose, onSubmit }) => {
|
const TaskDetailAttachmentPopover: FC<Props> = ({ onClose, onSubmit, isSubmitting }) => {
|
||||||
return <TaskDetailAttachmentForm onClose={onClose} onSubmit={onSubmit} />;
|
return <TaskDetailAttachmentForm onClose={onClose} onSubmit={onSubmit} isSubmitting={isSubmitting} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default TaskDetailAttachmentPopover;
|
export default TaskDetailAttachmentPopover;
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
export type TaskAttachment = {
|
export type TaskAttachment = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
createdAt: string;
|
type: "file" | "link";
|
||||||
linkId?: string;
|
file?: string;
|
||||||
fileName?: string;
|
createdAt?: string;
|
||||||
url?: string;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,26 +1,63 @@
|
|||||||
import { Edit } from "iconsax-react";
|
import { Edit } from "iconsax-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
import ProgressBar from "../../../../../components/ProgressBar";
|
import ProgressBar from "../../../../../components/ProgressBar";
|
||||||
import TrashWithConfrim from "../../../../../components/TrashWithConfrim";
|
import TrashWithConfrim from "../../../../../components/TrashWithConfrim";
|
||||||
|
import { ErrorType } from "../../../../../helpers/types";
|
||||||
|
import { useUpdateCheckListItem } from "../../../task/hooks/useTaskData";
|
||||||
|
import type { TaskChecklistItemType } from "../../../task/types/TaskTypes";
|
||||||
import ChecklistItem from "./ChecklistItem";
|
import ChecklistItem from "./ChecklistItem";
|
||||||
|
|
||||||
const CheckLists = () => {
|
type Props = {
|
||||||
const items = [
|
taskId: string;
|
||||||
{ id: "1", title: "آیتم ۱", checked: true },
|
checkListItems?: TaskChecklistItemType[];
|
||||||
{ id: "2", title: "آیتم ۱", checked: true },
|
checkListItemCount?: number;
|
||||||
{ id: "3", title: "آیتم ۱", checked: true },
|
completedCheckListItemCount?: number;
|
||||||
];
|
};
|
||||||
|
|
||||||
|
const CheckLists = ({
|
||||||
|
taskId,
|
||||||
|
checkListItems = [],
|
||||||
|
checkListItemCount,
|
||||||
|
completedCheckListItemCount,
|
||||||
|
}: Props) => {
|
||||||
|
const { t } = useTranslation("global");
|
||||||
|
const updateCheckListItem = useUpdateCheckListItem();
|
||||||
|
|
||||||
|
const items = checkListItems.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
title: item.title,
|
||||||
|
checked: item.isDone,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const totalCount = checkListItemCount ?? items.length;
|
||||||
|
const doneCount = completedCheckListItemCount ?? items.filter((item) => item.checked).length;
|
||||||
|
const progress = totalCount > 0 ? Math.round((doneCount / totalCount) * 100) : 0;
|
||||||
|
|
||||||
|
const handleToggle = (id: string, checked: boolean) => {
|
||||||
|
updateCheckListItem.mutate(
|
||||||
|
{ id, params: { isDone: !checked }, taskId },
|
||||||
|
{
|
||||||
|
onError: (error: ErrorType) => {
|
||||||
|
toast.error(error.response?.data?.error.message[0]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (checkListItems.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<div className="text-sm font-bold mt-px">چک لیست</div>
|
<div className="text-sm font-bold mt-px">{t("taskmanager.task_detail.checklist")}</div>
|
||||||
<Edit size={20} color="#0047FF" />
|
<Edit size={20} color="#0047FF" />
|
||||||
<TrashWithConfrim onDelete={() => {}} color="#FF0000" />
|
<TrashWithConfrim onDelete={() => {}} color="#FF0000" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex gap-2 items-center">
|
<div className="mt-4 flex gap-2 items-center">
|
||||||
<div className="text-xs shrink-0">۲۵٪</div>
|
<div className="text-xs shrink-0">{progress}٪</div>
|
||||||
<ProgressBar value={25} />
|
<ProgressBar value={progress} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex flex-col gap-2">
|
<div className="mt-4 flex flex-col gap-2">
|
||||||
@@ -29,7 +66,7 @@ const CheckLists = () => {
|
|||||||
key={item.id}
|
key={item.id}
|
||||||
title={item.title}
|
title={item.title}
|
||||||
checked={item.checked}
|
checked={item.checked}
|
||||||
onToggle={() => {}}
|
onToggle={() => handleToggle(item.id, item.checked)}
|
||||||
onEdit={() => {}}
|
onEdit={() => {}}
|
||||||
onDelete={() => {}}
|
onDelete={() => {}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+10
-19
@@ -3,26 +3,21 @@ import { type FC, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import Button from "../../../../../components/Button";
|
import Button from "../../../../../components/Button";
|
||||||
import Input from "../../../../../components/Input";
|
import Input from "../../../../../components/Input";
|
||||||
import Select from "../../../../../components/Select";
|
|
||||||
import type { TaskChecklist } from "./types";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
checklists: TaskChecklist[];
|
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSubmit: (title: string, copyFromId: string) => void;
|
onSubmit: (title: string) => void;
|
||||||
|
isSubmitting?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const TaskDetailChecklistForm: FC<Props> = ({ checklists, onClose, onSubmit }) => {
|
const TaskDetailChecklistForm: FC<Props> = ({ onClose, onSubmit, isSubmitting = false }) => {
|
||||||
const { t } = useTranslation("global");
|
const { t } = useTranslation("global");
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [copyFromId, setCopyFromId] = useState("");
|
|
||||||
|
|
||||||
const copyFromItems = [{ value: "", label: t("taskmanager.task_detail.none") }, ...checklists.map((checklist) => ({ value: checklist.id, label: checklist.title }))];
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
const trimmed = title.trim();
|
const trimmed = title.trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
onSubmit(trimmed, copyFromId);
|
onSubmit(trimmed);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -36,20 +31,16 @@ const TaskDetailChecklistForm: FC<Props> = ({ checklists, onClose, onSubmit }) =
|
|||||||
|
|
||||||
<Input label={t("taskmanager.task_detail.label_title")} value={title} onChange={(e) => setTitle(e.target.value)} className="h-8 bg-white/63 border-white" labelClassName="text-xs" />
|
<Input label={t("taskmanager.task_detail.label_title")} value={title} onChange={(e) => setTitle(e.target.value)} className="h-8 bg-white/63 border-white" labelClassName="text-xs" />
|
||||||
|
|
||||||
<Select
|
|
||||||
label={t("taskmanager.task_detail.copy_from")}
|
|
||||||
labelClassName="text-xs"
|
|
||||||
value={copyFromId}
|
|
||||||
onChange={(e) => setCopyFromId(e.target.value)}
|
|
||||||
items={copyFromItems}
|
|
||||||
className="h-8 bg-white/63 border-white text-xs rounded-xl"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Button type="button" onClick={onClose} className="h-8 bg-[#E8E4F0] text-[#292D32] rounded-xl text-xs w-[95px]">
|
<Button type="button" onClick={onClose} className="h-8 bg-[#E8E4F0] text-[#292D32] rounded-xl text-xs w-[95px]">
|
||||||
{t("cancel")}
|
{t("cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" onClick={handleSubmit} disabled={!title.trim()} className="h-8 bg-[#292D32] text-white rounded-xl text-xs disabled:opacity-50 w-[95px]">
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!title.trim() || isSubmitting}
|
||||||
|
className="h-8 bg-[#292D32] text-white rounded-xl text-xs disabled:opacity-50 w-[95px]"
|
||||||
|
>
|
||||||
{t("save")}
|
{t("save")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+4
-5
@@ -1,15 +1,14 @@
|
|||||||
import { type FC } from "react";
|
import { type FC } from "react";
|
||||||
import TaskDetailChecklistForm from "./TaskDetailChecklistForm";
|
import TaskDetailChecklistForm from "./TaskDetailChecklistForm";
|
||||||
import type { TaskChecklist } from "./types";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
checklists: TaskChecklist[];
|
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSubmit: (title: string, copyFromId: string) => void;
|
onSubmit: (title: string) => void;
|
||||||
|
isSubmitting?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const TaskDetailChecklistPopover: FC<Props> = ({ checklists, onClose, onSubmit }) => {
|
const TaskDetailChecklistPopover: FC<Props> = ({ onClose, onSubmit, isSubmitting }) => {
|
||||||
return <TaskDetailChecklistForm checklists={checklists} onClose={onClose} onSubmit={onSubmit} />;
|
return <TaskDetailChecklistForm onClose={onClose} onSubmit={onSubmit} isSubmitting={isSubmitting} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default TaskDetailChecklistPopover;
|
export default TaskDetailChecklistPopover;
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
import type { TaskChecklist } from "./types";
|
|
||||||
|
|
||||||
export const DEFAULT_CHECKLISTS: TaskChecklist[] = [
|
|
||||||
{ id: "1", title: "چک لیست پروژه" },
|
|
||||||
{ id: "2", title: "چک لیست انتشار" },
|
|
||||||
];
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export type TaskChecklist = {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
};
|
|
||||||
@@ -12,14 +12,23 @@ import { formatDateLabel } from "./utils";
|
|||||||
type Props = {
|
type Props = {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSubmit: (data: { startDate: DateObject | null; endDate: DateObject | null }) => void;
|
onSubmit: (data: { startDate: DateObject | null; endDate: DateObject | null }) => void;
|
||||||
|
initialStartDate?: DateObject | null;
|
||||||
|
initialEndDate?: DateObject | null;
|
||||||
|
isSubmitting?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const TaskDetailDateForm: FC<Props> = ({ onClose, onSubmit }) => {
|
const TaskDetailDateForm: FC<Props> = ({
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
initialStartDate = null,
|
||||||
|
initialEndDate = null,
|
||||||
|
isSubmitting = false,
|
||||||
|
}) => {
|
||||||
const { t } = useTranslation("global");
|
const { t } = useTranslation("global");
|
||||||
const [startDate, setStartDate] = useState<DateObject | null>(null);
|
const [startDate, setStartDate] = useState<DateObject | null>(initialStartDate);
|
||||||
const [endDate, setEndDate] = useState<DateObject | null>(null);
|
const [endDate, setEndDate] = useState<DateObject | null>(initialEndDate);
|
||||||
const [startEnabled, setStartEnabled] = useState(true);
|
const [startEnabled, setStartEnabled] = useState(Boolean(initialStartDate) || !initialEndDate);
|
||||||
const [endEnabled, setEndEnabled] = useState(false);
|
const [endEnabled, setEndEnabled] = useState(Boolean(initialEndDate));
|
||||||
const [activeField, setActiveField] = useState<TaskDateField>("start");
|
const [activeField, setActiveField] = useState<TaskDateField>("start");
|
||||||
|
|
||||||
const calendarValue = (() => {
|
const calendarValue = (() => {
|
||||||
@@ -135,10 +144,21 @@ const TaskDetailDateForm: FC<Props> = ({ onClose, onSubmit }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Button type="button" onClick={onClose} className="h-8 bg-[#E8E4F0] text-[#292D32] rounded-xl text-xs w-[95px]">
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="h-8 bg-[#E8E4F0] text-[#292D32] rounded-xl text-xs w-[95px]"
|
||||||
|
>
|
||||||
{t("cancel")}
|
{t("cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" onClick={handleSubmit} disabled={!canSubmit} className="h-8 bg-[#292D32] text-white rounded-xl text-xs disabled:opacity-50 w-[95px]">
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!canSubmit || isSubmitting}
|
||||||
|
isLoading={isSubmitting}
|
||||||
|
className="h-8 bg-[#292D32] text-white rounded-xl text-xs disabled:opacity-50 w-[95px]"
|
||||||
|
>
|
||||||
{t("taskmanager.task_detail.insert")}
|
{t("taskmanager.task_detail.insert")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,10 +5,27 @@ import TaskDetailDateForm from "./TaskDetailDateForm";
|
|||||||
type Props = {
|
type Props = {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSubmit: (data: { startDate: DateObject | null; endDate: DateObject | null }) => void;
|
onSubmit: (data: { startDate: DateObject | null; endDate: DateObject | null }) => void;
|
||||||
|
initialStartDate?: DateObject | null;
|
||||||
|
initialEndDate?: DateObject | null;
|
||||||
|
isSubmitting?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const TaskDetailDatePopover: FC<Props> = ({ onClose, onSubmit }) => {
|
const TaskDetailDatePopover: FC<Props> = ({
|
||||||
return <TaskDetailDateForm onClose={onClose} onSubmit={onSubmit} />;
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
initialStartDate,
|
||||||
|
initialEndDate,
|
||||||
|
isSubmitting,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<TaskDetailDateForm
|
||||||
|
onClose={onClose}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
initialStartDate={initialStartDate}
|
||||||
|
initialEndDate={initialEndDate}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
/>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default TaskDetailDatePopover;
|
export default TaskDetailDatePopover;
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
import DateObject from "react-date-object";
|
import DateObject from "react-date-object";
|
||||||
|
import gregorian from "react-date-object/calendars/gregorian";
|
||||||
import persian from "react-date-object/calendars/persian";
|
import persian from "react-date-object/calendars/persian";
|
||||||
|
import gregorian_en from "react-date-object/locales/gregorian_en";
|
||||||
import persian_fa from "react-date-object/locales/persian_fa";
|
import persian_fa from "react-date-object/locales/persian_fa";
|
||||||
|
|
||||||
|
export const parseApiDate = (value?: string | null): DateObject | null => {
|
||||||
|
if (!value) return null;
|
||||||
|
const date = new DateObject({ date: value, calendar: gregorian });
|
||||||
|
return date.isValid ? date : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toApiDate = (date: DateObject): string => {
|
||||||
|
return date.convert(gregorian, gregorian_en).format("YYYY-MM-DD");
|
||||||
|
};
|
||||||
|
|
||||||
export const formatDateLabel = (date: DateObject | null) => {
|
export const formatDateLabel = (date: DateObject | null) => {
|
||||||
if (!date) return "";
|
if (!date) return "";
|
||||||
return date.convert(persian, persian_fa).format("D MMMM");
|
return date.convert(persian, persian_fa).format("D MMMM");
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type Props = {
|
|||||||
onViewChange: (view: LabelsView) => void;
|
onViewChange: (view: LabelsView) => void;
|
||||||
onToggle: (id: string) => void;
|
onToggle: (id: string) => void;
|
||||||
onCreate: (title: string, color: string) => void;
|
onCreate: (title: string, color: string) => void;
|
||||||
|
isSubmitting?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const TaskDetailLabelsPopover: FC<Props> = ({
|
const TaskDetailLabelsPopover: FC<Props> = ({
|
||||||
@@ -19,6 +20,7 @@ const TaskDetailLabelsPopover: FC<Props> = ({
|
|||||||
onViewChange,
|
onViewChange,
|
||||||
onToggle,
|
onToggle,
|
||||||
onCreate,
|
onCreate,
|
||||||
|
isSubmitting = false,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -34,6 +36,7 @@ const TaskDetailLabelsPopover: FC<Props> = ({
|
|||||||
<TaskDetailNewLabelForm
|
<TaskDetailNewLabelForm
|
||||||
onBack={() => onViewChange("list")}
|
onBack={() => onViewChange("list")}
|
||||||
onSubmit={onCreate}
|
onSubmit={onCreate}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ import { LABEL_COLOR_OPTIONS } from "./constants";
|
|||||||
type Props = {
|
type Props = {
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onSubmit: (title: string, color: string) => void;
|
onSubmit: (title: string, color: string) => void;
|
||||||
|
isSubmitting?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const TaskDetailNewLabelForm: FC<Props> = ({ onBack, onSubmit }) => {
|
const TaskDetailNewLabelForm: FC<Props> = ({ onBack, onSubmit, isSubmitting = false }) => {
|
||||||
const { t } = useTranslation("global");
|
const { t } = useTranslation("global");
|
||||||
const colorInputRef = useRef<HTMLInputElement>(null);
|
const colorInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
@@ -48,7 +49,8 @@ const TaskDetailNewLabelForm: FC<Props> = ({ onBack, onSubmit }) => {
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={!title.trim()}
|
disabled={!title.trim() || isSubmitting}
|
||||||
|
isLoading={isSubmitting}
|
||||||
className="flex items-center justify-center gap-2 bg-white border border-[#D0D0D0] text-[#292D32] rounded-[6px] h-7 text-xs disabled:opacity-50"
|
className="flex items-center justify-center gap-2 bg-white border border-[#D0D0D0] text-[#292D32] rounded-[6px] h-7 text-xs disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<AddCircle size={14} color="#292D32" />
|
<AddCircle size={14} color="#292D32" />
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import { type FC, useMemo, useState } from "react";
|
import { type FC, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import Input from "../../../../../components/Input";
|
import Input from "../../../../../components/Input";
|
||||||
|
import TaskDetailLabelCheckbox from "../labels/TaskDetailLabelCheckbox";
|
||||||
import type { TaskUser } from "./types";
|
import type { TaskUser } from "./types";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
users: TaskUser[];
|
users: TaskUser[];
|
||||||
selectedIds: Set<string>;
|
selectedIds: Set<string>;
|
||||||
onToggle: (id: string) => void;
|
onToggle: (id: string) => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
isSubmitting?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const TaskDetailUsersList: FC<Props> = ({ users }) => {
|
const TaskDetailUsersList: FC<Props> = ({ users, selectedIds, onToggle, isLoading = false, isSubmitting = false }) => {
|
||||||
const { t } = useTranslation("global");
|
const { t } = useTranslation("global");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
@@ -30,17 +33,31 @@ const TaskDetailUsersList: FC<Props> = ({ users }) => {
|
|||||||
<div>
|
<div>
|
||||||
<div className="text-xs font-medium mb-2">{t("taskmanager.task_detail.members")}</div>
|
<div className="text-xs font-medium mb-2">{t("taskmanager.task_detail.members")}</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
{isLoading ? (
|
||||||
|
<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) => {
|
{filteredUsers.map((user) => {
|
||||||
|
const isSelected = selectedIds.has(user.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={user.id} className="flex gap-1.5 items-center">
|
<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 className="text-[10px]">{user.name}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{!filteredUsers.length ? <div className="text-[10px] text-description">{t("taskmanager.no_users_added")}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isSubmitting ? <div className="text-[10px] text-description text-center">{t("loading")}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,10 +6,20 @@ type Props = {
|
|||||||
users: TaskUser[];
|
users: TaskUser[];
|
||||||
selectedIds: Set<string>;
|
selectedIds: Set<string>;
|
||||||
onToggle: (id: string) => void;
|
onToggle: (id: string) => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
isSubmitting?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const TaskDetailUsersPopover: FC<Props> = ({ users, selectedIds, onToggle }) => {
|
const TaskDetailUsersPopover: FC<Props> = ({ users, selectedIds, onToggle, isLoading, isSubmitting }) => {
|
||||||
return <TaskDetailUsersList users={users} selectedIds={selectedIds} onToggle={onToggle} />;
|
return (
|
||||||
|
<TaskDetailUsersList
|
||||||
|
users={users}
|
||||||
|
selectedIds={selectedIds}
|
||||||
|
onToggle={onToggle}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
/>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default TaskDetailUsersPopover;
|
export default TaskDetailUsersPopover;
|
||||||
|
|||||||
@@ -1,20 +1,110 @@
|
|||||||
|
import { FC, useState } from "react";
|
||||||
import { TickCircle } from "iconsax-react";
|
import { TickCircle } from "iconsax-react";
|
||||||
|
import { useFormik } from "formik";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import * as Yup from "yup";
|
||||||
|
import moment from "moment-jalaali";
|
||||||
import Button from "../../../components/Button";
|
import Button from "../../../components/Button";
|
||||||
import DatePickerComponent from "../../../components/DatePicker";
|
import DatePickerComponent from "../../../components/DatePicker";
|
||||||
import Input from "../../../components/Input";
|
import Input from "../../../components/Input";
|
||||||
import Textarea from "../../../components/Textarea";
|
import Textarea from "../../../components/Textarea";
|
||||||
import CreateProjectSidebar from "./components/CreateProjectSidebar";
|
import { Pages } from "../../../config/Pages";
|
||||||
|
import { ErrorType } from "../../../helpers/types";
|
||||||
|
import { useSingleUpload } from "../../service/hooks/useServiceData";
|
||||||
|
import CreateProjectSidebar, {
|
||||||
|
BACKGROUND_PRESETS,
|
||||||
|
SelectedUser,
|
||||||
|
} from "./components/CreateProjectSidebar";
|
||||||
|
import { useCreateProject } from "./hooks/useProjectData";
|
||||||
|
import { CreateProjectType } from "./types/ProjectTypes";
|
||||||
|
|
||||||
const CreateProject = () => {
|
const CreateProject: FC = () => {
|
||||||
const { t } = useTranslation("global");
|
const { t } = useTranslation("global");
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const createProject = useCreateProject();
|
||||||
|
const singleUpload = useSingleUpload();
|
||||||
|
|
||||||
|
const [isActive, setIsActive] = useState(true);
|
||||||
|
const [selectedColor, setSelectedColor] = useState("#A8E6CF");
|
||||||
|
const [selectedUsers, setSelectedUsers] = useState<SelectedUser[]>([]);
|
||||||
|
const [selectedBackground, setSelectedBackground] = useState<string>(BACKGROUND_PRESETS[0]);
|
||||||
|
const [backgroundImage, setBackgroundImage] = useState<File | null>(null);
|
||||||
|
const [workspaceId, setWorkspaceId] = useState(searchParams.get("workspaceId") ?? "");
|
||||||
|
|
||||||
|
const handleCreateProject = async (values: CreateProjectType) => {
|
||||||
|
createProject.mutate(values, {
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(t("success"));
|
||||||
|
navigate(`${Pages.taskmanager.projectList}${workspaceId}`);
|
||||||
|
},
|
||||||
|
onError: (error: ErrorType) => {
|
||||||
|
toast.error(error.response?.data?.error.message[0]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formik = useFormik<
|
||||||
|
Pick<CreateProjectType, "name" | "ownerName" | "description" | "startDate">
|
||||||
|
>({
|
||||||
|
initialValues: {
|
||||||
|
name: "",
|
||||||
|
ownerName: "",
|
||||||
|
description: "",
|
||||||
|
startDate: "",
|
||||||
|
},
|
||||||
|
validationSchema: Yup.object({
|
||||||
|
name: Yup.string().required(t("errors.required")),
|
||||||
|
ownerName: Yup.string().required(t("errors.required")),
|
||||||
|
description: Yup.string(),
|
||||||
|
startDate: Yup.string().required(t("errors.required")),
|
||||||
|
}),
|
||||||
|
onSubmit: async (values) => {
|
||||||
|
if (!workspaceId) {
|
||||||
|
toast.error(t("taskmanager.select_workspace"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let backgroundPicture = "";
|
||||||
|
|
||||||
|
if (backgroundImage) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", backgroundImage);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const uploadResult = await singleUpload.mutateAsync(formData);
|
||||||
|
backgroundPicture = uploadResult?.data?.url ?? "";
|
||||||
|
} catch (error) {
|
||||||
|
const uploadError = error as ErrorType;
|
||||||
|
toast.error(uploadError.response?.data?.error.message[0]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCreateProject({
|
||||||
|
...values,
|
||||||
|
startDate: moment(values.startDate, "jYYYY/jMM/jDD").format("YYYY-MM-DD"),
|
||||||
|
backgroundPicture,
|
||||||
|
backgroundColor: selectedColor,
|
||||||
|
isActive,
|
||||||
|
workspaceId,
|
||||||
|
userIds: selectedUsers.map((user) => user.id),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full mt-4">
|
<div className="w-full mt-4">
|
||||||
<div className="flex w-full justify-between items-center">
|
<div className="flex w-full justify-between items-center">
|
||||||
<div>{t("taskmanager.new_project")}</div>
|
<div>{t("taskmanager.new_project")}</div>
|
||||||
<div>
|
<div>
|
||||||
<Button className="px-5" isLoading={false}>
|
<Button
|
||||||
|
className="px-5"
|
||||||
|
isLoading={createProject.isPending || singleUpload.isPending}
|
||||||
|
onClick={() => formik.handleSubmit()}
|
||||||
|
>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<TickCircle className="size-5" color="#fff" />
|
<TickCircle className="size-5" color="#fff" />
|
||||||
<div>{t("taskmanager.submit_project")}</div>
|
<div>{t("taskmanager.submit_project")}</div>
|
||||||
@@ -29,21 +119,65 @@ const CreateProject = () => {
|
|||||||
<div className="text-sm font-bold">{t("taskmanager.project_info")}</div>
|
<div className="text-sm font-bold">{t("taskmanager.project_info")}</div>
|
||||||
|
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<Input label={t("taskmanager.project_name")} />
|
<Input
|
||||||
|
label={t("taskmanager.project_name")}
|
||||||
|
{...formik.getFieldProps("name")}
|
||||||
|
error_text={
|
||||||
|
formik.touched.name && formik.errors.name ? formik.errors.name : ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8 rowTwoInput">
|
<div className="mt-8 rowTwoInput">
|
||||||
<DatePickerComponent label={t("taskmanager.project_start_date")} placeholder={t("taskmanager.select_date")} onChange={() => undefined} />
|
<DatePickerComponent
|
||||||
<Input label={t("taskmanager.employer_name")} />
|
label={t("taskmanager.project_start_date")}
|
||||||
|
placeholder={t("taskmanager.select_date")}
|
||||||
|
onChange={(value) => formik.setFieldValue("startDate", value)}
|
||||||
|
error_text={
|
||||||
|
formik.touched.startDate && formik.errors.startDate
|
||||||
|
? formik.errors.startDate
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={t("taskmanager.employer_name")}
|
||||||
|
{...formik.getFieldProps("ownerName")}
|
||||||
|
error_text={
|
||||||
|
formik.touched.ownerName && formik.errors.ownerName
|
||||||
|
? formik.errors.ownerName
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<Textarea label={t("description")} />
|
<Textarea
|
||||||
|
label={t("description")}
|
||||||
|
{...formik.getFieldProps("description")}
|
||||||
|
error_text={
|
||||||
|
formik.touched.description && formik.errors.description
|
||||||
|
? formik.errors.description
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CreateProjectSidebar />
|
<CreateProjectSidebar
|
||||||
|
isActive={isActive}
|
||||||
|
onIsActiveChange={setIsActive}
|
||||||
|
selectedColor={selectedColor}
|
||||||
|
onColorChange={setSelectedColor}
|
||||||
|
selectedUsers={selectedUsers}
|
||||||
|
onSelectedUsersChange={setSelectedUsers}
|
||||||
|
selectedBackground={selectedBackground}
|
||||||
|
onBackgroundChange={setSelectedBackground}
|
||||||
|
backgroundImage={backgroundImage}
|
||||||
|
onBackgroundImageChange={setBackgroundImage}
|
||||||
|
workspaceId={workspaceId}
|
||||||
|
onWorkspaceIdChange={setWorkspaceId}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,29 +1,47 @@
|
|||||||
import { useState } from "react";
|
import { FC, useState } from "react";
|
||||||
import projectsData from "./data/projects.json";
|
import { useParams } from "react-router-dom";
|
||||||
|
import PageLoading from "../../../components/PageLoading";
|
||||||
|
import { useGetWorkspaceDetail } from "../workspace/hooks/useWorkspaceData";
|
||||||
|
import { WorkspaceDetailType } from "../workspace/types/WorkspaceTypes";
|
||||||
import ProjectGrid from "./components/ProjectGrid";
|
import ProjectGrid from "./components/ProjectGrid";
|
||||||
import ProjectListHeader from "./components/ProjectListHeader";
|
import ProjectListHeader from "./components/ProjectListHeader";
|
||||||
import WorkspaceInfoBanner from "./components/WorkspaceInfoBanner";
|
import WorkspaceInfoBanner from "./components/WorkspaceInfoBanner";
|
||||||
import type { ProjectItem, WorkspaceInfo } from "./types/ProjectTypes";
|
import { useGetProjectsByWorkspace } from "./hooks/useProjectData";
|
||||||
|
import type { ProjectItem } from "./types/ProjectTypes";
|
||||||
|
|
||||||
const List = () => {
|
const List: FC = () => {
|
||||||
const { workspace, projects: initialProjects } = projectsData as {
|
const { workspaceId = "" } = useParams();
|
||||||
workspace: WorkspaceInfo;
|
const getProjects = useGetProjectsByWorkspace(workspaceId);
|
||||||
projects: ProjectItem[];
|
const getWorkspaceDetail = useGetWorkspaceDetail(workspaceId);
|
||||||
};
|
|
||||||
|
|
||||||
const [projects, setProjects] = useState<ProjectItem[]>(initialProjects);
|
const workspace: WorkspaceDetailType | undefined =
|
||||||
|
getWorkspaceDetail.data?.data?.workspace ?? getWorkspaceDetail.data?.data;
|
||||||
|
|
||||||
|
const apiProjects: ProjectItem[] =
|
||||||
|
getProjects.data?.data?.projects ?? getProjects.data?.data ?? [];
|
||||||
|
|
||||||
|
const [deletedIds, setDeletedIds] = useState<string[]>([]);
|
||||||
|
const projects = apiProjects.filter((project) => !deletedIds.includes(project.id));
|
||||||
|
|
||||||
const handleDelete = (id: string) => {
|
const handleDelete = (id: string) => {
|
||||||
setProjects((prev) => prev.filter((project) => project.id !== id));
|
setDeletedIds((prev) => [...prev, id]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isLoading = getProjects.isPending || getWorkspaceDetail.isPending;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full mt-4">
|
<div className="w-full mt-4">
|
||||||
<ProjectListHeader title={workspace.name} />
|
<ProjectListHeader title={workspace?.name ?? ""} workspaceId={workspaceId} />
|
||||||
|
|
||||||
|
{workspace?.description ? (
|
||||||
<WorkspaceInfoBanner description={workspace.description} />
|
<WorkspaceInfoBanner description={workspace.description} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<PageLoading />
|
||||||
|
) : (
|
||||||
<ProjectGrid projects={projects} onDelete={handleDelete} />
|
<ProjectGrid projects={projects} onDelete={handleDelete} />
|
||||||
|
)}
|
||||||
</div>
|
</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 SwitchComponent from "../../../../components/Switch";
|
||||||
import { useGetUsers } from "../../../users/hooks/useUserData";
|
import { useGetUsers } from "../../../users/hooks/useUserData";
|
||||||
import { UserItemType } from "../../../users/types/UserTypes";
|
import { UserItemType } from "../../../users/types/UserTypes";
|
||||||
|
import { useGetWorkspaces } from "../../workspace/hooks/useWorkspaceData";
|
||||||
|
import { WorkspaceItemType } from "../../workspace/types/WorkspaceTypes";
|
||||||
import WorkspaceColorPicker from "../../workspace/components/WorkspaceColorPicker";
|
import WorkspaceColorPicker from "../../workspace/components/WorkspaceColorPicker";
|
||||||
|
|
||||||
type SelectedUser = {
|
export type SelectedUser = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const BACKGROUND_PRESETS = [
|
export const BACKGROUND_PRESETS = [
|
||||||
"linear-gradient(45deg, #6a11cb, #2575fc)",
|
"linear-gradient(45deg, #6a11cb, #2575fc)",
|
||||||
"linear-gradient(45deg, #ff00cc, #333399)",
|
"linear-gradient(45deg, #ff00cc, #333399)",
|
||||||
"linear-gradient(45deg, #ff0000, #ff8c00)",
|
"linear-gradient(45deg, #ff0000, #ff8c00)",
|
||||||
@@ -21,18 +23,53 @@ const BACKGROUND_PRESETS = [
|
|||||||
"linear-gradient(45deg, #2c3e50, #4ca1af)",
|
"linear-gradient(45deg, #2c3e50, #4ca1af)",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const CreateProjectSidebar: FC = () => {
|
type Props = {
|
||||||
|
isActive: boolean;
|
||||||
|
onIsActiveChange: (value: boolean) => void;
|
||||||
|
selectedColor: string;
|
||||||
|
onColorChange: (color: string) => void;
|
||||||
|
selectedUsers: SelectedUser[];
|
||||||
|
onSelectedUsersChange: (users: SelectedUser[]) => void;
|
||||||
|
selectedBackground: string;
|
||||||
|
onBackgroundChange: (background: string) => void;
|
||||||
|
backgroundImage: File | null;
|
||||||
|
onBackgroundImageChange: (file: File | null) => void;
|
||||||
|
workspaceId: string;
|
||||||
|
onWorkspaceIdChange: (id: string) => void;
|
||||||
|
workspaceError?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CreateProjectSidebar: FC<Props> = ({
|
||||||
|
isActive,
|
||||||
|
onIsActiveChange,
|
||||||
|
selectedColor,
|
||||||
|
onColorChange,
|
||||||
|
selectedUsers,
|
||||||
|
onSelectedUsersChange,
|
||||||
|
selectedBackground,
|
||||||
|
onBackgroundChange,
|
||||||
|
backgroundImage,
|
||||||
|
onBackgroundImageChange,
|
||||||
|
workspaceId,
|
||||||
|
onWorkspaceIdChange,
|
||||||
|
workspaceError,
|
||||||
|
}) => {
|
||||||
const { t } = useTranslation("global");
|
const { t } = useTranslation("global");
|
||||||
const getUsers = useGetUsers("");
|
const getUsers = useGetUsers("");
|
||||||
|
const getWorkspaces = useGetWorkspaces();
|
||||||
const [isActive, setIsActive] = useState(true);
|
|
||||||
const [selectedColor, setSelectedColor] = useState("#A8E6CF");
|
|
||||||
const [selectedUsers, setSelectedUsers] = useState<SelectedUser[]>([]);
|
|
||||||
const [userSelectValue, setUserSelectValue] = useState("");
|
const [userSelectValue, setUserSelectValue] = useState("");
|
||||||
const [selectedBackground, setSelectedBackground] = useState<string>(BACKGROUND_PRESETS[0]);
|
|
||||||
const [backgroundImage, setBackgroundImage] = useState<File | null>(null);
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const workspaces: WorkspaceItemType[] =
|
||||||
|
getWorkspaces.data?.data?.workspaces ??
|
||||||
|
getWorkspaces.data?.data ??
|
||||||
|
[];
|
||||||
|
|
||||||
|
const workspaceItems = workspaces.map((workspace) => ({
|
||||||
|
label: workspace.name,
|
||||||
|
value: workspace.id,
|
||||||
|
}));
|
||||||
|
|
||||||
const userItems =
|
const userItems =
|
||||||
getUsers.data?.data?.users
|
getUsers.data?.data?.users
|
||||||
?.filter((user: UserItemType) => !selectedUsers.some((selected) => selected.id === user.id))
|
?.filter((user: UserItemType) => !selectedUsers.some((selected) => selected.id === user.id))
|
||||||
@@ -48,19 +85,22 @@ const CreateProjectSidebar: FC = () => {
|
|||||||
const user = getUsers.data?.data?.users?.find((item: UserItemType) => item.id === value);
|
const user = getUsers.data?.data?.users?.find((item: UserItemType) => item.id === value);
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
setSelectedUsers((prev) => [...prev, { id: user.id, name: `${user.firstName} ${user.lastName}` }]);
|
onSelectedUsersChange([
|
||||||
|
...selectedUsers,
|
||||||
|
{ id: user.id, name: `${user.firstName} ${user.lastName}` },
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
setUserSelectValue("");
|
setUserSelectValue("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveUser = (id: string) => {
|
const handleRemoveUser = (id: string) => {
|
||||||
setSelectedUsers((prev) => prev.filter((user) => user.id !== id));
|
onSelectedUsersChange(selectedUsers.filter((user) => user.id !== id));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBackgroundImageChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleBackgroundImageChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = event.target.files?.[0] ?? null;
|
const file = event.target.files?.[0] ?? null;
|
||||||
setBackgroundImage(file);
|
onBackgroundImageChange(file);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -69,10 +109,22 @@ const CreateProjectSidebar: FC = () => {
|
|||||||
<div>{t("taskmanager.project_status")}</div>
|
<div>{t("taskmanager.project_status")}</div>
|
||||||
<div className="flex gap-2 text-xs items-center text-description">
|
<div className="flex gap-2 text-xs items-center text-description">
|
||||||
<div>{isActive ? t("slider.active") : t("slider.inactive")}</div>
|
<div>{isActive ? t("slider.active") : t("slider.inactive")}</div>
|
||||||
<SwitchComponent active={isActive} onChange={setIsActive} />
|
<SwitchComponent active={isActive} onChange={onIsActiveChange} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
<Select
|
||||||
|
label={t("taskmanager.workspacename")}
|
||||||
|
placeholder={t("taskmanager.select_workspace")}
|
||||||
|
items={workspaceItems}
|
||||||
|
value={workspaceId}
|
||||||
|
onChange={(e) => onWorkspaceIdChange(e.target.value)}
|
||||||
|
className="border"
|
||||||
|
error_text={workspaceError ?? ""}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<Select label={t("taskmanager.users")} placeholder={t("taskmanager.select_user")} items={userItems} value={userSelectValue} onChange={handleUserSelect} className="border" />
|
<Select label={t("taskmanager.users")} placeholder={t("taskmanager.select_user")} items={userItems} value={userSelectValue} onChange={handleUserSelect} className="border" />
|
||||||
|
|
||||||
@@ -102,7 +154,7 @@ const CreateProjectSidebar: FC = () => {
|
|||||||
background,
|
background,
|
||||||
borderColor: selectedBackground === background ? "#000" : "#E2E8F0",
|
borderColor: selectedBackground === background ? "#000" : "#E2E8F0",
|
||||||
}}
|
}}
|
||||||
onClick={() => setSelectedBackground(background)}
|
onClick={() => onBackgroundChange(background)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -110,7 +162,7 @@ const CreateProjectSidebar: FC = () => {
|
|||||||
|
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<div className="text-sm mb-3">{t("taskmanager.choose_color")}</div>
|
<div className="text-sm mb-3">{t("taskmanager.choose_color")}</div>
|
||||||
<WorkspaceColorPicker selectedColor={selectedColor} onChange={setSelectedColor} />
|
<WorkspaceColorPicker selectedColor={selectedColor} onChange={onColorChange} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { FC } from "react";
|
import { CSSProperties, FC } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { Pages } from "../../../../config/Pages";
|
import { Pages } from "../../../../config/Pages";
|
||||||
import type { ProjectItem } from "../types/ProjectTypes";
|
import type { ProjectItem } from "../types/ProjectTypes";
|
||||||
import ProjectCardMenu from "./ProjectCardMenu";
|
import ProjectCardMenu from "./ProjectCardMenu";
|
||||||
@@ -17,21 +17,33 @@ const hexToRgba = (hex: string, alpha: number) => {
|
|||||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getCardBackground = (background: string) => {
|
const getCardBackgroundStyle = (project: ProjectItem): CSSProperties => {
|
||||||
const color = /^#[0-9A-Fa-f]{6}$/i.test(background) ? background : (background.match(/#[0-9A-Fa-f]{6}/i)?.[0] ?? "#A8E6CF");
|
if (project.backgroundPicture) {
|
||||||
|
return {
|
||||||
|
backgroundImage: `url(${project.backgroundPicture})`,
|
||||||
|
backgroundSize: "cover",
|
||||||
|
backgroundPosition: "center",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return `linear-gradient(135deg, ${color}, ${hexToRgba(color, 0.5)})`;
|
const color = project.backgroundColor ?? "#A8E6CF";
|
||||||
|
|
||||||
|
return {
|
||||||
|
background: `linear-gradient(135deg, ${color}, ${hexToRgba(color, 0.5)})`,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const ProjectCard: FC<Props> = ({ project, onDelete }) => {
|
const ProjectCard: FC<Props> = ({ project, onDelete }) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const handleEdit = () => {
|
const handleEdit = () => {
|
||||||
// TODO: navigate to edit page when available
|
navigate(`${Pages.taskmanager.updateProject}${project.id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-[20px] overflow-hidden shadow-sm border border-border/40 p-3">
|
<div className="bg-white rounded-[20px] overflow-hidden shadow-sm border border-border/40 p-3">
|
||||||
<Link to={Pages.taskmanager.workspace + project.id}>
|
<Link to={Pages.taskmanager.workspace + project.id}>
|
||||||
<div className="h-[84px] w-full rounded-[16px]" style={{ background: getCardBackground(project.background) }} />
|
<div className="h-[84px] w-full rounded-[16px]" style={getCardBackgroundStyle(project)} />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="flex items-center justify-between mt-3.5">
|
<div className="flex items-center justify-between mt-3.5">
|
||||||
|
|||||||
@@ -7,11 +7,15 @@ import { Pages } from "../../../../config/Pages";
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
title: string;
|
title: string;
|
||||||
|
workspaceId?: string;
|
||||||
onSettingsClick?: () => void;
|
onSettingsClick?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ProjectListHeader: FC<Props> = ({ title, onSettingsClick }) => {
|
const ProjectListHeader: FC<Props> = ({ title, workspaceId, onSettingsClick }) => {
|
||||||
const { t } = useTranslation("global");
|
const { t } = useTranslation("global");
|
||||||
|
const createProjectLink = workspaceId
|
||||||
|
? `${Pages.taskmanager.createProject}?workspaceId=${workspaceId}`
|
||||||
|
: Pages.taskmanager.createProject;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full justify-between items-center">
|
<div className="flex w-full justify-between items-center">
|
||||||
@@ -23,7 +27,7 @@ const ProjectListHeader: FC<Props> = ({ title, onSettingsClick }) => {
|
|||||||
<span>{t("sidebar.setting")}</span>
|
<span>{t("sidebar.setting")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Link to={Pages.taskmanager.createProject}>
|
<Link to={createProjectLink}>
|
||||||
<Button onClick={onSettingsClick} className="flex items-center gap-2 whitespace-nowrap px-4">
|
<Button onClick={onSettingsClick} className="flex items-center gap-2 whitespace-nowrap px-4">
|
||||||
<Add size={18} color="white" />
|
<Add size={18} color="white" />
|
||||||
<span>{t("taskmanager.new_project")}</span>
|
<span>{t("taskmanager.new_project")}</span>
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import * as api from "../service/ProjectService";
|
||||||
|
import { CreateProjectType, UpdateProjectType } from "../types/ProjectTypes";
|
||||||
|
|
||||||
|
export const useGetProjectsByWorkspace = (workspaceId: string) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["projects", workspaceId],
|
||||||
|
queryFn: () => api.getProjectsByWorkspace(workspaceId),
|
||||||
|
enabled: !!workspaceId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useGetProjectDetail = (id: string) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["project-detail", id],
|
||||||
|
queryFn: () => api.getProjectDetail(id),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useGetProject = (id: string) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["project", id],
|
||||||
|
queryFn: () => api.getProject(id),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCreateProject = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (variables: CreateProjectType) => api.createProject(variables),
|
||||||
|
onSuccess: (_data, variables) => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["projects", variables.workspaceId],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useUpdateProject = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, params }: { id: string; params: UpdateProjectType }) => api.updateProject(id, params),
|
||||||
|
onSuccess: (_data, variables) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["project-detail", variables.id],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import axios from "../../../../config/axios";
|
||||||
|
import { CreateProjectType, GetProjectResponse, UpdateProjectType } from "../types/ProjectTypes";
|
||||||
|
|
||||||
|
export const getProjectsByWorkspace = async (workspaceId: string) => {
|
||||||
|
const { data } = await axios.get(`/task-manager/projects/user/workspace/${workspaceId}`);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getProjectDetail = async (id: string) => {
|
||||||
|
const { data } = await axios.get(`/task-manager/projects/${id}`);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getProject = async (id: string): Promise<GetProjectResponse> => {
|
||||||
|
const { data } = await axios.get(`/task-manager/projects/detail/${id}`);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createProject = async (params: CreateProjectType) => {
|
||||||
|
const { data } = await axios.post(`/task-manager/projects`, params);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateProject = async (id: string, params: UpdateProjectType) => {
|
||||||
|
const { data } = await axios.patch(`/task-manager/projects/${id}`, params);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
@@ -1,10 +1,54 @@
|
|||||||
|
import { IResponse } from "../../../../types/response.types";
|
||||||
|
import { TaskItemType } from "../../task/types/TaskTypes";
|
||||||
|
|
||||||
export type ProjectItem = {
|
export type ProjectItem = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
background: string;
|
backgroundColor?: string;
|
||||||
|
backgroundPicture?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WorkspaceInfo = {
|
export type ProjectUserType = {
|
||||||
name: string;
|
id: string;
|
||||||
description: string;
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CreateProjectType = {
|
||||||
|
name: string;
|
||||||
|
ownerName: string;
|
||||||
|
description: string;
|
||||||
|
startDate: string;
|
||||||
|
backgroundPicture: string;
|
||||||
|
backgroundColor: string;
|
||||||
|
isActive: boolean;
|
||||||
|
workspaceId: string;
|
||||||
|
userIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectDetailType = CreateProjectType & {
|
||||||
|
id: string;
|
||||||
|
users?: ProjectUserType[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateProjectType = CreateProjectType;
|
||||||
|
|
||||||
|
export type ProjectTaskType = TaskItemType;
|
||||||
|
|
||||||
|
export type ProjectTaskPhaseType = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
order: number;
|
||||||
|
color: string;
|
||||||
|
tasks: ProjectTaskType[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectWithPhasesType = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
taskPhases: ProjectTaskPhaseType[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface GetProjectResponse extends IResponse<ProjectWithPhasesType> {
|
||||||
|
statusCode: number;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import * as api from "../service/TaskPhaseService";
|
||||||
|
import { CreateTaskPhaseType, UpdateTaskPhaseType } from "../types/TaskPhaseTypes";
|
||||||
|
|
||||||
|
export const useCreateTaskPhase = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (variables: CreateTaskPhaseType) => api.createTaskPhase(variables),
|
||||||
|
onSuccess: (_data, variables) => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["task-phases", variables.projectId],
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["project", variables.projectId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useUpdateTaskPhase = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, params }: { id: string; params: UpdateTaskPhaseType; projectId: string }) =>
|
||||||
|
api.updateTaskPhase(id, params),
|
||||||
|
onSuccess: (_data, variables) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["project", variables.projectId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDeleteTaskPhase = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id }: { id: string; projectId: string }) => api.deleteTaskPhase(id),
|
||||||
|
onSuccess: (_data, variables) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["project", variables.projectId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import axios from "../../../../config/axios";
|
||||||
|
import { CreateTaskPhaseType, UpdateTaskPhaseType } from "../types/TaskPhaseTypes";
|
||||||
|
|
||||||
|
export const createTaskPhase = async (params: CreateTaskPhaseType) => {
|
||||||
|
const { data } = await axios.post(`/task-manager/task-phases`, params);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateTaskPhase = async (id: string, params: UpdateTaskPhaseType) => {
|
||||||
|
const { data } = await axios.patch(`/task-manager/task-phases/${id}`, params);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteTaskPhase = async (id: string) => {
|
||||||
|
const { data } = await axios.delete(`/task-manager/task-phases/${id}`);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export type CreateTaskPhaseType = {
|
||||||
|
name: string;
|
||||||
|
order: number;
|
||||||
|
color: string;
|
||||||
|
projectId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateTaskPhaseType = {
|
||||||
|
order: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TaskPhaseItemType = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
order: number;
|
||||||
|
color: string;
|
||||||
|
projectId: string;
|
||||||
|
};
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import * as api from "../service/TaskService";
|
||||||
|
import {
|
||||||
|
ChangeTaskPhaseType,
|
||||||
|
CreateCheckListItemType,
|
||||||
|
CreateTaskAttachmentType,
|
||||||
|
CreateTaskRemarkType,
|
||||||
|
CreateTaskType,
|
||||||
|
UpdateCheckListItemType,
|
||||||
|
UpdateTaskType,
|
||||||
|
} from "../types/TaskTypes";
|
||||||
|
|
||||||
|
export const useGetTaskDetail = (id: string, enabled = true) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["task-detail", id],
|
||||||
|
queryFn: () => api.getTaskDetail(id),
|
||||||
|
enabled: !!id && enabled,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCreateTask = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (variables: CreateTaskType) => api.createTask(variables),
|
||||||
|
onSuccess: (_data, variables) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["project", variables.projectId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useUpdateTask = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, params }: { id: string; params: UpdateTaskType }) => api.updateTask(id, params),
|
||||||
|
onSuccess: (_data, variables) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["task-detail", variables.id] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["project"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useChangeTaskPhase = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, params }: { id: string; params: ChangeTaskPhaseType; projectId: string }) =>
|
||||||
|
api.changeTaskPhase(id, params),
|
||||||
|
onSuccess: (_data, variables) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["task-detail", variables.id] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["project", variables.projectId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["project"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDeleteTask = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id }: { id: string; projectId: string }) => api.deleteTask(id),
|
||||||
|
onSuccess: (_data, variables) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["task-detail", variables.id] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["project", variables.projectId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["project"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCreateTaskRemark = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (variables: CreateTaskRemarkType) => api.createTaskRemark(variables),
|
||||||
|
onSuccess: (_data, variables) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["task-detail", variables.taskId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["project"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCreateCheckListItem = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (variables: CreateCheckListItemType) => api.createCheckListItem(variables),
|
||||||
|
onSuccess: (_data, variables) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["task-detail", variables.taskId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["project"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useUpdateCheckListItem = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, params }: { id: string; params: UpdateCheckListItemType; taskId: string }) =>
|
||||||
|
api.updateCheckListItem(id, params),
|
||||||
|
onSuccess: (_data, variables) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["task-detail", variables.taskId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["project"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCreateTaskAttachment = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (variables: CreateTaskAttachmentType) => api.createTaskAttachment(variables),
|
||||||
|
onSuccess: (_data, variables) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["task-detail", variables.taskId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["project"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import axios from "../../../../config/axios";
|
||||||
|
import {
|
||||||
|
ChangeTaskPhaseType,
|
||||||
|
CreateCheckListItemResponse,
|
||||||
|
CreateCheckListItemType,
|
||||||
|
CreateTaskAttachmentType,
|
||||||
|
CreateTaskRemarkResponse,
|
||||||
|
CreateTaskRemarkType,
|
||||||
|
CreateTaskType,
|
||||||
|
GetTaskDetailResponse,
|
||||||
|
UpdateCheckListItemResponse,
|
||||||
|
UpdateCheckListItemType,
|
||||||
|
UpdateTaskType,
|
||||||
|
} from "../types/TaskTypes";
|
||||||
|
|
||||||
|
export const createTask = async (params: CreateTaskType) => {
|
||||||
|
const { data } = await axios.post(`/task-manager/tasks`, params);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTaskDetail = async (id: string): Promise<GetTaskDetailResponse> => {
|
||||||
|
const { data } = await axios.get(`/task-manager/tasks/detail/${id}`);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateTask = async (id: string, params: UpdateTaskType) => {
|
||||||
|
const { data } = await axios.patch(`/task-manager/tasks/${id}`, params);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const changeTaskPhase = async (id: string, params: ChangeTaskPhaseType) => {
|
||||||
|
const { data } = await axios.patch(`/task-manager/tasks/${id}/change-phase`, params);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteTask = async (id: string) => {
|
||||||
|
const { data } = await axios.delete(`/task-manager/tasks/${id}`);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createTaskRemark = async (params: CreateTaskRemarkType): Promise<CreateTaskRemarkResponse> => {
|
||||||
|
const { data } = await axios.post(`/task-manager/remarks`, params);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createCheckListItem = async (params: CreateCheckListItemType): Promise<CreateCheckListItemResponse> => {
|
||||||
|
const { data } = await axios.post(`/task-manager/check-list-items`, params);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateCheckListItem = async (
|
||||||
|
id: string,
|
||||||
|
params: UpdateCheckListItemType,
|
||||||
|
): Promise<UpdateCheckListItemResponse> => {
|
||||||
|
const { data } = await axios.patch(`/task-manager/check-list-items/${id}`, params);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createTaskAttachment = async (params: CreateTaskAttachmentType) => {
|
||||||
|
const { data } = await axios.post(`/task-manager/attachments`, params);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { IResponse } from "../../../../types/response.types";
|
||||||
|
|
||||||
|
export type CreateTaskType = {
|
||||||
|
title: string;
|
||||||
|
taskPhaseId: string;
|
||||||
|
order: number;
|
||||||
|
projectId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TaskItemType = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
taskPhaseId: string;
|
||||||
|
order: number;
|
||||||
|
tag?: string;
|
||||||
|
dateRange?: string;
|
||||||
|
attachments?: number;
|
||||||
|
checklistDone?: number;
|
||||||
|
checklistTotal?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TaskLabelType = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
color: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateTaskRemarkType = {
|
||||||
|
title: string;
|
||||||
|
color: string;
|
||||||
|
taskId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface CreateTaskRemarkResponse extends IResponse<{ remark: TaskLabelType }> {
|
||||||
|
statusCode: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TaskUserType = {
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TaskChecklistItemType = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
isDone: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateCheckListItemType = {
|
||||||
|
title: string;
|
||||||
|
isDone: boolean;
|
||||||
|
taskId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface CreateCheckListItemResponse extends IResponse<{ checkListItem: TaskChecklistItemType }> {
|
||||||
|
statusCode: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateCheckListItemType = {
|
||||||
|
isDone: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface UpdateCheckListItemResponse extends IResponse<{ checkListItem: TaskChecklistItemType }> {
|
||||||
|
statusCode: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TaskChecklistType = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
items: TaskChecklistItemType[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TaskAttachmentType = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
type: "file" | "link";
|
||||||
|
file?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateTaskAttachmentType = {
|
||||||
|
file: string;
|
||||||
|
title: string;
|
||||||
|
type: "file" | "link";
|
||||||
|
taskId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TaskDetailType = {
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
title: string;
|
||||||
|
description?: string | null;
|
||||||
|
color?: string | null;
|
||||||
|
taskPhaseId: string;
|
||||||
|
order?: number;
|
||||||
|
projectId?: string;
|
||||||
|
startDate?: string | null;
|
||||||
|
endDate?: string | null;
|
||||||
|
labels?: TaskLabelType[];
|
||||||
|
remarks?: TaskLabelType[];
|
||||||
|
users?: TaskUserType[];
|
||||||
|
checkListItems?: TaskChecklistItemType[];
|
||||||
|
checkListItemCount?: number;
|
||||||
|
completedCheckListItemCount?: number;
|
||||||
|
attachments?: TaskAttachmentType[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChangeTaskPhaseType = {
|
||||||
|
taskPhaseId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateTaskType = Partial<
|
||||||
|
Pick<TaskDetailType, "title" | "description" | "taskPhaseId" | "order" | "startDate" | "endDate">
|
||||||
|
> & {
|
||||||
|
color?: string;
|
||||||
|
userIds?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface GetTaskDetailResponse extends IResponse<TaskDetailType> {
|
||||||
|
statusCode: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { Task } from "../../types";
|
||||||
|
import type { TaskItemType } from "../types/TaskTypes";
|
||||||
|
|
||||||
|
export const mapTaskItemToTask = (task: TaskItemType, taskPhaseId: string, fallbackOrder: number): Task => ({
|
||||||
|
id: task.id,
|
||||||
|
columnId: task.taskPhaseId ?? taskPhaseId,
|
||||||
|
order: task.order ?? fallbackOrder,
|
||||||
|
title: task.title,
|
||||||
|
tag: task.tag ?? "",
|
||||||
|
dateRange: task.dateRange ?? "",
|
||||||
|
attachments: task.attachments ?? 0,
|
||||||
|
checklistDone: task.checklistDone ?? 0,
|
||||||
|
checklistTotal: task.checklistTotal ?? 0,
|
||||||
|
});
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
export type Column = {
|
export type Column = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
color?: string;
|
||||||
|
order?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Task = {
|
export type Task = {
|
||||||
|
|||||||
@@ -15,5 +15,5 @@ export const reorderColumns = (
|
|||||||
const result = [...columns];
|
const result = [...columns];
|
||||||
const [removed] = result.splice(oldIndex, 1);
|
const [removed] = result.splice(oldIndex, 1);
|
||||||
result.splice(newIndex, 0, removed);
|
result.splice(newIndex, 0, removed);
|
||||||
return result;
|
return result.map((column, index) => ({ ...column, order: index + 1 }));
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,27 +1,63 @@
|
|||||||
import { useMemo, useState, type FC } from "react";
|
import { useEffect, useMemo, useState, type FC } from "react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import PageLoading from "../../components/PageLoading";
|
||||||
|
import { ErrorType } from "../../helpers/types";
|
||||||
import AddNewColumn from "./components/AddNewColumn";
|
import AddNewColumn from "./components/AddNewColumn";
|
||||||
import Column from "./components/Column";
|
import Column from "./components/Column";
|
||||||
import HeaderWorkspace from "./components/HeaderWorkspace";
|
import HeaderWorkspace from "./components/HeaderWorkspace";
|
||||||
import TaskDetailModal from "./components/task-detail/TaskDetailModal";
|
import TaskDetailModal from "./components/task-detail/TaskDetailModal";
|
||||||
import tasksData from "./data/tasks.json";
|
import { useGetProject } from "./project/hooks/useProjectData";
|
||||||
import type { Task, WorkspaceData } from "./types";
|
import { useUpdateTaskPhase } from "./task-phase/hooks/useTaskPhaseData";
|
||||||
|
import { useChangeTaskPhase } from "./task/hooks/useTaskData";
|
||||||
|
import { mapTaskItemToTask } from "./task/utils/mapTaskItemToTask";
|
||||||
|
import type { TaskItemType } from "./task/types/TaskTypes";
|
||||||
|
import type { TaskPhaseItemType } from "./task-phase/types/TaskPhaseTypes";
|
||||||
|
import type { Column as ColumnType, Task } from "./types";
|
||||||
import { reorderColumns } from "./utils/reorderColumns";
|
import { reorderColumns } from "./utils/reorderColumns";
|
||||||
import { reorderTasks } from "./utils/reorderTasks";
|
import { reorderTasks } from "./utils/reorderTasks";
|
||||||
|
|
||||||
const Workspace: FC = () => {
|
const Workspace: FC = () => {
|
||||||
const [columns, setColumns] = useState(
|
const { slug: projectId = "" } = useParams<{ slug: string }>();
|
||||||
(tasksData as WorkspaceData).columns,
|
const { data: project, isPending } = useGetProject(projectId);
|
||||||
);
|
const changeTaskPhase = useChangeTaskPhase();
|
||||||
const [tasks, setTasks] = useState<Task[]>(tasksData.tasks as Task[]);
|
const updateTaskPhase = useUpdateTaskPhase();
|
||||||
|
const [columns, setColumns] = useState<ColumnType[]>([]);
|
||||||
|
const [tasks, setTasks] = useState<Task[]>([]);
|
||||||
const [draggedTaskId, setDraggedTaskId] = useState<string | null>(null);
|
const [draggedTaskId, setDraggedTaskId] = useState<string | null>(null);
|
||||||
const [draggedColumnId, setDraggedColumnId] = useState<string | null>(null);
|
const [draggedColumnId, setDraggedColumnId] = useState<string | null>(null);
|
||||||
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!project?.data?.taskPhases) return;
|
||||||
|
|
||||||
|
setColumns(
|
||||||
|
[...project.data.taskPhases]
|
||||||
|
.sort((a, b) => a.order - b.order)
|
||||||
|
.map((phase) => ({
|
||||||
|
id: phase.id,
|
||||||
|
title: phase.name,
|
||||||
|
color: phase.color,
|
||||||
|
order: phase.order,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}, [project?.data?.taskPhases]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!project?.data?.taskPhases) return;
|
||||||
|
|
||||||
|
const apiTasks = project.data.taskPhases.flatMap((phase) =>
|
||||||
|
(phase.tasks ?? []).map((task, index) =>
|
||||||
|
mapTaskItemToTask(task as TaskItemType, phase.id, index),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
setTasks(apiTasks);
|
||||||
|
}, [project?.data?.taskPhases]);
|
||||||
|
|
||||||
const tasksByColumn = useMemo(() => {
|
const tasksByColumn = useMemo(() => {
|
||||||
return columns.reduce<Record<string, Task[]>>((acc, column) => {
|
return columns.reduce<Record<string, Task[]>>((acc, column) => {
|
||||||
acc[column.id] = tasks
|
acc[column.id] = tasks.filter((task) => task.columnId === column.id).sort((a, b) => a.order - b.order);
|
||||||
.filter((task) => task.columnId === column.id)
|
|
||||||
.sort((a, b) => a.order - b.order);
|
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
}, [columns, tasks]);
|
}, [columns, tasks]);
|
||||||
@@ -36,8 +72,48 @@ const Workspace: FC = () => {
|
|||||||
|
|
||||||
const handleDrop = (columnId: string, index: number) => {
|
const handleDrop = (columnId: string, index: number) => {
|
||||||
if (!draggedTaskId) return;
|
if (!draggedTaskId) return;
|
||||||
setTasks((prev) => reorderTasks(prev, draggedTaskId, columnId, index));
|
|
||||||
|
const draggedTask = tasks.find((task) => task.id === draggedTaskId);
|
||||||
|
if (!draggedTask) return;
|
||||||
|
|
||||||
|
const previousTasks = tasks;
|
||||||
|
const columnChanged = draggedTask.columnId !== columnId;
|
||||||
|
const nextTasks = reorderTasks(tasks, draggedTaskId, columnId, index);
|
||||||
|
const movedTask = nextTasks.find((task) => task.id === draggedTaskId) ?? null;
|
||||||
|
|
||||||
|
setTasks(nextTasks);
|
||||||
setDraggedTaskId(null);
|
setDraggedTaskId(null);
|
||||||
|
|
||||||
|
if (selectedTask?.id === draggedTaskId && movedTask) {
|
||||||
|
setSelectedTask(movedTask);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!columnChanged) return;
|
||||||
|
|
||||||
|
changeTaskPhase.mutate(
|
||||||
|
{ id: draggedTaskId, params: { taskPhaseId: columnId }, projectId },
|
||||||
|
{
|
||||||
|
onError: (error: ErrorType) => {
|
||||||
|
setTasks(previousTasks);
|
||||||
|
if (selectedTask?.id === draggedTaskId) {
|
||||||
|
setSelectedTask(draggedTask);
|
||||||
|
}
|
||||||
|
toast.error(error.response?.data?.error.message[0]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTaskPhaseChanged = (taskId: string, taskPhaseId: string) => {
|
||||||
|
setTasks((prev) => {
|
||||||
|
const task = prev.find((item) => item.id === taskId);
|
||||||
|
if (!task || task.columnId === taskPhaseId) return prev;
|
||||||
|
|
||||||
|
const destinationIndex = prev.filter((item) => item.columnId === taskPhaseId).length;
|
||||||
|
return reorderTasks(prev, taskId, taskPhaseId, destinationIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
setSelectedTask((prev) => (prev?.id === taskId ? { ...prev, columnId: taskPhaseId } : prev));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleColumnDragStart = (columnId: string) => {
|
const handleColumnDragStart = (columnId: string) => {
|
||||||
@@ -50,13 +126,57 @@ const Workspace: FC = () => {
|
|||||||
|
|
||||||
const handleColumnDrop = (overColumnId: string) => {
|
const handleColumnDrop = (overColumnId: string) => {
|
||||||
if (!draggedColumnId || draggedColumnId === overColumnId) return;
|
if (!draggedColumnId || draggedColumnId === overColumnId) return;
|
||||||
setColumns((prev) => reorderColumns(prev, draggedColumnId, overColumnId));
|
|
||||||
|
const previousColumns = columns;
|
||||||
|
const nextColumns = reorderColumns(columns, draggedColumnId, overColumnId);
|
||||||
|
const movedColumn = nextColumns.find((column) => column.id === draggedColumnId);
|
||||||
|
|
||||||
|
setColumns(nextColumns);
|
||||||
setDraggedColumnId(null);
|
setDraggedColumnId(null);
|
||||||
|
|
||||||
|
if (!movedColumn?.order) return;
|
||||||
|
|
||||||
|
updateTaskPhase.mutate(
|
||||||
|
{ id: draggedColumnId, params: { order: movedColumn.order }, projectId },
|
||||||
|
{
|
||||||
|
onError: (error: ErrorType) => {
|
||||||
|
setColumns(previousColumns);
|
||||||
|
toast.error(error.response?.data?.error.message[0]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectedTaskColumn = selectedTask
|
const nextColumnOrder = columns.length
|
||||||
? columns.find((column) => column.id === selectedTask.columnId)
|
? Math.max(...columns.map((column) => column.order ?? 0)) + 1
|
||||||
: null;
|
: 1;
|
||||||
|
|
||||||
|
const handleColumnCreated = (phase: TaskPhaseItemType) => {
|
||||||
|
setColumns((prev) =>
|
||||||
|
[...prev, { id: phase.id, title: phase.name, color: phase.color, order: phase.order }].sort(
|
||||||
|
(a, b) => (a.order ?? 0) - (b.order ?? 0),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleColumnDeleted = (columnId: string) => {
|
||||||
|
setColumns((prev) => prev.filter((column) => column.id !== columnId));
|
||||||
|
setTasks((prev) => prev.filter((task) => task.columnId !== columnId));
|
||||||
|
if (selectedTask?.columnId === columnId) {
|
||||||
|
setSelectedTask(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTaskCreated = (task: TaskItemType, taskPhaseId: string) => {
|
||||||
|
setTasks((prev) => [
|
||||||
|
...prev,
|
||||||
|
mapTaskItemToTask(task, taskPhaseId, prev.filter((item) => item.columnId === taskPhaseId).length),
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isPending) {
|
||||||
|
return <PageLoading />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-[#01347A] h-full rounded-none xl:rounded-[32px] overflow-hidden flex flex-col">
|
<div className="bg-[#01347A] h-full rounded-none xl:rounded-[32px] overflow-hidden flex flex-col">
|
||||||
@@ -67,6 +187,7 @@ const Workspace: FC = () => {
|
|||||||
<Column
|
<Column
|
||||||
key={column.id}
|
key={column.id}
|
||||||
column={column}
|
column={column}
|
||||||
|
projectId={projectId}
|
||||||
tasks={tasksByColumn[column.id] ?? []}
|
tasks={tasksByColumn[column.id] ?? []}
|
||||||
draggedTaskId={draggedTaskId}
|
draggedTaskId={draggedTaskId}
|
||||||
draggedColumnId={draggedColumnId}
|
draggedColumnId={draggedColumnId}
|
||||||
@@ -77,18 +198,26 @@ const Workspace: FC = () => {
|
|||||||
onColumnDragEnd={handleColumnDragEnd}
|
onColumnDragEnd={handleColumnDragEnd}
|
||||||
onColumnDrop={handleColumnDrop}
|
onColumnDrop={handleColumnDrop}
|
||||||
onTaskClick={setSelectedTask}
|
onTaskClick={setSelectedTask}
|
||||||
|
onColumnDeleted={handleColumnDeleted}
|
||||||
|
onTaskCreated={handleTaskCreated}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<AddNewColumn />
|
<AddNewColumn
|
||||||
|
projectId={projectId}
|
||||||
|
nextOrder={nextColumnOrder}
|
||||||
|
onColumnCreated={handleColumnCreated}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TaskDetailModal
|
<TaskDetailModal
|
||||||
open={selectedTask !== null}
|
open={selectedTask !== null}
|
||||||
task={selectedTask}
|
task={selectedTask}
|
||||||
statusLabel={selectedTaskColumn?.title ?? ""}
|
columns={columns}
|
||||||
|
projectId={projectId}
|
||||||
onClose={() => setSelectedTask(null)}
|
onClose={() => setSelectedTask(null)}
|
||||||
|
onTaskPhaseChanged={handleTaskPhaseChanged}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,20 +1,71 @@
|
|||||||
|
import { FC, useState } from "react";
|
||||||
import { TickCircle } from "iconsax-react";
|
import { TickCircle } from "iconsax-react";
|
||||||
|
import { useFormik } from "formik";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import * as Yup from "yup";
|
||||||
import Button from "../../../components/Button";
|
import Button from "../../../components/Button";
|
||||||
import Input from "../../../components/Input";
|
import Input from "../../../components/Input";
|
||||||
import Select from "../../../components/Select";
|
|
||||||
import Textarea from "../../../components/Textarea";
|
import Textarea from "../../../components/Textarea";
|
||||||
import CreateWorkspaceSidebar from "./components/CreateWorkspaceSidebar";
|
import { Pages } from "../../../config/Pages";
|
||||||
|
import { ErrorType } from "../../../helpers/types";
|
||||||
|
import { useCreateWorkspace } from "./hooks/useWorkspaceData";
|
||||||
|
import { CreateWorkspaceType } from "./types/WorkspaceTypes";
|
||||||
|
import CreateWorkspaceSidebar, {
|
||||||
|
SelectedUser,
|
||||||
|
} from "./components/CreateWorkspaceSidebar";
|
||||||
|
|
||||||
const CreateWorkspace = () => {
|
const CreateWorkspace: FC = () => {
|
||||||
const { t } = useTranslation("global");
|
const { t } = useTranslation("global");
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const createWorkspace = useCreateWorkspace();
|
||||||
|
|
||||||
|
const [isActive, setIsActive] = useState(true);
|
||||||
|
const [selectedColor, setSelectedColor] = useState("#A8E6CF");
|
||||||
|
const [selectedUsers, setSelectedUsers] = useState<SelectedUser[]>([]);
|
||||||
|
|
||||||
|
const handleCreateWorkspace = (values: CreateWorkspaceType) => {
|
||||||
|
createWorkspace.mutate(values, {
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(t("success"));
|
||||||
|
navigate(Pages.taskmanager.workspaceList);
|
||||||
|
},
|
||||||
|
onError: (error: ErrorType) => {
|
||||||
|
toast.error(error.response?.data?.error.message[0]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formik = useFormik<Pick<CreateWorkspaceType, "name" | "description">>({
|
||||||
|
initialValues: {
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
},
|
||||||
|
validationSchema: Yup.object({
|
||||||
|
name: Yup.string().required(t("errors.required")),
|
||||||
|
description: Yup.string(),
|
||||||
|
}),
|
||||||
|
onSubmit: (values) => {
|
||||||
|
handleCreateWorkspace({
|
||||||
|
...values,
|
||||||
|
color: selectedColor,
|
||||||
|
isActive,
|
||||||
|
userIds: selectedUsers.map((user) => user.id),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full mt-4">
|
<div className="w-full mt-4">
|
||||||
<div className="flex w-full justify-between items-center">
|
<div className="flex w-full justify-between items-center">
|
||||||
<div>{t("taskmanager.new_workspace")}</div>
|
<div>{t("taskmanager.new_workspace")}</div>
|
||||||
<div>
|
<div>
|
||||||
<Button className="px-5" isLoading={false}>
|
<Button
|
||||||
|
className="px-5"
|
||||||
|
isLoading={createWorkspace.isPending}
|
||||||
|
onClick={() => formik.handleSubmit()}
|
||||||
|
>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<TickCircle className="size-5" color="#fff" />
|
<TickCircle className="size-5" color="#fff" />
|
||||||
<div>{t("taskmanager.submit_workspace")}</div>
|
<div>{t("taskmanager.submit_workspace")}</div>
|
||||||
@@ -26,20 +77,42 @@ const CreateWorkspace = () => {
|
|||||||
<div className="flex gap-6">
|
<div className="flex gap-6">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex-1 mt-10 bg-white py-8 xl:px-10 px-4 rounded-3xl">
|
<div className="flex-1 mt-10 bg-white py-8 xl:px-10 px-4 rounded-3xl">
|
||||||
<div className="text-sm font-bold">اطلاعات</div>
|
<div className="text-sm font-bold">{t("taskmanager.workspace_info")}</div>
|
||||||
|
|
||||||
<div className="mt-8 rowTwoInput">
|
|
||||||
<Input label={t("taskmanager.workspacename")} />
|
|
||||||
|
|
||||||
<Select label={t("taskmanager.workspacetype")} items={[]} />
|
|
||||||
</div>
|
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<Textarea label={t("taskmanager.workspace_description")} />
|
<Input
|
||||||
|
label={t("taskmanager.workspacename")}
|
||||||
|
{...formik.getFieldProps("name")}
|
||||||
|
error_text={
|
||||||
|
formik.touched.name && formik.errors.name
|
||||||
|
? formik.errors.name
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
<Textarea
|
||||||
|
label={t("taskmanager.workspace_description")}
|
||||||
|
{...formik.getFieldProps("description")}
|
||||||
|
error_text={
|
||||||
|
formik.touched.description && formik.errors.description
|
||||||
|
? formik.errors.description
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CreateWorkspaceSidebar />
|
<CreateWorkspaceSidebar
|
||||||
|
isActive={isActive}
|
||||||
|
onIsActiveChange={setIsActive}
|
||||||
|
selectedColor={selectedColor}
|
||||||
|
onColorChange={setSelectedColor}
|
||||||
|
selectedUsers={selectedUsers}
|
||||||
|
onSelectedUsersChange={setSelectedUsers}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 { UserItemType } from "../../../users/types/UserTypes";
|
||||||
import WorkspaceColorPicker from "./WorkspaceColorPicker";
|
import WorkspaceColorPicker from "./WorkspaceColorPicker";
|
||||||
|
|
||||||
type SelectedUser = {
|
export type SelectedUser = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CreateWorkspaceSidebar: FC = () => {
|
type Props = {
|
||||||
|
isActive: boolean;
|
||||||
|
onIsActiveChange: (value: boolean) => void;
|
||||||
|
selectedColor: string;
|
||||||
|
onColorChange: (color: string) => void;
|
||||||
|
selectedUsers: SelectedUser[];
|
||||||
|
onSelectedUsersChange: (users: SelectedUser[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CreateWorkspaceSidebar: FC<Props> = ({
|
||||||
|
isActive,
|
||||||
|
onIsActiveChange,
|
||||||
|
selectedColor,
|
||||||
|
onColorChange,
|
||||||
|
selectedUsers,
|
||||||
|
onSelectedUsersChange,
|
||||||
|
}) => {
|
||||||
const { t } = useTranslation("global");
|
const { t } = useTranslation("global");
|
||||||
const getUsers = useGetUsers("");
|
const getUsers = useGetUsers("");
|
||||||
|
|
||||||
const [isActive, setIsActive] = useState(true);
|
|
||||||
const [selectedColor, setSelectedColor] = useState("#A8E6CF");
|
|
||||||
const [selectedUsers, setSelectedUsers] = useState<SelectedUser[]>([]);
|
|
||||||
const [userSelectValue, setUserSelectValue] = useState("");
|
const [userSelectValue, setUserSelectValue] = useState("");
|
||||||
|
|
||||||
const userItems =
|
const userItems =
|
||||||
@@ -41,8 +53,8 @@ const CreateWorkspaceSidebar: FC = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
setSelectedUsers((prev) => [
|
onSelectedUsersChange([
|
||||||
...prev,
|
...selectedUsers,
|
||||||
{ id: user.id, name: `${user.firstName} ${user.lastName}` },
|
{ id: user.id, name: `${user.firstName} ${user.lastName}` },
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -51,7 +63,7 @@ const CreateWorkspaceSidebar: FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveUser = (id: string) => {
|
const handleRemoveUser = (id: string) => {
|
||||||
setSelectedUsers((prev) => prev.filter((user) => user.id !== id));
|
onSelectedUsersChange(selectedUsers.filter((user) => user.id !== id));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -60,7 +72,7 @@ const CreateWorkspaceSidebar: FC = () => {
|
|||||||
<div>{t("taskmanager.workspace_status")}</div>
|
<div>{t("taskmanager.workspace_status")}</div>
|
||||||
<div className="flex gap-2 text-xs items-center text-description">
|
<div className="flex gap-2 text-xs items-center text-description">
|
||||||
<div>{isActive ? t("slider.active") : t("slider.inactive")}</div>
|
<div>{isActive ? t("slider.active") : t("slider.inactive")}</div>
|
||||||
<SwitchComponent active={isActive} onChange={setIsActive} />
|
<SwitchComponent active={isActive} onChange={onIsActiveChange} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -69,7 +81,7 @@ const CreateWorkspaceSidebar: FC = () => {
|
|||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<WorkspaceColorPicker
|
<WorkspaceColorPicker
|
||||||
selectedColor={selectedColor}
|
selectedColor={selectedColor}
|
||||||
onChange={setSelectedColor}
|
onChange={onColorChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import * as api from "../service/WorkspaceService";
|
||||||
|
import {
|
||||||
|
CreateWorkspaceType,
|
||||||
|
UpdateWorkspaceType,
|
||||||
|
} from "../types/WorkspaceTypes";
|
||||||
|
|
||||||
|
export const useGetWorkspaces = () => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["workspaces"],
|
||||||
|
queryFn: api.getWorkspacesByUser,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useGetWorkspaceDetail = (id: string) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["workspace-detail", id],
|
||||||
|
queryFn: () => api.getWorkspaceDetail(id),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCreateWorkspace = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (variables: CreateWorkspaceType) => api.createWorkspace(variables),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["workspaces"],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useUpdateWorkspace = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
id,
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
params: UpdateWorkspaceType;
|
||||||
|
}) => api.updateWorkspace(id, params),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["workspaces"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["workspace-detail"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDeleteWorkspace = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => api.deleteWorkspace(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["workspaces"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import axios from "../../../../config/axios";
|
||||||
|
import {
|
||||||
|
CreateWorkspaceType,
|
||||||
|
UpdateWorkspaceType,
|
||||||
|
} from "../types/WorkspaceTypes";
|
||||||
|
|
||||||
|
export const getWorkspacesByUser = async () => {
|
||||||
|
const { data } = await axios.get(`/task-manager/workspaces/byuser`);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getWorkspaceDetail = async (id: string) => {
|
||||||
|
const { data } = await axios.get(`/task-manager/workspaces/${id}`);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createWorkspace = async (params: CreateWorkspaceType) => {
|
||||||
|
const { data } = await axios.post(`/task-manager/workspaces`, params);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateWorkspace = async (id: string, params: UpdateWorkspaceType) => {
|
||||||
|
const { data } = await axios.patch(`/task-manager/workspaces/${id}`, params);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteWorkspace = async (id: string) => {
|
||||||
|
const { data } = await axios.delete(`/task-manager/workspaces/${id}`);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export type CreateWorkspaceType = {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
color: string;
|
||||||
|
isActive: boolean;
|
||||||
|
userIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkspaceItemType = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
color: string;
|
||||||
|
isActive: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkspaceUserType = {
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkspaceDetailType = WorkspaceItemType & {
|
||||||
|
userIds?: string[];
|
||||||
|
users?: WorkspaceUserType[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateWorkspaceType = CreateWorkspaceType;
|
||||||
+392
-100
@@ -80,8 +80,11 @@ import PlanUsers from "../pages/support/PlanUsers";
|
|||||||
import UpdateSupport from "../pages/support/Update";
|
import UpdateSupport from "../pages/support/Update";
|
||||||
import CreateProject from "../pages/taskmanager/project/Create.tsx";
|
import CreateProject from "../pages/taskmanager/project/Create.tsx";
|
||||||
import ProjectList from "../pages/taskmanager/project/List.tsx";
|
import ProjectList from "../pages/taskmanager/project/List.tsx";
|
||||||
|
import UpdateProject from "../pages/taskmanager/project/Update.tsx";
|
||||||
import Workspace from "../pages/taskmanager/workspace.tsx";
|
import Workspace from "../pages/taskmanager/workspace.tsx";
|
||||||
import CreateWorkspace from "../pages/taskmanager/workspace/Create.tsx";
|
import CreateWorkspace from "../pages/taskmanager/workspace/Create.tsx";
|
||||||
|
import WorkspaceList from "../pages/taskmanager/workspace/List.tsx";
|
||||||
|
import UpdateWorkspace from "../pages/taskmanager/workspace/Update.tsx";
|
||||||
import TicketCategory from "../pages/ticket/Category";
|
import TicketCategory from "../pages/ticket/Category";
|
||||||
import CreateTicket from "../pages/ticket/CreateTicket";
|
import CreateTicket from "../pages/ticket/CreateTicket";
|
||||||
import TicketDetail from "../pages/ticket/Detail";
|
import TicketDetail from "../pages/ticket/Detail";
|
||||||
@@ -111,115 +114,404 @@ const MainRouter: FC = () => {
|
|||||||
{!isWorkspace && <SideBar />}
|
{!isWorkspace && <SideBar />}
|
||||||
<Header />
|
<Header />
|
||||||
<div className={clx("flex-1 mt-[68px] xl:mt-[81px]", !isWorkspace && "xl:ms-[269px]", !isWorkspace && hasSubMenu && "xl:ms-[305px]")}>
|
<div className={clx("flex-1 mt-[68px] xl:mt-[81px]", !isWorkspace && "xl:ms-[269px]", !isWorkspace && hasSubMenu && "xl:ms-[305px]")}>
|
||||||
<div
|
<div className={clx("flex flex-col", isWorkspace ? "h-[calc(100dvh-68px)] xl:h-[calc(100dvh-97px)] overflow-hidden" : "overflow-auto h-[calc(100vh-113px)]")}>
|
||||||
className={clx(
|
|
||||||
"flex flex-col",
|
|
||||||
isWorkspace ? "h-[calc(100dvh-68px)] xl:h-[calc(100dvh-97px)] overflow-hidden" : "overflow-auto h-[calc(100vh-113px)]",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className={clx("flex-1 min-h-0", isWorkspace && "overflow-hidden")}>
|
<div className={clx("flex-1 min-h-0", isWorkspace && "overflow-hidden")}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path={Pages.dashboard} element={<Home />} />
|
<Route
|
||||||
<Route path={Pages.services.mine} element={<MyServices />} />
|
path={Pages.dashboard}
|
||||||
<Route path={Pages.services.other} element={<OtherServices />} />
|
element={<Home />}
|
||||||
<Route path={Pages.services.detail + ":id"} element={<DetailService />} />
|
/>
|
||||||
<Route path={Pages.services.add} element={<AddService />} />
|
<Route
|
||||||
<Route path={Pages.services.list} element={<ListService />} />
|
path={Pages.services.mine}
|
||||||
<Route path={Pages.services.update + ":id"} element={<UpdateService />} />
|
element={<MyServices />}
|
||||||
<Route path={Pages.services.category} element={<Category />} />
|
/>
|
||||||
<Route path={Pages.services.plan + ":id"} element={<Plans />} />
|
<Route
|
||||||
<Route path={Pages.services.review} element={<Reviews />} />
|
path={Pages.services.other}
|
||||||
<Route path={Pages.ticket.list} element={<TicketList />} />
|
element={<OtherServices />}
|
||||||
<Route path={Pages.ticket.create} element={<CreateTicket />} />
|
/>
|
||||||
<Route path={Pages.ticket.category} element={<TicketCategory />} />
|
<Route
|
||||||
<Route path={Pages.ticket.detail + ":id"} element={<TicketDetail />} />
|
path={Pages.services.detail + ":id"}
|
||||||
<Route path={Pages.ads.list} element={<AdsList />} />
|
element={<DetailService />}
|
||||||
<Route path={Pages.ads.create} element={<CreateAds />} />
|
/>
|
||||||
<Route path={Pages.ads.update + ":id"} element={<UpdateAds />} />
|
<Route
|
||||||
<Route path={Pages.discount.list} element={<DiscountsList />} />
|
path={Pages.services.add}
|
||||||
<Route path={Pages.discount.create} element={<CreateDiscount />} />
|
element={<AddService />}
|
||||||
<Route path={Pages.discount.detail + ":id"} element={<UpdateDiscount />} />
|
/>
|
||||||
<Route path={Pages.blog.list} element={<BlogList />} />
|
<Route
|
||||||
<Route path={Pages.blog.create} element={<CreateBlog />} />
|
path={Pages.services.list}
|
||||||
<Route path={Pages.blog.category} element={<BlogCategory />} />
|
element={<ListService />}
|
||||||
<Route path={Pages.blog.detail + ":id"} element={<UpdateBlog />} />
|
/>
|
||||||
<Route path={Pages.transactions} element={<TransactionList />} />
|
<Route
|
||||||
<Route path={Pages.receipts.index} element={<ReceiptsList />} />
|
path={Pages.services.update + ":id"}
|
||||||
<Route path={Pages.receipts.detail + ":id"} element={<ReceiptsDetail />} />
|
element={<UpdateService />}
|
||||||
<Route path={Pages.receipts.create} element={<CreateReceipt />} />
|
/>
|
||||||
<Route path={Pages.announcement.list} element={<AnnouncementtList />} />
|
<Route
|
||||||
<Route path={Pages.announcement.detail + ":id"} element={<Update />} />
|
path={Pages.services.category}
|
||||||
<Route path={Pages.announcement.create} element={<Create />} />
|
element={<Category />}
|
||||||
<Route path={Pages.criticisms.list} element={<CriticismsList />} />
|
/>
|
||||||
<Route path={Pages.criticisms.detail + ":id"} element={<CriticismsDetail />} />
|
<Route
|
||||||
<Route path={Pages.learning.list} element={<LearningList />} />
|
path={Pages.services.plan + ":id"}
|
||||||
<Route path={Pages.learning.create} element={<CreateLearning />} />
|
element={<Plans />}
|
||||||
<Route path={Pages.learning.category} element={<LearningCategory />} />
|
/>
|
||||||
<Route path={Pages.setting} element={<Setting />} />
|
<Route
|
||||||
<Route path={Pages.wallet} element={<Wallet />} />
|
path={Pages.services.review}
|
||||||
<Route path={Pages.profile} element={<Profile />} />
|
element={<Reviews />}
|
||||||
<Route path={Pages.customer.list} element={<CustomerList />} />
|
/>
|
||||||
<Route path={Pages.customer.create} element={<CreateCustomer />} />
|
<Route
|
||||||
<Route path={Pages.customer.update + ":id"} element={<UpdateCustomer />} />
|
path={Pages.ticket.list}
|
||||||
<Route path={Pages.messages.list} element={<MessagesList />} />
|
element={<TicketList />}
|
||||||
<Route path={Pages.messages.detail + ":id"} element={<MessageDetail />} />
|
/>
|
||||||
<Route path={Pages.users.list} element={<UsersList />} />
|
<Route
|
||||||
<Route path={Pages.users.create} element={<CreateUser />} />
|
path={Pages.ticket.create}
|
||||||
<Route path={Pages.users.detail + ":id"} element={<UpdateUser />} />
|
element={<CreateTicket />}
|
||||||
<Route path={Pages.users.roleList} element={<RolesList />} />
|
/>
|
||||||
<Route path={Pages.users.roleCreate} element={<RoleCreate />} />
|
<Route
|
||||||
<Route path={Pages.users.roleUpdate + ":id"} element={<RoleUpdate />} />
|
path={Pages.ticket.category}
|
||||||
<Route path={Pages.users.groupAdd} element={<GroupCreate />} />
|
element={<TicketCategory />}
|
||||||
<Route path={Pages.users.groupList} element={<GroupList />} />
|
/>
|
||||||
<Route path={Pages.cardBank.create} element={<CreateBankCard />} />
|
<Route
|
||||||
<Route path={Pages.cardBank.list} element={<CardBankList />} />
|
path={Pages.ticket.detail + ":id"}
|
||||||
<Route path={Pages.cardBank.detail + ":id"} element={<EditBankCard />} />
|
element={<TicketDetail />}
|
||||||
<Route path={Pages.payment.list} element={<PaymentList />} />
|
/>
|
||||||
<Route path={Pages.sliders.create} element={<CreateSlider />} />
|
<Route
|
||||||
<Route path={Pages.sliders.list} element={<SliderList />} />
|
path={Pages.ads.list}
|
||||||
<Route path={Pages.sliders.detail + ":id"} element={<UpdateSlider />} />
|
element={<AdsList />}
|
||||||
<Route path={Pages.blog.comments} element={<Comments />} />
|
/>
|
||||||
<Route path={Pages.support.create} element={<CreatePlan />} />
|
<Route
|
||||||
<Route path={Pages.support.list} element={<SupportList />} />
|
path={Pages.ads.create}
|
||||||
<Route path={Pages.support.detail + ":id"} element={<UpdateSupport />} />
|
element={<CreateAds />}
|
||||||
<Route path={Pages.support.planUsers + ":id"} element={<PlanUsers />} />
|
/>
|
||||||
<Route path={Pages.services.guide + ":id"} element={<List />} />
|
<Route
|
||||||
<Route path={Pages.services.guide + ":id/create"} element={<CreateGuide />} />
|
path={Pages.ads.update + ":id"}
|
||||||
<Route path={Pages.services.guide + ":id/:guideId"} element={<UpdateGuide />} />
|
element={<UpdateAds />}
|
||||||
<Route path={Pages.feedback.list} element={<ListFeedback />} />
|
/>
|
||||||
<Route path={Pages.accessLogs.list} element={<AccessLogsList />} />
|
<Route
|
||||||
<Route path={Pages.accessLogs.stats} element={<AccessLogsStats />} />
|
path={Pages.discount.list}
|
||||||
<Route path={Pages.accessLogs.errors} element={<ErrorLogs />} />
|
element={<DiscountsList />}
|
||||||
<Route path={Pages.accessLogs.permissions} element={<PermissionLogs />} />
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.discount.create}
|
||||||
|
element={<CreateDiscount />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.discount.detail + ":id"}
|
||||||
|
element={<UpdateDiscount />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.blog.list}
|
||||||
|
element={<BlogList />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.blog.create}
|
||||||
|
element={<CreateBlog />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.blog.category}
|
||||||
|
element={<BlogCategory />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.blog.detail + ":id"}
|
||||||
|
element={<UpdateBlog />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.transactions}
|
||||||
|
element={<TransactionList />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.receipts.index}
|
||||||
|
element={<ReceiptsList />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.receipts.detail + ":id"}
|
||||||
|
element={<ReceiptsDetail />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.receipts.create}
|
||||||
|
element={<CreateReceipt />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.announcement.list}
|
||||||
|
element={<AnnouncementtList />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.announcement.detail + ":id"}
|
||||||
|
element={<Update />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.announcement.create}
|
||||||
|
element={<Create />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.criticisms.list}
|
||||||
|
element={<CriticismsList />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.criticisms.detail + ":id"}
|
||||||
|
element={<CriticismsDetail />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.learning.list}
|
||||||
|
element={<LearningList />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.learning.create}
|
||||||
|
element={<CreateLearning />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.learning.category}
|
||||||
|
element={<LearningCategory />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.setting}
|
||||||
|
element={<Setting />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.wallet}
|
||||||
|
element={<Wallet />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.profile}
|
||||||
|
element={<Profile />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.customer.list}
|
||||||
|
element={<CustomerList />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.customer.create}
|
||||||
|
element={<CreateCustomer />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.customer.update + ":id"}
|
||||||
|
element={<UpdateCustomer />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.messages.list}
|
||||||
|
element={<MessagesList />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.messages.detail + ":id"}
|
||||||
|
element={<MessageDetail />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.users.list}
|
||||||
|
element={<UsersList />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.users.create}
|
||||||
|
element={<CreateUser />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.users.detail + ":id"}
|
||||||
|
element={<UpdateUser />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.users.roleList}
|
||||||
|
element={<RolesList />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.users.roleCreate}
|
||||||
|
element={<RoleCreate />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.users.roleUpdate + ":id"}
|
||||||
|
element={<RoleUpdate />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.users.groupAdd}
|
||||||
|
element={<GroupCreate />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.users.groupList}
|
||||||
|
element={<GroupList />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.cardBank.create}
|
||||||
|
element={<CreateBankCard />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.cardBank.list}
|
||||||
|
element={<CardBankList />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.cardBank.detail + ":id"}
|
||||||
|
element={<EditBankCard />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.payment.list}
|
||||||
|
element={<PaymentList />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.sliders.create}
|
||||||
|
element={<CreateSlider />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.sliders.list}
|
||||||
|
element={<SliderList />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.sliders.detail + ":id"}
|
||||||
|
element={<UpdateSlider />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.blog.comments}
|
||||||
|
element={<Comments />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.support.create}
|
||||||
|
element={<CreatePlan />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.support.list}
|
||||||
|
element={<SupportList />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.support.detail + ":id"}
|
||||||
|
element={<UpdateSupport />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.support.planUsers + ":id"}
|
||||||
|
element={<PlanUsers />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.services.guide + ":id"}
|
||||||
|
element={<List />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.services.guide + ":id/create"}
|
||||||
|
element={<CreateGuide />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.services.guide + ":id/:guideId"}
|
||||||
|
element={<UpdateGuide />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.feedback.list}
|
||||||
|
element={<ListFeedback />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.accessLogs.list}
|
||||||
|
element={<AccessLogsList />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.accessLogs.stats}
|
||||||
|
element={<AccessLogsStats />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.accessLogs.errors}
|
||||||
|
element={<ErrorLogs />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.accessLogs.permissions}
|
||||||
|
element={<PermissionLogs />}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route path={Pages.dmenu.icons.list} element={<IconsList />} />
|
<Route
|
||||||
<Route path={Pages.dmenu.icons.groupList} element={<GroupIconList />} />
|
path={Pages.dmenu.icons.list}
|
||||||
<Route path={Pages.dmenu.warnings.list} element={<WarningsList />} />
|
element={<IconsList />}
|
||||||
<Route path={Pages.dmenu.restaurants.list} element={<RestaurantsList />} />
|
/>
|
||||||
<Route path={Pages.dmenu.restaurants.smsCountList} element={<SmsCountList />} />
|
<Route
|
||||||
<Route path={Pages.dmenu.restaurants.update + ":id"} element={<UpdateRestaurant />} />
|
path={Pages.dmenu.icons.groupList}
|
||||||
|
element={<GroupIconList />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.dmenu.warnings.list}
|
||||||
|
element={<WarningsList />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.dmenu.restaurants.list}
|
||||||
|
element={<RestaurantsList />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.dmenu.restaurants.smsCountList}
|
||||||
|
element={<SmsCountList />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.dmenu.restaurants.update + ":id"}
|
||||||
|
element={<UpdateRestaurant />}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route path={Pages.dkala.icons.list} element={<DkalaIconsList />} />
|
<Route
|
||||||
<Route path={Pages.dkala.icons.groupList} element={<DkalaGroupIconList />} />
|
path={Pages.dkala.icons.list}
|
||||||
<Route path={Pages.dkala.warnings.list} element={<DkalaWarningsList />} />
|
element={<DkalaIconsList />}
|
||||||
<Route path={Pages.dkala.shops.list} element={<ShopsList />} />
|
/>
|
||||||
<Route path={Pages.dkala.shops.smsCountList} element={<DkalaSmsCountList />} />
|
<Route
|
||||||
<Route path={Pages.dkala.shops.update + ":id"} element={<UpdateShop />} />
|
path={Pages.dkala.icons.groupList}
|
||||||
|
element={<DkalaGroupIconList />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.dkala.warnings.list}
|
||||||
|
element={<DkalaWarningsList />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.dkala.shops.list}
|
||||||
|
element={<ShopsList />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.dkala.shops.smsCountList}
|
||||||
|
element={<DkalaSmsCountList />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.dkala.shops.update + ":id"}
|
||||||
|
element={<UpdateShop />}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route path={Pages.dpage.stickers.list} element={<DpageStickersList />} />
|
<Route
|
||||||
|
path={Pages.dpage.stickers.list}
|
||||||
|
element={<DpageStickersList />}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route path={Pages.subscriptions.list} element={<SubscriptionsList />} />
|
<Route
|
||||||
|
path={Pages.subscriptions.list}
|
||||||
|
element={<SubscriptionsList />}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route path={Pages.dmail.list} element={<Domains />} />
|
<Route
|
||||||
|
path={Pages.dmail.list}
|
||||||
|
element={<Domains />}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route path={Pages.reseller.list} element={<ResellerList />} />
|
<Route
|
||||||
<Route path={Pages.reseller.create} element={<CreateReseller />} />
|
path={Pages.reseller.list}
|
||||||
<Route path={Pages.reseller.withdraw} element={<Withdraw />} />
|
element={<ResellerList />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.reseller.create}
|
||||||
|
element={<CreateReseller />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.reseller.withdraw}
|
||||||
|
element={<Withdraw />}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route path={Pages.taskmanager.workspace + ":slug"} element={<Workspace />} />
|
<Route
|
||||||
<Route path={Pages.taskmanager.createWorkspace} element={<CreateWorkspace />} />
|
path={Pages.taskmanager.workspace + ":slug"}
|
||||||
<Route path={Pages.taskmanager.createProject} element={<CreateProject />} />
|
element={<Workspace />}
|
||||||
<Route path={Pages.taskmanager.projectList} element={<ProjectList />} />
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.taskmanager.workspaceList}
|
||||||
|
element={<WorkspaceList />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.taskmanager.createWorkspace}
|
||||||
|
element={<CreateWorkspace />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.taskmanager.updateWorkspace + ":id"}
|
||||||
|
element={<UpdateWorkspace />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.taskmanager.createProject}
|
||||||
|
element={<CreateProject />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.taskmanager.updateProject + ":id"}
|
||||||
|
element={<UpdateProject />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Pages.taskmanager.projectList + ":workspaceId"}
|
||||||
|
element={<ProjectList />}
|
||||||
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
{!isWorkspace && <div className="h-20 shrink-0 xl:hidden" />}
|
{!isWorkspace && <div className="h-20 shrink-0 xl:hidden" />}
|
||||||
|
|||||||
+51
-6
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
|
Add,
|
||||||
ArrowDown2,
|
ArrowDown2,
|
||||||
BagTick,
|
BagTick,
|
||||||
Building,
|
Building,
|
||||||
@@ -51,6 +52,7 @@ import LearningSubMenu from "./components/LearningSubMenu";
|
|||||||
import ReceiptSubMenu from "./components/ReceiptSubMenu";
|
import ReceiptSubMenu from "./components/ReceiptSubMenu";
|
||||||
import ServicesSubMenu from "./components/ServicesSubMenu";
|
import ServicesSubMenu from "./components/ServicesSubMenu";
|
||||||
import SupportSubMenu from "./components/SupportSubMenu";
|
import SupportSubMenu from "./components/SupportSubMenu";
|
||||||
|
import TaskManagerSubMenu from "./components/TaskManagerSubMenu";
|
||||||
import TicketSubMenu from "./components/TicketSubMenu";
|
import TicketSubMenu from "./components/TicketSubMenu";
|
||||||
import UsersSubMenu from "./components/UsersSubMenu";
|
import UsersSubMenu from "./components/UsersSubMenu";
|
||||||
import SideBarItem from "./SideBarItem";
|
import SideBarItem from "./SideBarItem";
|
||||||
@@ -69,10 +71,20 @@ const SideBar: FC = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const split = location.pathname.split("/");
|
const split = location.pathname.split("/");
|
||||||
|
const isTaskManagerRoute =
|
||||||
|
location.pathname.startsWith(Pages.taskmanager.workspace) ||
|
||||||
|
location.pathname === Pages.taskmanager.workspaceList ||
|
||||||
|
location.pathname.startsWith(Pages.taskmanager.updateWorkspace) ||
|
||||||
|
location.pathname === Pages.taskmanager.createWorkspace ||
|
||||||
|
location.pathname === Pages.taskmanager.createProject ||
|
||||||
|
location.pathname === Pages.taskmanager.projectList;
|
||||||
|
|
||||||
if (SideBarItemHasSubMenu.includes(split[1])) {
|
if (SideBarItemHasSubMenu.includes(split[1])) {
|
||||||
setSubMenuName(split[1]);
|
setSubMenuName(split[1]);
|
||||||
setSubtMenu(true);
|
setSubtMenu(true);
|
||||||
|
} else if (isTaskManagerRoute) {
|
||||||
|
setSubMenuName("taskmanager");
|
||||||
|
setSubtMenu(true);
|
||||||
} else {
|
} else {
|
||||||
setSubMenuName("");
|
setSubMenuName("");
|
||||||
setSubtMenu(false);
|
setSubtMenu(false);
|
||||||
@@ -459,18 +471,49 @@ const SideBar: FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<div className={clx("text-xs text-[#8C90A3]")}>
|
<div className={clx("text-xs text-[#8C90A3]", !openMenu.includes("taskmanager") && "hidden")}>
|
||||||
<SideBarItem
|
<SideBarItem
|
||||||
icon={
|
icon={
|
||||||
<Task
|
<Task
|
||||||
variant={isActive("taskmanager") ? "Bold" : "Outline"}
|
variant={location.pathname === Pages.taskmanager.workspaceList ? "Bold" : "Outline"}
|
||||||
color={isActive("taskmanager") ? "black" : "#8C90A3"}
|
color={location.pathname === Pages.taskmanager.workspaceList ? "black" : "#8C90A3"}
|
||||||
size={iconSizeSideBar}
|
size={iconSizeSideBar}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
title={t("sidebar.taskmanager")}
|
title={t("submenu.taskmanager_workspace_list")}
|
||||||
isActive={isActive("taskmanager")}
|
isActive={location.pathname === Pages.taskmanager.workspaceList}
|
||||||
link={Pages.taskmanager.workspace + "dpage"}
|
link={Pages.taskmanager.workspaceList}
|
||||||
|
name="taskmanager"
|
||||||
|
activeName=""
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SideBarItem
|
||||||
|
icon={
|
||||||
|
<Add
|
||||||
|
variant={location.pathname === Pages.taskmanager.createWorkspace ? "Bold" : "Outline"}
|
||||||
|
color={location.pathname === Pages.taskmanager.createWorkspace ? "black" : "#8C90A3"}
|
||||||
|
size={iconSizeSideBar}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
title={t("submenu.taskmanager_create_workspace")}
|
||||||
|
isActive={location.pathname === Pages.taskmanager.createWorkspace}
|
||||||
|
link={Pages.taskmanager.createWorkspace}
|
||||||
|
name="taskmanager"
|
||||||
|
activeName=""
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SideBarItem
|
||||||
|
icon={
|
||||||
|
<Add
|
||||||
|
variant={location.pathname === Pages.taskmanager.createProject ? "Bold" : "Outline"}
|
||||||
|
color={location.pathname === Pages.taskmanager.createProject ? "black" : "#8C90A3"}
|
||||||
|
size={iconSizeSideBar}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
title={t("submenu.taskmanager_create_project")}
|
||||||
|
isActive={location.pathname === Pages.taskmanager.createProject}
|
||||||
|
link={Pages.taskmanager.createProject}
|
||||||
|
name="taskmanager"
|
||||||
activeName=""
|
activeName=""
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -948,6 +991,8 @@ const SideBar: FC = () => {
|
|||||||
<SupportSubMenu />
|
<SupportSubMenu />
|
||||||
) : subMenuName === "accessLogs" ? (
|
) : subMenuName === "accessLogs" ? (
|
||||||
<AccessLogsSubMenu />
|
<AccessLogsSubMenu />
|
||||||
|
) : subMenuName === "taskmanager" ? (
|
||||||
|
<TaskManagerSubMenu />
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { Task } from "iconsax-react";
|
||||||
|
import { FC } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
|
import { Pages } from "../../config/Pages";
|
||||||
|
import SubMenuItem from "./SubMenuItem";
|
||||||
|
|
||||||
|
const TaskManagerSubMenu: FC = () => {
|
||||||
|
const { t } = useTranslation("global");
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const isWorkspaceListActive = location.pathname === Pages.taskmanager.workspaceList;
|
||||||
|
const isKanbanActive =
|
||||||
|
location.pathname.startsWith(Pages.taskmanager.workspace) &&
|
||||||
|
!isWorkspaceListActive;
|
||||||
|
const isCreateWorkspaceActive = location.pathname === Pages.taskmanager.createWorkspace;
|
||||||
|
const isProjectListActive = location.pathname === Pages.taskmanager.projectList;
|
||||||
|
const isCreateProjectActive = location.pathname === Pages.taskmanager.createProject;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-12">
|
||||||
|
<div className="flex gap-3 items-center border-b pb-10 px-6">
|
||||||
|
<Task size={24} color="#000" variant="Bold" />
|
||||||
|
<div className="text-xs">{t("sidebar.taskmanager")}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col mt-10 gap-4">
|
||||||
|
<SubMenuItem
|
||||||
|
title={t("submenu.taskmanager_workspace_list")}
|
||||||
|
isActive={isWorkspaceListActive}
|
||||||
|
link={Pages.taskmanager.workspaceList}
|
||||||
|
/>
|
||||||
|
<SubMenuItem
|
||||||
|
title={t("submenu.taskmanager_workspace")}
|
||||||
|
isActive={isKanbanActive}
|
||||||
|
link={Pages.taskmanager.workspace + "dpage"}
|
||||||
|
/>
|
||||||
|
<SubMenuItem
|
||||||
|
title={t("submenu.taskmanager_create_workspace")}
|
||||||
|
isActive={isCreateWorkspaceActive}
|
||||||
|
link={Pages.taskmanager.createWorkspace}
|
||||||
|
/>
|
||||||
|
<SubMenuItem
|
||||||
|
title={t("submenu.taskmanager_project_list")}
|
||||||
|
isActive={isProjectListActive}
|
||||||
|
link={Pages.taskmanager.projectList}
|
||||||
|
/>
|
||||||
|
<SubMenuItem
|
||||||
|
title={t("submenu.taskmanager_create_project")}
|
||||||
|
isActive={isCreateProjectActive}
|
||||||
|
link={Pages.taskmanager.createProject}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TaskManagerSubMenu;
|
||||||
Reference in New Issue
Block a user