FeaturesCS Tickets (/tickets)CS Tickets (/tickets)

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(PriorityDot atom) 정합(workspace parent dir design_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 배지를 노출하고, 목록 필터에 category select(보조로 storeId URL 진입)를 추가한다(read-side LEFT 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.ts validTransitions — 클라 가드). backend 도 동일 가드를 가져 이중 방어 — 잘못된 전이는 409 TICKET_INVALID_STATUS_TRANSITION 으로 거부되고 FE 는 inline 에러 + 토스트로 처리하고 목록 새로고침을 유도한다.
  • 상태전이·담당자 배정/해제·우선순위 변경은 성공 시 OperatorAuditLog 에 기록된다 (TICKET_STATUS_CHANGED·TICKET_ASSIGNED·TICKET_PRIORITY_CHANGED, targetType TICKET, 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.ts CATEGORY_META — 재생·음악/방송·안내/결제·정산/계정·로그인/기타)이며 색만으로 구분하지 않도록 라벨을 병기한다. 행은 native <tr>(암시적 role=row 유지) + tabIndex + Enter/Space 키 조작 + focus-visible ring → /tickets/{id} 상세. (role="button" 을 주면 table semantics 가 깨지므로 부여하지 않는다 — audit 테이블 관례 동일.)
  • 빈 상태total === 0 진짜 0건(“등록된 CS 티켓이 없습니다”) vs total > 0 && items 0 out-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.tsxGET /api/v1/admin/tickets 를 server-side 로 fetch(보호 endpoint, OPERATOR-only — 토큰 서버 전용). audit/actions 와 동일 refresh-aware 패턴: refreshIfNeeded → fetch → 401 catch → forceRefresh 1회 재시도 → 그래도 실패 시 session.destroy() + /login. 5xx/네트워크는 errorMessage prop 으로 client banner(세션 유지). searchParams(status·priority·category·assigneeOperatorId·hqId·storeId·page)는 Next 15 Promise + 다중값 정규화. status/priority/category 는 generated enum(ListTicketsStatus/ListTicketsPriority/ ListTicketsCategory) 화이트리스트(아니면 무시). page 1-based URL → backend 0-based. searchParams 기반 key 로 client remount.
  • [id]/page.tsxGET /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 — generated useCreate(BFF catch-all /api/backend/... 경유) + 토스트.
  • [id]/ticket-detail-client.tsx — generated useAddComment·useChangeStatus·useChangePriority· useAssign. 우선순위는 색점 세그먼트(생성 다이얼로그 idiom 재사용 — radiogroup/roving tabIndex, 현재값 재클릭은 no-op) → 다른 값 클릭 시 즉시 mutate. 성공 시 router.refresh(). 404/409/403/5xx 는 ticket-errors.ts mapTicketError 로 매핑(TICKET_NOT_FOUND 포함).
  • ticket-meta.ts(공용) — 상태 라벨·tone, 우선순위 라벨·색점 색/링 메타(PRIORITY_META), 카테고리 라벨·tone 메타(CATEGORY_METACATEGORY_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 만 직렬화.

사이드바 “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)