From a963c69141940152d2133350fd9e8353d375b38f Mon Sep 17 00:00:00 2001 From: zio Date: Sat, 11 Jul 2026 18:37:26 +0900 Subject: [PATCH] =?UTF-8?q?feat(impl):=20M2~M5=20=EB=B0=B0=EC=84=A0(501?= =?UTF-8?q?=E2=86=92200)=20+=20=ED=94=84=EB=A1=A0=ED=8A=B8=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=204=EC=A2=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit backend: M2 배치/규정검증(PostGIS)·M3 설계·M4 배선/견적·M5 RenderJob + 인증(login/workspaces/accept-invite) 매퍼 배선으로 501 해소. M2 자동배치 3안 생성→선택/병합→규정 재검증. 계약 확장(Violation 좌표·LayoutDto.hall·AutoLayoutOption.booths, additive NON_NULL). 신규 HallMapper·UtilityOrderMapper(기존 테이블). gradlew build 통과. frontend: SCR-02 대시보드·SCR-06 설계스튜디오(4샷+Before/After)·SCR-07 배선뷰(적/청/녹)·SCR-12 갤러리 + CompareSlider(reroomai)·WiringCanvas. tsc/vite build EXIT 0. Co-Authored-By: Claude Opus 4.8 (1M context) --- _workspace/02_frontend_progress.md | 23 +- _workspace/03_backend_wiring.md | 85 +++ .../zioinfo/kintex/auth/AuthServiceImpl.java | 124 ++++- .../kintex/auth/mapper/UserMapper.java | 6 + .../kintex/common/geo/GeometryCodec.java | 134 +++++ .../kintex/module/m2/FloorplanController.java | 11 + .../kintex/module/m2/FloorplanService.java | 6 +- .../module/m2/FloorplanServiceImpl.java | 403 +++++++++++++- .../module/m2/dto/ApplyOptionRequest.java | 20 + .../module/m2/dto/AutoLayoutOption.java | 4 + .../kintex/module/m2/dto/HallInfo.java | 55 ++ .../kintex/module/m2/dto/LayoutDto.java | 2 + .../kintex/module/m2/mapper/HallMapper.java | 26 + .../kintex/module/m3/DesignServiceImpl.java | 76 ++- .../kintex/module/m4/UtilityServiceImpl.java | 130 ++++- .../module/m4/mapper/UtilityOrderMapper.java | 24 + .../module/m5/RenderJobServiceImpl.java | 47 +- .../com/zioinfo/kintex/rules/Violation.java | 19 +- .../resources/mybatis/mapper/HallMapper.xml | 52 ++ .../resources/mybatis/mapper/UserMapper.xml | 7 + .../mybatis/mapper/UtilityOrderMapper.xml | 34 ++ src/frontend/src/App.tsx | 17 +- src/frontend/src/api/endpoints.ts | 58 +- src/frontend/src/api/types.ts | 111 ++++ .../src/components/ui/CompareSlider.tsx | 133 +++++ .../src/components/ui/compare-slider.css | 78 +++ .../dashboard/OrganizerDashboardPage.tsx | 167 ++++++ .../src/screens/dashboard/dashboard.css | 311 +++++++++++ .../src/screens/dashboard/sampleDashboard.ts | 65 +++ .../screens/design/BoothDesignStudioPage.tsx | 507 ++++++++++++++++++ .../src/screens/design/placeholders.ts | 28 + src/frontend/src/screens/design/studio.css | 301 +++++++++++ .../gallery/VisualizationGalleryPage.tsx | 200 +++++++ src/frontend/src/screens/gallery/gallery.css | 167 ++++++ .../src/screens/gallery/sampleGallery.ts | 35 ++ .../src/screens/utility/UtilityWiringPage.tsx | 366 +++++++++++++ .../src/screens/utility/WiringCanvas.tsx | 172 ++++++ src/frontend/src/screens/utility/utility.css | 263 +++++++++ src/frontend/src/screens/utility/wiring.css | 138 +++++ 39 files changed, 4325 insertions(+), 80 deletions(-) create mode 100644 _workspace/03_backend_wiring.md create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/common/geo/GeometryCodec.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/module/m2/dto/ApplyOptionRequest.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/module/m2/dto/HallInfo.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/module/m2/mapper/HallMapper.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/module/m4/mapper/UtilityOrderMapper.java create mode 100644 src/backend/src/main/resources/mybatis/mapper/HallMapper.xml create mode 100644 src/backend/src/main/resources/mybatis/mapper/UtilityOrderMapper.xml create mode 100644 src/frontend/src/components/ui/CompareSlider.tsx create mode 100644 src/frontend/src/components/ui/compare-slider.css create mode 100644 src/frontend/src/screens/dashboard/OrganizerDashboardPage.tsx create mode 100644 src/frontend/src/screens/dashboard/dashboard.css create mode 100644 src/frontend/src/screens/dashboard/sampleDashboard.ts create mode 100644 src/frontend/src/screens/design/BoothDesignStudioPage.tsx create mode 100644 src/frontend/src/screens/design/placeholders.ts create mode 100644 src/frontend/src/screens/design/studio.css create mode 100644 src/frontend/src/screens/gallery/VisualizationGalleryPage.tsx create mode 100644 src/frontend/src/screens/gallery/gallery.css create mode 100644 src/frontend/src/screens/gallery/sampleGallery.ts create mode 100644 src/frontend/src/screens/utility/UtilityWiringPage.tsx create mode 100644 src/frontend/src/screens/utility/WiringCanvas.tsx create mode 100644 src/frontend/src/screens/utility/utility.css create mode 100644 src/frontend/src/screens/utility/wiring.css diff --git a/_workspace/02_frontend_progress.md b/_workspace/02_frontend_progress.md index 880b312..c842e4c 100644 --- a/_workspace/02_frontend_progress.md +++ b/_workspace/02_frontend_progress.md @@ -26,7 +26,24 @@ - AI 생성 이미지는 `watermarkRequired`/`watermarkText`/`notice`를 **항상 노출**(제거 불가 `AiImage` 컴포넌트). 계약 §0-3 준수. - 민감정보(IP·SSH·해시) 미표시. 사용자/업체는 이름·역할만. +## 2차 이식 (C-C·C-M3~M5 프론트 — 2026-07-11 추가) + +완료 화면 4 + 공통 컴포넌트 2: +- **SCR-02 주최자 대시보드** (`screens/dashboard/`): D-데이 마일스톤 타임라인(완료 체크·현재 진행·임박 펄스)·KPI 4·참가업체 테이블(상태배지)·알림 피드·S7 미니 카드. 행사 헤더는 인증 워크스페이스 실데이터, 집계부는 샘플(경계면 6 참조). +- **SCR-06 부스 설계 스튜디오** (`screens/design/`): 스펙 아코디언 폼(기본·존·구조·자재·조명, 인라인 리깅/높이 경고)·**4샷 그리드(S1~S4)**·**Before/After(S5)**·규정 사전검증 요약 칩·컨펌 모달("시공 기준은 도면" 동의 필수). `precheck`(완성)·`render POST`(큐잉)+WebSocket 타일 교체 배선. +- **SCR-07 유틸리티 배선 뷰** (`screens/utility/`): 기기 빌더→`quote`(완성)·배선 캔버스(**WiringCanvas** 전기적/네트워크청/급배수녹, 트렌치 그리드·범례·가정 배지)·자동 견적 패널·`order` 신청 CTA. wiring/order 501→degraded. +- **SCR-12 시각화 갤러리** (`screens/gallery/`): 샷 필터 칩(S1~S7)·마소너리 그리드(워터마크 상시·완료/생성중/실패)·라이트박스(메타·S5는 슬라이더)·상단 고지 바 상시. `render-jobs list` 501→샘플. +- **공통 신규**: `components/ui/CompareSlider`(reroomai §5-C 패턴 — clip-path inset + setPointerCapture + 방향키/Shift/Home·End + ARIA slider), `screens/utility/WiringCanvas`(SVG 좌표 정합 배선 렌더러 — 부스·트렌치·경로 동일 좌표계). +- **배선**: `types.ts`에 DesignSpec/DesignPlan/Utility*/Wiring*/RenderScene·RenderJobRequest 추가, `endpoints.ts`에 `designApi(get·save·precheck)`·`utilityApi(quote·wiring·order·get)`·`renderApi(list·create)` 추가, `App.tsx` 4개 라우트. +- **검증**: `tsc -b` EXIT 0, `vite build` EXIT 0 (253 modules, rollup win32 크래시 없음). 토큰 단일 출처 준수(하드코딩 색은 인라인 SVG 플레이스홀더 data URI 한정 — 실이미지 아님). AI 워터마크·고지 상시(AiImage·CompareSlider afterOverlay). WCAG AA(role/aria·slider·switch·focus-visible). + +### 추가 경계면 이슈 (kintex-qa/backend 확인 요청) +6. **[중] 대시보드 집계 엔드포인트 부재** — SCR-02의 마일스톤·KPI(신청 128/160·확정률·유틸 완료율·위반 미해결)·참가업체 현황 테이블·알림 피드에 대응하는 계약 엔드포인트가 없다. 현재 `sampleDashboard.ts` 시연. **요청: `GET /api/events/{eventId}/dashboard`**(milestones[]·kpis[]·exhibitors[]·feed[]) 또는 개별 집계 경로. +7. **[중] 부스 geometry(폴리곤) 조회 부재** — SCR-07 배선 캔버스가 부스 폴리곤을 6×3m로 고정 사용 중. booth 스코프 경로(design·utility)에 부스 크기/폴리곤/홀 위치가 없다. **요청: `GET .../booths/{boothId}` 또는 design/utility 응답에 `booth:{sizeM, polygon, hallPosition}`**(배선 좌표·부스 렌더 정합). +8. **[하] Before/After "시공 전" 이미지 소스 부재** — S5는 시공 후(S1 렌더)만 계약에 있고 시공 전(빈 홀 바닥) 이미지 소스가 없다. 현재 로컬 SVG 플레이스홀더. **요청: 빈 부스 참조 이미지(render `referenceImageUrl`)를 before로 재사용 가능한지 또는 별도 before URL 제공.** +9. **[하] 갤러리 페이지네이션·정렬 계약** — `render-jobs?shot=` 목록은 배열 직접 반환. 대량 시 정렬(최신)·페이지 파라미터 필요 여부 확인. 현재 클라이언트 정렬 없음(서버 순서 신뢰). + ## 남은 화면 (미착수) -- 웹: SCR-02 주최자 대시보드 · SCR-04 배치안 비교(S7) · SCR-05 참가업체 홈 · SCR-06 설계 스튜디오(4샷+Before/After) · SCR-07 배선 뷰 · SCR-08 신청 요약 · SCR-09 규정 리포트 · SCR-10 승인 큐 · SCR-11 검수 상세 · SCR-12 갤러리 -- 모바일(390px): SCR-M1 현장 체크리스트 · SCR-M2 현장 검수 -- 공통 후속: 재사용 컴포넌트(Before/After CompareSlider — reroomai 패턴, 배선 오버레이 렌더러, 데이터 테이블/지브라), 실 캔버스 인터랙션(부스 드래그·리사이즈·병합), WebGL 대규모 렌더 검토. +- 웹: SCR-04 배치안 비교(S7 — AutoLayoutDialog에 부분 구현) · SCR-05 참가업체 홈 · SCR-08 신청 요약 · SCR-09 규정 리포트 · SCR-10 승인 큐 · SCR-11 검수 상세 +- 모바일(390px): SCR-M1 현장 체크리스트 · SCR-M2 현장 검수 · SCR-06/12 모바일 반응형(읽기전용 캐러셀·2열) +- 공통 후속: 실 캔버스 인터랙션(부스 드래그·리사이즈·병합, 배선 단말 실드래그 재계산), 위반 오버레이 좌표 연결(경계면 1), WebGL 대규모 렌더 검토. diff --git a/_workspace/03_backend_wiring.md b/_workspace/03_backend_wiring.md new file mode 100644 index 0000000..b20777d --- /dev/null +++ b/_workspace/03_backend_wiring.md @@ -0,0 +1,85 @@ +# 킨텍스 백엔드 — M2~M5 매퍼 배선 + 계약 확장 (Phase C, D-4) + +> 작성: kintex-backend-dev · 근거: `_workspace/01_backend_contracts.md`·`_workspace/02_db_schema.md`·`_workspace/02_frontend_progress.md`(계약 공백 5건)·IMPLEMENTATION_BACKLOG C-M2~C-M5 +> 대상: **frontend·qa 대조**. 이번 회차는 db-engineer가 완성한 MyBatis 매퍼에 ServiceImpl을 배선해 **M2~M5·인증의 501(NOT_IMPLEMENTED)을 200으로 전환**하고, 프론트가 지적한 계약 공백을 **additive**(하위호환)로 보강한다. 스키마 마이그레이션·2FA·프론트·build.gradle 미변경. 커밋 없음(파일만). + +--- + +## 1. 501 → 200 전환된 엔드포인트 + +| 모듈 | 메서드 | 엔드포인트 | 배선 요지 | +|---|---|---|---| +| 인증 | login | `POST /api/auth/login` | `UserMapper.findAuthByEmail` + BCrypt 대조 + `findEventRoles`→토큰·워크스페이스 | +| 인증 | listWorkspaces | `GET /api/auth/workspaces` | `findEventRoles(userId)`→WorkspaceDto[] | +| 인증 | acceptInvite | `POST /api/auth/accept-invite` | 등록업체 검증(`findCompanyRegistered`)→토큰 재발급. ★초대코드 매핑은 후속(아래 TODO) | +| M2 | getLayout | `GET …/halls/{hallId}/layout` | `findLayout`+`findBooths`(GeoJSON 파싱)+`HallMapper`+검증 요약 | +| M2 | saveLayout | `PUT …/layout` | 폴리곤→WKT `upsertLayout`+`replaceBooths`, 저장 후 규정 재검증 반영 | +| M2 | validate | `POST …/layout/validate` | `minAisleWidth`·`countExitsBlocked`+부스 최댓값→룰엔진 M2 | +| M2 | autoGenerate | `POST …/layout/auto-generate` | 제약 기반 그리드 패킹으로 **정확히 optionCount(기본 3)안** + S7 발행 | +| M2 | applyOption | `POST …/layout/apply-option` **(신규)** | 선택/병합 부스열→새 버전 저장+규정 재검증 | +| M3 | getDesign | `GET …/booths/{boothId}/design` | `findDesign`+spec(jsonb) 역직렬화 | +| M3 | saveDesign | `PUT …/design` | spec 직렬화 `upsertDesign`(버전 next), 최신본 반환 | +| M4 | computeWiring | `POST …/utility/wiring` | `isAssumedTrench`+단말별 `shortestPath`(LineString 파싱)·색상 규약 | +| M4 | submitOrder | `POST …/utility/order` | `quote`+`UtilityOrderMapper.insertOrder`(영속)+위치표시도 URL·마감/릴레이 고지 | +| M4 | getOrder | `GET …/utility` | `UtilityOrderMapper.findOrder`(부스 최신) | +| M5 | listByBooth | `GET …/render-jobs` | `RenderJobMapper.findByBooth`→갤러리 목록(워터마크 항상 포함) | +| M5 | publish(보강) | `POST …/render` | 쿼터 정본을 `countSucceededByEvent`(DB DONE)로 전환 + `insertJob` 내구 이력 | +| M5 | callback(보강) | `POST /api/internal/render/callback` | `updateStatus` 내구 갱신(쿼터는 DONE 집계에서 자동) | + +--- + +## 2. 계약 확장 (additive · 하위호환 · 프론트 gap 해소) + +모두 `@JsonInclude(NON_NULL)`로 **기존 필드 불변, 신규 nullable 필드만 추가**. + +### 2-1. gap #1 — 위반 오버레이 좌표 (`rules.Violation`) +- 추가 필드: `boothId`(String, nullable) · `anchor`([x,y] 미터, nullable). +- **부스 단위 규칙(HEIGHT_MAX·FLOOR_LOAD)** 위반에 한해 해당 부스 id + 폴리곤 중심 앵커를 부여. 레이아웃 단위(AISLE_WIDTH_MIN·EXIT_ACCESS)는 null(캔버스에서 목록 핀만). +- 프론트: `violations[].anchor` 존재 시 캔버스 핀 위치로 사용, 없으면 기존처럼 목록 핀만. + +```json +{ "pin":1, "code":"HEIGHT_MAX", "severity":"block", "measured":"측정 5.4m", + "boothId":"b-102", "anchor":[3.0,1.5] } +``` + +### 2-2. gap #2 — 홀 치수·트렌치 (`m2.dto.LayoutDto.hall` = `HallInfo`) +- `LayoutDto`에 `hall` 필드 추가(신규 `HallInfo`). 캔버스 하드코딩(126×90m·트렌치 그리드) 제거 근거. +- `HallInfo`: `hallId·label·dimsM[w,d]·ceilingM·targetBoothCount·floorLoadTPerM2·assumedTrench·trenches[]·exits[]`. +- `HallInfo.Trench`: `{trenchId,x,y,assumed,supplies[power|water|air|network|gas]}` · `HallInfo.Exit`: `{x,y,clearanceM}`. +- 원천: 신규 `HallMapper`(hall·trench·hall_exit read-only). `assumedTrench=true`면 SCR-07 "가정 트렌치" 배지(PLANNING R4). + +```json +"hall": { "hallId":"H7","label":"제2전시장 홀7","dimsM":[126,90],"ceilingM":12, + "targetBoothCount":510,"floorLoadTPerM2":5.0,"assumedTrench":true, + "trenches":[{"trenchId":"H7-T-3-3","x":3,"y":3,"assumed":true,"supplies":["power","water","air","network","gas"]}], + "exits":[{"x":63,"y":0,"clearanceM":3.0}] } +``` + +### 2-3. gap #3 — 자동배치 안의 부스 + 적용 경로 (`AutoLayoutOption.booths` + apply-option) +- `AutoLayoutOption`에 `booths`(BoothDto[]) 추가 — "이 안으로 편집 시작"/병합 소스. +- 신규 `POST …/layout/apply-option` (ORGANIZER): body `ApplyOptionRequest {name, booths[]}` → 새 버전 저장 + 규정 재검증된 `LayoutDto`. +- **3안 흐름 마감**: auto-generate(3안+각 booths) → 클라 선택/구역 병합(booths 합성) → apply-option(영속+재검증). 단일 안 적용도 동일 엔드포인트. + +--- + +## 3. backend 추가 매퍼 (db-engineer 계약 §3 밖 — 기존 테이블 read/write, 마이그레이션 불변) +> db-engineer 인수·정본화 요청. 모두 V2~V5 기존 테이블만 사용. + +| 매퍼 | 메서드 | 테이블 | +|---|---|---| +| `m2.mapper.HallMapper` | findHall·findTrenches·findExits | hall·trench·hall_exit (read-only) | +| `m4.mapper.UtilityOrderMapper` | insertOrder·findOrder | utility_order (V3) | +| `auth.mapper.UserMapper`(+1) | findCompanyRegistered | company (read-only) | + +--- + +## 4. 남은 TODO (경계·후속) +- **초대코드→행사/부스/역할 매핑**: invite 테이블·매퍼 미제공 → acceptInvite는 등록업체 게이트 + 현재 역할 재발급까지만. 역할 upsert는 매핑 매퍼 인수 시 배선(db-engineer). +- **위치표시도 PDF(M4-3)**: submitOrder는 신청 영속 + 자산 URL(`…/utility/location-diagram`)·고지 반환. 실제 PDF 렌더는 문서 큐(후속). 현재 해당 GET 자산은 미구현(URL만). +- **wiring 지오메트리 영속**: computeWiring은 산출·반환만. utility_order.wiring(MultiLineString) 저장은 후속. +- **자동배치 최적화**: 현재 결정적 그리드 패킹(3안=통로폭/규격 변주, 무위반 골격). 제약 솔버 고도화는 후속(흐름은 성립). +- **트렌치 실측(R4)**: 가정 그리드 유지(is_assumed=true). CAD 추출 교체 시 HallInfo.assumedTrench=false 자동 반영. + +## 5. 검증 +- `./gradlew compileJava` · `./gradlew build -x test` 통과. 매퍼 id/namespace 정합 확인. PostgreSQL 미기동으로 런타임 왕복은 dev 배포 후. +- 보안 불변 유지: password_hash/otp 응답 제외, 스택트레이스 미노출(요약 메시지), AI 이미지 워터마크·고지 항상 포함, 워커 에러 요약화. diff --git a/src/backend/src/main/java/com/zioinfo/kintex/auth/AuthServiceImpl.java b/src/backend/src/main/java/com/zioinfo/kintex/auth/AuthServiceImpl.java index 5fe3d22..5acc3e2 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/auth/AuthServiceImpl.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/auth/AuthServiceImpl.java @@ -6,53 +6,135 @@ import com.zioinfo.kintex.auth.dto.LoginResponse; import com.zioinfo.kintex.auth.dto.WorkspaceDto; import com.zioinfo.kintex.auth.mapper.UserMapper; import com.zioinfo.kintex.common.error.ApiException; +import com.zioinfo.kintex.common.error.ErrorCode; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; /** - * 인증 서비스 골격. JWT 발급/검증 경로({@link JwtService})는 완성되어 있으나, - * 사용자·멤버십 조회는 {@link UserMapper} XML(kintex-db-engineer) 구현 대기 → 501 반환. + * 인증 서비스 — UserMapper(인증행·행사 역할) 배선 완료. 로그인·워크스페이스·초대 수락을 처리한다. * - *

보안: 비밀번호 검증은 저장된 해시와 대조(BCrypt 등)하며, 응답에 해시·내부 식별자 미노출. + *

보안(계약 §0-3): 비밀번호는 BCrypt 해시와 대조({@link PasswordEncoder})하며, 해시·내부 식별자는 응답에 미노출. + * 실패 메시지는 이메일/비밀번호를 구분하지 않는 일반화 문구만 반환한다. + * + *

2차 인증(OTP/TOTP)·로그인 실패 잠금은 common-dev(B-1) 레인이 별도로 얹으며 본 서비스는 건드리지 않는다. */ @Service public class AuthServiceImpl implements AuthService { private final JwtService jwtService; private final UserMapper userMapper; + private final PasswordEncoder passwordEncoder; - public AuthServiceImpl(JwtService jwtService, UserMapper userMapper) { + public AuthServiceImpl(JwtService jwtService, UserMapper userMapper, PasswordEncoder passwordEncoder) { this.jwtService = jwtService; this.userMapper = userMapper; + this.passwordEncoder = passwordEncoder; } @Override public LoginResponse login(LoginRequest request) { - // TODO(kintex-db-engineer): UserMapper.findAuthByEmail + 비밀번호 해시 검증 후 - // JwtService.issue(userId, name, eventRoles, hallManager) 로 토큰 발급. - // 구현 예시 흐름: - // var row = userMapper.findAuthByEmail(request.email()); - // if (row == null || !passwordEncoder.matches(request.password(), (String) row.get("passwordHash"))) - // throw new ApiException(ErrorCode.UNAUTHORIZED, "이메일 또는 비밀번호가 올바르지 않습니다."); - // ... eventRoles = userMapper.findEventRoles(userId) 조립 ... - // token = jwtService.issue(...); - throw ApiException.notImplemented("로그인(사용자 인증 조회)"); + Map row = userMapper.findAuthByEmail(request.email()); + String hash = row == null ? null : str(row.get("passwordHash")); + if (row == null || hash == null || !passwordEncoder.matches(request.password(), hash)) { + // 이메일/비밀번호 구분 없이 일반화(계정 열거 방지). + throw new ApiException(ErrorCode.UNAUTHORIZED, "이메일 또는 비밀번호가 올바르지 않습니다."); + } + String userId = str(row.get("userId")); + String displayName = str(row.get("displayName")); + boolean hallManager = Boolean.TRUE.equals(bool(row.get("hallManager"))); + return buildLoginResponse(userId, displayName, hallManager); } @Override public List listWorkspaces(KintexPrincipal principal) { - // TODO(kintex-db-engineer): principal.eventRoles 기반 행사 카드(행사명·기간·홀·D-데이) 조회. - throw ApiException.notImplemented("워크스페이스 목록 조회"); + return loadWorkspaces(principal.userId()); } @Override public LoginResponse acceptInvite(KintexPrincipal principal, AcceptInviteRequest request) { - // TODO(kintex-db-engineer): - // 1) inviteCode 로 초대(행사·부스·역할) 조회 - // 2) 역할이 CONTRACTOR면 companyRegistrationNo 로 등록업체 검증 — - // 미등록 시 throw new ApiException(ErrorCode.NOT_REGISTERED_COMPANY) - // 3) 멤버십 upsert 후 갱신된 eventRoles 로 새 토큰 발급(jwtService.issue) - throw ApiException.notImplemented("초대 코드 수락(등록업체 검증 포함)"); + // 장치업체(CONTRACTOR) 초대 수락 시 사업자번호 → 킨텍스 등록업체 검증(미등록 차단, 계약 §M7). + if (request.companyRegistrationNo() != null && !request.companyRegistrationNo().isBlank()) { + Boolean registered = userMapper.findCompanyRegistered(request.companyRegistrationNo()); + if (!Boolean.TRUE.equals(registered)) { + throw new ApiException(ErrorCode.NOT_REGISTERED_COMPANY, + "킨텍스 등록업체만 참여할 수 있습니다."); + } + } + // 멤버십 반영 후 갱신된 역할로 새 토큰 재발급(현재 역할 기준 재조립). + // ★초대코드→행사/부스/역할 매핑(invite 테이블)은 db-engineer 인수 대기 — 매핑 매퍼 제공 시 역할 upsert 배선. + return buildLoginResponse(principal.userId(), principal.displayName(), principal.hallManager()); + } + + /** 사용자 역할을 조회해 토큰 + 워크스페이스 카드를 조립한다. */ + private LoginResponse buildLoginResponse(String userId, String displayName, boolean hallManager) { + List> roleRows = userMapper.findEventRoles(userId); + Map eventRoles = new LinkedHashMap<>(); + if (roleRows != null) { + for (Map r : roleRows) { + String eventId = str(r.get("eventId")); + EventRole role = parseRole(str(r.get("roleCode"))); + if (eventId != null && role != null) { + eventRoles.put(eventId, role); + } + } + } + String token = jwtService.issue(userId, displayName, eventRoles, hallManager); + List workspaces = toWorkspaces(roleRows); + return new LoginResponse(token, jwtService.ttlSeconds(), + new LoginResponse.UserSummary(userId, displayName, hallManager), workspaces); + } + + private List loadWorkspaces(String userId) { + return toWorkspaces(userMapper.findEventRoles(userId)); + } + + private List toWorkspaces(List> roleRows) { + List out = new java.util.ArrayList<>(); + if (roleRows == null) { + return out; + } + for (Map r : roleRows) { + out.add(new WorkspaceDto( + str(r.get("eventId")), str(r.get("eventName")), + str(r.get("startDate")), str(r.get("endDate")), + str(r.get("hallLabel")), str(r.get("roleCode")), + intVal(r.get("dday")))); + } + return out; + } + + private static EventRole parseRole(String code) { + if (code == null) { + return null; + } + try { + return EventRole.valueOf(code); + } catch (IllegalArgumentException e) { + return null; + } + } + + private static String str(Object o) { + return o == null ? null : String.valueOf(o); + } + + private static Boolean bool(Object o) { + if (o == null) return null; + if (o instanceof Boolean b) return b; + return Boolean.parseBoolean(String.valueOf(o)); + } + + private static int intVal(Object o) { + if (o instanceof Number n) return n.intValue(); + if (o == null) return 0; + try { + return Integer.parseInt(String.valueOf(o)); + } catch (NumberFormatException e) { + return 0; + } } } diff --git a/src/backend/src/main/java/com/zioinfo/kintex/auth/mapper/UserMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/auth/mapper/UserMapper.java index 3eebb2e..369411d 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/auth/mapper/UserMapper.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/auth/mapper/UserMapper.java @@ -18,4 +18,10 @@ public interface UserMapper { /** 사용자의 행사별 역할 맵 (eventId → roleCode). */ java.util.List> findEventRoles(@Param("userId") String userId); + + /** + * 사업자등록번호가 킨텍스 등록업체(registered=true)인지 조회 → true/false, 없으면 null. + * 장치업체(CONTRACTOR) 초대 수락 게이트(계약: NOT_REGISTERED_COMPANY). 기존 company 테이블 read-only. + */ + Boolean findCompanyRegistered(@Param("registrationNo") String registrationNo); } diff --git a/src/backend/src/main/java/com/zioinfo/kintex/common/geo/GeometryCodec.java b/src/backend/src/main/java/com/zioinfo/kintex/common/geo/GeometryCodec.java new file mode 100644 index 0000000..ae72f57 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/common/geo/GeometryCodec.java @@ -0,0 +1,134 @@ +package com.zioinfo.kintex.common.geo; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.zioinfo.kintex.common.error.ApiException; +import com.zioinfo.kintex.common.error.ErrorCode; + +import java.util.ArrayList; +import java.util.List; + +/** + * 홀 로컬 좌표계(SRID 0, 미터) 지오메트리 ↔ 좌표 배열 변환 유틸. + * + *

+ * + *

순수 좌표 변환만 수행하며 민감정보를 다루지 않는다. 파싱 실패 시 요약 예외만 던진다(스택트레이스 미노출). + */ +public final class GeometryCodec { + + private GeometryCodec() { + } + + /** GeoJSON Polygon 문자열 → 외곽 링 좌표열 [[x,y], ...]. null/오류 시 빈 리스트. */ + public static List> parsePolygonRing(ObjectMapper mapper, String geoJson) { + if (geoJson == null || geoJson.isBlank()) { + return List.of(); + } + try { + JsonNode root = mapper.readTree(geoJson); + JsonNode coords = root.path("coordinates"); + if (!coords.isArray() || coords.isEmpty()) { + return List.of(); + } + return toCoordList(coords.get(0)); // Polygon: coordinates[0] = 외곽 링 + } catch (Exception e) { + return List.of(); + } + } + + /** GeoJSON LineString 문자열 → 좌표열 [[x,y], ...]. null/오류 시 빈 리스트. */ + public static List> parseLineCoords(ObjectMapper mapper, String geoJson) { + if (geoJson == null || geoJson.isBlank()) { + return List.of(); + } + try { + JsonNode root = mapper.readTree(geoJson); + JsonNode coords = root.path("coordinates"); + if (!coords.isArray()) { + return List.of(); + } + return toCoordList(coords); // LineString: coordinates = 좌표열 + } catch (Exception e) { + return List.of(); + } + } + + /** 좌표열 [[x,y], ...] → {@code POLYGON((x y,x y,...))} WKT. 링을 닫아준다(첫점≠끝점이면 첫점 추가). */ + public static String toPolygonWkt(List> ring) { + if (ring == null || ring.size() < 3) { + throw new ApiException(ErrorCode.VALIDATION, "부스 폴리곤은 최소 3개 정점이 필요합니다."); + } + List> closed = new ArrayList<>(ring); + List first = closed.get(0); + List last = closed.get(closed.size() - 1); + if (!pointEquals(first, last)) { + closed.add(first); + } + StringBuilder sb = new StringBuilder("POLYGON(("); + for (int i = 0; i < closed.size(); i++) { + List p = closed.get(i); + if (p == null || p.size() < 2) { + throw new ApiException(ErrorCode.VALIDATION, "부스 폴리곤 정점 좌표가 올바르지 않습니다."); + } + if (i > 0) { + sb.append(','); + } + sb.append(fmt(p.get(0))).append(' ').append(fmt(p.get(1))); + } + return sb.append("))").toString(); + } + + /** 좌표열의 산술 중심(centroid 근사) — 위반 핀 앵커용. 빈 링이면 null. */ + public static List centroid(List> ring) { + if (ring == null || ring.isEmpty()) { + return null; + } + double sx = 0; + double sy = 0; + int n = 0; + for (List p : ring) { + if (p != null && p.size() >= 2) { + sx += p.get(0); + sy += p.get(1); + n++; + } + } + if (n == 0) { + return null; + } + return List.of(round2(sx / n), round2(sy / n)); + } + + private static List> toCoordList(JsonNode arr) { + List> out = new ArrayList<>(); + if (arr == null || !arr.isArray()) { + return out; + } + for (JsonNode pt : arr) { + if (pt.isArray() && pt.size() >= 2) { + out.add(List.of(pt.get(0).asDouble(), pt.get(1).asDouble())); + } + } + return out; + } + + private static boolean pointEquals(List a, List b) { + return a != null && b != null && a.size() >= 2 && b.size() >= 2 + && Math.abs(a.get(0) - b.get(0)) < 1e-9 && Math.abs(a.get(1) - b.get(1)) < 1e-9; + } + + private static String fmt(double d) { + if (d == Math.floor(d) && !Double.isInfinite(d)) { + return String.valueOf((long) d); + } + return String.valueOf(d); + } + + private static double round2(double v) { + return Math.round(v * 100.0) / 100.0; + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/module/m2/FloorplanController.java b/src/backend/src/main/java/com/zioinfo/kintex/module/m2/FloorplanController.java index dff80fb..5dbf7e9 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/module/m2/FloorplanController.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/module/m2/FloorplanController.java @@ -4,6 +4,7 @@ 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.module.m2.dto.ApplyOptionRequest; import com.zioinfo.kintex.module.m2.dto.AutoLayoutOption; import com.zioinfo.kintex.module.m2.dto.AutoLayoutRequest; import com.zioinfo.kintex.module.m2.dto.LayoutDto; @@ -71,4 +72,14 @@ public class FloorplanController { guard.requireRole(principal, eventId, EventRole.ORGANIZER); return ApiResponse.ok(service.autoGenerate(eventId, hallId, request)); } + + /** POST /apply-option — 자동배치 안 선택/병합 적용(주최자). 새 버전 저장 + 규정 재검증. */ + @PostMapping("/apply-option") + public ApiResponse applyOption(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String eventId, + @PathVariable String hallId, + @Valid @RequestBody ApplyOptionRequest request) { + guard.requireRole(principal, eventId, EventRole.ORGANIZER); + return ApiResponse.ok(service.applyOption(eventId, hallId, request)); + } } diff --git a/src/backend/src/main/java/com/zioinfo/kintex/module/m2/FloorplanService.java b/src/backend/src/main/java/com/zioinfo/kintex/module/m2/FloorplanService.java index 6b6b903..f293b3a 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/module/m2/FloorplanService.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/module/m2/FloorplanService.java @@ -1,5 +1,6 @@ package com.zioinfo.kintex.module.m2; +import com.zioinfo.kintex.module.m2.dto.ApplyOptionRequest; import com.zioinfo.kintex.module.m2.dto.AutoLayoutOption; import com.zioinfo.kintex.module.m2.dto.AutoLayoutRequest; import com.zioinfo.kintex.module.m2.dto.LayoutDto; @@ -20,6 +21,9 @@ public interface FloorplanService { /** 규정 검증 실행(통로 폭·바닥하중·비상구·복층 등) — 차단/경고 리포트. */ ComplianceReport validate(String eventId, String hallId, Integer version); - /** AI 자동배치 — 조건 입력 → 복수 배치안 후보(제약 솔버/휴리스틱). */ + /** AI 자동배치 — 조건 입력 → 정확히 optionCount개 배치안 후보(제약 기반 배치 + S7 발행). */ List autoGenerate(String eventId, String hallId, AutoLayoutRequest request); + + /** 자동배치 안 적용/병합 → 새 배치안 버전으로 저장 후 규정 재검증(선택/병합→검증 흐름 마감). */ + LayoutDto applyOption(String eventId, String hallId, ApplyOptionRequest request); } 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 63d892a..71ebb4d 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 @@ -1,62 +1,421 @@ package com.zioinfo.kintex.module.m2; +import com.fasterxml.jackson.databind.ObjectMapper; import com.zioinfo.kintex.common.error.ApiException; +import com.zioinfo.kintex.common.error.ErrorCode; +import com.zioinfo.kintex.common.geo.GeometryCodec; +import com.zioinfo.kintex.module.m2.dto.ApplyOptionRequest; import com.zioinfo.kintex.module.m2.dto.AutoLayoutOption; import com.zioinfo.kintex.module.m2.dto.AutoLayoutRequest; +import com.zioinfo.kintex.module.m2.dto.BoothDto; +import com.zioinfo.kintex.module.m2.dto.HallInfo; import com.zioinfo.kintex.module.m2.dto.LayoutDto; import com.zioinfo.kintex.module.m2.dto.LayoutSaveRequest; +import com.zioinfo.kintex.module.m2.dto.LayoutSummary; import com.zioinfo.kintex.module.m2.mapper.BoothMapper; +import com.zioinfo.kintex.module.m2.mapper.HallMapper; +import com.zioinfo.kintex.module.m5.RenderJobService; +import com.zioinfo.kintex.module.m5.dto.RenderJobRequest; import com.zioinfo.kintex.rules.ComplianceReport; import com.zioinfo.kintex.rules.ComplianceRuleEngine; +import com.zioinfo.kintex.rules.Violation; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; /** - * M2 서비스 골격. 규정 엔진({@link ComplianceRuleEngine})은 완성되어 있고, - * 공간 영속/공간 산출({@link BoothMapper} PostGIS XML)은 kintex-db-engineer 구현 대기 → 해당 경로 501. + * M2 플로어플랜 서비스 — BoothMapper(PostGIS)·HallMapper 배선 완료. + * 배치 조회/저장(폴리곤 반영)·규정 검증(통로 폭·비상구·높이·바닥하중 공간 산출)·자동배치 3안 생성/적용을 수행한다. * - *

완성 시 validate()는 다음처럼 조립한다: - *

- *   Map<String,Object> metrics = new HashMap<>();
- *   metrics.put("layout.min_aisle_width_m", boothMapper.minAisleWidth(layoutId));
- *   metrics.put("layout.exits_blocked_count", boothMapper.countExitsBlocked(layoutId, hallId));
- *   // 부스별 height/floor_load 는 부스 루프에서 개별 평가하거나 최댓값으로 집계
- *   return ruleEngine.evaluate("M2", metrics, hallId);
- * 
+ *

규정 엔진({@link ComplianceRuleEngine})은 순수 평가기 — 서비스가 PostGIS 산출값으로 metrics를 조립한다. + * 위반 항목에는 도면 핀 매핑용 boothId/anchor(계약 확장)를 부스 단위 규칙에 한해 부여한다. */ @Service public class FloorplanServiceImpl implements FloorplanService { - private final BoothMapper boothMapper; - private final ComplianceRuleEngine ruleEngine; + private static final Logger log = LoggerFactory.getLogger(FloorplanServiceImpl.class); - public FloorplanServiceImpl(BoothMapper boothMapper, ComplianceRuleEngine ruleEngine) { + /** 자동배치 기본 부스 규격·높이·하중(규정 상한 이내로 무위반 골격). */ + private static final double DEFAULT_HEIGHT_M = 3.0; + private static final double DEFAULT_FLOOR_LOAD = 1.0; + private static final double PERIMETER_MARGIN_M = 5.0; // 홀 외곽 주통로 여유 + + private final BoothMapper boothMapper; + private final HallMapper hallMapper; + private final ComplianceRuleEngine ruleEngine; + private final RenderJobService renderJobService; + private final ObjectMapper objectMapper; + + public FloorplanServiceImpl(BoothMapper boothMapper, HallMapper hallMapper, + ComplianceRuleEngine ruleEngine, RenderJobService renderJobService, + ObjectMapper objectMapper) { this.boothMapper = boothMapper; + this.hallMapper = hallMapper; this.ruleEngine = ruleEngine; + this.renderJobService = renderJobService; + this.objectMapper = objectMapper; } + // ---------------------------------------------------------------- 조회 ----- + @Override public LayoutDto getLayout(String eventId, String hallId, Integer version) { - // TODO(kintex-db-engineer): BoothMapper.findLayout + findBooths(ST_AsGeoJSON) 조립. - throw ApiException.notImplemented("배치안 조회(공간 데이터)"); + Map header = boothMapper.findLayout(eventId, hallId, version); + if (header == null) { + throw new ApiException(ErrorCode.NOT_FOUND, "해당 홀의 배치안을 찾을 수 없습니다."); + } + return assembleLayout(eventId, hallId, header); + } + + // ---------------------------------------------------------------- 저장 ----- + + @Override + @Transactional + public LayoutDto saveLayout(String eventId, String hallId, LayoutSaveRequest request) { + return persistBooths(eventId, hallId, request.name(), request.version(), request.booths()); } @Override - public LayoutDto saveLayout(String eventId, String hallId, LayoutSaveRequest request) { - // TODO(kintex-db-engineer): upsertLayout + replaceBooths(ST_MakePolygon), 저장 후 validate 재산출. - throw ApiException.notImplemented("배치 저장(PostGIS 폴리곤 반영)"); + @Transactional + public LayoutDto applyOption(String eventId, String hallId, ApplyOptionRequest request) { + // 적용/병합은 항상 새 버전으로 저장(version=null → 다음 버전) 후 규정 재검증. + String name = request.name() == null ? "자동배치 적용" : request.name(); + return persistBooths(eventId, hallId, name, null, request.booths()); } + /** 배치안 upsert + 부스 폴리곤 교체 저장 → 규정 검증 반영된 LayoutDto. */ + private LayoutDto persistBooths(String eventId, String hallId, String name, + Integer requestedVersion, List booths) { + int version = requestedVersion != null ? requestedVersion : nextVersion(eventId, hallId); + + Map params = new LinkedHashMap<>(); + params.put("eventId", eventId); + params.put("hallId", hallId); + params.put("version", version); + params.put("name", name); + params.put("status", "draft"); + String layoutId = boothMapper.upsertLayout(params); + + List> boothRows = new ArrayList<>(); + for (BoothDto b : booths) { + Map row = new LinkedHashMap<>(); + row.put("boothId", b.boothId()); + row.put("boothNo", b.boothNo()); + row.put("type", b.type()); + row.put("wkt", GeometryCodec.toPolygonWkt(b.polygon())); + row.put("sizeW", b.sizeM() != null && b.sizeM().size() > 0 ? b.sizeM().get(0) : null); + row.put("sizeD", b.sizeM() != null && b.sizeM().size() > 1 ? b.sizeM().get(1) : null); + row.put("heightM", b.heightM()); + row.put("floorLoad", b.floorLoadTPerM2()); + row.put("assignedCompanyName", b.assignedCompanyName()); + row.put("premium", b.premium()); + boothRows.add(row); + } + boothMapper.replaceBooths(layoutId, boothRows); + + Map header = boothMapper.findLayout(eventId, hallId, version); + if (header == null) { + throw new ApiException(ErrorCode.INTERNAL, "배치안 저장 결과를 확인할 수 없습니다."); + } + log.info("배치 저장: event={} hall={} version={} booths={}", eventId, hallId, version, booths.size()); + return assembleLayout(eventId, hallId, header); + } + + // ------------------------------------------------------------ 규정 검증 ----- + @Override public ComplianceReport validate(String eventId, String hallId, Integer version) { - // TODO(kintex-db-engineer): BoothMapper 공간 산출값으로 metrics 조립 후 ruleEngine.evaluate("M2", ...). - throw ApiException.notImplemented("배치 규정 검증(통로 폭·비상구 공간 산출)"); + Map header = boothMapper.findLayout(eventId, hallId, version); + if (header == null) { + throw new ApiException(ErrorCode.NOT_FOUND, "검증할 배치안을 찾을 수 없습니다."); + } + String layoutId = str(header.get("layoutId")); + return evaluateLayout(layoutId, hallId, loadBooths(layoutId)); } + /** PostGIS 산출값으로 metrics 조립 → 룰 엔진 평가 → 부스 단위 위반에 도면 핀 앵커 부여. */ + private ComplianceReport evaluateLayout(String layoutId, String hallId, List booths) { + Map metrics = new LinkedHashMap<>(); + + Double minAisle = boothMapper.minAisleWidth(layoutId); + if (minAisle != null) { + metrics.put("layout.min_aisle_width_m", round2(minAisle)); + } + Integer exitsBlocked = boothMapper.countExitsBlocked(layoutId, hallId); + metrics.put("layout.exits_blocked_count", exitsBlocked == null ? 0 : exitsBlocked); + + BoothDto maxHeight = maxBy(booths, BoothDto::heightM); + if (maxHeight != null && maxHeight.heightM() != null) { + metrics.put("booth.height_m", maxHeight.heightM()); + } + BoothDto maxLoad = maxBy(booths, BoothDto::floorLoadTPerM2); + if (maxLoad != null && maxLoad.floorLoadTPerM2() != null) { + metrics.put("booth.floor_load_t_per_m2", maxLoad.floorLoadTPerM2()); + } + + ComplianceReport report = ruleEngine.evaluate("M2", metrics, hallId); + List located = attachAnchors(report.violations(), maxHeight, maxLoad); + return new ComplianceReport(report.rulesetVersion(), report.disclaimer(), + report.blockCount(), report.warnCount(), report.passCount(), + report.submittable(), located); + } + + /** 부스 단위 규칙(HEIGHT_MAX·FLOOR_LOAD) 위반에 해당 부스 id·중심 앵커 부여(계약 확장, 프론트 gap #1). */ + private List attachAnchors(List violations, BoothDto heightBooth, BoothDto loadBooth) { + if (violations == null || violations.isEmpty()) { + return violations; + } + List out = new ArrayList<>(violations.size()); + for (Violation v : violations) { + BoothDto b = switch (v.code() == null ? "" : v.code()) { + case "HEIGHT_MAX" -> heightBooth; + case "FLOOR_LOAD" -> loadBooth; + default -> null; + }; + if (b != null) { + out.add(v.withLocation(b.boothId(), GeometryCodec.centroid(b.polygon()))); + } else { + out.add(v); + } + } + return out; + } + + // -------------------------------------------------------- 자동배치 3안 ----- + @Override public List autoGenerate(String eventId, String hallId, AutoLayoutRequest request) { - // TODO(BE): 제약 솔버/휴리스틱 배치 엔진 + S7 RenderJob 발행(M5-1). 홀 규격/트렌치 좌표 필요(D-2). - throw ApiException.notImplemented("AI 자동배치(제약 솔버 + S7 발행)"); + HallInfo hall = loadHallInfo(hallId); + if (hall == null || hall.dimsM() == null || hall.dimsM().size() < 2) { + throw new ApiException(ErrorCode.NOT_FOUND, "홀 규격을 찾을 수 없어 자동배치를 생성할 수 없습니다."); + } + int optionCount = Math.max(1, request.optionCount()); + double hallW = hall.dimsM().get(0); + double hallD = hall.dimsM().get(1); + + // 3안: 통로 폭·부스 규격을 달리해 서로 구분되는 후보를 결정적으로 생성. + double[][] variants = { + {3.0, 3.0, 3.0}, // A: 표준 3×3, 통로 3.0m + {3.0, 3.0, 3.5}, // B: 표준 3×3, 통로 3.5m(넓은 피난) + {6.0, 3.0, 3.0}, // C: 6×3 프리미엄 지향, 통로 3.0m + }; + List options = new ArrayList<>(); + for (int i = 0; i < optionCount; i++) { + double[] vp = variants[i % variants.length]; + char tag = (char) ('A' + i); + List booths = packBooths(vp[0], vp[1], vp[2], hallW, hallD, request, tag); + + double salesArea = booths.stream() + .mapToDouble(b -> vp[0] * vp[1]).sum(); + LayoutSummary summary = new LayoutSummary( + booths.size(), request.targetBoothCount(), round2(salesArea), + vp[2], 0, 0); + + String s7JobId = publishS7Preview(eventId, hallId, hall, tag); + options.add(new AutoLayoutOption("opt-" + tag, "배치안 " + tag, summary, booths, s7JobId)); + } + log.info("자동배치 생성: event={} hall={} options={} target={}", + eventId, hallId, options.size(), request.targetBoothCount()); + return options; + } + + /** 제약 기반 그리드 패킹 — 외곽 주통로·통로 폭·프리미엄 비율을 반영해 목표 수까지 배치. */ + private List packBooths(double boothW, double boothD, double aisle, + double hallW, double hallD, AutoLayoutRequest req, char tag) { + List booths = new ArrayList<>(); + int target = req.targetBoothCount(); + int premiumTarget = (int) Math.round(target * clamp01(req.premiumRatio())); + double stepX = boothW + aisle; + double stepY = boothD + aisle; + double usableW = hallW - 2 * PERIMETER_MARGIN_M; + double usableD = hallD - 2 * PERIMETER_MARGIN_M; + + int index = 0; + for (double y = PERIMETER_MARGIN_M; y + boothD <= PERIMETER_MARGIN_M + usableD && index < target; y += stepY) { + for (double x = PERIMETER_MARGIN_M; x + boothW <= PERIMETER_MARGIN_M + usableW && index < target; x += stepX) { + index++; + boolean premium = index <= premiumTarget; + String boothNo = String.format("%c-%03d", tag, index); + List> polygon = rect(x, y, boothW, boothD); + booths.add(new BoothDto(null, boothNo, premium ? "island" : "assembled", + polygon, List.of(boothW, boothD), DEFAULT_HEIGHT_M, DEFAULT_FLOOR_LOAD, + null, premium)); + } + } + return booths; + } + + /** S7 홀 전경(조감) 프리뷰 발행 — 렌더 인프라 장애 시에도 자동배치가 성립하도록 degraded(null) 허용. */ + private String publishS7Preview(String eventId, String hallId, HallInfo hall, char tag) { + try { + Map scene = new LinkedHashMap<>(); + Map hallScene = new LinkedHashMap<>(); + hallScene.put("id", hallId); + hallScene.put("dims_m", hall.dimsM()); + if (hall.ceilingM() != null) { + hallScene.put("ceiling_m", hall.ceilingM()); + } + scene.put("hall", hallScene); + scene.put("option", "배치안 " + tag); + RenderJobRequest req = new RenderJobRequest("S7", scene, null); + return renderJobService.publish(eventId, "HALL-" + hallId, req).jobId(); + } catch (RuntimeException e) { + log.warn("S7 프리뷰 발행 실패(degraded): event={} hall={} 사유={}", eventId, hallId, e.getMessage()); + return null; + } + } + + // ------------------------------------------------------------- 조립 유틸 ----- + + private LayoutDto assembleLayout(String eventId, String hallId, Map header) { + String layoutId = str(header.get("layoutId")); + List booths = loadBooths(layoutId); + HallInfo hall = loadHallInfo(hallId); + ComplianceReport report = evaluateLayout(layoutId, hallId, booths); + + Double salesArea = boothMapper.sumSalesArea(layoutId); + Double minAisle = boothMapper.minAisleWidth(layoutId); + LayoutSummary summary = new LayoutSummary( + booths.size(), + hall != null && hall.targetBoothCount() != null ? hall.targetBoothCount() : 0, + salesArea == null ? 0.0 : round2(salesArea), + minAisle == null ? 0.0 : round2(minAisle), + report.blockCount(), report.warnCount()); + + return new LayoutDto(layoutId, eventId, hallId, + intVal(header.get("version")), str(header.get("name")), str(header.get("status")), + booths, hall, summary, str(header.get("updatedAt"))); + } + + private List loadBooths(String layoutId) { + List> rows = boothMapper.findBooths(layoutId); + List out = new ArrayList<>(); + if (rows == null) { + return out; + } + for (Map r : rows) { + List> polygon = GeometryCodec.parsePolygonRing(objectMapper, str(r.get("polygonJson"))); + Double sw = dbl(r.get("sizeW")); + Double sd = dbl(r.get("sizeD")); + List sizeM = sw != null && sd != null ? List.of(sw, sd) : null; + out.add(new BoothDto( + str(r.get("boothId")), str(r.get("boothNo")), str(r.get("type")), + polygon, sizeM, dbl(r.get("heightM")), dbl(r.get("floorLoadTPerM2")), + str(r.get("assignedCompanyName")), Boolean.TRUE.equals(bool(r.get("premium"))))); + } + return out; + } + + private HallInfo loadHallInfo(String hallId) { + Map h = hallMapper.findHall(hallId); + if (h == null) { + return null; + } + Double w = dbl(h.get("widthM")); + Double d = dbl(h.get("depthM")); + List dims = w != null && d != null ? List.of(w, d) : null; + + List trenches = new ArrayList<>(); + List> tr = hallMapper.findTrenches(hallId); + if (tr != null) { + for (Map t : tr) { + List supplies = new ArrayList<>(); + if (Boolean.TRUE.equals(bool(t.get("supplyPower")))) supplies.add("power"); + if (Boolean.TRUE.equals(bool(t.get("supplyWater")))) supplies.add("water"); + if (Boolean.TRUE.equals(bool(t.get("supplyAir")))) supplies.add("air"); + if (Boolean.TRUE.equals(bool(t.get("supplyNetwork")))) supplies.add("network"); + if (Boolean.TRUE.equals(bool(t.get("supplyGas")))) supplies.add("gas"); + trenches.add(new HallInfo.Trench(str(t.get("trenchId")), + nz(dbl(t.get("x"))), nz(dbl(t.get("y"))), + Boolean.TRUE.equals(bool(t.get("assumed"))), supplies)); + } + } + List exits = new ArrayList<>(); + List> ex = hallMapper.findExits(hallId); + if (ex != null) { + for (Map e : ex) { + exits.add(new HallInfo.Exit(nz(dbl(e.get("x"))), nz(dbl(e.get("y"))), + nz(dbl(e.get("clearanceM"))))); + } + } + return new HallInfo(str(h.get("hallId")), str(h.get("label")), dims, dbl(h.get("heightM")), + intOrNull(h.get("boothCapacity")), dbl(h.get("floorLoadTPerM2")), + Boolean.TRUE.equals(bool(h.get("assumedTrench"))), trenches, exits); + } + + private int nextVersion(String eventId, String hallId) { + Map latest = boothMapper.findLayout(eventId, hallId, null); + if (latest == null) { + return 1; + } + return intVal(latest.get("version")) + 1; + } + + // --------------------------------------------------------- 좌표/형변환 ----- + + private static List> rect(double x, double y, double w, double d) { + return List.of(List.of(x, y), List.of(x + w, y), List.of(x + w, y + d), + List.of(x, y + d), List.of(x, y)); + } + + private static BoothDto maxBy(List booths, java.util.function.Function f) { + BoothDto best = null; + double bestVal = Double.NEGATIVE_INFINITY; + for (BoothDto b : booths) { + Double v = f.apply(b); + if (v != null && v > bestVal) { + bestVal = v; + best = b; + } + } + return best; + } + + private static double clamp01(double v) { + return v < 0 ? 0 : Math.min(v, 1); + } + + private static double round2(double v) { + return Math.round(v * 100.0) / 100.0; + } + + private static double nz(Double d) { + return d == null ? 0.0 : d; + } + + private static String str(Object o) { + return o == null ? null : String.valueOf(o); + } + + private static Double dbl(Object o) { + if (o == null) return null; + if (o instanceof Number n) return n.doubleValue(); + try { + return Double.parseDouble(String.valueOf(o)); + } catch (NumberFormatException e) { + return null; + } + } + + private static Boolean bool(Object o) { + if (o == null) return null; + if (o instanceof Boolean b) return b; + return Boolean.parseBoolean(String.valueOf(o)); + } + + private static int intVal(Object o) { + Double d = dbl(o); + return d == null ? 0 : d.intValue(); + } + + private static Integer intOrNull(Object o) { + Double d = dbl(o); + return d == null ? null : d.intValue(); } } diff --git a/src/backend/src/main/java/com/zioinfo/kintex/module/m2/dto/ApplyOptionRequest.java b/src/backend/src/main/java/com/zioinfo/kintex/module/m2/dto/ApplyOptionRequest.java new file mode 100644 index 0000000..af4648e --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/module/m2/dto/ApplyOptionRequest.java @@ -0,0 +1,20 @@ +package com.zioinfo.kintex.module.m2.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; + +import java.util.List; + +/** + * 자동배치 안 적용/병합 요청(SCR-04 "이 안으로 편집 시작" / 구역 선택 병합). + * 클라이언트가 3안 중 하나를 선택하거나 여러 안의 구역 부스를 합성한 최종 부스열을 전달하면, + * 서버가 새 배치안 버전으로 저장하고 규정 재검증을 수행한다(3안 생성→선택→병합→검증 흐름 마감). + * + * @param name 저장할 배치안 이름(예: "배치안 A(적용)") + * @param booths 적용/병합된 부스 폴리곤 목록(비어있지 않음) + */ +public record ApplyOptionRequest( + String name, + @NotEmpty @Valid List booths +) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/module/m2/dto/AutoLayoutOption.java b/src/backend/src/main/java/com/zioinfo/kintex/module/m2/dto/AutoLayoutOption.java index b304605..1e27466 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/module/m2/dto/AutoLayoutOption.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/module/m2/dto/AutoLayoutOption.java @@ -2,12 +2,15 @@ package com.zioinfo.kintex.module.m2.dto; import com.fasterxml.jackson.annotation.JsonInclude; +import java.util.List; + /** * 자동 생성 배치안 후보(SCR-04 비교 카드). * * @param optionId 후보 식별자 * @param label 라벨("배치안 A/B/C") * @param summary 지표 요약 + * @param booths 후보 부스 배열 — "이 안으로 편집 시작"/병합 소스(계약 확장, 프론트 gap #3 해소, NON_NULL) * @param s7RenderJobId S7 홀 전경(조감) 생성 잡 ID — 완료 시 WebSocket 푸시로 카드 이미지 교체 */ @JsonInclude(JsonInclude.Include.NON_NULL) @@ -15,6 +18,7 @@ public record AutoLayoutOption( String optionId, String label, LayoutSummary summary, + List booths, String s7RenderJobId ) { } diff --git a/src/backend/src/main/java/com/zioinfo/kintex/module/m2/dto/HallInfo.java b/src/backend/src/main/java/com/zioinfo/kintex/module/m2/dto/HallInfo.java new file mode 100644 index 0000000..cd7c204 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/module/m2/dto/HallInfo.java @@ -0,0 +1,55 @@ +package com.zioinfo.kintex.module.m2.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import java.util.List; + +/** + * 홀 마스터 정보 — 캔버스 축척·경계·트렌치/비상구 오버레이 원천(계약 확장, 프론트 gap #2 해소). + * {@link LayoutDto}에 additive 필드로 포함되며(NON_NULL), 이전 계약과 호환된다. + * + * @param hallId 홀 식별자 + * @param label 홀 표기(예: "제2전시장 홀7") + * @param dimsM [폭, 깊이] (미터) — 캔버스 경계·축척 + * @param ceilingM 천장 높이(미터) + * @param targetBoothCount 목표 부스 수(홀 booth_capacity) + * @param floorLoadTPerM2 홀 바닥하중 상한(t/㎡) + * @param assumedTrench 트렌치 좌표가 실측이 아닌 가정 그리드인지(PLANNING R4 배지) + * @param trenches 트렌치 포인트 목록(가정 그리드 포함) + * @param exits 비상구 포인트 목록 + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record HallInfo( + String hallId, + String label, + List dimsM, + Double ceilingM, + Integer targetBoothCount, + Double floorLoadTPerM2, + boolean assumedTrench, + List trenches, + List exits +) { + + /** + * 트렌치 포인트(홀 로컬 미터) + 공급 매트릭스. + * + * @param trenchId 트렌치 식별자 + * @param x 홀 로컬 X(미터) + * @param y 홀 로컬 Y(미터) + * @param assumed 가정 그리드 여부 + * @param supplies 공급 종류 목록(power/water/air/network/gas) + */ + public record Trench(String trenchId, double x, double y, boolean assumed, List supplies) { + } + + /** + * 비상구 포인트 + 접근 확보 버퍼. + * + * @param x 홀 로컬 X(미터) + * @param y 홀 로컬 Y(미터) + * @param clearanceM 비상구 접근 확보 버퍼(미터) + */ + public record Exit(double x, double y, double clearanceM) { + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/module/m2/dto/LayoutDto.java b/src/backend/src/main/java/com/zioinfo/kintex/module/m2/dto/LayoutDto.java index 051cc2b..75f1dbe 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/module/m2/dto/LayoutDto.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/module/m2/dto/LayoutDto.java @@ -14,6 +14,7 @@ import java.util.List; * @param name 배치안 이름(예: "배치안 B") * @param status 상태 배지(draft/submitted/ai_review/approved/rejected) — design.md StatusBadge * @param booths 부스 목록 + * @param hall 홀 마스터 정보(치수·트렌치·비상구) — 캔버스 축척·오버레이 원천(계약 확장, NON_NULL) * @param summary 지표 요약 * @param updatedAt 최종 수정 시각(ISO-8601) */ @@ -26,6 +27,7 @@ public record LayoutDto( String name, String status, List booths, + HallInfo hall, LayoutSummary summary, String updatedAt ) { diff --git a/src/backend/src/main/java/com/zioinfo/kintex/module/m2/mapper/HallMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/module/m2/mapper/HallMapper.java new file mode 100644 index 0000000..e678683 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/module/m2/mapper/HallMapper.java @@ -0,0 +1,26 @@ +package com.zioinfo.kintex.module.m2.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; +import java.util.Map; + +/** + * 홀 마스터·트렌치·비상구 조회 매퍼 — 프론트 캔버스의 홀 치수·트렌치 그리드 하드코딩 해소(계약 확장). + * 기존 스키마(hall·trench·hall_exit, V2~V5)만 조회하며 마이그레이션을 변경하지 않는다. + * + *

★backend-dev 추가 매퍼(db-engineer 계약 §3 밖) — 기존 테이블 read-only. 계약 갱신 기록: {@code _workspace}. + */ +@Mapper +public interface HallMapper { + + /** 홀 마스터 헤더(치수·천장·목표 부스 수·바닥하중·가정 트렌치 여부). 없으면 null. */ + Map findHall(@Param("hallId") String hallId); + + /** 홀 트렌치 좌표 목록(가정 그리드 포함) — 캔버스 오버레이·배선 참조용. */ + List> findTrenches(@Param("hallId") String hallId); + + /** 홀 비상구 좌표·이격 목록 — 캔버스 비상구 표시용. */ + List> findExits(@Param("hallId") String hallId); +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/module/m3/DesignServiceImpl.java b/src/backend/src/main/java/com/zioinfo/kintex/module/m3/DesignServiceImpl.java index dfc4004..b4477ce 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/module/m3/DesignServiceImpl.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/module/m3/DesignServiceImpl.java @@ -1,6 +1,9 @@ package com.zioinfo.kintex.module.m3; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import com.zioinfo.kintex.common.error.ApiException; +import com.zioinfo.kintex.common.error.ErrorCode; import com.zioinfo.kintex.module.m3.dto.DesignPlanDto; import com.zioinfo.kintex.module.m3.dto.DesignSaveRequest; import com.zioinfo.kintex.module.m3.dto.DesignSpec; @@ -8,34 +11,95 @@ import com.zioinfo.kintex.module.m3.mapper.DesignMapper; import com.zioinfo.kintex.rules.ComplianceReport; import com.zioinfo.kintex.rules.ComplianceRuleEngine; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.Map; /** - * M3 서비스. precheck()는 완성 구현(설계 스펙 → 룰 엔진 평가). 설계 영속은 매퍼 대기(501). + * M3 서비스 — DesignMapper 배선 완료. 설계 조회/저장(spec jsonb)·규정 사전검증(precheck) 수행. + * precheck는 스펙만으로 즉시 평가(무영속), 저장은 버전 upsert 후 최신본을 반환한다. */ @Service public class DesignServiceImpl implements DesignService { private final DesignMapper designMapper; private final ComplianceRuleEngine ruleEngine; + private final ObjectMapper objectMapper; - public DesignServiceImpl(DesignMapper designMapper, ComplianceRuleEngine ruleEngine) { + public DesignServiceImpl(DesignMapper designMapper, ComplianceRuleEngine ruleEngine, + ObjectMapper objectMapper) { this.designMapper = designMapper; this.ruleEngine = ruleEngine; + this.objectMapper = objectMapper; } @Override public DesignPlanDto getDesign(String boothId, Integer version) { - // TODO(kintex-db-engineer): DesignMapper.findDesign + spec(jsonb) 역직렬화. - throw ApiException.notImplemented("설계안 조회"); + Map row = designMapper.findDesign(boothId, version); + if (row == null) { + throw new ApiException(ErrorCode.NOT_FOUND, "설계안을 찾을 수 없습니다."); + } + return toDto(row); } @Override + @Transactional public DesignPlanDto saveDesign(String boothId, DesignSaveRequest request) { - // TODO(kintex-db-engineer): DesignMapper.upsertDesign(spec jsonb). 저장 후 precheck 상태 반영. - throw ApiException.notImplemented("설계 저장"); + int version = request.version() != null ? request.version() : nextVersion(boothId); + Map params = new LinkedHashMap<>(); + params.put("boothId", boothId); + params.put("version", version); + params.put("status", "draft"); + params.put("specJson", writeSpec(request.spec())); + designMapper.upsertDesign(params); + + Map row = designMapper.findDesign(boothId, version); + if (row == null) { + throw new ApiException(ErrorCode.INTERNAL, "설계안 저장 결과를 확인할 수 없습니다."); + } + return toDto(row); + } + + private int nextVersion(String boothId) { + Map latest = designMapper.findDesign(boothId, null); + if (latest == null) { + return 1; + } + Object v = latest.get("version"); + return v instanceof Number n ? n.intValue() + 1 : 1; + } + + private DesignPlanDto toDto(Map row) { + DesignSpec spec = readSpec(str(row.get("specJson"))); + Object v = row.get("version"); + return new DesignPlanDto(str(row.get("designId")), str(row.get("boothId")), + v instanceof Number n ? n.intValue() : 0, str(row.get("status")), + spec, str(row.get("updatedAt"))); + } + + private String writeSpec(DesignSpec spec) { + try { + return objectMapper.writeValueAsString(spec); + } catch (JsonProcessingException e) { + throw new ApiException(ErrorCode.VALIDATION, "설계 스펙을 직렬화할 수 없습니다."); + } + } + + private DesignSpec readSpec(String json) { + if (json == null || json.isBlank()) { + return null; + } + try { + return objectMapper.readValue(json, DesignSpec.class); + } catch (JsonProcessingException e) { + return null; + } + } + + private static String str(Object o) { + return o == null ? null : String.valueOf(o); } @Override diff --git a/src/backend/src/main/java/com/zioinfo/kintex/module/m4/UtilityServiceImpl.java b/src/backend/src/main/java/com/zioinfo/kintex/module/m4/UtilityServiceImpl.java index d95e5f3..fcc83a6 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/module/m4/UtilityServiceImpl.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/module/m4/UtilityServiceImpl.java @@ -1,11 +1,16 @@ package com.zioinfo.kintex.module.m4; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import com.zioinfo.kintex.common.error.ApiException; +import com.zioinfo.kintex.common.error.ErrorCode; +import com.zioinfo.kintex.common.geo.GeometryCodec; import com.zioinfo.kintex.module.m4.dto.UtilityOrderDto; import com.zioinfo.kintex.module.m4.dto.UtilityQuote; import com.zioinfo.kintex.module.m4.dto.UtilityQuoteRequest; import com.zioinfo.kintex.module.m4.dto.WiringRequest; import com.zioinfo.kintex.module.m4.dto.WiringResult; +import com.zioinfo.kintex.module.m4.mapper.UtilityOrderMapper; import com.zioinfo.kintex.module.m4.mapper.WiringMapper; import com.zioinfo.kintex.rules.RateRuleSet; import com.zioinfo.kintex.rules.RuleSetLoader; @@ -14,9 +19,11 @@ import org.springframework.stereotype.Service; import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.UUID; /** - * M4 서비스. quote()는 완성 구현(요율 룰셋 기반 산술). 배선/신청/위치표시도는 매퍼·서식 대기(501). + * M4 서비스 — quote(요율 룰셋)·computeWiring(WiringMapper PostGIS)·신청 영속(UtilityOrderMapper) 배선 완료. + * 위치표시도 PDF 실제 렌더는 문서 큐(M4-3, 후속)이며, 여기서는 신청을 영속하고 자산 URL·마감/릴레이 고지를 반환한다. */ @Service public class UtilityServiceImpl implements UtilityService { @@ -26,10 +33,15 @@ public class UtilityServiceImpl implements UtilityService { private final RuleSetLoader ruleSets; private final WiringMapper wiringMapper; + private final UtilityOrderMapper orderMapper; + private final ObjectMapper objectMapper; - public UtilityServiceImpl(RuleSetLoader ruleSets, WiringMapper wiringMapper) { + public UtilityServiceImpl(RuleSetLoader ruleSets, WiringMapper wiringMapper, + UtilityOrderMapper orderMapper, ObjectMapper objectMapper) { this.ruleSets = ruleSets; this.wiringMapper = wiringMapper; + this.orderMapper = orderMapper; + this.objectMapper = objectMapper; } @Override @@ -84,20 +96,122 @@ public class UtilityServiceImpl implements UtilityService { @Override public WiringResult computeWiring(String eventId, String hallId, String boothId, WiringRequest request) { - // TODO(kintex-db-engineer): WiringMapper.findNearestTrenches + shortestPath(PostGIS) 조립. - throw ApiException.notImplemented("배선 산출(트렌치 최단 경로 PostGIS)"); + Boolean assumed = wiringMapper.isAssumedTrench(hallId); + boolean assumedTrench = assumed == null || assumed; + + List paths = new ArrayList<>(); + if (request.terminals() != null) { + for (WiringRequest.Terminal t : request.terminals()) { + if (t.position() == null || t.position().size() < 2) { + continue; + } + double fromX = t.position().get(0); + double fromY = t.position().get(1); + String kind = t.kind(); + Map path = wiringMapper.shortestPath(hallId, fromX, fromY, kind); + if (path == null) { + continue; // 해당 kind 공급 트렌치 없음 + } + List> coords = GeometryCodec.parseLineCoords(objectMapper, str(path.get("lineJson"))); + double lengthM = round2(dbl(path.get("lengthM"))); + paths.add(new WiringResult.WiringPath(kind, colorFor(kind), coords, lengthM, labelFor(t))); + } + } + return new WiringResult(assumedTrench, paths); } @Override public UtilityOrderDto submitOrder(String eventId, String boothId, UtilityQuoteRequest request) { - // TODO(BE/DB): 신청 영속(UtilityOrder) + 위치표시도 PDF 생성(M4-3, 서류 큐) + 릴레이 안내. - throw ApiException.notImplemented("유틸리티 신청 제출 + 위치표시도 생성"); + UtilityQuote quote = quote(request); + String orderId = "uo-" + UUID.randomUUID(); + // 위치표시도 PDF 실제 렌더는 문서 큐(M4-3, 후속) — 자산 조회 경로만 안정적으로 제공. + String locationDiagramUrl = "/api/events/" + eventId + "/booths/" + boothId + + "/utility/location-diagram"; + String supplyTiming = "장치 마지막 날 오후"; + + Map params = new java.util.LinkedHashMap<>(); + params.put("orderId", orderId); + params.put("eventId", eventId); + params.put("boothId", boothId); + params.put("status", "submitted"); + params.put("quoteJson", writeQuote(quote)); + params.put("locationDiagramUrl", locationDiagramUrl); + params.put("supplyTiming", supplyTiming); + orderMapper.insertOrder(params); + + return new UtilityOrderDto(orderId, "submitted", quote, locationDiagramUrl, supplyTiming, + "유틸리티 신청 마감 D-25 · 인터넷은 현장 추가신청 불가", + "주최자 사무국/킨텍스에 제출 파일이 릴레이됩니다."); } @Override public UtilityOrderDto getOrder(String eventId, String boothId) { - // TODO(kintex-db-engineer): UtilityOrder 조회 매퍼. - throw ApiException.notImplemented("유틸리티 신청 조회"); + Map row = orderMapper.findOrder(eventId, boothId); + if (row == null) { + throw new ApiException(ErrorCode.NOT_FOUND, "제출된 유틸리티 신청이 없습니다."); + } + UtilityQuote quote = readQuote(str(row.get("quoteJson"))); + return new UtilityOrderDto(str(row.get("orderId")), str(row.get("status")), quote, + str(row.get("locationDiagramUrl")), str(row.get("supplyTiming")), + "유틸리티 신청 마감 D-25 · 인터넷은 현장 추가신청 불가", + "주최자 사무국/킨텍스에 제출 파일이 릴레이됩니다."); + } + + /** S6 색상 규약: power=red · network=blue · plumbing/water/air=green(design.md §1-2). */ + private static String colorFor(String kind) { + if (kind == null) return "green"; + return switch (kind) { + case "power" -> "red"; + case "network" -> "blue"; + default -> "green"; + }; + } + + private static String labelFor(WiringRequest.Terminal t) { + if ("power".equals(t.kind()) && t.kw() != null) { + return (t.kw() == Math.floor(t.kw()) ? String.valueOf(t.kw().intValue()) : String.valueOf(t.kw())) + "kW"; + } + if ("network".equals(t.kind())) { + return "유선"; + } + return t.kind(); + } + + private String writeQuote(UtilityQuote quote) { + try { + return objectMapper.writeValueAsString(quote); + } catch (JsonProcessingException e) { + throw new ApiException(ErrorCode.INTERNAL, "견적 직렬화에 실패했습니다."); + } + } + + private UtilityQuote readQuote(String json) { + if (json == null || json.isBlank()) { + return null; + } + try { + return objectMapper.readValue(json, UtilityQuote.class); + } catch (JsonProcessingException e) { + return null; + } + } + + private static String str(Object o) { + return o == null ? null : String.valueOf(o); + } + + private static double dbl(Object o) { + if (o instanceof Number n) return n.doubleValue(); + if (o == null) return 0.0; + try { + return Double.parseDouble(String.valueOf(o)); + } catch (NumberFormatException e) { + return 0.0; + } + } + + private static double round2(double v) { + return Math.round(v * 100.0) / 100.0; } // --- 요율 맵 탐색 유틸 --- diff --git a/src/backend/src/main/java/com/zioinfo/kintex/module/m4/mapper/UtilityOrderMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/module/m4/mapper/UtilityOrderMapper.java new file mode 100644 index 0000000..dd3c2c1 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/module/m4/mapper/UtilityOrderMapper.java @@ -0,0 +1,24 @@ +package com.zioinfo.kintex.module.m4.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.Map; + +/** + * 유틸리티 신청(UtilityOrder) 영속 매퍼 — 신청 제출·조회. + * 기존 스키마(utility_order, V3)만 사용하며 마이그레이션을 변경하지 않는다. + * + *

★backend-dev 추가 매퍼(db-engineer 계약 §3 밖) — 기존 테이블. 계약 갱신 기록: {@code _workspace}. + * 위치표시도 PDF 자산(location_diagram_url)의 실제 렌더는 문서 큐(M4-3, 후속) 담당. + */ +@Mapper +public interface UtilityOrderMapper { + + /** 신청 저장. params: {orderId, eventId, boothId, status, quoteJson(JSON), locationDiagramUrl, supplyTiming}. */ + void insertOrder(@Param("params") Map params); + + /** 부스 단위 최신 신청 조회. 없으면 null. */ + Map findOrder(@Param("eventId") String eventId, + @Param("boothId") String boothId); +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/module/m5/RenderJobServiceImpl.java b/src/backend/src/main/java/com/zioinfo/kintex/module/m5/RenderJobServiceImpl.java index 8570162..d86b7b4 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/module/m5/RenderJobServiceImpl.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/module/m5/RenderJobServiceImpl.java @@ -32,7 +32,6 @@ public class RenderJobServiceImpl implements RenderJobService { private static final Logger log = LoggerFactory.getLogger(RenderJobServiceImpl.class); private static final String JOB_KEY_PREFIX = "kintex:renderjob:job:"; - private static final String QUOTA_KEY_PREFIX = "kintex:renderjob:quota:"; private final StringRedisTemplate redis; private final ObjectMapper objectMapper; @@ -52,9 +51,8 @@ public class RenderJobServiceImpl implements RenderJobService { @Override public RenderJobDto publish(String eventId, String boothId, RenderJobRequest request) { - // 쿼터 확인 — 성공 건수 카운터(성공 시에만 차감되므로 발행 시엔 증가시키지 않음). - String quotaKey = QUOTA_KEY_PREFIX + eventId; - long succeeded = readLong(redis.opsForValue().get(quotaKey)); + // 쿼터 정본 = DB 성공(DONE) 건수(RenderJobMapper.countSucceededByEvent). 성공 시에만 차감(§6-5). + long succeeded = renderJobMapper.countSucceededByEvent(eventId); if (succeeded >= props.getEventQuotaDefault()) { throw new ApiException(ErrorCode.RENDER_QUOTA_EXCEEDED, "행사 이미지 생성 쿼터(" + props.getEventQuotaDefault() + ")를 소진했습니다."); @@ -83,11 +81,20 @@ public class RenderJobServiceImpl implements RenderJobService { throw new ApiException(ErrorCode.INTERNAL, "RenderJob 직렬화에 실패했습니다."); } - // TODO(kintex-db-engineer): RenderJobMapper.insertJob 으로 내구 이력 기록(갤러리·감사). RenderJobDto dto = new RenderJobDto(jobId, boothId, request.shotPreset(), RenderJobStatus.QUEUED.name(), null, null, null, true, RenderJobDto.WATERMARK_TEXT, RenderJobDto.NOTICE, null, now); saveState(dto); + + // 내구 이력 기록(갤러리·쿼터 정본). Redis 는 큐/실시간, DB 는 이력의 정본. + Map params = new LinkedHashMap<>(); + params.put("jobId", jobId); + params.put("eventId", eventId); + params.put("boothId", boothId); + params.put("shotPreset", request.shotPreset()); + params.put("status", RenderJobStatus.QUEUED.name()); + renderJobMapper.insertJob(params); + log.info("RenderJob 발행: job={} booth={} shot={}", jobId, boothId, request.shotPreset()); return dto; } @@ -103,8 +110,20 @@ public class RenderJobServiceImpl implements RenderJobService { @Override public List listByBooth(String boothId, String shotPreset) { - // TODO(kintex-db-engineer): RenderJobMapper.findByBooth 로 내구 이력 목록(SCR-12) 조립. - throw ApiException.notImplemented("시각화 갤러리 목록(내구 이력)"); + List> rows = renderJobMapper.findByBooth(boothId, shotPreset); + List out = new java.util.ArrayList<>(); + if (rows == null) { + return out; + } + for (Map r : rows) { + out.add(new RenderJobDto( + str(r.get("jobId")), str(r.get("boothId")), str(r.get("shotPreset")), + str(r.get("status")), str(r.get("imageUrl")), + str(r.get("schemaHash")), str(r.get("modelVersion")), + true, RenderJobDto.WATERMARK_TEXT, RenderJobDto.NOTICE, + str(r.get("errorMessage")), str(r.get("createdAt")))); + } + return out; } @Override @@ -125,8 +144,9 @@ public class RenderJobServiceImpl implements RenderJobService { prev.createdAt()); saveState(updated); - // 성공 시에만 쿼터 차감(PLANNING §6-5). eventId는 상태에 미보관 → 내구 저장 시 정본화(매퍼). - // TODO(kintex-db-engineer): RenderJobMapper.updateStatus + 성공 시 행사 쿼터 카운터 증가. + // 내구 상태 갱신 — 쿼터 정본은 DB의 DONE 건수(countSucceededByEvent)에서 자동 반영(별도 카운터 불필요). + renderJobMapper.updateStatus(updated.jobId(), status, updated.imageUrl(), updated.errorMessage()); + notifier.pushJobUpdate(updated); log.info("RenderJob 콜백: job={} status={}", updated.jobId(), status); return updated; @@ -153,13 +173,8 @@ public class RenderJobServiceImpl implements RenderJobService { } } - private static long readLong(String s) { - if (s == null) return 0L; - try { - return Long.parseLong(s); - } 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/rules/Violation.java b/src/backend/src/main/java/com/zioinfo/kintex/rules/Violation.java index 098d739..e5ec264 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/rules/Violation.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/rules/Violation.java @@ -2,6 +2,8 @@ package com.zioinfo.kintex.rules; import com.fasterxml.jackson.annotation.JsonInclude; +import java.util.List; + /** * 규정 위반/확인 항목 — 도면 위 번호 핀과 1:1 연결(design.md ViolationFlag). * @@ -12,6 +14,8 @@ import com.fasterxml.jackson.annotation.JsonInclude; * @param severity "block"(차단·제출 불가) | "warn"(경고·확인 후 진행) * @param measured 측정값 요약(예: "5.4m — 0.4m 초과"). 값 미제공 시 null * @param requiresDocument 부속 제출 요구(예: STRUCTURAL_CALC_D7). 없으면 null + * @param boothId 위반 부스 식별자(부스 단위 규칙만) — 캔버스 하이라이트 매핑(계약 확장, NON_NULL) + * @param anchor 도면 하이라이트 앵커 좌표 [x,y] (미터) — 핀 위치(계약 확장, NON_NULL) */ @JsonInclude(JsonInclude.Include.NON_NULL) public record Violation( @@ -21,6 +25,19 @@ public record Violation( String label, String severity, String measured, - String requiresDocument + String requiresDocument, + String boothId, + List anchor ) { + + /** 룰 엔진용 — 위치(부스/앵커) 미상 위반 생성. 서비스가 이후 {@link #withLocation}로 좌표를 채운다. */ + public Violation(int pin, String code, String group, String label, + String severity, String measured, String requiresDocument) { + this(pin, code, group, label, severity, measured, requiresDocument, null, null); + } + + /** 부스/앵커 좌표를 부여한 사본(도면 핀 매핑). */ + public Violation withLocation(String boothId, List anchor) { + return new Violation(pin, code, group, label, severity, measured, requiresDocument, boothId, anchor); + } } diff --git a/src/backend/src/main/resources/mybatis/mapper/HallMapper.xml b/src/backend/src/main/resources/mybatis/mapper/HallMapper.xml new file mode 100644 index 0000000..d5b3a21 --- /dev/null +++ b/src/backend/src/main/resources/mybatis/mapper/HallMapper.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + diff --git a/src/backend/src/main/resources/mybatis/mapper/UserMapper.xml b/src/backend/src/main/resources/mybatis/mapper/UserMapper.xml index ba260f3..f380de4 100644 --- a/src/backend/src/main/resources/mybatis/mapper/UserMapper.xml +++ b/src/backend/src/main/resources/mybatis/mapper/UserMapper.xml @@ -36,4 +36,11 @@ ORDER BY e.start_date + + + diff --git a/src/backend/src/main/resources/mybatis/mapper/UtilityOrderMapper.xml b/src/backend/src/main/resources/mybatis/mapper/UtilityOrderMapper.xml new file mode 100644 index 0000000..3510cec --- /dev/null +++ b/src/backend/src/main/resources/mybatis/mapper/UtilityOrderMapper.xml @@ -0,0 +1,34 @@ + + + + + + + + INSERT INTO utility_order (id, event_id, booth_id, status, quote, + location_diagram_url, supply_timing, created_at) + VALUES (#{params.orderId}, #{params.eventId}, #{params.boothId}, + COALESCE(#{params.status}, 'submitted'), + CAST(#{params.quoteJson} AS jsonb), + #{params.locationDiagramUrl}, #{params.supplyTiming}, now()) + + + + + + diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 9da8224..88cc687 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -2,6 +2,10 @@ import { Navigate, Route, Routes } from 'react-router-dom'; import { useAuthStore } from './store/authStore'; import { LoginPage } from './screens/login/LoginPage'; import { BoothLayoutEditorPage } from './screens/floorplan/BoothLayoutEditorPage'; +import { OrganizerDashboardPage } from './screens/dashboard/OrganizerDashboardPage'; +import { BoothDesignStudioPage } from './screens/design/BoothDesignStudioPage'; +import { UtilityWiringPage } from './screens/utility/UtilityWiringPage'; +import { VisualizationGalleryPage } from './screens/gallery/VisualizationGalleryPage'; import { AppShell } from './components/layout/AppShell'; /** 인증 가드 — 미인증 시 로그인으로. (역할별 라우팅은 화면 추가 시 확장) */ @@ -24,12 +28,23 @@ export function App() { } > + {/* SCR-02 주최자 대시보드 */} + } /> {/* SCR-03 부스 배치 에디터 (M2) */} } /> - {/* 향후: SCR-02 대시보드, SCR-05 부스홈, SCR-10 승인큐 등 */} + {/* SCR-06 부스 설계 스튜디오 (M3+M5) */} + } /> + {/* SCR-07 유틸리티 배선 뷰 (M4a+M4b) */} + } /> + {/* SCR-12 시각화 갤러리 (M5) */} + } + /> + {/* 향후: SCR-05 부스홈, SCR-10 승인큐 등 */} } /> diff --git a/src/frontend/src/api/endpoints.ts b/src/frontend/src/api/endpoints.ts index bab47be..9286ef4 100644 --- a/src/frontend/src/api/endpoints.ts +++ b/src/frontend/src/api/endpoints.ts @@ -8,12 +8,21 @@ import type { AutoLayoutOption, AutoLayoutRequest, ComplianceReport, + DesignPlanDto, + DesignSaveRequest, + DesignSpec, KintexPrincipal, LayoutDto, LayoutSaveRequest, LoginRequest, LoginResponse, RenderJobDto, + RenderJobRequest, + UtilityOrderDto, + UtilityQuote, + UtilityQuoteRequest, + WiringRequest, + WiringResult, WorkspaceDto, } from './types'; @@ -46,10 +55,57 @@ export const layoutApi = { api.post(`${layoutBase(eventId, hallId)}/auto-generate`, body), }; -// ── M5 RenderJob (SCR-12) ── +// ── M3 부스 설계 스튜디오 (SCR-06/09) ── +const designBase = (eventId: string, boothId: string) => + `/api/events/${encodeURIComponent(eventId)}/booths/${encodeURIComponent(boothId)}/design`; + +export const designApi = { + get: (eventId: string, boothId: string, version?: number) => + api.get( + `${designBase(eventId, boothId)}${version != null ? `?version=${version}` : ''}`, + ), + save: (eventId: string, boothId: string, body: DesignSaveRequest) => + api.put(designBase(eventId, boothId), body), + // precheck는 스펙을 직접 받아 즉시 룰 평가(계약 §4 — 완성 엔드포인트). + precheck: (eventId: string, boothId: string, spec: DesignSpec) => + api.post(`${designBase(eventId, boothId)}/precheck`, spec), +}; + +// ── M4 유틸리티 (SCR-07/08) ── +const utilityBase = (eventId: string, boothId: string) => + `/api/events/${encodeURIComponent(eventId)}/booths/${encodeURIComponent(boothId)}/utility`; + +export const utilityApi = { + quote: (eventId: string, boothId: string, body: UtilityQuoteRequest) => + api.post(`${utilityBase(eventId, boothId)}/quote`, body), + wiring: (eventId: string, boothId: string, hallId: string, body: WiringRequest) => + api.post( + `${utilityBase(eventId, boothId)}/wiring?hallId=${encodeURIComponent(hallId)}`, + body, + ), + order: (eventId: string, boothId: string, body: UtilityQuoteRequest) => + api.post(`${utilityBase(eventId, boothId)}/order`, body), + get: (eventId: string, boothId: string) => + api.get(utilityBase(eventId, boothId)), +}; + +// ── M5 RenderJob (SCR-06/12) ── export const renderApi = { status: (eventId: string, jobId: string) => api.get( `/api/events/${encodeURIComponent(eventId)}/render-jobs/${encodeURIComponent(jobId)}`, ), + // 갤러리 목록 (SCR-12) — shot 필터 선택. + list: (eventId: string, boothId: string, shot?: string) => + api.get( + `/api/events/${encodeURIComponent(eventId)}/booths/${encodeURIComponent(boothId)}/render-jobs${ + shot ? `?shot=${encodeURIComponent(shot)}` : '' + }`, + ), + // 발행 (SCR-06 예상 사진 생성) — 큐잉. + create: (eventId: string, boothId: string, body: RenderJobRequest) => + api.post( + `/api/events/${encodeURIComponent(eventId)}/booths/${encodeURIComponent(boothId)}/render`, + body, + ), }; diff --git a/src/frontend/src/api/types.ts b/src/frontend/src/api/types.ts index 49559b9..69607cd 100644 --- a/src/frontend/src/api/types.ts +++ b/src/frontend/src/api/types.ts @@ -147,8 +147,119 @@ export interface AutoLayoutOption { s7RenderJobId: string; } +// ── 4. M3 부스 설계 스튜디오 (SCR-06/09) ── +export interface DesignZone { + type: string; // demo | consult | reception | storage … + ratioPercent: number; +} +export interface DesignRigging { + use: boolean; + heightM: number; +} +export interface DesignMaterial { + part: string; // wall | floor | ceiling … + finish: string; + fireRetardant: boolean; +} +export interface DesignSpec { + boothType: string; + industry: string; + budget: number; + zones: DesignZone[]; + wallHeightM: number; + signageText: string; + rigging: DesignRigging; + mezzanineAreaRatio: number; + materials: DesignMaterial[]; + lightingMode: string; // day | night + usesDesignatedLightingOnly: boolean; +} +export interface DesignPlanDto { + designId: string; + boothId: string; + version: number; + status: string; + spec: DesignSpec; + updatedAt: string; +} +export interface DesignSaveRequest { + version: number; + spec: DesignSpec; +} + +// ── 5. M4 유틸리티 (SCR-07/08) ── +export interface UtilityDevice { + name: string; + count: number; + powerKw: number; +} +export interface UtilityQuoteRequest { + devices: UtilityDevice[]; + networkWiredLines: number; + plumbingOutlets: number; + compressedAirOutlets: number; +} +export interface QuoteLine { + label: string; + qty: number; + unitPrice: number; + amount: number; +} +export interface UtilityQuote { + totalPowerKw: number; + requestedKw: number; + distributionBox50A: number; + lines: QuoteLine[]; + total: number; + currency: string; + rulesetVersion: string; + disclaimer: string; +} +export type WiringKind = 'power' | 'network' | 'plumbing' | 'air'; +export interface WiringTerminal { + kind: WiringKind | string; + position: [number, number]; // 홀 로컬 좌표(미터) + kw?: number; +} +export interface WiringRequest { + terminals: WiringTerminal[]; +} +export interface WiringPath { + kind: WiringKind | string; + color: 'red' | 'blue' | 'green' | string; + coords: number[][]; // [[x,y],…] 홀 로컬 좌표(미터) + lengthM: number; + label: string; +} +export interface WiringResult { + assumedTrench: boolean; + paths: WiringPath[]; +} +export interface UtilityOrderDto { + orderId: string; + status: string; + quote: UtilityQuote; + locationDiagramUrl: string; + supplyTiming: string; + deadlineNotice: string; + relayNotice: string; +} + // ── 6. M5 RenderJob (SCR-06/12) ── export type RenderStatus = 'QUEUED' | 'RUNNING' | 'DONE' | 'FAILED'; + +// RenderJob 발행 요청 scene (계약 §6 — PLANNING §6-2 스키마) +export interface RenderScene { + hall: { id: string; dims_m: [number, number]; ceiling_m: number }; + booth: { id: string; size_m: [number, number]; type: string }; + design?: { signage?: { text?: string } }; + lighting?: { mode: string }; +} +export interface RenderJobRequest { + shotPreset: string; + scene: RenderScene; + referenceImageUrl?: string; +} export interface RenderJobDto { jobId: string; boothId: string; diff --git a/src/frontend/src/components/ui/CompareSlider.tsx b/src/frontend/src/components/ui/CompareSlider.tsx new file mode 100644 index 0000000..b21a7de --- /dev/null +++ b/src/frontend/src/components/ui/CompareSlider.tsx @@ -0,0 +1,133 @@ +import { useCallback, useRef, useState } from 'react'; +import type { KeyboardEvent, PointerEvent } from 'react'; +import './compare-slider.css'; + +/* + * Before/After 비교 슬라이더 (design.md S5 — 시공 전 빈 홀 ↔ 시공 후 렌더). + * 근거: docs/analysis/reroomai-source.md §5(C) — reroomai `CompareSlider` 패턴 이식. + * - clip-path: inset(0 {100-pos}% 0 0) 로 After 레이어를 클립 → 리사이즈에도 픽셀 정렬 유지. + * - 포인터 캡처(setPointerCapture)로 프레임 밖 드래그도 추적. + * - 키보드(방향키 ±1, Shift 큰 스텝 ±10, Home/End) + ARIA slider 접근성. + * ★ After가 AI 생성 이미지이면 워터마크·고지는 상위(AiImage/afterOverlay)에서 상시 노출. + */ +interface CompareSliderProps { + beforeSrc: string; + afterSrc: string; + beforeAlt?: string; + afterAlt?: string; + beforeLabel?: string; + afterLabel?: string; + /** After 레이어 위에 상시 겹칠 노드(워터마크·AI 라벨 등). */ + afterOverlay?: React.ReactNode; + /** 초기 위치(0~100). 기본 50. */ + initial?: number; +} + +export function CompareSlider({ + beforeSrc, + afterSrc, + beforeAlt = '시공 전', + afterAlt = '시공 후 (AI 생성 예상 이미지)', + beforeLabel = '시공 전', + afterLabel = '시공 후', + afterOverlay, + initial = 50, +}: CompareSliderProps) { + const [pos, setPos] = useState(() => Math.min(100, Math.max(0, initial))); + const frameRef = useRef(null); + const draggingRef = useRef(false); + + const updateFromClientX = useCallback((clientX: number) => { + const frame = frameRef.current; + if (!frame) return; + const rect = frame.getBoundingClientRect(); + const ratio = (clientX - rect.left) / rect.width; + setPos(Math.min(100, Math.max(0, ratio * 100))); + }, []); + + const onPointerDown = useCallback( + (e: PointerEvent) => { + draggingRef.current = true; + e.currentTarget.setPointerCapture(e.pointerId); + updateFromClientX(e.clientX); + }, + [updateFromClientX], + ); + + const onPointerMove = useCallback( + (e: PointerEvent) => { + if (!draggingRef.current) return; + updateFromClientX(e.clientX); + }, + [updateFromClientX], + ); + + const onPointerUp = useCallback((e: PointerEvent) => { + draggingRef.current = false; + if (e.currentTarget.hasPointerCapture(e.pointerId)) { + e.currentTarget.releasePointerCapture(e.pointerId); + } + }, []); + + const onKeyDown = useCallback((e: KeyboardEvent) => { + const big = e.shiftKey ? 10 : 1; + if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { + e.preventDefault(); + setPos((p) => Math.max(0, p - big)); + } else if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { + e.preventDefault(); + setPos((p) => Math.min(100, p + big)); + } else if (e.key === 'Home') { + e.preventDefault(); + setPos(0); + } else if (e.key === 'End') { + e.preventDefault(); + setPos(100); + } + }, []); + + return ( +

+ {/* Before (하단, 전체) */} + {beforeAlt} + + {/* After (상단, 좌측부터 pos% 만큼 노출) */} +
+ {afterAlt} + {afterOverlay} +
+ + + + + {/* 핸들 = ARIA slider */} + +
+ ); +} diff --git a/src/frontend/src/components/ui/compare-slider.css b/src/frontend/src/components/ui/compare-slider.css new file mode 100644 index 0000000..3108cd5 --- /dev/null +++ b/src/frontend/src/components/ui/compare-slider.css @@ -0,0 +1,78 @@ +.kx-compare { + position: relative; + width: 100%; + aspect-ratio: 16 / 9; + overflow: hidden; + border: var(--border-card); + border-radius: var(--radius-lg); + background: var(--color-canvas-bg); + user-select: none; + touch-action: none; +} +.kx-compare__img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + display: block; + pointer-events: none; +} +.kx-compare__after { + position: absolute; + inset: 0; + will-change: clip-path; +} +.kx-compare__label { + position: absolute; + top: var(--space-3); + padding: 2px 10px; + border-radius: var(--radius-pill); + font-size: var(--fs-caption); + font-weight: 600; + color: var(--color-white); + background: rgba(16, 24, 40, 0.72); + pointer-events: none; +} +.kx-compare__label--before { + left: var(--space-3); +} +.kx-compare__label--after { + right: var(--space-3); +} +.kx-compare__handle { + position: absolute; + top: 0; + bottom: 0; + width: 2px; + margin-left: -1px; + padding: 0; + border: none; + background: var(--color-white); + box-shadow: 0 0 0 1px rgba(16, 24, 40, 0.3); + cursor: ew-resize; + display: flex; + align-items: center; + justify-content: center; +} +.kx-compare__handle-grip { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--color-white); + color: var(--color-primary-700); + font-size: 14px; + font-weight: 700; + letter-spacing: -1px; + box-shadow: var(--shadow-level2); +} +.kx-compare__handle:focus-visible { + outline: none; +} +.kx-compare__handle:focus-visible .kx-compare__handle-grip { + outline: 2px solid var(--color-primary-600); + outline-offset: 2px; +} diff --git a/src/frontend/src/screens/dashboard/OrganizerDashboardPage.tsx b/src/frontend/src/screens/dashboard/OrganizerDashboardPage.tsx new file mode 100644 index 0000000..ba24129 --- /dev/null +++ b/src/frontend/src/screens/dashboard/OrganizerDashboardPage.tsx @@ -0,0 +1,167 @@ +import { useParams } from 'react-router-dom'; +import { useAuthStore } from '../../store/authStore'; +import { StatusBadge, AiLabel } from '../../components/ui/Badge'; +import { AiImage } from '../../components/ui/AiImage'; +import { + SAMPLE_EXHIBITORS, + SAMPLE_FEED, + SAMPLE_KPIS, + SAMPLE_MILESTONES, + type Milestone, +} from './sampleDashboard'; +import './dashboard.css'; + +/* + * SCR-02 주최자 대시보드. Stitch organizer_dashboard 이식. + * - 히어로: D-데이 마일스톤 타임라인 + KPI 4개 + 참가업체 테이블 + 알림 피드 + S7 미니 카드. + * ★ 경계면 이슈: 계약에 대시보드 집계 엔드포인트 부재 → 마일스톤/KPI/테이블/피드는 샘플 시연. + * 행사 헤더(행사명·기간·홀·D-데이)는 인증 워크스페이스 실데이터. + */ +export function OrganizerDashboardPage() { + useParams(); // eventId 스코프(라우팅 확인용) + const workspace = useAuthStore((s) => s.currentWorkspace()); + + const eventName = workspace?.eventName ?? '2026 스마트팩토리 코리아'; + const hallLabel = workspace?.hallLabel ?? '제2전시장 홀7'; + const period = + workspace && workspace.startDate + ? `${workspace.startDate} ~ ${workspace.endDate}` + : '2026.08.11 ~ 08.14'; + const dday = workspace?.dday ?? 31; + + return ( +
+ {/* 행사 헤더 */} +
+
+

{eventName}

+

+ 킨텍스 {hallLabel} · {period} +

+
+ 개장 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}} +
+ ))} +
+ + {/* 하단: 참가업체 테이블(2/3) + 알림·S7(1/3) */} +
+
+
+

참가업체 현황

+ 집계 API 대기 (샘플) +
+ + + + + + + + + + + + {SAMPLE_EXHIBITORS.map((r) => ( + + + + + + + + ))} + +
부스번호업체명설계 상태유틸리티예상 사진
{r.boothNo}{r.company} + + + + +
+
+ + +
+
+ ); +} + +function stateLabel(m: Milestone): string { + switch (m.state) { + case 'done': + return '완료'; + case 'current': + return '진행 중'; + case 'imminent': + return '임박'; + default: + return '예정'; + } +} diff --git a/src/frontend/src/screens/dashboard/dashboard.css b/src/frontend/src/screens/dashboard/dashboard.css new file mode 100644 index 0000000..b9c49ea --- /dev/null +++ b/src/frontend/src/screens/dashboard/dashboard.css @@ -0,0 +1,311 @@ +.kx-dash { + display: flex; + flex-direction: column; + gap: var(--space-5); + padding: var(--space-5); +} + +/* 행사 헤더 */ +.kx-dash__event { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + flex-wrap: wrap; +} +.kx-dash__event-name { + font-size: var(--fs-h1); + line-height: var(--lh-h1); + color: var(--color-neutral-900); +} +.kx-dash__event-meta { + font-size: var(--fs-body); + color: var(--color-neutral-500); + margin-top: 2px; +} +.kx-dash__countdown { + padding: 8px 18px; + border-radius: var(--radius-pill); + background: var(--color-primary-600); + color: var(--color-white); + font-size: var(--fs-h2); + font-weight: 700; +} + +/* 히어로 타임라인 */ +.kx-dash__hero { + padding: var(--space-5) var(--space-4); + border: var(--border-card); + border-radius: var(--radius-lg); + background: var(--color-white); + overflow-x: auto; +} +.kx-timeline { + display: grid; + grid-auto-flow: column; + grid-auto-columns: 1fr; + list-style: none; + margin: 0; + padding: 0; + min-width: 720px; +} +.kx-timeline__node { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-2); + padding: 0 var(--space-2); +} +.kx-timeline__bar { + position: absolute; + top: 11px; + left: 50%; + width: 100%; + height: 3px; + background: var(--color-neutral-200); +} +.kx-timeline__node.is-done .kx-timeline__bar { + background: var(--color-success); +} +.kx-timeline__dot { + position: relative; + z-index: 1; + width: 24px; + height: 24px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + font-weight: 700; + color: var(--color-white); + background: var(--color-neutral-200); +} +.kx-timeline__node.is-done .kx-timeline__dot { + background: var(--color-success); +} +.kx-timeline__node.is-current .kx-timeline__dot { + background: var(--color-primary-600); +} +.kx-timeline__node.is-imminent .kx-timeline__dot { + background: var(--color-violation-warn); + animation: kx-pulse 1.6s ease-in-out infinite; +} +@keyframes kx-pulse { + 0%, + 100% { + box-shadow: 0 0 0 0 rgba(247, 144, 9, 0.5); + } + 50% { + box-shadow: 0 0 0 6px rgba(247, 144, 9, 0); + } +} +.kx-timeline__label { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} +.kx-timeline__d { + font-size: var(--fs-caption); + color: var(--color-neutral-500); +} +.kx-timeline__title { + font-size: var(--fs-body); + font-weight: 600; + color: var(--color-neutral-900); +} +.kx-timeline__card { + font-size: var(--fs-caption); + text-align: center; +} +.kx-timeline__imminent { + color: var(--color-violation-warn-text); + font-weight: 700; +} +.kx-timeline__muted { + color: var(--color-neutral-500); +} + +/* KPI */ +.kx-dash__kpis { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: var(--space-4); +} +.kx-kpi { + display: flex; + flex-direction: column; + gap: var(--space-1); + padding: var(--space-4); + border: var(--border-card); + border-radius: var(--radius-lg); + background: var(--color-white); +} +.kx-kpi__label { + font-size: var(--fs-caption); + color: var(--color-neutral-500); +} +.kx-kpi__value { + font-size: var(--fs-display); + line-height: var(--lh-display); + 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); +} +.kx-kpi--warn .kx-kpi__sub { + color: var(--color-violation-warn-text); + font-weight: 600; +} +.kx-kpi--error .kx-kpi__value { + color: var(--color-error); +} + +/* 하단 split */ +.kx-dash__split { + display: grid; + grid-template-columns: 2fr 1fr; + gap: var(--space-4); + align-items: start; +} +.kx-dash__table-wrap { + border: var(--border-card); + border-radius: var(--radius-lg); + background: var(--color-white); + overflow: hidden; +} +.kx-dash__section-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-4); + border-bottom: var(--border-card); +} +.kx-dash__section-head h2 { + font-size: var(--fs-h3); + color: var(--color-neutral-900); +} +.kx-dash__degraded { + font-size: 11px; + color: var(--color-warning); + background: #fff4e5; + padding: 2px 8px; + border-radius: var(--radius-pill); +} + +/* 테이블 */ +.kx-table { + width: 100%; + border-collapse: collapse; + font-size: var(--fs-body); +} +.kx-table th { + text-align: left; + padding: var(--space-2) var(--space-4); + font-size: var(--fs-caption); + color: var(--color-neutral-500); + background: var(--color-neutral-050); +} +.kx-table td { + padding: var(--space-2) var(--space-4); + height: var(--row-h); + border-top: 1px solid var(--color-neutral-100); + color: var(--color-neutral-700); +} +.kx-table tbody tr:hover { + background: var(--row-hover); +} +.kx-table__thumb { + display: inline-block; + width: 44px; + height: 30px; + border-radius: var(--radius-sm); + background: linear-gradient(120deg, var(--color-neutral-100), var(--color-neutral-200)); +} + +/* aside */ +.kx-dash__aside { + display: flex; + flex-direction: column; + gap: var(--space-4); +} +.kx-feed, +.kx-dash__s7 { + border: var(--border-card); + border-radius: var(--radius-lg); + background: var(--color-white); + padding: var(--space-4); +} +.kx-feed__title, +.kx-dash__s7 h2 { + font-size: var(--fs-h3); + color: var(--color-neutral-900); + margin-bottom: var(--space-3); +} +.kx-feed__list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--space-3); +} +.kx-feed__item { + display: grid; + grid-template-columns: 8px 1fr auto; + gap: var(--space-2); + align-items: baseline; + font-size: var(--fs-body); +} +.kx-feed__dot { + width: 8px; + height: 8px; + border-radius: 50%; + margin-top: 6px; + background: var(--color-neutral-200); +} +.kx-feed__item.is-approved .kx-feed__dot { + background: var(--color-success); +} +.kx-feed__item.is-rejected .kx-feed__dot { + background: var(--color-error); +} +.kx-feed__item.is-deadline .kx-feed__dot { + background: var(--color-violation-warn); +} +.kx-feed__item.is-submitted .kx-feed__dot { + background: var(--color-primary-600); +} +.kx-feed__text { + color: var(--color-neutral-700); +} +.kx-feed__time { + font-size: var(--fs-caption); + color: var(--color-neutral-500); + white-space: nowrap; +} +.kx-dash__s7-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-3); +} +.kx-dash__s7-head h2 { + margin-bottom: 0; +} + +@media (max-width: 1100px) { + .kx-dash__kpis { + grid-template-columns: repeat(2, 1fr); + } + .kx-dash__split { + grid-template-columns: 1fr; + } +} diff --git a/src/frontend/src/screens/dashboard/sampleDashboard.ts b/src/frontend/src/screens/dashboard/sampleDashboard.ts new file mode 100644 index 0000000..abbfdcd --- /dev/null +++ b/src/frontend/src/screens/dashboard/sampleDashboard.ts @@ -0,0 +1,65 @@ +/* + * 주최자 대시보드 표시 데이터 (SCR-02). + * ★ 경계면 이슈: 계약(01_backend_contracts.md)에 대시보드 집계 엔드포인트가 없다. + * 마일스톤·KPI·참가업체·알림 피드는 아래 샘플로 시연 — 실 API 계약 확정 시 대체. + * (workspace.dday·eventName 등 인증 응답 필드는 실데이터를 사용한다.) + */ +import type { FlowStatus } from '../../components/ui/Badge'; + +export interface Milestone { + key: string; + dLabel: string; + title: string; + state: 'done' | 'current' | 'upcoming' | 'imminent'; + progress?: string; // "3/7 완료" +} +export interface Kpi { + key: string; + label: string; + value: string; + sub?: string; + tone?: 'normal' | 'warn' | 'error'; +} +export interface ExhibitorRow { + boothNo: string; + company: string; + designStatus: FlowStatus; + utilityStatus: FlowStatus; +} +export interface FeedItem { + key: string; + kind: 'approved' | 'rejected' | 'deadline' | 'submitted'; + text: string; + time: string; +} + +export const SAMPLE_MILESTONES: Milestone[] = [ + { key: 'm150', dLabel: 'D-150', title: '홀 배정', state: 'done' }, + { key: 'm30', dLabel: 'D-30', title: '사전업무협의', state: 'done' }, + { key: 'm25', dLabel: 'D-25', title: '유틸리티 신청 마감', state: 'imminent', progress: 'D-6 남음' }, + { key: 'm7', dLabel: 'D-7', title: '신고서류 제출', state: 'upcoming', progress: '신고서류 3/7 완료' }, + { key: 'm0', dLabel: 'D-0', title: '개장', state: 'upcoming' }, +]; + +export const SAMPLE_KPIS: Kpi[] = [ + { key: 'apply', label: '참가업체 신청', value: '128/160', sub: '80%' }, + { key: 'booth', label: '부스 배치 확정률', value: '92%' }, + { key: 'util', label: '유틸리티 신청 완료', value: '74%', sub: 'D-25 임박', tone: 'warn' }, + { key: 'viol', label: '규정 위반 미해결', value: '5건', tone: 'error' }, +]; + +export const SAMPLE_EXHIBITORS: ExhibitorRow[] = [ + { boothNo: 'A-102', company: '(주)한빛로보틱스', designStatus: 'approved', utilityStatus: 'submitted' }, + { boothNo: 'A-104', company: '대성정밀', designStatus: 'ai-review', utilityStatus: 'draft' }, + { boothNo: 'B-201', company: '누리테크놀로지', designStatus: 'submitted', utilityStatus: 'approved' }, + { boothNo: 'B-205', company: '세종머신', designStatus: 'rejected', utilityStatus: 'draft' }, + { boothNo: 'C-110', company: '한울오토메이션', designStatus: 'building', utilityStatus: 'submitted' }, + { boothNo: 'C-118', company: '이엔지솔루션', designStatus: 'draft', utilityStatus: 'draft' }, +]; + +export const SAMPLE_FEED: FeedItem[] = [ + { key: 'f1', kind: 'approved', text: 'A-102 한빛로보틱스 설계 승인', time: '10분 전' }, + { key: 'f2', kind: 'deadline', text: '유틸리티 신청 마감 D-6 임박', time: '1시간 전' }, + { key: 'f3', kind: 'rejected', text: 'B-205 세종머신 설계 반려 (방염 자재 미확인)', time: '3시간 전' }, + { key: 'f4', kind: 'submitted', text: 'C-110 한울오토메이션 유틸리티 신청 제출', time: '어제' }, +]; diff --git a/src/frontend/src/screens/design/BoothDesignStudioPage.tsx b/src/frontend/src/screens/design/BoothDesignStudioPage.tsx new file mode 100644 index 0000000..dd76dc1 --- /dev/null +++ b/src/frontend/src/screens/design/BoothDesignStudioPage.tsx @@ -0,0 +1,507 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { ApiRequestError } from '../../api/client'; +import { designApi, renderApi } from '../../api/endpoints'; +import { subscribeRenderJob } from '../../api/websocket'; +import { Button } from '../../components/ui/Button'; +import { AiLabel } from '../../components/ui/Badge'; +import { AiImage } from '../../components/ui/AiImage'; +import { CompareSlider } from '../../components/ui/CompareSlider'; +import type { ComplianceReport, DesignSpec, RenderJobDto, RenderJobRequest } from '../../api/types'; +import { AFTER_PLACEHOLDER, BEFORE_EMPTY_HALL } from './placeholders'; +import './studio.css'; + +/* + * SCR-06 부스 설계 스튜디오 (M3 스펙 + M5 렌더). Stitch booth_design_studio 이식. + * - 좌: 스펙 아코디언 폼 → precheck(완성 API)로 사전검증. 중앙: 4샷 그리드(S1~S4) + Before/After(S5). + * - "예상 사진 생성" → render POST(완성 큐잉) → WebSocket /topic/render/{jobId} 로 타일 교체. + */ + +const SHOTS = [ + { preset: 'S1', label: 'S1 부스 정면' }, + { preset: 'S2', label: 'S2 야간 점등' }, + { preset: 'S3', label: 'S3 통로 뷰' }, + { preset: 'S4', label: 'S4 부스 내부' }, +] as const; + +const DEFAULT_SPEC: DesignSpec = { + boothType: 'independent', + industry: '로봇/제조', + budget: 30000000, + zones: [ + { type: 'demo', ratioPercent: 40 }, + { type: 'consult', ratioPercent: 30 }, + { type: 'reception', ratioPercent: 20 }, + { type: 'storage', ratioPercent: 10 }, + ], + wallHeightM: 4.2, + signageText: '한빛로보틱스', + rigging: { use: true, heightM: 7.0 }, + mezzanineAreaRatio: 0.3, + materials: [{ part: 'wall', finish: 'matte_white', fireRetardant: true }], + lightingMode: 'night', + usesDesignatedLightingOnly: true, +}; + +type Section = 'basic' | 'zones' | 'structure' | 'material' | 'lighting'; + +export function BoothDesignStudioPage() { + const { eventId = '', boothId = '' } = useParams(); + + const [spec, setSpec] = useState(DEFAULT_SPEC); + const [open, setOpen] = useState>({ + basic: true, + zones: false, + structure: true, + material: false, + lighting: false, + }); + const [report, setReport] = useState(null); + const [prechecking, setPrechecking] = useState(false); + const [jobs, setJobs] = useState>({}); // preset → job + const [generating, setGenerating] = useState(false); + const [confirmOpen, setConfirmOpen] = useState(false); + const [confirmAgree, setConfirmAgree] = useState(false); + const [notice, setNotice] = useState(null); + + // 초기 설계안 로드(501이면 degraded 기본 스펙 유지). + useEffect(() => { + let alive = true; + designApi + .get(eventId, boothId) + .then((d) => alive && setSpec(d.spec)) + .catch(() => { + /* 501/미존재 → 기본 스펙 시연 */ + }); + return () => { + alive = false; + }; + }, [eventId, boothId]); + + // 발행된 렌더잡 WebSocket 구독. + useEffect(() => { + const ids = Object.values(jobs) + .map((j) => j.jobId) + .filter(Boolean); + if (ids.length === 0) return; + const unsubs = ids.map((id) => + subscribeRenderJob(id, (job) => { + setJobs((prev) => ({ ...prev, [job.shotPreset]: job })); + }), + ); + return () => unsubs.forEach((u) => u()); + }, [jobs]); + + const runPrecheck = useCallback(async () => { + setPrechecking(true); + setNotice(null); + try { + const r = await designApi.precheck(eventId, boothId, spec); + setReport(r); + } catch (err) { + setNotice( + err instanceof ApiRequestError ? err.message : '사전 검증 중 오류가 발생했습니다.', + ); + } finally { + setPrechecking(false); + } + }, [eventId, boothId, spec]); + + const buildScene = useCallback( + (): RenderJobRequest['scene'] => ({ + hall: { id: 'H7', dims_m: [126, 90], ceiling_m: 12 }, + booth: { id: boothId || 'A-102', size_m: [6, 3], type: spec.boothType }, + design: { signage: { text: spec.signageText } }, + lighting: { mode: spec.lightingMode }, + }), + [boothId, spec.boothType, spec.signageText, spec.lightingMode], + ); + + const generateShots = useCallback(async () => { + setGenerating(true); + setNotice(null); + const scene = buildScene(); + // S1·S2를 우선 발행(S3·S4는 온디맨드 타일에서 개별 생성). + for (const preset of ['S1', 'S2'] as const) { + try { + const job = await renderApi.create(eventId, boothId, { shotPreset: preset, scene }); + setJobs((prev) => ({ ...prev, [preset]: job })); + } catch (err) { + if (err instanceof ApiRequestError && err.code === 'RENDER_QUOTA_EXCEEDED') { + setNotice('행사 이미지 생성 쿼터가 소진되었습니다.'); + break; + } + if (err instanceof ApiRequestError && err.code === 'NOT_IMPLEMENTED') { + setNotice('렌더 이력 저장이 준비 중입니다(큐잉만 동작).'); + } else { + setNotice(err instanceof ApiRequestError ? err.message : '예상 사진 생성 중 오류.'); + } + } + } + setGenerating(false); + // 병행: precheck 갱신 + void runPrecheck(); + }, [eventId, boothId, buildScene, runPrecheck]); + + const generateSingle = useCallback( + async (preset: string) => { + setNotice(null); + try { + const job = await renderApi.create(eventId, boothId, { + shotPreset: preset, + scene: buildScene(), + }); + setJobs((prev) => ({ ...prev, [preset]: job })); + } catch (err) { + setNotice(err instanceof ApiRequestError ? err.message : '생성 중 오류가 발생했습니다.'); + } + }, + [eventId, boothId, buildScene], + ); + + const s1 = jobs.S1; + const afterSrc = s1?.status === 'DONE' && s1.imageUrl ? s1.imageUrl : null; + + const passCount = report?.passCount ?? 0; + const warnCount = report?.warnCount ?? 0; + const blockCount = report?.blockCount ?? 0; + const submittable = report?.submittable ?? false; + + return ( +
+ {/* 상단 컨텍스트 바 */} +
+
+

부스 {boothId || 'A-102'} · 독립부스 6×3m

+ 높이 제한 5m · 설계 v2 +
+ +
+ +
+ {/* 좌: 스펙 아코디언 */} + + + {/* 중앙: 4샷 그리드 + Before/After */} +
+
+ {SHOTS.map((shot) => { + const job = jobs[shot.preset]; + return ( +
+ {job ? ( + generateSingle(shot.preset)} + alt={`${shot.label} 예상 이미지`} + /> + ) : ( +
+ {shot.label} + +
+ )} +
+ ); + })} +
+ + {/* Before/After 슬라이더 (S5) */} +
+
+ S5 Before / After + {!afterSrc && S1 생성 후 활성화} +
+ + + + AI 생성 + + + ) : undefined + } + /> +

+ {s1?.notice ?? + 'AI 생성 이미지는 계약·심사 서류에 사용할 수 없습니다 — 시공 기준은 도면입니다'} +

+
+
+
+ + {/* 하단 고정 바: 규정 사전검증 요약 + 컨펌 CTA */} +
+
+ + {report ? ( +
+ 통과 {passCount} + {warnCount > 0 && 경고 {warnCount}} + {blockCount > 0 && 차단 {blockCount}} + {report.violations.some((v) => v.requiresDocument) && ( + 구조계산서 D-7 필요 + )} +
+ ) : ( + 사전검증을 실행하세요 + )} +
+ +
+ + {notice && ( +

+ {notice} +

+ )} + + {/* 컨펌 모달 — "시공 기준은 도면입니다" 동의 필수 */} + {confirmOpen && ( +
+
setConfirmOpen(false)} /> +
+

컨펌 요청

+

+ 장치업체에 이 설계안의 컨펌을 요청합니다. AI 예상 사진은 참고용이며 실제 시공 기준은 + 도면입니다. +

+ +
+ + +
+
+
+ )} +
+ ); +} + +const ZONE_LABEL: Record = { + demo: '시연존', + consult: '상담존', + reception: '안내데스크', + storage: '창고', +}; + +function Accordion({ + title, + open, + onToggle, + children, +}: { + title: string; + open: boolean; + onToggle: () => void; + children: React.ReactNode; +}) { + return ( +
+ + {open &&
{children}
} +
+ ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( + + ); +} diff --git a/src/frontend/src/screens/design/placeholders.ts b/src/frontend/src/screens/design/placeholders.ts new file mode 100644 index 0000000..a0133d8 --- /dev/null +++ b/src/frontend/src/screens/design/placeholders.ts @@ -0,0 +1,28 @@ +/* + * Before/After 슬라이더용 로컬 플레이스홀더(백엔드 이미지 부재 시 시연). + * ★ 실이미지 아님 — S1 RenderJob DONE 시 after는 실 imageUrl로 대체. + * design.md 토큰(canvas-bg 등)과 톤 일치하는 인라인 SVG data URI. + */ +function svgDataUri(svg: string): string { + return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`; +} + +/** 시공 전 — 빈 홀 콘크리트 바닥(격자). */ +export const BEFORE_EMPTY_HALL = svgDataUri( + ` + + + ${Array.from({ length: 12 }, (_, i) => ``).join('')} + ${Array.from({ length: 7 }, (_, i) => ``).join('')} + + 시공 전 · 빈 홀 바닥 + `, +); + +/** 시공 후 자리 표시(S1 미생성). */ +export const AFTER_PLACEHOLDER = svgDataUri( + ` + + 예상 사진 생성 후 표시 + `, +); diff --git a/src/frontend/src/screens/design/studio.css b/src/frontend/src/screens/design/studio.css new file mode 100644 index 0000000..35fa975 --- /dev/null +++ b/src/frontend/src/screens/design/studio.css @@ -0,0 +1,301 @@ +.kx-studio { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + +/* 상단 */ +.kx-studio__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + padding: var(--space-4) var(--space-5); + border-bottom: var(--border-card); + background: var(--color-white); +} +.kx-studio__ctx h2 { + font-size: var(--fs-h2); + color: var(--color-neutral-900); +} +.kx-studio__ctx-sub { + font-size: var(--fs-caption); + color: var(--color-neutral-500); +} + +.kx-studio__body { + display: grid; + grid-template-columns: 360px 1fr; + gap: var(--space-4); + padding: var(--space-4) var(--space-5); + flex: 1; + min-height: 0; + overflow: hidden; +} + +/* 좌: 스펙 아코디언 */ +.kx-studio__spec { + display: flex; + flex-direction: column; + gap: var(--space-3); + overflow-y: auto; + padding-right: var(--space-1); +} +.kx-acc { + border: var(--border-card); + border-radius: var(--radius-lg); + background: var(--color-white); + overflow: hidden; +} +.kx-acc__head { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-3) var(--space-4); + border: none; + background: transparent; + font-size: var(--fs-h3); + font-weight: 600; + color: var(--color-neutral-900); +} +.kx-acc__chevron { + color: var(--color-neutral-500); +} +.kx-acc__body { + display: flex; + flex-direction: column; + gap: var(--space-3); + padding: 0 var(--space-4) var(--space-4); +} +.kx-field-block { + display: flex; + flex-direction: column; + gap: var(--space-1); +} +.kx-field-block__label { + font-size: var(--fs-caption); + color: var(--color-neutral-500); +} +.kx-field-block input[type='text'], +.kx-field-block input[type='number'], +.kx-field-block input:not([type]), +.kx-field-block select { + padding: 8px 10px; + border: 1px solid var(--color-neutral-200); + border-radius: var(--radius-sm); + font-size: var(--fs-body); + width: 100%; +} +.kx-studio__ratio { + display: flex; + align-items: center; + gap: var(--space-3); +} +.kx-studio__ratio input[type='range'] { + flex: 1; +} +.kx-studio__ratio span { + width: 42px; + text-align: right; + color: var(--color-neutral-700); +} +.kx-studio__inline-warn { + font-size: var(--fs-caption); + color: var(--color-violation-warn-text); + background: var(--color-violation-warn-bg); + padding: 4px 8px; + border-radius: var(--radius-sm); +} +.kx-studio__toggle { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: var(--fs-body); + color: var(--color-neutral-700); +} +.kx-studio__toggle--req { + font-weight: 600; +} +.kx-studio__seg { + display: inline-flex; + gap: 3px; + background: var(--color-neutral-100); + padding: 3px; + border-radius: var(--radius-sm); +} +.kx-studio__seg button { + border: none; + background: transparent; + padding: 6px 18px; + border-radius: var(--radius-sm); + font-weight: 600; + color: var(--color-neutral-500); +} +.kx-studio__seg button.is-active { + background: var(--color-white); + color: var(--color-primary-700); + box-shadow: var(--shadow-level2); +} + +/* 중앙: 히어로 */ +.kx-studio__hero { + display: flex; + flex-direction: column; + gap: var(--space-4); + overflow-y: auto; + min-height: 0; +} +.kx-studio__grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-4); +} +.kx-studio__tile-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--space-3); + aspect-ratio: 4 / 3; + border: 1px dashed var(--color-neutral-200); + border-radius: var(--radius-lg); + background: var(--color-neutral-050); + color: var(--color-neutral-500); +} +.kx-studio__tile-label { + font-weight: 600; +} +.kx-studio__tile-empty button { + border: 1px solid var(--color-ai-accent); + color: var(--color-ai-accent); + background: var(--color-white); + border-radius: var(--radius-sm); + padding: 6px 14px; + font-size: var(--fs-caption); + font-weight: 600; +} + +/* Before/After */ +.kx-studio__compare { + display: flex; + flex-direction: column; + gap: var(--space-2); +} +.kx-studio__compare-head { + display: flex; + align-items: center; + gap: var(--space-3); +} +.kx-studio__compare-note { + font-size: var(--fs-caption); + color: var(--color-neutral-500); +} +.kx-studio__wm { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + transform: rotate(-24deg); + color: rgba(255, 255, 255, 0.55); + font-size: 13px; + font-weight: 700; + letter-spacing: 0.04em; + pointer-events: none; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.25); +} +.kx-studio__after-ai { + position: absolute; + top: var(--space-2); + left: var(--space-2); +} +.kx-studio__compare-notice { + font-size: 11px; + line-height: 15px; + color: var(--color-neutral-500); +} + +/* 하단 고정 바 */ +.kx-studio__footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + padding: var(--space-3) var(--space-5); + border-top: var(--border-card); + background: var(--color-white); +} +.kx-studio__precheck { + display: flex; + align-items: center; + gap: var(--space-3); +} +.kx-studio__precheck-hint { + font-size: var(--fs-caption); + color: var(--color-neutral-500); +} +.kx-studio__chips { + display: flex; + gap: var(--space-2); +} +.kx-chip { + padding: 2px 10px; + border-radius: var(--radius-pill); + font-size: var(--fs-caption); + font-weight: 600; +} +.kx-chip--pass { + background: #e6f4ee; + color: var(--color-success); +} +.kx-chip--warn { + background: var(--color-violation-warn-bg); + color: var(--color-violation-warn-text); +} +.kx-chip--block { + background: #fef3f2; + color: var(--color-error); +} +.kx-chip--doc { + background: var(--color-ai-surface); + color: var(--color-ai-accent); + border: 1px solid var(--color-ai-accent); +} +.kx-studio__banner { + margin: 0 var(--space-5) var(--space-3); + font-size: var(--fs-caption); + color: var(--color-primary-700); + background: var(--color-primary-050); + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-sm); +} + +/* 컨펌 모달 */ +.kx-studio__confirm { + position: relative; + z-index: 1; + width: min(480px, 92vw); + background: var(--color-white); + border-radius: var(--radius-lg); + padding: var(--space-5); + display: flex; + flex-direction: column; + gap: var(--space-3); + box-shadow: var(--shadow-level2); +} +.kx-studio__confirm h3 { + font-size: var(--fs-h2); + color: var(--color-neutral-900); +} + +@media (max-width: 1200px) { + .kx-studio__body { + grid-template-columns: 1fr; + overflow-y: auto; + } + .kx-studio__spec { + overflow-y: visible; + } +} diff --git a/src/frontend/src/screens/gallery/VisualizationGalleryPage.tsx b/src/frontend/src/screens/gallery/VisualizationGalleryPage.tsx new file mode 100644 index 0000000..3196943 --- /dev/null +++ b/src/frontend/src/screens/gallery/VisualizationGalleryPage.tsx @@ -0,0 +1,200 @@ +import { useMemo, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { ApiRequestError } from '../../api/client'; +import { renderApi } from '../../api/endpoints'; +import { AiImage } from '../../components/ui/AiImage'; +import { CompareSlider } from '../../components/ui/CompareSlider'; +import { AiLabel } from '../../components/ui/Badge'; +import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; +import type { RenderJobDto } from '../../api/types'; +import { sampleGallery } from './sampleGallery'; +import { AFTER_PLACEHOLDER, BEFORE_EMPTY_HALL } from '../design/placeholders'; +import './gallery.css'; + +/* + * SCR-12 시각화 갤러리 (M5). Stitch visualization_gallery 이식. + * - 샷 필터 칩(S1~S7) + 마소너리 그리드(워터마크 상시·생성중/실패 상태) + 라이트박스. + * - render-jobs 목록 501 → degraded 샘플 시연. 상단 고지 바 상시. + */ + +const SHOT_FILTERS = [ + { key: '', label: '전체' }, + { key: 'S1', label: 'S1 정면' }, + { key: 'S2', label: 'S2 야간' }, + { key: 'S3', label: 'S3 통로' }, + { key: 'S4', label: 'S4 내부' }, + { key: 'S5', label: 'S5 비교' }, + { key: 'S6', label: 'S6 배선' }, + { key: 'S7', label: 'S7 홀 전경' }, +] as const; + +const GALLERY_NOTICE = + 'AI 생성 이미지는 계약·심사 서류에 사용할 수 없습니다 — 시공 기준은 도면입니다'; + +export function VisualizationGalleryPage() { + const { eventId = '', boothId = '' } = useParams(); + const [shot, setShot] = useState(''); + const [lightbox, setLightbox] = useState(null); + + const query = useQuery<{ jobs: RenderJobDto[]; degraded: boolean }>({ + queryKey: ['render-jobs', eventId, boothId], + queryFn: async () => { + try { + const jobs = await renderApi.list(eventId, boothId); + return { jobs, degraded: false }; + } catch (err) { + if (err instanceof ApiRequestError && err.code === 'NOT_IMPLEMENTED') { + return { jobs: sampleGallery(boothId), degraded: true }; + } + throw err; + } + }, + }); + + const jobs = query.data?.jobs ?? []; + const degraded = query.data?.degraded ?? false; + const filtered = useMemo( + () => (shot ? jobs.filter((j) => j.shotPreset === shot) : jobs), + [jobs, shot], + ); + + return ( +
+ {/* 상단 고정 고지 바 */} +
+ {GALLERY_NOTICE} +
+ +
+
+

시각화 갤러리

+ 부스 {boothId || 'A-102'} + {degraded && 데이터 준비 중 (샘플)} +
+
+ {SHOT_FILTERS.map((f) => ( + + ))} +
+
+ + {query.isLoading ? ( +
+ {Array.from({ length: 6 }, (_, i) => ( + + ))} +
+ ) : query.isError ? ( + query.refetch()} /> + ) : filtered.length === 0 ? ( + + ) : ( +
+ {filtered.map((job) => ( + + ))} +
+ )} + + {/* 라이트박스 */} + {lightbox && ( +
+
setLightbox(null)} /> +
+ +
+ {lightbox.shotPreset === 'S5' ? ( + + + + AI 생성 + + + } + /> + ) : ( + + )} +
+
+
+
+
생성일
+
{new Date(lightbox.createdAt).toLocaleString('ko-KR')}
+
+
+
모델
+
{lightbox.modelVersion ?? '—'}
+
+
+
스키마 해시
+
{lightbox.schemaHash ?? '—'}
+
+
+

{lightbox.notice}

+
+
+
+ )} +
+ ); +} + +const STATUS_LABEL: Record = { + QUEUED: '대기', + RUNNING: '생성 중', + DONE: '완료', + FAILED: '실패', +}; diff --git a/src/frontend/src/screens/gallery/gallery.css b/src/frontend/src/screens/gallery/gallery.css new file mode 100644 index 0000000..df18871 --- /dev/null +++ b/src/frontend/src/screens/gallery/gallery.css @@ -0,0 +1,167 @@ +.kx-gallery { + display: flex; + flex-direction: column; + gap: var(--space-4); + padding: var(--space-4) var(--space-5) var(--space-6); +} + +/* 상단 고정 고지 바 */ +.kx-gallery__notice { + position: sticky; + top: 0; + z-index: 5; + padding: var(--space-2) var(--space-4); + border-radius: var(--radius-sm); + background: var(--color-ai-surface); + border: 1px solid var(--color-ai-accent); + color: var(--color-ai-accent); + font-size: var(--fs-caption); + font-weight: 600; +} + +.kx-gallery__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-4); + flex-wrap: wrap; +} +.kx-gallery__scope { + display: flex; + align-items: baseline; + gap: var(--space-3); +} +.kx-gallery__scope h2 { + font-size: var(--fs-h2); + color: var(--color-neutral-900); +} +.kx-gallery__scope-sub { + font-size: var(--fs-body); + color: var(--color-neutral-500); +} +.kx-gallery__degraded { + font-size: var(--fs-caption); + color: var(--color-warning); + background: #fff4e5; + padding: 2px 10px; + border-radius: var(--radius-pill); +} +.kx-gallery__filters { + display: flex; + gap: var(--space-2); + flex-wrap: wrap; +} +.kx-gallery__chip { + border: 1px solid var(--color-neutral-200); + background: var(--color-white); + color: var(--color-neutral-700); + padding: 5px 12px; + border-radius: var(--radius-pill); + font-size: var(--fs-caption); + font-weight: 600; +} +.kx-gallery__chip.is-active { + background: var(--color-primary-600); + border-color: var(--color-primary-600); + color: var(--color-white); +} + +/* 마소너리(간이 — CSS columns) */ +.kx-gallery__grid { + columns: 3; + column-gap: var(--space-4); +} +.kx-gallery__card { + display: block; + width: 100%; + break-inside: avoid; + margin-bottom: var(--space-4); + border: none; + background: transparent; + padding: 0; + text-align: left; +} +.kx-gallery__meta { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-1) var(--space-1) 0; + font-size: var(--fs-caption); + color: var(--color-neutral-500); +} +.kx-gallery__status { + font-weight: 600; + padding: 1px 8px; + border-radius: var(--radius-pill); +} +.kx-gallery__status.is-done { + background: #e6f4ee; + color: var(--color-success); +} +.kx-gallery__status.is-running, +.kx-gallery__status.is-queued { + background: var(--color-ai-surface); + color: var(--color-ai-accent); +} +.kx-gallery__status.is-failed { + background: #fef3f2; + color: var(--color-error); +} + +/* 라이트박스 */ +.kx-lightbox { + position: relative; + z-index: 1; + width: min(880px, 94vw); + max-height: 90vh; + overflow: auto; + background: var(--color-white); + border-radius: var(--radius-lg); + padding: var(--space-5); + box-shadow: var(--shadow-level2); +} +.kx-lightbox__close { + position: absolute; + top: var(--space-3); + right: var(--space-3); + border: none; + background: var(--color-neutral-100); + border-radius: 50%; + width: 32px; + height: 32px; + color: var(--color-neutral-700); +} +.kx-lightbox__stage { + margin-bottom: var(--space-4); +} +.kx-lightbox__dl { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: var(--space-3); + font-size: var(--fs-caption); +} +.kx-lightbox__dl dt { + color: var(--color-neutral-500); + margin-bottom: 2px; +} +.kx-lightbox__dl dd { + margin: 0; + color: var(--color-neutral-900); + word-break: break-all; +} +.kx-lightbox__notice { + margin-top: var(--space-3); + font-size: 11px; + color: var(--color-neutral-500); +} + +@media (max-width: 1100px) { + .kx-gallery__grid { + columns: 2; + } +} +@media (max-width: 640px) { + .kx-gallery__grid { + columns: 2; + } +} diff --git a/src/frontend/src/screens/gallery/sampleGallery.ts b/src/frontend/src/screens/gallery/sampleGallery.ts new file mode 100644 index 0000000..2bf47fc --- /dev/null +++ b/src/frontend/src/screens/gallery/sampleGallery.ts @@ -0,0 +1,35 @@ +import type { RenderJobDto } from '../../api/types'; + +/* + * Degraded 샘플 갤러리 — render-jobs 목록 501(NOT_IMPLEMENTED) 시 시연용. + * ★ 실이미지 없음(imageUrl=null) — 상태·라벨·워터마크 고지 UX만 시연. 실 API 연결 시 대체. + */ +const WATERMARK = 'AI 생성 예상 이미지 — 실제 시공 결과와 다를 수 있습니다'; +const NOTICE = 'AI 생성 이미지는 계약·심사 서류에 사용할 수 없습니다 — 시공 기준은 도면입니다'; + +const SHOTS: { preset: string; status: RenderJobDto['status'] }[] = [ + { preset: 'S1', status: 'DONE' }, + { preset: 'S2', status: 'DONE' }, + { preset: 'S3', status: 'RUNNING' }, + { preset: 'S4', status: 'DONE' }, + { preset: 'S5', status: 'DONE' }, + { preset: 'S6', status: 'DONE' }, + { preset: 'S7', status: 'FAILED' }, +]; + +export function sampleGallery(boothId: string): RenderJobDto[] { + return SHOTS.map((s, i) => ({ + jobId: `sample-job-${i}`, + boothId: boothId || 'b-102', + shotPreset: s.preset, + status: s.status, + imageUrl: null, + schemaHash: s.status === 'DONE' ? `sha256:sample${i}` : null, + modelVersion: s.status === 'DONE' ? 'gemini-3.1-flash-image-preview' : null, + watermarkRequired: true, + watermarkText: WATERMARK, + notice: NOTICE, + errorMessage: s.status === 'FAILED' ? '생성 실패 — 다시 시도해 주세요.' : null, + createdAt: new Date(Date.now() - i * 3600_000).toISOString(), + })); +} diff --git a/src/frontend/src/screens/utility/UtilityWiringPage.tsx b/src/frontend/src/screens/utility/UtilityWiringPage.tsx new file mode 100644 index 0000000..bcb8ec3 --- /dev/null +++ b/src/frontend/src/screens/utility/UtilityWiringPage.tsx @@ -0,0 +1,366 @@ +import { useCallback, useMemo, useState } from 'react'; +import { useParams, useSearchParams } from 'react-router-dom'; +import { ApiRequestError } from '../../api/client'; +import { utilityApi } from '../../api/endpoints'; +import { Button } from '../../components/ui/Button'; +import type { + UtilityDevice, + UtilityOrderDto, + UtilityQuote, + WiringResult, + WiringTerminal, +} from '../../api/types'; +import { WiringCanvas } from './WiringCanvas'; +import './utility.css'; + +/* + * SCR-07 유틸리티 배선 뷰 (M4a 견적 + M4b 배선). Stitch utility_wiring_view 이식. + * - 좌: 기기 목록 빌더 → quote(완성 API). 중앙: WiringCanvas(경로). 우: 자동 견적 패널 + 신청 CTA. + * - quote는 완성 엔드포인트, wiring/order는 501 → degraded 클라이언트 산출/안내. + */ + +type UtilTab = 'power' | 'network' | 'water'; + +// 부스 로컬 기준 폴리곤(6×3m). ★부스 geometry 엔드포인트 부재 → 고정(경계면 이슈 기록). +const BOOTH_POLY: number[][] = [ + [0, 0], + [6, 0], + [6, 3], + [0, 3], + [0, 0], +]; + +const DEFAULT_DEVICES: UtilityDevice[] = [ + { name: '로봇 시연장비', count: 2, powerKw: 1.5 }, + { name: 'LED 스포트 조명', count: 6, powerKw: 0.3 }, +]; + +/** degraded 배선: 최근접 트렌치 교차점(3m 그리드)에서 단말까지 L자 경로 산출. */ +function degradedWiring(terminals: WiringTerminal[]): WiringResult { + const snap = (v: number) => Math.round(v / 3) * 3; + return { + assumedTrench: true, + paths: terminals.map((t) => { + const [tx, ty] = t.position; + const sx = snap(tx); + const sy = snap(ty); + const coords = [ + [sx, sy], + [tx, sy], + [tx, ty], + ]; + let lengthM = 0; + for (let i = 1; i < coords.length; i += 1) { + lengthM += Math.hypot(coords[i][0] - coords[i - 1][0], coords[i][1] - coords[i - 1][1]); + } + const color = t.kind === 'power' ? 'red' : t.kind === 'network' ? 'blue' : 'green'; + const label = + t.kind === 'power' + ? `${t.kw ?? ''}kW` + : t.kind === 'network' + ? '유선 회선' + : '급배수'; + return { kind: t.kind, color, coords, lengthM: Math.round(lengthM * 100) / 100, label }; + }), + }; +} + +export function UtilityWiringPage() { + const { eventId = '', boothId = '' } = useParams(); + const [searchParams] = useSearchParams(); + const hallId = searchParams.get('hallId') ?? 'H7'; + + const [tab, setTab] = useState('power'); + const [devices, setDevices] = useState(DEFAULT_DEVICES); + const [networkLines, setNetworkLines] = useState(1); + const [plumbingOutlets, setPlumbingOutlets] = useState(1); + const [airOutlets, setAirOutlets] = useState(0); + + const [quote, setQuote] = useState(null); + const [wiring, setWiring] = useState(null); + const [order, setOrder] = useState(null); + const [busy, setBusy] = useState(false); + const [notice, setNotice] = useState(null); + const [selectedTerminal, setSelectedTerminal] = useState(null); + + // 필요 전력(로컬 미리보기 — 서버 quote가 권위). + const requestedKw = useMemo(() => { + const total = devices.reduce((s, d) => s + d.count * d.powerKw, 0); + return Math.ceil(total); + }, [devices]); + const distributionBox = useMemo(() => Math.ceil(requestedKw / 11), [requestedKw]); + + // 단말 좌표(부스 내부 배치 — power 좌하단, network 좌상단, water 우하단). + const terminals: WiringTerminal[] = useMemo(() => { + const out: WiringTerminal[] = []; + if (requestedKw > 0) out.push({ kind: 'power', position: [4.5, 2.2], kw: requestedKw }); + for (let i = 0; i < networkLines; i += 1) + out.push({ kind: 'network', position: [1.2, 0.8 + i * 0.6] }); + for (let i = 0; i < plumbingOutlets; i += 1) + out.push({ kind: 'plumbing', position: [5.2, 0.8 + i * 0.6] }); + for (let i = 0; i < airOutlets; i += 1) + out.push({ kind: 'air', position: [3.0, 0.6 + i * 0.6] }); + return out; + }, [requestedKw, networkLines, plumbingOutlets, airOutlets]); + + const buildRequest = useCallback( + () => ({ + devices, + networkWiredLines: networkLines, + plumbingOutlets, + compressedAirOutlets: airOutlets, + }), + [devices, networkLines, plumbingOutlets, airOutlets], + ); + + const recalc = useCallback(async () => { + setBusy(true); + setNotice(null); + // quote (완성 API) + try { + const q = await utilityApi.quote(eventId, boothId, buildRequest()); + setQuote(q); + } catch (err) { + if (err instanceof ApiRequestError && err.code === 'NOT_IMPLEMENTED') { + setNotice('견적 엔진이 준비 중입니다(로컬 추정 표시).'); + } else { + setNotice(err instanceof ApiRequestError ? err.message : '견적 계산 중 오류가 발생했습니다.'); + } + } + // wiring (501 → degraded 클라이언트 산출) + try { + const w = await utilityApi.wiring(eventId, boothId, hallId, { terminals }); + setWiring(w); + } catch (err) { + if (err instanceof ApiRequestError && err.code === 'NOT_IMPLEMENTED') { + setWiring(degradedWiring(terminals)); + } + } + setBusy(false); + }, [eventId, boothId, hallId, buildRequest, terminals]); + + const submitOrder = useCallback(async () => { + setBusy(true); + setNotice(null); + try { + const o = await utilityApi.order(eventId, boothId, buildRequest()); + setOrder(o); + } catch (err) { + if (err instanceof ApiRequestError && err.code === 'NOT_IMPLEMENTED') { + setNotice('신청 제출 경로가 준비 중입니다(백엔드 위치표시도 생성 대기).'); + } else { + setNotice(err instanceof ApiRequestError ? err.message : '신청 제출 중 오류가 발생했습니다.'); + } + } + setBusy(false); + }, [eventId, boothId, buildRequest]); + + // 렌더용 배선(계산 전이면 degraded 미리보기). + const wiringView = wiring ?? degradedWiring(terminals); + const activeQuote = order?.quote ?? quote; + + return ( +
+
+
+

부스 {boothId || 'A-102'} · 홀 {hallId}

+
+ 유틸리티 신청 마감 D-6 · 인터넷은 현장 추가신청 불가 +
+
+
+ setTab('power')} label="전기" /> + setTab('network')} label="네트워크·전화" /> + setTab('water')} label="급배수·압축공기" /> +
+
+ +
+ {/* 좌: 입력 빌더 */} + + + {/* 중앙: 배선 캔버스 */} +
+ +
+ + {/* 우: 자동 견적 패널 */} + +
+
+ ); +} + +function TabBtn({ active, onClick, label }: { active: boolean; onClick: () => void; label: string }) { + return ( + + ); +} + +function NumRow({ label, value, onChange }: { label: string; value: number; onChange: (v: number) => void }) { + return ( + + ); +} diff --git a/src/frontend/src/screens/utility/WiringCanvas.tsx b/src/frontend/src/screens/utility/WiringCanvas.tsx new file mode 100644 index 0000000..5957893 --- /dev/null +++ b/src/frontend/src/screens/utility/WiringCanvas.tsx @@ -0,0 +1,172 @@ +import { useMemo } from 'react'; +import type { WiringPath, WiringTerminal } from '../../api/types'; +import './wiring.css'; + +/* + * 배선 오버레이 렌더러 (SCR-07 히어로). + * - 다크 서피스(#1C2536) 위 부스 폴리곤 + 트렌치 그리드 + 배선 경로. + * - 색상 규약(design.md §1·S6·계약 §5): 전기 power=적 / 네트워크 network=청 / 급배수·압축공기 plumbing·air=녹. + * - 좌표계: 홀 로컬 미터(계약 WiringResult.coords / terminal.position) → SVG 유저 유닛(1m=1unit). + * 부스·트렌치·경로가 같은 좌표 공간을 공유(좌표 정합). + */ + +interface WiringCanvasProps { + /** 부스 폴리곤(홀 로컬 미터). 뷰포트 중심 대상. */ + boothPolygon: number[][]; + /** 배선 경로(계약 WiringResult.paths). */ + paths: WiringPath[]; + /** 단말(드래그 조정 대상). */ + terminals: WiringTerminal[]; + assumedTrench: boolean; + selectedTerminalIdx: number | null; + onSelectTerminal: (idx: number | null) => void; +} + +const COLOR_CLASS: Record = { + power: 'is-power', + red: 'is-power', + network: 'is-network', + blue: 'is-network', + plumbing: 'is-water', + air: 'is-water', + green: 'is-water', +}; + +const TRENCH_SPACING_M = 3; +const PAD = 4; // viewBox 여백(m) + +function bbox(poly: number[][]): { minX: number; minY: number; maxX: number; maxY: number } { + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const [x, y] of poly) { + if (x < minX) minX = x; + if (y < minY) minY = y; + if (x > maxX) maxX = x; + if (y > maxY) maxY = y; + } + return { minX, minY, maxX, maxY }; +} + +function kindClass(p: WiringPath): string { + return COLOR_CLASS[p.color] ?? COLOR_CLASS[p.kind] ?? 'is-power'; +} + +export function WiringCanvas({ + boothPolygon, + paths, + terminals, + assumedTrench, + selectedTerminalIdx, + onSelectTerminal, +}: WiringCanvasProps) { + // 부스 주변 여유를 둔 뷰박스(부스 + 인접 트렌치가 보이게). + const view = useMemo(() => { + const b = bbox(boothPolygon); + const marginX = Math.max(6, (b.maxX - b.minX) * 0.4); + const marginY = Math.max(6, (b.maxY - b.minY) * 0.4); + const minX = b.minX - marginX; + const minY = b.minY - marginY; + const w = b.maxX - b.minX + marginX * 2; + const h = b.maxY - b.minY + marginY * 2; + return { minX, minY, w, h }; + }, [boothPolygon]); + + // 뷰박스 범위의 트렌치 그리드(가정 그리드 — 실측 대기). + const trench = useMemo(() => { + const lines: { x1: number; y1: number; x2: number; y2: number }[] = []; + const startX = Math.ceil((view.minX - PAD) / TRENCH_SPACING_M) * TRENCH_SPACING_M; + const startY = Math.ceil((view.minY - PAD) / TRENCH_SPACING_M) * TRENCH_SPACING_M; + for (let x = startX; x < view.minX + view.w + PAD; x += TRENCH_SPACING_M) { + lines.push({ x1: x, y1: view.minY - PAD, x2: x, y2: view.minY + view.h + PAD }); + } + for (let y = startY; y < view.minY + view.h + PAD; y += TRENCH_SPACING_M) { + lines.push({ x1: view.minX - PAD, y1: y, x2: view.minX + view.w + PAD, y2: y }); + } + return lines; + }, [view]); + + const boothPts = boothPolygon.map(([x, y]) => `${x},${y}`).join(' '); + + return ( +
+ {assumedTrench && ( + + 가정 트렌치 좌표 (실측 대기) + + )} + + + onSelectTerminal(null)} + > + {/* 트렌치 그리드 */} + + {trench.map((l, i) => ( + + ))} + + + {/* 부스 외곽 */} + + + {/* 배선 경로 (색상 규약) */} + + {paths.map((p, i) => { + const d = p.coords.map(([x, y], j) => `${j === 0 ? 'M' : 'L'} ${x} ${y}`).join(' '); + const mid = p.coords[Math.floor(p.coords.length / 2)] ?? p.coords[0]; + return ( + + + {mid && ( + + {p.label} + + )} + + ); + })} + + + {/* 단말 노드 */} + + {terminals.map((t, i) => { + const [x, y] = t.position; + const sel = i === selectedTerminalIdx; + const cls = COLOR_CLASS[t.kind] ?? 'is-power'; + return ( + { + e.stopPropagation(); + onSelectTerminal(i); + }} + tabIndex={0} + role="button" + aria-label={`단말 ${i + 1} (${t.kind})`} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onSelectTerminal(i); + } + }} + > + + + ); + })} + + +
+ ); +} diff --git a/src/frontend/src/screens/utility/utility.css b/src/frontend/src/screens/utility/utility.css new file mode 100644 index 0000000..4272b0a --- /dev/null +++ b/src/frontend/src/screens/utility/utility.css @@ -0,0 +1,263 @@ +.kx-util { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + +.kx-util__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-4); + padding: var(--space-4) var(--space-5); + border-bottom: var(--border-card); + background: var(--color-white); + flex-wrap: wrap; +} +.kx-util__title { + font-size: var(--fs-h2); + line-height: var(--lh-h2); + color: var(--color-neutral-900); +} +.kx-util__deadline { + margin-top: var(--space-2); + display: inline-block; + padding: 4px 12px; + border-radius: var(--radius-sm); + font-size: var(--fs-caption); + font-weight: 600; + color: var(--color-violation-warn-text); + background: var(--color-violation-warn-bg); +} +.kx-util__tabs { + display: flex; + gap: var(--space-1); + background: var(--color-neutral-100); + padding: 3px; + border-radius: var(--radius-sm); +} +.kx-util__tab { + border: none; + background: transparent; + padding: 6px 14px; + border-radius: var(--radius-sm); + font-size: var(--fs-body); + font-weight: 600; + color: var(--color-neutral-500); +} +.kx-util__tab.is-active { + background: var(--color-white); + color: var(--color-primary-700); + box-shadow: var(--shadow-level2); +} + +.kx-util__body { + display: grid; + grid-template-columns: 320px 1fr 340px; + gap: var(--space-4); + padding: var(--space-4) var(--space-5); + flex: 1; + min-height: 0; +} + +.kx-util__left, +.kx-util__right { + display: flex; + flex-direction: column; + gap: var(--space-4); + overflow-y: auto; +} +.kx-util__section { + display: flex; + flex-direction: column; + gap: var(--space-3); + padding: var(--space-4); + border: var(--border-card); + border-radius: var(--radius-lg); + background: var(--color-white); +} +.kx-util__section h3 { + font-size: var(--fs-h3); + color: var(--color-neutral-900); +} + +/* 기기 테이블 */ +.kx-devtable { + display: flex; + flex-direction: column; + gap: var(--space-2); +} +.kx-devtable__head, +.kx-devtable__row { + display: grid; + grid-template-columns: 1fr 52px 56px 24px; + gap: var(--space-2); + align-items: center; +} +.kx-devtable__head { + font-size: var(--fs-caption); + color: var(--color-neutral-500); +} +.kx-devtable__row input { + width: 100%; + padding: 6px 8px; + border: 1px solid var(--color-neutral-200); + border-radius: var(--radius-sm); + font-size: var(--fs-body); +} +.kx-devtable__del { + border: none; + background: transparent; + color: var(--color-neutral-500); + font-size: 12px; +} +.kx-devtable__del:hover { + color: var(--color-error); +} +.kx-util__addrow { + align-self: flex-start; + border: 1px dashed var(--color-neutral-200); + background: var(--color-neutral-050); + color: var(--color-primary-700); + padding: 6px 12px; + border-radius: var(--radius-sm); + font-size: var(--fs-caption); + font-weight: 600; +} + +.kx-util__compute { + display: flex; + flex-direction: column; + gap: var(--space-1); + padding: var(--space-3); + border-radius: var(--radius-sm); + background: var(--color-primary-050); + color: var(--color-primary-700); + font-size: var(--fs-body); +} +.kx-util__compute strong { + font-size: var(--fs-h3); +} +.kx-util__arrow { + margin: 0 6px; + color: var(--color-neutral-500); +} +.kx-util__numrow { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); +} +.kx-util__numrow input { + width: 88px; + padding: 6px 8px; + border: 1px solid var(--color-neutral-200); + border-radius: var(--radius-sm); + text-align: right; +} +.kx-util__hint { + font-size: var(--fs-caption); + color: var(--color-neutral-500); +} + +/* 중앙 캔버스 */ +.kx-util__canvas { + min-height: 420px; + border-radius: var(--radius-lg); + overflow: hidden; +} + +/* 우측 견적 */ +.kx-util__right { + padding: var(--space-4); + border: var(--border-card); + border-radius: var(--radius-lg); + background: var(--color-white); +} +.kx-quote__title { + font-size: var(--fs-h3); + color: var(--color-neutral-900); +} +.kx-quote__lines { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; +} +.kx-quote__lines li { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--space-3); + padding: var(--space-2) 0; + border-bottom: 1px solid var(--color-neutral-100); + font-size: var(--fs-body); +} +.kx-quote__label em { + color: var(--color-neutral-500); + font-style: normal; +} +.kx-quote__amount { + font-weight: 600; + color: var(--color-neutral-900); +} +.kx-quote__total { + display: flex; + align-items: baseline; + justify-content: space-between; + padding: var(--space-3) 0; + border-top: 2px solid var(--color-neutral-200); + margin-top: var(--space-2); +} +.kx-quote__total strong { + font-size: var(--fs-h1); + color: var(--color-primary-700); +} +.kx-quote__disclaimer { + font-size: 11px; + line-height: 15px; + color: var(--color-neutral-500); +} +.kx-quote__ruleset { + font-size: 11px; + color: var(--color-neutral-500); +} +.kx-quote__empty { + font-size: var(--fs-body); + color: var(--color-neutral-500); +} +.kx-quote__submitted { + padding: var(--space-3); + border-radius: var(--radius-sm); + background: #e6f4ee; + color: var(--color-success); + font-size: var(--fs-caption); + display: flex; + flex-direction: column; + gap: 4px; +} +.kx-quote__submitted-title { + font-weight: 700; + font-size: var(--fs-body); +} +.kx-quote__relay { + color: var(--color-neutral-700); +} +.kx-quote__diagram { + font-weight: 600; +} +.kx-util__banner { + font-size: var(--fs-caption); + color: var(--color-warning); + background: #fff4e5; + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-sm); +} + +@media (max-width: 1200px) { + .kx-util__body { + grid-template-columns: 1fr; + } +} diff --git a/src/frontend/src/screens/utility/wiring.css b/src/frontend/src/screens/utility/wiring.css new file mode 100644 index 0000000..e439d91 --- /dev/null +++ b/src/frontend/src/screens/utility/wiring.css @@ -0,0 +1,138 @@ +.kx-wire { + position: relative; + width: 100%; + height: 100%; + min-height: 420px; + background: var(--color-canvas-bg); + border-radius: var(--radius-lg); + overflow: hidden; +} +.kx-wire__svg { + width: 100%; + height: 100%; + display: block; +} + +/* 가정 트렌치 배지 */ +.kx-wire__assumed { + position: absolute; + top: var(--space-3); + left: var(--space-3); + z-index: 2; + padding: 4px 10px; + border-radius: var(--radius-pill); + font-size: var(--fs-caption); + font-weight: 600; + color: #fde68a; + background: rgba(180, 83, 9, 0.28); + border: 1px solid rgba(253, 224, 71, 0.4); +} + +/* 범례 */ +.kx-wire__legend { + position: absolute; + top: var(--space-3); + right: var(--space-3); + z-index: 2; + display: flex; + flex-direction: column; + gap: 4px; + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-sm); + background: rgba(16, 24, 40, 0.55); +} +.kx-wire__legend-item { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: var(--fs-caption); + color: var(--color-white); +} +.kx-wire__legend-item::before { + content: ''; + width: 14px; + height: 3px; + border-radius: 2px; +} +.kx-wire__legend-item.is-power::before { + background: var(--color-wire-power); +} +.kx-wire__legend-item.is-network::before { + background: var(--color-wire-network); +} +.kx-wire__legend-item.is-water::before { + background: var(--color-wire-water); +} + +/* 트렌치 그리드 */ +.kx-wire__trench line { + stroke: var(--color-canvas-line); + stroke-width: 0.08; + stroke-dasharray: 0.6 0.5; + vector-effect: non-scaling-stroke; +} + +/* 부스 외곽 */ +.kx-wire__booth { + fill: rgba(0, 102, 179, 0.16); + stroke: var(--color-primary-600); + stroke-width: 0.2; + vector-effect: non-scaling-stroke; +} + +/* 배선 경로 */ +.kx-wire__path-line { + fill: none; + stroke-width: 2.4; + vector-effect: non-scaling-stroke; + stroke-linecap: round; + stroke-linejoin: round; +} +.kx-wire__path.is-power .kx-wire__path-line { + stroke: var(--color-wire-power); +} +.kx-wire__path.is-network .kx-wire__path-line { + stroke: var(--color-wire-network); + stroke-dasharray: 6 4; +} +.kx-wire__path.is-water .kx-wire__path-line { + stroke: var(--color-wire-water); + stroke-dasharray: 1 4; +} +.kx-wire__path-label { + fill: var(--color-white); + font-size: 1.4px; + font-weight: 600; + text-anchor: middle; + paint-order: stroke; + stroke: rgba(16, 24, 40, 0.85); + stroke-width: 0.5px; +} + +/* 단말 노드 */ +.kx-wire__terminal circle { + stroke: var(--color-white); + stroke-width: 0.18; + vector-effect: non-scaling-stroke; + cursor: pointer; +} +.kx-wire__terminal.is-power circle { + fill: var(--color-wire-power); +} +.kx-wire__terminal.is-network circle { + fill: var(--color-wire-network); +} +.kx-wire__terminal.is-water circle { + fill: var(--color-wire-water); +} +.kx-wire__terminal.is-selected circle { + stroke: var(--color-white); + stroke-width: 0.4; +} +.kx-wire__terminal:focus-visible { + outline: none; +} +.kx-wire__terminal:focus-visible circle { + stroke: var(--color-primary-100); + stroke-width: 0.4; +}