CS Tickets — /tickets (CS 티켓 v1)
SPEC #027 정합. 운영사 백오피스 CS 티켓 관리. 시각: claude design Phase 3 핸드오프 ops-tickets 시안.
Overview
고객(본사/매장)의 문의·요청을 티켓으로 추적·처리하는 백오피스 화면. 운영자(OPERATOR)가
티켓을 생성하고, 상태(OPEN → IN_PROGRESS → RESOLVED → CLOSED)를 전이시키며, 채팅형 코멘트
스레드(고객 응답 REPLY / 내부 메모 INTERNAL)로 처리 이력을 남긴다. 담당자 배정/해제와
우선순위 색점(4단계)으로 분류한다. 목록 정렬은 updatedAt desc(최근 활동 우선).
시안 출처: claude design Phase 3 핸드오프
design/screens/ops-tickets.jsx(목록·상세·생성 다이얼로그) +design/ops-phase3-shared.jsx(PriorityDotatom) 정합(workspace parent dirdesign_handoff_linkmusic/). 우선순위는 4단계 색점(PriorityDot— URGENT=danger·HIGH=warn· NORMAL=info·LOW=muted, URGENT 만 링 강조, 항상 라벨 병기)으로 표시하고, 채팅 스레드는 REPLY(고객 응대, 좌측 중립 톤) ↔ INTERNAL(내부 메모, 우측 앰버 톤 + 잠금)으로 좌우·톤을 구분한다.
채널 (운영자 백오피스 ↔ 본사·매장 self-service)
ticket 백본(테이블·TicketStatus·전이표·코멘트)은 운영자 백오피스(/tickets, 이 페이지)가
정본이며, 고객 self-service 채널은 격리 service + view DTO 로 분리된다:
- 본사 채널(
apps/space/admin/support, SPEC #086/#108) — HQ_MANAGER 가hq_id스코프로 본인 본사 티켓을 작성·조회·댓글 + 제한적 상태/우선순위 변경. HQ Customer Support. - 매장 채널(
apps/space/store/support, SPEC #112) — STORE_MANAGER 가store_id스코프로 본인 매장 티켓을 작성·조회·댓글(상태/우선순위 변경 없음 — 운영자 전담). 작성 시 category 필수. 점장 티켓은hq_id=null로 본사 화면에 안 섞인다(과노출 차단). Store Customer Support.
두 채널 모두 운영자 view 의
assignee·INTERNAL 메모 등은 view DTO 에서 제외하고(과노출 회피), 댓글은kind=REPLY만 노출한다. category 컬럼은 V35 에서 공용 ticket 테이블에 nullable 로 추가됐다. 운영자 백오피스는 SPEC #113 에서 store ticket 을 매장명(storeName)·분류(category)로 식별·트리아지 한다 — 목록·상세에 출처(매장명/본사명)·category 배지를 노출하고, 목록 필터에categoryselect(보조로storeIdURL 진입)를 추가한다(read-sideLEFT JOIN store+ DTO 필드, 마이그레이션 0). 이로써 점장 CS end-to-end 가 닫힌다(점장 작성 → 운영자가 어느 매장·무슨 분류인지 식별·응대). 본사 백오피스의 category 표면화는 여전히 후속이다.
상태머신 (TicketStatus)
| 전이 | 규칙 |
|---|---|
| 착수 | OPEN → IN_PROGRESS |
| 해결 | IN_PROGRESS → RESOLVED |
| 종결 | RESOLVED → CLOSED |
| 재오픈 | RESOLVED → IN_PROGRESS · CLOSED → IN_PROGRESS |
- UI 는 현재 status 의 유효 전이만 컨트롤로 노출한다(
ticket-meta.tsvalidTransitions— 클라 가드). backend 도 동일 가드를 가져 이중 방어 — 잘못된 전이는 409TICKET_INVALID_STATUS_TRANSITION으로 거부되고 FE 는 inline 에러 + 토스트로 처리하고 목록 새로고침을 유도한다. - 상태전이·담당자 배정/해제·우선순위 변경은 성공 시
OperatorAuditLog에 기록된다 (TICKET_STATUS_CHANGED·TICKET_ASSIGNED·TICKET_PRIORITY_CHANGED, targetTypeTICKET, detail이전→이후/ 담당자 분기 — #047)./audit/actions에서 추적한다. 티켓 타임라인의 시스템 코멘트(SYSTEM) 연동은 여전히 후속(아래 Roadmap).
우선순위 (TicketPriority)
URGENT(긴급) · HIGH(높음) · NORMAL(보통) · LOW(낮음). 목록·상세·생성 다이얼로그에서
4단계 색점(PriorityDot — 시안 ops-phase3-shared)으로 표시한다: URGENT=danger(둘레 옅은 후광
링) · HIGH=warn · NORMAL=info · LOW=muted. 색만으로 구분하지 않도록 항상 라벨을 병기한다(접근성).
목록 필터 바에는 색점 범례(legend), 생성 다이얼로그·상세 액션 사이드에는 색점 세그먼트 버튼 그룹으로
선택한다. 상세에서는 즉시 변경 가능(SPEC #046) — 현재 값과 다른 색점 클릭 시 즉시
PATCH /api/v1/admin/tickets/{id}/priority(changeTicketPriority)로 변경하고 router.refresh()
한다. 상태머신이 없어 모든 값으로 자유 전이(전이 제약·409 없음). 변경은 OperatorAuditLog 에
기록된다(TICKET_PRIORITY_CHANGED, detail 이전→이후, 상태/담당자 변경과 동일 관례 — #047).
confirm 다이얼로그 없이 즉시 실행한다(optimistic 아님). 미존재 티켓은 404 TICKET_NOT_FOUND.
코멘트 스레드 (CommentKind)
시안 ChatBubble — 좌우 정렬·톤·비대칭 모서리로 종류를 구분한다.
REPLY(고객 응답) — 좌측 정렬, 중립 surface 톤(고객 응대).INTERNAL(내부 메모) — 우측 정렬, 옅은 앰버 배경 + 잠금 아이콘 + 앰버 태그(내부 통제용).
작성 폼은 kind 토글(고객 응답 / 내부 메모) + body. 빈/공백-only body 는 [코멘트 추가] 비활성.
UI
목록 (/tickets)
<PageHeader>제목 “CS 티켓” + 부제 “총 N건” + [티켓 생성] 버튼.- 필터 바 (server-driven, URL searchParams 단일 소스) — 상태 select · 우선순위 select ·
분류(category) select(전체 + 5종 — PLAYBACK·BROADCAST·BILLING·ACCOUNT·OTHER) · 담당자 ID 입력
- [적용]/[초기화]. 적용 시
router.push로 URL 갱신 → server component 재fetch (backend 가 필터·정렬·페이지네이션 책임). 1페이지로 리셋. (hqId·storeId는 URL 진입값을 보존 — UI 입력은 v1 생략.storeId는 점장 store ticket 트리아지용 보조 필터.)
- [적용]/[초기화]. 적용 시
- 테이블 — 우선순위 색점(
PriorityDot) · 제목 · 상태 pill · 분류 배지(category, 미설정 시 ”—”) · 출처(storeName/hqName중 채워진 쪽 — store ticket 이면 매장명, 둘 다 없으면 ”—”) · 담당자(없으면 “미배정”) · 코멘트 수 · 수정 시각(KST). category 는 점장 support 와 동일 라벨 매핑(ticket-meta.tsCATEGORY_META— 재생·음악/방송·안내/결제·정산/계정·로그인/기타)이며 색만으로 구분하지 않도록 라벨을 병기한다. 행은 native<tr>(암시적role=row유지) +tabIndex+ Enter/Space 키 조작 +focus-visiblering →/tickets/{id}상세. (role="button"을 주면 table semantics 가 깨지므로 부여하지 않는다 — audit 테이블 관례 동일.) - 빈 상태 —
total === 0진짜 0건(“등록된 CS 티켓이 없습니다”) vstotal > 0 && items 0out-of-range page(“이 페이지에 표시할 티켓이 없습니다” + [첫 페이지로]) 별도. page clamp. - 생성 다이얼로그 — title·body·priority(필수, 색점 세그먼트 버튼 그룹) + hqId·storeId(선택).
필수 필드 사전 검증(zod trim·min) — 미통과 시 [생성] 비활성. 성공 시
router.refresh()+ 토스트.
상세 (/tickets/{id})
- 헤더 — title · 우선순위 색점(
PriorityDot) · 상태 pill · 분류 배지(category, null 이면 생략) · 출처(store ticket 이면매장: {storeName}, 아니면본사: {hqName}) · 담당자 · 생성/수정 시각(KST)- [목록] 링크.
- 본문 + 채팅형 코멘트 스레드 — REPLY/INTERNAL 시각 구분. 코멘트 0건이면 “아직 코멘트가 없습니다”.
- 코멘트 작성 폼 — kind 토글 + body + [코멘트 추가].
- 액션 사이드 — 상태 변경(유효 전이만 버튼 노출) · 우선순위 변경(4단계 색점 세그먼트 — 다른 값 클릭 시 즉시 변경, 자유 전이) · 담당자 배정/해제.
- detail 이 null 이면(5xx/네트워크) 에러 배너. 404
TICKET_NOT_FOUND는 page 에서notFound().
시각 표시 (KST)
createdAt/updatedAt/코멘트 createdAt(backend UTC)은
Intl.DateTimeFormat("sv-SE", { timeZone: "Asia/Seoul", ... })(ticket-meta.ts formatKstDateTime)로
KST clock time 표시. timeZone 명시로 SSR/CSR 가 머신 TZ 와 무관하게 동일 결과(문자열 slice 미사용).
Implementation
Page (server component, refresh-aware)
apps/admin/src/app/(protected)/tickets/page.tsx—GET /api/v1/admin/tickets를 server-side 로 fetch(보호 endpoint, OPERATOR-only — 토큰 서버 전용). audit/actions 와 동일 refresh-aware 패턴:refreshIfNeeded→ fetch → 401 catch →forceRefresh1회 재시도 → 그래도 실패 시session.destroy()+/login. 5xx/네트워크는errorMessageprop 으로 client banner(세션 유지). searchParams(status·priority·category·assigneeOperatorId·hqId·storeId·page)는 Next 15 Promise + 다중값 정규화.status/priority/category는 generated enum(ListTicketsStatus/ListTicketsPriority/ListTicketsCategory) 화이트리스트(아니면 무시).page1-based URL → backend 0-based. searchParams 기반key로 client remount.[id]/page.tsx—GET /api/v1/admin/tickets/{id}server-side fetch./hq/[id]의 notFound 관용구(404 →notFound()).
Client
tickets-list-client.tsx— 필터 hydrate(enum 화이트리스트)·URL push·페이지네이션. 행 클릭/키보드 → 상세 네비.<TicketCreateDialog>마운트.ticket-create-dialog.tsx— generateduseCreate(BFF catch-all/api/backend/...경유) + 토스트.[id]/ticket-detail-client.tsx— generateduseAddComment·useChangeStatus·useChangePriority·useAssign. 우선순위는 색점 세그먼트(생성 다이얼로그 idiom 재사용 — radiogroup/roving tabIndex, 현재값 재클릭은 no-op) → 다른 값 클릭 시 즉시 mutate. 성공 시router.refresh(). 404/409/403/5xx 는ticket-errors.tsmapTicketError로 매핑(TICKET_NOT_FOUND포함).ticket-meta.ts(공용) — 상태 라벨·tone, 우선순위 라벨·색점 색/링 메타(PRIORITY_META), 카테고리 라벨·tone 메타(CATEGORY_META)·CATEGORY_ORDER(점장 support 동일 라벨 미러),validTransitions,formatKstDateTime. nullable category 는TicketCategory = NonNullable<...>로 좁혀 null 은 ”—” 폴백.priority-dot.tsx(공용) — 우선순위 4단계 색점 컴포넌트(PriorityDot, 시안ops-phase3-shared).
응답/요청 타입은 generated 스키마(@linkmusic/api-client) 단일 소스 — 수기 중복 정의 없음. server
helper backendListTickets/backendGetTicketDetail(lib/backend.ts)는 정의된 param 만 직렬화.
Navigation
사이드바 “CS 티켓”(/tickets)은 기존 NAV_ITEMS 에 이미 존재(SPEC 이전 placeholder href) — 변경 없음.
States & Edge Cases
| 상태 | 처리 |
|---|---|
| 티켓 0건 | ”등록된 CS 티켓이 없습니다” 빈 상태 (total === 0) |
| out-of-range page | ”이 페이지에 표시할 티켓이 없습니다” + [첫 페이지로] (total > 0, items 0) |
| 잘못된 상태 전이 | 409 TICKET_INVALID_STATUS_TRANSITION → inline 에러 + 새로고침 유도 (클라 가드로 1차 차단) |
| 5xx / 네트워크 실패 | client banner (세션 유지, 강제 로그아웃 X) |
| 401 (refresh 실패) | session.destroy() + /login redirect |
| 404 (미존재 id) | notFound() |
| 잘못된 status/priority/category query | 무시(필터 미적용 — 화이트리스트) |
| 담당자 미배정 | 목록 “미배정” · 상세 [해제] 비활성 |
| category null (legacy/hq·운영자 티켓) | 목록·상세 분류 배지 대신 ”—” / 생략 |
| storeName·hqName 둘 다 null (legacy) | 출처 ”—“ |
사이드바 CS 카운트 배지 (SPEC #119 F1 · BE 0)
운영사 OpsSidebar 의 /tickets(CS 티켓) 항목 우측에 미해결 카운트 pill 을 표시한다.
기존 GET /api/v1/admin/stats(AdminStatsResponse.tickets)를 재사용해 open + inProgress 를
바인딩한다 — 신규 BE 0. 대시보드(DashboardStats)의 useGetAdminStats() 와 동일 queryKey 이므로
react-query 가 dedup(중복 호출 0). 사이드바는 60초 폴링 옵션만 부여
(refetchInterval:60s·refetchIntervalInBackground:false — 탭 비활성 중단, focus 복귀 시 즉시).
SidebarItem.badge?: number prop(apps/admin/src/components/shell/sidebar.tsx)으로 추가했고, count 0·
로딩·에러 시 미표시(sidebar-badge-/tickets, 99 초과는 99+). pill 시각은 atom-grounded(sidebar-star
도트 패턴과 동형 — primary 톤·tabular-nums, 전용 시안 부재로 design-debt 등재).
Roadmap (후속)
- 첨부파일 · SLA/마감 · 고객 알림(이메일/푸시) · 태그.
- 본사 백오피스의 category 표면화(운영자 view 는 #113 에서 완료).
- 담당자 select(운영자 검색 — 현재 ID 직접 입력) · hqId/storeId 검색 picker(현재
storeId는 URL 진입만). - CS stats 대시보드 연동(대기 티켓 수).
- 시스템 코멘트(SYSTEM) 연동 — 상태/담당자/우선순위 변경을 티켓 타임라인에 SYSTEM 코멘트로도
남기기(현재는
OperatorAuditLog기록만 — #047). 이전 담당자 추적(현재 assign detail 은 결과만).
References
- SPEC #027 · #046 (우선순위 변경 컨트롤) · #047 (감사 로그 연동 — 상태/담당자/우선순위 변경
OperatorAuditLog기록) · #031-A (시안 교체) · #026 (audit 테이블 관용구 재사용) · #018/#024 (Dialog 전이 관용구) · #112 (점장 매장 채널 + category) · #113 (운영자 store ticket 식별 — storeName·category 표면화·필터, 점장 CS end-to-end 마감) linkmusic-frontend-space/apps/admin/src/app/(protected)/tickets/(page.tsx·tickets-list-client.tsx·ticket-create-dialog.tsx·ticket-meta.ts·priority-dot.tsx·ticket-errors.ts·[id]/page.tsx·[id]/ticket-detail-client.tsx)- 시안: workspace parent dir
design_handoff_linkmusic/design/screens/ops-tickets.jsx·design/ops-phase3-shared.jsx(PriorityDot)