371 lines
10 KiB
TypeScript
371 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import _ from "lodash";
|
|
import {
|
|
Archive,
|
|
Download,
|
|
Plus,
|
|
Share,
|
|
Star,
|
|
Trash2,
|
|
UserPlus,
|
|
} from "lucide-react";
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import { useSearchParams } from "react-router";
|
|
import { toast } from "sonner";
|
|
import { userApi } from "~/api/user-api.service";
|
|
import {
|
|
DataTable,
|
|
type Column,
|
|
type TableState,
|
|
} from "~/components/core/data-table";
|
|
import Loader from "~/components/loader";
|
|
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
|
|
import { Badge } from "~/components/ui/badge";
|
|
import { Button } from "~/components/ui/button";
|
|
import { delay } from "~/features/delay";
|
|
import {
|
|
stateToURLQuery,
|
|
urlQueryToState,
|
|
} from "~/features/state-url-converter";
|
|
import { useAppSelector } from "~/hooks/use-app-dispatch";
|
|
import { useUsersContext } from "~/layouts/contexts/user-layout.context";
|
|
import type { RootState } from "~/store";
|
|
import UserModal, { useUserModal } from "./components/users/user-modal";
|
|
|
|
export default function List() {
|
|
const { setEdit } = useUserModal();
|
|
const { setTitle, setDescription, setAction } = useUsersContext();
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
|
|
const prevStates = useRef<Record<string, any>>(null);
|
|
|
|
const [initStates, setInitStates] = useState<Record<string, any> | undefined>(
|
|
undefined
|
|
);
|
|
|
|
const [states, setStates] = useState<TableState<IUser> | undefined>(
|
|
undefined
|
|
);
|
|
|
|
const { user } = useAppSelector((state: RootState) => state.app);
|
|
|
|
const handleDelete = async (data: IUser) => {
|
|
const result = await userApi.delete(data);
|
|
|
|
if (!result) return;
|
|
|
|
refetch();
|
|
};
|
|
|
|
const handleBulkDelete = async (selectedUsers: IUser[]) => {
|
|
const result = await userApi.bulkDelete(selectedUsers);
|
|
|
|
if (!result) return;
|
|
|
|
refetch();
|
|
|
|
toast.info(`Đã xóa ${selectedUsers.length} người dùng thành công.`, {
|
|
duration: 3000,
|
|
description: "Các người dùng đã được xóa khỏi hệ thống.",
|
|
});
|
|
};
|
|
|
|
const columns: Column<IUser>[] = [
|
|
{
|
|
key: "email",
|
|
label: "Nhân viên",
|
|
displayType: "custom",
|
|
sortable: true,
|
|
render(value, row) {
|
|
return (
|
|
<div className="flex items-center space-x-3">
|
|
<Avatar className="h-9 w-9">
|
|
<AvatarImage src={row.avatar} alt={"employee.name"} />
|
|
<AvatarFallback className="text-sm">
|
|
{row.username.charAt(0)}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div>
|
|
<div className="font-medium">{row.username}</div>
|
|
<div className="text-sm text-gray-500">{row.email}</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
key: "roles",
|
|
label: "Vai trò",
|
|
displayType: "custom",
|
|
render: (value, row) => {
|
|
if (!row.roles.length) {
|
|
return <Badge variant={"secondary"}>Chưa có vai trò</Badge>;
|
|
}
|
|
|
|
return (
|
|
<div className="flex items-center gap-1 max-w-[200px] flex-wrap">
|
|
{row.roles.map((role) => {
|
|
return <Badge key={role.id}>{role.role_name}</Badge>;
|
|
})}
|
|
</div>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
key: "active",
|
|
label: "Trạng thái",
|
|
displayType: "status",
|
|
sortable: true,
|
|
displayOptions: {
|
|
statusMap: {
|
|
true: {
|
|
label: "Hoạt động",
|
|
variant: "default",
|
|
color: "bg-green-100 text-green-800 border-green-200",
|
|
},
|
|
false: {
|
|
label: "Ngừng hoạt động",
|
|
variant: "secondary",
|
|
color: "bg-gray-100 text-gray-800 border-gray-200",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
key: "is_login_enabled",
|
|
label: "Quyền truy cập",
|
|
displayType: "status",
|
|
sortable: true,
|
|
displayOptions: {
|
|
statusMap: {
|
|
true: {
|
|
label: "Có",
|
|
variant: "default",
|
|
color: "bg-green-100 text-green-800 border-green-200",
|
|
},
|
|
false: {
|
|
label: "Không",
|
|
variant: "secondary",
|
|
color: "bg-gray-100 text-gray-800 border-gray-200",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
|
|
{
|
|
key: "created_at",
|
|
label: "Ngày tham gia",
|
|
displayType: "datetime",
|
|
sortable: true,
|
|
},
|
|
];
|
|
|
|
const filterOptions: FilterOption[] = [
|
|
{
|
|
key: "email",
|
|
label: "Email",
|
|
type: "text" as const,
|
|
},
|
|
{
|
|
key: "first_name",
|
|
label: "First name",
|
|
type: "text" as const,
|
|
},
|
|
{
|
|
key: "last_name",
|
|
label: "Last name",
|
|
type: "text" as const,
|
|
},
|
|
{
|
|
key: "created_at",
|
|
label: "Ngày tham gia",
|
|
type: "dateRange" as const,
|
|
},
|
|
];
|
|
|
|
const bulkActions = [
|
|
{
|
|
key: "delete",
|
|
label: "Xóa",
|
|
icon: <Trash2 className="h-4 w-4 text-destructive" />,
|
|
variant: "destructive" as const,
|
|
action: handleBulkDelete,
|
|
confirmMessage:
|
|
"Bạn có chắc chắn muốn xóa các người dùng đã chọn? Hành động này không thể hoàn tác.",
|
|
},
|
|
];
|
|
|
|
// 🎯 Custom Actions - Đa dạng và có điều kiện
|
|
const customActions = [
|
|
// 📧 Communication actions
|
|
|
|
// 📊 Export actions
|
|
{
|
|
key: "export-profile",
|
|
label: "Xuất hồ sơ",
|
|
icon: <Download className="h-4 w-4" />,
|
|
action: (employee: any) => {},
|
|
},
|
|
|
|
// 🌟 Rating actions
|
|
{
|
|
key: "add-review",
|
|
label: "Thêm đánh giá",
|
|
icon: <Star className="h-4 w-4" />,
|
|
action: (employee: any) => {},
|
|
show: (employee: any) =>
|
|
employee.status === "active" && !employee.isManager, // Chỉ đánh giá nhân viên, không đánh giá manager
|
|
confirmMessage:
|
|
"Bạn có chắc chắn muốn xóa các người dùng đã chọn? Hành động này không thể hoàn tác.",
|
|
},
|
|
|
|
// 👥 Management actions
|
|
{
|
|
key: "promote-to-manager",
|
|
label: "Thăng chức Manager",
|
|
icon: <UserPlus className="h-4 w-4" />,
|
|
variant: "default" as const,
|
|
action: (employee: any) => {},
|
|
show: (employee: any) =>
|
|
employee.status === "active" &&
|
|
!employee.isManager &&
|
|
(employee.level === "Senior" || employee.level === "Mid"), // Chỉ Senior/Mid mới có thể thăng chức
|
|
},
|
|
|
|
// 📤 Share actions
|
|
{
|
|
key: "share-profile",
|
|
label: "Chia sẻ hồ sơ",
|
|
icon: <Share className="h-4 w-4" />,
|
|
action: (employee: any) => {
|
|
const profileUrl = `${window.location.origin}/employees/${employee.id}`;
|
|
navigator.clipboard.writeText(profileUrl);
|
|
},
|
|
},
|
|
|
|
// 📁 Archive actions
|
|
{
|
|
key: "archive",
|
|
label: "Lưu trữ",
|
|
icon: <Archive className="h-4 w-4" />,
|
|
variant: "secondary" as const,
|
|
action: (employee: any) => {},
|
|
show: (employee: any) => employee.status === "terminated", // Chỉ lưu trữ nhân viên đã nghỉ
|
|
},
|
|
];
|
|
|
|
useEffect(() => {
|
|
setTitle("Danh sách nhân viên");
|
|
setDescription("Bạn có thể thêm, sửa, xóa và tìm kiếm nhân viên tại đây.");
|
|
setAction?.(
|
|
<UserModal onSubmited={refetch}>
|
|
<Button className="cursor-pointer">
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
Thêm nhân viên
|
|
</Button>
|
|
</UserModal>
|
|
);
|
|
}, []);
|
|
|
|
// Init state từ URL
|
|
useEffect(() => {
|
|
const s = urlQueryToState(searchParams);
|
|
setInitStates(s);
|
|
setStates((prev) => (prev ? prev : s) as TableState<IUser>); // chỉ gán nếu chưa có state
|
|
}, []);
|
|
|
|
// Chuyển state sang query string + kiểm tra nếu khác với lần trước mới update
|
|
useEffect(() => {
|
|
if (!states) return;
|
|
|
|
const query = stateToURLQuery(states);
|
|
setSearchParams(query);
|
|
|
|
const { data, ...curStates } = states;
|
|
if (_.isEqual(curStates, prevStates.current)) return;
|
|
|
|
prevStates.current = curStates;
|
|
}, [states]);
|
|
|
|
// 👉 Ổn định queryKey để tránh refetch không cần thiết
|
|
const queryKey = useMemo(() => ["users", states], [states]);
|
|
|
|
const { data, isLoading, refetch } = useQuery({
|
|
queryKey,
|
|
queryFn: async () => {
|
|
if (!states) return [];
|
|
await delay(300); // Giả lập delay để thấy loading
|
|
const res = await userApi.index(states as TableState<IUser>);
|
|
return res;
|
|
},
|
|
enabled: !!states,
|
|
});
|
|
|
|
if (!initStates) return <Loader />;
|
|
|
|
return (
|
|
<div className="">
|
|
<DataTable
|
|
initialState={initStates}
|
|
customActions={customActions}
|
|
data={data?.data || []}
|
|
columns={columns}
|
|
searchKeys={["email"]}
|
|
filterOptions={filterOptions}
|
|
selectable={true}
|
|
bulkActions={bulkActions}
|
|
pagination={{
|
|
currentPage: data?.current_page || 1,
|
|
pageSize: data?.per_page || 10,
|
|
totalItems: data?.total || 0,
|
|
totalPages: data?.last_page || 0,
|
|
startIndex: data?.from || 0,
|
|
endIndex: data?.to || 0,
|
|
}}
|
|
loading={isLoading}
|
|
onPaginationChange={(p) => {
|
|
setStates(
|
|
(prev) =>
|
|
({
|
|
...prev,
|
|
pagination: {
|
|
...prev?.pagination,
|
|
currentPage: p.currentPage,
|
|
pageSize: p.pageSize,
|
|
},
|
|
} as TableState<IUser>)
|
|
);
|
|
}}
|
|
onFilterChange={({ search, ...filters }) => {
|
|
setStates(
|
|
(prev) =>
|
|
({
|
|
...prev,
|
|
filters,
|
|
search,
|
|
} as TableState<IUser>)
|
|
);
|
|
}}
|
|
onSortChange={(sort) => {
|
|
setStates(
|
|
(prev) =>
|
|
({
|
|
...prev,
|
|
sort,
|
|
} as TableState<IUser>)
|
|
);
|
|
}}
|
|
onDelete={handleDelete}
|
|
onEdit={setEdit}
|
|
options={{
|
|
disableDel(data) {
|
|
return data.id === user?.id;
|
|
},
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|