Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 91b0eef9c8 | |||
| 45b7b379ce | |||
| 35732fd50e | |||
| 7ec7fda0b4 | |||
| ff70fc78f5 | |||
| a2bab73e50 | |||
| d65a71ba1d | |||
| 043ef08b87 | |||
| 226abba5ed | |||
| 7013b93c53 | |||
| d84352d848 | |||
| 2b40dd33dd | |||
| e273df1ee2 | |||
| bf44502a24 | |||
| e76b1cf093 | |||
| 8bc6b32e26 | |||
| 2ee5c8da36 | |||
| 3dc7804b10 | |||
| fc3d900871 | |||
| 4018d8e135 | |||
| 1fe25c1944 |
32
api.ts
32
api.ts
@@ -7,11 +7,15 @@ export const API = {
|
||||
MEDIA: `${API_URL_ROOT}/users/current/media`,
|
||||
Update: `${API_URL_ROOT}/users/current`,
|
||||
CHANGE_PASSWORD: `${API_URL_ROOT}/users/current/password`,
|
||||
APPLICATION: `${API_URL_ROOT}/users/current/application`
|
||||
APPLICATION: `${API_URL_ROOT}/users/current/application`,
|
||||
CURRENT_PROJECT: `${API_URL_ROOT}/users/current/project`
|
||||
},
|
||||
Media:{
|
||||
GET_MEDIA: `${API_URL_ROOT}/media`,
|
||||
PRESIGNED: `${API_URL_ROOT}/media/presigned`,
|
||||
GET_MEDIA_BY_ID: (Id: number | string) => `${API_URL_ROOT}/media/${Id}`,
|
||||
DELETE_MEDIA_BY_ID: (Id: number | string) => `${API_URL_ROOT}/media/${Id}`,
|
||||
DELETE_MEDIA: `${API_URL_ROOT}/media`,
|
||||
},
|
||||
Auth : {
|
||||
LOGOUT: `${API_URL_ROOT}/auth/logout`,
|
||||
@@ -38,5 +42,31 @@ export const API = {
|
||||
CREATE_CV: `${API_URL_ROOT}/historian/application`,
|
||||
APPLICATION: `${API_URL_ROOT}/historian/application`,
|
||||
DELETE_CV: (Id: number | string) => `${API_URL_ROOT}/historian/application/${Id}`,
|
||||
},
|
||||
Project: {
|
||||
GET_CURRENT_PROJECT: `${API_URL_ROOT}/users/current/project`,
|
||||
CREATE: `${API_URL_ROOT}/projects`,
|
||||
GET_ALL: `${API_URL_ROOT}/projects`,
|
||||
GET_DETAIL: (id: number | string) => `${API_URL_ROOT}/projects/${id}`,
|
||||
UPDATE: (id: number | string) => `${API_URL_ROOT}/projects/${id}`,
|
||||
DELETE: (id: number | string) => `${API_URL_ROOT}/projects/${id}`,
|
||||
ADD_MEMBER: (id: number | string) => `${API_URL_ROOT}/projects/${id}/members`,
|
||||
UPDATE_MEMBER: (id: number | string, userId: number | string) => `${API_URL_ROOT}/projects/${id}/members/${userId}`,
|
||||
REMOVE_MEMBER: (id: number | string, userId: number | string) => `${API_URL_ROOT}/projects/${id}/members/${userId}`,
|
||||
CHANGE_OWNER: (id: number | string) => `${API_URL_ROOT}/projects/${id}/change-owner`,
|
||||
CREATE_COMMIT: (id: number | string) => `${API_URL_ROOT}/projects/${id}/commits`,
|
||||
GET_COMMITS: (id: number | string) => `${API_URL_ROOT}/projects/${id}/commits`,
|
||||
RESTORE_COMMIT: (id: number | string) => `${API_URL_ROOT}/projects/${id}/commits/restore`,
|
||||
},
|
||||
Submission:{
|
||||
GET_ALL: `${API_URL_ROOT}/submissions`,
|
||||
GET_DETAIL: (id: number | string) => `${API_URL_ROOT}/submissions/${id}`,
|
||||
UPDATE_STATUS: (id: number | string) => `${API_URL_ROOT}/submissions/${id}/status`,
|
||||
CREATE: `${API_URL_ROOT}/submissions`,
|
||||
DELETE: (id: string) => `${API_URL_ROOT}/submissions/${id}`,
|
||||
},
|
||||
Statistics: {
|
||||
GET_ALL: `${API_URL_ROOT}/statistics`,
|
||||
GET_BY_DATE: (date: string) => `${API_URL_ROOT}/statistics/${date}`,
|
||||
}
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export const LIMIT_ITEM_TABLE = 5;
|
||||
export const LIMIT_ITEM_TABLE = 5;
|
||||
export const INITIAL_LIMIT = 16;
|
||||
@@ -1,67 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { PUBLIC_ROUTES, canAccessRoute, UserRole } from "./src/config/routes.config"
|
||||
|
||||
/**
|
||||
* Middleware để kiểm tra authentication và authorization
|
||||
* Chạy TRƯỚC khi render page
|
||||
*/
|
||||
export async function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl
|
||||
|
||||
// 1. Kiểm tra nếu là public route
|
||||
if (PUBLIC_ROUTES.includes(pathname)) {
|
||||
// Nếu user đã login, không cho vào signin/signup
|
||||
const userDataCookie = request.cookies.get("userDataRedux")
|
||||
if (userDataCookie && (pathname === "/signin" || pathname === "/signup")) {
|
||||
return NextResponse.redirect(new URL("/", request.url))
|
||||
}
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
// 2. Kiểm tra token (cookies)
|
||||
const token = request.cookies.get("token") || request.cookies.get("access_token")
|
||||
const userDataCookie = request.cookies.get("userDataRedux")
|
||||
|
||||
// 3. Nếu không có token, redirect về signin
|
||||
if (!token) {
|
||||
const signinUrl = new URL("/signin", request.url)
|
||||
signinUrl.searchParams.set("from", pathname)
|
||||
return NextResponse.redirect(signinUrl)
|
||||
}
|
||||
|
||||
// 4. Kiểm tra role-based access
|
||||
if (userDataCookie) {
|
||||
try {
|
||||
const userData = JSON.parse(userDataCookie.value)
|
||||
const userRoles: UserRole[] = userData.roles?.map((r: any) => r.name) || []
|
||||
|
||||
// Kiểm tra user có quyền truy cập route này không
|
||||
if (!canAccessRoute(userRoles, pathname)) {
|
||||
// Redirect về dashboard hoặc 403 page
|
||||
return NextResponse.redirect(new URL("/error-403", request.url))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error parsing user data in middleware:", error)
|
||||
// Nếu lỗi parse, vẫn cho qua (để tránh infinite redirect)
|
||||
return NextResponse.next()
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
/**
|
||||
* Cấu hình matcher - middleware chỉ chạy cho những routes này
|
||||
*/
|
||||
export const config = {
|
||||
matcher: [
|
||||
/*
|
||||
* Chạy middleware cho tất cả paths ngoại trừ:
|
||||
* - _next/static (static files)
|
||||
* - _next/image (image optimization files)
|
||||
* - favicon.ico (favicon file)
|
||||
* - public folder
|
||||
*/
|
||||
"/((?!_next/static|_next/image|favicon.ico|public).*)",
|
||||
],
|
||||
}
|
||||
@@ -41,13 +41,11 @@ export default function RoleUpgrade() {
|
||||
|
||||
const handleIframeLoad = () => {
|
||||
const iframe = iframeRef.current;
|
||||
// Đảm bảo iframe và nội dung bên trong đã sẵn sàng và cùng nguồn gốc (same-origin)
|
||||
|
||||
if (!iframe || !iframe.contentDocument) return;
|
||||
|
||||
const updateHeight = () => {
|
||||
if (iframe.contentDocument) {
|
||||
// Mẹo: Reset height về 'auto' trước để lấy được chiều cao thực tế
|
||||
// (đặc biệt khi người dùng xóa bớt nội dung làm chiều cao ngắn lại)
|
||||
iframe.style.height = "auto";
|
||||
const scrollHeight =
|
||||
iframe.contentDocument.documentElement.scrollHeight;
|
||||
@@ -55,11 +53,8 @@ export default function RoleUpgrade() {
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Cập nhật chiều cao ngay khi iframe load xong HTML
|
||||
updateHeight();
|
||||
|
||||
// 2. Dùng ResizeObserver để theo dõi những thay đổi sau khi load
|
||||
// (VD: ảnh bên trong tải xong làm nội dung dài ra)
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
updateHeight();
|
||||
});
|
||||
@@ -198,7 +193,7 @@ export default function RoleUpgrade() {
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto pb-20">
|
||||
<PageBreadcrumb pageTitle="Nâng cấp tài khoản Nhà sử học" />
|
||||
<PageBreadcrumb pageTitle="Đăng ký trở thành Nhà sử học" />
|
||||
|
||||
<div className="flex items-center justify-between bg-white dark:bg-gray-900 p-2 rounded-xl border border-gray-200 dark:border-gray-800 mb-6">
|
||||
<div className="flex gap-2">
|
||||
|
||||
@@ -5,7 +5,7 @@ import ComponentCard from "@/components/common/ComponentCard";
|
||||
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
|
||||
import Pagination from "@/components/tables/Pagination";
|
||||
import Swal from "sweetalert2";
|
||||
import { ProtectedRoute } from "@/components/auth/ProtectedRoute";
|
||||
import CustomDateRangePicker from "@/components/common/CustomDateRangePicker";
|
||||
|
||||
import ApplicationTable, {
|
||||
AppSortColumn,
|
||||
@@ -188,12 +188,11 @@ export default function HistorianApplicationPage() {
|
||||
|
||||
const pagination = tableData?.pagination;
|
||||
|
||||
console.log(tableData)
|
||||
// console.log(tableData)
|
||||
// console.log("Pagination info:", pagination);
|
||||
return (
|
||||
<ProtectedRoute requiredRoles={["ADMIN", "MOD", "HISTORIAN"]}>
|
||||
<div>
|
||||
<PageBreadcrumb pageTitle="Quản lý hồ sơ" />
|
||||
<div>
|
||||
<PageBreadcrumb pageTitle="Quản lý hồ sơ" />
|
||||
|
||||
<div className="space-y-6">
|
||||
<ComponentCard
|
||||
@@ -232,6 +231,7 @@ export default function HistorianApplicationPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* CẬP NHẬT: Loại xác minh */}
|
||||
<div>
|
||||
<label className="block mb-2 text-sm font-medium">
|
||||
Loại xác minh
|
||||
@@ -242,13 +242,14 @@ export default function HistorianApplicationPage() {
|
||||
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>
|
||||
<option value="ID_CARD">Thẻ nhận dạng nhà nghiên cứu</option>
|
||||
<option value="EDUCATION">Bằng cấp</option>
|
||||
<option value="EXPERT">Chuyên gia</option>
|
||||
<option value="OTHER">Khác</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* CẬP NHẬT: Trạng thái */}
|
||||
<div>
|
||||
<label className="block mb-2 text-sm font-medium">
|
||||
Trạng thái
|
||||
@@ -259,46 +260,22 @@ export default function HistorianApplicationPage() {
|
||||
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>
|
||||
<option value="PENDING">Đang chờ duyệt</option>
|
||||
<option value="APPROVED">Đã duyệt</option>
|
||||
<option value="REJECTED">Từ chối</option>
|
||||
</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>
|
||||
<label className="block mb-2 text-sm font-medium">Thời gian</label>
|
||||
<CustomDateRangePicker
|
||||
onFilterChange={(sDate, eDate, sTime, eTime) => {
|
||||
setFromDate(sDate);
|
||||
setToDate(eDate);
|
||||
setFromTime(sTime);
|
||||
setToTime(eTime);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -355,6 +332,5 @@ export default function HistorianApplicationPage() {
|
||||
onRefresh={fetchApplications}
|
||||
/>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
}
|
||||
459
src/app/(admin)/(others-pages)/(management)/assets/page.tsx
Normal file
459
src/app/(admin)/(others-pages)/(management)/assets/page.tsx
Normal file
@@ -0,0 +1,459 @@
|
||||
"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 { toast } from "sonner";
|
||||
import CustomDateRangePicker from "@/components/common/CustomDateRangePicker";
|
||||
import MediaTable, {
|
||||
MediaItem,
|
||||
MediaSortColumn,
|
||||
} from "@/components/tables/MediaTable";
|
||||
import { LIMIT_ITEM_TABLE } from "../../../../../../constant";
|
||||
import { deleteMedia, deleteMediaById, getMedia } from "@/service/mediaService";
|
||||
import { URL_MEDIA } from "../../../../../../api";
|
||||
import Swal from "sweetalert2";
|
||||
|
||||
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 interface MediaResponse {
|
||||
status: boolean;
|
||||
message: string;
|
||||
data: MediaItem[];
|
||||
pagination: {
|
||||
current_page: number;
|
||||
page_size: number;
|
||||
total_records: number;
|
||||
total_pages: number;
|
||||
};
|
||||
}
|
||||
|
||||
export default function AssetsPage() {
|
||||
const [page, setPage] = useState<number>(1);
|
||||
const [limitInput, setLimitInput] = useState<string>(
|
||||
LIMIT_ITEM_TABLE.toString(),
|
||||
);
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState<string>("");
|
||||
const [mimeTypeFilter, setMimeTypeFilter] = useState<string>("");
|
||||
|
||||
const [fromDate, setFromDate] = useState<string>("");
|
||||
const [fromTime, setFromTime] = useState<string>("");
|
||||
const [toDate, setToDate] = useState<string>("");
|
||||
const [toTime, setToTime] = useState<string>("");
|
||||
|
||||
const [debouncedParams, setDebouncedParams] = useState({
|
||||
search: "",
|
||||
limit: LIMIT_ITEM_TABLE,
|
||||
mimeType: "",
|
||||
fromDate: "",
|
||||
fromTime: "",
|
||||
toDate: "",
|
||||
toTime: "",
|
||||
});
|
||||
|
||||
const [tableData, setTableData] = useState<MediaResponse | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
|
||||
const [sortBy, setSortBy] = useState<MediaSortColumn>("created_at");
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
|
||||
|
||||
// State quản lý checkbox & logic View (Yêu cầu 1 & 3)
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [index, setIndex] = useState<number>(-1); // Dùng cho thư viện xem ảnh
|
||||
const [isSelectionMode, setIsSelectionMode] = useState<boolean>(false);
|
||||
|
||||
const handleReset = () => {
|
||||
setSearchTerm("");
|
||||
setMimeTypeFilter("");
|
||||
setLimitInput(LIMIT_ITEM_TABLE.toString());
|
||||
setFromDate("");
|
||||
setFromTime("");
|
||||
setToDate("");
|
||||
setToTime("");
|
||||
setPage(1);
|
||||
setSelectedIds([]);
|
||||
|
||||
setDebouncedParams({
|
||||
search: "",
|
||||
limit: LIMIT_ITEM_TABLE,
|
||||
mimeType: "",
|
||||
fromDate: "",
|
||||
fromTime: "",
|
||||
toDate: "",
|
||||
toTime: "",
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedParams({
|
||||
search: searchTerm,
|
||||
limit: parseInt(limitInput) || LIMIT_ITEM_TABLE,
|
||||
mimeType: mimeTypeFilter,
|
||||
fromDate,
|
||||
fromTime,
|
||||
toDate,
|
||||
toTime,
|
||||
});
|
||||
setPage(1);
|
||||
}, 600);
|
||||
return () => clearTimeout(handler);
|
||||
}, [
|
||||
searchTerm,
|
||||
limitInput,
|
||||
mimeTypeFilter,
|
||||
fromDate,
|
||||
fromTime,
|
||||
toDate,
|
||||
toTime,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMediaData();
|
||||
}, [page, debouncedParams, sortBy, sortOrder]);
|
||||
|
||||
const fetchMediaData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const payload: any = {
|
||||
page: page,
|
||||
limit: debouncedParams.limit,
|
||||
search: debouncedParams.search || undefined,
|
||||
sort: sortBy,
|
||||
order: sortOrder,
|
||||
};
|
||||
|
||||
if (debouncedParams.mimeType)
|
||||
payload.mime_type = debouncedParams.mimeType;
|
||||
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;
|
||||
|
||||
const response = await getMedia(payload);
|
||||
if (response?.status) {
|
||||
setTableData(response);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error("Lỗi lấy danh sách tệp tin");
|
||||
console.error("Lỗi lấy danh sách tệp tin:", err);
|
||||
setTableData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, debouncedParams, sortBy, sortOrder]);
|
||||
|
||||
const handleSort = (column: MediaSortColumn) => {
|
||||
setPage(1);
|
||||
if (sortBy === column) {
|
||||
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
|
||||
} else {
|
||||
setSortBy(column);
|
||||
setSortOrder("desc");
|
||||
}
|
||||
};
|
||||
|
||||
// --- LOGIC YÊU CẦU 1: Chọn nhiều ---
|
||||
const handleToggleSelect = (id: string) => {
|
||||
setSelectedIds((prev) =>
|
||||
prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id],
|
||||
);
|
||||
};
|
||||
|
||||
const handleToggleSelectAll = (checked: boolean) => {
|
||||
if (checked && tableData) {
|
||||
setSelectedIds(tableData.data.map((i) => i.id));
|
||||
} else {
|
||||
setSelectedIds([]);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleItemSelection = (id: string) => {
|
||||
handleToggleSelect(id);
|
||||
};
|
||||
|
||||
const handleDeleteMulti = async () => {
|
||||
if (selectedIds.length === 0) return;
|
||||
|
||||
const result = await Swal.fire({
|
||||
title: "Xác nhận xóa?",
|
||||
text: `Bạn có chắc chắn muốn xóa ${selectedIds.length} tệp đã chọn? Hành động này không thể hoàn tác!`,
|
||||
icon: "warning",
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: "#ef4444",
|
||||
cancelButtonColor: "#6b7280",
|
||||
confirmButtonText: "Xóa",
|
||||
cancelButtonText: "Hủy",
|
||||
reverseButtons: true,
|
||||
});
|
||||
|
||||
if (result.isConfirmed) {
|
||||
try {
|
||||
const response = await deleteMedia(selectedIds);
|
||||
if (response?.status) {
|
||||
await Swal.fire({
|
||||
title: "Đã xóa!",
|
||||
text: `Đã xóa thành công ${selectedIds.length} tệp tin.`,
|
||||
icon: "success",
|
||||
confirmButtonColor: "#3b82f6",
|
||||
timer: 2000,
|
||||
});
|
||||
setSelectedIds([]);
|
||||
fetchMediaData();
|
||||
} else {
|
||||
toast.error(response?.message || "Xóa tệp thất bại");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Đã xảy ra lỗi khi xóa");
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSingle = async (id: string) => {
|
||||
const result = await Swal.fire({
|
||||
title: "Xóa tệp tin?",
|
||||
text: "Bạn có chắc chắn muốn xóa tệp này không?",
|
||||
icon: "warning",
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: "#ef4444",
|
||||
cancelButtonColor: "#6b7280",
|
||||
confirmButtonText: "Xóa ngay",
|
||||
cancelButtonText: "Quay lại",
|
||||
reverseButtons: true,
|
||||
});
|
||||
|
||||
if (result.isConfirmed) {
|
||||
try {
|
||||
const response = await deleteMediaById(id);
|
||||
if (response?.status) {
|
||||
await Swal.fire({
|
||||
title: "Thành công!",
|
||||
text: "Tệp tin đã được xóa bỏ.",
|
||||
icon: "success",
|
||||
confirmButtonColor: "#3b82f6",
|
||||
timer: 1500,
|
||||
});
|
||||
setSelectedIds((prev) => prev.filter((i) => i !== id));
|
||||
fetchMediaData();
|
||||
} else {
|
||||
toast.error(response?.message || "Không thể xóa tệp");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Lỗi hệ thống khi xóa");
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleItemClick = (item: MediaItem, idx: number) => {
|
||||
const isImage = item.mime_type.includes("image");
|
||||
|
||||
if (isSelectionMode) {
|
||||
toggleItemSelection(item.id);
|
||||
} else {
|
||||
if (isImage) {
|
||||
setIndex(idx);
|
||||
} else {
|
||||
const fileUrl = `${URL_MEDIA}${item.storage_key}`;
|
||||
const googleDocsUrl = `https://docs.google.com/viewer?url=${encodeURIComponent(fileUrl)}&embedded=true`;
|
||||
window.open(googleDocsUrl, "_blank");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const pagination = tableData?.pagination;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageBreadcrumb pageTitle="Quản lý tệp tin (Assets)" />
|
||||
|
||||
<div className="space-y-6">
|
||||
<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>
|
||||
}
|
||||
>
|
||||
<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
|
||||
type="text"
|
||||
placeholder="Tên tệp, ID..."
|
||||
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 tệp</label>
|
||||
<select
|
||||
value={mimeTypeFilter}
|
||||
onChange={(e) => setMimeTypeFilter(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="image">Hình ảnh (webp, jpeg, png...)</option>
|
||||
<option value="application/pdf">PDF</option>
|
||||
<option value="application/msword">Word (doc, docx)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block mb-2 text-sm font-medium">Thời gian</label>
|
||||
<CustomDateRangePicker
|
||||
onFilterChange={(sDate, eDate, sTime, eTime) => {
|
||||
setFromDate(sDate);
|
||||
setToDate(eDate);
|
||||
setFromTime(sTime);
|
||||
setToTime(eTime);
|
||||
}}
|
||||
/>
|
||||
</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 tệp tin"
|
||||
headerAction={
|
||||
<button
|
||||
onClick={handleDeleteMulti}
|
||||
disabled={selectedIds.length === 0}
|
||||
className={`flex items-center px-4 py-2 text-sm font-medium rounded-lg transition-colors border ${
|
||||
selectedIds.length > 0
|
||||
? "bg-red-500 text-white border-red-500 hover:bg-red-600 shadow-sm cursor-pointer"
|
||||
: "bg-gray-100 text-gray-400 border-gray-200 cursor-not-allowed dark:bg-gray-800 dark:border-gray-700"
|
||||
}`}
|
||||
>
|
||||
Xóa {selectedIds.length > 0 && `(${selectedIds.length})`}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
|
||||
<MediaTable
|
||||
data={tableData?.data || []}
|
||||
onSort={handleSort}
|
||||
sortBy={sortBy}
|
||||
sortOrder={sortOrder}
|
||||
selectedIds={selectedIds}
|
||||
onToggleSelect={handleToggleSelect}
|
||||
onToggleSelectAll={handleToggleSelectAll}
|
||||
onViewSingle={handleItemClick}
|
||||
onDeleteSingle={handleDeleteSingle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-6">
|
||||
<p className="text-sm text-gray-500">
|
||||
Hiển thị {pagination?.total_records || 0} tệp tin
|
||||
</p>
|
||||
|
||||
{pagination && pagination.total_pages > 1 && (
|
||||
<Pagination
|
||||
currentPage={pagination.current_page}
|
||||
totalPages={pagination.total_pages}
|
||||
onPageChange={(newPage) => setPage(newPage)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ComponentCard>
|
||||
</div>
|
||||
|
||||
{index >= 0 && tableData?.data && tableData.data[index] && (
|
||||
<div
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/80 backdrop-blur-sm transition-opacity"
|
||||
onClick={() => setIndex(-1)}
|
||||
>
|
||||
<div
|
||||
className="relative flex flex-col items-center justify-center p-4 max-w-[90vw] max-h-[90vh]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
|
||||
<button
|
||||
onClick={() => setIndex(-1)}
|
||||
className="absolute -top-12 right-0 md:-right-12 text-white/70 hover:text-white transition-colors p-2"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="w-8 h-8"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<img
|
||||
src={`${URL_MEDIA}${tableData.data[index].storage_key}`}
|
||||
alt={tableData.data[index].original_name}
|
||||
className="max-w-full max-h-[80vh] object-contain rounded-lg shadow-2xl"
|
||||
/>
|
||||
|
||||
<p className="mt-4 text-sm font-medium text-white/90 truncate max-w-full">
|
||||
{tableData.data[index].original_name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
259
src/app/(admin)/(others-pages)/(management)/project/page.tsx
Normal file
259
src/app/(admin)/(others-pages)/(management)/project/page.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
"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 { toast } from "sonner";
|
||||
import ProjectsTable, {
|
||||
ProjectItem,
|
||||
ProjectSortColumn,
|
||||
} from "@/components/tables/ProjectsTable";
|
||||
|
||||
import {
|
||||
getProjects,
|
||||
updateProject,
|
||||
deleteProject,
|
||||
transferProjectOwnership,
|
||||
} from "@/service/projectService";
|
||||
import Swal from "sweetalert2";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { LIMIT_ITEM_TABLE } from "../../../../../../constant";
|
||||
import { ProjectsResponse } from "@/interface/project";
|
||||
|
||||
import CustomDateRangePicker from "@/components/common/CustomDateRangePicker";
|
||||
|
||||
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 ProjectsPage(_props: {
|
||||
params: unknown;
|
||||
searchParams: unknown;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [page, setPage] = useState<number>(1);
|
||||
const [limitInput, setLimitInput] = useState<string>(
|
||||
LIMIT_ITEM_TABLE.toString(),
|
||||
);
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState<string>("");
|
||||
const [statusFilter, setStatusFilter] = useState<string>("");
|
||||
const [userIdsFilter, setUserIdsFilter] = useState<string>("");
|
||||
const [fromDate, setFromDate] = useState<string>("");
|
||||
const [fromTime, setFromTime] = useState<string>("");
|
||||
const [toDate, setToDate] = useState<string>("");
|
||||
const [toTime, setToTime] = useState<string>("");
|
||||
|
||||
const [resetKey, setResetKey] = useState<number>(0);
|
||||
|
||||
const [debouncedParams, setDebouncedParams] = useState({
|
||||
search: "",
|
||||
limit: LIMIT_ITEM_TABLE,
|
||||
statuses: "",
|
||||
userIds: "",
|
||||
fromDate: "",
|
||||
fromTime: "",
|
||||
toDate: "",
|
||||
toTime: "",
|
||||
});
|
||||
|
||||
const [tableData, setTableData] = useState<ProjectsResponse<ProjectItem> | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
|
||||
const [sortBy, setSortBy] = useState<ProjectSortColumn>("created_at");
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
|
||||
|
||||
const handleReset = () => {
|
||||
setSearchTerm("");
|
||||
setStatusFilter("");
|
||||
setUserIdsFilter("");
|
||||
setLimitInput(LIMIT_ITEM_TABLE.toString());
|
||||
setFromDate("");
|
||||
setFromTime("");
|
||||
setToDate("");
|
||||
setToTime("");
|
||||
setPage(1);
|
||||
setResetKey(prev => prev + 1);
|
||||
};
|
||||
|
||||
const handleDateFilterChange = (startD: string, endD: string, startT: string, endT: string) => {
|
||||
setFromDate(startD);
|
||||
setToDate(endD);
|
||||
setFromTime(startT);
|
||||
setToTime(endT);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedParams({
|
||||
search: searchTerm,
|
||||
limit: parseInt(limitInput) || LIMIT_ITEM_TABLE,
|
||||
statuses: statusFilter,
|
||||
userIds: userIdsFilter,
|
||||
fromDate,
|
||||
fromTime,
|
||||
toDate,
|
||||
toTime,
|
||||
});
|
||||
setPage(1);
|
||||
}, 100);
|
||||
return () => clearTimeout(handler);
|
||||
}, [
|
||||
searchTerm,
|
||||
limitInput,
|
||||
statusFilter,
|
||||
userIdsFilter,
|
||||
fromDate,
|
||||
fromTime,
|
||||
toDate,
|
||||
toTime,
|
||||
]);
|
||||
|
||||
const fetchProjectsData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const payload: any = {
|
||||
page: page,
|
||||
limit: debouncedParams.limit,
|
||||
search: debouncedParams.search || undefined,
|
||||
sort: sortBy,
|
||||
order: sortOrder,
|
||||
statuses: debouncedParams.statuses || undefined,
|
||||
user_ids: debouncedParams.userIds || undefined,
|
||||
};
|
||||
|
||||
const createdFrom = formatDateTimeToISO(debouncedParams.fromDate, debouncedParams.fromTime);
|
||||
if (createdFrom) payload.created_from = createdFrom;
|
||||
const createdTo = formatDateTimeToISO(debouncedParams.toDate, debouncedParams.toTime, true);
|
||||
if (createdTo) payload.created_to = createdTo;
|
||||
|
||||
const response = await getProjects(payload);
|
||||
|
||||
if (response?.status) {
|
||||
setTableData(response as unknown as ProjectsResponse<ProjectItem>);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error("Lỗi lấy danh sách dự án");
|
||||
console.error("Lỗi lấy danh sách dự án:", err);
|
||||
setTableData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, debouncedParams, sortBy, sortOrder]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProjectsData();
|
||||
}, [fetchProjectsData]);
|
||||
|
||||
const handleSort = (column: ProjectSortColumn) => {
|
||||
setPage(1);
|
||||
if (sortBy === column) {
|
||||
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
|
||||
} else {
|
||||
setSortBy(column);
|
||||
setSortOrder("desc");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleViewDetails = (id: string) => {
|
||||
router.push(`/projects/${id}`);
|
||||
};
|
||||
|
||||
const pagination = tableData?.pagination;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageBreadcrumb pageTitle="Quản lý dự án" />
|
||||
|
||||
<div className="space-y-6">
|
||||
<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">
|
||||
<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>
|
||||
}
|
||||
>
|
||||
<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 type="text" placeholder="Tên dự án, ID..." 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">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ả trạng thái</option>
|
||||
<option value="PUBLIC">PUBLIC</option>
|
||||
<option value="PRIVATE">PRIVATE</option>
|
||||
<option value="ARCHIVE">ARCHIVE</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block mb-2 text-sm font-medium">ID người dùng</label>
|
||||
<input type="text" placeholder="IDs (cách nhau bởi dấu phẩy)" value={userIdsFilter} onChange={(e) => setUserIdsFilter(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">Thời gian</label>
|
||||
<CustomDateRangePicker
|
||||
key={resetKey}
|
||||
onFilterChange={handleDateFilterChange}
|
||||
/>
|
||||
</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 dự án"
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
|
||||
<ProjectsTable
|
||||
data={tableData?.data || []}
|
||||
onSort={handleSort}
|
||||
sortBy={sortBy}
|
||||
sortOrder={sortOrder}
|
||||
onViewDetails={handleViewDetails}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-6">
|
||||
<p className="text-sm text-gray-500">
|
||||
Hiển thị {pagination?.total_records || 0} dự án
|
||||
</p>
|
||||
|
||||
{pagination && pagination.total_pages > 1 && (
|
||||
<Pagination
|
||||
currentPage={pagination.current_page}
|
||||
totalPages={pagination.total_pages}
|
||||
onPageChange={(newPage) => setPage(newPage)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ComponentCard>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
"use client";
|
||||
import ComponentCard from "@/components/common/ComponentCard";
|
||||
import Swal from "sweetalert2";
|
||||
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
|
||||
import BasicTableOne from "@/components/tables/BasicTableOne";
|
||||
import ChangeRoleModal from "@/components/tables/ChangeRoleModal";
|
||||
import UserDetailModal from "@/components/tables/UserDetailModal";
|
||||
import { responseUserTable, getUserDto, fullDataUser } from "@/interface/admin";
|
||||
import {
|
||||
apiDeleteUser,
|
||||
apiGetAllRole,
|
||||
apiGetListUser,
|
||||
apiRestoreUser,
|
||||
} from "@/service/adminService";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import Pagination from "@/components/tables/Pagination";
|
||||
import { LIMIT_ITEM_TABLE } from "../../../../../../constant";
|
||||
import CustomDateRangePicker from "@/components/common/CustomDateRangePicker";
|
||||
|
||||
export type SortColumn = "created_at" | "updated_at" | "display_name" | "email";
|
||||
|
||||
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 UserTable() {
|
||||
const [page, setPage] = useState<number>(1);
|
||||
const [limitInput, setLimitInput] = useState<string>(LIMIT_ITEM_TABLE.toString());
|
||||
|
||||
const [selectedRole, setSelectedRole] = useState<string>("");
|
||||
const [roles, setRoles] = useState<{ id: string; name: string }[]>([]);
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState<string>("");
|
||||
const [authProvider, setAuthProvider] = useState<string>("");
|
||||
const [isDeleted, setIsDeleted] = useState<boolean | undefined>(undefined);
|
||||
|
||||
const [fromDate, setFromDate] = useState<string>("");
|
||||
const [fromTime, setFromTime] = useState<string>("");
|
||||
const [toDate, setToDate] = useState<string>("");
|
||||
const [toTime, setToTime] = useState<string>("");
|
||||
|
||||
const [selectedUser, setSelectedUser] = useState<fullDataUser | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [roleUser, setRoleUser] = useState<fullDataUser | null>(null);
|
||||
const [isRoleModalOpen, setIsRoleModalOpen] = useState(false);
|
||||
|
||||
const [debouncedParams, setDebouncedParams] = useState({
|
||||
search: "",
|
||||
limit: 5,
|
||||
authProvider: "",
|
||||
fromDate: "",
|
||||
fromTime: "",
|
||||
toDate: "",
|
||||
toTime: "",
|
||||
});
|
||||
|
||||
const [tableData, setTableData] = useState<responseUserTable | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [sortBy, setSortBy] = useState<SortColumn | undefined>(undefined);
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
|
||||
|
||||
const handleReset = () => {
|
||||
setSearchTerm("");
|
||||
setAuthProvider("");
|
||||
setIsDeleted(undefined);
|
||||
setSelectedRole("");
|
||||
setLimitInput(LIMIT_ITEM_TABLE.toString());
|
||||
|
||||
setFromDate("");
|
||||
setFromTime("");
|
||||
setToDate("");
|
||||
setToTime("");
|
||||
|
||||
setPage(1);
|
||||
|
||||
setDebouncedParams({
|
||||
search: "",
|
||||
limit: LIMIT_ITEM_TABLE,
|
||||
authProvider: "",
|
||||
fromDate: "",
|
||||
fromTime: "",
|
||||
toDate: "",
|
||||
toTime: "",
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchRoles = async () => {
|
||||
try {
|
||||
const res = await apiGetAllRole();
|
||||
if (res?.status) {
|
||||
setRoles(res.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Lỗi lấy danh sách role:", err);
|
||||
}
|
||||
};
|
||||
fetchRoles();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedParams({
|
||||
search: searchTerm,
|
||||
limit: parseInt(limitInput) || LIMIT_ITEM_TABLE,
|
||||
authProvider: authProvider,
|
||||
fromDate,
|
||||
fromTime,
|
||||
toDate,
|
||||
toTime,
|
||||
});
|
||||
setPage(1);
|
||||
}, 600);
|
||||
return () => clearTimeout(handler);
|
||||
}, [
|
||||
searchTerm,
|
||||
limitInput,
|
||||
authProvider,
|
||||
fromDate,
|
||||
fromTime,
|
||||
toDate,
|
||||
toTime,
|
||||
]);
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const payload: any = {
|
||||
page: page,
|
||||
limit: debouncedParams.limit,
|
||||
search: debouncedParams.search || undefined,
|
||||
auth_provider: debouncedParams.authProvider || undefined,
|
||||
is_deleted: isDeleted,
|
||||
sort: sortBy,
|
||||
order: sortOrder,
|
||||
role_ids: selectedRole ? [selectedRole] : undefined,
|
||||
};
|
||||
|
||||
// Thêm format ngày giờ vào payload
|
||||
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;
|
||||
|
||||
const response = await apiGetListUser(payload as getUserDto);
|
||||
if (response?.status) {
|
||||
setTableData(response);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Fetch error:", err);
|
||||
setTableData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, debouncedParams, isDeleted, sortBy, sortOrder, selectedRole]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, [fetchUsers]);
|
||||
|
||||
const handleSort = (column: SortColumn) => {
|
||||
setPage(1);
|
||||
if (sortBy === column) {
|
||||
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
|
||||
} else {
|
||||
setSortBy(column);
|
||||
setSortOrder("desc");
|
||||
}
|
||||
};
|
||||
|
||||
const pagination = tableData?.pagination;
|
||||
|
||||
// console.log(pagination);
|
||||
|
||||
const handleOpenDetail = (user: fullDataUser) => {
|
||||
setSelectedUser(user);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenRoleModal = (user: fullDataUser) => {
|
||||
setRoleUser(user);
|
||||
setIsRoleModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (user: fullDataUser) => {
|
||||
const result = await Swal.fire({
|
||||
title: "Xác nhận khóa?",
|
||||
text: `Bạn có chắc muốn khóa người dùng ${user.profile?.display_name || user.email}?`,
|
||||
icon: "warning",
|
||||
showCancelButton: true,
|
||||
showCloseButton: true,
|
||||
confirmButtonColor: "#d33",
|
||||
cancelButtonColor: "#3085d6",
|
||||
confirmButtonText: "Đồng ý, khóa!",
|
||||
cancelButtonText: "Hủy",
|
||||
reverseButtons: true,
|
||||
});
|
||||
|
||||
if (result.isConfirmed) {
|
||||
try {
|
||||
await apiDeleteUser(user.id);
|
||||
Swal.fire(
|
||||
"Đã khóa!",
|
||||
"Người dùng đã bị tạm dừng hoạt động.",
|
||||
"success",
|
||||
);
|
||||
fetchUsers();
|
||||
} catch (err) {
|
||||
Swal.fire("Lỗi!", "Không thể thực hiện thao tác này.", "error");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestore = async (user: fullDataUser) => {
|
||||
const result = await Swal.fire({
|
||||
title: "Khôi phục tài khoản?",
|
||||
text: `Khôi phục quyền truy cập cho ${user.profile?.display_name || user.email}?`,
|
||||
icon: "question",
|
||||
showCancelButton: true,
|
||||
showCloseButton: true,
|
||||
confirmButtonColor: "#28a745",
|
||||
confirmButtonText: "Xác nhận",
|
||||
cancelButtonText: "Hủy",
|
||||
});
|
||||
|
||||
if (result.isConfirmed) {
|
||||
try {
|
||||
await apiRestoreUser(user.id);
|
||||
Swal.fire("Thành công", "Tài khoản đã được khôi phục.", "success");
|
||||
fetchUsers();
|
||||
} catch (err) {
|
||||
Swal.fire("Thất bại", "Vui lòng thử lại sau.", "error");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageBreadcrumb pageTitle="Quản lý người dùng" />
|
||||
<div className="space-y-6">
|
||||
<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>
|
||||
}
|
||||
>
|
||||
<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">Search</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Name, email..."
|
||||
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">
|
||||
Phương thức đăng nhập
|
||||
</label>
|
||||
<select
|
||||
value={authProvider}
|
||||
onChange={(e) => setAuthProvider(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 cursor-pointer bg-white outline-none focus:border-brand-500"
|
||||
>
|
||||
<option value="">Tất cả</option>
|
||||
<option value="google">Google</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block mb-2 text-sm font-medium">
|
||||
Trạng thái
|
||||
</label>
|
||||
<select
|
||||
onChange={(e) =>
|
||||
setIsDeleted(
|
||||
e.target.value === ""
|
||||
? undefined
|
||||
: e.target.value === "true",
|
||||
)
|
||||
}
|
||||
value={isDeleted === undefined ? "" : isDeleted ? "true" : "false"}
|
||||
className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 outline-none focus:border-brand-500 cursor-pointer"
|
||||
>
|
||||
<option value="">Tất cả</option>
|
||||
<option value="false">Hoạt động</option>
|
||||
<option value="true">Đã xóa</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block mb-2 text-sm font-medium">Thời gian</label>
|
||||
<CustomDateRangePicker
|
||||
onFilterChange={(sDate, eDate, sTime, eTime) => {
|
||||
setFromDate(sDate);
|
||||
setToDate(eDate);
|
||||
setFromTime(sTime);
|
||||
setToTime(eTime);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Limit */}
|
||||
<div>
|
||||
<label className="block mb-2 text-sm font-medium">
|
||||
Số lượng (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 người dùng">
|
||||
<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>
|
||||
)}
|
||||
|
||||
<BasicTableOne
|
||||
data={tableData?.data || []}
|
||||
onSort={handleSort}
|
||||
sortBy={sortBy}
|
||||
sortOrder={sortOrder}
|
||||
onViewDetail={handleOpenDetail}
|
||||
roles={roles}
|
||||
selectedRole={selectedRole}
|
||||
onFilterRole={(role) => setSelectedRole(role)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-6">
|
||||
<p className="text-sm text-gray-500">
|
||||
Hiển thị trang {pagination?.current_page || 1} /{" "}
|
||||
{pagination?.total_pages || 1} ({pagination?.total_records || 0}{" "}
|
||||
kết quả)
|
||||
</p>
|
||||
|
||||
{pagination && pagination.total_pages > 1 && (
|
||||
<Pagination
|
||||
currentPage={pagination.current_page}
|
||||
totalPages={pagination.total_pages}
|
||||
onPageChange={(newPage) => setPage(newPage)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<UserDetailModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
user={selectedUser}
|
||||
onChangeRole={handleOpenRoleModal}
|
||||
onDelete={(u) => {
|
||||
handleDelete(u);
|
||||
setIsModalOpen(false);
|
||||
}}
|
||||
onRestore={(u) => {
|
||||
handleRestore(u);
|
||||
setIsModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ChangeRoleModal
|
||||
isOpen={isRoleModalOpen}
|
||||
onClose={() => setIsRoleModalOpen(false)}
|
||||
user={roleUser}
|
||||
onSuccess={fetchUsers}
|
||||
/>
|
||||
</ComponentCard>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,449 +0,0 @@
|
||||
"use client";
|
||||
import ComponentCard from "@/components/common/ComponentCard";
|
||||
import Swal from "sweetalert2";
|
||||
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
|
||||
import BasicTableOne from "@/components/tables/BasicTableOne";
|
||||
import ChangeRoleModal from "@/components/tables/ChangeRoleModal";
|
||||
import UserDetailModal from "@/components/tables/UserDetailModal";
|
||||
import { responseUserTable, getUserDto, fullDataUser } from "@/interface/admin";
|
||||
import {
|
||||
apiDeleteUser,
|
||||
apiGetAllRole,
|
||||
apiGetListUser,
|
||||
apiRestoreUser,
|
||||
} from "@/service/adminService";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import Pagination from "@/components/tables/Pagination";
|
||||
import { LIMIT_ITEM_TABLE } from "../../../../../../constant";
|
||||
import { ProtectedRoute } from "@/components/auth/ProtectedRoute";
|
||||
|
||||
export type SortColumn = "created_at" | "updated_at" | "display_name" | "email";
|
||||
|
||||
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 UserTable() {
|
||||
const [page, setPage] = useState<number>(1);
|
||||
const [limitInput, setLimitInput] = useState<string>(
|
||||
LIMIT_ITEM_TABLE.toString(),
|
||||
);
|
||||
|
||||
const [selectedRole, setSelectedRole] = useState<string>("");
|
||||
const [roles, setRoles] = useState<{ id: string; name: string }[]>([]);
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState<string>("");
|
||||
const [authProvider, setAuthProvider] = useState<string>("");
|
||||
const [isDeleted, setIsDeleted] = useState<boolean | undefined>(undefined);
|
||||
|
||||
const [fromDate, setFromDate] = useState<string>("");
|
||||
const [fromTime, setFromTime] = useState<string>("");
|
||||
const [toDate, setToDate] = useState<string>("");
|
||||
const [toTime, setToTime] = useState<string>("");
|
||||
|
||||
const [selectedUser, setSelectedUser] = useState<fullDataUser | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [roleUser, setRoleUser] = useState<fullDataUser | null>(null);
|
||||
const [isRoleModalOpen, setIsRoleModalOpen] = useState(false);
|
||||
|
||||
const [debouncedParams, setDebouncedParams] = useState({
|
||||
search: "",
|
||||
limit: 5,
|
||||
authProvider: "",
|
||||
fromDate: "",
|
||||
fromTime: "",
|
||||
toDate: "",
|
||||
toTime: "",
|
||||
});
|
||||
|
||||
const [tableData, setTableData] = useState<responseUserTable | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [sortBy, setSortBy] = useState<SortColumn | undefined>(undefined);
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
|
||||
|
||||
const handleReset = () => {
|
||||
setSearchTerm("");
|
||||
setAuthProvider("");
|
||||
setIsDeleted(undefined);
|
||||
setSelectedRole("");
|
||||
setLimitInput(LIMIT_ITEM_TABLE.toString());
|
||||
|
||||
setFromDate("");
|
||||
setFromTime("");
|
||||
setToDate("");
|
||||
setToTime("");
|
||||
|
||||
setPage(1);
|
||||
|
||||
setDebouncedParams({
|
||||
search: "",
|
||||
limit: LIMIT_ITEM_TABLE,
|
||||
authProvider: "",
|
||||
fromDate: "",
|
||||
fromTime: "",
|
||||
toDate: "",
|
||||
toTime: "",
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchRoles = async () => {
|
||||
try {
|
||||
const res = await apiGetAllRole();
|
||||
if (res?.status) {
|
||||
setRoles(res.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Lỗi lấy danh sách role:", err);
|
||||
}
|
||||
};
|
||||
fetchRoles();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedParams({
|
||||
search: searchTerm,
|
||||
limit: parseInt(limitInput) || LIMIT_ITEM_TABLE,
|
||||
authProvider: authProvider,
|
||||
fromDate,
|
||||
fromTime,
|
||||
toDate,
|
||||
toTime,
|
||||
});
|
||||
setPage(1);
|
||||
}, 600);
|
||||
return () => clearTimeout(handler);
|
||||
}, [
|
||||
searchTerm,
|
||||
limitInput,
|
||||
authProvider,
|
||||
fromDate,
|
||||
fromTime,
|
||||
toDate,
|
||||
toTime,
|
||||
]);
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const payload: any = {
|
||||
page: page,
|
||||
limit: debouncedParams.limit,
|
||||
search: debouncedParams.search || undefined,
|
||||
auth_provider: debouncedParams.authProvider || undefined,
|
||||
is_deleted: isDeleted,
|
||||
sort: sortBy,
|
||||
order: sortOrder,
|
||||
role_ids: selectedRole ? [selectedRole] : undefined,
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
const response = await apiGetListUser(payload as getUserDto);
|
||||
if (response?.status) {
|
||||
setTableData(response);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Fetch error:", err);
|
||||
setTableData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, debouncedParams, isDeleted, sortBy, sortOrder, selectedRole]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, [fetchUsers]);
|
||||
|
||||
const handleSort = (column: SortColumn) => {
|
||||
setPage(1);
|
||||
if (sortBy === column) {
|
||||
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
|
||||
} else {
|
||||
setSortBy(column);
|
||||
setSortOrder("desc");
|
||||
}
|
||||
};
|
||||
|
||||
const pagination = tableData?.pagination;
|
||||
|
||||
// console.log(pagination);
|
||||
|
||||
const handleOpenDetail = (user: fullDataUser) => {
|
||||
setSelectedUser(user);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenRoleModal = (user: fullDataUser) => {
|
||||
setRoleUser(user);
|
||||
setIsRoleModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (user: fullDataUser) => {
|
||||
const result = await Swal.fire({
|
||||
title: "Xác nhận khóa?",
|
||||
text: `Bạn có chắc muốn khóa người dùng ${user.profile?.display_name || user.email}?`,
|
||||
icon: "warning",
|
||||
showCancelButton: true,
|
||||
showCloseButton: true,
|
||||
confirmButtonColor: "#d33",
|
||||
cancelButtonColor: "#3085d6",
|
||||
confirmButtonText: "Đồng ý, khóa!",
|
||||
cancelButtonText: "Hủy",
|
||||
reverseButtons: true,
|
||||
});
|
||||
|
||||
if (result.isConfirmed) {
|
||||
try {
|
||||
await apiDeleteUser(user.id);
|
||||
Swal.fire(
|
||||
"Đã khóa!",
|
||||
"Người dùng đã bị tạm dừng hoạt động.",
|
||||
"success",
|
||||
);
|
||||
fetchUsers();
|
||||
} catch (err) {
|
||||
Swal.fire("Lỗi!", "Không thể thực hiện thao tác này.", "error");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestore = async (user: fullDataUser) => {
|
||||
const result = await Swal.fire({
|
||||
title: "Khôi phục tài khoản?",
|
||||
text: `Khôi phục quyền truy cập cho ${user.profile?.display_name || user.email}?`,
|
||||
icon: "question",
|
||||
showCancelButton: true,
|
||||
showCloseButton: true,
|
||||
confirmButtonColor: "#28a745",
|
||||
confirmButtonText: "Xác nhận",
|
||||
cancelButtonText: "Hủy",
|
||||
});
|
||||
|
||||
if (result.isConfirmed) {
|
||||
try {
|
||||
await apiRestoreUser(user.id);
|
||||
Swal.fire("Thành công", "Tài khoản đã được khôi phục.", "success");
|
||||
fetchUsers();
|
||||
} catch (err) {
|
||||
Swal.fire("Thất bại", "Vui lòng thử lại sau.", "error");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ProtectedRoute requiredRoles={["ADMIN", "MOD"]}>
|
||||
<div>
|
||||
<PageBreadcrumb pageTitle="Quản lý người dùng" />
|
||||
<div className="space-y-6">
|
||||
<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>
|
||||
}
|
||||
>
|
||||
<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">Search</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Name, email..."
|
||||
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">
|
||||
Auth Provider
|
||||
</label>
|
||||
<select
|
||||
value={authProvider}
|
||||
onChange={(e) => setAuthProvider(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 cursor-pointer bg-white outline-none focus:border-brand-500"
|
||||
>
|
||||
<option value="">Tất cả</option>
|
||||
<option value="google">Google</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block mb-2 text-sm font-medium">
|
||||
Trạng thái xóa
|
||||
</label>
|
||||
<select
|
||||
onChange={(e) =>
|
||||
setIsDeleted(
|
||||
e.target.value === ""
|
||||
? undefined
|
||||
: e.target.value === "true",
|
||||
)
|
||||
}
|
||||
value={
|
||||
isDeleted === undefined ? "" : isDeleted ? "true" : "false"
|
||||
}
|
||||
className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 outline-none focus:border-brand-500 cursor-pointer"
|
||||
>
|
||||
<option value="">Tất cả</option>
|
||||
<option value="false">Hoạt động</option>
|
||||
<option value="true">Đã xóa</option>
|
||||
</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">
|
||||
Số lượng (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 người dùng">
|
||||
<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>
|
||||
)}
|
||||
|
||||
<BasicTableOne
|
||||
data={tableData?.data || []}
|
||||
onSort={handleSort}
|
||||
sortBy={sortBy}
|
||||
sortOrder={sortOrder}
|
||||
onViewDetail={handleOpenDetail}
|
||||
roles={roles}
|
||||
selectedRole={selectedRole}
|
||||
onFilterRole={(role) => setSelectedRole(role)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-6">
|
||||
<p className="text-sm text-gray-500">
|
||||
Hiển thị trang {pagination?.current_page || 1} /{" "}
|
||||
{pagination?.total_pages || 1} ({pagination?.total_records || 0}{" "}
|
||||
kết quả)
|
||||
</p>
|
||||
|
||||
{pagination && pagination.total_pages > 1 && (
|
||||
<Pagination
|
||||
currentPage={pagination.current_page}
|
||||
totalPages={pagination.total_pages}
|
||||
onPageChange={(newPage) => setPage(newPage)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<UserDetailModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
user={selectedUser}
|
||||
onChangeRole={handleOpenRoleModal}
|
||||
onDelete={(u) => {
|
||||
handleDelete(u);
|
||||
setIsModalOpen(false);
|
||||
}}
|
||||
onRestore={(u) => {
|
||||
handleRestore(u);
|
||||
setIsModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ChangeRoleModal
|
||||
isOpen={isRoleModalOpen}
|
||||
onClose={() => setIsRoleModalOpen(false)}
|
||||
user={roleUser}
|
||||
onSuccess={fetchUsers}
|
||||
/>
|
||||
</ComponentCard>
|
||||
</div>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export default function ApplicationDetailPage() {
|
||||
(state: RootState) => state.user.selectedApplication,
|
||||
);
|
||||
const router = useRouter();
|
||||
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [errMessage, setErrMessage] = useState<string>(
|
||||
"Không thể xóa đơn đăng ký này.",
|
||||
@@ -38,18 +39,6 @@ export default function ApplicationDetailPage() {
|
||||
|
||||
const config = statusConfig[application.status] || statusConfig.PENDING;
|
||||
|
||||
const formatDate = (dateString: string | null | undefined) => {
|
||||
if (!dateString) return "-";
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString("vi-VN", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
const isImageFile = (file: any) => {
|
||||
const isImageMime = file.mime_type?.startsWith("image/");
|
||||
const isImageExt = /\.(jpg|jpeg|png|webp|gif)$/i.test(file.storage_key);
|
||||
@@ -106,7 +95,7 @@ export default function ApplicationDetailPage() {
|
||||
background: isDarkMode ? "#18181b" : "#fff",
|
||||
color: isDarkMode ? "#fff" : "#000",
|
||||
});
|
||||
router.push("/profile");
|
||||
router.push("/account");
|
||||
} catch (error: any) {
|
||||
setErrMessage(
|
||||
error.response?.data?.message || "Có lỗi xảy ra khi xóa!",
|
||||
@@ -130,6 +119,17 @@ export default function ApplicationDetailPage() {
|
||||
}
|
||||
}
|
||||
};
|
||||
const formatDate = (dateString: string | null | undefined) => {
|
||||
if (!dateString) return "-";
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString("vi-VN", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
// console.log("Application Detail:", application);
|
||||
|
||||
@@ -226,57 +226,55 @@ export default function ApplicationDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{application?.reviewer && (
|
||||
<section className="pt-8 border-t border-zinc-200 dark:border-zinc-800">
|
||||
<label className="text-[11px] font-bold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider mb-3 block">
|
||||
Phản hồi từ người kiểm duyệt
|
||||
</label>
|
||||
{application?.reviewer && (
|
||||
<section className="pt-8 border-t border-zinc-200 dark:border-zinc-800">
|
||||
<label className="text-[11px] font-bold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider mb-3 block">
|
||||
Phản hồi từ người kiểm duyệt
|
||||
</label>
|
||||
|
||||
<div className="p-5 rounded-xl border border-zinc-200 dark:border-zinc-800 bg-zinc-50/50 dark:bg-zinc-900/50">
|
||||
<div className="flex items-start justify-between mb-5">
|
||||
<div className="flex items-center gap-3">
|
||||
{application?.reviewer?.avatar_url ? (
|
||||
<Image
|
||||
src={application.reviewer.avatar_url}
|
||||
alt={application.reviewer.display_name || "Avatar"}
|
||||
width={36}
|
||||
height={36}
|
||||
className="w-9 h-9 rounded-full object-cover border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-9 h-9 rounded-full bg-zinc-200 dark:bg-zinc-800 flex items-center justify-center text-zinc-500 font-medium text-sm">
|
||||
{application?.reviewer?.display_name?.charAt(0) || "R"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
{application?.reviewer?.display_name}
|
||||
</h4>
|
||||
<p className="text-[11px] text-zinc-500 dark:text-zinc-400">
|
||||
{application?.reviewer?.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[11px] text-zinc-400 dark:text-zinc-500 tabular-nums">
|
||||
{formatDate(application?.reviewed_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative ml-4 pl-4 border-l border-zinc-300 dark:border-zinc-700">
|
||||
<div className="text-sm text-zinc-700 dark:text-zinc-300 leading-relaxed">
|
||||
{application?.review_note ? (
|
||||
<p>{application.review_note}</p>
|
||||
) : (
|
||||
<p className="italic text-zinc-500">
|
||||
Không có ghi chú bổ sung.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5 rounded-xl border border-zinc-200 dark:border-zinc-800 bg-zinc-50/50 dark:bg-zinc-900/50">
|
||||
<div className="flex items-start justify-between mb-5">
|
||||
<div className="flex items-center gap-3">
|
||||
{application?.reviewer?.avatar_url ? (
|
||||
<Image
|
||||
src={application.reviewer.avatar_url}
|
||||
alt={application.reviewer.display_name || "Avatar"}
|
||||
width={36}
|
||||
height={36}
|
||||
className="w-9 h-9 rounded-full object-cover border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-9 h-9 rounded-full bg-zinc-200 dark:bg-zinc-800 flex items-center justify-center text-zinc-500 font-medium text-sm">
|
||||
{application?.reviewer?.display_name?.charAt(0) || "R"}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
{application?.reviewer?.display_name}
|
||||
</h4>
|
||||
<p className="text-[11px] text-zinc-500 dark:text-zinc-400">
|
||||
{application?.reviewer?.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[11px] text-zinc-400 dark:text-zinc-500 tabular-nums">
|
||||
{formatDate(application?.reviewed_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative ml-4 pl-4 border-l border-zinc-300 dark:border-zinc-700">
|
||||
<div className="text-sm text-zinc-700 dark:text-zinc-300 leading-relaxed">
|
||||
{application?.review_note ? (
|
||||
<p>{application.review_note}</p>
|
||||
) : (
|
||||
<p className="italic text-zinc-500">Không có ghi chú bổ sung.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Lightbox
|
||||
@@ -1,36 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import AccountDetails from "@/components/user-profile/AccountDetails";
|
||||
import ApplicationList from "@/components/user-profile/ApplicationList";
|
||||
import MediaCard from "@/components/user-profile/Media";
|
||||
import UserInfoCard from "@/components/user-profile/UserInfoCard";
|
||||
import UserMetaCard from "@/components/user-profile/UserMetaCard";
|
||||
import { MediaDto } from "@/interface/media";
|
||||
import { UserMetaCardProps } from "@/interface/user";
|
||||
import { apiGetCurrentUser } from "@/service/auth";
|
||||
import { apiGetCurrentUserApplications, apiGetCurrentUserMedia } from "@/service/userService";
|
||||
import { setUserData } from "@/store/features/userSlice";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
|
||||
export default function Profile() {
|
||||
const [user, setUser] = useState<UserMetaCardProps | null>(null);
|
||||
const [mediaData, setMediaData] = useState<MediaDto | null>(null);
|
||||
const [applications, setApplications] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
const userData = await apiGetCurrentUser();
|
||||
const mediaResponse = await apiGetCurrentUserMedia();
|
||||
const userApplications = await apiGetCurrentUserApplications();
|
||||
|
||||
console.log(userData);
|
||||
|
||||
if (userApplications?.data) {
|
||||
setApplications(userApplications.data);
|
||||
}
|
||||
|
||||
setMediaData(mediaResponse);
|
||||
dispatch(setUserData(userData.data));
|
||||
setUser(userData);
|
||||
} catch (err) {
|
||||
console.error("Lỗi:", err);
|
||||
@@ -50,8 +38,6 @@ export default function Profile() {
|
||||
<div className="space-y-6">
|
||||
<UserMetaCard data={user ?? {}} />
|
||||
<UserInfoCard data={{ ...user, openEdit: true }} />
|
||||
{(mediaData?.data?.length ?? 0) > 0 && <MediaCard data={mediaData ?? {}} />}
|
||||
<ApplicationList applications={applications} />
|
||||
<AccountDetails data={user ?? {}} />
|
||||
</div>
|
||||
</div>
|
||||
74
src/app/(admin)/(others-pages)/library/page.tsx
Normal file
74
src/app/(admin)/(others-pages)/library/page.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import ApplicationLibrary from "@/components/user-profile/ApplicationList";
|
||||
import { MediaDto } from "@/interface/media";
|
||||
import { apiGetCurrentUserApplications, apiGetCurrentUserMedia } from "@/service/userService";
|
||||
import MediaLibrary from "@/components/user-profile/Media";
|
||||
|
||||
export default function LibraryPage() {
|
||||
const [mediaData, setMediaData] = useState<MediaDto | null>(null);
|
||||
const [applications, setApplications] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchLibraryContent = async () => {
|
||||
try {
|
||||
const [mediaResponse, userApplications] = await Promise.all([
|
||||
apiGetCurrentUserMedia(),
|
||||
apiGetCurrentUserApplications()
|
||||
]);
|
||||
|
||||
if (userApplications?.data) setApplications(userApplications.data);
|
||||
setMediaData(mediaResponse);
|
||||
} catch (err) {
|
||||
console.error("Lỗi khi tải thư viện:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchLibraryContent();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-[50vh] items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-500 border-t-transparent"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hasNoData = (!mediaData?.data || mediaData.data.length === 0) && applications.length === 0;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50/50 p-4 dark:bg-zinc-950 lg:p-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-gray-900 dark:text-white">
|
||||
Thư viện
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{hasNoData ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-gray-200 py-24 text-center dark:border-zinc-800">
|
||||
<p className="text-lg font-medium text-gray-500 dark:text-gray-400">
|
||||
Chưa có nội dung nào trong thư viện
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-12">
|
||||
{(mediaData?.data?.length ?? 0) > 0 && (
|
||||
<section>
|
||||
<MediaLibrary data={mediaData ?? {}} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{applications.length > 0 && (
|
||||
<section>
|
||||
<ApplicationLibrary applications={applications} />
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
622
src/app/(admin)/(others-pages)/projects/[id]/page.tsx
Normal file
622
src/app/(admin)/(others-pages)/projects/[id]/page.tsx
Normal file
@@ -0,0 +1,622 @@
|
||||
"use client";
|
||||
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
getProjectDetailByID,
|
||||
updateProject,
|
||||
transferProjectOwnership,
|
||||
addProjectMember,
|
||||
updateProjectMemberRole,
|
||||
removeProjectMember,
|
||||
deleteProject,
|
||||
} from "@/service/projectService";
|
||||
import { Project } from "@/interface/project";
|
||||
import Swal from "sweetalert2";
|
||||
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
|
||||
|
||||
type TabType = "overview" | "members" | "settings";
|
||||
|
||||
export default function ProjectDetailsPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const id = params.id as string;
|
||||
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<TabType>("overview");
|
||||
|
||||
const [editForm, setEditForm] = useState({
|
||||
title: "",
|
||||
description: "",
|
||||
status: "PRIVATE" as any,
|
||||
});
|
||||
const [newOwnerId, setNewOwnerId] = useState("");
|
||||
const [newMember, setNewMember] = useState({
|
||||
user_id: "",
|
||||
role: "EDITOR" as any,
|
||||
});
|
||||
|
||||
const fetchProject = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getProjectDetailByID(id);
|
||||
if (res?.status && res.data) {
|
||||
setProject(res.data);
|
||||
setEditForm({
|
||||
title: res.data.title,
|
||||
description: res.data.description,
|
||||
status: res.data.project_status,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Lỗi khi tải dữ liệu dự án");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (id) fetchProject();
|
||||
}, [id]);
|
||||
|
||||
const handleUpdateInfo = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await updateProject(id, editForm);
|
||||
toast.success("Cập nhật thông tin thành công!");
|
||||
fetchProject();
|
||||
} catch (error) {
|
||||
toast.error("Cập nhật thất bại");
|
||||
}
|
||||
};
|
||||
|
||||
const handleTransferOwnership = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const memberName =
|
||||
project?.members?.find((m) => m.user_id === newOwnerId)?.display_name ||
|
||||
"thành viên này";
|
||||
|
||||
const result = await Swal.fire({
|
||||
title: "Chuyển quyền sở hữu?",
|
||||
html: `Bạn có chắc chắn muốn chuyển dự án này cho <b>${memberName}</b>?<br/>Hành động này <b>không thể hoàn tác</b> và bạn sẽ không còn là chủ sở hữu nữa.`,
|
||||
icon: "error",
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: "#238636",
|
||||
cancelButtonColor: "#30363d",
|
||||
confirmButtonText: "Tôi hiểu, chuyển quyền sở hữu",
|
||||
cancelButtonText: "Hủy bỏ",
|
||||
color: "#333",
|
||||
customClass: {
|
||||
popup: "border border-[#30363d] rounded-xl",
|
||||
},
|
||||
});
|
||||
|
||||
if (result.isConfirmed) {
|
||||
try {
|
||||
const res = await transferProjectOwnership(id, {
|
||||
new_owner_id: newOwnerId,
|
||||
});
|
||||
if (res?.status) {
|
||||
toast.success("Đã chuyển quyền sở hữu thành công!");
|
||||
setNewOwnerId("");
|
||||
fetchProject();
|
||||
} else {
|
||||
toast.error(res?.message || "Chuyển quyền thất bại");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Lỗi hệ thống khi chuyển quyền");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddMember = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newMember.user_id) return toast.error("Vui lòng nhập User ID");
|
||||
try {
|
||||
await addProjectMember(id, newMember);
|
||||
toast.success("Thêm thành viên thành công");
|
||||
setNewMember({ user_id: "", role: "EDITOR" });
|
||||
fetchProject();
|
||||
} catch (error) {
|
||||
toast.error("Lỗi thêm thành viên");
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateRole = async (userId: string, newRole: string) => {
|
||||
try {
|
||||
const res = await updateProjectMemberRole(id, userId, {
|
||||
role: newRole as any,
|
||||
});
|
||||
|
||||
if (res?.status) {
|
||||
toast.success("Cập nhật quyền thành công");
|
||||
fetchProject();
|
||||
} else {
|
||||
toast.error(res?.message || "Cập nhật quyền thất bại");
|
||||
}
|
||||
} catch (error: any) {
|
||||
const errorMessage =
|
||||
error.response?.data?.message || "Cập nhật quyền thất bại";
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveMember = async (userId: string) => {
|
||||
// 1. Hiển thị hộp thoại xác nhận bằng SweetAlert2
|
||||
const result = await Swal.fire({
|
||||
title: "Xác nhận xóa?",
|
||||
text: "Bạn có chắc chắn muốn xóa thành viên này khỏi dự án?",
|
||||
icon: "warning",
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: "#3085d6",
|
||||
cancelButtonColor: "#d33",
|
||||
confirmButtonText: "Đồng ý",
|
||||
cancelButtonText: "Hủy",
|
||||
});
|
||||
|
||||
if (!result.isConfirmed) return;
|
||||
|
||||
try {
|
||||
const res = await removeProjectMember(id, userId);
|
||||
|
||||
if (res?.status) {
|
||||
toast.success("Đã xóa thành viên");
|
||||
} else {
|
||||
toast.error(res?.message || "Xóa thành viên thất bại");
|
||||
}
|
||||
} catch (error: any) {
|
||||
const errorMessage =
|
||||
error.response?.data?.message || "Xóa thành viên thất bại";
|
||||
toast.error(errorMessage);
|
||||
|
||||
console.error("Remove Member Error:", error);
|
||||
} finally {
|
||||
fetchProject();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteProject = async () => {
|
||||
const result = await Swal.fire({
|
||||
title: "Xác nhận xóa dự án?",
|
||||
text: "Hành động này sẽ xóa vĩnh viễn dự án. Bạn không thể hoàn tác sau khi xác nhận!",
|
||||
icon: "warning",
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: "#d33",
|
||||
cancelButtonColor: "#30363d",
|
||||
confirmButtonText: "Tôi hiểu, xóa dự án này",
|
||||
cancelButtonText: "Hủy",
|
||||
color: "#333",
|
||||
customClass: {
|
||||
popup: "border border-[#30363d] rounded-xl",
|
||||
},
|
||||
});
|
||||
|
||||
if (result.isConfirmed) {
|
||||
try {
|
||||
const res = await deleteProject(id);
|
||||
if (res?.status) {
|
||||
toast.success("Đã xóa dự án thành công");
|
||||
router.push("/project");
|
||||
} else {
|
||||
toast.error(res?.message || "Xóa dự án thất bại");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Đã xảy ra lỗi khi kết nối máy chủ");
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (loading)
|
||||
return (
|
||||
<div className="flex justify-center p-20 text-gray-500">
|
||||
Đang tải dữ liệu...
|
||||
</div>
|
||||
);
|
||||
if (!project)
|
||||
return (
|
||||
<div className="flex justify-center p-20 text-red-500">
|
||||
Không tìm thấy dự án
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen dark:bg-[#0d1117] text-gray-900 dark:text-[#c9d1d9] font-sans">
|
||||
<PageBreadcrumb pageTitle="Chi tiết dự án" />
|
||||
<div className="pt-8 border-b border-gray-200 dark:border-[#30363d] bg-gray-50 dark:bg-[#0d1117]">
|
||||
<div className="px-6">
|
||||
<div className="flex items-center gap-2 text-xl mb-6">
|
||||
<div className="w-8 h-8 shrink-0 flex items-center justify-center">
|
||||
{project.user?.avatar_url ? (
|
||||
<div className="relative w-8 h-8 rounded-full overflow-hidden border border-gray-200 dark:border-gray-800">
|
||||
<Image
|
||||
src={project.user.avatar_url}
|
||||
alt="avatar"
|
||||
fill
|
||||
className="object-cover rounded-full"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center border border-gray-300 dark:border-gray-600">
|
||||
<span className="text-[10px] font-bold text-gray-500 dark:text-gray-300 leading-none">
|
||||
{project.user?.display_name?.charAt(0)?.toUpperCase() ||
|
||||
"U"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="font-medium text-blue-600 dark:text-[#58a6ff] hover:underline cursor-pointer">
|
||||
{project.user?.display_name}
|
||||
</span>
|
||||
<span className="text-gray-400">/</span>
|
||||
<strong className="font-semibold text-blue-600 dark:text-[#58a6ff] hover:underline cursor-pointer">
|
||||
{project.title}
|
||||
</strong>
|
||||
<span className="ml-2 px-2.5 py-0.5 text-xs font-medium rounded-full border border-gray-200 dark:border-[#30363d] text-gray-500 dark:text-[#8b949e]">
|
||||
{project.project_status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
{[
|
||||
{
|
||||
id: "overview",
|
||||
label: "Overview",
|
||||
icon: "M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z",
|
||||
},
|
||||
{
|
||||
id: "members",
|
||||
label: `Members`,
|
||||
count: project.members?.length || 0,
|
||||
icon: "M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z",
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
label: "Settings",
|
||||
icon: "M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z",
|
||||
},
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as TabType)}
|
||||
className={`flex items-center gap-2 px-3 py-2.5 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === tab.id
|
||||
? "border-[#f78166] text-gray-900 dark:text-[#c9d1d9]"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700 dark:hover:text-[#8b949e]"
|
||||
}`}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 opacity-70"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d={tab.icon}
|
||||
/>
|
||||
</svg>
|
||||
{tab.label}
|
||||
{tab.count !== undefined && (
|
||||
<span className="ml-1 px-2 py-0.5 text-xs rounded-full bg-gray-200/50 dark:bg-[#21262d] text-gray-600 dark:text-[#c9d1d9]">
|
||||
{tab.count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-8">
|
||||
{activeTab === "overview" && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
<div className="md:col-span-3">
|
||||
<div className="border border-gray-200 dark:border-[#30363d] rounded-xl overflow-hidden ">
|
||||
<div className="bg-gray-50 dark:bg-[#161b22] px-5 py-3 border-b border-gray-200 dark:border-[#30363d] font-semibold text-sm text-gray-800 dark:text-[#c9d1d9]">
|
||||
About
|
||||
</div>
|
||||
<div className="p-6 bg-white dark:bg-[#0d1117] text-[15px] leading-relaxed text-gray-700 dark:text-[#8b949e]">
|
||||
{project.description || (
|
||||
<i className="text-gray-400">
|
||||
Không có mô tả cho dự án này.
|
||||
</i>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-1 space-y-6">
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm mb-4 border-b border-gray-200 dark:border-[#30363d] pb-2 text-gray-800 dark:text-[#c9d1d9]">
|
||||
Owner
|
||||
</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 shrink-0 flex items-center justify-center">
|
||||
{project.user?.avatar_url ? (
|
||||
<div className="relative w-8 h-8 rounded-full overflow-hidden border border-gray-200 dark:border-gray-800">
|
||||
<Image
|
||||
src={project.user.avatar_url}
|
||||
alt="avatar"
|
||||
fill
|
||||
className="object-cover rounded-full"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center border border-gray-300 dark:border-gray-600">
|
||||
<span className="text-[10px] font-bold text-gray-500 dark:text-gray-300 leading-none">
|
||||
{project.user?.display_name
|
||||
?.charAt(0)
|
||||
?.toUpperCase() || "U"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="overflow-hidden">
|
||||
<div className="font-semibold text-sm text-gray-800 dark:text-[#c9d1d9] truncate">
|
||||
{project.user?.display_name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-[#8b949e] truncate">
|
||||
{project.user?.email || "No email"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "members" && (
|
||||
<div className="max-w-4xl">
|
||||
<h2 className="text-2xl font-normal mb-6 pb-2 border-b border-gray-200 dark:border-[#30363d]">
|
||||
Manage access
|
||||
</h2>
|
||||
|
||||
<form
|
||||
onSubmit={handleAddMember}
|
||||
className="flex flex-col sm:flex-row gap-3 mb-8 p-5 border border-gray-200 dark:border-[#30363d] rounded-xl bg-gray-50 dark:bg-[#161b22] "
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="User ID..."
|
||||
value={newMember.user_id}
|
||||
onChange={(e) =>
|
||||
setNewMember({ ...newMember, user_id: e.target.value })
|
||||
}
|
||||
className="flex-1 px-3 py-2 text-sm rounded-md border border-gray-300 dark:border-[#30363d] bg-white dark:bg-[#0d1117] outline-none focus:ring-2 focus:ring-blue-500/30 focus:border-blue-500 transition-shadow"
|
||||
/>
|
||||
<div className="flex gap-3">
|
||||
<select
|
||||
value={newMember.role}
|
||||
onChange={(e) =>
|
||||
setNewMember({ ...newMember, role: e.target.value as any })
|
||||
}
|
||||
className="px-3 py-2 text-sm rounded-md border border-gray-300 dark:border-[#30363d] bg-white dark:bg-[#0d1117] outline-none focus:ring-2 focus:ring-blue-500/30 focus:border-blue-500 cursor-pointer"
|
||||
>
|
||||
<option value="EDITOR">Editor</option>
|
||||
<option value="VIEWER">Viewer</option>
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-5 py-2 text-sm font-medium text-white bg-[#238636] border border-transparent rounded-md hover:bg-[#2ea043] transition-colors "
|
||||
>
|
||||
Add member
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="border border-gray-200 dark:border-[#30363d] rounded-xl overflow-hidden ">
|
||||
<div className="divide-y divide-gray-200 dark:divide-[#30363d]">
|
||||
{project.members && project.members.length > 0 ? (
|
||||
project.members.map((member) => (
|
||||
<div
|
||||
key={member.user_id}
|
||||
className="flex items-center justify-between p-4 hover:bg-gray-50 dark:hover:bg-[#161b22]/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<img
|
||||
src={
|
||||
member.avatar_url ||
|
||||
"https://github.com/identicons/jasonlong.png"
|
||||
}
|
||||
alt={member.display_name}
|
||||
className="w-10 h-10 rounded-full border border-gray-200 dark:border-gray-700"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-semibold text-sm text-gray-800 dark:text-[#c9d1d9]">
|
||||
{member.display_name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-[#8b949e]">
|
||||
ID: {member.user_id}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
value={member.role}
|
||||
onChange={(e) =>
|
||||
handleUpdateRole(member.user_id, e.target.value)
|
||||
}
|
||||
className="text-sm px-3 py-1.5 rounded-md border border-gray-300 dark:border-[#30363d] bg-white dark:bg-[#21262d] outline-none hover:bg-gray-50 dark:hover:bg-[#30363d] cursor-pointer transition-colors"
|
||||
>
|
||||
<option value="EDITOR">Editor</option>
|
||||
<option value="VIEWER">Viewer</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => handleRemoveMember(member.user_id)}
|
||||
className="text-red-500 hover:text-red-600 p-2 rounded-md hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
||||
title="Remove member"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="p-8 text-center text-gray-500 dark:text-[#8b949e] text-sm italic">
|
||||
Chưa có thành viên nào.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "settings" && (
|
||||
<div className="max-w-3xl space-y-10">
|
||||
<section>
|
||||
<h2 className="text-2xl font-normal mb-4 pb-2 border-b border-gray-200 dark:border-[#30363d]">
|
||||
General
|
||||
</h2>
|
||||
<form onSubmit={handleUpdateInfo} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold mb-1.5 text-gray-800 dark:text-[#c9d1d9]">
|
||||
Project name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.title}
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, title: e.target.value })
|
||||
}
|
||||
className="w-full max-w-md px-3 py-2 text-sm rounded-md border border-gray-300 dark:border-[#30363d] bg-white dark:bg-[#0d1117] outline-none focus:ring-2 focus:ring-blue-500/30 focus:border-blue-500 transition-shadow"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold mb-1.5 text-gray-800 dark:text-[#c9d1d9]">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
rows={4}
|
||||
value={editForm.description}
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, description: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 text-sm rounded-md border border-gray-300 dark:border-[#30363d] bg-white dark:bg-[#0d1117] outline-none focus:ring-2 focus:ring-blue-500/30 focus:border-blue-500 transition-shadow"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold mb-1.5 text-gray-800 dark:text-[#c9d1d9]">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
value={editForm.status}
|
||||
onChange={(e) =>
|
||||
setEditForm({
|
||||
...editForm,
|
||||
status: e.target.value as any,
|
||||
})
|
||||
}
|
||||
className="w-48 px-3 py-2 text-sm rounded-md border border-gray-300 dark:border-[#30363d] bg-white dark:bg-[#0d1117] outline-none focus:ring-2 focus:ring-blue-500/30 focus:border-blue-500 cursor-pointer"
|
||||
>
|
||||
<option value="PUBLIC">Public</option>
|
||||
<option value="PRIVATE">Private</option>
|
||||
<option value="ARCHIVE">Archive</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-5 py-2 text-sm font-medium rounded-md bg-[#21262d] text-[#c9d1d9] border border-[#30363d] hover:bg-[#30363d] transition-colors "
|
||||
>
|
||||
Update settings
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-2xl font-normal text-red-500 mb-4 pb-2 border-b border-red-500/30">
|
||||
Danger Zone
|
||||
</h2>
|
||||
<div className="border border-red-500/30 rounded-xl overflow-hidden">
|
||||
<div className="p-5 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="font-semibold text-sm text-gray-800 dark:text-[#c9d1d9]">
|
||||
Transfer ownership
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-[#8b949e] mt-1">
|
||||
Transfer this project to another member in the project.
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
onSubmit={handleTransferOwnership}
|
||||
className="flex gap-2"
|
||||
>
|
||||
<select
|
||||
value={newOwnerId}
|
||||
onChange={(e) => setNewOwnerId(e.target.value)}
|
||||
className="w-full sm:w-56 px-3 py-2 text-sm rounded-md border border-gray-300 dark:border-[#30363d] bg-white dark:bg-[#0d1117] outline-none focus:ring-2 focus:ring-red-500/30 focus:border-red-500 cursor-pointer"
|
||||
required
|
||||
>
|
||||
<option value="" disabled>
|
||||
-- Thành viên --
|
||||
</option>
|
||||
{project.members && project.members.length > 0 ? (
|
||||
project.members.map((member) => (
|
||||
<option key={member.user_id} value={member.user_id}>
|
||||
{member.display_name}
|
||||
</option>
|
||||
))
|
||||
) : (
|
||||
<option value="" disabled>
|
||||
Chưa có thành viên nào
|
||||
</option>
|
||||
)}
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={
|
||||
!newOwnerId ||
|
||||
!project.members ||
|
||||
project.members.length === 0
|
||||
}
|
||||
className="shrink-0 px-4 py-2 text-sm font-medium text-red-500 bg-transparent border border-red-500/50 rounded-md hover:bg-red-500 hover:text-white dark:hover:bg-red-900/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-red-500 dark:disabled:hover:bg-transparent"
|
||||
>
|
||||
Transfer
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div className="p-5 flex flex-col sm:flex-row sm:items-center justify-between gap-4 hover:bg-red-50/30 dark:hover:bg-red-900/10 transition-colors">
|
||||
<div>
|
||||
<div className="font-semibold text-sm text-gray-800 dark:text-[#c9d1d9]">
|
||||
Delete this project
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-[#8b949e] mt-1">
|
||||
Once you delete a project, there is no going back. Please
|
||||
be certain.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDeleteProject}
|
||||
className="shrink-0 px-4 py-2 text-sm font-medium text-red-500 bg-transparent border border-red-500/50 rounded-md hover:bg-red-500 hover:text-white dark:hover:bg-red-900/30 transition-colors"
|
||||
>
|
||||
Delete project
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
492
src/app/(admin)/(others-pages)/submissions/page.tsx
Normal file
492
src/app/(admin)/(others-pages)/submissions/page.tsx
Normal file
@@ -0,0 +1,492 @@
|
||||
"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 { toast } from "sonner";
|
||||
import { useRouter } from "next/navigation";
|
||||
import CustomDateRangePicker from "@/components/common/CustomDateRangePicker";
|
||||
import SubmissionsTable, {
|
||||
SubmissionItem,
|
||||
SubmissionSortColumn,
|
||||
} from "@/components/tables/SubmissionsTable";
|
||||
import {
|
||||
getSubmissionPayload,
|
||||
updateSubmissionPayload,
|
||||
} from "@/interface/submission";
|
||||
import { apiGetSubmission, updateProject } from "@/service/submisisonService";
|
||||
import { LIMIT_ITEM_TABLE } from "../../../../../constant";
|
||||
|
||||
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`;
|
||||
};
|
||||
|
||||
interface SubmissionsResponseData {
|
||||
data: SubmissionItem[];
|
||||
pagination: {
|
||||
current_page: number;
|
||||
page_size: number;
|
||||
total_records: number;
|
||||
total_pages: number;
|
||||
};
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const router = useRouter();
|
||||
const [page, setPage] = useState<number>(1);
|
||||
const [limitInput, setLimitInput] = useState<string>(
|
||||
LIMIT_ITEM_TABLE.toString(),
|
||||
);
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState<string>("");
|
||||
const [statusFilter, setStatusFilter] = useState<string>("");
|
||||
const [userIdsFilter, setUserIdsFilter] = useState<string>("");
|
||||
const [fromDate, setFromDate] = useState<string>("");
|
||||
const [fromTime, setFromTime] = useState<string>("");
|
||||
const [toDate, setToDate] = useState<string>("");
|
||||
const [toTime, setToTime] = useState<string>("");
|
||||
|
||||
const [resetKey, setResetKey] = useState<number>(0);
|
||||
|
||||
const [debouncedParams, setDebouncedParams] = useState({
|
||||
search: "",
|
||||
limit: LIMIT_ITEM_TABLE,
|
||||
statuses: "",
|
||||
userIds: "",
|
||||
fromDate: "",
|
||||
fromTime: "",
|
||||
toDate: "",
|
||||
toTime: "",
|
||||
});
|
||||
|
||||
const [tableData, setTableData] = useState<SubmissionsResponseData | null>(
|
||||
null,
|
||||
);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
|
||||
const [sortBy, setSortBy] = useState<SubmissionSortColumn>("created_at");
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [selectedItem, setSelectedItem] = useState<SubmissionItem | null>(null);
|
||||
const [updatePayload, setUpdatePayload] = useState<updateSubmissionPayload>({
|
||||
review_note: "",
|
||||
status: "APPROVED",
|
||||
});
|
||||
|
||||
const handleReset = () => {
|
||||
setSearchTerm("");
|
||||
setStatusFilter("");
|
||||
setUserIdsFilter("");
|
||||
setLimitInput(LIMIT_ITEM_TABLE.toString());
|
||||
setFromDate("");
|
||||
setFromTime("");
|
||||
setToDate("");
|
||||
setToTime("");
|
||||
setPage(1);
|
||||
setResetKey((prev) => prev + 1);
|
||||
};
|
||||
|
||||
const handleDateFilterChange = (
|
||||
startD: string,
|
||||
endD: string,
|
||||
startT: string,
|
||||
endT: string,
|
||||
) => {
|
||||
setFromDate(startD);
|
||||
setToDate(endD);
|
||||
setFromTime(startT);
|
||||
setToTime(endT);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedParams({
|
||||
search: searchTerm,
|
||||
limit: parseInt(limitInput) || LIMIT_ITEM_TABLE,
|
||||
statuses: statusFilter,
|
||||
userIds: userIdsFilter,
|
||||
fromDate,
|
||||
fromTime,
|
||||
toDate,
|
||||
toTime,
|
||||
});
|
||||
setPage(1);
|
||||
}, 100);
|
||||
return () => clearTimeout(handler);
|
||||
}, [
|
||||
searchTerm,
|
||||
limitInput,
|
||||
statusFilter,
|
||||
userIdsFilter,
|
||||
fromDate,
|
||||
fromTime,
|
||||
toDate,
|
||||
toTime,
|
||||
]);
|
||||
|
||||
const fetchSubmissionsData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const payload: Partial<getSubmissionPayload> = {
|
||||
page: page,
|
||||
limit: debouncedParams.limit,
|
||||
search: debouncedParams.search || undefined,
|
||||
sort: sortBy,
|
||||
order: sortOrder,
|
||||
statuses: debouncedParams.statuses
|
||||
? [debouncedParams.statuses]
|
||||
: undefined,
|
||||
user_ids: debouncedParams.userIds
|
||||
? debouncedParams.userIds
|
||||
.split(",")
|
||||
.map((id) => id.trim())
|
||||
.filter(Boolean)
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const createdFrom = formatDateTimeToISO(
|
||||
debouncedParams.fromDate,
|
||||
debouncedParams.fromTime,
|
||||
);
|
||||
if (createdFrom) payload.created_from = createdFrom;
|
||||
|
||||
const createdTo = formatDateTimeToISO(
|
||||
debouncedParams.toDate,
|
||||
debouncedParams.toTime,
|
||||
true,
|
||||
);
|
||||
if (createdTo) payload.created_to = createdTo;
|
||||
|
||||
const response = await apiGetSubmission(payload as getSubmissionPayload);
|
||||
|
||||
if (response?.status && response?.data) {
|
||||
setTableData(response.data);
|
||||
} else {
|
||||
setTableData(null);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error("Lỗi lấy danh sách bài nộp");
|
||||
console.error("Lỗi lấy danh sách bài nộp:", err);
|
||||
setTableData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, debouncedParams, sortBy, sortOrder]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSubmissionsData();
|
||||
}, [fetchSubmissionsData]);
|
||||
|
||||
const handleSort = (column: SubmissionSortColumn) => {
|
||||
setPage(1);
|
||||
if (sortBy === column) {
|
||||
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
|
||||
} else {
|
||||
setSortBy(column);
|
||||
setSortOrder("desc");
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewDetails = (id: string) => {
|
||||
router.push(`/submissions/${id}`);
|
||||
};
|
||||
|
||||
const handleOpenActionModal = (item: SubmissionItem) => {
|
||||
setSelectedItem(item);
|
||||
setUpdatePayload({
|
||||
status: item.status,
|
||||
review_note: "",
|
||||
});
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleUpdateSubmit = async () => {
|
||||
if (!selectedItem) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const response = await updateProject(selectedItem.id, updatePayload);
|
||||
|
||||
if (response?.status) {
|
||||
toast.success("Cập nhật thành công!");
|
||||
setIsModalOpen(false);
|
||||
fetchSubmissionsData();
|
||||
} else {
|
||||
if (response?.errors && Array.isArray(response.errors)) {
|
||||
toast.error(response.errors.message || "Lỗi dữ liệu không hợp lệ");
|
||||
} else {
|
||||
toast.error(response?.message || "Cập nhật thất bại");
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
const apiData = error.response?.data;
|
||||
if (apiData?.errors && Array.isArray(apiData.errors)) {
|
||||
apiData.errors.forEach((err: any) => {
|
||||
if (err.message === "review_note is too short (min 10)") {
|
||||
toast.error("Nội dung phải có ít nhất 10 ký tự");
|
||||
} else {
|
||||
toast.error(err.message || "Dữ liệu không hợp lệ");
|
||||
}
|
||||
});
|
||||
} else {
|
||||
toast.error("Lỗi hệ thống khi cập nhật");
|
||||
}
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const pagination = tableData?.pagination;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageBreadcrumb pageTitle="Quản lý Submissions" />
|
||||
|
||||
<div className="space-y-6">
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
}
|
||||
>
|
||||
<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
|
||||
type="text"
|
||||
placeholder="Tên dự án, 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">
|
||||
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ả trạng thái</option>
|
||||
<option value="PENDING">PENDING</option>
|
||||
<option value="APPROVED">APPROVED</option>
|
||||
<option value="REJECTED">REJECTED</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block mb-2 text-sm font-medium">
|
||||
ID người dùng
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="IDs (cách nhau bởi dấu phẩy)"
|
||||
value={userIdsFilter}
|
||||
onChange={(e) => setUserIdsFilter(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">
|
||||
Thời gian
|
||||
</label>
|
||||
<CustomDateRangePicker
|
||||
key={resetKey}
|
||||
onFilterChange={handleDateFilterChange}
|
||||
/>
|
||||
</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">
|
||||
<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>
|
||||
)}
|
||||
|
||||
<SubmissionsTable
|
||||
data={tableData?.data || []}
|
||||
onSort={handleSort}
|
||||
sortBy={sortBy}
|
||||
sortOrder={sortOrder}
|
||||
onViewDetails={handleViewDetails}
|
||||
onActionClick={handleOpenActionModal}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-6">
|
||||
<p className="text-sm text-gray-500">
|
||||
Hiển thị {pagination?.total_records || 0} bản ghi
|
||||
</p>
|
||||
|
||||
{pagination && pagination.total_pages > 1 && (
|
||||
<Pagination
|
||||
currentPage={pagination.current_page}
|
||||
totalPages={pagination.total_pages}
|
||||
onPageChange={(newPage) => setPage(newPage)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ComponentCard>
|
||||
</div>
|
||||
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-[99] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-xl w-full max-w-md overflow-hidden animate-in fade-in zoom-in duration-200">
|
||||
<div className="px-6 py-4 border-b border-gray-100 dark:border-gray-800 flex justify-between items-center">
|
||||
<h3 className="text-lg font-semibold text-gray-800 dark:text-gray-100">
|
||||
Đánh giá yêu cầu
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Trạng thái <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={updatePayload.status}
|
||||
onChange={(e) =>
|
||||
setUpdatePayload({
|
||||
...updatePayload,
|
||||
status: e.target.value,
|
||||
})
|
||||
}
|
||||
className="w-full px-4 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-gray-100 outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent transition-all"
|
||||
>
|
||||
<option value="APPROVED">APPROVED</option>
|
||||
<option value="REJECTED">REJECTED</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Ghi chú đánh giá (Review Note)
|
||||
</label>
|
||||
<textarea
|
||||
value={updatePayload.review_note}
|
||||
onChange={(e) =>
|
||||
setUpdatePayload({
|
||||
...updatePayload,
|
||||
review_note: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="Nhập ghi chú phản hồi..."
|
||||
rows={4}
|
||||
className="w-full px-4 py-3 border border-gray-200 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-gray-100 outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent transition-all resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 border-t border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-800/50 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="px-5 py-2 text-sm font-medium text-gray-600 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-700 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
Hủy
|
||||
</button>
|
||||
<button
|
||||
onClick={handleUpdateSubmit}
|
||||
disabled={isSubmitting}
|
||||
className="flex items-center px-5 py-2 text-sm font-medium text-white bg-brand-500 rounded-lg hover:bg-brand-600 focus:ring-4 focus:ring-brand-500/20 transition-all disabled:opacity-70 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<svg
|
||||
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
Đang lưu...
|
||||
</>
|
||||
) : (
|
||||
"Lưu thay đổi"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,10 @@ import { useSidebar } from "@/context/SidebarContext";
|
||||
import AppHeader from "@/layout/AppHeader";
|
||||
import AppSidebar from "@/layout/AppSidebar";
|
||||
import Backdrop from "@/layout/Backdrop";
|
||||
import React from "react";
|
||||
import { apiGetCurrentUser } from "@/service/auth";
|
||||
import { setUserData } from "@/store/features/userSlice";
|
||||
import React, { useEffect } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
|
||||
export default function AdminLayout({
|
||||
children,
|
||||
@@ -12,13 +15,27 @@ export default function AdminLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { isExpanded, isHovered, isMobileOpen } = useSidebar();
|
||||
const dispatch = useDispatch()
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
const userData = await apiGetCurrentUser();
|
||||
dispatch(setUserData(userData.data));
|
||||
} catch (err) {
|
||||
console.error("Lỗi:", err);
|
||||
}
|
||||
};
|
||||
fetchUser();
|
||||
}, [])
|
||||
|
||||
|
||||
// Dynamic class for main content margin based on sidebar state
|
||||
const mainContentMargin = isMobileOpen
|
||||
? "ml-0"
|
||||
: isExpanded || isHovered
|
||||
? "lg:ml-[290px]"
|
||||
: "lg:ml-[90px]";
|
||||
? "lg:ml-[290px]"
|
||||
: "lg:ml-[90px]";
|
||||
|
||||
return (
|
||||
<div className="min-h-screen xl:flex">
|
||||
@@ -27,7 +44,7 @@ export default function AdminLayout({
|
||||
<Backdrop />
|
||||
{/* Main Content Area */}
|
||||
<div
|
||||
className={`flex-1 transition-all duration-300 ease-in-out ${mainContentMargin}`}
|
||||
className={`flex-1 min-w-0 transition-all duration-300 ease-in-out ${mainContentMargin}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<AppHeader />
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import type { Metadata } from "next";
|
||||
import { EcommerceMetrics } from "@/components/ecommerce/EcommerceMetrics";
|
||||
import React from "react";
|
||||
import MonthlyTarget from "@/components/ecommerce/MonthlyTarget";
|
||||
import MonthlySalesChart from "@/components/ecommerce/MonthlySalesChart";
|
||||
import DailyNewChart from "@/components/ecommerce/DailyNewChart";
|
||||
import StatisticsChart from "@/components/ecommerce/StatisticsChart";
|
||||
import RecentOrders from "@/components/ecommerce/RecentOrders";
|
||||
import DemographicCard from "@/components/ecommerce/DemographicCard";
|
||||
import DetailedStatsTable from "@/components/ecommerce/DetailedStatsTable";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title:
|
||||
"Admin Dashboard",
|
||||
title: "Admin Dashboard",
|
||||
description: "This is Dashboard Home for History Web",
|
||||
};
|
||||
|
||||
@@ -18,25 +15,16 @@ export default function Ecommerce() {
|
||||
<div className="grid grid-cols-12 gap-4 md:gap-6">
|
||||
<div className="col-span-12 space-y-6 xl:col-span-7">
|
||||
<EcommerceMetrics />
|
||||
|
||||
<MonthlySalesChart />
|
||||
<DailyNewChart />
|
||||
</div>
|
||||
|
||||
<div className="col-span-12 xl:col-span-5">
|
||||
<MonthlyTarget />
|
||||
<DetailedStatsTable />
|
||||
</div>
|
||||
|
||||
<div className="col-span-12">
|
||||
<StatisticsChart />
|
||||
</div>
|
||||
|
||||
<div className="col-span-12 xl:col-span-5">
|
||||
<DemographicCard />
|
||||
</div>
|
||||
|
||||
<div className="col-span-12 xl:col-span-7">
|
||||
<RecentOrders />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import GridShape from "@/components/common/GridShape";
|
||||
import { Metadata } from "next";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Access Denied - 403 | Admin Dashboard",
|
||||
description:
|
||||
"This is Access Denied 403 page - you do not have permission to access this resource",
|
||||
};
|
||||
|
||||
export default function Error403() {
|
||||
return (
|
||||
<div className="relative flex flex-col items-center justify-center min-h-screen p-6 overflow-hidden z-1">
|
||||
<GridShape />
|
||||
<div className="mx-auto w-full max-w-[242px] text-center sm:max-w-[472px]">
|
||||
<h1 className="mb-8 font-bold text-gray-800 text-title-md dark:text-white/90 xl:text-title-2xl">
|
||||
403 - ACCESS DENIED
|
||||
</h1>
|
||||
|
||||
<Image
|
||||
src="/images/error/404.svg"
|
||||
alt="403"
|
||||
className="dark:hidden"
|
||||
width={472}
|
||||
height={152}
|
||||
/>
|
||||
<Image
|
||||
src="/images/error/404-dark.svg"
|
||||
alt="403"
|
||||
className="hidden dark:block"
|
||||
width={472}
|
||||
height={152}
|
||||
/>
|
||||
|
||||
<p className="mt-10 mb-6 text-base text-gray-700 dark:text-gray-400 sm:text-lg">
|
||||
You do not have permission to access this resource. Please contact your administrator if you believe this is a mistake.
|
||||
</p>
|
||||
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center justify-center rounded-lg border border-gray-300 bg-white px-5 py-3.5 text-sm font-medium text-gray-700 shadow-theme-xs hover:bg-gray-50 hover:text-gray-800 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.03] dark:hover:text-gray-200"
|
||||
>
|
||||
Back to Home Page
|
||||
</Link>
|
||||
</div>
|
||||
<p className="absolute text-sm text-center text-gray-500 -translate-x-1/2 bottom-6 left-1/2 dark:text-gray-400">
|
||||
© {new Date().getFullYear()} - Admin Dashboard
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -737,25 +737,48 @@ span.flatpickr-weekday,
|
||||
box-shadow: 0 1px 3px 0 rgba(16, 24, 40, 0.1),
|
||||
0 1px 2px 0 rgba(16, 24, 40, 0.06);
|
||||
opacity: 0.8;
|
||||
cursor: grabbing; /* Changes the cursor to indicate dragging */
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
html {
|
||||
scrollbar-gutter: stable;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
html {
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
-webkit-appearance: none;
|
||||
width: 10px;
|
||||
width: 10px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--color-gray-50);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
border-radius: 5px;
|
||||
background-color: rgba(0, 0, 0, .5);
|
||||
box-shadow: 0 0 1px rgba(255, 255, 255, .5);
|
||||
border-radius: 10px;
|
||||
background-color: var(--color-gray-300);
|
||||
border: 2px solid var(--color-gray-50);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--color-gray-400);
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-track {
|
||||
background: var(--color-gray-950);
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-thumb {
|
||||
background-color: var(--color-gray-700);
|
||||
border: 2px solid var(--color-gray-950);
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--color-gray-600);
|
||||
}
|
||||
|
||||
.ql-editor pre.ql-syntax {
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={`${inter.className} dark:bg-gray-900`}>
|
||||
<body className={`${inter.className} dark:bg-gray-900`} suppressHydrationWarning>
|
||||
<StoreProvider>
|
||||
<ThemeProvider>
|
||||
<SidebarProvider>{children} <Toaster closeButton richColors position="top-right" /> </SidebarProvider>
|
||||
|
||||
@@ -7,7 +7,7 @@ export default function GetUser() {
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { ReactNode, useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useAuth } from "@/hooks/useAuth"
|
||||
import { UserRole } from "@/config/routes.config"
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: ReactNode
|
||||
requiredRoles?: UserRole[]
|
||||
fallback?: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Component để protect routes dựa trên role
|
||||
* Sử dụng ở client-side trong layouts hoặc pages
|
||||
*/
|
||||
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
|
||||
children,
|
||||
requiredRoles = [],
|
||||
fallback,
|
||||
}) => {
|
||||
const router = useRouter()
|
||||
const { isAuthenticated, userRoles, hasAnyRole } = useAuth()
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
router.replace("/signin")
|
||||
}
|
||||
}, [isAuthenticated, router])
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (requiredRoles.length > 0 && !hasAnyRole(requiredRoles)) {
|
||||
if (fallback) {
|
||||
return <>{fallback}</>
|
||||
}
|
||||
router.replace("/error-403")
|
||||
return null
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper để render UI dựa trên role
|
||||
*/
|
||||
export const RoleGate: React.FC<{
|
||||
requiredRoles: UserRole[]
|
||||
children: ReactNode
|
||||
fallback?: ReactNode
|
||||
}> = ({ requiredRoles, children, fallback }) => {
|
||||
const { hasAnyRole } = useAuth()
|
||||
|
||||
if (!hasAnyRole(requiredRoles)) {
|
||||
return <>{fallback || null}</>
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import { API, HOME_URL } from "../../../api";
|
||||
import { setUserData } from "@/store/features/userSlice";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { saveUserToCookie } from "@/lib/cookieStorage";
|
||||
|
||||
export default function SignInForm() {
|
||||
const router = useRouter();
|
||||
@@ -64,19 +63,17 @@ export default function SignInForm() {
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
|
||||
const res = await apiSignIn(formData);
|
||||
// console.log("API Sign In Response:", res);
|
||||
|
||||
if (res.status === true) {
|
||||
toast.success("Đăng nhập thành công!");
|
||||
const data = await apiGetCurrentUser();
|
||||
|
||||
|
||||
// console.log("Current User Data:", data);
|
||||
if (data?.data) {
|
||||
// Lưu user data vào Redux và cookies
|
||||
dispatch(setUserData(data.data));
|
||||
saveUserToCookie(data.data);
|
||||
router.push("/");
|
||||
}
|
||||
} else {
|
||||
@@ -92,7 +89,7 @@ export default function SignInForm() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 lg:w-1/2 w-full">
|
||||
<div className="w-full max-w-md sm:pt-10 mx-auto mb-5">
|
||||
{/* <div className="w-full max-w-md sm:pt-10 mx-auto mb-5">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
|
||||
@@ -100,7 +97,7 @@ export default function SignInForm() {
|
||||
<ChevronLeftIcon />
|
||||
Back to dashboard
|
||||
</Link>
|
||||
</div>
|
||||
</div> */}
|
||||
<div className="flex flex-col justify-center flex-1 w-full max-w-md mx-auto">
|
||||
<div>
|
||||
<div className="mb-5 sm:mb-8">
|
||||
@@ -112,7 +109,7 @@ export default function SignInForm() {
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-5">
|
||||
<div className="grid grid-cols-1 gap-3 sm:gap-5">
|
||||
<button
|
||||
onClick={() => {
|
||||
const redirectUrl = HOME_URL;
|
||||
@@ -146,19 +143,6 @@ export default function SignInForm() {
|
||||
</svg>
|
||||
Sign in with Google
|
||||
</button>
|
||||
{/* <button className="inline-flex items-center justify-center gap-3 py-3 text-sm font-normal text-gray-700 transition-colors bg-gray-100 rounded-lg px-7 hover:bg-gray-200 hover:text-gray-800 dark:bg-white/5 dark:text-white/90 dark:hover:bg-white/10">
|
||||
<svg
|
||||
width="21"
|
||||
className="fill-current"
|
||||
height="20"
|
||||
viewBox="0 0 21 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M15.6705 1.875H18.4272L12.4047 8.75833L19.4897 18.125H13.9422L9.59717 12.4442L4.62554 18.125H1.86721L8.30887 10.7625L1.51221 1.875H7.20054L11.128 7.0675L15.6705 1.875ZM14.703 16.475H16.2305L6.37054 3.43833H4.73137L14.703 16.475Z" />
|
||||
</svg>
|
||||
Sign in with X
|
||||
</button> */}
|
||||
</div>
|
||||
<div className="relative py-3 sm:py-5">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
@@ -182,6 +166,7 @@ export default function SignInForm() {
|
||||
type="email"
|
||||
onChange={handleChange}
|
||||
defaultValue={formData.email}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -198,6 +183,7 @@ export default function SignInForm() {
|
||||
placeholder="Min. 8 characters"
|
||||
onChange={handleChange}
|
||||
defaultValue={formData.password}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<span
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
@@ -208,7 +194,6 @@ export default function SignInForm() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hiển thị thông báo lỗi nếu có */}
|
||||
{errorMsg && (
|
||||
<p className="text-sm text-red-500 font-medium">{errorMsg}</p>
|
||||
)}
|
||||
|
||||
@@ -9,11 +9,15 @@ import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { API, HOME_URL } from "../../../api";
|
||||
import Swal from "sweetalert2";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
|
||||
export default function SignUpForm() {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isChecked, setIsChecked] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const [step, setStep] = useState(1);
|
||||
const [formData, setFormData] = useState({
|
||||
fname: "",
|
||||
@@ -103,9 +107,15 @@ export default function SignUpForm() {
|
||||
};
|
||||
|
||||
const signupRes = await apiSignUp(signupPayload);
|
||||
Swal.fire("Đăng ký thành công! Đang chuyển hướng về trang đăng nhập!");
|
||||
|
||||
window.location.href = "/signin";
|
||||
await Swal.fire({
|
||||
title: "Tạo tài khoản thành công!. Quay về trang đăng nhập để tiếp tục",
|
||||
icon: "success",
|
||||
timer: 1500,
|
||||
showConfirmButton: false,
|
||||
});
|
||||
router.push("/signin");
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
@@ -121,11 +131,11 @@ export default function SignUpForm() {
|
||||
<div className="flex flex-col flex-1 lg:w-1/2 w-full overflow-y-auto no-scrollbar">
|
||||
<div className="w-full max-w-md sm:pt-10 mx-auto mb-5">
|
||||
<Link
|
||||
href="/"
|
||||
href="/signin"
|
||||
className="inline-flex items-center text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
Back to dashboard
|
||||
Back to Signin
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -150,7 +160,7 @@ export default function SignUpForm() {
|
||||
|
||||
{step === 1 && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-5">
|
||||
<div className="grid grid-cols-1 gap-3 sm:gap-5">
|
||||
<button
|
||||
onClick={() => {
|
||||
const redirectUrl = HOME_URL;
|
||||
@@ -184,9 +194,6 @@ export default function SignUpForm() {
|
||||
</svg>
|
||||
Sign up with Google
|
||||
</button>
|
||||
{/* <button className="inline-flex items-center justify-center gap-3 py-3 text-sm font-normal text-gray-700 transition-colors bg-gray-100 rounded-lg px-7 hover:bg-gray-200 hover:text-gray-800 dark:bg-white/5 dark:text-white/90 dark:hover:bg-white/10">
|
||||
Sign up with X
|
||||
</button> */}
|
||||
</div>
|
||||
<div className="relative py-3 sm:py-5">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
@@ -277,7 +284,7 @@ export default function SignUpForm() {
|
||||
<div
|
||||
className={
|
||||
formData.password.length > 0 &&
|
||||
!isValidPassword(formData.password)
|
||||
!isValidPassword(formData.password)
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: ""
|
||||
}
|
||||
|
||||
328
src/components/common/CustomDateRangePicker.tsx
Normal file
328
src/components/common/CustomDateRangePicker.tsx
Normal file
@@ -0,0 +1,328 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useMemo } from "react";
|
||||
|
||||
interface CustomDateRangePickerProps {
|
||||
onFilterChange: (
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
startTime: string,
|
||||
endTime: string
|
||||
) => void;
|
||||
}
|
||||
|
||||
const DAYS_OF_WEEK = ["Th2", "Th3", "Th4", "Th5", "Th6", "Th7", "CN"];
|
||||
const MONTHS = [
|
||||
"Tháng 1", "Tháng 2", "Tháng 3", "Tháng 4", "Tháng 5", "Tháng 6",
|
||||
"Tháng 7", "Tháng 8", "Tháng 9", "Tháng 10", "Tháng 11", "Tháng 12"
|
||||
];
|
||||
|
||||
const getStartOfDay = (date: Date) => new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
const formatDateToString = (date: Date | null) => {
|
||||
if (!date) return "";
|
||||
const d = date.getDate().toString().padStart(2, "0");
|
||||
const m = (date.getMonth() + 1).toString().padStart(2, "0");
|
||||
const y = date.getFullYear();
|
||||
return `${y}-${m}-${d}`;
|
||||
};
|
||||
|
||||
export default function CustomDateRangePicker({ onFilterChange }: CustomDateRangePickerProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [currentMonthDate, setCurrentMonthDate] = useState(getStartOfDay(new Date()));
|
||||
|
||||
// LOGIC MỚI: Tách biệt rõ ràng state Từ ngày và Đến ngày
|
||||
const [startTimestamp, setStartTimestamp] = useState<number | null>(null);
|
||||
const [endTimestamp, setEndTimestamp] = useState<number | null>(null);
|
||||
|
||||
// History dùng để theo dõi thứ tự click, phục vụ "Cửa sổ trượt" giữ 2 ngày gần nhất
|
||||
const [history, setHistory] = useState<number[]>([]);
|
||||
|
||||
const [startTime, setStartTime] = useState<string>("00:00");
|
||||
const [endTime, setEndTime] = useState<string>("23:59");
|
||||
|
||||
const startDate = startTimestamp ? new Date(startTimestamp) : null;
|
||||
const endDate = endTimestamp ? new Date(endTimestamp) : null;
|
||||
|
||||
// Click outside to close
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Update parent
|
||||
useEffect(() => {
|
||||
const sDate = startDate ? formatDateToString(startDate) : "";
|
||||
const eDate = endDate ? formatDateToString(endDate) : "";
|
||||
const sTime = startDate ? startTime : "";
|
||||
const eTime = endDate ? endTime : "";
|
||||
onFilterChange(sDate, eDate, sTime, eTime);
|
||||
}, [startTimestamp, endTimestamp, startTime, endTime]);
|
||||
|
||||
const { daysInMonth, emptyDays } = useMemo(() => {
|
||||
const year = currentMonthDate.getFullYear();
|
||||
const month = currentMonthDate.getMonth();
|
||||
const days = new Date(year, month + 1, 0).getDate();
|
||||
let firstDayIndex = new Date(year, month, 1).getDay() - 1;
|
||||
if (firstDayIndex < 0) firstDayIndex = 6;
|
||||
return { daysInMonth: days, emptyDays: firstDayIndex };
|
||||
}, [currentMonthDate]);
|
||||
|
||||
const handleDayClick = (day: number) => {
|
||||
const clickedTime = new Date(currentMonthDate.getFullYear(), currentMonthDate.getMonth(), day).getTime();
|
||||
|
||||
if (clickedTime === startTimestamp) {
|
||||
setStartTimestamp(null);
|
||||
setHistory(prev => prev.filter(t => t !== clickedTime));
|
||||
return;
|
||||
}
|
||||
if (clickedTime === endTimestamp) {
|
||||
setEndTimestamp(null);
|
||||
setHistory(prev => prev.filter(t => t !== clickedTime));
|
||||
return;
|
||||
}
|
||||
|
||||
let newStart = startTimestamp;
|
||||
let newEnd = endTimestamp;
|
||||
let newHistory = [...history];
|
||||
|
||||
if (newStart && newEnd) {
|
||||
const oldest = newHistory[0];
|
||||
if (oldest === newStart) newStart = null;
|
||||
else if (oldest === newEnd) newEnd = null;
|
||||
newHistory.shift();
|
||||
}
|
||||
|
||||
if (!newStart && !newEnd) {
|
||||
newEnd = clickedTime;
|
||||
}
|
||||
else if (!newStart && newEnd) {
|
||||
if (clickedTime < newEnd) {
|
||||
newStart = clickedTime;
|
||||
} else {
|
||||
newStart = newEnd;
|
||||
newEnd = clickedTime;
|
||||
}
|
||||
}
|
||||
else if (newStart && !newEnd) {
|
||||
if (clickedTime > newStart) {
|
||||
newEnd = clickedTime;
|
||||
} else {
|
||||
newEnd = newStart;
|
||||
newStart = clickedTime;
|
||||
}
|
||||
}
|
||||
|
||||
newHistory.push(clickedTime);
|
||||
setStartTimestamp(newStart);
|
||||
setEndTimestamp(newEnd);
|
||||
setHistory(newHistory);
|
||||
};
|
||||
|
||||
const handleManualDateChange = (val: string, target: 'start' | 'end') => {
|
||||
const newTime = val ? new Date(val).getTime() : null;
|
||||
|
||||
if (target === 'start') {
|
||||
const oldTime = startTimestamp;
|
||||
setStartTimestamp(newTime);
|
||||
setHistory(prev => {
|
||||
let h = prev;
|
||||
if (oldTime) h = h.filter(t => t !== oldTime);
|
||||
if (newTime) h = [...h, newTime];
|
||||
return h.slice(-2);
|
||||
});
|
||||
} else {
|
||||
const oldTime = endTimestamp;
|
||||
setEndTimestamp(newTime);
|
||||
setHistory(prev => {
|
||||
let h = prev;
|
||||
if (oldTime) h = h.filter(t => t !== oldTime);
|
||||
if (newTime) h = [...h, newTime];
|
||||
return h.slice(-2);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const nextMonth = () => setCurrentMonthDate(new Date(currentMonthDate.getFullYear(), currentMonthDate.getMonth() + 1, 1));
|
||||
const prevMonth = () => setCurrentMonthDate(new Date(currentMonthDate.getFullYear(), currentMonthDate.getMonth() - 1, 1));
|
||||
const nextYear = () => setCurrentMonthDate(new Date(currentMonthDate.getFullYear() + 1, currentMonthDate.getMonth(), 1));
|
||||
const prevYear = () => setCurrentMonthDate(new Date(currentMonthDate.getFullYear() - 1, currentMonthDate.getMonth(), 1));
|
||||
|
||||
const renderDateBox = (date: Date | null) => {
|
||||
if (!date) return "mm/dd/yyyy";
|
||||
const d = date.getDate().toString().padStart(2, "0");
|
||||
const m = (date.getMonth() + 1).toString().padStart(2, "0");
|
||||
const y = date.getFullYear();
|
||||
return `${m}/${d}/${y}`;
|
||||
};
|
||||
|
||||
const isSelected = (day: number) => {
|
||||
const d = new Date(currentMonthDate.getFullYear(), currentMonthDate.getMonth(), day).getTime();
|
||||
return d === startTimestamp || d === endTimestamp;
|
||||
};
|
||||
|
||||
const isInRange = (day: number) => {
|
||||
const d = new Date(currentMonthDate.getFullYear(), currentMonthDate.getMonth(), day).getTime();
|
||||
if (startTimestamp && endTimestamp && d > startTimestamp && d < endTimestamp) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative w-full" ref={containerRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center justify-between w-full px-4 py-2 text-sm border rounded-lg bg-white dark:bg-gray-800 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-500 transition-all outline-none text-gray-700 dark:text-gray-200"
|
||||
>
|
||||
<span>
|
||||
{(!startDate && !endDate)
|
||||
? "mm/dd/yyyy - mm/dd/yyyy"
|
||||
: `${renderDateBox(startDate)} - ${renderDateBox(endDate)}`
|
||||
}
|
||||
</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5 text-gray-500" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute top-12 left-0 z-50 w-[320px] p-4 bg-[#22272e] border border-gray-700 rounded-xl shadow-2xl text-gray-200">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="font-semibold mx-auto">Ngày</span>
|
||||
<button onClick={() => setIsOpen(false)} className="absolute right-4 text-gray-400 hover:text-white">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mb-4 text-sm">
|
||||
<div className="flex gap-2">
|
||||
<button onClick={prevYear} className="p-1 hover:bg-gray-700 rounded text-gray-400 hover:text-white">«</button>
|
||||
<button onClick={prevMonth} className="p-1 hover:bg-gray-700 rounded text-gray-400 hover:text-white">‹</button>
|
||||
</div>
|
||||
<span className="font-medium">
|
||||
{MONTHS[currentMonthDate.getMonth()]} {currentMonthDate.getFullYear()}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={nextMonth} className="p-1 hover:bg-gray-700 rounded text-gray-400 hover:text-white">›</button>
|
||||
<button onClick={nextYear} className="p-1 hover:bg-gray-700 rounded text-gray-400 hover:text-white">»</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-y-2 mb-6 text-center text-sm">
|
||||
{DAYS_OF_WEEK.map(day => (
|
||||
<div key={day} className="font-medium text-gray-400">{day}</div>
|
||||
))}
|
||||
|
||||
{Array.from({ length: emptyDays }).map((_, i) => (
|
||||
<div key={`empty-${i}`} className="text-gray-600"></div>
|
||||
))}
|
||||
|
||||
{Array.from({ length: daysInMonth }).map((_, i) => {
|
||||
const day = i + 1;
|
||||
const selected = isSelected(day);
|
||||
const inRange = isInRange(day);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={day}
|
||||
onClick={() => handleDayClick(day)}
|
||||
className={`w-8 h-8 mx-auto flex items-center justify-center rounded-md transition-colors
|
||||
${selected ? 'bg-blue-600 text-white font-semibold' : ''}
|
||||
${inRange && !selected ? 'bg-blue-900/50 text-blue-200' : ''}
|
||||
${!selected && !inRange ? 'hover:bg-gray-700 text-gray-300' : ''}
|
||||
`}
|
||||
>
|
||||
{day}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<hr className="border-gray-700 my-4" />
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-400 mb-2">Từ ngày</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!startTimestamp}
|
||||
onChange={(e) => {
|
||||
if (!e.target.checked) {
|
||||
setStartTimestamp(null);
|
||||
setHistory(prev => prev.filter(t => t !== startTimestamp));
|
||||
}
|
||||
}}
|
||||
className="w-4 h-4 rounded border-gray-600 bg-transparent text-blue-500 focus:ring-0 cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
disabled={!startTimestamp}
|
||||
value={startDate ? formatDateToString(startDate) : ""}
|
||||
onChange={(e) => handleManualDateChange(e.target.value, 'start')}
|
||||
className="flex-1 px-3 py-1.5 bg-[#2d333b] border border-gray-600 rounded-md text-sm outline-none focus:border-blue-500 disabled:opacity-50"
|
||||
/>
|
||||
<select
|
||||
disabled={!startTimestamp}
|
||||
value={startTime}
|
||||
onChange={(e) => setStartTime(e.target.value)}
|
||||
className="w-[100px] px-3 py-1.5 bg-[#2d333b] border border-gray-600 rounded-md text-sm outline-none focus:border-blue-500 disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
<option value="00:00">00:00</option>
|
||||
<option value="08:00">08:00</option>
|
||||
<option value="12:00">12:00</option>
|
||||
<option value="21:22">21:22</option>
|
||||
<option value="23:59">23:59</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-400 mb-2">Đến ngày</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!endTimestamp}
|
||||
onChange={(e) => {
|
||||
if (!e.target.checked) {
|
||||
setEndTimestamp(null);
|
||||
setHistory(prev => prev.filter(t => t !== endTimestamp));
|
||||
}
|
||||
}}
|
||||
className="w-4 h-4 rounded border-gray-600 bg-transparent text-blue-500 focus:ring-0 cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
disabled={!endTimestamp}
|
||||
value={endDate ? formatDateToString(endDate) : ""}
|
||||
onChange={(e) => handleManualDateChange(e.target.value, 'end')}
|
||||
className="flex-1 px-3 py-1.5 bg-[#2d333b] border border-gray-600 rounded-md text-sm outline-none focus:border-blue-500 disabled:opacity-50"
|
||||
/>
|
||||
<select
|
||||
disabled={!endTimestamp}
|
||||
value={endTime}
|
||||
onChange={(e) => setEndTime(e.target.value)}
|
||||
className="w-[100px] px-3 py-1.5 bg-[#2d333b] border border-gray-600 rounded-md text-sm outline-none focus:border-blue-500 disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
<option value="00:00">00:00</option>
|
||||
<option value="08:00">08:00</option>
|
||||
<option value="12:00">12:00</option>
|
||||
<option value="21:22">21:22</option>
|
||||
<option value="23:59">23:59</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
181
src/components/ecommerce/DailyNewChart.tsx
Normal file
181
src/components/ecommerce/DailyNewChart.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { ApexOptions } from "apexcharts";
|
||||
import { getStatistics } from "@/service/statisticsService";
|
||||
import { StatisticResponse } from "@/interface/statistics";
|
||||
|
||||
const Chart = dynamic(() => import("react-apexcharts"), { ssr: false });
|
||||
|
||||
const METRICS = [
|
||||
{ key: "users", name: "Người dùng" },
|
||||
{ key: "projects", name: "Dự án" },
|
||||
{ key: "commits", name: "Bản lưu" },
|
||||
{ key: "submissions", name: "Duyệt tin" },
|
||||
{ key: "medias", name: "Tệp đa phương tiện" },
|
||||
{ key: "wikis", name: "Bài viết Wiki" },
|
||||
{ key: "entities", name: "Thực thể" },
|
||||
{ key: "geometries", name: "Dữ liệu không gian" },
|
||||
];
|
||||
|
||||
export default function MonthlyNewChart() {
|
||||
const [startDate, setStartDate] = useState<string>("");
|
||||
const [endDate, setEndDate] = useState<string>("");
|
||||
const [chartData, setChartData] = useState<{ month: string; value: number }[]>([]);
|
||||
const [selectedMetricKey, setSelectedMetricKey] = useState<string>("users");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchChartData = async (start: string, end: string, metric: string) => {
|
||||
if (!start || !end) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getStatistics({ start_date: start, end_date: end });
|
||||
if (response?.data) {
|
||||
// Tổng hợp theo tháng
|
||||
const monthsMap: { [key: string]: number } = {};
|
||||
|
||||
response.data.forEach(item => {
|
||||
const date = new Date(item.date);
|
||||
const monthKey = `${String(date.getMonth() + 1).padStart(2, '0')}/${date.getFullYear()}`;
|
||||
const val = item[`new_${metric}` as keyof StatisticResponse] as number || 0;
|
||||
|
||||
if (!monthsMap[monthKey]) {
|
||||
monthsMap[monthKey] = 0;
|
||||
}
|
||||
monthsMap[monthKey] += val;
|
||||
});
|
||||
|
||||
const aggregated = Object.entries(monthsMap).map(([key, value]) => ({
|
||||
month: key,
|
||||
value: value
|
||||
})).sort((a, b) => {
|
||||
const [mA, yA] = a.month.split('/').map(Number);
|
||||
const [mB, yB] = b.month.split('/').map(Number);
|
||||
return yA !== yB ? yA - yB : mA - mB;
|
||||
});
|
||||
|
||||
setChartData(aggregated);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch chart data", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const today = new Date();
|
||||
const startOfYear = new Date(today.getFullYear(), 0, 1); // Từ đầu năm
|
||||
|
||||
const todayStr = today.toISOString().split('T')[0];
|
||||
const startOfYearStr = startOfYear.toISOString().split('T')[0];
|
||||
|
||||
setStartDate(startOfYearStr);
|
||||
setEndDate(todayStr);
|
||||
|
||||
fetchChartData(startOfYearStr, todayStr, selectedMetricKey);
|
||||
}, []);
|
||||
|
||||
const handleStartDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = e.target.value;
|
||||
setStartDate(val);
|
||||
fetchChartData(val, endDate, selectedMetricKey);
|
||||
};
|
||||
|
||||
const handleEndDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = e.target.value;
|
||||
setEndDate(val);
|
||||
fetchChartData(startDate, val, selectedMetricKey);
|
||||
};
|
||||
|
||||
const handleMetricChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const val = e.target.value;
|
||||
setSelectedMetricKey(val);
|
||||
fetchChartData(startDate, endDate, val);
|
||||
};
|
||||
|
||||
const categories = chartData.map(item => item.month);
|
||||
const series = [
|
||||
{
|
||||
name: "Tạo mới",
|
||||
data: chartData.map(item => item.value),
|
||||
}
|
||||
];
|
||||
|
||||
const options: ApexOptions = {
|
||||
colors: ["#465fff"],
|
||||
chart: {
|
||||
fontFamily: "Outfit, sans-serif",
|
||||
type: "bar",
|
||||
height: 180,
|
||||
toolbar: { show: false },
|
||||
},
|
||||
plotOptions: {
|
||||
bar: {
|
||||
horizontal: false,
|
||||
columnWidth: "40%",
|
||||
borderRadius: 5,
|
||||
borderRadiusApplication: "end",
|
||||
},
|
||||
},
|
||||
dataLabels: { enabled: false },
|
||||
stroke: { show: true, width: 4, colors: ["transparent"] },
|
||||
xaxis: {
|
||||
categories: categories,
|
||||
axisBorder: { show: false },
|
||||
axisTicks: { show: false },
|
||||
},
|
||||
legend: { show: false },
|
||||
yaxis: { labels: { style: { colors: ["#6B7280"] } } },
|
||||
grid: { yaxis: { lines: { show: true } } },
|
||||
fill: { opacity: 1 },
|
||||
tooltip: { y: { formatter: (val: number) => `${val}` } },
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-2xl border border-gray-200 bg-white px-5 pt-5 dark:border-gray-800 dark:bg-white/[0.03] sm:px-6 sm:pt-6">
|
||||
<div className="flex flex-col gap-4 mb-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-800 dark:text-white/90">
|
||||
Lượng tạo mới hàng tháng
|
||||
</h3>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<select
|
||||
value={selectedMetricKey}
|
||||
onChange={handleMetricChange}
|
||||
className="px-2 py-1 text-xs font-medium text-gray-700 bg-white border border-gray-200 rounded-lg focus:outline-none focus:border-brand-300 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300"
|
||||
>
|
||||
{METRICS.map((metric) => (
|
||||
<option key={metric.key} value={metric.key}>
|
||||
{metric.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={handleStartDateChange}
|
||||
className="px-2 py-1 text-xs rounded-lg border border-gray-200 bg-white text-gray-700 outline-none dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={handleEndDateChange}
|
||||
className="px-2 py-1 text-xs rounded-lg border border-gray-200 bg-white text-gray-700 outline-none dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-full overflow-x-auto custom-scrollbar">
|
||||
<div className="-ml-5 min-w-[650px] xl:min-w-full pl-2">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-[180px]">Đang tải...</div>
|
||||
) : (
|
||||
<Chart options={options} series={series} type="bar" height={180} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
92
src/components/ecommerce/DetailedStatsTable.tsx
Normal file
92
src/components/ecommerce/DetailedStatsTable.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { getStatisticByDate } from "@/service/statisticsService";
|
||||
import { StatisticResponse } from "@/interface/statistics";
|
||||
|
||||
const METRICS_MAP = [
|
||||
{ key: "users", name: "Người dùng" },
|
||||
{ key: "projects", name: "Dự án" },
|
||||
{ key: "commits", name: "Bản lưu" },
|
||||
{ key: "submissions", name: "Duyệt tin" },
|
||||
{ key: "medias", name: "Tệp đa phương tiện" },
|
||||
{ key: "wikis", name: "Bài viết Wiki" },
|
||||
{ key: "entities", name: "Thực thể" },
|
||||
{ key: "geometries", name: "Dữ liệu không gian" },
|
||||
];
|
||||
|
||||
export default function DetailedStatsTable() {
|
||||
const [todayData, setTodayData] = useState<StatisticResponse | null>(null);
|
||||
const [yesterdayData, setYesterdayData] = useState<StatisticResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const today = new Date();
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(today.getDate() - 1);
|
||||
|
||||
const todayStr = today.toISOString().split('T')[0];
|
||||
const yesterdayStr = yesterday.toISOString().split('T')[0];
|
||||
|
||||
const [todayRes, yesterdayRes] = await Promise.all([
|
||||
getStatisticByDate(todayStr),
|
||||
getStatisticByDate(yesterdayStr)
|
||||
]);
|
||||
|
||||
if (todayRes?.data) setTodayData(todayRes.data);
|
||||
if (yesterdayRes?.data) setYesterdayData(yesterdayRes.data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch detailed statistics", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const calculateGrowth = (today: number, yesterday: number) => {
|
||||
if (!yesterday || yesterday === 0) return 0;
|
||||
return ((today - yesterday) / yesterday) * 100;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] md:p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold text-gray-800 dark:text-white/90">
|
||||
Bảng Số Liệu Chi Tiết (Hôm nay vs Hôm qua)
|
||||
</h3>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-800">
|
||||
<th className="py-3 text-sm font-medium text-gray-500 dark:text-gray-400">Nội dung</th>
|
||||
<th className="py-3 text-sm font-medium text-gray-500 dark:text-gray-400">Hôm nay</th>
|
||||
<th className="py-3 text-sm font-medium text-gray-500 dark:text-gray-400">Hôm qua</th>
|
||||
<th className="py-3 text-sm font-medium text-gray-500 dark:text-gray-400">Tăng trưởng</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{METRICS_MAP.map((metric) => {
|
||||
const todayVal = todayData ? (todayData[`total_${metric.key}` as keyof StatisticResponse] as number) : 0;
|
||||
const yesterdayVal = yesterdayData ? (yesterdayData[`total_${metric.key}` as keyof StatisticResponse] as number) : 0;
|
||||
const growth = calculateGrowth(todayVal, yesterdayVal);
|
||||
|
||||
return (
|
||||
<tr key={metric.key} className="border-b border-gray-100 dark:border-gray-800/50 last:border-0">
|
||||
<td className="py-3 text-sm font-medium text-gray-800 dark:text-white/90">{metric.name}</td>
|
||||
<td className="py-3 text-sm text-gray-600 dark:text-gray-300">{loading ? "..." : todayVal.toLocaleString()}</td>
|
||||
<td className="py-3 text-sm text-gray-600 dark:text-gray-300">{loading ? "..." : yesterdayVal.toLocaleString()}</td>
|
||||
<td className={`py-3 text-sm font-medium ${growth >= 0 ? "text-success-500" : "text-error-500"}`}>
|
||||
{loading ? "..." : `${growth >= 0 ? "+" : ""}${growth.toFixed(2)}%`}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,50 @@
|
||||
"use client";
|
||||
import React from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Badge from "../ui/badge/Badge";
|
||||
import { ArrowDownIcon, ArrowUpIcon, BoxIconLine, GroupIcon } from "@/icons";
|
||||
import { getStatisticByDate } from "@/service/statisticsService";
|
||||
import { StatisticResponse } from "@/interface/statistics";
|
||||
|
||||
export const EcommerceMetrics = () => {
|
||||
const [todayData, setTodayData] = useState<StatisticResponse | null>(null);
|
||||
const [yesterdayData, setYesterdayData] = useState<StatisticResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const today = new Date();
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(today.getDate() - 1);
|
||||
|
||||
const todayStr = today.toISOString().split('T')[0];
|
||||
const yesterdayStr = yesterday.toISOString().split('T')[0];
|
||||
|
||||
const [todayRes, yesterdayRes] = await Promise.all([
|
||||
getStatisticByDate(todayStr),
|
||||
getStatisticByDate(yesterdayStr)
|
||||
]);
|
||||
|
||||
if (todayRes?.data) setTodayData(todayRes.data);
|
||||
if (yesterdayRes?.data) setYesterdayData(yesterdayRes.data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch statistics", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const calculateGrowth = (today: number, yesterday: number) => {
|
||||
if (!yesterday || yesterday === 0) return 0;
|
||||
return ((today - yesterday) / yesterday) * 100;
|
||||
};
|
||||
|
||||
const userGrowth = todayData && yesterdayData ? calculateGrowth(todayData.total_users, yesterdayData.total_users) : 0;
|
||||
const projectGrowth = todayData && yesterdayData ? calculateGrowth(todayData.total_projects, yesterdayData.total_projects) : 0;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:gap-6">
|
||||
{/* <!-- Metric Item Start --> */}
|
||||
@@ -15,15 +56,15 @@ export const EcommerceMetrics = () => {
|
||||
<div className="flex items-end justify-between mt-5">
|
||||
<div>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Customers
|
||||
Tổng Người Dùng
|
||||
</span>
|
||||
<h4 className="mt-2 font-bold text-gray-800 text-title-sm dark:text-white/90">
|
||||
3,782
|
||||
{loading ? "..." : todayData?.total_users?.toLocaleString() || "N/A"}
|
||||
</h4>
|
||||
</div>
|
||||
<Badge color="success">
|
||||
<ArrowUpIcon />
|
||||
11.01%
|
||||
<Badge color={userGrowth >= 0 ? "success" : "error"}>
|
||||
{userGrowth >= 0 ? <ArrowUpIcon /> : <ArrowDownIcon className="text-error-500" />}
|
||||
{Math.abs(userGrowth).toFixed(2)}%
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
@@ -37,16 +78,16 @@ export const EcommerceMetrics = () => {
|
||||
<div className="flex items-end justify-between mt-5">
|
||||
<div>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Orders
|
||||
Tổng Dự Án
|
||||
</span>
|
||||
<h4 className="mt-2 font-bold text-gray-800 text-title-sm dark:text-white/90">
|
||||
5,359
|
||||
{loading ? "..." : todayData?.total_projects?.toLocaleString() || "N/A"}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<Badge color="error">
|
||||
<ArrowDownIcon className="text-error-500" />
|
||||
9.05%
|
||||
<Badge color={projectGrowth >= 0 ? "success" : "error"}>
|
||||
{projectGrowth >= 0 ? <ArrowUpIcon /> : <ArrowDownIcon className="text-error-500" />}
|
||||
{Math.abs(projectGrowth).toFixed(2)}%
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
@@ -54,3 +95,5 @@ export const EcommerceMetrics = () => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EcommerceMetrics;
|
||||
|
||||
@@ -1,170 +1,168 @@
|
||||
"use client";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { ApexOptions } from "apexcharts";
|
||||
import flatpickr from "flatpickr";
|
||||
import ChartTab from "../common/ChartTab";
|
||||
import { CalenderIcon } from "../../icons";
|
||||
import { getStatistics } from "@/service/statisticsService";
|
||||
import { StatisticResponse } from "@/interface/statistics";
|
||||
|
||||
const Chart = dynamic(() => import("react-apexcharts"), { ssr: false });
|
||||
|
||||
const METRICS = [
|
||||
{ key: "users", name: "Người dùng" },
|
||||
{ key: "projects", name: "Dự án" },
|
||||
{ key: "commits", name: "Bản lưu" },
|
||||
{ key: "submissions", name: "Duyệt tin" },
|
||||
{ key: "medias", name: "Tệp đa phương tiện" },
|
||||
{ key: "wikis", name: "Bài viết Wiki" },
|
||||
{ key: "entities", name: "Thực thể" },
|
||||
{ key: "geometries", name: "Dữ liệu không gian" },
|
||||
];
|
||||
|
||||
export default function StatisticsChart() {
|
||||
const datePickerRef = useRef<HTMLInputElement>(null);
|
||||
const [startDate, setStartDate] = useState<string>("");
|
||||
const [endDate, setEndDate] = useState<string>("");
|
||||
const [chartData, setChartData] = useState<StatisticResponse[]>([]);
|
||||
const [selectedMetricKey, setSelectedMetricKey] = useState<string>("users");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchChartData = async (start: string, end: string) => {
|
||||
if (!start || !end) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getStatistics({ start_date: start, end_date: end });
|
||||
if (response?.data) {
|
||||
setChartData(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch chart data", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!datePickerRef.current) return;
|
||||
|
||||
const today = new Date();
|
||||
const sevenDaysAgo = new Date();
|
||||
sevenDaysAgo.setDate(today.getDate() - 6);
|
||||
|
||||
const fp = flatpickr(datePickerRef.current, {
|
||||
mode: "range",
|
||||
static: true,
|
||||
monthSelectorType: "static",
|
||||
dateFormat: "M d",
|
||||
defaultDate: [sevenDaysAgo, today],
|
||||
clickOpens: true,
|
||||
prevArrow:
|
||||
'<svg class="stroke-current" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12.5 15L7.5 10L12.5 5" stroke="" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>',
|
||||
nextArrow:
|
||||
'<svg class="stroke-current" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M7.5 15L12.5 10L7.5 5" stroke="" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>',
|
||||
});
|
||||
const todayStr = today.toISOString().split('T')[0];
|
||||
const sevenDaysAgoStr = sevenDaysAgo.toISOString().split('T')[0];
|
||||
|
||||
return () => {
|
||||
if (!Array.isArray(fp)) {
|
||||
fp.destroy();
|
||||
}
|
||||
};
|
||||
setStartDate(sevenDaysAgoStr);
|
||||
setEndDate(todayStr);
|
||||
|
||||
fetchChartData(sevenDaysAgoStr, todayStr);
|
||||
}, []);
|
||||
|
||||
const options: ApexOptions = {
|
||||
legend: {
|
||||
show: false, // Hide legend
|
||||
position: "top",
|
||||
horizontalAlign: "left",
|
||||
const handleStartDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = e.target.value;
|
||||
setStartDate(val);
|
||||
fetchChartData(val, endDate);
|
||||
};
|
||||
|
||||
const handleEndDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = e.target.value;
|
||||
setEndDate(val);
|
||||
fetchChartData(startDate, val);
|
||||
};
|
||||
|
||||
const categories = chartData.map(item => {
|
||||
const date = new Date(item.date);
|
||||
return `${date.getDate()}/${date.getMonth() + 1}`;
|
||||
});
|
||||
|
||||
const series = [
|
||||
{
|
||||
name: "Tổng cộng",
|
||||
data: chartData.map(item => item[`total_${selectedMetricKey}` as keyof StatisticResponse] as number || 0),
|
||||
},
|
||||
colors: ["#465FFF", "#9CB9FF"], // Define line colors
|
||||
{
|
||||
name: "Mới",
|
||||
data: chartData.map(item => item[`new_${selectedMetricKey}` as keyof StatisticResponse] as number || 0),
|
||||
}
|
||||
];
|
||||
|
||||
const options: ApexOptions = {
|
||||
legend: { show: true, position: "top" },
|
||||
colors: ["#465FFF", "#9CB9FF"],
|
||||
chart: {
|
||||
fontFamily: "Outfit, sans-serif",
|
||||
height: 310,
|
||||
type: "line", // Set the chart type to 'line'
|
||||
toolbar: {
|
||||
show: false, // Hide chart toolbar
|
||||
},
|
||||
type: "area",
|
||||
toolbar: { show: false },
|
||||
},
|
||||
stroke: {
|
||||
curve: "straight", // Define the line style (straight, smooth, or step)
|
||||
width: [2, 2], // Line width for each dataset
|
||||
},
|
||||
|
||||
stroke: { curve: "smooth", width: [2, 2] },
|
||||
fill: {
|
||||
type: "gradient",
|
||||
gradient: {
|
||||
opacityFrom: 0.55,
|
||||
opacityTo: 0,
|
||||
},
|
||||
},
|
||||
markers: {
|
||||
size: 0, // Size of the marker points
|
||||
strokeColors: "#fff", // Marker border color
|
||||
strokeWidth: 2,
|
||||
hover: {
|
||||
size: 6, // Marker size on hover
|
||||
},
|
||||
gradient: { opacityFrom: 0.55, opacityTo: 0 },
|
||||
},
|
||||
grid: {
|
||||
xaxis: {
|
||||
lines: {
|
||||
show: false, // Hide grid lines on x-axis
|
||||
},
|
||||
},
|
||||
yaxis: {
|
||||
lines: {
|
||||
show: true, // Show grid lines on y-axis
|
||||
},
|
||||
},
|
||||
},
|
||||
dataLabels: {
|
||||
enabled: false, // Disable data labels
|
||||
xaxis: { lines: { show: false } },
|
||||
yaxis: { lines: { show: true } },
|
||||
},
|
||||
dataLabels: { enabled: false },
|
||||
tooltip: {
|
||||
enabled: true, // Enable tooltip
|
||||
x: {
|
||||
format: "dd MMM yyyy", // Format for x-axis tooltip
|
||||
},
|
||||
enabled: true,
|
||||
x: { format: "dd MMM" },
|
||||
},
|
||||
xaxis: {
|
||||
type: "category", // Category-based x-axis
|
||||
categories: [
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mar",
|
||||
"Apr",
|
||||
"May",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Oct",
|
||||
"Nov",
|
||||
"Dec",
|
||||
],
|
||||
axisBorder: {
|
||||
show: false, // Hide x-axis border
|
||||
},
|
||||
axisTicks: {
|
||||
show: false, // Hide x-axis ticks
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false, // Disable tooltip for x-axis points
|
||||
},
|
||||
type: "category",
|
||||
categories: categories,
|
||||
axisBorder: { show: false },
|
||||
axisTicks: { show: false },
|
||||
},
|
||||
yaxis: {
|
||||
labels: {
|
||||
style: {
|
||||
fontSize: "12px", // Adjust font size for y-axis labels
|
||||
colors: ["#6B7280"], // Color of the labels
|
||||
},
|
||||
},
|
||||
title: {
|
||||
text: "", // Remove y-axis title
|
||||
style: {
|
||||
fontSize: "0px",
|
||||
fontSize: "12px",
|
||||
colors: ["#6B7280"],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const series = [
|
||||
{
|
||||
name: "Sales",
|
||||
data: [180, 190, 170, 160, 175, 165, 170, 205, 230, 210, 240, 235],
|
||||
},
|
||||
{
|
||||
name: "Revenue",
|
||||
data: [40, 30, 50, 40, 55, 40, 70, 100, 110, 120, 150, 140],
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div className="rounded-2xl border border-gray-200 bg-white px-5 pb-5 pt-5 dark:border-gray-800 dark:bg-white/[0.03] sm:px-6 sm:pt-6">
|
||||
<div className="flex flex-col gap-5 mb-6 sm:flex-row sm:justify-between">
|
||||
<div className="w-full">
|
||||
<h3 className="text-lg font-semibold text-gray-800 dark:text-white/90">
|
||||
Statistics
|
||||
Thống kê hệ thống
|
||||
</h3>
|
||||
<p className="mt-1 text-gray-500 text-theme-sm dark:text-gray-400">
|
||||
Target you've set for each month
|
||||
Dữ liệu thống kê theo thời gian
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 sm:justify-end">
|
||||
<ChartTab />
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-end">
|
||||
{/* Dropdown chọn Metric */}
|
||||
<select
|
||||
value={selectedMetricKey}
|
||||
onChange={(e) => setSelectedMetricKey(e.target.value)}
|
||||
className="px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-200 rounded-lg focus:outline-none focus:border-brand-300 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300"
|
||||
>
|
||||
{METRICS.map((metric) => (
|
||||
<option key={metric.key} value={metric.key}>
|
||||
{metric.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Date Picker Start */}
|
||||
<div className="relative inline-flex items-center">
|
||||
<CalenderIcon className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 lg:left-3 lg:top-1/2 lg:translate-x-0 lg:-translate-y-1/2 text-gray-500 dark:text-gray-400 pointer-events-none z-10" />
|
||||
<input
|
||||
ref={datePickerRef}
|
||||
className="h-10 w-10 lg:w-40 lg:h-auto lg:pl-10 lg:pr-3 lg:py-2 rounded-lg border border-gray-200 bg-white text-sm font-medium text-transparent lg:text-gray-700 outline-none dark:border-gray-700 dark:bg-gray-800 dark:lg:text-gray-300 cursor-pointer"
|
||||
placeholder="Select date range"
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={handleStartDateChange}
|
||||
className="px-3 py-2 rounded-lg border border-gray-200 bg-white text-sm font-medium text-gray-700 outline-none dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Date Picker End */}
|
||||
<div className="relative inline-flex items-center">
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={handleEndDateChange}
|
||||
className="px-3 py-2 rounded-lg border border-gray-200 bg-white text-sm font-medium text-gray-700 outline-none dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -172,7 +170,11 @@ export default function StatisticsChart() {
|
||||
|
||||
<div className="max-w-full overflow-x-auto custom-scrollbar">
|
||||
<div className="min-w-[1000px] xl:min-w-full">
|
||||
<Chart options={options} series={series} type="area" height={310} />
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-[310px]">Đang tải...</div>
|
||||
) : (
|
||||
<Chart options={options} series={series} type="area" height={310} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,7 @@ interface InputProps {
|
||||
success?: boolean;
|
||||
error?: boolean;
|
||||
hint?: string; // Optional hint text
|
||||
autoComplete?: string;
|
||||
}
|
||||
|
||||
const Input: FC<InputProps> = ({
|
||||
@@ -32,6 +33,7 @@ const Input: FC<InputProps> = ({
|
||||
success = false,
|
||||
error = false,
|
||||
hint,
|
||||
autoComplete
|
||||
}) => {
|
||||
// Determine input styles based on state (disabled, success, error)
|
||||
let inputClasses = `h-11 w-full rounded-lg border appearance-none px-4 py-2.5 text-sm shadow-theme-xs placeholder:text-gray-400 focus:outline-hidden focus:ring-3 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800 ${className}`;
|
||||
@@ -61,6 +63,7 @@ const Input: FC<InputProps> = ({
|
||||
step={step}
|
||||
disabled={disabled}
|
||||
className={inputClasses}
|
||||
autoComplete={autoComplete}
|
||||
/>
|
||||
|
||||
{/* Optional Hint Text */}
|
||||
|
||||
@@ -4,11 +4,15 @@ import Link from "next/link";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Dropdown } from "../ui/dropdown/Dropdown";
|
||||
import { DropdownItem } from "../ui/dropdown/DropdownItem";
|
||||
import { fullDataUser } from "@/interface/admin";
|
||||
import { UserMetaCardProps } from "@/interface/user";
|
||||
import { apiGetCurrentUser, apiLogout } from "@/service/auth";
|
||||
import { removeUserFromCookie } from "@/lib/cookieStorage";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ListIcon } from "@/icons";
|
||||
|
||||
export default function UserDropdown() {
|
||||
const router = useRouter();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [user, setUser] = useState<UserMetaCardProps | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
@@ -44,9 +48,6 @@ export default function UserDropdown() {
|
||||
} catch (error) {
|
||||
console.error("Logout failed", error);
|
||||
} finally {
|
||||
// Xóa user data từ cookies
|
||||
removeUserFromCookie();
|
||||
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
|
||||
@@ -118,7 +119,7 @@ export default function UserDropdown() {
|
||||
<DropdownItem
|
||||
onItemClick={closeDropdown}
|
||||
tag="a"
|
||||
href="/profile"
|
||||
href="/account"
|
||||
className="flex items-center gap-3 px-3 py-2 font-medium text-gray-700 rounded-lg group text-theme-sm hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
|
||||
>
|
||||
<svg
|
||||
@@ -136,35 +137,23 @@ export default function UserDropdown() {
|
||||
fill=""
|
||||
/>
|
||||
</svg>
|
||||
Edit profile
|
||||
Tài Khoản
|
||||
</DropdownItem>
|
||||
</li>
|
||||
{/* <li>
|
||||
<DropdownItem
|
||||
onItemClick={closeDropdown}
|
||||
tag="a"
|
||||
href="/profile"
|
||||
href="/role-upgrade"
|
||||
className="flex items-center gap-3 px-3 py-2 font-medium text-gray-700 rounded-lg group text-theme-sm hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
|
||||
>
|
||||
<svg
|
||||
className="fill-gray-500 group-hover:fill-gray-700 dark:fill-gray-400 dark:group-hover:fill-gray-300"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M10.4858 3.5L13.5182 3.5C13.9233 3.5 14.2518 3.82851 14.2518 4.23377C14.2518 5.9529 16.1129 7.02795 17.602 6.1682C17.9528 5.96567 18.4014 6.08586 18.6039 6.43667L20.1203 9.0631C20.3229 9.41407 20.2027 9.86286 19.8517 10.0655C18.3625 10.9253 18.3625 13.0747 19.8517 13.9345C20.2026 14.1372 20.3229 14.5859 20.1203 14.9369L18.6039 17.5634C18.4013 17.9142 17.9528 18.0344 17.602 17.8318C16.1129 16.9721 14.2518 18.0471 14.2518 19.7663C14.2518 20.1715 13.9233 20.5 13.5182 20.5H10.4858C10.0804 20.5 9.75182 20.1714 9.75182 19.766C9.75182 18.0461 7.88983 16.9717 6.40067 17.8314C6.04945 18.0342 5.60037 17.9139 5.39767 17.5628L3.88167 14.937C3.67903 14.586 3.79928 14.1372 4.15026 13.9346C5.63949 13.0748 5.63946 10.9253 4.15025 10.0655C3.79926 9.86282 3.67901 9.41401 3.88165 9.06303L5.39764 6.43725C5.60034 6.08617 6.04943 5.96581 6.40065 6.16858C7.88982 7.02836 9.75182 5.9539 9.75182 4.23399C9.75182 3.82862 10.0804 3.5 10.4858 3.5ZM13.5182 2L10.4858 2C9.25201 2 8.25182 3.00019 8.25182 4.23399C8.25182 4.79884 7.64013 5.15215 7.15065 4.86955C6.08213 4.25263 4.71559 4.61859 4.0986 5.68725L2.58261 8.31303C1.96575 9.38146 2.33183 10.7477 3.40025 11.3645C3.88948 11.647 3.88947 12.3531 3.40026 12.6355C2.33184 13.2524 1.96578 14.6186 2.58263 15.687L4.09863 18.3128C4.71562 19.3814 6.08215 19.7474 7.15067 19.1305C7.64015 18.8479 8.25182 19.2012 8.25182 19.766C8.25182 20.9998 9.25201 22 10.4858 22H13.5182C14.7519 22 15.7518 20.9998 15.7518 19.7663C15.7518 19.2015 16.3632 18.8487 16.852 19.1309C17.9202 19.7476 19.2862 19.3816 19.9029 18.3134L21.4193 15.6869C22.0361 14.6185 21.6701 13.2523 20.6017 12.6355C20.1125 12.3531 20.1125 11.647 20.6017 11.3645C21.6701 10.7477 22.0362 9.38152 21.4193 8.3131L19.903 5.68667C19.2862 4.61842 17.9202 4.25241 16.852 4.86917C16.3632 5.15138 15.7518 4.79856 15.7518 4.23377C15.7518 3.00024 14.7519 2 13.5182 2ZM9.6659 11.9999C9.6659 10.7103 10.7113 9.66493 12.0009 9.66493C13.2905 9.66493 14.3359 10.7103 14.3359 11.9999C14.3359 13.2895 13.2905 14.3349 12.0009 14.3349C10.7113 14.3349 9.6659 13.2895 9.6659 11.9999ZM12.0009 8.16493C9.88289 8.16493 8.1659 9.88191 8.1659 11.9999C8.1659 14.1179 9.88289 15.8349 12.0009 15.8349C14.1189 15.8349 15.8359 14.1179 15.8359 11.9999C15.8359 9.88191 14.1189 8.16493 12.0009 8.16493Z"
|
||||
fill=""
|
||||
/>
|
||||
</svg>
|
||||
Account settings
|
||||
<span className="menu-item-icon">
|
||||
<ListIcon />
|
||||
</span>
|
||||
Nhà Sử Học
|
||||
</DropdownItem>
|
||||
</li> */}
|
||||
<li>
|
||||
{/* <li>
|
||||
<DropdownItem
|
||||
onItemClick={closeDropdown}
|
||||
tag="a"
|
||||
@@ -186,9 +175,9 @@ export default function UserDropdown() {
|
||||
fill=""
|
||||
/>
|
||||
</svg>
|
||||
Support
|
||||
Hỗ Trợ
|
||||
</DropdownItem>
|
||||
</li>
|
||||
</li> */}
|
||||
</ul>
|
||||
<Link
|
||||
onClick={handleLogout}
|
||||
@@ -210,7 +199,7 @@ export default function UserDropdown() {
|
||||
fill=""
|
||||
/>
|
||||
</svg>
|
||||
Sign out
|
||||
Đăng xuất
|
||||
</Link>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ import "yet-another-react-lightbox/styles.css";
|
||||
import "yet-another-react-lightbox/plugins/captions.css";
|
||||
import { IsolatedContent } from "@/components/ui/IsolatedContent";
|
||||
import { apiDeleteHistorianCV } from "@/service/historianService";
|
||||
import { statusConfig } from "@/service/handler";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
@@ -101,7 +102,9 @@ export default function ApplicationDetailModal({
|
||||
if (!isOpen || !application) return null;
|
||||
|
||||
const userData = application.user || {};
|
||||
|
||||
const currentStatus = statusConfig[application.status] || {
|
||||
container: "bg-gray-50 border-gray-200 text-gray-600 shadow-sm",
|
||||
};
|
||||
return (
|
||||
<div className="fixed inset-0 z-999 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 dark:bg-gray-900 flex flex-col overflow-hidden text-gray-800 dark:text-gray-200">
|
||||
@@ -149,13 +152,18 @@ export default function ApplicationDetailModal({
|
||||
<p className="text-sm text-gray-500">
|
||||
{userData.email || "Không có email"}
|
||||
</p>
|
||||
<div className="mt-1 flex gap-2">
|
||||
<span className="px-2 py-0.5 text-[10px] font-bold uppercase text-blue-600 bg-blue-100 rounded-md">
|
||||
<div className="mt-1 flex gap-2 items-center">
|
||||
<span className="px-3 py-1 text-[11px] font-bold uppercase text-blue-600 bg-blue-100 rounded-md ">
|
||||
{application.verify_type}
|
||||
</span>
|
||||
<span
|
||||
className={`px-2 py-0.5 text-[10px] font-semibold uppercase rounded-md ${application.status === "PENDING" ? "bg-yellow-100 text-yellow-600" : "bg-red-100 text-red-500"}`}
|
||||
>
|
||||
className={`
|
||||
inline-flex items-center px-3 py-1
|
||||
rounded-md border text-[11px] font-semibold uppercase
|
||||
transition-colors duration-200
|
||||
${currentStatus.container}
|
||||
`}
|
||||
>
|
||||
{application.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -149,7 +149,7 @@ export default function ApplicationTable({
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-white/[0.05] dark:bg-white/[0.03]">
|
||||
<div className="max-w-full overflow-x-auto">
|
||||
<div className="min-w-[1300px]">
|
||||
<div className="min-w-[1000px]">
|
||||
<Table>
|
||||
<TableHeader className="border-b border-gray-100 dark:border-white/[0.05]">
|
||||
<TableRow>
|
||||
@@ -161,7 +161,7 @@ export default function ApplicationTable({
|
||||
</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]"
|
||||
className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400 min-w-[150px]"
|
||||
>
|
||||
Loại xác minh
|
||||
</TableCell>
|
||||
@@ -212,7 +212,7 @@ export default function ApplicationTable({
|
||||
</TableCell>
|
||||
<TableCell
|
||||
isHeader
|
||||
className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400 max-w-[200px]"
|
||||
className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400 max-w-[150px]"
|
||||
>
|
||||
Ghi chú
|
||||
</TableCell>
|
||||
|
||||
237
src/components/tables/MediaTable.tsx
Normal file
237
src/components/tables/MediaTable.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../ui/table";
|
||||
import Badge from "../ui/badge/Badge";
|
||||
|
||||
export type MediaSortColumn =
|
||||
| "created_at"
|
||||
| "updated_at"
|
||||
| "size"
|
||||
| "original_name"
|
||||
| "mime_type";
|
||||
|
||||
export interface MediaItem {
|
||||
id: string;
|
||||
user_id: string;
|
||||
storage_key: string;
|
||||
original_name: string;
|
||||
mime_type: string;
|
||||
size: number;
|
||||
file_metadata: any;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface MediaTableProps {
|
||||
data: MediaItem[];
|
||||
onSort: (column: MediaSortColumn) => void;
|
||||
sortBy?: MediaSortColumn;
|
||||
sortOrder?: "asc" | "desc";
|
||||
// Các props mới thêm cho yêu cầu chọn, xem, xóa
|
||||
selectedIds: string[];
|
||||
onToggleSelect: (id: string) => void;
|
||||
onToggleSelectAll: (checked: boolean) => void;
|
||||
onViewSingle: (item: MediaItem, index: number) => void;
|
||||
onDeleteSingle: (id: string) => void;
|
||||
}
|
||||
|
||||
export default function MediaTable({
|
||||
data,
|
||||
onSort,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
selectedIds,
|
||||
onToggleSelect,
|
||||
onToggleSelectAll,
|
||||
onViewSingle,
|
||||
onDeleteSingle,
|
||||
}: MediaTableProps) {
|
||||
const formatDate = (dateString: string | null | undefined) => {
|
||||
if (!dateString) return "-";
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString("vi-VN", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
const k = 1024;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||
};
|
||||
|
||||
const SortIcon = ({ column }: { column: MediaSortColumn }) => {
|
||||
const isActive = sortBy === column;
|
||||
return (
|
||||
<div className="flex flex-col ml-2 opacity-50 cursor-pointer hover:opacity-100">
|
||||
<svg
|
||||
className={`w-3 h-3 ${isActive && sortOrder === "asc" ? "text-blue-700 opacity-100" : "text-gray-400"}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
<svg
|
||||
className={`w-3 h-3 -mt-1 ${isActive && sortOrder === "desc" ? "text-blue-700 opacity-100" : "text-gray-400"}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getMimeTypeBadge = (mimeType: string) => {
|
||||
if (mimeType.includes("image")) {
|
||||
return (
|
||||
<Badge size="sm" variant="light" color="success">
|
||||
Hình ảnh
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (mimeType.includes("pdf") || mimeType.includes("word") || mimeType.includes("document")) {
|
||||
return (
|
||||
<Badge size="sm" variant="light" color="warning">
|
||||
Tài liệu
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge size="sm" variant="light" color="light">
|
||||
Khác
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const isAllSelected = data.length > 0 && selectedIds.length === data.length;
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-white/[0.05] dark:bg-white/[0.03]">
|
||||
<div className="max-w-full overflow-x-auto">
|
||||
<div className="min-w-[1000px]">
|
||||
<Table>
|
||||
<TableHeader className="border-b border-gray-100 dark:border-white/[0.05]">
|
||||
<TableRow>
|
||||
<TableCell isHeader className="px-5 py-3 w-12 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isAllSelected}
|
||||
onChange={(e) => onToggleSelectAll(e.target.checked)}
|
||||
className="w-4 h-4 text-brand-500 border-gray-300 rounded cursor-pointer focus:ring-brand-500 dark:bg-gray-800 dark:border-gray-600"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell isHeader className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400">
|
||||
<div className="flex items-center cursor-pointer select-none" onClick={() => onSort("original_name")}>
|
||||
Tên tệp <SortIcon column="original_name" />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell isHeader className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400">
|
||||
<div className="flex items-center cursor-pointer select-none" onClick={() => onSort("mime_type")}>
|
||||
Định dạng <SortIcon column="mime_type" />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell isHeader className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400">
|
||||
<div className="flex items-center cursor-pointer select-none" onClick={() => onSort("size")}>
|
||||
Kích thước <SortIcon column="size" />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell isHeader className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400">
|
||||
<div className="flex items-center cursor-pointer select-none" onClick={() => onSort("created_at")}>
|
||||
Ngày tải lên <SortIcon column="created_at" />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell isHeader className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400">
|
||||
<div className="flex items-center cursor-pointer select-none" onClick={() => onSort("updated_at")}>
|
||||
Cập nhật <SortIcon column="updated_at" />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell isHeader className="px-5 py-3 font-medium text-center text-gray-500 text-theme-xs dark:text-gray-400 min-w-[120px]">
|
||||
Thao tác
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody className="divide-y divide-gray-100 dark:divide-white/[0.05]">
|
||||
{data.length > 0 ? (
|
||||
data.map((item, idx) => (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className="hover:bg-gray-50/50 dark:hover:bg-white/[0.01] transition-colors"
|
||||
>
|
||||
{/* Yêu cầu 1: Cột 0 - Ô hình vuông chọn từng item */}
|
||||
<TableCell className="px-5 py-4 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.includes(item.id)}
|
||||
onChange={() => onToggleSelect(item.id)}
|
||||
className="w-4 h-4 text-brand-500 border-gray-300 rounded cursor-pointer focus:ring-brand-500 dark:bg-gray-800 dark:border-gray-600"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="px-5 py-4 text-start font-mono text-theme-xs truncate max-w-[250px]">
|
||||
{item.original_name}
|
||||
</TableCell>
|
||||
<TableCell className="px-5 py-4 text-start max-w-[100px] truncate">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div>{getMimeTypeBadge(item.mime_type)}</div>
|
||||
<span className="text-[10px] text-gray-400">{item.mime_type}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-5 py-4 text-start text-theme-sm font-medium">
|
||||
{formatBytes(item.size)}
|
||||
</TableCell>
|
||||
<TableCell className="px-5 py-4 text-gray-600 text-theme-sm dark:text-gray-400">
|
||||
{formatDate(item.created_at)}
|
||||
</TableCell>
|
||||
<TableCell className="px-5 py-4 text-gray-600 text-theme-sm dark:text-gray-400">
|
||||
{formatDate(item.updated_at)}
|
||||
</TableCell>
|
||||
<TableCell className="px-5 py-4 text-center">
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<button
|
||||
onClick={() => onViewSingle(item, idx)}
|
||||
className="text-brand-500 hover:text-brand-600 font-medium text-theme-sm"
|
||||
>
|
||||
Xem
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDeleteSingle(item.id)}
|
||||
className="text-red-500 hover:text-red-600 font-medium text-theme-sm"
|
||||
>
|
||||
Xóa
|
||||
</button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={7}
|
||||
className="px-5 py-20 text-center text-gray-500 italic"
|
||||
>
|
||||
Không tìm thấy tệp tin nào
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,33 @@ const Pagination: React.FC<PaginationProps> = ({
|
||||
totalPages,
|
||||
onPageChange,
|
||||
}) => {
|
||||
const allPages = Array.from({ length: totalPages }, (_, i) => i + 1);
|
||||
const getPaginationItems = () => {
|
||||
const delta = 1;
|
||||
const range = [];
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
if (i === 1 || i === totalPages || (i >= currentPage - delta && i <= currentPage + delta)) {
|
||||
range.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
const rangeWithDots: (number | string)[] = [];
|
||||
let l: number | undefined;
|
||||
for (const i of range) {
|
||||
if (l !== undefined) {
|
||||
if (i - l === 2) {
|
||||
rangeWithDots.push(l + 1);
|
||||
} else if (i - l > 2) {
|
||||
rangeWithDots.push('...');
|
||||
}
|
||||
}
|
||||
rangeWithDots.push(i);
|
||||
l = i;
|
||||
}
|
||||
|
||||
return rangeWithDots;
|
||||
};
|
||||
|
||||
const pages = getPaginationItems();
|
||||
|
||||
return (
|
||||
<div className="flex items-center ">
|
||||
@@ -21,21 +47,25 @@ const Pagination: React.FC<PaginationProps> = ({
|
||||
Previous
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{currentPage > 3 && <span className="px-2">...</span>}
|
||||
{allPages.map((page) => (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => onPageChange(page)}
|
||||
className={`px-4 py-2 rounded ${
|
||||
currentPage === page
|
||||
? "bg-brand-500 text-white"
|
||||
: "text-gray-700 dark:text-gray-400"
|
||||
} flex w-10 items-center justify-center h-10 rounded-lg text-sm font-medium hover:bg-blue-500/[0.08] hover:text-brand-500 dark:hover:text-brand-500`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
))}
|
||||
{currentPage < totalPages - 2 && <span className="px-2">...</span>}
|
||||
{pages.map((page, index) =>
|
||||
page === '...' ? (
|
||||
<span key={`dots-${index}`} className="flex h-10 w-10 items-center justify-center text-gray-500">
|
||||
...
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => onPageChange(page as number)}
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-lg text-sm font-medium hover:bg-blue-500/[0.08] hover:text-brand-500 dark:hover:text-brand-500 ${
|
||||
currentPage === page
|
||||
? "bg-brand-500 text-white"
|
||||
: "text-gray-700 dark:text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
|
||||
250
src/components/tables/ProjectsTable.tsx
Normal file
250
src/components/tables/ProjectsTable.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Badge from "../ui/badge/Badge";
|
||||
|
||||
export type ProjectSortColumn = "created_at" | "updated_at" | "title";
|
||||
|
||||
export interface ProjectItem {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
project_status: "PRIVATE" | "PUBLIC" | "ARCHIVE";
|
||||
owner_id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
user: {
|
||||
id: string;
|
||||
display_name: string;
|
||||
email: string;
|
||||
avatar_url: string;
|
||||
};
|
||||
members?: {
|
||||
user_id: string;
|
||||
role: string;
|
||||
display_name: string;
|
||||
avatar_url: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface ProjectsTableProps {
|
||||
data: ProjectItem[];
|
||||
onSort: (column: ProjectSortColumn) => void;
|
||||
sortBy?: ProjectSortColumn;
|
||||
sortOrder?: "asc" | "desc";
|
||||
onViewDetails: (id: string) => void;
|
||||
}
|
||||
|
||||
export default function ProjectsTable({
|
||||
data,
|
||||
onSort,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
onViewDetails,
|
||||
}: ProjectsTableProps) {
|
||||
const formatDate = (dateString: string | null | undefined) => {
|
||||
if (!dateString) return "-";
|
||||
const date = new Date(dateString);
|
||||
return `${date.toLocaleDateString("vi-VN", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})}`;
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: ProjectItem["project_status"]) => {
|
||||
switch (status) {
|
||||
case "PUBLIC":
|
||||
return (
|
||||
<Badge size="sm" variant="light" color="success">
|
||||
PUBLIC
|
||||
</Badge>
|
||||
);
|
||||
case "PRIVATE":
|
||||
return (
|
||||
<Badge size="sm" variant="light" color="warning">
|
||||
PRIVATE
|
||||
</Badge>
|
||||
);
|
||||
case "ARCHIVE":
|
||||
return (
|
||||
<Badge size="sm" variant="light" color="light">
|
||||
ARCHIVE
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Badge size="sm" variant="light" color="dark">
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const SortButton = ({
|
||||
column,
|
||||
label,
|
||||
}: {
|
||||
column: ProjectSortColumn;
|
||||
label: string;
|
||||
}) => {
|
||||
const isActive = sortBy === column;
|
||||
return (
|
||||
<button
|
||||
onClick={() => onSort(column)}
|
||||
className={`text-sm font-medium text-left hover:text-blue-500 transition-colors ${isActive
|
||||
? "text-blue-600 dark:text-blue-400"
|
||||
: "text-gray-500 dark:text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{label} {isActive && (sortOrder === "asc" ? "↑" : "↓")}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-[#0d1117]">
|
||||
<div className="max-w-full overflow-x-auto custom-scrollbar">
|
||||
<div className="min-w-[700px]">
|
||||
|
||||
<div className="flex items-center px-5 py-3 border-b border-gray-200 dark:border-gray-800 bg-gray-50/50 dark:bg-[#161b22]">
|
||||
|
||||
<div className="flex-1 pr-4 flex items-center gap-3">
|
||||
<span className="text-sm font-medium text-gray-400 dark:text-gray-500">
|
||||
Sắp xếp:
|
||||
</span>
|
||||
<SortButton column="title" label="Tên dự án" />
|
||||
</div>
|
||||
|
||||
<div className="w-40 shrink-0">
|
||||
<SortButton column="created_at" label="Ngày tạo" />
|
||||
</div>
|
||||
|
||||
<div className="w-40 shrink-0">
|
||||
<SortButton column="updated_at" label="Cập nhật" />
|
||||
</div>
|
||||
|
||||
<div className="w-[120px] shrink-0 text-right">
|
||||
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
Thành viên
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col divide-y divide-gray-200 dark:divide-gray-800">
|
||||
{data.length > 0 ? (
|
||||
data.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="group flex flex-col p-5 md:flex-row md:items-center gap-3 md:gap-0 hover:bg-gray-50 dark:hover:bg-[#161b22] transition-colors"
|
||||
>
|
||||
|
||||
<div className="flex-1 pr-4 min-w-0">
|
||||
<div
|
||||
onClick={() => onViewDetails(item.id)}
|
||||
className="flex items-center gap-2 cursor-pointer hover:underline w-full"
|
||||
>
|
||||
<div className="w-6 h-6 shrink-0 flex items-center justify-center">
|
||||
{item.user?.avatar_url ? (
|
||||
<div className="relative w-6 h-6 rounded-full overflow-hidden border border-gray-200 dark:border-gray-800">
|
||||
<Image
|
||||
src={item.user.avatar_url}
|
||||
alt="avatar"
|
||||
fill
|
||||
className="object-cover rounded-full"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-6 h-6 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center border border-gray-300 dark:border-gray-600">
|
||||
<span className="text-[10px] font-bold text-gray-500 dark:text-gray-300 leading-none">
|
||||
{item.user?.display_name?.charAt(0)?.toUpperCase() || "U"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center max-w-[200px]">
|
||||
<span className="text-[14px] font-medium text-gray-700 dark:text-gray-300 truncate">
|
||||
{item.user?.display_name || "Unknown"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span className="text-[14px] text-gray-400 dark:text-gray-600 shrink-0">
|
||||
/
|
||||
</span>
|
||||
|
||||
<h3 className="text-[14px] font-semibold text-blue-600 dark:text-[#58a6ff] truncate max-w-[250px]">
|
||||
{item.title}
|
||||
</h3>
|
||||
|
||||
<div className="shrink-0 flex justify-start">
|
||||
{getStatusBadge(item.project_status)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:w-40 shrink-0 text-xs text-gray-500 dark:text-[#8b949e]">
|
||||
<span>{formatDate(item.created_at)}</span>
|
||||
</div>
|
||||
|
||||
<div className="md:w-40 shrink-0 text-xs text-gray-500 dark:text-[#8b949e]">
|
||||
<span>{formatDate(item.updated_at)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center md:w-[120px] md:justify-end shrink-0">
|
||||
<div className="flex -space-x-2 overflow-hidden">
|
||||
{item.members && item.members.length > 0 ? (
|
||||
<>
|
||||
{item.members.slice(0, 4).map((m, index) =>
|
||||
m.avatar_url ? (
|
||||
<Image
|
||||
key={index}
|
||||
src={m.avatar_url}
|
||||
alt={m.display_name}
|
||||
width={32}
|
||||
height={32}
|
||||
title={m.display_name}
|
||||
className="inline-block w-8 h-8 rounded-full object-cover ring-2 ring-white group-hover:ring-gray-50 dark:ring-[#0d1117] dark:group-hover:ring-[#161b22] transition-colors"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
key={index}
|
||||
title={m.display_name}
|
||||
className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 dark:bg-gray-700 ring-2 ring-white group-hover:ring-gray-50 dark:ring-[#0d1117] dark:group-hover:ring-[#161b22] transition-colors"
|
||||
>
|
||||
<span className="text-xs font-medium text-gray-500 dark:text-gray-300">
|
||||
{m.display_name?.charAt(0)?.toUpperCase() || "U"}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
|
||||
{item.members.length > 4 && (
|
||||
<div
|
||||
title="Những người khác"
|
||||
className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-gray-100 dark:bg-gray-800 ring-2 ring-white group-hover:ring-gray-50 dark:ring-[#0d1117] dark:group-hover:ring-[#161b22] transition-colors z-10"
|
||||
>
|
||||
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
+{item.members.length - 4}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400 dark:text-gray-600 italic"></span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="p-10 text-center text-gray-500 dark:text-gray-400 italic">
|
||||
Không tìm thấy dự án nào
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
313
src/components/tables/SubmissionsTable.tsx
Normal file
313
src/components/tables/SubmissionsTable.tsx
Normal file
@@ -0,0 +1,313 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Badge from "@/components/ui/badge/Badge";
|
||||
|
||||
export type SubmissionSortColumn = "id" | "status" | "created_at" | "reviewed_at";
|
||||
|
||||
export interface SubmissionItem {
|
||||
id: string;
|
||||
project_id: string;
|
||||
commit_id: string;
|
||||
user_id: string;
|
||||
created_at: string;
|
||||
status: string;
|
||||
project_title: string;
|
||||
project_description: string;
|
||||
content?: string;
|
||||
reviewed_by?: string;
|
||||
reviewed_at?: string;
|
||||
review_note?: string;
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name: string;
|
||||
avatar_url?: string;
|
||||
};
|
||||
reviewer?: {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name: string;
|
||||
avatar_url?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface SubmissionsTableProps {
|
||||
data: SubmissionItem[];
|
||||
onSort: (column: SubmissionSortColumn) => void;
|
||||
sortBy?: SubmissionSortColumn;
|
||||
sortOrder?: "asc" | "desc";
|
||||
onViewDetails: (id: string) => void;
|
||||
onActionClick: (item: SubmissionItem) => void;
|
||||
}
|
||||
|
||||
export default function SubmissionsTable({
|
||||
data,
|
||||
onSort,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
onViewDetails,
|
||||
onActionClick,
|
||||
}: SubmissionsTableProps) {
|
||||
const formatDate = (dateString: string | null | undefined) => {
|
||||
if (!dateString) return "-";
|
||||
const date = new Date(dateString);
|
||||
return `${date.toLocaleDateString("vi-VN", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
})} ${date.toLocaleTimeString("vi-VN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}`;
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case "APPROVED":
|
||||
case "SUCCESS":
|
||||
return (
|
||||
<Badge size="sm" variant="light" color="success">
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
case "PENDING":
|
||||
return (
|
||||
<Badge size="sm" variant="light" color="warning">
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
case "REJECTED":
|
||||
case "FAILED":
|
||||
return (
|
||||
<Badge size="sm" variant="light" color="error">
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Badge size="sm" variant="light" color="dark">
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const SortButton = ({
|
||||
column,
|
||||
label,
|
||||
align = "left",
|
||||
}: {
|
||||
column: SubmissionSortColumn;
|
||||
label: string;
|
||||
align?: "left" | "right" | "center";
|
||||
}) => {
|
||||
const isActive = sortBy === column;
|
||||
// Căn lề chuẩn xác cho text và icon bên trong button
|
||||
const alignClass =
|
||||
align === "right"
|
||||
? "justify-end text-right ml-auto"
|
||||
: align === "center"
|
||||
? "justify-center text-center mx-auto"
|
||||
: "justify-start text-left";
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => onSort(column)}
|
||||
className={`text-sm font-medium hover:text-blue-500 transition-colors flex items-center gap-1 w-full ${alignClass} ${
|
||||
isActive
|
||||
? "text-blue-600 dark:text-blue-400"
|
||||
: "text-gray-500 dark:text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
{isActive && (
|
||||
<span className="text-xs">{sortOrder === "asc" ? "↑" : "↓"}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const AvatarWithTooltip = ({
|
||||
user,
|
||||
}: {
|
||||
user?: { display_name: string; avatar_url?: string };
|
||||
}) => {
|
||||
if (!user) return <span className="text-gray-400 text-xs italic">-</span>;
|
||||
|
||||
return (
|
||||
<div className="group/avatar relative flex justify-center items-center">
|
||||
<div className="w-8 h-8 shrink-0 flex items-center justify-center cursor-default">
|
||||
{user.avatar_url ? (
|
||||
<div className="relative w-8 h-8 rounded-full overflow-hidden border border-gray-200 dark:border-gray-700 ring-2 ring-transparent group-hover/avatar:ring-blue-100 dark:group-hover/avatar:ring-blue-900 transition-all">
|
||||
<Image
|
||||
src={user.avatar_url}
|
||||
alt="avatar"
|
||||
fill
|
||||
className="object-cover rounded-full"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center border border-gray-300 dark:border-gray-600 ring-2 ring-transparent group-hover/avatar:ring-blue-100 dark:group-hover/avatar:ring-blue-900 transition-all">
|
||||
<span className="text-xs font-bold text-gray-500 dark:text-gray-300 leading-none">
|
||||
{user.display_name?.charAt(0)?.toUpperCase() || "U"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="invisible opacity-0 group-hover/avatar:visible group-hover/avatar:opacity-100 absolute z-50 bottom-full left-1/2 -translate-x-1/2 mb-2 w-max max-w-[200px] px-3 py-1.5 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 text-xs font-medium rounded-lg shadow-xl whitespace-normal border border-gray-100 dark:border-gray-700 transition-all duration-200">
|
||||
{user.display_name}
|
||||
<div className="absolute top-full left-1/2 -translate-x-1/2 border-[5px] border-transparent border-t-white dark:border-t-gray-800"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-[#0d1117]">
|
||||
<div className="max-w-full overflow-x-auto custom-scrollbar">
|
||||
<div className="">
|
||||
|
||||
<div className="flex items-center px-5 py-3 border-b border-gray-200 dark:border-gray-800 bg-gray-50/50 dark:bg-[#161b22]">
|
||||
<div className="w-[220px] shrink-0 pr-4">
|
||||
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
Tên
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-[150px] pr-4">
|
||||
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
Dự án
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="w-[120px] shrink-0">
|
||||
<SortButton column="status" label="Trạng thái" />
|
||||
</div>
|
||||
|
||||
<div className="w-[180px] shrink-0 pr-4">
|
||||
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
Ghi chú
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="w-[140px] shrink-0">
|
||||
<SortButton column="reviewed_at" label="Cập nhật" align="right" />
|
||||
</div>
|
||||
|
||||
<div className="w-[100px] shrink-0 text-center">
|
||||
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
Người duyệt
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="w-[140px] shrink-0">
|
||||
<SortButton column="created_at" label="Ngày tạo" align="right" />
|
||||
</div>
|
||||
|
||||
<div className="w-[100px] shrink-0 text-right">
|
||||
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
Thao tác
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* =========== BODY =========== */}
|
||||
<div className="flex flex-col divide-y divide-gray-200 dark:divide-gray-800">
|
||||
{data.length > 0 ? (
|
||||
data.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="group flex flex-col p-5 md:flex-row md:items-center hover:bg-gray-50 dark:hover:bg-[#161b22] transition-colors gap-3 md:gap-0"
|
||||
>
|
||||
<div className="w-[220px] shrink-0 pr-4 flex items-center gap-3">
|
||||
<div className="w-8 h-8 shrink-0 flex items-center justify-center">
|
||||
{item.user?.avatar_url ? (
|
||||
<div className="relative w-8 h-8 rounded-full overflow-hidden border border-gray-200 dark:border-gray-700">
|
||||
<Image
|
||||
src={item.user.avatar_url}
|
||||
alt="avatar"
|
||||
fill
|
||||
className="object-cover rounded-full"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center border border-gray-300 dark:border-gray-600">
|
||||
<span className="text-xs font-bold text-gray-500 dark:text-gray-300 leading-none">
|
||||
{item.user?.display_name?.charAt(0)?.toUpperCase() || "U"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[13px] font-medium text-gray-700 dark:text-gray-300 truncate">
|
||||
{item.user?.display_name || "Unknown"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-[150px] pr-4 flex items-center">
|
||||
<div
|
||||
|
||||
className="flex flex-col min-w-0"
|
||||
>
|
||||
<h3 onClick={() => onViewDetails(item.id)} className="cursor-pointer hover:underline text-[14px] font-semibold text-blue-600 dark:text-[#58a6ff] truncate">
|
||||
{item.project_title}
|
||||
</h3>
|
||||
<span className="text-[12px] text-gray-400 dark:text-gray-500 truncate mt-0.5">
|
||||
{item.id}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-[120px] shrink-0 flex items-center justify-start">
|
||||
{getStatusBadge(item.status)}
|
||||
</div>
|
||||
|
||||
<div className="w-[180px] shrink-0 pr-4 flex items-center">
|
||||
<div className="group/note relative text-xs text-gray-500 dark:text-gray-400">
|
||||
<div className="truncate cursor-default">
|
||||
{item.review_note || "-"}
|
||||
</div>
|
||||
|
||||
{item.review_note && (
|
||||
<div className="invisible opacity-0 group-hover/note:visible group-hover/note:opacity-100 absolute z-50 bottom-full left-1/2 -translate-x-1/2 mb-2 w-max max-w-[250px] p-2.5 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 text-xs rounded-lg shadow-xl whitespace-normal break-words border border-gray-100 dark:border-gray-700 transition-all duration-200">
|
||||
{item.review_note}
|
||||
<div className="absolute top-full left-1/2 -translate-x-1/2 border-[6px] border-transparent border-t-white dark:border-t-gray-800"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-[140px] shrink-0 flex items-center justify-end text-right text-xs text-gray-500 dark:text-[#8b949e]">
|
||||
<span>{formatDate(item.reviewed_at)}</span>
|
||||
</div>
|
||||
|
||||
<div className="w-[100px] shrink-0 flex items-center justify-center">
|
||||
<AvatarWithTooltip user={item.reviewer} />
|
||||
</div>
|
||||
|
||||
<div className="w-[140px] shrink-0 flex items-center justify-end text-right text-xs text-gray-500 dark:text-[#8b949e]">
|
||||
<span>{formatDate(item.created_at)}</span>
|
||||
</div>
|
||||
|
||||
<div className="w-[100px] shrink-0 flex items-center justify-end">
|
||||
<button
|
||||
onClick={() => onActionClick(item)}
|
||||
className="px-3 py-1.5 text-xs font-medium text-blue-600 bg-blue-50 hover:bg-blue-100 rounded-lg dark:bg-blue-900/30 dark:text-blue-400 dark:hover:bg-blue-900/50 transition-colors"
|
||||
>
|
||||
Đánh giá
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="p-10 text-center text-gray-500 dark:text-gray-400 italic">
|
||||
Không tìm thấy yêu cầu nào
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,8 @@ export default function AccountDetails({ data }: { data: UserMetaCardProps }) {
|
||||
});
|
||||
|
||||
const [showOldPass, setShowOldPass] = useState(false);
|
||||
const [showNewPass, setShowNewPass] = useState(false);
|
||||
const [showConfirmPass, setShowConfirmPass] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -32,6 +34,8 @@ export default function AccountDetails({ data }: { data: UserMetaCardProps }) {
|
||||
confirm_password: "",
|
||||
});
|
||||
setShowOldPass(false);
|
||||
setShowNewPass(false);
|
||||
setShowConfirmPass(false);
|
||||
setError("");
|
||||
}
|
||||
}, [isOpen]);
|
||||
@@ -74,10 +78,15 @@ export default function AccountDetails({ data }: { data: UserMetaCardProps }) {
|
||||
`${err?.response?.data?.message}, Cập nhật thất bại, vui lòng kiểm tra lại thông tin. Mật khẩu tối thiểu 8 ký tự, 1 in hoa, 1 số và 1 ký tự đặc biệt.` ||
|
||||
"Failed to update password. Please check your current password.",
|
||||
);
|
||||
toast.error(err?.response?.data?.message || "Cập nhật thất bại, vui lòng kiểm tra lại thông tin!");
|
||||
toast.error(
|
||||
err?.response?.data?.message ||
|
||||
"Cập nhật thất bại, vui lòng kiểm tra lại thông tin!",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const isPasswordMismatch =
|
||||
formValues.confirm_password.length > 0 &&
|
||||
formValues.new_password !== formValues.confirm_password;
|
||||
return (
|
||||
<>
|
||||
<div className="p-5 border border-red-200 rounded-2xl dark:border-gray-800 lg:p-6">
|
||||
@@ -149,7 +158,6 @@ export default function AccountDetails({ data }: { data: UserMetaCardProps }) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 2. Current Password - CÓ NÚT HIỆN MẬT KHẨU */}
|
||||
<div className="col-span-2 relative">
|
||||
<Label>Current Password</Label>
|
||||
<div className="relative">
|
||||
@@ -174,28 +182,63 @@ export default function AccountDetails({ data }: { data: UserMetaCardProps }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. New Password - LUÔN ẨN */}
|
||||
<div className="col-span-2 lg:col-span-1">
|
||||
<div className="col-span-2 relative">
|
||||
<Label>New Password</Label>
|
||||
<Input
|
||||
type="text"
|
||||
name="new_password"
|
||||
placeholder="Enter new password"
|
||||
defaultValue={formValues.new_password}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showNewPass ? "text" : "password"}
|
||||
name="new_password"
|
||||
placeholder="Enter new password"
|
||||
defaultValue={formValues.new_password}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewPass(!showNewPass)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
{showNewPass ? (
|
||||
<EyeCloseIcon className="w-5 h-5" />
|
||||
) : (
|
||||
<EyeIcon className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 4. Confirm New Password - LUÔN ẨN */}
|
||||
<div className="col-span-2 lg:col-span-1">
|
||||
<div className="col-span-2 relative">
|
||||
<Label>Confirm New Password</Label>
|
||||
<Input
|
||||
type="text"
|
||||
name="confirm_password"
|
||||
placeholder="Re-type new password"
|
||||
defaultValue={formValues.confirm_password}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showConfirmPass ? "text" : "password"}
|
||||
name="confirm_password"
|
||||
placeholder="Confirm new password"
|
||||
defaultValue={formValues.confirm_password}
|
||||
onChange={handleChange}
|
||||
// Thêm class viền đỏ ở đây
|
||||
className={
|
||||
isPasswordMismatch
|
||||
? "border-red-500 focus:border-red-500 dark:border-red-500"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPass(!showConfirmPass)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
{showConfirmPass ? (
|
||||
<EyeCloseIcon className="w-5 h-5" />
|
||||
) : (
|
||||
<EyeIcon className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{/* Thêm một dòng text báo lỗi nhỏ ngay dưới ô input nếu muốn */}
|
||||
{isPasswordMismatch && (
|
||||
<p className="mt-1 text-xs text-red-500">
|
||||
Mật khẩu không khớp!
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -211,7 +254,11 @@ export default function AccountDetails({ data }: { data: UserMetaCardProps }) {
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<Button size="sm" type="submit" className="bg-red-500 hover:bg-red-600 ">
|
||||
<Button
|
||||
size="sm"
|
||||
type="submit"
|
||||
className="bg-red-500 hover:bg-red-600 "
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -46,7 +46,7 @@ const processMedia = (mediaArray: any[]) => {
|
||||
return { type: "empty" };
|
||||
};
|
||||
|
||||
export default function ApplicationSquareCardList({
|
||||
export default function ApplicationList({
|
||||
applications,
|
||||
}: {
|
||||
applications: any[];
|
||||
@@ -56,9 +56,9 @@ export default function ApplicationSquareCardList({
|
||||
|
||||
const handleViewDetail = (app: any) => {
|
||||
dispatch(setSelectedApplication(app));
|
||||
router.push(`/profile/applications`);
|
||||
router.push(`/account/applications`);
|
||||
};
|
||||
// Tạo object mapping để render icon dựa trên status
|
||||
|
||||
const StatusIcons: Record<string, React.ReactNode> = {
|
||||
APPROVED: (
|
||||
<svg
|
||||
@@ -111,12 +111,21 @@ export default function ApplicationSquareCardList({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-5 border rounded-xl dark:border-zinc-800 lg:p-6 bg-white dark:bg-zinc-950">
|
||||
<h4 className="text-lg font-bold text-zinc-800 dark:text-white/90 mb-5 tracking-tight">
|
||||
Applications CV
|
||||
</h4>
|
||||
<div className="rounded-2xl border border-gray-200 bg-white p-6 dark:border-zinc-800 dark:bg-zinc-900/50">
|
||||
<div className="mb-8 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between border-b border-gray-100 pb-5 dark:border-zinc-800">
|
||||
<h3 className="text-xl font-bold text-gray-800 dark:text-white/90">
|
||||
Hồ sơ{" "}
|
||||
<span className="ml-2 text-sm font-normal text-gray-500">
|
||||
({applications.length} tệp)
|
||||
</span>
|
||||
</h3>
|
||||
{/* <div className="text-sm text-gray-500">
|
||||
Cập nhật lần cuối: {new Date().toLocaleDateString("vi-VN")}
|
||||
</div> */}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{/* CHỈ SỬA DÒNG NÀY: Tăng số lượng cột (grid-cols) để các thẻ nhỏ lại */}
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{applications?.map((app) => {
|
||||
const mediaState = processMedia(app.media);
|
||||
const config = statusConfig[app.status] || statusConfig.PENDING;
|
||||
@@ -125,77 +134,69 @@ export default function ApplicationSquareCardList({
|
||||
<div
|
||||
key={app.id}
|
||||
onClick={() => handleViewDetail(app)}
|
||||
className="group relative h-60 aspect-square border dark:border-zinc-800 rounded-xl cursor-pointer overflow-hidden transition-all duration-300 hover:ring-2 hover:ring-blue-500/50"
|
||||
className="group relative flex aspect-square w-full cursor-pointer flex-col overflow-hidden rounded-xl border border-gray-200 bg-gray-50 transition-all duration-300 hover:ring-2 hover:ring-blue-500/50 dark:border-zinc-700 dark:bg-zinc-800"
|
||||
>
|
||||
{/* Media Layer */}
|
||||
<div className="absolute inset-0 z-0">
|
||||
{mediaState.type === "image" ? (
|
||||
<img
|
||||
src={mediaState.src}
|
||||
alt=""
|
||||
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700"
|
||||
className="h-full w-full object-cover transition-transform duration-700 group-hover:scale-105"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-zinc-100 dark:bg-zinc-900 flex items-center justify-center p-4">
|
||||
<div className="flex h-full w-full items-center justify-center bg-gray-100 p-4 dark:bg-zinc-800/80">
|
||||
{mediaState.type === "documents" ? (
|
||||
<div className="flex flex-wrap gap-1 justify-center">
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{mediaState.extensions?.slice(0, 3).map((ext, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="text-[9px] font-black px-1.5 py-0.5 bg-white dark:bg-zinc-800 rounded border dark:border-zinc-700 uppercase"
|
||||
className="rounded bg-white px-2 py-1 text-xs font-bold uppercase text-gray-600 border border-gray-200 dark:border-zinc-600 dark:bg-zinc-700 dark:text-gray-200"
|
||||
>
|
||||
.{ext}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gradient-to-br from-zinc-400 to-zinc-600 opacity-20" />
|
||||
<div className="h-full w-full bg-gradient-to-br from-gray-200 to-gray-300 opacity-50 dark:from-zinc-600 dark:to-zinc-800" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Overlay Gradient */}
|
||||
<div className="absolute inset-0 z-10 bg-gradient-to-t from-black/90 via-black/20 to-transparent transition-opacity group-hover:opacity-90" />
|
||||
<div className="absolute inset-0 z-10 bg-gradient-to-t from-black/90 via-black/40 to-transparent opacity-80 transition-opacity duration-300 group-hover:opacity-100" />
|
||||
|
||||
{/* TOP INFO: FILE COUNT & STATUS ICON */}
|
||||
<div className="absolute top-2 left-2 right-2 z-20 flex justify-between items-center">
|
||||
<div className="absolute left-3 right-3 top-3 z-20 flex items-start justify-between">
|
||||
{app.media?.length > 0 ? (
|
||||
<span className="text-[9px] font-bold text-white/60 bg-black/40 px-1.5 py-0.5 rounded-md backdrop-blur-sm">
|
||||
{app.media.length} FILE
|
||||
<span className="rounded-md bg-black/60 px-2 py-1 text-[10px] font-bold tracking-wider text-white backdrop-blur-md">
|
||||
{app.media.length} TỆP
|
||||
</span>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
|
||||
{/* Status Icon Wrapper */}
|
||||
<div
|
||||
className={`${config.container} rounded-full`}
|
||||
>
|
||||
<div className={`${config.container} rounded-full `}>
|
||||
{StatusIcons[app.status] || StatusIcons.PENDING}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Content */}
|
||||
<div className="absolute bottom-2 left-2 right-2 z-20 text-white">
|
||||
<div className="mb-1">
|
||||
<p className="text-[10px] font-bold uppercase opacity-80 truncate">
|
||||
{app.verify_type || "VERIFY"}
|
||||
</p>
|
||||
{app?.reviewer?.display_name && (
|
||||
<p className="text-[9px] font-medium text-blue-400 truncate">
|
||||
By: {app.reviewer.display_name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute bottom-0 left-0 right-0 z-20 p-4 text-white">
|
||||
<p className="mb-1 truncate text-xs font-bold uppercase tracking-wider text-gray-300">
|
||||
{app.verify_type || "VERIFY"}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between pt-1 border-t border-white/10">
|
||||
<p className="text-[9px] font-bold text-white/70">
|
||||
{app?.reviewer?.display_name && (
|
||||
<p className="mb-3 truncate text-sm font-medium text-blue-300">
|
||||
Người duyệt: {app.reviewer.display_name}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between border-t border-white/20 pt-2">
|
||||
<p className="text-xs font-semibold text-gray-300">
|
||||
{formatFullDateTime(app.created_at)}
|
||||
</p>
|
||||
<div className="transform translate-x-2 opacity-0 group-hover:translate-x-0 group-hover:opacity-100 transition-all duration-300">
|
||||
|
||||
<div className="flex h-6 w-6 -translate-x-2 items-center justify-center rounded-full bg-white/20 opacity-0 backdrop-blur-sm transition-all duration-300 group-hover:translate-x-0 group-hover:opacity-100">
|
||||
<svg
|
||||
className="w-4 h-4 text-blue-400"
|
||||
className="h-3 w-3 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -204,7 +205,7 @@ export default function ApplicationSquareCardList({
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M13 7l5 5m0 0l-5 5m5-5H6"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
@@ -216,4 +217,4 @@ export default function ApplicationSquareCardList({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,71 +1,396 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
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 Swal from "sweetalert2";
|
||||
import { MediaDto } from "@/interface/media";
|
||||
import { URL_MEDIA } from "../../../api";
|
||||
|
||||
export default function MediaCard({ data }: { data: MediaDto }) {
|
||||
const [index, setIndex] = useState(-1);
|
||||
const listMedia = data?.data || [];
|
||||
import { deleteMedia } from "@/service/mediaService";
|
||||
import { INITIAL_LIMIT } from "../../../constant";
|
||||
|
||||
const slides = listMedia.map((item) => ({
|
||||
export default function MediaLibrary({
|
||||
data,
|
||||
onRefresh,
|
||||
}: {
|
||||
data: MediaDto;
|
||||
onRefresh?: () => void;
|
||||
}) {
|
||||
const [index, setIndex] = useState(-1);
|
||||
|
||||
const [showAllImages, setShowAllImages] = useState(false);
|
||||
const [showAllDocs, setShowAllDocs] = useState(false);
|
||||
|
||||
const [localMedia, setLocalMedia] = useState(data?.data || []);
|
||||
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalMedia(data?.data || []);
|
||||
}, [data]);
|
||||
|
||||
const isImageFile = (file: any) => {
|
||||
const isImageMime = file.mime_type?.startsWith("image/");
|
||||
const isImageExt = /\.(jpg|jpeg|png|webp|gif)$/i.test(file.storage_key);
|
||||
return isImageMime || isImageExt;
|
||||
};
|
||||
|
||||
const imageFiles = localMedia.filter(isImageFile);
|
||||
const documentFiles = localMedia.filter((file) => !isImageFile(file));
|
||||
|
||||
const displayedImages = showAllImages
|
||||
? imageFiles
|
||||
: imageFiles.slice(0, INITIAL_LIMIT);
|
||||
const displayedDocs = showAllDocs
|
||||
? documentFiles
|
||||
: documentFiles.slice(0, INITIAL_LIMIT);
|
||||
|
||||
const imageSlides = imageFiles.map((item) => ({
|
||||
src: `${URL_MEDIA}${item.storage_key}`,
|
||||
title: item.original_name,
|
||||
description: `Size: ${(item.size / 1024).toFixed(2)} KB - Type: ${item.mime_type}`,
|
||||
description: `Kích thước: ${(item.size / 1024).toFixed(2)} KB - Loại: ${item.mime_type}`,
|
||||
}));
|
||||
// console.log("slides", listMedia);
|
||||
return (
|
||||
<div className="p-5 border border-gray-200 rounded-2xl dark:border-gray-800 lg:p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-800 dark:text-white/90 lg:mb-6">Media Assets</h3>
|
||||
|
||||
<div className="flex flex-row gap-4 overflow-x-auto pb-4 scrollbar-hide">
|
||||
{listMedia.map((item, idx) => (
|
||||
<div
|
||||
key={item.id}
|
||||
onClick={() => setIndex(idx)}
|
||||
className="group relative min-w-[150px] h-[150px] cursor-pointer overflow-hidden rounded-lg border border-gray-200"
|
||||
>
|
||||
const toggleSelectionMode = () => {
|
||||
setIsSelectionMode(!isSelectionMode);
|
||||
if (isSelectionMode) {
|
||||
setSelectedIds([]);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleItemSelection = (id: string, e?: React.MouseEvent) => {
|
||||
if (e) e.stopPropagation();
|
||||
|
||||
if (!isSelectionMode) {
|
||||
setIsSelectionMode(true);
|
||||
setSelectedIds([id]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedIds.includes(id)) {
|
||||
setSelectedIds(selectedIds.filter((selectedId) => selectedId !== id));
|
||||
} else {
|
||||
setSelectedIds([...selectedIds, id]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleItemClick = (item: any, idx: number, isImage: boolean) => {
|
||||
if (isSelectionMode) {
|
||||
toggleItemSelection(item.id);
|
||||
} else {
|
||||
if (isImage) {
|
||||
setIndex(idx);
|
||||
} else {
|
||||
const fileUrl = `${URL_MEDIA}${item.storage_key}`;
|
||||
const googleDocsUrl = `https://docs.google.com/viewer?url=${encodeURIComponent(fileUrl)}&embedded=true`;
|
||||
window.open(googleDocsUrl, "_blank");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSelected = async () => {
|
||||
const result = await Swal.fire({
|
||||
title: "Xóa tệp đính kèm?",
|
||||
text: `Bạn chuẩn bị xóa ${selectedIds.length} tệp. Hành động này không thể hoàn tác!`,
|
||||
icon: "warning",
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: "#ef4444",
|
||||
cancelButtonColor: "#6b7280",
|
||||
confirmButtonText: "Xóa vĩnh viễn",
|
||||
cancelButtonText: "Hủy",
|
||||
});
|
||||
|
||||
if (result.isConfirmed) {
|
||||
try {
|
||||
await deleteMedia(selectedIds);
|
||||
setLocalMedia((prev) =>
|
||||
prev.filter((item) => !selectedIds.includes(item.id)),
|
||||
);
|
||||
setIsSelectionMode(false);
|
||||
setSelectedIds([]);
|
||||
Swal.fire("Thành công!", "Các tệp đã được xóa.", "success");
|
||||
if (onRefresh) onRefresh();
|
||||
} catch (error) {
|
||||
Swal.fire("Lỗi!", "Không thể xóa tệp, vui lòng thử lại.", "error");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteFromLightbox = async () => {
|
||||
const currentImage = imageFiles[index];
|
||||
if (!currentImage) return;
|
||||
|
||||
const result = await Swal.fire({
|
||||
title: "Xóa ảnh này?",
|
||||
text: "Bạn có chắc chắn muốn xóa ảnh này không?",
|
||||
icon: "warning",
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: "#ef4444",
|
||||
cancelButtonColor: "#6b7280",
|
||||
confirmButtonText: "Xóa",
|
||||
cancelButtonText: "Hủy",
|
||||
// customClass: { container: 'z-99999999999999999999999' }
|
||||
});
|
||||
|
||||
if (result.isConfirmed) {
|
||||
try {
|
||||
await deleteMedia([currentImage.id]);
|
||||
setLocalMedia((prev) =>
|
||||
prev.filter((item) => item.id !== currentImage.id),
|
||||
);
|
||||
|
||||
if (imageFiles.length === 1) {
|
||||
setIndex(-1);
|
||||
} else if (index >= imageFiles.length - 1) {
|
||||
setIndex(index - 1);
|
||||
}
|
||||
|
||||
Swal.fire({
|
||||
title: "Thành công!",
|
||||
text: "Ảnh đã được xóa.",
|
||||
icon: "success",
|
||||
customClass: { container: "z-[9999999999]" },
|
||||
});
|
||||
if (onRefresh) onRefresh();
|
||||
} catch (error) {
|
||||
Swal.fire({
|
||||
title: "Lỗi!",
|
||||
text: "Không thể xóa ảnh.",
|
||||
icon: "error",
|
||||
customClass: { container: "z-[9999999999]" },
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const renderItemCard = (item: any, isImage: boolean, idx: number) => {
|
||||
const isSelected = selectedIds.includes(item.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
onClick={() => handleItemClick(item, idx, isImage)}
|
||||
className={`group relative aspect-square cursor-pointer overflow-hidden rounded-xl border-2 transition-all ${
|
||||
isSelected
|
||||
? "border-blue-500 ring-2 ring-blue-500/30"
|
||||
: "border-gray-200 hover:ring-2 hover:ring-blue-500 hover:ring-offset-2 dark:border-zinc-700 dark:hover:ring-offset-zinc-900"
|
||||
} ${isImage ? "bg-gray-100 dark:bg-zinc-800" : "bg-gray-50 dark:bg-zinc-800"}`}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => toggleItemSelection(item.id, e)}
|
||||
className={`absolute right-2 top-2 z-30 flex h-6 w-6 items-center justify-center rounded-full border-2 transition-all duration-200 ${
|
||||
isSelected
|
||||
? "border-blue-500 bg-blue-500"
|
||||
: "border-white bg-black/40 opacity-0 group-hover:opacity-100"
|
||||
} ${isSelectionMode && !isSelected ? "opacity-100" : ""}`}
|
||||
>
|
||||
{isSelected && (
|
||||
<svg
|
||||
className="h-3.5 w-3.5 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={3}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isImage ? (
|
||||
<>
|
||||
<img
|
||||
src={`${URL_MEDIA}${item.storage_key}`}
|
||||
alt={item.original_name}
|
||||
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-110"
|
||||
className={`h-full w-full object-cover transition-transform duration-500 ${isSelected ? "scale-105 opacity-80" : "group-hover:scale-110"}`}
|
||||
/>
|
||||
|
||||
<div className="absolute inset-0 flex items-end bg-black/40 p-2 opacity-0 transition-opacity duration-300 group-hover:opacity-100">
|
||||
<p className="w-full truncate text-xs text-white">
|
||||
{item.original_name}
|
||||
</p>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent opacity-0 transition-opacity duration-300 group-hover:opacity-100">
|
||||
<div className="absolute bottom-0 w-full p-3 text-white">
|
||||
<p className="truncate text-sm font-medium ">
|
||||
{item.original_name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-300">
|
||||
{(item.size / 1024).toFixed(0)} KB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{!isSelectionMode && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-black/40 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<span className="rounded-full border border-white/30 bg-white/20 px-3 py-1 text-xs font-bold text-white backdrop-blur-md">
|
||||
Xem ảnh
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={`flex h-full w-full flex-col items-center justify-center p-3 text-center transition-opacity ${isSelected ? "opacity-80" : ""}`}
|
||||
>
|
||||
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-lg bg-white text-2xl dark:bg-zinc-700">
|
||||
📄
|
||||
</div>
|
||||
<span className="line-clamp-2 px-2 text-xs font-medium text-gray-600 dark:text-gray-300">
|
||||
{item.original_name}
|
||||
</span>
|
||||
</div>
|
||||
{!isSelectionMode && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-black/40 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<span className="rounded-full border border-white/30 bg-white/20 px-3 py-1 text-xs font-bold text-white backdrop-blur-md">
|
||||
Xem file
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-gray-200 bg-white p-6 dark:border-zinc-800 dark:bg-zinc-900/50">
|
||||
<div className="mb-6 flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
|
||||
<h3 className="text-xl font-bold text-gray-800 dark:text-white/90">
|
||||
Media Assets
|
||||
<span className="ml-2 text-sm font-normal text-gray-500">
|
||||
({localMedia.length} tệp)
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{selectedIds.length > 0 && (
|
||||
<button
|
||||
onClick={handleDeleteSelected}
|
||||
className="flex items-center gap-1 rounded-lg bg-red-50 px-4 py-2 text-sm font-medium text-red-600 transition-colors hover:bg-red-100 dark:bg-red-500/10 dark:hover:bg-red-500/20"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
Xóa ({selectedIds.length})
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={toggleSelectionMode}
|
||||
className={`rounded-lg px-4 py-2 text-sm font-medium transition-all w-17.5 h-10 ${
|
||||
isSelectionMode
|
||||
? "bg-blue-500 text-white shadow-md hover:bg-blue-600"
|
||||
: "border border-gray-200 bg-gray-50 text-gray-700 hover:bg-gray-100 dark:border-zinc-700 dark:bg-zinc-800 dark:text-gray-200 dark:hover:bg-zinc-700"
|
||||
}`}
|
||||
>
|
||||
{isSelectionMode ? "Hủy" : "Chọn"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-10">
|
||||
{imageFiles.length > 0 && (
|
||||
<div>
|
||||
<h4 className="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-500">
|
||||
Hình ảnh ({imageFiles.length})
|
||||
</h4>
|
||||
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8">
|
||||
{" "}
|
||||
{displayedImages.map((item, idx) =>
|
||||
renderItemCard(item, true, idx),
|
||||
)}
|
||||
</div>
|
||||
{imageFiles.length > INITIAL_LIMIT && (
|
||||
<div className="mt-6 flex justify-center">
|
||||
<button
|
||||
onClick={() => setShowAllImages(!showAllImages)}
|
||||
className="rounded-lg border border-gray-200 bg-gray-50 px-5 py-2 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 dark:border-zinc-700 dark:bg-zinc-800 dark:text-gray-300"
|
||||
>
|
||||
{showAllImages
|
||||
? "Thu gọn"
|
||||
: `Xem thêm ${imageFiles.length - INITIAL_LIMIT} hình ảnh`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
|
||||
{documentFiles.length > 0 && (
|
||||
<div>
|
||||
<h4 className="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-500">
|
||||
Tài liệu ({documentFiles.length})
|
||||
</h4>
|
||||
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8">
|
||||
|
||||
{displayedDocs.map((item, idx) =>
|
||||
renderItemCard(item, false, idx),
|
||||
)}
|
||||
</div>
|
||||
{documentFiles.length > INITIAL_LIMIT && (
|
||||
<div className="mt-6 flex justify-center">
|
||||
<button
|
||||
onClick={() => setShowAllDocs(!showAllDocs)}
|
||||
className="rounded-lg border border-gray-200 bg-gray-50 px-5 py-2 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 dark:border-zinc-700 dark:bg-zinc-800 dark:text-gray-300"
|
||||
>
|
||||
{showAllDocs
|
||||
? "Thu gọn"
|
||||
: `Xem thêm ${documentFiles.length - INITIAL_LIMIT} tài liệu`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Lightbox
|
||||
index={index}
|
||||
open={index >= 0}
|
||||
close={() => setIndex(-1)}
|
||||
slides={slides}
|
||||
slides={imageSlides}
|
||||
plugins={[Zoom, Captions]}
|
||||
zoom={{
|
||||
maxZoomPixelRatio: 10,
|
||||
zoomInMultiplier: 2,
|
||||
doubleTapDelay: 300,
|
||||
doubleClickDelay: 300,
|
||||
doubleClickMaxStops: 2,
|
||||
keyboardMoveDistance: 50,
|
||||
wheelZoomDistanceFactor: 100,
|
||||
pinchZoomDistanceFactor: 100,
|
||||
toolbar={{
|
||||
buttons: [
|
||||
<button
|
||||
key="delete"
|
||||
type="button"
|
||||
className="yarl__button text-red-500"
|
||||
title="Xóa ảnh này"
|
||||
onClick={handleDeleteFromLightbox}
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>,
|
||||
"zoom",
|
||||
"close",
|
||||
],
|
||||
}}
|
||||
zoom={{ maxZoomPixelRatio: 3 }}
|
||||
animation={{ zoom: 200 }}
|
||||
styles={{
|
||||
root: {
|
||||
zIndex: 999999999,
|
||||
"--yarl__color_backdrop": "rgba(0, 0, 0, 0.85)",
|
||||
},
|
||||
root: { zIndex: 99, "--yarl__color_backdrop": "rgba(0, 0, 0, 0.9)" },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { uploadMedia } from "@/service/mediaService";
|
||||
import { toast } from "sonner";
|
||||
import { apiUpdateUser } from "@/service/userService";
|
||||
import { URL_MEDIA } from "../../../api";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function UserMetaCard({ data }: { data: UserMetaCardProps }) {
|
||||
const currentAvatar =
|
||||
@@ -15,6 +16,8 @@ export default function UserMetaCard({ data }: { data: UserMetaCardProps }) {
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (data.data?.profile?.avatar_url) {
|
||||
setPreviewImage(data.data.profile.avatar_url);
|
||||
@@ -49,15 +52,11 @@ export default function UserMetaCard({ data }: { data: UserMetaCardProps }) {
|
||||
if (data.data) {
|
||||
try {
|
||||
await apiUpdateUser({ avatar_url: url });
|
||||
window.location.href = window.location.pathname;
|
||||
toast.success("Cập nhật avatar thành công!");
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
console.error("Lỗi khi cập nhật avatar:", error);
|
||||
toast.warning(
|
||||
"Ảnh đã được tải lên nhưng không thể cập nhật hồ sơ. Vui lòng thử lại!",
|
||||
);
|
||||
toast.warning("Lỗi khi cập nhật ảnh đại diện. Vui lòng thử lại!");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -160,6 +159,9 @@ export default function UserMetaCard({ data }: { data: UserMetaCardProps }) {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
ID: {data.data?.id}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
/**
|
||||
* Role-Based Access Control Configuration
|
||||
* Định nghĩa routes theo role và quyền truy cập
|
||||
*/
|
||||
|
||||
export type UserRole = "ADMIN" | "MOD" | "HISTORIAN" | "USER"
|
||||
|
||||
export interface RouteConfig {
|
||||
path: string
|
||||
allowedRoles: UserRole[]
|
||||
label?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Public routes - không cần authentication
|
||||
*/
|
||||
export const PUBLIC_ROUTES = [
|
||||
"/signin",
|
||||
"/signup",
|
||||
"/reset-password",
|
||||
"/error-403",
|
||||
"/error-404",
|
||||
"/error-500",
|
||||
]
|
||||
|
||||
|
||||
export const PROTECTED_ROUTES: RouteConfig[] = [
|
||||
// Dashboard
|
||||
{
|
||||
path: "/",
|
||||
allowedRoles: ["ADMIN", "MOD", "HISTORIAN", "USER"],
|
||||
label: "Dashboard",
|
||||
},
|
||||
|
||||
// Admin/MOD + Historian routes
|
||||
{
|
||||
path: "/user-table",
|
||||
allowedRoles: ["ADMIN", "MOD"],
|
||||
label: "User Management",
|
||||
},
|
||||
{
|
||||
path: "/applications-tables",
|
||||
allowedRoles: ["ADMIN", "MOD"],
|
||||
label: "Applications",
|
||||
},
|
||||
|
||||
// All authenticated users
|
||||
{
|
||||
path: "/profile",
|
||||
allowedRoles: ["ADMIN", "MOD", "HISTORIAN", "USER"],
|
||||
label: "User Profile",
|
||||
},
|
||||
{
|
||||
path: "/calendar",
|
||||
allowedRoles: ["ADMIN", "MOD", "HISTORIAN", "USER"],
|
||||
label: "Calendar",
|
||||
},
|
||||
{
|
||||
path: "/line-chart",
|
||||
allowedRoles: ["ADMIN", "MOD", "HISTORIAN", "USER"],
|
||||
label: "Line Chart",
|
||||
},
|
||||
{
|
||||
path: "/bar-chart",
|
||||
allowedRoles: ["ADMIN", "MOD", "HISTORIAN", "USER"],
|
||||
label: "Bar Chart",
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Kiểm tra user role có được phép truy cập route không
|
||||
*/
|
||||
export const canAccessRoute = (
|
||||
userRoles: UserRole[] | undefined,
|
||||
routePath: string
|
||||
): boolean => {
|
||||
// Nếu không có role, không được truy cập
|
||||
if (!userRoles || userRoles.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Public routes không cần check
|
||||
if (PUBLIC_ROUTES.includes(routePath)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Tìm route config theo độ dài path giảm dần để tránh route '/' khớp với tất cả
|
||||
const routeConfig = PROTECTED_ROUTES
|
||||
.slice()
|
||||
.sort((a, b) => b.path.length - a.path.length)
|
||||
.find(
|
||||
(route) =>
|
||||
routePath === route.path ||
|
||||
(route.path !== "/" && routePath.startsWith(route.path + "/"))
|
||||
)
|
||||
|
||||
// Nếu route không được định nghĩa, cho phép (mặc định)
|
||||
if (!routeConfig) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check xem user có role được phép không
|
||||
return userRoles.some((role) => routeConfig.allowedRoles.includes(role))
|
||||
}
|
||||
|
||||
/**
|
||||
* Lấy danh sách routes cho user role
|
||||
*/
|
||||
export const getAvailableRoutes = (userRoles: UserRole[] | undefined): RouteConfig[] => {
|
||||
if (!userRoles || userRoles.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
return PROTECTED_ROUTES.filter((route) =>
|
||||
userRoles.some((role) => route.allowedRoles.includes(role))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Kiểm tra user có role nào không
|
||||
*/
|
||||
export const hasRole = (userRoles: UserRole[] | undefined, role: UserRole): boolean => {
|
||||
return userRoles?.includes(role) ?? false
|
||||
}
|
||||
|
||||
/**
|
||||
* Kiểm tra user có ít nhất một trong các role không
|
||||
*/
|
||||
export const hasAnyRole = (
|
||||
userRoles: UserRole[] | undefined,
|
||||
roles: UserRole[]
|
||||
): boolean => {
|
||||
return userRoles?.some((role) => roles.includes(role)) ?? false
|
||||
}
|
||||
|
||||
/**
|
||||
* Kiểm tra user có tất cả các role không
|
||||
*/
|
||||
export const hasAllRoles = (
|
||||
userRoles: UserRole[] | undefined,
|
||||
roles: UserRole[]
|
||||
): boolean => {
|
||||
return roles.every((role) => userRoles?.includes(role))
|
||||
}
|
||||
@@ -51,6 +51,7 @@ export const SidebarProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
const toggleSidebar = () => {
|
||||
setIsExpanded((prev) => !prev);
|
||||
};
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { useAppSelector } from '@/store/store';
|
||||
|
||||
import {
|
||||
canAccessRoute,
|
||||
hasRole,
|
||||
hasAnyRole,
|
||||
hasAllRoles,
|
||||
getAvailableRoutes,
|
||||
UserRole,
|
||||
} from "@/config/routes.config"
|
||||
|
||||
/**
|
||||
* Hook để kiểm tra authentication và authorization
|
||||
*/
|
||||
export const useAuth = () => {
|
||||
const user = useAppSelector((state) => state.user.data)
|
||||
const isAuthenticated = useAppSelector((state) => state.user.isAuthenticated)
|
||||
|
||||
const userRoles = user?.roles?.map((r) => r.name) as UserRole[] | undefined
|
||||
|
||||
return {
|
||||
user,
|
||||
isAuthenticated,
|
||||
userRoles,
|
||||
|
||||
/**
|
||||
* Kiểm tra user có role cụ thể không
|
||||
*/
|
||||
hasRole: (role: UserRole) => hasRole(userRoles, role),
|
||||
|
||||
/**
|
||||
* Kiểm tra user có ít nhất một trong các role không
|
||||
*/
|
||||
hasAnyRole: (roles: UserRole[]) => hasAnyRole(userRoles, roles),
|
||||
|
||||
/**
|
||||
* Kiểm tra user có tất cả các role không
|
||||
*/
|
||||
hasAllRoles: (roles: UserRole[]) => hasAllRoles(userRoles, roles),
|
||||
|
||||
/**
|
||||
* Kiểm tra user có quyền truy cập route không
|
||||
*/
|
||||
canAccessRoute: (path: string) => canAccessRoute(userRoles, path),
|
||||
|
||||
/**
|
||||
* Kiểm tra user là admin
|
||||
*/
|
||||
isAdmin: () => hasRole(userRoles, "ADMIN"),
|
||||
|
||||
/**
|
||||
* Kiểm tra user là moderator
|
||||
*/
|
||||
isModerator: () => hasRole(userRoles, "MOD"),
|
||||
|
||||
/**
|
||||
* Kiểm tra user là historian
|
||||
*/
|
||||
isHistorian: () => hasRole(userRoles, "HISTORIAN"),
|
||||
|
||||
/**
|
||||
* Lấy danh sách routes user có thể truy cập
|
||||
*/
|
||||
getAvailableRoutes: () => getAvailableRoutes(userRoles),
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { useDispatch } from "react-redux"
|
||||
import { setUserData, clearUserData } from "@/store/features/userSlice"
|
||||
import { getUserFromCookie } from "@/lib/cookieStorage"
|
||||
|
||||
export const useRestoreUserData = () => {
|
||||
const dispatch = useDispatch()
|
||||
|
||||
useEffect(() => {
|
||||
const userData = getUserFromCookie()
|
||||
if (userData) {
|
||||
dispatch(setUserData(userData))
|
||||
} else {
|
||||
dispatch(clearUserData())
|
||||
}
|
||||
}, [dispatch])
|
||||
}
|
||||
29
src/interface/common.ts
Normal file
29
src/interface/common.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export interface CommonResponse<T = any> {
|
||||
status: boolean;
|
||||
message: string;
|
||||
data: T;
|
||||
errors?: any; // Or a more specific error type
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
status: boolean;
|
||||
message: string;
|
||||
data: T[];
|
||||
pagination: {
|
||||
current_page: number;
|
||||
page_size: number;
|
||||
total_records: number;
|
||||
total_pages: number;
|
||||
};
|
||||
errors?: any;
|
||||
}
|
||||
|
||||
export interface CursorPaginatedResponse<T> {
|
||||
status: boolean;
|
||||
message: string;
|
||||
data: {
|
||||
items: T[];
|
||||
next_cursor_id?: string;
|
||||
};
|
||||
errors?: any;
|
||||
}
|
||||
66
src/interface/project.ts
Normal file
66
src/interface/project.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
export interface Project {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
project_status: "PRIVATE" | "PUBLIC" | "ARCHIVE";
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
is_deleted?: boolean;
|
||||
user_id?: string;
|
||||
user?: {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name: string;
|
||||
avatar_url: string;
|
||||
};
|
||||
commits?: any[];
|
||||
submission_ids?: any[];
|
||||
members?: ProjectMember[];
|
||||
}
|
||||
export interface ProjectsResponse<T = Project> {
|
||||
status: boolean;
|
||||
message: string;
|
||||
data: T[];
|
||||
pagination: {
|
||||
current_page: number;
|
||||
page_size: number;
|
||||
total_records: number;
|
||||
total_pages: number;
|
||||
};
|
||||
}
|
||||
export interface UpdateProjectPayload {
|
||||
title: string;
|
||||
description: string;
|
||||
status: "PRIVATE" | "PUBLIC" | "ARCHIVE";
|
||||
}
|
||||
export interface ChangeOwnerPayload {
|
||||
new_owner_id: string;
|
||||
}
|
||||
export interface ProjectMemberPayload {
|
||||
user_id?: string;
|
||||
role: "EDITOR" | "VIEWER" | "ADMIN";
|
||||
}
|
||||
export interface ProjectMember {
|
||||
user_id: string;
|
||||
role: string;
|
||||
display_name: string;
|
||||
avatar_url: string;
|
||||
}
|
||||
export interface GetProjectsParams {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
sort?: "created_at" | "updated_at" | "title";
|
||||
order?: "asc" | "desc";
|
||||
statuses?: string; // comma-separated
|
||||
user_ids?: string; // comma-separated
|
||||
created_from?: string; // ISO date string
|
||||
created_to?: string; // ISO date string
|
||||
}
|
||||
export interface CreateCommitPayload {
|
||||
edit_summary: string;
|
||||
snapshot_json: number[];
|
||||
}
|
||||
export interface RestoreCommitPayload {
|
||||
commit_id: string;
|
||||
}
|
||||
28
src/interface/statistics.ts
Normal file
28
src/interface/statistics.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export interface StatisticResponse {
|
||||
id: string;
|
||||
date: string;
|
||||
total_users: number;
|
||||
total_projects: number;
|
||||
total_commits: number;
|
||||
total_submissions: number;
|
||||
total_medias: number;
|
||||
total_wikis: number;
|
||||
total_entities: number;
|
||||
total_geometries: number;
|
||||
total_storage_bytes: number;
|
||||
new_users: number;
|
||||
new_projects: number;
|
||||
new_commits: number;
|
||||
new_submissions: number;
|
||||
new_medias: number;
|
||||
new_wikis: number;
|
||||
new_entities: number;
|
||||
new_geometries: number;
|
||||
new_storage_bytes: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface GetStatisticsParams {
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
}
|
||||
20
src/interface/submission.ts
Normal file
20
src/interface/submission.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export interface getSubmissionPayload {
|
||||
created_from: string;
|
||||
created_to: string;
|
||||
limit: number;
|
||||
order: "asc" | "desc";
|
||||
page: number;
|
||||
search: string;
|
||||
sort: "id" | "status" | "created_at" | "reviewed_at";
|
||||
project_id: string;
|
||||
reviewed_by: string;
|
||||
statuses: string[];
|
||||
user_ids: string[];
|
||||
}
|
||||
|
||||
export interface updateSubmissionPayload {
|
||||
review_note: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
"use client";
|
||||
import { ThemeToggleButton } from "@/components/common/ThemeToggleButton";
|
||||
import NotificationDropdown from "@/components/header/NotificationDropdown";
|
||||
import UserDropdown from "@/components/header/UserDropdown";
|
||||
import { useSidebar } from "@/context/SidebarContext";
|
||||
import Image from "next/image";
|
||||
@@ -90,6 +88,7 @@ const AppHeader: React.FC = () => {
|
||||
className="dark:hidden"
|
||||
src="./images/logo/logo.svg"
|
||||
alt="Logo"
|
||||
style={{ height: 'auto' }}
|
||||
/>
|
||||
<Image
|
||||
width={154}
|
||||
@@ -97,6 +96,7 @@ const AppHeader: React.FC = () => {
|
||||
className="hidden dark:block"
|
||||
src="./images/logo/logo.svg"
|
||||
alt="Logo"
|
||||
style={{ height: 'auto' }}
|
||||
/>
|
||||
</Link>
|
||||
|
||||
@@ -144,10 +144,10 @@ const AppHeader: React.FC = () => {
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Search or type command..."
|
||||
className="dark:bg-dark-900 h-11 w-full rounded-lg border border-gray-200 bg-transparent py-2.5 pl-12 pr-14 text-sm text-gray-800 shadow-theme-xs placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:bg-gray-900 dark:bg-white/[0.03] dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800 xl:w-[430px]"
|
||||
className="dark:bg-dark-900 h-11 w-full rounded-lg border border-gray-200 bg-transparent py-2.5 pl-12 pr-14 text-sm text-gray-800 shadow-theme-xs placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800 xl:w-[430px]"
|
||||
/>
|
||||
|
||||
<button className="absolute right-2.5 top-1/2 inline-flex -translate-y-1/2 items-center gap-0.5 rounded-lg border border-gray-200 bg-gray-50 px-[7px] py-[4.5px] text-xs -tracking-[0.2px] text-gray-500 dark:border-gray-800 dark:bg-white/[0.03] dark:text-gray-400">
|
||||
<button className="absolute right-2.5 top-1/2 inline-flex -translate-y-1/2 items-center gap-0.5 rounded-lg border border-gray-200 bg-gray-50 px-[7px] py-[4.5px] text-xs -tracking-[0.2px] text-gray-500 dark:border-gray-800 dark:bg-white/3 dark:text-gray-400">
|
||||
<span> ⌘ </span>
|
||||
<span> K </span>
|
||||
</button>
|
||||
@@ -162,10 +162,10 @@ const AppHeader: React.FC = () => {
|
||||
>
|
||||
<div className="flex items-center gap-2 2xsm:gap-3">
|
||||
{/* <!-- Dark Mode Toggler --> */}
|
||||
<ThemeToggleButton />
|
||||
{/* <ThemeToggleButton /> */}
|
||||
{/* <!-- Dark Mode Toggler --> */}
|
||||
|
||||
<NotificationDropdown />
|
||||
{/* <NotificationDropdown /> */}
|
||||
{/* <!-- Notification Menu Area --> */}
|
||||
</div>
|
||||
{/* <!-- User Area --> */}
|
||||
@@ -178,3 +178,4 @@ const AppHeader: React.FC = () => {
|
||||
};
|
||||
|
||||
export default AppHeader;
|
||||
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
"use client";
|
||||
import React, { useEffect, useRef, useState, useCallback } from "react";
|
||||
import React, { useEffect, useRef, useState, useCallback, useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useSelector } from "react-redux";
|
||||
import { RootState } from "@/store/store";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import {
|
||||
BoxCubeIcon,
|
||||
CalenderIcon,
|
||||
ChevronDownIcon,
|
||||
FileIcon,
|
||||
GridIcon,
|
||||
HorizontaLDots,
|
||||
ListIcon,
|
||||
@@ -17,59 +20,76 @@ import {
|
||||
TableIcon,
|
||||
UserCircleIcon,
|
||||
} from "../icons/index";
|
||||
import SidebarWidget from "./SidebarWidget";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { canAccessRoute, PUBLIC_ROUTES } from "@/config/routes.config";
|
||||
|
||||
type RoleName = "ADMIN" | "MOD" | "USER" | "HISTORIAN";
|
||||
|
||||
type NavItem = {
|
||||
name: string;
|
||||
icon: React.ReactNode;
|
||||
path?: string;
|
||||
subItems?: { name: string; path: string; pro?: boolean; new?: boolean }[];
|
||||
roles?: RoleName[];
|
||||
subItems?: {
|
||||
name: string;
|
||||
path: string;
|
||||
pro?: boolean;
|
||||
new?: boolean;
|
||||
roles?: RoleName[];
|
||||
}[];
|
||||
};
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
const ALL_NAV_ITEMS: NavItem[] = [
|
||||
{
|
||||
icon: <GridIcon />,
|
||||
name: "Dashboard",
|
||||
subItems: [{ name: "Ecommerce", path: "/", pro: false }],
|
||||
name: "Trang Chủ",
|
||||
// subItems: [{ name: "Ecommerce", path: "/", pro: false }],
|
||||
path: "/",
|
||||
},
|
||||
// {
|
||||
// icon: <CalenderIcon />,
|
||||
// name: "Calendar",
|
||||
// path: "/calendar",
|
||||
// },
|
||||
{
|
||||
icon: <CalenderIcon />,
|
||||
name: "Calendar",
|
||||
path: "/calendar",
|
||||
icon: <FileIcon />,
|
||||
name: "Thư Viện",
|
||||
path: "/library",
|
||||
},
|
||||
// {
|
||||
// name: "Forms",
|
||||
// icon: <ListIcon />,
|
||||
// subItems: [
|
||||
// // { name: "Form Elements", path: "/form-elements", pro: false },
|
||||
// { name: "Role Upgrade", path: "/role-upgrade", pro: false }
|
||||
// ],
|
||||
// },
|
||||
{
|
||||
name: "Quản lý",
|
||||
icon: <TableIcon />,
|
||||
subItems: [
|
||||
// { name: "Basic Tables", path: "/basic-tables", pro: false },
|
||||
{ name: "Tài Khoản", path: "/user-information", pro: false, roles: ["ADMIN", "MOD"] },
|
||||
{ name: "Hồ Sơ Nhà Sử Học", path: "/applications", pro: false, roles: ["ADMIN", "MOD"] },
|
||||
{ name: "Tệp Đăng Tải", path: "/assets", pro: false, roles: ["ADMIN", "MOD"] },
|
||||
{ name: "Dự Án", path: "/project", pro: false, roles: ["ADMIN", "MOD"] },
|
||||
{ name: "Duyệt Nội Dung", path: "/submissions", pro: false, roles: ["ADMIN", "MOD"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <UserCircleIcon />,
|
||||
name: "User Profile",
|
||||
path: "/profile",
|
||||
},
|
||||
|
||||
{
|
||||
name: "Forms",
|
||||
icon: <ListIcon />,
|
||||
subItems: [{ name: "Form Elements", path: "/form-elements", pro: false }, { name: "Role Upgrade", path: "/role-upgrade", pro: false }],
|
||||
},
|
||||
{
|
||||
name: "Tables",
|
||||
icon: <TableIcon />,
|
||||
subItems: [
|
||||
{ name: "Basic Tables", path: "/basic-tables", pro: false },
|
||||
{ name: "User Tables", path: "/user-table", pro: false },
|
||||
{ name: "Applications Tables", path: "/applications-tables", pro: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Pages",
|
||||
icon: <PageIcon />,
|
||||
subItems: [
|
||||
{ name: "Blank Page", path: "/blank", pro: false },
|
||||
{ name: "404 Error", path: "/error-404", pro: false },
|
||||
],
|
||||
name: "Tài Khoản",
|
||||
path: "/account",
|
||||
},
|
||||
// {
|
||||
// name: "Pages",
|
||||
// icon: <PageIcon />,
|
||||
// subItems: [
|
||||
// { name: "Blank Page", path: "/blank", pro: false },
|
||||
// { name: "404 Error", path: "/error-404", pro: false },
|
||||
// ],
|
||||
// },
|
||||
];
|
||||
|
||||
const othersItems: NavItem[] = [
|
||||
const OTHERS_ITEMS: NavItem[] = [
|
||||
{
|
||||
icon: <PieChartIcon />,
|
||||
name: "Charts",
|
||||
@@ -103,98 +123,109 @@ const othersItems: NavItem[] = [
|
||||
const AppSidebar: React.FC = () => {
|
||||
const { isExpanded, isMobileOpen, isHovered, setIsHovered } = useSidebar();
|
||||
const pathname = usePathname();
|
||||
const { userRoles } = useAuth();
|
||||
|
||||
const hasAccess = (path?: string) => {
|
||||
if (!path) return true;
|
||||
if (!userRoles || userRoles.length === 0) {
|
||||
return PUBLIC_ROUTES.includes(path) || ["/", "/profile", "/calendar"].includes(path);
|
||||
}
|
||||
return canAccessRoute(userRoles, path);
|
||||
const rolesData = useSelector((state: RootState) => state.user.data?.roles);
|
||||
|
||||
const userRoles = useMemo(() => {
|
||||
return rolesData?.map((r: any) => r.name) || [];
|
||||
}, [rolesData]);
|
||||
|
||||
const filterMenuByRole = useCallback((items: NavItem[]) => {
|
||||
return items
|
||||
.map((item) => ({
|
||||
...item,
|
||||
subItems: item.subItems?.filter((sub) => {
|
||||
if (!sub.roles) return true;
|
||||
return sub.roles.some((role) => userRoles.includes(role));
|
||||
}),
|
||||
}))
|
||||
.filter((item) => {
|
||||
if (item.roles && !item.roles.some((role) => userRoles.includes(role))) return false;
|
||||
if (item.subItems && item.subItems.length === 0 && !item.path) return false;
|
||||
return true;
|
||||
});
|
||||
}, [userRoles]);
|
||||
|
||||
const filteredNavItems = useMemo(() => filterMenuByRole(ALL_NAV_ITEMS), [filterMenuByRole]);
|
||||
const filteredOthersItems = useMemo(() => filterMenuByRole(OTHERS_ITEMS), [filterMenuByRole]);
|
||||
|
||||
const [openSubmenu, setOpenSubmenu] = useState<{
|
||||
type: "main" | "others";
|
||||
index: number;
|
||||
} | null>(null);
|
||||
const [subMenuHeight, setSubMenuHeight] = useState<Record<string, number>>({});
|
||||
const subMenuRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
|
||||
const isActive = useCallback((path: string) => path === pathname, [pathname]);
|
||||
|
||||
const handleSubmenuToggle = (index: number, menuType: "main" | "others") => {
|
||||
setOpenSubmenu((prev) =>
|
||||
prev?.type === menuType && prev?.index === index ? null : { type: menuType, index }
|
||||
);
|
||||
};
|
||||
|
||||
const buildNavItems = (items: NavItem[]) =>
|
||||
items
|
||||
.map((nav) => {
|
||||
if (nav.path && !hasAccess(nav.path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (nav.subItems) {
|
||||
const filteredItems = nav.subItems.filter((subItem) =>
|
||||
hasAccess(subItem.path),
|
||||
);
|
||||
if (filteredItems.length === 0) {
|
||||
return null;
|
||||
useEffect(() => {
|
||||
let submenuMatched = false;
|
||||
[
|
||||
{ items: filteredNavItems, type: "main" },
|
||||
{ items: filteredOthersItems, type: "others" },
|
||||
].forEach(({ items, type }) => {
|
||||
items.forEach((nav, index) => {
|
||||
nav.subItems?.forEach((sub) => {
|
||||
if (isActive(sub.path)) {
|
||||
setOpenSubmenu((prev) => {
|
||||
if (prev?.type === type && prev?.index === index) return prev;
|
||||
return { type: type as "main" | "others", index };
|
||||
});
|
||||
submenuMatched = true;
|
||||
}
|
||||
return { ...nav, subItems: filteredItems };
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return nav;
|
||||
})
|
||||
.filter((item): item is NavItem => item !== null);
|
||||
if (!submenuMatched) {
|
||||
setOpenSubmenu((prev) => (prev !== null ? null : prev));
|
||||
}
|
||||
}, [pathname, isActive, filteredNavItems, filteredOthersItems]);
|
||||
|
||||
const filteredMainNavItems = buildNavItems(navItems);
|
||||
const filteredOthersNavItems = buildNavItems(othersItems);
|
||||
useEffect(() => {
|
||||
if (openSubmenu !== null) {
|
||||
const key = `${openSubmenu.type}-${openSubmenu.index}`;
|
||||
if (subMenuRefs.current[key]) {
|
||||
setSubMenuHeight((prev) => ({
|
||||
...prev,
|
||||
[key]: subMenuRefs.current[key]?.scrollHeight || 0,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}, [openSubmenu]);
|
||||
|
||||
const renderMenuItems = (
|
||||
navItems: NavItem[],
|
||||
menuType: "main" | "others",
|
||||
) => (
|
||||
const renderMenuItems = (items: NavItem[], menuType: "main" | "others") => (
|
||||
<ul className="flex flex-col gap-4">
|
||||
{navItems.map((nav, index) => (
|
||||
{items.map((nav, index) => (
|
||||
<li key={nav.name}>
|
||||
{nav.subItems ? (
|
||||
<button
|
||||
onClick={() => handleSubmenuToggle(index, menuType)}
|
||||
className={`menu-item group ${
|
||||
className={`menu-item group uppercase ${
|
||||
openSubmenu?.type === menuType && openSubmenu?.index === index
|
||||
? "menu-item-active"
|
||||
: "menu-item-inactive"
|
||||
} cursor-pointer ${
|
||||
!isExpanded && !isHovered
|
||||
? "lg:justify-center"
|
||||
: "lg:justify-start"
|
||||
}`}
|
||||
? "menu-item-active" : "menu-item-inactive"
|
||||
} cursor-pointer ${!isExpanded && !isHovered ? "lg:justify-center" : "lg:justify-start"}`}
|
||||
>
|
||||
<span
|
||||
className={` ${
|
||||
openSubmenu?.type === menuType && openSubmenu?.index === index
|
||||
? "menu-item-icon-active"
|
||||
: "menu-item-icon-inactive"
|
||||
}`}
|
||||
>
|
||||
<span className={openSubmenu?.type === menuType && openSubmenu?.index === index ? "menu-item-icon-active" : "menu-item-icon-inactive"}>
|
||||
{nav.icon}
|
||||
</span>
|
||||
{(isExpanded || isHovered || isMobileOpen) && (
|
||||
<span className={`menu-item-text`}>{nav.name}</span>
|
||||
)}
|
||||
{(isExpanded || isHovered || isMobileOpen) && (
|
||||
<ChevronDownIcon
|
||||
className={`ml-auto w-5 h-5 transition-transform duration-200 ${
|
||||
openSubmenu?.type === menuType &&
|
||||
openSubmenu?.index === index
|
||||
? "rotate-180 text-brand-500"
|
||||
: ""
|
||||
}`}
|
||||
/>
|
||||
<>
|
||||
<span className={`menu-item-text`}>{nav.name}</span>
|
||||
<ChevronDownIcon className={`ml-auto w-5 h-5 transition-transform duration-200 ${openSubmenu?.type === menuType && openSubmenu?.index === index ? "rotate-180 text-brand-500" : ""}`} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
nav.path && (
|
||||
<Link
|
||||
href={nav.path}
|
||||
className={`menu-item group ${
|
||||
isActive(nav.path) ? "menu-item-active" : "menu-item-inactive"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`${
|
||||
isActive(nav.path)
|
||||
? "menu-item-icon-active"
|
||||
: "menu-item-icon-inactive"
|
||||
}`}
|
||||
>
|
||||
<Link href={nav.path} className={`menu-item group ${isActive(nav.path) ? "menu-item-active" : "menu-item-inactive"}`}>
|
||||
<span className={isActive(nav.path) ? "menu-item-icon-active" : "menu-item-icon-inactive"}>
|
||||
{nav.icon}
|
||||
</span>
|
||||
{(isExpanded || isHovered || isMobileOpen) && (
|
||||
@@ -205,52 +236,18 @@ const AppSidebar: React.FC = () => {
|
||||
)}
|
||||
{nav.subItems && (isExpanded || isHovered || isMobileOpen) && (
|
||||
<div
|
||||
ref={(el) => {
|
||||
subMenuRefs.current[`${menuType}-${index}`] = el;
|
||||
}}
|
||||
ref={(el) => { subMenuRefs.current[`${menuType}-${index}`] = el; }}
|
||||
className="overflow-hidden transition-all duration-300"
|
||||
style={{
|
||||
height:
|
||||
openSubmenu?.type === menuType && openSubmenu?.index === index
|
||||
? `${subMenuHeight[`${menuType}-${index}`]}px`
|
||||
: "0px",
|
||||
}}
|
||||
style={{ height: openSubmenu?.type === menuType && openSubmenu?.index === index ? `${subMenuHeight[`${menuType}-${index}`]}px` : "0px" }}
|
||||
>
|
||||
<ul className="mt-2 space-y-1 ml-9">
|
||||
{nav.subItems.map((subItem) => (
|
||||
<li key={subItem.name}>
|
||||
<Link
|
||||
href={subItem.path}
|
||||
className={`menu-dropdown-item ${
|
||||
isActive(subItem.path)
|
||||
? "menu-dropdown-item-active"
|
||||
: "menu-dropdown-item-inactive"
|
||||
}`}
|
||||
>
|
||||
<Link href={subItem.path} className={`menu-dropdown-item ${isActive(subItem.path) ? "menu-dropdown-item-active" : "menu-dropdown-item-inactive"}`}>
|
||||
{subItem.name}
|
||||
<span className="flex items-center gap-1 ml-auto">
|
||||
{subItem.new && (
|
||||
<span
|
||||
className={`ml-auto ${
|
||||
isActive(subItem.path)
|
||||
? "menu-dropdown-badge-active"
|
||||
: "menu-dropdown-badge-inactive"
|
||||
} menu-dropdown-badge `}
|
||||
>
|
||||
new
|
||||
</span>
|
||||
)}
|
||||
{subItem.pro && (
|
||||
<span
|
||||
className={`ml-auto ${
|
||||
isActive(subItem.path)
|
||||
? "menu-dropdown-badge-active"
|
||||
: "menu-dropdown-badge-inactive"
|
||||
} menu-dropdown-badge `}
|
||||
>
|
||||
pro
|
||||
</span>
|
||||
)}
|
||||
{subItem.new && <span className={`${isActive(subItem.path) ? "menu-dropdown-badge-active" : "menu-dropdown-badge-inactive"} menu-dropdown-badge`}>new</span>}
|
||||
{subItem.pro && <span className={`${isActive(subItem.path) ? "menu-dropdown-badge-active" : "menu-dropdown-badge-inactive"} menu-dropdown-badge`}>pro</span>}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
@@ -263,160 +260,45 @@ const AppSidebar: React.FC = () => {
|
||||
</ul>
|
||||
);
|
||||
|
||||
const [openSubmenu, setOpenSubmenu] = useState<{
|
||||
type: "main" | "others";
|
||||
index: number;
|
||||
} | null>(null);
|
||||
const [subMenuHeight, setSubMenuHeight] = useState<Record<string, number>>(
|
||||
{},
|
||||
);
|
||||
const subMenuRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
|
||||
// const isActive = (path: string) => path === pathname;
|
||||
const isActive = useCallback((path: string) => path === pathname, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if the current path matches any submenu item
|
||||
let submenuMatched = false;
|
||||
["main", "others"].forEach((menuType) => {
|
||||
const items = menuType === "main" ? navItems : othersItems;
|
||||
items.forEach((nav, index) => {
|
||||
if (nav.subItems) {
|
||||
nav.subItems.forEach((subItem) => {
|
||||
if (isActive(subItem.path)) {
|
||||
setOpenSubmenu({
|
||||
type: menuType as "main" | "others",
|
||||
index,
|
||||
});
|
||||
submenuMatched = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// If no submenu item matches, close the open submenu
|
||||
if (!submenuMatched) {
|
||||
setOpenSubmenu(null);
|
||||
}
|
||||
}, [pathname, isActive]);
|
||||
|
||||
useEffect(() => {
|
||||
// Set the height of the submenu items when the submenu is opened
|
||||
if (openSubmenu !== null) {
|
||||
const key = `${openSubmenu.type}-${openSubmenu.index}`;
|
||||
if (subMenuRefs.current[key]) {
|
||||
setSubMenuHeight((prevHeights) => ({
|
||||
...prevHeights,
|
||||
[key]: subMenuRefs.current[key]?.scrollHeight || 0,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}, [openSubmenu]);
|
||||
|
||||
const handleSubmenuToggle = (index: number, menuType: "main" | "others") => {
|
||||
setOpenSubmenu((prevOpenSubmenu) => {
|
||||
if (
|
||||
prevOpenSubmenu &&
|
||||
prevOpenSubmenu.type === menuType &&
|
||||
prevOpenSubmenu.index === index
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { type: menuType, index };
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`fixed mt-16 flex flex-col lg:mt-0 top-0 px-5 left-0 bg-white dark:bg-gray-900 dark:border-gray-800 text-gray-900 h-screen transition-all duration-300 ease-in-out z-50 border-r border-gray-200
|
||||
${
|
||||
isExpanded || isMobileOpen
|
||||
? "w-[290px]"
|
||||
: isHovered
|
||||
? "w-[290px]"
|
||||
: "w-[90px]"
|
||||
}
|
||||
${isMobileOpen ? "translate-x-0" : "-translate-x-full"}
|
||||
lg:translate-x-0`}
|
||||
${isExpanded || isMobileOpen ? "w-[290px]" : isHovered ? "w-[290px]" : "w-[90px]"}
|
||||
${isMobileOpen ? "translate-x-0" : "-translate-x-full"} lg:translate-x-0`}
|
||||
onMouseEnter={() => !isExpanded && setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div
|
||||
className={`py-8 flex ${
|
||||
!isExpanded && !isHovered ? "lg:justify-center" : "justify-start"
|
||||
}`}
|
||||
>
|
||||
<div className={`py-8 flex ${!isExpanded && !isHovered ? "lg:justify-center" : "justify-start"}`}>
|
||||
<Link href="/">
|
||||
{isExpanded || isHovered || isMobileOpen ? (
|
||||
<>
|
||||
<Image
|
||||
className="dark:hidden"
|
||||
src="/images/logo/logo.svg"
|
||||
alt="Logo"
|
||||
width={80}
|
||||
height={50}
|
||||
/>
|
||||
<Image
|
||||
className="hidden dark:block"
|
||||
src="/images/logo/logo.svg"
|
||||
alt="Logo"
|
||||
width={150}
|
||||
height={40}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Image
|
||||
src="/images/logo/logo.svg"
|
||||
alt="Logo"
|
||||
width={32}
|
||||
height={32}
|
||||
/>
|
||||
)}
|
||||
<Image
|
||||
src="/images/logo/logo.svg"
|
||||
alt="Logo"
|
||||
width={isExpanded || isHovered || isMobileOpen ? 80 : 32}
|
||||
height={isExpanded || isHovered || isMobileOpen ? 50 : 32}
|
||||
style={{ height: 'auto' }}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-col overflow-y-auto duration-300 ease-linear no-scrollbar">
|
||||
<nav className="mb-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h2
|
||||
className={`mb-4 text-xs uppercase flex leading-[20px] text-gray-400 ${
|
||||
!isExpanded && !isHovered
|
||||
? "lg:justify-center"
|
||||
: "justify-start"
|
||||
}`}
|
||||
>
|
||||
{isExpanded || isHovered || isMobileOpen ? (
|
||||
"Menu"
|
||||
) : (
|
||||
<HorizontaLDots />
|
||||
)}
|
||||
<h2 className={`mb-4 text-xs uppercase flex leading-[20px] text-gray-400 ${!isExpanded && !isHovered ? "lg:justify-center" : "justify-start"}`}>
|
||||
{isExpanded || isHovered || isMobileOpen ? "Menu" : <HorizontaLDots />}
|
||||
</h2>
|
||||
{renderMenuItems(filteredMainNavItems, "main")}
|
||||
{renderMenuItems(filteredNavItems, "main")}
|
||||
</div>
|
||||
|
||||
<div className="">
|
||||
<h2
|
||||
className={`mb-4 text-xs uppercase flex leading-[20px] text-gray-400 ${
|
||||
!isExpanded && !isHovered
|
||||
? "lg:justify-center"
|
||||
: "justify-start"
|
||||
}`}
|
||||
>
|
||||
{isExpanded || isHovered || isMobileOpen ? (
|
||||
"Others"
|
||||
) : (
|
||||
<HorizontaLDots />
|
||||
)}
|
||||
{/* <div>
|
||||
<h2 className={`mb-4 text-xs uppercase flex leading-[20px] text-gray-400 ${!isExpanded && !isHovered ? "lg:justify-center" : "justify-start"}`}>
|
||||
{isExpanded || isHovered || isMobileOpen ? "Others" : <HorizontaLDots />}
|
||||
</h2>
|
||||
{renderMenuItems(filteredOthersNavItems, "others")}
|
||||
</div>
|
||||
{renderMenuItems(filteredOthersItems, "others")}
|
||||
</div> */}
|
||||
</div>
|
||||
</nav>
|
||||
{/* {isExpanded || isHovered || isMobileOpen ? <SidebarWidget /> : null} */}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppSidebar;
|
||||
export default AppSidebar;
|
||||
@@ -1,37 +0,0 @@
|
||||
import { UserData } from "@/interface/user"
|
||||
|
||||
export const saveUserToCookie = (userData: UserData) => {
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
const userDataJson = JSON.stringify(userData)
|
||||
document.cookie = `userDataRedux=${encodeURIComponent(userDataJson)}; path=/; max-age=86400`
|
||||
}
|
||||
|
||||
|
||||
export const removeUserFromCookie = () => {
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
document.cookie = "userDataRedux=; path=/; max-age=0"
|
||||
}
|
||||
|
||||
export const getUserFromCookie = (): UserData | null => {
|
||||
if (typeof document === "undefined") return null
|
||||
|
||||
const name = "userDataRedux="
|
||||
const decodedCookie = decodeURIComponent(document.cookie)
|
||||
const cookieArray = decodedCookie.split(";")
|
||||
|
||||
for (let cookie of cookieArray) {
|
||||
cookie = cookie.trim()
|
||||
if (cookie.indexOf(name) === 0) {
|
||||
try {
|
||||
const userData = JSON.parse(cookie.substring(name.length))
|
||||
return userData
|
||||
} catch (error) {
|
||||
console.error("Error parsing user cookie:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -109,3 +109,22 @@ export const getMediaById = async (mediaId: number | string) => {
|
||||
return response?.data;
|
||||
}
|
||||
|
||||
export const deleteMedia = async (mediaIds: string[]) => {
|
||||
const response = await api.delete(API.Media.DELETE_MEDIA, {
|
||||
data: {
|
||||
media_ids: mediaIds
|
||||
}
|
||||
});
|
||||
return response?.data;
|
||||
}
|
||||
export const deleteMediaById = async (mediaId: string) => {
|
||||
const response = await api.delete(API.Media.DELETE_MEDIA_BY_ID(mediaId));
|
||||
return response?.data;
|
||||
}
|
||||
|
||||
export const getMedia = async (payload: any) => {
|
||||
const response = await api.get(API.Media.GET_MEDIA, {
|
||||
params: payload,
|
||||
});
|
||||
return response?.data;
|
||||
}
|
||||
72
src/service/projectService.ts
Normal file
72
src/service/projectService.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import api from "@/config/config";
|
||||
import { API } from "../../api";
|
||||
import { ProjectMemberPayload, ChangeOwnerPayload, CreateCommitPayload, GetProjectsParams, Project, RestoreCommitPayload, UpdateProjectPayload } from "@/interface/project";
|
||||
import { CommonResponse, CursorPaginatedResponse, PaginatedResponse } from "@/interface/common";
|
||||
|
||||
export const getProjects = async (params: GetProjectsParams): Promise<PaginatedResponse<Project>> => {
|
||||
const response = await api.get(API.Project.GET_ALL, { params });
|
||||
return response?.data;
|
||||
};
|
||||
|
||||
export const getProjectDetailByID = async (id: string): Promise<CommonResponse<Project>> => {
|
||||
const response = await api.get(API.Project.GET_DETAIL(id));
|
||||
return response?.data;
|
||||
};
|
||||
|
||||
export const updateProject = async (id: string, payload: UpdateProjectPayload): Promise<CommonResponse<Project>> => {
|
||||
const response = await api.put(API.Project.UPDATE(id), payload);
|
||||
return response?.data;
|
||||
};
|
||||
|
||||
export const deleteProject = async (id: string): Promise<CommonResponse> => {
|
||||
const response = await api.delete(API.Project.DELETE(id));
|
||||
return response?.data;
|
||||
};
|
||||
|
||||
export const transferProjectOwnership = async (id: string, payload: ChangeOwnerPayload): Promise<CommonResponse> => {
|
||||
const response = await api.put(API.Project.CHANGE_OWNER(id), payload);
|
||||
return response?.data;
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
// 2. NHÓM: QUẢN LÝ THÀNH VIÊN (MEMBERS)
|
||||
// ==========================================
|
||||
|
||||
export const addProjectMember = async (id: string, payload: ProjectMemberPayload): Promise<CommonResponse> => {
|
||||
const response = await api.post(API.Project.ADD_MEMBER(id), payload);
|
||||
return response?.data;
|
||||
};
|
||||
|
||||
export const updateProjectMemberRole = async (id: string, userId: string, payload: ProjectMemberPayload): Promise<CommonResponse> => {
|
||||
const response = await api.put(API.Project.UPDATE_MEMBER(id, userId), payload);
|
||||
return response?.data;
|
||||
};
|
||||
|
||||
export const removeProjectMember = async (id: string, userId: string): Promise<CommonResponse> => {
|
||||
const response = await api.delete(API.Project.REMOVE_MEMBER(id, userId));
|
||||
return response?.data;
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
// 3. NHÓM: LỊCH SỬ BẢN LƯU (COMMITS)
|
||||
// ==========================================
|
||||
|
||||
export const createProjectCommit = async (id: string, payload: CreateCommitPayload): Promise<CommonResponse> => {
|
||||
const response = await api.post(API.Project.CREATE_COMMIT(id), payload);
|
||||
return response?.data;
|
||||
};
|
||||
|
||||
export const getProjectCommits = async (id: string): Promise<CommonResponse> => {
|
||||
const response = await api.get(API.Project.GET_COMMITS(id));
|
||||
return response?.data;
|
||||
};
|
||||
|
||||
export const restoreProjectCommit = async (id: string, payload: RestoreCommitPayload): Promise<CommonResponse> => {
|
||||
const response = await api.post(API.Project.RESTORE_COMMIT(id), payload);
|
||||
return response?.data;
|
||||
};
|
||||
|
||||
export const getCurrentProject = async (params?: { cursor_id?: string; limit?: number }): Promise<CursorPaginatedResponse<Project>> => {
|
||||
const response = await api.get(API.Project.GET_CURRENT_PROJECT, { params });
|
||||
return response?.data;
|
||||
};
|
||||
14
src/service/statisticsService.ts
Normal file
14
src/service/statisticsService.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import api from "@/config/config";
|
||||
import { API } from "../../api";
|
||||
import { GetStatisticsParams, StatisticResponse } from "@/interface/statistics";
|
||||
import { CommonResponse } from "@/interface/common";
|
||||
|
||||
export const getStatistics = async (params?: GetStatisticsParams): Promise<CommonResponse<StatisticResponse[]>> => {
|
||||
const response = await api.get(API.Statistics.GET_ALL, { params });
|
||||
return response?.data;
|
||||
};
|
||||
|
||||
export const getStatisticByDate = async (date: string): Promise<CommonResponse<StatisticResponse>> => {
|
||||
const response = await api.get(API.Statistics.GET_BY_DATE(date));
|
||||
return response?.data;
|
||||
};
|
||||
23
src/service/submisisonService.ts
Normal file
23
src/service/submisisonService.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import api from "@/config/config";
|
||||
import { API } from "../../api";
|
||||
import { getSubmissionPayload, updateSubmissionPayload } from "@/interface/submission";
|
||||
|
||||
export const apiGetSubmission = async (params: getSubmissionPayload) => {
|
||||
const response = await api.get(API.Submission.GET_ALL, { params });
|
||||
return response?.data;
|
||||
};
|
||||
|
||||
export const apiGetSubmissionDetail = async (id: string) => {
|
||||
const response = await api.get(API.Submission.GET_DETAIL(id));
|
||||
return response?.data;
|
||||
};
|
||||
|
||||
export const updateProject = async (id: string, payload: updateSubmissionPayload) => {
|
||||
const response = await api.patch(API.Submission.UPDATE_STATUS(id), payload);
|
||||
return response?.data;
|
||||
};
|
||||
|
||||
export const deleteProject = async (id: string) => {
|
||||
const response = await api.delete(API.Submission.DELETE(id));
|
||||
return response?.data;
|
||||
};
|
||||
@@ -3,13 +3,6 @@
|
||||
import { useRef } from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { store } from './store';
|
||||
import { useRestoreUserData } from '@/hooks/useRestoreUserData';
|
||||
|
||||
|
||||
function RestoreUserDataComponent() {
|
||||
useRestoreUserData();
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function StoreProvider({
|
||||
children,
|
||||
@@ -17,10 +10,5 @@ export default function StoreProvider({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const storeRef = useRef(store);
|
||||
return (
|
||||
<Provider store={storeRef.current}>
|
||||
<RestoreUserDataComponent />
|
||||
{children}
|
||||
</Provider>
|
||||
);
|
||||
return <Provider store={storeRef.current}>{children}</Provider>;
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { UserData } from '@/interface/user';
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { getUserFromCookie } from '@/lib/cookieStorage';
|
||||
|
||||
const getStoredApplication = () => {
|
||||
if (typeof window !== "undefined") {
|
||||
@@ -10,24 +9,15 @@ const getStoredApplication = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const getStoredUserData = (): UserData | null => {
|
||||
if (typeof window === "undefined") {
|
||||
return null;
|
||||
}
|
||||
return getUserFromCookie();
|
||||
};
|
||||
|
||||
interface UserState {
|
||||
data: UserData | null;
|
||||
isAuthenticated: boolean;
|
||||
selectedApplication: any | null;
|
||||
}
|
||||
|
||||
const storedUserData = getStoredUserData();
|
||||
|
||||
const initialState: UserState = {
|
||||
data: storedUserData,
|
||||
isAuthenticated: Boolean(storedUserData),
|
||||
data: null,
|
||||
isAuthenticated: false,
|
||||
selectedApplication: getStoredApplication(),
|
||||
};
|
||||
|
||||
@@ -39,10 +29,6 @@ const userSlice = createSlice({
|
||||
state.data = action.payload;
|
||||
state.isAuthenticated = true;
|
||||
},
|
||||
clearUserData: (state) => {
|
||||
state.data = null;
|
||||
state.isAuthenticated = false;
|
||||
},
|
||||
setSelectedApplication: (state, action: PayloadAction<any>) => {
|
||||
state.selectedApplication = action.payload;
|
||||
if (typeof window !== "undefined") {
|
||||
@@ -58,5 +44,5 @@ const userSlice = createSlice({
|
||||
},
|
||||
});
|
||||
|
||||
export const { setUserData, clearUserData, setSelectedApplication, clearSelectedApplication } = userSlice.actions;
|
||||
export const { setUserData, setSelectedApplication, clearSelectedApplication } = userSlice.actions;
|
||||
export default userSlice.reducer;
|
||||
@@ -1,5 +1,4 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { useDispatch, useSelector, TypedUseSelectorHook } from 'react-redux'; // Thêm dòng này
|
||||
import userReducer from './features/userSlice';
|
||||
|
||||
export const store = configureStore({
|
||||
@@ -9,7 +8,4 @@ export const store = configureStore({
|
||||
});
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
|
||||
export const useAppDispatch = () => useDispatch<AppDispatch>();
|
||||
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
Reference in New Issue
Block a user