refactor(admin): #329 admin/page.tsx 분리 + localStorage 통일
- page.tsx 2817 LOC → 107 LOC (탭 라우팅 + CacheFlushButton만)
- _panels/ChannelsPanel.tsx (222), VideosPanel.tsx (1282),
RestaurantsPanel.tsx (675), UsersPanel.tsx (383), DaemonPanel.tsx (231)
- localStorage.getItem("tasteby_token") 10곳 → getAdminToken() (lib/admin-utils)
- 패널 내부 로직/state 그대로 (점진적 접근, 회귀 위험 최소화)
후속 분리:
- (신규) SSE 파싱 6곳을 consumeSseStream으로 통일
설계서: docs/design/329-admin-split/README.md
Refs: #329 (Developer)
This commit is contained in:
383
frontend/src/app/admin/_panels/UsersPanel.tsx
Normal file
383
frontend/src/app/admin/_panels/UsersPanel.tsx
Normal file
@@ -0,0 +1,383 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
// #329 — admin/page.tsx에서 추출
|
||||
interface AdminUser {
|
||||
id: string;
|
||||
email: string | null;
|
||||
nickname: string | null;
|
||||
avatar_url: string | null;
|
||||
is_admin: boolean;
|
||||
provider: string | null;
|
||||
created_at: string | null;
|
||||
favorite_count: number;
|
||||
review_count: number;
|
||||
memo_count: number;
|
||||
}
|
||||
|
||||
interface UserFavorite {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string | null;
|
||||
region: string | null;
|
||||
cuisine_type: string | null;
|
||||
rating: number | null;
|
||||
business_status: string | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
interface UserReview {
|
||||
id: string;
|
||||
restaurant_id: string;
|
||||
rating: number;
|
||||
review_text: string | null;
|
||||
visited_at: string | null;
|
||||
created_at: string | null;
|
||||
restaurant_name: string | null;
|
||||
}
|
||||
|
||||
interface UserMemo {
|
||||
id: string;
|
||||
restaurant_id: string;
|
||||
rating: number | null;
|
||||
memo_text: string | null;
|
||||
visited_at: string | null;
|
||||
created_at: string;
|
||||
restaurant_name: string | null;
|
||||
}
|
||||
|
||||
export function UsersPanel() {
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(0);
|
||||
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null);
|
||||
const [favorites, setFavorites] = useState<UserFavorite[]>([]);
|
||||
const [reviews, setReviews] = useState<UserReview[]>([]);
|
||||
const [memos, setMemos] = useState<UserMemo[]>([]);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const perPage = 20;
|
||||
|
||||
const loadUsers = useCallback(async (p: number) => {
|
||||
try {
|
||||
const res = await api.getAdminUsers({ limit: perPage, offset: p * perPage });
|
||||
setUsers(res.users);
|
||||
setTotal(res.total);
|
||||
} catch (e) {
|
||||
console.error("Failed to load users:", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers(page);
|
||||
}, [page, loadUsers]);
|
||||
|
||||
const handleSelectUser = async (u: AdminUser) => {
|
||||
if (selectedUser?.id === u.id) {
|
||||
setSelectedUser(null);
|
||||
setFavorites([]);
|
||||
setReviews([]);
|
||||
setMemos([]);
|
||||
return;
|
||||
}
|
||||
setSelectedUser(u);
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
const [favs, revs, mems] = await Promise.all([
|
||||
api.getAdminUserFavorites(u.id),
|
||||
api.getAdminUserReviews(u.id),
|
||||
api.getAdminUserMemos(u.id),
|
||||
]);
|
||||
setFavorites(favs);
|
||||
setReviews(revs);
|
||||
setMemos(mems);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(total / perPage);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-bold">유저 관리 ({total}명)</h2>
|
||||
|
||||
{/* Users Table */}
|
||||
<div className="bg-surface rounded-lg shadow overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-brand-50 border-b border-brand-100 text-brand-800 text-sm font-semibold">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2">사용자</th>
|
||||
<th className="text-left px-4 py-2">이메일</th>
|
||||
<th className="text-center px-4 py-2">관리자</th>
|
||||
<th className="text-center px-4 py-2">찜</th>
|
||||
<th className="text-center px-4 py-2">리뷰</th>
|
||||
<th className="text-center px-4 py-2">메모</th>
|
||||
<th className="text-left px-4 py-2">가입일</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr
|
||||
key={u.id}
|
||||
onClick={() => handleSelectUser(u)}
|
||||
className={`border-t cursor-pointer transition-colors ${
|
||||
selectedUser?.id === u.id
|
||||
? "bg-brand-50"
|
||||
: "hover:bg-brand-50/50"
|
||||
}`}
|
||||
>
|
||||
<td className="px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{u.avatar_url ? (
|
||||
<img
|
||||
src={u.avatar_url}
|
||||
alt=""
|
||||
className="w-7 h-7 rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-7 h-7 rounded-full bg-gray-200 flex items-center justify-center text-xs font-semibold text-gray-500">
|
||||
{(u.nickname || u.email || "?").charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<span className="font-medium">
|
||||
{u.nickname || "-"}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-500">{u.email || "-"}</td>
|
||||
<td className="px-4 py-2 text-center">
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await api.updateAdminUserAdmin(u.id, !u.is_admin);
|
||||
setUsers((prev) => prev.map((x) => x.id === u.id ? { ...x, is_admin: !u.is_admin } : x));
|
||||
} catch (err) {
|
||||
console.error("Failed to update admin:", err);
|
||||
alert("관리자 권한 변경에 실패했습니다.");
|
||||
}
|
||||
}}
|
||||
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium transition-colors ${
|
||||
u.is_admin
|
||||
? "bg-green-100 text-green-700 hover:bg-green-200"
|
||||
: "bg-gray-100 text-gray-400 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
{u.is_admin ? "ON" : "OFF"}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-center">
|
||||
{u.favorite_count > 0 ? (
|
||||
<span className="inline-block px-2 py-0.5 bg-red-50 text-red-600 rounded-full text-xs font-medium">
|
||||
{u.favorite_count}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-300">0</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-center">
|
||||
{u.review_count > 0 ? (
|
||||
<span className="inline-block px-2 py-0.5 bg-brand-50 text-brand-600 rounded-full text-xs font-medium">
|
||||
{u.review_count}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-300">0</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-center">
|
||||
{u.memo_count > 0 ? (
|
||||
<span className="inline-block px-2 py-0.5 bg-purple-50 text-purple-600 rounded-full text-xs font-medium">
|
||||
{u.memo_count}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-300">0</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-400 text-xs">
|
||||
{u.created_at?.slice(0, 10) || "-"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
className="px-3 py-1 text-sm border rounded disabled:opacity-30"
|
||||
>
|
||||
이전
|
||||
</button>
|
||||
<span className="text-sm text-gray-600">
|
||||
{page + 1} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||
disabled={page >= totalPages - 1}
|
||||
className="px-3 py-1 text-sm border rounded disabled:opacity-30"
|
||||
>
|
||||
다음
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selected User Detail */}
|
||||
{selectedUser && (
|
||||
<div className="bg-surface rounded-lg shadow p-5 space-y-4">
|
||||
<div className="flex items-center gap-3 pb-3 border-b">
|
||||
{selectedUser.avatar_url ? (
|
||||
<img
|
||||
src={selectedUser.avatar_url}
|
||||
alt=""
|
||||
className="w-12 h-12 rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center text-lg font-semibold text-gray-500">
|
||||
{(selectedUser.nickname || selectedUser.email || "?").charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="font-bold">{selectedUser.nickname || "-"}</div>
|
||||
<div className="text-sm text-gray-500">{selectedUser.email || "-"}</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{selectedUser.provider} · 가입: {selectedUser.created_at?.slice(0, 10)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{detailLoading ? (
|
||||
<p className="text-sm text-gray-500">로딩 중...</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* Favorites */}
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm mb-2 text-red-600">
|
||||
찜한 식당 ({favorites.length})
|
||||
</h3>
|
||||
{favorites.length === 0 ? (
|
||||
<p className="text-xs text-gray-400">찜한 식당이 없습니다.</p>
|
||||
) : (
|
||||
<div className="space-y-1 max-h-64 overflow-y-auto">
|
||||
{favorites.map((f) => (
|
||||
<div
|
||||
key={f.id}
|
||||
className="flex items-center justify-between border rounded px-3 py-2 text-xs"
|
||||
>
|
||||
<div>
|
||||
<span className="font-medium">{f.name}</span>
|
||||
{f.region && (
|
||||
<span className="ml-1.5 text-gray-400">{f.region}</span>
|
||||
)}
|
||||
{f.cuisine_type && (
|
||||
<span className="ml-1.5 text-gray-400">· {f.cuisine_type}</span>
|
||||
)}
|
||||
{f.business_status === "CLOSED_PERMANENTLY" && (
|
||||
<span className="ml-1.5 px-1 bg-red-100 text-red-600 rounded text-[10px]">
|
||||
폐업
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{f.rating && (
|
||||
<span className="text-yellow-500 shrink-0">
|
||||
★ {f.rating}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reviews */}
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm mb-2 text-brand-600">
|
||||
작성한 리뷰 ({reviews.length})
|
||||
</h3>
|
||||
{reviews.length === 0 ? (
|
||||
<p className="text-xs text-gray-400">작성한 리뷰가 없습니다.</p>
|
||||
) : (
|
||||
<div className="space-y-1 max-h-64 overflow-y-auto">
|
||||
{reviews.map((r) => (
|
||||
<div
|
||||
key={r.id}
|
||||
className="border rounded px-3 py-2 text-xs space-y-0.5"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">
|
||||
{r.restaurant_name || "알 수 없음"}
|
||||
</span>
|
||||
<span className="text-yellow-500 shrink-0">
|
||||
{"★".repeat(Math.round(r.rating))} {r.rating}
|
||||
</span>
|
||||
</div>
|
||||
{r.review_text && (
|
||||
<p className="text-gray-600 line-clamp-2">
|
||||
{r.review_text}
|
||||
</p>
|
||||
)}
|
||||
<div className="text-gray-400 text-[10px]">
|
||||
{r.visited_at && `방문: ${r.visited_at} · `}
|
||||
{r.created_at?.slice(0, 10)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Memos */}
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm mb-2 text-purple-600">
|
||||
작성한 메모 ({memos.length})
|
||||
</h3>
|
||||
{memos.length === 0 ? (
|
||||
<p className="text-xs text-gray-400">작성한 메모가 없습니다.</p>
|
||||
) : (
|
||||
<div className="space-y-1 max-h-64 overflow-y-auto">
|
||||
{memos.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className="border border-purple-200 rounded px-3 py-2 text-xs space-y-0.5 bg-purple-50/30"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">
|
||||
{m.restaurant_name || "알 수 없음"}
|
||||
</span>
|
||||
{m.rating && (
|
||||
<span className="text-yellow-500 shrink-0">
|
||||
{"★".repeat(Math.round(m.rating))} {m.rating}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{m.memo_text && (
|
||||
<p className="text-gray-600 line-clamp-2">
|
||||
{m.memo_text}
|
||||
</p>
|
||||
)}
|
||||
<div className="text-gray-400 text-[10px]">
|
||||
{m.visited_at && `방문: ${m.visited_at} · `}
|
||||
{m.created_at?.slice(0, 10)}
|
||||
<span className="ml-1 text-purple-400">비공개</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── 데몬 설정 ─── */
|
||||
Reference in New Issue
Block a user