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

View File

@@ -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<string>("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [verifyType, setVerifyType] = useState<string>("OTHER");
const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]);
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) => {
<div className="flex flex-col gap-6">
<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="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-800 dark:text-white">
@@ -182,7 +215,7 @@ const handleSubmit = async (e: React.FormEvent) => {
</div>
{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) => {
const docStyle = getDocumentStyle(item.extension);
@@ -190,14 +223,14 @@ const handleSubmit = async (e: React.FormEvent) => {
<div
key={item.id}
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" ? (
<Image
src={item.previewUrl}
alt={item.name}
fill
className="object-cover group-hover:scale-110 transition-transform"
className="object-cover transition-transform group-hover:scale-110"
/>
) : (
<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" />
</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}
</span>
<span className="text-[10px] text-gray-500 mt-1 uppercase font-bold">
@@ -221,7 +254,7 @@ const handleSubmit = async (e: React.FormEvent) => {
<button
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
className="w-3 h-3"
@@ -239,7 +272,7 @@ const handleSubmit = async (e: React.FormEvent) => {
</button>
{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
className="w-6 h-6 text-white"
fill="none"
@@ -266,7 +299,7 @@ const handleSubmit = async (e: React.FormEvent) => {
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"}
</button>
@@ -287,4 +320,4 @@ const handleSubmit = async (e: React.FormEvent) => {
/>
</div>
);
}
}

View File

@@ -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<number>(1);
const [limitInput, setLimitInput] = useState<string>("3");
const [limitInput, setLimitInput] = useState<string>(
LIMIT_ITEM_TABLE.toString(),
);
const [searchTerm, setSearchTerm] = useState<string>("");
const [statusFilter, setStatusFilter] = 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 [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<ApplicationResponse | null>(null);
@@ -41,18 +66,65 @@ export default function HistorianApplicationPage() {
const [sortBy, setSortBy] = useState<AppSortColumn>("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 (
<div>
<PageBreadcrumb pageTitle="Quản lý hồ sơ Sử gia" />
<PageBreadcrumb pageTitle="Quản lý hồ sơ" />
<div className="space-y-6">
<ComponentCard title="Bộ lọc tìm kiếm">
<div className="grid grid-cols-1 gap-4 mb-6 md:grid-cols-2 lg:grid-cols-4">
<ComponentCard
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>
<label className="block mb-2 text-sm font-medium">Tìm kiếm</label>
<input
@@ -154,6 +262,42 @@ export default function HistorianApplicationPage() {
</select>
</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>
<label className="block mb-2 text-sm font-medium">
Hiển thị (Limit)
@@ -201,11 +345,11 @@ export default function HistorianApplicationPage() {
</ComponentCard>
</div>
<ApplicationDetailModal
<ApplicationDetailModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
application={selectedApp || null}
onRefresh={fetchApplications} // Tải lại bảng sau khi Admin duyệt
onRefresh={fetchApplications}
/>
</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 */
.apexcharts-legend-text {
@apply !text-gray-700 dark:!text-gray-400;