feat(backoffice): FE integration (data fetch) for dashboard, teams, submission, and user management page (#70)

* feat(backoffice): create a boilerplate page for Hackathon dashboard

- Create an empty page for Hackathon dashboard
- Comment out and hide the existing backoffice sidebar

* feat(backoffice): Create a nested/dropdown sidebar list

- Create a dropdown sidebar list
- Show back the old navigation and group them
- Make the sidebar responsive for mobile view

* test datatable with mock data

* base UI for Hackathon backoffice

TODO:
- Organize table schema for users, teams, and submissions management
- Create API Contract for additional back-end endpoint

* feat(hackathon): draft data table column & API Contract

* update endpoint

* feat(backoffice): update page hackathon user management

- update data table component
- update filtering & pagination
- add modal display to edit and add user
- hide notification icon in backoffice wrapper

* feat(backoffice): add API contract for hackathon users

* feat(backoffice): little adjustment in hackathon users management UI and API contract

* feat(backoffice): update hackathon team management page

- add modal for manage team, add new team, and view project submission
- reorganize the table column and data table

* feat(backoffice): add searchable city filter

- add component for city filter
- apply to user management and team management pages

* feat(backoffice): improve hackathon team modal UI and add API contract

- Add feature to select city in team detail modal using CityFilterSelect component
- Add feature to change team logo and banner
- Add API contract documentation for hackathon teams in backoffice

* feat(backoffice): authentication middleware, error pages, and 404 page

* feat(backoffice): hackathon dashboard integration

* feat(backoffice): users page integration

- Get users data from API
- Set up server-side pagination and match URL params
- Hide filter that doesn't exist in back-end

* feat(backoffice): teams page integration

- Get teams data from API
- Hide filter that doesn't exists in back-end
- Simplify modal according to the back-end

* feat(backoffice): submission page integration

- Fetch submissions data from API
- Move submission modal to hackathon-submissions page
This commit is contained in:
Hafid Nur
2025-12-10 08:41:30 +07:00
committed by GitHub
parent 76933c0be7
commit 0c7887268c
25 changed files with 3020 additions and 459 deletions
+54
View File
@@ -0,0 +1,54 @@
import { api } from '../index';
import type {
TAdminUsersResponse,
TAdminTeamsResponse,
TAdminSubmissionsResponse,
} from '../../types/admin';
const ADMIN_BASE_URL = '/admin';
// Admin Users
export const getAdminUsers = async (params?: {
page?: number;
per_page?: number;
search?: string;
is_admin?: boolean;
}) => {
const response = await api.get<TAdminUsersResponse>(
`${ADMIN_BASE_URL}/users`,
{
params: {
...params,
is_admin: params?.is_admin ?? false,
},
}
);
return response.data;
};
// Admin Teams
export const getAdminTeams = async (params?: {
page?: number;
per_page?: number;
search?: string;
}) => {
const response = await api.get<TAdminTeamsResponse>(
`${ADMIN_BASE_URL}/teams`,
{ params }
);
return response.data;
};
// Admin Submissions
export const getAdminSubmissions = async (params?: {
page?: number;
per_page?: number;
search?: string;
status?: string;
}) => {
const response = await api.get<TAdminSubmissionsResponse>(
`${ADMIN_BASE_URL}/submissions`,
{ params }
);
return response.data;
};
+63
View File
@@ -0,0 +1,63 @@
import axios from 'axios';
import { useAuthStore } from '../hooks/auth';
// Backoffice Backend API Base URL
// In development, use proxy; in production, use full URL
const BACKOFFICE_API_URL = 'https://api.hackathon.imphnen.dev/api/v1';
// Create axios instance for backoffice backend
export const backofficeApi = axios.create({
baseURL: BACKOFFICE_API_URL,
headers: {
'Content-Type': 'application/json',
},
});
// Add auth token interceptor
backofficeApi.interceptors.request.use(
(config) => {
const { session } = useAuthStore.getState();
if (session?.token?.access_token) {
config.headers.Authorization = `Bearer ${session.token.access_token}`;
}
return config;
},
(error) => {
return Promise.reject(new Error(error.message || 'Request failed'));
}
);
// Error handling interceptor
backofficeApi.interceptors.response.use(
(response) => response,
(error) => {
// Handle 401 - clear session and redirect to login
if (error.response?.status === 401) {
const isAuthPage =
globalThis.window !== undefined &&
globalThis.location.pathname.startsWith('/auth');
if (!isAuthPage) {
useAuthStore.getState().clearSession();
if (globalThis.window !== undefined) {
globalThis.location.href = '/auth/login';
}
}
}
// If backend sends a message, use it
const backendMsg = error?.response?.data?.message;
if (backendMsg && typeof backendMsg === 'string') {
return Promise.reject(new Error(backendMsg));
}
// Fallback error message
return Promise.reject(new Error(error.message || 'An error occurred'));
}
);
// Response type
export interface BackofficeApiResponse<T> {
data: T;
message: string;
}
+10 -3
View File
@@ -6,6 +6,7 @@ export * from './users';
export * from './mentors';
export * from './upload';
export * from './hackathon';
export * from './admin';
// Common API response wrapper interface
export interface ApiResponse<T> {
@@ -20,7 +21,9 @@ const getSessionTokenFromCookies = () => {
if (typeof document === 'undefined') return null;
const cookies = document.cookie.split(';');
const tokenCookie = cookies.find(cookie => cookie.trim().startsWith(`${TOKEN_KEY}=`));
const tokenCookie = cookies.find((cookie) =>
cookie.trim().startsWith(`${TOKEN_KEY}=`)
);
if (!tokenCookie) return null;
@@ -32,13 +35,17 @@ const getSessionTokenFromCookies = () => {
}
};
const setSessionTokenToCookies = (tokenData: { token: { access_token: string; refresh_token: string } }) => {
const setSessionTokenToCookies = (tokenData: {
token: { access_token: string; refresh_token: string };
}) => {
if (typeof document === 'undefined') return;
const expires = new Date();
expires.setDate(expires.getDate() + 7);
document.cookie = `${TOKEN_KEY}=${encodeURIComponent(JSON.stringify(tokenData))}; expires=${expires.toUTCString()}; path=/; secure; samesite=strict`;
document.cookie = `${TOKEN_KEY}=${encodeURIComponent(
JSON.stringify(tokenData)
)}; expires=${expires.toUTCString()}; path=/; secure; samesite=strict`;
};
const removeSessionTokenFromCookies = () => {
+77 -34
View File
@@ -1,5 +1,6 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { hackathonApi, HackathonApiResponse } from '../../api/hackathon';
import { backofficeApi, BackofficeApiResponse } from '../../api/backoffice';
import { useAuthStore } from './use-auth-store';
export * from './use-auth-store';
@@ -77,10 +78,9 @@ export const useLogin = () => {
return useMutation({
mutationFn: async (data: LoginRequest) => {
const response = await hackathonApi.post<HackathonApiResponse<AuthResponse>>(
'/auth/login',
data
);
const response = await hackathonApi.post<
HackathonApiResponse<AuthResponse>
>('/auth/login', data);
return response.data.data;
},
onSuccess: (data) => {
@@ -115,10 +115,9 @@ export const useLogin = () => {
export const useSignup = () => {
return useMutation({
mutationFn: async (data: SignupRequest) => {
const response = await hackathonApi.post<HackathonApiResponse<MessageResponse>>(
'/auth/signup',
data
);
const response = await hackathonApi.post<
HackathonApiResponse<MessageResponse>
>('/auth/signup', data);
return response.data.data;
},
});
@@ -130,10 +129,9 @@ export const useGitHubCallback = () => {
return useMutation({
mutationFn: async (data: GitHubAuthRequest) => {
const response = await hackathonApi.post<HackathonApiResponse<AuthResponse>>(
'/auth/github',
data
);
const response = await hackathonApi.post<
HackathonApiResponse<AuthResponse>
>('/auth/github', data);
return response.data.data;
},
onSuccess: (data) => {
@@ -171,9 +169,9 @@ export const useSession = () => {
return useQuery({
queryKey: ['auth-session'],
queryFn: async () => {
const response = await hackathonApi.get<HackathonApiResponse<SessionResponse>>(
'/auth/session'
);
const response = await hackathonApi.get<
HackathonApiResponse<SessionResponse>
>('/auth/session');
return response.data.data;
},
enabled: !!session?.token,
@@ -184,10 +182,9 @@ export const useSession = () => {
export const useForgotPassword = () => {
return useMutation({
mutationFn: async (data: ForgotPasswordRequest) => {
const response = await hackathonApi.post<HackathonApiResponse<MessageResponse>>(
'/auth/forgot-password',
data
);
const response = await hackathonApi.post<
HackathonApiResponse<MessageResponse>
>('/auth/forgot-password', data);
return response.data.data;
},
});
@@ -197,10 +194,9 @@ export const useForgotPassword = () => {
export const useResetPassword = () => {
return useMutation({
mutationFn: async (data: ResetPasswordRequest) => {
const response = await hackathonApi.post<HackathonApiResponse<MessageResponse>>(
'/auth/reset-password',
data
);
const response = await hackathonApi.post<
HackathonApiResponse<MessageResponse>
>('/auth/reset-password', data);
return response.data.data;
},
});
@@ -219,6 +215,45 @@ export const useSignOut = () => {
});
};
// Backoffice Login
export const useBackofficeLogin = () => {
const { setSession } = useAuthStore();
return useMutation({
mutationFn: async (data: LoginRequest) => {
const response = await backofficeApi.post<
BackofficeApiResponse<AuthResponse>
>('/auth/login', data);
return response.data.data;
},
onSuccess: (data) => {
setSession({
token: data.token,
user: {
id: data.user.id,
email: data.user.email,
fullname: data.user.fullname,
phone_number: data.user.phone_number || '',
avatar: data.user.avatar || '',
birthdate: data.user.birthdate || '',
gender: data.user.gender || '',
is_active: data.user.is_active,
location: data.user.location,
bio: data.user.bio,
skills: data.user.skills,
role: {
id: data.user.role_id || '',
name: 'admin',
permissions: [],
created_at: '',
updated_at: '',
},
},
});
},
});
};
// GitHub OAuth URL helper
// The frontend needs to redirect to GitHub with the client_id
// After GitHub redirects back with a code, use useGitHubCallback
@@ -239,7 +274,9 @@ export const useGitHubAuth = () => {
// Get GitHub client ID from environment
const clientId = import.meta.env.VITE_GITHUB_CLIENT_ID || '';
if (!clientId) {
throw new Error('GitHub Client ID not configured. Set VITE_GITHUB_CLIENT_ID environment variable.');
throw new Error(
'GitHub Client ID not configured. Set VITE_GITHUB_CLIENT_ID environment variable.'
);
}
const redirectUri = `${globalThis.location.origin}/auth/callback`;
@@ -270,8 +307,16 @@ export const useEmailAuth = () => {
};
};
const signUpWithEmail = async (email: string, password: string, fullname: string) => {
const result = await signupMutation.mutateAsync({ email, password, fullname });
const signUpWithEmail = async (
email: string,
password: string,
fullname: string
) => {
const result = await signupMutation.mutateAsync({
email,
password,
fullname,
});
// Signup only returns a message (user needs to verify email first)
return {
message: result.message,
@@ -295,10 +340,9 @@ export const useEmailAuth = () => {
export const usePostLogin = () => {
return useMutation({
mutationFn: async (data: LoginRequest) => {
const response = await hackathonApi.post<HackathonApiResponse<AuthResponse>>(
'/auth/login',
data
);
const response = await hackathonApi.post<
HackathonApiResponse<AuthResponse>
>('/auth/login', data);
return { data: response.data.data };
},
});
@@ -308,10 +352,9 @@ export const usePostLogin = () => {
export const usePostRegister = () => {
return useMutation({
mutationFn: async (data: SignupRequest) => {
const response = await hackathonApi.post<HackathonApiResponse<AuthResponse>>(
'/auth/signup',
data
);
const response = await hackathonApi.post<
HackathonApiResponse<AuthResponse>
>('/auth/signup', data);
return { data: response.data.data };
},
});
+64
View File
@@ -0,0 +1,64 @@
export type TAdminMetaResponse = {
page: number;
per_page: number;
total_data: number;
total_page: number;
};
export type TAdminListResponse<T = unknown> = {
data: T[];
meta: TAdminMetaResponse;
};
// Admin Users
export type TAdminUserItem = {
id: string;
email: string;
fullname: string;
avatar: string | null;
phone_number: string | null;
location: string | null;
bio: string;
skills: string[];
is_active: boolean;
created_at: string;
updated_at: string;
};
export type TAdminUsersResponse = TAdminListResponse<TAdminUserItem>;
// Admin Teams
export type TAdminTeamItem = {
id: string;
name: string;
description: string;
city: string;
visibility: string;
logo: string | null;
banner: string | null;
leader_id: string;
created_at: string;
updated_at: string;
};
export type TAdminTeamsResponse = TAdminListResponse<TAdminTeamItem>;
// Admin Submissions
export type TAdminSubmissionItem = {
id: string;
team_id: string;
project_name: string;
description: string;
repository_url: string;
demo_url: string | null;
presentation_url: string | null;
screenshots: string[];
status: string;
submitted_at: string;
submitted_by: string;
created_at: string;
updated_at: string;
};
export type TAdminSubmissionsResponse =
TAdminListResponse<TAdminSubmissionItem>;
+1
View File
@@ -5,3 +5,4 @@ export * from './roles';
export * from './permissions';
export * from './mentors';
export * from './teams';
export * from './admin';
+132 -2
View File
@@ -23,6 +23,11 @@ interface DataTableProps<T extends RowData> {
columns?: ColumnDef<T, unknown>[];
pageSize?: number;
className?: string;
// server-side pagination props
manualPagination?: boolean;
pageCount?: number;
currentPage?: number;
onPageChange?: (page: number) => void;
}
export const DataTable = <T extends RowData>({
@@ -31,6 +36,10 @@ export const DataTable = <T extends RowData>({
columns = [],
pageSize = 9,
className,
manualPagination = false,
pageCount,
currentPage = 1,
onPageChange,
}: DataTableProps<T>) => {
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
@@ -75,10 +84,20 @@ export const DataTable = <T extends RowData>({
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
// server-side pagination config
manualPagination,
pageCount: manualPagination ? pageCount : undefined,
};
return config;
}, [memoizedData, memoizedColumns, pagination, sorting]);
}, [
memoizedData,
memoizedColumns,
pagination,
sorting,
manualPagination,
pageCount,
]);
// Prefer external table instance if provided; otherwise create an internal one
const internalTable = useReactTable(tableConfig);
@@ -166,7 +185,118 @@ export const DataTable = <T extends RowData>({
</tbody>
</table>
</div>
<Pagination table={t} />
{manualPagination && onPageChange && pageCount ? (
// Server-side pagination controls with numbered pages
<div className="flex items-center justify-center gap-10">
<button
className="disabled:opacity-50 cursor-pointer"
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
aria-label="Previous page"
>
<svg
className="w-4 h-4 text-neutral-800"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg>
</button>
<div className="flex gap-4 items-baseline">
{pageCount <= 8 ? (
// Show all pages if 8 or fewer
Array.from({ length: pageCount }, (_, index) => (
<button
key={index}
className={`size-[30px] py-2 flex items-center justify-center rounded-md cursor-pointer ${
currentPage === index + 1
? 'bg-primary-500 text-white'
: 'bg-primary-100 hover:bg-primary-200'
}`}
onClick={() => onPageChange(index + 1)}
>
{index + 1}
</button>
))
) : (
// Show ellipsis for many pages
<>
<button
onClick={() => onPageChange(1)}
className={`size-[30px] py-2 flex items-center justify-center rounded-md cursor-pointer ${
currentPage === 1
? 'bg-primary-500 text-white'
: 'bg-primary-100 hover:bg-primary-200'
}`}
>
1
</button>
{currentPage > 3 && <span>...</span>}
{Array.from(
{ length: 5 },
(_, index) => currentPage - 2 + index
)
.filter((page) => page > 1 && page < pageCount)
.map((page) => (
<button
key={page}
onClick={() => onPageChange(page)}
className={`size-[30px] py-2 flex items-center justify-center rounded-md cursor-pointer ${
currentPage === page
? 'bg-primary-500 text-white'
: 'bg-primary-100 hover:bg-primary-200'
}`}
>
{page}
</button>
))}
{currentPage < pageCount - 2 && <span>...</span>}
<button
onClick={() => onPageChange(pageCount)}
className={`size-[30px] py-2 flex items-center justify-center rounded-md cursor-pointer ${
currentPage === pageCount
? 'bg-primary-500 text-white'
: 'bg-primary-100 hover:bg-primary-200'
}`}
>
{pageCount}
</button>
</>
)}
</div>
<button
className="disabled:opacity-50 cursor-pointer"
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === pageCount}
aria-label="Next page"
>
<svg
className="w-4 h-4 text-neutral-800"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</button>
</div>
) : (
// Client-side pagination (default)
<Pagination table={t} />
)}
</div>
);
};