Phase 6. 업그레이드 신청 관리 구현
This commit is contained in:
@@ -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<RawSearchParams>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<section className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-normal">
|
||||||
|
업그레이드 신청
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
전체 {result.totalCount.toLocaleString()}건 중 {result.page} /{" "}
|
||||||
|
{result.totalPages} 페이지
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="grid grid-cols-1 gap-3 rounded-lg border bg-background p-4 sm:grid-cols-2 xl:grid-cols-[minmax(240px,1fr)_160px_110px_auto]"
|
||||||
|
method="get"
|
||||||
|
>
|
||||||
|
<label className="min-w-0 space-y-1.5">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
|
검색
|
||||||
|
</span>
|
||||||
|
<div className="relative min-w-0">
|
||||||
|
<Search
|
||||||
|
className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="h-9 w-full min-w-0 rounded-md border bg-background pl-9 pr-3 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30"
|
||||||
|
defaultValue={result.q}
|
||||||
|
name="q"
|
||||||
|
placeholder="마에스트로명, ID, 신청ID"
|
||||||
|
type="search"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="min-w-0 space-y-1.5">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
|
처리 상태
|
||||||
|
</span>
|
||||||
|
<AutoSubmitSelect
|
||||||
|
className="h-9 w-full min-w-0 rounded-md border bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30"
|
||||||
|
name="status"
|
||||||
|
value={result.status ?? "all"}
|
||||||
|
>
|
||||||
|
<option value="all">전체</option>
|
||||||
|
{statusOptions.map((status) => (
|
||||||
|
<option key={status} value={status}>
|
||||||
|
{REQUEST_STATUS_LABELS[status]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</AutoSubmitSelect>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="min-w-0 space-y-1.5">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
|
페이지 크기
|
||||||
|
</span>
|
||||||
|
<AutoSubmitSelect
|
||||||
|
className="h-9 w-full min-w-0 rounded-md border bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30"
|
||||||
|
name="pageSize"
|
||||||
|
value={result.pageSize}
|
||||||
|
>
|
||||||
|
{[10, 20, 50, 100].map((pageSize) => (
|
||||||
|
<option key={pageSize} value={pageSize}>
|
||||||
|
{pageSize}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</AutoSubmitSelect>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-wrap items-end gap-2 sm:col-span-2 xl:col-span-1">
|
||||||
|
<Button className="h-9" type="submit">
|
||||||
|
적용
|
||||||
|
</Button>
|
||||||
|
<Link
|
||||||
|
className={buttonVariants({
|
||||||
|
variant: "outline",
|
||||||
|
size: "default",
|
||||||
|
className: "h-9",
|
||||||
|
})}
|
||||||
|
href="/upgrade-requests"
|
||||||
|
>
|
||||||
|
초기화
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<UpgradeRequestsTable data={result.items} />
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
페이지당 {result.pageSize}개 표시
|
||||||
|
</p>
|
||||||
|
<nav
|
||||||
|
aria-label="업그레이드 신청 목록 페이지"
|
||||||
|
className="flex flex-wrap items-center gap-1.5"
|
||||||
|
>
|
||||||
|
{!hasFirstPage ? (
|
||||||
|
<Link
|
||||||
|
aria-label="처음 페이지"
|
||||||
|
className={buttonVariants({
|
||||||
|
variant: "outline",
|
||||||
|
size: "icon-sm",
|
||||||
|
})}
|
||||||
|
href={buildPageHref(params, 1)}
|
||||||
|
>
|
||||||
|
<ChevronsLeft className="size-4" aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{hasPreviousPageGroup ? (
|
||||||
|
<Link
|
||||||
|
aria-label="이전 5페이지"
|
||||||
|
className={buttonVariants({
|
||||||
|
variant: "outline",
|
||||||
|
size: "icon-sm",
|
||||||
|
})}
|
||||||
|
href={buildPageHref(params, Math.max(1, result.page - 5))}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{paginationItems.map((item) => (
|
||||||
|
<Link
|
||||||
|
aria-current={item === result.page ? "page" : undefined}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({
|
||||||
|
variant: item === result.page ? "default" : "outline",
|
||||||
|
size: "sm",
|
||||||
|
}),
|
||||||
|
"min-w-8 px-2"
|
||||||
|
)}
|
||||||
|
href={buildPageHref(params, item)}
|
||||||
|
key={item}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{hasNextPageGroup ? (
|
||||||
|
<Link
|
||||||
|
aria-label="다음 5페이지"
|
||||||
|
className={buttonVariants({
|
||||||
|
variant: "outline",
|
||||||
|
size: "icon-sm",
|
||||||
|
})}
|
||||||
|
href={buildPageHref(
|
||||||
|
params,
|
||||||
|
Math.min(result.totalPages, result.page + 5)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ChevronRight className="size-4" aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!hasLastPage ? (
|
||||||
|
<Link
|
||||||
|
aria-label="끝 페이지"
|
||||||
|
className={buttonVariants({
|
||||||
|
variant: "outline",
|
||||||
|
size: "icon-sm",
|
||||||
|
})}
|
||||||
|
href={buildPageHref(params, result.totalPages)}
|
||||||
|
>
|
||||||
|
<ChevronsRight className="size-4" aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="overflow-hidden rounded-lg border bg-background">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[1080px] border-collapse text-sm">
|
||||||
|
<thead className="bg-muted/60">
|
||||||
|
<tr className="border-b">
|
||||||
|
<HeaderCell>신청ID</HeaderCell>
|
||||||
|
<HeaderCell>마에스트로</HeaderCell>
|
||||||
|
<HeaderCell>현재 상태</HeaderCell>
|
||||||
|
<HeaderCell>현재 계정</HeaderCell>
|
||||||
|
<HeaderCell>요청 계정</HeaderCell>
|
||||||
|
<HeaderCell>추가 금액</HeaderCell>
|
||||||
|
<HeaderCell>신청일시</HeaderCell>
|
||||||
|
<HeaderCell>만료일</HeaderCell>
|
||||||
|
<HeaderCell>처리 상태</HeaderCell>
|
||||||
|
<HeaderCell>처리</HeaderCell>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.length > 0 ? (
|
||||||
|
data.map((request) => (
|
||||||
|
<tr
|
||||||
|
className="border-b last:border-b-0"
|
||||||
|
key={request.maestroUpgradeID}
|
||||||
|
>
|
||||||
|
<BodyCell>
|
||||||
|
<span className="font-mono text-xs tabular-nums">
|
||||||
|
{request.maestroUpgradeID}
|
||||||
|
</span>
|
||||||
|
</BodyCell>
|
||||||
|
<BodyCell>
|
||||||
|
<Link
|
||||||
|
className={buttonVariants({
|
||||||
|
variant: "link",
|
||||||
|
size: "sm",
|
||||||
|
className: "h-auto p-0 font-medium",
|
||||||
|
})}
|
||||||
|
href={`/maestros/${request.maestroID}`}
|
||||||
|
>
|
||||||
|
{request.maestroName}
|
||||||
|
</Link>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
MaestroID {request.maestroID}
|
||||||
|
</p>
|
||||||
|
</BodyCell>
|
||||||
|
<BodyCell>
|
||||||
|
{getActivateStatusLabel(request.registeredActivateStatus)}
|
||||||
|
</BodyCell>
|
||||||
|
<BodyCell>{getRegisteredAccountLabel(request)}</BodyCell>
|
||||||
|
<BodyCell>
|
||||||
|
{getAccountTypeLabel(request.requestedAccountType)}
|
||||||
|
</BodyCell>
|
||||||
|
<BodyCell>
|
||||||
|
<span className="tabular-nums">
|
||||||
|
{request.additionalPrice.toLocaleString()}원
|
||||||
|
</span>
|
||||||
|
</BodyCell>
|
||||||
|
<BodyCell>
|
||||||
|
{formatDateTime(request.requestedDateTime)}
|
||||||
|
</BodyCell>
|
||||||
|
<BodyCell>
|
||||||
|
{formatDate(request.availableActivateDateTime)}
|
||||||
|
</BodyCell>
|
||||||
|
<BodyCell>{getRequestStatusLabel(request.status)}</BodyCell>
|
||||||
|
<BodyCell>
|
||||||
|
<UpgradeRequestActions request={request} />
|
||||||
|
</BodyCell>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
className="h-28 px-3 text-center text-sm text-muted-foreground"
|
||||||
|
colSpan={10}
|
||||||
|
>
|
||||||
|
조건에 맞는 업그레이드 신청이 없습니다.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <span className="text-xs text-muted-foreground">처리 완료</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={() => void submitAction("approve")}
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Check className="size-4" aria-hidden="true" />
|
||||||
|
승인
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={() => void submitAction("reject")}
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<X className="size-4" aria-hidden="true" />
|
||||||
|
거절
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{errorMessage ? (
|
||||||
|
<p className="max-w-56 text-xs text-destructive">{errorMessage}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<th className="h-10 px-3 text-left text-xs font-medium text-muted-foreground">
|
||||||
|
{children}
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BodyCell({ children }: { children: React.ReactNode }) {
|
||||||
|
return <td className="h-12 px-3 align-middle">{children}</td>;
|
||||||
|
}
|
||||||
@@ -339,10 +339,10 @@ choco-admin/
|
|||||||
|
|
||||||
### Phase 6. 업그레이드 신청 관리
|
### Phase 6. 업그레이드 신청 관리
|
||||||
|
|
||||||
- [ ] 업그레이드 신청 목록 API + 화면
|
- [x] 업그레이드 신청 목록 API + 화면
|
||||||
- [ ] 승인/거절 확인 다이얼로그
|
- [x] 승인/거절 확인 다이얼로그
|
||||||
- [ ] 승인 API (`PATCH /api/upgrade-requests/[id]`) — 트랜잭션 + maestro_log
|
- [x] 승인 API (`PATCH /api/upgrade-requests/[id]`) — 트랜잭션 + maestro_log
|
||||||
- [ ] 거절 API — 트랜잭션 + maestro_log
|
- [x] 거절 API — 트랜잭션 + maestro_log
|
||||||
|
|
||||||
### Phase 7. 배포 환경 구성
|
### Phase 7. 배포 환경 구성
|
||||||
|
|
||||||
|
|||||||
@@ -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<string, string | string[] | undefined>;
|
||||||
|
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<UpgradeRequestListResult> {
|
||||||
|
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<void> {
|
||||||
|
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<string, string | undefined> {
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user