diff --git a/app/(admin)/maestros/[maestroId]/ExtensionRequestsSection.tsx b/app/(admin)/maestros/[maestroId]/ExtensionRequestsSection.tsx new file mode 100644 index 0000000..4dc27d9 --- /dev/null +++ b/app/(admin)/maestros/[maestroId]/ExtensionRequestsSection.tsx @@ -0,0 +1,144 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { Plus } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { formatDateTime, getAccountTypeLabel, getRequestStatusLabel } from "@/lib/utils"; +import type { MaestroDetail } from "@/lib/maestros"; + +type ExtensionRequestsSectionProps = { + maestroID: number; + accountType: number; + totalCount: number; + extensionRequests: MaestroDetail["extensionRequests"]; +}; + +export function ExtensionRequestsSection({ + maestroID, + accountType, + totalCount, + extensionRequests, +}: ExtensionRequestsSectionProps) { + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + const [isCreating, setIsCreating] = useState(false); + const [errorMessage, setErrorMessage] = useState(""); + + const isDisabled = isCreating || isPending; + + async function handleCreate() { + if (!window.confirm("연장 신청을 등록하시겠습니까?")) return; + + setIsCreating(true); + setErrorMessage(""); + + try { + const response = await fetch( + `/api/maestros/${maestroID}/extension-requests`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ accountType }), + } + ); + + const body = (await response.json().catch(() => null)) as { + message?: string; + } | null; + + if (!response.ok) { + throw new Error(body?.message ?? "연장 신청 등록에 실패했습니다."); + } + + startTransition(() => { + router.refresh(); + }); + } catch (error) { + setErrorMessage( + error instanceof Error ? error.message : "연장 신청 등록에 실패했습니다." + ); + } finally { + setIsCreating(false); + } + } + + return ( +
+
+
+

연장 신청 이력

+

+ 전체 {totalCount.toLocaleString()}건 중 최근 20건 +

+
+
+ + {errorMessage ? ( +

{errorMessage}

+ ) : null} +
+
+
+
+ + + + {["신청ID", "계정 유형", "신청일시", "상태"].map((header) => ( + + ))} + + + + {extensionRequests.length > 0 ? ( + extensionRequests.map((request) => ( + + + + + + + )) + ) : ( + + + + )} + +
+ {header} +
+ {request.maestroExtensionID} + + {getAccountTypeLabel(request.accountType)} + + {formatDateTime(request.requestedDateTime)} + + {getRequestStatusLabel(request.status)} +
+ 연장 신청 이력이 없습니다. +
+
+
+
+ ); +} diff --git a/app/(admin)/maestros/[maestroId]/page.tsx b/app/(admin)/maestros/[maestroId]/page.tsx index b2508b1..959e464 100644 --- a/app/(admin)/maestros/[maestroId]/page.tsx +++ b/app/(admin)/maestros/[maestroId]/page.tsx @@ -13,6 +13,7 @@ import { } from "@/lib/utils"; import { MaestroEditForm } from "./MaestroEditForm"; import { StudentsSection } from "./StudentsSection"; +import { ExtensionRequestsSection } from "./ExtensionRequestsSection"; type MaestroDetailPageProps = { params: Promise<{ @@ -92,23 +93,12 @@ export default async function MaestroDetailPage({
-
-

연장 신청 이력

-

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

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

업그레이드 신청 이력

diff --git a/app/api/maestros/[id]/extension-requests/route.ts b/app/api/maestros/[id]/extension-requests/route.ts new file mode 100644 index 0000000..04d3431 --- /dev/null +++ b/app/api/maestros/[id]/extension-requests/route.ts @@ -0,0 +1,54 @@ +import { z } from "zod"; +import { NextResponse } from "next/server"; + +import { createExtensionRequest } from "@/lib/extension-requests"; +import { ApiError } from "@/lib/errors"; +import { withApiHandler } from "@/lib/api-handler"; +import { logger } from "@/lib/logger"; +import { ACCOUNT_TYPES } from "@/lib/constants"; + +const CTX = "maestros/[id]/extension-requests"; + +const paidAccountTypeValues = [ + ACCOUNT_TYPES.BASIC_20, + ACCOUNT_TYPES.STANDARD_50, + ACCOUNT_TYPES.PRO_100, + ACCOUNT_TYPES.SCHOOL_500, + ACCOUNT_TYPES.SCHOOL_1000, +] as const; + +const postBodySchema = z.object({ + accountType: z.coerce + .number() + .int() + .refine((v) => (paidAccountTypeValues as readonly number[]).includes(v)), +}); + +export const POST = withApiHandler<{ id: string }>( + CTX, + async (request, { params, t0 }) => { + const { id } = await params; + const maestroID = Number(id); + + if (!Number.isInteger(maestroID) || maestroID <= 0) { + throw new ApiError("Invalid maestro id", 400); + } + + const body = postBodySchema.safeParse( + await request.json().catch(() => ({})) + ); + if (!body.success) { + throw new ApiError("Invalid request body", 400); + } + + const result = await createExtensionRequest(maestroID, body.data.accountType); + + logger.info(CTX, "created", { + maestroID, + maestroExtensionID: result.maestroExtensionID, + duration: Date.now() - t0, + }); + + return NextResponse.json(result, { status: 201 }); + } +); diff --git a/lib/extension-requests.ts b/lib/extension-requests.ts index 27c6ab7..5d6fcd9 100644 --- a/lib/extension-requests.ts +++ b/lib/extension-requests.ts @@ -157,6 +157,40 @@ export async function approveExtensionRequest( }); } +export async function createExtensionRequest( + maestroID: number, + accountType: number +): Promise<{ maestroExtensionID: number; requestedDateTime: string; status: number }> { + const maestro = await db.maestro.findUnique({ + where: { MaestroID: maestroID }, + select: { MaestroID: true }, + }); + + if (!maestro) { + throw new ApiError("마에스트로를 찾을 수 없습니다.", 404); + } + + const request = await db.maestro_extension.create({ + data: { + MaestroID: maestroID, + AccountType: accountType, + RequestedDateTime: new Date(), + Status: REQUEST_STATUSES.REQUESTED, + }, + select: { + MaestroExtensionID: true, + RequestedDateTime: true, + Status: true, + }, + }); + + return { + maestroExtensionID: request.MaestroExtensionID, + requestedDateTime: request.RequestedDateTime.toISOString(), + status: request.Status, + }; +} + export async function cancelExtensionRequest( maestroExtensionID: number ): Promise {