This commit is contained in:
2026-04-17 18:01:19 +07:00
parent 5b6b59dc01
commit 9d35fd3653
6 changed files with 467 additions and 258 deletions

View File

@@ -32,14 +32,23 @@ type PendingFile = {
export default function RoleUpgrade() {
const [content, setContent] = useState<string>("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [verifyType, setVerifyType] = useState<string>("OTHER");
const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]);
const [lightboxIndex, setLightboxIndex] = useState(-1);
const [isPreparingFiles, setIsPreparingFiles] = useState(false);
const [showPreview, setShowPreview] = useState(false);
const cleanHTMLContent = (rawHtml: string) => {
if (!rawHtml) return "";
const doc = new DOMParser().parseFromString(rawHtml, "text/html");
let decoded = doc.documentElement.textContent || rawHtml;
decoded = decoded.replace(/<pre[^>]*>/g, "").replace(/<\/pre>/g, "");
return decoded;
};
const handleContentChange = (value: string) => {
setContent(value);
};
@@ -51,12 +60,10 @@ export default function RoleUpgrade() {
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 {
@@ -74,8 +81,8 @@ export default function RoleUpgrade() {
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!");
console.error("Lỗi khi chuẩn bị file:", error);
toast.error("Lỗi khi chuẩn bị kết nối upload.");
} finally {
setIsPreparingFiles(false);
if (event.target) event.target.value = "";
@@ -89,6 +96,11 @@ export default function RoleUpgrade() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!content.trim()) {
toast.warning("Vui lòng nhập nội dung hồ sơ!");
return;
}
try {
setIsSubmitting(true);
@@ -99,29 +111,28 @@ export default function RoleUpgrade() {
});
const uploadedMediaIds = await Promise.all(uploadPromises);
const cleanPayloadContent = cleanHTMLContent(content);
const payload = {
content: content,
content: cleanPayloadContent,
media_ids: uploadedMediaIds,
verify_type: verifyType,
verify_type: verifyType,
};
console.log("Payload chuẩn bị gửi (JSON):", payload);
const cvResponse = await createHistorianCV(payload);
console.log("Response từ API:", cvResponse);
await createHistorianCV(payload);
Swal.fire({
title: "Gửi yêu cầu thành công!",
text: "Yêu cầu nâng cấp đã được gi đi. Chúng tôi sẽ xem xét và phản hồi sớm nhất có thể. Vui lòng kiểm tra Email khi có thông báo mới.",
text: "Hồ sơ của bạn đã được ghi nhận và đang chờ duyệt.",
icon: "success",
confirmButtonText: "OK",
});
setContent("");
setPendingFiles([]);
setVerifyType("ID_CARD");
setVerifyType("OTHER");
} catch (error) {
console.error("Lỗi:", error);
toast.error("Có lỗi xảy ra khi gửi yêu cầu. Vui lòng kiểm tra lại!");
console.error("Lỗi submit:", error);
toast.error("Có lỗi xảy ra khi gửi yêu cầu.");
} finally {
setIsSubmitting(false);
}
@@ -155,10 +166,53 @@ export default function RoleUpgrade() {
};
return (
<div className="max-w-4xl mx-auto">
<PageBreadcrumb pageTitle="Role Upgrade" />
<div className="max-w-4xl mx-auto pb-20">
<PageBreadcrumb pageTitle="Nâng cấp tài khoản Nhà sử học" />
<div className="flex items-center justify-between bg-white dark:bg-gray-900 p-2 rounded-xl border border-gray-200 dark:border-gray-800 mb-6">
<div className="flex gap-2">
<button
type="button"
onClick={() => setShowPreview(false)}
className={`px-4 py-2 text-sm font-bold rounded-lg transition-all ${!showPreview ? "bg-blue-600 text-white" : "text-gray-500 hover:bg-gray-100"}`}
>
Soạn thảo
</button>
<button
type="button"
onClick={() => setShowPreview(true)}
className={`px-4 py-2 text-sm font-bold rounded-lg transition-all ${showPreview ? "bg-blue-600 text-white" : "text-gray-500 hover:bg-gray-100"}`}
>
Xem trước giao diện
</button>
</div>
<span className="text-[10px] font-black uppercase text-gray-400 mr-2 tracking-widest">
{showPreview ? "Preview Mode" : "Edit Mode"}
</span>
</div>
<div className="flex flex-col gap-6">
<RichTextEditor value={content} onChange={handleContentChange} />
<div className="relative min-h-[400px]">
{!showPreview ? (
<RichTextEditor value={content} onChange={handleContentChange} />
) : (
<div className="bg-white border border-zinc-200 rounded-xl shadow-inner overflow-hidden min-h-[400px]">
{content ? (
<div className="preview-wrapper bg-white text-black">
<div
dangerouslySetInnerHTML={{
__html: cleanHTMLContent(content),
}}
/>
</div>
) : (
<div className="flex flex-col items-center justify-center h-[400px] text-zinc-400 italic bg-gray-50 dark:bg-gray-900">
<p>Chưa nội dung đ xem trước</p>
</div>
)}
</div>
)}
</div>
<div className="p-5 bg-white border border-gray-200 rounded-2xl dark:border-gray-800 dark:bg-gray-900">
<label className="block mb-3 text-sm font-semibold text-gray-700 dark:text-white">
@@ -167,43 +221,20 @@ export default function RoleUpgrade() {
<select
value={verifyType}
onChange={(e) => setVerifyType(e.target.value)}
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-200 dark:bg-gray-800 dark:border-gray-700 transition-all cursor-pointer text-gray-700 dark:text-gray-200"
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl outline-none focus:border-blue-500 dark:bg-gray-800 dark:border-gray-700"
>
<option value="OTHER">Tài liệu khác (Other)</option>
<option value="ID_CARD">Thẻ nhận dạng nhà nghiên cứu (ID Card)</option>
<option value="ID_CARD">Thẻ nhận dạng (ID Card)</option>
<option value="EDUCATION">Bằng cấp giáo dục (Education)</option>
<option value="EXPERT">Chứng nhận chuyên gia (Expert)</option>
</select>
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
* Vui lòng chọn loại tài liệu tương ng với các tệp bạn đính kèm bên dưới.
</p>
<p className="mt-2 text-xs text-red-400 dark:text-gray-400">
* Nếu bạn đính kèm nhiều loại tài liệu, vui lòng chọn mục " Tài liệu khác (Other) ".
</p>
</div>
{/* Upload Files */}
<div className="p-5 bg-white border border-gray-200 rounded-2xl dark:border-gray-800 dark:bg-gray-900">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-800 dark:text-white">
Tài liệu đính kèm
</h3>
<label
className={`flex items-center gap-2 cursor-pointer px-4 py-2 rounded-lg border border-dashed border-blue-500 text-blue-500 hover:bg-blue-50 transition ${isSubmitting ? "opacity-50 pointer-events-none" : ""}`}
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
<span>Đính kèm tệp</span>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold">Tài liệu đính kèm</h3>
<label className="cursor-pointer px-4 py-2 rounded-lg border border-dashed border-blue-500 text-blue-500 hover:bg-blue-50 transition">
<input
type="file"
multiple
@@ -211,50 +242,41 @@ export default function RoleUpgrade() {
accept="image/*,.pdf,.doc,.docx"
onChange={handleFileChange}
/>
<span>+ Thêm tệp</span>
</label>
</div>
{pendingFiles.length > 0 && (
<div className="grid grid-cols-2 gap-4 pt-4 mt-4 border-t border-gray-100 sm:grid-cols-4 md:grid-cols-5 dark:border-gray-800">
<div className="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-5 gap-4">
{pendingFiles.map((item) => {
const docStyle = getDocumentStyle(item.extension);
return (
<div
key={item.id}
onClick={() => handleItemClick(item)}
className="relative overflow-hidden transition-colors border border-gray-200 cursor-pointer group aspect-square rounded-xl dark:border-gray-700 bg-gray-50 dark:bg-gray-800 hover:border-blue-400"
className="relative group aspect-square border rounded-xl overflow-hidden bg-gray-50"
>
{item.type === "image" ? (
<Image
src={item.previewUrl}
alt={item.name}
alt="preview"
fill
className="object-cover transition-transform group-hover:scale-110"
className="object-cover"
/>
) : (
<div
className={`absolute inset-0 flex flex-col items-center justify-center p-3 ${docStyle.bg} dark:bg-gray-800`}
className={`flex flex-col items-center justify-center h-full ${docStyle.bg} p-2`}
>
<svg
className={`w-10 h-10 ${docStyle.color} mb-2`}
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-center text-gray-700 break-all line-clamp-2 dark:text-gray-300">
{item.name}
<span className={`text-xs font-bold ${docStyle.color}`}>
{item.extension.toUpperCase()}
</span>
<span className="text-[10px] text-gray-500 mt-1 uppercase font-bold">
{item.extension}
<span className="text-[10px] truncate w-full text-center mt-1">
{item.name}
</span>
</div>
)}
<button
onClick={(e) => removePendingFile(item.id, e)}
className="absolute z-10 p-1 text-white transition-opacity bg-red-500 rounded-full opacity-0 top-1 right-1 group-hover:opacity-100 hover:bg-red-600"
className="absolute top-1 right-1 bg-red-500 text-white rounded-full p-1 opacity-0 group-hover:opacity-100 transition"
>
<svg
className="w-3 h-3"
@@ -262,32 +284,9 @@ export default function RoleUpgrade() {
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{item.type === "image" && (
<div className="absolute inset-0 flex items-center justify-center transition-opacity opacity-0 pointer-events-none bg-black/40 group-hover:opacity-100">
<svg
className="w-6 h-6 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v3m0 0v3m0-3h3m-3 0H7"
/>
</svg>
</div>
)}
</div>
);
})}
@@ -298,26 +297,19 @@ export default function RoleUpgrade() {
<button
onClick={handleSubmit}
disabled={isSubmitting || isPreparingFiles}
className={`w-full py-4 text-white font-bold rounded-2xl shadow-lg transition-all transform flex justify-center items-center gap-2
${isSubmitting || isPreparingFiles ? "bg-gray-400 cursor-not-allowed" : "bg-blue-600 hover:bg-blue-700 active:scale-[0.98]"}`}
className={`w-full py-4 text-white font-bold rounded-2xl shadow-lg transition-all ${isSubmitting || isPreparingFiles ? "bg-gray-400" : "bg-blue-600 hover:bg-blue-700"}`}
>
{isSubmitting ? "Đang tải dữ liệu lên..." : "Gửi yêu cầu nâng cấp"}
{isSubmitting ? "Đang xử lý..." : "Gửi yêu cầu nâng cấp"}
</button>
</div>
<Lightbox
index={lightboxIndex}
open={lightboxIndex >= 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)",
},
}}
/>
</div>
);
}
}

View File

@@ -1,38 +1,212 @@
"use client";
import { useSelector } from "react-redux";
import { RootState } from "@/store/store";
import { SafeHTMLRenderer } from "@/components/ui/parse/SafeHTMLRenderer";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { apiDeleteHistorianCV } from "@/service/historianService";
import Swal from "sweetalert2";
// Import statusConfig để đồng bộ logic
import { statusConfig } from "@/service/handler";
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 { URL_MEDIA } from "../../../../../../api";
export default function ApplicationDetailPage() {
const application = useSelector((state: RootState) => state.user.selectedApplication);
const application = useSelector(
(state: RootState) => state.user.selectedApplication,
);
const router = useRouter();
const [isDeleting, setIsDeleting] = useState(false);
const [index, setIndex] = useState(-1);
if (!application) {
return <div className="p-10 text-center">Đang tải hoặc không dữ liệu...</div>;
return (
<div className="p-10 text-center text-zinc-500 font-medium">
Đang tải hoặc không dữ liệu...
</div>
);
}
const config = statusConfig[application.status] || statusConfig.PENDING;
const isImageFile = (file: any) => {
const isImageMime = file.mime_type?.startsWith("image/");
const isImageExt = /\.(jpg|jpeg|png|webp|gif)$/i.test(file.storage_key);
return isImageMime || isImageExt;
};
const imageMediaOnly = (application.media || []).filter(isImageFile);
const imageSlides = imageMediaOnly.map((item: any) => ({
src: `${URL_MEDIA}${item.storage_key}`,
title: item.original_name,
description: `Dung lượng: ${(item.size / 1024).toFixed(2)} KB`,
}));
const handleMediaClick = (item: any) => {
const fileUrl = `${URL_MEDIA}${item.storage_key}`;
if (isImageFile(item)) {
const photoIndex = imageMediaOnly.findIndex(
(img: any) => img.id === item.id,
);
setIndex(photoIndex);
} else {
const googleDocsUrl = `https://docs.google.com/viewer?url=${encodeURIComponent(fileUrl)}&embedded=true`;
window.open(googleDocsUrl, "_blank");
}
};
const handleDelete = async () => {
const isDarkMode = document.documentElement.classList.contains("dark");
const result = await Swal.fire({
title: "Xác nhận xóa?",
text: "Bạn sẽ không thể khôi phục lại đơn đăng ký này!",
icon: "warning",
showCancelButton: true,
confirmButtonColor: "#ef4444",
cancelButtonColor: "#71717a",
confirmButtonText: "Xóa ngay",
cancelButtonText: "Hủy",
background: isDarkMode ? "#18181b" : "#fff",
color: isDarkMode ? "#fff" : "#000",
});
if (result.isConfirmed) {
try {
setIsDeleting(true);
await apiDeleteHistorianCV(application.id);
await Swal.fire({
title: "Đã xóa!",
icon: "success",
timer: 1500,
showConfirmButton: false,
background: isDarkMode ? "#18181b" : "#fff",
color: isDarkMode ? "#fff" : "#000",
});
router.push("/profile");
} catch (error) {
Swal.fire({ title: "Lỗi!", icon: "error" });
} finally {
setIsDeleting(false);
}
}
};
console.log("Application Detail:", application); // Debug log để kiểm tra dữ liệu ứng dụng
return (
<div className="p-6 bg-white dark:bg-gray-900 rounded-xl shadow">
<h2 className="text-2xl font-bold mb-6 border-b pb-2">Chi tiết Application</h2>
<div className="space-y-6">
<div className="max-w-5xl mx-auto p-6 bg-white dark:bg-zinc-900 rounded-2xl shadow-sm border dark:border-zinc-800">
<div className="flex justify-between items-center mb-8 border-b dark:border-zinc-800 pb-6">
<div>
<label className="text-sm font-semibold text-gray-400 uppercase">Loại yêu cầu:</label>
<p className="text-lg font-medium">{application.verify_type}</p>
<h2 className="text-xl font-semibold dark:text-zinc-50 tracking-tight">
Chi tiết đơn đăng
</h2>
</div>
<div>
<label className="text-sm font-semibold text-gray-400 uppercase mb-3 block">
Nội dung hiển thị (CV):
</label>
{/* SỬ DỤNG Ở ĐÂY */}
<div className="border border-gray-100 rounded-lg p-2 bg-gray-50">
<SafeHTMLRenderer html={application.content} />
</div>
<div
className={`flex items-center gap-1.5 px-2 py-1 rounded-md backdrop-blur-md border ${config.container}`}
>
<span className={`text-[12px] font-bold uppercase tracking-wider`}>
{application.status}
</span>
</div>
{/* Các phần khác như Media... */}
</div>
<div className="space-y-10">
<section>
<label className="text-[11px] font-black text-zinc-400 uppercase tracking-widest mb-4 block">
Nội dung hiển thị (CV)
</label>
<div className="rounded-2xl border-2 border-zinc-50 dark:border-zinc-800 p-6 bg-zinc-50/50 dark:bg-zinc-950/30">
<SafeHTMLRenderer html={application.content} />
</div>
</section>
{application.media && application.media.length > 0 && (
<section>
<label className="text-[11px] font-black text-zinc-400 uppercase tracking-widest mb-4 block">
Tài liệu đính kèm ({application.media.length})
</label>
<div className="flex flex-row gap-4 overflow-x-auto pb-4 scrollbar-hide">
{application.media.map((item: any) => {
const isImg = isImageFile(item);
return (
<div
key={item.id}
onClick={() => handleMediaClick(item)}
className="group relative min-w-[160px] h-[160px] overflow-hidden rounded-2xl border-2 border-zinc-100 dark:border-zinc-800 cursor-pointer bg-zinc-100 dark:bg-zinc-900 flex flex-col items-center justify-center transition-all hover:border-blue-500"
>
{isImg ? (
<>
<img
src={`${URL_MEDIA}${item.storage_key}`}
alt={item.original_name}
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-110"
/>
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-end p-3">
<p className="text-[10px] text-white font-bold truncate">
Xem nh
</p>
</div>
</>
) : (
<div className="flex flex-col items-center p-4">
<div className="w-12 h-12 mb-3 flex items-center justify-center bg-white dark:bg-zinc-800 rounded-xl shadow-inner text-2xl">
📄
</div>
<p className="text-[10px] font-black text-zinc-700 dark:text-zinc-200 uppercase tracking-tighter">
.{item.storage_key.split(".").pop()}
</p>
<p className="text-[9px] text-zinc-400 truncate w-28 mt-1 font-medium italic">
{item.original_name}
</p>
<span className="mt-3 text-[9px] bg-blue-600 text-white px-3 py-1 rounded-full font-black uppercase opacity-0 group-hover:opacity-100 transition-opacity">
Preview
</span>
</div>
)}
</div>
);
})}
</div>
</section>
)}
{application.status !== "APPROVED" && (
<div className="pt-8 border-t border-zinc-200 dark:border-zinc-800">
<div className="flex justify-end w-full">
<button
onClick={handleDelete}
disabled={isDeleting}
className="group flex items-center gap-2 px-8 py-3 bg-red-100 hover:bg-red-500 text-red-600 hover:text-white dark:bg-red-950/20 dark:hover:bg-red-600 dark:text-red-400 dark:hover:text-white text-sm font-black rounded-xl transition-all duration-300 disabled:opacity-50"
>
{isDeleting ? "ĐANG XỬ LÝ..." : "XÓA"}
</button>
</div>
</div>
)}
</div>
<Lightbox
index={index}
open={index >= 0}
close={() => setIndex(-1)}
slides={imageSlides}
plugins={[Zoom, Captions]}
styles={{
root: {
zIndex: 99999,
"--yarl__color_backdrop": "rgba(0, 0, 0, 0.95)",
},
}}
/>
</div>
);
}
}