Phase 10. 마에스트로 정보 수정 및 학생 목록 페이지네이션 적용
This commit is contained in:
@@ -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) |
|
| `cancel_extension_maestro` | Extension cancelled (new type, not in original) |
|
||||||
| `upgrade_maestro` | Upgrade approved |
|
| `upgrade_maestro` | Upgrade approved |
|
||||||
| `reject_upgrade_maestro` | Upgrade rejected (new type, not in original) |
|
| `reject_upgrade_maestro` | Upgrade rejected (new type, not in original) |
|
||||||
| `update_maestro_name` | Maestro name changed |
|
| `update_maestro_name` | Maestro name changed | `{prev} -> {new}` |
|
||||||
| `update_maestro_email` | Maestro email changed |
|
| `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.
|
`Remark` column is `char(100)` — keep values under 100 characters.
|
||||||
|
|
||||||
|
|||||||
@@ -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<HTMLFormElement>) {
|
||||||
|
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 (
|
||||||
|
<form onSubmit={(e) => void handleSubmit(e)} className="mt-4 space-y-5">
|
||||||
|
<div className="grid gap-x-8 gap-y-4 text-sm md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<FormField label="이름">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
disabled={isDisabled}
|
||||||
|
maxLength={50}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
required
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="이메일">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
disabled={isDisabled}
|
||||||
|
maxLength={50}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="상태">
|
||||||
|
<select
|
||||||
|
className={inputClass}
|
||||||
|
disabled={isDisabled}
|
||||||
|
onChange={(e) => setActivateStatus(Number(e.target.value))}
|
||||||
|
value={activateStatus}
|
||||||
|
>
|
||||||
|
{activateStatusOptions.map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{ACTIVATE_STATUS_LABELS[s]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="계정 유형">
|
||||||
|
<select
|
||||||
|
className={inputClass}
|
||||||
|
disabled={isDisabled}
|
||||||
|
onChange={(e) => setAccountType(Number(e.target.value))}
|
||||||
|
value={accountType}
|
||||||
|
>
|
||||||
|
{paidAccountTypeOptions.map((t) => (
|
||||||
|
<option key={t} value={t}>
|
||||||
|
{ACCOUNT_TYPE_LABELS[t]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="사용 가능일">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
disabled={isDisabled}
|
||||||
|
onChange={(e) => setAvailableActivateDateTime(e.target.value)}
|
||||||
|
required
|
||||||
|
type="datetime-local"
|
||||||
|
value={availableActivateDateTime}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="입장코드 수정">
|
||||||
|
<select
|
||||||
|
className={inputClass}
|
||||||
|
disabled={isDisabled}
|
||||||
|
onChange={(e) => setAllowEditEnterCode(Number(e.target.value))}
|
||||||
|
value={allowEditEnterCode}
|
||||||
|
>
|
||||||
|
<option value={1}>허용</option>
|
||||||
|
<option value={0}>비허용</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-x-8 gap-y-3 border-t pt-4 text-sm md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<ReadOnlyItem
|
||||||
|
label="약관 동의일"
|
||||||
|
value={formatDateTime(maestro.acceptClausesDateTime)}
|
||||||
|
/>
|
||||||
|
<ReadOnlyItem
|
||||||
|
label="DB 학생 수"
|
||||||
|
value={playerCount.toLocaleString()}
|
||||||
|
/>
|
||||||
|
<ReadOnlyItem
|
||||||
|
label="PlayerCount"
|
||||||
|
value={maestro.playerCount.toLocaleString()}
|
||||||
|
/>
|
||||||
|
<ReadOnlyItem
|
||||||
|
label="체험 MaestroID"
|
||||||
|
value={maestro.maestroTestID?.toString() ?? "-"}
|
||||||
|
/>
|
||||||
|
<ReadOnlyItem
|
||||||
|
label="사용 가능일 (현재)"
|
||||||
|
value={formatDate(maestro.availableActivateDateTime)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button disabled={isDisabled} size="sm" type="submit">
|
||||||
|
<Save aria-hidden="true" className="size-4" />
|
||||||
|
저장
|
||||||
|
</Button>
|
||||||
|
{successMessage ? (
|
||||||
|
<p className="text-sm text-green-700 dark:text-green-400">
|
||||||
|
{successMessage}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{errorMessage ? (
|
||||||
|
<p className="text-sm text-destructive">{errorMessage}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormField({
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="space-y-1.5">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">{label}</span>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReadOnlyItem({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-medium text-muted-foreground">{label}</dt>
|
||||||
|
<dd className="mt-1 font-medium">{value}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<MaestroStudentsResult | null>(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<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
setPage(1);
|
||||||
|
setSubmittedQ(q);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-lg border bg-background p-5">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold">학생 목록</h2>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{data
|
||||||
|
? `전체 ${data.totalCount.toLocaleString()}명${submittedQ ? ` — "${submittedQ}" 검색 결과` : ""}`
|
||||||
|
: "로딩 중..."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form className="flex gap-2" onSubmit={handleSearch}>
|
||||||
|
<div className="relative">
|
||||||
|
<Search
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="h-8 w-44 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"
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
placeholder="이름 검색"
|
||||||
|
type="search"
|
||||||
|
value={q}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" type="submit">
|
||||||
|
검색
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{errorMessage ? (
|
||||||
|
<p className="mt-4 text-sm text-destructive">{errorMessage}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<StudentTable isLoading={isLoading} result={data} />
|
||||||
|
{data ? (
|
||||||
|
<StudentPagination
|
||||||
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={(size) => {
|
||||||
|
setPageSize(size);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
page={data.page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
totalPages={data.totalPages}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StudentTable({
|
||||||
|
result,
|
||||||
|
isLoading,
|
||||||
|
}: {
|
||||||
|
result: MaestroStudentsResult | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
}) {
|
||||||
|
if (isLoading || !result) {
|
||||||
|
return (
|
||||||
|
<div className="mt-4 flex h-24 items-center justify-center rounded-lg border">
|
||||||
|
<p className="text-sm text-muted-foreground">로딩 중...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-4 overflow-hidden rounded-lg border">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[360px] border-collapse text-sm">
|
||||||
|
<thead className="bg-muted/60">
|
||||||
|
<tr className="border-b">
|
||||||
|
<th className="h-10 px-3 text-left text-xs font-medium text-muted-foreground">
|
||||||
|
PlayerID
|
||||||
|
</th>
|
||||||
|
<th className="h-10 px-3 text-left text-xs font-medium text-muted-foreground">
|
||||||
|
이름
|
||||||
|
</th>
|
||||||
|
<th className="h-10 px-3 text-left text-xs font-medium text-muted-foreground">
|
||||||
|
입장코드
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{result.items.length > 0 ? (
|
||||||
|
result.items.map((player) => (
|
||||||
|
<tr
|
||||||
|
className="border-b last:border-b-0"
|
||||||
|
key={player.playerID}
|
||||||
|
>
|
||||||
|
<td className="h-11 px-3 align-middle">
|
||||||
|
<span className="font-mono text-xs tabular-nums">
|
||||||
|
{player.playerID}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="h-11 px-3 align-middle">{player.name}</td>
|
||||||
|
<td className="h-11 px-3 align-middle">
|
||||||
|
<span className="font-mono text-xs">{player.enterCode}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
className="h-24 text-center text-sm text-muted-foreground"
|
||||||
|
colSpan={3}
|
||||||
|
>
|
||||||
|
{result.q
|
||||||
|
? "검색 결과가 없습니다."
|
||||||
|
: "등록된 학생이 없습니다."}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="mt-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-muted-foreground">페이지당</span>
|
||||||
|
<select
|
||||||
|
className="h-7 rounded-md border bg-background px-2 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30"
|
||||||
|
onChange={(e) => onPageSizeChange(Number(e.target.value))}
|
||||||
|
value={pageSize}
|
||||||
|
>
|
||||||
|
{[10, 20, 50].map((size) => (
|
||||||
|
<option key={size} value={size}>
|
||||||
|
{size}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="text-sm text-muted-foreground">개 표시</span>
|
||||||
|
</label>
|
||||||
|
{showNav ? <nav
|
||||||
|
aria-label="학생 목록 페이지"
|
||||||
|
className="flex flex-wrap items-center gap-1.5"
|
||||||
|
>
|
||||||
|
{!hasFirstPage ? (
|
||||||
|
<Button
|
||||||
|
aria-label="처음 페이지"
|
||||||
|
className={buttonVariants({ variant: "outline", size: "icon-sm" })}
|
||||||
|
onClick={() => onPageChange(1)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ChevronsLeft aria-hidden="true" className="size-4" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{hasPreviousPageGroup ? (
|
||||||
|
<Button
|
||||||
|
aria-label="이전 5페이지"
|
||||||
|
className={buttonVariants({ variant: "outline", size: "icon-sm" })}
|
||||||
|
onClick={() => onPageChange(Math.max(1, page - 5))}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ChevronLeft aria-hidden="true" className="size-4" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{paginationItems.map((item) => (
|
||||||
|
<Button
|
||||||
|
aria-current={item === page ? "page" : undefined}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({
|
||||||
|
variant: item === page ? "default" : "outline",
|
||||||
|
size: "sm",
|
||||||
|
}),
|
||||||
|
"min-w-8 px-2"
|
||||||
|
)}
|
||||||
|
key={item}
|
||||||
|
onClick={() => onPageChange(item)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{hasNextPageGroup ? (
|
||||||
|
<Button
|
||||||
|
aria-label="다음 5페이지"
|
||||||
|
className={buttonVariants({ variant: "outline", size: "icon-sm" })}
|
||||||
|
onClick={() => onPageChange(Math.min(totalPages, page + 5))}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ChevronRight aria-hidden="true" className="size-4" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!hasLastPage ? (
|
||||||
|
<Button
|
||||||
|
aria-label="끝 페이지"
|
||||||
|
className={buttonVariants({ variant: "outline", size: "icon-sm" })}
|
||||||
|
onClick={() => onPageChange(totalPages)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ChevronsRight aria-hidden="true" className="size-4" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</nav> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
getActivateStatusLabel,
|
getActivateStatusLabel,
|
||||||
getRequestStatusLabel,
|
getRequestStatusLabel,
|
||||||
} from "@/lib/utils";
|
} from "@/lib/utils";
|
||||||
|
import { MaestroEditForm } from "./MaestroEditForm";
|
||||||
|
import { StudentsSection } from "./StudentsSection";
|
||||||
|
|
||||||
type MaestroDetailPageProps = {
|
type MaestroDetailPageProps = {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
@@ -81,65 +83,14 @@ export default async function MaestroDetailPage({
|
|||||||
|
|
||||||
<section className="rounded-lg border bg-background p-5">
|
<section className="rounded-lg border bg-background p-5">
|
||||||
<h2 className="text-base font-semibold">기본 정보</h2>
|
<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">
|
<MaestroEditForm
|
||||||
<InfoItem label="이름" value={maestro.name} />
|
maestro={detail.maestro}
|
||||||
<InfoItem label="이메일" value={maestro.email} />
|
playerCount={detail.counts.players}
|
||||||
<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>
|
</section>
|
||||||
|
|
||||||
|
<StudentsSection maestroID={maestroID} />
|
||||||
|
|
||||||
<div className="grid gap-4 xl:grid-cols-2">
|
<div className="grid gap-4 xl:grid-cols-2">
|
||||||
<section className="rounded-lg border bg-background p-5">
|
<section className="rounded-lg border bg-background p-5">
|
||||||
<h2 className="text-base font-semibold">연장 신청 이력</h2>
|
<h2 className="text-base font-semibold">연장 신청 이력</h2>
|
||||||
@@ -207,15 +158,6 @@ function SummaryCard({ label, value }: { label: string; value: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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({
|
function SimpleTable({
|
||||||
headers,
|
headers,
|
||||||
rows,
|
rows,
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export default async function MaestrosPage({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form
|
<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]"
|
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_auto]"
|
||||||
method="get"
|
method="get"
|
||||||
>
|
>
|
||||||
<label className="min-w-0 space-y-1.5">
|
<label className="min-w-0 space-y-1.5">
|
||||||
@@ -148,23 +148,6 @@ export default async function MaestrosPage({
|
|||||||
</AutoSubmitSelect>
|
</AutoSubmitSelect>
|
||||||
</label>
|
</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">
|
<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 className="h-9" type="submit">
|
||||||
적용
|
적용
|
||||||
@@ -185,9 +168,27 @@ export default async function MaestrosPage({
|
|||||||
<MaestrosTable data={result.items} />
|
<MaestrosTable data={result.items} />
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<p className="text-sm text-muted-foreground">
|
<form className="flex items-center gap-2" method="get">
|
||||||
페이지당 {result.pageSize}개 표시
|
{Object.entries(params).flatMap(([key, value]) => {
|
||||||
</p>
|
if (key === "page" || key === "pageSize") return [];
|
||||||
|
const v = Array.isArray(value) ? value[0] : value;
|
||||||
|
if (!v) return [];
|
||||||
|
return [<input key={key} name={key} type="hidden" value={v} />];
|
||||||
|
})}
|
||||||
|
<span className="text-sm text-muted-foreground">페이지당</span>
|
||||||
|
<AutoSubmitSelect
|
||||||
|
className="h-7 rounded-md border bg-background px-2 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((size) => (
|
||||||
|
<option key={size} value={size}>
|
||||||
|
{size}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</AutoSubmitSelect>
|
||||||
|
<span className="text-sm text-muted-foreground">개 표시</span>
|
||||||
|
</form>
|
||||||
<nav
|
<nav
|
||||||
aria-label="마에스트로 목록 페이지"
|
aria-label="마에스트로 목록 페이지"
|
||||||
className="flex flex-wrap items-center gap-1.5"
|
className="flex flex-wrap items-center gap-1.5"
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
import { getMaestroDetail } from "@/lib/maestros";
|
import { getMaestroDetail, updateMaestro, updateMaestroSchema } from "@/lib/maestros";
|
||||||
import { ApiError } from "@/lib/errors";
|
import { ApiError } from "@/lib/errors";
|
||||||
import { withApiHandler } from "@/lib/api-handler";
|
import { withApiHandler } from "@/lib/api-handler";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
|
const CTX = "maestros/[id]";
|
||||||
|
|
||||||
export const GET = withApiHandler<{ id: string }>(
|
export const GET = withApiHandler<{ id: string }>(
|
||||||
"maestros/[id]",
|
CTX,
|
||||||
async (_request, { params }) => {
|
async (_request, { params }) => {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const maestroID = Number(id);
|
const maestroID = Number(id);
|
||||||
@@ -23,3 +26,35 @@ export const GET = withApiHandler<{ id: string }>(
|
|||||||
return NextResponse.json(result);
|
return NextResponse.json(result);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const PATCH = withApiHandler<{ id: string }>(
|
||||||
|
CTX,
|
||||||
|
async (request, { params, t0 }) => {
|
||||||
|
const { id } = await params;
|
||||||
|
const maestroID = Number(id);
|
||||||
|
|
||||||
|
if (!Number.isInteger(maestroID) || maestroID <= 0) {
|
||||||
|
throw new ApiError("Invalid maestro id", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = updateMaestroSchema.safeParse(
|
||||||
|
await request.json().catch(() => ({}))
|
||||||
|
);
|
||||||
|
if (!body.success) {
|
||||||
|
throw new ApiError("Invalid request body", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await updateMaestro(maestroID, body.data);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
throw new ApiError("Not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(CTX, result.changed ? "updated" : "no change", {
|
||||||
|
id: maestroID,
|
||||||
|
duration: Date.now() - t0,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(result);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { getMaestroStudents } from "@/lib/maestros";
|
||||||
|
import { ApiError } from "@/lib/errors";
|
||||||
|
import { withApiHandler } from "@/lib/api-handler";
|
||||||
|
|
||||||
|
const CTX = "maestros/[id]/students";
|
||||||
|
|
||||||
|
export const GET = withApiHandler<{ id: string }>(
|
||||||
|
CTX,
|
||||||
|
async (request, { params }) => {
|
||||||
|
const { id } = await params;
|
||||||
|
const maestroID = Number(id);
|
||||||
|
|
||||||
|
if (!Number.isInteger(maestroID) || maestroID <= 0) {
|
||||||
|
throw new ApiError("Invalid maestro id", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const result = await getMaestroStudents(maestroID, url.searchParams);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
throw new ApiError("Not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(result);
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
# Phase 10. 마에스트로 정보 수정 및 학생 목록 고도화
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
|
||||||
|
`/maestros/[maestroId]` 상세 화면에서 마에스트로 정보를 직접 수정할 수 있는 폼을 추가하고, 학생 목록을 서버 사이드 페이지네이션과 이름 검색이 가능한 형태로 완성한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 마에스트로 상세 화면 수정 폼 구현
|
||||||
|
|
||||||
|
### 사전 조사 결과 (기존 mouse-typing 코드 확인 완료)
|
||||||
|
|
||||||
|
| 조사 항목 | 파일 위치 | 결과 |
|
||||||
|
|---|---|---|
|
||||||
|
| 이름 중복 검사 기준 | `server/maestro/update_maestro_info.php` | **중복 검사 없음** — 이름을 그대로 UPDATE. chocoadmin에서 신규 추가. |
|
||||||
|
| 이메일 변경 처리 | `server/maestro/update_maestro_info.php` | 변경 시 이메일 안내 메일 발송 + `maestro_log` 기록. 메일은 Phase 11에서 구현. |
|
||||||
|
| 입장코드 수정 컬럼 | `server/maestro/set_allow_edit_entercode.php` | `maestro.AllowEditEnterCode`: `0`(비허용) / `1`(허용). 로그 기록 없음. |
|
||||||
|
| 상태/계정유형 변경 로그 | `server/admin/` 전체 | 기존 관리자에서 상태/계정유형/사용가능일을 직접 수정하는 기능 없음. 신규 기능이므로 신규 로그 타입 정의. |
|
||||||
|
|
||||||
|
정의한 신규 로그 타입 (기존 코드에 없음):
|
||||||
|
|
||||||
|
| Type | 대상 필드 | Remark 형식 |
|
||||||
|
|---|---|---|
|
||||||
|
| `update_maestro_status` | `ActivateStatus` | `{prev} -> {new}` (숫자) |
|
||||||
|
| `update_maestro_account_type` | `AccountType` | `{prev} -> {new}` (숫자) |
|
||||||
|
| `update_maestro_available_date` | `AvailableActivateDateTime` | `{YYYY-MM-DD} -> {YYYY-MM-DD}` |
|
||||||
|
| `update_maestro_allow_enter_code` | `AllowEditEnterCode` | `{prev} -> {new}` (0 or 1) |
|
||||||
|
|
||||||
|
### API 구현: `PATCH /api/maestros/[id]` ✅
|
||||||
|
|
||||||
|
파일: `app/api/maestros/[id]/route.ts` (기존 GET과 동일 파일에 PATCH 추가)
|
||||||
|
서비스 레이어: `lib/maestros.ts`에 `updateMaestroSchema`, `UpdateMaestroPayload`, `updateMaestro()` 추가
|
||||||
|
|
||||||
|
**요청 스키마 (Zod)**
|
||||||
|
|
||||||
|
| 필드 | 타입 | 규칙 |
|
||||||
|
|---|---|---|
|
||||||
|
| `name` | `string` | 1자 이상, trim |
|
||||||
|
| `email` | `string` | 이메일 형식 |
|
||||||
|
| `activateStatus` | `0 \| 1 \| 2 \| 100` | `ACTIVATE_STATUSES` 상수 |
|
||||||
|
| `accountType` | `1 \| 2 \| 3 \| 4 \| 5` | 유료 타입만 허용 (`PaidAccountType`) |
|
||||||
|
| `availableActivateDateTime` | `string` | ISO datetime 또는 `YYYY-MM-DD` 형식 |
|
||||||
|
| `allowEditEnterCode` | `0 \| 1` | 정수 |
|
||||||
|
|
||||||
|
모든 필드는 optional — 전달된 필드만 업데이트한다.
|
||||||
|
|
||||||
|
**처리 순서**
|
||||||
|
|
||||||
|
1. `withApiHandler`로 인증 자동 검사
|
||||||
|
2. 대상 maestro 존재 확인 (`findUnique`) — 없으면 404
|
||||||
|
3. `name`이 포함된 경우: 동일 이름의 다른 마에스트로 존재 여부 확인 — 있으면 409
|
||||||
|
4. 변경 필드 추출 (전달값 vs 현재값 비교, 동일한 값이면 변경 대상에서 제외)
|
||||||
|
5. 변경 필드가 없으면 200 + `{ changed: false }` 반환 (DB 업데이트 및 로그 기록 생략)
|
||||||
|
6. 트랜잭션: `maestro` 업데이트 + `maestro_log` INSERT (변경된 필드별로 각각 로그 기록)
|
||||||
|
7. 성공 응답: 200 + `{ changed: true }`
|
||||||
|
|
||||||
|
**응답 정책**
|
||||||
|
|
||||||
|
| 상황 | HTTP | 비고 |
|
||||||
|
|---|---|---|
|
||||||
|
| 변경 없음 | 200 | `{ changed: false }` |
|
||||||
|
| 성공 | 200 | `{ changed: true }` |
|
||||||
|
| 이름 중복 | 409 | 자기 자신은 중복으로 처리하지 않음 |
|
||||||
|
| 존재하지 않는 마에스트로 | 404 | |
|
||||||
|
| 유효하지 않은 값 | 400 | Zod 파싱 실패 |
|
||||||
|
| 인증 없음 | 401 | `withApiHandler` 처리 |
|
||||||
|
|
||||||
|
### UI 구현: 수정 폼 ✅
|
||||||
|
|
||||||
|
파일: `app/(admin)/maestros/[maestroId]/MaestroEditForm.tsx` (신규 Client Component)
|
||||||
|
|
||||||
|
현재 상세 페이지(`page.tsx`)의 "기본 정보" 섹션을 읽기 전용 표시에서 편집 가능한 폼으로 교체한다.
|
||||||
|
|
||||||
|
**필드 구성**
|
||||||
|
|
||||||
|
| 필드 | 컴포넌트 | 비고 |
|
||||||
|
|---|---|---|
|
||||||
|
| 이름 | `<Input type="text">` | |
|
||||||
|
| 이메일 | `<Input type="email">` | |
|
||||||
|
| 상태 | `<Select>` | `ACTIVATE_STATUS_LABELS` 기반 |
|
||||||
|
| 계정유형 | `<Select>` | `ACCOUNT_TYPE_LABELS` 기반, 유료 타입만 |
|
||||||
|
| 사용 가능일 | `<Input type="datetime-local">` | |
|
||||||
|
| 입장코드 수정 | `<Select>` | 허용(1) / 비허용(0) |
|
||||||
|
|
||||||
|
**제출 흐름**
|
||||||
|
|
||||||
|
1. 저장 버튼 클릭 → 확인 다이얼로그 표시
|
||||||
|
2. 확인 → `PATCH /api/maestros/[id]` 호출, 저장 버튼 비활성화
|
||||||
|
3. 성공 → 성공 토스트 + `router.refresh()`로 화면 갱신
|
||||||
|
4. 실패 → 실패 토스트 (이름 중복 409 응답 시 "이미 사용 중인 이름입니다." 별도 메시지)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 학생 목록 구현
|
||||||
|
|
||||||
|
### API 구현: `GET /api/maestros/[id]/students` ✅
|
||||||
|
|
||||||
|
파일: `app/api/maestros/[id]/students/route.ts` (신규)
|
||||||
|
서비스 레이어: `lib/maestros.ts`에 `studentSearchSchema`, `MaestroStudentsResult`, `getMaestroStudents()` 추가
|
||||||
|
|
||||||
|
**쿼리 파라미터**
|
||||||
|
|
||||||
|
| 파라미터 | 타입 | 기본값 | 설명 |
|
||||||
|
|---|---|------|---|
|
||||||
|
| `page` | number | `1` | 페이지 번호 |
|
||||||
|
| `pageSize` | `10 \| 20 \| 50` | `10` | 페이지 크기 |
|
||||||
|
| `q` | string | `""` | 이름 검색 (`LIKE %q%`) |
|
||||||
|
|
||||||
|
**응답 형식**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
items: Array<{
|
||||||
|
playerID: number;
|
||||||
|
name: string;
|
||||||
|
enterCode: string;
|
||||||
|
accountType: number | null;
|
||||||
|
}>;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalCount: number;
|
||||||
|
totalPages: number;
|
||||||
|
q: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**처리 순서**
|
||||||
|
|
||||||
|
1. `withApiHandler`로 인증 검사
|
||||||
|
2. maestro 존재 확인 — 없으면 404
|
||||||
|
3. 쿼리 파라미터 Zod 파싱
|
||||||
|
4. `player` 테이블에서 `MaestroID` 조건 + 이름 `LIKE` 검색 + 페이지네이션 조회
|
||||||
|
5. 정렬: `PlayerID DESC`
|
||||||
|
|
||||||
|
### UI 구현: 학생 목록 ✅
|
||||||
|
|
||||||
|
현재 "학생 목록 요약" 섹션(최근 20명 고정)을 서버 사이드 페이지네이션 목록으로 교체한다.
|
||||||
|
|
||||||
|
파일: `app/(admin)/maestros/[maestroId]/StudentsSection.tsx` (신규 Client Component)
|
||||||
|
|
||||||
|
**구성 요소**
|
||||||
|
|
||||||
|
- 이름 검색 입력 (`<Input>`) + 검색 버튼
|
||||||
|
- 전체 학생 수 표시 (`전체 N명`)
|
||||||
|
- 학생 목록 테이블 (`PlayerID`, `이름`, `입장코드`)
|
||||||
|
- 페이지네이션 컨트롤 — 마에스트로 목록과 동일한 슬라이딩 윈도우 형식 (현재 페이지 ±2, 처음/끝 이동, ±5 그룹 이동)
|
||||||
|
- 빈 목록 상태: "등록된 학생이 없습니다."
|
||||||
|
- 검색 결과 없음 상태: "검색 결과가 없습니다."
|
||||||
|
|
||||||
|
**동작**
|
||||||
|
|
||||||
|
- 검색어 입력 후 Enter 또는 검색 버튼 클릭으로 조회 (실시간 debounce 없음)
|
||||||
|
- 검색어 변경 시 page를 1로 초기화
|
||||||
|
- 페이지 이동은 URL query string 없이 컴포넌트 state로 관리
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 검증 ✅
|
||||||
|
|
||||||
|
| 항목 | 방법 | 기대 결과 | 실행 결과 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 이름 중복 저장 실패 | 다른 마에스트로와 동일한 이름으로 저장 시도 | 409, "이미 사용 중인 이름입니다." | ✅ 409 + `{"message":"이미 사용 중인 이름입니다."}` |
|
||||||
|
| 이름 자기 자신과 동일 | 현재 이름 그대로 저장 | 변경 없음 처리 또는 중복 아님으로 정상 처리 | ✅ 200 + `{"changed":false}` |
|
||||||
|
| 정보 수정 성공 후 DB 반영 | 이름 / 이메일 / 상태 / 계정유형 / 사용 가능일 / 입장코드 수정 변경 후 DB 직접 조회 | 변경값 반영 확인 | ✅ 모든 필드 DB 반영 확인 |
|
||||||
|
| maestro_log 기록 확인 | 정보 수정 성공 후 `maestro_log` 조회 | 변경 필드별 `Type`, `Remark` 형식이 §1 정의와 일치 | ✅ 6개 Type 모두 정확한 형식으로 기록됨 |
|
||||||
|
| 변경 없는 저장 | 현재 값 그대로 저장 시도 | `{ changed: false }` 응답, `maestro_log` 미기록 | ✅ 200 + `{"changed":false}`, 로그 없음 |
|
||||||
|
| 학생 목록 페이지네이션 | 학생 수가 pageSize 초과인 마에스트로에서 다음 페이지 이동 | 정확한 레코드 표시, `totalCount` 일치 | ✅ 823명 마에스트로: totalPages=83, page=2 정상 |
|
||||||
|
| 학생 목록 이름 검색 | 존재하는 이름 일부로 검색 | 해당 학생만 필터링 | ✅ `q=최건` → totalCount=1, 해당 학생만 반환 |
|
||||||
|
| 학생 목록 검색 결과 없음 | 존재하지 않는 이름으로 검색 | 빈 상태 메시지 표시 | ✅ totalCount=0, items=[] |
|
||||||
|
| 비인증 접근 차단 | 세션 없이 `PATCH /api/maestros/[id]`, `GET /api/maestros/[id]/students` 호출 | 401 응답 | ✅ 401 + `{"message":"Unauthorized"}` |
|
||||||
|
|
||||||
|
### 부가 수정 사항
|
||||||
|
|
||||||
|
- `proxy.ts` matcher 수정: `api/auth` → `api/` (API 라우트 전체를 미들웨어 제외 대상으로 변경)
|
||||||
|
- 기존: API 라우트에 미들웨어가 개입하여 307 리다이렉트 반환
|
||||||
|
- 수정 후: API 라우트는 `withApiHandler`가 직접 401 처리
|
||||||
+258
@@ -2,6 +2,8 @@ import { z } from "zod";
|
|||||||
|
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
|
import { ApiError } from "@/lib/errors";
|
||||||
|
import { ACTIVATE_STATUSES, ACCOUNT_TYPES } from "@/lib/constants";
|
||||||
import type { Prisma } from "@/lib/generated/prisma/client";
|
import type { Prisma } from "@/lib/generated/prisma/client";
|
||||||
|
|
||||||
const pageSizeOptions = [10, 20, 50, 100] as const;
|
const pageSizeOptions = [10, 20, 50, 100] as const;
|
||||||
@@ -102,6 +104,262 @@ export type MaestroDetail = {
|
|||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const paidAccountTypeValues = [
|
||||||
|
ACCOUNT_TYPES.BASIC_20,
|
||||||
|
ACCOUNT_TYPES.STANDARD_50,
|
||||||
|
ACCOUNT_TYPES.PRO_100,
|
||||||
|
ACCOUNT_TYPES.SCHOOL_500,
|
||||||
|
ACCOUNT_TYPES.SCHOOL_1000,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const activateStatusValues = [
|
||||||
|
ACTIVATE_STATUSES.PENDING,
|
||||||
|
ACTIVATE_STATUSES.TRIAL,
|
||||||
|
ACTIVATE_STATUSES.ACTIVE,
|
||||||
|
ACTIVATE_STATUSES.CANCELED,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const updateMaestroSchema = z.object({
|
||||||
|
name: z.string().trim().min(1).max(50).optional(),
|
||||||
|
email: z.string().trim().email().max(50).optional(),
|
||||||
|
activateStatus: z.coerce
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.refine((v) => (activateStatusValues as readonly number[]).includes(v))
|
||||||
|
.optional(),
|
||||||
|
accountType: z.coerce
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.refine((v) => (paidAccountTypeValues as readonly number[]).includes(v))
|
||||||
|
.optional(),
|
||||||
|
availableActivateDateTime: z.string().min(1).optional(),
|
||||||
|
allowEditEnterCode: z.coerce.number().int().min(0).max(1).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type UpdateMaestroPayload = z.infer<typeof updateMaestroSchema>;
|
||||||
|
|
||||||
|
export async function updateMaestro(
|
||||||
|
maestroID: number,
|
||||||
|
payload: UpdateMaestroPayload
|
||||||
|
): Promise<{ changed: boolean } | null> {
|
||||||
|
const t0 = Date.now();
|
||||||
|
|
||||||
|
const maestro = await db.maestro.findUnique({
|
||||||
|
where: { MaestroID: maestroID },
|
||||||
|
select: {
|
||||||
|
Name: true,
|
||||||
|
Email: true,
|
||||||
|
ActivateStatus: true,
|
||||||
|
AccountType: true,
|
||||||
|
AvailableActivateDateTime: true,
|
||||||
|
AllowEditEnterCode: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!maestro) return null;
|
||||||
|
|
||||||
|
if (payload.name !== undefined && payload.name !== maestro.Name.trim()) {
|
||||||
|
const conflict = await db.maestro.findFirst({
|
||||||
|
where: { Name: payload.name, NOT: { MaestroID: maestroID } },
|
||||||
|
select: { MaestroID: true },
|
||||||
|
});
|
||||||
|
if (conflict) {
|
||||||
|
throw new ApiError("이미 사용 중인 이름입니다.", 409);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: {
|
||||||
|
Name?: string;
|
||||||
|
Email?: string;
|
||||||
|
ActivateStatus?: number;
|
||||||
|
AccountType?: number;
|
||||||
|
AvailableActivateDateTime?: Date;
|
||||||
|
AllowEditEnterCode?: number;
|
||||||
|
} = {};
|
||||||
|
const logs: Array<{ type: string; remark: string }> = [];
|
||||||
|
|
||||||
|
if (payload.name !== undefined && payload.name !== maestro.Name.trim()) {
|
||||||
|
updates.Name = payload.name;
|
||||||
|
logs.push({
|
||||||
|
type: "update_maestro_name",
|
||||||
|
remark: `${maestro.Name.trim()} -> ${payload.name}`.slice(0, 100),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (payload.email !== undefined && payload.email !== maestro.Email.trim()) {
|
||||||
|
updates.Email = payload.email;
|
||||||
|
logs.push({
|
||||||
|
type: "update_maestro_email",
|
||||||
|
remark: `${maestro.Email.trim()} -> ${payload.email}`.slice(0, 100),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
payload.activateStatus !== undefined &&
|
||||||
|
payload.activateStatus !== maestro.ActivateStatus
|
||||||
|
) {
|
||||||
|
updates.ActivateStatus = payload.activateStatus;
|
||||||
|
logs.push({
|
||||||
|
type: "update_maestro_status",
|
||||||
|
remark: `${maestro.ActivateStatus} -> ${payload.activateStatus}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
payload.accountType !== undefined &&
|
||||||
|
payload.accountType !== maestro.AccountType
|
||||||
|
) {
|
||||||
|
updates.AccountType = payload.accountType;
|
||||||
|
logs.push({
|
||||||
|
type: "update_maestro_account_type",
|
||||||
|
remark: `${maestro.AccountType} -> ${payload.accountType}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (payload.availableActivateDateTime !== undefined) {
|
||||||
|
const newDate = new Date(payload.availableActivateDateTime);
|
||||||
|
if (!isNaN(newDate.getTime())) {
|
||||||
|
const prevMin = Math.floor(
|
||||||
|
maestro.AvailableActivateDateTime.getTime() / 60000
|
||||||
|
);
|
||||||
|
const newMin = Math.floor(newDate.getTime() / 60000);
|
||||||
|
if (prevMin !== newMin) {
|
||||||
|
updates.AvailableActivateDateTime = newDate;
|
||||||
|
const prevStr = maestro.AvailableActivateDateTime.toISOString().slice(
|
||||||
|
0,
|
||||||
|
10
|
||||||
|
);
|
||||||
|
const newStr = newDate.toISOString().slice(0, 10);
|
||||||
|
logs.push({
|
||||||
|
type: "update_maestro_available_date",
|
||||||
|
remark: `${prevStr} -> ${newStr}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
payload.allowEditEnterCode !== undefined &&
|
||||||
|
payload.allowEditEnterCode !== maestro.AllowEditEnterCode
|
||||||
|
) {
|
||||||
|
updates.AllowEditEnterCode = payload.allowEditEnterCode;
|
||||||
|
logs.push({
|
||||||
|
type: "update_maestro_allow_enter_code",
|
||||||
|
remark: `${maestro.AllowEditEnterCode} -> ${payload.allowEditEnterCode}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(updates).length === 0) {
|
||||||
|
return { changed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.$transaction([
|
||||||
|
db.maestro.update({ where: { MaestroID: maestroID }, data: updates }),
|
||||||
|
...logs.map((log) =>
|
||||||
|
db.maestro_log.create({
|
||||||
|
data: {
|
||||||
|
Type: log.type,
|
||||||
|
MaestroID: maestroID,
|
||||||
|
LogDateTime: new Date(),
|
||||||
|
Remark: log.remark,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
logger.info("maestros/[id]", "updated", {
|
||||||
|
id: maestroID,
|
||||||
|
fields: Object.keys(updates),
|
||||||
|
duration: Date.now() - t0,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { changed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const studentPageSizeOptions = [10, 20, 50] as const;
|
||||||
|
type StudentPageSize = (typeof studentPageSizeOptions)[number];
|
||||||
|
|
||||||
|
const studentSearchSchema = z.object({
|
||||||
|
page: z.coerce.number().int().min(1).catch(1),
|
||||||
|
pageSize: z.coerce
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.refine((v) => studentPageSizeOptions.includes(v as StudentPageSize))
|
||||||
|
.catch(20),
|
||||||
|
q: z.string().trim().catch(""),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type MaestroStudentsResult = {
|
||||||
|
items: Array<{
|
||||||
|
playerID: number;
|
||||||
|
name: string;
|
||||||
|
enterCode: string;
|
||||||
|
accountType: number | null;
|
||||||
|
}>;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalCount: number;
|
||||||
|
totalPages: number;
|
||||||
|
q: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getMaestroStudents(
|
||||||
|
maestroID: number,
|
||||||
|
rawSearchParams: RawSearchParams = {}
|
||||||
|
): Promise<MaestroStudentsResult | null> {
|
||||||
|
const t0 = Date.now();
|
||||||
|
|
||||||
|
const maestroExists = await db.maestro.findUnique({
|
||||||
|
where: { MaestroID: maestroID },
|
||||||
|
select: { MaestroID: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!maestroExists) return null;
|
||||||
|
|
||||||
|
const params = studentSearchSchema.parse(searchParamsToRecord(rawSearchParams));
|
||||||
|
const skip = (params.page - 1) * params.pageSize;
|
||||||
|
|
||||||
|
const where: Prisma.playerWhereInput = {
|
||||||
|
MaestroID: maestroID,
|
||||||
|
...(params.q ? { Name: { contains: params.q } } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const [totalCount, players] = await Promise.all([
|
||||||
|
db.player.count({ where }),
|
||||||
|
db.player.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { PlayerID: "desc" },
|
||||||
|
skip,
|
||||||
|
take: params.pageSize,
|
||||||
|
select: {
|
||||||
|
PlayerID: true,
|
||||||
|
Name: true,
|
||||||
|
EnterCode: true,
|
||||||
|
AccountType: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalCount / params.pageSize));
|
||||||
|
|
||||||
|
logger.debug("maestros/[id]/students", "fetched", {
|
||||||
|
id: maestroID,
|
||||||
|
q: params.q,
|
||||||
|
page: params.page,
|
||||||
|
totalCount,
|
||||||
|
duration: Date.now() - t0,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: players.map((p) => ({
|
||||||
|
playerID: p.PlayerID,
|
||||||
|
name: p.Name.trim(),
|
||||||
|
enterCode: p.EnterCode.trim(),
|
||||||
|
accountType: p.AccountType,
|
||||||
|
})),
|
||||||
|
page: params.page,
|
||||||
|
pageSize: params.pageSize,
|
||||||
|
totalCount,
|
||||||
|
totalPages,
|
||||||
|
q: params.q,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function getMaestros(
|
export async function getMaestros(
|
||||||
rawSearchParams: RawSearchParams = {}
|
rawSearchParams: RawSearchParams = {}
|
||||||
): Promise<MaestroListResult> {
|
): Promise<MaestroListResult> {
|
||||||
|
|||||||
@@ -104,6 +104,22 @@ export function calculateExtendedAvailableDate(
|
|||||||
return extendedDate;
|
return extendedDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function toDatetimeLocal(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");
|
||||||
|
const hours = String(date.getHours()).padStart(2, "0");
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||||
|
|
||||||
|
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||||
|
}
|
||||||
|
|
||||||
function toDate(value: Date | string | null | undefined): Date | null {
|
function toDate(value: Date | string | null | undefined): Date | null {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -33,5 +33,5 @@ export async function proxy(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: ["/((?!api/auth|_next/static|_next/image|favicon.ico|.*\\..*).*)"],
|
matcher: ["/((?!api/|_next/static|_next/image|favicon.ico|.*\\..*).*)"],
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user