[feat] solution/backend,frontend: 개발자 전용 사이트관리·유저관리 — 조회 전용 경량 화면

admin 앱을 새 메뉴로 키우려면 새 도메인이 필요해서, solution 앱에 DEVELOPER 게이트 하나로 얹었다.
메뉴 문자열은 사장님 번들에도 실리지만 데이터 접근은 백엔드 게이트가 막는다.

- router/v1/ops: GET /v1/ops/sites · /v1/ops/users (RequireDeveloper)
- services/ops_service.py, crud/site_crud.list_all_sites, crud/user_crud.list_users
- frontend: OpsSitesPage · OpsUsersPage, AppShell 에 role===DEVELOPER 일 때만 두 줄
- api/generated: orval 코드젠 결과

프론트 tsc·eslint 통과, openapi 코드젠 성공. DB 조회는 미검증(DEVLOG 2026-09-23)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mina Choi 2026-09-28 15:53:02 +09:00
parent 79962b93e2
commit 46b74b310b
37 changed files with 1237 additions and 6 deletions

View File

@ -1,5 +1,26 @@
# 개발 일지 # 개발 일지
## 2026-09-23 — 개발자 전용 사이트관리·유저관리를 solution 앱에 경량으로
admin/frontend(:9801)를 새 메뉴로 키우려면 새 도메인이 필요하고 아직 그럴 기능도 안
갖춰졌다(대표 지시) — 그래서 대신 solution 앱(:9800)에 얹었다. `UserRole.DEVELOPER` 게이트
하나로, 회사 스코프를 걷어낸(2026-09-08, DECISIONS.md) 전 계정 사이트·유저 목록(읽기 전용)을 본다.
- **백엔드**: `router/v1/ops/ops.py`(`GET /v1/ops/sites`, `GET /v1/ops/users`, 전부
`RequireDeveloper`) + `services/ops_service.py` + `crud/site_crud.py:list_all_sites` /
`crud/user_crud.py:list_users`. 유저 목록은 USER/OWNER 만 — 개발자 계정은 여기서도 뺀다
(`UserRole` 주석 원칙을 내부 화면에도 지킨다).
- **프론트**: `pages/OpsSitesPage.tsx` · `OpsUsersPage.tsx`(`/ops/sites` · `/ops/users`).
`AppShell.tsx` 의 기본 nav(`OWNER_NAV`)에 `role===DEVELOPER` 일 때만 두 줄을 더 붙인다.
★ 이 문자열은 role 과 무관하게 사장님에게 나가는 번들에도 실린다(런타임 조건부 렌더일 뿐,
빌드 타임에 갈라지지 않는다) — AppShell 주석의 "메뉴가 섞이면 새어 나간다"가 그대로 적용된다.
실제 데이터 접근은 백엔드 게이트가 막으므로 새는 것은 경로 이름 정도다.
- 액션(재발행·상태 토글·강제 로그아웃 등)은 다음 단계 — 이번엔 조회만.
**검증** — DB 접속이 안 되는 환경이라 pytest 는 못 돌렸다: `app.openapi()` 로 라우터 임포트·
스키마 생성 확인, `scripts/export_openapi.py` → `orval` 코드젠 성공, 프론트 `tsc --noEmit` ·
`eslint src` 통과. 실제 DB 조회 동작은 미검증 — docker compose 로 띄운 뒤 확인 필요.
## 2026-09-22 — 카톡 5초 벽을 콜백으로 넘는다 ## 2026-09-22 — 카톡 5초 벽을 콜백으로 넘는다
실제 카톡에서 "시설 편의에서 바비큐 이용 문구 빼줘" 가 **"확인하는 데 시간이 조금 걸리네요"** 실제 카톡에서 "시설 편의에서 바비큐 이용 문구 빼줘" 가 **"확인하는 데 시간이 조금 걸리네요"**

View File

@ -5,7 +5,7 @@ from sqlalchemy import and_, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import place_photos, places, site_publish_logs, site_versions, sites from common.database.model.models import place_photos, places, site_publish_logs, site_versions, sites, users
from common.enums import BuildStatus, ErrorType, MediaStatus, SiteStatus from common.enums import BuildStatus, ErrorType, MediaStatus, SiteStatus
from common.logger import LOG from common.logger import LOG
from common.utils.gtime import GTime from common.utils.gtime import GTime
@ -48,6 +48,12 @@ class ISiteCRUD(ABC):
"""(ErrorType, [(place, site, built_at, primary_photo_url)], 총건수).""" """(ErrorType, [(place, site, built_at, primary_photo_url)], 총건수)."""
pass pass
@abstractmethod
async def list_all_sites(self, cdb: AsyncSession, skip, limit) -> Tuple[ErrorType, list, int]:
"""전 계정 사이트 목록(회사 스코프 없음) — 내부 운영(DEVELOPER) 전용.
(ErrorType, [(place, site, built_at, primary_photo_url, owner_login_id, owner_email, owner_name)], 총건수)."""
pass
@abstractmethod @abstractmethod
async def taken_domains(self, cdb: AsyncSession, domains: list) -> Tuple[ErrorType, set]: async def taken_domains(self, cdb: AsyncSession, domains: list) -> Tuple[ErrorType, set]:
pass pass
@ -157,6 +163,35 @@ 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]:
"""list_owner_sites 와 같은 조인이되 owner_user_id 필터가 없다 — 소유자 계정 정보를 같이 얹는다.
(ErrorType, [(place, site, built_at, primary_photo_url, owner_login_id, owner_email, owner_name)], 총건수)."""
try:
where = places.deleted == False # noqa: E712
cnt_err, cnt_rows = await DB_SESSION_MNG.execute(cdb, select(func.count()).select_from(places).where(where))
if cnt_err != ErrorType.SUCCESS:
return cnt_err, [], 0
total = int(cnt_rows[0] or 0) if cnt_rows else 0
query = (
select(places, sites, site_versions.built_at, _primary_photo_subquery(), users.id, users.email, users.name)
.join(users, users.user_id == places.owner_user_id)
.outerjoin(sites, and_(sites.place_id == places.place_id, sites.deleted == False)) # noqa: E712
.outerjoin(site_versions, site_versions.site_version_id == sites.current_version_id)
.where(where)
.order_by(places.created_at.desc())
.offset(skip)
.limit(limit)
)
list_err, rows = await DB_SESSION_MNG.execute(cdb, query)
if list_err != ErrorType.SUCCESS:
return list_err, [], 0
return ErrorType.SUCCESS, list(rows), total
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, [], 0
async def taken_domains(self, cdb: AsyncSession, domains: list) -> Tuple[ErrorType, set]: async def taken_domains(self, cdb: AsyncSession, domains: list) -> Tuple[ErrorType, set]:
"""후보 주소들 중 이미 쓰이는 것만 추린다. 대안 제안이 후보마다 왕복하지 않게 한 번에 본다.""" """후보 주소들 중 이미 쓰이는 것만 추린다. 대안 제안이 후보마다 왕복하지 않게 한 번에 본다."""
try: try:

View File

@ -1,16 +1,27 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Tuple from typing import Tuple
from sqlalchemy import select, func, update from sqlalchemy import and_, select, func, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import users from common.database.model.models import places, users
from common.enums import ErrorType from common.enums import ErrorType
from common.logger import LOG from common.logger import LOG
from common.utils.gtime import GTime from common.utils.gtime import GTime
def _place_count_subquery():
"""계정 하나가 가진 사업장 수 — users 에 상관 서브쿼리로 얹는다(N+1 회피)."""
return (
select(func.count())
.select_from(places)
.where(places.owner_user_id == users.user_id, places.deleted == False) # noqa: E712
.correlate(users)
.scalar_subquery()
)
# CRUD 는 인터페이스(I*) 와 구현(*) 으로 분리한다. # CRUD 는 인터페이스(I*) 와 구현(*) 으로 분리한다.
# - service 는 인터페이스 타입에 의존하고 Depends 로 구현을 주입받는다 (테스트/교체 용이). # - service 는 인터페이스 타입에 의존하고 Depends 로 구현을 주입받는다 (테스트/교체 용이).
# - 모든 메서드는 (session, ...) 을 받는다. session 은 람다 호출 시 매니저가 넘겨준다. # - 모든 메서드는 (session, ...) 을 받는다. session 은 람다 호출 시 매니저가 넘겨준다.
@ -47,6 +58,13 @@ class IUserCRUD(ABC):
async def update_user(self, cdb: AsyncSession, user_id, data: dict) -> ErrorType: async def update_user(self, cdb: AsyncSession, user_id, data: dict) -> ErrorType:
pass pass
@abstractmethod
async def list_users(
self, cdb: AsyncSession, roles: list, skip: int, limit: int, search: str | None = None
) -> Tuple[ErrorType, list, int]:
"""내부 운영(DEVELOPER) 전용 전체 계정 목록. (ErrorType, [(user, place_count)], 총건수)."""
pass
class UserCRUD(IUserCRUD): class UserCRUD(IUserCRUD):
async def get_user_by_login_id(self, cdb: AsyncSession, login_id: str) -> Tuple[ErrorType, users]: async def get_user_by_login_id(self, cdb: AsyncSession, login_id: str) -> Tuple[ErrorType, users]:
@ -151,3 +169,34 @@ class UserCRUD(IUserCRUD):
except Exception as ex: except Exception as ex:
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED return ErrorType.DB_RUN_FAILED
async def list_users(
self, cdb: AsyncSession, roles: list, skip: int, limit: int, search: str | None = None
) -> Tuple[ErrorType, list, int]:
"""role 이 roles 안에 있는 계정만 본다 — 개발자 계정은 호출측이 roles 에서 뺀다
(UserRole 주석: "개발자 계정은 고객사에 존재를 노출하지 않는다" 원칙을 내부 화면에서도 지킨다)."""
try:
where = and_(users.deleted == False, users.role.in_(roles)) # noqa: E712
if search:
like = f"%{search.strip()}%"
where = and_(where, (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(users).where(where))
if cnt_err != ErrorType.SUCCESS:
return cnt_err, [], 0
total = int(cnt_rows[0] or 0) if cnt_rows else 0
query = (
select(users, _place_count_subquery())
.where(where)
.order_by(users.created_at.desc())
.offset(skip)
.limit(limit)
)
list_err, rows = await DB_SESSION_MNG.execute(cdb, query)
if list_err != ErrorType.SUCCESS:
return list_err, [], 0
return ErrorType.SUCCESS, list(rows), total
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, [], 0

View File

@ -25,6 +25,7 @@ import router.v1.site.booking_request
import router.v1.site.post import router.v1.site.post
import router.v1.site.review import router.v1.site.review
import router.v1.local.local import router.v1.local.local
import router.v1.ops.ops
import router.v1.social.social import router.v1.social.social
import router.v1.social.oauth import router.v1.social.oauth
import router.v1.agent.kakao import router.v1.agent.kakao
@ -138,6 +139,7 @@ app.include_router(router.v1.site.post.owner_router)
app.include_router(router.v1.site.review.router) app.include_router(router.v1.site.review.router)
app.include_router(router.v1.local.local.router) app.include_router(router.v1.local.local.router)
app.include_router(router.v1.local.local.weather_router) app.include_router(router.v1.local.local.weather_router)
app.include_router(router.v1.ops.ops.router)
app.include_router(router.v1.social.social.router) app.include_router(router.v1.social.social.router)
app.include_router(router.v1.social.oauth.router) app.include_router(router.v1.social.oauth.router)

View File

@ -0,0 +1,27 @@
from fastapi import APIRouter, Depends, Query
from common.models.gmodel import PageParams
from router.v1.validator.dependencies import RemoveNoneResponse, RequireDeveloper
from services.ops_service import OpsService
from .protocol import Res_OpsSites, Res_OpsUsers
# 내부 운영(개발자) 전용 조회 — 회사 스코프를 걷어낸 전 계정 사이트·유저 목록.
# admin/frontend(:9801)를 새 도메인으로 키우는 대신 solution 앱(:9800)에 경량으로 얹은 것이다
# (2026-09-23 기획). RequireDeveloper 가 유일한 문이다 — OWNER(사장님)는 관리할 하위 계정이
# 없어(2026-09-08 회사/테넌트 걷어냄) 이 화면을 볼 이유가 없다.
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))
@router.get(path="/users", response_model=Res_OpsUsers, summary="전 계정 목록(개발자 전용)")
async def list_users(
service: OpsService = Depends(),
pg: PageParams = Depends(),
search: str | None = Query(None, description="이메일·이름·로그인 아이디 부분 일치"),
_user=Depends(RequireDeveloper),
):
return RemoveNoneResponse(await service.list_users(pg, search))

View File

@ -0,0 +1,53 @@
import uuid
from datetime import datetime
from typing import Optional
from common.enums import AuthProvider, PlaceCategory, PlaceStatus, SiteStatus, UserRole, UserStatus
from common.models.gmodel import Res_PageProtocol, WebPacketProtocol
class OpsSiteData(WebPacketProtocol):
"""전 계정 사이트 목록의 한 줄 — 사업장(place) + 사이트(site) + 소유자.
★ MySiteData(내 사이트 목록)와 같은 모양에 소유자 식별자만 얹었다 — 화면이 다를 뿐 값의 뜻은 같다."""
place_id: uuid.UUID
name: str
category: PlaceCategory
place_status: PlaceStatus
created_at: Optional[datetime] = None
site_id: Optional[uuid.UUID] = None
status: Optional[SiteStatus] = None
domain: Optional[str] = None
published_at: Optional[datetime] = None
thumbnail_url: Optional[str] = None
needs_rebuild: bool = False
owner_login_id: str
owner_email: Optional[str] = None
owner_name: Optional[str] = None
class Res_OpsSites(Res_PageProtocol):
sites: list[OpsSiteData] = []
class OpsUserData(WebPacketProtocol):
"""전 계정 목록의 한 줄. ★ role 은 항상 USER/OWNER 다 — 개발자 계정은 서비스가 걸러낸다."""
user_id: uuid.UUID
login_id: str
name: Optional[str] = None
email: Optional[str] = None
contact_number: Optional[str] = None
role: UserRole
status: UserStatus
provider: AuthProvider
created_at: Optional[datetime] = None
last_accessed_at: Optional[datetime] = None
place_count: int = 0
class Res_OpsUsers(Res_PageProtocol):
users: list[OpsUserData] = []

View File

@ -0,0 +1,89 @@
from fastapi import Depends
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import places, users
from common.enums import DBWRType, ErrorType, UserRole
from common.models.gmodel import PageParams
from crud.site_crud import ISiteCRUD, SiteCRUD
from crud.user_crud import IUserCRUD, UserCRUD
from router.v1.ops.protocol import OpsSiteData, OpsUserData, Res_OpsSites, Res_OpsUsers
class OpsService:
"""내부 운영(개발자) 전용 조회 — 회사 스코프 없는 전 계정 사이트·유저 목록.
admin/frontend 의 사업장 화면(사실 검증)과 역할이 다르다 — 여기는 발행 인프라 상태와
계정 상태만 본다. 게이트는 라우터가 RequireDeveloper 로 건다(여기서는 다시 확인하지 않는다)."""
def __init__(self, site_crud: ISiteCRUD = Depends(SiteCRUD), user_crud: IUserCRUD = Depends(UserCRUD)):
self.site_crud = site_crud
self.user_crud = user_crud
async def list_sites(self, pg: PageParams) -> 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),
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
res.sites = [
self._site_row(place, site, built_at, photo, login_id, email, name)
for place, site, built_at, photo, login_id, email, name in rows
]
res.total = total
return res
@staticmethod
def _site_row(place, site, built_at, primary_photo_url, owner_login_id, owner_email, owner_name) -> OpsSiteData:
# ★ 재빌드 판별은 site_service._my_site_row 와 같은 규칙이어야 한다 — 다르면 화면마다 다른 답을 한다.
changed = place.content_updated_at
ever_published = site is not None and getattr(site, "published_at", None) is not None
thumbnail_url = getattr(site, "thumbnail_url", None) or (primary_photo_url if ever_published else None)
return OpsSiteData(
place_id=place.place_id,
name=place.name,
category=place.category,
place_status=place.status,
created_at=place.created_at,
site_id=getattr(site, "site_id", None),
status=getattr(site, "status", None),
domain=getattr(site, "domain", None),
published_at=getattr(site, "published_at", None),
thumbnail_url=thumbnail_url,
needs_rebuild=bool(site is not None and changed and (built_at is None or changed > built_at)),
owner_login_id=owner_login_id,
owner_email=owner_email,
owner_name=owner_name,
)
async def list_users(self, pg: PageParams, search: str | None = None) -> Res_OpsUsers:
"""USER/OWNER 만 — 개발자 계정은 목록에서 뺀다(UserRole 주석: 고객사에 존재를 노출하지 않는다)."""
res = Res_OpsUsers(page=pg.page, size=pg.size)
roles = [UserRole.USER.value, UserRole.OWNER.value]
err_type, rows, total = await DB_SESSION_MNG.execute_lambda(
users.DBType(), DBWRType.DB_READ.value,
lambda s: self.user_crud.list_users(s, roles, pg.skip, pg.size, search),
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
res.users = [
OpsUserData(
user_id=user.user_id,
login_id=user.id,
name=user.name,
email=user.email,
contact_number=user.contact_number,
role=user.role,
status=user.status,
provider=user.provider,
created_at=user.created_at,
last_accessed_at=user.last_accessed_at,
place_count=place_count,
)
for user, place_count in rows
]
res.total = total
return res

View File

@ -74,7 +74,9 @@ export * from "./listMySitesParams";
export * from "./listPlacesParams"; export * from "./listPlacesParams";
export * from "./listReviewsParams"; export * from "./listReviewsParams";
export * from "./listShowcaseParams"; export * from "./listShowcaseParams";
export * from "./listSitesParams";
export * from "./listUpcomingPostsParams"; export * from "./listUpcomingPostsParams";
export * from "./listUsersParams";
export * from "./localContentData"; export * from "./localContentData";
export * from "./localContentDataBody"; export * from "./localContentDataBody";
export * from "./localContentDataCollectedAt"; export * from "./localContentDataCollectedAt";
@ -107,6 +109,21 @@ export * from "./mySiteDataStatus";
export * from "./mySiteDataTemplateId"; export * from "./mySiteDataTemplateId";
export * from "./mySiteDataThumbnailUrl"; export * from "./mySiteDataThumbnailUrl";
export * from "./oauthCallbackParams"; export * from "./oauthCallbackParams";
export * from "./opsSiteData";
export * from "./opsSiteDataCreatedAt";
export * from "./opsSiteDataDomain";
export * from "./opsSiteDataOwnerEmail";
export * from "./opsSiteDataOwnerName";
export * from "./opsSiteDataPublishedAt";
export * from "./opsSiteDataSiteId";
export * from "./opsSiteDataStatus";
export * from "./opsSiteDataThumbnailUrl";
export * from "./opsUserData";
export * from "./opsUserDataContactNumber";
export * from "./opsUserDataCreatedAt";
export * from "./opsUserDataEmail";
export * from "./opsUserDataLastAccessedAt";
export * from "./opsUserDataName";
export * from "./placeCandidate"; export * from "./placeCandidate";
export * from "./placeCandidateAddress"; export * from "./placeCandidateAddress";
export * from "./placeCandidateCategoryName"; export * from "./placeCandidateCategoryName";
@ -291,6 +308,10 @@ export * from "./resMyPosts";
export * from "./resMyPostsMsg"; export * from "./resMyPostsMsg";
export * from "./resMySites"; export * from "./resMySites";
export * from "./resMySitesMsg"; export * from "./resMySitesMsg";
export * from "./resOpsSites";
export * from "./resOpsSitesMsg";
export * from "./resOpsUsers";
export * from "./resOpsUsersMsg";
export * from "./resPlace"; export * from "./resPlace";
export * from "./resPlaceContentList"; export * from "./resPlaceContentList";
export * from "./resPlaceContentListMsg"; export * from "./resPlaceContentListMsg";
@ -381,6 +402,7 @@ export * from "./sourceType";
export * from "./testPost"; export * from "./testPost";
export * from "./unitData"; export * from "./unitData";
export * from "./userRole"; export * from "./userRole";
export * from "./userStatus";
export * from "./validationError"; export * from "./validationError";
export * from "./validationErrorCtx"; export * from "./validationErrorCtx";
export * from "./validationErrorLocItem"; export * from "./validationErrorLocItem";

View File

@ -0,0 +1,18 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type ListSitesParams = {
/**
* @minimum 1
*/
page?: number;
/**
* @minimum 1
* @maximum 100
*/
size?: number;
};

View File

@ -0,0 +1,22 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type ListUsersParams = {
/**
* 이메일·이름·로그인 아이디 부분 일치
*/
search?: string | null;
/**
* @minimum 1
*/
page?: number;
/**
* @minimum 1
* @maximum 100
*/
size?: number;
};

View File

@ -0,0 +1,38 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
import type { PlaceCategory } from "./placeCategory";
import type { PlaceStatus } from "./placeStatus";
import type { OpsSiteDataCreatedAt } from "./opsSiteDataCreatedAt";
import type { OpsSiteDataSiteId } from "./opsSiteDataSiteId";
import type { OpsSiteDataStatus } from "./opsSiteDataStatus";
import type { OpsSiteDataDomain } from "./opsSiteDataDomain";
import type { OpsSiteDataPublishedAt } from "./opsSiteDataPublishedAt";
import type { OpsSiteDataThumbnailUrl } from "./opsSiteDataThumbnailUrl";
import type { OpsSiteDataOwnerEmail } from "./opsSiteDataOwnerEmail";
import type { OpsSiteDataOwnerName } from "./opsSiteDataOwnerName";
/**
* 전 계정 사이트 목록의 한 줄 — 사업장(place) + 사이트(site) + 소유자.
★ MySiteData(내 사이트 목록)와 같은 모양에 소유자 식별자만 얹었다 — 화면이 다를 뿐 값의 뜻은 같다.
*/
export interface OpsSiteData {
place_id: string;
name: string;
category: PlaceCategory;
place_status: PlaceStatus;
created_at?: OpsSiteDataCreatedAt;
site_id?: OpsSiteDataSiteId;
status?: OpsSiteDataStatus;
domain?: OpsSiteDataDomain;
published_at?: OpsSiteDataPublishedAt;
thumbnail_url?: OpsSiteDataThumbnailUrl;
needs_rebuild?: boolean;
owner_login_id: string;
owner_email?: OpsSiteDataOwnerEmail;
owner_name?: OpsSiteDataOwnerName;
}

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type OpsSiteDataCreatedAt = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type OpsSiteDataDomain = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type OpsSiteDataOwnerEmail = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type OpsSiteDataOwnerName = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type OpsSiteDataPublishedAt = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type OpsSiteDataSiteId = string | null;

View File

@ -0,0 +1,9 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
import type { SiteStatus } from "./siteStatus";
export type OpsSiteDataStatus = SiteStatus | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type OpsSiteDataThumbnailUrl = string | null;

View File

@ -0,0 +1,31 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
import type { OpsUserDataName } from "./opsUserDataName";
import type { OpsUserDataEmail } from "./opsUserDataEmail";
import type { OpsUserDataContactNumber } from "./opsUserDataContactNumber";
import type { UserRole } from "./userRole";
import type { UserStatus } from "./userStatus";
import type { AuthProvider } from "./authProvider";
import type { OpsUserDataCreatedAt } from "./opsUserDataCreatedAt";
import type { OpsUserDataLastAccessedAt } from "./opsUserDataLastAccessedAt";
/**
* 전 계정 목록의 한 줄. ★ role 은 항상 USER/OWNER 다 — 개발자 계정은 서비스가 걸러낸다.
*/
export interface OpsUserData {
user_id: string;
login_id: string;
name?: OpsUserDataName;
email?: OpsUserDataEmail;
contact_number?: OpsUserDataContactNumber;
role: UserRole;
status: UserStatus;
provider: AuthProvider;
created_at?: OpsUserDataCreatedAt;
last_accessed_at?: OpsUserDataLastAccessedAt;
place_count?: number;
}

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type OpsUserDataContactNumber = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type OpsUserDataCreatedAt = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type OpsUserDataEmail = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type OpsUserDataLastAccessedAt = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type OpsUserDataName = string | null;

View File

@ -0,0 +1,18 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
import type { ErrorInfo } from "./errorInfo";
import type { ResOpsSitesMsg } from "./resOpsSitesMsg";
import type { OpsSiteData } from "./opsSiteData";
export interface ResOpsSites {
result?: ErrorInfo;
msg?: ResOpsSitesMsg;
total?: number;
page?: number;
size?: number;
sites?: OpsSiteData[];
}

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type ResOpsSitesMsg = string | null;

View File

@ -0,0 +1,18 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
import type { ErrorInfo } from "./errorInfo";
import type { ResOpsUsersMsg } from "./resOpsUsersMsg";
import type { OpsUserData } from "./opsUserData";
export interface ResOpsUsers {
result?: ErrorInfo;
msg?: ResOpsUsersMsg;
total?: number;
page?: number;
size?: number;
users?: OpsUserData[];
}

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type ResOpsUsersMsg = string | null;

View File

@ -0,0 +1,17 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
/**
* users.status 코드값.
*/
export type UserStatus = (typeof UserStatus)[keyof typeof UserStatus];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const UserStatus = {
ACTIVE: 1,
INACTIVE: 2,
} as const;

View File

@ -0,0 +1,314 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
import { useQuery } from "@tanstack/react-query";
import type {
DataTag,
DefinedInitialDataOptions,
DefinedUseQueryResult,
QueryClient,
QueryFunction,
QueryKey,
UndefinedInitialDataOptions,
UseQueryOptions,
UseQueryResult,
} from "@tanstack/react-query";
import type {
HTTPValidationError,
ListSitesParams,
ListUsersParams,
ResOpsSites,
ResOpsUsers,
} from ".././model";
import { customFetch } from "../../mutator/custom-fetch";
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
/**
* @summary 전 계정 사이트 목록(개발자 전용)
*/
export const listSites = (
params?: ListSitesParams,
options?: SecondParameter<typeof customFetch>,
signal?: AbortSignal,
) => {
return customFetch<ResOpsSites>(
{ url: `/v1/ops/sites`, method: "GET", params, signal },
options,
);
};
export const getListSitesQueryKey = (params?: ListSitesParams) => {
return [`/v1/ops/sites`, ...(params ? [params] : [])] as const;
};
export const getListSitesQueryOptions = <
TData = Awaited<ReturnType<typeof listSites>>,
TError = HTTPValidationError,
>(
params?: ListSitesParams,
options?: {
query?: Partial<
UseQueryOptions<Awaited<ReturnType<typeof listSites>>, TError, TData>
>;
request?: SecondParameter<typeof customFetch>;
},
) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListSitesQueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof listSites>>> = ({
signal,
}) => listSites(params, requestOptions, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listSites>>,
TError,
TData
> & { queryKey: DataTag<QueryKey, TData, TError> };
};
export type ListSitesQueryResult = NonNullable<
Awaited<ReturnType<typeof listSites>>
>;
export type ListSitesQueryError = HTTPValidationError;
export function useListSites<
TData = Awaited<ReturnType<typeof listSites>>,
TError = HTTPValidationError,
>(
params: undefined | ListSitesParams,
options: {
query: Partial<
UseQueryOptions<Awaited<ReturnType<typeof listSites>>, TError, TData>
> &
Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof listSites>>,
TError,
Awaited<ReturnType<typeof listSites>>
>,
"initialData"
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): DefinedUseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useListSites<
TData = Awaited<ReturnType<typeof listSites>>,
TError = HTTPValidationError,
>(
params?: ListSitesParams,
options?: {
query?: Partial<
UseQueryOptions<Awaited<ReturnType<typeof listSites>>, TError, TData>
> &
Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof listSites>>,
TError,
Awaited<ReturnType<typeof listSites>>
>,
"initialData"
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useListSites<
TData = Awaited<ReturnType<typeof listSites>>,
TError = HTTPValidationError,
>(
params?: ListSitesParams,
options?: {
query?: Partial<
UseQueryOptions<Awaited<ReturnType<typeof listSites>>, TError, TData>
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
/**
* @summary 전 계정 사이트 목록(개발자 전용)
*/
export function useListSites<
TData = Awaited<ReturnType<typeof listSites>>,
TError = HTTPValidationError,
>(
params?: ListSitesParams,
options?: {
query?: Partial<
UseQueryOptions<Awaited<ReturnType<typeof listSites>>, TError, TData>
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
} {
const queryOptions = getListSitesQueryOptions(params, options);
const query = useQuery(queryOptions, queryClient) as UseQueryResult<
TData,
TError
> & { queryKey: DataTag<QueryKey, TData, TError> };
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* @summary 전 계정 목록(개발자 전용)
*/
export const listUsers = (
params?: ListUsersParams,
options?: SecondParameter<typeof customFetch>,
signal?: AbortSignal,
) => {
return customFetch<ResOpsUsers>(
{ url: `/v1/ops/users`, method: "GET", params, signal },
options,
);
};
export const getListUsersQueryKey = (params?: ListUsersParams) => {
return [`/v1/ops/users`, ...(params ? [params] : [])] as const;
};
export const getListUsersQueryOptions = <
TData = Awaited<ReturnType<typeof listUsers>>,
TError = HTTPValidationError,
>(
params?: ListUsersParams,
options?: {
query?: Partial<
UseQueryOptions<Awaited<ReturnType<typeof listUsers>>, TError, TData>
>;
request?: SecondParameter<typeof customFetch>;
},
) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListUsersQueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof listUsers>>> = ({
signal,
}) => listUsers(params, requestOptions, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listUsers>>,
TError,
TData
> & { queryKey: DataTag<QueryKey, TData, TError> };
};
export type ListUsersQueryResult = NonNullable<
Awaited<ReturnType<typeof listUsers>>
>;
export type ListUsersQueryError = HTTPValidationError;
export function useListUsers<
TData = Awaited<ReturnType<typeof listUsers>>,
TError = HTTPValidationError,
>(
params: undefined | ListUsersParams,
options: {
query: Partial<
UseQueryOptions<Awaited<ReturnType<typeof listUsers>>, TError, TData>
> &
Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof listUsers>>,
TError,
Awaited<ReturnType<typeof listUsers>>
>,
"initialData"
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): DefinedUseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useListUsers<
TData = Awaited<ReturnType<typeof listUsers>>,
TError = HTTPValidationError,
>(
params?: ListUsersParams,
options?: {
query?: Partial<
UseQueryOptions<Awaited<ReturnType<typeof listUsers>>, TError, TData>
> &
Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof listUsers>>,
TError,
Awaited<ReturnType<typeof listUsers>>
>,
"initialData"
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useListUsers<
TData = Awaited<ReturnType<typeof listUsers>>,
TError = HTTPValidationError,
>(
params?: ListUsersParams,
options?: {
query?: Partial<
UseQueryOptions<Awaited<ReturnType<typeof listUsers>>, TError, TData>
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
/**
* @summary 전 계정 목록(개발자 전용)
*/
export function useListUsers<
TData = Awaited<ReturnType<typeof listUsers>>,
TError = HTTPValidationError,
>(
params?: ListUsersParams,
options?: {
query?: Partial<
UseQueryOptions<Awaited<ReturnType<typeof listUsers>>, TError, TData>
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
} {
const queryOptions = getListUsersQueryOptions(params, options);
const query = useQuery(queryOptions, queryClient) as UseQueryResult<
TData,
TError
> & { queryKey: DataTag<QueryKey, TData, TError> };
query.queryKey = queryOptions.queryKey;
return query;
}

View File

@ -19,6 +19,7 @@ export * from './generated/job/job';
export * from './generated/site/site'; export * from './generated/site/site';
export * from './generated/media/media'; export * from './generated/media/media';
export * from './generated/faq/faq'; export * from './generated/faq/faq';
export * from './generated/ops/ops';
export * from './generated/model'; export * from './generated/model';
// 잡 폴링(수집·비전·생성·빌드 공용). 생성물이 아니라 그 위에 얹은 얇은 유틸이다. // 잡 폴링(수집·비전·생성·빌드 공용). 생성물이 아니라 그 위에 얹은 얇은 유틸이다.

View File

@ -1,6 +1,7 @@
import type {ComponentType, ReactNode} from 'react'; import type {ComponentType, ReactNode} from 'react';
import {Link, NavLink, useLocation, useNavigate} from 'react-router'; import {Link, NavLink, useLocation, useNavigate} from 'react-router';
import {LayoutGrid, LogIn, LogOut, Receipt, Search, Store, Wand2} from 'lucide-react'; import {Globe2, LayoutGrid, LogIn, LogOut, Receipt, Search, Store, Users, Wand2} from 'lucide-react';
import {UserRole} from '@/api';
import {cn} from '@/lib/utils'; import {cn} from '@/lib/utils';
import {userLabel, useAuthStore} from '@/stores/auth'; import {userLabel, useAuthStore} from '@/stores/auth';
@ -36,11 +37,23 @@ const OWNER_NAV: NavItem[] = [
{to: '/pricing', match: '/pricing', label: '요금', icon: Receipt}, {to: '/pricing', match: '/pricing', label: '요금', icon: Receipt},
]; ];
export function AppShell({children, nav = OWNER_NAV}: {children: ReactNode; nav?: NavItem[]}) { /**
* 개발자 전용 두 줄(사이트관리·유저관리, 2026-09-23 기획). admin/frontend 를 새 도메인으로
* 키우는 대신 solution 앱에 경량으로 얹은 것 — 그래서 위 주석의 "메뉴가 섞이면 새어 나간다"가
* 그대로 적용된다: 이 문자열은 role 과 무관하게 사장님에게 나가는 번들에도 실린다(런타임에만
* 숨긴다). 실제 데이터 접근은 백엔드 RequireDeveloper 가 막으므로 노출은 경로 이름 정도다.
*/
const DEVELOPER_NAV: NavItem[] = [
{to: '/ops/sites', match: '/ops/sites', label: '사이트관리', icon: Globe2},
{to: '/ops/users', match: '/ops/users', label: '유저관리', icon: Users},
];
export function AppShell({children, nav}: {children: ReactNode; nav?: NavItem[]}) {
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const signOut = useAuthStore((s) => s.signOut); const signOut = useAuthStore((s) => s.signOut);
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const items = nav ?? (user?.role === UserRole.DEVELOPER ? [...OWNER_NAV, ...DEVELOPER_NAV] : OWNER_NAV);
return ( return (
<div className="flex h-screen w-screen overflow-hidden bg-background text-foreground"> <div className="flex h-screen w-screen overflow-hidden bg-background text-foreground">
@ -52,7 +65,7 @@ export function AppShell({children, nav = OWNER_NAV}: {children: ReactNode; nav?
</Link> </Link>
<nav className="flex-1 space-y-0.5 px-2"> <nav className="flex-1 space-y-0.5 px-2">
{nav.map(({to, match, label, icon: Icon}) => ( {items.map(({to, match, label, icon: Icon}) => (
<NavLink <NavLink
key={to} key={to}
to={to} to={to}

View File

@ -0,0 +1,158 @@
import {useState} from 'react';
import {Navigate} from 'react-router';
import {keepPreviousData} from '@tanstack/react-query';
import {Building2, ChevronLeft, ChevronRight, ExternalLink, Loader2} from 'lucide-react';
import {PlaceCategory, publishUrlString, SiteStatus} from '@o2o/shared';
import {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 {PUBLISH_HOST} from '@/lib/site';
import {useAuthStore} from '@/stores/auth';
const CATEGORY_LABEL: Record<number, string> = {
[PlaceCategory.LODGING]: '숙박',
[PlaceCategory.CAFE]: '카페',
[PlaceCategory.RESTAURANT]: '음식점',
[PlaceCategory.CLINIC]: '피부과·성형외과',
};
const STATUS_BADGE: Record<number, {label: string; variant: 'success' | 'warning' | 'outline' | 'default'}> = {
[SiteStatus.DRAFT]: {label: '발행 전', variant: 'default'},
[SiteStatus.REVIEW]: {label: '검수 대기', variant: 'warning'},
[SiteStatus.PUBLISHED]: {label: '발행됨', variant: 'success'},
[SiteStatus.SUSPENDED]: {label: '중지', variant: 'outline'},
[SiteStatus.UNPUBLISHED]: {label: '내림', variant: 'outline'},
};
function publishedUrl(row: OpsSiteData): string | null {
if (row.status !== SiteStatus.PUBLISHED || !row.domain) return null;
return publishUrlString(row.domain.split('.')[0], PUBLISH_HOST);
}
function ownerLabel(row: OpsSiteData): string {
return row.owner_name || row.owner_email || row.owner_login_id;
}
/**
* 사이트관리 — 개발자(DEVELOPER) 전용. 전 계정 사이트를 회사 스코프 없이 본다.
*
* ★ admin/frontend 의 사업장 화면(fact 검증)과 역할이 다르다 — 여기는 "발행이 나가 있나 ·
* 다시 구워야 하나 · 누구 계정인가"만 본다(2026-09-23 기획).
*/
export function OpsSitesPage() {
const isRestoring = useAuthStore((s) => s.isRestoring);
const role = useAuthStore((s) => s.user?.role);
const [page, setPage] = useState(1);
const size = 20;
// enabled: role 이 확정되기 전엔 요청하지 않는다 — 어차피 백엔드가 403 으로 막지만,
// 사장님 화면에서 실패 토스트가 먼저 뜨는 것과 새로고침 때 잠깐의 오탐 리다이렉트를 막는다.
const {data, isLoading, isError, error} = useListSites(
{page, size},
{query: {placeholderData: keepPreviousData, enabled: role === UserRole.DEVELOPER}},
);
// RequireAuth 와 같은 이유 — 복구가 끝나기 전에 판단하면 새로고침마다 리다이렉트가 한 번 번쩍인다.
if (isRestoring) return null;
if (role !== UserRole.DEVELOPER) return <Navigate to="/sites" replace />;
const rows = data?.sites ?? [];
const total = data?.total ?? 0;
const lastPage = Math.max(1, Math.ceil(total / size));
return (
<AppShell>
<PageContainer title="사이트관리" description="전 계정 사이트 발행 현황입니다. 회사 스코프 없이 전부 보입니다.">
{isLoading && (
<div className="flex items-center justify-center py-20 text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
</div>
)}
{isError && (
<EmptyState
title="목록을 불러오지 못했습니다"
description={(error as Error)?.message ?? '잠시 후 다시 시도해 주세요.'}
/>
)}
{!isLoading && !isError && rows.length === 0 && (
<EmptyState icon={Building2} title="사이트가 없습니다" />
)}
{!isLoading && !isError && rows.length > 0 && (
<>
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-card">
{rows.map((row) => {
const badge = STATUS_BADGE[row.status ?? SiteStatus.DRAFT] ?? STATUS_BADGE[SiteStatus.DRAFT];
const url = publishedUrl(row);
return (
<li key={row.place_id} className="flex items-center justify-between gap-4 px-4 py-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="truncate text-sm font-semibold">{row.name}</p>
<Badge variant="outline">{CATEGORY_LABEL[row.category] ?? '기타'}</Badge>
<Badge variant={badge.variant}>{badge.label}</Badge>
{row.needs_rebuild && <Badge variant="warning">재발행 필요</Badge>}
</div>
<p className="mt-1 truncate text-xs text-muted-foreground">
{ownerLabel(row)}
{row.owner_email && row.owner_name ? ` · ${row.owner_email}` : ''}
</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"
>
<ExternalLink className="size-3.5" />
열기
</a>
) : (
<span className="shrink-0 text-xs text-muted-foreground">
{row.domain ? `주소 예약됨 · ${row.domain}` : '주소 미정'}
</span>
)}
</li>
);
})}
</ul>
<div className="mt-3 flex items-center justify-between text-xs text-muted-foreground">
<span>총 {total}건</span>
<div className="flex items-center gap-1.5">
<Button
size="icon"
variant="ghost"
aria-label="이전 페이지"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft />
</Button>
<span className="tabular-nums">
{page} / {lastPage}
</span>
<Button
size="icon"
variant="ghost"
aria-label="다음 페이지"
disabled={page >= lastPage}
onClick={() => setPage((p) => Math.min(lastPage, p + 1))}
>
<ChevronRight />
</Button>
</div>
</div>
</>
)}
</PageContainer>
</AppShell>
);
}
// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다).
export default OpsSitesPage;

View File

@ -0,0 +1,162 @@
import {useState} from 'react';
import {Navigate} from 'react-router';
import {keepPreviousData} from '@tanstack/react-query';
import {ChevronLeft, ChevronRight, Loader2, Search, UserRound, X} from 'lucide-react';
import {AuthProvider, useListUsers, UserRole, UserStatus} 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 {useAuthStore} from '@/stores/auth';
const ROLE_LABEL: Record<number, string> = {
[UserRole.USER]: '일반',
[UserRole.OWNER]: '최고관리자',
};
const STATUS_BADGE: Record<number, {label: string; variant: 'success' | 'outline'}> = {
[UserStatus.ACTIVE]: {label: '활성', variant: 'success'},
[UserStatus.INACTIVE]: {label: '비활성', variant: 'outline'},
};
const PROVIDER_LABEL: Record<number, string> = {
[AuthProvider.LOCAL]: '아이디',
[AuthProvider.GOOGLE]: '구글',
};
/**
* 유저관리 — 개발자(DEVELOPER) 전용. admin/frontend 엔 아예 없던 화면이다.
*
* ★ 개발자 계정은 목록에 없다 — 백엔드(services/ops_service.list_users)가 USER/OWNER 로만
* 걸러 준다(UserRole 주석: "개발자 계정은 고객사에 존재를 노출하지 않는다").
*/
export function OpsUsersPage() {
const isRestoring = useAuthStore((s) => s.isRestoring);
const role = useAuthStore((s) => s.user?.role);
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const size = 20;
const {data, isLoading, isError, error} = useListUsers(
{page, size, search: search || undefined},
{query: {placeholderData: keepPreviousData, enabled: role === UserRole.DEVELOPER}},
);
// RequireAuth 와 같은 이유 — 복구가 끝나기 전에 판단하면 새로고침마다 리다이렉트가 한 번 번쩍인다.
if (isRestoring) return null;
if (role !== UserRole.DEVELOPER) return <Navigate to="/sites" replace />;
const rows = data?.users ?? [];
const total = data?.total ?? 0;
const lastPage = Math.max(1, Math.ceil(total / size));
return (
<AppShell>
<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>
}
>
{isLoading && (
<div className="flex items-center justify-center py-20 text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
</div>
)}
{isError && (
<EmptyState
title="목록을 불러오지 못했습니다"
description={(error as Error)?.message ?? '잠시 후 다시 시도해 주세요.'}
/>
)}
{!isLoading && !isError && rows.length === 0 && (
<EmptyState icon={UserRound} title="계정이 없습니다" />
)}
{!isLoading && !isError && rows.length > 0 && (
<>
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-card">
{rows.map((row) => {
const statusBadge = STATUS_BADGE[row.status ?? UserStatus.ACTIVE] ?? STATUS_BADGE[UserStatus.ACTIVE];
return (
<li key={row.user_id} className="flex items-center justify-between gap-4 px-4 py-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="truncate text-sm font-semibold">{row.name || row.login_id}</p>
<Badge variant="outline">{ROLE_LABEL[row.role] ?? '알 수 없음'}</Badge>
<Badge variant={statusBadge.variant}>{statusBadge.label}</Badge>
<Badge variant="outline">{PROVIDER_LABEL[row.provider] ?? '알 수 없음'}</Badge>
</div>
<p className="mt-1 truncate text-xs text-muted-foreground">
{[row.email, row.contact_number].filter(Boolean).join(' · ') || row.login_id}
</p>
</div>
<span className="shrink-0 text-xs text-muted-foreground">
사이트 {row.place_count}개
</span>
</li>
);
})}
</ul>
<div className="mt-3 flex items-center justify-between text-xs text-muted-foreground">
<span>총 {total}건</span>
<div className="flex items-center gap-1.5">
<Button
size="icon"
variant="ghost"
aria-label="이전 페이지"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft />
</Button>
<span className="tabular-nums">
{page} / {lastPage}
</span>
<Button
size="icon"
variant="ghost"
aria-label="다음 페이지"
disabled={page >= lastPage}
onClick={() => setPage((p) => Math.min(lastPage, p + 1))}
>
<ChevronRight />
</Button>
</div>
</div>
</>
)}
</PageContainer>
</AppShell>
);
}
// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다).
export default OpsUsersPage;

View File

@ -29,6 +29,8 @@ export default [
// "내 사이트" 카드의 관리 메뉴에서 온다(?placeId= 로 어느 사이트인지 받는다) — // "내 사이트" 카드의 관리 메뉴에서 온다(?placeId= 로 어느 사이트인지 받는다) —
// 사장님 여럿이 사이트 여럿을 가질 수 있어 전역 메뉴 하나로는 못 고른다. // 사장님 여럿이 사이트 여럿을 가질 수 있어 전역 메뉴 하나로는 못 고른다.
route('blog', 'pages/BlogPostsPage.tsx'), route('blog', 'pages/BlogPostsPage.tsx'),
route('ops/sites', 'pages/OpsSitesPage.tsx'),
route('ops/users', 'pages/OpsUsersPage.tsx'),
]), ]),
/** /**