비공개 메모 기능 추가 + 아이콘 개선

- 식당별 1:1 비공개 메모 CRUD (user_memos 테이블)
- 내 기록에 리뷰/메모 탭 분리
- 백오피스 유저 관리에 메모 수/상세 표시
- 리뷰/메모 작성 시 현재 날짜 기본값
- 지도우선/목록우선 버튼 Material Symbols 아이콘 적용

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
joungmin
2026-03-12 14:10:06 +09:00
parent 88c1b4243e
commit 3134994817
15 changed files with 667 additions and 45 deletions

View File

@@ -2148,6 +2148,7 @@ interface AdminUser {
created_at: string | null;
favorite_count: number;
review_count: number;
memo_count: number;
}
interface UserFavorite {
@@ -2171,6 +2172,16 @@ interface UserReview {
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;
}
function UsersPanel() {
const [users, setUsers] = useState<AdminUser[]>([]);
const [total, setTotal] = useState(0);
@@ -2178,6 +2189,7 @@ function UsersPanel() {
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;
@@ -2200,17 +2212,20 @@ function UsersPanel() {
setSelectedUser(null);
setFavorites([]);
setReviews([]);
setMemos([]);
return;
}
setSelectedUser(u);
setDetailLoading(true);
try {
const [favs, revs] = await Promise.all([
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 {
@@ -2233,6 +2248,7 @@ function UsersPanel() {
<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-left px-4 py-2"></th>
</tr>
</thead>
@@ -2284,6 +2300,15 @@ function UsersPanel() {
<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>
@@ -2343,7 +2368,7 @@ function UsersPanel() {
{detailLoading ? (
<p className="text-sm text-gray-500"> ...</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<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">
@@ -2419,6 +2444,46 @@ function UsersPanel() {
</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>

View File

@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { GoogleLogin } from "@react-oauth/google";
import LoginMenu from "@/components/LoginMenu";
import { api } from "@/lib/api";
import type { Restaurant, Channel, Review } from "@/lib/api";
import type { Restaurant, Channel, Review, Memo } from "@/lib/api";
import { useAuth } from "@/lib/auth-context";
import MapView, { MapBounds, FlyTo } from "@/components/MapView";
import SearchBar from "@/components/SearchBar";
@@ -191,6 +191,7 @@ export default function Home() {
const [showFavorites, setShowFavorites] = useState(false);
const [showMyReviews, setShowMyReviews] = useState(false);
const [myReviews, setMyReviews] = useState<(Review & { restaurant_id: string; restaurant_name: string | null })[]>([]);
const [myMemos, setMyMemos] = useState<(Memo & { restaurant_name: string | null })[]>([]);
const [visits, setVisits] = useState<{ today: number; total: number } | null>(null);
const [userLoc, setUserLoc] = useState<{ lat: number; lng: number }>({ lat: 37.498, lng: 127.0276 });
const [isSearchResult, setIsSearchResult] = useState(false);
@@ -518,10 +519,15 @@ export default function Home() {
if (showMyReviews) {
setShowMyReviews(false);
setMyReviews([]);
setMyMemos([]);
} else {
try {
const reviews = await api.getMyReviews();
const [reviews, memos] = await Promise.all([
api.getMyReviews(),
api.getMyMemos(),
]);
setMyReviews(reviews);
setMyMemos(memos);
setShowMyReviews(true);
setShowFavorites(false);
setSelected(null);
@@ -534,7 +540,8 @@ export default function Home() {
const sidebarContent = showMyReviews ? (
<MyReviewsList
reviews={myReviews}
onClose={() => { setShowMyReviews(false); setMyReviews([]); }}
memos={myMemos}
onClose={() => { setShowMyReviews(false); setMyReviews([]); setMyMemos([]); }}
onSelectRestaurant={async (restaurantId) => {
try {
const r = await api.getRestaurant(restaurantId);
@@ -563,7 +570,8 @@ export default function Home() {
const mobileListContent = showMyReviews ? (
<MyReviewsList
reviews={myReviews}
onClose={() => { setShowMyReviews(false); setMyReviews([]); }}
memos={myMemos}
onClose={() => { setShowMyReviews(false); setMyReviews([]); setMyMemos([]); }}
onSelectRestaurant={async (restaurantId) => {
try {
const r = await api.getRestaurant(restaurantId);
@@ -618,7 +626,7 @@ export default function Home() {
: "border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 hover:border-blue-300 hover:text-blue-500"
}`}
>
{viewMode === "map" ? "🗺 지도우선" : "목록우선"}
<Icon name={viewMode === "map" ? "map" : "list"} size={14} className="mr-0.5" />{viewMode === "map" ? "지도우선" : "목록우선"}
</button>
{user && (
<>
@@ -1193,6 +1201,7 @@ export default function Home() {
) : (
<MyReviewsList
reviews={myReviews}
memos={myMemos}
onClose={() => {}}
onSelectRestaurant={async (restaurantId) => {
try {