pig update

This commit is contained in:
2026-04-14 18:01:23 +07:00
parent 66e312e9d2
commit 720f13e9bb
15 changed files with 1358 additions and 2 deletions

View File

@@ -0,0 +1,290 @@
"use client";
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
import RichTextEditor from "@/components/form/role-upgrade/Editor";
import {
confirmUpload,
getPresignedUrl,
uploadFileToS3,
uploadMedia,
} from "@/service/mediaService";
import React, { useState } from "react";
import Image from "next/image";
import Lightbox from "yet-another-react-lightbox";
import Zoom from "yet-another-react-lightbox/plugins/zoom";
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";
type PendingFile = {
id: string;
file: File;
previewUrl: string;
name: string;
size: number;
type: "image" | "document";
extension: string;
presigned?: any;
};
export default function RoleUpgrade() {
const [content, setContent] = useState<string>("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]);
const [lightboxIndex, setLightboxIndex] = useState(-1);
const [isPreparingFiles, setIsPreparingFiles] = useState(false);
const handleContentChange = (value: string) => {
setContent(value);
};
const handleFileChange = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const files = event.target.files;
if (!files || files.length === 0) return;
setIsPreparingFiles(true);
try {
const newFilesPromises = Array.from(files).map(async (file) => {
const isImage = file.type.startsWith("image/");
const extension = file.name.split(".").pop()?.toLowerCase() || "";
const presigned = await getPresignedUrl(file);
return {
id: Math.random().toString(36).substring(7),
file: file,
previewUrl: isImage ? URL.createObjectURL(file) : "",
name: file.name,
size: file.size,
type: isImage ? "image" : "document",
extension: extension,
presigned: presigned,
} as PendingFile;
});
const newPendingFiles = await Promise.all(newFilesPromises);
setPendingFiles((prev) => [...prev, ...newPendingFiles]);
} catch (error) {
console.error("Lỗi khi chuẩn bị file đính kèm:", error);
alert("Lỗi khi chuẩn bị kết nối upload. Vui lòng thử lại!");
} finally {
setIsPreparingFiles(false);
if (event.target) event.target.value = "";
}
};
const removePendingFile = (idToRemove: string, e: React.MouseEvent) => {
e.stopPropagation();
setPendingFiles((prev) => prev.filter((item) => item.id !== idToRemove));
};
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 payload = {
content: content,
media_ids: uploadedMediaIds,
verify_type: "ID_CARD"
};
console.log("Payload chuẩn bị gửi (JSON):", payload);
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);
}
};
const imageFiles = pendingFiles.filter((f) => f.type === "image");
const slides = imageFiles.map((item) => ({
src: item.previewUrl,
title: item.name,
description: `Size: ${(item.size / 1024).toFixed(2)} KB`,
}));
const handleItemClick = (item: PendingFile) => {
if (item.type === "image") {
const index = imageFiles.findIndex((img) => img.id === item.id);
setLightboxIndex(index);
} else {
const fileUrl = URL.createObjectURL(item.file);
window.open(fileUrl, "_blank");
}
};
const getDocumentStyle = (ext: string) => {
if (["pdf"].includes(ext))
return { color: "text-red-500", bg: "bg-red-50" };
if (["doc", "docx"].includes(ext))
return { color: "text-blue-500", bg: "bg-blue-50" };
if (["xls", "xlsx"].includes(ext))
return { color: "text-emerald-500", bg: "bg-emerald-50" };
return { color: "text-gray-500", bg: "bg-gray-100" };
};
return (
<div className="max-w-4xl mx-auto">
<PageBreadcrumb pageTitle="Role Upgrade" />
<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">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-800 dark:text-white">
Tài liệu đính kèm
</h3>
<label
className={`flex items-center gap-2 cursor-pointer px-4 py-2 rounded-lg border border-dashed border-blue-500 text-blue-500 hover:bg-blue-50 transition ${isSubmitting ? "opacity-50 pointer-events-none" : ""}`}
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
<span>Đính kèm tệp</span>
<input
type="file"
multiple
className="hidden"
accept="image/*,.pdf,.doc,.docx"
onChange={handleFileChange}
/>
</label>
</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">
{pendingFiles.map((item) => {
const docStyle = getDocumentStyle(item.extension);
return (
<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"
>
{item.type === "image" ? (
<Image
src={item.previewUrl}
alt={item.name}
fill
className="object-cover group-hover:scale-110 transition-transform"
/>
) : (
<div
className={`absolute inset-0 flex flex-col items-center justify-center p-3 ${docStyle.bg} dark:bg-gray-800`}
>
<svg
className={`w-10 h-10 ${docStyle.color} mb-2`}
fill="currentColor"
viewBox="0 0 24 24"
>
<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">
{item.name}
</span>
<span className="text-[10px] text-gray-500 mt-1 uppercase font-bold">
{item.extension}
</span>
</div>
)}
<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"
>
<svg
className="w-3 h-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</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">
<svg
className="w-6 h-6 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v3m0 0v3m0-3h3m-3 0H7"
/>
</svg>
</div>
)}
</div>
);
})}
</div>
)}
</div>
<button
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 ? "Đang tải dữ liệu lên..." : "Gửi yêu cầu nâng cấp"}
</button>
</div>
<Lightbox
index={lightboxIndex}
open={lightboxIndex >= 0}
close={() => setLightboxIndex(-1)}
slides={slides}
plugins={[Zoom, Captions]}
zoom={{ maxZoomPixelRatio: 10 }}
styles={{
root: {
zIndex: 999999,
"--yarl__color_backdrop": "rgba(0, 0, 0, 0.9)",
},
}}
/>
</div>
);
}

View File

@@ -0,0 +1,212 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import ComponentCard from "@/components/common/ComponentCard";
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
import Pagination from "@/components/tables/Pagination";
import Swal from "sweetalert2";
import ApplicationTable, {
AppSortColumn,
} from "@/components/tables/ApplicationTable";
import { apiGetUserApplications } from "@/service/historianService";
import {
ApplicationDto,
ApplicationResponse,
GetApplicationsParams,
} from "@/interface/historian";
import ApplicationDetailModal from "@/components/tables/ApplicationDetailModal";
export default function HistorianApplicationPage() {
const [page, setPage] = useState<number>(1);
const [limitInput, setLimitInput] = useState<string>("3");
const [searchTerm, setSearchTerm] = useState<string>("");
const [statusFilter, setStatusFilter] = useState<string>("");
const [verifyTypeFilter, setVerifyTypeFilter] = useState<string>("");
const [selectedApp, setSelectedApp] = useState<ApplicationDto | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [debouncedParams, setDebouncedParams] = useState({
search: "",
limit: 3,
status: "",
verifyType: "",
});
const [tableData, setTableData] = useState<ApplicationResponse | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [sortBy, setSortBy] = useState<AppSortColumn>("created_at");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedParams({
search: searchTerm,
limit: parseInt(limitInput) || 3,
status: statusFilter,
verifyType: verifyTypeFilter,
});
setPage(1);
}, 600);
return () => clearTimeout(handler);
}, [searchTerm, limitInput, statusFilter, verifyTypeFilter]);
const fetchApplications = useCallback(async () => {
setLoading(true);
try {
const payload: GetApplicationsParams = {
page: page,
limit: debouncedParams.limit,
search: debouncedParams.search || undefined,
sort: sortBy,
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];
const response = await apiGetUserApplications(payload);
if (response?.status) {
setTableData(response);
}
} catch (err) {
console.error("Lỗi lấy danh sách hồ sơ:", err);
setTableData(null);
} finally {
setLoading(false);
}
}, [page, debouncedParams, sortBy, sortOrder]);
useEffect(() => {
fetchApplications();
}, [fetchApplications]);
const handleSort = (column: AppSortColumn) => {
setPage(1);
if (sortBy === column) {
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else {
setSortBy(column);
setSortOrder("desc");
}
};
const handleViewDetail = (app: ApplicationDto) => {
setSelectedApp(app);
setIsModalOpen(true);
};
const pagination = tableData?.pagination;
return (
<div>
<PageBreadcrumb pageTitle="Quản lý hồ sơ Sử gia" />
<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">
<div>
<label className="block mb-2 text-sm font-medium">Tìm kiếm</label>
<input
type="text"
placeholder="ID, nội dung..."
value={searchTerm}
onChange={(e) => setSearchTerm(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>
<label className="block mb-2 text-sm font-medium">
Loại xác minh
</label>
<select
value={verifyTypeFilter}
onChange={(e) => setVerifyTypeFilter(e.target.value)}
className="w-full px-3 py-2 bg-white dark:bg-gray-900 border rounded-lg cursor-pointer outline-none focus:border-brand-500"
>
<option value="">Tất cả</option>
<option value="1">Thẻ nhận dạng nhà nghiên cứu</option>
<option value="2">Bằng cấp</option>
<option value="3">Chuyên gia</option>
<option value="4">Khác</option>
</select>
</div>
<div>
<label className="block mb-2 text-sm font-medium">
Trạng thái
</label>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="w-full px-3 py-2 bg-white dark:bg-gray-900 border rounded-lg cursor-pointer outline-none focus:border-brand-500"
>
<option value="">Tất cả</option>
<option value="1">Đang chờ duyệt</option>
<option value="2">Đã duyệt</option>
<option value="3">Từ chối</option>
</select>
</div>
<div>
<label className="block mb-2 text-sm font-medium">
Hiển thị (Limit)
</label>
<input
type="number"
value={limitInput}
onChange={(e) => setLimitInput(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>
</ComponentCard>
<ComponentCard title="Danh sách hồ sơ">
<div className="relative min-h-[300px]">
{loading && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/50 dark:bg-gray-900/50 rounded-xl">
<div className="w-10 h-10 border-4 border-t-brand-500 rounded-full animate-spin"></div>
</div>
)}
<ApplicationTable
data={tableData?.data || []}
onSort={handleSort}
sortBy={sortBy}
sortOrder={sortOrder}
onViewDetail={handleViewDetail}
/>
</div>
<div className="flex items-center justify-between mt-6">
<p className="text-sm text-gray-500">
Hiển thị {pagination?.total_records || 0} hồ
</p>
{pagination && pagination.total_pages > 1 && (
<Pagination
currentPage={pagination.current_page}
totalPages={pagination.total_pages}
onPageChange={(newPage) => setPage(newPage)}
/>
)}
</div>
</ComponentCard>
</div>
<ApplicationDetailModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
application={selectedApp || null}
onRefresh={fetchApplications} // Tải lại bảng sau khi Admin duyệt
/>
</div>
);
}

View File

@@ -31,6 +31,7 @@ export default function Profile() {
};
fetchUser();
}, []);
return (
<div>
<div className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] lg:p-6">

View File

@@ -768,4 +768,44 @@ html {
border-radius: 5px;
background-color: rgba(0, 0, 0, .5);
box-shadow: 0 0 1px rgba(255, 255, 255, .5);
}
.ql-editor pre.ql-syntax {
background-color: #23241f;
color: #f8f8f2;
overflow: visible;
padding: 10px;
border-radius: 5px;
}
.ql-toolbar.ql-snow {
border: none !important;
border-bottom: 1px solid #e5e7eb !important;
background-color: #f9fafb;
padding: 12px !important;
}
.ql-container.ql-snow {
border: none !important;
font-size: 16px;
font-family: 'Inter', sans-serif;
}
.ql-editor {
min-height: 400px;
line-height: 1.6;
}
.dark .ql-toolbar.ql-snow {
background-color: #1f2937;
border-bottom: 1px solid #374151 !important;
color: #f3f4f6;
}
.dark .ql-snow .ql-stroke {
stroke: #d1d5db;
}
.dark .ql-editor {
color: #f3f4f6;
}