HQ Audit — /audit/hq (운영사 통합 본사 audit 화면)
SPEC #081 · #068 F4 마감. 시안 부재 → apps/space 본사 audit(/admin/audit, SPEC #067) idiom 미러 + hqName 컬럼 추가. [[feedback_design_only_from_handoff]] 준수.
Overview
운영사(OPERATOR)가 모든 본사의 hq_audit_log 를 한 화면에서 통합 조회하는 백오피스 페이지.
본사 매니저(HQ_MANAGER)는 자기 본사 audit 만 보지만(apps/space /admin/audit, SPEC #067 —
WHERE hq_id 강제), 운영사는 전체 본사 합집합 + hqName 컬럼으로 어느 본사의 audit 인지 식별
한다. append-only(편집/삭제 불가).
시안 정합: 시안 부재 → 기존 운영사 audit/actions(SPEC #026·#031-D) 헤더 idiom(
PageHeader
AuditTabs) + 본사 audit(apps/spaceSPEC #067) row idiom(액션 배지·행위자·임퍼소네이션 보조 줄) 을 미러한다. 임의 시각/배치 발명 0.
Route & Endpoint
| 항목 | 값 |
|---|---|
| FE 라우트 | /audit/hq (운영사 백오피스) |
| BE endpoint | GET /api/v1/admin/audit/hq — listOperatorHqAudit (OPERATOR-only) |
| 응답 | OperatorHqAuditListResponse{items, page, size, total} |
| 정렬 | occurred_at DESC, id ASC (서버 고정 — 결정적) |
| 페이지 크기 | 20 (PAGE_SIZE) |
본사 view 와의 차이 (SPEC #067 vs #081)
| 측면 | 본사 view (/admin/audit, #067) | 운영사 view (/audit/hq, #081) |
|---|---|---|
| 토큰 | HQ_MANAGER | OPERATOR |
| WHERE 가드 | WHERE hq_id = :hqId 강제 | 없음 (전체 본사 합집합) |
| 본사 격리 | 자기 본사만 | 모든 본사 — 정책 결정 (D6) |
| 응답 행 | HqAuditItem | OperatorHqAuditItem (= HqAuditItem 미러 + hqName) |
| 본사 필터 | 없음 (자기 본사뿐) | hqId? 옵션 — 특정 본사만 좁힘 |
| 검색 q | targetLabel + detail 부분일치 | 동일 |
| 임퍼소네이션 표기 | row 보조 줄 ↩ impersonatedByEmail + “운영자 위장” pill | 동일 |
| CSV export | exportHqAuditDispatches (#070) | exportOperatorHqAudit (#088) |
| 행위자 select | useListHqAuditActors (#069) | F2 후속 (현재 UUID 자유 입력) |
UI
<PageHeader>제목 “감사 로그 · 본사 audit” + 부제 “모든 본사의 감사 행 · 총 N건”.- 감사 영역 탭(SPEC #081 §D9) — 상단에
<AuditTabs>3종(임퍼소네이션 | 운영자 액션 | 본사 audit). 본 페이지는 3번째 탭.aria-current="page"활성 표시. - 단일 테이블 — 발생 시각(KST) · 본사명(hqName, 신규) · 액션(StatusPill) · 대상(타입 배지
- 라벨) · 행위자(이메일·역할 배지·임퍼소네이션 보조 줄) · 상세. 한 행에 모두 표시 → 드릴다운 없음.
- 필터 바 — 공용
@linkmusic/uiListToolbar(controlled) — 검색(q, 내장 input) + 발생 기간 from/to(date) + 액션 select(HqAuditAction15종 —Object.values자동, #108·#078·#144 포함) + 대상 유형 select(HqAuditTargetType). 본사 필터(hqId UUID 자유 입력)는 ListToolbar 의 select/date 차원에 맞지 않아 별도 행 으로 분리(F2 에서 select 전환). 적용 버튼 →appliedstate 갱신(draft↔applied 분리) → 1페이지 리셋. - 페이지네이션 — 공용
ListPagination(page/totalPages/total + 이전/다음). - 빈 상태 —
total === 0진짜 0건(필터 유무 분기 — 있으면 [필터 초기화] 보조) vstotal > 0 && items.length === 0out-of-range page([첫 페이지로]).
시각 표시 (KST)
occurredAt(backend UTC)은 공용 helper @/lib/format formatKstDateTime 으로 KST 시각으로
표시(SSR/CSR 머신 TZ 무관). 기간 필터 from/to 는 <input type=date> date-only → backend
호출 시 KST(+09:00) 경계 정규화(from=00:00:00.000·to=23:59:59.999 inclusive).
Implementation
Page (server component, shell only)
apps/admin/src/app/(protected)/audit/hq/page.tsx — <AuditHqClient /> 만 mount. protected
layout 이 이미 OPERATOR 인가 가드 + BFF 토큰 경유를 보장하므로 server-side fetch·refresh-aware
가드는 두지 않는다(actions/impersonation 의 server-driven 패턴과 의도적 분기 — 본 통합 view 는
deep-link 공유 가치가 낮아 client state 가 충분).
Client
audit-hq-client.tsx — generated useListOperatorHqAudit 훅 직접 호출(BFF mutator apiFetch
경유 — HttpOnly 토큰 보존). draft↔applied 분리(폼 입력은 draft, fetch 에는 applied). 액션·대상
유형 enum 옵션은 generated enum Object.values 자동 확장(후속 enum 추가 시 select UI 가 늘어남).
한글 라벨/톤은 Record<enum, ...> 전수 강제(누락 컴파일 에러).
Navigation
audit-tabs.tsx(SPEC #081 수정) — 3번째 탭 본사 audit(/audit/hq) 추가. 사이드바 “감사 로그”
항목은 단일 항목으로 유지하고 세 audit 페이지가 탭을 공유한다(기존 두 페이지에 본 슬라이스의 페이지가
합류).
States & Edge Cases
| 상태 | 처리 |
|---|---|
| audit 0건 | ”아직 기록된 본사 감사 로그가 없습니다” 빈 상태 (total === 0, 필터 없음) |
| 필터 0건 | ”조건에 맞는 감사 로그가 없습니다” + [필터 초기화] (total === 0, 필터 있음) |
| out-of-range page | ”이 페이지에 표시할 감사 로그가 없습니다” + [첫 페이지로] (total > 0, items 0) |
| 5xx / 네트워크 실패 | Banner danger “서버에 일시적인 문제가 있습니다. 잠시 후 다시 시도해주세요.” |
| 401 | mutator 가 /login?next=... 풀 리다이렉트(브라우저 한정·루프 가드) |
| 403 | Banner danger “이 페이지를 볼 권한이 없습니다.” |
| detail 없음/blank | 상세 셀 — |
| 잘못된 action/targetType 입력 | sanitize → "" 전체 폴백 (select 옵션과 항상 일치) |
q 길이 초과(>100) | client maxLength=100 + apply 시 slice(0,100) 가드 → backend 400 회피 |
| 잘못된 hqId UUID | backend @UUID 검증 400 → Banner 흡수 (F2 select 전환 시 차단) |
CSV 내보내기 (SPEC #088)
운영사 통합 본사 audit 행을 현재 적용된 필터 전체 매칭으로 CSV 다운로드한다. 화면은 페이지네이션 (20)이지만 CSV 는 같은 필터의 전체 집합을 한 파일로 — 회계·감사·법무 실수요. #048(운영자 액션)· #070(본사 송출)·#074(임퍼소네이션) 와 같은 패턴으로 도착 → CSV export 4종 완결.
Endpoint
GET /api/v1/admin/audit/hq/export (exportOperatorHqAudit, OPERATOR-only) — 200 text/csv; charset=utf-8(UTF-8 BOM + RFC4180) + Content-Disposition: attachment; filename*=UTF-8''본사통합감사_{yyyyMMdd_HHmmss}.csv(RFC5987 percent-encoded · 한글 파일명) +
X-Export-Truncated: true|false 헤더(상한 신호). query 는 listOperatorHqAudit 와 동일하되
page/size 만 제외 — from?·to? ISO-8601 date-time · hqId? · action? · targetType? ·
q?(≤100). 정렬 occurred_at DESC, id ASC 서버 고정(list 와 동일 결정적).
컬럼 (8종, 한국어 헤더)
| # | 헤더 | 비고 |
|---|---|---|
| 1 | 발생시각KST | occurredAt → Asia/Seoul (yyyy-MM-dd HH:mm:ss) |
| 2 | 본사명 | hqName (어느 본사 audit 인지 식별) |
| 3 | 액션 | 본사 audit 15종 한국어 라벨 매핑(송출/생성/수정/삭제/송출 취소/송출 원격중단/반복 예약 등록/반복 예약 취소/티켓 생성/티켓 댓글/티켓 상태 변경/티켓 우선순위 변경/매장 등록/매장 정보 수정/매장 지역 변경) |
| 4 | 대상 유형 | targetType (TTS_ANNOUNCEMENT → “안내방송”) |
| 5 | 대상 라벨 | targetLabel (없으면 빈 칸) |
| 6 | 행위자 이메일 | actorEmail |
| 7 | 행위자 역할 | actorRole 라벨(본사 매니저/운영자 위장) |
| 8 | 상세 | detail (없으면 빈 칸) |
상한 · CSV injection 방어
- 상한
EXPORT_MAX=50000행(베타 규모 충분). 초과 시 50,000+1 peek-ahead 감지 → 50,000 row 만 emit + 응답 헤더X-Export-Truncated: true→ 클라이언트가 잘림 경고 배너 노출 (“결과가 50,000행 으로 잘렸습니다. 필터를 좁힌 뒤 다시 시도해 주세요.”). - CSV injection 방어 — 공용
AuditCsvWriter가=·+·-·@·탭·CR 시작 셀에'prefix (SPEC #048/#070/#074 와 동일 모듈 재사용; new methodwriteOperatorHqAuditCsv).
FE 다운로드 패턴 (generated 훅 우회)
generated orval hook 미사용 — apiFetch mutator 가 모든 응답을 res.text() → JSON 파싱이라
text/csv 응답에 부적합. 공용 helper apps/admin/src/lib/csv-export.ts downloadCsvFromBackend( path, query, fallbackFilename) 가 우회한다(#048/#070/#074 와 동일 모듈).
- 브라우저에서 BFF (
/api/backend/v1/admin/audit/hq/export?...) 로 직접fetch(catch-all route handler 가arrayBuffer()passthrough +Content-Type/Content-Disposition헤더 보존). 새 라우트 0. res.blob()→URL.createObjectURL→<a download>부착·클릭·제거 → 다음 tick 에revokeObjectURL(Safari 다운로드 보호).- 401 감지 시
/login?next={pathname+search}풀 리다이렉트(mutator 동작 미러, 필터 search 보존).
/audit/hq PageHeader.primary 슬롯에 [CSV 내보내기] 버튼(lucide:Download 아이콘, secondary
variant, sm). 진행 중 disabled + aria-busy="true" + 라벨 “다운로드 중…”. 잘림 시 Banner variant="warn" + [닫기], 실패 시 Banner variant="danger" + [닫기] 를 AuditTabs 와 본문
사이에 배치(#074 미러).
보낼 필터 (applied 값)
화면 테이블이 보여주는 것과 일치하도록 draft(폼 입력) 가 아닌 applied 필터(appliedFrom·
appliedTo·appliedHqId·appliedAction·appliedTargetType·appliedQ)를 보낸다. <input type=date> date-only 는 KST(+09:00) 경계로 정규화한다(from=00:00:00.000·to=23:59:59.999
inclusive — list 와 동일 규칙). generated ExportOperatorHqAuditParams 의 actorAccountId 는
본 슬라이스에서 노출하지 않으므로(F2 행위자 select 후속) 항상 undefined(keyof 전수 강제 가드용).
Roadmap (후속)
F1 CSV 내보내기✅ SPEC #088 도착.- F2 본사 필터 select —
listOperatorHqOptions(또는 기존 본사 카탈로그 활용) 자동완성 select 로 UUID 자유 입력 대체. - 실시간 audit (#067 F6) · 보존 정책 (#067 F7) 은 본사·운영사 공통 후속.
- PDF · 자동 스케줄 (CSV export 4종 공통 후속).
References
- SPEC #081 (운영사 통합 본사 audit) · SPEC #088 (CSV 내보내기) · #068 F4 (마감) · #067 (본사 audit 백본 — row idiom 원본) · #048 (운영자 액션 CSV — 공용 helper 원본) · #070 (본사 audit CSV) · #074 (임퍼소네이션 CSV — 패턴 직접 미러 원본) · #026 · #031-D (운영사 audit 헤더 idiom)
apps/admin/src/app/(protected)/audit/hq/(page.tsx·audit-hq-client.tsx) ·apps/admin/src/lib/csv-export.ts·apps/admin/src/app/(protected)/audit/audit-tabs.tsx(탭 확장)- 시안 부재 —
apps/spacehq-audit-client.tsxrow idiom +apps/adminactions/impersonation 헤더 idiom 미러