-
-
-
- All Status
- Active
- Suspended
-
-
- All City
- Jakarta
- Bandung
-
+
+
+ {/* Search bar */}
+
+
+ setGlobalFilter(e.target.value)}
+ onKeyPress={handleSearchKeyPress}
+ />
+
+
+ {/* Per Page Dropdown */}
+
+
+ handlePerPageChange(parseInt(e.target.value, 10))
+ }
+ >
+ 10 / page
+ 20 / page
+ 50 / page
+ 100 / page
+
+
+
+ {/* Status Filter */}
+ {/*
+
+ handleStatusFilterChange(e.target.value)}
+ >
+ All Status
+ Submitted
+ Pending
+ Approved
+ Rejected
+
+
*/}
+
- {/* Table */}
-
+ {/* Active filters */}
+ {/* {statusFilter !== 'all' && (
+
+ Active filters:
+
+ Status: {statusFilter}
+ handleStatusFilterChange('all')}
+ className="text-info-600 hover:text-info-800 cursor-pointer"
+ >
+ ✕
+
+
+ {
+ handleStatusFilterChange('all');
+ setGlobalFilter('');
+ }}
+ className="text-sm text-neutral-600"
+ >
+ Clear All
+
+
+ )} */}
+
+ {/* Loading & results */}
+ {isLoading ? (
+
+
+
+ Loading submissions...
+
+
+ ) : filteredData.length > 0 ? (
+ <>
+
+ Showing {filteredData.length} of {totalData} submissions (Page{' '}
+ {currentPage} of {totalPages})
+ {isFetching && (
+ (Updating...)
+ )}
+
+
+ >
+ ) : (
+
+ No submissions found. Try adjusting your filters.
+
+ )}
- {/* Modals extracted into shared backoffice components */}
+ {/* Submission Modal */}
+ {selectedSubmission && (
+
+ )}
);
};
-export default HackathonUsersPage;
+export default HackathonSubmissionsPage;
diff --git a/apps/backoffice/src/app/(protected)/hackathon-teams/_components/modal-team-detail-new.tsx b/apps/backoffice/src/app/(protected)/hackathon-teams/_components/modal-team-detail-new.tsx
new file mode 100644
index 0000000..b2f7201
--- /dev/null
+++ b/apps/backoffice/src/app/(protected)/hackathon-teams/_components/modal-team-detail-new.tsx
@@ -0,0 +1,514 @@
+import { FC, useState, useEffect, useMemo, useRef } from 'react';
+import { Button } from '@imphnen-frontend-service/ui/atoms';
+import { CityFilterSelect } from '../../../../components/city-filter-select';
+import TeamBannerPlaceholder from './team-banner-placeholder';
+import { cn } from '@imphnen-frontend-service/utils';
+import { TAdminTeamItem } from '@imphnen-frontend-service/service';
+import {
+ TeamOutlined,
+ CloseOutlined,
+ DeleteOutlined,
+ SaveOutlined,
+ CalendarOutlined,
+ CrownOutlined,
+ ExclamationOutlined,
+ UploadOutlined,
+ CameraOutlined,
+ EyeOutlined,
+ EyeInvisibleOutlined,
+} from '@ant-design/icons';
+
+type TeamType = TAdminTeamItem;
+
+interface ModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ team: TeamType | null;
+}
+
+const ModalTeamDetail: FC
= ({ isOpen, onClose, team }) => {
+ const [formData, setFormData] = useState(null);
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+ const [showLogoMenu, setShowLogoMenu] = useState(false);
+ const logoInputRef = useRef(null);
+ const bannerInputRef = useRef(null);
+
+ // Initialize form data when modal opens
+ useEffect(() => {
+ if (isOpen) {
+ if (team) {
+ setFormData({ ...team });
+ } else {
+ setFormData({
+ id: '',
+ name: '',
+ description: '',
+ city: '',
+ banner: null,
+ logo: null,
+ visibility: 'public',
+ created_at: new Date().toISOString(),
+ updated_at: new Date().toISOString(),
+ leader_id: '',
+ });
+ }
+ }
+ }, [isOpen, team]);
+
+ // Check if form has changes
+ const hasChanges = useMemo(() => {
+ if (!formData || !team) return !!formData;
+ return (
+ formData.name !== team.name ||
+ formData.description !== team.description ||
+ formData.city !== team.city ||
+ formData.visibility !== team.visibility ||
+ formData.logo !== team.logo ||
+ formData.banner !== team.banner
+ );
+ }, [formData, team]);
+
+ // Check if required fields are filled
+ const isFormValid = useMemo(() => {
+ if (!formData) return false;
+ return (
+ formData.name.trim() !== '' &&
+ formData.city.trim() !== '' &&
+ formData.description.trim() !== ''
+ );
+ }, [formData]);
+
+ const canSave = hasChanges && isFormValid;
+
+ if (!isOpen || !formData) return null;
+
+ const handleInputChange = (field: keyof TeamType, value: string | null) => {
+ setFormData((prev) => (prev ? { ...prev, [field]: value } : null));
+ };
+
+ const handleSave = () => {
+ console.log('Saving team:', formData);
+ onClose();
+ };
+
+ const handleDelete = () => {
+ if (!team) return;
+ console.log('Deleting team:', team.id);
+ setShowDeleteConfirm(false);
+ onClose();
+ };
+
+ const handleLogoUpload = (event: React.ChangeEvent) => {
+ const file = event.target.files?.[0];
+ if (!file) return;
+
+ if (!file.type.startsWith('image/')) {
+ alert('Please select an image file');
+ return;
+ }
+ if (file.size > 5 * 1024 * 1024) {
+ alert('Image size must be less than 5MB');
+ return;
+ }
+
+ const reader = new FileReader();
+ reader.onload = (e) => {
+ const logoUrl = e.target?.result as string;
+ handleInputChange('logo', logoUrl);
+ setShowLogoMenu(false);
+ };
+ reader.readAsDataURL(file);
+ };
+
+ const handleBannerUpload = (event: React.ChangeEvent) => {
+ const file = event.target.files?.[0];
+ if (!file) return;
+
+ if (!file.type.startsWith('image/')) {
+ alert('Please select an image file');
+ return;
+ }
+ if (file.size > 5 * 1024 * 1024) {
+ alert('Image size must be less than 5MB');
+ return;
+ }
+
+ const reader = new FileReader();
+ reader.onload = (e) => {
+ const bannerUrl = e.target?.result as string;
+ handleInputChange('banner', bannerUrl);
+ };
+ reader.readAsDataURL(file);
+ };
+
+ return (
+
+
+ {/* Header */}
+
+
+
+
+
+
+
+ {team ? 'Team Details' : 'Create New Team'}
+
+
+ {team
+ ? 'View and manage team information'
+ : 'Add a new team to the hackathon'}
+
+
+
+
{
+ setShowLogoMenu(false);
+ onClose();
+ }}
+ >
+
+
+
+
+ {/* Content */}
+
setShowLogoMenu(false)}>
+
+
+
+ {/* Banner Section */}
+
+
+ Team Banner{' '}
+
+ (3:1 aspect ratio recommended)
+
+
+
+
+
+ {
+ e.stopPropagation();
+ bannerInputRef.current?.click();
+ }}
+ className="bg-white/90 hover:bg-white text-neutral-700 border-transparent shadow-sm gap-2"
+ >
+
+ {formData.banner ? 'Change Banner' : 'Add Banner'}
+
+ {formData.banner && (
+ {
+ e.stopPropagation();
+ handleInputChange('banner', null);
+ }}
+ className="bg-white/90 hover:bg-white text-red-600 border-transparent shadow-sm hover:text-red-700 gap-2"
+ >
+
+ Delete
+
+ )}
+
+
+
+
+ {/* Logo & Name */}
+
+
+
+ Logo
+
+
+
+ {formData.logo ? (
+
+ ) : (
+
+ )}
+
+
{
+ e.stopPropagation();
+ setShowLogoMenu(!showLogoMenu);
+ }}
+ className="absolute inset-0 bg-neutral-300/80 cursor-pointer rounded-full opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center w-24 h-24"
+ >
+
+
+ {showLogoMenu && (
+
+ {
+ e.stopPropagation();
+ logoInputRef.current?.click();
+ }}
+ className="w-full px-4 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-50 flex items-center gap-2 cursor-pointer"
+ >
+
+ {formData.logo ? 'Change Logo' : 'Upload Logo'}
+
+ {formData.logo && (
+ {
+ e.stopPropagation();
+ handleInputChange('logo', null);
+ setShowLogoMenu(false);
+ }}
+ className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center gap-2 cursor-pointer"
+ >
+
+ Remove Logo
+
+ )}
+
+ )}
+
+
+
+
+
+ Team Name *
+
+ handleInputChange('name', e.target.value)}
+ />
+
+
+
+ {/* Description */}
+
+
+ Description *
+
+
+
+ {/* City */}
+
+
+ City *
+
+
+ handleInputChange('city', city === 'all' ? '' : city)
+ }
+ className="w-full"
+ placeholder="Search cities..."
+ allOptionLabel="Select a city"
+ filterIcon={false}
+ />
+
+
+ {/* Visibility */}
+
+
+ {/* Team Details */}
+ {team && (
+
+
+
+ Team Leader ID
+
+
+
+
+ {team.leader_id}
+
+
+
+
+
+
+
+ Created
+
+
+
+
+ {new Date(team.created_at).toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ })}
+
+
+
+
+
+
+ Last Updated
+
+
+
+
+ {new Date(team.updated_at).toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ })}
+
+
+
+
+
+ )}
+
+
+ {/* Footer */}
+
+
+ {team && (
+ setShowDeleteConfirm(true)}
+ className="flex items-center gap-2"
+ >
+
+ Delete Team
+
+ )}
+
+
+
+
+ Cancel
+
+
+
+ {team ? 'Save Changes' : 'Create Team'}
+
+
+
+
+
+ {/* Delete Confirmation Modal */}
+ {showDeleteConfirm && (
+
+
+
+
+
+
+
+
+ Delete Team
+
+
+ This action cannot be undone.
+
+
+
+
+
+ Are you sure you want to delete "{team?.name}"? This will
+ permanently remove the team and all associated data.
+
+
+
+ setShowDeleteConfirm(false)}
+ >
+ Cancel
+
+
+
+ Delete Team
+
+
+
+
+ )}
+
+ );
+};
+
+export default ModalTeamDetail;
diff --git a/apps/backoffice/src/app/(protected)/hackathon-teams/_components/team-banner-placeholder.tsx b/apps/backoffice/src/app/(protected)/hackathon-teams/_components/team-banner-placeholder.tsx
new file mode 100644
index 0000000..cbd9cb6
--- /dev/null
+++ b/apps/backoffice/src/app/(protected)/hackathon-teams/_components/team-banner-placeholder.tsx
@@ -0,0 +1,82 @@
+import { FC } from 'react';
+import { TeamOutlined } from '@ant-design/icons';
+import { cn } from '@imphnen-frontend-service/utils';
+
+interface TeamBannerPlaceholderProps {
+ banner?: string;
+ teamName: string;
+ className?: string;
+ showPlaceholder?: boolean;
+}
+
+const TeamBannerPlaceholder: FC = ({
+ banner,
+ teamName,
+ className = '',
+ showPlaceholder = true,
+}) => {
+ const aspectRatioClass = 'aspect-[3/1]'; // 3:1 aspect ratio
+
+ if (!banner && !showPlaceholder) {
+ return null;
+ }
+
+ if (banner) {
+ return (
+
+
{
+ // Fallback to placeholder if image fails to load
+ const target = e.target as HTMLImageElement;
+ target.style.display = 'none';
+ const placeholder = target.nextElementSibling as HTMLElement;
+ if (placeholder) {
+ placeholder.style.display = 'flex';
+ }
+ }}
+ />
+ {/* Fallback placeholder (hidden by default, shown on image error) */}
+
+
+
+
{teamName}
+
Team Banner
+
+
+
+ );
+ }
+
+ // No banner - show placeholder
+ return (
+
+
+
+
{teamName}
+
No Banner
+
+
+ );
+};
+
+export default TeamBannerPlaceholder;
diff --git a/apps/backoffice/src/app/(protected)/hackathon-teams/page.tsx b/apps/backoffice/src/app/(protected)/hackathon-teams/page.tsx
index eb52968..9e29cb2 100644
--- a/apps/backoffice/src/app/(protected)/hackathon-teams/page.tsx
+++ b/apps/backoffice/src/app/(protected)/hackathon-teams/page.tsx
@@ -1,179 +1,275 @@
-import { FC, ReactElement, useState } from 'react';
+import {
+ FC,
+ ReactElement,
+ useState,
+ useEffect,
+ useMemo,
+ useCallback,
+} from 'react';
+import ModalTeamDetail from './_components/modal-team-detail-new';
+import { CityFilterSelect } from '../../../components/city-filter-select';
import {
BackofficeWrapper,
DataTable,
} from '@imphnen-frontend-service/ui/organisms';
-import {
- ColumnDef,
- getCoreRowModel,
- getPaginationRowModel,
- PaginationState,
- RowSelectionState,
- useReactTable,
-} from '@tanstack/react-table';
+import { ColumnDef } from '@tanstack/react-table';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { cn } from '@imphnen-frontend-service/utils';
-import { useTeams } from '@imphnen-frontend-service/service';
-import { EditOutlined } from '@ant-design/icons';
+import {
+ EditOutlined,
+ TeamOutlined,
+ SearchOutlined,
+ FilterOutlined,
+ PlusOutlined,
+ LoadingOutlined,
+} from '@ant-design/icons';
+import { useQuery } from '@tanstack/react-query';
+import {
+ getAdminTeams,
+ TAdminTeamItem,
+} from '@imphnen-frontend-service/service';
+import { useSearchParams } from 'react-router-dom';
+
+type TeamType = TAdminTeamItem;
export const HackathonTeamsPage: FC = (): ReactElement => {
- const { data: teamsData } = useTeams();
+ const [searchParams, setSearchParams] = useSearchParams();
+ const currentPage = Math.max(
+ 1,
+ parseInt(searchParams.get('page') || '1', 10)
+ );
+ const searchQuery = searchParams.get('search') || '';
+ const perPage = parseInt(searchParams.get('per_page') || '10', 10);
+ const [showDetailModal, setShowDetailModal] = useState(false);
+ const [showNewTeamModal, setShowNewTeamModal] = useState(false);
+ const [selectedTeam, setSelectedTeam] = useState(null);
+ useState(null);
+ const [globalFilter, setGlobalFilter] = useState(searchQuery);
- const [rowSelection, setRowSelection] = useState({});
- const [pagination, setPagination] = useState({
- pageIndex: 0,
- pageSize: 9,
+ // Advanced filtering states
+ const [visibilityFilter, setVisibilityFilter] = useState('all');
+ const [cityFilter, setCityFilter] = useState('all');
+
+ // Fetch teams from API
+ const {
+ data: teamsResponse,
+ isLoading,
+ isFetching,
+ } = useQuery({
+ queryKey: [
+ 'admin-teams',
+ currentPage,
+ perPage,
+ cityFilter,
+ visibilityFilter,
+ searchQuery,
+ ],
+ queryFn: () =>
+ getAdminTeams({
+ page: currentPage,
+ per_page: perPage,
+ search: searchQuery || undefined,
+ }),
+ staleTime: 30000, // 30 seconds cache
+ gcTime: 5 * 60 * 1000, // 5 minutes
});
- const mockData: TeamType[] = Array.from({ length: 90 }, (_, i) => ({
- id: `team-${i + 1}`,
- name: `Team ${i + 1} - ${i % 3 === 0 ? 'Innovators' : 'Hackers'}`,
- city: i % 2 === 0 ? 'Jakarta' : 'Bandung',
- visibility: i % 4 === 0 ? 'private' : 'public',
- member_count: Math.floor(Math.random() * 4) + 1,
- has_submission: i % 3 !== 0,
- created_at: new Date().toISOString(),
- updated_at: new Date().toISOString(),
- leader: {
- user: {
- fullname: `Leader User ${i}`,
- email: `leader${i}@example.com`,
- },
- },
- }));
+ const totalData = teamsResponse?.meta?.total_data || 0;
+ const totalPages = teamsResponse?.meta?.total_page || 1;
- interface TeamType {
- id: string;
- name: string;
- city: string;
- visibility: 'public' | 'private';
- member_count: number;
- has_submission: boolean;
- created_at: string;
- updated_at: string;
- leader?: {
- user: {
- fullname: string;
- email: string;
- };
- };
- }
+ // Handle page change - update URL query params
+ const handlePageChange = useCallback(
+ (newPage: number) => {
+ const params = new URLSearchParams();
+ params.set('page', newPage.toString());
+ if (perPage !== 10) params.set('per_page', perPage.toString());
+ if (searchQuery) params.set('search', searchQuery);
+ setSearchParams(params);
+ window.scrollTo({ top: 0, behavior: 'smooth' });
+ },
+ [setSearchParams, perPage, searchQuery]
+ );
- const columns: ColumnDef[] = [
- {
- accessorKey: 'id',
- header: 'ID',
+ // Validate page number doesn't exceed total pages
+ useEffect(() => {
+ if (!isLoading && totalPages > 0 && currentPage > totalPages) {
+ setSearchParams({ page: totalPages.toString() });
+ }
+ }, [currentPage, totalPages, setSearchParams, isLoading]);
+
+ // Sync globalFilter with URL search param on mount
+ useEffect(() => {
+ setGlobalFilter(searchQuery);
+ }, [searchQuery]);
+
+ // Handle search teams
+ const handleSearch = useCallback(() => {
+ const params = new URLSearchParams();
+ params.set('page', '1');
+ if (perPage !== 10) params.set('per_page', perPage.toString());
+ if (globalFilter.trim()) {
+ params.set('search', globalFilter.trim());
+ }
+ setSearchParams(params);
+ }, [globalFilter, setSearchParams, perPage]);
+
+ // Handle Enter key press in search input
+ const handleSearchKeyPress = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ handleSearch();
+ }
},
- {
- accessorKey: 'name',
- header: 'Team Name',
+ [handleSearch]
+ );
+
+ // Handle per page change
+ const handlePerPageChange = useCallback(
+ (newPerPage: number) => {
+ const params = new URLSearchParams();
+ params.set('page', '1');
+ params.set('per_page', newPerPage.toString());
+ if (searchQuery) params.set('search', searchQuery);
+ setSearchParams(params);
},
- {
- accessorKey: 'city',
- header: 'City',
- },
- {
- accessorKey: 'visibility',
- header: 'Visibility',
- cell: ({ row }) => {
- const isPublic = row.original.visibility === 'public';
- return (
-
- {isPublic ? 'Public' : 'Private'}
-
- );
- },
- },
- {
- accessorKey: 'member_count',
- header: 'Members',
- },
- {
- id: 'leader',
- header: 'Leader',
- cell: ({ row }) => {
- const leader = row.original.leader?.user;
- return leader ? (
-
-
- {leader.fullname}
+ [setSearchParams, searchQuery]
+ );
+
+ // Memoize the callback to prevent recreation
+ const handleShowDetailModal = useCallback((team: TeamType) => {
+ setSelectedTeam(team);
+ setShowDetailModal(true);
+ }, []);
+
+ const handleCloseDetailModal = useCallback(() => {
+ setShowDetailModal(false);
+ setSelectedTeam(null);
+ }, []);
+
+ const handleShowNewTeamModal = useCallback(() => {
+ setShowNewTeamModal(true);
+ }, []);
+
+ const handleCloseNewTeamModal = useCallback(() => {
+ setShowNewTeamModal(false);
+ }, []);
+
+ // Get teams data from API response
+ const filteredData = useMemo(() => {
+ return teamsResponse?.data || [];
+ }, [teamsResponse]);
+
+ // Memoize columns to prevent recreation on every render
+ const columns: ColumnDef
[] = useMemo(
+ () => [
+ {
+ accessorKey: 'name',
+ header: 'Team',
+ cell: ({ row }) => {
+ const team = row.original;
+ return (
+
+ {/* Team Logo */}
+
+ {team.logo ? (
+
+ ) : (
+
+ )}
+
+ {/* Team Name */}
+
- {leader.email}
+ );
+ },
+ enableSorting: true,
+ },
+ {
+ accessorKey: 'city',
+ header: 'City',
+ cell: ({ row }) => (
+ {row.original.city}
+ ),
+ enableSorting: true,
+ },
+ {
+ accessorKey: 'visibility',
+ header: 'Visibility',
+ cell: ({ row }) => {
+ const isPublic = row.original.visibility === 'public';
+ return (
+
+ {isPublic ? 'Public' : 'Private'}
+
+ );
+ },
+ enableSorting: true,
+ },
+ {
+ id: 'leader',
+ header: 'Leader ID',
+ cell: ({ row }) => (
+
+ {row.original.leader_id}
- ) : (
- -
- );
+ ),
+ enableSorting: false,
},
- },
- {
- accessorKey: 'has_submission',
- header: 'Submitted',
- cell: ({ row }) => {
- const hasSubmission = row.original.has_submission;
- return (
-
- {hasSubmission ? 'Yes' : 'No'}
+ {
+ accessorKey: 'created_at',
+ header: 'Created',
+ cell: ({ row }) => (
+
+ {new Date(row.original.created_at).toLocaleDateString('en-UK', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ })}
- );
+ ),
+ enableSorting: true,
+ sortingFn: 'datetime',
},
- },
- {
- accessorKey: 'updated_at',
- header: 'Last Updated',
- cell: ({ row }) => {
- return new Date(row.original.updated_at).toLocaleDateString();
+ {
+ id: 'actions',
+ header: 'Actions',
+ meta: { cellClassName: cn('w-48') },
+ cell: ({ row }) => (
+
+ handleShowDetailModal(row.original)}
+ >
+
+ Manage
+
+
+ ),
+ enableSorting: false,
},
- },
- {
- id: 'actions',
- header: 'Action',
- meta: { cellClassName: cn('w-48') },
- cell: ({ row }) => (
-
- {
- // View detail logic
- }}
- >
- View & Manage
-
-
- ),
- },
- ];
-
- const table = useReactTable({
- data: mockData,
- columns,
- state: {
- pagination,
- rowSelection,
- },
- enableRowSelection: true,
- onRowSelectionChange: setRowSelection,
- getCoreRowModel: getCoreRowModel(),
- getPaginationRowModel: getPaginationRowModel(),
- onPaginationChange: setPagination,
- pageCount: Math.ceil(mockData.length / pagination.pageSize),
- manualPagination: false,
- });
+ ],
+ [handleShowDetailModal]
+ );
return (
@@ -182,27 +278,168 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
{/* Filters and actions */}
-
-
-
- All Status
- Active
- Suspended
-
-
- All City
- Jakarta
- Bandung
-
+
+ {/* Left side - Search & filters */}
+
+ {/* Search bar */}
+
+
+ setGlobalFilter(e.target.value)}
+ onKeyPress={handleSearchKeyPress}
+ />
+
+
+ {/* Per Page Dropdown */}
+
+
+ handlePerPageChange(parseInt(e.target.value, 10))
+ }
+ >
+ 10 / page
+ 20 / page
+ 50 / page
+ 100 / page
+
+
+
+ {/* Visibility Filter */}
+ {/*
+
+ setVisibilityFilter(e.target.value)}
+ >
+ All Visibility
+ Public
+ Private
+
+
*/}
+
+ {/* City Filter */}
+ {/*
*/}
+
+
+ {/* Right side - Add Team Button */}
+
- {/* Table */}
-
+
+ {/* Active filters display */}
+ {(visibilityFilter !== 'all' || cityFilter !== 'all') && (
+
+ Active filters:
+
+ {/* Visibility filter badge */}
+ {visibilityFilter !== 'all' && (
+
+ Visibility: {visibilityFilter}
+ setVisibilityFilter('all')}
+ className="text-info-600 hover:text-info-800 cursor-pointer"
+ >
+ ✕
+
+
+ )}
+
+ {/* City filter badge */}
+ {cityFilter !== 'all' && (
+
+ City: {cityFilter}
+ setCityFilter('all')}
+ className="text-green-600 hover:text-green-800 cursor-pointer"
+ >
+ ✕
+
+
+ )}
+
+ {/* Clear all filters */}
+ {
+ setVisibilityFilter('all');
+ setCityFilter('all');
+ setGlobalFilter('');
+ }}
+ className="text-sm text-neutral-600"
+ >
+ Clear All
+
+
+ )}
+
+ {/* Loading & results display */}
+ {isLoading ? (
+
+
+ Loading teams...
+
+ ) : filteredData.length > 0 ? (
+ <>
+
+ Showing {filteredData.length} of {totalData} teams (Page{' '}
+ {currentPage} of {totalPages})
+ {isFetching && (
+ (Updating...)
+ )}
+
+
+ >
+ ) : (
+
+ No teams found. Try adjusting your filters.
+
+ )}
- {/* Modals extracted into shared backoffice components */}
+
+ {/* Modals component */}
+
+
+ {/* New Team Modal */}
+
);
};
diff --git a/apps/backoffice/src/app/(protected)/hackathon-users/_components/modal-user-detail.tsx b/apps/backoffice/src/app/(protected)/hackathon-users/_components/modal-user-detail.tsx
index 8182c5f..519044a 100644
--- a/apps/backoffice/src/app/(protected)/hackathon-users/_components/modal-user-detail.tsx
+++ b/apps/backoffice/src/app/(protected)/hackathon-users/_components/modal-user-detail.tsx
@@ -15,10 +15,10 @@ import {
interface UserType {
id: string;
- avatar?: string;
+ avatar?: string | null;
fullname: string;
bio?: string;
- location: string;
+ location: string | null;
is_active: boolean;
skills: string[];
created_at: string;
@@ -77,7 +77,7 @@ const ModalUserDetail: FC
= ({ isOpen, onClose, user }) => {
// Check if required fields are filled
const isFormValid = useMemo(() => {
if (!formData) return false;
- return formData.fullname.trim() !== '' && formData.location.trim() !== '';
+ return formData.fullname?.trim() !== '' && formData.location?.trim() !== '';
}, [formData]);
const canSave = hasChanges && isFormValid;
@@ -259,13 +259,13 @@ const ModalUserDetail: FC = ({ isOpen, onClose, user }) => {
{
setShowAvatarMenu(false);
handleCancel();
}}
>
-
+
@@ -294,13 +294,15 @@ const ModalUserDetail: FC = ({ isOpen, onClose, user }) => {
}
className={cn(
'w-full border rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none',
- formData.fullname.trim() === ''
+ !formData.fullname ||
+ formData.fullname.trim() === ''
? 'border-red-300 bg-red-50'
: 'border-neutral-300'
)}
placeholder="Enter full name"
/>
- {formData.fullname.trim() === '' && (
+ {(!formData.fullname ||
+ formData.fullname.trim() === '') && (
Full name is required
@@ -316,13 +318,14 @@ const ModalUserDetail: FC = ({ isOpen, onClose, user }) => {
Location *
handleInputChange('location', e.target.value)
}
className={cn(
'w-full border rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none bg-white',
- formData.location.trim() === ''
+ !formData.location ||
+ formData.location.trim() === ''
? 'border-red-300 bg-red-50'
: 'border-neutral-300'
)}
@@ -334,7 +337,8 @@ const ModalUserDetail: FC = ({ isOpen, onClose, user }) => {
Medan
Yogyakarta
- {formData.location.trim() === '' && (
+ {(!formData.location ||
+ formData.location.trim() === '') && (
Location is required
diff --git a/apps/backoffice/src/app/(protected)/hackathon-users/page.tsx b/apps/backoffice/src/app/(protected)/hackathon-users/page.tsx
index 500f9eb..5903fc5 100644
--- a/apps/backoffice/src/app/(protected)/hackathon-users/page.tsx
+++ b/apps/backoffice/src/app/(protected)/hackathon-users/page.tsx
@@ -1,4 +1,11 @@
-import { FC, ReactElement, useState, useMemo, useCallback } from 'react';
+import {
+ FC,
+ ReactElement,
+ useState,
+ useEffect,
+ useMemo,
+ useCallback,
+} from 'react';
import ModalUserDetail from './_components/modal-user-detail';
import {
BackofficeWrapper,
@@ -13,23 +20,19 @@ import {
SearchOutlined,
FilterOutlined,
PlusOutlined,
+ LoadingOutlined,
} from '@ant-design/icons';
-// Removed unused SearchOutlined icon after schema revision
+import { CityFilterSelect } from '../../../components/city-filter-select';
+import { useQuery } from '@tanstack/react-query';
+import {
+ getAdminUsers,
+ TAdminUserItem,
+} from '@imphnen-frontend-service/service';
+import { useSearchParams } from 'react-router-dom';
-// Define interface outside component
-interface UserType {
- id: string; // UUID
- avatar?: string;
- fullname: string;
- bio?: string;
- location: string;
- is_active: boolean; // admin can deactivate
- skills: string[]; // Frontend Developer, Backend Developer, etc.
- created_at: string;
- updated_at: string;
-}
+type UserType = TAdminUserItem;
-// Move mock data outside component to prevent recreation
+// Skills options for filter
const skillsOptions = [
'Frontend Developer',
'Backend Developer',
@@ -41,59 +44,108 @@ const skillsOptions = [
'Mobile Developer',
];
-const locations = ['Jakarta', 'Bandung', 'Surabaya', 'Medan', 'Yogyakarta'];
-const bios = [
- 'Passionate developer with 5+ years experience',
- 'Tech enthusiast and problem solver',
- 'Building scalable solutions for modern problems',
- 'Creative designer with technical background',
- 'Data-driven decision maker',
-];
-
-const mockData: UserType[] = Array.from({ length: 50 }, (_, i) => {
- const randomSkillsCount = Math.floor(Math.random() * 3) + 1; // 1-3 skills
- const randomSkills = skillsOptions
- .sort(() => 0.5 - Math.random())
- .slice(0, randomSkillsCount);
-
- return {
- id: `24db9e4d-ca4c-46aa-ac36-8ef04bbe01${String(i).padStart(2, '0')}`,
- avatar:
- i % 4 === 0
- ? `https://ui-avatars.com/api/?name=${encodeURIComponent(
- i % 3 === 0 ? 'Ahmad Wijuana' : 'Sofia Wijuana'
- )}&background=random`
- : undefined,
- fullname:
- i % 3 === 0
- ? 'Ahmad Wijuana'
- : i % 3 === 1
- ? 'Sofia Wijuana'
- : 'Budi Santoso',
- bio: i % 4 === 0 ? bios[i % bios.length] : undefined,
- location: locations[i % locations.length],
- is_active: i % 7 !== 0, // More realistic distribution
- skills: randomSkills,
- created_at: new Date(
- Date.now() - i * 86400000 * (Math.random() * 30 + 1)
- ).toISOString(), // Random within last 30-60 days
- updated_at: new Date().toISOString(),
- };
-});
-
export const HackathonUsersPage: FC = (): ReactElement => {
+ const [searchParams, setSearchParams] = useSearchParams();
+ const currentPage = Math.max(
+ 1,
+ parseInt(searchParams.get('page') || '1', 10)
+ );
+ const searchQuery = searchParams.get('search') || '';
+ const perPage = parseInt(searchParams.get('per_page') || '10', 10);
const [showDetailModal, setShowDetailModal] = useState(false);
const [showNewUserModal, setShowNewUserModal] = useState(false);
const [selectedUser, setSelectedUser] = useState(null);
- const [globalFilter, setGlobalFilter] = useState('');
+ const [globalFilter, setGlobalFilter] = useState(searchQuery);
// Advanced filtering states
const [statusFilter, setStatusFilter] = useState('all');
- const [locationFilter, setLocationFilter] = useState('all');
+ const [cityFilter, setCityFilter] = useState('all');
const [skillsFilter, setSkillsFilter] = useState([]);
- // Constants
- const pageSize = 10;
+ // Fetch users from API
+ const {
+ data: usersResponse,
+ isLoading,
+ isFetching,
+ } = useQuery({
+ queryKey: [
+ 'admin-users',
+ currentPage,
+ perPage,
+ cityFilter,
+ statusFilter,
+ searchQuery,
+ ],
+ queryFn: () =>
+ getAdminUsers({
+ page: currentPage,
+ per_page: perPage,
+ search: searchQuery || undefined,
+ }),
+ staleTime: 30000, // 30 seconds cache
+ gcTime: 5 * 60 * 1000, // 5 minutes
+ });
+
+ const totalData = usersResponse?.meta?.total_data || 0;
+ const totalPages = usersResponse?.meta?.total_page || 1;
+
+ // Handle page change - update URL query params
+ const handlePageChange = useCallback(
+ (newPage: number) => {
+ const params = new URLSearchParams();
+ params.set('page', newPage.toString());
+ if (perPage !== 10) params.set('per_page', perPage.toString());
+ if (searchQuery) params.set('search', searchQuery);
+ setSearchParams(params);
+ window.scrollTo({ top: 0, behavior: 'smooth' });
+ },
+ [setSearchParams, perPage, searchQuery]
+ );
+
+ // Validate page number doesn't exceed total pages
+ useEffect(() => {
+ if (!isLoading && totalPages > 0 && currentPage > totalPages) {
+ setSearchParams({ page: totalPages.toString() });
+ }
+ }, [currentPage, totalPages, setSearchParams, isLoading]);
+
+ // Sync globalFilter with URL search param on mount
+ useEffect(() => {
+ setGlobalFilter(searchQuery);
+ }, [searchQuery]);
+
+ // Handle search users
+ const handleSearch = useCallback(() => {
+ const params = new URLSearchParams();
+ params.set('page', '1');
+ if (perPage !== 10) params.set('per_page', perPage.toString());
+ if (globalFilter.trim()) {
+ params.set('search', globalFilter.trim());
+ }
+ setSearchParams(params);
+ }, [globalFilter, setSearchParams, perPage]);
+
+ // Handle Enter key press in search input
+ const handleSearchKeyPress = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ handleSearch();
+ }
+ },
+ [handleSearch]
+ );
+
+ // Handle per page change
+ const handlePerPageChange = useCallback(
+ (newPerPage: number) => {
+ const params = new URLSearchParams();
+ params.set('page', '1');
+ params.set('per_page', newPerPage.toString());
+ if (searchQuery) params.set('search', searchQuery);
+ setSearchParams(params);
+ },
+ [setSearchParams, searchQuery]
+ );
// Memoize the callback to prevent recreation
const handleShowDetailModal = useCallback((user: UserType) => {
@@ -116,29 +168,26 @@ export const HackathonUsersPage: FC = (): ReactElement => {
// Filter data based on current filter states
const filteredData = useMemo(() => {
- return mockData.filter((user) => {
+ const usersData = usersResponse?.data || [];
+ return usersData.filter((user: UserType) => {
// Status filter
if (statusFilter !== 'all') {
const isActive = statusFilter === 'active';
if (user.is_active !== isActive) return false;
}
- // Location filter
- if (locationFilter !== 'all' && user.location !== locationFilter) {
- return false;
- }
-
// Skills filter
if (skillsFilter.length > 0) {
+ const userSkills = user.skills || [];
const hasMatchingSkill = skillsFilter.some((skill) =>
- user.skills.includes(skill)
+ userSkills.includes(skill)
);
if (!hasMatchingSkill) return false;
}
return true;
});
- }, [statusFilter, locationFilter, skillsFilter]);
+ }, [usersResponse, statusFilter, skillsFilter]);
// Memoize columns to prevent recreation on every render
const columns: ColumnDef[] = useMemo(
@@ -173,23 +222,32 @@ export const HackathonUsersPage: FC = (): ReactElement => {
{
accessorKey: 'skills',
header: 'Skills',
- cell: ({ row }) => (
-
- {row.original.skills.slice(0, 2).map((skill, index) => (
-
- {skill.replace(' Developer', '').replace(' Engineer', '')}
-
- ))}
- {row.original.skills.length > 2 && (
-
- +{row.original.skills.length - 2}
-
- )}
-
- ),
+ cell: ({ row }) => {
+ const skills = row.original.skills || [];
+ return (
+
+ {skills.length > 0 ? (
+ <>
+ {skills.slice(0, 2).map((skill, index) => (
+
+ {skill.replace(' Developer', '').replace(' Engineer', '')}
+
+ ))}
+ {skills.length > 2 && (
+
+ +{skills.length - 2}
+
+ )}
+ >
+ ) : (
+ -
+ )}
+
+ );
+ },
enableSorting: false,
},
{
@@ -260,7 +318,7 @@ export const HackathonUsersPage: FC = (): ReactElement => {
Manage
- {
}}
>
{row.original.is_active ? 'Deactivate' : 'Activate'}
-
+ */}
),
enableSorting: false,
@@ -298,11 +356,28 @@ export const HackathonUsersPage: FC = (): ReactElement => {
placeholder="Search users by name or location..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
+ onKeyPress={handleSearchKeyPress}
/>
- {/* Status Filter */}
+ {/* Per Page Dropdown */}
+
+ handlePerPageChange(parseInt(e.target.value, 10))
+ }
+ >
+ 10 / page
+ 20 / page
+ 50 / page
+ 100 / page
+
+
+
+ {/* Status Filter */}
+ {/*
{
Active
Inactive
-
+ */}
- {/* Location Filter */}
-
-
- setLocationFilter(e.target.value)}
- >
- All Locations
- {locations.map((location) => (
-
- {location}
-
- ))}
-
-
+ {/* City Filter */}
+ {/*
+ {cityFilter !== 'all' && (
+
+ Location: {cityFilter}
+ setCityFilter('all')}
+ className="text-green-600 hover:text-green-800 cursor-pointer"
+ >
+ ✕
+
+
+ )} */}
{/* Skills Filter with Icon */}
- */}
{/* Right side - Add User Button */}
@@ -378,7 +456,7 @@ export const HackathonUsersPage: FC = (): ReactElement => {
{/* Active filters display */}
{(skillsFilter.length > 0 ||
statusFilter !== 'all' ||
- locationFilter !== 'all') && (
+ cityFilter !== 'all') && (
Active filters:
@@ -396,11 +474,11 @@ export const HackathonUsersPage: FC = (): ReactElement => {
)}
{/* Location filter badge */}
- {locationFilter !== 'all' && (
+ {cityFilter !== 'all' && (
- Location: {locationFilter}
+ City: {cityFilter}
setLocationFilter('all')}
+ onClick={() => setCityFilter('all')}
className="text-green-600 hover:text-green-800 cursor-pointer"
>
✕
@@ -432,7 +510,7 @@ export const HackathonUsersPage: FC = (): ReactElement => {
size="sm"
onClick={() => {
setStatusFilter('all');
- setLocationFilter('all');
+ setCityFilter('all');
setSkillsFilter([]);
setGlobalFilter('');
}}
@@ -443,17 +521,36 @@ export const HackathonUsersPage: FC = (): ReactElement => {
)}
- {/* Pagination-aware results display */}
- {filteredData.length > 0 && (
-
- Showing {Math.min(pageSize, filteredData.length)} of{' '}
- {filteredData.length} users
- {filteredData.length > pageSize}
+ {/* Loading & results display */}
+ {isLoading ? (
+
+
+ Loading users...
+
+ ) : filteredData.length > 0 ? (
+ <>
+
+ Showing {filteredData.length} of {totalData} users (Page{' '}
+ {currentPage} of {totalPages})
+ {isFetching && (
+ (Updating...)
+ )}
+
+
+ >
+ ) : (
+
+ No users found. Try adjusting your filters.
)}
-
- {/* Table */}
-
{/* Modals component */}
diff --git a/apps/backoffice/src/app/(protected)/layout.tsx b/apps/backoffice/src/app/(protected)/layout.tsx
index a1fb13e..957a288 100644
--- a/apps/backoffice/src/app/(protected)/layout.tsx
+++ b/apps/backoffice/src/app/(protected)/layout.tsx
@@ -23,7 +23,7 @@ export const AppLayout: FC = (): ReactElement => {
{/* Mobile menu button (shown on small screens) */}
setMobileSidebarOpen(true)}
aria-label="Open sidebar"
>
diff --git a/apps/backoffice/src/app/(public)/auth/login/_hooks/use-login.ts b/apps/backoffice/src/app/(public)/auth/login/_hooks/use-login.ts
index e0323ce..e5a4a01 100644
--- a/apps/backoffice/src/app/(public)/auth/login/_hooks/use-login.ts
+++ b/apps/backoffice/src/app/(public)/auth/login/_hooks/use-login.ts
@@ -2,22 +2,39 @@ import { useForm } from 'react-hook-form';
import {
authLoginSchema,
TLoginRequest,
+ useBackofficeLogin,
} from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod';
-import { useSession } from '@imphnen-frontend-service/utils';
+import { useNavigate } from 'react-router';
+import { toast } from 'sonner';
export const useLogin = () => {
+ const navigate = useNavigate();
+ const loginMutation = useBackofficeLogin();
+
const form = useForm({
resolver: zodResolver(authLoginSchema),
mode: 'all',
+ defaultValues: {
+ email: '',
+ password: '',
+ },
});
- const { signIn } = useSession();
-
- const onSubmit = form.handleSubmit((data) => signIn(data));
+ const onSubmit = form.handleSubmit(async (data) => {
+ try {
+ await loginMutation.mutateAsync(data);
+ toast.success('Login berhasil!');
+ navigate('/hackathon-dashboard');
+ } catch (error) {
+ console.error('[Backoffice Login] Error:', error);
+ toast.error((error as Error).message || 'Login gagal');
+ }
+ });
return {
form,
onSubmit,
+ isLoading: loginMutation.isPending,
};
};
diff --git a/apps/backoffice/src/app/(public)/auth/login/page.tsx b/apps/backoffice/src/app/(public)/auth/login/page.tsx
index bb0a020..31e2e9a 100644
--- a/apps/backoffice/src/app/(public)/auth/login/page.tsx
+++ b/apps/backoffice/src/app/(public)/auth/login/page.tsx
@@ -4,7 +4,7 @@ import { useLogin } from './_hooks/use-login';
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
export const Components: FC = (): ReactElement => {
- const { form, onSubmit } = useLogin();
+ const { form, onSubmit, isLoading } = useLogin();
return (
@@ -22,6 +22,7 @@ export const Components: FC = (): ReactElement => {
name="email"
size="lg"
className="w-full"
+ disabled={isLoading}
/>
{
name="password"
size="lg"
className="w-full"
+ disabled={isLoading}
/>
{
size="md"
className="w-full"
>
- Login
+ {isLoading ? 'Loading...' : 'Login'}
diff --git a/apps/backoffice/src/app/404.tsx b/apps/backoffice/src/app/404.tsx
new file mode 100644
index 0000000..f1e1736
--- /dev/null
+++ b/apps/backoffice/src/app/404.tsx
@@ -0,0 +1,23 @@
+import { Link } from 'react-router-dom';
+
+export default function NotFoundPage() {
+ return (
+
+
+
404
+
+ Page Not Found
+
+
+ The page you are looking for doesn't exist or has been moved.
+
+
+ Go Back Home
+
+
+
+ );
+}
diff --git a/apps/backoffice/src/app/error.tsx b/apps/backoffice/src/app/error.tsx
new file mode 100644
index 0000000..3859b00
--- /dev/null
+++ b/apps/backoffice/src/app/error.tsx
@@ -0,0 +1,29 @@
+import { useRouteError, isRouteErrorResponse } from 'react-router-dom';
+
+export default function ErrorPage() {
+ const error = useRouteError();
+ let errorMessage: string;
+
+ if (isRouteErrorResponse(error)) {
+ errorMessage = error.statusText;
+ } else if (error instanceof Error) {
+ errorMessage = error.message;
+ } else if (typeof error === 'string') {
+ errorMessage = error;
+ } else {
+ console.error(error);
+ errorMessage = 'Unknown error';
+ }
+
+ return (
+
+
+
Oops!
+
+ Sorry, an unexpected error has occurred.
+
+
{errorMessage}
+
+
+ );
+}
diff --git a/apps/backoffice/src/components/city-filter-select.tsx b/apps/backoffice/src/components/city-filter-select.tsx
new file mode 100644
index 0000000..9c9d79d
--- /dev/null
+++ b/apps/backoffice/src/components/city-filter-select.tsx
@@ -0,0 +1,148 @@
+import { FC, useState, useRef, useEffect } from 'react';
+import { FilterOutlined } from '@ant-design/icons';
+import INDONESIAN_CITIES from '../constants/cities';
+
+interface CityFilterSelectProps {
+ value: string;
+ onChange: (value: string) => void;
+ className?: string;
+ placeholder?: string;
+ allOptionLabel?: string;
+ filterIcon?: boolean;
+}
+
+export const CityFilterSelect: FC = ({
+ value,
+ onChange,
+ className = '',
+ placeholder = 'Search cities...',
+ allOptionLabel = 'All Cities',
+ filterIcon = true,
+}) => {
+ const [isOpen, setIsOpen] = useState(false);
+ const [searchQuery, setSearchQuery] = useState('');
+ const dropdownRef = useRef(null);
+ const inputRef = useRef(null);
+
+ // Filter cities based on search query
+ const filteredCities = INDONESIAN_CITIES.filter((city) =>
+ city.toLowerCase().includes(searchQuery.toLowerCase())
+ );
+
+ // Close dropdown when clicking outside
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (
+ dropdownRef.current &&
+ !dropdownRef.current.contains(event.target as Node)
+ ) {
+ setIsOpen(false);
+ setSearchQuery('');
+ }
+ };
+
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, []);
+
+ const handleSelectCity = (city: string) => {
+ onChange(city);
+ setSearchQuery('');
+ setIsOpen(false);
+ };
+
+ const handleInputClick = () => {
+ setIsOpen(true);
+ setSearchQuery('');
+ };
+
+ const handleClearSelection = () => {
+ onChange('all');
+ setSearchQuery('');
+ setIsOpen(false);
+ };
+
+ const displayValue = value === 'all' ? allOptionLabel : value;
+ const showClearButton = value !== 'all' && !isOpen;
+
+ return (
+
+
+ {filterIcon && (
+
+ )}
+ {
+ setSearchQuery(e.target.value);
+ if (!isOpen) setIsOpen(true);
+ }}
+ onClick={handleInputClick}
+ onFocus={handleInputClick}
+ placeholder={isOpen ? placeholder : displayValue}
+ className={`border border-neutral-200 rounded-lg pr-10 py-2.5 text-sm w-full focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer ${
+ filterIcon ? ' pl-10' : 'pl-3'
+ }`}
+ />
+ {showClearButton && (
+ {
+ e.stopPropagation();
+ handleClearSelection();
+ }}
+ className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600 text-xs cursor-pointer z-20"
+ >
+ ✕
+
+ )}
+
+
+ {isOpen && (
+
+ {/* All Cities Option */}
+
handleSelectCity('all')}
+ className={`px-3 py-2 cursor-pointer hover:bg-neutral-50 border-b border-neutral-100 ${
+ value === 'all'
+ ? 'bg-primary-50 text-primary-700 font-medium'
+ : 'text-neutral-900'
+ }`}
+ >
+ {allOptionLabel}
+
+
+ {/* Filtered Cities */}
+ {filteredCities.length > 0 ? (
+
+ {filteredCities.slice(0, 100).map((city) => (
+
handleSelectCity(city)}
+ className={`px-3 py-2 cursor-pointer hover:bg-neutral-50 text-sm ${
+ value === city
+ ? 'bg-primary-50 text-primary-700 font-medium'
+ : 'text-neutral-700'
+ }`}
+ >
+ {city}
+
+ ))}
+ {filteredCities.length > 100 && (
+
+ Showing first 100 results. Continue typing to refine...
+
+ )}
+
+ ) : searchQuery ? (
+
+ No cities found matching "{searchQuery}"
+
+ ) : null}
+
+ )}
+
+ );
+};
diff --git a/apps/backoffice/src/constants/cities.ts b/apps/backoffice/src/constants/cities.ts
new file mode 100644
index 0000000..9047bc0
--- /dev/null
+++ b/apps/backoffice/src/constants/cities.ts
@@ -0,0 +1,518 @@
+const INDONESIAN_CITIES: string[] = [
+ 'Aceh Selatan',
+ 'Aceh Tenggara',
+ 'Aceh Timur',
+ 'Aceh Tengah',
+ 'Aceh Barat',
+ 'Aceh Besar',
+ 'Pidie',
+ 'Aceh Utara',
+ 'Simeulue',
+ 'Aceh Singkil',
+ 'Bireuen',
+ 'Aceh Barat Daya',
+ 'Gayo Lues',
+ 'Aceh Jaya',
+ 'Nagan Raya',
+ 'Aceh Tamiang',
+ 'Bener Meriah',
+ 'Pidie Jaya',
+ 'Kota Banda Aceh',
+ 'Kota Sabang',
+ 'Kota Lhokseumawe',
+ 'Kota Langsa',
+ 'Kota Subulussalam',
+ 'Tapanuli Tengah',
+ 'Tapanuli Utara',
+ 'Tapanuli Selatan',
+ 'Nias',
+ 'Langkat',
+ 'Karo',
+ 'Deli Serdang',
+ 'Simalungun',
+ 'Asahan',
+ 'Labuhanbatu',
+ 'Dairi',
+ 'Toba',
+ 'Mandailing Natal',
+ 'Nias Selatan',
+ 'Pakpak Bharat',
+ 'Humbang Hasundutan',
+ 'Samosir',
+ 'Serdang Bedagai',
+ 'Batu Bara',
+ 'Padang Lawas Utara',
+ 'Padang Lawas',
+ 'Labuhanbatu Selatan',
+ 'Labuhanbatu Utara',
+ 'Nias Utara',
+ 'Nias Barat',
+ 'Kota Medan',
+ 'Kota Pematangsiantar',
+ 'Kota Sibolga',
+ 'Kota Tanjung Balai',
+ 'Kota Binjai',
+ 'Kota Tebing Tinggi',
+ 'Kota Padangsidimpuan',
+ 'Kota Gunungsitoli',
+ 'Pesisir Selatan',
+ 'Solok',
+ 'Sijunjung',
+ 'Tanah Datar',
+ 'Padang Pariaman',
+ 'Agam',
+ 'Lima Puluh Kota',
+ 'Pasaman',
+ 'Kepulauan Mentawai',
+ 'Dharmasraya',
+ 'Solok Selatan',
+ 'Pasaman Barat',
+ 'Kota Padang',
+ 'Kota Solok',
+ 'Kota Sawahlunto',
+ 'Kota Padang Panjang',
+ 'Kota Bukittinggi',
+ 'Kota Payakumbuh',
+ 'Kota Pariaman',
+ 'Kampar',
+ 'Indragiri Hulu',
+ 'Bengkalis',
+ 'Indragiri Hilir',
+ 'Pelalawan',
+ 'Rokan Hulu',
+ 'Rokan Hilir',
+ 'Siak',
+ 'Kuantan Singingi',
+ 'Kepulauan Meranti',
+ 'Kota Pekanbaru',
+ 'Kota Dumai',
+ 'Kerinci',
+ 'Merangin',
+ 'Sarolangun',
+ 'Batanghari',
+ 'Muaro Jambi',
+ 'Tanjung Jabung Barat',
+ 'Tanjung Jabung Timur',
+ 'Bungo',
+ 'Tebo',
+ 'Kota Jambi',
+ 'Kota Sungai Penuh',
+ 'Ogan Komering Ulu',
+ 'Ogan Komering Ilir',
+ 'Muara Enim',
+ 'Lahat',
+ 'Musi Rawas',
+ 'Musi Banyuasin',
+ 'Banyuasin',
+ 'Ogan Komering Ulu Timur',
+ 'Ogan Komering Ulu Selatan',
+ 'Ogan Ilir',
+ 'Empat Lawang',
+ 'Penukal Abab Lematang Ilir',
+ 'Musi Rawas Utara',
+ 'Kota Palembang',
+ 'Kota Pagar Alam',
+ 'Kota Lubuk Linggau',
+ 'Kota Prabumulih',
+ 'Bengkulu Selatan',
+ 'Rejang Lebong',
+ 'Bengkulu Utara',
+ 'Kaur',
+ 'Seluma',
+ 'Muko Muko',
+ 'Lebong',
+ 'Kepahiang',
+ 'Bengkulu Tengah',
+ 'Kota Bengkulu',
+ 'Lampung Selatan',
+ 'Lampung Tengah',
+ 'Lampung Utara',
+ 'Lampung Barat',
+ 'Tulang Bawang',
+ 'Tanggamus',
+ 'Lampung Timur',
+ 'Way Kanan',
+ 'Pesawaran',
+ 'Pringsewu',
+ 'Mesuji',
+ 'Tulang Bawang Barat',
+ 'Pesisir Barat',
+ 'Kota Bandar Lampung',
+ 'Kota Metro',
+ 'Bangka',
+ 'Belitung',
+ 'Bangka Selatan',
+ 'Bangka Tengah',
+ 'Bangka Barat',
+ 'Belitung Timur',
+ 'Kota Pangkal Pinang',
+ 'Bintan',
+ 'Karimun',
+ 'Natuna',
+ 'Lingga',
+ 'Kepulauan Anambas',
+ 'Kota Batam',
+ 'Kota Tanjung Pinang',
+ 'Kepulauan Seribu',
+ 'Kota Jakarta Pusat',
+ 'Kota Jakarta Utara',
+ 'Kota Jakarta Barat',
+ 'Kota Jakarta Selatan',
+ 'Kota Jakarta Timur',
+ 'Bogor',
+ 'Sukabumi',
+ 'Cianjur',
+ 'Bandung',
+ 'Garut',
+ 'Tasikmalaya',
+ 'Ciamis',
+ 'Kuningan',
+ 'Cirebon',
+ 'Majalengka',
+ 'Sumedang',
+ 'Indramayu',
+ 'Subang',
+ 'Purwakarta',
+ 'Karawang',
+ 'Bekasi',
+ 'Bandung Barat',
+ 'Pangandaran',
+ 'Kota Bogor',
+ 'Kota Sukabumi',
+ 'Kota Bandung',
+ 'Kota Cirebon',
+ 'Kota Bekasi',
+ 'Kota Depok',
+ 'Kota Cimahi',
+ 'Kota Tasikmalaya',
+ 'Kota Banjar',
+ 'Cilacap',
+ 'Banyumas',
+ 'Purbalingga',
+ 'Banjarnegara',
+ 'Kebumen',
+ 'Purworejo',
+ 'Wonosobo',
+ 'Magelang',
+ 'Boyolali',
+ 'Klaten',
+ 'Sukoharjo',
+ 'Wonogiri',
+ 'Karanganyar',
+ 'Sragen',
+ 'Grobogan',
+ 'Blora',
+ 'Rembang',
+ 'Pati',
+ 'Kudus',
+ 'Jepara',
+ 'Demak',
+ 'Semarang',
+ 'Temanggung',
+ 'Kendal',
+ 'Batang',
+ 'Pekalongan',
+ 'Pemalang',
+ 'Tegal',
+ 'Brebes',
+ 'Kota Magelang',
+ 'Kota Surakarta',
+ 'Kota Salatiga',
+ 'Kota Semarang',
+ 'Kota Pekalongan',
+ 'Kota Tegal',
+ 'Kulon Progo',
+ 'Bantul',
+ 'Gunungkidul',
+ 'Sleman',
+ 'Kota Yogyakarta',
+ 'Pacitan',
+ 'Ponorogo',
+ 'Trenggalek',
+ 'Tulungagung',
+ 'Blitar',
+ 'Kediri',
+ 'Malang',
+ 'Lumajang',
+ 'Jember',
+ 'Banyuwangi',
+ 'Bondowoso',
+ 'Situbondo',
+ 'Probolinggo',
+ 'Pasuruan',
+ 'Sidoarjo',
+ 'Mojokerto',
+ 'Jombang',
+ 'Nganjuk',
+ 'Madiun',
+ 'Magetan',
+ 'Ngawi',
+ 'Bojonegoro',
+ 'Tuban',
+ 'Lamongan',
+ 'Gresik',
+ 'Bangkalan',
+ 'Sampang',
+ 'Pamekasan',
+ 'Sumenep',
+ 'Kota Kediri',
+ 'Kota Blitar',
+ 'Kota Malang',
+ 'Kota Probolinggo',
+ 'Kota Pasuruan',
+ 'Kota Mojokerto',
+ 'Kota Madiun',
+ 'Kota Surabaya',
+ 'Kota Batu',
+ 'Pandeglang',
+ 'Lebak',
+ 'Tangerang',
+ 'Serang',
+ 'Kota Tangerang',
+ 'Kota Cilegon',
+ 'Kota Serang',
+ 'Kota Tangerang Selatan',
+ 'Jembrana',
+ 'Tabanan',
+ 'Badung',
+ 'Gianyar',
+ 'Klungkung',
+ 'Bangli',
+ 'Karangasem',
+ 'Buleleng',
+ 'Kota Denpasar',
+ 'Lombok Barat',
+ 'Lombok Tengah',
+ 'Lombok Timur',
+ 'Sumbawa',
+ 'Dompu',
+ 'Bima',
+ 'Sumbawa Barat',
+ 'Lombok Utara',
+ 'Kota Mataram',
+ 'Kota Bima',
+ 'Kupang',
+ 'Timor Tengah Selatan',
+ 'Timor Tengah Utara',
+ 'Belu',
+ 'Alor',
+ 'Flores Timur',
+ 'Sikka',
+ 'Ende',
+ 'Ngada',
+ 'Manggarai',
+ 'Sumba Timur',
+ 'Sumba Barat',
+ 'Lembata',
+ 'Rote Ndao',
+ 'Manggarai Barat',
+ 'Nagekeo',
+ 'Sumba Tengah',
+ 'Sumba Barat Daya',
+ 'Manggarai Timur',
+ 'Sabu Raijua',
+ 'Malaka',
+ 'Kota Kupang',
+ 'Sambas',
+ 'Mempawah',
+ 'Sanggau',
+ 'Ketapang',
+ 'Sintang',
+ 'Kapuas Hulu',
+ 'Bengkayang',
+ 'Landak',
+ 'Sekadau',
+ 'Melawi',
+ 'Kayong Utara',
+ 'Kubu Raya',
+ 'Kota Pontianak',
+ 'Kota Singkawang',
+ 'Kotawaringin Barat',
+ 'Kotawaringin Timur',
+ 'Kapuas',
+ 'Barito Selatan',
+ 'Barito Utara',
+ 'Katingan',
+ 'Seruyan',
+ 'Sukamara',
+ 'Lamandau',
+ 'Gunung Mas',
+ 'Pulang Pisau',
+ 'Murung Raya',
+ 'Barito Timur',
+ 'Kota Palangkaraya',
+ 'Tanah Laut',
+ 'Kotabaru',
+ 'Banjar',
+ 'Barito Kuala',
+ 'Tapin',
+ 'Hulu Sungai Selatan',
+ 'Hulu Sungai Tengah',
+ 'Hulu Sungai Utara',
+ 'Tabalong',
+ 'Tanah Bumbu',
+ 'Balangan',
+ 'Kota Banjarmasin',
+ 'Kota Banjarbaru',
+ 'Paser',
+ 'Kutai Kartanegara',
+ 'Berau',
+ 'Kutai Barat',
+ 'Kutai Timur',
+ 'Penajam Paser Utara',
+ 'Mahakam Ulu',
+ 'Kota Balikpapan',
+ 'Kota Samarinda',
+ 'Kota Bontang',
+ 'Bulungan',
+ 'Malinau',
+ 'Nunukan',
+ 'Tana Tidung',
+ 'Kota Tarakan',
+ 'Bolaang Mongondow',
+ 'Minahasa',
+ 'Kepulauan Sangihe',
+ 'Kepulauan Talaud',
+ 'Minahasa Selatan',
+ 'Minahasa Utara',
+ 'Minahasa Tenggara',
+ 'Bolaang Mongondow Utara',
+ 'Kepulauan Siau Tagulandang Biaro (Sitaro)',
+ 'Bolaang Mongondow Timur',
+ 'Bolaang Mongondow Selatan',
+ 'Kota Manado',
+ 'Kota Bitung',
+ 'Kota Tomohon',
+ 'Kota Kotamobagu',
+ 'Banggai',
+ 'Poso',
+ 'Donggala',
+ 'Toli Toli',
+ 'Buol',
+ 'Morowali',
+ 'Banggai Kepulauan',
+ 'Parigi Moutong',
+ 'Tojo Una Una',
+ 'Sigi',
+ 'Banggai Laut',
+ 'Morowali Utara',
+ 'Kota Palu',
+ 'Kepulauan Selayar',
+ 'Bulukumba',
+ 'Bantaeng',
+ 'Jeneponto',
+ 'Takalar',
+ 'Gowa',
+ 'Sinjai',
+ 'Bone',
+ 'Maros',
+ 'Pangkajene Kepulauan',
+ 'Barru',
+ 'Soppeng',
+ 'Wajo',
+ 'Sidenreng Rappang',
+ 'Pinrang',
+ 'Enrekang',
+ 'Luwu',
+ 'Tana Toraja',
+ 'Luwu Utara',
+ 'Luwu Timur',
+ 'Toraja Utara',
+ 'Kota Makassar',
+ 'Kota Pare Pare',
+ 'Kota Palopo',
+ 'Kolaka',
+ 'Konawe',
+ 'Muna',
+ 'Buton',
+ 'Konawe Selatan',
+ 'Bombana',
+ 'Wakatobi',
+ 'Kolaka Utara',
+ 'Konawe Utara',
+ 'Buton Utara',
+ 'Kolaka Timur',
+ 'Konawe Kepulauan',
+ 'Muna Barat',
+ 'Buton Tengah',
+ 'Buton Selatan',
+ 'Kota Kendari',
+ 'Kota Bau Bau',
+ 'Gorontalo',
+ 'Boalemo',
+ 'Bone Bolango',
+ 'Pahuwato',
+ 'Gorontalo Utara',
+ 'Kota Gorontalo',
+ 'Pasangkayu (Mamuju Utara)',
+ 'Mamuju',
+ 'Mamasa',
+ 'Polewali Mandar',
+ 'Majene',
+ 'Mamuju Tengah',
+ 'Maluku Tengah',
+ 'Maluku Tenggara',
+ 'Kepulauan Tanimbar (Maluku Tenggara Barat)',
+ 'Buru',
+ 'Seram Bagian Timur',
+ 'Seram Bagian Barat',
+ 'Kepulauan Aru',
+ 'Maluku Barat Daya',
+ 'Buru Selatan',
+ 'Kota Ambon',
+ 'Kota Tual',
+ 'Halmahera Barat',
+ 'Halmahera Tengah',
+ 'Halmahera Utara',
+ 'Halmahera Selatan',
+ 'Kepulauan Sula',
+ 'Halmahera Timur',
+ 'Pulau Morotai',
+ 'Pulau Taliabu',
+ 'Kota Ternate',
+ 'Kota Tidore Kepulauan',
+ 'Jayapura',
+ 'Kepulauan Yapen',
+ 'Biak Numfor',
+ 'Sarmi',
+ 'Keerom',
+ 'Waropen',
+ 'Supiori',
+ 'Mamberamo Raya',
+ 'Kota Jayapura',
+ 'Manokwari',
+ 'Fak Fak',
+ 'Teluk Bintuni',
+ 'Teluk Wondama',
+ 'Kaimana',
+ 'Manokwari Selatan',
+ 'Pegunungan Arfak',
+ 'Merauke',
+ 'Boven Digoel',
+ 'Mappi',
+ 'Asmat',
+ 'Nabire',
+ 'Puncak Jaya',
+ 'Paniai',
+ 'Mimika',
+ 'Puncak',
+ 'Dogiyai',
+ 'Intan Jaya',
+ 'Deiyai',
+ 'Jayawijaya',
+ 'Pegunungan Bintang',
+ 'Yahukimo',
+ 'Tolikara',
+ 'Mamberamo Tengah',
+ 'Yalimo',
+ 'Lanny Jaya',
+ 'Nduga',
+ 'Sorong',
+ 'Sorong Selatan',
+ 'Raja Ampat',
+ 'Tambrauw',
+ 'Maybrat',
+ 'Kota Sorong',
+];
+
+export default INDONESIAN_CITIES;
diff --git a/apps/backoffice/src/middleware.ts b/apps/backoffice/src/middleware.ts
index a1760a2..7a97b12 100644
--- a/apps/backoffice/src/middleware.ts
+++ b/apps/backoffice/src/middleware.ts
@@ -84,11 +84,11 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => {
session?.role?.permissions?.map?.((perm) => perm?.name) ?? [];
if (mappingPublicRoutes.includes(pathname)) {
- if (token) return redirect('/dashboard');
+ if (token) return redirect('/hackathon-dashboard');
return null;
}
- // if (!session) return redirect('/auth/login');
+ if (!session) return redirect('/auth/login');
const matchedRoute = mappingRoutePermissions.find(
(route) => route.path === pathname
@@ -100,7 +100,7 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => {
matchedRoute.permissions.some((perm) => userPermissions.includes(perm));
if (!hasPermission) {
- return '/dashboard';
+ return '/hackathon-dashboard';
}
}
diff --git a/apps/backoffice/vite.config.ts b/apps/backoffice/vite.config.ts
index 260280d..f57edc5 100644
--- a/apps/backoffice/vite.config.ts
+++ b/apps/backoffice/vite.config.ts
@@ -8,7 +8,7 @@ export default defineConfig(() => ({
root: __dirname,
cacheDir: '../../node_modules/.vite/apps/backoffice',
server: {
- port: 3000,
+ port: 3003,
host: 'localhost',
},
preview: {
diff --git a/libs/service/src/api/admin/index.ts b/libs/service/src/api/admin/index.ts
new file mode 100644
index 0000000..ada6b81
--- /dev/null
+++ b/libs/service/src/api/admin/index.ts
@@ -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(
+ `${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(
+ `${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(
+ `${ADMIN_BASE_URL}/submissions`,
+ { params }
+ );
+ return response.data;
+};
diff --git a/libs/service/src/api/backoffice.ts b/libs/service/src/api/backoffice.ts
new file mode 100644
index 0000000..2736a93
--- /dev/null
+++ b/libs/service/src/api/backoffice.ts
@@ -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 {
+ data: T;
+ message: string;
+}
diff --git a/libs/service/src/api/index.ts b/libs/service/src/api/index.ts
index b6f6c8c..b6c0c2d 100644
--- a/libs/service/src/api/index.ts
+++ b/libs/service/src/api/index.ts
@@ -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 {
@@ -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 = () => {
diff --git a/libs/service/src/hooks/auth/index.ts b/libs/service/src/hooks/auth/index.ts
index 2d41e37..a7fcb5e 100644
--- a/libs/service/src/hooks/auth/index.ts
+++ b/libs/service/src/hooks/auth/index.ts
@@ -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>(
- '/auth/login',
- data
- );
+ const response = await hackathonApi.post<
+ HackathonApiResponse
+ >('/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>(
- '/auth/signup',
- data
- );
+ const response = await hackathonApi.post<
+ HackathonApiResponse
+ >('/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>(
- '/auth/github',
- data
- );
+ const response = await hackathonApi.post<
+ HackathonApiResponse
+ >('/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>(
- '/auth/session'
- );
+ const response = await hackathonApi.get<
+ HackathonApiResponse
+ >('/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>(
- '/auth/forgot-password',
- data
- );
+ const response = await hackathonApi.post<
+ HackathonApiResponse
+ >('/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>(
- '/auth/reset-password',
- data
- );
+ const response = await hackathonApi.post<
+ HackathonApiResponse
+ >('/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
+ >('/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>(
- '/auth/login',
- data
- );
+ const response = await hackathonApi.post<
+ HackathonApiResponse
+ >('/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>(
- '/auth/signup',
- data
- );
+ const response = await hackathonApi.post<
+ HackathonApiResponse
+ >('/auth/signup', data);
return { data: response.data.data };
},
});
diff --git a/libs/service/src/types/admin/index.ts b/libs/service/src/types/admin/index.ts
new file mode 100644
index 0000000..7b762fd
--- /dev/null
+++ b/libs/service/src/types/admin/index.ts
@@ -0,0 +1,64 @@
+export type TAdminMetaResponse = {
+ page: number;
+ per_page: number;
+ total_data: number;
+ total_page: number;
+};
+
+export type TAdminListResponse = {
+ 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;
+
+// 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;
+
+// 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;
diff --git a/libs/service/src/types/index.ts b/libs/service/src/types/index.ts
index fb6c790..fa54611 100644
--- a/libs/service/src/types/index.ts
+++ b/libs/service/src/types/index.ts
@@ -5,3 +5,4 @@ export * from './roles';
export * from './permissions';
export * from './mentors';
export * from './teams';
+export * from './admin';
diff --git a/libs/ui/src/organisms/datatable/datatable.tsx b/libs/ui/src/organisms/datatable/datatable.tsx
index 0338398..bbc5866 100644
--- a/libs/ui/src/organisms/datatable/datatable.tsx
+++ b/libs/ui/src/organisms/datatable/datatable.tsx
@@ -23,6 +23,11 @@ interface DataTableProps {
columns?: ColumnDef[];
pageSize?: number;
className?: string;
+ // server-side pagination props
+ manualPagination?: boolean;
+ pageCount?: number;
+ currentPage?: number;
+ onPageChange?: (page: number) => void;
}
export const DataTable = ({
@@ -31,6 +36,10 @@ export const DataTable = ({
columns = [],
pageSize = 9,
className,
+ manualPagination = false,
+ pageCount,
+ currentPage = 1,
+ onPageChange,
}: DataTableProps) => {
const [pagination, setPagination] = React.useState({
pageIndex: 0,
@@ -75,10 +84,20 @@ export const DataTable = ({
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 = ({
-
+ {manualPagination && onPageChange && pageCount ? (
+ // Server-side pagination controls with numbered pages
+
+
onPageChange(currentPage - 1)}
+ disabled={currentPage === 1}
+ aria-label="Previous page"
+ >
+
+
+
+
+
+
+ {pageCount <= 8 ? (
+ // Show all pages if 8 or fewer
+ Array.from({ length: pageCount }, (_, index) => (
+ onPageChange(index + 1)}
+ >
+ {index + 1}
+
+ ))
+ ) : (
+ // Show ellipsis for many pages
+ <>
+ 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
+
+ {currentPage > 3 && ... }
+ {Array.from(
+ { length: 5 },
+ (_, index) => currentPage - 2 + index
+ )
+ .filter((page) => page > 1 && page < pageCount)
+ .map((page) => (
+ 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}
+
+ ))}
+ {currentPage < pageCount - 2 && ... }
+ 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}
+
+ >
+ )}
+
+
+
onPageChange(currentPage + 1)}
+ disabled={currentPage === pageCount}
+ aria-label="Next page"
+ >
+
+
+
+
+
+ ) : (
+ // Client-side pagination (default)
+
+ )}
);
};