diff --git a/app/(admin)/maestros/[maestroId]/UpgradeRequestsSection.tsx b/app/(admin)/maestros/[maestroId]/UpgradeRequestsSection.tsx new file mode 100644 index 0000000..435f5b0 --- /dev/null +++ b/app/(admin)/maestros/[maestroId]/UpgradeRequestsSection.tsx @@ -0,0 +1,191 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { ArrowUp } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { ACCOUNT_TYPES } from "@/lib/constants"; +import { formatDateTime, getAccountTypeLabel, getRequestStatusLabel } from "@/lib/utils"; +import type { MaestroDetail } from "@/lib/maestros"; + +type UpgradeRequestsSectionProps = { + maestroID: number; + accountType: number; + totalCount: number; + upgradeRequests: MaestroDetail["upgradeRequests"]; +}; + +const upgradeOptions = [ + { value: ACCOUNT_TYPES.BASIC_20, label: "1만원 (20명)" }, + { value: ACCOUNT_TYPES.STANDARD_50, label: "2만원 (50명)" }, + { value: ACCOUNT_TYPES.PRO_100, label: "3만원 (100명)" }, + { value: ACCOUNT_TYPES.SCHOOL_500, label: "4만원 (500명)" }, + { value: ACCOUNT_TYPES.SCHOOL_1000, label: "5만원 (1,000명)" }, +] as const; + +const selectClass = + "h-9 rounded-md border bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30 disabled:opacity-50"; + +export function UpgradeRequestsSection({ + maestroID, + accountType, + totalCount, + upgradeRequests, +}: UpgradeRequestsSectionProps) { + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + const [isCreating, setIsCreating] = useState(false); + const [selectedAccountType, setSelectedAccountType] = useState(null); + const [errorMessage, setErrorMessage] = useState(""); + + const isUpgradeEnabled = + selectedAccountType !== null && selectedAccountType > accountType; + const isDisabled = isCreating || isPending; + + async function handleUpgrade() { + if (selectedAccountType === null) return; + if (!window.confirm("업그레이드 신청을 등록하시겠습니까?")) return; + + setIsCreating(true); + setErrorMessage(""); + + try { + const response = await fetch( + `/api/maestros/${maestroID}/upgrade-requests`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ requestedAccountType: selectedAccountType }), + } + ); + + const body = (await response.json().catch(() => null)) as { + message?: string; + } | null; + + if (!response.ok) { + throw new Error(body?.message ?? "업그레이드 신청 등록에 실패했습니다."); + } + + setSelectedAccountType(null); + startTransition(() => { + router.refresh(); + }); + } catch (error) { + setErrorMessage( + error instanceof Error + ? error.message + : "업그레이드 신청 등록에 실패했습니다." + ); + } finally { + setIsCreating(false); + } + } + + return ( +
+
+
+

업그레이드 신청 이력

+

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

+
+
+
+ + +
+ {errorMessage ? ( +

{errorMessage}

+ ) : null} +
+
+
+
+ + + + {["신청ID", "현재", "요청", "신청일시", "상태"].map( + (header) => ( + + ) + )} + + + + {upgradeRequests.length > 0 ? ( + upgradeRequests.map((request) => ( + + + + + + + + )) + ) : ( + + + + )} + +
+ {header} +
+ {request.maestroUpgradeID} + + {getAccountTypeLabel(request.registeredAccountType)} + + {getAccountTypeLabel(request.requestedAccountType)} + + {formatDateTime(request.requestedDateTime)} + + {getRequestStatusLabel(request.status)} +
+ 업그레이드 신청 이력이 없습니다. +
+
+
+
+ ); +} diff --git a/app/(admin)/maestros/[maestroId]/page.tsx b/app/(admin)/maestros/[maestroId]/page.tsx index 959e464..ca84403 100644 --- a/app/(admin)/maestros/[maestroId]/page.tsx +++ b/app/(admin)/maestros/[maestroId]/page.tsx @@ -9,11 +9,11 @@ import { formatDateTime, getAccountTypeLabel, getActivateStatusLabel, - getRequestStatusLabel, } from "@/lib/utils"; import { MaestroEditForm } from "./MaestroEditForm"; import { StudentsSection } from "./StudentsSection"; import { ExtensionRequestsSection } from "./ExtensionRequestsSection"; +import { UpgradeRequestsSection } from "./UpgradeRequestsSection"; type MaestroDetailPageProps = { params: Promise<{ @@ -100,23 +100,12 @@ export default async function MaestroDetailPage({ totalCount={detail.counts.extensionRequests} /> -
-

업그레이드 신청 이력

-

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

- [ - request.maestroUpgradeID, - getAccountTypeLabel(request.registeredAccountType), - getAccountTypeLabel(request.requestedAccountType), - formatDateTime(request.requestedDateTime), - getRequestStatusLabel(request.status), - ])} - /> -
+
diff --git a/app/api/maestros/[id]/upgrade-requests/route.ts b/app/api/maestros/[id]/upgrade-requests/route.ts new file mode 100644 index 0000000..a2199b7 --- /dev/null +++ b/app/api/maestros/[id]/upgrade-requests/route.ts @@ -0,0 +1,58 @@ +import { z } from "zod"; +import { NextResponse } from "next/server"; + +import { createUpgradeRequest } from "@/lib/upgrade-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]/upgrade-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({ + requestedAccountType: 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 createUpgradeRequest( + maestroID, + body.data.requestedAccountType + ); + + logger.info(CTX, "created", { + maestroID, + maestroUpgradeID: result.maestroUpgradeID, + requestedAccountType: body.data.requestedAccountType, + duration: Date.now() - t0, + }); + + return NextResponse.json(result, { status: 201 }); + } +); diff --git a/lib/upgrade-requests.ts b/lib/upgrade-requests.ts index dcef5d1..2dccd0c 100644 --- a/lib/upgrade-requests.ts +++ b/lib/upgrade-requests.ts @@ -167,6 +167,44 @@ export async function approveUpgradeRequest( }); } +export async function createUpgradeRequest( + maestroID: number, + requestedAccountType: number +): Promise<{ maestroUpgradeID: number; registeredAccountType: number; requestedDateTime: string; status: number }> { + const maestro = await db.maestro.findUnique({ + where: { MaestroID: maestroID }, + select: { ActivateStatus: true, AccountType: true }, + }); + + if (!maestro) { + throw new ApiError("마에스트로를 찾을 수 없습니다.", 404); + } + + const request = await db.maestro_upgrade.create({ + data: { + MaestroID: maestroID, + RegisteredActivateStatus: maestro.ActivateStatus, + RegisteredAccountType: maestro.AccountType, + RequestedAccountType: requestedAccountType, + RequestedDateTime: new Date(), + Status: REQUEST_STATUSES.REQUESTED, + }, + select: { + MaestroUpgradeID: true, + RegisteredAccountType: true, + RequestedDateTime: true, + Status: true, + }, + }); + + return { + maestroUpgradeID: request.MaestroUpgradeID, + registeredAccountType: request.RegisteredAccountType, + requestedDateTime: request.RequestedDateTime.toISOString(), + status: request.Status, + }; +} + export async function rejectUpgradeRequest( maestroUpgradeID: number ): Promise {