Compare commits

...

47 Commits

Author SHA1 Message Date
ff12938d7e feat: 서비스 관리 삭제 기능 추가 (#49)
All checks were successful
SPMS_BO/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_WEB/pulls/50
2026-03-18 04:00:22 +00:00
SEAN
c872985f96 feat: 서비스 관리 삭제 기능 추가 (#49)
- service.api.ts: deleteService API 함수 추가 (POST /v1/in/service/delete)
- ServiceHeaderCard: 수정하기 버튼 옆 삭제하기 버튼 추가
- ServiceDetailPage: 삭제 확인 모달 및 핸들러 추가 (삭제 후 목록으로 이동)

Closes #49
2026-03-18 12:58:14 +09:00
98d2f970a5 fix: 대시보드 및 기기 관리 화면 버그 수정 (#47)
All checks were successful
SPMS_BO/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_WEB/pulls/48
2026-03-18 01:49:16 +00:00
SEAN
32b48af34c fix: 대시보드 및 기기 관리 화면 버그 수정 (#47)
- StatusBadge: 매핑 실패 시 undefined variant → default 폴백 처리
- RecentMessages: STATUS_MAP 미등록 status 값 → default 폴백 처리
- DashboardPage: mapChart에서 최근 7일 날짜 항상 채우기 (빈 날짜 0으로)
- DeviceListPage/DeviceSlidePanel: 플랫폼 비교 toLowerCase() 처리
- DeviceSlidePanel: 플랫폼 텍스트 iOS/Android 정규화, 날짜 formatDate 적용
- PlatformBadge: Android 아이콘 lineHeight: 1 추가로 수직 정렬 수정
- formatDate: 서버 기본값(0001-01-01) → "-" 반환
- SecretToggleCell: position fixed로 테이블 overflow 탈출

Closes #47
2026-03-18 10:43:08 +09:00
2bc3fe87c8 fix: TagManagePage service_name null 인덱싱 타입 에러 수정 (#45)
All checks were successful
SPMS_BO/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_WEB/pulls/46
2026-03-02 13:50:26 +00:00
SEAN
3f97982e4f fix: TagManagePage service_name null 인덱싱 타입 에러 수정 (#45)
- service_name이 null일 때 Record 인덱스로 사용 불가 (TS2538)
- null 폴백 처리: item.service_name ?? "미지정"

Closes #45
2026-03-02 22:45:31 +09:00
b522f968ee feat: 마이페이지 + 프로필 수정 API 연동 (#43)
Some checks failed
SPMS_BO/pipeline/head There was a failure building this commit
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_WEB/pulls/44
2026-03-02 12:39:13 +00:00
SEAN
34feab7fa9 feat: 마이페이지 + 프로필 수정 API 연동 (#43)
- account.api.ts 신규 생성 (프로필 조회/수정, 활동 내역, 비밀번호 변경)
- types.ts API 타입 추가 + MOCK_PROFILE/MOCK_ACTIVITIES 삭제
- MyPage.tsx: fetchProfile + fetchActivityList API 연동, 서버 페이지네이션
- ProfileEditPage.tsx: fetchProfile 초기값, updateProfile 저장, changePassword 연동

Closes #43
2026-03-02 21:32:17 +09:00
0b2aa91a43 feat: 태그 관리 페이지 API 연동 (#41)
Some checks failed
SPMS_BO/pipeline/head There was a failure building this commit
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_WEB/pulls/42
2026-03-02 09:10:50 +00:00
SEAN
b4b6426ded feat: 태그 관리 페이지 API 연동 (#41)
- tag.api.ts 신규 생성 (list/create/update/delete POST 엔드포인트)
- TagListItem, TagResponse, TagPagination 등 API 타입 정의
- Mock 데이터(MOCK_TAGS, SERVICE_OPTIONS 등) 제거
- 서비스 목록 동적 로드 (fetchServices) + 탭 필터링
- X-Service-Code 헤더 처리 (전체 API)
- tagCode 우측 배치 + 클릭 시 클립보드 복사
- swagger 제약조건 반영 (태그명 50자, 설명 200자)
- ServiceSummary에 serviceId 필드 추가

Closes #41
2026-03-02 18:08:48 +09:00
683d0e6f30 feat: 발송 통계 페이지 API 연동 (#39)
All checks were successful
SPMS_BO/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_WEB/pulls/40
2026-03-02 02:54:12 +00:00
SEAN
21dcc6335d feat: 발송 통계 페이지 API 연동 (#39)
- types.ts: Mock 데이터 7개 + SERVICE_FILTER_OPTIONS 삭제, swagger 기준 요청/응답 타입 15개 추가
- statistics.api.ts: 신규 생성 (fetchDailyStats, fetchHourlyStats, fetchDeviceStats, fetchHistoryList, fetchHistoryDetail, exportHistory)
- StatisticsPage.tsx: 4개 API 병렬 호출 + mapper 함수 6개로 차트 props 변환, fetchServices 서비스 필터 동적 로드
- StatisticsHistoryPage.tsx: 서버 필터링, API 페이지네이션, 엑셀 blob 다운로드, 패널에 messageCode 전달
- HistorySlidePanel.tsx: props를 messageCode로 변경, fetchHistoryDetail API 호출, 로딩 스켈레톤 추가

Closes #39
2026-03-02 11:52:23 +09:00
e37066ce31 feat: 기기 관리 페이지 API 연동 (#37)
All checks were successful
SPMS_BO/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_WEB/pulls/38
2026-03-02 01:52:13 +00:00
SEAN
589a0d67ce feat: 기기 관리 페이지 API 연동 (#37)
- types.ts: DeviceSummary/MOCK_DEVICES/SERVICE_FILTER_OPTIONS 삭제, swagger 기준 snake_case 타입 추가 (DeviceListItem, DeviceListRequest 등)
- device.api.ts: 신규 생성 (fetchDevices, deleteDevice, exportDevices)
- DeviceListPage.tsx: Mock → loadData/useCallback 서버 필터링, fetchServices로 서비스 목록 로드, 엑셀 내보내기 구현
- DeviceSlidePanel.tsx: DeviceListItem 타입 적용, deleteDevice API 호출 연동

Closes #37
2026-03-02 10:47:04 +09:00
22c6be0002 feat: 메시지 관리 페이지 API 연동 (#35)
All checks were successful
SPMS_BO/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_WEB/pulls/36
2026-03-02 00:59:18 +00:00
SEAN
2549930a5a feat: 메시지 관리 페이지 API 연동 (#35)
- types.ts: Mock 데이터 및 camelCase 타입 삭제, swagger 기준 snake_case 타입 추가
- message.api.ts: 신규 생성 (목록/상세/저장/삭제/검증 API 함수)
- MessageListPage: MOCK_MESSAGES → fetchMessages API, 서비스 필터 fetchServices로 실제 로드
- MessageSlidePanel: MOCK_MESSAGE_DETAILS → fetchMessageInfo API, deleteMessage API 연동
- MessageRegisterPage: SERVICE_OPTIONS → fetchServices API, validateMessage → saveMessage 흐름
- MessageRegisterPage: 서비스 선택을 FilterDropdown 스타일 커스텀 드롭다운으로 변경
- MessagePreview: 빈 내용 시 플레이스홀더 텍스트 제거

Closes #35
2026-03-02 09:51:02 +09:00
ad6010320a feat: 대시보드 API 타입 swagger 기준 전면 수정 (#33)
All checks were successful
SPMS_BO/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_WEB/pulls/34
2026-03-01 12:02:06 +00:00
SEAN
aef2890474 feat: 대시보드 API 타입 swagger 기준 전면 수정 (#33)
- types.ts: swagger 기준 타입 전면 교체 (DashboardKpi, DailyStat, HourlyStat, PlatformStat, TopMessage, DashboardData)
- DashboardPage.tsx: map 함수 4개 수정 (mapCards, mapChart, mapMessages, mapPlatform) + hasData 조건 개선
- ServiceDetailPage.tsx: KPI 필드명 변경 (total_sent→total_send, success_rate→계산식)
- DashboardFilter.tsx: 서비스 드롭다운 제거 (전체 서비스 통합 현황만 표시)
- dashboard.api.ts: 미사용 fetchServiceList/ServiceOption 제거

Closes #33
2026-03-01 20:56:17 +09:00
8f753a668b feat: 서비스 관리 API 연동 및 UI 개선 (#31)
All checks were successful
SPMS_BO/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_WEB/pulls/32
2026-03-01 01:38:39 +00:00
SEAN
bb4d531d8c feat: 서비스 관리 API 연동 및 UI 개선 (#31)
- 플랫폼 관리 API 연동 (FCM/APNs 인증서 등록/삭제)
- 서비스 상세 통계 카드 대시보드 KPI API 연결
- 통계 서브텍스트 전일 대비 변동 데이터 연결 (변동 없음 표시 포함)
- ServiceHeaderCard/ServiceEditPage optional chaining 버그 수정
- 날짜 표기 YYYY-MM-DD 형식 통일
- 페이지네이션 totalCount 필드명 수정 및 패딩 정렬
- 서비스 등록 완료 모달 UI 통일 및 문구 수정

Closes #31
2026-03-01 10:35:54 +09:00
cff57b5fad feat: 대시보드 API 연동 (#29)
All checks were successful
SPMS_BO/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_WEB/pulls/30
2026-02-28 08:12:15 +00:00
SEAN
cf7076f525 feat: 대시보드 API 연동 (#29)
- 대시보드 API 타입 정의 (DashboardRequest, DashboardData 등)
- API 함수 생성 (fetchDashboard, fetchServiceList)
- DashboardFilter 서비스 드롭다운 API 연동 (하드코딩 제거)
- DashboardPage 랜덤 더미 데이터 → API 호출로 전환
- 에러/빈 데이터 오버레이 분리 (API 에러 vs 조회 결과 없음)

Closes #29
2026-02-28 17:07:42 +09:00
SEAN
ca88d5ba08 feat: 설정 페이지 기능 개선 및 알림 상세 패널 구현
All checks were successful
SPMS_BO/pipeline/head This commit looks good
- 알림 상세 슬라이드 패널 (NotificationSlidePanel) 신규 생성
- 헤더 알림 드롭다운에서 클릭 시 알림 페이지 이동 + 패널 자동 오픈
- 프로필 수정 필수값 검증: useShake + 인라인 에러 메시지 패턴 적용
- 사이드바 프로필 아이콘 클릭 시 마이페이지 이동
- 사이드바 메뉴 그룹 경로 변경 시 자동 접힘 처리
- 대시보드, 마이페이지 UI 개선

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 16:30:22 +09:00
c3073f0e87 feat: 태그 관리 페이지 구현 (#25)
All checks were successful
SPMS_BO/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_WEB/pulls/26
2026-02-28 06:19:05 +00:00
SEAN
8b199803d6 feat: 태그 관리 페이지 구현 (#25)
- TagCard 컴포넌트 구현 (태그 정보 표시, 수정/삭제 기능)
- TagAddModal 컴포넌트 구현 (태그 등록 폼)
- TagDeleteModal 컴포넌트 구현 (삭제 확인 다이얼로그)
- TagManagePage 구현 (서비스별 탭 필터링 + 9건 단위 페이지네이션)
- 서비스 탭 오버플로우 시 가로 스크롤 처리 (scrollbar-hide 유틸 추가)
- 목 데이터 10건 구성

Closes #25
2026-02-28 15:15:49 +09:00
bd71547cc2 feat: 발송 관리 페이지 구현 (#23)
All checks were successful
SPMS_BO/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_WEB/pulls/24
2026-02-27 14:46:06 +00:00
SEAN
530e92240a feat: 발송 관리 페이지 구현 (#23)
- 발송 통계 페이지 (StatisticsPage): 4개 통계 카드, 월간 추이 라인 차트, 플랫폼별 도넛 차트, 시간대별 바 차트, 최근 이력 테이블, 오픈율 Top5
- 발송 이력 페이지 (StatisticsHistoryPage): 검색/서비스/상태/날짜 필터, 발송 이력 테이블, 행 클릭 슬라이드 패널 (발송 상세), 페이지네이션
- 타입 정의 + 목 데이터 15건 (types.ts)
- 브레드크럼 그룹 라벨 처리 (발송 관리 > 발송 통계/발송 이력)
- 날짜 필터 기본값: 오늘 기준 1달 전 ~ 오늘
- 대시보드와 카드/차트 스타일 통일
- 메시지 목록 연동: 슬라이드 패널에서 messageId 쿼리 파라미터로 이동

Closes #23
2026-02-27 23:31:13 +09:00
6501676a35 feat: 기기 관리 페이지 구현 (#21)
All checks were successful
SPMS_BO/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_WEB/pulls/22
2026-02-27 07:59:45 +00:00
SEAN
31d967ffbf feat: 기기 관리 페이지 구현 (#21)
- 타입 + 상수 + 목 데이터 정의 (DeviceSummary, 10건)
- Device ID / Push Token 토글 팝오버 셀 구현 (SecretToggleCell)
- 슬라이드 패널 상세 + 삭제 확인 모달 구현 (DeviceSlidePanel)
- 목록 페이지 구현 (필터 4개 + 8컬럼 테이블 + 스켈레톤 + 페이지네이션)
- PlatformBadge 아이콘 전용으로 수정
- FilterDropdown 드롭다운 옵션 중앙 정렬

Closes #21

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 16:57:08 +09:00
f23e6cb4b2 feat: 메시지 관리 페이지 구현 (#19)
All checks were successful
SPMS_BO/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_WEB/pulls/20
2026-02-27 06:36:17 +00:00
SEAN
6653e5da2a feat: 메시지 관리 페이지 구현 (#19)
- 메시지 타입 정의 + 목 데이터 10건 (types.ts)
- iOS/Android 푸시 알림 프리뷰 컴포넌트 (MessagePreview)
- 슬라이드 패널 상세 보기 + 삭제 기능 (MessageSlidePanel)
- 메시지 목록 페이지: 필터(ID/제목 검색, 서비스), 테이블, 페이지네이션
- 메시지 작성 페이지: 12-col 그리드 폼 + 실시간 프리뷰, 필수 검증, 저장 모달

Closes #19

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 15:33:42 +09:00
b1957b1bfc fix: TypeScript 빌드 에러 수정 (#17)
All checks were successful
SPMS_BO/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_WEB/pulls/18
2026-02-27 05:22:25 +00:00
SEAN
4f8c282572 fix: TypeScript 빌드 에러 수정 (#17)
- DashboardPage: 미사용 변수 i 제거 (TS6133)
- PlatformStatusIndicator: CredentialStatus 타입 내로잉 적용 (TS7053)

Closes #17
2026-02-27 14:15:36 +09:00
1b4fc79b2d feat: 서비스 관리 페이지 구현 (#14)
Some checks failed
SPMS_BO/pipeline/head There was a failure building this commit
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_WEB/pulls/16
2026-02-27 05:01:16 +00:00
SEAN
9db9d87dea feat: 서비스 관리 페이지 구현 (#14)
- 서비스 목록 페이지 (검색/필터/페이지네이션, 행 클릭 → 상세)
- 서비스 상세 페이지 (헤더카드/통계/플랫폼 관리 모달)
- 서비스 등록 페이지 (서비스명/플랫폼 선택/설명/관련링크)
- 서비스 수정 페이지 (상태 토글/메타정보/저장 확인 모달)
- 공통 훅 추출 (useShake, useBreadcrumbBack)
- 브레드크럼 동적 경로 지원 (/services/:id, /services/:id/edit)
- 인증 페이지 useShake 공통 훅 리팩터링

Closes #14
2026-02-27 13:53:56 +09:00
c89cfeaa56 feat: 대시보드 페이지 구현 및 공통 컴포넌트 추가 (#9)
Some checks failed
SPMS_BO/pipeline/head There was a failure building this commit
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_WEB/pulls/11
2026-02-27 01:29:17 +00:00
SEAN
59c206e0c2 feat: 대시보드 조회 로딩 상태 및 필터 disabled 처리
- 조회 클릭 시 전체 필터 비활성화 (날짜/드롭다운/초기화/조회)
- 각 위젯 로딩 오버레이 및 스켈레톤 추가
- 조회 완료 시 랜덤 Mock 데이터로 갱신
- 공통 컴포넌트(FilterDropdown, DateRangeInput, FilterResetButton)에 disabled prop 추가
- StatsCards 뱃지 아이콘 크기 및 중앙 정렬 개선

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 09:59:59 +09:00
SEAN
61508e25f7 feat: 대시보드 페이지 구현 (통계카드/차트/필터/최근발송/플랫폼도넛)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 09:29:22 +09:00
SEAN
af6ecab428 feat: 가이드라인 기반 공통 컴포넌트 및 레이아웃 개선
- 공통 컴포넌트 11개 생성 (PageHeader, StatusBadge, CategoryBadge, FilterDropdown, DateRangeInput, SearchInput, FilterResetButton, Pagination, EmptyState, CopyButton, PlatformBadge)
- AppHeader: 다단계 breadcrumb, 알림 드롭다운 구현
- AppLayout: 푸터 개인정보처리방침/이용약관 모달 추가
- AppSidebar: 이메일 폰트 자동 축소 (clamp)
- SignupPage: 모달 닫기 버튼 x 아이콘으로 통일
- Suspense fallback SVG 스피너로 변경

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 09:27:21 +09:00
1e8d33102e feat: 인증 페이지 API 연동 (회원가입/로그인/이메일 인증)
All checks were successful
SPMS_BO/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_WEB/pulls/8
2026-02-26 23:01:29 +00:00
SEAN
37ac854bc8 feat: 인증 페이지 API 연동 (회원가입/로그인/이메일 인증) (#7)
- 회원가입 API 연동 (POST /v1/in/auth/signup)
- 로그인 API 연동 (POST /v1/in/auth/login, next_action 분기)
- 이메일 인증 API 연동 (POST /v1/in/auth/email/verify, /resend)
- API 타입 swagger 스펙에 맞게 수정 (ApiResponse, auth types)
- User 타입 백엔드 AdminInfo 기반으로 변경
- authStore localStorage 영속화 (새로고침 시 인증 유지)
- Vite dev 프록시 설정 (/v1 → devspms)

Closes #7

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 08:00:04 +09:00
SEAN
2c4701939d fix: zod v4 z.literal() API 호환 (SignupPage)
All checks were successful
SPMS_BO/pipeline/head This commit looks good
errorMap은 zod v4에서 지원하지 않으므로 message로 교체.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 16:45:42 +09:00
SEAN
bb82846d59 fix: Jenkinsfile 프론트 빌드 소스 복사 방식 수정
Some checks failed
SPMS_BO/pipeline/head There was a failure building this commit
정지된 컨테이너에 docker cp하면 바인드 마운트에 반영되지 않아
호스트 볼륨의 옛날 소스로 빌드되는 문제 해결.
임시 alpine 컨테이너를 통해 호스트 볼륨에 직접 복사하도록 변경.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 16:22:48 +09:00
e8edbb528c feat: 인증 페이지 구현 (로그인/회원가입/이메일 인증) (#4)
All checks were successful
SPMS_BO/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_WEB/pulls/6
2026-02-26 05:51:13 +00:00
SEAN
ccfda47b96 feat: 인증 페이지 구현 (로그인/회원가입/이메일 인증) (#4)
- LoginPage: react-hook-form + zod 유효성검사, 비밀번호 토글, shake 애니메이션,
  로그인 성공/실패 처리, 성공 오버레이
- SignupPage: 이메일/비밀번호/이름/전화번호 실시간 검증, 전화번호 자동 하이픈,
  약관 동의 체크박스, 인증 메일 전송 모달, 이용약관/개인정보 모달
- VerifyEmailPage: 6자리 코드 입력(자동 포커스/붙여넣기), 인증 성공/실패,
  재전송 60초 쿨다운, 인증 완료 모달 + 홈 이동 오버레이
- ResetPasswordModal: 비밀번호 재설정 이메일 발송, sonner 토스트
- AuthLayout: flex 기반 풋터 위치 수정 (콘텐츠 중앙 + 풋터 하단)
- 라우터: verify-email 가드 추가 (인증 완료 시 홈 리다이렉트)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 14:37:42 +09:00
db3a22bb57 feat: 프론트엔드 아키텍처 셋업 (#2)
All checks were successful
SPMS_BO/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_WEB/pulls/3
2026-02-26 04:03:53 +00:00
SEAN
fc9b0c0f75 feat: 프론트엔드 아키텍처 셋업 (#2)
- Vite 기본 템플릿 정리 및 index.html 수정
- guideline.html 기반 디자인 토큰 적용 (index.css)
- Feature-based 폴더 구조 (8개 feature 모듈)
- 18개 placeholder 페이지 + lazy loading 라우터
- 레이아웃 컴포넌트 (AppLayout, AppHeader, AppSidebar, AuthLayout)
- Zustand 스토어 (authStore, uiStore)
- API 계층 (Axios client, auth.api)
- 타입 정의, 유틸리티, 환경변수 설정
- ErrorBoundary, ProtectedRoute, PublicRoute

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 13:02:22 +09:00
138 changed files with 24562 additions and 213 deletions

30
Jenkinsfile vendored
View File

@ -1,6 +1,6 @@
def TARGET_FRONT_BUILD = '' def TARGET_FRONT_BUILD = ''
def TARGET_RUN_SERVER = '' def TARGET_RUN_SERVER = ''
def CONTAINER_SRC_PATH = '/src' def FRONT_HOST_PATH = '/volume1/SPMS/PROJECT/Application/Front'
pipeline { pipeline {
agent any agent any
@ -13,7 +13,6 @@ pipeline {
echo "Current Branch: ${branchName}" echo "Current Branch: ${branchName}"
if (branchName == 'develop') { if (branchName == 'develop') {
// develop 브랜치 -> Debug 컨테이너
TARGET_FRONT_BUILD = 'spms-front-build-debug' TARGET_FRONT_BUILD = 'spms-front-build-debug'
TARGET_RUN_SERVER = 'spms-run-debug' TARGET_RUN_SERVER = 'spms-run-debug'
echo "[DEV Mode] Target: DEBUG Container" echo "[DEV Mode] Target: DEBUG Container"
@ -38,19 +37,26 @@ pipeline {
} }
} }
// [3단계] 컨테이너로 소스 복사 // [3단계] 실행 중인 임시 컨테이너를 통해 호스트 볼륨에 소스 복사
stage('Copy to Container') { // (정지된 컨테이너에 docker cp하면 바인드 마운트에 반영 안 됨)
stage('Copy to Host Volume') {
steps { steps {
script { script {
def sourcePath = "${WORKSPACE}/react/." def sourcePath = "${WORKSPACE}/react/."
echo "Copying from ${sourcePath} to ${TARGET_FRONT_BUILD}" echo "Copying source to host volume (${FRONT_HOST_PATH})..."
def containerId = sh(script: "docker ps -aqf 'name=${TARGET_FRONT_BUILD}'", returnStdout: true).trim()
if (containerId) { sh """
sh "docker cp ${sourcePath} ${TARGET_FRONT_BUILD}:${CONTAINER_SRC_PATH}" docker rm -f spms-source-copy 2>/dev/null || true
} else { docker run -d --name spms-source-copy \
error "Container ${TARGET_FRONT_BUILD} not found!" -v ${FRONT_HOST_PATH}:/target \
} alpine sleep 30
docker exec spms-source-copy sh -c \
'find /target -mindepth 1 -maxdepth 1 ! -name node_modules -exec rm -rf {} +'
docker cp ${sourcePath} spms-source-copy:/target/
docker rm -f spms-source-copy
"""
echo "Source copy complete."
} }
} }
} }
@ -58,7 +64,7 @@ pipeline {
stage('Build Frontend') { stage('Build Frontend') {
steps { steps {
script { script {
echo "Starting React Build..." echo "Starting React Build (npm install + build + deploy)..."
sh "docker start -a ${TARGET_FRONT_BUILD}" sh "docker start -a ${TARGET_FRONT_BUILD}"
echo "React Build Complete." echo "React Build Complete."
} }

2
react/.env.development Normal file
View File

@ -0,0 +1,2 @@
VITE_API_BASE_URL=
VITE_APP_TITLE=SPMS

2
react/.env.example Normal file
View File

@ -0,0 +1,2 @@
VITE_API_BASE_URL=http://localhost:8080/api
VITE_APP_TITLE=SPMS

23
react/components.json Normal file
View File

@ -0,0 +1,23 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

View File

@ -1,10 +1,20 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="ko">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>react</title> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@100..900&display=swap"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap"
rel="stylesheet"
/>
<title>SPMS</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

6752
react/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -10,11 +10,27 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.2.2",
"@tanstack/react-query": "^5.90.20",
"axios": "^1.13.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.563.0",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0" "react-dom": "^19.2.0",
"react-hook-form": "^7.71.1",
"react-router-dom": "^7.13.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"zod": "^4.3.6",
"zustand": "^5.0.11"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
"@tailwindcss/vite": "^4.1.18",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@types/react": "^19.2.5", "@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
@ -23,6 +39,10 @@
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24", "eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0", "globals": "^16.5.0",
"shadcn": "^3.8.4",
"tailwindcss": "^4.1.18",
"tailwindcss-animate": "^1.0.7",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.46.4", "typescript-eslint": "^8.46.4",
"vite": "^7.2.4" "vite": "^7.2.4"

View File

@ -1,42 +0,0 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

View File

@ -1,35 +1,29 @@
import { useState } from 'react' import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import reactLogo from './assets/react.svg' import { RouterProvider } from "react-router-dom";
import viteLogo from '/vite.svg' import { ThemeProvider } from "next-themes";
import './App.css' import { Toaster } from "@/components/ui/sonner";
import { ErrorBoundary } from "@/components/feedback/ErrorBoundary";
import { router } from "@/routes";
function App() { const queryClient = new QueryClient({
const [count, setCount] = useState(0) defaultOptions: {
queries: {
retry: 1,
staleTime: 5 * 60 * 1000, // 5분
refetchOnWindowFocus: false,
},
},
});
export default function App() {
return ( return (
<> <ErrorBoundary>
<div> <ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
<a href="https://vite.dev" target="_blank"> <QueryClientProvider client={queryClient}>
<img src={viteLogo} className="logo" alt="Vite logo" /> <RouterProvider router={router} />
</a> <Toaster />
<a href="https://react.dev" target="_blank"> </QueryClientProvider>
<img src={reactLogo} className="logo react" alt="React logo" /> </ThemeProvider>
</a> </ErrorBoundary>
</div> );
<h1>Vite + React</h1>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
</div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
</>
)
} }
export default App

View File

@ -0,0 +1,41 @@
import { apiClient } from "./client";
import type { ApiResponse } from "@/types/api";
import type {
ProfileResponse,
UpdateProfileRequest,
ActivityListRequest,
ActivityListResponse,
ChangePasswordRequest,
ChangePasswordResponse,
} from "@/features/settings/types";
/** 내 프로필 조회 */
export function fetchProfile() {
return apiClient.post<ApiResponse<ProfileResponse>>(
"/v1/in/account/profile/info",
);
}
/** 프로필 수정 */
export function updateProfile(data: UpdateProfileRequest) {
return apiClient.post<ApiResponse<ProfileResponse>>(
"/v1/in/account/profile/update",
data,
);
}
/** 활동 내역 조회 */
export function fetchActivityList(data: ActivityListRequest) {
return apiClient.post<ApiResponse<ActivityListResponse>>(
"/v1/in/account/profile/activity/list",
data,
);
}
/** 비밀번호 변경 */
export function changePassword(data: ChangePasswordRequest) {
return apiClient.post<ApiResponse<ChangePasswordResponse>>(
"/v1/in/auth/password/change",
data,
);
}

58
react/src/api/auth.api.ts Normal file
View File

@ -0,0 +1,58 @@
import { apiClient } from "./client";
import type { ApiResponse } from "@/types/api";
import type {
SignupRequest,
SignupResponse,
EmailCheckRequest,
EmailCheckResponse,
LoginRequest,
LoginResponse,
EmailVerifyRequest,
EmailVerifyResponse,
EmailResendRequest,
EmailResendResponse,
TokenRefreshRequest,
TokenRefreshResponse,
LogoutResponse,
TempPasswordRequest,
} from "@/features/auth/types";
/** 회원가입 */
export function signup(data: SignupRequest) {
return apiClient.post<ApiResponse<SignupResponse>>("/v1/in/auth/signup", data);
}
/** 이메일 중복 체크 */
export function checkEmail(data: EmailCheckRequest) {
return apiClient.post<ApiResponse<EmailCheckResponse>>("/v1/in/auth/email/check", data);
}
/** 로그인 */
export function login(data: LoginRequest) {
return apiClient.post<ApiResponse<LoginResponse>>("/v1/in/auth/login", data);
}
/** 이메일 인증 */
export function verifyEmail(data: EmailVerifyRequest) {
return apiClient.post<ApiResponse<EmailVerifyResponse>>("/v1/in/auth/email/verify", data);
}
/** 인증코드 재전송 */
export function resendVerifyEmail(data: EmailResendRequest) {
return apiClient.post<ApiResponse<EmailResendResponse>>("/v1/in/auth/email/verify/resend", data);
}
/** 토큰 갱신 */
export function refreshToken(data: TokenRefreshRequest) {
return apiClient.post<ApiResponse<TokenRefreshResponse>>("/v1/in/auth/token/refresh", data);
}
/** 로그아웃 */
export function logout() {
return apiClient.post<ApiResponse<LogoutResponse>>("/v1/in/auth/logout");
}
/** 임시 비밀번호 발급 */
export function requestTempPassword(data: TempPasswordRequest) {
return apiClient.post<ApiResponse<null>>("/v1/in/account/password/temp", data);
}

31
react/src/api/client.ts Normal file
View File

@ -0,0 +1,31 @@
import axios from "axios";
/** Axios 인스턴스 */
export const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL ?? "",
timeout: 15000,
headers: {
"Content-Type": "application/json",
},
});
/** 요청 인터셉터 - 토큰 자동 추가 */
apiClient.interceptors.request.use((config) => {
const token = localStorage.getItem("accessToken");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
/** 응답 인터셉터 - 401 처리 */
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem("accessToken");
window.location.href = "/login";
}
return Promise.reject(error);
},
);

View File

@ -0,0 +1,12 @@
import { apiClient } from "./client";
import type { ApiResponse } from "@/types/api";
import type { DashboardRequest, DashboardData } from "@/features/dashboard/types";
/** 대시보드 통합 조회 */
export function fetchDashboard(data: DashboardRequest, serviceCode?: string) {
return apiClient.post<ApiResponse<DashboardData>>(
"/v1/in/stats/dashboard",
data,
serviceCode ? { headers: { "X-Service-Code": serviceCode } } : undefined,
);
}

View File

@ -0,0 +1,43 @@
import { apiClient } from "./client";
import type { ApiResponse } from "@/types/api";
import type {
DeviceListRequest,
DeviceListResponse,
DeviceDeleteRequest,
DeviceExportRequest,
} from "@/features/device/types";
/** 기기 목록 조회 (X-Service-Code 선택) */
export function fetchDevices(
data: DeviceListRequest,
serviceCode?: string,
) {
return apiClient.post<ApiResponse<DeviceListResponse>>(
"/v1/in/device/list",
data,
serviceCode ? { headers: { "X-Service-Code": serviceCode } } : undefined,
);
}
/** 기기 삭제 (비활성화) */
export function deleteDevice(
data: DeviceDeleteRequest,
serviceCode: string,
) {
return apiClient.post<ApiResponse<null>>(
"/v1/in/device/admin/delete",
data,
{ headers: { "X-Service-Code": serviceCode } },
);
}
/** 기기 내보내기 (xlsx blob) */
export function exportDevices(
data: DeviceExportRequest,
serviceCode: string,
) {
return apiClient.post("/v1/in/device/export", data, {
headers: { "X-Service-Code": serviceCode },
responseType: "blob",
});
}

View File

@ -0,0 +1,58 @@
import { apiClient } from "./client";
import type { ApiResponse } from "@/types/api";
import type {
MessageListRequest,
MessageListResponse,
MessageInfoRequest,
MessageInfoResponse,
MessageSaveRequest,
MessageDeleteRequest,
MessageValidateRequest,
} from "@/features/message/types";
/** 메시지 목록 조회 */
export function fetchMessages(data: MessageListRequest) {
return apiClient.post<ApiResponse<MessageListResponse>>(
"/v1/in/message/list",
data,
);
}
/** 메시지 상세 조회 */
export function fetchMessageInfo(
data: MessageInfoRequest,
serviceCode: string,
) {
return apiClient.post<ApiResponse<MessageInfoResponse>>(
"/v1/in/message/info",
data,
{ headers: { "X-Service-Code": serviceCode } },
);
}
/** 메시지 저장 */
export function saveMessage(data: MessageSaveRequest, serviceCode: string) {
return apiClient.post<ApiResponse<null>>("/v1/in/message/save", data, {
headers: { "X-Service-Code": serviceCode },
});
}
/** 메시지 삭제 */
export function deleteMessage(
data: MessageDeleteRequest,
serviceCode: string,
) {
return apiClient.post<ApiResponse<null>>("/v1/in/message/delete", data, {
headers: { "X-Service-Code": serviceCode },
});
}
/** 메시지 검증 */
export function validateMessage(
data: MessageValidateRequest,
serviceCode: string,
) {
return apiClient.post<ApiResponse<null>>("/v1/in/message/validate", data, {
headers: { "X-Service-Code": serviceCode },
});
}

View File

@ -0,0 +1,88 @@
import { apiClient } from "./client";
import type { ApiResponse, PaginatedResponse } from "@/types/api";
import type {
ServiceListRequest,
ServiceSummary,
ServiceDetail,
ApiKeyResponse,
CreateServiceRequest,
CreateServiceResponse,
UpdateServiceRequest,
RegisterFcmRequest,
RegisterApnsRequest,
} from "@/features/service/types";
/** 서비스 목록 조회 */
export function fetchServices(data: ServiceListRequest) {
return apiClient.post<PaginatedResponse<ServiceSummary>>(
"/v1/in/service/list",
data,
);
}
/** 서비스 상세 조회 */
export function fetchServiceDetail(serviceCode: string) {
return apiClient.post<ApiResponse<ServiceDetail>>(
`/v1/in/service/${serviceCode}`,
);
}
/** API Key 전체 조회 (마스킹 해제) */
export function fetchApiKey(serviceCode: string) {
return apiClient.post<ApiResponse<ApiKeyResponse>>(
`/v1/in/service/${serviceCode}/apikey/view`,
);
}
/** 서비스 생성 */
export function createService(data: CreateServiceRequest) {
return apiClient.post<ApiResponse<CreateServiceResponse>>(
"/v1/in/service/create",
data,
);
}
/** 서비스 수정 */
export function updateService(data: UpdateServiceRequest) {
return apiClient.post<ApiResponse<null>>(
"/v1/in/service/update",
data,
);
}
/** FCM 인증서 등록 */
export function registerFcm(serviceCode: string, data: RegisterFcmRequest) {
return apiClient.post<ApiResponse<null>>(
`/v1/in/service/${serviceCode}/fcm`,
data,
);
}
/** FCM 인증서 삭제 */
export function deleteFcm(serviceCode: string) {
return apiClient.post<ApiResponse<null>>(
`/v1/in/service/${serviceCode}/fcm/delete`,
);
}
/** APNs 인증서 등록 */
export function registerApns(serviceCode: string, data: RegisterApnsRequest) {
return apiClient.post<ApiResponse<null>>(
`/v1/in/service/${serviceCode}/apns`,
data,
);
}
/** APNs 인증서 삭제 */
export function deleteApns(serviceCode: string) {
return apiClient.post<ApiResponse<null>>(
`/v1/in/service/${serviceCode}/apns/delete`,
);
}
/** 서비스 삭제 */
export function deleteService(serviceCode: string) {
return apiClient.post<ApiResponse<null>>("/v1/in/service/delete", {
service_code: serviceCode,
});
}

View File

@ -0,0 +1,82 @@
import { apiClient } from "./client";
import type { ApiResponse } from "@/types/api";
import type {
DailyStatRequest,
DailyStatResponse,
HourlyStatRequest,
HourlyStatResponse,
DeviceStatResponse,
HistoryListRequest,
HistoryListResponse,
HistoryDetailRequest,
HistoryDetailResponse,
HistoryExportRequest,
} from "@/features/statistics/types";
/** 일별 통계 조회 */
export function fetchDailyStats(
data: DailyStatRequest,
serviceCode?: string,
) {
return apiClient.post<ApiResponse<DailyStatResponse>>(
"/v1/in/stats/daily",
data,
serviceCode ? { headers: { "X-Service-Code": serviceCode } } : undefined,
);
}
/** 시간대별 통계 조회 */
export function fetchHourlyStats(
data: HourlyStatRequest,
serviceCode?: string,
) {
return apiClient.post<ApiResponse<HourlyStatResponse>>(
"/v1/in/stats/hourly",
data,
serviceCode ? { headers: { "X-Service-Code": serviceCode } } : undefined,
);
}
/** 디바이스 통계 조회 */
export function fetchDeviceStats(serviceCode?: string) {
return apiClient.post<ApiResponse<DeviceStatResponse>>(
"/v1/in/stats/device",
{},
serviceCode ? { headers: { "X-Service-Code": serviceCode } } : undefined,
);
}
/** 이력 목록 조회 */
export function fetchHistoryList(
data: HistoryListRequest,
serviceCode?: string,
) {
return apiClient.post<ApiResponse<HistoryListResponse>>(
"/v1/in/stats/history/list",
data,
serviceCode ? { headers: { "X-Service-Code": serviceCode } } : undefined,
);
}
/** 이력 상세 조회 */
export function fetchHistoryDetail(
data: HistoryDetailRequest,
serviceCode?: string,
) {
return apiClient.post<ApiResponse<HistoryDetailResponse>>(
"/v1/in/stats/history/detail",
data,
serviceCode ? { headers: { "X-Service-Code": serviceCode } } : undefined,
);
}
/** 이력 내보내기 (xlsx blob) */
export function exportHistory(
data: HistoryExportRequest,
serviceCode?: string,
) {
return apiClient.post("/v1/in/stats/history/export", data, {
...(serviceCode ? { headers: { "X-Service-Code": serviceCode } } : {}),
responseType: "blob",
});
}

40
react/src/api/tag.api.ts Normal file
View File

@ -0,0 +1,40 @@
import { apiClient } from "./client";
import type { ApiResponse } from "@/types/api";
import type {
TagListRequest,
TagListResponse,
TagResponse,
CreateTagRequest,
UpdateTagRequest,
DeleteTagRequest,
} from "@/features/tag/types";
/** 태그 목록 조회 (serviceCode 생략 시 전체 서비스) */
export function fetchTagList(data: TagListRequest, serviceCode?: string) {
return apiClient.post<ApiResponse<TagListResponse>>(
"/v1/in/tag/list",
data,
serviceCode ? { headers: { "X-Service-Code": serviceCode } } : undefined,
);
}
/** 태그 생성 */
export function createTag(data: CreateTagRequest, serviceCode: string) {
return apiClient.post<ApiResponse<TagResponse>>("/v1/in/tag/create", data, {
headers: { "X-Service-Code": serviceCode },
});
}
/** 태그 수정 */
export function updateTag(data: UpdateTagRequest, serviceCode: string) {
return apiClient.post<ApiResponse<TagResponse>>("/v1/in/tag/update", data, {
headers: { "X-Service-Code": serviceCode },
});
}
/** 태그 삭제 */
export function deleteTag(data: DeleteTagRequest, serviceCode: string) {
return apiClient.post<ApiResponse<null>>("/v1/in/tag/delete", data, {
headers: { "X-Service-Code": serviceCode },
});
}

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,28 @@
type CategoryVariant = "success" | "warning" | "error" | "info" | "default";
interface CategoryBadgeProps {
variant: CategoryVariant;
icon: string;
label: string;
}
const VARIANT_STYLES: Record<CategoryVariant, string> = {
success: "bg-green-50 text-green-700",
warning: "bg-amber-50 text-amber-700",
error: "bg-red-50 text-red-700",
info: "bg-blue-50 text-blue-700",
default: "bg-gray-100 text-gray-600",
};
/** 카테고리 뱃지 (아이콘 + 텍스트 패턴) */
export default function CategoryBadge({ variant, icon, label }: CategoryBadgeProps) {
return (
<span
className={`inline-flex flex-shrink-0 items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-semibold ${VARIANT_STYLES[variant]}`}
>
<span className="material-symbols-outlined" style={{ fontSize: "10px" }}>{icon}</span>
{label}
</span>
);
}

View File

@ -0,0 +1,37 @@
import { useState } from "react";
import { toast } from "sonner";
interface CopyButtonProps {
text: string;
}
/** 클립보드 복사 + 피드백 */
export default function CopyButton({ text }: CopyButtonProps) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
toast.success("클립보드에 복사되었습니다");
setTimeout(() => setCopied(false), 1500);
} catch {
toast.error("복사에 실패했습니다");
}
};
return (
<button
onClick={handleCopy}
className="inline-flex items-center justify-center size-7 rounded-md text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors"
title="복사"
>
<span
className="material-symbols-outlined"
style={{ fontSize: "14px" }}
>
{copied ? "check" : "content_copy"}
</span>
</button>
);
}

View File

@ -0,0 +1,74 @@
interface DateRangeInputProps {
startDate: string;
endDate: string;
onStartChange: (value: string) => void;
onEndChange: (value: string) => void;
startLabel?: string;
endLabel?: string;
disabled?: boolean;
}
/** 시작일~종료일 날짜 입력 (자동 보정) */
export default function DateRangeInput({
startDate,
endDate,
onStartChange,
onEndChange,
startLabel = "시작일",
endLabel = "종료일",
disabled,
}: DateRangeInputProps) {
const handleStartChange = (value: string) => {
onStartChange(value);
if (endDate && value > endDate) onEndChange(value);
};
const handleEndChange = (value: string) => {
onEndChange(value);
if (startDate && value < startDate) onStartChange(value);
};
return (
<>
{/* 시작일 */}
<div className="flex-1">
<label className="block text-xs font-medium text-gray-600 mb-1.5">
{startLabel}
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 material-symbols-outlined text-gray-400 text-base">
calendar_today
</span>
<input
type="date"
value={startDate}
onChange={(e) => handleStartChange(e.target.value)}
disabled={disabled}
className="w-full h-[38px] pl-10 pr-3 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-gray-50"
/>
</div>
</div>
<span className="text-gray-400 text-sm font-medium pb-2">~</span>
{/* 종료일 */}
<div className="flex-1">
<label className="block text-xs font-medium text-gray-600 mb-1.5">
{endLabel}
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 material-symbols-outlined text-gray-400 text-base">
calendar_today
</span>
<input
type="date"
value={endDate}
onChange={(e) => handleEndChange(e.target.value)}
disabled={disabled}
className="w-full h-[38px] pl-10 pr-3 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-gray-50"
/>
</div>
</div>
</>
);
}

View File

@ -0,0 +1,27 @@
import type { ReactNode } from "react";
interface EmptyStateProps {
icon?: string;
message: string;
description?: string;
action?: ReactNode;
}
/** 빈 데이터 상태 표시 */
export default function EmptyState({ icon = "inbox", message, description, action }: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<span
className="material-symbols-outlined text-gray-300 mb-4"
style={{ fontSize: "48px" }}
>
{icon}
</span>
<p className="text-sm font-medium text-gray-500 mb-1">{message}</p>
{description && (
<p className="text-xs text-gray-400 mb-4">{description}</p>
)}
{action && <div className="mt-2">{action}</div>}
</div>
);
}

View File

@ -0,0 +1,73 @@
import { useState, useRef, useEffect } from "react";
interface FilterDropdownProps {
label?: string;
value: string;
options: string[];
onChange: (value: string) => void;
className?: string;
disabled?: boolean;
}
/** 커스텀 셀렉트 드롭다운 (외부클릭 닫기) */
export default function FilterDropdown({
label,
value,
options,
onChange,
className = "",
disabled,
}: FilterDropdownProps) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
// 외부 클릭 시 닫기
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("click", handleClick);
return () => document.removeEventListener("click", handleClick);
}, []);
return (
<div className={`relative ${className}`} ref={ref}>
{label && (
<label className="block text-xs font-medium text-gray-600 mb-1.5">
{label}
</label>
)}
<button
type="button"
onClick={() => !disabled && setOpen((v) => !v)}
disabled={disabled}
className="w-full h-[38px] border border-gray-300 rounded-lg px-3 text-sm text-center flex items-center justify-between bg-white hover:border-gray-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:hover:border-gray-300"
>
<span className="truncate flex-1">{value}</span>
<span className="material-symbols-outlined text-gray-400 text-[18px] flex-shrink-0">
expand_more
</span>
</button>
{open && (
<ul className="absolute left-0 top-full mt-1 w-full bg-white border border-gray-200 rounded-lg shadow-lg z-50 py-1 overflow-hidden">
{options.map((opt) => (
<li
key={opt}
onClick={() => {
onChange(opt);
setOpen(false);
}}
className={`px-3 py-2 text-sm text-center hover:bg-gray-50 cursor-pointer transition-colors ${
opt === value ? "text-primary font-medium" : ""
}`}
>
{opt}
</li>
))}
</ul>
)}
</div>
);
}

View File

@ -0,0 +1,32 @@
import { useState } from "react";
interface FilterResetButtonProps {
onClick: () => void;
disabled?: boolean;
}
/** 초기화 버튼 (스핀 애니메이션 + 툴팁) */
export default function FilterResetButton({ onClick, disabled }: FilterResetButtonProps) {
const [spinning, setSpinning] = useState(false);
const handleClick = () => {
onClick();
setSpinning(true);
setTimeout(() => setSpinning(false), 500);
};
return (
<button
onClick={handleClick}
disabled={disabled}
className="filter-reset-btn h-[38px] w-[38px] flex-shrink-0 flex items-center justify-center border border-gray-300 rounded-lg text-gray-500 hover:text-red-500 hover:border-red-300 hover:bg-red-50 transition-colors relative disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:text-gray-500 disabled:hover:border-gray-300 disabled:hover:bg-transparent"
>
<span className="reset-tooltip"> </span>
<span
className={`material-symbols-outlined text-lg${spinning ? " spinning" : ""}`}
>
restart_alt
</span>
</button>
);
}

View File

@ -0,0 +1,22 @@
import type { ReactNode } from "react";
interface PageHeaderProps {
title: string;
description?: string;
action?: ReactNode;
}
/** 페이지 제목 + 설명 + 우측 액션 버튼 */
export default function PageHeader({ title, description, action }: PageHeaderProps) {
return (
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-[#0f172a]">{title}</h1>
{description && (
<p className="text-[#64748b] text-sm mt-1">{description}</p>
)}
</div>
{action && <div>{action}</div>}
</div>
);
}

View File

@ -0,0 +1,104 @@
interface PaginationProps {
currentPage: number;
totalPages: number;
totalItems: number;
pageSize: number;
onPageChange: (page: number) => void;
}
/** 테이블 페이지네이션 */
export default function Pagination({
currentPage,
totalPages,
totalItems,
pageSize,
onPageChange,
}: PaginationProps) {
const safeTotal = totalItems ?? 0;
const start = safeTotal > 0 ? (currentPage - 1) * pageSize + 1 : 0;
const end = Math.min(currentPage * pageSize, safeTotal);
/** 표시할 페이지 번호 목록 (최대 5개) */
const getPageNumbers = () => {
const pages: number[] = [];
let startPage = Math.max(1, currentPage - 2);
const endPage = Math.min(totalPages, startPage + 4);
startPage = Math.max(1, endPage - 4);
for (let i = startPage; i <= endPage; i++) {
pages.push(i);
}
return pages;
};
if (totalPages <= 0) return null;
return (
<div className="flex items-center justify-between px-6 py-3">
<span className="text-sm text-gray-500">
{safeTotal.toLocaleString()} {start.toLocaleString()}-{end.toLocaleString()}
</span>
<div className="flex items-center gap-1">
{/* 처음 */}
<button
onClick={() => onPageChange(1)}
disabled={currentPage === 1}
className="size-8 flex items-center justify-center rounded-lg text-sm text-gray-500 hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<span className="material-symbols-outlined" style={{ fontSize: "18px" }}>
first_page
</span>
</button>
{/* 이전 */}
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
className="size-8 flex items-center justify-center rounded-lg text-sm text-gray-500 hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<span className="material-symbols-outlined" style={{ fontSize: "18px" }}>
chevron_left
</span>
</button>
{/* 페이지 번호 */}
{getPageNumbers().map((page) => (
<button
key={page}
onClick={() => onPageChange(page)}
className={`size-8 flex items-center justify-center rounded-lg text-sm font-medium transition-colors ${
page === currentPage
? "bg-primary text-white"
: "text-gray-600 hover:bg-gray-100"
}`}
>
{page}
</button>
))}
{/* 다음 */}
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className="size-8 flex items-center justify-center rounded-lg text-sm text-gray-500 hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<span className="material-symbols-outlined" style={{ fontSize: "18px" }}>
chevron_right
</span>
</button>
{/* 마지막 */}
<button
onClick={() => onPageChange(totalPages)}
disabled={currentPage === totalPages}
className="size-8 flex items-center justify-center rounded-lg text-sm text-gray-500 hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<span className="material-symbols-outlined" style={{ fontSize: "18px" }}>
last_page
</span>
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,22 @@
interface PlatformBadgeProps {
platform: "ios" | "android";
}
/** iOS/Android 아이콘 뱃지 */
export default function PlatformBadge({ platform }: PlatformBadgeProps) {
if (platform === "ios") {
return (
<span className="inline-flex items-center justify-center w-8 h-6 rounded text-xs font-medium bg-gray-100 text-gray-700 border border-gray-200">
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" />
</svg>
</span>
);
}
return (
<span className="inline-flex items-center justify-center w-8 h-6 rounded text-xs font-medium bg-green-50 text-green-700 border border-green-200">
<span className="material-symbols-outlined" style={{ fontSize: "16px", lineHeight: "1" }}>android</span>
</span>
);
}

View File

@ -0,0 +1,39 @@
interface SearchInputProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
label?: string;
disabled?: boolean;
}
/** 검색 아이콘 + 텍스트 입력 */
export default function SearchInput({
value,
onChange,
placeholder = "검색...",
label,
disabled,
}: SearchInputProps) {
return (
<div className="flex-1">
{label && (
<label className="block text-xs font-medium text-gray-600 mb-1.5">
{label}
</label>
)}
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 material-symbols-outlined text-gray-400 text-base">
search
</span>
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
disabled={disabled}
className="w-full h-[38px] pl-10 pr-3 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-gray-50"
/>
</div>
</div>
);
}

View File

@ -0,0 +1,44 @@
type StatusVariant = "success" | "error" | "warning" | "info" | "default";
interface StatusBadgeProps {
variant: StatusVariant;
label: string;
}
const VARIANT_STYLES: Record<StatusVariant, { badge: string; dot: string }> = {
success: {
badge: "bg-green-50 text-green-700 border-green-200",
dot: "bg-green-500",
},
error: {
badge: "bg-red-50 text-red-700 border-red-200",
dot: "bg-red-500",
},
warning: {
badge: "bg-yellow-50 text-yellow-700 border-yellow-200",
dot: "bg-yellow-500",
},
info: {
badge: "bg-blue-50 text-blue-700 border-blue-200",
dot: "bg-blue-500",
},
default: {
badge: "bg-gray-100 text-gray-600 border-gray-200",
dot: "bg-gray-400",
},
};
/** 상태 dot 뱃지 (완료/실패/진행/예약/활성/비활성) */
export default function StatusBadge({ variant, label }: StatusBadgeProps) {
// variant가 유효하지 않으면 default로 폴백
const style = VARIANT_STYLES[variant] ?? VARIANT_STYLES.default;
return (
<span
className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium border ${style.badge}`}
>
<span className={`size-1.5 rounded-full ${style.dot}`} />
{label}
</span>
);
}

View File

@ -0,0 +1,52 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
/** React Error Boundary */
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("ErrorBoundary caught:", error, errorInfo);
}
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 p-6">
<h1 className="text-2xl font-bold text-destructive"> </h1>
<p className="text-muted-foreground">
{this.state.error?.message ?? "알 수 없는 오류"}
</p>
<button
onClick={() => window.location.reload()}
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
</button>
</div>
);
}
return this.props.children;
}
}

View File

@ -0,0 +1,228 @@
import { useState, useRef, useEffect } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import CategoryBadge from "@/components/common/CategoryBadge";
import {
MOCK_NOTIFICATIONS as SHARED_NOTIFICATIONS,
type NotificationType,
} from "@/features/settings/types";
/** 경로 → breadcrumb 레이블 매핑 (정적 경로) */
const pathLabels: Record<string, string> = {
"/dashboard": "대시보드",
"/services": "서비스 관리",
"/services/register": "서비스 등록",
"/messages": "메시지 관리",
"/messages/register": "메시지 등록",
"/statistics": "발송 통계",
"/statistics/history": "발송 이력",
"/devices": "기기 관리",
"/tags": "태그 관리",
"/settings": "마이 페이지",
"/settings/profile": "프로필 수정",
"/settings/notifications": "알림",
};
/** 그룹 경로 → 그룹 라벨 (사이드바 그룹명과 매핑) */
const groupLabels: Record<string, string> = {
"/statistics": "발송 관리",
};
/**
*
* pattern으로 pathname을 crumbs
*/
const dynamicPatterns: {
pattern: RegExp;
crumbs: (match: RegExpMatchArray) => { path: string; label: string }[];
}[] = [
{
// /services/:id 또는 /services/:id/edit (register 제외)
pattern: /^\/services\/(?!register$)([^/]+)(\/edit)?$/,
crumbs: (match) => {
const id = match[1];
const isEdit = !!match[2];
const result = [{ path: `/services/${id}`, label: "서비스 상세" }];
if (isEdit) {
result.push({ path: `/services/${id}/edit`, label: "서비스 수정" });
}
return result;
},
},
];
/** pathname → breadcrumb 배열 생성 */
function buildBreadcrumbs(pathname: string) {
const segments = pathname.split("/").filter(Boolean);
const crumbs: { path: string; label: string }[] = [];
// 1) 정적 경로 매칭 (누적 경로 기반)
const isLastSegment = (i: number) => i === segments.length - 1;
let currentPath = "";
for (let i = 0; i < segments.length; i++) {
currentPath += `/${segments[i]}`;
const groupLabel = groupLabels[currentPath];
if (groupLabel) {
crumbs.push({ path: currentPath, label: groupLabel });
// 마지막 세그먼트일 때만 페이지 라벨도 추가
if (isLastSegment(i)) {
const label = pathLabels[currentPath];
if (label) crumbs.push({ path: currentPath, label });
}
} else {
const label = pathLabels[currentPath];
if (label) crumbs.push({ path: currentPath, label });
}
}
// 2) 동적 경로 패턴 매칭
for (const { pattern, crumbs: buildDynamic } of dynamicPatterns) {
const match = pathname.match(pattern);
if (match) {
crumbs.push(...buildDynamic(match));
}
}
return crumbs;
}
/* ── NotificationType → CategoryBadge 매핑 ── */
const NOTI_BADGE_MAP: Record<
NotificationType,
{ variant: "success" | "warning" | "error" | "info" | "default"; icon: string }
> = {
"발송": { variant: "success", icon: "check_circle" },
"인증서": { variant: "warning", icon: "verified" },
"실패": { variant: "error", icon: "error" },
"서비스": { variant: "info", icon: "cloud" },
"시스템": { variant: "default", icon: "settings" },
};
/** 헤더 드롭다운에 표시할 최근 알림 (최대 5개) */
const HEADER_NOTIFICATIONS = SHARED_NOTIFICATIONS.slice(0, 5);
export default function AppHeader() {
const { pathname } = useLocation();
const navigate = useNavigate();
const crumbs = buildBreadcrumbs(pathname);
const [notiOpen, setNotiOpen] = useState(false);
const notiRef = useRef<HTMLDivElement>(null);
// 외부 클릭 시 드롭다운 닫기
useEffect(() => {
function handleClick(e: MouseEvent) {
if (notiRef.current && !notiRef.current.contains(e.target as Node)) {
setNotiOpen(false);
}
}
document.addEventListener("click", handleClick);
return () => document.removeEventListener("click", handleClick);
}, []);
// 페이지 이동 시 닫기
useEffect(() => {
setNotiOpen(false);
}, [pathname]);
return (
<header className="fixed left-64 right-0 top-0 z-40 flex h-16 items-center justify-between border-b border-gray-200 bg-white px-10 shadow-sm">
{/* 브레드크럼 (다단계) */}
<nav className="flex min-w-0 flex-1 items-center overflow-hidden text-sm">
{crumbs.length === 0 ? (
<span className="font-medium text-foreground">Home</span>
) : (
<>
<Link to="/" className="flex-shrink-0 whitespace-nowrap text-gray-500 transition-colors hover:text-primary">Home</Link>
{crumbs.map((crumb, i) => {
const isLast = i === crumbs.length - 1;
return (
<span key={crumb.path} className="flex flex-shrink-0 items-center">
<span className="material-symbols-outlined mx-2 text-base text-gray-300">chevron_right</span>
{isLast ? (
<span className="whitespace-nowrap font-medium text-foreground">{crumb.label}</span>
) : (
<Link to={crumb.path} className="whitespace-nowrap text-gray-500 transition-colors hover:text-primary">
{crumb.label}
</Link>
)}
</span>
);
})}
</>
)}
</nav>
{/* 우측 아이콘 */}
<div className="ml-auto flex flex-shrink-0 items-center gap-5 pl-4 text-gray-400">
{/* 알림 버튼 + 드롭다운 */}
<div className="relative" ref={notiRef}>
<button
onClick={() => setNotiOpen((v) => !v)}
className="group relative transition-colors hover:text-primary"
>
<span className="material-symbols-outlined text-2xl group-hover:animate-pulse">
notifications
</span>
<span className="absolute right-0.5 top-0.5 size-2 rounded-full border-2 border-white bg-red-500" />
</button>
{/* 알림 드롭다운 패널 */}
{notiOpen && (
<div className="absolute right-0 top-full mt-2 w-96 overflow-hidden rounded-xl border border-gray-200 bg-white shadow-lg z-50">
{/* 헤더 */}
<div className="flex items-center justify-between border-b border-gray-100 px-4 py-3">
<h3 className="text-sm font-bold text-foreground"></h3>
<Link
to="/settings/notifications"
className="text-xs font-medium text-primary transition-colors hover:text-[#1d4ed8]"
>
</Link>
</div>
{/* 알림 목록 */}
<div className="overflow-y-auto" style={{ maxHeight: 200, overscrollBehavior: "contain" }}>
{HEADER_NOTIFICATIONS.map((noti) => {
const badge = NOTI_BADGE_MAP[noti.type];
return (
<div
key={noti.id}
onClick={() => {
setNotiOpen(false);
navigate("/settings/notifications", {
state: { notificationId: noti.id },
});
}}
className={`cursor-pointer border-b border-gray-50 px-4 py-3 transition-colors hover:bg-gray-50 ${
!noti.read ? "bg-blue-50/20" : ""
}`}
>
<div className="mb-1 flex items-center gap-2">
<CategoryBadge variant={badge.variant} icon={badge.icon} label={noti.type} />
<p className={`flex-1 truncate text-xs text-foreground ${!noti.read ? "font-bold" : "font-medium"}`}>
{noti.title}
</p>
<span className="flex-shrink-0 text-[10px] text-gray-400">{noti.time}</span>
</div>
<p className="truncate text-[11px] text-gray-500">{noti.description}</p>
</div>
);
})}
</div>
</div>
)}
</div>
{/* 구분선 */}
<div className="h-6 w-px bg-gray-200" />
{/* 프로필 */}
<Link
to="/settings"
className="flex items-center gap-2 transition-colors hover:text-primary"
>
<span className="material-symbols-outlined text-2xl">account_circle</span>
</Link>
</div>
</header>
);
}

View File

@ -0,0 +1,111 @@
import { useState } from "react";
import { Outlet } from "react-router-dom";
import AppSidebar from "./AppSidebar";
import AppHeader from "./AppHeader";
import useBreadcrumbBack from "@/hooks/useBreadcrumbBack";
export default function AppLayout() {
useBreadcrumbBack();
const [termsModal, setTermsModal] = useState(false);
const [privacyModal, setPrivacyModal] = useState(false);
return (
<div className="flex min-h-screen w-full flex-row overflow-x-hidden">
{/* 사이드바 (w-64 고정) */}
<AppSidebar />
{/* 메인 영역 */}
<main className="ml-64 flex min-h-screen flex-1 flex-col bg-gray-50">
{/* 헤더 (h-16 고정) */}
<AppHeader />
{/* 컨텐츠 */}
<div className="mx-auto w-full max-w-6xl px-10 pb-12 pt-28">
<Outlet />
</div>
{/* 푸터 */}
<footer className="mt-auto flex items-center justify-between border-t border-gray-100 px-10 py-6 text-sm text-gray-400">
<p>&copy; 2026 Team.Stein. All rights reserved.</p>
<div className="flex items-center gap-4">
<button
onClick={() => setPrivacyModal(true)}
className="transition-colors hover:text-foreground"
>
</button>
<span className="text-gray-300">|</span>
<button
onClick={() => setTermsModal(true)}
className="transition-colors hover:text-foreground"
>
</button>
</div>
</footer>
</main>
{/* ───── 서비스 이용약관 모달 ───── */}
{termsModal && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
onClick={(e) => {
if (e.target === e.currentTarget) setTermsModal(false);
}}
>
<div className="w-full max-w-md rounded-xl bg-white p-6 shadow-2xl">
<div className="mb-4 flex items-center gap-3">
<div className="flex size-10 flex-shrink-0 items-center justify-center rounded-full bg-blue-100">
<span className="material-symbols-outlined text-xl text-blue-600">
description
</span>
</div>
<h3 className="flex-1 text-lg font-bold text-foreground">
</h3>
<button
type="button"
onClick={() => setTermsModal(false)}
className="flex-shrink-0 rounded-full p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-foreground"
>
<span className="material-symbols-outlined" style={{ fontSize: "20px" }}>close</span>
</button>
</div>
<div className="h-72 w-full rounded-lg border border-gray-200 bg-gray-50" />
</div>
</div>
)}
{/* ───── 개인정보 처리방침 모달 ───── */}
{privacyModal && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
onClick={(e) => {
if (e.target === e.currentTarget) setPrivacyModal(false);
}}
>
<div className="w-full max-w-md rounded-xl bg-white p-6 shadow-2xl">
<div className="mb-4 flex items-center gap-3">
<div className="flex size-10 flex-shrink-0 items-center justify-center rounded-full bg-blue-100">
<span className="material-symbols-outlined text-xl text-blue-600">
shield
</span>
</div>
<h3 className="flex-1 text-lg font-bold text-foreground">
</h3>
<button
type="button"
onClick={() => setPrivacyModal(false)}
className="flex-shrink-0 rounded-full p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-foreground"
>
<span className="material-symbols-outlined" style={{ fontSize: "20px" }}>close</span>
</button>
</div>
<div className="h-72 w-full rounded-lg border border-gray-200 bg-gray-50" />
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,202 @@
import { useState, useEffect } from "react";
import { Link, NavLink, useLocation } from "react-router-dom";
import { useAuthStore } from "@/stores/authStore";
/** 단일 메뉴 항목 */
interface NavItem {
to: string;
icon: string;
label: string;
}
/** 드롭다운 메뉴 그룹 */
interface NavGroup {
icon: string;
label: string;
children: { to: string; label: string }[];
}
type NavEntry =
| { type: "item"; item: NavItem }
| { type: "group"; group: NavGroup }
| { type: "separator" };
/** 네비게이션 메뉴 정의 (가이드라인 기준) */
const navEntries: NavEntry[] = [
{ type: "item", item: { to: "/dashboard", icon: "dashboard", label: "대시보드" } },
{ type: "item", item: { to: "/services", icon: "manage_accounts", label: "서비스 관리" } },
{ type: "item", item: { to: "/messages", icon: "mail", label: "메시지 관리" } },
{ type: "item", item: { to: "/devices", icon: "devices", label: "기기 관리" } },
{
type: "group",
group: {
icon: "send",
label: "발송 관리",
children: [
{ to: "/statistics", label: "발송 통계" },
{ to: "/statistics/history", label: "발송 이력" },
],
},
},
{ type: "item", item: { to: "/tags", icon: "sell", label: "태그 관리" } },
{ type: "separator" },
{
type: "group",
group: {
icon: "settings",
label: "설정",
children: [
{ to: "/settings/notifications", label: "알림" },
{ to: "/settings", label: "마이 페이지" },
],
},
},
];
/** 단일 메뉴 아이템 */
function SidebarItem({ item }: { item: NavItem }) {
const location = useLocation();
const isActive = location.pathname === item.to || location.pathname.startsWith(item.to + "/");
return (
<NavLink
to={item.to}
className={`flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors group ${
isActive
? "bg-white/10 text-white"
: "text-gray-400 hover:bg-white/5 hover:text-white"
}`}
>
<span className="material-symbols-outlined text-xl transition-colors group-hover:text-white">
{item.icon}
</span>
<span className="flex-1">{item.label}</span>
</NavLink>
);
}
/** 드롭다운 메뉴 그룹 */
function SidebarGroup({ group }: { group: NavGroup }) {
const location = useLocation();
const isChildActive = group.children.some(
(child) => location.pathname === child.to || location.pathname.startsWith(child.to + "/"),
);
const [isOpen, setIsOpen] = useState(isChildActive);
// 경로 변경 시: 해당 그룹 자식이면 열고, 아니면 닫기
useEffect(() => {
setIsOpen(isChildActive);
}, [isChildActive]);
return (
<div>
<button
onClick={() => setIsOpen(!isOpen)}
className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors group ${
isChildActive
? "bg-white/10 text-white"
: "text-gray-400 hover:bg-white/5 hover:text-white"
}`}
>
<span className="material-symbols-outlined text-xl transition-colors group-hover:text-white">
{group.icon}
</span>
<span className="flex-1 text-left">{group.label}</span>
<span
className="material-symbols-outlined text-base transition-transform"
style={{ transform: isOpen ? "rotate(180deg)" : "rotate(0deg)" }}
>
keyboard_arrow_down
</span>
</button>
{isOpen && (
<div className="mt-1 flex flex-col gap-1 pl-9">
{group.children.map((child) => {
const isActive = location.pathname === child.to;
return (
<NavLink
key={child.to}
to={child.to}
className={`flex items-center gap-2 rounded-lg px-3 py-1.5 text-sm transition-colors ${
isActive
? "bg-white/5 text-primary"
: "text-gray-500 hover:bg-white/5 hover:text-gray-300"
}`}
>
<span
className={`h-1.5 w-1.5 rounded-full ${
isActive ? "bg-primary" : "bg-gray-500"
}`}
/>
<span>{child.label}</span>
</NavLink>
);
})}
</div>
)}
</div>
);
}
export default function AppSidebar() {
const { user, logout } = useAuthStore();
return (
<aside className="fixed left-0 top-0 z-50 flex h-screen w-64 flex-shrink-0 flex-col justify-between border-r border-gray-800 bg-sidebar text-white">
<div className="flex flex-col gap-6 p-6">
{/* 로고 → 홈 이동 */}
<Link to="/" className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-lg border border-primary/30 bg-primary/20">
<span className="material-symbols-outlined text-2xl text-white">grid_view</span>
</div>
<div className="flex flex-col">
<h1 className="text-base font-bold leading-none tracking-tight text-white">SPMS</h1>
<p className="mt-1 text-xs font-normal text-gray-400">Admin Console</p>
</div>
</Link>
{/* 네비게이션 */}
<nav className="flex flex-col gap-1">
{navEntries.map((entry, idx) => {
if (entry.type === "separator") {
return <div key={idx} className="mx-3 my-2 h-px bg-gray-800" />;
}
if (entry.type === "item") {
return <SidebarItem key={entry.item.to} item={entry.item} />;
}
return <SidebarGroup key={entry.group.label} group={entry.group} />;
})}
</nav>
</div>
{/* 사용자 프로필 */}
<div className="border-t border-gray-800 p-4">
<div className="flex items-center gap-3 px-2">
<Link
to="/settings"
className="flex size-9 items-center justify-center rounded-full bg-blue-600 text-xs font-bold tracking-wide text-white shadow-sm hover:bg-blue-500 transition-colors"
title="마이 페이지"
>
{user?.name?.slice(0, 2)?.toUpperCase() ?? "AU"}
</Link>
<div className="flex-1 overflow-hidden" style={{ containerType: "inline-size" }}>
<p className="truncate text-sm font-medium text-white">
{user?.name ?? "Admin User"}
</p>
<p className="text-gray-400" style={{ fontSize: "clamp(9px, 2.5cqi, 12px)" }}>
{user?.email ?? "admin@spms.com"}
</p>
</div>
<button
onClick={logout}
className="rounded p-1 text-gray-400 transition-colors hover:bg-white/5 hover:text-white"
title="로그아웃"
>
<span className="material-symbols-outlined text-[20px]">logout</span>
</button>
</div>
</div>
</aside>
);
}

View File

@ -0,0 +1,19 @@
import { Outlet } from "react-router-dom";
export default function AuthLayout() {
return (
<div className="flex min-h-screen flex-col items-center bg-sidebar p-4 antialiased">
{/* 콘텐츠 중앙 정렬 */}
<div className="flex flex-1 w-full items-center justify-center">
<Outlet />
</div>
{/* 풋터 */}
<footer className="pb-6 w-full text-center">
<p className="text-xs font-medium tracking-wide text-gray-500">
&copy; 2026 Stein Co., Ltd.
</p>
</footer>
</div>
);
}

View File

@ -0,0 +1,66 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }

View File

@ -0,0 +1,107 @@
import * as React from "react"
import { Avatar as AvatarPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Avatar({
className,
size = "default",
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 overflow-hidden rounded-full select-none data-[size=lg]:size-10 data-[size=sm]:size-6",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted text-muted-foreground flex size-full items-center justify-center rounded-full text-sm group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full ring-2 select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"*:data-[slot=avatar]:ring-background group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"bg-muted text-muted-foreground ring-background relative flex size-8 shrink-0 items-center justify-center rounded-full text-sm ring-2 group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarBadge,
AvatarGroup,
AvatarGroupCount,
}

View File

@ -0,0 +1,48 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@ -0,0 +1,64 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import { CheckIcon } from "lucide-react"
import { Checkbox as CheckboxPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@ -0,0 +1,158 @@
"use client"
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@ -0,0 +1,255 @@
import * as React from "react"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@ -0,0 +1,165 @@
import * as React from "react"
import type { Label as LabelPrimitive } from "radix-ui"
import { Slot } from "radix-ui"
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
)
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField()
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot.Root>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot.Root
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : props.children
if (!body) {
return null
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
</p>
)
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@ -0,0 +1,168 @@
import * as React from "react"
import { cva } from "class-variance-authority"
import { ChevronDownIcon } from "lucide-react"
import { NavigationMenu as NavigationMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function NavigationMenu({
className,
children,
viewport = true,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
viewport?: boolean
}) {
return (
<NavigationMenuPrimitive.Root
data-slot="navigation-menu"
data-viewport={viewport}
className={cn(
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
{viewport && <NavigationMenuViewport />}
</NavigationMenuPrimitive.Root>
)
}
function NavigationMenuList({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
return (
<NavigationMenuPrimitive.List
data-slot="navigation-menu-list"
className={cn(
"group flex flex-1 list-none items-center justify-center gap-1",
className
)}
{...props}
/>
)
}
function NavigationMenuItem({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
return (
<NavigationMenuPrimitive.Item
data-slot="navigation-menu-item"
className={cn("relative", className)}
{...props}
/>
)
}
const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1"
)
function NavigationMenuTrigger({
className,
children,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
return (
<NavigationMenuPrimitive.Trigger
data-slot="navigation-menu-trigger"
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDownIcon
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
)
}
function NavigationMenuContent({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
return (
<NavigationMenuPrimitive.Content
data-slot="navigation-menu-content"
className={cn(
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
className
)}
{...props}
/>
)
}
function NavigationMenuViewport({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
return (
<div
className={cn(
"absolute top-full left-0 isolate z-50 flex justify-center"
)}
>
<NavigationMenuPrimitive.Viewport
data-slot="navigation-menu-viewport"
className={cn(
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
className
)}
{...props}
/>
</div>
)
}
function NavigationMenuLink({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
return (
<NavigationMenuPrimitive.Link
data-slot="navigation-menu-link"
className={cn(
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function NavigationMenuIndicator({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
return (
<NavigationMenuPrimitive.Indicator
data-slot="navigation-menu-indicator"
className={cn(
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
className
)}
{...props}
>
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
</NavigationMenuPrimitive.Indicator>
)
}
export {
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
navigationMenuTriggerStyle,
}

View File

@ -0,0 +1,188 @@
import * as React from "react"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@ -0,0 +1,141 @@
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as SheetPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@ -0,0 +1,724 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { PanelLeftIcon } from "lucide-react"
import { Slot } from "radix-ui"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "div"
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot.Root : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}) {
const Comp = asChild ? Slot.Root : "a"
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}

View File

@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@ -0,0 +1,38 @@
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }

View File

@ -0,0 +1,114 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@ -0,0 +1,57 @@
"use client"
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@ -0,0 +1,136 @@
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { toast } from "sonner";
import useShake from "@/hooks/useShake";
const resetSchema = z.object({
email: z
.string()
.min(1, "이메일을 입력해주세요.")
.email("올바른 이메일 형식을 입력해주세요."),
});
type ResetForm = z.infer<typeof resetSchema>;
interface Props {
open: boolean;
onClose: () => void;
}
export default function ResetPasswordModal({ open, onClose }: Props) {
const { triggerShake, cls } = useShake();
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<ResetForm>({
resolver: zodResolver(resetSchema),
});
/* 유효성 통과 → 발송 처리 */
const onSubmit = (_data: ResetForm) => {
// TODO: API 연동
reset();
onClose();
toast.success("임시 비밀번호가 발송되었습니다.");
};
/* 유효성 실패 → shake */
const onError = (fieldErrors: typeof errors) => {
triggerShake(Object.keys(fieldErrors));
};
/* 닫기 */
const handleClose = () => {
reset();
onClose();
};
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
onClick={(e) => {
if (e.target === e.currentTarget) handleClose();
}}
>
<div className="w-full max-w-md overflow-hidden rounded-xl bg-white shadow-2xl">
{/* 헤더 */}
<div className="px-8 pt-8 pb-4">
<h2 className="mb-2 text-xl font-bold text-foreground">
</h2>
<p className="text-sm leading-relaxed text-gray-500">
.
</p>
</div>
{/* 폼 */}
<form
className="px-8 pb-8"
onSubmit={handleSubmit(onSubmit, onError)}
noValidate
>
<div className="space-y-6">
<div className="space-y-1.5">
<label className="block text-sm font-bold text-gray-700">
</label>
<input
type="email"
autoComplete="email"
placeholder="user@spms.com"
className={`w-full rounded border bg-white px-4 py-3 font-sans text-sm text-gray-900 placeholder-gray-400 transition-all focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary ${
errors.email
? "border-red-500 shadow-[0_0_0_2px_rgba(239,68,68,0.15)]"
: "border-gray-300"
} ${cls("email")}`}
{...register("email")}
/>
{errors.email && (
<div className="mt-1.5 flex items-center gap-1.5 text-xs text-red-600">
<span className="material-symbols-outlined flex-shrink-0"
style={{ fontSize: "14px" }}>
error
</span>
<span>{errors.email.message}</span>
</div>
)}
</div>
{/* 버튼 */}
<div className="flex gap-3 pt-2">
<button
type="button"
className="flex-1 rounded-lg border border-gray-300 bg-white py-3 px-4 text-sm font-bold text-foreground transition-all hover:bg-gray-50"
onClick={handleClose}
>
</button>
<button
type="submit"
className="flex-1 rounded-lg bg-primary py-3 px-4 text-sm font-bold text-white shadow-lg shadow-blue-500/20 transition-all hover:bg-[#1d4ed8] active:scale-[0.98]"
>
</button>
</div>
</div>
</form>
{/* 풋터 */}
<div className="border-t border-gray-100 bg-gray-50 px-8 py-4 text-center">
<p className="text-xs text-gray-400">
?{" "}
<button className="font-medium text-primary hover:underline">
</button>
</p>
</div>
</div>
</div>
);
}

View File

View File

@ -0,0 +1,327 @@
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Link, useNavigate } from "react-router-dom";
import { AxiosError } from "axios";
import { login } from "@/api/auth.api";
import type { ApiError } from "@/types/api";
import { useAuthStore } from "@/stores/authStore";
import useShake from "@/hooks/useShake";
import ResetPasswordModal from "../components/ResetPasswordModal";
/* ───── zod 스키마 ───── */
const loginSchema = z.object({
email: z
.string()
.min(1, "이메일을 입력해주세요.")
.email("올바른 이메일 형식을 입력해주세요."),
password: z.string().min(1, "비밀번호를 입력해주세요."),
});
type LoginForm = z.infer<typeof loginSchema>;
export default function LoginPage() {
const navigate = useNavigate();
const setAuth = useAuthStore((s) => s.setAuth);
const [showPassword, setShowPassword] = useState(false);
const [resetOpen, setResetOpen] = useState(false);
const { triggerShake, cls } = useShake();
const [isLoading, setIsLoading] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
const [loginSuccess, setLoginSuccess] = useState(false);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginForm>({
resolver: zodResolver(loginSchema),
});
/* 유효성 통과 → 로그인 처리 */
const onSubmit = async (data: LoginForm) => {
setLoginError(null);
setIsLoading(true);
try {
const res = await login({ email: data.email, password: data.password });
const d = res.data.data;
setIsLoading(false);
setLoginSuccess(true);
switch (d.next_action) {
case "GO_DASHBOARD":
/* 토큰 & 유저 정보 저장 → 홈 이동 */
setAuth(
{
adminCode: d.admin.admin_code ?? "",
email: d.admin.email ?? "",
name: d.admin.name ?? "",
role: d.admin.role ?? "",
},
d.access_token ?? "",
d.refresh_token ?? "",
);
setTimeout(() => navigate("/", { replace: true }), 1000);
break;
case "VERIFY_EMAIL":
/* 이메일 미인증 → 인증 페이지 이동 */
setTimeout(
() =>
navigate("/verify-email", {
replace: true,
state: {
verifySessionId: d.verify_session_id,
accessToken: d.access_token,
refreshToken: d.refresh_token,
admin: d.admin,
},
}),
1000,
);
break;
case "CHANGE_PASSWORD":
/* 비밀번호 변경 필요 → 토큰 저장 후 비밀번호 변경으로 이동 */
setAuth(
{
adminCode: d.admin.admin_code ?? "",
email: d.admin.email ?? "",
name: d.admin.name ?? "",
role: d.admin.role ?? "",
},
d.access_token ?? "",
d.refresh_token ?? "",
);
setTimeout(() => navigate("/profile/edit", { replace: true }), 1000);
break;
default:
setTimeout(() => navigate("/", { replace: true }), 1000);
break;
}
} catch (err) {
setIsLoading(false);
const axiosErr = err as AxiosError<ApiError>;
const msg = axiosErr.response?.data?.msg;
setLoginError(msg ?? "이메일 또는 비밀번호가 올바르지 않습니다.");
triggerShake(["email", "password"]);
}
};
/* 유효성 실패 → shake */
const onError = (fieldErrors: typeof errors) => {
setLoginError(null);
triggerShake(Object.keys(fieldErrors));
};
return (
<>
<div className="z-10 w-full max-w-[420px] rounded-xl bg-white p-10 shadow-2xl">
{/* 로고 */}
<div className="mb-10 text-center">
<h1 className="mb-2 text-4xl font-black tracking-tight text-primary">
SPMS
</h1>
<p className="text-sm font-medium text-gray-500">
Stein Push Message Service
</p>
</div>
{/* 폼 */}
<form
className="space-y-6"
onSubmit={handleSubmit(onSubmit, onError)}
noValidate
>
{/* 이메일 */}
<div className="space-y-1.5">
<label
htmlFor="email"
className="block text-sm font-bold text-gray-700"
>
</label>
<input
id="email"
type="email"
autoComplete="email"
placeholder="user@spms.com"
className={`w-full rounded-lg border bg-white px-4 py-3 text-sm text-gray-900 placeholder-gray-400 transition-all focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/50 ${
errors.email
? "border-red-500 shadow-[0_0_0_2px_rgba(239,68,68,0.15)]"
: "border-gray-300"
} ${cls("email")}`}
{...register("email")}
/>
{errors.email && (
<div className="mt-1.5 flex items-center gap-1.5 text-xs text-red-600">
<span
className="material-symbols-outlined flex-shrink-0"
style={{ fontSize: "14px" }}
>
error
</span>
<span>{errors.email.message}</span>
</div>
)}
</div>
{/* 비밀번호 */}
<div className="space-y-1.5">
<label
htmlFor="password"
className="block text-sm font-bold text-gray-700"
>
</label>
<div className="relative flex items-center">
<input
id="password"
type={showPassword ? "text" : "password"}
placeholder="••••••••"
className={`w-full rounded-lg border bg-white px-4 py-3 pr-12 text-sm text-gray-900 placeholder-gray-400 transition-all focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/50 ${
errors.password
? "border-red-500 shadow-[0_0_0_2px_rgba(239,68,68,0.15)]"
: "border-gray-300"
} ${cls("password")}`}
{...register("password")}
/>
<button
type="button"
className="absolute right-3 flex items-center justify-center rounded-full p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600"
onClick={() => setShowPassword((v) => !v)}
>
<span className="material-symbols-outlined block text-[20px] leading-none">
{showPassword ? "visibility_off" : "visibility"}
</span>
</button>
</div>
{errors.password && (
<div className="mt-1.5 flex items-center gap-1.5 text-xs text-red-600">
<span
className="material-symbols-outlined flex-shrink-0"
style={{ fontSize: "14px" }}
>
error
</span>
<span>{errors.password.message}</span>
</div>
)}
</div>
{/* 로그인 실패 메시지 */}
{loginError && (
<div className="flex items-center gap-2 rounded-lg bg-red-50 px-4 py-3">
<span className="material-symbols-outlined flex-shrink-0 text-lg text-red-500">
error
</span>
<span className="text-sm font-medium text-red-600">
{loginError}
</span>
</div>
)}
{/* 로그인 버튼 */}
<div className="pt-2">
<button
type="submit"
disabled={isLoading}
className="w-full rounded-lg bg-primary py-3.5 px-4 font-bold text-white shadow-lg shadow-blue-500/20 transition-all hover:bg-[#1d4ed8] active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-70"
>
{isLoading ? (
<span className="inline-flex items-center gap-2">
<svg
className="size-5 animate-spin"
viewBox="0 0 24 24"
fill="none"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
...
</span>
) : (
"로그인"
)}
</button>
</div>
{/* 비밀번호 찾기 */}
<div className="text-center">
<button
type="button"
className="text-sm font-medium text-gray-400 transition-colors hover:text-primary hover:underline"
onClick={() => setResetOpen(true)}
>
?
</button>
</div>
{/* 회원가입 */}
<div className="text-center">
<span className="text-sm text-gray-400"> ?</span>
<Link
to="/signup"
className="ml-1 text-sm font-semibold text-primary transition-colors hover:text-[#1d4ed8] hover:underline"
>
</Link>
</div>
</form>
</div>
{/* 비밀번호 재설정 모달 */}
<ResetPasswordModal
open={resetOpen}
onClose={() => setResetOpen(false)}
/>
{/* 로그인 성공 오버레이 */}
{loginSuccess && (
<div className="fixed inset-0 z-[100] animate-[fadeIn_0.3s_ease]">
<div className="absolute inset-0 bg-white/60" />
<div className="absolute left-1/2 top-6 z-10 -translate-x-1/2">
<div className="flex items-center gap-3 rounded-xl bg-green-600 px-6 py-3.5 text-white shadow-lg">
<svg
className="size-5 flex-shrink-0 animate-spin"
viewBox="0 0 24 24"
fill="none"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
<span className="text-sm font-medium"> </span>
</div>
</div>
</div>
)}
</>
);
}

View File

@ -0,0 +1,510 @@
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Link, useNavigate } from "react-router-dom";
import { AxiosError } from "axios";
import { signup } from "@/api/auth.api";
import type { ApiError } from "@/types/api";
import useShake from "@/hooks/useShake";
/* ───── 정규식 ───── */
const pwRegex =
/^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,}$/;
const nameRegex = /^[가-힣]{2,}$/;
const phoneRegex = /^\d{2,3}-\d{3,4}-\d{4}$/;
/* ───── zod 스키마 ───── */
const signupSchema = z
.object({
email: z
.string()
.min(1, "이메일을 입력해주세요.")
.email("올바른 이메일 형식이 아닙니다."),
password: z
.string()
.min(1, "비밀번호를 입력해주세요.")
.regex(pwRegex, "8자 이상, 영문/숫자/특수문자 포함"),
passwordConfirm: z.string().min(1, "비밀번호 확인을 입력해주세요."),
name: z
.string()
.min(1, "이름을 입력해주세요.")
.regex(nameRegex, "한글 2자 이상 입력해주세요."),
phone: z
.string()
.min(1, "전화번호를 입력해주세요.")
.regex(phoneRegex, "올바른 전화번호 형식이 아닙니다."),
agree: z.literal(true, { message: "약관에 동의해주세요." }),
})
.refine((data) => data.password === data.passwordConfirm, {
message: "비밀번호가 일치하지 않습니다.",
path: ["passwordConfirm"],
});
type SignupForm = z.infer<typeof signupSchema>;
export default function SignupPage() {
const navigate = useNavigate();
const { triggerShake, cls } = useShake();
const [emailSentModal, setEmailSentModal] = useState(false);
const [termsModal, setTermsModal] = useState(false);
const [privacyModal, setPrivacyModal] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [serverError, setServerError] = useState<string | null>(null);
const {
register,
handleSubmit,
watch,
setValue,
setError,
formState: { errors },
} = useForm<SignupForm>({
resolver: zodResolver(signupSchema),
mode: "onChange",
defaultValues: { agree: false as unknown as true },
});
const password = watch("password");
const passwordConfirm = watch("passwordConfirm");
/* 비밀번호 일치 상태 */
const pwMatchStatus =
!passwordConfirm
? null
: passwordConfirm === password
? "match"
: "mismatch";
/* 전화번호 자동 하이픈 */
const handlePhoneInput = (e: React.ChangeEvent<HTMLInputElement>) => {
let v = e.target.value.replace(/[^0-9]/g, "");
if (v.length > 3 && v.length <= 7) {
v = v.slice(0, 3) + "-" + v.slice(3);
} else if (v.length > 7) {
v = v.slice(0, 3) + "-" + v.slice(3, 7) + "-" + v.slice(7, 11);
}
setValue("phone", v, { shouldValidate: true });
};
/* 가입 요청 */
const onSubmit = async (data: SignupForm) => {
setIsLoading(true);
setServerError(null);
try {
await signup({
email: data.email,
password: data.password,
name: data.name,
phone: data.phone,
agreeTerms: data.agree,
agreePrivacy: data.agree,
});
setEmailSentModal(true);
} catch (err) {
const axiosErr = err as AxiosError<ApiError>;
const code = axiosErr.response?.data?.code;
const msg = axiosErr.response?.data?.msg;
if (axiosErr.response?.status === 409 || code === "EMAIL_DUPLICATE") {
setError("email", { message: msg ?? "이미 사용 중인 이메일입니다." });
triggerShake(["email"]);
} else {
setServerError(msg ?? "회원가입에 실패했습니다. 다시 시도해주세요.");
}
} finally {
setIsLoading(false);
}
};
/* 유효성 실패 → shake */
const onError = (fieldErrors: typeof errors) => {
triggerShake(Object.keys(fieldErrors));
};
/* 힌트 스타일 헬퍼 */
const hintClass = (field: keyof SignupForm, defaultMsg: string) => {
const err = errors[field];
const isError = !!err;
return {
className: `mt-1.5 flex items-center gap-1.5 text-xs ${isError ? "text-red-600" : "text-gray-500"}`,
icon: isError ? "error" : "info",
message: isError ? (err.message as string) : defaultMsg,
};
};
/* 입력 필드 공통 className */
const inputClass = (field: keyof SignupForm) =>
`w-full rounded border bg-white px-4 py-3 font-sans text-sm text-gray-900 placeholder-gray-400 transition-all focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary ${
errors[field]
? "border-red-500 shadow-[0_0_0_2px_rgba(239,68,68,0.15)]"
: "border-gray-300"
} ${cls(field)}`;
return (
<>
<div className="my-auto flex w-full max-w-[500px] flex-col items-center">
{/* 로고 */}
<div className="mb-8 text-center">
<h1 className="mb-2 text-4xl font-black tracking-tight text-primary">
SPMS
</h1>
<p className="text-sm font-medium text-gray-400">
Stein Push Message Service
</p>
</div>
{/* 카드 */}
<div className="w-full rounded-xl bg-white p-8 shadow-2xl md:p-10">
{/* 헤더 */}
<div className="mb-8 flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold text-foreground"></h2>
<p className="mt-1 text-sm text-gray-500">
.
</p>
</div>
<Link
to="/login"
className="rounded-lg p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600"
>
<span className="material-symbols-outlined text-2xl">close</span>
</Link>
</div>
{/* 폼 */}
<form
className="space-y-5"
onSubmit={handleSubmit(onSubmit, onError)}
noValidate
>
{/* 서버 에러 */}
{serverError && (
<div className="flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
<span
className="material-symbols-outlined flex-shrink-0"
style={{ fontSize: "14px" }}
>
error
</span>
<span>{serverError}</span>
</div>
)}
{/* 이메일 */}
<div>
<label className="mb-1.5 block text-sm font-bold text-gray-700">
</label>
<input
type="email"
autoComplete="email"
placeholder="user@spms.com"
className={inputClass("email")}
{...register("email")}
/>
{(() => {
const h = hintClass(
"email",
"인증 메일이 발송되므로 사용 가능한 이메일을 입력해주세요.",
);
return (
<div className={h.className}>
<span className="material-symbols-outlined flex-shrink-0"
style={{ fontSize: "14px" }}>
{h.icon}
</span>
<span>{h.message}</span>
</div>
);
})()}
</div>
{/* 비밀번호 */}
<div>
<label className="mb-1.5 block text-sm font-bold text-gray-700">
</label>
<input
type="password"
placeholder="비밀번호를 입력하세요"
className={inputClass("password")}
{...register("password")}
/>
{(() => {
const h = hintClass(
"password",
"8자 이상, 영문/숫자/특수문자 포함",
);
return (
<div className={h.className}>
<span className="material-symbols-outlined flex-shrink-0"
style={{ fontSize: "14px" }}>
{h.icon}
</span>
<span>{h.message}</span>
</div>
);
})()}
</div>
{/* 비밀번호 확인 */}
<div>
<label className="mb-1.5 block text-sm font-bold text-gray-700">
</label>
<input
type="password"
placeholder="비밀번호를 다시 입력하세요"
className={inputClass("passwordConfirm")}
{...register("passwordConfirm")}
/>
{pwMatchStatus === "mismatch" && (
<div className="mt-1.5 flex items-center gap-1.5 text-xs text-red-600">
<span className="material-symbols-outlined flex-shrink-0"
style={{ fontSize: "14px" }}>
error
</span>
<span> .</span>
</div>
)}
{pwMatchStatus === "match" && !errors.passwordConfirm && (
<div className="mt-1.5 flex items-center gap-1.5 text-xs text-green-600">
<span className="material-symbols-outlined flex-shrink-0"
style={{ fontSize: "14px" }}>
check_circle
</span>
<span> .</span>
</div>
)}
</div>
{/* 구분선 */}
<div className="my-1 border-t border-gray-200" />
{/* 이름 */}
<div>
<label className="mb-1.5 block text-sm font-bold text-gray-700">
</label>
<input
type="text"
autoComplete="one-time-code"
placeholder="홍길동"
className={inputClass("name")}
{...register("name")}
/>
{errors.name && (
<div className="mt-1.5 flex items-center gap-1.5 text-xs text-red-600">
<span className="material-symbols-outlined flex-shrink-0"
style={{ fontSize: "14px" }}>
error
</span>
<span>{errors.name.message}</span>
</div>
)}
</div>
{/* 전화번호 */}
<div>
<label className="mb-1.5 block text-sm font-bold text-gray-700">
</label>
<input
type="tel"
autoComplete="one-time-code"
maxLength={13}
placeholder="010-0000-0000"
className={inputClass("phone")}
{...register("phone", { onChange: handlePhoneInput })}
/>
{errors.phone && (
<div className="mt-1.5 flex items-center gap-1.5 text-xs text-red-600">
<span className="material-symbols-outlined flex-shrink-0"
style={{ fontSize: "14px" }}>
error
</span>
<span>{errors.phone.message}</span>
</div>
)}
</div>
{/* 약관 동의 */}
<div className="pt-2">
<label className="group flex cursor-pointer items-center gap-2.5">
<div className="relative flex flex-shrink-0 items-center justify-center">
<input
type="checkbox"
className={`peer size-5 cursor-pointer appearance-none rounded border-2 transition-all checked:border-primary checked:bg-primary ${
errors.agree ? "border-red-500" : "border-gray-300"
} ${cls("agree")}`}
{...register("agree")}
/>
<span className="material-symbols-outlined pointer-events-none absolute text-sm text-white opacity-0 peer-checked:opacity-100">
check
</span>
</div>
<span
className={`text-sm transition-colors ${
errors.agree ? "text-red-500" : "text-gray-600"
} ${cls("agree")}`}
>
<button
type="button"
onClick={() => setTermsModal(true)}
className="font-semibold text-primary underline hover:text-[#1d4ed8]"
>
</button>
{" 및 "}
<button
type="button"
onClick={() => setPrivacyModal(true)}
className="font-semibold text-primary underline hover:text-[#1d4ed8]"
>
</button>
.
</span>
</label>
</div>
{/* 가입 버튼 */}
<div className="pt-4">
<button
type="submit"
disabled={isLoading}
className="flex w-full items-center justify-center gap-2 rounded-lg bg-primary py-3.5 px-4 font-bold text-white shadow-lg shadow-blue-500/20 transition-all hover:bg-[#1d4ed8] active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-60"
>
{isLoading ? (
<>
<span className="material-symbols-outlined animate-spin text-lg">
progress_activity
</span>
</>
) : (
"가입하기"
)}
</button>
</div>
</form>
{/* 하단 로그인 링크 */}
<div className="mt-8 border-t border-gray-100 pt-6 text-center">
<p className="text-sm text-gray-500">
?
<Link
to="/login"
className="ml-1 font-semibold text-primary hover:text-[#1d4ed8] hover:underline"
>
</Link>
</p>
</div>
</div>
</div>
{/* ───── 인증 메일 전송 모달 ───── */}
{emailSentModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="w-full max-w-md rounded-xl bg-white p-6 shadow-2xl">
<div className="mb-4 flex items-center gap-3">
<div className="flex size-10 flex-shrink-0 items-center justify-center rounded-full bg-green-100">
<span className="material-symbols-outlined text-xl text-green-600">
mark_email_read
</span>
</div>
<h3 className="text-lg font-bold text-foreground">
</h3>
</div>
<p className="mb-2 text-sm text-foreground">
.
</p>
<div className="mb-5 rounded-lg border border-blue-200 bg-blue-50 px-4 py-3">
<div className="flex items-center gap-1.5 text-xs font-medium text-blue-700">
<span className="material-symbols-outlined flex-shrink-0"
style={{ fontSize: "14px" }}>
info
</span>
<span> .</span>
</div>
</div>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={() => navigate("/login", { replace: true })}
className="rounded bg-primary px-5 py-2.5 text-sm font-medium text-white shadow-sm shadow-primary/30 transition hover:bg-[#1d4ed8]"
>
</button>
</div>
</div>
</div>
)}
{/* ───── 서비스 이용약관 모달 ───── */}
{termsModal && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
onClick={(e) => {
if (e.target === e.currentTarget) setTermsModal(false);
}}
>
<div className="w-full max-w-md rounded-xl bg-white p-6 shadow-2xl">
<div className="mb-4 flex items-center gap-3">
<div className="flex size-10 flex-shrink-0 items-center justify-center rounded-full bg-blue-100">
<span className="material-symbols-outlined text-xl text-blue-600">
description
</span>
</div>
<h3 className="flex-1 text-lg font-bold text-foreground">
</h3>
<button
type="button"
onClick={() => setTermsModal(false)}
className="flex-shrink-0 rounded-full p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-foreground"
>
<span className="material-symbols-outlined" style={{ fontSize: "20px" }}>close</span>
</button>
</div>
<div className="h-72 w-full rounded-lg border border-gray-200 bg-gray-50" />
</div>
</div>
)}
{/* ───── 개인정보 처리방침 모달 ───── */}
{privacyModal && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
onClick={(e) => {
if (e.target === e.currentTarget) setPrivacyModal(false);
}}
>
<div className="w-full max-w-md rounded-xl bg-white p-6 shadow-2xl">
<div className="mb-4 flex items-center gap-3">
<div className="flex size-10 flex-shrink-0 items-center justify-center rounded-full bg-blue-100">
<span className="material-symbols-outlined text-xl text-blue-600">
shield
</span>
</div>
<h3 className="flex-1 text-lg font-bold text-foreground">
</h3>
<button
type="button"
onClick={() => setPrivacyModal(false)}
className="flex-shrink-0 rounded-full p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-foreground"
>
<span className="material-symbols-outlined" style={{ fontSize: "20px" }}>close</span>
</button>
</div>
<div className="h-72 w-full rounded-lg border border-gray-200 bg-gray-50" />
</div>
</div>
)}
</>
);
}

View File

@ -0,0 +1,403 @@
import { useState, useRef, useEffect } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { AxiosError } from "axios";
import { verifyEmail, resendVerifyEmail } from "@/api/auth.api";
import type { ApiError } from "@/types/api";
import { useAuthStore } from "@/stores/authStore";
import useShake from "@/hooks/useShake";
interface LocationState {
verifySessionId?: string;
accessToken?: string;
refreshToken?: string;
admin?: { admin_code: string | null; email: string | null; name: string | null; role: string | null };
}
export default function VerifyEmailPage() {
const navigate = useNavigate();
const location = useLocation();
const setAuth = useAuthStore((s) => s.setAuth);
const state = (location.state as LocationState) ?? {};
const verifySessionId = state.verifySessionId ?? null;
const [code, setCode] = useState<string[]>(Array(6).fill(""));
const [codeError, setCodeError] = useState<string | null>(null);
const { triggerShake, cls } = useShake();
const [successModal, setSuccessModal] = useState(false);
const [resendModal, setResendModal] = useState(false);
const [homeOverlay, setHomeOverlay] = useState(false);
const [resendCooldown, setResendCooldown] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
/* 첫 번째 칸에 포커스 */
useEffect(() => {
inputRefs.current[0]?.focus();
}, []);
/* 재전송 쿨다운 타이머 */
useEffect(() => {
if (resendCooldown <= 0) return;
const timer = setInterval(() => {
setResendCooldown((prev) => prev - 1);
}, 1000);
return () => clearInterval(timer);
}, [resendCooldown]);
const isFilled = code.every((c) => c.length === 1);
/* 입력 처리 */
const handleInput = (index: number, value: string) => {
const digit = value.replace(/[^0-9]/g, "");
const newCode = [...code];
newCode[index] = digit;
setCode(newCode);
setCodeError(null);
if (digit && index < 5) {
inputRefs.current[index + 1]?.focus();
}
};
/* 포커스 시 해당 칸부터 끝까지 초기화 */
const handleFocus = (index: number) => {
requestAnimationFrame(() => {
setCode((prev) => {
const newCode = [...prev];
for (let i = index; i < 6; i++) {
newCode[i] = "";
}
return newCode;
});
setCodeError(null);
});
};
/* 백스페이스 → 이전 칸 */
const handleKeyDown = (index: number, e: React.KeyboardEvent) => {
if (e.key === "Backspace" && !code[index] && index > 0) {
inputRefs.current[index - 1]?.focus();
}
};
/* 붙여넣기 */
const handlePaste = (e: React.ClipboardEvent) => {
e.preventDefault();
const paste = e.clipboardData.getData("text").replace(/[^0-9]/g, "");
if (paste.length >= 6) {
const newCode = paste.slice(0, 6).split("");
setCode(newCode);
setCodeError(null);
inputRefs.current[5]?.focus();
}
};
/* 인증하기 */
const handleVerify = async () => {
const fullCode = code.join("");
if (fullCode.length !== 6) return;
setIsLoading(true);
try {
await verifyEmail({
code: fullCode,
verify_session_id: verifySessionId,
});
setSuccessModal(true);
} catch (err) {
const axiosErr = err as AxiosError<ApiError>;
const msg = axiosErr.response?.data?.msg;
setCodeError(msg ?? "인증 코드가 올바르지 않습니다.");
triggerShake(["code"]);
} finally {
setIsLoading(false);
}
};
/* 재전송 */
const handleResend = async () => {
if (resendCooldown > 0 || !verifySessionId) return;
try {
const res = await resendVerifyEmail({
verify_session_id: verifySessionId,
});
const d = res.data.data;
setResendModal(true);
setResendCooldown(d.cooldown_seconds || 60);
setCode(Array(6).fill(""));
setCodeError(null);
inputRefs.current[0]?.focus();
} catch (err) {
const axiosErr = err as AxiosError<ApiError>;
const msg = axiosErr.response?.data?.msg;
setCodeError(msg ?? "재전송에 실패했습니다. 잠시 후 다시 시도해주세요.");
}
};
/* 재전송 모달 닫기 */
const closeResendModal = () => {
setResendModal(false);
inputRefs.current[0]?.focus();
};
/* 홈 이동 */
const goHome = () => {
setSuccessModal(false);
setHomeOverlay(true);
/* 인증 완료 → 인증 상태 저장 */
if (state.admin) {
setAuth(
{
adminCode: state.admin.admin_code ?? "",
email: state.admin.email ?? "",
name: state.admin.name ?? "",
role: state.admin.role ?? "",
},
state.accessToken ?? "",
state.refreshToken ?? "",
);
}
setTimeout(() => navigate("/", { replace: true }), 1000);
};
return (
<>
<div className="flex w-full max-w-[460px] flex-col items-center">
{/* 로고 */}
<div className="mb-8 text-center">
<h1 className="mb-2 text-4xl font-black tracking-tight text-primary">
SPMS
</h1>
<p className="text-sm font-medium text-gray-400">
Stein Push Message Service
</p>
</div>
{/* 인증 카드 */}
<div className="w-full rounded-xl bg-white p-8 shadow-2xl md:p-10">
{/* 아이콘 + 제목 */}
<div className="mb-2 flex items-center gap-3">
<div className="flex size-10 flex-shrink-0 items-center justify-center rounded-full bg-blue-100">
<span className="material-symbols-outlined text-xl text-blue-600">
mark_email_read
</span>
</div>
<h2 className="text-lg font-bold text-foreground"> </h2>
</div>
<p className="mb-2 text-sm text-foreground">
6 .
</p>
<div className="mb-6 rounded-lg border border-blue-200 bg-blue-50 px-4 py-3">
<div className="flex items-center gap-1.5 text-xs font-medium text-blue-700">
<span className="material-symbols-outlined flex-shrink-0"
style={{ fontSize: "14px" }}>
info
</span>
<span> 5 .</span>
</div>
</div>
{/* 코드 입력 */}
<div className="mb-6">
<label className="mb-1.5 block text-sm font-bold text-gray-700">
</label>
<div className="flex gap-2">
{code.map((digit, i) => (
<input
key={i}
ref={(el) => {
inputRefs.current[i] = el;
}}
type="text"
inputMode="numeric"
autoComplete={i === 0 ? "one-time-code" : "off"}
maxLength={1}
value={digit}
onChange={(e) => handleInput(i, e.target.value)}
onFocus={() => handleFocus(i)}
onKeyDown={(e) => handleKeyDown(i, e)}
onPaste={i === 0 ? handlePaste : undefined}
className={`h-14 w-full rounded-lg border bg-white text-center font-sans text-2xl font-bold tracking-widest text-gray-900 transition-all focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary ${
codeError
? "border-red-500 shadow-[0_0_0_2px_rgba(239,68,68,0.15)]"
: "border-gray-300"
} ${cls("code")}`}
/>
))}
</div>
{codeError && (
<div className="mt-1.5 flex items-center gap-1.5 text-xs text-red-600">
<span className="material-symbols-outlined flex-shrink-0"
style={{ fontSize: "14px" }}>
error
</span>
<span>{codeError}</span>
</div>
)}
</div>
{/* 인증하기 버튼 */}
<div className="flex justify-end gap-3">
<button
type="button"
disabled={!isFilled || isLoading}
onClick={handleVerify}
className="rounded bg-primary px-5 py-2.5 text-sm font-medium text-white shadow-sm shadow-primary/30 transition hover:bg-[#1d4ed8] disabled:cursor-not-allowed disabled:opacity-50"
>
{isLoading ? "인증 중…" : "인증하기"}
</button>
</div>
{/* 재전송 */}
<div className="mt-5 border-t border-gray-100 pt-5 text-center">
<p className="text-sm text-gray-500">
?
<button
type="button"
onClick={handleResend}
className={`ml-1 font-semibold text-primary transition hover:text-[#1d4ed8] hover:underline ${
resendCooldown > 0
? "pointer-events-none opacity-50"
: ""
}`}
>
</button>
</p>
{resendCooldown > 0 && (
<p className="mt-1 text-xs text-gray-400">
{resendCooldown}
</p>
)}
</div>
</div>
{/* 풋터 */}
<footer className="mt-12 text-xs font-medium tracking-wide text-gray-500">
&copy; 2026 Stein Co., Ltd. All rights reserved.
</footer>
</div>
{/* 인증 완료 모달 */}
{successModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="w-full max-w-md rounded-xl bg-white p-6 shadow-2xl">
<div className="mb-4 flex items-center gap-3">
<div className="flex size-10 flex-shrink-0 items-center justify-center rounded-full bg-green-100">
<span className="material-symbols-outlined text-xl text-green-600">
check_circle
</span>
</div>
<h3 className="text-lg font-bold text-foreground">
</h3>
</div>
<p className="mb-2 text-sm text-foreground">
.
</p>
<div className="mb-5 rounded-lg border border-blue-200 bg-blue-50 px-4 py-3">
<div className="flex items-center gap-1.5 text-xs font-medium text-blue-700">
<span className="material-symbols-outlined flex-shrink-0"
style={{ fontSize: "14px" }}>
info
</span>
<span> .</span>
</div>
</div>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={goHome}
className="rounded bg-primary px-5 py-2.5 text-sm font-medium text-white shadow-sm shadow-primary/30 transition hover:bg-[#1d4ed8]"
>
</button>
</div>
</div>
</div>
)}
{/* 재전송 완료 모달 */}
{resendModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="w-full max-w-md rounded-xl bg-white p-6 shadow-2xl">
<div className="mb-4 flex items-center gap-3">
<div className="flex size-10 flex-shrink-0 items-center justify-center rounded-full bg-green-100">
<span className="material-symbols-outlined text-xl text-green-600">
forward_to_inbox
</span>
</div>
<h3 className="text-lg font-bold text-foreground">
</h3>
</div>
<p className="mb-2 text-sm text-foreground">
.
</p>
<div className="mb-5 space-y-1.5 rounded-lg border border-blue-200 bg-blue-50 px-4 py-3">
<div className="flex items-center gap-1.5 text-xs font-medium text-blue-700">
<span className="material-symbols-outlined flex-shrink-0"
style={{ fontSize: "14px" }}>
info
</span>
<span> .</span>
</div>
<div className="flex items-center gap-1.5 text-xs font-medium text-blue-700">
<span className="material-symbols-outlined flex-shrink-0"
style={{ fontSize: "14px" }}>
schedule
</span>
<span>{resendCooldown} .</span>
</div>
</div>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={closeResendModal}
className="rounded bg-primary px-5 py-2.5 text-sm font-medium text-white shadow-sm shadow-primary/30 transition hover:bg-[#1d4ed8]"
>
</button>
</div>
</div>
</div>
)}
{/* 홈 이동 오버레이 */}
{homeOverlay && (
<div className="fixed inset-0 z-[100] animate-[fadeIn_0.3s_ease]">
<div className="absolute inset-0 bg-white/60" />
<div className="absolute left-1/2 top-6 z-10 -translate-x-1/2">
<div className="flex items-center gap-3 rounded-xl bg-green-600 px-6 py-3.5 text-white shadow-lg">
<svg
className="size-5 flex-shrink-0 animate-spin"
viewBox="0 0 24 24"
fill="none"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
<span className="text-sm font-medium"> </span>
</div>
</div>
</div>
)}
</>
);
}

View File

@ -0,0 +1,102 @@
/* ───── 회원가입 ───── */
export interface SignupRequest {
email: string;
password: string;
name: string;
phone: string;
agreeTerms: boolean;
agreePrivacy: boolean;
}
export interface SignupResponse {
admin_code: string | null;
email: string | null;
verify_session_id: string | null;
email_sent: boolean;
}
/* ───── 이메일 중복 체크 ───── */
export interface EmailCheckRequest {
email: string;
}
export interface EmailCheckResponse {
email: string | null;
is_available: boolean;
}
/* ───── 로그인 ───── */
export interface LoginRequest {
email: string;
password: string;
}
export interface LoginResponse {
access_token: string | null;
refresh_token: string | null;
expires_in: number;
next_action: "GO_DASHBOARD" | "VERIFY_EMAIL" | "CHANGE_PASSWORD" | null;
email_verified: boolean;
verify_session_id: string | null;
email_sent: boolean | null;
must_change_password: boolean | null;
admin: AdminInfo;
}
export interface AdminInfo {
admin_code: string | null;
email: string | null;
name: string | null;
role: string | null;
}
/* ───── 이메일 인증 ───── */
export interface EmailVerifyRequest {
code: string;
verify_session_id?: string | null;
email?: string | null;
}
export interface EmailVerifyResponse {
verified: boolean;
next_action: string | null;
}
export interface EmailResendRequest {
verify_session_id: string;
}
export interface EmailResendResponse {
resent: boolean;
cooldown_seconds: number;
expires_in_seconds: number;
}
/* ───── 토큰 ───── */
export interface TokenRefreshRequest {
refresh_token: string;
}
export interface TokenRefreshResponse {
access_token: string | null;
refresh_token: string | null;
expires_in: number;
}
/* ───── 로그아웃 ───── */
export interface LogoutResponse {
logged_out: boolean;
redirect_to: string | null;
}
/* ───── 비밀번호 ───── */
export interface TempPasswordRequest {
email: string;
}

View File

@ -0,0 +1,67 @@
import { useState } from "react";
import DateRangeInput from "@/components/common/DateRangeInput";
import FilterResetButton from "@/components/common/FilterResetButton";
export interface DashboardFilterValues {
dateStart: string;
dateEnd: string;
}
interface DashboardFilterProps {
onSearch?: (filter: DashboardFilterValues) => void;
loading?: boolean;
}
/** 오늘 날짜 YYYY-MM-DD */
function today() {
return new Date().toISOString().slice(0, 10);
}
/** 30일 전 */
function thirtyDaysAgo() {
const d = new Date();
d.setDate(d.getDate() - 30);
return d.toISOString().slice(0, 10);
}
export default function DashboardFilter({ onSearch, loading }: DashboardFilterProps) {
const [dateStart, setDateStart] = useState(thirtyDaysAgo);
const [dateEnd, setDateEnd] = useState(today);
// 초기화
const handleReset = () => {
setDateStart(thirtyDaysAgo());
setDateEnd(today());
};
// 조회
const handleSearch = () => {
onSearch?.({ dateStart, dateEnd });
};
return (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-6">
<div className="flex items-end gap-4">
{/* 날짜 범위 */}
<DateRangeInput
startDate={dateStart}
endDate={dateEnd}
onStartChange={setDateStart}
onEndChange={setDateEnd}
disabled={loading}
/>
{/* 초기화 */}
<FilterResetButton onClick={handleReset} disabled={loading} />
{/* 조회 */}
<button
onClick={handleSearch}
disabled={loading}
className="h-[38px] flex-shrink-0 bg-gray-800 hover:bg-gray-900 active:scale-95 text-white rounded-lg px-5 text-sm font-medium transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,87 @@
interface PlatformData {
ios: number;
android: number;
}
interface PlatformDonutProps {
data?: PlatformData;
loading?: boolean;
}
const DEFAULT_DATA: PlatformData = { ios: 45, android: 55 };
export default function PlatformDonut({ data = DEFAULT_DATA, loading }: PlatformDonutProps) {
const { ios, android } = data;
return (
<div className="lg:col-span-1 bg-white rounded-xl shadow-sm border border-gray-200 p-6 flex flex-col relative">
{loading && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/70 rounded-xl">
<svg className="size-7 animate-spin text-primary" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
</div>
)}
<h2 className="text-base font-bold text-[#0f172a] mb-6"> </h2>
{/* 도넛 차트 */}
<div className="flex-1 flex flex-col items-center justify-center relative">
<div className="relative size-48">
<svg className="size-full" viewBox="0 0 36 36">
{/* 배경 원 */}
<path
className="text-gray-100"
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
fill="none"
stroke="currentColor"
strokeWidth="3"
/>
{/* Android (teal) */}
<path
className="text-teal-400"
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
fill="none"
stroke="currentColor"
strokeDasharray={`${android}, 100`}
strokeWidth="3"
/>
{/* iOS (primary blue) */}
<path
className="text-primary"
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
fill="none"
stroke="currentColor"
strokeDasharray={`${ios}, 100`}
strokeDashoffset={`${-android}`}
strokeWidth="3"
/>
</svg>
{/* 중앙 텍스트 */}
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-3xl font-bold text-gray-900">Total</span>
<span className="text-sm text-gray-500">Devices</span>
</div>
</div>
</div>
{/* 범례 */}
<div className="mt-8 flex flex-col gap-3 w-full px-4">
<div className="flex items-center justify-between p-3 rounded-lg bg-gray-50">
<div className="flex items-center gap-2">
<span className="size-3 rounded-full bg-primary flex-shrink-0" />
<span className="text-sm font-medium text-gray-600">iOS</span>
</div>
<span className="text-lg font-bold text-gray-900">{ios}%</span>
</div>
<div className="flex items-center justify-between p-3 rounded-lg bg-gray-50">
<div className="flex items-center gap-2">
<span className="size-3 rounded-full bg-teal-400 flex-shrink-0" />
<span className="text-sm font-medium text-gray-600">Android</span>
</div>
<span className="text-lg font-bold text-gray-900">{android}%</span>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,94 @@
import { Link } from "react-router-dom";
import StatusBadge from "@/components/common/StatusBadge";
type StatusVariant = "success" | "error" | "warning" | "default";
const STATUS_MAP: Record<string, StatusVariant> = {
: "success",
: "error",
: "warning",
: "default",
};
type MessageStatus = keyof typeof STATUS_MAP;
interface RecentMessage {
template: string;
targetCount: string;
status: MessageStatus;
sentAt: string;
}
interface RecentMessagesProps {
messages?: RecentMessage[];
loading?: boolean;
}
const DEFAULT_MESSAGES: RecentMessage[] = [
{ template: "가을맞이 프로모션 알림", targetCount: "12,405", status: "완료", sentAt: "2026-02-15 14:00" },
{ template: "정기 점검 안내", targetCount: "45,100", status: "완료", sentAt: "2026-02-15 10:30" },
{ template: "비밀번호 변경 알림", targetCount: "1", status: "실패", sentAt: "2026-02-15 09:15" },
{ template: "신규 서비스 런칭", targetCount: "8,500", status: "진행", sentAt: "2026-02-15 09:00" },
{ template: "야간 푸시 마케팅", targetCount: "3,200", status: "예약", sentAt: "2026-02-15 20:00" },
];
export default function RecentMessages({ messages = DEFAULT_MESSAGES, loading }: RecentMessagesProps) {
return (
<div className="lg:col-span-2 bg-white rounded-xl shadow-sm border border-gray-200 flex flex-col overflow-hidden relative">
{loading && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/70">
<svg className="size-7 animate-spin text-primary" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
</div>
)}
<div className="p-5 border-b border-gray-100 flex items-center justify-between">
<h2 className="text-base font-bold text-[#0f172a]"> </h2>
<Link
to="/statistics/history"
className="text-primary hover:text-[#1d4ed8] text-sm font-medium"
>
</Link>
</div>
<div className="overflow-x-auto flex-1">
<table className="w-full text-sm text-left h-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center">
릿
</th>
<th className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center">
</th>
<th className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center">
</th>
<th className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center">
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{messages.map((msg, i) => (
<tr key={i} className="hover:bg-gray-50 transition-colors">
<td className="px-6 py-3.5 font-medium text-gray-900 text-center">
{msg.template}
</td>
<td className="px-6 py-3.5 text-center text-gray-600">{msg.targetCount}</td>
<td className="px-6 py-3.5 text-center">
<StatusBadge
variant={STATUS_MAP[msg.status] ?? "default"}
label={msg.status}
/>
</td>
<td className="px-6 py-3.5 text-center text-gray-500">{msg.sentAt}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@ -0,0 +1,84 @@
import { Link } from "react-router-dom";
interface StatCard {
label: string;
value: string;
/** 값 뒤에 붙는 단위 (예: "%") */
unit?: string;
/** 우상단 뱃지 */
badge?: { type: "trend"; value: string } | { type: "icon"; icon: string; color: string };
link: string;
}
interface StatsCardsProps {
cards?: StatCard[];
loading?: boolean;
}
/** 하드코딩 기본 데이터 */
const DEFAULT_CARDS: StatCard[] = [
{
label: "오늘 발송 수",
value: "12,847",
badge: { type: "trend", value: "15%" },
link: "/statistics",
},
{
label: "성공률",
value: "98.7",
unit: "%",
badge: { type: "icon", icon: "check_circle", color: "bg-green-100 text-green-600" },
link: "/statistics",
},
{
label: "등록 기기 수",
value: "45,230",
badge: { type: "icon", icon: "devices", color: "bg-blue-50 text-primary" },
link: "/devices",
},
{
label: "활성 서비스",
value: "8",
badge: { type: "icon", icon: "grid_view", color: "bg-purple-50 text-purple-600" },
link: "/services",
},
];
export default function StatsCards({ cards = DEFAULT_CARDS, loading }: StatsCardsProps) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
{cards.map((card) => (
<Link
key={card.label}
to={card.link}
className="bg-white rounded-xl shadow-sm border border-gray-200 p-5 flex flex-col justify-between h-28 hover:shadow-md hover:border-primary/30 transition-all cursor-pointer"
>
<div className="flex items-center justify-between">
<h4 className="text-xs font-medium text-gray-500">{card.label}</h4>
{card.badge?.type === "trend" && (
<div className="bg-green-50 text-green-700 text-[10px] font-bold px-1.5 py-0.5 rounded-full inline-flex items-center gap-0.5">
<span className="material-symbols-outlined flex-shrink-0" style={{ fontSize: "10px" }}>trending_up</span>
{card.badge.value}
</div>
)}
{card.badge?.type === "icon" && (
<div className={`${card.badge.color} rounded-full size-6 inline-flex items-center justify-center`}>
<span className="material-symbols-outlined flex-shrink-0" style={{ fontSize: "14px" }}>{card.badge.icon}</span>
</div>
)}
</div>
{loading ? (
<div className="h-8 w-24 rounded bg-gray-100 animate-pulse" />
) : (
<div className="text-2xl font-bold text-gray-900">
{card.value}
{card.unit && (
<span className="text-base text-gray-400 ml-0.5 font-normal">{card.unit}</span>
)}
</div>
)}
</Link>
))}
</div>
);
}

View File

@ -0,0 +1,300 @@
import { useRef, useEffect, useCallback, useState } from "react";
interface ChartDataPoint {
label: string;
/** Y축 비율 (0=최상단, 1=최하단) — 0이 최대값 */
blue: number;
green: number;
sent: string;
reach: string;
}
interface WeeklyChartProps {
data?: ChartDataPoint[];
loading?: boolean;
}
/** 하드코딩 기본 데이터 (HTML 시안과 동일) */
const DEFAULT_DATA: ChartDataPoint[] = [
{ label: "02.09", blue: 0.75, green: 0.8, sent: "3,750", reach: "3,000" },
{ label: "02.10", blue: 0.6, green: 0.675, sent: "6,000", reach: "4,875" },
{ label: "02.11", blue: 0.4, green: 0.475, sent: "9,000", reach: "7,875" },
{ label: "02.12", blue: 0.5, green: 0.575, sent: "7,500", reach: "6,375" },
{ label: "02.13", blue: 0.2, green: 0.275, sent: "12,000", reach: "10,875" },
{ label: "02.14", blue: 0.3, green: 0.35, sent: "10,500", reach: "9,750" },
{ label: "Today", blue: 0.1, green: 0.175, sent: "13,500", reach: "12,375" },
];
const MARGIN = 0.02;
export default function WeeklyChart({ data = DEFAULT_DATA, loading }: WeeklyChartProps) {
const areaRef = useRef<HTMLDivElement>(null);
const animatedRef = useRef(false);
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
const count = data.length;
const xRatios =
count === 1
? [MARGIN]
: data.map((_, i) => MARGIN + (i / (count - 1)) * (1 - MARGIN * 2));
const drawChart = useCallback(() => {
const area = areaRef.current;
if (!area || count === 0) return;
// 기존 SVG + 점 제거
area.querySelectorAll("svg, .chart-dot").forEach((el) => el.remove());
const W = area.offsetWidth;
const H = area.offsetHeight;
if (W === 0 || H === 0) return;
const toPixel = (xr: number, yr: number): [number, number] => [
Math.round(xr * W * 10) / 10,
Math.round(yr * H * 10) / 10,
];
const bluePoints = xRatios.map((x, i) => toPixel(x, data[i].blue));
const greenPoints = xRatios.map((x, i) => toPixel(x, data[i].green));
const NS = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(NS, "svg");
svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
svg.style.cssText = "position:absolute;inset:0;width:100%;height:100%;";
function makePath(points: [number, number][], color: string) {
if (points.length < 2) return null;
const path = document.createElementNS(NS, "path");
path.setAttribute(
"d",
points.map((p, i) => `${i ? "L" : "M"}${p[0]},${p[1]}`).join(" "),
);
path.setAttribute("fill", "none");
path.setAttribute("stroke", color);
path.setAttribute("stroke-width", "2.5");
path.setAttribute("stroke-linecap", "round");
path.setAttribute("stroke-linejoin", "round");
svg.appendChild(path);
return path;
}
const bluePath = makePath(bluePoints, "#2563EB");
const greenPath = makePath(greenPoints, "#22C55E");
area.appendChild(svg);
// 애니메이션 (최초 1회만)
const DURATION = 2000;
function getCumulativeRatios(pts: [number, number][]) {
const d = [0];
for (let i = 1; i < pts.length; i++) {
const dx = pts[i][0] - pts[i - 1][0];
const dy = pts[i][1] - pts[i - 1][1];
d.push(d[i - 1] + Math.sqrt(dx * dx + dy * dy));
}
const t = d[d.length - 1];
return t > 0 ? d.map((v) => v / t) : d.map(() => 0);
}
function easeOutTime(r: number) {
return 1 - Math.sqrt(1 - r);
}
function renderLine(
path: SVGPathElement | null,
points: [number, number][],
color: string,
) {
if (path && !animatedRef.current) {
const len = path.getTotalLength();
path.style.strokeDasharray = String(len);
path.style.strokeDashoffset = String(len);
path.getBoundingClientRect(); // 강제 리플로
path.style.transition = `stroke-dashoffset ${DURATION}ms ease-out`;
path.style.strokeDashoffset = "0";
}
const ratios = getCumulativeRatios(points);
points.forEach(([px, py], i) => {
const dot = document.createElement("div");
dot.className = "chart-dot";
dot.style.left = px + "px";
dot.style.top = py + "px";
dot.style.borderColor = color;
area!.appendChild(dot);
if (!animatedRef.current && path) {
setTimeout(() => dot.classList.add("show"), easeOutTime(ratios[i]) * DURATION);
} else {
dot.classList.add("show");
}
});
}
renderLine(bluePath, bluePoints, "#2563EB");
renderLine(greenPath, greenPoints, "#22C55E");
animatedRef.current = true;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data]);
/* data가 바뀌면 애니메이션 재생 */
useEffect(() => {
animatedRef.current = false;
}, [data]);
useEffect(() => {
drawChart();
let resizeTimer: ReturnType<typeof setTimeout>;
const handleResize = () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(drawChart, 150);
};
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
clearTimeout(resizeTimer);
};
}, [drawChart]);
// 호버존 계산 (CSS 기반)
const getHoverZoneStyle = (i: number): React.CSSProperties => {
if (count <= 1) return { left: 0, width: "100%" };
const halfGap =
i === 0
? ((xRatios[1] - xRatios[0]) / 2) * 100
: i === count - 1
? ((xRatios[count - 1] - xRatios[count - 2]) / 2) * 100
: ((xRatios[i + 1] - xRatios[i - 1]) / 4) * 100;
return {
left: `${(xRatios[i] * 100 - halfGap).toFixed(2)}%`,
width: `${(halfGap * 2).toFixed(2)}%`,
};
};
if (count === 0) {
return (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-base font-bold text-[#0f172a]"> 7 </h2>
</div>
<div className="w-full h-72 flex items-center justify-center">
<p className="text-sm text-gray-400"> </p>
</div>
</div>
);
}
return (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
{/* 헤더 */}
<div className="flex items-center justify-between mb-6">
<h2 className="text-base font-bold text-[#0f172a]"> 7 </h2>
<div className="flex items-center gap-4 text-xs font-medium">
<div className="flex items-center gap-2">
<span className="size-3 rounded-full bg-primary" />
<span className="text-gray-600"></span>
</div>
<div className="flex items-center gap-2">
<span className="size-3 rounded-full bg-green-500" />
<span className="text-gray-600"></span>
</div>
</div>
</div>
{/* 차트 영역 */}
<div className="w-full h-72 relative">
{loading && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/70 rounded-lg">
<svg className="size-7 animate-spin text-primary" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
</div>
)}
{/* Y축 라벨 + 그리드 */}
<div className="absolute inset-0 flex flex-col justify-between text-xs text-gray-400 pb-6 pr-4 pointer-events-none">
<div className="flex items-center w-full">
<span className="w-8 text-right mr-2">15k</span>
<div className="flex-1 h-px bg-gray-100" />
</div>
<div className="flex items-center w-full">
<span className="w-8 text-right mr-2">10k</span>
<div className="flex-1 h-px bg-gray-100" />
</div>
<div className="flex items-center w-full">
<span className="w-8 text-right mr-2">5k</span>
<div className="flex-1 h-px bg-gray-100" />
</div>
<div className="flex items-center w-full">
<span className="w-8 text-right mr-2">0</span>
<div className="flex-1 h-px bg-gray-200" />
</div>
</div>
{/* SVG 차트 영역 */}
<div ref={areaRef} className="absolute top-1 bottom-6 left-10 right-4">
{/* 호버존 */}
{data.map((d, i) => (
<div
key={d.label}
className="absolute top-0 bottom-0 cursor-pointer z-[5]"
style={getHoverZoneStyle(i)}
onMouseEnter={() => setHoverIndex(i)}
onMouseLeave={() => setHoverIndex(null)}
>
{/* 가이드라인 */}
<div
className="absolute top-0 bottom-0 w-px left-1/2 bg-gray-300 pointer-events-none transition-opacity duration-150"
style={{ opacity: hoverIndex === i ? 1 : 0 }}
/>
{/* 툴팁 */}
<div
className="absolute left-1/2 pointer-events-none z-10 transition-all duration-150"
style={{
top: `${Math.max(0, Math.min(d.blue, d.green) * 100 - 5)}%`,
transform: `translateX(-50%) translateY(${hoverIndex === i ? 0 : 4}px)`,
opacity: hoverIndex === i ? 1 : 0,
visibility: hoverIndex === i ? "visible" : "hidden",
}}
>
<div className="bg-white border border-gray-200 rounded-lg shadow-lg px-4 py-3 min-w-[140px]">
<p className="text-xs font-bold text-gray-900 mb-2 pb-1.5 border-b border-gray-100">
{d.label}
</p>
<div className="flex items-center gap-2 mb-1.5">
<span className="size-2.5 rounded-full bg-[#2563EB] flex-shrink-0" />
<span className="text-xs text-gray-500 flex-1"></span>
<span className="text-xs font-semibold text-gray-900">{d.sent}</span>
</div>
<div className="flex items-center gap-2">
<span className="size-2.5 rounded-full bg-[#22C55E] flex-shrink-0" />
<span className="text-xs text-gray-500 flex-1"></span>
<span className="text-xs font-semibold text-gray-900">{d.reach}</span>
</div>
</div>
</div>
</div>
))}
</div>
{/* X축 날짜 라벨 */}
<div className="absolute bottom-0 left-10 right-4 h-5 text-xs text-gray-400">
{data.map((d, i) => (
<span
key={d.label}
className="absolute"
style={{
left: `${xRatios[i] * 100}%`,
transform: "translateX(-50%)",
}}
>
{d.label}
</span>
))}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,249 @@
import { useState, useCallback, useEffect } from "react";
import PageHeader from "@/components/common/PageHeader";
import DashboardFilter from "../components/DashboardFilter";
import type { DashboardFilterValues } from "../components/DashboardFilter";
import StatsCards from "../components/StatsCards";
import WeeklyChart from "../components/WeeklyChart";
import RecentMessages from "../components/RecentMessages";
import PlatformDonut from "../components/PlatformDonut";
import { fetchDashboard } from "@/api/dashboard.api";
import { formatNumber } from "@/utils/format";
import type { DashboardData } from "../types";
/** KPI → StatsCards props 변환 */
function mapCards(data: DashboardData) {
const { kpi } = data;
const successRate =
kpi.total_send > 0
? +(kpi.total_success / kpi.total_send * 100).toFixed(1)
: 0;
return [
{
label: "오늘 발송 수",
value: formatNumber(kpi.total_send),
badge: { type: "trend" as const, value: `${Math.abs(kpi.today_sent_change_rate)}%` },
link: "/statistics",
},
{
label: "성공률",
value: successRate.toFixed(1),
unit: "%",
badge: { type: "icon" as const, icon: "check_circle", color: "bg-green-100 text-green-600" },
link: "/statistics",
},
{
label: "등록 기기 수",
value: formatNumber(kpi.total_devices),
badge: { type: "icon" as const, icon: "devices", color: "bg-blue-50 text-primary" },
link: "/devices",
},
{
label: "활성 서비스",
value: String(kpi.active_service_count),
badge: { type: "icon" as const, icon: "grid_view", color: "bg-purple-50 text-purple-600" },
link: "/services",
},
];
}
/** daily → WeeklyChart props 변환 (최근 7일 기준, 없는 날짜는 0으로 채움) */
function mapChart(data: DashboardData) {
const allTrends = data.daily ?? [];
// 최근 7일 날짜 목록 생성 (오늘 포함)
const today = new Date();
const todayStr = today.toISOString().slice(0, 10);
const last7Days = Array.from({ length: 7 }, (_, i) => {
const d = new Date(today);
d.setDate(today.getDate() - 6 + i);
return d.toISOString().slice(0, 10);
});
const dataMap = new Map(allTrends.map((t) => [t.stat_date, t]));
// 없는 날짜는 send_count 0으로 채움
const trends = last7Days.map(
(date) =>
dataMap.get(date) ?? {
stat_date: date,
send_count: 0,
success_count: 0,
fail_count: 0,
open_count: 0,
ctr: 0,
},
);
const maxVal = Math.max(
...trends.map((t) => Math.max(t.send_count, t.success_count)),
1,
);
return trends.map((t) => {
const dateStr = t.stat_date ?? "";
const isToday = dateStr === todayStr;
const mm = dateStr.slice(5, 7);
const dd = dateStr.slice(8, 10);
return {
label: isToday ? "Today" : `${mm}.${dd}`,
blue: 1 - t.send_count / maxVal, // Y축 비율 (0=최상단)
green: 1 - t.success_count / maxVal,
sent: formatNumber(t.send_count),
reach: formatNumber(t.success_count),
};
});
}
/** top_messages → RecentMessages props 변환 */
function mapMessages(data: DashboardData) {
return (data.top_messages ?? []).map((m) => ({
template: m.title ?? "",
targetCount: formatNumber(m.total_send_count),
status: (m.status ?? "") as "완료" | "실패" | "진행" | "예약",
sentAt: "",
}));
}
/** platform_share → PlatformDonut props 변환 */
function mapPlatform(data: DashboardData) {
const shares = data.platform_share ?? [];
return {
ios: shares.find((p) => p.platform?.toLowerCase() === "ios")?.ratio ?? 0,
android: shares.find((p) => p.platform?.toLowerCase() === "android")?.ratio ?? 0,
};
}
export default function DashboardPage() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const [empty, setEmpty] = useState(false); // API 성공이나 데이터 없음
const [cards, setCards] = useState<ReturnType<typeof mapCards> | undefined>();
const [chart, setChart] = useState<ReturnType<typeof mapChart> | undefined>();
const [messages, setMessages] = useState<ReturnType<typeof mapMessages> | undefined>();
const [platform, setPlatform] = useState<ReturnType<typeof mapPlatform> | undefined>();
// 필터 상태 보관 (초기 로드 + 조회 버튼)
const [filter, setFilter] = useState<DashboardFilterValues>({
dateStart: (() => {
const d = new Date();
d.setDate(d.getDate() - 30);
return d.toISOString().slice(0, 10);
})(),
dateEnd: new Date().toISOString().slice(0, 10),
});
const loadDashboard = useCallback(async (f: DashboardFilterValues) => {
setLoading(true);
setError(false);
setEmpty(false);
try {
const res = await fetchDashboard(
{ start_date: f.dateStart, end_date: f.dateEnd },
);
const d = res.data.data;
// 데이터 비어있는지 판단 (서비스·기기가 있으면 KPI 표시)
const hasData =
d.kpi.total_send > 0 ||
d.kpi.total_devices > 0 ||
d.kpi.active_service_count > 0 ||
(d.daily ?? []).length > 0 ||
(d.top_messages ?? []).length > 0;
if (!hasData) {
setEmpty(true);
return;
}
setCards(mapCards(d));
setChart(mapChart(d));
setMessages(mapMessages(d));
setPlatform(mapPlatform(d));
} catch {
setError(true);
} finally {
setLoading(false);
}
}, []);
// 초기 로드
useEffect(() => {
loadDashboard(filter);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 조회 버튼 핸들러
const handleSearch = useCallback(
(f: DashboardFilterValues) => {
setFilter(f);
loadDashboard(f);
},
[loadDashboard],
);
// 스켈레톤 표시 조건
const showSkeleton = loading || error || empty || !cards;
return (
<>
{/* 페이지 헤더 */}
<PageHeader
title="대시보드"
description="서비스 발송 현황과 주요 지표를 한눈에 확인할 수 있습니다."
/>
{/* 필터 */}
<DashboardFilter onSearch={handleSearch} loading={loading} />
{/* 데이터 영역 */}
<div className="relative">
<StatsCards cards={showSkeleton ? undefined : cards} loading={showSkeleton} />
<WeeklyChart data={showSkeleton ? undefined : chart} loading={showSkeleton} />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<RecentMessages messages={showSkeleton ? undefined : messages} loading={showSkeleton} />
<PlatformDonut data={showSkeleton ? undefined : platform} loading={showSkeleton} />
</div>
{/* API 에러 오버레이 */}
{error && !loading && (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-white/90 rounded-xl">
<div className="text-center">
<span
className="material-symbols-outlined text-gray-400"
style={{ fontSize: "48px" }}
>
cloud_off
</span>
<p className="mt-2 text-sm font-semibold text-gray-600">
</p>
<p className="mt-1 text-xs text-gray-500">
</p>
</div>
</div>
)}
{/* 조회 결과 없음 오버레이 */}
{empty && !loading && (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-white/90 rounded-xl">
<div className="text-center">
<span
className="material-symbols-outlined text-gray-400"
style={{ fontSize: "48px" }}
>
inbox
</span>
<p className="mt-2 text-sm font-semibold text-gray-600">
</p>
<p className="mt-1 text-xs text-gray-500">
</p>
</div>
</div>
)}
</div>
</>
);
}

View File

@ -0,0 +1,109 @@
import { Link } from "react-router-dom";
import { useAuthStore } from "@/stores/authStore";
const shortcuts = [
{
to: "/dashboard",
icon: "dashboard",
label: "대시보드",
desc: "발송 현황 한눈에 보기",
color: "blue" as const,
},
{
to: "/services",
icon: "manage_accounts",
label: "서비스 관리",
desc: "서비스 목록 및 설정",
color: "blue" as const,
},
{
to: "/services/register",
icon: "add_circle",
label: "서비스 등록",
desc: "새 서비스 등록하기",
color: "green" as const,
},
{
to: "/statistics/history",
icon: "send",
label: "발송 내역",
desc: "최근 메시지 발송 기록",
color: "blue" as const,
},
] as const;
export default function HomePage() {
const userName = useAuthStore((s) => s.user?.name) ?? "관리자";
return (
<div className="flex-1 flex items-center justify-center pt-16">
<div className="text-center max-w-lg px-8">
{/* 아이콘 */}
<div className="mx-auto mb-8 size-20 rounded-2xl bg-primary/10 flex items-center justify-center border border-primary/20">
<span
className="material-symbols-outlined text-primary"
style={{ fontSize: "50px" }}
>
grid_view
</span>
</div>
{/* 인사말 */}
<h1 className="text-3xl font-bold text-foreground tracking-tight mb-3">
, {userName}
</h1>
<p className="text-muted-foreground text-base leading-relaxed mb-10">
SPMS Admin Console에 .
<br />
.
</p>
{/* 바로가기 */}
<div className="grid grid-cols-2 gap-4">
{shortcuts.map((item) => (
<Link
key={item.to}
to={item.to}
className="group flex items-center gap-3 p-4 bg-white border border-gray-200 rounded-xl hover:border-primary/30 hover:shadow-md transition-all"
>
<div
className={`size-10 rounded-lg flex items-center justify-center flex-shrink-0 transition-colors ${
item.color === "green"
? "bg-green-50 group-hover:bg-green-100"
: "bg-blue-50 group-hover:bg-primary/10"
}`}
>
<span
className={`material-symbols-outlined text-xl ${
item.color === "green" ? "text-green-600" : "text-primary"
}`}
>
{item.icon}
</span>
</div>
<div className="text-left">
<p
className={`text-sm font-semibold text-foreground transition-colors ${
item.color === "green"
? "group-hover:text-green-600"
: "group-hover:text-primary"
}`}
>
{item.label}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{item.desc}
</p>
</div>
</Link>
))}
</div>
{/* 버전 */}
<p className="text-xs text-gray-400 mt-10">
Stein Push Messaging Service v1.0
</p>
</div>
</div>
);
}

View File

@ -0,0 +1,74 @@
// 대시보드 API 요청
export interface DashboardRequest {
start_date: string | null;
end_date: string | null;
}
// 기간별 통계
export interface PeriodStat {
send_count: number;
success_count: number;
open_count: number;
ctr: number;
}
// KPI 지표
export interface DashboardKpi {
total_devices: number;
active_devices: number;
total_messages: number;
total_send: number;
total_success: number;
total_open: number;
avg_ctr: number;
active_service_count: number;
success_rate_change: number;
device_count_change: number;
today_sent_change_rate: number;
today: PeriodStat | null;
this_month: PeriodStat | null;
}
// 일별 통계
export interface DailyStat {
stat_date: string | null;
send_count: number;
success_count: number;
fail_count: number;
open_count: number;
ctr: number;
}
// 시간대별 통계
export interface HourlyStat {
hour: number;
send_count: number;
open_count: number;
ctr: number;
}
// 플랫폼별 통계
export interface PlatformStat {
platform: string | null;
count: number;
ratio: number;
}
// 상위 메시지
export interface TopMessage {
message_code: string | null;
title: string | null;
service_name: string | null;
total_send_count: number;
success_count: number;
status: string | null;
}
// 대시보드 통합 응답
export interface DashboardData {
kpi: DashboardKpi;
daily: DailyStat[] | null;
hourly: HourlyStat[] | null;
platform_share: PlatformStat[] | null;
top_messages: TopMessage[] | null;
}

View File

@ -0,0 +1,372 @@
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import CopyButton from "@/components/common/CopyButton";
import { deleteDevice } from "@/api/device.api";
import { formatDate } from "@/utils/format";
import type { DeviceListItem } from "../types";
interface DeviceSlidePanelProps {
isOpen: boolean;
onClose: () => void;
device: DeviceListItem | null;
serviceCode?: string;
}
export default function DeviceSlidePanel({
isOpen,
onClose,
device,
serviceCode,
}: DeviceSlidePanelProps) {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deleting, setDeleting] = useState(false);
const bodyRef = useRef<HTMLDivElement>(null);
// 패널 열릴 때 스크롤 최상단 리셋
useEffect(() => {
if (isOpen) {
bodyRef.current?.scrollTo(0, 0);
}
}, [isOpen, device]);
// ESC 닫기
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && isOpen) onClose();
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
// body 스크롤 잠금
useEffect(() => {
if (isOpen) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
return () => {
document.body.style.overflow = "";
};
}, [isOpen]);
// 기기 삭제
const handleDelete = async () => {
if (!device || !serviceCode) return;
setDeleting(true);
try {
await deleteDevice({ device_id: device.device_id }, serviceCode);
toast.success("기기가 삭제되었습니다.");
setShowDeleteConfirm(false);
onClose();
} catch {
toast.error("기기 삭제에 실패했습니다.");
} finally {
setDeleting(false);
}
};
// 플랫폼 아이콘 렌더링
const renderPlatformIcon = () => {
if (!device) return null;
if (device.platform?.toLowerCase() === "ios") {
return (
<div className="size-10 rounded-lg bg-gray-100 flex items-center justify-center flex-shrink-0">
<svg className="size-5 text-gray-700" viewBox="0 0 384 512" fill="currentColor">
<path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z" />
</svg>
</div>
);
}
return (
<div className="size-10 rounded-lg bg-green-50 flex items-center justify-center flex-shrink-0">
<span className="material-symbols-outlined text-green-700 text-xl">
android
</span>
</div>
);
};
// 수신 동의 박스
const ConsentBox = ({
label,
consented,
}: {
label: string;
consented: boolean;
}) => (
<div
className={`rounded-lg p-3 border text-center ${
consented
? "bg-green-50 border-green-200"
: "bg-gray-50 border-gray-200"
}`}
>
<div className="flex items-center justify-center gap-1.5 mb-1">
<span
className={`material-symbols-outlined ${consented ? "text-green-500" : "text-gray-400"}`}
style={{ fontSize: "16px" }}
>
{consented ? "check_circle" : "cancel"}
</span>
<span className="text-xs font-semibold text-gray-600">{label}</span>
</div>
<span
className={`text-sm font-bold ${consented ? "text-green-700" : "text-gray-500"}`}
>
{consented ? "동의" : "미동의"}
</span>
</div>
);
// 태그명 배열
const tagNames = device?.tags?.map((t) => t.tag_name) ?? [];
return (
<>
{/* 오버레이 */}
<div
className={`fixed inset-0 bg-black/30 z-50 transition-opacity duration-300 ${
isOpen ? "opacity-100" : "opacity-0 pointer-events-none"
}`}
onClick={onClose}
/>
{/* 패널 */}
<aside
className={`fixed top-0 right-0 h-full w-[480px] bg-white shadow-2xl z-50 flex flex-col transition-transform duration-300 ${
isOpen ? "translate-x-0" : "translate-x-full"
}`}
>
{/* 헤더 */}
<div className="flex items-center justify-between px-6 py-5 border-b border-gray-100 flex-shrink-0">
<div className="flex items-center gap-3">
{renderPlatformIcon()}
<div>
<h3 className="text-base font-bold text-[#0f172a]">
{device?.model}
</h3>
<p className="text-xs text-gray-500 mt-0.5">
{device?.os_version}
</p>
</div>
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors p-1 rounded-lg hover:bg-gray-100"
>
<span className="material-symbols-outlined text-xl">close</span>
</button>
</div>
{/* 본문 */}
<div
ref={bodyRef}
className="flex-1 overflow-y-auto px-6 py-5 space-y-5"
style={{ overscrollBehavior: "contain" }}
>
{device ? (
<>
{/* 소속 서비스 */}
<div>
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2 block">
</label>
<div className="bg-gray-50 rounded-lg p-3 border border-gray-100">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[#2563EB] text-lg">
apps
</span>
<span className="text-sm font-semibold text-[#0f172a]">
{device.service_name}
</span>
</div>
<code className="text-[11px] text-gray-500 bg-white px-2 py-0.5 rounded border border-gray-200 font-mono">
{device.service_code}
</code>
</div>
</div>
</div>
{/* Device ID */}
<div>
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2 block">
Device ID
</label>
<div className="bg-gray-50 rounded-lg p-3 border border-gray-100">
<div className="flex items-center gap-2">
<code className="text-xs text-gray-700 font-mono break-all leading-relaxed flex-1">
{String(device.device_id)}
</code>
<CopyButton text={String(device.device_id)} />
</div>
</div>
</div>
{/* Push Token */}
<div>
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2 block">
Push Token
</label>
<div className="bg-gray-50 rounded-lg p-3 border border-gray-100">
<div className="flex items-start gap-2">
<code className="text-xs text-gray-700 font-mono break-all leading-relaxed flex-1">
{device.device_token ?? ""}
</code>
<CopyButton text={device.device_token ?? ""} />
</div>
</div>
</div>
{/* 수신 동의 */}
<div>
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2 block">
</label>
<div className="grid grid-cols-2 gap-3">
<ConsentBox label="푸시 수신" consented={device.push_agreed} />
<ConsentBox label="광고 수신" consented={device.marketing_agreed} />
</div>
</div>
{/* 태그 */}
<div>
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2 block">
</label>
{tagNames.length > 0 ? (
<div className="flex flex-wrap gap-2">
{tagNames.map((tag) => (
<span
key={tag}
className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-blue-50 text-blue-700 border border-blue-200"
>
{tag}
</span>
))}
</div>
) : (
<p className="text-sm text-gray-400 italic">
</p>
)}
</div>
{/* 기기 정보 */}
<div className="border-t border-gray-100 pt-5">
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3 block">
</label>
<div className="grid grid-cols-2 gap-x-6 gap-y-3">
<div>
<p className="text-[11px] text-gray-400 mb-0.5"></p>
<p className="text-sm font-medium text-[#0f172a]">
{formatDate(device.created_at ?? "")}
</p>
</div>
<div>
<p className="text-[11px] text-gray-400 mb-0.5">
</p>
<p className="text-sm font-medium text-[#0f172a]">
{formatDate(device.last_active_at ?? "")}
</p>
</div>
<div>
<p className="text-[11px] text-gray-400 mb-0.5"> </p>
<p className="text-sm font-medium text-[#0f172a]">
{device.app_version}
</p>
</div>
<div>
<p className="text-[11px] text-gray-400 mb-0.5"></p>
<p className="text-sm font-medium text-[#0f172a]">
{device.platform?.toLowerCase() === "ios" ? "iOS" : "Android"}
</p>
</div>
</div>
</div>
</>
) : (
<div className="flex items-center justify-center h-full text-gray-400 text-sm">
</div>
)}
</div>
{/* 푸터 */}
{device && (
<div className="flex-shrink-0 border-t border-gray-100 px-6 py-4">
<button
type="button"
onClick={() => setShowDeleteConfirm(true)}
className="w-full h-10 border border-red-200 rounded-lg text-sm font-medium text-red-500 hover:bg-red-50 transition-colors flex items-center justify-center"
>
</button>
</div>
)}
</aside>
{/* 삭제 확인 모달 */}
{showDeleteConfirm && device && (
<div className="fixed inset-0 z-[60] flex items-center justify-center">
<div
className="absolute inset-0 bg-black/40"
onClick={() => setShowDeleteConfirm(false)}
/>
<div className="relative bg-white rounded-xl shadow-2xl max-w-md w-full mx-4 p-6 border border-gray-200">
<div className="flex items-center gap-3 mb-4">
<div className="size-10 rounded-full bg-red-100 flex items-center justify-center flex-shrink-0">
<span className="material-symbols-outlined text-red-600 text-xl">
warning
</span>
</div>
<h3 className="text-lg font-bold text-[#0f172a]"> </h3>
</div>
<p className="text-sm text-[#0f172a] mb-2">
?
</p>
<div className="bg-red-50 border border-red-200 rounded-lg px-4 py-3 mb-5 flex flex-col gap-1.5">
<div className="flex items-center gap-1.5 text-red-700 text-xs font-medium">
<span
className="material-symbols-outlined flex-shrink-0"
style={{ fontSize: "14px" }}
>
info
</span>
<span> .</span>
</div>
<div className="flex items-center gap-1.5 text-red-700 text-xs font-medium">
<span
className="material-symbols-outlined flex-shrink-0"
style={{ fontSize: "14px" }}
>
info
</span>
<span> .</span>
</div>
</div>
<div className="flex justify-end gap-3">
<button
onClick={() => setShowDeleteConfirm(false)}
disabled={deleting}
className="bg-white border border-gray-300 hover:bg-gray-50 text-[#0f172a] px-5 py-2.5 rounded text-sm font-medium transition disabled:opacity-50"
>
</button>
<button
onClick={handleDelete}
disabled={deleting}
className="bg-red-500 hover:bg-red-600 text-white px-5 py-2.5 rounded text-sm font-medium transition shadow-sm disabled:opacity-50"
>
{deleting ? "삭제 중..." : "삭제"}
</button>
</div>
</div>
</div>
)}
</>
);
}

View File

@ -0,0 +1,100 @@
import { useRef, useEffect, useState } from "react";
import CopyButton from "@/components/common/CopyButton";
interface SecretToggleCellProps {
label: string;
value: string;
dropdownKey: string;
openKey: string | null;
onToggle: (key: string | null) => void;
}
/** Device ID / Push Token 토글 팝오버 셀 */
export default function SecretToggleCell({
label,
value,
dropdownKey,
openKey,
onToggle,
}: SecretToggleCellProps) {
const containerRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const isOpen = openKey === dropdownKey;
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
// 외부 클릭 시 닫힘
useEffect(() => {
if (!isOpen) return;
const handleClick = (e: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node) &&
popoverRef.current &&
!popoverRef.current.contains(e.target as Node)
) {
onToggle(null);
}
};
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [isOpen, onToggle]);
// 스크롤 시 닫기
useEffect(() => {
if (!isOpen) return;
const close = () => onToggle(null);
window.addEventListener("scroll", close, true);
return () => window.removeEventListener("scroll", close, true);
}, [isOpen, onToggle]);
const handleToggle = () => {
if (isOpen) {
onToggle(null);
} else {
const rect = buttonRef.current?.getBoundingClientRect();
if (rect) {
setPos({ top: rect.bottom + 4, left: rect.left + rect.width / 2 });
}
onToggle(dropdownKey);
}
};
return (
<div
ref={containerRef}
className="relative inline-block"
onClick={(e) => e.stopPropagation()}
>
<button
ref={buttonRef}
className="text-xs text-[#2563EB] hover:text-[#1d4ed8] font-medium transition-colors inline-flex items-center gap-1"
onClick={handleToggle}
>
{label}
<span
className={`material-symbols-outlined transition-transform ${isOpen ? "rotate-180" : ""}`}
style={{ fontSize: "14px" }}
>
keyboard_arrow_down
</span>
</button>
{/* fixed로 렌더링해서 테이블 overflow 밖으로 벗어남 */}
{isOpen && pos && (
<div
ref={popoverRef}
className="fixed w-[340px] bg-gray-50 border border-gray-200 rounded-lg p-3 shadow-lg z-[9999]"
style={{ top: pos.top, left: pos.left, transform: "translateX(-50%)" }}
>
<div className="flex items-center gap-2">
<code className="text-[11px] text-gray-600 font-mono break-all leading-relaxed flex-1">
{value}
</code>
<CopyButton text={value} />
</div>
</div>
)}
</div>
);
}

View File

View File

@ -0,0 +1,478 @@
import { useState, useCallback, useEffect } from "react";
import { toast } from "sonner";
import PageHeader from "@/components/common/PageHeader";
import SearchInput from "@/components/common/SearchInput";
import FilterDropdown from "@/components/common/FilterDropdown";
import FilterResetButton from "@/components/common/FilterResetButton";
import Pagination from "@/components/common/Pagination";
import EmptyState from "@/components/common/EmptyState";
import PlatformBadge from "@/components/common/PlatformBadge";
import SecretToggleCell from "../components/SecretToggleCell";
import DeviceSlidePanel from "../components/DeviceSlidePanel";
import { formatDate } from "@/utils/format";
import { fetchDevices, exportDevices } from "@/api/device.api";
import { fetchServices } from "@/api/service.api";
import {
PLATFORM_FILTER_OPTIONS,
PUSH_CONSENT_FILTER_OPTIONS,
} from "../types";
import type { DeviceListItem } from "../types";
const PAGE_SIZE = 10;
// 테이블 컬럼 헤더
const COLUMNS = [
"소속 서비스",
"플랫폼",
"Device ID",
"Push Token",
"푸시 수신",
"광고 수신",
"태그",
"등록일",
];
export default function DeviceListPage() {
// 필터 입력 상태
const [search, setSearch] = useState("");
const [serviceFilter, setServiceFilter] = useState("전체 서비스");
const [platformFilter, setPlatformFilter] = useState("전체");
const [pushFilter, setPushFilter] = useState("전체");
const [currentPage, setCurrentPage] = useState(1);
const [loading, setLoading] = useState(false);
// 적용된 필터
const [appliedSearch, setAppliedSearch] = useState("");
const [appliedService, setAppliedService] = useState("전체 서비스");
const [appliedPlatform, setAppliedPlatform] = useState("전체");
const [appliedPush, setAppliedPush] = useState("전체");
// 서비스 목록 (API에서 로드)
const [serviceFilterOptions, setServiceFilterOptions] = useState<string[]>([
"전체 서비스",
]);
const [serviceCodeMap, setServiceCodeMap] = useState<
Record<string, string>
>({});
// 데이터
const [items, setItems] = useState<DeviceListItem[]>([]);
const [totalItems, setTotalItems] = useState(0);
const [totalPages, setTotalPages] = useState(1);
// 슬라이드 패널
const [panelOpen, setPanelOpen] = useState(false);
const [selectedDevice, setSelectedDevice] = useState<DeviceListItem | null>(
null,
);
// SecretToggleCell 배타적 관리
const [openDropdownKey, setOpenDropdownKey] = useState<string | null>(null);
// 서비스 목록 로드 (초기화 시 1회)
useEffect(() => {
(async () => {
try {
const res = await fetchServices({ page: 1, pageSize: 100 });
const svcItems = res.data.data.items ?? [];
const names = svcItems.map((s) => s.serviceName);
setServiceFilterOptions(["전체 서비스", ...names]);
const codeMap: Record<string, string> = {};
svcItems.forEach((s) => {
codeMap[s.serviceName] = s.serviceCode;
});
setServiceCodeMap(codeMap);
} catch {
// 서비스 목록 로드 실패 시 기본값 유지
}
})();
}, []);
// 데이터 로드
const loadData = useCallback(
async (
page: number,
keyword: string,
serviceName: string,
platform: string,
push: string,
) => {
setLoading(true);
try {
const serviceCode =
serviceName !== "전체 서비스"
? serviceCodeMap[serviceName] || undefined
: undefined;
const res = await fetchDevices(
{
page,
size: PAGE_SIZE,
keyword: keyword || undefined,
platform: platform !== "전체" ? platform : undefined,
push_agreed:
push === "동의" ? true : push === "미동의" ? false : undefined,
},
serviceCode,
);
const data = res.data.data;
setItems(data.items ?? []);
setTotalItems(data.totalCount ?? 0);
setTotalPages(data.totalPages ?? 1);
} catch {
setItems([]);
setTotalItems(0);
setTotalPages(1);
} finally {
setLoading(false);
}
},
[serviceCodeMap],
);
// 초기 로드
useEffect(() => {
loadData(1, "", "전체 서비스", "전체", "전체");
}, [loadData]);
// 조회
const handleQuery = () => {
setAppliedSearch(search);
setAppliedService(serviceFilter);
setAppliedPlatform(platformFilter);
setAppliedPush(pushFilter);
setCurrentPage(1);
loadData(1, search, serviceFilter, platformFilter, pushFilter);
};
// 필터 초기화
const handleReset = () => {
setSearch("");
setServiceFilter("전체 서비스");
setPlatformFilter("전체");
setPushFilter("전체");
setAppliedSearch("");
setAppliedService("전체 서비스");
setAppliedPlatform("전체");
setAppliedPush("전체");
setCurrentPage(1);
loadData(1, "", "전체 서비스", "전체", "전체");
};
// 페이지 변경
const handlePageChange = (page: number) => {
setCurrentPage(page);
loadData(page, appliedSearch, appliedService, appliedPlatform, appliedPush);
};
// 행 클릭
const handleRowClick = (device: DeviceListItem) => {
setOpenDropdownKey(null);
setSelectedDevice(device);
setPanelOpen(true);
};
// 패널 닫기 (삭제 후 목록 새로고침)
const handlePanelClose = () => {
setPanelOpen(false);
loadData(
currentPage,
appliedSearch,
appliedService,
appliedPlatform,
appliedPush,
);
};
// 엑셀 내보내기
const handleExport = async () => {
if (appliedService === "전체 서비스") {
toast.error("엑셀 다운로드를 위해 서비스를 선택해주세요.");
return;
}
const serviceCode = serviceCodeMap[appliedService];
if (!serviceCode) return;
try {
const res = await exportDevices(
{
keyword: appliedSearch || undefined,
platform:
appliedPlatform !== "전체" ? appliedPlatform : undefined,
push_agreed:
appliedPush === "동의"
? true
: appliedPush === "미동의"
? false
: undefined,
},
serviceCode,
);
const blob = new Blob([res.data], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `devices_${new Date().toISOString().split("T")[0]}.xlsx`;
a.click();
window.URL.revokeObjectURL(url);
} catch {
toast.error("엑셀 다운로드에 실패했습니다.");
}
};
// 체크/취소 아이콘
const StatusIcon = ({ active }: { active: boolean }) => (
<span
className={`material-symbols-outlined ${active ? "text-green-500" : "text-red-400"}`}
style={{ fontSize: "20px" }}
>
{active ? "check_circle" : "cancel"}
</span>
);
// 태그 아이콘 (있으면 체크, 없으면 dash)
const TagIcon = ({ hasTags }: { hasTags: boolean }) =>
hasTags ? (
<span
className="material-symbols-outlined text-green-500"
style={{ fontSize: "20px" }}
>
check_circle
</span>
) : (
<span
className="material-symbols-outlined text-red-400"
style={{ fontSize: "20px" }}
>
cancel
</span>
);
// 테이블 헤더 렌더링
const renderTableHead = () => (
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
{COLUMNS.map((col) => (
<th
key={col}
className={`px-6 py-4 text-xs font-semibold uppercase text-gray-700 text-center ${
["푸시 수신", "광고 수신", "태그"].includes(col)
? "w-[100px] whitespace-nowrap"
: ""
}`}
>
{col}
</th>
))}
</tr>
</thead>
);
return (
<div>
<PageHeader
title="기기 관리"
description="등록된 디바이스 현황을 조회하고 관리할 수 있습니다."
action={
<button
onClick={handleExport}
className="flex items-center justify-center gap-2 min-w-[154px] px-5 py-2.5 bg-white border border-gray-300 hover:bg-gray-50 text-[#0f172a] rounded-lg text-sm font-medium transition-colors"
>
<span className="material-symbols-outlined text-lg">download</span>
</button>
}
/>
{/* 필터바 */}
<div className="bg-white border border-gray-200 rounded-lg p-6 mb-6">
<div className="flex flex-wrap items-end gap-4">
<SearchInput
value={search}
onChange={setSearch}
placeholder="Device ID 또는 Push Token 검색"
label="검색어"
disabled={loading}
/>
<FilterDropdown
label="서비스"
value={serviceFilter}
options={serviceFilterOptions}
onChange={setServiceFilter}
className="w-[140px] flex-shrink-0"
disabled={loading}
/>
<FilterDropdown
label="플랫폼"
value={platformFilter}
options={PLATFORM_FILTER_OPTIONS}
onChange={setPlatformFilter}
className="w-[120px] flex-shrink-0"
disabled={loading}
/>
<FilterDropdown
label="푸시 수신"
value={pushFilter}
options={PUSH_CONSENT_FILTER_OPTIONS}
onChange={setPushFilter}
className="w-[120px] flex-shrink-0"
disabled={loading}
/>
<FilterResetButton onClick={handleReset} disabled={loading} />
<button
onClick={handleQuery}
disabled={loading}
className="h-[38px] flex-shrink-0 bg-gray-800 hover:bg-gray-900 active:scale-95 text-white rounded-lg px-5 text-sm font-medium transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
</div>
</div>
{/* 테이블 */}
{loading ? (
/* 로딩 스켈레톤 */
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden shadow-sm">
<table className="w-full text-sm">
{renderTableHead()}
<tbody>
{Array.from({ length: 5 }).map((_, i) => (
<tr
key={i}
className={i < 4 ? "border-b border-gray-100" : ""}
>
<td className="px-6 py-4 text-center">
<div className="h-4 w-20 rounded bg-gray-100 animate-pulse mx-auto" />
</td>
<td className="px-6 py-4 text-center">
<div className="h-4 w-14 rounded bg-gray-100 animate-pulse mx-auto" />
</td>
<td className="px-6 py-4 text-center">
<div className="h-4 w-16 rounded bg-gray-100 animate-pulse mx-auto" />
</td>
<td className="px-6 py-4 text-center">
<div className="h-4 w-16 rounded bg-gray-100 animate-pulse mx-auto" />
</td>
<td className="px-6 py-4 text-center">
<div className="h-4 w-6 rounded bg-gray-100 animate-pulse mx-auto" />
</td>
<td className="px-6 py-4 text-center">
<div className="h-4 w-6 rounded bg-gray-100 animate-pulse mx-auto" />
</td>
<td className="px-6 py-4 text-center">
<div className="h-4 w-6 rounded bg-gray-100 animate-pulse mx-auto" />
</td>
<td className="px-6 py-4 text-center">
<div className="h-4 w-20 rounded bg-gray-100 animate-pulse mx-auto" />
</td>
</tr>
))}
</tbody>
</table>
</div>
) : items.length > 0 ? (
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden shadow-sm">
<div className="overflow-x-auto">
<table className="w-full">
{renderTableHead()}
<tbody>
{items.map((device, idx) => (
<tr
key={device.device_id}
className={`${idx < items.length - 1 ? "border-b border-gray-100" : ""} hover:bg-gray-50/50 transition-colors cursor-pointer`}
onClick={() => handleRowClick(device)}
>
{/* 소속 서비스 */}
<td className="px-6 py-4 text-center">
<span className="text-sm text-gray-700">
{device.service_name}
</span>
</td>
{/* 플랫폼 */}
<td className="px-6 py-4 text-center">
<PlatformBadge
platform={
device.platform?.toLowerCase() === "ios" ? "ios" : "android"
}
/>
</td>
{/* Device ID */}
<td className="px-6 py-4 text-center">
<SecretToggleCell
label="ID 확인"
value={String(device.device_id)}
dropdownKey={`${device.device_id}-id`}
openKey={openDropdownKey}
onToggle={setOpenDropdownKey}
/>
</td>
{/* Push Token */}
<td className="px-6 py-4 text-center">
<SecretToggleCell
label="토큰 확인"
value={device.device_token ?? ""}
dropdownKey={`${device.device_id}-token`}
openKey={openDropdownKey}
onToggle={setOpenDropdownKey}
/>
</td>
{/* 푸시 수신 */}
<td className="px-6 py-4 text-center">
<StatusIcon active={device.push_agreed} />
</td>
{/* 광고 수신 */}
<td className="px-6 py-4 text-center">
<StatusIcon active={device.marketing_agreed} />
</td>
{/* 태그 */}
<td className="px-6 py-4 text-center">
<TagIcon hasTags={(device.tags?.length ?? 0) > 0} />
</td>
{/* 등록일 */}
<td className="px-6 py-4 text-center">
<span className="text-sm text-gray-500">
{formatDate(device.created_at ?? "")}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* 페이지네이션 */}
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={totalItems}
pageSize={PAGE_SIZE}
onPageChange={handlePageChange}
/>
</div>
) : (
<EmptyState
icon="search_off"
message="검색 결과가 없습니다"
description="다른 검색어를 입력하거나 필터를 변경해보세요."
/>
)}
{/* 슬라이드 패널 */}
<DeviceSlidePanel
isOpen={panelOpen}
onClose={handlePanelClose}
device={selectedDevice}
serviceCode={
selectedDevice?.service_code ??
(appliedService !== "전체 서비스"
? serviceCodeMap[appliedService]
: undefined)
}
/>
</div>
);
}

View File

@ -0,0 +1,69 @@
// 플랫폼 타입
export const PLATFORM = { IOS: "iOS", ANDROID: "Android" } as const;
export type Platform = (typeof PLATFORM)[keyof typeof PLATFORM];
// 필터 옵션 상수
export const PLATFORM_FILTER_OPTIONS = ["전체", "iOS", "Android"];
export const PUSH_CONSENT_FILTER_OPTIONS = ["전체", "동의", "미동의"];
// --- swagger 기준 요청/응답 타입 (snake_case) ---
/** 기기 목록 요청 */
export interface DeviceListRequest {
page: number;
size: number;
platform?: string | null;
push_agreed?: boolean | null;
marketing_agreed?: boolean | null;
tags?: number[] | null;
is_active?: boolean | null;
keyword?: string | null;
}
/** 기기 태그 아이템 */
export interface DeviceTagItem {
tag_id: number;
tag_name: string;
}
/** 기기 목록 아이템 */
export interface DeviceListItem {
device_id: number;
device_token: string | null;
platform: string | null;
model: string | null;
os_version: string | null;
app_version: string | null;
service_name: string | null;
service_code: string | null;
push_agreed: boolean;
marketing_agreed: boolean;
tags: DeviceTagItem[] | null;
created_at: string | null;
last_active_at: string | null;
is_active: boolean;
}
/** 기기 목록 응답 */
export interface DeviceListResponse {
items: DeviceListItem[] | null;
totalCount: number;
page: number;
size: number;
totalPages: number;
}
/** 기기 삭제 요청 */
export interface DeviceDeleteRequest {
device_id: number;
}
/** 기기 내보내기 요청 */
export interface DeviceExportRequest {
platform?: string | null;
push_agreed?: boolean | null;
marketing_agreed?: boolean | null;
tags?: number[] | null;
is_active?: boolean | null;
keyword?: string | null;
}

View File

@ -0,0 +1,379 @@
import { useState } from "react";
interface MessagePreviewProps {
title: string;
body: string;
hasImage: boolean;
appName?: string;
variant?: "large" | "small";
}
// 폰 배경 이미지 (밝은 그래디언트 배경)
const PHONE_BG =
"https://lh3.googleusercontent.com/aida-public/AB6AXuDmbD5msJ9uegWOcy0256wH6JsipGzrgtab3foEKiGVs_a4SbUCTPti6BVDJOQEP4ZCvbcAw9hI3C7QuUdPxrBf3jJm3VgKkWoqSzl--ZEbPIzimbYnM1HQEsRbil7nmWG_XscwPP30V3OFnyleVY_R7Urk0UYbrL8P1OJwW1xwYfBDJv4htBuICd9GR2NIJlSShaBxfF9Kgp59Cte3VapdHxCz9p2Cb9tf1t13xc2LV348V-kfyQNtL8XCZNP3LMrrUIR4SrV3cGM";
export default function MessagePreview({
title,
body,
hasImage,
appName = "SPMS",
variant = "large",
}: MessagePreviewProps) {
const [tab, setTab] = useState<"ios" | "android">("ios");
const isLarge = variant === "large";
const phoneWidth = isLarge ? "w-[300px]" : "w-[240px]";
const truncatedBody =
body.length > 50 ? body.substring(0, 50) + "..." : body;
return (
<div>
{/* 탭 */}
<div className="flex border-b border-gray-200 mb-4">
<button
type="button"
className={`flex-1 px-3 py-2 text-xs font-medium border-b-2 transition-colors cursor-pointer ${
tab === "ios"
? "text-[#0f172a] border-[#2563EB]"
: "text-gray-400 border-transparent hover:text-[#0f172a]"
}`}
onClick={() => setTab("ios")}
>
iOS
</button>
<button
type="button"
className={`flex-1 px-3 py-2 text-xs font-medium border-b-2 transition-colors cursor-pointer ${
tab === "android"
? "text-[#0f172a] border-[#2563EB]"
: "text-gray-400 border-transparent hover:text-[#0f172a]"
}`}
onClick={() => setTab("android")}
>
Android
</button>
</div>
{/* iOS 프리뷰 */}
{tab === "ios" && (
<div className="flex flex-col gap-5">
{/* Banner Notification */}
<div className="min-h-[130px]">
<p className="text-[10px] uppercase font-bold text-gray-400 tracking-wider mb-2">
Banner Notification
</p>
<div className="bg-white/90 backdrop-blur-md rounded-2xl p-3 shadow-lg border border-gray-200">
<div className="flex items-center gap-2.5">
<div className="size-8 bg-[#2563EB] rounded-xl flex items-center justify-center flex-shrink-0 shadow-sm">
<span
className="material-symbols-outlined text-white"
style={{ fontSize: "16px" }}
>
shopping_bag
</span>
</div>
<div className="flex-1 min-w-0">
<p className="text-xs font-bold text-[#0f172a] break-words">
{title || "메시지 제목을 입력하세요"}
</p>
<p className="text-[10px] text-gray-600 mt-0.5 break-words">
{body}
</p>
</div>
<div className="flex flex-col items-end gap-1 flex-shrink-0 self-start">
<p className="text-[10px] text-gray-400">now</p>
{hasImage && (
<div className="w-8 h-8 bg-gray-100 border border-dashed border-gray-300 rounded flex items-center justify-center">
<span
className="material-symbols-outlined text-gray-300"
style={{ fontSize: "14px" }}
>
image
</span>
</div>
)}
</div>
</div>
</div>
</div>
{/* Full Screen Preview - iOS */}
<div>
<p className="text-[10px] uppercase font-bold text-gray-400 tracking-wider mb-2">
Full Screen Preview
</p>
<div className="flex justify-center">
<div className={`relative ${phoneWidth}`}>
<div
className={`${isLarge ? "border-[10px]" : "border-[8px]"} border-gray-800 bg-gray-800 ${isLarge ? "rounded-[2.5rem]" : "rounded-[2rem]"} overflow-hidden shadow-xl`}
style={{ aspectRatio: "9/19.5" }}
>
{/* 노치 */}
<div
className={`absolute top-0 left-1/2 -translate-x-1/2 ${isLarge ? "w-32 h-7" : "w-24 h-5"} bg-black rounded-b-2xl z-10`}
/>
<div
className="w-full h-full bg-cover bg-center overflow-hidden relative"
style={{
backgroundImage: `url('${PHONE_BG}')`,
filter: "brightness(1)",
}}
>
{/* 상태바 */}
<div className="px-4 pt-2 pb-1 flex justify-between items-center text-white text-[10px] font-medium bg-black/20">
<span>9:41</span>
<div className="flex gap-0.5 items-center">
<span
className="material-symbols-outlined"
style={{ fontSize: "12px" }}
>
signal_cellular_alt
</span>
<span
className="material-symbols-outlined"
style={{ fontSize: "12px" }}
>
wifi
</span>
<span
className="material-symbols-outlined"
style={{ fontSize: "12px" }}
>
battery_full
</span>
</div>
</div>
{/* 알림 카드 */}
<div
className={`absolute ${isLarge ? "top-16 left-3 right-3" : "top-12 left-2 right-2"} bg-white/80 backdrop-blur-md rounded-lg shadow-lg border border-white/30 overflow-hidden`}
>
<div className="p-2.5">
<div className="flex items-center gap-2">
<div className="size-6 bg-[#2563EB] rounded-md flex items-center justify-center flex-shrink-0">
<span
className="material-symbols-outlined text-white"
style={{ fontSize: "12px" }}
>
shopping_bag
</span>
</div>
<div className="flex-1 min-w-0">
<p className="text-[10px] font-bold text-[#0f172a] break-words">
{title || "메시지 제목을 입력하세요"}
</p>
<p className="text-[8px] text-gray-600 mt-0.5 break-words">
{truncatedBody}
</p>
</div>
<p className="text-[8px] text-gray-400 flex-shrink-0 self-start">
now
</p>
</div>
</div>
{hasImage && (
<div
className="w-full bg-gray-100 border-t border-dashed border-gray-200 flex items-center justify-center"
style={{ height: isLarge ? 150 : 120 }}
>
<span className="material-symbols-outlined text-gray-300 text-2xl">
image
</span>
</div>
)}
</div>
{/* 홈 인디케이터 */}
<div className="absolute bottom-0 left-0 right-0 h-5 flex items-center justify-center">
<div className="w-24 h-1 bg-white/60 rounded-full" />
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)}
{/* Android 프리뷰 */}
{tab === "android" && (
<div className="flex flex-col gap-5">
{/* Banner Notification */}
<div className="min-h-[130px]">
<p className="text-[10px] uppercase font-bold text-gray-400 tracking-wider mb-2">
Banner Notification
</p>
<div className="bg-white rounded-2xl p-3 shadow-lg border border-gray-200">
<div className="flex items-center gap-1.5 mb-1.5">
<div className="size-3.5 bg-[#2563EB] rounded-full flex items-center justify-center flex-shrink-0">
<span
className="material-symbols-outlined text-white"
style={{ fontSize: "8px" }}
>
shopping_bag
</span>
</div>
<p className="text-[10px] text-gray-500 font-medium">{appName}</p>
<span className="text-[10px] text-gray-300 mx-0.5">&middot;</span>
<p className="text-[10px] text-gray-400">now</p>
</div>
<div className="flex items-center gap-2.5">
<div className="flex-1 min-w-0">
<p className="text-xs font-bold text-[#0f172a] break-words">
{title || "메시지 제목을 입력하세요"}
</p>
<p className="text-[10px] text-gray-600 mt-0.5 break-words">
{body}
</p>
</div>
{hasImage && (
<div className="w-8 h-8 bg-gray-100 border border-dashed border-gray-300 rounded flex items-center justify-center flex-shrink-0">
<span
className="material-symbols-outlined text-gray-300"
style={{ fontSize: "14px" }}
>
image
</span>
</div>
)}
</div>
</div>
</div>
{/* Full Screen Preview - Android */}
<div>
<p className="text-[10px] uppercase font-bold text-gray-400 tracking-wider mb-2">
Full Screen Preview
</p>
<div className="flex justify-center">
<div className={`relative ${phoneWidth}`}>
<div
className={`${isLarge ? "border-[10px]" : "border-[8px]"} border-gray-800 bg-gray-800 rounded-[2rem] overflow-hidden shadow-xl`}
style={{ aspectRatio: "9/19.5" }}
>
<div
className="w-full h-full bg-cover bg-center overflow-hidden relative"
style={{
backgroundImage: `url('${PHONE_BG}')`,
filter: "brightness(1)",
}}
>
{/* 상태바 */}
<div className="px-4 pt-2 pb-1 flex justify-between items-center text-white text-[10px] font-medium bg-black/20">
<span>12:30</span>
<div className="flex gap-0.5 items-center">
<span
className="material-symbols-outlined"
style={{ fontSize: "12px" }}
>
signal_cellular_alt
</span>
<span
className="material-symbols-outlined"
style={{ fontSize: "12px" }}
>
wifi
</span>
<span
className="material-symbols-outlined"
style={{ fontSize: "12px" }}
>
battery_full
</span>
</div>
</div>
{/* 알림 카드 */}
<div
className={`absolute ${isLarge ? "top-14" : "top-11"} left-2 right-2 bg-white/90 rounded-xl shadow-lg overflow-hidden`}
>
<div className="px-2.5 pt-2 pb-1.5">
<div className="flex items-center gap-1 mb-1">
<div className="size-3 bg-[#2563EB] rounded-full flex items-center justify-center flex-shrink-0">
<span
className="material-symbols-outlined text-white"
style={{ fontSize: "7px" }}
>
shopping_bag
</span>
</div>
<p className="text-[8px] text-gray-500 font-medium">
{appName}
</p>
<span className="text-[8px] text-gray-300 mx-0.5">
&middot;
</span>
<p className="text-[8px] text-gray-400">now</p>
<span
className="material-symbols-outlined text-gray-400 ml-auto"
style={{ fontSize: "10px" }}
>
expand_more
</span>
</div>
<div className="flex items-center gap-2">
<div className="flex-1 min-w-0">
<p className="text-[10px] font-bold text-[#0f172a] break-words">
{title || "메시지 제목을 입력하세요"}
</p>
<p className="text-[8px] text-gray-600 mt-0.5 break-words">
{truncatedBody}
</p>
</div>
{hasImage && (
<div className="w-8 h-8 bg-gray-100 border border-dashed border-gray-200 rounded flex items-center justify-center flex-shrink-0">
<span
className="material-symbols-outlined text-gray-300"
style={{ fontSize: "12px" }}
>
image
</span>
</div>
)}
</div>
</div>
{hasImage && (
<div
className="w-full bg-gray-100 border-t border-dashed border-gray-200 flex items-center justify-center"
style={{ height: isLarge ? 150 : 120 }}
>
<span className="material-symbols-outlined text-gray-300 text-2xl">
image
</span>
</div>
)}
</div>
{/* 네비게이션 바 */}
<div className="absolute bottom-0 left-0 right-0 h-4 flex items-center justify-center gap-10 bg-black/10">
<span
className="material-symbols-outlined text-white/60"
style={{ fontSize: "13px" }}
>
arrow_back
</span>
<span
className="material-symbols-outlined text-white/60"
style={{ fontSize: "13px" }}
>
circle
</span>
<span
className="material-symbols-outlined text-white/60"
style={{ fontSize: "13px" }}
>
crop_square
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)}
<p className="text-[10px] text-gray-400 leading-relaxed text-center mt-4">
.
<br />
OS .
</p>
</div>
);
}

View File

@ -0,0 +1,325 @@
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import CopyButton from "@/components/common/CopyButton";
import MessagePreview from "./MessagePreview";
import { fetchMessageInfo, deleteMessage } from "@/api/message.api";
import type { MessageInfoResponse } from "../types";
interface MessageSlidePanelProps {
isOpen: boolean;
onClose: () => void;
messageCode: string | null;
serviceCode: string | null;
}
export default function MessageSlidePanel({
isOpen,
onClose,
messageCode,
serviceCode,
}: MessageSlidePanelProps) {
const [detail, setDetail] = useState<MessageInfoResponse | null>(null);
const [loading, setLoading] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deleting, setDeleting] = useState(false);
const bodyRef = useRef<HTMLDivElement>(null);
// 메시지 상세 조회
useEffect(() => {
if (!isOpen || !messageCode || !serviceCode) {
setDetail(null);
return;
}
(async () => {
setLoading(true);
try {
const res = await fetchMessageInfo(
{ message_code: messageCode },
serviceCode,
);
setDetail(res.data.data);
} catch {
setDetail(null);
toast.error("메시지 상세 조회에 실패했습니다.");
} finally {
setLoading(false);
}
})();
}, [isOpen, messageCode, serviceCode]);
// 패널 열릴 때 스크롤 최상단으로 리셋
useEffect(() => {
if (isOpen) {
bodyRef.current?.scrollTo(0, 0);
}
}, [isOpen, messageCode]);
// ESC 닫기
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && isOpen) onClose();
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
// 패널 열릴 때 body 스크롤 잠금
useEffect(() => {
if (isOpen) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
return () => {
document.body.style.overflow = "";
};
}, [isOpen]);
// 삭제 처리
const handleDelete = async () => {
if (!detail?.message_code || !serviceCode) return;
setDeleting(true);
try {
await deleteMessage(
{ message_code: detail.message_code },
serviceCode,
);
toast.success(`${detail.message_code} 메시지가 삭제되었습니다.`);
setShowDeleteConfirm(false);
onClose();
} catch {
toast.error("메시지 삭제에 실패했습니다.");
} finally {
setDeleting(false);
}
};
// 기타 정보 문자열 변환
const extraText =
detail?.data != null
? typeof detail.data === "string"
? detail.data
: JSON.stringify(detail.data, null, 2)
: "";
return (
<>
{/* 오버레이 */}
<div
className={`fixed inset-0 bg-black/30 z-50 transition-opacity duration-300 ${
isOpen ? "opacity-100" : "opacity-0 pointer-events-none"
}`}
onClick={onClose}
/>
{/* 패널 */}
<aside
className={`fixed top-0 right-0 h-full w-[480px] bg-white shadow-2xl z-50 flex flex-col transition-transform duration-300 ${
isOpen ? "translate-x-0" : "translate-x-full"
}`}
>
{/* 패널 헤더 */}
<div className="flex items-center justify-between px-6 py-5 border-b border-gray-200">
<h2 className="text-lg font-bold text-[#0f172a]"> </h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors p-1 rounded-lg hover:bg-gray-100"
>
<span className="material-symbols-outlined text-xl">close</span>
</button>
</div>
{/* 패널 본문 */}
<div ref={bodyRef} className="flex-1 overflow-y-auto p-6">
{loading ? (
/* 로딩 스켈레톤 */
<div className="space-y-5">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i}>
<div className="h-3 w-16 rounded bg-gray-100 animate-pulse mb-2" />
<div className="h-9 w-full rounded bg-gray-100 animate-pulse" />
</div>
))}
</div>
) : detail ? (
<div className="space-y-5">
{/* 메시지 ID */}
<div className="flex items-center gap-3">
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider flex-shrink-0">
ID
</label>
<code className="text-sm font-medium text-[#2563EB] bg-[#2563EB]/5 px-2 py-0.5 rounded">
{detail.message_code}
</code>
<CopyButton text={detail.message_code ?? ""} />
</div>
<div className="h-px bg-gray-100" />
{/* 서비스 선택 */}
<div>
<label className="block text-sm font-medium text-[#0f172a] mb-2">
</label>
<p className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded text-sm text-gray-700">
{detail.service_name}
</p>
</div>
{/* 제목 */}
<div>
<label className="block text-sm font-medium text-[#0f172a] mb-2">
</label>
<p className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded text-sm text-gray-700">
{detail.title}
</p>
</div>
{/* 내용 */}
<div>
<label className="block text-sm font-medium text-[#0f172a] mb-2">
</label>
<div className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded text-sm text-gray-700 leading-relaxed min-h-[120px]">
{detail.body}
</div>
</div>
{/* 이미지 URL */}
<div>
<label className="block text-sm font-medium text-[#0f172a] mb-2">
URL
</label>
<p
className={`w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded text-sm truncate ${
detail.image_url
? "text-gray-500"
: "italic text-gray-400"
}`}
>
{detail.image_url || "등록된 이미지 없음"}
</p>
</div>
{/* 링크 URL */}
<div>
<div className="flex items-center gap-2 mb-2">
<label className="text-sm font-medium text-[#0f172a]">
URL
</label>
<span
className={`text-[11px] font-medium px-1.5 py-0.5 rounded ${
detail.link_type === "deeplink"
? "bg-purple-100 text-purple-700"
: "bg-blue-100 text-blue-700"
}`}
>
{detail.link_type === "deeplink" ? "딥링크" : "웹 링크"}
</span>
</div>
<p className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded text-sm text-gray-500 truncate">
{detail.link_url || "—"}
</p>
</div>
{/* 기타 정보 */}
<div>
<label className="block text-sm font-medium text-[#0f172a] mb-2">
</label>
<div className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded text-sm text-gray-700 leading-relaxed min-h-[80px] whitespace-pre-line">
{extraText || "—"}
</div>
</div>
<div className="h-px bg-gray-100" />
{/* 프리뷰 */}
<div>
<label className="block text-sm font-medium text-[#0f172a] mb-3">
</label>
<MessagePreview
title={detail.title ?? ""}
body={detail.body ?? ""}
hasImage={!!detail.image_url}
appName={detail.service_name ?? ""}
variant="small"
/>
</div>
<div className="h-px bg-gray-100" />
{/* 삭제 버튼 */}
<button
type="button"
onClick={() => setShowDeleteConfirm(true)}
className="w-full flex items-center justify-center border border-red-300 text-red-600 hover:bg-red-50 px-5 py-2.5 rounded text-sm font-medium transition"
>
</button>
</div>
) : (
<div className="flex items-center justify-center h-full text-gray-400 text-sm">
</div>
)}
</div>
</aside>
{/* 삭제 확인 모달 */}
{showDeleteConfirm && detail && (
<div className="fixed inset-0 z-[60] flex items-center justify-center">
<div
className="absolute inset-0 bg-black/40"
onClick={() => setShowDeleteConfirm(false)}
/>
<div className="relative bg-white rounded-xl shadow-2xl max-w-md w-full mx-4 p-6 border border-gray-200">
<div className="flex items-center gap-3 mb-4">
<div className="size-10 rounded-full bg-red-100 flex items-center justify-center flex-shrink-0">
<span className="material-symbols-outlined text-red-600 text-xl">
warning
</span>
</div>
<h3 className="text-lg font-bold text-[#0f172a]"> </h3>
</div>
<p className="text-sm text-[#0f172a] mb-2">
<strong>{detail.message_code}</strong> ?
</p>
<div className="bg-red-50 border border-red-200 rounded-lg px-4 py-3 mb-5">
<div className="flex items-center gap-1.5 text-red-700 text-xs font-medium">
<span
className="material-symbols-outlined flex-shrink-0"
style={{ fontSize: "14px" }}
>
info
</span>
<span> .</span>
</div>
</div>
<div className="flex justify-end gap-3">
<button
onClick={() => setShowDeleteConfirm(false)}
disabled={deleting}
className="bg-white border border-gray-300 hover:bg-gray-50 text-[#0f172a] px-5 py-2.5 rounded text-sm font-medium transition"
>
</button>
<button
onClick={handleDelete}
disabled={deleting}
className="bg-red-600 hover:bg-red-700 text-white px-5 py-2.5 rounded text-sm font-medium transition shadow-sm flex items-center gap-2 disabled:opacity-50"
>
<span className="material-symbols-outlined text-base">
delete
</span>
<span>{deleting ? "삭제 중..." : "삭제"}</span>
</button>
</div>
</div>
</div>
)}
</>
);
}

View File

@ -0,0 +1,333 @@
import { useState, useCallback, useEffect } from "react";
import { Link, useSearchParams } from "react-router-dom";
import PageHeader from "@/components/common/PageHeader";
import SearchInput from "@/components/common/SearchInput";
import FilterDropdown from "@/components/common/FilterDropdown";
import FilterResetButton from "@/components/common/FilterResetButton";
import Pagination from "@/components/common/Pagination";
import EmptyState from "@/components/common/EmptyState";
import CopyButton from "@/components/common/CopyButton";
import MessageSlidePanel from "../components/MessageSlidePanel";
import { formatDate } from "@/utils/format";
import { fetchMessages } from "@/api/message.api";
import { fetchServices } from "@/api/service.api";
import type { MessageListItem } from "../types";
const PAGE_SIZE = 10;
export default function MessageListPage() {
const [searchParams, setSearchParams] = useSearchParams();
// 필터 입력 상태
const [search, setSearch] = useState("");
const [serviceFilter, setServiceFilter] = useState("전체 서비스");
const [currentPage, setCurrentPage] = useState(1);
const [loading, setLoading] = useState(false);
// 실제 적용될 필터 (조회 버튼 클릭 시 반영)
const [appliedSearch, setAppliedSearch] = useState("");
const [appliedService, setAppliedService] = useState("전체 서비스");
// 데이터 상태
const [items, setItems] = useState<MessageListItem[]>([]);
const [totalItems, setTotalItems] = useState(0);
const [totalPages, setTotalPages] = useState(1);
// 서비스 필터 옵션
const [serviceFilterOptions, setServiceFilterOptions] = useState<string[]>([
"전체 서비스",
]);
const [serviceCodeMap, setServiceCodeMap] = useState<
Record<string, string>
>({});
// 슬라이드 패널 상태
const [panelOpen, setPanelOpen] = useState(false);
const [selectedMessageCode, setSelectedMessageCode] = useState<string | null>(
null,
);
const [selectedServiceCode, setSelectedServiceCode] = useState<string | null>(
null,
);
// 서비스 목록 로드
useEffect(() => {
(async () => {
try {
const res = await fetchServices({ page: 1, pageSize: 100 });
const svcItems = res.data.data.items ?? [];
const names = svcItems.map((s) => s.serviceName);
setServiceFilterOptions(["전체 서비스", ...names]);
const codeMap: Record<string, string> = {};
svcItems.forEach((s) => {
codeMap[s.serviceName] = s.serviceCode;
});
setServiceCodeMap(codeMap);
} catch {
// 서비스 목록 로드 실패 시 기본값 유지
}
})();
}, []);
// 데이터 로드
const loadData = useCallback(
async (page: number, keyword: string, serviceName: string) => {
setLoading(true);
try {
// 서비스명 → 서비스코드 변환
const serviceCode =
serviceName !== "전체 서비스"
? serviceCodeMap[serviceName] || undefined
: undefined;
const res = await fetchMessages({
page,
size: PAGE_SIZE,
keyword: keyword || undefined,
service_code: serviceCode,
});
const data = res.data.data;
setItems(data.items ?? []);
setTotalItems(data.totalCount ?? 0);
setTotalPages(data.totalPages ?? 1);
} catch {
setItems([]);
setTotalItems(0);
setTotalPages(1);
} finally {
setLoading(false);
}
},
[serviceCodeMap],
);
// 초기 로드
useEffect(() => {
loadData(1, "", "전체 서비스");
}, [loadData]);
// URL 쿼리 파라미터로 messageId가 넘어온 경우 자동 검색
useEffect(() => {
const messageId = searchParams.get("messageId");
if (messageId) {
setSearch(messageId);
setAppliedSearch(messageId);
setSearchParams({}, { replace: true });
loadData(1, messageId, "전체 서비스");
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// 조회 버튼
const handleQuery = () => {
setAppliedSearch(search);
setAppliedService(serviceFilter);
setCurrentPage(1);
loadData(1, search, serviceFilter);
};
// 필터 초기화
const handleReset = () => {
setSearch("");
setServiceFilter("전체 서비스");
setAppliedSearch("");
setAppliedService("전체 서비스");
setCurrentPage(1);
loadData(1, "", "전체 서비스");
};
// 페이지 변경
const handlePageChange = (page: number) => {
setCurrentPage(page);
loadData(page, appliedSearch, appliedService);
};
// 행 클릭 → 슬라이드 패널
const handleRowClick = (item: MessageListItem) => {
setSelectedMessageCode(item.message_code);
setSelectedServiceCode(item.service_code);
setPanelOpen(true);
};
return (
<div>
<PageHeader
title="메시지 목록"
description="시스템에서 발송된 모든 메시지 내역을 관리합니다."
action={
<Link
to="/messages/register"
className="flex items-center justify-center gap-2 min-w-[154px] px-5 py-2.5 bg-[#2563EB] hover:bg-[#1d4ed8] text-white rounded-lg text-sm font-medium transition-colors shadow-sm"
>
<span className="material-symbols-outlined text-lg">add</span>
</Link>
}
/>
{/* 필터바 */}
<div className="bg-white border border-gray-200 rounded-lg p-6 mb-6">
<div className="flex items-end gap-4">
<SearchInput
value={search}
onChange={setSearch}
placeholder="검색어를 입력하세요"
label="메시지 ID / 제목"
disabled={loading}
/>
<FilterDropdown
label="서비스 구분"
value={serviceFilter}
options={serviceFilterOptions}
onChange={setServiceFilter}
className="w-[140px] flex-shrink-0"
disabled={loading}
/>
<FilterResetButton onClick={handleReset} disabled={loading} />
<button
onClick={handleQuery}
disabled={loading}
className="h-[38px] flex-shrink-0 bg-gray-800 hover:bg-gray-900 active:scale-95 text-white rounded-lg px-5 text-sm font-medium transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
</div>
</div>
{/* 테이블 */}
{loading ? (
/* 로딩 스켈레톤 */
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden shadow-sm">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-4 text-xs font-semibold uppercase text-gray-700 text-center">
ID
</th>
<th className="px-6 py-4 text-xs font-semibold uppercase text-gray-700 text-center">
</th>
<th className="px-6 py-4 text-xs font-semibold uppercase text-gray-700 text-center">
</th>
<th className="px-6 py-4 text-xs font-semibold uppercase text-gray-700 text-center">
</th>
</tr>
</thead>
<tbody>
{Array.from({ length: 5 }).map((_, i) => (
<tr
key={i}
className={i < 4 ? "border-b border-gray-100" : ""}
>
<td className="px-6 py-4 text-center">
<div className="h-4 w-20 rounded bg-gray-100 animate-pulse mx-auto" />
</td>
<td className="px-6 py-4">
<div className="h-4 w-48 rounded bg-gray-100 animate-pulse" />
</td>
<td className="px-6 py-4 text-center">
<div className="h-4 w-24 rounded bg-gray-100 animate-pulse mx-auto" />
</td>
<td className="px-6 py-4 text-center">
<div className="h-4 w-20 rounded bg-gray-100 animate-pulse mx-auto" />
</td>
</tr>
))}
</tbody>
</table>
</div>
) : items.length > 0 ? (
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden shadow-sm">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-4 text-xs font-semibold uppercase text-gray-700 text-center">
ID
</th>
<th className="px-6 py-4 text-xs font-semibold uppercase text-gray-700 text-center">
</th>
<th className="px-6 py-4 text-xs font-semibold uppercase text-gray-700 text-center">
</th>
<th className="px-6 py-4 text-xs font-semibold uppercase text-gray-700 text-center">
</th>
</tr>
</thead>
<tbody>
{items.map((msg, idx) => (
<tr
key={msg.message_code ?? idx}
className={`${idx < items.length - 1 ? "border-b border-gray-100" : ""} hover:bg-gray-50/50 transition-colors cursor-pointer`}
onClick={() => handleRowClick(msg)}
>
{/* 메시지 ID */}
<td className="px-6 py-4 text-center">
<div
className="flex items-center justify-center gap-2"
onClick={(e) => e.stopPropagation()}
>
<code className="text-sm text-gray-700 font-medium">
{msg.message_code}
</code>
<CopyButton text={msg.message_code ?? ""} />
</div>
</td>
{/* 메시지 제목 */}
<td className="px-6 py-4">
<span className="text-sm text-gray-700 truncate max-w-xs block">
{msg.title}
</span>
</td>
{/* 서비스 */}
<td className="px-6 py-4 text-center">
<span className="text-sm text-gray-600">
{msg.service_name}
</span>
</td>
{/* 작성일 */}
<td className="px-6 py-4 text-center">
<span className="text-sm text-gray-500">
{formatDate(msg.created_at ?? "")}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* 페이지네이션 */}
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={totalItems}
pageSize={PAGE_SIZE}
onPageChange={handlePageChange}
/>
</div>
) : (
<EmptyState
icon="search_off"
message="검색 결과가 없습니다"
description="다른 검색어를 입력하거나 필터를 변경해보세요."
/>
)}
{/* 슬라이드 패널 */}
<MessageSlidePanel
isOpen={panelOpen}
onClose={() => {
setPanelOpen(false);
// 삭제 후 목록 새로고침
loadData(currentPage, appliedSearch, appliedService);
}}
messageCode={selectedMessageCode}
serviceCode={selectedServiceCode}
/>
</div>
);
}

View File

@ -0,0 +1,455 @@
import { useState, useRef, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import PageHeader from "@/components/common/PageHeader";
import useShake from "@/hooks/useShake";
import MessagePreview from "../components/MessagePreview";
import { LINK_TYPE } from "../types";
import type { LinkType } from "../types";
import { fetchServices } from "@/api/service.api";
import { validateMessage, saveMessage } from "@/api/message.api";
interface ServiceOption {
value: string;
label: string;
}
export default function MessageRegisterPage() {
const navigate = useNavigate();
const { triggerShake, cls } = useShake();
// 서비스 옵션 (API에서 로드)
const [serviceOptions, setServiceOptions] = useState<ServiceOption[]>([]);
// 폼 상태
const [service, setService] = useState("");
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
const [imageUrl, setImageUrl] = useState("");
const [linkUrl, setLinkUrl] = useState("");
const [linkType, setLinkType] = useState<LinkType>(LINK_TYPE.WEB);
const [extra, setExtra] = useState("");
// 서비스 드롭다운 상태
const [serviceOpen, setServiceOpen] = useState(false);
const serviceDropdownRef = useRef<HTMLDivElement>(null);
// 필드 ref (스크롤용)
const serviceRef = useRef<HTMLButtonElement>(null);
const titleRef = useRef<HTMLInputElement>(null);
// 에러 메시지 상태 (shake와 별도로 유지)
const [errors, setErrors] = useState<Record<string, string>>({});
// 확인 모달 상태
const [showConfirm, setShowConfirm] = useState(false);
const [saving, setSaving] = useState(false);
// 서비스 드롭다운 외부 클릭 닫기
useEffect(() => {
function handleClick(e: MouseEvent) {
if (
serviceDropdownRef.current &&
!serviceDropdownRef.current.contains(e.target as Node)
) {
setServiceOpen(false);
}
}
document.addEventListener("click", handleClick);
return () => document.removeEventListener("click", handleClick);
}, []);
// 서비스 옵션 로드
useEffect(() => {
(async () => {
try {
const res = await fetchServices({ page: 1, pageSize: 100 });
const items = res.data.data.items ?? [];
setServiceOptions(
items.map((s) => ({ value: s.serviceCode, label: s.serviceName })),
);
} catch {
// 로드 실패 시 빈 배열 유지
}
})();
}, []);
// 필수 필드 검증
const validate = (): boolean => {
const newErrors: Record<string, string> = {};
const shakeFields: string[] = [];
if (!service) {
newErrors.service = "필수 입력 항목입니다.";
shakeFields.push("service");
}
if (!title.trim()) {
newErrors.title = "필수 입력 항목입니다.";
shakeFields.push("title");
}
setErrors(newErrors);
if (shakeFields.length > 0) {
triggerShake(shakeFields);
// 첫 번째 에러 필드로 스크롤 + 포커스
const firstRef = shakeFields[0] === "service" ? serviceRef : titleRef;
firstRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
firstRef.current?.focus({ preventScroll: true });
return false;
}
return true;
};
// 저장 버튼 클릭
const handleSave = () => {
if (!validate()) return;
setShowConfirm(true);
};
// 모달 확인 → 저장 실행
const handleConfirmSave = async () => {
setSaving(true);
try {
// 서버 검증
await validateMessage(
{
title,
body,
image_url: imageUrl || null,
link_url: linkUrl || null,
link_type: linkType || null,
data: extra || undefined,
},
service,
);
// 저장
await saveMessage(
{
title,
body: body || null,
image_url: imageUrl || null,
link_url: linkUrl || null,
link_type: linkType || null,
data: extra || null,
},
service,
);
setShowConfirm(false);
toast.success("저장이 완료되었습니다.");
setTimeout(() => navigate("/messages"), 600);
} catch {
setShowConfirm(false);
toast.error("저장에 실패했습니다.");
} finally {
setSaving(false);
}
};
// 입력 시 해당 필드 에러 제거
const clearError = (field: string) => {
if (errors[field]) {
setErrors((prev) => {
const next = { ...prev };
delete next[field];
return next;
});
}
};
return (
<div>
<PageHeader
title="메시지 작성"
description="새로운 푸시 메시지를 작성하고 실시간 미리보기를 확인하세요."
/>
{/* 12-col 그리드 */}
<div className="grid grid-cols-12 gap-8">
{/* 좌측: 폼 (col-span-7) */}
<div className="col-span-7">
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
{/* 카드 헤더 */}
<div className="flex items-center gap-3 px-6 py-4 border-b border-gray-100 bg-gray-50">
<span className="material-symbols-outlined text-xl text-[#2563EB]">
edit_note
</span>
<h2 className="text-base font-bold text-[#0f172a]"> </h2>
</div>
{/* 폼 */}
<div className="p-6 space-y-6">
{/* 1. 서비스 선택 */}
<div>
<label className="block text-sm font-medium text-[#0f172a] mb-2">
<span className="text-red-500">*</span>
</label>
<div className="relative" ref={serviceDropdownRef}>
<button
ref={serviceRef}
type="button"
onClick={() => setServiceOpen((v) => !v)}
className={`w-full h-[42px] border rounded px-4 text-sm flex items-center justify-between bg-white hover:border-gray-400 transition-colors cursor-pointer ${
!service ? "text-gray-400" : "text-[#0f172a]"
} ${errors.service ? "border-red-500 ring-2 ring-red-500/15" : "border-gray-300"} ${cls("service")}`}
>
<span className="truncate">
{service
? serviceOptions.find((o) => o.value === service)
?.label
: "서비스를 선택하세요"}
</span>
<span className="material-symbols-outlined text-gray-400 text-[18px] flex-shrink-0">
expand_more
</span>
</button>
{serviceOpen && (
<ul className="absolute left-0 top-full mt-1 w-full bg-white border border-gray-200 rounded-lg shadow-lg z-50 py-1 overflow-hidden">
{serviceOptions.map((opt) => (
<li
key={opt.value}
onClick={() => {
setService(opt.value);
clearError("service");
setServiceOpen(false);
}}
className={`px-4 py-2.5 text-sm hover:bg-gray-50 cursor-pointer transition-colors ${
opt.value === service
? "text-[#2563EB] font-medium"
: "text-[#0f172a]"
}`}
>
{opt.label}
</li>
))}
</ul>
)}
</div>
{errors.service && (
<p className="flex items-center gap-1 mt-1.5 text-red-500 text-xs">
<span
className="material-symbols-outlined"
style={{ fontSize: "14px" }}
>
error
</span>
{errors.service}
</p>
)}
</div>
{/* 2. 제목 */}
<div>
<label className="block text-sm font-medium text-[#0f172a] mb-2">
<span className="text-red-500">*</span>
</label>
<input
ref={titleRef}
type="text"
value={title}
onChange={(e) => {
setTitle(e.target.value);
clearError("title");
}}
placeholder="메시지 제목을 입력하세요"
className={`w-full px-4 py-2.5 bg-white border rounded text-sm focus:outline-none focus:ring-2 focus:ring-[#2563EB] focus:border-transparent transition-shadow placeholder-gray-400 text-[#0f172a] ${
errors.title
? "border-red-500 ring-2 ring-red-500/15"
: "border-gray-300"
} ${cls("title")}`}
/>
{errors.title && (
<p className="flex items-center gap-1 mt-1.5 text-red-500 text-xs">
<span
className="material-symbols-outlined"
style={{ fontSize: "14px" }}
>
error
</span>
{errors.title}
</p>
)}
</div>
{/* 3. 내용 */}
<div>
<label className="block text-sm font-medium text-[#0f172a] mb-2">
</label>
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="메시지 내용을 입력하세요"
rows={6}
className="w-full px-4 py-2.5 bg-white border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-[#2563EB] focus:border-transparent transition-shadow placeholder-gray-400 text-[#0f172a] resize-none"
/>
</div>
{/* 4. 이미지 URL */}
<div>
<label className="block text-sm font-medium text-[#0f172a] mb-2">
URL
</label>
<input
type="text"
value={imageUrl}
onChange={(e) => setImageUrl(e.target.value)}
placeholder="https://example.com/image.jpg"
className="w-full px-4 py-2.5 bg-white border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-[#2563EB] focus:border-transparent transition-shadow placeholder-gray-400 text-[#0f172a]"
/>
<p className="text-[11px] text-gray-400 mt-1.5 flex items-center gap-1">
<span
className="material-symbols-outlined flex-shrink-0"
style={{ fontSize: "14px" }}
>
info
</span>
URL을
.
</p>
</div>
{/* 5. 링크 URL + 라디오 */}
<div>
<label className="block text-sm font-medium text-[#0f172a] mb-2">
URL
</label>
<input
type="text"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
placeholder="https://..."
className="w-full px-4 py-2.5 bg-white border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-[#2563EB] focus:border-transparent transition-shadow placeholder-gray-400 text-[#0f172a] mb-3"
/>
<div className="flex items-center gap-6">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="linkType"
value={LINK_TYPE.WEB}
checked={linkType === LINK_TYPE.WEB}
onChange={() => setLinkType(LINK_TYPE.WEB)}
className="w-4 h-4 text-[#2563EB] cursor-pointer"
/>
<span className="text-sm text-[#0f172a] font-medium">
URL
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="linkType"
value={LINK_TYPE.DEEPLINK}
checked={linkType === LINK_TYPE.DEEPLINK}
onChange={() => setLinkType(LINK_TYPE.DEEPLINK)}
className="w-4 h-4 text-[#2563EB] cursor-pointer"
/>
<span className="text-sm text-[#0f172a] font-medium">
</span>
</label>
</div>
</div>
{/* 6. 기타 정보 */}
<div>
<label className="block text-sm font-medium text-[#0f172a] mb-2">
</label>
<textarea
value={extra}
onChange={(e) => setExtra(e.target.value)}
placeholder="추가 정보를 입력하세요"
rows={6}
className="w-full px-4 py-2.5 bg-white border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-[#2563EB] focus:border-transparent transition-shadow placeholder-gray-400 text-[#0f172a] resize-none"
/>
</div>
{/* 저장 버튼 */}
<div className="pt-4 border-t border-gray-100">
<button
type="button"
onClick={handleSave}
className="flex items-center justify-center gap-2 bg-[#2563EB] hover:bg-[#1d4ed8] text-white px-6 py-2.5 rounded text-sm font-medium transition shadow-sm shadow-[#2563EB]/30 w-full"
>
</button>
</div>
</div>
</div>
</div>
{/* 우측: 프리뷰 (col-span-5, sticky) */}
<div className="col-span-5">
<div
className="sticky bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden p-6"
style={{ top: "6rem" }}
>
<MessagePreview
title={title}
body={body}
hasImage={!!imageUrl.trim()}
appName={serviceOptions.find((o) => o.value === service)?.label}
variant="large"
/>
</div>
</div>
</div>
{/* 확인 모달 */}
{showConfirm && (
<div
className="fixed inset-0 bg-black/40 z-[100] flex items-center justify-center"
onClick={(e) => {
if (e.target === e.currentTarget) setShowConfirm(false);
}}
>
<div className="bg-white rounded-xl shadow-2xl max-w-md w-full p-6 border border-gray-200">
<div className="flex items-center gap-3 mb-4">
<div className="size-10 rounded-full bg-amber-100 flex items-center justify-center flex-shrink-0">
<span className="material-symbols-outlined text-amber-600 text-xl">
warning
</span>
</div>
<h3 className="text-lg font-bold text-[#0f172a]"> </h3>
</div>
<p className="text-sm text-[#0f172a] mb-2">
?
</p>
<div className="bg-amber-50 border border-amber-200 rounded-lg px-4 py-3 mb-5">
<div className="flex items-center gap-1.5 text-amber-700 text-xs font-medium">
<span
className="material-symbols-outlined flex-shrink-0"
style={{ fontSize: "14px" }}
>
info
</span>
<span> .</span>
</div>
</div>
<div className="flex justify-end gap-3">
<button
onClick={() => setShowConfirm(false)}
disabled={saving}
className="bg-white border border-gray-300 hover:bg-gray-50 text-[#0f172a] px-5 py-2.5 rounded text-sm font-medium transition"
>
</button>
<button
onClick={handleConfirmSave}
disabled={saving}
className="bg-[#2563EB] hover:bg-[#1d4ed8] text-white px-5 py-2.5 rounded text-sm font-medium transition shadow-sm shadow-[#2563EB]/30 flex items-center gap-2 disabled:opacity-50"
>
<span className="material-symbols-outlined text-base">
check
</span>
<span>{saving ? "저장 중..." : "확인"}</span>
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,93 @@
// 링크 타입
export const LINK_TYPE = {
WEB: "web",
DEEPLINK: "deeplink",
} as const;
export type LinkType = (typeof LINK_TYPE)[keyof typeof LINK_TYPE];
// ── 목록 ──
/** 목록 요청 */
export interface MessageListRequest {
page: number;
size: number;
keyword?: string | null;
is_active?: boolean | null;
service_code?: string | null;
send_status?: string | null;
}
/** 목록 응답 아이템 */
export interface MessageListItem {
message_code: string | null;
title: string | null;
service_name: string | null;
service_code: string | null;
send_status: string | null;
created_at: string | null;
is_active: boolean;
}
/** 목록 응답 */
export interface MessageListResponse {
items: MessageListItem[] | null;
totalCount: number;
page: number;
size: number;
totalPages: number;
}
// ── 상세 ──
/** 상세 요청 */
export interface MessageInfoRequest {
message_code: string | null;
}
/** 상세 응답 */
export interface MessageInfoResponse {
message_code: string | null;
title: string | null;
body: string | null;
image_url: string | null;
link_url: string | null;
link_type: string | null;
data: unknown;
service_name: string | null;
service_code: string | null;
created_by_name: string | null;
latest_send_status: string | null;
created_at: string | null;
}
// ── 저장 ──
/** 저장 요청 */
export interface MessageSaveRequest {
title: string | null;
body: string | null;
image_url: string | null;
link_url: string | null;
link_type: string | null;
data: unknown;
}
// ── 삭제 ──
/** 삭제 요청 */
export interface MessageDeleteRequest {
message_code: string | null;
}
// ── 검증 ──
/** 검증 요청 */
export interface MessageValidateRequest {
title: string;
body: string;
image_url?: string | null;
link_url?: string | null;
link_type?: string | null;
data?: unknown;
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,356 @@
import { useState } from "react";
// Apple 로고 SVG
const AppleLogo = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" />
</svg>
);
interface PlatformSelectorProps {
androidChecked: boolean;
iosChecked: boolean;
onAndroidChange: (checked: boolean) => void;
onIosChange: (checked: boolean) => void;
iosAuthType: "p8" | "p12";
onIosAuthTypeChange: (type: "p8" | "p12") => void;
}
export default function PlatformSelector({
androidChecked,
iosChecked,
onAndroidChange,
onIosChange,
iosAuthType,
onIosAuthTypeChange,
}: PlatformSelectorProps) {
const [androidFile, setAndroidFile] = useState<File | null>(null);
const [iosFile, setIosFile] = useState<File | null>(null);
const [showPassword, setShowPassword] = useState(false);
return (
<div>
<label className="block text-sm font-medium text-[#0f172a] mb-3">
</label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* ── Android 카드 ── */}
<div
onClick={() => { if (!androidChecked) onAndroidChange(true); }}
className={`p-4 rounded-lg border border-gray-200 bg-white transition-opacity ${androidChecked ? "opacity-100" : "opacity-50 cursor-pointer hover:opacity-70"}`}
>
<div className="flex items-center gap-2 mb-3" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
id="android-checkbox"
checked={androidChecked}
onChange={(e) => {
onAndroidChange(e.target.checked);
if (!e.target.checked) setAndroidFile(null);
}}
className="w-4 h-4 rounded border-gray-300 text-[#2563EB] focus:ring-[#2563EB] cursor-pointer"
/>
<label
htmlFor="android-checkbox"
className="flex items-center gap-2 text-sm font-medium text-[#0f172a] cursor-pointer flex-1"
>
<span className="material-symbols-outlined text-[#22C55E]" style={{ fontSize: "20px" }}>
android
</span>
<span>Android</span>
</label>
</div>
{/* 파일 업로드 */}
<div>
<p className="text-xs font-medium text-[#64748b] mb-2">
</p>
{!androidFile ? (
androidChecked ? (
<label
className="flex flex-col items-center justify-center border-2 border-dashed border-gray-300 rounded-lg p-6 text-center transition-colors cursor-pointer hover:border-[#2563EB] hover:bg-[#2563EB]/5"
>
<span className="material-symbols-outlined text-gray-400 text-3xl mb-2">
upload_file
</span>
<p className="text-sm text-[#64748b] font-medium">
</p>
<p className="text-xs text-gray-400 mt-1">
.json
</p>
<input
type="file"
className="hidden"
accept=".json"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) setAndroidFile(file);
}}
/>
</label>
) : (
<div
className="flex flex-col items-center justify-center border-2 border-dashed border-gray-300 rounded-lg p-6 text-center"
>
<span className="material-symbols-outlined text-gray-400 text-3xl mb-2">
lock
</span>
<p className="text-sm text-[#64748b] font-medium">
</p>
</div>
)
) : (
<div className="flex items-center gap-3 bg-gray-50 border border-gray-200 rounded-lg p-4">
<span className="material-symbols-outlined text-[#2563EB] text-xl flex-shrink-0">
description
</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-[#0f172a] truncate">
{androidFile.name}
</p>
<p className="text-xs text-[#64748b]">
{(androidFile.size / 1024).toFixed(1)} KB
</p>
</div>
<button
type="button"
onClick={() => setAndroidFile(null)}
className="text-gray-400 hover:text-red-500 transition-colors flex-shrink-0"
>
<span className="material-symbols-outlined text-lg">close</span>
</button>
</div>
)}
</div>
</div>
{/* ── iOS 카드 ── */}
<div
onClick={() => { if (!iosChecked) onIosChange(true); }}
className={`p-4 rounded-lg border border-gray-200 bg-white transition-opacity ${iosChecked ? "opacity-100" : "opacity-50 cursor-pointer hover:opacity-70"}`}
>
<div className="flex items-center gap-2 mb-3" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
id="ios-checkbox"
checked={iosChecked}
onChange={(e) => {
onIosChange(e.target.checked);
if (!e.target.checked) {
setIosFile(null);
setShowPassword(false);
}
}}
className="w-4 h-4 rounded border-gray-300 text-[#2563EB] focus:ring-[#2563EB] cursor-pointer"
/>
<label
htmlFor="ios-checkbox"
className="flex items-center gap-2 text-sm font-medium text-[#0f172a] cursor-pointer flex-1"
>
<AppleLogo className="w-5 h-5" />
<span>iOS</span>
</label>
</div>
{/* iOS 미선택 시 — Android와 동일 */}
{!iosChecked && (
<div>
<p className="text-xs font-medium text-[#64748b] mb-2">
</p>
<div
className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center"
>
<span className="material-symbols-outlined text-gray-400 text-3xl flex justify-center mb-2">
lock
</span>
<p className="text-sm text-[#64748b] font-medium">
</p>
</div>
</div>
)}
{/* iOS 선택됨 — 상세 페이지 모달과 동일 레이아웃 */}
{iosChecked && (
<div className="space-y-5">
{/* 인증 방식 선택 */}
<div>
<label className="block text-sm font-medium text-[#0f172a] mb-2">
</label>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => { onIosAuthTypeChange("p8"); setIosFile(null); }}
className={`p-3 rounded-lg border-2 text-left transition ${
iosAuthType === "p8"
? "border-[#2563EB] bg-[#2563EB]/5"
: "border-gray-200 bg-white hover:border-gray-300"
}`}
>
<div className="flex items-center gap-2 mb-1">
<span
className={`material-symbols-outlined ${iosAuthType === "p8" ? "text-[#2563EB]" : "text-gray-500"}`}
style={{ fontSize: "14px" }}
>
vpn_key
</span>
<span className={`text-sm font-medium ${iosAuthType === "p8" ? "text-[#2563EB]" : "text-[#0f172a]"}`}>
Token (.p8)
</span>
</div>
<p className="text-[11px] text-[#64748b]">
APNs Auth Key , ()
</p>
</button>
<button
type="button"
onClick={() => { onIosAuthTypeChange("p12"); setIosFile(null); }}
className={`p-3 rounded-lg border-2 text-left transition ${
iosAuthType === "p12"
? "border-[#2563EB] bg-[#2563EB]/5"
: "border-gray-200 bg-white hover:border-gray-300"
}`}
>
<div className="flex items-center gap-2 mb-1">
<span
className={`material-symbols-outlined ${iosAuthType === "p12" ? "text-[#2563EB]" : "text-gray-500"}`}
style={{ fontSize: "14px" }}
>
badge
</span>
<span className={`text-sm font-medium ${iosAuthType === "p12" ? "text-[#2563EB]" : "text-[#0f172a]"}`}>
Certificate (.p12)
</span>
</div>
<p className="text-[11px] text-[#64748b]">
, 1
</p>
</button>
</div>
</div>
{/* 인증서 파일 */}
<div>
<label className="block text-sm font-medium text-[#0f172a] mb-2">
</label>
{!iosFile ? (
<label className="flex flex-col items-center justify-center border-2 border-dashed border-gray-300 rounded-lg p-6 cursor-pointer hover:border-[#2563EB] hover:bg-[#2563EB]/5 transition text-center">
<span className="material-symbols-outlined text-gray-400 text-3xl mb-2">
upload_file
</span>
<p className="text-sm text-[#0f172a] font-medium">
</p>
<p className="text-xs text-[#64748b] mt-1">
{iosAuthType === "p8"
? ".p8 파일만 업로드 가능합니다"
: ".p12 파일만 업로드 가능합니다"}
</p>
<input
type="file"
className="hidden"
accept={iosAuthType === "p8" ? ".p8" : ".p12"}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) setIosFile(file);
}}
/>
</label>
) : (
<div className="flex items-center gap-3 bg-gray-50 border border-gray-200 rounded-lg p-4">
<span className="material-symbols-outlined text-[#2563EB] text-xl flex-shrink-0">
description
</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-[#0f172a] truncate">
{iosFile.name}
</p>
<p className="text-xs text-[#64748b]">
{(iosFile.size / 1024).toFixed(1)} KB
</p>
</div>
<button
type="button"
onClick={() => setIosFile(null)}
className="text-gray-400 hover:text-red-500 transition-colors flex-shrink-0"
>
<span className="material-symbols-outlined text-lg">close</span>
</button>
</div>
)}
</div>
{/* P8: Key ID + Team ID */}
{iosAuthType === "p8" && (
<div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-[#0f172a] mb-1.5">
Key ID
</label>
<input
type="text"
placeholder="예: ABC123DEFG"
className="w-full px-3 py-2.5 border border-gray-300 rounded text-sm font-mono focus:outline-none focus:ring-2 focus:ring-[#2563EB]/20 focus:border-[#2563EB] transition placeholder-gray-400"
/>
</div>
<div>
<label className="block text-sm font-medium text-[#0f172a] mb-1.5">
Team ID
</label>
<input
type="text"
placeholder="예: 9ABCDEFGH1"
className="w-full px-3 py-2.5 border border-gray-300 rounded text-sm font-mono focus:outline-none focus:ring-2 focus:ring-[#2563EB]/20 focus:border-[#2563EB] transition placeholder-gray-400"
/>
</div>
</div>
<div className="flex items-center gap-1.5 text-[#64748b] text-xs mt-2">
<span
className="material-symbols-outlined flex-shrink-0"
style={{ fontSize: "14px" }}
>
info
</span>
<span>Apple Developer .</span>
</div>
</div>
)}
{/* P12: 비밀번호 */}
{iosAuthType === "p12" && (
<div>
<label className="block text-sm font-medium text-[#0f172a] mb-1.5">
P12
</label>
<div className="relative">
<input
type={showPassword ? "text" : "password"}
placeholder="P12 인증서 비밀번호를 입력하세요"
className="w-full px-3 py-2.5 pr-10 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-[#2563EB]/20 focus:border-[#2563EB] transition placeholder-gray-400"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 transition"
>
<span className="material-symbols-outlined text-lg">
{showPassword ? "visibility" : "visibility_off"}
</span>
</button>
</div>
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,89 @@
import type { PlatformCredentialSummary } from "../types";
// Apple 로고 SVG
const AppleLogo = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" />
</svg>
);
// 상태별 dot 색상
const DOT_STYLES = {
warn: "bg-amber-500",
error: "bg-red-500",
} as const;
// 상태별 툴팁 스타일
const TOOLTIP_STYLES = {
warn: "bg-[#fffbeb] text-[#b45309] border border-[#fde68a]",
error: "bg-[#fef2f2] text-[#dc2626] border border-[#fecaca]",
} as const;
const TOOLTIP_ARROW = {
warn: "border-b-[#fde68a]",
error: "border-b-[#fecaca]",
} as const;
const TOOLTIP_LABEL = {
warn: "주의",
error: "경고",
} as const;
// 상태별 배지 스타일 (error 상태일 때 배지 자체도 회색)
const BADGE_ACTIVE = {
android: "bg-green-50 text-green-700 border-green-200",
ios: "bg-slate-100 text-slate-700 border-slate-200",
} as const;
const BADGE_INACTIVE = "bg-gray-100 text-gray-400 border-gray-200";
interface PlatformStatusIndicatorProps {
platform: "android" | "ios";
credential: PlatformCredentialSummary;
}
export default function PlatformStatusIndicator({
platform,
credential,
}: PlatformStatusIndicatorProps) {
if (!credential?.registered) return null;
const hasIssue =
credential.credentialStatus === "warn" ||
credential.credentialStatus === "error";
const badgeClass =
credential.credentialStatus === "error"
? BADGE_INACTIVE
: BADGE_ACTIVE[platform];
return (
<div className="relative inline-flex group">
<span
className={`inline-flex items-center justify-center w-8 h-6 rounded text-xs font-medium border ${badgeClass}`}
>
{platform === "android" ? (
<span className="material-symbols-outlined" style={{ fontSize: "18px" }}>android</span>
) : (
<AppleLogo className="w-3.5 h-3.5" />
)}
</span>
{hasIssue && (credential.credentialStatus === "warn" || credential.credentialStatus === "error") && (
<>
<span
className={`absolute -top-1 -right-1 size-2.5 rounded-full border-2 border-white ${DOT_STYLES[credential.credentialStatus]}`}
/>
{/* 호버 툴팁 */}
<span
className={`absolute top-full left-1/2 -translate-x-1/2 mt-1.5 text-[11px] font-medium px-2.5 py-0.5 rounded-md whitespace-nowrap opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-150 pointer-events-none z-50 ${TOOLTIP_STYLES[credential.credentialStatus]}`}
>
{/* 위쪽 화살표 */}
<span
className={`absolute bottom-full left-1/2 -translate-x-1/2 border-4 border-transparent ${TOOLTIP_ARROW[credential.credentialStatus]}`}
/>
{TOOLTIP_LABEL[credential.credentialStatus]}
</span>
</>
)}
</div>
);
}

View File

@ -0,0 +1,139 @@
import { Link } from "react-router-dom";
import StatusBadge from "@/components/common/StatusBadge";
import CopyButton from "@/components/common/CopyButton";
import PlatformStatusIndicator from "./PlatformStatusIndicator";
import { formatNumber } from "@/utils/format";
import type { ServiceDetail } from "../types";
import { SERVICE_STATUS } from "../types";
interface ServiceHeaderCardProps {
service: ServiceDetail;
onShowApiKey: () => void;
onDelete: () => void;
}
export default function ServiceHeaderCard({
service,
onShowApiKey,
onDelete,
}: ServiceHeaderCardProps) {
return (
<div className="bg-white border border-gray-200 rounded-lg shadow-sm p-6 mb-6">
{/* 상단 행 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="size-14 rounded-xl bg-[#2563EB]/10 flex items-center justify-center">
<span className="material-symbols-outlined text-[#2563EB] text-3xl">
{service.serviceIcon || "hub"}
</span>
</div>
<div className="flex flex-col">
<div className="flex items-center gap-3">
<h2 className="text-2xl font-bold text-[#0f172a]">
{service.serviceName}
</h2>
<StatusBadge
variant={
service.status === SERVICE_STATUS.ACTIVE ? "success" : "error"
}
label={
service.status === SERVICE_STATUS.ACTIVE ? "활성" : "비활성"
}
/>
</div>
<div className="flex items-center gap-4 mt-1.5">
<div className="inline-flex items-center gap-1.5">
<span className="text-sm text-[#64748b] font-mono">
{service.serviceCode}
</span>
<CopyButton text={service.serviceCode} />
</div>
<span className="text-gray-300">|</span>
<button
onClick={onShowApiKey}
className="flex items-center gap-1 text-sm text-[#2563EB] hover:text-[#1d4ed8] transition-colors cursor-pointer"
>
<span
className="material-symbols-outlined"
style={{ fontSize: "14px" }}
>
vpn_key
</span>
<span className="font-medium">API </span>
</button>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={onDelete}
className="border border-red-200 text-red-500 hover:bg-red-50 px-5 py-2.5 rounded text-sm font-medium transition flex items-center gap-2"
>
<span className="material-symbols-outlined text-base">delete</span>
<span></span>
</button>
<Link
to={`/services/${service.serviceCode}/edit`}
className="bg-[#2563EB] hover:bg-[#1d4ed8] text-white px-5 py-2.5 rounded text-sm font-medium transition shadow-sm flex items-center gap-2"
>
<span className="material-symbols-outlined text-base">edit</span>
<span></span>
</Link>
</div>
</div>
{/* 메타 정보 */}
<div className="mt-6 pt-5 border-t border-gray-100 grid grid-cols-2 sm:grid-cols-4 gap-6">
<div className="flex flex-col">
<p className="text-xs text-[#64748b] font-semibold uppercase tracking-wide">
</p>
<div className="flex items-center gap-2.5 mt-1.5">
{service.platforms ? (
<>
<PlatformStatusIndicator
platform="android"
credential={service.platforms.android}
/>
<PlatformStatusIndicator
platform="ios"
credential={service.platforms.ios}
/>
{!service.platforms?.android?.registered &&
!service.platforms?.ios?.registered && (
<span className="text-xs text-gray-400"></span>
)}
</>
) : (
<span className="text-xs text-gray-400"></span>
)}
</div>
</div>
<div className="flex flex-col">
<p className="text-xs text-[#64748b] font-semibold uppercase tracking-wide">
</p>
<p className="text-sm font-semibold text-[#0f172a] mt-1.5">
{formatNumber(service.deviceCount)}
</p>
</div>
<div className="flex flex-col">
<p className="text-xs text-[#64748b] font-semibold uppercase tracking-wide">
</p>
<p className="text-sm font-semibold text-[#0f172a] mt-1.5">
{service.createdAt?.slice(0, 10)}
</p>
</div>
<div className="flex flex-col">
<p className="text-xs text-[#64748b] font-semibold uppercase tracking-wide">
</p>
<p className="text-sm font-semibold text-[#0f172a] mt-1.5">
{service.updatedAt?.slice(0, 10) ?? "-"}
</p>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,133 @@
interface StatCard {
label: string;
value: string;
sub: { type: "trend" | "stable"; text: string; color?: string };
icon: string;
iconBg: string;
iconColor: string;
}
interface ServiceStatsCardsProps {
totalSent: number;
successRate: number;
deviceCount: number;
todaySent: number;
sentChangeRate?: number;
successRateChange?: number;
deviceCountChange?: number;
todaySentChangeRate?: number;
}
export default function ServiceStatsCards({
totalSent,
successRate,
deviceCount,
todaySent,
sentChangeRate = 0,
successRateChange = 0,
deviceCountChange = 0,
todaySentChangeRate = 0,
}: ServiceStatsCardsProps) {
const noChange: StatCard["sub"] = { type: "stable", text: "변동 없음" };
const cards: StatCard[] = [
{
label: "총 발송 수",
value: totalSent.toLocaleString(),
sub: sentChangeRate === 0
? noChange
: sentChangeRate > 0
? { type: "trend", text: `+${sentChangeRate.toLocaleString()}`, color: "text-indigo-600" }
: { type: "trend", text: `${sentChangeRate.toLocaleString()}`, color: "text-red-500" },
icon: "equalizer",
iconBg: "bg-indigo-50",
iconColor: "text-indigo-600",
},
{
label: "성공률",
value: `${successRate}%`,
sub: successRateChange === 0
? noChange
: successRateChange > 0
? { type: "trend", text: `+${successRateChange.toFixed(1)}%`, color: "text-emerald-600" }
: { type: "trend", text: `${successRateChange.toFixed(1)}%`, color: "text-red-500" },
icon: "check_circle",
iconBg: "bg-emerald-50",
iconColor: "text-emerald-600",
},
{
label: "등록 기기 수",
value: deviceCount.toLocaleString(),
sub: deviceCountChange === 0
? noChange
: deviceCountChange > 0
? { type: "trend", text: `+${deviceCountChange.toLocaleString()} today`, color: "text-amber-600" }
: { type: "trend", text: `${deviceCountChange.toLocaleString()} today`, color: "text-red-500" },
icon: "devices",
iconBg: "bg-amber-50",
iconColor: "text-amber-600",
},
{
label: "오늘 발송",
value: todaySent.toLocaleString(),
sub: todaySentChangeRate === 0
? noChange
: todaySentChangeRate > 0
? { type: "trend", text: `+${todaySentChangeRate.toLocaleString()}`, color: "text-[#2563EB]" }
: { type: "trend", text: `${todaySentChangeRate.toLocaleString()}`, color: "text-red-500" },
icon: "today",
iconBg: "bg-[#2563EB]/5",
iconColor: "text-[#2563EB]",
},
];
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
{cards.map((card) => (
<div
key={card.label}
className="bg-white border border-gray-200 rounded-lg shadow-sm p-6"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="text-xs text-[#64748b] font-semibold uppercase tracking-wide">
{card.label}
</p>
<p className="text-2xl font-bold text-[#0f172a] mt-2">
{card.value}
</p>
<div className="mt-2">
{card.sub.type === "trend" && (
<p className={`text-xs ${card.sub.color ?? "text-green-600"} font-medium flex items-center gap-1`}>
<span
className="material-symbols-outlined"
style={{ fontSize: "14px" }}
>
{card.sub.text.startsWith("-") ? "trending_down" : "trending_up"}
</span>
<span>{card.sub.text}</span>
</p>
)}
{card.sub.type === "stable" && (
<p className="text-xs text-gray-600 font-medium flex items-center gap-1">
<span className="w-2 h-2 rounded-full bg-gray-400" />
<span>{card.sub.text}</span>
</p>
)}
</div>
</div>
<div
className={`size-12 rounded-lg ${card.iconBg} flex items-center justify-center flex-shrink-0`}
>
<span
className={`material-symbols-outlined ${card.iconColor} text-2xl`}
>
{card.icon}
</span>
</div>
</div>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,313 @@
import { useState, useEffect, useCallback } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { toast } from "sonner";
import PageHeader from "@/components/common/PageHeader";
import CopyButton from "@/components/common/CopyButton";
import ServiceHeaderCard from "../components/ServiceHeaderCard";
import ServiceStatsCards from "../components/ServiceStatsCards";
import PlatformManagement from "../components/PlatformManagement";
import { fetchServiceDetail, fetchApiKey, deleteService } from "@/api/service.api";
import { fetchDashboard } from "@/api/dashboard.api";
import type { ServiceDetail } from "../types";
import type { DashboardKpi } from "@/features/dashboard/types";
export default function ServiceDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
// 데이터 상태
const [service, setService] = useState<ServiceDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
// 통계 상태
const [stats, setStats] = useState<DashboardKpi | null>(null);
// API 키 모달 상태
const [showApiKey, setShowApiKey] = useState(false);
const [fullApiKey, setFullApiKey] = useState<string | null>(null);
const [apiKeyLoading, setApiKeyLoading] = useState(false);
// 삭제 모달 상태
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deleting, setDeleting] = useState(false);
// 서비스 상세 + 통계 로드
const loadData = useCallback(async (serviceCode: string) => {
setLoading(true);
setError(false);
// 오늘 날짜 기준 통계 요청
const today = new Date().toISOString().slice(0, 10);
try {
const [serviceRes, dashboardRes] = await Promise.allSettled([
fetchServiceDetail(serviceCode),
fetchDashboard({ start_date: today, end_date: today }, serviceCode),
]);
if (serviceRes.status === "fulfilled") {
setService(serviceRes.value.data.data);
} else {
setError(true);
return;
}
if (dashboardRes.status === "fulfilled") {
setStats(dashboardRes.value.data.data.kpi);
}
// 통계 실패 시 null 유지 → 0으로 폴백
} catch {
setError(true);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (!id) return;
loadData(id);
}, [id, loadData]);
// 플랫폼 변경 후 서비스 데이터 갱신 (로딩 스켈레톤 없이 조용히)
const handleRefresh = useCallback(async () => {
if (!id) return;
const today = new Date().toISOString().slice(0, 10);
try {
const [serviceRes, dashboardRes] = await Promise.allSettled([
fetchServiceDetail(id),
fetchDashboard({ start_date: today, end_date: today }, id),
]);
if (serviceRes.status === "fulfilled") {
setService(serviceRes.value.data.data);
}
if (dashboardRes.status === "fulfilled") {
setStats(dashboardRes.value.data.data.kpi);
}
} catch {
// 리프레시 실패는 무시 (기존 데이터 유지)
}
}, [id]);
// API 키 조회
const handleShowApiKey = async () => {
if (!id) return;
setApiKeyLoading(true);
try {
const res = await fetchApiKey(id);
setFullApiKey(res.data.data.apiKey);
setShowApiKey(true);
} catch {
// 실패 시 마스킹된 키라도 보여줌
setFullApiKey(service?.apiKey ?? null);
setShowApiKey(true);
} finally {
setApiKeyLoading(false);
}
};
const handleCloseApiKey = () => {
setShowApiKey(false);
setFullApiKey(null);
};
// 서비스 삭제
const handleDelete = async () => {
if (!id) return;
setDeleting(true);
try {
await deleteService(id);
toast.success("서비스가 삭제되었습니다.");
navigate("/services");
} catch {
toast.error("서비스 삭제에 실패했습니다.");
} finally {
setDeleting(false);
}
};
// 로딩 스켈레톤
if (loading) {
return (
<div>
<PageHeader title="서비스 상세 정보" />
<div className="bg-white border border-gray-200 rounded-lg shadow-sm p-6 mb-6">
<div className="flex items-center gap-4">
<div className="size-14 rounded-xl bg-gray-100 animate-pulse" />
<div className="flex flex-col gap-2">
<div className="h-6 w-48 rounded bg-gray-100 animate-pulse" />
<div className="h-4 w-32 rounded bg-gray-100 animate-pulse" />
</div>
</div>
<div className="mt-6 pt-5 border-t border-gray-100 grid grid-cols-4 gap-6">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex flex-col gap-2">
<div className="h-3 w-16 rounded bg-gray-100 animate-pulse" />
<div className="h-4 w-24 rounded bg-gray-100 animate-pulse" />
</div>
))}
</div>
</div>
</div>
);
}
// 에러 상태
if (error || !service) {
return (
<div>
<PageHeader title="서비스 상세 정보" />
<div className="bg-white border border-gray-200 rounded-lg shadow-sm p-12 flex flex-col items-center justify-center gap-3">
<span className="material-symbols-outlined text-4xl text-gray-300">
cloud_off
</span>
<p className="text-sm text-gray-500">
.
</p>
<button
onClick={() => window.location.reload()}
className="text-sm text-[#2563EB] hover:underline font-medium"
>
</button>
</div>
</div>
);
}
// 오늘 발송 = kpi.total_send (서비스별 조회 시 오늘 기간)
const todaySent = stats?.total_send ?? 0;
const successRate =
stats && stats.total_send > 0
? +(stats.total_success / stats.total_send * 100).toFixed(1)
: 0;
return (
<div>
<PageHeader title="서비스 상세 정보" />
<ServiceHeaderCard
service={service}
onShowApiKey={handleShowApiKey}
onDelete={() => setShowDeleteConfirm(true)}
/>
<ServiceStatsCards
totalSent={stats?.total_send ?? 0}
successRate={successRate}
deviceCount={service.deviceCount}
todaySent={todaySent}
sentChangeRate={stats?.today_sent_change_rate}
successRateChange={stats?.success_rate_change}
deviceCountChange={stats?.device_count_change}
todaySentChangeRate={stats?.today_sent_change_rate}
/>
<PlatformManagement service={service} onRefresh={handleRefresh} />
{/* 삭제 확인 모달 */}
{showDeleteConfirm && (
<div className="fixed inset-0 z-[60] flex items-center justify-center">
<div
className="absolute inset-0 bg-black/40"
onClick={() => setShowDeleteConfirm(false)}
/>
<div className="relative bg-white rounded-xl shadow-2xl max-w-md w-full mx-4 p-6 border border-gray-200">
<div className="flex items-center gap-3 mb-4">
<div className="size-10 rounded-full bg-red-100 flex items-center justify-center flex-shrink-0">
<span className="material-symbols-outlined text-red-600 text-xl">
warning
</span>
</div>
<h3 className="text-lg font-bold text-[#0f172a]"> </h3>
</div>
<p className="text-sm text-[#0f172a] mb-2">
<span className="font-semibold">{service.serviceName}</span> ?
</p>
<div className="bg-red-50 border border-red-200 rounded-lg px-4 py-3 mb-5 flex flex-col gap-1.5">
<div className="flex items-center gap-1.5 text-red-700 text-xs font-medium">
<span className="material-symbols-outlined flex-shrink-0" style={{ fontSize: "14px" }}>info</span>
<span> .</span>
</div>
<div className="flex items-center gap-1.5 text-red-700 text-xs font-medium">
<span className="material-symbols-outlined flex-shrink-0" style={{ fontSize: "14px" }}>info</span>
<span> .</span>
</div>
</div>
<div className="flex justify-end gap-3">
<button
onClick={() => setShowDeleteConfirm(false)}
disabled={deleting}
className="bg-white border border-gray-300 hover:bg-gray-50 text-[#0f172a] px-5 py-2.5 rounded text-sm font-medium transition disabled:opacity-50"
>
</button>
<button
onClick={handleDelete}
disabled={deleting}
className="bg-red-500 hover:bg-red-600 text-white px-5 py-2.5 rounded text-sm font-medium transition shadow-sm disabled:opacity-50"
>
{deleting ? "삭제 중..." : "삭제"}
</button>
</div>
</div>
</div>
)}
{/* API 키 확인 모달 */}
{showApiKey && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/40"
onClick={handleCloseApiKey}
/>
<div className="relative bg-white rounded-xl shadow-2xl max-w-lg w-full mx-4 p-6 border border-gray-200">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="size-10 rounded-full bg-[#2563EB]/10 flex items-center justify-center flex-shrink-0">
<span className="material-symbols-outlined text-[#2563EB] text-xl">
vpn_key
</span>
</div>
<h3 className="text-lg font-bold text-[#0f172a]">API </h3>
</div>
<button
onClick={handleCloseApiKey}
className="text-gray-400 hover:text-gray-600 transition-colors p-1 rounded hover:bg-gray-100"
>
<span className="material-symbols-outlined text-xl">
close
</span>
</button>
</div>
<div className="bg-amber-50 border border-amber-200 rounded-lg px-4 py-3 mb-4">
<div className="flex items-center gap-1.5 text-amber-700 text-xs font-medium">
<span
className="material-symbols-outlined flex-shrink-0"
style={{ fontSize: "14px" }}
>
warning
</span>
<span>
API .
</span>
</div>
</div>
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 flex items-center gap-3">
{apiKeyLoading ? (
<div className="flex-1 h-5 rounded bg-gray-200 animate-pulse" />
) : (
<>
<code className="flex-1 text-sm font-mono text-[#0f172a] break-all select-all">
{fullApiKey ?? service.apiKey}
</code>
<CopyButton text={fullApiKey ?? service.apiKey} />
</>
)}
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,570 @@
import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
import { AxiosError } from "axios";
import PageHeader from "@/components/common/PageHeader";
import PlatformStatusIndicator from "../components/PlatformStatusIndicator";
import useShake from "@/hooks/useShake";
import { formatNumber } from "@/utils/format";
import { fetchServiceDetail, updateService } from "@/api/service.api";
import type { ApiError } from "@/types/api";
import type { ServiceDetail } from "../types";
import { SERVICE_STATUS } from "../types";
// Apple 로고 SVG
const AppleLogo = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" />
</svg>
);
export default function ServiceEditPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
// 데이터 상태
const [service, setService] = useState<ServiceDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
// 폼 상태 (service 로드 후 초기화)
const [serviceName, setServiceName] = useState("");
const [isActive, setIsActive] = useState(true);
const [description, setDescription] = useState("");
// 에러 상태
const [nameError, setNameError] = useState(false);
const [nameErrorMsg, setNameErrorMsg] = useState("필수 입력 항목입니다.");
const { triggerShake, cls } = useShake();
// 제출 상태
const [submitting, setSubmitting] = useState(false);
// 모달 상태
const [showSaveModal, setShowSaveModal] = useState(false);
const [showCancelModal, setShowCancelModal] = useState(false);
// 서비스 상세 로드
useEffect(() => {
if (!id) return;
let cancelled = false;
async function load() {
setLoading(true);
setError(false);
try {
const res = await fetchServiceDetail(id!);
if (!cancelled) {
setService(res.data.data);
}
} catch {
if (!cancelled) {
setError(true);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
load();
return () => {
cancelled = true;
};
}, [id]);
// service 로드 후 폼 초기화
useEffect(() => {
if (service) {
setServiceName(service.serviceName);
setIsActive(service.status === SERVICE_STATUS.ACTIVE);
setDescription(service.description ?? "");
}
}, [service]);
// 저장
const handleSave = () => {
const trimmed = serviceName.trim();
if (!trimmed || trimmed.length < 2) {
setNameError(true);
setNameErrorMsg(
!trimmed ? "필수 입력 항목입니다." : "서비스 명은 2자 이상이어야 합니다.",
);
triggerShake(["name"]);
return;
}
setShowSaveModal(true);
};
const handleSaveConfirm = async () => {
setShowSaveModal(false);
setSubmitting(true);
try {
await updateService({
serviceCode: id!,
serviceName: serviceName.trim(),
description: description.trim() || null,
status: isActive ? 0 : 1,
});
toast.success("변경사항이 저장되었습니다.");
navigate(`/services/${id}`);
} catch (err) {
const axiosErr = err as AxiosError<ApiError>;
const status = axiosErr.response?.status;
const msg = axiosErr.response?.data?.msg;
if (status === 409) {
toast.error(msg ?? "이미 사용 중인 서비스 명입니다.");
setNameError(true);
setNameErrorMsg(msg ?? "이미 사용 중인 서비스 명입니다.");
triggerShake(["name"]);
} else if (status === 400) {
toast.error(msg ?? "변경된 내용이 없습니다.");
} else {
toast.error(msg ?? "저장에 실패했습니다. 다시 시도해주세요.");
}
} finally {
setSubmitting(false);
}
};
// 로딩 스켈레톤
if (loading) {
return (
<div>
<PageHeader title="서비스 수정" />
<div className="border border-gray-200 rounded-lg bg-white shadow-sm p-8">
<div className="space-y-6">
<div>
<div className="h-4 w-20 bg-gray-200 rounded animate-pulse mb-2" />
<div className="h-10 bg-gray-100 rounded animate-pulse" />
</div>
<div>
<div className="h-4 w-16 bg-gray-200 rounded animate-pulse mb-2" />
<div className="h-10 bg-gray-100 rounded animate-pulse" />
</div>
<div>
<div className="h-4 w-12 bg-gray-200 rounded animate-pulse mb-2" />
<div className="h-24 bg-gray-100 rounded animate-pulse" />
</div>
</div>
</div>
</div>
);
}
// 에러 상태
if (error || !service) {
return (
<div>
<PageHeader title="서비스 수정" />
<div className="border border-gray-200 rounded-lg bg-white shadow-sm p-12 text-center">
<span className="material-symbols-outlined text-gray-300 text-5xl mb-3 block">
error_outline
</span>
<p className="text-sm text-gray-500 mb-4">
.
</p>
<button
onClick={() => navigate("/services")}
className="text-sm text-[#2563EB] hover:underline font-medium"
>
</button>
</div>
</div>
);
}
return (
<div>
<PageHeader
title="서비스 수정"
description="서비스 정보를 수정하고 저장하세요."
/>
{/* 폼 카드 */}
<div className="border border-gray-200 rounded-lg bg-white shadow-sm overflow-hidden mb-8">
<div className="p-8 space-y-6">
{/* 1. 서비스 명 + 상태 토글 */}
<div>
<label className="block text-sm font-medium text-[#0f172a] mb-1.5">
<span className="text-red-500">*</span>
</label>
<div className="flex items-center gap-3">
<input
type="text"
value={serviceName}
onChange={(e) => {
setServiceName(e.target.value);
if (e.target.value.trim()) setNameError(false);
}}
className={`flex-1 px-3 py-2.5 bg-white border rounded text-sm focus:outline-none focus:ring-2 focus:ring-[#2563EB] focus:border-transparent transition-shadow placeholder-gray-400 ${
nameError ? "border-red-500 ring-2 ring-red-500/15" : "border-gray-300"
} ${cls("name")}`}
/>
<button
type="button"
onClick={() => setIsActive(!isActive)}
className={`w-[100px] inline-flex items-center justify-center gap-1.5 rounded px-3 py-2 text-xs font-medium cursor-pointer transition-colors flex-shrink-0 border ${
isActive
? "bg-green-50 border-green-200 text-green-700 hover:bg-green-100"
: "bg-gray-100 border-gray-300 text-gray-500 hover:bg-gray-200"
}`}
>
<span
className="material-symbols-outlined"
style={{ fontSize: "14px" }}
>
{isActive ? "check_circle" : "cancel"}
</span>
<span>{isActive ? "활성" : "비활성"}</span>
</button>
</div>
{nameError && (
<div className="flex items-center gap-1.5 text-red-600 text-xs mt-1.5">
<span
className="material-symbols-outlined flex-shrink-0"
style={{ fontSize: "14px" }}
>
error
</span>
<span>{nameErrorMsg}</span>
</div>
)}
</div>
{/* 2. 서비스 ID (읽기 전용) */}
<div>
<label className="block text-sm font-medium text-[#0f172a] mb-1.5">
ID
</label>
<div className="flex items-center gap-3">
<input
type="text"
value={service.serviceCode}
disabled
className="flex-1 px-3 py-2.5 bg-gray-50 border border-gray-200 rounded text-sm text-gray-500 cursor-not-allowed font-mono"
/>
<div className="w-[100px] flex items-center justify-center gap-1.5 bg-amber-50 border border-amber-200 rounded px-2 py-2 flex-shrink-0">
<span
className="material-symbols-outlined text-amber-600"
style={{ fontSize: "14px" }}
>
lock
</span>
<span className="text-xs text-amber-700 font-medium whitespace-nowrap">
</span>
</div>
</div>
</div>
{/* 3. 설명 */}
<div>
<label className="block text-sm font-medium text-[#0f172a] mb-1.5">
</label>
<textarea
rows={4}
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full px-3 py-2.5 bg-white border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-[#2563EB]/20 focus:border-[#2563EB] transition placeholder-gray-400 resize-none"
/>
</div>
{/* 메타 정보 (읽기 전용) */}
<div className="pt-4 border-t border-gray-100">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-6">
<div className="flex flex-col">
<p className="text-xs text-[#64748b] font-semibold uppercase tracking-wide">
</p>
<div className="flex items-center gap-1.5 mt-1.5">
{service.platforms ? (
<>
<PlatformStatusIndicator
platform="android"
credential={service.platforms.android}
/>
<PlatformStatusIndicator
platform="ios"
credential={service.platforms.ios}
/>
{!service.platforms?.android?.registered &&
!service.platforms?.ios?.registered && (
<span className="text-xs text-gray-400"></span>
)}
</>
) : (
<span className="text-xs text-gray-400"></span>
)}
</div>
</div>
<div className="flex flex-col">
<p className="text-xs text-[#64748b] font-semibold uppercase tracking-wide">
</p>
<p className="text-sm font-semibold text-[#0f172a] mt-1.5">
{formatNumber(service.deviceCount)}
</p>
</div>
<div className="flex flex-col">
<p className="text-xs text-[#64748b] font-semibold uppercase tracking-wide">
</p>
<p className="text-sm font-semibold text-[#0f172a] mt-1.5">
{service.createdAt?.slice(0, 10)}
</p>
</div>
<div className="flex flex-col">
<p className="text-xs text-[#64748b] font-semibold uppercase tracking-wide">
</p>
<p className="text-sm font-semibold text-[#0f172a] mt-1.5">
{service.updatedAt?.slice(0, 10) ?? "-"}
</p>
</div>
</div>
</div>
</div>
{/* 하단 액션바 */}
<div className="px-8 py-5 bg-gray-50 border-t border-gray-200 flex justify-end gap-3">
<button
onClick={() => setShowCancelModal(true)}
className="bg-white border border-gray-300 hover:bg-gray-50 text-[#0f172a] px-5 py-2.5 rounded text-sm font-medium transition"
>
</button>
<button
onClick={handleSave}
disabled={submitting}
className="bg-[#2563EB] hover:bg-[#1d4ed8] text-white px-5 py-2.5 rounded text-sm font-medium transition shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
{submitting ? "저장 중..." : "저장하기"}
</button>
</div>
</div>
{/* 플랫폼 관리 (읽기 전용) */}
<div className="bg-white border border-gray-200 rounded-lg shadow-sm">
<div className="px-6 py-5 border-b border-gray-100 flex items-center justify-between">
<h3 className="text-lg font-bold text-[#0f172a]"> </h3>
<span className="inline-flex items-center gap-1.5 text-xs text-[#64748b] bg-gray-50 border border-gray-200 px-3 py-1.5 rounded-full">
<span
className="material-symbols-outlined"
style={{ fontSize: "14px" }}
>
info
</span>
</span>
</div>
{/* Android */}
{service.platforms?.android?.registered && (
<div className="px-6 py-5 border-b border-gray-100 flex items-center justify-between">
<div className="flex items-center gap-4 flex-1">
<div className="size-12 rounded-lg bg-green-50 flex items-center justify-center flex-shrink-0">
<span className="material-symbols-outlined text-green-600 text-2xl">
android
</span>
</div>
<div className="flex-1">
<h4 className="text-sm font-bold text-[#0f172a]">Android</h4>
<p className="text-xs text-gray-600 mt-1">
Google FCM을 Android
</p>
<div className="flex items-center gap-2 mt-2">
{service.platforms.android.credentialStatus === "error" ? (
<>
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-50 text-red-700 border border-red-200">
</span>
<p className="text-xs text-red-600 font-medium">
{service.platforms.android.statusReason}
</p>
</>
) : service.platforms.android.credentialStatus === "warn" ? (
<>
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-50 text-amber-700 border border-amber-200">
</span>
<p className="text-xs text-amber-600 font-medium">
{service.platforms.android.statusReason}
</p>
</>
) : (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-700 border border-green-200">
</span>
)}
</div>
</div>
</div>
</div>
)}
{/* iOS */}
{service.platforms?.ios?.registered && (
<div className="px-6 py-5 flex items-center justify-between">
<div className="flex items-center gap-4 flex-1">
<div className="size-12 rounded-lg bg-gray-100 flex items-center justify-center flex-shrink-0">
<AppleLogo className="w-6 h-6 text-gray-600" />
</div>
<div className="flex-1">
<h4 className="text-sm font-bold text-[#0f172a]">iOS</h4>
<p className="text-xs text-gray-600 mt-1">
Apple APNs를 iOS
</p>
<div className="flex items-center gap-2 mt-2">
{service.platforms.ios.credentialStatus === "error" ? (
<>
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-50 text-red-700 border border-red-200">
</span>
<p className="text-xs text-red-600 font-medium">
{service.platforms.ios.statusReason}
</p>
</>
) : service.platforms.ios.credentialStatus === "warn" ? (
<>
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-50 text-amber-700 border border-amber-200">
</span>
<p className="text-xs text-amber-600 font-medium">
{service.platforms.ios.statusReason}
</p>
</>
) : (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-700 border border-green-200">
</span>
)}
</div>
</div>
</div>
</div>
)}
{/* 빈 상태 */}
{!service.platforms?.android?.registered &&
!service.platforms?.ios?.registered && (
<div className="px-6 py-12 text-center">
<span className="material-symbols-outlined text-gray-300 text-5xl mb-3 block">
devices
</span>
<p className="text-sm text-gray-400">
.
</p>
</div>
)}
</div>
{/* 저장 확인 모달 */}
{showSaveModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/40"
onClick={() => setShowSaveModal(false)}
/>
<div className="relative bg-white rounded-xl shadow-2xl max-w-md w-full mx-4 p-6 border border-gray-200">
<div className="flex items-center gap-3 mb-4">
<div className="size-10 rounded-full bg-blue-100 flex items-center justify-center flex-shrink-0">
<span className="material-symbols-outlined text-blue-600 text-xl">
save
</span>
</div>
<h3 className="text-lg font-bold text-[#0f172a]">
</h3>
</div>
<p className="text-sm text-[#0f172a] mb-2">
?
</p>
<div className="bg-blue-50 border border-blue-200 rounded-lg px-4 py-3 mb-5">
<div className="flex items-center gap-1.5 text-blue-700 text-xs font-medium">
<span
className="material-symbols-outlined flex-shrink-0"
style={{ fontSize: "14px" }}
>
info
</span>
<span> .</span>
</div>
</div>
<div className="flex justify-end gap-3">
<button
onClick={() => setShowSaveModal(false)}
className="bg-white border border-gray-300 hover:bg-gray-50 text-[#0f172a] px-5 py-2.5 rounded text-sm font-medium transition"
>
</button>
<button
onClick={handleSaveConfirm}
disabled={submitting}
className="bg-[#2563EB] hover:bg-[#1d4ed8] text-white px-5 py-2.5 rounded text-sm font-medium transition shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
</div>
</div>
</div>
)}
{/* 취소 확인 모달 */}
{showCancelModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/40"
onClick={() => setShowCancelModal(false)}
/>
<div className="relative bg-white rounded-xl shadow-2xl max-w-md w-full mx-4 p-6 border border-gray-200">
<div className="flex items-center gap-3 mb-4">
<div className="size-10 rounded-full bg-amber-100 flex items-center justify-center flex-shrink-0">
<span className="material-symbols-outlined text-amber-600 text-xl">
warning
</span>
</div>
<h3 className="text-lg font-bold text-[#0f172a]"> </h3>
</div>
<p className="text-sm text-[#0f172a] mb-2">
?
</p>
<div className="bg-amber-50 border border-amber-200 rounded-lg px-4 py-3 mb-5">
<div className="flex items-center gap-1.5 text-amber-700 text-xs font-medium">
<span
className="material-symbols-outlined flex-shrink-0"
style={{ fontSize: "14px" }}
>
info
</span>
<span> .</span>
</div>
</div>
<div className="flex justify-end gap-3">
<button
onClick={() => setShowCancelModal(false)}
className="bg-white border border-gray-300 hover:bg-gray-50 text-[#0f172a] px-5 py-2.5 rounded text-sm font-medium transition"
>
</button>
<button
onClick={() => navigate(`/services/${id}`)}
className="bg-[#2563EB] hover:bg-[#1d4ed8] text-white px-5 py-2.5 rounded text-sm font-medium transition shadow-sm flex items-center gap-2"
>
<span className="material-symbols-outlined text-base">
check
</span>
<span></span>
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,344 @@
import { useState, useEffect, useCallback } from "react";
import { Link, useNavigate } from "react-router-dom";
import PageHeader from "@/components/common/PageHeader";
import SearchInput from "@/components/common/SearchInput";
import FilterDropdown from "@/components/common/FilterDropdown";
import FilterResetButton from "@/components/common/FilterResetButton";
import StatusBadge from "@/components/common/StatusBadge";
import Pagination from "@/components/common/Pagination";
import EmptyState from "@/components/common/EmptyState";
import CopyButton from "@/components/common/CopyButton";
import PlatformStatusIndicator from "../components/PlatformStatusIndicator";
import { formatDate, formatNumber } from "@/utils/format";
import { fetchServices } from "@/api/service.api";
import { SERVICE_STATUS } from "../types";
import type { ServiceSummary } from "../types";
const STATUS_OPTIONS = ["전체 상태", "활성", "비활성"];
const PAGE_SIZE = 10;
// 상태 필터 → API status 값 매핑
function mapStatusFilter(filter: string): number | undefined {
if (filter === "활성") return 0;
if (filter === "비활성") return 1;
return undefined;
}
// 상태값 배지
function getStatusBadge(status: ServiceSummary["status"]) {
if (status === SERVICE_STATUS.ACTIVE) {
return <StatusBadge variant="success" label="활성" />;
}
return <StatusBadge variant="error" label="비활성" />;
}
export default function ServiceListPage() {
const navigate = useNavigate();
// 필터 상태
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("전체 상태");
const [currentPage, setCurrentPage] = useState(1);
// 데이터 상태
const [items, setItems] = useState<ServiceSummary[]>([]);
const [totalItems, setTotalItems] = useState(0);
const [totalPages, setTotalPages] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
// 실제 적용될 필터 (조회 버튼 클릭 시 반영)
const [appliedSearch, setAppliedSearch] = useState("");
const [appliedStatus, setAppliedStatus] = useState("전체 상태");
// API 호출
const loadData = useCallback(
async (page: number, searchKeyword: string, statusFilterValue: string) => {
setLoading(true);
setError(false);
try {
const res = await fetchServices({
page,
pageSize: PAGE_SIZE,
searchKeyword: searchKeyword || undefined,
status: mapStatusFilter(statusFilterValue),
});
const data = res.data.data;
setItems(data.items ?? []);
setTotalItems(data.totalCount ?? 0);
setTotalPages(data.totalPages ?? 1);
} catch {
setError(true);
setItems([]);
setTotalItems(0);
setTotalPages(1);
} finally {
setLoading(false);
}
},
[],
);
// 초기 로드
useEffect(() => {
loadData(1, "", "전체 상태");
}, [loadData]);
// 조회 버튼
const handleQuery = () => {
setAppliedSearch(search);
setAppliedStatus(statusFilter);
setCurrentPage(1);
loadData(1, search, statusFilter);
};
// 필터 초기화
const handleReset = () => {
setSearch("");
setStatusFilter("전체 상태");
setAppliedSearch("");
setAppliedStatus("전체 상태");
setCurrentPage(1);
loadData(1, "", "전체 상태");
};
// 페이지 변경
const handlePageChange = (page: number) => {
setCurrentPage(page);
loadData(page, appliedSearch, appliedStatus);
};
return (
<div>
<PageHeader
title="서비스 관리"
description="등록된 서비스의 현황을 조회하고 관리할 수 있습니다."
action={
<Link
to="/services/register"
className="flex items-center justify-center gap-2 min-w-[154px] px-5 py-2.5 bg-[#2563EB] hover:bg-[#1d4ed8] text-white rounded-lg text-sm font-medium transition-colors shadow-sm"
>
<span className="material-symbols-outlined text-lg">add</span>
</Link>
}
/>
{/* 필터바 */}
<div className="bg-white border border-gray-200 rounded-lg p-6 mb-6">
<div className="flex items-end gap-4">
<SearchInput
value={search}
onChange={setSearch}
placeholder="검색어를 입력하세요"
label="서비스명 / ID"
disabled={loading}
/>
<FilterDropdown
label="상태"
value={statusFilter}
options={STATUS_OPTIONS}
onChange={setStatusFilter}
className="w-[140px] flex-shrink-0"
disabled={loading}
/>
<FilterResetButton onClick={handleReset} disabled={loading} />
<button
onClick={handleQuery}
disabled={loading}
className="h-[38px] flex-shrink-0 bg-gray-800 hover:bg-gray-900 active:scale-95 text-white rounded-lg px-5 text-sm font-medium transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
</div>
</div>
{/* 에러 상태 */}
{error ? (
<div className="bg-white border border-gray-200 rounded-lg shadow-sm p-12 flex flex-col items-center justify-center gap-3">
<span className="material-symbols-outlined text-4xl text-gray-300">
cloud_off
</span>
<p className="text-sm text-gray-500">
.
</p>
<button
onClick={() => loadData(currentPage, appliedSearch, appliedStatus)}
className="text-sm text-[#2563EB] hover:underline font-medium"
>
</button>
</div>
) : loading ? (
/* 로딩 스켈레톤 */
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden shadow-sm">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center">
</th>
<th className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center">
ID
</th>
<th className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center">
</th>
<th className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center">
</th>
<th className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center">
</th>
<th className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center">
</th>
</tr>
</thead>
<tbody>
{Array.from({ length: 5 }).map((_, i) => (
<tr
key={i}
className={i < 4 ? "border-b border-gray-100" : ""}
>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="size-8 rounded-lg bg-gray-100 animate-pulse" />
<div className="h-4 w-24 rounded bg-gray-100 animate-pulse" />
</div>
</td>
<td className="px-6 py-4 text-center">
<div className="h-4 w-28 rounded bg-gray-100 animate-pulse mx-auto" />
</td>
<td className="px-6 py-4 text-center">
<div className="flex items-center justify-center gap-1.5">
<div className="w-8 h-6 rounded bg-gray-100 animate-pulse" />
<div className="w-8 h-6 rounded bg-gray-100 animate-pulse" />
</div>
</td>
<td className="px-6 py-4 text-center">
<div className="h-4 w-12 rounded bg-gray-100 animate-pulse mx-auto" />
</td>
<td className="px-6 py-4 text-center">
<div className="h-5 w-14 rounded-full bg-gray-100 animate-pulse mx-auto" />
</td>
<td className="px-6 py-4 text-center">
<div className="h-4 w-20 rounded bg-gray-100 animate-pulse mx-auto" />
</td>
</tr>
))}
</tbody>
</table>
</div>
) : items.length > 0 ? (
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden shadow-sm">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center">
</th>
<th className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center">
ID
</th>
<th className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center">
</th>
<th className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center">
</th>
<th className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center">
</th>
<th className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center">
</th>
</tr>
</thead>
<tbody>
{items.map((svc, idx) => (
<tr
key={svc.serviceCode}
className={`${idx < items.length - 1 ? "border-b border-gray-100" : ""} hover:bg-gray-50/50 transition-colors cursor-pointer`}
onClick={() => navigate(`/services/${svc.serviceCode}`)}
>
{/* 서비스명 */}
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="size-8 rounded-lg bg-[#2563EB]/10 flex items-center justify-center flex-shrink-0">
<span className="material-symbols-outlined text-[#2563EB] text-base">
{svc.serviceIcon || "hub"}
</span>
</div>
<span className="text-sm font-medium text-[#0f172a]">
{svc.serviceName}
</span>
</div>
</td>
{/* 서비스 ID */}
<td className="px-6 py-4 text-center">
<div
className="inline-flex items-center gap-1.5"
onClick={(e) => e.stopPropagation()}
>
<code className="text-sm text-gray-700 font-medium">
{svc.serviceCode}
</code>
<CopyButton text={svc.serviceCode} />
</div>
</td>
{/* 플랫폼 */}
<td className="px-6 py-4 text-center">
<div className="flex items-center justify-center gap-1.5">
{svc.platforms ? (
<>
<PlatformStatusIndicator
platform="android"
credential={svc.platforms.android}
/>
<PlatformStatusIndicator
platform="ios"
credential={svc.platforms.ios}
/>
</>
) : (
<span className="text-xs text-gray-400">-</span>
)}
</div>
</td>
{/* 기기 수 */}
<td className="px-6 py-4 text-center text-sm text-gray-700 font-medium">
{formatNumber(svc.deviceCount)}
</td>
{/* 상태 */}
<td className="px-6 py-4 text-center">
{getStatusBadge(svc.status)}
</td>
{/* 등록일 */}
<td className="px-6 py-4 text-center text-sm text-gray-500">
{formatDate(svc.createdAt)}
</td>
</tr>
))}
</tbody>
</table>
{/* 페이지네이션 */}
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={totalItems}
pageSize={PAGE_SIZE}
onPageChange={handlePageChange}
/>
</div>
) : (
<EmptyState
icon="search_off"
message="검색 결과가 없습니다"
description="다른 검색어를 입력하거나 필터를 변경해보세요."
/>
)}
</div>
);
}

View File

@ -0,0 +1,309 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { AxiosError } from "axios";
import PageHeader from "@/components/common/PageHeader";
import CopyButton from "@/components/common/CopyButton";
import PlatformSelector from "../components/PlatformSelector";
import useShake from "@/hooks/useShake";
import { createService } from "@/api/service.api";
import type { ApiError } from "@/types/api";
import type { CreateServiceResponse } from "../types";
export default function ServiceRegisterPage() {
const navigate = useNavigate();
// 폼 상태
const [serviceName, setServiceName] = useState("");
const [description, setDescription] = useState("");
const [relatedLink, setRelatedLink] = useState("");
const [androidChecked, setAndroidChecked] = useState(false);
const [iosChecked, setIosChecked] = useState(false);
const [iosAuthType, setIosAuthType] = useState<"p8" | "p12">("p8");
// 에러 상태
const [nameError, setNameError] = useState(false);
const [nameErrorMsg, setNameErrorMsg] = useState("서비스 명을 입력해주세요.");
const { triggerShake, cls } = useShake();
// 제출 상태
const [submitting, setSubmitting] = useState(false);
// 모달
const [showConfirm, setShowConfirm] = useState(false);
// API Key 모달
const [apiKeyResult, setApiKeyResult] = useState<CreateServiceResponse | null>(null);
const [showApiKeyModal, setShowApiKeyModal] = useState(false);
// 등록 버튼 클릭
const handleRegister = () => {
const trimmed = serviceName.trim();
if (!trimmed || trimmed.length < 2) {
setNameError(true);
setNameErrorMsg(
!trimmed ? "서비스 명을 입력해주세요." : "서비스 명은 2자 이상이어야 합니다.",
);
triggerShake(["name"]);
return;
}
setShowConfirm(true);
};
// 등록 확인
const handleConfirm = async () => {
setShowConfirm(false);
setSubmitting(true);
try {
const res = await createService({
serviceName: serviceName.trim(),
description: description.trim() || null,
});
setApiKeyResult(res.data.data);
setShowApiKeyModal(true);
} catch (err) {
const axiosErr = err as AxiosError<ApiError>;
const status = axiosErr.response?.status;
const msg = axiosErr.response?.data?.msg;
if (status === 409) {
toast.error(msg ?? "이미 사용 중인 서비스 명입니다.");
setNameError(true);
setNameErrorMsg(msg ?? "이미 사용 중인 서비스 명입니다.");
triggerShake(["name"]);
} else {
toast.error(msg ?? "서비스 등록에 실패했습니다. 다시 시도해주세요.");
}
} finally {
setSubmitting(false);
}
};
// API Key 모달 닫기 → 상세 페이지 이동
const handleApiKeyModalClose = () => {
setShowApiKeyModal(false);
if (apiKeyResult) {
navigate(`/services/${apiKeyResult.serviceCode}`);
}
};
return (
<div>
<PageHeader
title="서비스 등록"
description="새로운 플랫폼 서비스를 등록하고 API 권한을 설정하세요."
/>
{/* 폼 카드 */}
<div className="border border-gray-200 rounded-lg bg-white shadow-sm overflow-hidden">
<div className="p-8 space-y-6">
{/* 1. 서비스 명 */}
<div>
<label className="block text-sm font-medium text-[#0f172a] mb-1.5">
<span className="text-red-500">*</span>
</label>
<input
type="text"
value={serviceName}
onChange={(e) => {
setServiceName(e.target.value);
if (e.target.value.trim()) setNameError(false);
}}
placeholder="서비스 명을 입력하세요"
className={`w-full px-3 py-2.5 bg-white border rounded text-sm focus:outline-none focus:ring-2 focus:ring-[#2563EB] focus:border-transparent transition-shadow placeholder-gray-400 ${
nameError
? "border-red-500 ring-2 ring-red-500/15"
: "border-gray-300"
} ${cls("name")}`}
/>
{nameError && (
<div className="flex items-center gap-1.5 text-red-600 text-xs mt-1.5">
<span
className="material-symbols-outlined flex-shrink-0"
style={{ fontSize: "14px" }}
>
error
</span>
<span>{nameErrorMsg}</span>
</div>
)}
</div>
{/* 2. 플랫폼 선택 */}
<PlatformSelector
androidChecked={androidChecked}
iosChecked={iosChecked}
onAndroidChange={setAndroidChecked}
onIosChange={setIosChecked}
iosAuthType={iosAuthType}
onIosAuthTypeChange={setIosAuthType}
/>
{/* 4. 설명 */}
<div>
<label className="block text-sm font-medium text-[#0f172a] mb-1.5">
</label>
<textarea
rows={4}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="서비스에 대한 간략한 설명을 입력하세요"
className="w-full border border-gray-300 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-[#2563EB]/20 focus:border-[#2563EB] transition resize-none"
/>
</div>
{/* 5. 관련 링크 */}
<div>
<label className="block text-sm font-medium text-[#0f172a] mb-1.5">
</label>
<input
type="url"
value={relatedLink}
onChange={(e) => setRelatedLink(e.target.value)}
placeholder="https://example.com"
className="w-full px-3 py-2.5 bg-white border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-[#2563EB]/20 focus:border-[#2563EB] transition-shadow placeholder-gray-400"
/>
<div className="flex items-center gap-1.5 text-gray-500 text-xs mt-1.5">
<span
className="material-symbols-outlined flex-shrink-0"
style={{ fontSize: "14px" }}
>
info
</span>
<span> .</span>
</div>
</div>
</div>
{/* 하단 액션바 */}
<div className="px-8 py-5 bg-gray-50 border-t border-gray-200 flex justify-end gap-3">
<button
onClick={() => navigate("/services")}
className="bg-white border border-gray-300 hover:bg-gray-50 text-[#0f172a] px-5 py-2.5 rounded text-sm font-medium transition"
>
</button>
<button
onClick={handleRegister}
disabled={submitting}
className="bg-[#2563EB] hover:bg-[#1d4ed8] text-white px-5 py-2.5 rounded text-sm font-medium transition shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
{submitting ? "등록 중..." : "등록하기"}
</button>
</div>
</div>
{/* 등록 확인 모달 */}
{showConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/40"
onClick={() => setShowConfirm(false)}
/>
<div className="relative bg-white rounded-xl shadow-2xl max-w-md w-full mx-4 p-6 border border-gray-200">
<div className="flex items-center gap-3 mb-4">
<div className="size-10 rounded-full bg-amber-100 flex items-center justify-center flex-shrink-0">
<span className="material-symbols-outlined text-amber-600 text-xl">
warning
</span>
</div>
<h3 className="text-lg font-bold text-[#0f172a]">
</h3>
</div>
<p className="text-sm text-[#0f172a] mb-2">
?
</p>
<div className="bg-blue-50 border border-blue-200 rounded-lg px-4 py-3 mb-5">
<div className="flex items-center gap-1.5 text-blue-700 text-xs font-medium">
<span
className="material-symbols-outlined flex-shrink-0"
style={{ fontSize: "14px" }}
>
info
</span>
<span> ID는 .</span>
</div>
</div>
<div className="flex justify-end gap-3">
<button
onClick={() => setShowConfirm(false)}
className="bg-white border border-gray-300 hover:bg-gray-50 text-[#0f172a] px-5 py-2.5 rounded text-sm font-medium transition"
>
</button>
<button
onClick={handleConfirm}
className="bg-[#2563EB] hover:bg-[#1d4ed8] text-white px-5 py-2.5 rounded text-sm font-medium transition shadow-sm flex items-center gap-2"
>
<span className="material-symbols-outlined text-base">
check
</span>
<span></span>
</button>
</div>
</div>
</div>
)}
{/* API Key 결과 모달 */}
{showApiKeyModal && apiKeyResult && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/40" />
<div className="relative bg-white rounded-xl shadow-2xl max-w-lg w-full mx-4 p-6 border border-gray-200">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="size-10 rounded-full bg-green-100 flex items-center justify-center flex-shrink-0">
<span className="material-symbols-outlined text-green-600 text-xl">
check_circle
</span>
</div>
<h3 className="text-lg font-bold text-[#0f172a]">
</h3>
</div>
<button
onClick={handleApiKeyModalClose}
className="text-gray-400 hover:text-gray-600 transition-colors p-1 rounded hover:bg-gray-100"
>
<span className="material-symbols-outlined text-xl">
close
</span>
</button>
</div>
<div className="bg-amber-50 border border-amber-200 rounded-lg px-4 py-3 mb-4">
<div className="flex items-center gap-1.5 text-amber-700 text-xs font-medium">
<span
className="material-symbols-outlined flex-shrink-0"
style={{ fontSize: "14px" }}
>
warning
</span>
<span>
API .
</span>
</div>
</div>
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 flex items-center gap-3 mb-4">
<code className="flex-1 text-sm font-mono text-[#0f172a] break-all select-all">
{apiKeyResult.apiKey}
</code>
<CopyButton text={apiKeyResult.apiKey} />
</div>
<div className="flex justify-end">
<button
onClick={handleApiKeyModalClose}
className="bg-[#2563EB] hover:bg-[#1d4ed8] text-white px-5 py-2.5 rounded text-sm font-medium transition shadow-sm"
>
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,114 @@
// 서비스 상태
export const SERVICE_STATUS = {
ACTIVE: "Active",
SUSPENDED: "Suspended",
} as const;
export type ServiceStatus = (typeof SERVICE_STATUS)[keyof typeof SERVICE_STATUS];
// 인증서 상태
export const CREDENTIAL_STATUS = {
OK: "ok",
WARN: "warn",
ERROR: "error",
} as const;
export type CredentialStatus =
(typeof CREDENTIAL_STATUS)[keyof typeof CREDENTIAL_STATUS];
// 플랫폼 인증 요약
export interface PlatformCredentialSummary {
registered: boolean;
credentialStatus: CredentialStatus | null;
statusReason: string | null;
expiresAt: string | null;
}
// 서비스 목록 항목
export interface ServiceSummary {
serviceId: number;
serviceCode: string;
serviceName: string;
serviceIcon?: string;
description: string | null;
status: ServiceStatus;
createdAt: string;
deviceCount: number;
platforms: {
android: PlatformCredentialSummary;
ios: PlatformCredentialSummary;
} | null;
}
// 서비스 상세
export interface ServiceDetail extends ServiceSummary {
apiKey: string;
apiKeyCreatedAt: string;
apnsAuthType: "p8" | "p12" | null;
hasApnsKey: boolean;
hasFcmCredentials: boolean;
createdByName: string | null;
updatedAt: string | null;
apnsBundleId?: string | null;
apnsKeyId?: string | null;
apnsTeamId?: string | null;
webhookUrl?: string | null;
tags?: string | null;
subTier?: string | null;
subStartedAt?: string | null;
allowedIps?: string[] | null;
}
// 서비스 목록 요청
export interface ServiceListRequest {
page: number;
pageSize: number;
searchKeyword?: string;
status?: number; // 0=Active, 1=Suspended, undefined=전체
}
// API 키 응답 (view/refresh 공용)
export interface ApiKeyResponse {
serviceCode: string;
apiKey: string;
apiKeyCreatedAt: string;
}
// 서비스 생성 요청
export interface CreateServiceRequest {
serviceName: string;
description?: string | null;
}
// 서비스 생성 응답 (API Key 1회 표시)
export interface CreateServiceResponse {
serviceCode: string;
apiKey: string;
apiKeyCreatedAt: string;
}
// 서비스 수정 요청
export interface UpdateServiceRequest {
serviceCode: string;
serviceName?: string | null;
description?: string | null;
status?: number | null; // 0=Active, 1=Suspended
}
// FCM 인증서 등록 요청
export interface RegisterFcmRequest {
serviceAccountJson: string; // JSON 파일 내용 (문자열)
}
// APNs 인증서 등록 요청
export interface RegisterApnsRequest {
authType: "p8" | "p12";
bundleId: string;
// p8 전용
keyId?: string;
teamId?: string;
privateKey?: string;
// p12 전용
certificateBase64?: string;
certPassword?: string;
}

View File

@ -0,0 +1,185 @@
import { useEffect, useRef } from "react";
import type { Notification, NotificationType } from "../types";
interface NotificationSlidePanelProps {
isOpen: boolean;
onClose: () => void;
notification: Notification | null;
onMarkRead: (id: number) => void;
}
/** 타입별 배지 스타일 */
const BADGE_STYLE: Record<
NotificationType,
{ bg: string; text: string; icon: string }
> = {
"발송": { bg: "bg-green-50", text: "text-green-700", icon: "check_circle" },
"인증서": { bg: "bg-amber-50", text: "text-amber-700", icon: "warning" },
"서비스": { bg: "bg-blue-50", text: "text-blue-700", icon: "add_circle" },
"실패": { bg: "bg-red-50", text: "text-red-600", icon: "error" },
"시스템": { bg: "bg-gray-100", text: "text-gray-600", icon: "settings" },
};
/** 알림 상세 슬라이드 패널 */
export default function NotificationSlidePanel({
isOpen,
onClose,
notification,
onMarkRead,
}: NotificationSlidePanelProps) {
const bodyRef = useRef<HTMLDivElement>(null);
// 패널 열릴 때 스크롤 최상단 리셋
useEffect(() => {
if (isOpen) {
bodyRef.current?.scrollTo(0, 0);
}
}, [isOpen, notification]);
// ESC 닫기
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && isOpen) onClose();
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
// body 스크롤 잠금
useEffect(() => {
if (isOpen) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
return () => {
document.body.style.overflow = "";
};
}, [isOpen]);
const badge = notification ? BADGE_STYLE[notification.type] : null;
return (
<>
{/* 오버레이 */}
<div
className={`fixed inset-0 bg-black/30 z-50 transition-opacity duration-300 ${
isOpen ? "opacity-100" : "opacity-0 pointer-events-none"
}`}
onClick={onClose}
/>
{/* 패널 */}
<aside
className={`fixed top-0 right-0 h-full w-[480px] bg-white shadow-2xl z-50 flex flex-col transition-transform duration-300 ${
isOpen ? "translate-x-0" : "translate-x-full"
}`}
>
{/* 헤더 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 flex-shrink-0">
<h2 className="text-lg font-bold text-gray-900"> </h2>
<button
onClick={onClose}
className="size-8 flex items-center justify-center text-gray-400 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
>
<span className="material-symbols-outlined text-xl">close</span>
</button>
</div>
{/* 본문 */}
<div
ref={bodyRef}
className="flex-1 overflow-y-auto p-6 space-y-6"
style={{ overscrollBehavior: "contain" }}
>
{notification && badge ? (
<>
{/* 타입 배지 + 시간 + 날짜 */}
<div className="flex items-center gap-2">
<span
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded ${badge.bg} ${badge.text} text-xs font-semibold`}
>
<span
className="material-symbols-outlined"
style={{ fontSize: "13px" }}
>
{badge.icon}
</span>
{notification.type}
</span>
<span className="text-xs text-gray-400">
{notification.date} {notification.time}
</span>
{/* 읽음 상태 */}
{notification.read ? (
<span className="ml-auto inline-flex items-center gap-1 text-xs text-gray-400">
<span
className="material-symbols-outlined"
style={{ fontSize: "14px" }}
>
done_all
</span>
</span>
) : (
<span className="ml-auto inline-flex items-center gap-1 text-xs text-[#2563EB]">
<span className="size-2 rounded-full bg-[#2563EB]" />
</span>
)}
</div>
{/* 제목 */}
<h3 className="text-lg font-bold text-gray-900">
{notification.title}
</h3>
{/* 설명 (전체 표시) */}
<p className="text-sm text-gray-600 leading-relaxed">
{notification.description}
</p>
{/* 상세 내용 */}
{notification.detail && (
<div>
<h4 className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">
</h4>
<div className="bg-gray-50 rounded-lg p-4">
<pre className="text-sm text-gray-700 leading-relaxed whitespace-pre-wrap font-sans">
{notification.detail}
</pre>
</div>
</div>
)}
</>
) : (
<div className="flex items-center justify-center h-full text-gray-400 text-sm">
</div>
)}
</div>
{/* 푸터 */}
<div className="flex-shrink-0 px-6 py-4 border-t border-gray-200 flex gap-3">
{notification && !notification.read && (
<button
onClick={() => onMarkRead(notification.id)}
className="flex-1 px-4 py-2.5 bg-[#2563EB] text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors"
>
</button>
)}
<button
onClick={onClose}
className={`px-4 py-2.5 border border-gray-200 text-gray-700 text-sm font-medium rounded-lg hover:bg-gray-50 transition-colors ${
notification && !notification.read ? "" : "w-full"
}`}
>
</button>
</div>
</aside>
</>
);
}

View File

@ -0,0 +1,302 @@
import { useState, useEffect, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import PageHeader from "@/components/common/PageHeader";
import Pagination from "@/components/common/Pagination";
import { fetchProfile, fetchActivityList } from "@/api/account.api";
import { formatDate, formatDateTime, formatRelativeTime } from "@/utils/format";
import {
ROLE_LABELS,
type UserProfile,
type Activity,
type ProfileResponse,
type ActivityItem,
} from "../types";
// 활동 타입별 아이콘 + 색상 매핑
const ACTIVITY_STYLE: Record<
Activity["type"],
{ icon: string; bg: string; text: string }
> = {
send: { icon: "send", bg: "bg-green-50", text: "text-green-600" },
service: { icon: "cloud", bg: "bg-blue-50", text: "text-blue-600" },
device: { icon: "verified", bg: "bg-amber-50", text: "text-amber-600" },
auth: { icon: "login", bg: "bg-gray-50", text: "text-gray-500" },
};
const PAGE_SIZE = 5;
/** ProfileResponse → UserProfile 뷰모델 변환 */
function mapProfile(res: ProfileResponse): UserProfile {
return {
name: res.name ?? "",
email: res.email ?? "",
role: ROLE_LABELS[res.role] ?? `역할(${res.role})`,
team: res.organization ?? "",
phone: res.phone ?? "",
joinDate: res.created_at ? formatDate(res.created_at) : "",
lastLogin: res.last_login_at ? formatDateTime(res.last_login_at) : "",
};
}
/** 활동 타입 매핑 */
function mapActivityType(type: string | null): Activity["type"] {
if (type === "send" || type === "service" || type === "device" || type === "auth") return type;
return "auth"; // fallback
}
/** ActivityItem → Activity 뷰모델 변환 */
function mapActivity(item: ActivityItem, index: number): Activity {
return {
id: index,
type: mapActivityType(item.activity_type),
title: item.title ?? "",
detail: item.description ?? "",
time: item.occurred_at ? formatRelativeTime(item.occurred_at) : "",
};
}
export default function MyPage() {
const navigate = useNavigate();
// 프로필 상태
const [profile, setProfile] = useState<UserProfile | null>(null);
// 활동 내역 상태
const [activities, setActivities] = useState<Activity[]>([]);
const [currentPage, setCurrentPage] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const [totalPages, setTotalPages] = useState(1);
// 프로필 로드
useEffect(() => {
(async () => {
try {
const res = await fetchProfile();
setProfile(mapProfile(res.data.data));
} catch {
// 에러 시 빈 프로필 유지
}
})();
}, []);
// 활동 내역 로드
const loadActivities = useCallback(async (page: number) => {
try {
const res = await fetchActivityList({ page, size: PAGE_SIZE });
const data = res.data.data;
setActivities((data.items ?? []).map(mapActivity));
setTotalItems(data.pagination.total_count);
setTotalPages(data.pagination.total_pages || 1);
} catch {
setActivities([]);
setTotalItems(0);
setTotalPages(1);
}
}, []);
useEffect(() => {
loadActivities(currentPage);
}, [currentPage, loadActivities]);
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
// 프로필 로딩 중
if (!profile) {
return (
<>
<PageHeader
title="마이페이지"
description="내 계정 정보와 활동 내역을 확인하고 관리할 수 있습니다"
/>
<div className="flex items-center justify-center py-20 text-sm text-gray-400">
...
</div>
</>
);
}
const initials = profile.name
.split(" ")
.map((w) => w[0])
.join("")
.toUpperCase() || "?";
return (
<>
<PageHeader
title="마이페이지"
description="내 계정 정보와 활동 내역을 확인하고 관리할 수 있습니다"
action={
<button
onClick={() => navigate("/settings/profile")}
className="flex items-center gap-1.5 px-4 py-2 bg-white border border-gray-200 rounded-lg text-sm font-medium text-[#64748b] hover:bg-gray-50 hover:text-[#0f172a] transition-colors shadow-sm"
>
<span className="material-symbols-outlined text-base">edit</span>
</button>
}
/>
{/* 프로필 카드 */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm mb-6">
<div className="p-8">
<div className="flex items-center gap-6">
{/* 아바타 */}
<div className="size-20 rounded-full bg-[#2563EB] flex items-center justify-center text-white text-2xl font-bold tracking-wide shadow-md flex-shrink-0">
{initials}
</div>
{/* 기본 정보 */}
<div className="flex-1">
<div className="flex items-center gap-3 mb-1">
<h2 className="text-xl font-bold text-[#0f172a]">
{profile.name}
</h2>
<span className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold bg-[#2563EB]/10 text-[#2563EB] border border-[#2563EB]/20">
<span
className="material-symbols-outlined"
style={{ fontSize: "12px" }}
>
shield
</span>
{profile.role}
</span>
</div>
<p className="text-sm text-[#64748b]">{profile.email}</p>
<p className="text-xs text-gray-400 mt-1">
: {profile.lastLogin} (KST)
</p>
</div>
</div>
</div>
</div>
{/* 2열 그리드 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
{/* 계정 정보 */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
<h3 className="text-sm font-bold text-[#0f172a] flex items-center gap-2">
<span className="material-symbols-outlined text-base text-[#2563EB]">
person
</span>
</h3>
</div>
<div className="p-6 space-y-4">
{[
{ label: "소속", value: profile.team },
{ label: "연락처", value: profile.phone },
{ label: "가입일", value: profile.joinDate },
{ label: "최근 로그인", value: profile.lastLogin },
].map((item, i, arr) => (
<div key={item.label}>
<div className="flex items-center justify-between">
<span className="text-sm text-[#64748b]">{item.label}</span>
<span className="text-sm font-medium text-[#0f172a]">
{item.value}
</span>
</div>
{i < arr.length - 1 && (
<div className="h-px bg-gray-100 mt-4" />
)}
</div>
))}
</div>
</div>
{/* 보안 설정 (추후 개발) */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm">
<div className="flex items-center justify-between px-6 py-4">
<h3 className="text-sm font-bold text-[#0f172a] flex items-center gap-2">
<span className="material-symbols-outlined text-base text-[#2563EB]">
security
</span>
</h3>
<span className="text-xs text-gray-400 bg-gray-100 px-2.5 py-1 rounded-full">
</span>
</div>
</div>
</div>
{/* 최근 활동 내역 */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm mb-6">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
<h3 className="text-sm font-bold text-[#0f172a] flex items-center gap-2">
<span className="material-symbols-outlined text-base text-[#2563EB]">
history
</span>
</h3>
<span className="text-xs text-[#64748b]"> 7</span>
</div>
<div className="divide-y divide-gray-100">
{activities.length === 0 ? (
<div className="flex items-center justify-center py-12 text-sm text-gray-400">
</div>
) : (
activities.map((activity) => {
const style = ACTIVITY_STYLE[activity.type];
return (
<div
key={activity.id}
className="flex items-center gap-4 px-6 py-4 hover:bg-gray-50/50 transition-colors"
>
<div
className={`size-9 rounded-full ${style.bg} flex items-center justify-center flex-shrink-0`}
>
<span
className={`material-symbols-outlined ${style.text} text-lg`}
>
{style.icon}
</span>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-[#0f172a] truncate">
{activity.title}
</p>
<p className="text-xs text-gray-400 mt-0.5">
{activity.detail}
</p>
</div>
<span className="text-xs text-gray-400 flex-shrink-0">
{activity.time}
</span>
</div>
);
})
)}
</div>
{/* 페이지네이션 */}
<div className="border-t border-gray-100">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={totalItems}
pageSize={PAGE_SIZE}
onPageChange={handlePageChange}
/>
</div>
</div>
{/* 접속 기기 (추후 개발) */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm">
<div className="flex items-center justify-between px-6 py-4">
<h3 className="text-sm font-bold text-[#0f172a] flex items-center gap-2">
<span className="material-symbols-outlined text-base text-[#2563EB]">
devices
</span>
</h3>
<span className="text-xs text-gray-400 bg-gray-100 px-2.5 py-1 rounded-full">
</span>
</div>
</div>
</>
);
}

View File

@ -0,0 +1,248 @@
import { useState, useMemo, useCallback, useEffect } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { toast } from "sonner";
import PageHeader from "@/components/common/PageHeader";
import { MOCK_NOTIFICATIONS, type Notification, type NotificationType } from "../types";
import NotificationSlidePanel from "../components/NotificationSlidePanel";
// 타입별 배지 스타일
const BADGE_STYLE: Record<
NotificationType,
{ bg: string; text: string; icon: string }
> = {
"발송": { bg: "bg-green-50", text: "text-green-700", icon: "check_circle" },
"인증서": { bg: "bg-amber-50", text: "text-amber-700", icon: "warning" },
"서비스": { bg: "bg-blue-50", text: "text-blue-700", icon: "add_circle" },
"실패": { bg: "bg-red-50", text: "text-red-600", icon: "error" },
"시스템": { bg: "bg-gray-100", text: "text-gray-600", icon: "settings" },
};
// 날짜 그룹 레이블 → 표시 날짜
const DATE_LABELS: Record<string, string> = {
"오늘": "2026-02-19",
"어제": "2026-02-18",
"이번 주": "2026.02.16 ~ 02.17",
};
const PAGE_SIZE = 5;
export default function NotificationsPage() {
const location = useLocation();
const navigate = useNavigate();
const [notifications, setNotifications] = useState<Notification[]>(
() => [...MOCK_NOTIFICATIONS],
);
const [currentPage, setCurrentPage] = useState(1);
const [selectedNotification, setSelectedNotification] =
useState<Notification | null>(null);
// 헤더 드롭다운에서 넘어온 경우 해당 알림 패널 자동 오픈
useEffect(() => {
const state = location.state as { notificationId?: number } | null;
if (state?.notificationId) {
const target = notifications.find((n) => n.id === state.notificationId);
if (target) {
setSelectedNotification(target);
// 읽음 처리
if (!target.read) {
setNotifications((prev) =>
prev.map((n) =>
n.id === target.id ? { ...n, read: true } : n,
),
);
setSelectedNotification({ ...target, read: true });
}
}
// state 소비 후 제거 (새로고침 시 재트리거 방지)
navigate(location.pathname, { replace: true, state: null });
}
}, [location.state]); // eslint-disable-line react-hooks/exhaustive-deps
// 페이지네이션
const totalItems = notifications.length;
const totalPages = Math.max(1, Math.ceil(totalItems / PAGE_SIZE));
const pagedNotifications = useMemo(
() =>
notifications.slice(
(currentPage - 1) * PAGE_SIZE,
currentPage * PAGE_SIZE,
),
[notifications, currentPage],
);
// 페이징된 알림을 날짜별로 그룹핑
const grouped = useMemo(() => {
const map = new Map<string, Notification[]>();
for (const n of pagedNotifications) {
const arr = map.get(n.date) ?? [];
arr.push(n);
map.set(n.date, arr);
}
return map;
}, [pagedNotifications]);
// 전체 읽음
const handleReadAll = useCallback(() => {
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
toast.success("모든 알림을 읽음 처리했습니다");
}, []);
// 개별 읽음
const handleReadOne = useCallback((id: number) => {
setNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, read: true } : n)),
);
setSelectedNotification((prev) =>
prev?.id === id ? { ...prev, read: true } : prev,
);
}, []);
return (
<>
<PageHeader
title="알림"
description="시스템 알림과 발송 결과를 확인할 수 있습니다"
action={
<button
onClick={handleReadAll}
className="flex items-center gap-1.5 px-4 py-2 bg-white border border-gray-200 rounded-lg text-sm font-medium text-[#64748b] hover:bg-gray-50 hover:text-[#0f172a] transition-colors shadow-sm"
>
<span className="material-symbols-outlined text-base">
done_all
</span>
</button>
}
/>
{/* 날짜별 그룹 */}
{Array.from(grouped.entries()).map(([dateLabel, items]) => (
<div key={dateLabel} className="mb-8">
{/* 그룹 헤더 */}
<div className="flex items-center gap-3 mb-3">
<h2 className="text-xs font-bold text-[#64748b] uppercase tracking-wider">
{dateLabel}
</h2>
<div className="flex-1 h-px bg-gray-200" />
<span className="text-xs text-gray-400">
{DATE_LABELS[dateLabel] ?? ""}
</span>
</div>
{/* 알림 카드 목록 */}
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm">
{items.map((notification, idx) => {
const badge = BADGE_STYLE[notification.type];
const isUnread = !notification.read;
return (
<div
key={notification.id}
onClick={() => {
setSelectedNotification(notification);
handleReadOne(notification.id);
}}
className={`flex items-start gap-4 px-5 py-4 cursor-pointer transition-colors ${
idx < items.length - 1
? "border-b border-gray-100"
: ""
} ${
isUnread
? "bg-blue-50/20 hover:bg-blue-50/30"
: "hover:bg-gray-50"
}`}
>
{/* 읽음 상태 점 */}
<div className="flex-shrink-0 mt-0.5">
<div
className={`size-2.5 rounded-full ${
isUnread ? "bg-[#2563EB]" : "bg-gray-200"
}`}
/>
</div>
{/* 콘텐츠 */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded ${badge.bg} ${badge.text} text-[10px] font-semibold`}
>
<span
className="material-symbols-outlined"
style={{ fontSize: "11px" }}
>
{badge.icon}
</span>
{notification.type}
</span>
<span className="text-xs text-gray-400">
{notification.time}
</span>
</div>
<p
className={`text-sm truncate ${
isUnread
? "font-semibold text-[#0f172a]"
: "font-medium text-gray-600"
}`}
>
{notification.title}
</p>
<p
className={`text-xs truncate mt-0.5 ${
isUnread ? "text-gray-500" : "text-gray-400"
}`}
>
{notification.description}
</p>
</div>
</div>
);
})}
</div>
</div>
))}
{/* 페이지네이션 */}
<div className="flex items-center justify-center gap-1 mt-10">
<button
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="size-9 flex items-center justify-center rounded-lg text-gray-400 hover:bg-gray-100 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<span className="material-symbols-outlined text-xl">
chevron_left
</span>
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
<button
key={page}
onClick={() => setCurrentPage(page)}
className={`size-9 flex items-center justify-center rounded-lg text-sm font-medium transition-colors ${
page === currentPage
? "bg-[#2563EB] text-white"
: "text-gray-600 hover:bg-gray-100"
}`}
>
{page}
</button>
))}
<button
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="size-9 flex items-center justify-center rounded-lg text-gray-400 hover:bg-gray-100 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<span className="material-symbols-outlined text-xl">
chevron_right
</span>
</button>
</div>
{/* 알림 상세 패널 */}
<NotificationSlidePanel
isOpen={selectedNotification !== null}
onClose={() => setSelectedNotification(null)}
notification={selectedNotification}
onMarkRead={handleReadOne}
/>
</>
);
}

View File

@ -0,0 +1,658 @@
import { useState, useEffect, useMemo, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import PageHeader from "@/components/common/PageHeader";
import useShake from "@/hooks/useShake";
import { fetchProfile, updateProfile, changePassword } from "@/api/account.api";
// 비밀번호 규칙 정의
const PASSWORD_RULES = [
{ key: "length", label: "8자 이상 입력", test: (pw: string) => pw.length >= 8 },
{ key: "upper", label: "영문 대문자 포함", test: (pw: string) => /[A-Z]/.test(pw) },
{ key: "lower", label: "영문 소문자 포함", test: (pw: string) => /[a-z]/.test(pw) },
{ key: "number", label: "숫자 포함", test: (pw: string) => /\d/.test(pw) },
{ key: "special", label: "특수문자 포함", test: (pw: string) => /[^a-zA-Z0-9]/.test(pw) },
] as const;
// 강도 레벨 산정
function getStrengthLevel(pw: string): number {
if (!pw) return 0;
const checks = PASSWORD_RULES.filter((r) => r.test(pw)).length;
if (checks <= 2) return 1;
if (checks <= 4) return 2;
if (checks === 5 && pw.length >= 12) return 4;
if (checks === 5) return 3;
return 2;
}
const STRENGTH_CONFIG = [
{ label: "", color: "" },
{ label: "매우 약함", color: "bg-red-500 text-red-500" },
{ label: "약함", color: "bg-amber-500 text-amber-500" },
{ label: "보통", color: "bg-blue-500 text-blue-500" },
{ label: "강함", color: "bg-green-500 text-green-500" },
];
const STRENGTH_ICONS = ["", "error", "warning", "info", "check_circle"];
/** 원본 프로필 (초기화용) */
interface OriginalProfile {
name: string;
phone: string;
team: string;
email: string;
}
export default function ProfileEditPage() {
const navigate = useNavigate();
// 검증
const [nameError, setNameError] = useState(false);
const { triggerShake, cls } = useShake();
// 기본 정보 폼
const [name, setName] = useState("");
const [phone, setPhone] = useState("");
const [team, setTeam] = useState("");
const [email, setEmail] = useState("");
const [originalProfile, setOriginalProfile] = useState<OriginalProfile | null>(null);
const [loading, setLoading] = useState(true);
// 비밀번호 섹션
const [showPasswordForm, setShowPasswordForm] = useState(false);
const [currentPw, setCurrentPw] = useState("");
const [newPw, setNewPw] = useState("");
const [confirmPw, setConfirmPw] = useState("");
const [showCurrentPw, setShowCurrentPw] = useState(false);
const [showNewPw, setShowNewPw] = useState(false);
const [showConfirmPw, setShowConfirmPw] = useState(false);
// 모달 상태
const [showSaveModal, setShowSaveModal] = useState(false);
const [showPwModal, setShowPwModal] = useState(false);
// 프로필 로드
useEffect(() => {
(async () => {
try {
const res = await fetchProfile();
const data = res.data.data;
const profile: OriginalProfile = {
name: data.name ?? "",
phone: data.phone ?? "",
team: data.organization ?? "",
email: data.email ?? "",
};
setName(profile.name);
setPhone(profile.phone);
setTeam(profile.team);
setEmail(profile.email);
setOriginalProfile(profile);
} catch {
toast.error("프로필 정보를 불러오지 못했습니다.");
} finally {
setLoading(false);
}
})();
}, []);
// 비밀번호 규칙 통과 여부
const ruleResults = useMemo(
() => PASSWORD_RULES.map((r) => ({ ...r, pass: newPw ? r.test(newPw) : false })),
[newPw],
);
const strengthLevel = useMemo(() => getStrengthLevel(newPw), [newPw]);
const allRulesPass = ruleResults.every((r) => r.pass);
const passwordMatch = confirmPw.length > 0 && newPw === confirmPw;
const passwordMismatch = confirmPw.length > 0 && newPw !== confirmPw;
// 비밀번호 토글
const handleTogglePasswordForm = useCallback(() => {
if (showPasswordForm) {
// 닫기: 입력 초기화
setCurrentPw("");
setNewPw("");
setConfirmPw("");
setShowCurrentPw(false);
setShowNewPw(false);
setShowConfirmPw(false);
}
setShowPasswordForm((prev) => !prev);
}, [showPasswordForm]);
// 저장 확인
const handleSaveConfirm = useCallback(async () => {
try {
await updateProfile({
name: name.trim(),
phone,
organization: team,
});
toast.success("변경사항이 저장되었습니다");
navigate("/settings");
} catch {
toast.error("프로필 수정에 실패했습니다.");
}
setShowSaveModal(false);
}, [name, phone, team, navigate]);
// 비밀번호 변경 확인
const handlePwConfirm = useCallback(async () => {
try {
const res = await changePassword({
currentPassword: currentPw,
newPassword: newPw,
});
if (res.data.data.re_login_required) {
toast.success("비밀번호가 변경되었습니다. 다시 로그인해주세요.");
navigate("/auth/login");
} else {
toast.success("비밀번호가 변경되었습니다.");
navigate("/settings");
}
} catch {
toast.error("비밀번호 변경에 실패했습니다.");
}
setShowPwModal(false);
}, [currentPw, newPw, navigate]);
// 초기화
const handleReset = useCallback(() => {
if (originalProfile) {
setName(originalProfile.name);
setPhone(originalProfile.phone);
setTeam(originalProfile.team);
}
setNameError(false);
}, [originalProfile]);
if (loading) {
return (
<>
<PageHeader
title="프로필 수정"
description="계정 정보와 보안 설정을 수정할 수 있습니다"
/>
<div className="flex items-center justify-center py-20 text-sm text-gray-400">
...
</div>
</>
);
}
return (
<>
<PageHeader
title="프로필 수정"
description="계정 정보와 보안 설정을 수정할 수 있습니다"
/>
{/* 기본 정보 카드 */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm mb-6">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
<h3 className="text-sm font-bold text-[#0f172a] flex items-center gap-2">
<span className="material-symbols-outlined text-base text-[#2563EB]">
person
</span>
</h3>
</div>
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 이름 */}
<div>
<label className="block text-sm font-medium text-[#0f172a] mb-1.5">
<span className="text-red-500">*</span>
</label>
<input
type="text"
value={name}
onChange={(e) => {
setName(e.target.value);
if (e.target.value.trim()) setNameError(false);
}}
className={`w-full px-4 py-2.5 border rounded-lg text-sm text-[#0f172a] focus:outline-none focus:ring-2 transition-colors placeholder:text-gray-300 ${
nameError
? "border-red-500 ring-2 ring-red-500/15 focus:ring-red-500/20 focus:border-red-500"
: "border-gray-200 focus:ring-[#2563EB]/20 focus:border-[#2563EB]"
} ${cls("name")}`}
placeholder="이름을 입력해주세요"
/>
{nameError && (
<div className="flex items-center gap-1.5 text-red-600 text-xs mt-1.5">
<span
className="material-symbols-outlined flex-shrink-0"
style={{ fontSize: "14px" }}
>
error
</span>
<span> .</span>
</div>
)}
</div>
{/* 이메일 (읽기전용) */}
<div>
<label className="block text-sm font-medium text-[#0f172a] mb-1.5">
<span className="text-red-500">*</span>
</label>
<input
type="email"
value={email}
readOnly
className="w-full px-4 py-2.5 border border-gray-200 rounded-lg text-sm text-[#0f172a] bg-gray-50 cursor-not-allowed focus:outline-none"
/>
<div className="flex items-center gap-1.5 text-gray-500 text-xs mt-1">
<span
className="material-symbols-outlined flex-shrink-0"
style={{ fontSize: "14px" }}
>
info
</span>
<span> </span>
</div>
</div>
{/* 연락처 */}
<div>
<label className="block text-sm font-medium text-[#0f172a] mb-1.5">
</label>
<input
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
className="w-full px-4 py-2.5 border border-gray-200 rounded-lg text-sm text-[#0f172a] focus:outline-none focus:ring-2 focus:ring-[#2563EB]/20 focus:border-[#2563EB] transition-colors placeholder:text-gray-300"
placeholder="010-0000-0000"
/>
</div>
{/* 소속 */}
<div>
<label className="block text-sm font-medium text-[#0f172a] mb-1.5">
</label>
<input
type="text"
value={team}
onChange={(e) => setTeam(e.target.value)}
className="w-full px-4 py-2.5 border border-gray-200 rounded-lg text-sm text-[#0f172a] focus:outline-none focus:ring-2 focus:ring-[#2563EB]/20 focus:border-[#2563EB] transition-colors placeholder:text-gray-300"
placeholder="소속을 입력해주세요"
/>
</div>
</div>
</div>
</div>
{/* 비밀번호 변경 카드 */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm mb-6">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
<h3 className="text-sm font-bold text-[#0f172a] flex items-center gap-2">
<span className="material-symbols-outlined text-base text-[#2563EB]">
lock
</span>
</h3>
<button
onClick={handleTogglePasswordForm}
className="flex items-center gap-1 text-xs font-medium text-[#2563EB] hover:text-[#1d4ed8] transition-colors"
>
<span
className="material-symbols-outlined"
style={{ fontSize: "14px" }}
>
{showPasswordForm ? "close" : "edit"}
</span>
{showPasswordForm ? "취소" : "비밀번호 변경하기"}
</button>
</div>
{showPasswordForm ? (
<div className="p-6">
<div className="max-w-md space-y-5">
{/* 현재 비밀번호 */}
<div>
<label className="block text-sm font-medium text-[#0f172a] mb-1.5">
<span className="text-red-500">*</span>
</label>
<div className="relative">
<input
type={showCurrentPw ? "text" : "password"}
value={currentPw}
onChange={(e) => setCurrentPw(e.target.value)}
className="w-full px-4 py-2.5 pr-10 border border-gray-200 rounded-lg text-sm text-[#0f172a] focus:outline-none focus:ring-2 focus:ring-[#2563EB]/20 focus:border-[#2563EB] transition-colors placeholder:text-gray-300"
placeholder="현재 비밀번호를 입력해주세요"
/>
<button
type="button"
onClick={() => setShowCurrentPw((v) => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-[#0f172a] transition-colors"
>
<span className="material-symbols-outlined text-lg">
{showCurrentPw ? "visibility" : "visibility_off"}
</span>
</button>
</div>
</div>
{/* 새 비밀번호 */}
<div>
<label className="block text-sm font-medium text-[#0f172a] mb-1.5">
<span className="text-red-500">*</span>
</label>
<div className="relative">
<input
type={showNewPw ? "text" : "password"}
value={newPw}
onChange={(e) => setNewPw(e.target.value)}
className="w-full px-4 py-2.5 pr-10 border border-gray-200 rounded-lg text-sm text-[#0f172a] focus:outline-none focus:ring-2 focus:ring-[#2563EB]/20 focus:border-[#2563EB] transition-colors placeholder:text-gray-300"
placeholder="새 비밀번호를 입력해주세요"
/>
<button
type="button"
onClick={() => setShowNewPw((v) => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-[#0f172a] transition-colors"
>
<span className="material-symbols-outlined text-lg">
{showNewPw ? "visibility" : "visibility_off"}
</span>
</button>
</div>
{/* 비밀번호 규칙 */}
{newPw && (
<div className="mt-2 space-y-0.5">
{ruleResults
.filter((r) => !r.pass)
.map((rule) => (
<div
key={rule.key}
className="flex items-center gap-1.5 text-gray-500 text-xs"
>
<span
className="material-symbols-outlined flex-shrink-0"
style={{ fontSize: "14px" }}
>
keyboard
</span>
<span>{rule.label}</span>
</div>
))}
{ruleResults
.filter((r) => r.pass)
.map((rule) => (
<div
key={rule.key}
className="flex items-center gap-1.5 text-green-600 text-xs"
>
<span
className="material-symbols-outlined flex-shrink-0"
style={{ fontSize: "14px" }}
>
check_circle
</span>
<span className="line-through">{rule.label}</span>
</div>
))}
</div>
)}
{/* 강도 바 */}
<div className="mt-3">
<div className="flex gap-1 mb-1">
{[1, 2, 3, 4].map((i) => (
<div
key={i}
className={`h-1 flex-1 rounded-sm transition-colors ${
newPw && i <= strengthLevel
? STRENGTH_CONFIG[strengthLevel].color.split(" ")[0]
: "bg-gray-200"
}`}
/>
))}
</div>
<div
className={`flex items-center gap-1.5 text-xs ${
newPw
? STRENGTH_CONFIG[strengthLevel].color.split(" ")[1]
: "text-gray-500"
}`}
>
<span
className="material-symbols-outlined flex-shrink-0"
style={{ fontSize: "14px" }}
>
{newPw ? STRENGTH_ICONS[strengthLevel] : "info"}
</span>
<span>
{newPw
? STRENGTH_CONFIG[strengthLevel].label
: "모든 조건을 충족해야 변경할 수 있습니다"}
</span>
</div>
</div>
</div>
{/* 새 비밀번호 확인 */}
<div>
<label className="block text-sm font-medium text-[#0f172a] mb-1.5">
<span className="text-red-500">*</span>
</label>
<div className="relative">
<input
type={showConfirmPw ? "text" : "password"}
value={confirmPw}
onChange={(e) => setConfirmPw(e.target.value)}
className="w-full px-4 py-2.5 pr-10 border border-gray-200 rounded-lg text-sm text-[#0f172a] focus:outline-none focus:ring-2 focus:ring-[#2563EB]/20 focus:border-[#2563EB] transition-colors placeholder:text-gray-300"
placeholder="새 비밀번호를 다시 입력해주세요"
/>
<button
type="button"
onClick={() => setShowConfirmPw((v) => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-[#0f172a] transition-colors"
>
<span className="material-symbols-outlined text-lg">
{showConfirmPw ? "visibility" : "visibility_off"}
</span>
</button>
</div>
{passwordMatch && (
<div className="flex items-center gap-1.5 text-xs mt-1 text-green-600">
<span
className="material-symbols-outlined flex-shrink-0"
style={{ fontSize: "14px" }}
>
check_circle
</span>
<span> </span>
</div>
)}
{passwordMismatch && (
<div className="flex items-center gap-1.5 text-xs mt-1 text-red-600">
<span
className="material-symbols-outlined flex-shrink-0"
style={{ fontSize: "14px" }}
>
error
</span>
<span> </span>
</div>
)}
</div>
</div>
{/* 비밀번호 변경 버튼 */}
<div className="flex justify-end pt-4 mt-4 border-t border-gray-100">
<button
onClick={() => {
if (!currentPw || !newPw || !confirmPw) return;
if (!allRulesPass) return;
if (newPw !== confirmPw) return;
setShowPwModal(true);
}}
disabled={!currentPw || !allRulesPass || !passwordMatch}
className="px-5 py-2 bg-[#2563EB] text-white rounded-lg text-sm font-medium hover:bg-[#1d4ed8] transition-colors shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
</div>
</div>
) : (
<div className="px-6 py-4">
<div className="flex items-center gap-3">
<span className="material-symbols-outlined text-green-500 text-lg">
check_circle
</span>
<div>
<p className="text-sm text-[#0f172a]">
</p>
<p className="text-xs text-gray-400 mt-0.5">
변경: 2026-01-15
</p>
</div>
</div>
</div>
)}
</div>
{/* 보안 설정 (추후 개발) */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm mb-6">
<div className="flex items-center justify-between px-6 py-4">
<h3 className="text-sm font-bold text-[#0f172a] flex items-center gap-2">
<span className="material-symbols-outlined text-base text-[#2563EB]">
security
</span>
</h3>
<span className="text-xs text-gray-400 bg-gray-100 px-2.5 py-1 rounded-full">
</span>
</div>
</div>
{/* 하단 버튼 바 */}
<div className="flex items-center justify-between pt-2">
<button
onClick={() => navigate("/settings")}
className="flex items-center gap-1.5 px-5 py-2.5 border border-gray-200 rounded-lg text-sm font-medium text-[#64748b] hover:bg-gray-50 hover:text-[#0f172a] transition-colors"
>
</button>
<div className="flex items-center gap-3">
<button
onClick={handleReset}
className="px-5 py-2.5 border border-gray-200 rounded-lg text-sm font-medium text-[#64748b] hover:bg-gray-50 hover:text-[#0f172a] transition-colors"
>
</button>
<button
onClick={() => {
if (!name.trim()) {
setNameError(true);
triggerShake(["name"]);
return;
}
setShowSaveModal(true);
}}
className="px-6 py-2.5 bg-[#2563EB] text-white rounded-lg text-sm font-medium hover:bg-[#1d4ed8] transition-colors shadow-sm"
>
</button>
</div>
</div>
{/* 변경사항 저장 확인 모달 */}
{showSaveModal && (
<div className="fixed inset-0 z-50">
<div
className="absolute inset-0 bg-black/40"
onClick={() => setShowSaveModal(false)}
/>
<div className="flex items-center justify-center min-h-screen p-4">
<div className="relative bg-white rounded-xl shadow-2xl w-full max-w-md p-6 z-10 border border-gray-200">
<div className="flex items-center gap-3 mb-4">
<div className="size-10 rounded-full bg-amber-100 flex items-center justify-center flex-shrink-0">
<span className="material-symbols-outlined text-amber-600 text-xl">
warning
</span>
</div>
<h3 className="text-lg font-bold text-[#0f172a]">
</h3>
</div>
<p className="text-sm text-[#0f172a] mb-5">
?
</p>
<div className="flex justify-end gap-3">
<button
onClick={() => setShowSaveModal(false)}
className="bg-white border border-gray-300 hover:bg-gray-50 text-[#0f172a] px-5 py-2.5 rounded text-sm font-medium transition"
>
</button>
<button
onClick={handleSaveConfirm}
className="bg-[#2563EB] hover:bg-[#1d4ed8] text-white px-5 py-2.5 rounded text-sm font-medium transition shadow-sm"
>
</button>
</div>
</div>
</div>
</div>
)}
{/* 비밀번호 변경 확인 모달 */}
{showPwModal && (
<div className="fixed inset-0 z-50">
<div
className="absolute inset-0 bg-black/40"
onClick={() => setShowPwModal(false)}
/>
<div className="flex items-center justify-center min-h-screen p-4">
<div className="relative bg-white rounded-xl shadow-2xl w-full max-w-md p-6 z-10 border border-gray-200">
<div className="flex items-center gap-3 mb-4">
<div className="size-10 rounded-full bg-amber-100 flex items-center justify-center flex-shrink-0">
<span className="material-symbols-outlined text-amber-600 text-xl">
warning
</span>
</div>
<h3 className="text-lg font-bold text-[#0f172a]">
</h3>
</div>
<p className="text-sm text-[#0f172a] mb-2">
?
</p>
<div className="bg-amber-50 border border-amber-200 rounded-lg px-4 py-3 mb-5">
<div className="flex items-center gap-1.5 text-amber-700 text-xs font-medium">
<span
className="material-symbols-outlined flex-shrink-0"
style={{ fontSize: "14px" }}
>
info
</span>
<span>
, .
</span>
</div>
</div>
<div className="flex justify-end gap-3">
<button
onClick={() => setShowPwModal(false)}
className="bg-white border border-gray-300 hover:bg-gray-50 text-[#0f172a] px-5 py-2.5 rounded text-sm font-medium transition"
>
</button>
<button
onClick={handlePwConfirm}
className="bg-[#2563EB] hover:bg-[#1d4ed8] text-white px-5 py-2.5 rounded text-sm font-medium transition shadow-sm"
>
</button>
</div>
</div>
</div>
</div>
)}
</>
);
}

View File

@ -0,0 +1,223 @@
// Settings feature 타입 정의
// 사용자 프로필
export interface UserProfile {
name: string;
email: string;
role: string;
team: string;
phone: string;
joinDate: string;
lastLogin: string;
}
// 최근 활동
export interface Activity {
id: number;
type: "send" | "service" | "device" | "auth";
title: string;
detail: string;
time: string;
}
// 알림 타입
export const NOTIFICATION_TYPES = {
SEND: "발송",
CERT: "인증서",
SERVICE: "서비스",
FAIL: "실패",
SYSTEM: "시스템",
} as const;
export type NotificationType =
(typeof NOTIFICATION_TYPES)[keyof typeof NOTIFICATION_TYPES];
// 알림
export interface Notification {
id: number;
type: NotificationType;
title: string;
description: string;
time: string;
read: boolean;
date: string; // 그룹핑용 (오늘/어제/이번 주)
detail?: string; // 패널에서 보여줄 상세 내용
}
// --- API 응답 타입 ---
/** 프로필 응답 (ProfileResponseDto) */
export interface ProfileResponse {
admin_code: string | null;
email: string | null;
name: string | null;
phone: string | null;
role: number;
created_at: string;
last_login_at: string | null;
organization: string | null;
}
/** 역할 매핑 */
export const ROLE_LABELS: Record<number, string> = {
1: "관리자",
2: "운영자",
} as const;
// --- API 요청 타입 ---
/** 프로필 수정 요청 */
export interface UpdateProfileRequest {
name?: string | null;
phone?: string | null;
organization?: string | null;
}
/** 활동 내역 요청 */
export interface ActivityListRequest {
page: number;
size: number;
from?: string | null;
to?: string | null;
}
/** 활동 내역 항목 (ActivityItemDto) */
export interface ActivityItem {
activity_type: string | null;
title: string | null;
description: string | null;
ip_address: string | null;
occurred_at: string;
}
/** 활동 내역 페이지네이션 */
export interface ActivityPagination {
page: number;
size: number;
total_count: number;
total_pages: number;
}
/** 활동 내역 응답 */
export interface ActivityListResponse {
items: ActivityItem[] | null;
pagination: ActivityPagination;
}
/** 비밀번호 변경 요청 */
export interface ChangePasswordRequest {
currentPassword: string;
newPassword: string;
}
/** 비밀번호 변경 응답 */
export interface ChangePasswordResponse {
re_login_required: boolean;
}
// 목데이터: 알림
export const MOCK_NOTIFICATIONS: Notification[] = [
{
id: 1,
type: "발송",
title: "발송 완료: 마케팅 캠페인 알림",
description:
"2026-02-19 14:30에 1,234건 발송이 완료되었습니다. 대상 서비스: (주) 미래테크",
time: "14:30",
read: false,
date: "오늘",
detail:
"발송 채널: FCM (Android)\n전체 대상: 1,234건\n성공: 1,224건 (99.2%)\n실패: 10건 (0.8%)\n\n실패 사유:\n- 유효하지 않은 토큰: 7건\n- 네트워크 타임아웃: 3건",
},
{
id: 2,
type: "인증서",
title: "인증서 만료 예정: iOS Production",
description:
"7일 후 만료 예정입니다. 서비스 중단 방지를 위해 갱신을 진행해주세요",
time: "11:20",
read: false,
date: "오늘",
detail:
"인증서 정보:\n- 타입: iOS Production (P8)\n- 발급일: 2025-02-26\n- 만료일: 2026-02-26\n- 남은 일수: 7일\n\n갱신하지 않으면 iOS 푸시 발송이 중단됩니다. [서비스 관리 > 인증서]에서 갱신해주세요.",
},
{
id: 3,
type: "서비스",
title: "서비스 등록 완료: (주) 미래테크",
description:
"새 서비스가 정상적으로 등록되었습니다. 플랫폼 인증을 진행해주세요",
time: "09:45",
read: false,
date: "오늘",
detail:
"서비스명: (주) 미래테크\n서비스 ID: SVC-2026-0042\n등록일: 2026-02-19\n\n등록 플랫폼:\n- Android (FCM): 인증 완료\n- iOS (APNs): 인증 대기\n\niOS 인증서를 등록하면 iOS 기기에도 푸시를 발송할 수 있습니다.",
},
{
id: 4,
type: "실패",
title: "발송 실패: 긴급 공지 알림 (3건)",
description: "유효하지 않은 토큰으로 인해 발송에 실패했습니다",
time: "08:15",
read: true,
date: "오늘",
detail:
"발송 ID: SEND-20260219-003\n대상 서비스: Smart Factory\n실패 건수: 3건\n\n실패 상세:\n- device_token_expired: 2건\n- invalid_registration: 1건\n\n해당 기기 토큰을 갱신하거나 삭제 후 재발송해주세요.",
},
{
id: 5,
type: "시스템",
title: "시스템 점검 완료",
description: "정기 점검이 완료되어 정상 운영 중입니다",
time: "23:00",
read: true,
date: "어제",
detail:
"점검 시간: 2026-02-18 22:00 ~ 23:00\n점검 내용: 서버 보안 패치 및 DB 최적화\n\n영향 범위: 전체 서비스 일시 중단\n현재 상태: 정상 운영 중\n\n다음 정기 점검 예정: 2026-03-18",
},
{
id: 6,
type: "발송",
title: "발송 완료: 앱 업데이트 공지",
description: "5,678건 발송 완료. 대상 서비스: Smart Factory",
time: "16:45",
read: true,
date: "어제",
detail:
"발송 채널: FCM (Android) + APNs (iOS)\n전체 대상: 5,678건\n성공: 5,650건 (99.5%)\n실패: 28건 (0.5%)\n\n서비스: Smart Factory\n메시지 제목: 앱 업데이트 v2.5.0 안내",
},
{
id: 7,
type: "인증서",
title: "인증서 갱신 완료: Android FCM",
description: "FCM 서버 키가 정상적으로 갱신되었습니다",
time: "10:00",
read: true,
date: "어제",
detail:
"인증서 정보:\n- 타입: Android FCM (Server Key)\n- 이전 만료일: 2026-02-20\n- 갱신 후 만료일: 2027-02-18\n\n갱신은 자동으로 적용되었으며, 별도 조치가 필요하지 않습니다.",
},
{
id: 8,
type: "발송",
title: "발송 완료: 주간 리포트 알림",
description: "892건 발송 완료. 대상 서비스: (주) 미래테크",
time: "02.17 09:30",
read: true,
date: "이번 주",
detail:
"발송 채널: FCM (Android)\n전체 대상: 892건\n성공: 890건 (99.8%)\n실패: 2건 (0.2%)\n\n서비스: (주) 미래테크\n메시지 제목: 주간 리포트 (2026.02.10 ~ 02.16)",
},
{
id: 9,
type: "시스템",
title: "정기 점검 예정 안내",
description:
"2026-02-18 22:00 ~ 23:00 정기 점검이 예정되어 있습니다",
time: "02.16 02:00",
read: true,
date: "이번 주",
detail:
"점검 예정 시간: 2026-02-18 22:00 ~ 23:00\n점검 내용: 서버 보안 패치 및 DB 최적화\n\n영향 범위:\n- 전체 서비스 일시 중단\n- API 호출 불가\n- 관리자 콘솔 접속 불가\n\n점검 중 발송 예약 건은 점검 완료 후 자동 재개됩니다.",
},
];

Some files were not shown because too many files have changed in this diff Show More