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 은 generatedAdminListHqsStatus단일 소스. 탭은 공용 툴바와 별개의 상단 탭으로 유지(ops-hq-list 시각). - 공용 툴바
<ListToolbar>(@linkmusic/ui, 시안ops-list-toolbar· 매장 #044/운영자 #043 미러) — 검색 input(본사명·사업자번호,maxLength 100,aria-label) + 유형 / 요금제 select(generatedAdminListHqsType·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종
| status | badge | 의미 |
|---|---|---|
ACTIVE | success | 정상 |
ONBOARDING | info | 신규 등록 직후 — default |
UNPAID | warning | 미납 |
SUSPENDED | danger | 정지 (운영사 정지 — 세션 무효화 + 로그인 차단) |
정지/복구 액션 (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 }) —HqStatusTransitionDialog의handleConfirm이 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(“이미 처리된 상태입니다”), 403INDEPENDENT_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페이지로 리셋.
params는useMemo로 안정화 — 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 상세 (✅ SPEC #020 —/hq/:id)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.mdPage 2 linkmusic-frontend-space/apps/admin/src/app/(protected)/hq/