update application table information

This commit is contained in:
2026-04-17 09:47:04 +07:00
parent a366711d12
commit 9824f3b439
6 changed files with 278 additions and 272 deletions

View File

@@ -186,7 +186,8 @@ export default function HistorianApplicationPage() {
}; };
const pagination = tableData?.pagination; const pagination = tableData?.pagination;
// console.log("Pagination info:", pagination);
return ( return (
<div> <div>
<PageBreadcrumb pageTitle="Quản lý hồ sơ" /> <PageBreadcrumb pageTitle="Quản lý hồ sơ" />

View File

@@ -18,7 +18,6 @@ import { LIMIT_ITEM_TABLE } from "../../../../../../constant";
export type SortColumn = "created_at" | "updated_at" | "display_name" | "email"; export type SortColumn = "created_at" | "updated_at" | "display_name" | "email";
// Hàm helper format ngày giờ giống với Historian
const formatDateTimeToISO = ( const formatDateTimeToISO = (
dateStr: string, dateStr: string,
timeStr: string, timeStr: string,
@@ -42,7 +41,6 @@ export default function UserTable() {
const [authProvider, setAuthProvider] = useState<string>(""); const [authProvider, setAuthProvider] = useState<string>("");
const [isDeleted, setIsDeleted] = useState<boolean | undefined>(undefined); const [isDeleted, setIsDeleted] = useState<boolean | undefined>(undefined);
// --- THÊM STATE CHO NGÀY GIỜ ---
const [fromDate, setFromDate] = useState<string>(""); const [fromDate, setFromDate] = useState<string>("");
const [fromTime, setFromTime] = useState<string>(""); const [fromTime, setFromTime] = useState<string>("");
const [toDate, setToDate] = useState<string>(""); const [toDate, setToDate] = useState<string>("");
@@ -53,7 +51,6 @@ export default function UserTable() {
const [roleUser, setRoleUser] = useState<fullDataUser | null>(null); const [roleUser, setRoleUser] = useState<fullDataUser | null>(null);
const [isRoleModalOpen, setIsRoleModalOpen] = useState(false); const [isRoleModalOpen, setIsRoleModalOpen] = useState(false);
// Cập nhật params để chứa thêm ngày giờ
const [debouncedParams, setDebouncedParams] = useState({ const [debouncedParams, setDebouncedParams] = useState({
search: "", search: "",
limit: 5, limit: 5,
@@ -69,7 +66,6 @@ export default function UserTable() {
const [sortBy, setSortBy] = useState<SortColumn | undefined>(undefined); const [sortBy, setSortBy] = useState<SortColumn | undefined>(undefined);
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
// Hàm Reset bộ lọc
const handleReset = () => { const handleReset = () => {
setSearchTerm(""); setSearchTerm("");
setAuthProvider(""); setAuthProvider("");
@@ -136,7 +132,6 @@ export default function UserTable() {
const fetchUsers = useCallback(async () => { const fetchUsers = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
// Dùng any để tránh lỗi TS nếu interface getUserDto chưa update
const payload: any = { const payload: any = {
page: page, page: page,
limit: debouncedParams.limit, limit: debouncedParams.limit,
@@ -190,6 +185,8 @@ export default function UserTable() {
}; };
const pagination = tableData?.pagination; const pagination = tableData?.pagination;
console.log(pagination);
const handleOpenDetail = (user: fullDataUser) => { const handleOpenDetail = (user: fullDataUser) => {
setSelectedUser(user); setSelectedUser(user);

View File

@@ -1,22 +1,18 @@
"use client"; "use client";
import React, { useEffect, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import Image from "next/image"; import Image from "next/image";
import Swal from "sweetalert2"; import Swal from "sweetalert2";
import { ApplicationDto } from "@/interface/historian"; import { ApplicationDto } from "@/interface/historian";
import { import { apiUpdateApplicationStatus } from "@/service/adminService";
apiGetUserById,
apiUpdateApplicationStatus,
} from "@/service/adminService";
import { getMediaById } from "@/service/mediaService";
import Link from "next/link"; import Link from "next/link";
import { URL_MEDIA } from "../../../api"; import { URL_MEDIA } from "../../../api";
interface Props { interface Props {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
application: ApplicationDto | null; application: any;
onRefresh: () => void; // Gọi lại hàm fetch danh sách sau khi duyệt xong onRefresh: () => void;
} }
export default function ApplicationDetailModal({ export default function ApplicationDetailModal({
@@ -25,80 +21,37 @@ export default function ApplicationDetailModal({
application, application,
onRefresh, onRefresh,
}: Props) { }: Props) {
const [userData, setUserData] = useState<any>(null);
const [mediaList, setMediaList] = useState<any[]>([]);
const [reviewNote, setReviewNote] = useState(""); const [reviewNote, setReviewNote] = useState("");
const [loadingContent, setLoadingContent] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => { useEffect(() => {
if (isOpen && application) { if (isOpen && application) {
setReviewNote(application.review_note || ""); setReviewNote(application.review_note || "");
fetchDetails();
} else { } else {
setUserData(null);
setMediaList([]);
setReviewNote(""); setReviewNote("");
} }
}, [isOpen, application]); }, [isOpen, application]);
const fetchDetails = async () => {
if (!application) return;
setLoadingContent(true);
try {
const userRes = await apiGetUserById(application.user_id);
// console.log("User data:", userRes);
if (userRes?.data) setUserData(userRes.data);
if (application.media && application.media.length > 0) {
const mediaPromises = application.media.map((m: any) =>
getMediaById(m.id),
);
const mediaResponses = await Promise.all(mediaPromises);
setMediaList(mediaResponses.map((res) => res?.data || res));
}
} catch (error) {
console.error("Lỗi khi tải chi tiết:", error);
} finally {
setLoadingContent(false);
}
};
const handleUpdateStatus = async (status: "APPROVED" | "REJECTED") => { const handleUpdateStatus = async (status: "APPROVED" | "REJECTED") => {
if (!application) return; if (!application) return;
if (status === "REJECTED" && !reviewNote.trim()) { if (!reviewNote.trim()) {
Swal.fire( textareaRef.current?.focus();
"Cảnh báo",
"Vui lòng nhập lý do từ chối vào ô Ghi chú!",
"warning",
);
return; return;
} }
try { try {
setIsSubmitting(true); setIsSubmitting(true);
const payload = { await apiUpdateApplicationStatus(application.id, {
status: status, status,
review_note: reviewNote, review_note: reviewNote,
}; });
// console.log("Payload gửi lên API:", payload, "Application ID:", application.id);
await apiUpdateApplicationStatus(application.id, payload);
Swal.fire( Swal.fire("Thành công!", "Trạng thái hồ sơ đã được cập nhật.", "success");
"Thành công!",
`Hồ sơ đã được ${status === "APPROVED" ? "Phê duyệt" : "Từ chối"}.`,
"success",
);
onRefresh(); onRefresh();
onClose(); onClose();
} catch (error) { } catch (error) {
console.error("Lỗi cập nhật trạng thái:", error); Swal.fire("Lỗi", "Không thể cập nhật trạng thái.", "error");
Swal.fire("Lỗi", "Không thể cập nhật trạng thái lúc này.", "error");
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
@@ -106,40 +59,18 @@ export default function ApplicationDetailModal({
if (!isOpen || !application) return null; if (!isOpen || !application) return null;
const handleOpenFile = (media: any) => { const userData = application.user || {};
if (!media.url) { const mediaList = application.media || [];
Swal.fire("Lỗi", "Không tìm thấy đường dẫn file", "error");
return;
}
const isImage = media.url.match(/\.(jpeg|jpg|gif|png)$/i);
const isPdf = media.url.match(/\.(pdf)$/i);
if (isImage || isPdf) {
window.open(media.url, "_blank");
} else {
const link = document.createElement("a");
link.href = media.url;
link.download = media.original_name || "download";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
};
// console.log("ApplicationDetailModal:", application);
return ( return (
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"> <div className="fixed inset-0 z-999 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<div className="w-full max-w-4xl max-h-[90vh] bg-white rounded-2xl shadow-xl dark:bg-gray-900 flex flex-col overflow-hidden"> <div className="w-full max-w-4xl max-h-[90vh] bg-white rounded-2xl dark:bg-gray-900 flex flex-col overflow-hidden text-gray-800 dark:text-gray-200">
{/* HEADER */} {/* HEADER */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-800"> <div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-800">
<h3 className="text-xl font-semibold text-gray-800 dark:text-white"> <h3 className="text-xl font-semibold">Chi tiết yêu cầu nâng cấp</h3>
Chi tiết yêu cầu nâng cấp
</h3>
<button <button
onClick={onClose} onClick={onClose}
className="p-2 text-gray-500 transition-colors rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 dark:text-gray-400" className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-full transition-colors"
> >
<svg <svg
className="w-5 h-5" className="w-5 h-5"
@@ -159,163 +90,146 @@ export default function ApplicationDetailModal({
{/* BODY */} {/* BODY */}
<div className="flex-1 p-6 overflow-y-auto custom-scrollbar"> <div className="flex-1 p-6 overflow-y-auto custom-scrollbar">
{loadingContent ? ( <div className="flex flex-col gap-8">
<div className="flex flex-col items-center justify-center h-40 gap-3 text-gray-500"> {/* THÔNG TIN NGƯỜI DÙNG */}
<div className="w-8 h-8 border-4 border-blue-500 rounded-full border-t-transparent animate-spin"></div> <div className="p-5 border border-gray-200 rounded-xl dark:border-gray-800 bg-gray-50/50 dark:bg-white/[0.02]">
<p>Đang tải dữ liệu người dùng file đính kèm...</p> <h4 className="mb-4 text-xs font-bold text-gray-500 uppercase tracking-wider">
</div> Thông tin ng viên
) : ( </h4>
<div className="flex flex-col gap-8"> <div className="flex items-center gap-4">
<div className="p-5 border border-gray-200 rounded-xl dark:border-gray-800 bg-gray-50/50 dark:bg-white/[0.02]"> <div className="relative w-16 h-16 overflow-hidden border border-gray-200 rounded-full shrink-0 dark:border-gray-700">
<h4 className="mb-4 text-sm font-bold text-gray-500 uppercase"> <Image
Thông tin ng viên fill
</h4> src={userData.avatar_url || "/images/no-images.jpg"}
<div className="flex items-center gap-4"> alt="avatar"
<div className="relative w-16 h-16 overflow-hidden border border-gray-200 rounded-full shrink-0 dark:border-gray-700"> className="object-cover"
<Image />
width={64} </div>
height={64} <div>
src={ <h4 className="text-lg font-semibold">
userData?.profile?.avatar_url || "/images/no-images.jpg" {userData.display_name || "N/A"}
} </h4>
alt="avatar" <p className="text-sm text-gray-500">
className="object-cover w-full h-full" {userData.email || "Không có email"}
/> </p>
</div> <div className="mt-1 flex gap-2">
<div> <span className="px-2 py-0.5 text-[10px] font-bold uppercase text-blue-600 bg-blue-100 rounded-md dark:bg-blue-500/20 dark:text-blue-400">
<h4 className="text-lg font-semibold text-gray-800 dark:text-white"> {application.verify_type}
{userData?.profile?.display_name || application.user_id} </span>
</h4> <span
<p className="text-sm text-gray-500 dark:text-gray-400"> className={`px-2 py-0.5 text-[10px] font-semibold uppercase rounded-md ${application.status === "PENDING" ? "bg-yellow-100 text-yellow-600" : "bg-red-100 text-red-500"}`}
Email: {userData?.email || "Đang cập nhật..."} >
</p> {application.status}
<div className="mt-1"> </span>
<span className="px-2 py-0.5 text-[10px] font-medium tracking-wide text-blue-600 bg-blue-100 rounded-full dark:bg-blue-500/20 dark:text-blue-400">
{application.verify_type}
</span>
</div>
</div> </div>
</div> </div>
</div> </div>
<div>
<h4 className="mb-3 text-sm font-bold text-gray-500 uppercase">
Nội dung ng tuyển
</h4>
<div
className="p-4 prose text-gray-800 bg-white border border-gray-200 min-h-[100px] rounded-xl dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200 max-w-none"
dangerouslySetInnerHTML={{
__html:
application.content ||
"<p className='text-gray-400 italic'>Không có nội dung chữ.</p>",
}}
/>
</div>
<div>
<h4 className="mb-3 text-sm font-bold text-gray-500 uppercase">
Tệp đính kèm ({mediaList.length})
</h4>
{mediaList.length > 0 ? (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
{mediaList.map((media, idx) => {
const isImage =
media.url?.match(/\.(jpeg|jpg|gif|png)$/i) ||
media.mime_type?.startsWith("image/");
return (
<div
key={idx}
className="relative overflow-hidden border border-gray-200 group aspect-square rounded-xl dark:border-gray-700 bg-gray-50 dark:bg-gray-800"
>
{isImage ? (
<Image
src={media.url || "/images/no-images.jpg"}
alt="media"
fill
className="object-cover transition-transform duration-300 group-hover:scale-110"
/>
) : (
<div className="flex flex-col items-center justify-center w-full h-full p-3 text-center">
<svg
className="w-10 h-10 mb-2 text-blue-500"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm-1 1.5L18.5 9H13V3.5zM6 20V4h5v7h7v9H6z" />
</svg>
<span className="text-xs font-medium text-gray-600 line-clamp-2 dark:text-gray-300">
{media.original_name || "Tài liệu"}
</span>
</div>
)}
{/* <button
onClick={() => handleOpenFile(media)}
className="absolute inset-0 flex items-center justify-center transition-opacity bg-black/50 opacity-0 group-hover:opacity-100"
>
<span className="px-3 py-1 text-sm font-medium text-white bg-blue-600 rounded-lg">
{media.url.match(/\.(jpeg|jpg|gif|png)$/i)
? "Xem ảnh"
: "Tải tài liệu"}
</span>
</button> */}
<Link
className="absolute inset-0 flex items-center justify-center transition-opacity bg-black/50 opacity-0 group-hover:opacity-100"
href={`${URL_MEDIA}${media.storage_key}`}
target="_blank"
></Link>
</div>
);
})}
</div>
) : (
<p className="text-sm italic text-gray-500">
Không tệp đính kèm nào.
</p>
)}
</div>
{/* KHỐI 4: GHI CHÚ ADMIN */}
<div>
<h4 className="mb-3 text-sm font-bold text-gray-500 uppercase">
Ghi chú duyệt hồ
</h4>
<textarea
className="w-full p-4 text-sm bg-white border border-gray-200 rounded-xl dark:border-gray-800 dark:bg-gray-900 focus:ring-2 focus:ring-blue-500 outline-none resize-none h-[100px]"
placeholder="Nhập lý do từ chối (bắt buộc) hoặc ghi chú phê duyệt..."
value={reviewNote}
onChange={(e) => setReviewNote(e.target.value)}
disabled={
application.status !== 1 && application.status !== "PENDING"
} // Disable nếu đã duyệt
/>
</div>
</div> </div>
)}
<div>
<h4 className="mb-3 text-xs font-bold text-gray-500 uppercase tracking-wider">
Nội dung ng tuyển
</h4>
<div
className="p-4 prose bg-white border border-gray-200 min-h-[100px] rounded-xl dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200 max-w-none"
dangerouslySetInnerHTML={{
__html: application.content || "<i>Không có nội dung.</i>",
}}
/>
</div>
<div>
<h4 className="mb-3 text-xs font-bold text-gray-500 uppercase tracking-wider">
Tệp đính kèm ({mediaList.length})
</h4>
{mediaList.length > 0 ? (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
{mediaList.map((media: any, idx: number) => {
const isImage = media.mime_type?.startsWith("image/");
return (
<div
key={idx}
className="relative overflow-hidden border border-gray-200 group aspect-square rounded-xl dark:border-gray-700 bg-gray-50 dark:bg-gray-800"
>
{isImage ? (
<Image
src={`${URL_MEDIA}${media.storage_key}`}
alt="media"
fill
className="object-cover transition-transform duration-300 group-hover:scale-110"
/>
) : (
<div className="flex flex-col items-center justify-center w-full h-full p-3 text-center">
<svg
className="w-10 h-10 mb-2 text-blue-500"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm-1 1.5L18.5 9H13V3.5zM6 20V4h5v7h7v9H6z" />
</svg>
<span className="text-[10px] font-medium text-gray-600 line-clamp-2">
{media.original_name}
</span>
</div>
)}
<Link
className="absolute inset-0 flex items-center justify-center transition-opacity bg-black/40 opacity-0 group-hover:opacity-100 z-10"
href={`${URL_MEDIA}${media.storage_key}`}
target="_blank"
>
<span className="text-white text-xs font-bold px-3 py-1 bg-white/20 backdrop-blur-md rounded-full border border-white/30">
Xem file
</span>
</Link>
</div>
);
})}
</div>
) : (
<p className="text-sm italic text-gray-400">
Không tệp đính kèm.
</p>
)}
</div>
{/* GHI CHÚ */}
<div>
<h4 className="mb-3 text-xs font-bold text-gray-500 uppercase tracking-wider">
Ghi chú duyệt hồ
</h4>
<textarea
ref={textareaRef}
className="w-full p-4 text-sm bg-white border border-gray-200 rounded-xl dark:border-gray-800 dark:bg-gray-900 focus:ring-2 focus:ring-blue-500 outline-none resize-none h-[100px]"
placeholder="Nhập lý do (bắt buộc) hoặc ghi chú thêm..."
value={reviewNote}
onChange={(e) => setReviewNote(e.target.value)}
disabled={application.status !== "PENDING"}
/>
</div>
</div>
</div> </div>
{/* FOOTER ACTIONS */} {/* FOOTER */}
<div className="flex items-center justify-end gap-3 px-6 py-4 bg-gray-50 border-t border-gray-200 dark:bg-gray-900/50 dark:border-gray-800"> <div className="flex items-center justify-end gap-3 px-6 py-4 bg-gray-50 border-t border-gray-200 dark:bg-gray-900/50 dark:border-gray-800">
<button <button
onClick={onClose} onClick={onClose}
className="px-5 py-2.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-xl hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-700 dark:hover:bg-gray-700 transition-colors" className="px-5 py-2 text-sm font-medium border border-gray-300 rounded-xl hover:bg-gray-100 transition-colors"
> >
Đóng Đóng
</button> </button>
{application.status === "PENDING" && (
{/* Chỉ hiện nút duyệt/từ chối nếu status đang là PENDING (1) */}
{(application.status === 1 || application.status === "PENDING") && (
<> <>
<button <button
onClick={() => handleUpdateStatus("REJECTED")} onClick={() => handleUpdateStatus("REJECTED")}
disabled={isSubmitting} disabled={isSubmitting}
className="px-5 py-2.5 text-sm font-medium text-white bg-red-600 border border-transparent rounded-xl hover:bg-red-700 disabled:opacity-50 transition-colors" className="px-5 py-2 text-sm font-medium text-white bg-red-500 rounded-xl hover:bg-red-600 disabled:opacity-50 transition-all"
> >
Từ chối Từ chối
</button> </button>
<button <button
onClick={() => handleUpdateStatus("APPROVED")} onClick={() => handleUpdateStatus("APPROVED")}
disabled={isSubmitting} disabled={isSubmitting}
className="px-5 py-2.5 text-sm font-medium text-white bg-green-600 border border-transparent rounded-xl hover:bg-green-700 disabled:opacity-50 transition-colors shadow-lg shadow-green-500/30" className="px-5 py-2 text-sm font-medium text-white bg-green-500 rounded-xl hover:bg-green-600 disabled:opacity-50 transition-all"
> >
Phê duyệt Phê duyệt
</button> </button>

View File

@@ -51,15 +51,29 @@ export default function ApplicationTable({
<div className="flex flex-col ml-2 opacity-50 cursor-pointer hover:opacity-100"> <div className="flex flex-col ml-2 opacity-50 cursor-pointer hover:opacity-100">
<svg <svg
className={`w-3 h-3 ${isActive && sortOrder === "asc" ? "text-blue-700 opacity-100" : "text-gray-400"}`} className={`w-3 h-3 ${isActive && sortOrder === "asc" ? "text-blue-700 opacity-100" : "text-gray-400"}`}
fill="none" stroke="currentColor" viewBox="0 0 24 24" fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
> >
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 15l7-7 7 7" /> <path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M5 15l7-7 7 7"
/>
</svg> </svg>
<svg <svg
className={`w-3 h-3 -mt-1 ${isActive && sortOrder === "desc" ? "text-blue-700 opacity-100" : "text-gray-400"}`} className={`w-3 h-3 -mt-1 ${isActive && sortOrder === "desc" ? "text-blue-700 opacity-100" : "text-gray-400"}`}
fill="none" stroke="currentColor" viewBox="0 0 24 24" fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
> >
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M19 9l-7 7-7-7" /> <path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M19 9l-7 7-7-7"
/>
</svg> </svg>
</div> </div>
); );
@@ -70,28 +84,46 @@ export default function ApplicationTable({
switch (s) { switch (s) {
case "1": case "1":
case "PENDING": case "PENDING":
return <Badge size="sm" variant="light" color="warning">Đang chờ</Badge>; return (
<Badge size="sm" variant="light" color="warning">
Đang chờ
</Badge>
);
case "2": case "2":
case "APPROVED": case "APPROVED":
return <Badge size="sm" variant="light" color="success">Đã duyệt</Badge>; return (
<Badge size="sm" variant="light" color="success">
Đã duyệt
</Badge>
);
case "3": case "3":
case "REJECTED": case "REJECTED":
return <Badge size="sm" variant="light" color="error">Từ chối</Badge>; return (
<Badge size="sm" variant="light" color="error">
Từ chối
</Badge>
);
default: default:
return <Badge size="sm" variant="light" color="light">{status || "N/A"}</Badge>; return (
<Badge size="sm" variant="light" color="light">
{status || "N/A"}
</Badge>
);
} }
}; };
const renderVerifyTypes = (verifyType: string | string[] | number | number[]) => { const renderVerifyTypes = (
verifyType: string | string[] | number | number[],
) => {
const typeMap: Record<string, string> = { const typeMap: Record<string, string> = {
"1": "Thẻ nhận dạng nhà nghiên cứu", "1": "Thẻ nhận dạng nhà nghiên cứu",
"ID_CARD": "Thẻ nhận dạng nhà nghiên cứu", ID_CARD: "Thẻ nhận dạng nhà nghiên cứu",
"2": "Bằng cấp", "2": "Bằng cấp",
"EDUCATION": "Bằng cấp", EDUCATION: "Bằng cấp",
"3": "Chuyên gia", "3": "Chuyên gia",
"EXPERT": "Chuyên gia", EXPERT: "Chuyên gia",
"4": "Khác", "4": "Khác",
"OTHER": "Khác", OTHER: "Khác",
}; };
const typesArray = Array.isArray(verifyType) ? verifyType : [verifyType]; const typesArray = Array.isArray(verifyType) ? verifyType : [verifyType];
@@ -121,37 +153,73 @@ export default function ApplicationTable({
<Table> <Table>
<TableHeader className="border-b border-gray-100 dark:border-white/[0.05]"> <TableHeader className="border-b border-gray-100 dark:border-white/[0.05]">
<TableRow> <TableRow>
<TableCell isHeader className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400"> <TableCell
isHeader
className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400"
>
Người gửi (ID) Người gửi (ID)
</TableCell> </TableCell>
<TableCell isHeader className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400 min-w-[220px]"> <TableCell
isHeader
className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400 min-w-[220px]"
>
Loại xác minh Loại xác minh
</TableCell> </TableCell>
<TableCell isHeader className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400"> <TableCell
isHeader
className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400"
>
Đính kèm Đính kèm
</TableCell> </TableCell>
<TableCell isHeader className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400"> <TableCell
<div className="flex items-center cursor-pointer select-none" onClick={() => onSort("status")}> isHeader
className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400"
>
<div
className="flex items-center cursor-pointer select-none"
onClick={() => onSort("status")}
>
Trạng thái <SortIcon column="status" /> Trạng thái <SortIcon column="status" />
</div> </div>
</TableCell> </TableCell>
<TableCell isHeader className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400"> <TableCell
<div className="flex items-center cursor-pointer select-none" onClick={() => onSort("created_at")}> isHeader
className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400"
>
<div
className="flex items-center cursor-pointer select-none"
onClick={() => onSort("created_at")}
>
Ngày nộp <SortIcon column="created_at" /> Ngày nộp <SortIcon column="created_at" />
</div> </div>
</TableCell> </TableCell>
<TableCell isHeader className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400"> <TableCell
<div className="flex items-center cursor-pointer select-none" onClick={() => onSort("reviewed_at")}> isHeader
className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400"
>
<div
className="flex items-center cursor-pointer select-none"
onClick={() => onSort("reviewed_at")}
>
Cập nhật <SortIcon column="reviewed_at" /> Cập nhật <SortIcon column="reviewed_at" />
</div> </div>
</TableCell> </TableCell>
<TableCell isHeader className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400"> <TableCell
isHeader
className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400"
>
Cập nhật bởi Cập nhật bởi
</TableCell> </TableCell>
<TableCell isHeader className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400 max-w-[200px]"> <TableCell
isHeader
className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400 max-w-[200px]"
>
Ghi chú Ghi chú
</TableCell> </TableCell>
<TableCell isHeader className="px-5 py-3 font-medium text-center text-gray-500 text-theme-xs dark:text-gray-400"> <TableCell
isHeader
className="px-5 py-3 font-medium text-center text-gray-500 text-theme-xs dark:text-gray-400"
>
Thao tác Thao tác
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -160,17 +228,22 @@ export default function ApplicationTable({
<TableBody className="divide-y divide-gray-100 dark:divide-white/[0.05]"> <TableBody className="divide-y divide-gray-100 dark:divide-white/[0.05]">
{data.length > 0 ? ( {data.length > 0 ? (
data.map((app) => ( data.map((app) => (
<TableRow key={app.id} className="hover:bg-gray-50/50 dark:hover:bg-white/[0.01] transition-colors"> <TableRow
key={app.id}
className="hover:bg-gray-50/50 dark:hover:bg-white/[0.01] transition-colors"
>
<TableCell className="px-5 py-4 text-start font-mono text-theme-xs"> <TableCell className="px-5 py-4 text-start font-mono text-theme-xs">
{app.user_id.slice(0, 8)}... {app.user.display_name}
</TableCell> </TableCell>
<TableCell className="px-5 py-4 text-start"> <TableCell className="px-5 py-4 text-start">
{renderVerifyTypes(app.verify_type)} {renderVerifyTypes(app.verify_type)}
</TableCell> </TableCell>
<TableCell className="px-5 py-4 text-center text-theme-sm"> <TableCell className="px-5 py-4 text-center text-theme-sm">
<span className="font-bold text-gray-800 dark:text-white mr-1">{app.media?.length || 0}</span> <span className="font-bold text-gray-800 dark:text-white mr-1">
{app.media?.length || 0}
</span>
</TableCell> </TableCell>
<TableCell className="px-5 py-4 text-start"> <TableCell className="px-5 py-4 text-center ">
{getStatusBadge(app.status)} {getStatusBadge(app.status)}
</TableCell> </TableCell>
<TableCell className="px-5 py-4 text-gray-600 text-theme-sm dark:text-gray-400"> <TableCell className="px-5 py-4 text-gray-600 text-theme-sm dark:text-gray-400">
@@ -180,10 +253,17 @@ export default function ApplicationTable({
{formatDate(app.reviewed_at)} {formatDate(app.reviewed_at)}
</TableCell> </TableCell>
<TableCell className="px-5 py-4 text-gray-600 text-theme-sm dark:text-gray-400"> <TableCell className="px-5 py-4 text-gray-600 text-theme-sm dark:text-gray-400">
{app.reviewed_by || "-"} {app.reviewer?.display_name || "-"}
</TableCell> </TableCell>
<TableCell className="px-5 py-4 text-start text-theme-xs text-gray-500 dark:text-gray-400 truncate max-w-[200px]"> <TableCell className="group relative px-5 pb-4 text-start text-theme-xs text-gray-500 dark:text-gray-400 max-w-50">
{app.review_note || "-"} <div className="truncate">{app.review_note || "-"}</div>
{app.review_note && (
<div className="invisible group-hover:visible absolute z-50 bottom-full left-1/2 -translate-x-1/2 mb-1 w-max max-w-75 p-2 backdrop-blur-sm text-black text-xs rounded-lg shadow-xl wrap-break-words whitespace-normal">
{app.review_note}
{/* <div className="absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-gray-600"></div> */}
</div>
)}
</TableCell> </TableCell>
<TableCell className="px-5 py-4 text-center"> <TableCell className="px-5 py-4 text-center">
<button <button
@@ -197,7 +277,10 @@ export default function ApplicationTable({
)) ))
) : ( ) : (
<TableRow> <TableRow>
<TableCell colSpan={9} className="px-5 py-20 text-center text-gray-500 italic"> <TableCell
colSpan={9}
className="px-5 py-20 text-center text-gray-500 italic"
>
Không tìm thấy dữ liệu hồ Không tìm thấy dữ liệu hồ
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -208,4 +291,4 @@ export default function ApplicationTable({
</div> </div>
</div> </div>
); );
} }

View File

@@ -9,10 +9,7 @@ const Pagination: React.FC<PaginationProps> = ({
totalPages, totalPages,
onPageChange, onPageChange,
}) => { }) => {
const pagesAroundCurrent = Array.from( const allPages = Array.from({ length: totalPages }, (_, i) => i + 1);
{ length: Math.min(3, totalPages) },
(_, i) => i + Math.max(currentPage - 1, 1)
);
return ( return (
<div className="flex items-center "> <div className="flex items-center ">
@@ -25,7 +22,7 @@ const Pagination: React.FC<PaginationProps> = ({
</button> </button>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{currentPage > 3 && <span className="px-2">...</span>} {currentPage > 3 && <span className="px-2">...</span>}
{pagesAroundCurrent.map((page) => ( {allPages.map((page) => (
<button <button
key={page} key={page}
onClick={() => onPageChange(page)} onClick={() => onPageChange(page)}
@@ -40,6 +37,7 @@ const Pagination: React.FC<PaginationProps> = ({
))} ))}
{currentPage < totalPages - 2 && <span className="px-2">...</span>} {currentPage < totalPages - 2 && <span className="px-2">...</span>}
</div> </div>
<button <button
onClick={() => onPageChange(currentPage + 1)} onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages} disabled={currentPage === totalPages}

View File

@@ -9,20 +9,33 @@ export interface MediaDto {
export interface ApplicationDto { export interface ApplicationDto {
id: string; id: string;
user_id: string; user_id?: string;
verify_type: string | number ; verify_type: string | number;
content: string; content: string;
is_deleted: boolean; is_deleted: boolean;
status: string | number; status: string | number;
reviewed_by: string; reviewed_by: string;
review_note: string; review_note: string;
reviewed_at: string | null; reviewed_at: string | null;
created_at: string; created_at: string;
updated_at?: string; updated_at?: string;
media: any[]; media: any[];
user: {
display_name?: string;
avatar_url?: string;
full_name?: string;
id?: string;
email?: string;
};
reviewer?: {
display_name?: string;
avatar_url?: string;
full_name?: string;
id?: string;
email?: string;
};
} }
export interface GetApplicationsParams { export interface GetApplicationsParams {
page?: number; page?: number;
limit?: number; limit?: number;
@@ -46,4 +59,4 @@ export interface ApplicationResponse {
total_records: number; total_records: number;
total_pages: number; total_pages: number;
}; };
} }