project table
This commit is contained in:
20
api.ts
20
api.ts
@@ -7,7 +7,8 @@ export const API = {
|
|||||||
MEDIA: `${API_URL_ROOT}/users/current/media`,
|
MEDIA: `${API_URL_ROOT}/users/current/media`,
|
||||||
Update: `${API_URL_ROOT}/users/current`,
|
Update: `${API_URL_ROOT}/users/current`,
|
||||||
CHANGE_PASSWORD: `${API_URL_ROOT}/users/current/password`,
|
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:{
|
Media:{
|
||||||
GET_MEDIA: `${API_URL_ROOT}/media`,
|
GET_MEDIA: `${API_URL_ROOT}/media`,
|
||||||
@@ -41,5 +42,20 @@ export const API = {
|
|||||||
CREATE_CV: `${API_URL_ROOT}/historian/application`,
|
CREATE_CV: `${API_URL_ROOT}/historian/application`,
|
||||||
APPLICATION: `${API_URL_ROOT}/historian/application`,
|
APPLICATION: `${API_URL_ROOT}/historian/application`,
|
||||||
DELETE_CV: (Id: number | string) => `${API_URL_ROOT}/historian/application/${Id}`,
|
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`,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
283
src/app/(admin)/(others-pages)/(management)/project/page.tsx
Normal file
283
src/app/(admin)/(others-pages)/(management)/project/page.tsx
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
"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";
|
||||||
|
|
||||||
|
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 ProjectsResponse {
|
||||||
|
status: boolean;
|
||||||
|
message: string;
|
||||||
|
data: ProjectItem[];
|
||||||
|
pagination: {
|
||||||
|
current_page: number;
|
||||||
|
page_size: number;
|
||||||
|
total_records: number;
|
||||||
|
total_pages: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProjectsPage() {
|
||||||
|
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 | 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);
|
||||||
|
}
|
||||||
|
} 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 handleUpdate = async (project: ProjectItem) => {
|
||||||
|
/* Tương tự handleCreate, sử dụng Swal.fire để tạo form cập nhật */
|
||||||
|
toast.info(`Chức năng cập nhật cho dự án "${project.title}"`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
const result = await Swal.fire({
|
||||||
|
title: "Xác nhận xóa?",
|
||||||
|
text: "Bạn có chắc chắn muốn xóa dự án này? 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",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.isConfirmed) {
|
||||||
|
try {
|
||||||
|
// const response = await deleteProject(id);
|
||||||
|
// if (response?.status) {
|
||||||
|
toast.success("Đã xóa dự án thành công.");
|
||||||
|
fetchProjectsData();
|
||||||
|
// } else {
|
||||||
|
// toast.error(response?.message || "Xóa dự án thất bại");
|
||||||
|
// }
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Đã xảy ra lỗi khi xóa");
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTransferOwner = async (project: ProjectItem) => {
|
||||||
|
/* Sử dụng Swal.fire với input để nhập New Owner ID */
|
||||||
|
toast.info(`Chức năng chuyển quyền sở hữu cho dự án "${project.title}"`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewDetails = (id: string) => {
|
||||||
|
// Điều hướng đến trang chi tiết dự án (quản lý members, commits)
|
||||||
|
// router.push(`/projects/${id}`);
|
||||||
|
toast.info(`Xem chi tiết dự án ID: ${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}
|
||||||
|
onUpdate={handleUpdate}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onTransferOwner={handleTransferOwner}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -185,7 +185,7 @@ export default function MediaTable({
|
|||||||
<TableCell className="px-5 py-4 text-start font-mono text-theme-xs truncate max-w-[250px]">
|
<TableCell className="px-5 py-4 text-start font-mono text-theme-xs truncate max-w-[250px]">
|
||||||
{item.original_name}
|
{item.original_name}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-5 py-4 text-start">
|
<TableCell className="px-5 py-4 text-start max-w-[100px] truncate">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<div>{getMimeTypeBadge(item.mime_type)}</div>
|
<div>{getMimeTypeBadge(item.mime_type)}</div>
|
||||||
<span className="text-[10px] text-gray-400">{item.mime_type}</span>
|
<span className="text-[10px] text-gray-400">{item.mime_type}</span>
|
||||||
|
|||||||
@@ -9,7 +9,33 @@ const Pagination: React.FC<PaginationProps> = ({
|
|||||||
totalPages,
|
totalPages,
|
||||||
onPageChange,
|
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 (
|
return (
|
||||||
<div className="flex items-center ">
|
<div className="flex items-center ">
|
||||||
@@ -21,21 +47,25 @@ const Pagination: React.FC<PaginationProps> = ({
|
|||||||
Previous
|
Previous
|
||||||
</button>
|
</button>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{currentPage > 3 && <span className="px-2">...</span>}
|
{pages.map((page, index) =>
|
||||||
{allPages.map((page) => (
|
page === '...' ? (
|
||||||
<button
|
<span key={`dots-${index}`} className="flex h-10 w-10 items-center justify-center text-gray-500">
|
||||||
key={page}
|
...
|
||||||
onClick={() => onPageChange(page)}
|
</span>
|
||||||
className={`px-4 py-2 rounded ${
|
) : (
|
||||||
currentPage === page
|
<button
|
||||||
? "bg-brand-500 text-white"
|
key={page}
|
||||||
: "text-gray-700 dark:text-gray-400"
|
onClick={() => onPageChange(page as number)}
|
||||||
} 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`}
|
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
|
||||||
{page}
|
? "bg-brand-500 text-white"
|
||||||
</button>
|
: "text-gray-700 dark:text-gray-400"
|
||||||
))}
|
}`}
|
||||||
{currentPage < totalPages - 2 && <span className="px-2">...</span>}
|
>
|
||||||
|
{page}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
189
src/components/tables/ProjectsTable.tsx
Normal file
189
src/components/tables/ProjectsTable.tsx
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "../ui/table";
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectsTableProps {
|
||||||
|
data: ProjectItem[];
|
||||||
|
onSort: (column: ProjectSortColumn) => void;
|
||||||
|
sortBy?: ProjectSortColumn;
|
||||||
|
sortOrder?: "asc" | "desc";
|
||||||
|
onUpdate: (item: ProjectItem) => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
onTransferOwner: (item: ProjectItem) => void;
|
||||||
|
onViewDetails: (id: string) => void; // Để xem commits, members...
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProjectsTable({
|
||||||
|
data,
|
||||||
|
onSort,
|
||||||
|
sortBy,
|
||||||
|
sortOrder,
|
||||||
|
onUpdate,
|
||||||
|
onDelete,
|
||||||
|
onTransferOwner,
|
||||||
|
onViewDetails,
|
||||||
|
}: ProjectsTableProps) {
|
||||||
|
const formatDate = (dateString: string | null | undefined) => {
|
||||||
|
if (!dateString) return "-";
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString("vi-VN", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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 SortIcon = ({ column }: { column: ProjectSortColumn }) => {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 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("title")}>
|
||||||
|
Tiêu đề <SortIcon column="title" />
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell isHeader className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400">Trạng thái</TableCell>
|
||||||
|
<TableCell isHeader className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400">Chủ sở hữu</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ạo <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-[200px]">Thao tác</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
|
||||||
|
<TableBody className="divide-y divide-gray-100 dark:divide-white/[0.05]">
|
||||||
|
{data.length > 0 ? (
|
||||||
|
data.map((item) => (
|
||||||
|
<TableRow key={item.id} className="hover:bg-gray-50/50 dark:hover:bg-white/[0.01] transition-colors">
|
||||||
|
<TableCell className="px-5 py-4 text-start font-medium text-theme-sm truncate max-w-[250px]">{item.title}</TableCell>
|
||||||
|
<TableCell className="px-5 py-4 text-start">{getStatusBadge(item.project_status)}</TableCell>
|
||||||
|
<TableCell className="px-5 py-4 text-start text-theme-sm">{item.user?.display_name}</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-3">
|
||||||
|
<button onClick={() => onViewDetails(item.id)} title="Chi tiết" className="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button onClick={() => onUpdate(item)} title="Cập nhật" className="text-blue-500 hover:text-blue-700 dark:hover:text-blue-400">
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button onClick={() => onTransferOwner(item)} title="Chuyển quyền sở hữu" className="text-green-500 hover:text-green-700 dark:hover:text-green-400">
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button onClick={() => onDelete(item.id)} title="Xóa" className="text-red-500 hover:text-red-700 dark:hover:text-red-400">
|
||||||
|
<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>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="px-5 py-20 text-center text-gray-500 italic">
|
||||||
|
Không tìm thấy dự án nào
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
9
src/interface/project.ts
Normal file
9
src/interface/project.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export interface Project {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
project_status: "PRIVATE" | "PUBLIC" | "ARCHIVE";
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
// You can add other fields like 'members' if they are part of the response
|
||||||
|
}
|
||||||
@@ -70,6 +70,7 @@ const ALL_NAV_ITEMS: NavItem[] = [
|
|||||||
{ name: "Tài Khoản", path: "/user-information", pro: false, roles: ["ADMIN", "MOD"] },
|
{ 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: "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: "Tệp Đăng Tải", path: "/assets", pro: false, roles: ["ADMIN", "MOD"] },
|
||||||
|
{ name: "Dự Án", path: "/project", pro: false, roles: ["ADMIN", "MOD"] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
130
src/service/projectService.ts
Normal file
130
src/service/projectService.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import api from "@/config/config";
|
||||||
|
import { API } from "../../api";
|
||||||
|
import { Project } from "@/interface/project";
|
||||||
|
import { CommonResponse, CursorPaginatedResponse, PaginatedResponse } from "@/interface/common";
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// TYPES & INTERFACES (Cơ bản theo logic chuẩn)
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
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 UpdateProjectPayload {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
status?: "PRIVATE" | "PUBLIC" | "ARCHIVE";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddMemberPayload {
|
||||||
|
user_id: string;
|
||||||
|
role: "EDITOR" | "VIEWER";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateMemberRolePayload {
|
||||||
|
role: "EDITOR" | "VIEWER";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChangeOwnerPayload {
|
||||||
|
new_owner_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateCommitPayload {
|
||||||
|
edit_summary: string;
|
||||||
|
snapshot_json: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RestoreCommitPayload {
|
||||||
|
commit_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 1. NHÓM: QUẢN LÝ DỰ ÁN (PROJECTS)
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
export const getProjects = async (params: GetProjectsParams): Promise<PaginatedResponse<Project>> => {
|
||||||
|
const response = await api.get(API.Project.GET_ALL, { params });
|
||||||
|
return response?.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getProjectDetail = 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: AddMemberPayload): 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: UpdateMemberRolePayload): 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> => { // Assuming it returns a list of commits
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 4. NHÓM: LẤY DỰ ÁN QUA USER
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
export const getUserProjects = async (userId: string, 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