diff --git a/app/(admin)/extension-requests/page.tsx b/app/(admin)/extension-requests/page.tsx new file mode 100644 index 0000000..1265d83 --- /dev/null +++ b/app/(admin)/extension-requests/page.tsx @@ -0,0 +1,238 @@ +import Link from "next/link"; +import { + ChevronLeft, + ChevronRight, + ChevronsLeft, + ChevronsRight, + Search, +} from "lucide-react"; + +import { ExtensionRequestsTable } from "@/components/data-table/extension-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 { + getExtensionRequests, + type RawSearchParams, +} from "@/lib/extension-requests"; +import { cn } from "@/lib/utils"; + +type ExtensionRequestsPageProps = { + searchParams: Promise; +}; + +const statusOptions = [ + REQUEST_STATUSES.REQUESTED, + REQUEST_STATUSES.CLOSED, + REQUEST_STATUSES.APPLIED, +] as const; + +export default async function ExtensionRequestsPage({ + searchParams, +}: ExtensionRequestsPageProps) { + const params = await searchParams; + const result = await getExtensionRequests(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 `/extension-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/(admin)/maestros/page.tsx b/app/(admin)/maestros/page.tsx index 6114d7f..f515788 100644 --- a/app/(admin)/maestros/page.tsx +++ b/app/(admin)/maestros/page.tsx @@ -43,8 +43,8 @@ const sortOptions = [ { value: "id-asc", label: "ID 오래된순" }, { value: "joinedAt-desc", label: "가입일 최신순" }, { value: "joinedAt-asc", label: "가입일 오래된순" }, - { value: "availableAt-desc", label: "사용 가능일 최신순" }, - { value: "availableAt-asc", label: "사용 가능일 오래된순" }, + { value: "availableAt-desc", label: "만료일 최신순" }, + { value: "availableAt-asc", label: "만료일 오래된순" }, ] as const; export default async function MaestrosPage({ diff --git a/app/api/extension-requests/[id]/route.ts b/app/api/extension-requests/[id]/route.ts new file mode 100644 index 0000000..fa232f6 --- /dev/null +++ b/app/api/extension-requests/[id]/route.ts @@ -0,0 +1,67 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { auth } from "@/auth"; +import { + approveExtensionRequest, + cancelExtensionRequest, + ExtensionRequestActionError, +} from "@/lib/extension-requests"; + +const patchBodySchema = z.object({ + action: z.enum(["approve", "cancel"]), +}); + +type ExtensionRequestRouteContext = { + params: Promise<{ + id: string; + }>; +}; + +export async function PATCH( + request: Request, + { params }: ExtensionRequestRouteContext +) { + const session = await auth(); + + if (!session) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + const maestroExtensionID = Number(id); + + if (!Number.isInteger(maestroExtensionID) || maestroExtensionID <= 0) { + return NextResponse.json( + { message: "Invalid extension 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 approveExtensionRequest(maestroExtensionID); + return NextResponse.json({ ok: true, ...result }); + } + + await cancelExtensionRequest(maestroExtensionID); + return NextResponse.json({ ok: true }); + } catch (error) { + if (error instanceof ExtensionRequestActionError) { + return NextResponse.json( + { message: error.message }, + { status: error.statusCode } + ); + } + + throw error; + } +} diff --git a/app/api/extension-requests/route.ts b/app/api/extension-requests/route.ts new file mode 100644 index 0000000..302e77f --- /dev/null +++ b/app/api/extension-requests/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from "next/server"; + +import { auth } from "@/auth"; +import { getExtensionRequests } from "@/lib/extension-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 getExtensionRequests(url.searchParams); + + return NextResponse.json(result); +} diff --git a/components/data-table/extension-requests-table.tsx b/components/data-table/extension-requests-table.tsx new file mode 100644 index 0000000..2b4b483 --- /dev/null +++ b/components/data-table/extension-requests-table.tsx @@ -0,0 +1,199 @@ +"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 { REQUEST_STATUSES } from "@/lib/constants"; +import type { ExtensionRequestListItem } from "@/lib/extension-requests"; +import { + formatDate, + getAccountTypeLabel, + getActivateStatusLabel, + getRequestStatusLabel, +} from "@/lib/utils"; + +type ExtensionRequestsTableProps = { + data: ExtensionRequestListItem[]; +}; + +export function ExtensionRequestsTable({ data }: ExtensionRequestsTableProps) { + return ( +
+
+ + + + 신청ID + 마에스트로 + 요청 계정 + 현재 계정 + 현재 상태 + 신청일시 + 만료일 + 처리 상태 + 처리 + + + + {data.length > 0 ? ( + data.map((request) => ( + + + + {request.maestroExtensionID} + + + + + {request.maestroName} + +

+ MaestroID {request.maestroID} +

+
+ + {getAccountTypeLabel(request.requestedAccountType)} + + + {getAccountTypeLabel(request.currentAccountType)} + + + {getActivateStatusLabel(request.activateStatus)} + + {formatDate(request.requestedDateTime)} + + {formatDate(request.availableActivateDateTime)} + + {getRequestStatusLabel(request.status)} + + + + + )) + ) : ( + + + + )} + +
+ 조건에 맞는 연장 신청이 없습니다. +
+
+
+ ); +} + +function ExtensionRequestActions({ + request, +}: { + request: ExtensionRequestListItem; +}) { + 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" | "cancel") { + const actionLabel = action === "approve" ? "승인" : "취소"; + const confirmed = window.confirm( + `${request.maestroName} 연장 신청을 ${actionLabel}하시겠습니까?` + ); + + if (!confirmed) { + return; + } + + setErrorMessage(""); + + try { + const response = await fetch( + `/api/extension-requests/${request.maestroExtensionID}`, + { + 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 HeaderCell({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +function BodyCell({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/components/data-table/maestros-table.tsx b/components/data-table/maestros-table.tsx index 1db6b63..0422793 100644 --- a/components/data-table/maestros-table.tsx +++ b/components/data-table/maestros-table.tsx @@ -88,7 +88,7 @@ export function MaestrosTable({ data }: MaestrosTableProps) { }, { accessorKey: "availableActivateDateTime", - header: "사용 가능일", + header: "만료일", cell: ({ row }) => formatDate(row.original.availableActivateDateTime), }, ], diff --git a/docs/plan/project-plan.md b/docs/plan/project-plan.md index c5d5eb0..11ee0e0 100644 --- a/docs/plan/project-plan.md +++ b/docs/plan/project-plan.md @@ -332,10 +332,10 @@ choco-admin/ ### Phase 5. 연장 신청 관리 -- [ ] 연장 신청 목록 API + 화면 -- [ ] 승인/취소 확인 다이얼로그 (shadcn/ui AlertDialog) -- [ ] 승인 API (`PATCH /api/extension-requests/[id]`) — 트랜잭션 + maestro_log -- [ ] 취소 API — 트랜잭션 + maestro_log +- [x] 연장 신청 목록 API + 화면 +- [x] 승인/취소 확인 다이얼로그 +- [x] 승인 API (`PATCH /api/extension-requests/[id]`) — 트랜잭션 + maestro_log +- [x] 취소 API — 트랜잭션 + maestro_log ### Phase 6. 업그레이드 신청 관리 diff --git a/lib/extension-requests.ts b/lib/extension-requests.ts new file mode 100644 index 0000000..1b04b0d --- /dev/null +++ b/lib/extension-requests.ts @@ -0,0 +1,282 @@ +import { z } from "zod"; + +import { db } from "@/lib/db"; +import { REQUEST_STATUSES } from "@/lib/constants"; +import type { Prisma } from "@/lib/generated/prisma/client"; +import { + calculateExtendedAvailableDate, + getAccountTypeLabel, +} from "@/lib/utils"; + +const pageSizeOptions = [10, 20, 50, 100] as const; + +const extensionRequestSearchSchema = 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 ExtensionRequestSearchParams = z.infer< + typeof extensionRequestSearchSchema +>; + +export type ExtensionRequestListItem = { + maestroExtensionID: number; + maestroID: number; + maestroName: string; + requestedAccountType: number; + currentAccountType: number; + activateStatus: number; + requestedDateTime: string; + availableActivateDateTime: string; + status: number; +}; + +export type ExtensionRequestListResult = { + items: ExtensionRequestListItem[]; + page: number; + pageSize: number; + totalCount: number; + totalPages: number; + q: string; + status?: number | "all"; +}; + +export class ExtensionRequestActionError extends Error { + constructor( + message: string, + readonly statusCode: number + ) { + super(message); + this.name = "ExtensionRequestActionError"; + } +} + +export async function getExtensionRequests( + rawSearchParams: RawSearchParams = {} +): Promise { + const params = parseExtensionRequestSearchParams(rawSearchParams); + const where = buildExtensionRequestWhere(params); + const skip = (params.page - 1) * params.pageSize; + + const [totalCount, requests] = await Promise.all([ + db.maestro_extension.count({ where }), + db.maestro_extension.findMany({ + where, + orderBy: { MaestroExtensionID: "desc" }, + skip, + take: params.pageSize, + select: { + MaestroExtensionID: true, + MaestroID: true, + AccountType: true, + RequestedDateTime: true, + Status: true, + maestro: { + select: { + Name: true, + AccountType: true, + ActivateStatus: true, + AvailableActivateDateTime: true, + }, + }, + }, + }), + ]); + + return { + items: requests.map(toExtensionRequestListItem), + 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 approveExtensionRequest( + maestroExtensionID: number +): Promise<{ availableActivateDateTime: string }> { + return db.$transaction(async (tx) => { + const request = await getActionTarget(tx, maestroExtensionID); + const availableActivateDateTime = calculateExtendedAvailableDate( + request.maestro.AvailableActivateDateTime + ); + + await tx.maestro.update({ + where: { MaestroID: request.MaestroID }, + data: { + ActivateStatus: 2, + AvailableActivateDateTime: availableActivateDateTime, + }, + }); + await tx.maestro_extension.updateMany({ + where: { + MaestroID: request.MaestroID, + Status: REQUEST_STATUSES.REQUESTED, + }, + data: { Status: REQUEST_STATUSES.CLOSED }, + }); + await tx.maestro_extension.update({ + where: { MaestroExtensionID: request.MaestroExtensionID }, + data: { Status: REQUEST_STATUSES.APPLIED }, + }); + await tx.maestro_log.create({ + data: { + Type: "extension_maestro", + MaestroID: request.MaestroID, + LogDateTime: new Date(), + Remark: buildExtensionLogRemark( + request.maestro.Name, + request.AccountType + ), + }, + }); + + return { + availableActivateDateTime: availableActivateDateTime.toISOString(), + }; + }); +} + +export async function cancelExtensionRequest( + maestroExtensionID: number +): Promise { + await db.$transaction(async (tx) => { + const request = await getActionTarget(tx, maestroExtensionID); + + await tx.maestro_extension.update({ + where: { MaestroExtensionID: request.MaestroExtensionID }, + data: { Status: REQUEST_STATUSES.CLOSED }, + }); + await tx.maestro_log.create({ + data: { + Type: "cancel_extension_maestro", + MaestroID: request.MaestroID, + LogDateTime: new Date(), + Remark: buildExtensionLogRemark( + request.maestro.Name, + request.AccountType + ), + }, + }); + }); +} + +export function parseExtensionRequestSearchParams( + rawSearchParams: RawSearchParams +): ExtensionRequestSearchParams { + return extensionRequestSearchSchema.parse( + searchParamsToRecord(rawSearchParams) + ); +} + +async function getActionTarget( + tx: Prisma.TransactionClient, + maestroExtensionID: number +) { + const request = await tx.maestro_extension.findUnique({ + where: { MaestroExtensionID: maestroExtensionID }, + include: { maestro: true }, + }); + + if (!request) { + throw new ExtensionRequestActionError("연장 신청을 찾을 수 없습니다.", 404); + } + + if (request.Status !== REQUEST_STATUSES.REQUESTED) { + throw new ExtensionRequestActionError("이미 처리된 연장 신청입니다.", 409); + } + + return request; +} + +function buildExtensionRequestWhere( + params: ExtensionRequestSearchParams +): Prisma.maestro_extensionWhereInput { + const conditions: Prisma.maestro_extensionWhereInput[] = []; + + if (params.q) { + const keywordConditions: Prisma.maestro_extensionWhereInput[] = [ + { maestro: { Name: { contains: params.q } } }, + ]; + const numericQuery = Number(params.q); + + if (Number.isInteger(numericQuery) && numericQuery > 0) { + keywordConditions.unshift( + { MaestroExtensionID: 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 toExtensionRequestListItem(request: { + MaestroExtensionID: number; + MaestroID: number; + AccountType: number; + RequestedDateTime: Date; + Status: number; + maestro: { + Name: string; + AccountType: number; + ActivateStatus: number; + AvailableActivateDateTime: Date; + }; +}): ExtensionRequestListItem { + return { + maestroExtensionID: request.MaestroExtensionID, + maestroID: request.MaestroID, + maestroName: request.maestro.Name.trim(), + requestedAccountType: request.AccountType, + currentAccountType: request.maestro.AccountType, + activateStatus: request.maestro.ActivateStatus, + requestedDateTime: request.RequestedDateTime.toISOString(), + availableActivateDateTime: + request.maestro.AvailableActivateDateTime.toISOString(), + status: request.Status, + }; +} + +function buildExtensionLogRemark(maestroName: string, accountType: number) { + return `${maestroName.trim()}(${getAccountTypeLabel(accountType)})`.slice( + 0, + 100 + ); +}