Compare commits

...

7 Commits

Author SHA1 Message Date
91b0eef9c8 feat: implement role-based sidebar navigation and admin dashboard layout with statistics components
All checks were successful
Build and Release / release (push) Successful in 30s
2026-05-09 11:09:51 +07:00
45b7b379ce id submission
All checks were successful
Build and Release / release (push) Successful in 28s
2026-05-08 18:33:26 +07:00
35732fd50e manage submission page.
All checks were successful
Build and Release / release (push) Successful in 29s
2026-05-08 16:38:43 +07:00
7ec7fda0b4 responsive and new date time picker
All checks were successful
Build and Release / release (push) Successful in 28s
2026-05-02 16:04:45 +07:00
ff70fc78f5 update: management project
All checks were successful
Build and Release / release (push) Successful in 29s
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 15:03:49 +07:00
a2bab73e50 update: projectsmanage
All checks were successful
Build and Release / release (push) Successful in 29s
2026-04-29 12:08:09 +07:00
d65a71ba1d project table 2026-04-28 17:52:51 +07:00
32 changed files with 3099 additions and 285 deletions

29
api.ts
View File

@@ -7,7 +7,8 @@ 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`,
@@ -41,5 +42,31 @@ export const API = {
CREATE_CV: `${API_URL_ROOT}/historian/application`,
APPLICATION: `${API_URL_ROOT}/historian/application`,
DELETE_CV: (Id: number | string) => `${API_URL_ROOT}/historian/application/${Id}`,
},
Project: {
GET_CURRENT_PROJECT: `${API_URL_ROOT}/users/current/project`,
CREATE: `${API_URL_ROOT}/projects`,
GET_ALL: `${API_URL_ROOT}/projects`,
GET_DETAIL: (id: number | string) => `${API_URL_ROOT}/projects/${id}`,
UPDATE: (id: number | string) => `${API_URL_ROOT}/projects/${id}`,
DELETE: (id: number | string) => `${API_URL_ROOT}/projects/${id}`,
ADD_MEMBER: (id: number | string) => `${API_URL_ROOT}/projects/${id}/members`,
UPDATE_MEMBER: (id: number | string, userId: number | string) => `${API_URL_ROOT}/projects/${id}/members/${userId}`,
REMOVE_MEMBER: (id: number | string, userId: number | string) => `${API_URL_ROOT}/projects/${id}/members/${userId}`,
CHANGE_OWNER: (id: number | string) => `${API_URL_ROOT}/projects/${id}/change-owner`,
CREATE_COMMIT: (id: number | string) => `${API_URL_ROOT}/projects/${id}/commits`,
GET_COMMITS: (id: number | string) => `${API_URL_ROOT}/projects/${id}/commits`,
RESTORE_COMMIT: (id: number | string) => `${API_URL_ROOT}/projects/${id}/commits/restore`,
},
Submission:{
GET_ALL: `${API_URL_ROOT}/submissions`,
GET_DETAIL: (id: number | string) => `${API_URL_ROOT}/submissions/${id}`,
UPDATE_STATUS: (id: number | string) => `${API_URL_ROOT}/submissions/${id}/status`,
CREATE: `${API_URL_ROOT}/submissions`,
DELETE: (id: string) => `${API_URL_ROOT}/submissions/${id}`,
},
Statistics: {
GET_ALL: `${API_URL_ROOT}/statistics`,
GET_BY_DATE: (date: string) => `${API_URL_ROOT}/statistics/${date}`,
}
}

View File

@@ -5,6 +5,7 @@ import ComponentCard from "@/components/common/ComponentCard";
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
import Pagination from "@/components/tables/Pagination";
import Swal from "sweetalert2";
import CustomDateRangePicker from "@/components/common/CustomDateRangePicker";
import ApplicationTable, {
AppSortColumn,
@@ -266,39 +267,15 @@ export default function HistorianApplicationPage() {
</div>
<div>
<label className="block mb-2 text-sm font-medium">T ngày</label>
<div className="flex gap-2">
<input
type="date"
value={fromDate}
onChange={(e) => setFromDate(e.target.value)}
className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 outline-none focus:border-brand-500"
/>
<input
type="time"
value={fromTime}
onChange={(e) => setFromTime(e.target.value)}
className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 outline-none focus:border-brand-500"
/>
</div>
</div>
<div>
<label className="block mb-2 text-sm font-medium">Đến ngày</label>
<div className="flex gap-2">
<input
type="date"
value={toDate}
onChange={(e) => setToDate(e.target.value)}
className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 outline-none focus:border-brand-500"
/>
<input
type="time"
value={toTime}
onChange={(e) => setToTime(e.target.value)}
className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 outline-none focus:border-brand-500"
/>
</div>
<label className="block mb-2 text-sm font-medium">Thời gian</label>
<CustomDateRangePicker
onFilterChange={(sDate, eDate, sTime, eTime) => {
setFromDate(sDate);
setToDate(eDate);
setFromTime(sTime);
setToTime(eTime);
}}
/>
</div>
<div>

View File

@@ -5,6 +5,7 @@ import ComponentCard from "@/components/common/ComponentCard";
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
import Pagination from "@/components/tables/Pagination";
import { toast } from "sonner";
import CustomDateRangePicker from "@/components/common/CustomDateRangePicker";
import MediaTable, {
MediaItem,
MediaSortColumn,
@@ -335,38 +336,15 @@ export default function AssetsPage() {
</select>
</div>
<div>
<label className="block mb-2 text-sm font-medium">T ngày</label>
<div className="flex gap-2">
<input
type="date"
value={fromDate}
onChange={(e) => setFromDate(e.target.value)}
className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 outline-none focus:border-brand-500"
/>
<input
type="time"
value={fromTime}
onChange={(e) => setFromTime(e.target.value)}
className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 outline-none focus:border-brand-500"
/>
</div>
</div>
<div>
<label className="block mb-2 text-sm font-medium">Đến ngày</label>
<div className="flex gap-2">
<input
type="date"
value={toDate}
onChange={(e) => setToDate(e.target.value)}
className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 outline-none focus:border-brand-500"
/>
<input
type="time"
value={toTime}
onChange={(e) => setToTime(e.target.value)}
className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 outline-none focus:border-brand-500"
/>
</div>
<label className="block mb-2 text-sm font-medium">Thời gian</label>
<CustomDateRangePicker
onFilterChange={(sDate, eDate, sTime, eTime) => {
setFromDate(sDate);
setToDate(eDate);
setFromTime(sTime);
setToTime(eTime);
}}
/>
</div>
<div>
<label className="block mb-2 text-sm font-medium">
@@ -437,13 +415,13 @@ export default function AssetsPage() {
{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
onClick={() => setIndex(-1)}
>
<div
className="relative flex flex-col items-center justify-center p-4 max-w-[90vw] max-h-[90vh]"
onClick={(e) => e.stopPropagation()} // Bấm vào ảnh không bị đóng
onClick={(e) => e.stopPropagation()}
>
{/* 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"

View File

@@ -0,0 +1,259 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import ComponentCard from "@/components/common/ComponentCard";
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
import Pagination from "@/components/tables/Pagination";
import { toast } from "sonner";
import ProjectsTable, {
ProjectItem,
ProjectSortColumn,
} from "@/components/tables/ProjectsTable";
import {
getProjects,
updateProject,
deleteProject,
transferProjectOwnership,
} from "@/service/projectService";
import Swal from "sweetalert2";
import { useRouter } from "next/navigation";
import { LIMIT_ITEM_TABLE } from "../../../../../../constant";
import { ProjectsResponse } from "@/interface/project";
import CustomDateRangePicker from "@/components/common/CustomDateRangePicker";
const formatDateTimeToISO = (
dateStr: string,
timeStr: string,
isEndOfDay: boolean = false,
): string | undefined => {
if (!dateStr) return undefined;
const time = timeStr || (isEndOfDay ? "23:59" : "00:00");
return `${dateStr}T${time}:00.000000+07:00`;
};
export default function ProjectsPage(_props: {
params: unknown;
searchParams: unknown;
}) {
const router = useRouter();
const [page, setPage] = useState<number>(1);
const [limitInput, setLimitInput] = useState<string>(
LIMIT_ITEM_TABLE.toString(),
);
const [searchTerm, setSearchTerm] = useState<string>("");
const [statusFilter, setStatusFilter] = useState<string>("");
const [userIdsFilter, setUserIdsFilter] = useState<string>("");
const [fromDate, setFromDate] = useState<string>("");
const [fromTime, setFromTime] = useState<string>("");
const [toDate, setToDate] = useState<string>("");
const [toTime, setToTime] = useState<string>("");
const [resetKey, setResetKey] = useState<number>(0);
const [debouncedParams, setDebouncedParams] = useState({
search: "",
limit: LIMIT_ITEM_TABLE,
statuses: "",
userIds: "",
fromDate: "",
fromTime: "",
toDate: "",
toTime: "",
});
const [tableData, setTableData] = useState<ProjectsResponse<ProjectItem> | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [sortBy, setSortBy] = useState<ProjectSortColumn>("created_at");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
const handleReset = () => {
setSearchTerm("");
setStatusFilter("");
setUserIdsFilter("");
setLimitInput(LIMIT_ITEM_TABLE.toString());
setFromDate("");
setFromTime("");
setToDate("");
setToTime("");
setPage(1);
setResetKey(prev => prev + 1);
};
const handleDateFilterChange = (startD: string, endD: string, startT: string, endT: string) => {
setFromDate(startD);
setToDate(endD);
setFromTime(startT);
setToTime(endT);
};
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedParams({
search: searchTerm,
limit: parseInt(limitInput) || LIMIT_ITEM_TABLE,
statuses: statusFilter,
userIds: userIdsFilter,
fromDate,
fromTime,
toDate,
toTime,
});
setPage(1);
}, 100);
return () => clearTimeout(handler);
}, [
searchTerm,
limitInput,
statusFilter,
userIdsFilter,
fromDate,
fromTime,
toDate,
toTime,
]);
const fetchProjectsData = useCallback(async () => {
setLoading(true);
try {
const payload: any = {
page: page,
limit: debouncedParams.limit,
search: debouncedParams.search || undefined,
sort: sortBy,
order: sortOrder,
statuses: debouncedParams.statuses || undefined,
user_ids: debouncedParams.userIds || undefined,
};
const createdFrom = formatDateTimeToISO(debouncedParams.fromDate, debouncedParams.fromTime);
if (createdFrom) payload.created_from = createdFrom;
const createdTo = formatDateTimeToISO(debouncedParams.toDate, debouncedParams.toTime, true);
if (createdTo) payload.created_to = createdTo;
const response = await getProjects(payload);
if (response?.status) {
setTableData(response as unknown as ProjectsResponse<ProjectItem>);
}
} catch (err) {
toast.error("Lỗi lấy danh sách dự án");
console.error("Lỗi lấy danh sách dự án:", err);
setTableData(null);
} finally {
setLoading(false);
}
}, [page, debouncedParams, sortBy, sortOrder]);
useEffect(() => {
fetchProjectsData();
}, [fetchProjectsData]);
const handleSort = (column: ProjectSortColumn) => {
setPage(1);
if (sortBy === column) {
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else {
setSortBy(column);
setSortOrder("desc");
}
};
const handleViewDetails = (id: string) => {
router.push(`/projects/${id}`);
};
const pagination = tableData?.pagination;
return (
<div>
<PageBreadcrumb pageTitle="Quản lý dự án" />
<div className="space-y-6">
<ComponentCard
title="Bộ lọc tìm kiếm"
headerAction={
<button onClick={handleReset} className="flex items-center px-3 py-1.5 text-xs text-red-500">
<svg xmlns="http://www.w3.org/2000/svg" className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
}
>
<div className="grid grid-cols-1 gap-4 mb-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<div>
<label className="block mb-2 text-sm font-medium">Tìm kiếm</label>
<input type="text" placeholder="Tên dự án, ID..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 outline-none focus:border-brand-500" />
</div>
<div>
<label className="block mb-2 text-sm font-medium">Trạng thái</label>
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)} className="w-full px-3 py-2 bg-white dark:bg-gray-900 border rounded-lg cursor-pointer outline-none focus:border-brand-500">
<option value="">Tất cả trạng thái</option>
<option value="PUBLIC">PUBLIC</option>
<option value="PRIVATE">PRIVATE</option>
<option value="ARCHIVE">ARCHIVE</option>
</select>
</div>
<div>
<label className="block mb-2 text-sm font-medium">ID người dùng</label>
<input type="text" placeholder="IDs (cách nhau bởi dấu phẩy)" value={userIdsFilter} onChange={(e) => setUserIdsFilter(e.target.value)} className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 outline-none focus:border-brand-500" />
</div>
<div>
<label className="block mb-2 text-sm font-medium">Thời gian</label>
<CustomDateRangePicker
key={resetKey}
onFilterChange={handleDateFilterChange}
/>
</div>
<div>
<label className="block mb-2 text-sm font-medium">Hiển thị (Limit)</label>
<input type="number" value={limitInput} onChange={(e) => setLimitInput(e.target.value)} className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 outline-none focus:border-brand-500" />
</div>
</div>
</ComponentCard>
<ComponentCard
title="Danh sách dự án"
>
<div className="relative min-h-[300px]">
{loading && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/50 dark:bg-gray-900/50 rounded-xl">
<div className="w-10 h-10 border-4 border-t-brand-500 rounded-full animate-spin"></div>
</div>
)}
<ProjectsTable
data={tableData?.data || []}
onSort={handleSort}
sortBy={sortBy}
sortOrder={sortOrder}
onViewDetails={handleViewDetails}
/>
</div>
<div className="flex items-center justify-between mt-6">
<p className="text-sm text-gray-500">
Hiển thị {pagination?.total_records || 0} dự án
</p>
{pagination && pagination.total_pages > 1 && (
<Pagination
currentPage={pagination.current_page}
totalPages={pagination.total_pages}
onPageChange={(newPage) => setPage(newPage)}
/>
)}
</div>
</ComponentCard>
</div>
</div>
);
}

View File

@@ -15,6 +15,7 @@ import {
import { useEffect, useState, useCallback } from "react";
import Pagination from "@/components/tables/Pagination";
import { LIMIT_ITEM_TABLE } from "../../../../../../constant";
import CustomDateRangePicker from "@/components/common/CustomDateRangePicker";
export type SortColumn = "created_at" | "updated_at" | "display_name" | "email";
@@ -325,42 +326,16 @@ export default function UserTable() {
</select>
</div>
{/* Từ ngày */}
<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>
{/* Đến ngày */}
<div>
<label className="block mb-2 text-sm font-medium">Đến ngày</label>
<div className="flex gap-2">
<input
type="date"
value={toDate}
onChange={(e) => setToDate(e.target.value)}
className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 outline-none focus:border-brand-500"
/>
<input
type="time"
value={toTime}
onChange={(e) => setToTime(e.target.value)}
className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 outline-none focus:border-brand-500"
/>
</div>
<label className="block mb-2 text-sm font-medium">Thời gian</label>
<CustomDateRangePicker
onFilterChange={(sDate, eDate, sTime, eTime) => {
setFromDate(sDate);
setToDate(eDate);
setFromTime(sTime);
setToTime(eTime);
}}
/>
</div>
{/* Limit */}

View 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 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 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 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>
);
}

View File

@@ -0,0 +1,492 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import ComponentCard from "@/components/common/ComponentCard";
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
import Pagination from "@/components/tables/Pagination";
import { toast } from "sonner";
import { useRouter } from "next/navigation";
import CustomDateRangePicker from "@/components/common/CustomDateRangePicker";
import SubmissionsTable, {
SubmissionItem,
SubmissionSortColumn,
} from "@/components/tables/SubmissionsTable";
import {
getSubmissionPayload,
updateSubmissionPayload,
} from "@/interface/submission";
import { apiGetSubmission, updateProject } from "@/service/submisisonService";
import { LIMIT_ITEM_TABLE } from "../../../../../constant";
const formatDateTimeToISO = (
dateStr: string,
timeStr: string,
isEndOfDay: boolean = false,
): string | undefined => {
if (!dateStr) return undefined;
const time = timeStr || (isEndOfDay ? "23:59" : "00:00");
return `${dateStr}T${time}:00.000000+07:00`;
};
interface SubmissionsResponseData {
data: SubmissionItem[];
pagination: {
current_page: number;
page_size: number;
total_records: number;
total_pages: number;
};
}
export default function Page() {
const router = useRouter();
const [page, setPage] = useState<number>(1);
const [limitInput, setLimitInput] = useState<string>(
LIMIT_ITEM_TABLE.toString(),
);
const [searchTerm, setSearchTerm] = useState<string>("");
const [statusFilter, setStatusFilter] = useState<string>("");
const [userIdsFilter, setUserIdsFilter] = useState<string>("");
const [fromDate, setFromDate] = useState<string>("");
const [fromTime, setFromTime] = useState<string>("");
const [toDate, setToDate] = useState<string>("");
const [toTime, setToTime] = useState<string>("");
const [resetKey, setResetKey] = useState<number>(0);
const [debouncedParams, setDebouncedParams] = useState({
search: "",
limit: LIMIT_ITEM_TABLE,
statuses: "",
userIds: "",
fromDate: "",
fromTime: "",
toDate: "",
toTime: "",
});
const [tableData, setTableData] = useState<SubmissionsResponseData | null>(
null,
);
const [loading, setLoading] = useState<boolean>(true);
const [sortBy, setSortBy] = useState<SubmissionSortColumn>("created_at");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
const [isModalOpen, setIsModalOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [selectedItem, setSelectedItem] = useState<SubmissionItem | null>(null);
const [updatePayload, setUpdatePayload] = useState<updateSubmissionPayload>({
review_note: "",
status: "APPROVED",
});
const handleReset = () => {
setSearchTerm("");
setStatusFilter("");
setUserIdsFilter("");
setLimitInput(LIMIT_ITEM_TABLE.toString());
setFromDate("");
setFromTime("");
setToDate("");
setToTime("");
setPage(1);
setResetKey((prev) => prev + 1);
};
const handleDateFilterChange = (
startD: string,
endD: string,
startT: string,
endT: string,
) => {
setFromDate(startD);
setToDate(endD);
setFromTime(startT);
setToTime(endT);
};
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedParams({
search: searchTerm,
limit: parseInt(limitInput) || LIMIT_ITEM_TABLE,
statuses: statusFilter,
userIds: userIdsFilter,
fromDate,
fromTime,
toDate,
toTime,
});
setPage(1);
}, 100);
return () => clearTimeout(handler);
}, [
searchTerm,
limitInput,
statusFilter,
userIdsFilter,
fromDate,
fromTime,
toDate,
toTime,
]);
const fetchSubmissionsData = useCallback(async () => {
setLoading(true);
try {
const payload: Partial<getSubmissionPayload> = {
page: page,
limit: debouncedParams.limit,
search: debouncedParams.search || undefined,
sort: sortBy,
order: sortOrder,
statuses: debouncedParams.statuses
? [debouncedParams.statuses]
: undefined,
user_ids: debouncedParams.userIds
? debouncedParams.userIds
.split(",")
.map((id) => id.trim())
.filter(Boolean)
: undefined,
};
const createdFrom = formatDateTimeToISO(
debouncedParams.fromDate,
debouncedParams.fromTime,
);
if (createdFrom) payload.created_from = createdFrom;
const createdTo = formatDateTimeToISO(
debouncedParams.toDate,
debouncedParams.toTime,
true,
);
if (createdTo) payload.created_to = createdTo;
const response = await apiGetSubmission(payload as getSubmissionPayload);
if (response?.status && response?.data) {
setTableData(response.data);
} else {
setTableData(null);
}
} catch (err) {
toast.error("Lỗi lấy danh sách bài nộp");
console.error("Lỗi lấy danh sách bài nộp:", err);
setTableData(null);
} finally {
setLoading(false);
}
}, [page, debouncedParams, sortBy, sortOrder]);
useEffect(() => {
fetchSubmissionsData();
}, [fetchSubmissionsData]);
const handleSort = (column: SubmissionSortColumn) => {
setPage(1);
if (sortBy === column) {
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else {
setSortBy(column);
setSortOrder("desc");
}
};
const handleViewDetails = (id: string) => {
router.push(`/submissions/${id}`);
};
const handleOpenActionModal = (item: SubmissionItem) => {
setSelectedItem(item);
setUpdatePayload({
status: item.status,
review_note: "",
});
setIsModalOpen(true);
};
const handleUpdateSubmit = async () => {
if (!selectedItem) return;
setIsSubmitting(true);
try {
const response = await updateProject(selectedItem.id, updatePayload);
if (response?.status) {
toast.success("Cập nhật thành công!");
setIsModalOpen(false);
fetchSubmissionsData();
} else {
if (response?.errors && Array.isArray(response.errors)) {
toast.error(response.errors.message || "Lỗi dữ liệu không hợp lệ");
} else {
toast.error(response?.message || "Cập nhật thất bại");
}
}
} catch (error: any) {
const apiData = error.response?.data;
if (apiData?.errors && Array.isArray(apiData.errors)) {
apiData.errors.forEach((err: any) => {
if (err.message === "review_note is too short (min 10)") {
toast.error("Nội dung phải có ít nhất 10 ký tự");
} else {
toast.error(err.message || "Dữ liệu không hợp lệ");
}
});
} else {
toast.error("Lỗi hệ thống khi cập nhật");
}
console.error(error);
} finally {
setIsSubmitting(false);
}
};
const pagination = tableData?.pagination;
return (
<div>
<PageBreadcrumb pageTitle="Quản lý Submissions" />
<div className="space-y-6">
<ComponentCard
title="Bộ lọc tìm kiếm"
headerAction={
<button
onClick={handleReset}
className="flex items-center px-3 py-1.5 text-xs text-red-500"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-7 h-7"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2.5}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</button>
}
>
<div className="grid grid-cols-1 gap-4 mb-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<div>
<label className="block mb-2 text-sm font-medium">Tìm kiếm</label>
<input
type="text"
placeholder="Tên dự án, nội dung..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 outline-none focus:border-brand-500"
/>
</div>
<div>
<label className="block mb-2 text-sm font-medium">
Trạng thái
</label>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="w-full px-3 py-2 bg-white dark:bg-gray-900 border rounded-lg cursor-pointer outline-none focus:border-brand-500"
>
<option value="">Tất cả trạng thái</option>
<option value="PENDING">PENDING</option>
<option value="APPROVED">APPROVED</option>
<option value="REJECTED">REJECTED</option>
</select>
</div>
<div>
<label className="block mb-2 text-sm font-medium">
ID người dùng
</label>
<input
type="text"
placeholder="IDs (cách nhau bởi dấu phẩy)"
value={userIdsFilter}
onChange={(e) => setUserIdsFilter(e.target.value)}
className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 outline-none focus:border-brand-500"
/>
</div>
<div>
<label className="block mb-2 text-sm font-medium">
Thời gian
</label>
<CustomDateRangePicker
key={resetKey}
onFilterChange={handleDateFilterChange}
/>
</div>
<div>
<label className="block mb-2 text-sm font-medium">
Hiển thị (Limit)
</label>
<input
type="number"
value={limitInput}
onChange={(e) => setLimitInput(e.target.value)}
className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 outline-none focus:border-brand-500"
/>
</div>
</div>
</ComponentCard>
<ComponentCard title="Danh sách">
<div className="relative min-h-[300px]">
{loading && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/50 dark:bg-gray-900/50 rounded-xl">
<div className="w-10 h-10 border-4 border-t-brand-500 rounded-full animate-spin"></div>
</div>
)}
<SubmissionsTable
data={tableData?.data || []}
onSort={handleSort}
sortBy={sortBy}
sortOrder={sortOrder}
onViewDetails={handleViewDetails}
onActionClick={handleOpenActionModal}
/>
</div>
<div className="flex items-center justify-between mt-6">
<p className="text-sm text-gray-500">
Hiển thị {pagination?.total_records || 0} bản ghi
</p>
{pagination && pagination.total_pages > 1 && (
<Pagination
currentPage={pagination.current_page}
totalPages={pagination.total_pages}
onPageChange={(newPage) => setPage(newPage)}
/>
)}
</div>
</ComponentCard>
</div>
{isModalOpen && (
<div className="fixed inset-0 z-[99] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-xl w-full max-w-md overflow-hidden animate-in fade-in zoom-in duration-200">
<div className="px-6 py-4 border-b border-gray-100 dark:border-gray-800 flex justify-between items-center">
<h3 className="text-lg font-semibold text-gray-800 dark:text-gray-100">
Đánh giá yêu cầu
</h3>
<button
onClick={() => setIsModalOpen(false)}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Trạng thái <span className="text-red-500">*</span>
</label>
<select
value={updatePayload.status}
onChange={(e) =>
setUpdatePayload({
...updatePayload,
status: e.target.value,
})
}
className="w-full px-4 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-gray-100 outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent transition-all"
>
<option value="APPROVED">APPROVED</option>
<option value="REJECTED">REJECTED</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Ghi chú đánh giá (Review Note)
</label>
<textarea
value={updatePayload.review_note}
onChange={(e) =>
setUpdatePayload({
...updatePayload,
review_note: e.target.value,
})
}
placeholder="Nhập ghi chú phản hồi..."
rows={4}
className="w-full px-4 py-3 border border-gray-200 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-gray-100 outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent transition-all resize-none"
/>
</div>
</div>
<div className="px-6 py-4 border-t border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-800/50 flex justify-end gap-3">
<button
onClick={() => setIsModalOpen(false)}
className="px-5 py-2 text-sm font-medium text-gray-600 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-700 dark:hover:bg-gray-700 transition-colors"
>
Hủy
</button>
<button
onClick={handleUpdateSubmit}
disabled={isSubmitting}
className="flex items-center px-5 py-2 text-sm font-medium text-white bg-brand-500 rounded-lg hover:bg-brand-600 focus:ring-4 focus:ring-brand-500/20 transition-all disabled:opacity-70 disabled:cursor-not-allowed"
>
{isSubmitting ? (
<>
<svg
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Đang lưu...
</>
) : (
"Lưu thay đổi"
)}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -44,7 +44,7 @@ export default function AdminLayout({
<Backdrop />
{/* Main Content Area */}
<div
className={`flex-1 transition-all duration-300 ease-in-out ${mainContentMargin}`}
className={`flex-1 min-w-0 transition-all duration-300 ease-in-out ${mainContentMargin}`}
>
{/* Header */}
<AppHeader />

View File

@@ -1,15 +1,12 @@
import type { Metadata } from "next";
import { EcommerceMetrics } from "@/components/ecommerce/EcommerceMetrics";
import React from "react";
import MonthlyTarget from "@/components/ecommerce/MonthlyTarget";
import MonthlySalesChart from "@/components/ecommerce/MonthlySalesChart";
import DailyNewChart from "@/components/ecommerce/DailyNewChart";
import StatisticsChart from "@/components/ecommerce/StatisticsChart";
import RecentOrders from "@/components/ecommerce/RecentOrders";
import DemographicCard from "@/components/ecommerce/DemographicCard";
import DetailedStatsTable from "@/components/ecommerce/DetailedStatsTable";
export const metadata: Metadata = {
title:
"Admin Dashboard",
title: "Admin Dashboard",
description: "This is Dashboard Home for History Web",
};
@@ -18,25 +15,16 @@ export default function Ecommerce() {
<div className="grid grid-cols-12 gap-4 md:gap-6">
<div className="col-span-12 space-y-6 xl:col-span-7">
<EcommerceMetrics />
<MonthlySalesChart />
<DailyNewChart />
</div>
<div className="col-span-12 xl:col-span-5">
<MonthlyTarget />
<DetailedStatsTable />
</div>
<div className="col-span-12">
<StatisticsChart />
</div>
<div className="col-span-12 xl:col-span-5">
<DemographicCard />
</div>
<div className="col-span-12 xl:col-span-7">
<RecentOrders />
</div>
</div>
);
}

View File

@@ -17,7 +17,7 @@ export default function RootLayout({
}>) {
return (
<html lang="en">
<body className={`${inter.className} dark:bg-gray-900`}>
<body className={`${inter.className} dark:bg-gray-900`} suppressHydrationWarning>
<StoreProvider>
<ThemeProvider>
<SidebarProvider>{children} <Toaster closeButton richColors position="top-right" /> </SidebarProvider>

View File

@@ -63,14 +63,14 @@ export default function SignInForm() {
try {
setLoading(true);
const res = await apiSignIn(formData);
// console.log("API Sign In Response:", res);
if (res.status === true) {
toast.success("Đăng nhập thành công!");
const data = await apiGetCurrentUser();
// console.log("Current User Data:", data);
if (data?.data) {
dispatch(setUserData(data.data));
@@ -89,7 +89,7 @@ export default function SignInForm() {
return (
<div className="flex flex-col flex-1 lg:w-1/2 w-full">
<div className="w-full max-w-md sm:pt-10 mx-auto mb-5">
{/* <div className="w-full max-w-md sm:pt-10 mx-auto mb-5">
<Link
href="/"
className="inline-flex items-center text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
@@ -97,7 +97,7 @@ export default function SignInForm() {
<ChevronLeftIcon />
Back to dashboard
</Link>
</div>
</div> */}
<div className="flex flex-col justify-center flex-1 w-full max-w-md mx-auto">
<div>
<div className="mb-5 sm:mb-8">

View File

@@ -131,11 +131,11 @@ export default function SignUpForm() {
<div className="flex flex-col flex-1 lg:w-1/2 w-full overflow-y-auto no-scrollbar">
<div className="w-full max-w-md sm:pt-10 mx-auto mb-5">
<Link
href="/"
href="/signin"
className="inline-flex items-center text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
>
<ChevronLeftIcon />
Back to dashboard
Back to Signin
</Link>
</div>
@@ -284,7 +284,7 @@ export default function SignUpForm() {
<div
className={
formData.password.length > 0 &&
!isValidPassword(formData.password)
!isValidPassword(formData.password)
? "opacity-50 cursor-not-allowed"
: ""
}

View File

@@ -0,0 +1,328 @@
"use client";
import { useState, useRef, useEffect, useMemo } from "react";
interface CustomDateRangePickerProps {
onFilterChange: (
startDate: string,
endDate: string,
startTime: string,
endTime: string
) => void;
}
const DAYS_OF_WEEK = ["Th2", "Th3", "Th4", "Th5", "Th6", "Th7", "CN"];
const MONTHS = [
"Tháng 1", "Tháng 2", "Tháng 3", "Tháng 4", "Tháng 5", "Tháng 6",
"Tháng 7", "Tháng 8", "Tháng 9", "Tháng 10", "Tháng 11", "Tháng 12"
];
const getStartOfDay = (date: Date) => new Date(date.getFullYear(), date.getMonth(), date.getDate());
const formatDateToString = (date: Date | null) => {
if (!date) return "";
const d = date.getDate().toString().padStart(2, "0");
const m = (date.getMonth() + 1).toString().padStart(2, "0");
const y = date.getFullYear();
return `${y}-${m}-${d}`;
};
export default function CustomDateRangePicker({ onFilterChange }: CustomDateRangePickerProps) {
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const [currentMonthDate, setCurrentMonthDate] = useState(getStartOfDay(new Date()));
// LOGIC MỚI: Tách biệt rõ ràng state Từ ngày và Đến ngày
const [startTimestamp, setStartTimestamp] = useState<number | null>(null);
const [endTimestamp, setEndTimestamp] = useState<number | null>(null);
// History dùng để theo dõi thứ tự click, phục vụ "Cửa sổ trượt" giữ 2 ngày gần nhất
const [history, setHistory] = useState<number[]>([]);
const [startTime, setStartTime] = useState<string>("00:00");
const [endTime, setEndTime] = useState<string>("23:59");
const startDate = startTimestamp ? new Date(startTimestamp) : null;
const endDate = endTimestamp ? new Date(endTimestamp) : null;
// Click outside to close
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
// Update parent
useEffect(() => {
const sDate = startDate ? formatDateToString(startDate) : "";
const eDate = endDate ? formatDateToString(endDate) : "";
const sTime = startDate ? startTime : "";
const eTime = endDate ? endTime : "";
onFilterChange(sDate, eDate, sTime, eTime);
}, [startTimestamp, endTimestamp, startTime, endTime]);
const { daysInMonth, emptyDays } = useMemo(() => {
const year = currentMonthDate.getFullYear();
const month = currentMonthDate.getMonth();
const days = new Date(year, month + 1, 0).getDate();
let firstDayIndex = new Date(year, month, 1).getDay() - 1;
if (firstDayIndex < 0) firstDayIndex = 6;
return { daysInMonth: days, emptyDays: firstDayIndex };
}, [currentMonthDate]);
const handleDayClick = (day: number) => {
const clickedTime = new Date(currentMonthDate.getFullYear(), currentMonthDate.getMonth(), day).getTime();
if (clickedTime === startTimestamp) {
setStartTimestamp(null);
setHistory(prev => prev.filter(t => t !== clickedTime));
return;
}
if (clickedTime === endTimestamp) {
setEndTimestamp(null);
setHistory(prev => prev.filter(t => t !== clickedTime));
return;
}
let newStart = startTimestamp;
let newEnd = endTimestamp;
let newHistory = [...history];
if (newStart && newEnd) {
const oldest = newHistory[0];
if (oldest === newStart) newStart = null;
else if (oldest === newEnd) newEnd = null;
newHistory.shift();
}
if (!newStart && !newEnd) {
newEnd = clickedTime;
}
else if (!newStart && newEnd) {
if (clickedTime < newEnd) {
newStart = clickedTime;
} else {
newStart = newEnd;
newEnd = clickedTime;
}
}
else if (newStart && !newEnd) {
if (clickedTime > newStart) {
newEnd = clickedTime;
} else {
newEnd = newStart;
newStart = clickedTime;
}
}
newHistory.push(clickedTime);
setStartTimestamp(newStart);
setEndTimestamp(newEnd);
setHistory(newHistory);
};
const handleManualDateChange = (val: string, target: 'start' | 'end') => {
const newTime = val ? new Date(val).getTime() : null;
if (target === 'start') {
const oldTime = startTimestamp;
setStartTimestamp(newTime);
setHistory(prev => {
let h = prev;
if (oldTime) h = h.filter(t => t !== oldTime);
if (newTime) h = [...h, newTime];
return h.slice(-2);
});
} else {
const oldTime = endTimestamp;
setEndTimestamp(newTime);
setHistory(prev => {
let h = prev;
if (oldTime) h = h.filter(t => t !== oldTime);
if (newTime) h = [...h, newTime];
return h.slice(-2);
});
}
};
const nextMonth = () => setCurrentMonthDate(new Date(currentMonthDate.getFullYear(), currentMonthDate.getMonth() + 1, 1));
const prevMonth = () => setCurrentMonthDate(new Date(currentMonthDate.getFullYear(), currentMonthDate.getMonth() - 1, 1));
const nextYear = () => setCurrentMonthDate(new Date(currentMonthDate.getFullYear() + 1, currentMonthDate.getMonth(), 1));
const prevYear = () => setCurrentMonthDate(new Date(currentMonthDate.getFullYear() - 1, currentMonthDate.getMonth(), 1));
const renderDateBox = (date: Date | null) => {
if (!date) return "mm/dd/yyyy";
const d = date.getDate().toString().padStart(2, "0");
const m = (date.getMonth() + 1).toString().padStart(2, "0");
const y = date.getFullYear();
return `${m}/${d}/${y}`;
};
const isSelected = (day: number) => {
const d = new Date(currentMonthDate.getFullYear(), currentMonthDate.getMonth(), day).getTime();
return d === startTimestamp || d === endTimestamp;
};
const isInRange = (day: number) => {
const d = new Date(currentMonthDate.getFullYear(), currentMonthDate.getMonth(), day).getTime();
if (startTimestamp && endTimestamp && d > startTimestamp && d < endTimestamp) return true;
return false;
};
return (
<div className="relative w-full" ref={containerRef}>
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className="flex items-center justify-between w-full px-4 py-2 text-sm border rounded-lg bg-white dark:bg-gray-800 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-500 transition-all outline-none text-gray-700 dark:text-gray-200"
>
<span>
{(!startDate && !endDate)
? "mm/dd/yyyy - mm/dd/yyyy"
: `${renderDateBox(startDate)} - ${renderDateBox(endDate)}`
}
</span>
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5 text-gray-500" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" clipRule="evenodd" />
</svg>
</button>
{isOpen && (
<div className="absolute top-12 left-0 z-50 w-[320px] p-4 bg-[#22272e] border border-gray-700 rounded-xl shadow-2xl text-gray-200">
<div className="flex items-center justify-between mb-4">
<span className="font-semibold mx-auto">Ngày</span>
<button onClick={() => setIsOpen(false)} className="absolute right-4 text-gray-400 hover:text-white">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</button>
</div>
<div className="flex items-center justify-between mb-4 text-sm">
<div className="flex gap-2">
<button onClick={prevYear} className="p-1 hover:bg-gray-700 rounded text-gray-400 hover:text-white">«</button>
<button onClick={prevMonth} className="p-1 hover:bg-gray-700 rounded text-gray-400 hover:text-white"></button>
</div>
<span className="font-medium">
{MONTHS[currentMonthDate.getMonth()]} {currentMonthDate.getFullYear()}
</span>
<div className="flex gap-2">
<button onClick={nextMonth} className="p-1 hover:bg-gray-700 rounded text-gray-400 hover:text-white"></button>
<button onClick={nextYear} className="p-1 hover:bg-gray-700 rounded text-gray-400 hover:text-white">»</button>
</div>
</div>
<div className="grid grid-cols-7 gap-y-2 mb-6 text-center text-sm">
{DAYS_OF_WEEK.map(day => (
<div key={day} className="font-medium text-gray-400">{day}</div>
))}
{Array.from({ length: emptyDays }).map((_, i) => (
<div key={`empty-${i}`} className="text-gray-600"></div>
))}
{Array.from({ length: daysInMonth }).map((_, i) => {
const day = i + 1;
const selected = isSelected(day);
const inRange = isInRange(day);
return (
<button
key={day}
onClick={() => handleDayClick(day)}
className={`w-8 h-8 mx-auto flex items-center justify-center rounded-md transition-colors
${selected ? 'bg-blue-600 text-white font-semibold' : ''}
${inRange && !selected ? 'bg-blue-900/50 text-blue-200' : ''}
${!selected && !inRange ? 'hover:bg-gray-700 text-gray-300' : ''}
`}
>
{day}
</button>
);
})}
</div>
<hr className="border-gray-700 my-4" />
<div className="space-y-4">
<div>
<label className="block text-xs font-semibold text-gray-400 mb-2">Từ ngày</label>
<div className="flex items-center gap-3">
<input
type="checkbox"
checked={!!startTimestamp}
onChange={(e) => {
if (!e.target.checked) {
setStartTimestamp(null);
setHistory(prev => prev.filter(t => t !== startTimestamp));
}
}}
className="w-4 h-4 rounded border-gray-600 bg-transparent text-blue-500 focus:ring-0 cursor-pointer"
/>
<input
type="date"
disabled={!startTimestamp}
value={startDate ? formatDateToString(startDate) : ""}
onChange={(e) => handleManualDateChange(e.target.value, 'start')}
className="flex-1 px-3 py-1.5 bg-[#2d333b] border border-gray-600 rounded-md text-sm outline-none focus:border-blue-500 disabled:opacity-50"
/>
<select
disabled={!startTimestamp}
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
className="w-[100px] px-3 py-1.5 bg-[#2d333b] border border-gray-600 rounded-md text-sm outline-none focus:border-blue-500 disabled:opacity-50 cursor-pointer"
>
<option value="00:00">00:00</option>
<option value="08:00">08:00</option>
<option value="12:00">12:00</option>
<option value="21:22">21:22</option>
<option value="23:59">23:59</option>
</select>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-gray-400 mb-2">Đến ngày</label>
<div className="flex items-center gap-3">
<input
type="checkbox"
checked={!!endTimestamp}
onChange={(e) => {
if (!e.target.checked) {
setEndTimestamp(null);
setHistory(prev => prev.filter(t => t !== endTimestamp));
}
}}
className="w-4 h-4 rounded border-gray-600 bg-transparent text-blue-500 focus:ring-0 cursor-pointer"
/>
<input
type="date"
disabled={!endTimestamp}
value={endDate ? formatDateToString(endDate) : ""}
onChange={(e) => handleManualDateChange(e.target.value, 'end')}
className="flex-1 px-3 py-1.5 bg-[#2d333b] border border-gray-600 rounded-md text-sm outline-none focus:border-blue-500 disabled:opacity-50"
/>
<select
disabled={!endTimestamp}
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
className="w-[100px] px-3 py-1.5 bg-[#2d333b] border border-gray-600 rounded-md text-sm outline-none focus:border-blue-500 disabled:opacity-50 cursor-pointer"
>
<option value="00:00">00:00</option>
<option value="08:00">08:00</option>
<option value="12:00">12:00</option>
<option value="21:22">21:22</option>
<option value="23:59">23:59</option>
</select>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,181 @@
"use client";
import { useEffect, useState } from "react";
import dynamic from "next/dynamic";
import { ApexOptions } from "apexcharts";
import { getStatistics } from "@/service/statisticsService";
import { StatisticResponse } from "@/interface/statistics";
const Chart = dynamic(() => import("react-apexcharts"), { ssr: false });
const METRICS = [
{ key: "users", name: "Người dùng" },
{ key: "projects", name: "Dự án" },
{ key: "commits", name: "Bản lưu" },
{ key: "submissions", name: "Duyệt tin" },
{ key: "medias", name: "Tệp đa phương tiện" },
{ key: "wikis", name: "Bài viết Wiki" },
{ key: "entities", name: "Thực thể" },
{ key: "geometries", name: "Dữ liệu không gian" },
];
export default function MonthlyNewChart() {
const [startDate, setStartDate] = useState<string>("");
const [endDate, setEndDate] = useState<string>("");
const [chartData, setChartData] = useState<{ month: string; value: number }[]>([]);
const [selectedMetricKey, setSelectedMetricKey] = useState<string>("users");
const [loading, setLoading] = useState(true);
const fetchChartData = async (start: string, end: string, metric: string) => {
if (!start || !end) return;
setLoading(true);
try {
const response = await getStatistics({ start_date: start, end_date: end });
if (response?.data) {
// Tổng hợp theo tháng
const monthsMap: { [key: string]: number } = {};
response.data.forEach(item => {
const date = new Date(item.date);
const monthKey = `${String(date.getMonth() + 1).padStart(2, '0')}/${date.getFullYear()}`;
const val = item[`new_${metric}` as keyof StatisticResponse] as number || 0;
if (!monthsMap[monthKey]) {
monthsMap[monthKey] = 0;
}
monthsMap[monthKey] += val;
});
const aggregated = Object.entries(monthsMap).map(([key, value]) => ({
month: key,
value: value
})).sort((a, b) => {
const [mA, yA] = a.month.split('/').map(Number);
const [mB, yB] = b.month.split('/').map(Number);
return yA !== yB ? yA - yB : mA - mB;
});
setChartData(aggregated);
}
} catch (error) {
console.error("Failed to fetch chart data", error);
} finally {
setLoading(false);
}
};
useEffect(() => {
const today = new Date();
const startOfYear = new Date(today.getFullYear(), 0, 1); // Từ đầu năm
const todayStr = today.toISOString().split('T')[0];
const startOfYearStr = startOfYear.toISOString().split('T')[0];
setStartDate(startOfYearStr);
setEndDate(todayStr);
fetchChartData(startOfYearStr, todayStr, selectedMetricKey);
}, []);
const handleStartDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
setStartDate(val);
fetchChartData(val, endDate, selectedMetricKey);
};
const handleEndDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
setEndDate(val);
fetchChartData(startDate, val, selectedMetricKey);
};
const handleMetricChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const val = e.target.value;
setSelectedMetricKey(val);
fetchChartData(startDate, endDate, val);
};
const categories = chartData.map(item => item.month);
const series = [
{
name: "Tạo mới",
data: chartData.map(item => item.value),
}
];
const options: ApexOptions = {
colors: ["#465fff"],
chart: {
fontFamily: "Outfit, sans-serif",
type: "bar",
height: 180,
toolbar: { show: false },
},
plotOptions: {
bar: {
horizontal: false,
columnWidth: "40%",
borderRadius: 5,
borderRadiusApplication: "end",
},
},
dataLabels: { enabled: false },
stroke: { show: true, width: 4, colors: ["transparent"] },
xaxis: {
categories: categories,
axisBorder: { show: false },
axisTicks: { show: false },
},
legend: { show: false },
yaxis: { labels: { style: { colors: ["#6B7280"] } } },
grid: { yaxis: { lines: { show: true } } },
fill: { opacity: 1 },
tooltip: { y: { formatter: (val: number) => `${val}` } },
};
return (
<div className="overflow-hidden rounded-2xl border border-gray-200 bg-white px-5 pt-5 dark:border-gray-800 dark:bg-white/[0.03] sm:px-6 sm:pt-6">
<div className="flex flex-col gap-4 mb-4 sm:flex-row sm:items-center sm:justify-between">
<h3 className="text-lg font-semibold text-gray-800 dark:text-white/90">
Lượng tạo mới hàng tháng
</h3>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<select
value={selectedMetricKey}
onChange={handleMetricChange}
className="px-2 py-1 text-xs font-medium text-gray-700 bg-white border border-gray-200 rounded-lg focus:outline-none focus:border-brand-300 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300"
>
{METRICS.map((metric) => (
<option key={metric.key} value={metric.key}>
{metric.name}
</option>
))}
</select>
<input
type="date"
value={startDate}
onChange={handleStartDateChange}
className="px-2 py-1 text-xs rounded-lg border border-gray-200 bg-white text-gray-700 outline-none dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 cursor-pointer"
/>
<input
type="date"
value={endDate}
onChange={handleEndDateChange}
className="px-2 py-1 text-xs rounded-lg border border-gray-200 bg-white text-gray-700 outline-none dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 cursor-pointer"
/>
</div>
</div>
<div className="max-w-full overflow-x-auto custom-scrollbar">
<div className="-ml-5 min-w-[650px] xl:min-w-full pl-2">
{loading ? (
<div className="flex items-center justify-center h-[180px]">Đang tải...</div>
) : (
<Chart options={options} series={series} type="bar" height={180} />
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,92 @@
"use client";
import React, { useEffect, useState } from "react";
import { getStatisticByDate } from "@/service/statisticsService";
import { StatisticResponse } from "@/interface/statistics";
const METRICS_MAP = [
{ key: "users", name: "Người dùng" },
{ key: "projects", name: "Dự án" },
{ key: "commits", name: "Bản lưu" },
{ key: "submissions", name: "Duyệt tin" },
{ key: "medias", name: "Tệp đa phương tiện" },
{ key: "wikis", name: "Bài viết Wiki" },
{ key: "entities", name: "Thực thể" },
{ key: "geometries", name: "Dữ liệu không gian" },
];
export default function DetailedStatsTable() {
const [todayData, setTodayData] = useState<StatisticResponse | null>(null);
const [yesterdayData, setYesterdayData] = useState<StatisticResponse | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
try {
const today = new Date();
const yesterday = new Date();
yesterday.setDate(today.getDate() - 1);
const todayStr = today.toISOString().split('T')[0];
const yesterdayStr = yesterday.toISOString().split('T')[0];
const [todayRes, yesterdayRes] = await Promise.all([
getStatisticByDate(todayStr),
getStatisticByDate(yesterdayStr)
]);
if (todayRes?.data) setTodayData(todayRes.data);
if (yesterdayRes?.data) setYesterdayData(yesterdayRes.data);
} catch (error) {
console.error("Failed to fetch detailed statistics", error);
} finally {
setLoading(false);
}
};
fetchData();
}, []);
const calculateGrowth = (today: number, yesterday: number) => {
if (!yesterday || yesterday === 0) return 0;
return ((today - yesterday) / yesterday) * 100;
};
return (
<div className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] md:p-6">
<h3 className="mb-4 text-lg font-semibold text-gray-800 dark:text-white/90">
Bảng Số Liệu Chi Tiết (Hôm nay vs Hôm qua)
</h3>
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-800">
<th className="py-3 text-sm font-medium text-gray-500 dark:text-gray-400">Nội dung</th>
<th className="py-3 text-sm font-medium text-gray-500 dark:text-gray-400">Hôm nay</th>
<th className="py-3 text-sm font-medium text-gray-500 dark:text-gray-400">Hôm qua</th>
<th className="py-3 text-sm font-medium text-gray-500 dark:text-gray-400">Tăng trưởng</th>
</tr>
</thead>
<tbody>
{METRICS_MAP.map((metric) => {
const todayVal = todayData ? (todayData[`total_${metric.key}` as keyof StatisticResponse] as number) : 0;
const yesterdayVal = yesterdayData ? (yesterdayData[`total_${metric.key}` as keyof StatisticResponse] as number) : 0;
const growth = calculateGrowth(todayVal, yesterdayVal);
return (
<tr key={metric.key} className="border-b border-gray-100 dark:border-gray-800/50 last:border-0">
<td className="py-3 text-sm font-medium text-gray-800 dark:text-white/90">{metric.name}</td>
<td className="py-3 text-sm text-gray-600 dark:text-gray-300">{loading ? "..." : todayVal.toLocaleString()}</td>
<td className="py-3 text-sm text-gray-600 dark:text-gray-300">{loading ? "..." : yesterdayVal.toLocaleString()}</td>
<td className={`py-3 text-sm font-medium ${growth >= 0 ? "text-success-500" : "text-error-500"}`}>
{loading ? "..." : `${growth >= 0 ? "+" : ""}${growth.toFixed(2)}%`}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -1,9 +1,50 @@
"use client";
import React from "react";
import React, { useEffect, useState } from "react";
import Badge from "../ui/badge/Badge";
import { ArrowDownIcon, ArrowUpIcon, BoxIconLine, GroupIcon } from "@/icons";
import { getStatisticByDate } from "@/service/statisticsService";
import { StatisticResponse } from "@/interface/statistics";
export const EcommerceMetrics = () => {
const [todayData, setTodayData] = useState<StatisticResponse | null>(null);
const [yesterdayData, setYesterdayData] = useState<StatisticResponse | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
try {
const today = new Date();
const yesterday = new Date();
yesterday.setDate(today.getDate() - 1);
const todayStr = today.toISOString().split('T')[0];
const yesterdayStr = yesterday.toISOString().split('T')[0];
const [todayRes, yesterdayRes] = await Promise.all([
getStatisticByDate(todayStr),
getStatisticByDate(yesterdayStr)
]);
if (todayRes?.data) setTodayData(todayRes.data);
if (yesterdayRes?.data) setYesterdayData(yesterdayRes.data);
} catch (error) {
console.error("Failed to fetch statistics", error);
} finally {
setLoading(false);
}
};
fetchData();
}, []);
const calculateGrowth = (today: number, yesterday: number) => {
if (!yesterday || yesterday === 0) return 0;
return ((today - yesterday) / yesterday) * 100;
};
const userGrowth = todayData && yesterdayData ? calculateGrowth(todayData.total_users, yesterdayData.total_users) : 0;
const projectGrowth = todayData && yesterdayData ? calculateGrowth(todayData.total_projects, yesterdayData.total_projects) : 0;
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:gap-6">
{/* <!-- Metric Item Start --> */}
@@ -15,15 +56,15 @@ export const EcommerceMetrics = () => {
<div className="flex items-end justify-between mt-5">
<div>
<span className="text-sm text-gray-500 dark:text-gray-400">
Customers
Tổng Người Dùng
</span>
<h4 className="mt-2 font-bold text-gray-800 text-title-sm dark:text-white/90">
3,782
{loading ? "..." : todayData?.total_users?.toLocaleString() || "N/A"}
</h4>
</div>
<Badge color="success">
<ArrowUpIcon />
11.01%
<Badge color={userGrowth >= 0 ? "success" : "error"}>
{userGrowth >= 0 ? <ArrowUpIcon /> : <ArrowDownIcon className="text-error-500" />}
{Math.abs(userGrowth).toFixed(2)}%
</Badge>
</div>
</div>
@@ -37,16 +78,16 @@ export const EcommerceMetrics = () => {
<div className="flex items-end justify-between mt-5">
<div>
<span className="text-sm text-gray-500 dark:text-gray-400">
Orders
Tổng Dự Án
</span>
<h4 className="mt-2 font-bold text-gray-800 text-title-sm dark:text-white/90">
5,359
{loading ? "..." : todayData?.total_projects?.toLocaleString() || "N/A"}
</h4>
</div>
<Badge color="error">
<ArrowDownIcon className="text-error-500" />
9.05%
<Badge color={projectGrowth >= 0 ? "success" : "error"}>
{projectGrowth >= 0 ? <ArrowUpIcon /> : <ArrowDownIcon className="text-error-500" />}
{Math.abs(projectGrowth).toFixed(2)}%
</Badge>
</div>
</div>
@@ -54,3 +95,5 @@ export const EcommerceMetrics = () => {
</div>
);
};
export default EcommerceMetrics;

View File

@@ -1,170 +1,168 @@
"use client";
import { useEffect, useRef } from "react";
import { useEffect, useState } from "react";
import dynamic from "next/dynamic";
import { ApexOptions } from "apexcharts";
import flatpickr from "flatpickr";
import ChartTab from "../common/ChartTab";
import { CalenderIcon } from "../../icons";
import { getStatistics } from "@/service/statisticsService";
import { StatisticResponse } from "@/interface/statistics";
const Chart = dynamic(() => import("react-apexcharts"), { ssr: false });
const METRICS = [
{ key: "users", name: "Người dùng" },
{ key: "projects", name: "Dự án" },
{ key: "commits", name: "Bản lưu" },
{ key: "submissions", name: "Duyệt tin" },
{ key: "medias", name: "Tệp đa phương tiện" },
{ key: "wikis", name: "Bài viết Wiki" },
{ key: "entities", name: "Thực thể" },
{ key: "geometries", name: "Dữ liệu không gian" },
];
export default function StatisticsChart() {
const datePickerRef = useRef<HTMLInputElement>(null);
const [startDate, setStartDate] = useState<string>("");
const [endDate, setEndDate] = useState<string>("");
const [chartData, setChartData] = useState<StatisticResponse[]>([]);
const [selectedMetricKey, setSelectedMetricKey] = useState<string>("users");
const [loading, setLoading] = useState(true);
const fetchChartData = async (start: string, end: string) => {
if (!start || !end) return;
setLoading(true);
try {
const response = await getStatistics({ start_date: start, end_date: end });
if (response?.data) {
setChartData(response.data);
}
} catch (error) {
console.error("Failed to fetch chart data", error);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (!datePickerRef.current) return;
const today = new Date();
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(today.getDate() - 6);
const fp = flatpickr(datePickerRef.current, {
mode: "range",
static: true,
monthSelectorType: "static",
dateFormat: "M d",
defaultDate: [sevenDaysAgo, today],
clickOpens: true,
prevArrow:
'<svg class="stroke-current" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12.5 15L7.5 10L12.5 5" stroke="" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>',
nextArrow:
'<svg class="stroke-current" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M7.5 15L12.5 10L7.5 5" stroke="" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>',
});
const todayStr = today.toISOString().split('T')[0];
const sevenDaysAgoStr = sevenDaysAgo.toISOString().split('T')[0];
return () => {
if (!Array.isArray(fp)) {
fp.destroy();
}
};
setStartDate(sevenDaysAgoStr);
setEndDate(todayStr);
fetchChartData(sevenDaysAgoStr, todayStr);
}, []);
const options: ApexOptions = {
legend: {
show: false, // Hide legend
position: "top",
horizontalAlign: "left",
const handleStartDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
setStartDate(val);
fetchChartData(val, endDate);
};
const handleEndDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
setEndDate(val);
fetchChartData(startDate, val);
};
const categories = chartData.map(item => {
const date = new Date(item.date);
return `${date.getDate()}/${date.getMonth() + 1}`;
});
const series = [
{
name: "Tổng cộng",
data: chartData.map(item => item[`total_${selectedMetricKey}` as keyof StatisticResponse] as number || 0),
},
colors: ["#465FFF", "#9CB9FF"], // Define line colors
{
name: "Mới",
data: chartData.map(item => item[`new_${selectedMetricKey}` as keyof StatisticResponse] as number || 0),
}
];
const options: ApexOptions = {
legend: { show: true, position: "top" },
colors: ["#465FFF", "#9CB9FF"],
chart: {
fontFamily: "Outfit, sans-serif",
height: 310,
type: "line", // Set the chart type to 'line'
toolbar: {
show: false, // Hide chart toolbar
},
type: "area",
toolbar: { show: false },
},
stroke: {
curve: "straight", // Define the line style (straight, smooth, or step)
width: [2, 2], // Line width for each dataset
},
stroke: { curve: "smooth", width: [2, 2] },
fill: {
type: "gradient",
gradient: {
opacityFrom: 0.55,
opacityTo: 0,
},
},
markers: {
size: 0, // Size of the marker points
strokeColors: "#fff", // Marker border color
strokeWidth: 2,
hover: {
size: 6, // Marker size on hover
},
gradient: { opacityFrom: 0.55, opacityTo: 0 },
},
grid: {
xaxis: {
lines: {
show: false, // Hide grid lines on x-axis
},
},
yaxis: {
lines: {
show: true, // Show grid lines on y-axis
},
},
},
dataLabels: {
enabled: false, // Disable data labels
xaxis: { lines: { show: false } },
yaxis: { lines: { show: true } },
},
dataLabels: { enabled: false },
tooltip: {
enabled: true, // Enable tooltip
x: {
format: "dd MMM yyyy", // Format for x-axis tooltip
},
enabled: true,
x: { format: "dd MMM" },
},
xaxis: {
type: "category", // Category-based x-axis
categories: [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
],
axisBorder: {
show: false, // Hide x-axis border
},
axisTicks: {
show: false, // Hide x-axis ticks
},
tooltip: {
enabled: false, // Disable tooltip for x-axis points
},
type: "category",
categories: categories,
axisBorder: { show: false },
axisTicks: { show: false },
},
yaxis: {
labels: {
style: {
fontSize: "12px", // Adjust font size for y-axis labels
colors: ["#6B7280"], // Color of the labels
},
},
title: {
text: "", // Remove y-axis title
style: {
fontSize: "0px",
fontSize: "12px",
colors: ["#6B7280"],
},
},
},
};
const series = [
{
name: "Sales",
data: [180, 190, 170, 160, 175, 165, 170, 205, 230, 210, 240, 235],
},
{
name: "Revenue",
data: [40, 30, 50, 40, 55, 40, 70, 100, 110, 120, 150, 140],
},
];
return (
<div className="rounded-2xl border border-gray-200 bg-white px-5 pb-5 pt-5 dark:border-gray-800 dark:bg-white/[0.03] sm:px-6 sm:pt-6">
<div className="flex flex-col gap-5 mb-6 sm:flex-row sm:justify-between">
<div className="w-full">
<h3 className="text-lg font-semibold text-gray-800 dark:text-white/90">
Statistics
Thống hệ thống
</h3>
<p className="mt-1 text-gray-500 text-theme-sm dark:text-gray-400">
Target you've set for each month
Dữ liệu thống theo thời gian
</p>
</div>
<div className="flex items-center gap-3 sm:justify-end">
<ChartTab />
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-end">
{/* Dropdown chọn Metric */}
<select
value={selectedMetricKey}
onChange={(e) => setSelectedMetricKey(e.target.value)}
className="px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-200 rounded-lg focus:outline-none focus:border-brand-300 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300"
>
{METRICS.map((metric) => (
<option key={metric.key} value={metric.key}>
{metric.name}
</option>
))}
</select>
{/* Date Picker Start */}
<div className="relative inline-flex items-center">
<CalenderIcon className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 lg:left-3 lg:top-1/2 lg:translate-x-0 lg:-translate-y-1/2 text-gray-500 dark:text-gray-400 pointer-events-none z-10" />
<input
ref={datePickerRef}
className="h-10 w-10 lg:w-40 lg:h-auto lg:pl-10 lg:pr-3 lg:py-2 rounded-lg border border-gray-200 bg-white text-sm font-medium text-transparent lg:text-gray-700 outline-none dark:border-gray-700 dark:bg-gray-800 dark:lg:text-gray-300 cursor-pointer"
placeholder="Select date range"
type="date"
value={startDate}
onChange={handleStartDateChange}
className="px-3 py-2 rounded-lg border border-gray-200 bg-white text-sm font-medium text-gray-700 outline-none dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 cursor-pointer"
/>
</div>
{/* Date Picker End */}
<div className="relative inline-flex items-center">
<input
type="date"
value={endDate}
onChange={handleEndDateChange}
className="px-3 py-2 rounded-lg border border-gray-200 bg-white text-sm font-medium text-gray-700 outline-none dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 cursor-pointer"
/>
</div>
</div>
@@ -172,7 +170,11 @@ export default function StatisticsChart() {
<div className="max-w-full overflow-x-auto custom-scrollbar">
<div className="min-w-[1000px] xl:min-w-full">
<Chart options={options} series={series} type="area" height={310} />
{loading ? (
<div className="flex items-center justify-center h-[310px]">Đang tải...</div>
) : (
<Chart options={options} series={series} type="area" height={310} />
)}
</div>
</div>
</div>

View File

@@ -149,7 +149,7 @@ export default function ApplicationTable({
return (
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-white/[0.05] dark:bg-white/[0.03]">
<div className="max-w-full overflow-x-auto">
<div className="min-w-[1300px]">
<div className="min-w-[1000px]">
<Table>
<TableHeader className="border-b border-gray-100 dark:border-white/[0.05]">
<TableRow>
@@ -161,7 +161,7 @@ export default function ApplicationTable({
</TableCell>
<TableCell
isHeader
className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400 min-w-[220px]"
className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400 min-w-[150px]"
>
Loại xác minh
</TableCell>
@@ -212,7 +212,7 @@ export default function ApplicationTable({
</TableCell>
<TableCell
isHeader
className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400 max-w-[200px]"
className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400 max-w-[150px]"
>
Ghi chú
</TableCell>

View File

@@ -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]">
{item.original_name}
</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>{getMimeTypeBadge(item.mime_type)}</div>
<span className="text-[10px] text-gray-400">{item.mime_type}</span>

View File

@@ -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

View File

@@ -0,0 +1,250 @@
"use client";
import Image from "next/image";
import Badge from "../ui/badge/Badge";
export type ProjectSortColumn = "created_at" | "updated_at" | "title";
export interface ProjectItem {
id: string;
title: string;
description: string;
project_status: "PRIVATE" | "PUBLIC" | "ARCHIVE";
owner_id: string;
created_at: string;
updated_at: string;
user: {
id: string;
display_name: string;
email: string;
avatar_url: string;
};
members?: {
user_id: string;
role: string;
display_name: string;
avatar_url: string;
}[];
}
interface ProjectsTableProps {
data: ProjectItem[];
onSort: (column: ProjectSortColumn) => void;
sortBy?: ProjectSortColumn;
sortOrder?: "asc" | "desc";
onViewDetails: (id: string) => void;
}
export default function ProjectsTable({
data,
onSort,
sortBy,
sortOrder,
onViewDetails,
}: ProjectsTableProps) {
const formatDate = (dateString: string | null | undefined) => {
if (!dateString) return "-";
const date = new Date(dateString);
return `${date.toLocaleDateString("vi-VN", {
day: "2-digit",
month: "short",
year: "numeric",
})}`;
};
const getStatusBadge = (status: ProjectItem["project_status"]) => {
switch (status) {
case "PUBLIC":
return (
<Badge size="sm" variant="light" color="success">
PUBLIC
</Badge>
);
case "PRIVATE":
return (
<Badge size="sm" variant="light" color="warning">
PRIVATE
</Badge>
);
case "ARCHIVE":
return (
<Badge size="sm" variant="light" color="light">
ARCHIVE
</Badge>
);
default:
return (
<Badge size="sm" variant="light" color="dark">
{status}
</Badge>
);
}
};
const SortButton = ({
column,
label,
}: {
column: ProjectSortColumn;
label: string;
}) => {
const isActive = sortBy === column;
return (
<button
onClick={() => onSort(column)}
className={`text-sm font-medium text-left hover:text-blue-500 transition-colors ${isActive
? "text-blue-600 dark:text-blue-400"
: "text-gray-500 dark:text-gray-400"
}`}
>
{label} {isActive && (sortOrder === "asc" ? "↑" : "↓")}
</button>
);
};
return (
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-[#0d1117]">
<div className="max-w-full overflow-x-auto custom-scrollbar">
<div className="min-w-[700px]">
<div className="flex items-center px-5 py-3 border-b border-gray-200 dark:border-gray-800 bg-gray-50/50 dark:bg-[#161b22]">
<div className="flex-1 pr-4 flex items-center gap-3">
<span className="text-sm font-medium text-gray-400 dark:text-gray-500">
Sắp xếp:
</span>
<SortButton column="title" label="Tên dự án" />
</div>
<div className="w-40 shrink-0">
<SortButton column="created_at" label="Ngày tạo" />
</div>
<div className="w-40 shrink-0">
<SortButton column="updated_at" label="Cập nhật" />
</div>
<div className="w-[120px] shrink-0 text-right">
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">
Thành viên
</span>
</div>
</div>
<div className="flex flex-col divide-y divide-gray-200 dark:divide-gray-800">
{data.length > 0 ? (
data.map((item) => (
<div
key={item.id}
className="group flex flex-col p-5 md:flex-row md:items-center gap-3 md:gap-0 hover:bg-gray-50 dark:hover:bg-[#161b22] transition-colors"
>
<div className="flex-1 pr-4 min-w-0">
<div
onClick={() => onViewDetails(item.id)}
className="flex items-center gap-2 cursor-pointer hover:underline w-full"
>
<div className="w-6 h-6 shrink-0 flex items-center justify-center">
{item.user?.avatar_url ? (
<div className="relative w-6 h-6 rounded-full overflow-hidden border border-gray-200 dark:border-gray-800">
<Image
src={item.user.avatar_url}
alt="avatar"
fill
className="object-cover rounded-full"
/>
</div>
) : (
<div className="w-6 h-6 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center border border-gray-300 dark:border-gray-600">
<span className="text-[10px] font-bold text-gray-500 dark:text-gray-300 leading-none">
{item.user?.display_name?.charAt(0)?.toUpperCase() || "U"}
</span>
</div>
)}
</div>
<div className="flex items-center max-w-[200px]">
<span className="text-[14px] font-medium text-gray-700 dark:text-gray-300 truncate">
{item.user?.display_name || "Unknown"}
</span>
</div>
<span className="text-[14px] text-gray-400 dark:text-gray-600 shrink-0">
/
</span>
<h3 className="text-[14px] font-semibold text-blue-600 dark:text-[#58a6ff] truncate max-w-[250px]">
{item.title}
</h3>
<div className="shrink-0 flex justify-start">
{getStatusBadge(item.project_status)}
</div>
</div>
</div>
<div className="md:w-40 shrink-0 text-xs text-gray-500 dark:text-[#8b949e]">
<span>{formatDate(item.created_at)}</span>
</div>
<div className="md:w-40 shrink-0 text-xs text-gray-500 dark:text-[#8b949e]">
<span>{formatDate(item.updated_at)}</span>
</div>
<div className="flex items-center md:w-[120px] md:justify-end shrink-0">
<div className="flex -space-x-2 overflow-hidden">
{item.members && item.members.length > 0 ? (
<>
{item.members.slice(0, 4).map((m, index) =>
m.avatar_url ? (
<Image
key={index}
src={m.avatar_url}
alt={m.display_name}
width={32}
height={32}
title={m.display_name}
className="inline-block w-8 h-8 rounded-full object-cover ring-2 ring-white group-hover:ring-gray-50 dark:ring-[#0d1117] dark:group-hover:ring-[#161b22] transition-colors"
/>
) : (
<div
key={index}
title={m.display_name}
className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 dark:bg-gray-700 ring-2 ring-white group-hover:ring-gray-50 dark:ring-[#0d1117] dark:group-hover:ring-[#161b22] transition-colors"
>
<span className="text-xs font-medium text-gray-500 dark:text-gray-300">
{m.display_name?.charAt(0)?.toUpperCase() || "U"}
</span>
</div>
),
)}
{item.members.length > 4 && (
<div
title="Những người khác"
className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-gray-100 dark:bg-gray-800 ring-2 ring-white group-hover:ring-gray-50 dark:ring-[#0d1117] dark:group-hover:ring-[#161b22] transition-colors z-10"
>
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">
+{item.members.length - 4}
</span>
</div>
)}
</>
) : (
<span className="text-sm text-gray-400 dark:text-gray-600 italic"></span>
)}
</div>
</div>
</div>
))
) : (
<div className="p-10 text-center text-gray-500 dark:text-gray-400 italic">
Không tìm thấy dự án nào
</div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,313 @@
"use client";
import Image from "next/image";
import Badge from "@/components/ui/badge/Badge";
export type SubmissionSortColumn = "id" | "status" | "created_at" | "reviewed_at";
export interface SubmissionItem {
id: string;
project_id: string;
commit_id: string;
user_id: string;
created_at: string;
status: string;
project_title: string;
project_description: string;
content?: string;
reviewed_by?: string;
reviewed_at?: string;
review_note?: string;
user: {
id: string;
email: string;
display_name: string;
avatar_url?: string;
};
reviewer?: {
id: string;
email: string;
display_name: string;
avatar_url?: string;
};
}
interface SubmissionsTableProps {
data: SubmissionItem[];
onSort: (column: SubmissionSortColumn) => void;
sortBy?: SubmissionSortColumn;
sortOrder?: "asc" | "desc";
onViewDetails: (id: string) => void;
onActionClick: (item: SubmissionItem) => void;
}
export default function SubmissionsTable({
data,
onSort,
sortBy,
sortOrder,
onViewDetails,
onActionClick,
}: SubmissionsTableProps) {
const formatDate = (dateString: string | null | undefined) => {
if (!dateString) return "-";
const date = new Date(dateString);
return `${date.toLocaleDateString("vi-VN", {
day: "2-digit",
month: "2-digit",
year: "numeric",
})} ${date.toLocaleTimeString("vi-VN", {
hour: "2-digit",
minute: "2-digit",
})}`;
};
const getStatusBadge = (status: string) => {
switch (status) {
case "APPROVED":
case "SUCCESS":
return (
<Badge size="sm" variant="light" color="success">
{status}
</Badge>
);
case "PENDING":
return (
<Badge size="sm" variant="light" color="warning">
{status}
</Badge>
);
case "REJECTED":
case "FAILED":
return (
<Badge size="sm" variant="light" color="error">
{status}
</Badge>
);
default:
return (
<Badge size="sm" variant="light" color="dark">
{status}
</Badge>
);
}
};
const SortButton = ({
column,
label,
align = "left",
}: {
column: SubmissionSortColumn;
label: string;
align?: "left" | "right" | "center";
}) => {
const isActive = sortBy === column;
// Căn lề chuẩn xác cho text và icon bên trong button
const alignClass =
align === "right"
? "justify-end text-right ml-auto"
: align === "center"
? "justify-center text-center mx-auto"
: "justify-start text-left";
return (
<button
onClick={() => onSort(column)}
className={`text-sm font-medium hover:text-blue-500 transition-colors flex items-center gap-1 w-full ${alignClass} ${
isActive
? "text-blue-600 dark:text-blue-400"
: "text-gray-500 dark:text-gray-400"
}`}
>
{label}
{isActive && (
<span className="text-xs">{sortOrder === "asc" ? "↑" : "↓"}</span>
)}
</button>
);
};
const AvatarWithTooltip = ({
user,
}: {
user?: { display_name: string; avatar_url?: string };
}) => {
if (!user) return <span className="text-gray-400 text-xs italic">-</span>;
return (
<div className="group/avatar relative flex justify-center items-center">
<div className="w-8 h-8 shrink-0 flex items-center justify-center cursor-default">
{user.avatar_url ? (
<div className="relative w-8 h-8 rounded-full overflow-hidden border border-gray-200 dark:border-gray-700 ring-2 ring-transparent group-hover/avatar:ring-blue-100 dark:group-hover/avatar:ring-blue-900 transition-all">
<Image
src={user.avatar_url}
alt="avatar"
fill
className="object-cover rounded-full"
/>
</div>
) : (
<div className="w-8 h-8 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center border border-gray-300 dark:border-gray-600 ring-2 ring-transparent group-hover/avatar:ring-blue-100 dark:group-hover/avatar:ring-blue-900 transition-all">
<span className="text-xs font-bold text-gray-500 dark:text-gray-300 leading-none">
{user.display_name?.charAt(0)?.toUpperCase() || "U"}
</span>
</div>
)}
</div>
<div className="invisible opacity-0 group-hover/avatar:visible group-hover/avatar:opacity-100 absolute z-50 bottom-full left-1/2 -translate-x-1/2 mb-2 w-max max-w-[200px] px-3 py-1.5 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 text-xs font-medium rounded-lg shadow-xl whitespace-normal border border-gray-100 dark:border-gray-700 transition-all duration-200">
{user.display_name}
<div className="absolute top-full left-1/2 -translate-x-1/2 border-[5px] border-transparent border-t-white dark:border-t-gray-800"></div>
</div>
</div>
);
};
return (
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-[#0d1117]">
<div className="max-w-full overflow-x-auto custom-scrollbar">
<div className="">
<div className="flex items-center px-5 py-3 border-b border-gray-200 dark:border-gray-800 bg-gray-50/50 dark:bg-[#161b22]">
<div className="w-[220px] shrink-0 pr-4">
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">
Tên
</span>
</div>
<div className="flex-1 min-w-[150px] pr-4">
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">
Dự án
</span>
</div>
<div className="w-[120px] shrink-0">
<SortButton column="status" label="Trạng thái" />
</div>
<div className="w-[180px] shrink-0 pr-4">
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">
Ghi chú
</span>
</div>
<div className="w-[140px] shrink-0">
<SortButton column="reviewed_at" label="Cập nhật" align="right" />
</div>
<div className="w-[100px] shrink-0 text-center">
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">
Người duyệt
</span>
</div>
<div className="w-[140px] shrink-0">
<SortButton column="created_at" label="Ngày tạo" align="right" />
</div>
<div className="w-[100px] shrink-0 text-right">
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">
Thao tác
</span>
</div>
</div>
{/* =========== BODY =========== */}
<div className="flex flex-col divide-y divide-gray-200 dark:divide-gray-800">
{data.length > 0 ? (
data.map((item) => (
<div
key={item.id}
className="group flex flex-col p-5 md:flex-row md:items-center hover:bg-gray-50 dark:hover:bg-[#161b22] transition-colors gap-3 md:gap-0"
>
<div className="w-[220px] shrink-0 pr-4 flex items-center gap-3">
<div className="w-8 h-8 shrink-0 flex items-center justify-center">
{item.user?.avatar_url ? (
<div className="relative w-8 h-8 rounded-full overflow-hidden border border-gray-200 dark:border-gray-700">
<Image
src={item.user.avatar_url}
alt="avatar"
fill
className="object-cover rounded-full"
/>
</div>
) : (
<div className="w-8 h-8 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center border border-gray-300 dark:border-gray-600">
<span className="text-xs font-bold text-gray-500 dark:text-gray-300 leading-none">
{item.user?.display_name?.charAt(0)?.toUpperCase() || "U"}
</span>
</div>
)}
</div>
<span className="text-[13px] font-medium text-gray-700 dark:text-gray-300 truncate">
{item.user?.display_name || "Unknown"}
</span>
</div>
<div className="flex-1 min-w-[150px] pr-4 flex items-center">
<div
className="flex flex-col min-w-0"
>
<h3 onClick={() => onViewDetails(item.id)} className="cursor-pointer hover:underline text-[14px] font-semibold text-blue-600 dark:text-[#58a6ff] truncate">
{item.project_title}
</h3>
<span className="text-[12px] text-gray-400 dark:text-gray-500 truncate mt-0.5">
{item.id}
</span>
</div>
</div>
<div className="w-[120px] shrink-0 flex items-center justify-start">
{getStatusBadge(item.status)}
</div>
<div className="w-[180px] shrink-0 pr-4 flex items-center">
<div className="group/note relative text-xs text-gray-500 dark:text-gray-400">
<div className="truncate cursor-default">
{item.review_note || "-"}
</div>
{item.review_note && (
<div className="invisible opacity-0 group-hover/note:visible group-hover/note:opacity-100 absolute z-50 bottom-full left-1/2 -translate-x-1/2 mb-2 w-max max-w-[250px] p-2.5 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 text-xs rounded-lg shadow-xl whitespace-normal break-words border border-gray-100 dark:border-gray-700 transition-all duration-200">
{item.review_note}
<div className="absolute top-full left-1/2 -translate-x-1/2 border-[6px] border-transparent border-t-white dark:border-t-gray-800"></div>
</div>
)}
</div>
</div>
<div className="w-[140px] shrink-0 flex items-center justify-end text-right text-xs text-gray-500 dark:text-[#8b949e]">
<span>{formatDate(item.reviewed_at)}</span>
</div>
<div className="w-[100px] shrink-0 flex items-center justify-center">
<AvatarWithTooltip user={item.reviewer} />
</div>
<div className="w-[140px] shrink-0 flex items-center justify-end text-right text-xs text-gray-500 dark:text-[#8b949e]">
<span>{formatDate(item.created_at)}</span>
</div>
<div className="w-[100px] shrink-0 flex items-center justify-end">
<button
onClick={() => onActionClick(item)}
className="px-3 py-1.5 text-xs font-medium text-blue-600 bg-blue-50 hover:bg-blue-100 rounded-lg dark:bg-blue-900/30 dark:text-blue-400 dark:hover:bg-blue-900/50 transition-colors"
>
Đánh giá
</button>
</div>
</div>
))
) : (
<div className="p-10 text-center text-gray-500 dark:text-gray-400 italic">
Không tìm thấy yêu cầu nào
</div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -159,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>

29
src/interface/common.ts Normal file
View 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
View 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;
}

View File

@@ -0,0 +1,28 @@
export interface StatisticResponse {
id: string;
date: string;
total_users: number;
total_projects: number;
total_commits: number;
total_submissions: number;
total_medias: number;
total_wikis: number;
total_entities: number;
total_geometries: number;
total_storage_bytes: number;
new_users: number;
new_projects: number;
new_commits: number;
new_submissions: number;
new_medias: number;
new_wikis: number;
new_entities: number;
new_geometries: number;
new_storage_bytes: number;
created_at: string;
}
export interface GetStatisticsParams {
start_date?: string;
end_date?: string;
}

View File

@@ -0,0 +1,20 @@
export interface getSubmissionPayload {
created_from: string;
created_to: string;
limit: number;
order: "asc" | "desc";
page: number;
search: string;
sort: "id" | "status" | "created_at" | "reviewed_at";
project_id: string;
reviewed_by: string;
statuses: string[];
user_ids: string[];
}
export interface updateSubmissionPayload {
review_note: string;
status: string;
}

View File

@@ -1,6 +1,4 @@
"use client";
import { ThemeToggleButton } from "@/components/common/ThemeToggleButton";
import NotificationDropdown from "@/components/header/NotificationDropdown";
import UserDropdown from "@/components/header/UserDropdown";
import { useSidebar } from "@/context/SidebarContext";
import Image from "next/image";
@@ -90,6 +88,7 @@ const AppHeader: React.FC = () => {
className="dark:hidden"
src="./images/logo/logo.svg"
alt="Logo"
style={{ height: 'auto' }}
/>
<Image
width={154}
@@ -97,6 +96,7 @@ const AppHeader: React.FC = () => {
className="hidden dark:block"
src="./images/logo/logo.svg"
alt="Logo"
style={{ height: 'auto' }}
/>
</Link>
@@ -144,10 +144,10 @@ const AppHeader: React.FC = () => {
ref={inputRef}
type="text"
placeholder="Search or type command..."
className="dark:bg-dark-900 h-11 w-full rounded-lg border border-gray-200 bg-transparent py-2.5 pl-12 pr-14 text-sm text-gray-800 shadow-theme-xs placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:bg-gray-900 dark:bg-white/[0.03] dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800 xl:w-[430px]"
className="dark:bg-dark-900 h-11 w-full rounded-lg border border-gray-200 bg-transparent py-2.5 pl-12 pr-14 text-sm text-gray-800 shadow-theme-xs placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800 xl:w-[430px]"
/>
<button className="absolute right-2.5 top-1/2 inline-flex -translate-y-1/2 items-center gap-0.5 rounded-lg border border-gray-200 bg-gray-50 px-[7px] py-[4.5px] text-xs -tracking-[0.2px] text-gray-500 dark:border-gray-800 dark:bg-white/[0.03] dark:text-gray-400">
<button className="absolute right-2.5 top-1/2 inline-flex -translate-y-1/2 items-center gap-0.5 rounded-lg border border-gray-200 bg-gray-50 px-[7px] py-[4.5px] text-xs -tracking-[0.2px] text-gray-500 dark:border-gray-800 dark:bg-white/3 dark:text-gray-400">
<span> </span>
<span> K </span>
</button>

View File

@@ -70,6 +70,8 @@ const ALL_NAV_ITEMS: NavItem[] = [
{ name: "Tài Khoản", path: "/user-information", pro: false, roles: ["ADMIN", "MOD"] },
{ name: "Hồ Sơ Nhà Sử Học", path: "/applications", pro: false, roles: ["ADMIN", "MOD"] },
{ name: "Tệp Đăng Tải", path: "/assets", pro: false, roles: ["ADMIN", "MOD"] },
{ name: "Dự Án", path: "/project", pro: false, roles: ["ADMIN", "MOD"] },
{ name: "Duyệt Nội Dung", path: "/submissions", pro: false, roles: ["ADMIN", "MOD"] },
],
},
{
@@ -122,7 +124,6 @@ const AppSidebar: React.FC = () => {
const { isExpanded, isMobileOpen, isHovered, setIsHovered } = useSidebar();
const pathname = usePathname();
// 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(() => {
@@ -274,6 +275,7 @@ const AppSidebar: React.FC = () => {
alt="Logo"
width={isExpanded || isHovered || isMobileOpen ? 80 : 32}
height={isExpanded || isHovered || isMobileOpen ? 50 : 32}
style={{ height: 'auto' }}
/>
</Link>
</div>

View 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;
};

View File

@@ -0,0 +1,14 @@
import api from "@/config/config";
import { API } from "../../api";
import { GetStatisticsParams, StatisticResponse } from "@/interface/statistics";
import { CommonResponse } from "@/interface/common";
export const getStatistics = async (params?: GetStatisticsParams): Promise<CommonResponse<StatisticResponse[]>> => {
const response = await api.get(API.Statistics.GET_ALL, { params });
return response?.data;
};
export const getStatisticByDate = async (date: string): Promise<CommonResponse<StatisticResponse>> => {
const response = await api.get(API.Statistics.GET_BY_DATE(date));
return response?.data;
};

View File

@@ -0,0 +1,23 @@
import api from "@/config/config";
import { API } from "../../api";
import { getSubmissionPayload, updateSubmissionPayload } from "@/interface/submission";
export const apiGetSubmission = async (params: getSubmissionPayload) => {
const response = await api.get(API.Submission.GET_ALL, { params });
return response?.data;
};
export const apiGetSubmissionDetail = async (id: string) => {
const response = await api.get(API.Submission.GET_DETAIL(id));
return response?.data;
};
export const updateProject = async (id: string, payload: updateSubmissionPayload) => {
const response = await api.patch(API.Submission.UPDATE_STATUS(id), payload);
return response?.data;
};
export const deleteProject = async (id: string) => {
const response = await api.delete(API.Submission.DELETE(id));
return response?.data;
};