Compare commits
9 Commits
5dd7375aad
...
d82f39c2f6
| Author | SHA1 | Date | |
|---|---|---|---|
| d82f39c2f6 | |||
| 976fe42a2a | |||
| 605f6bcdf0 | |||
| 5fa387fa96 | |||
| fbe4caa9e1 | |||
| dbf9b3e180 | |||
| a93a6fa8e1 | |||
| af5b46baf3 | |||
| c6a5418d25 |
@@ -0,0 +1,21 @@
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.git
|
||||
.gitignore
|
||||
.next
|
||||
.pnpm-store
|
||||
.turbo
|
||||
.DS_Store
|
||||
.idea
|
||||
.claude
|
||||
coverage
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
README.md
|
||||
docs
|
||||
@@ -0,0 +1,17 @@
|
||||
# Local development
|
||||
DATABASE_URL="mysql://USER:PASSWORD@127.0.0.1:3306/chocomae"
|
||||
AUTH_SECRET="replace-with-local-secret"
|
||||
NEXTAUTH_SECRET="replace-with-local-secret"
|
||||
NEXTAUTH_URL="http://localhost:3001"
|
||||
|
||||
# Production example (.env.production)
|
||||
# DATABASE_URL="mysql://USER:PASSWORD@AWS_EC2_HOST:3306/chocomae"
|
||||
# AUTH_SECRET="replace-with-production-secret"
|
||||
# NEXTAUTH_SECRET="replace-with-production-secret"
|
||||
# NEXTAUTH_URL="http://NAS_HOST_OR_DOMAIN:3001"
|
||||
|
||||
# Stage example (.env.stage)
|
||||
# DATABASE_URL="mysql://USER:PASSWORD@STAGE_DB_HOST:3306/chocomae"
|
||||
# AUTH_SECRET="replace-with-stage-secret"
|
||||
# NEXTAUTH_SECRET="replace-with-stage-secret"
|
||||
# NEXTAUTH_URL="http://STAGE_HOST_OR_DOMAIN:3001"
|
||||
+42
-22
@@ -1,25 +1,45 @@
|
||||
### macOS
|
||||
# Finder metadata
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
|
||||
# Thumbnails
|
||||
._*
|
||||
|
||||
# Custom folder icons
|
||||
Icon
|
||||
|
||||
|
||||
# Volume root files
|
||||
.DocumentRevisions-V100
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
.VolumeIcon.icns
|
||||
.com.apple.timemachine.donotpresent
|
||||
|
||||
### Local development tools
|
||||
*.pem
|
||||
.idea/
|
||||
|
||||
|
||||
# Claude Code local settings
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
/lib/generated/prisma
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
.next
|
||||
node_modules
|
||||
pnpm-lock.yaml
|
||||
lib/generated/prisma
|
||||
docs
|
||||
AGENTS.md
|
||||
CLAUDE.md
|
||||
README.md
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
FROM node:22-alpine AS base
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
WORKDIR /app
|
||||
|
||||
FROM base AS deps
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
FROM base AS builder
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
ENV DATABASE_URL="mysql://build:build@127.0.0.1:3306/chocomae"
|
||||
ENV AUTH_SECRET="build-time-placeholder"
|
||||
ENV NEXTAUTH_SECRET="build-time-placeholder"
|
||||
ENV NEXTAUTH_URL="http://localhost:3000"
|
||||
RUN pnpm prisma generate
|
||||
RUN pnpm build
|
||||
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
ENV PORT=3000
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import {
|
||||
ArrowUpCircle,
|
||||
ClipboardList,
|
||||
GraduationCap,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
} from "lucide-react";
|
||||
|
||||
import { auth, signOut } from "@/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/", label: "대시보드", icon: LayoutDashboard },
|
||||
{ href: "/maestros", label: "마에스트로", icon: GraduationCap },
|
||||
{ href: "/extension-requests", label: "연장 신청", icon: ClipboardList },
|
||||
{ href: "/upgrade-requests", label: "업그레이드", icon: ArrowUpCircle },
|
||||
];
|
||||
|
||||
export default async function AdminLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const session = await auth();
|
||||
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-muted/40">
|
||||
<aside className="fixed inset-y-0 left-0 hidden w-64 border-r bg-background md:block">
|
||||
<div className="flex h-16 items-center border-b px-5">
|
||||
<Link className="text-lg font-semibold" href="/">
|
||||
chocoadmin
|
||||
</Link>
|
||||
</div>
|
||||
<nav className="space-y-1 p-3">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<Link
|
||||
className="flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
href={item.href}
|
||||
key={item.href}
|
||||
>
|
||||
<Icon className="size-4" aria-hidden="true" />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div className="md:pl-64">
|
||||
<header className="sticky top-0 z-10 flex h-16 items-center justify-between border-b bg-background px-5">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{session.user.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
관리자 ID {session.user.adminID}
|
||||
</p>
|
||||
</div>
|
||||
<form
|
||||
action={async () => {
|
||||
"use server";
|
||||
|
||||
await signOut({ redirectTo: "/login" });
|
||||
}}
|
||||
>
|
||||
<Button size="sm" type="submit" variant="outline">
|
||||
<LogOut className="size-4" aria-hidden="true" />
|
||||
로그아웃
|
||||
</Button>
|
||||
</form>
|
||||
</header>
|
||||
|
||||
<main className="p-5">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { getMaestroDetail } from "@/lib/maestros";
|
||||
import {
|
||||
formatDate,
|
||||
formatDateTime,
|
||||
getAccountTypeLabel,
|
||||
getActivateStatusLabel,
|
||||
getRequestStatusLabel,
|
||||
} from "@/lib/utils";
|
||||
|
||||
type MaestroDetailPageProps = {
|
||||
params: Promise<{
|
||||
maestroId: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export default async function MaestroDetailPage({
|
||||
params,
|
||||
}: MaestroDetailPageProps) {
|
||||
const { maestroId } = await params;
|
||||
const maestroID = Number(maestroId);
|
||||
|
||||
if (!Number.isInteger(maestroID) || maestroID <= 0) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const detail = await getMaestroDetail(maestroID);
|
||||
|
||||
if (!detail) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { maestro } = detail;
|
||||
|
||||
return (
|
||||
<section className="space-y-5">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<Link
|
||||
className={buttonVariants({
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
className: "mb-2 -ml-2",
|
||||
})}
|
||||
href="/maestros"
|
||||
>
|
||||
<ArrowLeft className="size-4" aria-hidden="true" />
|
||||
목록
|
||||
</Link>
|
||||
<h1 className="text-2xl font-semibold tracking-normal">
|
||||
{maestro.name}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
MaestroID {maestro.maestroID} · {maestro.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-4">
|
||||
<SummaryCard
|
||||
label="상태"
|
||||
value={getActivateStatusLabel(maestro.activateStatus)}
|
||||
/>
|
||||
<SummaryCard
|
||||
label="계정 유형"
|
||||
value={getAccountTypeLabel(maestro.accountType)}
|
||||
/>
|
||||
<SummaryCard
|
||||
label="학생 수"
|
||||
value={detail.counts.players.toLocaleString()}
|
||||
/>
|
||||
<SummaryCard
|
||||
label="사용 가능일"
|
||||
value={formatDate(maestro.availableActivateDateTime)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="rounded-lg border bg-background p-5">
|
||||
<h2 className="text-base font-semibold">기본 정보</h2>
|
||||
<dl className="mt-4 grid gap-x-8 gap-y-4 text-sm md:grid-cols-2 xl:grid-cols-3">
|
||||
<InfoItem label="이름" value={maestro.name} />
|
||||
<InfoItem label="이메일" value={maestro.email} />
|
||||
<InfoItem
|
||||
label="상태"
|
||||
value={getActivateStatusLabel(maestro.activateStatus)}
|
||||
/>
|
||||
<InfoItem
|
||||
label="계정 유형"
|
||||
value={getAccountTypeLabel(maestro.accountType)}
|
||||
/>
|
||||
<InfoItem
|
||||
label="DB 학생 수"
|
||||
value={detail.counts.players.toLocaleString()}
|
||||
/>
|
||||
<InfoItem
|
||||
label="PlayerCount"
|
||||
value={maestro.playerCount.toLocaleString()}
|
||||
/>
|
||||
<InfoItem
|
||||
label="사용 가능일"
|
||||
value={formatDateTime(maestro.availableActivateDateTime)}
|
||||
/>
|
||||
<InfoItem
|
||||
label="약관 동의일"
|
||||
value={formatDateTime(maestro.acceptClausesDateTime)}
|
||||
/>
|
||||
<InfoItem
|
||||
label="입장코드 수정"
|
||||
value={maestro.allowEditEnterCode === 1 ? "허용" : "비허용"}
|
||||
/>
|
||||
<InfoItem
|
||||
label="체험 MaestroID"
|
||||
value={maestro.maestroTestID?.toString() ?? "-"}
|
||||
/>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border bg-background p-5">
|
||||
<div className="flex items-end justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">학생 목록 요약</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
전체 {detail.counts.players.toLocaleString()}명 중 최근 20명
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<SimpleTable
|
||||
emptyText="등록된 학생이 없습니다."
|
||||
headers={["PlayerID", "이름", "입장코드", "AccountType"]}
|
||||
rows={detail.players.map((player) => [
|
||||
player.playerID,
|
||||
player.name,
|
||||
player.enterCode,
|
||||
player.accountType ?? "-",
|
||||
])}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<section className="rounded-lg border bg-background p-5">
|
||||
<h2 className="text-base font-semibold">연장 신청 이력</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
전체 {detail.counts.extensionRequests.toLocaleString()}건 중 최근
|
||||
20건
|
||||
</p>
|
||||
<SimpleTable
|
||||
emptyText="연장 신청 이력이 없습니다."
|
||||
headers={["신청ID", "계정 유형", "신청일시", "상태"]}
|
||||
rows={detail.extensionRequests.map((request) => [
|
||||
request.maestroExtensionID,
|
||||
getAccountTypeLabel(request.accountType),
|
||||
formatDateTime(request.requestedDateTime),
|
||||
getRequestStatusLabel(request.status),
|
||||
])}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border bg-background p-5">
|
||||
<h2 className="text-base font-semibold">업그레이드 신청 이력</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
전체 {detail.counts.upgradeRequests.toLocaleString()}건 중 최근 20건
|
||||
</p>
|
||||
<SimpleTable
|
||||
emptyText="업그레이드 신청 이력이 없습니다."
|
||||
headers={["신청ID", "현재", "요청", "신청일시", "상태"]}
|
||||
rows={detail.upgradeRequests.map((request) => [
|
||||
request.maestroUpgradeID,
|
||||
getAccountTypeLabel(request.registeredAccountType),
|
||||
getAccountTypeLabel(request.requestedAccountType),
|
||||
formatDateTime(request.requestedDateTime),
|
||||
getRequestStatusLabel(request.status),
|
||||
])}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="rounded-lg border bg-background p-5">
|
||||
<h2 className="text-base font-semibold">처리 로그</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
전체 {detail.counts.logs.toLocaleString()}건 중 최근 30건
|
||||
</p>
|
||||
<SimpleTable
|
||||
emptyText="처리 로그가 없습니다."
|
||||
headers={["LogID", "유형", "처리일시", "내용"]}
|
||||
rows={detail.logs.map((log) => [
|
||||
log.maestroLogID,
|
||||
log.type,
|
||||
formatDateTime(log.logDateTime),
|
||||
log.remark,
|
||||
])}
|
||||
/>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryCard({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<article className="rounded-lg border bg-background p-4">
|
||||
<p className="text-xs font-medium text-muted-foreground">{label}</p>
|
||||
<p className="mt-2 text-lg font-semibold">{value}</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoItem({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<dt className="text-xs font-medium text-muted-foreground">{label}</dt>
|
||||
<dd className="mt-1 break-words font-medium">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SimpleTable({
|
||||
headers,
|
||||
rows,
|
||||
emptyText,
|
||||
}: {
|
||||
headers: string[];
|
||||
rows: Array<Array<string | number>>;
|
||||
emptyText: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-4 overflow-hidden rounded-lg border">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[560px] border-collapse text-sm">
|
||||
<thead className="bg-muted/60">
|
||||
<tr className="border-b">
|
||||
{headers.map((header) => (
|
||||
<th
|
||||
className="h-10 px-3 text-left text-xs font-medium text-muted-foreground"
|
||||
key={header}
|
||||
>
|
||||
{header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length > 0 ? (
|
||||
rows.map((row, index) => (
|
||||
<tr className="border-b last:border-b-0" key={index}>
|
||||
{row.map((cell, cellIndex) => (
|
||||
<td
|
||||
className="h-11 max-w-[360px] break-words px-3 align-middle"
|
||||
key={cellIndex}
|
||||
>
|
||||
{cell}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td
|
||||
className="h-24 px-3 text-center text-sm text-muted-foreground"
|
||||
colSpan={headers.length}
|
||||
>
|
||||
{emptyText}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { MaestrosTable } from "@/components/data-table/maestros-table";
|
||||
import { AutoSubmitSelect } from "@/components/forms/auto-submit-select";
|
||||
import {
|
||||
ACCOUNT_TYPE_LABELS,
|
||||
ACCOUNT_TYPES,
|
||||
ACTIVATE_STATUS_LABELS,
|
||||
ACTIVATE_STATUSES,
|
||||
} from "@/lib/constants";
|
||||
import { getMaestros, type RawSearchParams } from "@/lib/maestros";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type MaestrosPageProps = {
|
||||
searchParams: Promise<RawSearchParams>;
|
||||
};
|
||||
|
||||
const activateStatusOptions = [
|
||||
ACTIVATE_STATUSES.PENDING,
|
||||
ACTIVATE_STATUSES.TRIAL,
|
||||
ACTIVATE_STATUSES.ACTIVE,
|
||||
ACTIVATE_STATUSES.CANCELED,
|
||||
] as const;
|
||||
|
||||
const accountTypeOptions = [
|
||||
ACCOUNT_TYPES.BASIC_20,
|
||||
ACCOUNT_TYPES.STANDARD_50,
|
||||
ACCOUNT_TYPES.PRO_100,
|
||||
ACCOUNT_TYPES.SCHOOL_500,
|
||||
ACCOUNT_TYPES.SCHOOL_1000,
|
||||
] as const;
|
||||
|
||||
const sortOptions = [
|
||||
{ value: "id-desc", label: "ID 최신순" },
|
||||
{ value: "id-asc", label: "ID 오래된순" },
|
||||
{ value: "joinedAt-desc", label: "가입일 최신순" },
|
||||
{ value: "joinedAt-asc", label: "가입일 오래된순" },
|
||||
{ value: "availableAt-desc", label: "만료일 최신순" },
|
||||
{ value: "availableAt-asc", label: "만료일 오래된순" },
|
||||
] as const;
|
||||
|
||||
export default async function MaestrosPage({
|
||||
searchParams,
|
||||
}: MaestrosPageProps) {
|
||||
const params = await searchParams;
|
||||
const result = await getMaestros(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 className="flex flex-col gap-1 sm:flex-row sm:items-end sm:justify-between">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="grid grid-cols-1 gap-3 rounded-lg border bg-background p-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-[minmax(220px,1fr)_160px_180px_190px_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"
|
||||
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="activateStatus"
|
||||
value={result.activateStatus ?? "all"}
|
||||
>
|
||||
<option value="all">전체</option>
|
||||
{activateStatusOptions.map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{ACTIVATE_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="accountType"
|
||||
value={result.accountType ?? "all"}
|
||||
>
|
||||
<option value="all">전체</option>
|
||||
{accountTypeOptions.map((accountType) => (
|
||||
<option key={accountType} value={accountType}>
|
||||
{ACCOUNT_TYPE_LABELS[accountType]}
|
||||
</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="sort"
|
||||
value={result.sort}
|
||||
>
|
||||
{sortOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</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="/maestros"
|
||||
>
|
||||
초기화
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<MaestrosTable 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 `/maestros?${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,55 @@
|
||||
import { Database, ShieldCheck, Table2 } from "lucide-react";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
|
||||
const checks = [
|
||||
{
|
||||
label: "Next.js 16",
|
||||
value: "App Router",
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
{
|
||||
label: "Prisma",
|
||||
value: "MariaDB introspected",
|
||||
icon: Database,
|
||||
},
|
||||
{
|
||||
label: "TanStack Table",
|
||||
value: "Installed",
|
||||
icon: Table2,
|
||||
},
|
||||
];
|
||||
|
||||
export default async function Home() {
|
||||
const session = await auth();
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-normal">대시보드</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{session?.user.name} 관리자 계정으로 로그인했습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{checks.map((item) => {
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<article
|
||||
className="rounded-lg border bg-card p-5 text-card-foreground"
|
||||
key={item.label}
|
||||
>
|
||||
<div className="mb-4 flex size-9 items-center justify-center rounded-md bg-muted">
|
||||
<Icon className="size-5" aria-hidden="true" />
|
||||
</div>
|
||||
<h2 className="text-base font-medium">{item.label}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{item.value}</p>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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,26 @@
|
||||
"use server";
|
||||
|
||||
import { AuthError } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { signIn } from "@/auth";
|
||||
|
||||
export async function loginAction(formData: FormData) {
|
||||
const callbackUrl = String(formData.get("callbackUrl") ?? "/");
|
||||
|
||||
try {
|
||||
await signIn("credentials", {
|
||||
adminName: formData.get("adminName"),
|
||||
password: formData.get("password"),
|
||||
redirectTo: callbackUrl,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
redirect(
|
||||
`/login?error=CredentialsSignin&callbackUrl=${encodeURIComponent(callbackUrl)}`
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { loginAction } from "./actions";
|
||||
|
||||
type LoginPageProps = {
|
||||
searchParams: Promise<{
|
||||
callbackUrl?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||
const session = await auth();
|
||||
|
||||
if (session) {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
const params = await searchParams;
|
||||
const callbackUrl = params.callbackUrl ?? "/";
|
||||
const hasError = params.error === "CredentialsSignin";
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-muted px-6 py-10">
|
||||
<section className="w-full max-w-md rounded-lg border bg-background p-8 shadow-sm">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-semibold tracking-normal">chocoadmin</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">관리자 로그인</p>
|
||||
</div>
|
||||
|
||||
{hasError ? (
|
||||
<div className="mb-5 rounded-md border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
관리자 아이디 또는 암호가 올바르지 않습니다.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form action={loginAction} className="space-y-5">
|
||||
<input name="callbackUrl" type="hidden" value={callbackUrl} />
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="adminName">
|
||||
관리자 아이디
|
||||
</label>
|
||||
<input
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
className="h-10 w-full rounded-md border bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30"
|
||||
id="adminName"
|
||||
name="adminName"
|
||||
required
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="password">
|
||||
암호
|
||||
</label>
|
||||
<input
|
||||
autoComplete="current-password"
|
||||
className="h-10 w-full rounded-md border bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30"
|
||||
id="password"
|
||||
name="password"
|
||||
required
|
||||
type="password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button className="w-full" type="submit">
|
||||
로그인
|
||||
</Button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { GET, POST } from "@/auth";
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,39 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { getMaestroDetail } from "@/lib/maestros";
|
||||
|
||||
type MaestroDetailRouteContext = {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: MaestroDetailRouteContext
|
||||
) {
|
||||
const session = await auth();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const maestroID = Number(id);
|
||||
|
||||
if (!Number.isInteger(maestroID) || maestroID <= 0) {
|
||||
return NextResponse.json(
|
||||
{ message: "Invalid maestro id" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await getMaestroDetail(maestroID);
|
||||
|
||||
if (!result) {
|
||||
return NextResponse.json({ message: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { getMaestros } from "@/lib/maestros";
|
||||
|
||||
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 getMaestros(url.searchParams);
|
||||
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
+130
@@ -0,0 +1,130 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: Arial, Helvetica, sans-serif;
|
||||
--font-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
--font-heading: var(--font-sans);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "chocoadmin",
|
||||
description: "Chocomae admin service",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="ko" className="h-full antialiased">
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import NextAuth, { type DefaultSession } from "next-auth";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
import { z } from "zod";
|
||||
|
||||
import { ACTIVATE_STATUSES } from "@/lib/constants";
|
||||
|
||||
const credentialsSchema = z.object({
|
||||
adminName: z.string().trim().min(1),
|
||||
password: z.string().min(1),
|
||||
});
|
||||
|
||||
type AdminAuthRow = {
|
||||
AdminID: bigint | number;
|
||||
Name: string;
|
||||
Email: string;
|
||||
AccountType: bigint | number;
|
||||
ActivateStatus: bigint | number;
|
||||
};
|
||||
|
||||
declare module "next-auth" {
|
||||
interface Session {
|
||||
user: {
|
||||
adminID: number;
|
||||
accountType: number;
|
||||
activateStatus: number;
|
||||
} & DefaultSession["user"];
|
||||
}
|
||||
|
||||
interface User {
|
||||
accountType: number;
|
||||
activateStatus: number;
|
||||
}
|
||||
}
|
||||
|
||||
declare module "next-auth/jwt" {
|
||||
interface JWT {
|
||||
accountType?: number;
|
||||
activateStatus?: number;
|
||||
}
|
||||
}
|
||||
|
||||
export const {
|
||||
handlers: { GET, POST },
|
||||
auth,
|
||||
signIn,
|
||||
signOut,
|
||||
} = NextAuth({
|
||||
pages: {
|
||||
signIn: "/login",
|
||||
},
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
},
|
||||
providers: [
|
||||
Credentials({
|
||||
credentials: {
|
||||
adminName: { label: "관리자 아이디", type: "text" },
|
||||
password: { label: "암호", type: "password" },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
const parsedCredentials = credentialsSchema.safeParse(credentials);
|
||||
|
||||
if (!parsedCredentials.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { db } = await import("@/lib/db");
|
||||
const { adminName, password } = parsedCredentials.data;
|
||||
const admins = await db.$queryRaw<AdminAuthRow[]>`
|
||||
SELECT AdminID, Name, Email, AccountType, ActivateStatus
|
||||
FROM admin
|
||||
WHERE Name = ${adminName} AND Password = PASSWORD(${password})
|
||||
LIMIT 1
|
||||
`;
|
||||
const admin = admins[0];
|
||||
const adminID = Number(admin?.AdminID);
|
||||
const accountType = Number(admin?.AccountType);
|
||||
const activateStatus = Number(admin?.ActivateStatus);
|
||||
|
||||
if (!admin || activateStatus === ACTIVATE_STATUSES.PENDING) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: String(adminID),
|
||||
name: admin.Name,
|
||||
email: admin.Email,
|
||||
accountType,
|
||||
activateStatus,
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
callbacks: {
|
||||
jwt({ token, user }) {
|
||||
if (user) {
|
||||
token.accountType = user.accountType;
|
||||
token.activateStatus = user.activateStatus;
|
||||
}
|
||||
|
||||
return token;
|
||||
},
|
||||
session({ session, token }) {
|
||||
return {
|
||||
...session,
|
||||
user: {
|
||||
...session.user,
|
||||
adminID: Number(token.sub),
|
||||
accountType: Number(token.accountType),
|
||||
activateStatus: Number(token.activateStatus),
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "base-nova",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
type ColumnDef,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import type { MaestroListItem } from "@/lib/maestros";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
formatDate,
|
||||
getAccountTypeLabel,
|
||||
getActivateStatusLabel,
|
||||
} from "@/lib/utils";
|
||||
|
||||
type MaestrosTableProps = {
|
||||
data: MaestroListItem[];
|
||||
};
|
||||
|
||||
export function MaestrosTable({ data }: MaestrosTableProps) {
|
||||
const router = useRouter();
|
||||
const columns = useMemo<ColumnDef<MaestroListItem>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "maestroID",
|
||||
header: "ID",
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs tabular-nums">
|
||||
{row.original.maestroID}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "이름",
|
||||
cell: ({ row }) => (
|
||||
<div className="min-w-32">
|
||||
<p className="font-medium">{row.original.name}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground md:hidden">
|
||||
{row.original.email}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "email",
|
||||
header: "이메일",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground">{row.original.email}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "accountType",
|
||||
header: "계정 유형",
|
||||
cell: ({ row }) => getAccountTypeLabel(row.original.accountType),
|
||||
},
|
||||
{
|
||||
accessorKey: "activateStatus",
|
||||
header: "상태",
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex h-6 items-center rounded-md border px-2 text-xs font-medium",
|
||||
getActivateStatusBadgeClass(row.original)
|
||||
)}
|
||||
>
|
||||
{getActivateStatusLabel(row.original.activateStatus)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "playerCount",
|
||||
header: "학생 수",
|
||||
cell: ({ row }) => (
|
||||
<span className="tabular-nums">
|
||||
{row.original.playerCount.toLocaleString()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "acceptClausesDateTime",
|
||||
header: "가입일",
|
||||
cell: ({ row }) => formatDate(row.original.acceptClausesDateTime),
|
||||
},
|
||||
{
|
||||
accessorKey: "availableActivateDateTime",
|
||||
header: "만료일",
|
||||
cell: ({ row }) => formatDate(row.original.availableActivateDateTime),
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
// TanStack Table manages internal functions that React Compiler should not memoize.
|
||||
// eslint-disable-next-line react-hooks/incompatible-library
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border bg-background">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[980px] border-collapse text-sm">
|
||||
<thead className="bg-muted/60">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr className="border-b" key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th
|
||||
className="h-10 px-3 text-left text-xs font-medium text-muted-foreground"
|
||||
key={header.id}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody>
|
||||
{table.getRowModel().rows.length > 0 ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<tr
|
||||
aria-label={`${row.original.name} 마에스트로 상세 보기`}
|
||||
className="cursor-pointer border-b transition-colors last:border-b-0 hover:bg-muted/60 focus-visible:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
|
||||
key={row.id}
|
||||
onClick={() => {
|
||||
router.push(`/maestros/${row.original.maestroID}`);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
router.push(`/maestros/${row.original.maestroID}`);
|
||||
}
|
||||
}}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td className="h-12 px-3 align-middle" key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td
|
||||
className="h-28 px-3 text-center text-sm text-muted-foreground"
|
||||
colSpan={columns.length}
|
||||
>
|
||||
조건에 맞는 마에스트로가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getActivateStatusBadgeClass(maestro: MaestroListItem) {
|
||||
if (
|
||||
maestro.activateStatus === 2 &&
|
||||
isBeforeToday(maestro.availableActivateDateTime)
|
||||
) {
|
||||
return "border-rose-200 bg-rose-50 text-rose-700";
|
||||
}
|
||||
|
||||
if (maestro.activateStatus === 2) {
|
||||
return "border-emerald-200 bg-emerald-50 text-emerald-700";
|
||||
}
|
||||
|
||||
if (maestro.activateStatus === 100) {
|
||||
return "border-muted bg-muted text-muted-foreground";
|
||||
}
|
||||
|
||||
return "border-amber-200 bg-amber-50 text-amber-700";
|
||||
}
|
||||
|
||||
function isBeforeToday(value: string) {
|
||||
const date = new Date(value);
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const targetDate = new Date(date);
|
||||
const today = new Date();
|
||||
|
||||
targetDate.setHours(0, 0, 0, 0);
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
return targetDate.getTime() < today.getTime();
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
type AutoSubmitSelectProps = ComponentProps<"select">;
|
||||
|
||||
export function AutoSubmitSelect({
|
||||
onChange,
|
||||
...props
|
||||
}: AutoSubmitSelectProps) {
|
||||
return (
|
||||
<select
|
||||
{...props}
|
||||
onChange={(event) => {
|
||||
onChange?.(event);
|
||||
event.currentTarget.form?.requestSubmit();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Button as ButtonPrimitive } from "@base-ui/react/button";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||
return (
|
||||
<ButtonPrimitive
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,16 @@
|
||||
services:
|
||||
chocoadmin:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: chocoadmin:latest
|
||||
container_name: chocoadmin
|
||||
ports:
|
||||
- "3001:3000"
|
||||
env_file:
|
||||
- ${ENV_FILE:-.env.production}
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 3000
|
||||
HOSTNAME: 0.0.0.0
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,354 @@
|
||||
# 기존 관리자 비즈니스 규칙
|
||||
|
||||
작성일: 2026-06-09
|
||||
|
||||
## 1. 개요
|
||||
|
||||
이 문서는 기존 `mouse-typing` 관리자 기능을 조사해 신규 `chocoadmin`에서 재현해야 할 비즈니스 규칙을 정리한 것이다.
|
||||
|
||||
조사 대상:
|
||||
|
||||
- 기존 서비스: `/Users/jisangs/work/jinaju/git/web/mouse-typing`
|
||||
- 관리자 화면: `src/web/admin/`
|
||||
- 관리자 서버: `src/web/server/admin/`
|
||||
- 공통 로그 유틸: `src/web/server/lib/maestro_log.php`
|
||||
- 계정 표시/계산 유틸: `src/web/js/lib/account_info.js`, `src/web/js/maestro_available_date.js`
|
||||
|
||||
중요 결론:
|
||||
|
||||
- 관리자 비밀번호는 MariaDB `PASSWORD()` 함수로 검증한다.
|
||||
- 연장 승인은 기존 만료일 기준 1년 연장하되, 만료된 계정은 오늘 기준 1년 연장한다.
|
||||
- 기존 연장 승인 서버는 날짜를 직접 계산하지 않고 화면에서 전달한 `new_available_date`를 저장한다.
|
||||
- 업그레이드 승인은 `AccountType`, `ActivateStatus`, `AvailableActivateDateTime`만 변경하고 `PlayerCount`는 변경하지 않는다.
|
||||
- 기존 관리자에는 연장 취소/업그레이드 거절 버튼이 없다. 승인 처리 중 같은 마에스트로의 다른 요청을 `Status=2`로 닫는 동작만 있다.
|
||||
- 기존 변경 처리에는 명시적 트랜잭션이 없다.
|
||||
|
||||
## 2. 기존 관리자 파일 맵
|
||||
|
||||
| 기존 화면 | 서버 파일 | 역할 | 신규 `chocoadmin` 매핑 |
|
||||
|---|---|---|---|
|
||||
| `admin/login.html` | `server/admin/login.php` | 관리자 로그인 | `/login`, NextAuth Credentials Provider |
|
||||
| `admin/admin_all_list.html` | `server/admin/maestro_all_list.php` | 전체 마에스트로 목록 조회 | `/maestros`, `GET /api/maestros` |
|
||||
| `admin/admin_register_list.html` | `server/admin/maestro_registered_list.php`, `server/admin/register_maestro.php` | 미승인 마에스트로 조회 및 등록 승인 | MVP 외 또는 상세 보조 규칙 |
|
||||
| `admin/admin_extension_list.html` | `server/admin/maestro_extension_list.php`, `server/admin/extension_maestro.php` | 연장 요청 조회 및 승인 | `/extension-requests`, `PATCH /api/extension-requests/[id]` |
|
||||
| `admin/admin_upgrade_list.html` | `server/admin/maestro_upgrade_list.php`, `server/admin/upgrade_maestro.php` | 업그레이드 요청 조회 및 승인 | `/upgrade-requests`, `PATCH /api/upgrade-requests/[id]` |
|
||||
| `admin/admin_expiration_list.html` | 없음 | 정적 골격만 존재, 실제 조회 로직 없음 | MVP 제외 |
|
||||
| `admin/admin_send_email.html` | `server/mail/send_mail_test.php` | 테스트 메일 발송 화면 | MVP 제외 |
|
||||
| `admin/admin_header.html` | 없음 | 관리자 메뉴 링크 | 공통 사이드바/헤더 |
|
||||
|
||||
## 3. 상태값 코드표
|
||||
|
||||
### `maestro.AccountType`
|
||||
|
||||
| 값 | 의미 | 정원 | 금액 |
|
||||
|---:|---|---:|---:|
|
||||
| 1 | 20명 | 20 | 10,000 |
|
||||
| 2 | 50명 | 50 | 20,000 |
|
||||
| 3 | 100명 | 100 | 30,000 |
|
||||
| 4 | 500명 | 500 | 40,000 |
|
||||
| 5 | 1,000명 | 1,000 | 50,000 |
|
||||
| 100 | 학생체험 | - | - |
|
||||
| 101 | 마에체험 | - | - |
|
||||
|
||||
### `maestro.ActivateStatus`
|
||||
|
||||
| 값 | 기존 표시 | 의미 |
|
||||
|---:|---|---|
|
||||
| 0 | 미승인 / 입금대기 | 가입 후 관리자 승인 전 |
|
||||
| 1 | 체험 | 체험 계정 |
|
||||
| 2 | 활성화 | 유료 활성 계정 |
|
||||
| 100 | 등록 취소 / 환불 | 등록 취소 또는 환불 상태 |
|
||||
|
||||
### `maestro_extension.Status`
|
||||
|
||||
| 값 | 기존 표시 | 의미 |
|
||||
|---:|---|---|
|
||||
| 1 | 요청 | 연장 요청 |
|
||||
| 2 | 취소 | 닫힌 요청 |
|
||||
| 3 | 업그레이드 적용 | 코드상 공통 표시 문구이나 실제 의미는 연장 적용 |
|
||||
|
||||
### `maestro_upgrade.Status`
|
||||
|
||||
| 값 | 기존 표시 | 의미 |
|
||||
|---:|---|---|
|
||||
| 1 | 요청 | 업그레이드 요청 |
|
||||
| 2 | 취소 | 닫힌 요청 |
|
||||
| 3 | 업그레이드 적용 | 업그레이드 적용 |
|
||||
|
||||
## 4. 로그인/관리자 인증 규칙
|
||||
|
||||
기존 흐름:
|
||||
|
||||
1. `login.html`에서 `admin_name`, `password`를 `server/admin/login.php`로 POST한다.
|
||||
2. `get_admin_id($adminName)`에서 `admin.Name = ?` 조건으로 관리자 존재 여부를 먼저 확인한다.
|
||||
3. `login($adminName, $password)`에서 `admin.Name = ? AND admin.Password = PASSWORD(?)` 조건으로 검증한다.
|
||||
4. 응답 데이터로 `adminID`, `accountType`, `activateStatus`를 반환한다.
|
||||
|
||||
실패 조건:
|
||||
|
||||
| 조건 | error_code | message |
|
||||
|---|---|---|
|
||||
| `Name`에 해당하는 관리자 없음 | `not_registered_id` | 등록되지 않은 서비스 관리자 아이디입니다. |
|
||||
| 비밀번호 검증 결과 없음 | `no_data` 또는 `wrong_password` | 로그인하지 못했습니다. / 비밀번호가 틀렸습니다. |
|
||||
| `ActivateStatus === 0` | `not_activated` | 서비스 관리자 계정이 아직 활성화되지 않았습니다. |
|
||||
|
||||
신규 구현 규칙:
|
||||
|
||||
- 기존 계정 호환을 위해 최초 구현은 MariaDB `PASSWORD()` 함수를 이용해 검증한다.
|
||||
- NextAuth 세션에는 최소 `adminID`, `accountType`, `activateStatus`를 포함한다.
|
||||
- `ActivateStatus = 0` 관리자는 로그인시키지 않는다.
|
||||
- `PASSWORD()`는 보안상 낡은 방식이므로, 해시 업그레이드는 별도 마이그레이션 과제로 분리한다.
|
||||
|
||||
## 5. 마에스트로 목록 조회 규칙
|
||||
|
||||
### 전체 목록
|
||||
|
||||
기존 서버: `server/admin/maestro_all_list.php`
|
||||
|
||||
검색어가 없을 때:
|
||||
|
||||
- 테이블: `maestro`
|
||||
- 컬럼: `MaestroID`, `Name`, `AccountType`, `ActivateStatus`, `AvailableActivateDateTime`, `AcceptClausesDateTime`, `PlayerCount`
|
||||
- 정렬: `MaestroID DESC`
|
||||
|
||||
검색어가 있을 때:
|
||||
|
||||
- 조건: `Name LIKE CONCAT('%', ?, '%')`
|
||||
- 정렬: `MaestroID DESC`
|
||||
- 주의: 기존 검색 쿼리는 `AvailableActivateDateTime`을 조회하지 않는데 화면은 해당 값을 사용한다. 신규 구현에서는 전체/검색 결과 모두 동일 컬럼을 반환해야 한다.
|
||||
|
||||
기존 화면 표시:
|
||||
|
||||
- 상태 텍스트는 `accountStatus(accountType, activateStatus)`로 계산한다.
|
||||
- `AvailableActivateDateTime`이 `0`으로 시작하면 빈 문자열로 표시한다.
|
||||
- 비활성화 버튼은 disabled 상태이며 동작하지 않는다.
|
||||
|
||||
신규 구현 규칙:
|
||||
|
||||
- `Name`, `Email`, `MaestroID` 검색을 지원하되 기존 기본 정렬은 `MaestroID DESC`로 시작한다.
|
||||
- 전체/검색 결과의 반환 컬럼 차이를 만들지 않는다.
|
||||
|
||||
### 미승인 등록 목록
|
||||
|
||||
기존 서버: `server/admin/maestro_registered_list.php`
|
||||
|
||||
- 테이블: `maestro`
|
||||
- 조건: `ActivateStatus = 0`
|
||||
- 검색 조건: `Name LIKE CONCAT('%', ?, '%') AND ActivateStatus = 0`
|
||||
- 컬럼: `MaestroID`, `Name`, `AccountType`, `AcceptClausesDateTime`
|
||||
- 정렬: `MaestroID DESC`
|
||||
|
||||
## 6. 등록/활성화 처리 규칙
|
||||
|
||||
기존 서버: `server/admin/register_maestro.php`
|
||||
|
||||
처리 순서:
|
||||
|
||||
1. `maestro.ActivateStatus = 1`로 변경한다.
|
||||
2. `maestro.AvailableActivateDateTime = '2022-07-31 23:59:59'`로 변경한다.
|
||||
3. `player`에 테스트 플레이어 `Name='마에스트로'`, `EnterCode=''`, `AccountType=1`을 추가한다.
|
||||
4. 추가된 테스트 플레이어를 `maestro.MaestroTestID`에 저장한다.
|
||||
5. `app.Status = 1`인 앱을 모두 `active_app`에 추가한다.
|
||||
6. 샘플 학생 4명을 `player.AccountType = 0`으로 추가한다.
|
||||
7. `player.AccountType = 0` 학생 수를 세어 `maestro.PlayerCount`에 저장한다.
|
||||
8. 등록 완료 메일을 발송한다.
|
||||
9. `maestro_log`에 `register_maestro` 로그를 기록한다.
|
||||
|
||||
주의:
|
||||
|
||||
- 코드에는 `DATE_ADD(NOW(), INTERVAL 1 YEAR)`와 `DATE_ADD(NOW(), INTERVAL 14 DAY)`가 주석으로 남아 있지만 실제 실행 쿼리는 고정일 `2022-07-31 23:59:59`다.
|
||||
- 현재 MVP의 핵심 변경 기능은 연장/업그레이드이므로 등록 승인은 별도 범위로 둘 수 있다.
|
||||
|
||||
## 7. 연장 신청 조회/승인 규칙
|
||||
|
||||
### 요청 생성
|
||||
|
||||
기존 서버: `server/maestro/request_extension_maestro.php`
|
||||
|
||||
- `maestro_extension`에 `AccountType`, `RequestedDateTime = NOW()`, `Status = 1`, `MaestroID`를 INSERT한다.
|
||||
- 같은 마에스트로의 기존 요청 중복 여부는 검사하지 않는다.
|
||||
- INSERT 후 `MaestroID` 기준 전체 연장 요청 수가 1개 이상인지 단순 확인한다.
|
||||
- 입금 안내 메일을 발송한다.
|
||||
|
||||
### 관리자 목록 조회
|
||||
|
||||
기존 서버: `server/admin/maestro_extension_list.php`
|
||||
|
||||
검색어가 없을 때:
|
||||
|
||||
- 테이블: `maestro_extension E`, `maestro M`
|
||||
- 조건: `E.Status < 2 AND E.MaestroID = M.MaestroID`
|
||||
- 컬럼: `E.MaestroExtensionID`, `E.MaestroID`, `M.Name`, `E.AccountType`, `E.RequestedDateTime`, `M.AvailableActivateDateTime`, `E.Status`
|
||||
- 정렬: `E.MaestroExtensionID DESC`
|
||||
|
||||
검색어가 있을 때:
|
||||
|
||||
- 조건: `M.Name LIKE CONCAT('%', ?, '%') AND E.MaestroID = M.MaestroID`
|
||||
- 주의: 검색 쿼리에는 `E.Status < 2` 조건이 없다. 신규 구현에서는 기본 정책에 맞게 상태 필터를 명시적으로 제공해야 한다.
|
||||
|
||||
### 관리자 승인
|
||||
|
||||
기존 화면: `admin/admin_extension_list.html`
|
||||
|
||||
- `availableActivateDateTime`의 날짜 부분을 기준으로 새 만료일을 계산한다.
|
||||
- 계산 유틸: `src/web/js/maestro_available_date.js`
|
||||
- 현재 만료일이 지났으면 오늘 기준 1년 후 날짜를 사용한다.
|
||||
- 현재 만료일이 지나지 않았으면 기존 만료일 기준 1년 후 날짜를 사용한다.
|
||||
- 서버에는 `new_available_date` 문자열을 POST한다.
|
||||
|
||||
기존 서버: `server/admin/extension_maestro.php`
|
||||
|
||||
처리 순서:
|
||||
|
||||
1. `maestro.ActivateStatus = 2`, `maestro.AvailableActivateDateTime = DATE_FORMAT(?, '%Y-%m-%d')`로 변경한다.
|
||||
2. 같은 `MaestroID`의 `maestro_extension.Status = 1` 요청을 모두 `Status = 2`로 변경한다.
|
||||
3. 선택한 `MaestroExtensionID` 요청을 `Status = 3`으로 변경한다.
|
||||
4. 연장 완료 메일을 발송한다.
|
||||
5. `maestro_log`에 `extension_maestro` 로그를 기록한다.
|
||||
|
||||
로그:
|
||||
|
||||
- `Type`: `extension_maestro`
|
||||
- `Remark`: `{maestro_name}({registered_account_type})`
|
||||
|
||||
신규 구현 규칙:
|
||||
|
||||
- 날짜 계산은 클라이언트가 아니라 서버에서 수행한다.
|
||||
- 기존 규칙과 동일하게 만료 전이면 기존 만료일 기준 +1년, 만료 후면 현재일 기준 +1년으로 계산한다.
|
||||
- `AvailableActivateDateTime` 저장 시 기존 서버는 날짜만 저장하므로 시간은 DB 기본 파싱 결과를 고려해야 한다. 신규 구현에서는 일관성을 위해 `YYYY-MM-DD 00:00:00` 또는 기존 호환 형식을 명시한다.
|
||||
- 승인 전 선택한 요청이 `Status = 1`인지 확인한다.
|
||||
- 트랜잭션 안에서 `maestro`, `maestro_extension`, `maestro_log`를 함께 처리한다.
|
||||
- 같은 마에스트로의 다른 `Status = 1` 요청을 `Status = 2`로 닫은 뒤 선택 요청을 `Status = 3`으로 적용하는 기존 동작을 재현한다.
|
||||
|
||||
## 8. 업그레이드 신청 조회/승인 규칙
|
||||
|
||||
### 요청 생성
|
||||
|
||||
기존 서버: `server/maestro/request_upgrade_maestro.php`
|
||||
|
||||
- `maestro_upgrade`에 `RegisteredActivateStatus`, `RegisteredAccountType`, `RequestedAccountType`, `RequestedDateTime = NOW()`, `Status = 1`, `MaestroID`를 INSERT한다.
|
||||
- 같은 마에스트로의 기존 요청 중복 여부는 검사하지 않는다.
|
||||
- INSERT 후 `MaestroID` 기준 전체 업그레이드 요청 수가 1개 이상인지 단순 확인한다.
|
||||
- 체험 계정이면 체험 업그레이드 입금 안내 메일, 그 외에는 일반 업그레이드 입금 안내 메일을 발송한다.
|
||||
|
||||
### 관리자 목록 조회
|
||||
|
||||
기존 서버: `server/admin/maestro_upgrade_list.php`
|
||||
|
||||
검색어가 없을 때:
|
||||
|
||||
- 테이블: `maestro_upgrade U`, `maestro M`
|
||||
- 조건: `U.Status < 2 AND U.MaestroID = M.MaestroID`
|
||||
- 컬럼: `U.MaestroUpgradeID`, `U.MaestroID`, `M.Name`, `U.RegisteredActivateStatus`, `U.RegisteredAccountType`, `U.RequestedAccountType`, `U.RequestedDateTime`, `M.AvailableActivateDateTime`, `U.Status`
|
||||
- 정렬: `U.MaestroUpgradeID DESC`
|
||||
|
||||
검색어가 있을 때:
|
||||
|
||||
- 조건: `M.Name LIKE CONCAT('%', ?, '%') AND U.MaestroID = M.MaestroID`
|
||||
- 주의: 검색 쿼리에는 `U.Status < 2` 조건이 없다. 신규 구현에서는 상태 필터를 명시적으로 제공해야 한다.
|
||||
|
||||
### 관리자 승인
|
||||
|
||||
기존 화면: `admin/admin_upgrade_list.html`
|
||||
|
||||
- `Status = 1`인 요청만 승인 버튼을 표시한다.
|
||||
- 체험 계정(`RegisteredActivateStatus = 1`)은 기존 요금제를 `trialAccountType`으로 표시하고 추가 금액은 요청 요금제 전체 금액으로 계산한다.
|
||||
- 그 외 계정은 추가 금액을 `RequestedAccountType` 금액 - `RegisteredAccountType` 금액으로 계산한다.
|
||||
|
||||
기존 서버: `server/admin/upgrade_maestro.php`
|
||||
|
||||
처리 순서:
|
||||
|
||||
1. `maestro.AccountType = upgrade_account_type`으로 변경한다.
|
||||
2. `maestro.ActivateStatus = 2`로 변경한다.
|
||||
3. `maestro.AvailableActivateDateTime = DATE_ADD(NOW(), INTERVAL 1 YEAR)`로 변경한다.
|
||||
4. 같은 `MaestroID`의 `maestro_upgrade.Status = 1` 요청을 모두 `Status = 2`로 변경한다.
|
||||
5. 선택한 `MaestroUpgradeID` 요청을 `Status = 3`으로 변경한다.
|
||||
6. 체험 계정이면 체험 업그레이드 완료 메일, 그 외에는 일반 업그레이드 완료 메일을 발송한다.
|
||||
7. `maestro_log`에 `upgrade_maestro` 로그를 기록한다.
|
||||
|
||||
로그:
|
||||
|
||||
- `Type`: `upgrade_maestro`
|
||||
- `Remark`: `{registered_account_type} -> {upgrade_account_type}`
|
||||
|
||||
확정 사항:
|
||||
|
||||
- `PlayerCount`는 변경하지 않는다.
|
||||
- `AvailableActivateDateTime`은 기존 만료일 기준이 아니라 승인 시점 `NOW()` 기준 1년 후로 설정한다.
|
||||
|
||||
신규 구현 규칙:
|
||||
|
||||
- 승인 전 선택한 요청이 `Status = 1`인지 확인한다.
|
||||
- 트랜잭션 안에서 `maestro`, `maestro_upgrade`, `maestro_log`를 함께 처리한다.
|
||||
- 같은 마에스트로의 다른 `Status = 1` 요청을 `Status = 2`로 닫은 뒤 선택 요청을 `Status = 3`으로 적용하는 기존 동작을 재현한다.
|
||||
- `PlayerCount`는 업그레이드 승인에서 변경하지 않는다.
|
||||
|
||||
## 9. 취소/거절 규칙
|
||||
|
||||
기존 관리자 UI/서버에서 확인된 사실:
|
||||
|
||||
- 연장 신청에 대한 별도 취소 버튼이나 취소 API는 없다.
|
||||
- 업그레이드 신청에 대한 별도 거절 버튼이나 거절 API는 없다.
|
||||
- `Status = 2`는 승인 처리 중 같은 마에스트로의 다른 미처리 요청을 닫기 위해 사용된다.
|
||||
- 화면 유틸 `status()`는 `Status = 2`를 "취소"로 표시하지만, 관리자가 명시적으로 취소/거절하는 흐름은 구현되어 있지 않다.
|
||||
|
||||
신규 구현 판단:
|
||||
|
||||
- `docs/plan/project-plan.md`의 취소/거절 기능은 기존 관리자 재현이 아니라 신규 보완 기능이다.
|
||||
- 취소/거절 API를 만들 경우 `Status = 1`인 단일 요청만 `Status = 2`로 변경하고 `maestro_log`에 별도 로그를 남기는 방식으로 정의해야 한다.
|
||||
- 기존 `maestro_log.Type`에는 취소/거절 전용 타입이 없으므로 신규 타입을 도입할지 기존 타입에 `Remark`로 표현할지 결정이 필요하다.
|
||||
|
||||
권장 신규 로그 타입:
|
||||
|
||||
- `cancel_extension_maestro`
|
||||
- `reject_upgrade_maestro`
|
||||
|
||||
## 10. `maestro_log` 기록 규칙
|
||||
|
||||
공통 함수: `src/web/server/lib/maestro_log.php`
|
||||
|
||||
```sql
|
||||
INSERT INTO maestro_log (Type, MaestroID, LogDateTime, Remark)
|
||||
VALUES (?, ?, NOW(), ?)
|
||||
```
|
||||
|
||||
확인된 로그 타입:
|
||||
|
||||
| Type | 발생 위치 | Remark 형식 |
|
||||
|---|---|---|
|
||||
| `add_maestro` | `server/maestro/add_maestro.php` | `{maestro_name} / {email} / {account_type}` |
|
||||
| `register_maestro` | `server/admin/register_maestro.php` | `{name} / {email}` |
|
||||
| `extension_maestro` | `server/admin/extension_maestro.php` | `{maestro_name}({registered_account_type})` |
|
||||
| `upgrade_maestro` | `server/admin/upgrade_maestro.php` | `{registered_account_type} -> {upgrade_account_type}` |
|
||||
| `update_maestro_name` | `server/maestro/update_maestro_info.php` | `{previous_name} -> {new_name}` |
|
||||
| `update_maestro_email` | `server/maestro/update_maestro_info.php` | `{previous_email} -> {new_email}` |
|
||||
|
||||
확인 결과:
|
||||
|
||||
- `update_maestro` 타입은 코드 검색에서 확인되지 않았다.
|
||||
- 로그에는 관리자 ID나 관리자 이름이 포함되지 않는다.
|
||||
- 로그 시간은 DB 서버의 `NOW()`를 사용한다.
|
||||
- `Remark` 컬럼은 `char(100)`이므로 신규 구현에서도 길이 초과에 주의한다.
|
||||
|
||||
## 11. 신규 `chocoadmin` 구현 시 반드시 지켜야 할 규칙
|
||||
|
||||
- 운영 DB 스키마는 변경하지 않는다.
|
||||
- 관리자 로그인은 기존 `admin.Name`과 `admin.Password = PASSWORD(?)` 호환 검증을 지원한다.
|
||||
- 모든 변경 API는 서버에서 현재 상태를 다시 조회하고 `Status = 1`인지 확인한다.
|
||||
- 승인/취소/거절 같은 변경 작업은 트랜잭션으로 묶는다.
|
||||
- 연장 승인은 서버에서 새 만료일을 계산한다.
|
||||
- 업그레이드 승인은 `PlayerCount`를 변경하지 않는다.
|
||||
- 연장/업그레이드 승인 시 같은 마에스트로의 다른 요청을 `Status = 2`로 닫고 선택 요청을 `Status = 3`으로 적용한다.
|
||||
- 기존 목록 검색 쿼리의 상태 필터 누락은 그대로 복제하지 말고 명시적 필터로 보완한다.
|
||||
- 메일 발송은 트랜잭션 커밋 이후 수행하는 것이 안전하다. 기존 코드는 DB 변경 후 메일을 발송한다.
|
||||
|
||||
## 12. 기존 코드에는 없지만 신규 구현에서 보완할 사항
|
||||
|
||||
- 명시적 트랜잭션: 기존 관리자 변경 로직에는 `BEGIN`, `COMMIT`, `ROLLBACK`이 없다.
|
||||
- 중복 처리 방지: 기존 승인 서버는 요청의 현재 `Status`를 먼저 검증하지 않는다.
|
||||
- 서버 사이드 날짜 계산: 기존 연장 승인은 클라이언트 계산값을 신뢰한다.
|
||||
- 검색 필터 일관성: 연장/업그레이드 검색 쿼리에 `Status < 2` 조건이 빠져 있다.
|
||||
- 목록 컬럼 일관성: 전체 마에스트로 검색 쿼리에 `AvailableActivateDateTime`이 빠져 있다.
|
||||
- 취소/거절 정의: 기존에는 별도 액션이 없으므로 신규 기능으로 명세화해야 한다.
|
||||
- 감사성 강화: 기존 `maestro_log`에는 관리자 식별자가 없으므로 세션 기반 감사 로그가 필요하면 별도 설계가 필요하다. 단, DB 스키마 변경 없이 구현하려면 `Remark`에 관리자 정보를 포함하는 방식만 가능하다.
|
||||
@@ -0,0 +1,76 @@
|
||||
# chocoadmin Deployment
|
||||
|
||||
## 1. Production Environment
|
||||
|
||||
Create `.env.production` on the production host. Do not commit it.
|
||||
|
||||
```bash
|
||||
DATABASE_URL="mysql://CHCOCO_ADMIN_USER:PASSWORD@AWS_EC2_HOST:3306/chocomae"
|
||||
AUTH_SECRET="replace-with-production-secret"
|
||||
NEXTAUTH_SECRET="replace-with-production-secret"
|
||||
NEXTAUTH_URL="http://NAS_HOST_OR_DOMAIN:3001"
|
||||
```
|
||||
|
||||
Create `.env.stage` on the stage host with the same keys and stage-specific values.
|
||||
|
||||
`AUTH_SECRET` and `NEXTAUTH_SECRET` should use the same strong random value per environment for Auth.js compatibility.
|
||||
|
||||
## 2. Synology Container Manager
|
||||
|
||||
1. Copy the repository to the NAS.
|
||||
2. Place `.env.production` in the repository root on the NAS.
|
||||
3. In Container Manager, create a project from `docker-compose.yml`.
|
||||
4. Build and start the project.
|
||||
5. Open `http://NAS_HOST_OR_DOMAIN:3001/login`.
|
||||
|
||||
Equivalent CLI command:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
The Docker build uses placeholder build-time environment variables only so Next.js can compile without committing secrets. Runtime values are read from `.env.production` by default.
|
||||
|
||||
To run with stage settings:
|
||||
|
||||
```bash
|
||||
ENV_FILE=.env.stage docker compose up -d --build
|
||||
```
|
||||
|
||||
## 3. AWS EC2 Security Group
|
||||
|
||||
Allow MariaDB only from the Synology NAS public IP.
|
||||
|
||||
- Type: `MYSQL/Aurora`
|
||||
- Protocol: `TCP`
|
||||
- Port: `3306`
|
||||
- Source: `NAS_PUBLIC_IP/32`
|
||||
- Description: `chocoadmin Synology NAS`
|
||||
|
||||
Do not open `3306` to `0.0.0.0/0`.
|
||||
|
||||
## 4. Read-Only Rehearsal
|
||||
|
||||
Before enabling approval/rejection operations against production DB:
|
||||
|
||||
1. Create or use a DB account with read-only permissions.
|
||||
2. Set `DATABASE_URL` in `.env.production` to that read-only account.
|
||||
3. Start the container.
|
||||
4. Verify login, maestro list, extension request list, and upgrade request list.
|
||||
5. Switch to the production write-capable chocoadmin DB account only after read screens work.
|
||||
|
||||
## 5. Smoke Checks
|
||||
|
||||
After container start:
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
docker compose logs -f chocoadmin
|
||||
```
|
||||
|
||||
Verify these routes in the browser:
|
||||
|
||||
- `/login`
|
||||
- `/maestros`
|
||||
- `/extension-requests`
|
||||
- `/upgrade-requests`
|
||||
@@ -0,0 +1,285 @@
|
||||
# Phase 0 작업 계획
|
||||
|
||||
## 1. 목적
|
||||
|
||||
`docs/plan/project-plan.md`의 `9. 개발 단계 계획 > Phase 0. 기존 관리자 코드 조사`를 수행하기 위한 실행 계획이다.
|
||||
|
||||
Phase 0의 목표는 기존 `mouse-typing` 관리자 기능의 비즈니스 규칙을 확인하고, 신규 `chocoadmin`에서 동일하게 재현해야 할 규칙을 `docs/business-rules.md`에 문서화하는 것이다.
|
||||
|
||||
## 2. 조사 대상
|
||||
|
||||
기존 서비스 경로:
|
||||
|
||||
- `/Users/jisangs/work/jinaju/git/web/mouse-typing`
|
||||
|
||||
우선 조사 디렉터리:
|
||||
|
||||
- `/Users/jisangs/work/jinaju/git/web/mouse-typing/src/web/admin/`
|
||||
- `/Users/jisangs/work/jinaju/git/web/mouse-typing/src/web/server/admin/`
|
||||
|
||||
주요 서버 파일:
|
||||
|
||||
- `login.php`
|
||||
- `maestro_all_list.php`
|
||||
- `maestro_registered_list.php`
|
||||
- `maestro_extension_list.php`
|
||||
- `extension_maestro.php`
|
||||
- `maestro_upgrade_list.php`
|
||||
- `upgrade_maestro.php`
|
||||
- `register_maestro.php`
|
||||
- `activate_stage.php`
|
||||
|
||||
주요 화면 파일:
|
||||
|
||||
- `login.html`
|
||||
- `admin_all_list.html`
|
||||
- `admin_register_list.html`
|
||||
- `admin_extension_list.html`
|
||||
- `admin_upgrade_list.html`
|
||||
- `admin_expiration_list.html`
|
||||
- `admin_header.html`
|
||||
|
||||
## 3. 확인해야 할 핵심 질문
|
||||
|
||||
### 3-1. 관리자 라우트와 기능 구조
|
||||
|
||||
- 관리자 화면별 HTML 파일과 서버 PHP 파일의 호출 관계를 확인한다.
|
||||
- 각 화면의 목록 조회 조건, 검색 조건, 정렬 기준, 상태 필터를 확인한다.
|
||||
- 신규 `chocoadmin`의 화면/API 구조와 직접 매핑 가능한 기능 목록을 정리한다.
|
||||
|
||||
산출물:
|
||||
|
||||
- 기존 화면 -> 기존 서버 파일 -> 신규 화면/API 매핑표
|
||||
|
||||
### 3-2. 연장 승인 규칙
|
||||
|
||||
- `extension_maestro.php`에서 연장 승인 처리 흐름을 확인한다.
|
||||
- `maestro_extension.Status` 변경값과 변경 조건을 확인한다.
|
||||
- `maestro.AvailableActivateDateTime` 계산 방식을 확인한다.
|
||||
- `maestro.ActivateStatus` 변경 여부와 값을 확인한다.
|
||||
- 실패/중복 처리 조건이 있는지 확인한다.
|
||||
|
||||
현재 `docs/plan/project-plan.md`에 기록된 가정:
|
||||
|
||||
- 계정 유형별 차이 없이 1년 연장
|
||||
|
||||
검증 기준:
|
||||
|
||||
- 기존 코드에서 날짜 계산 로직이 `+1 year`, `+12 month`, 고정 일수 중 무엇인지 확인한다.
|
||||
- 만료일이 과거인 경우 기준일이 현재일인지 기존 만료일인지 확인한다.
|
||||
|
||||
### 3-3. 업그레이드 승인 규칙
|
||||
|
||||
- `upgrade_maestro.php`에서 업그레이드 승인 처리 흐름을 확인한다.
|
||||
- `maestro_upgrade.Status` 변경값과 변경 조건을 확인한다.
|
||||
- `maestro.AccountType` 변경 기준을 확인한다.
|
||||
- `maestro.PlayerCount`를 즉시 변경하는지 확인한다.
|
||||
- 거절 처리 시 변경되는 테이블과 로그를 확인한다.
|
||||
|
||||
현재 `docs/plan/project-plan.md`에 기록된 가정:
|
||||
|
||||
- `AccountType`만 변경
|
||||
- `PlayerCount`는 변경하지 않음
|
||||
|
||||
검증 기준:
|
||||
|
||||
- 승인 SQL에 `PlayerCount` 업데이트가 포함되는지 확인한다.
|
||||
- 목록 조회 시 `RegisteredAccountType`, `RequestedAccountType`을 어떻게 표시하는지 확인한다.
|
||||
|
||||
### 3-4. 관리자 비밀번호 검증 방식
|
||||
|
||||
- `login.php`에서 `admin.Password` 검증 SQL을 확인한다.
|
||||
- MariaDB `PASSWORD()` 함수 사용 여부를 확인한다.
|
||||
- 세션에 저장되는 관리자 식별자와 권한 정보를 확인한다.
|
||||
- 실패 응답 형식과 비활성 관리자 처리 여부를 확인한다.
|
||||
|
||||
현재 `docs/plan/project-plan.md`에 기록된 가정:
|
||||
|
||||
- MariaDB `PASSWORD()` 함수로 저장/검증
|
||||
|
||||
검증 기준:
|
||||
|
||||
- `WHERE Password = PASSWORD(?)` 형태인지 확인한다.
|
||||
- `ActivateStatus`, `AccountType` 같은 추가 조건이 있는지 확인한다.
|
||||
|
||||
### 3-5. `maestro_log` 기록 형식
|
||||
|
||||
- 관리자 변경 액션이 `maestro_log`에 기록되는 모든 지점을 찾는다.
|
||||
- `Type` 값 목록을 확인한다.
|
||||
- `Remark` 문자열 형식과 포함 정보, 날짜 형식을 확인한다.
|
||||
- 로그 작성 시 관리자 ID/이름이 포함되는지 확인한다.
|
||||
|
||||
현재 `docs/plan/project-plan.md`에 기록된 `Type` 후보:
|
||||
|
||||
- `add_maestro`
|
||||
- `update_maestro`
|
||||
- `register_maestro`
|
||||
- `update_maestro_name`
|
||||
- `extension_maestro`
|
||||
- `upgrade_maestro`
|
||||
- `update_maestro_email`
|
||||
|
||||
검증 기준:
|
||||
|
||||
- 중복 기재된 값과 실제 미사용 값을 정리한다.
|
||||
- 각 액션별 `Remark` 예시를 코드 기준으로 문서화한다.
|
||||
|
||||
## 4. 작업 순서
|
||||
|
||||
### Step 1. 파일 인벤토리 작성
|
||||
|
||||
- 관리자 HTML/PHP 파일 목록을 확정한다.
|
||||
- 각 파일의 역할을 한 줄로 요약한다.
|
||||
- AJAX 호출 URL, form action, query parameter를 찾는다.
|
||||
|
||||
완료 기준:
|
||||
|
||||
- `docs/business-rules.md`에 "기존 관리자 파일 맵" 섹션 초안 작성
|
||||
|
||||
### Step 2. 로그인 로직 조사
|
||||
|
||||
- `login.html`과 `login.php`를 연결해 인증 흐름을 추적한다.
|
||||
- DB 조회 조건, 비밀번호 검증 방식, 세션 키를 기록한다.
|
||||
- 신규 NextAuth Credentials Provider에서 재현해야 할 조건을 정리한다.
|
||||
|
||||
완료 기준:
|
||||
|
||||
- `admin.Password` 검증 방식 확정
|
||||
- 로그인 성공/실패 조건 문서화
|
||||
|
||||
### Step 3. 목록 조회 로직 조사
|
||||
|
||||
- 전체 마에스트로 목록, 등록 신청 목록, 연장 신청 목록, 업그레이드 신청 목록 SQL을 확인한다.
|
||||
- 조인 테이블, 상태 필터, 기본 정렬, 표시 컬럼을 정리한다.
|
||||
- 신규 API 쿼리 설계에 필요한 컬럼 목록을 분리한다.
|
||||
|
||||
완료 기준:
|
||||
|
||||
- 신규 목록 API별 필수 SQL 조건 문서화
|
||||
|
||||
### Step 4. 등록/활성화 처리 조사
|
||||
|
||||
- `register_maestro.php`와 `activate_stage.php`의 상태 변경 로직을 확인한다.
|
||||
- `maestro.ActivateStatus`, `AvailableActivateDateTime`, `maestro_log` 처리 규칙을 기록한다.
|
||||
- MVP 범위에 직접 포함되지 않더라도 상세 화면과 로그 해석에 필요한 규칙을 정리한다.
|
||||
|
||||
완료 기준:
|
||||
|
||||
- 마에스트로 상태값 변경 규칙 문서화
|
||||
|
||||
### Step 5. 연장 승인/취소 처리 조사
|
||||
|
||||
- `maestro_extension_list.php`에서 목록 조회 조건을 확인한다.
|
||||
- `extension_maestro.php`에서 승인/취소 처리 SQL을 순서대로 기록한다.
|
||||
- 트랜잭션 사용 여부와 중복 처리 방지 조건을 확인한다.
|
||||
- 신규 구현에서 보완해야 할 원자성/중복 방지 항목을 별도로 표시한다.
|
||||
|
||||
완료 기준:
|
||||
|
||||
- 연장 승인 시 날짜 계산 기준 확정
|
||||
- 연장 취소 시 상태값과 로그 형식 확정
|
||||
|
||||
### Step 6. 업그레이드 승인/거절 처리 조사
|
||||
|
||||
- `maestro_upgrade_list.php`에서 목록 조회 조건을 확인한다.
|
||||
- `upgrade_maestro.php`에서 승인/거절 처리 SQL을 순서대로 기록한다.
|
||||
- `AccountType`, `PlayerCount`, `maestro_upgrade.Status`, `maestro_log` 변경 규칙을 확인한다.
|
||||
- 신규 구현에서 보완해야 할 원자성/중복 방지 항목을 별도로 표시한다.
|
||||
|
||||
완료 기준:
|
||||
|
||||
- 업그레이드 승인 시 변경 컬럼 확정
|
||||
- 업그레이드 거절 시 상태값과 로그 형식 확정
|
||||
|
||||
### Step 7. 비즈니스 규칙 문서 작성
|
||||
|
||||
`docs/business-rules.md`를 생성하고 다음 구조로 정리한다.
|
||||
|
||||
- 개요
|
||||
- 기존 관리자 파일 맵
|
||||
- 상태값 코드표
|
||||
- 로그인/관리자 인증 규칙
|
||||
- 마에스트로 목록 조회 규칙
|
||||
- 등록/활성화 처리 규칙
|
||||
- 연장 신청 조회/승인/취소 규칙
|
||||
- 업그레이드 신청 조회/승인/거절 규칙
|
||||
- `maestro_log` 기록 규칙
|
||||
- 신규 `chocoadmin` 구현 시 반드시 지켜야 할 규칙
|
||||
- 기존 코드에는 없지만 신규 구현에서 보완할 사항
|
||||
|
||||
완료 기준:
|
||||
|
||||
- Phase 1 이후 구현자가 기존 PHP 코드를 다시 열지 않아도 핵심 규칙을 확인할 수 있어야 한다.
|
||||
|
||||
## 5. 조사 방법
|
||||
|
||||
권장 검색 명령:
|
||||
|
||||
```bash
|
||||
rg -n "maestro_log|extension_maestro|upgrade_maestro|PASSWORD|AvailableActivateDateTime|PlayerCount|ActivateStatus|AccountType|Status" /Users/jisangs/work/jinaju/git/web/mouse-typing/src/web/server/admin
|
||||
```
|
||||
|
||||
파일별 상세 확인:
|
||||
|
||||
```bash
|
||||
sed -n '1,240p' /Users/jisangs/work/jinaju/git/web/mouse-typing/src/web/server/admin/login.php
|
||||
sed -n '1,260p' /Users/jisangs/work/jinaju/git/web/mouse-typing/src/web/server/admin/extension_maestro.php
|
||||
sed -n '1,260p' /Users/jisangs/work/jinaju/git/web/mouse-typing/src/web/server/admin/upgrade_maestro.php
|
||||
```
|
||||
|
||||
HTML 호출부 확인:
|
||||
|
||||
```bash
|
||||
rg -n "ajax|fetch|XMLHttpRequest|form|server/admin|extension_maestro|upgrade_maestro|login.php" /Users/jisangs/work/jinaju/git/web/mouse-typing/src/web/admin
|
||||
```
|
||||
|
||||
## 6. 완료 산출물
|
||||
|
||||
Phase 0 완료 시 저장소에 다음 문서가 있어야 한다.
|
||||
|
||||
- `docs/business-rules.md`
|
||||
- 기존 관리자 비즈니스 규칙의 최종 문서
|
||||
- `docs/phase/phase0.md`
|
||||
- Phase 0 수행 계획 문서
|
||||
|
||||
필요하면 다음 보조 자료를 `docs/` 아래에 추가한다.
|
||||
|
||||
- `docs/admin-file-map.md`
|
||||
- 파일 매핑이 길어질 경우 분리
|
||||
- `docs/sql-notes.md`
|
||||
- 기존 SQL 원문 기록이 길어질 경우 분리
|
||||
|
||||
## 7. 완료 체크리스트
|
||||
|
||||
- [x] `src/web/admin/` 화면 파일과 `src/web/server/admin/` 처리 파일의 연결 관계를 정리했다.
|
||||
- [x] 연장 승인 시 `AvailableActivateDateTime` 계산 방식을 코드 기준으로 확정했다.
|
||||
- [x] 연장 승인/취소 시 변경되는 테이블, 컬럼, 상태값, 로그 형식을 정리했다.
|
||||
- [x] 업그레이드 승인 시 `PlayerCount` 갱신 여부를 코드 기준으로 확정했다.
|
||||
- [x] 업그레이드 승인/거절 시 변경되는 테이블, 컬럼, 상태값, 로그 형식을 정리했다.
|
||||
- [x] `admin.Password` 저장/검증 방식을 코드 기준으로 확정했다.
|
||||
- [x] `maestro_log.Type` 값과 `Remark` 형식을 액션별로 정리했다.
|
||||
- [x] 기존 코드에 트랜잭션/중복 처리 방지가 부족한 지점을 신규 구현 보완사항으로 표시했다.
|
||||
- [x] 조사 결과를 `docs/business-rules.md`에 문서화했다.
|
||||
- [x] `docs/plan/project-plan.md`의 Phase 0 체크리스트를 완료 처리할 수 있는 상태가 되었다.
|
||||
|
||||
## 8. 리스크와 주의사항
|
||||
|
||||
- 기존 PHP 코드가 트랜잭션 없이 여러 SQL을 순차 실행할 수 있다. 신규 서비스는 기존 결과는 동일하게 유지하되, 변경 액션은 트랜잭션으로 보완한다.
|
||||
- 기존 코드에 중복 처리 방지 조건이 없더라도 신규 API는 `Status = 1` 조건 확인 후 변경해야 한다.
|
||||
- MariaDB `PASSWORD()` 함수는 최신 인증용 해시로 적합하지 않다. Phase 0에서는 기존 검증 방식을 정확히 재현하고, 해시 업그레이드는 별도 후속 과제로 분리한다.
|
||||
- 운영 DB 스키마는 임의 변경하지 않는다.
|
||||
- 문서화 시 추측과 코드로 확인된 사실을 구분해 적는다.
|
||||
|
||||
## 9. 예상 일정
|
||||
|
||||
| 순서 | 작업 | 예상 소요 |
|
||||
|---|---|---:|
|
||||
| 1 | 파일 인벤토리와 호출 관계 정리 | 30분 |
|
||||
| 2 | 로그인/인증 로직 조사 | 20분 |
|
||||
| 3 | 목록 조회 SQL 조사 | 40분 |
|
||||
| 4 | 등록/활성화 처리 조사 | 30분 |
|
||||
| 5 | 연장 승인/취소 처리 조사 | 40분 |
|
||||
| 6 | 업그레이드 승인/거절 처리 조사 | 40분 |
|
||||
| 7 | `docs/business-rules.md` 작성 및 검토 | 60분 |
|
||||
|
||||
총 예상 소요: 약 4시간
|
||||
@@ -0,0 +1,339 @@
|
||||
# chocoadmin 프로젝트 계획서
|
||||
|
||||
## 1. 프로젝트 개요
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 서비스명 | chocoadmin |
|
||||
| 목적 | chocomae 웹 서비스(mouse-typing)의 관리자 기능을 독립 웹 서비스로 분리 |
|
||||
| 기존 서비스 경로 | `/Users/jisangs/work/jinaju/git/web/mouse-typing` |
|
||||
| 신규 서비스 경로 | `/Users/jisangs/work/jinaju/git/web/chocoadmin` |
|
||||
| 사용 DB | 기존 chocomae DB (MariaDB 10.11.13) 그대로 사용 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 기술 스택
|
||||
|
||||
| 구분 | 기술 |
|
||||
|---|---|
|
||||
| 런타임 | Node.js (LTS 버전) |
|
||||
| 프레임워크 | Next.js 15 (App Router) |
|
||||
| 언어 | TypeScript |
|
||||
| DB 클라이언트 | `mysql2` (MariaDB 호환) |
|
||||
| 인증 | NextAuth.js v5 (기존 `admin` 테이블 활용) |
|
||||
| 환경변수 관리 | `.env.local` / `.env.production` |
|
||||
|
||||
---
|
||||
|
||||
## 3. CSS Framework 추천
|
||||
|
||||
관리자 화면은 주로 표(Table)와 폼(Form) 중심이므로, 빠른 개발과 유지보수성을 모두 고려한 3가지를 추천합니다.
|
||||
|
||||
### 3-1. Tailwind CSS + shadcn/ui ⭐ 추천
|
||||
|
||||
- **Tailwind CSS**: 유틸리티 클래스 기반으로 CSS 파일을 직접 작성할 일이 거의 없음
|
||||
- **shadcn/ui**: Tailwind 기반의 복사-붙여넣기형 컴포넌트 라이브러리. Button, Table, Dialog, Form 등 관리자 UI에 필요한 컴포넌트가 이미 갖춰져 있음
|
||||
- **장점**: Next.js App Router와 궁합이 뛰어남. 번들 사이즈가 작고, 컴포넌트 코드를 직접 소유하므로 커스터마이징이 자유로움
|
||||
- **단점**: 초기 세팅이 다소 필요함
|
||||
|
||||
### 3-2. Ant Design (antd)
|
||||
|
||||
- React 생태계에서 오랫동안 검증된 전통적인 관리자 UI 라이브러리
|
||||
- Table, Form, Modal, DatePicker 등 관리자 페이지에 필요한 컴포넌트가 모두 내장되어 있음
|
||||
- **장점**: 별도 CSS 작업 없이 완성도 높은 UI를 빠르게 구성 가능
|
||||
- **단점**: 번들 사이즈가 크고, 기본 디자인 톤에서 벗어나는 커스터마이징이 어려움
|
||||
|
||||
### 3-3. Mantine
|
||||
|
||||
- shadcn/ui보다 더 완결된 형태의 컴포넌트 세트 제공 (100개 이상)
|
||||
- 자체 훅(hook) 라이브러리도 포함되어 있어 복잡한 상태 관리 없이 폼·테이블 구현 가능
|
||||
- **장점**: 문서가 잘 정리되어 있고 TypeScript 지원이 뛰어남
|
||||
- **단점**: antd처럼 디자인 자유도가 shadcn/ui보다 낮음
|
||||
|
||||
> **최종 추천: Tailwind CSS + shadcn/ui**
|
||||
> Next.js 공식 생태계와 가장 잘 맞고, 장기적인 유지보수 측면에서 가장 유리합니다.
|
||||
|
||||
---
|
||||
|
||||
## 4. Table 라이브러리 추천
|
||||
|
||||
관리자 화면은 목록 조회, 검색, 정렬, 페이지네이션, 인라인 액션 버튼이 핵심이므로 아래 3가지를 추천합니다.
|
||||
|
||||
### 4-1. TanStack Table (React Table v8) ⭐ 추천
|
||||
|
||||
- Headless 방식(스타일 미포함)으로, Tailwind/shadcn과 조합해 완전한 커스터마이징 가능
|
||||
- 정렬, 필터, 페이지네이션, 행 선택, 컬럼 고정 등 필요한 기능을 필요한 만큼만 추가
|
||||
- **shadcn/ui DataTable**: TanStack Table + shadcn 조합의 공식 예제가 제공되어 빠른 시작 가능
|
||||
- **장점**: 번들 사이즈 최소, 자유도 최대, Next.js App Router 완벽 호환
|
||||
- **단점**: 기능을 직접 조립해야 하므로 초기 설정 비용이 있음
|
||||
|
||||
### 4-2. AG Grid (Community Edition)
|
||||
|
||||
- 대용량 데이터에 특화된 고성능 그리드 라이브러리
|
||||
- 정렬, 필터, 그룹핑, 엑셀 내보내기, 인라인 편집 등 풍부한 기능이 내장
|
||||
- 무료 Community Edition으로도 관리자 기능 구현에 충분
|
||||
- **장점**: 기능이 가장 풍부하고 성능이 뛰어남
|
||||
- **단점**: 번들 사이즈가 크고, 스타일 커스터마이징에 별도 CSS 작업이 필요
|
||||
|
||||
### 4-3. MUI DataGrid (Free tier)
|
||||
|
||||
- Material UI 기반의 데이터 그리드
|
||||
- 정렬, 필터, 페이지네이션, 컬럼 재배치가 내장
|
||||
- **장점**: Material UI 생태계와 자연스럽게 통합, 사용법이 직관적
|
||||
- **단점**: Tailwind와 함께 사용 시 스타일 충돌 이슈가 발생할 수 있음. 고급 기능은 Pro 유료 플랜 필요
|
||||
|
||||
> **최종 추천: TanStack Table + shadcn/ui DataTable**
|
||||
> shadcn/ui와 세트로 사용하면 코드 통일성이 높고, 필요한 기능만 붙일 수 있어 관리 부담이 적습니다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 개발 환경 및 배포 환경
|
||||
|
||||
### 5-1. 개발 환경
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 플랫폼 | Synology NAS (DS920+, 20GB RAM) - Docker |
|
||||
| DB (개발) | localhost MariaDB |
|
||||
| 실행 방법 | `docker compose up` |
|
||||
| 포트 | 3001 (기존 mouse-typing과 충돌 방지) |
|
||||
|
||||
### 5-2. 서비스 환경
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 플랫폼 | Synology NAS (DS920+) - Docker |
|
||||
| DB (서비스) | AWS EC2 MariaDB (chocomae 서비스와 동일 인스턴스) |
|
||||
| 접근 방식 | NAS에서 AWS EC2 DB에 직접 연결 (보안그룹 인바운드 규칙에 NAS 외부 IP 허용 필요) |
|
||||
|
||||
### 5-3. docker-compose.yml 구성 계획
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml (개발용)
|
||||
services:
|
||||
chocoadmin:
|
||||
build: .
|
||||
ports:
|
||||
- "3001:3000"
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- DB_HOST=host.docker.internal
|
||||
- DB_PORT=3306
|
||||
- DB_NAME=chocomae
|
||||
- DB_USER=...
|
||||
- DB_PASS=...
|
||||
volumes:
|
||||
- .:/app
|
||||
- /app/node_modules
|
||||
- /app/.next
|
||||
```
|
||||
|
||||
### 5-4. Dockerfile 계획
|
||||
|
||||
```dockerfile
|
||||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
CMD ["npm", "start"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 데이터베이스 주요 테이블 정리
|
||||
|
||||
스키마: [`docs/db-reference/schema.sql`](./db-reference/schema.sql)
|
||||
|
||||
| 테이블 | 설명 | 주요 컬럼 |
|
||||
|---|---|---|
|
||||
| `maestro` | 마에스트로(강사) 계정 | `MaestroID`, `Name`, `Email`, `AccountType`, `ActivateStatus`, `AvailableActivateDateTime` |
|
||||
| `maestro_extension` | 마에스트로 연장 신청 | `MaestroExtensionID`, `MaestroID`, `AccountType`, `RequestedDateTime`, `Status` |
|
||||
| `maestro_upgrade` | 마에스트로 업그레이드 신청 | `MaestroUpgradeID`, `MaestroID`, `RegisteredAccountType`, `RequestedAccountType`, `RequestedDateTime`, `Status` |
|
||||
| `maestro_log` | 마에스트로 처리 이력 | `MaestroLogID`, `MaestroID`, `Type`, `LogDateTime`, `Remark` |
|
||||
| `admin` | 관리자 계정 | `AdminID`, `Name`, `Password`, `Email`, `AccountType`, `ActivateStatus` |
|
||||
| `player` | 학생 계정 | `PlayerID`, `MaestroID`, `Name`, `EnterCode` |
|
||||
|
||||
### AccountType 코드표
|
||||
|
||||
| 값 | 의미 |
|
||||
|---|---|
|
||||
| 1 | 20명 (1만원) |
|
||||
| 2 | 50명 (2만원) |
|
||||
| 3 | 100명 (3만원) |
|
||||
| 4 | 500명 (4만원) |
|
||||
| 5 | 1,000명 (5만원) |
|
||||
|
||||
### ActivateStatus 코드표 (maestro)
|
||||
|
||||
| 값 | 의미 |
|
||||
|---|---|
|
||||
| 0 | 미승인 |
|
||||
| 1 | 체험 |
|
||||
| 2 | 활성화 |
|
||||
| 100 | 등록취소 |
|
||||
|
||||
### Status 코드표 (maestro_extension / maestro_upgrade)
|
||||
|
||||
| 값 | 의미 |
|
||||
|---|---|
|
||||
| 1 | 요청 |
|
||||
| 2 | 취소 |
|
||||
| 3 | 연장/업그레이드 적용 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 주요 화면 목록 및 API 설계
|
||||
|
||||
### 7-1. 화면 구조 (Next.js App Router)
|
||||
|
||||
```
|
||||
app/
|
||||
├── (auth)/
|
||||
│ └── login/ # 관리자 로그인
|
||||
├── (admin)/
|
||||
│ ├── layout.tsx # 공통 사이드바 + 헤더
|
||||
│ ├── maestro/
|
||||
│ │ ├── page.tsx # 마에스트로 전체 목록
|
||||
│ │ └── [id]/
|
||||
│ │ └── page.tsx # 마에스트로 상세 정보
|
||||
│ ├── extension/
|
||||
│ │ └── page.tsx # 연장 신청 목록 + 승인/취소
|
||||
│ └── upgrade/
|
||||
│ └── page.tsx # 업그레이드 신청 목록 + 승인/거절
|
||||
└── api/
|
||||
├── auth/[...nextauth]/
|
||||
├── maestro/
|
||||
│ ├── route.ts # GET: 목록 조회
|
||||
│ └── [id]/route.ts # GET: 상세, PATCH: 상태 변경
|
||||
├── extension/
|
||||
│ ├── route.ts # GET: 목록 조회
|
||||
│ └── [id]/route.ts # PATCH: 승인/취소
|
||||
└── upgrade/
|
||||
├── route.ts # GET: 목록 조회
|
||||
└── [id]/route.ts # PATCH: 승인/거절
|
||||
```
|
||||
|
||||
### 7-2. 화면별 기능 명세
|
||||
|
||||
#### [1] 마에스트로 전체 목록 (`/maestro`)
|
||||
|
||||
- 전체 마에스트로 목록 표시
|
||||
- 검색: 이름, 이메일
|
||||
- 필터: ActivateStatus (미승인 / 체험 / 활성화 / 등록취소)
|
||||
- 정렬: 가입일, 만료일
|
||||
- 컬럼: ID / 이름 / 이메일 / 계정유형 / 상태 / 인원수 / 사용가능일 / 상세보기
|
||||
|
||||
#### [2] 마에스트로 상세 정보 (`/maestro/[id]`)
|
||||
|
||||
- 기본 정보 (이름, 이메일, 계정 유형, 상태, 만료일)
|
||||
- 플레이어(학생) 목록
|
||||
- 연장 신청 이력
|
||||
- 업그레이드 신청 이력
|
||||
- 관리자 처리 로그
|
||||
|
||||
#### [3] 연장 신청 목록 (`/extension`)
|
||||
|
||||
- 연장 신청 목록 표시 (기본: Status=1 요청 건만)
|
||||
- 필터: 상태 (요청 / 취소 / 적용)
|
||||
- 컬럼: 신청ID / 마에스트로명 / 계정유형 / 신청일시 / 상태 / 승인버튼 / 취소버튼
|
||||
- 승인 시: `maestro.ActivateStatus = 2`, `maestro.AvailableActivateDateTime` 1년 연장, `maestro_extension.Status = 3`
|
||||
- 취소 시: `maestro_extension.Status = 2`
|
||||
|
||||
#### [4] 업그레이드 신청 목록 (`/upgrade`)
|
||||
|
||||
- 업그레이드 신청 목록 표시 (기본: Status=1 요청 건만)
|
||||
- 필터: 상태 (요청 / 취소 / 적용)
|
||||
- 컬럼: 신청ID / 마에스트로명 / 현재등급 / 요청등급 / 신청일시 / 상태 / 승인버튼 / 거절버튼
|
||||
- 승인 시: `maestro.AccountType` 변경, `maestro_upgrade.Status = 3`
|
||||
- 거절 시: `maestro_upgrade.Status = 2`
|
||||
|
||||
---
|
||||
|
||||
## 8. 개발 단계 계획
|
||||
|
||||
### Phase 1. 프로젝트 초기화 및 환경 구성
|
||||
|
||||
- [ ] Next.js 15 + TypeScript 프로젝트 생성
|
||||
- [ ] Tailwind CSS + shadcn/ui 설치 및 설정
|
||||
- [ ] TanStack Table 설치
|
||||
- [ ] mysql2 설치 및 DB 연결 유틸리티 작성
|
||||
- [ ] docker-compose.yml 작성 (개발용)
|
||||
- [ ] `.env.local` 환경변수 구성
|
||||
- [ ] NextAuth.js v5 설치 및 `admin` 테이블 기반 로그인 구현
|
||||
|
||||
### Phase 2. 공통 레이아웃 및 인증
|
||||
|
||||
- [ ] 로그인 페이지 (`/login`)
|
||||
- [ ] 인증 미들웨어 (비로그인 시 `/login` 리다이렉트)
|
||||
- [ ] 공통 레이아웃 (사이드바 네비게이션, 상단 헤더)
|
||||
|
||||
### Phase 3. 마에스트로 관리
|
||||
|
||||
- [ ] 마에스트로 전체 목록 페이지 + API
|
||||
- [ ] 마에스트로 상세 정보 페이지 + API
|
||||
|
||||
### Phase 4. 연장 신청 관리
|
||||
|
||||
- [ ] 연장 신청 목록 페이지 + API
|
||||
- [ ] 연장 승인 / 취소 기능 + API
|
||||
|
||||
### Phase 5. 업그레이드 신청 관리
|
||||
|
||||
- [ ] 업그레이드 신청 목록 페이지 + API
|
||||
- [ ] 업그레이드 승인 / 거절 기능 + API
|
||||
|
||||
### Phase 6. 배포 환경 구성
|
||||
|
||||
- [ ] Dockerfile (프로덕션용) 작성
|
||||
- [ ] docker-compose.prod.yml 작성 (Synology NAS용)
|
||||
- [ ] AWS EC2 DB 보안그룹 인바운드 설정 (NAS 외부 IP 허용)
|
||||
- [ ] Synology NAS Container Manager에서 배포 테스트
|
||||
|
||||
---
|
||||
|
||||
## 9. 디렉토리 구조 (목표)
|
||||
|
||||
```
|
||||
chocoadmin/
|
||||
├── app/
|
||||
│ ├── (auth)/
|
||||
│ │ └── login/
|
||||
│ ├── (admin)/
|
||||
│ │ ├── layout.tsx
|
||||
│ │ ├── maestro/
|
||||
│ │ ├── extension/
|
||||
│ │ └── upgrade/
|
||||
│ └── api/
|
||||
├── components/
|
||||
│ ├── ui/ # shadcn/ui 컴포넌트
|
||||
│ ├── data-table/ # TanStack Table 공통 컴포넌트
|
||||
│ └── layout/ # 사이드바, 헤더
|
||||
├── lib/
|
||||
│ ├── db.ts # mysql2 연결 풀
|
||||
│ └── utils.ts
|
||||
├── types/
|
||||
│ └── index.ts # DB 타입 정의
|
||||
├── docs/
|
||||
│ └── db-reference/
|
||||
│ └── schema.sql
|
||||
├── docker-compose.yml
|
||||
├── docker-compose.prod.yml
|
||||
├── Dockerfile
|
||||
├── .env.local # (gitignore)
|
||||
└── .env.production # (gitignore)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 보안 고려사항
|
||||
|
||||
- 관리자 전용 서비스이므로 외부 노출을 최소화 (NAS 내부 포트 또는 VPN 기반 접근 권장)
|
||||
- `admin.Password`는 해시 검증 방식으로 NextAuth 연동 (기존 암호화 방식 확인 필요)
|
||||
- DB 연결 정보는 `.env` 파일로 관리하며 Git에 포함하지 않음
|
||||
- AWS EC2 보안그룹에서 MariaDB 포트(3306)를 NAS IP로만 허용
|
||||
|
||||
---
|
||||
|
||||
*작성일: 2026-05-26*
|
||||
@@ -0,0 +1,501 @@
|
||||
# chocoadmin 개발 작업 계획
|
||||
|
||||
## 1. 프로젝트 개요
|
||||
|
||||
`chocoadmin`은 기존 `chocomae` 웹 서비스의 관리자 페이지와 관리자 기능만 독립 분리하는 신규 관리자 웹 서비스이다.
|
||||
|
||||
- 기존 서비스명: `chocomae`
|
||||
- 기존 서비스 개발 디렉토리: `/Users/jisangs/work/jinaju/git/web/mouse-typing`
|
||||
- 신규 서비스명: `chocoadmin`
|
||||
- 신규 서비스 개발 디렉토리: `/Users/jisangs/work/jinaju/git/web/chocoadmin`
|
||||
- 개발 목표: 기존 `mouse-typing` 서비스의 관리자 기능을 Next.js 기반 독립 서비스로 재구성
|
||||
- 기존 운영 환경: AWS EC2 `t3a.medium` 인스턴스 1개
|
||||
- 신규 개발/운영 플랫폼: Synology NAS Docker
|
||||
- 보유 NAS: Synology DS920+, RAM 20GB
|
||||
|
||||
## 2. 핵심 개발 방향
|
||||
|
||||
1. 기존 `chocomae` DB를 그대로 사용한다.
|
||||
2. 관리자 UI와 서버 로직만 신규 Next.js 서비스로 분리한다.
|
||||
3. 운영 DB 스키마를 임의 변경하지 않고, 기존 테이블과 상태값을 우선 존중한다.
|
||||
4. 관리자 액션은 반드시 서버 사이드에서 권한 검증, 트랜잭션, 로그 기록을 거친다.
|
||||
5. Synology NAS Docker에서 개발과 운영을 모두 재현 가능하게 구성한다.
|
||||
|
||||
## 3. 기술 스택 제안
|
||||
|
||||
### 기본 스택
|
||||
|
||||
- Runtime: Node.js LTS
|
||||
- Framework: Next.js App Router
|
||||
- Language: TypeScript
|
||||
- Database: MariaDB
|
||||
- DB Access: Prisma 또는 Drizzle ORM
|
||||
- Auth: NextAuth/Auth.js 또는 자체 세션 기반 인증
|
||||
- Validation: Zod
|
||||
- Package Manager: pnpm
|
||||
- Container: Docker, Docker Compose
|
||||
|
||||
### DB 접근 방식 추천
|
||||
|
||||
1. Prisma
|
||||
- 장점: 타입 안정성, 마이그레이션/스키마 관리 경험이 좋고 개발 속도가 빠름
|
||||
- 주의: 기존 MariaDB 스키마를 introspection해서 사용하는 방식이 적합함
|
||||
|
||||
2. Drizzle ORM
|
||||
- 장점: SQL에 가까운 제어, 가벼움, 서버 액션/API Route와 잘 맞음
|
||||
- 주의: 기존 DB 테이블 정의를 직접 코드로 옮기는 작업이 필요함
|
||||
|
||||
권장안: 초기 개발 생산성과 유지보수를 고려하면 Prisma를 우선 검토하고, 기존 DB 쿼리를 세밀하게 제어해야 하는 구간만 raw SQL을 병행한다.
|
||||
|
||||
## 4. CSS Framework 추천
|
||||
|
||||
관리자 서비스는 표, 검색, 필터, 상세 보기, 승인/거절 액션이 중심이므로 화려한 랜딩 페이지보다 유지보수성과 일관성이 중요하다.
|
||||
|
||||
### 1순위: Tailwind CSS + shadcn/ui
|
||||
|
||||
- 장점: Next.js와 궁합이 좋고, 관리자 화면에 필요한 버튼, 입력, 다이얼로그, 탭, 폼 구성이 빠름
|
||||
- 장점: 컴포넌트 코드를 프로젝트 안에서 직접 소유하므로 장기 유지보수에 유리함
|
||||
- 단점: Tailwind 클래스에 익숙해지는 시간이 필요함
|
||||
- 추천 이유: `chocoadmin`처럼 작은 팀/개인 프로젝트에서 빠르게 만들고 나중에 고치기 좋음
|
||||
|
||||
### 2순위: Mantine
|
||||
|
||||
- 장점: 컴포넌트가 풍부하고 Form, Modal, Table 주변 UI를 빠르게 만들기 좋음
|
||||
- 장점: 테마 시스템과 훅이 잘 갖춰져 있음
|
||||
- 단점: 디자인을 많이 커스터마이징하면 프레임워크 스타일에 묶일 수 있음
|
||||
- 추천 이유: 관리자 도구를 빠르게 안정적인 UI로 완성하기 좋음
|
||||
|
||||
### 3순위: Chakra UI
|
||||
|
||||
- 장점: 접근성, 컴포넌트 API, 테마 관리가 안정적임
|
||||
- 장점: Next.js 프로젝트에서 사용 사례가 많음
|
||||
- 단점: 대량 데이터 테이블은 별도 라이브러리 조합이 필요함
|
||||
- 추천 이유: 익숙한 컴포넌트 기반 개발을 선호할 때 적합함
|
||||
|
||||
최종 권장안: `Tailwind CSS + shadcn/ui`를 기본 UI 프레임워크로 사용한다.
|
||||
|
||||
## 5. Table 라이브러리 추천
|
||||
|
||||
관리자 화면은 마에스트로 목록, 연장 신청, 업그레이드 신청처럼 대부분 표 기반으로 운영되므로 정렬, 필터, 검색, 페이지네이션, 행 선택, 컬럼 표시 제어, 액션 버튼 확장이 쉬워야 한다.
|
||||
|
||||
### 1순위: TanStack Table
|
||||
|
||||
- 장점: headless 방식이라 UI 프레임워크와 자유롭게 결합 가능
|
||||
- 장점: 서버 사이드 페이지네이션, 정렬, 필터링을 구현하기 좋음
|
||||
- 장점: shadcn/ui의 Data Table 패턴과 잘 맞음
|
||||
- 단점: 직접 조립해야 하는 부분이 있어 초기 구현량이 있음
|
||||
- 추천 이유: 관리자 기능이 늘어날 가능성이 높은 `chocoadmin`에 가장 유연함
|
||||
|
||||
### 2순위: AG Grid Community
|
||||
|
||||
- 장점: 엑셀 같은 고급 그리드 기능이 강함
|
||||
- 장점: 컬럼 리사이즈, 고정 컬럼, 복잡한 필터 등 고급 기능이 풍부함
|
||||
- 단점: UI가 무겁고, 일부 기능은 Enterprise 라이선스가 필요함
|
||||
- 추천 이유: 향후 대량 데이터 운영 도구가 필요해질 때 적합함
|
||||
|
||||
### 3순위: Mantine React Table
|
||||
|
||||
- 장점: Mantine을 CSS/UI 프레임워크로 선택할 경우 통합 경험이 좋음
|
||||
- 장점: TanStack Table 기반이라 기능 확장성이 좋음
|
||||
- 단점: Mantine 생태계에 묶임
|
||||
- 추천 이유: Mantine을 선택하는 경우 가장 빠르게 관리자 표를 만들 수 있음
|
||||
|
||||
최종 권장안: `TanStack Table + shadcn/ui Data Table` 조합을 사용한다.
|
||||
|
||||
## 6. DB 기준 관리자 대상 테이블
|
||||
|
||||
스키마 파일: `/Users/jisangs/work/jinaju/git/web/chocoadmin/docs/db-reference/schema.sql`
|
||||
|
||||
### 주요 테이블
|
||||
|
||||
- `admin`: 관리자 계정
|
||||
- `maestro`: 마에스트로 계정
|
||||
- `maestro_extension`: 마에스트로 연장 신청
|
||||
- `maestro_upgrade`: 마에스트로 업그레이드 신청
|
||||
- `maestro_log`: 마에스트로 관련 처리 로그
|
||||
- `player`: 마에스트로에 속한 학생/플레이어
|
||||
|
||||
### 주요 상태값
|
||||
|
||||
`maestro.ActivateStatus`
|
||||
|
||||
- `0`: 미승인
|
||||
- `1`: 체험
|
||||
- `2`: 활성화
|
||||
- `100`: 등록취소
|
||||
|
||||
`maestro.AccountType`
|
||||
|
||||
- `1`: 20명
|
||||
- `2`: 50명
|
||||
- `3`: 100명
|
||||
- `4`: 500명
|
||||
- `5`: 1000명
|
||||
|
||||
`maestro_extension.Status`
|
||||
|
||||
- `1`: 요청
|
||||
- `2`: 취소
|
||||
- `3`: 연장 적용
|
||||
|
||||
`maestro_upgrade.Status`
|
||||
|
||||
- `1`: 요청
|
||||
- `2`: 취소
|
||||
- `3`: 업그레이드 적용
|
||||
|
||||
## 7. 주요 관리자 화면 계획
|
||||
|
||||
### 7.1 공통 레이아웃
|
||||
|
||||
- 로그인 화면
|
||||
- 좌측 사이드바
|
||||
- 상단 관리자 정보 영역
|
||||
- 공통 검색/필터 패턴
|
||||
- 공통 확인 다이얼로그
|
||||
- 처리 성공/실패 토스트
|
||||
- 접근 권한 없는 경우 안내 화면
|
||||
|
||||
### 7.2 마에스트로 전체 목록
|
||||
|
||||
목표: 전체 마에스트로 계정을 검색, 필터링, 확인할 수 있는 운영 화면을 만든다.
|
||||
|
||||
주요 기능:
|
||||
|
||||
- 전체 목록 조회
|
||||
- 이름, 이메일, `MaestroID` 검색
|
||||
- 계정 종류 필터
|
||||
- 활성 상태 필터
|
||||
- 가입/약관 동의/사용 가능 기간 기준 정렬
|
||||
- 학생 수 표시
|
||||
- 상세 화면 이동
|
||||
|
||||
표 주요 컬럼:
|
||||
|
||||
- `MaestroID`
|
||||
- `Name`
|
||||
- `Email`
|
||||
- `AccountType`
|
||||
- `ActivateStatus`
|
||||
- `PlayerCount`
|
||||
- `AvailableActivateDateTime`
|
||||
- `AcceptClausesDateTime`
|
||||
- `AllowEditEnterCode`
|
||||
|
||||
### 7.3 마에스트로 상세 정보 보기
|
||||
|
||||
목표: 특정 마에스트로의 계정 상태와 관련 신청 이력을 한 화면에서 확인한다.
|
||||
|
||||
주요 기능:
|
||||
|
||||
- 기본 계정 정보
|
||||
- 현재 계정 종류/활성 상태 표시
|
||||
- 학생 수 및 플레이어 목록 요약
|
||||
- 최근 연장 신청 이력
|
||||
- 최근 업그레이드 신청 이력
|
||||
- 처리 로그 표시
|
||||
|
||||
확장 후보 기능:
|
||||
|
||||
- 계정 상태 수동 변경
|
||||
- 학생 목록 상세 조회
|
||||
- 입장번호 변경 허용 여부 수정
|
||||
|
||||
단, 확장 후보 기능은 기존 관리자 기능과 운영 정책 확인 후 별도 구현한다.
|
||||
|
||||
### 7.4 마에스트로 연장 신청 목록
|
||||
|
||||
목표: `maestro_extension`의 연장 요청을 검토하고 승인 또는 취소 처리한다.
|
||||
|
||||
주요 기능:
|
||||
|
||||
- 연장 신청 목록 조회
|
||||
- 요청 상태별 필터
|
||||
- 요청일 기준 정렬
|
||||
- 마에스트로 정보 함께 표시
|
||||
- 연장 승인
|
||||
- 연장 취소
|
||||
- 처리 후 `maestro_log` 기록
|
||||
|
||||
승인 처리 초안:
|
||||
|
||||
1. `maestro_extension.Status = 1`인지 확인한다.
|
||||
2. 트랜잭션을 시작한다.
|
||||
3. `maestro_extension.Status`를 `3`으로 변경한다.
|
||||
4. `maestro.AccountType`, `AvailableActivateDateTime`, `ActivateStatus` 갱신 규칙을 기존 서비스에서 확인해 동일하게 적용한다.
|
||||
5. `maestro_log`에 처리 기록을 남긴다.
|
||||
6. 트랜잭션을 커밋한다.
|
||||
|
||||
취소 처리 초안:
|
||||
|
||||
1. `maestro_extension.Status = 1`인지 확인한다.
|
||||
2. 트랜잭션을 시작한다.
|
||||
3. `maestro_extension.Status`를 `2`로 변경한다.
|
||||
4. `maestro_log`에 취소 기록을 남긴다.
|
||||
5. 트랜잭션을 커밋한다.
|
||||
|
||||
### 7.5 마에스트로 업그레이드 신청 목록
|
||||
|
||||
목표: `maestro_upgrade`의 업그레이드 요청을 검토하고 승인 또는 거절 처리한다.
|
||||
|
||||
주요 기능:
|
||||
|
||||
- 업그레이드 신청 목록 조회
|
||||
- 요청 상태별 필터
|
||||
- 현재 계정 종류와 요청 계정 종류 비교 표시
|
||||
- 요청일 기준 정렬
|
||||
- 업그레이드 승인
|
||||
- 업그레이드 거절
|
||||
- 처리 후 `maestro_log` 기록
|
||||
|
||||
승인 처리 초안:
|
||||
|
||||
1. `maestro_upgrade.Status = 1`인지 확인한다.
|
||||
2. 트랜잭션을 시작한다.
|
||||
3. `maestro_upgrade.Status`를 `3`으로 변경한다.
|
||||
4. `maestro.AccountType`을 `RequestedAccountType`으로 변경한다.
|
||||
5. 필요 시 `PlayerCount` 정책을 기존 서비스에서 확인해 반영한다.
|
||||
6. `maestro_log`에 처리 기록을 남긴다.
|
||||
7. 트랜잭션을 커밋한다.
|
||||
|
||||
거절 처리 초안:
|
||||
|
||||
1. `maestro_upgrade.Status = 1`인지 확인한다.
|
||||
2. 트랜잭션을 시작한다.
|
||||
3. `maestro_upgrade.Status`를 `2`로 변경한다.
|
||||
4. `maestro_log`에 거절 기록을 남긴다.
|
||||
5. 트랜잭션을 커밋한다.
|
||||
|
||||
## 8. 애플리케이션 구조 제안
|
||||
|
||||
```text
|
||||
chocoadmin/
|
||||
app/
|
||||
login/
|
||||
maestros/
|
||||
page.tsx
|
||||
[maestroId]/
|
||||
page.tsx
|
||||
extension-requests/
|
||||
page.tsx
|
||||
upgrade-requests/
|
||||
page.tsx
|
||||
api/
|
||||
components/
|
||||
layout/
|
||||
data-table/
|
||||
forms/
|
||||
dialogs/
|
||||
lib/
|
||||
auth/
|
||||
db/
|
||||
validators/
|
||||
constants/
|
||||
docs/
|
||||
db-reference/
|
||||
schema.sql
|
||||
project-plan.md
|
||||
prisma/
|
||||
schema.prisma
|
||||
docker/
|
||||
docker-compose.yml
|
||||
Dockerfile
|
||||
```
|
||||
|
||||
## 9. Docker 개발/운영 계획
|
||||
|
||||
### 개발 환경
|
||||
|
||||
- Synology NAS Docker에서 Next.js 컨테이너 실행
|
||||
- 개발 DB는 `localhost` MariaDB 사용
|
||||
- `.env.local`로 DB 접속 정보 관리
|
||||
- 개발 DB는 운영 DB 덤프에서 민감 정보를 제거한 샘플 데이터 사용 권장
|
||||
|
||||
### 운영 환경
|
||||
|
||||
- Synology NAS Docker에서 `chocoadmin` 컨테이너 실행
|
||||
- 운영 DB는 기존 AWS EC2 MariaDB에 접속
|
||||
- AWS 보안 그룹에서 Synology NAS 공인 IP만 MariaDB 접속 허용
|
||||
- DB 계정은 관리자 서비스 전용 계정으로 분리
|
||||
- 최소 권한 원칙 적용
|
||||
- 운영 배포 시 환경변수는 Synology Container Manager 또는 `.env.production`으로 관리
|
||||
|
||||
### Docker 구성 초안
|
||||
|
||||
- `Dockerfile`: Next.js production build
|
||||
- `docker-compose.yml`: app service 정의
|
||||
- `healthcheck`: HTTP 상태 확인 엔드포인트 사용
|
||||
- `restart: unless-stopped`
|
||||
- 로그는 Docker 기본 로그 또는 Synology 로그 수집 방식 사용
|
||||
|
||||
## 10. 보안 계획
|
||||
|
||||
1. 관리자 로그인은 서버 세션 기반으로 구현한다.
|
||||
2. 관리자 비밀번호 저장 방식은 기존 `admin.Password` 구조를 확인한 뒤 개선 가능성을 검토한다.
|
||||
3. 운영 DB 접속 정보는 저장소에 커밋하지 않는다.
|
||||
4. 승인/취소/거절 같은 변경 액션은 CSRF 또는 서버 액션 보호를 적용한다.
|
||||
5. 관리자 액션은 `maestro_log`에 기록한다.
|
||||
6. 운영 DB 직접 수정 기능은 최소화한다.
|
||||
7. 승인/거절 API는 중복 처리 방지를 위해 현재 상태를 조건으로 검사한다.
|
||||
8. 모든 변경 작업은 트랜잭션으로 처리한다.
|
||||
|
||||
## 11. 개발 단계별 작업 계획
|
||||
|
||||
### Phase 0. 기존 관리자 기능 조사
|
||||
|
||||
- 기존 `mouse-typing` 코드에서 관리자 라우트와 처리 로직 조사
|
||||
- 연장 승인 시 `AvailableActivateDateTime` 계산 방식 확인
|
||||
- 업그레이드 승인 시 `PlayerCount`와 `AccountType` 처리 방식 확인
|
||||
- 기존 `admin.Password` 검증 방식 확인
|
||||
- 기존 로그 기록 형식 확인
|
||||
|
||||
완료 기준:
|
||||
|
||||
- 기존 관리자 처리 규칙을 문서화한다.
|
||||
- 신규 서비스에서 재현해야 할 비즈니스 규칙을 확정한다.
|
||||
|
||||
### Phase 1. 프로젝트 초기화
|
||||
|
||||
- Next.js + TypeScript 프로젝트 생성
|
||||
- ESLint, Prettier 설정
|
||||
- Tailwind CSS + shadcn/ui 설정
|
||||
- 기본 레이아웃 구성
|
||||
- 환경변수 구조 정의
|
||||
- Dockerfile, docker-compose 초안 작성
|
||||
|
||||
완료 기준:
|
||||
|
||||
- 로컬 또는 Synology Docker에서 기본 페이지가 실행된다.
|
||||
|
||||
### Phase 2. DB 연결 및 타입 구성
|
||||
|
||||
- MariaDB 연결 설정
|
||||
- Prisma introspection 또는 Drizzle schema 작성
|
||||
- DB 연결 헬퍼 구현
|
||||
- 상태값 상수화
|
||||
- 공통 날짜/계정 종류 표시 유틸 작성
|
||||
|
||||
완료 기준:
|
||||
|
||||
- 개발 DB에서 `maestro`, `maestro_extension`, `maestro_upgrade` 조회가 가능하다.
|
||||
|
||||
### Phase 3. 인증과 관리자 세션
|
||||
|
||||
- 로그인 화면 구현
|
||||
- 관리자 계정 검증
|
||||
- 세션 생성/만료 처리
|
||||
- 보호 라우트 처리
|
||||
- 로그아웃 구현
|
||||
|
||||
완료 기준:
|
||||
|
||||
- 로그인하지 않은 사용자는 관리자 화면에 접근할 수 없다.
|
||||
- 로그인한 관리자만 목록과 상세 화면에 접근할 수 있다.
|
||||
|
||||
### Phase 4. 마에스트로 목록/상세 화면
|
||||
|
||||
- 마에스트로 목록 API 또는 서버 액션 구현
|
||||
- TanStack Table 기반 목록 화면 구현
|
||||
- 검색, 필터, 정렬, 페이지네이션 구현
|
||||
- 상세 화면 구현
|
||||
- 상세 화면에서 연장/업그레이드/로그 요약 표시
|
||||
|
||||
완료 기준:
|
||||
|
||||
- 운영자가 마에스트로를 빠르게 검색하고 상세 정보를 확인할 수 있다.
|
||||
|
||||
### Phase 5. 연장 신청 관리
|
||||
|
||||
- 연장 신청 목록 구현
|
||||
- 상태 필터와 검색 구현
|
||||
- 승인/취소 확인 다이얼로그 구현
|
||||
- 승인/취소 서버 액션 구현
|
||||
- 트랜잭션과 로그 기록 적용
|
||||
|
||||
완료 기준:
|
||||
|
||||
- 요청 상태의 연장 신청을 승인 또는 취소할 수 있다.
|
||||
- 중복 처리와 이미 처리된 건의 재처리가 방지된다.
|
||||
|
||||
### Phase 6. 업그레이드 신청 관리
|
||||
|
||||
- 업그레이드 신청 목록 구현
|
||||
- 현재/요청 계정 비교 표시
|
||||
- 승인/거절 확인 다이얼로그 구현
|
||||
- 승인/거절 서버 액션 구현
|
||||
- 트랜잭션과 로그 기록 적용
|
||||
|
||||
완료 기준:
|
||||
|
||||
- 요청 상태의 업그레이드 신청을 승인 또는 거절할 수 있다.
|
||||
- 처리 결과가 마에스트로 계정에 정확히 반영된다.
|
||||
|
||||
### Phase 7. 운영 배포 준비
|
||||
|
||||
- Synology NAS Docker Compose 운영 설정 작성
|
||||
- 운영 환경변수 정리
|
||||
- AWS EC2 MariaDB 접근 보안 그룹 설정 가이드 작성
|
||||
- 백업/롤백 절차 작성
|
||||
- 관리자 액션 테스트 체크리스트 작성
|
||||
|
||||
완료 기준:
|
||||
|
||||
- Synology NAS에서 운영 컨테이너를 실행할 수 있다.
|
||||
- 운영 DB 연결 전 점검 항목이 문서화된다.
|
||||
|
||||
### Phase 8. 검증 및 안정화
|
||||
|
||||
- 주요 화면 수동 테스트
|
||||
- 승인/취소/거절 트랜잭션 테스트
|
||||
- 권한 없는 접근 테스트
|
||||
- Docker 재시작 테스트
|
||||
- 운영 DB read-only 리허설 후 실제 변경 기능 활성화
|
||||
|
||||
완료 기준:
|
||||
|
||||
- 핵심 관리자 업무가 신규 서비스에서 안정적으로 수행된다.
|
||||
|
||||
## 12. 우선 확인이 필요한 질문
|
||||
|
||||
1. 기존 `mouse-typing` 관리자 코드에서 연장 승인 시 정확히 며칠/몇 개월을 연장하는가?
|
||||
2. 업그레이드 승인 시 `PlayerCount`를 즉시 변경하는가, 아니면 `AccountType`만 변경하는가?
|
||||
3. `admin.Password`는 평문, 해시, 자체 암호화 중 어떤 방식인가?
|
||||
4. 운영 MariaDB가 외부 접속을 허용할 수 있는 네트워크 구조인가?
|
||||
5. 관리자 계정은 기존 `admin` 테이블을 그대로 사용할 것인가, 신규 인증 테이블을 둘 것인가?
|
||||
6. 운영 초기에 읽기 전용 모드로 먼저 배포한 뒤 변경 기능을 켤 것인가?
|
||||
|
||||
## 13. 추천 MVP 범위
|
||||
|
||||
1차 MVP는 아래 기능까지만 포함한다.
|
||||
|
||||
- 관리자 로그인
|
||||
- 마에스트로 전체 목록
|
||||
- 마에스트로 상세 정보 보기
|
||||
- 연장 신청 목록
|
||||
- 연장 승인/취소
|
||||
- 업그레이드 신청 목록
|
||||
- 업그레이드 승인/거절
|
||||
- 관리자 액션 로그 기록
|
||||
- Docker 기반 실행
|
||||
|
||||
MVP 이후 확장 후보:
|
||||
|
||||
- 관리자 계정 관리
|
||||
- 마에스트로 상태 직접 변경
|
||||
- 학생 목록 상세 관리
|
||||
- 데이터 내보내기 CSV
|
||||
- 처리 통계 대시보드
|
||||
- 감사 로그 전용 화면
|
||||
|
||||
## 14. 최종 추천 조합
|
||||
|
||||
- Framework: Next.js App Router
|
||||
- Language: TypeScript
|
||||
- UI/CSS: Tailwind CSS + shadcn/ui
|
||||
- Table: TanStack Table
|
||||
- DB: MariaDB 기존 `chocomae` DB
|
||||
- ORM: Prisma 우선 검토
|
||||
- Validation: Zod
|
||||
- Runtime/Deploy: Docker on Synology NAS
|
||||
- 운영 DB 연결: AWS EC2 MariaDB에 제한된 IP와 전용 계정으로 접속
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
# chocoadmin 프로젝트 계획서
|
||||
|
||||
## 1. 프로젝트 개요
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 서비스명 | chocoadmin |
|
||||
| 목적 | chocomae 웹 서비스(mouse-typing)의 관리자 기능을 독립 웹 서비스로 분리 |
|
||||
| 기존 서비스 경로 | `/Users/jisangs/work/jinaju/git/web/mouse-typing` |
|
||||
| 신규 서비스 경로 | `/Users/jisangs/work/jinaju/git/web/chocoadmin` |
|
||||
| 기존 운영 환경 | AWS EC2 t3a.medium 인스턴스 1개 |
|
||||
| 사용 DB | 기존 chocomae DB (MariaDB 10.11.13) 그대로 사용 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 핵심 개발 방향
|
||||
|
||||
1. 기존 `chocomae` DB를 그대로 사용한다. DB 스키마는 임의로 변경하지 않는다.
|
||||
2. 관리자 UI와 서버 로직만 신규 Next.js 서비스로 분리한다.
|
||||
3. 기존 테이블 구조와 상태값을 그대로 존중하고, 기존 서비스의 비즈니스 규칙을 먼저 파악한 뒤 동일하게 구현한다.
|
||||
4. 승인·취소·거절 같은 변경 액션은 반드시 서버 사이드에서 권한 검증 → 트랜잭션 → 로그 기록 순서로 처리한다.
|
||||
5. 중복 처리 방지를 위해 모든 변경 API는 현재 상태를 조건으로 먼저 검사한다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 기술 스택
|
||||
|
||||
| 구분 | 기술 |
|
||||
|---|---|
|
||||
| 런타임 | Node.js LTS |
|
||||
| 프레임워크 | Next.js 16 (App Router) |
|
||||
| 언어 | TypeScript |
|
||||
| 패키지 매니저 | pnpm |
|
||||
| UI / CSS | Tailwind CSS + shadcn/ui |
|
||||
| Table | TanStack Table (React Table v8) |
|
||||
| DB 클라이언트 | mysql2 (MariaDB 호환) |
|
||||
| ORM | Prisma (기존 DB introspection 방식으로 활용) |
|
||||
| 유효성 검증 | Zod |
|
||||
| 인증 | NextAuth.js v5 (기존 `admin` 테이블 활용) |
|
||||
| 환경변수 관리 | `.env.local` / `.env.production` |
|
||||
|
||||
### Prisma vs Drizzle ORM
|
||||
|
||||
두 가지 모두 MariaDB를 지원하나, 이 프로젝트에서는 **Prisma**를 우선 사용한다.
|
||||
|
||||
- 기존 DB를 `prisma db pull`(introspection)로 그대로 가져올 수 있어 초기 세팅이 빠름
|
||||
- TypeScript 타입 자동 생성으로 컬럼명 오타를 컴파일 단계에서 잡을 수 있음
|
||||
- 세밀한 SQL 제어가 필요한 구간은 `$queryRaw` / `$executeRaw`로 raw SQL 병행 가능
|
||||
|
||||
---
|
||||
|
||||
## 4. 개발 환경 및 배포 환경
|
||||
|
||||
### 4-1. 개발 환경
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 하드웨어 | 맥미니 |
|
||||
| IDE | IntelliJ IDEA |
|
||||
| 실행 방법 | IntelliJ 터미널에서 `pnpm dev` |
|
||||
| 포트 | 3001 (기존 mouse-typing과 충돌 방지) |
|
||||
| DB (개발) | 맥미니 로컬 MariaDB (`localhost:3306`) |
|
||||
| 환경변수 | `.env.local` |
|
||||
|
||||
### 4-2. 배포(서비스) 환경
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 플랫폼 | Synology NAS DS920+ (RAM 20GB) — Docker |
|
||||
| 실행 방법 | Synology Container Manager (`docker compose up`) |
|
||||
| DB (서비스) | AWS EC2 MariaDB (chocomae 서비스와 동일 인스턴스) |
|
||||
| DB 접근 | AWS 보안그룹에서 NAS 외부 IP만 3306 포트 허용 |
|
||||
| DB 계정 | chocoadmin 전용 최소 권한 계정으로 분리 |
|
||||
| 환경변수 | `.env.production` 또는 Container Manager 환경변수 설정 |
|
||||
|
||||
### 4-3. Dockerfile / docker-compose (배포용)
|
||||
|
||||
```dockerfile
|
||||
# Dockerfile
|
||||
FROM node:22-alpine
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY . .
|
||||
RUN pnpm build
|
||||
CMD ["pnpm", "start"]
|
||||
```
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
chocoadmin:
|
||||
build: .
|
||||
ports:
|
||||
- "3001:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DATABASE_URL=mysql://USER:PASS@AWS_EC2_HOST:3306/chocomae
|
||||
- NEXTAUTH_SECRET=...
|
||||
- NEXTAUTH_URL=http://NAS_IP:3001
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 데이터베이스 주요 테이블
|
||||
|
||||
스키마: [`docs/db-reference/schema.sql`](./db-reference/schema.sql)
|
||||
|
||||
| 테이블 | 설명 | 주요 컬럼 |
|
||||
|---|---|---|
|
||||
| `maestro` | 마에스트로(강사) 계정 | `MaestroID`, `Name`, `Email`, `AccountType`, `ActivateStatus`, `AvailableActivateDateTime`, `PlayerCount` |
|
||||
| `maestro_extension` | 연장 신청 | `MaestroExtensionID`, `MaestroID`, `AccountType`, `RequestedDateTime`, `Status` |
|
||||
| `maestro_upgrade` | 업그레이드 신청 | `MaestroUpgradeID`, `MaestroID`, `RegisteredAccountType`, `RequestedAccountType`, `RequestedDateTime`, `Status` |
|
||||
| `maestro_log` | 처리 이력 로그 | `MaestroLogID`, `MaestroID`, `Type`, `LogDateTime`, `Remark` |
|
||||
| `admin` | 관리자 계정 | `AdminID`, `Name`, `Password`, `Email`, `AccountType`, `ActivateStatus` |
|
||||
| `player` | 학생 계정 | `PlayerID`, `MaestroID`, `Name`, `EnterCode` |
|
||||
|
||||
### 상태값 코드표
|
||||
|
||||
**`maestro.AccountType`**
|
||||
|
||||
| 값 | 의미 |
|
||||
|---|---|
|
||||
| 1 | 20명 (1만원) |
|
||||
| 2 | 50명 (2만원) |
|
||||
| 3 | 100명 (3만원) |
|
||||
| 4 | 500명 (4만원) |
|
||||
| 5 | 1,000명 (5만원) |
|
||||
|
||||
**`maestro.ActivateStatus`**
|
||||
|
||||
| 값 | 의미 |
|
||||
|---|---|
|
||||
| 0 | 미승인 |
|
||||
| 1 | 체험 |
|
||||
| 2 | 활성화 |
|
||||
| 100 | 등록취소 |
|
||||
|
||||
**`maestro_extension.Status` / `maestro_upgrade.Status`**
|
||||
|
||||
| 값 | 의미 |
|
||||
|---|---|
|
||||
| 1 | 요청 |
|
||||
| 2 | 취소/거절 |
|
||||
| 3 | 적용 완료 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 애플리케이션 구조
|
||||
|
||||
```
|
||||
chocoadmin/
|
||||
├── app/
|
||||
│ ├── (auth)/
|
||||
│ │ └── login/
|
||||
│ │ └── page.tsx # 관리자 로그인
|
||||
│ ├── (admin)/
|
||||
│ │ ├── layout.tsx # 공통 사이드바 + 헤더
|
||||
│ │ ├── maestros/
|
||||
│ │ │ ├── page.tsx # 마에스트로 전체 목록
|
||||
│ │ │ └── [maestroId]/
|
||||
│ │ │ └── page.tsx # 마에스트로 상세 정보
|
||||
│ │ ├── extension-requests/
|
||||
│ │ │ └── page.tsx # 연장 신청 목록 + 승인/취소
|
||||
│ │ └── upgrade-requests/
|
||||
│ │ └── page.tsx # 업그레이드 신청 목록 + 승인/거절
|
||||
│ └── api/
|
||||
│ ├── auth/[...nextauth]/
|
||||
│ ├── maestros/
|
||||
│ │ ├── route.ts # GET: 목록
|
||||
│ │ └── [id]/route.ts # GET: 상세, PATCH: 상태 변경
|
||||
│ ├── extension-requests/
|
||||
│ │ └── [id]/route.ts # PATCH: 승인/취소
|
||||
│ └── upgrade-requests/
|
||||
│ └── [id]/route.ts # PATCH: 승인/거절
|
||||
├── components/
|
||||
│ ├── ui/ # shadcn/ui 컴포넌트
|
||||
│ ├── data-table/ # TanStack Table 공통 컴포넌트
|
||||
│ ├── layout/ # 사이드바, 헤더
|
||||
│ ├── forms/
|
||||
│ └── dialogs/ # 승인/취소 확인 다이얼로그
|
||||
├── lib/
|
||||
│ ├── db.ts # Prisma Client 싱글톤
|
||||
│ ├── auth.ts # NextAuth 설정
|
||||
│ ├── validators/ # Zod 스키마
|
||||
│ └── constants.ts # 상태값 상수
|
||||
├── types/
|
||||
│ └── index.ts
|
||||
├── prisma/
|
||||
│ └── schema.prisma # DB introspection 결과
|
||||
├── docs/
|
||||
│ └── db-reference/
|
||||
│ └── schema.sql
|
||||
├── .env.local # gitignore
|
||||
├── .env.production # gitignore
|
||||
├── Dockerfile
|
||||
└── docker-compose.yml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 주요 화면 기능 명세
|
||||
|
||||
### 7-1. 공통 레이아웃
|
||||
|
||||
- 로그인 화면 (미인증 시 `/login` 리다이렉트)
|
||||
- 좌측 사이드바 네비게이션
|
||||
- 공통 확인 다이얼로그 (승인/취소/거절 전 확인)
|
||||
- 처리 성공/실패 토스트 알림
|
||||
|
||||
### 7-2. 마에스트로 전체 목록 (`/maestros`)
|
||||
|
||||
- 전체 목록 조회 (서버 사이드 페이지네이션)
|
||||
- 검색: 이름, 이메일, MaestroID
|
||||
- 필터: ActivateStatus (미승인 / 체험 / 활성화 / 등록취소)
|
||||
- 필터: AccountType (20명 / 50명 / 100명 / 500명 / 1,000명)
|
||||
- 정렬: 가입일, 사용 가능일
|
||||
- 컬럼: ID / 이름 / 이메일 / 계정 유형 / 상태 / 학생 수 / 사용 가능일 / 상세보기
|
||||
|
||||
### 7-3. 마에스트로 상세 정보 (`/maestros/[maestroId]`)
|
||||
|
||||
- 기본 계정 정보 (이름, 이메일, 계정 유형, 상태, 만료일, 약관 동의일)
|
||||
- 플레이어(학생) 목록 요약
|
||||
- 연장 신청 이력
|
||||
- 업그레이드 신청 이력
|
||||
- 관리자 처리 로그 (`maestro_log`)
|
||||
|
||||
### 7-4. 연장 신청 목록 (`/extension-requests`)
|
||||
|
||||
- 연장 신청 목록 조회 (기본 필터: Status=1 요청 건)
|
||||
- 필터: 상태 (요청 / 취소 / 적용)
|
||||
- 컬럼: 신청ID / 마에스트로명 / 계정유형 / 신청일시 / 상태 / 승인 / 취소
|
||||
|
||||
**승인 처리 순서:**
|
||||
1. `maestro_extension.Status = 1` 인지 확인 (중복 처리 방지)
|
||||
2. 트랜잭션 시작
|
||||
3. `maestro_extension.Status → 3`
|
||||
4. `maestro.AvailableActivateDateTime` 연장, `maestro.ActivateStatus → 2` (기존 서비스 로직 확인 후 동일 적용)
|
||||
5. `maestro_log` 기록
|
||||
6. 트랜잭션 커밋
|
||||
|
||||
**취소 처리 순서:**
|
||||
1. `maestro_extension.Status = 1` 인지 확인
|
||||
2. 트랜잭션 시작
|
||||
3. `maestro_extension.Status → 2`
|
||||
4. `maestro_log` 기록
|
||||
5. 트랜잭션 커밋
|
||||
|
||||
### 7-5. 업그레이드 신청 목록 (`/upgrade-requests`)
|
||||
|
||||
- 업그레이드 신청 목록 조회 (기본 필터: Status=1 요청 건)
|
||||
- 필터: 상태 (요청 / 취소 / 적용)
|
||||
- 컬럼: 신청ID / 마에스트로명 / 현재등급 / 요청등급 / 신청일시 / 상태 / 승인 / 거절
|
||||
|
||||
**승인 처리 순서:**
|
||||
1. `maestro_upgrade.Status = 1` 인지 확인 (중복 처리 방지)
|
||||
2. 트랜잭션 시작
|
||||
3. `maestro_upgrade.Status → 3`
|
||||
4. `maestro.AccountType → RequestedAccountType` 변경 (PlayerCount 갱신 정책은 기존 서비스 확인 후 동일 적용)
|
||||
5. `maestro_log` 기록
|
||||
6. 트랜잭션 커밋
|
||||
|
||||
**거절 처리 순서:**
|
||||
1. `maestro_upgrade.Status = 1` 인지 확인
|
||||
2. 트랜잭션 시작
|
||||
3. `maestro_upgrade.Status → 2`
|
||||
4. `maestro_log` 기록
|
||||
5. 트랜잭션 커밋
|
||||
|
||||
---
|
||||
|
||||
## 8. 보안 고려사항
|
||||
|
||||
1. 관리자 로그인은 NextAuth 서버 세션 기반으로 구현한다.
|
||||
2. 기존 `admin.Password` 저장 방식(평문/해시/자체 암호화)을 먼저 확인한 뒤 동일 방식 또는 해시 업그레이드를 결정한다.
|
||||
3. 승인·취소·거절 액션은 Next.js Server Action 또는 API Route에서 CSRF 보호를 적용한다.
|
||||
4. DB 연결 정보는 `.env` 파일로 관리하며 Git에 커밋하지 않는다.
|
||||
5. 배포 DB 계정은 chocoadmin 전용 계정으로 분리하고 최소 권한(SELECT + 필요한 테이블만 UPDATE)을 적용한다.
|
||||
6. AWS EC2 보안그룹에서 MariaDB 포트(3306)를 NAS 외부 IP로만 허용한다.
|
||||
7. 관리자 전용 서비스이므로 외부에 직접 노출하지 않고 VPN 또는 NAS 내부 포트로 접근하는 방식을 권장한다.
|
||||
|
||||
---
|
||||
|
||||
## 9. 개발 단계 계획
|
||||
|
||||
### Phase 0. 기존 관리자 코드 조사
|
||||
|
||||
> 기존 서비스 비즈니스 규칙을 파악해 재현 실수를 방지한다.
|
||||
|
||||
- [x] `mouse-typing` 관리자 라우트와 처리 로직 분석 (`src/web/admin/`, `src/web/server/admin/`)
|
||||
- [x] 연장 승인 시 `AvailableActivateDateTime` 계산 방식 확인 (몇 개월/년 연장인지)
|
||||
- [x] 업그레이드 승인 시 `PlayerCount` 갱신 여부 확인
|
||||
- [x] `admin.Password` 검증 방식 확인 (평문 / MD5 / bcrypt 등)
|
||||
- [x] `maestro_log` 기록 형식(`Type`, `Remark`) 확인
|
||||
- [x] 조사 결과를 `docs/business-rules.md`에 문서화
|
||||
|
||||
### Phase 1. 프로젝트 초기화
|
||||
|
||||
- [x] `pnpm create next-app` (Next.js 16, TypeScript, App Router, Tailwind CSS)
|
||||
- [x] shadcn/ui 초기화 (`pnpm dlx shadcn@latest init`)
|
||||
- [x] TanStack Table 설치 (`pnpm add @tanstack/react-table`)
|
||||
- [x] Prisma 설치 및 MariaDB introspection (`pnpm add prisma`, `prisma db pull`)
|
||||
- [x] Zod 설치 (`pnpm add zod`)
|
||||
- [x] NextAuth.js v5 설치
|
||||
- [x] ESLint / Prettier 설정
|
||||
- [x] `.env.local` 환경변수 구조 정의
|
||||
- [x] IntelliJ에서 `pnpm dev` 실행 확인
|
||||
|
||||
### Phase 2. DB 연결 및 타입 구성
|
||||
|
||||
- [x] Prisma Client 싱글톤 (`lib/db.ts`)
|
||||
- [x] 상태값 상수 정의 (`lib/constants.ts`)
|
||||
- [x] 공통 유틸 작성 (날짜 포맷, AccountType 레이블 변환 등)
|
||||
- [x] 개발 DB에서 `maestro`, `maestro_extension`, `maestro_upgrade` 조회 확인
|
||||
|
||||
### Phase 3. 인증 및 공통 레이아웃
|
||||
|
||||
- [x] 로그인 페이지 (`/login`)
|
||||
- [x] NextAuth Credentials Provider — `admin` 테이블 기반 검증
|
||||
- [x] 인증 프록시 (`proxy.ts`) — 비로그인 시 `/login` 리다이렉트
|
||||
- [x] 공통 레이아웃 (`(admin)/layout.tsx`) — 사이드바, 헤더
|
||||
- [x] 로그아웃 기능
|
||||
|
||||
### Phase 4. 마에스트로 목록 및 상세
|
||||
|
||||
- [x] 마에스트로 목록 API (`GET /api/maestros`) — 검색·필터·페이지네이션 포함
|
||||
- [x] TanStack Table 기반 목록 화면
|
||||
- [x] 마에스트로 상세 API (`GET /api/maestros/[id]`)
|
||||
- [x] 상세 화면 — 연장/업그레이드 이력, 학생 목록 요약, 처리 로그
|
||||
|
||||
### Phase 5. 연장 신청 관리
|
||||
|
||||
- [x] 연장 신청 목록 API + 화면
|
||||
- [x] 승인/취소 확인 다이얼로그
|
||||
- [x] 승인 API (`PATCH /api/extension-requests/[id]`) — 트랜잭션 + maestro_log
|
||||
- [x] 취소 API — 트랜잭션 + maestro_log
|
||||
|
||||
### Phase 6. 업그레이드 신청 관리
|
||||
|
||||
- [x] 업그레이드 신청 목록 API + 화면
|
||||
- [x] 승인/거절 확인 다이얼로그
|
||||
- [x] 승인 API (`PATCH /api/upgrade-requests/[id]`) — 트랜잭션 + maestro_log
|
||||
- [x] 거절 API — 트랜잭션 + maestro_log
|
||||
|
||||
### Phase 7. 배포 환경 구성
|
||||
|
||||
- [x] Dockerfile (프로덕션용 pnpm 빌드)
|
||||
- [x] `docker-compose.yml` (Synology NAS Container Manager용)
|
||||
- [ ] AWS EC2 보안그룹 인바운드 설정 (NAS IP → 3306 허용) — `docs/deployment.md`에 절차 문서화
|
||||
- [ ] Synology NAS에서 컨테이너 실행 테스트 — `docs/deployment.md`에 절차 문서화
|
||||
- [ ] 운영 DB read-only 리허설 후 변경 기능 활성화 — `docs/deployment.md`에 절차 문서화
|
||||
|
||||
### Phase 8. 검증 및 안정화
|
||||
|
||||
- [ ] 승인/취소/거절 트랜잭션 시나리오 테스트
|
||||
- [ ] 중복 처리 방지 (이미 처리된 건 재처리 시 에러 확인)
|
||||
- [ ] 비인증 접근 차단 확인
|
||||
- [ ] Docker 재시작 후 정상 동작 확인
|
||||
|
||||
---
|
||||
|
||||
## 10. MVP 범위
|
||||
|
||||
**1차 MVP (위 Phase 0~7):**
|
||||
- 관리자 로그인
|
||||
- 마에스트로 전체 목록 및 상세 정보 보기
|
||||
- 연장 신청 목록 / 승인 / 취소
|
||||
- 업그레이드 신청 목록 / 승인 / 거절
|
||||
- 관리자 액션 로그 기록
|
||||
- Docker 기반 NAS 배포
|
||||
|
||||
**MVP 이후 확장 후보:**
|
||||
- 마에스트로 상태 직접 변경
|
||||
- 학생 목록 상세 관리
|
||||
- 데이터 내보내기 (CSV)
|
||||
- 처리 통계 대시보드
|
||||
- 감사 로그 전용 화면
|
||||
- 관리자 계정 관리
|
||||
|
||||
---
|
||||
|
||||
## 11. 구현 전 확인이 필요한 사항
|
||||
|
||||
기존 `mouse-typing` 서비스 코드(`src/web/server/admin/`)에서 반드시 확인해야 할 비즈니스 규칙들이다.
|
||||
|
||||
1. 연장 승인 시 `AvailableActivateDateTime`을 정확히 얼마나 연장하는가? (1년? 계정 유형별 다름?)
|
||||
: 계정 유형별로 차이 없고, 1년 연장
|
||||
2. 업그레이드 승인 시 `PlayerCount`를 즉시 변경하는가, 아니면 `AccountType`만 변경하는가?
|
||||
: AccountType만 변경
|
||||
3. `admin.Password`는 어떤 방식으로 저장/검증되는가? (평문, MD5, bcrypt 등)
|
||||
: MariaDB의 Password() 함수를 이용해서 저장/검증됨
|
||||
4. `maestro_log`의 `Type` 컬럼에 어떤 값들이 사용되는가? (`Remark` 형식 포함)
|
||||
: 다음과 같은 값들이 사용됨
|
||||
* add_maestro
|
||||
* update_maestro
|
||||
* add_maestro
|
||||
* register_maestro
|
||||
* update_maestro_name
|
||||
* extension_maestro
|
||||
* upgrade_maestro
|
||||
* update_maestro_email
|
||||
5. 운영 MariaDB가 외부 IP 접속을 허용할 수 있는 네트워크 구조인가?
|
||||
: 외부 IP 접속을 허용하고 있다
|
||||
|
||||
---
|
||||
|
||||
*작성일: 2026-05-26*
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -0,0 +1,81 @@
|
||||
export const ACCOUNT_TYPES = {
|
||||
BASIC_20: 1,
|
||||
STANDARD_50: 2,
|
||||
PRO_100: 3,
|
||||
SCHOOL_500: 4,
|
||||
SCHOOL_1000: 5,
|
||||
STUDENT_TRIAL: 100,
|
||||
MAESTRO_TRIAL: 101,
|
||||
} as const;
|
||||
|
||||
export const ACCOUNT_TYPE_LABELS = {
|
||||
[ACCOUNT_TYPES.BASIC_20]: "20명 (1만원)",
|
||||
[ACCOUNT_TYPES.STANDARD_50]: "50명 (2만원)",
|
||||
[ACCOUNT_TYPES.PRO_100]: "100명 (3만원)",
|
||||
[ACCOUNT_TYPES.SCHOOL_500]: "500명 (4만원)",
|
||||
[ACCOUNT_TYPES.SCHOOL_1000]: "1,000명 (5만원)",
|
||||
[ACCOUNT_TYPES.STUDENT_TRIAL]: "학생체험",
|
||||
[ACCOUNT_TYPES.MAESTRO_TRIAL]: "마에체험",
|
||||
} as const;
|
||||
|
||||
export const TRIAL_ACCOUNT_TYPE_LABELS = {
|
||||
[ACCOUNT_TYPES.BASIC_20]: "20명 (0원)",
|
||||
[ACCOUNT_TYPES.STANDARD_50]: "50명 (0원)",
|
||||
[ACCOUNT_TYPES.PRO_100]: "100명 (0원)",
|
||||
[ACCOUNT_TYPES.SCHOOL_500]: "500명 (0원)",
|
||||
[ACCOUNT_TYPES.SCHOOL_1000]: "1,000명 (0원)",
|
||||
} as const;
|
||||
|
||||
export const ACCOUNT_TYPE_PLAYER_COUNTS = {
|
||||
[ACCOUNT_TYPES.BASIC_20]: 20,
|
||||
[ACCOUNT_TYPES.STANDARD_50]: 50,
|
||||
[ACCOUNT_TYPES.PRO_100]: 100,
|
||||
[ACCOUNT_TYPES.SCHOOL_500]: 500,
|
||||
[ACCOUNT_TYPES.SCHOOL_1000]: 1000,
|
||||
} as const;
|
||||
|
||||
export const ACCOUNT_TYPE_PRICES = {
|
||||
[ACCOUNT_TYPES.BASIC_20]: 10000,
|
||||
[ACCOUNT_TYPES.STANDARD_50]: 20000,
|
||||
[ACCOUNT_TYPES.PRO_100]: 30000,
|
||||
[ACCOUNT_TYPES.SCHOOL_500]: 40000,
|
||||
[ACCOUNT_TYPES.SCHOOL_1000]: 50000,
|
||||
} as const;
|
||||
|
||||
export const ACTIVATE_STATUSES = {
|
||||
PENDING: 0,
|
||||
TRIAL: 1,
|
||||
ACTIVE: 2,
|
||||
CANCELED: 100,
|
||||
} as const;
|
||||
|
||||
export const ACTIVATE_STATUS_LABELS = {
|
||||
[ACTIVATE_STATUSES.PENDING]: "미승인",
|
||||
[ACTIVATE_STATUSES.TRIAL]: "체험",
|
||||
[ACTIVATE_STATUSES.ACTIVE]: "활성화",
|
||||
[ACTIVATE_STATUSES.CANCELED]: "등록 취소",
|
||||
} as const;
|
||||
|
||||
export const REQUEST_STATUSES = {
|
||||
REQUESTED: 1,
|
||||
CLOSED: 2,
|
||||
APPLIED: 3,
|
||||
} as const;
|
||||
|
||||
export const REQUEST_STATUS_LABELS = {
|
||||
[REQUEST_STATUSES.REQUESTED]: "요청",
|
||||
[REQUEST_STATUSES.CLOSED]: "취소",
|
||||
[REQUEST_STATUSES.APPLIED]: "적용 완료",
|
||||
} as const;
|
||||
|
||||
export type AccountType = (typeof ACCOUNT_TYPES)[keyof typeof ACCOUNT_TYPES];
|
||||
export type PaidAccountType =
|
||||
| typeof ACCOUNT_TYPES.BASIC_20
|
||||
| typeof ACCOUNT_TYPES.STANDARD_50
|
||||
| typeof ACCOUNT_TYPES.PRO_100
|
||||
| typeof ACCOUNT_TYPES.SCHOOL_500
|
||||
| typeof ACCOUNT_TYPES.SCHOOL_1000;
|
||||
export type ActivateStatus =
|
||||
(typeof ACTIVATE_STATUSES)[keyof typeof ACTIVATE_STATUSES];
|
||||
export type RequestStatus =
|
||||
(typeof REQUEST_STATUSES)[keyof typeof REQUEST_STATUSES];
|
||||
@@ -0,0 +1,25 @@
|
||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
|
||||
import { PrismaClient } from "@/lib/generated/prisma/client";
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma?: PrismaClient;
|
||||
};
|
||||
|
||||
function createPrismaClient() {
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
|
||||
if (!databaseUrl) {
|
||||
throw new Error("DATABASE_URL is required to initialize Prisma Client.");
|
||||
}
|
||||
|
||||
const adapter = new PrismaMariaDb(databaseUrl);
|
||||
|
||||
return new PrismaClient({ adapter });
|
||||
}
|
||||
|
||||
export const db = globalForPrisma.prisma ?? createPrismaClient();
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
globalForPrisma.prisma = db;
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
+370
@@ -0,0 +1,370 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { db } from "@/lib/db";
|
||||
import type { Prisma } from "@/lib/generated/prisma/client";
|
||||
|
||||
const pageSizeOptions = [10, 20, 50, 100] as const;
|
||||
const sortOptions = [
|
||||
"id-desc",
|
||||
"id-asc",
|
||||
"joinedAt-desc",
|
||||
"joinedAt-asc",
|
||||
"availableAt-desc",
|
||||
"availableAt-asc",
|
||||
] as const;
|
||||
|
||||
const maestroListSearchSchema = 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(""),
|
||||
activateStatus: z
|
||||
.union([z.literal("all"), z.coerce.number().int().min(0)])
|
||||
.optional()
|
||||
.catch(undefined),
|
||||
accountType: z
|
||||
.union([z.literal("all"), z.coerce.number().int().min(0)])
|
||||
.optional()
|
||||
.catch(undefined),
|
||||
sort: z.enum(sortOptions).catch("id-desc"),
|
||||
});
|
||||
|
||||
type PageSize = (typeof pageSizeOptions)[number];
|
||||
export type MaestroSort = (typeof sortOptions)[number];
|
||||
export type MaestroListSearchParams = z.infer<typeof maestroListSearchSchema>;
|
||||
export type RawSearchParams =
|
||||
| URLSearchParams
|
||||
| Record<string, string | string[] | undefined>;
|
||||
|
||||
export type MaestroListItem = {
|
||||
maestroID: number;
|
||||
name: string;
|
||||
email: string;
|
||||
accountType: number;
|
||||
activateStatus: number;
|
||||
availableActivateDateTime: string;
|
||||
acceptClausesDateTime: string;
|
||||
playerCount: number;
|
||||
};
|
||||
|
||||
export type MaestroListResult = {
|
||||
items: MaestroListItem[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalCount: number;
|
||||
totalPages: number;
|
||||
q: string;
|
||||
activateStatus?: number | "all";
|
||||
accountType?: number | "all";
|
||||
sort: MaestroSort;
|
||||
};
|
||||
|
||||
export type MaestroDetail = {
|
||||
maestro: MaestroListItem & {
|
||||
allowEditEnterCode: number;
|
||||
maestroTestID: number | null;
|
||||
};
|
||||
counts: {
|
||||
players: number;
|
||||
extensionRequests: number;
|
||||
upgradeRequests: number;
|
||||
logs: number;
|
||||
};
|
||||
players: Array<{
|
||||
playerID: number;
|
||||
name: string;
|
||||
enterCode: string;
|
||||
accountType: number | null;
|
||||
}>;
|
||||
extensionRequests: Array<{
|
||||
maestroExtensionID: number;
|
||||
accountType: number;
|
||||
requestedDateTime: string;
|
||||
status: number;
|
||||
}>;
|
||||
upgradeRequests: Array<{
|
||||
maestroUpgradeID: number;
|
||||
registeredActivateStatus: number;
|
||||
registeredAccountType: number;
|
||||
requestedAccountType: number;
|
||||
requestedDateTime: string;
|
||||
status: number;
|
||||
}>;
|
||||
logs: Array<{
|
||||
maestroLogID: number;
|
||||
type: string;
|
||||
logDateTime: string;
|
||||
remark: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function getMaestros(
|
||||
rawSearchParams: RawSearchParams = {}
|
||||
): Promise<MaestroListResult> {
|
||||
const params = parseMaestroListSearchParams(rawSearchParams);
|
||||
const where = buildMaestroWhere(params);
|
||||
const orderBy = buildMaestroOrderBy(params.sort);
|
||||
const skip = (params.page - 1) * params.pageSize;
|
||||
|
||||
const [totalCount, maestros] = await Promise.all([
|
||||
db.maestro.count({ where }),
|
||||
db.maestro.findMany({
|
||||
where,
|
||||
orderBy,
|
||||
skip,
|
||||
take: params.pageSize,
|
||||
select: {
|
||||
MaestroID: true,
|
||||
Name: true,
|
||||
Email: true,
|
||||
AccountType: true,
|
||||
ActivateStatus: true,
|
||||
AvailableActivateDateTime: true,
|
||||
AcceptClausesDateTime: true,
|
||||
PlayerCount: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / params.pageSize));
|
||||
|
||||
return {
|
||||
items: maestros.map(toMaestroListItem),
|
||||
page: params.page,
|
||||
pageSize: params.pageSize,
|
||||
totalCount,
|
||||
totalPages,
|
||||
q: params.q,
|
||||
activateStatus: params.activateStatus,
|
||||
accountType: params.accountType,
|
||||
sort: params.sort,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getMaestroDetail(
|
||||
maestroID: number
|
||||
): Promise<MaestroDetail | null> {
|
||||
const maestro = await db.maestro.findUnique({
|
||||
where: { MaestroID: maestroID },
|
||||
select: {
|
||||
MaestroID: true,
|
||||
Name: true,
|
||||
Email: true,
|
||||
AccountType: true,
|
||||
ActivateStatus: true,
|
||||
AvailableActivateDateTime: true,
|
||||
AcceptClausesDateTime: true,
|
||||
PlayerCount: true,
|
||||
AllowEditEnterCode: true,
|
||||
MaestroTestID: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!maestro) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [
|
||||
playersCount,
|
||||
extensionRequestsCount,
|
||||
upgradeRequestsCount,
|
||||
logsCount,
|
||||
players,
|
||||
extensionRequests,
|
||||
upgradeRequests,
|
||||
logs,
|
||||
] = await Promise.all([
|
||||
db.player.count({ where: { MaestroID: maestroID } }),
|
||||
db.maestro_extension.count({ where: { MaestroID: maestroID } }),
|
||||
db.maestro_upgrade.count({ where: { MaestroID: maestroID } }),
|
||||
db.maestro_log.count({ where: { MaestroID: maestroID } }),
|
||||
db.player.findMany({
|
||||
where: { MaestroID: maestroID },
|
||||
orderBy: { PlayerID: "desc" },
|
||||
take: 20,
|
||||
select: {
|
||||
PlayerID: true,
|
||||
Name: true,
|
||||
EnterCode: true,
|
||||
AccountType: true,
|
||||
},
|
||||
}),
|
||||
db.maestro_extension.findMany({
|
||||
where: { MaestroID: maestroID },
|
||||
orderBy: { MaestroExtensionID: "desc" },
|
||||
take: 20,
|
||||
select: {
|
||||
MaestroExtensionID: true,
|
||||
AccountType: true,
|
||||
RequestedDateTime: true,
|
||||
Status: true,
|
||||
},
|
||||
}),
|
||||
db.maestro_upgrade.findMany({
|
||||
where: { MaestroID: maestroID },
|
||||
orderBy: { MaestroUpgradeID: "desc" },
|
||||
take: 20,
|
||||
select: {
|
||||
MaestroUpgradeID: true,
|
||||
RegisteredActivateStatus: true,
|
||||
RegisteredAccountType: true,
|
||||
RequestedAccountType: true,
|
||||
RequestedDateTime: true,
|
||||
Status: true,
|
||||
},
|
||||
}),
|
||||
db.maestro_log.findMany({
|
||||
where: { MaestroID: maestroID },
|
||||
orderBy: { MaestroLogID: "desc" },
|
||||
take: 30,
|
||||
select: {
|
||||
MaestroLogID: true,
|
||||
Type: true,
|
||||
LogDateTime: true,
|
||||
Remark: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
maestro: {
|
||||
...toMaestroListItem(maestro),
|
||||
allowEditEnterCode: maestro.AllowEditEnterCode,
|
||||
maestroTestID: maestro.MaestroTestID,
|
||||
},
|
||||
counts: {
|
||||
players: playersCount,
|
||||
extensionRequests: extensionRequestsCount,
|
||||
upgradeRequests: upgradeRequestsCount,
|
||||
logs: logsCount,
|
||||
},
|
||||
players: players.map((player) => ({
|
||||
playerID: player.PlayerID,
|
||||
name: player.Name.trim(),
|
||||
enterCode: player.EnterCode.trim(),
|
||||
accountType: player.AccountType,
|
||||
})),
|
||||
extensionRequests: extensionRequests.map((request) => ({
|
||||
maestroExtensionID: request.MaestroExtensionID,
|
||||
accountType: request.AccountType,
|
||||
requestedDateTime: request.RequestedDateTime.toISOString(),
|
||||
status: request.Status,
|
||||
})),
|
||||
upgradeRequests: upgradeRequests.map((request) => ({
|
||||
maestroUpgradeID: request.MaestroUpgradeID,
|
||||
registeredActivateStatus: request.RegisteredActivateStatus,
|
||||
registeredAccountType: request.RegisteredAccountType,
|
||||
requestedAccountType: request.RequestedAccountType,
|
||||
requestedDateTime: request.RequestedDateTime.toISOString(),
|
||||
status: request.Status,
|
||||
})),
|
||||
logs: logs.map((log) => ({
|
||||
maestroLogID: log.MaestroLogID,
|
||||
type: log.Type.trim(),
|
||||
logDateTime: log.LogDateTime.toISOString(),
|
||||
remark: log.Remark.trim(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseMaestroListSearchParams(
|
||||
rawSearchParams: RawSearchParams
|
||||
): MaestroListSearchParams {
|
||||
return maestroListSearchSchema.parse(searchParamsToRecord(rawSearchParams));
|
||||
}
|
||||
|
||||
function buildMaestroWhere(
|
||||
params: MaestroListSearchParams
|
||||
): Prisma.maestroWhereInput {
|
||||
const conditions: Prisma.maestroWhereInput[] = [];
|
||||
|
||||
if (params.q) {
|
||||
const keywordConditions: Prisma.maestroWhereInput[] = [
|
||||
{ Name: { contains: params.q } },
|
||||
{ Email: { contains: params.q } },
|
||||
];
|
||||
const numericQuery = Number(params.q);
|
||||
|
||||
if (Number.isInteger(numericQuery) && numericQuery > 0) {
|
||||
keywordConditions.unshift({ MaestroID: numericQuery });
|
||||
}
|
||||
|
||||
conditions.push({ OR: keywordConditions });
|
||||
}
|
||||
|
||||
if (
|
||||
typeof params.activateStatus === "number" &&
|
||||
Number.isInteger(params.activateStatus)
|
||||
) {
|
||||
conditions.push({ ActivateStatus: params.activateStatus });
|
||||
}
|
||||
|
||||
if (
|
||||
typeof params.accountType === "number" &&
|
||||
Number.isInteger(params.accountType)
|
||||
) {
|
||||
conditions.push({ AccountType: params.accountType });
|
||||
}
|
||||
|
||||
return conditions.length > 0 ? { AND: conditions } : {};
|
||||
}
|
||||
|
||||
function buildMaestroOrderBy(
|
||||
sort: MaestroSort
|
||||
): Prisma.maestroOrderByWithRelationInput {
|
||||
switch (sort) {
|
||||
case "id-asc":
|
||||
return { MaestroID: "asc" };
|
||||
case "joinedAt-desc":
|
||||
return { AcceptClausesDateTime: "desc" };
|
||||
case "joinedAt-asc":
|
||||
return { AcceptClausesDateTime: "asc" };
|
||||
case "availableAt-desc":
|
||||
return { AvailableActivateDateTime: "desc" };
|
||||
case "availableAt-asc":
|
||||
return { AvailableActivateDateTime: "asc" };
|
||||
case "id-desc":
|
||||
default:
|
||||
return { MaestroID: "desc" };
|
||||
}
|
||||
}
|
||||
|
||||
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 toMaestroListItem(maestro: {
|
||||
MaestroID: number;
|
||||
Name: string;
|
||||
Email: string;
|
||||
AccountType: number;
|
||||
ActivateStatus: number;
|
||||
AvailableActivateDateTime: Date;
|
||||
AcceptClausesDateTime: Date;
|
||||
PlayerCount: number;
|
||||
}): MaestroListItem {
|
||||
return {
|
||||
maestroID: maestro.MaestroID,
|
||||
name: maestro.Name.trim(),
|
||||
email: maestro.Email.trim(),
|
||||
accountType: maestro.AccountType,
|
||||
activateStatus: maestro.ActivateStatus,
|
||||
availableActivateDateTime: maestro.AvailableActivateDateTime.toISOString(),
|
||||
acceptClausesDateTime: maestro.AcceptClausesDateTime.toISOString(),
|
||||
playerCount: maestro.PlayerCount,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import {
|
||||
ACCOUNT_TYPE_LABELS,
|
||||
ACCOUNT_TYPE_PLAYER_COUNTS,
|
||||
ACCOUNT_TYPE_PRICES,
|
||||
ACTIVATE_STATUS_LABELS,
|
||||
REQUEST_STATUS_LABELS,
|
||||
TRIAL_ACCOUNT_TYPE_LABELS,
|
||||
type AccountType,
|
||||
type ActivateStatus,
|
||||
type PaidAccountType,
|
||||
type RequestStatus,
|
||||
} from "@/lib/constants";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function getAccountTypeLabel(accountType: number): string {
|
||||
return (
|
||||
ACCOUNT_TYPE_LABELS[accountType as AccountType] ?? `${accountType}? (N/A)`
|
||||
);
|
||||
}
|
||||
|
||||
export function getTrialAccountTypeLabel(accountType: number): string {
|
||||
return (
|
||||
TRIAL_ACCOUNT_TYPE_LABELS[accountType as PaidAccountType] ??
|
||||
`${accountType}? (N/A)`
|
||||
);
|
||||
}
|
||||
|
||||
export function getAccountTypePlayerCount(accountType: number): number {
|
||||
return ACCOUNT_TYPE_PLAYER_COUNTS[accountType as PaidAccountType] ?? 0;
|
||||
}
|
||||
|
||||
export function getAccountTypePrice(accountType: number): number {
|
||||
return ACCOUNT_TYPE_PRICES[accountType as PaidAccountType] ?? 0;
|
||||
}
|
||||
|
||||
export function getActivateStatusLabel(activateStatus: number): string {
|
||||
return (
|
||||
ACTIVATE_STATUS_LABELS[activateStatus as ActivateStatus] ??
|
||||
`${activateStatus}? (N/A)`
|
||||
);
|
||||
}
|
||||
|
||||
export function getRequestStatusLabel(status: number): string {
|
||||
return REQUEST_STATUS_LABELS[status as RequestStatus] ?? `${status}? (N/A)`;
|
||||
}
|
||||
|
||||
export function calculateUpgradePrice(
|
||||
requestedAccountType: number,
|
||||
registeredAccountType: number
|
||||
): number {
|
||||
return (
|
||||
getAccountTypePrice(requestedAccountType) -
|
||||
getAccountTypePrice(registeredAccountType)
|
||||
);
|
||||
}
|
||||
|
||||
export function formatDate(value: Date | string | null | undefined): string {
|
||||
const date = toDate(value);
|
||||
|
||||
if (!date) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
export function formatDateTime(
|
||||
value: Date | string | null | undefined
|
||||
): string {
|
||||
const date = toDate(value);
|
||||
|
||||
if (!date) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const hours = String(date.getHours()).padStart(2, "0");
|
||||
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||
const seconds = String(date.getSeconds()).padStart(2, "0");
|
||||
|
||||
return `${formatDate(date)} ${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
export function calculateExtendedAvailableDate(
|
||||
availableDate: Date,
|
||||
now = new Date()
|
||||
): Date {
|
||||
const baseDate =
|
||||
availableDate.getTime() < now.getTime() ? now : availableDate;
|
||||
const extendedDate = new Date(baseDate);
|
||||
|
||||
extendedDate.setFullYear(extendedDate.getFullYear() + 1);
|
||||
extendedDate.setHours(0, 0, 0, 0);
|
||||
|
||||
return extendedDate;
|
||||
}
|
||||
|
||||
function toDate(value: Date | string | null | undefined): Date | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return date;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "chocoadmin",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.29.3",
|
||||
"scripts": {
|
||||
"dev": "next dev --port 3001",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"db:check": "tsx scripts/check-db.ts",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.5.0",
|
||||
"@prisma/adapter-mariadb": "^7.8.0",
|
||||
"@prisma/client": "^7.8.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.17.0",
|
||||
"mariadb": "^3.5.2",
|
||||
"next": "16.2.7",
|
||||
"next-auth": "5.0.0-beta.31",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"shadcn": "^4.11.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"dotenv": "^17.4.2",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.7",
|
||||
"prettier": "^3.8.4",
|
||||
"prisma": "^7.8.0",
|
||||
"tailwindcss": "^4",
|
||||
"tsx": "^4.22.4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
Generated
+7002
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
ignoredBuiltDependencies:
|
||||
- sharp
|
||||
- unrs-resolver
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,14 @@
|
||||
import { config } from "dotenv";
|
||||
import { defineConfig } from "prisma/config";
|
||||
|
||||
config({ path: ".env.local" });
|
||||
|
||||
export default defineConfig({
|
||||
schema: "prisma/schema.prisma",
|
||||
migrations: {
|
||||
path: "prisma/migrations",
|
||||
},
|
||||
datasource: {
|
||||
url: process.env["DATABASE_URL"],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
generator client {
|
||||
provider = "prisma-client"
|
||||
output = "../lib/generated/prisma"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "mysql"
|
||||
}
|
||||
|
||||
model active_app {
|
||||
ActiveAppID Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
MaestroID Int @db.UnsignedInt
|
||||
AppID Int @db.UnsignedInt
|
||||
maestro maestro @relation(fields: [MaestroID], references: [MaestroID], onUpdate: Restrict, map: "active_app_ibfk_1")
|
||||
app app @relation(fields: [AppID], references: [AppID], onUpdate: Restrict, map: "active_app_ibfk_2")
|
||||
|
||||
@@index([AppID], map: "AppID")
|
||||
@@index([MaestroID], map: "MaestroID")
|
||||
}
|
||||
|
||||
model admin {
|
||||
AdminID Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
Name String @db.Char(50)
|
||||
Password String @db.Char(50)
|
||||
Email String @db.Char(50)
|
||||
AccountType Int @db.UnsignedInt
|
||||
ActivateStatus Int @db.UnsignedInt
|
||||
}
|
||||
|
||||
model ads_client {
|
||||
AdsClientID Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
ClientName String @db.Char(50)
|
||||
typing_exam_ads typing_exam_ads[]
|
||||
}
|
||||
|
||||
model app {
|
||||
AppID Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
AppName String @db.Char(40)
|
||||
KoreanName String @db.Char(40)
|
||||
AppType Int @db.UnsignedInt
|
||||
Status Int @db.UnsignedInt
|
||||
HowToPlay String @db.Text
|
||||
active_app active_app[]
|
||||
app_highest_record app_highest_record[]
|
||||
best_record best_record[]
|
||||
}
|
||||
|
||||
model app_highest_record {
|
||||
AppHighestRecordID Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
MaestroID Int @db.UnsignedInt
|
||||
AppID Int @db.UnsignedInt
|
||||
PlayerID Int @db.UnsignedInt
|
||||
HighestRecord Float @db.Float
|
||||
RecordDateTime DateTime @db.DateTime(0)
|
||||
maestro maestro @relation(fields: [MaestroID], references: [MaestroID], onUpdate: Restrict, map: "app_highest_record_ibfk_1")
|
||||
app app @relation(fields: [AppID], references: [AppID], onUpdate: Restrict, map: "app_highest_record_ibfk_2")
|
||||
player player @relation(fields: [PlayerID], references: [PlayerID], onUpdate: Restrict, map: "app_highest_record_ibfk_3")
|
||||
|
||||
@@index([AppID], map: "AppID")
|
||||
@@index([MaestroID], map: "MaestroID")
|
||||
@@index([PlayerID], map: "PlayerID")
|
||||
}
|
||||
|
||||
model banned_word {
|
||||
BannedWordID Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
Word String @db.Char(50)
|
||||
}
|
||||
|
||||
model best_record {
|
||||
BestRecordID Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
MaestroID Int @db.UnsignedInt
|
||||
AppID Int @db.UnsignedInt
|
||||
PlayerID Int @db.UnsignedInt
|
||||
BestRecord Float @db.Float
|
||||
RecordDateTime DateTime @db.DateTime(0)
|
||||
maestro maestro @relation(fields: [MaestroID], references: [MaestroID], onUpdate: Restrict, map: "best_record_ibfk_1")
|
||||
app app @relation(fields: [AppID], references: [AppID], onUpdate: Restrict, map: "best_record_ibfk_2")
|
||||
player player @relation(fields: [PlayerID], references: [PlayerID], onUpdate: Restrict, map: "best_record_ibfk_3")
|
||||
|
||||
@@index([AppID], map: "AppID")
|
||||
@@index([MaestroID], map: "MaestroID")
|
||||
@@index([PlayerID], map: "PlayerID")
|
||||
}
|
||||
|
||||
model license_maestro_password {
|
||||
LicenseMaestroPasswordID Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
MaestroID Int @db.UnsignedInt
|
||||
Password Int @db.UnsignedInt
|
||||
maestro maestro @relation(fields: [MaestroID], references: [MaestroID], onUpdate: Restrict, map: "license_maestro_password_ibfk_1")
|
||||
|
||||
@@index([MaestroID], map: "MaestroID")
|
||||
}
|
||||
|
||||
model license_score {
|
||||
LicenseScoreID Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
MaestroID Int @db.UnsignedInt
|
||||
PlayerID Int @db.UnsignedInt
|
||||
SubjectName String @db.Char(50)
|
||||
Score Int @db.UnsignedInt
|
||||
ScoreDateTime DateTime @db.DateTime(0)
|
||||
maestro maestro @relation(fields: [MaestroID], references: [MaestroID], onUpdate: Restrict, map: "license_score_ibfk_1")
|
||||
player player @relation(fields: [PlayerID], references: [PlayerID], onUpdate: Restrict, map: "license_score_ibfk_2")
|
||||
|
||||
@@index([MaestroID], map: "MaestroID")
|
||||
@@index([PlayerID], map: "PlayerID")
|
||||
}
|
||||
|
||||
model license_time {
|
||||
LicenseTimeID Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
MaestroID Int @db.UnsignedInt
|
||||
PlayerID Int @db.UnsignedInt
|
||||
StartTime Int @db.UnsignedInt
|
||||
LeftTime Int
|
||||
SavedDateTime DateTime @db.DateTime(0)
|
||||
SubjectName String @db.Char(50)
|
||||
maestro maestro @relation(fields: [MaestroID], references: [MaestroID], onUpdate: Restrict, map: "license_time_ibfk_1")
|
||||
player player @relation(fields: [PlayerID], references: [PlayerID], onUpdate: Restrict, map: "license_time_ibfk_2")
|
||||
|
||||
@@index([MaestroID], map: "MaestroID")
|
||||
@@index([PlayerID], map: "PlayerID")
|
||||
}
|
||||
|
||||
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
|
||||
model maestro {
|
||||
MaestroID Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
Name String @db.Char(50)
|
||||
Password String @db.Char(50)
|
||||
Email String @db.Char(50)
|
||||
AccountType Int @db.UnsignedInt
|
||||
ActivateStatus Int @db.UnsignedInt
|
||||
AvailableActivateDateTime DateTime @db.DateTime(0)
|
||||
PlayerCount Int @db.UnsignedInt
|
||||
AcceptClausesDateTime DateTime @db.DateTime(0)
|
||||
AllowEditEnterCode Int @db.UnsignedInt
|
||||
MaestroTestID Int? @db.UnsignedInt
|
||||
active_app active_app[]
|
||||
app_highest_record app_highest_record[]
|
||||
best_record best_record[]
|
||||
license_maestro_password license_maestro_password[]
|
||||
license_score license_score[]
|
||||
license_time license_time[]
|
||||
maestro_extension maestro_extension[]
|
||||
maestro_upgrade maestro_upgrade[]
|
||||
player player[]
|
||||
typing_exam_record typing_exam_record[]
|
||||
}
|
||||
|
||||
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
|
||||
model maestro_extension {
|
||||
MaestroExtensionID Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
AccountType Int @db.UnsignedInt
|
||||
RequestedDateTime DateTime @db.DateTime(0)
|
||||
Status Int @db.UnsignedInt
|
||||
MaestroID Int @db.UnsignedInt
|
||||
maestro maestro @relation(fields: [MaestroID], references: [MaestroID], onUpdate: Restrict, map: "maestro_extension_ibfk_1")
|
||||
|
||||
@@index([MaestroID], map: "MaestroID")
|
||||
}
|
||||
|
||||
model maestro_log {
|
||||
MaestroLogID Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
Type String @db.Char(50)
|
||||
MaestroID Int @db.UnsignedInt
|
||||
LogDateTime DateTime @db.DateTime(0)
|
||||
Remark String @db.Char(100)
|
||||
}
|
||||
|
||||
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
|
||||
model maestro_upgrade {
|
||||
MaestroUpgradeID Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
RegisteredActivateStatus Int @default(1) @db.UnsignedInt
|
||||
RegisteredAccountType Int
|
||||
RequestedAccountType Int @db.UnsignedInt
|
||||
RequestedDateTime DateTime @db.DateTime(0)
|
||||
Status Int @db.UnsignedInt
|
||||
MaestroID Int @db.UnsignedInt
|
||||
maestro maestro @relation(fields: [MaestroID], references: [MaestroID], onUpdate: Restrict, map: "maestro_upgrade_ibfk_1")
|
||||
|
||||
@@index([MaestroID], map: "MaestroID")
|
||||
}
|
||||
|
||||
model player {
|
||||
PlayerID Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
MaestroID Int @db.UnsignedInt
|
||||
Name String @db.Char(50)
|
||||
EnterCode String @db.Char(8)
|
||||
AccountType Int? @default(0) @db.UnsignedInt
|
||||
app_highest_record app_highest_record[]
|
||||
best_record best_record[]
|
||||
license_score license_score[]
|
||||
license_time license_time[]
|
||||
maestro maestro @relation(fields: [MaestroID], references: [MaestroID], onUpdate: Restrict, map: "player_ibfk_1")
|
||||
typing_exam_record typing_exam_record[]
|
||||
|
||||
@@index([MaestroID], map: "MaestroID")
|
||||
}
|
||||
|
||||
model typing_exam_ads {
|
||||
TypingExamAdsID Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
AdsClientID Int @db.UnsignedInt
|
||||
WritingID Int @db.UnsignedInt
|
||||
FromDateTime DateTime @db.DateTime(0)
|
||||
ToDateTime DateTime @db.DateTime(0)
|
||||
Status Int @db.UnsignedInt
|
||||
Cost Float @db.Float
|
||||
ads_client ads_client @relation(fields: [AdsClientID], references: [AdsClientID], onUpdate: Restrict, map: "typing_exam_ads_ibfk_1")
|
||||
writing writing @relation(fields: [WritingID], references: [WritingID], onUpdate: Restrict, map: "typing_exam_ads_ibfk_2")
|
||||
|
||||
@@index([AdsClientID], map: "AdsClientID")
|
||||
@@index([WritingID], map: "WritingID")
|
||||
}
|
||||
|
||||
model typing_exam_highest_record {
|
||||
TypingExamHighestRecordID Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
MaestroID Int @db.UnsignedInt
|
||||
PlayerID Int @db.UnsignedInt
|
||||
WritingID Int @db.UnsignedInt
|
||||
HighestRecord Float @db.Float
|
||||
RecordDateTime DateTime @db.DateTime(0)
|
||||
|
||||
@@index([MaestroID], map: "MaestroID")
|
||||
@@index([PlayerID], map: "PlayerID")
|
||||
@@index([WritingID], map: "WritingID")
|
||||
}
|
||||
|
||||
model typing_exam_record {
|
||||
TypingExamRecordID Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
MaestroID Int @db.UnsignedInt
|
||||
PlayerID Int @db.UnsignedInt
|
||||
WritingID Int @db.UnsignedInt
|
||||
Record Float @db.Float
|
||||
RecordDateTime DateTime @db.DateTime(0)
|
||||
maestro maestro @relation(fields: [MaestroID], references: [MaestroID], onUpdate: Restrict, map: "typing_exam_record_ibfk_1")
|
||||
player player @relation(fields: [PlayerID], references: [PlayerID], onUpdate: Restrict, map: "typing_exam_record_ibfk_2")
|
||||
writing writing @relation(fields: [WritingID], references: [WritingID], onUpdate: Restrict, map: "typing_exam_record_ibfk_3")
|
||||
|
||||
@@index([MaestroID], map: "MaestroID")
|
||||
@@index([PlayerID], map: "PlayerID")
|
||||
@@index([WritingID], map: "WritingID")
|
||||
}
|
||||
|
||||
model writing {
|
||||
WritingID Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
ActivateStatus Int
|
||||
Language String @db.Char(10)
|
||||
Name String @db.Char(50)
|
||||
Filename String @db.Char(50)
|
||||
Writer String @db.Char(50)
|
||||
WriterID Int? @db.UnsignedInt
|
||||
UpdateDate DateTime @db.DateTime(0)
|
||||
LetterCount Int @db.UnsignedInt
|
||||
typing_exam_ads typing_exam_ads[]
|
||||
typing_exam_record typing_exam_record[]
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { getToken } from "next-auth/jwt";
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
|
||||
const PUBLIC_PATHS = ["/login"];
|
||||
|
||||
export async function proxy(request: NextRequest) {
|
||||
const { pathname, search } = request.nextUrl;
|
||||
const isPublicPath = PUBLIC_PATHS.includes(pathname);
|
||||
const token = await getToken({
|
||||
req: request,
|
||||
secret: process.env.AUTH_SECRET ?? process.env.NEXTAUTH_SECRET,
|
||||
});
|
||||
|
||||
if (!token && !isPublicPath) {
|
||||
const loginUrl = new URL("/login", request.url);
|
||||
loginUrl.searchParams.set("callbackUrl", `${pathname}${search}`);
|
||||
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
if (token && isPublicPath) {
|
||||
return NextResponse.redirect(new URL("/", request.url));
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!api/auth|_next/static|_next/image|favicon.ico|.*\\..*).*)"],
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
@@ -0,0 +1,87 @@
|
||||
import { config } from "dotenv";
|
||||
|
||||
config({ path: ".env.local" });
|
||||
|
||||
async function main() {
|
||||
const { db } = await import("@/lib/db");
|
||||
const { getAccountTypeLabel, getActivateStatusLabel, getRequestStatusLabel } =
|
||||
await import("@/lib/utils");
|
||||
|
||||
const [maestroCount, extensionCount, upgradeCount] = await Promise.all([
|
||||
db.maestro.count(),
|
||||
db.maestro_extension.count(),
|
||||
db.maestro_upgrade.count(),
|
||||
]);
|
||||
|
||||
const [maestros, extensionRequests, upgradeRequests] = await Promise.all([
|
||||
db.maestro.findMany({
|
||||
orderBy: { MaestroID: "desc" },
|
||||
select: {
|
||||
MaestroID: true,
|
||||
Name: true,
|
||||
AccountType: true,
|
||||
ActivateStatus: true,
|
||||
PlayerCount: true,
|
||||
},
|
||||
take: 3,
|
||||
}),
|
||||
db.maestro_extension.findMany({
|
||||
orderBy: { MaestroExtensionID: "desc" },
|
||||
select: {
|
||||
MaestroExtensionID: true,
|
||||
MaestroID: true,
|
||||
AccountType: true,
|
||||
Status: true,
|
||||
},
|
||||
take: 3,
|
||||
}),
|
||||
db.maestro_upgrade.findMany({
|
||||
orderBy: { MaestroUpgradeID: "desc" },
|
||||
select: {
|
||||
MaestroUpgradeID: true,
|
||||
MaestroID: true,
|
||||
RegisteredAccountType: true,
|
||||
RequestedAccountType: true,
|
||||
Status: true,
|
||||
},
|
||||
take: 3,
|
||||
}),
|
||||
]);
|
||||
|
||||
console.log("DB connection OK");
|
||||
console.log({
|
||||
counts: {
|
||||
maestro: maestroCount,
|
||||
maestro_extension: extensionCount,
|
||||
maestro_upgrade: upgradeCount,
|
||||
},
|
||||
maestros: maestros.map((maestro) => ({
|
||||
id: maestro.MaestroID,
|
||||
name: maestro.Name,
|
||||
accountType: getAccountTypeLabel(maestro.AccountType),
|
||||
activateStatus: getActivateStatusLabel(maestro.ActivateStatus),
|
||||
playerCount: maestro.PlayerCount,
|
||||
})),
|
||||
extensionRequests: extensionRequests.map((request) => ({
|
||||
id: request.MaestroExtensionID,
|
||||
maestroId: request.MaestroID,
|
||||
accountType: getAccountTypeLabel(request.AccountType),
|
||||
status: getRequestStatusLabel(request.Status),
|
||||
})),
|
||||
upgradeRequests: upgradeRequests.map((request) => ({
|
||||
id: request.MaestroUpgradeID,
|
||||
maestroId: request.MaestroID,
|
||||
from: getAccountTypeLabel(request.RegisteredAccountType),
|
||||
to: getAccountTypeLabel(request.RequestedAccountType),
|
||||
status: getRequestStatusLabel(request.Status),
|
||||
})),
|
||||
});
|
||||
|
||||
await db.$disconnect();
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error("DB connection check failed");
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export type {
|
||||
admin,
|
||||
maestro,
|
||||
maestro_extension,
|
||||
maestro_log,
|
||||
maestro_upgrade,
|
||||
player,
|
||||
} from "@/lib/generated/prisma/client";
|
||||
|
||||
export type {
|
||||
AccountType,
|
||||
ActivateStatus,
|
||||
PaidAccountType,
|
||||
RequestStatus,
|
||||
} from "@/lib/constants";
|
||||
Reference in New Issue
Block a user