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:
- Hq INSERT (type=FRANCHISE, businessNumber 검증)
- OperatorAccount INSERT (role=HQ_MANAGER, passwordHash=BCrypt,
passwordMustChange=true) - HqConsent INSERT (IP·UA 자동 캡처)
- 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