FeaturesHQ (본사)HQ List (/hq)

HQ List — /hq (본사 목록)

SPEC #013 도입 · #045 (검색·필터·페이지네이션 서버화 + client-query 전환). handoff 10-surface §3 Page 2.

Overview

운영사 백오피스의 ⭐ 핵심 화면. 베타 파트너 본사 목록 조회 + 임퍼소네이션 진입.

#045 부터 검색(본사명·사업자번호)·필터(status·type·plan)·페이지네이션이 모두 서버사이드이며, 목록은 generated useAdminListHqs(params)(react-query, client query)로 fetch 한다(매장 #044 · 운영자 #043 미러). 부분 서버화(client useMemo 필터)는 “현재 페이지만” 거르게 되어 페이지네이션과 모순되므로 모든 필터를 서버 파라미터로 옮겼다.

Spec

UI

  • <PageHeader> 제목 “본사” + 부제(총 N개 — total 기반) + 우측 [신규 본사 온보딩] primary 버튼
  • status 탭 5종 — 전체 · 활성 · 온보딩 중 · 미납 · 정지. 클릭 시 서버 status 필터로 동작하며 1페이지로 리셋. #045 서버 페이지네이션에선 전체 카운트가 현재 페이지 응답에 없으므로 count badge 는 제거 (현행 시각 보존, 무리한 재설계 없음). status enum 은 generated AdminListHqsStatus 단일 소스. 탭은 공용 툴바와 별개의 상단 탭으로 유지(ops-hq-list 시각).
  • 공용 툴바 <ListToolbar>(@linkmusic/ui, 시안 ops-list-toolbar · 매장 #044/운영자 #043 미러) — 검색 input(본사명·사업자번호, maxLength 100, aria-label) + 유형 / 요금제 select(generated AdminListHqsType·AdminListHqsPlan) + [적용]/[초기화] + rightSlot 에 총 N개 표기. status 는 상단 탭 전용이라 툴바 필터에서 제외. applied/draft state 분리 — [적용]/탭 전환/페이지 이동 때만 query params 갱신, draft↔applied 가 다르면 [적용] primary 강조 + “미적용 변경” 힌트(dirty). (temp-layout Phase 4 슬라이스 2에서 인라인 필터바를 공용 컴포넌트로 교체 — 시각 동일, 동작·계약 불변.)
  • 공용 페이지네이션 footer <ListPagination>total/size(기본 20)로 총 페이지·이전/다음 disabled 계산.
  • 테이블 9컬럼 — 체크 / 본사명(<OrgAvatar> + 굵은 이름) / 사업자번호 / 매장수 / 타입 / 플랜 / 상태 / 결제경과 / 작업([상세][전환][정지|복구])
  • 다중 선택 시 하단 일괄작업 바: “N개 본사 선택됨” + [약관 재발송][공지 발송][결제일 변경][선택 해제]
    • 모두 announceWip() 패턴 — “준비 중. 추후 안내 예정.” (후속 SPEC)

HqStatus 4종

statusbadge의미
ACTIVEsuccess정상
ONBOARDINGinfo신규 등록 직후 — default
UNPAIDwarning미납
SUSPENDEDdanger정지 (운영사 정지 — 세션 무효화 + 로그인 차단)

정지/복구 액션 (SPEC #018 · #024)

행 작업에 [상세][전환] 외 status 전이 버튼이 붙는다.

본사 상태노출 버튼endpoint
ACTIVE · ONBOARDING · UNPAID (FRANCHISE)정지POST /api/v1/admin/hq/{id}/suspend (body SuspendRequest{ reason } 필수)
SUSPENDED (FRANCHISE)복구POST /api/v1/admin/hq/{id}/reactivate (body 없음)
INDEPENDENT (가상 본사)없음 (숨김)
  • generated mutation 훅 useSuspend / useReactivate (BFF catch-all /api/backend/... 경유) 사용. 시그니처 상이(suspend { hqId, data: { reason } } · reactivate { hqId }) — HqStatusTransitionDialoghandleConfirm 이 mode 로 분기 호출(union 변수로 mutate 호출 시 타입 불일치 회피).
  • 정지 사유(SPEC #024) — [정지] 모드는 reason textarea(필수, max 255)를 노출한다. backend SuspendRequest @NotBlank·@Size(max=255) 와 동일 의도의 zod(trim min(1)·max(255)) 사전 검증(frontend.md #8) — 미입력/공백-only 시 [정지] 비활성, 제출 payload reason 은 trim 값. [복구] 모드는 사유 입력이 없다(변화 없음). 정지 사유는 HQ 상세 개요 탭에 표시되고, 복구 시 clear 된다. 정지 사유 입력 레이아웃은 시안 ops-hq-detail.jsx §SuspendHQDialog(Field label + hint + TextArea) 정합 (SPEC #031-B).
  • [정지] confirm dialog 는 “정지 시 해당 본사 관리자 세션이 무효화되고 로그인이 차단됩니다” 경고. [복구]는 단순 확인.
  • 성공 시 onMutated() → 부모 HqListClient 가 현재 필터 params 로 getAdminListHqsQueryKey(params) 를 invalidate → useAdminListHqs 재fetch (status 배지 갱신). #045 client-query 전환으로 router.refresh() 대신 react-query invalidate 가 정합(params 인자 없이 invalidate 하면 active query 키와 어긋나 갱신이 깨진다 — 현재 필터 params 로 invalidate).
  • 에러 매핑: 409 HQ_INVALID_STATUS_TRANSITION(“이미 처리된 상태입니다”), 403 INDEPENDENT_HQ_SUSPENSION_FORBIDDEN(“가상 본사는 변경할 수 없습니다”), 기타 → 일반 실패. extractCode 사용.

가상 본사 표시

  • INDEPENDENT 가상 본사 1행 포함 — “독립 매장 (가상)” 표시
  • [전환] 버튼 비활성 (가상 본사는 임퍼소네이션 대상 X) · [정지]/[복구] 액션 자체 숨김 (전이 불가)

임퍼소네이션 가능 분기

const canImpersonate = (hq: Hq): boolean =>
  hq.status !== "SUSPENDED" && hq.type !== "INDEPENDENT";
  • SUSPENDED → opacity 0.4 + disabled
  • INDEPENDENT → disabled + 사유 tooltip

Implementation

Backend endpoint

GET /api/v1/admin/hq/admin-list (operationId adminListHqs, SPEC #013 · #045):

HqAdminListResponse{ items: HqAdminListItem[], page, size, total }
  • query: q(본사명·사업자번호, max 100)·status·type·plan·page(0-base)·size(1..100 clamp, 기본 20)
  • 정렬: status priority (UNPAID → SUSPENDED → ACTIVE → ONBOARDING) → name asc → id tie-breaker (DB ORDER BY CASE)
  • status 미지정 = 가상 본사 포함 전체. storeCount 는 현재 페이지 hqId 집합 한정 집계 (#045)

⚠️ 기존 GET /api/v1/admin/hq (operationId listHqs, minimal id+name) 는 store onboarding dropdown 전용으로 별개 endpoint — 검색·페이지네이션 없음. 혼동 금지.

Page (셸)

// apps/admin/src/app/(protected)/hq/page.tsx (server component 셸)
export const dynamic = "force-dynamic";
const HqListPage = () => <HqListClient />;

#045: server-fetch + prop 주입을 제거하고 page 는 셸 역할만 한다. 보호 endpoint 호출(토큰 서버 전용)은 generated mutator(apiFetch)가 BFF catch-all /api/backend/... 경유로 처리한다.

Client query / filter

useAdminListHqs(params)(react-query) — params 는 client state:

  • appliedQ·appliedStatus·appliedType·appliedPlan·page(0-base)·size(20). draft 와 분리.
  • 검색·type·plan 은 [적용] 클릭 시, status 는 탭 클릭 시 즉시 반영하고 1페이지로 리셋.
  • paramsuseMemo 로 안정화 — query key/invalidate 키가 동일 참조를 쓰도록.
  • 정렬·필터 매칭은 모두 서버 책임(client useMemo 필터 없음).

States & Edge Cases

상태처리
본사 0건 (가상 본사 1건만)“본사 등록 없음” 빈 상태 + CTA [신규 본사 등록]
검색 결과 0”조건에 맞는 본사가 없습니다” + [필터 초기화] (공용 <ListNoResults>, hq-list-no-results) — 진짜 빈 목록과 구분
임퍼소네이션 confirm 취소dialog 닫기, 상태 유지
임퍼소네이션 실패 (IMPERSONATE_INVALID_TARGET)Toast
SUSPENDED 본사 행opacity + [전환] disabled · 행 작업에 [복구] 노출
INDEPENDENT 행”독립 매장 (가상)” 표시 + [전환] disabled · 정지/복구 액션 숨김
[정지] 확인세션 무효화 경고 → suspend mutation → 성공 시 현재 필터 목록 invalidate(onMutated)
[복구] 확인reactivate mutation → ACTIVE 전환
전이 불가 (race)409 → “이미 처리된 상태입니다” 에러 표시, 목록 유지

Constraints

  • backend admin-list 는 풀 권한 (OPERATOR-only) — backend @PreAuthorize
  • 가상 본사는 임퍼소네이션 절대 비활성 — UI 가드 + backend 검증 이중
  • SUSPENDED 본사도 임퍼소네이션 차단 — 동일

Roadmap

  • HQ 상세 (/hq/:id) ✅ SPEC #020 — GET /api/v1/admin/hq/{id} + 5탭 (개요/매장/결제/활동/계정). 행 [상세] → /hq/{id}. features/hq/detail 참고
  • HQ status transition (정지/복구) API + UI ✅ SPEC #018
  • HQ status 자동 전환 (ONBOARDING → ACTIVE · UNPAID 결제 실패 자동) · 정지 사유 저장
  • 일괄작업 활성화 (약관 재발송 · 공지 · 결제일 변경)
  • 정렬 UI (column header 클릭)
  • 페이지네이션 (50+ 본사 등록 시) ✅ SPEC #045 — 서버사이드 검색·필터·페이지네이션
  • CSV export
  • 필터/페이지네이션 공용 컴포넌트 추출 ✅ temp-layout Phase 4 슬라이스 2 완료 — <ListToolbar>·<ListPagination>·<ListNoResults>(@linkmusic/ui)로 운영자·매장·본사 공용화.

References

  • SPEC #013 §2-3·§2-4 · SPEC #018 (정지/복구 전이) · SPEC #045 (검색·필터·페이지네이션 서버화 + client-query 전환)
  • handoff 10-surface-ops-backoffice.md Page 2
  • linkmusic-frontend-space/apps/admin/src/app/(protected)/hq/