merge: feature/admin-full-access(관리자 전체 접근 · 사이트관리 검색 · 화면 썸네일) 을 main 에 병합

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Mina Choi 2026-09-30 15:34:21 +09:00
commit 3eed628013
11 changed files with 332 additions and 65 deletions

View File

@ -2,6 +2,7 @@
import os import os
os.environ.setdefault("APP_ENV", "test") os.environ.setdefault("APP_ENV", "test")
os.environ.setdefault("THUMBNAIL_CAPTURE", "0")
import uuid import uuid

View File

@ -44,7 +44,7 @@ class ISiteCRUD(ABC):
pass pass
@abstractmethod @abstractmethod
async def list_all_sites(self, cdb: AsyncSession, skip, limit) -> Tuple[ErrorType, list, int]: async def list_all_sites(self, cdb: AsyncSession, skip, limit, search=None, category=None) -> Tuple[ErrorType, list, int]:
"""전 계정 사이트 목록(회사 스코프 없음) — 내부 운영(DEVELOPER) 전용.""" """전 계정 사이트 목록(회사 스코프 없음) — 내부 운영(DEVELOPER) 전용."""
pass pass
@ -145,12 +145,23 @@ class SiteCRUD(ISiteCRUD):
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, [], 0 return ErrorType.DB_RUN_FAILED, [], 0
async def list_all_sites(self, cdb: AsyncSession, skip: int, limit: int) -> Tuple[ErrorType, list, int]: async def list_all_sites(
self, cdb: AsyncSession, skip: int, limit: int, search: str | None = None, category: int | None = None
) -> Tuple[ErrorType, list, int]:
"""list_owner_sites 와 같은 조인이되 owner_user_id 필터가 없다 — 소유자 계정 정보를 같이 얹는다.""" """list_owner_sites 와 같은 조인이되 owner_user_id 필터가 없다 — 소유자 계정 정보를 같이 얹는다."""
try: try:
where = places.deleted == False # noqa: E712 where = places.deleted == False # noqa: E712
if category is not None:
where = and_(where, places.category == category)
if search and search.strip():
like = f"%{search.strip()}%"
where = and_(where, places.name.ilike(like) | users.email.ilike(like)
| users.name.ilike(like) | users.id.ilike(like))
cnt_err, cnt_rows = await DB_SESSION_MNG.execute(cdb, select(func.count()).select_from(places).where(where)) cnt_err, cnt_rows = await DB_SESSION_MNG.execute(
cdb,
select(func.count()).select_from(places).join(users, users.user_id == places.owner_user_id).where(where),
)
if cnt_err != ErrorType.SUCCESS: if cnt_err != ErrorType.SUCCESS:
return cnt_err, [], 0 return cnt_err, [], 0
total = int(cnt_rows[0] or 0) if cnt_rows else 0 total = int(cnt_rows[0] or 0) if cnt_rows else 0

View File

@ -10,8 +10,14 @@ router = APIRouter(prefix="/v1/ops", tags=["Ops"])
@router.get(path="/sites", response_model=Res_OpsSites, summary="전 계정 사이트 목록(개발자 전용)") @router.get(path="/sites", response_model=Res_OpsSites, summary="전 계정 사이트 목록(개발자 전용)")
async def list_sites(service: OpsService = Depends(), pg: PageParams = Depends(), _user=Depends(RequireDeveloper)): async def list_sites(
return RemoveNoneResponse(await service.list_sites(pg)) service: OpsService = Depends(),
pg: PageParams = Depends(),
search: str | None = Query(None, description="상호·소유자 이메일·이름·로그인 아이디 부분 일치"),
category: int | None = Query(None, description="PlaceCategory"),
_user=Depends(RequireDeveloper),
):
return RemoveNoneResponse(await service.list_sites(pg, search, category))
@router.get(path="/users", response_model=Res_OpsUsers, summary="전 계정 목록(개발자 전용)") @router.get(path="/users", response_model=Res_OpsUsers, summary="전 계정 목록(개발자 전용)")

View File

@ -1,15 +1,21 @@
import asyncio import asyncio
import json import json
import uuid
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any, Union from typing import Any, Union
from fastapi import Depends from fastapi import Depends, Request
from sqlalchemy import select
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
import bcrypt import bcrypt
from jose import jwt, JWTError, ExpiredSignatureError from jose import jwt, JWTError, ExpiredSignatureError
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import places
from common.enums import ( from common.enums import (
DBWRType,
ErrorType,
EXCEPTION_ACCESS_TOKEN_EXPIRED, EXCEPTION_ACCESS_TOKEN_EXPIRED,
EXCEPTION_FORBIDDEN, EXCEPTION_FORBIDDEN,
EXCEPTION_INVALID_CLIENT_ACCESS, EXCEPTION_INVALID_CLIENT_ACCESS,
@ -96,8 +102,40 @@ def DecodeRefreshToken(jwt_token: str) -> UserInfo:
# Depends 용 토큰 검증기 # Depends 용 토큰 검증기
async def IsValidAccessToken(credentials: HTTPAuthorizationCredentials = Depends(security)) -> UserInfo: async def _place_owner(place_id: str) -> str | None:
return DecodeAccessToken(credentials.credentials) try:
pid = uuid.UUID(place_id)
except ValueError:
return None
err, rows = await DB_SESSION_MNG.execute_lambda(
places.DBType(),
DBWRType.DB_READ.value,
lambda s: DB_SESSION_MNG.execute(
s, select(places.owner_user_id).where(places.place_id == pid, places.deleted == False) # noqa: E712
),
)
if err != ErrorType.SUCCESS or not rows:
return None
return str(rows[0])
async def _act_as_place_owner(request: Request, user_info: UserInfo) -> UserInfo:
if (user_info.role or 0) < UserRole.DEVELOPER.value:
return user_info
place_id = request.path_params.get("place_id") or request.query_params.get("place_id")
if not place_id:
return user_info
owner = await _place_owner(str(place_id))
if owner is None or owner == str(user_info.user_id):
return user_info
LOG.i(f"[admin-access] {user_info.id} → place={place_id} owner={owner} {request.method} {request.url.path}")
return UserInfo(user_id=owner, id=user_info.id, role=user_info.role, token_version=user_info.token_version)
async def IsValidAccessToken(
request: Request, credentials: HTTPAuthorizationCredentials = Depends(security)
) -> UserInfo:
return await _act_as_place_owner(request, DecodeAccessToken(credentials.credentials))
async def IsValidRefreshToken(credentials: HTTPAuthorizationCredentials = Depends(security)) -> UserInfo: async def IsValidRefreshToken(credentials: HTTPAuthorizationCredentials = Depends(security)) -> UserInfo:

View File

@ -16,11 +16,11 @@ class OpsService:
self.site_crud = site_crud self.site_crud = site_crud
self.user_crud = user_crud self.user_crud = user_crud
async def list_sites(self, pg: PageParams) -> Res_OpsSites: async def list_sites(self, pg: PageParams, search: str | None = None, category: int | None = None) -> Res_OpsSites:
res = Res_OpsSites(page=pg.page, size=pg.size) res = Res_OpsSites(page=pg.page, size=pg.size)
err_type, rows, total = await DB_SESSION_MNG.execute_lambda( err_type, rows, total = await DB_SESSION_MNG.execute_lambda(
places.DBType(), DBWRType.DB_READ.value, places.DBType(), DBWRType.DB_READ.value,
lambda s: self.site_crud.list_all_sites(s, pg.skip, pg.size), lambda s: self.site_crud.list_all_sites(s, pg.skip, pg.size, search, category),
) )
if err_type != ErrorType.SUCCESS: if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type) res.result.SetResult(err_type)

View File

@ -30,6 +30,10 @@ MAX_BYTES = 5 * 1024 * 1024
# 썸네일은 재발행마다 바뀔 수 있고 주소는 그대로다 — 길게 캐시하면 옛 그림이 계속 뜬다. # 썸네일은 재발행마다 바뀔 수 있고 주소는 그대로다 — 길게 캐시하면 옛 그림이 계속 뜬다.
CACHE_CONTROL = "public, max-age=60, must-revalidate" CACHE_CONTROL = "public, max-age=60, must-revalidate"
CAPTURE_ENV = "THUMBNAIL_CAPTURE"
CAPTURE_VIEWPORT = {"width": 1280, "height": 800}
CAPTURE_TIMEOUT_MS = 30_000
# ── 썸네일 전용 저장소 ──────────────────────────────────────────────────────── # ── 썸네일 전용 저장소 ────────────────────────────────────────────────────────
_BASE_ENV = "THUMBNAIL_BLOB_BASE_URL" # https://<계정>.blob.core.windows.net/<컨테이너> _BASE_ENV = "THUMBNAIL_BLOB_BASE_URL" # https://<계정>.blob.core.windows.net/<컨테이너>
@ -121,6 +125,30 @@ async def _fetch(url: str) -> tuple[bytes, str, str] | None:
return data, content_type, ext return data, content_type, ext
def capture_enabled() -> bool:
return os.environ.get(CAPTURE_ENV, "1").strip() != "0"
async def _capture(url: str) -> tuple[bytes, str, str] | None:
try:
from playwright.async_api import async_playwright
async with async_playwright() as pw:
browser = await pw.chromium.launch(headless=True)
try:
page = await browser.new_page(viewport=CAPTURE_VIEWPORT)
await page.goto(url, wait_until="networkidle", timeout=CAPTURE_TIMEOUT_MS)
data = await page.screenshot(type="jpeg", quality=80)
finally:
await browser.close()
except Exception as ex: # noqa: BLE001
LOG.w(f"[thumbnail] 화면 촬영 실패(대표 사진으로 대신한다) {url}: {type(ex).__name__}: {ex}")
return None
if not data or len(data) > MAX_BYTES:
return None
return data, "image/jpeg", "jpg"
def _upload_sync(slug: str, data: bytes, content_type: str, ext: str) -> str: def _upload_sync(slug: str, data: bytes, content_type: str, ext: str) -> str:
name = blob_name(slug, ext) name = blob_name(slug, ext)
settings = ContentSettings(content_type=content_type, cache_control=CACHE_CONTROL) settings = ContentSettings(content_type=content_type, cache_control=CACHE_CONTROL)
@ -145,17 +173,21 @@ def _upload_sync(slug: str, data: bytes, content_type: str, ext: str) -> str:
async def store(slug: str, snapshot: dict, version: int | None = None) -> str | None: async def store(slug: str, snapshot: dict, version: int | None = None) -> str | None:
"""대표 사진을 썸네일로 올리고 공개 URL 을 돌려준다.""" """발행된 화면을 찍어 썸네일로 올리고 공개 URL 을 돌려준다. 못 찍으면 대표 사진을 쓴다."""
if not is_configured(): if not is_configured():
return None return None
row = site_payload.primary_media(snapshot) fetched = None
url = str((row or {}).get("url") or "").strip() if capture_enabled():
if not url: fetched = await _capture(f"{site_payload.publish_origin()}/s/{slug}")
LOG.w(f"[thumbnail] 대표 사진이 없다 — 썸네일 없이 발행한다: {slug}")
return None
fetched = await _fetch(url) if fetched is None:
row = site_payload.primary_media(snapshot)
url = str((row or {}).get("url") or "").strip()
if not url:
LOG.w(f"[thumbnail] 대표 사진이 없다 — 썸네일 없이 발행한다: {slug}")
return None
fetched = await _fetch(url)
if fetched is None: if fetched is None:
return None return None
data, content_type, ext = fetched data, content_type, ext = fetched

View File

@ -0,0 +1,57 @@
from common.enums import UserRole
async def _place(client, headers, name="남의가게"):
r = await client.post("/v1/place", headers=headers, json={"name": name, "category": 2})
return r.json()["place"]["place_id"]
async def test_개발자는_남의_사업장을_열고_고칠_수_있다(auth_headers, client):
owner = await auth_headers("owner1")
dev = await auth_headers("dev1", role=UserRole.DEVELOPER.value)
pid = await _place(client, owner)
got = (await client.get(f"/v1/place/{pid}", headers=dev)).json()
assert got["result"]["success"] is True
assert got["place"]["name"] == "남의가게"
assert (await client.get(f"/v1/place/{pid}/media/list", headers=dev)).json()["result"]["success"] is True
as_owner = (await client.get(f"/v1/place/{pid}/site", headers=owner)).json()
as_dev = (await client.get(f"/v1/place/{pid}/site", headers=dev)).json()
assert as_dev == as_owner
async def test_일반_사장님은_남의_사업장을_못_연다(auth_headers, client):
owner = await auth_headers("owner2")
other = await auth_headers("other2")
boss = await auth_headers("boss2", role=UserRole.OWNER.value)
pid = await _place(client, owner)
assert (await client.get(f"/v1/place/{pid}", headers=other)).json()["result"]["success"] is False
assert (await client.get(f"/v1/place/{pid}", headers=boss)).json()["result"]["success"] is False
async def test_개발자_자기_목록은_그대로다(auth_headers, client):
owner = await auth_headers("owner3")
dev = await auth_headers("dev3", role=UserRole.DEVELOPER.value)
await _place(client, owner)
listed = (await client.get("/v1/place/list", headers=dev)).json()
assert listed["result"]["success"] is True
assert all(p["name"] != "남의가게" for p in listed["places"])
async def test_사이트관리는_상호_소유자_업종으로_거른다(auth_headers, client):
owner = await auth_headers("owner4", name="김사장")
dev = await auth_headers("dev4", role=UserRole.DEVELOPER.value)
await client.post("/v1/place", headers=owner, json={"name": "검색카페", "category": 2})
await client.post("/v1/place", headers=owner, json={"name": "검색펜션", "category": 1})
def names(r):
return sorted(s["name"] for s in r.json()["sites"])
assert names(await client.get("/v1/ops/sites", headers=dev, params={"search": "검색"})) == ["검색카페", "검색펜션"]
assert names(await client.get("/v1/ops/sites", headers=dev, params={"search": "김사장"})) == ["검색카페", "검색펜션"]
assert names(await client.get("/v1/ops/sites", headers=dev, params={"search": "검색", "category": 2})) == ["검색카페"]
counted = (await client.get("/v1/ops/sites", headers=dev, params={"search": "펜션"})).json()
assert counted["total"] == 1

View File

@ -115,6 +115,39 @@ async def test_사이트_디렉터리_밖의_thumbs_에_올린다(blob, monkeypa
# ── 발행 경로와의 배선 ──────────────────────────────────────────────────── 아래 두 건은 "발행이 썸네일에 매달리지 않는가" 를 본다. # ── 발행 경로와의 배선 ──────────────────────────────────────────────────── 아래 두 건은 "발행이 썸네일에 매달리지 않는가" 를 본다.
async def test_발행된_화면을_찍어_올린다(blob, monkeypatch):
shots = []
async def _capture(url):
shots.append(url)
return b"shot", "image/jpeg", "jpg"
monkeypatch.setenv(site_thumbnail.CAPTURE_ENV, "1")
monkeypatch.setattr(site_thumbnail, "_capture", _capture)
snapshot = _snapshot({"media_id": "m1", "url": "https://cdn.test/front.jpg", "unit_id": None})
await site_thumbnail.store("butter", snapshot, 4)
from services import site_payload
assert shots == [f"{site_payload.publish_origin()}/s/butter"]
assert blob.uploads[f"{azure_static.DEFAULT_PREFIX}/thumbs/butter.jpg"]["body"] == b"shot"
async def test_촬영에_실패하면_대표_사진을_쓴다(blob, monkeypatch):
async def _capture(url):
return None
monkeypatch.setenv(site_thumbnail.CAPTURE_ENV, "1")
monkeypatch.setattr(site_thumbnail, "_capture", _capture)
_transport(monkeypatch, lambda req: httpx.Response(200, headers={"content-type": "image/jpeg"}, content=b"photo"))
snapshot = _snapshot({"media_id": "m1", "url": "https://cdn.test/front.jpg", "unit_id": None})
await site_thumbnail.store("butter", snapshot, 5)
assert blob.uploads[f"{azure_static.DEFAULT_PREFIX}/thumbs/butter.jpg"]["body"] == b"photo"
async def test_발행하면_사이트에_썸네일_주소가_남는다(auth_headers, client, db_engine, monkeypatch): async def test_발행하면_사이트에_썸네일_주소가_남는다(auth_headers, client, db_engine, monkeypatch):
from services import build_service from services import build_service
from tests.test_build_publish import _approved_media, _place, _run, _verified_facts from tests.test_build_publish import _approved_media, _place, _run, _verified_facts

View File

@ -6,6 +6,14 @@
*/ */
export type ListSitesParams = { export type ListSitesParams = {
/**
* 상호·소유자 이메일·이름·로그인 아이디 부분 일치
*/
search?: string | null;
/**
* PlaceCategory
*/
category?: number | null;
/** /**
* @minimum 1 * @minimum 1
*/ */

View File

@ -1,13 +1,16 @@
import {useState} from 'react'; import {useState} from 'react';
import {Navigate} from 'react-router'; import {Link, Navigate} from 'react-router';
import {keepPreviousData} from '@tanstack/react-query'; import {keepPreviousData} from '@tanstack/react-query';
import {Building2, ChevronLeft, ChevronRight, ExternalLink, Loader2} from 'lucide-react'; import {Building2, ChevronLeft, ChevronRight, ExternalLink, Loader2, Newspaper, Pencil, Search, Trash2, X} from 'lucide-react';
import {PlaceCategory, publishUrlString, SiteStatus} from '@o2o/shared'; import {PlaceCategory, publishUrlString, SiteStatus} from '@o2o/shared';
import {UserRole, useListSites, type OpsSiteData} from '@/api'; import {changeStatus, deletePlace, PublishAction, UserRole, useListSites, type OpsSiteData} from '@/api';
import {AppShell, EmptyState, PageContainer} from '@/components/layout/AppShell'; import {AppShell, EmptyState, PageContainer} from '@/components/layout/AppShell';
import {Badge} from '@/components/ui/badge'; import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
import {notify, notifyApiError} from '@/lib/notify';
import {PUBLISH_HOST} from '@/lib/site'; import {PUBLISH_HOST} from '@/lib/site';
import {cn} from '@/lib/utils';
import {useAuthStore} from '@/stores/auth'; import {useAuthStore} from '@/stores/auth';
const CATEGORY_LABEL: Record<number, string> = { const CATEGORY_LABEL: Record<number, string> = {
@ -39,14 +42,46 @@ export function OpsSitesPage() {
const isRestoring = useAuthStore((s) => s.isRestoring); const isRestoring = useAuthStore((s) => s.isRestoring);
const role = useAuthStore((s) => s.user?.role); const role = useAuthStore((s) => s.user?.role);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [category, setCategory] = useState<number | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const size = 20; const size = 20;
// enabled: role 이 확정되기 전엔 요청하지 않는다 — 어차피 백엔드가 403 으로 막지만, 사장님 화면에서 실패 토스트가 먼저 뜨는 것과 새로고침 때 잠깐의 오탐 리다이렉트를 막는다. // enabled: role 이 확정되기 전엔 요청하지 않는다 — 어차피 백엔드가 403 으로 막지만, 사장님 화면에서 실패 토스트가 먼저 뜨는 것과 새로고침 때 잠깐의 오탐 리다이렉트를 막는다.
const {data, isLoading, isError, error} = useListSites( const {data, isLoading, isError, error, refetch} = useListSites(
{page, size}, {page, size, search: search || undefined, category: category ?? undefined},
{query: {placeholderData: keepPreviousData, enabled: role === UserRole.DEVELOPER}}, {query: {placeholderData: keepPreviousData, enabled: role === UserRole.DEVELOPER}},
); );
// RequireAuth 와 같은 이유 — 복구가 끝나기 전에 판단하면 새로고침마다 리다이렉트가 한 번 번쩍인다. // RequireAuth 와 같은 이유 — 복구가 끝나기 전에 판단하면 새로고침마다 리다이렉트가 한 번 번쩍인다.
const handleDelete = async (row: OpsSiteData) => {
const published = row.status === SiteStatus.PUBLISHED;
const message = published
? `'${row.name}' 사업장을 삭제할까요?\n발행된 사이트를 먼저 내리고 삭제합니다.`
: `'${row.name}' 사업장을 삭제할까요?`;
if (!window.confirm(message)) return;
setBusyId(row.place_id);
try {
if (published) {
const down = await changeStatus(row.place_id, {action: PublishAction.UNPUBLISH});
if (!down.result?.success) {
notifyApiError({data: down}, '사이트를 내리지 못해 삭제하지 않았습니다.');
return;
}
}
const res = await deletePlace(row.place_id);
if (!res.result?.success) {
notifyApiError({data: res}, '사업장을 삭제하지 못했습니다.');
return;
}
notify.success(`'${row.name}' 을(를) 삭제했습니다.`);
await refetch();
} catch (deleteError) {
notifyApiError(deleteError, '사업장을 삭제하지 못했습니다.');
} finally {
setBusyId(null);
}
};
if (isRestoring) return null; if (isRestoring) return null;
if (role !== UserRole.DEVELOPER) return <Navigate to="/sites" replace />; if (role !== UserRole.DEVELOPER) return <Navigate to="/sites" replace />;
@ -56,7 +91,55 @@ export function OpsSitesPage() {
return ( return (
<AppShell> <AppShell>
<PageContainer title="사이트관리" description="전 계정 사이트 발행 현황입니다. 회사 스코프 없이 전부 보입니다."> <PageContainer
title="사이트관리"
description="전 계정 사이트 발행 현황입니다. 회사 스코프 없이 전부 보입니다."
actions={
<div className="relative w-64">
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
value={search}
onChange={(event) => {
setPage(1);
setSearch(event.target.value);
}}
placeholder="상호 · 이메일 · 이름 · 아이디 검색"
aria-label="상호 · 이메일 · 이름 · 아이디 검색"
className="h-9 pr-8 pl-8"
/>
{search && (
<button
type="button"
aria-label="검색어 지우기"
onClick={() => setSearch('')}
className="absolute top-1/2 right-2 -translate-y-1/2 cursor-pointer text-muted-foreground transition-colors hover:text-foreground"
>
<X className="size-3.5" />
</button>
)}
</div>
}
>
<div className="mb-3 flex flex-wrap gap-1.5">
{[null, ...Object.keys(CATEGORY_LABEL).map(Number)].map((value) => (
<button
key={value ?? 'all'}
type="button"
onClick={() => {
setPage(1);
setCategory(value);
}}
className={cn(
'h-8 cursor-pointer rounded-md border px-3 text-xs font-medium transition-colors',
category === value ? 'border-foreground bg-foreground text-background' : 'border-border hover:bg-muted',
)}
>
{value === null ? '전체' : CATEGORY_LABEL[value]}
</button>
))}
</div>
{isLoading && ( {isLoading && (
<div className="flex items-center justify-center py-20 text-muted-foreground"> <div className="flex items-center justify-center py-20 text-muted-foreground">
<Loader2 className="size-5 animate-spin" /> <Loader2 className="size-5 animate-spin" />
@ -95,21 +178,47 @@ export function OpsSitesPage() {
</p> </p>
</div> </div>
{url ? ( <div className="flex shrink-0 items-center gap-1.5">
<a <Link
href={url} to={`/builder?placeId=${row.place_id}`}
target="_blank" className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2.5 text-xs font-medium transition-colors hover:bg-muted"
rel="noopener noreferrer"
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md border border-border px-2.5 text-xs font-medium transition-colors hover:bg-muted"
> >
<ExternalLink className="size-3.5" /> <Pencil className="size-3.5" />
열기 빌더
</a> </Link>
) : ( <Link
<span className="shrink-0 text-xs text-muted-foreground"> to={`/blog?placeId=${row.place_id}`}
{row.domain ? `주소 예약됨 · ${row.domain}` : '주소 미정'} className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2.5 text-xs font-medium transition-colors hover:bg-muted"
</span> >
)} <Newspaper className="size-3.5" />
미니블로그
</Link>
{url ? (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md border border-border px-2.5 text-xs font-medium transition-colors hover:bg-muted"
>
<ExternalLink className="size-3.5" />
열기
</a>
) : (
<span className="shrink-0 text-xs text-muted-foreground">
{row.domain ? `주소 예약됨 · ${row.domain}` : '주소 미정'}
</span>
)}
<Button
size="icon"
variant="ghost"
aria-label={`${row.name} 삭제`}
disabled={busyId === row.place_id}
onClick={() => handleDelete(row)}
className="text-destructive"
>
{busyId === row.place_id ? <Loader2 className="animate-spin" /> : <Trash2 />}
</Button>
</div>
</li> </li>
); );
})} })}

View File

@ -6,12 +6,10 @@ import {useMemo, useState} from 'react';
import {Link, useNavigate} from 'react-router'; import {Link, useNavigate} from 'react-router';
import { import {
Building2, Building2,
CalendarClock,
Coffee, Coffee,
ExternalLink, ExternalLink,
Loader2, Loader2,
MoreHorizontal, MoreHorizontal,
Newspaper,
Pencil, Pencil,
Plus, Plus,
Search, Search,
@ -394,32 +392,6 @@ export function SitesPage() {
<Pencil className="size-3.5" /> <Pencil className="size-3.5" />
디자인·컨텐츠 관리 디자인·컨텐츠 관리
</button> </button>
<button
type="button"
onClick={() => {
setMenuId(null);
navigate(`/blog?placeId=${row.place_id}`);
}}
className="flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left text-xs transition-colors hover:bg-muted"
>
<Newspaper className="size-3.5" />
미니블로그 관리
</button>
<button
type="button"
onClick={() => {
setMenuId(null);
// 예약 요청은 DB 에 안 남는다 — 메일로만 가고 우리 쪽엔 로그 한 줄만 남는다.
notify.info(
'예약 요청은 메일로만 전달됩니다',
'연락처를 따로 저장하지 않기로 했습니다(2026-09-16). 목록이 필요하면 먼저 이 방침을 바꿔야 합니다.',
);
}}
className="flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left text-xs transition-colors hover:bg-muted"
>
<CalendarClock className="size-3.5" />
예약요청 관리
</button>
<div className="my-1 border-t border-border" /> <div className="my-1 border-t border-border" />
<button <button
type="button" type="button"