Phase 5. 연장 신청 관리 구현

This commit is contained in:
2026-06-11 08:56:36 +09:00
parent fbe4caa9e1
commit 5fa387fa96
8 changed files with 810 additions and 7 deletions
+238
View File
@@ -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<RawSearchParams>;
};
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 (
<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="/extension-requests"
>
</Link>
</div>
</form>
<ExtensionRequestsTable 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 `/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
);
}
+2 -2
View File
@@ -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({
+67
View File
@@ -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;
}
}
+17
View File
@@ -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);
}
@@ -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 (
<div className="overflow-hidden rounded-lg border bg-background">
<div className="overflow-x-auto">
<table className="w-full min-w-[960px] 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>
</tr>
</thead>
<tbody>
{data.length > 0 ? (
data.map((request) => (
<tr
className="border-b last:border-b-0"
key={request.maestroExtensionID}
>
<BodyCell>
<span className="font-mono text-xs tabular-nums">
{request.maestroExtensionID}
</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>
{getAccountTypeLabel(request.requestedAccountType)}
</BodyCell>
<BodyCell>
{getAccountTypeLabel(request.currentAccountType)}
</BodyCell>
<BodyCell>
{getActivateStatusLabel(request.activateStatus)}
</BodyCell>
<BodyCell>{formatDate(request.requestedDateTime)}</BodyCell>
<BodyCell>
{formatDate(request.availableActivateDateTime)}
</BodyCell>
<BodyCell>{getRequestStatusLabel(request.status)}</BodyCell>
<BodyCell>
<ExtensionRequestActions request={request} />
</BodyCell>
</tr>
))
) : (
<tr>
<td
className="h-28 px-3 text-center text-sm text-muted-foreground"
colSpan={9}
>
.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}
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 <span className="text-xs text-muted-foreground"> </span>;
}
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 (
<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("cancel")}
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 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>;
}
+1 -1
View File
@@ -88,7 +88,7 @@ export function MaestrosTable({ data }: MaestrosTableProps) {
},
{
accessorKey: "availableActivateDateTime",
header: "사용 가능일",
header: "만료일",
cell: ({ row }) => formatDate(row.original.availableActivateDateTime),
},
],
+4 -4
View File
@@ -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. 업그레이드 신청 관리
+282
View File
@@ -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<string, string | string[] | undefined>;
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<ExtensionRequestListResult> {
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<void> {
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<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 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
);
}