update application page, table.
This commit is contained in:
1
constant.ts
Normal file
1
constant.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const LIMIT_ITEM_TABLE = 5;
|
||||||
@@ -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ồ sơ 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -17,5 +17,5 @@ export interface MediaDto {
|
|||||||
|
|
||||||
export interface payloadPresignedMedia {
|
export interface payloadPresignedMedia {
|
||||||
filename:string;
|
filename:string;
|
||||||
contentType:string;
|
content_type:string;
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user