feat: implement core admin dashboard infrastructure including service layers, UI components, and submission management pages

This commit is contained in:
2026-05-13 17:02:15 +07:00
parent c13ddb37fc
commit f796ff9a96
32 changed files with 171 additions and 162 deletions
@@ -31,7 +31,7 @@ export default function Profile() {
return (
<div>
<div className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] lg:p-6">
<div className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/3 lg:p-6">
<h3 className="mb-5 text-lg font-semibold text-gray-800 dark:text-white/90 lg:mb-7">
Profile
</h3>
@@ -11,7 +11,7 @@ export default function BlankPage() {
return (
<div>
<PageBreadcrumb pageTitle="Blank Page" />
<div className="min-h-screen rounded-2xl border border-gray-200 bg-white px-5 py-7 dark:border-gray-800 dark:bg-white/[0.03] xl:px-10 xl:py-12">
<div className="min-h-screen rounded-2xl border border-gray-200 bg-white px-5 py-7 dark:border-gray-800 dark:bg-white/3 xl:px-10 xl:py-12">
<div className="mx-auto w-full max-w-[630px] text-center">
<h3 className="mb-4 font-semibold text-gray-800 text-theme-xl dark:text-white/90 sm:text-2xl">
Card Title Here
@@ -240,7 +240,6 @@ export default function Page() {
} else {
toast.error("Lỗi hệ thống khi cập nhật");
}
console.error(error);
} finally {
setIsSubmitting(false);
}
+6 -6
View File
@@ -16,7 +16,7 @@ export default function BadgePage() {
<div>
<PageBreadcrumb pageTitle="Badges" />
<div className="space-y-5 sm:space-y-6">
<div className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]">
<div className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/3">
<div className="px-6 py-5">
<h3 className="text-base font-medium text-gray-800 dark:text-white/90">
With Light Background
@@ -50,7 +50,7 @@ export default function BadgePage() {
</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]">
<div className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/3">
<div className="px-6 py-5">
<h3 className="text-base font-medium text-gray-800 dark:text-white/90">
With Solid Background
@@ -84,7 +84,7 @@ export default function BadgePage() {
</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]">
<div className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/3">
<div className="px-6 py-5">
<h3 className="text-base font-medium text-gray-800 dark:text-white/90">
Light Background with Left Icon
@@ -117,7 +117,7 @@ export default function BadgePage() {
</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]">
<div className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/3">
<div className="px-6 py-5">
<h3 className="text-base font-medium text-gray-800 dark:text-white/90">
Solid Background with Left Icon
@@ -150,7 +150,7 @@ export default function BadgePage() {
</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]">
<div className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/3">
<div className="px-6 py-5">
<h3 className="text-base font-medium text-gray-800 dark:text-white/90">
Light Background with Right Icon
@@ -183,7 +183,7 @@ export default function BadgePage() {
</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]">
<div className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/3">
<div className="px-6 py-5">
<h3 className="text-base font-medium text-gray-800 dark:text-white/90">
Solid Background with Right Icon
+1 -1
View File
@@ -554,7 +554,7 @@ span.flatpickr-weekday,
@apply p-2;
}
.fc .fc-daygrid-day.fc-day-today .fc-scrollgrid-sync-inner {
@apply rounded-sm bg-gray-100 dark:bg-white/[0.03];
@apply rounded-sm bg-gray-100 dark:bg-white/3;
}
.fc .fc-daygrid-day-number {
@apply !p-3 text-sm font-medium text-gray-700 dark:text-gray-400;
+9 -10
View File
@@ -87,12 +87,12 @@ const Calendar: React.FC = () => {
prevEvents.map((event) =>
event.id === selectedEvent.id
? {
...event,
title: eventTitle,
start: eventStartDate,
end: eventEndDate,
extendedProps: { calendar: eventLevel },
}
...event,
title: eventTitle,
start: eventStartDate,
end: eventEndDate,
extendedProps: { calendar: eventLevel },
}
: event
)
);
@@ -121,7 +121,7 @@ const Calendar: React.FC = () => {
};
return (
<div className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]">
<div className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/3">
<div className="custom-calendar">
<FullCalendar
ref={calendarRef}
@@ -201,9 +201,8 @@ const Calendar: React.FC = () => {
/>
<span className="flex items-center justify-center w-5 h-5 mr-2 border border-gray-300 rounded-full box dark:border-gray-700">
<span
className={`h-2 w-2 rounded-full bg-white ${
eventLevel === key ? "block" : "hidden"
}`}
className={`h-2 w-2 rounded-full bg-white ${eventLevel === key ? "block" : "hidden"
}`}
></span>
</span>
</span>
+1 -1
View File
@@ -17,7 +17,7 @@ const ComponentCard: React.FC<ComponentCardProps> = ({
}) => {
return (
<div
className={`rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03] ${className}`}
className={`rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/3 ${className}`}
>
{/* Card Header */}
<div className="px-6 py-5 flex items-center justify-between">
+1 -1
View File
@@ -133,7 +133,7 @@ export default function MonthlyNewChart() {
};
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="overflow-hidden rounded-2xl border border-gray-200 bg-white px-5 pt-5 dark:border-gray-800 dark:bg-white/3 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
+1 -1
View File
@@ -19,7 +19,7 @@ export default function DemographicCard() {
}
return (
<div className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] sm:p-6">
<div className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/3 sm:p-6">
<div className="flex justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-800 dark:text-white/90">
@@ -52,7 +52,7 @@ export default function DetailedStatsTable() {
};
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">
<div className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/3 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>
@@ -47,8 +47,7 @@ export const EcommerceMetrics = () => {
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:gap-6">
{/* <!-- Metric Item Start --> */}
<div className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] md:p-6">
<div className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/3 md:p-6">
<div className="flex items-center justify-center w-12 h-12 bg-gray-100 rounded-xl dark:bg-gray-800">
<GroupIcon className="text-gray-800 size-6 dark:text-white/90" />
</div>
@@ -68,10 +67,8 @@ export const EcommerceMetrics = () => {
</Badge>
</div>
</div>
{/* <!-- Metric Item End --> */}
{/* <!-- Metric Item Start --> */}
<div className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] md:p-6">
<div className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/3 md:p-6">
<div className="flex items-center justify-center w-12 h-12 bg-gray-100 rounded-xl dark:bg-gray-800">
<BoxIconLine className="text-gray-800 dark:text-white/90" />
</div>
@@ -91,7 +88,6 @@ export const EcommerceMetrics = () => {
</Badge>
</div>
</div>
{/* <!-- Metric Item End --> */}
</div>
);
};
@@ -108,7 +108,7 @@ export default function MonthlySalesChart() {
}
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="overflow-hidden rounded-2xl border border-gray-200 bg-white px-5 pt-5 dark:border-gray-800 dark:bg-white/3 sm:px-6 sm:pt-6">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-800 dark:text-white/90">
Monthly Sales
+1 -1
View File
@@ -73,7 +73,7 @@ export default function MonthlyTarget() {
}
return (
<div className="rounded-2xl border border-gray-200 bg-gray-100 dark:border-gray-800 dark:bg-white/[0.03]">
<div className="rounded-2xl border border-gray-200 bg-gray-100 dark:border-gray-800 dark:bg-white/3">
<div className="px-5 pt-5 bg-white shadow-default rounded-2xl pb-11 dark:bg-gray-900 sm:px-6 sm:pt-6">
<div className="flex justify-between">
<div>
+3 -3
View File
@@ -71,7 +71,7 @@ const tableData: Product[] = [
export default function RecentOrders() {
return (
<div className="overflow-hidden rounded-2xl border border-gray-200 bg-white px-4 pb-3 pt-4 dark:border-gray-800 dark:bg-white/[0.03] sm:px-6">
<div className="overflow-hidden rounded-2xl border border-gray-200 bg-white px-4 pb-3 pt-4 dark:border-gray-800 dark:bg-white/3 sm:px-6">
<div className="flex flex-col gap-2 mb-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-800 dark:text-white/90">
@@ -194,8 +194,8 @@ export default function RecentOrders() {
product.status === "Delivered"
? "success"
: product.status === "Pending"
? "warning"
: "error"
? "warning"
: "error"
}
>
{product.status}
+1 -1
View File
@@ -122,7 +122,7 @@ export default function StatisticsChart() {
};
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="rounded-2xl border border-gray-200 bg-white px-5 pb-5 pt-5 dark:border-gray-800 dark:bg-white/3 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">
+1 -1
View File
@@ -147,7 +147,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="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-white/[0.05] dark:bg-white/3">
<div className="max-w-full overflow-x-auto">
<div className="min-w-[1000px]">
<Table>
+5 -5
View File
@@ -84,7 +84,7 @@ export default function BasicTableOne({
};
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="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-white/[0.05] dark:bg-white/3">
<div className="max-w-full overflow-x-auto">
<div className="min-w-[1100px]">
<Table>
@@ -236,10 +236,10 @@ export default function BasicTableOne({
{role.name}
</span>
)) || (
<span className="text-gray-400 italic text-[10px]">
No Role
</span>
)}
<span className="text-gray-400 italic text-[10px]">
No Role
</span>
)}
</div>
</TableCell>
+1 -1
View File
@@ -121,7 +121,7 @@ export default function MediaTable({
const isAllSelected = data.length > 0 && selectedIds.length === data.length;
return (
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-white/[0.05] dark:bg-white/[0.03]">
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-white/[0.05] dark:bg-white/3">
<div className="max-w-full overflow-x-auto">
<div className="min-w-[1000px]">
<Table>
-37
View File
@@ -1,37 +0,0 @@
import axios from "axios";
import { API } from "../../api";
const axiosInstance = axios.create({
baseURL: "/",
withCredentials: true,
headers: {
"Content-Type": "application/json",
},
});
axiosInstance.interceptors.response.use(
(response) => {
if (response.data && response.data.status === false) {
return handleRefreshToken(response);
}
return response;
},
async (error) => {
return Promise.reject(error);
}
);
async function handleRefreshToken(originalResponse: any) {
try {
const refreshRes = await axios.get(API.Auth.REFRESH, { withCredentials: true });
if (refreshRes.data && refreshRes.data.status !== false) {
return axiosInstance(originalResponse.config);
}
} catch (err) {
console.error("Refresh token failed", err);
}
return originalResponse;
}
export default axiosInstance;
+61 -38
View File
@@ -1,65 +1,88 @@
import axios from "axios"
import { API_URL_ROOT } from "../../api"
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios";
const baseURL = API_URL_ROOT || "https://history-api.kain.id.vn"
export const baseURL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3344";
const api = axios.create({
export const api = axios.create({
baseURL,
withCredentials: true
})
withCredentials: true,
});
let isRefreshing = false
let queue: any[] = []
const processQueue = (error?: any) => {
queue.forEach((p) => {
if (error) p.reject(error)
else p.resolve()
})
queue = []
interface CustomAxiosRequestConfig extends InternalAxiosRequestConfig {
_retry?: boolean;
}
interface QueueItem {
resolve: () => void;
reject: (error: unknown) => void;
}
let isRefreshing = false;
let queue: QueueItem[] = [];
const processQueue = (error: unknown = null) => {
queue.forEach((p) => {
if (error) p.reject(error);
else p.resolve();
});
queue = [];
};
const skipRefreshUrls = [
"/auth/signin",
"/auth/signup",
"/auth/logout",
"/auth/refresh",
"/auth/forgot-password",
"/auth/token/create",
"/auth/token/verify",
];
api.interceptors.response.use(
(res) => res,
async (err) => {
const originalRequest = err.config
async (err: AxiosError) => {
const originalRequest = err.config as CustomAxiosRequestConfig;
if (err.response?.status === 401 && !originalRequest._retry) {
const url = originalRequest?.url || "";
const shouldSkip = skipRefreshUrls?.some((path) =>
url?.includes(path)
);
if (
err.response?.status === 401 &&
originalRequest &&
!originalRequest._retry &&
!shouldSkip
) {
if (isRefreshing) {
return new Promise((resolve, reject) => {
queue.push({
resolve: () => resolve(api(originalRequest)),
reject
})
})
reject: (queueErr) => reject(queueErr),
});
});
}
originalRequest._retry = true
isRefreshing = true
originalRequest._retry = true;
isRefreshing = true;
try {
await axios.post(
`${baseURL}/auth/refresh`,
{},
{ withCredentials: true }
)
await axios.post(`${baseURL}/auth/refresh`, {}, { withCredentials: true });
processQueue()
processQueue(null);
return api(originalRequest)
return api(originalRequest);
} catch (refreshErr) {
processQueue(refreshErr)
processQueue(refreshErr);
window.location.href = "/signin"
window.location.href = "/auth/signin"
return Promise.reject(refreshErr)
return Promise.reject(refreshErr);
} finally {
isRefreshing = false
isRefreshing = false;
}
}
return Promise.reject(err)
return Promise.reject(err);
}
)
export default api
);
+11 -4
View File
@@ -1,8 +1,15 @@
export interface CommonResponse<T = any> {
export interface ErrorResponse {
failed_field: string;
tag: string;
value: string;
message: string;
}
export interface CommonResponse<T = unknown> {
status: boolean;
message: string;
data: T;
errors?: any; // Or a more specific error type
errors?: ErrorResponse[]; // Or a more specific error type
}
export interface PaginatedResponse<T> {
@@ -15,7 +22,7 @@ export interface PaginatedResponse<T> {
total_records: number;
total_pages: number;
};
errors?: any;
errors?: ErrorResponse[];
}
export interface CursorPaginatedResponse<T> {
@@ -25,5 +32,5 @@ export interface CursorPaginatedResponse<T> {
items: T[];
next_cursor_id?: string;
};
errors?: any;
errors?: ErrorResponse[];
}
+1 -1
View File
@@ -19,7 +19,7 @@ export interface ApplicationDto {
reviewed_at: string | null;
created_at: string;
updated_at?: string;
media: any[];
media: MediaDto[];
user: {
display_name?: string;
avatar_url?: string;
+42 -20
View File
@@ -1,22 +1,46 @@
export interface CommitSimpleResponse {
id: string;
edit_summary: string;
}
export interface MemberSimpleResponse {
user_id: string;
role: string;
display_name: string;
avatar_url: string;
}
export interface SubmissionSimpleResponse {
id: string;
status: string;
}
export interface UserSimpleResponse {
id: string;
email: string;
display_name: string;
avatar_url: string;
}
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[];
latest_commit_id?: string | null;
project_status: "PRIVATE" | "PUBLIC" | "ARCHIVE" | string;
locked_by?: string | null;
is_deleted: boolean;
user_id: string;
created_at?: string | null;
updated_at?: string | null;
user?: UserSimpleResponse | null;
commits: CommitSimpleResponse[];
submissions: SubmissionSimpleResponse[];
members: MemberSimpleResponse[];
}
export interface ProjectsResponse<T = Project> {
status: boolean;
message: string;
@@ -28,24 +52,22 @@ export interface ProjectsResponse<T = Project> {
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;
+1 -1
View File
@@ -4,7 +4,7 @@ export default function SidebarWidget() {
return (
<div
className={`
mx-auto mb-10 w-full max-w-60 rounded-2xl bg-gray-50 px-4 py-5 text-center dark:bg-white/[0.03]`}
mx-auto mb-10 w-full max-w-60 rounded-2xl bg-gray-50 px-4 py-5 text-center dark:bg-white/3`}
>
<h3 className="mb-2 font-semibold text-gray-900 dark:text-white">
#1 Tailwind CSS Dashboard
+1 -1
View File
@@ -1,4 +1,4 @@
import api from "@/config/config";
import { api } from "@/config/config";
import { API } from "../../api";
import { getUserDto } from "@/interface/admin";
+1 -1
View File
@@ -1,4 +1,4 @@
import api from "@/config/config";
import { api } from "@/config/config";
import { API } from "../../api";
export const apiCreateOTP = async (email: string) => {
+2 -2
View File
@@ -1,4 +1,4 @@
import api from "@/config/config";
import { api } from "@/config/config";
import { API } from "../../api";
export const createHistorianCV = async (payload: any) => {
@@ -6,7 +6,7 @@ export const createHistorianCV = async (payload: any) => {
return response?.data;
};
export const apiGetUserApplications = async (payload :any) => {
export const apiGetUserApplications = async (payload: any) => {
const response = await api.get(API.Historian.APPLICATION, { params: payload });
return response?.data;
};
+1 -1
View File
@@ -1,4 +1,4 @@
import api from "@/config/config";
import { api } from "@/config/config";
import { API } from "../../api";
import { payloadPresignedMedia } from "@/interface/media";
import axios from "axios";
+1 -1
View File
@@ -1,4 +1,4 @@
import api from "@/config/config";
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";
+1 -1
View File
@@ -1,4 +1,4 @@
import api from "@/config/config";
import { api } from "@/config/config";
import { API } from "../../api";
import { GetStatisticsParams, StatisticResponse } from "@/interface/statistics";
import { CommonResponse } from "@/interface/common";
+1 -1
View File
@@ -1,4 +1,4 @@
import api from "@/config/config";
import { api } from "@/config/config";
import { API } from "../../api";
import { getSubmissionPayload, updateSubmissionPayload } from "@/interface/submission";
+1 -1
View File
@@ -1,4 +1,4 @@
import api from "@/config/config";
import { api } from "@/config/config";
import { API } from "../../api";
import { Profile } from "@/interface/user";