Compare commits

..

2 Commits

Author SHA1 Message Date
zio
0cc7f26c56 feat(m2): place auto-generated booths inside the hall floorplan
Owner directive 2026-07-14: booths must be auto-placed inside the floorplan.

- hallFloorplan.ts: per-hall floor-region calibration [x0,y0,x1,y1] (auto-measured
  largest color blob of the exhibition floor in each crawled JPG, halls 1-10)
- FloorplanCanvas: align calibrated floor region to hall rect (0,0-W,H m) so booth
  coordinates land inside the drawn floor; raise underlay visibility (0.3 -> 0.55)
- BoothLayoutEditorPage: use real hall dims from LayoutDto.hall (was hardcoded 126x90),
  pass floorRegion; types.ts HallInfoDto added in previous commit
- FloorplanServiceImpl.packBooths: honor request conditions as reserved zones -
  central cross aisles (>=40m spans), entrance clear zones, stage/lounge blocks
  (previously mainEntranceCount/stageCount/loungeCount were ignored)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 22:59:31 +09:00
zio
c686365021 feat(live-demo): admin stats API, auction realtime ranking push, CMS scheduled publish
- AdminStatsController/AdminStatsDto: real-count admin metrics (SCR-16), SystemAccessGuard.requireAdmin
- AuctionRealtimeService: STOMP /topic/auctions/{id}/ranking broadcast after commit (public ranking only, sealed-bid safe), polling fallback kept
- CmsScheduledPublisher: 30s DB poller -> CmsService.runScheduledPublish (same path as manual publish), V58 partial index (idempotent)
- Frontend: AdminDashboardPage real stats wiring, AuctionDetailPage live ranking via websocket, CmsWorkflowPage schedule UI
- CmsServiceTest: constructor updated for AuditLogService

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 22:59:14 +09:00
25 changed files with 863 additions and 73 deletions

View File

@ -57,4 +57,32 @@ public interface AdminDashboardMapper {
LIMIT 20
""")
List<Map<String, Object>> findLiveEvents(@Param("tenantId") String tenantId);
/**
* 관리자 집계 지표 원자 집계 (SCR-16 실전환) 테이블 존재분만.
* event·app_user·visitor_registration 테넌트 스코프, tenant·company 공용 레지스트리(§멀티테넌시 1단계 유지).
* 오늘 체크인 = checkin_state='done' 이고 checkin_at 오늘(테넌트 행사 조인). 신규 스키마 없음.
*/
@Select("""
SELECT
(SELECT count(*) FROM app_user
WHERE tenant_id = #{tenantId}) AS "totalUsers",
(SELECT count(*) FROM app_user
WHERE tenant_id = #{tenantId} AND status = 'ACTIVE') AS "activeUsers",
(SELECT count(*) FROM event
WHERE tenant_id = #{tenantId}) AS "totalEvents",
(SELECT count(*) FROM event
WHERE tenant_id = #{tenantId}
AND start_date <= CURRENT_DATE AND end_date >= CURRENT_DATE) AS "ongoingEvents",
(SELECT count(*) FROM event
WHERE tenant_id = #{tenantId} AND start_date > CURRENT_DATE) AS "upcomingEvents",
(SELECT count(*) FROM tenant) AS "tenantCount",
(SELECT count(*) FROM company) AS "companyCount",
(SELECT count(*) FROM visitor_registration vr
JOIN event e ON e.id = vr.event_id
WHERE e.tenant_id = #{tenantId}
AND vr.checkin_state = 'done'
AND vr.checkin_at::date = CURRENT_DATE) AS "todayCheckins"
""")
Map<String, Object> findStatCounts(@Param("tenantId") String tenantId);
}

View File

@ -1,6 +1,7 @@
package com.zioinfo.kintex.admin;
import com.zioinfo.kintex.admin.dto.AdminDashboardDto;
import com.zioinfo.kintex.admin.dto.AdminStatsDto;
import com.zioinfo.kintex.tenant.TenantContext;
import org.springframework.stereotype.Service;
@ -57,6 +58,24 @@ public class AdminDashboardService {
return new AdminDashboardDto(kpis, visitorTrend, live, tenants);
}
/**
* 관리자 집계 지표(SCR-16 실전환) 테이블 존재분만 반환. 테넌트 스코프는 대시보드와 동일(§8-2).
* 실시간 방문객·주차·라이브 IoT 지표는 미포함(M10/M14 이후) 화면에서 정직한 게이트 유지.
*/
public AdminStatsDto getStats(String tenant) {
String tenantId = TenantContext.currentTenantId();
Map<String, Object> c = mapper.findStatCounts(tenantId);
return new AdminStatsDto(
lng(c == null ? null : c.get("totalUsers")),
lng(c == null ? null : c.get("activeUsers")),
lng(c == null ? null : c.get("totalEvents")),
lng(c == null ? null : c.get("ongoingEvents")),
lng(c == null ? null : c.get("upcomingEvents")),
lng(c == null ? null : c.get("tenantCount")),
lng(c == null ? null : c.get("companyCount")),
lng(c == null ? null : c.get("todayCheckins")));
}
private static long lng(Object o) {
if (o == null) return 0L;
if (o instanceof Number n) return n.longValue();

View File

@ -0,0 +1,37 @@
package com.zioinfo.kintex.admin;
import com.zioinfo.kintex.admin.dto.AdminStatsDto;
import com.zioinfo.kintex.auth.KintexPrincipal;
import com.zioinfo.kintex.common.ApiResponse;
import com.zioinfo.kintex.system.SystemAccessGuard;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 관리자 집계 지표 API (SCR-16 실전환 · UNDEVELOPED_BACKLOG §1).
* 시스템관리자(홀매니저/ADMIN) 접근 대시보드 컨트롤러와 동일 가드(SystemAccessGuard.requireAdmin).
* 테이블 존재분(사용자·행사·테넌트·등록업체·오늘 체크인) 집계. 실시간 방문객·주차·라이브는 미포함.
*/
@RestController
@RequestMapping("/api/admin/stats")
public class AdminStatsController {
private final AdminDashboardService service;
private final SystemAccessGuard guard;
public AdminStatsController(AdminDashboardService service, SystemAccessGuard guard) {
this.service = service;
this.guard = guard;
}
/** GET — 실 집계 가능한 관리자 지표(개수만, 자격증명·PII 미포함). */
@GetMapping
public ApiResponse<AdminStatsDto> get(@AuthenticationPrincipal KintexPrincipal principal,
@RequestParam(required = false, defaultValue = "KINTEX") String tenant) {
guard.requireAdmin(principal);
return ApiResponse.ok(service.getStats(tenant));
}
}

View File

@ -0,0 +1,19 @@
package com.zioinfo.kintex.admin.dto;
/**
* 관리자 집계 지표 (SCR-16 실전환 · UNDEVELOPED_BACKLOG §1).
* 이미 테이블이 존재하는 집계 가능 지표만 노출한다 event · app_user · tenant · company · visitor_registration.
* 실시간 방문객·주차·라이브 IoT 지표는 M10/M14 센서·집계 인프라 이후 계약에 미포함(가짜 수치 생성 금지).
* 자격증명·PII(이메일 원문 ) 응답에 포함하지 않는다(개수 집계만).
*/
public record AdminStatsDto(
long totalUsers,
long activeUsers,
long totalEvents,
long ongoingEvents,
long upcomingEvents,
long tenantCount,
long companyCount,
long todayCheckins
) {
}

View File

@ -0,0 +1,117 @@
package com.zioinfo.kintex.auction;
import com.zioinfo.kintex.auction.dto.AuctionDtos.RankingSnapshot;
import com.zioinfo.kintex.auction.dto.AuctionDtos.RankRow;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.time.Instant;
import java.util.List;
import java.util.Map;
/**
* M15 옥션 실시간 순위 브로드캐스트(폴링 WebSocket 푸시 전환).
*
* <p>구독 토픽: {@code /topic/auctions/{auctionId}/ranking} (기존 {@code /topic/render/{jobId}} 컨벤션과 정합).
* 렌더 완료 푸시와 동일한 STOMP 인프라({@link com.zioinfo.kintex.config.WebSocketConfig}) 재사용한다
* 브로커 설정은 신설하지 않는다.
*
* <p><b>봉인 입찰 보안</b>: 페이로드({@link RankingSnapshot}) 모든 구독자에게 동일 전송되므로
* {@link AuctionService#publicRanking}(뷰어별 개인화 없음·최저가만 공개) 담는다. 개인화 순위( 순위·금액)
* 클라이언트가 인증된 상세 API로 재조회하여 서버 마스킹을 그대로 적용받는다.
*
* <p><b>트랜잭션 경계</b>: 순위 변동 트랜잭션이 <b>커밋된 뒤에만</b> 전송한다(afterCommit). 롤백 미전송
* 유령 갱신·구독자 재조회 레이스(커밋 조회) 방지한다.
*/
@Service
public class AuctionRealtimeService {
private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(AuctionRealtimeService.class);
private final SimpMessagingTemplate messaging;
private final AuctionMapper mapper;
public AuctionRealtimeService(SimpMessagingTemplate messaging, AuctionMapper mapper) {
this.messaging = messaging;
this.mapper = mapper;
}
/**
* 순위 변동 이벤트를 커밋 브로드캐스트한다.
*
* @param auctionId 옥션 ID
* @param event BID | CLOSE | AWARD (원인 라벨)
*/
public void broadcastRanking(String auctionId, String event) {
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
sendSafe(auctionId, event);
}
});
} else {
sendSafe(auctionId, event);
}
}
/** 전송 실패는 격리(순위 갱신은 폴백 폴링이 보장) — 스택트레이스 미노출. */
private void sendSafe(String auctionId, String event) {
try {
RankingSnapshot snap = buildSnapshot(auctionId, event);
if (snap == null) {
return;
}
messaging.convertAndSend("/topic/auctions/" + auctionId + "/ranking", snap);
} catch (RuntimeException e) {
log.warn("옥션 순위 브로드캐스트 실패(무시·폴링 폴백): auction={} reason={}",
auctionId, e.getClass().getSimpleName());
}
}
private RankingSnapshot buildSnapshot(String auctionId, String event) {
Map<String, Object> a = mapper.findAuction(auctionId);
if (a == null) {
return null;
}
boolean closed = bool(a.get("closed"));
List<RankRow> ranking = AuctionService.publicRanking(mapper.listRanking(auctionId));
return new RankingSnapshot(
auctionId, str(a.get("eventId")), event,
intOr(a.get("round"), 1), !closed,
intOr(a.get("bidderCount"), 0), lng(a.get("lowestPrice")),
ranking, Instant.now().toString());
}
// 로컬 매핑 헬퍼(AuctionService 동일 규칙, 무상태)
private static String str(Object o) {
return o == null ? null : String.valueOf(o);
}
private static boolean bool(Object o) {
if (o instanceof Boolean b) return b;
return o != null && Boolean.parseBoolean(String.valueOf(o));
}
private static int intOr(Object o, int def) {
if (o == null) return def;
if (o instanceof Number n) return n.intValue();
try {
return Integer.parseInt(String.valueOf(o));
} catch (NumberFormatException e) {
return def;
}
}
private static Long lng(Object o) {
if (o == null) return null;
if (o instanceof Number n) return n.longValue();
try {
return Long.valueOf(String.valueOf(o));
} catch (NumberFormatException e) {
return null;
}
}
}

View File

@ -37,17 +37,20 @@ public class AuctionService {
private final com.zioinfo.kintex.work.notification.NotificationService notificationService;
private final com.zioinfo.kintex.mail.MailService mailService;
private final com.zioinfo.kintex.mail.MailProperties mailProperties;
private final AuctionRealtimeService realtime;
public AuctionService(AuctionMapper mapper,
com.zioinfo.kintex.settlement.SettlementService settlementService,
com.zioinfo.kintex.work.notification.NotificationService notificationService,
com.zioinfo.kintex.mail.MailService mailService,
com.zioinfo.kintex.mail.MailProperties mailProperties) {
com.zioinfo.kintex.mail.MailProperties mailProperties,
AuctionRealtimeService realtime) {
this.mapper = mapper;
this.settlementService = settlementService;
this.notificationService = notificationService;
this.mailService = mailService;
this.mailProperties = mailProperties;
this.realtime = realtime;
}
// 목록(SCR-26)
@ -128,6 +131,25 @@ public class AuctionService {
return out;
}
/**
* 공개 순위(브로드캐스트용). {@link #buildRanking} myCompanyId=null·sealed 호출한 것과 동일
* <b>비응찰 뷰어가 폴링으로 보는 순위</b>(최저가 금액만 공개, 타사 금액·업체명 마스킹).
* 뷰어별 개인화(isMe· 금액) 절대 포함하지 않으므로 모든 구독자에게 전송해도 봉인 규칙을 위반하지 않는다.
*/
static List<RankRow> publicRanking(List<Map<String, Object>> rows) {
List<RankRow> out = new ArrayList<>();
int rank = 0;
for (Map<String, Object> row : rows) {
rank++;
boolean isLowest = rank == 1;
boolean reveal = isLowest; // isMe 항상 false 금액 노출 없음
String alias = isLowest ? "현재 최저가" : "업체 " + rank;
out.add(new RankRow(rank, alias, false, isLowest, !reveal,
reveal ? lngPrim(row.get("total")) : null));
}
return out;
}
// 응찰(견적서 제출)
@Transactional
public BidResult placeBid(String id, KintexPrincipal principal, BidRequest req) {
@ -195,6 +217,8 @@ public class AuctionService {
int myRank = mapper.lowerBidCount(id, total) + 1;
Long lowest = lng(mapper.findAuction(id).get("lowestPrice"));
// 순위 변동 커밋 공개 스냅샷 브로드캐스트(구독자 실시간 갱신 트리거).
realtime.broadcastRanking(id, "BID");
return new BidResult(bidId, intOr(a.get("round"), 1), subtotal, vat, total, subtotal + vat,
version, myRank, lowest);
}
@ -217,6 +241,8 @@ public class AuctionService {
throw new ApiException(ErrorCode.CONFLICT); // 이미 마감됨
}
mapper.closeAuction(id);
// 마감 봉인 상태(sealed=false) 변경을 구독자에게 반영.
realtime.broadcastRanking(id, "CLOSE");
return toSummary(mapper.findAuction(id));
}
@ -262,6 +288,8 @@ public class AuctionService {
// 수수료 청구 생성 실패는 낙찰 확정을 되돌리지 않는다(정산은 후속 재생성 가능). 스택트레이스 미노출.
}
// 낙찰 확정 상태 전환을 구독자에게 반영(순위 화면 실시간 갱신).
realtime.broadcastRanking(id, "AWARD");
return new AwardResult(awardId, req.bidId(), str(aw.get("companyName")), str(aw.get("awardedAt")));
}

View File

@ -52,6 +52,19 @@ public final class AuctionDtos {
boolean priceMasked, Long price) {
}
/**
* 실시간 순위 브로드캐스트 스냅샷(WebSocket {@code /topic/auctions/{id}/ranking}).
* <p><b>봉인 안전(구조적 보장)</b>: 페이로드는 모든 구독자에게 동일하게 전송되므로
* <b>뷰어별 개인화 정보( 순위· 금액·isMe) 담지 않는다</b>. ranking {@code isMe=false} 고정으로
* 마스킹된 <b>공개 순위</b>(현행 폴링이 비응찰 발주자에게 노출하는 수준과 동일 최저가 금액만 공개,
* 타사 금액·업체명 비공개) 포함한다. 개인화 순위는 클라이언트가 인증된 상세 API로 재조회한다.
* event = BID(응찰 접수/재응찰) | CLOSE(라운드 마감) | AWARD(낙찰).
*/
public record RankingSnapshot(
String auctionId, String eventId, String event, int round, boolean sealed,
int bidderCount, Long lowestPrice, List<RankRow> ranking, String at) {
}
/** 상세(SCR-27). ranking 은 봉인 마스킹 적용본. canBid/canViewAward 로 UI 게이팅. */
public record AuctionDetail(
String id, String eventId, String title, String category, String type, String status,

View File

@ -128,16 +128,35 @@ public interface CmsMapper {
""")
Map<String, Object> findVersion(@Param("contentId") String contentId, @Param("versionNo") int versionNo);
// 예약 게시(도달분 자동 게시)
/** scheduled_at 이 now 도달·미게시(approved 이하)인 콘텐츠를 published 로 승격. 반환=처리 건수. */
// 예약 게시(도달분 자동 게시 승인 상태만, @Scheduled 폴러/스윕이 사용)
/**
* 예약 시각 도달·승인(approved) 상태인 예약 콘텐츠의 id/event 목록(스윕 상한).
* raw '&lt;' 미사용 부팅 크래시 회피 위해 {@code now() >= scheduled_at} 표현한다.
*/
@Select("""
SELECT id, event_id AS "eventId"
FROM cms_content
WHERE scheduled_at IS NOT NULL
AND now() >= scheduled_at
AND status = 'approved'
ORDER BY scheduled_at
LIMIT #{limit}
""")
List<Map<String, Object>> findDueScheduled(@Param("limit") int limit);
/**
* 예약 게시 단건 전이(멱등·이중 전이 방지) approvedpublished 조건 UPDATE.
* 반환=영향 (0=이미 게시됨/전이 불가). WHERE 상태 조건으로 동시/재실행 이중 전이를 차단한다.
*/
@Update("""
UPDATE cms_content
SET status = 'published', published_at = now(), updated_at = now()
WHERE scheduled_at IS NOT NULL
AND scheduled_at <= now()
AND status <> 'published'
WHERE id = #{id}
AND status = 'approved'
AND scheduled_at IS NOT NULL
AND now() >= scheduled_at
""")
int publishDueScheduled();
int publishScheduledDue(@Param("id") String id);
// 미디어 라이브러리
@Insert("""

View File

@ -0,0 +1,42 @@
package com.zioinfo.kintex.cms;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* M17 CMS 예약 게시 트리거 Spring {@code @Scheduled} DB 폴러.
*
* <p>Redis 지연 대신 <b>DB 폴러</b> 채택했다: 단일 노드 전제에서 단순·결정적이며, 재기동 예약이
* 유실되지 않는다(예약 시각은 {@code cms_content.scheduled_at} 영속). 폴러는 30초 주기로
* {@link CmsService#runScheduledPublish()} 호출하며, 실제 전이(상태 조건 UPDATE·버전 스냅샷·감사 로그)
* 서비스가 <b>수동 게시와 동일한 경로</b> 수행한다.
*
* <p>{@code @Scheduled} 전역 활성화는 기존 {@code WebhookSchedulingConfig(@EnableScheduling)} 담당한다.
* 스윕 예외는 조용히 흡수하고 다음 주기에 재시도한다(스택트레이스·민감정보 미노출).
*/
@Component
public class CmsScheduledPublisher {
private static final Logger log = LoggerFactory.getLogger(CmsScheduledPublisher.class);
private final CmsService cmsService;
public CmsScheduledPublisher(CmsService cmsService) {
this.cmsService = cmsService;
}
/** 예약 게시 폴러 — 기동 30초 후 최초 실행, 이후 30초 고정 지연(이전 스윕 종료 기준). */
@Scheduled(initialDelay = 30_000, fixedDelay = 30_000)
public void sweep() {
try {
int published = cmsService.runScheduledPublish();
if (published > 0) {
log.info("M17 CMS 예약 게시 자동 전이 {}건", published);
}
} catch (Exception e) {
log.warn("M17 CMS 예약 게시 스윕 실패: {}", e.getClass().getSimpleName());
}
}
}

View File

@ -5,6 +5,7 @@ import com.zioinfo.kintex.ai.AiTextRouter.AiResult;
import com.zioinfo.kintex.auth.KintexPrincipal;
import com.zioinfo.kintex.cms.dto.*;
import com.zioinfo.kintex.common.PageResponse;
import com.zioinfo.kintex.common.audit.AuditLogService;
import com.zioinfo.kintex.common.error.ApiException;
import com.zioinfo.kintex.common.error.ErrorCode;
import com.zioinfo.kintex.common.text.HtmlSanitizer;
@ -54,23 +55,30 @@ public class CmsService {
private static final Map<String, String> LANG_NAMES = Map.of(
"ko", "한국어(Korean)", "en", "영어(English)", "zh", "중국어 간체(Simplified Chinese)", "ja", "일본어(Japanese)");
/** 예약 게시 스윕 1회당 처리 상한(폭주 방지). */
private static final int SCHED_SWEEP_LIMIT = 200;
/** 예약 게시 감사/버전 행위자(시스템). */
private static final String SCHED_ACTOR_ID = "system-scheduler";
private static final String SCHED_ACTOR_NAME = "시스템(예약 게시)";
private final CmsMapper mapper;
private final SystemAccessGuard scope;
private final AiTextRouter aiRouter;
private final AuditLogService audit;
private final Path uploadRoot;
public CmsService(CmsMapper mapper, SystemAccessGuard scope, AiTextRouter aiRouter,
public CmsService(CmsMapper mapper, SystemAccessGuard scope, AiTextRouter aiRouter, AuditLogService audit,
@Value("${kintex.upload.dir:./data/uploads}") String uploadDir) {
this.mapper = mapper;
this.scope = scope;
this.aiRouter = aiRouter;
this.audit = audit;
this.uploadRoot = Paths.get(uploadDir).toAbsolutePath().normalize();
}
// 콘텐츠 목록/조회
public PageResponse<CmsContentDto> list(String eventId, String status, String type, String keyword,
int page, int size) {
runScheduledPublish();
int p = Math.max(page, 0);
int s = size <= 0 ? 50 : Math.min(size, 200);
Map<String, Object> q = new HashMap<>();
@ -434,20 +442,47 @@ public class CmsService {
}
// 예약 게시 / 공개 조회
/** 예약 시각 도달분 자동 게시(조회 시 지연 처리). 반환=이번에 게시된 건수. */
/**
* 예약 게시 스윕 {@code CmsScheduledPublisher} (@Scheduled 폴러) 주기 호출한다.
* <p>대상: {@code scheduled_at <= now} AND status='approved' 콘텐츠. 건을 <b>수동 게시와 동일한 경로</b>
* published 전이한다 상태 조건 UPDATE(이중 전이 방지) {@link #snapshot} 버전 스냅샷 {@link AuditLogService}
* 감사 기록(action=CMS_CONTENT_TRANSITION, 수동 전이와 동일). 전이 로직을 복제하지 않고 재사용한다.
* @return 이번 스윕에서 새로 게시된 건수.
*/
@Transactional
public int runScheduledPublish() {
List<Map<String, Object>> due;
try {
return mapper.publishDueScheduled();
due = mapper.findDueScheduled(SCHED_SWEEP_LIMIT);
} catch (RuntimeException e) {
log.warn("scheduled publish sweep skipped: {}", e.getMessage());
log.warn("scheduled publish sweep skipped: {}", e.getClass().getSimpleName());
return 0;
}
if (due == null || due.isEmpty()) {
return 0;
}
int published = 0;
for (Map<String, Object> row : due) {
String id = str(row.get("id"));
if (id == null) {
continue;
}
// 이중 전이 방지 상태 조건 UPDATE(approvedpublished). 0이면 이미 처리됨 스킵.
if (mapper.publishScheduledDue(id) == 0) {
continue;
}
// 수동 게시와 동일하게 버전 스냅샷 + 감사 로그를 남긴다(전이 기록 정합).
snapshot(id, "scheduled-publish", SCHED_ACTOR_NAME);
audit.record(SCHED_ACTOR_ID, SCHED_ACTOR_NAME, "CMS_CONTENT_TRANSITION",
"cms_content", id, str(row.get("eventId")),
"예약 게시 자동 전이(approved → published)", "SUCCESS");
published++;
}
return published;
}
/** 공개 조회(무인증) — 게시 상태만. 조회 시 예약 도달분 자동 게시 후 반환. */
/** 공개 조회(무인증) — 게시 상태만. 예약 전이는 @Scheduled 폴러가 담당(조회는 순수 읽기). */
public List<CmsContentDto> publicByType(String type, String eventId, int limit) {
runScheduledPublish();
Map<String, Object> q = new HashMap<>();
q.put("type", type == null ? "PAGE" : type.trim().toUpperCase(Locale.ROOT));
q.put("eventId", blankToNull(eventId));

View File

@ -353,7 +353,12 @@ public class FloorplanServiceImpl implements FloorplanService {
+ " 예: {\"opt-A\":\"...\",\"opt-B\":\"...\"}\n";
}
/** 제약 기반 그리드 패킹 — 외곽 주통로·통로 폭·프리미엄 비율을 반영해 목표 수까지 배치. */
/**
* 제약 기반 그리드 패킹 외곽 주통로·통로 ·프리미엄 비율에 더해 요청 조건
* (주출입구·무대·라운지 ) <b>예약 영역</b>으로 반영해 목표 수까지 배치한다.
* 예약 영역(중앙 교차 통로·출입구 전면 클리어존·무대·라운지) 겹치는 셀은 건너뛰어
* 배치안이 실제 전시 평면도처럼 바닥면 안에서 구획된다(소유자 지시 2026-07-14).
*/
private List<BoothDto> packBooths(double boothW, double boothD, double aisle,
double hallW, double hallD, AutoLayoutRequest req, char tag) {
List<BoothDto> booths = new ArrayList<>();
@ -364,9 +369,14 @@ public class FloorplanServiceImpl implements FloorplanService {
double usableW = hallW - 2 * PERIMETER_MARGIN_M;
double usableD = hallD - 2 * PERIMETER_MARGIN_M;
List<double[]> reserved = reservedZones(hallW, hallD, aisle, req);
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) {
if (intersectsAny(reserved, x, y, x + boothW, y + boothD)) {
continue;
}
index++;
boolean premium = index <= premiumTarget;
String boothNo = String.format("%c-%03d", tag, index);
@ -379,6 +389,59 @@ public class FloorplanServiceImpl implements FloorplanService {
return booths;
}
/**
* 요청 조건 기반 예약 영역 [x0,y0,x1,y1] 목록( 로컬 m) 결정적 산출.
* <ul>
* <li>중앙 교차 주통로: 40m 이상이면 세로, 깊이 40m 이상이면 가로( max(1.5×통로, 4.5m))</li>
* <li>주출입구 클리어존: 전면(y=0) 변에 균등 분포, 개소당 9m × 6m 깊이</li>
* <li>무대: 후면(y=hallD) 중앙부에 12×9m + 사방 3m 버퍼</li>
* <li>라운지: 중앙부에 9×9m + 사방 3m 버퍼</li>
* </ul>
*/
private List<double[]> reservedZones(double hallW, double hallD, double aisle, AutoLayoutRequest req) {
List<double[]> zones = new ArrayList<>();
double mainAisle = Math.max(aisle * 1.5, 4.5);
if (hallW >= 40) {
double cx = hallW / 2;
zones.add(new double[]{cx - mainAisle / 2, 0, cx + mainAisle / 2, hallD});
}
if (hallD >= 40) {
double cy = hallD / 2;
zones.add(new double[]{0, cy - mainAisle / 2, hallW, cy + mainAisle / 2});
}
int entrances = Math.max(0, req.mainEntranceCount());
for (int i = 1; i <= entrances; i++) {
double ex = hallW * i / (entrances + 1.0);
zones.add(new double[]{ex - 4.5, 0, ex + 4.5, PERIMETER_MARGIN_M + 6.0});
}
int stages = Math.max(0, req.stageCount());
for (int i = 1; i <= stages; i++) {
double sx = hallW * i / (stages + 1.0);
double sy = hallD - PERIMETER_MARGIN_M - 9.0;
zones.add(new double[]{sx - 6.0 - 3.0, sy - 3.0, sx + 6.0 + 3.0, hallD});
}
int lounges = Math.max(0, req.loungeCount());
for (int i = 1; i <= lounges; i++) {
double lx = hallW * i / (lounges + 1.0);
double ly = hallD / 2;
zones.add(new double[]{lx - 4.5 - 3.0, ly - 4.5 - 3.0, lx + 4.5 + 3.0, ly + 4.5 + 3.0});
}
return zones;
}
private static boolean intersectsAny(List<double[]> zones, double x0, double y0, double x1, double y1) {
for (double[] z : zones) {
if (x0 < z[2] && x1 > z[0] && y0 < z[3] && y1 > z[1]) {
return true;
}
}
return false;
}
/** S7 홀 전경(조감) 프리뷰 발행 — 렌더 인프라 장애 시에도 자동배치가 성립하도록 degraded(null) 허용. */
private String publishS7Preview(String eventId, String hallId, HallInfo hall, char tag) {
try {

View File

@ -0,0 +1,8 @@
-- V58: M17 CMS 예약 게시 폴러 최적화 인덱스 (멱등 · 스키마 컬럼 변경 없음)
-- 근거: CmsScheduledPublisher(@Scheduled 30초 폴러)가 findDueScheduled 를 주기 실행한다
-- (WHERE scheduled_at IS NOT NULL AND now() >= scheduled_at AND status = 'approved').
-- cms_content.scheduled_at 컬럼은 V19 에서 이미 존재하므로 신규 컬럼은 없다. 반복 조회를 위한
-- 부분 인덱스만 추가한다(승인·예약 건만 인덱싱 → 작고 선택도 높음). IF NOT EXISTS 로 멱등.
CREATE INDEX IF NOT EXISTS idx_cms_content_sched_due
ON cms_content (scheduled_at)
WHERE scheduled_at IS NOT NULL AND status = 'approved';

View File

@ -38,7 +38,8 @@ class CmsServiceTest {
mapper = mock(CmsMapper.class);
scope = mock(SystemAccessGuard.class);
aiRouter = mock(AiTextRouter.class);
service = new CmsService(mapper, scope, aiRouter, "./build/test-uploads");
service = new CmsService(mapper, scope, aiRouter,
mock(com.zioinfo.kintex.common.audit.AuditLogService.class), "./build/test-uploads");
}
private Map<String, Object> contentRow(String status) {

View File

@ -6,6 +6,7 @@ import { api } from './client';
import type {
AcceptInviteRequest,
AdminDashboardData,
AdminStats,
AnalyticsData,
AnalyticsPeriod,
AnalyticsPerspective,
@ -257,6 +258,11 @@ export const adminApi = {
api.get<AdminDashboardData>(
`/api/admin/dashboard${tenant ? `?tenant=${encodeURIComponent(tenant)}` : ''}`,
),
// SCR-16 실 집계 지표(사용자·행사·테넌트·등록업체·오늘 체크인) — 실 테이블 존재분만.
stats: (tenant?: string) =>
api.get<AdminStats>(
`/api/admin/stats${tenant ? `?tenant=${encodeURIComponent(tenant)}` : ''}`,
),
};
// ── SCR-16 홀 현장 운영 ──

View File

@ -163,6 +163,14 @@ export interface LayoutSummary {
violationBlock: number;
violationWarn: number;
}
/** 홀 마스터 정보(M2 LayoutDto.hall) — 캔버스 축척·경계 원천. dimsM=[폭, 깊이](m). */
export interface HallInfoDto {
hallId: string;
label?: string | null;
dimsM?: number[] | null;
ceilingM?: number | null;
targetBoothCount?: number | null;
}
export interface LayoutDto {
layoutId: string;
eventId: string;
@ -171,6 +179,7 @@ export interface LayoutDto {
name: string;
status: string;
booths: BoothDto[];
hall?: HallInfoDto | null;
summary: LayoutSummary;
updatedAt: string;
}
@ -706,6 +715,18 @@ export interface AdminDashboardData {
tenants: string[];
}
// ── SCR-16 관리자 집계 지표 (admin.AdminStatsDto) — 실 테이블 존재분만 ──
export interface AdminStats {
totalUsers: number;
activeUsers: number;
totalEvents: number;
ongoingEvents: number;
upcomingEvents: number;
tenantCount: number;
companyCount: number;
todayCheckins: number;
}
// ── SCR-16 홀 현장 운영 (ops.OpsDto) ──
export interface OpsHall {
hallId: string;

View File

@ -1,14 +1,25 @@
/*
* WebSocket(STOMP over SockJS) RenderJob .
* 근거: 계약 §6 GET /ws(SockJS), prefix /topic,
* /topic/render/{jobId} RenderJobDto (design.md §1-4).
* WebSocket(STOMP over SockJS) .
* 근거: 계약 §6 GET /ws(SockJS), prefix /topic.
* - RenderJob : /topic/render/{jobId} (design.md §1-4)
* - : /topic/auctions/{auctionId}/ranking (M15 )
* STOMP refCount , // .
*/
import { Client, type IMessage, type StompSubscription } from '@stomp/stompjs';
import SockJS from 'sockjs-client';
import type { RenderJobDto } from './types';
/** 구독자에게 알리는 연결 상태(옥션 순위 폴백 판단용). */
export type WsStatus = 'connecting' | 'connected' | 'disconnected';
interface ConnListener {
onConnect: () => void;
onDisconnect: () => void;
}
let client: Client | null = null;
let refCount = 0;
const connListeners = new Set<ConnListener>();
const WS_URL = `${(import.meta.env.VITE_API_BASE as string | undefined) ?? ''}/ws`;
@ -20,13 +31,30 @@ function ensureClient(): Client {
reconnectDelay: 4000,
heartbeatIncoming: 10000,
heartbeatOutgoing: 10000,
// 조용한 프로덕션 로깅
debug: () => {},
onConnect: () => connListeners.forEach((l) => l.onConnect()),
onWebSocketClose: () => connListeners.forEach((l) => l.onDisconnect()),
onStompError: () => connListeners.forEach((l) => l.onDisconnect()),
});
client.activate();
return client;
}
function acquire(): Client {
refCount += 1;
return ensureClient();
}
function release(): void {
refCount -= 1;
if (refCount <= 0 && client) {
void client.deactivate();
client = null;
refCount = 0;
connListeners.clear();
}
}
/**
* RenderJob / .
* @returns .
@ -35,11 +63,9 @@ export function subscribeRenderJob(
jobId: string,
onUpdate: (job: RenderJobDto) => void,
): () => void {
const c = ensureClient();
refCount += 1;
let sub: StompSubscription | null = null;
const c = acquire();
const topic = `/topic/render/${jobId}`;
let sub: StompSubscription | null = null;
const doSubscribe = () => {
sub = c.subscribe(topic, (msg: IMessage) => {
@ -51,24 +77,74 @@ export function subscribeRenderJob(
});
};
const listener: ConnListener = {
onConnect: () => {
sub?.unsubscribe();
doSubscribe(); // 최초 연결·재연결 모두에서 재구독
},
onDisconnect: () => {
sub = null;
},
};
connListeners.add(listener);
if (c.connected) doSubscribe();
return () => {
connListeners.delete(listener);
sub?.unsubscribe();
release();
};
}
/**
* (M15). (··) .
* , <b> </b>
* API로 .
*
* @param onStatus 'disconnected' .
* @returns .
*/
export function subscribeAuctionRanking<T>(
auctionId: string,
onUpdate: (snapshot: T) => void,
onStatus?: (status: WsStatus) => void,
): () => void {
const c = acquire();
const topic = `/topic/auctions/${auctionId}/ranking`;
let sub: StompSubscription | null = null;
const doSubscribe = () => {
sub = c.subscribe(topic, (msg: IMessage) => {
try {
onUpdate(JSON.parse(msg.body) as T);
} catch {
/* 잘못된 페이로드 무시 */
}
});
};
const listener: ConnListener = {
onConnect: () => {
sub?.unsubscribe();
doSubscribe();
onStatus?.('connected');
},
onDisconnect: () => {
sub = null;
onStatus?.('disconnected');
},
};
connListeners.add(listener);
if (c.connected) {
doSubscribe();
onStatus?.('connected');
} else {
// 연결 후 구독
const prev = c.onConnect;
c.onConnect = (frame) => {
prev?.(frame);
doSubscribe();
};
onStatus?.('connecting');
}
return () => {
connListeners.delete(listener);
sub?.unsubscribe();
refCount -= 1;
if (refCount <= 0 && client) {
void client.deactivate();
client = null;
refCount = 0;
}
release();
};
}

View File

@ -17,14 +17,16 @@ import { IconClock, IconOperations } from '../../components/ui/icons';
import { adminApi } from '../../api/endpoints';
import { ApiRequestError } from '../../api/client';
import { StatusPill } from '../work/workShared';
import type { AdminDashboardData } from '../../api/types';
import type { AdminDashboardData, AdminStats } from '../../api/types';
import { ADMIN_KPIS, LIVE_EVENTS, TENANTS, VISITOR_TREND } from './sampleAdmin';
import './admin.css';
/*
* SCR-14 (M18 ). Stitch admin_dashboard .
* 경로: GET /api/admin/dashboard?tenant ( / ·· ).
* 폴백: NETWORK/NOT_FOUND sampleAdmin . .
* SCR-14/16 (M18 ). Stitch admin_dashboard .
* KPI: GET /api/admin/stats ( ···· , ).
* /추이: GET /api/admin/dashboard?tenant ( ). .
* ·· IoT M10/M14 ( ).
* 폴백: NETWORK/NOT_FOUND/NOT_IMPLEMENTED (degraded ).
*/
export function AdminDashboardPage() {
const { t } = useTranslation();
@ -36,9 +38,20 @@ export function AdminDashboardPage() {
retry: false,
});
const sq = useQuery({
queryKey: ['admin-stats', tenant],
queryFn: () => adminApi.stats(tenant),
retry: false,
});
const degraded = isDegradable(q.error);
const data: AdminDashboardData | null = q.data ?? (degraded ? FALLBACK : null);
const hardError = q.isError && !degraded;
const statsDegraded = isDegradable(sq.error);
const stats: AdminStats | null = sq.data ?? (statsDegraded ? STATS_FALLBACK : null);
const statsHardError = sq.isError && !statsDegraded;
const tenants = data?.tenants?.length ? data.tenants : TENANTS;
const activeTenant = tenant ?? tenants[0];
@ -75,13 +88,39 @@ export function AdminDashboardPage() {
))}
</nav>
{degraded && (
{(degraded || statsDegraded) && (
<div className="kx-admin__degraded-bar">
<span className="kx-bi__degraded">{t('admin.offlineSample')}</span>
{t('admin.degradedMsg')}
</div>
)}
{/* KPI — 실 집계 지표(GET /api/admin/stats). 실 테이블 존재분만. */}
<section className="kx-admin__kpis" aria-label={t('admin.kpisAria')}>
{sq.isLoading ? (
<>
<Skeleton height={92} radius={12} />
<Skeleton height={92} radius={12} />
<Skeleton height={92} radius={12} />
</>
) : statsHardError ? (
<ErrorState
message={t('admin.statsLoadError', { defaultValue: '집계 지표를 불러오지 못했습니다.' })}
onRetry={() => sq.refetch()}
/>
) : stats ? (
buildKpiCards(stats, t).map((k, i) => (
<div key={`${k.label}-${i}`} className="kx-kpi kx-kpi--normal">
<span className="kx-kpi__label">{k.label}</span>
<strong className="kx-kpi__value tnum">{k.value}</strong>
{k.sub && <span className="kx-kpi__sub">{k.sub}</span>}
</div>
))
) : (
<EmptyState title={t('admin.kpiEmpty')} />
)}
</section>
{q.isLoading && <AdminSkeleton />}
{hardError && (
@ -90,20 +129,6 @@ export function AdminDashboardPage() {
{data && (
<>
<section className="kx-admin__kpis" aria-label={t('admin.kpisAria')}>
{data.kpis.length === 0 ? (
<EmptyState title={t('admin.kpiEmpty')} />
) : (
data.kpis.map((k, i) => (
<div key={`${k.label}-${i}`} className="kx-kpi kx-kpi--normal">
<span className="kx-kpi__label">{k.label}</span>
<strong className="kx-kpi__value tnum">{k.value}</strong>
{k.sub && <span className="kx-kpi__sub">{k.sub}</span>}
</div>
))
)}
</section>
<div className="kx-admin__split">
<section className="kx-card kx-admin__chart-card" aria-label={t('admin.visitorTrend')}>
<div className="kx-card__head">
@ -213,6 +238,60 @@ const FALLBACK: AdminDashboardData = {
tenants: TENANTS,
};
/** 집계 지표 강등 폴백 — NETWORK/NOT_FOUND 시에만(정상 경로는 실 API). degraded 배너와 함께 노출. */
const STATS_FALLBACK: AdminStats = {
totalUsers: 0,
activeUsers: 0,
totalEvents: 0,
ongoingEvents: 0,
upcomingEvents: 0,
tenantCount: 0,
companyCount: 0,
todayCheckins: 0,
};
type Translate = (key: string, opts?: Record<string, unknown>) => string;
/**
* (AdminStats) KPI . shared (t defaultValue ).
* ·· IoT ( ) / .
*/
function buildKpiCards(s: AdminStats, t: Translate): { label: string; value: string; sub: string | null }[] {
const n = (v: number) => v.toLocaleString();
return [
{
label: t('admin.kpiOngoing', { defaultValue: '진행 행사' }),
value: n(s.ongoingEvents),
sub: t('admin.kpiOngoingSub', { defaultValue: '전체 {{n}}건', n: n(s.totalEvents) }),
},
{
label: t('admin.kpiUpcoming', { defaultValue: '예정 행사' }),
value: n(s.upcomingEvents),
sub: null,
},
{
label: t('admin.kpiActiveUsers', { defaultValue: '활성 사용자' }),
value: n(s.activeUsers),
sub: t('admin.kpiActiveUsersSub', { defaultValue: '총 {{n}}명', n: n(s.totalUsers) }),
},
{
label: t('admin.kpiCompanies', { defaultValue: '등록업체' }),
value: n(s.companyCount),
sub: null,
},
{
label: t('admin.kpiTodayCheckins', { defaultValue: '오늘 체크인' }),
value: n(s.todayCheckins),
sub: null,
},
{
label: t('admin.kpiTenants', { defaultValue: '테넌트' }),
value: n(s.tenantCount),
sub: null,
},
];
}
/** 관리 모듈 바로가기(M18) — 라우트는 App.tsx AdminGuard 배선과 동일. 첫 항목은 i18n 키. */
const ADMIN_MODULES: [string, string][] = [
['admin.modUsers', '/admin/users'],

View File

@ -17,9 +17,11 @@ import {
auctionApi,
formatWon,
type AuctionDetail,
type AuctionRankingSnapshot,
type MaterialKind,
type RankRow,
} from './auctionApi';
import { subscribeAuctionRanking, type WsStatus } from '../../api/websocket';
import { errMessage, useToast } from './aucShared';
import './auction.css';
@ -51,22 +53,44 @@ export function AuctionDetailPage() {
const { auctionId = '' } = useParams();
const navigate = useNavigate();
const qc = useQueryClient();
const { t } = useTranslation();
const { show, node: toast } = useToast();
const [tab, setTab] = useState<MaterialKind>('layout');
const [anon, setAnon] = useState(true);
const [seconds, setSeconds] = useState(0);
const [bidPrice, setBidPrice] = useState('');
const [leadDays, setLeadDays] = useState('');
// WebSocket 연결 상태 — 'connected' 면 실시간 푸시로 갱신하고 폴링을 저속(폴백)으로 늦춘다.
const [wsStatus, setWsStatus] = useState<WsStatus>('connecting');
const wsLive = wsStatus === 'connected';
const q = useQuery({
queryKey: ['auction', auctionId],
queryFn: () => auctionApi.detail(auctionId),
enabled: !!auctionId,
retry: false,
refetchInterval: 5000,
// 실시간 푸시가 살아 있으면 폴링을 30초 안전망으로 늦추고, 끊기면 즉시 5초 폴백.
refetchInterval: wsLive ? 30000 : 5000,
});
const detail = q.data;
// 실시간 순위 구독 — 서버 푸시(응찰·마감·낙찰) 수신 시 인증 상세를 재조회한다.
// ★ 봉인 안전: 푸시 페이로드는 공개 순위만 담고, 개인화 순위(내 순위·금액)는 인증 API 재조회로만 받는다.
useEffect(() => {
if (!auctionId) return;
const unsub = subscribeAuctionRanking<AuctionRankingSnapshot>(
auctionId,
() => {
qc.invalidateQueries({ queryKey: ['auction', auctionId] });
},
setWsStatus,
);
return () => {
unsub();
setWsStatus('connecting');
};
}, [auctionId, qc]);
// F035 물량서(BOQ) — 자료 탭이 boq 일 때만 서버 규칙 기반 산출 조회.
const boqQ = useQuery({
queryKey: ['auction', auctionId, 'boq'],
@ -227,7 +251,19 @@ export function AuctionDetailPage() {
{/* 우 — 실시간 순위·응찰 */}
<aside className="kx-rank" aria-label="실시간 순위">
<div className="kx-rank__head">
<h2> </h2>
<h2>
{/* 소극적 연결 표시 — 실시간 푸시 연결 시 옅은 점, 폴백(폴링) 시 숨김성 회색. */}
<span
className={`kx-rank__ws ${wsLive ? 'is-live' : 'is-poll'}`}
aria-hidden="true"
title={
wsLive
? t('auction.rank.wsLive', { defaultValue: '실시간 갱신 중' })
: t('auction.rank.wsPoll', { defaultValue: '주기적 갱신 중' })
}
/>
</h2>
<button
type="button"
className="kx-toggle"

View File

@ -559,6 +559,26 @@
font-weight: var(--fw-bold);
color: var(--color-neutral-900);
}
/* 소극적 실시간 연결 표시 — 과시 금지: 작은 점, 폴백 시 옅은 회색. */
.kx-rank__ws {
width: 7px;
height: 7px;
border-radius: 50%;
flex: none;
background: var(--color-neutral-300);
}
.kx-rank__ws.is-live {
background: var(--color-success, #16a34a);
box-shadow: 0 0 0 3px rgba(22, 163, 74, 0.14);
animation: kx-rank-ws-pulse 2.4s ease-in-out infinite;
}
@keyframes kx-rank-ws-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.45; }
}
@media (prefers-reduced-motion: reduce) {
.kx-rank__ws.is-live { animation: none; }
}
.kx-toggle {
display: inline-flex;
align-items: center;

View File

@ -81,6 +81,23 @@ export interface AuctionDetail {
finalPrice: number | null;
}
/**
* WebSocket (/topic/auctions/{id}/ranking).
* 안전: 서버가 ( · ·isMe) .
* , API(detail) .
*/
export interface AuctionRankingSnapshot {
auctionId: string;
eventId: string;
event: 'BID' | 'CLOSE' | 'AWARD';
round: number;
sealed: boolean;
bidderCount: number;
lowestPrice: number | null;
ranking: RankRow[];
at: string;
}
export interface BidResult {
id: string;
round: number;

View File

@ -48,6 +48,15 @@ function fmtDate(iso: string | null): string {
return Number.isNaN(d.getTime()) ? '-' : d.toLocaleString('ko-KR', { dateStyle: 'medium', timeStyle: 'short' });
}
// ISO(UTC, ...Z) → datetime-local 입력값(yyyy-MM-ddTHH:mm, 로컬 시각). 예약 게시 시각 편집용.
function toLocalInput(iso: string | null): string {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
export function CmsWorkflowPage() {
const [items, setItems] = useState<CmsContent[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
@ -56,6 +65,8 @@ export function CmsWorkflowPage() {
const [busy, setBusy] = useState(false);
const [signage, setSignage] = useState(true);
const [mailing, setMailing] = useState(false);
const [schedInput, setSchedInput] = useState('');
const [schedSaving, setSchedSaving] = useState(false);
async function reload(keepSelection = true) {
setLoading(true);
@ -83,6 +94,27 @@ export function CmsWorkflowPage() {
[items, selectedId],
);
// 선택 콘텐츠가 바뀌면 예약 시각 입력을 서버 값으로 동기화.
useEffect(() => {
setSchedInput(toLocalInput(selected?.scheduledAt ?? null));
}, [selectedId, selected?.scheduledAt]);
// 예약 게시 시각 저장(비우면 예약 해제). PUT /api/cms/contents/{id} — 제목은 서버 필수라 현재 제목을 함께 전송.
async function saveSchedule() {
if (!selected) return;
setSchedSaving(true);
setError(null);
try {
const iso = schedInput ? new Date(schedInput).toISOString() : null;
const updated = await cmsApi.updateContent(selected.id, { title: selected.title, scheduledAt: iso });
setItems((prev) => prev.map((c) => (c.id === updated.id ? updated : c)));
} catch (e) {
setError(e instanceof ApiRequestError ? e.message : '예약 시각 저장에 실패했습니다.');
} finally {
setSchedSaving(false);
}
}
async function advance(next: FlowStatus) {
if (!selected) return;
setBusy(true);
@ -231,12 +263,30 @@ export function CmsWorkflowPage() {
</div>
<div>
<div className="kx-cms-sublabel" style={{ marginBottom: 8 }}>
<div className="kx-cms-sublabel" style={{ marginBottom: 8, display: 'flex', alignItems: 'center', gap: 8 }}>
{selected?.scheduledAt && selected.status !== 'published' && (
<span className="kx-cms-pill kx-cms-pill--scheduled"></span>
)}
</div>
<div className="kx-cms-sched">
<IconCalendar size={18} />
<span>{selected?.scheduledAt ? fmtDate(selected.scheduledAt) : '예약 없음'}</span>
<input
type="datetime-local"
className="kx-cms-sched__input"
value={schedInput}
onChange={(e) => setSchedInput(e.target.value)}
aria-label="예약 게시 시각"
disabled={!selected || schedSaving}
/>
<Button variant="secondary" onClick={saveSchedule} disabled={!selected || schedSaving}>
{schedSaving ? '저장 중…' : '예약 저장'}
</Button>
</div>
<div className="kx-cms-sched__hint">
{selected?.scheduledAt
? `예약: ${fmtDate(selected.scheduledAt)} — 승인(approved) 상태이면 시각 도달 시 자동 게시됩니다.`
: '시각을 지정하면, 승인 후 예약 시각에 자동 게시됩니다. 비우고 저장하면 예약이 해제됩니다.'}
</div>
</div>
</section>

View File

@ -9,7 +9,7 @@ import { FloorplanCanvas, type CanvasLayer } from './FloorplanCanvas';
import { AutoLayoutDialog } from './AutoLayoutDialog';
import { ValidationPanel } from './ValidationPanel';
import { sampleLayout, emptyLayout } from './sampleLayout';
import { hallFloorplanUrl } from './hallFloorplan';
import { hallFloorplanUrl, hallFloorRegion } from './hallFloorplan';
import type { AutoLayoutOption, ComplianceReport, LayoutDto } from '../../api/types';
import './editor.css';
@ -173,7 +173,11 @@ export function BoothLayoutEditorPage() {
) : (
layout && (
<FloorplanCanvas
hallDims={[126, 90]}
hallDims={
layout.hall?.dimsM && layout.hall.dimsM.length >= 2
? [layout.hall.dimsM[0], layout.hall.dimsM[1]]
: [126, 90]
}
booths={layout.booths}
layers={layers}
selectedBoothId={selectedBoothId}
@ -181,6 +185,7 @@ export function BoothLayoutEditorPage() {
zoom={zoom}
onZoomChange={setZoom}
floorplanUrl={hallFloorplanUrl(hallId)}
floorRegion={hallFloorRegion(hallId)}
/>
)
)}

View File

@ -29,6 +29,12 @@ interface FloorplanCanvasProps {
zoom: number;
/** 실측 홀 도면(크롤 JPG) 언더레이 URL — 없거나 로드 실패 시 기존 다크 서피스만. */
floorplanUrl?: string | null;
/**
* [x0,y0,x1,y1] (hallFloorplan.ts ).
* (0,0~hw,hh)
* ( m) "안" . ( ).
*/
floorRegion?: [number, number, number, number] | null;
/** 마우스휠 확대/축소 시 페이지 zoom 상태 동기화(소유자 지시 2026-07-14). 미지정 시 휠 줌 비활성. */
onZoomChange?: (zoom: number) => void;
}
@ -55,9 +61,26 @@ export function FloorplanCanvas({
onSelectBooth,
zoom,
floorplanUrl,
floorRegion,
onZoomChange,
}: FloorplanCanvasProps) {
const [hw, hh] = hallDims;
// 도면 배치: 캘리브레이션 영역이 있으면 바닥면(fx0..fx1, fy0..fy1)이 홀 사각형에 오도록 역산.
const drawing = useMemo(() => {
if (!floorRegion) {
return { x: 0, y: 0, w: hw, h: hh, clipped: false };
}
const [fx0, fy0, fx1, fy1] = floorRegion;
const rw = fx1 - fx0;
const rh = fy1 - fy0;
if (rw <= 0 || rh <= 0) {
return { x: 0, y: 0, w: hw, h: hh, clipped: false };
}
const w = hw / rw;
const h = hh / rh;
return { x: -fx0 * w, y: -fy0 * h, w, h, clipped: true };
}, [floorRegion, hw, hh]);
const [drawingOk, setDrawingOk] = useState(true);
useEffect(() => setDrawingOk(true), [floorplanUrl]);
@ -164,10 +187,10 @@ export function FloorplanCanvas({
{floorplanUrl && drawingOk && (
<image
href={floorplanUrl}
x={0}
y={0}
width={hw}
height={hh}
x={drawing.x}
y={drawing.y}
width={drawing.w}
height={drawing.h}
preserveAspectRatio="none"
className="kx-canvas__drawing"
onError={() => setDrawingOk(false)}

View File

@ -25,8 +25,8 @@
/* 실측 홀 도면(JPG) 언더레이 — 다크 서피스 위 블루프린트 톤, 상호작용 통과 */
.kx-canvas__drawing {
opacity: 0.3;
filter: saturate(0.35);
opacity: 0.55;
filter: saturate(0.55);
pointer-events: none;
}

View File

@ -1,13 +1,41 @@
/*
* ID ( JPG) URL .
* ID ( JPG) URL + .
* docs/assets/floorplans public/media/floorplans (V46 ).
* null (FloorplanCanvas onError ).
*
* FLOOR_REGIONS: 도면 (1 ·2 )
* [x0, y0, x1, y1]. JPG는 ··
* (0,0~W,H m) "평면도 안" ( 2026-07-14).
* ( ) .
*/
const AVAILABLE_HALLS = new Set([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
export type FloorRegion = [number, number, number, number];
const FLOOR_REGIONS: Record<string, FloorRegion> = {
H1: [0.1583, 0.4015, 0.9167, 0.7956],
H2: [0.0667, 0.3723, 0.8944, 0.8029],
H3: [0.0667, 0.3759, 0.8972, 0.8102],
H4: [0.0667, 0.3759, 0.8972, 0.8066],
H5: [0.0528, 0.4416, 0.9028, 0.8686],
H6: [0.3222, 0.2162, 0.6917, 0.8176],
H7: [0.2583, 0.2027, 0.7306, 0.8547],
H8: [0.275, 0.5034, 0.7194, 0.8446],
H9: [0.2833, 0.2061, 0.7111, 0.8581],
H10: [0.2778, 0.5068, 0.7194, 0.8581],
};
function hallKey(hallId: string | null | undefined): string | null {
const m = /^H(\d+)$/i.exec((hallId ?? '').trim());
return m ? `H${Number(m[1])}` : null;
}
export function hallFloorplanUrl(hallId: string | null | undefined): string | null {
const m = /^H(\d+)$/i.exec((hallId ?? '').trim());
if (!m) return null;
const n = Number(m[1]);
return AVAILABLE_HALLS.has(n) ? `/media/floorplans/hall${n}.jpg` : null;
const key = hallKey(hallId);
if (!key || !(key in FLOOR_REGIONS)) return null;
return `/media/floorplans/hall${key.slice(1)}.jpg`;
}
/** 도면 이미지 내 전시 바닥면 분율 영역 — 캔버스가 이 영역을 홀 사각형에 정렬한다. */
export function hallFloorRegion(hallId: string | null | undefined): FloorRegion | null {
const key = hallKey(hallId);
return key ? FLOOR_REGIONS[key] ?? null : null;
}