update application page, table.

This commit is contained in:
2026-04-16 11:54:58 +07:00
parent 720f13e9bb
commit 925b42199c
12 changed files with 265 additions and 91 deletions

1
constant.ts Normal file
View File

@@ -0,0 +1 @@
export const LIMIT_ITEM_TABLE = 5;

View File

@@ -6,7 +6,6 @@ import {
confirmUpload, confirmUpload,
getPresignedUrl, getPresignedUrl,
uploadFileToS3, uploadFileToS3,
uploadMedia,
} from "@/service/mediaService"; } from "@/service/mediaService";
import React, { useState } from "react"; import React, { useState } from "react";
import Image from "next/image"; import Image from "next/image";
@@ -16,6 +15,8 @@ import Captions from "yet-another-react-lightbox/plugins/captions";
import "yet-another-react-lightbox/styles.css"; import "yet-another-react-lightbox/styles.css";
import "yet-another-react-lightbox/plugins/captions.css"; import "yet-another-react-lightbox/plugins/captions.css";
import { createHistorianCV } from "@/service/historianService"; import { createHistorianCV } from "@/service/historianService";
import { toast } from "sonner";
import Swal from "sweetalert2";
type PendingFile = { type PendingFile = {
id: string; id: string;
@@ -32,10 +33,13 @@ 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 [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 handleContentChange = (value: string) => { const handleContentChange = (value: string) => {
setContent(value); setContent(value);
}; };
@@ -83,7 +87,7 @@ export default function RoleUpgrade() {
setPendingFiles((prev) => prev.filter((item) => item.id !== idToRemove)); setPendingFiles((prev) => prev.filter((item) => item.id !== idToRemove));
}; };
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
try { try {
setIsSubmitting(true); setIsSubmitting(true);
@@ -98,7 +102,7 @@ const handleSubmit = async (e: React.FormEvent) => {
const payload = { const payload = {
content: content, content: content,
media_ids: uploadedMediaIds, media_ids: uploadedMediaIds,
verify_type: "ID_CARD" verify_type: verifyType,
}; };
console.log("Payload chuẩn bị gửi (JSON):", payload); console.log("Payload chuẩn bị gửi (JSON):", payload);
@@ -106,15 +110,22 @@ const handleSubmit = async (e: React.FormEvent) => {
const cvResponse = await createHistorianCV(payload); const cvResponse = await createHistorianCV(payload);
console.log("Response từ API:", cvResponse); console.log("Response từ API:", cvResponse);
alert("Gửi thành công!"); Swal.fire({
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.",
icon: "success",
confirmButtonText: "OK",
});
setContent(""); setContent("");
setPendingFiles([]); setPendingFiles([]);
setVerifyType("ID_CARD");
} catch (error) { } catch (error) {
console.error("Lỗi:", 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!");
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
}; };
const imageFiles = pendingFiles.filter((f) => f.type === "image"); const imageFiles = pendingFiles.filter((f) => f.type === "image");
const slides = imageFiles.map((item) => ({ const slides = imageFiles.map((item) => ({
@@ -149,6 +160,28 @@ const handleSubmit = async (e: React.FormEvent) => {
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<RichTextEditor value={content} onChange={handleContentChange} /> <RichTextEditor value={content} onChange={handleContentChange} />
<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">
Loại hồ xác minh <span className="text-red-500">*</span>
</label>
<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"
>
<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="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>
<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">
<h3 className="text-lg font-semibold text-gray-800 dark:text-white"> <h3 className="text-lg font-semibold text-gray-800 dark:text-white">
@@ -182,7 +215,7 @@ const handleSubmit = async (e: React.FormEvent) => {
</div> </div>
{pendingFiles.length > 0 && ( {pendingFiles.length > 0 && (
<div className="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-5 gap-4 pt-4 border-t border-gray-100 dark:border-gray-800 mt-4"> <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">
{pendingFiles.map((item) => { {pendingFiles.map((item) => {
const docStyle = getDocumentStyle(item.extension); const docStyle = getDocumentStyle(item.extension);
@@ -190,14 +223,14 @@ const handleSubmit = async (e: React.FormEvent) => {
<div <div
key={item.id} key={item.id}
onClick={() => handleItemClick(item)} onClick={() => handleItemClick(item)}
className="group relative aspect-square cursor-pointer overflow-hidden rounded-xl border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 hover:border-blue-400 transition-colors" 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={item.name}
fill fill
className="object-cover group-hover:scale-110 transition-transform" className="object-cover transition-transform group-hover:scale-110"
/> />
) : ( ) : (
<div <div
@@ -210,7 +243,7 @@ const handleSubmit = async (e: React.FormEvent) => {
> >
<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" /> <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> </svg>
<span className="text-xs font-medium text-center text-gray-700 dark:text-gray-300 break-all line-clamp-2"> <span className="text-xs font-medium text-center text-gray-700 break-all line-clamp-2 dark:text-gray-300">
{item.name} {item.name}
</span> </span>
<span className="text-[10px] text-gray-500 mt-1 uppercase font-bold"> <span className="text-[10px] text-gray-500 mt-1 uppercase font-bold">
@@ -221,7 +254,7 @@ const handleSubmit = async (e: React.FormEvent) => {
<button <button
onClick={(e) => removePendingFile(item.id, e)} onClick={(e) => removePendingFile(item.id, e)}
className="absolute top-1 right-1 bg-red-500 text-white rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity z-10 hover:bg-red-600" 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"
> >
<svg <svg
className="w-3 h-3" className="w-3 h-3"
@@ -239,7 +272,7 @@ const handleSubmit = async (e: React.FormEvent) => {
</button> </button>
{item.type === "image" && ( {item.type === "image" && (
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center pointer-events-none"> <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 <svg
className="w-6 h-6 text-white" className="w-6 h-6 text-white"
fill="none" fill="none"
@@ -266,7 +299,7 @@ const handleSubmit = async (e: React.FormEvent) => {
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 transform flex justify-center items-center gap-2
${isSubmitting ? "bg-gray-400 cursor-not-allowed" : "bg-blue-600 hover:bg-blue-700 active:scale-[0.98]"}`} ${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 tải dữ liệu lên..." : "Gửi yêu cầu nâng cấp"}
</button> </button>

View File

@@ -16,23 +16,48 @@ import {
GetApplicationsParams, GetApplicationsParams,
} from "@/interface/historian"; } from "@/interface/historian";
import ApplicationDetailModal from "@/components/tables/ApplicationDetailModal"; import ApplicationDetailModal from "@/components/tables/ApplicationDetailModal";
import { LIMIT_ITEM_TABLE } from "../../../../../../constant";
import { toast } from "sonner";
const formatDateTimeToISO = (
dateStr: string,
timeStr: string,
isEndOfDay: boolean = false,
): string | undefined => {
if (!dateStr) return undefined;
const time = timeStr || (isEndOfDay ? "23:59" : "00:00");
return `${dateStr}T${time}:00.000000+07:00`;
};
export default function HistorianApplicationPage() { export default function HistorianApplicationPage() {
const [page, setPage] = useState<number>(1); const [page, setPage] = useState<number>(1);
const [limitInput, setLimitInput] = useState<string>("3"); const [limitInput, setLimitInput] = useState<string>(
LIMIT_ITEM_TABLE.toString(),
);
const [searchTerm, setSearchTerm] = useState<string>(""); const [searchTerm, setSearchTerm] = useState<string>("");
const [statusFilter, setStatusFilter] = useState<string>(""); const [statusFilter, setStatusFilter] = useState<string>("");
const [verifyTypeFilter, setVerifyTypeFilter] = useState<string>(""); const [verifyTypeFilter, setVerifyTypeFilter] = useState<string>("");
const [fromDate, setFromDate] = useState<string>("");
const [fromTime, setFromTime] = useState<string>("");
const [toDate, setToDate] = useState<string>("");
const [toTime, setToTime] = useState<string>("");
const [selectedApp, setSelectedApp] = useState<ApplicationDto | null>(null); const [selectedApp, setSelectedApp] = useState<ApplicationDto | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [debouncedParams, setDebouncedParams] = useState({ const [debouncedParams, setDebouncedParams] = useState({
search: "", search: "",
limit: 3, limit: LIMIT_ITEM_TABLE,
status: "", status: "",
verifyType: "", verifyType: "",
fromDate: "",
fromTime: "",
toDate: "",
toTime: "",
}); });
const [tableData, setTableData] = useState<ApplicationResponse | null>(null); const [tableData, setTableData] = useState<ApplicationResponse | null>(null);
@@ -41,18 +66,65 @@ export default function HistorianApplicationPage() {
const [sortBy, setSortBy] = useState<AppSortColumn>("created_at"); const [sortBy, setSortBy] = useState<AppSortColumn>("created_at");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc"); const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
const handleReset = () => {
setSearchTerm("");
setStatusFilter("");
setVerifyTypeFilter("");
setLimitInput(LIMIT_ITEM_TABLE.toString());
setFromDate("");
setFromTime("");
setToDate("");
setToTime("");
setPage(1);
setDebouncedParams({
search: "",
limit: LIMIT_ITEM_TABLE,
status: "",
verifyType: "",
fromDate: "",
fromTime: "",
toDate: "",
toTime: "",
});
};
useEffect(() => { useEffect(() => {
const handler = setTimeout(() => { const handler = setTimeout(() => {
setDebouncedParams({ setDebouncedParams({
search: searchTerm, search: searchTerm,
limit: parseInt(limitInput) || 3, limit: parseInt(limitInput) || LIMIT_ITEM_TABLE,
status: statusFilter, status: statusFilter,
verifyType: verifyTypeFilter, verifyType: verifyTypeFilter,
fromDate,
fromTime,
toDate,
toTime,
}); });
setPage(1); setPage(1);
}, 600); }, 600);
return () => clearTimeout(handler); return () => clearTimeout(handler);
}, [searchTerm, limitInput, statusFilter, verifyTypeFilter]); }, [
searchTerm,
limitInput,
statusFilter,
verifyTypeFilter,
fromDate,
fromTime,
toDate,
toTime,
]);
useEffect(() => {
fetchApplications();
}, [
page,
debouncedParams,
sortBy,
sortOrder,
]);
const fetchApplications = useCallback(async () => { const fetchApplications = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -65,16 +137,32 @@ export default function HistorianApplicationPage() {
order: sortOrder, order: sortOrder,
}; };
// Backend yêu cầu mảng cho các filter này
if (debouncedParams.status) payload.statuses = [debouncedParams.status]; if (debouncedParams.status) payload.statuses = [debouncedParams.status];
if (debouncedParams.verifyType) if (debouncedParams.verifyType)
payload.verify_types = [debouncedParams.verifyType]; payload.verify_types = debouncedParams.verifyType;
const createdFrom = formatDateTimeToISO(
debouncedParams.fromDate,
debouncedParams.fromTime,
false,
);
if (createdFrom) payload.created_from = createdFrom;
const createdTo = formatDateTimeToISO(
debouncedParams.toDate,
debouncedParams.toTime,
true,
);
if (createdTo) payload.created_to = createdTo;
// console.log("Payload sent to API:", payload);
const response = await apiGetUserApplications(payload); const response = await apiGetUserApplications(payload);
if (response?.status) { if (response?.status) {
setTableData(response); setTableData(response);
} }
} catch (err) { } catch (err) {
toast.error("Lỗi lấy danh sách hồ sơ");
console.error("Lỗi lấy danh sách hồ sơ:", err); console.error("Lỗi lấy danh sách hồ sơ:", err);
setTableData(null); setTableData(null);
} finally { } finally {
@@ -82,10 +170,6 @@ export default function HistorianApplicationPage() {
} }
}, [page, debouncedParams, sortBy, sortOrder]); }, [page, debouncedParams, sortBy, sortOrder]);
useEffect(() => {
fetchApplications();
}, [fetchApplications]);
const handleSort = (column: AppSortColumn) => { const handleSort = (column: AppSortColumn) => {
setPage(1); setPage(1);
if (sortBy === column) { if (sortBy === column) {
@@ -105,11 +189,35 @@ export default function HistorianApplicationPage() {
return ( return (
<div> <div>
<PageBreadcrumb pageTitle="Quản lý hồ sơ Sử gia" /> <PageBreadcrumb pageTitle="Quản lý hồ sơ" />
<div className="space-y-6"> <div className="space-y-6">
<ComponentCard title="Bộ lọc tìm kiếm"> <ComponentCard
<div className="grid grid-cols-1 gap-4 mb-6 md:grid-cols-2 lg:grid-cols-4"> title="Bộ lọc tìm kiếm"
headerAction={
<button
onClick={handleReset}
className="flex items-center px-3 py-1.5 text-xs text-red-500 transition-colors border-red-100 dark:bg-red-500/10 dark:border-red-500/20 dark:hover:bg-red-500/20"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-7 h-7"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2.5}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</button>
}
>
{/* Cập nhật Grid để chứa đủ các ô filter */}
<div className="grid grid-cols-1 gap-4 mb-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<div> <div>
<label className="block mb-2 text-sm font-medium">Tìm kiếm</label> <label className="block mb-2 text-sm font-medium">Tìm kiếm</label>
<input <input
@@ -154,6 +262,42 @@ export default function HistorianApplicationPage() {
</select> </select>
</div> </div>
<div>
<label className="block mb-2 text-sm font-medium">Từ ngày</label>
<div className="flex gap-2">
<input
type="date"
value={fromDate}
onChange={(e) => setFromDate(e.target.value)}
className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 outline-none focus:border-brand-500"
/>
<input
type="time"
value={fromTime}
onChange={(e) => setFromTime(e.target.value)}
className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 outline-none focus:border-brand-500"
/>
</div>
</div>
<div>
<label className="block mb-2 text-sm font-medium">Đến ngày</label>
<div className="flex gap-2">
<input
type="date"
value={toDate}
onChange={(e) => setToDate(e.target.value)}
className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 outline-none focus:border-brand-500"
/>
<input
type="time"
value={toTime}
onChange={(e) => setToTime(e.target.value)}
className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 outline-none focus:border-brand-500"
/>
</div>
</div>
<div> <div>
<label className="block mb-2 text-sm font-medium"> <label className="block mb-2 text-sm font-medium">
Hiển thị (Limit) Hiển thị (Limit)
@@ -205,7 +349,7 @@ export default function HistorianApplicationPage() {
isOpen={isModalOpen} isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)} onClose={() => setIsModalOpen(false)}
application={selectedApp || null} application={selectedApp || null}
onRefresh={fetchApplications} // Tải lại bảng sau khi Admin duyệt onRefresh={fetchApplications}
/> />
</div> </div>
); );

View File

@@ -281,18 +281,6 @@
} }
@layer utilities {
/* For Remove Date Icon */
input[type="date"]::-webkit-inner-spin-button,
input[type="time"]::-webkit-inner-spin-button,
input[type="date"]::-webkit-calendar-picker-indicator,
input[type="time"]::-webkit-calendar-picker-indicator {
display: none;
-webkit-appearance: none;
}
}
/* third-party libraries CSS */ /* third-party libraries CSS */
.apexcharts-legend-text { .apexcharts-legend-text {
@apply !text-gray-700 dark:!text-gray-400; @apply !text-gray-700 dark:!text-gray-400;

View File

@@ -5,6 +5,7 @@ interface ComponentCardProps {
children: React.ReactNode; children: React.ReactNode;
className?: string; // Additional custom classes for styling className?: string; // Additional custom classes for styling
desc?: string; // Description text desc?: string; // Description text
headerAction?: React.ReactNode;
} }
const ComponentCard: React.FC<ComponentCardProps> = ({ const ComponentCard: React.FC<ComponentCardProps> = ({
@@ -12,13 +13,14 @@ const ComponentCard: React.FC<ComponentCardProps> = ({
children, children,
className = "", className = "",
desc = "", desc = "",
headerAction,
}) => { }) => {
return ( return (
<div <div
className={`rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03] ${className}`} className={`rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03] ${className}`}
> >
{/* Card Header */} {/* Card Header */}
<div className="px-6 py-5"> <div className="px-6 py-5 flex items-center justify-between">
<h3 className="text-base font-medium text-gray-800 dark:text-white/90"> <h3 className="text-base font-medium text-gray-800 dark:text-white/90">
{title} {title}
</h3> </h3>
@@ -27,6 +29,7 @@ const ComponentCard: React.FC<ComponentCardProps> = ({
{desc} {desc}
</p> </p>
)} )}
{headerAction && <div>{headerAction}</div>}
</div> </div>
{/* Card Body */} {/* Card Body */}

View File

@@ -9,6 +9,8 @@ import {
apiUpdateApplicationStatus, apiUpdateApplicationStatus,
} from "@/service/adminService"; } from "@/service/adminService";
import { getMediaById } from "@/service/mediaService"; import { getMediaById } from "@/service/mediaService";
import Link from "next/link";
import { URL_MEDIA } from "../../../api";
interface Props { interface Props {
isOpen: boolean; isOpen: boolean;
@@ -23,6 +25,7 @@ export default function ApplicationDetailModal({
application, application,
onRefresh, onRefresh,
}: Props) { }: Props) {
const [userData, setUserData] = useState<any>(null); const [userData, setUserData] = useState<any>(null);
const [mediaList, setMediaList] = useState<any[]>([]); const [mediaList, setMediaList] = useState<any[]>([]);
const [reviewNote, setReviewNote] = useState(""); const [reviewNote, setReviewNote] = useState("");
@@ -46,7 +49,7 @@ export default function ApplicationDetailModal({
try { try {
const userRes = await apiGetUserById(application.user_id); const userRes = await apiGetUserById(application.user_id);
console.log("User data:", userRes); // console.log("User data:", userRes);
if (userRes?.data) setUserData(userRes.data); if (userRes?.data) setUserData(userRes.data);
@@ -83,7 +86,7 @@ export default function ApplicationDetailModal({
status: status, status: status,
review_note: reviewNote, review_note: reviewNote,
}; };
// console.log("Payload gửi lên API:", payload, "Application ID:", application.id);
await apiUpdateApplicationStatus(application.id, payload); await apiUpdateApplicationStatus(application.id, payload);
Swal.fire( Swal.fire(
@@ -115,7 +118,6 @@ export default function ApplicationDetailModal({
if (isImage || isPdf) { if (isImage || isPdf) {
window.open(media.url, "_blank"); window.open(media.url, "_blank");
} else { } else {
const link = document.createElement("a"); const link = document.createElement("a");
link.href = media.url; link.href = media.url;
link.download = media.original_name || "download"; link.download = media.original_name || "download";
@@ -124,6 +126,9 @@ export default function ApplicationDetailModal({
document.body.removeChild(link); document.body.removeChild(link);
} }
}; };
// console.log("ApplicationDetailModal:", application);
return ( return (
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"> <div className="fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<div className="w-full max-w-4xl max-h-[90vh] bg-white rounded-2xl shadow-xl dark:bg-gray-900 flex flex-col overflow-hidden"> <div className="w-full max-w-4xl max-h-[90vh] bg-white rounded-2xl shadow-xl dark:bg-gray-900 flex flex-col overflow-hidden">
@@ -186,15 +191,12 @@ export default function ApplicationDetailModal({
</p> </p>
<div className="mt-1"> <div className="mt-1">
<span className="px-2 py-0.5 text-[10px] font-medium tracking-wide text-blue-600 bg-blue-100 rounded-full dark:bg-blue-500/20 dark:text-blue-400"> <span className="px-2 py-0.5 text-[10px] font-medium tracking-wide text-blue-600 bg-blue-100 rounded-full dark:bg-blue-500/20 dark:text-blue-400">
{application.verify_type === 1 {application.verify_type}
? "CĂN CƯỚC CÔNG DÂN"
: "BẰNG CẤP / KHÁC"}
</span> </span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div> <div>
<h4 className="mb-3 text-sm font-bold text-gray-500 uppercase"> <h4 className="mb-3 text-sm font-bold text-gray-500 uppercase">
Nội dung ng tuyển Nội dung ng tuyển
@@ -246,7 +248,7 @@ export default function ApplicationDetailModal({
</div> </div>
)} )}
<button {/* <button
onClick={() => handleOpenFile(media)} onClick={() => handleOpenFile(media)}
className="absolute inset-0 flex items-center justify-center transition-opacity bg-black/50 opacity-0 group-hover:opacity-100" className="absolute inset-0 flex items-center justify-center transition-opacity bg-black/50 opacity-0 group-hover:opacity-100"
> >
@@ -255,7 +257,12 @@ export default function ApplicationDetailModal({
? "Xem ảnh" ? "Xem ảnh"
: "Tải tài liệu"} : "Tải tài liệu"}
</span> </span>
</button> </button> */}
<Link
className="absolute inset-0 flex items-center justify-center transition-opacity bg-black/50 opacity-0 group-hover:opacity-100"
href={`${URL_MEDIA}${media.storage_key}`}
target="_blank"
></Link>
</div> </div>
); );
})} })}

View File

@@ -122,7 +122,7 @@ export default function ApplicationTable({
<TableHeader className="border-b border-gray-100 dark:border-white/[0.05]"> <TableHeader className="border-b border-gray-100 dark:border-white/[0.05]">
<TableRow> <TableRow>
<TableCell isHeader className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400"> <TableCell isHeader className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400">
Người dùng (ID) Người gửi (ID)
</TableCell> </TableCell>
<TableCell isHeader className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400 min-w-[220px]"> <TableCell isHeader className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400 min-w-[220px]">
Loại xác minh Loại xác minh

View File

@@ -10,11 +10,9 @@ export interface MediaDto {
export interface ApplicationDto { export interface ApplicationDto {
id: string; id: string;
user_id: string; user_id: string;
// Sửa từ number thành string | number verify_type: string | number ;
verify_type: string | number | string[] | number[];
content: string; content: string;
is_deleted: boolean; is_deleted: boolean;
// Sửa status để nhận cả 1,2,3 hoặc PENDING, APPROVED...
status: string | number; status: string | number;
reviewed_by: string; reviewed_by: string;
review_note: string; review_note: string;
@@ -32,7 +30,7 @@ export interface GetApplicationsParams {
sort?: string; sort?: string;
order?: "asc" | "desc"; order?: "asc" | "desc";
statuses?: string[]; statuses?: string[];
verify_types?: string[]; verify_types?: string;
created_from?: string; created_from?: string;
created_to?: string; created_to?: string;
reviewed_by?: string; reviewed_by?: string;

View File

@@ -17,5 +17,5 @@ export interface MediaDto {
export interface payloadPresignedMedia { export interface payloadPresignedMedia {
filename:string; filename:string;
contentType:string; content_type:string;
} }

View File

@@ -33,7 +33,7 @@ export const apiGetUserMedia = async (id: string) => {
}; };
export const apiUpdateApplicationStatus = async (id: string, payload: any) => { export const apiUpdateApplicationStatus = async (id: string, payload: any) => {
const response = await api.patch(API.Admin.UPDATE_APPLICATION_STATUS(id), payload); const response = await api.put(API.Admin.UPDATE_APPLICATION_STATUS(id), payload);
return response?.data; return response?.data;
}; };

View File

@@ -8,6 +8,5 @@ export const createHistorianCV = async (payload: any) => {
export const apiGetUserApplications = async (payload :any) => { 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 });
// console.log("API Response:", response.data);
return response?.data; return response?.data;
}; };

View File

@@ -58,14 +58,14 @@ export const uploadFileToS3 = async (
"Content-Type": file.type, "Content-Type": file.type,
}, },
}); });
// console.log("Response from S3 upload:", res); console.log("Response from S3 upload:", res);
}; };
export const confirmUpload = async (token_id: string) => { export const confirmUpload = async (token_id: string) => {
const res = await api.post("/media/presigned/complete", { const res = await api.post("/media/presigned/complete", {
token_id, token_id,
}); });
console.log("Response from confirm upload:", res);
return res.data; return res.data;
}; };
@@ -75,17 +75,17 @@ export const uploadMedia = async (file: File) => {
{ {
params: { params: {
fileName: file.name, fileName: file.name,
contentType: file.type, content_type: file.type,
size: file.size, size: file.size,
}, },
}, },
); );
// console.log("Presigned URL:", presigned); console.log("Presigned URL:", presigned);
await uploadFileToS3(file, presigned); await uploadFileToS3(file, presigned);
const media = await confirmUpload(presigned.token_id); const media = await confirmUpload(presigned.token_id);
// console.log("Media sau khi upload:", media); console.log("Media sau khi upload:", media);
return media; return media;
}; };
@@ -95,11 +95,12 @@ export const getPresignedUrl = async (file: File) => {
{ {
params: { params: {
fileName: file.name, fileName: file.name,
contentType: file.type, content_type: file.type,
size: file.size, size: file.size,
}, },
}, },
); );
console.log("Presigned URL:", presigned);
return presigned; return presigned;
}; };