update
This commit is contained in:
@@ -32,14 +32,23 @@ type PendingFile = {
|
|||||||
export default function RoleUpgrade() {
|
export default function RoleUpgrade() {
|
||||||
const [content, setContent] = useState<string>("");
|
const [content, setContent] = useState<string>("");
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
const [verifyType, setVerifyType] = useState<string>("OTHER");
|
const [verifyType, setVerifyType] = useState<string>("OTHER");
|
||||||
|
|
||||||
const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]);
|
const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]);
|
||||||
const [lightboxIndex, setLightboxIndex] = useState(-1);
|
const [lightboxIndex, setLightboxIndex] = useState(-1);
|
||||||
|
|
||||||
const [isPreparingFiles, setIsPreparingFiles] = useState(false);
|
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) => {
|
const handleContentChange = (value: string) => {
|
||||||
setContent(value);
|
setContent(value);
|
||||||
};
|
};
|
||||||
@@ -51,12 +60,10 @@ export default function RoleUpgrade() {
|
|||||||
if (!files || files.length === 0) return;
|
if (!files || files.length === 0) return;
|
||||||
|
|
||||||
setIsPreparingFiles(true);
|
setIsPreparingFiles(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const newFilesPromises = Array.from(files).map(async (file) => {
|
const newFilesPromises = Array.from(files).map(async (file) => {
|
||||||
const isImage = file.type.startsWith("image/");
|
const isImage = file.type.startsWith("image/");
|
||||||
const extension = file.name.split(".").pop()?.toLowerCase() || "";
|
const extension = file.name.split(".").pop()?.toLowerCase() || "";
|
||||||
|
|
||||||
const presigned = await getPresignedUrl(file);
|
const presigned = await getPresignedUrl(file);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -74,8 +81,8 @@ export default function RoleUpgrade() {
|
|||||||
const newPendingFiles = await Promise.all(newFilesPromises);
|
const newPendingFiles = await Promise.all(newFilesPromises);
|
||||||
setPendingFiles((prev) => [...prev, ...newPendingFiles]);
|
setPendingFiles((prev) => [...prev, ...newPendingFiles]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Lỗi khi chuẩn bị file đính kèm:", error);
|
console.error("Lỗi khi chuẩn bị file:", error);
|
||||||
alert("Lỗi khi chuẩn bị kết nối upload. Vui lòng thử lại!");
|
toast.error("Lỗi khi chuẩn bị kết nối upload.");
|
||||||
} finally {
|
} finally {
|
||||||
setIsPreparingFiles(false);
|
setIsPreparingFiles(false);
|
||||||
if (event.target) event.target.value = "";
|
if (event.target) event.target.value = "";
|
||||||
@@ -89,6 +96,11 @@ export default function RoleUpgrade() {
|
|||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (!content.trim()) {
|
||||||
|
toast.warning("Vui lòng nhập nội dung hồ sơ!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
@@ -99,29 +111,28 @@ export default function RoleUpgrade() {
|
|||||||
});
|
});
|
||||||
const uploadedMediaIds = await Promise.all(uploadPromises);
|
const uploadedMediaIds = await Promise.all(uploadPromises);
|
||||||
|
|
||||||
|
const cleanPayloadContent = cleanHTMLContent(content);
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
content: content,
|
content: cleanPayloadContent,
|
||||||
media_ids: uploadedMediaIds,
|
media_ids: uploadedMediaIds,
|
||||||
verify_type: verifyType,
|
verify_type: verifyType,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("Payload chuẩn bị gửi (JSON):", payload);
|
await createHistorianCV(payload);
|
||||||
|
|
||||||
const cvResponse = await createHistorianCV(payload);
|
|
||||||
console.log("Response từ API:", cvResponse);
|
|
||||||
|
|
||||||
Swal.fire({
|
Swal.fire({
|
||||||
title: "Gửi yêu cầu thành công!",
|
title: "Gửi yêu cầu thành công!",
|
||||||
text: "Yêu cầu nâng cấp đã được gửi đ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",
|
icon: "success",
|
||||||
confirmButtonText: "OK",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setContent("");
|
setContent("");
|
||||||
setPendingFiles([]);
|
setPendingFiles([]);
|
||||||
setVerifyType("ID_CARD");
|
setVerifyType("OTHER");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Lỗi:", error);
|
console.error("Lỗi submit:", error);
|
||||||
toast.error("Có lỗi xảy ra khi gửi yêu cầu. Vui lòng kiểm tra lại!");
|
toast.error("Có lỗi xảy ra khi gửi yêu cầu.");
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -155,10 +166,53 @@ export default function RoleUpgrade() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-4xl mx-auto pb-20">
|
||||||
<PageBreadcrumb pageTitle="Role Upgrade" />
|
<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">
|
<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 có 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">
|
<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">
|
<label className="block mb-3 text-sm font-semibold text-gray-700 dark:text-white">
|
||||||
@@ -167,43 +221,20 @@ export default function RoleUpgrade() {
|
|||||||
<select
|
<select
|
||||||
value={verifyType}
|
value={verifyType}
|
||||||
onChange={(e) => setVerifyType(e.target.value)}
|
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="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="EDUCATION">Bằng cấp giáo dục (Education)</option>
|
||||||
<option value="EXPERT">Chứng nhận chuyên gia (Expert)</option>
|
<option value="EXPERT">Chứng nhận chuyên gia (Expert)</option>
|
||||||
</select>
|
</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>
|
</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="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">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="text-lg font-semibold text-gray-800 dark:text-white">
|
<h3 className="text-lg font-semibold">Tài liệu đính kèm</h3>
|
||||||
Tài liệu đính kèm
|
<label className="cursor-pointer px-4 py-2 rounded-lg border border-dashed border-blue-500 text-blue-500 hover:bg-blue-50 transition">
|
||||||
</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>
|
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
multiple
|
multiple
|
||||||
@@ -211,50 +242,41 @@ export default function RoleUpgrade() {
|
|||||||
accept="image/*,.pdf,.doc,.docx"
|
accept="image/*,.pdf,.doc,.docx"
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
/>
|
/>
|
||||||
|
<span>+ Thêm tệp</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{pendingFiles.length > 0 && (
|
{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) => {
|
{pendingFiles.map((item) => {
|
||||||
const docStyle = getDocumentStyle(item.extension);
|
const docStyle = getDocumentStyle(item.extension);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={() => handleItemClick(item)}
|
className="relative group aspect-square border rounded-xl overflow-hidden bg-gray-50"
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
{item.type === "image" ? (
|
{item.type === "image" ? (
|
||||||
<Image
|
<Image
|
||||||
src={item.previewUrl}
|
src={item.previewUrl}
|
||||||
alt={item.name}
|
alt="preview"
|
||||||
fill
|
fill
|
||||||
className="object-cover transition-transform group-hover:scale-110"
|
className="object-cover"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<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
|
<span className={`text-xs font-bold ${docStyle.color}`}>
|
||||||
className={`w-10 h-10 ${docStyle.color} mb-2`}
|
{item.extension.toUpperCase()}
|
||||||
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>
|
</span>
|
||||||
<span className="text-[10px] text-gray-500 mt-1 uppercase font-bold">
|
<span className="text-[10px] truncate w-full text-center mt-1">
|
||||||
{item.extension}
|
{item.name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={(e) => removePendingFile(item.id, e)}
|
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
|
<svg
|
||||||
className="w-3 h-3"
|
className="w-3 h-3"
|
||||||
@@ -262,32 +284,9 @@ export default function RoleUpgrade() {
|
|||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<path
|
<path d="M6 18L18 6M6 6l12 12" />
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M6 18L18 6M6 6l12 12"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -298,26 +297,19 @@ export default function RoleUpgrade() {
|
|||||||
<button
|
<button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={isSubmitting || isPreparingFiles}
|
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
|
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 || isPreparingFiles ? "bg-gray-400 cursor-not-allowed" : "bg-blue-600 hover:bg-blue-700 active:scale-[0.98]"}`}
|
|
||||||
>
|
>
|
||||||
{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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Lightbox
|
<Lightbox
|
||||||
index={lightboxIndex}
|
index={lightboxIndex}
|
||||||
open={lightboxIndex >= 0}
|
open={lightboxIndex >= 0}
|
||||||
close={() => setLightboxIndex(-1)}
|
close={() => setLightboxIndex(-1)}
|
||||||
slides={slides}
|
slides={slides}
|
||||||
plugins={[Zoom, Captions]}
|
plugins={[Zoom, Captions]}
|
||||||
zoom={{ maxZoomPixelRatio: 10 }}
|
|
||||||
styles={{
|
|
||||||
root: {
|
|
||||||
zIndex: 999999,
|
|
||||||
"--yarl__color_backdrop": "rgba(0, 0, 0, 0.9)",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +1,212 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
import { RootState } from "@/store/store";
|
import { RootState } from "@/store/store";
|
||||||
import { SafeHTMLRenderer } from "@/components/ui/parse/SafeHTMLRenderer";
|
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() {
|
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) {
|
if (!application) {
|
||||||
return <div className="p-10 text-center">Đang tải hoặc không có dữ liệu...</div>;
|
return (
|
||||||
|
<div className="p-10 text-center text-zinc-500 font-medium">
|
||||||
|
Đang tải hoặc không có 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 (
|
return (
|
||||||
<div className="p-6 bg-white dark:bg-gray-900 rounded-xl shadow">
|
<div className="max-w-5xl mx-auto p-6 bg-white dark:bg-zinc-900 rounded-2xl shadow-sm border dark:border-zinc-800">
|
||||||
<h2 className="text-2xl font-bold mb-6 border-b pb-2">Chi tiết Application</h2>
|
<div className="flex justify-between items-center mb-8 border-b dark:border-zinc-800 pb-6">
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-semibold text-gray-400 uppercase">Loại yêu cầu:</label>
|
<h2 className="text-xl font-semibold dark:text-zinc-50 tracking-tight">
|
||||||
<p className="text-lg font-medium">{application.verify_type}</p>
|
Chi tiết đơn đăng ký
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div
|
||||||
<label className="text-sm font-semibold text-gray-400 uppercase mb-3 block">
|
className={`flex items-center gap-1.5 px-2 py-1 rounded-md backdrop-blur-md border ${config.container}`}
|
||||||
Nội dung hiển thị (CV):
|
>
|
||||||
</label>
|
<span className={`text-[12px] font-bold uppercase tracking-wider`}>
|
||||||
|
{application.status}
|
||||||
{/* SỬ DỤNG Ở ĐÂY */}
|
</span>
|
||||||
<div className="border border-gray-100 rounded-lg p-2 bg-gray-50">
|
|
||||||
<SafeHTMLRenderer html={application.content} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Các phần khác như Media... */}
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,15 @@
|
|||||||
import React, { useEffect, useRef, 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 { apiUpdateApplicationStatus } from "@/service/adminService";
|
import { apiUpdateApplicationStatus } from "@/service/adminService";
|
||||||
import Link from "next/link";
|
|
||||||
import { URL_MEDIA } from "../../../api";
|
import { URL_MEDIA } from "../../../api";
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -24,6 +28,9 @@ export default function ApplicationDetailModal({
|
|||||||
const [reviewNote, setReviewNote] = useState("");
|
const [reviewNote, setReviewNote] = useState("");
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
const [index, setIndex] = useState(-1);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen && application) {
|
if (isOpen && application) {
|
||||||
setReviewNote(application.review_note || "");
|
setReviewNote(application.review_note || "");
|
||||||
@@ -32,21 +39,44 @@ export default function ApplicationDetailModal({
|
|||||||
}
|
}
|
||||||
}, [isOpen, application]);
|
}, [isOpen, application]);
|
||||||
|
|
||||||
|
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 mediaList = application?.media || [];
|
||||||
|
const imageMediaOnly = mediaList.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 handleUpdateStatus = async (status: "APPROVED" | "REJECTED") => {
|
const handleUpdateStatus = async (status: "APPROVED" | "REJECTED") => {
|
||||||
if (!application) return;
|
if (!application) return;
|
||||||
|
|
||||||
if (!reviewNote.trim()) {
|
if (!reviewNote.trim()) {
|
||||||
textareaRef.current?.focus();
|
textareaRef.current?.focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
await apiUpdateApplicationStatus(application.id, {
|
await apiUpdateApplicationStatus(application.id, {
|
||||||
status,
|
status,
|
||||||
review_note: reviewNote,
|
review_note: reviewNote,
|
||||||
});
|
});
|
||||||
|
|
||||||
Swal.fire("Thành công!", "Trạng thái hồ sơ đã được cập nhật.", "success");
|
Swal.fire("Thành công!", "Trạng thái hồ sơ đã được cập nhật.", "success");
|
||||||
onRefresh();
|
onRefresh();
|
||||||
onClose();
|
onClose();
|
||||||
@@ -60,65 +90,33 @@ export default function ApplicationDetailModal({
|
|||||||
if (!isOpen || !application) return null;
|
if (!isOpen || !application) return null;
|
||||||
|
|
||||||
const userData = application.user || {};
|
const userData = application.user || {};
|
||||||
const mediaList = application.media || [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-999 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 dark:bg-gray-900 flex flex-col overflow-hidden text-gray-800 dark:text-gray-200">
|
<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 */}
|
|
||||||
<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">Chi tiết yêu cầu nâng cấp</h3>
|
<h3 className="text-xl font-semibold">Chi tiết yêu cầu nâng cấp</h3>
|
||||||
<button
|
<button onClick={onClose} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-full">
|
||||||
onClick={onClose}
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-full transition-colors"
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="w-5 h-5"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M6 18L18 6M6 6l12 12"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* BODY */}
|
|
||||||
<div className="flex-1 p-6 overflow-y-auto custom-scrollbar">
|
<div className="flex-1 p-6 overflow-y-auto custom-scrollbar">
|
||||||
<div className="flex flex-col gap-8">
|
<div className="flex flex-col gap-8">
|
||||||
{/* THÔNG TIN NGƯỜI DÙNG */}
|
|
||||||
<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="p-5 border border-gray-200 rounded-xl dark:border-gray-800 bg-gray-50/50 dark:bg-white/[0.02]">
|
||||||
<h4 className="mb-4 text-xs font-bold text-gray-500 uppercase tracking-wider">
|
<h4 className="mb-4 text-xs font-bold text-gray-500 uppercase tracking-wider">Thông tin ứng viên</h4>
|
||||||
Thông tin ứng viên
|
|
||||||
</h4>
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="relative w-16 h-16 overflow-hidden border border-gray-200 rounded-full shrink-0 dark:border-gray-700">
|
<div className="relative w-16 h-16 overflow-hidden border border-gray-200 rounded-full shrink-0">
|
||||||
<Image
|
<Image fill src={userData.avatar_url || "/images/no-images.jpg"} alt="avatar" className="object-cover" />
|
||||||
fill
|
|
||||||
src={userData.avatar_url || "/images/no-images.jpg"}
|
|
||||||
alt="avatar"
|
|
||||||
className="object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-lg font-semibold">
|
<h4 className="text-lg font-semibold">{userData.display_name || "N/A"}</h4>
|
||||||
{userData.display_name || "N/A"}
|
<p className="text-sm text-gray-500">{userData.email || "Không có email"}</p>
|
||||||
</h4>
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
{userData.email || "Không có email"}
|
|
||||||
</p>
|
|
||||||
<div className="mt-1 flex gap-2">
|
<div className="mt-1 flex gap-2">
|
||||||
<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">
|
<span className="px-2 py-0.5 text-[10px] font-bold uppercase text-blue-600 bg-blue-100 rounded-md">{application.verify_type}</span>
|
||||||
{application.verify_type}
|
<span 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"}`}>
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
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"}`}
|
|
||||||
>
|
|
||||||
{application.status}
|
{application.status}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -127,80 +125,53 @@ export default function ApplicationDetailModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h4 className="mb-3 text-xs font-bold text-gray-500 uppercase tracking-wider">
|
<h4 className="mb-3 text-xs font-bold text-gray-500 uppercase tracking-wider">Nội dung ứng tuyển</h4>
|
||||||
Nội dung ứng tuyển
|
|
||||||
</h4>
|
|
||||||
<div
|
<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"
|
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={{
|
dangerouslySetInnerHTML={{ __html: application.content || "<i>Không có nội dung.</i>" }}
|
||||||
__html: application.content || "<i>Không có nội dung.</i>",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h4 className="mb-3 text-xs font-bold text-gray-500 uppercase tracking-wider">
|
<h4 className="mb-3 text-xs font-bold text-gray-500 uppercase tracking-wider">Tệp đính kèm ({mediaList.length})</h4>
|
||||||
Tệp đính kèm ({mediaList.length})
|
|
||||||
</h4>
|
|
||||||
{mediaList.length > 0 ? (
|
{mediaList.length > 0 ? (
|
||||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
|
||||||
{mediaList.map((media: any, idx: number) => {
|
{mediaList.map((media: any, idx: number) => {
|
||||||
const isImage = media.mime_type?.startsWith("image/");
|
const isImg = isImageFile(media);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={idx}
|
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"
|
onClick={() => handleMediaClick(media)}
|
||||||
|
className="relative overflow-hidden border border-gray-200 group aspect-square rounded-xl dark:border-gray-700 bg-gray-50 dark:bg-gray-800 cursor-pointer"
|
||||||
>
|
>
|
||||||
{isImage ? (
|
{isImg ? (
|
||||||
<Image
|
<Image src={`${URL_MEDIA}${media.storage_key}`} alt="media" fill className="object-cover transition-transform duration-300 group-hover:scale-110" />
|
||||||
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">
|
<div className="flex flex-col items-center justify-center w-full h-full p-3 text-center">
|
||||||
<svg
|
<div className="w-10 h-10 mb-2 flex items-center justify-center bg-white dark:bg-zinc-800 rounded-lg shadow-sm text-xl">📄</div>
|
||||||
className="w-10 h-10 mb-2 text-blue-500"
|
<span className="text-[10px] font-medium text-gray-600 line-clamp-2">{media.original_name}</span>
|
||||||
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Link
|
<div className="absolute inset-0 flex items-center justify-center transition-opacity bg-black/40 opacity-0 group-hover:opacity-100 z-10">
|
||||||
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">
|
<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
|
{isImg ? "Xem ảnh" : "Xem file"}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm italic text-gray-400">
|
<p className="text-sm italic text-gray-400">Không có tệp đính kèm.</p>
|
||||||
Không có tệp đính kèm.
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* GHI CHÚ */}
|
|
||||||
<div>
|
<div>
|
||||||
<h4 className="mb-3 text-xs font-bold text-gray-500 uppercase tracking-wider">
|
<h4 className="mb-3 text-xs font-bold text-gray-500 uppercase tracking-wider">Ghi chú duyệt hồ sơ</h4>
|
||||||
Ghi chú duyệt hồ sơ
|
|
||||||
</h4>
|
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
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]"
|
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 h-[100px]"
|
||||||
placeholder="Nhập lý do (bắt buộc) hoặc ghi chú thêm..."
|
placeholder="Nhập lý do (bắt buộc)..."
|
||||||
value={reviewNote}
|
value={reviewNote}
|
||||||
onChange={(e) => setReviewNote(e.target.value)}
|
onChange={(e) => setReviewNote(e.target.value)}
|
||||||
disabled={application.status !== "PENDING"}
|
disabled={application.status !== "PENDING"}
|
||||||
@@ -209,34 +180,25 @@ export default function ApplicationDetailModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 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} className="px-5 py-2 text-sm font-medium border border-gray-300 rounded-xl hover:bg-gray-100 transition-colors">Đóng</button>
|
||||||
onClick={onClose}
|
|
||||||
className="px-5 py-2 text-sm font-medium border border-gray-300 rounded-xl hover:bg-gray-100 transition-colors"
|
|
||||||
>
|
|
||||||
Đóng
|
|
||||||
</button>
|
|
||||||
{application.status === "PENDING" && (
|
{application.status === "PENDING" && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button onClick={() => handleUpdateStatus("REJECTED")} disabled={isSubmitting} className="px-5 py-2 text-sm font-medium text-white bg-red-500 rounded-xl hover:bg-red-600">Từ chối</button>
|
||||||
onClick={() => handleUpdateStatus("REJECTED")}
|
<button onClick={() => handleUpdateStatus("APPROVED")} disabled={isSubmitting} className="px-5 py-2 text-sm font-medium text-white bg-green-500 rounded-xl hover:bg-green-600">Phê duyệt</button>
|
||||||
disabled={isSubmitting}
|
|
||||||
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
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleUpdateStatus("APPROVED")}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
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
|
|
||||||
</button>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Lightbox
|
||||||
|
index={index}
|
||||||
|
open={index >= 0}
|
||||||
|
close={() => setIndex(-1)}
|
||||||
|
slides={imageSlides}
|
||||||
|
plugins={[Zoom, Captions]}
|
||||||
|
styles={{ root: { zIndex: 999999 } }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -4,11 +4,19 @@ import { setSelectedApplication } from "@/store/features/userSlice";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useDispatch } from "react-redux";
|
import { useDispatch } from "react-redux";
|
||||||
import { URL_MEDIA } from "../../../api";
|
import { URL_MEDIA } from "../../../api";
|
||||||
|
import { statusConfig } from "@/service/handler";
|
||||||
|
|
||||||
const formatFullDateTime = (dateString: string) => {
|
const formatFullDateTime = (dateString: string) => {
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
const time = date.toLocaleTimeString("vi-VN", { hour: "2-digit", minute: "2-digit" });
|
const time = date.toLocaleTimeString("vi-VN", {
|
||||||
const day = date.toLocaleDateString("vi-VN", { day: "2-digit", month: "2-digit", year: "numeric" });
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
const day = date.toLocaleDateString("vi-VN", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
return `${time} ${day}`;
|
return `${time} ${day}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -20,18 +28,29 @@ const processMedia = (mediaArray: any[]) => {
|
|||||||
return isImageMime || isImageExt;
|
return isImageMime || isImageExt;
|
||||||
});
|
});
|
||||||
const docFiles = mediaArray.filter((file) => {
|
const docFiles = mediaArray.filter((file) => {
|
||||||
const isImage = file.mime_type?.startsWith("image/") || /\.(jpg|jpeg|png|webp|gif)$/i.test(file.storage_key);
|
const isImage =
|
||||||
|
file.mime_type?.startsWith("image/") ||
|
||||||
|
/\.(jpg|jpeg|png|webp|gif)$/i.test(file.storage_key);
|
||||||
return !isImage;
|
return !isImage;
|
||||||
});
|
});
|
||||||
if (imageFiles.length > 0) return { type: "image", src: `${URL_MEDIA}${imageFiles[0].storage_key}` };
|
if (imageFiles.length > 0)
|
||||||
|
return { type: "image", src: `${URL_MEDIA}${imageFiles[0].storage_key}` };
|
||||||
if (docFiles.length > 0) {
|
if (docFiles.length > 0) {
|
||||||
const extensions = docFiles.map((file) => file.mime_type ? file.mime_type.split("/")[1] : file.storage_key.split(".").pop() || "file");
|
const extensions = docFiles.map((file) =>
|
||||||
|
file.mime_type
|
||||||
|
? file.mime_type.split("/")[1]
|
||||||
|
: file.storage_key.split(".").pop() || "file",
|
||||||
|
);
|
||||||
return { type: "documents", extensions };
|
return { type: "documents", extensions };
|
||||||
}
|
}
|
||||||
return { type: "empty" };
|
return { type: "empty" };
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ApplicationSquareCardList({ applications }: { applications: any[] }) {
|
export default function ApplicationSquareCardList({
|
||||||
|
applications,
|
||||||
|
}: {
|
||||||
|
applications: any[];
|
||||||
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
@@ -45,16 +64,21 @@ export default function ApplicationSquareCardList({ applications }: { applicatio
|
|||||||
<h4 className="text-lg font-bold text-zinc-800 dark:text-white/90 mb-5 tracking-tight">
|
<h4 className="text-lg font-bold text-zinc-800 dark:text-white/90 mb-5 tracking-tight">
|
||||||
Applications CV
|
Applications CV
|
||||||
</h4>
|
</h4>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-4">
|
<div className="flex flex-wrap gap-4">
|
||||||
{applications?.map((app) => {
|
{applications?.map((app) => {
|
||||||
const mediaState = processMedia(app.media);
|
const mediaState = processMedia(app.media);
|
||||||
|
|
||||||
|
// --- LOGIC STATUS NẰM TRONG VÒNG LẶP ---
|
||||||
|
const config = statusConfig[app.status] || statusConfig.PENDING;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={app.id}
|
key={app.id}
|
||||||
onClick={() => handleViewDetail(app)}
|
onClick={() => handleViewDetail(app)}
|
||||||
className="group relative h-40 aspect-square border dark:border-zinc-800 rounded-xl cursor-pointer overflow-hidden transition-all duration-300 hover:ring-2 hover:ring-blue-500/50"
|
className="group relative h-60 aspect-square border dark:border-zinc-800 rounded-xl cursor-pointer overflow-hidden transition-all duration-300 hover:ring-2 hover:ring-blue-500/50"
|
||||||
>
|
>
|
||||||
|
{/* BACKGROUND */}
|
||||||
<div className="absolute inset-0 z-0">
|
<div className="absolute inset-0 z-0">
|
||||||
{mediaState.type === "image" ? (
|
{mediaState.type === "image" ? (
|
||||||
<img
|
<img
|
||||||
@@ -67,7 +91,10 @@ export default function ApplicationSquareCardList({ applications }: { applicatio
|
|||||||
{mediaState.type === "documents" ? (
|
{mediaState.type === "documents" ? (
|
||||||
<div className="flex flex-wrap gap-1 justify-center">
|
<div className="flex flex-wrap gap-1 justify-center">
|
||||||
{mediaState.extensions?.slice(0, 3).map((ext, i) => (
|
{mediaState.extensions?.slice(0, 3).map((ext, i) => (
|
||||||
<span key={i} className="text-[9px] font-black px-1.5 py-0.5 bg-white dark:bg-zinc-800 rounded border dark:border-zinc-700 uppercase">
|
<span
|
||||||
|
key={i}
|
||||||
|
className="text-[9px] font-black px-1.5 py-0.5 bg-white dark:bg-zinc-800 rounded border dark:border-zinc-700 uppercase"
|
||||||
|
>
|
||||||
.{ext}
|
.{ext}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
@@ -79,15 +106,24 @@ export default function ApplicationSquareCardList({ applications }: { applicatio
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* OVERLAY */}
|
||||||
<div className="absolute inset-0 z-10 bg-gradient-to-t from-black/90 via-black/20 to-transparent transition-opacity group-hover:opacity-90" />
|
<div className="absolute inset-0 z-10 bg-gradient-to-t from-black/90 via-black/20 to-transparent transition-opacity group-hover:opacity-90" />
|
||||||
|
|
||||||
|
{/* TOP INFO: STATUS & FILE COUNT */}
|
||||||
<div className="absolute top-2 left-2 right-2 z-20 flex justify-between items-start">
|
<div className="absolute top-2 left-2 right-2 z-20 flex justify-between items-start">
|
||||||
<div className={`flex items-center gap-1.5 rounded-md backdrop-blur-md border border-white/10 ${
|
<div
|
||||||
app.status === "PENDING" ? "bg-amber-500/20 text-amber-400" : "bg-emerald-500/20 text-emerald-400"
|
className={`flex items-center p-1 rounded-full border ${config.container}`}
|
||||||
}`}>
|
>
|
||||||
<span className={`w-2 h-2 rounded-full animate-pulse ${app.status === "PENDING" ? "bg-amber-500" : "bg-emerald-500"}`} />
|
<span
|
||||||
|
className={`w-2 h-2 rounded-full ${config.dot} ${
|
||||||
|
app.status === "PENDING" ? "animate-pulse" : ""
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{/* <span className="text-[9px] font-bold uppercase tracking-wider">
|
||||||
|
{app.status}
|
||||||
|
</span> */}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{app.media?.length > 0 && (
|
{app.media?.length > 0 && (
|
||||||
<span className="text-[9px] font-bold text-white/60 bg-black/40 px-1.5 py-0.5 rounded-md backdrop-blur-sm">
|
<span className="text-[9px] font-bold text-white/60 bg-black/40 px-1.5 py-0.5 rounded-md backdrop-blur-sm">
|
||||||
{app.media.length} FILE
|
{app.media.length} FILE
|
||||||
@@ -95,13 +131,14 @@ export default function ApplicationSquareCardList({ applications }: { applicatio
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* BOTTOM INFO */}
|
||||||
<div className="absolute bottom-2 left-2 right-2 z-20 text-white">
|
<div className="absolute bottom-2 left-2 right-2 z-20 text-white">
|
||||||
<div className="mb-1">
|
<div className="mb-1">
|
||||||
<p className="text-[10px] font-black tracking-tighter truncate">
|
<p className="text-[10px] font-bold uppercase opacity-80 truncate">
|
||||||
{app.verify_type || "VERIFY"}
|
{app.verify_type || "VERIFY"}
|
||||||
</p>
|
</p>
|
||||||
{app?.reviewer?.display_name && (
|
{app?.reviewer?.display_name && (
|
||||||
<p className="text-[9px] font-medium text-white/50 truncate">
|
<p className="text-[9px] font-medium text-blue-400 truncate">
|
||||||
By: {app.reviewer.display_name}
|
By: {app.reviewer.display_name}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -112,8 +149,18 @@ export default function ApplicationSquareCardList({ applications }: { applicatio
|
|||||||
{formatFullDateTime(app.created_at)}
|
{formatFullDateTime(app.created_at)}
|
||||||
</p>
|
</p>
|
||||||
<div className="transform translate-x-2 opacity-0 group-hover:translate-x-0 group-hover:opacity-100 transition-all duration-300">
|
<div className="transform translate-x-2 opacity-0 group-hover:translate-x-0 group-hover:opacity-100 transition-all duration-300">
|
||||||
<svg className="w-4 h-4 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
className="w-4 h-4 text-blue-400"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={3}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M13 7l5 5m0 0l-5 5m5-5H6"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -124,4 +171,4 @@ export default function ApplicationSquareCardList({ applications }: { applicatio
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
29
src/service/handler.ts
Normal file
29
src/service/handler.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
export const statusConfig: Record<string, { container: string; dot: string }> = {
|
||||||
|
PENDING: {
|
||||||
|
container: `
|
||||||
|
bg-amber-50
|
||||||
|
border-amber-200
|
||||||
|
text-amber-700
|
||||||
|
shadow-sm
|
||||||
|
`,
|
||||||
|
dot: "bg-amber-500",
|
||||||
|
},
|
||||||
|
APPROVED: {
|
||||||
|
container: `
|
||||||
|
bg-emerald-50
|
||||||
|
border-emerald-200
|
||||||
|
text-emerald-700
|
||||||
|
shadow-sm
|
||||||
|
`,
|
||||||
|
dot: "bg-emerald-500",
|
||||||
|
},
|
||||||
|
REJECTED: {
|
||||||
|
container: `
|
||||||
|
bg-red-50
|
||||||
|
border-red-200
|
||||||
|
text-red-700
|
||||||
|
shadow-sm
|
||||||
|
`,
|
||||||
|
dot: "bg-red-500",
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -10,3 +10,8 @@ export const apiGetUserApplications = async (payload :any) => {
|
|||||||
const response = await api.get(API.Historian.APPLICATION, { params: payload });
|
const response = await api.get(API.Historian.APPLICATION, { params: payload });
|
||||||
return response?.data;
|
return response?.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const apiDeleteHistorianCV = async (id: number | string) => {
|
||||||
|
const response = await api.delete(API.Historian.DELETE_CV(id));
|
||||||
|
return response?.data;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user