시즌>리그>토너먼트 트리로 운영합니다. 수정·삭제는 참가자 영향이 있어 사유 확인이 필요합니다.', 'web-central-hq-comp-create': '대회 정보·참가 규칙·진행 설정을 단계별로 만듭니다. 시즌은 1단계로 완료, 리그·토너먼트는 전 단계가 필요하며 생성은 요약 확인 후 확정됩니다.', 'web-central-hq-tours': '시즌·리그·토너먼트를 묶는 최상위 투어를 관리합니다. 투어는 본사 전용이며 하위 이벤트가 남아 있으면 삭제할 수 없습니다.', 'web-central-store-competition': '매장이 주최한 대회를 모니터링합니다. 본사는 조회·삭제(모더레이션)만 가능하고 생성 버튼이 없으며 삭제는 사유 확인이 필요합니다.', 'web-central-personal-competition': '사용자앱에서 개인이 만든 토너먼트를 조회합니다. 본사는 조회·삭제만 가능하고 생성·편집은 없습니다.', 'web-central-users': '전 회원을 요약 통계·필터·CSV로 조회합니다. 읽기 전용이며 회원 상태 설정·포인트 수동 조정은 제공하지 않습니다.', 'web-central-user-detail': '회원의 티어·포인트와 멤버십·활동·대회·메모 이력을 봅니다. 조회 전용이고 HQ 메모는 내부용이며 취소 메모는 이력을 남기는 soft-cancel입니다.', 'web-central-bay-sales': '매장 타석 이용료 매출을 모니터링합니다. 타석 매출은 100% 매장 몫이라 본사는 조회만 하고 수정·환불이 없으며 월 단위 집계입니다.', 'web-central-comp-sales': '본사 대회 참가비 매출을 유입 매장 기준으로 집계합니다. 참가비는 100% 본사 수익이고 매장은 유입 채널이며 환불 액션 없이 조회 전용입니다.', 'web-central-membership-sales': '전사 멤버십 구독 매출을 매장·월 기준으로 분석합니다. 조회 전용이라 편집·환불·삭제가 없고 월 단위 집계입니다.', 'web-central-store-list': '등록 매장을 검색·정렬로 조회하고 운영툴 진입 경로를 제공합니다. 조회 전용이며 운영툴은 감사 로그가 남는 본사 대리 로그인입니다.', 'web-central-store-detail': '단일 매장의 기본정보·연동·시설·장치를 조회합니다. 전부 읽기 전용이고 운영툴 진입은 감사 이벤트로 기록됩니다.' }, golfhubApp: { 'app-home': '회원 요약과 플랜·혜택·주요 기능으로 이동합니다.', 'app-auth': '고객 로그인·가입 경로를 시작합니다.', 'app-qr': '매장·스크린 이용을 위한 회원 QR을 제시합니다.', 'app-store': '예약할 매장과 날짜·기본 조건을 선택합니다.', 'app-reservation-search': '가용 시간과 타석·룸 조건을 비교해 예약을 시작합니다.', 'app-reservation-payment': '예약 금액·혜택을 확인하고 결제를 확정합니다.', 'app-purchase': '플랜·이용권의 결제 수단과 결과를 처리합니다.', 'app-membership': '보유 멤버십·이용권 상태와 후속 절차를 확인합니다.', 'app-reservation-detail': '예약 상세·변경 이력과 취소·일정 변경을 처리합니다.', 'app-competition-entry': '개인·팀 참가 조건과 결제·환불 절차를 관리합니다.' }, fms: { 'web-reservation-overview': '프로·날짜 기준 시간표에서 레슨 예약을 확인합니다.', 'web-messaging': '문자 발송에 사용할 MMS 사진을 관리합니다.', 'web-support': '서비스 이용료 청구·결제 내역을 조회합니다.' }, qapp: { 'app-onboarding': '앱 시작 전 버전·점검·필수 권한을 안내합니다.', 'app-auth': '계정 찾기·카카오 로그인·복구 절차를 제공합니다.', 'app-home': '주요 서비스 바로가기와 이벤트·콘텐츠를 제공합니다.', 'app-training': '연습·레슨 상품을 고르고 예약 흐름을 시작합니다.', 'app-reservation-search': '날짜·시간·인원·요청사항을 선택해 예약합니다.', 'app-reservation-payment': '앱 내 결제 웹 화면으로 이동해 결제를 진행합니다.', 'app-membership': '보유 이용권·사용 시작일·약관을 확인합니다.', 'app-purchase': '이용권 구매 상품과 완료 결과를 확인합니다.', 'app-competition': '대회 목록·상세·랭킹 정보를 FSApp 네이티브 화면에서 확인합니다.', 'app-competition-entry': 'FSApp에서는 대회 참가·생성 기능이 제공되지 않습니다.', 'app-report': '플레이·클럽 측정 기록을 요약하고 분석합니다.', 'app-scorecard': '스코어카드 목록과 홀별 결과를 확인합니다.', 'app-swing': '스크린·트레이닝 스윙 영상을 보고 분석합니다.', 'app-account-security': '보유 예약·이용권을 확인한 뒤 탈퇴합니다.' } }; const inventoryOptionOverrides = { qms: { "타석 오픈 시간선택 모달": ["M1 10·20·30·60·90·120분", "M2 180·240·300·360·420·480분", "M3 1~7일(하루 단위), 기본 60분", "라운드제 사용 불가(E307), 진행 예약 시 0분"], "시간변경 모달": ["시간제 5·10·15·20·30·60분", "몸풀기 5·10·15·20·30분, 최대 120분", "다음 예약·영업 종료 시각을 상한으로 적용"], "예약 입력 폼(모달)": ["시작·종료 5분 단위, 종료 기본값 +60분", "시 0~23, 인원 1~6명", "이름 최대 30자·닉네임 최대 30자·전화 최대 13자", "메모 최대 150자"], "예약 취소 모달": ["앱 예약 사유: 잔여 타석 없음·직접 입력", "직접 입력 최대 30자"], "타석 정렬": ["기본순·종료임박순", "종료임박순 반영 약 15초 지연", "드래그 순서 변경은 기본순에서만 허용"], "PC 인증번호 요청(카카오톡 코드)": ["인증코드 유효시간 5분"], "로그인 실패 처리(내부)": ["비밀번호 5회 실패 시 5분 잠금"], "홀 개수": ["9홀·18홀, 기본 9홀"], "서비스/대회이름/공지 입력": ["대회명 최대 80자", "공지 최대 1000자"], "공개여부(공개/비공개+비밀번호)": ["공개·비공개, 비공개 비밀번호 4자리"], "코스 컨디션 설정(난이도·멀리건·캐디·컨시드)": ["난이도 Master·Pro·SemiPro, 기본 Pro", "멀리건 0·1·3·5·10, 기본 1", "캐디 0·1·3·6·9, 기본 3", "컨시드 없음·0.61·1.22·2.44·3.66·4.88m"], "티박스 선택(남성/여성)": ["남성 기본 White·여성 기본 Red"], "예약 상세설정 저장": ["취소 시간 5·60·90·120·180·240·360분", "예약 간격 0·5·10·15·20·30·60분", "최대 예약일 1~7일·최소 예약 30·60·90·120분", "예약 단위 5·10·30·60분"], "매장 사진 업로드": ["최대 5장·파일당 최대 1MB", "허용 형식 JPG·PNG"] }, fms: { "타석 오픈 팝업": ["시간제 10~120분·3~15시간, 기본 60분", "최대 900분, 다음 예약 기준 10분 단위 내림"], "이용시간 변경 팝업": ["증감 5·10·15·20·30·60분"], "타석 예약 등록/수정 폼 출력": ["예약 5분 단위, 종료 기본값 +60분", "메모 최대 150자"], "레슨 예약 등록/수정 폼 출력": ["레슨 종료 기본값 +15분", "프로 근무시간에서 휴게시간·기존 예약 제외"], "현금 결제 폼 표시": ["현금영수증: 안 함·개인·사업자", "입력 장치: K사인패드·C카드"], "신용카드 결제 폼 표시": ["일시불·2·3·4·5·6개월", "5만원 미만은 일시불 강제"], "회원 메모 저장(AJAX)": ["회원 메모 최대 3000byte"], "SMS 실제 발송 처리": ["45byte 초과 시 LMS, 장문 최대 2000byte", "제목 최대 50자", "광고 문자는 22:00~08:00 발송 제한"], "직원 등록/수정 폼 렌더": ["이름 최대 20자, 사번 입력 maxlength 12·중복확인", "화면 안내는 사번 4~20자로 입력 제약과 충돌", "비밀번호 8~20자 영문+숫자+특수문자 !@#$%^*+=-"], "타석·레슨 예약정책 저장 AJAX": ["취소 시간 5·60·90·120·180·240·360분", "예약 지연 0·5·10·30·60분", "타석 최대 예약일 1~7일·레슨 1~21일", "준비시간 0·5·10·15·20·25·30분, 최소시간 10·15·30·60분, 예약 단위 5·10·30·60분"], "일반상품 등록 폼": ["횟수 1·5·10·15·직접", "기간 30·90·180·360일·직접", "상품명 최대 20자·설명 최대 8자·유효/사용기간 최대 1000일", "판매채널 All·FMS·FA APP, 상태 노출·숨김·판매불가"], "일반상품 수정 폼": ["시간권 B 600·1800·3600·7200분, C 15분 고정, 기타 60·90·120·150분", "상품명 최대 20자·설명 최대 8자", "판매기간 제한없음·기간지정"], "일반상품 저장(등록/수정)": ["상품명 최대 20자·설명 최대 8자", "유효/사용기간 최대 1000일", "POS 판매 필수, R-app 숨김, kiosk 주석 처리"], "패키지상품 등록 폼": ["직접 할인금액 0 이상·할인율 0~100%", "패키지명 최대 20자·설명 최대 8자"], "패키지상품 수정 폼": ["할인금액·할인율 상호 환산", "할인율 0~100%, 이름 20자·설명 8자"], "상품 상세 보기": ["할인율 3·5·10·20% 또는 직접 0~100%", "상품 단가 직접 입력"], "이용권 기간/횟수/시간 수정(AJAX)": ["횟수 ±1·시간 ±5분, 0 미만 불가", "시작 후 시작일 변경 불가"], "이용권 휴회(일시정지) 설정/해제(AJAX)": ["시작·종료일 필수, 시작일≤종료일", "휴회 상태 Y·N"], "라커 생성/수정/삭제": ["라커·시작일·종료일 필수, 시작일≤종료일", "일괄 추가 최대 500개"], "매장 기본정보 저장": ["매장 이미지 최대 5장", "주차 안내 최대 100자", "영업시간 30분 단위"], "레슨 스케줄 설정 팝업": ["휴게시간 최대 5구간"], "매출 상세 목록 조회(필터+더보기)": ["상세 조회 기간 최대 1개월"] }, qapp: { } }; // 원본 description/note/evidence는 검색·화면 동작 근거로 유지한다. // 아래 값은 프로젝트 상세 표에만 쓰는 짧은 표시값이다. const inventoryDisplayOverrides = { golfhubBranchWeb: { "주간·일자 타임라인": { entry: "예약 현황 → 주/일 날짜 선택 → 타석별 30분 그리드", options: ["채널·현장·승인대기·대회 범례"] }, "예약 생성": { entry: "빈 슬롯·추가 → 예약 입력", options: ["입력: 날짜·시간·인원·타석·메모"], condition: "충돌·과거 시간 불가" }, "예약 수정": { entry: "예약 상세 → 수정", options: ["타석 · 날짜 · 시간 · 메모"], condition: "인원·이름·전화 변경 불가" }, "드래그 이동": { entry: "예약 블록 → 시간·타석 이동", condition: "운영시간·대회·제외 타석 확인" }, "예약 상세": { entry: "예약 블록 클릭 → 상세(상태·결제·요청·메모)" }, "예약 취소": { entry: "예약 상세 → 취소", condition: "사유·환불 예상·대회 일괄취소 경고" }, "노쇼 처리": { entry: "예약 상세 → 노쇼", condition: "환불은 매장 정책 · 대회는 환불 제한" }, "검색·필터·정렬": { entry: "예약 내역 → 목록/변경이력 탭 → 기간·상태·타석·채널 필터", options: ["기간 · 상태 · 타석 · 채널"] }, "대회 목록·트리": { entry: "대회 관리 → 유형·상태 필터 → 리그/토너먼트 선택 → 상세·리더보드", options: ["리그 · 토너먼트 · 상태"] }, "본사 대회 수락·거절": { entry: "대회 관리 → 본사 대회 초대 → 조건·bay fee 확인 → 수락/거절" }, "대회 생성 마법사": { entry: "대회 → 3단계(대회정보·참가설정·진행설정) → 요약 확인 → 생성(즉시 모집)", options: ["유형 · 기간 · 참가조건 · 보상"], condition: "리그 최소 2라운드 · 매장 회원 대상" }, "본사 플랜 채택": { entry: "상품 → 전체/기본/프로모션 탭 → 상품 추가(HQ 플랜) → 가격·시간대·결제옵션·판매기간 → 저장", options: ["가격 · 시간대 · 결제옵션 · 판매기간"], condition: "구독자 있으면 핵심 조건 잠금" }, "Bay 매출 분석": { entry: "Bay 매출 → 기간 필터 → KPI·채널·tier → 행 상세(할인 내역)·영수증·환불", options: ["기간 · 채널 · tier"] }, "멤버십 판매 분석": { entry: "멤버십 판매 → 기간 필터 → KPI·상품 분포 → 상세·환불·CSV", options: ["기간 · 상품 · 상태"] }, "멤버십 환불": { entry: "판매 행/상세 → 환불 → 사유·방법 선택 → 확인", condition: "활성 멤버십만 · 본사 환불 정책 적용" }, "회원 목록·KPI": { entry: "매장 회원 → KPI 카드 → 필터·검색·정렬 → 상세·CSV", options: ["티어 · 상태 · 멤버십 · 성별"] }, "매장 기본정보 조회": { entry: "매장 관리 → 매장정보 탭 → 기본정보 조회", condition: "읽기 전용" }, "요일 운영시간·요금": { entry: "매장 관리 → 운영관리 탭 → 요일 스케줄·시간대 요금 → 휴무일 등록 → 저장", options: ["Peak·Off-Peak·이른아침·야간 요율"] }, "예약 승인·슬롯 정책": { entry: "매장 관리 → 예약설정 탭 → 자동/수동 승인·자동취소·예약창·환불 tier → 저장", options: ["승인 방식 · slot · 사전예약 · 환불 tier"] }, "타석·요금 설정": { entry: "매장 관리 → 타석설정 탭 → 요금 유형·인원 추가요금 → 타석 행 편집", options: ["이름 · 타입 · 방향 · 순서 · 대회제외"], condition: "정상 운영 중 잠금(점검 모드에서 편집)" } }, golfhubCentralWeb: { "티어 정의·혜택 연결": { entry: "티어 관리 → 티어 탭(Welcome~Platinum) → 색상·설명·혜택 편집 → 저장", options: ["색상 · 설명 · 혜택 선택"], condition: "티어명·유지포인트·기간은 읽기 전용" }, "플랜 템플릿 관리": { entry: "멤버십 템플릿 → 템플릿 선택·신규 → 이름·티어·권장가격·기간 → 혜택 필수/선택 지정 → 저장", options: ["플랜 정보 · 필수/선택 혜택"] }, "플랜 티어·권장가격 연결": { entry: "템플릿 → 티어·권장가격 하한/상한 연결 → 저장" }, "혜택 항목 카탈로그": { entry: "혜택 관리 → 좌측 목록 선택·신규 → 카테고리·타입·파라미터 → 사용처 확인 → 저장", options: ["카테고리 · 타입 · 파라미터"] }, "HQ 대회 목록·상세·리더보드": { entry: "HQ 대회 관리 → 관리 탭 트리 → 필터·정렬 → 상세(개요·참가자·리더보드)", options: ["검색 · 유형 · 상태 · 소유자"] }, "HQ 대회 생성": { entry: "HQ 대회 → 3단계(기본정보·참가설정·진행설정) → 요약 확인 → 생성", options: ["기본정보 · 참가설정 · 진행설정"] }, "HQ 투어 관리": { entry: "HQ 대회 대시보드 → 투어 관리 → 상태 필터 → 추가·수정·상세" }, "매장 주최 대회 조회·리더보드": { entry: "매장 대회 관리 → 매장 필터·정렬 → 상세·리더보드", condition: "본사는 조회·삭제만" }, "개인 주최 대회 조회·리더보드": { entry: "개인 대회 관리 → 주최자 검색 → 상세·리더보드", condition: "본사는 조회·삭제만" }, "회원 목록·필터·CSV": { entry: "회원 관리 → 요약 카드 → 필터·검색 → 열 정렬 → 상세·CSV", options: ["티어 · 멤버십 · 채널 · 성별"] }, "회원 상세·등급·CS 메모": { entry: "회원 목록 → 상세 → 탭(멤버십·포인트·활동·대회·메모) → HQ 메모 추가" }, "타석 매출 롤업": { entry: "Bay 매출 → 월 선택 → 지역·검색 → 매장 랭킹 정렬 → 상세·CSV", options: ["월 · 지역 · 검색"] }, "대회 참가비 매출": { entry: "대회 참가비 매출 → 월 선택 → 대회·유형·유입매장 필터 → 롤업 랭킹·CSV", options: ["월 · 대회 · 유입매장"] }, "멤버십 매출 랭킹·CSV": { entry: "멤버십 매출 → 월 선택 → 매장·상품·상태 필터 → 지역·상품 분포·랭킹·CSV", options: ["월 · 매장 · 상품 · 상태"] }, "매장 목록·검색": { entry: "매장 관리 → 매장명·주소 검색 → 열 정렬 → 상세·운영툴", options: ["매장명 · 주소 검색"] }, "매장 기본정보·프랜차이즈 범위": { entry: "매장 목록 → 상세 → 기본정보·주소·연동·장치 확인 → 운영툴" }, "매장 운영툴 대리 진입": { entry: "매장 상세 → 운영툴 → 확인 → 매장 운영 화면(HQ 대리)" } }, golfhubApp: { "하단 탭 내비게이션": { options: ["Home · Book · Competition · Me"] }, "개발용 컨텍스트 바": { options: ["user_id · store_id 직접 편집", "/entry · 데스크탑 링크"] }, "개발 로그인(/entry)": { options: ["기본 계정 demo-user-001"] }, "회원 인사·등급 배지": { options: ["PLATINUM · GOLD · SILVER · BRONZE"] }, "포인트 요약": { options: ["보유 P · 이번 달 적립 P"] }, "다음 등급 진행률": { options: ["남은 P · 진행률 %"] }, "매장 필터 선택": { options: ["GOLF 업종 매장 목록", "미선택 = 전체 플랜 보기"] }, "매장 상품·플랜 진열": { options: ["월정기 · 이용권 구분 배지", "FROM $가격/mo"] }, "내 QR 발급 시트": { options: ["60초 후 만료 · 1회용"] }, "플랜 목록": { options: ["ANYTIME · OFF-PEAK 이용 유형", "$가격/mo 표시"] }, "플랜 상세·가격 안내": { options: ["Monthly 자동갱신 · One-time 구분", "첫달 일할(잔여일/월일수) · 판매세 %"] }, "정기 구독 가입": { options: ["첫달 일할 결제", "매월 1일 정기 자동결제"] }, "일회성 이용권 구매": { options: ["기본가 · 혜택 할인 · 판매세", "적립 예정 +P"], condition: "무료 이용권은 결제 생략" }, "주문·결제 이력 탭": { options: ["최근 6개월", "PAID·PENDING·REFUNDED 등 상태 7종"] }, "혜택·등급 비교 탭": { options: ["등급 히어로 · 혜택 목록", "플랜 최대 3종 비교"] }, "포인트 탭": { condition: "외부 시스템 영역 · 상시 비활성" }, "구독 목록": { options: ["Monthly · Yearly 주기", "다음 결제일 · 다음 청구액"] }, "구독 해지·철회": { options: ["해지 예약(기간 만료까지 이용)", "자동갱신 재개(취소 철회)"], condition: "해지 약관 동의 필수" }, "매장(VENUE) 선택": { options: ["매장 드롭다운"] }, "타석 유형 선택": { options: ["Bay · Private Room"], condition: "Private Room 값 미전송" }, "인원·좌타 선택": { options: ["1~최대 6명(서버값) · 기본 2명", "좌타석 요청 토글(LEFT)"] }, "날짜 선택(7일)": { options: ["오늘부터 7일 스트립", "휴무일 비활성 표시"] }, "시간대 선택·홀드": { options: ["기본 15분 단위 · 최소 30분(서버값)", "Peak · Off-peak 구분"], condition: "Book 시 임시 홀드" }, "예약 요약·견적": { options: ["원가·구간·할증·할인", "이용권 차감 · 판매세 · 적립 P"] }, "결제수단 선택": { options: ["Card · Apple Pay · Google Pay"], condition: "시각 요소 · 결제 분기 없음" }, "결제 실행": { options: ["SQUARE(고정 nonce) · Reserve & Pay(Mock)"] }, "무료 예약 확정": { condition: "이용권 전액 충당 시" }, "예약 목록·상태 필터": { options: ["All · Confirmed · No-show · Cancelled", "최대 50건 조회"] }, "변경 이력 타임라인": { options: ["생성·확정·변경·취소·완료·노쇼", "회원 · 매장 · 시스템 구분"] }, "예약 정보·출입 PIN": { options: ["RES-6자리 예약번호 · ACCESS PIN"] }, "변경·취소 진입": { condition: "서버 허용 플래그로 노출" }, "환불 미리보기": { options: ["결제액 · 위약금 · 총 환불액", "시작 N분 전 정책 안내"] }, "취소 동의·확정": { condition: "취소·환불 약관 동의 필수" }, "일정 이동(취소+재예약)": { options: ["기존 전액 환불 + 신규 결제"] }, "인원 변경(제자리 정산)": { options: ["1~최대 6명(서버값)"] }, "차액 견적·추가 결제": { options: ["추가 결제 · 환불 · 변동 없음"] }, "결제 정보·가격 상세": { options: ["보기·카드입력·처리중·성공·실패 5단계"] }, "저장 카드로 결제": { options: ["Card-on-File 재사용"] }, "신규 카드 입력(Square)": { options: ["Sandbox 테스트 카드 4111…"], condition: "PUBLIC_SQUARE_* 환경변수 필요" }, "결제 완료 영수증": { options: ["결제수단 · PG · 거래 ID · 일시 · 금액"] }, "저장 카드 조회·삭제": { options: ["브랜드 · 끝 4자리 · 유효기간"] }, "새 카드 등록": { condition: "카드 저장·재사용 동의 필수" }, "대회 목록·phase 탭": { options: ["진행중 · 예정 · 종료", "TEAM · INDIVIDUAL · 참가비"] }, "내 대회": { options: ["진행중 · 예정 · 종료 + 건수", "결제 상태 5종 · 라운드 진행률"] }, "참가 설정(매장·형식·시간)": { options: ["개인 · 팀 참가", "요일(월~일) · 시간대 선택"] }, "라운드 일정·대체일": { options: ["휴일 라운드 → 대체 요일 선택"], condition: "미해결 휴일 있으면 진행 불가" }, "팀 구성·이메일 초대 검증": { options: ["팀 2~6명 · 기본 4명", "초대 최대 = 팀원 수 - 1"] }, "참가 견적·가격 상세": { options: ["참가비 · 번들 · 멤버 할인", "수수료 · 판매세 · 멤버십 커버"] }, "개인·팀 결제(전액/N빵)": { options: ["전액 결제 · N빵 분할(기본)", "대표자 라운딩 부담"] }, "팀 정보·멤버 현황": { options: ["CAPTAIN · TEAMMATE", "READY·CONFIRMED·PENDING·DECLINED·EXPIRED"] }, "초대 수락·거절": { options: ["수락(분담금 결제) · 거절"] }, "팀 전체 취소·환불": { options: ["멤버별 환불 · 예약 역분개"], condition: "대회 시작 1일 전까지" }, "계정 메뉴 허브": { options: ["멤버십 정보 · 플랜 · 결제수단 · 내 예약"] } }, qms: { "타석 오픈 시간선택·처리": { entry: "빈 타석 → 시간 선택 → 오픈", options: ["타석 운영 타입 · 이용시간"], condition: "운영시간·타석 상태 검증" }, "타석 오픈 시간선택 모달": { entry: "오픈 대기 타석 → 시간 선택", options: ["M1 10·20·30·60·90·120분", "구간2 180·240·300·360·420·480분", "M3 1~7일 · 기본 60분"], condition: "라운드제 불가 · 진행 중 0분" }, "시간변경 모달": { entry: "사용 중 타석 → 시간 변경", options: ["시간제 5·10·15·20·30·60분", "몸풀기 5·10·15·20·30분", "최대 120분"] }, "예약 입력 폼": { entry: "예약 셀 → 등록·수정·취소", options: ["날짜 · 시간 · 타석 · 인원", "예약자 · 연락처 · 메모"] }, "예약 입력 폼(모달)": { entry: "예약 셀 → 등록·수정", options: ["0~23시 · 5분 단위 · 1~6명", "이름·닉네임 30자 · 전화 13자", "메모 150자 · 종료 +60분"] }, "예약 취소 모달": { entry: "예약 상세 → 취소", options: ["잔여 타석 없음 · 직접 입력", "직접 입력 30자"] }, "코스 컨디션 설정(난이도·멀리건·캐디·컨시드)": { entry: "게임 설정 → 코스 옵션", options: ["난이도 Master·Pro·SemiPro", "멀리건 0·1·3·5·10 · 캐디 0·1·3·6·9", "컨시드 없음·0.61·1.22·2.44·3.66·4.88m"] }, "스코어 증서 출력": { entry: "타석·예약 → 타석이용내역 → 스코어카드 팝업 → 증서 출력", options: ["개인 · 팀 증서"] }, "스코어카드 모달": { entry: "타석현황·타석이용내역 → 스코어카드", options: ["홀별 타수 · 퍼팅 · 벌타", "증서 출력 연결"] }, "매장정보 페이지": { entry: "매장·운영 → 매장 정보", options: ["운영시간(평일·주말) · 휴무 안내", "매장 사진 · 주차 여부"] }, "운영 설정 페이지": { entry: "매장·운영 → 운영 설정 → 5개 탭", options: ["비밀번호 · 예약 · 직원 · 타석 · 보안"] }, "비밀번호 설정": { entry: "운영 설정 → 비밀번호설정 탭 → 적용", options: ["마스터 · 관리자 비밀번호", "숫자 4자리"], condition: "클라이언트 재시작 시 반영" }, "예약 정책 저장": { entry: "운영 설정 → 예약설정 탭 → App 예약 상세 설정 → 적용", options: ["예약 · 자동취소 on/off", "취소 가능 시간 · 예약 간 휴게", "최대 7일 · 최소 30분 · 5분 단위"] }, "직원 설정 페이지": { entry: "운영 설정 → 직원설정 탭", options: ["직원 목록 · 권한 표시"] }, "타석 설정": { entry: "운영 설정 → 타석설정 탭 → 적용", options: ["시간제 · 라운드제", "타석 이름 · 자동연장 분/회"], condition: "운영 모드 변경은 사용 중 타석이 없을 때" }, "타석 운영 로그": { entry: "타석설정 탭 → 변경 내역 조회" }, "보안 설정": { entry: "운영 설정 → 보안설정 탭", options: ["로그인 시 인증코드 항상 요청 토글"] }, "할인쿠폰 사용금액": { entry: "할인쿠폰 → 사용금액 탭", options: ["월별 사용금액 · 정산여부", "할인쿠폰 입력"] }, "할인쿠폰 상세내역": { entry: "할인쿠폰 → 상세 내역 탭" }, "로그인 화면": { entry: "로그인 → 매장주·직원 선택 → 아이디·비밀번호 입력", options: ["매장주 · 직원 전환", "아이디/비밀번호 찾기 · 원격지원"] }, "로그인 인증(매장주·직원·원툴)": { entry: "로그인 버튼 → 계정 유형별 인증 → 운영 화면 진입" }, "Q 매장 서비스 타입 검증": { entry: "로그인 과정에서 자동 확인", condition: "Q 서비스 매장만 통과" }, "로그인 실패·잠금 안내": { entry: "로그인 실패 시 안내 문구 표시", options: ["ID 없음 · 비밀번호 오류", "5회 실패 시 5분 잠금"] }, "PC 2차 인증번호 요청": { entry: "미인증 PC 로그인 → 인증번호 요청", condition: "등록된 휴대폰 필요" }, "PC 2차 인증번호 확인": { entry: "인증번호 입력 → 확인 → 로그인 완료" }, "신뢰 PC 쿠키·로그인 완료": { entry: "인증 성공한 PC는 1년간 재인증 생략", condition: "공지·미납 팝업이 이어질 수 있음" }, "로그아웃": { entry: "좌측 메뉴 하단 → 로그아웃" }, "이용약관": { entry: "로그인 화면 하단 → QMS 이용약관" }, "외부 통합계정 로그인": { entry: "로그인 성공 시 카카오VX 통합계정과 자동 연결" }, "타석현황 페이지": { entry: "타석·예약 → 타석 → 타석 현황", options: ["타일형 · 리스트형 보기", "타석 사용 상태 · 잔여시간"] }, "타석 정렬": { entry: "타석 현황 → 타석정렬 선택", options: ["기본 · 종료임박순", "기본순에서 타석 순서 저장"] }, "타석정보 모달": { entry: "타석 카드 → 타석정보", options: ["타석정보 · 예약확인 · 스코어카드", "게임설정 · 회원정보"] }, "타석이동 실행": { entry: "타석정보 → 타석 이동 → 이동할 타석 선택" }, "타석 종료 처리": { entry: "사용 중 타석 → 종료" }, "라운드 설정 모달": { entry: "타석 카드 → 라운드 설정", options: ["라운드 공개 · 비공개"] }, "장비 이벤트 조회": { entry: "타석 현황에서 장비 상태 자동 갱신" }, "타석이용내역 페이지": { entry: "타석·예약 → 타석 → 타석이용내역", options: ["기간 · 타석 · 게임모드 필터", "스코어카드 · 증서 연결"] }, "예약현황 스케줄 테이블": { entry: "타석·예약 → 예약 → 예약 현황", options: ["날짜 이동 · 오늘 바로가기", "QMS · App 예약 건수 표시"] }, "예약조회 페이지": { entry: "타석·예약 → 예약 → 예약 조회", options: ["기간 · 타석 · 상태 · 경로 필터", "예약자명 검색"] }, "예약 저장·수정·확정": { entry: "예약 팝업 → 내용 입력 → 등록·수정" }, "예약 드래그앤드롭 이동": { entry: "예약 현황에서 예약 블록을 끌어 다른 시간·타석으로 이동", condition: "미래 예약만 이동 가능" }, "예약 취소": { entry: "예약 상세 팝업 → 예약 취소", options: ["취소 사유 입력"] }, "예약별 실제 이용시간 조회": { entry: "예약 조회 → 사용시간 → 팝업으로 확인" }, "APP 예약 연동": { entry: "앱에서 등록한 예약이 예약 현황에 자동 반영" }, "우리매장대회 페이지": { entry: "대회 → 우리매장대회", options: ["대회 등록 · 코스 소개", "대회명 검색"] }, "대회 상세·랭킹 조회": { entry: "대회 목록 → 랭킹보기" }, "대회 스코어카드 조회": { entry: "대회 랭킹 → 참가자 선택 → 스코어카드" }, "대회 등록·수정 화면": { entry: "우리매장대회 → 대회 등록", options: ["대회명 · 기간 · 코스 · 티박스", "참가조건 · 시상 설정"] }, "코스·홀 시상 옵션 조회": { entry: "대회 등록에서 코스 선택 시 자동 표시", options: ["롱기스트 · 니어리스트 홀 선택"] }, "대회 저장·취소": { entry: "대회 등록 화면 → 저장 · 대회 취소" }, "대회 결과·인쇄": { entry: "종료된 대회 → 결과 → 인쇄 · Excel" }, "이용료 정산 요약": { entry: "정산 → 정산 → 이용료 정산 탭", options: ["적용 중 과금제 · 월별 이용량"] }, "A/S 정산": { entry: "정산 → 정산 → A/S 정산 탭", options: ["Excel 내보내기"] }, "정산 내역": { entry: "정산 → 정산 → 정산 내역 탭", options: ["Excel 내보내기"] }, "매장 요금 결제": { entry: "정산 → 결제 → 매장 요금 탭 → 매장 포인트 충전" }, "문자 요금 결제": { entry: "정산 → 결제 → 문자 요금 탭 → 문자 포인트 충전" }, "결제 내역": { entry: "정산 → 결제 → 결제 내역 탭" }, "무이자 할부 청구서": { entry: "정산 → 무이자 할부 → 청구서 탭" }, "할부 등록 내역": { entry: "정산 → 무이자 할부 → 등록 내역 탭" }, "할부 결제 내역": { entry: "정산 → 무이자 할부 → 결제 내역 탭", options: ["Excel 내보내기"] }, "회원 페이지": { entry: "회원·SMS → 회원", options: ["이름 · 전화 뒷자리 · 닉네임 검색", "개인정보 동의 회원만 보기"] }, "회원 상세·등급": { entry: "회원 목록 → 회원 선택 → 상세" }, "회원 이용권 목록": { entry: "회원 상세 → 이용권" }, "회원 예약·대회 이력": { entry: "회원 상세 → 예약 · 대회 이력" }, "회원 이용권 수정": { entry: "회원 이용권 → 수정", options: ["상태 · 기간 · 횟수 · 시간"] }, "회원 운영 메모": { entry: "회원 상세 → 메모 입력·저장" }, "회원 임시상태·활성확인": { entry: "회원 상세에서 계정 상태 확인·변경" }, "회원 Excel 이관": { entry: "회원 → Excel 업로드로 기존 회원·이용권 옮기기" }, "라커 페이지": { entry: "회원·SMS → 라커", options: ["선택 활성화 · 비활성화 · 삭제", "라커 카드에서 배정 현황 확인"] }, "라커 등록·수정·종료": { entry: "라커 → 라커 추가 · 카드 선택 → 이용관리", options: ["회원 배정 · 이용 기간 설정"] }, "SMS 페이지": { entry: "회원·SMS → SMS", options: ["기간 설정으로 발송 내역 조회"] }, "SMS 작성·발송": { entry: "회원 목록에서 대상 선택 → SMS 발송" }, "SMS 발송 이력·상세": { entry: "SMS → 발송 내역 → 상세 확인" }, "매장 기본정보 저장": { entry: "매장 정보 → 항목 수정 → 저장" }, "매장 사진 업로드·삭제": { entry: "매장 정보 → 매장 사진 → 파일찾기", condition: "1MB 이하 JPG·PNG" }, "임시 운영시간": { entry: "매장 정보 → 임시 운영 시간 설정", options: ["날짜별 등록 · 수정 · 삭제"] }, "휴업일 관리": { entry: "매장 정보 → 휴무 안내 등록·삭제" }, "직원 등록·수정·삭제": { entry: "운영 설정 → 직원설정 탭 → 직원 등록·수정", options: ["메뉴 권한 부여"], condition: "매장주·직원 권한에 따라 가능 범위가 다름" }, "PC 2차 인증 설정": { entry: "운영 설정 → 보안설정 탭 → 인증코드 요청 설정" }, "공지사항": { entry: "고객지원 → 공지 사항", options: ["제목+내용 검색"] }, "FAQ": { entry: "고객지원 → FAQ" }, "자료실·다운로드": { entry: "고객지원 → 자료실 → 첨부파일 다운로드" }, "포스터 신청": { entry: "고객지원 → 포스터신청" } }, fms: { "타석 오픈 팝업": { entry: "빈 타석 → 오픈", options: ["10~120분 · 3~15시간 · 기본 60분", "최대 900분 · 10분 단위"] }, "이용시간 변경 팝업": { entry: "사용 중 → 시간 변경", options: ["5·10·15·20·30·60분"] }, "타석 예약 등록/수정 폼 출력": { entry: "예약 셀 → 등록·조회·수정·취소", options: ["5분 단위 · 종료 +60분 · 메모 150자"], condition: "결제완료·지난·취소 예약 수정 불가" }, "신용카드 결제 폼 표시": { entry: "결제 건 → 카드 결제", options: ["일시불 · 2·3·4·5·6개월"], condition: "5만원 미만 일시불" }, "현금 결제 폼 표시": { entry: "결제 건 → 현금 결제", options: ["현금영수증 안 함·개인·사업자", "K사인패드 · C카드"] }, "일반상품 등록 폼": { entry: "상품 → 등록", options: ["횟수 1·5·10·15·직접", "기간 30·90·180·360일·직접", "All · FMS · FA APP"] }, "일반상품 수정 폼": { entry: "상품 → 수정", options: ["시간권 600·1800·3600·7200분", "레슨 15분 · 기타 60·90·120·150분", "판매기간 제한없음 · 기간지정"] }, "직원 등록/수정 폼 렌더": { entry: "직원 → 등록·수정", options: ["이름 20자 · 사번 12자", "비밀번호 8~20자 · 영문·숫자·특수문자"], condition: "사번 안내 4~20자와 상충" } }, qapp: { "스플래시": { entry: "앱 실행 시 자동", options: ["자동 분기(토큰有 홈 / 無 로그인)"] }, "업데이트 안내(강제/선택)": { entry: "스플래시 → 버전체크(자동)", options: ["강제=확인만", "선택=확인·취소"] }, "서비스 점검": { entry: "스플래시·로그인 → 점검 감지(자동)" }, "계정 복원": { entry: "스플래시 → 복원 트리거(현재 stub)" }, "로그인 진입": { entry: "스플래시 → 로그인", options: ["카카오 로그인", "직접입력 로그인", "회원가입"] }, "직접입력 로그인(ID/PW)": { entry: "로그인 → 직접입력 로그인", options: ["아이디 입력", "비밀번호 입력"] }, "권한 안내": { entry: "스플래시 → 권한 안내", options: ["필수·선택 권한 요청"] }, "회원가입 1 · 약관동의": { entry: "로그인 → 회원가입 → 약관동의", options: ["필수4종", "선택(마케팅·SMS·카카오프로필)", "전체동의"] }, "회원가입 2 · 닉네임": { entry: "약관동의 → 닉네임", options: ["닉네임 2~12자", "한/영/숫자만"] }, "회원가입 3 · 성별": { entry: "닉네임 → 성별", options: ["남자 · 여자"] }, "회원가입 4 · 휴대폰": { entry: "성별 → 휴대폰", options: ["휴대폰 번호 인증"] }, "회원가입 5 · 생년월일": { entry: "휴대폰 → 생년월일", options: ["생년월일 입력"] }, "회원가입 6 · 완료": { entry: "생년월일 → 완료", options: ["뒤로가기 차단"] }, "VT 로고": { entry: "홈 상단바 좌측" }, "전체메뉴(햄버거)": { entry: "홈 → 우측 ☰" }, "프로필(아바타 · 닉네임)": { entry: "홈 → 프로필 카드 탭" }, "최근 타수": { entry: "홈 → 프로필 카드 → 최근 타수" }, "최근 트레이닝": { entry: "홈 → 프로필 카드 → 최근 트레이닝" }, "통합예약 버튼": { entry: "홈 → 통합예약 CTA" }, "스코어카드": { entry: "홈 → 퀵메뉴 → 스코어카드" }, "클럽측정(비거리)": { entry: "홈 → 퀵메뉴 → 클럽측정" }, "예약내역": { entry: "홈 → 퀵메뉴 → 예약내역" }, "필드예약(4번째 슬롯)": { entry: "홈 → 퀵메뉴 → 필드예약", options: ["카카오골프 외부 딥링크"] }, "핫딜골프(4번째 슬롯 대체)": { entry: "golfHotDeal ON시 홈 → 퀵메뉴 4번째" }, "커뮤니티(4번째 슬롯 대체)": { entry: "두 플래그 OFF시 홈 → 퀵메뉴 4번째" }, "대회": { entry: "홈 → 퀵메뉴 → 대회", options: ["위치 조회(실패시 판교 폴백)", "거리단위 m/yd"] }, "이벤트": { entry: "홈 → 퀵메뉴 → 이벤트" }, "코스소개": { entry: "홈 → 퀵메뉴 → 코스소개" }, "공지사항": { entry: "앱 설정 / 홈 퀵메뉴 → 공지사항" }, "상단 배너": { entry: "홈 상단" }, "팝업 광고": { entry: "홈 진입 시(자동)", options: ["오늘 그만보기"] }, "큰 배너": { entry: "홈" }, "인기 아이템": { entry: "commerceAd ON시 홈" }, "MobWith 배너": { entry: "홈" }, "모두보기": { entry: "홈 → 필드예약 추천 → 모두보기" }, "지역 탭": { entry: "홈 → 필드예약 추천", options: ["서울경기·강원·충청·경상·전라·제주 6탭"] }, "매장(티타임) 카드": { entry: "홈 → 필드예약 추천 → 매장 카드", options: ["외부 URL(golfInfoUrl→bookingUrl→kakao.golf)"] }, "빈/에러 상태": { entry: "홈 → 필드예약 추천(조회 실패)", options: ["다시시도"] }, "홈 탭": { entry: "하단탭 0" }, "리포트 탭": { entry: "하단탭 1", options: ["스크린 · 트레이닝 탭"] }, "중앙 QR 버튼": { entry: "하단 중앙 QR" }, "매장찾기 탭": { entry: "하단탭 2" }, "스윙영상 탭": { entry: "하단탭 3", options: ["스크린 · 트레이닝 · 커뮤니티 탭"] }, "리포트 메인": { entry: "하단탭 리포트", options: ["스크린 · 트레이닝 2탭"] }, "기록분석 상세": { entry: "리포트 → 분석 항목 → 상세", options: ["분석유형", "머신 Q·T2", "라운드수(기본 5)"] }, "매장찾기 메인": { entry: "하단탭 매장찾기", options: ["맵·리스트 토글", "머신·편의시설·즐겨찾기 필터"] }, "매장 검색": { entry: "매장찾기 → 검색 오버레이", options: ["검색어 입력"] }, "매장 상세": { entry: "매장찾기 → 매장 선택" }, "매장 이미지 뷰어": { entry: "매장 상세 → 사진", options: ["이미지 스와이프(initialIndex)"] }, "매장 프로 상세": { entry: "매장 상세 → 소속 프로" }, "스윙영상 메인": { entry: "하단탭 스윙영상", options: ["스크린 · 트레이닝 · 커뮤니티 탭", "월 네비게이션(스크린·트레이닝)", "커뮤니티 성별·샷·정렬 필터"] }, "스크린 영상 상세": { entry: "스윙영상 스크린 → 영상" }, "트레이닝 영상 상세": { entry: "스윙영상 트레이닝 → 영상" }, "커뮤니티 상세": { entry: "스윙영상 커뮤니티 → 영상", options: ["신고 · 차단"] }, "커뮤니티 업로드": { entry: "스윙영상 커뮤니티 → 업로드", options: ["영상 선택", "갤러리 권한"] }, "커뮤니티 신고": { entry: "커뮤니티 상세 → 신고", options: ["게시글·댓글·회원 신고"] }, "그리기(드로잉)": { entry: "영상 상세 → 그리기", options: ["드로잉 도구"] }, "외부진입 글로벌 라우트": { entry: "스코어카드·리포트 → 스윙영상 상세" }, "드로어 헤더": { entry: "전체 메뉴(☰)", options: ["프로필칩", "포인트캐시칩(캐시C·포인트P)", "닫기"] }, "다가오는 예약 배너": { entry: "전체 메뉴(☰) → 상단 예약 배너" }, "메뉴 배너광고": { entry: "전체 메뉴(☰)" }, "MY · 예약내역": { entry: "전체 메뉴(☰) → MY → 예약내역" }, "예약내역 목록": { entry: "전체 메뉴(☰) → MY → 예약내역", options: ["전체 · 스크린 · 아카데미 탭"] }, "예약 상세": { entry: "예약내역 → 항목" }, "예약 취소": { entry: "예약 상세 → 취소", options: ["사유 4종(날짜변경·조건변경·개인사정·기타)", "기타 20자"] }, "취소 완료": { entry: "예약 취소 → 완료" }, "MY · 이용권 관리": { entry: "전체 메뉴(☰) → MY → 이용권 관리" }, "이용권 관리 목록": { entry: "전체 메뉴(☰) → MY → 이용권 관리", options: ["전체 · 이용중 · 이용완료 탭"] }, "이용권 상세": { entry: "이용권 목록 → 항목", options: ["예약하기"] }, "결제 상세/취소": { entry: "이용권 상세 → 결제", options: ["결제 취소"] }, "MY · 포인트 · 캐시": { entry: "전체 메뉴(☰) → MY → 포인트·캐시" }, "포인트 · 캐시 내역": { entry: "전체 메뉴(☰) → MY → 포인트·캐시", options: ["캐시·포인트 탭", "획득·이용 탭", "이용약관 시트"] }, "MY · 스크린 쿠폰": { entry: "딥링크 fscreenq://app/coupon (드로어 주석)" }, "쿠폰 목록": { entry: "딥링크 → 쿠폰함" }, "쿠폰 상세": { entry: "쿠폰 목록 → 항목" }, "SCREEN · 스코어카드": { entry: "전체 메뉴(☰) → 스크린 → 스코어카드" }, "스코어카드 목록": { entry: "전체 메뉴(☰) → 스크린 → 스코어카드", options: ["유형 필터", "제품(전체·Q·T2·FA)", "18H·9H 토글"] }, "스코어카드 상세": { entry: "스코어카드 목록 → 항목", options: ["홀별 스코어"] }, "스코어카드 공유": { entry: "스코어카드 상세 → 공유", options: ["결과 이미지 공유"] }, "SCREEN · 증서 관리": { entry: "전체 메뉴(☰) → 스크린 → 증서 관리" }, "증서 목록": { entry: "전체 메뉴(☰) → 스크린 → 증서 관리", options: ["머신 필터(스크린 Q · 스크린 T)"] }, "증서 상세": { entry: "증서 목록 → 항목", options: ["4종 템플릿 PageView", "저장·공유"] }, "SCREEN · 핫딜골프": { entry: "golfHotDeal ON시 전체 메뉴 → 스크린" }, "TRAINING · 이용권 구매": { entry: "전체 메뉴(☰) → 트레이닝 → 이용권 구매" }, "이용권 구매(ShopPass)": { entry: "전체 메뉴(☰) → 트레이닝 → 이용권 구매", options: ["매장 선택", "이용권 선택", "시작일 (main·purchase·finish)"] }, "구매 매장선택 picker": { entry: "이용권 구매 → 매장 선택", options: ["매장 검색(pass 모드)"] }, "이용권 선택 목록": { entry: "이용권 구매 → 이용권 선택", options: ["카테고리 탭(전체 외)"] }, "취소 · 환불 규정": { entry: "이용권 구매 → 규정 안내" }, "구매 라우트(구)": { entry: "(주석 · 미사용)" }, "TRAINING · 라커 관리": { entry: "전체 메뉴(☰) → 트레이닝 → 라커 관리" }, "라커 관리": { entry: "전체 메뉴(☰) → 트레이닝 → 라커 관리" }, "TRAINING · 클럽측정": { entry: "전체 메뉴(☰) → 트레이닝 → 클럽측정" }, "트레이닝(클럽측정) 목록": { entry: "전체 메뉴(☰) → 트레이닝 → 클럽측정" }, "트레이닝 상세": { entry: "트레이닝 목록 → 항목" }, "트레이닝 통계": { entry: "트레이닝 상세 → 통계", options: ["클럽별 통계"] }, "SETTING · 게임 설정": { entry: "전체 메뉴(☰) → 설정 → 게임 설정" }, "게임설정 진입": { entry: "전체 메뉴(☰) → 설정 → 게임 설정", options: ["스크린(Q·T2)", "트레이닝(Q·프렌즈아카데미)"] }, "스크린 Q 설정": { entry: "게임 설정 → 스크린 Q", options: ["클럽비거리·티높이(25~60mm/5)·티박스·난이도·타석(우·좌)", "스윙플레이트·나의스윙팝업·매트룰(지형·페어웨이)", "깃대표시·퍼팅가이드"] }, "스크린 T2 설정": { entry: "게임 설정 → 스크린 T2", options: ["클럽비거리", "티높이·티박스·난이도", "타석(우·좌)"] }, "트레이닝 Q 설정": { entry: "게임 설정 → 트레이닝 Q", options: ["티높이", "그린빠르기(4단)", "스윙영상팝업·카메라뷰(고정·버드) [저장]"] }, "프렌즈아카데미 설정": { entry: "게임 설정 → 프렌즈 아카데미", options: ["티높이·스윙팝업·카메라뷰", "비거리단위(m·yd)·타석(좌·우)", "골프등급(초보~프로) [저장]"] }, "클럽별 비거리 설정": { entry: "스크린/트레이닝 설정 → 클럽별 비거리", options: ["드라이버·7Iron만 편집", "드라이버 남140~300/여100~260(10)", "7I 남90~190/여70~150(5)"] }, "SETTING · 앱 설정": { entry: "전체 메뉴(☰) → 설정 → 앱 설정" }, "앱 설정": { entry: "전체 메뉴(☰) → 설정 → 앱 설정", options: ["알림설정 이동·쉐이크 ON/OFF·단위(m·yd)", "공지·앱버전", "로그아웃"] }, "알림 설정": { entry: "앱 설정 → 알림 설정", options: ["공지 · 이벤트 · 신규매장 ON/OFF"] }, "예약 메인(통합예약)": { entry: "홈 → 통합예약" }, "예약 매장검색(picker)": { entry: "예약 메인 → 매장 검색", options: ["머신 필터(스크린 Q·T2 / 트레이닝 Q·FA)"] }, "스크린예약 · 예약": { entry: "예약 → 매장 → 스크린 예약", options: ["제품 Quantum·T2", "18H·9H / 인원 1~6명", "요청(좌타방·2게임·포섬·포볼·기타100자)"] }, "스크린예약 · 확인": { entry: "스크린 예약 → 확인", options: ["저장카드 결제"] }, "스크린예약 · 완료": { entry: "스크린 예약 확인 → 완료", options: ["뒤로가기 차단"] }, "아카데미 · 예약": { entry: "예약 → 매장 → 아카데미 예약", options: ["날짜(7일)", "시작 시간대", "예약가능 타석 선택"] }, "아카데미 · 시간": { entry: "아카데미 예약 → 타석 → 시간", options: ["시간 슬롯(분단위) 선택"] }, "아카데미 · 확인": { entry: "아카데미 예약 → 확인", options: ["이용권 선택"] }, "아카데미 · 완료": { entry: "아카데미 확인 → 완료", options: ["뒤로가기 차단"] }, "레슨 · 이용권": { entry: "예약 → 레슨 → 이용권", options: ["이용권 선택"] }, "레슨 · 예약": { entry: "레슨 이용권 → 예약", options: ["날짜 · 시간"] }, "레슨 · 이용권에서 예약": { entry: "이용권 상세 → 예약하기" }, "레슨 · 확인": { entry: "레슨 예약 → 확인" }, "레슨 · 완료": { entry: "레슨 확인 → 완료", options: ["뒤로가기 차단"] }, "예약자 정보수정": { entry: "예약 화면 → 예약자 행", options: ["이름 수정", "연락처 수정+인증"] }, "저장카드 간편결제": { entry: "예약 확인 → 결제", options: ["카드 조회·등록·선택·삭제", "비만료 카드 최대 6장"] }, "내 정보": { entry: "홈/전체메뉴 → 프로필", options: ["프로필", "본인인증(인증됨/미인증)", "비번변경(T-ID만)·이용약관"] }, "프로필 편집": { entry: "내 정보 → 프로필", options: ["닉네임·이름·성별·주소·이메일 편집"] }, "닉네임 수정": { entry: "프로필 편집 → 닉네임", options: ["닉네임 입력"] }, "이름 수정": { entry: "프로필 편집 → 이름", options: ["이름 입력"] }, "성별 수정": { entry: "프로필 편집 → 성별", options: ["남 · 여"] }, "주소 검색": { entry: "프로필 편집 → 주소", options: ["주소 검색"] }, "이메일 변경": { entry: "프로필 편집 → 이메일", options: ["이메일 입력"] }, "비밀번호 변경": { entry: "내 정보 → 비밀번호 변경(T-ID)", options: ["현재·신규 비밀번호"] }, "이용약관": { entry: "내 정보 → 이용약관" }, "본인인증(SMS)": { entry: "내 정보 → 본인인증", options: ["휴대폰 SMS 인증(PASS 웹뷰)"] }, "탈퇴 · 방식선택": { entry: "프로필 편집 → 탈퇴", options: ["스크린 탈퇴 · 통합 탈퇴"] }, "탈퇴 · TID": { entry: "탈퇴 방식 → TID", options: ["동의 + 재로그인"] }, "탈퇴 · 카카오": { entry: "탈퇴 방식 → 카카오", options: ["동의 + 카카오 인증"] }, "탈퇴 완료": { entry: "탈퇴 → 완료", options: ["뒤로가기 차단"] }, "일반 웹뷰": { entry: "코스소개 등 진입" }, "결제 웹뷰": { entry: "이용권 구매 → 웹 결제" }, "동의 웹뷰": { entry: "약관 상세" }, "대회 웹뷰": { entry: "홈 퀵메뉴 → 대회", options: ["위치·거리단위 파라미터"] }, "이벤트 웹뷰": { entry: "홈 퀵메뉴 → 이벤트" }, "본인/보호자 인증 웹뷰": { entry: "본인인증·이용권결제 → PASS" }, "QR 로그인": { entry: "하단 중앙 QR" }, "메인 컨테이너": { entry: "로그인 후 진입", options: ["더블백 종료", "종료 팝업광고 바텀시트"] }, "전체메뉴 오버레이": { entry: "홈 → ☰" }, "미포팅 placeholder": { entry: "미구현 메뉴 탭" }, "무인매장 안내": { entry: "(그리드에서 제외 · 미노출)" }, "대회 라우트 정의": { entry: "(app_route 주석)" }, "이벤트 라우트 정의": { entry: "(app_route 주석)" }, "코스 라우트 정의": { entry: "(app_route 주석)" }, "리포트 라우트 정의(구)": { entry: "(app_route 주석)" }, "설정 딥링크 경로": { entry: "딥링크(GoRoute 미등록)" }, "프로필 딥링크 경로": { entry: "딥링크(GoRoute 미등록)" }, "스윙영상 부모 경로": { entry: "(부모 path 미등록)" }, "홈 탭 alias": { entry: "딥링크 /tab/home" }, "개발자 모드": { entry: "/dev-mode (개발)" }, "샌드박스": { entry: "/sandbox (개발)" }, "공통모듈": { entry: "샌드박스 → 공통모듈" }, "웹뷰 테스트": { entry: "샌드박스 → 웹뷰 테스트" }, "디자인시스템": { entry: "샌드박스 → 디자인시스템" }, "캘린더": { entry: "샌드박스 → 캘린더" }, "카카오맵": { entry: "샌드박스 → 카카오맵" }, "딥링크 테스트": { entry: "샌드박스 → 딥링크 테스트", options: ["쿠폰(준비중) 샘플 포함"] }, "PASS인증 테스트": { entry: "샌드박스 → PASS 인증 테스트" }, "샌드박스 메뉴(중복 라우트)": { entry: "/sandbox-menu (개발 · /sandbox와 동일 화면)" } } }; const inventoryDisplayOverrideFor = (projectKey, item) => inventoryDisplayOverrides[projectKey]?.[item.feature] || {}; const inventoryFilterMatches = (state, filter) => { if (filter === "all") return true; if (filter === "active") return state === "active"; if (filter === "limited") return state === "partial" || state === "mock" || state === "external"; return ["hidden", "commented", "disabled", "dev", "absent"].includes(state); }; const fmsClassify = (group, feature) => { const has = (re) => re.test(feature); if (/로그인|인증/.test(feature) || /로그인/.test(group)) return { major: "계정 · 로그인", submenu: "로그인", screen: null }; if (/타석|예약/.test(group)) { if (has(/이용내역|이용 내역|사용시간|이용시간/)) return { major: "타석 · 예약", submenu: "타석이용내역", screen: "fms-bay-history", title: "타석 이용내역 화면" }; if (has(/예약 조회|예약조회|예약 리스트|조회|이력/)) return { major: "타석 · 예약", submenu: "예약 조회", screen: "fms-reservation-list", title: "예약 조회 화면" }; if (has(/예약/)) return { major: "타석 · 예약", submenu: "전체 예약 현황", screen: "fms-reservation", title: "전체 예약 현황 화면" }; return { major: "타석 · 예약", submenu: "타석", screen: "fms-machine", title: "타석 현황 화면" }; } if (/결제/.test(group)) return { major: "결제", submenu: "결제", screen: "fms-payment", title: "결제 화면" }; if (/매장|회원/.test(group)) { if (has(/SMS|문자/)) return { major: "매장 · 회원", submenu: "SMS관리", screen: "fms-sms", title: "SMS관리 화면" }; if (has(/라커/)) return { major: "매장 · 회원", submenu: "라커관리", screen: "fms-locker", title: "라커관리 화면" }; if (has(/직원|메뉴권한|권한/)) return { major: "매장 · 회원", submenu: "직원관리", screen: "fms-staff", title: "직원관리 화면" }; if (has(/매장 기본|매장정보|매장 설정|운영시간|휴업|영업일|주차|매장 사진|매장 운영|개점|마감/)) return { major: "매장 · 회원", submenu: "매장관리", screen: "fms-shopinfo", title: "매장관리 화면" }; if (has(/레슨/)) return { major: "매장 · 회원", submenu: "회원관리", screen: "fms-lesson", title: "레슨회원 화면" }; return { major: "매장 · 회원", submenu: "회원관리", screen: "fms-members", title: "회원관리 화면" }; } if (/매출|정산/.test(group)) { if (has(/정산|대사|이용료|계산/)) return { major: "매출 · 정산", submenu: "정산", screen: "fms-calculate", title: "정산 화면" }; return { major: "매출 · 정산", submenu: "매출", screen: "fms-sales", title: "매출 화면" }; } if (/상품/.test(group)) { if (has(/판매내역|판매 내역|환불/)) return { major: "상품", submenu: "판매내역", screen: "fms-product-sales", title: "상품 판매내역 화면" }; return { major: "상품", submenu: "상품현황", screen: "fms-product", title: "상품현황 화면" }; } if (/설정|고객/.test(group)) { if (has(/공지|FAQ|자료실|포스터|고객지원|안내/)) return { major: "설정 · 고객", submenu: "공지사항", screen: "fms-notice", title: "공지사항 화면" }; return { major: "설정 · 고객", submenu: "설정", screen: "fms-settings", title: "설정 화면" }; } const parts = group.split(/\s*·\s*/).filter(Boolean); return { major: parts[0] || "기타", submenu: parts.slice(1).join(" · ") || "전체", screen: null }; }; const qmsClassify = (group, feature) => { const has = (re) => re.test(feature); if (/로그인/.test(group)) return { major: "계정 · 로그인", submenu: "로그인" }; if (/타석 · 예약/.test(group)) return { major: "타석 · 예약", submenu: "타석" }; if (/^예약$/.test(group)) return { major: "타석 · 예약", submenu: "예약" }; if (/대회/.test(group)) return { major: "대회", submenu: "우리매장대회" }; if (/상품/.test(group)) return { major: "상품", submenu: has(/판매/) ? "판매 내역" : "상품 관리" }; if (/정산|결제/.test(group)) { if (has(/무이자|할부/)) return { major: "정산", submenu: "무이자 할부" }; if (has(/결제|POS|카드|현금|영수증|대사|장바구니|승인|쿠폰 적용/)) return { major: "정산", submenu: "결제" }; return { major: "정산", submenu: "정산" }; } if (/회원|SMS/.test(group)) { if (has(/SMS|문자/)) return { major: "회원 · SMS", submenu: "SMS" }; if (has(/라커/)) return { major: "회원 · SMS", submenu: "라커" }; return { major: "회원 · SMS", submenu: "회원" }; } if (/매장|운영/.test(group)) return { major: "매장 · 운영", submenu: has(/매장정보|매장 기본|사진|임시 운영시간|휴업일/) ? "매장 정보" : "운영 설정" }; if (/고객지원|지원/.test(group)) { if (has(/FAQ/)) return { major: "고객지원", submenu: "FAQ" }; if (has(/자료실|다운로드/)) return { major: "고객지원", submenu: "자료실" }; if (has(/포스터/)) return { major: "고객지원", submenu: "포스터신청" }; return { major: "고객지원", submenu: "공지사항" }; } if (/쿠폰|QR 범위/.test(group)) return { major: "할인쿠폰", submenu: "할인쿠폰" }; const parts = group.split(/\s*·\s*/).filter(Boolean); return { major: parts[0] || "기타", submenu: parts.slice(1).join(" · ") || "전체" }; }; const inventoryGroupHierarchy = (projectKey, item) => { const feature = friendly(item.feature); if (projectKey === "fms") { const c = fmsClassify(friendly(item.group), feature); return { major: c.major, submenu: c.submenu }; } if (projectKey === "qms") { const c = qmsClassify(friendly(item.group), feature); return { major: c.major, submenu: c.submenu }; } if (["golfhubBranchWeb", "qrWeb"].includes(projectKey)) { if (/라커/.test(feature)) return { major: "매장", submenu: "라커" }; if (/레슨프로|레슨 프로|레슨 스케줄/.test(feature)) return { major: "매장", submenu: "레슨" }; if (/직원|메뉴권한/.test(feature) && !/로그인|인증/.test(feature)) return { major: "매장", submenu: "직원" }; if (/문자|SMS/.test(feature)) return { major: "회원", submenu: "SMS" }; } if (projectKey === 'qapp') { const qp = friendly(item.group).split(/\s*·\s*/).filter(Boolean); const qMajor = qp[0] || '기타'; let qSub = qp.slice(1).join(' · '); if (!qSub) qSub = qappSingleSubmenu(qMajor, feature); return { major: qMajor, submenu: qSub }; } const parts = friendly(item.group).split(/\s*·\s*/).filter(Boolean); return { major: parts[0] || "기타", submenu: parts.slice(1).join(" · ") || "전체" }; }; const inventoryComparisonGroup = (projectKey, item) => { const meta = inventoryProjectMeta[projectKey]; const group = friendly(item.group); const feature = friendly(item.feature); if (meta.mode === "web") { if (/문자|SMS|라커|POS|현금|카드 결제|영수증|대사|정산|매출|판매|공지|고객지원|서비스 이용료/.test(feature)) return "현장 운영 · POS·정산·라커 등"; if (/직원|권한|레슨프로/.test(feature)) return "계정 · 공통"; if (/상품|패키지|이용권/.test(feature)) return "회원 · 멤버십"; if (/계정|공통|인증|로그인/.test(group)) return "계정 · 공통"; if (/회원|멤버십/.test(group)) return "회원 · 멤버십"; if (/대회/.test(group)) return "대회"; if (/무인|설정/.test(group)) return "매장 설정 · 무인 운영"; if (/타석/.test(group) && /타석|오픈|종료|이동|시간변경|이용시간|정렬/.test(feature)) return "타석 · 라운드 운영"; if (/예약/.test(group) || /예약/.test(feature)) return "예약"; return "현장 운영 · POS·정산·라커 등"; } if (/대회/.test(group)) return "대회"; if (/예약|매장/.test(group)) return "매장 찾기 · 예약"; if (/이용권|멤버십|결제|QR/.test(group)) return "멤버십 · 결제"; if (/리포트|스윙/.test(group)) return "스코어 · 분석 · 훈련"; if (/숨김/.test(group)) return "콘텐츠 · 진입/숨김 구분"; return "계정 · 홈"; }; const inventoryScreenGroupIdFor = (projectKey, item) => { const meta = inventoryProjectMeta[projectKey]; const feature = friendly(item.feature); const group = friendly(item.group); if (meta.mode === "web") { if (projectKey === "golfhubBranchWeb") { if (/공통|인증|로그인/.test(group)) return "web-login"; if (/예약 현황/.test(group)) { return /주간|일자|타임라인|그리드|캘린더/.test(feature) ? "web-reservation-overview" : "web-reservation-detail"; } if (/예약 내역/.test(group)) { return /상세|변경|취소|출입|PIN/.test(feature) ? "web-reservation-detail" : "web-reservation-history"; } if (/대회/.test(group)) { if (/생성|마법사/.test(feature)) return "web-competition-form"; if (/본사|수락|거절|opt/i.test(feature)) return "web-competition-hqoptin"; if (/리더보드/.test(feature)) return "web-competition-leaderboard"; return "web-competition"; } if (/매출/.test(group)) return "web-sales"; if (/멤버십 관리/.test(group)) return "web-membership"; if (/회원/.test(group)) return /LTV|성향|활동|이력|메모|구매·방문/.test(feature) ? "web-member-history" : "web-member"; if (/무인/.test(group)) return "web-unmanned"; if (/매장 관리/.test(group)) { if (/매장정보/.test(group)) return "web-store-info"; if (/운영관리/.test(group)) return "web-store-ops"; if (/예약설정/.test(group)) return "web-store-booking"; if (/타석설정/.test(group)) return "web-store-bays"; return "web-store-settings"; } } if (projectKey === "golfhubCentralWeb") { if (/로그인|인증/.test(feature)) return "web-central-login"; if (/티어/.test(group)) return "web-central-grades"; if (/멤버십 템플릿/.test(group)) return "web-central-plans"; if (/혜택/.test(group)) return "web-central-benefits"; if (/대회 관리/.test(group)) { if (/개인/.test(group)) return "web-central-personal-competition"; if (/매장 대회/.test(group)) return "web-central-store-competition"; if (/생성/.test(feature)) return "web-central-hq-comp-create"; if (/투어/.test(feature)) return "web-central-hq-tours"; return "web-central-hq-competition"; } if (/매장 회원/.test(group)) return /상세|CS|메모/.test(feature) ? "web-central-user-detail" : "web-central-users"; if (/매출관리/.test(group)) { if (/Bay|타석/.test(group)) return "web-central-bay-sales"; if (/대회 참가비/.test(group)) return "web-central-comp-sales"; return "web-central-membership-sales"; } if (/매장 관리/.test(group)) return /목록|검색/.test(feature) ? "web-central-store-list" : "web-central-store-detail"; } // QMS의 예약 등록 입력은 예약 상세 이미지가 아니라, 실제 Figma 예약 현황 // 화면에서 `예약하기`를 눌러 열린 모달 위에 표시한다. 프로젝트 상세에서도 // 이 화면을 직접 연결해야 source-backed 입력 컨트롤에 도달할 수 있다. if (projectKey === "qms" && /예약현황 페이지|예약현황 스케줄 테이블|예약 입력 폼|예약 저장\/수정\/확정/.test(feature)) return "web-reservation-overview"; if (projectKey === "qms" && /예약조회 페이지|예약 리스트 조회|타석 사용시간 팝업/.test(feature)) return "web-reservation-history"; if (projectKey === "qms" && /예약 상세 폼|예약 취소/.test(feature)) return "web-reservation-detail"; if (/2차인증|2차 인증|PC 인증|보안/.test(feature)) return "web-security"; if (/로그인|로그아웃|세션/.test(feature)) return "web-login"; if (/문자|SMS/.test(feature)) return "web-messaging"; if (/라커/.test(feature)) return "web-locker"; if (/레슨프로|레슨 프로|레슨 스케줄/.test(feature)) return "web-lesson"; if (/직원|권한/.test(feature)) return "web-staff"; if (/대회/.test(group) && /생성|등록|수정|설정|조건|코스|티박스|공개|시상|홀 개수/.test(feature)) return "web-competition-form"; if (/대회/.test(group)) return "web-competition"; if (/무인|PIN|키오스크|장치/.test(feature) || /무인/.test(group)) return "web-unmanned"; if (/POS|현금|카드 결제|영수증|장바구니/.test(feature)) return "web-pos"; if (/대사|정산|매출|판매내역/.test(feature)) return "web-sales"; if (/공지|고객지원|서비스 이용료|A\/S/.test(feature)) return "web-support"; if (/상품|패키지/.test(feature)) return "web-membership"; } return screenGroupIdForDetail(meta.mode, inventoryComparisonGroup(projectKey, item), feature); }; const inventoryDirectScreen = (id, title, shot, note = "") => ({ screenGroup: { id, title, shots: [shot], note }, shot, representative: false, availability: null, interactionEnabled: true }); const inventoryExactScreenFor = (projectKey, item) => { if (item.qrScreen) { const qr = item.qrScreen; const shot = { img: qr.image, fullImg: qr.image, title: qr.title, source: qr.source, note: qr.definition }; return { screenGroup: { id: qr.id, title: qr.title, shots: [shot], note: qr.definition }, shot, representative: false, availability: null, interactionEnabled: true, runtime: qr.runtime, origin: 'qr', bootstrap: qr.bootstrap }; } const feature = friendly(item.feature); const group = friendly(item.group); const qmsScreen = (key, title) => inventoryDirectScreen( `qms-${key}`, title, qmsLiveImage(key, title, `${group} · 프로젝트별 상세`) ); const fmsScreen = (key, title) => inventoryDirectScreen( `fms-${key}`, title, fmsFigmaImage(key, title, `${group} · 프로젝트별 상세`) ); const fmsLiveScreen = (key, title) => inventoryDirectScreen(key, title, fmsLiveImage(key, title)); const qappScreen = (key, title) => inventoryDirectScreen( `qapp-${key}`, title, qappPrototypeImage(key, title) ); const centralScreen = (key, title, note = "") => { const shot = webScreenLookup.golfhubCentral?.get(key); return shot ? inventoryDirectScreen(`central-${key.replace(/^central-/, "")}`, title, shot, note) : { screenGroup: { id: `central-${key.replace(/^central-/, "")}`, title, shots: [], note }, shot: { availability: "no-screen", title, note: note || "연결할 본사 QA 화면을 확보하지 못했습니다." }, representative: false, interactionEnabled: false, availability: "no-screen" }; }; const centralNoScreen = (id, title, note) => ({ screenGroup: { id, title, shots: [], note }, shot: { availability: "no-screen", title, note }, representative: false, interactionEnabled: false, availability: "no-screen" }); // 본사 관리 화면은 아래 일반 경로(inventoryScreenGroupIdFor)의 web-central-* 캡처 그룹으로 해석한다. if (projectKey === "qms") { const qmsNoScreen = (key, title, note) => ({ screenGroup: { id: `qms-${key}`, title, shots: [], note }, shot: imageUnavailable("qms", "QMS", title, "no-screen", note), representative: false, interactionEnabled: false, availability: "no-screen" }); if (/로그인|로그아웃|원툴|Q ?매장|서비스 타입|인증번호|신뢰 PC|이용약관|통합계정/.test(feature)) return qmsScreen("login", "QMS 로그인·인증 화면"); if (/운영 설정 페이지|예약 정책|비밀번호 설정|직원 설정|직원 등록|직원.*삭제|타석 설정|타석 운영 로그|보안 설정|PC 2차 인증 설정|통신사 인증/.test(feature)) return qmsScreen("ops-password", "QMS 운영 설정 화면"); if (/예약 입력|예약 저장|예약 폼|예약 등록/.test(feature)) return qmsScreen("reservation-form", "QMS 예약 등록 팝업"); if (/예약현황|예약 드래그/.test(feature)) return qmsScreen("reservation-dashboard", "QMS 예약 현황·등록 화면"); if (/예약조회|예약별 실제 이용시간/.test(feature)) return qmsScreen("reservation-lookup", "QMS 예약 조회 화면"); if (/예약 취소/.test(feature)) return qmsScreen("reservation-detail", "QMS 예약 상세·취소 화면"); if (/스코어카드|증서/.test(feature) && !/대회/.test(feature)) return qmsScreen("scorecard", "QMS 스코어카드 팝업(증서 출력)"); if (/타석이용내역|검색 필터/.test(feature)) return qmsScreen("bay-history", "QMS 타석 이용 내역 화면"); if (/타석현황|타석 정렬|타석 오픈|타석정보|시간변경|타석이동|타석 종료|라운드 설정|장비 이벤트/.test(feature)) return qmsScreen("bay-live", "QMS 실시간 타석 운영 화면"); if (/우리매장대회|대회 상세|대회 랭킹|대회 스코어카드|대회 결과|투어대회/.test(feature)) return qmsScreen("competition-list", "QMS 우리매장대회 화면"); if (/대회 등록|대회 저장|코스·홀/.test(feature)) return qmsScreen("competition-create", "QMS 대회 등록·설정 화면"); if (/회원 페이지|회원 목록|회원 상세|회원 이용권|회원 예약|회원 운영|회원 임시|회원 Excel/.test(feature)) return qmsScreen("members", "QMS 회원 관리 화면"); if (/라커/.test(feature)) return qmsScreen("locker", "QMS 라커 관리 화면"); if (/SMS/.test(feature)) return qmsScreen("sms", "QMS SMS 화면"); if (/매장정보|매장 기본|사진|운영시간|휴업일/.test(feature)) return qmsScreen("store-info", "QMS 매장 정보 화면"); if (/공지사항|FAQ|자료실|포스터|고객지원/.test(feature)) return qmsScreen("support", "QMS 고객지원 화면"); if (/쿠폰/.test(feature)) return qmsScreen("coupon", "QMS 할인쿠폰 화면"); if (/이용료 정산|정산 내역|A\/S 정산|매장 요금|문자 요금|결제 내역|할부/.test(feature)) return qmsScreen("settlement", "QMS 정산·결제 화면"); if (/상품|POS|카드·현금|결제 승인|영수증|결제 대사|결제 완료/.test(feature)) return qmsNoScreen("internal-payment", "QMS 내부 상품·결제 화면", "KR main에 controller·view는 있으나 live 메뉴에 연결된 독립 화면이 아니거나 상품 GNB가 숨겨져 있습니다."); } if (projectKey === "fms") { const c = fmsClassify(group, feature); if (c.screen) return fmsLiveScreen(c.screen, c.title); return fmsScreen("login", "매장주·직원 로그인 화면"); } return null; }; const qappSingleSubmenu = (major, feature) => { const h = (...t) => t.some((x) => feature.includes(x)); if (major.startsWith('리포트')) return h('기록분석', '상세') ? '기록 분석' : '리포트 홈'; if (major.startsWith('매장찾기')) return h('상세', '이미지', '프로') ? '매장 상세' : '지도·검색'; if (major.startsWith('스윙영상')) { if (h('업로드', '신고', '커뮤니티')) return '커뮤니티'; if (h('그리기', '드로잉')) return '영상 편집'; if (h('외부', '글로벌')) return '외부 진입'; return '영상 목록'; } if (major.startsWith('예약')) { if (h('아카데미')) return '아카데미 예약'; if (h('레슨')) return '레슨 예약'; if (h('스크린')) return '스크린 예약'; if (h('예약자', '간편결제', '결제')) return '예약 결제'; return '예약 시작'; } if (major.startsWith('웹뷰')) return '웹뷰'; if (major.startsWith('개발')) return '샌드박스'; if (major.startsWith('콘텐츠')) return '콘텐츠'; return major; }; const qappInventoryScreen = (item) => { const f = friendly(item.feature || ''); const ev = friendly(item.evidence || ''); const g = friendly(item.group || ''); const hay = f + ' ' + ev + ' ' + g; const H = (...t) => t.some((x) => hay.includes(x)); const S = (id, title) => ({ id: 'qapp-' + id, title, shots: [], note: '' }); if (g.startsWith('숨김')) return S('hidden', '숨김·미연결 화면'); if (g.startsWith('개발')) return S('dev', '개발용 화면'); if (g.startsWith('웹뷰')) return S('webview', '웹뷰 화면'); if (g.startsWith('QR')) { if (H('메인 컨테이너', 'MainScreen', '오버레이')) return S('main', '메인 컨테이너 화면'); if (H('미포팅', '준비중', 'under-construction')) return S('todo', '미포팅 화면'); return S('qr', 'QR 로그인 화면'); } if (g.startsWith('진입')) { if (H('회원가입')) return S('signup', '회원가입 화면'); if (H('권한')) return S('permission', '권한 안내 화면'); if (H('로그인', '직접입력', 'SNS')) return S('login', '로그인 화면'); return S('boot', '앱 시작·점검 화면'); } if (g.startsWith('콘텐츠')) return S('notice', '공지사항 화면'); if (g.startsWith('전체메뉴') && H('공지', '/notice')) return S('appset', '앱 설정 화면'); if (H('아카데미') && !H('설정')) return S('res-academy', '아카데미 예약 화면'); if (H('레슨') && H('예약', '이용권', '확인', '완료', 'lesson')) return S('res-lesson', '레슨 예약 화면'); if (H('스크린예약')) return S('res-screen', '스크린 예약 화면'); if (H('예약자 정보', '예약자 정보수정')) return S('res-user', '예약자 정보 화면'); if (H('간편결제')) return S('res-pay', '예약 결제 화면'); if (H('예약 메인', '예약 매장검색', '통합예약')) return S('res-start', '예약 시작 화면'); if (H('/menu/training-ticket', '이용권 관리', 'training-ticket')) return S('pass', '이용권 관리 화면'); if (H('/shop-pass', '이용권 구매', 'shop-pass', 'shop_pass')) return S('shoppass', '이용권 구매 화면'); if (H('/point-cash', '포인트', '캐시')) return S('point', '포인트·캐시 화면'); if (H('/coupon', '쿠폰')) return S('coupon', '쿠폰 화면'); if (H('/booking-list', '/booking-detail', '/booking-cancel', '예약내역', '예약 상세', '예약 취소', '취소 완료', '다가오는 예약')) return S('booking', '예약 내역 화면'); if (H('/scorecard', '스코어카드')) return S('scorecard', '스코어카드 화면'); if (H('/training-record', '/training-stats', '클럽측정', '트레이닝 기록', '트레이닝 상세', '트레이닝 통계')) return S('training', '클럽측정·트레이닝 화면'); if (H('/report', '/tab/report', '리포트', '기록분석')) return S('report', '리포트 화면'); if (H('/tab/swing', '/swing-video', '스윙영상', '스윙 영상', '커뮤니티', '드로잉', '영상 상세', '영상 레슨')) return S('swing', '스윙영상 화면'); if (H('hole-in-one-certificate', '증서')) return S('cert', '증서 화면'); if (H('/menu/locker', '라커')) return S('locker', '라커 화면'); if (H('/game-setting', '게임 설정')) return S('gameset', '게임 설정 화면'); if (H('/setting-app', '/setting-alarm', '앱 설정', '알림 설정')) return S('appset', '앱 설정 화면'); if (H('/my-info', '/certificate', '내 정보', '프로필', '닉네임', '이름 수정', '성별', '주소', '이메일', '비밀번호', '약관', '본인인증', '탈퇴')) return S('myinfo', '내 정보·계정 화면'); if (H('/webview-competition', '대회')) return S('competition', '대회 화면'); if (H('/webview-event', '이벤트')) return S('event', '이벤트 화면'); if (H('코스')) return S('course', '코스 소개 화면'); if (H('/notice', '공지')) return S('notice', '공지사항 화면'); if (H('/shop-detail', '/shop-image', '/shop-pro', '매장 상세', '매장 이미지', '매장 프로')) return S('shopdetail', '매장 상세 화면'); if (H('매장 검색', '지도 검색')) return S('findshopmap', '매장찾기 지도 화면'); if (H('/tab/find-shop', '매장찾기')) return S('findshop', '매장찾기 화면'); if (H('/qr-login', 'QR 로그인', 'QR 본인', '스크린 QR')) return S('qr', 'QR 로그인 화면'); if (H('home_drawer', '드로어 헤더', '메뉴 배너', '전체메뉴')) return S('drawer', '전체 메뉴 화면'); if (H('feature_main', 'MainScreen', '메인 컨테이너')) return S('main', '메인 컨테이너 화면'); return S('home', '홈 화면'); }; const inventoryScreenFor = (projectKey, item) => { const exact = inventoryExactScreenFor(projectKey, item); if (exact) return exact; if (projectKey === 'qapp') { const sc = qappInventoryScreen(item); const fsappScreens = { 'qapp-home': 'home', 'qapp-login': 'onboarding', 'qapp-findshop': 'findshop', 'qapp-scorecard': 'scorecard', 'qapp-res-start': 'reservation', 'qapp-course': 'course', 'qapp-report': 'report', 'qapp-training': 'clubmeasure', 'qapp-competition': 'competition', 'qapp-event': 'event', 'qapp-swing': 'swing', 'qapp-shoppass': 'training', 'qapp-pass': 'ticketmanage', 'qapp-booking': 'booking', 'qapp-myinfo': 'myinfo', 'qapp-boot': 'splash', 'qapp-signup': 'signup', 'qapp-permission': 'permission', 'qapp-drawer': 'allmenu', 'qapp-notice': 'notice', 'qapp-gameset': 'gameset', 'qapp-appset': 'appset', 'qapp-point': 'point', 'qapp-coupon': 'coupon', 'qapp-cert': 'cert', 'qapp-res-screen': 'res-time', 'qapp-res-user': 'res-confirm', 'qapp-res-pay': 'res-confirm', 'qapp-res-lesson': 'res-bay', 'qapp-qr': 'qr', 'qapp-swing': 'swing', 'qapp-locker': 'locker', 'qapp-shopdetail': 'shopdetail', 'qapp-findshopmap': 'findshop&view=map' }; const fsScreen = fsappScreens[sc.id]; if (fsScreen) { const shot = { availability: null, title: sc.title, source: 'FriendsScreen 통합앱 Flutter · 인터랙티브 프로토타입', note: 'FriendsScreen 통합앱 — Flutter 코드 + Figma 디자인 기반 인터랙티브 프로토타입입니다.' }; return { screenGroup: { id: sc.id, title: sc.title, shots: [], note: '' }, shot, runtime: 'prototypes/fsapp.html?screen=' + fsScreen, representative: false, interactionEnabled: true, availability: null }; } return { screenGroup: sc, shot: { availability: 'no-screen', title: sc.title, note: 'FSApp 단독 IA — 화면 영역명을 표시합니다.' }, representative: false, interactionEnabled: false, availability: 'no-screen' }; } const meta = inventoryProjectMeta[projectKey]; const mode = meta.mode; const groupId = inventoryScreenGroupIdFor(projectKey, item); const fallbackId = detailFallbackGroup[mode]; const screenGroup = screenLevelGroups[mode].get(groupId) || screenLevelGroups[mode].get(fallbackId); if (projectKey === "golfhubApp") { const sc = ghAppInventoryScreen(item); const ghShotKey = ghAppScreenShotKeys[sc.id]; if (ghShotKey) { return { screenGroup: sc, shot: { ...ghAppShot(ghShotKey, sc.title), note: "GolfHub Edge 모바일 웹 프로토타입 화면입니다. 시스템 비교의 GolfVX Flutter Figma 화면과 분리해 표시합니다." }, representative: false, interactionEnabled: true, availability: null }; } return { screenGroup: sc, shot: { availability: "no-screen", title: sc.title, note: "현재 GolfHub Edge 모바일 웹 프로토타입 캡처에는 이 화면의 독립 근거가 없습니다. GolfVX Flutter 또는 FSApp 화면으로 대체하지 않습니다." }, representative: false, interactionEnabled: false, availability: "no-screen" }; } const shot = screenGroup?.shots.find((candidate) => candidate.systemKey === meta.systemKey); if (shot?.img) { const projectOwnInteraction = ["golfhubBranchWeb", "golfhubCentralWeb", "golfhubApp"].includes(projectKey) && !shot.representative; return { screenGroup, shot, representative: !!shot.representative, interactionEnabled: projectOwnInteraction, availability: null }; } const inactive = ["absent", "hidden", "commented", "disabled"].includes(item.state); const externalQappScreen = projectKey === "qapp" && /대회/.test(item.group); if ((shot?.availability === "no-screen" || shot?.availability === "no-feature") && (inactive || externalQappScreen)) { return { screenGroup, shot, representative: false, availability: shot.availability }; } const fallbackGroup = screenLevelGroups[mode].get(fallbackId); const fallbackShot = fallbackGroup?.shots.find((candidate) => candidate.systemKey === meta.systemKey && candidate.img); if (!fallbackShot) return { screenGroup, shot: null, representative: false, availability: "no-screen" }; return { screenGroup, shot: { ...fallbackShot, note: [fallbackShot.note, `${screenGroup?.title || friendly(item.feature)}의 독립 화면을 확보하지 못해 같은 프로젝트의 대표 운영 화면을 표시합니다.`].filter(Boolean).join(" ") }, representative: true, interactionEnabled: false, availability: null }; }; const inventoryTabDefinitionFor = (projectKey, screenInfo, items) => { const group = screenInfo?.screenGroup; if (items.some((item) => item.origin === 'qr') && group?.note) return group.note; const definition = inventoryProjectTabDefinitionOverrides[projectKey]?.[group?.id] || inventoryTabDefinitionOverrides[group?.id]; if (definition) return definition; const title = friendly(group?.title || items[0]?.group || "이 화면"); const actions = [...new Set(items.map((item) => friendly(item.feature)).filter(Boolean))].slice(0, 2); const fallback = actions.length ? `${title}에서 ${actions.join("·")}을 확인·관리합니다.` : `${title}의 주요 기능을 확인·관리합니다.`; return fallback; }; // 메뉴 사이의 선은 화면 묶음이 아니라 실제 메뉴·동작 근거가 확인된 흐름만 둔다. const inventoryMenuRelations = { golfhubBranchWeb: [ { from: "web-reservation-detail", to: "web-member", label: "회원 확인" }, { from: "web-reservation-detail", to: "web-sales", label: "결제·매출 반영" }, { from: "web-membership", to: "web-member", label: "이용권 적용" }, { from: "web-competition", to: "web-competition-form", label: "대회 생성" }, { from: "web-store-booking", to: "web-reservation-detail", label: "예약 정책" } ], golfhubCentralWeb: [ { from: "web-central-benefits", to: "web-central-grades", label: "혜택 부여" }, { from: "web-central-benefits", to: "web-central-plans", label: "혜택 구성" }, { from: "web-central-grades", to: "web-central-plans", label: "티어 연동" }, { from: "web-central-plans", to: "web-central-membership-sales", label: "판매 집계" }, { from: "web-central-hq-comp-create", to: "web-central-hq-competition", label: "대회 생성" }, { from: "web-central-hq-competition", to: "web-central-comp-sales", label: "참가비 분석" }, { from: "web-central-users", to: "web-central-user-detail", label: "회원 상세" }, { from: "web-central-store-list", to: "web-central-store-detail", label: "매장 상세" }, { from: "web-central-store-detail", to: "web-central-bay-sales", label: "매장 매출" } ], golfhubApp: [ { from: "app-auth", to: "app-home", label: "고객 진입" }, { from: "app-home", to: "app-store", label: "매장 선택" }, { from: "app-store", to: "app-reservation-search", label: "예약 조건" }, { from: "app-reservation-search", to: "app-reservation-payment", label: "견적·결제" }, { from: "app-reservation-payment", to: "app-reservation-detail", label: "예약 완료" }, { from: "app-membership", to: "app-purchase", label: "이용권 구매" }, { from: "app-competition", to: "app-competition-entry", label: "참가 신청" }, { from: "app-reservation-payment", to: "external-square", label: "결제 승인", type: "external" } ], qms: [ { from: "qms-login", to: "qms-bay-live", label: "운영 진입" }, { from: "qms-bay-live", to: "qms-reservation-dashboard", label: "예약 등록" }, { from: "qms-reservation-dashboard", to: "qms-reservation-detail", label: "상세·수정" }, { from: "qms-bay-live", to: "qms-bay-history", label: "이용 이력" }, { from: "qms-competition-list", to: "qms-competition-create", label: "대회 등록" }, { from: "qms-store-info", to: "qms-bay-live", label: "운영시간 반영" }, { from: "qms-login", to: "qms-members", label: "회원 운영" }, { from: "qms-members", to: "qms-locker", label: "라커 관리" }, { from: "qms-members", to: "qms-sms", label: "문자 발송" }, { from: "qms-store-info", to: "qms-staff-management", label: "직원 설정" }, { from: "qms-staff-management", to: "qms-security-settings", label: "보안 설정" }, { from: "qms-login", to: "qms-settlement", label: "정산·결제" }, { from: "qms-settlement", to: "qms-support", label: "고객지원" }, { from: "qms-login", to: "external-qms-account", label: "가입·계정찾기", type: "external" } ], qrWeb: [ { from: "qr-web-login", to: "qr-web-reservation-dashboard", label: "운영 진입" }, { from: "qr-web-reservation-dashboard", to: "qr-web-bay-operations", label: "실시간 타석" }, { from: "qr-web-reservation-dashboard", to: "qr-web-member-tickets", label: "이용권 확인" }, { from: "qr-web-product-pos", to: "qr-web-pos-sale", label: "현장 판매" }, { from: "qr-web-pos-sale", to: "qr-web-payment-point", label: "결제 처리" }, { from: "qr-web-store-settings", to: "qr-web-bay-operations", label: "운영 기준 반영" } ], fms: [ { from: "fms-login", to: "fms-bay-live", label: "운영 진입" }, { from: "fms-bay-live", to: "fms-reservation-detail", label: "예약 상세" }, { from: "fms-reservation-detail", to: "fms-payment-methods", label: "미결제 결제" }, { from: "fms-product-management", to: "fms-pos-sale", label: "상품 판매" }, { from: "fms-pos-sale", to: "fms-payment-methods", label: "결제 승인" }, { from: "fms-payment-methods", to: "web-sales", label: "매출 반영" }, { from: "web-member", to: "fms-reservation-detail", label: "이용권 사용" }, { from: "fms-reservation-settings", to: "fms-reservation-detail", label: "예약 규칙" }, { from: "fms-bay-settings", to: "fms-bay-live", label: "타석 설정" }, { from: "fms-sms-compose", to: "fms-sms-history", label: "발송 결과" }, { from: "fms-reconcile", to: "web-sales", label: "결제 대사" }, { from: "fms-login", to: "external-fms-account", label: "통합 계정", type: "external" }, { from: "fms-payment-methods", to: "external-fms-payment", label: "결제 단말·PG", type: "external" } ], qapp: [ { from: "qapp-login", to: "qapp-home", label: "로그인 완료" }, { from: "qapp-home", to: "qapp-drawer", label: "전체 메뉴" }, { from: "qapp-home", to: "qapp-res-start", label: "통합예약" }, { from: "qapp-res-start", to: "qapp-res-screen", label: "스크린 예약" }, { from: "qapp-res-screen", to: "qapp-res-pay", label: "결제·확정" }, { from: "qapp-res-start", to: "qapp-res-academy", label: "아카데미" }, { from: "qapp-res-start", to: "qapp-res-lesson", label: "레슨" }, { from: "qapp-home", to: "qapp-booking", label: "예약 내역" }, { from: "qapp-home", to: "qapp-scorecard", label: "스코어카드" }, { from: "qapp-home", to: "qapp-report", label: "리포트" }, { from: "qapp-home", to: "qapp-qr", label: "QR 로그인" }, { from: "qapp-findshop", to: "qapp-res-start", label: "예약 진입" }, { from: "qapp-drawer", to: "qapp-pass", label: "이용권 관리" }, { from: "qapp-drawer", to: "qapp-point", label: "포인트·캐시" }, { from: "qapp-drawer", to: "qapp-cert", label: "증서" }, { from: "qapp-drawer", to: "qapp-locker", label: "라커" }, { from: "qapp-drawer", to: "qapp-shoppass", label: "이용권 구매" }, { from: "qapp-drawer", to: "qapp-appset", label: "설정" }, { from: "qapp-drawer", to: "qapp-myinfo", label: "내 정보" }, { from: "qapp-shoppass", to: "external-qapp-payment", label: "WebView 결제", type: "external" } ], qrApp: [ { from: "qr-app-login", to: "qr-app-onboarding", label: "서비스 선택" }, { from: "qr-app-onboarding", to: "qr-app-home", label: "홈 진입" }, { from: "qr-app-home", to: "qr-app-unified-booking", label: "빠른 예약" }, { from: "qr-app-unified-booking", to: "qr-app-screen-booking", label: "스크린 예약" }, { from: "qr-app-unified-booking", to: "qr-app-training-booking", label: "트레이닝 예약" }, { from: "qr-app-unified-booking", to: "qr-app-lesson-booking", label: "레슨 예약" }, { from: "qr-app-screen-booking", to: "qr-app-booking-history", label: "예약 확인" }, { from: "qr-app-ticket", to: "qr-app-lesson-booking", label: "이용권 적용" } ] }; const inventoryMenuExternalNodes = { golfhubApp: [{ id: "external-square", title: "Square 결제", submenu: "결제" }], qms: [{ id: "external-qms-account", title: "통합 가입·계정찾기", submenu: "회원 연동" }], fms: [ { id: "external-fms-account", title: "카카오VX 통합계정", submenu: "계정" }, { id: "external-fms-payment", title: "결제 단말·PG", submenu: "결제" } ], qapp: [{ id: "external-qapp-payment", title: "WebView 이용권 결제", submenu: "결제" }] }; // 프로젝트별 실제 메뉴 구조와 메뉴 간 연계 흐름을 함께 보여준다. const inventoryActualNavigation = { golfhubBranchWeb: { label: "사이드바 leaf 9개", entries: "예약 현황·내역, 대회, 멤버십 상품, Bay·멤버십 매출, 회원, 무인 운영, 설정. 생성·상세는 비메뉴 하위 route다.", source: "apps/branch_web/components/layout/app-sidebar.tsx" }, golfhubCentralWeb: { label: "사이드바 leaf 12개", entries: "등급·플랜·혜택, 본사·매장·개인 대회, 회원, 3종 매출, 매장, 무인 관리. 투어·상세·생성은 비메뉴 하위 route다.", source: "apps/central_web/components/layout/app-sidebar.tsx" }, qms: { label: "활성 GNB 7개 · 활성 submenu 16개 · 정의된 tab 21개", entries: "타석·예약 2 submenu·4 tab, 대회 1 submenu, 정산 3 submenu·10 tab, 회원·SMS 3 submenu, 매장·운영 2 submenu·5 tab, 고객지원 4 submenu, 할인쿠폰 1 submenu·2 tab이다. 상품 GNB와 투어대회·매출 submenu는 display=false이며, 직원 계정은 accessKey로 더 제한된다.", source: "_conf_env/live/_menu.php · web/application/views/common/left_menu.view · web/application/views/common/top_menu.view" }, fms: { label: "6개 GNB · 렌더된 진입점 18개", entries: "설정 파일의 tab까지 펴면 33개 leaf지만, Shopopen은 메뉴 렌더가 주석이고 controller가 즉시 차단한다. 기능 행 수와 메뉴 수는 일치하지 않는다.", source: "_conf_env/live/_menu.php · views/common/left_menu.view" }, golfhubApp: { label: "하단 탭 4개", entries: "Home · Book · Competition · Me가 실제 탭이며 예약 상세·결제·플랜·팀 대회는 하위 route다. 네이티브 앱이 아닌 SvelteKit 모바일 프로토타입이다.", source: "apps/billing-api/frontend/src/routes/m/+layout.svelte" } }; const inventoryActualNavigationMarkup = (projectKey) => { const nav = inventoryActualNavigation[projectKey]; if (!nav) return "<b>메뉴 구조·연계도</b> 프로젝트별 실제 메뉴 진입점과 화면 흐름을 확인합니다."; return `<b>실제 메뉴 진입점 · ${esc(nav.label)}</b> ${esc(nav.entries)} <code>${esc(nav.source)}</code>`; }; let activeInventoryMenuMap = null; const inventoryMenuMapFor = (projectKey) => { const project = inventoryProjects[projectKey]; const majors = new Map(); const nodeByKey = new Map(); const nodes = []; const addNode = ({ major, submenu, screenId, title, external = false, item = null }) => { const nodeKey = `${major}\u001f${submenu}\u001f${screenId}`; const existing = nodeByKey.get(nodeKey); if (existing) { if (item) { existing.items.push(item); existing.qr = existing.qr || item.origin === 'qr'; } return existing; } const majorKey = `${external ? "external" : "menu"}\u001f${major}`; if (!majors.has(majorKey)) majors.set(majorKey, { id: majorKey, title: major, external, submenus: new Map() }); const majorEntry = majors.get(majorKey); if (!majorEntry.submenus.has(submenu)) majorEntry.submenus.set(submenu, { title: submenu, nodes: [] }); const node = { id: `${projectKey}:${nodeKey}`, domId: `inventory-menu-${projectKey}-${nodes.length + 1}`, major, submenu, screenId, title, external, qr: item?.origin === 'qr', items: item ? [item] : [], order: nodes.length }; nodeByKey.set(nodeKey, node); majorEntry.submenus.get(submenu).nodes.push(node); nodes.push(node); return node; }; (project?.rows || []).forEach((item) => { const { major, submenu } = inventoryGroupHierarchy(projectKey, item); const screenInfo = inventoryScreenFor(projectKey, item); const screenId = screenInfo.screenGroup?.id || `${item.group}-${item.feature}`; addNode({ major, submenu, screenId, title: friendly(screenInfo.screenGroup?.title || item.group || "화면"), item }); }); (inventoryMenuExternalNodes[projectKey] || []).forEach((external) => addNode({ major: "외부 연동", submenu: external.submenu || "서비스", screenId: external.id, title: external.title, external: true })); const nodesByScreenId = new Map(); nodes.forEach((node) => { const matches = nodesByScreenId.get(node.screenId) || []; matches.push(node); nodesByScreenId.set(node.screenId, matches); }); const nodesForScreen = (screenId) => nodesByScreenId.get(screenId) || []; const nodeForScreen = (screenId) => nodesForScreen(screenId) .slice() .sort((left, right) => right.items.length - left.items.length || left.order - right.order)[0]; const relations = inventoryMenuRelations[projectKey] || []; const unresolvedRelations = []; const edges = relations .map((relation, index) => { const from = nodeForScreen(relation.from); const to = nodeForScreen(relation.to); if (!from || !to || from.id === to.id) { unresolvedRelations.push({ ...relation, index, missing: [!from ? relation.from : null, !to ? relation.to : null].filter(Boolean) }); return null; } return { id: `${projectKey}:edge:${index + 1}`, domId: `inventory-menu-edge-${projectKey}-${index + 1}`, fromId: from.domId, toId: to.domId, fromScreenId: relation.from, toScreenId: relation.to, label: relation.label, type: relation.type || "flow" }; }) .filter(Boolean); const linkedScreenIds = new Set(edges.flatMap((edge) => [edge.fromScreenId, edge.toScreenId])); return { projectKey, project, majors, nodes, nodeByDomId: new Map(nodes.map((node) => [node.domId, node])), nodesByScreenId, edges, linkedScreenIds, relations, unresolvedRelations, hoveredNodeId: null, pinnedNodeId: null }; }; const inventoryMenuMapMarkup = (state) => { let majorIndex = 0; return [...state.majors.values()].map((major) => { majorIndex += 1; const submenus = [...major.submenus.values()].map((submenu) => { const heading = submenu.title === "전체" ? "" : `<span class="inventory-menu-submenu-title">${esc(submenu.title)}</span>`; const nodes = submenu.nodes.map((node) => { const qrOrigin = node.qr && !isQrInventoryProject(state.projectKey); const linked = state.linkedScreenIds.has(node.screenId); return `<button type="button" class="inventory-menu-node${node.external ? " is-external" : ""}${qrOrigin ? " is-qr" : ""}${linked ? " is-linked" : ""}" data-inventory-menu-node="${node.domId}" data-inventory-menu-screen-id="${esc(node.screenId)}" aria-pressed="false" aria-label="${esc(`${node.major} · ${node.submenu} · ${node.title}`)}">${qrOrigin ? '<span class="inventory-origin-chip">QR 추가</span>' : ''}${esc(node.title)}</button>`; }).join(""); return `<section class="inventory-menu-submenu">${heading}${nodes}</section>`; }).join(""); return `<section class="inventory-menu-major${major.external ? " is-external" : ""}"><h4 class="inventory-menu-major-title"><span class="inventory-menu-major-index">${major.external ? "EXT" : `m${String(majorIndex).padStart(2, "0")}`}</span>${esc(major.title)}</h4>${submenus}</section>`; }).join(""); }; const syncInventoryMenuMapHighlight = () => { const state = activeInventoryMenuMap; const map = root.querySelector("[data-inventory-menu-map]"); if (!state || map.dataset.graphProject !== state.projectKey) return; const activeNodeId = state.hoveredNodeId || state.pinnedNodeId; const activeNode = activeNodeId ? state.nodeByDomId.get(activeNodeId) : null; const activeScreenId = activeNode?.screenId || null; const activeEdges = activeScreenId ? state.edges.filter((edge) => edge.fromScreenId === activeScreenId || edge.toScreenId === activeScreenId) : []; const related = new Set(activeNodeId ? [activeNodeId] : []); activeEdges.forEach((edge) => { related.add(edge.fromId); related.add(edge.toId); (state.nodesByScreenId.get(edge.fromScreenId) || []).forEach((node) => related.add(node.domId)); (state.nodesByScreenId.get(edge.toScreenId) || []).forEach((node) => related.add(node.domId)); }); map.dataset.graphFocus = activeNodeId || ""; map.classList.toggle("is-highlighting", !!activeNodeId); root.querySelectorAll("[data-inventory-menu-node]").forEach((button) => { const nodeId = button.getAttribute("data-inventory-menu-node"); button.classList.toggle("is-active", nodeId === activeNodeId); button.classList.toggle("is-related", nodeId !== activeNodeId && related.has(nodeId)); button.classList.toggle("is-muted", !!activeNodeId && !related.has(nodeId)); button.setAttribute("aria-pressed", String(nodeId === state.pinnedNodeId)); }); root.querySelectorAll(".inventory-menu-major").forEach((major) => { const hasRelatedNode = [...major.querySelectorAll("[data-inventory-menu-node]")].some((node) => related.has(node.getAttribute("data-inventory-menu-node"))); major.classList.toggle("is-muted", !!activeNodeId && !hasRelatedNode); }); root.querySelectorAll("[data-inventory-menu-edge]").forEach((edge) => { edge.classList.toggle("is-active", activeEdges.some((entry) => entry.domId === edge.getAttribute("data-inventory-menu-edge"))); }); root.querySelectorAll("[data-inventory-menu-edge-label]").forEach((label) => { label.classList.toggle("is-active", activeEdges.some((entry) => entry.domId === label.getAttribute("data-inventory-menu-edge-label"))); }); const status = root.querySelector("[data-inventory-menu-map-status]"); const summary = activeEdges.map((edge) => { const from = state.nodeByDomId.get(edge.fromId); const to = state.nodeByDomId.get(edge.toId); return `${from?.title || "메뉴"} ${edge.label} ${to?.title || "메뉴"}`; }).join(" · "); status.textContent = activeNode ? `${activeNode.title}${summary ? ` · ${summary}` : ""}` : ""; }; const syncInventoryMenuMapLinks = () => { const state = activeInventoryMenuMap; const map = root.querySelector("[data-inventory-menu-map]"); const canvas = root.querySelector("[data-inventory-menu-map-canvas]"); const svg = root.querySelector("[data-inventory-menu-links]"); const labelLayer = root.querySelector("[data-inventory-menu-edge-labels]"); if (!state || !canvas || !svg || !labelLayer || map.dataset.graphProject !== state.projectKey) return; const canvasRect = canvas.getBoundingClientRect(); const width = Math.ceil(canvas.scrollWidth); const height = Math.ceil(canvas.scrollHeight); if (!width || !height || !canvasRect.width || !canvasRect.height) return; svg.setAttribute("viewBox", `0 0 ${width} ${height}`); svg.setAttribute("width", String(width)); svg.setAttribute("height", String(height)); const edgeLayouts = state.edges.map((edge) => { const fromNode = canvas.querySelector(`[data-inventory-menu-node="${edge.fromId}"]`); const toNode = canvas.querySelector(`[data-inventory-menu-node="${edge.toId}"]`); if (!fromNode || !toNode) return null; const fromRect = fromNode.getBoundingClientRect(); const toRect = toNode.getBoundingClientRect(); const fromCenterX = fromRect.left - canvasRect.left + fromRect.width / 2; const fromCenterY = fromRect.top - canvasRect.top + fromRect.height / 2; const toCenterX = toRect.left - canvasRect.left + toRect.width / 2; const toCenterY = toRect.top - canvasRect.top + toRect.height / 2; const sameColumn = Math.abs(toCenterX - fromCenterX) <= 24; const rightward = toCenterX >= fromCenterX; let startX = rightward ? fromRect.right - canvasRect.left : fromRect.left - canvasRect.left; let endX = rightward ? toRect.left - canvasRect.left : toRect.right - canvasRect.left; let controlStartX; let controlEndX; let labelX; let labelY; if (sameColumn) { startX = fromRect.right - canvasRect.left; endX = toRect.right - canvasRect.left; const routeX = Math.max(startX, endX) + 54; controlStartX = routeX; controlEndX = routeX; labelX = routeX; labelY = (fromCenterY + toCenterY) / 2; } else { const curve = Math.max(56, Math.min(190, Math.abs(endX - startX) * .45)); controlStartX = startX + (rightward ? curve : -curve); controlEndX = endX - (rightward ? curve : -curve); labelX = (startX + 3 * controlStartX + 3 * controlEndX + endX) / 8; labelY = (fromCenterY + 3 * fromCenterY + 3 * toCenterY + toCenterY) / 8 - 10; } const path = `M ${startX} ${fromCenterY} C ${controlStartX} ${fromCenterY}, ${controlEndX} ${toCenterY}, ${endX} ${toCenterY}`; return { ...edge, path, labelX, labelY }; }).filter(Boolean); svg.innerHTML = `<defs><marker id="inventory-menu-map-arrow" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M 0 0 L 8 4 L 0 8 z" fill="context-stroke"></path></marker></defs>${edgeLayouts.map((edge) => `<path class="inventory-menu-map-link" data-inventory-menu-edge="${edge.domId}" data-inventory-menu-edge-from="${edge.fromId}" data-inventory-menu-edge-to="${edge.toId}" data-relation-type="${esc(edge.type)}" d="${edge.path}" marker-end="url(#inventory-menu-map-arrow)"></path>`).join("")}`; labelLayer.innerHTML = edgeLayouts.map((edge) => `<span class="inventory-menu-edge-label" data-inventory-menu-edge-label="${edge.domId}" style="left:${edge.labelX}px;top:${edge.labelY}px">${esc(edge.label)}</span>`).join(""); syncInventoryMenuMapHighlight(); }; const renderInventoryMenuMap = (projectKey) => { const state = inventoryMenuMapFor(projectKey); const canvas = root.querySelector("[data-inventory-menu-map-canvas]"); const columns = root.querySelector("[data-inventory-menu-columns]"); const map = root.querySelector("[data-inventory-menu-map]"); const summary = root.querySelector("[data-inventory-menu-map-summary]"); const columnCount = Math.min(8, Math.max(1, state.majors.size)); const screenIds = new Set(state.nodes.map((node) => node.screenId)); const connectedScreenIds = new Set(state.edges.flatMap((edge) => [edge.fromScreenId, edge.toScreenId])); activeInventoryMenuMap = state; map.dataset.graphProject = projectKey; map.dataset.graphFocus = ""; map.dataset.graphNodeCount = String(state.nodes.length); map.dataset.graphScreenCount = String(screenIds.size); map.dataset.graphConnectedScreenCount = String(connectedScreenIds.size); map.dataset.graphConfiguredLinks = String(state.relations.length); map.dataset.graphLinkCount = String(state.edges.length); map.dataset.graphUnresolvedLinks = String(state.unresolvedRelations.length); if (summary) { summary.innerHTML = `${inventoryActualNavigationMarkup(projectKey)}
메뉴 ${state.nodes.length}개 · 화면 ${screenIds.size}개 · 연결 ${state.edges.length}/${state.relations.length}개 · 동일 화면 메뉴는 대표 연결선을 함께 강조합니다. 선 없음은 독립·접근조건·직접 흐름 근거 없음입니다.
`; } canvas.style.setProperty("--inventory-menu-map-columns", String(columnCount)); canvas.style.setProperty("--inventory-menu-map-min-width", `${Math.max(900, columnCount * 180)}px`); columns.innerHTML = inventoryMenuMapMarkup(state); root.querySelector("[data-inventory-menu-links]").innerHTML = ""; root.querySelector("[data-inventory-menu-edge-labels]").innerHTML = ""; root.querySelector("[data-inventory-menu-map-status]").textContent = ""; window.requestAnimationFrame(() => { syncInventoryMenuMapLinks(); window.requestAnimationFrame(syncInventoryMenuMapLinks); }); }; const setInventoryMenuMapHover = (nodeId) => { if (!activeInventoryMenuMap?.nodeByDomId.has(nodeId)) return; activeInventoryMenuMap.hoveredNodeId = nodeId; syncInventoryMenuMapHighlight(); }; const clearInventoryMenuMapHover = () => { if (!activeInventoryMenuMap) return; activeInventoryMenuMap.hoveredNodeId = null; syncInventoryMenuMapHighlight(); }; const inventoryOptionFactsFor = (projectKey, item) => { const override = inventoryOptionOverrides[projectKey]?.[item.feature] || []; const supplied = Array.isArray(item.qrOptions) ? item.qrOptions : []; const source = [friendly(item.description), friendly(item.note)].filter(Boolean).join(" "); const extracted = source.split(/(?<=[.!?])\s+/).map((part) => part.trim()).filter((part) => /\d|최대|최소|기본|고정|허용|제한|단위|선택지|종류|이내|이상|이하|켜기|끄기/i.test(part)); return [...new Set([...supplied, ...override, ...extracted])].slice(0, supplied.length || override.length ? Math.max(supplied.length, override.length, 4) : 3); }; const inventoryPlatformSummary = (item) => { if (!item.platformRows || item.platformScope === "common") return ""; const aos = item.platformRows.aos; const ios = item.platformRows.ios; if (item.platformScope === "aos-only") return `AOS 전용 · ${inventoryStateLabels[aos?.state] || aos?.state || "상태 확인"}`; if (item.platformScope === "ios-only") return `iOS 전용 · ${inventoryStateLabels[ios?.state] || ios?.state || "상태 확인"}`; return `AOS ${inventoryStateLabels[aos?.state] || aos?.state || "확인"} · iOS ${inventoryStateLabels[ios?.state] || ios?.state || "확인"}`; }; const inventoryScreenActionFor = (projectKey, item, index) => ({ index, title: friendly(item.feature), feature: friendly(item.feature), state: item.state, stateLabel: inventoryStateLabels[item.state] || item.state, entry: inventoryEntrySummary(item.description, item.feature, inventoryDisplayOverrideFor(projectKey, item).entry), description: friendly(item.description), options: inventoryOptionFactsFor(projectKey, item), note: friendly(item.note), platform: inventoryPlatformSummary(item), platformDelta: friendly(item.platformDelta) }); const prototypeDisabledStates = new Set(["mock", "hidden", "commented", "disabled", "dev", "absent"]); const prototypeLocalDate = () => { const now = new Date(); const offset = now.getTimezoneOffset() * 60000; return new Date(now.getTime() - offset).toISOString().slice(0, 10); }; const prototypeButtonLabel = (title) => friendly(title) .replace(/\s*\((?:AJAX|내부|팝업|모달)[^)]*\)/gi, "") .replace(/\s*(?:폼 출력|폼 렌더|폼 표시|팝업 출력|팝업 표시|모달 표시|처리 팝업)$/u, "") .replace(/\s+/g, " ") .trim(); const prototypeGenericKey = (projectKey, screenGroupId, action) => `${projectKey}.${screenGroupId || "screen"}.action-${action.index + 1}`; const prototypeChoice = (name, label, options, value, extra = {}) => ({ name, label, type: "choice", options, value, required: true, ...extra }); const prototypeText = (name, label, value = "", extra = {}) => ({ name, label, type: "text", value, ...extra }); const inventoryPrototypeActionFor = (projectKey, screenGroupId, action) => { const feature = action.title; const base = { ...action, actionKey: prototypeGenericKey(projectKey, screenGroupId, action), kind: "form", buttonLabel: prototypeButtonLabel(feature), fields: action.options.length ? [prototypeChoice("setting", "선택값", action.options.slice(0, 6), action.options[0], { wide: true })] : [prototypeText("value", "입력값", prototypeButtonLabel(feature), { required: true, wide: true })], success: `${prototypeButtonLabel(feature)} 처리가 완료되었습니다.`, stateText: `${prototypeButtonLabel(feature)} 적용 완료`, disabled: prototypeDisabledStates.has(action.state) }; if (projectKey === "qms" && feature === "타석 오픈 시간선택 모달") return { ...base, actionKey: "qms.bay.open-duration", kind: "form", title: "타석 오픈", buttonLabel: "타석 오픈", fields: [prototypeChoice("duration", "이용시간", ["10", "20", "30", "60", "90", "120"], "60", { suffix: "분", wide: true })], success: (values) => `타석을 ${values.duration}분으로 오픈하고 현황을 새로고침했습니다.`, stateText: (values) => `사용 중 · 이용시간 ${values.duration}분`, preview: (values) => `선택한 이용시간 ${values.duration || "60"}분으로 빈 타석을 오픈합니다.`, disabled: false }; if (projectKey === "qms" && feature === "예약 입력 폼(모달)") return { ...base, actionKey: "qms.reservation.create", title: "타석 예약 등록", buttonLabel: "예약 등록", fields: [ { name: "date", label: "예약일", type: "date", value: prototypeLocalDate(), required: true }, { name: "start", label: "시작 시간", type: "time", value: "14:00", required: true, step: 300 }, { name: "end", label: "종료 시간", type: "time", value: "15:00", required: true, step: 300 }, prototypeChoice("people", "인원", ["1", "2", "3", "4", "5", "6"], "2"), prototypeText("name", "예약자명", "홍길동", { required: true, maxLength: 30 }), prototypeText("phone", "연락처", "010-1234-5678", { required: true, maxLength: 13 }), { name: "memo", label: "메모", type: "textarea", value: "", maxLength: 150, wide: true } ], success: "예약이 등록되었습니다.", stateText: (values) => `${values.date} ${values.start} · ${values.name} 예약`, disabled: false }; if (projectKey === "fms" && /타석 예약 등록\/수정 폼 출력/.test(feature)) return { ...base, actionKey: "fms.reservation.create", title: "타석 예약 등록", buttonLabel: "예약 등록", fields: [ { name: "date", label: "예약일", type: "date", value: prototypeLocalDate(), required: true }, { name: "start", label: "시작 시간", type: "time", value: "14:00", required: true, step: 300 }, { name: "end", label: "종료 시간", type: "time", value: "15:00", required: true, step: 300 }, prototypeText("bookingNm", "예약자명", "홍길동", { required: true, pattern: "^[가-힣A-Za-z0-9]+$" }), prototypeText("bookingHp", "연락처", "010-1234-5678", { required: true }), prototypeText("managerNm", "담당자", "매장 관리자"), { name: "memo", label: "메모", type: "textarea", value: "", maxLength: 150, wide: true } ], success: "예약이 완료되었습니다. 바로 결제하시겠어요?", stateText: (values) => `예약 완료 · ${values.date} ${values.start}~${values.end} · ${values.bookingNm}`, preview: (values) => `${values.date || "예약일"} ${values.start || "시작"}~${values.end || "종료"} · ${values.bookingNm || "예약자"}`, disabled: false }; if (projectKey === "fms" && /신용카드 결제 폼 표시/.test(feature)) return { ...base, actionKey: "fms.payment.card", title: "카드 결제", buttonLabel: "카드 결제", fields: [ { name: "amount", label: "결제 금액", type: "number", value: "50000", required: true, min: 1000, step: 1000 }, prototypeChoice("installment", "할부", ["일시불", "2개월", "3개월", "4개월", "5개월", "6개월"], "일시불") ], confirmAfterApply: true, confirm: (values) => `${Number(values.amount || 0).toLocaleString("ko-KR")}원을 ${values.installment}로 승인 요청하시겠습니까?`, success: "카드 결제가 승인되었습니다.", stateText: (values) => `결제 승인 · ${Number(values.amount || 0).toLocaleString("ko-KR")}원 · ${values.installment}`, preview: (values) => `${Number(values.amount || 0).toLocaleString("ko-KR")}원 · ${values.installment || "일시불"}`, disabled: false }; if (projectKey === "fms" && /현금 결제 폼 표시/.test(feature)) return { ...base, actionKey: "fms.payment.cash", title: "현금 결제", buttonLabel: "현금 결제", fields: [ { name: "amount", label: "결제 금액", type: "number", value: "50000", required: true, min: 1, step: 1000 }, prototypeChoice("receipt", "현금영수증", ["발행 안함", "개인", "사업자"], "발행 안함"), prototypeChoice("device", "입력 장치", ["사인패드 입력", "카드 리딩"], "사인패드 입력", { wide: true }) ], confirmAfterApply: true, confirm: (values) => `${Number(values.amount || 0).toLocaleString("ko-KR")}원을 현금 결제하시겠습니까?`, success: "현금 결제가 승인되었습니다.", stateText: (values) => `현금 결제 완료 · ${Number(values.amount || 0).toLocaleString("ko-KR")}원`, disabled: false }; if (projectKey === "fms" && feature === "타석 오픈 팝업") return { ...base, actionKey: "fms.bay.open", title: "타석 오픈", buttonLabel: "타석 오픈", fields: [{ name: "duration", label: "이용시간", type: "select", value: "60분", required: true, wide: true, options: ["10분", "20분", "30분", "40분", "50분", "60분", "70분", "80분", "90분", "100분", "110분", "120분", "180분", "240분", "300분", "360분", "420분", "480분", "540분", "600분", "660분", "720분", "780분", "840분", "900분"], help: "다음 예약 전 준비시간을 제외한 실제 허용 범위에서 선택" }], success: "타석이 열렸습니다.", stateText: (values) => `사용 중 · ${values.duration}`, disabled: false }; if (projectKey === "fms" && feature === "이용시간 변경 팝업") return { ...base, actionKey: "fms.bay.change-time", title: "이용시간 변경", buttonLabel: "시간 변경", fields: [prototypeChoice("delta", "변경 시간", ["-60분", "-30분", "-15분", "+5분", "+10분", "+15분", "+30분", "+60분"], "+30분", { wide: true })], confirmAfterApply: true, confirm: (values) => `현재 타석의 이용시간을 ${values.delta} 변경하시겠습니까?`, success: (values) => `현재 타석의 이용시간이 ${values.delta.replace("+", "")} 증가/감소 되었습니다.`, stateText: (values) => `이용시간 ${values.delta} 변경 완료`, disabled: false }; if (projectKey === "fms" && /강제 종료 확인 팝업|타석 종료/.test(feature)) return { ...base, actionKey: "fms.bay.end", kind: "confirm", buttonLabel: "타석 종료", confirm: "이용 중인 타석을 종료하시겠습니까? 남은 이용시간은 복구되지 않습니다.", success: "타석이 종료되었습니다.", stateText: "빈 타석 · 이용 종료", disabled: false }; if (projectKey === "fms" && /타석 이동/.test(feature)) return { ...base, actionKey: "fms.bay.move", title: "타석 이동", buttonLabel: "타석 이동", fields: [prototypeChoice("target", "이동할 타석", ["02 타석", "03 타석", "05 타석"], "02 타석", { wide: true })], confirmAfterApply: true, confirm: (values) => `${values.target}으로 이동하시겠습니까?`, success: (values) => `${values.target}으로 타석을 이동하였습니다.`, stateText: (values) => `타석 이동 완료 · 01 타석 > ${values.target}`, disabled: false }; if (projectKey === "fms" && /예약취소|예약 취소/.test(feature)) return { ...base, actionKey: "fms.reservation.cancel", kind: "confirm", buttonLabel: "예약 취소", confirm: "선택한 타석 예약을 취소하시겠습니까?", success: "예약취소 처리가 완료되었습니다.", stateText: "예약 취소 완료", disabled: false }; if (projectKey === "fms" && /회원 메모 저장/.test(feature)) return { ...base, actionKey: "fms.member.memo", title: "메모입력", buttonLabel: "메모하기", fields: [{ name: "memo", label: "회원 메모", type: "textarea", value: "", required: true, maxLength: 3000, wide: true }], success: "메모가 저장되었습니다.", stateText: "회원 메모 저장 완료", disabled: false }; if (projectKey === "qapp" && /프로필 편집|프로필 조회·수정/.test(feature)) return { ...base, actionKey: "qapp.profile.nickname", title: "닉네임 변경", buttonLabel: "닉네임 수정", fields: [prototypeText("nickname", "닉네임", "골프친구", { required: true, minLength: 2, maxLength: 12, pattern: "^[가-힣A-Za-z0-9]+$", wide: true })], success: "닉네임이 변경되었습니다.", stateText: (values) => `프로필 · ${values.nickname}`, preview: (values) => `변경할 닉네임: ${values.nickname || "-"}`, disabled: false }; if (projectKey === "qapp" && feature === "예약 바로가기") return { ...base, actionKey: "qapp.booking.shortcut", kind: "navigate", buttonLabel: "예약하기", destination: "booking", success: "예약 화면으로 이동했습니다.", stateText: "예약 화면 진입", disabled: false }; if (projectKey === "qapp" && feature === "예약 조건 선택") return { ...base, actionKey: "qapp.booking.options", title: "예약 조건 선택", buttonLabel: "예약 조건", fields: [ prototypeChoice("bayType", "타석 유형", ["OPEN", "ROOM"], "OPEN"), prototypeChoice("hand", "손잡이", ["RIGHT", "LEFT", "BOTH"], "", { required: false }), prototypeChoice("people", "인원", ["1명", "2명", "3명", "4명", "5명", "6명"], "1명"), prototypeChoice("duration", "이용시간", ["60분", "90분", "120분", "150분", "180분", "210분", "240분", "270분", "300분", "330분", "360분"], "") ], success: "예약 조건을 저장하고 가용 타석을 확인했습니다.", stateText: (values) => `${values.bayType} · ${values.hand || "손잡이 미선택"} · ${values.people} · ${values.duration}`, disabled: false }; if (projectKey === "qapp" && feature === "예약 결제·확정") return { ...base, actionKey: "qapp.booking.complete", title: "예약 결제·확정", buttonLabel: "예약요청하기", fields: [ prototypeText("name", "예약자 이름", "", { required: true, minLength: 2, maxLength: 30, pattern: "^[A-Za-zㄱ-ㅣ가-힣 ]+$" }), prototypeText("phone", "전화번호", "", { required: true, pattern: "^[0-9]{7,15}$" }), prototypeChoice("policy", "노쇼 정책 동의", ["동의", "미동의"], "미동의"), prototypeChoice("card", "결제 카드", ["등록 카드(API 조회)", "새 카드 등록"], "등록 카드(API 조회)") ], confirmAfterApply: true, confirm: "선택한 조건으로 예약을 요청하시겠습니까?", success: "예약이 완료되었어요. 예약 내역에서 확인할 수 있습니다.", stateText: "예약 완료 · 예약 내역 동기화", disabled: false }; if (projectKey === "qapp" && feature === "예약 취소") return { ...base, actionKey: "qapp.booking.cancel", kind: "confirm", title: "예약을 취소하시겠어요?", buttonLabel: "예약 취소", confirm: "확인을 누르면, 해당날짜의 이용 예약이 취소됩니다.", success: "예약이 취소되었습니다.", stateText: "예약 취소 · 예약 목록 새로고침", disabled: false }; if (["golfhubBranchWeb", "golfhubApp"].includes(projectKey) && /예약.*(?:등록|생성|신청)|예약하기/.test(feature)) return { ...base, actionKey: `${projectKey}.reservation.create`, title: "예약 등록", buttonLabel: "예약 등록", fields: [ { name: "date", label: "예약일", type: "date", value: prototypeLocalDate(), required: true }, { name: "time", label: "시작 시간", type: "time", value: "14:00", required: true }, prototypeChoice("people", "인원", ["1명", "2명", "3명", "4명"], "2명"), prototypeText("name", "예약자", "홍길동", { required: true }) ], success: "예약이 등록되었습니다.", stateText: (values) => `${values.date} ${values.time} · ${values.people} 예약`, disabled: false }; if (/삭제|취소|종료|해제|로그아웃|탈퇴/.test(feature)) return { ...base, kind: "confirm", confirm: `${prototypeButtonLabel(feature)}하시겠습니까?`, fields: [] }; if (/바로가기|화면 진입|페이지 진입|상세 보기|목록 조회|내역 조회|조회$/.test(feature)) return { ...base, kind: "navigate", destination: `view-${action.index + 1}`, fields: [] }; if (/On\/Off|ON\/OFF|활성화|비활성화|토글/.test(feature)) return { ...base, kind: "toggle", fields: [], success: `${prototypeButtonLabel(feature)} 상태가 변경되었습니다.`, stateText: `${prototypeButtonLabel(feature)} · 켜짐` }; if (action.state === "external") return { ...base, kind: "navigate", destination: `external-${action.index + 1}`, fields: [], success: `${prototypeButtonLabel(feature)} 외부 화면으로 이동했습니다.` }; return base; }; const prototypeEntryActions = (actions) => { const internalFlow = /(?:AJAX|API|처리(?:$|\s|\()|실행(?:$|\s|\()|응답|결과 수신|콜백|갱신|재조회|목록 생성|데이터 생성|분기|검증|계산|리로드|payload|시간목록 팝업|확인 팝업|대상 타석목록 팝업|예약상세 팝업 신규|예약확인 팝업|실시간 현황$)/i; const selected = actions.filter((action) => !internalFlow.test(action.feature) || !/\.action-\d+$/.test(action.actionKey)); const deduped = []; const seen = new Set(); (selected.length ? selected : actions.slice(0, 1)).forEach((action) => { const key = action.actionKey || `${action.kind}:${action.buttonLabel}`; if (seen.has(key)) return; seen.add(key); deduped.push(action); }); return deduped; }; const inventoryDisplayTrim = (value, limit = 48) => { const text = friendly(value).replace(/\s+/g, " ").replace(/\s*·\s*/g, " · ").trim(); if (text.length <= limit) return text; const contentLimit = Math.max(1, limit - 1); const probe = text.slice(0, contentLimit + 1); const breakAt = Math.max(probe.lastIndexOf(" · "), probe.lastIndexOf(" → "), probe.lastIndexOf(", "), probe.lastIndexOf(" ")); const end = breakAt >= Math.floor(contentLimit * .62) ? breakAt : contentLimit; return `${text.slice(0, end).replace(/[\s,·→]+$/u, "")}…`; }; const inventoryEntrySummary = (description, feature = "", preferred = "") => { if (preferred) return preferred; const full = friendly(description).replace(/\s+/g, " ").trim(); if (!full) return "—"; const first = (full.split(/(?<=[.!?])\s+/u).find((part) => !/(?:필수|선택|부가|시스템) 기능입니다/u.test(part)) || full).trim(); const summary = first .replace(/^(?:운영자|관리자|사용자|매장주(?:와 직원)?|직원|앱|시스템)(?:가|는|은|이|을|를)?\s*/u, "") .replace(/\s*(?:앱 내 |사용자 )?선택 항목(?:은|는)?[\s\S]*$/u, "") .replace(/(?:하는|보여주는|제공하는|관리하는|처리하는) (?:화면|기능|항목)입니다\.?$/u, "") .replace(/(?:화면|기능|항목)입니다\.?$/u, "") .replace(/(?:필수|선택|부가|시스템) 기능입니다\.?$/u, "") .replace(/(?:을|를) 선택해\s*/gu, " → ") .replace(/(?:을|를) 눌러(?:서)?\s*/gu, " → ") .replace(/\s*(?:하고|하며|하거나)\s*/gu, " · ") .replace(/(?:보여준다|보여줍니다)\.?$/u, "표시") .replace(/(?:본다|봅니다)\.?$/u, "조회") .replace(/(?:고른다|고릅니다)\.?$/u, "선택") .replace(/없다\.?$/u, "없음") .replace(/있다\.?$/u, "있음") .replace(/않는다\.?$/u, "않음") .replace(/(?:합니다|한다|됩니다|된다|입니다|이다)\.?$/u, "") .replace(/\s*(?:기본|첫|하위)?\s*(?:화면|기능|항목)$/u, "") .replace(/[.,]\s*$/u, "") .replace(/\s+/g, " ") .replace(/\s*·\s*/g, " · ") .trim(); return inventoryDisplayTrim(summary || friendly(feature) || full, Infinity); }; const inventoryOptionValuePattern = /(?:\d|ON\/OFF|사용·미사용|켜기|끄기|공개|비공개|현금영수증|계좌이체|Apple|Google|남성|여성|좌타|우타|왼손|오른손|All|FMS)/i; const inventoryCompactOptionFact = (fact) => { const protectedFact = String(fact ?? "").replace(/ON\/OFF/gi, "사용·미사용").replace(/\bM2\b/gi, "__QMS_M2__"); const compact = friendly(protectedFact).replace(/__QMS_M2__/g, "M2") .replace(/^(?:현재 소스 기준|기존 명세\([^)]*\)|AOS 코드 내 구현중):\s*/u, "") .replace(/^(?:사용자 )?선택 항목(?:은|는|:)?\s*/u, "") .replace(/^(?:선택값|옵션값)(?:은|는|:)?\s*/u, "") .replace(/\s*(?:입니다|이다|합니다|한다)\.?$/u, "") .replace(/\s*(?:선택|입력|설정)(?:할 수 있습니다|합니다|한다)?\.?$/u, "") .replace(/,\s*(?:기본값은|기본은)\s*/u, " · 기본 ") .replace(/,\s*/g, " · ") .replace(/\s*·\s*/g, " · ") .replace(/\s+/g, " ") .trim(); return inventoryDisplayTrim(compact, Infinity); }; const inventorySelectionStatements = (item) => [friendly(item.description), friendly(item.note)].flatMap((source) => { const matches = [...source.matchAll(/(?:사용자 )?선택 항목(?:은|는|:)?\s*([^.!?]+)/gu)]; return matches.flatMap((match) => { const statement = match[1].trim(); const grouped = [...statement.matchAll(/\(([^()]{2,80})\)/gu)].map((group) => group[1].trim()).filter((group) => group.length <= 30 && /[·,\/]|\d/u.test(group)); if (grouped.length) return grouped; const values = statement .split(/\s*(?:을|를|은|는)?\s*(?:선택|고르|제공|입력|표시|조회|저장|적용|분기|관리|확인|이동|로서|이며|이고|입니다|입니다\.)/u)[0] .replace(/^(?:다음과 같습니다|다음)$/u, "") .trim(); return values && values.length <= 30 ? [values] : []; }); }); const inventoryNumericOptionFacts = (item) => { const source = [friendly(item.description), friendly(item.note)].filter(Boolean).join(" "); const facts = []; const lock = source.match(/(\d+)회[^.!?]{0,36}?(\d+)분[^.!?]{0,18}?(?:잠|제한)/u); if (lock) facts.push(`${lock[1]}회 실패 · ${lock[2]}분 잠금`); const values = source.match(/(?:(?:기본|최대|최소)\s*)?\d+(?:\s*(?:~|∼)\s*\d+)?(?:\s*[·,\/]\s*\d+(?:\s*(?:~|∼)\s*\d+)?)*\s*(?:분|시간|일|개월|년|명|자|byte|건|회|장|개|%|m|mm|자리|시|MB)(?:\s*(?:단위|전))?/gu) || []; return [...new Set([...facts, ...values])]; }; const inventoryOptionDisplayFactsFor = (projectKey, item) => { const display = inventoryDisplayOverrideFor(projectKey, item); const supplied = Array.isArray(item.qrOptions) ? item.qrOptions : Array.isArray(display.options) ? display.options : null; const selected = inventorySelectionStatements(item); const selectedFacts = new Set(selected.map(inventoryCompactOptionFact).filter(Boolean)); const curated = inventoryOptionOverrides[projectKey]?.[item.feature] || []; const candidates = supplied || [...curated, ...selected, ...inventoryNumericOptionFacts(item)]; return [...new Set(candidates.map(inventoryCompactOptionFact).filter((fact) => fact && (supplied || selectedFacts.has(fact) || inventoryOptionValuePattern.test(fact))))].slice(0, 3); }; const inventoryConditionSummaryFor = (projectKey, item) => { const display = inventoryDisplayOverrideFor(projectKey, item); if (display.condition) return display.condition; if (item.state === "active") return ""; const note = friendly(item.note); if (!note) return ""; if (/연락처.*(?:없|빠지|전송되지)/u.test(note)) return "연락처 미전송"; if (/로컬 상태|로컬.*처리/u.test(note)) return "로컬 상태"; if (/권한|접근.*차단/u.test(note)) return "권한 제한"; if (/메뉴.*(?:없|미노출)|노출되지/u.test(note)) return "메뉴 미노출"; if (/외부.*(?:이동|연결)/u.test(note)) return "외부 연결"; return ""; }; const inventoryPlatformDisplayFor = (projectKey, item) => { const display = inventoryDisplayOverrideFor(projectKey, item); if (display.platform) return display.platform; const raw = friendly(item.platformDelta); if (!raw) return ""; const compact = raw .replace(/AOS는?/gu, "AOS") .replace(/iOS는?/gu, "iOS") .replace(/(?:를 |을 |가 |이 )?(?:노출합니다|제공합니다|숨깁니다|표시합니다|처리합니다)\.?$/u, "") .replace(/\s+/g, " ") .trim(); return inventoryDisplayTrim(compact, Infinity); }; const inventoryPlatformMarkup = (item) => { if (!item.platformRows || item.platformScope === "common") return ""; const aos = item.platformRows.aos; const ios = item.platformRows.ios; if (item.platformScope === "aos-only") return `<span class="inventory-platform-chip is-delta" data-platform-scope="aos-only">AOS 전용 · ${esc(inventoryStateLabels[aos.state] || aos.state)}</span>`; if (item.platformScope === "ios-only") return `<span class="inventory-platform-chip is-delta" data-platform-scope="ios-only">iOS 전용 · ${esc(inventoryStateLabels[ios.state] || ios.state)}</span>`; return `<span class="inventory-platform-chip is-delta" data-platform-scope="state-delta">AOS ${esc(inventoryStateLabels[aos.state] || aos.state)}</span><span class="inventory-platform-chip is-delta">iOS ${esc(inventoryStateLabels[ios.state] || ios.state)}</span>`; }; const inventoryOptionMarkup = (projectKey, item) => { const facts = inventoryOptionDisplayFactsFor(projectKey, item); const condition = inventoryConditionSummaryFor(projectKey, item); const platform = inventoryPlatformDisplayFor(projectKey, item); return `<div class="inventory-option-summary">${facts.length ? `<ul class="inventory-option-facts">${facts.map((fact) => `<li>${esc(fact)}</li>`).join("")}</ul>` : `<span class="inventory-option-empty">—</span>`}${condition ? `<span class="inventory-option-condition">${esc(condition)}</span>` : ""}${platform ? `<span class="inventory-option-platform">${esc(platform)}</span>` : ""}</div>`; }; const prototypeRouteFor = (payload) => { if (payload.runtime) return payload.runtime; const group = payload.screenGroupId || ""; const alias = (table) => table[group] || ""; if (payload.projectKey === "golfhubBranchWeb") { const screen = alias({ "web-login": "login", "web-bay-operations": "reservation", "web-reservation-overview": "reservation", "web-reservation-detail": "reservation-detail", "web-reservation-history": "reservation-history", "web-member": "members", "web-member-history": "member-activity", "web-membership": "membership", "web-sales": "sales", "web-competition": "competition", "web-competition-form": "competition-form", "web-competition-hqoptin": "competition-hqoptin", "web-competition-leaderboard": "competition-leaderboard", "web-store-settings": "settings", "web-store-info": "settings-info", "web-store-ops": "settings-ops", "web-store-booking": "settings-booking", "web-store-bays": "settings-bays", "web-unmanned": "unmanned" }); return screen ? `prototypes/golfhub-web-shots.html?screen=${encodeURIComponent(screen)}` : ""; } if (payload.projectKey === "golfhubCentralWeb") { const screen = alias({ "web-central-login": "login", "web-central-grades": "grades", "web-central-plans": "plans", "web-central-benefits": "benefits", "web-central-hq-competition": "hq-competition", "web-central-hq-comp-create": "hq-comp-create", "web-central-hq-tours": "hq-tours", "web-central-store-competition": "store-competition", "web-central-personal-competition": "personal-competition", "web-central-users": "users", "web-central-user-detail": "user-detail", "web-central-bay-sales": "bay-sales", "web-central-comp-sales": "comp-sales", "web-central-membership-sales": "membership-sales", "web-central-store-list": "store-list", "web-central-store-detail": "store-detail" }); return screen ? `prototypes/golfhub-central-web-shots.html?screen=${encodeURIComponent(screen)}` : ""; } if (payload.projectKey === "golfhubApp") { // GolfHub APP에는 서로 다른 두 원본이 있다. // - 프로젝트별 상세: Edge Svelte 모바일 웹 프로토타입 // - 시스템/이미지 비교: GolfVX Flutter Figma // 이름이 비슷한 FSApp 런타임을 대체재로 쓰지 않는다. // 프로젝트별 상세의 화면 탭(gh-*)은 GolfVX Figma 기반 한글 프로토타입으로 연다. const ghProto = alias({ "gh-shell": "home", "gh-entry": "login", "gh-signup": "agreement", "gh-home": "home", "gh-plans": "plan-payment", "gh-plan-detail": "plan-payment", "gh-membership": "membership-benefits", "gh-my-plans": "plan-info", "gh-me": "me", "gh-book": "book-time", "gh-checkout": "booking-payment", "gh-history": "booking-history", "gh-reservation": "booking-history", "gh-cancel": "booking-cancel", "gh-reschedule": "booking-reschedule", "gh-pay": "booking-payment", "gh-cards": "booking-payment", "gh-events": "competition-list", "gh-my-events": "competition-list", "gh-event-setup": "competition-single-entry", "gh-event-checkout": "competition-payment", "gh-team": "competition-team-entry" }); if (ghProto) return `prototypes/golfhub-app.html?screen=${encodeURIComponent(ghProto)}`; const isFlutterFigma = /GolfVX Flutter Figma|golfhub\/figma\/flutter/i.test(String(payload.source || "")); const edgeScreen = alias({ "app-home": "home", "app-account": "me", "app-account-security": "me", "app-membership": "membership", "app-points": "membership-benefits", "app-purchase": "plan-detail", "app-plan": "plans", "app-reservation-search": "book", "app-reservation-payment": "payment-detail", "app-reservation-history": "history", "app-reservation-detail": "reservation-detail", "app-competition": "events", "app-competition-entry": "event-detail" }); if (!isFlutterFigma) { return edgeScreen ? `prototypes/golfhub-edge-mobile.html?screen=${encodeURIComponent(edgeScreen)}` : ""; } const screen = alias({ "app-onboarding": "permission", "app-auth": "login", "app-home": "home", "app-qr": "qr-scan", "app-account": "me", "app-account-security": "delete-account", "app-membership": "membership-benefits", "app-points": "points", "app-purchase": "plan-payment", "app-plan": "plan-info", "app-reservation-search": "book-time", "app-reservation-payment": "booking-payment", "app-reservation-history": "booking-history", "app-reservation-detail": "booking-reschedule", "app-competition": "competition-list", "app-competition-entry": "competition-single-entry", "app-report": "report", "app-scorecard": "scorecard-18h" }); return screen ? `prototypes/golfhub-app.html?screen=${encodeURIComponent(screen)}` : ""; } if (payload.projectKey === "qms") { const key = group.replace(/^qms-/, ""); const LIVE = new Set(["login", "bay-live", "bay-history", "reservation-dashboard", "reservation-lookup", "reservation-detail", "reservation-form", "scorecard", "ops-password", "ops-booking", "ops-bays", "coupon", "competition-list", "competition-create", "store-info", "staff-management", "security-settings", "members", "locker", "sms", "support", "settlement"]); return LIVE.has(key) ? `prototypes/qms-web-shots.html?screen=${encodeURIComponent(key)}` : ""; } if (payload.projectKey === "fms") { if (/^fms-(machine|reservation|reservation-list|bay-history|payment|members|sms|shopinfo|locker|staff|lesson|sales|calculate|product|product-sales|notice|settings)$/.test(group)) { return `prototypes/fms-web-shots.html?screen=${encodeURIComponent(group)}`; } const screen = alias({ "fms-login": "login", "fms-bay-live": "bay-live", "fms-reservation-detail": "reservation", "fms-payment-methods": "pos", "fms-pos-sale": "pos", "fms-member-detail": "member", "fms-product-management": "product", "fms-sms-compose": "sms", "fms-sms-history": "sms", "fms-staff-management": "staff", "fms-lesson-pro": "member", "fms-store-settings": "settings", "fms-store-operation-settings": "settings", "fms-reservation-settings": "settings", "fms-bay-settings": "settings", "fms-reconcile": "reconcile", "web-login": "login", "web-bay-operations": "bay-live", "web-reservation-overview": "reservation", "web-reservation-detail": "reservation", "web-reservation-history": "reservation", "web-pos": "pos", "web-member": "member", "web-member-history": "member", "web-membership": "member", "web-lesson": "member", "web-locker": "member", "web-sales": "reconcile", "web-messaging": "sms", "web-staff": "staff", "web-store-settings": "settings", "web-unmanned": "settings", "web-security": "settings", "web-support": "settings" }); return screen ? `prototypes/fms.html?screen=${encodeURIComponent(screen)}` : ""; } if (payload.projectKey === "qapp") { const screen = alias({ "qapp-qr": "home", "qapp-menu": "menu", "qapp-profile": "profile", "qapp-password": "settings", "qapp-game-settings": "settings", "qapp-app-settings": "settings", "qapp-phone": "profile", "qapp-book-screen": "store", "qapp-store": "store", "qapp-booking-form": "booking", "qapp-booking-confirm": "booking", "qapp-pay": "booking", "qapp-booking": "booking-detail", "qapp-booking-detail": "booking-detail", "qapp-ticket": "ticket", "qapp-report": "report", "qapp-score-detail": "report", "qapp-swing": "swing", "app-onboarding": "login", "app-auth": "login", "app-home": "home", "app-qr": "home", "app-account": "menu", "app-account-security": "settings", "app-membership": "ticket", "app-points": "ticket", "app-purchase": "ticket", "app-plan": "ticket", "app-store": "store", "app-training": "home", "app-reservation-search": "booking", "app-reservation-payment": "booking", "app-reservation-history": "booking-detail", "app-reservation-detail": "booking-detail", "app-competition": "home", "app-competition-entry": "home", "app-report": "report", "app-scorecard": "report", "app-swing": "swing", "app-share": "report", "app-locker": "menu", "app-certificate": "profile", "app-attendance": "home" }); const platform = payload.platform === "ios" ? "ios" : "aos"; return screen ? `prototypes/qapp.html?screen=${encodeURIComponent(screen)}&platform=${platform}` : ""; } return ""; }; const comparisonRuntimePayloadForShot = (mode, screenGroup, shot) => { if (!shot?.img || shot.representative || shot.origin === 'qr') return null; const system = String(shot.system || "").toLowerCase(); const systemKey = String(shot.systemKey || "").toLowerCase(); const projectKey = systemKey === "golfhubcentral" || system.includes("central") || system.includes("본사") ? "golfhubCentralWeb" : system.includes("qapp") ? "qapp" : system.includes("qms") ? "qms" : system.includes("fms") ? "fms" : mode === "app" ? "golfhubApp" : "golfhubBranchWeb"; const payload = { projectKey, platform: system.includes("ios") ? "ios" : "aos", screenGroupId: screenGroup.id, project: shot.system, title: `${screenGroup.title} 실행 화면`, note: "비교 항목에 대응하는 반응형 재현 화면입니다.", source: shot.source || "화면 단위 비교" }; return prototypeRouteFor(payload) ? payload : null; }; const comparisonRuntimeLaunchMarkup = (mode, screenGroup, shot) => { const payload = comparisonRuntimePayloadForShot(mode, screenGroup, shot); if (!payload) return ""; return `<button type="button" class="implementation-shot-launch" data-comparison-prototype data-runtime-project="${esc(payload.projectKey)}" data-runtime-platform="${esc(payload.platform)}" data-runtime-group="${esc(payload.screenGroupId)}" data-runtime-system="${esc(payload.project)}" data-runtime-title="${esc(payload.title)}" data-runtime-source="${esc(payload.source)}">동작 화면 열기</button>`; }; const inventoryScreenLaunchFor = (projectKey, project, screenInfo, items) => { const shot = screenInfo.shot || {}; const screenTitle = screenInfo.screenGroup?.title || friendly(items[0]?.group || items[0]?.feature || "화면"); const runtimePayload = { projectKey, screenGroupId: screenInfo.screenGroup?.id || '', platform: items.some((item) => item.platformScope === 'ios-only') ? 'ios' : 'aos', source: shot.source || '', runtime: screenInfo.runtime }; const runtime = prototypeRouteFor(runtimePayload); const qr = screenInfo.origin === 'qr'; if ((!shot.img && !(qr && runtime) && !((projectKey === "qapp" || projectKey === "golfhubApp") && runtime)) || screenInfo.representative) { return { available: false, state: "no-screen", reason: shot.note || "현재 연결할 화면 이미지를 확보하지 못했습니다.", screenTitle, qr }; } if (!runtime && !((projectKey === "qapp" || projectKey === "golfhubApp") && shot.img)) { return { available: false, state: "unresolved", reason: "정확한 화면 근거 확인 필요", screenTitle, qr }; } const actions = items.map((item, index) => inventoryScreenActionFor(projectKey, item, index)); const prototypeActions = prototypeEntryActions(actions.map((action) => inventoryPrototypeActionFor(projectKey, screenInfo.screenGroup?.id || "screen", action))); const interactive = screenInfo.interactionEnabled !== false && !screenInfo.representative && (!!screenInfo.runtime || (projectKey === "qapp" && !!runtime) || inventoryScreenHasInteraction(projectKey, shot.img)); return { available: true, state: "ready", screenTitle, qr, interactive, payload: { projectKey, platform: items.some((item) => item.platformScope === "ios-only") ? "ios" : "aos", mode: inventoryProjectMeta[projectKey].mode, screenGroupId: screenInfo.screenGroup?.id || "", project: project.title, title: `${screenTitle}${screenInfo.representative ? " · 대표 화면" : ""}`, img: shot.img, fullImg: shot.fullImg || shot.img, source: shot.source || "프로젝트 화면 자산", note: shot.note || `${project.title}의 ${screenTitle} 화면입니다.`, interactionEnabled: screenInfo.interactionEnabled !== false && !screenInfo.representative, runtime: screenInfo.runtime, origin: screenInfo.origin, bootstrap: screenInfo.bootstrap, actions, prototypeActions } }; }; const inventoryScreenBadge = (projectKey, project, screenInfo, items) => { const launch = inventoryScreenLaunchFor(projectKey, project, screenInfo, items); const standaloneQrProject = isQrInventoryProject(projectKey); if (!launch.available) { const label = launch.state === "no-screen" ? "화면 없음" : "화면 확인 필요"; return `<button type="button" class="inventory-screen-badge" disabled aria-label="${esc(`${project.title} ${launch.screenTitle} ${label}`)}" title="${esc(launch.reason)}">${label}</button>`; } const id = `inventory-screen-${++inventoryScreenSequence}`; inventoryScreenRegistry.set(id, launch.payload); return `<button type="button" class="inventory-screen-badge${launch.interactive ? " is-interactive" : ""}${launch.qr && !standaloneQrProject ? " is-qr" : ""}" data-inventory-screen="${id}" aria-label="${esc(`${project.title} ${launch.screenTitle} 화면 열기`)}">${launch.qr && !standaloneQrProject ? 'QR 화면' : "화면"}</button>`; }; function renderInventory() { const project = inventoryProjects[activeInventoryProject]; const standaloneQrProject = isQrInventoryProject(activeInventoryProject); const query = root.querySelector("[data-inventory-search]").value.trim().toLocaleLowerCase("ko"); const filter = root.querySelector("[data-inventory-filter]").value; const rows = project.rows.filter((item) => { const haystack = [item.group, item.feature, item.description, item.note, item.platformDelta, item.qrScreen?.definition, item.qrScreen?.policy, ...(item.qrOptions || []), ...(inventoryOptionOverrides[activeInventoryProject]?.[item.feature] || [])].join(" ").toLocaleLowerCase("ko"); return (!query || haystack.includes(query)) && inventoryFilterMatches(item.state, filter); }); const allScreenActions = new Map(); project.rows.forEach((item) => { const { major, submenu } = inventoryGroupHierarchy(activeInventoryProject, item); const screenInfo = inventoryScreenFor(activeInventoryProject, item); const tabKey = screenInfo.screenGroup?.id || `${item.group}-${item.feature}`; const actionKey = `${major}\u001f${submenu}\u001f${tabKey}`; if (!allScreenActions.has(actionKey)) allScreenActions.set(actionKey, []); allScreenActions.get(actionKey).push(item); }); const hierarchy = new Map(); rows.forEach((item) => { const { major, submenu } = inventoryGroupHierarchy(activeInventoryProject, item); const screenInfo = inventoryScreenFor(activeInventoryProject, item); const tabKey = screenInfo.screenGroup?.id || `${item.group}-${item.feature}`; const actionKey = `${major}\u001f${submenu}\u001f${tabKey}`; if (!hierarchy.has(major)) hierarchy.set(major, new Map()); if (!hierarchy.get(major).has(submenu)) hierarchy.get(major).set(submenu, new Map()); if (!hierarchy.get(major).get(submenu).has(tabKey)) hierarchy.get(major).get(submenu).set(tabKey, { screenInfo, items: [], actionKey }); hierarchy.get(major).get(submenu).get(tabKey).items.push(item); }); inventoryScreenRegistry.clear(); inventoryScreenSequence = 0; root.querySelector("[data-inventory-caption]").textContent = `${project.title} 프로젝트 상세 기능 목록`; const stats = project.stats ? ` · 공통 ${project.stats.both} · 상태 차이 ${project.stats.stateDelta} · AOS 전용 ${project.stats.aosOnly} · iOS 전용 ${project.stats.iosOnly}` : ""; root.querySelector("[data-inventory-scope]").innerHTML = `${project.scope} · <b>${rows.length}</b>개 항목${stats}`; const body = []; let majorIndex = 0; hierarchy.forEach((submenus, major) => { const majorRowspan = [...submenus.values()].reduce((sum, tabs) => sum + [...tabs.values()].reduce((count, tab) => count + tab.items.length, 0), 0); let majorFirst = true; let submenuIndex = 0; submenus.forEach((tabs, submenu) => { const submenuRowspan = [...tabs.values()].reduce((sum, tab) => sum + tab.items.length, 0); let submenuFirst = true; tabs.forEach(({ screenInfo, items, actionKey }) => { const screenActions = allScreenActions.get(actionKey) || items; let tabFirst = true; items.forEach((item, itemIndex) => { const cells = []; if (majorFirst) cells.push(`<th scope="rowgroup" rowspan="${majorRowspan}" class="inventory-major"><span class="inventory-major-code">m${String(majorIndex + 1).padStart(2, "0")}</span>${esc(major)}</th>`); if (submenuFirst) cells.push(`<th scope="rowgroup" rowspan="${submenuRowspan}" class="inventory-submenu">${esc(submenu)}</th>`); if (tabFirst) { const tabDefinition = inventoryTabDefinitionFor(activeInventoryProject, screenInfo, screenActions); const qrTab = screenInfo.origin === 'qr' && !standaloneQrProject; cells.push(`<th scope="rowgroup" rowspan="${items.length}" class="inventory-tab${qrTab ? ' is-qr' : ''}" data-inventory-screen-group="${esc(screenInfo.screenGroup?.id || "unknown")}">${qrTab ? '<span class="inventory-origin-chip">QR 추가</span>' : ''}<span class="inventory-tab-title">${esc(screenInfo.screenGroup?.title || friendly(item.group))}</span>${inventoryScreenBadge(activeInventoryProject, project, screenInfo, screenActions)}<p class="inventory-tab-definition" title="${esc(tabDefinition)}">${esc(tabDefinition)}</p></th>`); } const platform = inventoryPlatformMarkup(item); const qrRow = item.origin === 'qr' && !standaloneQrProject; const display = inventoryDisplayOverrideFor(activeInventoryProject, item); const actionMarkup = `<div class="inventory-action-head"><span class="inventory-action-index">${String(itemIndex + 1).padStart(2, "0")}</span><span class="inventory-action-title">${esc(friendly(item.feature))}</span></div><div class="inventory-action-meta"><span class="inventory-status ${esc(item.state)}">${esc(inventoryStateLabels[item.state] || item.state)}</span>${platform}</div>`; cells.push(`<th scope="row" class="inventory-action">${qrRow ? `<div class="inventory-qr-frame"><span class="inventory-origin-chip">QR 추가</span>${actionMarkup}</div>` : actionMarkup}</th>`); const entry = inventoryEntrySummary(item.description, item.feature, display.entry); const entryDesc = display.entry ? `<p class="inventory-entry-desc">${esc(friendly(item.description))}</p>` : ''; const entryInner = `<span class="inventory-entry-path">${esc(entry)}</span>${entryDesc}`; cells.push(`<td class="inventory-entry" title="${esc(friendly(item.description))}">${qrRow ? `<div class="inventory-qr-frame">${entryInner}</div>` : entryInner}</td>`); const optionMarkup = inventoryOptionMarkup(activeInventoryProject, item); cells.push(`<td class="inventory-options">${qrRow ? `<div class="inventory-qr-frame">${optionMarkup}</div>` : optionMarkup}</td>`); body.push(`<tr class="${[majorFirst ? 'inventory-major-start' : '', qrRow ? 'inventory-qr-row' : ''].filter(Boolean).join(' ')}" data-inventory-feature="${esc(item.feature)}">${cells.join("")}</tr>`); majorFirst = false; submenuFirst = false; tabFirst = false; }); }); submenuIndex += 1; }); majorIndex += 1; }); root.querySelector("[data-inventory-body]").innerHTML = rows.length ? body.join("") : `<tr class="inventory-empty"><td colspan="6">검색·상태 조건에 맞는 기능이 없습니다.</td></tr>`; root.querySelectorAll("[data-project]").forEach((button) => { const active = button.getAttribute("data-project") === activeInventoryProject; button.setAttribute("aria-pressed", String(active)); button.classList.toggle("btn-primary", active); }); if (activeInventoryMenuMap?.projectKey !== activeInventoryProject) { renderInventoryMenuMap(activeInventoryProject); } else { window.requestAnimationFrame(syncInventoryMenuMapLinks); } } const setInventoryProject = (projectKey) => { if (!inventoryProjects[projectKey]) return; activeInventoryProject = projectKey; renderInventory(); }; const comparisonStructureProjects = { web: [ { key: 'golfhubBranchWeb', label: 'GolfHub Web · 매장' }, { key: 'golfhubCentralWeb', label: 'GolfHub Web · 본사' }, { key: 'qms', label: 'QMS' }, { key: 'fms', label: 'FMS' } ], app: [ { key: 'golfhubApp', label: 'GolfHub APP' }, { key: 'qapp', label: 'FSApp' } ] }; const comparisonStructureDomains = { web: [ { id: 'account', title: '계정·보안' }, { id: 'reservation', title: '예약·타석 운영' }, { id: 'member', title: '회원·이용권' }, { id: 'commerce', title: '상품·결제·매출' }, { id: 'competition', title: '대회' }, { id: 'operations', title: '매장·직원·운영 설정' }, { id: 'support', title: '문자·고객지원' } ], app: [ { id: 'start', title: '시작·인증' }, { id: 'home', title: '홈·콘텐츠' }, { id: 'booking', title: '매장 찾기·예약' }, { id: 'benefits', title: '이용권·구매' }, { id: 'records', title: '플레이 기록·분석' }, { id: 'competition', title: '대회' }, { id: 'account', title: '내 정보·설정' } ] }; let activeComparisonMode = 'web'; let activeComparisonView = 'integration-web'; let activeIntegrationDecision = 'work'; const integrationGuidanceCatalog = { '실시간 타석 상태 연결': { flow: [ { title: '기준 연결', text: '예약 번호·타석 번호·이용 시간을 한 예약 건에 묶습니다.' }, { title: '예약표 반영', text: '예약표에서 현재 게임, 남은 시간, 다음 예약을 같은 기준으로 읽습니다.' }, { title: '현장 처리', text: '열기·연장·이동·종료를 예약 상세에서 실행합니다.' }, { title: '이력 저장', text: '변경 결과와 처리 직원·시각을 예약 이력에 남깁니다.' }, { title: '예외 확인', text: '현장 상태와 예약이 다르면 사유를 남기고 정정합니다.' } ], policies: [ '타석 상태를 바꿀 때는 예약 상태도 함께 바꾸며, 한쪽만 변경하지 않습니다.', '이동·종료 뒤에는 기존 타석을 즉시 비우고 다음 예약 가능 여부를 다시 계산합니다.', '수동 정정은 권한이 있는 직원만 할 수 있게 하고 정정 사유를 남깁니다.' ], done: '예약표의 상태, 실제 타석 상태, 다음 예약 시간이 일치하고 매장과 본사가 같은 이력을 확인할 수 있으면 완료입니다.' }, '예약·회원·결제 기준 연결': { flow: [ { title: '공통 번호 정의', text: '회원·예약·주문마다 통합 후에도 바뀌지 않는 공통 번호를 정합니다.' }, { title: '상태 맞춤', text: '예약·결제·환불 상태를 GolfHub 기준 용어와 순서로 맞춥니다.' }, { title: '현장 결과 수신', text: 'POS와 현장 처리 결과를 공통 번호에 연결해 전달받습니다.' }, { title: '본사 집계', text: '매장 처리 결과를 본사 매출·정산 화면에 자동 반영합니다.' }, { title: '이관 검증', text: '기존 이력을 옮긴 뒤 중복·누락·금액 차이를 점검합니다.' } ], policies: [ '회원 번호·예약 번호·주문 번호는 한 번 발급하면 모든 시스템에서 같은 값으로 사용합니다.', '예약과 환불의 최종 상태는 GolfHub가 관리하고 POS는 결제 결과를 전달하는 역할로 둡니다.', '기존 데이터 이관은 여러 번 실행해도 같은 이력이 중복 생성되지 않게 검증합니다.' ], done: '한 고객의 예약부터 환불까지 회원·금액·상태가 한 줄로 이어지고, 매장과 본사가 같은 결과를 보면 완료입니다.' }, '예약 생성·변경·취소': { flow: [ { title: '기존 화면 유지', text: '고객과 매장이 이미 쓰는 GolfHub 예약 화면과 동선을 유지합니다.' }, { title: '가능 시간 계산', text: '타석 상태·운영 시간·예약 간격을 반영해 가능한 시간만 보여줍니다.' }, { title: '요금 확인', text: '혜택·취소 마감·노쇼 기준을 예약 상세에서 함께 확인합니다.' }, { title: '변경 처리', text: '시간·타석·인원 변경 시 기존 예약과 새 예약의 영향을 함께 계산합니다.' }, { title: '이력 검증', text: '예약·변경·취소가 고객과 매장 이력에 같은 순서로 남는지 확인합니다.' } ], policies: [ '예약 가능 시간은 타석 고장·운영 중지·이미 잡힌 예약까지 반영해 계산합니다.', '취소 마감과 수수료는 고객 앱·매장 화면·본사 화면에서 같은 기준으로 적용합니다.', '노쇼와 관리자 강제 취소는 일반 취소와 분리하고 담당자와 사유를 기록합니다.' ], done: '고객과 직원이 같은 예약을 서로 다른 화면에서 변경해도 시간·요금·이력이 어긋나지 않으면 완료입니다.' }, '멤버십 상품·대회·매출 관리': { flow: [ { title: '상품 기준 확정', text: '본사가 상품 유형·가격·혜택·판매 가능 매장을 정합니다.' }, { title: '매장 채택', text: '매장은 본사가 허용한 상품만 선택해 판매와 예약에 사용합니다.' }, { title: '현장 주문 연결', text: 'POS 주문·환불을 상품·회원·예약 기준으로 GolfHub에 연결합니다.' }, { title: '대회 반영', text: '대회 참가비와 결과를 참가 번호와 상품 기준에 연결합니다.' }, { title: '정산 확인', text: '일별 매출과 환불이 본사 집계·정산에서 맞는지 대사합니다.' } ], policies: [ '상품명·가격·혜택·판매 기간은 본사가 관리하고 매장은 허용된 상품만 판매합니다.', '현장 환불은 원 주문과 연결해 처리하며, 환불 금액은 매출과 정산에서 동시에 차감합니다.', '대회 참가비·팀 결제·혜택 적용 규칙은 대회 생성 시점에 고정해 이후에도 이력을 보존합니다.' ], done: '매장 판매와 환불이 상품별·매장별 매출에 자동 반영되고, 본사 정산 금액과 일치하면 완료입니다.' }, '타석 열기·시간 조정·이동·종료': { flow: [ { title: '예약 선택', text: '직원이 예약표에서 현재 처리할 예약을 먼저 선택합니다.' }, { title: '상태 확인', text: '선택한 예약의 배정 타석과 실제 사용 상태를 확인합니다.' }, { title: '현장 동작', text: '열기·연장·이동·종료를 예약 맥락 안에서 실행합니다.' }, { title: '다음 예약 계산', text: '연장이나 이동이 다음 예약에 미치는 영향을 즉시 다시 계산합니다.' }, { title: '처리 기록', text: '변경 결과와 예외 사유를 예약·타석 이력에 남깁니다.' } ], policies: [ '타석 제어는 현재 예약 또는 현장 접수 건을 선택한 뒤에만 실행할 수 있게 합니다.', '시간 연장은 다음 예약과 충돌하면 가능한 시간만 제안하고 직원이 선택하게 합니다.', '타석 이동은 기존 타석 종료와 새 타석 시작을 하나의 처리로 기록해 중복 사용을 막습니다.' ], done: '직원이 예약표 하나에서 네 가지 동작을 처리하고, 결과가 타석·예약·매출 이력에 함께 남으면 완료입니다.' }, '일반상품·POS·부분환불·영수증': { flow: [ { title: '상품·주문 연결', text: 'POS 상품과 GolfHub 상품을 하나의 판매 기준으로 연결합니다.' }, { title: '결제 승인', text: '결제 수단과 승인 결과를 주문 번호에 기록합니다.' }, { title: '부분환불 처리', text: '환불할 항목과 금액·사유를 원 주문에서 선택합니다.' }, { title: '영수증 제공', text: '판매와 환불 영수증을 같은 주문 번호로 다시 확인할 수 있게 합니다.' }, { title: '마감·대사', text: '카운터 마감 후 POS 금액과 본사 매출을 비교합니다.' } ], policies: [ '모든 판매와 환불은 하나의 주문 번호로 연결하고, 별도 수기 매출을 만들지 않습니다.', '부분환불은 환불 항목·금액·사유·처리자를 남기고 원 결제 금액을 넘지 못하게 합니다.', '일 마감 후에는 임의 수정 대신 정정 거래로 처리해 매출 이력을 보존합니다.' ], done: '카운터의 판매·부분환불·영수증 결과가 예약과 일반상품 모두에서 본사 매출에 같은 금액으로 반영되면 완료입니다.' }, '멤버십·이용권·휴회 기준': { flow: [ { title: '상품 정책 결정', text: '이용권 유형·차감 방식·휴회·환불 기준을 먼저 확정합니다.' }, { title: '기존 권리 분류', text: '기존 회원의 이용권을 새 상품 기준에 맞춰 분류합니다.' }, { title: '회원 이력 이관', text: '회원 번호와 사용·휴회·환불 이력을 함께 옮깁니다.' }, { title: '매장 사용', text: '직원은 같은 회원 화면에서 권리 확인과 사용 처리를 합니다.' }, { title: '본사 감사', text: '본사는 권리 변경과 수동 조정 이력을 확인합니다.' } ], policies: [ '상품 유형별 차감 단위와 유효 기간은 한 번만 정의하고 모든 매장에 같은 방식으로 적용합니다.', '휴회·환불·수동 조정은 일반 사용과 분리하며 승인 권한과 사유를 반드시 남깁니다.', '기존 이용권은 회원 번호와 잔여 혜택·사용 이력을 함께 옮긴 뒤에만 기존 화면을 종료합니다.' ], done: '기존 회원이 통합 후에도 남은 혜택과 이력을 그대로 확인하고, 매장과 본사가 같은 잔여량을 보면 완료입니다.' }, '직원·레슨·라커·무인 운영 범위': { flow: [ { title: '매장 유형 분류', text: '매장별로 필요한 운영 기능과 제외할 기능을 구분합니다.' }, { title: '모듈 선택', text: '레슨·라커·무인 운영을 매장 옵션으로 켜고 끌 수 있게 합니다.' }, { title: '권한 배정', text: '직원 역할별로 메뉴와 처리 가능한 범위를 나눕니다.' }, { title: '현장 운영', text: '매장은 켜진 기능만 사용하고 처리 이력을 남깁니다.' }, { title: '본사 점검', text: '본사는 매장별 옵션·권한·사용 기록을 확인합니다.' } ], policies: [ '모든 매장에 같은 메뉴를 강제하지 않고 매장 유형별 활성 기능을 설정으로 관리합니다.', '직원·강사·관리자 권한은 필요한 업무만 허용하고 개인정보 조회 범위를 최소화합니다.', '무인 매장은 예외 발생 시 원격 확인과 현장 전환 담당자를 명확히 둡니다.' ], done: '매장 유형에 맞는 메뉴만 보이고, 각 역할이 허용된 업무만 처리하며 본사가 설정 상태를 확인할 수 있으면 완료입니다.' }, '숨김 메뉴와 비활성 운영 경로': { flow: [ { title: '정리 후보 수집', text: '숨김·미사용·중복 메뉴와 남아 있는 데이터를 목록으로 만듭니다.' }, { title: '담당 확인', text: '운영·사업·개발 담당자가 실제 사용 여부와 대체 경로를 확인합니다.' }, { title: '데이터 이관', text: '사용 중인 상품·매출·회원 이력만 GolfHub 기준으로 옮깁니다.' }, { title: '경로 전환', text: '기존 메뉴 진입은 새 화면으로 안내하거나 읽기 전용으로 전환합니다.' }, { title: '종료 점검', text: '일정 기간 사용 기록과 문의를 확인한 뒤 완전히 제거합니다.' } ], policies: [ '사용 중인 주문·회원·정산 이력이 남아 있으면 화면을 먼저 제거하지 않습니다.', '기존 화면을 종료하기 전 담당자가 대체 화면과 이관 완료 여부를 확인합니다.', '전환 기간에는 필요한 이력만 읽기 전용으로 제공하고 신규 입력은 GolfHub로 제한합니다.' ], done: '사용 중인 데이터는 GolfHub에서 확인되고, 제거한 메뉴로 새 업무가 들어오지 않으며 담당자 승인까지 끝나면 완료입니다.' }, '실사용 로그인·계정 연결': { flow: [ { title: '계정 기준 정리', text: 'GolfHub 회원 번호를 고객 계정의 기준으로 정합니다.' }, { title: '기존 계정 연결', text: 'QApp 로그인과 소셜 계정을 같은 고객 번호에 안전하게 연결합니다.' }, { title: '인증 전환', text: '개발용 사용자 선택을 실제 가입·로그인·비밀번호 재설정으로 교체합니다.' }, { title: '정보 누적', text: '예약·멤버십·플레이 기록을 로그인한 본인 계정에 누적합니다.' }, { title: '복구 지원', text: '연결 실패·중복 계정은 고객지원 절차로 안전하게 복구합니다.' } ], policies: [ '회원 번호는 GolfHub를 기준으로 하고, QApp 계정과 소셜 로그인은 연결 수단으로만 사용합니다.', '하나의 소셜 계정이 여러 회원 번호에 연결되지 않도록 가입·연결 단계에서 확인합니다.', '로그아웃·기기 변경·계정 복구 시 예약과 결제 정보가 다른 사람에게 보이지 않게 세션을 관리합니다.' ], done: '고객이 실제 계정으로 로그인했을 때 본인 예약·혜택·플레이 기록만 한 계정에 이어서 보이면 완료입니다.' }, '실결제·알림·딥링크 연결': { flow: [ { title: '주문 생성', text: '예약 또는 대회 신청 시 서버 기준 주문을 먼저 만듭니다.' }, { title: '결제 진행', text: '고객은 결제 수단을 선택하고 결제사 승인 절차를 진행합니다.' }, { title: '결과 확인', text: '결제 결과는 서버에서 확인한 뒤 주문 상태를 바꿉니다.' }, { title: '알림 발송', text: '확정·실패·환불 결과를 고객에게 알림으로 보냅니다.' }, { title: '상세 복귀', text: '알림을 누르면 앱의 해당 예약·결제·대회 상세로 바로 이동합니다.' } ], policies: [ '앱 화면의 결제 완료 표시는 최종 기준이 아니며 서버 확인이 끝난 주문만 확정으로 처리합니다.', '같은 결제 결과가 다시 들어와도 주문과 예약이 중복 확정되지 않도록 처리합니다.', '딥링크가 열리지 않는 상황에는 앱의 예약 또는 알림 목록으로 안전하게 돌아가게 합니다.' ], done: '실제 결제 승인·실패·환불 결과가 주문 상태와 알림에 맞게 반영되고, 알림에서 상세 화면으로 돌아오면 완료입니다.' }, '예약 가능 시간·혜택·변경·취소': { flow: [ { title: '매장·시간 선택', text: '고객이 매장과 이용 시간을 고르고 실제 가능한 시간만 확인합니다.' }, { title: '혜택 적용', text: '보유 멤버십·이용권과 할인 조건을 예약 전에 확인합니다.' }, { title: '예약 확정', text: '선택 정보와 결제 결과를 하나의 예약 건으로 만듭니다.' }, { title: '변경·취소', text: '고객이 허용된 시간 안에서 직접 변경·취소를 처리합니다.' }, { title: '이력 안내', text: '결과와 남은 혜택을 예약 이력과 알림에서 바로 보여줍니다.' } ], policies: [ 'GolfHub 예약 화면과 혜택 확인 흐름은 유지하고, 탐색에서 고른 매장 조건만 예약에 전달합니다.', '예약 가능 시간과 혜택은 결제 직전에 다시 확인해 중복 예약과 잘못된 차감을 막습니다.', '변경·취소 마감과 환불 기준은 고객 앱과 매장 화면에서 같은 정책으로 처리합니다.' ], done: '고객이 탐색부터 예약 변경·취소까지 한 흐름으로 처리하고, 매장 화면의 예약 상태와 일치하면 완료입니다.' }, '대회 참가·팀 결제': { flow: [ { title: '대회 선택', text: '고객이 대회 일정·조건·참가 가능 여부를 확인합니다.' }, { title: '참가자 등록', text: '개인 또는 팀 구성원을 참가 번호에 연결합니다.' }, { title: '팀 결제', text: '결제 담당자와 금액을 정하고 실제 결제를 진행합니다.' }, { title: '라운드 연결', text: '참가 번호로 예약·라운드 결과·랭킹을 이어 붙입니다.' }, { title: '이력 제공', text: '고객은 대회 이력에서 신청·결제·결과를 함께 확인합니다.' } ], policies: [ '참가자와 팀은 하나의 참가 번호로 관리해 결제와 결과가 분리되지 않게 합니다.', '팀 결제의 결제자·분담·취소·환불 기준은 참가 신청 전에 명확히 보여줍니다.', '대회 결과는 확정 뒤 수정 이력을 남기고, 고객 이력에는 본인 참가 정보만 보여줍니다.' ], done: '참가 신청부터 팀 결제·라운드 결과·랭킹까지 하나의 참가 번호로 이어져 고객 이력에서 확인되면 완료입니다.' }, '지도·시설 필터·즐겨찾기': { flow: [ { title: '매장 기준 정리', text: '매장 위치·시설·운영 시간·예약 가능 정보를 GolfHub 기준으로 모읍니다.' }, { title: '탐색 조건 적용', text: '거리·시설·지역 필터와 즐겨찾기 조건으로 매장을 찾습니다.' }, { title: '매장 선택', text: '고객이 비교한 매장을 선택해 상세와 예약 가능 시간을 봅니다.' }, { title: '예약 정보 전달', text: '선택한 매장과 조건을 GolfHub 예약 단계로 그대로 전달합니다.' }, { title: '개인화 유지', text: '즐겨찾기와 최근 탐색을 로그인한 고객 계정에 보관합니다.' } ], policies: [ '매장 위치·시설·운영 여부는 한 곳에서 관리해 지도와 예약 화면의 정보가 다르지 않게 합니다.', '위치 권한을 주지 않아도 지역 검색과 기본 정렬로 매장을 찾을 수 있게 합니다.', '즐겨찾기는 고객 개인 정보로 관리하고 매장 운영 상태가 바뀌면 예약 가능 여부를 우선 표시합니다.' ], done: '고객이 필터로 매장을 고른 뒤 같은 매장·조건이 예약 가능 시간 조회까지 이어지고, 즐겨찾기가 다음 방문에도 유지되면 완료입니다.' }, '스코어·샷·클럽·연습 분석': { flow: [ { title: '플레이 연결', text: 'QR 또는 로그인으로 고객·기기·라운드를 먼저 연결합니다.' }, { title: '기록 수집', text: '스코어·샷·클럽·연습 결과를 라운드 단위로 받습니다.' }, { title: '정합성 확인', text: '기기·시간·라운드 기준으로 중복과 누락을 점검합니다.' }, { title: '이력 통합', text: '개인 기록과 대회 결과를 한 플레이 이력에 모읍니다.' }, { title: '고객 제공', text: '고객은 날짜·매장·라운드별 기록과 분석을 확인합니다.' } ], policies: [ '모든 기록은 고객·기기·라운드 번호를 함께 가져야 다른 사람 기록과 섞이지 않습니다.', '원본 기록과 고객에게 보여줄 분석 결과를 구분해 수정·재계산 이력을 남깁니다.', '대회 결과는 공개 범위를 따르되 개인 연습 기록은 본인 중심으로 분리해 보여줍니다.' ], done: '한 고객의 플레이 결과가 라운드별로 중복 없이 쌓이고, 개인 기록과 대회 결과를 같은 앱 이력에서 확인하면 완료입니다.' }, '혜택 QR과 스크린 QR의 역할': { flow: [ { title: '목적 분리', text: '혜택 확인·예약 체크인·기기 로그인처럼 QR 목적을 먼저 나눕니다.' }, { title: '발급 기준 설정', text: '목적별 발급 주체·유효 시간·한 번 사용 여부를 정합니다.' }, { title: '스캔 검증', text: '스캔 시 QR 목적·고객·예약·기기 상태를 함께 확인합니다.' }, { title: '결과 처리', text: '성공하면 해당 업무만 처리하고 재사용·만료는 즉시 막습니다.' }, { title: '이력 확인', text: '발급·스캔·실패 이력을 고객과 운영자가 필요한 범위에서 확인합니다.' } ], policies: [ '혜택 QR과 기기 로그인 QR은 같은 코드처럼 보이지 않게 목적과 화면 안내를 분리합니다.', 'QR에는 필요한 최소 정보만 담고, 유효 시간과 재사용 가능 여부를 목적별로 다르게 관리합니다.', '스캔 실패 시 고객에게는 다음 행동을 안내하고 운영자에게는 실패 원인을 남깁니다.' ], done: '고객이 QR 목적을 혼동하지 않고, 각 QR이 정해진 업무만 처리하며 발급·사용 이력이 남으면 완료입니다.' }, '스윙 영상·기록 보관과 공개 범위': { flow: [ { title: '동의 확인', text: '영상·플레이 기록 수집과 공유 범위에 대한 고객 동의를 받습니다.' }, { title: '안전 보관', text: '영상과 기록을 고객·라운드 기준으로 분리해 보관합니다.' }, { title: '권한 적용', text: '고객·매장·본사별로 볼 수 있는 정보와 목적을 나눕니다.' }, { title: '공유·삭제 처리', text: '공유 요청과 삭제 요청을 정해진 절차와 기한으로 처리합니다.' }, { title: '보관 종료', text: '보관 기간이 끝난 데이터는 정책에 따라 삭제하고 이력을 남깁니다.' } ], policies: [ '영상과 상세 플레이 기록의 보관 기간은 별도로 정하고 고객에게 명확히 안내합니다.', '매장과 본사는 운영에 필요한 범위만 열람하며, 외부 공유는 고객 동의와 권한 확인 뒤에만 허용합니다.', '삭제 요청·권한 변경·공유 이력은 누가 언제 처리했는지 남겨 분쟁을 확인할 수 있게 합니다.' ], done: '고객이 본인 기록의 보관·공개·삭제 상태를 확인할 수 있고, 각 운영 주체가 허용된 범위만 볼 수 있으면 완료입니다.' }, '개발용 사용자 선택·시험 결제·비활성 기능': { flow: [ { title: '정리 대상 확인', text: '운영 화면에 남은 시험 계정·시험 결제·미완성 메뉴를 찾습니다.' }, { title: '실제 흐름 교체', text: '실사용 로그인·실결제·서버 저장이 되는 경로로 바꿉니다.' }, { title: '진입 차단', text: '운영 앱에서는 테스트 선택과 비활성 메뉴가 보이지 않게 합니다.' }, { title: '운영 검증', text: '실제 계정·결제·취소·알림 시나리오를 반복 확인합니다.' }, { title: '배포 점검', text: '출시 뒤 테스트 데이터와 오류 진입이 없는지 관찰합니다.' } ], policies: [ '시험 계정과 시험 결제는 운영 앱에 노출하지 않고 별도 검증 환경에서만 사용합니다.', '운영 화면에는 실제 저장·결제·알림까지 완료된 기능만 노출합니다.', '기능을 제거하기 전 고객이 쓰는 대체 경로와 기존 데이터 처리 기준을 확인합니다.' ], done: '실고객이 테스트 화면을 보지 않고 실제 인증·결제·저장 결과만 이용하며, 비활성 메뉴로 진입할 수 없으면 완료입니다.' } }; const integrationGuidance = (steps, policies, done) => ({ flow: steps.map(([title, text]) => ({ title, text })), policies, done }); Object.assign(integrationGuidanceCatalog, { '예약 입력값·변경 이력 보완': integrationGuidance([ ['항목 기준 정리', '예약자·연락처·인원·타석·시간·메모·변경 사유 중 실제로 저장할 항목을 하나로 정합니다.'], ['생성·수정 연결', '예약 생성과 수정에서 같은 항목을 저장하고, 화면에만 바뀌는 값이 없게 합니다.'], ['이동 검증', '시간·타석 이동 때 변경 사유와 충돌 검증 결과를 예약 건에 함께 기록합니다.'], ['이력 남기기', '변경 전후 값, 처리자, 시각, 사유를 예약 상세의 이력에서 시간순으로 확인하게 합니다.'], ['화면 대사', '예약표·상세·회원 이력·고객 안내가 같은 예약 정보를 보여주는지 점검합니다.'] ], [ '화면에서 수정할 수 있는 항목은 반드시 저장·조회·이력 화면에서도 같은 값으로 확인돼야 합니다.', '관리자 강제 변경은 일반 변경과 구분하고 사유와 처리자를 필수로 남깁니다.', '기존 예약 이력은 덮어쓰지 않고 새 변경 건으로 이어 붙여야 합니다.' ], '직원이 생성·수정·이동한 예약이 예약표와 상세, 이력에서 같은 값과 사유로 확인되면 완료입니다.'), '실시간 타석 상태·예약 연결': integrationGuidance([ ['연결 기준 만들기', '예약·타석·라운드가 어떤 번호로 연결되는지 정하고, 현장 접수 건도 같은 기준으로 만듭니다.'], ['상태 표준화', '예약됨·체크인·진행중·종료·지연 같은 상태와 전환 조건을 GolfHub 기준으로 통일합니다.'], ['실시간 반영', '장비 상태와 실제 이용 시간이 들어오면 예약표와 타석 현황에 같은 시각으로 갱신합니다.'], ['현장 조치 연결', '열기·연장·이동·종료를 실행하면 예약 상태, 다음 예약, 이용 이력이 함께 바뀌게 합니다.'], ['불일치 정정', '장비 상태와 예약이 다르면 담당자가 사유를 남겨 정정하고 본사도 이력을 확인합니다.'] ], [ '타석 상태만 바꾸거나 예약 상태만 바꾸는 단독 처리는 허용하지 않습니다.', '현장 신호가 늦거나 실패한 경우 마지막 정상 상태와 확인 필요 표시를 함께 보여줍니다.', '수동 정정과 강제 종료는 역할 권한과 사유 입력을 필수로 합니다.' ], '예약표의 현재 상태·남은 시간·다음 예약이 현장과 본사에서 같은 값으로 보이면 완료입니다.'), '매장 정보·운영시간 설정 완성': integrationGuidance([ ['매장 기준 정리', '매장명·연락처·사진·주소·운영시간·휴무일의 관리 주체와 수정 권한을 정합니다.'], ['요일별 시간 분리', '대표 요일 값을 복사하지 않고 요일별 운영 시간과 요금을 독립적으로 저장합니다.'], ['예외 일정 등록', '임시 운영시간·휴무일·공휴일은 기간과 사유를 함께 저장하고 만료 후 자동 해제합니다.'], ['예약 가능 시간 반영', '저장된 시간·휴무·타석 설정을 예약 생성과 고객 예약 가능 시간에 즉시 적용합니다.'], ['발행 확인', '매장 운영 화면, 본사 매장 정보, 고객 안내에 같은 정보가 보이는지 확인합니다.'] ], [ '예약에 영향을 주는 시간과 휴무일은 미래 예약 충돌을 먼저 검사한 뒤 변경합니다.', '사진과 안내 정보는 저장 성공 뒤에만 고객 화면에 노출합니다.', '기본 운영시간과 일시 예외는 같은 값으로 관리하지 않고 우선순위를 명확히 둡니다.' ], '고객이 예약한 시간과 매장·본사 화면의 운영 정보가 항상 일치하면 완료입니다.'), '본사·매장 권한 경계 보완': integrationGuidance([ ['역할표 확정', '본사·조직·매장 관리자와 직원별로 볼 수 있는 메뉴와 실행할 수 있는 업무를 정합니다.'], ['화면 진입 제한', '본사 대회 생성·투어·정책 화면은 허용된 역할에게만 보이게 합니다.'], ['처리 권한 일치', '메뉴를 숨기는 데서 끝내지 않고 저장·삭제·조회 요청도 같은 권한으로 차단합니다.'], ['대리 진입 관리', '본사가 매장 운영툴에 들어갈 때 대상 매장·목적·처리자를 함께 기록합니다.'], ['권한 점검', '역할별로 실제 가능한 화면과 처리 결과를 정기적으로 확인합니다.'] ], [ '화면의 버튼 노출 여부와 실제 데이터 처리 권한은 반드시 같은 역할 규칙을 사용합니다.', '본사 대리 업무는 원 매장 담당자가 확인할 수 있는 이력으로 남겨야 합니다.', '권한 변경은 즉시 반영하고 퇴사·매장 이동 계정은 기존 권한을 자동으로 제거합니다.' ], '매장 관리자가 본사 전용 업무를 실행할 수 없고, 본사 지원 업무는 모두 이력으로 남으면 완료입니다.'), '본사 대회 수락 결과 연결': integrationGuidance([ ['초대 조건 등록', '본사가 대상 매장·수락 기한·bay fee·참가 조건을 대회 건에 등록합니다.'], ['매장 수락 처리', '매장은 조건을 확인해 수락·거절하고 사유와 처리 시각을 남깁니다.'], ['본사 결과 확인', '본사는 매장별 응답과 미응답 현황을 같은 대회 상세에서 확인합니다.'], ['운영 범위 확정', '수락한 매장만 예약 가능 시간·참가자 접수·참가비 처리 대상으로 전환합니다.'], ['마감 검증', '기한 이후 변경과 예외 승인을 이력으로 남기고 최종 참여 매장을 확정합니다.'] ], [ '수락 기한이 지난 매장은 본사 승인 없이는 참가 대상으로 자동 전환하지 않습니다.', 'bay fee와 참가 조건은 수락 시점의 값을 보관해 이후 정책 변경과 분리합니다.', '거절·취소된 매장의 예약과 결제 후보는 즉시 대회 대상에서 제외합니다.' ], '본사와 매장이 같은 대회별 수락 결과와 대상 매장을 확인하고, 확정 매장만 운영에 반영되면 완료입니다.'), 'GolfHub 예약 운영 기본 흐름': integrationGuidance([ ['예약표 유지', '주간·일자 타임라인과 현재 시간선, 예약 상태·채널 범례를 현재 구조대로 유지합니다.'], ['배정 유지', '자동·수동 타석 배정과 충돌·과거 시간 검증을 기존 예약 생성 흐름에 둡니다.'], ['상태 처리 유지', '승인대기 승인·거절, 노쇼, 취소와 환불 예상 안내를 같은 예약 상세에서 처리합니다.'], ['이력 유지', '예약 생성·상태·이동·시간·메모 변경 이력을 기존 예약 건에 계속 쌓습니다.'], ['현장 기능 결합', '새 타석 상태와 제어 기능은 예약 화면을 대체하지 않고 예약 상세에 연결합니다.'] ], [ '고객과 매장이 이미 사용하는 예약 흐름은 새 기능 때문에 화면 구조를 다시 배우지 않게 합니다.', '취소·노쇼·승인 규칙은 고객 앱, 매장, 본사에서 같은 상태명과 순서로 보여야 합니다.', '새 현장 기능도 기존 예약 번호를 기준으로 연결해 중복 예약을 만들지 않습니다.' ], '기존 예약 동선은 그대로 쓰면서 현장 상태와 결제 결과가 같은 예약 상세에 추가되면 완료입니다.'), '본사 플랜→매장 판매 구조': integrationGuidance([ ['본사 카탈로그 유지', '본사가 플랜 템플릿·혜택·티어·권장가격을 관리하는 구조를 기준으로 둡니다.'], ['매장 채택 유지', '매장은 허용된 본사 플랜을 판매 상품으로 채택하고 판매 상태를 관리합니다.'], ['현장 판매 연결', 'POS에서 팔린 상품도 같은 플랜·상품 기준으로 주문과 회원 권리에 연결합니다.'], ['상품 보호 유지', '사용 중인 상품은 핵심 조건을 임의로 바꾸거나 삭제하지 못하게 합니다.'], ['매출 집계 연결', '판매·환불·사용 결과를 상품·매장·본사 매출에 같은 기준으로 반영합니다.'] ], [ '상품명·가격·혜택·판매 기간의 기준은 본사가 관리하고 매장은 허용 범위 안에서만 판매합니다.', '현장 상품과 멤버십 플랜은 이름이 같아도 상품 번호와 권리 규칙이 다르면 별도로 관리합니다.', '사용 중인 상품의 정책 변경은 새 버전으로 만들고 기존 가입자의 권리는 보존합니다.' ], '본사 정책, 매장 판매, 현장 주문, 회원 권리, 매출 집계가 하나의 상품 기준으로 이어지면 완료입니다.'), '대회의 본사·매장 운영 구조': integrationGuidance([ ['주최자 구분 유지', '본사 시즌·투어, 매장 주최, 개인 주최 대회를 현재 소유 구조대로 구분합니다.'], ['생성 책임 유지', '본사 대회는 본사에서, 매장 대회는 매장 운영 화면에서 생성하게 합니다.'], ['참여 절차 연결', '본사 초대 대회는 매장 수락 결과를 거쳐 대상 매장에만 운영되게 합니다.'], ['결과 운영 유지', '참가자·리더보드·CSV·수정 이력은 대회 소유자와 권한에 맞춰 관리합니다.'], ['매출 연결', '참가비와 환불은 해당 대회·매장·참가 번호에 연결해 본사 집계로 보냅니다.'] ], [ '대회 소유자와 생성 권한, 수정 권한, 삭제 권한은 서로 다를 수 있으므로 역할별로 분리합니다.', '대회 규칙과 참가비는 접수 시작 뒤 변경 이력을 남기고 기존 참가자에게 적용 기준을 보존합니다.', '본사 화면은 매장 대회를 모더레이션할 수 있어도 매장 생성 흐름을 대체하지 않습니다.' ], '대회의 주최자·참가 매장·결과·매출이 명확히 구분되면서 한 시스템에서 조회되면 완료입니다.'), '회원 360°·매출 롤업': integrationGuidance([ ['회원 기준 유지', '등급·포인트·구매·방문·대회 활동을 GolfHub 회원 번호 중심으로 계속 쌓습니다.'], ['매장 분석 유지', '매장에서는 회원 LTV·방문 성향·구매 이력과 Bay·멤버십 매출을 함께 봅니다.'], ['본사 롤업 유지', '본사는 매장별·지역별·채널별 매출을 롤업과 CSV로 확인합니다.'], ['현장 결과 연결', 'POS 판매와 실제 이용 결과를 같은 회원·예약·상품 번호에 넣습니다.'], ['CS 이력 보호', '본사 CS 메모와 매장 운영 메모는 목적과 열람 권한을 분리해 보관합니다.'] ], [ '회원 번호는 이관 뒤에도 바뀌지 않는 기준으로 두고 중복 고객은 별도 병합 절차로 처리합니다.', '현장 판매·환불은 매출 롤업에 자동 반영하되 원 거래와 처리자를 추적할 수 있어야 합니다.', '본사는 필요한 조회 권한만 갖고 매장 운영자가 입력한 메모의 열람 범위를 정책으로 정합니다.' ], '회원의 현장 이용과 판매 결과가 매장 분석과 본사 롤업에서 같은 고객·금액 기준으로 보이면 완료입니다.'), '본사 매장 목록·운영툴 대리 진입': integrationGuidance([ ['매장 선택 유지', '본사 매장 목록에서 대상 매장을 검색하고 마스터 정보와 운영 범위를 확인합니다.'], ['운영 화면 진입', '선택한 매장의 예약 그리드로 필요한 매장 컨텍스트를 넘겨 지원 업무를 시작합니다.'], ['권한 재확인', '대리 진입 뒤에도 본사 역할이 허용된 메뉴와 처리만 보이도록 다시 검증합니다.'], ['처리 이력 남기기', '누가 어느 매장에 왜 들어가 어떤 변경을 했는지 매장과 본사가 확인할 수 있게 남깁니다.'], ['업무 종료', '지원이 끝나면 원래 본사 화면으로 돌아가고 매장 세션으로 전환되지 않게 합니다.'] ], [ '대리 진입은 고객 지원과 운영 지원 목적에 한정하고 일반 매장 계정 공유로 사용하지 않습니다.', '매장별로 허용된 본사 지원 범위가 다르면 그 설정을 우선 적용합니다.', '대리 처리로 생긴 예약·환불·설정 변경도 원 매장 이력에 처리자와 함께 기록합니다.' ], '본사가 필요한 매장만 안전하게 지원하고, 매장은 대리 처리 내역을 확인할 수 있으면 완료입니다.'), '실시간 타석 현황·정렬': integrationGuidance([ ['상태 수집', '타석별 빈 상태·진행 중 게임·남은 시간·다음 예약을 예약·장비 정보에서 모읍니다.'], ['현황 카드 구성', '직원이 한 화면에서 타석명, 고객·예약, 시작·종료 예상, 다음 예약을 읽게 합니다.'], ['정렬 제공', '기본순과 종료 임박순처럼 현장 판단에 필요한 정렬 기준을 제공합니다.'], ['상세 연결', '카드를 누르면 예약 상세, 타석 제어, 스코어카드 같은 후속 업무로 바로 이동합니다.'], ['예외 표시', '지연·장비 오류·다음 예약 충돌은 일반 상태와 다르게 표시해 우선 조치하게 합니다.'] ], [ '현황 화면은 예약표를 대체하지 않고 현장 판단을 빠르게 하는 보조 화면으로 둡니다.', '남은 시간과 다음 예약은 서버 기준 시간을 사용해 사용자 PC 시간에 따라 달라지지 않게 합니다.', '상태 신호가 오래되면 마지막 수신 시각과 확인 필요 상태를 함께 보여줍니다.' ], '직원이 타석 현황만 보고 우선 조치할 타석과 다음 예약 충돌을 판단할 수 있으면 완료입니다.'), '타석 열기·시간 변경·이동·종료': integrationGuidance([ ['대상 선택', '직원은 현재 예약 또는 현장 접수 건을 고르고 배정 타석과 실제 상태를 확인합니다.'], ['가능 범위 계산', '운영시간·다음 예약·타석 고장·이용권 조건을 반영해 가능한 동작과 시간만 제안합니다.'], ['현장 동작 실행', '열기, 시간 연장·단축, 타석 이동, 종료를 하나의 예약 맥락에서 실행합니다.'], ['후속 일정 재계산', '동작이 끝나면 기존·새 타석의 다음 예약과 예약 가능 시간을 즉시 다시 계산합니다.'], ['이력과 매출 반영', '실제 이용 시간, 처리자, 사유, 추가 요금·환불 영향을 예약과 매출 이력에 남깁니다.'] ], [ '타석 이동은 기존 타석 종료와 새 타석 시작을 분리하지 않고 하나의 처리로 기록합니다.', '연장으로 다음 예약이 영향을 받으면 직원에게 가능한 선택지와 고객 안내 기준을 함께 보여줍니다.', '강제 종료와 수동 시간 변경은 별도 권한과 사유 입력이 있어야 합니다.' ], '직원이 예약표 또는 타석 현황에서 네 가지 조치를 처리하고 모든 결과를 같은 이력에서 확인하면 완료입니다.'), '현장 POS·카드/현금·영수증': integrationGuidance([ ['주문 만들기', '카운터에서 일반상품·이용권·예약 수납 항목을 하나의 주문에 담습니다.'], ['회원·예약 연결', '주문을 회원과 필요하면 예약에 연결해 혜택·이용권·방문 이력이 이어지게 합니다.'], ['결제 처리', '카드·현금 등 결제 수단별 승인 결과와 미결제 상태를 주문 번호에 기록합니다.'], ['상세 취소 처리', '환불할 결제 항목·금액·사유를 원 주문에서 선택하고 처리 결과를 남깁니다.'], ['영수증·매출 반영', '판매·취소 영수증을 재출력할 수 있게 하고 결과를 매장·본사 매출에 반영합니다.'] ], [ '판매와 환불은 수기 매출이 아니라 반드시 하나의 주문 번호를 기준으로 처리합니다.', '상세 취소는 원 결제 금액과 이미 취소한 금액을 넘을 수 없게 검증합니다.', '결제 승인 실패와 단말 오류는 주문을 완료로 바꾸지 않고 재시도·미결제 흐름으로 남깁니다.' ], '카운터에서 상품 판매부터 취소·영수증까지 처리한 결과가 회원·예약·매출에 같은 금액으로 반영되면 완료입니다.'), '타석 이용이력·스코어카드·증서': integrationGuidance([ ['이용 완료 기록', '타석 종료 때 예약·타석·라운드·실제 이용 시간을 하나의 이용 이력으로 만듭니다.'], ['이력 검색', '기간·타석·게임 모드·완료 상태로 현장 이용 기록을 빠르게 찾게 합니다.'], ['스코어카드 연결', '선택한 이용 이력에서 홀별 결과와 라운드 정보를 같은 화면으로 확인합니다.'], ['증서 발급', '발급 대상 기록을 확인한 뒤 개인·팀 증서를 만들고 발급 이력을 남깁니다.'], ['회원·대회 연결', '필요한 경우 이용 기록을 회원 활동과 대회 결과에 연결하되 목적별 공개 범위를 구분합니다.'] ], [ '스코어카드는 예약·타석·라운드 기준을 모두 확인한 뒤 다른 고객 기록과 섞이지 않게 표시합니다.', '증서는 발급 대상 조건과 재발급 권한을 분리하고 발급 이력을 보관합니다.', '일반 이용 기록과 대회 공식 결과는 수정·공개 규칙을 같은 방식으로 처리하지 않습니다.' ], '직원이 한 이용 건의 실제 시간·스코어카드·증서 상태를 추적하고 회원 이력에서도 필요한 결과를 확인하면 완료입니다.'), '기존 이용권·휴회·환불 이관 기준': integrationGuidance([ ['정책 확정', '시간·횟수·기간 이용권의 차감 단위, 유효기간, 휴회, 수동 조정, 환불 규칙을 확정합니다.'], ['변환표 만들기', '기존 상품별로 GolfHub의 대상 상품·잔여량·시작일·종료일 변환 규칙을 만듭니다.'], ['이력 이관', '회원 번호와 함께 잔여 권리, 사용·휴회·환불 이력을 순서대로 옮깁니다.'], ['병행 대사', '전환 전후에 회원별 잔여량·사용량·환불 금액을 비교해 차이를 확인합니다.'], ['운영 전환', '이관이 끝난 회원부터 GolfHub에서만 사용·휴회·환불을 처리하고 기존 원장은 읽기 전용으로 둡니다.'] ], [ '이용권 정책을 확정하기 전에는 이관을 시작하지 않고 예외 회원 목록을 별도로 관리합니다.', '휴회와 수동 조정은 일반 차감과 분리해 승인자·사유·기간을 반드시 남깁니다.', '한 회원의 권리가 이중으로 사용되지 않도록 전환 시점과 책임 시스템을 명확히 정합니다.' ], '기존 회원이 전환 뒤에도 남은 권리와 이력을 정확히 확인하고, 매장과 본사가 같은 잔여량을 보면 완료입니다.'), '일반·패키지 상품과 판매 채널': integrationGuidance([ ['상품 유형 정의', '멤버십, 이용권, 일반상품, 패키지, 레슨상품을 어떤 기준으로 구분할지 정합니다.'], ['판매처 결정', '매장 POS·고객 앱·기타 판매처 중 각 상품을 어디에서 팔 수 있는지 정합니다.'], ['가격·혜택 연결', '본사 권장가격과 매장 판매가격, 할인·혜택·유효기간의 변경 권한을 나눕니다.'], ['매장 활성화', '매장이 판매할 상품만 채택하고 판매 중지·재고·노출 상태를 관리하게 합니다.'], ['거래 이력 보존', '기존 판매 건은 당시 상품 구성과 가격을 보존하고 새 정책은 새 판매부터 적용합니다.'] ], [ '상품명은 같아도 차감 방식이나 판매처가 다르면 별도 상품으로 관리합니다.', '판매 채널마다 다른 가격·혜택을 허용할지 먼저 정하고, 허용하지 않으면 한 가격 기준만 사용합니다.', '패키지 구성 변경은 이미 판매된 패키지의 권리와 영수증을 바꾸지 않게 새 버전으로 처리합니다.' ], '한 상품이 어느 매장·화면에서 얼마에 팔리고 어떤 권리를 주는지 한 기준으로 설명되면 완료입니다.'), '직원·레슨·라커·문자 운영 범위': integrationGuidance([ ['매장 유형 분류', '일반 매장, 레슨 매장, 라커 운영 매장 등 필요한 운영 모듈을 매장 유형별로 분류합니다.'], ['모듈 선택', '직원 계정, 레슨 일정, 라커 배정, 문자 발송을 필요한 매장에만 켜도록 설정합니다.'], ['역할 배정', '직원·강사·매니저·본사별로 조회·예약·환불·발송·설정 권한을 나눕니다.'], ['개인정보 보호', '회원 연락처·레슨 정보·문자 수신 동의는 필요한 업무 범위에서만 보이게 합니다.'], ['운영 점검', '매장별 켜진 모듈과 담당자 권한, 발송·변경 이력을 본사에서 점검합니다.'] ], [ '모든 매장에 같은 메뉴를 강제하지 않고 사용하지 않는 모듈은 기본으로 숨깁니다.', '문자 발송은 수신 동의·발송 목적·잔액·이력을 함께 관리합니다.', '레슨 프로의 일정과 회원 레슨권은 한쪽만 수정돼도 예약이 어긋나지 않게 같은 기준으로 연결합니다.' ], '각 매장에는 필요한 운영 메뉴만 보이고, 직원별 권한과 회원 정보 접근 범위가 명확하면 완료입니다.'), '무인 장치·PIN 운영 수준': integrationGuidance([ ['목표 결정', '무인 운영을 안내 수준으로 둘지 도어락·브릿지·키오스크까지 실제 연동할지 결정합니다.'], ['장치 기준 수립', '연동할 장치, 상태값, 예약과의 연결 방식, 장치 공급사 책임 범위를 정합니다.'], ['PIN 정책 설정', '발급 시점·유효시간·재사용·재전송·임시 PIN과 고객 안내 기준을 정합니다.'], ['장애 대응 설계', '장치 오프라인·발급 실패·문열림 실패 시 현장·CS·본사의 처리 책임과 절차를 만듭니다.'], ['운영 선택 반영', '실연동을 하지 않으면 mock 상태와 무인 운영 메뉴를 고객·매장 화면에서 제거합니다.'] ], [ '실제 장치 연동이 없는 상태를 정상 운영 상태처럼 표시하지 않습니다.', 'PIN과 출입 이력은 개인정보·보안 정보로 분리하고 열람 권한과 보관 기간을 정합니다.', '장치 장애 시 고객을 막지 않도록 대체 입장·현장 지원 절차를 준비합니다.' ], '무인 운영의 실제 범위와 장애 책임이 정해지고, 선택한 수준에 맞는 화면과 이력만 남으면 완료입니다.'), '서비스 이용료·A/S·문자 비용 정산 범위': integrationGuidance([ ['대상 확정', '매장 매출과 별도로 관리할 서비스 이용료·A/S 비용·문자 비용의 범위를 정합니다.'], ['청구 기준 설정', '청구 주기, 과세·면세, 할인·면제, 증빙 자료와 계산 기준을 정합니다.'], ['매장 처리 연결', '매장은 청구 내역을 확인하고 납부·이의·증빙 제출을 같은 흐름에서 처리합니다.'], ['본사 대사', '본사는 청구·납부·미납·조정 결과를 매출 롤업과 분리해 대사합니다.'], ['전환 계획', '통합하지 않는 항목은 기존 도구 사용 기간과 데이터 조회 방법을 명확히 남깁니다.'] ], [ '고객 판매 매출과 프랜차이즈 운영 비용은 같은 화면에 섞지 않고 계정과 책임자를 구분합니다.', '금액 조정과 면제는 승인권자·사유·증빙을 남기고 원 청구 금액을 보존합니다.', '미납 제재가 고객 예약·현장 결제에 미치는 영향은 사업 정책으로 먼저 확정합니다.' ], '매장과 본사가 청구·납부·조정 내역을 같은 기준으로 확인하거나, 병행 도구의 경계가 명확하면 완료입니다.'), 'QMS 전용 매장 판별·원툴 로그인': integrationGuidance([ ['대상 계정 확인', 'QMS 전용 로그인 규칙을 쓰는 매장·직원 계정과 필요한 이력만 목록으로 만듭니다.'], ['공통 계정 연결', '각 계정을 GolfHub 매장·직원 역할에 연결하고 중복·퇴사 계정을 정리합니다.'], ['권한 검증', '로그인 뒤 필요한 기능은 매장 유형·역할 설정으로 제공하고 QMS 서비스 판별은 제거합니다.'], ['전환 안내', '기존 사용자는 GolfHub 로그인 경로로 안내하고 QMS 전용 접속은 신규 업무를 막습니다.'], ['종료 확인', '전환 기간의 로그인 실패·문의·누락 권한을 점검한 뒤 전용 분기를 종료합니다.'] ], [ '로그인 단계에서 특정 레거시 서비스 여부로 사용자를 막지 않고 역할과 매장 설정으로 판단합니다.', '기존 원툴 계정의 비밀번호나 인증 정보를 그대로 복제하지 않고 공통 인증 절차로 전환합니다.', '종료 전 필요한 계정 이력과 감사 기록은 보존하되 신규 업무는 GolfHub로만 들어오게 합니다.' ], '모든 운영자가 GolfHub 공통 계정으로 필요한 업무에만 접근하고 QMS 전용 로그인 분기가 남지 않으면 완료입니다.'), 'QMS 숨김 APP 예약 접수 경로': integrationGuidance([ ['기존 요청 확인', '숨김 경로로 들어온 예약 요청의 사용 여부와 남아 있는 예약 데이터를 확인합니다.'], ['새 접점 지정', '고객 앱 예약은 GolfHub 예약 생성·승인·거절 흐름 한 곳으로만 들어오게 정합니다.'], ['상태 변환', '기존 요청의 예약·결제·취소 상태를 GolfHub 상태값으로 변환해 이력을 연결합니다.'], ['호출 전환', '앱과 외부 연동의 호출 대상을 새 경로로 바꾸고 중복 수신을 차단합니다.'], ['기존 경로 종료', '새 요청이 더 이상 들어오지 않는 것을 확인한 뒤 숨김 QMS 경로는 읽기 전용 기록만 남깁니다.'] ], [ '같은 예약이 기존·신규 경로로 두 번 생성되지 않도록 요청 식별값과 전환 시점을 검증합니다.', '고객 앱에서 보이는 승인·거절·취소 결과는 GolfHub 예약 상태를 최종 기준으로 사용합니다.', '기존 경로를 닫기 전에 예약·결제·환불 이력이 빠짐없이 조회되는지 확인합니다.' ], '고객 앱 예약이 GolfHub 한 경로로만 들어오고 과거 QMS 요청도 같은 예약 이력에서 확인되면 완료입니다.'), 'QMS 코드만 남은 상품·POS 메뉴': integrationGuidance([ ['남은 항목 분류', 'QMS 코드에만 남은 상품·판매·POS 기능을 실제 사용 데이터와 화면 여부로 나눕니다.'], ['필요 기능 선택', '현장에 필요한 장바구니·결제·환불·영수증 동작만 요구사항으로 남깁니다.'], ['GolfHub 재구성', '선택한 동작은 FMS의 실제 운영 흐름을 참고해 GolfHub 상품·주문 기준으로 구현합니다.'], ['레거시 복원 금지', 'QMS의 숨김 메뉴 구조와 화면은 다시 열지 않고 신규 입력도 받지 않습니다.'], ['기록 보존', '사용 중인 상품·주문·정산 데이터만 이관하거나 조회용으로 보존합니다.'] ], [ '코드가 존재한다는 이유만으로 기능을 통합 대상으로 삼지 않고 실제 운영 필요성을 먼저 확인합니다.', '현장 POS는 GolfHub 주문 번호·회원·예약 기준으로 새로 만들고 QMS 내부 화면 구조를 재사용하지 않습니다.', '숨김 메뉴의 데이터가 남아 있어도 신규 거래는 GolfHub에서만 생성하게 합니다.' ], '필요한 현장 판매 기능은 GolfHub에 있고, QMS의 숨김 상품·POS 메뉴를 다시 열 이유가 없어지면 완료입니다.'), 'FMS의 주석 처리 상품 옵션': integrationGuidance([ ['비활성 항목 확인', '태그, 개별 회원 설정, 요일 선택, 고정된 판매 위치처럼 실제 화면에서 꺼진 옵션을 확인합니다.'], ['사업 필요성 판단', '각 옵션이 현재 매장 운영에 실제 필요한지 사업·운영 담당자가 먼저 결정합니다.'], ['정책 우선 설계', '필요한 옵션만 상품 정책·권한·판매 채널 규칙으로 정의한 뒤 GolfHub 모델에 넣습니다.'], ['데이터 선별 이관', '실제로 사용된 상품 조건과 판매 이력만 옮기고 고정값·주석 데이터는 제외합니다.'], ['레거시 종료', '확정되지 않은 옵션은 신규 화면에 노출하지 않고 FMS 화면 복원을 하지 않습니다.'] ], [ '주석 처리되었거나 고정값으로만 동작하는 옵션은 현재 운영 기능으로 간주하지 않습니다.', '새 상품 옵션은 누가 설정하고 어디서 판매하며 예약·이용권에 어떤 영향을 주는지 함께 정해야 합니다.', '과거 판매 이력은 당시 조건을 보존하되 불필요한 화면 옵션까지 복제하지 않습니다.' ], '필요한 상품 조건만 GolfHub 정책과 화면에 남고, FMS의 비활성 옵션을 그대로 옮기지 않으면 완료입니다.') }); Object.assign(integrationGuidanceCatalog, { '운영자 2차 인증·신뢰 PC': integrationGuidance([ ['대상 업무 정하기', '환불·권한 변경·정산처럼 추가 인증이 필요한 역할과 업무를 먼저 정합니다.'], ['추가 인증 요청', '등록된 휴대폰으로 일회성 인증을 보내고, 만료·재전송·실패 횟수를 같은 기준으로 처리합니다.'], ['신뢰 PC 등록', '인증이 끝난 기기만 사용자가 선택한 기간 동안 신뢰 PC로 등록하고, 매장·계정 기준으로 목록을 남깁니다.'], ['민감 업무 재확인', '신뢰 PC여도 위험도가 높은 변경은 최근 인증 여부를 다시 확인한 뒤 실행합니다.'], ['이력·복구 관리', '성공·실패·해제·강제 로그아웃을 기록하고, 분실 기기는 본인이나 관리자가 즉시 해제하게 합니다.'] ], [ 'IP 주소만으로 신뢰 PC를 판단하지 않고, 계정·기기·만료 시각을 함께 검증합니다.', '신뢰 PC의 유효기간과 강제 해제 권한은 역할과 매장 범위에 맞춰 제한합니다.', '권한·결제·회원 정보 변경은 처리자와 추가 인증 결과를 감사 이력에 남깁니다.' ], '신뢰하지 않은 기기에서는 추가 인증 없이는 민감 업무를 처리할 수 없고, 매장과 본사가 같은 보안 이력을 확인하면 완료입니다.'), '실고객 시작·가입·권한 흐름': integrationGuidance([ ['운영 진입 분리', '개발용 진입 경로와 운영용 시작 화면을 분리하고 운영 빌드에서는 고객 시작 화면만 열리게 합니다.'], ['시작 조건 확인', '앱 시작 때 점검·필수 업데이트·로그인 상태를 확인한 뒤 가능한 다음 단계만 보여줍니다.'], ['가입·계정 연결', '로그인·가입·기존 고객 연결을 하나의 고객 번호로 처리해 예약·멤버십·플레이 기록이 분리되지 않게 합니다.'], ['권한 목적 안내', '위치·카메라·알림은 필요한 순간에 목적을 설명하고 고객이 거절해도 가능한 기능과 불가능한 기능을 구분해 안내합니다.'], ['복구·종료 처리', '로그아웃·토큰 만료·계정 찾기·탈퇴 요청 뒤에는 개인 예약과 결제 정보가 다른 계정에 남지 않게 정리합니다.'] ], [ '운영 화면에서 개발 토큰·임시 사용자·매장 선택값을 고객 식별값으로 사용하지 않습니다.', '가입 전 약관 동의와 개인정보 수집 항목은 고객 계정 생성 시점에 기록합니다.', '카메라·위치·알림 권한은 거절했을 때도 앱이 멈추지 않고 대체 행동을 안내해야 합니다.' ], '신규 고객이 정식 가입부터 로그인·권한 설정을 마치고 본인 예약·멤버십·기록만 보는 상태가 되면 완료입니다.'), '예약 조건·홀드 값 실제 반영': integrationGuidance([ ['예약 항목 계약', '타석 유형·손잡이·인원·이용 시간처럼 실제 지원할 항목과 값 범위를 하나로 정합니다.'], ['가용성 조회 연결', '고객이 고른 값만 가용성 조회에 전달하고 지원하지 않는 선택지는 화면에서 숨기거나 비활성화합니다.'], ['홀드 스냅샷 생성', '선택값·가격 조건·만료 시각을 임시 홀드에 함께 저장해 다음 단계에서 값이 바뀌지 않게 합니다.'], ['견적·예약 확정 대사', '서버 견적과 예약 상세가 같은 홀드 값을 사용하게 하고 결제 직전에는 만료·가격 변동을 다시 확인합니다.'], ['예외 안내', '홀드 만료·인원 초과·타석 불가가 발생하면 이전 선택과 다시 고를 항목을 고객에게 명확히 보여줍니다.'] ], [ '화면에 있는 선택값은 가용성·홀드·견적·예약 상세 중 어느 하나에서도 빠지면 안 됩니다.', '지원하지 않는 Private Room이나 More hours 같은 옵션은 임시 문구로 남기지 않고 노출 여부를 결정합니다.', '가격·할인·세금은 결제 화면에서 새로 계산하지 않고 동일한 서버 견적을 기준으로 확정합니다.' ], '고객이 고른 조건이 예약 확인·결제·예약 내역에서 같은 값으로 보이고, 불가능한 조건은 예약 전에 차단되면 완료입니다.'), '실결제·완료 후 안내 연결': integrationGuidance([ ['주문 먼저 만들기', '예약·플랜·대회마다 결제 전 주문 번호를 만들고 금액·고객·상품·예약 정보를 고정합니다.'], ['상품별 결제 호출', '정한 PG와 결제수단만 호출하고, 화면의 결제수단 선택이 실제 승인 요청과 일치하게 합니다.'], ['승인 결과 확정', '성공·실패·취소·중복 응답을 서버에서 판정해 승인된 주문만 예약·구독·참가 상태를 바꿉니다.'], ['모바일 완료 화면', '완료 뒤에는 앱 안에서 영수증·예약 상세·멤버십·대회 참가 화면으로 이어지게 합니다.'], ['실패·재시도 처리', '실패나 앱 이탈 뒤에는 새 주문을 중복 생성하지 않고 기존 주문을 재시도하거나 미결제로 안내합니다.'] ], [ 'Mock·Sandbox·표시용 결제수단은 운영 빌드에서 고객에게 노출하지 않습니다.', '결제 완료 화면은 화면 전환이 아니라 서버 승인 결과를 받은 뒤에만 표시합니다.', '영수증·메일·캘린더 알림은 결제·예약 상태가 확정된 하나의 주문 번호를 기준으로 보냅니다.' ], '실제 결제 결과가 예약·주문·영수증·알림에 같은 금액과 상태로 남고, 고객이 앱 안에서 완료 내용을 다시 열 수 있으면 완료입니다.'), '홈 예약 카드·알림 복귀 연결': integrationGuidance([ ['가장 가까운 예약 선정', '고객별 예정 예약 가운데 시작 시각과 상태가 유효한 건을 홈 카드 대상으로 정합니다.'], ['홈 상태 요약', '매장·시간·현재 상태·출입 PIN·변경 필요 여부만 홈에서 빠르게 확인하게 합니다.'], ['알림 이벤트 정의', '예약 확정·변경·취소·출입 전 알림처럼 고객에게 필요한 사건과 발송 시점을 정합니다.'], ['딥링크 연결', '알림을 누르면 해당 예약·결제·대회 상세로 이동하고 로그인 만료 시에는 로그인 뒤 원래 화면으로 돌아오게 합니다.'], ['상태 새로고침', '앱을 다시 열거나 알림에서 돌아왔을 때 서버 상태를 다시 받아 오래된 PIN·예약 상태가 남지 않게 합니다.'] ], [ '홈에는 다른 고객이나 취소·종료된 예약의 PIN과 정보가 보이면 안 됩니다.', 'PIN은 유효 시간·재발급·노출 범위를 예약 정책과 동일하게 적용합니다.', '푸시를 거절한 고객도 앱 안에서 예약 변경 결과를 확인할 수 있어야 합니다.' ], '고객이 홈과 알림 어느 곳에서 시작해도 같은 예약 상태·PIN·결제 상세에 도착하면 완료입니다.'), '대회 참가·팀 결제 운영화': integrationGuidance([ ['참가 조건 검증', '대회별 참가 가능 기간·인원·팀 규칙·참가비·취소 기준을 신청 전에 서버에서 확인합니다.'], ['팀 상태 연결', '팀 생성·초대·수락·거절·탈퇴를 하나의 팀 번호와 참가 번호에 누적해 모든 화면에서 같은 상태를 보게 합니다.'], ['결제 책임 분리', '대표 결제·분할 결제·미결제 참여자의 책임과 마감 기준을 정하고 실제 승인 결과만 반영합니다.'], ['취소·환불 처리', '개인·팀 단위 취소 때 남은 팀 상태와 환불 가능 금액을 계산해 주문 이력과 함께 남깁니다.'], ['결과 연결', '대회 종료 뒤 참가 번호에 리더보드·시상·결제·환불 결과를 모아 고객이 이력에서 확인하게 합니다.'] ], [ 'stub·하드코딩·Mock 참가 상태는 운영 데이터와 섞지 않고 전환 전에 제거합니다.', '팀 결제 금액의 합계와 참가비, 환불 합계는 대회 주문 금액을 넘을 수 없습니다.', '대표자 변경이나 팀 해산은 이미 승인된 결제와 참가 자격에 미치는 영향을 먼저 안내해야 합니다.' ], '참가 신청부터 팀 상태·실결제·취소·결과까지 한 참가 번호로 추적되고, 고객과 운영자가 같은 상태를 보면 완료입니다.'), '4탭 셸·홈 멤버십 경험': integrationGuidance([ ['진입점 정리', '운영 로그인 뒤 Home·Book·Competition·Me 네 탭으로 들어오게 하고 개발용 선택 화면은 분리합니다.'], ['탭 책임 유지', '예약은 Book, 대회는 Competition, 계정·결제 이력은 Me처럼 각 탭의 역할을 유지합니다.'], ['홈 핵심 정보 연결', '등급·포인트·보유 혜택·선택 매장과 다음 예약을 고객 계정 기준으로 같은 홈에 요약합니다.'], ['공통 이동 규칙', '상세 화면에서 뒤로 가기·탭 이동·로그인 만료 뒤 복귀 위치가 예측 가능하게 동작하도록 정합니다.'], ['상태 동기화', '예약·플랜·대회 결과가 바뀌면 관련 탭을 다시 열었을 때 같은 상태를 보이게 갱신합니다.'] ], [ '새 기능을 추가해도 고객이 어디서 예약·대회·내 정보를 찾는지 바뀌지 않게 탭 책임을 지킵니다.', '홈은 광고성 정보보다 고객이 당장 사용할 등급·혜택·예약 상태를 먼저 보여줍니다.', '동일한 고객 정보는 탭마다 다른 값으로 캐시하지 않고 공통 계정 기준으로 갱신합니다.' ], '개발용 문맥 없이도 고객이 네 탭에서 자기 멤버십·예약·대회·계정 정보를 일관되게 찾으면 완료입니다.'), '플랜·구독·이용권 구매 구조': integrationGuidance([ ['상품 기준 고정', '본사 플랜·매장 플랜·일회 PASS의 판매 가능 매장·가격·혜택·해지 조건을 한 카탈로그로 관리합니다.'], ['구매 전 견적', '첫 결제 일할 계산·무료 PASS·세금·할인을 고객에게 확정 전 한 번에 보여줍니다.'], ['구독 상태 관리', '정기 결제·해지·철회·만료를 고객 멤버십 상태와 주문 이력에 같은 시각으로 반영합니다.'], ['모바일 완료 연결', '구매 성공 뒤에는 데스크탑 경로가 아니라 앱의 멤버십·주문 상세로 돌아가게 합니다.'], ['혜택 적용 확인', '새 플랜이나 이용권 혜택이 다음 예약 견적과 매장 이용에서 언제부터 적용되는지 확인하게 합니다.'] ], [ '상품 설명·가격·해지 조건은 구매 화면과 주문 이력에서 같은 버전을 보여야 합니다.', '무료 PASS와 유료 구독은 결제 여부·해지·만료 규칙을 서로 섞지 않습니다.', '구매 완료 후 외부 데스크탑 화면으로 보내 고객이 앱 맥락을 잃게 하지 않습니다.' ], '고객이 앱에서 플랜을 비교·구매·해지하고, 같은 앱에서 멤버십 상태와 주문 이력을 확인하면 완료입니다.'), '예약 견적·변경·취소·출입 PIN': integrationGuidance([ ['예약 번호 기준 유지', '가용성 조회·홀드·견적·결제·예약 변경과 취소를 하나의 예약 번호와 상태 이력으로 묶습니다.'], ['가격 스냅샷 저장', '타석료·혜택·할인·세금·인원 차액을 결제 시점의 견적으로 저장해 나중에 다시 계산하지 않게 합니다.'], ['변경 가능성 재확인', '시간·인원·타석 변경 때는 새 가용성·차액·취소 조건을 확인한 뒤 고객에게 확정시키게 합니다.'], ['취소·환불 안내', '취소 전 환불 예상액·불가 사유·처리 시점을 보여주고 실제 결과를 주문 이력에 남깁니다.'], ['출입 PIN 제어', '예약 상태·유효 시간·재발급 기준에 따라 PIN을 발급하고 취소·만료 때 바로 무효화합니다.'] ], [ '새 예약 유형도 기존 예약 번호·견적·취소 정책에서 벗어난 별도 흐름으로 만들지 않습니다.', '고객 화면과 매장 운영 화면은 같은 예약 상태·인원·금액·PIN 유효 여부를 보여야 합니다.', '예약 변경 뒤에는 이전 예약과 새 예약이 중복으로 활성화되지 않게 원자적으로 처리합니다.' ], '예약 생성부터 변경·취소·출입까지 고객과 매장이 하나의 예약 번호와 같은 금액·상태를 확인하면 완료입니다.'), '대회 탐색·개인·팀 참가 구조': integrationGuidance([ ['탐색 기준 유지', '진행·예정·종료 상태와 매장·일정·참가 조건으로 대회를 찾는 현재 구조를 유지합니다.'], ['참가 번호 발급', '개인 참가와 팀 참가 모두 고객·대회·팀을 연결한 참가 번호를 발급합니다.'], ['팀 역할 표시', '팀장·초대 대기·수락·결제 대기 같은 역할과 상태를 모든 구성원이 같은 기준으로 보게 합니다.'], ['실시간 결과 연결', '결제·취소·라운드 결과·리더보드 변화가 해당 참가 번호의 상세와 이력에 반영되게 합니다.'], ['종료 후 보관', '대회가 끝나도 참가자·결제·순위·환불 결과를 고객이 이력에서 확인하게 유지합니다.'] ], [ '대회 단계별 탭은 표시용 분류가 아니라 실제 참가 가능 여부와 일치해야 합니다.', '개인·팀 참가를 오가도 중복 참가나 중복 결제가 생기지 않게 검증합니다.', '리더보드 공개 범위와 팀원 정보는 대회 정책 및 고객 동의 범위 안에서만 노출합니다.' ], '고객이 대회를 찾고 참가한 뒤 종료 결과까지 하나의 참가 이력에서 확인하면 완료입니다.'), '저장 카드·결제 이력': integrationGuidance([ ['카드 토큰 관리', '카드 번호 대신 PG 토큰·카드 별칭·만료 정보만 고객 계정에 연결해 저장합니다.'], ['등록·삭제 기준', '새 카드 등록과 기본 카드 변경·삭제 때 사용 중인 구독·미결제 주문 영향을 먼저 확인합니다.'], ['상품별 결제 사용', '예약·플랜·대회가 허용된 동일한 카드 정책과 승인 절차를 사용하게 합니다.'], ['주문 이력 통합', '결제 성공·실패·취소·환불과 영수증을 고객이 한 주문 이력에서 찾게 합니다.'], ['보안·문의 대응', '카드 변경·승인 실패·환불 요청의 시각과 결과를 고객 문의와 운영 대사에 활용할 수 있게 남깁니다.'] ], [ '앱은 카드 실번호나 보안코드를 보관·재표시하지 않습니다.', '카드 삭제는 정기 결제나 미결제 주문이 남아 있으면 대체 카드 또는 해지 절차를 먼저 안내합니다.', '결제 이력에는 실제 승인·환불 결과만 표시하고 Mock 거래를 섞지 않습니다.' ], '고객이 저장 카드를 안전하게 관리하고 모든 상품의 실제 결제·환불 이력을 한곳에서 확인하면 완료입니다.') }); Object.assign(integrationGuidanceCatalog, { '매장 지도·검색·필터·상세': integrationGuidance([ ['탐색 기준 통합', '매장 ID를 기준으로 이름·주소·좌표·시설·영업 정보·즐겨찾기 상태를 한 매장 정보로 정리합니다.'], ['위치·검색 제공', '위치 권한을 허용한 고객에게는 주변 매장을, 거절한 고객에게는 지역·키워드 검색을 제공합니다.'], ['필터·정렬 적용', '시설·거리·예약 가능 여부 같은 조건을 지도와 목록에 같은 기준으로 적용합니다.'], ['매장 상세 확인', '고객이 사진·시설·운영 시간·혼잡 정보·이용 안내를 확인한 뒤 예약으로 넘어가게 합니다.'], ['예약으로 값 전달', '선택한 매장 ID와 필요 조건을 예약 가용성 조회로 전달해 다시 같은 매장을 고르게 하지 않습니다.'] ], [ '지도 마커·목록·매장 상세는 동일한 매장 ID와 운영 정보를 사용해야 합니다.', '위치 정보는 현재 탐색에 필요한 경우에만 사용하고 저장·공유 기준을 고객에게 안내합니다.', '즐겨찾기는 기기별 임시 목록이 아니라 고객 계정에 연결해 로그인한 다른 기기에서도 같은 결과를 보여줍니다.' ], '고객이 지도나 검색에서 고른 매장의 실제 정보와 예약 가능 시간을 같은 흐름에서 확인하고 예약까지 완료하면 됩니다.'), '스크린 QR 로그인': integrationGuidance([ ['QR 역할 분리', '매장 혜택 QR과 스크린 기기 로그인 QR을 이름·진입 화면·발급 주체부터 분리합니다.'], ['기기 QR 검증', '카메라로 읽은 QR이 유효한 매장·기기·타석인지 확인하고 만료·재사용·잘못된 기기 오류를 구분합니다.'], ['고객 세션 연결', '고객 계정과 기기·라운드·예약 정보를 연결해 어떤 화면에서 플레이를 시작했는지 기록합니다.'], ['중복 로그인 방지', '이미 다른 기기에 로그인한 상태나 종료된 예약은 안내하고, 필요한 경우 이전 세션을 명확히 종료합니다.'], ['종료·이력 반영', '플레이 종료·강제 로그아웃·QR 만료 결과를 고객 기록과 매장 운영 이력에 함께 남깁니다.'] ], [ '혜택용 QR을 기기 로그인에 재사용하거나 반대로 기기 QR로 회원 혜택을 적용하지 않습니다.', 'QR에는 고객의 민감 정보를 직접 넣지 않고 서버가 검증할 수 있는 짧은 수명 식별값만 사용합니다.', '기기 로그인 성공·실패·강제 종료는 매장·기기·고객 기준으로 감사할 수 있어야 합니다.' ], '고객이 기기 QR을 스캔하면 본인 라운드만 시작되고, 종료 뒤에는 예약·플레이 기록·기기 상태가 일치하면 완료입니다.'), '플레이 기록·리포트 분석': integrationGuidance([ ['기록 단위 정의', '라운드·훈련·클럽 측정처럼 서로 다른 기록의 시작·종료·고객·기기·매장 연결 기준을 정합니다.'], ['원본 데이터 수집', '기기에서 나온 스코어·샷·거리·정확도는 측정 시각과 출처를 보존한 원본 기록으로 받습니다.'], ['개인 이력 구성', '고객이 날짜·매장·라운드별로 기록을 보고, 대회 결과와 일반 플레이 기록을 구분해 찾게 합니다.'], ['분석 화면 계산', '기간·클럽·훈련 유형별 통계는 실제 누적 기록으로 계산하고 기준 날짜와 표본 수를 표시합니다.'], ['오류 정정 처리', '누락·오류 기록은 고객이 임의로 덮어쓰지 않게 하고 정정 사유와 원본 보존 기준을 둡니다.'] ], [ '예시 수치나 데모 라운드를 실제 고객 통계와 같은 화면에 섞어 보여주지 않습니다.', '대회 결과와 개인 연습 기록은 공개 범위와 정정 권한이 다를 수 있으므로 구분해 관리합니다.', '기기·매장 연동이 실패한 기록은 완성된 결과로 표시하지 않고 재처리 또는 오류 상태로 남깁니다.' ], '고객의 실제 라운드·훈련 데이터만 개인 이력과 분석에 누적되고, 대회 결과와도 필요한 범위에서 연결되면 완료입니다.'), '스윙 영상·커뮤니티': integrationGuidance([ ['영상 출처 연결', '스크린·트레이닝에서 생성된 영상을 고객·매장·라운드·생성 시각에 연결해 소유자를 분명히 합니다.'], ['업로드·처리 관리', '업로드 중·완료·실패 상태와 재시도 방법을 보여주고, 파일 처리 전에는 공개하지 않습니다.'], ['공개 범위 선택', '개인 보관·지인 공유·커뮤니티 공개를 구분해 고객이 영상마다 선택하게 합니다.'], ['커뮤니티 운영', '공개 게시물은 댓글·신고·차단·숨김 처리와 운영자 검토 흐름을 함께 제공합니다.'], ['삭제·보관 처리', '고객 삭제 요청·신고 확정·보관 기간 만료 때 원본·썸네일·공유 링크를 함께 처리합니다.'] ], [ '고객이 명시적으로 공개하지 않은 영상은 매장이나 다른 고객의 커뮤니티에 노출하지 않습니다.', '영상 공개 범위 변경과 삭제는 캐시·공유 링크에도 즉시 반영되도록 처리합니다.', '신고·차단은 콘텐츠 처리 결과와 담당자 이력을 남겨 운영 기준을 확인할 수 있어야 합니다.' ], '영상의 생성부터 공개·신고·삭제까지 고객의 선택과 운영 처리 결과가 일관되게 반영되면 완료입니다.'), '트레이닝·레슨 예약': integrationGuidance([ ['예약 유형 분리', 'Bay 예약과 트레이닝·아카데미·레슨을 상품·자원·소요 시간·취소 규칙이 다른 예약 유형으로 정의합니다.'], ['자원 가용성 연결', '매장 공간·장비·프로 일정·휴게 시간을 함께 확인해 겹치는 예약을 막습니다.'], ['이용권·가격 적용', '레슨권·횟수권·할인·추가 요금을 견적에 반영하고 차감 시점과 환불 기준을 정합니다.'], ['고객·프로 확인', '고객에게는 담당 프로·장소·준비 안내를, 프로에게는 일정·고객 정보·변경 알림을 제공합니다.'], ['결과 이력 연결', '이용 완료 뒤에는 출석·차감·노쇼·다음 예약 권유를 고객 이력과 운영 화면에 남깁니다.'] ], [ '레슨 예약은 Bay 예약의 시간 슬롯만 재사용하지 않고 프로·공간 자원과 이용권 규칙을 함께 검증합니다.', '직원·프로가 고객 예약을 변경할 때는 고객 알림과 변경 사유를 같은 예약 이력에 남깁니다.', '레슨권 차감·복원·환불은 결제 주문과 예약 완료 상태를 모두 확인한 뒤 처리합니다.' ], '고객이 레슨이나 트레이닝을 예약·변경·취소하고, 매장과 프로가 같은 일정·이용권 상태를 보면 완료입니다.'), 'QR 목적과 발급 기준': integrationGuidance([ ['QR 목록 만들기', '회원 혜택·예약 확인·체크인·기기 로그인처럼 필요한 QR 목적을 먼저 목록으로 정합니다.'], ['발급 주체 결정', '각 QR이 고객 앱·매장 단말·본사 시스템 중 어디에서 누구의 요청으로 발급되는지 정합니다.'], ['유효 규칙 정의', '만료 시각·한 번 사용 여부·재발급·동시 사용 가능 여부를 목적별로 정합니다.'], ['검증 결과 설계', '스캔한 쪽에서 고객·예약·기기·매장 조건을 확인하고 성공·실패 이유를 명확히 반환합니다.'], ['안내·이력 연결', '고객 화면에는 쉬운 목적 안내를, 운영 화면에는 발급·스캔·실패 이력을 제공합니다.'] ], [ '하나의 QR이 여러 목적을 동시에 수행하도록 만들지 않고 목적별 데이터와 유효 규칙을 분리합니다.', 'QR 문자열에는 고객 이름·전화번호·결제 정보 같은 민감 정보를 직접 넣지 않습니다.', '만료·취소·기기 교체 뒤에는 기존 QR이 다시 사용되지 않도록 서버에서 차단합니다.' ], '고객과 매장 직원이 QR의 목적을 혼동하지 않고, 각 QR이 정한 대상과 기간 안에서만 동작하면 완료입니다.'), '통합 예약 유형과 필드 예약 범위': integrationGuidance([ ['사업 범위 확정', '스크린·트레이닝·레슨·필드 중 GolfHub 앱이 직접 확정할 예약 유형과 단순 연결할 유형을 결정합니다.'], ['공통 데이터 정의', '유형마다 달라도 되는 항목과 고객·매장·시간·가격·취소 상태처럼 공통으로 관리할 항목을 구분합니다.'], ['상품·가격 기준 연결', '각 예약 유형이 어떤 상품·이용권·할인·결제수단을 쓰는지 한 카탈로그에 연결합니다.'], ['외부 예약 경계', '필드처럼 외부 시스템에서 확정되는 예약은 책임 주체·상태 동기화·고객 문의 창구를 명확히 합니다.'], ['단계별 출시', '우선 유형을 먼저 운영 검증하고, 나머지 유형은 정책과 자원 연동이 준비된 뒤 순차적으로 엽니다.'] ], [ '직접 확정하는 예약과 외부로 연결만 하는 예약을 고객에게 같은 완료 상태처럼 보여주지 않습니다.', '모든 유형은 예약 전 가격·취소 규칙·문의 책임을 고객이 확인할 수 있어야 합니다.', '새 유형을 추가해도 기존 Bay 예약의 가용성·취소·결제 규칙이 바뀌지 않게 분리 검증합니다.' ], '고객이 예약 유형별로 누가 확정하고 어떤 가격·취소 규칙이 적용되는지 이해한 상태에서 예약하면 완료입니다.'), '결제수단·PG·저장 카드 정책': integrationGuidance([ ['상품별 결제 표 만들기', '예약·플랜·대회·이용권마다 허용할 결제수단과 결제 책임 주체를 결정합니다.'], ['PG·가맹점 연결', 'Square·WebView 등 실제 사용할 PG와 가맹점·통화·영수증 발행 주체를 하나의 운영 기준으로 정합니다.'], ['저장 카드 기준', '카드 토큰 보관·기본 카드·만료·삭제·자동 결제 가능 여부를 고객 동의와 함께 정합니다.'], ['예외 처리 통일', '승인 실패·중복 승인·부분 환불·예약 취소 때 재시도·문의·환불 순서를 모든 상품에 적용합니다.'], ['대사·문의 연결', 'PG 거래 번호·GolfHub 주문 번호·예약 번호를 연결해 고객 문의와 매장 정산에서 같은 거래를 찾게 합니다.'] ], [ '결제 화면의 선택지와 실제로 가능한 PG·카드 조건이 다르면 안 됩니다.', '카드 보관·자동 결제는 고객 동의와 해지·삭제 경로를 명확히 제공해야 합니다.', '상품별 환불 규칙이 달라도 원 결제 금액과 승인·취소 이력은 하나의 주문 기준으로 대사합니다.' ], '고객과 운영자가 어떤 상품을 어떤 결제수단으로 처리하는지, 실패·환불 때 무엇을 해야 하는지 같은 기준으로 알면 완료입니다.'), '플레이·영상 데이터와 공개 범위': integrationGuidance([ ['데이터 분류', '스코어·샷·클럽 측정·훈련 기록·스윙 영상처럼 수집할 데이터와 생성 주체를 분류합니다.'], ['보관 기간 정하기', '데이터 종류별 기본 보관 기간·자동 삭제·백업 보존·고객 요청 삭제 기준을 결정합니다.'], ['열람 권한 설정', '고객 본인·프로·매장·본사·커뮤니티가 각각 어떤 데이터까지 볼 수 있는지 역할별로 정합니다.'], ['공유·신고 처리', '고객 공유·공개 전환·신고·차단·다운로드 요청을 동일한 공개 정책으로 처리합니다.'], ['삭제·감사 이력', '삭제·정정·권한 변경은 원인과 처리 결과를 남기고, 필요한 운영 감사 범위만 별도로 보관합니다.'] ], [ '개인 기록은 기본 비공개로 두고 공개나 공유는 고객의 명확한 선택 뒤에만 적용합니다.', '매장·본사 운영자는 업무에 필요한 최소 범위만 열람하게 하고 개인 영상의 임의 열람을 막습니다.', '삭제 요청은 화면에서만 숨기지 않고 원본·미리보기·공유 링크·검색 결과를 함께 처리합니다.' ], '데이터마다 누가 얼마나 오래 보고 공유·삭제할 수 있는지가 고객과 운영자에게 같은 기준으로 적용되면 완료입니다.'), '포인트·알림·내 정보 운영 범위': integrationGuidance([ ['제공 기능 결정', '포인트·등급·알림·프로필·계정 보안 중 운영 앱에서 실제 제공할 기능을 우선순위로 확정합니다.'], ['기준 데이터 연결', '포인트·등급은 멤버십 원장, 알림은 예약·결제·대회 사건, 내 정보는 고객 계정 원장을 기준으로 연결합니다.'], ['화면 책임 배치', '홈 요약·알림함·Me 메뉴에서 어떤 정보를 보여주고 어디서 수정하는지 정합니다.'], ['설정·동의 제공', '알림 채널·마케팅 수신·프로필 수정·계정 탈퇴의 고객 설정과 처리 결과를 제공합니다.'], ['단계적 메뉴 오픈', '아직 준비되지 않은 메뉴는 빈 버튼으로 남기지 않고 기능 준비가 끝난 순서대로 정식 진입점으로 엽니다.'] ], [ '포인트 잔액과 등급은 화면별 계산값이 아니라 하나의 멤버십 원장을 기준으로 보여야 합니다.', '알림 수신 동의와 마케팅 동의는 예약·결제 같은 필수 안내와 구분해 관리합니다.', '미연결 메뉴는 고객에게 노출하지 않고, 제공 범위가 확정된 뒤 모바일 화면과 지원 경로를 함께 엽니다.' ], '고객이 실제 제공되는 포인트·알림·내 정보만 앱 안에서 안전하게 확인·설정할 수 있으면 완료입니다.'), '개발용 컨텍스트·임시 로그인': integrationGuidance([ ['개발 진입 전수 확인', '/entry·임시 토큰·사용자 선택·매장 선택처럼 개발 편의를 위한 모든 진입점을 목록으로 만듭니다.'], ['운영 빌드 차단', '운영 환경에서는 개발 진입 코드와 UI가 번들·라우팅·딥링크 어디에서도 열리지 않게 분리합니다.'], ['정식 로그인 대체', '고객은 정식 로그인·가입·계정 복구로만 들어오고 매장 문맥은 권한과 예약으로 자동 결정되게 합니다.'], ['테스트 환경 분리', '테스트 계정과 테스트 데이터는 운영 고객과 같은 DB·알림·결제 흐름에 섞이지 않게 별도 환경으로 운영합니다.'], ['전환 확인', '배포 전 운영 앱에서 임시 계정 진입·토큰 고정·매장 강제 선택이 모두 차단되는지 점검합니다.'] ], [ '개발 편의 기능을 숨기는 것만으로는 충분하지 않으며 운영 환경에서 실행 자체가 불가능해야 합니다.', '임시 토큰이나 테스트 계정으로 만든 예약·결제 데이터는 운영 고객 이력에 남기지 않습니다.', '정식 인증 전에는 다른 고객·매장 정보가 기본값으로 노출되지 않게 합니다.' ], '운영 앱을 새로 설치한 고객이 개발 화면을 전혀 보지 않고 정식 인증으로만 시작하면 완료입니다.'), 'Mock 결제·고정 Sandbox 카드': integrationGuidance([ ['테스트 흐름 식별', 'Mock 완료 버튼·Sandbox nonce·고정 테스트 카드·표시용 결제수단을 모두 찾아 운영 대상과 분리합니다.'], ['환경별 결제 분리', '개발·검증 환경의 결제 키·카드·웹훅과 운영 PG 설정을 서로 다른 계정과 설정으로 관리합니다.'], ['운영 UI 제거', '운영 앱에는 실제로 승인 가능한 결제수단만 보여주고 테스트 완료 화면과 문구를 제거합니다.'], ['실제 결과 반영', '승인·실패·취소·환불 웹훅을 주문 번호로 대사해 예약·구독·대회 상태를 바꿉니다.'], ['회귀 점검', '결제 실패·앱 종료·중복 클릭·환불 같은 상황에서 완료로 잘못 보이지 않는지 확인합니다.'] ], [ '운영 PG 키와 테스트 키는 코드·환경 변수·로그에서 서로 섞이지 않게 관리합니다.', '화면의 성공 문구는 서버가 실제 승인 결과를 받은 뒤에만 표시합니다.', '테스트 카드는 고객 UI와 고객 문의 이력에 남기지 않습니다.' ], '운영 고객이 실제 승인 결과 없이 결제 완료를 보거나 Sandbox 거래가 주문 이력에 남는 일이 없으면 완료입니다.'), 'FSApp의 주석 처리 구 라우트': integrationGuidance([ ['이전 경로 분류', '주석 처리되었거나 현재 메뉴에서 빠진 대회·이벤트·코스·구 리포트 경로를 사용 여부와 데이터 유무로 나눕니다.'], ['실사용 여부 확인', '최근 사용자 접근·운영 의존·외부 링크·보존해야 할 이력이 있는지 확인해 복원 대상과 종료 대상을 구분합니다.'], ['새 메뉴 연결', '계속 필요한 기능만 새 GolfHub 메뉴·권한·데이터 기준에 맞춰 새 진입점으로 연결합니다.'], ['기존 경로 종료', '종료 대상은 신규 진입·신규 데이터 생성을 막고, 필요하면 고객에게 새 화면 또는 종료 안내로 전환합니다.'], ['기록 보존 점검', '과거 결과·예약·영상 같은 필요한 데이터가 새 이력에서 조회되는지 확인한 뒤 코드를 정리합니다.'] ], [ '코드에 남아 있다는 이유만으로 이전 기능을 운영 앱에 다시 노출하지 않습니다.', '사용 중인 외부 딥링크가 있으면 새 경로 또는 종료 안내로 안전하게 전환합니다.', '과거 데이터 보존과 화면 복원은 별개이며, 조회만 필요하면 읽기 전용 이력으로 남깁니다.' ], '실사용 기능은 새 메뉴에서 정상 이용되고, 이전 경로로는 신규 고객 업무가 더 이상 들어오지 않으면 완료입니다.'), '예시값으로 채운 기록·분석': integrationGuidance([ ['예시 데이터 식별', '기록 부족 시 보여주는 고정 통계·샘플 라운드·예시 차트를 실제 데이터와 구분해 목록으로 만듭니다.'], ['빈 상태 설계', '실제 기록이 없을 때는 다음 플레이·QR 로그인·기록 생성 방법을 안내하는 빈 상태 화면을 제공합니다.'], ['실제 데이터 조건', '분석을 표시할 최소 기록 수·기간·기기 연동 완료 조건을 정하고 화면에 기준을 알려줍니다.'], ['계산값 검증', '통계·평균·비교 수치는 실제 원본 기록에서 다시 계산하고 기준 날짜와 표본 수를 함께 보여줍니다.'], ['데모 분리', '교육·데모가 필요하면 운영 고객 계정과 분리된 체험 환경에서만 사용합니다.'] ], [ '예시값을 실제 고객 성적처럼 보이게 하거나 개인 기록·대회 결과와 섞지 않습니다.', '데이터 부족은 오류가 아니라 명확한 빈 상태와 다음 행동으로 안내합니다.', '분석 기준이 달라질 때는 과거 수치가 어떻게 달라지는지 고객과 운영자가 이해할 수 있게 관리합니다.' ], '고객이 자기 실제 기록만 보고, 기록이 없을 때도 왜 비어 있는지와 다음 행동을 이해하면 완료입니다.'), '외부 화면 우회·비활성 메뉴 자리': integrationGuidance([ ['우회 경로 목록화', '데스크탑 페이지·외부 브라우저·미연결 탭·비활성 버튼으로 빠지는 모든 고객 경로를 확인합니다.'], ['모바일 도착 화면 결정', '계속 제공할 기능은 앱 안의 주문·멤버십·내 정보·알림 화면 중 어디로 도착할지 정합니다.'], ['준비 전 노출 차단', '모바일 완료 화면과 지원 경로가 없는 기능은 메뉴·버튼·알림 링크에서 숨깁니다.'], ['전환 링크 적용', '기존 링크는 새 앱 화면으로 바꾸고 로그인 만료·앱 미설치·외부 브라우저 예외를 안전하게 처리합니다.'], ['지원·측정 확인', '우회가 남아 있는지 이동 실패·이탈·문의 데이터를 확인하고 완전히 전환된 뒤 기존 링크를 종료합니다.'] ], [ '고객을 데스크탑으로 보내는 것은 임시 해결로 두지 않고 모바일 도착 화면 또는 비노출 중 하나를 결정합니다.', '동작하지 않는 메뉴 자리는 고객에게 보이지 않게 하고 준비된 기능만 정식 탭·알림에 연결합니다.', '딥링크가 실패해도 고객이 빈 화면에 머물지 않게 로그인·홈·지원 안내 중 안전한 대체 화면을 제공합니다.' ], '앱의 모든 노출 메뉴와 알림 링크가 실제로 사용할 수 있는 모바일 화면으로 연결되고, 미연결 버튼이 남지 않으면 완료입니다.') }); const integrationGuidanceAccents = { work: 'var(--primary)', keep: '#15803d', bring: '#2563eb', decide: '#b45309', drop: '#b91c1c' }; const structureDomainIdFor = (mode, screenGroupId, item, projectKey = '') => { const key = String(screenGroupId || '').toLocaleLowerCase('en'); const raw = `${friendly(item.group)} ${friendly(item.feature)}`.toLocaleLowerCase('ko'); const explicitDomain = item.structureDomain || qrStructureDomains[projectKey]?.[item.feature]; if (explicitDomain) return explicitDomain; if (mode === 'web') { if (key.startsWith('central-')) { if (/(등급|혜택|플랜|회원)/.test(raw)) return 'member'; if (/대회/.test(raw)) return 'competition'; if (/(매출|거래|결제)/.test(raw)) return 'commerce'; if (/(매장|도어|조명|무인|운영|타석|가격)/.test(raw)) return 'operations'; if (/(central-(login|auth|permission)|권한|인증|보안)/.test(`${key} ${raw}`)) return 'account'; return 'operations'; } if (/competition/.test(key) || /대회/.test(raw)) return 'competition'; if (/(reservation|bay|lesson)/.test(key) || /(예약|타석|레슨)/.test(raw)) return 'reservation'; if (/(member|membership|locker)/.test(key) || /(회원|멤버십|이용권|라커)/.test(raw)) return 'member'; if (/(sales|pos|payment|product|reconcile)/.test(key) || /(상품|결제|매출|정산|대사)/.test(raw)) return 'commerce'; if (/(messaging|support)/.test(key) || /(문자|sms|고객지원|공지)/.test(raw)) return 'support'; if (/(staff|store|unmanned|settings)/.test(key) || /(직원|매장|설정|무인)/.test(raw)) return 'operations'; if (/(login|security)/.test(key) || /(로그인|인증|보안|계정)/.test(raw)) return 'account'; return 'operations'; } if (/competition/.test(key) || /대회/.test(raw)) return 'competition'; if (/(booking|book|reservation|store|training)/.test(key) || /(예약|매장|연습|레슨)/.test(raw)) return 'booking'; if (/(membership|plan|purchase|points)/.test(key) || /(멤버십|이용권|플랜|구매|포인트)/.test(raw)) return 'benefits'; if (/(report|scorecard|swing|share|certificate|attendance)/.test(key) || /(리포트|기록|스코어|스윙|공유|인증서|출석)/.test(raw)) return 'records'; if (/(account|settings|profile|password)/.test(key) || /(프로필|비밀번호|설정|탈퇴|내 정보)/.test(raw)) return 'account'; if (/(auth|onboarding|login|qr)/.test(key) || /(로그인|가입|인증|온보딩|권한|qr)/.test(raw)) return 'start'; return 'home'; }; const structureHierarchyFor = (projectKey, mode) => { const hierarchy = new Map(); (inventoryProjects[projectKey]?.rows || []).forEach((item) => { const { major, submenu } = inventoryGroupHierarchy(projectKey, item); const screenInfo = inventoryScreenFor(projectKey, item); const tabKey = screenInfo.screenGroup?.id || `${item.group}-${item.feature}`; const domainId = structureDomainIdFor(mode, tabKey, item, projectKey); if (!hierarchy.has(domainId)) hierarchy.set(domainId, new Map()); const majors = hierarchy.get(domainId); if (!majors.has(major)) majors.set(major, new Map()); const submenus = majors.get(major); if (!submenus.has(submenu)) submenus.set(submenu, new Map()); const tabs = submenus.get(submenu); if (!tabs.has(tabKey)) tabs.set(tabKey, { screenInfo, items: [] }); tabs.get(tabKey).items.push(item); }); return hierarchy; }; const structureTabMarkup = (projectKey, tab) => { const project = inventoryProjects[projectKey]; const launch = inventoryScreenLaunchFor(projectKey, project, tab.screenInfo, tab.items); const title = launch.screenTitle; const qr = tab.items.some((item) => item.origin === 'qr'); const screenGroupId = tab.screenInfo.screenGroup?.id || `${tab.items[0]?.group || "screen"}-${tab.items[0]?.feature || "unknown"}`; const screenId = launch.available ? `structure-screen-${++inventoryScreenSequence}` : ""; if (launch.available) structureScreenRegistry.set(screenId, launch.payload); const controlAttrs = `data-structure-screen data-structure-project="${esc(projectKey)}" data-structure-screen-group="${esc(screenGroupId)}" data-structure-origin="${qr ? "qr" : "source"}"`; const statusLabel = launch.state === "no-screen" ? "화면 없음" : "화면 확인 필요"; const availabilityAttrs = launch.available ? `data-structure-screen-id="${esc(screenId)}" title="동작 화면 열기"` : `data-structure-availability="${esc(launch.state)}" disabled title="${esc(launch.reason)}"`; const unavailable = launch.available ? "" : `<small class="structure-screen-unavailable">${statusLabel}</small>`; const actions = tab.items.map((item) => `<li class='structure-action'><button type="button" class="structure-action-button" ${controlAttrs} ${availabilityAttrs} aria-label="${esc(`${project.title} ${title} ${friendly(item.feature)} ${launch.available ? "화면 열기" : statusLabel}`)}">${esc(friendly(item.feature))}</button></li>`).join(''); return `<section class='structure-tab${qr ? ' is-qr' : ''}' data-structure-tab data-structure-project="${esc(projectKey)}" data-structure-screen-group="${esc(screenGroupId)}"${qr ? ' data-structure-origin="qr"' : ''}>${qr ? '<span class="inventory-origin-chip">QR 추가</span>' : ''}<button type="button" class="structure-tab-name${launch.available ? ' is-interactive' : ' is-unavailable'}" ${controlAttrs} ${availabilityAttrs} aria-label="${esc(`${project.title} ${title} ${launch.available ? "화면 열기" : statusLabel}`)}"><span class="structure-tab-label">${esc(title)}</span>${unavailable}</button><ul class='structure-actions'>${actions}</ul></section>`; }; const structureTreeMarkup = (domainMap, projectKey) => { if (!domainMap?.size) return `<div class='structure-empty'>—</div>`; return [...domainMap.entries()].map(([major, submenuMap]) => { const submenus = [...submenuMap.entries()].map(([submenu, tabMap]) => `<section class='structure-submenu'><span class='structure-submenu-title'>${esc(submenu)}</span>${[...tabMap.values()].map((tab) => structureTabMarkup(projectKey, tab)).join('')}</section>`).join(''); return `<section class='structure-major'><b class='structure-major-title'>${esc(major)}</b><div class='structure-major-content'>${submenus}</div></section>`; }).join(''); }; const updateComparisonModeButtons = () => { root.querySelectorAll('[data-comparison-mode]').forEach((button) => { const active = button.getAttribute('data-comparison-mode') === activeComparisonMode; button.setAttribute('aria-pressed', String(active)); button.classList.toggle('btn-primary', active); }); }; function renderStructure(mode = activeComparisonMode) { const projects = comparisonStructureProjects[mode] || comparisonStructureProjects.web; const domains = comparisonStructureDomains[mode] || comparisonStructureDomains.web; const structureRoot = root.querySelector('[data-comparison-structure]'); if (!structureRoot) return; structureScreenRegistry.clear(); const structures = new Map(projects.map((project) => [project.key, structureHierarchyFor(project.key, mode)])); root.dataset.mode = mode; structureRoot.querySelector('[data-structure-caption]').textContent = mode === 'app' ? 'GolfHub APP과 FSApp 메뉴 구조' : 'GolfHub Web 매장·본사, QMS, FMS 메뉴 구조'; structureRoot.querySelector('[data-structure-head]').innerHTML = `<tr><th scope='col'>업무</th>${projects.map((project) => `<th scope='col' data-structure-project='${esc(project.key)}'>${esc(project.label)}</th>`).join('')}</tr>`; structureRoot.querySelector('[data-structure-body]').innerHTML = domains.map((domain) => `<tr class='structure-domain-row' data-structure-domain='${esc(domain.id)}'><th scope='row' class='structure-domain'><b>${esc(domain.title)}</b></th>${projects.map((project) => `<td class='structure-cell' data-structure-project='${esc(project.key)}'>${structureTreeMarkup(structures.get(project.key).get(domain.id), project.key)}</td>`).join('')}</tr>`).join(''); } const integrationProjectSummary = (mode) => { const projects = comparisonStructureProjects[mode] || []; const total = projects.reduce((sum, project) => sum + (inventoryProjects[project.key]?.rows.length || 0), 0); const breakdown = projects.map((project) => `${project.label} ${inventoryProjects[project.key]?.rows.length || 0}건`).join(' · '); const comparisonCount = (datasets[mode]?.groups || []).reduce((sum, group) => sum + group.rows.length, 0); return { total, breakdown, comparisonCount }; }; const integrationStructureMarkup = (mode) => { renderStructure(mode); const source = root.querySelector('[data-comparison-structure] .structure-table'); const clone = source.cloneNode(true); clone.classList.add('integration-full-structure-table'); clone.querySelectorAll('button').forEach((button) => { const label = document.createElement('span'); label.className = button.className.replace(/is-interactive|is-unavailable/g, '').trim() || 'structure-tab-name'; label.innerHTML = button.innerHTML; button.replaceWith(label); }); return clone.outerHTML; }; const integrationFeatureMarkup = (mode) => { render(mode); const source = root.querySelector('[data-compare-table-shell] .compare-table'); const clone = source.cloneNode(true); clone.classList.add('integration-full-feature-table'); clone.querySelectorAll('.feature-review-trigger').forEach((button) => button.remove()); clone.querySelectorAll('.feature-detail-row').forEach((row) => { row.hidden = false; row.classList.add('is-open'); row.querySelectorAll('details').forEach((details) => { details.open = true; details.removeAttribute('id'); }); }); clone.querySelectorAll('.screen-evidence-note, .implementation-shot-grid').forEach((element) => element.remove()); return clone.outerHTML; }; const integrationRawInventoryMarkup = (mode) => { const projects = comparisonStructureProjects[mode] || []; const tables = projects.map((project) => { const rows = inventoryProjects[project.key]?.rows || []; const body = rows.map((item) => { const stateLabel = inventoryStateLabels[item.state] || item.state; const note = item.note ? `<small>${esc(friendly(item.note))}</small>` : ""; return `<tr><th>${esc(friendly(item.group))}</th><td><b>${esc(friendly(item.feature))}</b></td><td><span class="inventory-status ${esc(item.state)}">${esc(stateLabel)}</span></td><td>${esc(friendly(item.description))}${note}</td></tr>`; }).join(""); return `<section class="integration-raw-project"><h4>${esc(project.label)} <em>${rows.length}건</em></h4><div class="integration-raw-table-shell"><table><thead><tr><th>분석 분류</th><th>기능</th><th>상태</th><th>프로젝트별 상세 분석</th></tr></thead><tbody>${body}</tbody></table></div></section>`; }).join(""); return `<section class="integration-raw-analysis"><header><p>PROJECT DETAIL · FULL TRACE</p><h3>프로젝트별 상세 분석 전수</h3><small>메뉴 구조와 이관 비교에서 누락 없이 확인할 수 있도록, 프로젝트별 상세 탭의 상태·설명·제약을 그대로 수록합니다. div')]; const currentLabel = frames[0]?.querySelector('dt')?.textContent.trim() || '현재'; const current = frames[0]?.querySelector('dd')?.textContent.trim() || '현재 상태를 확인합니다.'; const targetLabel = frames[1]?.querySelector('dt')?.textContent.trim() || '적용'; const target = frames[1]?.querySelector('dd')?.textContent.trim() || '통합 기준으로 적용합니다.'; const group = card.closest('.integration-action-group'); const decision = ['work', 'keep', 'bring', 'decide', 'drop'].find((name) => group?.classList.contains(`is-${name}`)) || 'work'; const decisionLabel = group?.querySelector('header p')?.textContent.trim() || '통합 판단'; const bundle = integrationDecisionBundleById[card.dataset.integrationBundle || '']; const fallback = { flow: [ { title: '현재 확인', text: current }, { title: '적용 기준', text: target }, { title: '운영 연결', text: '담당 화면과 처리 이력을 같은 기준으로 연결합니다.' }, { title: '결과 검증', text: '현장과 본사에서 같은 결과가 보이는지 확인합니다.' } ], policies: [ `${currentLabel}와 ${targetLabel} 기준을 담당자별로 다르게 적용하지 않습니다.`, '변경 결과와 담당자·처리 시각을 확인할 수 있게 이력을 남깁니다.', '기존 경로를 종료하기 전에 대체 화면과 데이터 확인을 끝냅니다.' ], done: `${title}의 현재 상태와 통합 결과가 매장·본사·고객 화면에서 같은 기준으로 확인되면 완료입니다.` }; const selectedFeature = card.dataset.integrationFeature || title; const mode = card.dataset.integrationMode || ''; const detail = integrationDecisionDetailFor(mode, selectedFeature); return { title, currentLabel, current, targetLabel, target, decision, decisionLabel, ...(bundle ? integrationFeatureGuidanceFor(mode, bundle, detail, selectedFeature) : (integrationGuidanceCatalog[title] || fallback)) }; }; const renderIntegrationGuidance = (guidance) => { const overlay = root.querySelector('[data-integration-guidance-overlay]'); if (!overlay) return; overlay.style.setProperty('--integration-guidance-accent', integrationGuidanceAccents[guidance.decision] || 'var(--primary)'); overlay.querySelector('[data-integration-guidance-decision]').textContent = `${guidance.decisionLabel} · 실행 가이드`; overlay.querySelector('[data-integration-guidance-title]').textContent = guidance.title; overlay.querySelector('[data-integration-guidance-current-label]').textContent = guidance.currentLabel; overlay.querySelector('[data-integration-guidance-current]').textContent = guidance.current; overlay.querySelector('[data-integration-guidance-target-label]').textContent = guidance.targetLabel; overlay.querySelector('[data-integration-guidance-target]').textContent = guidance.target; const flow = overlay.querySelector('[data-integration-guidance-flow]'); flow.style.setProperty('--guidance-step-count', String(guidance.flow.length)); flow.innerHTML = guidance.flow.map((step, index) => { const item = Array.isArray(step) ? { title: step[0], text: step[1] } : step; return `<li><span>${String(index + 1).padStart(2, '0')}</span><b>${esc(item.title)}</b><p>${esc(item.text)}</p></li>`; }).join(''); const references = Array.isArray(guidance.references) ? guidance.references : []; const referenceSection = overlay.querySelector('[data-integration-guidance-reference-section]'); const referenceTarget = overlay.querySelector('[data-integration-guidance-references]'); if (referenceSection && referenceTarget) { referenceSection.hidden = !references.length; referenceTarget.innerHTML = references.map((reference) => `<li><b>${esc(reference.label)}</b><span>${esc(reference.status)}</span><p>${esc(reference.text)}</p></li>`).join(''); } overlay.querySelector('[data-integration-guidance-policies]').innerHTML = guidance.policies.map((policy) => `<li>${esc(policy)}</li>`).join(''); const coverage = Array.isArray(guidance.coverage) ? guidance.coverage : []; const coverageSection = overlay.querySelector('[data-integration-guidance-coverage-section]'); const coverageTarget = overlay.querySelector('[data-integration-guidance-coverage]'); if (coverageSection && coverageTarget) { coverageSection.hidden = !coverage.length; coverageTarget.innerHTML = coverage.map((feature) => `<li>${esc(feature)}</li>`).join(''); } overlay.querySelector('[data-integration-guidance-done]').textContent = guidance.done; }; const openIntegrationGuidance = (trigger) => { const overlay = root.querySelector('[data-integration-guidance-overlay]'); const card = trigger.closest('.integration-capability-card'); if (!overlay || !card) return; renderIntegrationGuidance(integrationGuidanceDataFor(card)); integrationGuidanceTrigger = trigger; root.querySelectorAll('[data-integration-guidance-trigger]').forEach((frame) => frame.setAttribute('aria-expanded', 'false')); trigger.setAttribute('aria-expanded', 'true'); overlay.dataset.previousOverflow = document.body.style.overflow; document.body.style.overflow = 'hidden'; overlay.hidden = false; overlay.querySelector('.integration-guidance-dialog').focus(); }; const closeIntegrationGuidance = () => { const overlay = root.querySelector('[data-integration-guidance-overlay]'); if (!overlay || overlay.hidden) return false; overlay.hidden = true; document.body.style.overflow = overlay.dataset.previousOverflow || ''; delete overlay.dataset.previousOverflow; integrationGuidanceTrigger?.setAttribute('aria-expanded', 'false'); integrationGuidanceTrigger?.focus(); integrationGuidanceTrigger = null; return true; }; const setupIntegrationGuidance = () => { const overlay = root.querySelector('[data-integration-guidance-overlay]'); if (!overlay) return; root.querySelectorAll('.integration-capability-card dl > div:last-child').forEach((frame) => { if (frame.hasAttribute('data-integration-guidance-trigger')) return; const title = frame.closest('.integration-capability-card')?.querySelector('h4')?.textContent.trim() || '적용 실행 가이드'; frame.tabIndex = 0; frame.setAttribute('role', 'button'); frame.setAttribute('data-integration-guidance-trigger', ''); frame.setAttribute('aria-haspopup', 'dialog'); frame.setAttribute('aria-expanded', 'false'); frame.setAttribute('aria-label', `${title} 적용 실행 가이드 열기`); frame.addEventListener('click', () => openIntegrationGuidance(frame)); frame.addEventListener('keydown', (event) => { if (event.key !== 'Enter' && event.key !== ' ') return; event.preventDefault(); openIntegrationGuidance(frame); }); }); overlay.querySelector('[data-integration-guidance-close]').addEventListener('click', closeIntegrationGuidance); overlay.addEventListener('click', (event) => { if (event.target === event.currentTarget) closeIntegrationGuidance(); }); document.addEventListener('keydown', (event) => { if (overlay.hidden) return; if (event.key === 'Escape') { event.preventDefault(); closeIntegrationGuidance(); return; } if (event.key !== 'Tab') return; const dialog = overlay.querySelector('.integration-guidance-dialog'); const focusable = [...dialog.querySelectorAll('a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])')]; if (!focusable.length) return; const first = focusable[0]; const last = focusable[focusable.length - 1]; if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } }); }; function setComparisonView(view) { activeComparisonView = ["integration-web", "integration-app"].includes(view) ? view : "integration-web"; root.querySelectorAll("[data-compare-view]").forEach((button) => { const active = button.getAttribute("data-compare-view") === activeComparisonView; button.setAttribute("aria-pressed", String(active)); button.classList.toggle("btn-primary", active); }); const comparisonModeControls = root.querySelector("[data-comparison-mode-controls]"); if (comparisonModeControls) comparisonModeControls.hidden = true; root.querySelector("[data-comparison-structure]").hidden = true; root.querySelector("[data-compare-table-shell]").hidden = true; root.querySelector("[data-compare-audit]").hidden = true; root.querySelector("[data-scope]").hidden = true; root.querySelector("[data-image-comparison]").hidden = true; root.querySelectorAll("[data-integration-board]").forEach((panel) => { panel.hidden = true; }); const mode = activeComparisonView.replace("integration-", ""); const workspace = root.querySelector("[data-integration-workspace]"); if (workspace) { workspace.hidden = false; workspace.dataset.mode = mode; workspace.querySelectorAll("[data-integration-panel]").forEach((panel) => { panel.hidden = panel.getAttribute("data-integration-panel") !== mode; }); setIntegrationDecision(activeIntegrationDecision); } } root.querySelectorAll("[data-comparison-mode]").forEach((button) => button.addEventListener("click", () => setComparisonMode(button.getAttribute("data-comparison-mode")))); root.querySelectorAll("[data-compare-view]").forEach((button) => button.addEventListener("click", () => setComparisonView(button.getAttribute("data-compare-view")))); root.querySelectorAll("[data-integration-decision]").forEach((button) => button.addEventListener("click", () => setIntegrationDecision(button.getAttribute("data-integration-decision")))); root.querySelectorAll("[data-image-mode]").forEach((button) => button.addEventListener("click", () => { activeImageMode = button.getAttribute("data-image-mode"); renderImageComparison(); })); root.querySelectorAll("[data-catalog-view]").forEach((button) => button.addEventListener("click", () => setCatalogView(button.getAttribute("data-catalog-view")))); root.querySelectorAll("[data-project]").forEach((button) => button.addEventListener("click", () => { setInventoryProject(button.getAttribute("data-project")); })); root.querySelector("[data-inventory-search]").addEventListener("input", renderInventory); root.querySelector("[data-inventory-filter]").addEventListener("change", renderInventory); root.addEventListener("pointerover", (event) => { const node = event.target.closest?.("[data-inventory-menu-node]"); if (!node || !node.closest("[data-inventory-menu-map]")) return; if (event.relatedTarget && node.contains(event.relatedTarget)) return; setInventoryMenuMapHover(node.getAttribute("data-inventory-menu-node")); }); root.addEventListener("pointerout", (event) => { const node = event.target.closest?.("[data-inventory-menu-node]"); if (!node || !node.closest("[data-inventory-menu-map]")) return; if (event.relatedTarget && node.contains(event.relatedTarget)) return; clearInventoryMenuMapHover(); }); root.addEventListener("focusin", (event) => { const node = event.target.closest?.("[data-inventory-menu-node]"); if (node?.closest("[data-inventory-menu-map]")) setInventoryMenuMapHover(node.getAttribute("data-inventory-menu-node")); }); root.addEventListener("focusout", (event) => { const node = event.target.closest?.("[data-inventory-menu-node]"); if (!node?.closest("[data-inventory-menu-map]")) return; window.requestAnimationFrame(() => { const focusedNode = document.activeElement?.closest?.("[data-inventory-menu-node]"); if (!focusedNode) clearInventoryMenuMapHover(); }); }); root.addEventListener("pointerdown", (event) => { if (!activeInventoryMenuMap?.pinnedNodeId || event.target.closest?.("[data-inventory-menu-map]")) return; activeInventoryMenuMap.pinnedNodeId = null; clearInventoryMenuMapHover(); }); root.addEventListener("click", (event) => { const menuNode = event.target.closest?.("[data-inventory-menu-node]"); if (menuNode) { const nodeId = menuNode.getAttribute("data-inventory-menu-node"); if (activeInventoryMenuMap?.pinnedNodeId === nodeId) { activeInventoryMenuMap.pinnedNodeId = null; activeInventoryMenuMap.hoveredNodeId = null; } else if (activeInventoryMenuMap?.nodeByDomId.has(nodeId)) { activeInventoryMenuMap.pinnedNodeId = nodeId; activeInventoryMenuMap.hoveredNodeId = nodeId; } syncInventoryMenuMapHighlight(); return; } const hotspot = event.target.closest?.("[data-inventory-screen-hotspot]"); if (hotspot) { event.preventDefault(); activateInventoryScreenHotspot(hotspot); return; } if (event.target.closest?.("[data-screen-action-apply]")) { event.preventDefault(); applyScreenActionForm(event.target.closest("[data-screen-action-form]")); return; } const duplicateButton = event.target.closest?.("[data-screen-action-duplicate-check]"); if (duplicateButton) { const form = duplicateButton.closest("[data-screen-action-form]"); const fieldName = duplicateButton.getAttribute("data-screen-action-duplicate-check"); const value = String(form?.elements[fieldName]?.value || "").trim(); if (!value) { const error = form?.querySelector("[data-screen-action-error]"); if (error) { error.textContent = "직원 ID를 입력해 주세요."; error.hidden = false; } return; } form.dataset.duplicateCheckedValue = value; duplicateButton.textContent = "사용 가능한 ID · 확인 완료"; return; } if (event.target.closest?.("[data-screen-action-confirm]")) { if (activeScreenAction) completeScreenAction(activeScreenAction, pendingScreenActionValues || {}); return; } if (event.target.closest?.("[data-screen-action-confirm-cancel]")) { if (!closeScreenActionConfirm()) closeScreenAction(); return; } if (event.target.closest?.("[data-screen-action-cancel]")) { closeScreenAction(); return; } const processLayer = event.target.closest?.("[data-screen-action-layer]"); if (processLayer && event.target === processLayer) { closeScreenAction(); return; } const structureScreenButton = event.target.closest?.("[data-structure-screen]"); if (structureScreenButton) { const payload = structureScreenRegistry.get(structureScreenButton.getAttribute("data-structure-screen-id")); if (payload) openInventoryScreenPayload(payload, structureScreenButton); return; } const comparisonPrototypeButton = event.target.closest?.("[data-comparison-prototype]"); if (comparisonPrototypeButton) { const payload = { projectKey: comparisonPrototypeButton.getAttribute("data-runtime-project"), platform: comparisonPrototypeButton.getAttribute("data-runtime-platform"), screenGroupId: comparisonPrototypeButton.getAttribute("data-runtime-group"), project: comparisonPrototypeButton.getAttribute("data-runtime-system"), title: comparisonPrototypeButton.getAttribute("data-runtime-title"), note: "비교 항목에 대응하는 반응형 재현 화면입니다.", source: comparisonPrototypeButton.getAttribute("data-runtime-source") }; const runtimeId = `comparison-runtime-${++inventoryScreenSequence}`; inventoryScreenRegistry.set(runtimeId, payload); openInventoryScreen(runtimeId, comparisonPrototypeButton); return; } const screenButton = event.target.closest?.("[data-inventory-screen]"); if (screenButton) openInventoryScreen(screenButton.getAttribute("data-inventory-screen"), screenButton); }); root.addEventListener("input", (event) => { const form = event.target.closest?.("[data-screen-action-form]"); if (form) { if (activeScreenAction?.duplicateCheckField === event.target.name) delete form.dataset.duplicateCheckedValue; syncScreenActionDependencies(form, activeScreenAction); updateScreenActionPreview(form); } }); root.addEventListener("change", (event) => { const form = event.target.closest?.("[data-screen-action-form]"); if (form) { syncScreenActionDependencies(form, activeScreenAction); updateScreenActionPreview(form); } }); root.addEventListener("submit", (event) => { const inlineForm = event.target.closest?.("[data-inventory-screen-controls]"); if (inlineForm) { event.preventDefault(); return; } const form = event.target.closest?.("[data-screen-action-form]"); if (!form || !activeScreenAction) return; event.preventDefault(); applyScreenActionForm(form); }); root.querySelector("[data-inventory-screen-close]").addEventListener("click", closeInventoryScreen); root.querySelector("[data-inventory-screen-overlay]").addEventListener("click", (event) => { if (event.target === event.currentTarget) closeInventoryScreen(); }); document.addEventListener("keydown", (event) => { const overlay = root.querySelector("[data-inventory-screen-overlay]"); if (event.key === "Escape" && overlay.hidden && activeInventoryMenuMap && (activeInventoryMenuMap.pinnedNodeId || activeInventoryMenuMap.hoveredNodeId)) { event.preventDefault(); activeInventoryMenuMap.pinnedNodeId = null; activeInventoryMenuMap.hoveredNodeId = null; syncInventoryMenuMapHighlight(); return; } if (event.key === "Escape") { if (overlay.hidden) return; event.preventDefault(); if (closeScreenActionConfirm()) return; if (closeScreenAction()) return; if (activeInventoryScreenState !== "initial") { renderInventoryScreenState("initial", { focusFirst: true }); return; } closeInventoryScreen(); return; } if (event.key !== "Tab" || overlay.hidden) return; const focusable = [...overlay.querySelectorAll("a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])")]; if (!focusable.length) return; const first = focusable[0]; const last = focusable[focusable.length - 1]; if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } }); window.addEventListener("resize", () => { syncInventoryScreenCanvas(); window.requestAnimationFrame(syncInventoryMenuMapLinks); }); root.addEventListener("toggle", (event) => { const details = event.target.closest?.(".feature-review"); if (!details) return; syncFeatureReviewState(details); }, true); root.querySelectorAll(".phone-card header b").forEach((label) => { if (!/^qapp\s+/i.test(label.textContent)) return; label.textContent = label.textContent.replace(/^qapp\s+/i, "FSApp · ") + " 차이"; }); renderIntegrationDecisionWorkspace(); renderIntegrationComparisons(); setupIntegrationGuidance(); setComparisonMode("web"); setComparisonView("integration-web"); renderInventory(); })(); </script> </div> <script> (() => { if (window.FloatingUIDOM == null) { return; } const { autoUpdate, computePosition, flip, offset, shift, size } = window.FloatingUIDOM; const tooltipPlacements = new Set(["top", "right", "bottom", "left"]); const tooltipId = `codex-visualization-tooltip-${ window.crypto?.randomUUID?.() ?? Date.now().toString(36) }`; const tooltipGap = 5; const hoverDelay = 700; const skipDelay = 300; let activeTrigger = null; let pendingTrigger = null; let tooltip = null; let openTimeout = null; let autoUpdateCleanup = null; let positionRequest = 0; let skipDelayUntil = 0; const getTrigger = (target) => target?.nodeType === Node.ELEMENT_NODE ? target.closest("[data-tooltip]") : null; const containsTarget = (trigger, target) => target?.nodeType != null && trigger.contains(target); const getContent = (trigger) => trigger.getAttribute("data-tooltip")?.trim() ?? ""; const getPlacement = (trigger) => { const placement = trigger.getAttribute("data-tooltip-placement"); return tooltipPlacements.has(placement) ? placement : "top"; }; const getTooltip = () => { if (tooltip == null) { tooltip = document.createElement("div"); tooltip.id = tooltipId; tooltip.className = "tooltip"; tooltip.setAttribute("role", "tooltip"); } return tooltip; }; const setDescribedBy = (trigger, described) => { const ids = new Set( (trigger.getAttribute("aria-describedby") ?? "") .split(/\s+/) .filter(Boolean), ); if (described) { ids.add(tooltipId); } else { ids.delete(tooltipId); } if (ids.size === 0) { trigger.removeAttribute("aria-describedby"); } else { trigger.setAttribute("aria-describedby", [...ids].join(" ")); } }; const clearOpenTimeout = () => { if (openTimeout != null) { window.clearTimeout(openTimeout); openTimeout = null; } pendingTrigger = null; }; const closeTooltip = (activateSkipDelay = true) => { clearOpenTimeout(); positionRequest += 1; autoUpdateCleanup?.(); autoUpdateCleanup = null; if (activeTrigger == null) { return; } setDescribedBy(activeTrigger, false); activeTrigger = null; tooltip?.remove(); if (activateSkipDelay) { skipDelayUntil = Date.now() + skipDelay; } }; const updatePosition = (trigger, floating) => { const request = ++positionRequest; void computePosition(trigger, floating, { middleware: [ offset(tooltipGap), flip({ padding: tooltipGap }), shift({ padding: tooltipGap }), size({ padding: tooltipGap, apply({ availableHeight, availableWidth, elements }) { elements.floating.style.setProperty( "--tooltip-available-width", `${Math.max(0, availableWidth)}px`, ); elements.floating.style.setProperty( "--tooltip-available-height", `${Math.max(0, availableHeight)}px`, ); }, }), ], placement: getPlacement(trigger), strategy: "fixed", }) .then(({ x, y }) => { if (activeTrigger !== trigger || request !== positionRequest) { return; } const scale = window.devicePixelRatio || 1; floating.style.transform = `translate(${Math.round(x * scale) / scale}px, ${Math.round(y * scale) / scale}px)`; floating.style.visibility = "visible"; }) .catch(() => { if (activeTrigger === trigger && request === positionRequest) { closeTooltip(false); } }); }; const openTooltip = (trigger) => { const content = getContent(trigger); if (!trigger.isConnected || content.length === 0) { return; } clearOpenTimeout(); if (activeTrigger === trigger) { return; } closeTooltip(); const floating = getTooltip(); floating.textContent = content; floating.style.visibility = "hidden"; floating.style.transform = "translate(0, 0)"; document.body.appendChild(floating); activeTrigger = trigger; setDescribedBy(trigger, true); autoUpdateCleanup = autoUpdate(trigger, floating, () => { updatePosition(trigger, floating); }); }; const requestOpen = (trigger, immediate = false) => { if ( getContent(trigger).length === 0 || activeTrigger === trigger || pendingTrigger === trigger ) { return; } clearOpenTimeout(); pendingTrigger = trigger; const delay = immediate || activeTrigger != null || Date.now() < skipDelayUntil ? 0 : hoverDelay; if (delay === 0) { openTooltip(trigger); return; } openTimeout = window.setTimeout(() => { openTimeout = null; pendingTrigger = null; openTooltip(trigger); }, delay); }; const handlePointerOpen = (event) => { if (event.pointerType === "touch") { return; } const trigger = getTrigger(event.target); if (trigger == null || containsTarget(trigger, event.relatedTarget)) { return; } requestOpen(trigger); }; const handleLeave = (event) => { const trigger = getTrigger(event.target); if (trigger == null || containsTarget(trigger, event.relatedTarget)) { return; } if (activeTrigger === trigger || pendingTrigger === trigger) { closeTooltip(); } }; const handleFocusIn = (event) => { const trigger = getTrigger(event.target); if (trigger?.matches(":focus-visible")) { requestOpen(trigger, true); } }; const handleKeyDown = (event) => { if (event.key === "Escape") { closeTooltip(); } }; const handleContextMenu = (event) => { const trigger = getTrigger(event.target); if (activeTrigger === trigger || pendingTrigger === trigger) { closeTooltip(); } }; const destroy = () => { closeTooltip(false); document.removeEventListener("pointerover", handlePointerOpen); document.removeEventListener("pointerout", handleLeave); document.removeEventListener("focusin", handleFocusIn); document.removeEventListener("focusout", handleLeave); document.removeEventListener("keydown", handleKeyDown); document.removeEventListener("contextmenu", handleContextMenu); tooltip = null; }; document.addEventListener("pointerover", handlePointerOpen); document.addEventListener("pointerout", handleLeave); document.addEventListener("focusin", handleFocusIn); document.addEventListener("focusout", handleLeave); document.addEventListener("keydown", handleKeyDown); document.addEventListener("contextmenu", handleContextMenu); window.addEventListener("pagehide", destroy, { once: true }); })(); </script> <script> (() => { const initialize = () => { globalThis.lucide?.createIcons({ attrs: { width: 16, height: 16 } }); }; if (globalThis.lucide != null) { initialize(); return; } document .getElementById("codex-visualization-lucide") ?.addEventListener("load", initialize, { once: true }); })(); </script> </body> </html> ">