FeaturesHQ (본사)HQ Mode TTS 안내방송 (apps/space /admin/announcements)

HQ Mode — 본사 TTS 안내방송 (apps/space /admin/announcements)

SPEC #061 도입 (생성·보관). SPEC #062 — 수정(조건부 재합성) 추가. SPEC #063 — 톤 프리셋(voice 연동) 추가. 송출 슬라이스 — [송출](전체 매장 ALL) 액션 추가. SPEC #065 — 송출 이력 조회(read-only) 추가. SPEC #066 — list 송출 요약. SPEC #067 — 송출 audit(actor·시각·대상 스냅샷, /admin/audit 페이지). SPEC #068 — 라이프사이클 audit 확장(create/update/delete). SPEC #071 — 이력 다이얼로그 “행위자” 컬럼(announcement_dispatch.audit_id V28 + DispatchHistoryItem 3 필드 + impersonate 보조 줄, #065 F1 마감). SPEC #072 — 송출 다이얼로그 STORES 모드 활성(전체 매장 ALL / 선택 매장 STORES 모드 토글 + 매장 다중선택 체크리스트 + 검색 + 결과 배너 모드 줄, FE-only). SPEC #073 — 송출 다이얼로그 미리듣기 게이트(MiniPlayer 패턴 미러 + onPlay 1회 게이트 + 닫힘/재오픈 시 리셋, FE-only · 시안 hq-broadcast-new Step 2 정렬). SPEC #075 — 송출 이력 헤더 고유 매장 수(distinctStoreCount). SPEC #076 — 송출 이력 행 inline [재송출](STORES 1곳 미러). SPEC #077 — 송출 이력 행 inline [취소](PENDING 행만 · PATCH /api/v1/hq/dispatches/{id}/cancel · DispatchStatus CANCELED 추가 · 점장 player 자동 제외 · 같은 트랜잭션 audit HQ_ANNOUNCEMENT_DISPATCH_CANCELED). SPEC #077 확장 — 송출 이력 행 inline [원격 중단](revoke, PENDING 행만 · POST /api/v1/hq/dispatches/{dispatchId}/revoke · PENDING→CANCELED + revoked_at + 별도 audit HQ_DISPATCH_REVOKED · 점장 pending 응답 revokedDispatchIds 신호로 player 가 재생 중 멘트 즉시 중단 — cancel=재생 전 무효화, revoke=재생 중/직전 best-effort 중단으로 의도 분리). SPEC #078 — 본사 송출 예약/스케줄(즉시/예약 모드 토글 + DispatchRequest.scheduledAt ISO + V29 announcement_dispatch.scheduled_at + DispatchStatus.SCHEDULED 추가 + Spring @Scheduled 1분 cron 디스패처 SCHEDULED→PENDING 원자 전이 + 이력 다이얼로그 StatusPill 4종/헤더 chip 5종/scheduledAt 셀/[취소] SCHEDULED 활성). 반복 송출 예약 관리 UI — BE 백본(GET/POST /api/v1/hq/dispatch-schedules · PATCH .../{id}/cancel) 위에 FE 관리 화면(/admin/announcements/schedules) 추가. 안내방송을 매일/매주(byWeekday CSV) 지정 시각(5분 슬롯)에 반복 송출하는 규칙을 등록·조회(상태 필터·페이지네이션)·취소(ACTIVE→CANCELED). 안내방송 영역 서브 탭(콘텐츠/반복 예약) 동선. 전용 시안 부재 — 임시 레이아웃(인접 화면 atom 정합, FE-only). 실 HQ_MANAGER 직접 로그인 표면(apps/space · space.linkmusic.io).

본사 모드(apps/space)의 실 HQ_MANAGER 화면이다. 본 표면 범위는 **TTS 안내방송 콘텐츠의 생성(합성)·목록·재생·수정·삭제 + 매장 송출(전체 매장 ALL / 선택 매장 STORES — SPEC #072) + 송출 다이얼로그 미리듣기 게이트(SPEC #073)스케줄·REGION 모드·더킹·우선순위·CM송·LLM 자동멘트는 범위 밖(후속).

Overview

HQ_MANAGER 가 제목 + 텍스트 + voice(5종) + 톤 프리셋(voice 연동) 을 입력하면 서버가 Typecast 로 음성을 합성(동기 완료) → Azure blob 저장 → TtsAnnouncement 엔티티로 관리한다. 본사 모드에서 자기 본사(hqId)의 안내방송을 생성·목록·재생·수정·삭제한다. 전부 hqId 스코프(토큰 주체 도출, 요청 파라미터 없음, BE verifyHqScope — 타 본사 안내방송 접근 불가).

시안 출처: workspace parent dir design_handoff_linkmusic_7/design/screens/hq-announcements.jsx (안내방송 관리 전용 시안 — 목록·행 재생·생성/수정/삭제 다이얼로그의 단일 소스) + hq-broadcast-new.jsx §Step1Content(생성 입력 스타일 참조). 시안의 스텝퍼·미리듣기 게이트·스케줄· 송출·대상 단계는 본 표면 범위 밖이라 제외하고, 단일 폼 생성/수정 다이얼로그로 구성. 목록/검색/ 페이지네이션은 공용 ListToolbar/ListPagination(본사 /playlists #057 관용구)으로 정합.

목록 (/admin/announcements)

apps/space/src/app/admin/announcements/page.tsx(server 셸) + hq-announcement-list-client.tsx (client). 본사 /playlists(#057)와 동일하게 서버사이드 페이지네이션 + client-query (useListHqTtsAnnouncements) — q(제목)·page·size 를 client state 로 보유. 정렬은 서버 고정 (created_at DESC), soft-delete 제외.

  • 툴바: 공용 ListToolbar — 검색(제목, ≤100) + 적용/초기화 + 우측 슬롯 [새 안내방송](생성 다이얼로그 트리거).
  • (TtsAnnouncementListItem): 제목(title)·목소리(voiceDisplayName, BE 한글 표시명)·톤(tonePresetDisplayName)·길이(durationSecondsm:ss, null 이면 )·생성일(createdAt, KST)·재생·송출 횟수(#066)·마지막 송출(#066·#097)·송출·이력·수정·삭제. 톤 배지(#063): 비-기본 톤(tonePreset !== "NORMAL")만 목소리 옆 배지로 강조하고, 기본(NORMAL)은 노이즈라 생략한다(hq-announcement-tone-{id}). 송출 요약(#066): 재생 옆 두 컬럼 — 송출 횟수(dispatchCount, 우측정렬 tabular-nums, 0 이면 )·마지막 송출(lastDispatchedAtformatKstDateTime, null 이면 ). 카운트는 dispatch row 수=중복 송출 포함이며 고유 매장 수가 아니다. 마지막 송출 fan-out 사이즈(#097): 마지막 송출 셀 같은 자리에 한 단계 작은 보조 줄로 (N매장) 표기(lastDispatchStoreCount). 누적 아닌 마지막 1회 호출의 distinct 매장 수lastDispatchedAt 가 가리키는 그 호출의 fan-out. 미송출이거나 legacy null 이면 표시 없음(noise 차단). 행 [이력] 진입을 결정할 때 가벼운 시그널이며 행 자체의 강조 변형은 디자인 시안 공백이라 추가하지 않는다(testid hq-announcement-dispatch-count-{id}·hq-announcement-last-dispatched-{id}·hq-announcement-last-dispatch-store-count-{id}).
  • 재생 (커스텀 미니 플레이어, 시안 §RowPlayer): list item DTO 에는 audioUrl 이 없으므로(상세에만 존재), 행의 [재생]을 누른 행만 useGetHqTtsAnnouncement(id, { enabled }) 로 상세를 lazy fetchaudioUrl 을 얻는다(N+1 회피, react-query 캐시). 재생 표현은 브라우저 기본 <audio controls> 가 아니라 시안의 커스텀 미니 플레이어(원형 재생/일시정지 버튼 + 진행 바 + 0:03 / 0:08 시각 + 닫기 X)다 — 실제 재생은 sr-only 숨김 네이티브 <audio> 가 구동하고 UI 만 커스텀 렌더한다. phase: idle([재생] 버튼) → loading(“불러오는 중…” 스피너) → error(“오디오를 불러올 수 없습니다” + 닫기) → ready(미니 플레이어). 단일 재생: 한 행을 펼치면 부모(HqAnnouncementListClient)가 playingId 로 다른 행을 닫아 동시 재생을 막는다. 펼친 직후 자동 재생을 시도하되 브라우저 autoplay 차단 시 일시정지 상태로 남는다(safePlay, jsdom 등 미구현 환경도 안전). 캐시버스터(#062): 재합성된 오디오는 같은 blob url 에 덮어쓰기되므로 stale 브라우저 캐시를 피하기 위해 audioUrl?v={updatedAt} 을 붙인다(상세 응답의 updatedAt 우선, 없으면 행의 updatedAt).
  • 송출: 행 [송출] → 확인 다이얼로그(DispatchAnnouncementDialog, 삭제 다이얼로그 idiom 미러 + SPEC #072 모드 토글) → useDispatchHqTtsAnnouncement(POST /api/v1/hq/announcements/{id}/dispatch, body { target: "ALL" } 또는 { target: "STORES", storeIds: [...] }) → 201 DispatchResponse{ dispatchedCount, dispatchIds }. 성공 시 목록 상단 배너로 “대상: 전체 매장” 또는 “대상: 선택 매장 N개” 보조 줄 + “N개 매장에 송출했습니다”(0이면 “송출할 매장이 없습니다” 안내) + 결과 배너 우측 [이력 보기] 액션이 같은 안내방송 송출 이력 다이얼로그로 점프(#065 — dispatchedCount=0 이면 표시 X). 아래 송출 다이얼로그 참조.
  • 송출 이력 조회 (#065): 행 [이력] → DispatchHistoryDialoguseListHqTtsAnnouncementDispatches(GET /api/v1/hq/announcements/{id}/dispatches) → 200 DispatchHistoryResponse{items,page,size,total,aggregate}. 매장 단위 송출 row + 페이지 무관 집계(total/played/pending). 아래 송출 이력 조회 참조.
  • 수정: 행 [수정] → 생성 다이얼로그를 create/edit 겸용으로 재사용(EditAnnouncementDialog). 아래 수정 다이얼로그 참조.
  • 삭제: 행 [삭제] → 확인 다이얼로그 → useDeleteHqTtsAnnouncement(204 soft-delete) → 목록 invalidate. 404(이미 삭제됨)는 성공으로 흡수 + 목록 갱신.
  • 빈 상태: 검색 0건(ListNoResults CTA) vs 진짜 빈 목록 구분.
  • 페이지네이션: 공용 ListPagination — 총 N개 + 이전/다음(경계 disabled).

생성 다이얼로그 (합성)

create-announcement-dialog.tsx — 공용 Dialog + 단일 폼.

  • 입력: 제목(@maxLength 255)·텍스트(여러 줄 textarea, @NotBlank @Size(max 1000), 실시간 글자수 표시)·voice(TtsVoice 5종 select, 한글 라벨)·톤 프리셋(TtsTonePreset, voice 연동 — 시안 §AnnouncementDialog 의 칩/pill 버튼 그룹 role="group", aria-pressed)·발화 속도(tempo, #139 — voice select 아래 select, 0.5~2.0 step 0.1, 기본 1.0=1.0× (기본), tts-voice-meta.ts TTS_TEMPO_OPTIONS/formatTempoLabel, hq-announcement-tempo-select).
  • voice 라벨: 생성 폼은 합성 전이라 BE voiceDisplayName 을 받을 수 없으므로 프론트가 라벨을 보유(tts-voice-meta.ts). enum value 는 generated CreateTtsAnnouncementRequestVoice(OpenAPI 단일 소스), value→한글 라벨만 매핑(라벨이 어긋나도 합성은 enum value 로 진행).
  • 톤 프리셋 (voice 연동, #063): 다이얼로그가 열릴 때 useListHqTtsVoices(GET /api/v1/hq/tts-voices)로 voice별 허용 톤 프리셋 매핑(TtsVoiceListResponse{voices:[{voice,voiceDisplayName,presets:[{preset,presetDisplayName}]}]})을 가져와, 선택된 voice 의 presets 만 톤 칩(pill 버튼)으로 노출한다(hq-announcement-tone-preset-group 그룹 안에 hq-announcement-tone-preset-{PRESET} 칩, 선택 시 aria-pressed=true). 지원하지 않는 톤 칩은 애초에 렌더하지 않는다(시안 §AnnouncementDialog — “애초에 선택 불가하게”). voice 를 바꿔 현재 톤이 새 voice 의 허용 집합 밖이면 자동으로 첫 칩(=NORMAL)으로 리셋한다(UI 가 허용 위반을 애초에 차단). 모든 voice 가 NORMAL(기본) 을 포함하므로 기본값은 NORMAL. voices 응답이 아직/실패면 폴백으로 NORMAL 칩만 노출(tts-voice-meta.ts 정적 라벨)해 폼이 깨지지 않는다.
  • 합성: useCreateHqTtsAnnouncement 로 트리거(요청에 voice·tonePreset·tempo(#139) 포함). 합성은 서버에서 수 초 걸림 — pending 중 스피너(Loader2) + “합성 중…” + 입력·액션 disable + ESC/overlay 닫기 차단(요청 유실 방지). 성공 시 목록 invalidate + 닫기.
  • 검증: 빈 제목/텍스트는 클라이언트에서 submit disable(불필요한 합성 호출 절약). OpenAPI 제약과 일치(frontend.md §8).

송출 다이얼로그 (전체 매장 / 선택 매장 / 지역)

dispatch-announcement-dialog.tsx — 삭제 다이얼로그 idiom 미러(공용 Dialog + 확인) + SPEC #072 모드 토글 + SPEC #144 REGION 모드. SPEC #061 의 첫 슬라이스는 target: "ALL" 고정이었고, SPEC #072 가 같은 다이얼로그에 송출 대상 모드 라디오(전체 매장 ALL / 선택 매장 STORES)를 추가해 BE 백본(DispatchTarget.STORES)을 활성화했으며, SPEC #144 가 지역(REGION) 옵션을 추가했다.

  • 트리거: 행 [송출] → controlled 다이얼로그(dispatchTarget = { id, title, updatedAt }). 확인 시 useDispatchHqTtsAnnouncement({ id, data }). 모드 = ALL → data: { target: "ALL" }. 모드 = STORES → data: { target: "STORES", storeIds: [...선택 id...] }. 모드 = REGION(#144) → data: { target: "REGION", region: "<시/도>" }(storeIds 미전송).
  • REGION 모드 (SPEC #144): 모드 라디오에 “지역” 추가. 선택 시 시/도 select(apps/space/src/lib/region.ts REGION_OPTIONS 17종 + 맨 위 “지역 선택” placeholder) 노출. target=REGION 인데 미선택이면 송출 disabled + 보조 텍스트 송출할 지역을 선택해 주세요.(BE 400 DISPATCH_REGION_REQUIRED 선검증). 결과 콜백(onDispatched) 페이로드에 region(시/도) 추가 → 배너 첫 줄 대상: 지역 (서울). testid hq-announcement-dispatch-mode-region·-region-picker·-region-select·-region-hint.
  • 미리듣기 게이트 (SPEC #073): 다이얼로그 본문 최상단(모드 토글 위)에 미리듣기 섹션. 다이얼로그 open 시점에 useGetHqTtsAnnouncement(target.id, { query: { enabled: open } }) 로 detail 을 lazy fetch — 같은 쿼리키를 AnnouncementRowPlayer 가 행 펼침 시 이미 채워뒀으면 즉시 react-query 캐시 hit. detail.audioUrl 에 ?v={target.updatedAt} 캐시버스터(목록 prop 우선, announcement-row-player 와 동일 정책) 적용. 시각·동작은 MiniPlayer 패턴(SPEC #064) 그대로 inline 미러 — ▶/⏸ 토글 · 진행바(role progressbar · aria-valuenow) · 시간 표시 m:ss / m:ss · 안내방송 제목. <audio>onPlay 이벤트가 한 번이라도 발생하면 previewed=true게이트 통과 (끝까지 듣기 강제는 UX 마찰 큼, F1 후속). 통과 전엔 송출 버튼 disabled + 보조 텍스트 미리듣기를 1회 이상 재생해 주세요. (우선순위 1 — STORES 빈 선택 가드보다 먼저). 통과 후 우측 상단에 미리듣기 완료 표식. 다이얼로그를 닫으면 audio paused + currentTime=0 + previewed=false 로 리셋 — 재오픈 시 게이트 다시 강제(open prop 변화를 단일 진실원으로 useEffect). phase: loading 스피너 / error Banner danger + [다시 시도] (query.refetch()) / edge audioUrl 빈 문자열 Banner danger “오디오를 찾을 수 없습니다. 다시 시도해 주세요” + audio mount X(노이즈 요청 회피). 시안 hq-broadcast-new Step 2 정렬. 새 컴포넌트 추출 없음(시안 정합 + 두 곳 한정).
  • 모드 토글 (SPEC #072 + #144): 다이얼로그 상단 fieldset + 라디오 3종(role="radiogroup" — 전체 매장 / 선택 매장 / 지역). 기본값 = ALL(기존 동작 보존, 가벼움). 모드를 바꾸면 인라인 에러 배너는 함께 리셋. 다이얼로그를 닫으면 모든 로컬 state(모드·검색어·선택 set·선택 지역·미리듣기 게이트·스케줄 모드)가 리셋돼 다음에 다시 열 때 ALL/빈 선택/미선택 지역/미리듣기 미통과/IMMEDIATE 로 시작한다.
  • 송출 시점 토글 (SPEC #078 — 즉시/예약): STORES 모드 토글과 별개의 두 번째 fieldset + 라디오 2종(role="radiogroup", 시안 hq-broadcast-new Step 3 두 카드 토글 미러). 기본값 = IMMEDIATE(기존 동작 보존). SCHEDULED 모드: 토글 아래에 <input type="date">(min={오늘} — KST 기준 캘린더 단계 1차 차단) + <input type="time">(24h). 다이얼로그 open 시점에 현재+1시간 KST 로 자동 채움(빈 입력 가드 완화 — 시안엔 빈 초기값이지만 FE 는 여유 시각으로 채움). 클라이언트 검증: ① 빈 값 → “예약 시각을 입력해 주세요” 보조 텍스트 + 송출 버튼 disabled. ② 결과 시각 ≤ 현재 → “현재 이후 시각을 입력해 주세요” + disabled. mutation 페이로드에 scheduledAt 키 추가: IMMEDIATE 면 키 자체를 생략(기존 동작·BE 트레이스 일관), SCHEDULED 면 사용자 입력 date·time 을 +09:00 오프셋과 결합해 new Date(...).toISOString() 으로 KST 경계 정규화한 UTC ISO. 송출 버튼 라벨 분기: IMMEDIATE = “전체 매장에 송출” / “선택 매장에 송출” (기존) → SCHEDULED = “예약 등록하기”. pending 중엔 IMMEDIATE = “송출 중…” / SCHEDULED = “예약 등록 중…”. 결과 콜백(onDispatched) 페이로드에 scheduleMode (IMMEDIATE 또는 SCHEDULED) + scheduledAt (SCHEDULED 면 ISO) 추가 — 부모(HqAnnouncementListClient)가 결과 배너를 분기한다(IMMEDIATE = 기존 N개 매장에 송출했습니다 / SCHEDULED = info 톤 {KST 시각}에 {제목} 안내방송 예약 등록 완료 (N개 매장)formatKstDateTime 으로 24h KST 표기). 다이얼로그를 닫으면 scheduleMode·scheduledDate·scheduledTime 모두 리셋(resetLocaluseEffect([open]) 와 짝). BE 에러 매핑: 400 DISPATCH_SCHEDULED_AT_PAST → Banner danger “과거 시각으로 예약할 수 없습니다.” · 400 DISPATCH_SCHEDULED_AT_TOO_FAR → “1년을 초과한 예약은 허용되지 않습니다.” (BE 가 시계 차·동시성으로 클라 가드를 뚫고 들어온 케이스). 송출 disabled 우선순위(SPEC #073/#078 결합): ① 미리듣기 미통과 → ② SCHEDULED + 빈 입력 → ③ SCHEDULED + 과거 시각 → ④ STORES + 0개 선택 → ⑤ mutation pending.
  • STORES 모드 picker (SPEC #072): ALL 모드에서는 picker 미노출. STORES 모드 진입 시 useListHqStores({ size: 100 }) 를 enabled-게이팅으로 호출(HqStoreListResponse{items, page, size, total}, SPEC #051 재사용) → 한 번에 최대 100개. 검색(placeholder="매장명 검색") 은 클라이언트 측 case-insensitive 부분일치(매장명). 100개 초과 본사는 검색으로 좁힌 뒤 선택(서버 검색 페이지네이션은 SPEC #072 F2 후속). 카운터 N개 선택 / 검색 결과 M개 좌측 + 보조 액션 전체 선택 / 전체 해제(현재 검색 결과 범위 — 모두 선택돼 있으면 해제, 아니면 선택) 우측. 각 행은 <label> + <input type="checkbox"> + 매장명 + SUSPENDED/폐점 배지(있을 때만 — ACTIVE/INACTIVE 는 노출 X). 폐점 매장도 선택 가능(BE 가 STORES 모드에서 명시 지정 존중).
  • 빈 선택 가드: STORES 모드 + 0개 선택 → 송출 버튼 disabled + 입력 아래 보조 텍스트 매장 1개 이상을 선택해 주세요. REGION 모드 + 미선택 지역 → disabled + 송출할 지역을 선택해 주세요.(#144). ALL 모드면 항상 활성(pending 중에만 disable). 송출 버튼 disabled 우선순위(SPEC #073/#078/#144): ① 미리듣기 미통과 → ② SCHEDULED + 빈/과거 시각 → ③ STORES + 0개 선택 → ④ REGION + 미선택 지역 → ⑤ mutation pending.
  • 결과: 201 DispatchResponse{ dispatchedCount, dispatchIds }. 부모(HqAnnouncementListClient)가 목록 상단 배너(hq-announcement-dispatch-result)로 안내 — dispatchedCount > 0 → success 톤 “N개 매장에 송출했습니다”, 0 → info 톤 “송출할 매장이 없습니다”(산하 매장 미존재일 수 있어 에러로 보지 않음). 배너 첫 줄에 SPEC #072 보조 줄 대상: 전체 매장 또는 대상: 선택 매장 N개(N = 보낸 storeIds 길이) 노출 — onDispatched 콜백 페이로드에 dispatchMode·selectedStoreCount 가 함께 올라온다.
  • fan-out 의미: 송출은 매장당 PENDING announcement_dispatch row 를 만든다. 각 매장 점장 player 가 폴링해 곡 끝에 1회 삽입 재생하고 ack 한다(Store Player). 중복 송출 허용(같은 안내방송을 여러 번 송출하면 PENDING row 가 누적). STORES 모드는 fan-out 범위만 선택 매장으로 좁힌다(BE 가 storeIds 를 본사 산하로 검증).
  • 다이얼로그 폭: 720px(w-[720px]) — 매장 리스트가 답답하지 않도록 기본 520 보다 넓힘(twMerge 가 기본 w override).
  • pending 중 스피너 + 입력·체크박스·라디오·액션 disable + ESC/overlay 닫기 차단(요청 유실 방지).

송출 에러 매핑

status · code동작
201성공 — onDispatched(결과 배너) + 닫기
404 TTS_ANNOUNCEMENT_NOT_FOUND”삭제되었거나 존재하지 않는 안내방송입니다.” (닫지 않음)
404 DISPATCH_NOT_FOUND(점장 ack 경로 전용 — 본사 송출에는 비해당)
404 DISPATCH_STORE_NOT_FOUND (SPEC #072)“선택한 매장 중 본사 산하가 아닌 매장이 있습니다.” (닫지 않음 — storeIds 검증 가드)
400 DISPATCH_INVALID_TARGET”송출 대상이 올바르지 않습니다.” (STORES 인데 storeIds 생략(null) 또는 빈 배열 minItems:1 검증 400. FE 가 0개 선택을 submit disable 로 미리 막지만 방어)
400 DISPATCH_SCHEDULED_AT_PAST (SPEC #078)“과거 시각으로 예약할 수 없습니다.” (scheduledAt <= now — FE 가 클라 가드로 막지만 시계 차·동시성으로 도달 가능)
400 DISPATCH_SCHEDULED_AT_TOO_FAR (SPEC #078)“1년을 초과한 예약은 허용되지 않습니다.” (페이로드 폭주 방지 1년 상한)
401 (AUTH_UNAUTHENTICATED·NO_SESSION)“다시 로그인해주세요.” (닫지 않음)
403 (scope/role)“송출할 권한이 없습니다.” (닫지 않음)
그 외 5xx”서버에 일시적인 문제가 있습니다.” (닫지 않음)

송출 이력 조회

dispatch-history-dialog.tsx (#065) — 공용 Dialog + 본사 목록 테이블 idiom 미러. read-only 다이얼로그. dispatch 백본(announcement_dispatch, V23) 의 row 를 그 안내방송 scope 로 조회해 본사가 “내가 보낸 안내방송이 어디로 어떻게 송출됐는지” 본다. 매장 그룹핑 토글 view(#089·#098)·행/매장 일괄 재송출·취소(#096·#125)·재송출/취소(#076·#077) 모두 도착 — 고유 매장 수는 SPEC #075 에서 헤더 chip 으로, list 요약은 SPEC #066 도착.

  • 진입점 2종: ① /admin/announcements 행 [이력] 버튼(액션 군 [송출] [이력] [수정] [삭제]), ② 송출 결과 배너 우측 [이력 보기] 액션(dispatchedCount > 0 일 때만 — 0이면 보여줄 이력 없음).
  • 호출: useListHqTtsAnnouncementDispatches(id, { page, size })enabled: target !== null 로 다이얼로그가 열려 있을 때만 fetch. target 변경 시 자연스럽게 새 query.
  • 응답: DispatchHistoryResponse{items,page,size,total,aggregate} — items=현재 페이지 송출 row(DispatchHistoryItem{dispatchId,storeId,storeName,status,createdAt,playedAt?}), aggregate=페이지 무관 전체 카운트({total,played,pending,distinctStoreCount}). 정렬 서버 고정 created_at DESC, id DESC 결정적.
  • 집계 헤더 chip 5종 (SPEC #078): 총 N건 · 예약 N · 재생 N · 대기 N · 매장 N곳(시간 흐름: 예약→대기→재생 라이프사이클 미러). 페이지 이동·재로드와 무관하게 같은 숫자(announcement scope 전체). 예약 chip(SPEC #078, aggregate.scheduled = SCHEDULED row 수, 디스패처 전이 전만 카운트) — info 톤(아직 진행 전). 고유 매장 수(SPEC #075, distinctStoreCount = COUNT(DISTINCT storeId)) — 같은 매장에 다회 송출돼도 한 번만 카운트해 본사가 “매장 단위 도달 범위”를 한 눈에 인지(중복 누적 row 의 total 만으로는 매장 수 직관 0). 매장 0건(송출 전)도 매장 0곳 으로 노출 — 빈 상태는 본문 empty state 가 따로 안내한다.
  • 테이블: 5컬럼 매장 / 상태 / 송출 시각 / 재생 시각 / 행위자(SPEC #071, #065 F1 마감). 상태는 <StatusPill> 5종(SPEC #077·#078·#143): SCHEDULED=info “예약” / PENDING=warn “대기” / PLAYED=success “재생” / CANCELED=muted “취소됨” / MISSED=muted “미재생(만료)“(SPEC #143 — 도래+grace 초과 자동 만료, 취소됨과 라벨로 구분) — 전수 강제 Record<DispatchHistoryItemStatus,...> 로 새 상태 추가 시 컴파일 누락 노출. PENDING·SCHEDULED·CANCELED·MISSED 행의 재생 시각은 . SCHEDULED row 의 “송출 시각” 열은 scheduledAt(예약 시각) 으로 분기(SPEC #078) — createdAt(=예약 등록 시각)은 title tooltip 보조 노출(hover 로 등록 시점 확인). 그 외(PENDING/PLAYED/CANCELED)는 기존대로 createdAt. 다이얼로그 폭 820px(공용 520 보다 넓힘 — 5컬럼 표).
  • 행위자 셀(SPEC #071): 메인 줄 = actorEmail(font-mono, /admin/audit HqAuditRow 패턴 미러). actorRole === "OPERATOR_IMPERSONATING" 이면 아래 보조 줄에 ↩ {impersonatedByEmail} + “운영자 위장” pill 배지(원본 운영자·위장 본사 매니저 둘 다 추적). 직접 송출(HQ_MANAGER)은 보조 줄 없음. V28 이전 row(announcement_dispatch.audit_id IS NULLactorEmail === null)는 메인 셀에 로 폴백·보조 줄 없음(백필 안 함). 다이얼로그 actor 정보는 /admin/audit 의 동일 audit row 와 항상 같은 값(공통 hq_audit_log 스냅샷, announcement_dispatch.audit_id FK 매핑).
  • 중복 송출 표시: 같은 매장에 여러 번 송출된 경우 별개 row 로 전부 노출(매장 그룹핑 요약은 후속 F). FE 는 임의 합치지 않는다.
  • 페이지네이션: 공용 ListPagination(size 20). total > 0 이고 items 가 있을 때만 노출 — empty/error/loading 일 때는 숨김.
  • 에러: 404 TTS_ANNOUNCEMENT_NOT_FOUND(“삭제되었거나 존재하지 않는 안내방송입니다.” — STORE_BROADCAST 출처 흡수) · 403(권한 없음) · 5xx(서버 일시 문제). 모두 상단 danger 배너로 격리, 표는 숨김.
  • 빈 상태: total=0 은 에러 아님(아직 송출 안 함·산하 매장 0건 송출). MailCheck 아이콘 + “아직 송출 이력이 없습니다.” 안내.
  • 행 inline [재송출] (SPEC #076 + #078 확장): 행위자 셀 오른쪽 끝에 컴팩트 텍스트 버튼 + lucide Send 아이콘(hq-announcement-redispatch-row-{dispatchId}, 새 컬럼 추가 X — 표 답답해지지 않게). SCHEDULED/PENDING/PLAYED/CANCELED 모두 활성(status 분기 없음 — 다른 매장에 다시 보내거나 같은 매장에 즉시 송출하는 의미 유지). SCHEDULED row 의 [재송출] 도 STORES 모드 1곳 즉시 송출(예약 row 와 별개의 새 PENDING row 생성). V28 이전 row(actorEmail = null)도 활성 — 액션 actor 는 현재 로그인 본사 매니저(또는 임퍼소네이션 운영자). 폐점 매장 row 도 활성(BE 가 STORES 모드에서 명시 지정 존중). 클릭 → 본문 표 위에 inline confirm strip(hq-announcement-redispatch-strip) 노출 — ”**{매장명}**에 다시 송출하시겠습니까? [재송출] [취소]“(새 모달 컴포넌트 추출 X — strip 으로 시각 무게 최소화). strip [재송출] 클릭 → useDispatchHqTtsAnnouncement mutation 호출({ target: "STORES", storeIds: [그 매장 id] }, BE 변경 0 · 새 endpoint 0 · 새 audit 0 — dispatch hook 이 자체 기록). mutating 중 strip [재송출] 버튼 disabled + aria-busy + “재송출 중…” 표식. 성공 → 같은 announcement scope 의 history query 전체를 invalidate(getListHqTtsAnnouncementDispatchesQueryKey(target.id) 접두 매치) → 새 PENDING row 가 표에 등장하고 헤더 chip(total · pending · distinctStoreCount) 도 함께 자동 갱신. 실패 → strip 자리를 Banner danger(hq-announcement-redispatch-error)로 교체 — “재송출에 실패했습니다. 잠시 후 다시 시도해 주세요”(엣지 케이스 DISPATCH_STORE_NOT_FOUND 도 같은 일반 메시지로 흡수 · §D9). strip [취소] 또는 다이얼로그 닫힘(target → null) 시 strip dismiss + 선택 매장 state 리셋(useEffect([target]) 단일 진실원 — 재오픈 시 깨끗한 상태). 푸터 [닫기] secondary 유지.
  • 매장 단위 그룹핑 토글 (SPEC #089, #065 D3 후속): 헤더 chip 줄 아래에 컴팩트 radiogroup(hq-announcement-history-group-toggle) — 행 단위 / 매장 단위 라디오 2종(시안 부재 — atom-grounded, 송출 다이얼로그 STORES 모드 radiogroup 패턴 미러). 기본 OFF(groupByStore=false — 기존 dispatch row 단위 표 그대로, 회귀 0). ON 시 본문 표가 매장 단위 view 로 전환된다 — 컬럼 매장명 / 송출 횟수 / 최근 송출 시각 / 최근 상태 / 펼치기 chevron(hq-announcement-history-group-row-{storeId}). 그룹핑은 클라이언트 측 — 현재 페이지의 dispatch row 만 storeId 로 묶고(BE 신규 endpoint 0), 그룹 내 row 는 createdAt DESC 정렬해 “최근 송출 시각·최근 상태” 가 [0] 으로 결정적. 매장 단위 정렬은 그룹 내 가장 최근 createdAt DESC — 가장 최근 송출 매장이 위. 매장 row 클릭 또는 chevron 클릭 → 펼치기/접기 토글 — 그 매장의 dispatch row 목록이 sub-row 로 노출(들여쓰기 pl-8 + bg-surface-2/40, 기존 DispatchHistoryRownested prop 분기 — 컴포넌트 추출 없이 inline). 펼친 dispatch row 는 기존 5컬럼 표(매장·상태·송출시각·재생시각·행위자)와 inline [재송출]/[취소] 그대로 유지. 매장 단위 row 자체엔 단일 inline [재송출]/[취소] 노출 X(§D6 — 펼친 dispatch row 에서만). 매장 단위 일괄 액션은 별도 체크박스 컬럼으로 SPEC #125 가 도착(아래 항목). 다이얼로그 닫힘(target → null)·재오픈 시 토글 OFF + 펼친 매장 집합 비움(useEffect([target]) 단일 진실원 §D5). 시안 부재 — 추후 정식 시안 도착 시 정합 교체(F4). 잔여 후속: 매장 단위 BE 페이지네이션(F3).
  • 매장명 검색 (SPEC #098, #089 F2 마감 + F1 highlight): 그룹핑 토글 ON 모드 한정 으로 토글 아래 줄에 컴팩트 <input type="search">(hq-announcement-history-store-search · 너비 max-w-xs) 추가(시안 부재 — atom-grounded, STORES 모드 매장 검색 input idiom 미러). 입력값이 비어있으면 기존 노출 그대로(storeQuery="" 시 row pass-through). 비어있지 않으면 storeName.toLowerCase().includes(query.trim().toLowerCase())grouping 이전에 row 필터(같은 storeId row 가 분산되지 않게 — 현재 페이지 한정). 필터 후 0 매장이면 표 자리에 안내 검색 결과가 없습니다.(hq-announcement-history-store-search-empty). 다이얼로그 닫힘 · 그룹핑 OFF 전환 · 페이지 이동 시 storeQuery 리셋(useEffect[target]·[groupByStore]·[page]). 정규식 사용 안 함 · 다중 토큰 사용 안 함(단순 substring). 클라 필터의 “현재 페이지 한정” 제약은 F2 매장 단위 BE 페이지네이션 도착 시 해소. #098 F1 — 매칭 substring highlight: 노출 매장의 매장명에서 검색어와 일치하는 substring 을 <mark> 로 감싸 시각 강조(hq-announcement-history-store-search-highlight · bg-yellow-200/70 라이트 · bg-yellow-400/40 다크). 같은 storeName 안에 다회 등장하면 모두 표시. 빈 검색어 면 plain text(불필요 fragment 회피).
  • 다중 일괄 액션 (SPEC #096 행 단위 + SPEC #125 매장 그룹 단위, #076 F2 + #077 F1 + #089 F1 마감): 양 모드 모두 노출. 헤더 chip 줄 아래에 일괄 액션 영역(hq-announcement-history-bulk-actions) — 카운터(행 모드 “N개 선택” / 그룹 모드 “N개 매장 선택”, hq-announcement-history-bulk-counter) + [선택 재송출] primary(hq-announcement-history-bulk-redispatch) + [선택 취소] danger(hq-announcement-history-bulk-cancel). 선택 0 → 두 버튼 disabled. [선택 취소] 는 cancellable(PENDING/SCHEDULED) 카운트 0이면 disabled(PLAYED·CANCELED 선택은 카운트 제외).
    • 체크박스 컬럼: 행 모드 = 표 첫 셀에 행 체크박스(헤더 hq-announcement-history-select-all = “현재 페이지 전체 선택/해제” · 행 hq-announcement-history-select-row-{dispatchId}). 그룹 모드 = 매장 단위 체크박스(헤더 hq-announcement-history-select-all = 검색 필터 통과 전체 매장 선택/해제 · 매장 hq-announcement-history-select-store-{storeId}, 일부만 선택된 매장은 indeterminate). 매장 체크박스 클릭은 펼치기(row onClick)와 분리(stopPropagation) — 선택해도 펼쳐지지 않는다.
    • 선택 단일 진실원: 양 모드 모두 selectedDispatchIds(dispatchId Set). 그룹 모드의 매장 체크박스는 그 매장 그룹의 dispatchId 전부를 한꺼번에 토글 → 재송출 dedupe·취소 대상(“매장 내 cancellable 전부”)이 행 단위 #096 로직과 자동 정합(handleBulk* 핸들러·뮤테이션 공유). 선택 가능 집합은 모드별로 다름 — 행 = 현재 페이지 전체 row, 그룹 = 검색 필터(#098) 통과 그룹의 row.
    • [선택 재송출] 클릭 → confirm strip(hq-announcement-history-bulk-redispatch-strip) **N개 행**을 재송출하시겠습니까? (중복 매장은 한 번만)(행) / **N개 매장**에 재송출하시겠습니까?(그룹) [재송출 확정] [닫기] → 확정 → 선택분의 unique storeIds 추출(dedupe) → useDispatchHqTtsAnnouncement.mutateAsync({ id, data: { target: 'STORES', storeIds } }) 1회 호출(BE 변경 0 · 새 endpoint 0). 성공 → history query invalidate + 선택 리셋 + 결과 banner success 재송출 완료 (N매장). 실패 → hq-announcement-history-bulk-error Banner danger.
    • [선택 취소] 클릭 → confirm strip(hq-announcement-history-bulk-cancel-strip) 선택 N개 중 취소 가능 M개를 취소합니다(행) / 선택 매장 N곳 중 취소 가능 M개를 취소합니다(그룹) [취소 확정] [닫기] → 확정 → cancellable row 만 useCancelHqDispatch.mutateAsync({ id: dispatchId }) 각각 호출 Promise.allSettled([...]) (병렬 다중 호출 — F2 BE bulk endpoint 후속). 완료 시 invalidate + 선택 리셋 + 결과 banner — 실패 0이면 success 톤 취소 완료 N개, 부분 실패면 danger 톤 취소 완료 N개 / 실패 M개.
    • 진행 표시: bulk 진행 중 두 버튼 모두 disabled + 보조 텍스트 처리 중...(hq-announcement-history-bulk-progress) + 체크박스도 disabled. 모드 전환(행↔그룹)·검색·페이지 이동·다이얼로그 닫힘/재오픈: 선택·진행 플래그·결과 banner·에러·confirm strip 모두 단일 진실원에서 리셋(useEffect([target])·[groupByStore]·[page] — §D3). 기존 단일 행 [재송출]/[취소] 액션은 그대로 — 회귀 0. 잔여 후속: BE bulk endpoint(대규모 본사 — 클라 다중 호출 → 한 트랜잭션, #089 F2).
  • 행 inline [취소] (SPEC #077 + #078 확장): [재송출] 옆 컴팩트 텍스트 버튼 + lucide Ban 아이콘(hq-announcement-cancel-row-{dispatchId}). PENDING + SCHEDULED 양쪽 노출 — PLAYED·CANCELED 는 종착 상태라 미노출(시간 되감기 X · 중복 취소 가드). SCHEDULED 도 본사가 디스패처 전이 전 단방향 CANCELED 로 보낼 수 있다(BE D14 — WHERE id AND hq_id AND (status='PENDING' OR status='SCHEDULED')). 클릭 → 같은 strip 영역을 모드 분기로 재사용(actionMode: 'redispatch' | 'cancel') — “{매장명} 송출을 취소하시겠습니까? [취소 확정] [닫기]“(hq-announcement-cancel-strip, [취소 확정] = danger 톤). strip [취소 확정] 클릭 → useCancelHqDispatch({ id: dispatchId }) mutation(PATCH /api/v1/hq/dispatches/{id}/cancel, body 없음, 204 No Content). 같은 트랜잭션 BE audit 1건(HQ_ANNOUNCEMENT_DISPATCH_CANCELED) 자동 기록 — /admin/audit 에 동시 등장. 성공 → 같은 announcement scope history query invalidate → 해당 row status 가 CANCELED 로 자동 갱신 + 헤더 chip pending -1(distinctStoreCount 무영향). 점장 player 자동 제외: 점장 pending 조회는 WHERE status='PENDING' 만 매치 → CANCELED 자동 제외(별도 cleanup 0, Store Player 참조). 실패 매핑: 404 DISPATCH_NOT_FOUND(이미 PLAYED/CANCELED · 동시 취소 · 미존재 · 타 본사 — BE 가 존재·상태 은닉, 점장 ack 멱등 패턴 미러) → “이미 재생됐거나 취소된 송출입니다”(hq-announcement-cancel-error Banner danger) · 그 외 → “송출 취소에 실패했습니다. 잠시 후 다시 시도해 주세요”. strip [닫기] 또는 다이얼로그 닫힘 시 actionMode·actionSelection·actionError 모두 리셋(useEffect([target]) 단일 진실원). 잔여 후속: 다중 행 일괄 취소(F1 — 체크박스) · 안내방송 단위 일괄 취소(F2 — 모든 fan-out PENDING row).
  • 행 inline [원격 중단] (SPEC #077 확장 — revoke): [취소] 옆 컴팩트 텍스트 버튼 + lucide StopCircle 아이콘(hq-announcement-revoke-row-{dispatchId}). PENDING 행만 노출 — cancel(PENDING+SCHEDULED) 과 달리 SCHEDULED 는 아직 매장에 fan-out 전이라 의미 없고, PLAYED·CANCELED 는 종착. cancel 과 운영 의도 분리: cancel = 재생 전 무효화(예약/대기 취소), revoke = 점장 player 가 그 멘트를 재생 중/직전일 때 즉시 강제 중단(best-effort). 본사는 매장의 “재생 중” 여부를 모르므로 strip·성공 banner 문구로 best-effort 임을 명시한다. 클릭 → 같은 strip 영역을 모드 분기로 재사용(actionMode: 'revoke') — “{매장명} 재생을 원격으로 중단하시겠습니까? 재생 중이면 곧 멈추고, 아직 재생 전이면 취소됩니다. [원격 중단] [닫기]“(hq-announcement-revoke-strip, [원격 중단] = danger 톤). strip [원격 중단] 클릭 → useRevokeHqDispatch({ dispatchId }) mutation(POST /api/v1/hq/dispatches/{dispatchId}/revoke, body 없음, 204 No Content). BE 가 PENDING→CANCELED 단방향 전이 + revoked_at=now set + 같은 트랜잭션 audit 1건(HQ_DISPATCH_REVOKED) 자동 기록(cancel 과 별도 audit 액션 — 운영 의도 구분). 성공 → history query invalidate(해당 row CANCELED 반영) + best-effort 안내 banner info 톤(hq-announcement-revoke-notice) “원격 중단 요청 — 재생 중이면 곧 멈추고, 아직 재생 전이면 취소됩니다.” 점장 전달 경로: 점장 GET /api/v1/store/announcements/pending 응답의 revokedDispatchIds(오늘 KST 윈도우 본인 매장 revoke 목록)에 그 dispatchId 가 노출 → player 가 폴링 갱신 시 소비(SSE 없음, Store Player 참조). 실패 매핑: 404/409(이미 재생/종료 · 동시 revoke · 미존재 · 타 본사 — BE 존재·상태 은닉, cancel 과 동일 멱등) → “이미 재생됐거나 종료된 송출입니다”(hq-announcement-revoke-error Banner danger) · 그 외 → “원격 중단에 실패했습니다. 잠시 후 다시 시도해 주세요”. 연타 방지(mutation isPending 중 confirm disabled + aria-busy + “중단 중…”). strip [닫기] 또는 다이얼로그 닫힘 시 actionMode·actionSelection·actionError·revokeNotice 리셋.

송출 이력 에러 매핑

status · code동작
200성공 — 헤더 집계 + 행 표 + 페이지네이션 렌더
404 TTS_ANNOUNCEMENT_NOT_FOUND”삭제되었거나 존재하지 않는 안내방송입니다.” (배너, 표 숨김)
403 (scope/role)“송출 이력을 볼 권한이 없습니다.” (배너, 표 숨김)
401 (AUTH_UNAUTHENTICATED·NO_SESSION)“다시 로그인해주세요.”
그 외 5xx”서버에 일시적인 문제가 있습니다. 잠시 후 다시 시도해주세요.” (배너, 표 숨김)

시안 출처: workspace parent dir design_handoff_linkmusic/briefs/requests/dispatch-history-dialog.md (handoff 요청서 — 전용 시안 부재, FE 가 공용 Dialog + 본사 목록 테이블 + ListPagination·StatusPill atom 미러로 임시 구현. 시안 받으면 정합 교체).

반복 송출 예약 (/admin/announcements/schedules)

본사가 안내방송을 매일/매주 지정 시각에 반복 송출하는 규칙을 등록·조회·취소한다. BE 백본 (GET/POST /api/v1/hq/dispatch-schedules · PATCH .../{id}/cancel + Spring @Scheduled 전개)은 완비돼 있고 FE 관리 화면만 없던 슬라이스라 FE-only. 전용 시안 부재 — 사용자 승인된 임시 레이아웃으로, 인접 안내방송 목록·송출 다이얼로그 atom·idiom(공용 Dialog/Banner/Input/Button + StatusPill + ListPagination + radiogroup + 매장 체크박스 리스트)을 그대로 미러한다(새 시각 창작 없음).

진입 동선 — 서브 탭

안내방송 영역이 두 화면(콘텐츠 /admin/announcements · 반복 예약 /admin/announcements/schedules)으로 나뉘므로, 두 page 헤더 아래에 공통 서브 탭(AnnouncementTabs안내방송 / 반복 예약)을 둔다. 사이드바 hq-sidebar 는 두 경로 모두 startsWith("/admin/announcements/") 로 “안내방송” 항목을 활성 처리하므로 서브 탭만 추가했다(usePathname 으로 활성 탭 결정).

목록 (HqDispatchScheduleListClient)

apps/space/src/app/admin/announcements/schedules/page.tsx(server 셸) + hq-dispatch-schedule-list-client.tsx (client). 안내방송 목록 셸과 동일 — 서버사이드 페이지네이션 + client-query(useListHqDispatchSchedules)로 status·page 를 client state 로 보유. 정렬은 서버 고정(created_at DESC).

  • 상태 필터: 툴바 radiogroup 활성(ACTIVE) / 취소됨(CANCELED) — ListHqDispatchSchedulesParams.status. 기본 ACTIVE.
  • (DispatchScheduleResponse): 안내방송 제목 · 빈도(매일 / 매주 (월·수·금) / 매시간 (9~21시) · 짝수 시간 (…) · 홀수 시간 (…) (#139, 운영시간 부기)) · 송출 시각(비-시각형은 slotTime HH:MM, 시각형은 매시 :MM — 시(HH) 무시) · 대상(전체 매장 / 지정 N매장 / 지역 (서울) (#144)) · 기간(startsOn ~ endsOn, endsOn 없으면 무기한) · 상태(<StatusPill> — ACTIVE=success “활성” / CANCELED=muted “취소됨”) · 취소 액션.
  • 제목 조인: 규칙 응답에는 안내방송 제목이 없고 announcementId 만 있으므로 안내방송 목록(useListHqTtsAnnouncements, size 100 1회 로드)을 id→title 맵으로 조인해 행에 표시한다. 미조인 행은 안내방송 {id 앞 8자}… 폴백. 100개 초과 본사 조인 누락은 F 후속(서버측 제목 포함 응답).
  • 취소: ACTIVE 행만 [취소] 노출(CANCELED 는 종착 상태). [취소] → 확인 다이얼로그(CancelDispatchScheduleDialog) → useCancelHqDispatchSchedule(PATCH .../{id}/cancel, 204) → 목록 invalidate. 404/이미 취소는 성공으로 흡수 + 목록 정리.
  • 빈 상태: total=0CalendarClock 아이콘 + 상태별 안내(“아직 등록된 반복 예약이 없습니다” / “취소된 반복 예약이 없습니다”).

타임테이블 (DispatchScheduleTimetable, #139)

ACTIVE 필터에서 타임테이블 / 캘린더 두 뷰를 탭(hq-dispatch-schedule-view-tabs)으로 공존시킨다 — 상호보완: 타임테이블은 반복 예약의 추상 주기(시각×방송), 캘린더(아래)는 그 주기가 전개된 구체 발생 이벤트(날짜별). 기본 탭은 타임테이블. 목록 위에 시각×방송 그리드(read-only · 접이 패널)를 둬, 어느 시각(hour)에 무슨 안내방송이 걸렸는지 한눈에 본다. daiso 의 분 그룹 패널을 시각 참고하되 우리 데이터·atom 으로 구성.

  • 데이터 소스: 전체 활성(ACTIVE) 규칙을 타임테이블이 직접 fetch한다(useListHqDispatchSchedules({status: ACTIVE, page: 0, size: 100})) — 목록 표의 페이지네이션·status 필터와 독립. 즉 타임테이블은 목록 현재 페이지가 아니라 항상 전체 활성 규칙을 집계해 보여준다(코드리뷰 F 후속: 규칙이 2페이지 이상이면 현재 페이지만 fetch 하던 부분 집계 문제 해소). 캘린더 endpoint(getHqDispatchCalendar)는 타임테이블 뷰에선 미사용(별도 “캘린더” 탭이 사용): (1) 반복 규칙(frequency·slotTime·byWeekday·startHour/endHour)이 곧 그리드 축이라 규칙 응답이 주간 패턴에 직접적, (2) 캘린더는 매장·날짜별 구체 발생이라 주간 그리드로 접으려면 매장 중복 제거·날짜 폴딩 + 추가 fetch(62일 윈도우)가 필요. 안내방송 제목 조인도 타임테이블이 자체로 받아(규칙과 동일하게 전체 페이지) announcementId→title 맵을 구성한다.
  • 전체 페이지 집계 (>100 cap 제거): BE 가 size 를 1..100 으로 clamp(MAX_PAGE_SIZE=100)하므로 한 페이지로 최대 100건. 활성 total 이 100을 초과하면 첫 페이지 응답의 total 로 필요한 추가 페이지 수를 계산해 page 1..N 을 useQueries(generated getListHqDispatchSchedulesQueryOptions — page 별 queryKey 분리, 충돌 없음)로 함께 받아 전부 concat한다 → 타임테이블이 전체 활성 규칙을 빠짐없이 집계한다(이전의 hq-dispatch-schedule-timetable-cap-note “활성 100건 기준” note 는 제거됨). 100 이하면 추가 호출 없음(현행 유지). 과도 fetch·무한루프 방지를 위해 추가 페이지 수는 MAX_EXTRA_PAGES=49(page0 포함 최대 5000건 — 현실 규칙 수 상회)로 clamp한다. 안내방송 제목 조인도 동일하게 total 기준 전체 페이지를 받아 전 규칙의 announcementId 를 커버한다.
  • 전개 규칙(슬롯 → 시(hour) 그룹): DAILY/WEEKLY 는 slotTime 의 시(HH) 1개(WEEKLY 는 칩에 요일 부기), 시각형(HOURLY/EVEN_HOURS/ODD_HOURS)은 운영시간(startHour~endHour)을 hour 단위 전개(expandOperatingHours — 짝/홀 필터)해 각 hour 에 매시 :MM 칩.
  • 표시: 시(hour) 행 → :MM + 안내방송 제목 + 부가 라벨(요일·빈도) 칩. 토글 헤더에 활성 N건 · 시각 N슬롯 요약 — 여기서 N건 = 전체 활성 total(query.data.total, 현재 페이지 길이 아님).

송출 캘린더 (월 그리드)

타임테이블과 짝을 이루는 “캘린더” 탭(HqDispatchCalendar → 공용 presentational DispatchCalendar). 반복 규칙이 전개된 구체 발생 이벤트(예약·즉시 송출)를 월 그리드(7열×주행, 일요일 시작)로 본다. 전용 시안 부재 — atom-grounded(surface/hairline/StatusPill 토큰 재사용, 새 시각 창작 X).

  • 데이터: useGetHqDispatchCalendar({ from, to })(generated). 보는 달의 그리드 범위(앞뒤 패딩 포함 ≤42칸)만 fetch 하므로 BE 의 62일 cap 안에서 항상 단일 호출이다(@/lib/calendar monthGridRange). 이전/다음 월 네비 시 from/to 가 바뀌어 queryKey 분리 → 자동 refetch. 월/핸들러는 wrapper 가 state 로 보유하고 공용 컴포넌트는 표현만(상태 비보유).
  • KST 변환: 이벤트 scheduledAt(UTC ISO) → @/lib/calendar kstDayOf 로 KST 날짜(YYYY-MM-DD)로 변환해 해당 셀에 배치(StoreScheduledListClient 의 KST offset 기법과 동일 · UTC+9 고정).
  • 셀 표현: 날짜 + 그날 이벤트를 status/kind 색 점 + 제목으로 최대 3건 노출, 초과분은 +N건 요약(셀 높이 폭주 방지). 색은 dispatch-calendar-meta: 긴급(EMERGENCY)=danger, 예약/대기=info, 재생됨=success, 취소=muted(취소선). 본사 캘린더는 매장명(storeName)을 이벤트 title 속성 보조로 노출(showStoreName). 셀 hover/title 로 상세(제목·매장·kind·status). 오늘 날짜는 primary 동그라미 강조. 범례 + 총 N건 칩.
  • 상태: 로딩 중 그리드 흐림(opacity-50), 빈 달은 점 없는 그리드만, 400 DISPATCH_CALENDAR_INVALID_RANGE → “조회 기간이 너무 깁니다…” 에러(그리드 범위가 항상 cap 안이라 실제로는 거의 발생 안 함 — 방어).
  • 재사용: DispatchCalendar·@/lib/calendar·dispatch-calendar-meta점장 캘린더와 공용(점장은 showStoreName=false 만 다름). 점장 캘린더 참조.

툴바 [반복 예약 등록] → self-triggering 다이얼로그 → useCreateHqDispatchSchedule(POST /api/v1/hq/dispatch-schedules).

  • 입력: 안내방송 select(안내방송 목록 1회 로드) · 빈도(DAILY/WEEKLY/HOURLY/EVEN_HOURS/ODD_HOURS radiogroup — generated enum 단일 소스에서 파생, #139):
    • WEEKLY 면 요일 토글 버튼 다중선택 + 송출 시각(시·분 select).
    • 시각형(HOURLY/EVEN_HOURS/ODD_HOURS) 이면 운영시간(시작~종료 hour select, 0-23) + 매시 오프셋(분 select) 만(시(HH) 입력 없음 — “매시 :MM” 안내).
    • 비-시각형 은 송출 시각(시·분 select). 모든 경우 분은 5분 단위 옵션만 노출해 5분 슬롯 경계를 UI 로 강제.
  • 대상(ALL/STORES/REGION radiogroup — STORES 면 검색 + 매장 체크박스 리스트, REGION(#144) 이면 시/도 select(미선택 시 제출 disabled + 송출할 지역을 선택해 주세요.), 송출 다이얼로그 미러) · 시작일(<input type="date" min={오늘}>) · 종료일(선택, 비우면 무한). 페이로드: REGION 이면 region 만 포함(storeIds 미전송).
  • 페이로드 정규화: byWeekday 는 선택 요일을 월~일 순 CSV(MON,WED)로 정규화(toByWeekdayCsv), WEEKLY 외엔 생략. 시각형이면 startHour/endHour(운영시간) 동봉 + slotTime00:MM(시 무시, 매시 :MM 오프셋), 비-시각형이면 HH:MM · DAILY/WEEKLY 는 startHour/endHour 생략. storeIds 는 STORES 면 선택 id 배열, ALL 이면 생략. endsOn 빈 값 = 무한 → null 명시.
  • 결과: 성공 시 부모(onCreated)로 올려 목록 상단 success 배너(반복 예약을 등록했습니다…) + ACTIVE 필터로 리셋 + 목록 invalidate.

클라이언트 검증 (BE 제약 일치)

dispatch-schedule-meta.ts 의 helper 로 BE OpenAPI 제약과 일치시킨다(frontend.md §8). 위반 시 등록 버튼 disabled + 보조 안내.

규칙검증
announcementId필수(미선택 시 disabled)
slotTime 5분 슬롯isValidSlotTimeHH:MM 24h + 분이 5의 배수(분 select 가 5분 옵션만 노출해 UI 단계에서 보장)
byWeekday(WEEKLY)요일 ≥1(MON~SUN 토큰) — 미선택 시 disabled + “요일을 1개 이상 선택해 주세요”
운영시간(시각형, #139)isOperatingHoursValid — startHour·endHour 0-23 + startHour ≤ endHour — 위반 시 disabled + “종료 시각은 시작 시각 이후(또는 같음)여야 합니다”
storeIds(STORES)매장 ≥1 — 빈 선택 시 disabled + “매장 1개 이상을 선택해 주세요”
startsOn필수
endsOn ≥ startsOnisEndsOnValid — 지정 시 시작일 이상(date 문자열 사전식 비교). 위반 시 disabled + “종료일은 시작일 이후여야 합니다”

반복 예약 에러 매핑

status · code동작
201 (등록) / 204 (취소)성공 — 목록 invalidate + 배너
400 DISPATCH_SCHEDULE_INVALID”입력값이 올바르지 않습니다. 빈도·요일·시각을 확인해주세요.” (클라 가드를 뚫고 들어온 케이스 방어)
404 TTS_ANNOUNCEMENT_NOT_FOUND”삭제되었거나 존재하지 않는 안내방송입니다.”
400 DISPATCH_STORE_NOT_FOUND”선택한 매장 중 본사 산하가 아닌 매장이 있습니다.”
404 DISPATCH_SCHEDULE_NOT_FOUND·DISPATCH_SCHEDULE_ALREADY_CANCELED (취소)“이미 취소되었거나 존재하지 않는 예약입니다.” → 성공 흡수 + 목록 정리
403 (scope/role)“예약을 등록할/취소할 권한이 없습니다.”
401 (AUTH_UNAUTHENTICATED·NO_SESSION)“다시 로그인해주세요.”
그 외 5xx”서버에 일시적인 문제가 있습니다. 잠시 후 다시 시도해주세요.”

전용 시안 부재 — 사용자 승인된 임시 레이아웃(atom-grounded). 정식 시안 도착 시 정합 교체. 잔여 후속: 안내방송 제목 서버측 포함(조인 제거) · 규칙 수정(현재는 취소 후 재등록).

수정 다이얼로그 (#062, 조건부 재합성)

create-announcement-dialog.tsxcreate/edit 겸용으로 확장했다. 생성 진입점 (CreateAnnouncementDialog)은 self-trigger 버튼 + create 폼, 수정은 행이 controlled 로 여는 EditAnnouncementDialog(삭제 다이얼로그와 동일 idiom — 단일 다이얼로그로 “어느 행을 수정 중인지” 처리). 두 모드 모두 공용 본문 AnnouncementFormBody(제목·텍스트·voice·톤 프리셋 폼)를 쓴다. 제목은 “안내방송 수정”, 제출 버튼은 “저장”.

  • 초기값 채움: list item DTO 에는 text 가 없으므로(상세에만 존재), edit 다이얼로그가 열릴 때 useGetHqTtsAnnouncement(id) 로 상세를 fetch 해 제목·텍스트·voice·톤 프리셋(tonePreset)·발화 속도(tempo, #139) 초기값을 채운다. 상세 로딩 중엔 폼 대신 “불러오는 중…” 표시(hq-announcement-edit-loading), 상세 로드 실패는 폼 대신 인라인 에러(hq-announcement-edit-load-error). seed 한 비-기본 톤은 voices 응답이 settle 되기 전엔 섣불리 리셋하지 않는다(폴백 NORMAL 집합으로 덮어쓰기 방지).
  • 저장: useUpdateHqTtsAnnouncement(PUT, UpdateTtsAnnouncementRequest = title·text·voice·tonePreset·tempo(#139) 전체 교체). 성공 시 목록 invalidate + 닫기.
  • 조건부 재합성(§5-1): text·voice·톤 프리셋·발화 속도(tempo) 중 하나라도 기존과 다르면 BE 가 Typecast 로 재합성해 같은 오디오 url 에 덮어쓰고 길이를 갱신한다(수 초 소요). title 만 바뀌면 재합성하지 않아 빠르다. 판정은 BE 가 하며, FE 는 두 경우 모두 동일한 “합성 중…” pending UI(스피너 + 입력·액션 disable + ESC/overlay 닫기 차단)로 처리한다.
  • 검증: 생성과 동일 — 빈 제목/텍스트 submit disable, OpenAPI 제약 일치. 상세가 채워지기 전(seeding 미완)·로딩/에러 상태에서는 submit disable.

에러 매핑 (수정)

status · code동작
200성공 — onSaved(목록 invalidate) + 닫기
502 TTS_SYNTHESIS_FAILED”합성에 실패했습니다. 잠시 후 다시 시도해주세요.” (닫지 않음)
503 TTS_TOKEN_NOT_CONFIGURED”TTS 가 설정되지 않았습니다. 관리자에게 문의해주세요.” (닫지 않음)
400 TTS_INVALID_TONE_PRESET”선택한 voice 에서 지원하지 않는 톤입니다. 다른 톤을 선택해주세요.” (UI 가 애초에 막지만 방어, 닫지 않음)
404 TTS_ANNOUNCEMENT_NOT_FOUND이미 삭제됨 — 성공으로 흡수, 목록 invalidate + 닫기
403 (scope/role)“안내방송을 수정 권한이 없습니다.” (닫지 않음)
그 외 5xx”서버에 일시적인 문제가 있습니다.” (닫지 않음)

재합성 실패(502/503)는 BE 가 row 미변경(원자성) 으로 격리한다. 상세 fetch 자체가 실패하면 폼을 띄우지 않고 load-error 배너만 노출(저장 불가).

에러 매핑 (생성)

status · code메시지
502 TTS_SYNTHESIS_FAILED합성에 실패했습니다. 잠시 후 다시 시도해주세요.
503 TTS_TOKEN_NOT_CONFIGUREDTTS 가 설정되지 않았습니다. 관리자에게 문의해주세요.
400 TTS_INVALID_TONE_PRESET선택한 voice 에서 지원하지 않는 톤입니다. 다른 톤을 선택해주세요.
403 (scope/role)안내방송을 만들 권한이 없습니다.
401 (AUTH_UNAUTHENTICATED·NO_SESSION)다시 로그인해주세요.
그 외 5xx서버에 일시적인 문제가 있습니다. 잠시 후 다시 시도해주세요.
그 외 4xx안내방송을 만들 수 없습니다. 입력을 확인해주세요.

code 가 없어도 status(502/503/403/5xx)로 흡수(BFF 가 code 를 평탄화하지 못한 경우). react-query TError 는 generated ErrorResponse 로 타이핑되나 런타임 에러는 mutator ApiError(status·body) 이므로 error.body 에서 extractCode 로 code 를 꺼낸다.

삭제 에러 매핑

status · code동작
204성공 — onDeleted(목록 invalidate) + 닫기
404 TTS_ANNOUNCEMENT_NOT_FOUND이미 삭제됨 — 성공으로 흡수, 목록 invalidate + 닫기
403”삭제할 권한이 없습니다.” (닫지 않음)
그 외 5xx”서버에 일시적인 문제가 있습니다.” (닫지 않음)

라이프사이클 audit (#067 송출 + #068 생성/수정/삭제)

본사 안내방송 라이프사이클 4종(생성·수정·삭제·송출) 액션을 hq_audit_log 에 actor·시각·대상 스냅샷으로 append-only 기록한다(SPEC #067 송출 백본 + SPEC #068 생성/수정/삭제 확장). UI 가시화는 별도 페이지 /admin/audit 에서 — 본 페이지에는 audit 진입점 표시 없음(F1 후속, 분리된 진입점).

  • HQ_ANNOUNCEMENT_DISPATCHED (#067): HqAnnouncementDispatchService.dispatch 끝(0건/N건 모두) — detail target={ALL|STORES}·count=N·storeIds=[...앞 10개...] (1024자 cap).
  • HQ_ANNOUNCEMENT_CREATED (#068): HqTtsAnnouncementService.create 끝 — detail title·voice·tonePreset·durationSeconds(null = “null” 명시).
  • HQ_ANNOUNCEMENT_UPDATED (#068): HqTtsAnnouncementService.update 끝 — detail title·voice·tonePreset·resynthesized={true|false}. 모든 필드 동일 시 early return (audit 노이즈 차단). resynthesized 는 service 가 이미 판정한 값(text/voice/tonePreset 변경 여부) 그대로.
  • HQ_ANNOUNCEMENT_DELETED (#068): HqTtsAnnouncementQueryService.delete — softDelete 전 title 사전 fetch → softDelete → audit (race 시 affected=0 으로 404 수렴, 원자성).
  • actor 해석 (전 액션 공통): principal.accountId = HQ_MANAGER 계정. 운영자 임퍼소네이션 중이면(principal.impersonatedBy != null) actor_role=OPERATOR_IMPERSONATING + impersonated_by_operator_id 동시 기록(원본 운영자·위장 본사 둘 다 추적).
  • 원자성: audit INSERT 실패 → 전체 트랜잭션 롤백 (해당 액션 row 도 함께 롤백). audit 무결성 우선. create 의 blob 은 audit 실패 시 compensateBlob 보상 삭제. update 의 외부 부작용(storagePort.upload) 은 save·audit 이후 마지막에 호출해 audit 실패 시 blob 미덮어쓰기(commit 실패/프로세스 종료 극한 케이스에서는 일시 불일치 가능 — outbox 후속).
  • 조회: 본사 /admin/audit 페이지 — HQ Mode 감사 참조.

자세한 계약·인덱스·필터·UI: HQ Mode 감사.

사이드바

HQSidebar 의 “안내방송” 항목(/admin/announcements)이 enabled — #061 에서 disabled “준비 중” (/admin/broadcast)에서 전환. #067: “감사”(/admin/audit) 항목이 신규 enabled — 본사 송출 audit 조회 화면.

인가

/api/v1/hq/announcements/**/api/v1/hq/tts-voices(#063 voice·톤 매핑 조회) → hasRole("HQ_MANAGER") 1차 경계 + service verifyHqScope claim↔DB 재검증(#049/#057 재사용). 미인증 401 · role/소속 불일치 403 PRINCIPAL_SCOPE_MISMATCH · 타 본사/미존재 404 TTS_ANNOUNCEMENT_NOT_FOUND(존재 은닉). 모든 호출은 generated apiFetch 가 BFF catch-all /api/backend/... 경유(토큰 서버 전용).

합성 흐름 (BE 요약)

BE(SPEC #061 §D1~D9): create = verifyHqScopeTypecastClient.synthesize(text, voice) (공개 API /api/speak → polling → audio bytes, 동기 완료, read ~60s·3회 재시도) → StoragePort 업로드(blob key {prefix}/tts/{id}.mp3, UUID=PK 선생성) → row insert → 실패 시 보상 삭제(orphan 방지). 외부 실패는 502/503 으로 격리(5xx 금지). TYPECAST_API_TOKEN env 미설정이어도 부팅 성공, 합성 호출 시 503. 삭제는 soft-delete(blob 유지, 복구 가능).

수정(#062): update = verifyHqScope(본인 본사 활성 row, 미존재/삭제/타 본사 → 404 존재 은닉) → text·voice·톤 프리셋(#063) 중 하나라도 기존과 다르면 재합성(같은 blob key 덮어쓰기 + durationSeconds 갱신), title 만 바뀌면 재합성 skip. 재합성 실패(502/503)는 row 미변경(원자성). updatedAt 갱신.

톤 프리셋(#063): voices = GET /api/v1/hq/tts-voices 가 voice별 허용 톤 프리셋 매핑 (TtsVoiceListResponse)을 반환한다. 합성 시 tonePreset(@nullable, 모든 voice 가 NORMAL 포함)을 Typecast emotion 파라미터로 전달한다. voice 가 지원하지 않는 톤 → 400 TTS_INVALID_TONE_PRESET.

프로덕션 활성화 (#134)

응답 계약 검증 완료(2026-06-17)TypecastClient 의 응답 파싱 가정을 실 토큰 round-trip 으로 검증했고 100% 일치함을 확인했다(파싱 코드 변경 0):

단계실 응답 키코드 추출(1순위)
POST /api/speakresult.speak_v2_urlextractPollUrl
poll (v2 URL) GETresult.status: "done"readStatus
pollresult.audio_download_urlextractAudioUrl
pollresult.duration (초)extractDuration
downloadContent-Type: audio/wav · RIFF/WAVE PCMdownloadAudio

타이밍도 speak ~0.14s + 첫 poll 즉시 done 으로 현 timeout 값(connect 5s·read 30s·pollMaxTotal 60s· interval 700ms·retries 3) 적정.

연동 진단(smoke-test) — 운영자 설정 /settings/notifications 의 [TTS 연결 확인] 버튼이 POST /api/v1/admin/tts/smoke-test(OPERATOR, runTtsSmokeTest)를 호출한다. 고정 짧은 텍스트(voice SHEAN·NORMAL)로 부작용 없는 진단 합성 1회(blob·DB 미저장) → 성공 200 TtsSmokeTestResponse (ok·voice·durationSeconds?·audioByteSize·elapsedMs). 실패는 503 TTS_TOKEN_NOT_CONFIGURED(env 미설정 → 환경변수 안내)·502 TTS_SYNTHESIS_FAILED(외부 실패 → 재시도 안내)로 구분. 운영자 수동 전용(quota 보호 — 자동 폴링 없음).

⚠️ env 의무: TYPECAST_API_TOKEN(relaxed binding → typecast.api-token)을 Render production env 에 주입해야 실제 합성이 동작한다. 미설정이어도 부팅은 성공하고, 합성/smoke-test 시점에만 503. 토큰 주입 후 [TTS 연결 확인]으로 최종 검증한다.

Followups

  • 송출 (첫 슬라이스): [송출](전체 매장 ALL) → 점장 player 삽입 재생 — 도착(본사→매장 재생 고리 폐쇄).
  • 송출 이력 조회 (#065): 행 [이력] / 결과 배너 [이력 보기] → DispatchHistoryDialog(집계 + 매장 행 + 페이지네이션, read-only) — 도착(본사→매장 피드백 루프 폐쇄).
  • 송출 이력 헤더 고유 매장 수 (#075, #065 D3 후속 마감): DispatchHistoryAggregate.distinctStoreCount 추가(COUNT(DISTINCT storeId)). DispatchHistoryDialog 헤더 chip 4번째 매장 N곳 으로 매장 단위 도달 범위 한 줄 노출(매장 0건도 0곳). BE+FE 비파괴 작은 슬라이스. 매장별 그룹핑 토글 view·매장당 요약(최근 송출 시각·총 횟수)은 F.
  • 매장 그룹핑 토글 view (#089·#098 마감): 같은 매장 다수 송출을 매장 단위로 collapse 한 요약(최근 송출 시각·총 횟수·상태) + 펼치기 sub-row + 매장명 검색/highlight — 도착(헤더 radiogroup 행 단위/매장 단위, 클라 측 현재 페이지 grouping). 잔여: 매장 단위 BE 페이지네이션(#089 F3) · 정식 시안(#089 F4).
  • list 요약 (#066·#097): listHqTtsAnnouncements 목록 행에 dispatchCount·lastDispatchedAt 추가(LEFT JOIN + GROUP BY 한 쿼리 평탄 projection, V26 인덱스 재활용). 도착. 본사가 행에서 송출 시그널을 바로 확인하고 이력 다이얼로그 진입 비용을 줄인다. #097 마감: lastDispatchStoreCount 추가 — 마지막 송출 호출의 fan-out distinct 매장 수(누적 아닌 마지막 1회만). LEFT JOIN LATERAL last_call (LIMIT 1) 로 announcement 당 1회 산출, scalar subquery 가 audit_id(V28+) 또는 created_at(legacy) 그룹으로 distinct store_id 카운트(CAST AS int). FE 는 마지막 송출 셀에 (N매장) 보조 줄. 고유 매장 수는 SPEC #075 에서 이력 다이얼로그 헤더 chip 으로 추가(distinctStoreCount) — 행 단위 요약(매장당 collapse view)은 매장 그룹핑 토글 view F 후속.
  • 재송출 (#076, #065 F 후속 일부 마감): 송출 이력 다이얼로그 행 inline [재송출] 버튼 + 확인 strip + useDispatchHqTtsAnnouncement STORES 모드 1곳 호출 + history invalidate + 자동 audit. FE-only · BE 변경 0 · 새 endpoint 0.
  • 송출 취소 (#077, #065 F · #076 F1 후속 마감): 송출 이력 다이얼로그 행 inline [취소] 버튼(PENDING 만) + 동일 strip 영역 mode 분기(redispatch/cancel) + useCancelHqDispatch(PATCH /api/v1/hq/dispatches/{id}/cancel, 204) + history invalidate. BE: DispatchStatus CANCELED 추가 · 원자적 조건부 UPDATE(WHERE id AND hq_id AND status=PENDING, 0행=404 은닉, 동시 취소·중복 가드) · 같은 트랜잭션 audit HQ_ANNOUNCEMENT_DISPATCH_CANCELED(/admin/audit 5종으로 확장). 점장 player 자동 제외(이미 WHERE status='PENDING' 만 매치 — 코드 변경 0). 잔여 후속(다중 행·안내방송 단위 일괄)은 F1/F2 로 분리.
  • 다중 행 일괄 액션 (#096 마감): 행 단위 모드 체크박스 + [선택 재송출](unique storeIds dedupe → dispatch 1회) + [선택 취소](cancellable PENDING/SCHEDULED Promise.allSettled → 부분 실패 banner) — 도착. FE-only · 뮤테이션 재사용 · BE 변경 0.
  • 매장 그룹 모드 일괄 액션 (#125, #089 F1 마감): 그룹(매장) 모드에 매장 단위 체크박스(헤더=전체 선택/해제·indeterminate) + 일괄 재송출/취소 — 도착(#096 D9 “그룹 모드 숨김” 해제). 행 단위 #096 로직을 매장 단위로 미러(매장 체크박스 = 그 매장 dispatchId 전부 토글 → dedupe·“매장 내 cancellable 전부” 자동 정합, 선택 단일 진실원·뮤테이션 공유). 모드 전환·검색·페이지·닫힘 시 선택 리셋. FE-only · BE 변경 0.
  • F2 안내방송 단위 일괄 취소: 안내방송 1건의 모든 fan-out PENDING row 일괄 CANCELED.
  • F BE bulk endpoint (#089 F2): 대규모 본사 일괄 재송출/취소를 클라 다중 호출 대신 한 트랜잭션으로.
  • F 점장 ack 알림: PLAYED 전이 시 본사 화면 실시간 알림(웹소켓·SSE).
  • 송출 매장 개별선택(SPEC #072, STORES 모드): 송출 다이얼로그 모드 토글 + 매장 다중선택 체크리스트 + 검색 + 결과 배너 모드 줄. BE 백본(#061) 재사용·FE-only.
  • 즉시/예약 모드 (SPEC #078, 20-policy §3·4 마감 일부): 송출 다이얼로그 즉시(IMMEDIATE)/예약(SCHEDULED) 토글 + date/time 입력 + KST 경계 정규화(scheduledAt ISO) + 새 상태 SCHEDULED + Spring @Scheduled(cron='0 * * * * *') 1분 디스패처(SCHEDULED→PENDING 원자 전이). 이력 다이얼로그 StatusPill 4종/헤더 chip 5종/scheduledAt 셀/[취소] SCHEDULED 활성. 잔여 송출 고도화: 분 슬롯·REGION 모드·더킹·우선순위·반복 예약(F) · 본사 일정 캘린더 view(F).
  • F 미리듣기 게이트: 송출 전 1회 재생 강제(시안 Step2).
  • voice tone preset (#063): voice별 톤 프리셋 선택(voice 연동) — 도착. ✅ audit (#067): HQ_MANAGER 송출 actor 추적 — 도착(hq_audit_log 백본 + /admin/audit 페이지). ✅ F1 이력 다이얼로그 actor 컬럼 (#071): announcement_dispatch.audit_id V28 FK + DispatchHistoryItem.{actorEmail,actorRole,impersonatedByEmail} + 5번째 “행위자” 컬럼(impersonate 보조 줄 + 배지) — 도착(#065 F1 마감, /admin/audit 와 동일 audit row 가 다이얼로그에서도 보임).
  • F 이력 CSV export.
  • F LLM 자동멘트·CM송·미리듣기 캐시·다국어·녹음 업로드.