- 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)
384 lines
14 KiB
TypeScript
384 lines
14 KiB
TypeScript
"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>
|
|
);
|
|
}
|
|
|
|
/* ─── 데몬 설정 ─── */
|