Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions pages/admin/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import Head from 'next/head'
import React from 'react'
import { useAdminDashboard } from 'src/admin/hooks'
import { AdminLayout, TabFilterBar } from 'src/admin/components/AdminLayout'
import { AdminLoginPage } from 'src/admin/components/AdminLoginPage'
import { ClubsTab } from 'src/admin/components/ClubsTab'
import { HistoriesTab } from 'src/admin/components/HistoriesTab'
import { ManagerRequestsTab } from 'src/admin/components/ManagerRequestsTab'
import { VerificationRequestsTab } from 'src/admin/components/VerificationRequestsTab'

const AdminDashboardPage = () => {
const {
authReady,
authToken,
activeTab,
setActiveTab,
statusFilter,
setStatusFilter,
totalCount,
handleLogin,
handleLogout,
clubs,
managerRequests,
verificationRequests,
histories,
} = useAdminDashboard()

if (!authReady) {
return (
<>
<Head>
<title>올클 운영진 로그인</title>
</Head>
<main className="flex min-h-screen items-center justify-center bg-slate-50 px-4">
<div className="h-28 w-full max-w-md animate-pulse rounded-md border border-slate-200 bg-white" />
</main>
</>
)
}

if (!authToken) {
return <AdminLoginPage onLogin={handleLogin} />
}

return (
<>
<Head>
<title>올클 운영진 대시보드</title>
</Head>
<AdminLayout
activeTab={activeTab}
onTabChange={setActiveTab}
totalCount={totalCount}
statusFilter={statusFilter}
onLogout={handleLogout}
>
<TabFilterBar
activeTab={activeTab}
statusFilter={statusFilter}
onStatusChange={setStatusFilter}
/>
{activeTab === 'clubs' && (
<ClubsTab
clubs={clubs.data}
isLoading={clubs.isLoading}
error={clubs.error}
isMutating={clubs.isMutating}
onDecide={clubs.onDecide}
/>
)}
{activeTab === 'managerRequests' && (
<ManagerRequestsTab
requests={managerRequests.data}
isLoading={managerRequests.isLoading}
error={managerRequests.error}
isMutating={managerRequests.isMutating}
onDecide={managerRequests.onDecide}
/>
)}
{activeTab === 'verificationRequests' && (
<VerificationRequestsTab
requests={verificationRequests.data}
isLoading={verificationRequests.isLoading}
error={verificationRequests.error}
isMutating={verificationRequests.isMutating}
onDecide={verificationRequests.onDecide}
/>
)}
{activeTab === 'histories' && (
<HistoriesTab
histories={histories.data}
isLoading={histories.isLoading}
error={histories.error}
onSearch={histories.onSearch}
/>
)}
</AdminLayout>
</>
)
}

export default AdminDashboardPage
99 changes: 99 additions & 0 deletions src/admin/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import type {
AdminClubsResponse,
AdminClubDetailResponse,
AdminClubManagerRequestsResponse,
AdminClubVerificationRequestsResponse,
AdminClubHistoriesResponse,
} from 'src/lib/schemas/admin'
import { ADMIN_AUTH_TOKEN_KEY, buildQuery } from 'src/admin/constants'
import type { DecisionStatus, StatusFilter } from 'src/admin/types'

type FetchOptions = NonNullable<Parameters<typeof fetch>[1]>

export const request = async <T>(url: string, init?: FetchOptions): Promise<T> => {
const token =
typeof window === 'undefined' ? null : window.localStorage.getItem(ADMIN_AUTH_TOKEN_KEY)
const response = await fetch(url, {
...init,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...init?.headers,
},
})

if (!response.ok) {
const message = await response.text()
throw new Error(message || `Request failed with ${response.status}`)
}

return response.json() as Promise<T>
}

export const fetchClubs = (status: StatusFilter) =>
request<AdminClubsResponse>(
`/api/v1/admin/clubs${buildQuery({ status: status === 'ALL' ? undefined : status })}`,
)

export const fetchClubDetail = (uuid: string) =>
request<AdminClubDetailResponse>(`/api/v1/admin/clubs/${uuid}`)

export const fetchManagerRequests = (status: StatusFilter) =>
request<AdminClubManagerRequestsResponse>(
`/api/v1/admin/clubs/manager-requests${buildQuery({
status: status === 'ALL' ? undefined : status,
})}`,
)

export const fetchVerificationRequests = (status: StatusFilter) =>
request<AdminClubVerificationRequestsResponse>(
`/api/v1/admin/clubs/verifications${buildQuery({
status: status === 'ALL' ? undefined : status,
})}`,
)

export const fetchHistories = (query: string) =>
request<AdminClubHistoriesResponse>(
`/api/v1/admin/clubs/histories${buildQuery({ query, limit: 30 })}`,
)

export const updateClubStatus = (payload: {
uuid: string
status: DecisionStatus
reject_reason?: string
is_official_verified: boolean
}) =>
request(`/api/v1/admin/clubs/${payload.uuid}/status`, {
method: 'PATCH',
body: JSON.stringify({
status: payload.status,
reject_reason: payload.reject_reason,
is_official_verified: payload.is_official_verified,
}),
})

export const updateManagerRequestStatus = (payload: {
id: number
status: DecisionStatus
reject_reason?: string
}) =>
request(`/api/v1/admin/clubs/manager-requests/${payload.id}/status`, {
method: 'PATCH',
body: JSON.stringify({
status: payload.status,
reject_reason: payload.reject_reason,
}),
})

export const updateVerificationStatus = (payload: {
id: number
status: DecisionStatus
reject_reason?: string
}) =>
request(`/api/v1/admin/clubs/verifications/${payload.id}/status`, {
method: 'PATCH',
body: JSON.stringify({
status: payload.status,
reject_reason: payload.reject_reason,
}),
})
115 changes: 115 additions & 0 deletions src/admin/components/AdminLayout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import React from 'react'
import { TABS } from 'src/admin/constants'
import type { AdminTab, StatusFilter } from 'src/admin/types'
import { StatusBadge, StatusFilterBar } from './ui'

export const AdminLayout = ({
activeTab,
onTabChange,
totalCount,
statusFilter,
onLogout,
children,
}: {
activeTab: AdminTab
onTabChange: (tab: AdminTab) => void
totalCount: number
statusFilter: StatusFilter
onLogout: () => void
children: React.ReactNode
}) => (
<main className="min-h-screen bg-slate-50 text-slate-950">
<div className="mx-auto flex w-full max-w-7xl gap-6 px-4 py-5 lg:px-6">
<aside className="sticky top-5 hidden h-[calc(100vh-40px)] w-60 shrink-0 flex-col justify-between border-r border-slate-200 pr-5 lg:flex">
<div>
<div className="mb-8">
<p className="text-sm font-semibold text-primary-700">Allclear Admin</p>
<h1 className="mt-2 text-2xl font-bold tracking-normal">운영진 대시보드</h1>
</div>
<nav className="space-y-1">
{TABS.map((tab) => (
<button
key={tab.value}
type="button"
onClick={() => onTabChange(tab.value)}
className={`w-full rounded-md px-3 py-2 text-left text-sm font-semibold transition ${
activeTab === tab.value
? 'bg-slate-950 text-white'
: 'text-slate-600 hover:bg-white hover:text-slate-950'
}`}
>
{tab.label}
</button>
))}
</nav>
</div>
<p className="text-xs leading-5 text-slate-500">
API 응답 권한은 서버의 운영진 인증 정책을 그대로 따릅니다.
</p>
</aside>

<section className="min-w-0 flex-1">
<div className="mb-5 lg:hidden">
<p className="text-sm font-semibold text-primary-700">Allclear Admin</p>
<h1 className="mt-2 text-2xl font-bold">운영진 대시보드</h1>
</div>

<div className="mb-5 overflow-x-auto rounded-md border border-slate-200 bg-white p-1 lg:hidden">
<div className="flex min-w-max gap-1">
{TABS.map((tab) => (
<button
key={tab.value}
type="button"
onClick={() => onTabChange(tab.value)}
className={`rounded px-3 py-2 text-sm font-semibold ${
activeTab === tab.value
? 'bg-slate-950 text-white'
: 'text-slate-600 hover:bg-slate-100'
}`}
>
{tab.label}
</button>
))}
</div>
</div>

<header className="mb-5 flex flex-col gap-4 border-b border-slate-200 pb-5 md:flex-row md:items-end md:justify-between">
<div>
<p className="text-sm font-medium text-slate-500">현재 탭</p>
<h2 className="mt-1 text-3xl font-bold">
{TABS.find((tab) => tab.value === activeTab)?.label}
</h2>
</div>
<div className="flex items-center gap-3">
<StatusBadge status={statusFilter === 'ALL' ? undefined : statusFilter} />
<span className="rounded-md border border-slate-200 bg-white px-3 py-2 text-sm font-semibold">
총 {totalCount.toLocaleString()}건
</span>
<button
type="button"
onClick={onLogout}
className="rounded-md border border-slate-300 bg-white px-3 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-100"
>
로그아웃
</button>
</div>
</header>

{children}
</section>
</div>
</main>
)

export const TabFilterBar = ({
activeTab,
statusFilter,
onStatusChange,
}: {
activeTab: AdminTab
statusFilter: StatusFilter
onStatusChange: (status: StatusFilter) => void
}) => {
if (activeTab === 'histories') return null
return <StatusFilterBar value={statusFilter} onChange={onStatusChange} />
}
Loading
Loading