diff --git a/package-lock.json b/package-lock.json index 97d4016..a135486 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "react-dnd-html5-backend": "^16.0.1", "react-dom": "^19.2.0", "react-dropzone": "^14.3.8", + "react-quill-new": "^3.8.3", "react-redux": "^9.2.0", "sonner": "^2.0.7", "sweetalert2": "^11.26.24", @@ -5867,12 +5868,24 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "license": "Apache-2.0" + }, "node_modules/fast-glob": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", @@ -7316,6 +7329,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" + }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", @@ -7323,6 +7348,13 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -7832,6 +7864,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parchment": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz", + "integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==", + "license": "BSD-3-Clause" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -8035,6 +8073,35 @@ ], "license": "MIT" }, + "node_modules/quill": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz", + "integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==", + "license": "BSD-3-Clause", + "dependencies": { + "eventemitter3": "^5.0.1", + "lodash-es": "^4.17.21", + "parchment": "^3.0.0", + "quill-delta": "^5.1.0" + }, + "engines": { + "npm": ">=8.2.3" + } + }, + "node_modules/quill-delta": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz", + "integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==", + "license": "MIT", + "dependencies": { + "fast-diff": "^1.3.0", + "lodash.clonedeep": "^4.5.0", + "lodash.isequal": "^4.5.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, "node_modules/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", @@ -8131,6 +8198,21 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/react-quill-new": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/react-quill-new/-/react-quill-new-3.8.3.tgz", + "integrity": "sha512-c96PYqFTo0pI4R3e79B3rH9LUIce1kIQbmTBu/imJQZk8305ogyLyBqKKjG2UoInDlquXqePSzmBo2aVia3ttw==", + "license": "MIT", + "dependencies": { + "lodash-es": "^4.17.21", + "quill": "~2.0.3" + }, + "peerDependencies": { + "quill-delta": "^5.1.0", + "react": "^16 || ^17 || ^18 || ^19", + "react-dom": "^16 || ^17 || ^18 || ^19" + } + }, "node_modules/react-redux": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", diff --git a/package.json b/package.json index 340b3b3..1903a1c 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "react-dnd-html5-backend": "^16.0.1", "react-dom": "^19.2.0", "react-dropzone": "^14.3.8", + "react-quill-new": "^3.8.3", "react-redux": "^9.2.0", "sonner": "^2.0.7", "sweetalert2": "^11.26.24", diff --git a/src/app/(admin)/(others-pages)/(forms)/role-upgrade/page.tsx b/src/app/(admin)/(others-pages)/(forms)/role-upgrade/page.tsx new file mode 100644 index 0000000..5241071 --- /dev/null +++ b/src/app/(admin)/(others-pages)/(forms)/role-upgrade/page.tsx @@ -0,0 +1,290 @@ +"use client"; + +import PageBreadcrumb from "@/components/common/PageBreadCrumb"; +import RichTextEditor from "@/components/form/role-upgrade/Editor"; +import { + confirmUpload, + getPresignedUrl, + uploadFileToS3, + uploadMedia, +} from "@/service/mediaService"; +import React, { useState } from "react"; +import Image from "next/image"; +import Lightbox from "yet-another-react-lightbox"; +import Zoom from "yet-another-react-lightbox/plugins/zoom"; +import Captions from "yet-another-react-lightbox/plugins/captions"; +import "yet-another-react-lightbox/styles.css"; +import "yet-another-react-lightbox/plugins/captions.css"; +import { createHistorianCV } from "@/service/historianService"; + +type PendingFile = { + id: string; + file: File; + previewUrl: string; + name: string; + size: number; + type: "image" | "document"; + extension: string; + presigned?: any; +}; + +export default function RoleUpgrade() { + const [content, setContent] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + + const [pendingFiles, setPendingFiles] = useState([]); + const [lightboxIndex, setLightboxIndex] = useState(-1); + + const [isPreparingFiles, setIsPreparingFiles] = useState(false); + const handleContentChange = (value: string) => { + setContent(value); + }; + + const handleFileChange = async ( + event: React.ChangeEvent, + ) => { + const files = event.target.files; + if (!files || files.length === 0) return; + + setIsPreparingFiles(true); + + try { + const newFilesPromises = Array.from(files).map(async (file) => { + const isImage = file.type.startsWith("image/"); + const extension = file.name.split(".").pop()?.toLowerCase() || ""; + + const presigned = await getPresignedUrl(file); + + return { + id: Math.random().toString(36).substring(7), + file: file, + previewUrl: isImage ? URL.createObjectURL(file) : "", + name: file.name, + size: file.size, + type: isImage ? "image" : "document", + extension: extension, + presigned: presigned, + } as PendingFile; + }); + + const newPendingFiles = await Promise.all(newFilesPromises); + setPendingFiles((prev) => [...prev, ...newPendingFiles]); + } catch (error) { + console.error("Lỗi khi chuẩn bị file đính kèm:", error); + alert("Lỗi khi chuẩn bị kết nối upload. Vui lòng thử lại!"); + } finally { + setIsPreparingFiles(false); + if (event.target) event.target.value = ""; + } + }; + + const removePendingFile = (idToRemove: string, e: React.MouseEvent) => { + e.stopPropagation(); + setPendingFiles((prev) => prev.filter((item) => item.id !== idToRemove)); + }; + +const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + try { + setIsSubmitting(true); + + const uploadPromises = pendingFiles.map(async (item) => { + await uploadFileToS3(item.file, item.presigned); + const confirmRes = await confirmUpload(item.presigned.token_id); + return confirmRes?.data?.id || confirmRes?.id; + }); + const uploadedMediaIds = await Promise.all(uploadPromises); + + const payload = { + content: content, + media_ids: uploadedMediaIds, + verify_type: "ID_CARD" + }; + + console.log("Payload chuẩn bị gửi (JSON):", payload); + + const cvResponse = await createHistorianCV(payload); + console.log("Response từ API:", cvResponse); + + alert("Gửi thành công!"); + setContent(""); + setPendingFiles([]); + } catch (error) { + console.error("Lỗi:", error); + } finally { + setIsSubmitting(false); + } +}; + + const imageFiles = pendingFiles.filter((f) => f.type === "image"); + const slides = imageFiles.map((item) => ({ + src: item.previewUrl, + title: item.name, + description: `Size: ${(item.size / 1024).toFixed(2)} KB`, + })); + + const handleItemClick = (item: PendingFile) => { + if (item.type === "image") { + const index = imageFiles.findIndex((img) => img.id === item.id); + setLightboxIndex(index); + } else { + const fileUrl = URL.createObjectURL(item.file); + window.open(fileUrl, "_blank"); + } + }; + + const getDocumentStyle = (ext: string) => { + if (["pdf"].includes(ext)) + return { color: "text-red-500", bg: "bg-red-50" }; + if (["doc", "docx"].includes(ext)) + return { color: "text-blue-500", bg: "bg-blue-50" }; + if (["xls", "xlsx"].includes(ext)) + return { color: "text-emerald-500", bg: "bg-emerald-50" }; + return { color: "text-gray-500", bg: "bg-gray-100" }; + }; + + return ( +
+ +
+ + +
+
+

+ Tài liệu đính kèm +

+ +
+ + {pendingFiles.length > 0 && ( +
+ {pendingFiles.map((item) => { + const docStyle = getDocumentStyle(item.extension); + + return ( +
handleItemClick(item)} + className="group relative aspect-square cursor-pointer overflow-hidden rounded-xl border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 hover:border-blue-400 transition-colors" + > + {item.type === "image" ? ( + {item.name} + ) : ( +
+ + + + + {item.name} + + + {item.extension} + +
+ )} + + + + {item.type === "image" && ( +
+ + + +
+ )} +
+ ); + })} +
+ )} +
+ + +
+ = 0} + close={() => setLightboxIndex(-1)} + slides={slides} + plugins={[Zoom, Captions]} + zoom={{ maxZoomPixelRatio: 10 }} + styles={{ + root: { + zIndex: 999999, + "--yarl__color_backdrop": "rgba(0, 0, 0, 0.9)", + }, + }} + /> +
+ ); +} diff --git a/src/app/(admin)/(others-pages)/(tables)/applications-tables/page.tsx b/src/app/(admin)/(others-pages)/(tables)/applications-tables/page.tsx new file mode 100644 index 0000000..fc67fc1 --- /dev/null +++ b/src/app/(admin)/(others-pages)/(tables)/applications-tables/page.tsx @@ -0,0 +1,212 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import ComponentCard from "@/components/common/ComponentCard"; +import PageBreadcrumb from "@/components/common/PageBreadCrumb"; +import Pagination from "@/components/tables/Pagination"; +import Swal from "sweetalert2"; + +import ApplicationTable, { + AppSortColumn, +} from "@/components/tables/ApplicationTable"; +import { apiGetUserApplications } from "@/service/historianService"; +import { + ApplicationDto, + ApplicationResponse, + GetApplicationsParams, +} from "@/interface/historian"; +import ApplicationDetailModal from "@/components/tables/ApplicationDetailModal"; + +export default function HistorianApplicationPage() { + const [page, setPage] = useState(1); + const [limitInput, setLimitInput] = useState("3"); + + const [searchTerm, setSearchTerm] = useState(""); + const [statusFilter, setStatusFilter] = useState(""); + const [verifyTypeFilter, setVerifyTypeFilter] = useState(""); + + const [selectedApp, setSelectedApp] = useState(null); + const [isModalOpen, setIsModalOpen] = useState(false); + + const [debouncedParams, setDebouncedParams] = useState({ + search: "", + limit: 3, + status: "", + verifyType: "", + }); + + const [tableData, setTableData] = useState(null); + const [loading, setLoading] = useState(true); + + const [sortBy, setSortBy] = useState("created_at"); + const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc"); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedParams({ + search: searchTerm, + limit: parseInt(limitInput) || 3, + status: statusFilter, + verifyType: verifyTypeFilter, + }); + setPage(1); + }, 600); + return () => clearTimeout(handler); + }, [searchTerm, limitInput, statusFilter, verifyTypeFilter]); + + const fetchApplications = useCallback(async () => { + setLoading(true); + try { + const payload: GetApplicationsParams = { + page: page, + limit: debouncedParams.limit, + search: debouncedParams.search || undefined, + sort: sortBy, + order: sortOrder, + }; + + // Backend yêu cầu mảng cho các filter này + if (debouncedParams.status) payload.statuses = [debouncedParams.status]; + if (debouncedParams.verifyType) + payload.verify_types = [debouncedParams.verifyType]; + + const response = await apiGetUserApplications(payload); + if (response?.status) { + setTableData(response); + } + } catch (err) { + console.error("Lỗi lấy danh sách hồ sơ:", err); + setTableData(null); + } finally { + setLoading(false); + } + }, [page, debouncedParams, sortBy, sortOrder]); + + useEffect(() => { + fetchApplications(); + }, [fetchApplications]); + + const handleSort = (column: AppSortColumn) => { + setPage(1); + if (sortBy === column) { + setSortOrder(sortOrder === "asc" ? "desc" : "asc"); + } else { + setSortBy(column); + setSortOrder("desc"); + } + }; + + const handleViewDetail = (app: ApplicationDto) => { + setSelectedApp(app); + setIsModalOpen(true); + }; + + const pagination = tableData?.pagination; + + return ( +
+ + +
+ +
+
+ + setSearchTerm(e.target.value)} + className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 outline-none focus:border-brand-500" + /> +
+ +
+ + +
+ +
+ + +
+ +
+ + setLimitInput(e.target.value)} + className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 outline-none focus:border-brand-500" + /> +
+
+
+ + +
+ {loading && ( +
+
+
+ )} + + +
+ +
+

+ Hiển thị {pagination?.total_records || 0} hồ sơ +

+ + {pagination && pagination.total_pages > 1 && ( + setPage(newPage)} + /> + )} +
+
+
+ + setIsModalOpen(false)} + application={selectedApp || null} + onRefresh={fetchApplications} // Tải lại bảng sau khi Admin duyệt + /> +
+ ); +} diff --git a/src/app/(admin)/(others-pages)/profile/page.tsx b/src/app/(admin)/(others-pages)/profile/page.tsx index dd9b9a3..2212cb4 100644 --- a/src/app/(admin)/(others-pages)/profile/page.tsx +++ b/src/app/(admin)/(others-pages)/profile/page.tsx @@ -31,6 +31,7 @@ export default function Profile() { }; fetchUser(); }, []); + return (
diff --git a/src/app/globals.css b/src/app/globals.css index 63c46a5..a38806a 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -768,4 +768,44 @@ html { border-radius: 5px; background-color: rgba(0, 0, 0, .5); box-shadow: 0 0 1px rgba(255, 255, 255, .5); +} + +.ql-editor pre.ql-syntax { + background-color: #23241f; + color: #f8f8f2; + overflow: visible; + padding: 10px; + border-radius: 5px; +} + +.ql-toolbar.ql-snow { + border: none !important; + border-bottom: 1px solid #e5e7eb !important; + background-color: #f9fafb; + padding: 12px !important; +} + +.ql-container.ql-snow { + border: none !important; + font-size: 16px; + font-family: 'Inter', sans-serif; +} + +.ql-editor { + min-height: 400px; + line-height: 1.6; +} + +.dark .ql-toolbar.ql-snow { + background-color: #1f2937; + border-bottom: 1px solid #374151 !important; + color: #f3f4f6; +} + +.dark .ql-snow .ql-stroke { + stroke: #d1d5db; +} + +.dark .ql-editor { + color: #f3f4f6; } \ No newline at end of file diff --git a/src/components/form/role-upgrade/Editor.tsx b/src/components/form/role-upgrade/Editor.tsx new file mode 100644 index 0000000..bd7afd8 --- /dev/null +++ b/src/components/form/role-upgrade/Editor.tsx @@ -0,0 +1,98 @@ +"use client"; + +import React, { useEffect, useRef } from "react"; +import dynamic from "next/dynamic"; +import "react-quill-new/dist/quill.snow.css"; + +const ReactQuillEditor = dynamic( + async () => { + const { default: RQ } = await import("react-quill-new"); + return ({ forwardedRef, ...props }: any) => ( + + ); + }, + { + ssr: false, + loading: () => ( +
+ ), + }, +); + +interface EditorProps { + value: string; + onChange: (content: string) => void; + handleImageUpload?: (file: File) => Promise; + onEditorReady?: () => void; +} + +const RichTextEditor = ({ + value, + onChange, + handleImageUpload, + onEditorReady, +}: EditorProps) => { + const quillRef = useRef(null); + + const modules = { + toolbar: [ + [{ font: [] }, { size: [] }], + ["bold", "italic", "underline", "strike"], + [{ color: [] }, { background: [] }], + [{ script: "sub" }, { script: "super" }], + [{ header: [1, 2, 3, 4, 5, 6, false] }], + ["blockquote", "code-block"], + [ + { list: "ordered" }, + { list: "bullet" }, + { indent: "-1" }, + { indent: "+1" }, + ], + [{ direction: "rtl" }, { align: [] }], + ["link", "image", "video", "formula"], + ["clean"], + ], + }; + + useEffect(() => { + if (quillRef.current && handleImageUpload) { + const quill = quillRef.current.getEditor(); + const toolbar = quill.getModule("toolbar"); + toolbar.addHandler("image", () => { + const input = document.createElement("input"); + input.setAttribute("type", "file"); + input.setAttribute("accept", "image/*"); + input.click(); + input.onchange = async () => { + const file = input.files?.[0]; + if (file) { + try { + const url = await handleImageUpload(file); + const range = quill.getSelection(); + quill.insertEmbed(range?.index || 0, "image", url); + } catch (error) { + console.error("Upload failed", error); + } + } + }; + }); + } + onEditorReady?.(); + }, [handleImageUpload]); + + return ( +
+ +
+ ); +}; + +export default RichTextEditor; diff --git a/src/components/tables/ApplicationDetailModal.tsx b/src/components/tables/ApplicationDetailModal.tsx new file mode 100644 index 0000000..059edbc --- /dev/null +++ b/src/components/tables/ApplicationDetailModal.tsx @@ -0,0 +1,321 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import Image from "next/image"; +import Swal from "sweetalert2"; +import { ApplicationDto } from "@/interface/historian"; +import { + apiGetUserById, + apiUpdateApplicationStatus, +} from "@/service/adminService"; +import { getMediaById } from "@/service/mediaService"; + +interface Props { + isOpen: boolean; + onClose: () => void; + application: ApplicationDto | null; + onRefresh: () => void; // Gọi lại hàm fetch danh sách sau khi duyệt xong +} + +export default function ApplicationDetailModal({ + isOpen, + onClose, + application, + onRefresh, +}: Props) { + const [userData, setUserData] = useState(null); + const [mediaList, setMediaList] = useState([]); + const [reviewNote, setReviewNote] = useState(""); + const [loadingContent, setLoadingContent] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + + useEffect(() => { + if (isOpen && application) { + setReviewNote(application.review_note || ""); + fetchDetails(); + } else { + setUserData(null); + setMediaList([]); + setReviewNote(""); + } + }, [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") => { + if (!application) return; + + if (status === "REJECTED" && !reviewNote.trim()) { + Swal.fire( + "Cảnh báo", + "Vui lòng nhập lý do từ chối vào ô Ghi chú!", + "warning", + ); + return; + } + + try { + setIsSubmitting(true); + const payload = { + status: status, + review_note: reviewNote, + }; + + await apiUpdateApplicationStatus(application.id, payload); + + Swal.fire( + "Thành công!", + `Hồ sơ đã được ${status === "APPROVED" ? "Phê duyệt" : "Từ chối"}.`, + "success", + ); + onRefresh(); + onClose(); + } 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 lúc này.", "error"); + } finally { + setIsSubmitting(false); + } + }; + + if (!isOpen || !application) return null; + + const handleOpenFile = (media: any) => { + if (!media.url) { + 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); + } + }; + return ( +
+
+ {/* HEADER */} +
+

+ Chi tiết yêu cầu nâng cấp +

+ +
+ + {/* BODY */} +
+ {loadingContent ? ( +
+
+

Đang tải dữ liệu người dùng và file đính kèm...

+
+ ) : ( +
+
+

+ Thông tin ứng viên +

+
+
+ avatar +
+
+

+ {userData?.profile?.display_name || application.user_id} +

+

+ Email: {userData?.email || "Đang cập nhật..."} +

+
+ + {application.verify_type === 1 + ? "CĂN CƯỚC CÔNG DÂN" + : "BẰNG CẤP / KHÁC"} + +
+
+
+
+ +
+

+ Nội dung ứng tuyển +

+
Không có nội dung chữ.

", + }} + /> +
+ +
+

+ Tệp đính kèm ({mediaList.length}) +

+ {mediaList.length > 0 ? ( +
+ {mediaList.map((media, idx) => { + const isImage = + media.url?.match(/\.(jpeg|jpg|gif|png)$/i) || + media.mime_type?.startsWith("image/"); + return ( +
+ {isImage ? ( + media + ) : ( +
+ + + + + {media.original_name || "Tài liệu"} + +
+ )} + + +
+ ); + })} +
+ ) : ( +

+ Không có tệp đính kèm nào. +

+ )} +
+ + {/* KHỐI 4: GHI CHÚ ADMIN */} +
+

+ Ghi chú duyệt hồ sơ +

+