FeaturesStore (매장)Store List (/stores)

Store List — /stores (매장 목록)

SPEC #019 도입. SPEC #044 — 서버사이드 검색·필터·페이지네이션. ops-hq-list/운영자(#043) 패턴 재사용 (매장 전용 시안 부재).

Overview

운영사(OPERATOR)가 전체 매장을 조회하는 백오피스 화면. 직전까지 /stores/new(등록)만 있고 목록이 없었다. HQ admin-list(#013)와 동일 패턴으로 GET /api/v1/admin/stores/admin-list

  • 매장 목록 페이지를 구현. 사이드바 “매장” 항목이 목록(/stores)으로 이동하고, 목록 상단 [매장 등록] 버튼이 /stores/new 로 진입한다.

SPEC #044 — server-driven 전환: 매장이 늘어나며 페이지네이션을 서버사이드로 도입했다. 페이지네이션을 서버로 옮기면 기존 client useMemo 필터(q·type·status·plan)는 “현재 페이지만” 거르게 되어 깨진다 → 모든 필터를 서버 파라미터로 이전하고, 목록 fetch 를 server-component 직접 호출에서 client-query(useAdminListStores)로 전환했다(운영자 /users #043 미러). page.tsx 는 셸이 되고, 필터·페이지 state 와 mutation invalidate 는 client(store-list-client.tsx)가 보유한다.

시안 출처: 매장 목록 전용 handoff 시안은 없다. 운영사 백오피스 list 관용구가 HQ 목록·운영자(#043) 목록과 동일하므로 그 패턴(검색·필터·테이블·StatusPill·페이지네이션·빈 상태)을 재사용한다 — 신규 시각 디자인이 아니다.

Spec

UI

  • <PageHeader> 제목 “매장” + 부제 “총 N개 매장 등록됨”(로딩 중 “불러오는 중…”) + 우측 [매장 등록] primary 버튼 (/stores/new)
  • 공용 툴바 <ListToolbar>(@linkmusic/ui, 시안 ops-list-toolbar · 운영자 #043 미러) — 검색 input(매장명 · 본사명 · 주소, maxLength 100, aria-label) + 유형 / 상태 / 요금제 select(3개) + [적용] / [초기화]. 입력 draft 와 적용된 값(fetch 반영)을 분리해, [적용]·페이지 이동 때만 query params 가 바뀐다. draft↔applied 가 다르면 [적용] primary 강조 + “미적용 변경” 힌트(dirty). (temp-layout Phase 4 슬라이스 2에서 인라인 필터바를 공용 컴포넌트로 교체 — 시각 동일, 동작·계약 불변.)
  • select 옵션은 generated enum(AdminListStoresType·AdminListStoresStatus·AdminListStoresPlan)을 단일 소스로 라벨링(전체 = "" → backend 에 undefined 로 omit).
  • 테이블 9컬럼 — 매장명(<OrgAvatar> + 굵은 이름, 클릭 시 /stores/{id} 상세 네비 #036, 폐점 시 폐점 배지 #044) / 본사 / 타입 / 플랜 / 상태 / 담당자 / 주소 / 최근 온라인 / 점장 계정 (#021)
  • 공용 페이지네이션 footer <ListPagination> — “현재/총 페이지 · 총 N개” + [이전]/[다음] (total/sizetotalPages·disabled 계산). 필터 변경 시 page 0 으로 리셋.
  • 점장 계정 컬럼: hasManagerAccount === true[발급됨 · 관리] 진입점(클릭 → 관리 다이얼로그, #029), false 면 [계정 발급] secondary 버튼 (#021).
  • HQ 목록과 달리 체크박스·일괄작업·임퍼소네이션·상태전이 액션 없음 (매장 상세·일괄작업은 후속 SPEC, Not in scope).
  • HQ 목록의 5탭 대신 상태 select 필터(3값이라 select 로 충분 — 공용 툴바 filters 항목).

StoreStatus 3종

statusbadge의미
ACTIVEsuccess정상 운영
INACTIVEmuted비활성
SUSPENDEDdanger정지

폐점(closedAt, #039)은 #044 부터 목록 행에 폐점 배지로 노출한다. 폐점은 status 와 독립된 terminal 축이며, status 미지정 = 폐점 포함 전체이므로 목록에 폐점 매장이 섞여 나온다. #044 에서 StoreAdminListItemclosedAt: string | null 필드가 추가돼(closedAt != null → muted “폐점” 배지), 가시성을 확보했다. 폐점 매장의 상세·상태전이 가드는 매장 상세(/stores/{id})에서 처리한다.

StoreType 3종

type라벨badge
INDEPENDENT독립info
DIRECT직영muted
FRANCHISE가맹muted

최근 온라인 (lastOnlineAt)

  • lastOnlineAt raw timestamp 의 경과 시간을 한국어 상대시각으로 표시 (방금 전 · N분 전 · N시간 전 · N일 전).
  • null/미파싱 시 .
  • heartbeat 기반 온/오프라인 임계 판정은 v1 미포함 (SPEC #019 Not in scope) — raw 노출만.

Implementation

Backend endpoint (#044)

GET /api/v1/admin/stores/admin-list (OPERATOR-only) — query q?·status?·type?·plan?·hqId?·page(0)·size(20,1..100 clamp):

{ items: [{ id, name, type, status, hqId, hqName, plan?, address?, managerName?, billingAnchorDay?, lastOnlineAt?, closedAt?, createdAt, hasManagerAccount }], page, size, total }
  • Store + Hq(name) join projection 으로 hqName 을 함께 반환 (N+1 회피).
  • 검색 q 는 매장명·본사명·주소 부분일치(대소문자 무시). 필터는 모두 nullable(미지정 = 전체).
  • 정렬: status priority (SUSPENDED → INACTIVE → ACTIVE) → name asc → id asc (페이지 간 결정성).
  • 폐점(closedAt) 매장 포함 (status 미지정 = 전체). 행에 closedAt·hasManagerAccount derived 필드 노출.

Page (server-component 셸) + Client query (#044)

// apps/admin/src/app/(protected)/stores/page.tsx (server 셸)
const StoreListPage = () => <StoreListClient />;

/stores 는 client-query 로 전환됐다(D7 — 운영자 /users #043 미러). 보호 endpoint 호출은 generated mutator(apiFetch)가 BFF catch-all /api/backend/... 경유로 처리하므로 토큰은 서버 전용이다. 목록 fetch·필터·페이지·invalidate 는 모두 client(store-list-client.tsx)가 보유한다.

// store-list-client.tsx (요지)
const params = useMemo(() => ({ q, status, type, plan, page, size: 20 }), [...]);
const query = useAdminListStores(params);           // react-query client query
const stores = query.data?.status === 200 ? query.data.data.items : [];
const total  = query.data?.status === 200 ? query.data.data.total : 0;
  • 응답 타입은 generated StoreAdminListResponse·AdminListStoresParams (@linkmusic/api-client) 단일 소스 — 수기 중복 정의 없음.
  • 에러(403/5xx/네트워크)는 mutator ApiError(status·body)로 도달 — code 매핑 후 client banner.

Server-driven filter / pagination (#044)

client state(적용된 값 vs 입력 draft 분리):

  • 검색 q — backend 가 name · hqName · address 부분일치 처리 (FE 는 maxLength 100 사전 컷)
  • 타입 / 상태 / 플랜 — dropdown → backend 파라미터 (빈 값 = 전체, omit)
  • page 0-base / size 20. 필터 [적용]·[초기화] 시 page 0 리셋, [이전]/[다음] 으로 페이지 이동
  • client useMemo 필터 제거 — 서버사이드 페이지네이션과 모순되므로(현재 페이지만 거름) 전부 서버화

Mutation invalidate 정합 (#044)

점장 발급(#021)/관리(#029) 다이얼로그 성공 시, 부모가 현재 필터 paramsgetAdminListStoresQueryKey(params) 를 invalidate 해 그 목록을 refetch 한다(콜백 onIssued/onMutated). params 인자 없이 invalidate 하면 active query 와 키가 어긋나 갱신이 깨진다. (/stores/{id} 상세는 server-component 라 콜백에서 router.refresh() 를 호출한다 — 매장 목록과 갱신 경로가 다르다.)

점장 계정 발급 (#021)

시안 출처: handoff ops-store-manager (design_handoff_linkmusic/design/screens/ops-store-manager.jsxIssueManagerDialog)를 기반으로 구현 — 시안 atom(OpsDialog·OpsDialogHeader·Field·Banner)을 @linkmusic/ui (Dialog·Banner·Button) + admin 관례로 매핑했고, 관리 다이얼로그(#029)와 시각 일관(헤더 subtitle·매장 info chip·임시 비번 mono input·인라인 danger Banner)을 맞췄다. 시안의 임시 비번 생성·per-field error 는 평문 임시 비번 계약·form-level 검증을 유지하려 미채택(기능 불변).

매장(Store)은 생성 시점에 로그인 계정이 없다(onboarding 은 Store + 연락처만 INSERT). 운영사가 매장 목록에서 점장(STORE_MANAGER) 로그인 계정을 사후 발급한다.

  • 트리거: 매장 행 점장 계정 컬럼의 [계정 발급] 버튼 → store-manager-issue-dialog.tsx 오픈(대상 store 전달). 발급됨 매장은 버튼 대신 [발급됨 · 관리] 진입점.
  • 레이아웃: 헤더 subtitle “첫 로그인 시 비밀번호 변경 강제” · surface-2 매장 info chip(Store 아이콘 + 매장명) · email(로그인 ID)/담당자 이름/임시 비밀번호(mono) 3필드(모두 필수 *). 임시 비번 input 은 type="password" + autoComplete="new-password".
  • : email · 담당자명 · 임시 비밀번호. zod 사전 검증이 backend 검증 의도와 일치 — tempPassword min 8 / max 100(OpenAPI 길이 제약), email 형식 · name 필수(max 255)는 backend 런타임 @Email/@NotBlank 를 미러(OpenAPI 스키마에는 길이만 노출). 미통과 시 [발급] 비활성.
  • 제출: generated useIssue mutation → POST /api/v1/admin/stores/{storeId}/managers (mutator apiFetch 가 BFF catch-all /api/backend/... 경유, 토큰 서버 전용). 성공 시 onIssued()(부모가 현재 필터 useAdminListStores query invalidate, #044) 로 목록 hasManagerAccount 갱신. pending 중 닫기·중복 제출 차단.
  • 계정 전달 안내문 복사 (#120): 성공 시 즉시 닫지 않고 안내문 복사 단계로 전환한다. AccountShareSection((protected)/account-share-section.tsx)이 입력했던 email·임시 비밀번호 + 로그인 안내(로그인: /login점장은 매장 클라이언트 space app 의 별도 origin 으로 로그인하므로 admin origin 을 쓰지 않고 경로만 표기) + “첫 로그인 시 비밀번호 변경” 안내를 한 묶음 평문으로 조합(buildAccountShareText, lib/account-share.ts)해 미리보기 + [안내문 복사] 버튼을 띄운다. navigator.clipboard.writeText(https 전제) 성공/실패를 toast + 인라인 배지로 안내하고, 실패 시 미리보기 평문에서 운영자가 직접 선택 복사할 수 있다. 임시 비밀번호는 이 모달 컨텍스트 내에서만 사용하며 재노출·저장·로깅하지 않는다(메일 인프라 부재 보완 — 수동 전달). 운영자는 [완료]로 닫는다.

에러 매핑 (backend ErrorResponse code → 메시지)

code / status메시지
409 DUPLICATE_EMAIL이미 사용 중인 이메일입니다. 다른 이메일을 입력해주세요.
404 STORE_NOT_FOUND해당 매장을 찾을 수 없습니다. 목록을 새로고침해주세요.
403 (권한)이 작업을 수행할 권한이 없습니다.
5xx서버에 일시적인 문제가 있습니다. 잠시 후 다시 시도해주세요.
그 외 / 네트워크계정 발급에 실패했습니다 / 서버에 연결할 수 없습니다.

점장 계정 관리 (#029)

발급(#021)에 이은 점장 계정 라이프사이클 관리. handoff 시안 ops-store-manager (design_handoff_linkmusic/design/screens/ops-store-manager.jsxManageManagerDialog)를 기반으로 구현 — 임시 레이아웃 아님. 시안 atom(OpsDialog·OpsBadge·OpsBtn)을 @linkmusic/ui(Dialog·StatusPill·Button)로 매핑했다.

  • 진입점: 매장 행 점장 계정 컬럼의 발급됨 매장 배지가 [발급됨 · 관리] 클릭형 진입점으로 전환 — 클릭 시 store-manager-manage-dialog.tsx 오픈. 미발급 매장은 기존 [계정 발급] 다이얼로그 유지(#021).
  • 데이터 로드: 다이얼로그가 열릴 때 generated useListStoreManagers(storeId)(react-query, enabled: open && storeId)로 매장 점장 목록을 fetch. 응답 StoreManagerListResponse.items 를 계정 카드로 렌더.
  • 계정 카드: email · name · 상태 배지(StatusPill — ACTIVE 정상/success · SUSPENDED 정지/danger · WITHDRAWN 회수됨/muted) · passwordMustChange(첫 변경 대기 warn 배지) · 마지막 로그인/가입일 KST(Intl timeZone Asia/Seoullib/format formatKstDateTime/formatKstDate). WITHDRAWN 카드는 회색·반투명.
  • 상태머신·상태별 액션 노출 (시안대로):
상태노출 액션
ACTIVE비밀번호 재설정 · 정지 · 회수
SUSPENDED복구 · 회수
WITHDRAWN없음 (terminal)
  • 비밀번호 재설정 (ACTIVE 만): 임시 비밀번호 입력(zod min 8 / max 100 — OpenAPI ResetPasswordRequest 제약). useResetStoreManagerPasswordPOST .../managers/{managerId}/reset-password. 성공 시 목록을 닫지 않고 안내 노출(평문 비번은 운영자가 입력한 값이라 별도 노출 불필요).
  • 정지 (ACTIVE 만): 사유 textarea(@NotBlank·max 255 — trim 후 min 1) + 2-step 확인 + 즉시 로그아웃 경고. useSuspendStoreManagerPOST .../suspend ({ reason }). ACTIVE→SUSPENDED.
  • 복구 (SUSPENDED 만): 사유 없는 단순 확인. useReactivateStoreManagerPOST .../reactivate (body 없음). SUSPENDED→ACTIVE.
  • 회수 (ACTIVE·SUSPENDED): 2-step “되돌릴 수 없음” 확인 + 회수 사유 입력(감사용). useRevokeStoreManagerDELETE .../managers/{managerId}. → WITHDRAWN(terminal). 회수 후 같은 이메일은 재사용 불가.
  • 갱신: mutation 성공 시 getListStoreManagersQueryKey(storeId) 를 invalidate + onMutated() (부모가 현재 필터 useAdminListStores query invalidate, #044 — 회수 시 매장 목록 hasManagerAccount 갱신 대비).

에러 매핑 (#029, backend ErrorResponse code → 메시지)

code / status메시지
409 ACCOUNT_INVALID_STATUS_TRANSITION이미 처리된 상태입니다. 목록을 새로고침해주세요.
404 STORE_NOT_FOUND해당 매장을 찾을 수 없습니다. 목록을 새로고침해주세요.
404 STORE_MANAGER_NOT_FOUND해당 점장 계정을 찾을 수 없습니다. 목록을 새로고침해주세요.
403 (권한)이 작업을 수행할 권한이 없습니다.
5xx서버에 일시적인 문제가 있습니다. 잠시 후 다시 시도해주세요.
그 외 / 네트워크작업에 실패했습니다 / 서버에 연결할 수 없습니다.

States & Edge Cases

상태처리
로딩 중”매장 목록을 불러오는 중…” (헤더 부제 + 본문)
매장 0건(필터 없음)“등록된 매장이 없습니다” 빈 상태 + CTA [매장 등록] (store-list-empty)
검색/필터 결과 0”조건에 맞는 매장이 없습니다” + [필터 초기화] (공용 <ListNoResults>, store-list-no-results) — 진짜 빈 목록과 구분
403 / 5xx / 네트워크 실패client banner (code 매핑, 세션 유지)
plan/address/managerName/lastOnlineAt null해당 셀
closedAt 설정매장명 옆 muted “폐점” 배지 (#044)
hasManagerAccount true점장 계정 컬럼 “발급됨” 배지 (발급 버튼 숨김)
발급 중 다이얼로그 닫기 시도차단 (중복 제출/race 회피)
첫 페이지 / 마지막 페이지[이전] / [다음] 각각 disabled

Roadmap

  • 매장 상세 (/stores/:id) ✅ SPEC #036 완료 — 매장명 셀 클릭 → /stores/{id} 상세. Store Detail 참고.
  • 검색·필터·페이지네이션 ✅ SPEC #044 완료 — 서버사이드 검색(q)·status/type/plan 필터·page/size 페이지네이션.
  • heartbeat 기반 온/오프라인 임계 판정 · 실시간 표시
  • 일괄작업 (공지 발송 · 플랜 변경 등)
  • 정렬 UI (column header 클릭)
  • 필터/페이지네이션 공통 컴포넌트 추출 ✅ temp-layout Phase 4 슬라이스 2 완료 — <ListToolbar>·<ListPagination>·<ListNoResults>(@linkmusic/ui)로 운영자·매장·본사 공용화.
  • HQ 모드 매장 목록 ✅ SPEC #051 완료 — apps/space /admin/stores 산하 매장 목록(listHqStores, hqId 스코프). 본 운영사 /stores 패턴 재사용 (HQ Mode 대시보드·산하 매장).

References

  • SPEC #019 · SPEC #044 (검색·필터·페이지네이션) · SPEC #011 (store onboarding) · SPEC #013 (hq admin-list 패턴) · SPEC #043 (운영자 목록 미러 원본) · SPEC #021 (STORE_MANAGER 발급) · SPEC #029 (STORE_MANAGER 관리)
  • linkmusic-frontend-space/apps/admin/src/app/(protected)/stores/ (store-list-client.tsx · store-manager-issue-dialog.tsx · store-manager-manage-dialog.tsx)