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)·길이(durationSeconds→m:ss, null 이면—)·생성일(createdAt, KST)·재생·송출 횟수(#066)·마지막 송출(#066·#097)·송출·이력·수정·삭제. 톤 배지(#063): 비-기본 톤(tonePreset !== "NORMAL")만 목소리 옆 배지로 강조하고, 기본(NORMAL)은 노이즈라 생략한다(hq-announcement-tone-{id}). 송출 요약(#066): 재생 옆 두 컬럼 — 송출 횟수(dispatchCount, 우측정렬tabular-nums,0이면—)·마지막 송출(lastDispatchedAt의formatKstDateTime,null이면—). 카운트는 dispatch row 수=중복 송출 포함이며 고유 매장 수가 아니다. 마지막 송출 fan-out 사이즈(#097): 마지막 송출 셀 같은 자리에 한 단계 작은 보조 줄로(N매장)표기(lastDispatchStoreCount). 누적 아닌 마지막 1회 호출의 distinct 매장 수 —lastDispatchedAt가 가리키는 그 호출의 fan-out. 미송출이거나 legacy null 이면 표시 없음(noise 차단). 행 [이력] 진입을 결정할 때 가벼운 시그널이며 행 자체의 강조 변형은 디자인 시안 공백이라 추가하지 않는다(testidhq-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 fetch 해audioUrl을 얻는다(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: [...] }) → 201DispatchResponse{ dispatchedCount, dispatchIds }. 성공 시 목록 상단 배너로 “대상: 전체 매장” 또는 “대상: 선택 매장 N개” 보조 줄 + “N개 매장에 송출했습니다”(0이면 “송출할 매장이 없습니다” 안내) + 결과 배너 우측 [이력 보기] 액션이 같은 안내방송 송출 이력 다이얼로그로 점프(#065 —dispatchedCount=0이면 표시 X). 아래 송출 다이얼로그 참조. - 송출 이력 조회 (#065): 행 [이력] →
DispatchHistoryDialog→useListHqTtsAnnouncementDispatches(GET /api/v1/hq/announcements/{id}/dispatches) → 200DispatchHistoryResponse{items,page,size,total,aggregate}. 매장 단위 송출 row + 페이지 무관 집계(total/played/pending). 아래 송출 이력 조회 참조. - 수정: 행 [수정] → 생성 다이얼로그를 create/edit 겸용으로 재사용(
EditAnnouncementDialog). 아래 수정 다이얼로그 참조. - 삭제: 행 [삭제] → 확인 다이얼로그 →
useDeleteHqTtsAnnouncement(204 soft-delete) → 목록 invalidate. 404(이미 삭제됨)는 성공으로 흡수 + 목록 갱신. - 빈 상태: 검색 0건(
ListNoResultsCTA) vs 진짜 빈 목록 구분. - 페이지네이션: 공용
ListPagination— 총 N개 + 이전/다음(경계 disabled).
생성 다이얼로그 (합성)
create-announcement-dialog.tsx — 공용 Dialog + 단일 폼.
- 입력: 제목(
@maxLength 255)·텍스트(여러 줄 textarea,@NotBlank @Size(max 1000), 실시간 글자수 표시)·voice(TtsVoice5종 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.tsTTS_TEMPO_OPTIONS/formatTempoLabel,hq-announcement-tempo-select). - voice 라벨: 생성 폼은 합성 전이라 BE
voiceDisplayName을 받을 수 없으므로 프론트가 라벨을 보유(tts-voice-meta.ts). enum value 는 generatedCreateTtsAnnouncementRequestVoice(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.tsREGION_OPTIONS17종 + 맨 위 “지역 선택” placeholder) 노출. target=REGION 인데 미선택이면 송출 disabled + 보조 텍스트송출할 지역을 선택해 주세요.(BE 400DISPATCH_REGION_REQUIRED선검증). 결과 콜백(onDispatched) 페이로드에region(시/도) 추가 → 배너 첫 줄대상: 지역 (서울). testidhq-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-newStep 2 정렬. 새 컴포넌트 추출 없음(시안 정합 + 두 곳 한정). - 모드 토글 (SPEC #072 + #144): 다이얼로그 상단
fieldset+ 라디오 3종(role="radiogroup"— 전체 매장 / 선택 매장 / 지역). 기본값 = ALL(기존 동작 보존, 가벼움). 모드를 바꾸면 인라인 에러 배너는 함께 리셋. 다이얼로그를 닫으면 모든 로컬 state(모드·검색어·선택 set·선택 지역·미리듣기 게이트·스케줄 모드)가 리셋돼 다음에 다시 열 때 ALL/빈 선택/미선택 지역/미리듣기 미통과/IMMEDIATE 로 시작한다. - 송출 시점 토글 (SPEC #078 — 즉시/예약): STORES 모드 토글과 별개의 두 번째
fieldset+ 라디오 2종(role="radiogroup", 시안hq-broadcast-newStep 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모두 리셋(resetLocal—useEffect([open])와 짝). BE 에러 매핑: 400DISPATCH_SCHEDULED_AT_PAST→ Banner danger “과거 시각으로 예약할 수 없습니다.” · 400DISPATCH_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_dispatchrow 를 만든다. 각 매장 점장 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/auditHqAuditRow패턴 미러).actorRole === "OPERATOR_IMPERSONATING"이면 아래 보조 줄에↩ {impersonatedByEmail}+ “운영자 위장” pill 배지(원본 운영자·위장 본사 매니저 둘 다 추적). 직접 송출(HQ_MANAGER)은 보조 줄 없음. V28 이전 row(announcement_dispatch.audit_id IS NULL→actorEmail === null)는 메인 셀에—로 폴백·보조 줄 없음(백필 안 함). 다이얼로그 actor 정보는/admin/audit의 동일 audit row 와 항상 같은 값(공통hq_audit_log스냅샷,announcement_dispatch.audit_idFK 매핑). - 중복 송출 표시: 같은 매장에 여러 번 송출된 경우 별개 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 [재송출] 클릭 →useDispatchHqTtsAnnouncementmutation 호출({ 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, 기존DispatchHistoryRow의nestedprop 분기 — 컴포넌트 추출 없이 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-errorBanner 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 로 자동 갱신 + 헤더 chippending -1(distinctStoreCount 무영향). 점장 player 자동 제외: 점장 pending 조회는WHERE status='PENDING'만 매치 → CANCELED 자동 제외(별도 cleanup 0, Store Player 참조). 실패 매핑: 404DISPATCH_NOT_FOUND(이미 PLAYED/CANCELED · 동시 취소 · 미존재 · 타 본사 — BE 가 존재·상태 은닉, 점장 ack 멱등 패턴 미러) → “이미 재생됐거나 취소된 송출입니다”(hq-announcement-cancel-errorBanner 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=nowset + 같은 트랜잭션 audit 1건(HQ_DISPATCH_REVOKED) 자동 기록(cancel 과 별도 audit 액션 — 운영 의도 구분). 성공 → history query invalidate(해당 row CANCELED 반영) + best-effort 안내 bannerinfo톤(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-errorBanner danger) · 그 외 → “원격 중단에 실패했습니다. 잠시 후 다시 시도해 주세요”. 연타 방지(mutationisPending중 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·StatusPillatom 미러로 임시 구현. 시안 받으면 정합 교체).
반복 송출 예약 (/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, 운영시간 부기)) · 송출 시각(비-시각형은slotTimeHH: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=0—CalendarClock아이콘 + 상태별 안내(“아직 등록된 반복 예약이 없습니다” / “취소된 반복 예약이 없습니다”).
타임테이블 (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(generatedgetListHqDispatchSchedulesQueryOptions— 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/calendarmonthGridRange). 이전/다음 월 네비 시from/to가 바뀌어 queryKey 분리 → 자동 refetch. 월/핸들러는 wrapper 가 state 로 보유하고 공용 컴포넌트는 표현만(상태 비보유). - KST 변환: 이벤트
scheduledAt(UTC ISO) →@/lib/calendarkstDayOf로 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), 빈 달은 점 없는 그리드만, 400DISPATCH_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(운영시간) 동봉 +slotTime은00: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분 슬롯 | isValidSlotTime — HH: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 ≥ startsOn | isEndsOnValid — 지정 시 시작일 이상(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.tsx 를 create/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_CONFIGURED | TTS 가 설정되지 않았습니다. 관리자에게 문의해주세요. |
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건 모두) — detailtarget={ALL|STORES}·count=N·storeIds=[...앞 10개...](1024자 cap). - HQ_ANNOUNCEMENT_CREATED (#068):
HqTtsAnnouncementService.create끝 — detailtitle·voice·tonePreset·durationSeconds(null = “null” 명시). - HQ_ANNOUNCEMENT_UPDATED (#068):
HqTtsAnnouncementService.update끝 — detailtitle·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 = verifyHqScope → TypecastClient.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/speak | result.speak_v2_url | extractPollUrl |
| poll (v2 URL) GET | result.status: "done" | readStatus |
| poll | result.audio_download_url | extractAudioUrl |
| poll | result.duration (초) | extractDuration |
| download | Content-Type: audio/wav · RIFF/WAVE PCM | downloadAudio |
타이밍도 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 +
useDispatchHqTtsAnnouncementSTORES 모드 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:DispatchStatusCANCELED추가 · 원자적 조건부 UPDATE(WHERE id AND hq_id AND status=PENDING, 0행=404 은닉, 동시 취소·중복 가드) · 같은 트랜잭션 auditHQ_ANNOUNCEMENT_DISPATCH_CANCELED(/admin/audit5종으로 확장). 점장 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 경계 정규화(scheduledAtISO) + 새 상태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_idV28 FK +DispatchHistoryItem.{actorEmail,actorRole,impersonatedByEmail}+ 5번째 “행위자” 컬럼(impersonate 보조 줄 + 배지) — 도착(#065 F1 마감,/admin/audit와 동일 audit row 가 다이얼로그에서도 보임). - F 이력 CSV export.
- F LLM 자동멘트·CM송·미리듣기 캐시·다국어·녹음 업로드.