Files
tasteby/frontend/src/app/admin/_panels/UsersPanel.tsx
joungmin 7d95ecb3cb 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)
2026-06-15 15:52:08 +09:00

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>
);
}
/* ─── 데몬 설정 ─── */