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

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

SPEC #010 정합. v0.3.0 도입. handoff 10-surface §3 Page 3.

Overview

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

Spec

Step 구조

Step내용
1. 기본 정보본사명 · 사업자번호(SPEC #013) · 플랜(AI/TRUST) · 결제 anchor day · LLM keywords / max per hour
2. 약관 동의활성 TermsDocument · PrivacyPolicy 본문 표시 + 3 항목 체크 + 서명자명
3. 계약 · 결제plan/billingAnchorDay 만 (UI 미수집 — backend column 없음. PRD §11-3 후속)
4. 계정 발급manager.email · tempPassword · name
5. 완료확인 요약 + [등록] button → 단일 POST

Stepper 제출 전략

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

State shape

type OnboardingState = {
  step: 0 | 1 | 2 | 3 | 4;
  hq: {
    name: string;
    businessNumber?: string;     // SPEC #013 신설
    plan: "AI" | "TRUST";
    billingAnchorDay: number;
    llmKeywords: string;
    llmMaxPerHour: number;
  };
  consent: {
    termsAgreed: boolean;
    privacyAgreed: boolean;
    llmAgreed: boolean;
    signerName: string;
  };
  manager: { email: string; tempPassword: string; name: string };
};

Implementation

활성 약관 조회 (server component)

apps/admin/src/app/(protected)/hq/new/page.tsx:

const HqNewPage = async () => {
  const [terms, privacy] = await Promise.all([
    backendActiveTerms(),
    backendActivePrivacy(),
  ]);
  if (!terms || !privacy) {
    return <Banner variant="warn">약관이 게시되지 않았습니다. 약관 게시 후 다시 시도해 주세요.</Banner>;
  }
  return <HqNewClient terms={terms} privacy={privacy} />;
};

Step 2 약관 표시

<details> HTML 태그 — 접고 펼치기. 본문은 markdown 그대로 (rehype 없이 단순 <pre>). 시각 디자인은 시안 도착 후 별도 SPEC.

제출

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, terms, privacy);
    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;
    }
    toast.show({
      title: `본사 '${state.hq.name}'가 등록되었습니다`,
      tone: "success",
    });
    router.replace("/");
    router.refresh();
  } catch {
    setError("서버에 연결할 수 없습니다.");
  } finally {
    setPending(false);
  }
};

에러 매핑(mapSubmitError) — INVALID_LEGAL_VERSION(400, step 2 로 이동) · DUPLICATE_EMAIL(409, step 4 로 이동) · VALIDATION_FAILED/VALIDATION_ERROR(generic) · AUTH_ACCOUNT_SUSPENDED/AUTH_ACCOUNT_WITHDRAWN (/login) · BACKEND_UNREACHABLE(연결 실패) · status >= 500(서버 일시 장애 fallback).

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

Endpoint

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

  • request: { hq: {...}, consent: {termsVersion, privacyPolicyVersion}, manager: {...} }
  • response 200: { hqId, managerAccountId }
  • 단일 transaction:
    1. Hq INSERT (type=FRANCHISE, businessNumber 검증)
    2. OperatorAccount INSERT (role=HQ_MANAGER, passwordHash=BCrypt, passwordMustChange=true)
    3. HqConsent INSERT (IP·UA 자동 캡처)
    4. HqPrivacyConsent INSERT

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

상태처리
활성 약관 부재페이지 진입 차단 + 안내 Banner
step navigation: 미입력 필드다음 step button disabled
email 중복 (409)“이미 사용 중인 이메일입니다” — step 4 inline
잘못된 약관 version (400)“페이지 새로고침 후 다시 시도”
5xx”잠시 후 다시 시도”
새로고침state 소실 (의도)
backend 단일 transaction 실패부분 INSERT 안 됨 (transaction rollback 보장)

Constraints

  • 단일 POST — 부분 저장 안 함 (transaction)
  • 약관 version 은 backend 활성 버전과 일치 — UI 가 미리 조회
  • HQ_MANAGER 계정의 passwordMustChange=true — 첫 로그인 시 강제 변경 (#007)
  • handoff 시안 부재 — stepper UI 는 영역 분할 + label 만, 시각 디자인 후속 SPEC

Roadmap

  • step 3 계약 · 결제 — backend column 추가 후 폼 활성화
  • CSV 매장 일괄 등록 — 후속
  • 환영 이메일 자동 발송 — Mail 인프라 SPEC
  • sessionStorage 임시 저장 — 후속 chore
  • 본사 상세 (/hq/:id) — backend GET 추가 후

References

  • SPEC #010 · #013
  • 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