diff --git a/constant.ts b/constant.ts new file mode 100644 index 0000000..d2e48b1 --- /dev/null +++ b/constant.ts @@ -0,0 +1 @@ +export const LIMIT_ITEM_TABLE = 5; \ No newline at end of file diff --git a/src/app/(admin)/(others-pages)/(forms)/role-upgrade/page.tsx b/src/app/(admin)/(others-pages)/(forms)/role-upgrade/page.tsx index 5241071..2191b31 100644 --- a/src/app/(admin)/(others-pages)/(forms)/role-upgrade/page.tsx +++ b/src/app/(admin)/(others-pages)/(forms)/role-upgrade/page.tsx @@ -6,7 +6,6 @@ import { confirmUpload, getPresignedUrl, uploadFileToS3, - uploadMedia, } from "@/service/mediaService"; import React, { useState } from "react"; 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/plugins/captions.css"; import { createHistorianCV } from "@/service/historianService"; +import { toast } from "sonner"; +import Swal from "sweetalert2"; type PendingFile = { id: string; @@ -25,17 +26,20 @@ type PendingFile = { size: number; type: "image" | "document"; extension: string; - presigned?: any; + presigned?: any; }; export default function RoleUpgrade() { const [content, setContent] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); + + const [verifyType, setVerifyType] = useState("OTHER"); const [pendingFiles, setPendingFiles] = useState([]); const [lightboxIndex, setLightboxIndex] = useState(-1); const [isPreparingFiles, setIsPreparingFiles] = useState(false); + const handleContentChange = (value: string) => { setContent(value); }; @@ -83,38 +87,45 @@ export default function RoleUpgrade() { setPendingFiles((prev) => prev.filter((item) => item.id !== idToRemove)); }; -const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - try { - setIsSubmitting(true); + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + try { + setIsSubmitting(true); - const uploadPromises = pendingFiles.map(async (item) => { - await uploadFileToS3(item.file, item.presigned); - const confirmRes = await confirmUpload(item.presigned.token_id); - return confirmRes?.data?.id || confirmRes?.id; - }); - const uploadedMediaIds = await Promise.all(uploadPromises); + const uploadPromises = pendingFiles.map(async (item) => { + await uploadFileToS3(item.file, item.presigned); + const confirmRes = await confirmUpload(item.presigned.token_id); + return confirmRes?.data?.id || confirmRes?.id; + }); + const uploadedMediaIds = await Promise.all(uploadPromises); - const payload = { - content: content, - media_ids: uploadedMediaIds, - verify_type: "ID_CARD" - }; + const payload = { + content: content, + media_ids: uploadedMediaIds, + 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); - console.log("Response từ API:", cvResponse); + const cvResponse = await createHistorianCV(payload); + console.log("Response từ API:", cvResponse); - alert("Gửi thành công!"); - setContent(""); - setPendingFiles([]); - } catch (error) { - console.error("Lỗi:", error); - } finally { - setIsSubmitting(false); - } -}; + 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(""); + 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 slides = imageFiles.map((item) => ({ @@ -149,6 +160,28 @@ const handleSubmit = async (e: React.FormEvent) => {
+
+ + +

+ * 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. +

+

+ * 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) ". +

+
+

@@ -182,7 +215,7 @@ const handleSubmit = async (e: React.FormEvent) => {

{pendingFiles.length > 0 && ( -
+
{pendingFiles.map((item) => { const docStyle = getDocumentStyle(item.extension); @@ -190,14 +223,14 @@ const handleSubmit = async (e: React.FormEvent) => {
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.name} ) : (
{ > - + {item.name} @@ -221,7 +254,7 @@ const handleSubmit = async (e: React.FormEvent) => { {item.type === "image" && ( -
+
{ onClick={handleSubmit} disabled={isSubmitting || isPreparingFiles} className={`w-full py-4 text-white font-bold rounded-2xl shadow-lg transition-all transform flex justify-center items-center gap-2 - ${isSubmitting ? "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"} @@ -287,4 +320,4 @@ const handleSubmit = async (e: React.FormEvent) => { />
); -} +} \ No newline at end of file diff --git a/src/app/(admin)/(others-pages)/(tables)/applications-tables/page.tsx b/src/app/(admin)/(others-pages)/(tables)/applications-tables/page.tsx index fc67fc1..7f42cf3 100644 --- a/src/app/(admin)/(others-pages)/(tables)/applications-tables/page.tsx +++ b/src/app/(admin)/(others-pages)/(tables)/applications-tables/page.tsx @@ -16,23 +16,48 @@ import { GetApplicationsParams, } from "@/interface/historian"; 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() { const [page, setPage] = useState(1); - const [limitInput, setLimitInput] = useState("3"); + const [limitInput, setLimitInput] = useState( + LIMIT_ITEM_TABLE.toString(), + ); const [searchTerm, setSearchTerm] = useState(""); const [statusFilter, setStatusFilter] = useState(""); const [verifyTypeFilter, setVerifyTypeFilter] = useState(""); + const [fromDate, setFromDate] = useState(""); + const [fromTime, setFromTime] = useState(""); + const [toDate, setToDate] = useState(""); + const [toTime, setToTime] = useState(""); + const [selectedApp, setSelectedApp] = useState(null); const [isModalOpen, setIsModalOpen] = useState(false); const [debouncedParams, setDebouncedParams] = useState({ search: "", - limit: 3, + limit: LIMIT_ITEM_TABLE, status: "", verifyType: "", + fromDate: "", + fromTime: "", + toDate: "", + toTime: "", }); const [tableData, setTableData] = useState(null); @@ -41,18 +66,65 @@ export default function HistorianApplicationPage() { const [sortBy, setSortBy] = useState("created_at"); 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(() => { const handler = setTimeout(() => { setDebouncedParams({ search: searchTerm, - limit: parseInt(limitInput) || 3, + limit: parseInt(limitInput) || LIMIT_ITEM_TABLE, status: statusFilter, verifyType: verifyTypeFilter, + fromDate, + fromTime, + toDate, + toTime, }); setPage(1); }, 600); 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 () => { setLoading(true); @@ -65,16 +137,32 @@ export default function HistorianApplicationPage() { order: sortOrder, }; - // Backend yêu cầu mảng cho các filter này if (debouncedParams.status) payload.statuses = [debouncedParams.status]; 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); if (response?.status) { setTableData(response); } } 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); setTableData(null); } finally { @@ -82,10 +170,6 @@ export default function HistorianApplicationPage() { } }, [page, debouncedParams, sortBy, sortOrder]); - useEffect(() => { - fetchApplications(); - }, [fetchApplications]); - const handleSort = (column: AppSortColumn) => { setPage(1); if (sortBy === column) { @@ -105,11 +189,35 @@ export default function HistorianApplicationPage() { return (
- +
- -
+ + + + + + } + > + {/* Cập nhật Grid để chứa đủ các ô filter */} +
+
+ +
+ 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" + /> + 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" + /> +
+
+ +
+ +
+ 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" + /> + 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" + /> +
+
+
- setIsModalOpen(false)} application={selectedApp || null} - onRefresh={fetchApplications} // Tải lại bảng sau khi Admin duyệt + onRefresh={fetchApplications} />
); diff --git a/src/app/globals.css b/src/app/globals.css index a38806a..4bd4008 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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 */ .apexcharts-legend-text { @apply !text-gray-700 dark:!text-gray-400; diff --git a/src/components/common/ComponentCard.tsx b/src/components/common/ComponentCard.tsx index d0afa02..42b03d0 100644 --- a/src/components/common/ComponentCard.tsx +++ b/src/components/common/ComponentCard.tsx @@ -5,6 +5,7 @@ interface ComponentCardProps { children: React.ReactNode; className?: string; // Additional custom classes for styling desc?: string; // Description text + headerAction?: React.ReactNode; } const ComponentCard: React.FC = ({ @@ -12,13 +13,14 @@ const ComponentCard: React.FC = ({ children, className = "", desc = "", + headerAction, }) => { return (
{/* Card Header */} -
+

{title}

@@ -27,8 +29,9 @@ const ComponentCard: React.FC = ({ {desc}

)} + {headerAction &&
{headerAction}
}
- + {/* Card Body */}
{children}
diff --git a/src/components/tables/ApplicationDetailModal.tsx b/src/components/tables/ApplicationDetailModal.tsx index 059edbc..f5439c1 100644 --- a/src/components/tables/ApplicationDetailModal.tsx +++ b/src/components/tables/ApplicationDetailModal.tsx @@ -9,6 +9,8 @@ import { apiUpdateApplicationStatus, } from "@/service/adminService"; import { getMediaById } from "@/service/mediaService"; +import Link from "next/link"; +import { URL_MEDIA } from "../../../api"; interface Props { isOpen: boolean; @@ -23,6 +25,7 @@ export default function ApplicationDetailModal({ application, onRefresh, }: Props) { + const [userData, setUserData] = useState(null); const [mediaList, setMediaList] = useState([]); const [reviewNote, setReviewNote] = useState(""); @@ -46,7 +49,7 @@ export default function ApplicationDetailModal({ try { const userRes = await apiGetUserById(application.user_id); - console.log("User data:", userRes); + // console.log("User data:", userRes); if (userRes?.data) setUserData(userRes.data); @@ -55,7 +58,7 @@ export default function ApplicationDetailModal({ getMediaById(m.id), ); const mediaResponses = await Promise.all(mediaPromises); - + setMediaList(mediaResponses.map((res) => res?.data || res)); } } catch (error) { @@ -83,7 +86,7 @@ export default function ApplicationDetailModal({ status: status, review_note: reviewNote, }; - + // console.log("Payload gửi lên API:", payload, "Application ID:", application.id); await apiUpdateApplicationStatus(application.id, payload); Swal.fire( @@ -115,7 +118,6 @@ export default function ApplicationDetailModal({ if (isImage || isPdf) { window.open(media.url, "_blank"); } else { - const link = document.createElement("a"); link.href = media.url; link.download = media.original_name || "download"; @@ -124,6 +126,9 @@ export default function ApplicationDetailModal({ document.body.removeChild(link); } }; + + // console.log("ApplicationDetailModal:", application); + return (
@@ -186,15 +191,12 @@ export default function ApplicationDetailModal({

- {application.verify_type === 1 - ? "CĂN CƯỚC CÔNG DÂN" - : "BẰNG CẤP / KHÁC"} + {application.verify_type}
-

Nội dung ứng tuyển @@ -245,8 +247,8 @@ export default function ApplicationDetailModal({

)} - - + */} +
); })} diff --git a/src/components/tables/ApplicationTable.tsx b/src/components/tables/ApplicationTable.tsx index f0633be..0ec1cdf 100644 --- a/src/components/tables/ApplicationTable.tsx +++ b/src/components/tables/ApplicationTable.tsx @@ -122,7 +122,7 @@ export default function ApplicationTable({ - Người dùng (ID) + Người gửi (ID) Loại xác minh diff --git a/src/interface/historian.ts b/src/interface/historian.ts index 9feb1e2..26eebad 100644 --- a/src/interface/historian.ts +++ b/src/interface/historian.ts @@ -10,11 +10,9 @@ export interface MediaDto { export interface ApplicationDto { id: string; user_id: string; - // Sửa từ number thành string | number - verify_type: string | number | string[] | number[]; + verify_type: string | number ; content: string; is_deleted: boolean; - // Sửa status để nhận cả 1,2,3 hoặc PENDING, APPROVED... status: string | number; reviewed_by: string; review_note: string; @@ -32,7 +30,7 @@ export interface GetApplicationsParams { sort?: string; order?: "asc" | "desc"; statuses?: string[]; - verify_types?: string[]; + verify_types?: string; created_from?: string; created_to?: string; reviewed_by?: string; diff --git a/src/interface/media.ts b/src/interface/media.ts index bfce0c6..f7213cf 100644 --- a/src/interface/media.ts +++ b/src/interface/media.ts @@ -17,5 +17,5 @@ export interface MediaDto { export interface payloadPresignedMedia { filename:string; - contentType:string; + content_type:string; } \ No newline at end of file diff --git a/src/service/adminService.ts b/src/service/adminService.ts index 5715081..66db04e 100644 --- a/src/service/adminService.ts +++ b/src/service/adminService.ts @@ -33,7 +33,7 @@ export const apiGetUserMedia = async (id: string) => { }; 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; }; diff --git a/src/service/historianService.ts b/src/service/historianService.ts index 4057dec..4223d83 100644 --- a/src/service/historianService.ts +++ b/src/service/historianService.ts @@ -8,6 +8,5 @@ export const createHistorianCV = async (payload: any) => { export const apiGetUserApplications = async (payload :any) => { const response = await api.get(API.Historian.APPLICATION, { params: payload }); - // console.log("API Response:", response.data); return response?.data; }; diff --git a/src/service/mediaService.ts b/src/service/mediaService.ts index be7fec8..d4fc921 100644 --- a/src/service/mediaService.ts +++ b/src/service/mediaService.ts @@ -58,14 +58,14 @@ export const uploadFileToS3 = async ( "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) => { const res = await api.post("/media/presigned/complete", { token_id, }); - + console.log("Response from confirm upload:", res); return res.data; }; @@ -75,17 +75,17 @@ export const uploadMedia = async (file: File) => { { params: { fileName: file.name, - contentType: file.type, + content_type: file.type, size: file.size, }, }, ); - // console.log("Presigned URL:", presigned); + console.log("Presigned URL:", presigned); await uploadFileToS3(file, presigned); const media = await confirmUpload(presigned.token_id); - // console.log("Media sau khi upload:", media); + console.log("Media sau khi upload:", media); return media; }; @@ -95,11 +95,12 @@ export const getPresignedUrl = async (file: File) => { { params: { fileName: file.name, - contentType: file.type, + content_type: file.type, size: file.size, }, }, ); + console.log("Presigned URL:", presigned); return presigned; };