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

This commit is contained in:
Hafid Nur
2025-11-30 18:54:34 +07:00
parent 5faba43728
commit 1343404e01
8 changed files with 200 additions and 360 deletions
@@ -1,7 +1,4 @@
import { FC, ReactElement, useState } from 'react';
import ModalUserDetail from '../../../components/modal-user-detail';
import ModalSuspendOrBan from '../../../components/modal-suspend-or-ban';
import ModalDeleteUser from '../../../components/modal-delete-user';
import {
BackofficeWrapper,
DataTable,
@@ -16,13 +13,9 @@ import {
} from '@tanstack/react-table';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { cn } from '@imphnen-frontend-service/utils';
import { SearchOutlined } from '@ant-design/icons';
import { EditOutlined } from '@ant-design/icons';
export const HackathonUsersPage: FC = (): ReactElement => {
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showDetailModal, setShowDetailModal] = useState(false);
const [showSuspendModal, setShowSuspendModal] = useState(false);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
@@ -31,80 +24,38 @@ export const HackathonUsersPage: FC = (): ReactElement => {
const mockData: any[] = Array.from({ length: 90 }, (_, i) => ({
id: i + 1,
name: i % 3 === 0 ? 'Ahmad Wijuana' : 'Sofia Wijuana',
email: 'fullname23@gmail.com',
rating: 4.5,
status: i % 2 === 0 ? 'active' : 'inactive',
project_name: `Project ${i + 1}`,
repository_url: `https://github.com/user/repo${i + 1}`,
demo_url: `https://demo.example.com/project${i + 1}`,
presentation_url: `https://slides.example.com/project${i + 1}`,
}));
type UserStatus = 'active' | 'inactive';
interface UserType {
interface SubmissionType {
id: number;
name: string;
email: string;
rating: number;
status: UserStatus;
project_name: string;
repository_url: string;
demo_url: string;
presentation_url: string;
}
const columns: ColumnDef<UserType>[] = [
const columns: ColumnDef<SubmissionType>[] = [
{
id: 'select',
meta: { cellClassName: cn('w-20') },
header: ({ table }) => (
<input
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
header: 'Project Name',
accessorKey: 'project_name',
},
{
id: 'name',
header: 'Name',
accessorKey: 'name',
header: 'Repository URL',
accessorKey: 'repository_url',
},
{
id: 'email',
header: 'Email',
accessorKey: 'email',
header: 'Demo URL',
accessorKey: 'demo_url',
},
{
id: 'rating',
header: 'Rating',
accessorKey: 'rating',
},
{
id: 'status',
header: 'Status',
accessorKey: 'status',
cell: ({ row }) => {
const status = row.original.status;
const statusColors: Record<UserStatus, string> = {
active: 'bg-success-200 text-success-500',
inactive: 'bg-danger-200 text-danger-500',
};
const statusText: Record<UserStatus, string> = {
active: 'Active',
inactive: 'Inactive',
};
return (
<div
className={`py-2 px-4 rounded-md text-center ${statusColors[status]}`}
>
{statusText[status]}
</div>
);
},
header: 'Presentation URL',
accessorKey: 'presentation_url',
},
{
header: 'Action',
@@ -113,17 +64,12 @@ export const HackathonUsersPage: FC = (): ReactElement => {
<Button
variant="primary"
size="sm"
onClick={
(e) => {}
// {
// e.stopPropagation();
// setSelectedUserId(row.original.id);
// setShowDetail(true);
// }
}
className="flex items-center gap-2 w-max"
onClick={() => {
// View detail logic
}}
>
<SearchOutlined className="text-[16px]" /> Lihat Detail & Action
<EditOutlined className="text-base" /> View & Manage
</Button>
),
},
@@ -175,18 +121,6 @@ export const HackathonUsersPage: FC = (): ReactElement => {
</section>
{/* Modals extracted into shared backoffice components */}
<ModalUserDetail
isOpen={showDetailModal}
onClose={() => setShowDetailModal(false)}
/>
<ModalSuspendOrBan
isOpen={showSuspendModal}
onClose={() => setShowSuspendModal(false)}
/>
<ModalDeleteUser
isOpen={showDeleteModal}
onClose={() => setShowDeleteModal(false)}
/>
</BackofficeWrapper>
);
};
@@ -1,7 +1,4 @@
import { FC, ReactElement, useState } from 'react';
import ModalUserDetail from '../../../components/modal-user-detail';
import ModalSuspendOrBan from '../../../components/modal-suspend-or-ban';
import ModalDeleteUser from '../../../components/modal-delete-user';
import {
BackofficeWrapper,
DataTable,
@@ -16,11 +13,11 @@ import {
} 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';
export const HackathonTeamsPage: FC = (): ReactElement => {
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showDetailModal, setShowDetailModal] = useState(false);
const [showSuspendModal, setShowSuspendModal] = useState(false);
const { data: teamsData } = useTeams();
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [pagination, setPagination] = useState<PaginationState>({
@@ -64,34 +61,12 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
const columns: ColumnDef<TeamType>[] = [
{
id: 'select',
meta: { cellClassName: cn('w-12') },
header: ({ table }) => (
<input
type="checkbox"
className="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
accessorKey: 'id',
header: 'ID',
},
{
accessorKey: 'name',
header: 'Team Name',
cell: ({ row }) => (
<div>
<div className="font-medium text-gray-900">{row.original.name}</div>
<div className="text-xs text-gray-500">{row.original.id}</div>
</div>
),
},
{
accessorKey: 'city',
@@ -105,10 +80,10 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
return (
<span
className={cn(
'px-2 py-1 rounded-full text-xs font-medium',
'py-2 px-4 text-sm rounded-2xl text-center',
isPublic
? 'bg-green-100 text-green-700'
: 'bg-gray-100 text-gray-700'
? 'bg-info-200 text-info-700'
: 'bg-gray-200 text-gray-700'
)}
>
{isPublic ? 'Public' : 'Private'}
@@ -119,12 +94,6 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
{
accessorKey: 'member_count',
header: 'Members',
cell: ({ row }) => (
<div className="flex items-center gap-1">
<span className="font-medium">{row.getValue('member_count')}</span>
<span className="text-gray-500 text-xs">members</span>
</div>
),
},
{
id: 'leader',
@@ -139,22 +108,22 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
<div className="text-xs text-gray-500">{leader.email}</div>
</div>
) : (
<span className="text-gray-400 italic">No leader</span>
<span className="text-gray-400 italic">-</span>
);
},
},
{
accessorKey: 'has_submission',
header: 'Submission',
header: 'Submitted',
cell: ({ row }) => {
const hasSubmission = row.original.has_submission;
return (
<span
className={cn(
'px-2 py-1 rounded-full text-xs font-medium',
'py-2 px-4 text-sm rounded-2xl text-center',
hasSubmission
? 'bg-blue-100 text-blue-700'
: 'bg-orange-100 text-orange-700'
? 'bg-success-200 text-success-700'
: 'bg-danger-200 text-danger-700'
)}
>
{hasSubmission ? 'Yes' : 'No'}
@@ -166,11 +135,7 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
accessorKey: 'updated_at',
header: 'Last Updated',
cell: ({ row }) => {
return (
<span className="text-sm text-gray-500">
{new Date(row.original.updated_at).toLocaleDateString()}
</span>
);
return new Date(row.original.updated_at).toLocaleDateString();
},
},
{
@@ -180,25 +145,14 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Button
variant="text"
variant="primary"
size="sm"
className="text-primary-600 hover:text-primary-700 p-0"
className="flex items-center gap-2 w-max"
onClick={() => {
// View detail logic
}}
>
View
</Button>
<span className="text-gray-300">|</span>
<Button
variant="text"
size="sm"
className="text-gray-600 hover:text-gray-700 p-0"
onClick={() => {
// Manage logic
}}
>
Manage
<EditOutlined className="text-base" /> View & Manage
</Button>
</div>
),
@@ -249,18 +203,6 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
<DataTable data={mockData} columns={columns} table={table} />
</section>
{/* Modals extracted into shared backoffice components */}
<ModalUserDetail
isOpen={showDetailModal}
onClose={() => setShowDetailModal(false)}
/>
<ModalSuspendOrBan
isOpen={showSuspendModal}
onClose={() => setShowSuspendModal(false)}
/>
<ModalDeleteUser
isOpen={showDeleteModal}
onClose={() => setShowDeleteModal(false)}
/>
</BackofficeWrapper>
);
};
@@ -14,7 +14,7 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose }) => {
<div className="bg-white rounded-lg shadow w-full max-w-3xl">
<div className="border-b px-6 py-4 flex justify-between items-center">
<h2 className="text-p3 font-semibold">User Detail</h2>
<button className="p-2" onClick={onClose}>
<button className="p-2 cursor-pointer" onClick={onClose}>
</button>
</div>
@@ -1,7 +1,5 @@
import { FC, ReactElement, useState } from 'react';
import ModalUserDetail from '../../../components/modal-user-detail';
import ModalSuspendOrBan from '../../../components/modal-suspend-or-ban';
import ModalDeleteUser from '../../../components/modal-delete-user';
import ModalUserDetail from './_components/modal-user-detail';
import {
BackofficeWrapper,
DataTable,
@@ -16,12 +14,11 @@ import {
} from '@tanstack/react-table';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { cn } from '@imphnen-frontend-service/utils';
import { SearchOutlined } from '@ant-design/icons';
import { EditOutlined } from '@ant-design/icons';
// Removed unused SearchOutlined icon after schema revision
export const HackathonUsersPage: FC = (): ReactElement => {
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showDetailModal, setShowDetailModal] = useState(false);
const [showSuspendModal, setShowSuspendModal] = useState(false);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [pagination, setPagination] = useState<PaginationState>({
@@ -29,82 +26,68 @@ export const HackathonUsersPage: FC = (): ReactElement => {
pageSize: 9,
});
const mockData: any[] = Array.from({ length: 90 }, (_, i) => ({
id: i + 1,
name: i % 3 === 0 ? 'Ahmad Wijuana' : 'Sofia Wijuana',
email: 'fullname23@gmail.com',
rating: 4.5,
status: i % 2 === 0 ? 'active' : 'inactive',
}));
type UserStatus = 'active' | 'inactive';
// Mock data aligned to API user schema
interface UserType {
id: number;
name: string;
id: string;
fullname: string;
email: string;
rating: number;
status: UserStatus;
is_active: boolean;
location: string;
created_at: string;
updated_at: string;
avatar?: string;
phone_number?: string;
}
const mockData: UserType[] = Array.from({ length: 50 }, (_, i) => ({
id: `24db9e4d-ca4c-46aa-ac36-8ef04bbe015f`,
fullname: i % 3 === 0 ? 'Ahmad Wijuana' : 'Sofia Wijuana',
email: `user${i + 1}@example.com`,
is_active: i % 5 !== 0,
location: i % 2 === 0 ? 'Jakarta' : 'Bandung',
created_at: new Date(Date.now() - i * 86400000).toISOString(),
updated_at: new Date().toISOString(),
avatar: undefined,
phone_number: '+62-812-0000-000',
}));
const columns: ColumnDef<UserType>[] = [
{
id: 'select',
meta: { cellClassName: cn('w-20') },
header: ({ table }) => (
<input
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
accessorKey: 'id',
header: 'ID',
},
{
id: 'name',
header: 'Name',
accessorKey: 'name',
accessorKey: 'fullname',
header: 'Full Name',
},
{
id: 'email',
header: 'Email',
accessorKey: 'email',
header: 'Email',
},
{
id: 'rating',
header: 'Rating',
accessorKey: 'rating',
accessorKey: 'location',
header: 'Location',
},
{
id: 'status',
accessorKey: 'is_active',
header: 'Status',
accessorKey: 'status',
cell: ({ row }) => {
const status = row.original.status;
const statusColors: Record<UserStatus, string> = {
active: 'bg-success-200 text-success-500',
inactive: 'bg-danger-200 text-danger-500',
};
const statusText: Record<UserStatus, string> = {
active: 'Active',
inactive: 'Inactive',
};
return (
cell: ({ row }) => (
<div
className={`py-2 px-4 rounded-md text-center ${statusColors[status]}`}
className={cn(
'py-2 px-4 text-sm rounded-2xl text-center',
row.original.is_active
? 'bg-success-200 text-success-700'
: 'bg-danger-200 text-danger-700'
)}
>
{statusText[status]}
{row.original.is_active ? 'Active' : 'Inactive'}
</div>
);
),
},
{
accessorKey: 'created_at',
header: 'Joined',
cell: ({ row }) => new Date(row.original.created_at).toLocaleDateString(),
},
{
header: 'Action',
@@ -113,17 +96,12 @@ export const HackathonUsersPage: FC = (): ReactElement => {
<Button
variant="primary"
size="sm"
onClick={
(e) => {}
// {
// e.stopPropagation();
// setSelectedUserId(row.original.id);
// setShowDetail(true);
// }
}
className="flex items-center gap-2 w-max"
onClick={() => {
// View detail logic
}}
>
<SearchOutlined className="text-[16px]" /> Lihat Detail & Action
<EditOutlined className="text-base" /> View & Manage
</Button>
),
},
@@ -161,10 +139,10 @@ export const HackathonUsersPage: FC = (): ReactElement => {
<select className="border border-neutral-200 rounded-md px-3 py-2 text-label1 w-full sm:w-40">
<option value="all">All Status</option>
<option value="active">Active</option>
<option value="suspended">Suspended</option>
<option value="inactive">Inactive</option>
</select>
<select className="border border-neutral-200 rounded-md px-3 py-2 text-label1 w-full sm:w-40">
<option value="all">All City</option>
<option value="all">All Location</option>
<option value="jakarta">Jakarta</option>
<option value="bandung">Bandung</option>
</select>
@@ -174,19 +152,11 @@ export const HackathonUsersPage: FC = (): ReactElement => {
<DataTable data={mockData} columns={columns} table={table} />
</section>
{/* Modals extracted into shared backoffice components */}
{/* Modals component */}
<ModalUserDetail
isOpen={showDetailModal}
onClose={() => setShowDetailModal(false)}
/>
<ModalSuspendOrBan
isOpen={showSuspendModal}
onClose={() => setShowSuspendModal(false)}
/>
<ModalDeleteUser
isOpen={showDeleteModal}
onClose={() => setShowDeleteModal(false)}
/>
</BackofficeWrapper>
);
};
@@ -1,41 +0,0 @@
import { FC } from 'react';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
}
const ModalDeleteUser: FC<ModalProps> = ({ isOpen, onClose }) => {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50">
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
<div className="fixed inset-0 flex items-center justify-center p-4">
<div className="bg-white rounded-lg shadow w-full max-w-md">
<div className="border-b px-6 py-4 flex justify-between items-center">
<h2 className="text-p3 font-semibold">Delete User</h2>
<button className="p-2" onClick={onClose}>
</button>
</div>
<div className="p-6">
<p className="text-label2 text-neutral-700">
Are you sure you want to delete this user? This action cannot be
undone.
</p>
<div className="flex justify-end gap-2 mt-4">
<button className="px-3 py-2 rounded-md border" onClick={onClose}>
Cancel
</button>
<button className="px-3 py-2 rounded-md bg-red-600 text-white">
Delete
</button>
</div>
</div>
</div>
</div>
</div>
);
};
export default ModalDeleteUser;
@@ -1,42 +0,0 @@
import { FC } from 'react';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
}
const ModalSuspendOrBan: FC<ModalProps> = ({ isOpen, onClose }) => {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50">
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
<div className="fixed inset-0 flex items-center justify-center p-4">
<div className="bg-white rounded-lg shadow w-full max-w-lg">
<div className="border-b px-6 py-4 flex justify-between items-center">
<h2 className="text-p3 font-semibold">Suspend or Ban User</h2>
<button className="p-2" onClick={onClose}>
</button>
</div>
<div className="p-6 flex flex-col gap-4">
<label className="text-label2 text-neutral-700">Reason</label>
<textarea
className="border border-neutral-200 rounded-md px-3 py-2"
rows={4}
/>
<div className="flex justify-end gap-2 mt-2">
<button className="px-3 py-2 rounded-md border" onClick={onClose}>
Cancel
</button>
<button className="px-3 py-2 rounded-md bg-amber-600 text-white">
Suspend
</button>
</div>
</div>
</div>
</div>
</div>
);
};
export default ModalSuspendOrBan;
@@ -0,0 +1,58 @@
# Hackathon Backoffice API Contract
## GET /api/v1/dashboard
### Purpose
Single endpoint delivering aggregated metrics for the IMPHNEN x Kolosal.ai Hackathon dashboard.
### Authentication & Authorization
- Requires admin (backoffice) scope: e.g. `role=admin`
- 401 if unauthenticated, 403 if authenticated but lacking required scope.
### Examples
```
GET /api/v1/hackathon/dashboard
```
### Response Schema
```jsonc
{
"data": {
"total_participants": 1261,
"total_teams": 206,
"total_submissions": 0 // Total project submitted
}
}
```
### Field Types
| Path | Type | Notes |
| ------------------------- | ------- | --------------------- |
| `data.total_participants` | integer | >= 0 |
| `data.total_teams` | integer | >= 0 |
| `data.total_submissions` | integer | <= `data.total_teams` |
### Errors
| Status | Code | Message | Notes |
| ------ | ---------------- | ----------------------------- | --------------------- |
| 401 | `unauthorized` | `authentication required` | Missing/invalid token |
| 403 | `forbidden` | `insufficient permissions` | Lacks required scope |
| 429 | `rate_limited` | `too many dashboard requests` | Rate limiting |
| 500 | `internal_error` | `unexpected server error` | Unhandled exception |
## Rate Limiting
- Suggested: 30 requests / minute / admin user.
- Return 429 with `Retry-After` header.
---
**Revision History**
- v1.0.0 (2025-11-30): Initial contract drafted.
@@ -1,15 +1,16 @@
import { cn } from "@imphnen-frontend-service/utils"
import { Icon } from '@iconify/react'
import { FC, ReactElement, ReactNode } from "react"
import { Button } from "../../atoms"
import { cn } from '@imphnen-frontend-service/utils';
import { Icon } from '@iconify/react';
import { FC, ReactElement, ReactNode } from 'react';
import { Button } from '../../atoms';
import { useAuthStore } from '@imphnen-frontend-service/service';
export type TBackofficeWrapperProps = {
children: ReactNode
title?: string
className?: string
classHeader?: string
classTitle?: string
}
children: ReactNode;
title?: string;
className?: string;
classHeader?: string;
classTitle?: string;
};
export const BackofficeWrapper: FC<TBackofficeWrapperProps> = ({
children,
@@ -18,34 +19,52 @@ export const BackofficeWrapper: FC<TBackofficeWrapperProps> = ({
classHeader,
classTitle,
}): ReactElement => {
const { session } = useAuthStore();
const user = session?.user;
return (
<main className={cn("w-full px-[48px] py-[40px] flex flex-col gap-8", className)}>
<header className={cn("bg-white py-5 px-7 rounded-md shadow flex items-center justify-between", classHeader)}>
<h1 className={cn("text-[19px] text-primary-500 font-semibold", classTitle)}>{title}</h1>
<main
className={cn(
'w-full px-[48px] py-[40px] flex flex-col gap-8',
className
)}
>
<header
className={cn(
'bg-white py-5 px-7 rounded-md shadow flex items-center justify-between',
classHeader
)}
>
<h1
className={cn(
'text-[19px] text-primary-500 font-semibold',
classTitle
)}
>
{title}
</h1>
<div className="flex items-center gap-x-6">
<Button
type="button"
variant="secondary"
className="max-h-full p-3"
>
<Button type="button" variant="secondary" className="max-h-full p-3">
<Icon icon="mdi:bell-outline" className="size-6" />
</Button>
<div className="flex items-center gap-x-6">
<div className="text-neutral-600 font-medium">
<p className="text-p3">Rizal Syaepulloh</p>
<p className="text-label1">Super Admin</p>
<p className="text-p3">{user?.fullname || 'Full Name'}</p>
<p className="text-label1">Admin</p>
</div>
<div className="size-12 rounded-full overflow-hidden">
<img src="/images/asd687hwq6nds4dfjj2983.webp" alt="Profile" className="size-full object-cover" />
<img
src={user?.avatar || '/images/asd687hwq6nds4dfjj2983.webp'}
alt="Profile"
className="size-full object-cover"
/>
</div>
</div>
</div>
</header>
<section>
{children}
</section>
<section>{children}</section>
</main>
)
}
);
};