From 16bfb238c1e29635d4d222be010b5e4392a1bf93 Mon Sep 17 00:00:00 2001 From: zio Date: Sun, 12 Jul 2026 03:26:47 +0900 Subject: [PATCH] feat: real-data aggregation APIs + TOTP 2FA hardening + work screens (SCR-39~48) - Aggregation packages: analytics/dashboard/ops/admin/catalog (+V13 indexes) - TOTP 2FA: SecretCipher(AES-256-GCM), V12 migration, OTP enroll/challenge UI, OTP_ENFORCE env - Common work screens SCR-39~48 wired to work/* contracts (gaps disabled+tooltip) - M2 booth overlap check (BOOTH_OVERLAP, compliance-v1.1) + 8 unit test classes - QA PASS (boundary cross-check), backend test + frontend tsc green Co-Authored-By: Claude Opus 4.8 (1M context) --- .../admin/AdminDashboardController.java | 35 + .../kintex/admin/AdminDashboardMapper.java | 51 ++ .../kintex/admin/AdminDashboardService.java | 70 ++ .../kintex/admin/dto/AdminDashboardDto.java | 25 + .../kintex/analytics/AnalyticsController.java | 39 + .../kintex/analytics/AnalyticsMapper.java | 55 ++ .../kintex/analytics/AnalyticsService.java | 128 +++ .../kintex/analytics/dto/AnalyticsDto.java | 35 + .../catalog/EventCatalogController.java | 47 + .../kintex/catalog/EventCatalogMapper.java | 73 ++ .../kintex/catalog/EventCatalogService.java | 12 + .../catalog/EventCatalogServiceImpl.java | 52 ++ .../kintex/catalog/dto/ExhibitionDto.java | 18 + .../zioinfo/kintex/config/SecurityConfig.java | 1 + .../kintex/dashboard/DashboardController.java | 35 + .../kintex/dashboard/DashboardMapper.java | 73 ++ .../kintex/dashboard/DashboardService.java | 155 ++++ .../kintex/dashboard/dto/DashboardDto.java | 30 + .../module/m2/FloorplanServiceImpl.java | 3 + .../kintex/module/m2/mapper/BoothMapper.java | 6 + .../com/zioinfo/kintex/ops/OpsController.java | 36 + .../com/zioinfo/kintex/ops/OpsMapper.java | 26 + .../com/zioinfo/kintex/ops/OpsService.java | 39 + .../com/zioinfo/kintex/ops/dto/OpsDto.java | 31 + .../security/AccountSecurityMapper.java | 9 +- .../kintex/security/OtpChallengeStore.java | 12 + .../zioinfo/kintex/security/SecretCipher.java | 97 ++ .../security/SecurityPolicyProperties.java | 8 + .../kintex/security/TwoFactorController.java | 15 +- .../kintex/security/TwoFactorService.java | 71 +- .../kintex/security/dto/OtpEnrollRequest.java | 7 + .../security/dto/SecureLoginResponse.java | 11 +- .../src/main/resources/application.yml | 7 + .../resources/db/migration/V12__totp_2fa.sql | 24 + .../V13__catalog_dashboard_indexes.sql | 12 + .../resources/mybatis/mapper/BoothMapper.xml | 13 + .../resources/rulesets/compliance-v1.json | 15 +- .../analytics/AnalyticsServiceTest.java | 67 ++ .../kintex/common/geo/GeometryCodecTest.java | 64 ++ .../dashboard/DashboardServiceTest.java | 74 ++ .../module/m4/UtilityQuoteCalcTest.java | 87 ++ .../rules/ComplianceRuleEngineTest.java | 145 +++ .../security/LoginAttemptServiceTest.java | 90 ++ .../kintex/security/SecretCipherTest.java | 43 + .../kintex/security/TotpServiceTest.java | 54 ++ src/frontend/src/App.tsx | 33 + src/frontend/src/api/client.ts | 4 +- src/frontend/src/api/endpoints.ts | 235 +++++ src/frontend/src/api/types.ts | 395 ++++++++ .../src/components/layout/AppShell.tsx | 57 +- .../src/components/layout/app-shell.css | 33 + src/frontend/src/hooks/useResolvedEventId.ts | 14 + .../src/screens/admin/AdminDashboardPage.tsx | 234 +++-- src/frontend/src/screens/admin/admin.css | 14 + .../analytics/AnalyticsDashboardPage.tsx | 414 +++++---- .../dashboard/OrganizerDashboardPage.tsx | 352 ++++--- .../src/screens/dashboard/dashboard.css | 11 + src/frontend/src/screens/login/LoginPage.tsx | 72 +- .../src/screens/login/OtpChallengePanel.tsx | 136 +++ .../src/screens/login/OtpSetupPage.tsx | 168 ++++ src/frontend/src/screens/login/login.css | 64 ++ .../src/screens/ops/HallOperationsPage.tsx | 353 ++++---- src/frontend/src/screens/ops/ops.css | 19 + .../schedule/ExhibitionSchedulePage.tsx | 116 ++- src/frontend/src/screens/shared.css | 4 + src/frontend/src/screens/work/MeetingPage.tsx | 324 +++++++ src/frontend/src/screens/work/MessagePage.tsx | 258 ++++++ src/frontend/src/screens/work/MyPage.tsx | 243 +++++ src/frontend/src/screens/work/NoticePage.tsx | 255 ++++++ .../screens/work/NotificationCenterPage.tsx | 171 ++++ src/frontend/src/screens/work/OpinionPage.tsx | 284 ++++++ src/frontend/src/screens/work/ReportPage.tsx | 251 +++++ src/frontend/src/screens/work/SearchPage.tsx | 179 ++++ .../src/screens/work/WorkSchedulePage.tsx | 322 +++++++ src/frontend/src/screens/work/WorklogPage.tsx | 339 +++++++ src/frontend/src/screens/work/work.css | 855 ++++++++++++++++++ src/frontend/src/screens/work/workShared.tsx | 85 ++ 77 files changed, 7718 insertions(+), 576 deletions(-) create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/admin/AdminDashboardController.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/admin/AdminDashboardMapper.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/admin/AdminDashboardService.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/admin/dto/AdminDashboardDto.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/analytics/AnalyticsController.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/analytics/AnalyticsMapper.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/analytics/AnalyticsService.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/analytics/dto/AnalyticsDto.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/catalog/EventCatalogController.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/catalog/EventCatalogMapper.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/catalog/EventCatalogService.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/catalog/EventCatalogServiceImpl.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/catalog/dto/ExhibitionDto.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/dashboard/DashboardController.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/dashboard/DashboardMapper.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/dashboard/DashboardService.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/dashboard/dto/DashboardDto.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/ops/OpsController.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/ops/OpsMapper.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/ops/OpsService.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/ops/dto/OpsDto.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/security/SecretCipher.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/security/dto/OtpEnrollRequest.java create mode 100644 src/backend/src/main/resources/db/migration/V12__totp_2fa.sql create mode 100644 src/backend/src/main/resources/db/migration/V13__catalog_dashboard_indexes.sql create mode 100644 src/backend/src/test/java/com/zioinfo/kintex/analytics/AnalyticsServiceTest.java create mode 100644 src/backend/src/test/java/com/zioinfo/kintex/common/geo/GeometryCodecTest.java create mode 100644 src/backend/src/test/java/com/zioinfo/kintex/dashboard/DashboardServiceTest.java create mode 100644 src/backend/src/test/java/com/zioinfo/kintex/module/m4/UtilityQuoteCalcTest.java create mode 100644 src/backend/src/test/java/com/zioinfo/kintex/rules/ComplianceRuleEngineTest.java create mode 100644 src/backend/src/test/java/com/zioinfo/kintex/security/LoginAttemptServiceTest.java create mode 100644 src/backend/src/test/java/com/zioinfo/kintex/security/SecretCipherTest.java create mode 100644 src/backend/src/test/java/com/zioinfo/kintex/security/TotpServiceTest.java create mode 100644 src/frontend/src/hooks/useResolvedEventId.ts create mode 100644 src/frontend/src/screens/login/OtpChallengePanel.tsx create mode 100644 src/frontend/src/screens/login/OtpSetupPage.tsx create mode 100644 src/frontend/src/screens/work/MeetingPage.tsx create mode 100644 src/frontend/src/screens/work/MessagePage.tsx create mode 100644 src/frontend/src/screens/work/MyPage.tsx create mode 100644 src/frontend/src/screens/work/NoticePage.tsx create mode 100644 src/frontend/src/screens/work/NotificationCenterPage.tsx create mode 100644 src/frontend/src/screens/work/OpinionPage.tsx create mode 100644 src/frontend/src/screens/work/ReportPage.tsx create mode 100644 src/frontend/src/screens/work/SearchPage.tsx create mode 100644 src/frontend/src/screens/work/WorkSchedulePage.tsx create mode 100644 src/frontend/src/screens/work/WorklogPage.tsx create mode 100644 src/frontend/src/screens/work/work.css create mode 100644 src/frontend/src/screens/work/workShared.tsx diff --git a/src/backend/src/main/java/com/zioinfo/kintex/admin/AdminDashboardController.java b/src/backend/src/main/java/com/zioinfo/kintex/admin/AdminDashboardController.java new file mode 100644 index 0000000..3cdb504 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/admin/AdminDashboardController.java @@ -0,0 +1,35 @@ +package com.zioinfo.kintex.admin; + +import com.zioinfo.kintex.admin.dto.AdminDashboardDto; +import com.zioinfo.kintex.auth.KintexPrincipal; +import com.zioinfo.kintex.common.ApiResponse; +import com.zioinfo.kintex.system.SystemAccessGuard; +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; + +/** + * 관리자 백오피스 대시보드 API (SCR-14). 시스템관리자(홀매니저/ADMIN)만 접근. + */ +@RestController +@RequestMapping("/api/admin/dashboard") +public class AdminDashboardController { + + private final AdminDashboardService service; + private final SystemAccessGuard guard; + + public AdminDashboardController(AdminDashboardService service, SystemAccessGuard guard) { + this.service = service; + this.guard = guard; + } + + /** GET — 전 행사 KPI·라이브 행사·(원천 있으면)관람객 추이. */ + @GetMapping + public ApiResponse get(@AuthenticationPrincipal KintexPrincipal principal, + @RequestParam(required = false, defaultValue = "KINTEX") String tenant) { + guard.requireAdmin(principal); + return ApiResponse.ok(service.getDashboard(tenant)); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/admin/AdminDashboardMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/admin/AdminDashboardMapper.java new file mode 100644 index 0000000..f66b816 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/admin/AdminDashboardMapper.java @@ -0,0 +1,51 @@ +package com.zioinfo.kintex.admin; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Select; + +import java.util.List; +import java.util.Map; + +/** + * 관리자 대시보드 집계 매퍼 — event·hall_assignment·app_user·company read-only. + * 진행 여부는 오늘 날짜(start≤오늘≤end) 기준. 마이그레이션 불변. + */ +@Mapper +public interface AdminDashboardMapper { + + /** KPI 원자 집계 — 진행 행사·사용 홀·활성 사용자·등록업체 수. */ + @Select(""" + SELECT + (SELECT count(*) FROM event + WHERE start_date <= CURRENT_DATE AND end_date >= CURRENT_DATE) AS ongoingEvents, + (SELECT count(DISTINCT ha.hall_id) FROM hall_assignment ha JOIN event e ON e.id = ha.event_id + WHERE e.start_date <= CURRENT_DATE AND e.end_date >= CURRENT_DATE) AS hallsInUse, + (SELECT count(*) FROM app_user WHERE status = 'ACTIVE') AS activeUsers, + (SELECT count(*) FROM company) AS companies, + (SELECT count(*) FROM event + WHERE start_date > CURRENT_DATE) AS upcomingEvents + """) + Map findKpiCounts(); + + /** 진행 중(라이브) 행사 — 대표 홀·부스 수·홀 수용량. 최대 20건. */ + @Select(""" + SELECT e.id AS eventId, + e.name, + COALESCE(h.label, '') AS hall, + e.status, + (SELECT count(*) FROM booth b JOIN layout l ON l.id = b.layout_id + WHERE l.event_id = e.id) AS boothCount, + COALESCE((SELECT sum(h2.booth_capacity) FROM hall_assignment ha2 + JOIN hall h2 ON h2.id = ha2.hall_id WHERE ha2.event_id = e.id), 0) AS capacity + FROM event e + LEFT JOIN LATERAL ( + SELECT hall_id FROM hall_assignment ha + WHERE ha.event_id = e.id ORDER BY is_primary DESC LIMIT 1 + ) pa ON true + LEFT JOIN hall h ON h.id = pa.hall_id + WHERE e.start_date <= CURRENT_DATE AND e.end_date >= CURRENT_DATE + ORDER BY e.start_date DESC + LIMIT 20 + """) + List> findLiveEvents(); +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/admin/AdminDashboardService.java b/src/backend/src/main/java/com/zioinfo/kintex/admin/AdminDashboardService.java new file mode 100644 index 0000000..fc4ca56 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/admin/AdminDashboardService.java @@ -0,0 +1,70 @@ +package com.zioinfo.kintex.admin; + +import com.zioinfo.kintex.admin.dto.AdminDashboardDto; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * 관리자 대시보드 서비스 — 실 테이블 집계로 KPI·라이브 행사를 조립. + * 관람객 추이는 센서/집계 인프라 부재 → 빈배열. 테넌트는 실 데이터(KINTEX)만 반환. + */ +@Service +public class AdminDashboardService { + + private final AdminDashboardMapper mapper; + + public AdminDashboardService(AdminDashboardMapper mapper) { + this.mapper = mapper; + } + + public AdminDashboardDto getDashboard(String tenant) { + Map c = mapper.findKpiCounts(); + long ongoing = lng(c == null ? null : c.get("ongoingEvents")); + long halls = lng(c == null ? null : c.get("hallsInUse")); + long users = lng(c == null ? null : c.get("activeUsers")); + long companies = lng(c == null ? null : c.get("companies")); + long upcoming = lng(c == null ? null : c.get("upcomingEvents")); + + List kpis = new ArrayList<>(); + kpis.add(new AdminDashboardDto.Kpi("진행 행사", String.valueOf(ongoing), halls + "개 홀")); + kpis.add(new AdminDashboardDto.Kpi("예정 행사", String.valueOf(upcoming), null)); + kpis.add(new AdminDashboardDto.Kpi("활성 사용자", String.valueOf(users), null)); + kpis.add(new AdminDashboardDto.Kpi("등록업체", String.valueOf(companies), null)); + + List live = new ArrayList<>(); + List> rows = mapper.findLiveEvents(); + if (rows != null) { + for (Map r : rows) { + long booths = lng(r.get("boothCount")); + long capacity = lng(r.get("capacity")); + int occupancy = capacity > 0 ? (int) Math.min(100, Math.round(booths * 100.0 / capacity)) : 0; + live.add(new AdminDashboardDto.LiveEvent( + str(r.get("eventId")), str(r.get("name")), str(r.get("hall")), + occupancy, "ongoing")); + } + } + + // 관람객 추이: 센서/집계 원천 부재 → 빈배열(정직). 테넌트: 실 데이터 KINTEX 만. + List visitorTrend = new ArrayList<>(); + List tenants = List.of("KINTEX"); + + return new AdminDashboardDto(kpis, visitorTrend, live, tenants); + } + + private static long lng(Object o) { + if (o == null) return 0L; + if (o instanceof Number n) return n.longValue(); + try { + return Long.parseLong(String.valueOf(o)); + } catch (NumberFormatException e) { + return 0L; + } + } + + private static String str(Object o) { + return o == null ? null : String.valueOf(o); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/admin/dto/AdminDashboardDto.java b/src/backend/src/main/java/com/zioinfo/kintex/admin/dto/AdminDashboardDto.java new file mode 100644 index 0000000..e13a836 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/admin/dto/AdminDashboardDto.java @@ -0,0 +1,25 @@ +package com.zioinfo.kintex.admin.dto; + +import java.util.List; + +/** + * 관리자 백오피스 대시보드 (SCR-14 / 06_backend_api_gaps §4). + * 집계원: event(진행/라이브)·hall_assignment·app_user·company. 관람객 추이는 센서/집계 부재 → 빈배열(정직). + */ +public record AdminDashboardDto( + List kpis, + List visitorTrend, + List liveEvents, + List tenants +) { + public record Kpi(String label, String value, String sub) { + } + + /** 시간대별 관람객(원천 부재 시 미집계). */ + public record VisitorPoint(String hour, long count) { + } + + /** 라이브(진행 중) 행사 — 점유율=부스수/홀 수용. */ + public record LiveEvent(String eventId, String name, String hall, int occupancy, String status) { + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/analytics/AnalyticsController.java b/src/backend/src/main/java/com/zioinfo/kintex/analytics/AnalyticsController.java new file mode 100644 index 0000000..70fd5b8 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/analytics/AnalyticsController.java @@ -0,0 +1,39 @@ +package com.zioinfo.kintex.analytics; + +import com.zioinfo.kintex.analytics.dto.AnalyticsDto; +import com.zioinfo.kintex.auth.EventAccessGuard; +import com.zioinfo.kintex.auth.KintexPrincipal; +import com.zioinfo.kintex.common.ApiResponse; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 경영분석 BI API (SCR-13). 행사 멤버/홀매니저 열람. + * perspective(operator|exhibitor)·period(event|d90|annual)로 집계 세트가 갈린다. + */ +@RestController +@RequestMapping("/api/events/{eventId}/analytics") +public class AnalyticsController { + + private final AnalyticsService service; + private final EventAccessGuard guard; + + public AnalyticsController(AnalyticsService service, EventAccessGuard guard) { + this.service = service; + this.guard = guard; + } + + /** GET — KPI·매출 추이·손익·(원천 있으면)업종·참가업체 성과. */ + @GetMapping + public ApiResponse get(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String eventId, + @RequestParam(required = false, defaultValue = "operator") String perspective, + @RequestParam(required = false, defaultValue = "event") String period) { + guard.requireEventAccess(principal, eventId); + return ApiResponse.ok(service.getAnalytics(eventId, perspective, period)); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/analytics/AnalyticsMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/analytics/AnalyticsMapper.java new file mode 100644 index 0000000..ae4edba --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/analytics/AnalyticsMapper.java @@ -0,0 +1,55 @@ +package com.zioinfo.kintex.analytics; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; +import java.util.Map; + +/** + * 경영분석 BI 집계 매퍼 — booth(PostGIS ST_Area)·utility_order·master_data(요율) read-only. + * 매출은 판매면적×임대 요율(원/㎡·12h)로 산출한다. 마이그레이션 불변. + */ +@Mapper +public interface AnalyticsMapper { + + /** 전시홀 임대 요율(원/㎡) — master_data RATE.exhibitionHallPerM2. 없으면 null. */ + @Select(""" + SELECT (value_json #>> '{}')::numeric + FROM master_data WHERE category = 'RATE' AND code = 'exhibitionHallPerM2' + """) + Double findRentalRatePerM2(); + + /** 행사 부스 총 판매면적(㎡)·부스 수. */ + @Select(""" + SELECT COALESCE(sum(ST_Area(b.geom)), 0) AS salesArea, + count(*) AS boothCount + FROM booth b JOIN layout l ON l.id = b.layout_id + WHERE l.event_id = #{eventId} + """) + Map findAreaAndCount(@Param("eventId") String eventId); + + /** 월별 부스 생성 추이(period=YYYY-MM, 면적·부스 수). days>0 이면 최근 N일로 제한. */ + @Select(""" + + """) + List> findMonthlyTrend(@Param("eventId") String eventId, @Param("days") int days); + + /** 유틸리티 매출 — quote.total 합계(숫자 형태만, 없으면 0). jsonb {@code ?} 연산자는 JDBC 파라미터와 충돌하므로 회피. */ + @Select(""" + SELECT COALESCE(sum((quote->>'total')::numeric), 0) + FROM utility_order + WHERE event_id = #{eventId} AND quote->>'total' ~ '^[0-9]+(\\.[0-9]+)?$' + """) + Double findUtilityRevenue(@Param("eventId") String eventId); +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/analytics/AnalyticsService.java b/src/backend/src/main/java/com/zioinfo/kintex/analytics/AnalyticsService.java new file mode 100644 index 0000000..5e15c52 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/analytics/AnalyticsService.java @@ -0,0 +1,128 @@ +package com.zioinfo.kintex.analytics; + +import com.zioinfo.kintex.analytics.dto.AnalyticsDto; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * 경영분석 BI 서비스 — 판매면적×임대 요율로 매출을 산출하고 부스/유틸리티 실적을 조립. + * perspective(operator|exhibitor)에 따라 필드 세트가 갈린다. 원천 부재 지표는 빈배열(정직). + */ +@Service +public class AnalyticsService { + + private final AnalyticsMapper mapper; + + public AnalyticsService(AnalyticsMapper mapper) { + this.mapper = mapper; + } + + public AnalyticsDto getAnalytics(String eventId, String perspective, String period) { + boolean exhibitor = "exhibitor".equalsIgnoreCase(perspective); + int days = periodDays(period); + + Double rate = mapper.findRentalRatePerM2(); + double ratePerM2 = rate == null ? 0.0 : rate; + + Map ac = mapper.findAreaAndCount(eventId); + double salesArea = dbl(ac == null ? null : ac.get("salesArea")); + long boothCount = lng(ac == null ? null : ac.get("boothCount")); + long rentalRevenue = Math.round(salesArea * ratePerM2); + long utilityRevenue = Math.round(dbl(mapper.findUtilityRevenue(eventId))); + + List kpis = new ArrayList<>(); + kpis.add(new AnalyticsDto.Kpi("임대 매출", formatKrw(rentalRevenue), null, "flat")); + kpis.add(new AnalyticsDto.Kpi("유틸리티 매출", formatKrw(utilityRevenue), null, "flat")); + kpis.add(new AnalyticsDto.Kpi("판매 부스", String.valueOf(boothCount), null, "flat")); + kpis.add(new AnalyticsDto.Kpi("판매 면적", Math.round(salesArea) + "㎡", null, "flat")); + + List trend = new ArrayList<>(); + List> trendRows = mapper.findMonthlyTrend(eventId, days); + if (trendRows != null) { + for (Map r : trendRows) { + long area = Math.round(dbl(r.get("salesArea"))); + trend.add(new AnalyticsDto.TrendPoint( + str(r.get("period")), Math.round(area * ratePerM2), lng(r.get("booths")))); + } + } + + // 손익(operator 관점) — 실 매출 항목만. 업종 분류 원천 부재 → sectors 미집계. + List pnl = new ArrayList<>(); + List sectors = new ArrayList<>(); + if (!exhibitor) { + pnl.add(new AnalyticsDto.PnlRow("부스 임대", rentalRevenue, grade(rentalRevenue))); + pnl.add(new AnalyticsDto.PnlRow("유틸리티", utilityRevenue, grade(utilityRevenue))); + } + + // 참가업체 성과 — 방문자 리드(M13) 원천 부재 → 미집계(빈배열). + List exhibitorPerf = new ArrayList<>(); + + return new AnalyticsDto(kpis, trend, sectors, pnl, exhibitorPerf); + } + + private static int periodDays(String period) { + if (period == null) { + return 0; + } + return switch (period.toLowerCase()) { + case "d90" -> 90; + case "annual" -> 365; + default -> 0; // event = 전체 기간 + }; + } + + /** 금액 등급 — 억 단위 임계(단순·결정적). 원천 임계 룰셋 확정 시 교체. */ + private static String grade(long amount) { + if (amount >= 1_000_000_000L) return "A"; + if (amount >= 100_000_000L) return "B"; + return "C"; + } + + /** 원화 축약 표기(억/만). */ + private static String formatKrw(long won) { + if (won >= 100_000_000L) { + double eok = won / 100_000_000.0; + return "₩" + trim(eok) + "억"; + } + if (won >= 10_000L) { + double man = won / 10_000.0; + return "₩" + trim(man) + "만"; + } + return "₩" + won; + } + + private static String trim(double v) { + double r = Math.round(v * 100.0) / 100.0; + if (r == Math.floor(r)) { + return String.valueOf((long) r); + } + return String.valueOf(r); + } + + private static double dbl(Object o) { + if (o == null) return 0.0; + if (o instanceof Number n) return n.doubleValue(); + try { + return Double.parseDouble(String.valueOf(o)); + } catch (NumberFormatException e) { + return 0.0; + } + } + + private static long lng(Object o) { + if (o == null) return 0L; + if (o instanceof Number n) return n.longValue(); + try { + return Long.parseLong(String.valueOf(o)); + } catch (NumberFormatException e) { + return 0L; + } + } + + private static String str(Object o) { + return o == null ? null : String.valueOf(o); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/analytics/dto/AnalyticsDto.java b/src/backend/src/main/java/com/zioinfo/kintex/analytics/dto/AnalyticsDto.java new file mode 100644 index 0000000..6bd70af --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/analytics/dto/AnalyticsDto.java @@ -0,0 +1,35 @@ +package com.zioinfo.kintex.analytics.dto; + +import java.util.List; + +/** + * 경영분석 BI 집계 (SCR-13 / 06_backend_api_gaps §3). + * 집계원: booth 면적(ST_Area)×임대 요율(master_data)·utility_order.quote·booth 월별 추이. + * 업종(sectors)·리드(exhibitorPerf)는 원천(M13 방문자/업종 분류) 부재 → 빈배열(정직 반환). + */ +public record AnalyticsDto( + List kpis, + List trend, + List sectors, + List pnl, + List exhibitorPerf +) { + public record Kpi(String label, String value, String delta, String trend) { + } + + /** 월별 매출·부스 추이(period="YYYY-MM"). */ + public record TrendPoint(String period, long revenue, long booths) { + } + + /** 업종 구성(원천 부재 시 미집계). */ + public record Sector(String name, double sharePercent, long revenue) { + } + + /** 손익 항목(grade: A|B|C, 금액 기준 등급). */ + public record PnlRow(String item, long amount, String grade) { + } + + /** 참가업체 성과(리드·전환율 — M13 연동 필요). */ + public record ExhibitorPerf(String companyName, long leads, double conversionPercent) { + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/catalog/EventCatalogController.java b/src/backend/src/main/java/com/zioinfo/kintex/catalog/EventCatalogController.java new file mode 100644 index 0000000..96a62f2 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/catalog/EventCatalogController.java @@ -0,0 +1,47 @@ +package com.zioinfo.kintex.catalog; + +import com.zioinfo.kintex.auth.EventAccessGuard; +import com.zioinfo.kintex.auth.KintexPrincipal; +import com.zioinfo.kintex.catalog.dto.ExhibitionDto; +import com.zioinfo.kintex.common.ApiResponse; +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; + +import java.util.List; + +/** + * 전시 일정 카탈로그 API (SCR-15). + * 인증 필수(로그인 사용자면 누구나) — 워크스페이스(내 행사)와 달리 전 홀 10년치 카탈로그를 제공한다. + *

배열 직접 반환({@code ApiResponse>}) — 프론트 {@code exhibitionApi.list()} 계약 정합. + */ +@RestController +@RequestMapping("/api/events") +public class EventCatalogController { + + /** 카탈로그 상한(과도 응답 방지) — V10 시드 1,098건을 단일 응답으로 커버. */ + private static final int MAX_LIMIT = 2000; + + private final EventCatalogService service; + private final EventAccessGuard guard; + + public EventCatalogController(EventCatalogService service, EventAccessGuard guard) { + this.service = service; + this.guard = guard; + } + + /** GET /api/events?year=&status=ongoing|upcoming|ended — 필터 없으면 전체(시작일 내림차순). */ + @GetMapping + public ApiResponse> list(@AuthenticationPrincipal KintexPrincipal principal, + @RequestParam(required = false) String year, + @RequestParam(required = false) String status, + @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)); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/catalog/EventCatalogMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/catalog/EventCatalogMapper.java new file mode 100644 index 0000000..dbffe97 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/catalog/EventCatalogMapper.java @@ -0,0 +1,73 @@ +package com.zioinfo.kintex.catalog; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; +import java.util.Map; + +/** + * 전시 일정 카탈로그 매퍼 — {@code event} 테이블 read-only 조회(V10 시드 1,098건). + *

hallLabel 은 {@code hall_assignment}(대표 홀 우선) → {@code hall.label} 조인으로 enrich(없으면 null). + * category·estVisitors 는 원천 컬럼 부재 → 항상 null(후속 트랙 enrich). 마이그레이션을 변경하지 않는다. + * + *

status 필터는 DB 원문(active/ended)이 계약 값(ongoing|upcoming|ended)과 다르므로 날짜 기준으로 판정한다 + * — 프론트의 재계산 규칙과 동일: ended=end<오늘, ongoing=시작≤오늘≤종료, upcoming=시작>오늘. + */ +@Mapper +public interface EventCatalogMapper { + + /** 카탈로그 조회 — year(시작연도)·status(날짜 기준) 선택 필터. 시작일 내림차순. */ + @Select(""" + + """) + List> findEvents(@Param("year") String year, + @Param("status") String status, + @Param("limit") int limit, + @Param("offset") int offset); + + /** 필터 조건 총 건수(페이징 메타). */ + @Select(""" + + """) + long countEvents(@Param("year") String year, @Param("status") String status); +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/catalog/EventCatalogService.java b/src/backend/src/main/java/com/zioinfo/kintex/catalog/EventCatalogService.java new file mode 100644 index 0000000..2222cd6 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/catalog/EventCatalogService.java @@ -0,0 +1,12 @@ +package com.zioinfo.kintex.catalog; + +import com.zioinfo.kintex.catalog.dto.ExhibitionDto; + +import java.util.List; + +/** 전시 일정 카탈로그 서비스 — 전 홀 10년치 행사 목록(멤버십 무관 조회). */ +public interface EventCatalogService { + + /** year(시작연도)·status(ongoing|upcoming|ended, 날짜 기준) 선택 필터. 시작일 내림차순. */ + List list(String year, String status, int limit, int offset); +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/catalog/EventCatalogServiceImpl.java b/src/backend/src/main/java/com/zioinfo/kintex/catalog/EventCatalogServiceImpl.java new file mode 100644 index 0000000..93c907f --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/catalog/EventCatalogServiceImpl.java @@ -0,0 +1,52 @@ +package com.zioinfo.kintex.catalog; + +import com.zioinfo.kintex.catalog.dto.ExhibitionDto; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** 전시 일정 카탈로그 서비스 구현 — {@link EventCatalogMapper} 조회 결과를 DTO로 매핑. */ +@Service +public class EventCatalogServiceImpl implements EventCatalogService { + + private final EventCatalogMapper mapper; + + public EventCatalogServiceImpl(EventCatalogMapper mapper) { + this.mapper = mapper; + } + + @Override + public List list(String year, String status, int limit, int offset) { + List> rows = mapper.findEvents(nullIfBlank(year), nullIfBlank(status), limit, offset); + List out = new ArrayList<>(rows == null ? 0 : rows.size()); + if (rows != null) { + for (Map r : rows) { + out.add(new ExhibitionDto( + str(r.get("id")), str(r.get("name")), + str(r.get("startDate")), str(r.get("endDate")), str(r.get("status")), + str(r.get("hallLabel")), str(r.get("category")), intOrNull(r.get("estVisitors")))); + } + } + return out; + } + + private static String nullIfBlank(String s) { + return s == null || s.isBlank() || "all".equalsIgnoreCase(s) ? null : s; + } + + private static String str(Object o) { + return o == null ? null : String.valueOf(o); + } + + private static Integer intOrNull(Object o) { + if (o == null) return null; + if (o instanceof Number n) return n.intValue(); + try { + return Integer.valueOf(String.valueOf(o)); + } catch (NumberFormatException e) { + return null; + } + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/catalog/dto/ExhibitionDto.java b/src/backend/src/main/java/com/zioinfo/kintex/catalog/dto/ExhibitionDto.java new file mode 100644 index 0000000..6183e9a --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/catalog/dto/ExhibitionDto.java @@ -0,0 +1,18 @@ +package com.zioinfo.kintex.catalog.dto; + +/** + * 전시 일정 카탈로그 항목 (SCR-15 / 06_backend_api_gaps §1). + * 필수(DB 보유): id·name·startDate·endDate·status. 선택(enrich·nullable): hallLabel·category·estVisitors. + *

status 는 {@code event.status} DB 원문(active/ended 등)을 그대로 반환 — 프론트가 날짜 기준 재계산한다. + */ +public record ExhibitionDto( + String id, + String name, + String startDate, + String endDate, + String status, + String hallLabel, + String category, + Integer estVisitors +) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/config/SecurityConfig.java b/src/backend/src/main/java/com/zioinfo/kintex/config/SecurityConfig.java index cd3af48..e6675f5 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/config/SecurityConfig.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/config/SecurityConfig.java @@ -35,6 +35,7 @@ public class SecurityConfig { .requestMatchers("/health", "/api/auth/login", "/ws/**", "/api/internal/render/callback", "/api/auth/login/secure", "/api/auth/otp/verify", + "/api/auth/otp/enroll/setup", "/api/auth/otp/enroll/verify", "/api/auth/register", "/api/auth/password/forgot", "/api/auth/password/reset", // 공개 콘텐츠(로그인 슬라이드 등) + 업로드 정적 파일 diff --git a/src/backend/src/main/java/com/zioinfo/kintex/dashboard/DashboardController.java b/src/backend/src/main/java/com/zioinfo/kintex/dashboard/DashboardController.java new file mode 100644 index 0000000..d71edeb --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/dashboard/DashboardController.java @@ -0,0 +1,35 @@ +package com.zioinfo.kintex.dashboard; + +import com.zioinfo.kintex.auth.EventAccessGuard; +import com.zioinfo.kintex.auth.KintexPrincipal; +import com.zioinfo.kintex.common.ApiResponse; +import com.zioinfo.kintex.dashboard.dto.DashboardDto; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 주최자 대시보드 API (SCR-02). 행사 멤버/홀매니저 열람. + */ +@RestController +@RequestMapping("/api/events/{eventId}/dashboard") +public class DashboardController { + + private final DashboardService service; + private final EventAccessGuard guard; + + public DashboardController(DashboardService service, EventAccessGuard guard) { + this.service = service; + this.guard = guard; + } + + /** GET — 마일스톤·KPI·참가업체·활동 피드 집계. */ + @GetMapping + public ApiResponse get(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String eventId) { + guard.requireEventAccess(principal, eventId); + return ApiResponse.ok(service.getDashboard(eventId)); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/dashboard/DashboardMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/dashboard/DashboardMapper.java new file mode 100644 index 0000000..e586731 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/dashboard/DashboardMapper.java @@ -0,0 +1,73 @@ +package com.zioinfo.kintex.dashboard; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; +import java.util.Map; + +/** + * 주최자 대시보드 집계 매퍼 — event·booth·design_plan·utility_order·render_job read-only. + * booth 는 {@code layout.event_id} 경유로 행사에 귀속된다. 마이그레이션 불변. + */ +@Mapper +public interface DashboardMapper { + + /** 행사 헤더(마일스톤 파생용 시작/종료일). 없으면 null. */ + @Select(""" + SELECT to_char(start_date, 'YYYY-MM-DD') AS startDate, + to_char(end_date, 'YYYY-MM-DD') AS endDate, + name + FROM event WHERE id = #{eventId} + """) + Map findEventHeader(@Param("eventId") String eventId); + + /** KPI 원자 집계(단일 행) — 부스 수·목표 부스·설계 승인·유틸리티 신청·렌더 완료. */ + @Select(""" + SELECT + (SELECT count(*) FROM booth b JOIN layout l ON l.id = b.layout_id + WHERE l.event_id = #{eventId}) AS boothCount, + (SELECT COALESCE(sum(h.booth_capacity), 0) FROM hall_assignment ha + JOIN hall h ON h.id = ha.hall_id WHERE ha.event_id = #{eventId}) AS targetBooths, + (SELECT count(*) FROM booth b JOIN layout l ON l.id = b.layout_id + JOIN design_plan dp ON dp.booth_id = b.id + WHERE l.event_id = #{eventId} AND dp.status = 'approved') AS designApproved, + (SELECT count(*) FROM design_plan dp JOIN booth b ON b.id = dp.booth_id + JOIN layout l ON l.id = b.layout_id WHERE l.event_id = #{eventId}) AS designTotal, + (SELECT count(*) FROM utility_order u WHERE u.event_id = #{eventId}) AS utilityOrders, + (SELECT count(*) FROM render_job r WHERE r.event_id = #{eventId} AND r.status = 'DONE') AS renderDone + """) + Map findKpiCounts(@Param("eventId") String eventId); + + /** 참가업체 부스 현황(최신 설계안 상태 조인). 최대 100건. */ + @Select(""" + SELECT b.booth_no AS boothNo, + b.assigned_company_name AS companyName, + COALESCE(dp.status, 'draft') AS status + FROM booth b + JOIN layout l ON l.id = b.layout_id + LEFT JOIN LATERAL ( + SELECT status FROM design_plan d + WHERE d.booth_id = b.id ORDER BY version DESC LIMIT 1 + ) dp ON true + WHERE l.event_id = #{eventId} + ORDER BY b.booth_no NULLS LAST + LIMIT 100 + """) + List> findExhibitors(@Param("eventId") String eventId); + + /** 활동 피드 — 렌더잡 최근 이력(승인/위반 원천 부재 시 render 만). 최대 12건. */ + @Select(""" + SELECT r.id, + r.status, + r.shot_preset AS shotPreset, + r.booth_id AS boothId, + to_char(r.updated_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS at + FROM render_job r + WHERE r.event_id = #{eventId} + ORDER BY r.updated_at DESC + LIMIT 12 + """) + List> findRecentRenders(@Param("eventId") String eventId); +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/dashboard/DashboardService.java b/src/backend/src/main/java/com/zioinfo/kintex/dashboard/DashboardService.java new file mode 100644 index 0000000..6a3406a --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/dashboard/DashboardService.java @@ -0,0 +1,155 @@ +package com.zioinfo.kintex.dashboard; + +import com.zioinfo.kintex.common.error.ApiException; +import com.zioinfo.kintex.common.error.ErrorCode; +import com.zioinfo.kintex.dashboard.dto.DashboardDto; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * 주최자 대시보드 서비스 — 실 테이블 집계로 KPI·참가업체·피드를 조립. + * 마일스톤은 행사 시작일 기준 표준 일정을 파생(state 는 오늘 기준 판정) — 원천 값이 아닌 파생 스케줄. + */ +@Service +public class DashboardService { + + /** 시작일 기준 상대 마일스톤(라벨, 시작일로부터 -일수). 개장은 0. */ + private static final Object[][] MILESTONE_TEMPLATE = { + {"홀 배정", 150}, + {"부스 배치 확정", 60}, + {"유틸리티 신청 마감", 25}, + {"신고서류 제출", 7}, + {"개장", 0}, + }; + + private final DashboardMapper mapper; + + public DashboardService(DashboardMapper mapper) { + this.mapper = mapper; + } + + public DashboardDto getDashboard(String eventId) { + Map header = mapper.findEventHeader(eventId); + if (header == null) { + throw new ApiException(ErrorCode.NOT_FOUND, "행사를 찾을 수 없습니다."); + } + return new DashboardDto( + buildMilestones(str(header.get("startDate"))), + buildKpis(mapper.findKpiCounts(eventId)), + buildExhibitors(mapper.findExhibitors(eventId)), + buildFeed(mapper.findRecentRenders(eventId))); + } + + /** 행사 시작일에서 상대 일정을 파생. 시작일이 없으면 빈 목록(정직). */ + private List buildMilestones(String startDate) { + List out = new ArrayList<>(); + LocalDate start = parseDate(startDate); + if (start == null) { + return out; + } + LocalDate today = LocalDate.now(); + for (Object[] tpl : MILESTONE_TEMPLATE) { + String label = (String) tpl[0]; + int daysBefore = (int) tpl[1]; + LocalDate date = start.minusDays(daysBefore); + String state; + if (date.isBefore(today)) { + state = "done"; + } else if (date.isEqual(today)) { + state = "current"; + } else { + state = "upcoming"; + } + out.add(new DashboardDto.Milestone(label, date.toString(), state)); + } + return out; + } + + private List buildKpis(Map c) { + List out = new ArrayList<>(); + if (c == null) { + return out; + } + long boothCount = lng(c.get("boothCount")); + long targetBooths = lng(c.get("targetBooths")); + long designApproved = lng(c.get("designApproved")); + long designTotal = lng(c.get("designTotal")); + long utilityOrders = lng(c.get("utilityOrders")); + long renderDone = lng(c.get("renderDone")); + + String boothValue = targetBooths > 0 ? boothCount + "/" + targetBooths : String.valueOf(boothCount); + out.add(new DashboardDto.Kpi("부스 배치", boothValue, null, "flat")); + out.add(new DashboardDto.Kpi("설계 승인", designApproved + "/" + designTotal, null, "flat")); + out.add(new DashboardDto.Kpi("유틸리티 신청", String.valueOf(utilityOrders), null, "flat")); + out.add(new DashboardDto.Kpi("렌더 완료", String.valueOf(renderDone), null, "flat")); + return out; + } + + private List buildExhibitors(List> rows) { + List out = new ArrayList<>(); + if (rows == null) { + return out; + } + for (Map r : rows) { + out.add(new DashboardDto.Exhibitor( + str(r.get("companyName")), str(r.get("boothNo")), str(r.get("status")))); + } + return out; + } + + private List buildFeed(List> rows) { + List out = new ArrayList<>(); + if (rows == null) { + return out; + } + for (Map r : rows) { + String status = str(r.get("status")); + String preset = str(r.get("shotPreset")); + String message = "이미지 생성(" + preset + ") " + renderStatusLabel(status); + out.add(new DashboardDto.Feed(str(r.get("id")), "render", message, str(r.get("at")))); + } + return out; + } + + private static String renderStatusLabel(String status) { + if (status == null) { + return ""; + } + return switch (status) { + case "DONE" -> "완료"; + case "FAILED" -> "실패"; + case "RUNNING" -> "진행 중"; + default -> "대기"; + }; + } + + private static LocalDate parseDate(String s) { + if (s == null || s.isBlank()) { + return null; + } + try { + return LocalDate.parse(s); + } catch (DateTimeParseException e) { + return null; + } + } + + private static long lng(Object o) { + if (o == null) return 0L; + if (o instanceof Number n) return n.longValue(); + try { + return Long.parseLong(String.valueOf(o)); + } catch (NumberFormatException e) { + return 0L; + } + } + + private static String str(Object o) { + return o == null ? null : String.valueOf(o); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/dashboard/dto/DashboardDto.java b/src/backend/src/main/java/com/zioinfo/kintex/dashboard/dto/DashboardDto.java new file mode 100644 index 0000000..5791ad2 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/dashboard/dto/DashboardDto.java @@ -0,0 +1,30 @@ +package com.zioinfo.kintex.dashboard.dto; + +import java.util.List; + +/** + * 주최자 대시보드 집계 (SCR-02 / 06_backend_api_gaps §2). + * 집계원: event·booth(layout)·design_plan·utility_order·render_job. 데이터 없으면 0/빈배열(정직 반환). + */ +public record DashboardDto( + List milestones, + List kpis, + List exhibitors, + List feed +) { + /** 마일스톤 — 행사 시작일 기준 파생 일정(state 는 오늘 기준 판정). */ + public record Milestone(String label, String date, String state) { + } + + /** KPI — 이력 부재 시 delta=null·trend=flat. */ + public record Kpi(String label, String value, String delta, String trend) { + } + + /** 참가업체 부스 현황(booth + 최신 설계안 상태). */ + public record Exhibitor(String companyName, String boothNo, String status) { + } + + /** 활동 피드(렌더/승인/위반) — 현재 render_job 이력 원천. */ + public record Feed(String id, String kind, String message, String at) { + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/module/m2/FloorplanServiceImpl.java b/src/backend/src/main/java/com/zioinfo/kintex/module/m2/FloorplanServiceImpl.java index 71ebb4d..1147149 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/module/m2/FloorplanServiceImpl.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/module/m2/FloorplanServiceImpl.java @@ -150,6 +150,9 @@ public class FloorplanServiceImpl implements FloorplanService { Integer exitsBlocked = boothMapper.countExitsBlocked(layoutId, hallId); metrics.put("layout.exits_blocked_count", exitsBlocked == null ? 0 : exitsBlocked); + Integer overlaps = boothMapper.countOverlappingBooths(layoutId); + metrics.put("layout.overlap_count", overlaps == null ? 0 : overlaps); + BoothDto maxHeight = maxBy(booths, BoothDto::heightM); if (maxHeight != null && maxHeight.heightM() != null) { metrics.put("booth.height_m", maxHeight.heightM()); diff --git a/src/backend/src/main/java/com/zioinfo/kintex/module/m2/mapper/BoothMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/module/m2/mapper/BoothMapper.java index dbd2d02..6c57024 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/module/m2/mapper/BoothMapper.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/module/m2/mapper/BoothMapper.java @@ -38,4 +38,10 @@ public interface BoothMapper { /** 비상구를 차단하는 부스 수 — ST_Intersects(booth, exit_buffer). */ Integer countExitsBlocked(@Param("layoutId") String layoutId, @Param("hallId") String hallId); + + /** + * 폴리곤 내부가 겹치는 부스 쌍의 수 — 부스는 서로 겹칠 수 없다(배치 무결성). + * ST_Overlaps(경계 교차)뿐 아니라 포함·동일도 무효이므로 "내부 교차(ST_Intersects AND NOT ST_Touches)"로 판정한다. + */ + Integer countOverlappingBooths(@Param("layoutId") String layoutId); } diff --git a/src/backend/src/main/java/com/zioinfo/kintex/ops/OpsController.java b/src/backend/src/main/java/com/zioinfo/kintex/ops/OpsController.java new file mode 100644 index 0000000..9e876bb --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/ops/OpsController.java @@ -0,0 +1,36 @@ +package com.zioinfo.kintex.ops; + +import com.zioinfo.kintex.auth.EventAccessGuard; +import com.zioinfo.kintex.auth.EventRole; +import com.zioinfo.kintex.auth.KintexPrincipal; +import com.zioinfo.kintex.common.ApiResponse; +import com.zioinfo.kintex.ops.dto.OpsDto; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 홀 현장 운영 API (SCR-16). 홀매니저/주최자 열람. + */ +@RestController +@RequestMapping("/api/events/{eventId}/ops") +public class OpsController { + + private final OpsService service; + private final EventAccessGuard guard; + + public OpsController(OpsService service, EventAccessGuard guard) { + this.service = service; + this.guard = guard; + } + + /** GET — 홀 혼잡·HVAC·조명·주차 현황(센서 미연동 지표는 0/null). */ + @GetMapping + public ApiResponse get(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String eventId) { + guard.requireRole(principal, eventId, EventRole.ORGANIZER, EventRole.HALL_MANAGER); + return ApiResponse.ok(service.getOps(eventId)); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/ops/OpsMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/ops/OpsMapper.java new file mode 100644 index 0000000..9c78cc1 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/ops/OpsMapper.java @@ -0,0 +1,26 @@ +package com.zioinfo.kintex.ops; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; +import java.util.Map; + +/** + * 홀 현장 운영 매퍼 — hall_assignment·hall read-only(행사에 배정된 홀 목록). + * 혼잡/HVAC/조명/주차 센서 원천이 없으므로 홀 목록만 조회한다. 마이그레이션 불변. + */ +@Mapper +public interface OpsMapper { + + /** 행사 배정 홀 목록(대표 홀 우선 정렬). */ + @Select(""" + SELECT h.id AS hallId, h.label + FROM hall_assignment ha + JOIN hall h ON h.id = ha.hall_id + WHERE ha.event_id = #{eventId} + ORDER BY ha.is_primary DESC, h.id + """) + List> findAssignedHalls(@Param("eventId") String eventId); +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/ops/OpsService.java b/src/backend/src/main/java/com/zioinfo/kintex/ops/OpsService.java new file mode 100644 index 0000000..8ed55ea --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/ops/OpsService.java @@ -0,0 +1,39 @@ +package com.zioinfo.kintex.ops; + +import com.zioinfo.kintex.ops.dto.OpsDto; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * 홀 현장 운영 서비스 — 배정 홀 목록만 실 조회. 혼잡/HVAC/조명/주차는 센서 부재 → 0/null/빈배열(정직). + */ +@Service +public class OpsService { + + private final OpsMapper mapper; + + public OpsService(OpsMapper mapper) { + this.mapper = mapper; + } + + public OpsDto getOps(String eventId) { + List halls = new ArrayList<>(); + List> rows = mapper.findAssignedHalls(eventId); + if (rows != null) { + for (Map r : rows) { + // 혼잡 센서(M14) 미연동 → estPeople=0, heat=smooth. + halls.add(new OpsDto.Hall(str(r.get("hallId")), str(r.get("label")), 0L, "smooth")); + } + } + // HVAC/조명/주차 센서·IoT 미연동 → null/빈배열. + OpsDto.Hvac hvac = new OpsDto.Hvac(null, null, null); + return new OpsDto(halls, hvac, new ArrayList<>(), new ArrayList<>()); + } + + private static String str(Object o) { + return o == null ? null : String.valueOf(o); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/ops/dto/OpsDto.java b/src/backend/src/main/java/com/zioinfo/kintex/ops/dto/OpsDto.java new file mode 100644 index 0000000..a82435b --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/ops/dto/OpsDto.java @@ -0,0 +1,31 @@ +package com.zioinfo.kintex.ops.dto; + +import java.util.List; + +/** + * 홀 현장 운영 현황 (SCR-16 / 06_backend_api_gaps §5). + * 집계원: hall_assignment·hall(실 홀 목록). 혼잡/HVAC/조명/주차는 센서·IoT 미연동(PLANNING M14) → 0/null/빈배열(정직). + *

하드웨어 연동 코드 없음 — 표시 전용. 실시간성 필요 시 WebSocket /topic/ops/{eventId} 확장 검토(현재 폴링 GET). + */ +public record OpsDto( + List halls, + Hvac hvac, + List lighting, + List parking +) { + /** 홀 혼잡(estPeople·heat) — 센서 부재 시 0/smooth. */ + public record Hall(String hallId, String label, long estPeople, String heat) { + } + + /** 공조 계측(센서 부재 시 null). */ + public record Hvac(Double tempC, Integer humidityPercent, Integer co2Ppm) { + } + + /** 조명 존 점등률. */ + public record LightingZone(String zone, int onPercent) { + } + + /** 주차 존 점유·수용. */ + public record ParkingZone(String zone, int occupancy, int capacity) { + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/security/AccountSecurityMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/security/AccountSecurityMapper.java index 23a8d34..359a0d0 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/security/AccountSecurityMapper.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/security/AccountSecurityMapper.java @@ -37,12 +37,13 @@ public interface AccountSecurityMapper { + "verify_method = 'OTP', updated_at = now() WHERE id = #{userId}") int saveOtpSecret(@Param("userId") String userId, @Param("secret") String secret); - /** OTP 활성(코드 확인 완료). */ - @Update("UPDATE app_user SET otp_enabled = true, updated_at = now() WHERE id = #{userId}") + /** OTP 활성(코드 확인 완료) — 최초 활성 시각 기록. */ + @Update("UPDATE app_user SET otp_enabled = true, verify_method = 'OTP', " + + "otp_verified_at = COALESCE(otp_verified_at, now()), updated_at = now() WHERE id = #{userId}") int enableOtp(@Param("userId") String userId); - /** OTP 초기화(비활성 + 시크릿 폐기) — 본인 해제/관리자 리셋. */ - @Update("UPDATE app_user SET otp_secret = NULL, otp_enabled = false, " + /** OTP 초기화(비활성 + 시크릿 폐기 + 검증시각 초기화) — 본인 해제/관리자 리셋. */ + @Update("UPDATE app_user SET otp_secret = NULL, otp_enabled = false, otp_verified_at = NULL, " + "verify_method = 'EMAIL', updated_at = now() WHERE id = #{userId}") int resetOtp(@Param("userId") String userId); diff --git a/src/backend/src/main/java/com/zioinfo/kintex/security/OtpChallengeStore.java b/src/backend/src/main/java/com/zioinfo/kintex/security/OtpChallengeStore.java index 70d9a13..f3c260e 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/security/OtpChallengeStore.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/security/OtpChallengeStore.java @@ -31,6 +31,18 @@ public class OtpChallengeStore { return token; } + /** 토큰 조회(비소비) → userId. 만료/무효면 null. 등록(enroll) setup 단계에서 사용. */ + public String peek(String token) { + if (token == null) { + return null; + } + Entry e = store.get(token); + if (e == null || e.expiresAt() < Instant.now().toEpochMilli()) { + return null; + } + return e.userId(); + } + /** 토큰 소비(1회성) → userId. 만료/무효면 null. */ public String consume(String token) { if (token == null) { diff --git a/src/backend/src/main/java/com/zioinfo/kintex/security/SecretCipher.java b/src/backend/src/main/java/com/zioinfo/kintex/security/SecretCipher.java new file mode 100644 index 0000000..54f7f24 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/security/SecretCipher.java @@ -0,0 +1,97 @@ +package com.zioinfo.kintex.security; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Base64; + +/** + * OTP 시크릿 저장용 AES-256-GCM 봉인/해제(계약 §0-3: otp_secret 암호화 저장 필수). + *

포맷 {@code enc:v1:base64(iv(12) || ciphertext || tag(16))}. 접두어가 없으면 평문으로 간주해 + * 그대로 반환한다(암호화 도입 이전 시크릿과의 하위호환·점진 이관). 키는 env 주입만 + * ({@code KINTEX_OTP_ENC_KEY}, base64 32바이트). 미주입 시 개발 전용 파생키를 쓰되 경고 로그를 남긴다. + * 키·평문 시크릿은 로그/응답에 절대 남기지 않는다. + */ +@Component +public class SecretCipher { + + private static final Logger log = LoggerFactory.getLogger(SecretCipher.class); + private static final String PREFIX = "enc:v1:"; + private static final String TRANSFORM = "AES/GCM/NoPadding"; + private static final int IV_LEN = 12; + private static final int TAG_BITS = 128; + + private final SecureRandom random = new SecureRandom(); + private final SecretKeySpec key; + + public SecretCipher(SecurityPolicyProperties props) { + this.key = resolveKey(props.getOtp().getEncKey()); + } + + /** 평문 시크릿을 봉인(enc:v1:...). null/blank 는 그대로 반환. */ + public String seal(String plain) { + if (plain == null || plain.isBlank()) { + return plain; + } + try { + byte[] iv = new byte[IV_LEN]; + random.nextBytes(iv); + Cipher cipher = Cipher.getInstance(TRANSFORM); + cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv)); + byte[] ct = cipher.doFinal(plain.getBytes(StandardCharsets.UTF_8)); + byte[] out = new byte[iv.length + ct.length]; + System.arraycopy(iv, 0, out, 0, iv.length); + System.arraycopy(ct, 0, out, iv.length, ct.length); + return PREFIX + Base64.getEncoder().encodeToString(out); + } catch (Exception e) { + throw new IllegalStateException("OTP 시크릿 암호화 실패", e); + } + } + + /** 봉인 시크릿을 해제. 접두어가 없으면 평문으로 간주(하위호환). null 은 null. */ + public String open(String stored) { + if (stored == null || stored.isBlank()) { + return stored; + } + if (!stored.startsWith(PREFIX)) { + return stored; // 이관 이전 평문 + } + try { + byte[] raw = Base64.getDecoder().decode(stored.substring(PREFIX.length())); + byte[] iv = new byte[IV_LEN]; + System.arraycopy(raw, 0, iv, 0, IV_LEN); + Cipher cipher = Cipher.getInstance(TRANSFORM); + cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv)); + byte[] pt = cipher.doFinal(raw, IV_LEN, raw.length - IV_LEN); + return new String(pt, StandardCharsets.UTF_8); + } catch (Exception e) { + throw new IllegalStateException("OTP 시크릿 복호화 실패", e); + } + } + + private SecretKeySpec resolveKey(String configured) { + if (configured != null && !configured.isBlank()) { + byte[] k = Base64.getDecoder().decode(configured.trim()); + if (k.length != 32) { + throw new IllegalStateException("KINTEX_OTP_ENC_KEY 는 base64 32바이트(AES-256)여야 합니다."); + } + return new SecretKeySpec(k, "AES"); + } + // 개발 전용 파생키 — 운영에서는 반드시 env 주입. 값은 로그에 남기지 않는다. + log.warn("KINTEX_OTP_ENC_KEY 미주입 → 개발 전용 파생키 사용(운영 배포 전 반드시 주입 필요)"); + try { + byte[] k = MessageDigest.getInstance("SHA-256") + .digest("kintex-dev-otp-key-do-not-use-in-prod".getBytes(StandardCharsets.UTF_8)); + return new SecretKeySpec(k, "AES"); + } catch (Exception e) { + throw new IllegalStateException("개발 파생키 생성 실패", e); + } + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/security/SecurityPolicyProperties.java b/src/backend/src/main/java/com/zioinfo/kintex/security/SecurityPolicyProperties.java index d849429..0c1ae69 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/security/SecurityPolicyProperties.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/security/SecurityPolicyProperties.java @@ -31,8 +31,16 @@ public class SecurityPolicyProperties { public static class Otp { /** TOTP 발급자 표기(Authenticator 앱 라벨). */ private String issuer = "KINTEX"; + /** OTP 시크릿 암호화 키(base64 32바이트, AES-256). env 주입만 — 미주입 시 개발 파생키. */ + private String encKey; + /** 필수 역할(관람객 외)에 OTP 등록·검증 강제. 운영 true 권장, 데모 편의 시 false. */ + private boolean enforce = true; public String getIssuer() { return issuer; } public void setIssuer(String v) { this.issuer = v; } + public String getEncKey() { return encKey; } + public void setEncKey(String v) { this.encKey = v; } + public boolean isEnforce() { return enforce; } + public void setEnforce(boolean v) { this.enforce = v; } } public static class AdminSeed { diff --git a/src/backend/src/main/java/com/zioinfo/kintex/security/TwoFactorController.java b/src/backend/src/main/java/com/zioinfo/kintex/security/TwoFactorController.java index 7a67fb4..03effa9 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/security/TwoFactorController.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/security/TwoFactorController.java @@ -5,6 +5,7 @@ import com.zioinfo.kintex.auth.KintexPrincipal; import com.zioinfo.kintex.auth.dto.LoginResponse; import com.zioinfo.kintex.common.ApiResponse; import com.zioinfo.kintex.security.dto.OtpConfirmRequest; +import com.zioinfo.kintex.security.dto.OtpEnrollRequest; import com.zioinfo.kintex.security.dto.OtpSetupResponse; import com.zioinfo.kintex.security.dto.OtpStatusResponse; import com.zioinfo.kintex.security.dto.OtpVerifyRequest; @@ -40,12 +41,24 @@ public class TwoFactorController { return ApiResponse.ok(service.secureLogin(req.email(), req.password())); } - /** 공개 — 2단계 OTP 검증 → 최종 토큰. */ + /** 공개 — 2단계 OTP 검증(등록 사용자) → 최종 토큰. */ @PostMapping("/otp/verify") public ApiResponse verifyOtp(@Valid @RequestBody OtpVerifyRequest req) { return ApiResponse.ok(service.verifyOtp(req.challengeToken(), req.code())); } + /** 공개(프리오스) — 필수 역할 등록 강제: setup(시크릿·QR 발급). challengeToken 필요. */ + @PostMapping("/otp/enroll/setup") + public ApiResponse enrollSetup(@Valid @RequestBody OtpEnrollRequest req) { + return ApiResponse.ok(service.enrollSetup(req.challengeToken())); + } + + /** 공개(프리오스) — 필수 역할 등록 강제: verify(코드 확인·활성화) → 최종 토큰. */ + @PostMapping("/otp/enroll/verify") + public ApiResponse enrollVerify(@Valid @RequestBody OtpVerifyRequest req) { + return ApiResponse.ok(service.enrollVerify(req.challengeToken(), req.code())); + } + /** 인증 — OTP 등록 시작(시크릿·QR 발급). */ @PostMapping("/otp/setup") public ApiResponse setup(@AuthenticationPrincipal KintexPrincipal principal) { diff --git a/src/backend/src/main/java/com/zioinfo/kintex/security/TwoFactorService.java b/src/backend/src/main/java/com/zioinfo/kintex/security/TwoFactorService.java index c2daa50..4cdb762 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/security/TwoFactorService.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/security/TwoFactorService.java @@ -34,11 +34,14 @@ public class TwoFactorService { private final TotpService totpService; private final LoginAttemptService loginAttempts; private final OtpChallengeStore challengeStore; + private final SecretCipher secretCipher; + private final SecurityPolicyProperties props; public TwoFactorService(AccountSecurityMapper securityMapper, UserMapper userMapper, JwtService jwtService, PasswordEncoder passwordEncoder, TotpService totpService, LoginAttemptService loginAttempts, - OtpChallengeStore challengeStore) { + OtpChallengeStore challengeStore, SecretCipher secretCipher, + SecurityPolicyProperties props) { this.securityMapper = securityMapper; this.userMapper = userMapper; this.jwtService = jwtService; @@ -46,9 +49,11 @@ public class TwoFactorService { this.totpService = totpService; this.loginAttempts = loginAttempts; this.challengeStore = challengeStore; + this.secretCipher = secretCipher; + this.props = props; } - /** 1단계: 잠금 확인 → 자격검증 → (OTP면 챌린지, 아니면 토큰). */ + /** 1단계: 잠금 확인 → 자격검증 → (OTP등록자=챌린지, 필수역할 미등록=등록강제, 그 외=토큰). */ public SecureLoginResponse secureLogin(String email, String rawPassword) { Map row = securityMapper.findAuthSecurityByEmail(email); loginAttempts.assertNotLocked(row); @@ -59,11 +64,16 @@ public class TwoFactorService { } String userId = str(row.get("userId")); loginAttempts.recordSuccess(userId); + boolean hallManager = bool(row.get("hallManager")); if (Boolean.TRUE.equals(bool(row.get("otpEnabled")))) { return SecureLoginResponse.otpRequired(challengeStore.issue(userId)); } - return SecureLoginResponse.ok(buildLoginResponse(userId, - str(row.get("displayName")), bool(row.get("hallManager")))); + // 미등록 — 필수 역할이면 등록 강제(프리오스 챌린지), 관람객 등은 선택(즉시 토큰). + if (props.getOtp().isEnforce() + && requiresOtp(userId, hallManager, str(row.get("roleCode")))) { + return SecureLoginResponse.otpEnroll(challengeStore.issue(userId)); + } + return SecureLoginResponse.ok(buildLoginResponse(userId, str(row.get("displayName")), hallManager)); } /** 2단계: 챌린지 토큰 + OTP 코드 검증 → 최종 로그인 토큰. */ @@ -73,17 +83,42 @@ public class TwoFactorService { throw new ApiException(ErrorCode.OTP_REQUIRED, "2차 인증 세션이 만료되었습니다. 다시 로그인해 주세요."); } Map sec = securityMapper.findSecurityById(userId); - if (sec == null || !totpService.verify(str(sec.get("otpSecret")), code)) { + if (sec == null || !totpService.verify(secretCipher.open(str(sec.get("otpSecret"))), code)) { throw new ApiException(ErrorCode.OTP_INVALID); } return buildLoginResponse(userId, str(sec.get("displayName")), bool(sec.get("hallManager"))); } - /** OTP 등록 시작 — 시크릿 발급·QR 반환(미활성, confirm 필요). */ + /** 로그인 중 등록 강제 — setup 단계(프리오스 챌린지 유지). 시크릿 발급·QR 반환. */ + public OtpSetupResponse enrollSetup(String challengeToken) { + String userId = challengeStore.peek(challengeToken); + if (userId == null) { + throw new ApiException(ErrorCode.OTP_REQUIRED, "2차 인증 세션이 만료되었습니다. 다시 로그인해 주세요."); + } + Map sec = securityMapper.findSecurityById(userId); + String label = sec != null ? str(sec.get("email")) : userId; + return setupOtp(userId, label != null ? label : userId); + } + + /** 로그인 중 등록 강제 — verify 단계(챌린지 소비·활성화 후 최종 토큰). */ + public LoginResponse enrollVerify(String challengeToken, String code) { + String userId = challengeStore.consume(challengeToken); + if (userId == null) { + throw new ApiException(ErrorCode.OTP_REQUIRED, "2차 인증 세션이 만료되었습니다. 다시 로그인해 주세요."); + } + Map sec = securityMapper.findSecurityById(userId); + if (sec == null || !totpService.verify(secretCipher.open(str(sec.get("otpSecret"))), code)) { + throw new ApiException(ErrorCode.OTP_INVALID); + } + securityMapper.enableOtp(userId); + return buildLoginResponse(userId, str(sec.get("displayName")), bool(sec.get("hallManager"))); + } + + /** OTP 등록 시작 — 시크릿 발급(봉인 저장)·QR 반환(미활성, confirm 필요). */ public OtpSetupResponse setupOtp(String userId, String userLabel) { String secret = totpService.generateSecret(); - securityMapper.saveOtpSecret(userId, secret); - return new OtpSetupResponse(secret, + securityMapper.saveOtpSecret(userId, secretCipher.seal(secret)); // 저장은 암호문 + return new OtpSetupResponse(secret, // 화면 1회 표기는 평문 totpService.otpAuthUri(secret, userLabel), totpService.qrImageDataUri(secret, userLabel)); } @@ -91,12 +126,30 @@ public class TwoFactorService { /** OTP 등록 확인 — 코드 검증 후 활성화. */ public void confirmOtp(String userId, String code) { Map sec = securityMapper.findSecurityById(userId); - if (sec == null || !totpService.verify(str(sec.get("otpSecret")), code)) { + if (sec == null || !totpService.verify(secretCipher.open(str(sec.get("otpSecret"))), code)) { throw new ApiException(ErrorCode.OTP_INVALID); } securityMapper.enableOtp(userId); } + /** + * 필수 역할 판정(계약 §5B): 홀매니저·플랫폼 ADMIN/MANAGER·행사 역할 보유자(주최자/참가/장치)는 OTP 필수, + * 순수 관람객(행사 역할 없음)만 선택. 행사 역할 유무는 event_member 로 판정. + */ + private boolean requiresOtp(String userId, boolean hallManager, String roleCode) { + if (hallManager) { + return true; + } + if (roleCode != null) { + String r = roleCode.toUpperCase(); + if (r.equals("ADMIN") || r.equals("MANAGER")) { + return true; + } + } + List> roleRows = userMapper.findEventRoles(userId); + return roleRows != null && !roleRows.isEmpty(); + } + /** OTP 초기화(본인 해제 또는 관리자 리셋). */ public void resetOtp(String userId) { securityMapper.resetOtp(userId); diff --git a/src/backend/src/main/java/com/zioinfo/kintex/security/dto/OtpEnrollRequest.java b/src/backend/src/main/java/com/zioinfo/kintex/security/dto/OtpEnrollRequest.java new file mode 100644 index 0000000..e5b0ea2 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/security/dto/OtpEnrollRequest.java @@ -0,0 +1,7 @@ +package com.zioinfo.kintex.security.dto; + +import jakarta.validation.constraints.NotBlank; + +/** OTP 등록 강제 흐름(로그인 중) — setup 단계 요청. 프리오스 challengeToken 만 필요. */ +public record OtpEnrollRequest(@NotBlank String challengeToken) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/security/dto/SecureLoginResponse.java b/src/backend/src/main/java/com/zioinfo/kintex/security/dto/SecureLoginResponse.java index 0ce716b..69758b7 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/security/dto/SecureLoginResponse.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/security/dto/SecureLoginResponse.java @@ -4,8 +4,11 @@ import com.zioinfo.kintex.auth.dto.LoginResponse; /** * 2FA 로그인 1단계 응답. status: - * - "OK" → OTP 미사용. {@code login}에 최종 토큰/워크스페이스. - * - "OTP_REQUIRED" → 2차 인증 필요. {@code challengeToken}으로 /otp/verify 진행. + * - "OK" → OTP 미사용(관람객 등). {@code login}에 최종 토큰/워크스페이스. + * - "OTP_REQUIRED" → OTP 등록 사용자. {@code challengeToken}으로 /otp/verify 진행(2단계 코드). + * - "OTP_ENROLL" → 필수 역할이나 OTP 미등록 → 등록 강제. {@code challengeToken}으로 + * /otp/enroll/setup → /otp/enroll/verify 진행 후 최종 토큰. + * challengeToken 은 짧은 수명(5분)·1회성 프리오스(pre-auth) 토큰(정식 JWT 아님). */ public record SecureLoginResponse(String status, String challengeToken, LoginResponse login) { @@ -16,4 +19,8 @@ public record SecureLoginResponse(String status, String challengeToken, LoginRes public static SecureLoginResponse otpRequired(String challengeToken) { return new SecureLoginResponse("OTP_REQUIRED", challengeToken, null); } + + public static SecureLoginResponse otpEnroll(String challengeToken) { + return new SecureLoginResponse("OTP_ENROLL", challengeToken, null); + } } diff --git a/src/backend/src/main/resources/application.yml b/src/backend/src/main/resources/application.yml index 3884b62..572d3d8 100644 --- a/src/backend/src/main/resources/application.yml +++ b/src/backend/src/main/resources/application.yml @@ -3,6 +3,9 @@ # - DB_URL / DB_USER / DB_PASSWORD : PostgreSQL(PostGIS) 접속 # - REDIS_HOST / REDIS_PORT : Redis 작업 큐 # - JWT_SECRET : JWT 서명 키(HS256, 최소 32바이트) +# - KINTEX_OTP_ENC_KEY : OTP 시크릿 AES-256-GCM 키(base64 32바이트) — 운영 주입 필수 +# - OTP_ENFORCE : 필수 역할 OTP 등록 강제(기본 true; 데모 완화 시 false) +# - ADMIN_SEED_ENABLED/ADMIN_EMAIL/ADMIN_PASSWORD : 최초 관리자 프로비저닝(하드코딩 시드 금지) # - GEMINI_API_KEY : 나노바나나 Python 워커 전용(백엔드는 큐 발행만; 여기서 미사용) server: port: ${SERVER_PORT:8080} @@ -56,6 +59,10 @@ kintex: lock-minutes: ${LOGIN_LOCK_MINUTES:15} otp: issuer: ${OTP_ISSUER:KINTEX} + # OTP 시크릿 AES-256-GCM 암호화 키(base64 32바이트). 운영은 env 주입 필수, 미주입 시 개발 파생키. + enc-key: ${KINTEX_OTP_ENC_KEY:} + # 필수 역할(관람객 외) OTP 등록·검증 강제. 데모 편의 시 OTP_ENFORCE=false 로 완화. + enforce: ${OTP_ENFORCE:true} # 최초 관리자 프로비저닝 — 값이 모두 주입될 때만 멱등 upsert. 미주입 시 시드 안 함(기본 관리자 계정 없음). admin-seed: enabled: ${ADMIN_SEED_ENABLED:false} diff --git a/src/backend/src/main/resources/db/migration/V12__totp_2fa.sql b/src/backend/src/main/resources/db/migration/V12__totp_2fa.sql new file mode 100644 index 0000000..b23b857 --- /dev/null +++ b/src/backend/src/main/resources/db/migration/V12__totp_2fa.sql @@ -0,0 +1,24 @@ +-- ============================================================================ +-- V12 — 2차 인증(TOTP RFC6238) 완결: OTP 상태·로그인 실패 잠금 컬럼 멱등 정합. +-- 보안 불변(계약 §0-3): otp_secret 은 AES-256-GCM 암호화 저장(SecretCipher), API 응답/로그 노출 금지. +-- 주의: 아래 컬럼 다수는 V2/V7 에서 이미 생성됨 → 전부 IF NOT EXISTS 멱등 ALTER(재현·이관 안전). +-- ============================================================================ + +-- 신규: OTP 최초 활성(검증 완료) 시각 — 감사/마이페이지 상태 표시용. +ALTER TABLE app_user ADD COLUMN IF NOT EXISTS otp_verified_at timestamptz; + +-- 기존 정합 재확인(멱등) — 신규 환경/부분 이관 대비. +ALTER TABLE app_user ADD COLUMN IF NOT EXISTS otp_secret varchar(255); +ALTER TABLE app_user ADD COLUMN IF NOT EXISTS otp_enabled boolean NOT NULL DEFAULT false; +ALTER TABLE app_user ADD COLUMN IF NOT EXISTS verify_method varchar(10) NOT NULL DEFAULT 'EMAIL'; +ALTER TABLE app_user ADD COLUMN IF NOT EXISTS failed_login_count integer NOT NULL DEFAULT 0; +ALTER TABLE app_user ADD COLUMN IF NOT EXISTS locked_until timestamptz; + +-- 암호문(enc:v1:base64) 수용을 위해 폭 확장(V2 는 varchar(96)) — 멱등. +ALTER TABLE app_user ALTER COLUMN otp_secret TYPE varchar(255); + +COMMENT ON COLUMN app_user.otp_verified_at IS 'OTP 최초 활성(검증) 시각 — 감사/상태 표시'; +COMMENT ON COLUMN app_user.otp_secret IS 'TOTP 시크릿(AES-256-GCM 봉인, enc:v1:) — API 응답/로그 노출 금지(계약 §0-3)'; + +-- 잠금 만료 조회/스윕 최적화(부분 인덱스) — 멱등. +CREATE INDEX IF NOT EXISTS idx_app_user_locked_until ON app_user (locked_until) WHERE locked_until IS NOT NULL; diff --git a/src/backend/src/main/resources/db/migration/V13__catalog_dashboard_indexes.sql b/src/backend/src/main/resources/db/migration/V13__catalog_dashboard_indexes.sql new file mode 100644 index 0000000..6d1e159 --- /dev/null +++ b/src/backend/src/main/resources/db/migration/V13__catalog_dashboard_indexes.sql @@ -0,0 +1,12 @@ +-- V13: 카탈로그·대시보드·경영분석 집계 API 성능 인덱스 (멱등 · read-only 스키마 불변) +-- 근거: GET /api/events(카탈로그 1,098건 시작일 정렬·연도 필터) + 대시보드/BI 유틸리티 집계. +-- 신규 테이블/컬럼 없음 — 인덱스만 추가(V12 인증 트랙과 무충돌). + +-- 카탈로그: 시작일 내림차순 정렬 + 연도 필터(extract(year))의 스캔 비용 완화 +CREATE INDEX IF NOT EXISTS idx_event_start_date ON event (start_date DESC); + +-- 카탈로그: status 원문 필터(보조) +CREATE INDEX IF NOT EXISTS idx_event_status ON event (status); + +-- 대시보드/경영분석: utility_order 를 event 단위로 집계(기존 인덱스는 booth 기준만 존재) +CREATE INDEX IF NOT EXISTS idx_utility_order_event ON utility_order (event_id); diff --git a/src/backend/src/main/resources/mybatis/mapper/BoothMapper.xml b/src/backend/src/main/resources/mybatis/mapper/BoothMapper.xml index f5aca14..074b7c4 100644 --- a/src/backend/src/main/resources/mybatis/mapper/BoothMapper.xml +++ b/src/backend/src/main/resources/mybatis/mapper/BoothMapper.xml @@ -107,4 +107,17 @@ AND ST_Intersects(bo.geom, ST_Buffer(ex.geom, ex.clearance_m)) + + + diff --git a/src/backend/src/main/resources/rulesets/compliance-v1.json b/src/backend/src/main/resources/rulesets/compliance-v1.json index efe3877..cb0b4ec 100644 --- a/src/backend/src/main/resources/rulesets/compliance-v1.json +++ b/src/backend/src/main/resources/rulesets/compliance-v1.json @@ -1,6 +1,6 @@ { - "rulesetVersion": "compliance-v1.0", - "effectiveDate": "2026-07-11", + "rulesetVersion": "compliance-v1.1", + "effectiveDate": "2026-07-12", "source": "PLANNING.md v1.2 §7-1 규정 룰셋 / 참가업체·전시주최자 매뉴얼", "disclaimer": "본 룰셋은 사전 필터이며 최종 승인은 킨텍스 및 구조기술사의 판단에 따른다.", "rules": [ @@ -80,6 +80,17 @@ "operator": "eq", "threshold": 0 }, + { + "code": "BOOTH_OVERLAP", + "group": "layout", + "label": "부스 간 겹침 금지(배치 무결성)", + "module": ["M2"], + "severity": "block", + "metric": "layout.overlap_count", + "operator": "eq", + "threshold": 0, + "note": "PostGIS ST_Intersects AND NOT ST_Touches(내부 교차) 기반 서버 산출값" + }, { "code": "CLEARANCE_WALL", "group": "clearance", diff --git a/src/backend/src/test/java/com/zioinfo/kintex/analytics/AnalyticsServiceTest.java b/src/backend/src/test/java/com/zioinfo/kintex/analytics/AnalyticsServiceTest.java new file mode 100644 index 0000000..6e08f58 --- /dev/null +++ b/src/backend/src/test/java/com/zioinfo/kintex/analytics/AnalyticsServiceTest.java @@ -0,0 +1,67 @@ +package com.zioinfo.kintex.analytics; + +import com.zioinfo.kintex.analytics.dto.AnalyticsDto; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 경영분석 서비스 단위테스트 — 매출=면적×요율 산출, 억 단위 표기·등급, exhibitor 관점 pnl 제외. + */ +class AnalyticsServiceTest { + + private AnalyticsMapper mapper; + private AnalyticsService service; + + @BeforeEach + void setUp() { + mapper = mock(AnalyticsMapper.class); + service = new AnalyticsService(mapper); + } + + @Test + void computesRentalRevenueFromAreaAndRate() { + when(mapper.findRentalRatePerM2()).thenReturn(2250.0); + Map ac = new HashMap<>(); + ac.put("salesArea", 100000.0); // 100,000 ㎡ + ac.put("boothCount", 486); + when(mapper.findAreaAndCount("e1")).thenReturn(ac); + when(mapper.findUtilityRevenue("e1")).thenReturn(0.0); + when(mapper.findMonthlyTrend("e1", 0)).thenReturn(List.of()); + + AnalyticsDto dto = service.getAnalytics("e1", "operator", "event"); + + // 100,000 * 2250 = 225,000,000 → "₩2.25억". + AnalyticsDto.Kpi rental = dto.kpis().stream() + .filter(k -> "임대 매출".equals(k.label())).findFirst().orElseThrow(); + assertEquals("₩2.25억", rental.value()); + + // operator 관점 → 부스 임대 pnl 존재, grade B(1억~10억). + AnalyticsDto.PnlRow pnl = dto.pnl().stream() + .filter(p -> "부스 임대".equals(p.item())).findFirst().orElseThrow(); + assertEquals(225_000_000L, pnl.amount()); + assertEquals("B", pnl.grade()); + } + + @Test + void exhibitorPerspectiveOmitsPnl() { + when(mapper.findRentalRatePerM2()).thenReturn(2250.0); + when(mapper.findAreaAndCount("e1")).thenReturn(new HashMap<>()); + when(mapper.findUtilityRevenue("e1")).thenReturn(0.0); + when(mapper.findMonthlyTrend("e1", 90)).thenReturn(List.of()); + + AnalyticsDto dto = service.getAnalytics("e1", "exhibitor", "d90"); + + assertTrue(dto.pnl().isEmpty()); + assertTrue(dto.sectors().isEmpty()); + assertTrue(dto.exhibitorPerf().isEmpty()); + } +} diff --git a/src/backend/src/test/java/com/zioinfo/kintex/common/geo/GeometryCodecTest.java b/src/backend/src/test/java/com/zioinfo/kintex/common/geo/GeometryCodecTest.java new file mode 100644 index 0000000..f64cd12 --- /dev/null +++ b/src/backend/src/test/java/com/zioinfo/kintex/common/geo/GeometryCodecTest.java @@ -0,0 +1,64 @@ +package com.zioinfo.kintex.common.geo; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.zioinfo.kintex.common.error.ApiException; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 지오메트리 코덱 단위테스트 — 부스 폴리곤 WKT 조립(링 닫힘 보정)·GeoJSON 파싱·중심 산출. + * PostGIS ST_GeomFromText 입력 정합성(SRID 0, 미터)을 코드 레벨에서 보증한다. + */ +class GeometryCodecTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + @Test + void toPolygonWkt_closesOpenRing() { + // 6×3 부스(열린 링) → 닫힌 POLYGON WKT + List> ring = List.of( + List.of(0.0, 0.0), List.of(6.0, 0.0), List.of(6.0, 3.0), List.of(0.0, 3.0)); + String wkt = GeometryCodec.toPolygonWkt(ring); + assertEquals("POLYGON((0 0,6 0,6 3,0 3,0 0))", wkt); + } + + @Test + void toPolygonWkt_keepsAlreadyClosedRing() { + List> ring = List.of( + List.of(0.0, 0.0), List.of(6.0, 0.0), List.of(6.0, 3.0), + List.of(0.0, 3.0), List.of(0.0, 0.0)); + assertEquals("POLYGON((0 0,6 0,6 3,0 3,0 0))", GeometryCodec.toPolygonWkt(ring)); + } + + @Test + void toPolygonWkt_rejectsDegenerateRing() { + assertThrows(ApiException.class, + () -> GeometryCodec.toPolygonWkt(List.of(List.of(0.0, 0.0), List.of(1.0, 1.0)))); + } + + @Test + void parsePolygonRing_readsGeoJsonOuterRing() { + String geoJson = "{\"type\":\"Polygon\",\"coordinates\":[[[0,0],[6,0],[6,3],[0,3],[0,0]]]}"; + List> ring = GeometryCodec.parsePolygonRing(mapper, geoJson); + assertEquals(5, ring.size()); + assertEquals(List.of(6.0, 3.0), ring.get(2)); + } + + @Test + void centroid_returnsArithmeticMean() { + List> ring = List.of( + List.of(0.0, 0.0), List.of(6.0, 0.0), List.of(6.0, 3.0), List.of(0.0, 3.0)); + assertEquals(List.of(3.0, 1.5), GeometryCodec.centroid(ring)); + } + + @Test + void parse_handlesNullAndBlankSafely() { + assertTrue(GeometryCodec.parsePolygonRing(mapper, null).isEmpty()); + assertTrue(GeometryCodec.parseLineCoords(mapper, " ").isEmpty()); + } +} diff --git a/src/backend/src/test/java/com/zioinfo/kintex/dashboard/DashboardServiceTest.java b/src/backend/src/test/java/com/zioinfo/kintex/dashboard/DashboardServiceTest.java new file mode 100644 index 0000000..c754dc0 --- /dev/null +++ b/src/backend/src/test/java/com/zioinfo/kintex/dashboard/DashboardServiceTest.java @@ -0,0 +1,74 @@ +package com.zioinfo.kintex.dashboard; + +import com.zioinfo.kintex.common.error.ApiException; +import com.zioinfo.kintex.common.error.ErrorCode; +import com.zioinfo.kintex.dashboard.dto.DashboardDto; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 대시보드 서비스 단위테스트 — 마일스톤 파생(과거=done)·KPI 조립·미존재 행사 404. + */ +class DashboardServiceTest { + + private DashboardMapper mapper; + private DashboardService service; + + @BeforeEach + void setUp() { + mapper = mock(DashboardMapper.class); + service = new DashboardService(mapper); + } + + @Test + void throwsNotFoundWhenEventMissing() { + when(mapper.findEventHeader("x")).thenReturn(null); + ApiException ex = assertThrows(ApiException.class, () -> service.getDashboard("x")); + assertEquals(ErrorCode.NOT_FOUND, ex.getCode()); + } + + @Test + void derivesMilestonesAndKpisFromRealCounts() { + // 미래 시작일 → 개장 upcoming, 먼 과거 파생일(D-150 등)은 done. + String futureStart = LocalDate.now().plusDays(10).toString(); + Map header = new HashMap<>(); + header.put("startDate", futureStart); + when(mapper.findEventHeader("e1")).thenReturn(header); + + Map counts = new HashMap<>(); + counts.put("boothCount", 12); + counts.put("targetBooths", 20); + counts.put("designApproved", 3); + counts.put("designTotal", 8); + counts.put("utilityOrders", 5); + counts.put("renderDone", 7); + when(mapper.findKpiCounts("e1")).thenReturn(counts); + when(mapper.findExhibitors("e1")).thenReturn(List.of()); + when(mapper.findRecentRenders("e1")).thenReturn(List.of()); + + DashboardDto dto = service.getDashboard("e1"); + + // 마일스톤 5종, 개장(D-0)은 미래 → upcoming. + assertEquals(5, dto.milestones().size()); + DashboardDto.Milestone opening = dto.milestones().get(dto.milestones().size() - 1); + assertEquals("개장", opening.label()); + assertEquals("upcoming", opening.state()); + // 초기 D-150 파생일은 과거 → done. + assertEquals("done", dto.milestones().get(0).state()); + + // KPI: 부스 배치 "12/20". + assertTrue(dto.kpis().stream().anyMatch(k -> "부스 배치".equals(k.label()) && "12/20".equals(k.value()))); + assertTrue(dto.kpis().stream().anyMatch(k -> "설계 승인".equals(k.label()) && "3/8".equals(k.value()))); + } +} diff --git a/src/backend/src/test/java/com/zioinfo/kintex/module/m4/UtilityQuoteCalcTest.java b/src/backend/src/test/java/com/zioinfo/kintex/module/m4/UtilityQuoteCalcTest.java new file mode 100644 index 0000000..bf09705 --- /dev/null +++ b/src/backend/src/test/java/com/zioinfo/kintex/module/m4/UtilityQuoteCalcTest.java @@ -0,0 +1,87 @@ +package com.zioinfo.kintex.module.m4; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.zioinfo.kintex.module.m4.dto.UtilityQuote; +import com.zioinfo.kintex.module.m4.dto.UtilityQuoteRequest; +import com.zioinfo.kintex.module.m4.mapper.UtilityOrderMapper; +import com.zioinfo.kintex.module.m4.mapper.WiringMapper; +import com.zioinfo.kintex.rules.RuleProperties; +import com.zioinfo.kintex.rules.RuleSetLoader; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +/** + * M4 유틸리티 자동 견적 계산 단위테스트 — 실제 요율 룰셋(rates-v1.json) 기준. + * 소비전력 합산·신청용량 올림·분전반(50A/11kW) 산출·회선/급배수/압축공기 라인·합계 정합을 검증한다. + */ +class UtilityQuoteCalcTest { + + private UtilityServiceImpl service; + + @BeforeEach + void setUp() { + RuleProperties props = new RuleProperties(); + props.setComplianceRuleset(new ClassPathResource("rulesets/compliance-v1.json")); + props.setRateRuleset(new ClassPathResource("rulesets/rates-v1.json")); + RuleSetLoader loader = new RuleSetLoader(props, new ObjectMapper()); + loader.load(); + service = new UtilityServiceImpl(loader, mock(WiringMapper.class), + mock(UtilityOrderMapper.class), new ObjectMapper()); + } + + @Test + void quote_sumsPower_boxesCeil_andLineAmounts() { + // 2대 × 5.0kW = 10kW → requestedKw 10, 분전반 ceil(10/11)=1 + UtilityQuoteRequest req = new UtilityQuoteRequest( + List.of(new UtilityQuoteRequest.Device("LED 사이니지", 2, 5.0)), + 2, // network wired lines + 1, // plumbing outlets + 1); // compressed air outlets + UtilityQuote q = service.quote(req); + + assertEquals(10.0, q.totalPowerKw(), 1e-9); + assertEquals(10, q.requestedKw()); + assertEquals(1, q.distributionBox50A()); + + // rates-v1.json: perKw 55000, box50A 100000, wired 150000, plumb 150000, air 150000 + long expected = 10L * 55000 // 전기 + + 1L * 100000 // 분전반 + + 2L * 150000 // 유선회선 + + 1L * 150000 // 급배수 + + 1L * 150000; // 압축공기 + assertEquals(expected, q.total()); + assertEquals("KRW", q.currency()); + assertEquals("rates-v1.0", q.rulesetVersion()); + assertEquals(5, q.lines().size()); + } + + @Test + void quote_fractionalKw_roundsUpRequestedCapacity() { + // 3대 × 2.2kW = 6.6kW → requestedKw ceil = 7 + UtilityQuoteRequest req = new UtilityQuoteRequest( + List.of(new UtilityQuoteRequest.Device("계측장비", 3, 2.2)), + 0, 0, 0); + UtilityQuote q = service.quote(req); + assertEquals(7, q.requestedKw()); + assertEquals(1, q.distributionBox50A()); // ceil(7/11)=1 + assertEquals(7L * 55000 + 100000, q.total()); + } + + @Test + void quote_noDemand_isZeroWithNoLines() { + UtilityQuoteRequest req = new UtilityQuoteRequest(List.of(), 0, 0, 0); + UtilityQuote q = service.quote(req); + assertEquals(0, q.requestedKw()); + assertEquals(0, q.distributionBox50A()); + assertEquals(0L, q.total()); + assertTrue(q.lines().isEmpty()); + assertTrue(q.disclaimer() != null && !q.disclaimer().isBlank()); // 공시가 고지 항상 포함 + } +} diff --git a/src/backend/src/test/java/com/zioinfo/kintex/rules/ComplianceRuleEngineTest.java b/src/backend/src/test/java/com/zioinfo/kintex/rules/ComplianceRuleEngineTest.java new file mode 100644 index 0000000..095d05e --- /dev/null +++ b/src/backend/src/test/java/com/zioinfo/kintex/rules/ComplianceRuleEngineTest.java @@ -0,0 +1,145 @@ +package com.zioinfo.kintex.rules; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.util.HashMap; +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.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 규정 룰 엔진 단위테스트 — 실제 배포 룰셋(compliance-v1.json)을 로드해 평가 정확도를 검증한다. + * 대상: 높이 5m 차단·통로폭·비상구·부스 겹침·홀별 바닥하중(lteHall)·방염·리깅(warn) + submittable 집계. + */ +class ComplianceRuleEngineTest { + + private ComplianceRuleEngine engine; + + @BeforeEach + void setUp() { + RuleProperties props = new RuleProperties(); + props.setComplianceRuleset(new ClassPathResource("rulesets/compliance-v1.json")); + props.setRateRuleset(new ClassPathResource("rulesets/rates-v1.json")); + RuleSetLoader loader = new RuleSetLoader(props, new ObjectMapper()); + loader.load(); + engine = new ComplianceRuleEngine(loader); + } + + private Violation find(ComplianceReport r, String code) { + return r.violations().stream().filter(v -> code.equals(v.code())).findFirst().orElse(null); + } + + @Test + void loadsRealRuleset() { + ComplianceReport r = engine.evaluate("M2", Map.of("booth.height_m", 3.0), null); + assertEquals("compliance-v1.1", r.rulesetVersion()); + assertNotNull(r.disclaimer()); + } + + @Test + void m2_allWithinLimits_isSubmittable() { + Map m = new HashMap<>(); + m.put("booth.height_m", 3.0); + m.put("layout.min_aisle_width_m", 3.5); + m.put("layout.exits_blocked_count", 0); + m.put("layout.overlap_count", 0); + m.put("booth.floor_load_t_per_m2", 4.0); + ComplianceReport r = engine.evaluate("M2", m, "H7"); + assertEquals(0, r.blockCount()); + assertTrue(r.submittable()); + assertTrue(r.passCount() >= 5); + } + + @Test + void m2_heightOverFiveMeters_blocks() { + ComplianceReport r = engine.evaluate("M2", Map.of("booth.height_m", 5.4), "H7"); + Violation v = find(r, "HEIGHT_MAX"); + assertNotNull(v); + assertEquals("block", v.severity()); + assertFalse(r.submittable()); + } + + @Test + void m2_aisleNarrowerThanThreeMeters_blocks() { + ComplianceReport r = engine.evaluate("M2", Map.of("layout.min_aisle_width_m", 2.5), null); + assertNotNull(find(r, "AISLE_WIDTH_MIN")); + assertFalse(r.submittable()); + } + + @Test + void m2_overlappingBooths_blocks_andReportsCount() { + ComplianceReport r = engine.evaluate("M2", Map.of("layout.overlap_count", 2), null); + Violation v = find(r, "BOOTH_OVERLAP"); + assertNotNull(v); + assertEquals("block", v.severity()); + assertTrue(v.measured().contains("2")); + assertFalse(r.submittable()); + } + + @Test + void m2_floorLoad_hallSpecificLimit() { + // 홀6 상한 2.0 t/㎡ — 3.0 은 초과(차단). + ComplianceReport h6 = engine.evaluate("M2", Map.of("booth.floor_load_t_per_m2", 3.0), "H6"); + assertNotNull(find(h6, "FLOOR_LOAD")); + // 홀7 상한 5.0 t/㎡ — 동일 3.0 은 통과. + ComplianceReport h7 = engine.evaluate("M2", Map.of("booth.floor_load_t_per_m2", 3.0), "H7"); + assertEquals(null, find(h7, "FLOOR_LOAD")); + } + + @Test + void warnViolation_doesNotBlockSubmission() { + // 인접 벽 이격 0.1m (< 0.3m) → CLEARANCE_WALL warn. warn 만이면 제출 가능(submittable true). + ComplianceReport r = engine.evaluate("M2", Map.of("booth.clearance_wall_m", 0.1), null); + Violation v = find(r, "CLEARANCE_WALL"); + assertNotNull(v); + assertEquals("warn", v.severity()); + assertEquals(0, r.blockCount()); + assertEquals(1, r.warnCount()); + assertTrue(r.submittable()); + } + + @Test + void riggingBetweenOperator_inRangeCurrentlyPasses() { + // 현행 룰셋: RIGGING_RANGE operator=between → 6.5~8.5m 내이면 "통과"로 집계(위반 미발생). + // 라벨 의도("구간 진입 시 D-7 필요")와 반대 — 갭 기록: _workspace/08_m2m5_contract_changes.md. + ComplianceReport r = engine.evaluate("M3", Map.of("rigging.height_m", 7.0), null); + assertEquals(null, find(r, "RIGGING_RANGE")); + assertTrue(r.passCount() >= 1); + } + + @Test + void m3_nonFireRetardantMaterials_blocks() { + ComplianceReport r = engine.evaluate("M3", Map.of("materials.all_fire_retardant", false), null); + assertNotNull(find(r, "FIRE_RETARDANT")); + assertFalse(r.submittable()); + } + + @Test + void unmeasuredMetrics_areSkipped_notCountedAsPass() { + ComplianceReport r = engine.evaluate("M2", Map.of(), null); + assertEquals(0, r.blockCount()); + assertEquals(0, r.warnCount()); + assertEquals(0, r.passCount()); + assertTrue(r.violations().isEmpty()); + } + + @Test + void unloadedRuleset_degradesGracefully() { + RuleProperties props = new RuleProperties(); + props.setComplianceRuleset(new ClassPathResource("rulesets/does-not-exist.json")); + RuleSetLoader loader = new RuleSetLoader(props, new ObjectMapper()); + loader.load(); + ComplianceReport r = new ComplianceRuleEngine(loader) + .evaluate("M2", Map.of("booth.height_m", 9.9), null); + assertEquals("compliance-unloaded", r.rulesetVersion()); + assertTrue(r.submittable()); + assertEquals(List.of(), r.violations()); + } +} diff --git a/src/backend/src/test/java/com/zioinfo/kintex/security/LoginAttemptServiceTest.java b/src/backend/src/test/java/com/zioinfo/kintex/security/LoginAttemptServiceTest.java new file mode 100644 index 0000000..9f81827 --- /dev/null +++ b/src/backend/src/test/java/com/zioinfo/kintex/security/LoginAttemptServiceTest.java @@ -0,0 +1,90 @@ +package com.zioinfo.kintex.security; + +import com.zioinfo.kintex.common.error.ApiException; +import com.zioinfo.kintex.common.error.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.time.OffsetDateTime; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +/** + * 로그인 실패 잠금 카운터/정책 단위테스트 — 임계 초과 시 잠금, 성공 시 리셋, 계정 미존재 시 무변경. + */ +class LoginAttemptServiceTest { + + private AccountSecurityMapper mapper; + private LoginAttemptService service; + + @BeforeEach + void setUp() { + mapper = mock(AccountSecurityMapper.class); + SecurityPolicyProperties props = new SecurityPolicyProperties(); + props.getLockout().setMaxFailedAttempts(5); + props.getLockout().setLockMinutes(30); + service = new LoginAttemptService(mapper, props); + } + + private Map row(int failedCount, OffsetDateTime lockedUntil) { + Map m = new HashMap<>(); + m.put("failedCount", failedCount); + m.put("lockedUntil", lockedUntil); + return m; + } + + @Test + void locksAccountWhenFailuresReachThreshold() { + // 직전 4회 실패 → 이번 실패가 5회째 → 잠금. + service.recordFailure("u@kintex.test", row(4, null)); + verify(mapper).incrementFailure("u@kintex.test"); + ArgumentCaptor until = ArgumentCaptor.forClass(OffsetDateTime.class); + verify(mapper).lockUntil(org.mockito.ArgumentMatchers.eq("u@kintex.test"), until.capture()); + // 잠금 만료는 미래(대략 30분 후)여야 한다. + org.junit.jupiter.api.Assertions.assertTrue(until.getValue().isAfter(OffsetDateTime.now())); + } + + @Test + void doesNotLockBeforeThreshold() { + service.recordFailure("u@kintex.test", row(1, null)); // 2회째 + verify(mapper).incrementFailure("u@kintex.test"); + verify(mapper, never()).lockUntil(org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.any()); + } + + @Test + void missingAccountDoesNotMutateState() { + service.recordFailure("nobody@kintex.test", null); + verify(mapper, never()).incrementFailure(org.mockito.ArgumentMatchers.anyString()); + verify(mapper, never()).lockUntil(org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.any()); + } + + @Test + void assertNotLockedThrowsWhenLocked() { + Map locked = row(5, OffsetDateTime.now().plusMinutes(10)); + ApiException ex = assertThrows(ApiException.class, () -> service.assertNotLocked(locked)); + assertEquals(ErrorCode.ACCOUNT_LOCKED, ex.getCode()); + } + + @Test + void assertNotLockedPassesWhenLockExpired() { + Map expired = row(5, OffsetDateTime.now().minusMinutes(1)); + assertDoesNotThrow(() -> service.assertNotLocked(expired)); + } + + @Test + void recordSuccessClearsLoginState() { + service.recordSuccess("user-1"); + verify(mapper, times(1)).clearLoginState("user-1"); + } +} diff --git a/src/backend/src/test/java/com/zioinfo/kintex/security/SecretCipherTest.java b/src/backend/src/test/java/com/zioinfo/kintex/security/SecretCipherTest.java new file mode 100644 index 0000000..7bea770 --- /dev/null +++ b/src/backend/src/test/java/com/zioinfo/kintex/security/SecretCipherTest.java @@ -0,0 +1,43 @@ +package com.zioinfo.kintex.security; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * OTP 시크릿 봉인/해제(AES-256-GCM) 왕복·하위호환 단위테스트. + */ +class SecretCipherTest { + + private SecretCipher newCipher() { + // encKey 미주입 → 개발 파생키 사용(테스트 결정적). + return new SecretCipher(new SecurityPolicyProperties()); + } + + @Test + void sealThenOpenRoundTrips() { + SecretCipher cipher = newCipher(); + String secret = "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP"; + String sealed = cipher.seal(secret); + assertTrue(sealed.startsWith("enc:v1:"), "봉인 접두어"); + org.junit.jupiter.api.Assertions.assertNotEquals(secret, sealed, "저장값은 평문과 달라야 함"); + assertEquals(secret, cipher.open(sealed), "복호화 왕복 일치"); + } + + @Test + void openTreatsUnprefixedAsPlaintext() { + SecretCipher cipher = newCipher(); + // 이관 이전 평문 시크릿은 그대로 반환(하위호환). + assertEquals("PLAINSECRET", cipher.open("PLAINSECRET")); + } + + @Test + void handlesNullAndBlank() { + SecretCipher cipher = newCipher(); + assertNull(cipher.seal(null)); + assertNull(cipher.open(null)); + assertEquals("", cipher.seal("")); + } +} diff --git a/src/backend/src/test/java/com/zioinfo/kintex/security/TotpServiceTest.java b/src/backend/src/test/java/com/zioinfo/kintex/security/TotpServiceTest.java new file mode 100644 index 0000000..014ed28 --- /dev/null +++ b/src/backend/src/test/java/com/zioinfo/kintex/security/TotpServiceTest.java @@ -0,0 +1,54 @@ +package com.zioinfo.kintex.security; + +import dev.samstevens.totp.code.DefaultCodeGenerator; +import dev.samstevens.totp.time.SystemTimeProvider; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * TotpService — RFC6238 코드 검증 핵심 로직 단위테스트(외부 리소스 無). + */ +class TotpServiceTest { + + private TotpService newService() { + SecurityPolicyProperties props = new SecurityPolicyProperties(); + props.getOtp().setIssuer("KINTEX-TEST"); + return new TotpService(props); + } + + @Test + void generatesSecretAndVerifiesCurrentCode() throws Exception { + TotpService svc = newService(); + String secret = svc.generateSecret(); + assertNotNull(secret); + assertFalse(secret.isBlank()); + + // 현재 타임스텝의 정상 코드는 검증 통과해야 한다. + long currentBucket = new SystemTimeProvider().getTime() / 30; + String validCode = new DefaultCodeGenerator().generate(secret, currentBucket); + assertTrue(svc.verify(secret, validCode), "현재 타임스텝 코드는 유효해야 함"); + } + + @Test + void rejectsWrongCodeAndBlankInputs() { + TotpService svc = newService(); + String secret = svc.generateSecret(); + assertFalse(svc.verify(secret, "000000"), "임의 코드는 거부"); + assertFalse(svc.verify(secret, ""), "빈 코드는 거부"); + assertFalse(svc.verify(null, "123456"), "시크릿 null 은 거부"); + assertFalse(svc.verify(secret, null), "코드 null 은 거부"); + } + + @Test + void otpAuthUriContainsIssuerAndSecret() { + TotpService svc = newService(); + String secret = svc.generateSecret(); + String uri = svc.otpAuthUri(secret, "user@kintex.test"); + assertTrue(uri.startsWith("otpauth://totp/"), "otpauth URI 스킴"); + assertTrue(uri.contains("KINTEX-TEST"), "issuer 포함"); + assertTrue(uri.contains(secret), "secret 포함"); + } +} diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 0785118..a0be737 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -1,6 +1,7 @@ import { Navigate, Outlet, Route, Routes } from 'react-router-dom'; import { useAuthStore } from './store/authStore'; import { LoginPage } from './screens/login/LoginPage'; +import { OtpSetupPage } from './screens/login/OtpSetupPage'; import { BoothLayoutEditorPage } from './screens/floorplan/BoothLayoutEditorPage'; import { LayoutComparisonPage } from './screens/floorplan/LayoutComparisonPage'; import { OrganizerDashboardPage } from './screens/dashboard/OrganizerDashboardPage'; @@ -20,6 +21,16 @@ import { LoginSlideAdminPage } from './screens/admin/LoginSlideAdminPage'; import { ComponentGuidePage } from './screens/styleguide/ComponentGuidePage'; import { ContractorOnsiteChecklistPage } from './screens/mobile/ContractorOnsiteChecklistPage'; import { ManagerFieldInspectionPage } from './screens/mobile/ManagerFieldInspectionPage'; +import { WorklogPage } from './screens/work/WorklogPage'; +import { WorkSchedulePage } from './screens/work/WorkSchedulePage'; +import { MessagePage } from './screens/work/MessagePage'; +import { NoticePage } from './screens/work/NoticePage'; +import { OpinionPage } from './screens/work/OpinionPage'; +import { SearchPage } from './screens/work/SearchPage'; +import { MeetingPage } from './screens/work/MeetingPage'; +import { ReportPage } from './screens/work/ReportPage'; +import { NotificationCenterPage } from './screens/work/NotificationCenterPage'; +import { MyPage } from './screens/work/MyPage'; import { AppShell } from './components/layout/AppShell'; /** 인증 가드 — 미인증 시 로그인으로. (역할별 라우팅은 화면 추가 시 확장) */ @@ -33,6 +44,15 @@ export function App() { return ( } /> + {/* 2차 인증(OTP) 관리 — 인증 필요, 셸 없이 단독 카드. */} + + + + } + /> {/* 인증 필요 — 사이드바 셸 안 (데스크톱 1440) */} } /> {/* SCR-17 컴포넌트 가이드 (내부 전용 · 네비 비노출) */} } /> + + {/* §5B 공통 업무 기능 (SCR-39~48) — 역할 공통 영역 */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + {/* SCR-47 알림센터 · SCR-48 마이페이지 */} + } /> + } /> {/* 모바일 전용 (390) — 셸 없이 전폭, 조회·현장 */} diff --git a/src/frontend/src/api/client.ts b/src/frontend/src/api/client.ts index c725dc8..fb4e18c 100644 --- a/src/frontend/src/api/client.ts +++ b/src/frontend/src/api/client.ts @@ -33,7 +33,7 @@ export function getAccessToken(): string | null { } interface RequestOptions { - method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; + method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; body?: unknown; signal?: AbortSignal; /** 인증 헤더 생략(공개 경로). */ @@ -102,6 +102,8 @@ export const api = { request(path, { ...opts, method: 'POST', body }), put: (path: string, body?: unknown, opts?: Omit) => request(path, { ...opts, method: 'PUT', body }), + patch: (path: string, body?: unknown, opts?: Omit) => + request(path, { ...opts, method: 'PATCH', body }), del: (path: string, opts?: Omit) => request(path, { ...opts, method: 'DELETE' }), /** multipart 업로드(POST FormData) — 파일 업로드 경로 전용. */ diff --git a/src/frontend/src/api/endpoints.ts b/src/frontend/src/api/endpoints.ts index d3c8af2..15267bf 100644 --- a/src/frontend/src/api/endpoints.ts +++ b/src/frontend/src/api/endpoints.ts @@ -5,12 +5,19 @@ import { api } from './client'; import type { AcceptInviteRequest, + AdminDashboardData, + AnalyticsData, + AnalyticsPeriod, + AnalyticsPerspective, AutoLayoutOption, AutoLayoutRequest, ComplianceReport, + DashboardData, + OpsData, DesignPlanDto, DesignSaveRequest, DesignSpec, + ExhibitionDto, ForgotPasswordRequest, KintexPrincipal, LayoutDto, @@ -19,23 +26,67 @@ import type { LoginResponse, LoginSlideDto, LoginSlideUpdateRequest, + MeetingActionDto, + MeetingActionRequest, + MeetingDetail, + MeetingDto, + MeetingSaveRequest, + MessageDto, + MessageSendRequest, + NoticeDto, + NoticeSaveRequest, + NotificationDto, + OpinionCommentDto, + OpinionDetail, + OpinionDto, + OpinionSaveRequest, + OtpSetupResponse, + OtpStatusResponse, + PageResponse, RegisterRequest, RegisterResponse, RenderJobDto, RenderJobRequest, + ReportDto, + ReportSaveRequest, ResetPasswordRequest, + ScheduleDto, + ScheduleSaveRequest, + SearchResultItem, + SecureLoginResponse, UtilityOrderDto, UtilityQuote, UtilityQuoteRequest, WiringRequest, WiringResult, + WorkStatsDto, + WorklogDto, + WorklogSaveRequest, WorkspaceDto, } from './types'; // ── 인증·워크스페이스 (SCR-01) ── export const authApi = { + // 레거시 단일 단계 로그인(하위호환 유지). 신규 UI 는 secureLogin 사용. login: (body: LoginRequest) => api.post('/api/auth/login', body, { anonymous: true }), + // 2FA/잠금 적용 1단계 — status(OK|OTP_REQUIRED|OTP_ENROLL) 분기. + secureLogin: (body: LoginRequest) => + api.post('/api/auth/login/secure', body, { anonymous: true }), + // 2단계(등록 사용자) — challengeToken + 6자리 → 최종 토큰. + otpVerify: (challengeToken: string, code: string) => + api.post('/api/auth/otp/verify', { challengeToken, code }, { anonymous: true }), + // 등록 강제 setup — challengeToken 으로 시크릿·QR 발급(프리오스). + otpEnrollSetup: (challengeToken: string) => + api.post('/api/auth/otp/enroll/setup', { challengeToken }, { anonymous: true }), + // 등록 강제 verify — 코드 확인·활성화 → 최종 토큰. + otpEnrollVerify: (challengeToken: string, code: string) => + api.post('/api/auth/otp/enroll/verify', { challengeToken, code }, { anonymous: true }), + // 마이페이지(인증) — 본인 OTP 등록/확인/상태/해제. + otpSetup: () => api.post('/api/auth/otp/setup'), + otpConfirm: (code: string) => api.post('/api/auth/otp/confirm', { code }), + otpStatus: () => api.get('/api/auth/otp/status'), + otpResetSelf: () => api.post('/api/auth/otp/reset'), workspaces: () => api.get('/api/auth/workspaces'), me: () => api.get('/api/auth/me'), acceptInvite: (body: AcceptInviteRequest) => @@ -51,6 +102,28 @@ export const authApi = { api.post('/api/auth/password/reset', body, { anonymous: true }), }; +// ── 전시 일정 카탈로그 (SCR-15) ── +// 제안 계약(백엔드 갭): `GET /api/events` → ExhibitionDto[] (전 홀 10년치 카탈로그). +// 인증 workspaces(내가 멤버인 행사)와 달리 이 경로는 전체 행사 카탈로그를 제공한다. +// 백엔드 미구현 시 NOT_IMPLEMENTED(501)/NOT_FOUND(404) → 호출부에서 크롤링 샘플 폴백. +export const exhibitionApi = { + list: (params?: { year?: string; status?: string }) => { + const qs = new URLSearchParams(); + if (params?.year && params.year !== 'all') qs.set('year', params.year); + if (params?.status && params.status !== 'all') qs.set('status', params.status); + const q = qs.toString(); + return api.get(`/api/events${q ? `?${q}` : ''}`); + }, +}; + +// ── 시스템관리 — 사용자(관리자) ── +// 사용자 관리 화면은 후속(현재 API 계약만). OTP 초기화는 SystemAccessGuard.requireAdmin + @Audited. +export const adminUserApi = { + // 관리자 — 대상 사용자 OTP 강제 초기화(분실/기기 교체 대응). 다음 로그인 시 재등록. + otpReset: (userId: string) => + api.post(`/api/admin/users/${encodeURIComponent(userId)}/otp/reset`), +}; + // ── 로그인 슬라이드 (SCR-01 캐러셀 · 관리자) ── export const loginSlideApi = { // 공개 — 활성만, 최신 등록순 최대 5건(인증 불필요). @@ -146,3 +219,165 @@ export const renderApi = { body, ), }; + +/* ───────────────────────────────────────────────────────── + * 대시보드 계열 집계 (SCR-02·13·14·16) — 실 API + * 근거: 백엔드 com.zioinfo.kintex.{dashboard,analytics,admin,ops} 컨트롤러(정본). + * 실패(NETWORK/NOT_FOUND) 시 각 화면이 샘플 폴백으로 강등. + * ───────────────────────────────────────────────────────── */ + +// ── SCR-02 주최자 대시보드 집계 ── +export const dashboardApi = { + get: (eventId: string) => + api.get(`/api/events/${encodeURIComponent(eventId)}/dashboard`), +}; + +// ── SCR-13 경영분석 BI 집계 ── +export const analyticsApi = { + get: (eventId: string, perspective: AnalyticsPerspective, period: AnalyticsPeriod) => + api.get( + `/api/events/${encodeURIComponent(eventId)}/analytics?perspective=${perspective}&period=${period}`, + ), +}; + +// ── SCR-14 관리자 백오피스 대시보드 ── +export const adminApi = { + dashboard: (tenant?: string) => + api.get( + `/api/admin/dashboard${tenant ? `?tenant=${encodeURIComponent(tenant)}` : ''}`, + ), +}; + +// ── SCR-16 홀 현장 운영 ── +export const opsApi = { + get: (eventId: string) => + api.get(`/api/events/${encodeURIComponent(eventId)}/ops`), +}; + +/* ───────────────────────────────────────────────────────── + * §5B 공통 업무 기능 (SCR-39~48) — /api/work/* + * 근거: 백엔드 com.zioinfo.kintex.work.* 컨트롤러 시그니처(정본). + * ───────────────────────────────────────────────────────── */ + +function qs(params: Record): string { + const sp = new URLSearchParams(); + for (const [k, v] of Object.entries(params)) { + if (v !== undefined && v !== null && v !== '') sp.set(k, String(v)); + } + const s = sp.toString(); + return s ? `?${s}` : ''; +} + +// ── SCR-39 업무일지 ── +export interface WorklogListParams { + eventId?: string; + fromDate?: string; + toDate?: string; + status?: string; + page?: number; + size?: number; +} +export const worklogApi = { + list: (p: WorklogListParams = {}) => + api.get>(`/api/work/worklogs${qs({ ...p })}`), + get: (id: string) => api.get(`/api/work/worklogs/${encodeURIComponent(id)}`), + create: (body: WorklogSaveRequest) => api.post('/api/work/worklogs', body), + update: (id: string, body: WorklogSaveRequest) => + api.put(`/api/work/worklogs/${encodeURIComponent(id)}`, body), + remove: (id: string) => api.del(`/api/work/worklogs/${encodeURIComponent(id)}`), +}; + +// ── SCR-40 통합 일정 ── +export const scheduleWorkApi = { + list: (p: { eventId?: string; fromAt?: string; toAt?: string } = {}) => + api.get(`/api/work/schedules${qs({ ...p })}`), + get: (id: string) => api.get(`/api/work/schedules/${encodeURIComponent(id)}`), + create: (body: ScheduleSaveRequest) => api.post('/api/work/schedules', body), + update: (id: string, body: ScheduleSaveRequest) => + api.put(`/api/work/schedules/${encodeURIComponent(id)}`, body), + remove: (id: string) => api.del(`/api/work/schedules/${encodeURIComponent(id)}`), +}; + +// ── SCR-41 쪽지 ── +export const messageApi = { + inbox: (page = 0, size = 20) => + api.get>(`/api/work/messages/inbox${qs({ page, size })}`), + sent: (page = 0, size = 20) => + api.get>(`/api/work/messages/sent${qs({ page, size })}`), + unreadCount: () => api.get<{ unread: number }>('/api/work/messages/unread-count'), + send: (body: MessageSendRequest) => api.post<{ id: string }>('/api/work/messages', body), + read: (id: string) => api.post(`/api/work/messages/${encodeURIComponent(id)}/read`), + remove: (id: string) => api.del(`/api/work/messages/${encodeURIComponent(id)}`), +}; + +// ── SCR-42 공지 (조회=인증, 쓰기=매니저) ── +export const noticeApi = { + list: (p: { eventId?: string; category?: string; keyword?: string; page?: number; size?: number } = {}) => + api.get>(`/api/work/notices${qs({ ...p })}`), + get: (id: string) => api.get(`/api/work/notices/${encodeURIComponent(id)}`), + create: (body: NoticeSaveRequest) => api.post('/api/work/notices', body), + update: (id: string, body: NoticeSaveRequest) => + api.put(`/api/work/notices/${encodeURIComponent(id)}`, body), + remove: (id: string) => api.del(`/api/work/notices/${encodeURIComponent(id)}`), +}; + +// ── SCR-43 의견접수 (조회/등록=인증, 상태·답변=매니저) ── +export const opinionApi = { + list: (p: { eventId?: string; status?: string; page?: number; size?: number } = {}) => + api.get>(`/api/work/opinions${qs({ ...p })}`), + get: (id: string) => api.get(`/api/work/opinions/${encodeURIComponent(id)}`), + create: (body: OpinionSaveRequest) => api.post('/api/work/opinions', body), + changeStatus: (id: string, value: string) => + api.patch(`/api/work/opinions/${encodeURIComponent(id)}/status${qs({ value })}`), + comment: (id: string, content: string) => + api.post(`/api/work/opinions/${encodeURIComponent(id)}/comments`, { content }), + remove: (id: string) => api.del(`/api/work/opinions/${encodeURIComponent(id)}`), +}; + +// ── SCR-44 통합검색 ── +export const searchApi = { + search: (q: string, limit = 30) => + api.get(`/api/work/search${qs({ q, limit })}`), +}; + +// ── SCR-45 회의록 ── +export const meetingApi = { + list: (p: { eventId?: string; page?: number; size?: number } = {}) => + api.get>(`/api/work/meetings${qs({ ...p })}`), + get: (id: string) => api.get(`/api/work/meetings/${encodeURIComponent(id)}`), + create: (body: MeetingSaveRequest) => api.post('/api/work/meetings', body), + update: (id: string, body: MeetingSaveRequest) => + api.put(`/api/work/meetings/${encodeURIComponent(id)}`, body), + saveMinutes: (id: string, minutes: string) => + api.put(`/api/work/meetings/${encodeURIComponent(id)}/minutes`, { minutes }), + addAction: (id: string, body: MeetingActionRequest) => + api.post(`/api/work/meetings/${encodeURIComponent(id)}/actions`, body), + actionStatus: (actionId: string, value: string) => + api.patch(`/api/work/meetings/actions/${encodeURIComponent(actionId)}/status${qs({ value })}`), + remove: (id: string) => api.del(`/api/work/meetings/${encodeURIComponent(id)}`), +}; + +// ── SCR-46 업무보고 + 통계 ── +export const reportApi = { + list: (p: { eventId?: string; reportType?: string; page?: number; size?: number } = {}) => + api.get>(`/api/work/reports${qs({ ...p })}`), + get: (id: string) => api.get(`/api/work/reports/${encodeURIComponent(id)}`), + create: (body: ReportSaveRequest) => api.post('/api/work/reports', body), + update: (id: string, body: ReportSaveRequest) => + api.put(`/api/work/reports/${encodeURIComponent(id)}`, body), + remove: (id: string) => api.del(`/api/work/reports/${encodeURIComponent(id)}`), +}; +export const workStatsApi = { + worklog: (p: { eventId?: string; fromDate?: string; toDate?: string } = {}) => + api.get(`/api/work/stats/worklog${qs({ ...p })}`), +}; + +// ── SCR-47 알림센터 ── +export const notificationApi = { + list: (p: { unreadOnly?: boolean; page?: number; size?: number } = {}) => + api.get>(`/api/work/notifications${qs({ ...p })}`), + unreadCount: () => api.get<{ unread: number }>('/api/work/notifications/unread-count'), + read: (id: string) => api.post(`/api/work/notifications/${encodeURIComponent(id)}/read`), + readAll: () => api.post('/api/work/notifications/read-all'), + remove: (id: string) => api.del(`/api/work/notifications/${encodeURIComponent(id)}`), +}; diff --git a/src/frontend/src/api/types.ts b/src/frontend/src/api/types.ts index d72a661..2d6be8c 100644 --- a/src/frontend/src/api/types.ts +++ b/src/frontend/src/api/types.ts @@ -49,6 +49,22 @@ export interface WorkspaceDto { myRole: EventRole; dday: number; } +// ── 전시 일정 카탈로그 (SCR-15 · M1·M6) ── +// 원천: `event` 테이블(V10 seed_exhibitions_10yr — kintex.com 크롤링 10년치 1,098건). +// 제안 계약: `GET /api/events` → ExhibitionDto[] (백엔드 갭 06_backend_api_gaps.md §1). +// DB 컬럼(id·name·start_date·end_date·status)은 필수, 나머지는 후속 enrich(nullable). +export type ExhibitionStatus = 'ongoing' | 'upcoming' | 'ended'; +export interface ExhibitionDto { + id: string; + name: string; + startDate: string; // "2026-08-11" + endDate: string; // "2026-08-14" + status: ExhibitionStatus | string; // DB 원문(ended 등) — 프론트는 날짜 기준 재계산 + hallLabel?: string | null; // 배정 홀(후속 enrich) — 예 "제2전시장 홀7" + category?: string | null; // 카테고리(후속 enrich) + estVisitors?: number | null; // 추정 인원(후속 enrich) +} + export interface AuthUser { userId: string; displayName: string; @@ -64,6 +80,28 @@ export interface LoginRequest { email: string; password: string; } + +// ── 2차 인증(OTP·TOTP) — 로그인 2단계 + 등록 강제 ── +// 1단계 /api/auth/login/secure 응답 status: +// - OK → OTP 미사용(관람객). login 에 최종 토큰. +// - OTP_REQUIRED → 등록 사용자. challengeToken 으로 /otp/verify(6자리) → 최종 토큰. +// - OTP_ENROLL → 필수 역할 미등록. challengeToken 으로 /otp/enroll/setup → /otp/enroll/verify. +export type SecureLoginStatus = 'OK' | 'OTP_REQUIRED' | 'OTP_ENROLL'; +export interface SecureLoginResponse { + status: SecureLoginStatus; + challengeToken: string | null; + login: LoginResponse | null; +} +// 시크릿·otpauth URI·QR(data:image/png;base64) — 등록 화면 1회 표기용(시크릿은 저장/재노출 금지). +export interface OtpSetupResponse { + secret: string; + otpAuthUri: string; + qrImageDataUri: string; +} +export interface OtpStatusResponse { + otpEnabled: boolean; + verifyMethod: string; +} export interface AcceptInviteRequest { inviteCode: string; companyRegistrationNo: string; @@ -322,3 +360,360 @@ export interface LoginSlideUpdateRequest { sortOrder?: number | null; active?: boolean; } + +/* ───────────────────────────────────────────────────────── + * §5B 공통 업무 기능 (SCR-39~48) — 백엔드 com.zioinfo.kintex.work.* + * ★ 각 DTO는 대응 컨트롤러/레코드 시그니처와 정확히 일치(추측 금지). + * ───────────────────────────────────────────────────────── */ + +// ── SCR-39 업무일지 (work/worklog) ── +export interface WorklogDto { + id: string; + eventId: string | null; + writerId: string; + writerName: string; + workDate: string; // YYYY-MM-DD + title: string; + workType: string | null; + progress: string | null; + status: string | null; + content: string | null; + hours: number; + createdAt: string; + updatedAt: string; +} +export interface WorklogSaveRequest { + eventId?: string | null; + workDate: string; + title: string; + workType?: string | null; + progress?: string | null; + status?: string | null; + content?: string | null; + hours?: number | null; +} + +// ── SCR-40 통합 일정 (work/schedule) ── +export interface ScheduleDto { + id: string; + eventId: string | null; + ownerId: string; + ownerName: string; + title: string; + scheduleType: string | null; + importance: string | null; + startAt: string; // ISO-8601 + endAt: string | null; + allDay: boolean; + location: string | null; + content: string | null; + color: string | null; + createdAt: string; +} +export interface ScheduleSaveRequest { + eventId?: string | null; + title: string; + scheduleType?: string | null; + importance?: string | null; + startAt: string; + endAt?: string | null; + allDay?: boolean | null; + location?: string | null; + content?: string | null; + color?: string | null; +} + +// ── SCR-41 쪽지 (work/message) ── +export interface MessageDto { + id: string; + senderId: string; + senderName: string; + title: string; + content: string; + recvType: string | null; // RECV | REF (수신함 뷰) + readAt: string | null; + createdAt: string; +} +export interface MessageSendRequest { + title?: string | null; + content?: string | null; + recipientIds: string[]; // RECV + refIds?: string[]; // REF +} + +// ── SCR-42 공지 (work/notice) ── +export interface NoticeDto { + id: string; + eventId: string | null; + category: string | null; + title: string; + content: string | null; + pinned: boolean; + authorId: string; + authorName: string; + viewCount: number; + publishedAt: string | null; + createdAt: string; +} +export interface NoticeSaveRequest { + eventId?: string | null; + category?: string | null; + title: string; + content?: string | null; + pinned?: boolean | null; +} + +// ── SCR-43 의견접수 (work/opinion) ── +export interface OpinionDto { + id: string; + eventId: string | null; + category: string | null; + title: string; + content: string | null; + status: string; // 접수 RECEIVED | 검토중 REVIEWING | 답변완료 ANSWERED (백엔드 권위) + authorId: string; + authorName: string; + secretYn: string | null; + createdAt: string; +} +export interface OpinionCommentDto { + id: string; + opinionId: string; + authorId: string; + authorName: string; + content: string; + createdAt: string; +} +export interface OpinionDetail { + opinion: OpinionDto; + comments: OpinionCommentDto[]; +} +export interface OpinionSaveRequest { + eventId?: string | null; + category?: string | null; + title: string; + content?: string | null; + secretYn?: string | null; +} + +// ── SCR-44 통합검색 (work/search) ── +// type: WORKLOG | NOTICE | MEETING | REPORT | OPINION +export type SearchResultType = 'WORKLOG' | 'NOTICE' | 'MEETING' | 'REPORT' | 'OPINION' | string; +export interface SearchResultItem { + type: SearchResultType; + id: string; + title: string; + createdAt: string; +} + +// ── SCR-45 회의록 (work/meeting) ── +export interface MeetingDto { + id: string; + eventId: string | null; + title: string; + location: string | null; + meetingAt: string; // ISO-8601 + organizerId: string; + organizerName: string; + content: string | null; + minutes: string | null; + createdAt: string; +} +export interface MeetingActionDto { + id: string; + meetingId: string; + seq: number; + actionItem: string; + assigneeId: string | null; + dueDate: string | null; + status: string; // OPEN | DONE … +} +export interface MeetingDetail { + meeting: MeetingDto; + actions: MeetingActionDto[]; +} +export interface MeetingSaveRequest { + eventId?: string | null; + title: string; + location?: string | null; + meetingAt: string; + content?: string | null; +} +export interface MeetingActionRequest { + actionItem: string; + assigneeId?: string | null; + dueDate?: string | null; +} + +// ── SCR-46 업무보고 (work/report) + 통계 (work/stats) ── +export type ReportType = 'DAILY' | 'WEEKLY' | 'MONTHLY' | string; +export interface ReportDto { + id: string; + eventId: string | null; + reportType: string; + periodFrom: string | null; + periodTo: string | null; + title: string; + content: string | null; + authorId: string; + authorName: string; + createdAt: string; +} +export interface ReportSaveRequest { + eventId?: string | null; + reportType?: string | null; + periodFrom?: string | null; + periodTo?: string | null; + title: string; + content?: string | null; +} +export interface StatItem { + name: string; + count: number; + hours: number; +} +export interface WorkStatsDto { + totalCount: number; + totalHours: number; + byStatus: StatItem[]; + byType: StatItem[]; + byWriter: StatItem[]; +} + +// ── SCR-47 알림센터 (work/notification) ── +export interface NotificationDto { + id: string; + recipientId: string; + eventId: string | null; + notiType: string; + title: string; + message: string | null; + link: string | null; + readAt: string | null; + createdAt: string; +} + +/* ───────────────────────────────────────────────────────── + * 대시보드 계열 집계 (SCR-02·13·14·16) + * 정본: 백엔드 record — com.zioinfo.kintex.{dashboard,analytics,admin,ops}.dto.* + * 봉투 내부 data. 원천 부재 지표는 백엔드가 0/null/빈배열로 정직 반환한다. + * ───────────────────────────────────────────────────────── */ + +// ── SCR-02 주최자 대시보드 (dashboard.DashboardDto) ── +export interface DashboardMilestone { + label: string; + date: string; // "YYYY-MM-DD" + state: 'done' | 'current' | 'upcoming' | string; +} +export interface DashboardKpi { + label: string; + value: string; + delta: string | null; + trend: 'up' | 'down' | 'flat' | string; +} +export interface DashboardExhibitor { + companyName: string; + boothNo: string; + status: string | null; +} +export interface DashboardFeed { + id: string; + kind: string; // "render" 등 + message: string; + at: string; // ISO +} +export interface DashboardData { + milestones: DashboardMilestone[]; + kpis: DashboardKpi[]; + exhibitors: DashboardExhibitor[]; + feed: DashboardFeed[]; +} + +// ── SCR-13 경영분석 BI (analytics.AnalyticsDto) ── +export type AnalyticsPerspective = 'operator' | 'exhibitor'; +export type AnalyticsPeriod = 'event' | 'd90' | 'annual'; +export interface AnalyticsKpi { + label: string; + value: string; + delta: string | null; + trend: 'up' | 'down' | 'flat' | string; +} +export interface AnalyticsTrendPoint { + period: string; // "YYYY-MM" + revenue: number; + booths: number; +} +export interface AnalyticsSector { + name: string; + sharePercent: number; + revenue: number; +} +export interface AnalyticsPnlRow { + item: string; + amount: number; + grade: 'A' | 'B' | 'C' | string; +} +export interface AnalyticsExhibitorPerf { + companyName: string; + leads: number; + conversionPercent: number; +} +export interface AnalyticsData { + kpis: AnalyticsKpi[]; + trend: AnalyticsTrendPoint[]; + sectors: AnalyticsSector[]; + pnl: AnalyticsPnlRow[]; + exhibitorPerf: AnalyticsExhibitorPerf[]; +} + +// ── SCR-14 관리자 백오피스 (admin.AdminDashboardDto) ── +export interface AdminKpiDto { + label: string; + value: string; + sub: string | null; +} +export interface AdminVisitorPoint { + hour: string; + count: number; +} +export interface AdminLiveEvent { + eventId: string; + name: string; + hall: string | null; + occupancy: number; + status: string; +} +export interface AdminDashboardData { + kpis: AdminKpiDto[]; + visitorTrend: AdminVisitorPoint[]; + liveEvents: AdminLiveEvent[]; + tenants: string[]; +} + +// ── SCR-16 홀 현장 운영 (ops.OpsDto) ── +export interface OpsHall { + hallId: string; + label: string; + estPeople: number; + heat: 'smooth' | 'moderate' | 'busy' | string; +} +export interface OpsHvac { + tempC: number | null; + humidityPercent: number | null; + co2Ppm: number | null; +} +export interface OpsLightingZone { + zone: string; + onPercent: number; +} +export interface OpsParkingZone { + zone: string; + occupancy: number; + capacity: number; +} +export interface OpsData { + halls: OpsHall[]; + hvac: OpsHvac; + lighting: OpsLightingZone[]; + parking: OpsParkingZone[]; +} diff --git a/src/frontend/src/components/layout/AppShell.tsx b/src/frontend/src/components/layout/AppShell.tsx index ccfd0cd..2bebf56 100644 --- a/src/frontend/src/components/layout/AppShell.tsx +++ b/src/frontend/src/components/layout/AppShell.tsx @@ -1,18 +1,21 @@ import type { ComponentType } from 'react'; -import { NavLink, Outlet } from 'react-router-dom'; +import { NavLink, Outlet, useNavigate } from 'react-router-dom'; import { useAuthStore } from '../../store/authStore'; import { DdayChip } from '../ui/Badge'; import { IconAnalytics, IconBell, IconCalendar, + IconCheckCircle, IconDashboard, IconDocument, IconExhibitors, IconFloorplan, IconOperations, + IconSearch, IconSettings, IconSettlement, + IconUsers, type IconProps, } from '../ui/icons'; import './app-shell.css'; @@ -34,6 +37,18 @@ const NAV_ITEMS: { key: string; label: string; Icon: ComponentType; t { key: 'admin', label: '관리자', Icon: IconSettings, to: '/admin' }, ]; +/** §5B 공통 업무 기능(SCR-39~48) — 전 역할 공통 영역. */ +const WORK_NAV: { key: string; label: string; Icon: ComponentType; to: string }[] = [ + { key: 'worklog', label: '업무일지', Icon: IconDocument, to: '/work/worklog' }, + { key: 'work-schedule', label: '일정', Icon: IconCalendar, to: '/work/schedule' }, + { key: 'message', label: '쪽지', Icon: IconUsers, to: '/work/message' }, + { key: 'notice', label: '공지', Icon: IconBell, to: '/work/notice' }, + { key: 'meeting', label: '회의록', Icon: IconOperations, to: '/work/meeting' }, + { key: 'report', label: '업무보고', Icon: IconAnalytics, to: '/work/report' }, + { key: 'opinion', label: '의견접수', Icon: IconCheckCircle, to: '/work/opinion' }, + { key: 'search', label: '통합검색', Icon: IconSearch, to: '/work/search' }, +]; + /** * 웹 인증 셸 (design.md §2-2) — 좌측 240px 사이드바 + 상단 바(브레드크럼·D-데이·알림). * 캔버스 화면은 이 셸 안에서 전폭을 사용한다. @@ -42,6 +57,7 @@ export function AppShell() { const user = useAuthStore((s) => s.user); const workspace = useAuthStore((s) => s.currentWorkspace()); const logout = useAuthStore((s) => s.logout); + const navigate = useNavigate(); return (

@@ -84,11 +100,37 @@ export function AppShell() { ))} +
업무 공통
+
    + {WORK_NAV.map((item) => ( +
  • + `kx-shell__nav-link ${isActive ? 'is-active' : ''}`} + > + + {item.label} + +
  • + ))} +
+
-
{workspace && } -
diff --git a/src/frontend/src/components/layout/app-shell.css b/src/frontend/src/components/layout/app-shell.css index fd62d66..0cdd70b 100644 --- a/src/frontend/src/components/layout/app-shell.css +++ b/src/frontend/src/components/layout/app-shell.css @@ -138,6 +138,39 @@ color: var(--color-primary-600); } +/* §5B 업무 공통 2번째 내비 그룹 — 콘텐츠 높이만 차지, 사이드바 스크롤. */ +.kx-shell__sidebar { + overflow-y: auto; +} +.kx-shell__nav { + flex: 0 0 auto; +} +.kx-shell__nav-label { + padding: var(--space-4) var(--space-5) var(--space-2); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + color: var(--color-neutral-500); + text-transform: uppercase; +} +.kx-shell__user { + margin-top: auto; +} +.kx-shell__avatar--btn { + border: none; + cursor: pointer; +} +.kx-shell__user-name--btn { + background: none; + border: none; + text-align: left; + cursor: pointer; + padding: 0; +} +.kx-shell__user-name--btn:hover { + color: var(--color-primary-600); +} + .kx-shell__main { flex: 1; display: flex; diff --git a/src/frontend/src/hooks/useResolvedEventId.ts b/src/frontend/src/hooks/useResolvedEventId.ts new file mode 100644 index 0000000..8266fd7 --- /dev/null +++ b/src/frontend/src/hooks/useResolvedEventId.ts @@ -0,0 +1,14 @@ +import { useParams } from 'react-router-dom'; +import { useAuthStore } from '../store/authStore'; + +/** + * 라우트에 :eventId 가 없는 화면(SCR-13 /analytics · SCR-16 /ops)에서 대상 행사를 해소한다. + * 우선순위: 라우트 param → 로그인 시 선택한 currentEventId → 첫 워크스페이스. + * (LoginPage 의 워크스페이스 선택 패턴 — selectEvent(ws.eventId) 로 currentEventId 설정.) + */ +export function useResolvedEventId(): string | null { + const { eventId: routeEventId } = useParams(); + const currentEventId = useAuthStore((s) => s.currentEventId); + const firstWorkspace = useAuthStore((s) => s.workspaces[0]?.eventId ?? null); + return routeEventId ?? currentEventId ?? firstWorkspace; +} diff --git a/src/frontend/src/screens/admin/AdminDashboardPage.tsx b/src/frontend/src/screens/admin/AdminDashboardPage.tsx index 62dc72e..784f4d6 100644 --- a/src/frontend/src/screens/admin/AdminDashboardPage.tsx +++ b/src/frontend/src/screens/admin/AdminDashboardPage.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import { Link } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; import { Bar, BarChart, @@ -10,26 +11,33 @@ import { YAxis, } from 'recharts'; import { CHART } from '../chartColors'; -import { - ADMIN_KPIS, - LIVE_EVENTS, - TENANTS, - VISITOR_TREND, -} from './sampleAdmin'; +import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; +import { adminApi } from '../../api/endpoints'; +import { ApiRequestError } from '../../api/client'; +import { StatusPill } from '../work/workShared'; +import type { AdminDashboardData } from '../../api/types'; +import { ADMIN_KPIS, LIVE_EVENTS, TENANTS, VISITOR_TREND } from './sampleAdmin'; import './admin.css'; -const MOCK = `${import.meta.env.BASE_URL}mock/organizer_dashboard`; -const THUMBS = [`${MOCK}/img1.jpg`, `${MOCK}/img2.jpg`, `${MOCK}/img3.jpg`]; - /* - * SCR-16 관리자 대시보드 (M18 랜딩). Stitch admin_dashboard 이식. - * - 시스템 개요: KPI 3 + 방문객 추이 막대(오늘/주간) + 진행중 이벤트 리스트. - * ★ 경계면(혼합): 지표 대부분 샘플(집계/센서 미구축). 플로팅 AI 봇은 P2 → 미구현(비노출). - * M18 본체(사용자·역할·감사로그·설정)는 후속 화면 세트(/admin/*). + * SCR-14 관리자 백오피스 대시보드 (M18 랜딩). Stitch admin_dashboard 이식. + * 정상 경로: GET /api/admin/dashboard?tenant (실집계 — 진행/예정 행사·사용자·라이브 점유). + * 폴백: NETWORK/NOT_FOUND 시에만 sampleAdmin 로 강등. 관람객 추이는 센서 부재로 백엔드 빈배열 → 빈 상태. */ export function AdminDashboardPage() { - const [range, setRange] = useState<'today' | 'weekly'>('today'); - const [tenant, setTenant] = useState(TENANTS[0]); + const [tenant, setTenant] = useState(undefined); + + const q = useQuery({ + queryKey: ['admin-dashboard', tenant], + queryFn: () => adminApi.dashboard(tenant), + retry: false, + }); + + const degraded = isDegradable(q.error); + const data: AdminDashboardData | null = q.data ?? (degraded ? FALLBACK : null); + const hardError = q.isError && !degraded; + const tenants = data?.tenants?.length ? data.tenants : TENANTS; + const activeTenant = tenant ?? tenants[0]; return (
@@ -39,18 +47,16 @@ export function AdminDashboardPage() {

실시간 지표 및 운영 상태

- {/* 관리자 하위 화면 — 로그인 슬라이드 관리 */} 로그인 슬라이드 관리 - {/* 플랫폼 슈퍼관리자 전용 테넌트 스위처(샘플) */} @@ -60,79 +66,92 @@ export function AdminDashboardPage() {
-
- 집계 API 대기 (샘플) - 실시간 방문객·주차·추이 위젯은 시연용 샘플입니다. 시스템관리(M18) 지표는 후속 연동됩니다. -
+ {degraded && ( +
+ 오프라인 — 샘플 + 집계 API에 연결하지 못해 시연용 샘플을 표시합니다. +
+ )} -
- {ADMIN_KPIS.map((k) => ( -
- {k.label} - {k.value} - {k.delta && ( - - {k.tone === 'warn' ? '⚠' : k.deltaDir === 'down' ? '▼' : '▲'} {k.delta} - - )} - {k.bar != null && ( -
- -
- )} -
- ))} -
+ {q.isLoading && } -
-
-
-

방문객 추이 ({range === 'today' ? '오늘' : '주간 평균'})

-
- - -
-
-
- - - - - - - - - -
-
+ {hardError && ( + q.refetch()} /> + )} - -
- {/* 플로팅 AI 컨시어지 버튼: PLANNING M18 미포함 → P2/후속. 현재 비노출. */} + + )}
); } @@ -143,3 +162,44 @@ const TOOLTIP_STYLE = { border: '1px solid #E4E7EC', boxShadow: '0 4px 12px rgba(16,24,40,0.1)', } as const; + +function AdminSkeleton() { + return ( + + ); +} + +function clamp(n: number): number { + return Math.max(0, Math.min(100, Math.round(n))); +} + +function isDegradable(error: unknown): boolean { + return ( + error instanceof ApiRequestError && + (error.code === 'NETWORK' || error.code === 'NOT_FOUND' || error.code === 'NOT_IMPLEMENTED') + ); +} + +/** + * 샘플 폴백(백엔드 shape 매핑) — 정상 경로는 실 API. NETWORK/NOT_FOUND 시에만 사용. + * 근거: 기존 sampleAdmin.ts(집계·센서 미구축 시연 데이터)를 AdminDashboardData 로 강등 변환. + */ +const FALLBACK: AdminDashboardData = { + kpis: ADMIN_KPIS.map((k) => ({ label: k.label, value: k.value, sub: k.delta ?? null })), + visitorTrend: VISITOR_TREND.map((p) => ({ hour: p.hour, count: p.today })), + liveEvents: LIVE_EVENTS.filter((e) => e.live).map((e) => ({ + eventId: e.id, + name: e.title, + hall: e.hallTime, + occupancy: e.progress ?? 60, + status: 'ongoing', + })), + tenants: TENANTS, +}; diff --git a/src/frontend/src/screens/admin/admin.css b/src/frontend/src/screens/admin/admin.css index 190e7c4..2e502f8 100644 --- a/src/frontend/src/screens/admin/admin.css +++ b/src/frontend/src/screens/admin/admin.css @@ -147,6 +147,20 @@ font-size: var(--fs-caption); color: var(--color-neutral-500); } +.kx-livecard__occ { + display: flex; + align-items: center; + gap: var(--space-2); + margin-top: var(--space-2); +} +.kx-livecard__occ .kx-barlist__track { + flex: 1; +} +.kx-livecard__occ-val { + font-size: var(--fs-caption); + color: var(--color-neutral-700); + white-space: nowrap; +} @media (max-width: 1100px) { .kx-admin__kpis { diff --git a/src/frontend/src/screens/analytics/AnalyticsDashboardPage.tsx b/src/frontend/src/screens/analytics/AnalyticsDashboardPage.tsx index 69a4972..b421224 100644 --- a/src/frontend/src/screens/analytics/AnalyticsDashboardPage.tsx +++ b/src/frontend/src/screens/analytics/AnalyticsDashboardPage.tsx @@ -1,4 +1,5 @@ import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; import { Area, AreaChart, @@ -8,40 +9,53 @@ import { XAxis, YAxis, } from 'recharts'; -import { AiLabel, StatusBadge } from '../../components/ui/Badge'; +import { AiLabel } from '../../components/ui/Badge'; import { Button } from '../../components/ui/Button'; +import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; import { CHART } from '../chartColors'; +import { analyticsApi } from '../../api/endpoints'; +import { ApiRequestError } from '../../api/client'; +import { useResolvedEventId } from '../../hooks/useResolvedEventId'; +import type { + AnalyticsData, + AnalyticsKpi, + AnalyticsPeriod, + AnalyticsPerspective, +} from '../../api/types'; import { EXHIBITOR_KPIS, EXHIBITOR_PERF, - EXHIBITOR_TREND, - GRADE_LABEL, OPERATOR_KPIS, OPERATOR_PNL, - OPERATOR_TREND, SECTORS, - type Kpi, - type Perspective, - type Period, } from './sampleAnalytics'; import './analytics.css'; /* * SCR-13 경영분석 대시보드 (M16 BI). Stitch business_intelligence 이식. - * - 관점 토글(운영사/참가업체)로 KPI·추세·성과 테이블 격리 전환. - * ★ 경계면: BI 집계·예측 파이프라인 미구축(PLANNING §5A M16-1) → 전 지표 샘플 시연. - * (라우트 /analytics 는 테넌트 전역. 행사 P&L 드릴다운은 후속.) + * 정상 경로: GET /api/events/{eventId}/analytics?perspective&period (실집계 — 매출·부스 추이·손익). + * 관점(운영사/참가업체)·기간 토글이 쿼리 파라미터를 구동 → 실 refetch. + * 폴백: NETWORK/NOT_FOUND 시에만 sampleAnalytics 로 강등. sectors·exhibitorPerf 원천 부재 시 백엔드 빈배열 → 빈 상태. */ export function AnalyticsDashboardPage() { - const [perspective, setPerspective] = useState('operator'); - const [period, setPeriod] = useState('event'); - + const [perspective, setPerspective] = useState('operator'); + const [period, setPeriod] = useState('event'); + const eventId = useResolvedEventId(); const isOperator = perspective === 'operator'; - const kpis = isOperator ? OPERATOR_KPIS : EXHIBITOR_KPIS; + + const q = useQuery({ + queryKey: ['analytics', eventId, perspective, period], + queryFn: () => analyticsApi.get(eventId as string, perspective, period), + enabled: !!eventId, + retry: false, + }); + + const degraded = isDegradable(q.error); + const data: AnalyticsData | null = q.data ?? (degraded ? fallback(perspective) : null); + const hardError = q.isError && !degraded; return (
- {/* 헤더 */}

경영분석

@@ -70,7 +84,7 @@ export function AnalyticsDashboardPage() { className="kx-select" aria-label="분석 기간" value={period} - onChange={(e) => setPeriod(e.target.value as Period)} + onChange={(e) => setPeriod(e.target.value as AnalyticsPeriod)} > @@ -81,138 +95,155 @@ export function AnalyticsDashboardPage() {
-
- 집계 API 대기 (샘플) - BI 데이터마트·예측 파이프라인 미구축 — 아래 지표는 시연용 샘플입니다. -
+ {!eventId && ( + + )} - {/* KPI 밴드 */} -
- {kpis.map((k) => ( - - ))} -
+ {degraded && ( +
+ 오프라인 — 샘플 + 집계 API에 연결하지 못해 시연용 샘플을 표시합니다. +
+ )} - {/* 히어로 추세(2/3) + 업종 구성(1/3) */} -
-
-
-

{isOperator ? '매출 구성 추이' : '참가업체 vs 바이어 매칭 추이'}

- AI 수요예측 -
-

우측 점선 구간은 AI 예측치입니다 (단위: 백만원 / 건).

-
- - {isOperator ? ( - - - - - - - - - - - - - - - + {q.isLoading && } + + {hardError && ( + q.refetch()} /> + )} + + {data && ( + <> + {/* KPI 밴드 */} +
+ {data.kpis.length === 0 ? ( + + ) : ( + data.kpis.map((k, i) => ) + )} +
+ + {/* 히어로 추세(2/3) + 업종 구성(1/3) */} +
+
+
+

매출 · 부스 추이

+ 월별 집계 +
+

월별 임대·유틸리티 매출과 부스 수 추이입니다 (매출 단위: 원).

+
+ {data.trend.length === 0 ? ( + + ) : ( + + + + + + + + + + + shortWon(v as number)} /> + + + name === '매출' ? formatWon(value as number) : `${(value as number).toLocaleString()} 부스` + } + /> + + + + + )} +
+
+ +
+
+

업종별 구성

+
+ {data.sectors.length === 0 ? ( + ) : ( - - - - - - - - +
    + {data.sectors.map((s) => ( +
  • +
    + {s.name} + {s.sharePercent.toFixed(1)}% +
    +
    + +
    +
  • + ))} +
)} - +
-
-
-
-

업종별 구성

-
-
    - {SECTORS.map((s) => ( -
  • -
    - {s.label} - {s.pct}% -
    -
    - -
    -
  • - ))} -
-
-
- - {/* 성과 테이블(전폭) */} -
-
-

{isOperator ? '홀·행사별 손익(P&L)' : '상위 참가사 성과'}

- 집계 API 대기 (샘플) -
-
- {isOperator ? ( - - - - - - - - - - - - - {OPERATOR_PNL.map((r) => ( - - - - - - - - - ))} - -
행사매출공헌이익마진율상태
{r.event}{r.hall}{r.revenue}{r.contribution}{r.margin}
- ) : ( - - - - - - - - - - - - - {EXHIBITOR_PERF.map((r) => ( - - - - - - - - - ))} - -
업체명부스방문 수매칭 수설계 상태성과
{r.company}{r.booth}{r.visits.toLocaleString()}{r.matches}
- )} -
-
+ {/* 성과 테이블(전폭) */} +
+
+

{isOperator ? '손익(P&L) 구성' : '참가사 성과'}

+ {degraded && 샘플} +
+
+ {isOperator ? ( + data.pnl.length === 0 ? ( + + ) : ( + + + + + + + + + + {data.pnl.map((r, i) => ( + + + + + + ))} + +
항목금액등급
{r.item}{formatWon(r.amount)}
+ ) + ) : data.exhibitorPerf.length === 0 ? ( + + ) : ( + + + + + + + + + + {data.exhibitorPerf.map((r, i) => ( + + + + + + ))} + +
업체명리드전환율
{r.companyName}{r.leads.toLocaleString()}{r.conversionPercent.toFixed(1)}%
+ )} +
+
+ + )}
); } @@ -224,23 +255,102 @@ const TOOLTIP_STYLE = { boxShadow: '0 4px 12px rgba(16,24,40,0.1)', } as const; -function KpiCard({ kpi }: { kpi: Kpi }) { +function BiSkeleton() { return ( -
- - {kpi.label} - {kpi.ai && ✦ AI} - + + ); +} + +function KpiCard({ kpi }: { kpi: AnalyticsKpi }) { + const dir = kpi.trend === 'down' ? 'down' : 'up'; + return ( +
+ {kpi.label} {kpi.value} {kpi.delta && ( - - {kpi.deltaDir === 'down' ? '▼' : '▲'} {kpi.delta} + + {kpi.trend === 'down' ? '▼' : kpi.trend === 'up' ? '▲' : ''} {kpi.delta} )}
); } -function GradePill({ grade }: { grade: 'excellent' | 'good' | 'fair' | 'review' }) { - return {GRADE_LABEL[grade]}; +type GradeKey = 'excellent' | 'good' | 'fair' | 'review'; +const GRADE_MAP: Record = { + A: { key: 'excellent', label: 'A' }, + B: { key: 'good', label: 'B' }, + C: { key: 'fair', label: 'C' }, +}; +function GradePill({ grade }: { grade: string }) { + const g = GRADE_MAP[grade] ?? { key: 'review' as GradeKey, label: grade }; + return {g.label}; +} + +/** 원 금액 → 억/만 단위 축약. */ +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 isDegradable(error: unknown): boolean { + return ( + error instanceof ApiRequestError && + (error.code === 'NETWORK' || error.code === 'NOT_FOUND' || error.code === 'NOT_IMPLEMENTED') + ); +} + +/** + * 샘플 폴백(백엔드 shape 매핑) — 정상 경로는 실 API. NETWORK/NOT_FOUND 시에만 사용. + * 근거: 기존 sampleAnalytics.ts(BI 데이터마트 미구축 시연 데이터)를 AnalyticsData 로 강등 변환. + */ +function fallback(perspective: AnalyticsPerspective): AnalyticsData { + const isOperator = perspective === 'operator'; + const kpis = (isOperator ? OPERATOR_KPIS : EXHIBITOR_KPIS).map((k) => ({ + label: k.label, + value: k.value, + delta: k.delta ?? null, + trend: k.deltaDir === 'down' ? 'down' : k.deltaDir === 'up' ? 'up' : 'flat', + })); + return { + kpis, + trend: [], + sectors: SECTORS.map((s) => ({ name: s.label, sharePercent: s.pct, revenue: 0 })), + pnl: isOperator + ? OPERATOR_PNL.map((r) => ({ + item: `${r.event} (${r.hall})`, + amount: parseWon(r.revenue), + grade: r.grade === 'excellent' ? 'A' : r.grade === 'good' ? 'B' : r.grade === 'fair' ? 'C' : 'C', + })) + : [], + exhibitorPerf: isOperator + ? [] + : EXHIBITOR_PERF.map((r) => ({ + companyName: r.company, + leads: r.matches, + conversionPercent: r.visits > 0 ? (r.matches / r.visits) * 100 : 0, + })), + }; +} +/** "₩412M" 류 표시 문자열 → 원 단위 근사(폴백 전용). */ +function parseWon(s: string): number { + const n = parseFloat(s.replace(/[^0-9.]/g, '')); + if (Number.isNaN(n)) return 0; + if (/B/i.test(s)) return Math.round(n * 1e9); + if (/M/i.test(s)) return Math.round(n * 1e6); + return Math.round(n); } diff --git a/src/frontend/src/screens/dashboard/OrganizerDashboardPage.tsx b/src/frontend/src/screens/dashboard/OrganizerDashboardPage.tsx index 56dc919..229894e 100644 --- a/src/frontend/src/screens/dashboard/OrganizerDashboardPage.tsx +++ b/src/frontend/src/screens/dashboard/OrganizerDashboardPage.tsx @@ -1,29 +1,33 @@ import { useParams } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; import { useAuthStore } from '../../store/authStore'; -import { StatusBadge, AiLabel } from '../../components/ui/Badge'; +import { AiLabel } from '../../components/ui/Badge'; import { AiImage } from '../../components/ui/AiImage'; +import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; +import { dashboardApi } from '../../api/endpoints'; +import { ApiRequestError } from '../../api/client'; +import type { DashboardData } from '../../api/types'; +import { StatusPill, timeAgo } from '../work/workShared'; import { SAMPLE_EXHIBITORS, SAMPLE_FEED, SAMPLE_KPIS, SAMPLE_MILESTONES, - type Milestone, } from './sampleDashboard'; import './dashboard.css'; -// Stitch organizer_dashboard 번들 이미지(실 KINTEX 촬영본). +// Stitch organizer_dashboard 번들 이미지(실 KINTEX 촬영본 — 히어로 시각화 데코, AI 샘플 라벨). const MOCK = `${import.meta.env.BASE_URL}mock/organizer_dashboard`; -const S7_AERIAL = `${MOCK}/img3.jpg`; // 홀 전경 조감(아이소메트릭 항공뷰) -const BOOTH_THUMBS = [`${MOCK}/img1.jpg`, `${MOCK}/img2.jpg`, `${MOCK}/img3.jpg`]; +const S7_AERIAL = `${MOCK}/img3.jpg`; /* * SCR-02 주최자 대시보드. Stitch organizer_dashboard 이식. - * - 히어로: D-데이 마일스톤 타임라인 + KPI 4개 + 참가업체 테이블 + 알림 피드 + S7 미니 카드. - * ★ 경계면 이슈: 계약에 대시보드 집계 엔드포인트 부재 → 마일스톤/KPI/테이블/피드는 샘플 시연. - * 행사 헤더(행사명·기간·홀·D-데이)는 인증 워크스페이스 실데이터. + * 정상 경로: GET /api/events/{eventId}/dashboard (실집계 — milestones·kpis·exhibitors·feed). + * 폴백: NETWORK/NOT_FOUND 시에만 sampleDashboard 로 강등(degraded 배너). 그 외 오류는 재시도 UI. + * 백엔드가 빈배열을 주는 섹션은 빈 상태로 표기(가짜 수치로 덮지 않음). */ export function OrganizerDashboardPage() { - useParams(); // eventId 스코프(라우팅 확인용) + const { eventId } = useParams(); const workspace = useAuthStore((s) => s.currentWorkspace()); const eventName = workspace?.eventName ?? '2026 스마트팩토리 코리아'; @@ -34,9 +38,20 @@ export function OrganizerDashboardPage() { : '2026.08.11 ~ 08.14'; const dday = workspace?.dday ?? 31; + const q = useQuery({ + queryKey: ['dashboard', eventId], + queryFn: () => dashboardApi.get(eventId as string), + enabled: !!eventId, + retry: false, + }); + + const degraded = isDegradable(q.error); + const data: DashboardData | null = q.data ?? (degraded ? FALLBACK : null); + const hardError = q.isError && !degraded; + return (
- {/* 행사 헤더 */} + {/* 행사 헤더 — 인증 워크스페이스 실데이터 */}

{eventName}

@@ -47,132 +62,225 @@ export function OrganizerDashboardPage() { 개장 D-{dday}
- {/* 히어로: 마일스톤 타임라인 */} -
-
    - {SAMPLE_MILESTONES.map((m, i) => ( -
  1. - {i < SAMPLE_MILESTONES.length - 1 && ( -
  2. - ))} -
-
+ - {/* KPI 카드 4개 */} -
- {SAMPLE_KPIS.map((k) => ( -
- {k.label} - {k.value} - {k.sub && {k.sub}} +
+
+

홀 전경 조감

+ S7 +
+ +
+
- ))} -
- - {/* 하단: 참가업체 테이블(2/3) + 알림·S7(1/3) */} -
-
-
-

참가업체 현황

- 집계 API 대기 (샘플) -
- - - - - - - - - - - - {SAMPLE_EXHIBITORS.map((r, i) => ( - - - - - - - - ))} - -
부스번호업체명설계 상태유틸리티예상 사진
{r.boothNo}{r.company} - - - - - {`${r.company} -
-
- - -
+ + )}
); } -function stateLabel(m: Milestone): string { - switch (m.state) { +function DashboardSkeleton() { + return ( + + ); +} + +/** NETWORK/NOT_FOUND(백엔드 미배포·미접속) 만 샘플 폴백으로 강등. */ +function isDegradable(error: unknown): boolean { + return ( + error instanceof ApiRequestError && + (error.code === 'NETWORK' || error.code === 'NOT_FOUND' || error.code === 'NOT_IMPLEMENTED') + ); +} + +function milestoneClass(state: string): string { + if (state === 'done') return 'done'; + if (state === 'current') return 'imminent'; + return 'upcoming'; +} +function stateLabel(state: string): string { + switch (state) { case 'done': return '완료'; case 'current': return '진행 중'; - case 'imminent': - return '임박'; default: return '예정'; } } +function kpiTone(trend: string): 'normal' | 'warn' | 'error' { + return trend === 'down' ? 'warn' : 'normal'; +} +function feedKind(kind: string): string { + if (kind === 'approval' || kind === 'approved') return 'approved'; + if (kind === 'violation' || kind === 'rejected') return 'rejected'; + if (kind === 'deadline') return 'deadline'; + return 'submitted'; +} +/** "YYYY-MM-DD" → D-데이 라벨(오늘 기준). */ +function ddayLabel(dateStr: string): string { + const d = new Date(dateStr); + if (Number.isNaN(d.getTime())) return dateStr; + const today = new Date(); + today.setHours(0, 0, 0, 0); + d.setHours(0, 0, 0, 0); + const diff = Math.round((d.getTime() - today.getTime()) / 86400000); + if (diff === 0) return 'D-DAY'; + return diff > 0 ? `D-${diff}` : `D+${-diff}`; +} + +/** + * 샘플 폴백(백엔드 shape 매핑) — 정상 경로는 실 API. NETWORK/NOT_FOUND 시에만 사용. + * 근거: 기존 sampleDashboard.ts(경계면 시연 데이터)를 DashboardData 형태로 강등 변환. + */ +const FALLBACK: DashboardData = { + milestones: SAMPLE_MILESTONES.map((m) => ({ + label: m.title, + date: '', + state: m.state === 'imminent' ? 'current' : m.state === 'current' ? 'current' : m.state, + })), + kpis: SAMPLE_KPIS.map((k) => ({ + label: k.label, + value: k.value, + delta: k.sub ?? null, + trend: k.tone === 'error' ? 'down' : k.tone === 'warn' ? 'down' : 'flat', + })), + exhibitors: SAMPLE_EXHIBITORS.map((e) => ({ + companyName: e.company, + boothNo: e.boothNo, + status: e.designStatus, + })), + feed: SAMPLE_FEED.map((f) => ({ id: f.key, kind: f.kind, message: f.text, at: '' })), +}; diff --git a/src/frontend/src/screens/dashboard/dashboard.css b/src/frontend/src/screens/dashboard/dashboard.css index 484d7d4..c7d0881 100644 --- a/src/frontend/src/screens/dashboard/dashboard.css +++ b/src/frontend/src/screens/dashboard/dashboard.css @@ -199,6 +199,17 @@ padding: 2px 8px; border-radius: var(--radius-pill); } +.kx-dash__degraded-bar { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: var(--fs-caption); + color: var(--color-neutral-700); + background: #fff8ef; + border: 1px solid #ffe4bf; + padding: var(--space-2) var(--space-4); + border-radius: var(--radius-lg); +} /* 테이블 */ .kx-table { diff --git a/src/frontend/src/screens/login/LoginPage.tsx b/src/frontend/src/screens/login/LoginPage.tsx index ef72145..91c3bb3 100644 --- a/src/frontend/src/screens/login/LoginPage.tsx +++ b/src/frontend/src/screens/login/LoginPage.tsx @@ -10,10 +10,12 @@ import { InviteDialog } from './InviteDialog'; import { SignupDialog } from './SignupDialog'; import { ForgotPasswordDialog } from './ForgotPasswordDialog'; import { LoginSlides } from './LoginSlides'; -import type { WorkspaceDto } from '../../api/types'; +import { OtpChallengePanel } from './OtpChallengePanel'; +import type { LoginResponse, WorkspaceDto } from '../../api/types'; import './login.css'; -type Phase = 'login' | 'workspace'; +type Phase = 'login' | 'otp' | 'workspace'; +type OtpMode = 'verify' | 'enroll'; // 아이디 기억 — 이메일만 저장(비번 저장 금지). const REMEMBER_KEY = 'kintex_remember_email'; @@ -33,6 +35,8 @@ export function LoginPage() { const [inviteOpen, setInviteOpen] = useState(false); const [signupOpen, setSignupOpen] = useState(false); const [forgotOpen, setForgotOpen] = useState(false); + // 2FA 챌린지 — 1단계 성공 후 OTP 단계로 전환. + const [otpChallenge, setOtpChallenge] = useState<{ token: string; mode: OtpMode } | null>(null); // 아이디 기억 프리필 — 저장된 이메일이 있으면 채우고 체크 유지. useEffect(() => { @@ -43,24 +47,44 @@ export function LoginPage() { } }, []); + // 최종 로그인 확정(공통) — 토큰 적용 + 워크스페이스 단계 이동. + function completeLogin(res: LoginResponse) { + if (remember) localStorage.setItem(REMEMBER_KEY, email.trim()); + else localStorage.removeItem(REMEMBER_KEY); + applyLogin(res); + setOtpChallenge(null); + setPhase('workspace'); + } + async function handleLogin(e: React.FormEvent) { e.preventDefault(); setError(null); setSubmitting(true); try { - const res = await authApi.login({ email, password }); - if (remember) localStorage.setItem(REMEMBER_KEY, email.trim()); - else localStorage.removeItem(REMEMBER_KEY); - applyLogin(res); - setPhase('workspace'); + // 2FA/잠금 적용 1단계 — status 로 분기. + const res = await authApi.secureLogin({ email, password }); + if (res.status === 'OK' && res.login) { + completeLogin(res.login); + } else if (res.challengeToken) { + // OTP_REQUIRED(등록자 6자리) | OTP_ENROLL(미등록 등록 강제). + setOtpChallenge({ + token: res.challengeToken, + mode: res.status === 'OTP_ENROLL' ? 'enroll' : 'verify', + }); + setPhase('otp'); + } else { + setError('로그인 응답을 처리하지 못했습니다. 다시 시도해 주세요.'); + } } catch (err) { if (err instanceof ApiRequestError) { - // 계약: login은 매퍼 대기 시 501(NOT_IMPLEMENTED) — degraded 안내 - setError( - err.code === 'NOT_IMPLEMENTED' - ? '로그인 서비스가 준비 중입니다. 잠시 후 다시 시도해 주세요.' - : err.message, - ); + // 잠금·미구현·자격오류 등 코드별 안내(계정 존재 여부 미노출). + if (err.code === 'ACCOUNT_LOCKED') { + setError('로그인 시도가 일시적으로 제한되었습니다. 잠시 후 다시 시도해 주세요.'); + } else if (err.code === 'NOT_IMPLEMENTED') { + setError('로그인 서비스가 준비 중입니다. 잠시 후 다시 시도해 주세요.'); + } else { + setError(err.message); + } } else { setError('로그인 중 오류가 발생했습니다.'); } @@ -75,6 +99,28 @@ export function LoginPage() { navigate(`/events/${ws.eventId}/halls/H7/layout`); } + if (phase === 'otp' && otpChallenge) { + return ( +
+ +
+ { + setOtpChallenge(null); + setPassword(''); + setError(null); + setPhase('login'); + }} + /> +

© 2026 KINTEX. All rights reserved.

+
+
+ ); + } + if (phase === 'workspace') { return (
diff --git a/src/frontend/src/screens/login/OtpChallengePanel.tsx b/src/frontend/src/screens/login/OtpChallengePanel.tsx new file mode 100644 index 0000000..a5b0b94 --- /dev/null +++ b/src/frontend/src/screens/login/OtpChallengePanel.tsx @@ -0,0 +1,136 @@ +import { useEffect, useState } from 'react'; +import { authApi } from '../../api/endpoints'; +import { ApiRequestError } from '../../api/client'; +import { Button } from '../../components/ui/Button'; +import type { LoginResponse, OtpSetupResponse } from '../../api/types'; + +/** + * 로그인 2단계 OTP 패널 — LoginPage 1단계 성공 후 표시. + * - mode="verify" : 이미 OTP 등록된 사용자 → 6자리 코드만 입력. + * - mode="enroll" : 필수 역할 미등록 → setup(QR·시크릿) 발급 후 6자리 확인·활성화. + * challengeToken 은 짧은 수명(5분)·1회성 프리오스 토큰(정식 JWT 아님). + */ +interface Props { + challengeToken: string; + mode: 'verify' | 'enroll'; + onSuccess: (res: LoginResponse) => void; + onCancel: () => void; +} + +export function OtpChallengePanel({ challengeToken, mode, onSuccess, onCancel }: Props) { + const [setup, setSetup] = useState(null); + const [code, setCode] = useState(''); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [loadingSetup, setLoadingSetup] = useState(mode === 'enroll'); + + // 등록 강제 모드 — 진입 시 시크릿·QR 발급. + useEffect(() => { + if (mode !== 'enroll') return; + let alive = true; + (async () => { + try { + const res = await authApi.otpEnrollSetup(challengeToken); + if (alive) setSetup(res); + } catch (err) { + if (alive) setError(messageOf(err)); + } finally { + if (alive) setLoadingSetup(false); + } + })(); + return () => { + alive = false; + }; + }, [mode, challengeToken]); + + async function submit(e: React.FormEvent) { + e.preventDefault(); + if (code.length !== 6) return; + setError(null); + setBusy(true); + try { + const res = + mode === 'enroll' + ? await authApi.otpEnrollVerify(challengeToken, code) + : await authApi.otpVerify(challengeToken, code); + onSuccess(res); + } catch (err) { + setError(messageOf(err)); + setCode(''); + } finally { + setBusy(false); + } + } + + return ( +
+

2차 인증

+

+ {mode === 'enroll' + ? '보안 정책에 따라 OTP(Authenticator) 등록이 필요합니다.' + : 'Authenticator 앱에 표시된 6자리 코드를 입력하세요.'} +

+ + {mode === 'enroll' && ( +
+ {loadingSetup &&

QR 발급 중…

} + {setup && ( + <> +
    +
  1. Google/Microsoft Authenticator 앱에서 아래 QR을 스캔하세요.
  2. +
  3. 스캔이 안 되면 수동 키를 직접 입력하세요.
  4. +
  5. 앱에 표시된 6자리 코드로 등록을 완료하세요.
  6. +
+
+ OTP QR +
+
+ 수동 입력 키 + {setup.secret} +
+ + )} +
+ )} + +
+
+ + setCode(e.target.value.replace(/\D/g, '').slice(0, 6))} + autoFocus + /> +
+ + {error && ( +

+ {error} +

+ )} + + + +
+
+ ); +} + +function messageOf(err: unknown): string { + if (err instanceof ApiRequestError) { + if (err.code === 'OTP_INVALID') return '코드가 올바르지 않습니다. 다시 시도해 주세요.'; + if (err.code === 'OTP_REQUIRED') return '인증 세션이 만료되었습니다. 다시 로그인해 주세요.'; + return err.message; + } + return '인증 중 오류가 발생했습니다.'; +} diff --git a/src/frontend/src/screens/login/OtpSetupPage.tsx b/src/frontend/src/screens/login/OtpSetupPage.tsx new file mode 100644 index 0000000..b9f9667 --- /dev/null +++ b/src/frontend/src/screens/login/OtpSetupPage.tsx @@ -0,0 +1,168 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { authApi } from '../../api/endpoints'; +import { ApiRequestError } from '../../api/client'; +import { Button } from '../../components/ui/Button'; +import type { OtpSetupResponse } from '../../api/types'; +import './login.css'; + +/** + * 2차 인증(Authenticator/TOTP) 관리 — 로그인 상태에서 접근(/otp-setup). + * 상태 조회 → 미등록이면 setup(QR)·6자리 confirm → 활성화. 등록 상태면 해제 가능. + */ +type Phase = 'loading' | 'idle' | 'setup' | 'enabled'; + +export function OtpSetupPage() { + const navigate = useNavigate(); + const [phase, setPhase] = useState('loading'); + const [data, setData] = useState(null); + const [code, setCode] = useState(''); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + useEffect(() => { + let alive = true; + (async () => { + try { + const st = await authApi.otpStatus(); + if (alive) setPhase(st.otpEnabled ? 'enabled' : 'idle'); + } catch (err) { + if (alive) { + setError(messageOf(err)); + setPhase('idle'); + } + } + })(); + return () => { + alive = false; + }; + }, []); + + async function startSetup() { + setError(null); + setBusy(true); + try { + const res = await authApi.otpSetup(); + setData(res); + setPhase('setup'); + } catch (err) { + setError(messageOf(err)); + } finally { + setBusy(false); + } + } + + async function confirm() { + setError(null); + setBusy(true); + try { + await authApi.otpConfirm(code.trim()); + setData(null); + setCode(''); + setPhase('enabled'); + } catch (err) { + setError(messageOf(err)); + } finally { + setBusy(false); + } + } + + async function disable() { + setError(null); + setBusy(true); + try { + await authApi.otpResetSelf(); + setPhase('idle'); + } catch (err) { + setError(messageOf(err)); + } finally { + setBusy(false); + } + } + + return ( +
+
+

2차 인증 — Authenticator(OTP)

+

+ Google·Microsoft Authenticator 등 OTP 앱으로 6자리 코드를 사용하는 2차 인증을 관리합니다. +

+ + {error && ( +

+ {error} +

+ )} + + {phase === 'loading' &&

상태 확인 중…

} + + {phase === 'idle' && ( +
+

등록을 시작하면 QR 코드가 발급됩니다.

+ +
+ )} + + {phase === 'setup' && data && ( +
+
    +
  1. Authenticator 앱에서 아래 QR을 스캔하세요.
  2. +
  3. 스캔이 안 되면 수동 키를 직접 입력하세요.
  4. +
  5. 앱에 표시된 6자리 코드를 입력해 확인하세요.
  6. +
+
+ OTP QR +
+
+ 수동 입력 키 + {data.secret} +
+
+ + setCode(e.target.value.replace(/\D/g, '').slice(0, 6))} + /> +
+ +
+ )} + + {phase === 'enabled' && ( +
+

2차 인증(Authenticator)이 활성화되어 있습니다.

+ +
+ )} + + +
+
+ ); +} + +function messageOf(err: unknown): string { + if (err instanceof ApiRequestError) { + if (err.code === 'OTP_INVALID') return '코드가 올바르지 않습니다. 다시 시도해 주세요.'; + return err.message; + } + return 'OTP 처리 중 오류가 발생했습니다.'; +} diff --git a/src/frontend/src/screens/login/login.css b/src/frontend/src/screens/login/login.css index c1e9be8..d285eb0 100644 --- a/src/frontend/src/screens/login/login.css +++ b/src/frontend/src/screens/login/login.css @@ -494,3 +494,67 @@ font-size: var(--fs-body); color: var(--color-neutral-700); } + +/* ── 2차 인증(OTP) 패널 ─────────────────────────────────────────── */ +.kx-otp-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-4); + background: var(--color-neutral-050); +} +.kx-otp__enroll { + display: flex; + flex-direction: column; + gap: var(--space-3); + margin-bottom: var(--space-3); +} +.kx-otp__steps { + margin: 0; + padding-left: 18px; + font-size: var(--fs-caption); + line-height: 1.8; + color: var(--color-neutral-700); +} +.kx-otp__qr { + text-align: center; +} +.kx-otp__qr img { + border: 1px solid var(--color-neutral-200); + border-radius: 8px; + background: var(--color-white); + padding: 8px; +} +.kx-otp__secret { + display: flex; + flex-direction: column; + gap: 4px; +} +.kx-otp__secret-label { + font-size: var(--fs-caption); + color: var(--color-neutral-500); +} +.kx-otp__secret code { + display: block; + word-break: break-all; + background: var(--color-neutral-050); + border: 1px solid var(--color-neutral-200); + color: var(--color-neutral-900); + padding: 8px 10px; + border-radius: 6px; + font-size: var(--fs-caption); +} +.kx-otp__code-input { + letter-spacing: 6px; + text-align: center; + font-size: 20px; +} +.kx-otp__ok { + font-size: var(--fs-body); + color: var(--color-primary-700); +} +.kx-otp__cancel { + align-self: center; + margin-top: var(--space-2); +} diff --git a/src/frontend/src/screens/ops/HallOperationsPage.tsx b/src/frontend/src/screens/ops/HallOperationsPage.tsx index 7c0b837..aa9e4ff 100644 --- a/src/frontend/src/screens/ops/HallOperationsPage.tsx +++ b/src/frontend/src/screens/ops/HallOperationsPage.tsx @@ -1,31 +1,45 @@ import { useState } from 'react'; -import { - PolarAngleAxis, - RadialBar, - RadialBarChart, - ResponsiveContainer, -} from 'recharts'; -import { CHART, occupancyTone } from '../chartColors'; +import { useQuery } from '@tanstack/react-query'; +import { occupancyTone } from '../chartColors'; +import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; +import { opsApi } from '../../api/endpoints'; +import { ApiRequestError } from '../../api/client'; +import { useResolvedEventId } from '../../hooks/useResolvedEventId'; +import type { OpsData } from '../../api/types'; import { HALL_CELLS, - HALL_GROUPS, - HVAC, + HVAC as SAMPLE_HVAC, LIGHTING_ZONES, PARKING_ZONES, - TOTAL_EST_PEOPLE, } from './sampleOps'; import './ops.css'; /* - * SCR-14 홀 현장운영 대시보드 (M14). Stitch hall_operations 이식. - * - 히어로: 혼잡도 히트맵(홀 셀) + 우측 HVAC·조명존 + 하단 주차 점유. - * ★ 경계면: 센서·IoT 전부 미연동 → 위젯 전부 샘플. 하드웨어 연동 코드 없음(§2.6). + * SCR-16 홀 현장운영 대시보드 (M14). Stitch hall_operations 이식. + * 정상 경로: GET /api/events/{eventId}/ops (배정 홀 실목록 + HVAC/조명/주차). + * ★ 센서·IoT 미연동(PLANNING M14) → 백엔드가 estPeople=0·hvac=null·조명/주차 빈배열을 정직 반환. + * 따라서 실 경로에서도 대부분 빈 상태 UI로 표기(가짜 센서값으로 덮지 않음). + * 폴백: NETWORK/NOT_FOUND 시에만 sampleOps 로 강등. */ export function HallOperationsPage() { const [mode, setMode] = useState<'live' | 'history'>('live'); - const [group, setGroup] = useState(HALL_GROUPS[0].id); + const eventId = useResolvedEventId(); - const powerPct = Math.round((HVAC.powerLoadMw / HVAC.powerCapacityMw) * 100); + const q = useQuery({ + queryKey: ['ops', eventId], + queryFn: () => opsApi.get(eventId as string), + enabled: !!eventId, + retry: false, + }); + + const degraded = isDegradable(q.error); + const data: OpsData | null = q.data ?? (degraded ? FALLBACK : null); + const hardError = q.isError && !degraded; + + const hvac = data?.hvac; + const hasHvac = !!hvac && (hvac.tempC != null || hvac.humidityPercent != null || hvac.co2Ppm != null); + const totalPeople = data?.halls.reduce((s, h) => s + h.estPeople, 0) ?? 0; + const hasPeople = totalPeople > 0; return (
@@ -35,159 +49,190 @@ export function HallOperationsPage() {

실시간 시설 모니터링 및 자원 최적화

- -
-
- {/* 히어로: 혼잡도 히트맵 (span 8) */} -
-
-

실시간 혼잡도 히트맵

- -
- 센서 미연동 · 체크인(M10) 파생/샘플 + {!eventId && ( + + )} -
- {HALL_CELLS.map((c) => ( -
- {c.label} - {c.level === 'busy' && 혼잡} - {c.estPeople.toLocaleString()}명 -
- ))} -
+ {degraded && ( +
+ 오프라인 — 샘플 + 집계 API에 연결하지 못해 시연용 샘플을 표시합니다. +
+ )} -
-
    -
  • 원활
  • -
  • 보통
  • -
  • 혼잡
  • -
- - 총 추정 인원 {TOTAL_EST_PEOPLE.toLocaleString()}명 + {q.isLoading && } + + {hardError && ( + q.refetch()} /> + )} + + {data && ( +
+ {/* 히어로: 홀 혼잡도 */} +
+
+

홀별 현황

+
+ + {hasPeople ? '체크인(M10) 파생' : '센서 미연동 · 배정 홀 목록'} -
-
- {/* 우측: HVAC + 조명존 (span 4) */} - - - {/* 하단: 주차 점유 (span 12) */} -
-
-

주차 점유 현황

- iparking 연계 (샘플) -
-
- {PARKING_ZONES.map((z) => { - const pct = Math.round((z.occupied / z.total) * 100); - const tone = occupancyTone(pct); - return ( -
-
- {z.label} - {pct}% -
-
- -
- - {z.occupied.toLocaleString()} / {z.total.toLocaleString()}면 +
+
    +
  • 원활
  • +
  • 보통
  • +
  • 혼잡
  • +
+ + {hasPeople ? ( + <>총 추정 인원 {totalPeople.toLocaleString()}명 + ) : ( + <>인원 계측 미연동 + )}
- ); - })} -
-
-
+ + )} + + + {/* 우측: HVAC + 조명존 */} + + + {/* 하단: 주차 점유 */} +
+
+

주차 점유 현황

+
+ {data.parking.length === 0 ? ( + + ) : ( +
+ {data.parking.map((z) => { + const pct = z.capacity > 0 ? Math.round((z.occupancy / z.capacity) * 100) : 0; + const tone = occupancyTone(pct); + return ( +
+
+ {z.zone} + {pct}% +
+
+ +
+ + {z.occupancy.toLocaleString()} / {z.capacity.toLocaleString()}면 + +
+ ); + })} +
+ )} +
+
+ )}
); } + +function OpsSkeleton() { + return ( + + ); +} + +function heatClass(heat: string): string { + if (heat === 'busy' || heat === 'moderate' || heat === 'smooth') return heat; + return 'smooth'; +} + +function isDegradable(error: unknown): boolean { + return ( + error instanceof ApiRequestError && + (error.code === 'NETWORK' || error.code === 'NOT_FOUND' || error.code === 'NOT_IMPLEMENTED') + ); +} + +/** + * 샘플 폴백(백엔드 shape 매핑) — 정상 경로는 실 API. NETWORK/NOT_FOUND 시에만 사용. + * 근거: 기존 sampleOps.ts(센서·IoT 미연동 시연 데이터)를 OpsData 로 강등 변환. + */ +const FALLBACK: OpsData = { + halls: HALL_CELLS.map((c) => ({ hallId: c.id, label: c.label, estPeople: c.estPeople, heat: c.level })), + hvac: { tempC: SAMPLE_HVAC.avgTempC, humidityPercent: null, co2Ppm: null }, + lighting: LIGHTING_ZONES.map((z) => ({ zone: z.label, onPercent: z.pct })), + parking: PARKING_ZONES.map((z) => ({ zone: z.label, occupancy: z.occupied, capacity: z.total })), +}; diff --git a/src/frontend/src/screens/ops/ops.css b/src/frontend/src/screens/ops/ops.css index 5cba7d4..58d66a4 100644 --- a/src/frontend/src/screens/ops/ops.css +++ b/src/frontend/src/screens/ops/ops.css @@ -64,6 +64,25 @@ border-radius: var(--radius-pill); } +.kx-ops__degraded-bar { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: var(--fs-caption); + color: var(--color-neutral-700); + background: #fff8ef; + border: 1px solid #ffe4bf; + padding: var(--space-2) var(--space-4); + border-radius: var(--radius-lg); +} +.kx-ops__degraded-badge { + font-size: 11px; + color: var(--color-warning); + background: #fff4e5; + padding: 2px 8px; + border-radius: var(--radius-pill); +} + /* 히트맵 홀 셀 */ .kx-ops__halls { flex: 1; diff --git a/src/frontend/src/screens/schedule/ExhibitionSchedulePage.tsx b/src/frontend/src/screens/schedule/ExhibitionSchedulePage.tsx index 08a28bd..ead6842 100644 --- a/src/frontend/src/screens/schedule/ExhibitionSchedulePage.tsx +++ b/src/frontend/src/screens/schedule/ExhibitionSchedulePage.tsx @@ -1,8 +1,11 @@ import { useMemo, useState } from 'react'; -import { useAuthStore } from '../../store/authStore'; +import { useQuery } from '@tanstack/react-query'; +import { ApiRequestError } from '../../api/client'; +import { exhibitionApi } from '../../api/endpoints'; +import type { ExhibitionDto } from '../../api/types'; import { StatusBadge } from '../../components/ui/Badge'; import { Button } from '../../components/ui/Button'; -import { EmptyState } from '../../components/ui/States'; +import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; import { IconCalendar, IconChevronLeft, @@ -18,6 +21,7 @@ import { eventDaysFor, eventsByDateFor, hallUtilizationFor, + type EventCategory, type EventStatus, type ScheduleEvent, } from './sampleSchedule'; @@ -26,6 +30,42 @@ import './schedule.css'; 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) +const TODAY_YMD = '2026-07-11'; + +const KNOWN_CATEGORIES = new Set(['exhibition', 'culture', 'trade', 'tech']); + +/** 날짜 기준 상태 재계산 — DB status 원문 대신 TODAY 기준 진행/예정/종료로 정규화. */ +function statusFromDates(start: string, end: string): EventStatus { + if (end < TODAY_YMD) return 'ended'; + if (start > TODAY_YMD) return 'upcoming'; + return 'ongoing'; +} + +/** "2026-08-11","2026-08-14" → "2026.08.11–08.14" */ +function periodLabel(start: string, end: string): string { + const s = start.split('-').join('.'); + const e = end.split('-').join('.'); + return e.startsWith(s.slice(0, 5)) ? `${s}–${e.slice(5)}` : `${s}–${e}`; +} + +/** 실 API ExhibitionDto → 화면 ScheduleEvent 매핑. DB 미포함 필드(카테고리·홀·인원)는 기본값. */ +function toScheduleEvent(e: ExhibitionDto, i: number): ScheduleEvent { + const category = (KNOWN_CATEGORIES.has(e.category as EventCategory) + ? (e.category as EventCategory) + : 'exhibition') as EventCategory; + return { + id: e.id, + title: e.name, + category, + status: statusFromDates(e.startDate, e.endDate), + period: periodLabel(e.startDate, e.endDate), + start: e.startDate, + end: e.endDate, + halls: e.hallLabel ?? '홀 미배정', + estVisitors: e.estVisitors != null ? `${e.estVisitors.toLocaleString()}명` : '집계 대기', + thumbIdx: i % THUMBS.length, + }; +} /** 홀 필터 매칭 — halls 문자열("홀 4, 5A"·"제2전시장 홀7")에서 홀 번호만 추출해 전시장 범위("1-5"/"6-10")와 대조. "제N전시장"의 N·"7A-1"의 -1 오매칭 방지. */ function matchesHall(halls: string, range: string): boolean { @@ -47,7 +87,6 @@ function matchesHall(halls: string, range: string): boolean { * ★ 경계면: 행사 목록은 인증 workspaces(실 원천) 우선, 없으면 크롤링 데이터 폴백. */ export function ExhibitionSchedulePage() { - const workspaces = useAuthStore((s) => s.workspaces); const [view, setView] = useState<'list' | 'calendar'>('list'); const [year, setYear] = useState('2026'); const [hall, setHall] = useState('all'); @@ -56,24 +95,31 @@ export function ExhibitionSchedulePage() { // 우측 미니 캘린더·홀 가동률 공유 월 커서 const [asideCursor, setAsideCursor] = useState(new Date(2026, 6, 1)); // 2026년 7월 - // 실 원천: 인증 workspaces → ScheduleEvent 매핑. 없으면 크롤링 10년치 폴백. - const usingReal = workspaces.length > 0; - const events: ScheduleEvent[] = useMemo(() => { - if (!usingReal) return ALL_EVENTS; - return workspaces.map((w, i) => ({ - id: w.eventId, - title: w.eventName, - // ★ category/estVisitors 는 WorkspaceDto 미포함 → 샘플/기본값 - category: 'exhibition' as const, - status: (w.dday > 0 ? 'upcoming' : 'ongoing') as EventStatus, - period: `${w.startDate} – ${w.endDate}`, - start: w.startDate, - end: w.endDate, - halls: w.hallLabel, - estVisitors: '집계 대기', - thumbIdx: i % THUMBS.length, - })); - }, [usingReal, workspaces]); + /* + * 실 원천: `GET /api/events` 전 홀 10년치 카탈로그(ExhibitionDto[]) — event 테이블(V10 시드). + * 백엔드 미구현(NOT_IMPLEMENTED/NOT_FOUND)·네트워크 오류 시에만 크롤링 샘플(ALL_EVENTS)로 폴백한다. + * ALL_EVENTS는 백엔드 V10 seed_exhibitions_10yr 와 동일 원천(kintex.com 크롤링)이라 shape·건수 정합. + */ + const query = useQuery<{ events: ScheduleEvent[]; degraded: boolean }>({ + queryKey: ['exhibitions'], + queryFn: async () => { + try { + const rows = await exhibitionApi.list(); + return { events: rows.map(toScheduleEvent), degraded: false }; + } catch (err) { + if ( + err instanceof ApiRequestError && + (err.code === 'NOT_IMPLEMENTED' || err.code === 'NOT_FOUND' || err.code === 'NETWORK') + ) { + return { events: ALL_EVENTS, degraded: true }; + } + throw err; + } + }, + }); + + const events = query.data?.events ?? []; + const degraded = query.data?.degraded ?? false; const filtered = useMemo( () => @@ -96,7 +142,8 @@ export function ExhibitionSchedulePage() {

전시 일정

- 킨텍스 전 홀 행사 일정 관리·모니터링 — 10년치 {ALL_EVENTS.length.toLocaleString()}건 (kintex.com 수집) + 킨텍스 전 홀 행사 일정 관리·모니터링 — {events.length.toLocaleString()}건 + {degraded && ' (kintex.com 크롤링 폴백)'}

@@ -126,7 +173,7 @@ export function ExhibitionSchedulePage() { ))}
- {!usingReal && 행사 API 대기 (크롤링 데이터)} + {degraded && 행사 API 대기 (크롤링 데이터)}
@@ -134,7 +181,28 @@ export function ExhibitionSchedulePage() {
- {view === 'list' ? ( + {query.isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) : query.isError ? ( +
+ query.refetch()} + /> +
+ ) : view === 'list' ? (

{listed.length.toLocaleString()}건

{listed.length === 0 ? ( diff --git a/src/frontend/src/screens/shared.css b/src/frontend/src/screens/shared.css index 695744a..77e8b2e 100644 --- a/src/frontend/src/screens/shared.css +++ b/src/frontend/src/screens/shared.css @@ -101,6 +101,10 @@ font-weight: 700; color: var(--color-neutral-900); } +.kx-kpi__sub { + font-size: var(--fs-caption); + color: var(--color-neutral-500); +} .kx-kpi--warn { border-color: var(--color-violation-warn); } diff --git a/src/frontend/src/screens/work/MeetingPage.tsx b/src/frontend/src/screens/work/MeetingPage.tsx new file mode 100644 index 0000000..45ca872 --- /dev/null +++ b/src/frontend/src/screens/work/MeetingPage.tsx @@ -0,0 +1,324 @@ +/* + * SCR-45 회의록 (meeting). 참조: UIWS meeting/MeetingDetailPage. + * 좌: 회의 목록 / 우: 상세(본문·회의록 편집·액션아이템). + * 백엔드: /api/work/meetings (get·create·minutes·actions·action status). + * ★ 갭: 녹음 재생·STT 전사·AI 요약·Jasper PDF 엔드포인트 부재 → 해당 UI는 disabled+툴팁. 07_work_api_gaps.md 기록. + */ +import { useEffect, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { meetingApi } from '../../api/endpoints'; +import type { MeetingSaveRequest } from '../../api/types'; +import { Button } from '../../components/ui/Button'; +import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; +import { IconDownload, IconPlus } from '../../components/ui/icons'; +import { StatusPill, errMessage, fmtDateTime, useToast } from './workShared'; +import './work.css'; + +const NOT_SUPPORTED = '백엔드 미지원 — STT/PDF 파이프라인 연동 후 활성화'; + +export function MeetingPage() { + const qc = useQueryClient(); + const { show, node: toast } = useToast(); + const [selectedId, setSelectedId] = useState(null); + const [composing, setComposing] = useState(false); + const [minutesDraft, setMinutesDraft] = useState(''); + const [actionText, setActionText] = useState(''); + + const listQ = useQuery({ + queryKey: ['meetings'], + queryFn: () => meetingApi.list({ page: 0, size: 50 }), + }); + const detailQ = useQuery({ + queryKey: ['meeting', selectedId], + queryFn: () => meetingApi.get(selectedId as string), + enabled: !!selectedId, + }); + + useEffect(() => { + if (detailQ.data) setMinutesDraft(detailQ.data.meeting.minutes ?? ''); + }, [detailQ.data]); + + const createM = useMutation({ + mutationFn: (b: MeetingSaveRequest) => meetingApi.create(b), + onSuccess: (m) => { + show('회의가 등록되었습니다.'); + setComposing(false); + setSelectedId(m.id); + qc.invalidateQueries({ queryKey: ['meetings'] }); + }, + onError: (e) => show(errMessage(e)), + }); + const minutesM = useMutation({ + mutationFn: ({ id, minutes }: { id: string; minutes: string }) => + meetingApi.saveMinutes(id, minutes), + onSuccess: () => { + show('회의록이 저장되었습니다.'); + qc.invalidateQueries({ queryKey: ['meeting', selectedId] }); + }, + onError: (e) => show(errMessage(e)), + }); + const actionM = useMutation({ + mutationFn: ({ id, item }: { id: string; item: string }) => + meetingApi.addAction(id, { actionItem: item }), + onSuccess: () => { + setActionText(''); + qc.invalidateQueries({ queryKey: ['meeting', selectedId] }); + }, + onError: (e) => show(errMessage(e)), + }); + const actionStatusM = useMutation({ + mutationFn: ({ actionId, value }: { actionId: string; value: string }) => + meetingApi.actionStatus(actionId, value), + onSuccess: () => qc.invalidateQueries({ queryKey: ['meeting', selectedId] }), + onError: (e) => show(errMessage(e)), + }); + + const rows = listQ.data?.items ?? []; + + return ( +
+
+
+

회의록

+

사전업무협의(D-30) 회의 기록 · 액션아이템 관리

+
+ +
+ +
+ {/* 목록 */} +
+
+ {listQ.isLoading && + Array.from({ length: 5 }).map((_, i) => ( +
+ + +
+ ))} + {listQ.isError && ( + listQ.refetch()} /> + )} + {!listQ.isLoading && !listQ.isError && rows.length === 0 && ( + + )} + {rows.map((m) => ( +
{ + setComposing(false); + setSelectedId(m.id); + }} + > + {m.title} + + {fmtDateTime(m.meetingAt)} + {m.location && ` · ${m.location}`} + +
+ ))} +
+
+ + {/* 상세 */} +
+ {composing ? ( + setComposing(false)} + onSave={(b) => createM.mutate(b)} + /> + ) : !selectedId ? ( + + ) : detailQ.isLoading ? ( + <> + + + + ) : detailQ.isError ? ( + detailQ.refetch()} /> + ) : detailQ.data ? ( + <> +
+

{detailQ.data.meeting.title}

+ + + +
+
+ {detailQ.data.meeting.organizerName} + · {fmtDateTime(detailQ.data.meeting.meetingAt)} + {detailQ.data.meeting.location && · {detailQ.data.meeting.location}} +
+ + {/* 녹음/STT (미지원) */} +
+ ▶ 녹음 재생 · STT 전사 (준비 중) +
+ + {detailQ.data.meeting.content && ( +
+ 회의 안건 +
{detailQ.data.meeting.content}
+
+ )} + + {/* 회의록 편집 */} +
+ 회의록 +