[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:
parent
79962b93e2
commit
46b74b310b
@ -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초 벽을 콜백으로 넘는다
|
||||||
|
|
||||||
실제 카톡에서 "시설 편의에서 바비큐 이용 문구 빼줘" 가 **"확인하는 데 시간이 조금 걸리네요"**
|
실제 카톡에서 "시설 편의에서 바비큐 이용 문구 빼줘" 가 **"확인하는 데 시간이 조금 걸리네요"**
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
0
solution/backend/router/v1/ops/__init__.py
Normal file
0
solution/backend/router/v1/ops/__init__.py
Normal file
27
solution/backend/router/v1/ops/ops.py
Normal file
27
solution/backend/router/v1/ops/ops.py
Normal 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))
|
||||||
53
solution/backend/router/v1/ops/protocol.py
Normal file
53
solution/backend/router/v1/ops/protocol.py
Normal 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] = []
|
||||||
89
solution/backend/services/ops_service.py
Normal file
89
solution/backend/services/ops_service.py
Normal 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
|
||||||
@ -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";
|
||||||
|
|||||||
18
solution/frontend/src/api/generated/model/listSitesParams.ts
Normal file
18
solution/frontend/src/api/generated/model/listSitesParams.ts
Normal 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;
|
||||||
|
};
|
||||||
22
solution/frontend/src/api/generated/model/listUsersParams.ts
Normal file
22
solution/frontend/src/api/generated/model/listUsersParams.ts
Normal 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;
|
||||||
|
};
|
||||||
38
solution/frontend/src/api/generated/model/opsSiteData.ts
Normal file
38
solution/frontend/src/api/generated/model/opsSiteData.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
31
solution/frontend/src/api/generated/model/opsUserData.ts
Normal file
31
solution/frontend/src/api/generated/model/opsUserData.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
18
solution/frontend/src/api/generated/model/resOpsSites.ts
Normal file
18
solution/frontend/src/api/generated/model/resOpsSites.ts
Normal 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[];
|
||||||
|
}
|
||||||
@ -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;
|
||||||
18
solution/frontend/src/api/generated/model/resOpsUsers.ts
Normal file
18
solution/frontend/src/api/generated/model/resOpsUsers.ts
Normal 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[];
|
||||||
|
}
|
||||||
@ -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;
|
||||||
17
solution/frontend/src/api/generated/model/userStatus.ts
Normal file
17
solution/frontend/src/api/generated/model/userStatus.ts
Normal 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;
|
||||||
314
solution/frontend/src/api/generated/ops/ops.ts
Normal file
314
solution/frontend/src/api/generated/ops/ops.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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';
|
||||||
|
|
||||||
// 잡 폴링(수집·비전·생성·빌드 공용). 생성물이 아니라 그 위에 얹은 얇은 유틸이다.
|
// 잡 폴링(수집·비전·생성·빌드 공용). 생성물이 아니라 그 위에 얹은 얇은 유틸이다.
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
158
solution/frontend/src/pages/OpsSitesPage.tsx
Normal file
158
solution/frontend/src/pages/OpsSitesPage.tsx
Normal 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;
|
||||||
162
solution/frontend/src/pages/OpsUsersPage.tsx
Normal file
162
solution/frontend/src/pages/OpsUsersPage.tsx
Normal 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;
|
||||||
@ -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'),
|
||||||
]),
|
]),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user