From 5f9bb39ce1d61b43e266fab4c77c7884c594cea1 Mon Sep 17 00:00:00 2001 From: jisangs Date: Mon, 29 Jun 2026 17:20:24 +0900 Subject: [PATCH] =?UTF-8?q?Phase=2010.=20=EB=A7=88=EC=97=90=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A1=9C=20=EC=A0=95=EB=B3=B4=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EB=B0=8F=20=ED=95=99=EC=83=9D=20=EB=AA=A9=EB=A1=9D=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=EB=84=A4=EC=9D=B4=EC=85=98=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 8 +- .../maestros/[maestroId]/MaestroEditForm.tsx | 254 ++++++++++++++ .../maestros/[maestroId]/StudentsSection.tsx | 325 ++++++++++++++++++ app/(admin)/maestros/[maestroId]/page.tsx | 72 +--- app/(admin)/maestros/page.tsx | 43 +-- app/api/maestros/[id]/route.ts | 39 ++- app/api/maestros/[id]/students/route.ts | 28 ++ docs/phase/phase10.md | 176 ++++++++++ lib/maestros.ts | 258 ++++++++++++++ lib/utils.ts | 16 + proxy.ts | 2 +- 11 files changed, 1130 insertions(+), 91 deletions(-) create mode 100644 app/(admin)/maestros/[maestroId]/MaestroEditForm.tsx create mode 100644 app/(admin)/maestros/[maestroId]/StudentsSection.tsx create mode 100644 app/api/maestros/[id]/students/route.ts create mode 100644 docs/phase/phase10.md diff --git a/AGENTS.md b/AGENTS.md index a7b590d..e225b38 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,8 +54,12 @@ Always record a log entry after a successful DB change. Use these `Type` values: | `cancel_extension_maestro` | Extension cancelled (new type, not in original) | | `upgrade_maestro` | Upgrade approved | | `reject_upgrade_maestro` | Upgrade rejected (new type, not in original) | -| `update_maestro_name` | Maestro name changed | -| `update_maestro_email` | Maestro email changed | +| `update_maestro_name` | Maestro name changed | `{prev} -> {new}` | +| `update_maestro_email` | Maestro email changed | `{prev} -> {new}` | +| `update_maestro_status` | ActivateStatus changed (admin, new) | `{prev} -> {new}` (numeric) | +| `update_maestro_account_type` | AccountType changed (admin, new) | `{prev} -> {new}` (numeric) | +| `update_maestro_available_date` | AvailableActivateDateTime changed (admin, new) | `{YYYY-MM-DD} -> {YYYY-MM-DD}` | +| `update_maestro_allow_enter_code` | AllowEditEnterCode changed (admin, new) | `{prev} -> {new}` (0 or 1) | `Remark` column is `char(100)` — keep values under 100 characters. diff --git a/app/(admin)/maestros/[maestroId]/MaestroEditForm.tsx b/app/(admin)/maestros/[maestroId]/MaestroEditForm.tsx new file mode 100644 index 0000000..df44969 --- /dev/null +++ b/app/(admin)/maestros/[maestroId]/MaestroEditForm.tsx @@ -0,0 +1,254 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { Save } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + ACCOUNT_TYPE_LABELS, + ACCOUNT_TYPES, + ACTIVATE_STATUS_LABELS, + ACTIVATE_STATUSES, +} from "@/lib/constants"; +import { formatDate, formatDateTime, toDatetimeLocal } from "@/lib/utils"; +import type { MaestroDetail } from "@/lib/maestros"; + +type MaestroEditFormProps = { + maestro: MaestroDetail["maestro"]; + playerCount: number; +}; + +const paidAccountTypeOptions = [ + ACCOUNT_TYPES.BASIC_20, + ACCOUNT_TYPES.STANDARD_50, + ACCOUNT_TYPES.PRO_100, + ACCOUNT_TYPES.SCHOOL_500, + ACCOUNT_TYPES.SCHOOL_1000, +] as const; + +const activateStatusOptions = [ + ACTIVATE_STATUSES.PENDING, + ACTIVATE_STATUSES.TRIAL, + ACTIVATE_STATUSES.ACTIVE, + ACTIVATE_STATUSES.CANCELED, +] as const; + +const inputClass = + "h-9 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 disabled:opacity-50"; + +export function MaestroEditForm({ maestro, playerCount }: MaestroEditFormProps) { + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + const [isSaving, setIsSaving] = useState(false); + + const [name, setName] = useState(maestro.name); + const [email, setEmail] = useState(maestro.email); + const [activateStatus, setActivateStatus] = useState(maestro.activateStatus); + const [accountType, setAccountType] = useState(maestro.accountType); + const [availableActivateDateTime, setAvailableActivateDateTime] = useState( + toDatetimeLocal(maestro.availableActivateDateTime) + ); + const [allowEditEnterCode, setAllowEditEnterCode] = useState( + maestro.allowEditEnterCode + ); + + const [errorMessage, setErrorMessage] = useState(""); + const [successMessage, setSuccessMessage] = useState(""); + + const isDisabled = isSaving || isPending; + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + + if (!window.confirm("마에스트로 정보를 저장하시겠습니까?")) return; + + setIsSaving(true); + setErrorMessage(""); + setSuccessMessage(""); + + try { + const response = await fetch(`/api/maestros/${maestro.maestroID}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name, + email, + activateStatus, + accountType, + availableActivateDateTime, + allowEditEnterCode, + }), + }); + + const body = (await response.json().catch(() => null)) as { + changed?: boolean; + message?: string; + } | null; + + if (!response.ok) { + throw new Error(body?.message ?? "저장에 실패했습니다."); + } + + if (body?.changed) { + setSuccessMessage("저장 완료"); + startTransition(() => { + router.refresh(); + }); + } else { + setSuccessMessage("변경 사항이 없습니다."); + } + } catch (error) { + setErrorMessage( + error instanceof Error ? error.message : "저장에 실패했습니다." + ); + } finally { + setIsSaving(false); + } + } + + return ( +
void handleSubmit(e)} className="mt-4 space-y-5"> +
+ + setName(e.target.value)} + required + type="text" + value={name} + /> + + + + setEmail(e.target.value)} + required + type="email" + value={email} + /> + + + + + + + + + + + + setAvailableActivateDateTime(e.target.value)} + required + type="datetime-local" + value={availableActivateDateTime} + /> + + + + + +
+ +
+ + + + + +
+ +
+ + {successMessage ? ( +

+ {successMessage} +

+ ) : null} + {errorMessage ? ( +

{errorMessage}

+ ) : null} +
+
+ ); +} + +function FormField({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( + + ); +} + +function ReadOnlyItem({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/app/(admin)/maestros/[maestroId]/StudentsSection.tsx b/app/(admin)/maestros/[maestroId]/StudentsSection.tsx new file mode 100644 index 0000000..1a1a1de --- /dev/null +++ b/app/(admin)/maestros/[maestroId]/StudentsSection.tsx @@ -0,0 +1,325 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { + ChevronLeft, + ChevronRight, + ChevronsLeft, + ChevronsRight, + Search, +} from "lucide-react"; + +import { Button, buttonVariants } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import type { MaestroStudentsResult } from "@/lib/maestros"; + +type StudentsSectionProps = { + maestroID: number; +}; + +export function StudentsSection({ maestroID }: StudentsSectionProps) { + const [q, setQ] = useState(""); + const [submittedQ, setSubmittedQ] = useState(""); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [errorMessage, setErrorMessage] = useState(""); + + useEffect(() => { + let cancelled = false; + + const run = async () => { + setIsLoading(true); + setErrorMessage(""); + + try { + const params = new URLSearchParams({ + page: String(page), + pageSize: String(pageSize), + q: submittedQ, + }); + + const response = await fetch( + `/api/maestros/${maestroID}/students?${params.toString()}` + ); + + if (!response.ok) { + throw new Error("학생 목록을 불러오지 못했습니다."); + } + + const result = (await response.json()) as MaestroStudentsResult; + + if (!cancelled) { + setData(result); + } + } catch (err) { + if (!cancelled) { + setErrorMessage( + err instanceof Error + ? err.message + : "학생 목록을 불러오지 못했습니다." + ); + } + } finally { + if (!cancelled) { + setIsLoading(false); + } + } + }; + + void run(); + + return () => { + cancelled = true; + }; + }, [maestroID, submittedQ, page, pageSize]); + + function handleSearch(e: React.FormEvent) { + e.preventDefault(); + setPage(1); + setSubmittedQ(q); + } + + return ( +
+
+
+

학생 목록

+

+ {data + ? `전체 ${data.totalCount.toLocaleString()}명${submittedQ ? ` — "${submittedQ}" 검색 결과` : ""}` + : "로딩 중..."} +

+
+
+
+
+ +
+
+ + {errorMessage ? ( +

{errorMessage}

+ ) : ( + <> + + {data ? ( + { + setPageSize(size); + setPage(1); + }} + page={data.page} + pageSize={pageSize} + totalPages={data.totalPages} + /> + ) : null} + + )} +
+ ); +} + +function StudentTable({ + result, + isLoading, +}: { + result: MaestroStudentsResult | null; + isLoading: boolean; +}) { + if (isLoading || !result) { + return ( +
+

로딩 중...

+
+ ); + } + + return ( +
+
+ + + + + + + + + + {result.items.length > 0 ? ( + result.items.map((player) => ( + + + + + + )) + ) : ( + + + + )} + +
+ PlayerID + + 이름 + + 입장코드 +
+ + {player.playerID} + + {player.name} + {player.enterCode} +
+ {result.q + ? "검색 결과가 없습니다." + : "등록된 학생이 없습니다."} +
+
+
+ ); +} + +function StudentPagination({ + page, + pageSize, + totalPages, + onPageChange, + onPageSizeChange, +}: { + page: number; + pageSize: number; + totalPages: number; + onPageChange: (page: number) => void; + onPageSizeChange: (pageSize: number) => void; +}) { + const showNav = totalPages > 1; + const paginationItems = buildPaginationItems(page, totalPages); + const firstVisiblePage = paginationItems[0] ?? 1; + const lastVisiblePage = paginationItems.at(-1) ?? totalPages; + const hasFirstPage = paginationItems.includes(1); + const hasLastPage = paginationItems.includes(totalPages); + const hasPreviousPageGroup = firstVisiblePage > 1; + const hasNextPageGroup = lastVisiblePage < totalPages; + + return ( +
+ + {showNav ? : null} +
+ ); +} + +function buildPaginationItems(currentPage: number, totalPages: number) { + const startPage = Math.max(1, currentPage - 2); + const endPage = Math.min(totalPages, currentPage + 2); + + return Array.from( + { length: endPage - startPage + 1 }, + (_, index) => startPage + index + ); +} diff --git a/app/(admin)/maestros/[maestroId]/page.tsx b/app/(admin)/maestros/[maestroId]/page.tsx index 91fc4a2..b2508b1 100644 --- a/app/(admin)/maestros/[maestroId]/page.tsx +++ b/app/(admin)/maestros/[maestroId]/page.tsx @@ -11,6 +11,8 @@ import { getActivateStatusLabel, getRequestStatusLabel, } from "@/lib/utils"; +import { MaestroEditForm } from "./MaestroEditForm"; +import { StudentsSection } from "./StudentsSection"; type MaestroDetailPageProps = { params: Promise<{ @@ -81,65 +83,14 @@ export default async function MaestroDetailPage({

기본 정보

-
- - - - - - - - - - -
-
- -
-
-
-

학생 목록 요약

-

- 전체 {detail.counts.players.toLocaleString()}명 중 최근 20명 -

-
-
- [ - player.playerID, - player.name, - player.enterCode, - player.accountType ?? "-", - ])} +
+ +

연장 신청 이력

@@ -207,15 +158,6 @@ function SummaryCard({ label, value }: { label: string; value: string }) { ); } -function InfoItem({ label, value }: { label: string; value: string }) { - return ( -
-
{label}
-
{value}
-
- ); -} - function SimpleTable({ headers, rows, diff --git a/app/(admin)/maestros/page.tsx b/app/(admin)/maestros/page.tsx index f515788..2d949a7 100644 --- a/app/(admin)/maestros/page.tsx +++ b/app/(admin)/maestros/page.tsx @@ -73,7 +73,7 @@ export default async function MaestrosPage({
- -