This commit is contained in:
@@ -1,25 +1,44 @@
|
|||||||
import { InfoCircle, Setting2, UserAdd } from "iconsax-react";
|
import { InfoCircle, Setting2, UserAdd } from "iconsax-react";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import Button from "../../../components/Button";
|
||||||
|
|
||||||
type HeaderWorkspaceProps = {
|
type HeaderWorkspaceProps = {
|
||||||
projectName: string;
|
projectName: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const HeaderWorkspace: FC<HeaderWorkspaceProps> = ({ projectName }) => {
|
const HeaderWorkspace: FC<HeaderWorkspaceProps> = ({ projectName }) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-between items-center bg-primary h-14 sm:h-16 xl:h-[102px] px-3 sm:px-6 xl:px-8 shrink-0 gap-3">
|
<div className="flex justify-between items-center bg-primary h-14 sm:h-16 xl:h-[102px] px-3 sm:px-6 xl:px-8 shrink-0 gap-3">
|
||||||
<div className="flex gap-2 sm:gap-4 items-center min-w-0">
|
<div className="flex gap-2 sm:gap-4 items-center min-w-0">
|
||||||
<div className="text-white text-base sm:text-lg xl:text-xl truncate">{projectName}</div>
|
<div className="text-white text-base sm:text-lg xl:text-xl truncate">{projectName}</div>
|
||||||
<InfoCircle size={18} color="white" />
|
<InfoCircle
|
||||||
|
size={18}
|
||||||
|
color="white"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 sm:gap-4 items-center shrink-0">
|
<div className="flex gap-2 sm:gap-4 items-center shrink-0">
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
label="برگشت"
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex gap-1 items-center px-2 sm:px-3 rounded-lg bg-white h-8">
|
<div className="flex gap-1 items-center px-2 sm:px-3 rounded-lg bg-white h-8">
|
||||||
<UserAdd size={18} color="black" />
|
<UserAdd
|
||||||
<div className="text-black text-xs hidden sm:block">اشتراک گذاری</div>
|
size={18}
|
||||||
|
color="black"
|
||||||
|
/>
|
||||||
|
<div className="text-black text-xs hidden sm:block whitespace-nowrap">اشتراک گذاری</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Setting2 size={18} color="white" />
|
<Setting2
|
||||||
|
size={18}
|
||||||
|
color="white"
|
||||||
|
className="min-w-[18px]"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { type FC, useEffect, useMemo, useState } from "react";
|
import { type FC, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { useGetUsers } from "../../../users/hooks/useUserData";
|
import { useGetUsersInfinite } from "../../../users/hooks/useUserData";
|
||||||
import TaskDetailAttachmentPopover from "./attachment/TaskDetailAttachmentPopover";
|
import TaskDetailAttachmentPopover from "./attachment/TaskDetailAttachmentPopover";
|
||||||
import TaskDetailDatePopover from "./date/TaskDetailDatePopover";
|
import TaskDetailDatePopover from "./date/TaskDetailDatePopover";
|
||||||
import { parseApiDate, toApiDate } from "./date/utils";
|
import { parseApiDate, toApiDate } from "./date/utils";
|
||||||
@@ -35,7 +35,16 @@ const TaskDetailToolbar: FC<Props> = ({ activeTab, onTabChange, taskDetail, task
|
|||||||
const createTaskAttachment = useCreateTaskAttachment();
|
const createTaskAttachment = useCreateTaskAttachment();
|
||||||
const singleUpload = useSingleUpload();
|
const singleUpload = useSingleUpload();
|
||||||
const updateTask = useUpdateTask();
|
const updateTask = useUpdateTask();
|
||||||
const getUsers = useGetUsers("");
|
const isUsersOpen = activeTab === "users";
|
||||||
|
const [userSearch, setUserSearch] = useState("");
|
||||||
|
const [debouncedUserSearch, setDebouncedUserSearch] = useState("");
|
||||||
|
const {
|
||||||
|
data: usersData,
|
||||||
|
isPending: isUsersPending,
|
||||||
|
isFetchingNextPage: isFetchingNextUsersPage,
|
||||||
|
fetchNextPage: fetchNextUsersPage,
|
||||||
|
hasNextPage: hasNextUsersPage,
|
||||||
|
} = useGetUsersInfinite(debouncedUserSearch, undefined, isUsersOpen);
|
||||||
const [labelsView, setLabelsView] = useState<LabelsView>("list");
|
const [labelsView, setLabelsView] = useState<LabelsView>("list");
|
||||||
const [labels, setLabels] = useState<TaskLabel[]>([]);
|
const [labels, setLabels] = useState<TaskLabel[]>([]);
|
||||||
const [selectedLabelIds, setSelectedLabelIds] = useState<Set<string>>(new Set());
|
const [selectedLabelIds, setSelectedLabelIds] = useState<Set<string>>(new Set());
|
||||||
@@ -43,6 +52,14 @@ const TaskDetailToolbar: FC<Props> = ({ activeTab, onTabChange, taskDetail, task
|
|||||||
const [startDate, setStartDate] = useState<DateObject | null>(null);
|
const [startDate, setStartDate] = useState<DateObject | null>(null);
|
||||||
const [endDate, setEndDate] = useState<DateObject | null>(null);
|
const [endDate, setEndDate] = useState<DateObject | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
setDebouncedUserSearch(userSearch.trim());
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
return () => window.clearTimeout(timeoutId);
|
||||||
|
}, [userSearch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!taskDetail) return;
|
if (!taskDetail) return;
|
||||||
|
|
||||||
@@ -69,10 +86,13 @@ const TaskDetailToolbar: FC<Props> = ({ activeTab, onTabChange, taskDetail, task
|
|||||||
const selectedLabels = labels.filter((label) => selectedLabelIds.has(label.id));
|
const selectedLabels = labels.filter((label) => selectedLabelIds.has(label.id));
|
||||||
const users = useMemo((): TaskUser[] => {
|
const users = useMemo((): TaskUser[] => {
|
||||||
const apiUsers: TaskUser[] =
|
const apiUsers: TaskUser[] =
|
||||||
getUsers.data?.data?.users?.map((user: UserItemType) => ({
|
usersData?.pages.flatMap(
|
||||||
id: user.id,
|
(page) =>
|
||||||
name: `${user.firstName} ${user.lastName}`.trim(),
|
page.data?.users?.map((user: UserItemType) => ({
|
||||||
})) ?? [];
|
id: user.id,
|
||||||
|
name: `${user.firstName} ${user.lastName}`.trim(),
|
||||||
|
})) ?? [],
|
||||||
|
) ?? [];
|
||||||
const taskUsers: TaskUser[] =
|
const taskUsers: TaskUser[] =
|
||||||
taskDetail?.users?.map((user) => ({
|
taskDetail?.users?.map((user) => ({
|
||||||
id: user.id,
|
id: user.id,
|
||||||
@@ -87,10 +107,20 @@ const TaskDetailToolbar: FC<Props> = ({ activeTab, onTabChange, taskDetail, task
|
|||||||
});
|
});
|
||||||
|
|
||||||
return Array.from(userMap.values());
|
return Array.from(userMap.values());
|
||||||
}, [getUsers.data, taskDetail?.users]);
|
}, [usersData?.pages, taskDetail?.users]);
|
||||||
|
const listUsers = useMemo((): TaskUser[] => {
|
||||||
|
return (
|
||||||
|
usersData?.pages.flatMap(
|
||||||
|
(page) =>
|
||||||
|
page.data?.users?.map((user: UserItemType) => ({
|
||||||
|
id: user.id,
|
||||||
|
name: `${user.firstName} ${user.lastName}`.trim(),
|
||||||
|
})) ?? [],
|
||||||
|
) ?? []
|
||||||
|
);
|
||||||
|
}, [usersData?.pages]);
|
||||||
const selectedUsers = users.filter((user) => selectedUserIds.has(user.id));
|
const selectedUsers = users.filter((user) => selectedUserIds.has(user.id));
|
||||||
const isLabelsOpen = activeTab === "labels";
|
const isLabelsOpen = activeTab === "labels";
|
||||||
const isUsersOpen = activeTab === "users";
|
|
||||||
const isChecklistOpen = activeTab === "checklist";
|
const isChecklistOpen = activeTab === "checklist";
|
||||||
const isAttachmentOpen = activeTab === "attachment";
|
const isAttachmentOpen = activeTab === "attachment";
|
||||||
const isDateOpen = activeTab === "date";
|
const isDateOpen = activeTab === "date";
|
||||||
@@ -305,11 +335,18 @@ const TaskDetailToolbar: FC<Props> = ({ activeTab, onTabChange, taskDetail, task
|
|||||||
usersPopover={
|
usersPopover={
|
||||||
isUsersOpen ? (
|
isUsersOpen ? (
|
||||||
<TaskDetailUsersPopover
|
<TaskDetailUsersPopover
|
||||||
users={users}
|
users={listUsers}
|
||||||
selectedIds={selectedUserIds}
|
selectedIds={selectedUserIds}
|
||||||
onToggle={handleUserToggle}
|
onToggle={handleUserToggle}
|
||||||
isLoading={getUsers.isPending}
|
isLoading={isUsersPending}
|
||||||
isSubmitting={updateTask.isPending}
|
isSubmitting={updateTask.isPending}
|
||||||
|
hasNextPage={hasNextUsersPage}
|
||||||
|
isFetchingNextPage={isFetchingNextUsersPage}
|
||||||
|
onLoadMore={() => {
|
||||||
|
void fetchNextUsersPage();
|
||||||
|
}}
|
||||||
|
search={userSearch}
|
||||||
|
onSearchChange={setUserSearch}
|
||||||
/>
|
/>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type FC, useMemo, useState } from "react";
|
import { type FC } 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 TaskDetailLabelCheckbox from "../labels/TaskDetailLabelCheckbox";
|
||||||
@@ -10,17 +10,35 @@ type Props = {
|
|||||||
onToggle: (id: string) => void;
|
onToggle: (id: string) => void;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
isSubmitting?: boolean;
|
isSubmitting?: boolean;
|
||||||
|
hasNextPage?: boolean;
|
||||||
|
isFetchingNextPage?: boolean;
|
||||||
|
onLoadMore?: () => void;
|
||||||
|
search: string;
|
||||||
|
onSearchChange: (value: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const TaskDetailUsersList: FC<Props> = ({ users, selectedIds, onToggle, isLoading = false, isSubmitting = false }) => {
|
const TaskDetailUsersList: FC<Props> = ({
|
||||||
|
users,
|
||||||
|
selectedIds,
|
||||||
|
onToggle,
|
||||||
|
isLoading = false,
|
||||||
|
isSubmitting = false,
|
||||||
|
hasNextPage = false,
|
||||||
|
isFetchingNextPage = false,
|
||||||
|
onLoadMore,
|
||||||
|
search,
|
||||||
|
onSearchChange,
|
||||||
|
}) => {
|
||||||
const { t } = useTranslation("global");
|
const { t } = useTranslation("global");
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
|
|
||||||
const filteredUsers = useMemo(() => {
|
const handleScroll = (event: React.UIEvent<HTMLDivElement>) => {
|
||||||
const query = search.trim().toLowerCase();
|
const target = event.currentTarget;
|
||||||
if (!query) return users;
|
const isNearBottom = target.scrollHeight - target.scrollTop - target.clientHeight < 80;
|
||||||
return users.filter((user) => user.name.toLowerCase().includes(query));
|
|
||||||
}, [users, search]);
|
if (isNearBottom && hasNextPage && !isFetchingNextPage) {
|
||||||
|
onLoadMore?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
@@ -28,7 +46,13 @@ const TaskDetailUsersList: FC<Props> = ({ users, selectedIds, onToggle, isLoadin
|
|||||||
<h3 className="text-xs font-bold text-center">{t("taskmanager.users")}</h3>
|
<h3 className="text-xs font-bold text-center">{t("taskmanager.users")}</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Input label={t("search")} value={search} onChange={(e) => setSearch(e.target.value)} className="h-8 bg-white/63 border-white" labelClassName="text-xs" />
|
<Input
|
||||||
|
label={t("search")}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
className="h-8 bg-white/63 border-white"
|
||||||
|
labelClassName="text-xs"
|
||||||
|
/>
|
||||||
|
|
||||||
<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>
|
||||||
@@ -36,8 +60,8 @@ const TaskDetailUsersList: FC<Props> = ({ users, selectedIds, onToggle, isLoadin
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-[10px] text-description text-center py-2">{t("loading")}</div>
|
<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">
|
<div className="flex flex-col gap-2 max-h-48 overflow-y-auto" onScroll={handleScroll}>
|
||||||
{filteredUsers.map((user) => {
|
{users.map((user) => {
|
||||||
const isSelected = selectedIds.has(user.id);
|
const isSelected = selectedIds.has(user.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -52,7 +76,8 @@ const TaskDetailUsersList: FC<Props> = ({ users, selectedIds, onToggle, isLoadin
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{!filteredUsers.length ? <div className="text-[10px] text-description">{t("taskmanager.no_users_added")}</div> : null}
|
{isFetchingNextPage ? <div className="text-[10px] text-description text-center py-1">{t("loading")}</div> : null}
|
||||||
|
{!users.length ? <div className="text-[10px] text-description">{t("taskmanager.no_users_added")}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,9 +8,25 @@ type Props = {
|
|||||||
onToggle: (id: string) => void;
|
onToggle: (id: string) => void;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
isSubmitting?: boolean;
|
isSubmitting?: boolean;
|
||||||
|
hasNextPage?: boolean;
|
||||||
|
isFetchingNextPage?: boolean;
|
||||||
|
onLoadMore?: () => void;
|
||||||
|
search: string;
|
||||||
|
onSearchChange: (value: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const TaskDetailUsersPopover: FC<Props> = ({ users, selectedIds, onToggle, isLoading, isSubmitting }) => {
|
const TaskDetailUsersPopover: FC<Props> = ({
|
||||||
|
users,
|
||||||
|
selectedIds,
|
||||||
|
onToggle,
|
||||||
|
isLoading,
|
||||||
|
isSubmitting,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
onLoadMore,
|
||||||
|
search,
|
||||||
|
onSearchChange,
|
||||||
|
}) => {
|
||||||
return (
|
return (
|
||||||
<TaskDetailUsersList
|
<TaskDetailUsersList
|
||||||
users={users}
|
users={users}
|
||||||
@@ -18,6 +34,11 @@ const TaskDetailUsersPopover: FC<Props> = ({ users, selectedIds, onToggle, isLoa
|
|||||||
onToggle={onToggle}
|
onToggle={onToggle}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
|
hasNextPage={hasNextPage}
|
||||||
|
isFetchingNextPage={isFetchingNextPage}
|
||||||
|
onLoadMore={onLoadMore}
|
||||||
|
search={search}
|
||||||
|
onSearchChange={onSearchChange}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const UsersList: FC = () => {
|
|||||||
const { t } = useTranslation('global')
|
const { t } = useTranslation('global')
|
||||||
const [page, setPage] = useState<number>(1)
|
const [page, setPage] = useState<number>(1)
|
||||||
const [search, setSearch] = useState<string>('')
|
const [search, setSearch] = useState<string>('')
|
||||||
const getUsers = useGetUsers(search)
|
const getUsers = useGetUsers(search, undefined, page)
|
||||||
const deleteUser = useDeleteUser()
|
const deleteUser = useDeleteUser()
|
||||||
|
|
||||||
const handleDeleteUser = (id: string) => {
|
const handleDeleteUser = (id: string) => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as api from "../service/UserService";
|
import * as api from "../service/UserService";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery, useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
CreateGroupType,
|
CreateGroupType,
|
||||||
CreateRoleType,
|
CreateRoleType,
|
||||||
@@ -47,10 +47,25 @@ export const useCreateUser = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useGetUsers = (search: string, permission?: string) => {
|
export const useGetUsers = (search: string, permission?: string, page = 1) => {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["users", search, permission],
|
queryKey: ["users", search, permission, page],
|
||||||
queryFn: () => api.getUsers(search, permission),
|
queryFn: () => api.getUsers(search, permission, page),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useGetUsersInfinite = (search: string, permission?: string, enabled = true) => {
|
||||||
|
return useInfiniteQuery({
|
||||||
|
queryKey: ["users-infinite", search, permission],
|
||||||
|
queryFn: ({ pageParam = 1 }) => api.getUsers(search, permission, pageParam),
|
||||||
|
initialPageParam: 1,
|
||||||
|
getNextPageParam: (lastPage) => {
|
||||||
|
const currentPage = lastPage.data?.pager?.page;
|
||||||
|
const totalPages = lastPage.data?.pager?.totalPages;
|
||||||
|
|
||||||
|
return currentPage < totalPages ? currentPage + 1 : undefined;
|
||||||
|
},
|
||||||
|
enabled,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ export const createUser = async (params: CreateUserType) => {
|
|||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getUsers = async (search: string, permission?: string) => {
|
export const getUsers = async (search: string, permission?: string, page = 1) => {
|
||||||
let query = `?q=${search}`;
|
let query = `?q=${search}&page=${page}&limit=10`;
|
||||||
if (permission) query += `&permission=${permission}`;
|
if (permission) query += `&permission=${permission}`;
|
||||||
const { data } = await axios.get(`/users/admins${query}`);
|
const { data } = await axios.get(`/users/admins${query}`);
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
Reference in New Issue
Block a user