sub
This commit is contained in:
@@ -0,0 +1,15 @@
|
|||||||
|
-- Add points/level/grade columns to users
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
|
||||||
|
-- points
|
||||||
|
ALTER TABLE "users" ADD COLUMN "points" INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- level
|
||||||
|
ALTER TABLE "users" ADD COLUMN "level" INTEGER NOT NULL DEFAULT 1;
|
||||||
|
|
||||||
|
-- grade (0~10 권장, DB 레벨에서는 정수 제약만, 범위는 앱에서 검증)
|
||||||
|
ALTER TABLE "users" ADD COLUMN "grade" INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
|
||||||
|
|
||||||
@@ -233,6 +233,10 @@ model User {
|
|||||||
birth DateTime
|
birth DateTime
|
||||||
phone String @unique
|
phone String @unique
|
||||||
rank Int @default(0)
|
rank Int @default(0)
|
||||||
|
// 누적 포인트, 레벨, 등급(0~10)
|
||||||
|
points Int @default(0)
|
||||||
|
level Int @default(1)
|
||||||
|
grade Int @default(0)
|
||||||
|
|
||||||
status UserStatus @default(active)
|
status UserStatus @default(active)
|
||||||
authLevel AuthLevel @default(USER)
|
authLevel AuthLevel @default(USER)
|
||||||
|
|||||||
@@ -1,36 +1,80 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||||
|
|
||||||
export default function AdminUsersPage() {
|
export default function AdminUsersPage() {
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
const { data, mutate } = useSWR<{ users: any[] }>(`/api/admin/users?q=${encodeURIComponent(q)}`, fetcher);
|
const [queryDraft, setQueryDraft] = useState("");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(20);
|
||||||
|
const key = useMemo(() => (
|
||||||
|
`/api/admin/users?q=${encodeURIComponent(q)}&page=${page}&pageSize=${pageSize}`
|
||||||
|
),[q, page, pageSize]);
|
||||||
|
const { data, mutate } = useSWR<{ total: number; page: number; pageSize: number; users: any[] }>(key, fetcher);
|
||||||
const users = data?.users ?? [];
|
const users = data?.users ?? [];
|
||||||
|
const total = data?.total ?? 0;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
const handleSearch = () => {
|
||||||
|
const term = queryDraft.trim();
|
||||||
|
if (!term) { setQ(""); setPage(1); return; }
|
||||||
|
setQ(term);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1>사용자 관리</h1>
|
<h1>사용자 관리</h1>
|
||||||
<div style={{ display: "flex", gap: 8, marginBottom: 12 }}>
|
<div className="mb-3 rounded-xl border border-neutral-300 bg-white p-3 flex items-center gap-2">
|
||||||
<input placeholder="검색(nickname/phone/name)" value={q} onChange={(e) => setQ(e.target.value)} />
|
<input
|
||||||
|
className="h-9 w-full max-w-[360px] px-3 rounded-md border border-neutral-300 bg-white text-sm"
|
||||||
|
placeholder="검색 (닉네임/전화/이름)"
|
||||||
|
value={queryDraft}
|
||||||
|
onChange={(e) => setQueryDraft(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter") handleSearch(); }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="h-9 px-4 rounded-md border border-neutral-300 bg-white text-sm hover:bg-neutral-100 disabled:opacity-50"
|
||||||
|
onClick={handleSearch}
|
||||||
|
disabled={false}
|
||||||
|
>
|
||||||
|
검색
|
||||||
|
</button>
|
||||||
|
<span className="ml-auto text-xs text-neutral-600">총 {total.toLocaleString()}명</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-neutral-200 overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-[#f6f4f4] border-b border-[#e6e6e6]">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2 text-left text-[12px] text-[#8c8c8c]">닉네임</th>
|
||||||
|
<th className="px-4 py-2 text-left text-[12px] text-[#8c8c8c]">이름</th>
|
||||||
|
<th className="px-4 py-2 text-left text-[12px] text-[#8c8c8c]">전화</th>
|
||||||
|
<th className="px-4 py-2 text-right text-[12px] text-[#8c8c8c]">포인트</th>
|
||||||
|
<th className="px-4 py-2 text-center text-[12px] text-[#8c8c8c]">레벨</th>
|
||||||
|
<th className="px-4 py-2 text-center text-[12px] text-[#8c8c8c]">등급</th>
|
||||||
|
<th className="px-4 py-2 text-center text-[12px] text-[#8c8c8c]">상태</th>
|
||||||
|
<th className="px-4 py-2 text-left text-[12px] text-[#8c8c8c]">권한</th>
|
||||||
|
<th className="px-4 py-2 text-center text-[12px] text-[#8c8c8c]">작업</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-[#ececec] bg-white">
|
||||||
|
{users.map((u) => (
|
||||||
|
<Row key={u.userId} u={u} onChanged={mutate} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 12 }}>
|
||||||
|
<button disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>이전</button>
|
||||||
|
<span style={{ fontSize: 12 }}>페이지 {page} / {totalPages}</span>
|
||||||
|
<button disabled={page >= totalPages} onClick={() => setPage((p) => Math.min(totalPages, p + 1))}>다음</button>
|
||||||
|
<span style={{ marginLeft: 12, fontSize: 12 }}>페이지 크기</span>
|
||||||
|
<select value={pageSize} onChange={(e) => { setPageSize(parseInt(e.target.value, 10)); setPage(1); }}>
|
||||||
|
{[10, 20, 50, 100].map((s) => (<option key={s} value={s}>{s}</option>))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>닉네임</th>
|
|
||||||
<th>이름</th>
|
|
||||||
<th>전화</th>
|
|
||||||
<th>상태</th>
|
|
||||||
<th>권한</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{users.map((u) => (
|
|
||||||
<Row key={u.userId} u={u} onChanged={mutate} />
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -45,25 +89,30 @@ function Row({ u, onChanged }: { u: any; onChanged: () => void }) {
|
|||||||
}
|
}
|
||||||
const allRoles = ["admin", "editor", "user"] as const;
|
const allRoles = ["admin", "editor", "user"] as const;
|
||||||
return (
|
return (
|
||||||
<tr>
|
<tr className="hover:bg-neutral-50">
|
||||||
<td>{u.nickname}</td>
|
<td className="px-4 py-2 text-left">{u.nickname}</td>
|
||||||
<td>{u.name}</td>
|
<td className="px-4 py-2 text-left">{u.name}</td>
|
||||||
<td>{u.phone}</td>
|
<td className="px-4 py-2 text-left">{u.phone}</td>
|
||||||
<td>
|
<td className="px-4 py-2 text-right tabular-nums">{(u.points ?? 0).toLocaleString()}</td>
|
||||||
<select value={status} onChange={(e) => setStatus(e.target.value)}>
|
<td className="px-4 py-2 text-center">{u.level ?? 1}</td>
|
||||||
<option value="active">active</option>
|
<td className="px-4 py-2 text-center">{u.grade ?? 0}</td>
|
||||||
<option value="suspended">suspended</option>
|
<td className="px-4 py-2 text-center">
|
||||||
<option value="withdrawn">withdrawn</option>
|
<select className="h-8 px-2 border border-neutral-300 rounded-md bg-white text-sm" value={status} onChange={(e) => setStatus(e.target.value)}>
|
||||||
|
<option value="active">활성</option>
|
||||||
|
<option value="suspended">정지</option>
|
||||||
|
<option value="withdrawn">탈퇴</option>
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td className="px-4 py-2">
|
||||||
{allRoles.map((r) => (
|
{allRoles.map((r) => (
|
||||||
<label key={r} style={{ marginRight: 8 }}>
|
<label key={r} className="mr-2">
|
||||||
<input type="checkbox" checked={roles.includes(r)} onChange={(e) => setRoles((prev) => (e.target.checked ? Array.from(new Set([...prev, r])) : prev.filter((x) => x !== r)))} /> {r}
|
<input type="checkbox" checked={roles.includes(r)} onChange={(e) => setRoles((prev) => (e.target.checked ? Array.from(new Set([...prev, r])) : prev.filter((x) => x !== r)))} /> {r}
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</td>
|
</td>
|
||||||
<td><button onClick={save}>저장</button></td>
|
<td className="px-4 py-2 text-center">
|
||||||
|
<button className="h-8 px-3 rounded-md bg-[#f94b37] text-white text-sm border border-[#d73b29] hover:opacity-95" onClick={save}>저장</button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,34 +4,46 @@ import prisma from "@/lib/prisma";
|
|||||||
export async function GET(req: Request) {
|
export async function GET(req: Request) {
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const q = searchParams.get("q") || "";
|
const q = searchParams.get("q") || "";
|
||||||
const users = await prisma.user.findMany({
|
const page = Math.max(1, parseInt(searchParams.get("page") || "1", 10));
|
||||||
where: q
|
const pageSize = Math.min(100, Math.max(1, parseInt(searchParams.get("pageSize") || "20", 10)));
|
||||||
? {
|
const where = q
|
||||||
OR: [
|
? {
|
||||||
{ nickname: { contains: q } },
|
OR: [
|
||||||
{ phone: { contains: q } },
|
{ nickname: { contains: q } },
|
||||||
{ name: { contains: q } },
|
{ phone: { contains: q } },
|
||||||
],
|
{ name: { contains: q } },
|
||||||
}
|
],
|
||||||
: {},
|
}
|
||||||
orderBy: { createdAt: "desc" },
|
: {};
|
||||||
select: {
|
|
||||||
userId: true,
|
const [total, users] = await Promise.all([
|
||||||
nickname: true,
|
prisma.user.count({ where }),
|
||||||
name: true,
|
prisma.user.findMany({
|
||||||
phone: true,
|
where,
|
||||||
status: true,
|
orderBy: { createdAt: "desc" },
|
||||||
authLevel: true,
|
skip: (page - 1) * pageSize,
|
||||||
createdAt: true,
|
take: pageSize,
|
||||||
userRoles: { select: { role: { select: { name: true } } } },
|
select: {
|
||||||
},
|
userId: true,
|
||||||
take: 100,
|
nickname: true,
|
||||||
});
|
name: true,
|
||||||
|
phone: true,
|
||||||
|
status: true,
|
||||||
|
authLevel: true,
|
||||||
|
createdAt: true,
|
||||||
|
points: true,
|
||||||
|
level: true,
|
||||||
|
grade: true,
|
||||||
|
userRoles: { select: { role: { select: { name: true } } } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
const items = users.map((u) => ({
|
const items = users.map((u) => ({
|
||||||
...u,
|
...u,
|
||||||
roles: u.userRoles.map((r) => r.role.name),
|
roles: u.userRoles.map((r) => r.role.name),
|
||||||
}));
|
}));
|
||||||
return NextResponse.json({ users: items });
|
return NextResponse.json({ total, page, pageSize, users: items });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { PostList } from "@/app/components/PostList";
|
|||||||
import { HeroBanner } from "@/app/components/HeroBanner";
|
import { HeroBanner } from "@/app/components/HeroBanner";
|
||||||
import { BoardToolbar } from "@/app/components/BoardToolbar";
|
import { BoardToolbar } from "@/app/components/BoardToolbar";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
|
||||||
// Next 15: params/searchParams가 Promise가 될 수 있어 안전 언랩 처리합니다.
|
// Next 15: params/searchParams가 Promise가 될 수 있어 안전 언랩 처리합니다.
|
||||||
export default async function BoardDetail({ params, searchParams }: { params: any; searchParams: any }) {
|
export default async function BoardDetail({ params, searchParams }: { params: any; searchParams: any }) {
|
||||||
@@ -9,6 +10,7 @@ export default async function BoardDetail({ params, searchParams }: { params: an
|
|||||||
const sp = searchParams?.then ? await searchParams : searchParams;
|
const sp = searchParams?.then ? await searchParams : searchParams;
|
||||||
const id = p.id as string;
|
const id = p.id as string;
|
||||||
const sort = (sp?.sort as "recent" | "popular" | undefined) ?? "recent";
|
const sort = (sp?.sort as "recent" | "popular" | undefined) ?? "recent";
|
||||||
|
const period = (sp?.period as string | undefined) ?? "monthly";
|
||||||
// 보드 slug 조회 (새 글 페이지 프리셋 전달)
|
// 보드 slug 조회 (새 글 페이지 프리셋 전달)
|
||||||
const h = await headers();
|
const h = await headers();
|
||||||
const host = h.get("host") ?? "localhost:3000";
|
const host = h.get("host") ?? "localhost:3000";
|
||||||
@@ -19,6 +21,21 @@ export default async function BoardDetail({ params, searchParams }: { params: an
|
|||||||
const board = (boards || []).find((b: any) => b.id === id);
|
const board = (boards || []).find((b: any) => b.id === id);
|
||||||
const siblingBoards = (boards || []).filter((b: any) => b.category?.id && b.category.id === board?.category?.id);
|
const siblingBoards = (boards || []).filter((b: any) => b.category?.id && b.category.id === board?.category?.id);
|
||||||
const categoryName = board?.category?.name ?? "";
|
const categoryName = board?.category?.name ?? "";
|
||||||
|
|
||||||
|
// 리스트 뷰 타입 확인 (특수랭킹일 경우 게시글 대신 랭킹 노출)
|
||||||
|
const boardView = await prisma.board.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { listViewType: { select: { key: true } } },
|
||||||
|
});
|
||||||
|
const isSpecialRanking = boardView?.listViewType?.key === "list_special_rank";
|
||||||
|
|
||||||
|
let rankingItems: { userId: string; nickname: string; points: number }[] = [];
|
||||||
|
if (isSpecialRanking) {
|
||||||
|
const rankingUrl = new URL(`/api/ranking?period=${encodeURIComponent(period)}`, base).toString();
|
||||||
|
const rankingRes = await fetch(rankingUrl, { cache: "no-store" });
|
||||||
|
const rankingData = await rankingRes.json().catch(() => ({ items: [] }));
|
||||||
|
rankingItems = rankingData?.items ?? [];
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* 상단 배너 (서브카테고리 표시) */}
|
{/* 상단 배너 (서브카테고리 표시) */}
|
||||||
@@ -33,12 +50,40 @@ export default async function BoardDetail({ params, searchParams }: { params: an
|
|||||||
<section>
|
<section>
|
||||||
<BoardToolbar boardId={id} />
|
<BoardToolbar boardId={id} />
|
||||||
<div className="p-0">
|
<div className="p-0">
|
||||||
<PostList
|
{isSpecialRanking ? (
|
||||||
boardId={id}
|
<div className="rounded-xl border border-neutral-200 overflow-hidden">
|
||||||
sort={sort}
|
<div className="px-4 py-3 border-b border-neutral-200 flex items-center justify-between bg-[#f6f4f4]">
|
||||||
variant="board"
|
<h2 className="text-sm text-neutral-700">포인트 랭킹 ({period})</h2>
|
||||||
newPostHref={`/posts/new?boardId=${id}${board?.slug ? `&boardSlug=${board.slug}` : ""}`}
|
<div className="text-xs text-neutral-500 flex gap-2">
|
||||||
/>
|
<a href={`?period=daily`} className={`px-2 py-0.5 rounded ${period === "daily" ? "bg-neutral-900 text-white" : "bg-white border border-neutral-300"}`}>일간</a>
|
||||||
|
<a href={`?period=weekly`} className={`px-2 py-0.5 rounded ${period === "weekly" ? "bg-neutral-900 text-white" : "bg-white border border-neutral-300"}`}>주간</a>
|
||||||
|
<a href={`?period=monthly`} className={`px-2 py-0.5 rounded ${period === "monthly" ? "bg-neutral-900 text-white" : "bg-white border border-neutral-300"}`}>월간</a>
|
||||||
|
<a href={`?period=all`} className={`px-2 py-0.5 rounded ${period === "all" ? "bg-neutral-900 text-white" : "bg-white border border-neutral-300"}`}>전체</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ol className="divide-y divide-neutral-200">
|
||||||
|
{rankingItems.map((i, idx) => (
|
||||||
|
<li key={i.userId} className="px-4 py-3 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-neutral-900 text-white text-xs">{idx + 1}</span>
|
||||||
|
<span className="truncate text-neutral-900 font-medium">{i.nickname || "회원"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 text-sm text-neutral-700">{i.points}점</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{rankingItems.length === 0 && (
|
||||||
|
<li className="px-4 py-10 text-center text-neutral-500">랭킹 데이터가 없습니다.</li>
|
||||||
|
)}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<PostList
|
||||||
|
boardId={id}
|
||||||
|
sort={sort}
|
||||||
|
variant="board"
|
||||||
|
newPostHref={`/posts/new?boardId=${id}${board?.slug ? `&boardSlug=${board.slug}` : ""}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user