From 605f6bcdf0b95da4410f096c6be29fc608424228 Mon Sep 17 00:00:00 2001 From: jisangs Date: Thu, 11 Jun 2026 09:06:26 +0900 Subject: [PATCH] =?UTF-8?q?Phase=206.=20=EC=97=85=EA=B7=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EB=93=9C=20=EC=8B=A0=EC=B2=AD=20=EA=B4=80=EB=A6=AC=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)/upgrade-requests/page.tsx | 240 +++++++++++++ app/api/upgrade-requests/[id]/route.ts | 67 ++++ app/api/upgrade-requests/route.ts | 17 + .../data-table/upgrade-requests-table.tsx | 217 ++++++++++++ docs/plan/project-plan.md | 8 +- lib/upgrade-requests.ts | 325 ++++++++++++++++++ 6 files changed, 870 insertions(+), 4 deletions(-) create mode 100644 app/(admin)/upgrade-requests/page.tsx create mode 100644 app/api/upgrade-requests/[id]/route.ts create mode 100644 app/api/upgrade-requests/route.ts create mode 100644 components/data-table/upgrade-requests-table.tsx create mode 100644 lib/upgrade-requests.ts diff --git a/app/(admin)/upgrade-requests/page.tsx b/app/(admin)/upgrade-requests/page.tsx new file mode 100644 index 0000000..4cc85dc --- /dev/null +++ b/app/(admin)/upgrade-requests/page.tsx @@ -0,0 +1,240 @@ +import Link from "next/link"; +import { + ChevronLeft, + ChevronRight, + ChevronsLeft, + ChevronsRight, + Search, +} from "lucide-react"; + +import { UpgradeRequestsTable } from "@/components/data-table/upgrade-requests-table"; +import { AutoSubmitSelect } from "@/components/forms/auto-submit-select"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { REQUEST_STATUS_LABELS, REQUEST_STATUSES } from "@/lib/constants"; +import { + getUpgradeRequests, + type RawSearchParams, +} from "@/lib/upgrade-requests"; +import { cn } from "@/lib/utils"; + +type UpgradeRequestsPageProps = { + searchParams: Promise; +}; + +const statusOptions = [ + REQUEST_STATUSES.REQUESTED, + REQUEST_STATUSES.CLOSED, + REQUEST_STATUSES.APPLIED, +] as const; + +export default async function UpgradeRequestsPage({ + searchParams, +}: UpgradeRequestsPageProps) { + const params = await searchParams; + const result = await getUpgradeRequests(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 `/upgrade-requests?${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/upgrade-requests/[id]/route.ts b/app/api/upgrade-requests/[id]/route.ts new file mode 100644 index 0000000..71e0164 --- /dev/null +++ b/app/api/upgrade-requests/[id]/route.ts @@ -0,0 +1,67 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { auth } from "@/auth"; +import { + approveUpgradeRequest, + rejectUpgradeRequest, + UpgradeRequestActionError, +} from "@/lib/upgrade-requests"; + +const patchBodySchema = z.object({ + action: z.enum(["approve", "reject"]), +}); + +type UpgradeRequestRouteContext = { + params: Promise<{ + id: string; + }>; +}; + +export async function PATCH( + request: Request, + { params }: UpgradeRequestRouteContext +) { + const session = await auth(); + + if (!session) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + const maestroUpgradeID = Number(id); + + if (!Number.isInteger(maestroUpgradeID) || maestroUpgradeID <= 0) { + return NextResponse.json( + { message: "Invalid upgrade request id" }, + { status: 400 } + ); + } + + const body = patchBodySchema.safeParse( + await request.json().catch(() => ({})) + ); + + if (!body.success) { + return NextResponse.json({ message: "Invalid action" }, { status: 400 }); + } + + try { + if (body.data.action === "approve") { + const result = await approveUpgradeRequest(maestroUpgradeID); + return NextResponse.json({ ok: true, ...result }); + } + + await rejectUpgradeRequest(maestroUpgradeID); + return NextResponse.json({ ok: true }); + } catch (error) { + if (error instanceof UpgradeRequestActionError) { + return NextResponse.json( + { message: error.message }, + { status: error.statusCode } + ); + } + + throw error; + } +} diff --git a/app/api/upgrade-requests/route.ts b/app/api/upgrade-requests/route.ts new file mode 100644 index 0000000..3861d8c --- /dev/null +++ b/app/api/upgrade-requests/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from "next/server"; + +import { auth } from "@/auth"; +import { getUpgradeRequests } from "@/lib/upgrade-requests"; + +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 getUpgradeRequests(url.searchParams); + + return NextResponse.json(result); +} diff --git a/components/data-table/upgrade-requests-table.tsx b/components/data-table/upgrade-requests-table.tsx new file mode 100644 index 0000000..9142191 --- /dev/null +++ b/components/data-table/upgrade-requests-table.tsx @@ -0,0 +1,217 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { Check, X } from "lucide-react"; +import { useState, useTransition } from "react"; + +import { Button, buttonVariants } from "@/components/ui/button"; +import { ACTIVATE_STATUSES, REQUEST_STATUSES } from "@/lib/constants"; +import type { UpgradeRequestListItem } from "@/lib/upgrade-requests"; +import { + formatDate, + formatDateTime, + getAccountTypeLabel, + getActivateStatusLabel, + getRequestStatusLabel, + getTrialAccountTypeLabel, +} from "@/lib/utils"; + +type UpgradeRequestsTableProps = { + data: UpgradeRequestListItem[]; +}; + +export function UpgradeRequestsTable({ data }: UpgradeRequestsTableProps) { + return ( +
+
+ + + + 신청ID + 마에스트로 + 현재 상태 + 현재 계정 + 요청 계정 + 추가 금액 + 신청일시 + 만료일 + 처리 상태 + 처리 + + + + {data.length > 0 ? ( + data.map((request) => ( + + + + {request.maestroUpgradeID} + + + + + {request.maestroName} + +

+ MaestroID {request.maestroID} +

+
+ + {getActivateStatusLabel(request.registeredActivateStatus)} + + {getRegisteredAccountLabel(request)} + + {getAccountTypeLabel(request.requestedAccountType)} + + + + {request.additionalPrice.toLocaleString()}원 + + + + {formatDateTime(request.requestedDateTime)} + + + {formatDate(request.availableActivateDateTime)} + + {getRequestStatusLabel(request.status)} + + + + + )) + ) : ( + + + + )} + +
+ 조건에 맞는 업그레이드 신청이 없습니다. +
+
+
+ ); +} + +function UpgradeRequestActions({ + request, +}: { + request: UpgradeRequestListItem; +}) { + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + const [errorMessage, setErrorMessage] = useState(""); + const isRequested = request.status === REQUEST_STATUSES.REQUESTED; + + if (!isRequested) { + return 처리 완료; + } + + async function submitAction(action: "approve" | "reject") { + const actionLabel = action === "approve" ? "승인" : "거절"; + const confirmed = window.confirm( + `${request.maestroName} 업그레이드 신청을 ${actionLabel}하시겠습니까?` + ); + + if (!confirmed) { + return; + } + + setErrorMessage(""); + + try { + const response = await fetch( + `/api/upgrade-requests/${request.maestroUpgradeID}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action }), + } + ); + + if (!response.ok) { + const body = (await response.json().catch(() => null)) as { + message?: string; + } | null; + + throw new Error( + body?.message ?? "업그레이드 신청 처리에 실패했습니다." + ); + } + + startTransition(() => { + router.refresh(); + }); + } catch (error) { + setErrorMessage( + error instanceof Error + ? error.message + : "업그레이드 신청 처리에 실패했습니다." + ); + } + } + + return ( +
+
+ + +
+ {errorMessage ? ( +

{errorMessage}

+ ) : null} +
+ ); +} + +function getRegisteredAccountLabel(request: UpgradeRequestListItem) { + if (request.registeredActivateStatus === ACTIVATE_STATUSES.TRIAL) { + return getTrialAccountTypeLabel(request.registeredAccountType); + } + + return getAccountTypeLabel(request.registeredAccountType); +} + +function HeaderCell({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +function BodyCell({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/docs/plan/project-plan.md b/docs/plan/project-plan.md index 11ee0e0..a5c4070 100644 --- a/docs/plan/project-plan.md +++ b/docs/plan/project-plan.md @@ -339,10 +339,10 @@ choco-admin/ ### Phase 6. 업그레이드 신청 관리 -- [ ] 업그레이드 신청 목록 API + 화면 -- [ ] 승인/거절 확인 다이얼로그 -- [ ] 승인 API (`PATCH /api/upgrade-requests/[id]`) — 트랜잭션 + maestro_log -- [ ] 거절 API — 트랜잭션 + maestro_log +- [x] 업그레이드 신청 목록 API + 화면 +- [x] 승인/거절 확인 다이얼로그 +- [x] 승인 API (`PATCH /api/upgrade-requests/[id]`) — 트랜잭션 + maestro_log +- [x] 거절 API — 트랜잭션 + maestro_log ### Phase 7. 배포 환경 구성 diff --git a/lib/upgrade-requests.ts b/lib/upgrade-requests.ts new file mode 100644 index 0000000..27c0778 --- /dev/null +++ b/lib/upgrade-requests.ts @@ -0,0 +1,325 @@ +import { z } from "zod"; + +import { db } from "@/lib/db"; +import { ACTIVATE_STATUSES, REQUEST_STATUSES } from "@/lib/constants"; +import type { Prisma } from "@/lib/generated/prisma/client"; +import { + calculateUpgradePrice, + getAccountTypeLabel, + getAccountTypePrice, + getTrialAccountTypeLabel, +} from "@/lib/utils"; + +const pageSizeOptions = [10, 20, 50, 100] as const; + +const upgradeRequestSearchSchema = z.object({ + page: z.coerce.number().int().min(1).catch(1), + pageSize: z.coerce + .number() + .int() + .refine((value) => pageSizeOptions.includes(value as PageSize)) + .catch(10), + q: z.string().trim().catch(""), + status: z + .union([z.literal("all"), z.coerce.number().int().min(0)]) + .optional() + .catch(REQUEST_STATUSES.REQUESTED), +}); + +type PageSize = (typeof pageSizeOptions)[number]; +export type RawSearchParams = + | URLSearchParams + | Record; +export type UpgradeRequestSearchParams = z.infer< + typeof upgradeRequestSearchSchema +>; + +export type UpgradeRequestListItem = { + maestroUpgradeID: number; + maestroID: number; + maestroName: string; + registeredActivateStatus: number; + registeredAccountType: number; + requestedAccountType: number; + requestedDateTime: string; + availableActivateDateTime: string; + status: number; + additionalPrice: number; +}; + +export type UpgradeRequestListResult = { + items: UpgradeRequestListItem[]; + page: number; + pageSize: number; + totalCount: number; + totalPages: number; + q: string; + status?: number | "all"; +}; + +export class UpgradeRequestActionError extends Error { + constructor( + message: string, + readonly statusCode: number + ) { + super(message); + this.name = "UpgradeRequestActionError"; + } +} + +export async function getUpgradeRequests( + rawSearchParams: RawSearchParams = {} +): Promise { + const params = parseUpgradeRequestSearchParams(rawSearchParams); + const where = buildUpgradeRequestWhere(params); + const skip = (params.page - 1) * params.pageSize; + + const [totalCount, requests] = await Promise.all([ + db.maestro_upgrade.count({ where }), + db.maestro_upgrade.findMany({ + where, + orderBy: { MaestroUpgradeID: "desc" }, + skip, + take: params.pageSize, + select: { + MaestroUpgradeID: true, + MaestroID: true, + RegisteredActivateStatus: true, + RegisteredAccountType: true, + RequestedAccountType: true, + RequestedDateTime: true, + Status: true, + maestro: { + select: { + Name: true, + AvailableActivateDateTime: true, + }, + }, + }, + }), + ]); + + return { + items: requests.map(toUpgradeRequestListItem), + page: params.page, + pageSize: params.pageSize, + totalCount, + totalPages: Math.max(1, Math.ceil(totalCount / params.pageSize)), + q: params.q, + status: params.status, + }; +} + +export async function approveUpgradeRequest( + maestroUpgradeID: number +): Promise<{ availableActivateDateTime: string }> { + return db.$transaction(async (tx) => { + const request = await getActionTarget(tx, maestroUpgradeID); + const availableActivateDateTime = calculateUpgradeAvailableDate(); + + await tx.maestro.update({ + where: { MaestroID: request.MaestroID }, + data: { + AccountType: request.RequestedAccountType, + ActivateStatus: ACTIVATE_STATUSES.ACTIVE, + AvailableActivateDateTime: availableActivateDateTime, + }, + }); + await tx.maestro_upgrade.updateMany({ + where: { + MaestroID: request.MaestroID, + Status: REQUEST_STATUSES.REQUESTED, + }, + data: { Status: REQUEST_STATUSES.CLOSED }, + }); + await tx.maestro_upgrade.update({ + where: { MaestroUpgradeID: request.MaestroUpgradeID }, + data: { Status: REQUEST_STATUSES.APPLIED }, + }); + await tx.maestro_log.create({ + data: { + Type: "upgrade_maestro", + MaestroID: request.MaestroID, + LogDateTime: new Date(), + Remark: buildUpgradeLogRemark( + request.RegisteredActivateStatus, + request.RegisteredAccountType, + request.RequestedAccountType + ), + }, + }); + + return { + availableActivateDateTime: availableActivateDateTime.toISOString(), + }; + }); +} + +export async function rejectUpgradeRequest( + maestroUpgradeID: number +): Promise { + await db.$transaction(async (tx) => { + const request = await getActionTarget(tx, maestroUpgradeID); + + await tx.maestro_upgrade.update({ + where: { MaestroUpgradeID: request.MaestroUpgradeID }, + data: { Status: REQUEST_STATUSES.CLOSED }, + }); + await tx.maestro_log.create({ + data: { + Type: "reject_upgrade_maestro", + MaestroID: request.MaestroID, + LogDateTime: new Date(), + Remark: buildUpgradeLogRemark( + request.RegisteredActivateStatus, + request.RegisteredAccountType, + request.RequestedAccountType + ), + }, + }); + }); +} + +export function parseUpgradeRequestSearchParams( + rawSearchParams: RawSearchParams +): UpgradeRequestSearchParams { + return upgradeRequestSearchSchema.parse( + searchParamsToRecord(rawSearchParams) + ); +} + +async function getActionTarget( + tx: Prisma.TransactionClient, + maestroUpgradeID: number +) { + const request = await tx.maestro_upgrade.findUnique({ + where: { MaestroUpgradeID: maestroUpgradeID }, + }); + + if (!request) { + throw new UpgradeRequestActionError( + "업그레이드 신청을 찾을 수 없습니다.", + 404 + ); + } + + if (request.Status !== REQUEST_STATUSES.REQUESTED) { + throw new UpgradeRequestActionError( + "이미 처리된 업그레이드 신청입니다.", + 409 + ); + } + + return request; +} + +function buildUpgradeRequestWhere( + params: UpgradeRequestSearchParams +): Prisma.maestro_upgradeWhereInput { + const conditions: Prisma.maestro_upgradeWhereInput[] = []; + + if (params.q) { + const keywordConditions: Prisma.maestro_upgradeWhereInput[] = [ + { maestro: { Name: { contains: params.q } } }, + ]; + const numericQuery = Number(params.q); + + if (Number.isInteger(numericQuery) && numericQuery > 0) { + keywordConditions.unshift( + { MaestroUpgradeID: numericQuery }, + { MaestroID: numericQuery } + ); + } + + conditions.push({ OR: keywordConditions }); + } + + if (typeof params.status === "number" && Number.isInteger(params.status)) { + conditions.push({ Status: params.status }); + } + + return conditions.length > 0 ? { AND: conditions } : {}; +} + +function searchParamsToRecord( + rawSearchParams: RawSearchParams +): Record { + if (rawSearchParams instanceof URLSearchParams) { + return Object.fromEntries(rawSearchParams.entries()); + } + + return Object.fromEntries( + Object.entries(rawSearchParams).map(([key, value]) => [ + key, + Array.isArray(value) ? value[0] : value, + ]) + ); +} + +function toUpgradeRequestListItem(request: { + MaestroUpgradeID: number; + MaestroID: number; + RegisteredActivateStatus: number; + RegisteredAccountType: number; + RequestedAccountType: number; + RequestedDateTime: Date; + Status: number; + maestro: { + Name: string; + AvailableActivateDateTime: Date; + }; +}): UpgradeRequestListItem { + return { + maestroUpgradeID: request.MaestroUpgradeID, + maestroID: request.MaestroID, + maestroName: request.maestro.Name.trim(), + registeredActivateStatus: request.RegisteredActivateStatus, + registeredAccountType: request.RegisteredAccountType, + requestedAccountType: request.RequestedAccountType, + requestedDateTime: request.RequestedDateTime.toISOString(), + availableActivateDateTime: + request.maestro.AvailableActivateDateTime.toISOString(), + status: request.Status, + additionalPrice: calculateAdditionalPrice( + request.RegisteredActivateStatus, + request.RegisteredAccountType, + request.RequestedAccountType + ), + }; +} + +function calculateAdditionalPrice( + registeredActivateStatus: number, + registeredAccountType: number, + requestedAccountType: number +) { + if (registeredActivateStatus === ACTIVATE_STATUSES.TRIAL) { + return getAccountTypePrice(requestedAccountType); + } + + return calculateUpgradePrice(requestedAccountType, registeredAccountType); +} + +function calculateUpgradeAvailableDate(now = new Date()) { + const availableDate = new Date(now); + + availableDate.setFullYear(availableDate.getFullYear() + 1); + + return availableDate; +} + +function buildUpgradeLogRemark( + registeredActivateStatus: number, + registeredAccountType: number, + requestedAccountType: number +) { + const registeredLabel = + registeredActivateStatus === ACTIVATE_STATUSES.TRIAL + ? getTrialAccountTypeLabel(registeredAccountType) + : getAccountTypeLabel(registeredAccountType); + + return `${registeredLabel} -> ${getAccountTypeLabel(requestedAccountType)}`.slice( + 0, + 100 + ); +}