This commit is contained in:
2026-04-18 18:02:21 +07:00
parent 9d35fd3653
commit 300b35190d
14 changed files with 439 additions and 107 deletions

View File

@@ -8,6 +8,7 @@ import Link from "next/link";
import { useState } from "react";
import { toast } from "sonner";
import { API, HOME_URL } from "../../../api";
import Swal from "sweetalert2";
export default function SignUpForm() {
const [showPassword, setShowPassword] = useState(false);
@@ -102,7 +103,7 @@ export default function SignUpForm() {
};
const signupRes = await apiSignUp(signupPayload);
alert("Đăng ký thành công! Đang chuyển hướng...");
Swal.fire("Đăng ký thành công! Đang chuyển hướng về trang đăng nhập!");
window.location.href = "/signin";
} catch (error) {

View File

@@ -11,6 +11,8 @@ 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 { IsolatedContent } from "@/components/ui/IsolatedContent";
import { apiDeleteHistorianCV } from "@/service/historianService";
interface Props {
isOpen: boolean;
@@ -57,7 +59,9 @@ export default function ApplicationDetailModal({
const handleMediaClick = (item: any) => {
const fileUrl = `${URL_MEDIA}${item.storage_key}`;
if (isImageFile(item)) {
const photoIndex = imageMediaOnly.findIndex((img: any) => img.id === item.id);
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`;
@@ -87,6 +91,13 @@ export default function ApplicationDetailModal({
}
};
const handleDeleteApplication = async () => {
await apiDeleteHistorianCV(application.id);
Swal.fire("Thành công!", "Hồ sơ đã được xóa.", "success");
onRefresh();
onClose();
};
if (!isOpen || !application) return null;
const userData = application.user || {};
@@ -96,9 +107,22 @@ export default function ApplicationDetailModal({
<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="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>
<button onClick={onClose} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-full">
<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" />
<button
onClick={onClose}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-full"
>
<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>
</button>
</div>
@@ -106,17 +130,32 @@ export default function ApplicationDetailModal({
<div className="flex-1 p-6 overflow-y-auto custom-scrollbar">
<div className="flex flex-col gap-8">
<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">Thông tin ng viên</h4>
<h4 className="mb-4 text-xs font-bold text-gray-500 uppercase tracking-wider">
Thông tin ng viên
</h4>
<div className="flex items-center gap-4">
<div className="relative w-16 h-16 overflow-hidden border border-gray-200 rounded-full shrink-0">
<Image fill src={userData.avatar_url || "/images/no-images.jpg"} alt="avatar" className="object-cover" />
<Image
fill
src={userData.avatar_url || "/images/no-images.jpg"}
alt="avatar"
className="object-cover"
/>
</div>
<div>
<h4 className="text-lg font-semibold">{userData.display_name || "N/A"}</h4>
<p className="text-sm text-gray-500">{userData.email || "Không có email"}</p>
<h4 className="text-lg font-semibold">
{userData.display_name || "N/A"}
</h4>
<p className="text-sm text-gray-500">
{userData.email || "Không có email"}
</p>
<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">{application.verify_type}</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"}`}>
<span className="px-2 py-0.5 text-[10px] font-bold uppercase text-blue-600 bg-blue-100 rounded-md">
{application.verify_type}
</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}
</span>
</div>
@@ -125,15 +164,18 @@ export default function ApplicationDetailModal({
</div>
<div>
<h4 className="mb-3 text-xs font-bold text-gray-500 uppercase tracking-wider">Nội dung ng tuyển</h4>
<div
className="p-4 prose bg-white border border-gray-200 min-h-[100px] rounded-xl dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200 max-w-none"
dangerouslySetInnerHTML={{ __html: application.content || "<i>Không có nội dung.</i>" }}
/>
<h4 className="mb-3 text-xs font-bold text-gray-500 uppercase tracking-wider">
Nội dung ng tuyển
</h4>
<div className="p-4 prose bg-white border border-gray-200 min-h-[100px] rounded-xl dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200 max-w-none">
<IsolatedContent html={application.content} />
</div>
</div>
<div>
<h4 className="mb-3 text-xs font-bold text-gray-500 uppercase tracking-wider">Tệp đính kèm ({mediaList.length})</h4>
<h4 className="mb-3 text-xs font-bold text-gray-500 uppercase tracking-wider">
Tệp đính kèm ({mediaList.length})
</h4>
{mediaList.length > 0 ? (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
{mediaList.map((media: any, idx: number) => {
@@ -145,11 +187,20 @@ export default function ApplicationDetailModal({
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"
>
{isImg ? (
<Image src={`${URL_MEDIA}${media.storage_key}`} alt="media" fill className="object-cover transition-transform duration-300 group-hover:scale-110" />
<Image
src={`${URL_MEDIA}${media.storage_key}`}
alt="media"
fill
className="object-cover transition-transform duration-300 group-hover:scale-110"
/>
) : (
<div className="flex flex-col items-center justify-center w-full h-full p-3 text-center">
<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>
<span className="text-[10px] font-medium text-gray-600 line-clamp-2">{media.original_name}</span>
<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>
<span className="text-[10px] font-medium text-gray-600 line-clamp-2">
{media.original_name}
</span>
</div>
)}
<div className="absolute inset-0 flex items-center justify-center transition-opacity bg-black/40 opacity-0 group-hover:opacity-100 z-10">
@@ -162,12 +213,16 @@ export default function ApplicationDetailModal({
})}
</div>
) : (
<p className="text-sm italic text-gray-400">Không tệp đính kèm.</p>
<p className="text-sm italic text-gray-400">
Không tệp đính kèm.
</p>
)}
</div>
<div>
<h4 className="mb-3 text-xs font-bold text-gray-500 uppercase tracking-wider">Ghi chú duyệt hồ </h4>
<h4 className="mb-3 text-xs font-bold text-gray-500 uppercase tracking-wider">
Ghi chú duyệt hồ
</h4>
<textarea
ref={textareaRef}
className="w-full p-4 text-sm bg-white border border-gray-200 rounded-xl dark:border-gray-800 dark:bg-gray-900 focus:ring-2 focus:ring-blue-500 outline-none h-[100px]"
@@ -181,13 +236,36 @@ export default function ApplicationDetailModal({
</div>
<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 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" && (
<>
<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>
<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>
<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>
<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>
</>
)}
<button
onClick={handleDeleteApplication}
className="px-5 py-2 text-sm font-medium text-white bg-red-500 rounded-xl hover:bg-red-600"
>
Xóa
</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>
</div>
</div>
@@ -201,4 +279,4 @@ export default function ApplicationDetailModal({
/>
</div>
);
}
}

View File

@@ -5,7 +5,7 @@ type PaginationProps = {
};
const Pagination: React.FC<PaginationProps> = ({
currentPage,
currentPage,
totalPages,
onPageChange,
}) => {

View File

@@ -0,0 +1,41 @@
import { useEffect, useRef } from "react";
export function IsolatedContent({ html }: { html: string }) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (containerRef.current) {
let shadow = containerRef.current.shadowRoot;
if (!shadow) {
shadow = containerRef.current.attachShadow({ mode: "open" });
}
const styleTag = `
<style>
:host {
display: block;
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
color: inherit;
line-height: 1.6;
}
.inner-content {
font-size: 14.5px;
color: currentColor;
}
p { margin-bottom: 1rem; }
h1, h2, h3 { margin-top: 1.5rem; margin-bottom: 0.5rem; font-weight: 700; line-height: 1.2; }
ul, ol { padding-left: 1.5rem; margin-bottom: 1rem; }
li { margin-bottom: 0.25rem; }
img { max-width: 100%; height: auto; border-radius: 8px; }
a { color: #3b82f6; text-decoration: underline; }
blockquote { border-left: 4px solid #e5e7eb; padding-left: 1rem; font-style: italic; color: #6b7280; }
</style>
`;
shadow.innerHTML = `${styleTag}<div class="inner-content">${html || "<i>Không có nội dung.</i>"}</div>`;
}
}, [html]);
return <div ref={containerRef} />;
}

View File

@@ -58,6 +58,57 @@ export default function ApplicationSquareCardList({
dispatch(setSelectedApplication(app));
router.push(`/profile/applications`);
};
// Tạo object mapping để render icon dựa trên status
const StatusIcons: Record<string, React.ReactNode> = {
APPROVED: (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
className="size-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
),
PENDING: (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
className="size-6 animate-pulse p-1 "
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 18 9 11.25l4.306 4.306a11.95 11.95 0 0 1 5.814-5.518l2.74-1.22m0 0-5.94-2.281m5.94 2.28-2.28 5.941"
/>
</svg>
),
REJECTED: (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
className="size-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m9.75 9.75 4.5 4.5m0-4.5-4.5 4.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
),
};
return (
<div className="p-5 border rounded-xl dark:border-zinc-800 lg:p-6 bg-white dark:bg-zinc-950">
@@ -68,8 +119,6 @@ export default function ApplicationSquareCardList({
<div className="flex flex-wrap gap-4">
{applications?.map((app) => {
const mediaState = processMedia(app.media);
// --- LOGIC STATUS NẰM TRONG VÒNG LẶP ---
const config = statusConfig[app.status] || statusConfig.PENDING;
return (
@@ -78,7 +127,7 @@ export default function ApplicationSquareCardList({
onClick={() => handleViewDetail(app)}
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 */}
{/* Media Layer */}
<div className="absolute inset-0 z-0">
{mediaState.type === "image" ? (
<img
@@ -106,32 +155,28 @@ export default function ApplicationSquareCardList({
)}
</div>
{/* OVERLAY */}
{/* Overlay Gradient */}
<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={`flex items-center p-1 rounded-full border ${config.container}`}
>
<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>
{app.media?.length > 0 && (
{/* TOP INFO: FILE COUNT & STATUS ICON */}
<div className="absolute top-2 left-2 right-2 z-20 flex justify-between items-center">
{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">
{app.media.length} FILE
</span>
) : (
<div />
)}
{/* Status Icon Wrapper */}
<div
className={`${config.container} rounded-full`}
>
{StatusIcons[app.status] || StatusIcons.PENDING}
</div>
</div>
{/* BOTTOM INFO */}
{/* Bottom Content */}
<div className="absolute bottom-2 left-2 right-2 z-20 text-white">
<div className="mb-1">
<p className="text-[10px] font-bold uppercase opacity-80 truncate">

View File

@@ -7,7 +7,7 @@ import Button from "../ui/button/Button";
import Input from "../form/input/InputField";
import Label from "../form/Label";
import { Profile, UserMetaCardProps } from "@/interface/user";
import { apiUpdateUser } from "@/service/userService";
import { apiUpdateUser, apiUpdateUserCurrent } from "@/service/userService";
import { toast } from "sonner";
import Link from "next/link";
@@ -51,7 +51,7 @@ export default function UserInfoCard({ data }: { data: UserMetaCardProps }) {
if (!userId) return;
try {
const response = await apiUpdateUser(userId, formData);
const response = await apiUpdateUserCurrent(formData);
if (response && response.status === false) {
toast.error(response.message || "Cập nhật thất bại.");
@@ -90,14 +90,6 @@ export default function UserInfoCard({ data }: { data: UserMetaCardProps }) {
</h4>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:gap-7 2xl:gap-x-32">
{/* <div>
<p className="mb-2 text-xs leading-normal text-gray-500 dark:text-gray-400">
Avatar
</p>
<p className="text-sm font-medium text-gray-800 dark:text-white/90">
{data.data?.profile?.avatar_url || "avatar_url"}
</p>
</div> */}
<div>
<p className="mb-2 text-xs leading-normal text-gray-500 dark:text-gray-400">
Full Name

View File

@@ -4,10 +4,13 @@ import { useState, useRef, useEffect } from "react";
import Image from "next/image";
import { UserMetaCardProps } from "@/interface/user";
import { uploadMedia } from "@/service/mediaService";
import { toast } from "sonner";
import { apiUpdateUser, apiUpdateUserCurrent } from "@/service/userService";
import { URL_MEDIA } from "../../../api";
export default function UserMetaCard({ data }: { data: UserMetaCardProps }) {
const currentAvatar = data.data?.profile?.avatar_url || "/images/no-images.jpg";
const currentAvatar =
data.data?.profile?.avatar_url || "/images/no-images.jpg";
const [previewImage, setPreviewImage] = useState(currentAvatar);
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -24,26 +27,40 @@ export default function UserMetaCard({ data }: { data: UserMetaCardProps }) {
}
};
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const handleFileChange = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
if (!file) return;
const objectUrl = URL.createObjectURL(file);
const backupImage = previewImage;
const backupImage = previewImage;
setPreviewImage(objectUrl);
try {
setIsUploading(true);
const uploadedMedia = await uploadMedia(file);
// console.log("Upload thành công:", uploadedMedia);
if (uploadedMedia?.url) {
setPreviewImage(uploadedMedia.url);
if (uploadedMedia?.storage_key) {
const url = URL_MEDIA + uploadedMedia.storage_key;
setPreviewImage(url);
if (data.data) {
try {
await apiUpdateUserCurrent({ avatar_url: url });
toast.success("Cập nhật avatar thành công!");
setTimeout(() => {
window.location.reload();
}, 1000);
} catch (error) {
console.error("Lỗi khi cập nhật avatar:", error);
toast.warning(
"Ảnh đã được tải lên nhưng không thể cập nhật hồ sơ. Vui lòng thử lại!",
);
}
}
}
} catch (error) {
console.error("Lỗi khi upload avatar:", error);
setPreviewImage(backupImage);
@@ -55,7 +72,6 @@ export default function UserMetaCard({ data }: { data: UserMetaCardProps }) {
}
}
};
return (
<div className="p-5 border border-gray-200 rounded-2xl dark:border-gray-800 lg:p-6">
<div className="flex flex-col gap-5 xl:flex-row xl:items-center xl:justify-between">
@@ -72,17 +88,47 @@ export default function UserMetaCard({ data }: { data: UserMetaCardProps }) {
className="object-cover w-full h-full"
priority
/>
<div className="absolute inset-0 flex items-center justify-center transition-opacity bg-black/50 opacity-0 group-hover:opacity-100">
{isUploading ? (
<svg className="w-6 h-6 text-white animate-spin" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
<svg
className="w-6 h-6 text-white animate-spin"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
) : (
<svg className="w-6 h-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
<svg
className="w-6 h-6 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
)}
</div>
@@ -102,7 +148,8 @@ export default function UserMetaCard({ data }: { data: UserMetaCardProps }) {
</h4>
<div className="flex flex-col items-center gap-1 text-center xl:flex-row xl:gap-3 xl:text-left">
<p className="text-sm text-blue-500 dark:text-gray-400">
{data.data?.roles?.map((role) => role.name).join(", ") || "No roles available"}
{data.data?.roles?.map((role) => role.name).join(", ") ||
"No roles available"}
</p>
{data.data?.profile?.bio && (
<>
@@ -118,4 +165,4 @@ export default function UserMetaCard({ data }: { data: UserMetaCardProps }) {
</div>
</div>
);
}
}