feat(backlog): wave 3 — BI global overview, admin CRUD screens+guard, schedule real-data (V23); fix boot crash
- fix: MyBatis <script> XML violations (AuctionMapper deadline<now(), VisitorMapper <=) that crash-looped boot and triggered health rollback; add MapperAnnotationParseTest regression guard - M16 BI: GET /api/analytics/overview — cross-event revenue, monthly hall utilization, exhibitor retention, linear-regression forecast (hall-manager only, no migration) - M18 admin: users/roles/codes/menus CRUD screens consuming live system APIs, AdminGuard on all /admin/* routes, landing module shortcuts; A8 rulesets confirmed already live-wired - SCR-15 schedule: V23 (category/est_visitors backfill), hall-utilization + calendar APIs, screen switched to real data with offline fallback - QA: replace text check glyph with IconCheck SVG - verified: compileJava, test (incl. mapper parse), tsc -b --force, vite build all EXIT 0 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
335b9aa8a4
commit
8a49a68a6a
@ -12,11 +12,11 @@
|
|||||||
|
|
||||||
| 상태 | 항목 | 내용 | 담당 | 편입 Phase |
|
| 상태 | 항목 | 내용 | 담당 | 편입 Phase |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| 🔶 | SCR-13 BI 실데이터 전환 | BI 데이터마트·KpiSnapshot 파이프라인 미구축 → 화면 전체 샘플. 집계 API(매출·RevPAD·P&L·리텐션·예측) 신설 필요 | DA·BI·BE | D-M16 |
|
| ✅ | SCR-13 BI 실데이터 전환 | ~~집계 API 신설 필요~~ → **완료(2026-07-12)**: 행사 스코프 `/api/events/{id}/analytics`(P&L 포함) + 테넌트 전역 `/api/analytics/overview`(행사 간 매출·홀 가동률 추이·리텐션·선형회귀 예측). 잔여: 실 정산 데이터 연동 시 매출 산식 고도화(M9) | BI·BE | 완료 |
|
||||||
| 🔶 | SCR-14 현장운영 실전환 | 혼잡=M10 체크인 파생, 전력=M4 부하 집계로 실전환 경로 확보. CCTV·HVAC/전력계·iparking·조명 IoT는 **하드웨어 계약 필요 — 연동 코드 착수 금지** | BE·FE | E-M14 |
|
| 🔶 | SCR-14 현장운영 실전환 | 혼잡=M10 체크인 파생, 전력=M4 부하 집계로 실전환 경로 확보. CCTV·HVAC/전력계·iparking·조명 IoT는 **하드웨어 계약 필요 — 연동 코드 착수 금지** | BE·FE | E-M14 |
|
||||||
| 🔶 | SCR-15 일정 실전환 | 행사 목록만 실(인증 workspaces). WorkspaceDto에 category·estVisitors 필드 추가 + 홀 가동률 집계 API + 캘린더 점유 실데이터 | BE·FE | D-M1/6 |
|
| ✅ | SCR-15 일정 실전환 | **완료(2026-07-12)**: V23(category·est_visitors 크롤 백필) + `/api/events/hall-utilization`·`/api/events/calendar` + 화면 실 전환. 잔여: hall_assignment 역사 백필(자유텍스트 파싱 리스크로 보류), M10 실집계 연동 시 est_visitors 갱신 | BE·FE | 완료 |
|
||||||
| 🔶 | SCR-16 관리자 집계 실전환 | 사용자수·활성행사수 등 실 가능 지표 API 연결. 실시간 방문객·주차·라이브는 M10/M14 이후 | ADM·BE | D-M18 |
|
| 🔶 | SCR-16 관리자 집계 실전환 | 사용자수·활성행사수 등 실 가능 지표 API 연결. 실시간 방문객·주차·라이브는 M10/M14 이후 | ADM·BE | D-M18 |
|
||||||
| 🔶 | M18 관리자 본체 화면 | ~~7종 미구현~~ → **audit·settings 실배선 완료 + rulesets·tenants 화면(샘플) 완료(2026-07-12)**. 잔여: users·roles·codes·menus CRUD 화면(백엔드 라이브) + A8 룰셋 조회 API + A9 멀티테넌시 백엔드 | ADM·FE | D-M18 |
|
| 🔶 | M18 관리자 본체 화면 | **users·roles·codes·menus CRUD 화면 + AdminGuard(전 /admin/* 가드) + A8 룰셋 실배선 확인 완료(2026-07-12)**. 잔여: A9 멀티테넌시 백엔드(대형 트랙 §4와 동일) + AuthUser.roleCode 노출(AdminGuard 정확 판정) | ADM·BE | D-M18 |
|
||||||
| ⬜ | SCR-16 플로팅 AI 봇 | design.md상 P2·비노출 기본 — 미구현(주석만) | AI·FE | P2 |
|
| ⬜ | SCR-16 플로팅 AI 봇 | design.md상 P2·비노출 기본 — 미구현(주석만) | AI·FE | P2 |
|
||||||
| ⬜ | AppShell 기존 네비 배선 | 대시보드·플로어플랜·참가업체·서류·정산 5항목 여전히 `href="#"` — 행사 스코프 라우트라 워크스페이스 컨텍스트 선택 UX 필요(신규 4항목만 배선됨) | FE·DES | C-C |
|
| ⬜ | AppShell 기존 네비 배선 | 대시보드·플로어플랜·참가업체·서류·정산 5항목 여전히 `href="#"` — 행사 스코프 라우트라 워크스페이스 컨텍스트 선택 UX 필요(신규 4항목만 배선됨) | FE·DES | C-C |
|
||||||
| ⬜ | `/_styleguide` 노출 방침 | 내부 전용 라우트로만 구분 — 프로덕션 빌드 제외(env 분기 등) 방침 미확정 | FE·TA | E-DEP |
|
| ⬜ | `/_styleguide` 노출 방침 | 내부 전용 라우트로만 구분 — 프로덕션 빌드 제외(env 분기 등) 방침 미확정 | FE·TA | E-DEP |
|
||||||
@ -34,8 +34,8 @@
|
|||||||
| ⬜ | M10/M12 백엔드 | 사전등록 집계·리드·EDM 발송·스폰서십 (PII는 서버 마스킹 응답 계약) | D-M10/12 |
|
| ⬜ | M10/M12 백엔드 | 사전등록 집계·리드·EDM 발송·스폰서십 (PII는 서버 마스킹 응답 계약) | D-M10/12 |
|
||||||
| ⬜ | M17 CMS 백엔드 | 콘텐츠·게시 전이·다국어·마이크로사이트 스키마 | D-M17 |
|
| ⬜ | M17 CMS 백엔드 | 콘텐츠·게시 전이·다국어·마이크로사이트 스키마 | D-M17 |
|
||||||
| ⬜ | 공개 카탈로그 API | `GET /api/public/events` 등 공개 7갭(G1~G7, port_public.md) — 현재 공개 화면 전부 샘플 | D-M12 |
|
| ⬜ | 공개 카탈로그 API | `GET /api/public/events` 등 공개 7갭(G1~G7, port_public.md) — 현재 공개 화면 전부 샘플 | D-M12 |
|
||||||
| ⬜ | 프론트 admin RBAC 가드 | /admin/* 라우트 프론트 가드(백엔드 requireAdmin 403은 확보) | C |
|
| ✅ | 프론트 admin RBAC 가드 | **완료(2026-07-12)** — AdminGuard + 전 /admin/* 라우트 배선(hallManager 기준·백엔드가 최종 권위) | 완료 |
|
||||||
| ⬜ | QA minor 2 | kx-field 접두 격리(auction/admin-modules.css)·★/✓ 글리프 SVG화 | C |
|
| ✅ | QA minor 2 | **완료(2026-07-12)** — kx-field 스코프 격리·★ SVG는 기왕 해소, ✓(OrganizerDashboardPage) IconCheck 교체 | 완료 |
|
||||||
| ⬜ | 모바일 B2C 탭 셸 | 관람객 트랙 탭 부재 — M15는 /tickets 라우트만 존재 + RN node_modules 손상(devops 재설치) | 모바일 |
|
| ⬜ | 모바일 B2C 탭 셸 | 관람객 트랙 탭 부재 — M15는 /tickets 라우트만 존재 + RN node_modules 손상(devops 재설치) | 모바일 |
|
||||||
|
|
||||||
## 2. 백그라운드 산출물 — 커밋 대기·미완 검증 필요
|
## 2. 백그라운드 산출물 — 커밋 대기·미완 검증 필요
|
||||||
|
|||||||
@ -76,7 +76,9 @@
|
|||||||
## 7. 배포·운영 함정 (반드시 숙지)
|
## 7. 배포·운영 함정 (반드시 숙지)
|
||||||
- `deploy_kintex.sh`는 백엔드도 빌드해야 함(과거 미빌드 버그·수정됨). `gradlew`는 `sh ./gradlew`(실행권한 이슈). `grep -q` SIGPIPE 회피.
|
- `deploy_kintex.sh`는 백엔드도 빌드해야 함(과거 미빌드 버그·수정됨). `gradlew`는 `sh ./gradlew`(실행권한 이슈). `grep -q` SIGPIPE 회피.
|
||||||
- **★MyBatis+PostgreSQL 별칭(2026-07-12 규명)**: PG는 따옴표 없는 별칭을 소문자로 접음(`AS userId`→`userid`) → **Map 반환 @Select는 반드시 `AS "userId"`(쌍따옴표)**. 안 그러면 컴파일·단위테스트 통과하고 런타임에서 키 전부 null(secure 로그인 전멸·집계 API 0/null이었음). UserMapper.xml 컨벤션 준수. 신규 매퍼 작성 시 필수 점검.
|
- **★MyBatis+PostgreSQL 별칭(2026-07-12 규명)**: PG는 따옴표 없는 별칭을 소문자로 접음(`AS userId`→`userid`) → **Map 반환 @Select는 반드시 `AS "userId"`(쌍따옴표)**. 안 그러면 컴파일·단위테스트 통과하고 런타임에서 키 전부 null(secure 로그인 전멸·집계 API 0/null이었음). UserMapper.xml 컨벤션 준수. 신규 매퍼 작성 시 필수 점검.
|
||||||
- **프론트 검증은 `tsc -b`**(서버 빌드와 동일) — `tsc --noEmit`은 프로젝트 레퍼런스 설정을 안 타서 서버에서만 실패하는 오류(noUnusedLocals 등)를 놓침.
|
- **프론트 검증은 `tsc -b`**(서버 빌드와 동일) — `tsc --noEmit`은 프로젝트 레퍼런스 설정을 안 타서 서버에서만 실패하는 오류(noUnusedLocals 등)를 놓침. **CSS import 누락은 tsc가 못 잡음** → 커밋 전 `npx vite build`도 통과시켜라(2026-07-12 checkin.css 누락으로 서버 프론트 빌드 2회 실패).
|
||||||
|
- **★MyBatis `<script>` XML 이스케이프(2026-07-12 부팅 크래시)**: 어노테이션 SQL의 `<script>` 블록 안에 이스케이프 안 된 `<`/`<=` 비교연산이 있으면 SAX 파싱 실패 → 매퍼 빈 생성 실패 → **부팅 크래시 루프 → 헬스 게이트 롤백**. compileJava·일반 단위테스트로는 안 잡힘. `<=`로 이스케이프하거나 비교를 뒤집어라(`now() > a.deadline`). 회귀 가드 = `MapperAnnotationParseTest`(전 @Mapper 어노테이션 SQL 파싱, DB 불필요).
|
||||||
|
- **배포 로그 판독**: deploy_server journal의 "ERROR ... 실패: [deploy-kintex] 프론트 빌드 시작"은 stderr 오기록으로 **성공 배포에도 찍힘** — 성패는 소요시간(실패≈12s/성공≈3min)과 jar mtime·ActiveEnterTimestamp로 판정.
|
||||||
- deploy_server 웹훅 로그는 실패 stderr를 잘라 기록 — 실제 에러는 서버 `/opt/kintex/src`에서 빌드 재현으로 확보.
|
- deploy_server 웹훅 로그는 실패 stderr를 잘라 기록 — 실제 에러는 서버 `/opt/kintex/src`에서 빌드 재현으로 확보.
|
||||||
- **2FA 운영 게이트**: admin 등 필수 역할은 최초 로그인 시 `OTP_ENROLL`(웹 UI에서 QR 등록 필요). 운영 배포 전 `KINTEX_OTP_ENC_KEY`(base64 32B) env 주입 필수(미주입 시 개발 파생키). 레거시 `/api/auth/login`은 secureLogin 위임 — OTP 대상은 401 OTP_REQUIRED(모바일은 2FA 플로우 채택 필요).
|
- **2FA 운영 게이트**: admin 등 필수 역할은 최초 로그인 시 `OTP_ENROLL`(웹 UI에서 QR 등록 필요). 운영 배포 전 `KINTEX_OTP_ENC_KEY`(base64 32B) env 주입 필수(미주입 시 개발 파생키). 레거시 `/api/auth/login`은 secureLogin 위임 — OTP 대상은 401 OTP_REQUIRED(모바일은 2FA 플로우 채택 필요).
|
||||||
- dev DB에 E2E 테스트 계정 `qa-e2e-visitor@kintex.zioinfo.co.kr`(VISITOR) 존재 — 운영 이관 시 제거.
|
- dev DB에 E2E 테스트 계정 `qa-e2e-visitor@kintex.zioinfo.co.kr`(VISITOR) 존재 — 운영 이관 시 제거.
|
||||||
@ -96,5 +98,6 @@
|
|||||||
| 2026-07-12 | **Stitch 전 화면 확보 완료(64/64)** — 서비스 회복 후 수확 패스 재실행: 잔여 39화면 전부 서버측 지연 생성 완료 상태(프로젝트 27→155 화면)로 재생성 0회·다운로드 43파일(보조 변형 5 포함: P4 3단계·P7 예매확인·M15 권종선택) 실패 0. `_workspace/stitch_gen/{harvest2.py,screen_titles.txt,harvest_report.md}`. 다음: frontend/mobile dev 이식 + design.md 🔲미생성 마커 갱신(designer 경유) |
|
| 2026-07-12 | **Stitch 전 화면 확보 완료(64/64)** — 서비스 회복 후 수확 패스 재실행: 잔여 39화면 전부 서버측 지연 생성 완료 상태(프로젝트 27→155 화면)로 재생성 0회·다운로드 43파일(보조 변형 5 포함: P4 3단계·P7 예매확인·M15 권종선택) 실패 0. `_workspace/stitch_gen/{harvest2.py,screen_titles.txt,harvest_report.md}`. 다음: frontend/mobile dev 이식 + design.md 🔲미생성 마커 갱신(designer 경유) |
|
||||||
| 2026-07-12 | **실데이터·2FA 트랙 마감** — ①실데이터 집계 API 5패키지(analytics·dashboard·ops·admin·catalog + V13 인덱스) ②TOTP 2FA 완결(SecretCipher AES-256-GCM·V12·OtpChallengePanel/OtpSetupPage·OTP_ENFORCE env) ③공통 업무기능 SCR-39~48 화면(`screens/work/`) 배선 ④M2 부스 겹침 검사(BOOTH_OVERLAP·compliance-v1.1) + 단위테스트 3종. 검증: backend compileJava+test·frontend tsc 통과, QA PASS(blocker/major 0, minor 2=RIGGING_RANGE semantics 소유자 결정 대기·운영 KINTEX_OTP_ENC_KEY 주입 게이트 — `_workspace/09_qa_track_close.md`). 계약 갭 기록: `_workspace/{07_work_api_gaps,08_m2m5_contract_changes}.md` |
|
| 2026-07-12 | **실데이터·2FA 트랙 마감** — ①실데이터 집계 API 5패키지(analytics·dashboard·ops·admin·catalog + V13 인덱스) ②TOTP 2FA 완결(SecretCipher AES-256-GCM·V12·OtpChallengePanel/OtpSetupPage·OTP_ENFORCE env) ③공통 업무기능 SCR-39~48 화면(`screens/work/`) 배선 ④M2 부스 겹침 검사(BOOTH_OVERLAP·compliance-v1.1) + 단위테스트 3종. 검증: backend compileJava+test·frontend tsc 통과, QA PASS(blocker/major 0, minor 2=RIGGING_RANGE semantics 소유자 결정 대기·운영 KINTEX_OTP_ENC_KEY 주입 게이트 — `_workspace/09_qa_track_close.md`). 계약 갭 기록: `_workspace/{07_work_api_gaps,08_m2m5_contract_changes}.md` |
|
||||||
| 2026-07-12 | **Stitch 확보 화면 이식 완료(웹 24 + 공개 7단계플로우 + 모바일 1)** — 에이전트 7팀 병렬(폴더 소유권 분리·공유파일 통합자 단일 배선). ①도메인: SCR-22/23/25(서류·신고서류·도크, M6/M8 샘플)·SCR-26/27/29/38(옥션 3종+수주 대시보드, M15 샘플, 봉인입찰 마스킹·등록업체만 응찰 UI)·SCR-30/32/33/34(관람객·리드·EDM·스폰서십, M10/M12 샘플, PII 마스킹)·SCR-35/36/37(CMS 3종, M17 샘플) ②관리자: **A5 감사로그·A6 시스템설정 = 라이브 백엔드 실배선**(PageResponse·시크릿 마스킹), A8 룰셋(정적 v1.1 스냅샷)·A9 테넌트(샘플) ③공개: P1~P7 자체 PublicShell(비인증 /public/*·/tickets/*, PG위임 고지) ④모바일: M15 티켓지갑(mobile/app/tickets, QR placeholder) ⑤통합: App 라우트 18종+AppShell "도메인 모듈" 네비. tsc -b EXIT 0·QA PASS(minor 2 코스메틱: kx-field 접두, ★/✓ 글리프). 각 팀 API 계약 초안 = `_workspace/port_*.md`. 커밋 becbc55 |
|
| 2026-07-12 | **Stitch 확보 화면 이식 완료(웹 24 + 공개 7단계플로우 + 모바일 1)** — 에이전트 7팀 병렬(폴더 소유권 분리·공유파일 통합자 단일 배선). ①도메인: SCR-22/23/25(서류·신고서류·도크, M6/M8 샘플)·SCR-26/27/29/38(옥션 3종+수주 대시보드, M15 샘플, 봉인입찰 마스킹·등록업체만 응찰 UI)·SCR-30/32/33/34(관람객·리드·EDM·스폰서십, M10/M12 샘플, PII 마스킹)·SCR-35/36/37(CMS 3종, M17 샘플) ②관리자: **A5 감사로그·A6 시스템설정 = 라이브 백엔드 실배선**(PageResponse·시크릿 마스킹), A8 룰셋(정적 v1.1 스냅샷)·A9 테넌트(샘플) ③공개: P1~P7 자체 PublicShell(비인증 /public/*·/tickets/*, PG위임 고지) ④모바일: M15 티켓지갑(mobile/app/tickets, QR placeholder) ⑤통합: App 라우트 18종+AppShell "도메인 모듈" 네비. tsc -b EXIT 0·QA PASS(minor 2 코스메틱: kx-field 접두, ★/✓ 글리프). 각 팀 API 계약 초안 = `_workspace/port_*.md`. 커밋 becbc55 |
|
||||||
|
| 2026-07-12 | **3차 웨이브(백로그 3트랙) + 배포 크래시 근본수정** — ①크래시 규명: c6f3a8d jar가 MyBatis `<script>` XML 위반(AuctionMapper `deadline < now()`·VisitorMapper `<=`)으로 부팅 크래시 루프→롤백. 매퍼 교정 + `MapperAnnotationParseTest` 회귀 가드 + `checkin.css` 누락 작성(서버 프론트 빌드 실패 원인) ②M16 BI 전역: `GET /api/analytics/overview`(행사 간 매출·홀 가동률 추이·리텐션·선형회귀 예측, 홀매니저 전용, 마이그레이션 無) + /analytics 인라인 섹션 ③M18 관리자: 사용자·역할/권한·공통코드·메뉴 CRUD 화면 4종(백엔드 system 라이브 소비, 신규 백엔드 0)+`AdminGuard`(전 /admin/* 라우트 가드 배선)+랜딩 모듈 바로가기. A8 룰셋은 기왕 실배선 확인 ④SCR-15 일정: V23(category·est_visitors 백필 1,098건)+홀 가동률/캘린더 API 2종+실 전환 ⑤QA minor: ✓글리프 SVG화(kx-field·★는 기왕 해소). 검증: compileJava·test(매퍼 파싱 포함)·tsc -b --force·vite build 전부 EXIT 0. ⚠AdminGuard 판정 한계: 전역 role_code 미노출로 hallManager 기준 — 정확 판정은 AuthUser.roleCode 노출 필요(후속) |
|
||||||
| 2026-07-12 | **도메인 2차 웨이브(중단분 복구·마감)** — 중단됐던 미커밋 웨이브 완성: ①M15 옥션 실배선 고도화(`screens/auction/auctionApi.ts` 정본 + 스코어링·견적계산 단위테스트) ②M10 체크인 데스크(SCR-31, `/visitors/checkin` 라우트·네비 신규 배선, QR 토큰·checkin_at·리드 exhibitor_id/동의/메모 확장, 체크인/검색/통계/배지재발급/리드생성/CSV export API — 마스킹·RBAC·@Audited) + 룰기반 리드 스코어링 엔진(`visitor/scoring`) ③M17 CMS 버전 이력·미디어 라이브러리·공개 CMS API(`/api/public/cms`)·HtmlSanitizer ④단위테스트 7종. **★Flyway 수복: 이미 배포된 V17/V19에 append돼 있던 확장 SQL을 V21로 분리·원복**(체크섬 불일치 배포실패 예방). 검증: compileJava·test·tsc -b 전부 EXIT 0. ⚠고아 초안 `src/api/auction{Api,Types}.ts` 2파일은 화면 미사용(정본은 screens/auction 로컬) — 삭제는 소유자 확인 필요 규칙상 **미커밋·보류** |
|
| 2026-07-12 | **도메인 2차 웨이브(중단분 복구·마감)** — 중단됐던 미커밋 웨이브 완성: ①M15 옥션 실배선 고도화(`screens/auction/auctionApi.ts` 정본 + 스코어링·견적계산 단위테스트) ②M10 체크인 데스크(SCR-31, `/visitors/checkin` 라우트·네비 신규 배선, QR 토큰·checkin_at·리드 exhibitor_id/동의/메모 확장, 체크인/검색/통계/배지재발급/리드생성/CSV export API — 마스킹·RBAC·@Audited) + 룰기반 리드 스코어링 엔진(`visitor/scoring`) ③M17 CMS 버전 이력·미디어 라이브러리·공개 CMS API(`/api/public/cms`)·HtmlSanitizer ④단위테스트 7종. **★Flyway 수복: 이미 배포된 V17/V19에 append돼 있던 확장 SQL을 V21로 분리·원복**(체크섬 불일치 배포실패 예방). 검증: compileJava·test·tsc -b 전부 EXIT 0. ⚠고아 초안 `src/api/auction{Api,Types}.ts` 2파일은 화면 미사용(정본은 screens/auction 로컬) — 삭제는 소유자 확인 필요 규칙상 **미커밋·보류** |
|
||||||
| 2026-07-12 | **배포·핫픽스 3건 + 풀 E2E 라이브 검증 완료** — 1차 push는 서버 `tsc -b` 실패로 프론트 미배포(로컬 `--noEmit`과 설정 차이): ①toast 미렌더+TS6133·recharts formatter 타입 수정(8509787) ②레거시 `/api/auth/login`이 잠금·OTP 강제를 우회하던 보안 구멍 → secureLogin 위임(a657f95) ③**PG 별칭 소문자 폴딩으로 Map 매퍼 22파일·191별칭 전부 런타임 null이던 결함 → `AS "alias"` 일괄 교정(89bf2ce)** — login-slides imageUrl null(01:08부터)·secure 로그인 전멸이 이것. E2E 확인: secure 로그인 admin=OTP_ENROLL+챌린지(시크릿 미누출)·레거시=401 차단·visitor 가입→로그인→카탈로그 78건 실데이터·admin API 403 RBAC·Flyway V12/V13 적용·신규 번들 라이브. design.md v2.1.1(Stitch 84화면 상태 정합·designer 경유) |
|
| 2026-07-12 | **배포·핫픽스 3건 + 풀 E2E 라이브 검증 완료** — 1차 push는 서버 `tsc -b` 실패로 프론트 미배포(로컬 `--noEmit`과 설정 차이): ①toast 미렌더+TS6133·recharts formatter 타입 수정(8509787) ②레거시 `/api/auth/login`이 잠금·OTP 강제를 우회하던 보안 구멍 → secureLogin 위임(a657f95) ③**PG 별칭 소문자 폴딩으로 Map 매퍼 22파일·191별칭 전부 런타임 null이던 결함 → `AS "alias"` 일괄 교정(89bf2ce)** — login-slides imageUrl null(01:08부터)·secure 로그인 전멸이 이것. E2E 확인: secure 로그인 admin=OTP_ENROLL+챌린지(시크릿 미누출)·레거시=401 차단·visitor 가입→로그인→카탈로그 78건 실데이터·admin API 403 RBAC·Flyway V12/V13 적용·신규 번들 라이브. design.md v2.1.1(Stitch 84화면 상태 정합·designer 경유) |
|
||||||
|
|||||||
@ -52,4 +52,106 @@ public interface AnalyticsMapper {
|
|||||||
WHERE event_id = #{eventId} AND quote->>'total' ~ '^[0-9]+(\\.[0-9]+)?$'
|
WHERE event_id = #{eventId} AND quote->>'total' ~ '^[0-9]+(\\.[0-9]+)?$'
|
||||||
""")
|
""")
|
||||||
Double findUtilityRevenue(@Param("eventId") String eventId);
|
Double findUtilityRevenue(@Param("eventId") String eventId);
|
||||||
|
|
||||||
|
// ── 테넌트 전역(행사 횡단) BI 집계 — M16 잔여 갭 ──────────────────────────
|
||||||
|
|
||||||
|
/** 전체 전시홀 수(가동률 분모 산정용). */
|
||||||
|
@Select("SELECT count(*) FROM hall")
|
||||||
|
long countHalls();
|
||||||
|
|
||||||
|
/** 전체 개최 행사 수(기간 무관, KPI용). */
|
||||||
|
@Select("SELECT count(*) FROM event")
|
||||||
|
long countEvents();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 월별 개최 행사-일수(booked hall-days 근사) — 최근 N개월.
|
||||||
|
* 행사 기간(end-start+1, 최소 1일)을 시작월에 합산. 가동률은 서비스가 홀수·그달 일수로 환산.
|
||||||
|
*/
|
||||||
|
@Select("""
|
||||||
|
SELECT to_char(e.start_date, 'YYYY-MM') AS "period",
|
||||||
|
COALESCE(sum(GREATEST((e.end_date - e.start_date) + 1, 1)), 0) AS "bookedDays"
|
||||||
|
FROM event e
|
||||||
|
WHERE e.start_date IS NOT NULL
|
||||||
|
AND e.start_date >= (date_trunc('month', now()) - make_interval(months => #{months}))
|
||||||
|
GROUP BY to_char(e.start_date, 'YYYY-MM')
|
||||||
|
ORDER BY 1
|
||||||
|
""")
|
||||||
|
List<Map<String, Object>> findMonthlyEventDays(@Param("months") int months);
|
||||||
|
|
||||||
|
/** 부스가 배치된 행사의 판매면적·부스 수 랭킹(매출 상위 N). 임대 매출은 서비스가 요율로 환산. */
|
||||||
|
@Select("""
|
||||||
|
SELECT l.event_id AS "eventId",
|
||||||
|
e.name AS "eventName",
|
||||||
|
COALESCE(sum(ST_Area(b.geom)), 0) AS "areaM2",
|
||||||
|
count(b.id) AS "boothCount"
|
||||||
|
FROM booth b
|
||||||
|
JOIN layout l ON l.id = b.layout_id
|
||||||
|
JOIN event e ON e.id = l.event_id
|
||||||
|
GROUP BY l.event_id, e.name
|
||||||
|
HAVING count(b.id) > 0
|
||||||
|
ORDER BY sum(ST_Area(b.geom)) DESC
|
||||||
|
LIMIT #{limit}
|
||||||
|
""")
|
||||||
|
List<Map<String, Object>> findEventAreaRanking(@Param("limit") int limit);
|
||||||
|
|
||||||
|
/** 행사별 유틸리티 매출 합(매출 비교 조립용, N+1 회피). */
|
||||||
|
@Select("""
|
||||||
|
SELECT event_id AS "eventId",
|
||||||
|
COALESCE(sum((quote->>'total')::numeric), 0) AS "utility"
|
||||||
|
FROM utility_order
|
||||||
|
WHERE quote->>'total' ~ '^[0-9]+(\\.[0-9]+)?$'
|
||||||
|
GROUP BY event_id
|
||||||
|
""")
|
||||||
|
List<Map<String, Object>> findUtilityRevenueByEvent();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 참가사 리텐션 요약 — 부스 배정사(booth.assigned_company_name) 기준 고유 참가사 수와
|
||||||
|
* 2개 이상 행사에 참가한 재참가사 수.
|
||||||
|
*/
|
||||||
|
@Select("""
|
||||||
|
SELECT COUNT(*) AS "totalCompanies",
|
||||||
|
COUNT(*) FILTER (WHERE ec > 1) AS "returningCompanies"
|
||||||
|
FROM (
|
||||||
|
SELECT lower(btrim(b.assigned_company_name)) AS company,
|
||||||
|
count(DISTINCT l.event_id) AS ec
|
||||||
|
FROM booth b
|
||||||
|
JOIN layout l ON l.id = b.layout_id
|
||||||
|
WHERE b.assigned_company_name IS NOT NULL AND btrim(b.assigned_company_name) <> ''
|
||||||
|
GROUP BY lower(btrim(b.assigned_company_name))
|
||||||
|
) t
|
||||||
|
""")
|
||||||
|
Map<String, Object> findRetentionSummary();
|
||||||
|
|
||||||
|
/** 연도별 리텐션 — 해당 연도 참가사 중 이전 연도에도 참가한 참가사 수. */
|
||||||
|
@Select("""
|
||||||
|
WITH ce AS (
|
||||||
|
SELECT DISTINCT lower(btrim(b.assigned_company_name)) AS company,
|
||||||
|
EXTRACT(YEAR FROM e.start_date)::int AS yr
|
||||||
|
FROM booth b
|
||||||
|
JOIN layout l ON l.id = b.layout_id
|
||||||
|
JOIN event e ON e.id = l.event_id
|
||||||
|
WHERE b.assigned_company_name IS NOT NULL AND btrim(b.assigned_company_name) <> ''
|
||||||
|
AND e.start_date IS NOT NULL
|
||||||
|
)
|
||||||
|
SELECT c.yr::text AS "period",
|
||||||
|
count(*) AS "companies",
|
||||||
|
count(*) FILTER (WHERE EXISTS (
|
||||||
|
SELECT 1 FROM ce p WHERE p.company = c.company AND p.yr < c.yr
|
||||||
|
)) AS "returningCompanies"
|
||||||
|
FROM ce c
|
||||||
|
GROUP BY c.yr
|
||||||
|
ORDER BY c.yr
|
||||||
|
""")
|
||||||
|
List<Map<String, Object>> findRetentionByYear();
|
||||||
|
|
||||||
|
/** 연도별 개최 행사 수(수요예측 추세 입력). */
|
||||||
|
@Select("""
|
||||||
|
SELECT EXTRACT(YEAR FROM start_date)::int AS "yr",
|
||||||
|
count(*) AS "eventCount"
|
||||||
|
FROM event
|
||||||
|
WHERE start_date IS NOT NULL
|
||||||
|
GROUP BY EXTRACT(YEAR FROM start_date)
|
||||||
|
ORDER BY 1
|
||||||
|
""")
|
||||||
|
List<Map<String, Object>> findYearlyEventCounts();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,43 @@
|
|||||||
|
package com.zioinfo.kintex.analytics;
|
||||||
|
|
||||||
|
import com.zioinfo.kintex.analytics.dto.AnalyticsOverviewDto;
|
||||||
|
import com.zioinfo.kintex.auth.EventAccessGuard;
|
||||||
|
import com.zioinfo.kintex.auth.KintexPrincipal;
|
||||||
|
import com.zioinfo.kintex.common.ApiResponse;
|
||||||
|
import com.zioinfo.kintex.common.error.ApiException;
|
||||||
|
import com.zioinfo.kintex.common.error.ErrorCode;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테넌트 전역(행사 횡단) 경영분석 BI (M16 잔여 갭 — SCR-13).
|
||||||
|
* 행사 간 매출 비교·홀 가동률 추이·참가사 리텐션·수요예측(추세). 홀매니저(킨텍스 내부) 전용 열람.
|
||||||
|
* 행사 스코프 집계는 {@link AnalyticsController}(/api/events/{id}/analytics)가 담당한다.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/analytics")
|
||||||
|
public class AnalyticsOverviewController {
|
||||||
|
|
||||||
|
private final AnalyticsService service;
|
||||||
|
private final EventAccessGuard guard;
|
||||||
|
|
||||||
|
public AnalyticsOverviewController(AnalyticsService service, EventAccessGuard guard) {
|
||||||
|
this.service = service;
|
||||||
|
this.guard = guard;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET — 전역 KPI·행사 간 매출 비교·홀 가동률 추이·리텐션·수요예측. 홀매니저만 허용. */
|
||||||
|
@GetMapping("/overview")
|
||||||
|
public ApiResponse<AnalyticsOverviewDto> overview(
|
||||||
|
@AuthenticationPrincipal KintexPrincipal principal,
|
||||||
|
@RequestParam(required = false, defaultValue = "5") int years) {
|
||||||
|
guard.require(principal);
|
||||||
|
if (!principal.hallManager()) {
|
||||||
|
throw new ApiException(ErrorCode.FORBIDDEN);
|
||||||
|
}
|
||||||
|
return ApiResponse.ok(service.getOverview(years));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,9 +1,12 @@
|
|||||||
package com.zioinfo.kintex.analytics;
|
package com.zioinfo.kintex.analytics;
|
||||||
|
|
||||||
import com.zioinfo.kintex.analytics.dto.AnalyticsDto;
|
import com.zioinfo.kintex.analytics.dto.AnalyticsDto;
|
||||||
|
import com.zioinfo.kintex.analytics.dto.AnalyticsOverviewDto;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.YearMonth;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@ -63,6 +66,157 @@ public class AnalyticsService {
|
|||||||
return new AnalyticsDto(kpis, trend, sectors, pnl, exhibitorPerf);
|
return new AnalyticsDto(kpis, trend, sectors, pnl, exhibitorPerf);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 테넌트 전역(행사 횡단) BI — M16 잔여 갭 ───────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테넌트 전역 경영분석 — 행사 간 매출 비교·홀 가동률 추이(월별)·참가사 리텐션·수요예측(추세).
|
||||||
|
* years(1..20)는 가동률 추이 창(=years×12개월)과 수요예측 표시 기간을 결정한다.
|
||||||
|
* 모든 지표는 기존 테이블 read-only 집계이며 AI 호출은 없다.
|
||||||
|
*/
|
||||||
|
public AnalyticsOverviewDto getOverview(int years) {
|
||||||
|
int y = Math.max(1, Math.min(20, years));
|
||||||
|
int months = y * 12;
|
||||||
|
|
||||||
|
Double rate = mapper.findRentalRatePerM2();
|
||||||
|
double ratePerM2 = rate == null ? 0.0 : rate;
|
||||||
|
long hallCount = mapper.countHalls();
|
||||||
|
long totalEvents = mapper.countEvents();
|
||||||
|
|
||||||
|
// 행사 간 매출 비교(상위 8) — 임대(면적×요율)+유틸리티.
|
||||||
|
Map<String, Long> utilByEvent = new HashMap<>();
|
||||||
|
List<Map<String, Object>> utilRows = mapper.findUtilityRevenueByEvent();
|
||||||
|
if (utilRows != null) {
|
||||||
|
for (Map<String, Object> r : utilRows) {
|
||||||
|
utilByEvent.put(str(r.get("eventId")), Math.round(dbl(r.get("utility"))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
List<AnalyticsOverviewDto.EventRevenue> eventComparison = new ArrayList<>();
|
||||||
|
List<Map<String, Object>> ranking = mapper.findEventAreaRanking(8);
|
||||||
|
if (ranking != null) {
|
||||||
|
for (Map<String, Object> r : ranking) {
|
||||||
|
String eventId = str(r.get("eventId"));
|
||||||
|
long area = Math.round(dbl(r.get("areaM2")));
|
||||||
|
long rental = Math.round(area * ratePerM2);
|
||||||
|
long revenue = rental + utilByEvent.getOrDefault(eventId, 0L);
|
||||||
|
eventComparison.add(new AnalyticsOverviewDto.EventRevenue(
|
||||||
|
eventId, str(r.get("eventName")), revenue,
|
||||||
|
lng(r.get("boothCount")), area, grade(revenue)));
|
||||||
|
}
|
||||||
|
// 매출(임대+유틸) 기준 재정렬 — 랭킹은 면적순이라 유틸 반영 후 순위가 바뀔 수 있다.
|
||||||
|
eventComparison.sort((a, b) -> Long.compare(b.revenue(), a.revenue()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 홀 가동률 추이(월별) — bookedDays / (홀수 × 그달 일수) × 100.
|
||||||
|
List<AnalyticsOverviewDto.OccupancyPoint> occupancy = new ArrayList<>();
|
||||||
|
List<Map<String, Object>> occRows = mapper.findMonthlyEventDays(months);
|
||||||
|
if (occRows != null) {
|
||||||
|
for (Map<String, Object> r : occRows) {
|
||||||
|
String period = str(r.get("period"));
|
||||||
|
long booked = lng(r.get("bookedDays"));
|
||||||
|
long capacity = hallCount * daysInMonth(period);
|
||||||
|
double pct = capacity > 0 ? (booked * 100.0) / capacity : 0.0;
|
||||||
|
occupancy.add(new AnalyticsOverviewDto.OccupancyPoint(
|
||||||
|
period, round1(pct), booked, capacity));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 참가사 리텐션(재참가).
|
||||||
|
Map<String, Object> retSum = mapper.findRetentionSummary();
|
||||||
|
long totalCompanies = lng(retSum == null ? null : retSum.get("totalCompanies"));
|
||||||
|
long returningCompanies = lng(retSum == null ? null : retSum.get("returningCompanies"));
|
||||||
|
double retentionPct = totalCompanies > 0
|
||||||
|
? round1((returningCompanies * 100.0) / totalCompanies) : 0.0;
|
||||||
|
List<AnalyticsOverviewDto.RetentionYear> retByYear = new ArrayList<>();
|
||||||
|
List<Map<String, Object>> retRows = mapper.findRetentionByYear();
|
||||||
|
if (retRows != null) {
|
||||||
|
for (Map<String, Object> r : retRows) {
|
||||||
|
long companies = lng(r.get("companies"));
|
||||||
|
long returning = lng(r.get("returningCompanies"));
|
||||||
|
double pct = companies > 0 ? round1((returning * 100.0) / companies) : 0.0;
|
||||||
|
retByYear.add(new AnalyticsOverviewDto.RetentionYear(
|
||||||
|
str(r.get("period")), companies, returning, pct));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AnalyticsOverviewDto.Retention retention = new AnalyticsOverviewDto.Retention(
|
||||||
|
totalCompanies, returningCompanies, retentionPct, retByYear);
|
||||||
|
|
||||||
|
// 수요예측(단순 추세) — 연도별 개최 행사 수 실측 + 최소자승 2년 투영.
|
||||||
|
AnalyticsOverviewDto.Forecast forecast = buildForecast(mapper.findYearlyEventCounts(), y);
|
||||||
|
|
||||||
|
// 요약 KPI.
|
||||||
|
List<AnalyticsOverviewDto.Kpi> kpis = new ArrayList<>();
|
||||||
|
kpis.add(new AnalyticsOverviewDto.Kpi("누적 개최 행사", String.valueOf(totalEvents), "전 기간"));
|
||||||
|
kpis.add(new AnalyticsOverviewDto.Kpi("참가사 재참가율", retentionPct + "%",
|
||||||
|
returningCompanies + "/" + totalCompanies + " 재참가"));
|
||||||
|
double avgOcc = occupancy.stream().mapToDouble(AnalyticsOverviewDto.OccupancyPoint::occupancyPercent)
|
||||||
|
.average().orElse(0.0);
|
||||||
|
kpis.add(new AnalyticsOverviewDto.Kpi("평균 홀 가동률", round1(avgOcc) + "%", "최근 " + y + "년"));
|
||||||
|
if (!forecast.projection().isEmpty()) {
|
||||||
|
AnalyticsOverviewDto.ForecastPoint next = forecast.projection().get(0);
|
||||||
|
kpis.add(new AnalyticsOverviewDto.Kpi("내년 개최 예측",
|
||||||
|
String.valueOf(Math.round(next.value())) + "건", next.period() + " 추세 투영"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AnalyticsOverviewDto(kpis, eventComparison, occupancy, retention, forecast);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 연도별 실측 + 최소자승 선형 추세로 향후 2년 투영. 표시 기간은 최근 {@code years}년으로 제한. */
|
||||||
|
private static AnalyticsOverviewDto.Forecast buildForecast(List<Map<String, Object>> yearlyRows, int years) {
|
||||||
|
List<int[]> series = new ArrayList<>(); // [year, count]
|
||||||
|
if (yearlyRows != null) {
|
||||||
|
for (Map<String, Object> r : yearlyRows) {
|
||||||
|
series.add(new int[]{(int) lng(r.get("yr")), (int) lng(r.get("eventCount"))});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<AnalyticsOverviewDto.ForecastPoint> history = new ArrayList<>();
|
||||||
|
List<AnalyticsOverviewDto.ForecastPoint> projection = new ArrayList<>();
|
||||||
|
if (series.isEmpty()) {
|
||||||
|
return new AnalyticsOverviewDto.Forecast(
|
||||||
|
"yearlyEventCount", "linear-least-squares", history, projection);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 최소자승(전체 실측 사용) — x=연도 인덱스.
|
||||||
|
int n = series.size();
|
||||||
|
double sx = 0, sy = 0, sxy = 0, sxx = 0;
|
||||||
|
for (int i = 0; i < n; i++) {
|
||||||
|
double x = i, yv = series.get(i)[1];
|
||||||
|
sx += x; sy += yv; sxy += x * yv; sxx += x * x;
|
||||||
|
}
|
||||||
|
double denom = n * sxx - sx * sx;
|
||||||
|
double slope = denom != 0 ? (n * sxy - sx * sy) / denom : 0.0;
|
||||||
|
double intercept = (sy - slope * sx) / n;
|
||||||
|
|
||||||
|
// 실측 히스토리는 최근 years년만 노출.
|
||||||
|
int fromIdx = Math.max(0, n - years);
|
||||||
|
for (int i = fromIdx; i < n; i++) {
|
||||||
|
history.add(new AnalyticsOverviewDto.ForecastPoint(
|
||||||
|
String.valueOf(series.get(i)[0]), series.get(i)[1], false));
|
||||||
|
}
|
||||||
|
// 향후 2년 투영(음수 방지).
|
||||||
|
int lastYear = series.get(n - 1)[0];
|
||||||
|
for (int k = 1; k <= 2; k++) {
|
||||||
|
double predicted = intercept + slope * (n - 1 + k);
|
||||||
|
projection.add(new AnalyticsOverviewDto.ForecastPoint(
|
||||||
|
String.valueOf(lastYear + k), round1(Math.max(0.0, predicted)), true));
|
||||||
|
}
|
||||||
|
return new AnalyticsOverviewDto.Forecast(
|
||||||
|
"yearlyEventCount", "linear-least-squares", history, projection);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "YYYY-MM" → 그 달의 일수. 파싱 실패 시 30(보수적). */
|
||||||
|
private static int daysInMonth(String period) {
|
||||||
|
try {
|
||||||
|
return YearMonth.parse(period).lengthOfMonth();
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
return 30;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double round1(double v) {
|
||||||
|
return Math.round(v * 10.0) / 10.0;
|
||||||
|
}
|
||||||
|
|
||||||
private static int periodDays(String period) {
|
private static int periodDays(String period) {
|
||||||
if (period == null) {
|
if (period == null) {
|
||||||
return 0;
|
return 0;
|
||||||
|
|||||||
@ -0,0 +1,54 @@
|
|||||||
|
package com.zioinfo.kintex.analytics.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테넌트 전역(행사 횡단) 경영분석 BI (M16 / SCR-13 잔여 갭).
|
||||||
|
* 행사 스코프 {@link AnalyticsDto}와 별개로, 행사 간 매출 비교·홀 가동률 추이·참가사 리텐션·
|
||||||
|
* 수요예측(단순 추세 기반, AI 호출 없음)을 조립한다. 집계원은 기존 테이블(event·hall·booth·
|
||||||
|
* layout·utility_order·master_data) read-only. 원천 부재 지표는 빈배열/0 으로 정직 반환한다.
|
||||||
|
* 열람 권한 = 홀매니저(킨텍스 내부, 전 행사 열람).
|
||||||
|
*/
|
||||||
|
public record AnalyticsOverviewDto(
|
||||||
|
List<Kpi> kpis,
|
||||||
|
List<EventRevenue> eventComparison,
|
||||||
|
List<OccupancyPoint> hallOccupancy,
|
||||||
|
Retention retention,
|
||||||
|
Forecast forecast
|
||||||
|
) {
|
||||||
|
/** 요약 KPI(라벨·값·부가). */
|
||||||
|
public record Kpi(String label, String value, String hint) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 행사 간 매출 비교 — 부스가 배치된 행사만(임대=면적×요율, 유틸=견적 합). */
|
||||||
|
public record EventRevenue(
|
||||||
|
String eventId, String eventName, long revenue,
|
||||||
|
long boothCount, long areaM2, String grade) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 홀 가동률 추이(월별). occupancyPercent = bookedDays / (홀수 × 그달 일수) × 100. */
|
||||||
|
public record OccupancyPoint(
|
||||||
|
String period, double occupancyPercent, long bookedDays, long capacityDays) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 참가사 리텐션(재참가) 요약 + 연도별 추이. */
|
||||||
|
public record Retention(
|
||||||
|
long totalCompanies, long returningCompanies, double retentionPercent,
|
||||||
|
List<RetentionYear> byYear) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 연도별 리텐션 — 해당 연도 참가사 중 이전 연도에도 참가한 비율. */
|
||||||
|
public record RetentionYear(
|
||||||
|
String period, long companies, long returningCompanies, double retentionPercent) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 수요예측(단순 추세) — 연도별 개최 행사 수 실측 + 최소자승 추세 투영. */
|
||||||
|
public record Forecast(
|
||||||
|
String metric, String method,
|
||||||
|
List<ForecastPoint> history, List<ForecastPoint> projection) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 예측 포인트 — projected=true 이면 추세 투영값. */
|
||||||
|
public record ForecastPoint(String period, double value, boolean projected) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -32,7 +32,7 @@ public interface AuctionMapper {
|
|||||||
a.round,
|
a.round,
|
||||||
to_char(a.deadline AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "deadline",
|
to_char(a.deadline AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "deadline",
|
||||||
CEIL(EXTRACT(EPOCH FROM (a.deadline - now())) / 86400.0)::int AS "dday",
|
CEIL(EXTRACT(EPOCH FROM (a.deadline - now())) / 86400.0)::int AS "dday",
|
||||||
(a.deadline < now()) AS "closed",
|
(now() > a.deadline) AS "closed",
|
||||||
a.materials,
|
a.materials,
|
||||||
a.accent,
|
a.accent,
|
||||||
a.material_package_id AS "materialPackageId",
|
a.material_package_id AS "materialPackageId",
|
||||||
|
|||||||
@ -2,7 +2,9 @@ package com.zioinfo.kintex.catalog;
|
|||||||
|
|
||||||
import com.zioinfo.kintex.auth.EventAccessGuard;
|
import com.zioinfo.kintex.auth.EventAccessGuard;
|
||||||
import com.zioinfo.kintex.auth.KintexPrincipal;
|
import com.zioinfo.kintex.auth.KintexPrincipal;
|
||||||
|
import com.zioinfo.kintex.catalog.dto.CalendarDayDto;
|
||||||
import com.zioinfo.kintex.catalog.dto.ExhibitionDto;
|
import com.zioinfo.kintex.catalog.dto.ExhibitionDto;
|
||||||
|
import com.zioinfo.kintex.catalog.dto.HallUtilizationDto;
|
||||||
import com.zioinfo.kintex.common.ApiResponse;
|
import com.zioinfo.kintex.common.ApiResponse;
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
@ -10,6 +12,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
|||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -32,16 +35,45 @@ public class EventCatalogController {
|
|||||||
this.guard = guard;
|
this.guard = guard;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GET /api/events?year=&status=ongoing|upcoming|ended — 필터 없으면 전체(시작일 내림차순). */
|
/** GET /api/events?year=&status=ongoing|upcoming|ended&category= — 필터 없으면 전체(시작일 내림차순). */
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public ApiResponse<List<ExhibitionDto>> list(@AuthenticationPrincipal KintexPrincipal principal,
|
public ApiResponse<List<ExhibitionDto>> list(@AuthenticationPrincipal KintexPrincipal principal,
|
||||||
@RequestParam(required = false) String year,
|
@RequestParam(required = false) String year,
|
||||||
@RequestParam(required = false) String status,
|
@RequestParam(required = false) String status,
|
||||||
|
@RequestParam(required = false) String category,
|
||||||
@RequestParam(required = false, defaultValue = "0") int page,
|
@RequestParam(required = false, defaultValue = "0") int page,
|
||||||
@RequestParam(required = false, defaultValue = "2000") int size) {
|
@RequestParam(required = false, defaultValue = "2000") int size) {
|
||||||
guard.require(principal);
|
guard.require(principal);
|
||||||
int limit = Math.min(Math.max(size, 1), MAX_LIMIT);
|
int limit = Math.min(Math.max(size, 1), MAX_LIMIT);
|
||||||
int offset = Math.max(page, 0) * limit;
|
int offset = Math.max(page, 0) * limit;
|
||||||
return ApiResponse.ok(service.list(year, status, limit, offset));
|
return ApiResponse.ok(service.list(year, status, category, limit, offset));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/events/hall-utilization?year=&month= — 지정 월 홀 그룹 가동률(실 event 기반 집계).
|
||||||
|
* year/month 미지정 시 현재 연/월. 인증 필수(카탈로그와 동일).
|
||||||
|
*/
|
||||||
|
@GetMapping("/hall-utilization")
|
||||||
|
public ApiResponse<List<HallUtilizationDto>> hallUtilization(@AuthenticationPrincipal KintexPrincipal principal,
|
||||||
|
@RequestParam(required = false, defaultValue = "0") int year,
|
||||||
|
@RequestParam(required = false, defaultValue = "0") int month) {
|
||||||
|
guard.require(principal);
|
||||||
|
int y = year > 0 ? year : LocalDate.now().getYear();
|
||||||
|
int m = month > 0 ? month : LocalDate.now().getMonthValue();
|
||||||
|
return ApiResponse.ok(service.hallUtilization(y, m));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/events/calendar?year=&month= — 지정 월 캘린더 점유(행사 진행 날짜·건수).
|
||||||
|
* year/month 미지정 시 현재 연/월. 인증 필수.
|
||||||
|
*/
|
||||||
|
@GetMapping("/calendar")
|
||||||
|
public ApiResponse<List<CalendarDayDto>> calendar(@AuthenticationPrincipal KintexPrincipal principal,
|
||||||
|
@RequestParam(required = false, defaultValue = "0") int year,
|
||||||
|
@RequestParam(required = false, defaultValue = "0") int month) {
|
||||||
|
guard.require(principal);
|
||||||
|
int y = year > 0 ? year : LocalDate.now().getYear();
|
||||||
|
int m = month > 0 ? month : LocalDate.now().getMonthValue();
|
||||||
|
return ApiResponse.ok(service.calendar(y, m));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,16 +9,18 @@ import java.util.Map;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 전시 일정 카탈로그 매퍼 — {@code event} 테이블 read-only 조회(V10 시드 1,098건).
|
* 전시 일정 카탈로그 매퍼 — {@code event} 테이블 read-only 조회(V10 시드 1,098건).
|
||||||
* <p>hallLabel 은 {@code hall_assignment}(대표 홀 우선) → {@code hall.label} 조인으로 enrich(없으면 null).
|
* <p>hallLabel 은 {@code hall_assignment}(대표 홀 우선) → {@code hall.label} 조인 우선, 없으면 V23 백필
|
||||||
* category·estVisitors 는 원천 컬럼 부재 → 항상 null(후속 트랙 enrich). 마이그레이션을 변경하지 않는다.
|
* {@code event.hall_label}(크롤 배정 홀 문자열)로 폴백. category·estVisitors 는 V23 백필 컬럼을 그대로 반환.
|
||||||
*
|
*
|
||||||
* <p>status 필터는 DB 원문(active/ended)이 계약 값(ongoing|upcoming|ended)과 다르므로 <b>날짜 기준</b>으로 판정한다
|
* <p>status 필터는 DB 원문(active/ended)이 계약 값(ongoing|upcoming|ended)과 다르므로 <b>날짜 기준</b>으로 판정한다
|
||||||
* — 프론트의 재계산 규칙과 동일: ended=end<오늘, ongoing=시작≤오늘≤종료, upcoming=시작>오늘.
|
* — 프론트의 재계산 규칙과 동일: ended=end<오늘, ongoing=시작≤오늘≤종료, upcoming=시작>오늘.
|
||||||
|
*
|
||||||
|
* <p>★ PG 소문자 폴딩 회피: Map 반환 카멜케이스 별칭은 반드시 {@code AS "camelCase"} 쌍따옴표.
|
||||||
*/
|
*/
|
||||||
@Mapper
|
@Mapper
|
||||||
public interface EventCatalogMapper {
|
public interface EventCatalogMapper {
|
||||||
|
|
||||||
/** 카탈로그 조회 — year(시작연도)·status(날짜 기준) 선택 필터. 시작일 내림차순. */
|
/** 카탈로그 조회 — year(시작연도)·status(날짜 기준)·category 선택 필터. 시작일 내림차순. */
|
||||||
@Select("""
|
@Select("""
|
||||||
<script>
|
<script>
|
||||||
SELECT e.id,
|
SELECT e.id,
|
||||||
@ -26,9 +28,9 @@ public interface EventCatalogMapper {
|
|||||||
to_char(e.start_date, 'YYYY-MM-DD') AS "startDate",
|
to_char(e.start_date, 'YYYY-MM-DD') AS "startDate",
|
||||||
to_char(e.end_date, 'YYYY-MM-DD') AS "endDate",
|
to_char(e.end_date, 'YYYY-MM-DD') AS "endDate",
|
||||||
e.status,
|
e.status,
|
||||||
h.label AS "hallLabel",
|
COALESCE(h.label, e.hall_label) AS "hallLabel",
|
||||||
CAST(NULL AS varchar) AS category,
|
e.category AS "category",
|
||||||
CAST(NULL AS integer) AS "estVisitors"
|
e.est_visitors AS "estVisitors"
|
||||||
FROM event e
|
FROM event e
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT ha.hall_id
|
SELECT ha.hall_id
|
||||||
@ -42,6 +44,9 @@ public interface EventCatalogMapper {
|
|||||||
<if test="year != null and year != ''">
|
<if test="year != null and year != ''">
|
||||||
AND extract(year FROM e.start_date) = CAST(#{year} AS integer)
|
AND extract(year FROM e.start_date) = CAST(#{year} AS integer)
|
||||||
</if>
|
</if>
|
||||||
|
<if test="category != null and category != ''">
|
||||||
|
AND e.category = #{category}
|
||||||
|
</if>
|
||||||
<if test="status == 'ended'">AND e.end_date < CURRENT_DATE</if>
|
<if test="status == 'ended'">AND e.end_date < CURRENT_DATE</if>
|
||||||
<if test="status == 'ongoing'">AND e.start_date <= CURRENT_DATE AND e.end_date >= CURRENT_DATE</if>
|
<if test="status == 'ongoing'">AND e.start_date <= CURRENT_DATE AND e.end_date >= CURRENT_DATE</if>
|
||||||
<if test="status == 'upcoming'">AND e.start_date > CURRENT_DATE</if>
|
<if test="status == 'upcoming'">AND e.start_date > CURRENT_DATE</if>
|
||||||
@ -52,9 +57,35 @@ public interface EventCatalogMapper {
|
|||||||
""")
|
""")
|
||||||
List<Map<String, Object>> findEvents(@Param("year") String year,
|
List<Map<String, Object>> findEvents(@Param("year") String year,
|
||||||
@Param("status") String status,
|
@Param("status") String status,
|
||||||
|
@Param("category") String category,
|
||||||
@Param("limit") int limit,
|
@Param("limit") int limit,
|
||||||
@Param("offset") int offset);
|
@Param("offset") int offset);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 홀 가동률·캘린더 점유 공용 — 지정 월과 겹치는 행사(시작·종료·홀 라벨)를 반환한다.
|
||||||
|
* hallLabel 은 hall_assignment(대표 홀) 우선, 없으면 V23 {@code event.hall_label} 폴백.
|
||||||
|
* 집계(홀 그룹 버킷·일자 확장)는 서비스에서 수행한다.
|
||||||
|
*/
|
||||||
|
@Select("""
|
||||||
|
SELECT to_char(e.start_date, 'YYYY-MM-DD') AS "startDate",
|
||||||
|
to_char(e.end_date, 'YYYY-MM-DD') AS "endDate",
|
||||||
|
COALESCE(h.label, e.hall_label) AS "hallLabel"
|
||||||
|
FROM event e
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT ha.hall_id
|
||||||
|
FROM hall_assignment ha
|
||||||
|
WHERE ha.event_id = e.id
|
||||||
|
ORDER BY ha.is_primary DESC
|
||||||
|
LIMIT 1
|
||||||
|
) pa ON true
|
||||||
|
LEFT JOIN hall h ON h.id = pa.hall_id
|
||||||
|
WHERE e.start_date IS NOT NULL AND e.end_date IS NOT NULL
|
||||||
|
AND e.start_date <= CAST(#{monthEnd} AS date)
|
||||||
|
AND e.end_date >= CAST(#{monthStart} AS date)
|
||||||
|
""")
|
||||||
|
List<Map<String, Object>> findEventsOverlappingMonth(@Param("monthStart") String monthStart,
|
||||||
|
@Param("monthEnd") String monthEnd);
|
||||||
|
|
||||||
/** 필터 조건 총 건수(페이징 메타). */
|
/** 필터 조건 총 건수(페이징 메타). */
|
||||||
@Select("""
|
@Select("""
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@ -1,12 +1,20 @@
|
|||||||
package com.zioinfo.kintex.catalog;
|
package com.zioinfo.kintex.catalog;
|
||||||
|
|
||||||
|
import com.zioinfo.kintex.catalog.dto.CalendarDayDto;
|
||||||
import com.zioinfo.kintex.catalog.dto.ExhibitionDto;
|
import com.zioinfo.kintex.catalog.dto.ExhibitionDto;
|
||||||
|
import com.zioinfo.kintex.catalog.dto.HallUtilizationDto;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/** 전시 일정 카탈로그 서비스 — 전 홀 10년치 행사 목록(멤버십 무관 조회). */
|
/** 전시 일정 카탈로그 서비스 — 전 홀 10년치 행사 목록·집계(멤버십 무관 조회). */
|
||||||
public interface EventCatalogService {
|
public interface EventCatalogService {
|
||||||
|
|
||||||
/** year(시작연도)·status(ongoing|upcoming|ended, 날짜 기준) 선택 필터. 시작일 내림차순. */
|
/** year(시작연도)·status(ongoing|upcoming|ended, 날짜 기준)·category 선택 필터. 시작일 내림차순. */
|
||||||
List<ExhibitionDto> list(String year, String status, int limit, int offset);
|
List<ExhibitionDto> list(String year, String status, String category, int limit, int offset);
|
||||||
|
|
||||||
|
/** 지정 월(1~12) 홀 그룹 가동률 집계 — 실 event/hall_assignment/hall_label 기반. */
|
||||||
|
List<HallUtilizationDto> hallUtilization(int year, int month);
|
||||||
|
|
||||||
|
/** 지정 월(1~12) 캘린더 점유 — 행사가 진행 중인 날짜·건수. */
|
||||||
|
List<CalendarDayDto> calendar(int year, int month);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,16 +1,34 @@
|
|||||||
package com.zioinfo.kintex.catalog;
|
package com.zioinfo.kintex.catalog;
|
||||||
|
|
||||||
|
import com.zioinfo.kintex.catalog.dto.CalendarDayDto;
|
||||||
import com.zioinfo.kintex.catalog.dto.ExhibitionDto;
|
import com.zioinfo.kintex.catalog.dto.ExhibitionDto;
|
||||||
|
import com.zioinfo.kintex.catalog.dto.HallUtilizationDto;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
/** 전시 일정 카탈로그 서비스 구현 — {@link EventCatalogMapper} 조회 결과를 DTO로 매핑. */
|
/** 전시 일정 카탈로그 서비스 구현 — {@link EventCatalogMapper} 조회 결과를 DTO로 매핑·집계. */
|
||||||
@Service
|
@Service
|
||||||
public class EventCatalogServiceImpl implements EventCatalogService {
|
public class EventCatalogServiceImpl implements EventCatalogService {
|
||||||
|
|
||||||
|
/** 홀 가동률 그룹(프론트 sampleSchedule.HALL_GROUPS 와 정합). */
|
||||||
|
private static final HallGroup[] HALL_GROUPS = {
|
||||||
|
new HallGroup("홀 1-3", 1, 3),
|
||||||
|
new HallGroup("홀 4-5", 4, 5),
|
||||||
|
new HallGroup("홀 6-8", 6, 8),
|
||||||
|
new HallGroup("홀 9-10", 9, 10),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** "제N전시장" 접두(홀 번호 오인식 방지) 제거용. */
|
||||||
|
private static final Pattern CENTER_PREFIX = Pattern.compile("제\\s*\\d+\\s*전시장");
|
||||||
|
private static final Pattern DIGITS = Pattern.compile("\\d+");
|
||||||
|
|
||||||
private final EventCatalogMapper mapper;
|
private final EventCatalogMapper mapper;
|
||||||
|
|
||||||
public EventCatalogServiceImpl(EventCatalogMapper mapper) {
|
public EventCatalogServiceImpl(EventCatalogMapper mapper) {
|
||||||
@ -18,8 +36,9 @@ public class EventCatalogServiceImpl implements EventCatalogService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<ExhibitionDto> list(String year, String status, int limit, int offset) {
|
public List<ExhibitionDto> list(String year, String status, String category, int limit, int offset) {
|
||||||
List<Map<String, Object>> rows = mapper.findEvents(nullIfBlank(year), nullIfBlank(status), limit, offset);
|
List<Map<String, Object>> rows =
|
||||||
|
mapper.findEvents(nullIfBlank(year), nullIfBlank(status), nullIfBlank(category), limit, offset);
|
||||||
List<ExhibitionDto> out = new ArrayList<>(rows == null ? 0 : rows.size());
|
List<ExhibitionDto> out = new ArrayList<>(rows == null ? 0 : rows.size());
|
||||||
if (rows != null) {
|
if (rows != null) {
|
||||||
for (Map<String, Object> r : rows) {
|
for (Map<String, Object> r : rows) {
|
||||||
@ -32,6 +51,124 @@ public class EventCatalogServiceImpl implements EventCatalogService {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<HallUtilizationDto> hallUtilization(int year, int month) {
|
||||||
|
MonthRange mr = MonthRange.of(year, month);
|
||||||
|
List<Map<String, Object>> rows = mapper.findEventsOverlappingMonth(mr.startYmd(), mr.endYmd());
|
||||||
|
|
||||||
|
// 홀 그룹별 점유 일자 집합
|
||||||
|
List<boolean[]> busy = new ArrayList<>();
|
||||||
|
for (int g = 0; g < HALL_GROUPS.length; g++) {
|
||||||
|
busy.add(new boolean[mr.days() + 1]); // 1-based day index
|
||||||
|
}
|
||||||
|
if (rows != null) {
|
||||||
|
for (Map<String, Object> r : rows) {
|
||||||
|
int[] halls = hallNumbers(str(r.get("hallLabel")));
|
||||||
|
if (halls.length == 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
int from = dayInMonth(str(r.get("startDate")), mr, true);
|
||||||
|
int to = dayInMonth(str(r.get("endDate")), mr, false);
|
||||||
|
for (int g = 0; g < HALL_GROUPS.length; g++) {
|
||||||
|
HallGroup hg = HALL_GROUPS[g];
|
||||||
|
boolean inGroup = false;
|
||||||
|
for (int n : halls) {
|
||||||
|
if (n >= hg.lo && n <= hg.hi) {
|
||||||
|
inGroup = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!inGroup) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
boolean[] flags = busy.get(g);
|
||||||
|
for (int d = from; d <= to; d++) {
|
||||||
|
flags[d] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<HallUtilizationDto> out = new ArrayList<>(HALL_GROUPS.length);
|
||||||
|
for (int g = 0; g < HALL_GROUPS.length; g++) {
|
||||||
|
boolean[] flags = busy.get(g);
|
||||||
|
int busyDays = 0;
|
||||||
|
for (int d = 1; d <= mr.days(); d++) {
|
||||||
|
if (flags[d]) {
|
||||||
|
busyDays++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int pct = mr.days() == 0 ? 0 : Math.round(busyDays * 100f / mr.days());
|
||||||
|
out.add(new HallUtilizationDto(HALL_GROUPS[g].label, pct, busyDays, mr.days()));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<CalendarDayDto> calendar(int year, int month) {
|
||||||
|
MonthRange mr = MonthRange.of(year, month);
|
||||||
|
List<Map<String, Object>> rows = mapper.findEventsOverlappingMonth(mr.startYmd(), mr.endYmd());
|
||||||
|
int[] counts = new int[mr.days() + 1]; // 1-based
|
||||||
|
if (rows != null) {
|
||||||
|
for (Map<String, Object> r : rows) {
|
||||||
|
int from = dayInMonth(str(r.get("startDate")), mr, true);
|
||||||
|
int to = dayInMonth(str(r.get("endDate")), mr, false);
|
||||||
|
for (int d = from; d <= to; d++) {
|
||||||
|
counts[d]++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
List<CalendarDayDto> out = new ArrayList<>();
|
||||||
|
for (int d = 1; d <= mr.days(); d++) {
|
||||||
|
if (counts[d] > 0) {
|
||||||
|
out.add(new CalendarDayDto(mr.ymd(d), counts[d]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 홀 라벨 문자열에서 홀 번호(1~10)만 추출. "제N전시장" 접두는 제거해 오인식 방지. */
|
||||||
|
static int[] hallNumbers(String hallLabel) {
|
||||||
|
if (hallLabel == null || hallLabel.isBlank()) {
|
||||||
|
return new int[0];
|
||||||
|
}
|
||||||
|
String cleaned = CENTER_PREFIX.matcher(hallLabel).replaceAll(" ");
|
||||||
|
Matcher m = DIGITS.matcher(cleaned);
|
||||||
|
List<Integer> nums = new ArrayList<>();
|
||||||
|
while (m.find()) {
|
||||||
|
try {
|
||||||
|
int n = Integer.parseInt(m.group());
|
||||||
|
if (n >= 1 && n <= 10) {
|
||||||
|
nums.add(n);
|
||||||
|
}
|
||||||
|
} catch (NumberFormatException ignore) {
|
||||||
|
// skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int[] arr = new int[nums.size()];
|
||||||
|
for (int i = 0; i < arr.length; i++) {
|
||||||
|
arr[i] = nums.get(i);
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 행사 시작/종료 YMD 를 월 범위로 클램프한 일(day) 인덱스. start=true 면 하한 1, end 면 상한 days. */
|
||||||
|
private static int dayInMonth(String ymd, MonthRange mr, boolean isStart) {
|
||||||
|
if (ymd == null) {
|
||||||
|
return isStart ? 1 : mr.days();
|
||||||
|
}
|
||||||
|
// ymd 가 이 달에 속하면 그 일, 이전 달 시작이면 1, 다음 달 종료면 마지막 일
|
||||||
|
String prefix = mr.prefix();
|
||||||
|
if (ymd.startsWith(prefix)) {
|
||||||
|
try {
|
||||||
|
return Integer.parseInt(ymd.substring(8, 10));
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
return isStart ? 1 : mr.days();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return isStart ? 1 : mr.days();
|
||||||
|
}
|
||||||
|
|
||||||
private static String nullIfBlank(String s) {
|
private static String nullIfBlank(String s) {
|
||||||
return s == null || s.isBlank() || "all".equalsIgnoreCase(s) ? null : s;
|
return s == null || s.isBlank() || "all".equalsIgnoreCase(s) ? null : s;
|
||||||
}
|
}
|
||||||
@ -49,4 +186,33 @@ public class EventCatalogServiceImpl implements EventCatalogService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private record HallGroup(String label, int lo, int hi) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 월 범위(정규화된 1~12 월) — 시작/종료 YMD·일수·YMD 헬퍼. */
|
||||||
|
private record MonthRange(int year, int month, int days) {
|
||||||
|
static MonthRange of(int year, int monthRaw) {
|
||||||
|
int m = Math.min(Math.max(monthRaw, 1), 12);
|
||||||
|
int y = year <= 0 ? LocalDate.now().getYear() : year;
|
||||||
|
int d = LocalDate.of(y, m, 1).lengthOfMonth();
|
||||||
|
return new MonthRange(y, m, d);
|
||||||
|
}
|
||||||
|
|
||||||
|
String prefix() {
|
||||||
|
return String.format("%04d-%02d", year, month);
|
||||||
|
}
|
||||||
|
|
||||||
|
String startYmd() {
|
||||||
|
return prefix() + "-01";
|
||||||
|
}
|
||||||
|
|
||||||
|
String endYmd() {
|
||||||
|
return String.format("%04d-%02d-%02d", year, month, days);
|
||||||
|
}
|
||||||
|
|
||||||
|
String ymd(int day) {
|
||||||
|
return String.format("%04d-%02d-%02d", year, month, day);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,14 @@
|
|||||||
|
package com.zioinfo.kintex.catalog.dto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 월별 캘린더 점유 항목 (SCR-15 미니 캘린더 하이라이트).
|
||||||
|
* 해당 월에서 하나 이상 행사가 진행 중인 날짜만 반환한다(행사 없는 날은 미포함).
|
||||||
|
*
|
||||||
|
* @param date "YYYY-MM-DD"
|
||||||
|
* @param count 그 날 진행 중인 행사 수
|
||||||
|
*/
|
||||||
|
public record CalendarDayDto(
|
||||||
|
String date,
|
||||||
|
int count
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
package com.zioinfo.kintex.catalog.dto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 홀 가동률 집계 항목 (SCR-15 우측 aside).
|
||||||
|
* 홀 그룹(예 "홀 1-3")별로 해당 월에 하나 이상 행사가 점유한 일수(busyDays) / 월 일수(totalDays) 비율(pct, %).
|
||||||
|
*
|
||||||
|
* @param label 홀 그룹 라벨
|
||||||
|
* @param pct 가동률(%) — round(busyDays/totalDays*100)
|
||||||
|
* @param busyDays 점유 일수
|
||||||
|
* @param totalDays 월 일수
|
||||||
|
*/
|
||||||
|
public record HallUtilizationDto(
|
||||||
|
String label,
|
||||||
|
int pct,
|
||||||
|
int busyDays,
|
||||||
|
int totalDays
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -93,7 +93,7 @@ public interface VisitorMapper {
|
|||||||
@Select("""
|
@Select("""
|
||||||
<script>
|
<script>
|
||||||
SELECT id,
|
SELECT id,
|
||||||
CASE WHEN char_length(name) <= 1 THEN name
|
CASE WHEN char_length(name) <= 1 THEN name
|
||||||
WHEN char_length(name) = 2 THEN left(name,1) || '*'
|
WHEN char_length(name) = 2 THEN left(name,1) || '*'
|
||||||
ELSE left(name,1) || repeat('*', char_length(name)-2) || right(name,1)
|
ELSE left(name,1) || repeat('*', char_length(name)-2) || right(name,1)
|
||||||
END AS "nameMasked",
|
END AS "nameMasked",
|
||||||
|
|||||||
1121
src/backend/src/main/resources/db/migration/V23__schedule_enrich.sql
Normal file
1121
src/backend/src/main/resources/db/migration/V23__schedule_enrich.sql
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,7 @@
|
|||||||
package com.zioinfo.kintex.analytics;
|
package com.zioinfo.kintex.analytics;
|
||||||
|
|
||||||
import com.zioinfo.kintex.analytics.dto.AnalyticsDto;
|
import com.zioinfo.kintex.analytics.dto.AnalyticsDto;
|
||||||
|
import com.zioinfo.kintex.analytics.dto.AnalyticsOverviewDto;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
@ -9,6 +10,7 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
@ -64,4 +66,50 @@ class AnalyticsServiceTest {
|
|||||||
assertTrue(dto.sectors().isEmpty());
|
assertTrue(dto.sectors().isEmpty());
|
||||||
assertTrue(dto.exhibitorPerf().isEmpty());
|
assertTrue(dto.exhibitorPerf().isEmpty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void overviewComputesRetentionOccupancyAndForecast() {
|
||||||
|
when(mapper.findRentalRatePerM2()).thenReturn(2000.0);
|
||||||
|
when(mapper.countHalls()).thenReturn(10L);
|
||||||
|
when(mapper.countEvents()).thenReturn(1098L);
|
||||||
|
when(mapper.findUtilityRevenueByEvent()).thenReturn(List.of());
|
||||||
|
|
||||||
|
Map<String, Object> ev = new HashMap<>();
|
||||||
|
ev.put("eventId", "e1");
|
||||||
|
ev.put("eventName", "스마트팩토리");
|
||||||
|
ev.put("areaM2", 50000.0);
|
||||||
|
ev.put("boothCount", 300L);
|
||||||
|
when(mapper.findEventAreaRanking(8)).thenReturn(List.of(ev));
|
||||||
|
|
||||||
|
Map<String, Object> occ = new HashMap<>();
|
||||||
|
occ.put("period", "2026-02"); // 28일
|
||||||
|
occ.put("bookedDays", 56L); // 56 / (10*28)=20%
|
||||||
|
when(mapper.findMonthlyEventDays(60)).thenReturn(List.of(occ));
|
||||||
|
|
||||||
|
Map<String, Object> retSum = new HashMap<>();
|
||||||
|
retSum.put("totalCompanies", 10L);
|
||||||
|
retSum.put("returningCompanies", 4L); // 40%
|
||||||
|
when(mapper.findRetentionSummary()).thenReturn(retSum);
|
||||||
|
when(mapper.findRetentionByYear()).thenReturn(List.of());
|
||||||
|
|
||||||
|
// 연 3건씩 증가하는 완벽한 선형 → 다음해 투영 = 마지막+3.
|
||||||
|
Map<String, Object> y1 = new HashMap<>();
|
||||||
|
y1.put("yr", 2024); y1.put("eventCount", 100L);
|
||||||
|
Map<String, Object> y2 = new HashMap<>();
|
||||||
|
y2.put("yr", 2025); y2.put("eventCount", 103L);
|
||||||
|
Map<String, Object> y3 = new HashMap<>();
|
||||||
|
y3.put("yr", 2026); y3.put("eventCount", 106L);
|
||||||
|
when(mapper.findYearlyEventCounts()).thenReturn(List.of(y1, y2, y3));
|
||||||
|
|
||||||
|
AnalyticsOverviewDto dto = service.getOverview(5);
|
||||||
|
|
||||||
|
assertEquals(1, dto.eventComparison().size());
|
||||||
|
assertEquals(100_000_000L, dto.eventComparison().get(0).revenue()); // 50000*2000
|
||||||
|
assertEquals(20.0, dto.hallOccupancy().get(0).occupancyPercent());
|
||||||
|
assertEquals(40.0, dto.retention().retentionPercent());
|
||||||
|
assertEquals(2, dto.forecast().projection().size());
|
||||||
|
assertEquals(109.0, dto.forecast().projection().get(0).value()); // 106+3
|
||||||
|
assertTrue(dto.forecast().projection().get(0).projected());
|
||||||
|
assertFalse(dto.forecast().history().isEmpty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,48 @@
|
|||||||
|
package com.zioinfo.kintex.common;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.fail;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.session.Configuration;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.AnnotatedBeanDefinition;
|
||||||
|
import org.springframework.beans.factory.config.BeanDefinition;
|
||||||
|
import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider;
|
||||||
|
import org.springframework.core.type.filter.AnnotationTypeFilter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전 @Mapper 인터페이스의 어노테이션 SQL(@Select/@Insert/@Update/@Delete, <script> 포함) 파싱 회귀 테스트.
|
||||||
|
*
|
||||||
|
* 배경(2026-07-12 배포 크래시): <script> 블록 안의 이스케이프 안 된 비교연산 `<`가 XML(SAX) 파싱을 깨면
|
||||||
|
* compileJava·일반 단위테스트(스프링 컨텍스트 미기동)는 통과하고 서버 부팅에서만 크래시 루프가 난다.
|
||||||
|
* 이 테스트는 DB·스프링 컨텍스트 없이 MyBatis Configuration.addMapper 로 파싱만 수행해 그 유형을 로컬에서 잡는다.
|
||||||
|
*/
|
||||||
|
class MapperAnnotationParseTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void allMapperAnnotationSqlParses() throws Exception {
|
||||||
|
ClassPathScanningCandidateComponentProvider scanner =
|
||||||
|
new ClassPathScanningCandidateComponentProvider(false) {
|
||||||
|
@Override
|
||||||
|
protected boolean isCandidateComponent(AnnotatedBeanDefinition bd) {
|
||||||
|
return bd.getMetadata().isInterface();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
scanner.addIncludeFilter(new AnnotationTypeFilter(Mapper.class));
|
||||||
|
Set<BeanDefinition> defs = scanner.findCandidateComponents("com.zioinfo.kintex");
|
||||||
|
assertFalse(defs.isEmpty(), "@Mapper 스캔 결과 0건 — 패키지/애노테이션 확인");
|
||||||
|
|
||||||
|
Configuration cfg = new Configuration();
|
||||||
|
for (BeanDefinition bd : defs) {
|
||||||
|
Class<?> mapper = Class.forName(bd.getBeanClassName());
|
||||||
|
try {
|
||||||
|
cfg.addMapper(mapper);
|
||||||
|
} catch (Exception e) {
|
||||||
|
fail("매퍼 어노테이션 SQL 파싱 실패: " + mapper.getName()
|
||||||
|
+ " — <script> 블록의 '<'는 < 로 이스케이프하거나 비교를 뒤집어라. 원인: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -50,6 +50,11 @@ import { AuditLogPage } from './screens/admin/AuditLogPage';
|
|||||||
import { SystemSettingsPage } from './screens/admin/SystemSettingsPage';
|
import { SystemSettingsPage } from './screens/admin/SystemSettingsPage';
|
||||||
import { RulesetVersionsPage } from './screens/admin/RulesetVersionsPage';
|
import { RulesetVersionsPage } from './screens/admin/RulesetVersionsPage';
|
||||||
import { TenantAdminPage } from './screens/admin/TenantAdminPage';
|
import { TenantAdminPage } from './screens/admin/TenantAdminPage';
|
||||||
|
import { AdminGuard } from './screens/admin/AdminGuard';
|
||||||
|
import { SysUserAdminPage } from './screens/admin/SysUserAdminPage';
|
||||||
|
import { RoleAdminPage } from './screens/admin/RoleAdminPage';
|
||||||
|
import { CommonCodeAdminPage } from './screens/admin/CommonCodeAdminPage';
|
||||||
|
import { MenuAdminPage } from './screens/admin/MenuAdminPage';
|
||||||
import {
|
import {
|
||||||
PublicHomePage,
|
PublicHomePage,
|
||||||
PublicEventDetailPage,
|
PublicEventDetailPage,
|
||||||
@ -146,9 +151,9 @@ export function App() {
|
|||||||
{/* SCR-15 전시 일정 관리 (M1·M6) */}
|
{/* SCR-15 전시 일정 관리 (M1·M6) */}
|
||||||
<Route path="/schedule" element={<ExhibitionSchedulePage />} />
|
<Route path="/schedule" element={<ExhibitionSchedulePage />} />
|
||||||
{/* SCR-16 관리자 대시보드 (M18 랜딩) */}
|
{/* SCR-16 관리자 대시보드 (M18 랜딩) */}
|
||||||
<Route path="/admin" element={<AdminDashboardPage />} />
|
<Route path="/admin" element={<AdminGuard><AdminDashboardPage /></AdminGuard>} />
|
||||||
{/* 관리자 — 로그인 슬라이드 관리 (M18) */}
|
{/* 관리자 — 로그인 슬라이드 관리 (M18) */}
|
||||||
<Route path="/admin/login-slides" element={<LoginSlideAdminPage />} />
|
<Route path="/admin/login-slides" element={<AdminGuard><LoginSlideAdminPage /></AdminGuard>} />
|
||||||
{/* SCR-17 컴포넌트 가이드 (내부 전용 · 네비 비노출) */}
|
{/* SCR-17 컴포넌트 가이드 (내부 전용 · 네비 비노출) */}
|
||||||
<Route path="/_styleguide" element={<ComponentGuidePage />} />
|
<Route path="/_styleguide" element={<ComponentGuidePage />} />
|
||||||
|
|
||||||
@ -185,11 +190,16 @@ export function App() {
|
|||||||
<Route path="/cms" element={<CmsWorkflowPage />} />
|
<Route path="/cms" element={<CmsWorkflowPage />} />
|
||||||
<Route path="/cms/microsite" element={<MicrositeBuilderPage />} />
|
<Route path="/cms/microsite" element={<MicrositeBuilderPage />} />
|
||||||
<Route path="/cms/i18n" element={<MultilingualCmsPage />} />
|
<Route path="/cms/i18n" element={<MultilingualCmsPage />} />
|
||||||
{/* SCR-A5~A9 관리자 모듈 (감사로그·설정=실배선, 룰셋·테넌트=샘플) */}
|
{/* SCR-A5~A9 관리자 모듈 (감사로그·설정·룰셋=실배선, 테넌트=샘플) — AdminGuard 가드 */}
|
||||||
<Route path="/admin/audit" element={<AuditLogPage />} />
|
<Route path="/admin/audit" element={<AdminGuard><AuditLogPage /></AdminGuard>} />
|
||||||
<Route path="/admin/settings" element={<SystemSettingsPage />} />
|
<Route path="/admin/settings" element={<AdminGuard><SystemSettingsPage /></AdminGuard>} />
|
||||||
<Route path="/admin/masterdata/rulesets" element={<RulesetVersionsPage />} />
|
<Route path="/admin/masterdata/rulesets" element={<AdminGuard><RulesetVersionsPage /></AdminGuard>} />
|
||||||
<Route path="/admin/tenants" element={<TenantAdminPage />} />
|
<Route path="/admin/tenants" element={<AdminGuard><TenantAdminPage /></AdminGuard>} />
|
||||||
|
{/* M18 시스템관리 CRUD (백엔드 system 패키지 라이브) — AdminGuard 가드 */}
|
||||||
|
<Route path="/admin/users" element={<AdminGuard><SysUserAdminPage /></AdminGuard>} />
|
||||||
|
<Route path="/admin/roles" element={<AdminGuard><RoleAdminPage /></AdminGuard>} />
|
||||||
|
<Route path="/admin/codes" element={<AdminGuard><CommonCodeAdminPage /></AdminGuard>} />
|
||||||
|
<Route path="/admin/menus" element={<AdminGuard><MenuAdminPage /></AdminGuard>} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* SCR-P1~P7 공개 홍보 사이트 — 비로그인, 자체 PublicShell */}
|
{/* SCR-P1~P7 공개 홍보 사이트 — 비로그인, 자체 PublicShell */}
|
||||||
|
|||||||
@ -66,6 +66,12 @@ export function AdminDashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<nav className="kx-admin__modules" aria-label="관리 모듈 바로가기">
|
||||||
|
{ADMIN_MODULES.map(([label, to]) => (
|
||||||
|
<Link key={to} to={to} className="kx-btn kx-btn--secondary">{label}</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
{degraded && (
|
{degraded && (
|
||||||
<div className="kx-admin__degraded-bar">
|
<div className="kx-admin__degraded-bar">
|
||||||
<span className="kx-bi__degraded">오프라인 — 샘플</span>
|
<span className="kx-bi__degraded">오프라인 — 샘플</span>
|
||||||
@ -203,3 +209,15 @@ const FALLBACK: AdminDashboardData = {
|
|||||||
})),
|
})),
|
||||||
tenants: TENANTS,
|
tenants: TENANTS,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 관리 모듈 바로가기(M18) — 라우트는 App.tsx AdminGuard 배선과 동일. */
|
||||||
|
const ADMIN_MODULES: [string, string][] = [
|
||||||
|
['사용자', '/admin/users'],
|
||||||
|
['역할·권한', '/admin/roles'],
|
||||||
|
['공통코드', '/admin/codes'],
|
||||||
|
['메뉴', '/admin/menus'],
|
||||||
|
['감사로그', '/admin/audit'],
|
||||||
|
['시스템설정', '/admin/settings'],
|
||||||
|
['룰셋', '/admin/masterdata/rulesets'],
|
||||||
|
['테넌트', '/admin/tenants'],
|
||||||
|
];
|
||||||
|
|||||||
50
src/frontend/src/screens/admin/AdminGuard.tsx
Normal file
50
src/frontend/src/screens/admin/AdminGuard.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
/*
|
||||||
|
* 관리자 백오피스(M18) 프론트 RBAC 가드 — 방어 심층화(Defense-in-depth).
|
||||||
|
* 백엔드 SystemAccessGuard.requireAdmin 이 최종 권위(홀매니저 || app_user.role_code=ADMIN)이며,
|
||||||
|
* 이 컴포넌트는 비관리자에게 관리자 화면 UI 를 노출하지 않기 위한 프론트 게이트다.
|
||||||
|
*
|
||||||
|
* ★ 판정 근거: 프론트 authStore 는 전역 role_code(ADMIN/MANAGER)를 노출하지 않고
|
||||||
|
* AuthUser.hallManager(플랫폼 운영자 플래그)와 행사 스코프 역할(EventRole)만 보유한다.
|
||||||
|
* 따라서 프론트에서 확인 가능한 관리자 신호 = hallManager===true, 보조로 현재 행사 역할 HALL_MANAGER.
|
||||||
|
* 전역 ADMIN/MANAGER 계정이라도 hallManager=false 이면 프론트에선 구분 불가 → 이 경우 백엔드가 통과시키므로
|
||||||
|
* 화면 진입 후 데이터는 정상 로드된다(게이트는 명백한 비관리자 차단 목적). 이 한계는 _workspace 노트에 기록.
|
||||||
|
*
|
||||||
|
* 사용(통합자 배선): 관리자 라우트 element 를 <AdminGuard> 로 감싼다. App.tsx 는 이 트랙에서 수정하지 않는다.
|
||||||
|
*/
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useAuthStore } from '../../store/authStore';
|
||||||
|
import { IconBlock } from '../../components/ui/icons';
|
||||||
|
import './admin-modules.css';
|
||||||
|
|
||||||
|
/** 프론트에서 확인 가능한 관리자 접근 여부(백엔드가 최종 권위). */
|
||||||
|
export function useIsAdmin(): boolean {
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const roleForCurrent = useAuthStore((s) => s.roleForCurrent);
|
||||||
|
if (!user) return false;
|
||||||
|
if (user.hallManager) return true;
|
||||||
|
return roleForCurrent() === 'HALL_MANAGER';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminGuard({ children }: { children: ReactNode }) {
|
||||||
|
const authed = useAuthStore((s) => s.isAuthenticated);
|
||||||
|
const isAdmin = useIsAdmin();
|
||||||
|
|
||||||
|
if (authed && isAdmin) return <>{children}</>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="kx-page kx-adm">
|
||||||
|
<div className="kx-adm-denied" role="alert">
|
||||||
|
<span className="kx-adm-denied__icon" aria-hidden="true">
|
||||||
|
<IconBlock size={40} />
|
||||||
|
</span>
|
||||||
|
<h1 className="kx-adm-denied__title">접근 권한이 없습니다</h1>
|
||||||
|
<p className="kx-adm-denied__desc">
|
||||||
|
{authed
|
||||||
|
? '이 페이지는 시스템 관리자(홀매니저·ADMIN)만 이용할 수 있습니다. 접근이 필요하면 관리자에게 문의하세요.'
|
||||||
|
: '관리자 백오피스는 로그인 후 이용할 수 있습니다.'}
|
||||||
|
</p>
|
||||||
|
<p className="kx-adm-denied__code">403 · FORBIDDEN</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
405
src/frontend/src/screens/admin/CommonCodeAdminPage.tsx
Normal file
405
src/frontend/src/screens/admin/CommonCodeAdminPage.tsx
Normal file
@ -0,0 +1,405 @@
|
|||||||
|
/*
|
||||||
|
* M18 공통코드 관리 (SCR-A3 · §5B). CommonCodeController 실배선.
|
||||||
|
* 정본: GET /api/common/code-groups(인증) · GET /api/common/codes/{grp}(인증) · POST /api/admin/codes/groups · POST /api/admin/codes ·
|
||||||
|
* DELETE /api/admin/codes/groups/{grp} · DELETE /api/admin/codes/{grp}/{code} (ADMIN).
|
||||||
|
* UI: 좌 그룹 목록(선택·추가), 우 코드 값 테이블(추가/수정/삭제 모달). 저장은 upsert(코드 존재 시 갱신).
|
||||||
|
*/
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
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 { codeApi, type Code, type CodeGroup } from './adminCrudApi';
|
||||||
|
import './admin-modules.css';
|
||||||
|
|
||||||
|
export function CommonCodeAdminPage() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { show, node: toast } = useToast();
|
||||||
|
|
||||||
|
const groupsQ = useQuery({ queryKey: ['admin-code-groups'], queryFn: codeApi.groups, retry: false });
|
||||||
|
const [selected, setSelected] = useState<string | null>(null);
|
||||||
|
const [groupModal, setGroupModal] = useState<CodeGroup | null | undefined>(undefined);
|
||||||
|
const [codeModal, setCodeModal] = useState<Code | null | undefined>(undefined);
|
||||||
|
|
||||||
|
const groups = groupsQ.data ?? [];
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selected && groups.length > 0) setSelected(groups[0].grpCode);
|
||||||
|
}, [groups, selected]);
|
||||||
|
|
||||||
|
const activeGroup = groups.find((g) => g.grpCode === selected) ?? null;
|
||||||
|
|
||||||
|
const codesQ = useQuery({
|
||||||
|
queryKey: ['admin-codes', selected],
|
||||||
|
queryFn: () => codeApi.codes(selected as string),
|
||||||
|
enabled: !!selected,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveGroupM = useMutation({
|
||||||
|
mutationFn: (g: CodeGroup) => codeApi.saveGroup(g),
|
||||||
|
onSuccess: (_d, g) => {
|
||||||
|
show('코드 그룹을 저장했습니다.');
|
||||||
|
setGroupModal(undefined);
|
||||||
|
setSelected(g.grpCode);
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin-code-groups'] });
|
||||||
|
},
|
||||||
|
onError: (e) => show(errMessage(e)),
|
||||||
|
});
|
||||||
|
const delGroupM = useMutation({
|
||||||
|
mutationFn: (grpCode: string) => codeApi.removeGroup(grpCode),
|
||||||
|
onSuccess: () => {
|
||||||
|
show('코드 그룹을 삭제했습니다.');
|
||||||
|
setSelected(null);
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin-code-groups'] });
|
||||||
|
},
|
||||||
|
onError: (e) => show(errMessage(e)),
|
||||||
|
});
|
||||||
|
const saveCodeM = useMutation({
|
||||||
|
mutationFn: (c: Code) => codeApi.saveCode(c),
|
||||||
|
onSuccess: () => {
|
||||||
|
show('코드를 저장했습니다.');
|
||||||
|
setCodeModal(undefined);
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin-codes', selected] });
|
||||||
|
},
|
||||||
|
onError: (e) => show(errMessage(e)),
|
||||||
|
});
|
||||||
|
const delCodeM = useMutation({
|
||||||
|
mutationFn: ({ grpCode, code }: { grpCode: string; code: string }) => codeApi.removeCode(grpCode, code),
|
||||||
|
onSuccess: () => {
|
||||||
|
show('코드를 삭제했습니다.');
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin-codes', selected] });
|
||||||
|
},
|
||||||
|
onError: (e) => show(errMessage(e)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const codes = codesQ.data ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="kx-page kx-adm">
|
||||||
|
<header className="kx-adm__head">
|
||||||
|
<div>
|
||||||
|
<h1 className="kx-adm__title">공통코드 관리</h1>
|
||||||
|
<p className="kx-adm__subtitle">드롭다운·분류에 쓰이는 공통코드 그룹과 코드 값을 관리합니다.</p>
|
||||||
|
</div>
|
||||||
|
<Button leadingIcon={<IconPlus size={16} />} onClick={() => setGroupModal(null)}>그룹 추가</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{groupsQ.isLoading && <Skeleton height={320} radius={12} />}
|
||||||
|
{groupsQ.isError && !groupsQ.isLoading && (
|
||||||
|
<ErrorState message="코드 그룹을 불러오지 못했습니다." onRetry={() => groupsQ.refetch()} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!groupsQ.isLoading && !groupsQ.isError && (
|
||||||
|
groups.length === 0 ? (
|
||||||
|
<EmptyState title="코드 그룹이 없습니다" description="그룹을 추가해 공통코드를 구성하세요." />
|
||||||
|
) : (
|
||||||
|
<div className="kx-adm-codes">
|
||||||
|
{/* 좌: 그룹 */}
|
||||||
|
<section className="kx-card kx-adm-codes__groups" aria-label="코드 그룹">
|
||||||
|
<div className="kx-adm-codes__head">
|
||||||
|
<h2>코드 그룹</h2>
|
||||||
|
</div>
|
||||||
|
<ul className="kx-adm-glist">
|
||||||
|
{groups.map((g) => (
|
||||||
|
<li key={g.grpCode}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`kx-adm-gitem ${g.grpCode === selected ? 'is-active' : ''}`}
|
||||||
|
onClick={() => setSelected(g.grpCode)}
|
||||||
|
>
|
||||||
|
<span className="kx-adm-gitem__name">{g.grpName}</span>
|
||||||
|
<span className="kx-adm-gitem__code">{g.grpCode}{g.useYn === 'N' ? ' · 미사용' : ''}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 우: 코드 값 */}
|
||||||
|
<section className="kx-card kx-adm-codes__values" aria-label="코드 값">
|
||||||
|
<div className="kx-adm-codes__head">
|
||||||
|
<div>
|
||||||
|
<h2>{activeGroup?.grpName ?? '코드'} 값</h2>
|
||||||
|
{activeGroup?.description && <p className="kx-adm-muted">{activeGroup.description}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="kx-adm-tbl__actions">
|
||||||
|
{activeGroup && (
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={() => setGroupModal(activeGroup)}>그룹 수정</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="kx-adm-del"
|
||||||
|
disabled={delGroupM.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`그룹 '${activeGroup.grpName}' 과 하위 코드를 삭제하시겠습니까?`))
|
||||||
|
delGroupM.mutate(activeGroup.grpCode);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
그룹 삭제
|
||||||
|
</Button>
|
||||||
|
<Button leadingIcon={<IconPlus size={16} />} onClick={() => setCodeModal(null)}>코드 추가</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{codesQ.isLoading && <div style={{ padding: 16 }}><Skeleton height={160} radius={8} /></div>}
|
||||||
|
{codesQ.isError && !codesQ.isLoading && (
|
||||||
|
<div style={{ padding: 16 }}>
|
||||||
|
<ErrorState message="코드 값을 불러오지 못했습니다." onRetry={() => codesQ.refetch()} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!codesQ.isLoading && !codesQ.isError && (
|
||||||
|
codes.length === 0 ? (
|
||||||
|
<div style={{ padding: 16 }}>
|
||||||
|
<EmptyState title="코드가 없습니다" description="코드를 추가하세요." />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="kx-table-scroll">
|
||||||
|
<table className="kx-table kx-table--zebra kx-adm-tbl">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>코드</th>
|
||||||
|
<th>이름</th>
|
||||||
|
<th>값</th>
|
||||||
|
<th className="kx-num">정렬</th>
|
||||||
|
<th>사용</th>
|
||||||
|
<th style={{ textAlign: 'right' }}>관리</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{codes.map((c) => (
|
||||||
|
<tr key={c.code}>
|
||||||
|
<td className="kx-adm-mono">{c.code}</td>
|
||||||
|
<td className="kx-adm-tbl__strong">{c.codeName}</td>
|
||||||
|
<td className="kx-adm-muted">{c.codeValue ?? '—'}</td>
|
||||||
|
<td className="kx-num tnum">{c.sortOrder}</td>
|
||||||
|
<td>
|
||||||
|
<span className={`kx-adm-pill kx-adm-pill--${c.useYn === 'N' ? 'neutral' : 'success'}`}>
|
||||||
|
{c.useYn === 'N' ? '미사용' : '사용'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="kx-adm-tbl__actions">
|
||||||
|
<Button variant="ghost" onClick={() => setCodeModal(c)}>수정</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="kx-adm-del"
|
||||||
|
disabled={delCodeM.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`코드 '${c.codeName}' 을(를) 삭제하시겠습니까?`))
|
||||||
|
delCodeM.mutate({ grpCode: c.grpCode, code: c.code });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{groupModal !== undefined && (
|
||||||
|
<GroupModal
|
||||||
|
group={groupModal}
|
||||||
|
existingCodes={groups.map((g) => g.grpCode)}
|
||||||
|
saving={saveGroupM.isPending}
|
||||||
|
onClose={() => setGroupModal(undefined)}
|
||||||
|
onSubmit={(g) => saveGroupM.mutate(g)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{codeModal !== undefined && activeGroup && (
|
||||||
|
<CodeModal
|
||||||
|
grpCode={activeGroup.grpCode}
|
||||||
|
code={codeModal}
|
||||||
|
existingCodes={codes.map((c) => c.code)}
|
||||||
|
saving={saveCodeM.isPending}
|
||||||
|
onClose={() => setCodeModal(undefined)}
|
||||||
|
onSubmit={(c) => saveCodeM.mutate(c)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{toast}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GroupModal({
|
||||||
|
group,
|
||||||
|
existingCodes,
|
||||||
|
saving,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
group: CodeGroup | null;
|
||||||
|
existingCodes: string[];
|
||||||
|
saving: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (g: CodeGroup) => void;
|
||||||
|
}) {
|
||||||
|
const isNew = group === null;
|
||||||
|
const [grpCode, setGrpCode] = useState(group?.grpCode ?? '');
|
||||||
|
const [grpName, setGrpName] = useState(group?.grpName ?? '');
|
||||||
|
const [description, setDescription] = useState(group?.description ?? '');
|
||||||
|
const [useYn, setUseYn] = useState(group?.useYn ?? 'Y');
|
||||||
|
const [sortOrder, setSortOrder] = useState(String(group?.sortOrder ?? 0));
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
if (!grpCode.trim() || !grpName.trim()) {
|
||||||
|
setError('그룹 코드와 이름은 필수입니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isNew && existingCodes.includes(grpCode.trim())) {
|
||||||
|
setError('이미 존재하는 그룹 코드입니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSubmit({
|
||||||
|
grpCode: grpCode.trim(),
|
||||||
|
grpName: grpName.trim(),
|
||||||
|
description: description.trim() || null,
|
||||||
|
useYn,
|
||||||
|
sortOrder: Number(sortOrder) || 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="kx-adm-modal" role="dialog" aria-modal="true" aria-labelledby="group-modal-title">
|
||||||
|
<div className="kx-adm-modal__backdrop" onClick={onClose} />
|
||||||
|
<form className="kx-adm-modal__panel" onSubmit={submit}>
|
||||||
|
<h2 id="group-modal-title" className="kx-adm-modal__title">{isNew ? '코드 그룹 추가' : '코드 그룹 수정'}</h2>
|
||||||
|
<div className="kx-adm-modal__grid">
|
||||||
|
<label className="kx-adm-field">
|
||||||
|
<span className="kx-adm-field__label">그룹 코드 *</span>
|
||||||
|
<input className="kx-adm-field__input kx-adm-mono" value={grpCode} disabled={!isNew} onChange={(e) => setGrpCode(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="kx-adm-field">
|
||||||
|
<span className="kx-adm-field__label">그룹 이름 *</span>
|
||||||
|
<input className="kx-adm-field__input" value={grpName} onChange={(e) => setGrpName(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="kx-adm-field kx-adm-field--full">
|
||||||
|
<span className="kx-adm-field__label">설명</span>
|
||||||
|
<input className="kx-adm-field__input" value={description} onChange={(e) => setDescription(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="kx-adm-field">
|
||||||
|
<span className="kx-adm-field__label">정렬</span>
|
||||||
|
<input className="kx-adm-field__input" type="number" value={sortOrder} onChange={(e) => setSortOrder(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="kx-adm-field">
|
||||||
|
<span className="kx-adm-field__label">사용 여부</span>
|
||||||
|
<select className="kx-adm-field__select" value={useYn} onChange={(e) => setUseYn(e.target.value)}>
|
||||||
|
<option value="Y">사용</option>
|
||||||
|
<option value="N">미사용</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</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}>취소</Button>
|
||||||
|
<Button type="submit" disabled={saving}>{saving ? '저장 중…' : '저장'}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CodeModal({
|
||||||
|
grpCode,
|
||||||
|
code,
|
||||||
|
existingCodes,
|
||||||
|
saving,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
grpCode: string;
|
||||||
|
code: Code | null;
|
||||||
|
existingCodes: string[];
|
||||||
|
saving: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (c: Code) => void;
|
||||||
|
}) {
|
||||||
|
const isNew = code === null;
|
||||||
|
const [codeVal, setCodeVal] = useState(code?.code ?? '');
|
||||||
|
const [codeName, setCodeName] = useState(code?.codeName ?? '');
|
||||||
|
const [codeValue, setCodeValue] = useState(code?.codeValue ?? '');
|
||||||
|
const [attr1, setAttr1] = useState(code?.attr1 ?? '');
|
||||||
|
const [sortOrder, setSortOrder] = useState(String(code?.sortOrder ?? 0));
|
||||||
|
const [useYn, setUseYn] = useState(code?.useYn ?? 'Y');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
if (!codeVal.trim() || !codeName.trim()) {
|
||||||
|
setError('코드와 이름은 필수입니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isNew && existingCodes.includes(codeVal.trim())) {
|
||||||
|
setError('이미 존재하는 코드입니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSubmit({
|
||||||
|
grpCode,
|
||||||
|
code: codeVal.trim(),
|
||||||
|
codeName: codeName.trim(),
|
||||||
|
codeValue: codeValue.trim() || null,
|
||||||
|
sortOrder: Number(sortOrder) || 0,
|
||||||
|
useYn,
|
||||||
|
attr1: attr1.trim() || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="kx-adm-modal" role="dialog" aria-modal="true" aria-labelledby="code-modal-title">
|
||||||
|
<div className="kx-adm-modal__backdrop" onClick={onClose} />
|
||||||
|
<form className="kx-adm-modal__panel" onSubmit={submit}>
|
||||||
|
<h2 id="code-modal-title" className="kx-adm-modal__title">{isNew ? '코드 추가' : '코드 수정'}</h2>
|
||||||
|
<p className="kx-adm-modal__desc">그룹 <span className="kx-adm-mono">{grpCode}</span></p>
|
||||||
|
<div className="kx-adm-modal__grid">
|
||||||
|
<label className="kx-adm-field">
|
||||||
|
<span className="kx-adm-field__label">코드 *</span>
|
||||||
|
<input className="kx-adm-field__input kx-adm-mono" value={codeVal} disabled={!isNew} onChange={(e) => setCodeVal(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="kx-adm-field">
|
||||||
|
<span className="kx-adm-field__label">이름 *</span>
|
||||||
|
<input className="kx-adm-field__input" value={codeName} onChange={(e) => setCodeName(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="kx-adm-field">
|
||||||
|
<span className="kx-adm-field__label">값</span>
|
||||||
|
<input className="kx-adm-field__input" value={codeValue} onChange={(e) => setCodeValue(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="kx-adm-field">
|
||||||
|
<span className="kx-adm-field__label">속성1(attr1)</span>
|
||||||
|
<input className="kx-adm-field__input" value={attr1} onChange={(e) => setAttr1(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="kx-adm-field">
|
||||||
|
<span className="kx-adm-field__label">정렬</span>
|
||||||
|
<input className="kx-adm-field__input" type="number" value={sortOrder} onChange={(e) => setSortOrder(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="kx-adm-field">
|
||||||
|
<span className="kx-adm-field__label">사용 여부</span>
|
||||||
|
<select className="kx-adm-field__select" value={useYn} onChange={(e) => setUseYn(e.target.value)}>
|
||||||
|
<option value="Y">사용</option>
|
||||||
|
<option value="N">미사용</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</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}>취소</Button>
|
||||||
|
<Button type="submit" disabled={saving}>{saving ? '저장 중…' : '저장'}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
286
src/frontend/src/screens/admin/MenuAdminPage.tsx
Normal file
286
src/frontend/src/screens/admin/MenuAdminPage.tsx
Normal file
@ -0,0 +1,286 @@
|
|||||||
|
/*
|
||||||
|
* M18 메뉴 관리 (SCR-A4 · §5B). MenuController 실배선.
|
||||||
|
* 정본: GET /api/admin/menus → MenuNode[] (flat) · POST /api/admin/menus(upsert) · DELETE /api/admin/menus/{id}.
|
||||||
|
* UI: parentId 로 트리를 구성해 깊이 들여쓰기 표로 표시. 추가/수정 모달(상위 메뉴 선택·경로·역할·정렬·사용).
|
||||||
|
*/
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
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 { menuApi, MENU_PRG_TYPES, type MenuNode, type MenuSaveBody } from './adminCrudApi';
|
||||||
|
import './admin-modules.css';
|
||||||
|
|
||||||
|
interface FlatMenu extends MenuNode {
|
||||||
|
depth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 서버 응답(flat 또는 nested)을 parentId 기준 깊이 정렬 평면 목록으로 정규화. */
|
||||||
|
function toDepthList(nodes: MenuNode[]): FlatMenu[] {
|
||||||
|
// children 이 있으면 우선 평탄화
|
||||||
|
const flat: MenuNode[] = [];
|
||||||
|
const walk = (arr: MenuNode[]) => {
|
||||||
|
for (const n of arr) {
|
||||||
|
flat.push(n);
|
||||||
|
if (n.children && n.children.length > 0) walk(n.children);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(nodes);
|
||||||
|
const byId = new Map(flat.map((n) => [n.id, n]));
|
||||||
|
const childrenOf = new Map<string | null, MenuNode[]>();
|
||||||
|
for (const n of flat) {
|
||||||
|
const pid = n.parentId && byId.has(n.parentId) ? n.parentId : null;
|
||||||
|
const arr = childrenOf.get(pid) ?? [];
|
||||||
|
arr.push(n);
|
||||||
|
childrenOf.set(pid, arr);
|
||||||
|
}
|
||||||
|
const out: FlatMenu[] = [];
|
||||||
|
const emit = (pid: string | null, depth: number) => {
|
||||||
|
const arr = (childrenOf.get(pid) ?? []).sort((a, b) => a.sortOrder - b.sortOrder);
|
||||||
|
for (const n of arr) {
|
||||||
|
out.push({ ...n, depth });
|
||||||
|
emit(n.id, depth + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
emit(null, 0);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MenuAdminPage() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { show, node: toast } = useToast();
|
||||||
|
|
||||||
|
const q = useQuery({ queryKey: ['admin-menus'], queryFn: menuApi.all, retry: false });
|
||||||
|
const [editing, setEditing] = useState<MenuNode | null | undefined>(undefined);
|
||||||
|
|
||||||
|
const flat = useMemo(() => toDepthList(q.data ?? []), [q.data]);
|
||||||
|
|
||||||
|
const saveM = useMutation({
|
||||||
|
mutationFn: (body: MenuSaveBody) => menuApi.save(body),
|
||||||
|
onSuccess: () => {
|
||||||
|
show('메뉴를 저장했습니다.');
|
||||||
|
setEditing(undefined);
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin-menus'] });
|
||||||
|
},
|
||||||
|
onError: (e) => show(errMessage(e)),
|
||||||
|
});
|
||||||
|
const delM = useMutation({
|
||||||
|
mutationFn: (id: string) => menuApi.remove(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
show('메뉴를 삭제했습니다.');
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin-menus'] });
|
||||||
|
},
|
||||||
|
onError: (e) => show(errMessage(e)),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="kx-page kx-adm">
|
||||||
|
<header className="kx-adm__head">
|
||||||
|
<div>
|
||||||
|
<h1 className="kx-adm__title">메뉴 관리</h1>
|
||||||
|
<p className="kx-adm__subtitle">역할별 내비게이션 메뉴 트리와 경로·접근 역할을 관리합니다.</p>
|
||||||
|
</div>
|
||||||
|
<Button leadingIcon={<IconPlus size={16} />} onClick={() => setEditing(null)}>메뉴 추가</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{q.isLoading && <Skeleton height={320} radius={12} />}
|
||||||
|
{q.isError && !q.isLoading && (
|
||||||
|
<ErrorState message="메뉴를 불러오지 못했습니다." onRetry={() => q.refetch()} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!q.isLoading && !q.isError && (
|
||||||
|
<section className="kx-card kx-adm-table-card" aria-label="메뉴 트리">
|
||||||
|
{flat.length === 0 ? (
|
||||||
|
<EmptyState title="등록된 메뉴가 없습니다" description="메뉴를 추가하세요." />
|
||||||
|
) : (
|
||||||
|
<div className="kx-table-scroll">
|
||||||
|
<table className="kx-table kx-table--zebra kx-adm-tbl">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>메뉴명</th>
|
||||||
|
<th>경로</th>
|
||||||
|
<th>유형</th>
|
||||||
|
<th>접근 역할</th>
|
||||||
|
<th className="kx-num">정렬</th>
|
||||||
|
<th>사용</th>
|
||||||
|
<th style={{ textAlign: 'right' }}>관리</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{flat.map((m) => (
|
||||||
|
<tr key={m.id}>
|
||||||
|
<td>
|
||||||
|
<span className="kx-adm-menu__name">
|
||||||
|
{m.depth > 0 && (
|
||||||
|
<span className="kx-adm-menu__depth" aria-hidden="true">
|
||||||
|
{' '.repeat(m.depth)}└
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="kx-adm-tbl__strong">{m.menuName}</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="kx-adm-mono">{m.menuPath ?? '—'}</td>
|
||||||
|
<td className="kx-adm-muted">{m.prgType ?? '—'}</td>
|
||||||
|
<td className="kx-adm-muted">{m.requiredRole ?? '전체'}</td>
|
||||||
|
<td className="kx-num tnum">{m.sortOrder}</td>
|
||||||
|
<td>
|
||||||
|
<span className={`kx-adm-pill kx-adm-pill--${m.useYn === 'N' ? 'neutral' : 'success'}`}>
|
||||||
|
{m.useYn === 'N' ? '미사용' : '사용'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="kx-adm-tbl__actions">
|
||||||
|
<Button variant="ghost" onClick={() => setEditing(m)}>수정</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="kx-adm-del"
|
||||||
|
disabled={delM.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`메뉴 '${m.menuName}' 을(를) 삭제하시겠습니까? 하위 메뉴가 있으면 함께 정리됩니다.`))
|
||||||
|
delM.mutate(m.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editing !== undefined && (
|
||||||
|
<MenuModal
|
||||||
|
menu={editing}
|
||||||
|
options={flat}
|
||||||
|
saving={saveM.isPending}
|
||||||
|
onClose={() => setEditing(undefined)}
|
||||||
|
onSubmit={(body) => saveM.mutate(body)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{toast}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuModal({
|
||||||
|
menu,
|
||||||
|
options,
|
||||||
|
saving,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
menu: MenuNode | null;
|
||||||
|
options: FlatMenu[];
|
||||||
|
saving: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (body: MenuSaveBody) => void;
|
||||||
|
}) {
|
||||||
|
const isNew = menu === null;
|
||||||
|
const [id, setId] = useState(menu?.id ?? '');
|
||||||
|
const [menuName, setMenuName] = useState(menu?.menuName ?? '');
|
||||||
|
const [parentId, setParentId] = useState(menu?.parentId ?? '');
|
||||||
|
const [menuPath, setMenuPath] = useState(menu?.menuPath ?? '');
|
||||||
|
const [prgType, setPrgType] = useState(menu?.prgType ?? 'SCREEN');
|
||||||
|
const [requiredRole, setRequiredRole] = useState(menu?.requiredRole ?? '');
|
||||||
|
const [sortOrder, setSortOrder] = useState(String(menu?.sortOrder ?? 0));
|
||||||
|
const [useYn, setUseYn] = useState(menu?.useYn ?? 'Y');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 자기 자신은 상위 후보에서 제외(순환 방지 — 하위까지 엄격 제외는 서버 검증 위임)
|
||||||
|
const parentOptions = options.filter((o) => o.id !== menu?.id);
|
||||||
|
|
||||||
|
function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
if (!menuName.trim()) {
|
||||||
|
setError('메뉴명은 필수입니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isNew && !id.trim()) {
|
||||||
|
setError('메뉴 ID(고유 키)는 필수입니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isNew && options.some((o) => o.id === id.trim())) {
|
||||||
|
setError('이미 존재하는 메뉴 ID 입니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSubmit({
|
||||||
|
id: isNew ? id.trim() : (menu?.id ?? null),
|
||||||
|
parentId: parentId || null,
|
||||||
|
menuName: menuName.trim(),
|
||||||
|
menuPath: menuPath.trim() || null,
|
||||||
|
prgType,
|
||||||
|
requiredRole: requiredRole.trim() || null,
|
||||||
|
sortOrder: Number(sortOrder) || 0,
|
||||||
|
useYn,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="kx-adm-modal" role="dialog" aria-modal="true" aria-labelledby="menu-modal-title">
|
||||||
|
<div className="kx-adm-modal__backdrop" onClick={onClose} />
|
||||||
|
<form className="kx-adm-modal__panel" onSubmit={submit}>
|
||||||
|
<h2 id="menu-modal-title" className="kx-adm-modal__title">{isNew ? '메뉴 추가' : '메뉴 수정'}</h2>
|
||||||
|
<div className="kx-adm-modal__grid">
|
||||||
|
{isNew && (
|
||||||
|
<label className="kx-adm-field kx-adm-field--full">
|
||||||
|
<span className="kx-adm-field__label">메뉴 ID (고유 키) *</span>
|
||||||
|
<input className="kx-adm-field__input kx-adm-mono" value={id} placeholder="예: admin-users" onChange={(e) => setId(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<label className="kx-adm-field">
|
||||||
|
<span className="kx-adm-field__label">메뉴명 *</span>
|
||||||
|
<input className="kx-adm-field__input" value={menuName} onChange={(e) => setMenuName(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="kx-adm-field">
|
||||||
|
<span className="kx-adm-field__label">상위 메뉴</span>
|
||||||
|
<select className="kx-adm-field__select" value={parentId} onChange={(e) => setParentId(e.target.value)}>
|
||||||
|
<option value="">(최상위)</option>
|
||||||
|
{parentOptions.map((o) => (
|
||||||
|
<option key={o.id} value={o.id}>{'—'.repeat(o.depth)} {o.menuName}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="kx-adm-field kx-adm-field--full">
|
||||||
|
<span className="kx-adm-field__label">경로(path)</span>
|
||||||
|
<input className="kx-adm-field__input kx-adm-mono" value={menuPath} placeholder="예: /admin/users" onChange={(e) => setMenuPath(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="kx-adm-field">
|
||||||
|
<span className="kx-adm-field__label">유형</span>
|
||||||
|
<select className="kx-adm-field__select" value={prgType ?? 'SCREEN'} onChange={(e) => setPrgType(e.target.value)}>
|
||||||
|
{MENU_PRG_TYPES.map((t) => (
|
||||||
|
<option key={t} value={t}>{t}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="kx-adm-field">
|
||||||
|
<span className="kx-adm-field__label">접근 역할</span>
|
||||||
|
<input className="kx-adm-field__input kx-adm-mono" value={requiredRole} placeholder="예: ADMIN (비우면 전체)" onChange={(e) => setRequiredRole(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="kx-adm-field">
|
||||||
|
<span className="kx-adm-field__label">정렬</span>
|
||||||
|
<input className="kx-adm-field__input" type="number" value={sortOrder} onChange={(e) => setSortOrder(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="kx-adm-field">
|
||||||
|
<span className="kx-adm-field__label">사용 여부</span>
|
||||||
|
<select className="kx-adm-field__select" value={useYn} onChange={(e) => setUseYn(e.target.value)}>
|
||||||
|
<option value="Y">사용</option>
|
||||||
|
<option value="N">미사용</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</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}>취소</Button>
|
||||||
|
<Button type="submit" disabled={saving}>{saving ? '저장 중…' : '저장'}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
272
src/frontend/src/screens/admin/RoleAdminPage.tsx
Normal file
272
src/frontend/src/screens/admin/RoleAdminPage.tsx
Normal file
@ -0,0 +1,272 @@
|
|||||||
|
/*
|
||||||
|
* M18 역할/권한(RBAC) 관리 (SCR-A2 · §5B). RoleController 실배선.
|
||||||
|
* 정본: GET /api/admin/roles → Role[] · GET /api/admin/roles/permissions → Permission[] · POST 저장 · DELETE /{roleCode}.
|
||||||
|
* UI: 좌 역할 목록(선택), 우 권한 매트릭스(역할 × 권한 체크). 역할 저장 시 permissions[] 갱신.
|
||||||
|
* 상태 3종: 로딩 스켈레톤 / 빈 / 에러(재시도).
|
||||||
|
*/
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
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 { roleApi, type Role } from './adminCrudApi';
|
||||||
|
import './admin-modules.css';
|
||||||
|
|
||||||
|
export function RoleAdminPage() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { show, node: toast } = useToast();
|
||||||
|
|
||||||
|
const rolesQ = useQuery({ queryKey: ['admin-roles'], queryFn: roleApi.list, retry: false });
|
||||||
|
const permsQ = useQuery({ queryKey: ['admin-permissions'], queryFn: roleApi.permissions, retry: false });
|
||||||
|
|
||||||
|
const [selected, setSelected] = useState<string | null>(null);
|
||||||
|
const [editing, setEditing] = useState<Role | null | undefined>(undefined);
|
||||||
|
|
||||||
|
const roles = rolesQ.data ?? [];
|
||||||
|
const perms = permsQ.data ?? [];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selected && roles.length > 0) setSelected(roles[0].roleCode);
|
||||||
|
}, [roles, selected]);
|
||||||
|
|
||||||
|
const activeRole = roles.find((r) => r.roleCode === selected) ?? null;
|
||||||
|
|
||||||
|
const saveM = useMutation({
|
||||||
|
mutationFn: (role: Role) => roleApi.save(role),
|
||||||
|
onSuccess: () => {
|
||||||
|
show('역할을 저장했습니다.');
|
||||||
|
setEditing(undefined);
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin-roles'] });
|
||||||
|
},
|
||||||
|
onError: (e) => show(errMessage(e)),
|
||||||
|
});
|
||||||
|
const delM = useMutation({
|
||||||
|
mutationFn: (roleCode: string) => roleApi.remove(roleCode),
|
||||||
|
onSuccess: () => {
|
||||||
|
show('역할을 삭제했습니다.');
|
||||||
|
setSelected(null);
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin-roles'] });
|
||||||
|
},
|
||||||
|
onError: (e) => show(errMessage(e)),
|
||||||
|
});
|
||||||
|
|
||||||
|
function togglePerm(permCode: string, on: boolean) {
|
||||||
|
if (!activeRole) return;
|
||||||
|
const next = on
|
||||||
|
? [...new Set([...activeRole.permissions, permCode])]
|
||||||
|
: activeRole.permissions.filter((p) => p !== permCode);
|
||||||
|
saveM.mutate({ ...activeRole, permissions: next });
|
||||||
|
}
|
||||||
|
|
||||||
|
const loading = rolesQ.isLoading || permsQ.isLoading;
|
||||||
|
const errored = rolesQ.isError || permsQ.isError;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="kx-page kx-adm">
|
||||||
|
<header className="kx-adm__head">
|
||||||
|
<div>
|
||||||
|
<h1 className="kx-adm__title">역할 · 권한 (RBAC)</h1>
|
||||||
|
<p className="kx-adm__subtitle">
|
||||||
|
역할(주최자·참가업체·장치업체·홀매니저·관리자·관람객)별 리소스 권한을 매트릭스로 배정합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button leadingIcon={<IconPlus size={16} />} onClick={() => setEditing(null)}>역할 추가</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{loading && <Skeleton height={320} radius={12} />}
|
||||||
|
{errored && !loading && (
|
||||||
|
<ErrorState
|
||||||
|
message="역할·권한 정보를 불러오지 못했습니다."
|
||||||
|
onRetry={() => {
|
||||||
|
rolesQ.refetch();
|
||||||
|
permsQ.refetch();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !errored && (
|
||||||
|
roles.length === 0 ? (
|
||||||
|
<EmptyState title="정의된 역할이 없습니다" description="역할을 추가해 RBAC 를 구성하세요." />
|
||||||
|
) : (
|
||||||
|
<div className="kx-adm-matrix">
|
||||||
|
{/* 좌: 역할 목록 */}
|
||||||
|
<section className="kx-card kx-adm-matrix__roles" aria-label="역할 목록">
|
||||||
|
<div className="kx-adm-codes__head">
|
||||||
|
<h2>역할</h2>
|
||||||
|
</div>
|
||||||
|
<ul className="kx-adm-glist">
|
||||||
|
{roles.map((r) => (
|
||||||
|
<li key={r.roleCode}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`kx-adm-gitem ${r.roleCode === selected ? 'is-active' : ''}`}
|
||||||
|
onClick={() => setSelected(r.roleCode)}
|
||||||
|
>
|
||||||
|
<span className="kx-adm-gitem__name">{r.roleName}</span>
|
||||||
|
<span className="kx-adm-gitem__code">
|
||||||
|
{r.roleCode} · 권한 {r.permissions.length}
|
||||||
|
{r.useYn === 'N' ? ' · 미사용' : ''}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 우: 권한 매트릭스 */}
|
||||||
|
<section className="kx-card kx-adm-matrix__perms" aria-label="권한 매트릭스">
|
||||||
|
{activeRole ? (
|
||||||
|
<>
|
||||||
|
<div className="kx-adm-codes__head" style={{ padding: 0, border: 'none', marginBottom: 8 }}>
|
||||||
|
<div>
|
||||||
|
<h2>{activeRole.roleName} 권한</h2>
|
||||||
|
<p className="kx-adm-muted">{activeRole.description ?? activeRole.roleCode}</p>
|
||||||
|
</div>
|
||||||
|
<div className="kx-adm-tbl__actions">
|
||||||
|
<Button variant="ghost" onClick={() => setEditing(activeRole)}>역할 정보</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="kx-adm-del"
|
||||||
|
disabled={delM.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`역할 '${activeRole.roleName}' 을(를) 삭제하시겠습니까?`))
|
||||||
|
delM.mutate(activeRole.roleCode);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{perms.length === 0 ? (
|
||||||
|
<EmptyState title="정의된 권한이 없습니다" />
|
||||||
|
) : (
|
||||||
|
<div className="kx-adm-permgrid">
|
||||||
|
{perms.map((p) => {
|
||||||
|
const on = activeRole.permissions.includes(p.permCode);
|
||||||
|
return (
|
||||||
|
<label key={p.permCode} className="kx-adm-permcheck">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={on}
|
||||||
|
disabled={saveM.isPending}
|
||||||
|
onChange={(e) => togglePerm(p.permCode, e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="kx-adm-permcheck__body">
|
||||||
|
<span className="kx-adm-permcheck__name">{p.permName}</span>
|
||||||
|
<span className="kx-adm-permcheck__code">{p.permCode}</span>
|
||||||
|
{p.description && (
|
||||||
|
<span className="kx-adm-permcheck__desc">{p.description}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<EmptyState title="역할을 선택하세요" />
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editing !== undefined && (
|
||||||
|
<RoleModal
|
||||||
|
role={editing}
|
||||||
|
existingCodes={roles.map((r) => r.roleCode)}
|
||||||
|
saving={saveM.isPending}
|
||||||
|
onClose={() => setEditing(undefined)}
|
||||||
|
onSubmit={(role) => saveM.mutate(role)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{toast}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoleModal({
|
||||||
|
role,
|
||||||
|
existingCodes,
|
||||||
|
saving,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
role: Role | null;
|
||||||
|
existingCodes: string[];
|
||||||
|
saving: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (role: Role) => void;
|
||||||
|
}) {
|
||||||
|
const isNew = role === null;
|
||||||
|
const [roleCode, setRoleCode] = useState(role?.roleCode ?? '');
|
||||||
|
const [roleName, setRoleName] = useState(role?.roleName ?? '');
|
||||||
|
const [description, setDescription] = useState(role?.description ?? '');
|
||||||
|
const [useYn, setUseYn] = useState(role?.useYn ?? 'Y');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
if (!roleCode.trim() || !roleName.trim()) {
|
||||||
|
setError('역할 코드와 이름은 필수입니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isNew && existingCodes.includes(roleCode.trim().toUpperCase())) {
|
||||||
|
setError('이미 존재하는 역할 코드입니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSubmit({
|
||||||
|
roleCode: roleCode.trim().toUpperCase(),
|
||||||
|
roleName: roleName.trim(),
|
||||||
|
description: description.trim() || null,
|
||||||
|
useYn,
|
||||||
|
permissions: role?.permissions ?? [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="kx-adm-modal" role="dialog" aria-modal="true" aria-labelledby="role-modal-title">
|
||||||
|
<div className="kx-adm-modal__backdrop" onClick={onClose} />
|
||||||
|
<form className="kx-adm-modal__panel" onSubmit={submit}>
|
||||||
|
<h2 id="role-modal-title" className="kx-adm-modal__title">{isNew ? '역할 추가' : '역할 정보 수정'}</h2>
|
||||||
|
<p className="kx-adm-modal__desc">권한 배정은 저장 후 매트릭스에서 체크로 관리합니다.</p>
|
||||||
|
<div className="kx-adm-modal__grid">
|
||||||
|
<label className="kx-adm-field">
|
||||||
|
<span className="kx-adm-field__label">역할 코드 *</span>
|
||||||
|
<input
|
||||||
|
className="kx-adm-field__input kx-adm-mono"
|
||||||
|
value={roleCode}
|
||||||
|
disabled={!isNew}
|
||||||
|
placeholder="예: ORGANIZER"
|
||||||
|
onChange={(e) => setRoleCode(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="kx-adm-field">
|
||||||
|
<span className="kx-adm-field__label">역할 이름 *</span>
|
||||||
|
<input className="kx-adm-field__input" value={roleName} onChange={(e) => setRoleName(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="kx-adm-field kx-adm-field--full">
|
||||||
|
<span className="kx-adm-field__label">설명</span>
|
||||||
|
<input className="kx-adm-field__input" value={description} onChange={(e) => setDescription(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="kx-adm-field">
|
||||||
|
<span className="kx-adm-field__label">사용 여부</span>
|
||||||
|
<select className="kx-adm-field__select" value={useYn} onChange={(e) => setUseYn(e.target.value)}>
|
||||||
|
<option value="Y">사용</option>
|
||||||
|
<option value="N">미사용</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</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}>취소</Button>
|
||||||
|
<Button type="submit" disabled={saving}>{saving ? '저장 중…' : '저장'}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
394
src/frontend/src/screens/admin/SysUserAdminPage.tsx
Normal file
394
src/frontend/src/screens/admin/SysUserAdminPage.tsx
Normal file
@ -0,0 +1,394 @@
|
|||||||
|
/*
|
||||||
|
* M18 사용자 관리 (SCR-A1 · §5B). SysUserController 실배선.
|
||||||
|
* 정본: GET /api/admin/users?keyword&status&roleCode&page&size → PageResponse<SysUser> · POST/PUT · PATCH status · POST password/otp reset.
|
||||||
|
* 보안: 비밀번호는 생성 시에만 입력·전송하고 목록/응답에는 노출되지 않는다(SysUserDto 미포함). 재설정은 서버 정책 위임(값 미표시).
|
||||||
|
* 상태 3종: 로딩 스켈레톤 / 빈 / 에러(재시도). 검색·페이지네이션·생성/수정 모달.
|
||||||
|
*/
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
|
||||||
|
import { Button } from '../../components/ui/Button';
|
||||||
|
import { IconPlus, IconSearch } from '../../components/ui/icons';
|
||||||
|
import { errMessage, fmtDateTime, useToast } from '../work/workShared';
|
||||||
|
import {
|
||||||
|
sysUserApi,
|
||||||
|
USER_ROLE_CODES,
|
||||||
|
USER_STATUSES,
|
||||||
|
type SysUser,
|
||||||
|
type UserSaveBody,
|
||||||
|
type UserSearchParams,
|
||||||
|
} from './adminCrudApi';
|
||||||
|
import './admin-modules.css';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
export function SysUserAdminPage() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { show, node: toast } = useToast();
|
||||||
|
|
||||||
|
const [keyword, setKeyword] = useState('');
|
||||||
|
const [status, setStatus] = useState('');
|
||||||
|
const [roleCode, setRoleCode] = useState('');
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [applied, setApplied] = useState<UserSearchParams>({});
|
||||||
|
const [editing, setEditing] = useState<SysUser | null | undefined>(undefined); // undefined=닫힘, null=신규
|
||||||
|
|
||||||
|
const q = useQuery({
|
||||||
|
queryKey: ['admin-users', applied, page],
|
||||||
|
queryFn: () => sysUserApi.list({ ...applied, page, size: PAGE_SIZE }),
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveM = useMutation({
|
||||||
|
mutationFn: ({ userId, body }: { userId: string | null; body: UserSaveBody }) =>
|
||||||
|
userId ? sysUserApi.update(userId, body) : sysUserApi.create(body),
|
||||||
|
onSuccess: () => {
|
||||||
|
show('사용자를 저장했습니다.');
|
||||||
|
setEditing(undefined);
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin-users'] });
|
||||||
|
},
|
||||||
|
onError: (e) => show(errMessage(e)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusM = useMutation({
|
||||||
|
mutationFn: ({ userId, value }: { userId: string; value: string }) =>
|
||||||
|
sysUserApi.changeStatus(userId, value),
|
||||||
|
onSuccess: () => {
|
||||||
|
show('상태를 변경했습니다.');
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin-users'] });
|
||||||
|
},
|
||||||
|
onError: (e) => show(errMessage(e)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const otpM = useMutation({
|
||||||
|
mutationFn: (userId: string) => sysUserApi.resetOtp(userId),
|
||||||
|
onSuccess: () => show('OTP 를 초기화했습니다.'),
|
||||||
|
onError: (e) => show(errMessage(e)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = q.data?.items ?? [];
|
||||||
|
const total = q.data?.total ?? 0;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
setPage(0);
|
||||||
|
setApplied({
|
||||||
|
keyword: keyword.trim() || undefined,
|
||||||
|
status: status || undefined,
|
||||||
|
roleCode: roleCode || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function resetFilters() {
|
||||||
|
setKeyword('');
|
||||||
|
setStatus('');
|
||||||
|
setRoleCode('');
|
||||||
|
setPage(0);
|
||||||
|
setApplied({});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="kx-page kx-adm">
|
||||||
|
<header className="kx-adm__head">
|
||||||
|
<div>
|
||||||
|
<h1 className="kx-adm__title">사용자 관리</h1>
|
||||||
|
<p className="kx-adm__subtitle">
|
||||||
|
계정 생성·수정, 역할 배정, 상태(활성/잠금/비활성)와 OTP 초기화를 관리합니다. 비밀번호는 마스킹되어 표시되지 않습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button leadingIcon={<IconPlus size={16} />} onClick={() => setEditing(null)}>
|
||||||
|
사용자 추가
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* 검색 툴바 */}
|
||||||
|
<section className="kx-card kx-adm-filter" aria-label="사용자 검색">
|
||||||
|
<div className="kx-adm-toolbar">
|
||||||
|
<div className="kx-adm-toolbar__filters">
|
||||||
|
<label className="kx-adm-field">
|
||||||
|
<span className="kx-adm-field__label">검색어</span>
|
||||||
|
<input
|
||||||
|
className="kx-adm-field__input"
|
||||||
|
placeholder="이름·이메일"
|
||||||
|
value={keyword}
|
||||||
|
onChange={(e) => setKeyword(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && applyFilters()}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="kx-adm-field">
|
||||||
|
<span className="kx-adm-field__label">역할</span>
|
||||||
|
<select className="kx-adm-field__select" value={roleCode} onChange={(e) => setRoleCode(e.target.value)}>
|
||||||
|
<option value="">전체</option>
|
||||||
|
{USER_ROLE_CODES.map((r) => (
|
||||||
|
<option key={r} value={r}>{r}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="kx-adm-field">
|
||||||
|
<span className="kx-adm-field__label">상태</span>
|
||||||
|
<select className="kx-adm-field__select" value={status} onChange={(e) => setStatus(e.target.value)}>
|
||||||
|
<option value="">전체</option>
|
||||||
|
{USER_STATUSES.map((s) => (
|
||||||
|
<option key={s} value={s}>{s}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="kx-adm-toolbar__actions">
|
||||||
|
<Button variant="ghost" onClick={resetFilters}>초기화</Button>
|
||||||
|
<Button leadingIcon={<IconSearch size={16} />} onClick={applyFilters}>검색</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{q.isLoading && <TableSkeleton />}
|
||||||
|
{q.isError && !q.isLoading && (
|
||||||
|
<ErrorState message="사용자 목록을 불러오지 못했습니다." onRetry={() => q.refetch()} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!q.isLoading && !q.isError && (
|
||||||
|
<section className="kx-card kx-adm-table-card" aria-label="사용자 목록">
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<EmptyState title="사용자가 없습니다" description="검색 조건을 조정하거나 사용자를 추가하세요." />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="kx-table-scroll">
|
||||||
|
<table className="kx-table kx-table--zebra kx-adm-tbl">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>사용자</th>
|
||||||
|
<th>이메일</th>
|
||||||
|
<th>역할</th>
|
||||||
|
<th>부서</th>
|
||||||
|
<th>상태</th>
|
||||||
|
<th>OTP</th>
|
||||||
|
<th>최근 로그인</th>
|
||||||
|
<th style={{ textAlign: 'right' }}>관리</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((u) => (
|
||||||
|
<tr key={u.userId}>
|
||||||
|
<td>
|
||||||
|
<span className="kx-adm-tbl__strong">{u.displayName ?? '—'}</span>
|
||||||
|
{u.hallManager && (
|
||||||
|
<span className="kx-adm-typechip" style={{ marginLeft: 6 }}>홀매니저</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="kx-adm-muted">{u.email ?? '—'}</td>
|
||||||
|
<td><RoleChip value={u.roleCode} /></td>
|
||||||
|
<td className="kx-adm-muted">{u.deptId ?? '—'}</td>
|
||||||
|
<td><StatusCell status={u.status} /></td>
|
||||||
|
<td>
|
||||||
|
<span className={`kx-adm-pill kx-adm-pill--${u.otpEnabled ? 'success' : 'neutral'}`}>
|
||||||
|
{u.otpEnabled ? '설정됨' : '미설정'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="kx-adm-mono">{u.lastLoginAt ? fmtDateTime(u.lastLoginAt) : '—'}</td>
|
||||||
|
<td>
|
||||||
|
<div className="kx-adm-tbl__actions">
|
||||||
|
<Button variant="ghost" onClick={() => setEditing(u)}>수정</Button>
|
||||||
|
{u.status !== 'LOCKED' ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
disabled={statusM.isPending}
|
||||||
|
onClick={() => statusM.mutate({ userId: u.userId, value: 'LOCKED' })}
|
||||||
|
>
|
||||||
|
잠금
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
disabled={statusM.isPending}
|
||||||
|
onClick={() => statusM.mutate({ userId: u.userId, value: 'ACTIVE' })}
|
||||||
|
>
|
||||||
|
잠금해제
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{u.otpEnabled && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
disabled={otpM.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`${u.displayName ?? u.userId} 의 OTP 를 초기화하시겠습니까?`))
|
||||||
|
otpM.mutate(u.userId);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
OTP초기화
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<Pager page={page} totalPages={totalPages} total={total} onPage={setPage} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editing !== undefined && (
|
||||||
|
<UserModal
|
||||||
|
user={editing}
|
||||||
|
saving={saveM.isPending}
|
||||||
|
onClose={() => setEditing(undefined)}
|
||||||
|
onSubmit={(body) => saveM.mutate({ userId: editing?.userId ?? null, body })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{toast}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoleChip({ value }: { value: string | null }) {
|
||||||
|
const v = (value ?? 'USER').toUpperCase();
|
||||||
|
const cls = v === 'ADMIN' ? 'admin' : v === 'MANAGER' ? 'manager' : 'user';
|
||||||
|
return <span className={`kx-adm-rolechip kx-adm-rolechip--${cls}`}>{v}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusCell({ status }: { status: string | null }) {
|
||||||
|
const v = (status ?? 'ACTIVE').toUpperCase();
|
||||||
|
const tone = v === 'ACTIVE' ? 'success' : v === 'LOCKED' ? 'error' : 'neutral';
|
||||||
|
const label = v === 'ACTIVE' ? '활성' : v === 'LOCKED' ? '잠금' : v === 'DISABLED' ? '비활성' : v;
|
||||||
|
return <span className={`kx-adm-pill kx-adm-pill--${tone}`}>{label}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserModal({
|
||||||
|
user,
|
||||||
|
saving,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
user: SysUser | null;
|
||||||
|
saving: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (body: UserSaveBody) => void;
|
||||||
|
}) {
|
||||||
|
const isNew = user === null;
|
||||||
|
const [email, setEmail] = useState(user?.email ?? '');
|
||||||
|
const [displayName, setDisplayName] = useState(user?.displayName ?? '');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [roleCode, setRoleCode] = useState(user?.roleCode ?? 'USER');
|
||||||
|
const [deptId, setDeptId] = useState(user?.deptId ?? '');
|
||||||
|
const [hallManager, setHallManager] = useState(user?.hallManager ?? false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
if (!email.trim() || !displayName.trim()) {
|
||||||
|
setError('이메일과 표시 이름은 필수입니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isNew && password.trim() === '') {
|
||||||
|
setError('신규 사용자는 초기 비밀번호가 필요합니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body: UserSaveBody = {
|
||||||
|
email: email.trim(),
|
||||||
|
displayName: displayName.trim(),
|
||||||
|
roleCode,
|
||||||
|
deptId: deptId.trim() || null,
|
||||||
|
hallManager,
|
||||||
|
};
|
||||||
|
if (isNew) body.password = password;
|
||||||
|
onSubmit(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="kx-adm-modal" role="dialog" aria-modal="true" aria-labelledby="user-modal-title">
|
||||||
|
<div className="kx-adm-modal__backdrop" onClick={onClose} />
|
||||||
|
<form className="kx-adm-modal__panel" onSubmit={submit}>
|
||||||
|
<h2 id="user-modal-title" className="kx-adm-modal__title">{isNew ? '사용자 추가' : '사용자 수정'}</h2>
|
||||||
|
<p className="kx-adm-modal__desc">
|
||||||
|
{isNew ? '초기 비밀번호는 저장 후 화면에 표시되지 않습니다.' : '이메일은 수정할 수 없습니다. 비밀번호 재설정은 별도 정책을 따릅니다.'}
|
||||||
|
</p>
|
||||||
|
<div className="kx-adm-modal__grid">
|
||||||
|
<label className="kx-adm-field kx-adm-field--full">
|
||||||
|
<span className="kx-adm-field__label">이메일 *</span>
|
||||||
|
<input
|
||||||
|
className="kx-adm-field__input"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
disabled={!isNew}
|
||||||
|
autoComplete="off"
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="kx-adm-field">
|
||||||
|
<span className="kx-adm-field__label">표시 이름 *</span>
|
||||||
|
<input className="kx-adm-field__input" value={displayName} onChange={(e) => setDisplayName(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="kx-adm-field">
|
||||||
|
<span className="kx-adm-field__label">부서</span>
|
||||||
|
<input className="kx-adm-field__input" value={deptId} onChange={(e) => setDeptId(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
{isNew && (
|
||||||
|
<label className="kx-adm-field kx-adm-field--full">
|
||||||
|
<span className="kx-adm-field__label">초기 비밀번호 *</span>
|
||||||
|
<input
|
||||||
|
className="kx-adm-field__input"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
autoComplete="new-password"
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<label className="kx-adm-field">
|
||||||
|
<span className="kx-adm-field__label">역할</span>
|
||||||
|
<select className="kx-adm-field__select" value={roleCode ?? 'USER'} onChange={(e) => setRoleCode(e.target.value)}>
|
||||||
|
{USER_ROLE_CODES.map((r) => (
|
||||||
|
<option key={r} value={r}>{r}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="kx-adm-check" style={{ alignSelf: 'end', paddingBottom: 8 }}>
|
||||||
|
<input type="checkbox" checked={hallManager} onChange={(e) => setHallManager(e.target.checked)} />
|
||||||
|
홀매니저(내부 운영자)
|
||||||
|
</label>
|
||||||
|
</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}>취소</Button>
|
||||||
|
<Button type="submit" disabled={saving}>{saving ? '저장 중…' : '저장'}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Pager({
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
total,
|
||||||
|
onPage,
|
||||||
|
}: {
|
||||||
|
page: number;
|
||||||
|
totalPages: number;
|
||||||
|
total: number;
|
||||||
|
onPage: (p: number) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="kx-adm-pager">
|
||||||
|
<p className="kx-adm-pager__info">총 <strong className="tnum">{total.toLocaleString()}</strong>명</p>
|
||||||
|
<div className="kx-adm-pager__nav">
|
||||||
|
<Button variant="ghost" onClick={() => onPage(Math.max(0, page - 1))} disabled={page === 0}>이전</Button>
|
||||||
|
<span className="kx-adm-pager__page tnum">{page + 1} / {totalPages}</span>
|
||||||
|
<Button variant="ghost" onClick={() => onPage(Math.min(totalPages - 1, page + 1))} disabled={page >= totalPages - 1}>다음</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="kx-card" aria-hidden="true" style={{ display: 'grid', gap: 12 }}>
|
||||||
|
{[0, 1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<Skeleton key={i} height={44} radius={6} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -390,6 +390,199 @@
|
|||||||
.kx-adm-wiz__step.is-todo .kx-adm-wiz__label { color: var(--color-neutral-500); }
|
.kx-adm-wiz__step.is-todo .kx-adm-wiz__label { color: var(--color-neutral-500); }
|
||||||
.kx-adm-wiz__desc { font-size: var(--fs-caption); color: var(--color-neutral-500); }
|
.kx-adm-wiz__desc { font-size: var(--fs-caption); color: var(--color-neutral-500); }
|
||||||
|
|
||||||
|
/* ═══════════ 공통 CRUD (사용자·역할·공통코드·메뉴) ═══════════ */
|
||||||
|
/* 툴바(검색 + 액션) */
|
||||||
|
.kx-adm-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.kx-adm-toolbar__filters { display: flex; align-items: flex-end; gap: var(--space-3); flex-wrap: wrap; }
|
||||||
|
.kx-adm-toolbar__filters .kx-adm-field { min-width: 180px; }
|
||||||
|
.kx-adm-toolbar__actions { display: flex; gap: var(--space-2); }
|
||||||
|
|
||||||
|
/* 일반 관리 테이블(kx-adm-audit 파생) */
|
||||||
|
.kx-adm-tbl { width: 100%; border-collapse: collapse; }
|
||||||
|
.kx-adm-tbl th,
|
||||||
|
.kx-adm-tbl td {
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
text-align: left;
|
||||||
|
font-size: var(--fs-body);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.kx-adm-tbl thead th {
|
||||||
|
background: var(--color-neutral-100);
|
||||||
|
font-size: var(--fs-caption);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-neutral-500);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
border-bottom: var(--border-card);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.kx-adm-tbl tbody tr { border-bottom: 1px solid var(--color-neutral-100); }
|
||||||
|
.kx-adm-tbl tbody tr:hover { background: var(--color-primary-050); }
|
||||||
|
.kx-adm-tbl tbody tr.is-selected { background: var(--color-primary-050); }
|
||||||
|
.kx-adm-tbl__strong { font-weight: 600; color: var(--color-neutral-900); }
|
||||||
|
.kx-adm-tbl__actions { display: flex; gap: var(--space-2); justify-content: flex-end; white-space: nowrap; }
|
||||||
|
|
||||||
|
/* 역할 칩 */
|
||||||
|
.kx-adm-rolechip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
font-size: var(--fs-caption);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.kx-adm-rolechip--admin { color: var(--color-error); background: #fef3f2; }
|
||||||
|
.kx-adm-rolechip--manager { color: var(--color-primary-700); background: var(--color-primary-100); }
|
||||||
|
.kx-adm-rolechip--user { color: var(--color-neutral-500); background: var(--color-neutral-100); }
|
||||||
|
|
||||||
|
/* ── 모달(관리자 격리 — login.css 의존 회피) ── */
|
||||||
|
.kx-adm-modal {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
.kx-adm-modal__backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(15, 23, 42, 0.44);
|
||||||
|
}
|
||||||
|
.kx-adm-modal__panel {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 560px;
|
||||||
|
max-height: calc(100vh - 48px);
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--color-white);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-modal, 0 20px 48px rgba(15, 23, 42, 0.24));
|
||||||
|
padding: var(--space-5, 24px);
|
||||||
|
}
|
||||||
|
.kx-adm-modal__title { font-size: var(--fs-h2); font-weight: 700; color: var(--color-neutral-900); }
|
||||||
|
.kx-adm-modal__desc { margin-top: 4px; font-size: var(--fs-caption); color: var(--color-neutral-500); }
|
||||||
|
.kx-adm-modal__grid {
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
.kx-adm-modal__grid .kx-adm-field--full { grid-column: 1 / -1; }
|
||||||
|
.kx-adm-modal__actions {
|
||||||
|
margin-top: var(--space-5, 24px);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
.kx-adm-modal__error {
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: #fef3f2;
|
||||||
|
color: var(--color-error);
|
||||||
|
font-size: var(--fs-caption);
|
||||||
|
}
|
||||||
|
.kx-adm-field__select {
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 var(--space-3);
|
||||||
|
border: var(--border-card);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-white);
|
||||||
|
font-size: var(--fs-body);
|
||||||
|
color: var(--color-neutral-900);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.kx-adm-secnote {
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-neutral-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 권한 매트릭스(역할 × 권한) ── */
|
||||||
|
.kx-adm-matrix { display: grid; grid-template-columns: 280px 1fr; gap: var(--space-4); align-items: start; }
|
||||||
|
.kx-adm-matrix__roles { padding: 0; overflow: hidden; }
|
||||||
|
.kx-adm-matrix__perms { padding: var(--space-4); }
|
||||||
|
.kx-adm-permgrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
}
|
||||||
|
.kx-adm-permcheck {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
padding: var(--space-3);
|
||||||
|
border: var(--border-card);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.kx-adm-permcheck input { margin-top: 3px; }
|
||||||
|
.kx-adm-permcheck__body { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||||
|
.kx-adm-permcheck__name { font-weight: 600; color: var(--color-neutral-900); font-size: var(--fs-body); }
|
||||||
|
.kx-adm-permcheck__code { font-size: 11px; color: var(--color-neutral-500); font-family: ui-monospace, monospace; }
|
||||||
|
.kx-adm-permcheck__desc { font-size: var(--fs-caption); color: var(--color-neutral-500); }
|
||||||
|
|
||||||
|
/* ── 공통코드 2단(그룹 → 코드) ── */
|
||||||
|
.kx-adm-codes { display: grid; grid-template-columns: 320px 1fr; gap: var(--space-4); align-items: start; }
|
||||||
|
.kx-adm-codes__groups { padding: 0; overflow: hidden; }
|
||||||
|
.kx-adm-codes__values { padding: 0; overflow: hidden; }
|
||||||
|
.kx-adm-codes__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--space-4);
|
||||||
|
border-bottom: var(--border-card);
|
||||||
|
}
|
||||||
|
.kx-adm-codes__head h2 { font-size: var(--fs-h3); font-weight: 700; color: var(--color-neutral-900); }
|
||||||
|
.kx-adm-glist { list-style: none; margin: 0; padding: var(--space-2); display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.kx-adm-gitem {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: var(--space-3);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-left: 4px solid transparent;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-white);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.kx-adm-gitem:hover { background: var(--color-neutral-050); }
|
||||||
|
.kx-adm-gitem.is-active { border-left-color: var(--color-primary-600); background: var(--color-primary-050); }
|
||||||
|
.kx-adm-gitem__name { font-weight: 600; color: var(--color-neutral-900); }
|
||||||
|
.kx-adm-gitem__code { font-size: 11px; color: var(--color-neutral-500); font-family: ui-monospace, monospace; }
|
||||||
|
|
||||||
|
/* ── 메뉴 트리 ── */
|
||||||
|
.kx-adm-menu__name { display: inline-flex; align-items: center; gap: 6px; }
|
||||||
|
.kx-adm-menu__depth { color: var(--color-neutral-200); font-family: ui-monospace, monospace; white-space: pre; }
|
||||||
|
|
||||||
|
/* ── 접근 거부(AdminGuard 403) ── */
|
||||||
|
.kx-adm-denied {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: 64px var(--space-4);
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 480px;
|
||||||
|
}
|
||||||
|
.kx-adm-denied__icon { color: var(--color-error); }
|
||||||
|
.kx-adm-denied__title { font-size: var(--fs-h1); font-weight: 700; color: var(--color-neutral-900); }
|
||||||
|
.kx-adm-denied__desc { font-size: var(--fs-body); color: var(--color-neutral-500); }
|
||||||
|
.kx-adm-denied__code { margin-top: var(--space-3); font-size: var(--fs-caption); font-family: ui-monospace, monospace; color: var(--color-neutral-400, #94a3b8); }
|
||||||
|
|
||||||
/* ── 반응형 ── */
|
/* ── 반응형 ── */
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.kx-adm-filter__grid { grid-template-columns: 1fr 1fr; }
|
.kx-adm-filter__grid { grid-template-columns: 1fr 1fr; }
|
||||||
@ -397,10 +590,14 @@
|
|||||||
.kx-adm-ruleset { grid-template-columns: 1fr; }
|
.kx-adm-ruleset { grid-template-columns: 1fr; }
|
||||||
.kx-adm-tenant { grid-template-columns: 1fr; }
|
.kx-adm-tenant { grid-template-columns: 1fr; }
|
||||||
.kx-adm-add__grid { grid-template-columns: 1fr 1fr; }
|
.kx-adm-add__grid { grid-template-columns: 1fr 1fr; }
|
||||||
|
.kx-adm-matrix { grid-template-columns: 1fr; }
|
||||||
|
.kx-adm-codes { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.kx-adm-filter__grid { grid-template-columns: 1fr; }
|
.kx-adm-filter__grid { grid-template-columns: 1fr; }
|
||||||
.kx-adm-settings { grid-template-columns: 1fr; }
|
.kx-adm-settings { grid-template-columns: 1fr; }
|
||||||
.kx-adm-add__grid { grid-template-columns: 1fr; }
|
.kx-adm-add__grid { grid-template-columns: 1fr; }
|
||||||
.kx-adm-pager { flex-direction: column; align-items: stretch; }
|
.kx-adm-pager { flex-direction: column; align-items: stretch; }
|
||||||
|
.kx-adm-modal__grid { grid-template-columns: 1fr; }
|
||||||
|
.kx-adm-toolbar { flex-direction: column; align-items: stretch; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -338,3 +338,10 @@
|
|||||||
accent-color: var(--color-primary-600);
|
accent-color: var(--color-primary-600);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 관리 모듈 바로가기 스트립(M18 랜딩) ── */
|
||||||
|
.kx-admin__modules {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|||||||
154
src/frontend/src/screens/admin/adminCrudApi.ts
Normal file
154
src/frontend/src/screens/admin/adminCrudApi.ts
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
/*
|
||||||
|
* M18 관리자 CRUD(사용자·역할/권한·공통코드·메뉴) 실배선 API 계약.
|
||||||
|
* 공용 클라이언트(../../api/client)만 사용 — client.ts·endpoints.ts·types.ts 무수정.
|
||||||
|
* 정본: 백엔드 컨트롤러 시그니처(com.zioinfo.kintex.system.*).
|
||||||
|
* - 사용자 /api/admin/users (SysUserController) — 목록 PageResponse
|
||||||
|
* - 역할 /api/admin/roles (RoleController) — 목록·권한·저장·삭제
|
||||||
|
* - 공통코드 /api/common/code-groups·/api/admin/codes/** (CommonCodeController)
|
||||||
|
* - 메뉴 /api/admin/menus (MenuController) — flat 목록·저장·삭제
|
||||||
|
* ★ 보안: 비밀번호·OTP 시크릿은 조회 DTO에 미포함(백엔드 SysUserDto). 비밀번호는 생성/재설정 시 전송만 하고
|
||||||
|
* 화면·목록에는 절대 표시하지 않는다(응답에도 없음).
|
||||||
|
*/
|
||||||
|
import { api } from '../../api/client';
|
||||||
|
import type { PageResponse } from '../../api/types';
|
||||||
|
|
||||||
|
// ══════════ 사용자 관리 (SysUserController) ══════════
|
||||||
|
/** 시스템 사용자 카드 — 민감정보(해시·OTP 시크릿) 미포함. */
|
||||||
|
export interface SysUser {
|
||||||
|
userId: string;
|
||||||
|
email: string | null;
|
||||||
|
displayName: string | null;
|
||||||
|
hallManager: boolean;
|
||||||
|
roleCode: string | null;
|
||||||
|
deptId: string | null;
|
||||||
|
status: string | null;
|
||||||
|
otpEnabled: boolean;
|
||||||
|
lastLoginAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 생성/수정 요청. password 는 생성 시에만(응답·목록 노출 금지). */
|
||||||
|
export interface UserSaveBody {
|
||||||
|
email: string;
|
||||||
|
displayName: string;
|
||||||
|
password?: string;
|
||||||
|
roleCode?: string;
|
||||||
|
deptId?: string | null;
|
||||||
|
hallManager?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserSearchParams {
|
||||||
|
keyword?: string;
|
||||||
|
status?: string;
|
||||||
|
roleCode?: string;
|
||||||
|
page?: number;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sysUserApi = {
|
||||||
|
list: (p: UserSearchParams = {}) => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (p.keyword) qs.set('keyword', p.keyword);
|
||||||
|
if (p.status) qs.set('status', p.status);
|
||||||
|
if (p.roleCode) qs.set('roleCode', p.roleCode);
|
||||||
|
qs.set('page', String(p.page ?? 0));
|
||||||
|
qs.set('size', String(p.size ?? 20));
|
||||||
|
return api.get<PageResponse<SysUser>>(`/api/admin/users?${qs.toString()}`);
|
||||||
|
},
|
||||||
|
create: (body: UserSaveBody) => api.post<SysUser>('/api/admin/users', body),
|
||||||
|
update: (userId: string, body: UserSaveBody) =>
|
||||||
|
api.put<SysUser>(`/api/admin/users/${encodeURIComponent(userId)}`, body),
|
||||||
|
changeStatus: (userId: string, value: string) =>
|
||||||
|
api.patch<void>(`/api/admin/users/${encodeURIComponent(userId)}/status?value=${encodeURIComponent(value)}`),
|
||||||
|
resetPassword: (userId: string, newPassword: string) =>
|
||||||
|
api.post<void>(`/api/admin/users/${encodeURIComponent(userId)}/password`, { newPassword }),
|
||||||
|
resetOtp: (userId: string) =>
|
||||||
|
api.post<void>(`/api/admin/users/${encodeURIComponent(userId)}/otp/reset`),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const USER_ROLE_CODES = ['USER', 'MANAGER', 'ADMIN'] as const;
|
||||||
|
export const USER_STATUSES = ['ACTIVE', 'LOCKED', 'DISABLED'] as const;
|
||||||
|
|
||||||
|
// ══════════ 역할/권한 (RoleController) ══════════
|
||||||
|
export interface Role {
|
||||||
|
roleCode: string;
|
||||||
|
roleName: string;
|
||||||
|
description: string | null;
|
||||||
|
useYn: string | null;
|
||||||
|
permissions: string[];
|
||||||
|
}
|
||||||
|
export interface Permission {
|
||||||
|
permCode: string;
|
||||||
|
permName: string;
|
||||||
|
description: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const roleApi = {
|
||||||
|
list: () => api.get<Role[]>('/api/admin/roles'),
|
||||||
|
permissions: () => api.get<Permission[]>('/api/admin/roles/permissions'),
|
||||||
|
save: (role: Role) => api.post<void>('/api/admin/roles', role),
|
||||||
|
remove: (roleCode: string) => api.del<void>(`/api/admin/roles/${encodeURIComponent(roleCode)}`),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ══════════ 공통코드 (CommonCodeController) ══════════
|
||||||
|
export interface CodeGroup {
|
||||||
|
grpCode: string;
|
||||||
|
grpName: string;
|
||||||
|
description: string | null;
|
||||||
|
useYn: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
}
|
||||||
|
export interface Code {
|
||||||
|
grpCode: string;
|
||||||
|
code: string;
|
||||||
|
codeName: string;
|
||||||
|
codeValue: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
useYn: string | null;
|
||||||
|
attr1: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const codeApi = {
|
||||||
|
groups: () => api.get<CodeGroup[]>('/api/common/code-groups'),
|
||||||
|
codes: (grpCode: string) => api.get<Code[]>(`/api/common/codes/${encodeURIComponent(grpCode)}`),
|
||||||
|
saveGroup: (group: CodeGroup) => api.post<void>('/api/admin/codes/groups', group),
|
||||||
|
removeGroup: (grpCode: string) =>
|
||||||
|
api.del<void>(`/api/admin/codes/groups/${encodeURIComponent(grpCode)}`),
|
||||||
|
saveCode: (code: Code) => api.post<void>('/api/admin/codes', code),
|
||||||
|
removeCode: (grpCode: string, code: string) =>
|
||||||
|
api.del<void>(`/api/admin/codes/${encodeURIComponent(grpCode)}/${encodeURIComponent(code)}`),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ══════════ 메뉴 (MenuController) ══════════
|
||||||
|
export interface MenuNode {
|
||||||
|
id: string;
|
||||||
|
parentId: string | null;
|
||||||
|
menuName: string;
|
||||||
|
menuPath: string | null;
|
||||||
|
icon: string | null;
|
||||||
|
prgType: string | null;
|
||||||
|
requiredRole: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
useYn: string | null;
|
||||||
|
children?: MenuNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 저장 요청(트리 없는 평면 노드). id 가 있으면 수정, 없으면 신규(백엔드 upsert). */
|
||||||
|
export interface MenuSaveBody {
|
||||||
|
id?: string | null;
|
||||||
|
parentId?: string | null;
|
||||||
|
menuName: string;
|
||||||
|
menuPath?: string | null;
|
||||||
|
icon?: string | null;
|
||||||
|
prgType?: string | null;
|
||||||
|
requiredRole?: string | null;
|
||||||
|
sortOrder?: number | null;
|
||||||
|
useYn?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const menuApi = {
|
||||||
|
all: () => api.get<MenuNode[]>('/api/admin/menus'),
|
||||||
|
save: (body: MenuSaveBody) => api.post<void>('/api/admin/menus', body),
|
||||||
|
remove: (id: string) => api.del<void>(`/api/admin/menus/${encodeURIComponent(id)}`),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MENU_PRG_TYPES = ['GROUP', 'SCREEN', 'LINK'] as const;
|
||||||
@ -29,6 +29,7 @@ import {
|
|||||||
OPERATOR_PNL,
|
OPERATOR_PNL,
|
||||||
SECTORS,
|
SECTORS,
|
||||||
} from './sampleAnalytics';
|
} from './sampleAnalytics';
|
||||||
|
import { GlobalOverviewSection } from './GlobalOverviewSection';
|
||||||
import './analytics.css';
|
import './analytics.css';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@ -244,6 +245,9 @@ export function AnalyticsDashboardPage() {
|
|||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 테넌트 전역(행사 횡단) BI — 홀매니저 전용. 권한/오프라인 시 내부에서 숨김. */}
|
||||||
|
<GlobalOverviewSection />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
278
src/frontend/src/screens/analytics/GlobalOverviewSection.tsx
Normal file
278
src/frontend/src/screens/analytics/GlobalOverviewSection.tsx
Normal file
@ -0,0 +1,278 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
CartesianGrid,
|
||||||
|
Line,
|
||||||
|
LineChart,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from 'recharts';
|
||||||
|
import { AiLabel } from '../../components/ui/Badge';
|
||||||
|
import { EmptyState, Skeleton } from '../../components/ui/States';
|
||||||
|
import { CHART } from '../chartColors';
|
||||||
|
import { ApiRequestError } from '../../api/client';
|
||||||
|
import {
|
||||||
|
analyticsOverviewApi,
|
||||||
|
type AnalyticsOverview,
|
||||||
|
type ForecastPoint,
|
||||||
|
} from './analyticsOverviewApi';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* SCR-13 경영분석 — 테넌트 전역(행사 횡단) 섹션. 홀매니저 전용.
|
||||||
|
* 실 API GET /api/analytics/overview?years=N. 권한 없음(FORBIDDEN)·오프라인·미구현이면 섹션 숨김(null).
|
||||||
|
* 행사 간 매출 비교 · 홀 가동률 추이(월별) · 참가사 리텐션 · 수요예측(추세, AI 호출 없음).
|
||||||
|
*/
|
||||||
|
export function GlobalOverviewSection() {
|
||||||
|
const [years, setYears] = useState(5);
|
||||||
|
const q = useQuery({
|
||||||
|
queryKey: ['analytics-overview', years],
|
||||||
|
queryFn: () => analyticsOverviewApi.get(years),
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 권한 없음/오프라인/미구현 → 조용히 숨김(비홀매니저 화면 오염 방지).
|
||||||
|
if (q.isError && isHideable(q.error)) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="kx-bi__global" aria-label="테넌트 전역 경영분석">
|
||||||
|
<div className="kx-bi__global-head">
|
||||||
|
<div>
|
||||||
|
<h2 className="kx-bi__global-title">전사 경영분석 (행사 횡단)</h2>
|
||||||
|
<p className="kx-card__hint">행사 간 매출 비교 · 홀 가동률 추이 · 참가사 리텐션 · 수요예측</p>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
className="kx-select"
|
||||||
|
aria-label="분석 연수"
|
||||||
|
value={years}
|
||||||
|
onChange={(e) => setYears(Number(e.target.value))}
|
||||||
|
>
|
||||||
|
<option value={3}>최근 3년</option>
|
||||||
|
<option value={5}>최근 5년</option>
|
||||||
|
<option value={10}>최근 10년</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{q.isLoading && <Skeleton height={280} radius={12} />}
|
||||||
|
{q.isError && !isHideable(q.error) && (
|
||||||
|
<EmptyState title="전역 집계를 불러오지 못했습니다" description="잠시 후 다시 시도해 주세요." />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{q.data && <OverviewBody data={q.data} />}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OverviewBody({ data }: { data: AnalyticsOverview }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 전역 KPI 밴드 */}
|
||||||
|
<div className="kx-bi__kpis" aria-label="전역 핵심 지표">
|
||||||
|
{data.kpis.length === 0 ? (
|
||||||
|
<EmptyState title="집계된 지표가 없습니다" />
|
||||||
|
) : (
|
||||||
|
data.kpis.map((k, i) => (
|
||||||
|
<div key={`${k.label}-${i}`} className="kx-kpi kx-kpi--normal">
|
||||||
|
<span className="kx-kpi__label">{k.label}</span>
|
||||||
|
<strong className="kx-kpi__value tnum">{k.value}</strong>
|
||||||
|
{k.hint && <span className="kx-kpi__hint">{k.hint}</span>}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="kx-bi__split">
|
||||||
|
{/* 행사 간 매출 비교 */}
|
||||||
|
<section className="kx-card" aria-label="행사 간 매출 비교">
|
||||||
|
<div className="kx-card__head">
|
||||||
|
<h2>행사 간 매출 비교</h2>
|
||||||
|
<AiLabel>상위 8개</AiLabel>
|
||||||
|
</div>
|
||||||
|
<p className="kx-card__hint">부스가 배치된 행사의 임대+유틸리티 매출 순위입니다 (단위: 원).</p>
|
||||||
|
{data.eventComparison.length === 0 ? (
|
||||||
|
<EmptyState title="비교할 행사가 없습니다" description="부스가 배치된 행사 매출이 집계되면 표시됩니다." />
|
||||||
|
) : (
|
||||||
|
<div className="kx-bi__chart">
|
||||||
|
<ResponsiveContainer width="100%" height={280}>
|
||||||
|
<BarChart
|
||||||
|
data={data.eventComparison}
|
||||||
|
layout="vertical"
|
||||||
|
margin={{ top: 8, right: 16, bottom: 0, left: 8 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid stroke={CHART.neutral200} strokeDasharray="3 3" horizontal={false} />
|
||||||
|
<XAxis
|
||||||
|
type="number"
|
||||||
|
tick={{ fontSize: 12, fill: CHART.slate }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={{ stroke: CHART.neutral200 }}
|
||||||
|
tickFormatter={(v) => shortWon(v as number)}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
type="category"
|
||||||
|
dataKey="eventName"
|
||||||
|
tick={{ fontSize: 11, fill: CHART.slate }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
width={120}
|
||||||
|
tickFormatter={(v) => truncate(v as string, 12)}
|
||||||
|
/>
|
||||||
|
<Tooltip contentStyle={TOOLTIP_STYLE} formatter={(v) => formatWon(v as number)} />
|
||||||
|
<Bar dataKey="revenue" name="매출" fill={CHART.primary600} radius={[0, 4, 4, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 참가사 리텐션 */}
|
||||||
|
<section className="kx-card" aria-label="참가사 리텐션">
|
||||||
|
<div className="kx-card__head">
|
||||||
|
<h2>참가사 리텐션</h2>
|
||||||
|
</div>
|
||||||
|
<p className="kx-card__hint">
|
||||||
|
누적 재참가율{' '}
|
||||||
|
<strong className="tnum">{data.retention.retentionPercent.toFixed(1)}%</strong> (
|
||||||
|
{data.retention.returningCompanies.toLocaleString()}/
|
||||||
|
{data.retention.totalCompanies.toLocaleString()} 재참가)
|
||||||
|
</p>
|
||||||
|
{data.retention.byYear.length === 0 ? (
|
||||||
|
<EmptyState title="연도별 리텐션 미집계" description="부스 배정사 원천이 연동되면 표시됩니다." />
|
||||||
|
) : (
|
||||||
|
<div className="kx-bi__chart">
|
||||||
|
<ResponsiveContainer width="100%" height={280}>
|
||||||
|
<LineChart data={data.retention.byYear} margin={{ top: 8, right: 16, bottom: 0, left: -8 }}>
|
||||||
|
<CartesianGrid stroke={CHART.neutral200} strokeDasharray="3 3" vertical={false} />
|
||||||
|
<XAxis dataKey="period" tick={{ fontSize: 12, fill: CHART.slate }} tickLine={false} axisLine={{ stroke: CHART.neutral200 }} />
|
||||||
|
<YAxis tick={{ fontSize: 12, fill: CHART.slate }} tickLine={false} axisLine={false} width={40} tickFormatter={(v) => `${v}%`} />
|
||||||
|
<Tooltip contentStyle={TOOLTIP_STYLE} formatter={(v) => `${(v as number).toFixed(1)}%`} />
|
||||||
|
<Line type="monotone" dataKey="retentionPercent" name="재참가율" stroke={CHART.success} strokeWidth={2} dot={{ r: 3 }} />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="kx-bi__split">
|
||||||
|
{/* 홀 가동률 추이 */}
|
||||||
|
<section className="kx-card" aria-label="홀 가동률 추이">
|
||||||
|
<div className="kx-card__head">
|
||||||
|
<h2>홀 가동률 추이 (월별)</h2>
|
||||||
|
</div>
|
||||||
|
<p className="kx-card__hint">개최 행사-일수 ÷ (홀수 × 그달 일수) 기준 월별 가동률입니다.</p>
|
||||||
|
{data.hallOccupancy.length === 0 ? (
|
||||||
|
<EmptyState title="가동률 데이터가 없습니다" />
|
||||||
|
) : (
|
||||||
|
<div className="kx-bi__chart">
|
||||||
|
<ResponsiveContainer width="100%" height={280}>
|
||||||
|
<BarChart data={data.hallOccupancy} margin={{ top: 8, right: 16, bottom: 0, left: -8 }}>
|
||||||
|
<CartesianGrid stroke={CHART.neutral200} strokeDasharray="3 3" vertical={false} />
|
||||||
|
<XAxis dataKey="period" tick={{ fontSize: 11, fill: CHART.slate }} tickLine={false} axisLine={{ stroke: CHART.neutral200 }} />
|
||||||
|
<YAxis tick={{ fontSize: 12, fill: CHART.slate }} tickLine={false} axisLine={false} width={40} tickFormatter={(v) => `${v}%`} />
|
||||||
|
<Tooltip contentStyle={TOOLTIP_STYLE} formatter={(v) => `${(v as number).toFixed(1)}%`} />
|
||||||
|
<Bar dataKey="occupancyPercent" name="가동률" fill={CHART.primary600} radius={[4, 4, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 수요예측(추세) */}
|
||||||
|
<section className="kx-card" aria-label="수요예측">
|
||||||
|
<div className="kx-card__head">
|
||||||
|
<h2>수요예측 (개최 추세)</h2>
|
||||||
|
<AiLabel>추세 투영</AiLabel>
|
||||||
|
</div>
|
||||||
|
<p className="kx-card__hint">연도별 개최 행사 수 실측 + 최소자승 선형 추세로 향후 2년 투영(점선).</p>
|
||||||
|
<ForecastChart forecast={data.forecast} />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ForecastChart({ forecast }: { forecast: AnalyticsOverview['forecast'] }) {
|
||||||
|
const merged = mergeForecast(forecast.history, forecast.projection);
|
||||||
|
if (merged.length === 0) {
|
||||||
|
return <EmptyState title="예측할 이력이 없습니다" description="연도별 개최 이력이 집계되면 표시됩니다." />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="kx-bi__chart">
|
||||||
|
<ResponsiveContainer width="100%" height={280}>
|
||||||
|
<LineChart data={merged} margin={{ top: 8, right: 16, bottom: 0, left: -8 }}>
|
||||||
|
<CartesianGrid stroke={CHART.neutral200} strokeDasharray="3 3" vertical={false} />
|
||||||
|
<XAxis dataKey="period" tick={{ fontSize: 12, fill: CHART.slate }} tickLine={false} axisLine={{ stroke: CHART.neutral200 }} />
|
||||||
|
<YAxis tick={{ fontSize: 12, fill: CHART.slate }} tickLine={false} axisLine={false} width={40} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={TOOLTIP_STYLE}
|
||||||
|
formatter={(v, name) => (v == null ? ['-', name as string] : [`${Math.round(v as number)}건`, name as string])}
|
||||||
|
/>
|
||||||
|
<Line type="monotone" dataKey="actual" name="실측" stroke={CHART.primary600} strokeWidth={2} dot={{ r: 3 }} connectNulls />
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="projected"
|
||||||
|
name="예측"
|
||||||
|
stroke={CHART.aiAccent}
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeDasharray="5 4"
|
||||||
|
dot={{ r: 3 }}
|
||||||
|
connectNulls
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MergedPoint {
|
||||||
|
period: string;
|
||||||
|
actual: number | null;
|
||||||
|
projected: number | null;
|
||||||
|
}
|
||||||
|
/** 실측+예측을 단일 시리즈로 병합. 마지막 실측점을 예측선에 복제해 두 선을 연결한다. */
|
||||||
|
function mergeForecast(history: ForecastPoint[], projection: ForecastPoint[]): MergedPoint[] {
|
||||||
|
const out: MergedPoint[] = history.map((p) => ({ period: p.period, actual: p.value, projected: null }));
|
||||||
|
if (out.length > 0 && projection.length > 0) {
|
||||||
|
out[out.length - 1].projected = out[out.length - 1].actual; // 브릿지
|
||||||
|
}
|
||||||
|
for (const p of projection) {
|
||||||
|
out.push({ period: p.period, actual: null, projected: p.value });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOOLTIP_STYLE = {
|
||||||
|
fontSize: 12,
|
||||||
|
borderRadius: 8,
|
||||||
|
border: '1px solid #E4E7EC',
|
||||||
|
boxShadow: '0 4px 12px rgba(16,24,40,0.1)',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function formatWon(v: number): string {
|
||||||
|
if (v >= 1e8) return `₩${(v / 1e8).toFixed(2)}억`;
|
||||||
|
if (v >= 1e4) return `₩${Math.round(v / 1e4).toLocaleString()}만`;
|
||||||
|
return `₩${v.toLocaleString()}`;
|
||||||
|
}
|
||||||
|
function shortWon(v: number): string {
|
||||||
|
if (v >= 1e8) return `${(v / 1e8).toFixed(0)}억`;
|
||||||
|
if (v >= 1e4) return `${Math.round(v / 1e4)}만`;
|
||||||
|
return String(v);
|
||||||
|
}
|
||||||
|
function truncate(s: string, n: number): string {
|
||||||
|
return s.length > n ? `${s.slice(0, n)}…` : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 숨김 처리 대상 오류(권한 없음·오프라인·미구현) — 비홀매니저/오프라인 시 섹션 무노출. */
|
||||||
|
function isHideable(error: unknown): boolean {
|
||||||
|
return (
|
||||||
|
error instanceof ApiRequestError &&
|
||||||
|
(error.code === 'FORBIDDEN' ||
|
||||||
|
error.code === 'UNAUTHORIZED' ||
|
||||||
|
error.code === 'NETWORK' ||
|
||||||
|
error.code === 'NOT_FOUND' ||
|
||||||
|
error.code === 'NOT_IMPLEMENTED')
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -73,3 +73,38 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 테넌트 전역(행사 횡단) BI 섹션 ─────────────────────────────── */
|
||||||
|
.kx-bi__global {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
margin-top: var(--space-6);
|
||||||
|
padding-top: var(--space-5);
|
||||||
|
border-top: var(--border-card);
|
||||||
|
}
|
||||||
|
.kx-bi__global-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-4);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.kx-bi__global-title {
|
||||||
|
font-size: var(--fs-h3, 1.125rem);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-neutral-900);
|
||||||
|
}
|
||||||
|
/* 전역 섹션의 하위 split은 동일 폭 2컬럼 */
|
||||||
|
.kx-bi__global .kx-bi__split {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
.kx-kpi__hint {
|
||||||
|
font-size: var(--fs-caption);
|
||||||
|
color: var(--color-neutral-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.kx-bi__global .kx-bi__split {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
69
src/frontend/src/screens/analytics/analyticsOverviewApi.ts
Normal file
69
src/frontend/src/screens/analytics/analyticsOverviewApi.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
/*
|
||||||
|
* SCR-13 경영분석 — 테넌트 전역(행사 횡단) BI 로컬 API.
|
||||||
|
* 백엔드 GET /api/analytics/overview?years=N (com.zioinfo.kintex.analytics.AnalyticsOverviewController).
|
||||||
|
* 홀매니저 전용. 공용 api/{client,endpoints,types}는 수정하지 않고 client의 `api`만 재사용한다.
|
||||||
|
* 계약 shape = AnalyticsOverviewDto(백엔드).
|
||||||
|
*/
|
||||||
|
import { api } from '../../api/client';
|
||||||
|
|
||||||
|
export interface OverviewKpi {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
hint: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventRevenue {
|
||||||
|
eventId: string;
|
||||||
|
eventName: string;
|
||||||
|
revenue: number;
|
||||||
|
boothCount: number;
|
||||||
|
areaM2: number;
|
||||||
|
grade: 'A' | 'B' | 'C' | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OccupancyPoint {
|
||||||
|
period: string; // "YYYY-MM"
|
||||||
|
occupancyPercent: number;
|
||||||
|
bookedDays: number;
|
||||||
|
capacityDays: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RetentionYear {
|
||||||
|
period: string; // "YYYY"
|
||||||
|
companies: number;
|
||||||
|
returningCompanies: number;
|
||||||
|
retentionPercent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Retention {
|
||||||
|
totalCompanies: number;
|
||||||
|
returningCompanies: number;
|
||||||
|
retentionPercent: number;
|
||||||
|
byYear: RetentionYear[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ForecastPoint {
|
||||||
|
period: string; // "YYYY"
|
||||||
|
value: number;
|
||||||
|
projected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Forecast {
|
||||||
|
metric: string;
|
||||||
|
method: string;
|
||||||
|
history: ForecastPoint[];
|
||||||
|
projection: ForecastPoint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsOverview {
|
||||||
|
kpis: OverviewKpi[];
|
||||||
|
eventComparison: EventRevenue[];
|
||||||
|
hallOccupancy: OccupancyPoint[];
|
||||||
|
retention: Retention;
|
||||||
|
forecast: Forecast;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const analyticsOverviewApi = {
|
||||||
|
get: (years = 5) =>
|
||||||
|
api.get<AnalyticsOverview>(`/api/analytics/overview?years=${encodeURIComponent(years)}`),
|
||||||
|
};
|
||||||
@ -4,6 +4,7 @@ import { useAuthStore } from '../../store/authStore';
|
|||||||
import { AiLabel } from '../../components/ui/Badge';
|
import { AiLabel } from '../../components/ui/Badge';
|
||||||
import { AiImage } from '../../components/ui/AiImage';
|
import { AiImage } from '../../components/ui/AiImage';
|
||||||
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
|
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
|
||||||
|
import { IconCheck } from '../../components/ui/icons';
|
||||||
import { dashboardApi } from '../../api/endpoints';
|
import { dashboardApi } from '../../api/endpoints';
|
||||||
import { ApiRequestError } from '../../api/client';
|
import { ApiRequestError } from '../../api/client';
|
||||||
import type { DashboardData } from '../../api/types';
|
import type { DashboardData } from '../../api/types';
|
||||||
@ -92,7 +93,7 @@ export function OrganizerDashboardPage() {
|
|||||||
<span className="kx-timeline__bar" aria-hidden="true" />
|
<span className="kx-timeline__bar" aria-hidden="true" />
|
||||||
)}
|
)}
|
||||||
<span className="kx-timeline__dot" aria-hidden="true">
|
<span className="kx-timeline__dot" aria-hidden="true">
|
||||||
{m.state === 'done' ? '✓' : ''}
|
{m.state === 'done' ? <IconCheck size={12} /> : null}
|
||||||
</span>
|
</span>
|
||||||
<div className="kx-timeline__label">
|
<div className="kx-timeline__label">
|
||||||
<span className="kx-timeline__d tnum">{ddayLabel(m.date)}</span>
|
<span className="kx-timeline__d tnum">{ddayLabel(m.date)}</span>
|
||||||
|
|||||||
@ -23,10 +23,20 @@ import {
|
|||||||
hallUtilizationFor,
|
hallUtilizationFor,
|
||||||
type EventCategory,
|
type EventCategory,
|
||||||
type EventStatus,
|
type EventStatus,
|
||||||
|
type HallUtilRow,
|
||||||
type ScheduleEvent,
|
type ScheduleEvent,
|
||||||
} from './sampleSchedule';
|
} from './sampleSchedule';
|
||||||
|
import { scheduleApi } from './scheduleApi';
|
||||||
import './schedule.css';
|
import './schedule.css';
|
||||||
|
|
||||||
|
/** 집계 API 실패 시 크롤링 샘플로 강등할지 판정(NETWORK/NOT_FOUND/NOT_IMPLEMENTED). */
|
||||||
|
function isDegradable(err: unknown): boolean {
|
||||||
|
return (
|
||||||
|
err instanceof ApiRequestError &&
|
||||||
|
(err.code === 'NETWORK' || err.code === 'NOT_FOUND' || err.code === 'NOT_IMPLEMENTED')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const MOCK = `${import.meta.env.BASE_URL}mock/organizer_dashboard`;
|
const MOCK = `${import.meta.env.BASE_URL}mock/organizer_dashboard`;
|
||||||
const THUMBS = [`${MOCK}/img1.jpg`, `${MOCK}/img2.jpg`, `${MOCK}/img3.jpg`];
|
const THUMBS = [`${MOCK}/img1.jpg`, `${MOCK}/img2.jpg`, `${MOCK}/img3.jpg`];
|
||||||
const TODAY = new Date(2026, 6, 11); // 2026-07-11 (currentDate)
|
const TODAY = new Date(2026, 6, 11); // 2026-07-11 (currentDate)
|
||||||
@ -329,7 +339,28 @@ function MiniCalendar({ cursor, onCursor }: { cursor: Date; onCursor: (d: Date)
|
|||||||
const month = cursor.getMonth();
|
const month = cursor.getMonth();
|
||||||
const firstDay = new Date(year, month, 1).getDay();
|
const firstDay = new Date(year, month, 1).getDay();
|
||||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||||
const eventDays = useMemo(() => eventDaysFor(year, month), [year, month]);
|
|
||||||
|
/*
|
||||||
|
* 실 원천: `GET /api/events/calendar?year=&month=` — event 테이블 기반 월 점유 집계.
|
||||||
|
* 실패(NETWORK/NOT_FOUND/NOT_IMPLEMENTED) 시에만 크롤링 샘플(eventDaysFor)로 폴백.
|
||||||
|
* eventDays 키 포맷은 `${year}-${month+1}-${day}`(무패딩) — 셀 렌더와 정합.
|
||||||
|
*/
|
||||||
|
const query = useQuery<{ days: Set<string>; degraded: boolean }>({
|
||||||
|
queryKey: ['schedule-calendar', year, month],
|
||||||
|
queryFn: async () => {
|
||||||
|
try {
|
||||||
|
const rows = await scheduleApi.calendar(year, month + 1);
|
||||||
|
const days = new Set<string>();
|
||||||
|
for (const r of rows) days.add(`${year}-${month + 1}-${Number(r.date.slice(8, 10))}`);
|
||||||
|
return { days, degraded: false };
|
||||||
|
} catch (err) {
|
||||||
|
if (isDegradable(err)) return { days: eventDaysFor(year, month), degraded: true };
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const eventDays = query.data?.days ?? new Set<string>();
|
||||||
|
const degraded = query.data?.degraded ?? false;
|
||||||
|
|
||||||
const cells: (number | null)[] = [];
|
const cells: (number | null)[] = [];
|
||||||
for (let i = 0; i < firstDay; i++) cells.push(null);
|
for (let i = 0; i < firstDay; i++) cells.push(null);
|
||||||
@ -342,7 +373,7 @@ function MiniCalendar({ cursor, onCursor }: { cursor: Date; onCursor: (d: Date)
|
|||||||
<section className="kx-card kx-cal" aria-label="미니 캘린더">
|
<section className="kx-card kx-cal" aria-label="미니 캘린더">
|
||||||
<div className="kx-cal__head">
|
<div className="kx-cal__head">
|
||||||
<button className="kx-cal__nav" aria-label="이전 달" onClick={() => onCursor(new Date(year, month - 1, 1))}>‹</button>
|
<button className="kx-cal__nav" aria-label="이전 달" onClick={() => onCursor(new Date(year, month - 1, 1))}>‹</button>
|
||||||
<strong>{year}년 {month + 1}월</strong>
|
<strong>{year}년 {month + 1}월{degraded && <span className="kx-bi__degraded"> 오프라인 — 샘플</span>}</strong>
|
||||||
<button className="kx-cal__nav" aria-label="다음 달" onClick={() => onCursor(new Date(year, month + 1, 1))}>›</button>
|
<button className="kx-cal__nav" aria-label="다음 달" onClick={() => onCursor(new Date(year, month + 1, 1))}>›</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="kx-cal__grid" role="grid">
|
<div className="kx-cal__grid" role="grid">
|
||||||
@ -371,12 +402,31 @@ function MiniCalendar({ cursor, onCursor }: { cursor: Date; onCursor: (d: Date)
|
|||||||
function HallUtilization({ cursor }: { cursor: Date }) {
|
function HallUtilization({ cursor }: { cursor: Date }) {
|
||||||
const year = cursor.getFullYear();
|
const year = cursor.getFullYear();
|
||||||
const month = cursor.getMonth();
|
const month = cursor.getMonth();
|
||||||
const rows = useMemo(() => hallUtilizationFor(year, month), [year, month]);
|
|
||||||
|
/*
|
||||||
|
* 실 원천: `GET /api/events/hall-utilization?year=&month=` — event + hall_assignment/hall_label 집계.
|
||||||
|
* 실패(NETWORK/NOT_FOUND/NOT_IMPLEMENTED) 시에만 크롤링 샘플(hallUtilizationFor)로 폴백.
|
||||||
|
*/
|
||||||
|
const query = useQuery<{ rows: HallUtilRow[]; degraded: boolean }>({
|
||||||
|
queryKey: ['schedule-hall-util', year, month],
|
||||||
|
queryFn: async () => {
|
||||||
|
try {
|
||||||
|
const data = await scheduleApi.hallUtilization(year, month + 1);
|
||||||
|
return { rows: data.map((h) => ({ label: h.label, pct: h.pct })), degraded: false };
|
||||||
|
} catch (err) {
|
||||||
|
if (isDegradable(err)) return { rows: hallUtilizationFor(year, month), degraded: true };
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const rows = query.data?.rows ?? [];
|
||||||
|
const degraded = query.data?.degraded ?? false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="kx-card" aria-label="홀 가동률">
|
<section className="kx-card" aria-label="홀 가동률">
|
||||||
<div className="kx-card__head">
|
<div className="kx-card__head">
|
||||||
<h2>홀 가동률 ({year}년 {month + 1}월)</h2>
|
<h2>홀 가동률 ({year}년 {month + 1}월)</h2>
|
||||||
<span className="kx-bi__degraded">크롤링 데이터 기반</span>
|
{degraded && <span className="kx-bi__degraded">오프라인 — 샘플</span>}
|
||||||
</div>
|
</div>
|
||||||
<ul className="kx-barlist">
|
<ul className="kx-barlist">
|
||||||
{rows.map((h) => (
|
{rows.map((h) => (
|
||||||
|
|||||||
29
src/frontend/src/screens/schedule/scheduleApi.ts
Normal file
29
src/frontend/src/screens/schedule/scheduleApi.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
/*
|
||||||
|
* SCR-15 전시 일정 로컬 API 래퍼 — 홀 가동률·캘린더 점유 집계.
|
||||||
|
* 공용 client 의 `api` 만 import(엔드포인트 계약은 백엔드 com.zioinfo.kintex.catalog.EventCatalogController 정본).
|
||||||
|
* 실패(NETWORK/NOT_FOUND/NOT_IMPLEMENTED) 시 호출부에서 크롤링 샘플로 강등한다.
|
||||||
|
*/
|
||||||
|
import { api } from '../../api/client';
|
||||||
|
|
||||||
|
/** 홀 그룹 가동률 항목 (백엔드 HallUtilizationDto 정합). */
|
||||||
|
export interface HallUtilizationDto {
|
||||||
|
label: string;
|
||||||
|
pct: number;
|
||||||
|
busyDays: number;
|
||||||
|
totalDays: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 캘린더 점유 항목 — 행사 진행 날짜만(백엔드 CalendarDayDto 정합). */
|
||||||
|
export interface CalendarDayDto {
|
||||||
|
date: string; // "YYYY-MM-DD"
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const scheduleApi = {
|
||||||
|
/** 지정 월(1~12) 홀 가동률 집계. */
|
||||||
|
hallUtilization: (year: number, month: number) =>
|
||||||
|
api.get<HallUtilizationDto[]>(`/api/events/hall-utilization?year=${year}&month=${month}`),
|
||||||
|
/** 지정 월(1~12) 캘린더 점유. */
|
||||||
|
calendar: (year: number, month: number) =>
|
||||||
|
api.get<CalendarDayDto[]>(`/api/events/calendar?year=${year}&month=${month}`),
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user