From fbe4caa9e1d248ea46853e5ec762e4b6954e3b87 Mon Sep 17 00:00:00 2001 From: jisangs Date: Wed, 10 Jun 2026 22:22:45 +0900 Subject: [PATCH] =?UTF-8?q?Phase=204.=20=EB=A7=88=EC=97=90=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A1=9C=20=EB=AA=A9=EB=A1=9D=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(admin)/maestros/[maestroId]/page.tsx | 273 ++++++++++++++++ app/(admin)/maestros/page.tsx | 295 +++++++++++++++++ app/api/maestros/[id]/route.ts | 39 +++ app/api/maestros/route.ts | 17 + components/data-table/maestros-table.tsx | 208 ++++++++++++ components/forms/auto-submit-select.tsx | 20 ++ docs/plan/project-plan.md | 8 +- lib/maestros.ts | 370 ++++++++++++++++++++++ 8 files changed, 1226 insertions(+), 4 deletions(-) create mode 100644 app/(admin)/maestros/[maestroId]/page.tsx create mode 100644 app/(admin)/maestros/page.tsx create mode 100644 app/api/maestros/[id]/route.ts create mode 100644 app/api/maestros/route.ts create mode 100644 components/data-table/maestros-table.tsx create mode 100644 components/forms/auto-submit-select.tsx create mode 100644 lib/maestros.ts diff --git a/app/(admin)/maestros/[maestroId]/page.tsx b/app/(admin)/maestros/[maestroId]/page.tsx new file mode 100644 index 0000000..91fc4a2 --- /dev/null +++ b/app/(admin)/maestros/[maestroId]/page.tsx @@ -0,0 +1,273 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { ArrowLeft } from "lucide-react"; + +import { buttonVariants } from "@/components/ui/button"; +import { getMaestroDetail } from "@/lib/maestros"; +import { + formatDate, + formatDateTime, + getAccountTypeLabel, + getActivateStatusLabel, + getRequestStatusLabel, +} from "@/lib/utils"; + +type MaestroDetailPageProps = { + params: Promise<{ + maestroId: string; + }>; +}; + +export default async function MaestroDetailPage({ + params, +}: MaestroDetailPageProps) { + const { maestroId } = await params; + const maestroID = Number(maestroId); + + if (!Number.isInteger(maestroID) || maestroID <= 0) { + notFound(); + } + + const detail = await getMaestroDetail(maestroID); + + if (!detail) { + notFound(); + } + + const { maestro } = detail; + + return ( +
+
+
+ +
+
+ +
+ + + + +
+ +
+

기본 정보

+
+ + + + + + + + + + +
+
+ +
+
+
+

학생 목록 요약

+

+ 전체 {detail.counts.players.toLocaleString()}명 중 최근 20명 +

+
+
+ [ + player.playerID, + player.name, + player.enterCode, + player.accountType ?? "-", + ])} + /> +
+ +
+
+

연장 신청 이력

+

+ 전체 {detail.counts.extensionRequests.toLocaleString()}건 중 최근 + 20건 +

+ [ + request.maestroExtensionID, + getAccountTypeLabel(request.accountType), + formatDateTime(request.requestedDateTime), + getRequestStatusLabel(request.status), + ])} + /> +
+ +
+

업그레이드 신청 이력

+

+ 전체 {detail.counts.upgradeRequests.toLocaleString()}건 중 최근 20건 +

+ [ + request.maestroUpgradeID, + getAccountTypeLabel(request.registeredAccountType), + getAccountTypeLabel(request.requestedAccountType), + formatDateTime(request.requestedDateTime), + getRequestStatusLabel(request.status), + ])} + /> +
+
+ +
+

처리 로그

+

+ 전체 {detail.counts.logs.toLocaleString()}건 중 최근 30건 +

+ [ + log.maestroLogID, + log.type, + formatDateTime(log.logDateTime), + log.remark, + ])} + /> +
+
+ ); +} + +function SummaryCard({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +function InfoItem({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function SimpleTable({ + headers, + rows, + emptyText, +}: { + headers: string[]; + rows: Array>; + emptyText: string; +}) { + return ( +
+
+ + + + {headers.map((header) => ( + + ))} + + + + {rows.length > 0 ? ( + rows.map((row, index) => ( + + {row.map((cell, cellIndex) => ( + + ))} + + )) + ) : ( + + + + )} + +
+ {header} +
+ {cell} +
+ {emptyText} +
+
+
+ ); +} diff --git a/app/(admin)/maestros/page.tsx b/app/(admin)/maestros/page.tsx new file mode 100644 index 0000000..6114d7f --- /dev/null +++ b/app/(admin)/maestros/page.tsx @@ -0,0 +1,295 @@ +import Link from "next/link"; +import { + ChevronLeft, + ChevronRight, + ChevronsLeft, + ChevronsRight, + Search, +} from "lucide-react"; + +import { Button, buttonVariants } from "@/components/ui/button"; +import { MaestrosTable } from "@/components/data-table/maestros-table"; +import { AutoSubmitSelect } from "@/components/forms/auto-submit-select"; +import { + ACCOUNT_TYPE_LABELS, + ACCOUNT_TYPES, + ACTIVATE_STATUS_LABELS, + ACTIVATE_STATUSES, +} from "@/lib/constants"; +import { getMaestros, type RawSearchParams } from "@/lib/maestros"; +import { cn } from "@/lib/utils"; + +type MaestrosPageProps = { + searchParams: Promise; +}; + +const activateStatusOptions = [ + ACTIVATE_STATUSES.PENDING, + ACTIVATE_STATUSES.TRIAL, + ACTIVATE_STATUSES.ACTIVE, + ACTIVATE_STATUSES.CANCELED, +] as const; + +const accountTypeOptions = [ + ACCOUNT_TYPES.BASIC_20, + ACCOUNT_TYPES.STANDARD_50, + ACCOUNT_TYPES.PRO_100, + ACCOUNT_TYPES.SCHOOL_500, + ACCOUNT_TYPES.SCHOOL_1000, +] as const; + +const sortOptions = [ + { value: "id-desc", label: "ID 최신순" }, + { value: "id-asc", label: "ID 오래된순" }, + { value: "joinedAt-desc", label: "가입일 최신순" }, + { value: "joinedAt-asc", label: "가입일 오래된순" }, + { value: "availableAt-desc", label: "사용 가능일 최신순" }, + { value: "availableAt-asc", label: "사용 가능일 오래된순" }, +] as const; + +export default async function MaestrosPage({ + searchParams, +}: MaestrosPageProps) { + const params = await searchParams; + const result = await getMaestros(params); + const paginationItems = buildPaginationItems(result.page, result.totalPages); + const firstVisiblePage = paginationItems[0] ?? 1; + const lastVisiblePage = paginationItems.at(-1) ?? result.totalPages; + const hasFirstPage = paginationItems.includes(1); + const hasLastPage = paginationItems.includes(result.totalPages); + const hasPreviousPageGroup = firstVisiblePage > 1; + const hasNextPageGroup = lastVisiblePage < result.totalPages; + + return ( +
+
+
+

마에스트로

+

+ 전체 {result.totalCount.toLocaleString()}명 중 {result.page} /{" "} + {result.totalPages} 페이지 +

+
+
+ +
+ + + + + + + + + + +
+ + + 초기화 + +
+
+ + + +
+

+ 페이지당 {result.pageSize}개 표시 +

+ +
+
+ ); +} + +function buildPageHref(rawParams: RawSearchParams, page: number) { + const params = new URLSearchParams(); + + Object.entries(rawParams).forEach(([key, value]) => { + const firstValue = Array.isArray(value) ? value[0] : value; + + if (firstValue && key !== "page") { + params.set(key, firstValue); + } + }); + params.set("page", String(Math.max(1, page))); + + return `/maestros?${params.toString()}`; +} + +function buildPaginationItems(currentPage: number, totalPages: number) { + const startPage = Math.max(1, currentPage - 2); + const endPage = Math.min(totalPages, currentPage + 2); + + return Array.from( + { length: endPage - startPage + 1 }, + (_, index) => startPage + index + ); +} diff --git a/app/api/maestros/[id]/route.ts b/app/api/maestros/[id]/route.ts new file mode 100644 index 0000000..9d3cf1a --- /dev/null +++ b/app/api/maestros/[id]/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from "next/server"; + +import { auth } from "@/auth"; +import { getMaestroDetail } from "@/lib/maestros"; + +type MaestroDetailRouteContext = { + params: Promise<{ + id: string; + }>; +}; + +export async function GET( + _request: Request, + { params }: MaestroDetailRouteContext +) { + const session = await auth(); + + if (!session) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + const maestroID = Number(id); + + if (!Number.isInteger(maestroID) || maestroID <= 0) { + return NextResponse.json( + { message: "Invalid maestro id" }, + { status: 400 } + ); + } + + const result = await getMaestroDetail(maestroID); + + if (!result) { + return NextResponse.json({ message: "Not found" }, { status: 404 }); + } + + return NextResponse.json(result); +} diff --git a/app/api/maestros/route.ts b/app/api/maestros/route.ts new file mode 100644 index 0000000..3e40dc9 --- /dev/null +++ b/app/api/maestros/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from "next/server"; + +import { auth } from "@/auth"; +import { getMaestros } from "@/lib/maestros"; + +export async function GET(request: Request) { + const session = await auth(); + + if (!session) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); + } + + const url = new URL(request.url); + const result = await getMaestros(url.searchParams); + + return NextResponse.json(result); +} diff --git a/components/data-table/maestros-table.tsx b/components/data-table/maestros-table.tsx new file mode 100644 index 0000000..1db6b63 --- /dev/null +++ b/components/data-table/maestros-table.tsx @@ -0,0 +1,208 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { + flexRender, + getCoreRowModel, + type ColumnDef, + useReactTable, +} from "@tanstack/react-table"; +import { useMemo } from "react"; + +import type { MaestroListItem } from "@/lib/maestros"; +import { cn } from "@/lib/utils"; +import { + formatDate, + getAccountTypeLabel, + getActivateStatusLabel, +} from "@/lib/utils"; + +type MaestrosTableProps = { + data: MaestroListItem[]; +}; + +export function MaestrosTable({ data }: MaestrosTableProps) { + const router = useRouter(); + const columns = useMemo[]>( + () => [ + { + accessorKey: "maestroID", + header: "ID", + cell: ({ row }) => ( + + {row.original.maestroID} + + ), + }, + { + accessorKey: "name", + header: "이름", + cell: ({ row }) => ( +
+

{row.original.name}

+

+ {row.original.email} +

+
+ ), + }, + { + accessorKey: "email", + header: "이메일", + cell: ({ row }) => ( + {row.original.email} + ), + }, + { + accessorKey: "accountType", + header: "계정 유형", + cell: ({ row }) => getAccountTypeLabel(row.original.accountType), + }, + { + accessorKey: "activateStatus", + header: "상태", + cell: ({ row }) => ( + + {getActivateStatusLabel(row.original.activateStatus)} + + ), + }, + { + accessorKey: "playerCount", + header: "학생 수", + cell: ({ row }) => ( + + {row.original.playerCount.toLocaleString()} + + ), + }, + { + accessorKey: "acceptClausesDateTime", + header: "가입일", + cell: ({ row }) => formatDate(row.original.acceptClausesDateTime), + }, + { + accessorKey: "availableActivateDateTime", + header: "사용 가능일", + cell: ({ row }) => formatDate(row.original.availableActivateDateTime), + }, + ], + [] + ); + + // TanStack Table manages internal functions that React Compiler should not memoize. + // eslint-disable-next-line react-hooks/incompatible-library + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + }); + + return ( +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {table.getRowModel().rows.length > 0 ? ( + table.getRowModel().rows.map((row) => ( + { + router.push(`/maestros/${row.original.maestroID}`); + }} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + router.push(`/maestros/${row.original.maestroID}`); + } + }} + role="link" + tabIndex={0} + > + {row.getVisibleCells().map((cell) => ( + + ))} + + )) + ) : ( + + + + )} + +
+ {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} +
+ {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} +
+ 조건에 맞는 마에스트로가 없습니다. +
+
+
+ ); +} + +function getActivateStatusBadgeClass(maestro: MaestroListItem) { + if ( + maestro.activateStatus === 2 && + isBeforeToday(maestro.availableActivateDateTime) + ) { + return "border-rose-200 bg-rose-50 text-rose-700"; + } + + if (maestro.activateStatus === 2) { + return "border-emerald-200 bg-emerald-50 text-emerald-700"; + } + + if (maestro.activateStatus === 100) { + return "border-muted bg-muted text-muted-foreground"; + } + + return "border-amber-200 bg-amber-50 text-amber-700"; +} + +function isBeforeToday(value: string) { + const date = new Date(value); + + if (Number.isNaN(date.getTime())) { + return false; + } + + const targetDate = new Date(date); + const today = new Date(); + + targetDate.setHours(0, 0, 0, 0); + today.setHours(0, 0, 0, 0); + + return targetDate.getTime() < today.getTime(); +} diff --git a/components/forms/auto-submit-select.tsx b/components/forms/auto-submit-select.tsx new file mode 100644 index 0000000..5f6f7f9 --- /dev/null +++ b/components/forms/auto-submit-select.tsx @@ -0,0 +1,20 @@ +"use client"; + +import type { ComponentProps } from "react"; + +type AutoSubmitSelectProps = ComponentProps<"select">; + +export function AutoSubmitSelect({ + onChange, + ...props +}: AutoSubmitSelectProps) { + return ( +