[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초 벽을 콜백으로 넘는다
실제 카톡에서 "시설 편의에서 바비큐 이용 문구 빼줘" 가 **"확인하는 데 시간이 조금 걸리네요"**

View File

@ -5,7 +5,7 @@ from sqlalchemy import and_, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
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.logger import LOG
from common.utils.gtime import GTime
@ -48,6 +48,12 @@ class ISiteCRUD(ABC):
"""(ErrorType, [(place, site, built_at, primary_photo_url)], 총건수)."""
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
async def taken_domains(self, cdb: AsyncSession, domains: list) -> Tuple[ErrorType, set]:
pass
@ -157,6 +163,35 @@ 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]:
"""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]:
"""후보 주소들 중 이미 쓰이는 것만 추린다. 대안 제안이 후보마다 왕복하지 않게 한 번에 본다."""
try:

View File

@ -1,16 +1,27 @@
from abc import ABC, abstractmethod
from typing import Tuple
from sqlalchemy import select, func, update
from sqlalchemy import and_, select, func, update
from sqlalchemy.ext.asyncio import AsyncSession
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.logger import LOG
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*) 와 구현(*) 으로 분리한다.
# - service 는 인터페이스 타입에 의존하고 Depends 로 구현을 주입받는다 (테스트/교체 용이).
# - 모든 메서드는 (session, ...) 을 받는다. session 은 람다 호출 시 매니저가 넘겨준다.
@ -47,6 +58,13 @@ class IUserCRUD(ABC):
async def update_user(self, cdb: AsyncSession, user_id, data: dict) -> ErrorType:
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):
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:
LOG.e_no_callstack(ex)
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.review
import router.v1.local.local
import router.v1.ops.ops
import router.v1.social.social
import router.v1.social.oauth
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.local.local.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.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 "./listReviewsParams";
export * from "./listShowcaseParams";
export * from "./listSitesParams";
export * from "./listUpcomingPostsParams";
export * from "./listUsersParams";
export * from "./localContentData";
export * from "./localContentDataBody";
export * from "./localContentDataCollectedAt";
@ -107,6 +109,21 @@ export * from "./mySiteDataStatus";
export * from "./mySiteDataTemplateId";
export * from "./mySiteDataThumbnailUrl";
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 "./placeCandidateAddress";
export * from "./placeCandidateCategoryName";
@ -291,6 +308,10 @@ export * from "./resMyPosts";
export * from "./resMyPostsMsg";
export * from "./resMySites";
export * from "./resMySitesMsg";
export * from "./resOpsSites";
export * from "./resOpsSitesMsg";
export * from "./resOpsUsers";
export * from "./resOpsUsersMsg";
export * from "./resPlace";
export * from "./resPlaceContentList";
export * from "./resPlaceContentListMsg";
@ -381,6 +402,7 @@ export * from "./sourceType";
export * from "./testPost";
export * from "./unitData";
export * from "./userRole";
export * from "./userStatus";
export * from "./validationError";
export * from "./validationErrorCtx";
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/media/media';
export * from './generated/faq/faq';
export * from './generated/ops/ops';
export * from './generated/model';
// 잡 폴링(수집·비전·생성·빌드 공용). 생성물이 아니라 그 위에 얹은 얇은 유틸이다.

View File

@ -1,6 +1,7 @@
import type {ComponentType, ReactNode} from 'react';
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 {userLabel, useAuthStore} from '@/stores/auth';
@ -36,11 +37,23 @@ const OWNER_NAV: NavItem[] = [
{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 signOut = useAuthStore((s) => s.signOut);
const location = useLocation();
const navigate = useNavigate();
const items = nav ?? (user?.role === UserRole.DEVELOPER ? [...OWNER_NAV, ...DEVELOPER_NAV] : OWNER_NAV);
return (
<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>
<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
key={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= 로 어느 사이트인지 받는다) —
// 사장님 여럿이 사이트 여럿을 가질 수 있어 전역 메뉴 하나로는 못 고른다.
route('blog', 'pages/BlogPostsPage.tsx'),
route('ops/sites', 'pages/OpsSitesPage.tsx'),
route('ops/users', 'pages/OpsUsersPage.tsx'),
]),
/**