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
|
import os
|
||||||
|
|
||||||
os.environ.setdefault("APP_ENV", "test")
|
os.environ.setdefault("APP_ENV", "test")
|
||||||
|
os.environ.setdefault("THUMBNAIL_CAPTURE", "0")
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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="전 계정 목록(개발자 전용)")
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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,16 +173,20 @@ 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
|
||||||
|
|
||||||
|
fetched = None
|
||||||
|
if capture_enabled():
|
||||||
|
fetched = await _capture(f"{site_payload.publish_origin()}/s/{slug}")
|
||||||
|
|
||||||
|
if fetched is None:
|
||||||
row = site_payload.primary_media(snapshot)
|
row = site_payload.primary_media(snapshot)
|
||||||
url = str((row or {}).get("url") or "").strip()
|
url = str((row or {}).get("url") or "").strip()
|
||||||
if not url:
|
if not url:
|
||||||
LOG.w(f"[thumbnail] 대표 사진이 없다 — 썸네일 없이 발행한다: {slug}")
|
LOG.w(f"[thumbnail] 대표 사진이 없다 — 썸네일 없이 발행한다: {slug}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
fetched = await _fetch(url)
|
fetched = await _fetch(url)
|
||||||
if fetched is None:
|
if fetched is None:
|
||||||
return None
|
return None
|
||||||
|
|||||||
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):
|
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
|
||||||
|
|||||||
@ -6,6 +6,14 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export type ListSitesParams = {
|
export type ListSitesParams = {
|
||||||
|
/**
|
||||||
|
* 상호·소유자 이메일·이름·로그인 아이디 부분 일치
|
||||||
|
*/
|
||||||
|
search?: string | null;
|
||||||
|
/**
|
||||||
|
* PlaceCategory
|
||||||
|
*/
|
||||||
|
category?: number | null;
|
||||||
/**
|
/**
|
||||||
* @minimum 1
|
* @minimum 1
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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,6 +178,21 @@ export function OpsSitesPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<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 ? (
|
{url ? (
|
||||||
<a
|
<a
|
||||||
href={url}
|
href={url}
|
||||||
@ -110,6 +208,17 @@ export function OpsSitesPage() {
|
|||||||
{row.domain ? `주소 예약됨 · ${row.domain}` : '주소 미정'}
|
{row.domain ? `주소 예약됨 · ${row.domain}` : '주소 미정'}
|
||||||
</span>
|
</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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user