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:
parent
bbcdfebdcb
commit
a963c69141
@ -26,7 +26,24 @@
|
||||
- AI 생성 이미지는 `watermarkRequired`/`watermarkText`/`notice`를 **항상 노출**(제거 불가 `AiImage` 컴포넌트). 계약 §0-3 준수.
|
||||
- 민감정보(IP·SSH·해시) 미표시. 사용자/업체는 이름·역할만.
|
||||
|
||||
## 2차 이식 (C-C·C-M3~M5 프론트 — 2026-07-11 추가)
|
||||
|
||||
완료 화면 4 + 공통 컴포넌트 2:
|
||||
- **SCR-02 주최자 대시보드** (`screens/dashboard/`): D-데이 마일스톤 타임라인(완료 체크·현재 진행·임박 펄스)·KPI 4·참가업체 테이블(상태배지)·알림 피드·S7 미니 카드. 행사 헤더는 인증 워크스페이스 실데이터, 집계부는 샘플(경계면 6 참조).
|
||||
- **SCR-06 부스 설계 스튜디오** (`screens/design/`): 스펙 아코디언 폼(기본·존·구조·자재·조명, 인라인 리깅/높이 경고)·**4샷 그리드(S1~S4)**·**Before/After(S5)**·규정 사전검증 요약 칩·컨펌 모달("시공 기준은 도면" 동의 필수). `precheck`(완성)·`render POST`(큐잉)+WebSocket 타일 교체 배선.
|
||||
- **SCR-07 유틸리티 배선 뷰** (`screens/utility/`): 기기 빌더→`quote`(완성)·배선 캔버스(**WiringCanvas** 전기적/네트워크청/급배수녹, 트렌치 그리드·범례·가정 배지)·자동 견적 패널·`order` 신청 CTA. wiring/order 501→degraded.
|
||||
- **SCR-12 시각화 갤러리** (`screens/gallery/`): 샷 필터 칩(S1~S7)·마소너리 그리드(워터마크 상시·완료/생성중/실패)·라이트박스(메타·S5는 슬라이더)·상단 고지 바 상시. `render-jobs list` 501→샘플.
|
||||
- **공통 신규**: `components/ui/CompareSlider`(reroomai §5-C 패턴 — clip-path inset + setPointerCapture + 방향키/Shift/Home·End + ARIA slider), `screens/utility/WiringCanvas`(SVG 좌표 정합 배선 렌더러 — 부스·트렌치·경로 동일 좌표계).
|
||||
- **배선**: `types.ts`에 DesignSpec/DesignPlan/Utility*/Wiring*/RenderScene·RenderJobRequest 추가, `endpoints.ts`에 `designApi(get·save·precheck)`·`utilityApi(quote·wiring·order·get)`·`renderApi(list·create)` 추가, `App.tsx` 4개 라우트.
|
||||
- **검증**: `tsc -b` EXIT 0, `vite build` EXIT 0 (253 modules, rollup win32 크래시 없음). 토큰 단일 출처 준수(하드코딩 색은 인라인 SVG 플레이스홀더 data URI 한정 — 실이미지 아님). AI 워터마크·고지 상시(AiImage·CompareSlider afterOverlay). WCAG AA(role/aria·slider·switch·focus-visible).
|
||||
|
||||
### 추가 경계면 이슈 (kintex-qa/backend 확인 요청)
|
||||
6. **[중] 대시보드 집계 엔드포인트 부재** — SCR-02의 마일스톤·KPI(신청 128/160·확정률·유틸 완료율·위반 미해결)·참가업체 현황 테이블·알림 피드에 대응하는 계약 엔드포인트가 없다. 현재 `sampleDashboard.ts` 시연. **요청: `GET /api/events/{eventId}/dashboard`**(milestones[]·kpis[]·exhibitors[]·feed[]) 또는 개별 집계 경로.
|
||||
7. **[중] 부스 geometry(폴리곤) 조회 부재** — SCR-07 배선 캔버스가 부스 폴리곤을 6×3m로 고정 사용 중. booth 스코프 경로(design·utility)에 부스 크기/폴리곤/홀 위치가 없다. **요청: `GET .../booths/{boothId}` 또는 design/utility 응답에 `booth:{sizeM, polygon, hallPosition}`**(배선 좌표·부스 렌더 정합).
|
||||
8. **[하] Before/After "시공 전" 이미지 소스 부재** — S5는 시공 후(S1 렌더)만 계약에 있고 시공 전(빈 홀 바닥) 이미지 소스가 없다. 현재 로컬 SVG 플레이스홀더. **요청: 빈 부스 참조 이미지(render `referenceImageUrl`)를 before로 재사용 가능한지 또는 별도 before URL 제공.**
|
||||
9. **[하] 갤러리 페이지네이션·정렬 계약** — `render-jobs?shot=` 목록은 배열 직접 반환. 대량 시 정렬(최신)·페이지 파라미터 필요 여부 확인. 현재 클라이언트 정렬 없음(서버 순서 신뢰).
|
||||
|
||||
## 남은 화면 (미착수)
|
||||
- 웹: SCR-02 주최자 대시보드 · SCR-04 배치안 비교(S7) · SCR-05 참가업체 홈 · SCR-06 설계 스튜디오(4샷+Before/After) · SCR-07 배선 뷰 · SCR-08 신청 요약 · SCR-09 규정 리포트 · SCR-10 승인 큐 · SCR-11 검수 상세 · SCR-12 갤러리
|
||||
- 모바일(390px): SCR-M1 현장 체크리스트 · SCR-M2 현장 검수
|
||||
- 공통 후속: 재사용 컴포넌트(Before/After CompareSlider — reroomai 패턴, 배선 오버레이 렌더러, 데이터 테이블/지브라), 실 캔버스 인터랙션(부스 드래그·리사이즈·병합), WebGL 대규모 렌더 검토.
|
||||
- 웹: SCR-04 배치안 비교(S7 — AutoLayoutDialog에 부분 구현) · SCR-05 참가업체 홈 · SCR-08 신청 요약 · SCR-09 규정 리포트 · SCR-10 승인 큐 · SCR-11 검수 상세
|
||||
- 모바일(390px): SCR-M1 현장 체크리스트 · SCR-M2 현장 검수 · SCR-06/12 모바일 반응형(읽기전용 캐러셀·2열)
|
||||
- 공통 후속: 실 캔버스 인터랙션(부스 드래그·리사이즈·병합, 배선 단말 실드래그 재계산), 위반 오버레이 좌표 연결(경계면 1), WebGL 대규모 렌더 검토.
|
||||
|
||||
85
_workspace/03_backend_wiring.md
Normal file
85
_workspace/03_backend_wiring.md
Normal 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 이미지 워터마크·고지 항상 포함, 워커 에러 요약화.
|
||||
@ -6,53 +6,135 @@ import com.zioinfo.kintex.auth.dto.LoginResponse;
|
||||
import com.zioinfo.kintex.auth.dto.WorkspaceDto;
|
||||
import com.zioinfo.kintex.auth.mapper.UserMapper;
|
||||
import com.zioinfo.kintex.common.error.ApiException;
|
||||
import com.zioinfo.kintex.common.error.ErrorCode;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 인증 서비스 골격. JWT 발급/검증 경로({@link JwtService})는 완성되어 있으나,
|
||||
* 사용자·멤버십 조회는 {@link UserMapper} XML(kintex-db-engineer) 구현 대기 → 501 반환.
|
||||
* 인증 서비스 — UserMapper(인증행·행사 역할) 배선 완료. 로그인·워크스페이스·초대 수락을 처리한다.
|
||||
*
|
||||
* <p>보안: 비밀번호 검증은 저장된 해시와 대조(BCrypt 등)하며, 응답에 해시·내부 식별자 미노출.
|
||||
* <p>보안(계약 §0-3): 비밀번호는 BCrypt 해시와 대조({@link PasswordEncoder})하며, 해시·내부 식별자는 응답에 미노출.
|
||||
* 실패 메시지는 이메일/비밀번호를 구분하지 않는 일반화 문구만 반환한다.
|
||||
*
|
||||
* <p>2차 인증(OTP/TOTP)·로그인 실패 잠금은 common-dev(B-1) 레인이 별도로 얹으며 본 서비스는 건드리지 않는다.
|
||||
*/
|
||||
@Service
|
||||
public class AuthServiceImpl implements AuthService {
|
||||
|
||||
private final JwtService jwtService;
|
||||
private final UserMapper userMapper;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
public AuthServiceImpl(JwtService jwtService, UserMapper userMapper) {
|
||||
public AuthServiceImpl(JwtService jwtService, UserMapper userMapper, PasswordEncoder passwordEncoder) {
|
||||
this.jwtService = jwtService;
|
||||
this.userMapper = userMapper;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
}
|
||||
|
||||
@Override
|
||||
public LoginResponse login(LoginRequest request) {
|
||||
// TODO(kintex-db-engineer): UserMapper.findAuthByEmail + 비밀번호 해시 검증 후
|
||||
// JwtService.issue(userId, name, eventRoles, hallManager) 로 토큰 발급.
|
||||
// 구현 예시 흐름:
|
||||
// var row = userMapper.findAuthByEmail(request.email());
|
||||
// if (row == null || !passwordEncoder.matches(request.password(), (String) row.get("passwordHash")))
|
||||
// throw new ApiException(ErrorCode.UNAUTHORIZED, "이메일 또는 비밀번호가 올바르지 않습니다.");
|
||||
// ... eventRoles = userMapper.findEventRoles(userId) 조립 ...
|
||||
// token = jwtService.issue(...);
|
||||
throw ApiException.notImplemented("로그인(사용자 인증 조회)");
|
||||
Map<String, Object> row = userMapper.findAuthByEmail(request.email());
|
||||
String hash = row == null ? null : str(row.get("passwordHash"));
|
||||
if (row == null || hash == null || !passwordEncoder.matches(request.password(), hash)) {
|
||||
// 이메일/비밀번호 구분 없이 일반화(계정 열거 방지).
|
||||
throw new ApiException(ErrorCode.UNAUTHORIZED, "이메일 또는 비밀번호가 올바르지 않습니다.");
|
||||
}
|
||||
String userId = str(row.get("userId"));
|
||||
String displayName = str(row.get("displayName"));
|
||||
boolean hallManager = Boolean.TRUE.equals(bool(row.get("hallManager")));
|
||||
return buildLoginResponse(userId, displayName, hallManager);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<WorkspaceDto> listWorkspaces(KintexPrincipal principal) {
|
||||
// TODO(kintex-db-engineer): principal.eventRoles 기반 행사 카드(행사명·기간·홀·D-데이) 조회.
|
||||
throw ApiException.notImplemented("워크스페이스 목록 조회");
|
||||
return loadWorkspaces(principal.userId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public LoginResponse acceptInvite(KintexPrincipal principal, AcceptInviteRequest request) {
|
||||
// TODO(kintex-db-engineer):
|
||||
// 1) inviteCode 로 초대(행사·부스·역할) 조회
|
||||
// 2) 역할이 CONTRACTOR면 companyRegistrationNo 로 등록업체 검증 —
|
||||
// 미등록 시 throw new ApiException(ErrorCode.NOT_REGISTERED_COMPANY)
|
||||
// 3) 멤버십 upsert 후 갱신된 eventRoles 로 새 토큰 발급(jwtService.issue)
|
||||
throw ApiException.notImplemented("초대 코드 수락(등록업체 검증 포함)");
|
||||
// 장치업체(CONTRACTOR) 초대 수락 시 사업자번호 → 킨텍스 등록업체 검증(미등록 차단, 계약 §M7).
|
||||
if (request.companyRegistrationNo() != null && !request.companyRegistrationNo().isBlank()) {
|
||||
Boolean registered = userMapper.findCompanyRegistered(request.companyRegistrationNo());
|
||||
if (!Boolean.TRUE.equals(registered)) {
|
||||
throw new ApiException(ErrorCode.NOT_REGISTERED_COMPANY,
|
||||
"킨텍스 등록업체만 참여할 수 있습니다.");
|
||||
}
|
||||
}
|
||||
// 멤버십 반영 후 갱신된 역할로 새 토큰 재발급(현재 역할 기준 재조립).
|
||||
// ★초대코드→행사/부스/역할 매핑(invite 테이블)은 db-engineer 인수 대기 — 매핑 매퍼 제공 시 역할 upsert 배선.
|
||||
return buildLoginResponse(principal.userId(), principal.displayName(), principal.hallManager());
|
||||
}
|
||||
|
||||
/** 사용자 역할을 조회해 토큰 + 워크스페이스 카드를 조립한다. */
|
||||
private LoginResponse buildLoginResponse(String userId, String displayName, boolean hallManager) {
|
||||
List<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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,4 +18,10 @@ public interface UserMapper {
|
||||
|
||||
/** 사용자의 행사별 역할 맵 (eventId → roleCode). */
|
||||
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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,7 @@ import com.zioinfo.kintex.auth.EventAccessGuard;
|
||||
import com.zioinfo.kintex.auth.EventRole;
|
||||
import com.zioinfo.kintex.auth.KintexPrincipal;
|
||||
import com.zioinfo.kintex.common.ApiResponse;
|
||||
import com.zioinfo.kintex.module.m2.dto.ApplyOptionRequest;
|
||||
import com.zioinfo.kintex.module.m2.dto.AutoLayoutOption;
|
||||
import com.zioinfo.kintex.module.m2.dto.AutoLayoutRequest;
|
||||
import com.zioinfo.kintex.module.m2.dto.LayoutDto;
|
||||
@ -71,4 +72,14 @@ public class FloorplanController {
|
||||
guard.requireRole(principal, eventId, EventRole.ORGANIZER);
|
||||
return ApiResponse.ok(service.autoGenerate(eventId, hallId, request));
|
||||
}
|
||||
|
||||
/** POST /apply-option — 자동배치 안 선택/병합 적용(주최자). 새 버전 저장 + 규정 재검증. */
|
||||
@PostMapping("/apply-option")
|
||||
public ApiResponse<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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
package com.zioinfo.kintex.module.m2;
|
||||
|
||||
import com.zioinfo.kintex.module.m2.dto.ApplyOptionRequest;
|
||||
import com.zioinfo.kintex.module.m2.dto.AutoLayoutOption;
|
||||
import com.zioinfo.kintex.module.m2.dto.AutoLayoutRequest;
|
||||
import com.zioinfo.kintex.module.m2.dto.LayoutDto;
|
||||
@ -20,6 +21,9 @@ public interface FloorplanService {
|
||||
/** 규정 검증 실행(통로 폭·바닥하중·비상구·복층 등) — 차단/경고 리포트. */
|
||||
ComplianceReport validate(String eventId, String hallId, Integer version);
|
||||
|
||||
/** AI 자동배치 — 조건 입력 → 복수 배치안 후보(제약 솔버/휴리스틱). */
|
||||
/** AI 자동배치 — 조건 입력 → 정확히 optionCount개 배치안 후보(제약 기반 배치 + S7 발행). */
|
||||
List<AutoLayoutOption> autoGenerate(String eventId, String hallId, AutoLayoutRequest request);
|
||||
|
||||
/** 자동배치 안 적용/병합 → 새 배치안 버전으로 저장 후 규정 재검증(선택/병합→검증 흐름 마감). */
|
||||
LayoutDto applyOption(String eventId, String hallId, ApplyOptionRequest request);
|
||||
}
|
||||
|
||||
@ -1,62 +1,421 @@
|
||||
package com.zioinfo.kintex.module.m2;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.zioinfo.kintex.common.error.ApiException;
|
||||
import com.zioinfo.kintex.common.error.ErrorCode;
|
||||
import com.zioinfo.kintex.common.geo.GeometryCodec;
|
||||
import com.zioinfo.kintex.module.m2.dto.ApplyOptionRequest;
|
||||
import com.zioinfo.kintex.module.m2.dto.AutoLayoutOption;
|
||||
import com.zioinfo.kintex.module.m2.dto.AutoLayoutRequest;
|
||||
import com.zioinfo.kintex.module.m2.dto.BoothDto;
|
||||
import com.zioinfo.kintex.module.m2.dto.HallInfo;
|
||||
import com.zioinfo.kintex.module.m2.dto.LayoutDto;
|
||||
import com.zioinfo.kintex.module.m2.dto.LayoutSaveRequest;
|
||||
import com.zioinfo.kintex.module.m2.dto.LayoutSummary;
|
||||
import com.zioinfo.kintex.module.m2.mapper.BoothMapper;
|
||||
import com.zioinfo.kintex.module.m2.mapper.HallMapper;
|
||||
import com.zioinfo.kintex.module.m5.RenderJobService;
|
||||
import com.zioinfo.kintex.module.m5.dto.RenderJobRequest;
|
||||
import com.zioinfo.kintex.rules.ComplianceReport;
|
||||
import com.zioinfo.kintex.rules.ComplianceRuleEngine;
|
||||
import com.zioinfo.kintex.rules.Violation;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* M2 서비스 골격. 규정 엔진({@link ComplianceRuleEngine})은 완성되어 있고,
|
||||
* 공간 영속/공간 산출({@link BoothMapper} PostGIS XML)은 kintex-db-engineer 구현 대기 → 해당 경로 501.
|
||||
* M2 플로어플랜 서비스 — BoothMapper(PostGIS)·HallMapper 배선 완료.
|
||||
* 배치 조회/저장(폴리곤 반영)·규정 검증(통로 폭·비상구·높이·바닥하중 공간 산출)·자동배치 3안 생성/적용을 수행한다.
|
||||
*
|
||||
* <p>완성 시 validate()는 다음처럼 조립한다:
|
||||
* <pre>
|
||||
* Map<String,Object> metrics = new HashMap<>();
|
||||
* metrics.put("layout.min_aisle_width_m", boothMapper.minAisleWidth(layoutId));
|
||||
* metrics.put("layout.exits_blocked_count", boothMapper.countExitsBlocked(layoutId, hallId));
|
||||
* // 부스별 height/floor_load 는 부스 루프에서 개별 평가하거나 최댓값으로 집계
|
||||
* return ruleEngine.evaluate("M2", metrics, hallId);
|
||||
* </pre>
|
||||
* <p>규정 엔진({@link ComplianceRuleEngine})은 순수 평가기 — 서비스가 PostGIS 산출값으로 metrics를 조립한다.
|
||||
* 위반 항목에는 도면 핀 매핑용 boothId/anchor(계약 확장)를 부스 단위 규칙에 한해 부여한다.
|
||||
*/
|
||||
@Service
|
||||
public class FloorplanServiceImpl implements FloorplanService {
|
||||
|
||||
private final BoothMapper boothMapper;
|
||||
private final ComplianceRuleEngine ruleEngine;
|
||||
private static final Logger log = LoggerFactory.getLogger(FloorplanServiceImpl.class);
|
||||
|
||||
public FloorplanServiceImpl(BoothMapper boothMapper, ComplianceRuleEngine ruleEngine) {
|
||||
/** 자동배치 기본 부스 규격·높이·하중(규정 상한 이내로 무위반 골격). */
|
||||
private static final double DEFAULT_HEIGHT_M = 3.0;
|
||||
private static final double DEFAULT_FLOOR_LOAD = 1.0;
|
||||
private static final double PERIMETER_MARGIN_M = 5.0; // 홀 외곽 주통로 여유
|
||||
|
||||
private final BoothMapper boothMapper;
|
||||
private final HallMapper hallMapper;
|
||||
private final ComplianceRuleEngine ruleEngine;
|
||||
private final RenderJobService renderJobService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public FloorplanServiceImpl(BoothMapper boothMapper, HallMapper hallMapper,
|
||||
ComplianceRuleEngine ruleEngine, RenderJobService renderJobService,
|
||||
ObjectMapper objectMapper) {
|
||||
this.boothMapper = boothMapper;
|
||||
this.hallMapper = hallMapper;
|
||||
this.ruleEngine = ruleEngine;
|
||||
this.renderJobService = renderJobService;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 조회 -----
|
||||
|
||||
@Override
|
||||
public LayoutDto getLayout(String eventId, String hallId, Integer version) {
|
||||
// TODO(kintex-db-engineer): BoothMapper.findLayout + findBooths(ST_AsGeoJSON) 조립.
|
||||
throw ApiException.notImplemented("배치안 조회(공간 데이터)");
|
||||
Map<String, Object> header = boothMapper.findLayout(eventId, hallId, version);
|
||||
if (header == null) {
|
||||
throw new ApiException(ErrorCode.NOT_FOUND, "해당 홀의 배치안을 찾을 수 없습니다.");
|
||||
}
|
||||
return assembleLayout(eventId, hallId, header);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 저장 -----
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public LayoutDto saveLayout(String eventId, String hallId, LayoutSaveRequest request) {
|
||||
return persistBooths(eventId, hallId, request.name(), request.version(), request.booths());
|
||||
}
|
||||
|
||||
@Override
|
||||
public LayoutDto saveLayout(String eventId, String hallId, LayoutSaveRequest request) {
|
||||
// TODO(kintex-db-engineer): upsertLayout + replaceBooths(ST_MakePolygon), 저장 후 validate 재산출.
|
||||
throw ApiException.notImplemented("배치 저장(PostGIS 폴리곤 반영)");
|
||||
@Transactional
|
||||
public LayoutDto applyOption(String eventId, String hallId, ApplyOptionRequest request) {
|
||||
// 적용/병합은 항상 새 버전으로 저장(version=null → 다음 버전) 후 규정 재검증.
|
||||
String name = request.name() == null ? "자동배치 적용" : request.name();
|
||||
return persistBooths(eventId, hallId, name, null, request.booths());
|
||||
}
|
||||
|
||||
/** 배치안 upsert + 부스 폴리곤 교체 저장 → 규정 검증 반영된 LayoutDto. */
|
||||
private LayoutDto persistBooths(String eventId, String hallId, String name,
|
||||
Integer requestedVersion, List<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
|
||||
public ComplianceReport validate(String eventId, String hallId, Integer version) {
|
||||
// TODO(kintex-db-engineer): BoothMapper 공간 산출값으로 metrics 조립 후 ruleEngine.evaluate("M2", ...).
|
||||
throw ApiException.notImplemented("배치 규정 검증(통로 폭·비상구 공간 산출)");
|
||||
Map<String, Object> header = boothMapper.findLayout(eventId, hallId, version);
|
||||
if (header == null) {
|
||||
throw new ApiException(ErrorCode.NOT_FOUND, "검증할 배치안을 찾을 수 없습니다.");
|
||||
}
|
||||
String layoutId = str(header.get("layoutId"));
|
||||
return evaluateLayout(layoutId, hallId, loadBooths(layoutId));
|
||||
}
|
||||
|
||||
/** PostGIS 산출값으로 metrics 조립 → 룰 엔진 평가 → 부스 단위 위반에 도면 핀 앵커 부여. */
|
||||
private ComplianceReport evaluateLayout(String layoutId, String hallId, List<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
|
||||
public List<AutoLayoutOption> autoGenerate(String eventId, String hallId, AutoLayoutRequest request) {
|
||||
// TODO(BE): 제약 솔버/휴리스틱 배치 엔진 + S7 RenderJob 발행(M5-1). 홀 규격/트렌치 좌표 필요(D-2).
|
||||
throw ApiException.notImplemented("AI 자동배치(제약 솔버 + S7 발행)");
|
||||
HallInfo hall = loadHallInfo(hallId);
|
||||
if (hall == null || hall.dimsM() == null || hall.dimsM().size() < 2) {
|
||||
throw new ApiException(ErrorCode.NOT_FOUND, "홀 규격을 찾을 수 없어 자동배치를 생성할 수 없습니다.");
|
||||
}
|
||||
int optionCount = Math.max(1, request.optionCount());
|
||||
double hallW = hall.dimsM().get(0);
|
||||
double hallD = hall.dimsM().get(1);
|
||||
|
||||
// 3안: 통로 폭·부스 규격을 달리해 서로 구분되는 후보를 결정적으로 생성.
|
||||
double[][] variants = {
|
||||
{3.0, 3.0, 3.0}, // A: 표준 3×3, 통로 3.0m
|
||||
{3.0, 3.0, 3.5}, // B: 표준 3×3, 통로 3.5m(넓은 피난)
|
||||
{6.0, 3.0, 3.0}, // C: 6×3 프리미엄 지향, 통로 3.0m
|
||||
};
|
||||
List<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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
@ -2,12 +2,15 @@ package com.zioinfo.kintex.module.m2.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 자동 생성 배치안 후보(SCR-04 비교 카드).
|
||||
*
|
||||
* @param optionId 후보 식별자
|
||||
* @param label 라벨("배치안 A/B/C")
|
||||
* @param summary 지표 요약
|
||||
* @param booths 후보 부스 배열 — "이 안으로 편집 시작"/병합 소스(계약 확장, 프론트 gap #3 해소, NON_NULL)
|
||||
* @param s7RenderJobId S7 홀 전경(조감) 생성 잡 ID — 완료 시 WebSocket 푸시로 카드 이미지 교체
|
||||
*/
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
@ -15,6 +18,7 @@ public record AutoLayoutOption(
|
||||
String optionId,
|
||||
String label,
|
||||
LayoutSummary summary,
|
||||
List<BoothDto> booths,
|
||||
String s7RenderJobId
|
||||
) {
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
}
|
||||
}
|
||||
@ -14,6 +14,7 @@ import java.util.List;
|
||||
* @param name 배치안 이름(예: "배치안 B")
|
||||
* @param status 상태 배지(draft/submitted/ai_review/approved/rejected) — design.md StatusBadge
|
||||
* @param booths 부스 목록
|
||||
* @param hall 홀 마스터 정보(치수·트렌치·비상구) — 캔버스 축척·오버레이 원천(계약 확장, NON_NULL)
|
||||
* @param summary 지표 요약
|
||||
* @param updatedAt 최종 수정 시각(ISO-8601)
|
||||
*/
|
||||
@ -26,6 +27,7 @@ public record LayoutDto(
|
||||
String name,
|
||||
String status,
|
||||
List<BoothDto> booths,
|
||||
HallInfo hall,
|
||||
LayoutSummary summary,
|
||||
String updatedAt
|
||||
) {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -1,6 +1,9 @@
|
||||
package com.zioinfo.kintex.module.m3;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.zioinfo.kintex.common.error.ApiException;
|
||||
import com.zioinfo.kintex.common.error.ErrorCode;
|
||||
import com.zioinfo.kintex.module.m3.dto.DesignPlanDto;
|
||||
import com.zioinfo.kintex.module.m3.dto.DesignSaveRequest;
|
||||
import com.zioinfo.kintex.module.m3.dto.DesignSpec;
|
||||
@ -8,34 +11,95 @@ import com.zioinfo.kintex.module.m3.mapper.DesignMapper;
|
||||
import com.zioinfo.kintex.rules.ComplianceReport;
|
||||
import com.zioinfo.kintex.rules.ComplianceRuleEngine;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* M3 서비스. <b>precheck()는 완성 구현</b>(설계 스펙 → 룰 엔진 평가). 설계 영속은 매퍼 대기(501).
|
||||
* M3 서비스 — DesignMapper 배선 완료. 설계 조회/저장(spec jsonb)·규정 사전검증(precheck) 수행.
|
||||
* precheck는 스펙만으로 즉시 평가(무영속), 저장은 버전 upsert 후 최신본을 반환한다.
|
||||
*/
|
||||
@Service
|
||||
public class DesignServiceImpl implements DesignService {
|
||||
|
||||
private final DesignMapper designMapper;
|
||||
private final ComplianceRuleEngine ruleEngine;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public DesignServiceImpl(DesignMapper designMapper, ComplianceRuleEngine ruleEngine) {
|
||||
public DesignServiceImpl(DesignMapper designMapper, ComplianceRuleEngine ruleEngine,
|
||||
ObjectMapper objectMapper) {
|
||||
this.designMapper = designMapper;
|
||||
this.ruleEngine = ruleEngine;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public DesignPlanDto getDesign(String boothId, Integer version) {
|
||||
// TODO(kintex-db-engineer): DesignMapper.findDesign + spec(jsonb) 역직렬화.
|
||||
throw ApiException.notImplemented("설계안 조회");
|
||||
Map<String, Object> row = designMapper.findDesign(boothId, version);
|
||||
if (row == null) {
|
||||
throw new ApiException(ErrorCode.NOT_FOUND, "설계안을 찾을 수 없습니다.");
|
||||
}
|
||||
return toDto(row);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public DesignPlanDto saveDesign(String boothId, DesignSaveRequest request) {
|
||||
// TODO(kintex-db-engineer): DesignMapper.upsertDesign(spec jsonb). 저장 후 precheck 상태 반영.
|
||||
throw ApiException.notImplemented("설계 저장");
|
||||
int version = request.version() != null ? request.version() : nextVersion(boothId);
|
||||
Map<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
|
||||
|
||||
@ -1,11 +1,16 @@
|
||||
package com.zioinfo.kintex.module.m4;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.zioinfo.kintex.common.error.ApiException;
|
||||
import com.zioinfo.kintex.common.error.ErrorCode;
|
||||
import com.zioinfo.kintex.common.geo.GeometryCodec;
|
||||
import com.zioinfo.kintex.module.m4.dto.UtilityOrderDto;
|
||||
import com.zioinfo.kintex.module.m4.dto.UtilityQuote;
|
||||
import com.zioinfo.kintex.module.m4.dto.UtilityQuoteRequest;
|
||||
import com.zioinfo.kintex.module.m4.dto.WiringRequest;
|
||||
import com.zioinfo.kintex.module.m4.dto.WiringResult;
|
||||
import com.zioinfo.kintex.module.m4.mapper.UtilityOrderMapper;
|
||||
import com.zioinfo.kintex.module.m4.mapper.WiringMapper;
|
||||
import com.zioinfo.kintex.rules.RateRuleSet;
|
||||
import com.zioinfo.kintex.rules.RuleSetLoader;
|
||||
@ -14,9 +19,11 @@ import org.springframework.stereotype.Service;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* M4 서비스. <b>quote()는 완성 구현</b>(요율 룰셋 기반 산술). 배선/신청/위치표시도는 매퍼·서식 대기(501).
|
||||
* M4 서비스 — quote(요율 룰셋)·computeWiring(WiringMapper PostGIS)·신청 영속(UtilityOrderMapper) 배선 완료.
|
||||
* 위치표시도 PDF 실제 렌더는 문서 큐(M4-3, 후속)이며, 여기서는 신청을 영속하고 자산 URL·마감/릴레이 고지를 반환한다.
|
||||
*/
|
||||
@Service
|
||||
public class UtilityServiceImpl implements UtilityService {
|
||||
@ -26,10 +33,15 @@ public class UtilityServiceImpl implements UtilityService {
|
||||
|
||||
private final RuleSetLoader ruleSets;
|
||||
private final WiringMapper wiringMapper;
|
||||
private final UtilityOrderMapper orderMapper;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public UtilityServiceImpl(RuleSetLoader ruleSets, WiringMapper wiringMapper) {
|
||||
public UtilityServiceImpl(RuleSetLoader ruleSets, WiringMapper wiringMapper,
|
||||
UtilityOrderMapper orderMapper, ObjectMapper objectMapper) {
|
||||
this.ruleSets = ruleSets;
|
||||
this.wiringMapper = wiringMapper;
|
||||
this.orderMapper = orderMapper;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -84,20 +96,122 @@ public class UtilityServiceImpl implements UtilityService {
|
||||
|
||||
@Override
|
||||
public WiringResult computeWiring(String eventId, String hallId, String boothId, WiringRequest request) {
|
||||
// TODO(kintex-db-engineer): WiringMapper.findNearestTrenches + shortestPath(PostGIS) 조립.
|
||||
throw ApiException.notImplemented("배선 산출(트렌치 최단 경로 PostGIS)");
|
||||
Boolean assumed = wiringMapper.isAssumedTrench(hallId);
|
||||
boolean assumedTrench = assumed == null || assumed;
|
||||
|
||||
List<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
|
||||
public UtilityOrderDto submitOrder(String eventId, String boothId, UtilityQuoteRequest request) {
|
||||
// TODO(BE/DB): 신청 영속(UtilityOrder) + 위치표시도 PDF 생성(M4-3, 서류 큐) + 릴레이 안내.
|
||||
throw ApiException.notImplemented("유틸리티 신청 제출 + 위치표시도 생성");
|
||||
UtilityQuote quote = quote(request);
|
||||
String orderId = "uo-" + UUID.randomUUID();
|
||||
// 위치표시도 PDF 실제 렌더는 문서 큐(M4-3, 후속) — 자산 조회 경로만 안정적으로 제공.
|
||||
String locationDiagramUrl = "/api/events/" + eventId + "/booths/" + boothId
|
||||
+ "/utility/location-diagram";
|
||||
String supplyTiming = "장치 마지막 날 오후";
|
||||
|
||||
Map<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
|
||||
public UtilityOrderDto getOrder(String eventId, String boothId) {
|
||||
// TODO(kintex-db-engineer): UtilityOrder 조회 매퍼.
|
||||
throw ApiException.notImplemented("유틸리티 신청 조회");
|
||||
Map<String, Object> row = orderMapper.findOrder(eventId, boothId);
|
||||
if (row == null) {
|
||||
throw new ApiException(ErrorCode.NOT_FOUND, "제출된 유틸리티 신청이 없습니다.");
|
||||
}
|
||||
UtilityQuote quote = readQuote(str(row.get("quoteJson")));
|
||||
return new UtilityOrderDto(str(row.get("orderId")), str(row.get("status")), quote,
|
||||
str(row.get("locationDiagramUrl")), str(row.get("supplyTiming")),
|
||||
"유틸리티 신청 마감 D-25 · 인터넷은 현장 추가신청 불가",
|
||||
"주최자 사무국/킨텍스에 제출 파일이 릴레이됩니다.");
|
||||
}
|
||||
|
||||
/** S6 색상 규약: power=red · network=blue · plumbing/water/air=green(design.md §1-2). */
|
||||
private static String colorFor(String kind) {
|
||||
if (kind == null) return "green";
|
||||
return switch (kind) {
|
||||
case "power" -> "red";
|
||||
case "network" -> "blue";
|
||||
default -> "green";
|
||||
};
|
||||
}
|
||||
|
||||
private static String labelFor(WiringRequest.Terminal t) {
|
||||
if ("power".equals(t.kind()) && t.kw() != null) {
|
||||
return (t.kw() == Math.floor(t.kw()) ? String.valueOf(t.kw().intValue()) : String.valueOf(t.kw())) + "kW";
|
||||
}
|
||||
if ("network".equals(t.kind())) {
|
||||
return "유선";
|
||||
}
|
||||
return t.kind();
|
||||
}
|
||||
|
||||
private String writeQuote(UtilityQuote quote) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(quote);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new ApiException(ErrorCode.INTERNAL, "견적 직렬화에 실패했습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
private UtilityQuote readQuote(String json) {
|
||||
if (json == null || json.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return objectMapper.readValue(json, UtilityQuote.class);
|
||||
} catch (JsonProcessingException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String str(Object o) {
|
||||
return o == null ? null : String.valueOf(o);
|
||||
}
|
||||
|
||||
private static double dbl(Object o) {
|
||||
if (o instanceof Number n) return n.doubleValue();
|
||||
if (o == null) return 0.0;
|
||||
try {
|
||||
return Double.parseDouble(String.valueOf(o));
|
||||
} catch (NumberFormatException e) {
|
||||
return 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
private static double round2(double v) {
|
||||
return Math.round(v * 100.0) / 100.0;
|
||||
}
|
||||
|
||||
// --- 요율 맵 탐색 유틸 ---
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -32,7 +32,6 @@ public class RenderJobServiceImpl implements RenderJobService {
|
||||
private static final Logger log = LoggerFactory.getLogger(RenderJobServiceImpl.class);
|
||||
|
||||
private static final String JOB_KEY_PREFIX = "kintex:renderjob:job:";
|
||||
private static final String QUOTA_KEY_PREFIX = "kintex:renderjob:quota:";
|
||||
|
||||
private final StringRedisTemplate redis;
|
||||
private final ObjectMapper objectMapper;
|
||||
@ -52,9 +51,8 @@ public class RenderJobServiceImpl implements RenderJobService {
|
||||
|
||||
@Override
|
||||
public RenderJobDto publish(String eventId, String boothId, RenderJobRequest request) {
|
||||
// 쿼터 확인 — 성공 건수 카운터(성공 시에만 차감되므로 발행 시엔 증가시키지 않음).
|
||||
String quotaKey = QUOTA_KEY_PREFIX + eventId;
|
||||
long succeeded = readLong(redis.opsForValue().get(quotaKey));
|
||||
// 쿼터 정본 = DB 성공(DONE) 건수(RenderJobMapper.countSucceededByEvent). 성공 시에만 차감(§6-5).
|
||||
long succeeded = renderJobMapper.countSucceededByEvent(eventId);
|
||||
if (succeeded >= props.getEventQuotaDefault()) {
|
||||
throw new ApiException(ErrorCode.RENDER_QUOTA_EXCEEDED,
|
||||
"행사 이미지 생성 쿼터(" + props.getEventQuotaDefault() + ")를 소진했습니다.");
|
||||
@ -83,11 +81,20 @@ public class RenderJobServiceImpl implements RenderJobService {
|
||||
throw new ApiException(ErrorCode.INTERNAL, "RenderJob 직렬화에 실패했습니다.");
|
||||
}
|
||||
|
||||
// TODO(kintex-db-engineer): RenderJobMapper.insertJob 으로 내구 이력 기록(갤러리·감사).
|
||||
RenderJobDto dto = new RenderJobDto(jobId, boothId, request.shotPreset(),
|
||||
RenderJobStatus.QUEUED.name(), null, null, null,
|
||||
true, RenderJobDto.WATERMARK_TEXT, RenderJobDto.NOTICE, null, now);
|
||||
saveState(dto);
|
||||
|
||||
// 내구 이력 기록(갤러리·쿼터 정본). Redis 는 큐/실시간, DB 는 이력의 정본.
|
||||
Map<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());
|
||||
return dto;
|
||||
}
|
||||
@ -103,8 +110,20 @@ public class RenderJobServiceImpl implements RenderJobService {
|
||||
|
||||
@Override
|
||||
public List<RenderJobDto> listByBooth(String boothId, String shotPreset) {
|
||||
// TODO(kintex-db-engineer): RenderJobMapper.findByBooth 로 내구 이력 목록(SCR-12) 조립.
|
||||
throw ApiException.notImplemented("시각화 갤러리 목록(내구 이력)");
|
||||
List<Map<String, Object>> rows = renderJobMapper.findByBooth(boothId, shotPreset);
|
||||
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
|
||||
@ -125,8 +144,9 @@ public class RenderJobServiceImpl implements RenderJobService {
|
||||
prev.createdAt());
|
||||
saveState(updated);
|
||||
|
||||
// 성공 시에만 쿼터 차감(PLANNING §6-5). eventId는 상태에 미보관 → 내구 저장 시 정본화(매퍼).
|
||||
// TODO(kintex-db-engineer): RenderJobMapper.updateStatus + 성공 시 행사 쿼터 카운터 증가.
|
||||
// 내구 상태 갱신 — 쿼터 정본은 DB의 DONE 건수(countSucceededByEvent)에서 자동 반영(별도 카운터 불필요).
|
||||
renderJobMapper.updateStatus(updated.jobId(), status, updated.imageUrl(), updated.errorMessage());
|
||||
|
||||
notifier.pushJobUpdate(updated);
|
||||
log.info("RenderJob 콜백: job={} status={}", updated.jobId(), status);
|
||||
return updated;
|
||||
@ -153,13 +173,8 @@ public class RenderJobServiceImpl implements RenderJobService {
|
||||
}
|
||||
}
|
||||
|
||||
private static long readLong(String s) {
|
||||
if (s == null) return 0L;
|
||||
try {
|
||||
return Long.parseLong(s);
|
||||
} catch (NumberFormatException e) {
|
||||
return 0L;
|
||||
}
|
||||
private static String str(Object o) {
|
||||
return o == null ? null : String.valueOf(o);
|
||||
}
|
||||
|
||||
/** 워커 에러 메시지는 요약만 통과(스택트레이스·민감정보 유입 방지). */
|
||||
|
||||
@ -2,6 +2,8 @@ package com.zioinfo.kintex.rules;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 규정 위반/확인 항목 — 도면 위 번호 핀과 1:1 연결(design.md ViolationFlag).
|
||||
*
|
||||
@ -12,6 +14,8 @@ import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
* @param severity "block"(차단·제출 불가) | "warn"(경고·확인 후 진행)
|
||||
* @param measured 측정값 요약(예: "5.4m — 0.4m 초과"). 값 미제공 시 null
|
||||
* @param requiresDocument 부속 제출 요구(예: STRUCTURAL_CALC_D7). 없으면 null
|
||||
* @param boothId 위반 부스 식별자(부스 단위 규칙만) — 캔버스 하이라이트 매핑(계약 확장, NON_NULL)
|
||||
* @param anchor 도면 하이라이트 앵커 좌표 [x,y] (미터) — 핀 위치(계약 확장, NON_NULL)
|
||||
*/
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public record Violation(
|
||||
@ -21,6 +25,19 @@ public record Violation(
|
||||
String label,
|
||||
String severity,
|
||||
String measured,
|
||||
String requiresDocument
|
||||
String requiresDocument,
|
||||
String boothId,
|
||||
List<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);
|
||||
}
|
||||
}
|
||||
|
||||
52
src/backend/src/main/resources/mybatis/mapper/HallMapper.xml
Normal file
52
src/backend/src/main/resources/mybatis/mapper/HallMapper.xml
Normal 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>
|
||||
@ -36,4 +36,11 @@
|
||||
ORDER BY e.start_date
|
||||
</select>
|
||||
|
||||
<!-- 사업자등록번호 → 등록업체 여부(CONTRACTOR 초대 게이트). 없으면 null. read-only. -->
|
||||
<select id="findCompanyRegistered" resultType="boolean">
|
||||
SELECT registered
|
||||
FROM company
|
||||
WHERE registration_no = #{registrationNo}
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
|
||||
@ -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>
|
||||
@ -2,6 +2,10 @@ import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { useAuthStore } from './store/authStore';
|
||||
import { LoginPage } from './screens/login/LoginPage';
|
||||
import { BoothLayoutEditorPage } from './screens/floorplan/BoothLayoutEditorPage';
|
||||
import { OrganizerDashboardPage } from './screens/dashboard/OrganizerDashboardPage';
|
||||
import { BoothDesignStudioPage } from './screens/design/BoothDesignStudioPage';
|
||||
import { UtilityWiringPage } from './screens/utility/UtilityWiringPage';
|
||||
import { VisualizationGalleryPage } from './screens/gallery/VisualizationGalleryPage';
|
||||
import { AppShell } from './components/layout/AppShell';
|
||||
|
||||
/** 인증 가드 — 미인증 시 로그인으로. (역할별 라우팅은 화면 추가 시 확장) */
|
||||
@ -24,12 +28,23 @@ export function App() {
|
||||
</RequireAuth>
|
||||
}
|
||||
>
|
||||
{/* SCR-02 주최자 대시보드 */}
|
||||
<Route path="/events/:eventId/dashboard" element={<OrganizerDashboardPage />} />
|
||||
{/* SCR-03 부스 배치 에디터 (M2) */}
|
||||
<Route
|
||||
path="/events/:eventId/halls/:hallId/layout"
|
||||
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 path="*" element={<Navigate to="/login" replace />} />
|
||||
|
||||
@ -8,12 +8,21 @@ import type {
|
||||
AutoLayoutOption,
|
||||
AutoLayoutRequest,
|
||||
ComplianceReport,
|
||||
DesignPlanDto,
|
||||
DesignSaveRequest,
|
||||
DesignSpec,
|
||||
KintexPrincipal,
|
||||
LayoutDto,
|
||||
LayoutSaveRequest,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
RenderJobDto,
|
||||
RenderJobRequest,
|
||||
UtilityOrderDto,
|
||||
UtilityQuote,
|
||||
UtilityQuoteRequest,
|
||||
WiringRequest,
|
||||
WiringResult,
|
||||
WorkspaceDto,
|
||||
} from './types';
|
||||
|
||||
@ -46,10 +55,57 @@ export const layoutApi = {
|
||||
api.post<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 = {
|
||||
status: (eventId: string, jobId: string) =>
|
||||
api.get<RenderJobDto>(
|
||||
`/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,
|
||||
),
|
||||
};
|
||||
|
||||
@ -147,8 +147,119 @@ export interface AutoLayoutOption {
|
||||
s7RenderJobId: string;
|
||||
}
|
||||
|
||||
// ── 4. M3 부스 설계 스튜디오 (SCR-06/09) ──
|
||||
export interface DesignZone {
|
||||
type: string; // demo | consult | reception | storage …
|
||||
ratioPercent: number;
|
||||
}
|
||||
export interface DesignRigging {
|
||||
use: boolean;
|
||||
heightM: number;
|
||||
}
|
||||
export interface DesignMaterial {
|
||||
part: string; // wall | floor | ceiling …
|
||||
finish: string;
|
||||
fireRetardant: boolean;
|
||||
}
|
||||
export interface DesignSpec {
|
||||
boothType: string;
|
||||
industry: string;
|
||||
budget: number;
|
||||
zones: DesignZone[];
|
||||
wallHeightM: number;
|
||||
signageText: string;
|
||||
rigging: DesignRigging;
|
||||
mezzanineAreaRatio: number;
|
||||
materials: DesignMaterial[];
|
||||
lightingMode: string; // day | night
|
||||
usesDesignatedLightingOnly: boolean;
|
||||
}
|
||||
export interface DesignPlanDto {
|
||||
designId: string;
|
||||
boothId: string;
|
||||
version: number;
|
||||
status: string;
|
||||
spec: DesignSpec;
|
||||
updatedAt: string;
|
||||
}
|
||||
export interface DesignSaveRequest {
|
||||
version: number;
|
||||
spec: DesignSpec;
|
||||
}
|
||||
|
||||
// ── 5. M4 유틸리티 (SCR-07/08) ──
|
||||
export interface UtilityDevice {
|
||||
name: string;
|
||||
count: number;
|
||||
powerKw: number;
|
||||
}
|
||||
export interface UtilityQuoteRequest {
|
||||
devices: UtilityDevice[];
|
||||
networkWiredLines: number;
|
||||
plumbingOutlets: number;
|
||||
compressedAirOutlets: number;
|
||||
}
|
||||
export interface QuoteLine {
|
||||
label: string;
|
||||
qty: number;
|
||||
unitPrice: number;
|
||||
amount: number;
|
||||
}
|
||||
export interface UtilityQuote {
|
||||
totalPowerKw: number;
|
||||
requestedKw: number;
|
||||
distributionBox50A: number;
|
||||
lines: QuoteLine[];
|
||||
total: number;
|
||||
currency: string;
|
||||
rulesetVersion: string;
|
||||
disclaimer: string;
|
||||
}
|
||||
export type WiringKind = 'power' | 'network' | 'plumbing' | 'air';
|
||||
export interface WiringTerminal {
|
||||
kind: WiringKind | string;
|
||||
position: [number, number]; // 홀 로컬 좌표(미터)
|
||||
kw?: number;
|
||||
}
|
||||
export interface WiringRequest {
|
||||
terminals: WiringTerminal[];
|
||||
}
|
||||
export interface WiringPath {
|
||||
kind: WiringKind | string;
|
||||
color: 'red' | 'blue' | 'green' | string;
|
||||
coords: number[][]; // [[x,y],…] 홀 로컬 좌표(미터)
|
||||
lengthM: number;
|
||||
label: string;
|
||||
}
|
||||
export interface WiringResult {
|
||||
assumedTrench: boolean;
|
||||
paths: WiringPath[];
|
||||
}
|
||||
export interface UtilityOrderDto {
|
||||
orderId: string;
|
||||
status: string;
|
||||
quote: UtilityQuote;
|
||||
locationDiagramUrl: string;
|
||||
supplyTiming: string;
|
||||
deadlineNotice: string;
|
||||
relayNotice: string;
|
||||
}
|
||||
|
||||
// ── 6. M5 RenderJob (SCR-06/12) ──
|
||||
export type RenderStatus = 'QUEUED' | 'RUNNING' | 'DONE' | 'FAILED';
|
||||
|
||||
// RenderJob 발행 요청 scene (계약 §6 — PLANNING §6-2 스키마)
|
||||
export interface RenderScene {
|
||||
hall: { id: string; dims_m: [number, number]; ceiling_m: number };
|
||||
booth: { id: string; size_m: [number, number]; type: string };
|
||||
design?: { signage?: { text?: string } };
|
||||
lighting?: { mode: string };
|
||||
}
|
||||
export interface RenderJobRequest {
|
||||
shotPreset: string;
|
||||
scene: RenderScene;
|
||||
referenceImageUrl?: string;
|
||||
}
|
||||
export interface RenderJobDto {
|
||||
jobId: string;
|
||||
boothId: string;
|
||||
|
||||
133
src/frontend/src/components/ui/CompareSlider.tsx
Normal file
133
src/frontend/src/components/ui/CompareSlider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
src/frontend/src/components/ui/compare-slider.css
Normal file
78
src/frontend/src/components/ui/compare-slider.css
Normal 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;
|
||||
}
|
||||
167
src/frontend/src/screens/dashboard/OrganizerDashboardPage.tsx
Normal file
167
src/frontend/src/screens/dashboard/OrganizerDashboardPage.tsx
Normal 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 '예정';
|
||||
}
|
||||
}
|
||||
311
src/frontend/src/screens/dashboard/dashboard.css
Normal file
311
src/frontend/src/screens/dashboard/dashboard.css
Normal 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;
|
||||
}
|
||||
}
|
||||
65
src/frontend/src/screens/dashboard/sampleDashboard.ts
Normal file
65
src/frontend/src/screens/dashboard/sampleDashboard.ts
Normal 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: '어제' },
|
||||
];
|
||||
507
src/frontend/src/screens/design/BoothDesignStudioPage.tsx
Normal file
507
src/frontend/src/screens/design/BoothDesignStudioPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
src/frontend/src/screens/design/placeholders.ts
Normal file
28
src/frontend/src/screens/design/placeholders.ts
Normal 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>`,
|
||||
);
|
||||
301
src/frontend/src/screens/design/studio.css
Normal file
301
src/frontend/src/screens/design/studio.css
Normal 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;
|
||||
}
|
||||
}
|
||||
200
src/frontend/src/screens/gallery/VisualizationGalleryPage.tsx
Normal file
200
src/frontend/src/screens/gallery/VisualizationGalleryPage.tsx
Normal 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: '실패',
|
||||
};
|
||||
167
src/frontend/src/screens/gallery/gallery.css
Normal file
167
src/frontend/src/screens/gallery/gallery.css
Normal 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;
|
||||
}
|
||||
}
|
||||
35
src/frontend/src/screens/gallery/sampleGallery.ts
Normal file
35
src/frontend/src/screens/gallery/sampleGallery.ts
Normal 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(),
|
||||
}));
|
||||
}
|
||||
366
src/frontend/src/screens/utility/UtilityWiringPage.tsx
Normal file
366
src/frontend/src/screens/utility/UtilityWiringPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
172
src/frontend/src/screens/utility/WiringCanvas.tsx
Normal file
172
src/frontend/src/screens/utility/WiringCanvas.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
263
src/frontend/src/screens/utility/utility.css
Normal file
263
src/frontend/src/screens/utility/utility.css
Normal 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;
|
||||
}
|
||||
}
|
||||
138
src/frontend/src/screens/utility/wiring.css
Normal file
138
src/frontend/src/screens/utility/wiring.css
Normal 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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user