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:
zio 2026-07-12 09:44:44 +09:00
parent 335b9aa8a4
commit 8a49a68a6a
34 changed files with 4135 additions and 34 deletions

View File

@ -12,11 +12,11 @@
| 상태 | 항목 | 내용 | 담당 | 편입 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-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 |
| 🔶 | 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 |
| ⬜ | AppShell 기존 네비 배선 | 대시보드·플로어플랜·참가업체·서류·정산 5항목 여전히 `href="#"` — 행사 스코프 라우트라 워크스페이스 컨텍스트 선택 UX 필요(신규 4항목만 배선됨) | FE·DES | C-C |
| ⬜ | `/_styleguide` 노출 방침 | 내부 전용 라우트로만 구분 — 프로덕션 빌드 제외(env 분기 등) 방침 미확정 | FE·TA | E-DEP |
@ -34,8 +34,8 @@
| ⬜ | M10/M12 백엔드 | 사전등록 집계·리드·EDM 발송·스폰서십 (PII는 서버 마스킹 응답 계약) | D-M10/12 |
| ⬜ | M17 CMS 백엔드 | 콘텐츠·게시 전이·다국어·마이크로사이트 스키마 | D-M17 |
| ⬜ | 공개 카탈로그 API | `GET /api/public/events` 등 공개 7갭(G1~G7, port_public.md) — 현재 공개 화면 전부 샘플 | D-M12 |
| ⬜ | 프론트 admin RBAC 가드 | /admin/* 라우트 프론트 가드(백엔드 requireAdmin 403은 확보) | C |
| ⬜ | QA minor 2 | kx-field 접두 격리(auction/admin-modules.css)·★/✓ 글리프 SVG화 | C |
| ✅ | 프론트 admin RBAC 가드 | **완료(2026-07-12)** — AdminGuard + 전 /admin/* 라우트 배선(hallManager 기준·백엔드가 최종 권위) | 완료 |
| ✅ | QA minor 2 | **완료(2026-07-12)** — kx-field 스코프 격리·★ SVG는 기왕 해소, ✓(OrganizerDashboardPage) IconCheck 교체 | 완료 |
| ⬜ | 모바일 B2C 탭 셸 | 관람객 트랙 탭 부재 — M15는 /tickets 라우트만 존재 + RN node_modules 손상(devops 재설치) | 모바일 |
## 2. 백그라운드 산출물 — 커밋 대기·미완 검증 필요

View File

@ -76,7 +76,9 @@
## 7. 배포·운영 함정 (반드시 숙지)
- `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 컨벤션 준수. 신규 매퍼 작성 시 필수 점검.
- **프론트 검증은 `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·일반 단위테스트로는 안 잡힘. `&lt;=`로 이스케이프하거나 비교를 뒤집어라(`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`에서 빌드 재현으로 확보.
- **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) 존재 — 운영 이관 시 제거.
@ -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 | **실데이터·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 | **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 | **배포·핫픽스 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 경유) |

View File

@ -52,4 +52,106 @@ public interface AnalyticsMapper {
WHERE event_id = #{eventId} AND quote->>'total' ~ '^[0-9]+(\\.[0-9]+)?$'
""")
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();
}

View File

@ -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));
}
}

View File

@ -1,9 +1,12 @@
package com.zioinfo.kintex.analytics;
import com.zioinfo.kintex.analytics.dto.AnalyticsDto;
import com.zioinfo.kintex.analytics.dto.AnalyticsOverviewDto;
import org.springframework.stereotype.Service;
import java.time.YearMonth;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@ -63,6 +66,157 @@ public class AnalyticsService {
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) {
if (period == null) {
return 0;

View File

@ -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) {
}
}

View File

@ -32,7 +32,7 @@ public interface AuctionMapper {
a.round,
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",
(a.deadline < now()) AS "closed",
(now() > a.deadline) AS "closed",
a.materials,
a.accent,
a.material_package_id AS "materialPackageId",

View File

@ -2,7 +2,9 @@ package com.zioinfo.kintex.catalog;
import com.zioinfo.kintex.auth.EventAccessGuard;
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.HallUtilizationDto;
import com.zioinfo.kintex.common.ApiResponse;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
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.RestController;
import java.time.LocalDate;
import java.util.List;
/**
@ -32,16 +35,45 @@ public class EventCatalogController {
this.guard = guard;
}
/** GET /api/events?year=&status=ongoing|upcoming|ended — 필터 없으면 전체(시작일 내림차순). */
/** GET /api/events?year=&status=ongoing|upcoming|ended&category= — 필터 없으면 전체(시작일 내림차순). */
@GetMapping
public ApiResponse<List<ExhibitionDto>> list(@AuthenticationPrincipal KintexPrincipal principal,
@RequestParam(required = false) String year,
@RequestParam(required = false) String status,
@RequestParam(required = false) String category,
@RequestParam(required = false, defaultValue = "0") int page,
@RequestParam(required = false, defaultValue = "2000") int size) {
guard.require(principal);
int limit = Math.min(Math.max(size, 1), MAX_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));
}
}

View File

@ -9,16 +9,18 @@ import java.util.Map;
/**
* 전시 일정 카탈로그 매퍼 {@code event} 테이블 read-only 조회(V10 시드 1,098건).
* <p>hallLabel {@code hall_assignment}(대표 우선) {@code hall.label} 조인으로 enrich(없으면 null).
* category·estVisitors 원천 컬럼 부재 항상 null(후속 트랙 enrich). 마이그레이션을 변경하지 않는다.
* <p>hallLabel {@code hall_assignment}(대표 우선) {@code hall.label} 조인 우선, 없으면 V23 백필
* {@code event.hall_label}(크롤 배정 문자열) 폴백. category·estVisitors V23 백필 컬럼을 그대로 반환.
*
* <p>status 필터는 DB 원문(active/ended) 계약 (ongoing|upcoming|ended) 다르므로 <b>날짜 기준</b>으로 판정한다
* 프론트의 재계산 규칙과 동일: ended=end&lt;오늘, ongoing=시작오늘종료, upcoming=시작&gt;오늘.
*
* <p> PG 소문자 폴딩 회피: Map 반환 카멜케이스 별칭은 반드시 {@code AS "camelCase"} 쌍따옴표.
*/
@Mapper
public interface EventCatalogMapper {
/** 카탈로그 조회 — year(시작연도)·status(날짜 기준) 선택 필터. 시작일 내림차순. */
/** 카탈로그 조회 — year(시작연도)·status(날짜 기준)·category 선택 필터. 시작일 내림차순. */
@Select("""
<script>
SELECT e.id,
@ -26,9 +28,9 @@ public interface EventCatalogMapper {
to_char(e.start_date, 'YYYY-MM-DD') AS "startDate",
to_char(e.end_date, 'YYYY-MM-DD') AS "endDate",
e.status,
h.label AS "hallLabel",
CAST(NULL AS varchar) AS category,
CAST(NULL AS integer) AS "estVisitors"
COALESCE(h.label, e.hall_label) AS "hallLabel",
e.category AS "category",
e.est_visitors AS "estVisitors"
FROM event e
LEFT JOIN LATERAL (
SELECT ha.hall_id
@ -42,6 +44,9 @@ public interface EventCatalogMapper {
<if test="year != null and year != ''">
AND extract(year FROM e.start_date) = CAST(#{year} AS integer)
</if>
<if test="category != null and category != ''">
AND e.category = #{category}
</if>
<if test="status == 'ended'">AND e.end_date &lt; CURRENT_DATE</if>
<if test="status == 'ongoing'">AND e.start_date &lt;= CURRENT_DATE AND e.end_date &gt;= CURRENT_DATE</if>
<if test="status == 'upcoming'">AND e.start_date &gt; CURRENT_DATE</if>
@ -52,9 +57,35 @@ public interface EventCatalogMapper {
""")
List<Map<String, Object>> findEvents(@Param("year") String year,
@Param("status") String status,
@Param("category") String category,
@Param("limit") int limit,
@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("""
<script>

View File

@ -1,12 +1,20 @@
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.HallUtilizationDto;
import java.util.List;
/** 전시 일정 카탈로그 서비스 — 전 홀 10년치 행사 목록(멤버십 무관 조회). */
/** 전시 일정 카탈로그 서비스 — 전 홀 10년치 행사 목록·집계(멤버십 무관 조회). */
public interface EventCatalogService {
/** year(시작연도)·status(ongoing|upcoming|ended, 날짜 기준) 선택 필터. 시작일 내림차순. */
List<ExhibitionDto> list(String year, String status, int limit, int offset);
/** year(시작연도)·status(ongoing|upcoming|ended, 날짜 기준)·category 선택 필터. 시작일 내림차순. */
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);
}

View File

@ -1,16 +1,34 @@
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.HallUtilizationDto;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/** 전시 일정 카탈로그 서비스 구현 — {@link EventCatalogMapper} 조회 결과를 DTO로 매핑. */
/** 전시 일정 카탈로그 서비스 구현 — {@link EventCatalogMapper} 조회 결과를 DTO로 매핑·집계. */
@Service
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;
public EventCatalogServiceImpl(EventCatalogMapper mapper) {
@ -18,8 +36,9 @@ public class EventCatalogServiceImpl implements EventCatalogService {
}
@Override
public List<ExhibitionDto> list(String year, String status, int limit, int offset) {
List<Map<String, Object>> rows = mapper.findEvents(nullIfBlank(year), nullIfBlank(status), limit, 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), nullIfBlank(category), limit, offset);
List<ExhibitionDto> out = new ArrayList<>(rows == null ? 0 : rows.size());
if (rows != null) {
for (Map<String, Object> r : rows) {
@ -32,6 +51,124 @@ public class EventCatalogServiceImpl implements EventCatalogService {
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) {
return s == null || s.isBlank() || "all".equalsIgnoreCase(s) ? null : s;
}
@ -49,4 +186,33 @@ public class EventCatalogServiceImpl implements EventCatalogService {
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);
}
}
}

View File

@ -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
) {
}

View File

@ -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
) {
}

View File

@ -93,7 +93,7 @@ public interface VisitorMapper {
@Select("""
<script>
SELECT id,
CASE WHEN char_length(name) <= 1 THEN name
CASE WHEN char_length(name) &lt;= 1 THEN name
WHEN char_length(name) = 2 THEN left(name,1) || '*'
ELSE left(name,1) || repeat('*', char_length(name)-2) || right(name,1)
END AS "nameMasked",

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,7 @@
package com.zioinfo.kintex.analytics;
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.Test;
@ -9,6 +10,7 @@ import java.util.List;
import java.util.Map;
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.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@ -64,4 +66,50 @@ class AnalyticsServiceTest {
assertTrue(dto.sectors().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());
}
}

View File

@ -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, &lt;script&gt; 포함) 파싱 회귀 테스트.
*
* 배경(2026-07-12 배포 크래시): &lt;script&gt; 블록 안의 이스케이프 비교연산 `<` 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> 블록의 '<'는 &lt; 로 이스케이프하거나 비교를 뒤집어라. 원인: " + e.getMessage());
}
}
}
}

View File

@ -50,6 +50,11 @@ import { AuditLogPage } from './screens/admin/AuditLogPage';
import { SystemSettingsPage } from './screens/admin/SystemSettingsPage';
import { RulesetVersionsPage } from './screens/admin/RulesetVersionsPage';
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 {
PublicHomePage,
PublicEventDetailPage,
@ -146,9 +151,9 @@ export function App() {
{/* SCR-15 전시 일정 관리 (M1·M6) */}
<Route path="/schedule" element={<ExhibitionSchedulePage />} />
{/* SCR-16 관리자 대시보드 (M18 랜딩) */}
<Route path="/admin" element={<AdminDashboardPage />} />
<Route path="/admin" element={<AdminGuard><AdminDashboardPage /></AdminGuard>} />
{/* 관리자 — 로그인 슬라이드 관리 (M18) */}
<Route path="/admin/login-slides" element={<LoginSlideAdminPage />} />
<Route path="/admin/login-slides" element={<AdminGuard><LoginSlideAdminPage /></AdminGuard>} />
{/* SCR-17 컴포넌트 가이드 (내부 전용 · 네비 비노출) */}
<Route path="/_styleguide" element={<ComponentGuidePage />} />
@ -185,11 +190,16 @@ export function App() {
<Route path="/cms" element={<CmsWorkflowPage />} />
<Route path="/cms/microsite" element={<MicrositeBuilderPage />} />
<Route path="/cms/i18n" element={<MultilingualCmsPage />} />
{/* SCR-A5~A9 관리자 모듈 (감사로그·설정=실배선, 룰셋·테넌트=샘플) */}
<Route path="/admin/audit" element={<AuditLogPage />} />
<Route path="/admin/settings" element={<SystemSettingsPage />} />
<Route path="/admin/masterdata/rulesets" element={<RulesetVersionsPage />} />
<Route path="/admin/tenants" element={<TenantAdminPage />} />
{/* SCR-A5~A9 관리자 모듈 (감사로그·설정·룰셋=실배선, 테넌트=샘플) — AdminGuard 가드 */}
<Route path="/admin/audit" element={<AdminGuard><AuditLogPage /></AdminGuard>} />
<Route path="/admin/settings" element={<AdminGuard><SystemSettingsPage /></AdminGuard>} />
<Route path="/admin/masterdata/rulesets" element={<AdminGuard><RulesetVersionsPage /></AdminGuard>} />
<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>
{/* SCR-P1~P7 공개 홍보 사이트 — 비로그인, 자체 PublicShell */}

View File

@ -66,6 +66,12 @@ export function AdminDashboardPage() {
</div>
</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 && (
<div className="kx-admin__degraded-bar">
<span className="kx-bi__degraded"> </span>
@ -203,3 +209,15 @@ const FALLBACK: AdminDashboardData = {
})),
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'],
];

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -390,6 +390,199 @@
.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); }
/* ═══════════ 공통 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) {
.kx-adm-filter__grid { grid-template-columns: 1fr 1fr; }
@ -397,10 +590,14 @@
.kx-adm-ruleset { grid-template-columns: 1fr; }
.kx-adm-tenant { grid-template-columns: 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) {
.kx-adm-filter__grid { grid-template-columns: 1fr; }
.kx-adm-settings { grid-template-columns: 1fr; }
.kx-adm-add__grid { grid-template-columns: 1fr; }
.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; }
}

View File

@ -338,3 +338,10 @@
accent-color: var(--color-primary-600);
cursor: pointer;
}
/* ── 관리 모듈 바로가기 스트립(M18 랜딩) ── */
.kx-admin__modules {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
}

View 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;

View File

@ -29,6 +29,7 @@ import {
OPERATOR_PNL,
SECTORS,
} from './sampleAnalytics';
import { GlobalOverviewSection } from './GlobalOverviewSection';
import './analytics.css';
/*
@ -244,6 +245,9 @@ export function AnalyticsDashboardPage() {
</section>
</>
)}
{/* 테넌트 전역(행사 횡단) BI — 홀매니저 전용. 권한/오프라인 시 내부에서 숨김. */}
<GlobalOverviewSection />
</div>
);
}

View 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')
);
}

View File

@ -73,3 +73,38 @@
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;
}
}

View 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)}`),
};

View File

@ -4,6 +4,7 @@ import { useAuthStore } from '../../store/authStore';
import { AiLabel } from '../../components/ui/Badge';
import { AiImage } from '../../components/ui/AiImage';
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
import { IconCheck } from '../../components/ui/icons';
import { dashboardApi } from '../../api/endpoints';
import { ApiRequestError } from '../../api/client';
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__dot" aria-hidden="true">
{m.state === 'done' ? '✓' : ''}
{m.state === 'done' ? <IconCheck size={12} /> : null}
</span>
<div className="kx-timeline__label">
<span className="kx-timeline__d tnum">{ddayLabel(m.date)}</span>

View File

@ -23,10 +23,20 @@ import {
hallUtilizationFor,
type EventCategory,
type EventStatus,
type HallUtilRow,
type ScheduleEvent,
} from './sampleSchedule';
import { scheduleApi } from './scheduleApi';
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 THUMBS = [`${MOCK}/img1.jpg`, `${MOCK}/img2.jpg`, `${MOCK}/img3.jpg`];
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 firstDay = new Date(year, month, 1).getDay();
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)[] = [];
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="미니 캘린더">
<div className="kx-cal__head">
<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>
</div>
<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 }) {
const year = cursor.getFullYear();
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 (
<section className="kx-card" aria-label="홀 가동률">
<div className="kx-card__head">
<h2> ({year} {month + 1})</h2>
<span className="kx-bi__degraded"> </span>
{degraded && <span className="kx-bi__degraded"> </span>}
</div>
<ul className="kx-barlist">
{rows.map((h) => (

View 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}`),
};