FeaturesHQ (본사)HQ Onboarding (/hq/new)

HQ Onboarding — /hq/new (4-step stepper)

SPEC #010 정합 · SPEC #147 약관 동의 시점 이전. v0.3.0 도입. handoff 10-surface §3 Page 3.

Overview

운영사 (OPERATOR) 가 신규 FRANCHISE 본사를 등록. backend POST /api/v1/admin/hq 단일 transaction 을 4-step 클라이언트 stepper 로 감싼다.

SPEC #147: 약관 동의 step 이 발급 마법사에서 제거됐다(5-step → 4-step). 약관 동의는 본사 관리자 본인이 계정 설정(첫 로그인) 시 진행한다(setup 페이지 — features/auth/account-emails). 발급 request 에서 consent 가 빠졌고, 발급 page 의 활성 약관 server fetch·미게시 차단 Banner 도 제거됐다.

Spec

Step 구조

Step내용
1. 기본 정보본사명 · 사업자번호(SPEC #013) · 플랜(AI/TRUST) · 결제 anchor day · LLM keywords / max per hour
2. 계약 · 결제plan/billingAnchorDay (step1 과 동일 state 참조 — IA 분리)
3. 계정 발급manager.email · tempPassword · name
4. 확인요약 + “약관 동의는 본사 관리자 본인이 계정 설정 시 진행” 안내 + [등록] button → 단일 POST

Stepper 제출 전략

4 step 모두 클라이언트 state (useReducer) 로 수집 → 마지막 step 클릭 → POST 한 번. 새로고침 / 탭 닫기 → state 소실 (의도적 — 베타 단순). sessionStorage 보존은 후속 chore.

State shape

type OnboardingState = {
  step: 0 | 1 | 2 | 3;
  hq: {
    name: string;
    businessNumber?: string;     // SPEC #013 신설
    plan: "AI" | "TRUST";
    billingAnchorDay: number;
    llmKeywords: string;
    llmMaxPerHour: number;
  };
  manager: { email: string; tempPassword: string; name: string };
  // SPEC #147 — consent state 제거. 동의는 계정 설정(setup) 단계로 이전.
};

Implementation

진입 페이지 (server component)

apps/admin/src/app/(protected)/hq/new/page.tsx — SPEC #147 이후 약관 server fetch 가 없으므로 단순히 stepper 만 렌더한다(발급은 약관과 무관).

const HqNewPage = () => <HqOnboardingStepper />;

제출

react-query useMutation 이 아니라 catch-all BFF proxy (/api/backend/...) 로 직접 fetch 한다. 이 proxy 는 backend response 를 status·body 그대로 passthrough 하므로 status·code 로 직접 분기·매핑한다 (apps/admin/src/app/(protected)/hq/new/hq-onboarding-stepper.tsx):

const handleSubmit = async () => {
  if (pending) return;
  setError(null);
  setPending(true);
  try {
    const body = buildHqOnboardingRequest(state);  // SPEC #147 — consent 미포함
    const res = await fetch("/api/backend/api/v1/admin/hq", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(body),
      credentials: "same-origin",
    });
    if (!res.ok) {
      const payload: unknown = await res.json().catch(() => null);
      const mapping = mapSubmitError(res.status, extractCode(payload));
      if (mapping.redirectLogin) {
        router.replace("/login");
        return;
      }
      setError(mapping.message);
      if (mapping.jumpToStep !== undefined) {
        dispatch({ type: "SET_STEP", step: mapping.jumpToStep });
        setErroredSteps([mapping.jumpToStep]);
      }
      return;
    }
    const payload = (await res.json().catch(() => null)) as
      | { hqId: string; managerAccountId: string }
      | null;
    toast.show({
      title: `본사 '${state.hq.name}'가 등록되었습니다`,
      tone: "success",
    });
    // SPEC #155 H8 — 홈으로 즉시 이탈하지 않고 자격증명 전달 단계로 전환.
    setIssued({
      email: state.manager.email,
      tempPassword: state.manager.tempPassword,
      managerAccountId: payload?.managerAccountId ?? "",
      hqId: payload?.hqId ?? "",
    });
  } catch {
    setError("서버에 연결할 수 없습니다.");
  } finally {
    setPending(false);
  }
};

자격증명 전달 (SPEC #155 H8)

발급 성공 시 홈(/)으로 즉시 router.replace 하지 않고 자격증명 전달 단계로 전환한다 (issued state). 점장(store-manager-issue-dialog)·운영자(operator-issue-dialog) 발급과 동일한 AccountShareSection 을 재사용한다:

  • [설정 이메일 보내기] — 응답 managerAccountIduseResendAccountSetup 호출(권장 경로).
  • 임시 비밀번호 안내문 복사 — 메일 미수신 대비 오프라인 폴백(입력값 스냅샷, 재노출·저장 없음).
  • [본사 상세 보기] — 응답 hqId/hq/:id 이동. [완료 · 목록으로]/ 이동.

본사 관리자는 매장 클라이언트(space app, 별도 origin)로 로그인하므로 안내문에는 경로(/login)만 표기한다. 메일은 등록과 동시에 자동 발송되지 않으며, 운영자가 이 화면에서 설정 메일을 보내거나 안내문을 복사해 수동 전달한다(step 3/4 문구도 이에 맞춰 정정됨).

임시 비밀번호 분실·재발급은 이제 실재하는 경로를 안내한다(SPEC #158) — 본사 상세 > 계정 탭 > [관리] > [비밀번호 재설정](새 임시 비밀번호 발행) 또는 [설정메일 재발송](설정 링크 재전송). step 3(step3-manager) 안내 문구도 이 경로를 가리키도록 정정됐다(이전에는 존재하지 않는 액션을 가리키던 stale 문구).

에러 매핑(mapSubmitError) — DUPLICATE_EMAIL(409, 계정 발급 step 으로 이동) · VALIDATION_FAILED/VALIDATION_ERROR(generic) · AUTH_ACCOUNT_SUSPENDED/AUTH_ACCOUNT_WITHDRAWN (/login) · BACKEND_UNREACHABLE(연결 실패) · status >= 500(서버 일시 장애 fallback). SPEC #147 로 발급 request 에 약관이 없으므로 INVALID_LEGAL_VERSION 매핑은 제거됐다(약관 검증은 setup 단계).

진행 인디케이터는 admin 셸의 OpsStepper (@/components/shell/ops-stepper) 를 쓴다 — steps · current · completed · errored props (@linkmusic/ui 컴포넌트 아님).

이탈 경로 (SPEC #164 C4)

마법사 footer 왼쪽에 [목록으로](onboarding-cancel) 이탈 액션이 있어 진행 중에도 본사 목록(/hq)으로 빠져나갈 수 있다(기존엔 [이전]/[다음]뿐이라 브라우저 뒤로/URL 수정 외 이탈 불가). 입력이 있는 단계(isOnboardingDirty — hq·manager 가 초기값에서 하나라도 달라짐)에서는 OnboardingLeaveConfirmDialog(공용 apps/admin/src/app/(protected)/onboarding-leave-confirm-dialog.tsx) 로 “작성 중인 내용이 사라집니다” 확인을 먼저 받고, [나가기] 시 router.push("/hq") 한다. step0 빈 입력에서는 확인 없이 곧장 이동한다. 발급 성공 후 자격증명 전달 화면(AccountShareSection)은 이미 [완료 · 목록]/[상세]가 있으므로 이 이탈 액션 대상이 아니다(진행 중 단계 한정).

Endpoint

POST /api/v1/admin/hq (OPERATOR-only):

  • request: { hq: {...}, manager: {...} } (SPEC #147 — consent 제거)
  • response 200: { hqId, managerAccountId }
  • 단일 transaction:
    1. Hq INSERT (type=FRANCHISE, businessNumber 검증)
    2. OperatorAccount INSERT (role=HQ_MANAGER, passwordHash=BCrypt, passwordMustChange=true)
  • 약관 consent INSERT 는 발급이 아니라 본사 관리자 본인의 계정 설정(setup/complete) 트랜잭션에서 수행된다(SPEC #147).

Validation

zod schema (apps/admin/src/app/(protected)/hq/new/use-onboarding-state.ts):

  • name: required, 1~100 chars
  • businessNumber: ^[0-9]{3}-[0-9]{2}-[0-9]{5}$ (SPEC #013), optional
  • plan: enum AI/TRUST
  • billingAnchorDay: 1~31
  • llmMaxPerHour: 1~12
  • email: 이메일 형식 (backend 와 일치)
  • tempPassword: 8자 이상

States & Edge Cases

상태처리
step navigation: 미입력 필드다음 step button disabled
[목록으로] · 입력 있음 (SPEC #164 C4)확인 다이얼로그 → [나가기] 시 /hq 이동
[목록으로] · step0 빈 입력확인 없이 곧장 /hq 이동
email 중복 (409)“이미 사용 중인 이메일입니다” — 계정 발급 step inline
5xx”잠시 후 다시 시도”
새로고침state 소실 (의도)
backend 단일 transaction 실패부분 INSERT 안 됨 (transaction rollback 보장)

활성 약관 부재·약관 version 검증은 발급이 아니라 계정 설정(setup) 단계에서 다룬다(SPEC #147).

Constraints

  • 단일 POST — 부분 저장 안 함 (transaction)
  • 약관 동의는 발급이 아니라 본사 관리자 본인의 계정 설정(setup) 단계에서 수집 (SPEC #147)
  • HQ_MANAGER 계정의 passwordMustChange=true — 첫 로그인 시 강제 변경 (#007)
  • handoff 시안 부재 — stepper UI 는 영역 분할 + label 만, 시각 디자인 후속 SPEC

Roadmap

  • step 3 계약 · 결제 — backend column 추가 후 폼 활성화
  • CSV 매장 일괄 등록 — 후속
  • 등록과 동시 자동 메일 발송 — 현재는 수동 전달(설정 메일 버튼 + 안내문 복사, SPEC #155). 자동 발송은 후속
  • sessionStorage 임시 저장 — 후속 chore
  • 본사 상세 (/hq/:id) — backend GET 추가 후

References

  • SPEC #010 · #013 · #147 · #155
  • handoff 10-surface-ops-backoffice.md §3 (PRD Page 3)
  • linkmusic-frontend-space/apps/admin/src/app/(protected)/hq/new/
  • linkmusic-msa-space-was/.../api/admin/HqController.kt