merge: feature/admin-full-access(관리자 전체 접근 · 사이트관리 검색 · 화면 썸네일) 을 main 에 병합
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
3eed628013
@ -2,6 +2,7 @@
|
||||
import os
|
||||
|
||||
os.environ.setdefault("APP_ENV", "test")
|
||||
os.environ.setdefault("THUMBNAIL_CAPTURE", "0")
|
||||
|
||||
import uuid
|
||||
|
||||
|
||||
@ -44,7 +44,7 @@ class ISiteCRUD(ABC):
|
||||
pass
|
||||
|
||||
@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) 전용."""
|
||||
pass
|
||||
|
||||
@ -145,12 +145,23 @@ class SiteCRUD(ISiteCRUD):
|
||||
LOG.e_no_callstack(ex)
|
||||
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 필터가 없다 — 소유자 계정 정보를 같이 얹는다."""
|
||||
try:
|
||||
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:
|
||||
return cnt_err, [], 0
|
||||
total = int(cnt_rows[0] or 0) if cnt_rows else 0
|
||||
|
||||
@ -10,8 +10,14 @@ router = APIRouter(prefix="/v1/ops", tags=["Ops"])
|
||||
|
||||
|
||||
@router.get(path="/sites", response_model=Res_OpsSites, summary="전 계정 사이트 목록(개발자 전용)")
|
||||
async def list_sites(service: OpsService = Depends(), pg: PageParams = Depends(), _user=Depends(RequireDeveloper)):
|
||||
return RemoveNoneResponse(await service.list_sites(pg))
|
||||
async def list_sites(
|
||||
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="전 계정 목록(개발자 전용)")
|
||||
|
||||
@ -1,15 +1,21 @@
|
||||
import asyncio
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
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.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
import bcrypt
|
||||
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 (
|
||||
DBWRType,
|
||||
ErrorType,
|
||||
EXCEPTION_ACCESS_TOKEN_EXPIRED,
|
||||
EXCEPTION_FORBIDDEN,
|
||||
EXCEPTION_INVALID_CLIENT_ACCESS,
|
||||
@ -96,8 +102,40 @@ def DecodeRefreshToken(jwt_token: str) -> UserInfo:
|
||||
|
||||
|
||||
# Depends 용 토큰 검증기
|
||||
async def IsValidAccessToken(credentials: HTTPAuthorizationCredentials = Depends(security)) -> UserInfo:
|
||||
return DecodeAccessToken(credentials.credentials)
|
||||
async def _place_owner(place_id: str) -> str | None:
|
||||
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:
|
||||
|
||||
@ -16,11 +16,11 @@ class OpsService:
|
||||
self.site_crud = site_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)
|
||||
err_type, rows, total = await DB_SESSION_MNG.execute_lambda(
|
||||
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:
|
||||
res.result.SetResult(err_type)
|
||||
|
||||
@ -30,6 +30,10 @@ MAX_BYTES = 5 * 1024 * 1024
|
||||
# 썸네일은 재발행마다 바뀔 수 있고 주소는 그대로다 — 길게 캐시하면 옛 그림이 계속 뜬다.
|
||||
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/<컨테이너>
|
||||
@ -121,6 +125,30 @@ async def _fetch(url: str) -> tuple[bytes, str, str] | None:
|
||||
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:
|
||||
name = blob_name(slug, ext)
|
||||
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:
|
||||
"""대표 사진을 썸네일로 올리고 공개 URL 을 돌려준다."""
|
||||
"""발행된 화면을 찍어 썸네일로 올리고 공개 URL 을 돌려준다. 못 찍으면 대표 사진을 쓴다."""
|
||||
if not is_configured():
|
||||
return 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 = None
|
||||
if capture_enabled():
|
||||
fetched = await _capture(f"{site_payload.publish_origin()}/s/{slug}")
|
||||
|
||||
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:
|
||||
return None
|
||||
data, content_type, ext = fetched
|
||||
|
||||
57
solution/backend/tests/test_admin_access.py
Normal file
57
solution/backend/tests/test_admin_access.py
Normal 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
|
||||
@ -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):
|
||||
from services import build_service
|
||||
from tests.test_build_publish import _approved_media, _place, _run, _verified_facts
|
||||
|
||||
@ -6,6 +6,14 @@
|
||||
*/
|
||||
|
||||
export type ListSitesParams = {
|
||||
/**
|
||||
* 상호·소유자 이메일·이름·로그인 아이디 부분 일치
|
||||
*/
|
||||
search?: string | null;
|
||||
/**
|
||||
* PlaceCategory
|
||||
*/
|
||||
category?: number | null;
|
||||
/**
|
||||
* @minimum 1
|
||||
*/
|
||||
|
||||
@ -1,13 +1,16 @@
|
||||
import {useState} from 'react';
|
||||
import {Navigate} from 'react-router';
|
||||
import {Link, Navigate} from 'react-router';
|
||||
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 {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 {Badge} from '@/components/ui/badge';
|
||||
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 {cn} from '@/lib/utils';
|
||||
import {useAuthStore} from '@/stores/auth';
|
||||
|
||||
const CATEGORY_LABEL: Record<number, string> = {
|
||||
@ -39,14 +42,46 @@ export function OpsSitesPage() {
|
||||
const isRestoring = useAuthStore((s) => s.isRestoring);
|
||||
const role = useAuthStore((s) => s.user?.role);
|
||||
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;
|
||||
// enabled: role 이 확정되기 전엔 요청하지 않는다 — 어차피 백엔드가 403 으로 막지만, 사장님 화면에서 실패 토스트가 먼저 뜨는 것과 새로고침 때 잠깐의 오탐 리다이렉트를 막는다.
|
||||
const {data, isLoading, isError, error} = useListSites(
|
||||
{page, size},
|
||||
const {data, isLoading, isError, error, refetch} = useListSites(
|
||||
{page, size, search: search || undefined, category: category ?? undefined},
|
||||
{query: {placeholderData: keepPreviousData, enabled: role === UserRole.DEVELOPER}},
|
||||
);
|
||||
|
||||
// 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 (role !== UserRole.DEVELOPER) return <Navigate to="/sites" replace />;
|
||||
|
||||
@ -56,7 +91,55 @@ export function OpsSitesPage() {
|
||||
|
||||
return (
|
||||
<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 && (
|
||||
<div className="flex items-center justify-center py-20 text-muted-foreground">
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
@ -95,21 +178,47 @@ export function OpsSitesPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{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"
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<Link
|
||||
to={`/builder?placeId=${row.place_id}`}
|
||||
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"
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
열기
|
||||
</a>
|
||||
) : (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{row.domain ? `주소 예약됨 · ${row.domain}` : '주소 미정'}
|
||||
</span>
|
||||
)}
|
||||
<Pencil className="size-3.5" />
|
||||
빌더
|
||||
</Link>
|
||||
<Link
|
||||
to={`/blog?placeId=${row.place_id}`}
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -6,12 +6,10 @@ import {useMemo, useState} from 'react';
|
||||
import {Link, useNavigate} from 'react-router';
|
||||
import {
|
||||
Building2,
|
||||
CalendarClock,
|
||||
Coffee,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
Newspaper,
|
||||
Pencil,
|
||||
Plus,
|
||||
Search,
|
||||
@ -394,32 +392,6 @@ export function SitesPage() {
|
||||
<Pencil className="size-3.5" />
|
||||
디자인·컨텐츠 관리
|
||||
</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" />
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user