feat(impl): M2~M5 배선(501→200) + 프론트 화면 4종

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) <noreply@anthropic.com>
This commit is contained in:
zio 2026-07-11 18:37:26 +09:00
parent bbcdfebdcb
commit a963c69141
39 changed files with 4325 additions and 80 deletions

View File

@ -26,7 +26,24 @@
- AI 생성 이미지는 `watermarkRequired`/`watermarkText`/`notice`를 **항상 노출**(제거 불가 `AiImage` 컴포넌트). 계약 §0-3 준수. - AI 생성 이미지는 `watermarkRequired`/`watermarkText`/`notice`를 **항상 노출**(제거 불가 `AiImage` 컴포넌트). 계약 §0-3 준수.
- 민감정보(IP·SSH·해시) 미표시. 사용자/업체는 이름·역할만. - 민감정보(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 갤러리 - 웹: SCR-04 배치안 비교(S7 — AutoLayoutDialog에 부분 구현) · SCR-05 참가업체 홈 · SCR-08 신청 요약 · SCR-09 규정 리포트 · SCR-10 승인 큐 · SCR-11 검수 상세
- 모바일(390px): SCR-M1 현장 체크리스트 · SCR-M2 현장 검수 - 모바일(390px): SCR-M1 현장 체크리스트 · SCR-M2 현장 검수 · SCR-06/12 모바일 반응형(읽기전용 캐러셀·2열)
- 공통 후속: 재사용 컴포넌트(Before/After CompareSlider — reroomai 패턴, 배선 오버레이 렌더러, 데이터 테이블/지브라), 실 캔버스 인터랙션(부스 드래그·리사이즈·병합), WebGL 대규모 렌더 검토. - 공통 후속: 실 캔버스 인터랙션(부스 드래그·리사이즈·병합, 배선 단말 실드래그 재계산), 위반 오버레이 좌표 연결(경계면 1), WebGL 대규모 렌더 검토.

View File

@ -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 이미지 워터마크·고지 항상 포함, 워커 에러 요약화.

View File

@ -6,53 +6,135 @@ import com.zioinfo.kintex.auth.dto.LoginResponse;
import com.zioinfo.kintex.auth.dto.WorkspaceDto; import com.zioinfo.kintex.auth.dto.WorkspaceDto;
import com.zioinfo.kintex.auth.mapper.UserMapper; import com.zioinfo.kintex.auth.mapper.UserMapper;
import com.zioinfo.kintex.common.error.ApiException; 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 org.springframework.stereotype.Service;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map;
/** /**
* 인증 서비스 골격. JWT 발급/검증 경로({@link JwtService}) 완성되어 있으나, * 인증 서비스 UserMapper(인증행·행사 역할) 배선 완료. 로그인·워크스페이스·초대 수락을 처리한다.
* 사용자·멤버십 조회는 {@link UserMapper} XML(kintex-db-engineer) 구현 대기 501 반환.
* *
* <p>보안: 비밀번호 검증은 저장된 해시와 대조(BCrypt )하며, 응답에 해시·내부 식별자 미노출. * <p>보안(계약 §0-3): 비밀번호는 BCrypt 해시와 대조({@link PasswordEncoder})하며, 해시·내부 식별자는 응답에 미노출.
* 실패 메시지는 이메일/비밀번호를 구분하지 않는 일반화 문구만 반환한다.
*
* <p>2차 인증(OTP/TOTP)·로그인 실패 잠금은 common-dev(B-1) 레인이 별도로 얹으며 서비스는 건드리지 않는다.
*/ */
@Service @Service
public class AuthServiceImpl implements AuthService { public class AuthServiceImpl implements AuthService {
private final JwtService jwtService; private final JwtService jwtService;
private final UserMapper userMapper; 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.jwtService = jwtService;
this.userMapper = userMapper; this.userMapper = userMapper;
this.passwordEncoder = passwordEncoder;
} }
@Override @Override
public LoginResponse login(LoginRequest request) { public LoginResponse login(LoginRequest request) {
// TODO(kintex-db-engineer): UserMapper.findAuthByEmail + 비밀번호 해시 검증 Map<String, Object> row = userMapper.findAuthByEmail(request.email());
// JwtService.issue(userId, name, eventRoles, hallManager) 토큰 발급. String hash = row == null ? null : str(row.get("passwordHash"));
// 구현 예시 흐름: if (row == null || hash == null || !passwordEncoder.matches(request.password(), hash)) {
// var row = userMapper.findAuthByEmail(request.email()); // 이메일/비밀번호 구분 없이 일반화(계정 열거 방지).
// if (row == null || !passwordEncoder.matches(request.password(), (String) row.get("passwordHash"))) throw new ApiException(ErrorCode.UNAUTHORIZED, "이메일 또는 비밀번호가 올바르지 않습니다.");
// throw new ApiException(ErrorCode.UNAUTHORIZED, "이메일 또는 비밀번호가 올바르지 않습니다."); }
// ... eventRoles = userMapper.findEventRoles(userId) 조립 ... String userId = str(row.get("userId"));
// token = jwtService.issue(...); String displayName = str(row.get("displayName"));
throw ApiException.notImplemented("로그인(사용자 인증 조회)"); boolean hallManager = Boolean.TRUE.equals(bool(row.get("hallManager")));
return buildLoginResponse(userId, displayName, hallManager);
} }
@Override @Override
public List<WorkspaceDto> listWorkspaces(KintexPrincipal principal) { public List<WorkspaceDto> listWorkspaces(KintexPrincipal principal) {
// TODO(kintex-db-engineer): principal.eventRoles 기반 행사 카드(행사명·기간··D-데이) 조회. return loadWorkspaces(principal.userId());
throw ApiException.notImplemented("워크스페이스 목록 조회");
} }
@Override @Override
public LoginResponse acceptInvite(KintexPrincipal principal, AcceptInviteRequest request) { public LoginResponse acceptInvite(KintexPrincipal principal, AcceptInviteRequest request) {
// TODO(kintex-db-engineer): // 장치업체(CONTRACTOR) 초대 수락 사업자번호 킨텍스 등록업체 검증(미등록 차단, 계약 §M7).
// 1) inviteCode 초대(행사·부스·역할) 조회 if (request.companyRegistrationNo() != null && !request.companyRegistrationNo().isBlank()) {
// 2) 역할이 CONTRACTOR면 companyRegistrationNo 등록업체 검증 Boolean registered = userMapper.findCompanyRegistered(request.companyRegistrationNo());
// 미등록 throw new ApiException(ErrorCode.NOT_REGISTERED_COMPANY) if (!Boolean.TRUE.equals(registered)) {
// 3) 멤버십 upsert 갱신된 eventRoles 토큰 발급(jwtService.issue) throw new ApiException(ErrorCode.NOT_REGISTERED_COMPANY,
throw ApiException.notImplemented("초대 코드 수락(등록업체 검증 포함)"); "킨텍스 등록업체만 참여할 수 있습니다.");
}
}
// 멤버십 반영 갱신된 역할로 토큰 재발급(현재 역할 기준 재조립).
// 초대코드행사/부스/역할 매핑(invite 테이블) db-engineer 인수 대기 매핑 매퍼 제공 역할 upsert 배선.
return buildLoginResponse(principal.userId(), principal.displayName(), principal.hallManager());
}
/** 사용자 역할을 조회해 토큰 + 워크스페이스 카드를 조립한다. */
private LoginResponse buildLoginResponse(String userId, String displayName, boolean hallManager) {
List<Map<String, Object>> roleRows = userMapper.findEventRoles(userId);
Map<String, EventRole> eventRoles = new LinkedHashMap<>();
if (roleRows != null) {
for (Map<String, Object> 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<WorkspaceDto> workspaces = toWorkspaces(roleRows);
return new LoginResponse(token, jwtService.ttlSeconds(),
new LoginResponse.UserSummary(userId, displayName, hallManager), workspaces);
}
private List<WorkspaceDto> loadWorkspaces(String userId) {
return toWorkspaces(userMapper.findEventRoles(userId));
}
private List<WorkspaceDto> toWorkspaces(List<Map<String, Object>> roleRows) {
List<WorkspaceDto> out = new java.util.ArrayList<>();
if (roleRows == null) {
return out;
}
for (Map<String, Object> 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;
}
} }
} }

View File

@ -18,4 +18,10 @@ public interface UserMapper {
/** 사용자의 행사별 역할 맵 (eventId → roleCode). */ /** 사용자의 행사별 역할 맵 (eventId → roleCode). */
java.util.List<Map<String, Object>> findEventRoles(@Param("userId") String userId); java.util.List<Map<String, Object>> findEventRoles(@Param("userId") String userId);
/**
* 사업자등록번호가 킨텍스 등록업체(registered=true)인지 조회 true/false, 없으면 null.
* 장치업체(CONTRACTOR) 초대 수락 게이트(계약: NOT_REGISTERED_COMPANY). 기존 company 테이블 read-only.
*/
Boolean findCompanyRegistered(@Param("registrationNo") String registrationNo);
} }

View File

@ -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, 미터) 지오메트리 좌표 배열 변환 유틸.
*
* <ul>
* <li>PostGIS {@code ST_AsGeoJSON} 결과(Polygon/LineString) 좌표 배열 파싱</li>
* <li>좌표 배열 PostGIS {@code ST_GeomFromText} 입력용 WKT 조립(닫힌 보정)</li>
* </ul>
*
* <p>순수 좌표 변환만 수행하며 민감정보를 다루지 않는다. 파싱 실패 요약 예외만 던진다(스택트레이스 미노출).
*/
public final class GeometryCodec {
private GeometryCodec() {
}
/** GeoJSON Polygon 문자열 → 외곽 링 좌표열 [[x,y], ...]. null/오류 시 빈 리스트. */
public static List<List<Double>> 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<List<Double>> 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<List<Double>> ring) {
if (ring == null || ring.size() < 3) {
throw new ApiException(ErrorCode.VALIDATION, "부스 폴리곤은 최소 3개 정점이 필요합니다.");
}
List<List<Double>> closed = new ArrayList<>(ring);
List<Double> first = closed.get(0);
List<Double> 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<Double> 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<Double> centroid(List<List<Double>> ring) {
if (ring == null || ring.isEmpty()) {
return null;
}
double sx = 0;
double sy = 0;
int n = 0;
for (List<Double> 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<List<Double>> toCoordList(JsonNode arr) {
List<List<Double>> 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<Double> a, List<Double> 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;
}
}

View File

@ -4,6 +4,7 @@ import com.zioinfo.kintex.auth.EventAccessGuard;
import com.zioinfo.kintex.auth.EventRole; import com.zioinfo.kintex.auth.EventRole;
import com.zioinfo.kintex.auth.KintexPrincipal; import com.zioinfo.kintex.auth.KintexPrincipal;
import com.zioinfo.kintex.common.ApiResponse; 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.AutoLayoutOption;
import com.zioinfo.kintex.module.m2.dto.AutoLayoutRequest; import com.zioinfo.kintex.module.m2.dto.AutoLayoutRequest;
import com.zioinfo.kintex.module.m2.dto.LayoutDto; import com.zioinfo.kintex.module.m2.dto.LayoutDto;
@ -71,4 +72,14 @@ public class FloorplanController {
guard.requireRole(principal, eventId, EventRole.ORGANIZER); guard.requireRole(principal, eventId, EventRole.ORGANIZER);
return ApiResponse.ok(service.autoGenerate(eventId, hallId, request)); return ApiResponse.ok(service.autoGenerate(eventId, hallId, request));
} }
/** POST /apply-option — 자동배치 안 선택/병합 적용(주최자). 새 버전 저장 + 규정 재검증. */
@PostMapping("/apply-option")
public ApiResponse<LayoutDto> 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));
}
} }

View File

@ -1,5 +1,6 @@
package com.zioinfo.kintex.module.m2; 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.AutoLayoutOption;
import com.zioinfo.kintex.module.m2.dto.AutoLayoutRequest; import com.zioinfo.kintex.module.m2.dto.AutoLayoutRequest;
import com.zioinfo.kintex.module.m2.dto.LayoutDto; import com.zioinfo.kintex.module.m2.dto.LayoutDto;
@ -20,6 +21,9 @@ public interface FloorplanService {
/** 규정 검증 실행(통로 폭·바닥하중·비상구·복층 등) — 차단/경고 리포트. */ /** 규정 검증 실행(통로 폭·바닥하중·비상구·복층 등) — 차단/경고 리포트. */
ComplianceReport validate(String eventId, String hallId, Integer version); ComplianceReport validate(String eventId, String hallId, Integer version);
/** AI 자동배치 — 조건 입력 → 복수 배치안 후보(제약 솔버/휴리스틱). */ /** AI 자동배치 — 조건 입력 → 정확히 optionCount개 배치안 후보(제약 기반 배치 + S7 발행). */
List<AutoLayoutOption> autoGenerate(String eventId, String hallId, AutoLayoutRequest request); List<AutoLayoutOption> autoGenerate(String eventId, String hallId, AutoLayoutRequest request);
/** 자동배치 안 적용/병합 → 새 배치안 버전으로 저장 후 규정 재검증(선택/병합→검증 흐름 마감). */
LayoutDto applyOption(String eventId, String hallId, ApplyOptionRequest request);
} }

View File

@ -1,62 +1,421 @@
package com.zioinfo.kintex.module.m2; 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.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.AutoLayoutOption;
import com.zioinfo.kintex.module.m2.dto.AutoLayoutRequest; 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.LayoutDto;
import com.zioinfo.kintex.module.m2.dto.LayoutSaveRequest; 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.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.ComplianceReport;
import com.zioinfo.kintex.rules.ComplianceRuleEngine; 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.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map;
/** /**
* M2 서비스 골격. 규정 엔진({@link ComplianceRuleEngine}) 완성되어 있고, * M2 플로어플랜 서비스 BoothMapper(PostGIS)·HallMapper 배선 완료.
* 공간 영속/공간 산출({@link BoothMapper} PostGIS XML) kintex-db-engineer 구현 대기 해당 경로 501. * 배치 조회/저장(폴리곤 반영)·규정 검증(통로 ·비상구·높이·바닥하중 공간 산출)·자동배치 3안 생성/적용을 수행한다.
* *
* <p>완성 validate() 다음처럼 조립한다: * <p>규정 엔진({@link ComplianceRuleEngine}) 순수 평가기 서비스가 PostGIS 산출값으로 metrics를 조립한다.
* <pre> * 위반 항목에는 도면 매핑용 boothId/anchor(계약 확장) 부스 단위 규칙에 한해 부여한다.
* Map&lt;String,Object&gt; metrics = new HashMap&lt;&gt;();
* 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);
* </pre>
*/ */
@Service @Service
public class FloorplanServiceImpl implements FloorplanService { public class FloorplanServiceImpl implements FloorplanService {
private final BoothMapper boothMapper; private static final Logger log = LoggerFactory.getLogger(FloorplanServiceImpl.class);
private final ComplianceRuleEngine ruleEngine;
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.boothMapper = boothMapper;
this.hallMapper = hallMapper;
this.ruleEngine = ruleEngine; this.ruleEngine = ruleEngine;
this.renderJobService = renderJobService;
this.objectMapper = objectMapper;
} }
// ---------------------------------------------------------------- 조회 -----
@Override @Override
public LayoutDto getLayout(String eventId, String hallId, Integer version) { public LayoutDto getLayout(String eventId, String hallId, Integer version) {
// TODO(kintex-db-engineer): BoothMapper.findLayout + findBooths(ST_AsGeoJSON) 조립. Map<String, Object> header = boothMapper.findLayout(eventId, hallId, version);
throw ApiException.notImplemented("배치안 조회(공간 데이터)"); 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 @Override
public LayoutDto saveLayout(String eventId, String hallId, LayoutSaveRequest request) { @Transactional
// TODO(kintex-db-engineer): upsertLayout + replaceBooths(ST_MakePolygon), 저장 validate 재산출. public LayoutDto applyOption(String eventId, String hallId, ApplyOptionRequest request) {
throw ApiException.notImplemented("배치 저장(PostGIS 폴리곤 반영)"); // 적용/병합은 항상 버전으로 저장(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<BoothDto> booths) {
int version = requestedVersion != null ? requestedVersion : nextVersion(eventId, hallId);
Map<String, Object> 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<Map<String, Object>> boothRows = new ArrayList<>();
for (BoothDto b : booths) {
Map<String, Object> 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<String, Object> 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 @Override
public ComplianceReport validate(String eventId, String hallId, Integer version) { public ComplianceReport validate(String eventId, String hallId, Integer version) {
// TODO(kintex-db-engineer): BoothMapper 공간 산출값으로 metrics 조립 ruleEngine.evaluate("M2", ...). Map<String, Object> header = boothMapper.findLayout(eventId, hallId, version);
throw ApiException.notImplemented("배치 규정 검증(통로 폭·비상구 공간 산출)"); 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<BoothDto> booths) {
Map<String, Object> 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<Violation> 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<Violation> attachAnchors(List<Violation> violations, BoothDto heightBooth, BoothDto loadBooth) {
if (violations == null || violations.isEmpty()) {
return violations;
}
List<Violation> 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 @Override
public List<AutoLayoutOption> autoGenerate(String eventId, String hallId, AutoLayoutRequest request) { public List<AutoLayoutOption> autoGenerate(String eventId, String hallId, AutoLayoutRequest request) {
// TODO(BE): 제약 솔버/휴리스틱 배치 엔진 + S7 RenderJob 발행(M5-1). 규격/트렌치 좌표 필요(D-2). HallInfo hall = loadHallInfo(hallId);
throw ApiException.notImplemented("AI 자동배치(제약 솔버 + S7 발행)"); 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<AutoLayoutOption> options = new ArrayList<>();
for (int i = 0; i < optionCount; i++) {
double[] vp = variants[i % variants.length];
char tag = (char) ('A' + i);
List<BoothDto> 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<BoothDto> packBooths(double boothW, double boothD, double aisle,
double hallW, double hallD, AutoLayoutRequest req, char tag) {
List<BoothDto> 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<List<Double>> 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<String, Object> scene = new LinkedHashMap<>();
Map<String, Object> 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<String, Object> header) {
String layoutId = str(header.get("layoutId"));
List<BoothDto> 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<BoothDto> loadBooths(String layoutId) {
List<Map<String, Object>> rows = boothMapper.findBooths(layoutId);
List<BoothDto> out = new ArrayList<>();
if (rows == null) {
return out;
}
for (Map<String, Object> r : rows) {
List<List<Double>> polygon = GeometryCodec.parsePolygonRing(objectMapper, str(r.get("polygonJson")));
Double sw = dbl(r.get("sizeW"));
Double sd = dbl(r.get("sizeD"));
List<Double> 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<String, Object> h = hallMapper.findHall(hallId);
if (h == null) {
return null;
}
Double w = dbl(h.get("widthM"));
Double d = dbl(h.get("depthM"));
List<Double> dims = w != null && d != null ? List.of(w, d) : null;
List<HallInfo.Trench> trenches = new ArrayList<>();
List<Map<String, Object>> tr = hallMapper.findTrenches(hallId);
if (tr != null) {
for (Map<String, Object> t : tr) {
List<String> 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<HallInfo.Exit> exits = new ArrayList<>();
List<Map<String, Object>> ex = hallMapper.findExits(hallId);
if (ex != null) {
for (Map<String, Object> 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<String, Object> latest = boothMapper.findLayout(eventId, hallId, null);
if (latest == null) {
return 1;
}
return intVal(latest.get("version")) + 1;
}
// --------------------------------------------------------- 좌표/형변환 -----
private static List<List<Double>> 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<BoothDto> booths, java.util.function.Function<BoothDto, Double> 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();
} }
} }

View File

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

View File

@ -2,12 +2,15 @@ package com.zioinfo.kintex.module.m2.dto;
import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude;
import java.util.List;
/** /**
* 자동 생성 배치안 후보(SCR-04 비교 카드). * 자동 생성 배치안 후보(SCR-04 비교 카드).
* *
* @param optionId 후보 식별자 * @param optionId 후보 식별자
* @param label 라벨("배치안 A/B/C") * @param label 라벨("배치안 A/B/C")
* @param summary 지표 요약 * @param summary 지표 요약
* @param booths 후보 부스 배열 "이 안으로 편집 시작"/병합 소스(계약 확장, 프론트 gap #3 해소, NON_NULL)
* @param s7RenderJobId S7 전경(조감) 생성 ID 완료 WebSocket 푸시로 카드 이미지 교체 * @param s7RenderJobId S7 전경(조감) 생성 ID 완료 WebSocket 푸시로 카드 이미지 교체
*/ */
@JsonInclude(JsonInclude.Include.NON_NULL) @JsonInclude(JsonInclude.Include.NON_NULL)
@ -15,6 +18,7 @@ public record AutoLayoutOption(
String optionId, String optionId,
String label, String label,
LayoutSummary summary, LayoutSummary summary,
List<BoothDto> booths,
String s7RenderJobId String s7RenderJobId
) { ) {
} }

View File

@ -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<Double> dimsM,
Double ceilingM,
Integer targetBoothCount,
Double floorLoadTPerM2,
boolean assumedTrench,
List<Trench> trenches,
List<Exit> 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<String> supplies) {
}
/**
* 비상구 포인트 + 접근 확보 버퍼.
*
* @param x 로컬 X(미터)
* @param y 로컬 Y(미터)
* @param clearanceM 비상구 접근 확보 버퍼(미터)
*/
public record Exit(double x, double y, double clearanceM) {
}
}

View File

@ -14,6 +14,7 @@ import java.util.List;
* @param name 배치안 이름(: "배치안 B") * @param name 배치안 이름(: "배치안 B")
* @param status 상태 배지(draft/submitted/ai_review/approved/rejected) design.md StatusBadge * @param status 상태 배지(draft/submitted/ai_review/approved/rejected) design.md StatusBadge
* @param booths 부스 목록 * @param booths 부스 목록
* @param hall 마스터 정보(치수·트렌치·비상구) 캔버스 축척·오버레이 원천(계약 확장, NON_NULL)
* @param summary 지표 요약 * @param summary 지표 요약
* @param updatedAt 최종 수정 시각(ISO-8601) * @param updatedAt 최종 수정 시각(ISO-8601)
*/ */
@ -26,6 +27,7 @@ public record LayoutDto(
String name, String name,
String status, String status,
List<BoothDto> booths, List<BoothDto> booths,
HallInfo hall,
LayoutSummary summary, LayoutSummary summary,
String updatedAt String updatedAt
) { ) {

View File

@ -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) 조회하며 마이그레이션을 변경하지 않는다.
*
* <p>backend-dev 추가 매퍼(db-engineer 계약 §3 ) 기존 테이블 read-only. 계약 갱신 기록: {@code _workspace}.
*/
@Mapper
public interface HallMapper {
/** 홀 마스터 헤더(치수·천장·목표 부스 수·바닥하중·가정 트렌치 여부). 없으면 null. */
Map<String, Object> findHall(@Param("hallId") String hallId);
/** 홀 트렌치 좌표 목록(가정 그리드 포함) — 캔버스 오버레이·배선 참조용. */
List<Map<String, Object>> findTrenches(@Param("hallId") String hallId);
/** 홀 비상구 좌표·이격 목록 — 캔버스 비상구 표시용. */
List<Map<String, Object>> findExits(@Param("hallId") String hallId);
}

View File

@ -1,6 +1,9 @@
package com.zioinfo.kintex.module.m3; 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.ApiException;
import com.zioinfo.kintex.common.error.ErrorCode;
import com.zioinfo.kintex.module.m3.dto.DesignPlanDto; import com.zioinfo.kintex.module.m3.dto.DesignPlanDto;
import com.zioinfo.kintex.module.m3.dto.DesignSaveRequest; import com.zioinfo.kintex.module.m3.dto.DesignSaveRequest;
import com.zioinfo.kintex.module.m3.dto.DesignSpec; 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.ComplianceReport;
import com.zioinfo.kintex.rules.ComplianceRuleEngine; import com.zioinfo.kintex.rules.ComplianceRuleEngine;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.HashMap; import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map; import java.util.Map;
/** /**
* M3 서비스. <b>precheck() 완성 구현</b>(설계 스펙 엔진 평가). 설계 영속은 매퍼 대기(501). * M3 서비스 DesignMapper 배선 완료. 설계 조회/저장(spec jsonb)·규정 사전검증(precheck) 수행.
* precheck는 스펙만으로 즉시 평가(무영속), 저장은 버전 upsert 최신본을 반환한다.
*/ */
@Service @Service
public class DesignServiceImpl implements DesignService { public class DesignServiceImpl implements DesignService {
private final DesignMapper designMapper; private final DesignMapper designMapper;
private final ComplianceRuleEngine ruleEngine; 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.designMapper = designMapper;
this.ruleEngine = ruleEngine; this.ruleEngine = ruleEngine;
this.objectMapper = objectMapper;
} }
@Override @Override
public DesignPlanDto getDesign(String boothId, Integer version) { public DesignPlanDto getDesign(String boothId, Integer version) {
// TODO(kintex-db-engineer): DesignMapper.findDesign + spec(jsonb) 역직렬화. Map<String, Object> row = designMapper.findDesign(boothId, version);
throw ApiException.notImplemented("설계안 조회"); if (row == null) {
throw new ApiException(ErrorCode.NOT_FOUND, "설계안을 찾을 수 없습니다.");
}
return toDto(row);
} }
@Override @Override
@Transactional
public DesignPlanDto saveDesign(String boothId, DesignSaveRequest request) { public DesignPlanDto saveDesign(String boothId, DesignSaveRequest request) {
// TODO(kintex-db-engineer): DesignMapper.upsertDesign(spec jsonb). 저장 precheck 상태 반영. int version = request.version() != null ? request.version() : nextVersion(boothId);
throw ApiException.notImplemented("설계 저장"); Map<String, Object> 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<String, Object> row = designMapper.findDesign(boothId, version);
if (row == null) {
throw new ApiException(ErrorCode.INTERNAL, "설계안 저장 결과를 확인할 수 없습니다.");
}
return toDto(row);
}
private int nextVersion(String boothId) {
Map<String, Object> 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<String, Object> 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 @Override

View File

@ -1,11 +1,16 @@
package com.zioinfo.kintex.module.m4; 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.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.UtilityOrderDto;
import com.zioinfo.kintex.module.m4.dto.UtilityQuote; import com.zioinfo.kintex.module.m4.dto.UtilityQuote;
import com.zioinfo.kintex.module.m4.dto.UtilityQuoteRequest; import com.zioinfo.kintex.module.m4.dto.UtilityQuoteRequest;
import com.zioinfo.kintex.module.m4.dto.WiringRequest; import com.zioinfo.kintex.module.m4.dto.WiringRequest;
import com.zioinfo.kintex.module.m4.dto.WiringResult; 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.module.m4.mapper.WiringMapper;
import com.zioinfo.kintex.rules.RateRuleSet; import com.zioinfo.kintex.rules.RateRuleSet;
import com.zioinfo.kintex.rules.RuleSetLoader; import com.zioinfo.kintex.rules.RuleSetLoader;
@ -14,9 +19,11 @@ import org.springframework.stereotype.Service;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID;
/** /**
* M4 서비스. <b>quote() 완성 구현</b>(요율 룰셋 기반 산술). 배선/신청/위치표시도는 매퍼·서식 대기(501). * M4 서비스 quote(요율 룰셋)·computeWiring(WiringMapper PostGIS)·신청 영속(UtilityOrderMapper) 배선 완료.
* 위치표시도 PDF 실제 렌더는 문서 (M4-3, 후속)이며, 여기서는 신청을 영속하고 자산 URL·마감/릴레이 고지를 반환한다.
*/ */
@Service @Service
public class UtilityServiceImpl implements UtilityService { public class UtilityServiceImpl implements UtilityService {
@ -26,10 +33,15 @@ public class UtilityServiceImpl implements UtilityService {
private final RuleSetLoader ruleSets; private final RuleSetLoader ruleSets;
private final WiringMapper wiringMapper; 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.ruleSets = ruleSets;
this.wiringMapper = wiringMapper; this.wiringMapper = wiringMapper;
this.orderMapper = orderMapper;
this.objectMapper = objectMapper;
} }
@Override @Override
@ -84,20 +96,122 @@ public class UtilityServiceImpl implements UtilityService {
@Override @Override
public WiringResult computeWiring(String eventId, String hallId, String boothId, WiringRequest request) { public WiringResult computeWiring(String eventId, String hallId, String boothId, WiringRequest request) {
// TODO(kintex-db-engineer): WiringMapper.findNearestTrenches + shortestPath(PostGIS) 조립. Boolean assumed = wiringMapper.isAssumedTrench(hallId);
throw ApiException.notImplemented("배선 산출(트렌치 최단 경로 PostGIS)"); boolean assumedTrench = assumed == null || assumed;
List<WiringResult.WiringPath> 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<String, Object> path = wiringMapper.shortestPath(hallId, fromX, fromY, kind);
if (path == null) {
continue; // 해당 kind 공급 트렌치 없음
}
List<List<Double>> 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 @Override
public UtilityOrderDto submitOrder(String eventId, String boothId, UtilityQuoteRequest request) { public UtilityOrderDto submitOrder(String eventId, String boothId, UtilityQuoteRequest request) {
// TODO(BE/DB): 신청 영속(UtilityOrder) + 위치표시도 PDF 생성(M4-3, 서류 ) + 릴레이 안내. UtilityQuote quote = quote(request);
throw ApiException.notImplemented("유틸리티 신청 제출 + 위치표시도 생성"); String orderId = "uo-" + UUID.randomUUID();
// 위치표시도 PDF 실제 렌더는 문서 (M4-3, 후속) 자산 조회 경로만 안정적으로 제공.
String locationDiagramUrl = "/api/events/" + eventId + "/booths/" + boothId
+ "/utility/location-diagram";
String supplyTiming = "장치 마지막 날 오후";
Map<String, Object> 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 @Override
public UtilityOrderDto getOrder(String eventId, String boothId) { public UtilityOrderDto getOrder(String eventId, String boothId) {
// TODO(kintex-db-engineer): UtilityOrder 조회 매퍼. Map<String, Object> row = orderMapper.findOrder(eventId, boothId);
throw ApiException.notImplemented("유틸리티 신청 조회"); 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;
} }
// --- 요율 탐색 유틸 --- // --- 요율 탐색 유틸 ---

View File

@ -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) 사용하며 마이그레이션을 변경하지 않는다.
*
* <p>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<String, Object> params);
/** 부스 단위 최신 신청 조회. 없으면 null. */
Map<String, Object> findOrder(@Param("eventId") String eventId,
@Param("boothId") String boothId);
}

View File

@ -32,7 +32,6 @@ public class RenderJobServiceImpl implements RenderJobService {
private static final Logger log = LoggerFactory.getLogger(RenderJobServiceImpl.class); private static final Logger log = LoggerFactory.getLogger(RenderJobServiceImpl.class);
private static final String JOB_KEY_PREFIX = "kintex:renderjob:job:"; 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 StringRedisTemplate redis;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
@ -52,9 +51,8 @@ public class RenderJobServiceImpl implements RenderJobService {
@Override @Override
public RenderJobDto publish(String eventId, String boothId, RenderJobRequest request) { public RenderJobDto publish(String eventId, String boothId, RenderJobRequest request) {
// 쿼터 확인 성공 건수 카운터(성공 시에만 차감되므로 발행 시엔 증가시키지 않음). // 쿼터 정본 = DB 성공(DONE) 건수(RenderJobMapper.countSucceededByEvent). 성공 시에만 차감(§6-5).
String quotaKey = QUOTA_KEY_PREFIX + eventId; long succeeded = renderJobMapper.countSucceededByEvent(eventId);
long succeeded = readLong(redis.opsForValue().get(quotaKey));
if (succeeded >= props.getEventQuotaDefault()) { if (succeeded >= props.getEventQuotaDefault()) {
throw new ApiException(ErrorCode.RENDER_QUOTA_EXCEEDED, throw new ApiException(ErrorCode.RENDER_QUOTA_EXCEEDED,
"행사 이미지 생성 쿼터(" + props.getEventQuotaDefault() + ")를 소진했습니다."); "행사 이미지 생성 쿼터(" + props.getEventQuotaDefault() + ")를 소진했습니다.");
@ -83,11 +81,20 @@ public class RenderJobServiceImpl implements RenderJobService {
throw new ApiException(ErrorCode.INTERNAL, "RenderJob 직렬화에 실패했습니다."); throw new ApiException(ErrorCode.INTERNAL, "RenderJob 직렬화에 실패했습니다.");
} }
// TODO(kintex-db-engineer): RenderJobMapper.insertJob 으로 내구 이력 기록(갤러리·감사).
RenderJobDto dto = new RenderJobDto(jobId, boothId, request.shotPreset(), RenderJobDto dto = new RenderJobDto(jobId, boothId, request.shotPreset(),
RenderJobStatus.QUEUED.name(), null, null, null, RenderJobStatus.QUEUED.name(), null, null, null,
true, RenderJobDto.WATERMARK_TEXT, RenderJobDto.NOTICE, null, now); true, RenderJobDto.WATERMARK_TEXT, RenderJobDto.NOTICE, null, now);
saveState(dto); saveState(dto);
// 내구 이력 기록(갤러리·쿼터 정본). Redis /실시간, DB 이력의 정본.
Map<String, Object> 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()); log.info("RenderJob 발행: job={} booth={} shot={}", jobId, boothId, request.shotPreset());
return dto; return dto;
} }
@ -103,8 +110,20 @@ public class RenderJobServiceImpl implements RenderJobService {
@Override @Override
public List<RenderJobDto> listByBooth(String boothId, String shotPreset) { public List<RenderJobDto> listByBooth(String boothId, String shotPreset) {
// TODO(kintex-db-engineer): RenderJobMapper.findByBooth 내구 이력 목록(SCR-12) 조립. List<Map<String, Object>> rows = renderJobMapper.findByBooth(boothId, shotPreset);
throw ApiException.notImplemented("시각화 갤러리 목록(내구 이력)"); List<RenderJobDto> out = new java.util.ArrayList<>();
if (rows == null) {
return out;
}
for (Map<String, Object> 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 @Override
@ -125,8 +144,9 @@ public class RenderJobServiceImpl implements RenderJobService {
prev.createdAt()); prev.createdAt());
saveState(updated); saveState(updated);
// 성공 시에만 쿼터 차감(PLANNING §6-5). eventId는 상태에 미보관 내구 저장 정본화(매퍼). // 내구 상태 갱신 쿼터 정본은 DB의 DONE 건수(countSucceededByEvent)에서 자동 반영(별도 카운터 불필요).
// TODO(kintex-db-engineer): RenderJobMapper.updateStatus + 성공 행사 쿼터 카운터 증가. renderJobMapper.updateStatus(updated.jobId(), status, updated.imageUrl(), updated.errorMessage());
notifier.pushJobUpdate(updated); notifier.pushJobUpdate(updated);
log.info("RenderJob 콜백: job={} status={}", updated.jobId(), status); log.info("RenderJob 콜백: job={} status={}", updated.jobId(), status);
return updated; return updated;
@ -153,13 +173,8 @@ public class RenderJobServiceImpl implements RenderJobService {
} }
} }
private static long readLong(String s) { private static String str(Object o) {
if (s == null) return 0L; return o == null ? null : String.valueOf(o);
try {
return Long.parseLong(s);
} catch (NumberFormatException e) {
return 0L;
}
} }
/** 워커 에러 메시지는 요약만 통과(스택트레이스·민감정보 유입 방지). */ /** 워커 에러 메시지는 요약만 통과(스택트레이스·민감정보 유입 방지). */

View File

@ -2,6 +2,8 @@ package com.zioinfo.kintex.rules;
import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude;
import java.util.List;
/** /**
* 규정 위반/확인 항목 도면 번호 핀과 1:1 연결(design.md ViolationFlag). * 규정 위반/확인 항목 도면 번호 핀과 1:1 연결(design.md ViolationFlag).
* *
@ -12,6 +14,8 @@ import com.fasterxml.jackson.annotation.JsonInclude;
* @param severity "block"(차단·제출 불가) | "warn"(경고·확인 진행) * @param severity "block"(차단·제출 불가) | "warn"(경고·확인 진행)
* @param measured 측정값 요약(: "5.4m — 0.4m 초과"). 미제공 null * @param measured 측정값 요약(: "5.4m — 0.4m 초과"). 미제공 null
* @param requiresDocument 부속 제출 요구(: STRUCTURAL_CALC_D7). 없으면 null * @param requiresDocument 부속 제출 요구(: STRUCTURAL_CALC_D7). 없으면 null
* @param boothId 위반 부스 식별자(부스 단위 규칙만) 캔버스 하이라이트 매핑(계약 확장, NON_NULL)
* @param anchor 도면 하이라이트 앵커 좌표 [x,y] (미터) 위치(계약 확장, NON_NULL)
*/ */
@JsonInclude(JsonInclude.Include.NON_NULL) @JsonInclude(JsonInclude.Include.NON_NULL)
public record Violation( public record Violation(
@ -21,6 +25,19 @@ public record Violation(
String label, String label,
String severity, String severity,
String measured, String measured,
String requiresDocument String requiresDocument,
String boothId,
List<Double> 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<Double> anchor) {
return new Violation(pin, code, group, label, severity, measured, requiresDocument, boothId, anchor);
}
} }

View File

@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<!--
HallMapper — 홀 마스터·트렌치·비상구 조회(계약 확장, read-only). SRID 0 = 홀 로컬 평면(미터).
★backend-dev 추가(db-engineer 계약 §3 밖). 기존 테이블(hall·trench·hall_exit)만 조회, 마이그레이션 불변.
-->
<mapper namespace="com.zioinfo.kintex.module.m2.mapper.HallMapper">
<!-- 홀 마스터 헤더. -->
<select id="findHall" resultType="map">
SELECT id AS "hallId",
label,
width_m AS "widthM",
depth_m AS "depthM",
height_m AS "heightM",
floor_load_t_per_m2 AS "floorLoadTPerM2",
booth_capacity AS "boothCapacity",
has_gas AS "hasGas",
is_assumed_trench AS "assumedTrench"
FROM hall
WHERE id = #{hallId}
</select>
<!-- 홀 트렌치 좌표(가정 그리드 포함). -->
<select id="findTrenches" resultType="map">
SELECT id AS "trenchId",
ST_X(geom) AS "x",
ST_Y(geom) AS "y",
supply_power AS "supplyPower",
supply_water AS "supplyWater",
supply_air AS "supplyAir",
supply_network AS "supplyNetwork",
supply_gas AS "supplyGas",
is_assumed AS "assumed"
FROM trench
WHERE hall_id = #{hallId}
ORDER BY id
</select>
<!-- 홀 비상구 좌표·이격. -->
<select id="findExits" resultType="map">
SELECT id AS "exitId",
ST_X(geom) AS "x",
ST_Y(geom) AS "y",
clearance_m AS "clearanceM"
FROM hall_exit
WHERE hall_id = #{hallId}
ORDER BY id
</select>
</mapper>

View File

@ -36,4 +36,11 @@
ORDER BY e.start_date ORDER BY e.start_date
</select> </select>
<!-- 사업자등록번호 → 등록업체 여부(CONTRACTOR 초대 게이트). 없으면 null. read-only. -->
<select id="findCompanyRegistered" resultType="boolean">
SELECT registered
FROM company
WHERE registration_no = #{registrationNo}
</select>
</mapper> </mapper>

View File

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<!--
UtilityOrderMapper — M4 유틸리티 신청 영속(utility_order, V3). quote 는 jsonb, wiring 은 후속(문서 큐).
★backend-dev 추가(db-engineer 계약 §3 밖). 기존 테이블만 사용, 마이그레이션 불변.
-->
<mapper namespace="com.zioinfo.kintex.module.m4.mapper.UtilityOrderMapper">
<!-- 신청 저장(재제출 시 신규 접수번호로 이력 축적). -->
<insert id="insertOrder">
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())
</insert>
<!-- 부스 단위 최신 신청. -->
<select id="findOrder" resultType="map">
SELECT id AS "orderId",
status,
quote::text AS "quoteJson",
location_diagram_url AS "locationDiagramUrl",
supply_timing AS "supplyTiming"
FROM utility_order
WHERE event_id = #{eventId}
AND booth_id = #{boothId}
ORDER BY created_at DESC
LIMIT 1
</select>
</mapper>

View File

@ -2,6 +2,10 @@ import { Navigate, Route, Routes } from 'react-router-dom';
import { useAuthStore } from './store/authStore'; import { useAuthStore } from './store/authStore';
import { LoginPage } from './screens/login/LoginPage'; import { LoginPage } from './screens/login/LoginPage';
import { BoothLayoutEditorPage } from './screens/floorplan/BoothLayoutEditorPage'; 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'; import { AppShell } from './components/layout/AppShell';
/** 인증 가드 — 미인증 시 로그인으로. (역할별 라우팅은 화면 추가 시 확장) */ /** 인증 가드 — 미인증 시 로그인으로. (역할별 라우팅은 화면 추가 시 확장) */
@ -24,12 +28,23 @@ export function App() {
</RequireAuth> </RequireAuth>
} }
> >
{/* SCR-02 주최자 대시보드 */}
<Route path="/events/:eventId/dashboard" element={<OrganizerDashboardPage />} />
{/* SCR-03 부스 배치 에디터 (M2) */} {/* SCR-03 부스 배치 에디터 (M2) */}
<Route <Route
path="/events/:eventId/halls/:hallId/layout" path="/events/:eventId/halls/:hallId/layout"
element={<BoothLayoutEditorPage />} element={<BoothLayoutEditorPage />}
/> />
{/* 향후: SCR-02 대시보드, SCR-05 부스홈, SCR-10 승인큐 등 */} {/* SCR-06 부스 설계 스튜디오 (M3+M5) */}
<Route path="/events/:eventId/booths/:boothId/design" element={<BoothDesignStudioPage />} />
{/* SCR-07 유틸리티 배선 뷰 (M4a+M4b) */}
<Route path="/events/:eventId/booths/:boothId/utility" element={<UtilityWiringPage />} />
{/* SCR-12 시각화 갤러리 (M5) */}
<Route
path="/events/:eventId/booths/:boothId/gallery"
element={<VisualizationGalleryPage />}
/>
{/* 향후: SCR-05 부스홈, SCR-10 승인큐 등 */}
</Route> </Route>
<Route path="*" element={<Navigate to="/login" replace />} /> <Route path="*" element={<Navigate to="/login" replace />} />

View File

@ -8,12 +8,21 @@ import type {
AutoLayoutOption, AutoLayoutOption,
AutoLayoutRequest, AutoLayoutRequest,
ComplianceReport, ComplianceReport,
DesignPlanDto,
DesignSaveRequest,
DesignSpec,
KintexPrincipal, KintexPrincipal,
LayoutDto, LayoutDto,
LayoutSaveRequest, LayoutSaveRequest,
LoginRequest, LoginRequest,
LoginResponse, LoginResponse,
RenderJobDto, RenderJobDto,
RenderJobRequest,
UtilityOrderDto,
UtilityQuote,
UtilityQuoteRequest,
WiringRequest,
WiringResult,
WorkspaceDto, WorkspaceDto,
} from './types'; } from './types';
@ -46,10 +55,57 @@ export const layoutApi = {
api.post<AutoLayoutOption[]>(`${layoutBase(eventId, hallId)}/auto-generate`, body), api.post<AutoLayoutOption[]>(`${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<DesignPlanDto>(
`${designBase(eventId, boothId)}${version != null ? `?version=${version}` : ''}`,
),
save: (eventId: string, boothId: string, body: DesignSaveRequest) =>
api.put<DesignPlanDto>(designBase(eventId, boothId), body),
// precheck는 스펙을 직접 받아 즉시 룰 평가(계약 §4 — 완성 엔드포인트).
precheck: (eventId: string, boothId: string, spec: DesignSpec) =>
api.post<ComplianceReport>(`${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<UtilityQuote>(`${utilityBase(eventId, boothId)}/quote`, body),
wiring: (eventId: string, boothId: string, hallId: string, body: WiringRequest) =>
api.post<WiringResult>(
`${utilityBase(eventId, boothId)}/wiring?hallId=${encodeURIComponent(hallId)}`,
body,
),
order: (eventId: string, boothId: string, body: UtilityQuoteRequest) =>
api.post<UtilityOrderDto>(`${utilityBase(eventId, boothId)}/order`, body),
get: (eventId: string, boothId: string) =>
api.get<UtilityOrderDto>(utilityBase(eventId, boothId)),
};
// ── M5 RenderJob (SCR-06/12) ──
export const renderApi = { export const renderApi = {
status: (eventId: string, jobId: string) => status: (eventId: string, jobId: string) =>
api.get<RenderJobDto>( api.get<RenderJobDto>(
`/api/events/${encodeURIComponent(eventId)}/render-jobs/${encodeURIComponent(jobId)}`, `/api/events/${encodeURIComponent(eventId)}/render-jobs/${encodeURIComponent(jobId)}`,
), ),
// 갤러리 목록 (SCR-12) — shot 필터 선택.
list: (eventId: string, boothId: string, shot?: string) =>
api.get<RenderJobDto[]>(
`/api/events/${encodeURIComponent(eventId)}/booths/${encodeURIComponent(boothId)}/render-jobs${
shot ? `?shot=${encodeURIComponent(shot)}` : ''
}`,
),
// 발행 (SCR-06 예상 사진 생성) — 큐잉.
create: (eventId: string, boothId: string, body: RenderJobRequest) =>
api.post<RenderJobDto>(
`/api/events/${encodeURIComponent(eventId)}/booths/${encodeURIComponent(boothId)}/render`,
body,
),
}; };

View File

@ -147,8 +147,119 @@ export interface AutoLayoutOption {
s7RenderJobId: string; 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) ── // ── 6. M5 RenderJob (SCR-06/12) ──
export type RenderStatus = 'QUEUED' | 'RUNNING' | 'DONE' | 'FAILED'; 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 { export interface RenderJobDto {
jobId: string; jobId: string;
boothId: string; boothId: string;

View File

@ -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<HTMLDivElement>(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<HTMLButtonElement>) => {
draggingRef.current = true;
e.currentTarget.setPointerCapture(e.pointerId);
updateFromClientX(e.clientX);
},
[updateFromClientX],
);
const onPointerMove = useCallback(
(e: PointerEvent<HTMLButtonElement>) => {
if (!draggingRef.current) return;
updateFromClientX(e.clientX);
},
[updateFromClientX],
);
const onPointerUp = useCallback((e: PointerEvent<HTMLButtonElement>) => {
draggingRef.current = false;
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
}, []);
const onKeyDown = useCallback((e: KeyboardEvent<HTMLButtonElement>) => {
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 (
<div className="kx-compare" ref={frameRef}>
{/* Before (하단, 전체) */}
<img className="kx-compare__img" src={beforeSrc} alt={beforeAlt} draggable={false} />
{/* After (상단, 좌측부터 pos% 만큼 노출) */}
<div
className="kx-compare__after"
style={{ clipPath: `inset(0 ${100 - pos}% 0 0)` }}
aria-hidden={pos === 0}
>
<img className="kx-compare__img" src={afterSrc} alt={afterAlt} draggable={false} />
{afterOverlay}
</div>
<span className="kx-compare__label kx-compare__label--before" aria-hidden="true">
{beforeLabel}
</span>
<span className="kx-compare__label kx-compare__label--after" aria-hidden="true">
{afterLabel}
</span>
{/* 핸들 = ARIA slider */}
<button
type="button"
className="kx-compare__handle"
style={{ left: `${pos}%` }}
role="slider"
aria-label={`${beforeLabel}${afterLabel} 비교 슬라이더`}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(pos)}
aria-valuetext={`${afterLabel} ${Math.round(pos)}% 노출`}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onKeyDown={onKeyDown}
>
<span className="kx-compare__handle-grip" aria-hidden="true">
</span>
</button>
</div>
);
}

View File

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

View File

@ -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 (
<div className="kx-dash">
{/* 행사 헤더 */}
<header className="kx-dash__event">
<div>
<h1 className="kx-dash__event-name">{eventName}</h1>
<p className="kx-dash__event-meta">
{hallLabel} · {period}
</p>
</div>
<span className="kx-dash__countdown tnum"> D-{dday}</span>
</header>
{/* 히어로: 마일스톤 타임라인 */}
<section className="kx-dash__hero" aria-label="마일스톤 타임라인">
<ol className="kx-timeline">
{SAMPLE_MILESTONES.map((m, i) => (
<li key={m.key} className={`kx-timeline__node is-${m.state}`}>
{i < SAMPLE_MILESTONES.length - 1 && (
<span className="kx-timeline__bar" aria-hidden="true" />
)}
<span className="kx-timeline__dot" aria-hidden="true">
{m.state === 'done' ? '✓' : ''}
</span>
<div className="kx-timeline__label">
<span className="kx-timeline__d tnum">{m.dLabel}</span>
<span className="kx-timeline__title">{m.title}</span>
</div>
<div className="kx-timeline__card">
{m.progress ? (
<span className={m.state === 'imminent' ? 'kx-timeline__imminent' : ''}>
{m.progress}
</span>
) : (
<span className="kx-timeline__muted">{stateLabel(m)}</span>
)}
</div>
</li>
))}
</ol>
</section>
{/* KPI 카드 4개 */}
<section className="kx-dash__kpis" aria-label="핵심 지표">
{SAMPLE_KPIS.map((k) => (
<div key={k.key} className={`kx-kpi kx-kpi--${k.tone ?? 'normal'}`}>
<span className="kx-kpi__label">{k.label}</span>
<strong className="kx-kpi__value tnum">{k.value}</strong>
{k.sub && <span className="kx-kpi__sub">{k.sub}</span>}
</div>
))}
</section>
{/* 하단: 참가업체 테이블(2/3) + 알림·S7(1/3) */}
<div className="kx-dash__split">
<section className="kx-dash__table-wrap" aria-label="참가업체 현황">
<div className="kx-dash__section-head">
<h2> </h2>
<span className="kx-dash__degraded"> API ()</span>
</div>
<table className="kx-table">
<thead>
<tr>
<th></th>
<th></th>
<th> </th>
<th></th>
<th> </th>
</tr>
</thead>
<tbody>
{SAMPLE_EXHIBITORS.map((r) => (
<tr key={r.boothNo}>
<td className="tnum">{r.boothNo}</td>
<td>{r.company}</td>
<td>
<StatusBadge status={r.designStatus} />
</td>
<td>
<StatusBadge status={r.utilityStatus} />
</td>
<td>
<span className="kx-table__thumb" aria-hidden="true" />
</td>
</tr>
))}
</tbody>
</table>
</section>
<aside className="kx-dash__aside">
<section className="kx-feed" aria-label="알림 피드">
<h2 className="kx-feed__title"></h2>
<ul className="kx-feed__list">
{SAMPLE_FEED.map((f) => (
<li key={f.key} className={`kx-feed__item is-${f.kind}`}>
<span className="kx-feed__dot" aria-hidden="true" />
<span className="kx-feed__text">{f.text}</span>
<span className="kx-feed__time">{f.time}</span>
</li>
))}
</ul>
</section>
<section className="kx-dash__s7" aria-label="홀 전경 조감">
<div className="kx-dash__s7-head">
<h2> </h2>
<AiLabel>S7</AiLabel>
</div>
<AiImage
imageUrl={null}
status="QUEUED"
watermarkText="AI 생성 예상 이미지 — 실제 시공 결과와 다를 수 있습니다"
notice="AI 생성 이미지는 계약·심사 서류에 사용할 수 없습니다 — 시공 기준은 도면입니다"
shotLabel="S7 홀 전경"
alt="홀 전경 조감 예상 이미지"
/>
</section>
</aside>
</div>
</div>
);
}
function stateLabel(m: Milestone): string {
switch (m.state) {
case 'done':
return '완료';
case 'current':
return '진행 중';
case 'imminent':
return '임박';
default:
return '예정';
}
}

View File

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

View File

@ -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: '어제' },
];

View File

@ -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<DesignSpec>(DEFAULT_SPEC);
const [open, setOpen] = useState<Record<Section, boolean>>({
basic: true,
zones: false,
structure: true,
material: false,
lighting: false,
});
const [report, setReport] = useState<ComplianceReport | null>(null);
const [prechecking, setPrechecking] = useState(false);
const [jobs, setJobs] = useState<Record<string, RenderJobDto>>({}); // preset → job
const [generating, setGenerating] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const [confirmAgree, setConfirmAgree] = useState(false);
const [notice, setNotice] = useState<string | null>(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 (
<div className="kx-studio">
{/* 상단 컨텍스트 바 */}
<header className="kx-studio__head">
<div className="kx-studio__ctx">
<h2> {boothId || 'A-102'} · 6×3m</h2>
<span className="kx-studio__ctx-sub"> 5m · v2</span>
</div>
<Button variant="ai" leadingIcon="✦" onClick={generateShots} disabled={generating}>
{generating ? '생성 요청 중…' : '예상 사진 생성'}
</Button>
</header>
<div className="kx-studio__body">
{/* 좌: 스펙 아코디언 */}
<aside className="kx-studio__spec" aria-label="설계 스펙">
<Accordion
title="기본 정보"
open={open.basic}
onToggle={() => setOpen((o) => ({ ...o, basic: !o.basic }))}
>
<Field label="업종">
<input
value={spec.industry}
onChange={(e) => setSpec((s) => ({ ...s, industry: e.target.value }))}
/>
</Field>
<Field label="예산 (₩)">
<input
type="number"
className="tnum"
value={spec.budget}
onChange={(e) => setSpec((s) => ({ ...s, budget: Number(e.target.value) }))}
/>
</Field>
</Accordion>
<Accordion
title="공간 구성"
open={open.zones}
onToggle={() => setOpen((o) => ({ ...o, zones: !o.zones }))}
>
{spec.zones.map((z, i) => (
<Field key={z.type} label={ZONE_LABEL[z.type] ?? z.type}>
<div className="kx-studio__ratio">
<input
type="range"
min={0}
max={100}
value={z.ratioPercent}
aria-label={`${ZONE_LABEL[z.type] ?? z.type} 비율`}
onChange={(e) =>
setSpec((s) => ({
...s,
zones: s.zones.map((x, j) =>
j === i ? { ...x, ratioPercent: Number(e.target.value) } : x,
),
}))
}
/>
<span className="tnum">{z.ratioPercent}%</span>
</div>
</Field>
))}
</Accordion>
<Accordion
title="구조"
open={open.structure}
onToggle={() => setOpen((o) => ({ ...o, structure: !o.structure }))}
>
<Field label={`벽체 높이 (${spec.wallHeightM}m · 최대 5m)`}>
<input
type="range"
min={2}
max={6}
step={0.1}
value={spec.wallHeightM}
aria-label="벽체 높이"
onChange={(e) => setSpec((s) => ({ ...s, wallHeightM: Number(e.target.value) }))}
/>
{spec.wallHeightM > 5 && (
<p className="kx-studio__inline-warn"> 5m </p>
)}
</Field>
<Field label="간판 문구">
<input
value={spec.signageText}
onChange={(e) => setSpec((s) => ({ ...s, signageText: e.target.value }))}
/>
</Field>
<label className="kx-studio__toggle">
<input
type="checkbox"
checked={spec.rigging.use}
onChange={(e) =>
setSpec((s) => ({ ...s, rigging: { ...s.rigging, use: e.target.checked } }))
}
/>
/
</label>
{spec.rigging.use && (
<p className="kx-studio__inline-warn">
6.5~8.5m: 구조계산서 D-7
</p>
)}
</Accordion>
<Accordion
title="자재"
open={open.material}
onToggle={() => setOpen((o) => ({ ...o, material: !o.material }))}
>
<Field label="벽체 마감">
<select
value={spec.materials[0]?.finish}
onChange={(e) =>
setSpec((s) => ({
...s,
materials: [{ ...s.materials[0], finish: e.target.value }],
}))
}
>
<option value="matte_white"> </option>
<option value="wood"></option>
<option value="metal"></option>
</select>
</Field>
<label className="kx-studio__toggle kx-studio__toggle--req">
<input
type="checkbox"
checked={spec.materials[0]?.fireRetardant ?? false}
onChange={(e) =>
setSpec((s) => ({
...s,
materials: [{ ...s.materials[0], fireRetardant: e.target.checked }],
}))
}
/>
()
</label>
</Accordion>
<Accordion
title="조명"
open={open.lighting}
onToggle={() => setOpen((o) => ({ ...o, lighting: !o.lighting }))}
>
<div className="kx-studio__seg" role="radiogroup" aria-label="조명 모드">
{(['day', 'night'] as const).map((m) => (
<button
key={m}
role="radio"
aria-checked={spec.lightingMode === m}
className={spec.lightingMode === m ? 'is-active' : ''}
onClick={() => setSpec((s) => ({ ...s, lightingMode: m }))}
>
{m === 'day' ? '주간' : '야간'}
</button>
))}
</div>
</Accordion>
</aside>
{/* 중앙: 4샷 그리드 + Before/After */}
<section className="kx-studio__hero">
<div className="kx-studio__grid">
{SHOTS.map((shot) => {
const job = jobs[shot.preset];
return (
<div className="kx-studio__tile" key={shot.preset}>
{job ? (
<AiImage
imageUrl={job.imageUrl}
status={job.status}
watermarkText={job.watermarkText}
notice={job.notice}
shotLabel={shot.label}
versionTag="설계 v2"
onRetry={() => generateSingle(shot.preset)}
alt={`${shot.label} 예상 이미지`}
/>
) : (
<div className="kx-studio__tile-empty">
<span className="kx-studio__tile-label">{shot.label}</span>
<button onClick={() => generateSingle(shot.preset)}> </button>
</div>
)}
</div>
);
})}
</div>
{/* Before/After 슬라이더 (S5) */}
<div className="kx-studio__compare">
<div className="kx-studio__compare-head">
<AiLabel>S5 Before / After</AiLabel>
{!afterSrc && <span className="kx-studio__compare-note">S1 </span>}
</div>
<CompareSlider
beforeSrc={BEFORE_EMPTY_HALL}
afterSrc={afterSrc ?? AFTER_PLACEHOLDER}
beforeLabel="시공 전"
afterLabel="시공 후"
afterOverlay={
afterSrc ? (
<>
<div className="kx-studio__wm" aria-hidden="true">
{s1?.watermarkText ?? 'AI 생성 예상 이미지'}
</div>
<span className="kx-studio__after-ai">
<AiLabel>AI </AiLabel>
</span>
</>
) : undefined
}
/>
<p className="kx-studio__compare-notice">
{s1?.notice ??
'AI 생성 이미지는 계약·심사 서류에 사용할 수 없습니다 — 시공 기준은 도면입니다'}
</p>
</div>
</section>
</div>
{/* 하단 고정 바: 규정 사전검증 요약 + 컨펌 CTA */}
<footer className="kx-studio__footer">
<div className="kx-studio__precheck">
<Button variant="ghost" onClick={runPrecheck} disabled={prechecking}>
{prechecking ? '검증 중…' : '규정 사전검증'}
</Button>
{report ? (
<div className="kx-studio__chips tnum">
<span className="kx-chip kx-chip--pass"> {passCount}</span>
{warnCount > 0 && <span className="kx-chip kx-chip--warn"> {warnCount}</span>}
{blockCount > 0 && <span className="kx-chip kx-chip--block"> {blockCount}</span>}
{report.violations.some((v) => v.requiresDocument) && (
<span className="kx-chip kx-chip--doc"> D-7 </span>
)}
</div>
) : (
<span className="kx-studio__precheck-hint"> </span>
)}
</div>
<Button disabled={!!report && !submittable} onClick={() => setConfirmOpen(true)}>
</Button>
</footer>
{notice && (
<p className="kx-studio__banner" role="status">
{notice}
</p>
)}
{/* 컨펌 모달 — "시공 기준은 도면입니다" 동의 필수 */}
{confirmOpen && (
<div className="kx-modal" role="dialog" aria-modal="true" aria-labelledby="confirm-title">
<div className="kx-modal__backdrop" onClick={() => setConfirmOpen(false)} />
<div className="kx-studio__confirm">
<h3 id="confirm-title"> </h3>
<p>
. AI
.
</p>
<label className="kx-studio__toggle kx-studio__toggle--req">
<input
type="checkbox"
checked={confirmAgree}
onChange={(e) => setConfirmAgree(e.target.checked)}
/>
()
</label>
<div className="kx-modal__actions">
<Button variant="ghost" onClick={() => setConfirmOpen(false)}>
</Button>
<Button
disabled={!confirmAgree}
onClick={() => {
setConfirmOpen(false);
setNotice('컨펌 요청이 전송되었습니다.');
}}
>
</Button>
</div>
</div>
</div>
)}
</div>
);
}
const ZONE_LABEL: Record<string, string> = {
demo: '시연존',
consult: '상담존',
reception: '안내데스크',
storage: '창고',
};
function Accordion({
title,
open,
onToggle,
children,
}: {
title: string;
open: boolean;
onToggle: () => void;
children: React.ReactNode;
}) {
return (
<section className="kx-acc">
<button
className="kx-acc__head"
aria-expanded={open}
onClick={onToggle}
type="button"
>
<span>{title}</span>
<span className="kx-acc__chevron" aria-hidden="true">
{open ? '▾' : '▸'}
</span>
</button>
{open && <div className="kx-acc__body">{children}</div>}
</section>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="kx-field-block">
<span className="kx-field-block__label">{label}</span>
{children}
</label>
);
}

View File

@ -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(
`<svg xmlns='http://www.w3.org/2000/svg' width='640' height='360'>
<rect width='640' height='360' fill='#d9d5cf'/>
<g stroke='#b8b2a8' stroke-width='1'>
${Array.from({ length: 12 }, (_, i) => `<line x1='${i * 55}' y1='0' x2='${i * 55}' y2='360'/>`).join('')}
${Array.from({ length: 7 }, (_, i) => `<line x1='0' y1='${i * 55}' x2='640' y2='${i * 55}'/>`).join('')}
</g>
<text x='320' y='185' fill='#7a746a' font-family='sans-serif' font-size='18' text-anchor='middle'> · </text>
</svg>`,
);
/** 시공 후 자리 표시(S1 미생성). */
export const AFTER_PLACEHOLDER = svgDataUri(
`<svg xmlns='http://www.w3.org/2000/svg' width='640' height='360'>
<rect width='640' height='360' fill='#1c2536'/>
<text x='320' y='185' fill='#8aa0c6' font-family='sans-serif' font-size='18' text-anchor='middle'> </text>
</svg>`,
);

View File

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

View File

@ -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<string>('');
const [lightbox, setLightbox] = useState<RenderJobDto | null>(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 (
<div className="kx-gallery">
{/* 상단 고정 고지 바 */}
<div className="kx-gallery__notice" role="note">
{GALLERY_NOTICE}
</div>
<header className="kx-gallery__head">
<div className="kx-gallery__scope">
<h2> </h2>
<span className="kx-gallery__scope-sub"> {boothId || 'A-102'}</span>
{degraded && <span className="kx-gallery__degraded"> ()</span>}
</div>
<div className="kx-gallery__filters" role="tablist" aria-label="샷 필터">
{SHOT_FILTERS.map((f) => (
<button
key={f.key || 'all'}
role="tab"
aria-selected={shot === f.key}
className={`kx-gallery__chip ${shot === f.key ? 'is-active' : ''}`}
onClick={() => setShot(f.key)}
>
{f.label}
</button>
))}
</div>
</header>
{query.isLoading ? (
<div className="kx-gallery__grid">
{Array.from({ length: 6 }, (_, i) => (
<Skeleton key={i} height={200} radius={8} />
))}
</div>
) : query.isError ? (
<ErrorState onRetry={() => query.refetch()} />
) : filtered.length === 0 ? (
<EmptyState
title="아직 생성된 이미지가 없습니다"
description="설계 스튜디오에서 예상 사진을 생성하세요."
/>
) : (
<div className="kx-gallery__grid">
{filtered.map((job) => (
<button
key={job.jobId}
className="kx-gallery__card"
onClick={() => setLightbox(job)}
aria-label={`${job.shotPreset} 상세 보기`}
>
<AiImage
imageUrl={job.imageUrl}
status={job.status}
watermarkText={job.watermarkText}
notice={job.notice}
shotLabel={job.shotPreset}
versionTag="설계 v2"
alt={`${job.shotPreset} 예상 이미지`}
/>
<div className="kx-gallery__meta tnum">
<span>{new Date(job.createdAt).toLocaleDateString('ko-KR')}</span>
<span className={`kx-gallery__status is-${job.status.toLowerCase()}`}>
{STATUS_LABEL[job.status]}
</span>
</div>
</button>
))}
</div>
)}
{/* 라이트박스 */}
{lightbox && (
<div className="kx-modal" role="dialog" aria-modal="true" aria-label="이미지 상세">
<div className="kx-modal__backdrop" onClick={() => setLightbox(null)} />
<div className="kx-lightbox">
<button
className="kx-lightbox__close"
onClick={() => setLightbox(null)}
aria-label="닫기"
>
</button>
<div className="kx-lightbox__stage">
{lightbox.shotPreset === 'S5' ? (
<CompareSlider
beforeSrc={BEFORE_EMPTY_HALL}
afterSrc={lightbox.imageUrl ?? AFTER_PLACEHOLDER}
afterOverlay={
<>
<div className="kx-studio__wm" aria-hidden="true">
{lightbox.watermarkText}
</div>
<span className="kx-studio__after-ai">
<AiLabel>AI </AiLabel>
</span>
</>
}
/>
) : (
<AiImage
imageUrl={lightbox.imageUrl}
status={lightbox.status}
watermarkText={lightbox.watermarkText}
notice={lightbox.notice}
shotLabel={lightbox.shotPreset}
alt={`${lightbox.shotPreset} 원본`}
/>
)}
</div>
<div className="kx-lightbox__meta">
<dl className="kx-lightbox__dl mono">
<div>
<dt></dt>
<dd>{new Date(lightbox.createdAt).toLocaleString('ko-KR')}</dd>
</div>
<div>
<dt></dt>
<dd>{lightbox.modelVersion ?? '—'}</dd>
</div>
<div>
<dt> </dt>
<dd>{lightbox.schemaHash ?? '—'}</dd>
</div>
</dl>
<p className="kx-lightbox__notice">{lightbox.notice}</p>
</div>
</div>
</div>
)}
</div>
);
}
const STATUS_LABEL: Record<RenderJobDto['status'], string> = {
QUEUED: '대기',
RUNNING: '생성 중',
DONE: '완료',
FAILED: '실패',
};

View File

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

View File

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

View File

@ -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<UtilTab>('power');
const [devices, setDevices] = useState<UtilityDevice[]>(DEFAULT_DEVICES);
const [networkLines, setNetworkLines] = useState(1);
const [plumbingOutlets, setPlumbingOutlets] = useState(1);
const [airOutlets, setAirOutlets] = useState(0);
const [quote, setQuote] = useState<UtilityQuote | null>(null);
const [wiring, setWiring] = useState<WiringResult | null>(null);
const [order, setOrder] = useState<UtilityOrderDto | null>(null);
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
const [selectedTerminal, setSelectedTerminal] = useState<number | null>(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 (
<div className="kx-util">
<header className="kx-util__head">
<div>
<h2 className="kx-util__title"> {boothId || 'A-102'} · {hallId}</h2>
<div className="kx-util__deadline" role="note">
D-6 ·
</div>
</div>
<div className="kx-util__tabs" role="tablist" aria-label="유틸리티 종류">
<TabBtn active={tab === 'power'} onClick={() => setTab('power')} label="전기" />
<TabBtn active={tab === 'network'} onClick={() => setTab('network')} label="네트워크·전화" />
<TabBtn active={tab === 'water'} onClick={() => setTab('water')} label="급배수·압축공기" />
</div>
</header>
<div className="kx-util__body">
{/* 좌: 입력 빌더 */}
<aside className="kx-util__left" aria-label="유틸리티 입력">
{tab === 'power' && (
<section className="kx-util__section">
<h3> </h3>
<div className="kx-devtable">
<div className="kx-devtable__head">
<span></span>
<span></span>
<span>kW</span>
<span aria-hidden="true" />
</div>
{devices.map((d, i) => (
<div className="kx-devtable__row" key={i}>
<input
aria-label={`기기 ${i + 1} 이름`}
value={d.name}
onChange={(e) =>
setDevices((ds) => ds.map((x, j) => (j === i ? { ...x, name: e.target.value } : x)))
}
/>
<input
type="number"
className="tnum"
aria-label={`기기 ${i + 1} 수량`}
value={d.count}
min={0}
onChange={(e) =>
setDevices((ds) =>
ds.map((x, j) => (j === i ? { ...x, count: Number(e.target.value) } : x)),
)
}
/>
<input
type="number"
className="tnum"
step={0.1}
aria-label={`기기 ${i + 1} 소비전력`}
value={d.powerKw}
min={0}
onChange={(e) =>
setDevices((ds) =>
ds.map((x, j) => (j === i ? { ...x, powerKw: Number(e.target.value) } : x)),
)
}
/>
<button
className="kx-devtable__del"
aria-label={`기기 ${i + 1} 삭제`}
onClick={() => setDevices((ds) => ds.filter((_, j) => j !== i))}
>
</button>
</div>
))}
</div>
<button
className="kx-util__addrow"
onClick={() => setDevices((ds) => [...ds, { name: '신규 기기', count: 1, powerKw: 0.5 }])}
>
+
</button>
<div className="kx-util__compute tnum">
<div>
<strong>{requestedKw}kW</strong>
<span className="kx-util__arrow"></span> {requestedKw}kW
</div>
<div> 50A × {distributionBox} </div>
</div>
</section>
)}
{tab === 'network' && (
<section className="kx-util__section">
<h3>·</h3>
<NumRow label="유선 인터넷 회선" value={networkLines} onChange={setNetworkLines} />
<p className="kx-util__hint"> .</p>
</section>
)}
{tab === 'water' && (
<section className="kx-util__section">
<h3>·</h3>
<NumRow label="급배수 구" value={plumbingOutlets} onChange={setPlumbingOutlets} />
<NumRow label="압축공기 구" value={airOutlets} onChange={setAirOutlets} />
</section>
)}
<Button variant="secondary" block onClick={recalc} disabled={busy}>
{busy ? '계산 중…' : '견적·배선 계산'}
</Button>
</aside>
{/* 중앙: 배선 캔버스 */}
<section className="kx-util__canvas">
<WiringCanvas
boothPolygon={BOOTH_POLY}
paths={wiringView.paths}
terminals={terminals}
assumedTrench={wiringView.assumedTrench}
selectedTerminalIdx={selectedTerminal}
onSelectTerminal={setSelectedTerminal}
/>
</section>
{/* 우: 자동 견적 패널 */}
<aside className="kx-util__right" aria-label="자동 견적">
<h3 className="kx-quote__title"> </h3>
{activeQuote ? (
<>
<ul className="kx-quote__lines tnum">
{activeQuote.lines.map((l, i) => (
<li key={i}>
<span className="kx-quote__label">
{l.label}
{l.qty > 1 && <em> ×{l.qty}</em>}
</span>
<span className="kx-quote__amount">{l.amount.toLocaleString()}</span>
</li>
))}
</ul>
<div className="kx-quote__total tnum">
<span></span>
<strong>{activeQuote.total.toLocaleString()}</strong>
</div>
<p className="kx-quote__disclaimer">{activeQuote.disclaimer}</p>
<p className="kx-quote__ruleset"> {activeQuote.rulesetVersion}</p>
</>
) : (
<p className="kx-quote__empty">
· <strong>· </strong> .
</p>
)}
{order ? (
<div className="kx-quote__submitted" role="status">
<p className="kx-quote__submitted-title"> </p>
<p>{order.supplyTiming}</p>
<p className="kx-quote__relay">{order.relayNotice}</p>
<a className="kx-quote__diagram" href={order.locationDiagramUrl}>
</a>
</div>
) : (
<Button block onClick={submitOrder} disabled={busy || !activeQuote}>
</Button>
)}
{notice && (
<p className="kx-util__banner" role="status">
{notice}
</p>
)}
</aside>
</div>
</div>
);
}
function TabBtn({ active, onClick, label }: { active: boolean; onClick: () => void; label: string }) {
return (
<button
role="tab"
aria-selected={active}
className={`kx-util__tab ${active ? 'is-active' : ''}`}
onClick={onClick}
>
{label}
</button>
);
}
function NumRow({ label, value, onChange }: { label: string; value: number; onChange: (v: number) => void }) {
return (
<label className="kx-util__numrow">
<span>{label}</span>
<input
type="number"
className="tnum"
min={0}
value={value}
onChange={(e) => onChange(Math.max(0, Number(e.target.value)))}
/>
</label>
);
}

View File

@ -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<string, string> = {
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 (
<div className="kx-wire">
{assumedTrench && (
<span className="kx-wire__assumed" title="실측 트렌치 좌표 반영 전 — 가정 그리드">
( )
</span>
)}
<div className="kx-wire__legend" aria-hidden="true">
<span className="kx-wire__legend-item is-power"></span>
<span className="kx-wire__legend-item is-network"></span>
<span className="kx-wire__legend-item is-water"></span>
</div>
<svg
className="kx-wire__svg"
viewBox={`${view.minX} ${view.minY} ${view.w} ${view.h}`}
role="img"
aria-label={`부스 배선 도면, 경로 ${paths.length}`}
onClick={() => onSelectTerminal(null)}
>
{/* 트렌치 그리드 */}
<g className="kx-wire__trench">
{trench.map((l, i) => (
<line key={i} x1={l.x1} y1={l.y1} x2={l.x2} y2={l.y2} />
))}
</g>
{/* 부스 외곽 */}
<polygon className="kx-wire__booth" points={boothPts} />
{/* 배선 경로 (색상 규약) */}
<g className="kx-wire__paths">
{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 (
<g key={i} className={`kx-wire__path ${kindClass(p)}`}>
<path d={d} className="kx-wire__path-line" />
{mid && (
<text x={mid[0]} y={mid[1] - 0.6} className="kx-wire__path-label">
{p.label}
</text>
)}
</g>
);
})}
</g>
{/* 단말 노드 */}
<g className="kx-wire__terminals">
{terminals.map((t, i) => {
const [x, y] = t.position;
const sel = i === selectedTerminalIdx;
const cls = COLOR_CLASS[t.kind] ?? 'is-power';
return (
<g
key={i}
className={`kx-wire__terminal ${cls} ${sel ? 'is-selected' : ''}`}
onClick={(e) => {
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);
}
}}
>
<circle cx={x} cy={y} r={0.9} />
</g>
);
})}
</g>
</svg>
</div>
);
}

View File

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

View File

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