This commit is contained in:
2026-04-08 16:16:26 +07:00
parent 8f71d46652
commit 30a2286f52
13 changed files with 718 additions and 67 deletions

View File

@@ -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>
))
) : (

View 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>
);
}

View 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 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>
);
}

View File

@@ -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>
)}

View File

@@ -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">