Compare commits
20 Commits
391d4b169b
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| ff70fc78f5 | |||
| a2bab73e50 | |||
| d65a71ba1d | |||
| 043ef08b87 | |||
| 226abba5ed | |||
| 7013b93c53 | |||
| d84352d848 | |||
| 2b40dd33dd | |||
| e273df1ee2 | |||
| bf44502a24 | |||
| e76b1cf093 | |||
| 8bc6b32e26 | |||
| 2ee5c8da36 | |||
| 3dc7804b10 | |||
| fc3d900871 | |||
| 4018d8e135 | |||
| 1fe25c1944 | |||
| 49b99289bb | |||
| 5c622902ad | |||
| 0d08fb57c8 |
23
api.ts
23
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,20 @@ 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`,
|
||||
},
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export const LIMIT_ITEM_TABLE = 5;
|
||||
export const INITIAL_LIMIT = 16;
|
||||
@@ -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">
|
||||
|
||||
@@ -187,7 +187,7 @@ export default function HistorianApplicationPage() {
|
||||
|
||||
const pagination = tableData?.pagination;
|
||||
|
||||
console.log(tableData)
|
||||
// console.log(tableData)
|
||||
// console.log("Pagination info:", pagination);
|
||||
return (
|
||||
<div>
|
||||
@@ -218,7 +218,6 @@ export default function HistorianApplicationPage() {
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{/* Cập nhật Grid để chứa đủ các ô filter */}
|
||||
<div className="grid grid-cols-1 gap-4 mb-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<div>
|
||||
<label className="block mb-2 text-sm font-medium">Tìm kiếm</label>
|
||||
@@ -231,6 +230,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
|
||||
@@ -241,13 +241,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
|
||||
@@ -258,9 +259,9 @@ 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>
|
||||
|
||||
481
src/app/(admin)/(others-pages)/(management)/assets/page.tsx
Normal file
481
src/app/(admin)/(others-pages)/(management)/assets/page.tsx
Normal file
@@ -0,0 +1,481 @@
|
||||
"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 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">Từ ngày</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="date"
|
||||
value={fromDate}
|
||||
onChange={(e) => setFromDate(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 outline-none focus:border-brand-500"
|
||||
/>
|
||||
<input
|
||||
type="time"
|
||||
value={fromTime}
|
||||
onChange={(e) => setFromTime(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 outline-none focus:border-brand-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block mb-2 text-sm font-medium">Đến ngày</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="date"
|
||||
value={toDate}
|
||||
onChange={(e) => setToDate(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 outline-none focus:border-brand-500"
|
||||
/>
|
||||
<input
|
||||
type="time"
|
||||
value={toTime}
|
||||
onChange={(e) => setToTime(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 outline-none focus:border-brand-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block mb-2 text-sm font-medium">
|
||||
Hiển thị (Limit)
|
||||
</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)} // Click ra vùng tối sẽ đóng
|
||||
>
|
||||
<div
|
||||
className="relative flex flex-col items-center justify-center p-4 max-w-[90vw] max-h-[90vh]"
|
||||
onClick={(e) => e.stopPropagation()} // Bấm vào ảnh không bị đóng
|
||||
>
|
||||
{/* Nút X đóng Modal */}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
234
src/app/(admin)/(others-pages)/(management)/project/page.tsx
Normal file
234
src/app/(admin)/(others-pages)/(management)/project/page.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
"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";
|
||||
|
||||
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(),
|
||||
);
|
||||
|
||||
// Filters state
|
||||
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 [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);
|
||||
};
|
||||
|
||||
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">
|
||||
<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" />
|
||||
<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>
|
||||
<input type="text" placeholder="IDs người dùng (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 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 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>
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -292,7 +292,7 @@ export default function UserTable() {
|
||||
|
||||
<div>
|
||||
<label className="block mb-2 text-sm font-medium">
|
||||
Auth Provider
|
||||
Phương thức đăng nhập
|
||||
</label>
|
||||
<select
|
||||
value={authProvider}
|
||||
@@ -306,7 +306,7 @@ export default function UserTable() {
|
||||
|
||||
<div>
|
||||
<label className="block mb-2 text-sm font-medium">
|
||||
Trạng thái xóa
|
||||
Trạng thái
|
||||
</label>
|
||||
<select
|
||||
onChange={(e) =>
|
||||
@@ -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.",
|
||||
@@ -94,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!",
|
||||
@@ -118,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);
|
||||
|
||||
@@ -247,7 +259,7 @@ export default function ApplicationDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[11px] text-zinc-400 dark:text-zinc-500 tabular-nums">
|
||||
{application?.reviewed_at}
|
||||
{formatDate(application?.reviewed_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -9,8 +9,8 @@ import DemographicCard from "@/components/ecommerce/DemographicCard";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title:
|
||||
"Next.js E-commerce Dashboard | TailAdmin - Next.js Dashboard Template",
|
||||
description: "This is Next.js Home for TailAdmin Dashboard Template",
|
||||
"Admin Dashboard",
|
||||
description: "This is Dashboard Home for History Web",
|
||||
};
|
||||
|
||||
export default function Ecommerce() {
|
||||
|
||||
@@ -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;
|
||||
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 {
|
||||
|
||||
@@ -109,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;
|
||||
@@ -143,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">
|
||||
@@ -179,6 +166,7 @@ export default function SignInForm() {
|
||||
type="email"
|
||||
onChange={handleChange}
|
||||
defaultValue={formData.email}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -195,6 +183,7 @@ export default function SignInForm() {
|
||||
placeholder="Min. 8 characters"
|
||||
onChange={handleChange}
|
||||
defaultValue={formData.password}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<span
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
@@ -205,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
|
||||
@@ -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">
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { fullDataUser } from "@/interface/admin";
|
||||
import { UserMetaCardProps } from "@/interface/user";
|
||||
import { apiGetCurrentUser, apiLogout } from "@/service/auth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ListIcon } from "@/icons";
|
||||
|
||||
export default function UserDropdown() {
|
||||
const router = useRouter();
|
||||
@@ -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>
|
||||
|
||||
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
|
||||
|
||||
230
src/components/tables/ProjectsTable.tsx
Normal file
230
src/components/tables/ProjectsTable.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
"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 `Updated on ${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={`w-20 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] min-w-[700px]">
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200 dark:border-gray-800 bg-gray-50/50 dark:bg-[#161b22]">
|
||||
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300 w-40">
|
||||
</span>
|
||||
<div className="flex items-center gap-4 shrink-0">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 w-20">
|
||||
Sắp xếp:
|
||||
</span>
|
||||
<SortButton column="title" label="Tên" />
|
||||
<SortButton column="created_at" label="Ngày tạo" />
|
||||
<SortButton column="updated_at" label="Cập nhật" />
|
||||
</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 justify-between hover:bg-gray-50 dark:hover:bg-[#161b22] transition-colors"
|
||||
>
|
||||
<div className="flex-1 pr-4 max-w-full md:max-w-[75%]">
|
||||
<div
|
||||
onClick={() => onViewDetails(item.id)}
|
||||
className="flex items-center gap-2 mb-2 cursor-pointer hover:underline"
|
||||
>
|
||||
<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-[250px]">
|
||||
<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-[300px]">
|
||||
{item.title}
|
||||
</h3>
|
||||
|
||||
<div className="shrink-0 w-20 flex justify-start">
|
||||
{getStatusBadge(item.project_status)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4 text-xs text-gray-500 dark:text-[#8b949e] h-5">
|
||||
<span>{formatDate(item.updated_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center mt-4 md:mt-0 w-[120px] 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -51,6 +51,7 @@ export const SidebarProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
const toggleSidebar = () => {
|
||||
setIsExpanded((prev) => !prev);
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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,57 +20,75 @@ import {
|
||||
TableIcon,
|
||||
UserCircleIcon,
|
||||
} from "../icons/index";
|
||||
import SidebarWidget from "./SidebarWidget";
|
||||
|
||||
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"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
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",
|
||||
@@ -102,64 +123,109 @@ const AppSidebar: React.FC = () => {
|
||||
const { isExpanded, isMobileOpen, isHovered, setIsHovered } = useSidebar();
|
||||
const pathname = usePathname();
|
||||
|
||||
const renderMenuItems = (
|
||||
navItems: NavItem[],
|
||||
menuType: "main" | "others",
|
||||
) => (
|
||||
// Lấy data gốc từ Redux (không bị render lại vô cớ)
|
||||
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 }
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
if (!submenuMatched) {
|
||||
setOpenSubmenu((prev) => (prev !== null ? null : prev));
|
||||
}
|
||||
}, [pathname, isActive, filteredNavItems, filteredOthersItems]);
|
||||
|
||||
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 = (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) && (
|
||||
@@ -170,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>
|
||||
@@ -228,157 +260,41 @@ 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}
|
||||
/>
|
||||
</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(navItems, "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(othersItems, "others")}
|
||||
</div>
|
||||
{renderMenuItems(filteredOthersItems, "others")}
|
||||
</div> */}
|
||||
</div>
|
||||
</nav>
|
||||
{/* {isExpanded || isHovered || isMobileOpen ? <SidebarWidget /> : null} */}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user