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