"use client"; import { getAdminToken, consumeSseStream } from "@/lib/admin-utils"; import { useCallback, useEffect, useState } from "react"; import { api } from "@/lib/api"; import type { Restaurant, VideoLink } from "@/lib/api"; // #329 — admin/page.tsx에서 추출 export function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) { const [restaurants, setRestaurants] = useState([]); const [loading, setLoading] = useState(false); const [page, setPage] = useState(0); const [nameSearch, setNameSearch] = useState(""); const [selected, setSelected] = useState(null); const [editForm, setEditForm] = useState>({}); const [saving, setSaving] = useState(false); const [videos, setVideos] = useState([]); const [tablingSearching, setTablingSearching] = useState(false); const [bulkTabling, setBulkTabling] = useState(false); const [bulkTablingProgress, setBulkTablingProgress] = useState({ current: 0, total: 0, name: "", linked: 0, notFound: 0 }); const [catchtableSearching, setCatchtableSearching] = useState(false); const [bulkCatchtable, setBulkCatchtable] = useState(false); const [bulkCatchtableProgress, setBulkCatchtableProgress] = useState({ current: 0, total: 0, name: "", linked: 0, notFound: 0 }); type RestSortKey = "name" | "region" | "cuisine_type" | "price_range" | "rating" | "business_status"; const [sortKey, setSortKey] = useState("name"); const [sortAsc, setSortAsc] = useState(true); const perPage = 20; const handleSort = (key: RestSortKey) => { if (sortKey === key) setSortAsc(!sortAsc); else { setSortKey(key); setSortAsc(true); } }; const sortIcon = (key: RestSortKey) => sortKey !== key ? " ↕" : sortAsc ? " ↑" : " ↓"; const load = useCallback(() => { setLoading(true); api .getRestaurants({ limit: 500 }) .then(setRestaurants) .catch(console.error) .finally(() => setLoading(false)); }, []); useEffect(() => { load(); }, [load]); // #322/#323 LLM 검증 UI const [verifyPending, setVerifyPending] = useState(null); const [verifying, setVerifying] = useState(false); const [verifyResult, setVerifyResult] = useState(null); const loadVerifyPending = useCallback(() => { api.getVerifyPending().then((r) => setVerifyPending(r.pending)).catch(() => setVerifyPending(null)); }, []); useEffect(() => { loadVerifyPending(); }, [loadVerifyPending]); const handleVerifyAll = async () => { if (!isAdmin) return; if (!confirm(`미검증 식당 ${verifyPending ?? "?"}건을 LLM으로 일괄 검증합니다.\n잘못 등록된 데이터/프랜차이즈를 자동으로 숨김 처리합니다.\n진행할까요?`)) return; setVerifying(true); setVerifyResult(null); try { const r = await api.verifyAll(10); setVerifyResult(`${r.processed}건 검증 완료`); loadVerifyPending(); load(); } catch (e) { setVerifyResult(`실패: ${e instanceof Error ? e.message : String(e)}`); } finally { setVerifying(false); } }; const handleToggleHidden = async (r: Restaurant) => { if (!isAdmin) return; const becomingHidden = !r.hidden; const reason = becomingHidden ? (prompt("숨김 사유(선택)", "manual") ?? "manual") : ""; try { await api.setRestaurantHidden(r.id, becomingHidden, reason || "manual"); load(); } catch (e) { alert(`실패: ${e instanceof Error ? e.message : String(e)}`); } }; const filtered = restaurants.filter((r) => { if (nameSearch && !r.name.toLowerCase().includes(nameSearch.toLowerCase())) return false; return true; }); const sorted = [...filtered].sort((a, b) => { let av: string | number = a[sortKey] ?? ""; let bv: string | number = b[sortKey] ?? ""; if (sortKey === "rating") { av = a.rating ?? 0; bv = b.rating ?? 0; } const cmp = av < bv ? -1 : av > bv ? 1 : 0; return sortAsc ? cmp : -cmp; }); const totalPages = Math.max(1, Math.ceil(sorted.length / perPage)); const paged = sorted.slice(page * perPage, (page + 1) * perPage); const handleSelect = (r: Restaurant) => { if (selected?.id === r.id) { setSelected(null); setVideos([]); return; } setSelected(r); api.getRestaurantVideos(r.id).then(setVideos).catch(() => setVideos([])); setEditForm({ name: r.name || "", address: r.address || "", region: r.region || "", cuisine_type: r.cuisine_type || "", price_range: r.price_range || "", phone: r.phone || "", website: r.website || "", latitude: r.latitude != null ? String(r.latitude) : "", longitude: r.longitude != null ? String(r.longitude) : "", }); }; const handleSave = async () => { if (!selected) return; setSaving(true); try { const data: Record = { ...editForm }; if (editForm.latitude) data.latitude = parseFloat(editForm.latitude); else data.latitude = null; if (editForm.longitude) data.longitude = parseFloat(editForm.longitude); else data.longitude = null; await api.updateRestaurant(selected.id, data as Partial); load(); setSelected(null); } catch (e) { alert("저장 실패: " + (e instanceof Error ? e.message : String(e))); } finally { setSaving(false); } }; const handleDelete = async () => { if (!selected) return; if (!confirm(`"${selected.name}" 식당을 삭제하시겠습니까?\n연결된 영상 매핑, 리뷰, 벡터도 함께 삭제됩니다.`)) return; try { await api.deleteRestaurant(selected.id); setSelected(null); load(); } catch { alert("삭제 실패"); } }; return (
{ setNameSearch(e.target.value); setPage(0); }} onKeyDown={(e) => e.key === "Escape" && setNameSearch("")} className="border border-r-0 rounded-l px-3 py-2 text-sm w-48 bg-surface text-gray-900" /> {nameSearch ? ( ) : ( )}
{isAdmin && (<> {/* #322/#323 — LLM 검증 */}
미검증 {verifyPending ?? "?"}건 {verifyResult && {verifyResult}}
)} {nameSearch ? `${sorted.length} / ` : ""}총 {restaurants.length}개 식당
{bulkTabling && bulkTablingProgress.name && (
{bulkTablingProgress.current}/{bulkTablingProgress.total} - {bulkTablingProgress.name} 연결: {bulkTablingProgress.linked} / 미발견: {bulkTablingProgress.notFound}
)} {bulkCatchtable && bulkCatchtableProgress.name && (
{bulkCatchtableProgress.current}/{bulkCatchtableProgress.total} - {bulkCatchtableProgress.name} 연결: {bulkCatchtableProgress.linked} / 미발견: {bulkCatchtableProgress.notFound}
)}
{paged.map((r) => ( handleSelect(r)} className={`border-b cursor-pointer hover:bg-brand-50/50 ${selected?.id === r.id ? "bg-brand-50" : ""}`} > ))} {!loading && filtered.length === 0 && ( )}
handleSort("name")}>이름{sortIcon("name")} handleSort("region")}>지역{sortIcon("region")} handleSort("cuisine_type")}>음식 종류{sortIcon("cuisine_type")} handleSort("price_range")}>가격대{sortIcon("price_range")} handleSort("rating")}>평점{sortIcon("rating")} handleSort("business_status")}>상태{sortIcon("business_status")} 검증
{r.name} {r.region || "-"} {r.cuisine_type || "-"} {r.price_range || "-"} {r.rating ? ( {r.rating} ) : "-"} {r.business_status === "CLOSED_PERMANENTLY" ? ( 폐업 ) : r.business_status === "CLOSED_TEMPORARILY" ? ( 임시휴업 ) : ( - )} e.stopPropagation()}> {r.hidden ? ( ) : r.verified_at ? ( ) : ( 미검증 )}
식당 데이터가 없습니다
{totalPages > 1 && (
{page + 1} / {totalPages}
)} {/* 식당 상세/수정 패널 */} {selected && (

{selected.name}

{[ { key: "name", label: "이름" }, { key: "address", label: "주소" }, { key: "region", label: "지역" }, { key: "cuisine_type", label: "음식 종류" }, { key: "price_range", label: "가격대" }, { key: "phone", label: "전화" }, { key: "website", label: "웹사이트" }, { key: "latitude", label: "위도" }, { key: "longitude", label: "경도" }, ].map(({ key, label }) => (
setEditForm((f) => ({ ...f, [key]: e.target.value }))} className="w-full border rounded px-2 py-1.5 text-sm bg-surface text-gray-900" disabled={!isAdmin} />
))}
{selected.business_status && (

Google 상태: {selected.business_status} {selected.rating && <> / ★ {selected.rating} ({selected.rating_count?.toLocaleString()})}

)} {selected.google_place_id && (

Google Maps에서 보기

)} {/* 테이블링 연결 */} {isAdmin && (

테이블링

{selected.tabling_url === "NONE" ? ( 검색완료-없음 ) : selected.tabling_url ? ( {selected.tabling_url} ) : ( 미연결 )} {selected.tabling_url && ( )}
)} {/* 캐치테이블 연결 */} {isAdmin && (

캐치테이블

{selected.catchtable_url === "NONE" ? ( 검색완료-없음 ) : selected.catchtable_url ? ( {selected.catchtable_url} ) : ( 미연결 )} {selected.catchtable_url && ( )}
)} {videos.length > 0 && (

연결된 영상 ({videos.length})

{videos.map((v) => (
{v.channel_name} {v.title} {v.published_at?.slice(0, 10)}
))}
)}
{isAdmin && } {isAdmin && }
)}
); } /* ─── 유저 관리 ─── */ 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; }