sort
This commit is contained in:
8
api.ts
8
api.ts
@@ -18,9 +18,15 @@ export const API = {
|
||||
CREATEOTP: `${API_URL_ROOT}/auth/token/create`,
|
||||
VERIFYOTP: `${API_URL_ROOT}/auth/token/verify`,
|
||||
REFRESH: `${API_URL_ROOT}/auth/refresh`,
|
||||
GOOGLE_LOGIN: `${API_URL_ROOT}/auth/google/login`
|
||||
GOOGLE_LOGIN: `${API_URL_ROOT}/auth/google/login`,
|
||||
FORGOT_PASSWORD: `${API_URL_ROOT}/auth/forgot-password`,
|
||||
},
|
||||
Admin:{
|
||||
GET_LIST_USERS: `${API_URL_ROOT}/users`,
|
||||
CHANGE_ROLE: (Id: number | string) => `${API_URL_ROOT}/users/${Id}/role`,
|
||||
DELETE_USER: (Id: number | string) => `${API_URL_ROOT}/users/${Id}`,
|
||||
RESTORE_USER: (Id: number | string) => `${API_URL_ROOT}/users/${Id}/restore`,
|
||||
GET_USER_MEDIA: (Id: number | string) => `${API_URL_ROOT}/users/${Id}/media`,
|
||||
GET_ALL_ROLE: `${API_URL_ROOT}/roles`
|
||||
}
|
||||
}
|
||||
@@ -2,39 +2,48 @@
|
||||
import ComponentCard from "@/components/common/ComponentCard";
|
||||
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
|
||||
import BasicTableOne from "@/components/tables/BasicTableOne";
|
||||
import { responseUserTable, getUserDto } from "@/interface/admin";
|
||||
import { apiGetListUser } from "@/service/adminService";
|
||||
import ChangeRoleModal from "@/components/tables/ChangeRoleModal";
|
||||
import UserDetailModal from "@/components/tables/UserDetailModal";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import { responseUserTable, getUserDto, fullDataUser } from "@/interface/admin";
|
||||
import {
|
||||
apiDeleteUser,
|
||||
apiGetListUser,
|
||||
apiRestoreUser,
|
||||
} from "@/service/adminService";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export type SortColumn = "created_at" | "updated_at" | "display_name" | "email";
|
||||
|
||||
export default function UserTable() {
|
||||
// --- States cho Pagination ---
|
||||
const [limit, setLimit] = useState<number>(5); // Default theo ảnh là 5
|
||||
const [limit, setLimit] = useState<number>(5);
|
||||
const [limitInput, setLimitInput] = useState<string>("5");
|
||||
const [page, setPage] = useState<number>(1);
|
||||
|
||||
// --- States cho Filter (Theo ảnh Swagger) ---
|
||||
const [searchTerm, setSearchTerm] = useState<string>("");
|
||||
const [authProvider, setAuthProvider] = useState<string>("");
|
||||
const [createdFrom, setCreatedFrom] = useState<string>("");
|
||||
const [createdTo, setCreatedTo] = useState<string>("");
|
||||
const [isDeleted, setIsDeleted] = useState<boolean | undefined>(undefined);
|
||||
|
||||
// Debounced states
|
||||
const [selectedUser, setSelectedUser] = useState<fullDataUser | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
const [roleUser, setRoleUser] = useState<fullDataUser | null>(null);
|
||||
const [isRoleModalOpen, setIsRoleModalOpen] = useState(false);
|
||||
|
||||
const [debouncedParams, setDebouncedParams] = useState({
|
||||
search: "",
|
||||
limit: 5,
|
||||
authProvider: "",
|
||||
});
|
||||
|
||||
// --- States cho Table ---
|
||||
const [tableData, setTableData] = useState<responseUserTable | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [sortBy, setSortBy] = useState<SortColumn | undefined>(undefined);
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
|
||||
|
||||
// 1. Xử lý Debounce cho các ô input text
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedParams({
|
||||
@@ -42,12 +51,11 @@ export default function UserTable() {
|
||||
limit: parseInt(limitInput) || 5,
|
||||
authProvider: authProvider,
|
||||
});
|
||||
setPage(1); // Reset về trang 1 khi filter thay đổi
|
||||
setPage(1);
|
||||
}, 600);
|
||||
return () => clearTimeout(handler);
|
||||
}, [searchTerm, limitInput, authProvider]);
|
||||
|
||||
// 2. Hàm fetch data
|
||||
const fetchUsers = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -73,7 +81,15 @@ export default function UserTable() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, debouncedParams, createdFrom, createdTo, isDeleted, sortBy, sortOrder]);
|
||||
}, [
|
||||
page,
|
||||
debouncedParams,
|
||||
createdFrom,
|
||||
createdTo,
|
||||
isDeleted,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
@@ -91,6 +107,42 @@ export default function UserTable() {
|
||||
|
||||
const pagination = tableData?.pagination;
|
||||
|
||||
const handleOpenDetail = (user: fullDataUser) => {
|
||||
setSelectedUser(user);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
const handleOpenRoleModal = (user: fullDataUser) => {
|
||||
setRoleUser(user);
|
||||
setIsRoleModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (user: fullDataUser) => {
|
||||
const confirmMessage = `Bạn có chắc chắn muốn khóa/xóa người dùng ${user.profile?.display_name || user.email}?`;
|
||||
if (window.confirm(confirmMessage)) {
|
||||
try {
|
||||
await apiDeleteUser(user.id);
|
||||
toast.success("Đã khóa người dùng thành công!");
|
||||
fetchUsers();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error("Đã xảy ra lỗi khi xóa!");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestore = async (user: fullDataUser) => {
|
||||
const confirmMessage = `Bạn có chắc chắn muốn khôi phục người dùng ${user.profile?.display_name || user.email}?`;
|
||||
if (window.confirm(confirmMessage)) {
|
||||
try {
|
||||
await apiRestoreUser(user.id);
|
||||
toast.success("Khôi phục người dùng thành công!");
|
||||
fetchUsers();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error("Đã xảy ra lỗi khi khôi phục!");
|
||||
}
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<PageBreadcrumb pageTitle="Quản lý người dùng" />
|
||||
@@ -112,7 +164,9 @@ export default function UserTable() {
|
||||
|
||||
{/* Auth Provider */}
|
||||
<div>
|
||||
<label className="block mb-2 text-sm font-medium">Auth Provider</label>
|
||||
<label className="block mb-2 text-sm font-medium">
|
||||
Auth Provider
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="google"
|
||||
@@ -122,11 +176,18 @@ export default function UserTable() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<label className="block mb-2 text-sm font-medium">Trạng thái xóa</label>
|
||||
<label className="block mb-2 text-sm font-medium">
|
||||
Trạng thái xóa
|
||||
</label>
|
||||
<select
|
||||
onChange={(e) => setIsDeleted(e.target.value === "" ? undefined : e.target.value === "true")}
|
||||
onChange={(e) =>
|
||||
setIsDeleted(
|
||||
e.target.value === ""
|
||||
? undefined
|
||||
: e.target.value === "true",
|
||||
)
|
||||
}
|
||||
className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700"
|
||||
>
|
||||
<option value="">Tất cả</option>
|
||||
@@ -137,7 +198,9 @@ export default function UserTable() {
|
||||
|
||||
{/* Limit */}
|
||||
<div>
|
||||
<label className="block mb-2 text-sm font-medium">Số lượng (Limit)</label>
|
||||
<label className="block mb-2 text-sm font-medium">
|
||||
Số lượng (Limit)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={limitInput}
|
||||
@@ -161,31 +224,53 @@ export default function UserTable() {
|
||||
onSort={handleSort}
|
||||
sortBy={sortBy}
|
||||
sortOrder={sortOrder}
|
||||
onViewDetail={handleOpenDetail}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Phân trang sử dụng data từ API mới */}
|
||||
<div className="flex items-center justify-between mt-6">
|
||||
<p className="text-sm text-gray-500">
|
||||
Hiển thị trang {pagination?.current_page} / {pagination?.total_pages} ({pagination?.total_records} kết quả)
|
||||
Hiển thị trang {pagination?.current_page} /{" "}
|
||||
{pagination?.total_pages} ({pagination?.total_records} kết quả)
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
disabled={page <= 1 || loading}
|
||||
onClick={() => setPage(p => p - 1)}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
className="px-4 py-2 border rounded-md disabled:opacity-50 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
Trước
|
||||
</button>
|
||||
<button
|
||||
disabled={page >= (pagination?.total_pages || 1) || loading}
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
className="px-4 py-2 border rounded-md disabled:opacity-50 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
Sau
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<UserDetailModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
user={selectedUser}
|
||||
onChangeRole={handleOpenRoleModal}
|
||||
onDelete={(u) => {
|
||||
handleDelete(u);
|
||||
setIsModalOpen(false);
|
||||
}}
|
||||
onRestore={(u) => {
|
||||
handleRestore(u);
|
||||
setIsModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ChangeRoleModal
|
||||
isOpen={isRoleModalOpen}
|
||||
onClose={() => setIsRoleModalOpen(false)}
|
||||
user={roleUser}
|
||||
onSuccess={fetchUsers}
|
||||
/>
|
||||
</ComponentCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function Profile() {
|
||||
</h3>
|
||||
<div className="space-y-6">
|
||||
<UserMetaCard data={user ?? {}} />
|
||||
<UserInfoCard data={user ?? {}} />
|
||||
<UserInfoCard data={{ ...user, openEdit: true }} />
|
||||
{(mediaData?.data?.length ?? 0) > 0 && <MediaCard data={mediaData ?? {}} />}
|
||||
<AccountDetails data={user ?? {}} />
|
||||
</div>
|
||||
|
||||
239
src/app/(full-width-pages)/(auth)/reset-password/page.tsx
Normal file
239
src/app/(full-width-pages)/(auth)/reset-password/page.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
"use client";
|
||||
import Input from "@/components/form/input/InputField";
|
||||
import Label from "@/components/form/Label";
|
||||
import { ChevronLeftIcon, EyeCloseIcon, EyeIcon } from "@/icons";
|
||||
import { apiCreateOTP, apiVerifyOTP, apiResetPassword } from "@/service/auth";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function ResetPasswordForm() {
|
||||
const [step, setStep] = useState(1);
|
||||
const [email, setEmail] = useState("");
|
||||
const [otp, setOtp] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const [errorMsg, setErrorMsg] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const isValidEmail = (email: string) => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
};
|
||||
|
||||
const isValidPassword = (pass: string) => {
|
||||
const passwordRegex = /^(?=.*[A-Z])(?=.*\d)(?=.*[\W_]).{8,}$/;
|
||||
return passwordRegex.test(pass);
|
||||
};
|
||||
|
||||
const handleSendOtp = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setErrorMsg("");
|
||||
|
||||
if (!email.trim()) {
|
||||
setErrorMsg("Vui lòng nhập email.");
|
||||
return;
|
||||
}
|
||||
if (!isValidEmail(email)) {
|
||||
setErrorMsg("Email không đúng định dạng.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
await apiCreateOTP(email);
|
||||
toast.success("Mã OTP đã được gửi đến email của bạn!");
|
||||
setStep(2);
|
||||
} catch (error) {
|
||||
setErrorMsg("Lỗi khi gửi OTP. Vui lòng kiểm tra lại email.");
|
||||
toast.error("Gửi OTP thất bại.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetPassword = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setErrorMsg("");
|
||||
|
||||
if (!otp.trim()) {
|
||||
setErrorMsg("Vui lòng nhập mã OTP.");
|
||||
return;
|
||||
}
|
||||
if (!isValidPassword(newPassword)) {
|
||||
setErrorMsg("Mật khẩu chưa đủ điều kiện bảo mật.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const verifyRes = await apiVerifyOTP(email, otp);
|
||||
const tokenId = verifyRes?.data?.token_id;
|
||||
|
||||
if (!tokenId) {
|
||||
throw new Error("OTP không hợp lệ hoặc đã hết hạn.");
|
||||
}
|
||||
|
||||
const resetPayload = {
|
||||
email: email,
|
||||
new_password: newPassword,
|
||||
token_id: tokenId,
|
||||
};
|
||||
|
||||
await apiResetPassword(resetPayload);
|
||||
|
||||
toast.success("Đổi mật khẩu thành công!");
|
||||
window.location.href = "/signin";
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Đổi mật khẩu thất bại.";
|
||||
setErrorMsg(errorMessage);
|
||||
console.error("Reset password error:", error);
|
||||
toast.error("Vui lòng kiểm tra lại mã OTP.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 w-full lg:w-1/2 overflow-y-auto no-scrollbar">
|
||||
<div className="w-full max-w-md mx-auto mb-5 sm:pt-10">
|
||||
<Link
|
||||
href="/signin"
|
||||
className="inline-flex items-center text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
Back to Sign In
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col justify-center flex-1 w-full max-w-md mx-auto">
|
||||
<div>
|
||||
<div className="mb-5 sm:mb-8">
|
||||
<h1 className="mb-2 font-semibold text-gray-800 text-title-sm dark:text-white/90 sm:text-title-md">
|
||||
{step === 1 ? "Forgot Password" : "Set New Password"}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{step === 1
|
||||
? "Enter your email address to receive an OTP code."
|
||||
: `We sent an OTP to ${email}. Please enter it along with your new password.`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{errorMsg && (
|
||||
<div className="p-3 mb-4 text-sm rounded text-error-500 bg-error-50 dark:bg-error-500/10">
|
||||
{errorMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<form onSubmit={handleSendOtp}>
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<Label>
|
||||
Email <span className="text-error-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="email"
|
||||
name="email"
|
||||
defaultValue={email}
|
||||
onChange={(e) => {
|
||||
setEmail(e.target.value);
|
||||
setErrorMsg("");
|
||||
}}
|
||||
placeholder="Enter your registered email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !email.trim()}
|
||||
className={`flex items-center justify-center w-full px-4 py-3 text-sm font-medium text-white transition rounded-lg shadow-theme-xs
|
||||
${loading || !email.trim() ? "bg-gray-400 cursor-not-allowed" : "bg-brand-500 hover:bg-brand-600"}`}
|
||||
>
|
||||
{loading ? "Sending OTP..." : "Send Reset Code"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<form onSubmit={handleResetPassword}>
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<Label>
|
||||
OTP Code <span className="text-error-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="text"
|
||||
name="otp"
|
||||
defaultValue={otp}
|
||||
onChange={(e) => {
|
||||
setOtp(e.target.value);
|
||||
setErrorMsg("");
|
||||
}}
|
||||
placeholder="Enter the 6-digit code"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>
|
||||
New Password <span className="text-error-500">*</span>
|
||||
</Label>
|
||||
<div
|
||||
className={`relative ${newPassword.length > 0 && !isValidPassword(newPassword) ? "border border-red-500 ring-1 ring-red-500 rounded-lg" : ""}`}
|
||||
>
|
||||
<Input
|
||||
name="newPassword"
|
||||
defaultValue={newPassword}
|
||||
onChange={(e) => {
|
||||
setNewPassword(e.target.value);
|
||||
setErrorMsg("");
|
||||
}}
|
||||
placeholder="Min. 8 characters"
|
||||
type={showPassword ? "text" : "password"}
|
||||
/>
|
||||
<span
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute z-30 -translate-y-1/2 cursor-pointer right-4 top-1/2"
|
||||
>
|
||||
{showPassword ? <EyeIcon /> : <EyeCloseIcon />}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p
|
||||
className={`mt-2 text-xs ${newPassword.length === 0 ? "text-gray-400" : isValidPassword(newPassword) ? "text-green-500" : "text-red-500"}`}
|
||||
>
|
||||
Mật khẩu phải chứa tối thiểu 8 ký tự, 1 chữ cái in hoa, 1 chữ số và 1 ký tự đặc biệt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep(1)}
|
||||
className="flex items-center justify-center w-1/3 px-4 py-3 text-sm font-medium text-gray-700 transition bg-gray-200 rounded-lg hover:bg-gray-300 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !isValidPassword(newPassword)}
|
||||
className={`flex items-center justify-center w-2/3 px-4 py-3 text-sm font-medium text-white transition rounded-lg shadow-theme-xs
|
||||
${loading || !isValidPassword(newPassword) ? "bg-brand-400 opacity-70 cursor-not-allowed" : "bg-brand-500 hover:bg-brand-600"}`}
|
||||
>
|
||||
{loading ? "Resetting..." : "Reset Password"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -750,3 +750,21 @@ span.flatpickr-weekday,
|
||||
opacity: 0.8;
|
||||
cursor: grabbing; /* Changes the cursor to indicate dragging */
|
||||
}
|
||||
|
||||
html {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
html {
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
-webkit-appearance: none;
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
border-radius: 5px;
|
||||
background-color: rgba(0, 0, 0, .5);
|
||||
box-shadow: 0 0 1px rgba(255, 255, 255, .5);
|
||||
}
|
||||
@@ -11,23 +11,24 @@ import Badge from "../ui/badge/Badge";
|
||||
import Image from "next/image";
|
||||
import { fullDataUser } from "@/interface/admin";
|
||||
|
||||
// Kiểu dữ liệu sort dựa trên UserTable
|
||||
type SortColumn = "created_at" | "updated_at" | "display_name" | "email";
|
||||
|
||||
interface BasicTableOneProps {
|
||||
data: fullDataUser[];
|
||||
onSort: (column: SortColumn) => void;
|
||||
onViewDetail: (user: fullDataUser) => void;
|
||||
sortBy?: SortColumn;
|
||||
sortOrder?: "asc" | "desc";
|
||||
}
|
||||
|
||||
export default function BasicTableOne({
|
||||
data,
|
||||
data,
|
||||
onSort,
|
||||
onViewDetail,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
}: BasicTableOneProps) {
|
||||
// Format ngày tháng: 08 Apr 2026
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
if (!dateString) return "-";
|
||||
const date = new Date(dateString);
|
||||
@@ -38,7 +39,6 @@ export default function BasicTableOne({
|
||||
});
|
||||
};
|
||||
|
||||
// Component icon sắp xếp
|
||||
const SortIcon = ({ column }: { column: SortColumn }) => {
|
||||
const isActive = sortBy === column;
|
||||
return (
|
||||
@@ -78,7 +78,6 @@ export default function BasicTableOne({
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-white/[0.05] dark:bg-white/[0.03]">
|
||||
<div className="max-w-full overflow-x-auto">
|
||||
{/* Đảm bảo bảng có chiều rộng tối thiểu để không bị nát layout trên mobile */}
|
||||
<div className="min-w-[1100px]">
|
||||
<Table>
|
||||
<TableHeader className="border-b border-gray-100 dark:border-white/[0.05]">
|
||||
@@ -146,6 +145,9 @@ export default function BasicTableOne({
|
||||
<SortIcon column="updated_at" />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell isHeader className="px-5 py-3 font-medium text-gray-500 text-center text-theme-xs dark:text-gray-400">
|
||||
Thao tác
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
@@ -156,7 +158,6 @@ export default function BasicTableOne({
|
||||
key={user.id}
|
||||
className="hover:bg-gray-50/50 dark:hover:bg-white/[0.01] transition-colors"
|
||||
>
|
||||
{/* Cột Tên + Avatar */}
|
||||
<TableCell className="px-5 py-4 text-start">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 overflow-hidden rounded-full flex-shrink-0 bg-gray-100 dark:bg-gray-800 flex items-center justify-center">
|
||||
@@ -185,12 +186,10 @@ export default function BasicTableOne({
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
{/* Cột Email */}
|
||||
<TableCell className="px-5 py-4 text-gray-600 text-start text-theme-sm dark:text-gray-400">
|
||||
{user.email}
|
||||
</TableCell>
|
||||
|
||||
{/* Cột Roles (Badge list) */}
|
||||
<TableCell className="px-5 py-4 text-start text-theme-sm">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{user.roles && user.roles.length > 0 ? (
|
||||
@@ -208,7 +207,6 @@ export default function BasicTableOne({
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
{/* Cột Trạng thái (Sử dụng Badge component) */}
|
||||
<TableCell className="px-5 py-4 text-start">
|
||||
<Badge
|
||||
size="sm"
|
||||
@@ -219,15 +217,22 @@ export default function BasicTableOne({
|
||||
</Badge>
|
||||
</TableCell>
|
||||
|
||||
{/* Cột Ngày tham gia */}
|
||||
<TableCell className="px-5 py-4 text-gray-600 text-theme-sm dark:text-gray-400">
|
||||
{formatDate(user.created_at)}
|
||||
</TableCell>
|
||||
|
||||
{/* Cột Ngày cập nhật */}
|
||||
<TableCell className="px-5 py-4 text-gray-600 text-theme-sm dark:text-gray-400">
|
||||
{formatDate(user.updated_at)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="px-5 py-4 text-center">
|
||||
<button
|
||||
onClick={() => onViewDetail(user)}
|
||||
className="text-brand-500 hover:text-brand-600 font-medium text-theme-sm transition-colors"
|
||||
>
|
||||
Chi tiết
|
||||
</button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
|
||||
141
src/components/tables/ChangeRoleModal.tsx
Normal file
141
src/components/tables/ChangeRoleModal.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Modal } from "../ui/modal";
|
||||
import Button from "../ui/button/Button";
|
||||
import { fullDataUser } from "@/interface/admin";
|
||||
import { apiGetAllRole, apiChangeRole } from "@/service/adminService";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Role {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface ChangeRoleModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
user: fullDataUser | null;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export default function ChangeRoleModal({ isOpen, onClose, user, onSuccess }: ChangeRoleModalProps) {
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [selectedRoleIds, setSelectedRoleIds] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fetchingRoles, setFetchingRoles] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && user) {
|
||||
setFetchingRoles(true);
|
||||
apiGetAllRole()
|
||||
.then((res) => {
|
||||
if (res?.status) setRoles(res.data);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Lỗi fetch roles:", err);
|
||||
toast.error("Không thể lấy danh sách vai trò");
|
||||
})
|
||||
.finally(() => setFetchingRoles(false));
|
||||
|
||||
const currentUserRoles = user.roles?.map((r) => r.id) || [];
|
||||
setSelectedRoleIds(currentUserRoles);
|
||||
}
|
||||
}, [isOpen, user]);
|
||||
|
||||
const handleToggleRole = (roleId: string) => {
|
||||
setSelectedRoleIds((prev) =>
|
||||
prev.includes(roleId) ? prev.filter((id) => id !== roleId) : [...prev, roleId]
|
||||
);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const payload = {
|
||||
role_ids: selectedRoleIds,
|
||||
user_id: user.id,
|
||||
};
|
||||
console.log("Payload gửi lên API:", payload);
|
||||
await apiChangeRole(user.id, payload);
|
||||
toast.success("Cập nhật vai trò thành công!");
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Lỗi khi cập nhật vai trò!");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} className="max-w-[400px] m-4">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl p-6 shadow-lg border border-gray-100 dark:border-gray-800 w-full relative">
|
||||
|
||||
<div className="mb-6 pr-6">
|
||||
<h3 className="text-lg font-semibold text-gray-800 dark:text-white">
|
||||
Vai trò người dùng
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400 truncate">
|
||||
{user.profile?.display_name || user.email}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
{fetchingRoles ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="w-6 h-6 border-2 border-gray-200 border-t-brand-500 rounded-full animate-spin"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col space-y-1 max-h-[300px] overflow-y-auto custom-scrollbar -mx-2 px-2">
|
||||
{roles.map((role) => {
|
||||
const isSelected = selectedRoleIds.includes(role.id);
|
||||
return (
|
||||
<label
|
||||
key={role.id}
|
||||
className="flex items-center gap-3 p-2.5 rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => handleToggleRole(role.id)}
|
||||
className="w-4 h-4 text-brand-500 border-gray-300 rounded focus:ring-brand-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
/>
|
||||
<span className={`text-sm ${isSelected ? "text-gray-900 dark:text-white font-medium" : "text-gray-700 dark:text-gray-300"}`}>
|
||||
{role.name}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-3 mt-8">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={loading || fetchingRoles}
|
||||
>
|
||||
Hủy
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
type="submit"
|
||||
disabled={loading || fetchingRoles}
|
||||
className="min-w-[100px]"
|
||||
>
|
||||
{loading ? "Đang lưu..." : "Lưu"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
127
src/components/tables/UserDetailModal.tsx
Normal file
127
src/components/tables/UserDetailModal.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
import { Modal } from "../ui/modal";
|
||||
import UserMetaCard from "@/components/user-profile/UserMetaCard";
|
||||
import UserInfoCard from "@/components/user-profile/UserInfoCard";
|
||||
import { fullDataUser } from "@/interface/admin";
|
||||
import { useEffect, useState } from "react";
|
||||
import { MediaDto } from "@/interface/media";
|
||||
import { apiGetUserMedia } from "@/service/adminService";
|
||||
import MediaCard from "@/components/user-profile/Media";
|
||||
|
||||
interface UserDetailModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
user: fullDataUser | null;
|
||||
onChangeRole: (user: fullDataUser) => void;
|
||||
onDelete: (user: fullDataUser) => void;
|
||||
onRestore: (user: fullDataUser) => void;
|
||||
}
|
||||
|
||||
export default function UserDetailModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
user,
|
||||
onChangeRole,
|
||||
onDelete,
|
||||
onRestore,
|
||||
}: UserDetailModalProps) {
|
||||
const [mediaData, setMediaData] = useState<MediaDto | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const formattedData = { data: user };
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.id && isOpen) {
|
||||
const fetchUserMedia = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const mediaResponse = await apiGetUserMedia(user.id);
|
||||
setMediaData(mediaResponse);
|
||||
} catch (err) {
|
||||
console.error("Lỗi fetch media:", err);
|
||||
setMediaData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchUserMedia();
|
||||
}
|
||||
}, [user?.id, isOpen]);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} className="max-w-[850px] m-4">
|
||||
<div className="no-scrollbar relative w-full max-w-[850px] overflow-y-auto rounded-3xl bg-white p-4 dark:bg-gray-900 lg:p-8">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h3 className="text-xl font-bold text-gray-800 dark:text-white">
|
||||
Chi tiết người dùng
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 custom-scrollbar max-h-[65vh] overflow-y-auto pr-2">
|
||||
<UserMetaCard data={formattedData as any} />
|
||||
<UserInfoCard data={formattedData as any} />
|
||||
|
||||
<div className="min-h-[150px] relative">
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center py-10 space-y-3">
|
||||
<div className="w-10 h-10 border-4 border-gray-200 border-t-brand-500 rounded-full animate-spin"></div>
|
||||
<p className="text-sm text-gray-500 animate-pulse">Đang tải tài liệu...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{(mediaData?.data?.length ?? 0) > 0 ? (
|
||||
<MediaCard data={mediaData ?? {}} />
|
||||
) : (
|
||||
<div className="p-5 border border-dashed border-gray-200 rounded-2xl text-center text-gray-400 text-sm">
|
||||
Người dùng này chưa có dữ liệu media.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 pt-6 border-t border-gray-100 dark:border-white/[0.05] flex items-center justify-between">
|
||||
<div className="text-sm text-gray-500">
|
||||
Thao tác quản trị viên
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => onChangeRole(user)}
|
||||
className="px-4 py-2 text-sm font-medium text-purple-600 bg-purple-50 rounded-lg hover:bg-purple-100 dark:bg-purple-500/10 dark:text-purple-400 dark:hover:bg-purple-500/20 transition-colors"
|
||||
>
|
||||
Đổi vai trò
|
||||
</button>
|
||||
|
||||
{user.is_deleted ? (
|
||||
<button
|
||||
onClick={() => onRestore(user)}
|
||||
className="px-4 py-2 text-sm font-medium text-green-600 bg-green-50 rounded-lg hover:bg-green-100 dark:bg-green-500/10 dark:text-green-400 dark:hover:bg-green-500/20 transition-colors"
|
||||
>
|
||||
Khôi phục
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => onDelete(user)}
|
||||
className="px-4 py-2 text-sm font-medium text-red-600 bg-red-50 rounded-lg hover:bg-red-100 dark:bg-red-500/10 dark:text-red-400 dark:hover:bg-red-500/20 transition-colors"
|
||||
>
|
||||
Khóa / Xóa
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -6,8 +6,8 @@ interface ModalProps {
|
||||
onClose: () => void;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
showCloseButton?: boolean; // New prop to control close button visibility
|
||||
isFullscreen?: boolean; // Default to false for backwards compatibility
|
||||
showCloseButton?: boolean;
|
||||
isFullscreen?: boolean;
|
||||
}
|
||||
|
||||
export const Modal: React.FC<ModalProps> = ({
|
||||
@@ -15,7 +15,7 @@ export const Modal: React.FC<ModalProps> = ({
|
||||
onClose,
|
||||
children,
|
||||
className,
|
||||
showCloseButton = true, // Default to true for backwards compatibility
|
||||
showCloseButton = true,
|
||||
isFullscreen = false,
|
||||
}) => {
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
@@ -52,13 +52,13 @@ export const Modal: React.FC<ModalProps> = ({
|
||||
|
||||
const contentClasses = isFullscreen
|
||||
? "w-full h-full"
|
||||
: "relative w-full rounded-3xl bg-white dark:bg-gray-900";
|
||||
: "relative w-full rounded-xl bg-white dark:bg-gray-900";
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center overflow-y-auto modal z-99999">
|
||||
{!isFullscreen && (
|
||||
<div
|
||||
className="fixed inset-0 h-full w-full bg-gray-400/50 backdrop-blur-[32px]"
|
||||
className="fixed inset-0 h-full w-full bg-black/50 backdrop-blur-[2px]"
|
||||
onClick={onClose}
|
||||
></div>
|
||||
)}
|
||||
|
||||
@@ -95,7 +95,7 @@ export default function UserInfoCard({ data }: { data: UserMetaCardProps }) {
|
||||
Phone
|
||||
</p>
|
||||
<p className="text-sm font-medium text-gray-800 dark:text-white/90">
|
||||
{data.data?.profile?.phone || "+123 456 7890"}
|
||||
{data.data?.profile?.phone || "+XXX XXX XXX"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -132,26 +132,27 @@ export default function UserInfoCard({ data }: { data: UserMetaCardProps }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={openModal}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-full border border-gray-300 bg-white px-4 py-3 text-sm font-medium text-gray-700 shadow-theme-xs hover:bg-gray-50 hover:text-gray-800 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.03] dark:hover:text-gray-200 lg:inline-flex lg:w-auto"
|
||||
>
|
||||
{/* SVG giữ nguyên */}
|
||||
<svg
|
||||
className="fill-current"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 18 18"
|
||||
{data.openEdit && (
|
||||
<button
|
||||
onClick={openModal}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-full border border-gray-300 bg-white px-4 py-3 text-sm font-medium text-gray-700 shadow-theme-xs hover:bg-gray-50 hover:text-gray-800 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.03] dark:hover:text-gray-200 lg:inline-flex lg:w-auto"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M15.0911 2.78206C14.2125 1.90338 12.7878 1.90338 11.9092 2.78206L4.57524 10.116C4.26682 10.4244 4.0547 10.8158 3.96468 11.2426L3.31231 14.3352C3.25997 14.5833 3.33653 14.841 3.51583 15.0203C3.69512 15.1996 3.95286 15.2761 4.20096 15.2238L7.29355 14.5714C7.72031 14.4814 8.11172 14.2693 8.42013 13.9609L15.7541 6.62695C16.6327 5.74827 16.6327 4.32365 15.7541 3.44497L15.0911 2.78206ZM12.9698 3.84272C13.2627 3.54982 13.7376 3.54982 14.0305 3.84272L14.6934 4.50563C14.9863 4.79852 14.9863 5.2734 14.6934 5.56629L14.044 6.21573L12.3204 4.49215L12.9698 3.84272ZM11.2597 5.55281L5.6359 11.1766C5.53309 11.2794 5.46238 11.4099 5.43238 11.5522L5.01758 13.5185L6.98394 13.1037C7.1262 13.0737 7.25666 13.003 7.35947 12.9002L12.9833 7.27639L11.2597 5.55281Z"
|
||||
fill=""
|
||||
/>
|
||||
</svg>
|
||||
Edit
|
||||
</button>
|
||||
<svg
|
||||
className="fill-current"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 18 18"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M15.0911 2.78206C14.2125 1.90338 12.7878 1.90338 11.9092 2.78206L4.57524 10.116C4.26682 10.4244 4.0547 10.8158 3.96468 11.2426L3.31231 14.3352C3.25997 14.5833 3.33653 14.841 3.51583 15.0203C3.69512 15.1996 3.95286 15.2761 4.20096 15.2238L7.29355 14.5714C7.72031 14.4814 8.11172 14.2693 8.42013 13.9609L15.7541 6.62695C16.6327 5.74827 16.6327 4.32365 15.7541 3.44497L15.0911 2.78206ZM12.9698 3.84272C13.2627 3.54982 13.7376 3.54982 14.0305 3.84272L14.6934 4.50563C14.9863 4.79852 14.9863 5.2734 14.6934 5.56629L14.044 6.21573L12.3204 4.49215L12.9698 3.84272ZM11.2597 5.55281L5.6359 11.1766C5.53309 11.2794 5.46238 11.4099 5.43238 11.5522L5.01758 13.5185L6.98394 13.1037C7.1262 13.0737 7.25666 13.003 7.35947 12.9002L12.9833 7.27639L11.2597 5.55281Z"
|
||||
fill=""
|
||||
/>
|
||||
</svg>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Modal isOpen={isOpen} onClose={closeModal} className="max-w-[700px] m-4">
|
||||
|
||||
@@ -50,4 +50,5 @@ export interface UserMetaCardProps {
|
||||
message?: string;
|
||||
status?: boolean;
|
||||
data?: Data;
|
||||
openEdit?: boolean;
|
||||
}
|
||||
@@ -3,8 +3,31 @@ import { API } from "../../api";
|
||||
import { getUserDto } from "@/interface/admin";
|
||||
|
||||
export const apiGetListUser = async (payload: getUserDto) => {
|
||||
const response = await api.get(API.Admin.GET_LIST_USERS, {
|
||||
const response = await api.get(API.Admin.GET_LIST_USERS, {
|
||||
params: payload,
|
||||
});
|
||||
return response?.data;
|
||||
};
|
||||
|
||||
export const apiChangeRole = async (id: string, payload: any) => {
|
||||
const response = await api.patch(API.Admin.CHANGE_ROLE(id), payload);
|
||||
console.log("Response từ API sau khi đổi role:", response);
|
||||
return response?.data;
|
||||
};
|
||||
export const apiDeleteUser = async (id: string) => {
|
||||
const response = await api.delete(API.Admin.DELETE_USER(id));
|
||||
return response?.data;
|
||||
};
|
||||
export const apiRestoreUser = async (id: string) => {
|
||||
const response = await api.patch(API.Admin.RESTORE_USER(id));
|
||||
return response?.data;
|
||||
};
|
||||
export const apiGetAllRole = async () => {
|
||||
const response = await api.get(API.Admin.GET_ALL_ROLE);
|
||||
return response?.data;
|
||||
};
|
||||
|
||||
export const apiGetUserMedia = async (id: string) => {
|
||||
const response = await api.get(API.Admin.GET_USER_MEDIA(id));
|
||||
return response?.data;
|
||||
};
|
||||
|
||||
@@ -31,6 +31,11 @@ export const apiSignIn = async (payload: any) => {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const apiResetPassword = async (payload: any) => {
|
||||
const response = await api.post(API.Auth.FORGOT_PASSWORD, payload);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const apiGetCurrentUser = async () => {
|
||||
const response = await api.get(API.User.CURRENT);
|
||||
return response?.data;
|
||||
|
||||
Reference in New Issue
Block a user