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;
@@ -25,17 +26,20 @@ type PendingFile = {
size: number; size: number;
type: "image" | "document"; type: "image" | "document";
extension: string; extension: string;
presigned?: any; presigned?: any;
}; };
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 [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,38 +87,45 @@ 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);
const uploadPromises = pendingFiles.map(async (item) => { const uploadPromises = pendingFiles.map(async (item) => {
await uploadFileToS3(item.file, item.presigned); await uploadFileToS3(item.file, item.presigned);
const confirmRes = await confirmUpload(item.presigned.token_id); const confirmRes = await confirmUpload(item.presigned.token_id);
return confirmRes?.data?.id || confirmRes?.id; return confirmRes?.data?.id || confirmRes?.id;
}); });
const uploadedMediaIds = await Promise.all(uploadPromises); const uploadedMediaIds = await Promise.all(uploadPromises);
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);
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({
setContent(""); title: "Gửi yêu cầu thành công!",
setPendingFiles([]); 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.",
} catch (error) { icon: "success",
console.error("Lỗi:", error); confirmButtonText: "OK",
} finally { });
setIsSubmitting(false); setContent("");
} setPendingFiles([]);
}; setVerifyType("ID_CARD");
} catch (error) {
console.error("Lỗi:", error);
toast.error("Có lỗi xảy ra khi gửi yêu cầu. Vui lòng kiểm tra lại!");
} finally {
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>
@@ -287,4 +320,4 @@ const handleSubmit = async (e: React.FormEvent) => {
/> />
</div> </div>
); );
} }

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)
@@ -201,11 +345,11 @@ export default function HistorianApplicationPage() {
</ComponentCard> </ComponentCard>
</div> </div>
<ApplicationDetailModal <ApplicationDetailModal
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,8 +29,9 @@ const ComponentCard: React.FC<ComponentCardProps> = ({
{desc} {desc}
</p> </p>
)} )}
{headerAction && <div>{headerAction}</div>}
</div> </div>
{/* Card Body */} {/* Card Body */}
<div className="p-4 border-t border-gray-100 dark:border-gray-800 sm:p-6"> <div className="p-4 border-t border-gray-100 dark:border-gray-800 sm:p-6">
<div className="space-y-6">{children}</div> <div className="space-y-6">{children}</div>

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);
@@ -55,7 +58,7 @@ export default function ApplicationDetailModal({
getMediaById(m.id), getMediaById(m.id),
); );
const mediaResponses = await Promise.all(mediaPromises); const mediaResponses = await Promise.all(mediaPromises);
setMediaList(mediaResponses.map((res) => res?.data || res)); setMediaList(mediaResponses.map((res) => res?.data || res));
} }
} catch (error) { } catch (error) {
@@ -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
@@ -245,8 +247,8 @@ export default function ApplicationDetailModal({
</span> </span>
</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;
}; };