Compare commits

...

3 Commits

Author SHA1 Message Date
zio
452398d941 docs: reconcile owner feedback log with deployed state (2026-07-13)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 05:16:36 +09:00
zio
7a117a018c fix(mobile): declare roleCode on AuthUser so admin/manager land on tabs home, not visitor tickets
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 05:16:35 +09:00
zio
ecf60aac1d feat(web): tabulator data table + breadcrumb/404 navigation + message-template admin
- KxDataTable: in-house tabulator features (sort/filter/pagination/CSV UTF-8 BOM)
  on canonical .kx-table; applied to exhibitors, lead scoring, visitor registration
- Breadcrumb in shell content top (menu-IA-aligned trails, detail->list return),
  NotFoundPage wired to router catch-all with role-aware home return
- MessageAdminPage (/admin/messages): V48 sys_message CRUD + {key} placeholder
  render preview, wired to shell menu / breadcrumb / MDI labels / admin dashboard
- i18n: datatable/breadcrumb/notFound/msg namespaces, ko-en-zh-ja parity kept

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 05:16:35 +09:00
25 changed files with 1957 additions and 247 deletions

View File

@ -2,7 +2,7 @@
> 이번 세션에서 소유자가 지시한 모든 항목을 전수 기록한다. 상태: ✅완료(배포) · 🚚배포중 · ⏳작업중 · 📋대기. > 이번 세션에서 소유자가 지시한 모든 항목을 전수 기록한다. 상태: ✅완료(배포) · 🚚배포중 · ⏳작업중 · 📋대기.
> 이 파일을 기준으로 누락 없이 처리한다. 새 지시는 여기에 추가한다. > 이 파일을 기준으로 누락 없이 처리한다. 새 지시는 여기에 추가한다.
> 최종 갱신: 2026-07-12 > 최종 갱신: 2026-07-13 (04:33 배포분 반영 — 276293e·a5a78f1·7316cd9·c77cba7·0add2cd·37aeebd 라이브 확인)
--- ---
@ -10,26 +10,26 @@
| # | 지시 | 상태 | 비고 | | # | 지시 | 상태 | 비고 |
|---|------|------|------| |---|------|------|------|
| 1.1 | **Nifty 카드 타입** 적용 (https://preview.themeon.net/nifty/ui-elements/cards/) — 라운딩·그림자·헤더/바디/푸터 구조·보더 톤 | 📋대기 | `.kxp-ecard`는 8px 라운딩 있으나 Nifty 카드 구조 전면 정렬 필요. 다음 패스 | | 1.1 | **Nifty 카드 타입** 적용 (https://preview.themeon.net/nifty/ui-elements/cards/) — 라운딩·그림자·헤더/바디/푸터 구조·보더 톤 | ✅완료 | 276293e `.kx-card` Nifty 구조 정렬, 2026-07-13 배포 |
| 1.2 | **그리드는 Nifty Advanced table headers**로 통일 (tables/static-tables) | ⏳작업중 | 정본 `.kx-table` 존재, 미준수 6화면 수렴 (감사완료) | | 1.2 | **그리드는 Nifty Advanced table headers**로 통일 (tables/static-tables) | ✅완료 | 276293e `.kx-table` 6화면 수렴, 배포됨 |
| 1.3 | **Tabulator 기능** 추가 (tables/tabulator — 정렬·필터·페이지·CSV) | 📋대기 | task #32 | | 1.3 | **Tabulator 기능** 추가 (tables/tabulator — 정렬·필터·페이지·CSV) | 🚚구현완료 | task #32 — KxDataTable 자체구현(정렬/필터/페이지/CSV BOM), 참가업체·리드스코어·등록자 3화면 적용. 배포 대기(2026-07-13) |
| 1.4 | **상세페이지 = Nifty blog** 스타일 (blog-apps/blog) | 📋대기 | task #33 | | 1.4 | **상세페이지 = Nifty blog** 스타일 (blog-apps/blog) | ✅완료 | a5a78f1 Nifty detail views, 배포됨 |
| 1.5 | **버튼 = Nifty buttons**, **Dropdowns**, **컴포넌트**, **List Group**, **Typography/Modals/Offcanvas** = ui-elements 전체 채택 | ⏳부분 | Offcanvas 완료, 나머지 정렬 진행 | | 1.5 | **버튼 = Nifty buttons**, **Dropdowns**, **컴포넌트**, **List Group**, **Typography/Modals/Offcanvas** = ui-elements 전체 채택 | ⏳부분 | Offcanvas 완료, 나머지 정렬 진행 |
| 1.6 | **Nifty 스타일 학습 md 생성** | ✅완료 | `docs/DESIGN_SYSTEM_NIFTY.md`·`docs/analysis/nifty-design-refs.md` | | 1.6 | **Nifty 스타일 학습 md 생성** | ✅완료 | `docs/DESIGN_SYSTEM_NIFTY.md`·`docs/analysis/nifty-design-refs.md` |
| 1.7 | **아이콘은 선(line/stroke) SVG**로 직접 (이모지 금지) | ✅원칙 | 전 신규화면 준수 | | 1.7 | **아이콘은 선(line/stroke) SVG**로 직접 (이모지 금지) | ✅원칙 | 전 신규화면 준수 |
| 1.8 | **UI는 WISE(UIWS) 문자 그대로** — 셸·메뉴·캘린더 자체 재해석 금지 | ✅원칙 | 메모리 [[ui-follow-wise-strictly]] | | 1.8 | **UI는 WISE(UIWS) 문자 그대로** — 셸·메뉴·캘린더 자체 재해석 금지 | ✅원칙 | 메모리 [[ui-follow-wise-strictly]] |
| 1.9 | **폰트를 페이지마다 안 맞춤** — 전 화면 타이포 일관화 | ⏳작업중 | 감사완료(하드코딩 262건/36파일). `--fs-micro`·공용 `.kx-page__title`·weight 토큰 신설로 정렬. 다음 패스 | | 1.9 | **폰트를 페이지마다 안 맞춤** — 전 화면 타이포 일관화 | ✅완료 | 276293e font 179+weight 415 토큰화(`--fs-micro`·공용 제목클래스), 배포됨 |
| 1.10 | **모든 경계선 엷은 회색** | ⏳부분 | 구조 보더는 neutral-200(#e4e7ec) 사용 중. 의미색·hover는 유지, 지정화면 정렬 | | 1.10 | **모든 경계선 엷은 회색** | ⏳부분 | 구조 보더는 neutral-200(#e4e7ec) 사용 중. 의미색·hover는 유지, 지정화면 정렬 |
| 1.11 | **좌우 2단 폭 50:50**, 전 화면 **공백 없는 반응형**(풀블리드) | ✅원칙 | task #39 스윕 대기 | | 1.11 | **좌우 2단 폭 50:50**, 전 화면 **공백 없는 반응형**(풀블리드) | ⏳작업중 | task #39 스윕 진행 중(2026-07-13) |
| 1.12 | **top 설정 아이콘** 추가 + **설정 레이아웃 커스터마이저**(offcanvas 테마/레이아웃/색상) | ⏳부분 | 설정 아이콘 완료, 커스터마이저 task #43 | | 1.12 | **top 설정 아이콘** 추가 + **설정 레이아웃 커스터마이저**(offcanvas 테마/레이아웃/색상) | ✅완료 | a5a78f1 settings customizer(customizerStore), 배포됨 |
## 2. 캘린더 ## 2. 캘린더
| # | 지시 | 상태 | | # | 지시 | 상태 |
|---|------|------| |---|------|------|
| 2.1 | 캘린더를 **Nifty app-views/calendar**처럼 (옅은 회색 격자·연한 today·가벼운 이벤트 칩) | 🚚배포중 | 큰 캘린더 재스타일 완료(격자 neutral-100·today 연노랑·소프트 칩), 배포 중 | | 2.1 | 캘린더를 **Nifty app-views/calendar**처럼 (옅은 회색 격자·연한 today·가벼운 이벤트 칩) | ✅완료 | 배포됨(2026-07-13 라이브) |
| 2.2 | **공휴일 붉은색 + 일자 옆 공휴일명** (2050년까지 데이터) | 🚚배포중 | 큰 캘린더+미니 달력 배선. 7월은 공휴일 없음(8/15·9월 추석 표시) | | 2.2 | **공휴일 붉은색 + 일자 옆 공휴일명** (2050년까지 데이터) | ✅완료 | c77cba7 홈 캘린더 공휴일 마커 포함, 배포됨 |
| 2.3 | **달력 경계선 모두 회색** | 🚚배포중 | 미니 달력 셀 회색 테두리 추가 | | 2.3 | **달력 경계선 모두 회색** | ✅완료 | 배포됨 |
| 2.4 | 일정 **월/주/일** 뷰 (WISE CalendarView) | ✅완료 | | 2.4 | 일정 **월/주/일** 뷰 (WISE CalendarView) | ✅완료 |
| 2.5 | 전시 일정 50:50 비율·달력 날짜 위로(하단 공백 제거) | ✅완료 | | 2.5 | 전시 일정 50:50 비율·달력 날짜 위로(하단 공백 제거) | ✅완료 |
@ -39,48 +39,48 @@
|---|------|------| |---|------|------|
| 3.1 | **첫 화면 = 제품 소개** (미인증 루트는 히어로 랜딩, 로그인 폼만 노출 금지) | ✅완료 | 메모리 [[product-identity-first]] | | 3.1 | **첫 화면 = 제품 소개** (미인증 루트는 히어로 랜딩, 로그인 폼만 노출 금지) | ✅완료 | 메모리 [[product-identity-first]] |
| 3.2 | 제품명 **'KINTEX AI 전시·행사시스템'** | ✅완료 | | 3.2 | 제품명 **'KINTEX AI 전시·행사시스템'** | ✅완료 |
| 3.3 | **visitor 메인 = 세련된 홈페이지** (AI 관람 도우미 중심) | 🚚배포중 | AiAssistant·기능타일·연월 브리핑·쇼케이스 구현 | | 3.3 | **visitor 메인 = 세련된 홈페이지** (AI 관람 도우미 중심) | ✅완료 | AiAssistant·기능타일·연월 브리핑·쇼케이스, 배포됨 |
| 3.4 | 메인에 **AI 기능 전면 노출** (AI가 뭘 하는지 한눈에) | 🚚배포중 | AI 기능 쇼케이스 6타일 | | 3.4 | 메인에 **AI 기능 전면 노출** (AI가 뭘 하는지 한눈에) | ✅완료 | AI 기능 쇼케이스 6타일, 배포됨 |
| 3.5 | AI가 답하는 정보는 **크롤링해서 DB 저장** (실시간 외부호출 X) | ✅완료 | V43 visitor_guide·transport, 공개 AI API 근거응답 | | 3.5 | AI가 답하는 정보는 **크롤링해서 DB 저장** (실시간 외부호출 X) | ✅완료 | V43 visitor_guide·transport, 공개 AI API 근거응답 |
| 3.6 | **AI는 전시·행사정보 학습** (근거 기반) | ✅완료 | grounding + 인용, 환각 차단 | | 3.6 | **AI는 전시·행사정보 학습** (근거 기반) | ✅완료 | grounding + 인용, 환각 차단 |
| 3.7 | **AI가 연간·월간 전시·행사 기획정보 제시** | 🚚배포중 | AiPlanningBriefing(연/월 토글) | | 3.7 | **AI가 연간·월간 전시·행사 기획정보 제시** | ✅완료 | AiPlanningBriefing(연/월 토글), 배포됨 |
| 3.8 | 비지터가 **가장 알고 싶은 정보를 AI로 쉽게** | ✅설계 | 관람 도우미 예시칩·구조화 카드 | | 3.8 | 비지터가 **가장 알고 싶은 정보를 AI로 쉽게** | ✅설계 | 관람 도우미 예시칩·구조화 카드 |
| 3.9 | 코엑스 분석 → **visitor/business/agency 3-트랙** 분리 | ✅완료 | 관문 + 3 서브홈 + TrackSwitcher | | 3.9 | 코엑스 분석 → **visitor/business/agency 3-트랙** 분리 | ✅완료 | 관문 + 3 서브홈 + TrackSwitcher |
| 3.10 | **"코엑스처럼" 전면 리뉴얼** (히어로 배경·애니메이션·비주얼) | 🚚배포중 | design.md v2.4 §3C-V, 코엑스풍 비주얼·모션 구현 | | 3.10 | **"코엑스처럼" 전면 리뉴얼** (히어로 배경·애니메이션·비주얼) | ✅완료 | design.md v2.4 §3C-V 구현, 배포됨 |
| 3.11 | 메인 카드가 **밋밋한 파란 placeholder** (포스터 이미지 없음) | ✅완료 | V46 실제 포스터 이미지로 교체(배포됨) | | 3.11 | 메인 카드가 **밋밋한 파란 placeholder** (포스터 이미지 없음) | ✅완료 | V46 실제 포스터 이미지로 교체(배포됨) |
| 3.12 | **메인 카드 이미지 크기 안 맞음**(세로 포스터가 16:11 가로 프레임에 늘어남/잘림) | ⏳수정됨 | 포스터 실측 세로 0.67~0.8 → 카드 미디어 3:4 프레임 + `.kxp-ecard__poster` `object-fit:cover`(왜곡 제거). 다음 배포 | | 3.12 | **메인 카드 이미지 크기 안 맞음**(세로 포스터가 16:11 가로 프레임에 늘어남/잘림) | ✅완료 | 3:4 프레임 + `object-fit:cover`, 배포됨 |
| 3.13 | **메인 히어로 이미지 5장 회전(슬라이드)** | ⏳착수 | 크롤러가 KINTEX 전시장/시설 히어로 이미지 5장 `public/media/hero/` 확보 → 프론트 히어로 회전 UI(배포 후) | | 3.13 | **메인 히어로 이미지 5장 회전(슬라이드)** | ✅완료 | 7316cd9 실사진 5장 회전 히어로, 배포됨 |
## 4. 역할 · 네비게이션 ## 4. 역할 · 네비게이션
| # | 지시 | 상태 | | # | 지시 | 상태 |
|---|------|------| |---|------|------|
| 4.1 | **로그인 사용자 구분으로 메인페이지 + 메뉴 그룹 결정** (디폴트 visitor, 로그인 후 business/admin/agency) | 🚚배포중 | plan_role_routing 구현(admin→/admin·agency→/contractor·visitor→/visitor·ops/business→/home) + 역할별 메뉴 게이트 | | 4.1 | **로그인 사용자 구분으로 메인페이지 + 메뉴 그룹 결정** (디폴트 visitor, 로그인 후 business/admin/agency) | ✅완료 | plan_role_routing(roleTrack.ts) + 역할별 메뉴 게이트, 배포됨 |
| 4.2 | **각 트랙 메인페이지 Stitch 디자인 의뢰** | ✅부분 | Stitch 2회 실패 → design.md 스펙 직접 구현(문서화 폴백) | | 4.2 | **각 트랙 메인페이지 Stitch 디자인 의뢰** | ✅부분 | Stitch 2회 실패 → design.md 스펙 직접 구현(문서화 폴백) |
| 4.3 | **모바일 앱도 동일 로직** | 📋대기 | 웹 완료 후 모바일 반영 | | 4.3 | **모바일 앱도 동일 로직** | 🚚구현완료 | 기구현 확인 + 결함 수정(roleCode 타입 누락 → 워크스페이스 없는 ADMIN이 티켓으로 오랜딩). tsc 통과(2026-07-13) |
| 4.4 | **상세→메인 복귀·브레드크럼·404** 내비게이션 | ⏳ | renewal 하네스 | | 4.4 | **상세→메인 복귀·브레드크럼·404** 내비게이션 | 🚚구현완료 | Breadcrumb(셸 콘텐츠 상단, 메뉴 IA 정합) + NotFound catch-all(역할별 홈 복귀). 배포 대기(2026-07-13) |
## 5. 데이터 / 시드 ## 5. 데이터 / 시드
| # | 지시 | 상태 | | # | 지시 | 상태 |
|---|------|------| |---|------|------|
| 5.1 | **모든 테이블에 데이터** (50~100건) | ✅완료 | V42 대량시드(40테이블 2,535행) | | 5.1 | **모든 테이블에 데이터** (50~100건) | ✅완료 | V42 대량시드(40테이블 2,535행) |
| 5.2 | **정산·결제 데이터 없음** — 행사스코프 정렬 | 🚚배포중 | V47: 데모 해소행사 `e-2026-live`에 정산 폐루프+옥션+관람객+리드+캠페인 | | 5.2 | **정산·결제 데이터 없음** — 행사스코프 정렬 | ✅완료 | V47 `e-2026-live` 정산 폐루프, 배포됨 |
| 5.3 | 테스트 데이터 **더 현실적으로** | ✅완료 | V39·V42 실감형 | | 5.3 | 테스트 데이터 **더 현실적으로** | ✅완료 | V39·V42 실감형 |
| 5.4 | 행사정보 **크롤링해서 데이터 넣기** | ✅완료 | 크롤 시드 | | 5.4 | 행사정보 **크롤링해서 데이터 넣기** | ✅완료 | 크롤 시드 |
| 5.5 | **연간·월간 전시 기획정보**도 적재 | ✅완료 | V43 event 생성열·v_event_calendar 뷰 | | 5.5 | **연간·월간 전시 기획정보**도 적재 | ✅완료 | V43 event 생성열·v_event_calendar 뷰 |
| 5.6 | 포스터 이미지 **다 다운받아 빌드 내장** | 🚚배포중 | V46 34장 `/media/posters/` | | 5.6 | 포스터 이미지 **다 다운받아 빌드 내장** | ✅완료 | V46 `/media/posters/`, 배포됨 |
## 6. 정산·도메인 화면 ## 6. 정산·도메인 화면
| # | 지시 | 상태 | | # | 지시 | 상태 |
|---|------|------| |---|------|------|
| 6.1 | **정산·결제 패딩 없음** | 🚚배포중 | `.kx-settle` 패딩 추가 | | 6.1 | **정산·결제 패딩 없음** | ✅완료 | `.kx-settle` 패딩, 배포됨 |
| 6.2 | **참가업체 관리가 홈으로 감** — 전용 화면 신설 | 📋대기 | task #41 | | 6.2 | **참가업체 관리가 홈으로 감** — 전용 화면 신설 | ✅완료 | a5a78f1 ExhibitorsPage(screens/exhibitors), 배포됨 |
| 6.3 | 부서 관리 = **WISE 조직도** | ✅완료 | | 6.3 | 부서 관리 = **WISE 조직도** | ✅완료 |
| 6.4 | 결재관리 **반려·재상신·결재선** (WISE) | ✅완료 | | 6.4 | 결재관리 **반려·재상신·결재선** (WISE) | ✅완료 |
| 6.5 | 통합검색에 **행사·전시 포함** | ✅완료 | | 6.5 | 통합검색에 **행사·전시 포함** | ✅완료 |
| 6.6 | 시스템관리 **메시지 관리**({} 플레이스홀더+샘플) | 📋대기 | task #35 | | 6.6 | 시스템관리 **메시지 관리**({} 플레이스홀더+샘플) | 🚚구현완료 | 백엔드 ✅(276293e V48) + 프론트 MessageAdminPage(/admin/messages — 필터·검색·upsert·렌더 미리보기, i18n×4) 신설. 배포 대기(2026-07-13) |
| 6.7 | 물류허브·서류/마일스톤·CMS·일정 = WISE | ✅완료 | | 6.7 | 물류허브·서류/마일스톤·CMS·일정 = WISE | ✅완료 |
| 6.8 | **그룹권한(역할↔메뉴) 메뉴** 존재 확인 | ✅확인 | RoleAdminPage + RoleMenuAdminPage | | 6.8 | **그룹권한(역할↔메뉴) 메뉴** 존재 확인 | ✅확인 | RoleAdminPage + RoleMenuAdminPage |
@ -89,7 +89,7 @@
| # | 지시 | 상태 | | # | 지시 | 상태 |
|---|------|------| |---|------|------|
| 7.1 | **파란 배경 버튼 = 흰 글자** | ✅완료 | `--color-on-accent`(다크에서도 흰색). 라이브 반영(캐시 새로고침 필요) | | 7.1 | **파란 배경 버튼 = 흰 글자** | ✅완료 | `--color-on-accent`(다크에서도 흰색). 라이브 반영(캐시 새로고침 필요) |
| 7.2 | 다크모드 색상배경 위 **흰 글자 어두워지는 버그** 전수 수정 | ✅완료 | 77곳 on-accent. +통계밴드 2곳 추가수정(배포중) | | 7.2 | 다크모드 색상배경 위 **흰 글자 어두워지는 버그** 전수 수정 | ✅완료 | 77곳 on-accent + 통계밴드 2곳, 배포됨 |
| 7.3 | 회색 버튼은 옅은 ghost — 흰 글자 아님(솔리드 진회색만 흰글자) | ✅확인 | | 7.3 | 회색 버튼은 옅은 ghost — 흰 글자 아님(솔리드 진회색만 흰글자) | ✅확인 |
## 8. 파비콘 · 브랜드 ## 8. 파비콘 · 브랜드
@ -106,7 +106,7 @@
| 9.1 | **내정보 (WISE 참조) + 생체인식 + 사진등록** | ✅완료 | app/profile, expo-local-authentication, expo-image-picker. 아바타 API(백엔드) 완료 | | 9.1 | **내정보 (WISE 참조) + 생체인식 + 사진등록** | ✅완료 | app/profile, expo-local-authentication, expo-image-picker. 아바타 API(백엔드) 완료 |
| 9.2 | **전시·공연 앱 벤치마킹** (다운로드·좋아요 상위순) | ✅완료 | docs/analysis/mobile-benchmark-exhibition-performance.md + 백로그 12항목 | | 9.2 | **전시·공연 앱 벤치마킹** (다운로드·좋아요 상위순) | ✅완료 | docs/analysis/mobile-benchmark-exhibition-performance.md + 백로그 12항목 |
| 9.3 | **앱 위변조 방지 + 시큐어 코딩 + 보안 체크리스트 통과** | ✅완료 | 루트/탈옥·무결성·Hermes+R8·화면캡처·cleartext·권한최소. 체크리스트/감사 문서 | | 9.3 | **앱 위변조 방지 + 시큐어 코딩 + 보안 체크리스트 통과** | ✅완료 | 루트/탈옥·무결성·Hermes+R8·화면캡처·cleartext·권한최소. 체크리스트/감사 문서 |
| 9.4 | **다국어** — 웹 4개국어 완비, 모바일은 한국어(백로그) | ⏳ | 웹 ✅(ko/en/zh/ja), 모바일 i18n 미도입(MB-11) | | 9.4 | **다국어** — 웹 4개국어 완비, 모바일도 4개국어 | ✅완료 | 웹 ✅ + 모바일 ✅(mobile/lib/i18n ko/en/zh/ja + LanguageSelector, react-i18next) |
## 10. AI ## 10. AI
@ -121,7 +121,7 @@
| # | 지시 | 상태 | | # | 지시 | 상태 |
|---|------|------| |---|------|------|
| 11.1 | **FK 최소화, 공통코드로 관리** | ✅표준 | docs/architecture/data.md §10 (소프트참조·공통코드) | | 11.1 | **FK 최소화, 공통코드로 관리** | ✅표준 | docs/architecture/data.md §10 (소프트참조·공통코드) |
| 11.2 | **필요한 인덱스 생성** | 🚚배포중 | V44 성능 인덱스 8건(핫패스) — 이미 배포됨 | | 11.2 | **필요한 인덱스 생성** | ✅완료 | V44 성능 인덱스 8건, 배포됨 |
| 11.3 | **view/mview/function 고려** | ✅표준 | data.md §11 | | 11.3 | **view/mview/function 고려** | ✅표준 | data.md §11 |
| 11.4 | **자동화 배치·프로시저 고려** | ✅표준 | data.md §13 배치 카탈로그 | | 11.4 | **자동화 배치·프로시저 고려** | ✅표준 | data.md §13 배치 카탈로그 |
| 11.5 | **ten_id/tenant_id PK 맨 앞 복합키** | ✅원칙 | 메모리 [[tenant-id-pk-standard]] | | 11.5 | **ten_id/tenant_id PK 맨 앞 복합키** | ✅원칙 | 메모리 [[tenant-id-pk-standard]] |
@ -142,15 +142,29 @@
--- ---
## 미해결·대기 요약 (다음 우선 처리) ## 미해결·대기 요약 (다음 우선 처리 — 2026-07-13 갱신)
1. **Nifty 디자인 시스템 전면 정렬** — 카드(ui-elements/cards)·폰트(--fs-micro·공용 제목클래스)·그리드(.kx-table 6화면) 일괄 (1.1·1.2·1.9) **이번 세션 작업 중:**
2. **참가업체 관리 전용 화면** (6.2 / task #41) 1. **Tabulator 기능** (1.3 / task #32) — KxDataTable 자체구현(정렬·필터·페이지·CSV)
3. **메시지 관리** (6.6 / task #35) 2. **브레드크럼 + 상세→메인 복귀 + 404** (4.4)
4. **Tabulator**·**상세 Nifty blog**·**설정 커스터마이저** (1.3·1.4·1.12 / task #32·33·43) 3. **모바일 역할별 랜딩** (4.3)
4. **메시지 관리 프론트 화면** (6.6 / task #35 — 백엔드 V48은 완료, 화면 부재 확인)
5. **전 화면 공백없는 반응형 스윕** (1.11 / task #39) 5. **전 화면 공백없는 반응형 스윕** (1.11 / task #39)
6. **모바일 역할 랜딩·다국어** (4.3·9.4)
7. **상세→메인 내비게이션** (4.4) **잔여 대기:**
6. **Nifty 버튼/드롭다운/List Group 등 잔여 컴포넌트 정렬** (1.5 부분)
7. **경계선 회색 지정화면 정렬** (1.10 부분)
8. **ReRoomAI image-to-image** (13.2 / visualizer 트랙)
~~완료 처리(2026-07-13 배포 확인): Nifty 카드·그리드·폰트(1.1·1.2·1.9), 참가업체 관리(6.2), 상세 Nifty blog(1.4), 설정 커스터마이저(1.12), 히어로 5장 회전(3.13), 카드 3:4(3.12), 모바일 다국어(9.4)~~
---
## 처리 로그 (2026-07-13 세션)
**배포 검증 (04:33 KST):** push(e67db2e 병합) → Gitea webhook 자동배포 → 프론트 번들(/var/www/kintex)·백엔드 jar 모두 04:33 갱신, HTTPS 200. ★원격 main이 배포 파이프라인에 의해 force-rewrite(58aa844)되어 로컬과 분기 → `-s ours` 병합으로 로컬 정본 유지+원격 이력 보존 후 push. ★외부에서 :8021/:9001 직접 접근은 방화벽 차단 — health 게이트는 SSH(키 `~/.ssh/guardia_server`) 경유 localhost 확인이 정본.
**진행:** Tabulator(KxDataTable)·브레드크럼/404(웹), 역할별 랜딩(모바일) 병렬 구현 → 반응형 스윕 → 메시지 관리 화면.
--- ---

View File

@ -17,7 +17,7 @@ React Native + Expo(expo-router, TypeScript) 모바일 앱. 조회·승인·현
## 역할별 랜딩 ## 역할별 랜딩
- `lib/roleTrack.ts` — 웹 `src/frontend/src/lib/roleTrack.ts` 패리티(우선순위 admin>ops>business>agency>visitor, 미판정=visitor 기본). - `lib/roleTrack.ts` — 웹 `src/frontend/src/lib/roleTrack.ts` 패리티(우선순위 admin>ops>business>agency>visitor, 미판정=visitor 기본).
- 로그인 응답 user에 roleCode가 없어 `hallManager`를 ops 신호로 사용(roleCode 아는 호출부는 옵션 전달). - 로그인 응답 user`roleCode`(app_user.role_code, 웹 `LoginResponse.user`와 동일 shape)를 반환한다 — `ADMIN`/`MANAGER` 우선 판정, 누락 시 `hallManager`를 ops 신호로 폴백(관람객 계정은 roleCode null → workspaces myRole로 판정).
- 모바일 랜딩 매핑: admin/ops/business→`/(tabs)` 홈 · agency(장치·시공)→`/(tabs)/field` · visitor(관람객)→`/tickets`. - 모바일 랜딩 매핑: admin/ops/business→`/(tabs)` 홈 · agency(장치·시공)→`/(tabs)/field` · visitor(관람객)→`/tickets`.
- 콜드 부팅 시 workspaces 미복원 misroute 방지 위해 로그인 시 계산한 랜딩 경로를 secure-store에 지속(`AuthContext`). - 콜드 부팅 시 workspaces 미복원 misroute 방지 위해 로그인 시 계산한 랜딩 경로를 secure-store에 지속(`AuthContext`).

View File

@ -3,8 +3,8 @@
* 우선순위: admin > ops > business > agency > visitor. visitor() . * 우선순위: admin > ops > business > agency > visitor. visitor() .
* *
* ( ): * ( ):
* · user(AuthUser) roleCode가 hallManager=true를 ops . * · roleCode는 /api/auth/login (AuthUser.roleCode).
* (roleCode를 /api/auth/me의 MePrincipal.roleCode ) * roleCode('ADMIN'/'MANAGER') , hallManager=true를 ops .
* · (/admin·/contractor/dashboard·/visitor·/home) . * · (/admin·/contractor/dashboard·/visitor·/home) .
*/ */
import type { AuthUser, EventRole, WorkspaceDto } from './types'; import type { AuthUser, EventRole, WorkspaceDto } from './types';
@ -26,9 +26,9 @@ export function trackOf(role: EventRole | null | undefined): Track {
} }
} }
/** 랜딩 판정 입력 — 로그인 응답 user + workspaces. roleCode는 알 때만(옵션). */ /** 랜딩 판정 입력 — 로그인 응답 user(roleCode 포함) + workspaces. */
export interface LandingInput { export interface LandingInput {
user?: (AuthUser & { roleCode?: string | null }) | null; user?: AuthUser | null;
workspaces?: WorkspaceDto[] | null; workspaces?: WorkspaceDto[] | null;
} }

View File

@ -54,6 +54,10 @@ export interface AuthUser {
userId: string; userId: string;
displayName: string; displayName: string;
hallManager: boolean; hallManager: boolean;
// 전역 역할(app_user.role_code — 'ADMIN' | 'MANAGER' | …). 공유 백엔드 /api/auth/login 이
// 웹과 동일하게 반환한다(웹 LoginResponse.user 와 동일 shape). 관람객 등 미지정 계정은 null/undefined.
// 역할별 랜딩 판정(roleTrack.resolveLandingTrack)이 admin/ops 우선순위 결정에 사용.
roleCode?: string | null;
} }
export interface LoginResponse { export interface LoginResponse {
accessToken: string; accessToken: string;

View File

@ -72,6 +72,7 @@ import { SysUserAdminPage } from './screens/admin/SysUserAdminPage';
import { RoleAdminPage } from './screens/admin/RoleAdminPage'; import { RoleAdminPage } from './screens/admin/RoleAdminPage';
import { CommonCodeAdminPage } from './screens/admin/CommonCodeAdminPage'; import { CommonCodeAdminPage } from './screens/admin/CommonCodeAdminPage';
import { MenuAdminPage } from './screens/admin/MenuAdminPage'; import { MenuAdminPage } from './screens/admin/MenuAdminPage';
import { MessageAdminPage } from './screens/admin/MessageAdminPage';
import { import {
PublicGatewayPage, PublicGatewayPage,
VisitorTrackPage, VisitorTrackPage,
@ -85,6 +86,7 @@ import {
PublicInquiryPage, PublicInquiryPage,
PublicTicketPage, PublicTicketPage,
} from './screens/public'; } from './screens/public';
import { NotFoundPage } from './screens/common/NotFoundPage';
import { AppShell } from './components/layout/AppShell'; import { AppShell } from './components/layout/AppShell';
/** SCR-22 → SCR-23 작성 화면 진입 배선. */ /** SCR-22 → SCR-23 작성 화면 진입 배선. */
@ -259,6 +261,7 @@ export function App() {
<Route path="/admin/system-health" element={<AdminGuard><SystemHealthPage /></AdminGuard>} /> <Route path="/admin/system-health" element={<AdminGuard><SystemHealthPage /></AdminGuard>} />
<Route path="/admin/mail-config" element={<AdminGuard><MailConfigPage /></AdminGuard>} /> <Route path="/admin/mail-config" element={<AdminGuard><MailConfigPage /></AdminGuard>} />
<Route path="/admin/notify-config" element={<AdminGuard><NotifyConfigPage /></AdminGuard>} /> <Route path="/admin/notify-config" element={<AdminGuard><NotifyConfigPage /></AdminGuard>} />
<Route path="/admin/messages" element={<AdminGuard><MessageAdminPage /></AdminGuard>} />
<Route path="/admin/holidays" element={<AdminGuard><HolidayAdminPage /></AdminGuard>} /> <Route path="/admin/holidays" element={<AdminGuard><HolidayAdminPage /></AdminGuard>} />
</Route> </Route>
@ -292,7 +295,8 @@ export function App() {
</Route> </Route>
<Route path="/" element={<RootGate />} /> <Route path="/" element={<RootGate />} />
<Route path="*" element={<RootGate />} /> {/* 404 — 미매칭 경로(공개·인증 공통, 셸 비의존 전폭 카드 · 4.4) */}
<Route path="*" element={<NotFoundPage />} />
</Routes> </Routes>
); );
} }

View File

@ -0,0 +1,373 @@
/*
* KxDataTable (Nifty tables/tabulator · 1.3 / task #32).
*
* `.kx-table`(screens/shared.css) .
* (tabulator-tables ) ··i18n· .
*
* 기능: 컬럼 (asc/desc/)· ()·(10/25/50)·
* CSV (UTF-8 BOM · · CRLF · ).
*
* rows (/) .
* / toolbarStart (// ).
*
* :
* <KxDataTable
* columns={[{ key:'name', header:t('...'), value:(r)=>r.name },
* { key:'amount', header:t('...'), numeric:true, value:(r)=>r.amount,
* render:(r)=><span className="tnum">{r.amount.toLocaleString()}</span> }]}
* rows={rows} rowKey={(r)=>r.id} onRowClick={setSel} csvFileName="exhibitors" />
*/
import { useMemo, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { EmptyState } from './ui/States';
import { IconDownload, IconSearch, IconSort } from './ui/icons';
import './data-table.css';
export type KxCellValue = string | number | null | undefined;
export interface KxColumn<T> {
/** 안정 키(정렬·React key). */
key: string;
/** 헤더 라벨(이미 i18n 해소된 문자열). */
header: string;
/** 정렬·검색·CSV 에 쓰는 원시 값 접근자. 없으면 그 컬럼은 정렬/검색/CSV 제외. */
value?: (row: T) => KxCellValue;
/** 셀 렌더(미지정 시 value 문자열 표시). */
render?: (row: T) => ReactNode;
/** 숫자열 — 우정렬 tabular-nums(.kx-num) + 수치 비교 정렬. */
numeric?: boolean;
/** 정렬 가능 여부(기본: value 가 있으면 true). */
sortable?: boolean;
/** 검색 대상 여부(기본: value 가 있으면 true). */
searchable?: boolean;
/** CSV 포함 여부(기본 true). */
csv?: boolean;
/** 셀(td) 추가 클래스. */
tdClassName?: string;
/** 헤더(th) 추가 클래스. */
thClassName?: string;
}
type SortDir = 'asc' | 'desc';
interface SortState {
key: string;
dir: SortDir;
}
export interface KxDataTableProps<T> {
columns: KxColumn<T>[];
rows: T[];
rowKey: (row: T) => string;
onRowClick?: (row: T) => void;
isRowSelected?: (row: T) => boolean;
rowClassName?: (row: T) => string | undefined;
/** 제브라 스트라이프(기본 true). */
zebra?: boolean;
/** 검색 입력 노출(기본 true). */
searchable?: boolean;
searchPlaceholder?: string;
/** 페이지 크기 선택지(기본 [10,25,50]). */
pageSizeOptions?: number[];
initialPageSize?: number;
/** 지정 시 CSV 내보내기 버튼 노출 — 다운로드 파일명(확장자 제외). */
csvFileName?: string;
/** 툴바 좌측(외부 필터 등) · 우측 추가 슬롯. */
toolbarStart?: ReactNode;
toolbarEnd?: ReactNode;
ariaLabel?: string;
emptyTitle?: string;
emptyDescription?: string;
className?: string;
}
/** CSV 셀 이스케이프(쉼표·따옴표·줄바꿈 방어). */
function csvCell(v: KxCellValue): string {
const s = String(v ?? '');
return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
}
/** 값 → 검색 문자열(소문자). */
function searchText(v: KxCellValue): string {
return String(v ?? '').toLowerCase();
}
/** 두 셀 값 비교(numeric 이면 수치, 아니면 로케일 문자열). null/undefined 는 뒤로. */
function compare(a: KxCellValue, b: KxCellValue, numeric: boolean): number {
const an = a === null || a === undefined || a === '';
const bn = b === null || b === undefined || b === '';
if (an && bn) return 0;
if (an) return 1;
if (bn) return -1;
if (numeric) return Number(a) - Number(b);
return String(a).localeCompare(String(b), undefined, { numeric: true });
}
export function KxDataTable<T>({
columns,
rows,
rowKey,
onRowClick,
isRowSelected,
rowClassName,
zebra = true,
searchable = true,
searchPlaceholder,
pageSizeOptions = [10, 25, 50],
initialPageSize,
csvFileName,
toolbarStart,
toolbarEnd,
ariaLabel,
emptyTitle,
emptyDescription,
className = '',
}: KxDataTableProps<T>) {
const { t } = useTranslation();
const [query, setQuery] = useState('');
const [sort, setSort] = useState<SortState | null>(null);
const [pageSize, setPageSize] = useState(initialPageSize ?? pageSizeOptions[0] ?? 10);
const [page, setPage] = useState(0);
const colOf = (key: string) => columns.find((c) => c.key === key);
const canSort = (c: KxColumn<T>) => (c.sortable ?? !!c.value) && !!c.value;
const canSearch = (c: KxColumn<T>) => (c.searchable ?? !!c.value) && !!c.value;
// ── 필터(검색) ──
const filtered = useMemo(() => {
const kw = query.trim().toLowerCase();
if (!kw) return rows;
const searchCols = columns.filter(canSearch);
return rows.filter((r) =>
searchCols.some((c) => searchText(c.value!(r)).includes(kw)),
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rows, query, columns]);
// ── 정렬 ──
const sorted = useMemo(() => {
if (!sort) return filtered;
const col = colOf(sort.key);
if (!col?.value) return filtered;
const dir = sort.dir === 'asc' ? 1 : -1;
// 안정 정렬: 원본 인덱스 tie-break.
return filtered
.map((r, i) => [r, i] as const)
.sort((x, y) => {
const c = compare(col.value!(x[0]), col.value!(y[0]), !!col.numeric) * dir;
return c !== 0 ? c : x[1] - y[1];
})
.map(([r]) => r);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filtered, sort, columns]);
// ── 페이지네이션(page 는 렌더 시 유효 범위로 클램프) ──
const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize));
const safePage = Math.min(page, totalPages - 1);
const start = safePage * pageSize;
const pageRows = sorted.slice(start, start + pageSize);
const onSearch = (v: string) => {
setQuery(v);
setPage(0);
};
const onPageSize = (v: number) => {
setPageSize(v);
setPage(0);
};
const toggleSort = (key: string) => {
setPage(0);
setSort((prev) => {
if (!prev || prev.key !== key) return { key, dir: 'asc' };
if (prev.dir === 'asc') return { key, dir: 'desc' };
return null; // desc → 해제
});
};
const exportCsv = () => {
const csvCols = columns.filter((c) => c.value && c.csv !== false);
const header = csvCols.map((c) => csvCell(c.header)).join(',');
const lines = sorted.map((r) => csvCols.map((c) => csvCell(c.value!(r))).join(','));
// UTF-8 BOM() + CRLF — 한글 엑셀 호환.
const csv = '' + [header, ...lines].join('\r\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${csvFileName ?? 'export'}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const showToolbar = searchable || !!csvFileName || !!toolbarStart || !!toolbarEnd;
return (
<div className={`kx-dt ${className}`}>
{showToolbar && (
<div className="kx-dt__toolbar">
{toolbarStart}
{searchable && (
<label className="kx-dt__search">
<IconSearch size={16} className="kx-dt__search-ic" />
<input
className="kx-dt__search-input"
type="search"
value={query}
placeholder={searchPlaceholder ?? t('datatable.search')}
onChange={(e) => onSearch(e.target.value)}
aria-label={searchPlaceholder ?? t('datatable.searchAria')}
/>
</label>
)}
<div className="kx-dt__toolbar-end">
{toolbarEnd}
{csvFileName && (
<button
type="button"
className="kx-dt__csv"
onClick={exportCsv}
disabled={sorted.length === 0}
>
<IconDownload size={16} />
{t('datatable.csv')}
</button>
)}
</div>
</div>
)}
<div className="kx-table-scroll">
<table
className={`kx-table ${zebra ? 'kx-table--zebra' : ''}`}
aria-label={ariaLabel}
>
<thead>
<tr>
{columns.map((c) => {
const sortable = canSort(c);
const active = sort?.key === c.key;
const dir = active ? sort!.dir : undefined;
const ariaSort = active ? (dir === 'asc' ? 'ascending' : 'descending') : 'none';
return (
<th
key={c.key}
className={`${c.numeric ? 'kx-num' : ''} ${c.thClassName ?? ''}`}
aria-sort={sortable ? (ariaSort as 'ascending' | 'descending' | 'none') : undefined}
scope="col"
>
{sortable ? (
<button
type="button"
className={`kx-dt__sort-btn ${c.numeric ? 'kx-dt__sort-btn--num' : ''}`}
data-dir={dir ?? 'none'}
onClick={() => toggleSort(c.key)}
title={
dir === 'asc'
? t('datatable.sortDesc')
: dir === 'desc'
? t('datatable.sortClear')
: t('datatable.sortAsc')
}
>
<span className="kx-dt__sort-label">{c.header}</span>
<IconSort size={14} className="kx-dt__sort-ic" aria-hidden="true" />
</button>
) : (
c.header
)}
</th>
);
})}
</tr>
</thead>
<tbody>
{pageRows.map((r) => {
const selected = isRowSelected?.(r);
const extra = rowClassName?.(r) ?? '';
return (
<tr
key={rowKey(r)}
className={`${onRowClick ? 'kx-dt__row--click' : ''} ${selected ? 'is-selected' : ''} ${extra}`}
aria-selected={isRowSelected ? !!selected : undefined}
tabIndex={onRowClick ? 0 : undefined}
onClick={onRowClick ? () => onRowClick(r) : undefined}
onKeyDown={
onRowClick
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onRowClick(r);
}
}
: undefined
}
>
{columns.map((c) => (
<td key={c.key} className={`${c.numeric ? 'kx-num tnum' : ''} ${c.tdClassName ?? ''}`}>
{c.render ? c.render(r) : (c.value ? String(c.value(r) ?? '-') : null)}
</td>
))}
</tr>
);
})}
{pageRows.length === 0 && (
<tr>
<td colSpan={columns.length} className="kx-dt__empty-cell">
<EmptyState
title={emptyTitle ?? t('datatable.emptyTitle')}
description={emptyDescription ?? (query ? t('datatable.emptyFiltered') : undefined)}
/>
</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="kx-dt__footer">
<span className="kx-dt__count tnum">
{t('datatable.count', { shown: pageRows.length, total: sorted.length })}
</span>
<div className="kx-dt__pager">
<label className="kx-dt__pagesize">
<span className="kx-dt__pagesize-label">{t('datatable.pageSize')}</span>
<select
className="kx-select kx-dt__pagesize-select"
value={pageSize}
onChange={(e) => onPageSize(Number(e.target.value))}
aria-label={t('datatable.pageSize')}
>
{pageSizeOptions.map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
</label>
<div className="kx-dt__pagenav">
<button
type="button"
className="kx-dt__page-btn"
disabled={safePage <= 0}
onClick={() => setPage(Math.max(0, safePage - 1))}
>
{t('datatable.prev')}
</button>
<span className="kx-dt__pageinfo tnum">
{t('datatable.pageInfo', { page: safePage + 1, total: totalPages })}
</span>
<button
type="button"
className="kx-dt__page-btn"
disabled={safePage + 1 >= totalPages}
onClick={() => setPage(safePage + 1)}
>
{t('datatable.next')}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,229 @@
/*
* KxDataTable 스타일 Nifty tabulator 계보. 정본 `.kx-table`(screens/shared.css) 위에
* 툴바(검색·CSV·외부필터)·정렬 헤더 버튼·푸터(페이지 크기·페이저) 얹는다.
* ·간격·폰트는 kx 토큰만 사용(하드코딩 hex 금지) · 다크/라이트 테마 · SVG 아이콘.
*/
@import '../screens/shared.css';
.kx-dt {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
/* ── 툴바(검색 · 외부 필터 · CSV) ── */
.kx-dt__toolbar {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
}
.kx-dt__toolbar-end {
margin-left: auto;
display: flex;
align-items: center;
gap: var(--space-2);
}
.kx-dt__search {
position: relative;
display: inline-flex;
align-items: center;
min-width: 200px;
max-width: 320px;
flex: 1 1 220px;
}
.kx-dt__search-ic {
position: absolute;
left: 10px;
color: var(--color-neutral-500);
pointer-events: none;
}
.kx-dt__search-input {
width: 100%;
height: 36px;
padding: 0 var(--space-3) 0 34px;
border: var(--border-card);
border-radius: var(--radius-sm);
background: var(--color-white);
font-size: var(--fs-body);
color: var(--color-neutral-900);
}
.kx-dt__search-input::placeholder {
color: var(--color-neutral-500);
}
.kx-dt__search-input:focus-visible {
outline: none;
border-color: var(--color-primary-600);
box-shadow: 0 0 0 3px var(--color-primary-050);
}
/* CSV 내보내기 버튼(secondary 아웃라인 톤) */
.kx-dt__csv {
display: inline-flex;
align-items: center;
gap: 6px;
height: 36px;
padding: 0 var(--space-3);
border: var(--border-card);
border-radius: var(--radius-sm);
background: var(--color-white);
color: var(--color-neutral-700);
font-size: var(--fs-body);
font-weight: var(--fw-semibold);
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
white-space: nowrap;
}
.kx-dt__csv:hover:not(:disabled) {
background: var(--color-neutral-050);
border-color: var(--color-neutral-500);
color: var(--color-neutral-900);
}
.kx-dt__csv:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.kx-dt__csv:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--color-primary-050);
}
/* ── 정렬 헤더 버튼 ── */
.kx-dt__sort-btn {
display: inline-flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 0;
border: none;
background: transparent;
font: inherit;
letter-spacing: inherit;
color: inherit;
text-align: left;
cursor: pointer;
}
.kx-dt__sort-btn--num {
justify-content: flex-end;
}
.kx-dt__sort-btn:hover {
color: var(--color-primary-700);
}
.kx-dt__sort-btn:focus-visible {
outline: none;
color: var(--color-primary-700);
box-shadow: 0 0 0 3px var(--color-primary-050);
border-radius: var(--radius-sm);
}
.kx-dt__sort-ic {
flex: none;
color: var(--color-neutral-500);
}
/* 방향에 따라 활성 셰브런만 강조(기본은 둘 다 흐리게) */
.kx-dt__sort-btn .kx-sort-up,
.kx-dt__sort-btn .kx-sort-down {
opacity: 0.32;
transition: opacity 0.12s ease;
}
.kx-dt__sort-btn[data-dir='asc'] .kx-sort-up,
.kx-dt__sort-btn[data-dir='desc'] .kx-sort-down {
opacity: 1;
}
.kx-dt__sort-btn[data-dir='asc'] .kx-dt__sort-ic,
.kx-dt__sort-btn[data-dir='desc'] .kx-dt__sort-ic {
color: var(--color-primary-600);
}
/* 클릭 가능 행 */
.kx-dt__row--click {
cursor: pointer;
}
.kx-table tbody tr.is-selected {
background: var(--color-primary-100);
}
.kx-dt__empty-cell {
padding: 0 !important;
}
.kx-dt__empty-cell:hover {
background: transparent;
}
/* ── 푸터(건수 · 페이지 크기 · 페이저) ── */
.kx-dt__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
flex-wrap: wrap;
}
.kx-dt__count {
font-size: var(--fs-caption);
color: var(--color-neutral-500);
}
.kx-dt__pager {
display: flex;
align-items: center;
gap: var(--space-4);
flex-wrap: wrap;
}
.kx-dt__pagesize {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.kx-dt__pagesize-label {
font-size: var(--fs-caption);
color: var(--color-neutral-500);
}
.kx-dt__pagesize-select {
height: 32px;
padding: 0 var(--space-2);
}
.kx-dt__pagenav {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.kx-dt__page-btn {
height: 32px;
padding: 0 var(--space-3);
border: var(--border-card);
border-radius: var(--radius-sm);
background: var(--color-white);
color: var(--color-neutral-700);
font-size: var(--fs-caption);
font-weight: var(--fw-semibold);
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease;
}
.kx-dt__page-btn:hover:not(:disabled) {
background: var(--color-neutral-050);
border-color: var(--color-neutral-500);
}
.kx-dt__page-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.kx-dt__page-btn:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--color-primary-050);
}
.kx-dt__pageinfo {
font-size: var(--fs-caption);
color: var(--color-neutral-700);
min-width: 96px;
text-align: center;
}
@media (max-width: 640px) {
.kx-dt__toolbar-end {
margin-left: 0;
}
.kx-dt__search {
max-width: none;
}
.kx-dt__footer {
justify-content: flex-start;
}
}

View File

@ -9,6 +9,7 @@ import { useMdiStore } from '../../store/mdiStore';
import { useUiStore, isMobileViewport } from '../../store/uiStore'; import { useUiStore, isMobileViewport } from '../../store/uiStore';
import { useIsAdmin } from '../../screens/admin/AdminGuard'; import { useIsAdmin } from '../../screens/admin/AdminGuard';
import { MdiTabBar } from './MdiTabBar'; import { MdiTabBar } from './MdiTabBar';
import { Breadcrumb } from './Breadcrumb';
import { ShellFooter } from './ShellFooter'; import { ShellFooter } from './ShellFooter';
import { SettingsCustomizer } from './SettingsCustomizer'; import { SettingsCustomizer } from './SettingsCustomizer';
import { labelForPath } from './mdiLabels'; import { labelForPath } from './mdiLabels';
@ -197,6 +198,7 @@ const GROUPS: NavGroupDef[] = [
{ key: 'admin-settings', label: '시스템설정', labelKey: 'shell.menu.items.settings', Icon: IconSettings, to: '/admin/settings' }, { key: 'admin-settings', label: '시스템설정', labelKey: 'shell.menu.items.settings', Icon: IconSettings, to: '/admin/settings' },
{ key: 'admin-mail-config', label: '메일 설정', labelKey: 'shell.menu.items.mailConfig', Icon: IconBell, to: '/admin/mail-config' }, { key: 'admin-mail-config', label: '메일 설정', labelKey: 'shell.menu.items.mailConfig', Icon: IconBell, to: '/admin/mail-config' },
{ key: 'admin-notify-config', label: '알림 설정', labelKey: 'shell.menu.items.notifyConfig', Icon: IconBell, to: '/admin/notify-config' }, { key: 'admin-notify-config', label: '알림 설정', labelKey: 'shell.menu.items.notifyConfig', Icon: IconBell, to: '/admin/notify-config' },
{ key: 'admin-messages', label: '메시지 관리', labelKey: 'shell.menu.items.msgTemplates', Icon: IconDocument, to: '/admin/messages' },
// 마스터·기타 // 마스터·기타
{ key: 'sec-masteretc', label: '마스터·기타', labelKey: 'shell.menu.sections.masterEtc', section: true }, { key: 'sec-masteretc', label: '마스터·기타', labelKey: 'shell.menu.sections.masterEtc', section: true },
{ key: 'admin-tenants', label: '테넌트', labelKey: 'shell.menu.items.tenants', Icon: IconExhibitors, to: '/admin/tenants' }, { key: 'admin-tenants', label: '테넌트', labelKey: 'shell.menu.items.tenants', Icon: IconExhibitors, to: '/admin/tenants' },
@ -503,6 +505,9 @@ export function AppShell() {
<MdiTabBar /> <MdiTabBar />
{/* 브레드크럼 — 콘텐츠 영역 상단(홈>섹션>현재, 조각 클릭 이동 · 4.4). */}
<Breadcrumb />
<main className="kx-shell__content" id="kx-main-content"> <main className="kx-shell__content" id="kx-main-content">
<Outlet /> <Outlet />
</main> </main>

View File

@ -0,0 +1,54 @@
/*
* ( 4.4) .
* `홈 > 섹션 > 현재` (· /) .
* AppShell MdiTabBar content ( ) .
* / ( ). 4 i18n.
*/
import { useLocation, NavLink } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { IconChevronRight } from '../ui/icons';
import { crumbTrail, type Crumb } from './breadcrumbConfig';
import './breadcrumb.css';
export function Breadcrumb() {
const { pathname } = useLocation();
const { t } = useTranslation();
// 홈/루트는 브레드크럼 불필요.
if (pathname === '/home' || pathname === '/') return null;
const trail = crumbTrail(pathname);
const crumbs: Crumb[] = [{ labelKey: 'shell.menu.home', to: '/home' }, ...trail];
const label = (c: Crumb) =>
c.labelKey ? t(c.labelKey, { defaultValue: c.label ?? '' }) : c.label ?? '';
return (
<nav className="kx-breadcrumb" aria-label={t('breadcrumb.aria')}>
<ol className="kx-breadcrumb__list">
{crumbs.map((c, i) => {
const last = i === crumbs.length - 1;
return (
<li key={`${c.labelKey ?? c.label ?? i}-${i}`} className="kx-breadcrumb__item">
{i > 0 && (
<IconChevronRight size={14} className="kx-breadcrumb__sep" aria-hidden="true" />
)}
{c.to && !last ? (
<NavLink to={c.to} className="kx-breadcrumb__link">
{label(c)}
</NavLink>
) : (
<span
className={`kx-breadcrumb__cur ${last ? 'is-current' : ''}`}
aria-current={last ? 'page' : undefined}
>
{label(c)}
</span>
)}
</li>
);
})}
</ol>
</nav>
);
}

View File

@ -0,0 +1,60 @@
/*
* 브레드크럼 콘텐츠 영역 상단(MdiTabBar content 사이). kx 토큰만 · SVG 구분자.
* 컬럼 레이아웃의 flex-none 바로 자리한다(기존 구조 불변).
*/
.kx-breadcrumb {
flex-shrink: 0;
padding: var(--space-2) var(--gutter, var(--space-5));
background: var(--color-neutral-050);
border-bottom: var(--border-card);
}
.kx-breadcrumb__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 2px;
font-size: var(--fs-caption);
line-height: var(--lh-caption);
}
.kx-breadcrumb__item {
display: inline-flex;
align-items: center;
gap: 2px;
min-width: 0;
}
.kx-breadcrumb__sep {
color: var(--color-neutral-200);
flex: none;
margin: 0 2px;
}
.kx-breadcrumb__link {
color: var(--color-neutral-500);
text-decoration: none;
padding: 2px 4px;
border-radius: var(--radius-sm);
white-space: nowrap;
transition: color 0.12s ease, background 0.12s ease;
}
.kx-breadcrumb__link:hover {
color: var(--color-primary-700);
text-decoration: underline;
}
.kx-breadcrumb__link:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--color-primary-050);
}
.kx-breadcrumb__cur {
color: var(--color-neutral-500);
padding: 2px 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 320px;
}
.kx-breadcrumb__cur.is-current {
color: var(--color-neutral-900);
font-weight: var(--fw-semibold);
}

View File

@ -0,0 +1,125 @@
/*
* (Home ; Breadcrumb Home ).
*
* IA(AppShell GROUPS) + / .
* i18n (shell.menu.groups.* · shell.menu.items.* · breadcrumb.*) ,
* label (ko) . ,
* / to (/ ).
*
* mdiLabels import (AppShell Breadcrumb config AppShell) .
*/
import { labelForPath } from './mdiLabels';
export interface Crumb {
/** i18n 키(우선). */
labelKey?: string;
/** 리터럴 라벨(i18n 미해소 폴백). */
label?: string;
/** 클릭 이동 경로(없으면 비링크 텍스트 — 그룹 섹션 등). */
to?: string;
}
/** 그룹(대분류) 크럼 — 라우트 없음(아코디언 섹션). */
const G = (g: string): Crumb => ({ labelKey: `shell.menu.groups.${g}` });
/** 메뉴 항목 크럼 — to 지정 시 링크. */
const I = (i: string, to?: string): Crumb => ({ labelKey: `shell.menu.items.${i}`, to });
/** breadcrumb.* 전용 라벨(상세 화면 등 메뉴에 없는 라벨). */
const B = (k: string): Crumb => ({ labelKey: `breadcrumb.${k}` });
/** 정적(정확 일치) 경로 → 트레일. */
const STATIC: Record<string, Crumb[]> = {
// 전시 운영
'/exhibitors': [G('ops'), I('exhibitors')],
'/schedule': [G('ops'), I('schedule')],
'/halls': [G('ops'), I('halls')],
'/booth-sales': [G('ops'), I('boothSales')],
'/ops/operations': [G('ops'), I('operations')],
// 설계·시공
'/docs': [G('design'), I('documents')],
'/docs/authoring': [G('design'), I('documents', '/docs'), B('docsAuthoring')],
'/auctions': [G('design'), I('auctions')],
'/contractor/dashboard': [G('design'), I('contractor')],
'/logistics': [G('design'), I('logistics')],
// 관람객·마케팅
'/visitors': [G('visitor'), I('visitors')],
'/visitors/checkin': [G('visitor'), I('visitors', '/visitors'), I('checkin')],
'/leads': [G('visitor'), I('leads')],
'/campaigns': [G('visitor'), I('campaigns')],
'/sponsorship': [G('visitor'), I('sponsorship')],
'/cms': [G('visitor'), I('cms')],
'/cms/microsite': [G('visitor'), I('cms', '/cms'), B('micrositeBuilder')],
'/cms/i18n': [G('visitor'), I('cms', '/cms'), B('i18nCms')],
// 정산·분석
'/settlement': [G('finance'), I('settlement')],
'/analytics': [G('finance'), I('analytics')],
// 업무 공통
'/work/worklog': [G('work'), I('worklog')],
'/work/schedule': [G('work'), I('workSchedule')],
'/work/message': [G('work'), I('message')],
'/work/notice': [G('work'), I('notice')],
'/work/meeting': [G('work'), I('meeting')],
'/work/report': [G('work'), I('report')],
'/work/approval': [G('work'), I('approval')],
'/work/opinion': [G('work'), I('opinion')],
'/work/search': [G('work'), I('search')],
'/app-qr': [G('work'), I('appqr')],
// 개인·알림
'/notifications': [B('notifications')],
'/me': [B('me')],
// 시스템관리 (상위 링크 = 관리 대시보드 /admin)
'/admin': [G('system'), I('admin')],
'/admin/login-slides': [G('system'), I('admin', '/admin'), I('slides')],
'/admin/audit': [G('system'), I('admin', '/admin'), I('audit')],
'/admin/settings': [G('system'), I('admin', '/admin'), I('settings')],
'/admin/masterdata/rulesets': [G('system'), I('admin', '/admin'), I('rulesets')],
'/admin/tenants': [G('system'), I('admin', '/admin'), I('tenants')],
'/admin/users': [G('system'), I('admin', '/admin'), I('users')],
'/admin/roles': [G('system'), I('admin', '/admin'), I('roles')],
'/admin/codes': [G('system'), I('admin', '/admin'), I('codes')],
'/admin/menus': [G('system'), I('admin', '/admin'), I('menus')],
'/admin/depts': [G('system'), I('admin', '/admin'), I('depts')],
'/admin/companies': [G('system'), I('admin', '/admin'), I('companies')],
'/admin/programs': [G('system'), I('admin', '/admin'), I('programs')],
'/admin/role-menus': [G('system'), I('admin', '/admin'), I('roleMenus')],
'/admin/auth-policy': [G('system'), I('admin', '/admin'), I('authPolicy')],
'/admin/login-history': [G('system'), I('admin', '/admin'), I('loginHistory')],
'/admin/error-log': [G('system'), I('admin', '/admin'), I('errorLog')],
'/admin/system-health': [G('system'), I('admin', '/admin'), I('systemHealth')],
'/admin/mail-config': [G('system'), I('admin', '/admin'), I('mailConfig')],
'/admin/notify-config': [G('system'), I('admin', '/admin'), I('notifyConfig')],
'/admin/messages': [G('system'), I('admin', '/admin'), I('msgTemplates')],
'/admin/holidays': [G('system'), I('admin', '/admin'), I('holidays')],
};
/** 동적 경로 패턴 → 트레일(구체적 규칙 우선). */
const PATTERNS: { re: RegExp; trail: Crumb[] }[] = [
{ re: /^\/auctions\/[^/]+\/award$/, trail: [G('design'), I('auctions', '/auctions'), B('auctionAward')] },
{ re: /^\/auctions\/[^/]+$/, trail: [G('design'), I('auctions', '/auctions'), B('auctionDetail')] },
{ re: /^\/events\/[^/]+\/dashboard$/, trail: [G('ops'), I('dashboard')] },
{ re: /^\/events\/[^/]+\/booths\/[^/]+\/home$/, trail: [B('exhibitorHome')] },
{ re: /^\/events\/[^/]+\/halls\/[^/]+\/layout$/, trail: [G('design'), I('floorplan')] },
{ re: /^\/events\/[^/]+\/halls\/[^/]+\/comparison$/, trail: [G('design'), B('comparison')] },
{ re: /^\/events\/[^/]+\/booths\/[^/]+\/design$/, trail: [G('design'), B('design')] },
{ re: /^\/events\/[^/]+\/booths\/[^/]+\/utility\/order$/, trail: [G('design'), B('utilityOrder')] },
{ re: /^\/events\/[^/]+\/booths\/[^/]+\/utility$/, trail: [G('design'), B('utility')] },
{ re: /^\/events\/[^/]+\/booths\/[^/]+\/compliance$/, trail: [G('design'), B('compliance')] },
{ re: /^\/events\/[^/]+\/halls\/[^/]+\/approvals$/, trail: [G('ops'), B('approvals')] },
{ re: /^\/events\/[^/]+\/halls\/[^/]+\/booths\/[^/]+\/review$/, trail: [G('ops'), B('reviewDetail')] },
{ re: /^\/events\/[^/]+\/booths\/[^/]+\/gallery$/, trail: [G('design'), B('gallery')] },
];
/** 라우트 → 크럼 트레일(Home 제외). 미등록 경로는 라우트 라벨 1개로 폴백. */
export function crumbTrail(pathname: string): Crumb[] {
const clean = pathname.split('?')[0].split('#')[0];
if (STATIC[clean]) return STATIC[clean];
for (const { re, trail } of PATTERNS) {
if (re.test(clean)) return trail;
}
return [{ label: labelForPath(clean) }];
}

View File

@ -55,6 +55,7 @@ const STATIC_LABELS: Record<string, string> = {
'/admin/roles': '역할 관리', '/admin/roles': '역할 관리',
'/admin/codes': '공통 코드', '/admin/codes': '공통 코드',
'/admin/menus': '메뉴 관리', '/admin/menus': '메뉴 관리',
'/admin/messages': '메시지 관리',
}; };
/** 동적 경로 패턴(정규식) → 라벨. 구체적 규칙을 먼저 둔다. */ /** 동적 경로 패턴(정규식) → 라벨. 구체적 규칙을 먼저 둔다. */

View File

@ -304,6 +304,16 @@ export function IconChevronDown(p: IconProps) {
); );
} }
/** ⇅ 정렬(위/아래 셰브런) — 데이터테이블 헤더. 활성 방향은 CSS 로 강조(.kx-sort-up/.kx-sort-down). */
export function IconSort(p: IconProps) {
return (
<Svg {...p}>
<path className="kx-sort-up" d="M8 10L12 6L16 10" {...SRJ} />
<path className="kx-sort-down" d="M8 14L12 18L16 14" {...SRJ} />
</Svg>
);
}
/** 좌우 화살촉(비교 슬라이더 그립). */ /** 좌우 화살촉(비교 슬라이더 그립). */
export function IconCompareArrows(p: IconProps) { export function IconCompareArrows(p: IconProps) {
return ( return (

View File

@ -90,7 +90,8 @@
"notifyConfig": "Notify Config", "notifyConfig": "Notify Config",
"tenants": "Tenants", "tenants": "Tenants",
"rulesets": "Rulesets", "rulesets": "Rulesets",
"slides": "Login Slides" "slides": "Login Slides",
"msgTemplates": "Messages"
}, },
"sections": { "sections": {
"userOrg": "Users & Org", "userOrg": "Users & Org",
@ -1320,7 +1321,8 @@
"modAudit": "Audit log", "modAudit": "Audit log",
"modSettings": "System settings", "modSettings": "System settings",
"modRulesets": "Rulesets", "modRulesets": "Rulesets",
"modTenants": "Tenants" "modTenants": "Tenants",
"modMessages": "Messages"
}, },
"tenant": { "tenant": {
"title": "Tenant Management · Onboarding", "title": "Tenant Management · Onboarding",
@ -2701,5 +2703,86 @@
"corner": "Corner", "corner": "Corner",
"island": "Island" "island": "Island"
} }
},
"datatable": {
"search": "Search",
"searchAria": "Search table",
"csv": "Export CSV",
"prev": "Prev",
"next": "Next",
"pageSize": "Rows",
"pageInfo": "{{page}} / {{total}}",
"count": "Showing {{shown}} · {{total}} total",
"emptyTitle": "No data to display",
"emptyFiltered": "No results match your search",
"sortAsc": "Sort ascending",
"sortDesc": "Sort descending",
"sortClear": "Clear sort"
},
"breadcrumb": {
"aria": "Breadcrumb",
"docsAuthoring": "Report Authoring",
"micrositeBuilder": "Microsite Builder",
"i18nCms": "Multilingual CMS",
"notifications": "Notifications",
"me": "My Page",
"auctionDetail": "Auction Detail",
"auctionAward": "Award Comparison",
"exhibitorHome": "Exhibitor Home",
"comparison": "AI Layout Comparison",
"design": "Booth Design Studio",
"utility": "Utility Wiring",
"utilityOrder": "Utility Order",
"compliance": "Compliance Check",
"approvals": "Approval Queue",
"reviewDetail": "Review Detail",
"gallery": "Visualization Gallery"
},
"notFound": {
"title": "Page not found",
"desc": "The page you requested does not exist or has moved. Please check the address.",
"home": "Go home",
"back": "Go back"
},
"msg": {
"title": "Message Templates",
"subtitle": "Manage notification, email and notice texts as code/locale templates with {key} placeholders",
"add": "Add template",
"category": "Category",
"allCategories": "All categories",
"locale": "Locale",
"allLocales": "All locales",
"search": "Search",
"searchPlaceholder": "Search code, title, template",
"filterAria": "Message filters",
"loadError": "Failed to load messages",
"emptyTitle": "No messages",
"emptyDesc": "No templates match the filters. Try adding one.",
"code": "Code",
"msgTitle": "Title",
"placeholders": "Placeholders",
"use": "Use",
"manage": "Manage",
"used": "In use",
"unused": "Unused",
"preview": "Preview",
"edit": "Edit",
"delete": "Delete",
"deleteConfirm": "Delete template {{code}} ({{locale}})?",
"saved": "Saved",
"deleted": "Deleted",
"errRequired": "Code, title and template are required",
"useYn": "Use Y/N",
"template": "Template",
"templatePlaceholder": "Use {key} placeholders, e.g. {name}, {eventName}",
"detectedParams": "Detected parameters",
"cancel": "Cancel",
"save": "Save",
"saving": "Saving…",
"close": "Close",
"render": "Render",
"rendering": "Rendering…",
"renderResult": "Result",
"noParams": "This template has no parameters"
} }
} }

View File

@ -90,7 +90,8 @@
"notifyConfig": "通知設定", "notifyConfig": "通知設定",
"tenants": "テナント", "tenants": "テナント",
"rulesets": "ルールセット", "rulesets": "ルールセット",
"slides": "ログインスライド" "slides": "ログインスライド",
"msgTemplates": "メッセージ管理"
}, },
"sections": { "sections": {
"userOrg": "ユーザー・組織", "userOrg": "ユーザー・組織",
@ -1320,7 +1321,8 @@
"modAudit": "監査ログ", "modAudit": "監査ログ",
"modSettings": "システム設定", "modSettings": "システム設定",
"modRulesets": "ルールセット", "modRulesets": "ルールセット",
"modTenants": "テナント" "modTenants": "テナント",
"modMessages": "メッセージ管理"
}, },
"tenant": { "tenant": {
"title": "テナント管理·オンボーディング", "title": "テナント管理·オンボーディング",
@ -2701,5 +2703,86 @@
"corner": "コーナーブース", "corner": "コーナーブース",
"island": "アイランドブース" "island": "アイランドブース"
} }
},
"datatable": {
"search": "検索",
"searchAria": "表を検索",
"csv": "CSV エクスポート",
"prev": "前へ",
"next": "次へ",
"pageSize": "表示件数",
"pageInfo": "{{page}} / {{total}} ページ",
"count": "{{shown}} 件表示 · 全 {{total}} 件",
"emptyTitle": "表示するデータがありません",
"emptyFiltered": "検索条件に一致する結果がありません",
"sortAsc": "昇順で並べ替え",
"sortDesc": "降順で並べ替え",
"sortClear": "並べ替え解除"
},
"breadcrumb": {
"aria": "パンくず",
"docsAuthoring": "申請書類作成",
"micrositeBuilder": "マイクロサイトビルダー",
"i18nCms": "多言語 CMS",
"notifications": "通知センター",
"me": "マイページ",
"auctionDetail": "オークション詳細",
"auctionAward": "落札比較",
"exhibitorHome": "出展社ホーム",
"comparison": "AI レイアウト比較",
"design": "ブース設計スタジオ",
"utility": "配線ビュー",
"utilityOrder": "設備申請",
"compliance": "規定チェック",
"approvals": "審査キュー",
"reviewDetail": "審査詳細",
"gallery": "ビジュアライゼーションギャラリー"
},
"notFound": {
"title": "ページが見つかりません",
"desc": "リクエストされたページは存在しないか、移動されました。アドレスをご確認ください。",
"home": "ホームへ",
"back": "前のページ"
},
"msg": {
"title": "メッセージ管理",
"subtitle": "通知・メール・お知らせ文言をコード・ロケール別テンプレートで管理します({key} プレースホルダー置換)",
"add": "テンプレート追加",
"category": "分類",
"allCategories": "すべての分類",
"locale": "ロケール",
"allLocales": "すべてのロケール",
"search": "検索",
"searchPlaceholder": "コード・タイトル・本文を検索",
"filterAria": "メッセージフィルター",
"loadError": "メッセージ一覧を読み込めませんでした",
"emptyTitle": "メッセージがありません",
"emptyDesc": "条件に合うテンプレートがありません。新規追加してください。",
"code": "コード",
"msgTitle": "タイトル",
"placeholders": "プレースホルダー",
"use": "使用",
"manage": "管理",
"used": "使用",
"unused": "未使用",
"preview": "プレビュー",
"edit": "編集",
"delete": "削除",
"deleteConfirm": "テンプレート {{code}}{{locale}})を削除しますか?",
"saved": "保存しました",
"deleted": "削除しました",
"errRequired": "コード・タイトル・本文は必須です",
"useYn": "使用可否",
"template": "本文テンプレート",
"templatePlaceholder": "{name}・{eventName} など {key} プレースホルダーを使用します",
"detectedParams": "検出されたパラメータ",
"cancel": "キャンセル",
"save": "保存",
"saving": "保存中…",
"close": "閉じる",
"render": "レンダリング",
"rendering": "レンダリング中…",
"renderResult": "レンダリング結果",
"noParams": "置換パラメータのないテンプレートです"
} }
} }

View File

@ -90,7 +90,8 @@
"notifyConfig": "알림 설정", "notifyConfig": "알림 설정",
"tenants": "테넌트", "tenants": "테넌트",
"rulesets": "룰셋", "rulesets": "룰셋",
"slides": "로그인 슬라이드" "slides": "로그인 슬라이드",
"msgTemplates": "메시지 관리"
}, },
"sections": { "sections": {
"userOrg": "사용자·조직", "userOrg": "사용자·조직",
@ -1320,7 +1321,8 @@
"modAudit": "감사로그", "modAudit": "감사로그",
"modSettings": "시스템설정", "modSettings": "시스템설정",
"modRulesets": "룰셋", "modRulesets": "룰셋",
"modTenants": "테넌트" "modTenants": "테넌트",
"modMessages": "메시지 관리"
}, },
"tenant": { "tenant": {
"title": "테넌트 관리·온보딩", "title": "테넌트 관리·온보딩",
@ -2701,5 +2703,86 @@
"corner": "코너부스", "corner": "코너부스",
"island": "아일랜드부스" "island": "아일랜드부스"
} }
},
"datatable": {
"search": "검색",
"searchAria": "표 검색",
"csv": "CSV 내보내기",
"prev": "이전",
"next": "다음",
"pageSize": "표시 개수",
"pageInfo": "{{page}} / {{total}} 페이지",
"count": "{{shown}}건 표시 · 총 {{total}}건",
"emptyTitle": "표시할 데이터가 없습니다",
"emptyFiltered": "검색 조건에 맞는 결과가 없습니다",
"sortAsc": "오름차순 정렬",
"sortDesc": "내림차순 정렬",
"sortClear": "정렬 해제"
},
"breadcrumb": {
"aria": "경로",
"docsAuthoring": "신고서류 작성",
"micrositeBuilder": "마이크로사이트 빌더",
"i18nCms": "다국어 CMS",
"notifications": "알림센터",
"me": "마이페이지",
"auctionDetail": "옥션 상세",
"auctionAward": "낙찰 비교",
"exhibitorHome": "참가업체 홈",
"comparison": "AI 배치안 비교",
"design": "부스 설계 스튜디오",
"utility": "유틸리티 배선",
"utilityOrder": "유틸리티 신청",
"compliance": "규정 검증",
"approvals": "검수 대기열",
"reviewDetail": "검수 상세",
"gallery": "시각화 갤러리"
},
"notFound": {
"title": "페이지를 찾을 수 없습니다",
"desc": "요청하신 페이지가 존재하지 않거나 이동되었습니다. 주소를 확인해 주세요.",
"home": "홈으로",
"back": "이전 페이지"
},
"msg": {
"title": "메시지 관리",
"subtitle": "알림·이메일·통지 문구를 코드·로케일별 템플릿으로 관리합니다 ({key} 플레이스홀더 치환)",
"add": "템플릿 추가",
"category": "분류",
"allCategories": "전체 분류",
"locale": "로케일",
"allLocales": "전체 로케일",
"search": "검색",
"searchPlaceholder": "코드·제목·본문 검색",
"filterAria": "메시지 필터",
"loadError": "메시지 목록을 불러오지 못했습니다",
"emptyTitle": "메시지가 없습니다",
"emptyDesc": "조건에 맞는 템플릿이 없습니다. 새 템플릿을 추가해 보세요.",
"code": "코드",
"msgTitle": "제목",
"placeholders": "플레이스홀더",
"use": "사용",
"manage": "관리",
"used": "사용",
"unused": "미사용",
"preview": "미리보기",
"edit": "수정",
"delete": "삭제",
"deleteConfirm": "{{code}} ({{locale}}) 템플릿을 삭제할까요?",
"saved": "저장되었습니다",
"deleted": "삭제되었습니다",
"errRequired": "코드·제목·본문은 필수입니다",
"useYn": "사용 여부",
"template": "본문 템플릿",
"templatePlaceholder": "{name}·{eventName} 형식의 {key} 플레이스홀더를 사용하세요",
"detectedParams": "감지된 파라미터",
"cancel": "취소",
"save": "저장",
"saving": "저장 중…",
"close": "닫기",
"render": "렌더",
"rendering": "렌더 중…",
"renderResult": "렌더 결과",
"noParams": "치환할 파라미터가 없는 템플릿입니다"
} }
} }

View File

@ -90,7 +90,8 @@
"notifyConfig": "通知设置", "notifyConfig": "通知设置",
"tenants": "租户", "tenants": "租户",
"rulesets": "规则集", "rulesets": "规则集",
"slides": "登录幻灯片" "slides": "登录幻灯片",
"msgTemplates": "消息管理"
}, },
"sections": { "sections": {
"userOrg": "用户·组织", "userOrg": "用户·组织",
@ -1320,7 +1321,8 @@
"modAudit": "审计日志", "modAudit": "审计日志",
"modSettings": "系统设置", "modSettings": "系统设置",
"modRulesets": "规则集", "modRulesets": "规则集",
"modTenants": "租户" "modTenants": "租户",
"modMessages": "消息管理"
}, },
"tenant": { "tenant": {
"title": "租户管理·上线", "title": "租户管理·上线",
@ -2701,5 +2703,86 @@
"corner": "转角展位", "corner": "转角展位",
"island": "岛式展位" "island": "岛式展位"
} }
},
"datatable": {
"search": "搜索",
"searchAria": "搜索表格",
"csv": "导出 CSV",
"prev": "上一页",
"next": "下一页",
"pageSize": "每页",
"pageInfo": "{{page}} / {{total}} 页",
"count": "显示 {{shown}} 条 · 共 {{total}} 条",
"emptyTitle": "暂无数据",
"emptyFiltered": "没有符合条件的结果",
"sortAsc": "升序排序",
"sortDesc": "降序排序",
"sortClear": "取消排序"
},
"breadcrumb": {
"aria": "路径",
"docsAuthoring": "申报文件撰写",
"micrositeBuilder": "微网站构建器",
"i18nCms": "多语言 CMS",
"notifications": "通知中心",
"me": "我的页面",
"auctionDetail": "竞价详情",
"auctionAward": "中标比较",
"exhibitorHome": "参展商主页",
"comparison": "AI 布局比较",
"design": "展位设计工作室",
"utility": "配线视图",
"utilityOrder": "配套申请",
"compliance": "合规检查",
"approvals": "审核队列",
"reviewDetail": "审核详情",
"gallery": "可视化画廊"
},
"notFound": {
"title": "找不到页面",
"desc": "您请求的页面不存在或已移动,请检查网址。",
"home": "返回首页",
"back": "返回上一页"
},
"msg": {
"title": "消息管理",
"subtitle": "以代码·语言模板管理通知、邮件与告知文案({key} 占位符替换)",
"add": "新增模板",
"category": "分类",
"allCategories": "全部分类",
"locale": "语言",
"allLocales": "全部语言",
"search": "搜索",
"searchPlaceholder": "搜索代码·标题·内容",
"filterAria": "消息筛选",
"loadError": "无法加载消息列表",
"emptyTitle": "暂无消息",
"emptyDesc": "没有符合条件的模板,请新增。",
"code": "代码",
"msgTitle": "标题",
"placeholders": "占位符",
"use": "使用",
"manage": "管理",
"used": "使用",
"unused": "停用",
"preview": "预览",
"edit": "编辑",
"delete": "删除",
"deleteConfirm": "要删除模板 {{code}}{{locale}})吗?",
"saved": "已保存",
"deleted": "已删除",
"errRequired": "代码、标题、内容为必填",
"useYn": "是否使用",
"template": "内容模板",
"templatePlaceholder": "使用 {key} 占位符,如 {name}、{eventName}",
"detectedParams": "检测到的参数",
"cancel": "取消",
"save": "保存",
"saving": "保存中…",
"close": "关闭",
"render": "渲染",
"rendering": "渲染中…",
"renderResult": "渲染结果",
"noParams": "此模板没有可替换参数"
} }
} }

View File

@ -219,6 +219,7 @@ const ADMIN_MODULES: [string, string][] = [
['admin.modRoles', '/admin/roles'], ['admin.modRoles', '/admin/roles'],
['admin.modCodes', '/admin/codes'], ['admin.modCodes', '/admin/codes'],
['admin.modMenus', '/admin/menus'], ['admin.modMenus', '/admin/menus'],
['admin.modMessages', '/admin/messages'],
['admin.modAudit', '/admin/audit'], ['admin.modAudit', '/admin/audit'],
['admin.modSettings', '/admin/settings'], ['admin.modSettings', '/admin/settings'],
['admin.modRulesets', '/admin/masterdata/rulesets'], ['admin.modRulesets', '/admin/masterdata/rulesets'],

View File

@ -0,0 +1,373 @@
/*
* M18 '메시지 관리' (#35 · 6.6). MessageController .
* UI: 분류/ + 릿 (upsert) · ({key} ).
* 컨벤션: CommonCodeAdminPage (kx-adm · react-query · admin-modules.css , CSS ).
*/
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
import { Button } from '../../components/ui/Button';
import { IconPlus } from '../../components/ui/icons';
import { errMessage, useToast } from '../work/workShared';
import {
MESSAGE_CATEGORIES,
MESSAGE_LOCALES,
extractPlaceholders,
messageApi,
type SysMessage,
} from './messageAdminApi';
import './admin-modules.css';
export function MessageAdminPage() {
const { t } = useTranslation();
const qc = useQueryClient();
const { show, node: toast } = useToast();
const [category, setCategory] = useState('');
const [locale, setLocale] = useState('');
const [keyword, setKeyword] = useState('');
const [editModal, setEditModal] = useState<SysMessage | null | undefined>(undefined);
const [previewModal, setPreviewModal] = useState<SysMessage | undefined>(undefined);
const listQ = useQuery({
queryKey: ['admin-messages', category, locale],
queryFn: () => messageApi.list({ category: category || undefined, locale: locale || undefined }),
retry: false,
});
const saveM = useMutation({
mutationFn: (m: SysMessage) => messageApi.save(m),
onSuccess: () => {
show(t('msg.saved'));
setEditModal(undefined);
qc.invalidateQueries({ queryKey: ['admin-messages'] });
},
onError: (e) => show(errMessage(e)),
});
const delM = useMutation({
mutationFn: ({ code, loc }: { code: string; loc: string | null }) =>
messageApi.remove(code, loc ?? undefined),
onSuccess: () => {
show(t('msg.deleted'));
qc.invalidateQueries({ queryKey: ['admin-messages'] });
},
onError: (e) => show(errMessage(e)),
});
const rows = listQ.data ?? [];
const categories = useMemo(() => {
const merged = new Set<string>(MESSAGE_CATEGORIES);
rows.forEach((r) => merged.add(r.category));
return Array.from(merged);
}, [rows]);
const filtered = useMemo(() => {
const kw = keyword.trim().toLowerCase();
if (!kw) return rows;
return rows.filter(
(r) =>
r.msgCode.toLowerCase().includes(kw) ||
r.title.toLowerCase().includes(kw) ||
r.template.toLowerCase().includes(kw),
);
}, [rows, keyword]);
return (
<div className="kx-page kx-adm">
<header className="kx-adm__head">
<div>
<h1 className="kx-adm__title">{t('msg.title')}</h1>
<p className="kx-adm__subtitle">{t('msg.subtitle')}</p>
</div>
<Button leadingIcon={<IconPlus size={16} />} onClick={() => setEditModal(null)}>
{t('msg.add')}
</Button>
</header>
<section className="kx-card kx-adm-filter" aria-label={t('msg.filterAria')}>
<div className="kx-adm-filter__grid">
<label className="kx-adm-field">
<span className="kx-adm-field__label">{t('msg.category')}</span>
<select className="kx-adm-field__select" value={category} onChange={(e) => setCategory(e.target.value)}>
<option value="">{t('msg.allCategories')}</option>
{categories.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</label>
<label className="kx-adm-field">
<span className="kx-adm-field__label">{t('msg.locale')}</span>
<select className="kx-adm-field__select" value={locale} onChange={(e) => setLocale(e.target.value)}>
<option value="">{t('msg.allLocales')}</option>
{MESSAGE_LOCALES.map((l) => (
<option key={l} value={l}>{l}</option>
))}
</select>
</label>
<label className="kx-adm-field">
<span className="kx-adm-field__label">{t('msg.search')}</span>
<input
className="kx-adm-field__input"
placeholder={t('msg.searchPlaceholder')}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
/>
</label>
</div>
</section>
{listQ.isLoading && <Skeleton height={320} radius={12} />}
{listQ.isError && !listQ.isLoading && (
<ErrorState message={t('msg.loadError')} onRetry={() => listQ.refetch()} />
)}
{!listQ.isLoading && !listQ.isError && (
filtered.length === 0 ? (
<EmptyState title={t('msg.emptyTitle')} description={t('msg.emptyDesc')} />
) : (
<section className="kx-card" aria-label={t('msg.title')}>
<div className="kx-table-scroll">
<table className="kx-table kx-table--zebra kx-adm-tbl">
<thead>
<tr>
<th>{t('msg.code')}</th>
<th>{t('msg.locale')}</th>
<th>{t('msg.category')}</th>
<th>{t('msg.msgTitle')}</th>
<th>{t('msg.placeholders')}</th>
<th>{t('msg.use')}</th>
<th style={{ textAlign: 'right' }}>{t('msg.manage')}</th>
</tr>
</thead>
<tbody>
{filtered.map((m) => {
const params = extractPlaceholders(m.template);
return (
<tr key={`${m.msgCode}:${m.locale}`}>
<td className="kx-adm-mono">{m.msgCode}</td>
<td className="kx-adm-mono">{m.locale ?? 'ko'}</td>
<td className="kx-adm-muted">{m.category}</td>
<td className="kx-adm-tbl__strong">{m.title}</td>
<td className="kx-adm-muted kx-adm-mono">
{params.length > 0 ? params.map((p) => `{${p}}`).join(' ') : '—'}
</td>
<td>
<span className={`kx-adm-pill kx-adm-pill--${m.useYn === 'N' ? 'neutral' : 'success'}`}>
{m.useYn === 'N' ? t('msg.unused') : t('msg.used')}
</span>
</td>
<td>
<div className="kx-adm-tbl__actions">
<Button variant="ghost" onClick={() => setPreviewModal(m)}>{t('msg.preview')}</Button>
<Button variant="ghost" onClick={() => setEditModal(m)}>{t('msg.edit')}</Button>
<Button
variant="ghost"
className="kx-adm-del"
disabled={delM.isPending}
onClick={() => {
if (window.confirm(t('msg.deleteConfirm', { code: m.msgCode, locale: m.locale ?? 'ko' })))
delM.mutate({ code: m.msgCode, loc: m.locale });
}}
>
{t('msg.delete')}
</Button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</section>
)
)}
{editModal !== undefined && (
<EditModal
msg={editModal}
categories={categories}
saving={saveM.isPending}
onClose={() => setEditModal(undefined)}
onSubmit={(m) => saveM.mutate(m)}
/>
)}
{previewModal !== undefined && (
<PreviewModal msg={previewModal} onClose={() => setPreviewModal(undefined)} />
)}
{toast}
</div>
);
}
function EditModal({
msg,
categories,
saving,
onClose,
onSubmit,
}: {
msg: SysMessage | null;
categories: string[];
saving: boolean;
onClose: () => void;
onSubmit: (m: SysMessage) => void;
}) {
const { t } = useTranslation();
const isNew = msg === null;
const [msgCode, setMsgCode] = useState(msg?.msgCode ?? '');
const [loc, setLoc] = useState(msg?.locale ?? 'ko');
const [category, setCategory] = useState(msg?.category ?? categories[0] ?? 'ACCOUNT');
const [title, setTitle] = useState(msg?.title ?? '');
const [template, setTemplate] = useState(msg?.template ?? '');
const [useYn, setUseYn] = useState(msg?.useYn ?? 'Y');
const [error, setError] = useState<string | null>(null);
const params = extractPlaceholders(template);
function submit(e: React.FormEvent) {
e.preventDefault();
setError(null);
if (!msgCode.trim() || !title.trim() || !template.trim()) {
setError(t('msg.errRequired'));
return;
}
onSubmit({
msgCode: msgCode.trim().toUpperCase(),
locale: loc,
category: category.trim().toUpperCase(),
title: title.trim(),
template,
useYn,
updatedAt: null,
});
}
return (
<div className="kx-adm-modal" role="dialog" aria-modal="true" aria-labelledby="msg-modal-title">
<div className="kx-adm-modal__backdrop" onClick={onClose} />
<form className="kx-adm-modal__panel" onSubmit={submit}>
<h2 id="msg-modal-title" className="kx-adm-modal__title">{isNew ? t('msg.add') : t('msg.edit')}</h2>
<div className="kx-adm-modal__grid">
<label className="kx-adm-field">
<span className="kx-adm-field__label">{t('msg.code')}</span>
<input
className="kx-adm-field__input kx-adm-mono"
value={msgCode}
disabled={!isNew}
placeholder="AUCTION_INVITE"
onChange={(e) => setMsgCode(e.target.value)}
/>
</label>
<label className="kx-adm-field">
<span className="kx-adm-field__label">{t('msg.locale')}</span>
<select className="kx-adm-field__select" value={loc} disabled={!isNew} onChange={(e) => setLoc(e.target.value)}>
{MESSAGE_LOCALES.map((l) => (
<option key={l} value={l}>{l}</option>
))}
</select>
</label>
<label className="kx-adm-field">
<span className="kx-adm-field__label">{t('msg.category')}</span>
<input
className="kx-adm-field__input kx-adm-mono"
value={category}
list="msg-category-options"
onChange={(e) => setCategory(e.target.value)}
/>
<datalist id="msg-category-options">
{categories.map((c) => (
<option key={c} value={c} />
))}
</datalist>
</label>
<label className="kx-adm-field">
<span className="kx-adm-field__label">{t('msg.useYn')}</span>
<select className="kx-adm-field__select" value={useYn ?? 'Y'} onChange={(e) => setUseYn(e.target.value)}>
<option value="Y">{t('msg.used')}</option>
<option value="N">{t('msg.unused')}</option>
</select>
</label>
<label className="kx-adm-field kx-adm-field--full">
<span className="kx-adm-field__label">{t('msg.msgTitle')}</span>
<input className="kx-adm-field__input" value={title} onChange={(e) => setTitle(e.target.value)} />
</label>
<label className="kx-adm-field kx-adm-field--full">
<span className="kx-adm-field__label">{t('msg.template')}</span>
<textarea
className="kx-adm-field__input"
rows={5}
value={template}
placeholder={t('msg.templatePlaceholder')}
onChange={(e) => setTemplate(e.target.value)}
/>
</label>
{params.length > 0 && (
<p className="kx-adm-muted kx-adm-field--full">
{t('msg.detectedParams')}: <span className="kx-adm-mono">{params.map((p) => `{${p}}`).join(' ')}</span>
</p>
)}
</div>
{error && <p className="kx-adm-modal__error" role="alert">{error}</p>}
<div className="kx-adm-modal__actions">
<Button variant="ghost" type="button" onClick={onClose}>{t('msg.cancel')}</Button>
<Button type="submit" disabled={saving}>{saving ? t('msg.saving') : t('msg.save')}</Button>
</div>
</form>
</div>
);
}
function PreviewModal({ msg, onClose }: { msg: SysMessage; onClose: () => void }) {
const { t } = useTranslation();
const params = extractPlaceholders(msg.template);
const [values, setValues] = useState<Record<string, string>>({});
const renderM = useMutation({
mutationFn: () => messageApi.render(msg.msgCode, msg.locale, values),
});
return (
<div className="kx-adm-modal" role="dialog" aria-modal="true" aria-labelledby="msg-preview-title">
<div className="kx-adm-modal__backdrop" onClick={onClose} />
<div className="kx-adm-modal__panel">
<h2 id="msg-preview-title" className="kx-adm-modal__title">{t('msg.preview')}</h2>
<p className="kx-adm-modal__desc">
<span className="kx-adm-mono">{msg.msgCode}</span> · {msg.locale ?? 'ko'} · {msg.category}
</p>
<div className="kx-adm-modal__grid">
<label className="kx-adm-field kx-adm-field--full">
<span className="kx-adm-field__label">{t('msg.template')}</span>
<textarea className="kx-adm-field__input kx-adm-mono" rows={4} value={msg.template} readOnly />
</label>
{params.map((p) => (
<label key={p} className="kx-adm-field">
<span className="kx-adm-field__label kx-adm-mono">{`{${p}}`}</span>
<input
className="kx-adm-field__input"
value={values[p] ?? ''}
onChange={(e) => setValues((v) => ({ ...v, [p]: e.target.value }))}
/>
</label>
))}
{params.length === 0 && <p className="kx-adm-muted kx-adm-field--full">{t('msg.noParams')}</p>}
{renderM.data && (
<div className="kx-adm-field--full">
<span className="kx-adm-field__label">{t('msg.renderResult')}</span>
<p className="kx-card" style={{ padding: 12, whiteSpace: 'pre-wrap' }}>{renderM.data.text}</p>
</div>
)}
{renderM.isError && (
<p className="kx-adm-modal__error kx-adm-field--full" role="alert">{errMessage(renderM.error)}</p>
)}
</div>
<div className="kx-adm-modal__actions">
<Button variant="ghost" type="button" onClick={onClose}>{t('msg.close')}</Button>
<Button type="button" disabled={renderM.isPending} onClick={() => renderM.mutate()}>
{renderM.isPending ? t('msg.rendering') : t('msg.render')}
</Button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,61 @@
/*
* M18 '메시지 관리' API (#35 · V48 sys_message).
* 정본: MessageController(com.zioinfo.kintex.system.message)
* - GET /api/admin/messages?category=&locale= [ADMIN]
* - GET /api/admin/messages/{code} [ADMIN]
* - POST /api/admin/messages / upsert [ADMIN]
* - DELETE /api/admin/messages/{code}?locale= (locale )[ADMIN]
* - POST /api/messages/{code}/render?locale= {params} []
* 릿 {name}·{eventName} (PII·릿) .
*/
import { api } from '../../api/client';
export interface SysMessage {
msgCode: string;
locale: string | null;
category: string;
title: string;
template: string;
useYn: string | null;
updatedAt: string | null;
}
export interface RenderResult {
code: string;
locale: string | null;
text: string;
}
export const messageApi = {
list: (p: { category?: string; locale?: string } = {}) => {
const qs = new URLSearchParams();
if (p.category) qs.set('category', p.category);
if (p.locale) qs.set('locale', p.locale);
const q = qs.toString();
return api.get<SysMessage[]>(`/api/admin/messages${q ? `?${q}` : ''}`);
},
byCode: (code: string) => api.get<SysMessage[]>(`/api/admin/messages/${encodeURIComponent(code)}`),
save: (msg: SysMessage) => api.post<void>('/api/admin/messages', msg),
remove: (code: string, locale?: string) =>
api.del<void>(
`/api/admin/messages/${encodeURIComponent(code)}${locale ? `?locale=${encodeURIComponent(locale)}` : ''}`,
),
render: (code: string, locale: string | null, params: Record<string, string>) =>
api.post<RenderResult>(
`/api/messages/${encodeURIComponent(code)}/render${locale ? `?locale=${encodeURIComponent(locale)}` : ''}`,
{ params },
),
};
/** V48 시드 분류 정본 — 백엔드는 자유 문자열이나 UI 는 이 목록 + 목록에서 발견된 값을 병합해 노출. */
export const MESSAGE_CATEGORIES = ['AUCTION', 'SETTLEMENT', 'ACCOUNT', 'VISITOR', 'MARKETING'] as const;
export const MESSAGE_LOCALES = ['ko', 'en', 'zh', 'ja'] as const;
/** template 의 {key} 플레이스홀더 추출(중복 제거·등장 순서 유지). */
export function extractPlaceholders(template: string): string[] {
const found: string[] = [];
for (const m of template.matchAll(/\{(\w+)\}/g)) {
if (!found.includes(m[1])) found.push(m[1]);
}
return found;
}

View File

@ -0,0 +1,46 @@
/*
* 404 ( 4.4). catch-all(`*`).
* ()· (Nifty ).
* , (/) .
* SVG ( ).
*/
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../../store/authStore';
import { landingPathFor } from '../../lib/roleTrack';
import { Button } from '../../components/ui/Button';
import { IconArrowLeft, IconHome, IconSearch } from '../../components/ui/icons';
import './not-found.css';
export function NotFoundPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const user = useAuthStore((s) => s.user);
const workspaces = useAuthStore((s) => s.workspaces);
const homePath = isAuthenticated ? landingPathFor(user, workspaces) : '/';
return (
<div className="kx-nf">
<div className="kx-nf__card kx-card" role="alert" aria-labelledby="kx-nf-title">
<div className="kx-nf__icon" aria-hidden="true">
<IconSearch size={40} />
</div>
<p className="kx-nf__code">404</p>
<h1 id="kx-nf-title" className="kx-nf__title">
{t('notFound.title')}
</h1>
<p className="kx-nf__desc">{t('notFound.desc')}</p>
<div className="kx-nf__actions">
<Button variant="primary" leadingIcon={<IconHome size={16} />} onClick={() => navigate(homePath)}>
{t('notFound.home')}
</Button>
<Button variant="secondary" leadingIcon={<IconArrowLeft size={16} />} onClick={() => navigate(-1)}>
{t('notFound.back')}
</Button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,60 @@
/*
* 404 화면 전폭 중앙 정렬 카드(Nifty ). kx 토큰만 · 다크/라이트 · SVG.
*/
@import '../shared.css';
.kx-nf {
min-height: 100vh;
min-height: 100dvh;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-6);
background: var(--color-neutral-050);
}
.kx-nf__card {
width: 100%;
max-width: 460px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: var(--space-3);
padding: var(--space-8) var(--space-5);
}
.kx-nf__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 72px;
height: 72px;
border-radius: var(--radius-pill);
background: var(--color-primary-050);
color: var(--color-primary-600);
}
.kx-nf__code {
font-size: var(--fs-display);
line-height: var(--lh-display);
font-weight: var(--fw-bold);
color: var(--color-neutral-500);
letter-spacing: 0.04em;
}
.kx-nf__title {
font-size: var(--fs-h1);
line-height: var(--lh-h1);
font-weight: var(--fw-bold);
color: var(--color-neutral-900);
}
.kx-nf__desc {
font-size: var(--fs-body);
line-height: var(--lh-body);
color: var(--color-neutral-500);
max-width: 340px;
}
.kx-nf__actions {
display: flex;
gap: var(--space-3);
margin-top: var(--space-3);
flex-wrap: wrap;
justify-content: center;
}

View File

@ -11,6 +11,7 @@ import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
import { Offcanvas } from '../../components/ui/Offcanvas'; import { Offcanvas } from '../../components/ui/Offcanvas';
import { KxDataTable, type KxColumn } from '../../components/KxDataTable';
import { IconUsers, IconExhibitors, IconCheckCircle, IconSettlement } from '../../components/ui/icons'; import { IconUsers, IconExhibitors, IconCheckCircle, IconSettlement } from '../../components/ui/icons';
import { useResolvedEventId } from '../../hooks/useResolvedEventId'; import { useResolvedEventId } from '../../hooks/useResolvedEventId';
import { exhibitorsApi } from './exhibitorsApi'; import { exhibitorsApi } from './exhibitorsApi';
@ -60,7 +61,6 @@ function fmtArea(a: number | null): string {
export function ExhibitorsPage() { export function ExhibitorsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const eventId = useResolvedEventId(); const eventId = useResolvedEventId();
const [keyword, setKeyword] = useState('');
const [saleFilter, setSaleFilter] = useState<SaleFilter>(''); const [saleFilter, setSaleFilter] = useState<SaleFilter>('');
const [selected, setSelected] = useState<ExhibitorRow | null>(null); const [selected, setSelected] = useState<ExhibitorRow | null>(null);
@ -71,18 +71,42 @@ export function ExhibitorsPage() {
retry: false, retry: false,
}); });
// 세그먼트(판매상태) 필터만 여기서 — 검색·정렬·페이지·CSV 는 KxDataTable 담당.
const rows = useMemo(() => { const rows = useMemo(() => {
const all = q.data?.exhibitors ?? []; const all = q.data?.exhibitors ?? [];
const kw = keyword.trim().toLowerCase(); return saleFilter ? all.filter((r) => r.saleStatus === saleFilter) : all;
return all.filter((r) => { }, [q.data, saleFilter]);
if (saleFilter && r.saleStatus !== saleFilter) return false;
if (kw) { const columns = useMemo<KxColumn<ExhibitorRow>[]>(
const hay = `${r.companyName ?? ''} ${r.boothNo ?? ''}`.toLowerCase(); () => [
if (!hay.includes(kw)) return false; { key: 'company', header: t('exhibitorsPage.colCompany'), value: (r) => r.companyName,
} render: (r) => <span className="kx-exh__company">{r.companyName}</span> },
return true; { key: 'booth', header: t('exhibitorsPage.colBooth'), value: (r) => r.boothNo ?? '',
}); render: (r) => <span className="tnum">{r.boothNo ?? '-'}</span> },
}, [q.data, keyword, saleFilter]); { key: 'type', header: t('exhibitorsPage.colType'),
value: (r) => (r.boothType ? t(`exhibitorsPage.boothType.${r.boothType}`, { defaultValue: r.boothType }) : ''),
render: (r) => (r.boothType ? t(`exhibitorsPage.boothType.${r.boothType}`, { defaultValue: r.boothType }) : '-') },
{ key: 'area', header: t('exhibitorsPage.colArea'), numeric: true, value: (r) => r.areaM2 ?? null,
render: (r) => fmtArea(r.areaM2) },
{ key: 'sale', header: t('exhibitorsPage.colSale'), value: (r) => saleLabel(t, r.saleStatus),
render: (r) => <Pill tone={saleTone(r.saleStatus)} label={saleLabel(t, r.saleStatus)} /> },
{ key: 'design', header: t('exhibitorsPage.colDesign'), value: (r) => designLabel(t, r.designStatus),
render: (r) => <Pill tone={designTone(r.designStatus)} label={designLabel(t, r.designStatus)} /> },
{ key: 'settlement', header: t('exhibitorsPage.colSettlement'), value: (r) => invoiceLabel(t, r.invoiceStatus),
render: (r) => (
<>
<Pill tone={invoiceTone(r.invoiceStatus)} label={invoiceLabel(t, r.invoiceStatus)} />
{r.outstanding > 0 && (
<span className="kx-exh__due tnum">{t('exhibitorsPage.dueShort', { won: fmtWon(r.outstanding) })}</span>
)}
</>
) },
{ key: 'contact', header: t('exhibitorsPage.colContact'),
value: (r) => r.contactName ?? r.contactPhone ?? '',
render: (r) => r.contactName ?? r.contactPhone ?? <span className="kx-exh__muted">{t('exhibitorsPage.contactNone')}</span> },
],
[t],
);
if (!eventId) { if (!eventId) {
return ( return (
@ -122,32 +146,6 @@ export function ExhibitorsPage() {
{/* 목록 카드 */} {/* 목록 카드 */}
<section className="kx-card kx-exh__list-card" aria-label={t('exhibitorsPage.listTitle')}> <section className="kx-card kx-exh__list-card" aria-label={t('exhibitorsPage.listTitle')}>
<div className="kx-card__head kx-exh__toolbar">
<div className="kx-exh__search">
<input
className="kx-input kx-exh__search-input"
type="search"
placeholder={t('exhibitorsPage.searchPlaceholder')}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
aria-label={t('exhibitorsPage.searchPlaceholder')}
/>
</div>
<div className="kx-seg" role="tablist" aria-label={t('exhibitorsPage.saleFilterLabel')}>
{SALE_FILTERS.map((v) => (
<button
key={v || 'all'}
role="tab"
aria-selected={saleFilter === v}
className={`kx-seg__btn ${saleFilter === v ? 'is-active' : ''}`}
onClick={() => setSaleFilter(v)}
>
{v === '' ? t('exhibitorsPage.filterAll') : saleLabel(t, v)}
</button>
))}
</div>
</div>
{q.isLoading ? ( {q.isLoading ? (
<div style={{ display: 'grid', gap: 8 }} aria-hidden="true"> <div style={{ display: 'grid', gap: 8 }} aria-hidden="true">
{[0, 1, 2, 3, 4].map((i) => ( {[0, 1, 2, 3, 4].map((i) => (
@ -156,50 +154,34 @@ export function ExhibitorsPage() {
</div> </div>
) : q.isError ? ( ) : q.isError ? (
<ErrorState message={t('exhibitorsPage.loadError')} onRetry={() => q.refetch()} /> <ErrorState message={t('exhibitorsPage.loadError')} onRetry={() => q.refetch()} />
) : rows.length === 0 ? (
<EmptyState
title={t('exhibitorsPage.emptyTitle')}
description={t('exhibitorsPage.emptyDesc')}
/>
) : ( ) : (
<div className="kx-table-scroll"> <KxDataTable
<table className="kx-table kx-table--zebra"> columns={columns}
<thead> rows={rows}
<tr> rowKey={(r) => r.boothId}
<th>{t('exhibitorsPage.colCompany')}</th> onRowClick={setSelected}
<th>{t('exhibitorsPage.colBooth')}</th> isRowSelected={(r) => r.boothId === selected?.boothId}
<th>{t('exhibitorsPage.colType')}</th> searchPlaceholder={t('exhibitorsPage.searchPlaceholder')}
<th className="kx-num">{t('exhibitorsPage.colArea')}</th> csvFileName={`exhibitors_${eventId}`}
<th>{t('exhibitorsPage.colSale')}</th> ariaLabel={t('exhibitorsPage.listTitle')}
<th>{t('exhibitorsPage.colDesign')}</th> emptyTitle={t('exhibitorsPage.emptyTitle')}
<th>{t('exhibitorsPage.colSettlement')}</th> emptyDescription={t('exhibitorsPage.emptyDesc')}
<th>{t('exhibitorsPage.colContact')}</th> toolbarStart={
</tr> <div className="kx-seg" role="tablist" aria-label={t('exhibitorsPage.saleFilterLabel')}>
</thead> {SALE_FILTERS.map((v) => (
<tbody> <button
{rows.map((r) => ( key={v || 'all'}
<tr key={r.boothId} className="kx-exh__row" onClick={() => setSelected(r)}> role="tab"
<td className="kx-exh__company">{r.companyName}</td> aria-selected={saleFilter === v}
<td className="tnum">{r.boothNo ?? '-'}</td> className={`kx-seg__btn ${saleFilter === v ? 'is-active' : ''}`}
<td>{r.boothType ? t(`exhibitorsPage.boothType.${r.boothType}`, { defaultValue: r.boothType }) : '-'}</td> onClick={() => setSaleFilter(v)}
<td className="kx-num tnum">{fmtArea(r.areaM2)}</td> >
<td><Pill tone={saleTone(r.saleStatus)} label={saleLabel(t, r.saleStatus)} /></td> {v === '' ? t('exhibitorsPage.filterAll') : saleLabel(t, v)}
<td><Pill tone={designTone(r.designStatus)} label={designLabel(t, r.designStatus)} /></td> </button>
<td>
<Pill tone={invoiceTone(r.invoiceStatus)} label={invoiceLabel(t, r.invoiceStatus)} />
{r.outstanding > 0 && (
<span className="kx-exh__due tnum">{t('exhibitorsPage.dueShort', { won: fmtWon(r.outstanding) })}</span>
)}
</td>
<td>{r.contactName ?? r.contactPhone ?? <span className="kx-exh__muted">{t('exhibitorsPage.contactNone')}</span>}</td>
</tr>
))} ))}
</tbody> </div>
</table> }
</div> />
)}
{!q.isLoading && !q.isError && rows.length > 0 && (
<p className="kx-exh__count">{t('exhibitorsPage.count', { n: rows.length })}</p>
)} )}
</section> </section>

View File

@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query';
import { AiLabel } from '../../components/ui/Badge'; import { AiLabel } from '../../components/ui/Badge';
import { Button } from '../../components/ui/Button'; import { Button } from '../../components/ui/Button';
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
import { KxDataTable, type KxColumn } from '../../components/KxDataTable';
import { IconClock, IconDownload, IconSpark } from '../../components/ui/icons'; import { IconClock, IconDownload, IconSpark } from '../../components/ui/icons';
import { ApiRequestError } from '../../api/client'; import { ApiRequestError } from '../../api/client';
import { useResolvedEventId } from '../../hooks/useResolvedEventId'; import { useResolvedEventId } from '../../hooks/useResolvedEventId';
@ -77,6 +78,22 @@ export function LeadScoringPage() {
); );
const selected = leads.find((l) => l.id === selectedId) ?? rows[0] ?? leads[0] ?? null; const selected = leads.find((l) => l.id === selectedId) ?? rows[0] ?? leads[0] ?? null;
const columns = useMemo<KxColumn<LeadItemDto>[]>(
() => [
{ key: 'name', header: '이름', value: (l) => l.nameMasked,
render: (l) => <span className="kx-vis__lead-name">{l.nameMasked}</span> },
{ key: 'company', header: '소속', value: (l) => l.company ?? '', render: (l) => l.company ?? '-' },
{ key: 'product', header: '관심 제품', value: (l) => l.product ?? '', render: (l) => l.product ?? '-' },
{ key: 'interest', header: '관심도', numeric: true, value: (l) => l.interest,
render: (l) => <Stars value={l.interest} /> },
{ key: 'score', header: 'AI 스코어', numeric: true, value: (l) => l.score,
render: (l) => <ScoreGauge score={l.score} /> },
{ key: 'collectedAt', header: '수집시각', value: (l) => l.collectedAt ?? '',
render: (l) => <span className="tnum">{l.collectedAt ?? '-'}</span> },
],
[],
);
const hotCount = leads.filter((l) => l.score >= 80).length; const hotCount = leads.filter((l) => l.score >= 80).length;
const kpis = [ const kpis = [
{ label: '총 리드', value: total.toLocaleString(), sub: undefined as string | undefined, ai: false }, { label: '총 리드', value: total.toLocaleString(), sub: undefined as string | undefined, ai: false },
@ -147,69 +164,32 @@ export function LeadScoringPage() {
<div className="kx-vis__lead-split"> <div className="kx-vis__lead-split">
{/* 리드 테이블 */} {/* 리드 테이블 */}
<section className="kx-card kx-vis__lead-table-card" aria-label="리드 목록"> <section className="kx-card kx-vis__lead-table-card" aria-label="리드 목록">
<div className="kx-vis__filters" role="group" aria-label="리드 필터"> <KxDataTable
<span className="kx-vis__filters-label"></span> columns={columns}
<button rows={rows}
type="button" rowKey={(l) => l.id}
className={`kx-chip ${hotOnly ? 'is-active' : ''}`} onRowClick={(l) => setSelectedId(l.id)}
aria-pressed={hotOnly} isRowSelected={(l) => l.id === selected?.id}
onClick={() => setHotOnly((v) => !v)} rowClassName={() => 'kx-vis__lead-row'}
> zebra={false}
80+ {hotOnly && <span aria-hidden="true">×</span>} searchPlaceholder="이름·소속·제품 검색"
</button> ariaLabel="리드 목록"
<span className="kx-vis__filters-count tnum">{rows.length}</span> emptyTitle="수집된 리드가 없습니다"
</div> emptyDescription="부스 배지 QR 스캔이 수집되면 표시됩니다."
<div className="kx-table-scroll"> toolbarStart={
<table className="kx-table"> <div className="kx-vis__filters" role="group" aria-label="리드 필터">
<thead> <span className="kx-vis__filters-label"></span>
<tr> <button
<th scope="col"></th> type="button"
<th scope="col"></th> className={`kx-chip ${hotOnly ? 'is-active' : ''}`}
<th scope="col"> </th> aria-pressed={hotOnly}
<th scope="col"></th> onClick={() => setHotOnly((v) => !v)}
<th scope="col">AI </th> >
<th scope="col"></th> 80+ {hotOnly && <span aria-hidden="true">×</span>}
</tr> </button>
</thead> </div>
<tbody> }
{rows.map((l) => { />
const isSel = l.id === selected?.id;
return (
<tr
key={l.id}
className={`kx-vis__lead-row ${isSel ? 'is-selected' : ''}`}
onClick={() => setSelectedId(l.id)}
tabIndex={0}
aria-selected={isSel}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setSelectedId(l.id);
}
}}
>
<td className="kx-vis__lead-name">{l.nameMasked}</td>
<td>{l.company ?? '-'}</td>
<td>{l.product ?? '-'}</td>
<td><Stars value={l.interest} /></td>
<td><ScoreGauge score={l.score} /></td>
<td className="tnum">{l.collectedAt ?? '-'}</td>
</tr>
);
})}
{rows.length === 0 && (
<tr>
<td colSpan={6}>
<EmptyState title="수집된 리드가 없습니다" description="부스 배지 QR 스캔이 수집되면 표시됩니다." />
</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="kx-vis__table-foot">
<span className="kx-vis__count"> {total.toLocaleString()} {rows.length} </span>
</div>
</section> </section>
{/* 리드 상세·팔로업 */} {/* 리드 상세·팔로업 */}

View File

@ -1,3 +1,4 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { import {
Area, Area,
@ -14,6 +15,7 @@ import {
import { AiLabel } from '../../components/ui/Badge'; import { AiLabel } from '../../components/ui/Badge';
import { Button } from '../../components/ui/Button'; import { Button } from '../../components/ui/Button';
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
import { KxDataTable, type KxColumn } from '../../components/KxDataTable';
import { IconDocument, IconDownload, IconSpark, IconTag, IconTrendUp, IconUsers } from '../../components/ui/icons'; import { IconDocument, IconDownload, IconSpark, IconTag, IconTrendUp, IconUsers } from '../../components/ui/icons';
import { ApiRequestError } from '../../api/client'; import { ApiRequestError } from '../../api/client';
import { useResolvedEventId } from '../../hooks/useResolvedEventId'; import { useResolvedEventId } from '../../hooks/useResolvedEventId';
@ -57,9 +59,34 @@ export function VisitorRegistrationDashboardPage() {
const summary: VisitorSummaryDto | null = summaryQ.data ?? (degraded ? SAMPLE_SUMMARY : null); const summary: VisitorSummaryDto | null = summaryQ.data ?? (degraded ? SAMPLE_SUMMARY : null);
const rows: VisitorListItemDto[] = const rows: VisitorListItemDto[] =
listQ.data?.items ?? (degraded ? SAMPLE_ROWS : []); listQ.data?.items ?? (degraded ? SAMPLE_ROWS : []);
const total = listQ.data?.total ?? (degraded ? SAMPLE_ROWS.length : 0);
const hardError = (summaryQ.isError && !degraded) || (listQ.isError && !degraded); const hardError = (summaryQ.isError && !degraded) || (listQ.isError && !degraded);
const columns = useMemo<KxColumn<VisitorListItemDto>[]>(
() => [
{ key: 'name', header: '이름', value: (r) => r.nameMasked,
render: (r) => (
<span className="kx-vis__person">
<span className="kx-vis__avatar" aria-hidden="true">{initialOf(r.nameMasked)}</span>
{r.nameMasked}
</span>
) },
{ key: 'type', header: '유형', value: (r) => typeLabelOf(r.type), render: (r) => <TypePill type={r.type} /> },
{ key: 'company', header: '소속', value: (r) => r.company ?? '', render: (r) => r.company ?? '-' },
{ key: 'registeredAt', header: '등록일', value: (r) => r.registeredAt ?? '',
render: (r) => <span className="tnum">{r.registeredAt ?? '-'}</span> },
{ key: 'checkin', header: '체크인', value: (r) => checkinLabelOf(r.checkin), render: (r) => <CheckinPill state={r.checkin} /> },
{ key: 'badge', header: '배지', value: (r) => (r.badgeIssued ? '발급' : '미발급'),
render: (r) => (r.badgeIssued ? (
<span className="kx-vis__badge-issued" title="배지 발급 완료">
<QrGlyph size={16} />
</span>
) : (
<span className="kx-vis__badge-none"></span>
)) },
],
[],
);
return ( return (
<div className="kx-vis"> <div className="kx-vis">
<header className="kx-vis__head"> <header className="kx-vis__head">
@ -238,57 +265,16 @@ export function VisitorRegistrationDashboardPage() {
<h2><IconUsers className="kx-title-ic" size={18} /> </h2> <h2><IconUsers className="kx-title-ic" size={18} /> </h2>
<span className="kx-vis__pii-note"> · </span> <span className="kx-vis__pii-note"> · </span>
</div> </div>
<div className="kx-table-scroll"> <KxDataTable
<table className="kx-table kx-table--zebra"> columns={columns}
<thead> rows={rows}
<tr> rowKey={(r) => r.id}
<th scope="col"></th> searchPlaceholder="이름·소속 검색"
<th scope="col"></th> csvFileName={`visitors_${eventId}`}
<th scope="col"></th> ariaLabel="전체 신청자 명단"
<th scope="col"></th> emptyTitle="등록자가 없습니다"
<th scope="col"></th> emptyDescription="사전등록이 접수되면 이 곳에 표시됩니다."
<th scope="col"></th> />
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id}>
<td>
<span className="kx-vis__person">
<span className="kx-vis__avatar" aria-hidden="true">{initialOf(r.nameMasked)}</span>
{r.nameMasked}
</span>
</td>
<td><TypePill type={r.type} /></td>
<td>{r.company ?? '-'}</td>
<td className="tnum">{r.registeredAt ?? '-'}</td>
<td><CheckinPill state={r.checkin} /></td>
<td>
{r.badgeIssued ? (
<span className="kx-vis__badge-issued" title="배지 발급 완료">
<QrGlyph size={16} />
</span>
) : (
<span className="kx-vis__badge-none"></span>
)}
</td>
</tr>
))}
{rows.length === 0 && (
<tr>
<td colSpan={6}>
<EmptyState title="등록자가 없습니다" description="사전등록이 접수되면 이 곳에 표시됩니다." />
</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="kx-vis__table-foot">
<span className="kx-vis__count">
{total.toLocaleString()} {rows.length}
</span>
</div>
</section> </section>
</> </>
)} )}
@ -319,12 +305,22 @@ function initialOf(nameMasked: string): string {
} }
const KNOWN_TYPES: RegVisitorType[] = ['visitor', 'buyer', 'vip']; const KNOWN_TYPES: RegVisitorType[] = ['visitor', 'buyer', 'vip'];
/** 유형 코드 → 표시 라벨(검색·CSV·정렬 값). */
function typeLabelOf(type: string): string {
const t = (KNOWN_TYPES as string[]).includes(type) ? (type as RegVisitorType) : 'visitor';
return REG_TYPE_LABEL[t];
}
function TypePill({ type }: { type: string }) { function TypePill({ type }: { type: string }) {
const t = (KNOWN_TYPES as string[]).includes(type) ? (type as RegVisitorType) : 'visitor'; const t = (KNOWN_TYPES as string[]).includes(type) ? (type as RegVisitorType) : 'visitor';
return <span className={`kx-vis__type is-${t}`}>{REG_TYPE_LABEL[t]}</span>; return <span className={`kx-vis__type is-${t}`}>{REG_TYPE_LABEL[t]}</span>;
} }
const KNOWN_CHECKIN: CheckinState[] = ['done', 'waiting', 'cancelled']; const KNOWN_CHECKIN: CheckinState[] = ['done', 'waiting', 'cancelled'];
/** 체크인 코드 → 표시 라벨(검색·CSV·정렬 값). */
function checkinLabelOf(state: string): string {
const s = (KNOWN_CHECKIN as string[]).includes(state) ? (state as CheckinState) : 'waiting';
return CHECKIN_LABEL[s];
}
function CheckinPill({ state }: { state: string }) { function CheckinPill({ state }: { state: string }) {
const s = (KNOWN_CHECKIN as string[]).includes(state) ? (state as CheckinState) : 'waiting'; const s = (KNOWN_CHECKIN as string[]).includes(state) ? (state as CheckinState) : 'waiting';
return ( return (